FUN_00893290 is ServerTradeManager::GenerateTradeRaidEncounters -- ServerTradeManagerImpl vftable slot 10 -- rolling Chance(TRADE_RAID_ODDS_PLAYER = 0.2) and Chance(TRADE_RAID_ODDS_NPC = 0.05) once per player. Both are strictly inside (0,1) so each is exactly one word, and no back-edge contains either site, so one word per player per site is a hard bound. Why no sweep found it: zero direct calls to it exist in the image and its only reference is a vtable slot. The dispatch is a "call edx" through slot 10 at 0x007d8469 inside DetectEncounters -- one instruction before the DIRECT call that lane I's closure did follow. Lane I's inventory is not wrong; its stated caveat about indirect edges was load-bearing, and this is what it was hiding. strategic-turn-internals.md line 153 had already named 0x00893290 "raid encounter generation" against these exact StrategyVars. What was missing was that it is where a turn's RNG goes. Ghidra's size is wrong again: real body 1546 bytes ending 0x0089389a, reported 1532, ending mid-instruction. Rule 17, third time.
40 KiB
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
- 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::GenerateTradeRaidEncounters0x00893290, twoChancerolls per player atTRADE_RAID_ODDS_PLAYER(0.2) andTRADE_RAID_ODDS_NPC(0.05). Model that first; it is most of a turn. - Two byte-identical oracle pairs with a known RNG cost are in
verify/results/shim/tailrng/:z-t6-endturn→z-t6-autosave(18 words) andz2-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. - 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.
- 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
ProcessTurnwould have been correct; on that turn it would have been one word out and every later turn would diverge. - 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
SNMAllCombatDoneis 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().encountersreads 1 at the phase-6ApplyEncounterResultcall 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 makesApplyEncounterResulta 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:
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. -
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).FUN_00893290is unidentified. -
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.
-
Resolved, and the flag was my own error. The offset is right and so is the count.playersreads 8 on a 2-player save, and may be theS+0x64bug again.StrategyServer's base-class ctor 0x0085b120 (entered withecx = S+4) zero-initialises four consecutive vectors as three-word triples with the fourth word skipped —+0x40/+0x50/+0x60/+0x70raw, 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 atS+0x64exactly where B4 measured it. Five NPC accessors at 0x00788de0ff bounds-check an index against([S+0x58] − [S+0x54]) >> 2and 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.NumPlrsreads **8** in the Human saves (two species) and **7** in every Zuul save (one species), against aSummary.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-turn2and 7 onzuul-turn16-noderoute, matching each save'sNumPlrsexactly. I generalised from one run without re-reading the other, and it took a check aimed at something else to catch it. - 4 NPC pseudo-players
-
Which of the 12-to-44
S+0x8increments 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 "ProcessNodeSpaceTraveldraws 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.
ProcessNodeSpaceTravelmoved 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 = 0on 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: nullacross 64 records. -
The ledger's block-chain machinery has never run live. Every observation in both runs sat inside a single MT block —
leftwalked 432 → 413 → 395 → 375 → 357 onref-turn2and 263 → 243 → 223 → 201 → 181 on the Zuul save, never reaching 0. So every live word count reduces toleft_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 thanleft) 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:
- 51 of the 53 node lines on this map can never expire —
npt == 0takesRemainingLife'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. - 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.
- 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 measuredrngdelta of 1; the tail's total becomes 1 instead of 0; the bracket total becomesProcessTurn + 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_within5read 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 Resolvemay not take the same path as a manually fought battle; the tactical engine has its ownMars::CombatSimgenerator atsim+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
ProcessTurnon the same turn, thatres_no_battleflipped 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.
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.
ChancecallsNextFloatinternally, so its 16 words appear twice: once on theChancerows and once on a row whose return address (0x008e6e04) is insideChance'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_005798e0andFUN_0069dbb0drawing on a differentMars::RNGinstance — theStrategyClient's atclient+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_AssignContactsnever ran on these three turns — its gate inEncounterDetect_ProcessTeamRecord(some team-record member must have+0xfc != 0) was not satisfied. Its hook recorded no call, so lane I's one inlined site inProcessTurn'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.- Three turns of one save. The two
Chancesites fired 8/8 every turn with no variation, so nothing here says what makes them fire fewer times, andFUN_00893290's own gating is unmeasured.
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.