Lane P wrote the expected numbers into sots-engine docs/P-events-wiring.md §4 before this run existed. Every one held. First End Turn (ref-turn2 -> Launch -> End Turn, shim.cfg.recapb3 unchanged, build eventlive-dd38117-20260908T0916Z, main dd38117 with no source change): 3 calls, 3 compared, 0 divergent, tracecmp exit 0 — where lane R's run exited 1 on side.events.after.v.next_id orig=4 ours=3. Call 0 read turn=3, events_turn_bucket_exists=true, events_next_id_in=3, events_in_turn_bucket=1, events_dedup_risk=0, no events_scan_truncated, next_id 3->4 on both sides, turns/turns_bytes 2/48 unchanged, node[144].progress 2879->5768, flag 1->2, rng identical — the whole predicted list. Five End Turns: exit 1 with 2 divergent calls instead of 3, each short by exactly 1 (next_id orig 7 ours 6, orig 12 ours 11) = the deliberately unmodelled EVENT_TECHS_UNLOCKED. A 0-divergence result there would have been suspicious. sizeof(Game::ObservedTech) = 44 measured live: observed_techs.bytes grew by exactly 44 on both completion calls (440->484, 484->528), confirming lane X's static pin behaviourally. Non-researching players measured 880 = 20 x 44 and never moved. One deviation from lane R, and it is a workload effect, not a defect: rng diverged on call 9 because the completed tech had research_roll_pending set and the OnTechResearched callback drew one word ours does not (left 374 vs 375). Lane R's "RNG matched 15 of 15" was workload luck; the honest statement is that the rng region matches on every call that does not complete a roll-triggering tech. Zuul double roll CLOSED. No species-5 save existed, so this lane made one: a custom game with only Zuul in the Available Species pool, 4 End Turns under the same compare config. 8 calls, 8 compared, 0 divergent, exit 0; all four researching calls have species=5 and advance the generator by two, not one (left 540->538, 522->520, 504->502, 485->483), and ours reproduced every post-state bit-for-bit. Save added as verify/results/saves/zuul-turn5-species5.sav. End-Turn oracle byte-identical to lane R's on the first turn, so none of this perturbs the game. clean_room_check OK and host ctest 33/33, run as separate commands. VM140 restored to the recap build at the main menu and released.
11 KiB
Live verification of the event-posting model, and two workloads (lane V, 2026-09-08)
Lane P wrote a numeric prediction of this run into sots-engine docs/P-events-wiring.md §4
before the run existed. This is the check. The prediction held field for field on both
runs — the strongest evidence this project produces, because it was written down first.
- Build
eventlive-dd38117-20260908T0916Z(sots-engine maindd38117, no source change), cross-built on CT111/srv/re-lab/build/sots-engine-v, staged/srv/re-lab/shim/dist-v→C:\SOTS\shimdist-von VM140. Exports byte-identical to the realbinkw32.dll(66 names). - Recipe:
shim.cfg.recapb3unchanged,ref-turn2.sav→ Launch → End Turn, then four more End Turns in the same session (turn 2 → turn 7). Onerui.ps1call per click, screenshot between every one. - Oracle held:
(Autosave EndTurn).sav=bb4fd9ac89f41e3b…,(Autosave).sav=978041acd168b56e…— identical to lane R's, so the events wiring does not perturb the game. - Artefacts:
verify/traces/eventlive-b3-*.jsonl[.gz],verify/results/compare/eventlive-b3-*.{json,md}, shim logs and screenshots inverify/results/shim/eventlive/.
1. First End Turn — 3 calls, 3 compared, 0 divergent, exit 0
Lane R's run exited 1 here with one divergent field
(side.events.after.v.next_id orig=4 ours=3). That field is now clean, and the fields lane P
named as "the model is right for the right reason" all read as forecast:
| field, call 0 | predicted | observed |
|---|---|---|
turn |
3 | 3 |
events_turn_bucket_exists |
true | true |
events_next_id_in |
3 | 3 |
events_in_turn_bucket |
1 | 1 |
events_dedup_risk |
0 | 0 |
events_scan_truncated |
absent | absent |
events.next_id |
3 → 4 both sides | 3 → 4, no divergence |
events.turns / turns_bytes |
2 / 0x30 unchanged | 2 / 48 unchanged |
node[144].progress |
2879 → 5768 | 2879 → 5768 |
node[144].flag |
1 → 2 | 1 → 2 |
rng |
identical | left 432→431, next_index 192→193, mt hash identical |
observed_techs.bytes |
unchanged, all 3 calls | 440 / 880 / 880, unchanged |
Alloc {tech 144, 2889}, species 2, fpu_cw = 0x127f. Calls 1 and 2 are the other players with
empty event lists (EvNxID = 0) and their scratch headers came back byte-identical.
2. Five End Turns — exit 1, 2 divergent calls, each short by exactly 1
| call | alloc | draws | next_id orig → ours |
predicted |
|---|---|---|---|---|
| 0 | {144, 2889} | 1 | 4 → 4 | 4 / 4 ✓ |
| 3 | {144, 2898} | 0 | 7 → 6 | 7 / 6 ✓ |
| 6 | {142, 3064} | 1 | 8 → 8 | 8 / 8 ✓ |
| 9 | {142, 3074} | 0 (+1 in the callback, §4) | 12 → 11 | 12 / 11 ✓ |
| 12 | {9, 3086} | 1 | 14 → 14 | 14 / 14 ✓ |
| 1,2,4,5,7,8,10,11,13,14 | zero-spend | 0 | 0 → 0 | 0 / 0 ✓ |
Lane R's run had 3 divergent calls and the two completions were short by two ids each; both
are now short by one, and that one is EVENT_TECHS_UNLOCKED, which lane P deliberately did
not model because evaluating its trigger needs the child-unlock cascade ours does not run.
A 0-divergence result here would have been suspicious, not good.
Note the run is not lane R's run: from turn 4 the AI picks a different research target (lane R's trap #2), and calls 6, 9 and 12 have different techs from lane R's capture. The event id totals landed on the predicted values anyway, which is a stronger result than a replay would have been.
Guard conversion (P-events-wiring.md §4.3) — held
observed_techs is a Result region now, so player+0x274/0x278/0x27c no longer appear as
undeclared guard spans; the vector shows up in the diff as observed_techs.bytes instead.
Undeclared spans: 13 in 2 calls (lane R) → 10 in 2 calls.
Guard spans this run, resolved against the recovered ServerPlayer table:
| offset | call 3 | call 9 | field |
|---|---|---|---|
player+0x10c/0x110/0x114 |
3 each | ConMod[0..2] |
|
player+0x124 |
3 | OutMod |
|
player+0x196 |
1 | byte 2 of design-option mask B at +0x194 (TechMaskA + 4) — lane R saw it from inside OnTechResearched as b 64 → 2097216 = 0x40 → 0x200040 |
|
player+0x294 |
4 | 4 | ResTNm, the research-target pointer cleared |
player+0x3b4 |
1 | ResErrRoll = research_roll_pending, cleared by the callback |
|
tree_header+0x20 |
1 | 1 | the tree's completion-order counter |
So the two spans that are "new" relative to lane R are both already-named fields, seen from the
ProcessResearch caller instead of from inside OnTechResearched. Nothing here is unnamed.
3. sizeof(Game::ObservedTech) = 44 — measured on the running game
| completion call | observed_techs.bytes (orig side) |
delta |
|---|---|---|
| 3 | 440 → 484 | +44 |
| 9 | 484 → 528 | +44 |
An independent behavioural confirmation of a purely static pin: lane X pinned 0x2c three
ways off the instruction stream (magic divide by 44, imul reg,reg,0x2c, add edi,0x2c) and
lane S mapped every field from the serializer. The running game agrees to the byte. Both
non-researching players' vectors measured 880 = 20 × 44 on all 15 calls and never moved.
Recorded on ObservedTech_sizeof and ServerPlayer_off_ObservedTechs in ghidra/addresses.json.
Caveat for the lane that wires the append: RecordObservedTech (0x007ba1a0) de-duplicates by
tech name, so a re-observation appends nothing. "No delta" is not a contradiction until you have
checked whether the tech was already observed.
4. One deviation from lane R, and it is a workload effect
rng diverged on call 9: original left 374 / next_index 250, ours 375 / 249 — the
original drew one word ours did not.
That is the RollResearchEvent draw inside OnTechResearched, at exactly the two generator
positions lane R measured from inside that hook (left 375 → 374, next_index 249 → 250) when
IND_TRKSTL completed with research_roll_pending set. B3 declared SetResearched and its
owner callback out of scope, and P-events-wiring.md §3 says the callback "consumes one extra
RNG word", so this is a declared boundary showing itself.
What it corrects: lane R's "RNG matched 15 of 15" was workload luck, not a property.
Lane R's five-turn session happened not to complete a roll-triggering tech on the B3 hook; mine
did. The honest statement is: ProcessResearch's rng region reproduces bit-for-bit on every
call that does not complete a roll-triggering tech, and is short by exactly one draw on every
call that does — until whichever lane takes SetResearched models the callback.
5. Workload unblocked: Zuul double roll, closed
The board row asked for one live compare from a species-5 save where left drops by 2, not 1.
No save in verify/ had a Zuul player, so the double roll was disassembly + host tests only.
Made on the VM: a custom game with only Zuul left enabled in the Available Species pool
(so both players are Zuul), same 28-star / 2-player / 7 ly settings, then four End Turns under
the same shim.cfg.recapb3 compare config.
8 calls, 8 compared, 0 divergent, tracecmp exit 0. The four researching calls all have
species = 5 and all advance the generator by two:
| call | turn | alloc | left |
next_index |
mt hash |
|---|---|---|---|---|---|
| 0 | 2 | {144, 982} | 540 → 538 | 84 → 86 | unchanged |
| 2 | 3 | {144, 960} | 522 → 520 | 102 → 104 | unchanged |
| 4 | 4 | {144, 1417} | 504 → 502 | 120 → 122 | unchanged |
| 6 | 5 | {144, 1372} | 485 → 483 | 139 → 141 | unchanged |
ours reproduced every one bit-for-bit (the rng region is compared, and no call diverged).
Calls 1, 3, 5, 7 are the second Zuul player with a zero allocation — no draw, and clean.
Save: verify/results/saves/zuul-turn5-species5.sav
(sha256 48559ab5b719b332e47b4bf43af85a616bff1508125d62edb211b80637234af6, 59,131 B, turn 5,
2 Zuul players, homeworld Gallandro, Rip Bore DE in Alpha Fleet, Slaves: 0 on the planet
panel). Left on the VM as C:\SOTS\SavedGames\zuul-turn5.sav.
No tech completed in five turns (node 144 reached ~4,700 of 5,000), so this save does not
yet exercise a Zuul completion, SetResearched, or the Zuul boarding-pod grant. Load it and run
one more End Turn to get there.
6. Workloads NOT achieved
- Expense sliders / a debtor / a research-aid treaty. Not attempted. The eight always-zero
ComputeBudgetslots stay unexercised. Getting there needs deliberate overspending across several turns and a second empire willing to sign an aid treaty — a multi-turn play session, not a click path. - A save that sets
research_roll_pendingat load time. Not made. The flag was observed set and cleared inside a turn (§4), which is what the RollResearchEvent row already covers, but there is still no save that starts with it true. - A tech completion in the Zuul game. One more End Turn from
zuul-turn5.savshould do it.
7. VM140 click-path notes (additions to running-the-game.md)
- The >60 s startup is real, a third time. After
schtasks /Run /TN SOTSthe Kerberos intro is still on screen at t+30 s and the main menu appeared at ≈ t+70 s. Screenshot and verify; never sleep-and-click. - One click per
rui.ps1call, confirmed again. A five-clickcmd.txton the Available Species checkboxes lost sync and left three of five toggles in the wrong state. Single clicks with a screenshot between worked every time. typeinto the game's text fields did not register in this session — neither the Create Game name field nor the Save Game file-name field tookSendKeystext (the field simply kept its default). Workaround used: save under the default name and rename the.savon disk afterwards; the Load dialog lists files by filename, so a renamed save appears correctly.- Load-dialog geometry with the 8-file set is unchanged for existing lanes.
zuul-turn5sorts afterref-turn2, soref-turn2is still row 7 at (400, 436) andzuul-turn5is the new row 8 at (400, 465). Rows are 29 px apart from y = 262. Verified by screenshot (verify/results/shim/eventlive/load-dialog-8files.png). - Custom Game Setup coordinates (1024×768 windowed): species checkboxes at y = 449, x = 178
(Human) / 322 (Hiver) / 466 (Tarkas) / 611 (Liir) / 901 (Morrigi) — all five clicked and
verified; Zuul's sits at x ≈ 756 by the same 144-px pitch but was never clicked.
Create Game(836, 707); game-nameOK(600, 393); lobbyLaunch(511, 663). The setup screen remembers the previous game's settings, so 28 stars / 2 players carried over from the reference game. - Quit path used at the end: menu (1000, 714) →
Quit to Main Menu(938, 699) →OK(537, 377).
8. VM state left behind
Restored: build recap-7584bad-20260908T0615Z from C:\SOTS\shimdist-recap, hooks=trace,
main menu verified by screenshot. C:\SOTS\shimdist-v and C:\SOTS\ui\v{deploy,release,click}.ps1
left in place as a template. SavedGames now holds 8 files: the pre-existing 7 plus
zuul-turn5.sav; the three autosaves belong to the Zuul game (they were the reference game's
before, and are transient either way).