- determinism oracle regenerated and byte-identical (bb4fd9ac / 978041ac) - the four phase-23/33 draw-bearing tail callees run EVERY turn; the three inner functions holding the draws run zero times -- the gate is inside each outer body - CreateRaidEncounter is called (2 on one turn) and draws nothing: candidate list empty - Zuul: 7 calls / 7 words per trade-raid Chance site, 14 not 16, as predicted - EncounterDetect_Run receives an EMPTY record vector, so ProcessTeamRecord and AssignContacts never run; the 2-word detection residual is in 0x007d5150's subtree - a MinHook detour on 0x00893290 changes the game's output; bisected over six runs. The un-instrumented game and lane Z's instrument agree, so lane Z's numbers stand - lane AI1 insert: P2 held across two fresh processes, Rung B stays as written
660 lines
43 KiB
Markdown
660 lines
43 KiB
Markdown
# The RNG ledger for one strategic turn — measured, not inferred
|
||
|
||
Lane Z, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
||
Engine worktree `wip/tailrng`, `sots-engine/docs/Z-tail-rng.md` (the prediction, committed before the build).
|
||
|
||
**The question.** The milestone is *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. Lane K
|
||
(`combat-done-tail.md` §3) found two draw sites in `StrategyServer::OnAllCombatDone_Tail` that nothing models
|
||
and that both run **before** the autosave, and concluded that a reimplementation reproducing both
|
||
`ProcessTurn` functions exactly would still diverge. Nobody had measured what a turn actually costs.
|
||
|
||
**The answer, up front.** Across eight measured End Turns on two saves, a strategic turn advances the
|
||
strategic generator by **18–22 words**, *all* of it inside `StrategyServer::ProcessTurn`, and the residual
|
||
outside the two turn drivers is **exactly zero**. The tail's cost on these turns is **0**. The defect lane K found is real and
|
||
**latent**: it will bite the first turn a node line expires or a real battle resolves, and our saves reach
|
||
neither.
|
||
|
||
---
|
||
|
||
## 0. Where to start if you are building the standalone
|
||
|
||
1. **A turn costs 18–22 generator words on the reference save, and §11 says which call site spends each
|
||
one**, summing to the measured total with nothing left over on three consecutive turns. Sixteen of them
|
||
are trade-raid generation — `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290, two `Chance`
|
||
rolls per player at `TRADE_RAID_ODDS_PLAYER` (0.2) and `TRADE_RAID_ODDS_NPC` (0.05). Model that first;
|
||
it is most of a turn.
|
||
2. **Two byte-identical oracle pairs with a known RNG cost** are in `verify/results/shim/tailrng/`:
|
||
`z-t6-endturn` → `z-t6-autosave` (18 words) and `z2-endturn` → `z2-autosave` (20). Both were verified
|
||
from the file bytes independently of any hook. Test against those before any other save, because no
|
||
corpus save carries a known cost.
|
||
3. **Combat is free.** The first battle ever instrumented in this campaign moved the strategic generator
|
||
by **0** words (§10). A reimplementation can model a turn's RNG and nothing about combat.
|
||
4. **Node-line decay is not free, and it is no longer hypothetical.** It fired on turn 64 of the Zuul save
|
||
and cost exactly the 1 word lane K predicted from the instruction stream (§9). Before that turn a
|
||
reimplementation modelling only `ProcessTurn` would have been correct; on that turn it would have been
|
||
one word out and every later turn would diverge.
|
||
5. **The generator does not move between turns** (§2.1), so the interval to reproduce is closed at both
|
||
ends.
|
||
|
||
## 1. The instrument, and why it is not an RNG hook
|
||
|
||
Counting draws by hooking the primitives would have undercounted, and the campaign now knows by exactly how
|
||
much. This lane's scan of the tempering immediates found **four** draw entry points where every prior lane's
|
||
primitive set had three, and twelve functions with inlined draws. Lane J then found fourteen inlined-draw
|
||
functions; lane I re-ran the scan at real instruction boundaries and audited it site by site, and its
|
||
numbers are the ones to use: **seven entry points** and **eleven game functions with inlined draws over 28
|
||
sites**, with a brute byte scan finding zero orphans (`findings/control-flow/inlined-draws.md`).
|
||
|
||
So the honest sequence is: three → four (this lane) → seven (lane I). The fourth,
|
||
`Mars::RNG::NextUInt` 0x004f7670, is the one this lane's static work surfaced; the fifth, sixth and seventh
|
||
— including `Mars_RNG_GaussianRange` 0x008e6e30, the only **unbounded** entry point at two words per
|
||
attempt — are lane I's. Exactly **one** of the eleven inlined-draw functions is reachable from
|
||
`StrategyServer::ProcessTurn` (0x007aa240, depth 4) and one more from the tail.
|
||
|
||
**None of that changes a single number in this document**, and that is the point worth taking away. The
|
||
instrument was never built from the primitive set or from the call graph: it reads the generator's **state**
|
||
before and after a boundary and reports the difference. An inlined draw, a draw through an entry point
|
||
nobody had named, a draw through a vtable — all of them move `left`, and all of them are counted. That is why method rule 16 (*any RNG accounting built from the call graph alone is a
|
||
lower bound*) does not apply to it, and why three revisions of the primitive inventory landed underneath
|
||
this measurement without disturbing it.
|
||
|
||
The state it reads: `Mars::RNG` is
|
||
`{void* vptr; uint32 mt[624]; uint32* next; int32 left}`, `sizeof 0x9cc` — `next` at +0x9c4 is a **pointer**
|
||
into the block and `left` at +0x9c8 is the counter, and the inner primitives are entered with
|
||
`ECX = &mt[0] = RNG+4` while the outer helpers take the object base and do the `add ecx,4` themselves —
|
||
which is what lane T's "the generator is entered at `rng+4`" note was seeing. The
|
||
draw is `if (left == 0) Twist(); y = *next++; --left;` — a pre-check against **0**, never −1.
|
||
|
||
The block transform is a pure function, so the blocks a generator visits form a forward-only chain.
|
||
`RngLedger` (`sots-engine/src/shim/hooks/rng_ledger.{h,cpp}`) indexes that chain from the first block it
|
||
sees and positions any state exactly:
|
||
|
||
```
|
||
words(block, left) = block * 624 + (624 - left)
|
||
```
|
||
|
||
Differences between positions are then exact **across twists, across rejection loops, and across draws
|
||
nobody hooked**. Nine host tests pin the arithmetic, including the block boundary (`left == 0` is a real
|
||
state and must not be off by 624), a rejection loop counted against a shadow generator, and the
|
||
out-of-order case below.
|
||
|
||
**One ordering subtlety, and it is load-bearing.** `Hook<>` takes the `before` snapshot at entry but renders
|
||
it (calls the region's `describe`) only *after* the original returns — so for nested calls the inner
|
||
snapshots are rendered first, and by the time an outer `before` is rendered the chain has moved past it.
|
||
Walking a Mersenne Twister backwards is not possible. Every hook therefore **observes at entry from
|
||
`describe_args`**, which indexes the entry block while it is still current; the render then resolves it from
|
||
the memo. Without that, every outer-call `before` would read `words: null`.
|
||
|
||
Six nested trace hooks, all watching the object at `S+0x16c`:
|
||
|
||
`StrategyHost::Autosave` (both markers) › `StrategyServer::ProcessTurn` › `OnAllCombatDone_Tail` ›
|
||
`ApplyEncounterResult` (phase 6) › node-line decay (phase 11) › `ProcessNodeSpaceTravel` (runs twice a turn).
|
||
|
||
---
|
||
|
||
## 2. The ledger
|
||
|
||
Save `ref-turn2` (2-player Morrigi vs AI), four consecutive End Turns, build `z-tailrng-20260908T1314Z`,
|
||
`hooks=trace`. Words are 32-bit MT outputs consumed by the generator at `S+0x16c`.
|
||
|
||
| turn | `ProcessTurn` | tail | `ApplyEncounterResult` | node-line decay | `ProcessNodeSpaceTravel` ×2 | **bracket total** | **residual** |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 3 | **19** | 0 | 0 | 0 | 0, 0 | (incomplete — see below) | — |
|
||
| 4 | **18** | 0 | 0 | 0 | 0, 0 | **18** | **0** |
|
||
| 5 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** |
|
||
| 6 | **18** | 0 | 0 | 0 | 0, 0 | **18** | **0** |
|
||
|
||
"Bracket" is `Autosave(endTurn=1)` → `Autosave(endTurn=0)`: the pre-turn save file to the post-turn save
|
||
file, which is exactly the interval a standalone has to reproduce. The turn-3 bracket is incomplete **by
|
||
construction** and is reported rather than dropped: the pre-turn autosave of the first End Turn after a load
|
||
runs before any turn driver, so the hook has no server pointer yet and its record carries `words: null`.
|
||
|
||
A second save, `zuul-turn16-noderoute` (Zuul vs Zuul), build `z-tailrng2-20260908T1328Z`:
|
||
|
||
| turn | `ProcessTurn` | tail | `ApplyEncounterResult` | node-line decay | `ProcessNodeSpaceTravel` ×2 | **bracket total** | **residual** |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 17 | **20** | 0 | 0 | 0 | 0, 0 | (incomplete) | — |
|
||
| 18 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** |
|
||
| 19 | **22** | 0 | 0 | 0 | 0, 0 | **22** | **0** |
|
||
| 20 | **20** | 0 | 0 | 0 | 0, 0 | **20** | **0** |
|
||
|
||
**So: we consume 18–22 words per turn and model 0 of them.**
|
||
|
||
Not 0 because the modelling is bad — because *nothing in the repo models any part of a turn's RNG
|
||
consumption as a count*. B1/B3/B4 compare the generator's state around three specific functions and get it
|
||
right; no lane has ever stated a turn's total. This table is that statement.
|
||
|
||
### 2.1 The generator does not move outside the turn pipeline
|
||
|
||
Every turn's `ProcessTurn` entry position equals the previous turn's post-turn autosave position, exactly:
|
||
211 → 211, 229 → 229, 249 → 249. The UI, the renderer and the per-frame tick draw **nothing** from the
|
||
strategic generator between turns. For the standalone this is worth as much as the total: the interval it
|
||
must reproduce is closed.
|
||
|
||
---
|
||
|
||
## 3. An independent check, from the files rather than from memory
|
||
|
||
The two autosaves of the turn-6 bracket were pulled off the VM and their `Sim.RNG` blobs parsed by
|
||
`verify/save-reader/save_reader.py` (the frame's payload is 2503 bytes: `mt[624]`, then `left` as int32 at
|
||
+2496). Twisting the pre-turn block forward until it matches the post-turn block, and applying the same
|
||
position formula:
|
||
|
||
```
|
||
z-t6-endturn.sav (pre-turn) left = 375
|
||
z-t6-autosave.sav (post-turn) left = 357
|
||
twists = 0 -> words consumed between the two files = 18
|
||
```
|
||
|
||
The live ledger recorded 18 for that turn, `left` 375 → 357. **Two instruments that share no code path — one
|
||
reading process memory through a hook, one reading gzip-compressed file bytes through the save reader —
|
||
agree exactly.** Files and the checker in `verify/results/shim/tailrng/`.
|
||
|
||
**And the two do not share a hidden assumption** (the trap of method rule 8, which is live here because both
|
||
sides know how to twist an MT block). `twists = 0`: the block is byte-identical in the two files, so the
|
||
file-side number is `left_before − left_after` and involves the twist implementation **not at all**. The
|
||
agreement is therefore about the game's behaviour, not about two copies of the same algorithm agreeing with
|
||
each other.
|
||
|
||
This is the pair a standalone should be tested against first: it is a byte-identical oracle *with a known
|
||
RNG cost attached*, which none of the eleven corpus saves has.
|
||
|
||
**A second pair was produced by the later per-site run and agrees the same way.**
|
||
`z2-endturn.sav` → `z2-autosave.sav` (the turn-4 → turn-5 bracket of `ref-turn2`) gives `left` 395 → 375,
|
||
`twists = 0`, **20 words** — matching both the boundary ledger's bracket for that turn and §11's per-site
|
||
sum. So on turn 5 of `ref-turn2` **three instruments that share no code path agree on 20**: a hook reading
|
||
process memory around a phase boundary, a set of detours keyed by return address, and the two save files on
|
||
disk.
|
||
|
||
| pair | turns | `left` | words |
|
||
|---|---|---|---|
|
||
| `z-t6-endturn` → `z-t6-autosave` | 5 → 6 | 375 → 357 | **18** |
|
||
| `z2-endturn` → `z2-autosave` | 4 → 5 | 395 → 375 | **20** |
|
||
|
||
---
|
||
|
||
## 4. Lane K's inference, settled
|
||
|
||
> **§6, labelled hypothesis:** "I did not prove that `SNMAllCombatDone` is delivered on turns with no
|
||
> combat."
|
||
|
||
**The handler runs on every End Turn.** Eight out of eight, across two unrelated saves,
|
||
`OnAllCombatDone_Tail` recorded exactly one call per End Turn, at depth 0, between the two autosaves, with
|
||
the post-turn autosave following it. The determinism-note inference was right.
|
||
|
||
The stronger claim — that it runs with an *empty encounter vector* — is **not** settled by this workload and
|
||
must not be reported as though it were. On **both** saves the encounter vector is **empty at `ProcessTurn`
|
||
entry and holds exactly one encounter by the time the tail runs**, on every one of the eight turns:
|
||
detection (`ProcessTurn` phase 31) creates it, and the tail's phase 7 clears it. So what is proved is "the
|
||
tail runs on a turn with **no battle**", not "on a turn with no encounter at all". See §8 for what closing
|
||
the remaining gap needs.
|
||
|
||
Two things fall out of the same records and are worth more than the phrasing:
|
||
|
||
* **Phase 7 really is a wholesale `clear()`.** `encounters` reads **1** at the phase-6
|
||
`ApplyEncounterResult` call and **0** at the phase-11 node-line-decay call, on every turn. Lane K read
|
||
that off the instruction stream against a decompile that reads as a conditional prune; it is now also a
|
||
behavioural fact.
|
||
* **Every encounter on these turns has `res->+0x4 != 0`** (`res_no_battle = 1`), the flag that makes
|
||
`ApplyEncounterResult` a whole-function no-op. Its measured cost is 0 words, which is what that gate
|
||
predicts, and which is why the combat resolver has never run under any instrument this campaign has built.
|
||
|
||
---
|
||
|
||
## 5. `S+0x8` has a name, and it is `ModCount` — correcting `combat-done-tail.md` §7.1, lane T §0.1, `addresses.json`, and this lane's own prediction
|
||
|
||
Lane K wrote that `S+0x8` "advances **at least twice** per turn" and that "the word at `S+0x8` has never been
|
||
named" (lane T §0.1). The first is right and this lane's prediction that it advances **exactly** twice is
|
||
**wrong**. The second is now answered — by `StrategyServer::Write`'s own wire tags:
|
||
|
||
```
|
||
0079fb2f lea edx,[edi+0x08] ; push "ModCount" ; edi = S -- the same edi that indexes
|
||
0079fb40 lea eax,[edi+0x0c] ; push "Frame" ; the players vector at +0x54
|
||
```
|
||
|
||
So **`S+0x8` is `ModCount` and `S+0xc` is `Frame`**, the turn number. `ghidra/addresses.json` has the name on
|
||
the wrong word: its `StrategyServer_off_ModCount = 0x8` is the stored-frame offset of `S+0xc`, which the wire
|
||
calls `Frame` — `turn-spine.md` was right to call it that and lane T flagged the clash without being able to
|
||
settle it. The name `ModCount` belongs to the word lane T recorded as `StrategyServer_off_PhaseCounter = 0x4`.
|
||
|
||
Confirmed three ways, and the third is the satisfying one. **From the saves:**
|
||
|
||
| save | `Frame` | `ModCount` |
|
||
|---|---|---|
|
||
| turn1-state / turn2-state / turn3-state | 1 / 2 / 3 | 0 / 12 / 24 |
|
||
| z-t6-endturn / z-t6-autosave (this lane's bracket) | 5 / 6 | 50 / 62 |
|
||
| zuul-turn16-noderoute / zuul-turn23-fleet23 | 16 / 23 | 241 / 412 |
|
||
|
||
`Frame` is the turn; `ModCount` moves **+12 per turn** on the early Human game and averages **+24** on the
|
||
Zuul one. **From the live trace**, `S+0x8` at hook entry:
|
||
|
||
| save | turn | `ProcessTurn` entry | tail entry | tail's callees | increments to the next turn |
|
||
|---|---|---|---|---|---|
|
||
| ref-turn2 | 3 | 22 | 23 | 24 | **12** |
|
||
| ref-turn2 | 4 | 34 | 35 | 36 | **14** |
|
||
| ref-turn2 | 5 | 48 | 49 | 50 | **12** |
|
||
| ref-turn2 | 6 | 60 | 61 | 62 | — |
|
||
| zuul-noderoute | 17 | 253 | 254 | 255 | **16** |
|
||
| zuul-noderoute | 18 | 269 | 270 | 271 | **21** |
|
||
| zuul-noderoute | 19 | 290 | 291 | 292 | **44** |
|
||
| zuul-noderoute | 20 | 334 | 335 | 336 | — |
|
||
|
||
The live deltas (12, 14, 12 on the Human game; 16, 21, 44 on the Zuul one) sit exactly where the saves'
|
||
`ModCount` deltas say they should. The two turn drivers account for **2 of 12 to 44** increments; the rest
|
||
are spread across the turn and mostly fall between the post-turn autosave and the next `ProcessTurn`.
|
||
|
||
That is no longer a mystery to be chased — **it is what a modification counter is for**. `S+0x8` is not a
|
||
turn number, not a driver-invocation counter and not a constant per turn: it counts state mutations, so it
|
||
scales with the size of the empire, and asking "which writer is responsible" has no single answer. Lane K's
|
||
operational conclusion stands and is now explained rather than merely observed. `S+0xc` (`Frame`) reads 3, 4,
|
||
5, 6 and 17, 18, 19, 20 over the same records and is the turn counter.
|
||
|
||
**For the integrator:** this is a name collision to reconcile, not a new entry. `StrategyServer_off_ModCount`
|
||
(0x8, stored frame) and `StrategyServer_off_PhaseCounter` (0x4, stored frame) are the two words above with
|
||
their names swapped; `ghidra/addresses.d/lane-z.json` records the evidence under
|
||
`StrategyServer_wire_ModCount_vs_Frame` rather than adding a third name for either word.
|
||
|
||
## 6. Corrections to `combat-done-tail.md` §3 and §6.1
|
||
|
||
Both from the instruction stream, both load-bearing for anyone reimplementing these functions.
|
||
|
||
**§3 — the node-line fleet check does not gate the roll.** Lane K: *"The roll is skipped for a line if any
|
||
fleet with flag `0x20000` is targeting it."* The straight-line order in node-line decay's first loop is
|
||
|
||
```
|
||
0x007ae088 call NodePath::RemainingLife ; expiry test
|
||
0x007ae08f jg 0x007ae1e2 ; not expired -> next record, NO DRAW
|
||
0x007ae095 mov ecx,[esi+0x16c] ; THE DRAW
|
||
0x007ae0a5 call 0x008e6dd0 ; Mars::RNG::Chance(0.5f)
|
||
0x007ae0aa test al,al ; je 0x007ae1e2 ; roll failed -> next record
|
||
0x007ae0b2 ... ; THE 0x20000-FLEET SCAN STARTS HERE
|
||
```
|
||
|
||
The scan begins 0x1d bytes **after** the `Chance` call and is reached only when the roll *succeeded*. It
|
||
suppresses the collapse (`0x007a92e0` / `0x007a4700`), never the draw. Lane K's headline — one `NextFloat`
|
||
per expired node line per turn — survives intact and is now pinned to a formula.
|
||
|
||
**§6.1 — `StrategyHost::Autosave` is `ret 8` and returns a value.** Its epilogue is `c2 08 00`, and
|
||
`0x00895b5c mov eax,esi` puts the `std::string*` (the MSVC named-return slot) in EAX. A hook declaring it
|
||
`void` drops EAX at both call sites. Neither call site passes `this`: both hardcode `mov ecx,0xb29f98`.
|
||
(The `+0x54` candidate on that global was recorded live and is **not** the `StrategyServer` — `server_agrees`
|
||
is false on all eight autosave records, so the global that the autosave uses is a different object from the
|
||
`StrategyHost` whose `+0x54` `OnMessage` reads.)
|
||
|
||
### 6.1 The expiry predicate, now concrete
|
||
|
||
`NodePath::RemainingLife` 0x006e2130, `__thiscall(NodePath*, int turn)`, `ret 4`, whole 122-byte body read:
|
||
|
||
```c
|
||
if (npt(+0x04) == 0) return INT_MAX; // permanent line, never expires
|
||
if (npdtn(+0x1c) == INT_MAX) return INT_MAX; // immortal line
|
||
aged = (npctm(+0x14) >= 0 && turn >= npctm) ? turn - npctm : 0;
|
||
wear = (npdtf(+0x20) != INT_MAX && npdtf > 0) ? nptf(+0x24) / npdtf : 0; // SIGNED idiv
|
||
rem = npdtn - wear - aged;
|
||
return rem > 0 ? rem : 0; // callee-side clamp
|
||
```
|
||
|
||
A line is expired exactly when this returns 0. **The lifetime is derived, never ticked** — the function
|
||
writes nothing, and neither does the loop around it — so there is no decrement-ordering question and a
|
||
snapshot at function entry is a valid prediction basis. `nptf` is never sign-checked, so the division's
|
||
signed truncation must be reproduced literally.
|
||
|
||
`Chance` 0x008e6dd0 returns **false with no draw** when `p <= 0` and **true with no draw** when `p >= 1`;
|
||
`0.5f` takes neither, so it is exactly one word, and the comparison is `p > r` (equality returns false).
|
||
A NaN `p` falls through both early-outs and *does* draw — irrelevant here, noted because it is the kind of
|
||
edge a reimplementation gets wrong.
|
||
|
||
---
|
||
|
||
## 7. One generator, confirmed twice
|
||
|
||
Static: the image has one persistent strategic `Mars::RNG`, at `S+0x16c`, constructed by the
|
||
`StrategyServer` ctor 0x007d78d0 (`push 0x9cc` + `Seed(0)` at 0x007d7d25/0x007d7d3d) and reseeded only from
|
||
`Read` and `LoadGame`. `StrategyClient+0x134` and `Mars::CombatSim+0x108` exist but are unreachable from the
|
||
turn roots; three more are stack temporaries in map generation. **The combat resolver draws from the same
|
||
`S+0x16c` object** — all three RNG entry points in its 750-node direct-call closure load `[reg+0x16c]`.
|
||
|
||
Behavioural: across **64 ledger observations over eight turns on two saves**, every state resolved on a
|
||
single forward chain — no `words: null`, no second chain. If a second generator had been in play, the ledger
|
||
would have said so by construction rather than by anyone noticing.
|
||
|
||
---
|
||
|
||
## 8. What is not settled, listed as loudly as the results
|
||
|
||
* ~~The combat resolver has never run under an instrument.~~ **It has now, once — see §10. It cost 0
|
||
words.** What remains unsettled is everything a single auto-resolved encounter cannot speak for; §10.2
|
||
lists it.
|
||
* ~~Node-line expiry did not fire.~~ **It fired on turn 64 — §9.1.** What is still open about it is in
|
||
§9.3.
|
||
* ~~`FUN_00893290` is unidentified.~~ **Identified — §11.1.** What is still open there is whether a
|
||
*successful* raid roll costs a further word (0 or 1, undetermined on a workload where none succeeded).
|
||
* **A turn with a genuinely empty encounter vector was not observed** (§4). Both saves produce exactly one
|
||
sighting encounter on every turn. The tail-runs-every-turn claim is settled; the no-encounters variant is
|
||
still an inference, now a much narrower one.
|
||
* ~~`players` reads 8 on a 2-player save, and may be the `S+0x64` bug again.~~ **Resolved, and the flag was
|
||
my own error.** The offset is right and so is the count. `StrategyServer`'s base-class ctor 0x0085b120
|
||
(entered with `ecx = S+4`) zero-initialises four consecutive vectors as three-word triples with the fourth
|
||
word skipped — `+0x40/+0x50/+0x60/+0x70` raw, 0x10 apart, allocator-last — which enumerates the players
|
||
triple as literally `{S+0x54, S+0x58, S+0x5c}` with no frame arithmetic at all, and puts the fleets vector
|
||
at `S+0x64` exactly where B4 measured it. Five NPC accessors at 0x00788de0ff bounds-check an index against
|
||
`([S+0x58] − [S+0x54]) >> 2` and then index `_Myfirst`, which is a third confirmation.
|
||
|
||
**The vector is not the lobby's player list.** It is `#empires + one rebel-AI per distinct empire species
|
||
+ 4 NPC pseudo-players` (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony — all species
|
||
4). `Sim.NumPlrs` reads **8** in the Human saves (two species) and **7** in every Zuul save (one species),
|
||
against a `Summary.Players` array with **2** entries in both. Both numbers are right; they count different
|
||
things.
|
||
|
||
**And my draft of this document was wrong about my own data.** It said the hook read 8 "on both saves". It
|
||
did not: the trace reads 8 on `ref-turn2` and **7** on `zuul-turn16-noderoute`, matching each save's
|
||
`NumPlrs` exactly. I generalised from one run without re-reading the other, and it took a check aimed at
|
||
something else to catch it.
|
||
* **Which of the 12-to-44 `S+0x8` increments per turn come from where** (§5), and what they scale with.
|
||
* The direct-call sweeps behind "node-line decay's only RNG site is the `Chance(0.5f)`", "its downstream
|
||
pair draws nothing" and "`ProcessNodeSpaceTravel` draws nothing" are **worth less than they look**, and
|
||
method rule 16 (landed by lane J while this run was in flight) says why: an inlined draw leaves no
|
||
call-graph edge at all, so a call sweep is a lower bound. Rule 17 applies too — those sweeps clipped at
|
||
Ghidra's reported function sizes.
|
||
|
||
**The behavioural measurement is what carries these claims, not the sweeps.** `ProcessNodeSpaceTravel`
|
||
moved the generator by 0 words on **16** observations (twice per turn, eight turns) and node-line decay by
|
||
0 on **8**. That evidence is immune to both rules, because it does not ask which function drew — it asks
|
||
whether the generator moved.
|
||
* **No Guard region is declared by any hook in this family, so nothing here can report an undeclared
|
||
write.** That is deliberate — these hooks make no claim about game state at all, and a guard over the
|
||
generator would only duplicate the Result region that already covers the whole object — but it means the
|
||
usual harness-audit safety net is absent by design. Both traces show `err = 0`, `undeclared = 0` on 32
|
||
records each; the second number is vacuous and should be read that way.
|
||
* **The one record with no ledger position is the first pre-turn autosave of each session**, which runs
|
||
before any turn driver and therefore before the hooks know the server pointer. It declares no region at
|
||
all rather than declaring one it cannot fill. Every record that *did* declare the region resolved: 0
|
||
`words: null` across 64 records.
|
||
* **The ledger's block-chain machinery has never run live.** Every observation in both runs sat inside a
|
||
single MT block — `left` walked 432 → 413 → 395 → 375 → 357 on `ref-turn2` and 263 → 243 → 223 → 201 → 181
|
||
on the Zuul save, never reaching 0. So every live word count reduces to `left_before − left_after`, and the
|
||
twist-and-index path that makes the instrument correct across block boundaries is exercised **only by the
|
||
host tests**. A turn that crosses a boundary (any turn spending more words than `left`) is the first real
|
||
test of it. This is the thinnest part of the instrument and the one to watch.
|
||
* Everything here is one game state per save, two saves, eight turns, with **158 words** of generator
|
||
movement in total (192 → 267 and 361 → 443). It is a thin workload measured precisely, not a broad one.
|
||
In particular the per-turn total moved only between 18 and 22 across eight turns: the *variation* is barely
|
||
sampled, and nothing here says what makes it 18 rather than 22.
|
||
|
||
## 9. Node-line expiry — a distance, not an absence
|
||
|
||
Lane O's `zuul-turn16-noderoute.sav` was pushed to the VM and played forward. Phase 11 draws one word per
|
||
**expired** node line; rather than report "we ran N turns and it never fired", the hook was extended to
|
||
classify the whole `NodePath` population at entry, using the same `RemainingLife` formula the original
|
||
tests. The classification is what makes the negative result usable:
|
||
|
||
| turn | node paths | permanent (`npt == 0`) | immortal (`npdtn == INT_MAX`) | **mortal** | min remaining life | ≤ 5 | expired → words |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 17 | 53 | 51 | 0 | **2** | 40 | 0 | 0 |
|
||
| 18 | 54 | 51 | 0 | **3** | 42 | 0 | 0 |
|
||
| 19 | 56 | 51 | 0 | **5** | 41 | 0 | 0 |
|
||
| 20 | 57 | 51 | 0 | **6** | 43 | 0 | 0 |
|
||
|
||
Three things follow, none of which was knowable before:
|
||
|
||
1. **51 of the 53 node lines on this map can never expire** — `npt == 0` takes `RemainingLife`'s first
|
||
early-out. The static map's node network is not a decay candidate at all. Only *dug* lines are, which is
|
||
why this is a Zuul save: the mortal count rises by roughly one per turn as the Zuul dig.
|
||
2. **Every mortal line is ~40 turns from expiry**, and the population's minimum stays in a 40–43 band while
|
||
new lines are added at full life. So the first phase-11 draw on this save is **tens of turns away**, not
|
||
one or two — and it is reachable, which "we saw nothing" would not have told anyone.
|
||
3. It explains why the campaign never noticed: no save in the corpus is within 40 turns of a decay event,
|
||
and the ones that could get there are the newest saves in it.
|
||
|
||
### 9.1 The prediction, and the turn it came true
|
||
|
||
Because `min_life` was falling by exactly 1 per turn — 43, 42, 41 … 30 at turn 34, with the traffic term
|
||
contributing nothing on this map — a numeric prediction became possible, and it was committed to
|
||
`sots-engine/docs/Z-tail-rng.md` §6 at turn 34 with the run still in flight:
|
||
|
||
> **P9. The first phase-11 draw happens on turn 64, and costs exactly 1 word.** Node-line decay records
|
||
> `np_min_life = 0`, `predict_words = 1`, and a measured `rng` delta of 1; the tail's total becomes 1
|
||
> instead of 0; the bracket total becomes `ProcessTurn + 1`.
|
||
|
||
The game was played to turn 64. **Every clause held.**
|
||
|
||
| turn | node paths | mortal | min life | ≤5 | expired | `predict_words` | node-decay words | **tail words** | `ProcessTurn` | bracket | residual |
|
||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||
| 62 | 64 | 13 | 2 | 1 | 0 | 0 | 0 | 0 | 18 | 18 | 0 |
|
||
| 63 | 64 | 13 | 1 | 1 | 0 | 0 | 0 | 0 | 18 | 18 | 0 |
|
||
| **64** | 64 | 13 | — | 0 | **1** | **1** | **1** | **1** | 20 | **21** | **0** |
|
||
|
||
**That is the first non-zero tail cost this campaign has ever recorded**, and it is exactly the draw lane K
|
||
found by reading 0x007ae095. The defect lane K warned about is no longer latent, no longer inferred and no
|
||
longer a hypothesis: **on turn 64 of this save, a reimplementation that models `ProcessTurn` perfectly and
|
||
stops would have written an autosave one generator word out of step, and every subsequent turn would
|
||
diverge.**
|
||
|
||
`predict_words` is computed at hook entry, *before* the original runs, from the same `RemainingLife`
|
||
predicate transcribed in §6.1. It said 1; the measurement said 1. That is a real check of the model — as
|
||
opposed to the 63 preceding turns, where it said 0 and the measurement said 0, which checked nothing and was
|
||
reported that way.
|
||
|
||
### 9.2 The twist path ran, live, on the same turn
|
||
|
||
The instrument's thinnest part (§8: "the block-chain machinery has never run live") was exercised on this
|
||
very turn. `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` words. Sixty-three turns
|
||
of measurements had all sat inside a single block, so every earlier word count reduced to a subtraction; this
|
||
one did not, and the bracket still reconciled to a residual of 0.
|
||
|
||
### 9.3 What is still not settled about node lines
|
||
|
||
* **One expiry, on one map.** 51 of the 64 lines are permanent; the 13 mortal ones are Zuul-dug. A
|
||
non-Zuul game may never produce a mortal line at all.
|
||
* **The `0x20000`-fleet gate has never been exercised**, because it only matters when the roll *succeeds*
|
||
and no fleet was riding this line. Whether the roll succeeded here is not visible in a word count — the
|
||
draw costs 1 either way, which is the whole point of §6's correction.
|
||
* **Two expiries on one turn has never been observed** (`np_within5` read 1, never 2), so "one word per
|
||
expired line" is confirmed for *one* line and extrapolated for two.
|
||
|
||
|
||
---
|
||
|
||
## 10. A real battle, measured — and it costs nothing
|
||
|
||
The turn-54 End Turn of the long run stopped on an **Encounter at Gallandro**: the player's five ships
|
||
(3 DE Colonizer, 2 DE Armor) against a **Von Neumann**. That is the workload §8 said did not exist and lane
|
||
J's `combat-resolver.md` asked for — every encounter in 54 turns until this one had `res->+0x4` set, making
|
||
`ApplyEncounterResult` a whole-function no-op. **Auto Resolve** was chosen (the dialog's four options are
|
||
Fight Manually / Auto Resolve / Fight Manually If Opponent Does / Retreat), and the prediction was committed
|
||
to `sots-engine/docs/Z-tail-rng.md` §7 with the dialog still on screen and unclicked.
|
||
|
||
| turn | `res_no_battle` | `ApplyEncounterResult` words | tail words | `ProcessTurn` words | bracket | residual |
|
||
|---|---|---|---|---|---|---|
|
||
| 52 | 1 | 0 | 0 | 18 | 18 | 0 |
|
||
| 53 | 1 | 0 | 0 | 18 | 18 | 0 |
|
||
| 54 | 1 | 0 | 0 | 16 | 16 | 0 |
|
||
| **55** | **0** | **0** | **0** | 22 | **22** | **0** |
|
||
|
||
**P10 predicted a non-zero tail cost and was wrong.** The first battle this campaign has ever instrumented
|
||
moved the strategic generator by **zero words**, and the bracket residual stayed 0 — so combat proper
|
||
(`RunCombatRound` / the combat server, which run between `ProcessTurn` and the tail and are hooked by
|
||
nobody) drew nothing either. Every one of the turn's 22 words was inside `StrategyServer::ProcessTurn`, just
|
||
as on a peaceful turn.
|
||
|
||
That is the *strong* form of lane J's reading. Lane J established from the instruction stream that the
|
||
resolver has **no unconditional draw** — its three sites are a node-cannon `NextInt`, an inlined `NextFloat`
|
||
per back-engineering candidate, and a `NextInt` per successful roll of that. This run shows that on an
|
||
ordinary encounter **none of the three fires**, and lane J's own cheap prediction — *a plain fleet battle
|
||
should cost the same as a peaceful turn* — holds exactly.
|
||
|
||
### 10.1 Why this matters to the standalone
|
||
|
||
A reimplementation that models a strategic turn's RNG and **nothing about combat** reproduces the generator
|
||
correctly through a battle. Combat's effect on the save is entirely in the state it writes, not in the
|
||
generator it advances. That is a much cheaper milestone than "read the 7,641-byte resolver first", and it
|
||
was not knowable before this run: the honest prior was lane K's "draw counts are entirely combat-dependent
|
||
and unknown".
|
||
|
||
### 10.2 What one battle does not settle — and it is a lot
|
||
|
||
* **One encounter, auto-resolved.** `Auto Resolve` may not take the same path as a manually fought battle;
|
||
the tactical engine has its own `Mars::CombatSim` generator at `sim+0x108` (§7) which nothing here
|
||
watches. A manually fought battle is a different experiment and has still never been run.
|
||
* **The opponent was a Von Neumann**, an NPC pseudo-player, not a rival empire's war fleet. No node cannon
|
||
was present, so R1 could not fire; whether R2's salvage roll was skipped because no candidate had a
|
||
non-zero salvage slot, or because the arm was not reached at all, is not distinguishable from a word
|
||
count of 0.
|
||
* **A cost of 0 is the easiest number to produce by accident.** It is exactly what a hook that compared
|
||
nothing would report. The reasons to believe it here are that the same hook reported 18–22 for
|
||
`ProcessTurn` on the same turn, that `res_no_battle` flipped to 0 for the first time in 55 turns on
|
||
exactly the turn the battle happened, and that the player's fleet was destroyed — the battle demonstrably
|
||
occurred. It is still one observation.
|
||
* **No `EVENT_*` or state-side check was made.** These hooks declare the generator and nothing else, so
|
||
this says the battle was RNG-free and says nothing about whether it was *computed* correctly.
|
||
|
||
---
|
||
|
||
## 11. The per-call-site ledger — every word of a turn, attributed
|
||
|
||
§2 said *where* a turn's words are spent (all inside `ProcessTurn`); this says *which call site* spends
|
||
them. The seven generator entry points are detoured and each call records
|
||
`__builtin_return_address(0)` — the game instruction after its own `call` — with the word cost taken from
|
||
`left` before and after (`sots-engine/src/shim/hooks/draw_sites.{h,cpp}`; report tool
|
||
`tools/rng_site_report.py`). This is attribution, not discovery: lane I closed the search space at seven
|
||
entry points and 22 sites in `ProcessTurn`'s closure.
|
||
|
||
Three consecutive End Turns on `ref-turn2`, build `z-sites2-20260908T1432Z`:
|
||
|
||
| call site | owner | entry point | calls/turn | words/turn |
|
||
|---|---|---|---|---|
|
||
| 0x0050329d | `FUN_00503200`+0x9d ← `DetectEncounters` (lane I, depth 4) | `NextFloat` | 1 | 1 |
|
||
| 0x007929a4 | `FUN_00792750`+0x254 ← `DetectEncounters` (lane I, depth 3) | `NextInt` | 1 | 1 |
|
||
| 0x00587888 | `TechTree::ProcessResearch`+0x1c8 | `NextFloat` | 0–1 | 0–1 |
|
||
| 0x0088df4f | `ServerPlayer::RollResearchEvent`+0x2f (lane T) | `NextFloat` | 0–1 | 0–1 |
|
||
| **0x00893426** | **`FUN_00893290`+0x196** | **`Chance`** | **8** | **8** |
|
||
| **0x00893513** | **`FUN_00893290`+0x283** | **`Chance`** | **8** | **8** |
|
||
|
||
| turn | site sum | `ProcessTurn`, measured independently | residual |
|
||
|---|---|---|---|
|
||
| 3 | **19** | 19 | **0** |
|
||
| 4 | **18** | 18 | **0** |
|
||
| 5 | **20** | 20 | **0** |
|
||
|
||
**Nothing is unattributed, on any of the three turns.** The two instruments share no code path — one reads
|
||
`left` around a boundary and reconstructs an absolute position, the other reads `left` around a single call
|
||
and keys on a return address — and they agree word for word. The 18–20 spread that §2 could only report is
|
||
now *explained*: it is the two optional research draws, both of which are gated.
|
||
|
||
### 11.1 The dominant consumer is trade-raid generation, reached by a virtual call
|
||
|
||
**16 of every turn's 18–20 words are `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290** —
|
||
`ServerTradeManagerImpl` vftable 0x00a31b74 **slot 10**, `thiscall(this, vector<TeamRecord>*)`, `ret 4`.
|
||
It loops over `StrategyServer::Players` and rolls, per player, up to three `Mars::RNG::Chance` calls on the
|
||
strategic generator:
|
||
|
||
| site | probability | StrategyVar | image default | fired |
|
||
|---|---|---|---|---|
|
||
| +0x196 (0x00893426) | player raid | `TRADE_RAID_ODDS_PLAYER` | **0.2f** | 8/8 |
|
||
| +0x283 (0x00893513) | NPC raid, gated on `0.0f < S->+0x1a0` (player-independent, so all-or-nothing per turn) | `TRADE_RAID_ODDS_NPC` | **0.05f** | 8/8 |
|
||
| +0x33e (0x008935ce) | refugee raid, gated on a subsystem manager being present | `TRADE_RAID_ODDS_REFUGEE` | **0.05f** | 0/8 |
|
||
|
||
All three probabilities are strictly inside (0,1), so `Chance` takes neither early-out and spends exactly
|
||
one word — which is why the measurement is 1 per call. **No back-edge in the function contains any of the
|
||
three sites**, so one word per player per site is a hard bound, not an observation. The records vector it
|
||
is handed is the same 0x74-stride `TeamRecord` vector lane I's `EncounterDetect_Run` receives.
|
||
|
||
**Why no static sweep found it.** There are **zero** direct `call rel32` targets equal to 0x00893290 in the
|
||
image, and exactly one dword `0x00893290` in `.rdata` — at 0x00a31b9c, vftable + 0x28. The edge is
|
||
|
||
```
|
||
007d845d mov ecx,[esi+0x158] ; S->tradeManager
|
||
007d8463 mov eax,[ecx] ; vptr
|
||
007d8465 mov edx,[eax+0x28] ; slot 10
|
||
007d8469 call edx ; <-- 0x00893290, VIRTUAL
|
||
007d8470 lea ecx,[ebp-0x30] ; ... ; call 0x007cb080 <-- lane I's EncounterDetect_Run, DIRECT
|
||
```
|
||
|
||
inside `StrategyServer::DetectEncounters`, which `ProcessTurn` calls directly. Lane I's closure **entered
|
||
this very function and followed the direct call one instruction later**; it could not follow the virtual
|
||
one, and said so. So this is not a hole in lane I's work — the tempering-immediate scan's recall claim is
|
||
intact and its 22-site list is explicitly a direct-edge closure. It is the demonstration that the caveat
|
||
was load-bearing: **the largest single RNG consumer of a strategic turn hangs off a virtual edge inside a
|
||
function the closure already contained.**
|
||
|
||
The repo had also already met this function without knowing what it cost:
|
||
`findings/subsystems/strategic-turn-internals.md` line 153 lists 0x00893290 as "raid encounter generation"
|
||
against these exact three StrategyVars. What was missing was the connection to the ledger.
|
||
|
||
**Ghidra's size is wrong again** (method rule 17): the real body ends at 0x0089389a, 1546 bytes, and
|
||
Ghidra's 1532 lands mid-instruction — the same failure lane I hit on 0x007aa240.
|
||
|
||
**One live cost is not yet bounded.** A *successful* roll calls slot 17,
|
||
`ServerTradeManager::CreateRaidEncounter` 0x008938a0, which draws a `NextInt` at 0x008939ee to pick a
|
||
target — but returns without drawing when the candidate list is empty. So a success costs **0 or 1 further
|
||
word**. On these three turns it cost 0, which is consistent either with no roll succeeding (≈11% on the
|
||
image defaults, so three quiet turns in a row is unremarkable) or with an empty candidate list every time.
|
||
A word count cannot separate those, and separating them is the cheapest experiment left on this path.
|
||
|
||
> **SETTLED BY LANE H, 2026-09-08 — and two corrections to this paragraph**
|
||
> (`findings/control-flow/tail-probes.md` §4). An entry counter on 0x008938a0 recorded **2 calls on
|
||
> Zuul turn 24 and 1 on turn 25, all drawing 0 words**: the rolls *do* succeed and **the candidate
|
||
> list is empty**. A successful raid roll costs 0 further words on our corpus.
|
||
> (a) "three quiet turns in a row is unremarkable" understates it by three orders of magnitude —
|
||
> 11% is the probability of a quiet *turn* (`0.8^8 × 0.95^8`), so three in a row is 0.14%, about
|
||
> 1 in 720. (b) The caller list below is **incomplete**: `0x007d5150`, the other subtree of
|
||
> `DetectEncounters`, also dispatches slot 17, so an entry count of 2 is two slot-17 calls from one
|
||
> of two known sites, not two trade-raid successes.
|
||
|
||
### 11.2 Two bookkeeping corrections the raw numbers need
|
||
|
||
The shim's raw totals are 35 / 34 / 36, not 19 / 18 / 20, and both differences are accounting rather than
|
||
measurement — `tools/rng_site_report.py` applies them and shows its working:
|
||
|
||
* **Helper-internal rows double-count.** `Chance` calls `NextFloat` internally, so its 16 words appear
|
||
twice: once on the `Chance` rows and once on a row whose return address (0x008e6e04) is *inside*
|
||
`Chance`'s own body. The report subtracts any row landing inside another entry point's body.
|
||
* **Other generators are not this generator.** 8 calls per turn come from `FUN_00578cf0`, `FUN_005798e0`
|
||
and `FUN_0069dbb0` drawing on a **different `Mars::RNG` instance** — the `StrategyClient`'s at
|
||
`client+0x134` (§7). They are real draws and they are correctly excluded: they never touch the strategic
|
||
generator the save serialises. The first version of this instrument did not distinguish them and reported
|
||
44 words against a bracket of 18, which is what caught it.
|
||
|
||
The second point is worth keeping: **a per-site RNG ledger that does not identify which generator each draw
|
||
came from is not a ledger.** The boundary instrument was immune to this by construction because it watches
|
||
one object; the site instrument had to be told.
|
||
|
||
### 11.3 What the site ledger did not see
|
||
|
||
* **`EncounterDetect_AssignContacts` never ran** on these three turns — its gate in
|
||
`EncounterDetect_ProcessTeamRecord` (some team-record member must have `+0xfc != 0`) was not satisfied.
|
||
Its hook recorded no call, so lane I's one inlined site in `ProcessTurn`'s closure contributed **0**,
|
||
which is consistent with the sums reconciling exactly. **The inlined-draw path is therefore still
|
||
unexercised**, and if it fires on some other save the site sum will fall short of the bracket by exactly
|
||
its cost — which is how it will announce itself.
|
||
|
||
> **CORRECTED BY LANE H, 2026-09-08** (`tail-probes.md` §6). The gate is not the reason. Lane H
|
||
> hooked `EncounterDetect_ProcessTeamRecord` itself: it recorded **zero calls** over five End Turns
|
||
> on two saves, while an entry probe on `EncounterDetect_Run` recorded **one call per turn**. Run
|
||
> calls ProcessTeamRecord once per record, so **the record vector it receives is empty** — the
|
||
> `+0xfc` gate is never evaluated because there is nothing to evaluate it on. Lane I's
|
||
> `|contacts| × |detectors|` bound is untouched and still has nothing to bound.
|
||
>
|
||
> The same lane found where the two detection words really are, and it is not this subtree:
|
||
> `DetectEncounters` → **0x007d5150** (one call site, at 0x007d860b) → 0x0078cc70 → 0x00503200
|
||
> (`NextFloat` 0x0050329d) and → 0x00792750 (`NextInt` 0x007929a4). Every edge on that path has a
|
||
> single caller. It is one word per player that passes a per-player gate, and on both corpus saves
|
||
> exactly one player passes — so "2 per turn" is a coincidence of these saves, not a constant.
|
||
* Three turns of one save. The two `Chance` sites fired 8/8 every turn with no variation, so nothing here
|
||
says what makes them fire *fewer* times, and `FUN_00893290`'s own gating is unmeasured.
|
||
|
||
> **LANE H, 2026-09-08.** The free prediction at the end of §11.3 was run and **held exactly**: on
|
||
> `zuul-turn23-fleet23` (7-entry player vector) both sites read **7 calls / 7 words**, for **14**
|
||
> instead of 16, on two consecutive turns. The loop iterates `StrategyServer::Players` and nothing
|
||
> else. Lane H also found that **hooking 0x00893290 itself perturbs the game** (`tail-probes.md`
|
||
> §2) — but the *un-instrumented* game reproduces this document's `z2-autosave.sav` byte for byte,
|
||
> so the lane-Z instrument is behaviour-neutral and every number here stands.
|
||
|
||
**A free prediction, for whoever runs this next.** If the 8 calls per site are one per entry of the server's
|
||
player vector, then on any **Zuul** save — where that vector holds **7**, not 8 (§8) — the two sites should
|
||
cost **14** words per turn instead of 16, and the turn total should drop by 2 for the same activity. The
|
||
Zuul runs in §9 were made with the earlier build and carry no site table, so this is untested; it is the
|
||
cheapest available check on what `FUN_00893290` iterates, and it does not require identifying the function
|
||
first.
|