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.
5.5 KiB
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.