435 lines
30 KiB
Markdown
435 lines
30 KiB
Markdown
# 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.
|