sots-re/findings/control-flow/gate-indexed-rng-audit.md
alex 48db3cc425 lane AG: the gate-indexed RNG audit -- 31 unread-gate rows, 6 new draw sites, the SVSO generator named
One row per statically reachable draw site in BeginProcessTurn u ProcessTurn u
OnAllCombatDone_Tail, each carrying a measured firing or a decoded predicate on
save fields plus the 22-save corpus count. No total is reported: 31 rows (30
distinct sites plus one grouped SVSO row) are unread gates, and that visible
blank column is the deliverable.

Three gates decoded:
  0x0082cdb8 = the addiction spread roll (Chance ADDICTION_SPREAD_ODDS 0.2f);
    corpus 0 of 22 -- nadct is 0 on all 28 systems of all 22 saves. Writer found:
    ServerSystem_BeginAddiction has exactly two callers, both inside vslot15, so
    an INTER-EMPIRE route plus a species addict tech must seed it first.
  0x0088dc43 = the spy counter-mission roll, p = cmo accumulating 0.2f per turn,
    so Chance costs 0 words from the fifth turn on; corpus 0 of 22 by deat/cm.
  0x0088b613 = the raid target pick. RNG_NextInt has no zero-bound early-out, so
    a single-candidate list still draws -- which proves lane AC's B returned
    before the draw. Failed conjunct named: the raider sat on sector 832 while
    all four rt records source in 816/816/768/752. Next workload: sector 816.

Six draw sites no firing-indexed table could hold: five behind
CreateRaidEncounter's empty-candidate-list gate, and 0x00820c1b again from
Slot13RngCalleeB -- so B costs 2 words on a success, not 0.

SVSO generator closed: all 17 drawing overrides use GetGame()->+0x16c.
Rule 17 again: ServerSpyManager_vslot14 really ends at 0x0088dd2b, not 0x0088dd03.
2026-09-08 22:25:52 -04:00

48 KiB
Raw Blame History

The gate-indexed RNG audit — one row per statically reachable draw site, and every blank cell visible

  • Type: control-flow (static; no measurement taken, no VM used)
  • Owner / date: lane AG · 2026-09-09 · host + dumps/sots.exe only
  • Instrument: objdump -b binary -m i386 -M intel over dumps/sots.exe, decoded from real instruction boundaries to the next function start, never to Ghidra's reported size (rule 17); call graph rebuilt from scratch (E8 rel32 + E9 rel32 landing on a function start + lane V2's pinned virtual edges); corpus counted with verify/save-reader/save_reader.py --dump over the 22 saves in verify/results/saves/
  • Created by: findings/resolutions/2026-09-09-tail-draws.md §8 item 3 — "the gate-indexed audit: one table, every reachable draw site, firing-or-predicate, corpus count"
  • Corrects (rule 11): lane V2 §4.3 caution (a) — closed; lane AC §8's "three rt route records" — it is four in ac-turn22-raider.sav; lane Z §11.1 / lane H §4 on CreateRaidEncounter's cost — a success costs far more than "0 or 1 further word"
  • Ghidra is single-user and this lane did not enter it. Everything here is read from the image on the host with the campaign's own tools; nothing was written back. The ReVa MCP server was down (CONNECTION_CLOSED) for the whole lane and the HTTP fallback was not needed.

0. What this table is, and what the old one was

Lane Z's ledger is indexed by firing: a site enters it when a state we happened to run reaches it. Such a table grows only by luck, and it has now been qualified twice in two days (L1's hive draw, AC's raid roll). This table is indexed by gate: one row per site the code can reach, whether or not any state we own reaches it. Each row carries either a measured firing or a decoded predicate on save fields with the corpus counted against it. A row with neither is an unread gate and is listed as one in §6.

No total is reported. Thirty-one rows are unread gates (thirty distinct draw sites — 0x0081351c is reachable from both drivers and appears twice — plus one grouped row covering sixteen SVSO overrides whose sites were not individually enumerated). A "complete" number over this table would repeat exactly the mistake the lane exists to fix.

Three results the firing-indexed table could not have produced:

  1. Six draw sites nobody had listed, all behind ServerTradeManager::CreateRaidEncounter's empty-candidate-list gate (§5.1). Lane H measured that function entered 3 times drawing 0 words; the ledger therefore recorded one site (0x008939ee) and could not see the five past it.
  2. A seventh unlisted site inside Slot13RngCalleeB's own subtree — 0x00820c1b, a NextFloat in FUN_00820af0 (§5.2). Lane AD's committed prediction should say B costs 2 words on success, not 1.
  3. The SVSO generator is named (§4): all seventeen SVScriptObject overrides that can draw use GetGame()->+0x16c — the strategic generator, the one the save serialises. V2 §4.3's standing blank is closed.

1. Method, stated so it can be repeated and attacked

tools:   scratch scripts over dumps/sots.exe + dumps/functions.json (41,089 starts)
edges:   every E8 rel32; every E9 rel32 whose target is a function start (lane V2 §5's rule);
         plus pinned virtual edges: OnAllCombatDone_Tail -> the eleven phase-23/33 vtable targets
         (V2 §3), DetectEncounters -> GenerateTradeRaidEncounters (slot 10, lane Z),
         GenerateTradeRaidEncounters | 0x007d5150 -> CreateRaidEncounter (slot 17, lane H)
roots:   StrategyServer::ProcessTurn 0x007dc6c0  (closure 1,541)
         StrategyServer::OnAllCombatDone_Tail 0x007d92a0 (closure 1,710)
sites:   every call whose target is one of the SEVEN RNG entry points (lane I §4)
inlined: lane I's image-wide tempering-immediate scan, recall proven complete over all 67
         immediate occurrences -- reused, not repeated

Rule 16 is satisfied by reuse, not by a new sweep, and that is deliberate. Lane I's scan is image-wide with proven complete recall (all 67 occurrences of both tempering immediates fall inside decoded instructions; a brute byte scan finds nothing extra). Re-running it could only reproduce it. What lane I's result says here is: exactly two inlined draw sites lie in the two turn drivers' closures — 0x007aa3b6 in EncounterDetect_AssignContacts (ProcessTurn, depth 4) and one in 0x007a7f30 (Tail, depth 3) — and the other twenty-six sites in nine functions belong to map setup, the lobby screen, the strategy network client/server and two combat encounters, reachable from neither driver (V2 §4.2 resolved all nine ancestries).

One correction to how the brief framed rule 16. The spy detection roll at 0x00887c8a is not an inlined MT draw. 0x008e6dd0 is Mars::RNG::Chance, a real entry point, and 0x00887c8a is a plain E8 call to it that any call-graph sweep sees. What is true — and is AC's actual point — is that the call sits in ServerSpyManager::vslot13's own body rather than inside P (0x008408e0), so an entry probe on P cannot see it. That is an instrument-choice trap, not a recall trap, and the fix is lane Z's return-address ledger. Both traps are real; they are different.

Two site kinds excluded from every row, for the reason lane Z §11.2 gives. 0x008e6e04 (the NextFloat inside Chance) and 0x0047d8a6 (the NextFloat inside FloatRange) are helper-internal: their words are already charged to the caller's row. Counting them is the double-count that made the first version of lane Z's site instrument report 44 words against a bracket of 18.

What the sweep cannot see, said plainly. Indirect edges other than the pinned ones; call [reg] through function-pointer members; and any draw in a subtree entered only through an unpinned vtable slot. Every reachability claim below is therefore a lower bound, exactly as lane I's and V2's were.


2. The table

Status key: M = measured firing with the configuration it was measured at · P = decoded predicate on save fields with the corpus counted · U = unread gate, the audit's own to-do list. "words" is per call unless stated. NF/NI/Ch/NU/FR = NextFloat / NextInt / Chance / NextUInt / FloatRange.

2.1 StrategyServer::BeginProcessTurn — outside both turn drivers

site container entry st firing, or predicate + corpus count
0x0052770f SVSOSwarmQueen::RegisterHives NI M 2 words on turn1-state.sav turn 1→2 (two hives created), 0 on turn 2→3; lane L1, VM140, shim.cfg.l1hive, oracle reproduced. One word per new hive, plus an unmeasured rejection loop (bound 10 ⇒ mask 15 ⇒ E[1.45] words/hive)

2.2 StrategyServer::ProcessTurn

site container entry st firing, or predicate + corpus count
0x00893426 GenerateTradeRaidEncounters Ch M 8 calls / 8 words per turn on ref-turn2 (8 players); 7/7 on zuul-turn23-fleet23 (7 players) — lane Z §11, lane H. One per entry of StrategyServer::Players, TRADE_RAID_ODDS_PLAYER 0.2f, no back edge
0x00893513 same Ch M same, TRADE_RAID_ODDS_NPC 0.05f, gated on 0.0f < S->+0x1a0 (player-independent)
0x008935ce same Ch U 0 of 8 turns. Gated on a subsystem manager being present (TRADE_RAID_ODDS_REFUGEE). Gate not decoded to a save field
0x0050329d FUN_00503200 ← DetectEncounters→0x007d5150 NF M 1 word/turn on both corpus saves — but lane H: one word per player passing a per-player gate, and exactly one player passes on both. The gate itself is unread; "1/turn" is a coincidence of two saves (rule 20)
0x007929a4 FUN_00792750, same subtree NI M same, same caveat
0x0088df4f ServerPlayer::RollResearchEvent NF M 0–1 words/turn, unconditional per player that reaches it (lane T); the 18↔20 spread lane Z reported is this site and 0x00587888
0x00587888 TechTree::ProcessResearch NF M 0–1 words/turn
0x005878bb same NF U never in the ledger. Second research draw; branch condition not decoded
0x007aa3b6 EncounterDetect_AssignContacts (inlined) NF M 0 words, 5 End Turns, 2 saves — and lane H found why: EncounterDetect_ProcessTeamRecord is called zero times, so the record vector EncounterDetect_Run receives is empty. The +0xfc gate is never evaluated. Cost is `Σ_c min(T_c,
0x0088a08f ServerPlayer::RollResearchAccident NF U absent from the ledger on 3 turns. Top-of-function gates not decoded
0x00889e40 same NI U "
0x00889f86 same NI U "
0x00889fd6 same NI U "
0x0088a1bd same → FloatRange FR U " (1 word; the two-narrowing float model is pinned in sots-engine)
0x0088a20f same → FloatRange FR U "
0x00889c33 FUN_00889bb0 ← RollResearchAccident NI U "
0x00758966 ServerSystem::ProcessRebellion Ch U absent from the ledger. Per system; gate not decoded
0x0074fe21 FUN_0074fbe0 ← rebellion Ch U "
0x00753d87 FUN_00753c60 ← rebellion Ch U "
0x007564b4 FUN_00756350 ← rebellion Ch U "
0x00747fa8 FUN_00747f50 ← MoveFleet NI U "
0x00747ffd same NI U "
0x007b677c ProbabilisticJump NF U unconditional per call, and the call is per fleet whose current waypoint is type 5. No corpus save is known to contain a node jump; the waypoint-type predicate was not decoded here
0x007b67e7 same NU U the second word, on the non-arrival branch: [dest->+0x58->+0x158] < float(unit × [+0x154]). Neither field is on the wire as far as this lane checked
0x0081351c FUN_008134e0 (also in Tail) Ch U absent from the ledger on every measured turn; gate not decoded
0x008939ee CreateRaidEncounter NI M entered 2× on Zuul turn 24, 1× on turn 25, 0 words every time (lane H). The function returns at 0x008938fa when the candidate vector FUN_0083b110 builds is empty — that gate is before this draw. Rule 20's case study
0x0088f1eb TradeManager_SpawnEncounterSquadron ← 0x00892640 ← CreateRaidEncounter NI U NEW (§5.1). Past the empty-list gate; never reached in any measurement
0x0088f356 same NI U NEW. "
0x0083b460 FUN_0083b1e0 ← SpawnEncounterSquadron NI U NEW. "
0x00848fd9 FUN_00848e50 ← 0x00892640 NI U NEW. "
0x00820c1b FUN_00820af0 ← 0x0082ce00 ← 0x00892640 NF U NEW. " — and the same site is reached from the Tail through Slot13RngCalleeB (§5.2)

2.3 StrategyServer::OnAllCombatDone_Tail

site container entry st firing, or predicate + corpus count
0x00820e18 TradeManager::Slot13RngCalleeA ← trade vslot13 NF M 1 word/turn on ac-turn22-raider lineage, 0 on lane L3's turn-15 control; lane AC, VM141, probes=11 (not byte-neutral — the number is entry-attributed and stands, the turn totals do not). Predicate: a fleet parked on a TradeSector node with Pos bit-equal, owner's bit set in tscr, tsct/species-4 ⇒ FtFlg & 0x800. Corpus: 1 of 22 (ac-turn22-raider; ac-turn20-raider has the fleet arriving)
0x0088b613 TradeManager::Slot13RngCalleeB NI P §3.3. Predicate: A succeeded and ∃ a route whose trfr endpoint resolves to the raider's own sector and whose tro owner stands at relation ≤ 0 to the raider. Corpus: 0 of 22 — and the failed conjunct is named: AC's raider sat on sector TradeID 832, whose member systems are indices 13/23/0/3; the four routes' trfr are 18/16/14/20, in sectors 816/816/768/752. B ran and returned before the draw
0x00820c1b FUN_00820af0 ← Slot13RngCalleeB NF P NEW (§5.2). Same predicate as 0x0088b613, then NOT (owner species == 1 OR arg-3 species test). Corpus: 0 of 22. Its existence changes B's cost model from 1 word to 2
0x007ae0a5 StrategyServer::NodeLineDecay (phase 11) Ch M 1 word on Zuul turn 64 (lane Z §9) — one per node line whose lifetime expired, p = 0.5f, neither Chance early-out
0x007bb69b FUN_007bb530 ← ApplyEncounterResult (phase 6) NI U node-cannon path (EVENT_NODECANNON_FLINGS/_KILLS). Combat-dependent; combat measured 0 strategic words on the one battle ever instrumented (lane Z §10), which is a statement about that battle
0x00852ec7 FUN_00852d30 ← 0x007a0540 ← 0x007a7f30 (phase 6) NI U salvage / back-engineering special project (EVENT_SPRJBACKENG_UNLOCKED). Same caveat
(inlined) 0x007a7f30 CombatResolve_SalvageBackEng (phase 6) NF U lane I's site #8, the second confirmed rule-16 instance; combat-dependent
0x0082cdb8 ServerTradeManagerImpl::vslot15 Ch P §3.1 — this is the addiction-spread roll. Predicate: ∃ an rt route with trfrs == 0, trtos == 0, whose trfr system has nadct > 0. Corpus: 0 of 22 — nadct is 0 on all 28 systems of all 22 saves (616 system records). Words = Σ over such routes of that system's non-zero addiction slots
0x0088dc43 ServerSpyManager::vslot14 Ch P §3.2 — the spy counter-mission roll. Predicate: ∃ a spy with deat != 0, target system owned, target not independent (hindi false), and cm ∈ {1,2,3,4}. Corpus: 0 of 22 — the only three saves with a spy all read deat 0, cm 0, cmo 0. p = cmo and Chance costs 0 words once cmo ≥ 1
0x00887c8a ServerSpyManager::vslot13 (call in the caller's body) Ch U AC decoded the chain around it: sdet == -1 arm, gated on FUN_00743f80(system) & 1. Reached only after deat != 0. Corpus: 0 of 22 by deat; the 0x00743f80 conjunct itself is not decoded to a save field
0x008409c7 SpyManager::Slot13RngCallee (P) NI U past deat != 0, sdet != -1, turn − sdet ≥ 3, system owned, owner ≠ spy's owner. Corpus: 0 of 22. The inner gate on the NextInt is unread
0x00840929 same Ch U " (the false-flag draw, 0.25f/0.75f)
0x00840a3c same Ch U "
0x0081351c FUN_008134e0 (also in ProcessTurn) Ch U as above

2.4 SVSO script-object overrides — reachable from both drivers when S+0x1b4 is non-null

Seventeen overrides can reach a draw (V2 §4.3: slot 4 × 11, slot 11 × 4, slot 25 × 2). All seventeen draw on GetGame()->+0x16c — §4. The whole surface is guarded by mov edi,[esi+0x1b4]; cmp edi,ebx; je, and lane T and lane K both recorded S+0x1b4 as null in a normal game.

row st firing, or predicate + corpus count
SVSOSlaversRefuel::UpdateDifficultyTier 0x00515820, two +0x16c sites M 0 words on 50 consecutive End Turns (lane L1 §5.1), entered every turn, two stores at frames 2 and 50 exactly as predicted. The draws sit behind the gate that never opened
the other sixteen U Predicate not decoded, corpus not counted. The one save-visible conjunct that is known — SvSctOb carrying a live script object of that class — is where the value-domain census reports 16 constants under Sim.SvSctOb.EncObj: no corpus game ever triggered a random encounter. So the corpus count is 0 by construction for any predicate resting on those fields, and the rows stay unread rather than negative

2.5 Sites lane I found that are in neither driver

Nine functions, twenty-six inlined sites: 0x004b1f20 (CombatNetworkClient slot 3), 0x00507ac0 (CrowRuinsEncounter slot 11, 12 sites), 0x005232a0 (SwarmEncounter slot 11), 0x006ec720 / 0x006f65f0 / 0x006f7890 (StrategyNetworkServer slots 3/4/7), 0x0079f7d0 and 0x007c4140 (StrategyNetworkClient slots 3/6), 0x007c2fa0 (StrategyLobbyScreen slot 3, 4 sites). Reachable from no turn driver over direct + tail-jump edges; V2 §4.2 named every ancestry. Row status: outside the interval, not unread — the interval this audit covers is BeginProcessTurn → post-turn autosave, and lane Z measured the generator as motionless outside it.


3. The three unread gates, decoded

3.1 0x0082cdb8 — it is the addiction spread roll, and its seed path draws nothing

ServerTradeManagerImpl::vslot15 0x0082cca0 — phase 23 call 8, dispatched at 0x007d9804. Ghidra's size 334 is right here; the body ends at 0x0082cdf0. Read in full:

routes = this->+0x3c .. +0x40                     ; the SERIALISED route vector -- see below
for i in 0 .. count-1:
    rt = routes[i]                                                        (bounds-checked, else 0)
    if (BYTE[rt+0x25] == 0) continue                                      ; G1
    pFrom = players[rt->trfow]   pTo = players[rt->trtow]                 ; via [this+4]+0x50 = S+0x54
    if (rt->trtos == 0 && pTo != pFrom):                                  ; --- BLOCK 1, NO DRAW ---
        sysTo = systems[rt->trto]                                         ; via GetGame()->+0x44
        for k in 0..6:
            if ((pFrom->[0x348 + 4k] >> 4) & 1)  BeginAddiction(sysTo, k)
    if (rt->trfrs != 0) continue                                          ; G2
    if (rt->trtos != 0) continue                                          ; G3
    sysFrom = systems[rt->trfr]   sysTo = systems[rt->trto]
    for k in 0..6:                                                        ; --- BLOCK 2 ---
        if (sysFrom->addiction[k] != 0)                                   ; G4
            if (Chance(GetGame()->rng, *ADDICTION_SPREAD_ODDS))           ; <<< 0x0082cdb8, 1 WORD
                BeginAddiction(sysTo, k)

How every tag was pinned, so this can be checked rather than believed.

  • this+0x3c/+0x40 is the route vector and this+0x1c/+0x20 the sector vector, from ServerTradeManagerImpl::Write 0x0082cb60, whose this is the sub-object at +0x4c (vtable_map.py who 0x0082cb60 → 0x00a31b64 +0x4c slot 2): it writes NumTradeSectors from [this−0x30] = obj+0x1c, SctSize from [this−0x44] = obj+0x08, and rt frames from [this−0x10] = obj+0x3c. The base ctor 0x00841bf0 zeroes four 0x10-byte vectors at +0x0c/+0x1c/+0x2c/+0x3c and loads +0x08 from TRADE_SECTOR_SIZE (image default 7.0).
  • BYTE[rt+0x25] != 0 is exactly the condition Write uses to decide whether to emit an rt record (0x0082cc3d, byte-for-byte the same test as 0x0082ccd4). So G1 is not merely satisfiable — it is satisfied by every rt in every save, by construction.
  • Game::TradeRoute field offsets are the campaign's own recovered layout (objects/layouts.md, grade verified): +0x04 tro, +0x08 trfow, +0x0c trfr, +0x10 trfrs, +0x14 trtow, +0x18 trto, +0x1c trtos, +0x20 trtc. FUN_00841cd0 settles what trfr/trfrs mean: if (rt->trfrs == 0) endpoint = GetGame()->systems[rt->trfr] else endpoint = registry_lookup(S+0x84, rt->trfrs). trfr is a system index; trfrs is a non-system endpoint id, 0 meaning "use the index".
  • system+0x1e4 .. +0x1ff is the int[7] addiction table serialised as nadct (count of non-zero entries) then sparse (ads = index, adt = value) pairs — findings/objects/struct-recovery.md. FUN_00745e40(system, k) returns system->addiction[k] != 0 (its 0/1/2/3 phase computation from ADDICTION_PHASE2_START = 10 and ADDICTION_PHASE3_START = 15 is discarded by the setne al the caller reads).
  • *ds:0x00af0c50 → 0x00af0c4c is the StrategyVar ADDICTION_SPREAD_ODDS, image default 0.2f, resolved from its GlobalConst static-init stub (the M1 loader, loader-prototypes.md). 0.2 takes neither Chance early-out, so the site costs exactly one word per evaluation.
  • FUN_0074ef70 = ServerSystem::BeginAddiction(k): refuses if the system is unowned, if bit 5 of owner->[0x348+4k] is set, or if the slot is already stamped; otherwise posts EVENTSUM_ADDICTION_OUTBREAK / EVENTMSG_ADDICTION_OUTBREAK and writes system->addiction[k] = GetGame()->Frame. It draws nothing.

Predicate on save fields, and the corpus counted against it

Words = Σ over rt records with trfrs == 0 ∧ trtos == 0 of nadct on the system at index trfr.

save rt records trfrs/trtos systems with nadct > 0 words
human-turn11-spytechs 1 0 / 0 0 of 28 0
human-turn15-spyprogram 1 0 / 0 0 of 28 0
ac-turn20-raider 3 0 / 0 0 of 28 0
ac-turn22-raider 4 0 / 0 0 of 28 0
the other 18 saves 0 — 0 of 28 0

Corpus count: 0 of 22. The failed conjunct is G4 — not the route container, which four saves have, and not trfrs/trtos, which are 0 on all nine route records. No system in the corpus is addicted to anything.

And the writer is found (rule 28 practice 3), which is the interesting part. ServerSystem::BeginAddiction has exactly two callers, both inside vslot15 itself — BLOCK 1 and BLOCK 2. So the only way any system becomes addicted is this one function, and BLOCK 2 cannot run until BLOCK 1 has seeded it. BLOCK 1 needs:

  • a route with trtos == 0 whose trfow != trtow — an inter-empire trade route; every route in the corpus has trfow == trtow == 1, so BLOCK 1 has never run either;
  • the from owner holding bit 4 of player->[0x348 + 4k].

player+0x348 is written by ServerPlayer::RebuildSpeciesTechFlags 0x0082bf10 (already named in addresses.json): for each of the seven species k it reads eight tech ids from SpeciesDef[k]+0x78 .. +0x94, tests each with TechTree_HasResearched, and sets or clears the corresponding bit. InitTable 0x005453a0 fills SpeciesDef+0x88 — bit 4 — from FUN_0053b620, the per-species addict tech table, whose out-of-range arm logs "Species %s: No addict tech.":

species k 0 1 2 3 4 5 6
addict tech id 0x2799 0x279a 0x279b 0x279c 0xC5 = none 0xC5 = none 0x279d

(0xC5 = 197 is the image's "no tech" sentinel; these are the 10000-based TechID enum, not wire techIds, so tools/techid_table.py does not name them.)

Recipe for whoever holds a guest next, named from the failed conjunct and not from the nearest container: research a species' addict tech (bit 4), then establish a trade route between two different empires (trfow != trtow) — BLOCK 1 then stamps the destination system's addiction slot with no draw, and on the following turn any route out of that system rolls 0x0082cdb8 once per addicted slot. Building more trade routes inside one empire, which is what the corpus has four of, can never do it.

3.2 0x0088dc43 — the spy counter-mission roll, with an accumulating probability

ServerSpyManager::vslot14 0x0088db80 — phase 33 call 10, dispatched at 0x007d989b. Rule 17 bites again: Ghidra reports 387 bytes, ending at 0x0088dd03, which is inside the function. The real body ends at 0x0088dd2b, followed by a five-entry jump table at 0x0088dd2c.

for each spy in this->+0x10 .. +0x14:
    if (spy->deat == 0) continue                                          ; G1
    sysT = registry_lookup(S+0x84, spy->deat)      ; the TARGET system
    sysO = registry_lookup(S+0x84, spy->sown)
    if (ServerSystem_GetOwner(sysT) == NULL) continue                     ; G2
    if (sysT->+0x1c8 != 0) continue                ; hindi -- independent ; G3
    if (spy->spyon != 0 && spy->spyon != ownerId(sysT)):
        <reset>; spy->sdo = 0.0f; spy->sdet = -1                          ; owner changed
    if (spy->cm != 0):
        if ((unsigned)(spy->cm - 1) > 3) continue                         ; G4: cm in {1,2,3,4}
        if (Chance(GetGame()->rng, spy->cmo))      ; <<< 0x0088dc43
            <mission complete: FUN_00888870, advance stage>
        else
            spy->cmo = (float)(spy->cmo + 0.2)     ; ds:0x009e20d8, a WIDENED 0.2f (rule 23)
    else:
        if (spy->ncp is non-empty) continue
        if (GetGame()->Frame - spy->tdep < 3) continue
        spy->spyon = ownerId(sysT); spy->spyat = ...
        <fall through to the stage machine>
    <stage machine: switch(spy->cm) 0->1, 1->(owner->CnTrd ? 2 : 3), 2->3, 3->4, 4->0;
     spy->cmo = (cm ? 0.2f : 0.0f)>                ; ds:0x009e1fc0 = 0.2f

Cost model, which matters more than the predicate. cmo starts at 0.2f and gains 0.2f per failed turn. Mars::RNG::Chance draws nothing when p ≥ 1 (lane I §4). So one counter-mission stage costs at most 4 words and then succeeds for free on the fifth turn:

turn of the stage 1 2 3 4 5
cmo 0.2 0.4 0.6 0.8 ≥ 1.0
words 1 1 1 1 0

A reimplementation that models Chance without the p ≥ 1 early-out is one word out of step on every fifth turn of every counter-mission.

Predicate and corpus count

∃ a spy with deat != 0 and cm ∈ {1,2,3,4}, whose deat system is owned and has hindi == false.

field across the corpus
nspy 1 in human-turn15-spyprogram, ac-turn20-raider, ac-turn22-raider; 0 in the other 19
deat 0 in all three spy records
cm 0 in all three
cmo 0 in all three
sdet −1 in all three
hindi true on exactly 1 of 28 systems, in 10 of 22 saves — so G3 is not the binding conjunct

Corpus count: 0 of 22. The failed conjunct is G1 (deat == 0) and, one step behind it, cm == 0. This agrees exactly with the value-domain census, which lists deat, cm, cmo, cbh, sdo among the 234 fields that have only ever held one value; the count is therefore 0 by construction, not by inspection.

What lane AS needs, named from the failed conjunct. deat is the spy's target system handle and it is not spyon. Both spyon and cm are written by vslot14 itself once deat is set and Frame − tdep ≥ 3 with ncp empty — so the whole counter-mission machine bootstraps from deat alone. That makes deat's writer the single question worth answering before the workload is built; this lane did not find it (§7).

One caution for lane AS. cm's stage machine consults owner->+0xff — the field AC identified as CnTrd (FTL Economics) — when advancing 1 → 2 or 1 → 3. A spy program run without FTL Economics takes a different branch of the machine and reaches a different stage sequence.

3.3 0x0088b613 — the raid target pick, and why B cost 0 words when it fired

TradeManager::Slot13RngCalleeB 0x0088b440, called at 0x0088f042 immediately after A returns true. Ghidra's 1,323 is right (body ends 0x0088b974).

if (fleet == 0) return
if (FUN_0080ec50(fleet)) fleet->Pos = sector->Pos          ; snap
sector = StarFleet_GetLocationAsTradeSector(fleet); if (!sector) return
flag  = FUN_0080ecf0(fleet)                                ; see below -- ALWAYS FALSE here
cand  = {}
for each rt in this->+0x3c..+0x40:
    if (rt == 0) continue
    if (rt->trfr < 0 || rt->trfr >= count(this->+0x0c..+0x10)) continue   ; G_B1a
    if (((void**)this->+0x0c)[rt->trfr] != sector) continue               ; G_B1b
    if (flag) { if (rt->tro != fleet->owner->id) continue }               ; unreachable arm
    else {
        owner = registry_lookup(S+0x84, rt->tro); if (!owner) continue    ; G_B2
        if (PlayerAlliances_Relation(raider->+0x28, &raider[0x168], owner->+0x28) > 0) continue  ; G_B3
    }
    cand.push_back(rt)
if (cand.empty()) return                                                  ; G_B4
n = cand.size()
bound = n - 1
target = cand[ RNG_NextInt(GetGame()->rng, &bound) ]       ; <<< 0x0088b613, >= 1 WORD
if (!FUN_00820af0(this, &out, target, fleet)) return       ; <<< draws again, 0x00820c1b (§5.2)
fleet->Pos = out
if (!flag) for each player p with p->+0x5c != 4 and (sector->tssec >> p->+0x28) & 1: <post news>

RNG_NextInt has no zero-bound early-out. Read in full (0x004271c0, 139 bytes, Ghidra's size correct): it smears *bound down into a mask and then enters the draw loop unconditionally, rejecting while (y & mask) > *bound. With *bound == 0 the mask is 0, the first word is accepted, and the call costs exactly one word. So a single-candidate list still draws.

That settles what lane AC measured. AC saw Slot13RngCalleeB entered once on the turn A succeeded and the tail delta was 1 word, all of it A's. Since B cannot reach 0x0088b613 without spending a word, B returned before the draw, and the only early return past the sector lookup is G_B4: the candidate list was empty. That is the failed conjunct, and it is not a container that anyone had been staring at.

Why the flag arm is dead on this path. FUN_0080ecf0(fleet) returns fleet is at a TradeSector ∧ ¬(runtime flag 0x80000) ∧ TradeSector_PlayerTradesHere(sector, owner) ∧ ¬(FtFlg & 0x800). But vslot13's G3 (TradeSector_FleetMayRaid 0x0080ed50, AC §2.1) requires the opposite on the last two: if PlayerTradesHere is true it demands FtFlg & 0x800, and if it is false the flag computation returns false at its own second test. Any fleet that reaches B through vslot13 therefore has flag == false, deterministically — so the diplomacy branch is the live one and the "raid your own routes" branch is unreachable from the tail.

Corpus count, and the arithmetic that names the next workload

Read out of ac-turn22-raider.sav — the only corpus state where A has ever succeeded:

sector TradeID tsct member systems (indices) fleets present
752 2 1, 10, 20, 24, 17 tsflt 6352
768 2 2, 7, 14, 21, 22, 27 tsflt 5712
784 0 4, 8, 12, 25 —
800 0 6, 11, 5 —
816 2 9, 15, 16, 18, 19, 26 tsflt 2736 (the AI's Freighters), 1010
832 0 13, 23, 0, 3 tsflt 3744 — AC's raider

The four routes' trfr are 18, 16, 14, 20 → sectors 816, 816, 768, 752. None is 832. So on the one state in which A has ever fired, B's candidate list was empty because the raider was parked on the one sector in the game that no trade route is sourced in.

G_B3 passes on that state, so it is not competing for blame. PlayerAlliances_Relation 0x006d2050 is already decoded in the campaign (lane T2): 3 = allied and self, 2 = non-aggression, 1 = cease-fire, 0 = war, and the mask bit is the player's index field. B rejects a route when the relation is > 0, i.e. only a route whose owner you are at war with may be raided — and self returns 3, which is the second, independent reason the "raid your own routes" arm is dead. In ac-turn22-raider.sav both player 0 (the raider) and player 1 (tro = 32, the route owner) carry AL = NA = CF = 0, so the relation is 0 = war and G_B3 passes. Only G_B1b failed.

Corpus count: 0 of 22.

The next workload, named from the failed conjunct. From ac-turn22-raider.sav, move the raider from sector 6 (TradeID 832) to sector 5 (TradeID 816). Then:

  • vslot13 G2 ✓ (fleet at a TradeSector node), G3b ✓ (tscr = 253, bit 0 = the human player), G3c ✓ — tsct = 2 is bit 1, the AI's bit, so TradeSector_PlayerTradesHere(sector, player 0) is false and no FtFlg & 0x800 is required, exactly as on sector 832;
  • G4 after arrival (Pos bit-equal, two End Turns);
  • A rolls; on success B finds two candidates (trfr 16 and 18 both lie in sector 816), draws NextInt(bound = 1) — mask 1, no rejection possible, exactly 1 word — then FUN_00820af0 draws a NextFloat unless the species short-circuit fires (§5.2);
  • free rider: sector 816 also holds the AI's Freighters fleet, which AC read statically as doubling the raid odds — the first state that can test that arithmetic.

Committed prediction, so this is falsifiable: on that state, OnAllCombatDone_Tail costs 1 word on a turn A fails and 3 words on a turn A succeeds (A 1 + B 1 + 0x00820af0 1), with B entered exactly on the succeeding turns.

The one thing this decode rests on that was not proven. this+0x0c is a 0x10-byte vector indexed by rt->trfr, which FUN_00841cd0 proves is a system index, and whose elements B compares for pointer identity against a TradeSector*. The only coherent reading is a per-system "containing trade sector" table, and TradeManager_SpawnEncounterSquadron's caller uses the same lookup and then reads ->+0x4, which for a TradeSector is its TradeID (that is the field Write emits under the TradeID tag). This lane did not find the code that fills +0x0c. If it is instead a sector-indexed vector of six, then trfr ∈ {14,16,18,20} fails the bounds check G_B1a on every corpus route and 0x0088b613 is unreachable in principle. The two readings are distinguished by exactly the experiment above: parking on sector 816 makes the draw fire under the first reading and not under the second. Whoever runs it should report the outcome as a verdict on this paragraph, not only as a word count.


4. The SVSO generator — named, and it is the strategic one

Lane V2 §4.3 left this open: "the draws are at depth 2–4 and the generator arrives as an argument, so which generator these use is not established."

It is GetGame()->+0x16c — StrategyServer's own generator, the one the save serialises — for all seventeen overrides V2 lists as reaching a draw.

Method: rebuild the direct+tail-jump closure of each of the seventeen slot-4/11/25 targets, find every mov r32,[r32+0x16c] in it, and read back where the base register came from.

the seventeen how the generator arrives
CrowDefenders s11, CrowRuins s11, Ortgay s11, Swarm s4, SwarmQueen s25, SlaversRefuel s4 call GetGame (0x00578050) immediately before the +0x16c load, in 0x005108c0 / 0x00518340 / 0x004fb280 / 0x00515c20 / 0x0051ad70 / 0x00515820
HiverInvasion s4, HolyLands s4, JewelsOfTheCrown s4, ProgressionWars s25, PuppetMaster s4, Sots s4, SystemKiller s4, UpstartApes s4, VonNeumann s11 through the two global draw wrappers FUN_00579500 (NextInt) and FUN_00579ae0 (NextFloat), which both do mov ecx,0xb29f98; call 0x005f6450 — the inlined body of GetGame — then [eax+0x16c]
Tournament s4 FUN_0054a550, whose esi carries +0x44/+0x48 (the systems vector) — the StrategyServer itself
CivilWar s4 FUN_00554f40 → call GetGame; mov ecx,eax; call 0x00786200 / 0x00786230, and those read [ecx+0x16c] before Mars_RNG_GaussianRange

GetGame 0x00578050 is mov ecx,0xb29f98; jmp 0x005f6450 and 0x005f6450 is mov eax,[ecx+4]; ret — i.e. *(void**)0x00b29f9c, a singleton whose +0xc is the frame and +0x16c the generator. Lane L1 measured SVSOSwarmQueen::RegisterHives — which reaches its generator by the same GetGame()->+0x16c — moving the strategic word position, and the values it produced matched two historical saves. That is an independent live confirmation of this static reading.

Two false positives worth recording so the next lane does not repeat them. A +0x16c load is not evidence of a generator on its own: FUN_006a8650 and FUN_0074d1c0 use +0x16c/+0x170 as a std::vector begin/end pair (sub eax,[esi+0x16c]; sar 2). Image-wide, 145 functions contain a [reg+0x16c] load; the generator claim has to come from what the pointer is used for.


5. Sites the firing-indexed table structurally could not contain

5.1 Five draw sites behind CreateRaidEncounter's empty-candidate-list gate

ServerTradeManager::CreateRaidEncounter 0x008938a0 builds a candidate vector with FUN_0083b110 and returns false at 0x008938fa when it is empty — before every draw in the function. Lane H measured it entered 2× on Zuul turn 24 and 1× on turn 25, 0 words each time, which is the campaign's own case study for rule 20. What the ledger could then record was one site (0x008939ee). Past the gate there are five more, in a subtree nobody has enumerated:

CreateRaidEncounter 0x008938a0
  @0x008939ee  NextInt                                        (known)
  @0x00893a25 -> 0x00892640
       -> TradeManager_SpawnEncounterSquadron 0x0088f070
              @0x0088f1eb NextInt        @0x0088f356 NextInt
              -> 0x0083b1e0  @0x0083b460 NextInt
       -> 0x00848e50          @0x00848fd9 NextInt
       -> 0x0082ce00 -> FUN_00820af0    @0x00820c1b NextFloat

Each NextInt carries its own rejection loop, so the cost of one successful raid encounter is unbounded above and is certainly not "0 or 1 further word".

Correction, rule 11. tail-rng-ledger.md §11.1 says "a success costs 0 or 1 further word", and lane H's insert reads "a successful raid roll costs 0 further words on our corpus". The second is right about the corpus and neither is right about the code: a success whose candidate list is non-empty costs at least six draws, and the standalone must model that before the first raid encounter is ever created. Both sentences should gain a pointer here.

5.2 0x00820c1b — a second draw inside Slot13RngCalleeB's own subtree

FUN_00820af0(TradeManager*, Vector3* out, TradeRoute* rt, ...) computes the interception point along the raided route:

owner = registry_lookup(S+0x84, rt->tro)
a = systems[rt->trfr]->Pos   b = systems[rt->trto]->Pos
dist = |b - a| - 0.0
if (owner->+0x5c == 1)                       frac = 1.0     ; species 1
else if (FUN_0081a2d0(arg3) == 1)            frac = 1.0
else if (FUN_0053baf0(SpeciesDef(...)))      frac = 1.0
else  frac = 0.0 + (1.0 - 0.0) * NextFloat() ;               <<< 0x00820c1b, 1 WORD
out = a + dir * (frac*dist + <double @0x009e20a0>)

It is called from two places: 0x0082cf65 (inside the CreateRaidEncounter subtree, §5.1) and 0x0088b62c — Slot13RngCalleeB, immediately after the target pick. So B's cost on success is 1 (NextInt) + 1 (NextFloat), minus the species short-circuit.

This should change lane AD's committed predictions before its build. The resolution's §4 lists "B costs 0 on every success" as a prediction to commit; on this reading B costs 2 on a success with a non-empty candidate list, and 0 only because AC's list was empty. AD's probe is worth running exactly as briefed — it just needs the third hypothesis in the sheet.


6. The audit's own to-do list — the blank cells, listed

Thirty-one rows are unread gates — thirty distinct draw sites plus the grouped SVSO row. They are the value of this table; the firing-indexed one could not show them.

group rows what is missing
ServerPlayer::RollResearchAccident 0x00889dc0 and its subtree 7 (0x0088a08f, 0x00889e40, 0x00889f86, 0x00889fd6, 0x0088a1bd, 0x0088a20f, 0x00889c33) lane I recorded "all behind top-of-function gates" and nobody has decoded them. Highest-value unread group — it is per player, in ProcessTurn, and would fire on an ordinary save
the rebellion subtree 4 (0x00758966, 0x0074fe21, 0x00753d87, 0x007564b4) per system; no corpus save has ever had a rebellion
MoveFleet / ProbabilisticJump 4 (0x00747fa8, 0x00747ffd, 0x007b677c, 0x007b67e7) needs a fleet on a node line (waypoint type 5). human-turn3-noderoute and zuul-turn16-noderoute exist and were never bracketed for this
CreateRaidEncounter past the gate 5 (§5.1) needs a non-empty raid candidate list — the same workload as §3.3
the spy chain 4 (0x00887c8a, 0x008409c7, 0x00840929, 0x00840a3c) corpus 0 by deat; the inner gates are unread. Lane AS's ground
SVSO overrides other than SlaversRefuel 1 grouped row (16 targets) S+0x1b4 null in a normal game; SvSctOb.EncObj has 16 census constants
the second research draw 0x005878bb 1 branch condition inside TechTree::ProcessResearch
the refugee raid roll 0x008935ce 1 "a subsystem manager is present" — not yet a save field
FUN_008134e0 0x0081351c 1 site, listed under both drivers in both drivers, never in the ledger, gate not decoded
combat phase 6 3 (0x007bb69b, 0x00852ec7, and the inlined site in 0x007a7f30) combat-dependent; one battle has ever been instrumented

Three rows are P with corpus count 0 and a recipe attached (§3.1, §3.2, §3.3) — rule 6 with an instruction sheet, which is a different thing from a negative.


7. What this lane did not settle

  • ServerTradeManager+0x0c's writer was not found. §3.3's last paragraph states the consequence and the experiment that decides it. This is the one load-bearing inference in the document and it is flagged as one.
  • SpyCraft.deat's writer was not found. It is the single conjunct that gates the entire spy half, and per rule 28 practice 3 a field that reads 0 in all 22 saves is a corpus coincidence until its writer is named. Lane AS should find it before building anything.
  • FUN_00743f80(system) & 1 — the conjunct on AC's inline detection roll 0x00887c8a — was not decoded to a save field.
  • The diplomacy conjunct is decoded but never exercised. PlayerAlliances_Relation was already read by lane T2 and G_B3 resolves to "at war"; the corpus satisfies it only because nobody has ever signed anything (Sim.Player.dipstats holds 11 census constants — no corpus game reached a treaty). So the §3.3 workload gets G_B3 for free today and would lose it the moment a lane builds the treaty workload. Worth stating in AD's sheet as a conjunct that currently passes by accident.
  • Which of RebuildSpeciesTechFlags' eight bits is bit 5 (the one that blocks BeginAddiction) was not pinned; only bit 4 was, through InitTable's SpeciesDef+0x88 slot. EVENTSUM_ADDICTION_TEMPERENCE exists in the image and is the obvious lead.
  • Nothing here was measured. Every "M" row is another lane's measurement, cited with the configuration it was taken at. In particular every AC number is probes=11, which lane H bisected as not byte-neutral; the tail delta is entry-attributed and stands, the turn totals are not calibration data, and no AC autosave is an oracle pair.
  • The corpus is 22 saves, not 20. The brief says twenty; verify/results/saves/ holds twenty-two .sav files and the value-domain census counts the same twenty-two. Every count in this document is over those twenty-two, listed by name in §3.1's table plus the eighteen with no rt record.

8. Artefacts

what where
this document findings/control-flow/gate-indexed-rng-audit.md
new addresses (12 entries, no name collides with an existing one) ghidra/addresses.d/ag.json
nothing was written to Ghidra, no VM was used, no build directory was created —

Proposed board rows

New row:

| GATE-INDEXED RNG AUDIT: 31 unread-gate rows, 6 new draw sites, the SVSO generator named | control-flow | verified | high | 100% | 2026-09-09 | **Lane AG**, host only, no VM, no Ghidra (`findings/control-flow/gate-indexed-rng-audit.md`). The resolution's §8 item 3, delivered: one row per STATICALLY REACHABLE draw site in `BeginProcessTurn` u `ProcessTurn` u `OnAllCombatDone_Tail` over direct + tail-jump + pinned-virtual edges, each carrying a MEASURED firing or a DECODED PREDICATE on save fields with the 22-save corpus counted against it. **NO TOTAL IS REPORTED - 31 rows (30 distinct sites + one grouped SVSO row) are UNREAD GATES**, and that visible blank column is the deliverable. THREE GATES DECODED. (1) `0x0082cdb8` is the **ADDICTION SPREAD ROLL**: `ServerTradeManagerImpl::vslot15` iterates the SERIALISED route vector (its G1, `BYTE[rt+0x25]!=0`, is byte-for-byte the test `Write` uses to emit an `rt`), and for each route with `trfrs==0 && trtos==0` rolls `Chance(ADDICTION_SPREAD_ODDS=0.2f)` once per species slot the FROM system is addicted to. Corpus **0 of 22**: `nadct` is 0 on all 28 systems of all 22 saves. WRITER FOUND (rule 28 practice 3): `ServerSystem::BeginAddiction 0x0074ef70` has EXACTLY TWO CALLERS, both inside vslot15 - so BLOCK 1 (an INTER-EMPIRE route, `trfow != trtow`, plus bit 4 of `player+0x348+4k`, which `RebuildSpeciesTechFlags` sets from the per-species ADDICT TECH table `FUN_0053b620`) must seed it before the roll can ever happen. Every corpus route is intra-AI (`trfow==trtow==1`), so neither block has run. (2) `0x0088dc43` is the SPY COUNTER-MISSION roll, `p = spy->cmo` starting at 0.2f and gaining 0.2f per failure - so `Chance` costs 0 WORDS from the fifth turn on (`p>=1` early-out), a stage costs at most 4 words. Predicate `deat != 0 AND cm in {1..4}`; corpus **0 of 22** by construction (both fields are among the census's 234 constants). (3) `0x0088b613` is the RAID TARGET PICK, and **`RNG_NextInt` HAS NO ZERO-BOUND EARLY-OUT** (read in full: mask smear then an unconditional draw loop) - so a single-candidate list still costs a word, which PROVES lane AC's `B` returned BEFORE the draw. The failed conjunct is named: AC's raider sat on sector `TradeID 832`, member systems 13/23/0/3, while the four `rt` records' `trfr` are 18/16/14/20, lying in sectors 816/816/768/752. **NEXT WORKLOAD: park the raider on sector 816 instead of 832** - two candidates, `NextInt(bound=1)` cannot reject, `tsct=2` is the AI's bit so no `FtFlg&0x800` is needed, and the AI's Freighters fleet is already there to test the doubled odds. SIX NEW DRAW SITES the firing-indexed table structurally could not hold: five behind `CreateRaidEncounter`'s empty-candidate-list gate (`0x0088f1eb`, `0x0088f356`, `0x0083b460`, `0x00848fd9`, `0x00820c1b` via `0x00892640`) and `0x00820c1b` AGAIN from `Slot13RngCalleeB` at `0x0088b62c`. **SVSO GENERATOR CLOSED**: all 17 overrides that can draw use `GetGame()->+0x16c` = `*(void**)0x00b29f9c` -> +0x16c, the STRATEGIC generator - V2 §4.3 caution (a) retired. Rule 17 bit twice more (`ServerSpyManager::vslot14` real end 0x0088dd2b vs Ghidra's 0x0088dd03, jump table past it) |

Edits to existing rows:

  • Row 186 (RNG LEDGER CLOSED - zero residual) — append: GATE-INDEXED SUCCESSOR 2026-09-09 (lane AG): the site set is now enumerated by GATE rather than by firing - 43 entry-point call sites plus 2 inlined sites inside the two turn drivers' closures over direct + tail-jump + pinned-virtual edges, of which 30 distinct sites are UNREAD GATES. Six were not in any earlier inventory. No per-turn total is claimed.
  • Row 207 (V2's eight indirect sites) — append: THREE OF THE FOUR UNREAD GATES ARE NOW DECODED (lane AG): 0x0082cdb8 = addiction spread (corpus 0 of 22, nadct0 everywhere, and its seed path needs an INTER-EMPIRE route plus a species addict tech); 0x0088dc43 = spy counter-mission, p = cmo accumulating 0.2f/turn so Chance costs 0 words once p>=1 (corpus 0 of 22 bydeat/cm); 0x0088b613 = raid target pick, and NextInt has no zero-bound early-out so B provably returned before it - the failed conjunct is the raider's SECTOR, not any container. Still unread: 0x00887c8a's FUN_00743f80 & 1 conjunct and the three sites inside P.
  • Row 399 — append: AND lane AC's "three rt route records" is FOUR in ac-turn22-raider.sav (three in ac-turn20-raider); corrected by direct read, lane AG.
  • The CreateRaidEncounter clause of row 186 / tail-rng-ledger.md §11.1 — "a success costs 0 or 1 further word" is wrong about the CODE (right about the corpus): a success with a non-empty candidate list reaches FIVE more draw sites through 0x00892640, each NextInt with its own rejection loop. Lane AG §5.1.
  • Lane AD's brief (resolution §4/§7) — one prediction to add before the build: B costs 2 words on a success with a non-empty candidate list (NextInt 0x0088b613 + NextFloat 0x00820c1b in FUN_00820af0, unless the species short-circuit fires), not 0. AC's 0 was an empty list, not a free success.