sots-engine/docs/Z-tail-rng.md

16 KiB
Raw Blame History

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.