From 3ecf98c5e655edf969d762fcfb2a94014960c41e Mon Sep 17 00:00:00 2001 From: Hector Bailey Date: Fri, 11 Sep 2026 21:21:17 +0100 Subject: [PATCH] The brake step runs in every air state, as it does for every air mission The original has one per-tick air mover. Mover::Update (0x43DD20) hands any unit whose definition says canfly to 0x43D290, whatever mission it is on, and the brake step sits inside that. So the bomber's run, the gunship's ring and the fighter's dogfight all strip speed above BrakeRate and put it back along the nose, the same as a unit on its way somewhere. In RWE the gunship ring and the dogfight already flew through the flying state's velocity function and so had the step from the first port. The attack run steers its own heading and did not; it now runs the step on the velocity it starts from, before the swing, in the original's order. A Thunder's BrakeRate of 0.4 against its MaxVelocity of 9 means a bomber flies where it points. Two attack-run fixtures had left the nose on its default heading while the aircraft flew flat out along another; they point the nose along the flight path now, as updateUnitRotation keeps it in the game, and their decoded geometry is unchanged. Issue: #41 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PJCbwC9MGZnM6erMDKptun --- docs/TOTALA-EXE.md | 9 +- src/rwe/sim/UnitBehaviorService_air.test.cpp | 16 +++- src/rwe/sim/UnitBehaviorService_util.cpp | 13 ++- src/rwe/sim/brakerate.test.cpp | 87 ++++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/docs/TOTALA-EXE.md b/docs/TOTALA-EXE.md index a6411dbc0..ff9d30d7b 100644 --- a/docs/TOTALA-EXE.md +++ b/docs/TOTALA-EXE.md @@ -139,9 +139,12 @@ the job rather than on the flight path. 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. + a ninety-degree turn drops to a few degrees (`brakerate.test.cpp`). Applied + in every air state since B4 #41: `Mover::Update` (`0x43DD20`, §87) hands any + `canfly` unit to `0x43D290` whatever its mission, so the gunship ring and the + dogfight, which already flew through the flying state's function, had it + from the first port, and the attack run, which steers its own heading, now + runs the brake step before that swing. - **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. diff --git a/src/rwe/sim/UnitBehaviorService_air.test.cpp b/src/rwe/sim/UnitBehaviorService_air.test.cpp index ad71713c6..e6751015a 100644 --- a/src/rwe/sim/UnitBehaviorService_air.test.cpp +++ b/src/rwe/sim/UnitBehaviorService_air.test.cpp @@ -296,6 +296,8 @@ namespace rwe auto state = makeAttackRunState(SimVector(0_ss, 50_ss, 0_ss)); state.phase = AirMovementStateAttackRun::Phase::Approaching; state.currentVelocity = SimVector(4_ss, 0_ss, 0_ss); + // Pointing the way it flies, as it would be in the game. + unit.rotation = UnitState::toRotation(state.currentVelocity); // Fly the reversal out and check it never slows to a hover. auto slowest = state.currentVelocity.length(); @@ -327,11 +329,20 @@ namespace rwe auto state = makeAttackRunState(SimVector(0_ss, 50_ss, 0_ss)); state.phase = AirMovementStateAttackRun::Phase::Approaching; state.currentVelocity = SimVector(4_ss, 0_ss, 0_ss); + // Pointing the way it flies: with the nose left on its default + // heading the brake step would veer the aircraft off along it on + // the first tick and the reversal would be over in four. + unit.rotation = UnitState::toRotation(state.currentVelocity); for (int tick = 0; tick < 200 && state.currentVelocity.x >= 0_ss; ++tick) { state.currentVelocity = computeNewAttackRunVelocity(unit, def, state); unit.position = unit.position + state.currentVelocity; + // The nose follows the flight path, as updateUnitRotation + // turns it; the brake step redirects speed above BrakeRate + // along it, so a nose left on its default heading would + // hold the aircraft on that heading for the whole loop. + unit.rotation = UnitState::toRotation(state.currentVelocity); } // Half a circle of radius r displaces the aircraft about 2r sideways. @@ -420,8 +431,11 @@ namespace rwe auto state = makeAttackRunState(SimVector(100_ss, 50_ss, 0_ss)); state.phase = AirMovementStateAttackRun::Phase::Engaging; state.runOutDirection = SimVector(1_ss, 0_ss, 0_ss); - // Already at max velocity + // Already at max velocity, and pointing the way it flies: the + // nose follows the run's flight path in updateUnitRotation, and + // the brake step redirects speed above BrakeRate along it. state.currentVelocity = SimVector(4_ss, 0_ss, 0_ss); + unit.rotation = UnitState::toRotation(SimVector(1_ss, 0_ss, 0_ss)); auto v = computeNewAttackRunVelocity(unit, def, state); // velocity magnitude should remain at maxVelocity (4) REQUIRE(v.length() > 3.99_ssf); diff --git a/src/rwe/sim/UnitBehaviorService_util.cpp b/src/rwe/sim/UnitBehaviorService_util.cpp index 6dca258c0..8c813a261 100644 --- a/src/rwe/sim/UnitBehaviorService_util.cpp +++ b/src/rwe/sim/UnitBehaviorService_util.cpp @@ -920,9 +920,18 @@ namespace rwe { auto targetPoint = computeAttackRunTargetPoint(unit, unitDefinition, physics); + // The brake step first, as in the original, where every air mission + // goes through the one per-tick mover (Mover::Update at 0x43DD20 + // picks 0x43D290 for any `canfly` unit, §87) and so through the + // step at 0x43D38E before the heading is touched. A Thunder's + // BrakeRate of 0.4 against its MaxVelocity of 9 means nearly all of + // its speed is pulled round to its nose every tick: a bomber flies + // where it points. + auto currentVelocity = applyBrakeRateNoseReaim(physics.currentVelocity, UnitState::toDirection(unit.rotation), unitDefinition.brakeRate); + // Speed: always winding up towards the aircraft's best. A run never // brakes — flying slower does not help it hit anything. - auto speed = physics.currentVelocity.length(); + auto speed = currentVelocity.length(); speed = rweMin(unitDefinition.maxVelocity, speed + unitDefinition.acceleration); // Heading: an aircraft cannot slide sideways, it banks. Swing the @@ -930,7 +939,7 @@ namespace rwe // of turn, so coming back for another pass is a arc of radius // speed / turnRate flown at full speed, rather than a stop and a // pivot on the spot. - SimVector flatVelocity(physics.currentVelocity.x, 0_ss, physics.currentVelocity.z); + SimVector flatVelocity(currentVelocity.x, 0_ss, currentVelocity.z); auto currentHeading = flatVelocity.lengthSquared() > 0_ss ? UnitState::toRotation(flatVelocity) : unit.rotation; diff --git a/src/rwe/sim/brakerate.test.cpp b/src/rwe/sim/brakerate.test.cpp index 90b89a682..e75ce9545 100644 --- a/src/rwe/sim/brakerate.test.cpp +++ b/src/rwe/sim/brakerate.test.cpp @@ -39,6 +39,21 @@ namespace rwe 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); } + /** ARMTHUND, the Thunder bomber: MaxVelocity=9 BrakeRate=0.4 Acceleration=0.08 TurnRate=356. */ + UnitDefinition armthund() { return fighterFromFbi(9_ss, SimScalar(0.4f), SimScalar(0.08f), 356_ss); } + /** ARMBRAWL, the Brawler gunship: MaxVelocity=6.6 BrakeRate=4 Acceleration=0.16 TurnRate=800, HoverAttack=1. */ + UnitDefinition armbrawl() { return fighterFromFbi(SimScalar(6.6f), 4_ss, SimScalar(0.16f), 800_ss); } + + /** A unit at the origin with its nose on +z, flying flat out along +x: the worst crab there is. */ + UnitState crabbingUnit() + { + std::vector pieces; + UnitState unit(pieces, std::unique_ptr{}); + unit.position = SimVector(0_ss, 100_ss, 0_ss); + unit.previousPosition = unit.position; + unit.rotation = UnitState::toRotation(SimVector(0_ss, 0_ss, 1_ss)); + return unit; + } float horizontalSpeed(const SimVector& v) { @@ -177,4 +192,76 @@ namespace rwe REQUIRE(caWith <= worstCrabThroughTurn(caWithout) + 1.0f); } } + + TEST_CASE("brake step: every air state goes through it, as every air mission does in the original", "[brakerate]") + { + // Mover::Update (0x43DD20) picks the air follower 0x43D290 for any + // canfly unit whatever its mission (TOTALA-EXE.md S:87), and the + // brake step is inside it. Each state here starts a unit flying flat + // out along +x with its nose on +z and a goal far off along +x, and + // reads what one tick does to the sideways speed. With the rule the + // crosswise component drops to BrakeRate (give or take a tick's + // acceleration); with BrakeRate out of reach it stays where it was. + SECTION("the bomber's attack run, ARMTHUND") + { + auto def = armthund(); + auto unit = crabbingUnit(); + AirMovementStateAttackRun run; + run.phase = AirMovementStateAttackRun::Phase::Approaching; + run.lastKnownTargetPos = SimVector(100000_ss, 100_ss, 0_ss); + run.runOutDirection = SimVector(1_ss, 0_ss, 0_ss); + run.currentVelocity = SimVector(9_ss, 0_ss, 0_ss); + + auto with = computeNewAttackRunVelocity(unit, def, run); + // 0.4 stays across, 8.6 goes along the nose, then the run swings + // the heading a tick's turn back towards the target. + REQUIRE(simScalarToFloat(with.z) > simScalarToFloat(with.x)); + REQUIRE(simScalarToFloat(with.z) > 7.0f); + + auto without = def; + without.brakeRate = 1000_ss; + auto flat = computeNewAttackRunVelocity(unit, without, run); + REQUIRE(simScalarToFloat(flat.x) > 8.0f); + } + + SECTION("the gunship ring, ARMBRAWL") + { + auto def = armbrawl(); + auto unit = crabbingUnit(); + AirMovementStateHoverAttack ring; + ring.station = SimVector(100000_ss, 100_ss, 0_ss); + ring.targetPosition = ring.station; + ring.currentVelocity = SimVector(SimScalar(6.6f), 0_ss, 0_ss); + + auto with = computeNewHoverAttackVelocity(unit, def, ring); + REQUIRE(simScalarToFloat(with.x) == Approx(4.0f).margin(0.2f)); + REQUIRE(simScalarToFloat(with.z) > 2.0f); + + auto without = def; + without.brakeRate = 1000_ss; + auto flat = computeNewHoverAttackVelocity(unit, without, ring); + REQUIRE(simScalarToFloat(flat.x) > 6.0f); + REQUIRE(simScalarToFloat(flat.z) < 0.2f); + } + + SECTION("the dogfight, ARMFIG") + { + auto def = armfig(); + auto unit = crabbingUnit(); + AirMovementStateDogfight fight; + fight.phase = AirMovementStateDogfight::Phase::Pursuing; + fight.goalPosition = SimVector(100000_ss, 100_ss, 0_ss); + fight.currentVelocity = SimVector(10_ss, 0_ss, 0_ss); + + auto with = computeNewDogfightVelocity(unit, def, fight); + REQUIRE(simScalarToFloat(with.x) == Approx(6.0f).margin(0.4f)); + REQUIRE(simScalarToFloat(with.z) > 3.0f); + + auto without = def; + without.brakeRate = 1000_ss; + auto flat = computeNewDogfightVelocity(unit, without, fight); + REQUIRE(simScalarToFloat(flat.x) > 9.0f); + REQUIRE(simScalarToFloat(flat.z) < 0.4f); + } + } }