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

630 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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,|detectors|)`, bounded by `\|contacts\|×\|detectors\|`, **and a trial burns a word even when `thresh = 0.0f` and cannot succeed** |
| `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
`techId`s, 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, `nadct` 0 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 by `deat`/`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.