Written with the run in flight at turn 34. min_life is decrementing by exactly 1 per turn (43 down to 30), so the traffic term contributes nothing on this map and the oldest mortal line expires 30 turns out. Four ways it can be wrong, each with its own symptom. Every prediction this lane has made so far compared 0 against 0; this one does not.
182 lines
12 KiB
Markdown
182 lines
12 KiB
Markdown
# 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 `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.
|
||
|
||
---
|
||
|
||
## 5. Outcome (added after the run; nothing above was edited)
|
||
|
||
Build `z-tailrng-20260908T1314Z` on `ref-turn2` (4 End Turns) and `z-tailrng2-20260908T1328Z` on
|
||
`zuul-turn16-noderoute`. Full report: `sots-re/findings/control-flow/tail-rng-ledger.md`.
|
||
|
||
| # | prediction | outcome |
|
||
|---|---|---|
|
||
| P1 | the tail runs on a turn with no combat | **held, half** — one call per End Turn on 8 of 8. But every turn had exactly one *encounter* (a sighting, `res->+0x4 != 0`), so what is proved is "no battle", not "no encounter". Narrowed, not closed |
|
||
| P2 | quiet turn → tail consumes 0 words | **held** — 0 on 8 of 8 |
|
||
| P3 | tail = 0 on the reference saves; the defect is latent | **held** |
|
||
| P4 | `ProcessTurn` spends a small nonzero state-dependent count, order 1–20 | **held** — 18, 19, 20, 22 across eight turns |
|
||
| P5 | residual > 0 in general, **0 on a quiet turn** | **held** — residual exactly 0 on all six complete brackets |
|
||
| P6 | `S+0x8` advances exactly twice per turn | **FALSIFIED** — it advances **12–14** times per turn; the two drivers are 2 of them. Lane K's "at least twice" was the right phrasing and its conclusion (never treat `S+0x8` as a turn number) is strengthened |
|
||
| P7 | node-line expiry probably will not fire | **held**, and quantified rather than left as an absence: 51 of 53 lines on the Zuul map are permanent (`npt == 0`), the mortal ones are dug by the Zuul at ~1/turn, and every one is ~40 turns from expiry |
|
||
| P8 | one generator, every observation resolves | **held** — no `words: null` in any record |
|
||
|
||
Two results worth more than the predictions:
|
||
|
||
* **The generator does not move between turns.** Each turn's `ProcessTurn` entry position equals the
|
||
previous post-turn autosave position exactly. The interval a standalone must reproduce is closed.
|
||
* **The ledger was checked against the save files.** The turn-6 autosave pair gives 18 words read straight
|
||
out of the two `Sim.RNG` blobs — and with `twists == 0`, so that number does not go through anyone's twist
|
||
implementation. The live hook said 18.
|
||
|
||
Two corrections went back into `combat-done-tail.md` in place: the node-line fleet check runs *after* the
|
||
`Chance` call and cannot gate the draw, and `StrategyHost::Autosave` is `ret 8` returning the `std::string*`
|
||
in EAX.
|
||
|
||
---
|
||
|
||
## 6. A second prediction, written before the run reached it
|
||
|
||
Committed at turn 34 of the `zuul-turn16-noderoute` long run, with the run still in flight.
|
||
|
||
The node-line population's minimum remaining life is now decrementing by **exactly 1 per turn** —
|
||
43, 42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 33, 32, 31, **30** at turn 34 — i.e. pure ageing, with the
|
||
traffic term contributing nothing on this map. `NodePath::RemainingLife` clamps at 0 and the loop skips on
|
||
`> 0`, so the oldest mortal line expires the turn its remaining life reaches 0.
|
||
|
||
**P9. The first phase-11 draw happens on turn 64, and costs exactly 1 word.** On that turn:
|
||
`Game::StrategyServer::NodeLineDecay` records `np_min_life = 0`, `predict_words = 1`, and a measured
|
||
`rng` delta of **1**; `OnAllCombatDone_Tail`'s total becomes **1** instead of 0 — the first non-zero tail
|
||
cost this campaign has ever recorded — and the bracket total becomes `ProcessTurn + 1`.
|
||
|
||
*Falsified by:*
|
||
* **it fires earlier than 64** — then the traffic term `nptf / npdtf` is contributing after all, and the
|
||
ageing-only reading of the last fifteen turns is wrong;
|
||
* **it fires later than 64, or not at all by turn 66** — then `min_life` is not the line that expires first
|
||
(a line dug later with a shorter `npdtn` would do it), or `S+0xc` is not the `turn` the original passes;
|
||
* **it costs more than 1 word** — then more than one line expires on the same turn, which the `np_within5`
|
||
column would have warned about (it has read 0 on every turn so far), **or** node-line decay has a draw
|
||
site the sweeps missed, which is exactly what method rule 16 says a call-graph sweep cannot rule out;
|
||
* **it costs 0 with `np_min_life = 0`** — then the expiry predicate transcribed in §6.1 of the finding is
|
||
wrong somewhere, most likely in the two never-expire early-outs.
|
||
|
||
This is the first genuinely falsifiable numeric claim this lane can make: every earlier one compared 0
|
||
against 0.
|