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.
87 lines
3.3 KiB
Markdown
87 lines
3.3 KiB
Markdown
# 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`.
|