# B4 — the colony turn and the fleet movement pass, old vs new **Result (2026-09-08): verified on the live game. 36 compared, 0 divergences.** | pass | build | records | verdict | |---|---|---|---| | scout (`b4scout`) | `b4-fix1-…0600Z` | 28 colony + 1 movement | `tracecmp.py` exit 0, 0 invalid | | trace (`b4trace`) | `b4-fix1-…0600Z` | 28 colony + 7 `MoveFleet` + 1 pass | exit 0, 0 invalid | | compare (`b4compare`) | `b4-fix2-…0615Z` | 36 calls, **all 36 compared** | **0 diverged, 0 errors**, exit 0 | The headline is the scout's, and it is what the milestone was for: **the generator did not move on a single one of the 28 systems** — `left` delta 0 and the `mt[624]` block hash identical before and after, on every record — which is exactly what the static sweep predicted (only `ProcessRebellion` draws, and no colony rebelled this turn). `fpu_cw = 0x127f` (53-bit) on all 28, confirming the two earlier lanes rather than re-investigating. The one fleet that actually moved reproduced bit for bit: fleet 34 went `(-11.9286022, 4.71900415, 2.31785131) -> (-10.5634995, 5.97172451, 1.56473362)` and our float32 position is identical in all three components; its ship's range went 20 -> 18, i.e. exactly `speed 2 x dt 1.0`. `sim::PlanFleetMovement`'s predicted call schedule matched the observed one exactly (all seven fleets, `dt = 1.0`, in fleet-vector order — no pursuits in this save). Three bugs in my own hooks were caught by the runs, all of which would have produced a *clean compare that checked nothing* (see "Three hook bugs the live runs caught"). Host suite 30/30, `tools/clean_room_check.sh` OK. Ghidra was used to write the verified prototypes back into the shared project (`reva-server` stopped for the run and restarted afterwards). The VM is released at the main menu with `hooks=trace` and build `b4-fix2-20260908T0615Z` deployed. ## 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. ## Three hook bugs the live runs caught None would have shown up as a divergence. Each would have made a compare that reported "0 diverged" while checking less than it claimed — the same class of failure the harness audit lane is chasing, found the same way B3 found its own: by looking at the trace rather than at the verdict. 1. **`describe_args` runs before `regions()`.** The template calls them in that order and both descriptors took their snapshot in `regions()`, so every argument sourced from it was **one call stale** — the first record showed zeros, the rest showed the previous system's state. The `inputs` region was always right (its "before" is captured after `regions()` returns), so the compare would have been sound while the trace's own argument record lied. Fixed: capture in `describe_args`, reuse in `regions()`. 2. **The StrategyServer has two bases four bytes apart.** A `ServerSystem`'s `owner` word (+0x10) points at a base four bytes *above* the one the class's own methods receive in ECX (the generator accessor at 0x007437f0 does `owner - 4`). Every `StrategyServer_off_*` in the contract is relative to the raw/owner base, so the colony hook — which starts from `sys->owner` — was right, and the two movement hooks, which applied the same offsets to `this`, were not. `ProcessFleetMovement` read an empty player vector, declared **zero** gate-traffic regions, and reported `players=0 fleets=0`; tracecmp dutifully printed "1 call, 0 diverged" for a hook that compared nothing. Fixed with an explicit `raw_server()` rebase. 3. **`StrategyServer_off_Fleets` was the Ghidra-base number written down as a raw-base one.** With `0x64` instead of `0x60` the hook enumerated the fleet vector's *spare capacity* rather than its elements: the trace pass showed `ProcessFleetMovement` reporting 1 fleet while `MoveFleet` was called 7 times in the same turn. The gate-traffic totals came out all-zero and matched ours — correct by luck, from a garbage fleet list. Fixed to `0x60`; the compare pass then reported 7 fleets in exactly the order `MoveFleet` was called for them. The contract entry for `StrategyServer_off_Players` now spells the two-base hazard out, and the three corrected offsets carry the reason in their prototype text. ## 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 the runs actually exercised — and what they did not A clean compare over 36 calls is worth exactly as much as the coverage behind it, so here is the coverage, region by region, from the compare log itself. **`ServerSystem::ProcessTurn`, 28 calls.** Only **3 systems are owned** (indices 4 and 15, the two players' home worlds; index 16, an independent/NPC colony owned by player 7 and the only non-home one). The other 25 are unowned. | region | carried a value? | what that means | |---|---|---| | `ntdev` | **yes, moved on 3** | the stable-increment path is verified on all three owned colonies | | `rcex` | **yes, moved on 6** | the recon countdown sweep is genuinely exercised, including a nibble reaching zero | | `rcex_mask` | one system held a stale bit with a zero counter | the "skip the sweep when the word is zero" branch is verified | | `infra` | matched on all 28, but never *changed* | owned colonies sit at 1.0 (the apply is a no-op) and unowned ones at 0.0, so the decay is only exercised in its clamp branch | | `ibon` / `pbon` | matched, never changed | both home colonies are at their cap, so `ApplyPopBonus` short-circuits and `ApplyInfraBonus` returns at `Infra >= 1`; the accrual gate fails on `ntdev` | | `tres` / `haltv` | matched at 0 / false on all 28 | zero branch only | | `bats2` / `bats_mask` | zero on all 28 | **the battle countdown is completely untested** | | `rng` | matched on all 28, bit for bit | the real result: no colony drew a word | **`MoveFleet`, 7 calls.** Six are fleets with **no waypoints at all** (`wpt_type = -1`), which exercise only the early-out. **One call does real work**: fleet 34, waypoint type 1 (a straight run) to a system, `speed 2 x dt 1.0`, and it matched on `pos`, `prev_pos` and its ship's range. So the step arithmetic is verified for exactly one straight-line move. Not exercised at all: the range clamp against a real limit (the ship had 20 range for a 2-unit move), the stranded case, node-line travel (type 2), a node route (type 3), a gate teleport (type 4), a probabilistic jump (type 5, and with it every RNG draw in the movement path), the multi-waypoint recursion, and an arrival. **`ProcessFleetMovement`, 1 call.** All eight `gate_traffic` words matched — but every one of them is **0 before and 0 after**. Three fleets carry non-zero traffic words (10, 12, 12…) yet none has a waypoint, so nothing is summed. **The gate-traffic total is verified in its zero branch only.** The pass schedule was checked separately against `sim::PlanFleetMovement` and matched, but this save has no pursuits, so only the "everything else, dt 1.0" pass is covered. ## Writes of the original that the declared regions do NOT cover Stated explicitly, because a clean compare says nothing about any of it. This is the content of the `coverage` block once `HookPolicy` carries the field (the harness audit lane owns that struct change); it lives as a comment at the top of each descriptor header until then. **`ServerSystem::ProcessTurn`** — `state: partial` - **the addiction sweep's morale events**: `ours` computes the `{species, event id, delta}` list, but nothing applies it, so the system's `Morale int[7]` and its **morale-event vector append** are never written or compared. *Risk: high — this is precisely B3's failure mode, a list append outside every declared region.* Not exercised by this save either (no addiction). - the whole plague pass, imperial and civilian population growth, `AdjustResources`, the in-orbit refuel, `ProcessSlaves` and `ProcessRebellion`. *Risk: high; no guard.* - the independent-colony imperial↔civilian population drift (0x007514f0). *Risk: medium.* - `TnsOH`, the build queue's own list, and every ship the queue creates. *Risk: medium.* **`MoveFleet`** — `state: partial` - **every arrival handler**: `FleetArrives`, the `SEFleetArrived` **event object and its dispatch**, the next-leg pathability check, and the **insert into the server's in-motion set** at +0x210. *Risk: high — an event post and two `std::set` inserts, none declared.* - the departure block: the script hook at StrategyServer+0x1b4, per-ship action cancellation, and `ServerSystem::FleetDeparts` (which rewrites a system's fleet vector and its per-player presence bitmasks). *Risk: high.* - **the waypoint vector itself** — a completed leg pops its waypoint and a failed probabilistic jump rewrites the whole route. *Risk: high.* - the tanker top-up and the fleet flag word at +0x10c. *Risk: medium.* - the node-line step: our side computes `speed x dt` for every waypoint type, so a **type-2 waypoint's step is wrong by construction**. *Risk: high; mitigated only by `wpt_type` being on the record — treat type 2 as not compared.* **`ProcessFleetMovement`** — `state: partial` - the entire pass schedule and therefore every `MoveFleet` call it makes. *Mitigation: `sim::PlanFleetMovement` predicts the order and the trace was checked against it.* - the per-fleet destination position at +0xec and the flag-0x2 / flag-0x100 clears. *Medium.* - **`OnFleetArrived`**: it takes the set difference of +0x200 and +0x210, clears both, and dispatches a per-player arrival event. *Risk: high.* ## Runs (`/srv/re-lab/shim/traces/`) | file | mode | build | calls | result | |---|---|---|---|---| | `b4-scout.jsonl` | trace | `b4-fix1-…0600Z` | 29 | exit 0, 0 invalid; **RNG delta 0 on all 28 systems** | | `b4-trace-golden.jsonl` | trace | `b4-fix1-…0600Z` | 36 | exit 0, 0 invalid; schedule matched the prediction | | `b4-compare.jsonl` | compare | `b4-fix2-…0615Z` | 36 | **36 compared, 0 diverged, 0 errors** | | `b4-compare-turn2/3.png` | | | | savings 289,688 -> 532,369, the reference oracle state | Workload each time: main menu -> Load Game -> Single Player -> `ref-turn2.sav` -> Launch -> turn 2 (savings 289,688) -> **End Turn** -> turn 3 (savings 532,369). ## Still owed * **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 consequence of the input boundary above, not an omission: our side models a slice of each function, and feeding that slice to the game would strand every arriving fleet or skip a colony's whole turn. * **A richer save.** The reference save cannot exercise plague, rebellion (and with it every colony RNG draw), slaves, terraforming, the addiction sweep, the battle countdown, a bonus-absorbing colony, gate traffic, node-line travel, a probabilistic jump, an arrival, or a stranded fleet. A save with a plague, a rebelling colony, a Hiver gate network and a Zuul node bore would cover most of them and is the natural next workload. * **The node-line step is not wired into the hook** — it needs the node graph walked from the live server. Until then a type-2 waypoint is compared with the wrong step. * **`ComputeOutputFromRates` is read but not hooked**: it repairs damaged ships in orbit as a side effect, so it cannot run on a scratch copy of the system. Its own milestone. * The three descriptors are `unstated` to the harness; the text above is ready to drop into the `coverage` field the moment `HookPolicy` carries it.