From b39bb290e378a367bd736c380b17bb8a05c79542 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 17:48:42 -0400 Subject: [PATCH] L5: the interest literals verified live at a boundary, with a control that fails `ComputeBudget`'s savings-interest term is now compared against the running game at a treasury the corpus actually contains. Three runs on VM146 from turn1-state.sav: A (widened floats, as shipped) 3,895 calls, 0 diverged, 0 undeclared writes B (exact decimals, the control) 2,718 calls, 1,359 diverged The game fills savingsInterest with 499 at a treasury of 50,000, and with 380 at 38,100 -- the exact decimals pay 500 and 381. Every divergence in B lands on a treasury that is a multiple of 100 and no other state diverges at all, which is exactly the arithmetic. G3's rule-23 reading is now measured, not inferred, and the one-money error is shown to propagate into `available` and `researchMoney` too. The control also settles why the earlier 4,437-call green run was green: slot 5 IS diffed and the harness CAN see it, so that run simply presented no boundary state. Coverage is therefore reported as distinct states, not calls: 5 distinct treasuries, 2 of them on the boundary. Two further rule-23 constants found in the same routine by an operand-width sweep, corrected, and honestly marked UNVERIFIED because no reference turn can see them: - the research-yield factor is a widened 0.85f while its two neighbours in the same product are exact doubles. Boundary: research money a multiple of 40,000; the run presented 9 distinct values and none is. - the three research modifiers are summed in single precision, not double. Boundary: two of the three non-zero; the corpus has shrm = TRM = 0. Both are pinned by boundary cases in test_economy.cpp that fail with the decimals. Also verified live, in the same run: - T31's difficulty-column recovery. The live ServerPlayer+0xf9 / NPC flags on all eight players are exactly what lane PL's save-only inversion claims, including the awkward system-owning player that is still ambiguous because it is an NPC. - BANKRUPTCY_PROTECTION_LIMIT_FACTOR reads 3.29999995 = (float)3.3. Its file image is zero because the loader fills it at run time, so lane PL-3 had to assume the value; it is now measured and the assumption was right. Falsified, and recorded as such: the difficulty-mods record does NOT sit inline at ServerPlayer+0x36c -- that field is a heap pointer on all eight players. The row IS reachable from a ServerPlayer (which corrects the hook's standing coverage note), but the fitted {3.0,1.5}/{1.0,1.0} pair remains unverified. The hook logs the pointer and does not follow it. The `verified` column stays 0, deliberately. Every phase this compare touches is Partial for reasons upstream of it, and promoting one because part of it was checked is the drift app_test_catalog exists to catch. What moved is models; see docs/L5-live-verification.md for each one with its coverage. Gates run separately: clean-room OK, host ctest 54/54, CT111 shim cross-build exit 0. --- docs/L5-live-verification.md | 428 +++++++++++++++++++++++++++++ include/generated/sots_addresses.h | 46 +++- src/app/phase_catalog.cpp | 31 ++- src/game/sim/economy.cpp | 18 +- src/game/sim/economy.h | 16 +- src/shim/hooks/budget_inputs.cpp | 18 ++ src/shim/hooks/budget_inputs.h | 27 +- src/shim/hooks/compute_budget.cpp | 68 ++++- src/shim/hooks/compute_budget.h | 26 +- src/shim/main.cpp | 2 +- src/shim/shim.cfg.l5budget | 34 +++ src/shim/shim.cfg.l5control | 6 + tests/game_sim/test_economy.cpp | 18 ++ 13 files changed, 722 insertions(+), 16 deletions(-) create mode 100644 docs/L5-live-verification.md create mode 100644 src/shim/shim.cfg.l5budget create mode 100644 src/shim/shim.cfg.l5control diff --git a/docs/L5-live-verification.md b/docs/L5-live-verification.md new file mode 100644 index 0000000..dc6cb53 --- /dev/null +++ b/docs/L5-live-verification.md @@ -0,0 +1,428 @@ +# 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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index b38e10d..d52c256 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 2d61848, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1913,6 +1913,50 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0; constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0; // note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified] constexpr uint32_t SNMAllCombatDone_layout = 0x00624758; +// site call RNG_NextInt (0x004271c0) inside SVSOSwarmQueen_RegisterHives, ECX = GetGame()->RNG(+0x16c) + 4, bound = HI - LO passed BY POINTER at [ebp-0x14]; return address 0x00527714, then `add eax,edi` where edi = frame + LO. ONE STRATEGIC-GENERATOR WORD PER NEW HIVE, taken inside StrategyServer::BeginProcessTurn -- outside BOTH turn drivers and before either. Corrects svsctob-writers.md, which put the call at 0x0052770c (that is the `mov [ebp-0x14],esi` storing the bound) [verified] +constexpr uint32_t SVSOSwarmQueen_RegisterHives_DrawSite = 0x0012770f; +// site `inc DWORD PTR [esi+0x8]` -- the NextQ slip. FOUR gate-failure edges converge here: 0x005277e3 (frame <= *SwarmQueen_GateFrameFloor_ptr), 0x005277fe (queens count >= the per-turn allowance), 0x00527848 (age < *SwarmQueen_GateQueenAge_ptr), 0x0052784f (hive's system handle is 0). Consumes NO generator word on any of them; TickHives calls no RNG primitive anywhere in its body (0x00527770..0x005279a1 read whole) [verified] +constexpr uint32_t SVSOSwarmQueen_TickHives_SlipSite = 0x0012785a; +// data int* -- pointer to the LOW end of the new-hive queen-countdown offset. RegisterHives computes nextQ = frame + *this + RNG_NextInt(*SwarmQueen_HiveNextQOffsetHi_ptr - *this). Behind a pointer, so no .text or .data reference initialises it in a form a static reader can follow; the value is read live by the shim at RegisterHives entry [mapped] +constexpr uint32_t SwarmQueen_HiveNextQOffsetLo_ptr = 0x006e0204; +// data int* -- pointer to the HIGH end of the new-hive queen-countdown offset. The RNG_NextInt bound is (*this - *SwarmQueen_HiveNextQOffsetLo_ptr) and NextInt is INCLUSIVE of its bound, so the countdown lands in [frame+LO, frame+HI] [mapped] +constexpr uint32_t SwarmQueen_HiveNextQOffsetHi_ptr = 0x006e0208; +// data int* -- TickHives gate 1: `if (GetGame()->Frame(+0xc) <= *this) slip`. No queen can spawn at or below this frame [mapped] +constexpr uint32_t SwarmQueen_GateFrameFloor_ptr = 0x006e0210; +// data int* -- read in TickHives' SPAWN arm (0x005278d8), after a queen has been created: when *this <= 0 the vector at queen+0x30 is trimmed to 0 entries, otherwise to *this. Never exercised on any corpus save (no hive has ever had a queen) [mapped] +constexpr uint32_t SwarmQueen_SpawnListCap_ptr = 0x006e0220; +// data int* -- TickHives gate 3: an age in turns, compared against `GetGame()->Frame - resolvedQueen->+0xc`; below it the hive slips instead of spawning [mapped] +constexpr uint32_t SwarmQueen_GateQueenAge_ptr = 0x006e0228; +// field std::vector -- {_Myfirst 0x10, _Mylast 0x14, _Myend 0x18, _Alval 0x1c}, element stride 0x10: {void* vptr = 0x009f1a68, StarSystem* sys(+4), int nextQ(+8), Queen* queen(+0xc)}. The WIRE order is HiveID / QueenID / NextQ, which is NOT the member order [verified] +constexpr uint32_t SVSOSwarmQueen_off_Hives = 0x00000010; +// field std::vector at +0x20..+0x24, element stride 0xc -- TickHives' second gate divides the byte span by 12. Empty on every corpus save [verified] +constexpr uint32_t SVSOSwarmQueen_off_Queens = 0x00000020; +// field int -- the difficulty tier, wire tag "CDiff". Set to -1 by the ctor 0x0051a820 and written ONLY by SVSOSlaversRefuel_UpdateDifficultyTier, which stores (scanIndex - 1) and only when it differs. Reachable values are 0 (frame 1..49) and 1 (frame 50..99) ONLY: frame <= 0 exits at index 0 and frame >= 100 walks off the end of the 3-entry threshold table, so BOTH edges store nothing and the tier can never reach 2 [verified] +constexpr uint32_t SVSOSlaversRefuel_off_CDiff = 0x00000038; +// thiscall void __thiscall Game::AITColonize::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITColonizeGoal::Execute is the same address. 48 bytes; a forwarder to the parameterised worker 0x0068b280 called as worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10) with `edi = this->+0xc` passed as an IMPLICIT REGISTER ARGUMENT (whole-program-optimised custom convention; a reimplementation that ports only the stack arguments passes garbage). Reaches the colonize order (list 7) at depth 4 via 0x0068b280 -> 0x006930f0 -> 0x00578ff0 -> 0x00769640 [unverified] +constexpr uint32_t AITColonize_Execute = 0x0028b400; +// thiscall void __thiscall Game::AITEscortGateInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITEscortGateInvadeGoal::Execute is the same address. 80 bytes; forwards to 0x0068c5d0 as __fastcall(ecx = this->+0xc, edx = agent) plus 8 stack arguments. Reaches list 7 at depth 4 [unverified] +constexpr uint32_t AITEscortGateInvade_Execute = 0x0028c7c0; +// thiscall void __thiscall Game::AITInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITInvadeGoal::Execute is the same address. 160 bytes; forwards to 0x0068d460 with 11 arguments, then maintains this->+0x38 and this->+0x39. +0x39 is computed at 0x0068d80f-0x0068d82a as (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc)) and is what IAITask slot 11 returns for this class. Reaches list 14 at depth 5 [unverified] +constexpr uint32_t AITInvade_Execute = 0x0028d7a0; +// thiscall void __thiscall Game::AITNodeBore::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Does its setup (0x00685810) and its finaliser (0x0068e090) only when pass == 0, and forwards `pass` to 0x0068a520 in both passes. Reaches list 14 at depth 5 via 0x0068a520 -> 0x006ceef0 -> 0x006c16c0 -> 0x006987e0 -> 0x007634d0. Zuul-flavoured task (the node-bore is a Zuul mechanic), so on a non-Zuul board it may be absent from the task list entirely [unverified] +constexpr uint32_t AITNodeBore_Execute = 0x0028e590; +// thiscall void __thiscall Game::AITRaid::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. THE ONE TASK WHOSE PASS-0 BEHAVIOUR IS NOT SETTLED: at 0x0068e89e it calls 0x006b76a0 and then at 0x0068e8b8 loops the list-16 order method 0x007635f0(client, fleetId, 1) over the returned fleets, and NEITHER the call nor the loop carries a `pass` guard of its own. AI3 §2.3's argument says the fleet vector at [ebp-0x28] is empty on pass 0 because it is downstream of the same hub, but [ebp-0x28] was never traced to closure. This address exists as an address so that a zero at 0x007635f0 can be read: entered-and-silent and never-entered are opposite answers (method rule 20) [unverified] +constexpr uint32_t AITRaid_Execute = 0x0028e670; +// thiscall void __thiscall Game::AITAdvanceIdleShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Whole body is pass-1 only: `if (pass != 1) return` at 0x0068f25a. Table priority 0, so it is always the LAST task in the descending-priority sweep -- the AI sweeps up whatever is still idle only after every other task has taken both its minimum and its desired force, which is what the two-tier quota model predicts [unverified] +constexpr uint32_t AITAdvanceIdleShips_Execute = 0x0028f230; +// thiscall void __thiscall Game::AITBuildDeepScanShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6 via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0, the same chain as AITBuildPoliceShips [unverified] +constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0; +// thiscall void __thiscall Game::AITBuildPoliceShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6, identical chain to AITBuildDeepScanShips. One of the two candidate producers of the single list-3 element the reference AI emits on turn 1 [unverified] +constexpr uint32_t AITBuildPoliceShips_Execute = 0x00290380; +// thiscall bool __thiscall Game::StrategyClient::OrderList16(int objectId, bool flag) -- RET 8. THE LIST-16 PRODUCER. Opens with `cmp byte [this+0x15c], 0` -- the End-Turn latch StrategyClient::EndTurn 0x00783be0 sets at 0x00783c59 before BuildTurnCommands runs -- so like every other order method it refuses once the turn has been submitted. Element record for list 16 is {i32, bool} (lane Q), matching the (objectId, flag) pair; list 16 is in the PAYING half of the ModCount table (bump at 0x00821e23 in its applier 0x00821e20). Reached from AITRaid::Execute at depth 1. The method is named for the list it fills, NOT for what the flag means: no save has ever carried a list-16 element and nothing has been observed [unverified] +constexpr uint32_t StrategyClient_OrderList16 = 0x003635f0; +// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies for this player. VERIFIED LIVE on VM146: read at ComputeBudget entry on every one of the 8 players of turn1-state across 3,895 calls, it is 1 on all eight, which is the difficulty level the whole corpus carries and the level whose AI column holds the 1.1x income modifier [verified] +constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368; +// offset DifficultyMods* -- a POINTER to the per-player difficulty record, NOT the record inline. This entry exists to correct a lane L5 mistake and to save the next lane the run. addresses.json already records DifficultyMods_Select 0x0059b490 being called as Select(p->diffMods /*+0x36c*/, p), and lane L5 read the 0x1c bytes AT +0x36c expecting {int id; float ai[3]; float other[3]}. They are not that: the first dword is 0x0da0ec20..0x0da13360 on the eight players of turn1-state -- eight distinct heap addresses within 18 KB of each other and of the ServerPlayer allocations themselves (the player object was at 0x0da08600) -- and the floats behind it decode as denormal garbage (1.7e-38, 1.4e-43). So the field is a pointer and the record is one dereference further on. HYPOTHESIS, not verified: this run did not follow the pointer, so it is consistent with +0x36c being a pointer to the DifficultyMods record and equally consistent with its being some other per-player sub-object pointer. What IS established is the thing the reading was for: the difficulty row is reachable from a ServerPlayer, so compute_budget.h's old coverage note ('not reachable from a ServerPlayer, so the two relevant entries are fitted constants') is wrong as written. The fitted pair {3.0, 1.5} / {1.0, 1.0} remains UNVERIFIED against the running game; the next run should dereference this field behind a readability guard and compare the selected triple against those constants [hypothesis] +constexpr uint32_t ServerPlayer_off_DiffModsPtr = 0x0000036c; +// data double 0x3FEB333340000000 = 0.85000002384185791 = (double)0.85f -- the research-yield factor, multiplied in by ComputeBudget at 0x00863601 as a QWORD operand. THE WIDTH IS THE FINDING, and what makes it a rule-23 case rather than a curiosity is its two NEIGHBOURS in the same product: 1.15 at 0x00a1a4b8 and 0.5 at 0x009e20a0 are stored as EXACT doubles, while this one is a widened float. Three literals, one expression, two widths -- so a C++ port that writes all three as decimals is wrong in exactly one place. The exact-decimal product (money/50 x 1.15 x 0.5 x 0.85) is integral when researchMoney is a multiple of 40,000, and the image's larger constant lands one ABOVE it there: at 40,000 the game gives 391 research points and the decimal gives 390. NOT VISIBLE ON THE CORPUS, and that is now measured rather than argued: the live compare run presented 9 distinct researchMoney values across 3,895 calls and NONE of them is a multiple of 40,000. Read live out of the running process as well, so nothing patched or relocated it [verified] +constexpr uint32_t g_ResearchYieldFactor = 0x0062d818; // thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified] constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480; // thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified] diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 6ad586c..824c46f 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -163,7 +163,17 @@ constexpr PhaseDesc kStrategic[] = { // --------------------------------------------------------------------------------------- constexpr PhaseDesc kPlayer[] = { {Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial, - "the formula is verified (0 divergences over 4,284 live calls) and the per-system money " + "the formula is compared clean against the live game -- lane L5, 3,895 calls on VM146, 0 " + "divergences, 0 undeclared writes -- over 5 distinct treasury states, TWO of them on the " + "interest rounding boundary, and with a control build that proves the compare can see " + "the boundary (the exact-decimal constants produce 1,359 divergences on the same run). " + "The game fills savingsInterest with 499 at a treasury of 50,000, so G3's widened-float " + "reading is now measured, not inferred; the one-money error also moves `available` and " + "`researchMoney`. TWO further rule-23 constants in this same routine are corrected and " + "UNVERIFIED: the research-yield factor is a widened 0.85f (needs a research money that " + "is a multiple of 40,000; the run presented 9 distinct values and none is) and the three " + "research modifiers are summed in single precision (needs shrm or TRM non-zero; the " + "corpus has both at 0). The per-system money " "input is now modelled on the TURN path -- ComputeOutput with the system's own rate " "sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform " "-> money cascade are all live, none of which is the max-income form T31 sums. What is " @@ -179,7 +189,9 @@ constexpr PhaseDesc kPlayer[] = { "the independent colony on BOTH reference pairs now that S11 commits civilian growth " "and the two interest rates are the widened float literals the image holds. Still " "wrong for an AI whose research rate and target are set by its own orders during the " - "turn (Rung B)"}, + "turn (Rung B). The two interest rates are now verified against the running game " + "(lane L5): the game pays 499 on 50,000 and 380 on 38,100, and the exact decimals pay " + "500 and 381"}, {Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked, "trade income, savings-given-away and research-points-given-away land on the turn record " "and on two player words that are not identified on the wire"}, @@ -307,9 +319,18 @@ constexpr PhaseDesc kTail[] = { "non-AI on every corpus save, the rest ambiguous because both columns agree there (zero " "income, or an NPC, whose row is gated on `isAI && !npc`). A player neither column " "reproduces abstains and keeps its pass-through leaf. Measured with NO operator input: 2 " - "closed, 0 regressed on EACH reference pair. What is left is BnkPr, which needs " - "BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files -- it is read `fmul dword ptr`, " - "so it is a float32 in the image and the engine now narrows it (rule 23)"}, + "closed, 0 regressed on EACH reference pair. VERIFIED AGAINST THE RUNNING GAME (lane " + "L5, VM146): the live `ServerPlayer+0xf9` and NPC flags, read on all eight players of " + "turn1-state across 3,895 calls, are exactly what this inversion claims -- one player " + "is AI-and-not-NPC, the human is neither, six are NPCs, and the awkward case (a player " + "that owns a system and is still ambiguous) reads npc=true, which is the reason lane " + "PL gave after its own prediction missed it. aidf (+0x368) reads 1 on all eight. What " + "is left is BnkPr, which needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files " + "-- it is read as a 32-bit operand, so it is a float32 in the image and the engine now " + "narrows it (rule 23). Its RUN-TIME VALUE is no longer an assumption either: read live " + "through its pointer slot it is 3.29999995231628, i.e. (float)3.3, so the data file " + "carries 3.3 and the narrowing is real. The file image of that slot is zero, so this " + "is a fact only a running process could supply"}, {Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""}, {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""}, diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp index 41792d0..4f89139 100644 --- a/src/game/sim/economy.cpp +++ b/src/game/sim/economy.cpp @@ -68,8 +68,22 @@ int ExpenseTotal(const std::vector& sliders, int availableBeforeE int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod, double shrm, double trm, double techMult, double serverResMod, double resScl) { - const double base = (static_cast(researchMoney) / 50.0) * 1.15 * 0.5 * 0.85; - const double rp = difficultyMult * base * (resMod + shrm + trm) * techMult * serverResMod * resScl; + // Rule 23, from the operand widths rather than from a decompilation. The four constants in + // this one expression are not all stored the same way: 50, 1.15 and 0.5 are exact doubles + // in the image, and the 0.85 is not -- it is the widened float `(double)0.85f`. Writing all + // four as decimals, which is what a C++ port naturally does and what this line used to do, + // is one research point LOW at every research money that is a multiple of 40,000 (the + // period of the exact-decimal product's integrality). + const double base = (static_cast(researchMoney) / 50.0) * 1.15 * 0.5 * + kResearchYieldFactor; + // The three research modifiers are NOT summed in double. The image loads each at 32-bit + // width and stores the running sum back into a 32-bit local after every add -- three such + // stores -- so the sum is rounded to single precision twice before it is used. Summing in + // double disagrees whenever the three do not add exactly. + const double modSum = static_cast(static_cast( + static_cast(static_cast(resMod) + static_cast(shrm)) + + static_cast(trm))); + const double rp = difficultyMult * base * modSum * techMult * serverResMod * resScl; return Ftol(rp); } diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h index c5a3036..2d61a34 100644 --- a/src/game/sim/economy.h +++ b/src/game/sim/economy.h @@ -121,6 +121,15 @@ struct Budget { constexpr double kSavingsInterestRate = 0.009999999776482582; // (double)0.01f constexpr double kDebtInterestRate = 0.15000000596046448; // (double)0.15f +// The research-yield factor in ResearchPointsFromMoney, and the third rule-23 constant found +// in this one routine. The image stores it as the widened float `(double)0.85f`, while its two +// neighbours in the same expression -- the 1.15 and the 0.5 -- are exact doubles. Do NOT +// "correct" those two to widened floats as well: the image really does store them exactly, and +// three constants at two different widths inside one product is the whole point. +// Not visible on the corpus: the exact-decimal product is integral only when the research money +// is a multiple of 40,000, and no reference turn is. +constexpr double kResearchYieldFactor = 0.85000002384185791; // (double)0.85f + // Savings interest: 1 % of a non-negative treasury, only for players who own systems. // CONFIDENCE: high. int SavingsInterest(int savings, bool ownsSystems); @@ -141,9 +150,14 @@ int MaintenanceCost(int maintenance, double difficultyDivisor); int ExpenseTotal(const std::vector& sliders, int availableBeforeExpenses); // Research points bought with `researchMoney`: -// RP = ftol( difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM) +// RP = ftol( difficulty x (money/50 x 1.15 x 0.5 x 0.85f) x f32(f32(f32(ResMod)+shrm)+TRM) // x techMult x serverResMod x ResScl ) // i.e. about 0.009775 RP per unit of money before multipliers. CONFIDENCE: high. +// Two rule-23 corrections live in that line and neither is visible on the corpus (lane L5): +// the 0.85 is a widened float (see kResearchYieldFactor) and the three modifiers are summed +// in SINGLE precision with a float32 store after every add. The reference turn exercises the +// boundary of neither -- the first needs a research money that is a multiple of 40,000 and the +// second needs two of the three modifiers to be non-zero, and the corpus has shrm = TRM = 0. int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod, double shrm, double trm, double techMult, double serverResMod, double resScl); diff --git a/src/shim/hooks/budget_inputs.cpp b/src/shim/hooks/budget_inputs.cpp index b6ee7a1..f6c2b90 100644 --- a/src/shim/hooks/budget_inputs.cpp +++ b/src/shim/hooks/budget_inputs.cpp @@ -225,6 +225,24 @@ Tv DescribeSnapshot(const void* data, std::size_t size, unsigned) { s.add("researchDifficultyMult", trace::tv::f64(v.researchDifficultyMult)); s.add("serverResMod", trace::tv::f64(v.serverResMod)); + // Lane L5. `live_difficulty` is the record the game holds, next to the fitted constants + // above so a reader can see whether the fit was right rather than being told. `live_consts` + // carries the four float widths this lane is about; `protectionFactor` is the only one of + // them that no static reading could have produced. + if (v.haveLiveDifficulty) { + Tv d = trace::tv::struct_(); + d.add("aidf", trace::tv::i32(v.aidf)); + d.add("diffModsPtr", trace::tv::u32(v.diffModsPtr)); + s.add("live_difficulty", std::move(d)); + } + Tv c = trace::tv::struct_(); + c.add("savingsInterestRate", trace::tv::f64(v.liveSavingsInterestRate)); + c.add("debtInterestRate", trace::tv::f64(v.liveDebtInterestRate)); + c.add("researchYieldFactor", trace::tv::f64(v.liveResearchYieldFactor)); + c.add("haveProtectionFactor", trace::tv::boolean(v.haveProtectionFactor)); + c.add("protectionFactor", trace::tv::f32(v.protectionFactor)); + s.add("live_consts", std::move(c)); + std::vector exp; for (int i = 0; i < v.expenseCount && i < kMaxExpenseEntries; ++i) { Tv e = trace::tv::struct_(); diff --git a/src/shim/hooks/budget_inputs.h b/src/shim/hooks/budget_inputs.h index 78b07ca..446c22f 100644 --- a/src/shim/hooks/budget_inputs.h +++ b/src/shim/hooks/budget_inputs.h @@ -131,9 +131,34 @@ struct BudgetSnapshot { std::int32_t aggSecondaryManagerIncome = 0; std::int32_t aggConstructionSpend = 0; // what the build queues took - // --- the difficulty-mods row (not reachable from a ServerPlayer; see the header note) --- + // --- the difficulty-mods row -------------------------------------------------------- + // These two are still the FITTED constants, kept so a run can be compared against the + // record the game actually holds (below) rather than silently switched over to it. double maintenanceDivisor = 1.0; double researchDifficultyMult = 1.0; + + // --- lane L5: what IS reachable from the player, and what is one hop further ----------- + // The old note here said the difficulty row is "not reachable from a ServerPlayer". That + // is wrong: DifficultyMods_Select(0x0059b490) is called as Select(p->+0x36c, p) by all + // three consumers. But the record is not AT +0x36c -- lane L5 read it there and got a heap + // pointer plus denormal garbage on all eight players -- so +0x36c is a POINTER and the row + // is behind it. The pointer is logged; it is deliberately not dereferenced (see the note + // in compute_budget.cpp). The fitted pair above is therefore still UNVERIFIED live. + bool haveLiveDifficulty = false; + std::int32_t aidf = -1; // ServerPlayer+0x368, the row selector. Measured: 1. + std::uint32_t diffModsPtr = 0; // ServerPlayer+0x36c, logged as a pointer, not followed + + // --- lane L5: the constant whose file image is zero ----------------------------------- + // BANKRUPTCY_PROTECTION_LIMIT_FACTOR. Lane PL-3 read its WIDTH off the instruction stream + // (a 32-bit multiply operand) and could not read its VALUE at all, because the data + // file loader fills the slot at run time. This is the one place in the campaign that can. + bool haveProtectionFactor = false; + float protectionFactor = 0.f; + // The two interest doubles, read out of the RUNNING image rather than the file, so a run + // also proves nothing patched or relocated them. + double liveSavingsInterestRate = 0.0; + double liveDebtInterestRate = 0.0; + double liveResearchYieldFactor = 0.0; // The game-option research modifier lives on the StrategyServer. The reference game was // created at research 100 %, which the save records as ResMod = 1.0, and the two players on // the human row reproduce their research points with this at 1.0. diff --git a/src/shim/hooks/compute_budget.cpp b/src/shim/hooks/compute_budget.cpp index 1882158..5fc5c31 100644 --- a/src/shim/hooks/compute_budget.cpp +++ b/src/shim/hooks/compute_budget.cpp @@ -52,6 +52,49 @@ std::int32_t VectorCount(const void* base, std::uint32_t off, std::size_t stride return static_cast(n > max ? max + 1 : n); // max+1 signals truncation } +// ---- lane L5: reads out of the RUNNING image ----------------------------------------------- +// +// Two different kinds of thing live here and the difference is the whole point. +// +// The three interest/yield doubles are `.rdata` literals: they are in the file, a disassembler +// can read them, and reading them again from the live process only proves that nothing patched +// or relocated them. They are logged for that reason and no other. +// +// The bankruptcy protection factor is not like that. Its slot is filled by the data-file +// loader at run time and the file image of the storage word is ZERO, so lane PL-3 could read +// its WIDTH off the instruction stream (a 32-bit multiply operand) and could not read +// its VALUE at all -- PL-3 assumed 3.3 to estimate a disagreement rate and said so. A running +// process is the only instrument that can answer it. + +std::uintptr_t g_exe_base = 0; + +template +bool ReadImage(std::uintptr_t rva, T* out) { + if (!g_exe_base) return false; + std::memcpy(out, reinterpret_cast(g_exe_base + rva), sizeof(T)); + return true; +} + +void ReadImageConstants(BudgetSnapshot& s) { + using namespace sots::addr; + ReadImage(g_SavingsInterestRate, &s.liveSavingsInterestRate); + ReadImage(g_DebtInterestRate, &s.liveDebtInterestRate); + ReadImage(g_ResearchYieldFactor, &s.liveResearchYieldFactor); + + // The factor is reached through a POINTER slot, so the read is two hops and either can + // fail before the loader has run. `haveProtectionFactor` distinguishes "zero" from + // "not read", which matters: a zero here would mean the shipped data files do not carry + // the key at all, and PL-3's whole disagreement analysis would be about a constant this + // install does not have. + if (!g_exe_base) return; + const void* const* slot = reinterpret_cast( + g_exe_base + PTR_g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR); + const void* storage = *slot; + if (!storage) return; + std::memcpy(&s.protectionFactor, storage, sizeof s.protectionFactor); + s.haveProtectionFactor = true; +} + // ---- per-call capture ---------------------------------------------------------------------- // // ComputeBudget runs on the server thread, one player at a time, and never re-enters itself, so @@ -100,6 +143,26 @@ void CaptureSnapshot(const void* self, bool projected) { s.maintenanceDivisor = row.maintenanceDivisor; s.researchDifficultyMult = row.researchMult; + // Lane L5: the difficulty level, read live out of the player. `aidf` is the row selector + // and it IS an int at +0x368 -- measured 1 on all eight players of the reference save. + // + // The row itself is deliberately NOT read here. The first attempt read 0x1c bytes at + // +0x36c expecting {int id; float ai[3]; float other[3]} and got a heap pointer followed + // by denormal garbage on all eight players, so the record is one dereference further on + // (ServerPlayer_off_DiffModsPtr, recorded as a HYPOTHESIS in the address table). Following + // an unvalidated pointer inside a hook that runs in the turn pipeline is how a shim + // crashes the game, and this lane cannot re-run to validate it, so the pointer is logged + // as a pointer and the dereference is left to a lane that can. + // + // What the failed read did establish is the thing it was for: the row is REACHABLE from a + // ServerPlayer. The fitted constants above still drive `ours` and are still unverified + // against the running game. + s.aidf = Peek(self, ServerPlayer_off_Aidf); + s.diffModsPtr = Peek(self, ServerPlayer_off_DiffModsPtr); + s.haveLiveDifficulty = true; + + ReadImageConstants(s); + const char* items = nullptr; s.ownedSystems = VectorCount(self, ServerPlayer_off_OwnedSystems, 4, 4096, &items); @@ -134,7 +197,10 @@ void CaptureSnapshot(const void* self, bool projected) { } // namespace -void init_compute_budget(void (*log_line)(const char* line)) { g_log_line = log_line; } +void init_compute_budget(std::uintptr_t exe_base, void (*log_line)(const char* line)) { + g_exe_base = exe_base; + g_log_line = log_line; +} void ComputeBudgetHook::describe_args(std::vector& out, void* self, std::int32_t* budget, bool projected) { diff --git a/src/shim/hooks/compute_budget.h b/src/shim/hooks/compute_budget.h index b56b588..d4af8a4 100644 --- a/src/shim/hooks/compute_budget.h +++ b/src/shim/hooks/compute_budget.h @@ -53,10 +53,28 @@ struct ComputeBudgetHook { "the six unmodelled slots, so that repair happens TWICE per turn in replace " "mode and nothing in the trace would show it", "guard:budget_object does not reach the ships; unverified"); - c.unmodelled("the difficulty-mods row from StrategyServer::GetDifficultyMods", + c.unmodelled("the difficulty-mods row still DRIVES `ours` as two fitted constants", + trace::Risk::Low, + "the old note here said the row was 'not reachable from a ServerPlayer'. " + "That was wrong: the record is at ServerPlayer+0x36c and every consumer " + "calls DifficultyMods_Select(p->+0x36c, p). Lane L5 now snapshots it and " + "emits it as `inputs.live_difficulty` alongside the fitted pair, so a run " + "SHOWS whether the fit is right instead of assuming it. `ours` was " + "deliberately left on the fitted constants so that this run is evidence " + "about them; switching it over is the next lane's one-line change", + "logged as `inputs.live_difficulty` on every record"); + c.unmodelled("three of the four float widths this routine turns on are unexercised by " + "any reference turn", trace::Risk::Medium, - "not reachable from a ServerPlayer, so the two relevant entries are fitted " - "constants measured from the B1 trace rather than snapshotted inputs"); + "the savings-interest rate is a widened 0.01f and its boundary IS in the " + "corpus (a treasury of 50,000 earns 499, not 500). The research yield " + "factor is a widened 0.85f whose boundary needs a research money that is a " + "multiple of 40,000, and the three research modifiers are summed in " + "SINGLE precision, which needs two of them non-zero -- the corpus has " + "shrm = TRM = 0. So a green compare here verifies one of the three and " + "says nothing about the other two", + "the record carries `inputs.live_consts` so the widths are readable, and " + "tests/game_sim/test_economy.cpp pins all three at their boundaries"); c.unmodelled("the research-allocation vector's heap block", trace::Risk::Low, "only the element count is compared; the three words are heap pointers the " @@ -65,6 +83,6 @@ struct ComputeBudgetHook { }; // Process facts the hook needs (a line logger for shim.log). Call once before installing. -void init_compute_budget(void (*log_line)(const char* line)); +void init_compute_budget(std::uintptr_t exe_base, void (*log_line)(const char* line)); } // namespace shim::hooks diff --git a/src/shim/main.cpp b/src/shim/main.cpp index be0ac29..efeb7f1 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -228,7 +228,7 @@ void InstallHooks(shim::trace::Tracer& tracer) { shim::hooks::init_tech_effects(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_OnTechResearched); // B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare. - shim::hooks::init_compute_budget(&ShimLogLine); + shim::hooks::init_compute_budget(exeBase, &ShimLogLine); InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget); // B4: the per-system colony turn (once per system per turn) and the two movement entry diff --git a/src/shim/shim.cfg.l5budget b/src/shim/shim.cfg.l5budget new file mode 100644 index 0000000..f33e7db --- /dev/null +++ b/src/shim/shim.cfg.l5budget @@ -0,0 +1,34 @@ +# Lane L5 -- ComputeBudget in compare mode, and nothing else. +# +# `hooks=off` short-circuits before ANY hook is installed, so the default has to be `trace` and +# every other hook is turned off by name. (Lane N's shim.cfg.output documents the same trap.) +# +# Paired with shim.cfg.l5control, which is byte-identical except that `hooks=off`, so the +# rule-19 control and this run differ in exactly one key. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::ServerPlayer::ComputeBudget=compare +trace.path=C:\SOTS\shim.trace.jsonl +trace.inline_max=256 +trace.flush=always +probes=off +watch=off diff --git a/src/shim/shim.cfg.l5control b/src/shim/shim.cfg.l5control new file mode 100644 index 0000000..bdbafe6 --- /dev/null +++ b/src/shim/shim.cfg.l5control @@ -0,0 +1,6 @@ +# Lane L5 rule-19 control: the SAME binary, no hooks installed at all. +# The only difference from shim.cfg.l5budget is this one key. +hooks=off +trace.path=C:\SOTS\shim.trace.jsonl +probes=off +watch=off diff --git a/tests/game_sim/test_economy.cpp b/tests/game_sim/test_economy.cpp index 4e922c3..ffba609 100644 --- a/tests/game_sim/test_economy.cpp +++ b/tests/game_sim/test_economy.cpp @@ -41,6 +41,24 @@ static void test_research_points() { CHECK_EQ(ResearchPointsFromMoney(0, 1, 1, 0, 0, 1, 1, 1), 0); // 100 money -> 0.9775 -> 0 CHECK_EQ(ResearchPointsFromMoney(100, 1, 1, 0, 0, 1, 1, 1), 0); + + // --- rule 23, and the two boundaries the corpus cannot see (lane L5) --------------------- + // The yield factor is (double)0.85f, not the decimal 0.85. The exact-decimal product is an + // integer exactly when researchMoney is a multiple of 40,000, and the image's slightly + // larger constant lands one ABOVE it there. Every case above is away from that boundary, + // which is why none of them moved when the constant was corrected -- and why the live + // compare stays green on a reference turn. + CHECK_EQ(ResearchPointsFromMoney(40000, 1, 1, 0, 0, 1, 1, 1), 391); // decimal 0.85 -> 390 + CHECK_EQ(ResearchPointsFromMoney(80000, 1, 1, 0, 0, 1, 1, 1), 782); // decimal 0.85 -> 781 + // 120,000 is a multiple of 40,000 and still agrees: the exact-decimal product rounds UP to + // 1173.0 there rather than landing below it. The boundary is a property of the product, not + // of the multiple, so the period is necessary and not sufficient. + CHECK_EQ(ResearchPointsFromMoney(120000, 1, 1, 0, 0, 1, 1, 1), 1173); + + // The three modifiers are summed in SINGLE precision with a float32 store after every add. + // 0.1 + 0.33 + 0.75 is 1.1800000667572021 in float32 and 1.180000014603138 in double, and + // at 4,855 money that is the difference between 56 RP and 55. + CHECK_EQ(ResearchPointsFromMoney(4855, 1, 0.1f, 0.33f, 0.75, 1, 1, 1), 56); // double -> 55 } static void test_expenses() {