L5: the money chain's float widths, measured live at a boundary
Four float constants in ComputeBudget have a width a C++ port gets wrong by default, and the corpus exercises the boundary of exactly one. That one is now measured against the running game, with a control build that must fail and does. savings interest (double)0.01f boundary: treasury a multiple of 100 IN CORPUS debt interest (double)0.15f boundary: a negative treasury not in corpus research yield (double)0.85f boundary: research money mult of 40k not in corpus the three ResMod summed in f32 boundary: two of three non-zero not in corpus Three constants sitting beside those, in the same expressions, are EXACT doubles and must not be "corrected" -- so "widen every literal" is its own defect and the only safe procedure is to read each operand. Same pattern elsewhere in the chain: ComputeOutputFromRates multiplies by an exact 1.5 then a widened 1.2f two instructions later, and NormaliseOutputRates holds 1e-4 at BOTH widths. Live result (VM146, turn1-state.sav, three runs): the game pays 499 interest on a treasury of 50,000 and 380 on 38,100; the exact decimals pay 500 and 381. Every divergence in the control build lands on a multiple of 100 and nothing else diverges at all. Coverage stated as distinct states, not calls: 5 distinct treasuries, 2 on the boundary -- against the earlier green run's 20 distinct states, none on a boundary. BANKRUPTCY_PROTECTION_LIMIT_FACTOR read live: 3.29999995 = (float)3.3. Its file image is zero because the loader fills it at run time, so lane PL-3 could read the width and had to assume the value. The assumption was right and is now measured. Falsified: the difficulty-mods record does not sit inline at ServerPlayer+0x36c; that field is a heap pointer on all eight players. Recorded as a hypothesis with the measurement, not as a fact. Second finding, from the rule-19 control: turn1-state -> turn2 is NOT a deterministic pair. Three runs gave three post-turn autosaves differing in exactly four leaves -- one Singularity shadow empire's research pick and the derived checksum. Two of the three runs carried identical hooks, and the un-instrumented run was a third value, so this is the game and not the instrument. The determinism oracle stands for ref-turn2 -> turn3 and does not generalise to its neighbour; no lane should use this pair as a byte-match oracle.
This commit is contained in:
parent
2d6184888f
commit
ea7881acb3
4 changed files with 270 additions and 0 deletions
100
findings/subsystems/money-chain-float-widths.md
Normal file
100
findings/subsystems/money-chain-float-widths.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# The money chain's float widths, and what the corpus can and cannot see
|
||||
|
||||
Lane L5, 2026-09-08. Guest VM146. Instrument: the shim's `compare` mode on
|
||||
`Game::ServerPlayer::ComputeBudget`, plus an operand-width sweep of the money chain.
|
||||
|
||||
Rule 23 says: read the operand widths, not the decompilation. This is the sweep that says how
|
||||
many places in the money chain that actually matters, and — for the first time in the campaign —
|
||||
a **live boundary test with a control build that must fail**.
|
||||
|
||||
## 1. The result in one table
|
||||
|
||||
`ComputeBudget` turns on **four** float constants whose width a C++ port gets wrong by default.
|
||||
The corpus exercises the boundary of exactly one of them.
|
||||
|
||||
| constant | image storage | boundary condition | in the corpus? |
|
||||
|---|---|---|---|
|
||||
| savings-interest rate | `(double)0.01f` | treasury a multiple of 100 | **YES** — 50,000 and 38,100 |
|
||||
| debt-interest rate | `(double)0.15f` | negative treasury, multiple of 20/3 | no — no player is in debt |
|
||||
| research-yield factor | `(double)0.85f` | research money a multiple of 40,000 | **no** — 9 distinct values, none is |
|
||||
| the three research modifiers | summed in **float32**, with a store after every add | two of the three non-zero | **no** — `shrm` = `TRM` = 0 |
|
||||
|
||||
And three constants sitting *beside* those in the same expressions are **exact doubles** in the
|
||||
image and must not be "corrected": the 1.15 and the 0.5 in the research product, and the 0.01 and
|
||||
1.65 in the infrastructure term. `ServerSystem::ComputeOutputFromRates` likewise multiplies by an
|
||||
exact 1.5 and then by a widened `1.2f` two instructions later, and
|
||||
`ServerSystem::NormaliseOutputRates` holds the *same* constant `1e-4` at both widths — a 64-bit
|
||||
one for the threshold compare and a 32-bit one for the all-zero fallback seed.
|
||||
|
||||
**So "widen every float literal" is its own defect.** The only safe procedure is to read each
|
||||
operand.
|
||||
|
||||
Cleanly free of the problem: `SetBankruptcyState` and `BankruptcyLevel` (no x87 at all),
|
||||
`GrowCivilianPops` (an all-integer path), `PopGrowthDelta` and `MaxPopGeneric` (their constants —
|
||||
1.5, 0.5, 0.75, 1e8 — are exactly representable).
|
||||
|
||||
## 2. The live boundary test
|
||||
|
||||
Three runs from `turn1-state.sav`, where the human and the AI empire both hold a treasury of
|
||||
exactly 50,000. Same DLL for the first two, `hooks=off` vs `compare`; the third is the same
|
||||
source with the interest constants replaced by the exact decimals.
|
||||
|
||||
Run A (widened, the engine as shipped): **3,895 calls, 0 divergences, 0 undeclared writes.**
|
||||
The game fills `savingsInterest` with **499** at a treasury of 50,000.
|
||||
|
||||
Run B (exact decimals — the build that must fail): **1,359 divergences of 2,718 calls**, and
|
||||
they land exactly where the arithmetic says:
|
||||
|
||||
| treasury | multiple of 100? | calls | diverged | game | 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 |
|
||||
|
||||
Two independent treasuries, both on the boundary, both agreeing with the widened float. The
|
||||
reading is now a measurement.
|
||||
|
||||
**The error is not confined to one slot.** A diverging call moves three fields — the interest is
|
||||
an income line, so it propagates into `available` and into `researchMoney` (1,317 of the 1,359).
|
||||
|
||||
## 3. Why the earlier green run was green
|
||||
|
||||
`ComputeBudget` had previously compared **4,437 calls with 0 divergences** while carrying the
|
||||
wrong constant. This run settles that it was not an instrument defect: slot 5 *is* diffed, and the
|
||||
control build produced 1,359 divergences on it. The earlier workload simply presented no treasury
|
||||
that was a multiple of 100.
|
||||
|
||||
The number to report next to a compare verdict is therefore **not the call count**. Run A:
|
||||
|
||||
> 3,895 calls, **5 distinct treasury states, 2 of them on the rounding boundary**, 0 divergences.
|
||||
|
||||
versus the earlier run's 4,437 calls over 20 distinct states, none on a boundary. The second run
|
||||
has fewer calls and far more evidence.
|
||||
|
||||
## 4. The constant the file cannot hold
|
||||
|
||||
`BANKRUPTCY_PROTECTION_LIMIT_FACTOR` is filled by the data-file loader at run time; the file image
|
||||
of its storage word is zero. Lane PL read its *width* off the instruction stream and had to
|
||||
*assume* its value (3.3) to estimate a disagreement rate.
|
||||
|
||||
Read live through the pointer slot at `0x00aedfdc`, on every one of 3,895 calls, it is
|
||||
**3.29999995231628417968750** — exactly `(float)3.3`. The data file carries 3.3, PL's assumption
|
||||
was right, and the narrowing PL predicted is real: the number the game multiplies by is the
|
||||
nearest float32 below 3.3, which is why a `double` multiply disagrees at every max income that is
|
||||
a multiple of ten.
|
||||
|
||||
## 5. What was falsified
|
||||
|
||||
The difficulty-mods record does **not** sit inline at `ServerPlayer+0x36c`. Read there on all
|
||||
eight players it is a heap pointer (`0x0da0ec20`…`0x0da13360`, within 18 KB of the `ServerPlayer`
|
||||
allocations) followed by denormal garbage. The record is one dereference further on. The prediction
|
||||
that the row is *reachable from a `ServerPlayer`* survives — which corrects the shim's standing
|
||||
note that it is not — but the fitted pair `{3.0, 1.5}` / `{1.0, 1.0}` is still unverified against
|
||||
the game, and the pointer is logged rather than followed (an unvalidated dereference inside a
|
||||
turn-pipeline hook is how a shim crashes the game). `ServerPlayer_off_DiffModsPtr` is in the
|
||||
address table as a hypothesis with the measurement attached.
|
||||
|
||||
`ServerPlayer+0x368` (`aidf`) **is** confirmed: 1 on all eight players, the level the corpus
|
||||
carries.
|
||||
55
findings/subsystems/turn1-to-turn2-nondeterminism.md
Normal file
55
findings/subsystems/turn1-to-turn2-nondeterminism.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# `turn1-state → turn2` is not a deterministic pair
|
||||
|
||||
Lane L5, 2026-09-08, VM146. A by-product of a rule-19 control, and a caution for any lane that
|
||||
reaches for this pair as an oracle.
|
||||
|
||||
## What was measured
|
||||
|
||||
Three End Turns from the same input save, through the identical UI path, in three freshly launched
|
||||
processes:
|
||||
|
||||
| run | shim | `(Autosave).sav` sha256[0:16] | `Player[512 "Singularity"]/ResTNm` |
|
||||
|---|---|---|---|
|
||||
| control | `hooks=off` (no MinHook at all) | `62061918176e3441` | `XNC_TrnsLir2` |
|
||||
| A | `ComputeBudget=compare` | `7fc9e6ab46b47794` | `XNC_TrnsHvr2` |
|
||||
| B | same hooks, different arithmetic on OUR side only | `ab4ac2d7e2977260` | `BIO_GnMod` |
|
||||
|
||||
Every pairwise diff is **exactly four leaves**: that research-target name, two of that player's
|
||||
tech-tree status words, and the derived `/Summary/Checksum`. Nothing else in the state moves.
|
||||
|
||||
## Why this is not the instrument
|
||||
|
||||
* Runs A and B carry **identical hooks**. They differ only in a constant on our side of the
|
||||
compare, and compare mode runs `ours` on scratch copies and never lets it touch game memory. A
|
||||
hook-induced perturbation cannot produce A ≠ B.
|
||||
* The `hooks=off` control is a **third** value, not one of the other two.
|
||||
* Run B reproduced `ab4ac2d7e2977260…`, which is the hash `running-the-game.md` records for the
|
||||
original live session's `Autosave - turn2.sav`. So the un-instrumented behaviour is inside the
|
||||
observed spread.
|
||||
* All three runs wrote a **byte-identical pre-turn autosave** (`a3f9dc4b49fc669c…`). The loader and
|
||||
the writer are deterministic; only the turn's AI decision is not.
|
||||
|
||||
## Why it matters
|
||||
|
||||
`determinism-oracle.md` established byte-reproducibility on **`ref-turn2 → turn3`** — five runs,
|
||||
two processes, one pair of hashes. That result is sound and is not contradicted here. What is new
|
||||
is that **it does not generalise**: the neighbouring pair in the same game is unstable, in one
|
||||
shadow empire's AI research pick.
|
||||
|
||||
Consequences:
|
||||
|
||||
* Do not use `turn1-state → turn2` as a byte-match oracle. Use `ref-turn2 → turn3`.
|
||||
* A standalone comparison against a `turn1-state → turn2` post-save is comparing against one draw
|
||||
from a distribution. The four unstable leaves are `ResTNm` and two `St[n]` words on
|
||||
`Player[512]`, plus `Checksum`.
|
||||
* This corroborates lane PL's placement of `ResTNm` / `ResErrRoll` on Rung B for the Singularity
|
||||
shadow empires: those leaves are not merely AI-dependent, they are **not reproducible**, so no
|
||||
amount of modelling the AI's *rules* will close them without also reproducing whatever the pick
|
||||
is actually keyed on.
|
||||
|
||||
## What was not established
|
||||
|
||||
The cause. The candidates are the shadow empires' `ResErrRoll` (a research error roll that lane PL
|
||||
saw flip `False → True`, i.e. an RNG consumer on exactly this player class) and anything in the AI
|
||||
path keyed on wall-clock time or frame count rather than on the saved generator. Distinguishing
|
||||
them is a probe on the pick site, not another End Turn.
|
||||
28
ghidra/addresses.d/lane-l5.json
Normal file
28
ghidra/addresses.d/lane-l5.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"entries": [
|
||||
{
|
||||
"name": "ServerPlayer_off_Aidf",
|
||||
"offset": "0x368",
|
||||
"convention": "offset",
|
||||
"status": "verified",
|
||||
"prototype": "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",
|
||||
"source": "lane L5 live read on VM146 2026-09-08 (shim compare run l5A-widened, 3,895 ComputeBudget records)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_off_DiffModsPtr",
|
||||
"offset": "0x36c",
|
||||
"convention": "offset",
|
||||
"status": "hypothesis",
|
||||
"prototype": "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",
|
||||
"source": "lane L5 live read on VM146 2026-09-08; the prediction that the record sits inline at +0x36c was FALSIFIED by the run (docs/L5-live-verification.md E3-c)"
|
||||
},
|
||||
{
|
||||
"name": "g_ResearchYieldFactor",
|
||||
"addr": "0x00a2d818",
|
||||
"convention": "data",
|
||||
"status": "verified",
|
||||
"prototype": "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",
|
||||
"source": "lane L5 operand-width sweep of the money chain 2026-09-08, plus the live read on VM146 (inputs.live_consts.researchYieldFactor == 0.8500000238418579 on all 3,895 records)"
|
||||
}
|
||||
]
|
||||
}
|
||||
87
verify/results/compare/l5-budget-boundary.md
Normal file
87
verify/results/compare/l5-budget-boundary.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# L5 — `ComputeBudget` compare at the interest boundary, with a failing control
|
||||
|
||||
VM146, 2026-09-08. Input save `turn1-state.sav` (= `Autosave EndTurn - turn2.sav`, 64,967 B,
|
||||
sha256 `a3f9dc4b49fc669c…`). Identical UI path for all three runs.
|
||||
|
||||
## Runs
|
||||
|
||||
| run | build | `hooks` | `ComputeBudget` | dll md5 |
|
||||
|---|---|---|---|---|
|
||||
| control | `l5A-widened` | `off` | not installed | `F8DFC4AEE31FA60C5A2BE1E70CC38633` |
|
||||
| A | `l5A-widened` | `trace` | `compare` | `F8DFC4AEE31FA60C5A2BE1E70CC38633` |
|
||||
| B | `l5B-decimal-CONTROL` | `trace` | `compare` | `0FCC19DC2B9A769DB5FE2C4DC4683C78` |
|
||||
|
||||
A and B are the same source except `kSavingsInterestRate` / `kDebtInterestRate`: widened floats
|
||||
in A, exact decimals in B. That is our side of the compare only.
|
||||
|
||||
## tracecmp
|
||||
|
||||
```
|
||||
A calls 3895 compared 3895 diverged 0 invalid 0 undeclared writes 0
|
||||
B calls 2718 compared 2718 diverged 1359 invalid 0 undeclared writes 0
|
||||
```
|
||||
|
||||
## Coverage — distinct states, not call counts
|
||||
|
||||
| treasury (`Sav`) | multiple of 100 | calls (A) | calls (B) | diverged (B) | game `savingsInterest` | exact decimal |
|
||||
|---:|---|---:|---:|---:|---:|---:|
|
||||
| 0 | — | 303 | 303 | 0 | 0 | 0 |
|
||||
| 38,100 | **yes** | 1 | 1 | **1** | 380 | 381 |
|
||||
| 50,000 | **yes** | 1,371 | 1,358 | **1,358** | **499** | 500 |
|
||||
| 92,651 | no | 28 | 28 | 0 | 926 | 926 |
|
||||
| 289,688 | no | 2,192 | 1,028 | 0 | 2,896 | 2,896 |
|
||||
|
||||
**5 distinct treasury states, 2 on the rounding boundary.** Compare with the run this replaces:
|
||||
4,437 calls, 20 distinct states, none on a boundary.
|
||||
|
||||
`projected` split in A: 3,888 turn-path, 7 UI.
|
||||
|
||||
Diverging fields in B (per call): `savingsInterest` 1,359, `available` 1,359, `researchMoney`
|
||||
1,317. Sample diff at `Sav = 50,000`:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Live constants, read on every one of A's 3,895 records
|
||||
|
||||
```
|
||||
savingsInterestRate 0.009999999776482582 = (double)0.01f
|
||||
debtInterestRate 0.15000000596046448 = (double)0.15f
|
||||
researchYieldFactor 0.8500000238418579 = (double)0.85f
|
||||
protectionFactor 3.29999995 = (float)3.3 [slot non-null; file image is zero]
|
||||
```
|
||||
|
||||
## Live per-player flags (all 8 players of `turn1-state`)
|
||||
|
||||
| PlyrIdx | isAI | npc | ownedSystems | aidf |
|
||||
|---:|---|---|---:|---:|
|
||||
| 0 | false | false | 1 | 1 |
|
||||
| 1 | **true** | false | 1 | 1 |
|
||||
| 2 | true | true | 0 | 1 |
|
||||
| 3 | true | true | 0 | 1 |
|
||||
| 4 | true | true | 0 | 1 |
|
||||
| 5 | true | true | 0 | 1 |
|
||||
| 6 | true | true | 0 | 1 |
|
||||
| 7 | true | true | **1** | 1 |
|
||||
|
||||
Exactly one AI-and-not-NPC player; the human is neither; player 7 owns a system and is an NPC,
|
||||
which is why T31 calls it ambiguous.
|
||||
|
||||
`researchMoney`: 9 distinct values across A (`0, 24717, 59772, 59922, 60521, 68047, 217752,
|
||||
218204, 218508`). **None is a multiple of 40,000**, so the research-yield-factor width is
|
||||
unexercised.
|
||||
|
||||
## Autosaves
|
||||
|
||||
| run | `(Autosave EndTurn).sav` | `(Autosave).sav` |
|
||||
|---|---|---|
|
||||
| control | `a3f9dc4b49fc669c…` | `62061918176e3441…` |
|
||||
| A | `a3f9dc4b49fc669c…` | `7fc9e6ab46b47794…` |
|
||||
| B | `a3f9dc4b49fc669c…` | `ab4ac2d7e2977260…` |
|
||||
|
||||
Pre-turn identical in all three. Post-turn differs by exactly four leaves per pair, all in one
|
||||
shadow empire's AI research pick — **not the hook**; see
|
||||
`findings/subsystems/turn1-to-turn2-nondeterminism.md`.
|
||||
Loading…
Add table
Reference in a new issue