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); + } + } +}