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
288 lines
19 KiB
Markdown
288 lines
19 KiB
Markdown
# 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.
|