sots-engine/docs/H-probes.md

20 KiB
Raw Blame History

Lane H — five live probes, predicted before the build

Lane H, 2026-09-08. Worktree wip/probes off main b2bad30. Every number below was written before the shim was built and before any run (method rule 2). Each section ends with the ways the prediction can fail and the symptom of each, so a wrong prediction is informative rather than merely wrong.

The lane exists because of method rule 18: five open questions that are one probe away and that other lanes have been circling statically.


P1 — the determinism oracle, regenerated

Lane N's End Turns overwrote the three autosave contents on VM140. The file set is unchanged, so every documented Load-dialog row position still holds, and ref-turn2.sav on the VM still hashes ab4ac2d7e2977260… (checked before the run). What is gone is the byte-identical reference pair.

P1. Loading ref-turn2.sav and pressing End Turn once, under build recap-7584bad-20260908T0615Z with hooks=trace, writes

(Autosave EndTurn).sav   66,732 B   bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948
(Autosave).sav           67,219 B   978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921

These are the values findings/subsystems/determinism-oracle.md recorded on 2026-09-07 across five runs in two processes, and which lane R reproduced under the recap build.

Why this is worth a run of its own. Everything since has changed the process the oracle runs in: a different shim build with nineteen template hooks and seven entry-point detours installed, an x87 control-word module, a Zuul game played to turn 64 in the same SavedGames directory, and eight save files where there were four. If the pair still reproduces, the oracle is revalidated across all of that, and every Phase-2 comparison that leans on it keeps its footing.

Falsification.

  • Either hash differs. Then the oracle is not stable across the session's changes and nothing downstream of it can be trusted until the cause is found. Candidate causes, in the order they would be checked: the installed binkw32.dll is not the recap build (hash it); shim.cfg is not the recap trace config (read it); display.cfg / Profile_re.ini changed (they are inputs to the process, not to the save, so this would be a new finding); ref-turn2.sav is not the file it was (already excluded — hashed before the run).
  • Only (Autosave).sav differs. The pre-turn marker is written from loaded state and the post-turn one from computed state, so a pre-turn match with a post-turn mismatch localises the problem to the turn computation rather than to the load or the writer.
  • Only (Autosave EndTurn).sav differs. That would be the more alarming of the two: it is the load → save round trip, which determinism-oracle.md proved stable for the pre-turn form, and it would mean the loader or the writer moved.

P2 — the four tail callees that can draw, and have never been seen to

Lane V2 closed lane K's tier-4 blind spot: of the eleven phase-23/33 vtable targets on StrategyServer+0x158 / +0x15c, four reach a draw on the strategic generator at StrategyServer+0x16c, at eight instructions —

outer callee slot inner draw sites
ServerSpyManager_vslot13 0x008877b0 spy 13 — Chance 0x00887c8a
" " 0x008408e0 Chance 0x00840929, Chance 0x00840a3c, NextInt 0x008409c7
ServerSpyManager_vslot14 0x0088db80 spy 14 — Chance 0x0088dc43
ServerTradeManagerImpl_vslot15 0x0082cca0 trade 15 — Chance 0x0082cdb8
ServerTradeManagerImpl_vslot13 0x0088ef80 trade 13 0x00820ca0 NextFloat 0x00820e18
" " 0x0088b440 NextInt 0x0088b613

Lane Z measured 0 tail words on 8 turns, so tail-rng-ledger.md's "the tail draws nothing" is a property of eight turns, not of the code. Our corpus has no live trade routes and no spy program.

The distinction this probe exists to make is the one a word count cannot: not reached versus reached and gated. "Never observed firing" is currently ambiguous between them, and the two have opposite consequences for a reimplementation — the first says the phase is conditional, the second says the phase always runs and the gate is a data condition our saves never satisfy.

P2a. Each of the four outer callees is entered exactly once per End Turn, on every turn, on every save. The phase-23 and phase-33 dispatch sites (0x007d97a7 … 0x007d9811, 0x007d989b, 0x007d98a8) are unconditional call [vptr+n] in a straight-line block, so entry is not a function of game state at all. Entry counters read calls = 1 per End Turn for each of 0x008877b0, 0x0088db80, 0x0082cca0, 0x0088ef80.

P2b. On the existing corpus none of the eight draw sites fires. No draw_sites row appears with ret_rva in {0x00887c8f, 0x0084092e, 0x00840a41, 0x008409cc, 0x0088dc48, 0x0082cdbd, 0x00820e1d, 0x0088b618} (the return address is the site plus the five bytes of the call), and the tail's bracket cost stays 0 — reproducing lane Z's number under a strictly better instrument.

P2c. The three inner functions 0x008408e0, 0x00820ca0 and 0x0088b440 are entered 0 times. This is the load-bearing half. Together with P2a it says the gate is inside the outer callee, above the inner call — i.e. an empty container (no spy programs, no trade routes) short-circuits before any draw is reached — and it localises the condition to one function each rather than to "somewhere in a 200-function closure".

Falsification.

  • An outer callee is entered 0 times. Then phase 23/33 is itself conditional, and lane V2's "reached ONLY virtually, from <site>" needs a gate note added: reachability from that site is not the same as being called. This is the single most useful way for P2 to be wrong.
  • An inner is entered but draws 0. The gate is below the inner call, not above it. The word count is still 0 and lane Z's table is unchanged, but the map moves.
  • Any of the eight sites fires on the existing corpus. Then tail-rng-ledger.md §2's "the tail's cost on these turns is 0" is still true of those eight turns and false of ours, and the ledger needs the tail term after all — the standalone's generator model would be short by that many words on exactly the turns that matter.
  • An entry counter reads more than 1 per turn. The dispatch block would then be inside a loop nobody has read, which changes the cost model from "0 or 1 draw per site" to "per iteration".

P2b-alt — an amendment, written after P2b and before any run

Everything above was committed before I read the four callee bodies. I then read them (to name the three inner functions for addresses.d), and one of the four does not look like the other three. This is recorded as a competing prediction rather than folded into P2b, because retrofitting P2b after seeing the code would destroy the test.

Read from the decompiled bodies, each confirmed to be a real function start with exactly one caller:

callee the loop it is empty when
ServerTradeManagerImpl_vslot15 0x0082cca0 over the vector at tradeManager+0x3c .. +0x40 no trade routes
ServerSpyManager_vslot14 0x0088db80 over the vector at spyManager+0x10 .. +0x14 no spy programs
ServerSpyManager_vslot13 0x008877b0 1,869 bytes, same manager, not fully read —
ServerTradeManagerImpl_vslot13 0x0088ef80 over GetServer()+0x64 .. +0x68 — the FLEETS vector no fleets

The first two are the shape P2b assumes: an empty container short-circuits the loop and no draw is reachable, which is exactly "no trade routes and no spy program". The fourth is not. Its loop iterates fleets — every save has fleets — and the draw is gated by a chain of per-fleet tests inside the body (0x0088f036 calls 0x00820ca0, whose result gates 0x0088f042's call to 0x0088b440).

P2b-alt. On a save with fleets, ServerTradeManagerImpl_vslot13's loop body runs, and probe row 5 (0x00820ca0) fires with a non-zero count — which means a NextFloat at 0x00820e18 and therefore a NON-ZERO TAIL WORD COUNT. If so, tail-rng-ledger.md's "the tail's cost on these turns is 0" is not merely workload-limited, it is limited by a per-fleet gate that our two ledger saves happened to fail, and the Zuul save named for having 23 fleets is the obvious place to look.

P2b and P2b-alt disagree about the same run, which is what makes it worth making. If P2b holds on a 23-fleet save, the gate is above 0x00820ca0 and the fleet loop is not the reason. If P2b-alt holds, the four-callee blind spot is not "no trade, no spies" — it is one gate on one fleet property, and the ledger needs the tail term now rather than after a manufactured workload.

What this probe cannot settle without a manufactured workload. Whether the sites draw when trade routes or spy programs exist. That needs a save with both, and building one is the expensive part of this lane; if it is not built, P2 downgrades from "the tail never draws" to "the tail never draws on a game with no trade and no espionage", which is a narrower claim than the one in the ledger today and must be written that way (rule 6).


P3 — DetectEncounters, the last non-research generator residual

Lane Y's standalone models 16 of a turn's ~20 words; the residual on its two calibrated pairs is 4 and 2, and 2 words on both are encounter detection — sites 0x0050329d (NextFloat, depth 4) and 0x007929a4 (NextInt, depth 3), each 1 call and 1 word per turn on all three measured turns. Lane Y refused to model it: "fitting 2/turn to three observations is not a model."

Lane I bounds the containing family at |contacts| × |detectors| and established that EncounterDetect_AssignContacts draws one inlined NextFloat per (contact, detector) trial, before the accept test — so an untec­hed detector has threshold 0.0f, burns a word, and can never accept. But lane Z's site run found AssignContacts never ran: the gate in EncounterDetect_ProcessTeamRecord (0x007ca640) was not satisfied on any measured turn.

So the probe is on the caller. EncounterDetect_ProcessTeamRecord is bracketed with the three quantities lane I derived from its instruction stream, computed at hook entry from the record:

  • gate — ∃ entry of rec->(+0x28 .. +0x2c), stride 0x44, with entry[0]->+0xfc != 0;
  • contacts — entries whose object has +0xfc != 0;
  • detectors — entries whose object has +0xfc == 0 and +0xfb == 0;
  • bound = contacts × detectors, the AssignContacts worst case.

P3a. ProcessTeamRecord is called once per 0x74-byte TeamRecord in the vector EncounterDetect_Run receives — the same StrategyServer+0x1e8 vector the tail hooks count as encounters. On ref-turn2 that vector holds 1 record by the time the tail runs, so the expected call count per turn is small and single-digit.

P3b. The turn's 2 detection words are consumed inside ProcessTeamRecord. Both residual sites sit at depth 3 and 4 below DetectEncounters, and ProcessTeamRecord is the only depth-2 function on that path. The bracket over it therefore reads 2 words per turn, and the detection residual outside it is 0.

P3c — the one that changes the map. The 2 words are not the AssignContacts family. gate reads false on every record, AssignContacts is not called, and the |contacts| × |detectors| bound is irrelevant to the observed residual. If that holds, lane Y's 2-word residual and lane I's bound are two different things that have been discussed as one, and the residual is a fixed per-record cost (one NextInt + one NextFloat) somewhere above the gate — which is a formula, and a much cheaper one than the family bound.

Falsification.

  • ProcessTeamRecord costs 0 words. Then the two residual sites are reached by some other path out of DetectEncounters and P3b's depth reasoning is wrong; the next probe is a bracket on DetectEncounters itself.
  • gate is true and AssignContacts fires. Excellent outcome, not a failure: contacts and detectors are then both recorded live and the bound becomes checkable against a measured word count for the first time. P3c is wrong and lane I's family is the residual after all.
  • The cost is 2 per record rather than 2 per turn. Then it scales with the record count and the standalone needs 2 × |records|, not +2. This is distinguishable only if some turn has more than one record — which is exactly why the call count is recorded, not assumed.
  • My computed contacts/detectors disagree with the callee's own when AssignContacts does fire. Then lane I's classification rules are wrong or the record layout is, and the hook says so by printing both numbers side by side rather than only mine.

P4 — CreateRaidEncounter: separating two hypotheses a word count cannot

A successful trade-raid roll calls ServerTradeManagerImpl slot 17, ServerTradeManager_CreateRaidEncounter 0x008938a0, which draws a NextInt at 0x008939ee to pick a target but returns at 0x0089391c without drawing when the candidate vector is empty. It cost 0 words on all three of lane Z's measured turns.

Lane Z read that as "consistent either with no roll succeeding (≈11% on the defaults, so three quiet turns in a row is unremarkable) or with an empty candidate list every time".

A correction to that reading, before the run (rule 11). 11% is the probability of a quiet turn, not of three. With eight player entries and the image defaults the per-turn probability that no roll at all succeeds is 0.8^8 × 0.95^8 = 0.1113; three consecutive quiet turns is 0.00138, or about 1 in 720. The two hypotheses were never equally likely. The expected number of successes per turn is 8 × 0.2 + 8 × 0.05 = 2.0.

P4. CreateRaidEncounter is entered on essentially every turn — about 2 calls per turn — and draws 0 words because the candidate list is empty. Entry counter ≥ 1 on at least two of three turns; no draw_sites row at ret_rva 0x008939f3.

Falsification.

  • 0 entries across three turns. Under the model that is a 1-in-720 event, so it would mean the model is wrong, not that we were unlucky. Two concrete candidates, both checkable next: the odds are not the image defaults on this save (a StrategyVar override in the game data), or one of the two per-player skip gates lane Z found in GenerateTradeRaidEncounters (the visitedMask bit test at 0x00893302, the >2 pre-filter at 0x008933e0) suppresses the call while still spending the Chance word — which would also mean the 16 words are not 16 independent rolls.
  • Entries > 0 and words > 0. Then a raid target was picked, the candidate list is non-empty, and the standalone needs the extra word and the target-selection draw — the row lane Y flagged as "the one that will bite" becomes live rather than theoretical.
  • Entries far above 2 per turn. The roll is not the only caller, or the odds are higher than the defaults.

P5 — the Zuul word count: what the raid loop actually iterates

Lane Z's free prediction, restated by lane Y as a consequence of its model: the two Chance sites in GenerateTradeRaidEncounters cost one word per entry of StrategyServer::Players, which holds 8 on the Human saves and 7 on the Zuul ones (Sim.NumPlrs: #empires + one rebel AI per distinct empire species + 4 NPC pseudo-players). Lane N ran a Zuul End Turn but with the RNG hooks off, so it does not answer this.

P5. On a Zuul save each of 0x00893426 and 0x00893513 records calls = 7, words = 7, for 14 words at the trade-raid block instead of 16, and the turn total is 14 + 2 (detection) + 0–2 (research) = 16–18, against 18–22 on the Human save.

Falsification, and this is the point of the probe.

  • 8 calls per site on a 7-entry player vector. The loop is not iterating the player vector. The next candidates, in order: the TeamRecord vector it is handed as an argument, a species list, or a fixed NPC count plus empires. This is the outcome that would matter most — lane Y's trade_raid.cpp counts NumPlrs and would be wrong on every save.
  • 7 calls at one site and 8 at the other. The two sites have different loop bounds; the NPC site's player-independent gate (0.0f < S->+0x1a0) is already known to be all-or-nothing per turn, so this would mean something else again.
  • Fewer than 7. One of the two per-player skip gates fires on the Zuul save and never fired on the Human one — which would make the 16 an upper bound rather than the hard bound lane Z reported.

What is being measured, and what is not

Every hook in this lane declares the strategic generator and nothing else, exactly as lane Z's family does. None of them makes any claim about whether the turn was computed correctly. The entry counters are register-transparent asm stubs that log and tail-jump to the trampoline: they cannot be wrong about a calling convention because they never assume one, and they cannot report a word count, only a call count — which is deliberate, because the word counts already come from two independent instruments that agree.

A clean run here is a statement about RNG accounting and coverage, and about nothing else.


Outcome — written after the runs

Everything above this line was committed before the shim was built. This section is the adjudication; the full report with the evidence is sots-re/findings/control-flow/tail-probes.md.

prediction verdict
P1 oracle reproduces held. bb4fd9ac89f41e3b… and 978041acd168b56e…, both byte-exact
P2a four outer callees entered once per End Turn held. 1 per turn, 3 turns, 2 saves, every row
P2b none of the eight draw sites fires on the corpus held. No draw-site row at any of the eight return addresses; tail cost 0
P2c the three inner callees entered 0 times held. The gate is inside each outer body, above the inner call
P2b-alt the fleet loop makes slot 13 draw on a fleet-rich save falsified. 23 fleets, slot 13 entered, 0x00820ca0 entered zero times — the gate is the per-fleet test chain
P3a ProcessTeamRecord called once per record vacuous. It was called zero times; the record vector is empty
P3b the 2 detection words are inside ProcessTeamRecord falsified, exactly as its falsification clause anticipated. They are in 0x007d5150's subtree, one call from DetectEncounters
P3c the 2 words are not the AssignContacts family held, and for a stronger reason than predicted: not a failed gate, an empty vector
P4 CreateRaidEncounter entered ~2/turn, drawing 0 held. 2 entries on one turn, 1 on the next, 0 words on all of them — candidate list empty
P5 7 calls / 7 words per site on a Zuul save, 14 not 16 held exactly, on two consecutive turns

The thing no prediction covered

The instrument changed the game. Three End Turns under the full lane-H build produced 19/18/19 words where lane Z recorded 19/18/20, from a byte-identical input save, and an autosave 4 bytes different from the one lane Z's runs produced twice. Six single-End-Turn runs from z2-endturn.sav bisected it to one address: a MinHook detour on ServerTradeManager_GenerateTradeRaidEncounters 0x00893290. hooks=off, the template hooks alone, and the first eight probes all reproduce the un-instrumented result; adding the ninth does not.

Two consequences for this repo:

  1. probes= was added to shim.cfg so the probe set is switchable and bisectable. Every number in the report was re-taken at probes=8, the largest configuration proved byte-identical to the un-instrumented game.
  2. Lane Z's ledger is behaviour-neutral and its published numbers stand — that had never been checked, and it came out the right way.

The mechanism is undetermined. The five bytes MinHook patches at 0x00893290 land on a clean instruction boundary (push ebp; mov ebp,esp; push -1) with no branch target inside them, so a mangled prologue is not the explanation. The suppressed draw is RollResearchEvent's at 0x0088df4f, which runs earlier in the turn than the hooked function — which is the part to chase.