Z: prediction for the per-turn RNG ledger, written before the build

Six nested trace hooks bracket one End Turn between the two autosaves and
attribute every word the strategic generator consumes to a phase. Position is
recovered from (mt[624], left) alone via a forward-only block chain, so word
deltas are exact across twists and across NextInt rejection loops.

Eight predictions with their falsifications, including the two that matter:
the tail runs on a no-combat turn (lane K inferred it), and a quiet turn's
residual outside the two drivers is zero.
This commit is contained in:
alex 2026-09-08 09:02:32 -04:00
parent 2bf38bc379
commit 45a7a8c7b8

122
docs/Z-tail-rng.md Normal file
View file

@ -0,0 +1,122 @@
# 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 `FUN_007cfd00`) 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 `FUN_007d5af0` (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 `NextInt` that 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.