sots-engine/docs/L1-predictions.md
alex 2947e24ed9 L1: hook BeginProcessTurn and the three script-object writers a turn reaches
Lane SV recovered the script-object subsystem statically and predicted that
SVSOSwarmQueen::RegisterHives takes one RNG_NextInt per new hive inside
StrategyServer::BeginProcessTurn -- which runs inside lane Z's autosave bracket
and outside every one of its subtotals, so a draw there had never been
attributed by anything.

Five nested trace hooks, each declaring the strategic generator as a region and
each carrying a model evaluated at entry so the record can disagree with it:

  StrategyServer::BeginProcessTurn        the unhooked interval, plus a region
                                          over Frame so the increment is a fact
    SVSOSwarmQueen::OnTurnBegin           evt 0x13, vtable slot +0x60
      SVSOSwarmQueen::RegisterHives       predict_new_hives from the original's
                                          own two predicates; reads the LO/HI
                                          config pointers live
      SVSOSwarmQueen::TickHives           the NextQ slip and its three gates
    SVSOSlaversRefuel::UpdateDifficultyTier  tail phase 20; a 4-byte region over
                                          CDiff so a store and its ABSENCE are
                                          distinguishable (method rule 20)

Also watch.mode=snlv: one arming line moves slot 1 from the NVO map's _Mysize to
the target system's SnLv, and the arming sweep now prints SnLv with its decoded
per-player 2-bit levels for every system, which costs no debug register.

Measured on VM140: hive creation costs 2 words in BeginProcessTurn and the
residual outside the two turn drivers is 2, not 0; the next turn it is 0 again.
LO=20 HI=30. Spica's SnLv reads 0x200 with AFlags 0. The oracle reproduced
byte for byte with every one of these detours live.

Predictions and outcomes: docs/L1-predictions.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 17:43:10 -04:00

221 lines
14 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.

# Lane L1 — predictions, committed before the build
Written 2026-09-08 against `main` `0117495`, before any code was written and before any VM run.
Instrument: `src/shim/hooks/tail_rng.cpp` (five new nested hooks), `src/shim/hooks/watchpoints.cpp`
(one new arming mode). Workloads: `turn1-state.sav` (hives), `ref-turn2.sav` (oracle control and
`SnLv`).
The point of writing this first is that every number below can be wrong in a way that is visible in
the trace. Each prediction carries its falsifier.
---
## 0. What the static reading says, re-derived independently
Lane SV recovered all of this without running anything. Before predicting, its instruction reading
was re-done from `dumps/sots.exe` with `objdump -M intel` at real instruction boundaries (rule 17 —
disassembled past Ghidra's reported end to the `int3` padding in every case). **Everything below was
confirmed byte for byte**, with two small corrections of address, not of substance.
* `StrategyServer::BeginProcessTurn` **0x007d98e0 .. 0x007d9ae8** (`ret`, thiscall, no stack args).
`inc [esi+0xc]` at **0x007d990a** is `Frame++`. The last thing the function does before its
epilogue is the script two-step at **0x007d9ab8**: `vft[0x10](0x13, 0)` then `vft[0x60]()`. `+0x60`
is exactly what lane SV's jump table gives for event 0x13. Everything else in the body is two
container walks (`S+0x44` systems, `S+0x64` fleets) and an event post (`push 0x24`).
* `SVSOSwarmQueen_OnTurnBegin` **0x00529930 .. 0x0052999f** (`ret`): `RegisterHives` →
prune-by-`EggScio` → `TickHives` → `0x00505100`.
* `SVSOSwarmQueen_RegisterHives` **0x00527630 .. 0x00527761** (`ret`). Per system in
`GetGame()->+0x44..+0x48`, `[sys+0x184] == this->+0x4` and no existing hive whose `+0x4` is that
system; then
```
5276e1 mov ecx,ds:0xae0204 ; &LO
5276e7 mov edx,ds:0xae0208 ; &HI
5276ed mov eax,[eax+0xc] ; frame (eax = GetGame())
5276f0 mov edi,[ecx] ; LO
5276f2 mov esi,[edx] ; HI
5276f4 add edi,eax ; add esi,eax
5276f8 call GetGame ; mov eax,[eax+0x16c]
527707 sub esi,edi ; bound = HI - LO
527709 lea ecx,[eax+4] ; ECX = &mt
52770f call 0x4271c0 ; RNG_NextInt <-- THE DRAW, one per new hive
527717 add eax,edi ; nextQ = frame + LO + draw
52771f call 0x523080 ; push_back
```
**Correction to `svsctob-writers.md` §4.3:** the draw call is at **0x0052770f** (return address
0x00527714), not 0x0052770c — 0x0052770c is the `mov [ebp-0x14],esi` that stores the bound. The
substance is unchanged and `GetGame()` (0x00578050) returns the **`S` frame**: `[+0xc]` is `Frame`
and `[+0x16c]` is the strategic generator, the same object lane Z's ledger watches.
* `SVSOSwarmQueen_TickHives` **0x00527770 .. 0x005279a1** (`ret`). Four gate-failure edges
(`0x005277e3`, `0x005277fe`, `0x00527848`, `0x0052784f`) all land on **`inc [esi+0x8]`** at
**0x0052785a**. Gates read `[0x00ae0210]` (a frame floor), a queens-vector cap over
`(this->+0x24 − this->+0x20)/12`, and `[0x00ae0228]`; the spawn arm reads `[0x00ae0220]`.
**No RNG primitive is called anywhere in the body.**
* `SVSOSlaversRefuel_UpdateDifficultyTier` **0x00515820**. The stack table, read off the stores:
`{1,1,1} {50,2,3} {100,2,5}` at `[ebp-0x34]`, `[ebp-0x28]`, `[ebp-0x1c]`. Only the **threshold**
column is ever loaded (`cmp [ecx],edx`); the other two are dead in this function. The scan is
```
515893 xor eax,eax ; lea ecx,[ebp-0x34]
515898 cmp [ecx],edx ; jg 0x5158c2 ; first threshold > frame
51589c add eax,ebx ; add ecx,0xc ; cmp eax,3 ; jl 0x515898
<fall through to 0x5158a6 = THE EPILOGUE, NO STORE>
5158c2 test eax,eax ; jle 0x5158a6 ; index 0 -> NO STORE
5158c6 dec eax ; cmp eax,-1 ; je ... ; (redundant with the jle)
5158cc cmp [edi+0x38],eax ; je ... ; unchanged -> NO STORE
5158d1 mov [edi+0x38],eax ; CDiff = index - 1
```
and the class's ctor at **0x0051a820** stores `[eax+0x38] = 0xffffffff`, which is exactly the
`CDiff = -1` that `turn1-state.sav` carries. **Lane SV's off-by-one reading is confirmed
independently: `CDiff` can only ever hold 0 or 1.**
* `SVSOSlaversRefuel_HandleEvent` **0x0051a800** is nine instructions, `ret 8`:
`if (evt == 0x14) UpdateDifficultyTier(this)`.
## 1. The corpus fact this lane is built on
`turn1-state.sav` (Frame 1) has `SvSctOb / EncID 10 / Hives` **count 0**. `turn2-state.sav`
(Frame 2) has **two** hives, `(HiveID 336, QueenID 0, NextQ 31)` and `(400, 0, 29)`.
`turn3-state.sav` has the same two with `NextQ` **32 / 30**. The two systems with `EggScio == 3` in
`turn1-state.sav` are 336 and 400.
So the End Turn taken from `turn1-state.sav` is **the hive-creation turn**, and the End Turn after it
is **the slip turn**. Both are one click.
---
## P1 — hive creation draws, and lane Z's "residual exactly zero" is a statement about its workload
On the End Turn taken from `turn1-state.sav`:
1. `SVSOSwarmQueen_RegisterHives` is **entered exactly once**, inside `BeginProcessTurn`, before
`StrategyServer::ProcessTurn` runs.
2. It reports `hives_in = 0` and `hives_out = 2`.
3. Its measured word cost is **2** — one `RNG_NextInt` per new hive. (`NextInt` is a
reject-on-`(y & mask) > n` loop, so 2 is the *minimum*; see the falsifier.)
4. `SVSOSwarmQueen_OnTurnBegin`'s cost is the same 2 (`TickHives` adds nothing).
5. `StrategyServer::BeginProcessTurn`'s cost is the same 2.
6. **The bracket residual is 2, not 0**: `Autosave(endTurn=1) → Autosave(endTurn=0)` total minus
`ProcessTurn` minus the tail equals **2**.
7. The two hives written carry `NextQ` **31** and **29**, matching `turn2-state.sav` exactly.
**What this would mean.** `tail-rng-ledger.md`'s headline — *"the residual outside the two turn
drivers is exactly zero"* — is correct for turns 4, 5, 6, 18, 19, 20, 52-55 and 62-64 of two saves
whose hives already existed, and **wrong as a statement about the code**. That is the same shape as
"the tail draws nothing", which was true of eight turns and false of turn 64.
### Falsifiers
| symptom | what it would mean |
|---|---|
| `RegisterHives` **not entered** | the evt-0x13 delivery does not reach `SVSOSwarmQueen` on this save. SV's `evt -> slot` map or its class table would need correcting. The entry probe separates this from "entered and drew nothing" (rule 20). |
| entered, `hives_in = 0`, **0 words** | the draw is not taken — e.g. the bound `HI-LO` is 0, in which case `NextInt` may return without consuming. Lane Z's zero would stand and SV's claim would be **wrong**. |
| entered, `hives_in = 2` already | the hives exist before `BeginProcessTurn`, so `turn1-state.sav` is not the pre-creation state I read it as. |
| cost **> 2** | `RNG_NextInt`'s rejection loop fired. Expected: with `bound = HI-LO`, `mask = 2^ceil(log2(bound+1)) − 1`, cost per draw is `(mask+1)/(bound+1)` words on average. A cost of 3 on two hives is unremarkable and does **not** falsify the claim; it falsifies only the literal "2". |
| residual **2** but `BeginProcessTurn` cost **0** | the draw is between the autosave markers but outside `BeginProcessTurn`. |
| `NextQ` ≠ 31/29 | the state I loaded is not the state `turn2-state.sav` came from (different queued orders). Does not falsify the draw; does kill P3. |
## P2 — the control: the second End Turn costs 0 outside the drivers
On the End Turn taken from the state P1 produced (frame 2 → 3):
* `RegisterHives` is **entered**, finds both systems already referenced, and costs **0 words**.
* `TickHives` is **entered**, takes a gate-failure edge on both hives, costs **0 words**, and
`NextQ` reads **32 / 30** afterwards — SV's slip rule, live.
* `BeginProcessTurn`'s cost is **0** and the bracket residual returns to **0**.
**This is what makes P1 mean "creation draws" rather than "`BeginProcessTurn` always draws".** If
P2's residual is also 2, the draw is not hive creation and P1's attribution is wrong even if its
number is right.
Falsifier for the slip specifically: if `NextQ` moves by anything other than +1 per hive, or if
`TickHives` costs words, the `inc`-only model is wrong.
## P3 — `LO` and `HI`, which no save can show
The shim prints `*(int*)0x00ae0204` and `*(int*)0x00ae0208` at `RegisterHives` entry. Lane SV could
not follow these to an initialiser and correctly refused to fit them from one two-hive observation.
Reading them live closes the leaf outright.
Prediction: `NextQ = frame + LO + NextInt(HI − LO)` with `frame = 2` reproduces 31 and 29, i.e.
`LO + r₁ = 29` and `LO + r₂ = 27`, with both `r` in `[0, HI − LO]`. Falsifier: any `(LO, HI)` for
which those two are impossible.
## P4 — `CDiff`: two edges, one of them unreachable by playing forward
Model, transcribed above:
| frame at the tail's evt 0x14 | scan index | stored |
|---|---|---|
| ≤ 0 | 0 | **nothing** |
| 1 … 49 | 1 | `CDiff = 0` |
| 50 … 99 | 2 | `CDiff = 1` |
| ≥ 100 | falls off the end | **nothing** |
Predictions, in increasing cost:
* **P4a (free, this run).** `UpdateDifficultyTier` is **entered once per End Turn** on
`turn1-state.sav`, stores `0` on the first (frame 2, `CDiff` −1 → 0) and stores **nothing** on the
second (frame 3, unchanged). The `this+0x38` region shows the store and its absence. This is rule
20's distinction applied to a *store*: "entered and stored nothing" is not "not entered".
* **P4b (~48 End Turns).** The first End Turn whose frame lands in 50…99 stores `CDiff = 1`, and
every later turn below 100 stores nothing.
* **P4c (~98 End Turns).** At frame ≥ 100 the function is still **entered every turn** and stores
**nothing**; `CDiff` stays 1 forever. Tier 2 is unreachable.
* **P4d.** The `frame ≤ 0` edge is unreachable in a real game: `Frame` starts at 1 and the tail
never runs before the first End Turn, which is exactly why `turn1-state.sav` carries the ctor's
−1. This is recorded as a code property, not a workload gap.
Falsifier for P4b/P4c: `CDiff` reading **2** at any frame falsifies the whole reading.
## P5 — `SnLv`, one arming line
`nvo-tshn-visible-owner.md` §6 marks *"the sensor branch is what refreshes Spica"* as **[H]** because
`SnLv` (`ServerSystem+0x24`) is not serialised and has never been measured. The watchpoint module's
`tshn` mode already finds Spica by predicate at arm time; the new `snlv` mode points slot 1 at
`sys+0x24` instead of the map's `_Mysize`, and additionally **logs `SnLv` for all 28 systems at arm
time**, which costs no debug register at all.
Prediction: Spica's `SnLv` has a **non-zero** 2-bit field for the player index its `NVO` record is
keyed on (4 on `ref-turn2.sav`), i.e. `(SnLv >> 8) & 3 != 0`; and Bismol's is **zero** for player 1.
That makes the sensor branch the live refresher of Spica ([H] → [V]) and settles which conjunct
fails at Bismol.
Falsifiers: Spica's field reads 0 → the refresh comes from somewhere else entirely and the model in
§5 of that document is wrong about the mechanism even though it fits the corpus. Bismol's field
reads non-zero → the failing conjunct is `HasResearched(CCC_AdvSens)` alone, which the corpus already
proves true, and the "outside the sensor envelope" alternative is dead.
## P6 — the instrument is behaviour-neutral (rule 19), and I expect it might not be
Five new MinHook detours are installed. Lane H proved one detour on a clean prologue boundary
changed the autosave by 4 bytes. So this is a genuine prediction and not a formality:
**One End Turn from `ref-turn2.sav` with the full L1 hook set live reproduces the oracle:**
`(Autosave EndTurn).sav` = `bb4fd9ac89f41e3b…`, `(Autosave).sav` = `978041acd168b56e…`.
If it does not, every number in P1–P4 is void and the correct report is which hook broke it, found
by bisecting the five. The `hooks=off` run on the same workload is taken as well, so the
hive-creation autosave has an uninstrumented twin to compare against.
---
# Outcomes, appended after the runs (2026-09-08)
Recorded here beside the predictions rather than only in the finding, so the pair can be read
together. Full account: `sots-re/findings/control-flow/hive-creation-rng.md` and
`sots-re/findings/subsystems/snlv-measured.md`.
| prediction | outcome |
|---|---|
| **P1** hive creation draws 2 words in `BeginProcessTurn`; residual 2 | **held exactly.** `RegisterHives` entered once at depth 2, `predict_new_hives = 2`, words 170 → 172; `BeginProcessTurn` 2, `ProcessTurn` 20. The draw-site ledger independently shows a new row at return address 0x00527714, `NextInt`, 2 calls / 2 words. |
| **P1** `NextQ` 31 / 29 | **held**, via a route the prediction did not spell out: `RegisterHives` writes 30 / 28 and `TickHives`' slip takes them to 31 / 29 **on the same turn**. |
| **P2** the next turn costs 0 outside the drivers | **held.** `RegisterHives` entered, `predict_new_hives = 0`, 0 words; `BeginProcessTurn` 0; bracket 192 → 211 = `ProcessTurn`'s 19; residual 0. `NextQ` 32 / 30. |
| **P3** `LO`/`HI` read live | **`LO = 20`, `HI = 30`** ⇒ `NextQ = frame + 20 + NextInt(10)`, inclusive. Both draws land in range (8 and 6). The three `TickHives` gate constants are 10, 5 and 3. |
| **P4a** `UpdateDifficultyTier` entered every turn, stores on frame 2 only | **held.** Frame 2: `predict_path = store`, region `cdiff` −1 → 0. Frame 3: `predict_path = no_store_unchanged`, 0 → 0, **entered and stored nothing**. |
| **P4b / P4c** the frame-50 and frame-100 edges | see the finding; the table scan was re-read independently and `CDiff` can only hold 0 or 1. |
| **P5** Spica's `SnLv` non-zero for its `NVO` key player | **held.** `AFlags = 0x0`, `SnLv = 0x00000200`, level **2** for player 4. Bismol is not on this map, so that half is still open. |
| **P5** the level is 1 | **wrong, and informatively.** The observed non-visible level is **2**; `ComputeContactLevel` has four return tails and the documented "else 1" names only one. |
| **P6** the instrument is behaviour-neutral | **held.** `ref-turn2.sav` + one End Turn with all five new detours live reproduced `bb4fd9ac…` / `978041ac…` exactly, and again with the four watchpoints armed on top. |
**The one thing no prediction anticipated**, and it is the most important by-product: loading
`turn1-state.sav` and ending one turn produces a **different** post-turn autosave in every process,
including with `hooks=off` and nothing installed. One field moves — player 3's research target — and
the strategic generator does not. See the finding's §6.1.