273 lines
17 KiB
Markdown
273 lines
17 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.
|
||
|
||
---
|
||
|
||
## 7. P10, written with the encounter dialog on screen and unclicked
|
||
|
||
The turn-54 End Turn on the Zuul long run stopped on an **Encounter at Gallandro**: the player's 5 ships
|
||
(3 DE Colonizer, 2 DE Armor) against a **Von Neumann**, with the dialog offering Fight Manually / Auto
|
||
Resolve / Fight Manually If Opponent Does / Retreat. Auto Resolve has not been clicked yet.
|
||
|
||
This is the workload `sots-re/findings/control-flow/tail-rng-ledger.md` §8 says does not exist and lane J's
|
||
`combat-resolver.md` asks for: **every encounter measured so far had `res->+0x4` set, so
|
||
`ApplyEncounterResult` was a whole-function no-op and the combat resolver has never executed under any
|
||
instrument this campaign has built.**
|
||
|
||
**P10. Clicking Auto Resolve produces the first non-zero tail cost this campaign has recorded.**
|
||
Specifically: `ApplyEncounterResult` records `res_no_battle = 0` for the first time; its measured `rng`
|
||
delta is **greater than 0**; and `OnAllCombatDone_Tail`'s total equals that delta, because node-line decay
|
||
is still 30-odd turns from firing and every other phase has measured 0 across 54 turns.
|
||
|
||
On the size, following lane J's map of the three conditional sites: there is no node cannon here, so R1
|
||
should not fire. A **Von Neumann is salvage** — R2 is one inlined `NextFloat` per back-engineering candidate
|
||
per combatant with a non-zero salvage slot, and R3 one `NextInt` per successful R2 roll. So the expected
|
||
cost is **small and odd-shaped: a handful of words, not a multiple of anything.** I am deliberately not
|
||
naming a single number, because I have not read the resolver and lane J has: a range with a mechanism
|
||
behind it is the honest form of this prediction.
|
||
|
||
*Falsified by:*
|
||
* **tail cost 0 with `res_no_battle = 0`** — then the resolver really has no draw on a plain battle path,
|
||
which would make lane J's "no unconditional draw" stronger than either of us expected, and would mean a
|
||
battle is free to a reimplementation;
|
||
* **a large cost (tens or hundreds of words)** — then something in the battle path draws per ship or per
|
||
round, and the resolver's three sites are not the whole story;
|
||
* **a non-zero cost that does NOT show up under `ApplyEncounterResult`** — then the draw happens in combat
|
||
proper (`RunCombatRound` / the combat server), which runs *between* `ProcessTurn` and the tail and is
|
||
hooked by nobody; the bracket residual would go positive for the first time and that is where it would
|
||
appear.
|
||
|
||
The third case is the one to watch: it is the only way this workload can produce a **non-zero residual**,
|
||
which every prediction so far has said should be 0 on a quiet turn.
|
||
|
||
|
||
---
|
||
|
||
## 8. P10 outcome — falsified, and the falsification is the result
|
||
|
||
`res_no_battle` flipped to **0** for the first time in 55 turns, the player's fleet was destroyed, and the
|
||
measured cost was:
|
||
|
||
| | words |
|
||
|---|---|
|
||
| `ApplyEncounterResult` (the battle) | **0** |
|
||
| `OnAllCombatDone_Tail` | **0** |
|
||
| `StrategyServer::ProcessTurn` | 22 |
|
||
| bracket total / residual | 22 / **0** |
|
||
|
||
P10's main claim ("greater than 0") is **wrong**, and it fell into its own first falsification branch: the
|
||
resolver really has no draw on a plain battle path. The residual stayed 0, so combat proper drew nothing
|
||
either — the third branch, the one worth watching, did not fire.
|
||
|
||
The consequence is worth more than the prediction would have been: **a reimplementation can model a
|
||
strategic turn's RNG and nothing about combat, and still reproduce the generator through a battle.** Lane
|
||
J's own cheap prediction — a plain fleet battle costs the same as a peaceful turn — holds exactly.
|
||
|
||
Caveats in `sots-re/findings/control-flow/tail-rng-ledger.md` §10.2; the short version is that this is one
|
||
auto-resolved encounter against an NPC, `Auto Resolve` may not take a manually-fought battle's path, and a
|
||
cost of 0 is the easiest number in the world to produce by accident.
|
||
|
||
---
|
||
|
||
## 9. P9 outcome — held on every clause
|
||
|
||
The game was played to turn 64.
|
||
|
||
| | predicted at turn 34 | measured at turn 64 |
|
||
|---|---|---|
|
||
| turn of the first draw | 64 | **64** |
|
||
| `np_min_life` at that turn | 0 | 0 (`np_within5` = 0, `expired` = 1) |
|
||
| `predict_words` | 1 | **1** |
|
||
| node-line decay `rng` delta | 1 | **1** |
|
||
| `OnAllCombatDone_Tail` total | 1 | **1** |
|
||
| bracket | `ProcessTurn` + 1 | 20 + 1 = **21**, residual **0** |
|
||
|
||
Not one of the four falsification branches fired. `predict_words` is computed at hook entry from the
|
||
transcribed `RemainingLife` predicate, *before* the original runs, so this is a real check of the model —
|
||
unlike the 63 preceding turns where it said 0, the measurement said 0, and nothing was checked.
|
||
|
||
**And the instrument's own weakest point was exercised on the same turn.** §4 of the finding flagged that
|
||
every live measurement so far had sat inside a single MT block, so the block-chain code had only ever run in
|
||
host tests. On turn 64 `ProcessTurn` entered with `left = 11` and left with `left = 615` — it crossed a
|
||
block boundary, the generator twisted, and the ledger reported `11 + (624 − 615) = 20`. The bracket still
|
||
reconciled to a residual of 0.
|