sots-re/findings/subsystems/spy-detection-roll.md

45 KiB
Raw Blame History

The spy detection roll — decoded, its gate turned into a save predicate, and measured

  • Type: subsystem (static decode + live measurement)
  • Owner / date: lane AS · 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, shim build as-c172c99-20260909T0205Z, dist C:\SOTS\shimdist-as
  • Predictions, committed before the build: sots-engine docs/AS-predictions.md (commit c172c99, addendum 61875a4)
  • Answers: the resolution 2026-09-09-tail-draws.md §8 item 2 (the spy half of the tail gate)
  • Corrects: the resolution's "the detection roll is inline in vslot13" (it is a direct call); lane L3 §6's "spies2 is not the spy list"; board row 399's V2 misquote is not touched here

(Sections 1–3 were written and committed before any measurement. Sections 4 onward are the measurement.)


0. Headline

The detection roll fires, it costs one word, and its gate is an asteroid belt.

Mars::RNG::Chance at 0x00887c8a — in the body of ServerSpyManager::vslot13, phase 23 call 9 of 9 of OnAllCombatDone_Tail — rolls once per deployed, undetected spy per turn, on the strategic generator, at a probability sdo that starts at 0.0084 as measured and accumulates by the same amount every turn. OnAllCombatDone_Tail went 363 → 364: one word, against 0 on every turn any lane has ever measured.

The predicate is spy.deat != 0 && spy.sdet == -1 && sys(deat).ARes2 > 0 && (sys(deat).TerrFl & 1). Corpus count: 0 of 22. The failed conjunct is deat != 0 — every spy this campaign has ever built was still docked to its tender. Not trade routes, not freighters, not spyon, and not the asteroid belt: all 22 corpus saves already had a usable belt somewhere, and two of them already had four AI-owned ones.

And the polarity is the opposite of the intuitive one: when the belt is gone the roll does not happen and the spy is detected anyway. A zero at this site is two different results.


1. The chain, decoded from the instruction stream

Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. Every body below was disassembled from its start to the next function start (rule 17); no Ghidra size was used. ReVa/CT111's MCP endpoint was down for this session (CONNECTION_CLOSED), so nothing was read in the decompiler — this is all instruction stream, which for these bodies is a feature.

Game::ServerSpyManager::vslot13 = 0x008877b0 (lane V2's name), dispatched at 0x007d9811 from OnAllCombatDone_Tail on receiver S+0x15c slot 13. Body 0x008877b0 .. 0x00887f30. Throughout: ebx = 0, edi = this (the manager), esi = the current Game::SpyCraft. The loop is over the vector at this+0x10 .. this+0x14 — the same vector lane L3 watched grow to 1 at Build Spy.

0088781d  je  0x887e92   G1   spy.deat (+0x10) == 0                    -> next spy
0088782d  call 0x8b9240       sysA   = registry(server+0x84, spy.deat)   -> [ebp-0xbc]
00887845  call 0x8b9240       ownerO = registry(server+0x84, spy.sown)   -> [ebp-0xc0]
00887856  je  0x887c5b   G2   spy.sdet (+0x40) == -1                   -> BRANCH D (detection)
00887867  jl  0x887e92        (server.turn - sdet) < 3                 -> next spy
0088787a  je  0x887c4f        sysA.PID == 0                            -> sdet := -1, next spy
00887891  je  0x887c4f        sysA.PID == ownerO                       -> sdet := -1, next spy
00887af2  call 0x8408e0       <-- P, the false-flag draw (three sites inside)

Branch D — the detection roll:

00887c5b  push eax            ; ownerO
00887c62  push [ebp-0xbc]     ; sysA
00887c63  push esi            ; the spy
00887c66  call 0x81f570  D1   SpyCraft_AccumulateDetectionOdds -> writes spy.sdo (+0x3c)
00887c6b  mov ecx,[ebp-0xbc]
00887c71  call 0x743f80  D2   t = ServerSystem_BeltUsableFlags(sysA)
00887c76  test al,1
00887c78  je  0x887c97        (t & 1) == 0  -> SKIP THE ROLL, straight to D4
00887c7a  mov ecx,[edi+8]     ; the StrategyServer
00887c7d  fld  [esi+0x3c]     ; spy.sdo
00887c80  mov ecx,[ecx+0x16c] ; THE STRATEGIC GENERATOR
00887c86  push ecx
00887c87  fstp [esp]
00887c8a  call 0x8e6dd0  D3   Mars::RNG::Chance(strategic, sdo)   <-- THE DETECTION ROLL
                              return address 0x00887c8f
00887c8f  test al,al
00887c91  je  0x887e92        roll failed -> next spy, still undetected
00887c97  mov edx,[edi+8]
00887c9a  mov eax,[edx+0xc]   ; the current turn
00887c9d  mov [esi+0x40],eax  D4   spy.sdet := turn   (DETECTED; then the event strings)

1.1 It is a direct call, not an inlined draw — the instrument advice was right for the wrong reason

The resolution that created this lane says the roll is inline in vslot13. It is not. The bytes are e8 41 f1 05 00 — a plain E8 rel32 to the Chance entry point 0x008e6dd0. Nothing resembling rule 16's inlined tempering sequence is present.

What is true, and is the whole reason draw_sites is the right instrument, is that the call sits in the caller rather than in SpyManager::Slot13RngCallee 0x008408e0. An entry probe on 0x008408e0 therefore reads zero whether or not the roll fires — but that is instrument choice, not inlining, and the distinction matters because an inlined draw would be invisible to draw_sites too, and this one is not. Lane AG read this independently and reached the same conclusion; the correction is recorded in both places.

1.2 The gate is an asteroid belt, and it is sixteen bytes

00743f80  cmp dword ptr [ecx + 0x6c], 0
00743f84  mov eax, dword ptr [ecx + 0x19c]
00743f8a  jg  0x743f8f
00743f8c  and eax, 0xfffffffe          ; clear bit 0 when [ecx+0x6c] <= 0
00743f8f  ret

On Game::ServerSystem (objects/layouts.md): +0x6c = ARes2, +0x19c = TerrFl, +0x100 = PID (which ServerSystem_GetOwner 0x007437e0, used three times above, returns). So

bit 0 of the return = (TerrFl & 1) AND (ARes2 > 0) — "this system still has a usable asteroid belt".

ARes2 is already the field lane AC identified as the asteroid-belt resource (values 1500–3000, matching SYSTEM_MIN/MAX_ASTEROID_RESOURCES). TerrFl bit 0 is its flag; §2 checks that reading against the corpus, which is the only place a static reading of an unnamed bit can be checked without a debugger.

The polarity, stated because everyone's intuition gets it backwards. The branch is a je that jumps past the roll. Belt usable ⇒ roll. Belt gone ⇒ no draw at all, and sdet := turn unconditionally. The spy hides in the belt; if the belt is mined out it has nowhere to hide and is spotted for free. So at this site, a draw_sites row with calls = 0 and a sdet that has moved is a positive result about a different arm, and must never be reported as "the roll did not fire".

1.3 sdo — where the probability comes from, and why the roll should cost a word

0x0081f570 (SpyCraft_AccumulateDetectionOdds(spy, sys, spyOwner), __stdcall, ret 0xc, 512 bytes) is called unconditionally on branch D, immediately before the gate, and writes spy.sdo (+0x3c):

  • if sys.PID == 0 (unowned): fldz; fstp [esi+0x3c] — sdo := 0.0 and return. A spy at an unowned system rolls at p = 0, which costs no word.
  • otherwise it walks the system's fleet vector accumulating three counters ([ebp-4], [ebp-8], [ebp-0xc]) and computes, with every literal read as the four bytes in the image (rule 23 — these are widened floats, not decimals):
p  = 0.01f                                   (0x009e31c0)
p += (countA > 0) ? 0.01f : 0                (0x009e3e14)
p += (countB > 0) ? 0.02f : 0                (0x00a1d048)
p += 0.001 * countC                          (0x009e23c0)
p *= (spyOwner->[0x5c] == 5) ? 0.75f         (0x009e5ac4)
   : (spyOwner->[0x5c] == 6) ? 0.5f          (0x00a2c788)
   : 1.0f
p *= 0.7                                     (0x009e5df8)
sdo = min(sdo + p, 1.0)                      (0x009e1ef0)

Two properties matter more than the arithmetic. It accumulates onto the previous sdo, and it is clamped at 1.0. Mars::RNG::Chance costs 0 words at p <= 0 and at p >= 1 (board row 367). So:

  • a fresh deploy at a quiet foreign system gives sdo = 0.7 × 0.01 = 0.007 — strictly inside (0, 1), so the roll costs exactly one word;
  • sdo climbs by ~0.007/turn, so a spy left in place for ~143 turns reaches p >= 1, at which point the site costs nothing and always succeeds. That is a second way a zero at this site is not a negative, and it is the same shape as lane AG's cmo early-out.

(Measured afterwards: 0.0084, i.e. 0.7 × (0.01 + 0.001 × 2) — countC = 2 rather than the 0 assumed here. The one-word conclusion is unaffected and the form of the expression reproduced exactly. §4.2a.)

1.4 What a deploy actually writes — and one draw site nobody had

ServerSpyManager::DeploySpy = 0x00887410, vftable 0x00a3073c slot 7, zero direct call sites, reached only from the SHIPACTION_DEPLOYSPY handler 0x0078c930 through [[dispatcher+0x15c]+0x1c] — the stack-built ship-action function-pointer table that lane B6 identified as a distinct indirection class. On the success path it writes:

spy->Reset()  (0x00838070)
spy->sdo  (+0x3c) := 0.0f
spy->sdet (+0x40) := -1
spy->cbh  (+0x18) := <the float argument>
spy->tdep (+0x14) := server->[+0xc]        ; the current turn
spy->atto (+0xc)  := 0 ; carrierShip->[+0xa8] := 0        ; detach from the tender
ServerSystem_AddSpy(system, spy)   at 0x008874d4

and AddSpy is where deat is born (§1.5). Because the deploy leaves sdet == -1 and sdo == 0, the detection branch is entered on the very next OnAllCombatDone_Tail — which is the same End Turn that applied the order, since ApplyAllTurnCommands runs before the tail.

The float argument is computed in the handler, and it is a draw:

0078c975  mov ecx,[esi+0x16c]      ; esi = StrategyServer -- THE STRATEGIC GENERATOR
0078c97c  add ecx,4                ; the +4 sub-object, same one the trade-raid roll uses
0078c97f  call 0x47d830            ; Mars::RNG::NextFloat   <-- 1 word, ret 0x0078c984
0078c984  fld qword [0x009e21b0]   ; 6.283185482025146 == 2*pi
          cbh = NextFloat() * 2*pi

0x0078c97f is a strategic-generator draw site in no existing inventory. It is not one of lane V2's eight, and it is not in lane I's 22, because it hangs off the ship-action table and no call-graph sweep reaches it. It fires once per Deploy Spy order applied, inside ApplyAllTurnCommands — so it lands in the ProcessTurn bracket, not the tail's. Any gate-indexed audit (lane AG) should carry it, and its predicate is "a SHIPACTION_DEPLOYSPY command in the turn's command stream", which is a stream predicate rather than a save predicate — a category the audit does not yet have a column for.

1.5 deat and spies2 are written by the same two instructions — spies2 is closed

ServerSystem::AddSpy = 0x007514c0, 48 bytes, complete:

007514c3  mov edx,[ebp+8]         ; the spy
007514c6  test edx,edx / je ret
007514ca  test ecx,ecx / jne
007514ce  xor eax,eax             ; this == NULL -> store 0
007514d2  mov eax,[ecx+4]         ; the system's own registry handle
007514d5  mov [edx+0x10],eax      ; *** spy->deat = system handle ***
007514d8  add edx,4               ; &spy->sid
007514db  push edx
007514dc  add ecx,0x1cc           ; &system->spies2
007514e2  call 0x59f1a0           ; push_back  (lane AI3's wrapper)

and its exact mirror ServerSystem::RemoveSpy 0x0074f550 sets spy->deat = 0 and erases spy->sid from the same +0x1cc vector. ServerSystem +0x1cc is spies2 in objects/layouts.md. AddSpy has exactly one caller (0x008874d4, inside DeploySpy) and no vtable slot, so this is the only way deat ever becomes non-zero at runtime.

spies2 is the per-system vector of DEPLOYED spy ids, and deat is its reciprocal back-pointer. They are written by the same function, two instructions apart.

  • Lane L3 §6: "spies2 is therefore not the spy list" — half right and worth correcting in place. It is not the spy manager's craft list; it is the system's deployed-spy list. L3's inference from "this save has a spy and spies2 is still empty" was sound about what spies2 is not, and the alternative it offered second ("or it only fills for a deployed spy") is the right one.
  • Lane AC's P4 ("fills only for a deployed spy") is confirmed, and now from the writer rather than by elimination.

This retires one third of lane W's rule-6 flag on spies2 / SysMem / mts statically, and §5 exercises it live.

1.6 vslot14's roll is a different gate — and it excludes independent colonies

For the record, because target choice depends on it. ServerSpyManager::vslot14 0x0088db80 (phase 33) loops the same vector and requires spy.deat != 0, sys.PID != 0, and sys.indi (+0x1c8) == 0 (ServerSystem_IsIndependent 0x00743fa0) — not an independent colony — plus spy.cm (+0x24) in 1..4, before its Chance at 0x0088dc43. Lane AG has decoded that site fully; the consequence here is only that an AI-empire target exercises strictly more than an Independent Colony, which is why this lane went looking for an AI-owned belt rather than taking the easy NPC one.


2. The corpus, counted before measuring (rule 28b)

verify/save-reader/save_reader.py --json over all 22 saves in verify/results/saves/.

count
saves with any spymgr.spy record 3 (L3's turn-15, AC's turn-20 and turn-22)
of those, deat != 0 0
saves satisfying the full detection-roll predicate 0 of 22
the failed conjunct spy.deat != 0

The integrator's value-domain census (value-domain-census.md) reaches the same 0 independently and more strongly: deat, sdet, sdo, cbh, cm, cmo are all in the 234 fields that have only ever held one value, so the count is 0 by construction.

Two things fell out of the same pass, and the first is the load-bearing one.

  • ARes2 > 0 and TerrFl & 1 agree on every one of 616 system records (22 saves × 28 systems), with no exception in either direction. That is a save-side confirmation of §1.2's reading of an unnamed flag bit, obtained without a debugger, over a sample large enough to be worth something. It got stronger on the state this lane built (§4): there, TerrFl also takes the values 2 and 3, and ARes2 > 0 still coincides exactly with TerrFl & 1 — so bit 1 is a second, independent terrain feature and bit 0 is not merely "TerrFl is non-zero".
  • The belt was never the missing conjunct. All 22 corpus saves have at least one system with a usable belt (5 to 15 of 28), and two of them — human-turn2-orders.sav and human-turn3-noderoute.sav — already have four AI-owned belt systems. Lane AC's map had none, and the campaign generalised from it. Those two saves are PTech 0 / ResM 1.0, so the spy tech chain is tens of turns away and they were not a usable shortcut; but "no AI colony can have a belt" was a fact about lane L3's one map, exactly the rule-28 practice-3 trap.

3. Predictions

Committed to sots-engine docs/AS-predictions.md at c172c99 before the build, with the addendum at 61875a4 before the measurement. Summarised here; the falsification table is in that file.

prediction
P1 an AI-owned belt system exists within ≤ 5 map regenerations at lane L3's custom settings
P2 the predicate of §2; corpus count 0 of 22; failed conjunct deat != 0
P3 the roll fires: one draw_sites row at ret_rva 0x00887c8f, Chance, calls = 1, strategic = true
P4 it costs 1 word; no_draw_calls = 0
P5 the tail bracket goes 0 → 1; the trade half contributes 0 (tscr 252, no fleet at a sector node)
P6 sdet most likely stays −1 (p ≈ 0.993); sdo moves off its corpus-constant 0 to ≈ 0.007, and that is the save-side confirmation
P7 spies2 non-empty in the target system only
P8 the hooks=off pair reproduces byte-identically in two fresh processes
P9 probes=8 is byte-neutral on this workload too

4. The workload — built from a cold main menu in one session

Shim build as-c172c99-20260909T0205Z, cross-built on CT111 in this lane's own directory /srv/re-lab/build/sots-engine-as (rm -rf build-host build-shim before configure — rule 24), staged to /srv/re-lab/shim/dist-as, deployed to C:\SOTS\shimdist-as on VM144. Exports checked identical to the real binkw32.dll (66 names).

Guest adoption check, first, before anything else. ref-turn2.sav, one End Turn, hooks=off, this lane's own build:

(Autosave EndTurn).sav  bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948   66,732
(Autosave).sav          978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921   67,219

Both identical to the published oracle in verify/results/saves/determinism-hashes.txt. The binary and the guest are sane.

4.1 The map — zero regenerations were needed

New custom game at lane L3's settings (28 stars, 5 ly, 2 players Human vs Tarkas, treasury 9,850,000, 10 initial colonies, 15 initial technologies, econ 148 %, research 146 %), species set explicitly to Human on the launch card (it defaults to Random — L3's note holds).

The first map generated had two AI-owned belt systems. The brief expected several regenerations; P1's estimate of ≤ 5 was never tested because none were needed.

SysID name PID ARes2 TerrFl note
400 Kepler 32 (AI) 2435 1 the target — 3.5 ly from my colony Midway
224 Delta Pavonis 32 (AI) 1537 1 the spare
272 Solaris 0 2773 1 unowned — sdo would be forced to 0 here (§1.3)
160 / 480 Piscium / Pascal 16 (me) 2448 / 2522 1 mine
336 Copernicus 0 2359 3 bit 1 also set
128 Downbelow 0 1965 1
384 / 448 / 464 Nunki / Mu Bootis / Jinx — 0 2 bit 1 set, bit 0 clear, no belt

That last row is the one that matters for §1.2. In the whole 22-save corpus TerrFl had only ever been 0 or 1, so "bit 0" and "non-zero" were indistinguishable. This map has three systems at TerrFl = 2 with ARes2 = 0 and one at TerrFl = 3 with ARes2 = 2359. ARes2 > 0 still coincides exactly with TerrFl & 1, and now it is bit 0 specifically, not "TerrFl is set".

4.2 The path to a deployed spy, with what each turn cost

The four techs came faster than lane L3's recipe suggested because CCC_FTLBrdB was in the 15-technology roll again (it appears to be common) and because the top-of-screen Savings ↔ Research slider at its right end turns a 2-turn tech into a 1-turn tech.

turn what
2 new game; research CCC_SpyBm (CCC_FTLBrdB already researched from the initial roll)
4 CCC_SpyBm done; research IND_OrbFound
6 done; research IND_CruisCon (2 turns → 1 with the research slider at max)
7 done; research IND_SlvgTech
8 all four techs; design a Cruiser with the Repair and Salvage mission section; queue it
9 the tender exists; fleet panel's Special → Build Spy → Select All → OK
10 the spy craft exists: nspy 1, deat 0, sdet -1, sdo 0, atto = the tender — the corpus state
10 Move the fleet to Kepler (Dest: Kepler, 4 Turns)
14 fleet arrived: Flt.LocID = 400, read from the autosave, not the panel
14 Special → Deploy Spy — the menu entry is present, which is itself the belt gate answering
15 order applied. deat = 400, sdet = -1, sdo = 0.0084, spies2 = [1] at Kepler

Two things about turn 15 are worth stating separately, because they are results rather than steps.

(a) sdo = 0.0084, and that is §1.3's formula evaluated, not fitted. The prediction was 0.007 for a quiet system (0.7 × 0.01). The measured value is 0.7 × 0.012 = 0.0084, i.e. countC = 2 in the third term. So the form — a 0.7 multiplier over a 0.01 base plus 0.001 per unit of a counter — reproduces exactly, at a value no run of this campaign has ever produced, on a state nobody had built. The identity of countC is not established (it is 2 at a system holding one foreign colony and my one-ship fleet); the constant and the shape are.

(b) spies2 filled, in exactly the system the writer says. Reading all 28 systems of the turn-15 autosave: spies2 is count = 0 in 27 of them and count = 1, element = 1 at Kepler, where 1 is the spy's sid. This is the first non-empty spies2 in the corpus and it matches ServerSystem::AddSpy instruction for instruction (§1.5).


5. The measurement

All three runs load the same file, MyGameas1spydep.sav (sha256 854a10fa1ea602f0f7909f9bf9154942e56ea5fa1db4cce47d6d32bf53952e08, 76,705 B, turn 15, --strict clean, 0 errors / 0 warnings), in a freshly launched process, and press End Turn once. Repo copy: verify/results/saves/as-turn15-spydeployed.sav.

5.1 The oracle pair (rule 26) — and what it says honestly

run config (Autosave EndTurn).sav (the input, re-saved) (Autosave).sav (post-turn)
A hooks=off 98e45d3745b91450… 76,706 262f8bda97c9511d… 77,685
B hooks=off, fresh process 98e45d3745b91450… identical e34775a757e6fb3d… 77,649 differs
M probes=8 98e45d3745b91450… identical 262f8bda97c9511d… identical to A

The pre-turn save is byte-identical in all three, which proves the input state — including any pending order — was the same in every run. The post-turn saves are not.

A vs B differs in exactly 22 leaves, and every one of them is inside one AI player. state_checksum --no-audit localises them:

/Summary/Checksum                                                  985948837 -> 985948925
/Sim/turnstats/history/hist[1]/stats[15]/tch                              46 -> 45
/Sim/players/Player[32 "Revenge Fleet"]/TechTree/St[62]                    2 -> 3
/Sim/players/Player[32 "Revenge Fleet"]/TechTree/TResDone[62]              0 -> 22469
/Sim/players/Player[32 "Revenge Fleet"]/TechTree/St[64]                    4 -> 2
/Sim/players/Player[32 "Revenge Fleet"]/TechTree/TResDone[64]          22469 -> 0
/Sim/players/Player[32 "Revenge Fleet"]/TechTree/TAcq[64]                 16 -> -1
/Sim/players/Player[32 "Revenge Fleet"]/TechTree/TiAcq[64]                45 -> -1
/Sim/players/Player[32 "Revenge Fleet"]/ResTNm                           '' -> 'DRV_NodFoc'
… plus that player's Events/EvNxID/otch bookkeeping for the same pick

In A the AI completed Overthrusting; in B it is part-way through DRV_NodFoc. Nothing else moved — not one leaf of my empire, not deat, sdet, sdo, spies2, trdmgr or any fleet.

That is precisely the mechanism 2026-09-08-ai-seed-per-process.md established: each AI StrategyClient's generator is seeded with a word that differs in every process, so its research pick is a per-process outcome. Board row 326 recorded the same signature on turn1-state → turn2 — "one shadow empire's research pick plus the derived checksum".

So the control agrees with itself modulo the known non-deterministic leaves, which is the bar the brief set — and it is a weaker bar than byte-identity, which must be said plainly. What is established is: on this input, in two fresh un-instrumented processes, every leaf outside one AI player's research bookkeeping reproduced exactly. What is not established is byte-identity, and this workload cannot establish it until the client seeds are pinned (the resolution's Rung C).

And the instrumented run M came out byte-identical to A. Read carefully, that is:

  • strong for the thing that matters — probes=8 changed no leaf of the sim, the spy, or my empire, on the same input, which is the byte-neutrality claim P9 makes;
  • and, for the AI-pick leaf specifically, a coincidence of size 1/k (rule 26's corollary): M happened to draw the same AI seed outcome as A. It is not evidence about the instrument in that one leaf class, and I am not treating it as such.

5.2 The instrument was armed, and one probe reading zero is the point

shim.log: seven draw-site detours create=MH_OK enable=MH_OK; probe: installing 8 of 12; all eight MH_OK; no COVERAGE: line. draw_site_overflow = 0.

probe entries this turn
Game::ServerSpyManager::vslot13 0x008877b0 1
Game::ServerSpyManager::vslot14 0x0088db80 1
Game::ServerTradeManagerImpl::vslot13 0x0088ef80 1
Game::ServerTradeManagerImpl::vslot15 0x0082cca0 1
Game::SpyManager::Slot13RngCallee 0x008408e0 0
Game::TradeManager::Slot13RngCalleeA 0x00820ca0 0
Game::TradeManager::Slot13RngCalleeB 0x0088b440 0
Game::ServerTradeManager::CreateRaidEncounter 0x008938a0 0
indices 8–11 (GenerateTradeRaidEncounters, three controls) NOT INSTALLED — probes=8 installs 0–7

That table is the lane's methodological point in one line. The detection roll fired, and the entry probe on SpyManager::Slot13RngCallee read 0 on the same turn. Four lanes read that zero and concluded the spy subtree was draw-free. It was never a statement about the subtree; it was a statement about which function the call sits in.

5.3 The detection roll — it fires, and it costs one word

draw_sites, the post-turn autosave marker, the row verbatim:

entry           = Chance
ret_rva         = 0x0048_7c8f   (VA 0x00887c8f — the instruction after `call 0x8e6dd0` at 0x00887c8a)
calls           = 1
words           = 1
no_draw_calls   = 0
strategic       = true

and the same site in the per-call-site report, with the owner resolved:

* STRAT Chance   call 0x00887c8a   <ServerSpyManager::vslot13>+0x4da   calls=1  words=1

The boundary ledger for the same turn:

BeginProcessTurn      345 -> 345    0
  ProcessTurn         345 -> 363   18
  OnAllCombatDone_Tail  363 -> 364    1
Autosave              364 -> 364    0

OnAllCombatDone_Tail costs 1 word, against 0 on every turn any lane has ever measured, and the whole of it is the detection roll.

Turn total 19 words; the per-site sum after removing helper-internal rows is 19; residual 0. The tail's one word is the only site in the tail that fired: no row at 0x00820e18 or 0x0088b613 (trade 13 — tscr is 252 in this game and no fleet stands on a sector node, so lane AC's predicate says 0 and it is 0), none at 0x0082cdb8 (trade 15), none at 0x0088dc43 (spy 14 — cm = 0, lane AG's failed conjunct), and none of P's three.

Predictions, scored. P1 held trivially (0 regenerations, not ≤ 5). P2 held. P3 held exactly. P4 held exactly — one word, no_draw_calls = 0, and the reasoning behind it (four widened-float literals plus Chance's cost at interior p) was written down before the run. P5 held — 0 → 1. P6 held in both halves: sdet stayed −1 (the roll failed, as a 1.68 % roll should), and sdo moved from its corpus-constant 0 to 0.0084 on the deploy turn and to 0.0168 after the measured turn — an increment of exactly 0.0084 both times, which is the accumulator running. P7 held. P8 held only modulo the AI-seed leaves (§5.1). P9 held.

5.4 The second instrument agreed — which is what makes this more than one number

The coordinator asked for a save-visible confirmation independent of the bracket. There are two:

  • sdo moved and by the right amount. 0 → 0.0084 → 0.0168. Only branch D writes sdo, and it writes it immediately before the gate, so a moving sdo proves the branch ran; the equal increments prove the accumulator, not a one-off.
  • spies2 filled in exactly one system. That is the deploy half rather than the roll, but it confirms the deat reading the whole predicate rests on.

sdet did not move, and per P6 that was the predicted outcome, not a failure.


6. Coverage — what this did not touch, said as loudly as what it did

  • One measured turn, one spy, one target system, one fleet of one ship. The cost is 1 word on that turn. The loop is over the spy vector, so the cost should be one word per deployed, undetected spy at a system with a usable belt — that is a reading, not a measurement. Rule 20: do not fit a constant to one observation. A second spy at a second belt system separates per-spy from per-turn and costs one more deploy.
  • The sdo arithmetic is validated only in its first two terms. countA, countB and the species multiplier were 0/0/1.0 here, and countC = 2 is inferred from the value, not observed. A Zuul or Liir owner (+0x5c = 5 or 6) would exercise the 0.75/0.5 branches; a system with enemy ships present would exercise countA/countB.
  • The auto-detect arm was never taken. (TerrFl & 1) == 0 at the target has not been reached — it would need a belt to be mined out from under a deployed spy. So §1.2's polarity claim rests on the instruction stream plus the deploy-time refusal, not on a measured firing. It is the single most load-bearing unmeasured branch in this document.
  • P (0x008408e0) and its three draws stay unfired, and the failed conjunct is now sdet, not deat. From this save the recipe is arithmetic rather than a guess: sdo grows 0.0084/turn, so P(detected) is 12 % after 5 more End Turns, 38 % after 10, 65 % after 15, 85 % after 20 — then P runs 3 turns after sdet is stamped, provided the system is still foreign-owned. About 20 End Turns from as-turn15-spydeployed.sav, no clicking.
  • 0x0078c97f was decoded and not measured (§6.1).
  • probes=11 was not used anywhere in this lane, and no number here is comparable to lane AC's turn totals, which were taken at that configuration.
  • No Guard region is declared by any hook in this family, the same gap lanes Z and H reported: undeclared = 0 in this trace is vacuous.
  • One control run was lost to the harness and is reported rather than hidden. A third hooks=off run mis-detected the post-turn End Turn button as "turn still running" and clicked End Turn three times, advancing the line to turn 18. It was discarded; nothing in §5 uses it. The defect was in this lane's own click automation (the brightness test had the wrong polarity: after a completed turn the button is brighter, not dimmer), which is rule 19 pointing at the operator rather than at the detour. Its lesson is worth carrying: verify the turn number changed, not a button's colour.

6.1 A draw site nobody has — the deploy's own NextFloat

Decoded here and not measured, so it is a rule-6 hypothesis with a hook site attached:

0078c975  mov ecx,[esi+0x16c]     ; esi = StrategyServer -- the STRATEGIC generator
0078c97c  add ecx,4
0078c97f  call 0x0047d830         ; Mars::RNG::NextFloat -- 1 word, return address 0x0078c984
0078c984  fld qword [0x009e21b0]  ; 6.283185482025146 == 2*pi
          spy.cbh = NextFloat() * 2*pi
0078c9bb  call edx                ; -> ServerSpyManager::DeploySpy, vtable slot 7

inside the SHIPACTION_DEPLOYSPY handler 0x0078c930, which is entry 9 of the stack-built ship-action function-pointer table (lane B6's indirection class — no direct callers, no vtable slot, invisible to every call-graph sweep this campaign has run).

It fires once per Deploy Spy order applied, inside ApplyAllTurnCommands, so it lands in the ProcessTurn bracket, not the tail's. Its predicate is not a predicate on save fields at all — it is "a SHIPACTION_DEPLOYSPY command in this turn's command stream", a stream predicate, which lane AG's gate-indexed audit has no column for and should grow one.

To measure it costs one run: load verify/results/saves/as-turn14-predeploy.sav under probes=8, select Kepler, Special → Deploy Spy → <ship> → OK, End Turn, and read draw_sites at ret_rva 0x0078c984. This lane attempted it and lost the run to the map UI (Kepler and Midway sit within four pixels of each other at the zoom the map opens at, and Midway wins the hit test), then chose to report the site honestly rather than rush a fourth process. Prediction, committed here for whoever takes it: one call, one word, strategic = true, in the ProcessTurn bracket, and the same turn's tail still costs exactly 1 — the deploy and the first detection roll happen on the same End Turn, because ApplyAllTurnCommands runs before OnAllCombatDone_Tail.


7. VM144 as left

Restored and verified by screenshot at the main menu, profile re, 2026-09-09 00:03 local.

  • C:\SOTS\binkw32.dll restored to lane L3's build from C:\SOTS\shimdist-l3\binkw32.dll (sha256 479B8614D2417603…, byte-identical to what this lane found in place).
  • C:\SOTS\shim.cfg restored from C:\SOTS\ui\preAS-shim.cfg (L3's shim.cfg.l3probe).
  • The three autosaves restored byte-identical from C:\SOTS\ui\preAS-SavedGames (1985E6F4… / 24B2E072… / 5EC80C1E…).
  • C:\SOTS\shim.trace.jsonl removed.

SavedGames is now 15 files, not 13, so every Load-dialog row position has moved again — screenshot the dialog, do not reuse a remembered row. The two additions are this lane's, and they are worth leaving because they are the only deployed-spy states in existence:

file turn why it is worth keeping
MyGameas1predeploy.sav 14 fleet + tender + docked spy standing at an AI belt colony, one click from a deploy — the input for §6.1
MyGameas1spydep.sav 15 the deployed spy: deat 400, sdet -1, sdo 0.0084, spies2 = [1] at Kepler

At the 15-file set, verified by screenshot this session: MyGameas1predeploy is at (400, 347) and MyGameas1spydep at (400, 376); OK is unmoved at (682, 624).

Also left in place, all harmless: C:\SOTS\shimdist-as\ (build as-c172c99-20260909T0205Z, 64 files), C:\SOTS\ui\asgo.ps1, C:\SOTS\ui\ashash.ps1, C:\SOTS\ui\preAS-shim.cfg, C:\SOTS\ui\preAS-SavedGames\ (13 files), C:\SOTS\ui\asfetch.sav / asgrab.jsonl / asgrab.log (scratch). click_helper.ps1 was not touched — this lane needed no new verbs, which is the first VM lane in a while that did not.

7.1 Guest notes worth carrying forward

  • The map's hover readout works with a plain move on VM144 — lane AC's jmove jiggle was not needed here. Enter Move mode first; the readout renders at the hovered star, not at the cursor.
  • Finding a named system on the star map is solvable arithmetically instead of by hunting. Hover four systems, read their names, and least-squares-fit an affine map from the save's Pos vectors to screen coordinates; the residuals came out ≤ 8 px on 5 points, which is enough to click a specific star first time. The script is in this lane's scratch notes and the method is the reusable part. Its failure mode is systems that project within a few pixels of each other (Kepler and Midway), where the nearer one wins the hit test.
  • The End Turn button is BRIGHTER after a turn completes, not dimmer. Any "is the turn still running?" test built on that button's colour has the polarity backwards. Poll the autosave mtime and the turn number in the ticker, never the button.
  • Accepting the AI's non-aggression offer (turn 6 here) stopped the combat encounters that were interrupting every third End Turn, and cost nothing this lane needed.

8. Corrections to earlier findings (rule 11)

  • 2026-09-09-tail-draws.md §0.7 and §7, and lane AC §2.2: "the detection roll is inline in vslot13". It is a direct E8 call to the Chance entry point sitting in vslot13's body. The instrument advice that followed from it is right; the stated reason is not. §1.1.
  • Lane L3 §6: "spies2 is therefore not the spy list". Half right. It is not the spy manager's craft list; it is the system's deployed-spy id vector, written by ServerSystem::AddSpy in the same two instructions that set deat. L3's own second alternative was the correct one. §1.5.
  • Lane AC §5: "the textbook target — an enemy colony with a belt — does not exist in this game." True of that map and read across the campaign as if it were true of the game. Two corpus saves already had four AI-owned belt systems, and the first map this lane generated had two. §2.
  • Lane V2 §3.1's spy-13 row listed 0x00887c8a correctly and is vindicated, like its trade-13 row before it. Its "never observed firing" note is now discharged for this one site.
  • Ghidra's size for 0x008877b0 is 1,869 bytes; the next function starts at 0x00887f30, i.e. 1,920. The detection roll at 0x00887c8a is inside both, but the 51-byte shortfall is rule 17 again, on the very function this lane exists to read.
  • This lane's own pre-registered §1.1, corrected here rather than edited there (rule 12). docs/AS-predictions.md calls 0x0083ce50 "the deploy validator" and says its 0x1000000 refusal bit is the Can't deploy spy: Spy requires an asteroid belt to hide. message. Read properly afterwards, 0x0083ce50 is a shared placement validator with ~21 refusal bits (colonise, mine, dump ore, too many things present, …), reached from 0x00760ce0 and the order path 0x00849460, and it is not on ServerSpyManager::DeploySpy's call chain at all — that chain is SHIPACTION_DEPLOYSPY 0x0078c930 → vtable slot 7 0x00887410 → AddSpy, and it never calls 0x00743f80. What survives, and is all §1.2 needs, is that 0x00743f80 bit 0 gates a belt-shaped refusal there and the detection roll here; which action that refusal belongs to was over-claimed and is not established. The empirical half is unaffected: Deploy Spy was offered and accepted at a belt system, and the corpus agreement of ARes2 > 0 with TerrFl & 1 is 616 of 616.

9. Artifacts

what where
the deployed-spy save — first deat != 0 and first non-empty spies2 in the corpus verify/results/saves/as-turn15-spydeployed.sav (76,705 B, --strict 0 errors / 0 warnings, sha256 854a10fa1ea602f0…)
the pre-deploy save, one click from a deploy at an AI belt colony verify/results/saves/as-turn14-predeploy.sav (75,908 B, sha256 53986f8511cbf992…)
the instrumented trace (draw_sites + probe_entries + the boundary ledger) verify/traces/as-probes8-turn15-turn16.jsonl.gz
shim log for that run (probe and detour install status) verify/results/shim/as/as-probes8-run.shim.log
addresses this lane mints ghidra/addresses.d/as.json (7 entries; 6 verified, 1 mapped)
predictions, committed before the build sots-engine docs/AS-predictions.md (c172c99, addendum 61875a4)
instrument sots-engine src/shim/shim.cfg.hp8 and shim.cfg.hoff, both unchanged — this lane wrote no engine code

9.1 The hashes, for the record

input   MyGameas1spydep.sav        854a10fa1ea602f0f7909f9bf9154942e56ea5fa1db4cce47d6d32bf53952e08
A/B/M   (Autosave EndTurn).sav     98e45d3745b914506fc4c409ad5a5ad0938a2003ab4c845421a99a8065149c3a
A       (Autosave).sav             262f8bda97c9511d8d2b41e6a8a0582d6f5364c2b01b1caf99e639d0e4f53ebe
B       (Autosave).sav             e34775a757e6fb3df647d7a589e8b1819b90bea094196a9e3cc819e51ed63257
M       (Autosave).sav             262f8bda97c9511d8d2b41e6a8a0582d6f5364c2b01b1caf99e639d0e4f53ebe

This is not a calibration pair for the standalone, and it must not be entered in determinism-hashes.txt as one. A ≠ B, in the AI-research-pick leaf class, so the turn is reproducible only once the client seeds are pinned. What it is is a reproducible input and a turn whose every non-AI leaf reproduces across three processes and two configurations.


Proposed campaign/board.md rows

New row:

| THE SPY DETECTION ROLL FIRES - 1 word, gated on an asteroid belt; `spies2` and `deat` closed | verify | verified | high | 100% | 2026-09-09 | **Lane AS, VM144, build `as-c172c99-20260909T0205Z`, `probes=8` (never 11).** `Mars::RNG::Chance` at **0x00887c8a**, in the BODY of `ServerSpyManager::vslot13` (tail phase 23 call 9), fired **1 call / 1 word / no_draw_calls=0 / strategic=true** at `ret_rva 0x00887c8f`; `OnAllCombatDone_Tail` went **363 -> 364**, against 0 on every turn any lane has ever measured. Turn total 19, per-site sum 19, **residual 0**. THE ENTRY PROBE ON `SpyManager::Slot13RngCallee 0x008408e0` READ **0 ON THE SAME TURN** - that zero was never a statement about the subtree, only about which function the call sits in (rule 28 practice 4, demonstrated rather than argued). **CORRECTION: the roll is NOT inline** (the resolution and AC §2.2 both say so) - it is a plain `E8` to the `Chance` entry point in the caller. **THE PREDICATE**, decoded to save fields: `spy.deat != 0 && spy.sdet == -1 && sys(deat).ARes2 > 0 && (sys(deat).TerrFl & 1)`; **corpus count 0 of 22, failed conjunct `deat != 0`**. `ServerSystem_BeltUsableFlags 0x00743f80` (16 bytes) returns `TerrFl` with bit 0 cleared when `ARes2 <= 0`; `ARes2>0` and `TerrFl&1` agree on all 616 corpus system records AND on the new map, which also has `TerrFl = 2` and `3`, so bit 0 is the belt specifically. **POLARITY: belt gone => NO DRAW AND THE SPY IS DETECTED ANYWAY** (`je` past the roll to `sdet := turn`) - a zero here is two different results. Cost: `sdo` starts at 0, `SpyCraft_AccumulateDetectionOdds 0x0081f570` ACCUMULATES `0.7*(0.01 + [cA]0.01 + [cB]0.02 + 0.001*cC) * species(1.0/0.75/0.5)` and CLAMPS AT 1.0, so `Chance` costs 1 word until sdo reaches 1 and 0 thereafter. Measured `sdo` 0 -> 0.0084 -> 0.0168 (= 0.7*0.012 exactly, a value no run had produced). **`spies2` IS CLOSED**: `ServerSystem::AddSpy 0x007514c0` sets `spy.deat = system handle` and pushes `spy.sid` into `ServerSystem+0x1cc` in the same two instructions - `spies2` is the per-system DEPLOYED-spy id vector; L3's "not the spy list" was half right, AC's P4 was right. Measured: `count=1 element=1` at Kepler, 0 in the other 27. **NEW DRAW SITE NOBODY HAS**: `0x0078c97f` NextFloat in the SHIPACTION_DEPLOYSPY handler `0x0078c930` (`cbh = NextFloat()*2pi`), strategic generator, in the ProcessTurn bracket - decoded, NOT measured, and its predicate is on the COMMAND STREAM not the save, a column the gate-indexed audit lacks. Oracle: input byte-identical in 3 processes; A vs B differ in **22 leaves, all inside one AI player's research pick** (the known per-process client seed, resolution 2026-09-08) - so this is NOT a calibration pair and must not enter `determinism-hashes.txt`. `probes=8` run came out byte-identical to control A. Saves: `as-turn15-spydeployed.sav`, `as-turn14-predeploy.sav`; findings `findings/subsystems/spy-detection-roll.md` |

Edits to existing rows:

  • Row 62 (guest holders) — VM144 = FREE (lane AS released 2026-09-09; restored to L3's build + shim.cfg + the three pre-AS autosaves byte-identical, main menu verified by screenshot). SavedGames is now 15 FILES - AS added MyGameas1predeploy (400,347) and MyGameas1spydep (400,376), so ROW POSITIONS HAVE MOVED AGAIN; screenshot the dialog. AS left C:\SOTS\shimdist-as + ui\as{go,hash}.ps1 + ui\preAS-*; click_helper.ps1 untouched.
  • Row 207 (V2's eight sites) — append: FIRED 2026-09-09: 0x00887c8a (spy 13), lane AS, 1 word, behind a deployed spy at a foreign colony with a usable asteroid belt. V2 vindicated a second time. Five of the eight still unfired: P's three (0x00840929 / 0x008409c7 / 0x00840a3c, gate now sdet != -1and turn-sdet>=3, ~20 End Turns from as-turn15-spydeployed.sav), 0x0088dc43 (spy 14, gatecm in 1..4, lane AG), 0x0082cdb8 (trade 15). AND THE INVENTORY IS SHORT ONE: 0x0078c97f in the SHIPACTION_DEPLOYSPY handler draws a NextFloat on the strategic generator and is in no sweep, because it hangs off the stack-built ship-action table.
  • Row 373 / the spies2 rule-6 flag — CLOSED 2026-09-09 by lane AS, from the writer and then live. spies2 (ServerSystem+0x1cc) is the per-system vector of DEPLOYED spy ids: ServerSystem::AddSpy 0x007514c0 sets SpyCraft+0x10 (deat) to the system handle and push_backs SpyCraft+0x4 (sid) into +0x1cc; RemoveSpy 0x0074f550 is the exact mirror. Measured non-empty for the first time in the corpus: count=1, element=1, in the deploy system only. SysMemandmts are untouched and their flags stay up.
  • Row 186 / tail-rng-ledger.md — append to the existing qualification: THIRD site outside the firing-indexed table has now fired: the spy detection roll (tail T23c9, lane AS). The pattern is now three for three - hives, the raid roll, the detection roll - and each fired on the first state built for it. The gate-indexed audit is the fix, not a longer table.