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

19 KiB
Raw Blame History

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.