The turn's RNG cost has never been measured end to end. combat-done-tail.md
found two draw sites in OnAllCombatDone_Tail that nothing models and that both
run before the autosave, so a reimplementation that reproduces both ProcessTurn
functions exactly still diverges the first turn a node line expires.
RngLedger recovers an ABSOLUTE WORD POSITION from (mt[624], left) alone, by
indexing the forward-only chain of blocks the twist generates. Word deltas
between any two observations are then exact -- across twists, across NextInt
rejection loops, and across draws nobody hooked. That last point is not
theoretical: the image has four draw entry points, one of which (NextUInt
0x004f7670) appears in no previous lane's primitive set, plus inlined draws in
twelve functions. A primitive-counting hook would have undercounted silently.
Six nested trace hooks bracket one End Turn between the two autosaves and
attribute the words: the two turn drivers, the two tail phases that can draw,
and ProcessNodeSpaceTravel because it runs twice a turn. NodeLineDecay carries
a real model -- one word per expired node line under NodePath::RemainingLife --
so compare mode checks the count rather than reporting it.
Corrections from the instruction stream, both load-bearing:
* StrategyHost::Autosave is ret 8, not ret 4, and returns the std::string* in
EAX. A void-returning hook would have dropped it at both call sites.
* node-line decay's 0x20000-fleet skip runs AFTER the Chance(0.5f) call, not
before, so it cannot change the draw count -- combat-done-tail.md reads as
if it gated the roll.
fpu.sample_turn releases StrategyServer::ProcessTurn, which the fpu sampler and
this ledger both want and MinHook grants to one of them. Default on: no
existing run changes behaviour.
Host ctest 37/37; shim cross-built on CT111; clean-room check OK.
7.9 KiB
Z — the RNG ledger for one strategic turn
Lane Z, 2026-09-08. Worktree wip/tailrng. Written before the build was staged (method rule 2).
The milestone this serves: a standalone that loads a save, runs one strategic turn, and writes an autosave that byte-matches the original's. Generator state is part of the saved state, so the turn is not reproduced until every word the strategic generator consumes is accounted for.
Lane K (sots-re/findings/control-flow/combat-done-tail.md §3) found two draw sites in
StrategyServer::OnAllCombatDone_Tail that no lane models, both before the autosave. Every RNG
accounting in the repo assumes the generator only advances inside StrategyServer::ProcessTurn.
This lane measures what actually happens.
1. The instrument
Not an RNG-primitive hook. The generator's whole state is mt[624] + left, and the twist is a pure
function of the block, so position is recoverable from state alone:
position(block_index, left) = block_index * 624 + (624 - left)
RngLedger (src/shim/hooks/rng_ledger.{h,cpp}, host-tested) keeps a forward-only chain of blocks
from the first block it ever sees, memoised by a 64-bit hash of the 2496-byte block. Observing a
state resolves its block to an index (extending the chain by twisting when the block is ahead of the
frontier) and returns an absolute word position. Word deltas between any two observations are then
exact, including across twists, and no assumption is made about how many words a NextInt
rejection loop spent — the state says.
Ordering matters and is handled explicitly. Hook<> calls describe_args and regions at entry
(chronological) but calls every region describe after the original returns, so a nested call's
before snapshot would otherwise be described after the chain had already moved past it. Each hook
therefore observes at entry from describe_args, which memoises the entry block; the describe
that runs at exit then resolves it from the memo instead of walking backwards.
Six hooks, all trace, nested so the subtotals attribute:
| hook | address | why |
|---|---|---|
Game::StrategyHost::Autosave |
0x00895210 | the two absolute markers. endTurn=1 is the pre-turn state, endTurn=0 the post-turn state; the words between them are the turn's whole RNG cost as the two saves see it |
Game::StrategyServer::ProcessTurn |
0x007dc6c0 | the half the repo already believes in |
Game::StrategyServer::OnAllCombatDone_Tail |
0x007d92a0 | the half nothing models |
Game::StrategyServer::ApplyEncounterResult |
0x007d8920 | tail phase 6, the combat-resolver subtree |
Game::StrategyServer::NodeLineDecay |
0x007ae010 | tail phase 11, the Chance(0.5f) per expired node line |
Game::StrategyServer::ProcessNodeSpaceTravel |
0x007a0e20 | runs twice a turn (ProcessTurn phase 7 and tail phase 10) and has never been swept for draws |
The residual — Autosave(1)→Autosave(0) total minus the sum of the attributed subtotals — is the
number this lane exists to produce. It is the part of a turn a reimplementation would silently miss.
2. Predictions
P1 — the tail runs on a turn with no combat. Lane K flagged this as inferred from the
determinism note, not from the instruction stream. Predicted: OnAllCombatDone_Tail records exactly
one call per End Turn regardless of encounters.
Falsified by: an End Turn where Autosave(endTurn=0) records a call and no tail call precedes it
in the same turn. If that happens, §6's autosave mechanism is reached some other way and lane K's
§7.1 (S+0x8 advances twice per turn) is wrong too — so P1 and P6 fail together or not at all.
P2 — on a quiet turn the tail consumes exactly 0 words. Both known draw sites are conditional: phase 6's loop body never runs with an empty encounter vector, and phase 11 draws once per expired node line. Nothing else in the 36 phases reached a primitive in lane K's sweep. Falsified by: a nonzero tail delta on a turn whose encounter count is 0 and whose node-line decay subtotal is 0. That would be a draw site lane K's callee sweep missed, and it would be the most important single result of this lane — more important than a confirmation.
P3 — the tail's contribution is 0 on most turns, which is why nothing broke. The defect lane K
found is latent, not active: our saves have never expired a node line or fought a battle at the point
where the tail runs. Predicted: on ref-turn2 and the turn1/2/3-state saves, tail delta = 0.
Falsified by: any nonzero tail delta on the reference saves — which would mean existing "RNG
matched" results were luckier than they looked (compare method rule 11).
P4 — ProcessTurn consumes a small, nonzero, state-dependent number of words. Order 1–20 on a
two-player early-game turn: research rolls are 1 word per player whose gate passes and 2 on the
plague path (lane T §3.1), plus whatever ProcessStations / ProcessSurrenders / ProcessMissions
/ ProcessSpecialProjects spend — none of which has ever been measured.
Falsified by: zero (meaning the research gate never passes and nothing else draws — possible, and
then the interesting question moves entirely to what the AI does), or by hundreds (meaning a
per-system or per-fleet draw nobody has found).
P5 — the attributed subtotals do not sum to the bracket. Predicted residual > 0, because combat
itself (RunCombatRound 0x007cbe80 / the combat server 0x007cfd00) runs between
ProcessTurn and the tail and is hooked by nobody. On a quiet turn, though, predicted residual
= 0 exactly: the two drivers should account for every word between the two autosaves.
Falsified by: a nonzero residual on a quiet turn. That is a draw site outside both drivers and
outside combat, and it would mean the turn has a third RNG consumer.
P6 — S+0x8 advances exactly twice per turn. Lane K's §7.1 correction, live. Each hook records
S+0x8 and S+0xc at entry.
Falsified by: any other count. Recorded because it is free and it settles a published correction.
P7 — node-line expiry will probably not fire. Phase 11 draws per expired line; lifetimes are long. Predicted: several End Turns on the node-route saves produce zero phase-11 draws, and this lane reports that plainly rather than claiming the path is covered (method rule 6 — a path no save exercises is a hypothesis, and it stays labelled one). Falsified by: a phase-11 subtotal > 0, which would make the model in §3 checkable.
P8 — one generator, not several. All six hooks watch the object at S+0x16c. Predicted: every
observation resolves against a single forward chain.
Falsified by: an UNKNOWN position, i.e. a block the chain cannot reach — which means a second
generator instance, and every attribution above would need re-reading.
3. What ours models
NodeLineDecay carries a model: walk the 0x30-stride node-line records at [S+0x154]+8/+0xc, count
the expired ones under the same predicate the original tests, and advance the scratch generator by
that many words. In compare mode the diff on the rng region is then a real check of the count.
OnAllCombatDone_Tail and ProcessTurn carry no model and say so in Coverage: their cost is
whatever their subtrees spend, and the honest statement is the measured number plus the named
unmodelled subtrees, not a prediction dressed as one.
4. What this cannot settle
- The combat resolver
0x007d5af0(7499 B) stays unread. Its draw count is measured here as part of the phase-6 subtotal, never modelled. - A turn with no combat cannot exercise phase 6 at all, so its subtotal on a quiet turn is 0 by construction and proves nothing about combat.
- The ledger measures words, not draws. A
NextIntthat rejects three times is four words and one draw; this instrument reports four and cannot tell you it was one call. That is the right unit for save-state reproduction and the wrong unit for counting decisions.