# L5 — the standalone's newest models, checked against the running game Lane L5, 2026-09-08. Worktree `wip/l5` off `main` `0117495`. Guest **VM146** (`192.168.10.146`), a ZFS clone of the reference guest VM140. The `verified` column of `src/app/phase_catalog.cpp` has read **0** all campaign, by construction: `app_test_catalog` asserts it, because in that table `verified` means *compared against the live game* and no lane that wrote a phase held a VM. This lane holds one. Everything below is written **before** the builds and before any run. --- ## 0. What a live compare can settle that a host test cannot Three of this lane's four targets are claims about **values the executable never stores in a file** or about **rounding the host cannot reproduce**: * a data-file constant whose file image is zero because the loader fills it at run time (`BANKRUPTCY_PROTECTION_LIMIT_FACTOR`, lane PL); * the per-player "is AI" flag at `ServerPlayer+0xf9`, which no save carries and which lane PL's T31 recovers *by inference from the stored `BnkEl`*; * the game's own x87 rounding of a product whose error exceeds half an ulp of the answer (lane G3's rescale). And one is a claim about a **boundary the corpus contains but the existing compare never reported on** (rule 23, `ComputeBudget`'s interest literals). --- ## 1. E1 — the interest literals, at the boundary, with a control that must fail ### The setting Lane G3 corrected `ComputeBudget` to multiply the treasury by **widened float** literals — `kSavingsInterestRate = 0.009999999776482582` = `(double)0.01f` and `kDebtInterestRate = 0.15000000596046448` = `(double)0.15f` — because the image loads `0x009e31c0` and `0x009ed188`, which hold those widened values, not the exact decimals. The consequence G3 states is that **a treasury of exactly 50,000 earns 499, not 500.** G3 did not measure that against the game. It inferred it from a one-money residual on the human's savings, and it wrote down, honestly, that the behavioural compare had not caught it: `ComputeBudget` had run **4,437 calls with 0 divergences** and "that run presented only 20 distinct states and none of them sat on a rounding boundary." ### The fact that makes this decidable in one End Turn `turn1-state.sav` (= `Autosave EndTurn - turn2.sav`, 64,967 B) carries **exactly two distinct treasuries across its eight players**: | `Sav` | players | multiple of 100? | |---:|---:|---| | 50,000 | 2 (the human at `@0000f2a8`, the AI empire at `@0001b3d8`) | **yes** | | 0 | 6 | yes, trivially | `SavingsInterest` gates on `savings >= 0 && ownsSystems`, and the interest term diverges between the two constants exactly when `savings` is a **multiple of 100**: * exact decimal: `50000 x 0.01 = 500.0` → `trunc` = **500** * image widened: `50000 x 0.009999999776482582 = 499.9999888241291` → `trunc` = **499** So the corpus **does** sit on the boundary, and slot 5 (`kSlotSavingsInterest`) is **not** in the declared input boundary — `IsInputSlot` names slots 1, 2, 3, 4, 7 and 11, and 5 is not among them, so `FillSlots` writes `b.savingsInterest` from *our* arithmetic and the harness diffs it. That is a contradiction worth stating plainly before the run: **if slot 5 is compared, and the boundary is in the corpus, the 4,437-call run should have diverged and did not.** Either the 4,437 calls were not on this save, or something about the compare does not reach slot 5. The run below distinguishes those two, and that is the point of the control build. ### E1 predictions **E1-a.** Under build **A** (`main`, G3's widened constants), `hook.Game::ServerPlayer::ComputeBudget=compare`, one End Turn from `turn1-state.sav`: **0 divergences**, and the original fills slot 5 with **499** for the human and **499** for the AI empire, **0** for the other six. **E1-b (the control that must fail).** Under build **B** — byte-identical to A except `kSavingsInterestRate = 0.01` and `kDebtInterestRate = 0.15`, the exact decimals — the same run reports **exactly 2 divergences**, both on `side.budget.after.v.savingsInterest`, both `ours = 500` against `orig = 499`, on the two players whose `Sav` is 50,000 and who own systems. Every other slot and every other player agrees. **E1-c (rule 19).** Build B changes only `ours()`. `compare` mode never lets ours touch game memory. So the **autosave written by build B must be byte-identical to the one written by build A, and both must be byte-identical to a `hooks=off` control run.** Three files, one hash. **E1-d (coverage, rule 1 / rule 15).** The run will report a call count in the low tens, not thousands, and a **distinct-state count of 2** on the treasury input. That number goes next to the verdict. A green compare over 2 distinct states is worth reporting as 2, not as "clean". ### Falsification * *The original fills slot 5 with 500.* Then G3's rule-23 reading is **wrong**, the correction introduced a defect, and the residual it closed did so for another reason. This is the outcome that would matter most, and it is why the run is worth making. * *Build B also reports 0 divergences.* Then slot 5 is not actually reaching the diff — an **instrument** defect, not an engine one, and it would retroactively explain the 4,437 green calls. Symptom to check: whether the record carries `side.budget.after` at all, and whether the human's call appears in the log. * *Build B diverges on more than 2 calls, or on a player whose `Sav` is 0.* Then `SavingsInterest` is reached on a path that ignores its `ownsSystems` gate, or the UI's `projected=true` calls are in the log with treasuries the save does not carry — in which case the distinct-state count is larger than 2 and the whole coverage claim above has to be restated from the log. * *The autosaves differ between A, B and the `hooks=off` control.* Then compare mode is leaking into game memory, which would invalidate every compare result the campaign has recorded, not just this one. --- ## 2. E2 — T31's difficulty column, checked against the flag the save does not carry Lane PL's T31 identifies which difficulty column applies **from the save alone**, with no operator input, on all 11 saves, by computing `BnkEl` under both columns and matching the stored value. Its own resolution is "**1 AI and 1 non-AI on every one of the eleven saves**, with the rest ambiguous", and §7 records that "ambiguous" has two causes — a player that owns nothing, and player 576, an **NPC**, for which the row selector `isAI && !npc` hands both columns the same row. The running game holds the answer directly. `ServerPlayer_off_IsAI = 0xf9` and `ServerPlayer_off_NPC` are **already snapshotted** by `compute_budget.cpp` and already emitted into every trace record as `inputs.isAI` / `inputs.npc`. So E1's run produces, per player per turn, the live values of the two bits T31 infers — **at no extra cost and in the same run**. **E2-a.** The live flags on `turn1-state`, for the eight players, are: `isAI=false, npc=false` for the human; `isAI=true, npc=false` for exactly one player (the AI empire); and for the remaining six, at least one with `npc=true` — the one T31 calls ambiguous-because-NPC (player 576, the Independent Colony). **E2-b.** T31's save-only identification agrees with the live flags on **every player it does not call ambiguous**, and every player it *does* call ambiguous is either `maxIncome == 0` or `npc == true`. If both hold, T31's recovery is verified against the game rather than against its own inversion. ### Falsification * *A player T31 identifies as AI reads `isAI=false` live.* T31's inversion is fitting noise; the two columns are being distinguished by something other than the ×1.1, and PL-2's leaf closures are coincidence. * *More than one player reads `isAI=true && npc=false`.* Then "1 AI and 1 non-AI on every save" is a property of the inversion, not of the roster, and the ambiguous bucket is hiding real AI players whose max income happens to be 0. * *No player reads `npc=true`.* Then §7's explanation of the sixth ambiguous player is wrong and the ambiguity has a third cause. --- ## 3. E3 — the constants the file image cannot hold Two of the campaign's live claims rest on values that are **zero in the executable on disk** because the data-file loader writes them at run time, and on a table the shim reads at fitted constants: * `g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR` at `0x00b23e28`, reached through the pointer slot `0x00aedfdc`. Lane PL-3 read its *width* off the instruction stream (`D8 09` = `fmul m32fp`, a float32, two instructions after `DD 05` = `fld m64fp`, a double) and could not read its *value* at all: "the file image of the slot is zero: the loader fills it at run time". PL-3 assumed 3.3 to compute the disagreement rate and said so. * the difficulty-mods triple. `budget_inputs.h` carries `kDifficultyAI{3.0, 1.5}` and `kDifficultyHuman{1.0, 1.0}` as **fitted constants "measured from the B1 trace rather than snapshotted inputs"**, with the coverage note "not reachable from a ServerPlayer". **E3-a.** Read live, the protection factor is a finite positive float32, and it is **exactly representable as a float32** — i.e. `(float)value == value` — which is the whole content of PL-3's width claim. Its value is **not** predicted here; PL-3's 3.3 is an assumption and this run replaces it with a measurement. **E3-b.** The difficulty row the game hands the AI empire has its fleet-maintenance divisor and its research multiplier equal to the fitted `{3.0, 1.5}`, and the human's equal to `{1.0, 1.0}`. **E3-c.** `ServerPlayer+0x8` points into the `StrategyServer` frame (the address table records `ServerPlayer+0x8 == S+4`), so the difficulty record **is** reachable from a `ServerPlayer` and the coverage note "not reachable from a ServerPlayer" is wrong as written. ### Falsification * *The slot reads zero or unreadable at the turn gate.* Then the loader has not run, or the key is absent from this install's data files, and PL-3's whole disagreement analysis is about a constant the shipped game does not have. Symptom: `BnkPr` is 0 for every player, which the corpus can be checked against. * *The fitted difficulty pair disagrees with the live row.* Then B1's fit was on a workload that happens to match and `ComputeBudget`'s maintenance and research terms are wrong off-corpus. * *`ServerPlayer+0x8` does not resolve to a plausible server frame.* Then E3-c is wrong and the coverage note stands; the value has to come from a hook on the server instead. --- ## 4. What is deliberately NOT attempted, and why **G3's civilian growth is not compared on a stock End Turn.** The instrument would be a new `compare` hook on `ServerSystem::GrowCivilianPops` (`0x00754220`, verified thiscall, no stack args, `void`), whose verdict is the `Population` rows behind `ServerSystem+0x1a0`. On the reference save that run presents ~28 calls of which 2–3 have civilians, **all single-species, all at ideal suitability, none blockaded, none seeding, none at the settle limit** — the degenerate case in which G3's rescale `trunc(applied x (clamped/total))` is *exact by construction* and the clamp is the only thing being tested. That is the same shape as the 4,437-call green run this lane exists to distrust: it would come back clean and license nothing. Recording it as a measured decision rather than an omission: **the civilian-growth compare is worth making only on a workload the corpus lacks** — a multi-species colony (the rescale's truncation and its lack of renormalisation), a blockaded colony (`haltv[1]`), a colony over its settle limit (the `-50,000,000` floor), or a newly landed colony ship (the seeding loop). Each of those turns one row of G3 §4.4's falsification table from "invisible here" into a measurement. Building such a save is a game-play session, not an instrument change, and it is the right next lane's work. --- *Outcomes are recorded in §5 onward, after the runs, next to the predictions above.* --- # 5. Outcomes Three runs on **VM146**, all from `turn1-state.sav` (the Turn-1 save, human and AI empire both at a treasury of exactly 50,000), all through the identical Load → Single Player → OK → row → OK → Launch → End Turn path: | run | DLL | `hooks` | `ComputeBudget` | |---|---|---|---| | **control** | `l5A-widened` | `off` | not installed | | **A** | `l5A-widened` | `trace` | `compare` | | **B** | `l5B-decimal-CONTROL` | `trace` | `compare` | A and B differ **only** in `kSavingsInterestRate` / `kDebtInterestRate` — widened floats in A, the exact decimals in B — which is our side of the compare and never touches game memory. ## 5.1 E1 — held, and the control fired exactly as designed **E1-a held.** Run A: **3,895 calls, 3,895 compared, 0 diverged, 0 undeclared writes.** The original filled `savingsInterest` with **499** at a treasury of 50,000 — not 500. **E1-b held in substance and was wrong in its units.** I predicted "exactly 2 divergences". Run B reported **1,359 divergences over 2,718 calls**. The prediction was right about *which states* diverge and wrong about the counting: I counted players, and the harness counts calls — `ComputeBudget` is invoked several hundred times per player per turn by the AI's own evaluation loop, not once. Recorded rather than quietly corrected, because the same mistake would have made a call-count look like a coverage claim. What the control shows, per treasury: | treasury | multiple of 100? | calls in B | diverged | game's `savingsInterest` | exact decimal | |---:|---|---:|---:|---:|---:| | 0 | — | 303 | **0** | 0 | 0 | | 38,100 | **yes** | 1 | **1** | 380 | 381 | | 50,000 | **yes** | 1,358 | **1,358** | **499** | 500 | | 92,651 | no | 28 | **0** | 926 | 926 | | 289,688 | no | 1,028 | **0** | 2,896 | 2,896 | Every divergence is on a multiple of 100 and no other state diverges at all — which is exactly the arithmetic: the two constants differ in their truncated product only when the treasury is a multiple of 100. **The game agrees with the widened float and disagrees with the decimal, at two independent treasuries.** G3's rule-23 correction is verified against the running game. **A blast radius larger than "one money".** The diff on a diverging call is three fields, not one: ``` side.budget.after.v.savingsInterest orig 499 ours 500 side.budget.after.v.available orig 239091 ours 239092 side.budget.after.v.researchMoney orig 59772 ours 59773 ``` The interest is an income line, so the error propagates into `available` and then into the money routed to research — 1,317 of the 1,359 diverging calls also move `researchMoney`. A one-money interest defect moves the player's research spend too. **E1-c held.** All three runs wrote a byte-identical **pre-turn** autosave (`a3f9dc4b49fc669c…`, 64,967 B). Load and save are deterministic. The post-turn autosaves are discussed in §5.4 and the answer is *not* the hook. **E1-d — coverage, and my prediction was wrong here too.** I predicted 2 distinct treasury states; the run presented **5** (the trace spans the post-turn UI as well as the turn), of which **2 are on the interest boundary**. The honest coverage line for run A is therefore: > `ComputeBudget`: 3,895 calls (3,888 turn-path + 7 `projected=true`), **5 distinct treasury > states, 2 of them on the rounding boundary**, 0 divergences, 0 undeclared writes. Compare that with the number this lane was sent to distrust — "4,437 calls, 0 divergences, 20 distinct states, none on a boundary". The difference between the two runs is not the call count. **The contradiction in §1 is resolved.** Slot 5 *is* compared and the harness *can* see it: the control build produced 1,359 divergences on it. So the earlier 4,437-call green run genuinely did not present a boundary treasury. The instrument was never broken; the workload was thin. ## 5.2 E2 — T31's difficulty-column recovery, checked against the flag the save does not carry Held on every point. The live `ServerPlayer+0xf9` / NPC flags, read on all eight players: | PlyrIdx | `isAI` | `npc` | owns systems | T31's verdict from the save alone | |---:|---|---|---:|---| | 0 (human) | false | false | 1 | non-AI column | | 1 (AI empire) | **true** | false | 1 | AI column | | 2–6 | true | **true** | 0 | ambiguous (zero income) | | 7 (Independent Colony) | true | **true** | **1** | ambiguous (both columns agree) | **E2-a held**: exactly one player is `isAI && !npc`, the human is neither, and six are NPCs. **E2-b held**: every player T31 calls ambiguous is either income-less or an NPC, and the one awkward case — a player that owns a system and is *still* ambiguous — reads `npc = true` live, which is precisely the explanation lane PL §7 gave after its own prediction missed it. T31 identifies the column from the save with no operator input, and the running game agrees. `aidf` (`ServerPlayer+0x368`) reads **1** on all eight players across 3,895 records — the difficulty level the corpus carries, and the level whose AI column holds the ×1.1. ## 5.3 E3 — one confirmed, one falsified **E3-a held, and it is the one result no static reading could have produced.** The `BANKRUPTCY_PROTECTION_LIMIT_FACTOR` slot at `0x00aedfdc` was non-null on every call and the float32 behind it reads **3.29999995231628417968750** — exactly `(float)3.3`. So: * the shipped data file does carry 3.3, and **PL-3's assumption was right**; * the narrowing PL-3 read off the instruction stream is **real**: the constant the game multiplies by is not the decimal 3.3 but the nearest float32 below it. PL-3 had to write "the file image of the slot is zero: the loader fills it at run time" and estimate from an assumed value. It is now measured. The three `.rdata` doubles read identically in the running process to their file images (`0.009999999776482582`, `0.15000000596046448`, `0.8500000238418579`), so nothing patched or relocated them. **E3-c was FALSIFIED, in the way its own falsifier described.** I predicted the difficulty record sits inline at `ServerPlayer+0x36c`. It does not: the dword there is a heap pointer on all eight players (`0x0da0ec20`…`0x0da13360`, within 18 KB of the `ServerPlayer` allocations themselves) and the "floats" behind it decode as denormal garbage. The record is **one dereference further on**. What survives is the part the reading was actually for: the row **is** reachable from a `ServerPlayer`, so `compute_budget.h`'s note "not reachable from a ServerPlayer" is wrong as written and has been corrected. **E3-b is therefore not settled**: the fitted pair `{3.0, 1.5}` / `{1.0, 1.0}` remains unverified against the running game. The hook now logs the pointer *as a pointer* and does not follow it — dereferencing an unvalidated pointer inside a hook that runs in the turn pipeline is how a shim crashes the game, and this lane could not re-run to validate it. `ServerPlayer_off_DiffModsPtr` is in the address table as a **hypothesis** with the measurement attached, and the next run is one guarded dereference. ## 5.4 A determinism result that was not being looked for The three post-turn autosaves are all different, and **the hook is not the cause**: | run | hooks | `(Autosave).sav` | `Player[512 "Singularity"]/ResTNm` | |---|---|---|---| | control | **off** | `62061918176e3441` | `XNC_TrnsLir2` | | A | compare | `7fc9e6ab46b47794` | `XNC_TrnsHvr2` | | B | compare | `ab4ac2d7e2977260` | `BIO_GnMod` | Each pair differs by **exactly four leaves**: one shadow empire's research-target name, two of its tech-tree status words, and the derived `/Summary/Checksum`. Nothing else in the state moves. Runs A and B carry **identical hooks** and differ only in our side's arithmetic, which compare mode never lets touch game memory — so a hook-induced perturbation cannot explain A ≠ B. And run B reproduced `ab4ac2d7e2977260…`, which is the hash `findings/subsystems/running-the-game.md` records for the original live session's `Autosave - turn2.sav`. So the instrument is exonerated (rule 19 discharged) and the real finding is about the game: > **The `turn1-state → turn2` transition is not reproducible across runs.** The determinism > oracle was established on `ref-turn2 → turn3`, where five runs in two processes gave one hash. > It does not extend to this pair: three runs gave three hashes, differing only in one Singularity > shadow empire's AI research pick. The pre-turn autosave is byte-identical across all three runs, so the loader and the writer are deterministic; only the turn's AI decision is not. This is worth knowing before any lane uses `turn1-state → turn2` as an oracle pair, and it corroborates lane PL's placing `ResTNm`/ `ResErrRoll` on Rung B — those leaves are not merely AI-dependent, they are *unstable*. ## 5.5 Two more rule-23 defects, found by reading and invisible to the corpus The operand-width sweep of the money chain turned up two more constants in `ComputeBudget` itself, both now corrected and both **unobservable on any reference turn**: 1. **The research-yield factor is a widened float.** `0x00a2d818` holds `(double)0.85f`, while its two neighbours in the same product — the 1.15 and the 0.5 — are exact doubles. Three literals, one expression, two widths. The exact-decimal product is integral only when the research money is a multiple of 40,000, and the image's constant lands one *above* it there: at 40,000 the game gives 391 research points and the decimal gives 390. **Measured coverage: the run presented 9 distinct research-money values and none is a multiple of 40,000.** That is why the compare stayed green with the wrong constant in place. 2. **The three research modifiers are summed in single precision**, with a float32 store after every add, not in double. The corpus has `shrm = TRM = 0`, so the sum is exactly `ResMod` and the two agree by construction. Both are pinned by boundary cases in `tests/game_sim/test_economy.cpp` that fail with the decimals and pass with the image's constants. Neither is verified against the game, and the doc says so — the sweep also confirmed that `1.15`, `0.5`, `500`, `0.01` (in the infrastructure term) and `1.65` really *are* exact doubles in the image, so "widen every literal" would be its own defect. ## 5.6 G3's clamp, observed on the un-instrumented game The control run's own strategy-map panel, with no hooks installed at all, shows Gamma Cephei's civilian population going **500,000,000 → 520,000,000** across the End Turn: exactly the **+20,000,000** step cap that lane G3 read out of the population-type table. That is the clamp claim confirmed on the game's own readout, independent of any instrument. It is *not* a verification of G3's rescale, which is the part G3 flagged as "a measurement, not a theorem". The rescale is exact by construction in the one-species case, and every colony on this save is single-species. §6 says what workload would settle it. --- # 6. What the `verified` column reads after this lane, and why **It still reads 0, and moving it would have been the wrong answer.** In this table `verified` means "implemented here **and** compared against the live game", and `app_test_catalog` asserts it stays 0 so the claim cannot drift upward by accident. Every phase this lane's compare touches is `Partial` — `P01 ComputeBudget` because the repair demand of ships in orbit is still taken as 0; `P02 ApplyNetToSavings` because an AI's research rate is set by its own orders during the turn; `T31 UpdateBankruptcyLimits` because `BnkPr` needs a tuning constant that is not wired. Those are all blocked **upstream of the compare**, and none of them is something a VM can fix. Promoting a `Partial` phase to `verified` because part of it was checked is exactly the drift the assertion exists to catch. So the ladder's two conditions — *complete* and *compared* — are currently blocked by different things, and the column measures the conjunction. What this lane moved is **models**, and those are listed with their coverage in §5. The catalog notes for `P01`, `P02` and `T31` have been updated to carry the live evidence and its measured coverage instead of the older, weaker call counts. # 7. The workload the next lane needs Not more analysis. Two saves that the corpus does not contain: 1. **A multi-species colony**, which is the only thing that exercises G3's rescale `trunc(applied x (clamped/total))` outside its degenerate exact form — the one float G3 says is "a measurement, not a theorem". Also a blockaded colony (`haltv[1]`), a colony over its settle limit (the −50,000,000 floor) and a newly landed colony ship (the seeding loop): four rows of G3 §4.4's falsification table that read "invisible here" today. 2. **A treasury that is a multiple of 40,000 in research money**, which is the only thing that can observe the yield-factor width live. It is reachable without editing memory: the research slider changes `ResRate` and therefore the research money, and each slider position is a separate `ComputeBudget` call — sweeping it turns the UI into a boundary generator, which is the cheapest way to raise the distinct-state count that §5.1 shows is the thing that actually decides whether a compare means anything. Both are game-play sessions, not instrument changes.