From 101a08868554b5e7fd277e5e2285d5b4b63466bc Mon Sep 17 00:00:00 2001 From: Hector Bailey Date: Fri, 11 Sep 2026 20:19:02 +0100 Subject: [PATCH] An aircraft's speed above BrakeRate goes where its nose points The original's per-tick air movement has a brake step between drag and the turn (0x43D38E-0x43D47D): if the horizontal speed is above the unit's BrakeRate, the horizontal velocity is scaled down to BrakeRate and the speed stripped off is added back along the heading. Vertical velocity is left alone. It is why its aircraft barely crab. RWE had it decoded and deliberately left out, because with only construction aircraft measured, which never reach their BrakeRate of 1.5, it moved nothing. Measured with the shipped fighters it moves a great deal. A Freedom Fighter (MaxVelocity 10, BrakeRate 6) or a Vamp (12, 7) put through a ninety-degree turn crabbed by fifty to seventy degrees at the worst point without it; with it the flight path stays within a few degrees of the nose throughout. The construction aircraft is unchanged to within a degree, as the earlier measurement said. It runs in the flying state's velocity update, which take-off shares. The attack run, the gunship ring and the dogfight steer their own velocities and are left for their own pass. Issue: #23 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PJCbwC9MGZnM6erMDKptun --- CMakeLists.txt | 1 + docs/TOTALA-EXE.md | 20 ++- src/rwe/sim/UnitBehaviorService_util.cpp | 34 +++++ src/rwe/sim/UnitBehaviorService_util.h | 11 ++ src/rwe/sim/brakerate.test.cpp | 180 +++++++++++++++++++++++ 5 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 src/rwe/sim/brakerate.test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fc3ef8cd..a3058e11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/TOTALA-EXE.md b/docs/TOTALA-EXE.md index 986340fe..a6411dbc 100644 --- a/docs/TOTALA-EXE.md +++ b/docs/TOTALA-EXE.md @@ -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. @@ -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 diff --git a/src/rwe/sim/UnitBehaviorService_util.cpp b/src/rwe/sim/UnitBehaviorService_util.cpp index fe70f937..6dca258c 100644 --- a/src/rwe/sim/UnitBehaviorService_util.cpp +++ b/src/rwe/sim/UnitBehaviorService_util.cpp @@ -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) @@ -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 diff --git a/src/rwe/sim/UnitBehaviorService_util.h b/src/rwe/sim/UnitBehaviorService_util.h index 04a35505..de1b527c 100644 --- a/src/rwe/sim/UnitBehaviorService_util.h +++ b/src/rwe/sim/UnitBehaviorService_util.h @@ -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); /** diff --git a/src/rwe/sim/brakerate.test.cpp b/src/rwe/sim/brakerate.test.cpp new file mode 100644 index 00000000..90b89a68 --- /dev/null +++ b/src/rwe/sim/brakerate.test.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include +#include +#include +#include + +/** + * 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 pieces; + UnitState unit(pieces, std::make_unique(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(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); + } + } +}