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.
100 lines
5.5 KiB
Markdown
100 lines
5.5 KiB
Markdown
# 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.
|