# B4 — the colony turn and the fleet movement pass, old vs new **Status (2026-09-08): code complete, cross-built, staged; every VM step still owed.** VM140 was held by another lane for the whole of this milestone, so nothing was deployed, the game was not stopped or relaunched, and `C:\SOTS\binkw32.dll` / `C:\SOTS\shimdist` were not touched. Everything below is offline work plus what the *binary* says; the run list is at the end. The build lives in its own tree (`/srv/re-lab/build/sots-engine-b4`) and its own dist (`/srv/re-lab/shim/dist-b4`), not the shared ones. Build `b4-final-20260908T0500Z`, exports 66 names identical to `binkw32.dll`. Host suite **30/30**. `tools/clean_room_check.sh` OK. Ghidra was available this round and was used at the end to write the verified prototypes back into the shared project (`reva-server` stopped for the run and restarted afterwards). ## What was hooked | hook name (record `hook`) | RVA | prototype (all now `[verified]`) | |---|---|---| | `Game::ServerSystem::ProcessTurn` | 0x003598e0 | `void (ServerSystem*)` — **no stack arguments** | | `Game::StrategyServer::MoveFleet` | 0x003d9ee0 | `bool (StrategyServer*, StarFleet*, float dt)` | | `Game::StrategyServer::ProcessFleetMovement` | 0x003da9a0 | `void (StrategyServer*)` | All three are `__thiscall` and go through `Hook<>` with `CallConv::Thiscall`. Sources: `src/shim/hooks/colony_turn.{h,cpp}` and `src/shim/hooks/fleet_movement.{h,cpp}`, installed from `src/shim/main.cpp` after the B1/B2/B3 hooks. The pure halves are `src/shim/hooks/colony_inputs.{h,cpp}` (lib `shim_colony`, ctest `shim_colony_unit`) and `src/shim/hooks/movement_inputs.{h,cpp}` (lib `shim_movement`, ctest `shim_movement_unit`) — the B1 split, so the mapping is exhaustively testable without the VM. ### How the prototypes were verified Every one was read off the instruction stream (`objdump -d` over the shipped exe) before it was hooked, because M0's lesson is that a wrong `thiscall` prototype crashes the game: * `ServerSystem::ProcessTurn` ends in a **plain `ret`** and nothing in the body reads `[ebp+8]`. The existing decompile shows a second parameter `void* stream`; that is a Ghidra guess and it is wrong. Hooking it as a one-argument function would have corrupted the stack. * `MoveFleet` ends in `ret 8`; `[ebp+8]` goes into ESI as the fleet and `[ebp+0xc]` is read with `fld DWORD` — a 4-byte float. Each of the five call sites pushes its `dt` with `push ecx; fstp DWORD PTR [esp]`. It returns `AL`, and the caller tests it. * `ProcessFleetMovement` ends in a plain `ret` with `mov esi,ecx` and no stack reads. ## The declared input boundary — say it out loud Both colony and movement targets are mostly *dispatchers*. Being honest about that is what makes the compare mean anything. **`ServerSystem::ProcessTurn`.** The function body writes: the unowned infrastructure decay, both pending bonus pools (through `ApplyInfraBonus` / `ApplyPopBonus`), `ntdev`, the long-stability accrual, `TRes = 0`, `haltv[0..2] = false`, the two per-player countdown sweeps, and the addiction morale events. Everything else is a callee. So the declared regions are exactly > `infra`, `ibon`, `pbon`, `ntdev`, `tres`, `haltv`, `bats2`, `bats_mask`, `rcex`, > `rcex_mask`, `rng` and **nothing else about the system is declared** — population, morale, resources, plague, slaves and rebellion state are simply not regions, so the harness never compares them. They are recorded in the `inputs` region for the trace instead. Two live values our side asks the game for rather than re-deriving: `ServerSystem::IsStable` (the stability verdict) and `ServerSystem::MaxPop` (the imperial capacity the accrual reads) — both read-only, the same delegation B3 makes to `TechTree::Cost`. **`MoveFleet`.** Declared: `pos`, `prev_pos`, one region per ship's `range`, the generator, and the return value. Not declared: the departure hook, the route revalidation, every arrival handler, the waypoint list, the tanker top-up. **A call that arrives is expected to differ in all of that**, and none of it is compared. **`ProcessFleetMovement`.** Declared: each player's gate-traffic word. That is the one thing our side can honestly reproduce, because the original computes it at the very *end* of the pass from the post-move fleet state — which is exactly the state `ours` reads. The pass schedule itself is recorded in the arguments (and `sim::PlanFleetMovement` predicts the call order so a trace can be checked against it) but is not compared, because reproducing it would mean running `MoveFleet`. Neither `MoveFleet` nor `ProcessTurn` offers **replace** mode. Both say so in `shim.log` and fall back to the original: our side models a slice, and feeding that slice to the game would strand every arriving fleet or skip a colony's whole turn. ### The RNG region, and why it has teeth here A colony turn's RNG consumption was swept function by function to call depth one: `ProcessPlague`, the civilian growth pass and `ProcessSlaves` make **no draws at all**, and neither does `ProcessTurn` itself. **`ProcessRebellion` is the only consumer**, and its count is data-dependent — one `RandChance` per iteration of a 64-bit rebel counter, plus a short-circuiting per-species roll loop, plus one outcome roll. So the generator is a declared region whose expected post-state on a system with no rebellion is **bit-identical**, and any movement of it names the system whose rebellion fired. `ours` seeds a `mars::rng::MT19937` from the pre-call snapshot, consumes the draws its own pass makes (currently none) and writes the state back, exactly as B3 does. `next` is rebuilt against the live generator address so the describer's index arithmetic reads the same on both sides. `MoveFleet` declares the generator too, because a type-5 waypoint draws from it: **one word on a successful jump, two on a miss** (the second seeds the random scatter direction). ## Corrections found by reading the binary Twenty-two, none of them tuned to make anything match. The ones that change behaviour: ### Movement 1. **The range grace margin is added, not subtracted.** The notes said `range = MinRange(fleet) − 0.05`. The constant at `0x00a1d2c0` is `0x3d4ccccd` — `+0.05f`, sign bit clear — and `MinRange` *adds* its float argument (`fadd DWORD PTR [ebp+8]`). The correct expression is `range = float32(MinRange(fleet) + 0.05f)`: a grace fudge so a fleet exactly at its range limit can still reach the target. Our old code stopped fleets 0.1 short. 2. **The out-of-range case zeroes the *range*, not the step.** The original asks for the minimum range a second time with no margin and, when that is exactly zero, does `fstp DWORD PTR [ebp+0x8]` — the range slot. `step` is untouched, and `step` is later the **divisor** of the pass fraction, so zeroing it instead changes (or NaNs) the recursion. 3. **`move` has no floor at zero.** `move = min(min(range, step), distance)` in that order; a negative minimum range moves the fleet backwards. The floor at zero exists only on each ship's range in the fuel loop. 4. **`MinRange` seeds its accumulator with `FLT_MAX`,** so an empty fleet is unconstrained rather than stranded, and it skips no ship — a range-exempt tanker still clamps the fleet. 5. **The probabilistic jump does not stop part-way along the vector.** On a miss the fleet is placed at `dest + randomUnitVector x v` where `v = float32(roll x CstE)` — *scattered around the destination* by exactly `v`, at the cost of a second RNG draw. The jump succeeds iff `!(v > CstT)`, so equality arrives, and the pass fraction is 1.0 either way, so a type-5 waypoint never recurses. 6. **Only waypoint type 3 reports a partial pass fraction.** `IsNodeWaypoint` is a 7-entry jump table that is true for 3 alone — and the node-*line* case of the movement switch is type **2**, which it does not accept. Everything else reports a full pass unless the move was blocked, in which case the fraction is `clamp01(move / step)`. 7. **The recursion threshold is a widened float literal and the test is strict.** The constant at `0x00a261f0` is an 8-byte double whose value is exactly `(double)0.9999f` = 0.9998999834060669; `fraction == threshold` does **not** recurse, and the recursive `dt` is `float32((1 − fraction) x dt)`. 8. **The stutter overlap rule is not a midpoint.** When `seg[i].end > seg[i+1].start` the original sets *both* boundaries to `float32(seg[i].end + 0.5 x (seg[i].end − seg[i+1].start))` — the mirror of the midpoint about `seg[i].end`, pushing the boundary **forward past both chords** by half the overlap. Verified down to the ModRM byte, because the FSUB/FADD operand order is the whole claim. Nothing is dropped and nothing is clipped back, so a chord swallowed by its predecessor comes out **inverted** (`start > end`) and the step loop skips it. We reproduce it, bug and all. 9. Smaller ones in the same pass: the chord parameters are clamped to `[0, length]` before the drop test (without which the intersect routine's `±FLT_MAX` sentinels would poison the list); the drop threshold is `fabs(start − end) <= 0.01f` (a float32 literal, and inclusive); the sort is a real `std::sort` keyed on `start` alone, so ties are unordered. 10. **The pass schedule is a pursuit model, not a "departing / in-transit / other" split.** Every fleet whose current waypoint targets another *fleet* is classified by the relation between the two owners: relation 0 (no treaty) makes it a pursuer, anything else a follower. Prey move half a turn, then pursuers move half a turn and a pursuer that arrives retires itself *and its prey* from the rest of the schedule, then the surviving prey take their second half, then everything unscheduled takes a full turn (an uncaught pursuer gets a second half instead), then the followers take a full turn. A fleet that is only a *follower's* target is not prey and takes a normal full turn. 11. **Gate traffic** is the sum of a **signed int16** at `fleet+0xc0` over fleets whose *front* waypoint type is 4 or 5, indexed by the owner's own index word, and it is **assigned** to each player rather than accumulated. Two things worth recording: the original accumulates by `player->index` but writes back by the player's *position* in the server's vector, which agree only while `players[j]->index == j`; and the accumulator is a fixed 32 ints with no bounds check. 12. **The field the notes called `FPogn2` at `fleet+0xec` is `FPdpos`.** By the FlightPlan layout `FPogn2` is at `+0xe0`; `+0xec` is the destination position, which is what the pass writes. 13. **`STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33` in the shipped data** (radius 2), so the linear ramp collapses to a constant 0.33× inside any influence sphere and 1.0× outside. The ramp is still implemented because the constants are data-file tunable, but a run against shipped data cannot distinguish it from a binary in/out multiplier — worth knowing before reading a clean compare as evidence for the ramp. ### Colony 14. **The population growth curve has no `pop / capacity` term.** The notes had `g = clamp01((1 − clamp01(pop/cap))^EXP)`. The capacity is never passed into the growth chain at all; the base of the power is a **suitability** term: `base = 1 − clamp01(min(|ideal − clamp(suit, 0, 20)|, SuitTol) / SuitTol)`, and the exponent is clamped into `[0.01f, 1000]` before a real `pow()`. Every modifier that follows is gated on a strict `> 0` and stored back to a float32. The 50,000,000 cap is not here either — it lives in the apply. 15. **The over-cap shrink runs only when the colony was *already* over the cap**, is computed from the *old* population, and a colony that merely grows past the cap simply lands on it. 16. **The second `SYSTEMBONUS_MINTURNS` gate is `ntdev`, not `rbtn`.** Read off the two `cmp`s at the head of `AccrueSystemBonus`. Our `SystemBonusInputs` field was named `turnsSinceRebellion`; it is `turnsDeveloping`. 17. **Both bonus-apply helpers reset `ntdev` to zero on a colony that is not the owner's home system.** That is a real feedback loop: a colony still absorbing a bonus never accumulates the developing turns the accrual gate wants, so it fails the gate on the same turn. `ApplyInfraBonus` also snaps `Infra` to **exactly 1.0f** when the pool covers the whole remainder, and an unowned system *drops* its whole population pool. 18. **The output-rate normaliser pins the trade slider.** It takes a "pinned channel" argument that every call site leaves null, which selects trade. Only trade is clamped into `[0, 1]`; the other three are summed in float32 (trade excluded) and rescaled to `1 − trade`; the all-zero fallback is an equal split over **three** channels, not four. Our version rescaled all four symmetrically. 19. **The engine's "round" is `fistp`/`fild` — round to nearest, ties to EVEN.** Not round-half-away-from-zero. 302.5 becomes 302. And `out[0]`, `out[3]` and the construction points go through the *truncating* helper, not the rounding one. 20. **Every carrying capacity is rounded down to a multiple of ten** by the shared helper, and `Size x 1e8` is an exact 64-bit *integer* product (the 1e8 is the immediate `0x05f5e100`); only the modifier chain is floating point. The arcology bonus is 0 for slaves. 21. **The terraforming modifier is inside the point *count*, not only the yield** — a better modifier needs proportionally fewer points, which is what keeps need and yield consistent — the sign is `-1` only for `suit > ideal` strictly, and the apply clamps at the ideal from whichever side it approached. `TerraformPointsNeeded` is a `ceil`, not a truncation. 22. Smaller ones: the slave death rate folds the hazard term into the base *before* the output term (order matters when every step narrows to float32), an unowned system reports 1.0 rather than 0, the worst plague at the system contributes an **additive** rate term, and both `SLAVES_MIN/MAX_DEATHS` are disabled by *any* negative value with the result clamped into `[0, slaves]`. `BuildQueue::ProcessTurn` returns the leftover points by value, a money refusal **skips** the order rather than stopping the pass, and removal is a separate sweep that unlinks every order at or below zero. ### Float discipline Every constant was checked bit by bit, because B2 and B3 were both bitten here. | what | address | bits | value | kind | |---|---|---|---|---| | unowned infra decay | `0x009e9170` | `3f947ae140000000` | 0.019999999552965164 | **widened `0.02f`**, not the decimal | | range grace | `0x00a1d2c0` | `3d4ccccd` | +0.05000000074505806 | float32 `0.05f`, **positive** | | recursion threshold | `0x00a261f0` | `3fefff2e40000000` | 0.9998999834060669 | double whose value is `(double)0.9999f` | | stutter chord drop | `0x009e3e14` | `3c23d70a` | 0.009999999776482582 | float32 `0.01f` | | stutter overlap half | `0x009e20a0` | `3fe0000000000000` | 0.5 | **true double** | | hazard band `+0.1` | `0x00a1a438` | `3fb999999999999a` | 0.1 | **true double**, not `(double)0.1f` | | output-rate threshold | `0x009e22c8` | `3f1a36e2e0000000` | 9.999999747378752e-05 | widened `1e-4f` | | infra divisor | `0x00a1f930` | `3f014d2f5dbb9cfa` | 3.3e-05 | **true double** | | infra chain | — | — | `/500`, `x0.01`, `x1.65` | 0.01 and 1.65 are true doubles; the three steps are not folded | | terraform yield | `0x009e62b8` | `3ff3333340000000` | 1.2000000476837158 | **widened `1.2f`** | | terraform need | `0x00a1f928` | `3ffccccce0000000` | 1.8000000715255737 | `1.5 x (double)1.2f` | | growth exponent floor | `0x009e3e14` | `3c23d70a` | 0.01f | shared with the chord drop | | slave mod base / step | `0x009e3030` / `0x009e20d8` | `3f4ccccd` / `3fc99999a0000000` | 0.800000011920929 / 0.20000000298023224 | float32 `0.8f` / widened `0.2f` | `numeric.h` gained `F32()` (narrow-and-widen) and `RoundHalfEven()`; `rng.h`'s `IRandom` gained `NextUInt32()` for the raw word the jump scatter takes. ## What our side runs * `sots::sim::ProcessColonyTurn` (new, `game/sim/colony.{h,cpp}`) — the dispatcher's own writes, in the original's order, plus `ColonyCountdowns` / `AddictionPhaseOf` and the corrected `ApplyPopulationBonus` / `ApplyInfrastructureBonus` / `AccrueSystemBonus`. * `sots::sim::StepFleet` (the shim adapter) over `ResolveMoveStep`, `AdvanceAlongDirection`, `ConsumeShipRange`, `PassFraction` and `RollProbabilisticJump`. * `sots::sim::GateTrafficTotals` and `sots::sim::PlanFleetMovement`. * `sots::sim::NodeLineStep` and `BuildStutterSegments` are corrected and unit-tested but are **not** wired into the hook: the node-line step needs the node graph walked from the live server, which this milestone does not do. A node-line waypoint therefore records its type in the trace and is left to the original — a declared gap, not a silent one. ## Host tests `ctest` **30/30** (was 28). New coverage: * `game_sim_colony` — 200 checks. Rewritten around the corrected growth curve (the suitability base, the `[0, 20]` clamp, the exponent clamp), the ties-to-even rounding, the pinned-trade normaliser, the terraform modifier inside the point count, the infra/suit apply clamps, the additive plague rate and the both-ends slave clamp. * `game_sim_movement` — 144 checks. The `+0.05` grace, the range-zeroing stranded case, the absence of a floor on `move`, the empty-fleet `FLT_MAX`, the scatter semantics of a failed jump and its two draws, the type-3-only pass fraction, the strict recursion threshold, the forward-pushed overlap boundary (including the inverted segment it produces), the pass schedule with and without a catch, and the gate-traffic sum. * `shim_colony_unit` — 62 checks. The snapshot round trip, the input mapping, a reference-save shaped colony turn, the bonus/accrual interaction with the non-home `ntdev` reset, the addiction sweep across all four phases plus temperance, the unowned colony, and the countdown edges (index 15, the clamp, the skip-when-zero). * `shim_movement_unit` — 56 checks. Straight steps, the exempt-tanker clamp, the arrival snap, the node-waypoint fraction, the gate teleport, both jump outcomes, a held fleet, the gate traffic and the schedule. ## Gotchas 1. **`ServerSystem::ProcessTurn` takes no arguments.** The decompile in the RE handoff shows a second parameter. Trusting it would have pushed a garbage word and corrupted the stack — exactly M0's failure mode. 2. **`MoveFleet` recurses into itself** for a multi-waypoint leg. `Hook<>` handles the nesting (depth, call ids) and the inner call gets its own record, but the outer record's "after" snapshot includes everything the recursion did — read a record's `depth` before comparing two of them. 3. **Per-call state lives in statics** between `regions()` → `rebind()` → `ours()` (M1's concession). Safe here because the turn pass is single-threaded, but `MoveFleet` *does* nest — the compare path is still correct because the template runs `regions`/`rebind`/`ours` for one call before the original's recursion can start, but do not add state that has to survive the original's execution. 4. **`Region::name` is a `const char*` held for the whole call**, so the per-ship and per-player name strings are `reserve`d once up front; a reallocation would dangle every name already pushed. 5. **`ProcessFleetMovement`'s `ours` re-reads the fleet list.** The gate-traffic total is computed by the original *after* the passes, so comparing against a pre-call snapshot would diverge for the wrong reason. 6. A compare record for `ProcessTurn` is small (twelve regions), but the generator's 2496-byte block is hashed rather than inlined — keep `trace.inline_max` at 256. 7. `MoveFleet` in `compare` is the busiest of the three; on the reference save that is a handful of records, but on a large map it is every fleet times up to five passes times the recursion depth. The `b4scout` config exists for exactly that reason: it leaves `MoveFleet` off. ## What remains (needs the VM) The lane holding VM140 must be finished first; then, in this order: 1. Deploy `/srv/re-lab/shim/dist-b4` (build `b4-final-20260908T0500Z`): `scp` it to `C:\SOTS\shimdist-b4\` and run `deploy.ps1 -Dist C:\SOTS\shimdist-b4` — **a separate staging directory from the shared `C:\SOTS\shimdist`**, so no other lane's dist is overwritten. 2. **Scout pass.** Copy `shim.cfg.b4scout` over `C:\SOTS\shim.cfg`, relaunch, load `ref-turn2.sav`, press End Turn once, pull `C:\SOTS\shim.trace.jsonl` → `b4-scout.jsonl`. `tracecmp.py` must exit 0 with 0 invalid records. `MoveFleet` is off in this config, so the file stays small. **Read off it before going further:** * `fpu_cw` on every record — expect `0x027f`; `0x007f` / `0x003f` means 24-bit x87 precision and the float mapping needs the PC24 route (B3's open question). * per `ServerSystem::ProcessTurn` record: `args.rng_left_in` minus `side.rng.after.left`. **Expect 0 on every system.** A non-zero delta names a system whose `ProcessRebellion` fired, and that system's compare record is then expected to diverge on `rng` and only on `rng`. * how many systems report `owned`, `stable`, a non-zero `ibon`/`pbon`, a non-empty `civilians` list and a non-zero `bats2`/`rcex`. That is the coverage table for step 4. * per `ProcessFleetMovement` record: the `fleet_state` list — how many fleets exist, how many have waypoints, and whether any waypoint type is 4 or 5. If none is, gate traffic is exercised in its zero branch only and must be reported that way. 3. **Trace pass.** Copy `shim.cfg.b4trace` (adds `MoveFleet`), relaunch, load `ref-turn2.sav`, End Turn → `b4-trace-golden.jsonl`. Check the `MoveFleet` record sequence against `sim::PlanFleetMovement`'s prediction: the fleet ids and `dt` values should appear in the pass order documented above. A mismatch is a finding about the schedule, not about the step. 4. **Compare pass.** Copy `shim.cfg.b4compare`, relaunch, load `ref-turn2.sav`, End Turn → `b4-compare.jsonl`. Expected: * `ServerSystem::ProcessTurn` — **0 divergences on every system**, on all twelve regions. The reference save is a turn-2 two-empire game, so `infra`/`ibon`/`pbon` are probably exercised in their no-op branches and `bats2`/`rcex` in their all-zero branch; say which regions actually carried a value rather than implying the rest passed. * `MoveFleet` — 0 divergences on `pos`, `prev_pos` and every `ship[i].range` for a call that did **not** arrive. A call that arrived is expected to match on those too (the snap is a verbatim copy) but the RNG and the undeclared arrival state are the original's; a divergence on `pos` after an arrival is a real finding. * `ProcessFleetMovement` — 0 divergences on every `gate_traffic[i]`. Any other diff is a real finding: report it, do not tune the formula. 5. Restore the previous `shim.cfg` (`hooks=trace`) and leave the game at the main menu, as M1/M2/B1/B3 left it. **Not done, and worth saying:** * **Replace mode is not offered** for any of the three hooks, so there is no End-Turn oracle result for this milestone. That is a deliberate consequence of the input boundary, not an omission — the strongest available evidence here is the compare plus the RNG post-state. * **Sub-paths the reference save will not exercise**, and which must therefore be reported as untested rather than passed: plague, rebellion (and with it every RNG draw in a colony turn), slaves, terraforming (the reference colony sits at its ideal), the addiction sweep at any phase, the unowned-system infrastructure decay, a non-home colony's `ntdev` reset, the probabilistic jump, the gate teleport, node-line travel, and a stranded fleet. A save with a plague, a rebelling colony, a Hiver gate network and a Zuul node bore would exercise most of them and is the natural next workload. * The **node-line step** is modelled and unit-tested but not hooked (see "What our side runs"), and under shipped data the stutter ramp is a constant anyway (correction 13). * `ComputeOutputFromRates` was read instruction by instruction and every correction is folded into `game/sim/colony`, but it is **not hooked**: it repairs damaged ships in orbit as a side effect (`0x00751590` with its estimate flag clear), so a compare hook cannot run it on a scratch copy of the system without isolating the ships too. That is its own milestone.