sots-engine/docs/B4.md

24 KiB
Raw Blame History

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

  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 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.