sots-re/findings/control-flow/tail-probes.md
alex 8ad9971f6e lane H: five live probes, the oracle restored, and a harness perturbation bisected to one hook
- determinism oracle regenerated and byte-identical (bb4fd9ac / 978041ac)
- the four phase-23/33 draw-bearing tail callees run EVERY turn; the three inner
  functions holding the draws run zero times -- the gate is inside each outer body
- CreateRaidEncounter is called (2 on one turn) and draws nothing: candidate list empty
- Zuul: 7 calls / 7 words per trade-raid Chance site, 14 not 16, as predicted
- EncounterDetect_Run receives an EMPTY record vector, so ProcessTeamRecord and
  AssignContacts never run; the 2-word detection residual is in 0x007d5150's subtree
- a MinHook detour on 0x00893290 changes the game's output; bisected over six runs.
  The un-instrumented game and lane Z's instrument agree, so lane Z's numbers stand
- lane AI1 insert: P2 held across two fresh processes, Rung B stays as written
2026-09-08 13:53:36 -04:00

438 lines
27 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.

# Five probes on the running game — and a harness defect the probes found in themselves
Lane H, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
Engine worktree `wip/probes`, predictions in `sots-engine/docs/H-probes.md`, **committed before the build**
(method rule 2). VM140 held for the duration; released at the end.
**The headline, in order of what depends on what.**
1. **The determinism oracle reproduces exactly.** `bb4fd9ac…` and `978041ac…`, byte for byte, after
everything this session changed. §1.
2. **A hook in this lane's own instrument changed the game's output**, and it was caught by comparing
against a save lane Z had already produced. Bisected to **one address**. Every measurement below
was then re-taken on a configuration proved byte-neutral against that save. §2.
3. The four tail callees that can draw are **entered on every single turn** and gated *inside*; the
three inner functions that hold the draws are entered **zero** times. "Never observed firing" was
about a gate, not about reachability. §3.
4. `CreateRaidEncounter` **is called** — twice on one measured turn — and **draws nothing**. The two
hypotheses a word count could not separate are separated: the candidate list is empty. §4.
5. The Zuul prediction holds exactly: **7 calls and 7 words per site, 14 not 16.** The raid loop
iterates the player vector. §5.
6. `EncounterDetect::AssignContacts` cannot fire on our corpus for a reason nobody had measured: its
caller is never called, because `EncounterDetect_Run` receives an **empty** record vector. The
2-word detection residual is somewhere else entirely, and §6 names the function it is in.
---
## 1. The oracle, regenerated — and it reproduces
Lane N's End Turns overwrote the three autosave *contents* on VM140 and said so. The file set was
never disturbed, `ref-turn2.sav` still hashed `ab4ac2d7e2977260…` (checked before the run), and all 11
curated saves were in the repo, so nothing was lost — but the reference pair had to be remade.
Load `ref-turn2.sav`, one End Turn, build `recap-7584bad-20260908T0615Z`, `hooks=trace`:
| file | size | sha256 | historical value |
|---|---|---|---|
| `(Autosave EndTurn).sav` | 66,732 | `bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948` | **identical** |
| `(Autosave).sav` | 67,219 | `978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921` | **identical** |
**Both reproduce.** The turn-2 state on screen (Imperial Savings 289,688) and the turn-3 state
(532,369) match `determinism-oracle.md` as well.
This is worth more than a repeat. The process the oracle now runs in is not the one it was measured
in: nineteen template hooks and seven entry-point detours installed instead of one asm stub, an x87
control-word module, eight save files where there were four, and a Zuul game played to turn 64 in the
same directory in between. **The oracle is revalidated across all of that**, which is what every
Phase-2 comparison downstream of it needed.
Files: `verify/results/shim/probes/oracle-restore/h-oracle-{endturn,autosave}.sav`.
**And the same two files were put back on the VM at the end**, so the three autosaves once again carry
the documented oracle bytes. That is a restore of bytes this lane produced by an actual run today, not
a substitution of an older snapshot — the distinction lane N was right to insist on.
---
## 2. The instrument perturbed the game. Bisected to one address.
This section is first among the probes because it invalidates a run and because nothing in this
campaign has ever tested it.
### 2.1 How it was caught
Three End Turns from `ref-turn2` under the full lane-H build gave per-turn word counts of
**19, 18, 19**. Lane Z's per-site table for the same three turns of the same save reads **19, 18, 20**.
One word apart on the third turn — the kind of difference that is easy to explain away as workload.
It was not explainable, because the **input was byte-identical**. The run's `(Autosave EndTurn).sav`
(the turn-4 pre-turn state) hashed `8648957fecf5041233f9add4c8173f78981f026f133ecdbecdb89ddd10917048`,
which is exactly lane Z's `z2-endturn.sav`. Same bytes in; different bytes out:
| | `(Autosave).sav` after the turn-4 → turn-5 End Turn | size |
|---|---|---|
| lane Z (`z2-autosave.sav`, and `z-t5-autosave.sav` from a second lane-Z run) | `b2124798470d85ea…` | 68,267 |
| lane H, full build | `058656787877cbee…` | 68,263 |
Both sides are reproducible: lane Z's value appears in two of its runs, and lane H's appeared in two
of its own (three consecutive End Turns, and a fresh load of `z2-endturn.sav` followed by one End
Turn). So this is not noise in either direction.
### 2.2 The bisect
`z2-endturn.sav` was pushed to the VM and the same single End Turn run five more times, changing one
thing each time. A `probes=` key was added to `shim.cfg` for exactly this purpose — an instrument that
can change what it measures has to be switchable, or the question cannot be asked at all.
| configuration | `(Autosave).sav` | verdict |
|---|---|---|
| `hooks=off` — the shim installs **nothing** | `b2124798…` | the un-instrumented game |
| all template hooks + 7 draw-site detours, `probes=0` | `b2124798…` | **neutral** |
| + probes 0–3 (the four phase-23/33 vtable callees) | `b2124798…` | neutral |
| + probes 4–7 (three inner callees, `CreateRaidEncounter`) | `b2124798…` | neutral |
| + probe 8 (`ServerTradeManager_GenerateTradeRaidEncounters` 0x00893290) | **`058656…`** | **perturbs** |
Two things fall out, and the first is the more important:
* **The un-instrumented game agrees with lane Z, not with lane H.** So lane Z's ledger — 7 draw-site
detours and 6 nested template hooks — **is behaviour-neutral on this pair**, and its published
numbers stand. This is the first time any lane has checked that, and it came out the right way.
* **Hooking `GenerateTradeRaidEncounters` at 0x00893290 with a MinHook detour changes the turn.**
Specifically it *suppresses* the `ServerPlayer::RollResearchEvent` draw at 0x0088df4f: the clean run
spends 20 words with that site firing once, the perturbed run spends 19 with it not firing at all.
### 2.3 What the mechanism is not
The obvious relocation hazard is **absent**. The bytes at 0x00893290 are
```
55 push ebp
8b ec mov ebp,esp
6a ff push -1 <- the 5-byte patch boundary falls exactly here
68 37 c3 99 00 push 0x99c337 ; SEH scope table
64 a1 00 00 00 00 mov eax,fs:[0] ; ... standard MSVC __try + /GS prologue
```
so MinHook's five bytes land on a clean instruction boundary, the relocated prologue is three
position-independent instructions, and there is no branch target inside the patched range. A mangled
prologue is therefore **not** the explanation, which makes this more interesting rather than less.
**The mechanism is undetermined and is recorded as an open item, not guessed at.** What is odd, and is
the thing to chase: `RollResearchEvent` runs *earlier in the turn* than trade-raid generation, so
hooking 0x00893290 changed a decision taken before the hooked function was reached. The cheapest next
probe is a compare-mode hook on `RollResearchEvent` in both configurations, reading the threshold and
the progress value it tests, on the same input save.
### 2.4 What this costs, and what it does not
* Every number in §§3–6 was re-taken at `probes=8`, the largest configuration proved byte-identical
to the un-instrumented game on this save. Nothing below rests on the perturbed run.
* The perturbed run's trace is kept — `verify/traces/h-probes-human-perturbed-t3-5.jsonl.gz`, report
`verify/results/shim/probes/human-t3-5-perturbed.txt` — labelled as such, because it is the evidence
for §2 and because its probe *entry counts* agree with the clean runs on every row.
* **The general lesson is a method rule waiting to be written.** A behavioural harness has to be
checked against the oracle, not just against itself. It cost one save comparison to find this; it
would have cost a published wrong number to find it later. The `probes=` key exists so the check is
cheap to repeat: any lane adding detours should run one End Turn from `z2-endturn.sav` and confirm
`b2124798470d85ea…` before trusting its own measurements.
---
## 3. The four tail callees — reached on every turn, gated inside
Lane V2 closed lane K's tier-4 blind spot: four of the eleven phase-23/33 vtable targets on
`StrategyServer+0x158`/`+0x15c` reach a draw on the strategic generator, at eight instructions. Lane Z
measured 0 tail words on eight turns. Between those two facts sat a question a word count cannot
answer — *not reached*, or *reached and gated*?
Register-transparent asm stubs (`pushfl; pushal; push idx; call; popal; popfl; jmp trampoline`) count
entries without assuming a calling convention. Two saves, three measured turns:
| address | what | Human t5 | Zuul t24 | Zuul t25 |
|---|---|---|---|---|
| 0x008877b0 | `ServerSpyManager` slot 13 | **1** | **1** | **1** |
| 0x0088db80 | `ServerSpyManager` slot 14 | **1** | **1** | **1** |
| 0x0088ef80 | `ServerTradeManagerImpl` slot 13 | **1** | **1** | **1** |
| 0x0082cca0 | `ServerTradeManagerImpl` slot 15 | **1** | **1** | **1** |
| 0x008408e0 | `SpyManager_Slot13RngCallee` | 0 | 0 | 0 |
| 0x00820ca0 | `TradeManager_Slot13RngCalleeA` | 0 | 0 | 0 |
| 0x0088b440 | `TradeManager_Slot13RngCalleeB` | 0 | 0 | 0 |
**P2a and P2c both hold.** The phase-23/33 dispatch block is unconditional, exactly as lane V2 read
it; and the gate that keeps the draws from firing is **inside each outer body, above the inner call**.
No draw-site row appeared at any of the eight return addresses on any turn, and the tail's bracket
cost stayed 0 — reproducing lane Z's number under an instrument that can now say *why*.
So the correct statement is no longer "the tail draws nothing" and not even "the tail draws nothing on
these turns". It is: **the tail's four draw-bearing callees run on every turn and return before
reaching their draws.** That is a much stronger negative, and it converts the open question from
"which turns reach these functions" to "what does the gate in each body test".
### 3.1 Three of the four gates are an empty container; the fourth is not, and it was wrong
Read from the bodies (each confirmed a real function start with exactly one caller):
| callee | the loop it drives | empty when |
|---|---|---|
| `ServerTradeManagerImpl` slot 15 | vector at `tradeManager+0x3c .. +0x40` | no trade routes |
| `ServerSpyManager` slot 14 | vector at `spyManager+0x10 .. +0x14` | no spy programs |
| `ServerSpyManager` slot 13 | 1,869 bytes, same manager, not fully read | — |
| **`ServerTradeManagerImpl` slot 13** | **`GetServer()+0x64 .. +0x68` — the FLEETS vector** | **no fleets** |
That last row produced a competing prediction, **P2b-alt**, committed after the bodies were read and
before any run: on a save with fleets, slot 13's loop body runs, `TradeManager_Slot13RngCalleeA`
fires, and the tail cost is non-zero.
**P2b-alt is falsified, and cleanly.** `zuul-turn23-fleet23.sav` is named for its 23 fleets; slot 13
was entered on both its turns and 0x00820ca0 was entered **zero** times. So the fleet loop is not the
gate — the per-fleet test chain that precedes the call at 0x0088f036 is, and it rejects every fleet on
a 23-fleet save. That is a narrower and more useful place to look than "the loop never runs", and it is
the kind of wrong prediction that pays for itself: had P2b been the only one written down, its
confirmation would have been read as "no trade routes, no spies" and the fleet loop would have gone
unexamined.
### 3.2 What is still a hypothesis (rule 6)
**Whether any of the eight draw sites ever fires** is still unexercised. The workload that would
settle it — a game with live trade routes and a live spy program — was **not built**, and it is worth
saying exactly why rather than vaguely:
* SotS 1 trade income requires trade-station construction and the tech behind it; from `ref-turn2`
(turn 2, 1 colony, 0 techs) that is tens of End Turns at 30–60 s each plus a Build/Research click
path per turn. The newest Zuul save in the corpus (turn 23) still has **no** trade routes: slot 15's
loop body did not execute on it.
* The corresponding spy state is worse — no lane has yet identified which UI produces an entry in the
`spyManager+0x10` vector, so there is no click path to write down, only a container to watch.
* What this lane did instead was cheaper and, for the map, better: it established that the containers
are the gate for two of the four and that the fourth is gated per-fleet. A future lane that builds
the workload now knows which two vectors to watch fill, and can confirm the workload took *before*
spending a turn measuring.
---
## 4. `CreateRaidEncounter` — the two hypotheses, separated
A successful trade-raid roll calls `ServerTradeManagerImpl` slot 17, `CreateRaidEncounter` 0x008938a0,
which draws a `NextInt` at 0x008939ee to pick a target but returns at 0x0089391c **without drawing**
when the candidate vector is empty. It cost 0 words on all three of lane Z's turns, which lane Z read
as consistent with either "no roll succeeded" or "the candidate list is empty".
**A correction to the prior first (rule 11).** Lane Z wrote that no roll succeeding is "≈11% on the
defaults, so three quiet turns in a row is unremarkable". 11% is the probability of a quiet *turn*:
`0.8^8 × 0.95^8 = 0.1113`. Three consecutive is `0.00138`, about **1 in 720**. The two hypotheses were
never close to equally likely, and the expected number of successes per turn is `8 × 0.2 + 8 × 0.05 = 2.0`.
**Measured:**
| turn | `CreateRaidEncounter` entries | words drawn |
|---|---|---|
| Human t4 → t5 | 0 | 0 |
| Zuul t23 → t24 | **2** | **0** |
| Zuul t24 → t25 | **1** | **0** |
| Human t3/t4/t5, perturbed run | 0 / 1 / 0 | 0 |
**Settled: rolls succeed at about the predicted rate, and the candidate list is empty every time.**
Four successful rolls were observed across five measured turns and not one of them reached the
`NextInt` at 0x008939ee — no draw-site row at return address 0x008939f3 on any turn.
For the standalone this is the useful form: on a corpus like ours the cost of a successful raid roll
is **0 further words**, and the row lane Y flagged as "the one that will bite" does not bite yet. It
will bite the first turn a raid candidate exists, and the candidate list is built by 0x0083b110 — a
function no lane has read.
**One caveat this lane found and must state.** `CreateRaidEncounter` is slot 17 and is reached by
virtual dispatch, and `GenerateTradeRaidEncounters` is **not its only caller**: `0x007d5150`, the
other subtree of `DetectEncounters` (see §6), also dispatches slot 17 —
`(**(code **)(**(int **)(S + 0x158) + 0x44))(out, player, 1, records)` — on the branch where its own
per-player roll returns 0x12. So an entry count of 2 does not prove two *trade-raid* successes; it
proves two slot-17 calls from one of two known sites. Separating them needs the return address, which
an entry counter deliberately does not capture. `tail-rng-ledger.md` §11.1's "called from
`GenerateTradeRaidEncounters` at 0x0089345e / 0x00893548 / 0x00893603" is incomplete as a list of
callers.
---
## 5. The Zuul word count — 14, exactly as predicted
Lane Z's free prediction, restated by lane Y as a consequence of its `trade_raid.cpp` model: the two
`Chance` sites in `GenerateTradeRaidEncounters` cost one word per entry of `StrategyServer::Players`,
which holds 8 on the Human saves and 7 on the Zuul ones. Lane N ran a Zuul End Turn with the RNG hooks
off, so it could not answer.
`zuul-turn23-fleet23.sav`, two End Turns, `probes=8`:
| site | Human t5 | **Zuul t24** | **Zuul t25** |
|---|---|---|---|
| 0x00893426 `Chance` (`TRADE_RAID_ODDS_PLAYER`) | 8 calls / 8 words | **7 / 7** | **7 / 7** |
| 0x00893513 `Chance` (`TRADE_RAID_ODDS_NPC`) | 8 calls / 8 words | **7 / 7** | **7 / 7** |
| trade-raid block total | 16 | **14** | **14** |
| turn total (attributed = measured) | 20 | **18** | **18** |
**P5 holds in full**, and it is a prediction that survived a changed workload rather than a repeat of a
recording: 7 is a number no earlier run produced. The loop iterates the server's player vector and
nothing else — not the `TeamRecord` vector it is handed, not a species list, not a fixed NPC count.
Lane Y's `trade_raid.cpp`, which counts `NumPlrs`, is right.
The Zuul turn's remaining 4 words are 2 detection (§6) and **2 research** — both `ProcessResearch`
sites (0x00587888 and 0x005878bb) fired on both turns, which is the second research site's first
appearance in a site table.
---
## 6. `DetectEncounters` — the residual is not the `AssignContacts` family, and here is where it is
Lane Y's standalone leaves a 2-word residual per turn, attributed by lane Z to 0x0050329d
(`NextFloat`, depth 4) and 0x007929a4 (`NextInt`, depth 3), and refused to model it: *"fitting 2/turn
to three observations is not a model."* Lane I bounds the containing family at
`|contacts| × |detectors|` and showed `EncounterDetect_AssignContacts` draws one inlined `NextFloat`
per (contact, detector) trial. Lane Z found `AssignContacts` never ran.
`EncounterDetect_ProcessTeamRecord` 0x007ca640 — its only caller — was hooked, with lane I's gate
predicate, contact count and detector count recomputed from the record at hook entry.
**It never ran either. On any turn, on either save.** The hook installed (`MH_OK` for create and
enable, logged), was registered in the trace's meta line, and emitted **zero records** across five
End Turns, while the entry probe on `EncounterDetect_Run` recorded **one call per turn, every turn**.
`EncounterDetect_Run` calls `ProcessTeamRecord` once per 0x74-byte record. One call, zero records
means the vector it receives is **empty**. So:
* **`AssignContacts` cannot fire on our corpus, and the reason is one level higher than anyone
thought.** It is not that the `+0xfc` gate fails on the records — there are no records. Lane I's
`|contacts| × |detectors|` bound is correct and simply has nothing to bound yet.
* **P3b is falsified**, exactly as its falsification clause anticipated, and the depth reasoning that
produced it was wrong about the *path*, not the depth.
### 6.1 Where the two words actually are
A call-graph walk from both residual sites upward lands in the same place, and it is not
`EncounterDetect_Run`:
```
StrategyServer::DetectEncounters 0x007d7f70 (the last phase of ProcessTurn)
├─ 0x007d8469 GenerateTradeRaidEncounters [virtual] 16 words (14 on Zuul) §5
├─ 0x007d8470 EncounterDetect_Run 0x007cb080 0 words -- empty record vector
└─ 0x007d860b 0x007d5150 ** the 2 words **
├─ 0x007d52b3 0x0078cc70 -> 0x0078ccb1 0x00503200 -> NextFloat 0x0050329d 1 word
└─ 0x007d5311 / 0x007d5338 0x00792750 -> NextInt 0x007929a4 1 word
```
Every one of those edges is a direct `call` with a **single** incoming reference, so the attribution
is not a guess: 0x00503200 has exactly one caller (0x0078cc70), which has exactly one caller
(0x007d5150); 0x00792750 has two call sites, both inside 0x007d5150; and 0x007d5150 has exactly one
caller, `DetectEncounters` at 0x007d860b.
**The whole detection residual is inside one 744-byte function with one call site.** That is a far
better handle than a family bound over two counts nobody can observe, and it makes the residual a
tractable modelling target rather than a fitted constant. From its body:
* it first builds a bitmask over the same `records` vector (`entry[0]->+0xf9 == 0` sets bit
`entry[0]->+0x28`) and returns immediately if the mask is 0;
* it then loops the **player vector** at `+0x54/+0x58`, skipping players whose mask bit is set or
whose `+0xf9` is non-zero, builds a per-player candidate vector (0x00795870), and for each
non-empty one calls 0x0078cc70 — **that is the `NextFloat`** — whose non-zero return leads on, and
whose return value of **0x12 dispatches slot 17, `CreateRaidEncounter`** (§4's caveat);
* the measured cost is 1 call and 1 word per site per turn on 8-player and 7-player saves alike, so
only one player reaches the draw per turn on both — the per-player gate, not the loop bound, is what
sets the count.
That last observation is the one to hand lane Y: **the 2 words are not `2 × |players|` and not a
constant by luck — they are one per player that passes a gate, and on both corpus saves exactly one
player passes.** A model that assumes 2/turn will be right on our saves and wrong on a busier one, and
now there is a named function to read to find out when.
### 6.2 The hook that measured nothing, reported as loudly as the ones that did (rule 15)
`EncounterDetectProcessTeamRecordHook` compared nothing, because it never ran. Its coverage notes, its
gate predicate, its contact/detector classifier and its byte-vs-dword cross-check on `+0xfc` are all
**unexercised**. They are kept in the tree because the moment a save produces a non-empty record
vector they become the check that matters — but nothing in this report rests on them, and the
`fc_byte_vs_dword_disagreements` counter has never returned a value. Lane I's classification rules are
therefore still exactly as verified as they were: from the instruction stream, and from nowhere else.
---
## 7. Coverage — what these runs did not touch
* **Five End Turns on two saves.** Human `ref-turn2` turn 4 → 5 (once clean, three times in the
perturbed run) and Zuul turn 23 → 25. That is a thin workload measured precisely.
* **No trade routes, no spy programs, no manufactured workload for either.** §3.2 says what it would
take. Until then, every statement about the eight tail draw sites is "they are not reached on a game
with none of either", and the four callees' gates are located but not read.
* **`ServerSpyManager` slot 13 (1,869 bytes) was not read.** Its loop and its gate are unexamined; it
is grouped with slot 14 above only by manager, not by evidence.
* **The probe counters cannot say which caller.** They record entries, not return addresses — a
deliberate cost of being calling-convention-agnostic — and §4 shows that costing something real.
* **The perturbation's mechanism is unknown**, and it is the largest open item this lane produced.
A second, independent instance may already exist and be undetected in some other lane's build; the
`z2-endturn.sav` → `b2124798470d85ea…` check is cheap enough that every future VM lane should run it.
* **No `Guard` region is declared by any hook in this family**, the same deliberate gap lane Z
reported: these hooks measure the generator and claim nothing about game state. `undeclared = 0` in
these traces is vacuous and must be read that way.
* **`probes=8` is proved neutral on one save and one turn.** It is not proved neutral in general, and
the Zuul numbers in §3 and §5 inherit that limit — they were taken on a configuration validated
against a Human turn, not a Zuul one.
## 8. Corrections to earlier findings
* `tail-rng-ledger.md` §11.1: the caller list for `CreateRaidEncounter` is incomplete — 0x007d5150
dispatches slot 17 as well (§4).
* `tail-rng-ledger.md` §11.1: "three quiet turns in a row is unremarkable" — it is a 1-in-720 event,
and the measurement shows the rolls do succeed (§4).
* `tail-rng-ledger.md` §11.3 and lane Y's residual table: the 2-word detection residual is **not** the
`AssignContacts` family and is not blocked on the `+0xfc` gate; `EncounterDetect_Run` receives an
empty vector, and the words are in 0x007d5150's subtree (§6).
* Lane V2's per-slot notes: "never observed firing" is confirmed for the draws and **wrong as a
statement about the callees** — all four run on every turn (§3).
* `combat-done-tail.md` tier 4 / lane V2: the trade slot-13 loop is over the **fleets** vector, and a
23-fleet save still reaches no draw (§3.1).
## 9. Files
* Predictions (pre-registered): `sots-engine/docs/H-probes.md`.
* Traces: `verify/traces/h-probes-human-t5.jsonl.gz`, `h-probes-zuul-t24-25.jsonl.gz`,
`h-probes-human-perturbed-t3-5.jsonl.gz`.
* Reports: `verify/results/shim/probes/{human-t5,zuul-t24-25,human-t3-5-perturbed}.txt`;
tool `tools/probe_report.py`.
* Oracle pair: `verify/results/shim/probes/oracle-restore/h-oracle-{endturn,autosave}.sav`.
* Addresses: `ghidra/addresses.d/lane-h.json` (3 entries, all `mapped`).
* Shim: `sots-engine/src/shim/hooks/probe_entry.{h,cpp}`, the `probes=` config key, and
`EncounterDetectProcessTeamRecordHook` in `tail_rng.{h,cpp}`; configs `shim.cfg.hprobes*`,
`shim.cfg.hp{0,4,8,9,10,11}`, `shim.cfg.hoff`.
---
## 10. Priority insert from lane AI1 — run before the VM was released
Lane AI1 asked for its P1–P3 on the same VM session. Results are written up where they belong, in
`findings/subsystems/ai-turn-logic.md` §8.1; in brief:
* **P2 held — and it is the roadmap-level one.** `ref-turn2.sav` loaded in **two separate process
launches** (pids 3400 and 2028, killed and relaunched between), shim at `hooks=off` so nothing is
installed, one End Turn each: both autosaves byte-identical to each other and to the 2026-09-07
oracle. Byte-identity subsumes `ModCount` and `Summary.Checksum`. **The AI's turn is reproducible
from a save alone**, so `backlog.md` §2's Rung B stays as written. Seven fresh processes across two
saves agreed today in total, counting the `z2-endturn.sav` bisect runs of §2.
* **P1 could not be answered as specified.** `session.log` after a full load + End Turn of a game with
an AI player is 22 lines and contains no `RunAI:`, no `random seed` and no `AI Process Turn` line;
the game's stdout carries the same 22 and nothing more. The level-2 messages are not emitted by this
build at its default log level, so the "one log read, no build" route does not exist. A suggestive
counter-indication to the all-zero-seed prediction is recorded there as a hypothesis, with the
decisive test named (record the `this` pointer beside the return address in `draw_sites.cpp`).
* **P3 not run** — no `ModCount` watchpoint was armed and no debugger session was held.
## 11. VM140 released
Left at the **main menu**, verified by screenshot, build `recap-7584bad-20260908T0615Z`,
`shim.cfg` = the recap trace config, `SavedGames\` back to the documented **8-file** set
(`ref-turn2` still at (400,436), `zuul-turn5` at (400,465)) with the three autosaves carrying the
oracle bytes `bb4fd9ac…` / `978041ac…` / `978041ac…`. Lane H left `C:\SOTS\shimdist-h` and
`C:\SOTS\ui\h{deploy,release}.ps1` in place, plus `C:\SOTS\ui\h-oracle-{endturn,autosave}.sav` and
`ai-run{1,2}.log`.
**One VM note worth carrying forward.** The board's advice to screenshot before clicking is right but
under-specified: the *intro* screens are bright white, so a brightness threshold fires on them and the
click batch lands nowhere. The reliable detector is **redness in the main menu's Exit button** —
crop `100x14+462+674`, require `mean.r > 0.2` and `mean.g < 0.15` (the menu reads 0.327 / 0.036; the
white intro reads 1.0 / 1.0; a black screen reads 0 / 0). With that, every one of this lane's ten
launches reached the menu on the first attempt, at t+48…54 s.