diff --git a/findings/subsystems/spy-program-draws.md b/findings/subsystems/spy-program-draws.md new file mode 100644 index 0000000..f3761e9 --- /dev/null +++ b/findings/subsystems/spy-program-draws.md @@ -0,0 +1,301 @@ +# The spy program's remaining draws — `P`'s branch split, the counter-mission roll, and the deploy's own `NextFloat` + +- **Type:** subsystem (static decode + live measurement) +- **Owner / date:** lane AP · 2026-09-09 · guest **VM144** (`sots-re-win10-144`, `re@192.168.10.144`) +- **Instrument:** lane Z's `draw_sites` return-address ledger at **`probes=8`** (the byte-neutral set; + never `probes=11` — lane H bisected 11 as not byte-neutral) +- **Continues:** lane AS's `findings/subsystems/spy-detection-roll.md`, whose §6 named this workload + from its own failed conjunct; lane AG's `findings/control-flow/gate-indexed-rng-audit.md` §3.2 +- **Inputs:** `verify/results/saves/as-turn15-spydeployed.sav` (target 1), + `verify/results/saves/as-turn14-predeploy.sav` (target 2) + +*(Sections 0–3 were written and committed **before** the build and before any measurement, in their +own commit. Sections 4 onward are the measurement. Rule 2.)* + +--- + +## 0. Headline of the predictions + +> **The brief that created this lane asks for "`P`'s three draws". There are three sites but they are +> two mutually exclusive branches, and the branch selector is the spy owner's *species*.** From +> `as-turn15-spydeployed.sav` **exactly one** of the three can ever fire — `0x00840a3c` — because the +> spy's owner is player 0 (`re`), `Species = 0` (Human). `0x00840929` and `0x008409c7` are gated on +> `spyOwner->Species == 6`, i.e. **Morrigi**, and are unreachable from this save, from every save in +> the 22-save corpus, and from any workload that does not start a *Morrigi* empire. +> +> Predicted, before the run: `0x00840a3c` fires once, for one word, on the End Turn that produces +> `Frame == sdet + 3`; and two sites nobody expected fire on the way — the spy **counter-mission** +> roll `0x0088dc43` (lane AG §3.2, corpus 0 of 22, never fired) starting at **Frame 19**, and the +> deploy's own `NextFloat` `0x0078c97f` on a separate one-turn run. + +--- + +## 1. `P` decoded — `SpyManager::Slot13RngCallee` `0x008408e0`, complete + +Program `sots` / "Sword of the Stars.exe", ImageBase `0x00400000`, all addresses VAs. Body +disassembled from its start **to the next function start** (rule 17): `0x008408e0 .. 0x00840a5a`, +followed by `int3` padding. The ReVa MCP server is down (`CONNECTION_CLOSED`) for this lane too, so +this is instruction stream only. + +### 1.1 How `P` is reached, and that there is no further gate + +The chain in `ServerSpyManager::vslot13` `0x008877b0` from lane AS's §1, extended past the point AS +stopped at. **Between the last gate and the call to `P` there is no branch at all** — the whole span +`0x00887897 .. 0x00887af2` is two `EVENT_SPY_DESTROYED_OWNER`-shaped string builds, a `push_back`, +and an event post: + +``` +0088781d je 0x887e92 G1 spy.deat (+0x10) == 0 -> next spy +00887856 je 0x887c5b G2 spy.sdet (+0x40) == -1 -> BRANCH D (the detection roll) +00887867 jl 0x887e92 G3 (server.Frame - sdet) < 3 -> next spy +0088787a je 0x887c4f G4 ServerSystem_GetOwner(sysA) == NULL -> sdet := -1, next spy +00887891 je 0x887c4f G5 ServerSystem_GetOwner(sysA) == ownerO -> sdet := -1, next spy +008878a1 call 0x59f1a0 push_back spy.sid into a LOCAL vector at [ebp-0xdc] +008879dd call 0x8862b0 PostEvent "EVENT_SPY_DESTROYED_OWNER" +00887ae3 call 0x7437e0 eax = ServerSystem_GetOwner(sysA) +00887aee push eax ; arg2 = the TARGET system's owner +00887aef push edx ; arg1 = ownerO, the SPY's owner ([ebp-0xc0]) +00887af0 mov ecx,edi ; this = the spy manager +00887af2 call 0x8408e0 P <-- the false-flag draw +00887af7 ... +00887b07 cmp eax,ebx +00887b09 je 0x887b5b ; P returned NULL -> the "unknown empire" event string +``` + +so **once `sdet` is stamped and three turns have passed, `P` is called unconditionally** (given a +target system that is owned and not owned by the spy's own empire). `P`'s arguments are two +`Game::ServerPlayer*`, not ids — `P` compares them by pointer against entries of the player vector. + +### 1.2 The species branch — and why "three draws" is one draw + +``` +00840909 mov esi,[ebp+8] ; esi = arg1 = the SPY'S OWNER (a ServerPlayer*) +0084090c cmp dword ptr [esi+0x5c], 0x6 ; +0x5c = ServerPlayer::Species +00840910 jne 0x840a29 ; NOT species 6 -> BRANCH B + +; ---- BRANCH A, species == 6 only -------------------------------------------------------------- +00840919 fld dword ptr ds:0x9e5ac4 ; 0.75f (bytes 00 00 40 3f) +0084091f mov ecx,[eax+0x16c] ; THE STRATEGIC GENERATOR (the object, not +4) +00840929 call 0x8e6dd0 A1 ; Mars::RNG::Chance(strategic, 0.75f) ret 0x0084092e +00840930 je 0x840a45 ; roll failed -> return NULL + ; build a candidate list over StrategyServer +0x54..+0x58 (the player vector): + ; skip p if p->+0xfb != 0, p->+0xf8 != 0, p == arg1, or p == arg2 +008409ac je 0x840a07 ; list EMPTY -> free it, return NULL (no NextInt) +008409ae eax = count - 1 +008409b0 mov ecx,[...+0x16c]; add ecx,4 ; the +4 SUB-OBJECT (the NextInt convention) +008409c7 call 0x4271c0 A2 ; Mars::RNG::NextInt(&(count-1)) ret 0x008409cc +008409cf esi = list[eax] ; the blamed empire + return esi + +; ---- BRANCH B, every other species ------------------------------------------------------------ +00840a2c fld dword ptr ds:0x9e5ac0 ; 0.25f (bytes 00 00 80 3e) +00840a32 mov ecx,[edx+0x16c] ; the strategic generator +00840a3c call 0x8e6dd0 B1 ; Mars::RNG::Chance(strategic, 0.25f) ret 0x00840a41 +00840a43 jne 0x8409f2 ; roll SUCCEEDED -> return esi == arg1 (the true owner) +00840a45 xor eax,eax ; else return NULL +``` + +Both float constants were read as the **four bytes in the image** (rule 23): `0x009e5ac0` is +`00 00 80 3e` = `0.25f` and `0x009e5ac4` is `00 00 40 3f` = `0.75f`. `0x009e5ac4` is the *same* word +lane AS read as the species-5 multiplier in `SpyCraft_AccumulateDetectionOdds` — one constant, two +uses. + +**So `P` is the attribution roll for a destroyed spy, and it has two completely different shapes:** + +| spy owner | draw sites reached | semantics | +|---|---|---| +| `Species == 6` | `0x00840929` (`Chance` 0.75f), then `0x008409c7` (`NextInt`) **only if the roll succeeds and the candidate list is non-empty** | 75 % of the time a **random third empire** is blamed — a false flag. The true owner is *never* returned. | +| every other species | `0x00840a3c` (`Chance` 0.25f) | 25 % of the time the **true owner** is returned; otherwise nobody is blamed. | + +### 1.3 The species table, decoded from the corpus + +`Game::ServerPlayer +0x5c` is the field `objects/layouts.md` names **`Species`** (Write +`0x008563e0`). The `sim/species` node lists seven `ISsp` names in index order, identical in every +save read: + +| 0 | 1 | 2 | 3 | 4 | 5 | 6 | +|---|---|---|---|---|---|---| +| Human | Hiver | Tarkas | Liir | **_NPC** | Zuul | **Morrigi** | + +Cross-checked against lane V's `zuul-turn5-species5.sav`, whose three real players all carry +`Species = 5` — and lane V's hook independently reported `species=5` on the Zuul double-roll. The +four NPC empires (`Alien Menace`, `Peacekeeper Enforcer`, `Von Neumann`, `Independent Colony`) carry +`Species = 4` in every save. + +That also **names lane AS's two unexplained multipliers**: `SpyCraft_AccumulateDetectionOdds` +multiplies the per-turn detection odds by `0.75f` for **Zuul** and `0.5f` for **Morrigi**, and by +`1.0f` for everyone else. Morrigi are the stealth species on both sites — half the detection odds, +and a false flag when they are caught. + +### 1.4 What happens to the spy on that turn + +The local `vector` filled at `0x008878a1` is drained after the spy loop: + +``` +00887eb0..00887ee8 for each collected sid, back to front: + [[manager]+0x28](sid) ; vtable slot 10 = 0x00838480 +``` + +and `0x00838480` looks the `SpyCraft` up by `sid`, calls **`ServerSystem::RemoveSpy 0x0074f550`** on +the `deat` system (which clears `spy.deat` and erases `sid` from `spies2`), clears the tender's +`atto` back-pointer (`spy+0xc`, `ship+0xa8`), erases the craft from the manager's vector at `+0x10`, +and invokes the scalar deleting destructor. **The spy is gone at the end of `vslot13`**, which is +phase 23 — *before* `vslot14` runs at phase 33. + +### 1.5 The deploy's `NextFloat` — `0x0078c97f`, re-read + +`SHIPACTION_DEPLOYSPY` handler `0x0078c930`, body to the next function start `0x0078c9e0`. One gate, +then the draw, with no branch between them: + +``` +0078c93d..0078c964 build a 12-byte stack query object (vtable 0x00a09648) and ask it about the ship +0078c969 test al,al +0078c96b jne 0x78c975 ; FALSE -> return 0 with NO DRAW +0078c975 mov ecx,[esi+0x16c]; add ecx,4 ; the strategic generator's +4 sub-object +0078c97f call 0x47d830 ; Mars::RNG::NextFloat ret 0x0078c984 <-- THE DRAW +0078c984 fld qword [0x009e21b0] ; 6.2831854820251465 == 2*pi +0078c98a fldz ; LO = 0.0 + angle = LO + (HI - LO) * r ; the float-range idiom, stored through a float32 temp +0078c9ac call 0x0080c860 ; (ship, angle) -> eax +0078c9bb call edx ; [[esi+0x15c]+0x1c] = ServerSpyManager::DeploySpy slot 7 +``` + +Lane AS read this as `cbh = NextFloat() * 2pi`; that is right in value (`LO` is literally `fldz`) and +the expanded form is worth recording because it is the campaign's standard `lerp(LO, HI, r)` shape, +not a bare multiply. **The draw is unconditional once the validator passes, so it is exactly one +strategic word per applied Deploy Spy order**, in the `ProcessTurn` bracket +(`ApplyAllTurnCommands`), not the tail's. + +**Its predicate is on the command stream**, not on save fields: *"a `SHIPACTION_DEPLOYSPY` command in +this turn's command stream"*. Lane AG's gate-indexed audit has two kinds of cell — a measured firing, +or a predicate over the save — and this is neither. + +### 1.6 A complete RNG scan of all four bodies + +Every `call`/`jmp` in each body decoded to a real instruction boundary and matched against the seven +RNG entry points (`NextFloat 0x0047d830`, `NextInt 0x004271c0`, `Chance 0x008e6dd0`, +`Twist 0x00426e00`, `Seed 0x0049fdf0`, `0x005876c0`, `0x0057da00`): + +| body | range | RNG sites | +|---|---|---| +| `ServerSpyManager::vslot13` | `0x008877b0 .. 0x00887f30` | `0x00887c8a` only | +| `SpyManager::Slot13RngCallee` (`P`) | `0x008408e0 .. 0x00840a5a` | `0x00840929`, `0x008409c7`, `0x00840a3c` | +| `SHIPACTION_DEPLOYSPY` handler | `0x0078c930 .. 0x0078c9e0` | `0x0078c97f` only | +| `ServerSpyManager::vslot14` | `0x0088db80 .. 0x0088dd2c` | `0x0088dc43` only | + +No inlined draw is present in any of them (rule 16's tempering immediates do not appear). + +--- + +## 2. The corpus and the state, counted before building (rule 28 practice 5) + +`as-turn15-spydeployed.sav`, read with `verify/save-reader/save_reader.py`: + +| field | value | consequence | +|---|---|---| +| `sim/Frame` | 15 | | +| spy `sid` | 1 | | +| spy `sown` | 16 | player 0, `re` | +| **`re`'s `Species`** | **0 (Human)** | **`P` takes branch B; `0x00840929` and `0x008409c7` are unreachable** | +| spy `deat` | 400 (Kepler) | G1 satisfied — lane AS's contribution | +| spy `sdet` | **−1** | **the failed conjunct; G2 sends us to branch D every turn** | +| spy `sdo` | 0.00839999970048666 | | +| spy `tdep` | **15** | `vslot14`'s `Frame − tdep ≥ 3` first holds at **Frame 18** | +| spy `cm`, `cmo`, `spyon`, `ncp` | all 0 | `vslot14` G4 fails today; it bootstraps itself at Frame 18 | +| spy `cbh` | 5.033599376678467 | `= 2π × 0.8011207…`, i.e. `0x0078c97f` already fired once, unmeasured | +| players | 7 — `re`(0), `Revenge Fleet`(0), `Spengler`(0), 4 × `_NPC`(4) | branch A's candidate list would be non-empty, if it were reachable | + +**Corpus count for `P`, on the predicate `∃ spy with deat != 0 && sdet != -1 && Frame − sdet ≥ 3`: +0 of 22 before this lane, and 0 of 23 including `as-turn15-spydeployed.sav`.** The failed conjunct is +`sdet == -1`, exactly as lane AS handed it over — and it is not a conjunct any amount of clicking can +satisfy directly. It is satisfied by *waiting*, at a probability the code fixes. + +**Corpus count for `0x0078c97f`:** not expressible over save fields at all (§1.5). The nearest +save-side witness is `cbh != 0`, which holds in **1 of 23** saves — `as-turn15-spydeployed.sav`, +because the site fired on the turn that produced it. + +**No save needs to be manufactured for either target.** Lane AS built both states. This lane +generates no map and plays no new game. + +--- + +## 3. Predictions + +Committed before the build, before the deploy of any binary to VM144, and before any End Turn. + +### 3.1 The static claims + +| | prediction | how it is wrong | +|---|---|---| +| **PA1** | `P` reaches **at most one** draw site per call, selected by `spyOwner->Species (+0x5c) == 6`. From this save, only `0x00840a3c`. | a `draw_sites` row at `ret_rva 0x0044092e` or `0x004409cc` on any turn of this workload falsifies it outright | +| **PA2** | Species indices are `0 Human, 1 Hiver, 2 Tarkas, 3 Liir, 4 _NPC, 5 Zuul, 6 Morrigi`; therefore `AccumulateDetectionOdds`'s `0.75f` is **Zuul** and its `0.5f` is **Morrigi** | a save whose `sim/species` list is ordered differently, or a Zuul save with `Species != 5` | +| **PA3** | `0x00840a3c` is `Chance(0.25f)` — a widened `float`, not the decimal 0.25 read from a decompiler | the four bytes at `0x009e5ac0` are not `00 00 80 3e` | +| **PA4** | the spy is **destroyed** at the end of `vslot13` on the turn `P` runs: `nspy` 1 → 0, `spies2` at Kepler 1 → 0, and no spy record survives in the autosave | the post-`P` autosave still carries a `spy` record | + +### 3.2 Target 1 — the wait, and the per-turn word trajectory + +Let `T` be the `Frame` on which `sdet` is stamped. `sdo` grows by **0.0084 per turn** and the roll on +the End Turn producing `Frame f` uses `sdo = 0.0084 × (f − 14)`, because `AccumulateDetectionOdds` +runs *before* the gate. `Chance` costs one word for `0 < p < 1` (board row 367). + +| | prediction | +|---|---| +| **PB1** | `sdo` in the autosave after the End Turn producing `Frame f` is exactly `0.0084 × (f − 14)` as a float32, for every `f` until detection: 0.0168, 0.0252, 0.0336, 0.0420, 0.0504, … | +| **PB2** | `OnAllCombatDone_Tail` costs **1** word on Frames 16, 17, 18 (`vslot13`'s detection roll alone) | +| **PB3** | **`0x0088dc43` fires for the first time in this campaign on Frame 19**, one call, one word, `ret_rva 0x0048dc48`, `strategic = true` — because `vslot14` bootstraps its counter-mission at Frame 18 (`Frame − tdep = 3`, `ncp` empty), setting `spyon`, `cm := 1`, `cmo := 0.2f`, **without drawing on Frame 18 itself** | +| **PB4** | from Frame 19 the tail costs **2** words per turn (`vslot13` 1 + `vslot14` 1), and drops to 1 on any turn where `cmo` has accumulated to ≥ 1.0 or where `cm` has just cycled back to 0 | +| **PB5** | save-side: `cm` moves off its corpus-constant 0 on **Frame 18**, `spyon` moves to the Kepler owner's id on Frame 18, and `cmo` starts stepping by `0.2f` per failed counter-mission roll from Frame 19 | +| **PB6** | on the detection turn `T`: `vslot13` still costs 1 word (the roll that *succeeds*), `sdet := T` | +| **PB7** | on `T+1` and `T+2`: **`vslot13` costs ZERO words** — `sdet != -1` skips branch D, and `Frame − sdet < 3` skips the destruction path. A zero at `0x00487c8f` on those two turns is a *positive* result about a third arm and must not be read as "the roll did not fire" (rule 28 practice 6) | +| **PB8** | on `T+3`: one row at `ret_rva 0x00440a41`, `entry = Chance`, `calls = 1`, `words = 1`, `no_draw_calls = 0`, `strategic = true`; `vslot13`'s own `0x00487c8f` row **absent**; `vslot14` contributes **0** because the spy was destroyed at the end of phase 23 | +| **PB9** | the entry probe on **`Game::SpyManager::Slot13RngCallee 0x008408e0` reads 1** on Frame `T+3` — its first non-zero in the campaign, and the complement of lane AS's demonstration that its zero said nothing about the subtree | +| **PB10** | cumulative detection probability: **11 % by +4 turns, 43 % by +10, 70 % by +15, 87 % by +20** — computed from `p_k = 0.0084(k+1)`. *(Lane AS's brief quoted 38/65/85; that is the same model evaluated one turn late, using `p_k = 0.0084k`. Minor, and stated so the difference is on the record before the run.)* | +| **PB11** | the tail bracket total over the whole run equals the per-site sum: **residual 0** on every turn | + +**PB12 — the tail case, stated in advance.** At +20 turns there is a 13 % chance `sdet` is still −1. +If that happens, the result reported is the **`sdo` trajectory against PB1** plus the `0x0088dc43` +series, and `P` stays a decoded-not-measured site. That is a real measurement of the accumulator and +it will be reported as such rather than ground out. + +### 3.3 Target 2 — the deploy order + +From `as-turn14-predeploy.sav`, one Deploy Spy order, one End Turn, `probes=8`: + +| | prediction | +|---|---| +| **PC1** | one `draw_sites` row at `ret_rva 0x0038c984`, `entry = NextFloat`, `calls = 1`, `words = 1`, `no_draw_calls = 0`, `strategic = true` | +| **PC2** | it lands in the **`ProcessTurn`** bracket, not `OnAllCombatDone_Tail`'s, because `ApplyAllTurnCommands` runs first | +| **PC3** | the same turn's `OnAllCombatDone_Tail` costs **exactly 1** — the first detection roll at `sdo = 0.0084` | +| **PC4** | the resulting `spy.cbh` reproduces lane AS's value **exactly**: `5.033599376678467`, i.e. `2π × 0.80112…`. The strategic generator is seeded from the save and the per-process AI client seed does not feed it, so the same input plus the same order must draw the same word. **This is the sharpest single prediction in the document**: it says the deploy draw is reproducible across processes, sessions and lanes | +| **PC5** | `spies2` at Kepler goes 0 → `[1]`, `deat` 0 → 400, `tdep := 15`, `sdo := 0` then `0.0084` — reproducing lane AS's §4.2 from a different process | + +### 3.4 The control + +| | prediction | +|---|---| +| **PD1** | two fresh `hooks=off` processes on the **same** input will produce a byte-identical *pre*-turn `(Autosave EndTurn).sav` and **differing** post-turn autosaves | +| **PD2** | the differences localise to **one AI player's research pick plus `Summary/Checksum` and the `turnstats` `tch` cell** — the per-process `StrategyClient` seed of `2026-09-08-ai-seed-per-process.md`. Nothing of my empire, the spy, `deat`, `sdet`, `sdo`, `cm`, `cmo` or `spies2` moves | +| **PD3** | therefore **this is not a calibration pair and must not enter `determinism-hashes.txt`** — stated in advance so it cannot be a post-hoc excuse (rule 26) | +| **PD4** | the leaves this lane's numbers depend on — the `spymgr` sub-tree and the `systems` sub-tree — **do** reproduce across the control pair, and that is the claim that will be checked by sub-tree rather than by whole-file hash | + +### 3.5 How the whole model could be wrong + +* **`countC` is not stable.** `sdo`'s per-turn increment is `0.7 × (0.01 + 0.001 × countC)` with + `countC = 2` inferred, not observed. If a fleet moves in or out of Kepler the increment changes and + PB1 breaks — **and that would be the most interesting failure available**, because it would + identify `countC` from the delta. Symptom: an `sdo` step that is not 0.0084. +* **`vslot14` might not bootstrap.** AG's `ncp` check is on a container this lane has only seen as + the integer 0. If `ncp` is a count and non-empty means something else, PB3 fires late or not at + all. Symptom: Frame 19 tail costs 1, not 2, and `cm` stays 0. +* **The stage machine consults `owner->+0xff` (`CnTrd`, FTL Economics) at 1 → 2/3.** This game has no + FTL Economics, so the 1 → 3 arm is the one that runs; that changes which stage sequence is + exercised but not any word count. +* **PB7's two zero turns are the polarity trap in a new place.** `vslot13` will be *entered* on + `T+1`/`T+2` (the probe reads 1) and cost 0. If this lane reported that as "the roll stopped + working", it would repeat exactly the mistake rule 28 exists for. +* **PC4 can fail benignly.** If AI orders applied before mine consume strategic words, `cbh` differs + and the deploy draw is still 1 word. That falsifies *reproducibility*, not the site. + +--- + +*(End of the pre-registered section. Everything below is measurement.)*