20 KiB
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-enginewip/l1,src/shim/hooks/tail_rng.{h,cpp}(five new hooks), build0117495-dirty-20260908T2113Z, configshim.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) andfindings/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
.textor.datareference initialises in a form this lane could follow. FittingLOandHIfrom 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:
TickHiveswas entered on both turns and cost 0 words both times.- On turn 1→2 it received
next_q30 / 28 and the save carries 31 / 29. - On turn 2→3 it received 31 / 29 and the save carries 32 / 30.
predict_gate1_failswas 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.
What is not yet measured is the 50 and 100 crossings live. §7 says what that costs and why it was
not bought with the VM time available; the entry hook is in the tree and prints predict_path on
every turn, so whoever plays a game past frame 50 gets both edges 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
NextQ31 / 29 and then 32 / 30, exactly the valuesturn2-state.savandturn3-state.savcarry. -
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.savthis run 3 ResTNm = BIO_GnModResTNm = XNC_TrnsLir2Players 0, 1 and 2 agree exactly (
"",IND_Waldo,DRV_PlsFiss), as doStatus,EvNxID,NumOwnand every other field checked. The twoTechTree/Stbytes that also move are that choice's consequence, andSummary/Checksumfollows.
The hooks=off control was then run from the same input, and it is the reason this section is worth
reading. It produced a third answer.
| run | instrument | (Autosave).sav |
size | player 3's ResTNm |
|---|---|---|---|---|
| the original 2026-09-07 session (live game, never loaded) | phase-2 shim, hooks=trace |
ab4ac2d7e2977260 |
66,739 | BIO_GnMod |
| this lane | full L1 hook set | 62061918176e3441 |
66,739 | XNC_TrnsLir2 |
| this lane, control | hooks=off — nothing installed at all |
e43ec1d2b443c101 |
66,740 | XNC_TrnsMorr2 |
Players 0, 1 and 2 pick identically in all three ("", IND_Waldo, DRV_PlsFiss). Only player 3
moves, and it moves with the instrument removed as well.
So the turn-1 → turn-2 transition of this game is not deterministic across processes, and no hook
is responsible. The three picks are three different tech ids, two of them species-tagged variants
of the same node (XNC_TrnsLir2 / XNC_TrnsMorr2), which is the signature of a selection over a
container whose order is not fixed — a pointer-keyed map or an unstable tie-break — rather than of a
generator draw. The strategic generator's trajectory was identical to the historical game's in
the instrumented run, so whatever chooses here does not draw from it.
Two consequences, both of which matter more than this lane:
turn1-state.savmust not be used as a determinism-oracle input.determinism-oracle.mdestablishes byte-identity forref-turn2 → turn 3and was never run on turn 1; that claim is intact and this is a different pair.- The standalone milestone has a target that the original itself does not always hit. A reimplementation cannot be held to a byte match on a turn where the original disagrees with itself. Which turns those are is now an open question with one known member.
None of this touches §0–§5: the RNG numbers were taken on a run whose generator trajectory matches the historical game word for word, and the same hook set reproduces the oracle exactly on the adjacent turn.
7. What this does NOT settle (rule 15)
- One hive-creation event, on one save.
turn1-state.savis 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.
CDiffat 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 —BeginProcessTurnandOnTurnBeginagree 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-enginewip/l1,docs/L1-predictions.md