From 1932377b92324b04b48e0cd8c6e04593ed37e1b7 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 12:23:42 -0400 Subject: [PATCH] lane H: five probe predictions, committed before the build --- docs/H-probes.md | 241 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/H-probes.md diff --git a/docs/H-probes.md b/docs/H-probes.md new file mode 100644 index 0000000..f17db66 --- /dev/null +++ b/docs/H-probes.md @@ -0,0 +1,241 @@ +# 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 ``" 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". + +**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.