24 KiB
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::ProcessTurnends in a plainretand nothing in the body reads[ebp+8]. The existing decompile shows a second parametervoid* stream; that is a Ghidra guess and it is wrong. Hooking it as a one-argument function would have corrupted the stack.MoveFleetends inret 8;[ebp+8]goes into ESI as the fleet and[ebp+0xc]is read withfld DWORD— a 4-byte float. Each of the five call sites pushes itsdtwithpush ecx; fstp DWORD PTR [esp]. It returnsAL, and the caller tests it.ProcessFleetMovementends in a plainretwithmov esi,ecxand 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
- The range grace margin is added, not subtracted. The notes said
range = MinRange(fleet) − 0.05. The constant at0x00a1d2c0is0x3d4ccccd—+0.05f, sign bit clear — andMinRangeadds its float argument (fadd DWORD PTR [ebp+8]). The correct expression isrange = 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. - 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.stepis untouched, andstepis later the divisor of the pass fraction, so zeroing it instead changes (or NaNs) the recursion. movehas 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.MinRangeseeds its accumulator withFLT_MAX, so an empty fleet is unconstrained rather than stranded, and it skips no ship — a range-exempt tanker still clamps the fleet.- The probabilistic jump does not stop part-way along the vector. On a miss the fleet is
placed at
dest + randomUnitVector x vwherev = float32(roll x CstE)— scattered around the destination by exactlyv, 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. - Only waypoint type 3 reports a partial pass fraction.
IsNodeWaypointis 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 isclamp01(move / step). - The recursion threshold is a widened float literal and the test is strict. The constant
at
0x00a261f0is an 8-byte double whose value is exactly(double)0.9999f= 0.9998999834060669;fraction == thresholddoes not recurse, and the recursivedtisfloat32((1 − fraction) x dt). - The stutter overlap rule is not a midpoint. When
seg[i].end > seg[i+1].startthe original sets both boundaries tofloat32(seg[i].end + 0.5 x (seg[i].end − seg[i+1].start))— the mirror of the midpoint aboutseg[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. - 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_MAXsentinels would poison the list); the drop threshold isfabs(start − end) <= 0.01f(a float32 literal, and inclusive); the sort is a realstd::sortkeyed onstartalone, so ties are unordered. - 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.
- Gate traffic is the sum of a signed int16 at
fleet+0xc0over 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 byplayer->indexbut writes back by the player's position in the server's vector, which agree only whileplayers[j]->index == j; and the accumulator is a fixed 32 ints with no bounds check. - The field the notes called
FPogn2atfleet+0xecisFPdpos. By the FlightPlan layoutFPogn2is at+0xe0;+0xecis the destination position, which is what the pass writes. STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33in 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
- The population growth curve has no
pop / capacityterm. The notes hadg = 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 realpow(). Every modifier that follows is gated on a strict> 0and stored back to a float32. The 50,000,000 cap is not here either — it lives in the apply. - 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.
- The second
SYSTEMBONUS_MINTURNSgate isntdev, notrbtn. Read off the twocmps at the head ofAccrueSystemBonus. OurSystemBonusInputsfield was namedturnsSinceRebellion; it isturnsDeveloping. - Both bonus-apply helpers reset
ntdevto 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.ApplyInfraBonusalso snapsInfrato exactly 1.0f when the pool covers the whole remainder, and an unowned system drops its whole population pool. - 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 to1 − trade; the all-zero fallback is an equal split over three channels, not four. Our version rescaled all four symmetrically. - The engine's "round" is
fistp/fild— round to nearest, ties to EVEN. Not round-half-away-from-zero. 302.5 becomes 302. Andout[0],out[3]and the construction points go through the truncating helper, not the rounding one. - Every carrying capacity is rounded down to a multiple of ten by the shared helper, and
Size x 1e8is an exact 64-bit integer product (the 1e8 is the immediate0x05f5e100); only the modifier chain is floating point. The arcology bonus is 0 for slaves. - 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
-1only forsuit > idealstrictly, and the apply clamps at the ideal from whichever side it approached.TerraformPointsNeededis aceil, not a truncation. - 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_DEATHSare disabled by any negative value with the result clamped into[0, slaves].BuildQueue::ProcessTurnreturns 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, plusColonyCountdowns/AddictionPhaseOfand the correctedApplyPopulationBonus/ApplyInfrastructureBonus/AccrueSystemBonus.sots::sim::StepFleet(the shim adapter) overResolveMoveStep,AdvanceAlongDirection,ConsumeShipRange,PassFractionandRollProbabilisticJump.sots::sim::GateTrafficTotalsandsots::sim::PlanFleetMovement.sots::sim::NodeLineStepandBuildStutterSegmentsare 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.05grace, the range-zeroing stranded case, the absence of a floor onmove, the empty-fleetFLT_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-homentdevreset, 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
ServerSystem::ProcessTurntakes 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.MoveFleetrecurses 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'sdepthbefore comparing two of them.- Per-call state lives in statics between
regions()→rebind()→ours()(M1's concession). Safe here because the turn pass is single-threaded, butMoveFleetdoes nest — the compare path is still correct because the template runsregions/rebind/oursfor one call before the original's recursion can start, but do not add state that has to survive the original's execution. Region::nameis aconst char*held for the whole call, so the per-ship and per-player name strings arereserved once up front; a reallocation would dangle every name already pushed.ProcessFleetMovement'soursre-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.- A compare record for
ProcessTurnis small (twelve regions), but the generator's 2496-byte block is hashed rather than inlined — keeptrace.inline_maxat 256. MoveFleetincompareis 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. Theb4scoutconfig exists for exactly that reason: it leavesMoveFleetoff.
What remains (needs the VM)
The lane holding VM140 must be finished first; then, in this order:
- Deploy
/srv/re-lab/shim/dist-b4(buildb4-final-20260908T0500Z):scpit toC:\SOTS\shimdist-b4\and rundeploy.ps1 -Dist C:\SOTS\shimdist-b4— a separate staging directory from the sharedC:\SOTS\shimdist, so no other lane's dist is overwritten. - Scout pass. Copy
shim.cfg.b4scoutoverC:\SOTS\shim.cfg, relaunch, loadref-turn2.sav, press End Turn once, pullC:\SOTS\shim.trace.jsonl→b4-scout.jsonl.tracecmp.pymust exit 0 with 0 invalid records.MoveFleetis off in this config, so the file stays small. Read off it before going further:fpu_cwon every record — expect0x027f;0x007f/0x003fmeans 24-bit x87 precision and the float mapping needs the PC24 route (B3's open question).- per
ServerSystem::ProcessTurnrecord:args.rng_left_inminusside.rng.after.left. Expect 0 on every system. A non-zero delta names a system whoseProcessRebellionfired, and that system's compare record is then expected to diverge onrngand only onrng. - how many systems report
owned,stable, a non-zeroibon/pbon, a non-emptycivilianslist and a non-zerobats2/rcex. That is the coverage table for step 4. - per
ProcessFleetMovementrecord: thefleet_statelist — 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.
- Trace pass. Copy
shim.cfg.b4trace(addsMoveFleet), relaunch, loadref-turn2.sav, End Turn →b4-trace-golden.jsonl. Check theMoveFleetrecord sequence againstsim::PlanFleetMovement's prediction: the fleet ids anddtvalues should appear in the pass order documented above. A mismatch is a finding about the schedule, not about the step. - Compare pass. Copy
shim.cfg.b4compare, relaunch, loadref-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, soinfra/ibon/pbonare probably exercised in their no-op branches andbats2/rcexin their all-zero branch; say which regions actually carried a value rather than implying the rest passed.MoveFleet— 0 divergences onpos,prev_posand everyship[i].rangefor 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 onposafter an arrival is a real finding.ProcessFleetMovement— 0 divergences on everygate_traffic[i]. Any other diff is a real finding: report it, do not tune the formula.
- 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
ntdevreset, 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).
ComputeOutputFromRateswas read instruction by instruction and every correction is folded intogame/sim/colony, but it is not hooked: it repairs damaged ships in orbit as a side effect (0x00751590with 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.