Compare commits

...

2 commits

Author SHA1 Message Date
alex
7eeb68b8bd lane AG merged: gate-indexed audit rows, three gates decoded, tail-rng-ledger 11.1 corrected in place 2026-09-08 22:29:14 -04:00
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
6 changed files with 931 additions and 12 deletions

View file

@ -1,16 +1,16 @@
# SotS RE campaign — coverage dashboard
Generated 2026-09-09 01:59 UTC · `sots-re` @ d98f435,2026-09-08 · `sots-engine` @ e62d974,2026-09-08 (238 commits) · regenerate with `tools/dashboard.py`
Generated 2026-09-09 02:29 UTC · `sots-re` @ 48db3cc,2026-09-08 · `sots-engine` @ 35b05eb,2026-09-08 (239 commits) · regenerate with `tools/dashboard.py`
> **North star:** A functional reimplementation of the engine — behavior-equivalent, NOT byte-for-byte
## 1. Map coverage (campaign/board.md)
403 targets · mapped-or-better **356/403** `[█████████░] 88%` · verified **314/403** `[████████░░] 78%`
405 targets · mapped-or-better **358/405** `[█████████░] 88%` · verified **316/405** `[████████░░] 78%`
| Status | Count | % |
|---|---:|---:|
| verified | 314 | 78% |
| verified | 316 | 78% |
| mapped | 42 | 10% |
| in-progress | 4 | 1% |
| backlog | 41 | 10% |
@ -19,7 +19,7 @@ Generated 2026-09-09 01:59 UTC · `sots-re` @ d98f435,2026-09-08 · `sots-engine
| Type | verified | mapped | in-progress | backlog | blocked | total |
|---|---:|---:|---:|---:|---:|---:|
| objects | 46 | 6 | 0 | 3 | 1 | 56 |
| control-flow | 24 | 2 | 0 | 0 | 0 | 26 |
| control-flow | 26 | 2 | 0 | 0 | 0 | 28 |
| subsystems | 4 | 8 | 0 | 2 | 1 | 15 |
| engine | 29 | 0 | 0 | 0 | 0 | 29 |
| verify | 101 | 15 | 3 | 35 | 0 | 154 |
@ -31,7 +31,7 @@ Generated 2026-09-09 01:59 UTC · `sots-re` @ d98f435,2026-09-08 · `sots-engine
- RTTI type descriptors: **1,924** (`Game::` 1,404, `Mars::` 194; serializable types 179)
- Classes with recovered member layouts: **384** / 1,598 named classes `[██░░░░░░░░] 24%` — `objects/layouts.json` (serializer recovery) plus classes recovered by hand in `struct-recovery.md` + `schema-gaps-resolved.md`. Note 179 types are *serializable*; the recovery also reaches non-serializable ones, so this is not a subset of that
- Functions: **41,411** (parsed from `01-fingerprint.md`); named/annotated in the **address contract** (`ghidra/addresses.json`, not Ghidra's full rename count): **1247**, verified **1122** `[█████████░] 90%`
- Functions: **41,411** (parsed from `01-fingerprint.md`); named/annotated in the **address contract** (`ghidra/addresses.json`, not Ghidra's full rename count): **1269**, verified **1139** `[█████████░] 90%`
## 3. Data layer
@ -133,11 +133,11 @@ Most recent open:
## 9. Delta since previous dashboard
- verified targets: 314 → 314 (+0) · mapped-or-better: 356 → 356 (+0)
- verified targets: 314 → 316 (+2) · mapped-or-better: 356 → 358 (+2)
- engine LOC: 58,618 → 58,618 (+0) · test files: 122 → 122 (+0) · checks: 4,244 → 4,244 (+0)
- addresses verified: 1,122 → 1,122 (+0) · recovered layouts: 384 → 384 (+0) · open questions: 26 → 26 (+0)
- addresses verified: 1,122 → 1,139 (+17) · recovered layouts: 384 → 384 (+0) · open questions: 26 → 26 (+0)
- standalone leaves closed: 45 → 45 (+0) · leaves still diverging: 63 → 63 (+0)
---
warnings: board.md: unknown types subsystems; mars-rng.md: no oracle total row parsed; mars-stream.md: no oracle total row parsed; mars-vfs.md: no oracle total row parsed
<!-- dashboard-metrics {"verified": 314, "mapped_plus": 356, "targets": 403, "loc": 58618, "tests": 122, "checks": 4244, "addr_verified": 1122, "addr_total": 1247, "layouts": 384, "open_q": 26, "sa_closed": 45, "sa_left": 63} -->
<!-- dashboard-metrics {"verified": 316, "mapped_plus": 358, "targets": 405, "loc": 58618, "tests": 122, "checks": 4244, "addr_verified": 1139, "addr_total": 1269, "layouts": 384, "open_q": 26, "sa_closed": 45, "sa_left": 63} -->

View file

@ -168,7 +168,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| conformance check honest about what it cannot check | engine | verified | high | 100% | 2026-09-08 | Lane Q: the generic check<T>() could NOT be used, and that is the finding not an obstacle - every tag in this block is ".", so the LCS degenerates to a strict positional compare where any primitive disagreement is fatal, AND the table's tail describes ELEMENTS where the wire has COUNTS. The dedicated check states only what is checkable: prologue 17/17 ITEM FOR ITEM (SchemaProbe takes every branch, so this is real evidence that the conditional structure read from the instruction stream is the one the recovery flattened) and tail 27 lists vs 27 table items. Tail reported as WIRE-ONLY, never claimed as matched. 86 shapes/838 items -> 87/856, still 0 MISMATCH |
| INLINED RNG DRAWS ARE INVISIBLE TO CALL-GRAPH SWEEPS | verify | verified | high | 100% | 2026-09-08 | LANE J'S HEADLINE, and it invalidates a class of prior analysis. The combat resolver's NextFloat is INLINED, so its only call-graph edge is `caller -> RNG_Twist`, which reads as a bare Twist and is NOT one - it is the lazy twist INSIDE NextFloat. That is why lane K reported "no NextFloat in that subtree". An image-wide scan for the MT TEMPERING IMMEDIATES AT INSTRUCTION BOUNDARIES finds **14 game functions with inlined draws no RNG sweep can see**, and TWO OF THEM - FUN_004f7670 (84 bytes) and FUN_007aa240 - ARE IN ProcessTurn'S CLOSURE AT DEPTH 4. Any RNG accounting built from the call graph alone is a LOWER BOUND. Now rule 16 |
| CORRECTION: Ghidra's function size is not the function | meta | verified | high | 100% | 2026-09-08 | Ghidra reports 7,499 bytes for the combat resolver; the real body is 0x007d5af0..0x007d78c8 = 7,641 and Ghidra's end lands MID-INSTRUCTION. Lane J first read a callee as straight-line because it dumped at Ghidra's size and the outer BACK-EDGE FELL OUTSIDE - it is a loop over combatants, which changes the draw count of the very site it was measuring. A delegated sweep made the same mistake independently. Same defect lane X hit from the other side (clipping at fva+sizeInBytes lost 11% of functions; sweeping to the next function start took coverage 89% -> 100%). Now rule 17: never trust a function's end, disassemble to the next function start |
| combat resolver mapped | control-flow | verified | high | 85% | 2026-09-08 | Lane J read all 7,641 bytes. THE RESOLVER DRAWS NOTHING ITSELF - exactly three sites in its subtree, each behind a function whose callerCount is 1 (the resolver), so nothing else in the image can trigger them: R1 NextInt @0x007bb69b (node cannon, once per battle, gated on something having been flung AND a non-empty candidate list) mean 4/3 words; R2 an INLINED NextFloat @0x007a84bd, one per back-engineering candidate per combatant with a non-zero salvage slot, exactly 1 each; R3 NextInt @0x00852ec7, one per successful R2 roll if the project list is non-empty, mean 2^ceil(log2 n)/n. Structure: only EIGHT non-stack stores in the whole function and EXACTLY ONE indirect call (in a _CxxThrowException path) - it composes and posts events and delegates every mutation to its 70 callees. Also: the subtree DOES write the turn-results accumulator at S->+0x2f4[PlyrIdx*0x11c]+0x90 on the surrender arm (a DIFFERENT member from the +0x24 lane K found, and that writer has SEVEN callers); 23 EVENT_* keys in the closure vs lane K's 7, five new, INCLUDING PLAGUE BEING DECIDED INSIDE COMBAT; sizeof(Game::TacReport)=0x94 enumerated twice |
| combat resolver mapped | control-flow | verified | high | 85% | 2026-09-08 | Lane J read all 7,641 bytes. THE RESOLVER DRAWS NOTHING ITSELF - exactly three sites in its subtree, each behind a function whose callerCount is 1 (the resolver), so nothing else in the image can trigger them: R1 NextInt @0x007bb69b (node cannon, once per battle, gated on something having been flung AND a non-empty candidate list) mean 4/3 words; R2 an INLINED NextFloat @0x007a84bd, one per back-engineering candidate per combatant with a non-zero salvage slot, exactly 1 each; R3 NextInt @0x00852ec7, one per successful R2 roll if the project list is non-empty, mean 2^ceil(log2 n)/n. Structure: only EIGHT non-stack stores in the whole function and EXACTLY ONE indirect call (in a _CxxThrowException path) - it composes and posts events and delegates every mutation to its 70 callees. Also: the subtree DOES write the turn-results accumulator at S->+0x2f4[PlyrIdx*0x11c]+0x90 on the surrender arm (a DIFFERENT member from the +0x24 lane K found, and that writer has SEVEN callers); 23 EVENT_* keys in the closure vs lane K's 7, five new, INCLUDING PLAGUE BEING DECIDED INSIDE COMBAT; sizeof(Game::TacReport)=0x94 enumerated twice **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. |
| resolver formula is UNTESTED (stated) | verify | backlog | — | 0% | 2026-09-08 | Lane J's honest limit: every encounter in lane Z's workload had res->+0x4 set, so ApplyEncounterResult was a no-op and THE RESOLVER HAS NEVER EXECUTED UNDER AN INSTRUMENT. The formula is untested. combat-resolver.md 10.3 specifies the workload; the first cheap PREDICTION is that a plain fleet battle with no node cannon and no salvage should cost the SAME 18-20 words as a peaceful turn, because the resolver has no unconditional draw |
| lane J errors caught before publishing | meta | verified | high | 100% | 2026-09-08 | Two, both corrected pre-publication and both worth keeping: (1) read a callee as straight-line from a truncated Ghidra range, hiding the outer loop that sets R2's count - now rule 17; (2) drafted a 0x11c site as a false positive after converting its address BY HAND, wrongly - re-disassembling from a known boundary showed a real SETurnResults write. THE SCANNER WAS RIGHT AND THE READER WAS NOT |
| inlined-draw inventory COMPLETE | verify | verified | high | 100% | 2026-09-08 | Lane I re-ran the tempering-immediate scan ITSELF at real instruction boundaries (all 41,089 functions decoded TO THE NEXT FUNCTION START, never to Ghidra's size). SIXTEEN functions carry the masks inside a decoded instruction, 67 occurrences - and RECALL IS PROVED COMPLETE: a brute byte scan over the executable sections finds the SAME 67 and ZERO ORPHANS. 70 functions desync mid-decode, every one inside int3 padding, none hiding a site. **LANE J'S 14 WAS WRONG IN TWO WAYS**: one is a FALSE POSITIVE (FUN_008cca30's four bytes are the rel32 displacement of a call, not a temper chain) and FOUR are RNG ENTRY POINTS, not game code - including 0x004f7670 which lane J counted as a game function with an inlined draw and which is actually Mars::RNG::NextUInt, a primitive. CORRECTED FIGURE: **eleven game functions, 28 sites**. Only TWO are reachable from the turn drivers by direct edges: FUN_007aa240 (StrategyServer::ProcessTurn, depth 4) and FUN_007a7f30 (OnAllCombatDone_Tail depth 3 / CombatResolver_Run depth 1). The nine others top out at vtable slots with no direct caller - and lane I states AS LOUDLY AS THE RESULT that "not in the closure" is proved FOR DIRECT EDGES ONLY |
@ -183,7 +183,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| THE FINDING THAT SHAPED THE DESIGN: report regressed, never net it off | meta | verified | high | 100% | 2026-09-08 | Lane S2 first implemented AND COMMITTED S31's player-status restore. The comparison tool immediately reported TWO REGRESSED LEAVES on turn2->turn3: two Player.Status words that AGREED with the oracle before the turn and DISAGREED after. The phase writes 1, the file carries 4, a load resets to 0 - so a writer between phase 31 and the autosave is unaccounted. S31 is now blocked, and `regressed` is reported NEXT TO `closed` in every run, NEVER NETTED OFF. Consequent design rule: a phase whose FORMULA we hold but whose INPUTS we do not is EVALUATED AND REPORTED, NOT WRITTEN, unless --commit-blocked. Same for the generator (--commit-rng) |
| standalone: two things deliberately NOT implemented | verify | backlog | — | 0% | 2026-09-08 | (1) TShn/ltis - 18 of the remaining 204 leaves, moving 1->2 on 8-10 systems on BOTH pairs. A `+1` closes them in ten lines. NOTHING NAMES THEIR WRITER, so that is a hypothesis not a reading; named in the docs as the cheapest measured target. (2) RNG write-back - an advanced-but-incomplete state is WRONG DIFFERENTLY from an untouched one. One hypothesis IS under test and survived a changed workload: `stable = owned && !abandoned && !destroyed` (the original asks a callee) closed the same 3 ntdev leaves on both pairs - six agreements, zero disagreements - and is labelled a hypothesis in code, log AND docs |
| RANKED blockers to the byte-match | meta | mapped | high | 100% | 2026-09-08 | Lane S2's ranking, which is now the project's critical path: (1) THE RNG LEDGER - lane Z; nothing in src/app can close it. (2) The population->base-output term - ONE FORMULA GATING 5 OF THE 44 PHASES. (3) The 37-phase post-combat tail, which is the driver THE AUTOSAVE IS WRITTEN FROM. (4) The `nve` visibility record (32 leaves, one mechanism x 8). (5) The event pipeline. (6) Summary.Checksum. (7) The Player.Status writer. (8) ModCount - the real turn advances it 12-44 times from writers spread across BOTH drivers |
| RNG LEDGER CLOSED - zero residual | verify | verified | high | 100% | 2026-09-08 | THE BLOCKER IS GONE. 18-22 words per turn, EVERY ONE ATTRIBUTED TO A CALL SITE, summing to the independently-measured total with ZERO RESIDUAL on three turns. THREE INSTRUMENTS AGREE on turn 5's 20 words: boundary hook, return-address detours, and the two save files - and the file check has `twists = 0`, so it does NOT route through anyone's twist implementation. Two calibrated oracle pairs are in the repo for the standalone to test against. Also settled live: the tail RUNS ON EVERY End Turn (8/8, two saves), confirming lane K's inference (its stronger no-encounter form is narrowed, not closed); node-line decay fired on turn 64 for EXACTLY 1 WORD, matching a prediction committed THIRTY TURNS EARLIER, and exercised the twist path live on the same turn; and COMBAT IS FREE - the first instrumented battle cost 0 words |
| RNG LEDGER CLOSED - zero residual | verify | verified | high | 100% | 2026-09-08 | THE BLOCKER IS GONE. 18-22 words per turn, EVERY ONE ATTRIBUTED TO A CALL SITE, summing to the independently-measured total with ZERO RESIDUAL on three turns. THREE INSTRUMENTS AGREE on turn 5's 20 words: boundary hook, return-address detours, and the two save files - and the file check has `twists = 0`, so it does NOT route through anyone's twist implementation. Two calibrated oracle pairs are in the repo for the standalone to test against. Also settled live: the tail RUNS ON EVERY End Turn (8/8, two saves), confirming lane K's inference (its stronger no-encounter form is narrowed, not closed); node-line decay fired on turn 64 for EXACTLY 1 WORD, matching a prediction committed THIRTY TURNS EARLIER, and exercised the twist path live on the same turn; and COMBAT IS FREE - the first instrumented battle cost 0 words **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. |
| the dominant RNG consumer: trade-raid generation | subsystems | verified | high | 100% | 2026-09-08 | Sixteen of the ~20 words per turn are ServerTradeManager::GenerateTradeRaidEncounters (0x00893290) - ServerTradeManagerImpl vftable 0x00a31b74 SLOT 10, looping StrategyServer::Players and rolling per player: +0x196 player raid TRADE_RAID_ODDS_PLAYER 0.2f (8/8 fired), +0x283 NPC raid 0.05f (gate is player-INDEPENDENT so all-or-nothing per turn, 8/8), +0x33e refugee raid 0.05f (0/8). All three probabilities are strictly inside (0,1) so `Chance` takes NEITHER early-out - exactly one word each, matching the measurement. NO BACK-EDGE CONTAINS ANY OF THE THREE SITES, so one word per player per site is a HARD BOUND, not an observation. STILL UNBOUNDED, flagged: a SUCCESSFUL roll calls slot 17 (CreateRaidEncounter 0x008938a0) which draws a NextInt but returns without drawing on an empty candidate list, so 0 or 1 further word; it cost 0 on all three turns, consistent EITHER with no roll succeeding (~11% on the defaults) OR with the candidate list being empty every time - a word count cannot separate those |
| WHY NO SWEEP FOUND IT: the virtual edge | meta | verified | high | 100% | 2026-09-08 | ZERO direct calls to 0x00893290 exist in the image; its only reference anywhere is the vtable slot. The dispatch is `call edx` through slot 10 at 0x007d8469, inside DetectEncounters - ONE INSTRUCTION BEFORE the direct call lane I's closure DID follow. LANE I'S INVENTORY IS NOT WRONG: its tempering-scan recall claim is intact and its 22-site list is EXPLICITLY a direct-edge closure, with the indirect-call caveat stated as loudly as the result. This is the demonstration that the caveat was LOAD-BEARING - the single largest RNG consumer of a strategic turn hangs off a virtual edge inside a function the closure already contained. THE LESSON: a closure over direct edges is a lower bound, and saying so is what let the next lane find this in one step |
| the repo had already met this function | meta | verified | high | 100% | 2026-09-08 | strategic-turn-internals.md line 153 ALREADY listed 0x00893290 as "raid encounter generation" against these exact three StrategyVars. What was missing was not the identification but THE CONNECTION TO THE LEDGER - nobody knew it was where a turn's RNG goes. Worth remembering when the next "unknown" turns up: search the notes before the binary |
@ -395,7 +395,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| the composer's loop has NEVER RUN | verify | backlog | — | 0% | 2026-09-09 | Stated plainly by the lane: `H_obs = 0` on all six composer calls of all four runs, for two different reasons - on turn 1 the weapon lookups gate it, on turn 15 all three calls were price queries returning nine steps earlier. **Four of eight live sites have never fired; two of nine exits have ever been taken.** D/E/G/H are read, not measured. **THE WORKLOAD: a turn where the AI BUILDS (not prices) a design, on hull size > 0, with a point-defence weapon researched.** SD pushed human-turn15-spyprogram at it and still got only price queries; a follow-up should play that game forward until the AI commits a cruiser. **The instrument for it is already committed and cross-built (`w_alt`/`w_def`) but NO RUN HAS USED IT** |
| SD self-reported a rule-21 shaped error | meta | verified | high | 100% | 2026-09-09 | Twice it ran `verify/design-rules/stock_designs.py` inside the **shared** sots-re clone to identify a save's players; **the script writes stock_designs.json as a side effect** and it restored the file with `git checkout -- <path>`. Clean afterwards, but that is exactly rule 21's shape - **and it would not have known if another lane had work in flight there.** Worth generalising: a read-only-looking script can have write side effects, so run analysis tools against a copy, not the shared tree |
| REVERSAL: THE TAIL DOES DRAW - W3's risk is REALISED | verify | verified | high | 100% | 2026-09-09 | **Lane AC overturns the "retired by evidence" conclusion I reported.** OnAllCombatDone_Tail consumed **1 RNG word per turn** in the state AC built, against **0 in its own control** (same guest, same build, same probe config, one End Turn on L3's turn-15 save, run immediately after). `TradeManager::Slot13RngCalleeA` - **entered 0 times in every measurement any lane had ever taken** - was entered **1x/turn on both instrumented turns**; CalleeB on the turn the roll succeeded; CreateRaidEncounter once. All probes installed=true, watchpoint canary PASS every turn. **THE RNG LEDGER AND THE STANDALONE'S GENERATOR MODEL NEED EXTENDING BY ONE DRAW SITE** |
| and the gate is a TECH, not container contents | verify | verified | high | 100% | 2026-09-09 | **Neither freighters nor a deployed spy is in the predicate** - AC disassembled the gate chain BEFORE building the state. `ServerTradeManagerImpl::vslot13` does not iterate routes or sectors; it iterates the **FLEET** vector and requires a fleet whose LocID resolves to a node of kind **2 (TradeSector)**, positioned **bit-equal** to the sector, whose owner's bit is set in the sector's **`tscr`** mask. `tscr` was **252 in every corpus save = exactly the six NPC slots**, and AC's own prediction P6 said that excluded real players and the callees would stay 0. **WRONG, AND INSTRUCTIVELY**: the mask is not fixed. Researching **`CCC_ComRaid` (Commerce Raiding, RP 12,000, one turn off CCC_FTLEcon)** takes tscr **252 -> 253** on every sector and sets the ServerPlayer flag serialised as **`CnRad`**. **The game says so in as many words**: before the tech, `Alpha Fleet cannot raid trade sector.`; after it the same click is accepted. **That dialog is the cheapest test of the whole chain** |
| and the gate is a TECH, not container contents | verify | verified | high | 100% | 2026-09-09 | **Neither freighters nor a deployed spy is in the predicate** - AC disassembled the gate chain BEFORE building the state. `ServerTradeManagerImpl::vslot13` does not iterate routes or sectors; it iterates the **FLEET** vector and requires a fleet whose LocID resolves to a node of kind **2 (TradeSector)**, positioned **bit-equal** to the sector, whose owner's bit is set in the sector's **`tscr`** mask. `tscr` was **252 in every corpus save = exactly the six NPC slots**, and AC's own prediction P6 said that excluded real players and the callees would stay 0. **WRONG, AND INSTRUCTIVELY**: the mask is not fixed. Researching **`CCC_ComRaid` (Commerce Raiding, RP 12,000, one turn off CCC_FTLEcon)** takes tscr **252 -> 253** on every sector and sets the ServerPlayer flag serialised as **`CnRad`**. **The game says so in as many words**: before the tech, `Alpha Fleet cannot raid trade sector.`; after it the same click is accepted. **That dialog is the cheapest test of the whole chain** **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. |
| CORRECTION: L3's own save falsified the freighter hypothesis | verify | verified | high | 100% | 2026-09-09 | L3 3.5 says "every trade sector has `tsnumflt = 0`". **Wrong about its own save**: `human-turn15-spyprogram.sav` sector TradeID 816 has **`tsnumflt 1`, `tsflt 2736` - the AI's Freighters fleet, on an active route, across all seven of L3's instrumented turns, with the callees at 0.** So "freighters in service" was falsified BEFORE lane AC started. Also **`tsflt` is not a freighter list** - at turn 20 sector 6 reads tsflt 3744, which is AC's WARSHIP fleet. **[CORRECTED 2026-09-09 by the resolver: LANE V2 IS VINDICATED AND WAS MISQUOTED.** `indirect-edges.md` 3.1 lists trade slot 13 as **DRAWING at 0x00820e18 - the exact site that fired.** "Closure 185, draw-free" is V2's row for **slot 9**, the route rebuilder lane L3 trapped. Lane AC carried the number to the wrong slot and I published it. V2's static reading was right all along.] |
| spies2 NOT settled, and the deploy state is unreachable in that game | verify | backlog | — | 0% | 2026-09-09 | AC could not deploy a spy and **declined to infer around it**. `ARes2` on ServerSystem **is the asteroid-belt field** (non-zero values 1703-2531 sit inside SYSTEM_MIN/MAX_ASTEROID_RESOURCES 1500-3000); in L3's save only **5 of 28 systems have a belt and NONE is AI-owned** - the textbook deploy target does not exist in that game. **AND L3's "the next condition is spyon" UNDERSTATES IT BY THREE TURNS AND A DETECTION ROLL**: the chain needs `deat != 0`, then `sdet` set by a detection roll on an EARLIER turn, then `turn - sdet >= 3`. **That detection roll is itself an RNG draw, inline in vslot13, not inside P** - so a probe on P reading 0 does not mean the spy half is draw-free |
| AC's coverage caveat, stated | verify | backlog | — | 0% | 2026-09-09 | Two turns, one fleet, one sector, one ship. **`Slot13RngCalleeA` has a short-circuit (`design+0x12c > 1` returns true without rolling) that AC's hull did not exercise**, so a different raider could cost **0 words while still firing B**. The one-word-per-turn figure is this raider's, not the site's |
@ -408,3 +408,5 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| the coverage ratchet is blind along the value axis | verify | verified | high | 100% | 2026-09-09 | Board row 402 predicted AC's two saves would break the ratchet. **They did not** - both score 100.0% typed and round trip byte-identically, because their novelty (`LocID` at a TradeSector, `tscr = 253`, three `rt` records) lands in fields that were **already typed**. `CoverageArchive` is indexed by **shape**, exactly as lane Z's ledger was indexed by **firing**: both instruments are sound at what they measure and both are blind where the surprises come from. New instrument `DomainArchive` + `test_domains` (engine `e62d974`) measures the other axis |
| 234 of 724 typed fields have only ever held one value | verify | verified | high | 100% | 2026-09-09 | The census over 22 saves: **724 fields observed, 490 vary, 234 constant across the whole corpus - 32%**. `tscr` was one of the 234 until a one-turn tech moved it. Clusters: `Sim.Player.dipstats` 11 (no corpus game reached a treaty), `SvSctOb.EncObj` 16 (**no corpus game triggered a random encounter**), `spymgr.spy` 12, `CmbR.CRPlSv2.TacReports` 9 (tactical reports never populated). Individually suspicious because non-zero and non-round: `spy.atto = 3728`, `EncObj.infest.mtrn = INT32_MAX` (a sentinel), `CD.researchBoostFraction = 0.999215`. Rule 23's thin coverage with a number on it, rule 8's eighteen fields generalised. Ratchet at 490 varying; `findings/subsystems/value-domain-census.md` |
| the spy sub-object is unexercised: 0 of 22 before lane AS starts | verify | verified | high | 100% | 2026-09-09 | Direct input to lane AS. The census reads `spymgr.spy.sdet = -1` in **all 22 saves**, with `cbh`/`cm`/`cmo`/`deat`/`sdo` all 0 - `sdet = -1` sits where a detection result would go and is a sentinel. So the **corpus count for any spy-path predicate is 0 of 22 before the lane takes a single measurement**, which is exactly what rule 28 requires before a zero may be called a negative. Also gives lane AG a mechanically computable corpus-count column: a predicate resting on any of the 234 constants has count 0 by construction |
| 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) |
| CORRECTION: `CreateRaidEncounter` success is not 0-or-1 words | control-flow | verified | high | 100% | 2026-09-09 | `tail-rng-ledger.md` 11.1's **"a success costs 0 or 1 further word"** is right about the **corpus** and wrong about the **code**. A success with a **non-empty** candidate list reaches **five** further draw sites through `0x00892640` (`0x0088f1eb`, `0x0088f356`, `0x0083b460`, `0x00848fd9`, `0x00820c1b`), each a `NextInt` with its own rejection loop. Lane AG 5.1. The firing-indexed table could not hold these because no corpus state has ever had a non-empty list |

View file

@ -0,0 +1,630 @@
# 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.

View file

@ -348,6 +348,8 @@ would have said so by construction rather than by anyone noticing.
§9.3.
* ~~`FUN_00893290` is unidentified.~~ **Identified — §11.1.** What is still open there is whether a
*successful* raid roll costs a further word (0 or 1, undetermined on a workload where none succeeded).
**ANSWERED 2026-09-09 (lane AG): neither — a success with a non-empty candidate list reaches five
further draw sites, unbounded above. The rolls that "succeeded" here succeeded into an EMPTY list.**
* **A turn with a genuinely empty encounter vector was not observed** (§4). Both saves produce exactly one
sighting encounter on every turn. The tail-runs-every-turn claim is settled; the no-encounters variant is
still an inference, now a much narrower one.
@ -615,6 +617,15 @@ A word count cannot separate those, and separating them is the cheapest experime
> (`findings/control-flow/tail-probes.md` §4). An entry counter on 0x008938a0 recorded **2 calls on
> Zuul turn 24 and 1 on turn 25, all drawing 0 words**: the rolls *do* succeed and **the candidate
> list is empty**. A successful raid roll costs 0 further words on our corpus.
>
> **CORRECTED IN PLACE 2026-09-09 by lane AG (rule 11).** That sentence is right about the **corpus**
> and wrong about the **code**, and the qualifier "on our corpus" is doing more work than it looks.
> A success with a **non-empty** candidate list reaches **five** further draw sites through
> `0x00892640` — `0x0088f1eb`, `0x0088f356`, `0x0083b460`, `0x00848fd9`, `0x00820c1b` — each a
> `NextInt` with its own rejection loop, so the cost is unbounded above, not "0 or 1". The reason no
> one saw them is structural: **no state we own has ever had a non-empty candidate list**, and a
> firing-indexed ledger cannot contain a site that never fired. See
> `gate-indexed-rng-audit.md` §5.1 and §3.3.
> (a) "three quiet turns in a row is unremarkable" understates it by three orders of magnitude —
> 11% is the probability of a quiet *turn* (`0.8^8 × 0.95^8`), so three in a row is 0.14%, about
> 1 in 720. (b) The caller list below is **incomplete**: `0x007d5150`, the other subtree of

180
ghidra/addresses.d/ag.json Normal file
View file

@ -0,0 +1,180 @@
{
"entries": [
{
"name": "ServerSystem_GetAddictionPhase",
"addr": "0x00745e40",
"convention": "thiscall",
"prototype": "int __thiscall (ServerSystem* this, int speciesIdx) // 91 B, RET 4. Reads this->addiction[speciesIdx] at +0x1e4+4k -- the int[7] table serialised as `nadct` + sparse (`ads`,`adt`) pairs. Returns 0 when the slot is 0; otherwise 1/2/3 by (Frame - stamp) against *StrategyVar_ADDICTION_PHASE2_START (10) and *_PHASE3_START (15). EVERY non-zero return path ends in `setne al`, so a caller reading AL gets the BOOLEAN `addiction[k] != 0` and the phase is discarded -- which is how ServerTradeManagerImpl_vslot15 uses it",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09); disassembled from dumps/sots.exe to the next function start"
},
{
"name": "ServerSystem_BeginAddiction",
"addr": "0x0074ef70",
"convention": "thiscall",
"prototype": "void __thiscall (ServerSystem* this, int speciesIdx) // 621 B, RET 4. Refuses if this->+0x100 (owner) is null, if bit 5 of owner->[0x348 + 4*speciesIdx] is set, or if ServerSystem_GetAddictionPhase already reports the slot stamped. Otherwise computes a money term, posts EVENTSUM/EVENTMSG_ADDICTION_OUTBREAK and writes this->addiction[speciesIdx] = GetGame()->Frame(+0xc). DRAWS NOTHING. ONLY TWO CALLERS IMAGE-WIDE, both inside ServerTradeManagerImpl_vslot15 (0x0082cd50 the un-rolled seed grant, 0x0082cdc4 behind the Chance) -- so this function is the sole way any system becomes addicted, and the Chance-gated spread cannot fire until the un-rolled grant has seeded it",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "SpeciesDef_AddictTechForSpecies",
"addr": "0x0053b620",
"convention": "thiscall",
"prototype": "void __thiscall (int speciesIdx /*ECX*/, int* out /*EAX*/) // jump table at 0x0053b6e8, seven entries; Ghidra's size 78 truncates before the table (rule 17). Writes the 10000-based TechID of that species' ADDICT TECH: k=0 0x2799, 1 0x279a, 2 0x279b, 3 0x279c, 4 and 5 -> 0xC5 (the image's `no tech` sentinel), 6 0x279d. Out-of-range logs `Species %s: No addict tech.` (0x009f9630). Called once, from InitTable 0x005453a0 at 0x005458a7, filling SpeciesDef+0x88 -- which is BIT 4 of ServerPlayer+0x348+4k in ServerPlayer_RebuildSpeciesTechFlags, the bit that gates the un-rolled addiction grant. NOT a wire techId, so tools/techid_table.py does not name these",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "StrategyVar_ADDICTION_SPREAD_ODDS",
"addr": "0x00af0c4c",
"convention": "data",
"prototype": "float -- the probability ServerTradeManagerImpl_vslot15 passes to Mars::RNG::Chance at 0x0082cdb8, reached as `mov ecx,ds:0x00af0c50; fld [ecx]` (0x00af0c50 holds a pointer to this storage word). Image default 0.2f, strictly inside (0,1), so Chance takes NEITHER early-out and the site costs EXACTLY ONE WORD per evaluation. Registered by a GlobalConst static-init stub against Data/Strategy/StrategyVars.txt with the %f parser 0x008b7020",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09); name resolved from the M1 loader stub, findings/subsystems/loader-prototypes.md"
},
{
"name": "StrategyVar_ADDICTION_PHASE2_START",
"addr": "0x00aeca54",
"convention": "data",
"prototype": "int -- image default 10. ServerSystem_GetAddictionPhase returns 2 rather than 1 once (Frame - addiction[k]) exceeds this. Pointer at 0x00aeca58. Dead for the vslot15 gate, which only reads the boolean",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "StrategyVar_ADDICTION_PHASE3_START",
"addr": "0x00aeca5c",
"convention": "data",
"prototype": "int -- image default 15. ServerSystem_GetAddictionPhase returns 3 once (Frame - addiction[k]) exceeds this. Pointer at 0x00aeca60",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "ServerSystem_IsIndependent",
"addr": "0x00743fa0",
"convention": "thiscall",
"prototype": "bool __thiscall (ServerSystem* this) // 12 B. `return this->+0x1c8 != 0` -- the IndependenceInfo* that the wire carries as `hindi` (present) + `indi`. ServerSpyManager_vslot14 rejects a spy whose target system passes this, which is conjunct G3 of the 0x0088dc43 counter-mission roll",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "ServerSpyManager_vslot14_RealEnd",
"addr": "0x0088dd2b",
"convention": "marker",
"prototype": "The last byte of ServerSpyManager_vslot14's body (the `ret`). GHIDRA REPORTS 387 BYTES, i.e. an end of 0x0088dd03, WHICH IS INSIDE THE FUNCTION -- a third instance of rule 17 after 0x007aa240 and 0x00893290. A five-entry jump table for the mission-stage switch sits at 0x0088dd2c..0x0088dd3f, past both the Ghidra end and the real end; padding then runs to the next function start 0x0088dd40",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "TradeSector_FleetRaidsOwnRoutes",
"addr": "0x0080ecf0",
"convention": "cdecl",
"prototype": "bool __cdecl (StarFleet* fleet) // 91 B. `fleet && GetLocationAsTradeSector(fleet) && !FUN_00813ab0(fleet,0x80000,0) && TradeSector_PlayerTradesHere(sector, fleet->owner) && !FUN_006fe1d0(fleet, 0x800)`. TradeManager_Slot13RngCalleeB uses it to choose between two candidate filters. IT IS DETERMINISTICALLY FALSE for any fleet that reached B through vslot13: TradeSector_FleetMayRaid (0x0080ed50) demands the OPPOSITE on the last two conjuncts, so the `raid your own routes` arm of B is unreachable from the tail. PlayerAlliances_Relation returning 3 for self is the second, independent reason",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "TradeManager_ResolveRouteEndpointOwner",
"addr": "0x00841cd0",
"convention": "thiscall",
"prototype": "ServerTradeManager method, 974 B. The function that DISAMBIGUATES Game::TradeRoute's endpoint fields: `if (rt->trfrs(+0x10) == 0) endpoint = GetGame()->Systems(+0x44)[rt->trfr(+0xc)] else endpoint = registry_lookup(GetGame()+0x84, rt->trfrs)`. So trfr / trto are SYSTEM INDICES into StrategyServer's system vector and trfrs / trtos are non-system endpoint IDS with 0 meaning `use the index`. Only the endpoint-resolution head of this function was read; the rest is unread",
"status": "mapped",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "TradeManager_ComputeRaidInterceptPoint",
"addr": "0x00820af0",
"convention": "thiscall",
"prototype": "bool __thiscall (ServerTradeManager* this, Vector3* out, TradeRoute* rt, void* arg3) // 425 B, RET 0xC. Places the raid interception point along the route: a = Systems[rt->trfr]->Pos, b = Systems[rt->trto]->Pos, dist = |b-a|; frac = 1.0 when the route owner's species (+0x5c) is 1 or when either of two species tests on arg3 passes, ELSE frac = float(NextFloat()) drawn at 0x00820c1b; out = a + dir*(frac*dist + <double 0x009e20a0>). TWO CALLERS: 0x0082cf65 (inside CreateRaidEncounter's subtree) and 0x0088b62c (TradeManager_Slot13RngCalleeB, immediately after the target pick)",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "TradeManager_RaidInterceptPoint_DrawSite",
"addr": "0x00820c1b",
"convention": "site",
"prototype": "call RNG_NextFloat (0x0047d830) inside TradeManager_ComputeRaidInterceptPoint, ECX = GetGame()->RNG(+0x16c) + 4. ONE STRATEGIC-GENERATOR WORD, unless the species short-circuit at 0x00820bc2/0x00820bd7/0x00820bf2 sets frac = 1.0 first. NOT IN ANY EARLIER INVENTORY. It makes TradeManager_Slot13RngCalleeB cost TWO words on a success (this plus RNG_NextInt at 0x0088b613), not the 0 lane AC measured on a state whose candidate list was empty",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "TradeManager_BuildRaidCandidates",
"addr": "0x0083b110",
"convention": "thiscall",
"prototype": "ServerTradeManager method called at 0x008938ed as the FIRST act of ServerTradeManager_CreateRaidEncounter; fills the local vector at [ebp-0x1c]. When it comes back EMPTY, CreateRaidEncounter returns false at 0x008938fa -- BEFORE every draw in the function and its subtree. This is the gate lane H measured as `entered 3 times, 0 words'. Body not read",
"status": "mapped",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "CreateRaidEncounter_EmptyCandidateListReturn",
"addr": "0x008938fa",
"convention": "site",
"prototype": "`jne 0x00893932` -- taken when TradeManager_BuildRaidCandidates produced a non-empty vector; the fall-through frees the vector and returns false with AL = 0. EVERY draw in CreateRaidEncounter and its subtree lies past this branch: 0x008939ee, then through 0x00892640 the five sites 0x0088f1eb, 0x0088f356, 0x0083b460, 0x00848fd9, 0x00820c1b. tail-rng-ledger.md section 11.1's `a success costs 0 or 1 further word' is right about the corpus and wrong about the code",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "TradeManager_BuildRaidEncounter",
"addr": "0x00892640",
"convention": "thiscall",
"prototype": "ServerTradeManager method, single caller ServerTradeManager_CreateRaidEncounter at 0x00893a25 (i.e. past the empty-candidate-list gate AND past the target pick at 0x008939ee). Calls TradeManager_SpawnEncounterSquadron 0x0088f070 (@0x008926ce), 0x00848e50 (@0x008926d9) and 0x0082ce00 (@0x00892700). The whole draw subtree of a SUCCESSFUL raid encounter hangs off it and has never been reached by any measurement in this campaign",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "SpawnEncounterSquadron_DrawSite1",
"addr": "0x0088f1eb",
"convention": "site",
"prototype": "call RNG_NextInt (0x004271c0) inside TradeManager_SpawnEncounterSquadron 0x0088f070, generator loaded at 0x0088f1da from +0x16c. One word plus its rejection loop. Reachable only past CreateRaidEncounter_EmptyCandidateListReturn",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "SpawnEncounterSquadron_DrawSite2",
"addr": "0x0088f356",
"convention": "site",
"prototype": "call RNG_NextInt (0x004271c0) inside TradeManager_SpawnEncounterSquadron 0x0088f070, generator loaded at 0x0088f334. Same reachability",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "RaidEncounter_SquadronHelper",
"addr": "0x0083b1e0",
"convention": "thiscall",
"prototype": "Called only from TradeManager_SpawnEncounterSquadron at 0x0088f0f7. Draws RNG_NextInt at 0x0083b460 on the strategic generator (loaded at 0x0083b44c). Body not read; listed because the sweep places it inside CreateRaidEncounter's unreached subtree",
"status": "mapped",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "RaidEncounter_SquadronHelper_DrawSite",
"addr": "0x0083b460",
"convention": "site",
"prototype": "call RNG_NextInt (0x004271c0) inside 0x0083b1e0, generator from +0x16c at 0x0083b44c",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "RaidEncounter_Helper848e50",
"addr": "0x00848e50",
"convention": "thiscall",
"prototype": "Called only from TradeManager_BuildRaidEncounter at 0x008926d9. Draws RNG_NextInt at 0x00848fd9 on the strategic generator (loaded at 0x00848fc8). Body not read",
"status": "mapped",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "RaidEncounter_Helper848e50_DrawSite",
"addr": "0x00848fd9",
"convention": "site",
"prototype": "call RNG_NextInt (0x004271c0) inside 0x00848e50, generator from +0x16c at 0x00848fc8",
"status": "verified",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
},
{
"name": "RaidEncounter_Helper82ce00",
"addr": "0x0082ce00",
"convention": "thiscall",
"prototype": "Called only from TradeManager_BuildRaidEncounter at 0x00892700; calls TradeManager_ComputeRaidInterceptPoint at 0x0082cf65, which is where the NextFloat at 0x00820c1b is spent on this path. Body not read",
"status": "mapped",
"source": "findings/control-flow/gate-indexed-rng-audit.md (lane AG 2026-09-09)"
}
]
}

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ d816252, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 48db3cc, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1237,6 +1237,50 @@ constexpr uint32_t Game_StarSystem_OutputRates_Write = 0x00345190;
constexpr uint32_t Game_StarSystem_OutputRates_Read = 0x003472a0;
// layout sizeof(Game::StarSystem::OutputRates) -- enumeration meets embedding [verified]
constexpr uint32_t sizeof_Game_StarSystem_OutputRates = 0x0000001c;
// thiscall int __thiscall (ServerSystem* this, int speciesIdx) // 91 B, RET 4. Reads this->addiction[speciesIdx] at +0x1e4+4k -- the int[7] table serialised as `nadct` + sparse (`ads`,`adt`) pairs. Returns 0 when the slot is 0; otherwise 1/2/3 by (Frame - stamp) against *StrategyVar_ADDICTION_PHASE2_START (10) and *_PHASE3_START (15). EVERY non-zero return path ends in `setne al`, so a caller reading AL gets the BOOLEAN `addiction[k] != 0` and the phase is discarded -- which is how ServerTradeManagerImpl_vslot15 uses it [verified]
constexpr uint32_t ServerSystem_GetAddictionPhase = 0x00345e40;
// thiscall void __thiscall (ServerSystem* this, int speciesIdx) // 621 B, RET 4. Refuses if this->+0x100 (owner) is null, if bit 5 of owner->[0x348 + 4*speciesIdx] is set, or if ServerSystem_GetAddictionPhase already reports the slot stamped. Otherwise computes a money term, posts EVENTSUM/EVENTMSG_ADDICTION_OUTBREAK and writes this->addiction[speciesIdx] = GetGame()->Frame(+0xc). DRAWS NOTHING. ONLY TWO CALLERS IMAGE-WIDE, both inside ServerTradeManagerImpl_vslot15 (0x0082cd50 the un-rolled seed grant, 0x0082cdc4 behind the Chance) -- so this function is the sole way any system becomes addicted, and the Chance-gated spread cannot fire until the un-rolled grant has seeded it [verified]
constexpr uint32_t ServerSystem_BeginAddiction = 0x0034ef70;
// thiscall void __thiscall (int speciesIdx /*ECX*/, int* out /*EAX*/) // jump table at 0x0053b6e8, seven entries; Ghidra's size 78 truncates before the table (rule 17). Writes the 10000-based TechID of that species' ADDICT TECH: k=0 0x2799, 1 0x279a, 2 0x279b, 3 0x279c, 4 and 5 -> 0xC5 (the image's `no tech` sentinel), 6 0x279d. Out-of-range logs `Species %s: No addict tech.` (0x009f9630). Called once, from InitTable 0x005453a0 at 0x005458a7, filling SpeciesDef+0x88 -- which is BIT 4 of ServerPlayer+0x348+4k in ServerPlayer_RebuildSpeciesTechFlags, the bit that gates the un-rolled addiction grant. NOT a wire techId, so tools/techid_table.py does not name these [verified]
constexpr uint32_t SpeciesDef_AddictTechForSpecies = 0x0013b620;
// data float -- the probability ServerTradeManagerImpl_vslot15 passes to Mars::RNG::Chance at 0x0082cdb8, reached as `mov ecx,ds:0x00af0c50; fld [ecx]` (0x00af0c50 holds a pointer to this storage word). Image default 0.2f, strictly inside (0,1), so Chance takes NEITHER early-out and the site costs EXACTLY ONE WORD per evaluation. Registered by a GlobalConst static-init stub against Data/Strategy/StrategyVars.txt with the %f parser 0x008b7020 [verified]
constexpr uint32_t StrategyVar_ADDICTION_SPREAD_ODDS = 0x006f0c4c;
// data int -- image default 10. ServerSystem_GetAddictionPhase returns 2 rather than 1 once (Frame - addiction[k]) exceeds this. Pointer at 0x00aeca58. Dead for the vslot15 gate, which only reads the boolean [verified]
constexpr uint32_t StrategyVar_ADDICTION_PHASE2_START = 0x006eca54;
// data int -- image default 15. ServerSystem_GetAddictionPhase returns 3 once (Frame - addiction[k]) exceeds this. Pointer at 0x00aeca60 [verified]
constexpr uint32_t StrategyVar_ADDICTION_PHASE3_START = 0x006eca5c;
// thiscall bool __thiscall (ServerSystem* this) // 12 B. `return this->+0x1c8 != 0` -- the IndependenceInfo* that the wire carries as `hindi` (present) + `indi`. ServerSpyManager_vslot14 rejects a spy whose target system passes this, which is conjunct G3 of the 0x0088dc43 counter-mission roll [verified]
constexpr uint32_t ServerSystem_IsIndependent = 0x00343fa0;
// marker The last byte of ServerSpyManager_vslot14's body (the `ret`). GHIDRA REPORTS 387 BYTES, i.e. an end of 0x0088dd03, WHICH IS INSIDE THE FUNCTION -- a third instance of rule 17 after 0x007aa240 and 0x00893290. A five-entry jump table for the mission-stage switch sits at 0x0088dd2c..0x0088dd3f, past both the Ghidra end and the real end; padding then runs to the next function start 0x0088dd40 [verified]
constexpr uint32_t ServerSpyManager_vslot14_RealEnd = 0x0048dd2b;
// cdecl bool __cdecl (StarFleet* fleet) // 91 B. `fleet && GetLocationAsTradeSector(fleet) && !FUN_00813ab0(fleet,0x80000,0) && TradeSector_PlayerTradesHere(sector, fleet->owner) && !FUN_006fe1d0(fleet, 0x800)`. TradeManager_Slot13RngCalleeB uses it to choose between two candidate filters. IT IS DETERMINISTICALLY FALSE for any fleet that reached B through vslot13: TradeSector_FleetMayRaid (0x0080ed50) demands the OPPOSITE on the last two conjuncts, so the `raid your own routes` arm of B is unreachable from the tail. PlayerAlliances_Relation returning 3 for self is the second, independent reason [verified]
constexpr uint32_t TradeSector_FleetRaidsOwnRoutes = 0x0040ecf0;
// thiscall ServerTradeManager method, 974 B. The function that DISAMBIGUATES Game::TradeRoute's endpoint fields: `if (rt->trfrs(+0x10) == 0) endpoint = GetGame()->Systems(+0x44)[rt->trfr(+0xc)] else endpoint = registry_lookup(GetGame()+0x84, rt->trfrs)`. So trfr / trto are SYSTEM INDICES into StrategyServer's system vector and trfrs / trtos are non-system endpoint IDS with 0 meaning `use the index`. Only the endpoint-resolution head of this function was read; the rest is unread [mapped]
constexpr uint32_t TradeManager_ResolveRouteEndpointOwner = 0x00441cd0;
// thiscall bool __thiscall (ServerTradeManager* this, Vector3* out, TradeRoute* rt, void* arg3) // 425 B, RET 0xC. Places the raid interception point along the route: a = Systems[rt->trfr]->Pos, b = Systems[rt->trto]->Pos, dist = |b-a|; frac = 1.0 when the route owner's species (+0x5c) is 1 or when either of two species tests on arg3 passes, ELSE frac = float(NextFloat()) drawn at 0x00820c1b; out = a + dir*(frac*dist + <double 0x009e20a0>). TWO CALLERS: 0x0082cf65 (inside CreateRaidEncounter's subtree) and 0x0088b62c (TradeManager_Slot13RngCalleeB, immediately after the target pick) [verified]
constexpr uint32_t TradeManager_ComputeRaidInterceptPoint = 0x00420af0;
// site call RNG_NextFloat (0x0047d830) inside TradeManager_ComputeRaidInterceptPoint, ECX = GetGame()->RNG(+0x16c) + 4. ONE STRATEGIC-GENERATOR WORD, unless the species short-circuit at 0x00820bc2/0x00820bd7/0x00820bf2 sets frac = 1.0 first. NOT IN ANY EARLIER INVENTORY. It makes TradeManager_Slot13RngCalleeB cost TWO words on a success (this plus RNG_NextInt at 0x0088b613), not the 0 lane AC measured on a state whose candidate list was empty [verified]
constexpr uint32_t TradeManager_RaidInterceptPoint_DrawSite = 0x00420c1b;
// thiscall ServerTradeManager method called at 0x008938ed as the FIRST act of ServerTradeManager_CreateRaidEncounter; fills the local vector at [ebp-0x1c]. When it comes back EMPTY, CreateRaidEncounter returns false at 0x008938fa -- BEFORE every draw in the function and its subtree. This is the gate lane H measured as `entered 3 times, 0 words'. Body not read [mapped]
constexpr uint32_t TradeManager_BuildRaidCandidates = 0x0043b110;
// site `jne 0x00893932` -- taken when TradeManager_BuildRaidCandidates produced a non-empty vector; the fall-through frees the vector and returns false with AL = 0. EVERY draw in CreateRaidEncounter and its subtree lies past this branch: 0x008939ee, then through 0x00892640 the five sites 0x0088f1eb, 0x0088f356, 0x0083b460, 0x00848fd9, 0x00820c1b. tail-rng-ledger.md section 11.1's `a success costs 0 or 1 further word' is right about the corpus and wrong about the code [verified]
constexpr uint32_t CreateRaidEncounter_EmptyCandidateListReturn = 0x004938fa;
// thiscall ServerTradeManager method, single caller ServerTradeManager_CreateRaidEncounter at 0x00893a25 (i.e. past the empty-candidate-list gate AND past the target pick at 0x008939ee). Calls TradeManager_SpawnEncounterSquadron 0x0088f070 (@0x008926ce), 0x00848e50 (@0x008926d9) and 0x0082ce00 (@0x00892700). The whole draw subtree of a SUCCESSFUL raid encounter hangs off it and has never been reached by any measurement in this campaign [verified]
constexpr uint32_t TradeManager_BuildRaidEncounter = 0x00492640;
// site call RNG_NextInt (0x004271c0) inside TradeManager_SpawnEncounterSquadron 0x0088f070, generator loaded at 0x0088f1da from +0x16c. One word plus its rejection loop. Reachable only past CreateRaidEncounter_EmptyCandidateListReturn [verified]
constexpr uint32_t SpawnEncounterSquadron_DrawSite1 = 0x0048f1eb;
// site call RNG_NextInt (0x004271c0) inside TradeManager_SpawnEncounterSquadron 0x0088f070, generator loaded at 0x0088f334. Same reachability [verified]
constexpr uint32_t SpawnEncounterSquadron_DrawSite2 = 0x0048f356;
// thiscall Called only from TradeManager_SpawnEncounterSquadron at 0x0088f0f7. Draws RNG_NextInt at 0x0083b460 on the strategic generator (loaded at 0x0083b44c). Body not read; listed because the sweep places it inside CreateRaidEncounter's unreached subtree [mapped]
constexpr uint32_t RaidEncounter_SquadronHelper = 0x0043b1e0;
// site call RNG_NextInt (0x004271c0) inside 0x0083b1e0, generator from +0x16c at 0x0083b44c [verified]
constexpr uint32_t RaidEncounter_SquadronHelper_DrawSite = 0x0043b460;
// thiscall Called only from TradeManager_BuildRaidEncounter at 0x008926d9. Draws RNG_NextInt at 0x00848fd9 on the strategic generator (loaded at 0x00848fc8). Body not read [mapped]
constexpr uint32_t RaidEncounter_Helper848e50 = 0x00448e50;
// site call RNG_NextInt (0x004271c0) inside 0x00848e50, generator from +0x16c at 0x00848fc8 [verified]
constexpr uint32_t RaidEncounter_Helper848e50_DrawSite = 0x00448fd9;
// thiscall Called only from TradeManager_BuildRaidEncounter at 0x00892700; calls TradeManager_ComputeRaidInterceptPoint at 0x0082cf65, which is where the NextFloat at 0x00820c1b is spent on this path. Body not read [mapped]
constexpr uint32_t RaidEncounter_Helper82ce00 = 0x0042ce00;
// thiscall void (Game::StrategyAIAgent::Streamable* this, Mars::Stream* s) // the body of every `Player.<id>.AIAgent` CD block. 36 wire items, NO conditionals: the only `if` the decompiler shows around `lnat` is an inlined std::vector destructor whose operator delete is marked noreturn, and both paths converge at 0x006c72e8. The agent object is *(this+4) [verified]
constexpr uint32_t Game_StrategyAIAgent_Streamable_Write = 0x002c6f00;
// thiscall bool (Game::StrategyAIAgent::Streamable* this, Mars::Stream* s) [verified]
@ -1319,6 +1363,18 @@ constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054;
constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0;
// thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified]
constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780;
// __thiscall Game::TradeSector* (Game::StarFleet* this) /* THE GATE NOBODY HAD SATISFIED. Reads StarFleet+0xa0 (`LocID`, resolved to a StarMapNode*) and returns it ONLY if node+0x14 == 2, else NULL. Whole body, validated at this address: `mov eax,[ecx+0xa0]; test eax,eax; je +8; cmp dword ptr [eax+0x14],2; je +2; xor eax,eax; ret`. Called twice from ServerTradeManagerImpl::vslot13 (0x0088ef80+0x39 and +0x98) and once from the raid predicate 0x0080ed50. Because every fleet in every corpus save before this lane was parked at a STAR (kind 0), this returned NULL for all of them, which is why the trade-raid callees had never been entered by any lane. CORROBORATED BY SAVE: after this lane moved a fleet onto a sector node, its serialized `LocID` reads 832, which is the `TradeID` of trade sector 6 in the same file, and its `Pos` is bit-equal to that sector's. */ [mapped]
constexpr uint32_t StarFleet_GetLocationAsTradeSector = 0x002fe320;
// __thiscall void (Mars::StarMapNode* this, int kind) /* StarMapNode::StarMapNode(int). Stores the ctor argument to this+0x14, the word GetLocationAsTradeSector tests. Enumerating EVERY call site is what pins the kind constants, and there are exactly three: Game::StarSystem's ctor 0x00752ea0 passes 0, Game::StarFleet's 0x00706140 passes 1, and Game::TradeSector's 0x00858020 passes 2. So node+0x14 == 2 means TradeSector and nothing else. Prologue validated at this address (push ebp; mov ebp,esp; push -1; push 0x982dc8; fs:[0] SEH frame; security cookie from 0xaf8b78). NOT READ IN GHIDRA: the rest of the body. */ [mapped]
constexpr uint32_t StarMapNode_ctor_kind = 0x00330900;
// __cdecl bool (Game::StarFleet* fleet) /* The raid predicate -- gate G3 of ServerTradeManagerImpl::vslot13, called at 0x0088efc7. Returns false unless: the fleet's location is a trade-sector node; FUN_00813ab0(fleet, 0x80000, 0) is false (a runtime-only 64-bit flag pair at StarFleet+0xb8/+0xbc, NOT serialized, so it has no save tag); the fleet has an owner (StarFleet+0x58 -> Game::ServerPlayer*); and the owner's player index bit is set in the sector's `tscr` mask at TradeSector+0x90. If the owner's bit is ALSO set in `tsct` (TradeSector+0x8c), or the owner's ServerPlayer+0x5c == 4, the fleet must additionally carry StarFleet.FtFlg (+0x10c) & 0x800. Mask test validated at 0x0080ed8f: `mov ecx,[edx+0x28]; push edi; mov edi,1; shl edi,cl; test [eax+0x90],edi`. THE UI SURFACES THIS PREDICATE VERBATIM: ordering a fleet onto a sector node without it pops `<fleet> cannot raid trade sector.` (MOVEWARNING_NO_RAID); the server-side twin string is `OnCommand: Fleet not able to raid.` */ [mapped]
constexpr uint32_t TradeSector_FleetMayRaid = 0x0040ed50;
// __cdecl bool (Game::TradeSector* sector, Game::ServerPlayer* player) /* Tests the player's index bit in the sector's `tsct` mask (TradeSector+0x8c) at 0x0080e9de..0x0080e9e8, short-circuiting true when player->+0x5c == 4. Used by the raid predicate to decide whether the raider additionally needs FtFlg & 0x800, and by the juicy-target scan 0x0081a150. CORROBORATED BY SAVE: `tsct` is 2 on the three sectors the AI (player index 1) trades through and 0 on sector 6, which is the human player's -- and the human raider on sector 6 needed no flag. */ [mapped]
constexpr uint32_t TradeSector_PlayerTradesHere = 0x0040e9c0;
// __cdecl void (Game::TradeSector* sector, uint32 keepMask) /* ANDs six consecutive words with a low-N-players mask: +0x88 `tssec`, +0x8c `tsct`, +0x90 `tscr`, +0x94 `ptssec`, +0x98 `ptsct`, +0x9c `ptscr` (stores at 0x0080ead7, 0x0080eadd, 0x0080eae3, 0x0080eae9, 0x0080eaef, 0x0080eaf5). This is what identifies all six of those save tags as PER-PLAYER BITMASKS rather than counts or colours, which had never been established -- `tscr` reads 252 in every save in the corpus and 252 is 0b11111100, i.e. the six NPC player slots. CORROBORATED BY SAVE: researching CCC_ComRaid takes `tscr` from 252 to 253 on all six sectors of the same game, i.e. it sets bit 0, the researching player's index, and sets that player's serialized `CnRad`. NOT LOCATED: the writer that sets a bit -- no `or`/`bts` against +0x8c/+0x90 was found in .text, so the masks are rebuilt wholesale somewhere this lane did not find. */ [mapped]
constexpr uint32_t TradeSector_TrimPlayerMasks = 0x0040eac0;
// __none /* NOT A FUNCTION -- the `sdet == -1` arm inside Game::ServerSpyManager::vslot13 (0x008877b0), branched to from 0x00887856. It calls FUN_0081f570(spy, system, owner), tests FUN_00743f80(system) & 1, and on that bit ROLLS FUN_008e6dd0(StrategyServer+0x16c, the RNG, with spy->sdo at SpyCraft+0x3c), then stamps sdet = turn and posts EVENT_SPY_DETECTED. Recorded here because it is a SECOND, INDEPENDENT WAY FOR THE TAIL TO DRAW that no entry probe on SpyManager::Slot13RngCallee can see: the draw is inline in the outer callee. It is unreachable in every save in the corpus because the gate before it, `SpyCraft.deat (+0x10) != 0` at 0x0088781d, rejects a built-but-undeployed spy. HYPOTHESIS, NOT MEASURED (rule 6): no save has a deployed spy and this lane did not build one. */ [hypothesis]
constexpr uint32_t SpyManager_DetectionRoll_Branch = 0x00487c5b;
// thiscall void __thiscall Game::StrategyApp::RunAI(int playerNetId, const char* aiCustomDataName, unsigned char aiPersonality, unsigned int rngSeed) -- RET 0x10, four stack args. The ONE-SHOT AI construction path, reached only from StrategyNetworkClient::OnMessage 0x00784640+0x96e, case SNMRunAI (net msg id 0x3d). Resolves the player through the handle registry at server+0x84; refuses on a human (`p->IsAI(+0xf9) == 0` -> "RunAI: Cannot create a StrategyClient/AI for a human player."); logs "RunAI: Creating AI client for %s using %08x for random seed."; operator_new(0x708) + StrategyClient ctor 0x00782ed0 WITH rngSeed; StrategyServer::InitGame; StrategyClient::CreateAI 0x007653c0(aiPersonality) which builds the Game::StrategyAIAgent into StrategyClient+0x12c ("RunAI: Failed to create AI for %s (player %d)."); if a save-game blob was supplied, "Loading AI custom data from save game..." through agent vt[5]/vt[6]; finally RaiseAIPrepareTurn 0x00815f20. NOT a per-turn entry point [verified]
constexpr uint32_t StrategyApp_RunAI = 0x004706f0;
// cdecl void (Game::StrategyServer* srv) with EBX = Game::StrategyAIAgent* (register-passed; both call sites set EBX before the call). Builds a stack Game::SEAIPrepareTurn (vftable 0x00a23c00) and invokes agent->vt[1](9, &ev) -- i.e. StrategyAIAgent::OnEvent with client event type 9. Also fires the SVScriptObject hooks at srv+0x1b4 with ids 9 and 0xa. Exactly two call sites: RunAI 0x008706f0+0x26c and StrategyApp::CreateGame 0x00888e80+0x3f5 -- both are game/AI construction, so SEAIPrepareTurn is NOT raised once per turn despite the name [verified]
@ -1831,6 +1887,28 @@ constexpr uint32_t InlinedDrawScan_FalsePositive_008cca30 = 0x004cca8f;
constexpr uint32_t MT_TemperMask1 = 0xff3a58ad;
// constant 0xffffdf8c -- the second tempering mask as the image spells it, `y ^= (y & 0xffffdf8c) << 15`, equal to the textbook `(y << 15) & 0xefc60000` (0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Image-wide there are 34 occurrences of these bytes inside decoded instructions; 33 are genuine and ONE (0x008cca90) is the rel32 displacement of a call. Always require BOTH masks plus a preceding `shr r32,0xb` before calling a hit a draw [verified]
constexpr uint32_t MT_TemperMask2 = 0xffffdf8c;
// thiscall bool (IDMap* this, int numNodes, int localNode, int startId) // ret 0xc. THE SEEDING ENTRY POINT, and it names the save's three NM* tags. Calls FUN_008b8f80 (clear), then REFUSES numNodes > 0x10 ('IDMap: Cannot support %d nodes.') -- SIXTEEN is the hard cap, and it is the low nibble of IDMap_AllocateID's id. Refuses localNode outside [0, numNodes) ('IDMap: Node %d does not exist, valid nodes are 0-%d.'). On success: resize the 0x14-stride node vector at this->+0x08 via FUN_008b99d0, ZERO EVERY NODE'S COUNTER (the loop at 0x008b9b20 stores 0 to +eax+0x10, eax += 0x14), then write startId into nodes[localNode].counter ALONE (0x008b9b38), then this->+0x18 = localNode (0x008b9b3d). SO: exactly one node's counter is ever seeded; every other node starts at 0, and nothing else in the class ever restores one -- IDMap_Insert 0x008b9350 does NOT touch a counter. Four call sites: StrategyServer_Read +0x105 = Initialize(NMSz, NMLc, NMnx) on S+0x84 -- WHICH NAMES THE TAGS: NMSz = node count, NMLc = LOCAL NODE INDEX, NMnx = that node's counter; StrategyServer_LoadGame +0x107 = Initialize(16, 0, 0); StrategySim_OnCreateGame +0x45 = Initialize(msg->+4, msg->+8, msg->+0xc) on the CLIENT's map at +0x80; and FUN_008a9f80 +0x1ce = Initialize(1, 0, 0) on an unrelated Mars scene object's map at +0x68 [verified]
constexpr uint32_t IDMap_Initialize = 0x004b9ad0;
// thiscall int (IDMap* this) // 10 B: push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. The IDMap-relative form of the same thunk IDMap_AllocateLocalID 0x0080f710 wraps from the StrategySim side (that one is `ecx += 0x80` then jmp here). Five direct call sites, all StrategySim methods reached on BOTH the server and a client: StrategySim_CreateDesign +0xe0, FUN_008713a0 +0x3df, SystemBuildQueue_AttachBuiltShip 0x0088e7f0 +0x42, TradeManager_SpawnEncounterSquadron 0x0088f070 +0x216 and +0x28f [verified]
constexpr uint32_t IDMap_AllocateOnLocalNode = 0x004b8b70;
// thiscall int (IDMap* this, int nodeIndex) // 29 B, ret 4: node = IDMap_FindNode(nodeIndex); return node ? node->+0x10 : 0. A READ of a per-node counter with no side effect. Its only interesting caller is StrategyServer_InitGameForPlayer 0x007c8f05, which uses it to SEED a client: the CreateGame message carries the server's current counter for the node that client is about to own. Since a save restores only ONE node's counter (Initialize zeroes the rest), this returns 0 for every client node in a freshly loaded game [verified]
constexpr uint32_t IDMap_GetNodeCounter = 0x004b8b80;
// thiscall void* (IDMap* this, int nodeIndex) // 101 B, ret 4. Bounds-checks nodeIndex against (this->+0x0c - this->+0x08)/0x14 -- the 0x66666667 / sar 3 divide-by-20 that pins the NodeEntry stride at 0x14 -- logs 'IDMap: Node %d does not exist, valid nodes are 0-%d.' and returns 0 when out of range; else returns _Myfirst + nodeIndex*0x14. Shared by IDMap_AllocateID and IDMap_GetNodeCounter [verified]
constexpr uint32_t IDMap_FindNode = 0x004b8a70;
// thiscall void (StrategySim* this, CreateGameMsg* msg) // ret 4. CASE 0 of StrategyClient::RaiseEvent 0x00783ee0's 0x2c-entry jump table at 0x00784200 (the handler body is at 0x00783f05). FIRST ACT: IDMap_Initialize(this+0x80, msg->+0x04 numNodes, msg->+0x08 localNode, msg->+0x0c startId) -- THIS IS WHERE A CLIENT'S ID SPACE IS SET UP, and the only path by which an IDMap ever gets a local node index other than 0. Then copies the rest of the message into the sim: +0x10 -> this->+0x08, +0x48 -> this->+0x10, +0x14 -> this->+0xb8, the two floats at +0x18/+0x1c -> this->+0xbc/+0xc0, the bools at +0x20/+0x21 -> this->+0xc4/+0xc5, +0x6c -> this->+0x154, +0x4c -> this->+0xf8, then the vectors from this+0x40 on [verified]
constexpr uint32_t StrategySim_OnCreateGame = 0x00376f20;
// thiscall void (StrategyServer* this /*the S frame*/, int playerObjectId, int arg2) // THE NODE-INDEX ASSIGNMENT, in four instructions. Resolves the player through this->+0x84 (the id-to-object lookup FUN_008b9240) and takes esi = player->PlyrIdx(+0x28). Then at 0x007c8ecb: `cmp esi,-1 / je L / inc esi / jmp / L: xor esi,esi` -- localNode = (PlyrIdx == -1) ? 0 : PlyrIdx + 1. NODE 0 IS THE SERVER'S; PLAYER k GETS NODE k+1. It then fills the CreateGame message on the stack at [ebp-0x138]: +0x00 vtable 0x00a252c0, +0x04 numNodes = (server IDMap node vector length, the /20 divide at 0x007c8ee1 over ebx+0x8c/+0x90), +0x08 localNode = that esi, +0x0c startId = IDMap_GetNodeCounter(S+0x84, localNode). Sends it, and StrategySim_OnCreateGame is what receives it. See findings/subsystems/id-allocation.md section 3 for why startId is always 0 for a client after a save load [verified]
constexpr uint32_t StrategyServer_InitGameForPlayer = 0x003c8d90;
// thiscall void (StrategyServer* this, ...) // At +0x107 it calls IDMap_Initialize(this+0x84, 16, 0, 0) -- SIXTEEN NODES, LOCAL NODE 0, COUNTER 0. That is where the corpus's NMSz 16 / NMLc 0 come from, and it is why every id created by the host carries low nibble 0 [verified]
constexpr uint32_t StrategyServer_LoadGame = 0x003dd530;
// thiscall void (StrategySim* this, ServerPlayer* owner, void* params, int explicitId, char, char, char, char) // ret 0x18. THE CLIENT-ALLOCATES / SERVER-HONOURS SPLIT, in one branch. operator new(0x1a8) -> ctor FUN_00874c70 -> FUN_0057c6d0(params); design->+0x130 = owner, design->+0x134 = this->+0x08. Then at 0x008828b3: `if (explicitId != 0) use it; else id = IDMap_AllocateOnLocalNode(this+0x80)`, followed by IDMap_Insert(this+0x80, design+0xa0, id). BECAUSE StrategyClient AND StrategyServer BOTH DERIVE FROM StrategySim, this is the SAME code on both sides: the client runs it with explicitId 0 and mints an id on ITS node, puts that id in the turn command, and the server runs it again with explicitId set and mints nothing. A reimplementation that allocates on apply produces every AI-created id wrong [verified]
constexpr uint32_t StrategySim_CreateDesign = 0x004827e0;
// label THE techId -> NAME MAP, in eight instructions inside MasterTechTree_ctor 0x0058b870, immediately after the LoadTechFile loop. 0x0058b9df: vector::operator=(this+0x24, this+0x14) -- the sorted list at +0x24 is a COPY of the parse-order list at +0x14, which is why a parse-order dump does not match the ids. 0x0058b9ff: std::sort(first=[this+0x24], last=[this+0x28], ideal=(last-first)/4, pred) = FUN_00583b10, MSVC _Sort (the _ISORT_MAX 0x20 test and the _Ideal 3/4 halving are both there); the predicate is INLINED in FUN_00581130 at 0x00581190 and 0x005811b8 as `_stricmp(a->name, b->name) < 0` on TechDef+0x04 (a std::string, SSO-tested at +0x14 against 0x10) -- MSVCR100 _stricmp, so the order is CASE-INSENSITIVE, not byte-wise. 0x0058ba19..0x0058ba37: `for (i = 0; i < n; ++i) sortedList[i]->+0x00 = i;` -- THE WIRE techId IS LITERALLY THE 0-BASED INDEX INTO THE _stricmp-SORTED MASTER LIST. The same sorted vector is then the binary-search index: FUN_0057f120 (std::lower_bound) resolves every `requires`/`allows` name against it at 0x0058baed. NOT the 10000-based TechID enum, which is a separate 196-entry .rdata table [verified]
constexpr uint32_t MasterTechTree_SortAndNumber = 0x0018b9df;
// thiscall bool (TechTree* this /*per-player*/, int techId) // THE WIRE-SPACE BOUNDS CHECK, and it is what proves the space. Rejects techId < 0 and techId >= (this->+0x14 - this->+0x10)/4 -- the per-player node vector, 293 entries in stock data. Then esi = masterList[techId] from this->+0x04 -> +0x24/+0x28 (the SORTED vector, MasterTechTree_SortAndNumber) and edx = this->+0x10[techId] (the player's node). Reads the name as a std::string at masterEntry+0x04 with the SSO test at +0x18 -- so TechDef is {+0x00 int techId, +0x04 std::string name, ...}. Called by the research-target gate applier at 0x0088ff49 with the techId straight off the command payload ([block-0x28]) [verified]
constexpr uint32_t TechTree_IsResearchable = 0x0017e820;
// cdecl void (T** first, T** last, int ideal, Pred pred) // MSVC std::sort's _Sort: the `(last-first)/4 <= 0x20` insertion-sort cutoff at 0x00583b26, the `ideal -= ideal/2 + ideal/4` heap-sort fallback counter, and _Unguarded_partition FUN_00581130. Identified here because MasterTechTree_ctor uses it to build the tech id space; the same body will be reached from anywhere else that sorts a pointer vector [verified]
constexpr uint32_t MSVC_Sort = 0x00183b10;
// thiscall void (CombatResolveContext* this) // PLAIN RET, no stack args. THE COMBAT RESOLVER under phase 6 of StrategyServer::OnAllCombatDone_Tail. Exactly one caller: StrategyServer::ApplyEncounterResult 0x007d8920 at 0x007d8d24, on the full-battle path only (res->+0x4 == 0). REAL BODY IS 0x007d5af0..0x007d78c8 = 7641 B; Ghidra's 7499 stops mid-instruction at 0x007d783b. SHAPE, read from the instruction stream: (1) prologue + 16 unconditional this-calls 0x007d5b1e..0x007d5c02; (2) ONE loop over enc->members, stride 0x44, 0x007d5c30..0x007d779d -- 7021 of the 7641 bytes, with four inner loops and no other outer control flow; (3) five more unconditional this-calls 0x007d77a3..0x007d77cd; (4) a victor block gated on ctx->+0xa34 != -1; (5) FUN_0079c740 and the epilogue. ONLY EIGHT NON-STACK STORES IN THE WHOLE BODY and exactly ONE indirect call (inside a _CxxThrowException path): it composes and posts per-player events and delegates every state mutation to callees. Strings it composes: EVENT_<TYPE>_FIGHT, EVENTSUM_<TYPE>, EVENTMSG_<...>, EVENT_TRADERAIDERS, EVENT_COMBAT_OBSERVED, EVENT_DEFEAT, EVENT_VICTORY, EVENT_ENGAGED, EVENT_STATION_KILLED, and the ENTITYVICTORY/ENTITYDEFEAT/UNRESOLVED outcome tokens. DRAWS NO RNG ITSELF -- see CombatResolve_NodeCannon and CombatResolve_SalvageBackEng [verified]
constexpr uint32_t CombatResolver_Run = 0x003d5af0;
// thiscall CombatResolveContext* (CombatResolveContext* this, StrategyServer* S /*the S frame*/, Encounter* enc, Game::EncounterResults* res) // built as a ~0xea0-byte STACK local at [ebp-0xea0] in StrategyServer::ApplyEncounterResult, immediately before the resolver call. Field assignment read from the instruction stream: this->+0x00 = S; this->+0x04 = operator new(0x5c) then FUN_005a13f0 (a per-player lookup object); this->+0x08 = enc; this->+0x0c = res; this->+0x10 = 0; this->+0x14 = 0 (byte); this->+0x18 = an empty std::string; this->+0x38 = FUN_00536890. Further fields the resolver uses: +0x290/+0x330/+0x430 per-player int arrays indexed by PlyrIdx*4; +0x7b0 + PlyrIdx*0x10 a per-player vector; +0x9b0 + PlyrIdx*4 the posted-event pointer; +0xa34 the winner PlyrIdx (-1 = none); +0xe7c = FUN_00787690(enc), set by the resolver's first act [verified]
@ -1933,6 +2011,10 @@ constexpr uint32_t SVSOSwarmQueen_off_Hives = 0x00000010;
constexpr uint32_t SVSOSwarmQueen_off_Queens = 0x00000020;
// field int -- the difficulty tier, wire tag "CDiff". Set to -1 by the ctor 0x0051a820 and written ONLY by SVSOSlaversRefuel_UpdateDifficultyTier, which stores (scanIndex - 1) and only when it differs. Reachable values are 0 (frame 1..49) and 1 (frame 50..99) ONLY: frame <= 0 exits at index 0 and frame >= 100 walks off the end of the 3-entry threshold table, so BOTH edges store nothing and the tier can never reach 2 [verified]
constexpr uint32_t SVSOSlaversRefuel_off_CDiff = 0x00000038;
// __thiscall void (ShipAction* this, ...) /* THE `Build Spy` SHIP-ACTION HANDLER -- the writer at the top of the only call chain that has ever grown Game::ServerSpyManager's spy vector. Trapped live: with a debug breakpoint on spyManager+0x10/+0x14 and a `Build Spy` order issued from the fleet panel's Special menu, the single allocation of that vector arrived as 0x00789620+0x53 -> 0x008383c0+0x9c -> 0x0059f1a0+0x5f -> 0x00483410+0x7c/+0x82, on the turn thread, inside the End Turn that applied the order. REACHABILITY: same indirection class as its sibling ShipBorneBuildQueue_ProcessTurn 0x00789500 (lane B6) -- 0x120 bytes away, zero direct call sites, no vtable slot; both are entries in the STACK-BUILT function-pointer table the ship-action dispatcher (lane T's 0x007b9c4b site, dispatched from StrategyServer 0x007dc92b/0x007dc952/0x007dcb0c) indexes by action type. NOT READ IN GHIDRA: the body, the action-type index and the argument list are unknown; what is measured is the entry's position in the chain and the fact that no other path grew the container across 7 instrumented turns */ [mapped]
constexpr uint32_t ShipAction_BuildSpy = 0x00389620;
// __thiscall void (Game::ServerSpyManager* this, ...) /* The spy-craft creator: the only frame between the Build Spy ship action and the push_back that grows the manager's vector at this+0x10..0x18. Trapped at +0x9c calling 0x0059f1a0 with that vector; the element it appends is the Game::SpyCraft whose serialized record then appears under `spymgr`/`spy` with sid=1, sown=<player id>, atto=<tender ship id>, tdep=-1. NOT READ IN GHIDRA: whether it also does the payment, the tender bookkeeping and the event post, or delegates them */ [mapped]
constexpr uint32_t ServerSpyManager_CreateSpyCraft = 0x004383c0;
// thiscall void __thiscall Game::AITColonize::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITColonizeGoal::Execute is the same address. 48 bytes; a forwarder to the parameterised worker 0x0068b280 called as worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10) with `edi = this->+0xc` passed as an IMPLICIT REGISTER ARGUMENT (whole-program-optimised custom convention; a reimplementation that ports only the stack arguments passes garbage). Reaches the colonize order (list 7) at depth 4 via 0x0068b280 -> 0x006930f0 -> 0x00578ff0 -> 0x00769640 [unverified]
constexpr uint32_t AITColonize_Execute = 0x0028b400;
// thiscall void __thiscall Game::AITEscortGateInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITEscortGateInvadeGoal::Execute is the same address. 80 bytes; forwards to 0x0068c5d0 as __fastcall(ecx = this->+0xc, edx = agent) plus 8 stack arguments. Reaches list 7 at depth 4 [unverified]
@ -2109,6 +2191,20 @@ constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopB = 0x004907b1;
// label the THIRD and last per-player gate loop inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x24`; it tests the group-4 gate at +0x2c and applies its {bool, int} payload through 0x00821b90. It is the final step of the whole batch. Together with the loop-A head at 0x0088fdb0 and the loop-B head at 0x008907b1, and the six inlined ModCount bump sites, this gives nine positions of the thirty-step apply schedule an address-monotonicity check -- the only part of the sequence that can be re-derived rather than inherited from the read of the `add edi, imm` chain [verified]
constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopC = 0x0049080a;
// custom int (ECX: StrategyAIAgent* agent, EDX: SectionContext parts[3], STACK: DesignRequest* req, STACK: char costOnly) /* ECX+EDX in registers, TWO stack arguments, and the CALLER cleans them (`add esp,0x8` at 0x006ae719) -- neither cdecl nor MSVC __fastcall, but the private convention MSVC gives a static function whose address never escapes. Returns 0 on success and 1..8 for eight distinct bail-outs: 1 over budget, 2 no hull candidate, 3 no command section, 4 no engine section, 5/6/7 a forced-tech lookup failed for request-flag bit 4/8/0x10, 8 no weapon matched a bank. `req` = {int hullSize; float budget; int role; uint flags}. `parts` is the same 0x124-stride three-section array lane D2 found on the other side of the pipeline at Game::ShipDesign::AggregateSectionStats: +0 ShipSectionDef*, +4.. one weapon id per weapon BANK, +0xcc the bank count (min(hull banks, 0x32)). `costOnly` non-zero returns right after pricing, having already drawn at sites A/B/C -- which is why one AI design request can cost two composer calls */ [verified]
constexpr uint32_t AIComposeShipBlueprint = 0x002ad700;
// custom void (ECX: ?, EDX: StrategyAIAgent* agent, STACK: ..., DesignRequest* req, char costOnly) /* the composer's ONLY caller. Builds a list of 1..3 hull sizes from req.hullSize and request-flag bits 0 and 1 (bit 0 adds size-1, bit 1 adds size+1 when size<2), clears both bits, then calls AIComposeShipBlueprint once per size and BREAKS ON THE FIRST SUCCESS. So one design request costs 1..3 composer calls, and every failed attempt has already spent the RNG words it reached before failing. Three call sites: 0x006cdaa6 and 0x006cdb1c in 0x006cda40 (the real design pass) and 0x006b7ecb in 0x006b7e40, which passes costOnly=1 */ [verified]
constexpr uint32_t AIComposeShipDesign = 0x002ae620;
// custom void* (EAX: int count, STACK: void** array) /* 0x1f bytes whole: `if (!count) return 0; return array[cl_RandRange(0, count-1)];`. The count arrives in EAX. This is a LIVE DRAW SITE that lane PAR's 23-row table does not list -- PAR measured it live at return address 0x00691ea0 (2 calls, 2 words on turn 1) without adding it to the static inventory, so the AI turn has 22 live sites, not 21. The composer calls it once, at 0x006ada61, to pick the command section */ [verified]
constexpr uint32_t AIPickRandomElement = 0x00291e90;
// offset size -- one entry of the three-section design context array the composer fills and Game::ShipDesign::AggregateSectionStats 0x00826af0 consumes. +0x000 ShipSectionDef* (null = empty slot), +0x004..+0x0cb up to 0x32 weapon ids, ONE PER BANK, which is exactly the DGbnk2 list the design record carries on the wire (SHIP_DESIGN_RULES B1: one weapon per bank{} block, in file order); +0x0cc the bank count = min((def+0x320 - def+0x31c)/0x30, 0x32); +0x0d0..+0x11f up to 20 candidate weapon ids gathered for that section; +0x120 that candidate count, capped at 20 -- the cap the string "AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS" names. Slot order in memory is mission, command, engine (verify/design-rules/SHIP_DESIGN_RULES.md 1), NOT the on-disk order. Live: on turn 1 a Human destroyer design gives bank counts 3 / 1 / 2 for DEExtendedRange / DECommand / DEFission [verified]
constexpr uint32_t AIShipSectionContext_stride = 0x00000124;
// offset std::vector<Bank> begin -- weapon-bank descriptors on a Game::ShipSectionDef, stride 0x30, end pointer at +0x320. Bank+0x00 = turretclass index (0 = `standard`, the class 1,533 of the 3,721 shipped banks carry; 0xc and 0x17 are two others the composer's large-bank pass also accepts), Bank+0x04 = turretsize index (1 = `small`, 3 = `large`). The composer's only loop-carried draw fires once per (class 0, size 1) bank -- a small standard bank -- and its job is to overwrite a fraction of them with a point-defence weapon [verified]
constexpr uint32_t ShipSectionDef_off_Banks = 0x0000031c;
// offset Mars::String -- the section's file name on a Game::ShipSectionDef; capacity word at +0x1c8, so the payload is the inline buffer when capacity < 0x10 and *(char**)(def+0x1b4) otherwise. The composer compares it case-insensitively against "DEPointDefence" and "CRPointDefence" at 0x006ae4e6/0x006ae4fd: a section whose name matches gets a point-defence weapon in EVERY qualifying bank instead of every D'-th one [verified]
constexpr uint32_t ShipSectionDef_off_Name = 0x000001b4;
// cdecl int (StrategyAIAgent* agent, int hullClass, std::vector<int>* restrictTo, int bankClass, int bankSize, void* ctx, int flag) /* seven stack arguments, caller cleans 0x38 for the two calls at 0x006ae3c1/0x006ae3dc. Returns a weapon id, or 0 when nothing fits. The composer calls it in the weapon-assignment loop and then TWICE more just before the point-defence pass: once with a one-element list holding the literal 0x25 (read as the point-defence weapon family -- INFERRED, from the DEPointDefence/CRPointDefence section names the same pass matches and the 0.2 probability it rolls) and once unrestricted. A null from the unrestricted call skips the whole point-defence block; a null from the restricted one makes every iteration short-circuit without drawing. Both make the loop-carried draw cost zero, which is why lane SD's probe records the two return values separately */ [verified]
constexpr uint32_t AIChooseWeaponForBank = 0x002ad2a0;
// thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified]
constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0;
// data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified]