sots-re/findings/control-flow/hive-creation-rng.md
alex 4f805e381a L1: CDiff crosses at frame 50, and the AI client seed is fresh in every process
Two results from the same VM session, both of which needed a workload the
corpus cannot supply.

CDiff, played forward. ref-turn2 loaded and 49 End Turns driven through the UI
helper to frame 51, with the entry hook emitting one record per turn:

  50 calls, frames 2..51, EXACTLY TWO STORES
    frame  2  -1 -> 0
    frame 50   0 -> 1     <- the modelled tier transition, at the modelled frame
  and 47 turns between them on which the writer ran and wrote NOTHING

predict_path was computed at entry from the transcribed threshold table on all
fifty turns and agreed with the cdiff region on every one. Frame >= 100 is still
a code read and is not claimed as a measurement.

The AI seed probe (asked for by the coordinator, ranked above CDiff). Hooks on
Mars::RNG::Seed and StrategyApp::RunAI, two launches from turn1-state, load
only. Every AI client seed differs between processes -- net 32, 496 and 512 all
move -- while the record structure is identical and one Seed call with seed=0
produces a byte-identical state in both runs.

So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering
effect predicted, and the 'one of three varies' observation is explained by two
of the three empires having a research pick that is robust to the stream. This
falsifies lane AI1's 'every draw from the static generator returns 0', which
that lane had explicitly flagged as arithmetic rather than measurement.

Where the seed comes from is NOT established; the finding names the one hook
that would settle it and the six values it must reproduce.

VM140 left as found: the 8-file save set with its oracle bytes intact.

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

24 KiB
Raw Permalink Blame History

Hive creation draws, inside BeginProcessTurn — lane Z's zero was the workload, not the code

  • Type: control-flow (live measurement)
  • Status: verified — five nested boundary hooks on the running game, reconciled against a return-address draw-site ledger that shares no code path with them
  • Confidence: high on the RNG result (two instruments agree word for word on two consecutive turns, and the values the draws produce match two corpus saves exactly); see §7 for what is not settled
  • Owner / date: lane L1 · 2026-09-08 · VM140
  • Instrument: sots-engine wip/l1, src/shim/hooks/tail_rng.{h,cpp} (five new hooks), build 0117495-dirty-20260908T2113Z, config shim.cfg.l1hive
  • Predictions, committed before the build: sots-engine/docs/L1-predictions.md
  • Corrects: findings/control-flow/tail-rng-ledger.md §0/§2 (qualified, not overturned) and findings/objects/svsctob-writers.md §4.3 (one address)

0. The answer

SVSOSwarmQueen::RegisterHives draws one strategic-generator word per new hive, inside StrategyServer::BeginProcessTurn, which runs between the pre-turn autosave and StrategyServer::ProcessTurn — outside both turn drivers and before either. Measured on the End Turn taken from turn1-state.sav, where the swarm's two hives are created:

turn BeginProcessTurn ProcessTurn tail turn total residual outside the drivers
1 → 2 (hives created) 2 20 0 22 2
2 → 3 (hives exist) 0 19 0 19 0

Lane SV predicted this from the instruction stream and could not run it. It is right.

Read the two rows' evidence differently, because they are not equally strong. Row 2 is a closed bracket: Autosave(endTurn=1) at word 192, Autosave(endTurn=0) at 211, subtotals 0 + 19 + 0, residual 0 by subtraction. Row 1's bracket is incomplete by construction — it is the first End Turn after a load, so the pre-turn autosave hook has no server pointer yet and records words: null, the same limitation tail-rng-ledger.md reports for its turn 3. Its total of 22 comes from the independent per-call-site ledger (§2.1), which sums to 22 with nothing unattributed and carries the hive draws as their own row. So row 1's residual is attributed, not subtracted — and row 2, which is subtracted, is the one that shows the residual returning to 0.

What this does to tail-rng-ledger.md. Its headline — "a strategic turn advances the strategic generator by 18–22 words, all of it inside StrategyServer::ProcessTurn, and the residual outside the two turn drivers is exactly zero" — is correct for every turn it measured and false as a statement about the code. All eight of its turns, and the twelve more it added later, were turns on which the hives already existed. On the one turn in this campaign's corpus where they do not, the residual is 2 and it is outside ProcessTurn.

That is the same shape as "the tail draws nothing", which was true of eight turns and false on turn 64. Rule 20's distinction, one level up: a ledger that closes to zero is a statement about the states that occurred.

What it means for the standalone. A reimplementation that models ProcessTurn and the tail perfectly writes a correct autosave on every turn of the corpus and is two generator words out of step on the turn the hives appear, after which every later turn diverges. The interval it must reproduce starts at BeginProcessTurn, not at ProcessTurn.


1. The instrument, and why it could see this when lane Z's could not

Lane Z's six hooks bracket Autosave(endTurn=1) → ProcessTurn → combat → tail → Autosave(endTurn=0). BeginProcessTurn runs inside that bracket and outside every subtotal, so a draw there was always visible as residual — and lane Z measured the residual as 0, correctly, because on its turns there was nothing to see. Nothing had ever been hooked in that interval.

This lane adds five nested hooks, each declaring the live Mars::RNG at S+0x16c as a region whose describe reports an absolute word position (lane Z's RngLedger):

Autosave(endTurn=1) ....................... 192          <- lane Z
  StrategyServer::BeginProcessTurn ........ NEW          <- the interval nobody had hooked
    SVSOSwarmQueen::OnTurnBegin ........... NEW            (evt 0x13, vtable slot +0x60)
      SVSOSwarmQueen::RegisterHives ....... NEW            THE DRAW
      SVSOSwarmQueen::TickHives ........... NEW            the NextQ slip; 0 words
  StrategyServer::ProcessTurn ............. 19           <- lane Z
  StrategyServer::OnAllCombatDone_Tail .... 0            <- lane Z
    SVSOSlaversRefuel::UpdateDifficultyTier  NEW           (evt 0x14, tail phase 20)
Autosave(endTurn=0) ....................... 211

Each new hook carries a model evaluated at entry — the hive count and the two config constants, the per-hive NextQ values and the three gate constants, the whole CDiff threshold-table scan — so the record can disagree with the model instead of merely agreeing with itself.

2. The hive-creation turn, measured

Workload: turn1-state.sav (Frame 1, SvSctOb / EncID 10 / Hives count 0, two systems with EggScio == 3), pushed to VM140 as l1-turn1.sav, loaded, End Turn.

[d2] SVSOSwarmQueen::RegisterHives    words 170 -> 172   DELTA 2
        scenario_tag = 3   enc_id = 10   hives_in = -1 (empty)   systems = 28
        systems_tagged = 2   predict_new_hives = 2   predict_min_words = 2
        hive_nextq_lo = {ptr 0x0158e0a0, value 20}
        hive_nextq_hi = {ptr 0x0158e0a4, value 30}
[d2] SVSOSwarmQueen::TickHives        words 172 -> 172   DELTA 0
        hives_in = 2
        hives = [{sys 0x0e46e788, egg_scio 3, next_q 30, queen 0},
                 {sys 0x0e46dbe8, egg_scio 3, next_q 28, queen 0}]
        gate_frame_floor = 10   gate_queen_age = 5   spawn_list_cap = 3
        predict_gate1_fails = true   predict_words = 0
[d1] SVSOSwarmQueen::OnTurnBegin      words 170 -> 172   DELTA 2
[d0] StrategyServer::BeginProcessTurn words 170 -> 172   DELTA 2   side[frame] 1 -> 2
[d0] StrategyServer::ProcessTurn      words 172 -> 192   DELTA 20
[d0] OnAllCombatDone_Tail             words 192 -> 192   DELTA 0

predict_new_hives is computed at entry by transcribing the original's own two predicates over the live Systems vector — sys->EggScio(+0x184) == queen->scenarioTag(+0x4) and "no existing hive references this system". It said 2; the generator moved 2. The prediction and the measurement were produced by different code paths and neither was fitted to the other.

2.1 A third instrument agrees, and it is keyed on the return address

The per-call-site ledger (lane Z's draw_sites, seven detoured generator entry points recording __builtin_return_address(0)) reported, on the same turn:

return VA entry point calls words site
0x00527714 RNG_NextInt 2 2 RegisterHives — a site no previous run has ever recorded
0x0058788d NextFloat 1 1 TechTree::ProcessResearch+0x1c8
0x0088df54 NextFloat 1 1 ServerPlayer::RollResearchEvent+0x2f
0x0089342b Chance 8 8 GenerateTradeRaidEncounters+0x196
0x00893518 Chance 8 8 GenerateTradeRaidEncounters+0x283
0x005032a2 NextFloat 1 1 DetectEncounters subtree (lane H)
0x007929a9 NextInt 1 1 DetectEncounters subtree (lane H)
0x008e6e09 NextFloat 16 16 inside Chance's own body — the helper-internal double count

38 raw, minus the 16 helper-internal, = 22 = 2 (BeginProcessTurn) + 20 (ProcessTurn), with nothing left over. On the next turn the same table reads 35 − 16 = 19 and the 0x00527714 row is absent entirely.

0x00527714 is the return address of the call 0x004271c0 at 0x0052770f. Lane SV's document puts that call at 0x0052770c; 0x0052770c is the mov [ebp-0x14],esi that stores the bound. Corrected in ghidra/addresses.d/lane-l1.json.

2.2 The draws produced the historical values, which is the strongest check available

NextQ = frame + LO + NextInt(HI − LO). Live: LO = 20, HI = 30, frame = 2.

hive on system A hive on system B
after RegisterHives (measured) next_q = 30 ⇒ draw 8 next_q = 28 ⇒ draw 6
after TickHives' slip, same turn 31 29
turn2-state.sav (historical) 31 29
after the next turn's slip (measured) 32 30
turn3-state.sav (historical) 32 30

Two draws, two systems, four numbers, all matching saves captured in a different process on a different day. The generator trajectory is identical to the historical game's, which is a much stronger statement about instrument neutrality than a word count could be — and it is corroborated by the absolute positions: this run reaches word 192 after turn 2 and 211 after turn 3, and tail-rng-ledger.md §2.1 records 211 as the ProcessTurn entry position of turn 4 on the same game.

3. LO and HI — the leaf lane SV declared blocked

"blocked on one MT draw and two data-file constants … behind pointers at 0x00ae0204/0x00ae0208 that no .text or .data reference initialises in a form this lane could follow. Fitting LO and HI from a single two-hive observation would have been fitting, not derivation."

Refusing to fit was right, and reading them live costs one LogF-shaped line:

pointer value meaning
*(int*)0x00ae0204 20 LO — the low end of the queen-countdown window
*(int*)0x00ae0208 30 HI — the high end; RNG_NextInt's bound is HI − LO = 10, inclusive
*(int*)0x00ae0210 10 TickHives gate 1: no queen may spawn while Frame <= 10
*(int*)0x00ae0228 5 TickHives gate 3: a minimum age in turns
*(int*)0x00ae0220 3 a cap applied inside the (never-executed) spawn arm

So a new hive's countdown is frame + 20 + U{0..10}, and it then slips +1 per turn for as long as any gate fails. The leaf is closed: a standalone needs these five integers and the two predicates, not the data files.

4. The NextQ slip, live

Lane SV recovered the slip statically — a single inc [hive+8] at 0x0052785a that four gate-failure edges converge on — and reproduced 31/29 → 32/30 with no draw and no fitting. Confirmed:

  • TickHives was entered on both turns and cost 0 words both times.
  • On turn 1→2 it received next_q 30 / 28 and the save carries 31 / 29.
  • On turn 2→3 it received 31 / 29 and the save carries 32 / 30.
  • predict_gate1_fails was true on both turns, and now has a number behind it: Frame <= 10, so on this save no queen can spawn before turn 11 whatever the countdown says. Every hive slips for at least the first ten turns of its life.

The spawn arm still has never executed (rule 6). No hive in any corpus save has a queen, and queens_in was empty on both turns. What the gate constants now let anyone state is why, and how far away it is, rather than "we never saw it".

5. CDiff — both edges, and one of them settled without a workload

SVSOSlaversRefuel::UpdateDifficultyTier 0x00515820 was entered once per End Turn, at depth 1 inside the tail (event 0x14, through the class's seven-instruction generic handler 0x0051a800), and cost 0 words both times.

turn frame cdiff_in predict_path region cdiff before → after
1 → 2 2 −1 store −1 → 0
2 → 3 3 0 no_store_unchanged 0 → 0

The second row is the point. "Entered and stored nothing" is now distinguishable from "did not run" — which is exactly what a save-file comparison cannot do, and why every corpus pair after frame 2 shows CDiff motionless without saying whether the writer ran.

The table itself was re-read from the instruction stream, independently of lane SV, at real instruction boundaries:

515868  [ebp-0x34] = 1     [ebp-0x30] = 1   [ebp-0x2c] = 1
515877  [ebp-0x28] = 0x32  [ebp-0x24] = 2   [ebp-0x20] = 3
515885  [ebp-0x1c] = 0x64  [ebp-0x18] = 2   [ebp-0x14] = 5
515893  xor eax,eax ; lea ecx,[ebp-0x34]
515898  cmp [ecx],edx ; jg 0x5158c2          ; first threshold GREATER than the frame
51589c  add eax,ebx ; add ecx,0xc ; cmp eax,3 ; jl 0x515898
        <falls through to 0x5158a6, which IS THE EPILOGUE -- no store>
5158c2  test eax,eax ; jle 0x5158a6          ; index 0 -> no store
5158cc  cmp [edi+0x38],eax ; je 0x5158a6     ; unchanged -> no store
5158d1  mov [edi+0x38],eax                   ; CDiff = index - 1

Only the threshold column is ever loaded; the (1,1) (2,3) (2,5) payload columns are dead in this function. So:

frame scan index stored
≤ 0 0 nothing
1 … 49 1 CDiff = 0
50 … 99 2 CDiff = 1
≥ 100 walks off the end nothing

CDiff can only ever hold 0 or 1. Tier 2 is unreachable at any frame, and lane SV's reading of that off-by-one is confirmed by a second independent disassembly. The class ctor 0x0051a820 stores [eax+0x38] = -1, which is precisely the CDiff = −1 that turn1-state.sav carries and the reason frame ≤ 0 never matters: Frame starts at 1 and the tail does not run before the first End Turn.

5.1 The frame-50 crossing, played forward and measured

The corpus cannot reach frame 50 — its deepest save is turn 23 — so the game was played forward. ref-turn2.sav loaded, then 49 End Turns driven through the UI helper, with the entry hook emitting one record per turn:

50 UpdateDifficultyTier calls, frames 2 .. 51
  frame= 2  cdiff_in=-1  store               -1 -> 0   <== STORE
  frame= 49 cdiff_in= 0  no_store_unchanged   0 -> 0
  frame= 50 cdiff_in= 0  store                0 -> 1   <== STORE
  frame= 51 cdiff_in= 1  no_store_unchanged   1 -> 1

Fifty consecutive turns, fifty entries, exactly two stores, at exactly the two predicted frames. Between them sit 47 turns on which the function ran and wrote nothing — which is the part a save-file comparison can never show, and the reason the hook prints predict_path rather than only a value.

predict_path was computed at entry from the transcribed table on every one of the fifty turns and agreed with the cdiff region on every one, including both transitions. The 47 quiet turns check nothing by themselves and are reported that way; the two that moved are the test.

Frame ≥ 100 was not reached and is not claimed as a measurement. It is ~50 more End Turns at ~30 s each, and the answer is already a twice-read property of a three-entry table scan rather than a question about state: at frame 100 the loop exits with index 3 and falls into the epilogue. The hook is in the tree and prints predict_path = no_store_ran_off_end_frame_ge_100 when it happens, so whoever plays a game past frame 100 gets that edge for free.

6. Instrument neutrality (rule 19) — checked, and it needed checking twice

Five new MinHook detours were installed for this lane, on top of lane Z's six template hooks, seven draw-site detours and twelve entry probes. Lane H's finding is that one detour on a clean prologue boundary changed the game's output by four bytes, so this was a real prediction, not a formality.

The oracle reproduces, exactly. Same build, same shim.cfg.l1hive, load ref-turn2.sav, one End Turn:

file size sha256 prefix historical
(Autosave EndTurn).sav 66,732 bb4fd9ac89f41e3b identical
(Autosave).sav 67,219 978041acd168b56e identical

That run's trace is the second half of the same story: RegisterHives entered with the two hives already present and NextQ 31 / 29, drew 0, BeginProcessTurn cost 0, ProcessTurn cost 19, position 192 → 211. So the hooks that measured 2 words on the creation turn measure 0 on a turn that produces a byte-identical historical autosave.

6.1 The creation turn is NOT a byte oracle — and the reason is a live nondeterminism, not the hooks

Loading turn1-state.sav and ending one turn does not reproduce turn2-state.sav (ab4ac2d7…); it produces a 66,739-byte save with a different hash. Reported here rather than buried, because it is the kind of thing that looks like instrument perturbation and is not:

  • The generator trajectory is identical to the historical game's — same word positions (192 after turn 2, 211 after turn 3), and the two hive draws produce NextQ 31 / 29 and then 32 / 30, exactly the values turn2-state.sav and turn3-state.sav carry.

  • The pre-turn autosave round-trips byte-identically (a3f9dc4b… in, a3f9dc4b… out).

  • The whole difference, at schema level, is one field on one player:

    player historical turn2-state.sav this run
    3 ResTNm = BIO_GnMod ResTNm = XNC_TrnsLir2

    Players 0, 1 and 2 agree exactly ("", IND_Waldo, DRV_PlsFiss), as do Status, EvNxID, NumOwn and every other field checked. The two TechTree/St bytes that also move are that choice's consequence, and Summary/Checksum follows.

The hooks=off control was then run from the same input, and it produced a third answer.

Lane L5 found the same thing independently on VM146 the same day, from the other direction — as a by-product of its own rule-19 control — and owns the write-up: findings/subsystems/turn1-to-turn2-nondeterminism.md. This lane's two runs are corroboration and they widen its spread, so the union is recorded here and the analysis is not repeated.

run instrument (Autosave).sav that player's ResTNm
the original 2026-09-07 session (live game, never loaded) phase-2 shim, hooks=trace ab4ac2d7e2977260 BIO_GnMod
lane L5 run B ComputeBudget=compare ab4ac2d7e2977260 BIO_GnMod
lane L5 run A same hooks, different constant in ours only 7fc9e6ab46b47794 XNC_TrnsHvr2
lane L5 control hooks=off 62061918176e3441 XNC_TrnsLir2
L1 full L1 hook set 62061918176e3441 XNC_TrnsLir2
L1 control hooks=off e43ec1d2b443c101 XNC_TrnsMorr2

The two rows in bold are what this lane adds, and the second one sharpens lane L5's conclusion. L5 argued from "the hooks=off control is a third value, not one of the other two". This lane ran hooks=off again and got a fourth value — so hooks=off is not self-consistent with itself across processes, which is a stronger statement than "no hook is responsible" and rules out the whole class of explanations in which some particular instrument configuration selects the pick. Four distinct outcomes over six runs, and this lane's instrumented run happens to coincide with L5's uninstrumented one, which is the clearest possible demonstration that the hooks are not the variable.

Players 0, 1 and 2 pick identically in every run ("", IND_Waldo, DRV_PlsFiss). Only the shadow-empire player moves. In this lane's instrumented run the strategic generator's trajectory was identical to the historical game's — same word positions, and the hive draws produced the historical NextQ values — so whatever chooses here does not draw from it.

6.2 It is bounded, and the strategic generator is not what varies

No claim in §0–§5 rests on an autosave diff. The hive result is a generator word position measured by two instruments that share no code path — a region ledger reading left around a phase boundary, and a set of detours keyed on return address — plus the entry-side prediction those numbers were checked against. Diffing turn1-state → turn2 bytes is not part of it, and would not have been able to answer the question either way.

The variation is small and exactly located. Every pairwise leaf comparison over the three post-turn saves this lane holds gives 4 differing leaves out of 35,032, with the leaf count and the whole tree structure identical:

/Summary/Checksum            (derived)
/Sim/Player/TechTree/St      2 <-> 3     (the node it stopped researching)
/Sim/Player/TechTree/St      3 <-> 2     (the node it started researching)
/Sim/Player/ResTNm           BIO_GnMod | XNC_TrnsLir2 | XNC_TrnsMorr2 | XNC_TrnsHvr2

so the reference pair remains usable for everything except one shadow empire's research selection. This matches lane L5's characterisation exactly, on a fourth sample.

And it is not the strategic generator. This is the part this lane can add that a save diff cannot: in the instrumented run the strategic generator's trajectory was identical to the historical game's — the two hive draws produced the historical NextQ 31 / 29, the next turn's slip produced 32 / 30, and the absolute word positions 192 and 211 are the ones tail-rng-ledger.md §2.1 records for the same game. A pick drawn from S+0x16c would have moved that trajectory. It did not.

There is an obvious next place to look, and this lane's own draw-site table points at it: 8 words per turn are drawn from a different Mars::RNG in the same process — the StrategyClient's at client+0x134, from 0x00578cf0, 0x005798e0 and 0x0069dbb0 (tail-rng-ledger.md §11.2 first identified these and correctly excluded them). That object is not serialised, so if the AI's pick draws from it, its seed is not in the save and a per-process variation is exactly what one would expect — which is lane AI1's "seeded from an unseeded global" reading, arriving from a different direction. Two rows of that table appear on every turn of both saves measured here: draw_site_words_other_rng = 8, draw_site_calls_other_rng = 8.

The experiment that would settle it is a boundary hook on the AI research-selection site with a region over client+0x134 rather than S+0x16c, on two fresh processes: if the two runs enter it with different generator state, the cause is found. That is one hook and one afternoon, and it is the difference between "the reference pair has four unstable leaves" and "we know why".

7. What this does NOT settle (rule 15)

  • One hive-creation event, on one save. turn1-state.sav is the only save in the corpus whose swarm queen has zero hives and whose next turn creates some; the six Zuul saves sit at zero hives for eighteen turns and never transition. So "one word per new hive" is confirmed for two hives created in one call and extrapolated beyond that.
  • RNG_NextInt's rejection loop never fired. Bound 10 ⇒ mask 15, so a draw is rejected with probability 5/16 and the expected cost is 16/11 ≈ 1.45 words per hive, not 1. Two hives costing exactly 2 is a ~47% outcome. A reimplementation must model the rejection loop, not the constant. This run cannot tell the two apart and does not claim to.
  • The spawn arm, and everything past it. No queen has ever spawned under any instrument. Its callees 0x0050dfc0 and 0x004fe810 are unread and may draw.
  • CDiff at frame 50 and frame 100 — modelled and predicted, not observed (§5).
  • BeginProcessTurn's other work is unmodelled. Its two container walks (three calls per system, one per fleet) and its event post are not declared by anything. On these two turns their cost is zero by subtraction — BeginProcessTurn and OnTurnBegin agree to the word on both turns — but that is a measurement on 28 systems and one player's fleets, not a proof.
  • No game-state region is declared by any of these hooks. They measure the generator and nothing else. A clean run here says the RNG accounting is right and says nothing about whether the turn was computed correctly.
  • Event 0x15 and the alliance events remain unreachable from any save we hold — unchanged from lane SV.

8. Files

  • Traces: verify/results/shim/l1/trace-et1.jsonl (End Turn 1, hive creation), trace-et2.jsonl (both End Turns; the second is the control turn)
  • Report tool used to read them: verify/results/shim/l1/rep.py
  • Saves produced: verify/results/shim/l1/
  • Addresses: ghidra/addresses.d/lane-l1.json (10 entries)
  • Instrument + predictions: sots-engine wip/l1, docs/L1-predictions.md