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
19 KiB
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::BeginProcessTurn0x007d98e0 .. 0x007d9ae8 (ret, thiscall, no stack args).inc [esi+0xc]at 0x007d990a isFrame++. The last thing the function does before its epilogue is the script two-step at 0x007d9ab8:vft[0x10](0x13, 0)thenvft[0x60]().+0x60is exactly what lane SV's jump table gives for event 0x13. Everything else in the body is two container walks (S+0x44systems,S+0x64fleets) and an event post (push 0x24).SVSOSwarmQueen_OnTurnBegin0x00529930 .. 0x0052999f (ret):RegisterHives→ prune-by-EggScio→TickHives→0x00505100.SVSOSwarmQueen_RegisterHives0x00527630 .. 0x00527761 (ret). Per system inGetGame()->+0x44..+0x48,[sys+0x184] == this->+0x4and no existing hive whose+0x4is that system; then
Correction to5276e1 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_backsvsctob-writers.md§4.3: the draw call is at 0x0052770f (return address 0x00527714), not 0x0052770c — 0x0052770c is themov [ebp-0x14],esithat stores the bound. The substance is unchanged andGetGame()(0x00578050) returns theSframe:[+0xc]isFrameand[+0x16c]is the strategic generator, the same object lane Z's ledger watches.SVSOSwarmQueen_TickHives0x00527770 .. 0x005279a1 (ret). Four gate-failure edges (0x005277e3,0x005277fe,0x00527848,0x0052784f) all land oninc [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_UpdateDifficultyTier0x00515820. 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
and the class's ctor at 0x0051a820 stores515893 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[eax+0x38] = 0xffffffff, which is exactly theCDiff = -1thatturn1-state.savcarries. Lane SV's off-by-one reading is confirmed independently:CDiffcan only ever hold 0 or 1.SVSOSlaversRefuel_HandleEvent0x0051a800 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:
SVSOSwarmQueen_RegisterHivesis entered exactly once, insideBeginProcessTurn, beforeStrategyServer::ProcessTurnruns.- It reports
hives_in = 0andhives_out = 2. - Its measured word cost is 2 — one
RNG_NextIntper new hive. (NextIntis a reject-on-(y & mask) > nloop, so 2 is the minimum; see the falsifier.) SVSOSwarmQueen_OnTurnBegin's cost is the same 2 (TickHivesadds nothing).StrategyServer::BeginProcessTurn's cost is the same 2.- The bracket residual is 2, not 0:
Autosave(endTurn=1) → Autosave(endTurn=0)total minusProcessTurnminus the tail equals 2. - The two hives written carry
NextQ31 and 29, matchingturn2-state.savexactly.
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):
RegisterHivesis entered, finds both systems already referenced, and costs 0 words.TickHivesis entered, takes a gate-failure edge on both hives, costs 0 words, andNextQreads 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).
UpdateDifficultyTieris entered once per End Turn onturn1-state.sav, stores0on the first (frame 2,CDiff−1 → 0) and stores nothing on the second (frame 3, unchanged). Thethis+0x38region 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;
CDiffstays 1 forever. Tier 2 is unreachable. - P4d. The
frame ≤ 0edge is unreachable in a real game:Framestarts at 1 and the tail never runs before the first End Turn, which is exactly whyturn1-state.savcarries 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.