sots-re/findings/subsystems/money-chain-float-widths.md
alex ea7881acb3 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.
2026-09-08 17:49:35 -04:00

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.