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

706 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# The 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 != -1` and turn-sdet>=3, ~20 End Turns from as-turn15-spydeployed.sav), 0x0088dc43 (spy 14, gate `cm 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. `SysMem` and `mts` 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.`