Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ set(TEST_FILES
src/rwe/game/viewcull.test.cpp
src/rwe/sim/buildangle.test.cpp
src/rwe/sim/bombsight.test.cpp
src/rwe/sim/brakerate.test.cpp
src/rwe/sim/cob.test.cpp
src/rwe/sim/SimAngle.test.cpp
src/rwe/sim/SimVector.test.cpp
Expand Down
20 changes: 12 additions & 8 deletions docs/TOTALA-EXE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,17 @@ the job rather than on the flight path.

### Deliberately not ported

- **The `BrakeRate` nose re-aim** (`0x43D38E`–`0x43D47D`) is real behaviour and is
why the original's aircraft barely crab — speed above `BrakeRate` is stripped
and re-injected along the nose, so they fly roughly where they point. It moved
the construction-aircraft bank peak by 0.00° in testing, because a
construction aircraft never reaches its `BrakeRate` of 1.5, so it was left out
rather than risk changing fast aircraft. `brakeRate` is still unused for air
units in RWE.
- ~~**The `BrakeRate` nose re-aim** (`0x43D38E`–`0x43D47D`)~~: ported 2026-09-11
(`applyBrakeRateNoseReaim`, in the flying state's velocity update, after drag
and before the profile as in the original). It is why the original's aircraft
barely crab: speed above `BrakeRate` is stripped and re-injected along the
nose, so they fly roughly where they point. It had been left out because it
moved the construction-aircraft bank peak by 0.00° (a construction aircraft
never reaches its `BrakeRate` of 1.5) and nothing faster had been measured.
Measured now with the shipped fighters' numbers: the worst crab angle through
a ninety-degree turn drops to a few degrees (`brakerate.test.cpp`). Still not
applied in the attack-run, gunship and dogfight states, which steer their own
velocities; those want their own pass.
- **Pitch.** The original also pitches aircraft from the longitudinal component
of the same accumulator, via `PitchScale` (`def+0x1A6`) into `unit+0x68`. RWE
has no pitch for units at all and the renderer applies only yaw and roll.
Expand Down Expand Up @@ -8723,7 +8727,7 @@ original:
- **Off-map fog cells read as the nearest on-map cell.** Reading them as "clear"
leaves the frame's ragged edge with no neighbouring tile to cover it, and a
strip of map shows through at the border.
- **No `BrakeRate` nose re-aim and no pitch** — see §1.
- **No pitch**, see §1 (the `BrakeRate` nose re-aim is ported now).
- **A mobile unit's `buildangle` is ignored.** The original overwrites a mobile
unit's spawn heading with the raw value (§8), which for everything but the ten
capital ships is zero and so agrees with RWE's half-turn default anyway. RWE
Expand Down
34 changes: 34 additions & 0 deletions src/rwe/sim/UnitBehaviorService_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,31 @@ namespace rwe
*/
static constexpr SimScalar AirArrivalTaperDistance = 8_ss;

SimVector applyBrakeRateNoseReaim(const SimVector& velocity, const SimVector& nose, SimScalar brakeRate)
{
// 0x43D391-0x43D3D9: hypot of the x and z components against
// brakerate, and nothing happens at or below it.
SimVector horizontal(velocity.x, 0_ss, velocity.z);
auto speed = horizontal.length();
if (speed <= brakeRate)
{
return velocity;
}

// 0x43D3DF-0x43D42E: each of x and z is multiplied by brakerate/speed,
// so the horizontal velocity keeps its direction at BrakeRate long.
// 0x43D42E-0x43D47D: the excess, speed - brakerate, goes along the
// unit's own heading (the sixteen-bit angle at unit+0x66) and is
// added to x and z. y is never read.
auto scale = brakeRate / speed;
auto excess = speed - brakeRate;
SimVector noseFlat(nose.x, 0_ss, nose.z);
return SimVector(
velocity.x * scale + noseFlat.x * excess,
velocity.y,
velocity.z * scale + noseFlat.z * excess);
}

SimVector computeNewAirUnitVelocity(const UnitState& unit, const UnitDefinition& unitDefinition, const AirMovementStateFlying& physics)
{
if (!physics.targetPosition)
Expand All @@ -779,6 +804,15 @@ namespace rwe
: 1_ss;
auto currentVelocity = physics.currentVelocity * drag;

// Then the brake step, second of the original's six, before the
// steering gets its say. A fighter at MaxVelocity 10 with BrakeRate 6
// has four units of speed a tick pulled round to its nose every
// tick, which is what keeps it flying where it points through a
// turn. A construction aircraft at BrakeRate 1.5 is hardly touched,
// which is why leaving this out moved nothing when only those were
// measured.
currentVelocity = applyBrakeRateNoseReaim(currentVelocity, UnitState::toDirection(unit.rotation), unitDefinition.brakeRate);

// The original's arrival profile: steer for sqrt(2 * Acceleration *
// distance) towards the target, so the aircraft is always travelling
// exactly as fast as it can still shed before it gets there. Holding
Expand Down
11 changes: 11 additions & 0 deletions src/rwe/sim/UnitBehaviorService_util.h
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,17 @@ namespace rwe

SimVector decelerate(SimVector currentVelocity, SimScalar deceleration);

/**
* The original's brake step (0x43D38E-0x43D47D), the second of the six
* in its per-tick air movement: if the horizontal speed is above
* BrakeRate, the horizontal velocity is scaled down to BrakeRate and the
* speed stripped off is added back along the nose. Vertical velocity is
* left alone. It is why the original's aircraft barely crab: anything
* above BrakeRate flies where the aircraft points, whatever the steering
* asked for. `nose` is the unit's heading as a horizontal unit vector.
*/
SimVector applyBrakeRateNoseReaim(const SimVector& velocity, const SimVector& nose, SimScalar brakeRate);

SimVector computeNewAirUnitVelocity(const UnitState& unit, const UnitDefinition& unitDefinition, const AirMovementStateFlying& physics);

/**
Expand Down
180 changes: 180 additions & 0 deletions src/rwe/sim/brakerate.test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <rwe/sim/UnitBehaviorService_util.h>
#include <rwe/sim/UnitDefinition.h>
#include <rwe/sim/UnitState.h>
#include <rwe/sim/sim_test_util.h>
#include <vector>

/**
* The original's BrakeRate nose re-aim, 0x43D38E-0x43D47D in TotalA.exe:
* horizontal speed above BrakeRate is stripped and put back along the nose.
* The numbers are the shipped rev31 ones -- ARMFIG and CORVAMP for the
* fighters, ARMCA for the construction aircraft -- because the whole point
* is what the rule does to a fast aircraft against a slow one, and a
* fixture that invented its own would not show that.
*/
namespace rwe
{
namespace
{
using Catch::Approx;

UnitDefinition fighterFromFbi(SimScalar maxVelocity, SimScalar brakeRate, SimScalar acceleration, SimScalar turnRate)
{
UnitDefinition d{};
d.isMobile = true;
d.canMove = true;
d.canFly = true;
d.maxVelocity = maxVelocity;
d.brakeRate = brakeRate;
d.acceleration = acceleration;
d.turnRate = turnRate;
return d;
}

/** ARMFIG, the Freedom Fighter: MaxVelocity=10 BrakeRate=6 Acceleration=0.35 TurnRate=512. */
UnitDefinition armfig() { return fighterFromFbi(10_ss, 6_ss, SimScalar(0.35f), 512_ss); }
/** CORVAMP, the Vamp: MaxVelocity=12 BrakeRate=7 Acceleration=0.35 TurnRate=620. */
UnitDefinition corvamp() { return fighterFromFbi(12_ss, 7_ss, SimScalar(0.35f), 620_ss); }
/** ARMCA, the construction aircraft: MaxVelocity=6.9 BrakeRate=1.5 Acceleration=0.06 TurnRate=90. */
UnitDefinition armca() { return fighterFromFbi(SimScalar(6.9f), SimScalar(1.5f), SimScalar(0.06f), 90_ss); }

float horizontalSpeed(const SimVector& v)
{
return simScalarToFloat(SimVector(v.x, 0_ss, v.z).length());
}

/** Angle in degrees between the flight path and the nose, both flat. */
float crabDegrees(const SimVector& velocity, const SimVector& nose)
{
SimVector v(velocity.x, 0_ss, velocity.z);
SimVector n(nose.x, 0_ss, nose.z);
auto cosine = simScalarToFloat(v.dot(n)) / (simScalarToFloat(v.length()) * simScalarToFloat(n.length()));
cosine = std::max(-1.0f, std::min(1.0f, cosine));
return std::acos(cosine) * 180.0f / 3.14159265f;
}

/**
* Flies a fighter through a ninety-degree turn: it starts at speed
* along +z with its nose on +z, and is then given a target due +x,
* a long way off. The nose comes round at TurnRate a tick and the
* velocity follows the profile, exactly as the flying state does it.
* Returns the worst crab angle seen over the turn.
*/
float worstCrabThroughTurn(const UnitDefinition& def)
{
auto script = makeEmptyCobScript();
std::vector<UnitMesh> pieces;
UnitState unit(pieces, std::make_unique<CobEnvironment>(script.get()));
// A bare UnitState leaves its position uninitialised; the spawn
// helpers always set it, and so must this.
unit.position = SimVector(0_ss, 0_ss, 0_ss);
unit.previousPosition = unit.position;
unit.rotation = UnitState::toRotation(SimVector(0_ss, 0_ss, 1_ss));

AirMovementStateFlying flying;
flying.currentVelocity = SimVector(0_ss, 0_ss, def.maxVelocity);
flying.targetPosition = SimVector(100000_ss, 0_ss, 0_ss);

auto turnRateThisFrame = SimAngle(static_cast<uint16_t>(simScalarToFloat(def.turnRate)));

float worst = 0.0f;
for (int t = 0; t < 120; ++t)
{
auto direction = *flying.targetPosition - unit.position;
unit.rotation = turnTowards(unit.rotation, UnitState::toRotation(direction), turnRateThisFrame);
flying.currentVelocity = computeNewAirUnitVelocity(unit, def, flying);
unit.position = unit.position + flying.currentVelocity;
worst = std::max(worst, crabDegrees(flying.currentVelocity, UnitState::toDirection(unit.rotation)));
}
return worst;
}
}

TEST_CASE("brake step: speed at or below BrakeRate is left exactly as it was", "[brakerate]")
{
auto nose = SimVector(0_ss, 0_ss, 1_ss);
// ARMCA cruising at its own top speed sideways to its nose: 6.9 is
// above 1.5, so this is the one construction aircraft case that
// does change. Below it nothing does.
auto slow = SimVector(1_ss, SimScalar(-0.5f), 1_ss);
auto out = applyBrakeRateNoseReaim(slow, nose, SimScalar(1.5f));
REQUIRE(simScalarToFloat(out.x) == Approx(1.0f));
REQUIRE(simScalarToFloat(out.y) == Approx(-0.5f));
REQUIRE(simScalarToFloat(out.z) == Approx(1.0f));

auto exact = SimVector(6_ss, 0_ss, 0_ss);
auto same = applyBrakeRateNoseReaim(exact, nose, 6_ss);
REQUIRE(simScalarToFloat(same.x) == Approx(6.0f));
REQUIRE(simScalarToFloat(same.z) == Approx(0.0f));
}

TEST_CASE("brake step: the excess above BrakeRate goes along the nose", "[brakerate]")
{
// A Freedom Fighter at its full 10 flying dead sideways to a nose on
// +z: 6 stays on the flight path, the other 4 goes where it points.
auto nose = SimVector(0_ss, 0_ss, 1_ss);
auto out = applyBrakeRateNoseReaim(SimVector(10_ss, 0_ss, 0_ss), nose, 6_ss);
REQUIRE(simScalarToFloat(out.x) == Approx(6.0f));
REQUIRE(simScalarToFloat(out.z) == Approx(4.0f));

SECTION("the vertical component is never touched")
{
auto climbing = applyBrakeRateNoseReaim(SimVector(10_ss, 3_ss, 0_ss), nose, 6_ss);
REQUIRE(simScalarToFloat(climbing.y) == Approx(3.0f));
REQUIRE(simScalarToFloat(climbing.x) == Approx(6.0f));
REQUIRE(simScalarToFloat(climbing.z) == Approx(4.0f));
}

SECTION("flying where it points, nothing changes but the accounting")
{
auto straight = applyBrakeRateNoseReaim(SimVector(0_ss, 0_ss, 10_ss), nose, 6_ss);
REQUIRE(simScalarToFloat(straight.x) == Approx(0.0f));
REQUIRE(simScalarToFloat(straight.z) == Approx(10.0f));
}

SECTION("the re-aimed velocity is never faster than it came in")
{
REQUIRE(horizontalSpeed(out) < 10.0f);
REQUIRE(horizontalSpeed(out) == Approx(std::sqrt(36.0f + 16.0f)));
}
}

TEST_CASE("brake step: a fast fighter crabs less through a turn", "[brakerate]")
{
// The same ninety-degree turn, with the rule and with BrakeRate set
// above MaxVelocity so it can never fire. The fighters' shipped
// numbers keep their flight path within a few degrees of the nose;
// without the rule the velocity lags the nose by a wide margin.
auto withRule = armfig();
auto without = armfig();
without.brakeRate = 1000_ss;

auto crabWith = worstCrabThroughTurn(withRule);
auto crabWithout = worstCrabThroughTurn(without);

REQUIRE(crabWithout > 20.0f);
REQUIRE(crabWith < crabWithout / 2.0f);
REQUIRE(crabWith < 15.0f);

SECTION("and the Vamp, faster and tighter, the same")
{
auto vampWith = worstCrabThroughTurn(corvamp());
auto vampWithout = corvamp();
vampWithout.brakeRate = 1000_ss;
REQUIRE(vampWith < worstCrabThroughTurn(vampWithout) / 2.0f);
}

SECTION("a construction aircraft is left much as it was")
{
// Its BrakeRate of 1.5 is a fifth of its top speed, but its
// turn is so slow that the profile keeps velocity and nose
// close anyway. The rule must not move it far.
auto caWith = worstCrabThroughTurn(armca());
auto caWithout = armca();
caWithout.brakeRate = 1000_ss;
REQUIRE(caWith <= worstCrabThroughTurn(caWithout) + 1.0f);
}
}
}