sots-engine/docs/B4.md

30 KiB
Raw Blame History

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

  1. 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.
  2. 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.
  3. The second SYSTEMBONUS_MINTURNS gate is ntdev, not rbtn. Read off the two cmps at the head of AccrueSystemBonus. Our SystemBonusInputs field was named turnsSinceRebellion; it is turnsDeveloping.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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 reserved 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.