sots-re/findings/subsystems/spy-program-draws.md
alex f8ff1b6fa9 AP: predictions for the spy program's remaining draws, committed before the build
P's three sites are two mutually exclusive branches on the spy owner's Species;
only 0x00840a3c is reachable from a Human empire. Species table decoded from the
corpus. Predicted trajectory for the 20-turn wait, including the counter-mission
roll 0x0088dc43 firing at Frame 19, and the deploy NextFloat 0x0078c97f.
2026-09-09 00:25:11 -04:00

20 KiB
Raw Blame History

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<int> 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<int> 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.)