sots-engine/docs/L1-predictions.md
alex fa53e02b53 L1: probe the AI client seed across two processes -- it is fresh every time
Two hooks, Mars::RNG::Seed 0x0049fdf0 and StrategyApp::RunAI 0x008706f0, and a
config that turns everything else off. Two launches from the same save, load
only -- the AI clients are constructed on load, so no End Turn is needed.

Result: every AI client's generator seed differs between processes (net 32,
496 and 512 all move), while the record structure is byte-for-byte the same
shape and one Seed call with seed=0 produces an identical state in both runs.
So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering
effect that was predicted, and lane AI1's 'every draw from the static generator
returns 0' is falsified by measurement.

The prediction said the opposite and is left in docs/L1-predictions.md with its
outcome underneath.

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

288 lines
19 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.
---
# P7 — the AI seed probe (added 2026-09-08, before the build, at the coordinator's request)
**The question.** `turn1-state → turn2` is not reproducible across processes; exactly one of the
three AI players picks a different research target each run. Is that a **seed** effect (a per-process
generator seed) or an **ordering** effect (a tie broken by container order under ASLR)?
**The probe.** Hook `RNG_Seed` 0x0049fdf0 (`thiscall RNG* (RNG* this, uint32 seed)`, `ret 4`) and
`StrategyApp::RunAI` 0x008706f0 (`ret 0x10`, whose 4th argument lane AI1 identified as the AI
client's seed). Launch twice from `turn1-state.sav`, **load only — no End Turn is needed**, because
the client and its generator are constructed on load. Compare the two ordered sequences of
`(this, seed)`.
**Prediction: the seeds are IDENTICAL across the two processes, and the AI client's seed is 0.**
Reasoning, and it is lane AI1's rather than mine: `SNMRunAI` takes the seed it passes to `RunAI` from
the static `Mars::RNG` in `.data` whose only static initialiser writes the *`IStreamable`* vftable
rather than the one `RNG_Seed` installs — so **none of the image's six `RNG_Seed` call sites targets
it**, its `mt[624]` is zero-initialised BSS, and an all-zero MT19937 state is a fixed point of the
twist. Every draw from it is 0. A per-process time seed on a *client* generator would also desync
lockstep multiplayer, which this engine has (`multiplayer-tier0-verified.md`).
So I expect this probe to **rule the seed out** and leave the ordering hypothesis — which agrees with
the coordinator's expectation, and I am saying so explicitly rather than pretending to have arrived
independently.
**Falsifiers, and each is more interesting than the prediction holding:**
| symptom | what it would mean |
|---|---|
| the two processes' seed sequences **differ** | AI1's reading is wrong; the seed is per-process and the ordering hypothesis is unnecessary. Find what feeds `RNG_Seed`. |
| seeds identical but the AI client's is **non-zero** | the static generator is seeded somewhere AI1's six-site sweep did not reach. Still constant, still an ordering effect, but AI1's "every draw returns 0" needs correcting. |
| the **number or order** of `RNG_Seed` calls differs between processes | something upstream of the seeding is already process-dependent, which is a bigger finding than either hypothesis. |
| `RunAI` is not entered at all on a load | the AI clients are not (re)constructed on load, so the probe measures nothing and must move to the turn itself. This is the one that would waste the run, so `RunAI` is hooked as much for that as for its argument. |
**What this probe cannot do:** it cannot confirm the ordering hypothesis, only fail to refute it.
Confirming it needs the candidate list the varying empire builds — several equal-priority candidates
where the other two AIs have a unique best. That is a different hook and is not attempted here.
## P7 outcome — wrong, and that is the useful part
**Falsified on the first falsifier row.** Two launches, same save, load only:
| `RunAI` | net id | process 1 | process 2 |
|---|---|---|---|
| 1 | 32 | `0x75F692C0` | `0x414F415E` |
| 2 | 496 | `0xF2EDAC21` | `0x10B94E78` |
| 3 | 512 | `0x165A2ADB` | `0xC218DBF8` |
**Every AI client seed is fresh per process.** The record *structure* is identical — 8 records, same
order, same net ids, same personality, same `ai_data` — and one of the four `Seed` calls takes
`seed = 0` and produces a byte-identical state in both runs, which is the built-in control against
"the instrument randomised it".
So it is a **seed** effect, not an ordering effect, and lane AI1's "every draw from the static
generator returns 0" is falsified by measurement (it was flagged by its own author as arithmetic
rather than measurement, with a prediction attached — this is that prediction coming back negative).
The coordinator's supporting argument — *"a time-seeded per-client RNG would move all three"* — does
not follow: all three seeds **do** move, and a different stream only shows up in the save where the
decision it feeds actually depends on the draw. Two of the three empires evidently have a unique best
research candidate and the third does not.
Written up in `sots-re/findings/subsystems/ai-client-seed-is-per-process.md`, including the one thing
this probe did **not** establish — where the seed comes from — and the single hook that would.