L5: the interest literals verified live at a boundary, with a control that fails
`ComputeBudget`'s savings-interest term is now compared against the running game at
a treasury the corpus actually contains. Three runs on VM146 from turn1-state.sav:
A (widened floats, as shipped) 3,895 calls, 0 diverged, 0 undeclared writes
B (exact decimals, the control) 2,718 calls, 1,359 diverged
The game fills savingsInterest with 499 at a treasury of 50,000, and with 380 at
38,100 -- the exact decimals pay 500 and 381. Every divergence in B lands on a
treasury that is a multiple of 100 and no other state diverges at all, which is
exactly the arithmetic. G3's rule-23 reading is now measured, not inferred, and the
one-money error is shown to propagate into `available` and `researchMoney` too.
The control also settles why the earlier 4,437-call green run was green: slot 5 IS
diffed and the harness CAN see it, so that run simply presented no boundary state.
Coverage is therefore reported as distinct states, not calls: 5 distinct treasuries,
2 of them on the boundary.
Two further rule-23 constants found in the same routine by an operand-width sweep,
corrected, and honestly marked UNVERIFIED because no reference turn can see them:
- the research-yield factor is a widened 0.85f while its two neighbours in the
same product are exact doubles. Boundary: research money a multiple of 40,000;
the run presented 9 distinct values and none is.
- the three research modifiers are summed in single precision, not double.
Boundary: two of the three non-zero; the corpus has shrm = TRM = 0.
Both are pinned by boundary cases in test_economy.cpp that fail with the decimals.
Also verified live, in the same run:
- T31's difficulty-column recovery. The live ServerPlayer+0xf9 / NPC flags on all
eight players are exactly what lane PL's save-only inversion claims, including
the awkward system-owning player that is still ambiguous because it is an NPC.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR reads 3.29999995 = (float)3.3. Its file image
is zero because the loader fills it at run time, so lane PL-3 had to assume the
value; it is now measured and the assumption was right.
Falsified, and recorded as such: the difficulty-mods record does NOT sit inline at
ServerPlayer+0x36c -- that field is a heap pointer on all eight players. The row IS
reachable from a ServerPlayer (which corrects the hook's standing coverage note),
but the fitted {3.0,1.5}/{1.0,1.0} pair remains unverified. The hook logs the
pointer and does not follow it.
The `verified` column stays 0, deliberately. Every phase this compare touches is
Partial for reasons upstream of it, and promoting one because part of it was checked
is the drift app_test_catalog exists to catch. What moved is models; see
docs/L5-live-verification.md for each one with its coverage.
Gates run separately: clean-room OK, host ctest 54/54, CT111 shim cross-build exit 0.
This commit is contained in:
parent
011749571b
commit
b39bb290e3
13 changed files with 722 additions and 16 deletions
428
docs/L5-live-verification.md
Normal file
428
docs/L5-live-verification.md
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
# L5 — the standalone's newest models, checked against the running game
|
||||
|
||||
Lane L5, 2026-09-08. Worktree `wip/l5` off `main` `0117495`. Guest **VM146** (`192.168.10.146`),
|
||||
a ZFS clone of the reference guest VM140.
|
||||
|
||||
The `verified` column of `src/app/phase_catalog.cpp` has read **0** all campaign, by construction:
|
||||
`app_test_catalog` asserts it, because in that table `verified` means *compared against the live
|
||||
game* and no lane that wrote a phase held a VM. This lane holds one. Everything below is written
|
||||
**before** the builds and before any run.
|
||||
|
||||
---
|
||||
|
||||
## 0. What a live compare can settle that a host test cannot
|
||||
|
||||
Three of this lane's four targets are claims about **values the executable never stores in a file**
|
||||
or about **rounding the host cannot reproduce**:
|
||||
|
||||
* a data-file constant whose file image is zero because the loader fills it at run time
|
||||
(`BANKRUPTCY_PROTECTION_LIMIT_FACTOR`, lane PL);
|
||||
* the per-player "is AI" flag at `ServerPlayer+0xf9`, which no save carries and which lane PL's
|
||||
T31 recovers *by inference from the stored `BnkEl`*;
|
||||
* the game's own x87 rounding of a product whose error exceeds half an ulp of the answer
|
||||
(lane G3's rescale).
|
||||
|
||||
And one is a claim about a **boundary the corpus contains but the existing compare never reported
|
||||
on** (rule 23, `ComputeBudget`'s interest literals).
|
||||
|
||||
---
|
||||
|
||||
## 1. E1 — the interest literals, at the boundary, with a control that must fail
|
||||
|
||||
### The setting
|
||||
|
||||
Lane G3 corrected `ComputeBudget` to multiply the treasury by **widened float** literals —
|
||||
`kSavingsInterestRate = 0.009999999776482582` = `(double)0.01f` and
|
||||
`kDebtInterestRate = 0.15000000596046448` = `(double)0.15f` — because the image loads
|
||||
`0x009e31c0` and `0x009ed188`, which hold those widened values, not the exact decimals. The
|
||||
consequence G3 states is that **a treasury of exactly 50,000 earns 499, not 500.**
|
||||
|
||||
G3 did not measure that against the game. It inferred it from a one-money residual on the human's
|
||||
savings, and it wrote down, honestly, that the behavioural compare had not caught it:
|
||||
`ComputeBudget` had run **4,437 calls with 0 divergences** and "that run presented only 20 distinct
|
||||
states and none of them sat on a rounding boundary."
|
||||
|
||||
### The fact that makes this decidable in one End Turn
|
||||
|
||||
`turn1-state.sav` (= `Autosave EndTurn - turn2.sav`, 64,967 B) carries **exactly two distinct
|
||||
treasuries across its eight players**:
|
||||
|
||||
| `Sav` | players | multiple of 100? |
|
||||
|---:|---:|---|
|
||||
| 50,000 | 2 (the human at `@0000f2a8`, the AI empire at `@0001b3d8`) | **yes** |
|
||||
| 0 | 6 | yes, trivially |
|
||||
|
||||
`SavingsInterest` gates on `savings >= 0 && ownsSystems`, and the interest term diverges between
|
||||
the two constants exactly when `savings` is a **multiple of 100**:
|
||||
|
||||
* exact decimal: `50000 x 0.01 = 500.0` → `trunc` = **500**
|
||||
* image widened: `50000 x 0.009999999776482582 = 499.9999888241291` → `trunc` = **499**
|
||||
|
||||
So the corpus **does** sit on the boundary, and slot 5 (`kSlotSavingsInterest`) is **not** in the
|
||||
declared input boundary — `IsInputSlot` names slots 1, 2, 3, 4, 7 and 11, and 5 is not among them,
|
||||
so `FillSlots` writes `b.savingsInterest` from *our* arithmetic and the harness diffs it.
|
||||
|
||||
That is a contradiction worth stating plainly before the run: **if slot 5 is compared, and the
|
||||
boundary is in the corpus, the 4,437-call run should have diverged and did not.** Either the
|
||||
4,437 calls were not on this save, or something about the compare does not reach slot 5. The run
|
||||
below distinguishes those two, and that is the point of the control build.
|
||||
|
||||
### E1 predictions
|
||||
|
||||
**E1-a.** Under build **A** (`main`, G3's widened constants), `hook.Game::ServerPlayer::ComputeBudget=compare`,
|
||||
one End Turn from `turn1-state.sav`: **0 divergences**, and the original fills slot 5 with **499**
|
||||
for the human and **499** for the AI empire, **0** for the other six.
|
||||
|
||||
**E1-b (the control that must fail).** Under build **B** — byte-identical to A except
|
||||
`kSavingsInterestRate = 0.01` and `kDebtInterestRate = 0.15`, the exact decimals — the same run
|
||||
reports **exactly 2 divergences**, both on `side.budget.after.v.savingsInterest`, both
|
||||
`ours = 500` against `orig = 499`, on the two players whose `Sav` is 50,000 and who own systems.
|
||||
Every other slot and every other player agrees.
|
||||
|
||||
**E1-c (rule 19).** Build B changes only `ours()`. `compare` mode never lets ours touch game
|
||||
memory. So the **autosave written by build B must be byte-identical to the one written by build A,
|
||||
and both must be byte-identical to a `hooks=off` control run.** Three files, one hash.
|
||||
|
||||
**E1-d (coverage, rule 1 / rule 15).** The run will report a call count in the low tens, not
|
||||
thousands, and a **distinct-state count of 2** on the treasury input. That number goes next to the
|
||||
verdict. A green compare over 2 distinct states is worth reporting as 2, not as "clean".
|
||||
|
||||
### Falsification
|
||||
|
||||
* *The original fills slot 5 with 500.* Then G3's rule-23 reading is **wrong**, the correction
|
||||
introduced a defect, and the residual it closed did so for another reason. This is the outcome
|
||||
that would matter most, and it is why the run is worth making.
|
||||
* *Build B also reports 0 divergences.* Then slot 5 is not actually reaching the diff — an
|
||||
**instrument** defect, not an engine one, and it would retroactively explain the 4,437 green
|
||||
calls. Symptom to check: whether the record carries `side.budget.after` at all, and whether the
|
||||
human's call appears in the log.
|
||||
* *Build B diverges on more than 2 calls, or on a player whose `Sav` is 0.* Then `SavingsInterest`
|
||||
is reached on a path that ignores its `ownsSystems` gate, or the UI's `projected=true` calls are
|
||||
in the log with treasuries the save does not carry — in which case the distinct-state count is
|
||||
larger than 2 and the whole coverage claim above has to be restated from the log.
|
||||
* *The autosaves differ between A, B and the `hooks=off` control.* Then compare mode is leaking
|
||||
into game memory, which would invalidate every compare result the campaign has recorded, not
|
||||
just this one.
|
||||
|
||||
---
|
||||
|
||||
## 2. E2 — T31's difficulty column, checked against the flag the save does not carry
|
||||
|
||||
Lane PL's T31 identifies which difficulty column applies **from the save alone**, with no operator
|
||||
input, on all 11 saves, by computing `BnkEl` under both columns and matching the stored value. Its
|
||||
own resolution is "**1 AI and 1 non-AI on every one of the eleven saves**, with the rest ambiguous",
|
||||
and §7 records that "ambiguous" has two causes — a player that owns nothing, and player 576, an
|
||||
**NPC**, for which the row selector `isAI && !npc` hands both columns the same row.
|
||||
|
||||
The running game holds the answer directly. `ServerPlayer_off_IsAI = 0xf9` and
|
||||
`ServerPlayer_off_NPC` are **already snapshotted** by `compute_budget.cpp` and already emitted into
|
||||
every trace record as `inputs.isAI` / `inputs.npc`. So E1's run produces, per player per turn, the
|
||||
live values of the two bits T31 infers — **at no extra cost and in the same run**.
|
||||
|
||||
**E2-a.** The live flags on `turn1-state`, for the eight players, are: `isAI=false, npc=false` for
|
||||
the human; `isAI=true, npc=false` for exactly one player (the AI empire); and for the remaining
|
||||
six, at least one with `npc=true` — the one T31 calls ambiguous-because-NPC (player 576, the
|
||||
Independent Colony).
|
||||
|
||||
**E2-b.** T31's save-only identification agrees with the live flags on **every player it does not
|
||||
call ambiguous**, and every player it *does* call ambiguous is either `maxIncome == 0` or
|
||||
`npc == true`. If both hold, T31's recovery is verified against the game rather than against its
|
||||
own inversion.
|
||||
|
||||
### Falsification
|
||||
|
||||
* *A player T31 identifies as AI reads `isAI=false` live.* T31's inversion is fitting noise; the
|
||||
two columns are being distinguished by something other than the ×1.1, and PL-2's leaf closures
|
||||
are coincidence.
|
||||
* *More than one player reads `isAI=true && npc=false`.* Then "1 AI and 1 non-AI on every save" is
|
||||
a property of the inversion, not of the roster, and the ambiguous bucket is hiding real AI
|
||||
players whose max income happens to be 0.
|
||||
* *No player reads `npc=true`.* Then §7's explanation of the sixth ambiguous player is wrong and
|
||||
the ambiguity has a third cause.
|
||||
|
||||
---
|
||||
|
||||
## 3. E3 — the constants the file image cannot hold
|
||||
|
||||
Two of the campaign's live claims rest on values that are **zero in the executable on disk**
|
||||
because the data-file loader writes them at run time, and on a table the shim reads at fitted
|
||||
constants:
|
||||
|
||||
* `g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR` at `0x00b23e28`, reached through the pointer slot
|
||||
`0x00aedfdc`. Lane PL-3 read its *width* off the instruction stream (`D8 09` = `fmul m32fp`, a
|
||||
float32, two instructions after `DD 05` = `fld m64fp`, a double) and could not read its *value*
|
||||
at all: "the file image of the slot is zero: the loader fills it at run time". PL-3 assumed 3.3
|
||||
to compute the disagreement rate and said so.
|
||||
* the difficulty-mods triple. `budget_inputs.h` carries `kDifficultyAI{3.0, 1.5}` and
|
||||
`kDifficultyHuman{1.0, 1.0}` as **fitted constants "measured from the B1 trace rather than
|
||||
snapshotted inputs"**, with the coverage note "not reachable from a ServerPlayer".
|
||||
|
||||
**E3-a.** Read live, the protection factor is a finite positive float32, and it is **exactly
|
||||
representable as a float32** — i.e. `(float)value == value` — which is the whole content of PL-3's
|
||||
width claim. Its value is **not** predicted here; PL-3's 3.3 is an assumption and this run replaces
|
||||
it with a measurement.
|
||||
|
||||
**E3-b.** The difficulty row the game hands the AI empire has its fleet-maintenance divisor and its
|
||||
research multiplier equal to the fitted `{3.0, 1.5}`, and the human's equal to `{1.0, 1.0}`.
|
||||
|
||||
**E3-c.** `ServerPlayer+0x8` points into the `StrategyServer` frame (the address table records
|
||||
`ServerPlayer+0x8 == S+4`), so the difficulty record **is** reachable from a `ServerPlayer` and the
|
||||
coverage note "not reachable from a ServerPlayer" is wrong as written.
|
||||
|
||||
### Falsification
|
||||
|
||||
* *The slot reads zero or unreadable at the turn gate.* Then the loader has not run, or the key is
|
||||
absent from this install's data files, and PL-3's whole disagreement analysis is about a constant
|
||||
the shipped game does not have. Symptom: `BnkPr` is 0 for every player, which the corpus can be
|
||||
checked against.
|
||||
* *The fitted difficulty pair disagrees with the live row.* Then B1's fit was on a workload that
|
||||
happens to match and `ComputeBudget`'s maintenance and research terms are wrong off-corpus.
|
||||
* *`ServerPlayer+0x8` does not resolve to a plausible server frame.* Then E3-c is wrong and the
|
||||
coverage note stands; the value has to come from a hook on the server instead.
|
||||
|
||||
---
|
||||
|
||||
## 4. What is deliberately NOT attempted, and why
|
||||
|
||||
**G3's civilian growth is not compared on a stock End Turn.** The instrument would be a new
|
||||
`compare` hook on `ServerSystem::GrowCivilianPops` (`0x00754220`, verified thiscall, no stack
|
||||
args, `void`), whose verdict is the `Population` rows behind `ServerSystem+0x1a0`. On the reference
|
||||
save that run presents ~28 calls of which 2–3 have civilians, **all single-species, all at ideal
|
||||
suitability, none blockaded, none seeding, none at the settle limit** — the degenerate case in
|
||||
which G3's rescale `trunc(applied x (clamped/total))` is *exact by construction* and the clamp is
|
||||
the only thing being tested. That is the same shape as the 4,437-call green run this lane exists to
|
||||
distrust: it would come back clean and license nothing.
|
||||
|
||||
Recording it as a measured decision rather than an omission: **the civilian-growth compare is worth
|
||||
making only on a workload the corpus lacks** — a multi-species colony (the rescale's truncation and
|
||||
its lack of renormalisation), a blockaded colony (`haltv[1]`), a colony over its settle limit (the
|
||||
`-50,000,000` floor), or a newly landed colony ship (the seeding loop). Each of those turns one row
|
||||
of G3 §4.4's falsification table from "invisible here" into a measurement. Building such a save is
|
||||
a game-play session, not an instrument change, and it is the right next lane's work.
|
||||
|
||||
---
|
||||
|
||||
*Outcomes are recorded in §5 onward, after the runs, next to the predictions above.*
|
||||
|
||||
---
|
||||
|
||||
# 5. Outcomes
|
||||
|
||||
Three runs on **VM146**, all from `turn1-state.sav` (the Turn-1 save, human and AI empire both at
|
||||
a treasury of exactly 50,000), all through the identical Load → Single Player → OK → row → OK →
|
||||
Launch → End Turn path:
|
||||
|
||||
| run | DLL | `hooks` | `ComputeBudget` |
|
||||
|---|---|---|---|
|
||||
| **control** | `l5A-widened` | `off` | not installed |
|
||||
| **A** | `l5A-widened` | `trace` | `compare` |
|
||||
| **B** | `l5B-decimal-CONTROL` | `trace` | `compare` |
|
||||
|
||||
A and B differ **only** in `kSavingsInterestRate` / `kDebtInterestRate` — widened floats in A, the
|
||||
exact decimals in B — which is our side of the compare and never touches game memory.
|
||||
|
||||
## 5.1 E1 — held, and the control fired exactly as designed
|
||||
|
||||
**E1-a held.** Run A: **3,895 calls, 3,895 compared, 0 diverged, 0 undeclared writes.** The
|
||||
original filled `savingsInterest` with **499** at a treasury of 50,000 — not 500.
|
||||
|
||||
**E1-b held in substance and was wrong in its units.** I predicted "exactly 2 divergences". Run B
|
||||
reported **1,359 divergences over 2,718 calls**. The prediction was right about *which states*
|
||||
diverge and wrong about the counting: I counted players, and the harness counts calls —
|
||||
`ComputeBudget` is invoked several hundred times per player per turn by the AI's own evaluation
|
||||
loop, not once. Recorded rather than quietly corrected, because the same mistake would have
|
||||
made a call-count look like a coverage claim.
|
||||
|
||||
What the control shows, per treasury:
|
||||
|
||||
| treasury | multiple of 100? | calls in B | diverged | game's `savingsInterest` | 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 |
|
||||
|
||||
Every divergence is on a multiple of 100 and no other state diverges at all — which is exactly the
|
||||
arithmetic: the two constants differ in their truncated product only when the treasury is a
|
||||
multiple of 100. **The game agrees with the widened float and disagrees with the decimal, at two
|
||||
independent treasuries.** G3's rule-23 correction is verified against the running game.
|
||||
|
||||
**A blast radius larger than "one money".** The diff on a diverging call is three fields, not one:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
The interest is an income line, so the error propagates into `available` and then into the money
|
||||
routed to research — 1,317 of the 1,359 diverging calls also move `researchMoney`. A one-money
|
||||
interest defect moves the player's research spend too.
|
||||
|
||||
**E1-c held.** All three runs wrote a byte-identical **pre-turn** autosave
|
||||
(`a3f9dc4b49fc669c…`, 64,967 B). Load and save are deterministic. The post-turn autosaves are
|
||||
discussed in §5.4 and the answer is *not* the hook.
|
||||
|
||||
**E1-d — coverage, and my prediction was wrong here too.** I predicted 2 distinct treasury states;
|
||||
the run presented **5** (the trace spans the post-turn UI as well as the turn), of which **2 are
|
||||
on the interest boundary**. The honest coverage line for run A is therefore:
|
||||
|
||||
> `ComputeBudget`: 3,895 calls (3,888 turn-path + 7 `projected=true`), **5 distinct treasury
|
||||
> states, 2 of them on the rounding boundary**, 0 divergences, 0 undeclared writes.
|
||||
|
||||
Compare that with the number this lane was sent to distrust — "4,437 calls, 0 divergences, 20
|
||||
distinct states, none on a boundary". The difference between the two runs is not the call count.
|
||||
|
||||
**The contradiction in §1 is resolved.** Slot 5 *is* compared and the harness *can* see it: the
|
||||
control build produced 1,359 divergences on it. So the earlier 4,437-call green run genuinely did
|
||||
not present a boundary treasury. The instrument was never broken; the workload was thin.
|
||||
|
||||
## 5.2 E2 — T31's difficulty-column recovery, checked against the flag the save does not carry
|
||||
|
||||
Held on every point. The live `ServerPlayer+0xf9` / NPC flags, read on all eight players:
|
||||
|
||||
| PlyrIdx | `isAI` | `npc` | owns systems | T31's verdict from the save alone |
|
||||
|---:|---|---|---:|---|
|
||||
| 0 (human) | false | false | 1 | non-AI column |
|
||||
| 1 (AI empire) | **true** | false | 1 | AI column |
|
||||
| 2–6 | true | **true** | 0 | ambiguous (zero income) |
|
||||
| 7 (Independent Colony) | true | **true** | **1** | ambiguous (both columns agree) |
|
||||
|
||||
**E2-a held**: exactly one player is `isAI && !npc`, the human is neither, and six are NPCs.
|
||||
**E2-b held**: every player T31 calls ambiguous is either income-less or an NPC, and the one
|
||||
awkward case — a player that owns a system and is *still* ambiguous — reads `npc = true` live,
|
||||
which is precisely the explanation lane PL §7 gave after its own prediction missed it. T31
|
||||
identifies the column from the save with no operator input, and the running game agrees.
|
||||
|
||||
`aidf` (`ServerPlayer+0x368`) reads **1** on all eight players across 3,895 records — the
|
||||
difficulty level the corpus carries, and the level whose AI column holds the ×1.1.
|
||||
|
||||
## 5.3 E3 — one confirmed, one falsified
|
||||
|
||||
**E3-a held, and it is the one result no static reading could have produced.** The
|
||||
`BANKRUPTCY_PROTECTION_LIMIT_FACTOR` slot at `0x00aedfdc` was non-null on every call and the
|
||||
float32 behind it reads **3.29999995231628417968750** — exactly `(float)3.3`. So:
|
||||
|
||||
* the shipped data file does carry 3.3, and **PL-3's assumption was right**;
|
||||
* the narrowing PL-3 read off the instruction stream is **real**: the constant the game multiplies
|
||||
by is not the decimal 3.3 but the nearest float32 below it. PL-3 had to write "the file image of
|
||||
the slot is zero: the loader fills it at run time" and estimate from an assumed value. It is now
|
||||
measured.
|
||||
|
||||
The three `.rdata` doubles read identically in the running process to their file images
|
||||
(`0.009999999776482582`, `0.15000000596046448`, `0.8500000238418579`), so nothing patched or
|
||||
relocated them.
|
||||
|
||||
**E3-c was FALSIFIED, in the way its own falsifier described.** I predicted the difficulty record
|
||||
sits inline at `ServerPlayer+0x36c`. It does not: the dword there is a heap pointer on all eight
|
||||
players (`0x0da0ec20`…`0x0da13360`, within 18 KB of the `ServerPlayer` allocations themselves) and
|
||||
the "floats" behind it decode as denormal garbage. The record is **one dereference further on**.
|
||||
|
||||
What survives is the part the reading was actually for: the row **is** reachable from a
|
||||
`ServerPlayer`, so `compute_budget.h`'s note "not reachable from a ServerPlayer" is wrong as
|
||||
written and has been corrected. **E3-b is therefore not settled**: the fitted pair
|
||||
`{3.0, 1.5}` / `{1.0, 1.0}` remains unverified against the running game. The hook now logs the
|
||||
pointer *as a pointer* and does not follow it — dereferencing an unvalidated pointer inside a hook
|
||||
that runs in the turn pipeline is how a shim crashes the game, and this lane could not re-run to
|
||||
validate it. `ServerPlayer_off_DiffModsPtr` is in the address table as a **hypothesis** with the
|
||||
measurement attached, and the next run is one guarded dereference.
|
||||
|
||||
## 5.4 A determinism result that was not being looked for
|
||||
|
||||
The three post-turn autosaves are all different, and **the hook is not the cause**:
|
||||
|
||||
| run | hooks | `(Autosave).sav` | `Player[512 "Singularity"]/ResTNm` |
|
||||
|---|---|---|---|
|
||||
| control | **off** | `62061918176e3441` | `XNC_TrnsLir2` |
|
||||
| A | compare | `7fc9e6ab46b47794` | `XNC_TrnsHvr2` |
|
||||
| B | compare | `ab4ac2d7e2977260` | `BIO_GnMod` |
|
||||
|
||||
Each pair differs by **exactly four leaves**: one shadow empire's research-target name, two of its
|
||||
tech-tree status words, and the derived `/Summary/Checksum`. Nothing else in the state moves.
|
||||
|
||||
Runs A and B carry **identical hooks** and differ only in our side's arithmetic, which compare
|
||||
mode never lets touch game memory — so a hook-induced perturbation cannot explain A ≠ B. And run
|
||||
B reproduced `ab4ac2d7e2977260…`, which is the hash `findings/subsystems/running-the-game.md`
|
||||
records for the original live session's `Autosave - turn2.sav`. So the instrument is exonerated
|
||||
(rule 19 discharged) and the real finding is about the game:
|
||||
|
||||
> **The `turn1-state → turn2` transition is not reproducible across runs.** The determinism
|
||||
> oracle was established on `ref-turn2 → turn3`, where five runs in two processes gave one hash.
|
||||
> It does not extend to this pair: three runs gave three hashes, differing only in one Singularity
|
||||
> shadow empire's AI research pick.
|
||||
|
||||
The pre-turn autosave is byte-identical across all three runs, so the loader and the writer are
|
||||
deterministic; only the turn's AI decision is not. This is worth knowing before any lane uses
|
||||
`turn1-state → turn2` as an oracle pair, and it corroborates lane PL's placing `ResTNm`/
|
||||
`ResErrRoll` on Rung B — those leaves are not merely AI-dependent, they are *unstable*.
|
||||
|
||||
## 5.5 Two more rule-23 defects, found by reading and invisible to the corpus
|
||||
|
||||
The operand-width sweep of the money chain turned up two more constants in `ComputeBudget`
|
||||
itself, both now corrected and both **unobservable on any reference turn**:
|
||||
|
||||
1. **The research-yield factor is a widened float.** `0x00a2d818` holds `(double)0.85f`, while its
|
||||
two neighbours in the same product — the 1.15 and the 0.5 — are exact doubles. Three literals,
|
||||
one expression, two widths. The exact-decimal product is integral only when the research money
|
||||
is a multiple of 40,000, and the image's constant lands one *above* it there: at 40,000 the
|
||||
game gives 391 research points and the decimal gives 390.
|
||||
**Measured coverage: the run presented 9 distinct research-money values and none is a multiple
|
||||
of 40,000.** That is why the compare stayed green with the wrong constant in place.
|
||||
2. **The three research modifiers are summed in single precision**, with a float32 store after
|
||||
every add, not in double. The corpus has `shrm = TRM = 0`, so the sum is exactly `ResMod` and
|
||||
the two agree by construction.
|
||||
|
||||
Both are pinned by boundary cases in `tests/game_sim/test_economy.cpp` that fail with the decimals
|
||||
and pass with the image's constants. Neither is verified against the game, and the doc says so —
|
||||
the sweep also confirmed that `1.15`, `0.5`, `500`, `0.01` (in the infrastructure term) and `1.65`
|
||||
really *are* exact doubles in the image, so "widen every literal" would be its own defect.
|
||||
|
||||
## 5.6 G3's clamp, observed on the un-instrumented game
|
||||
|
||||
The control run's own strategy-map panel, with no hooks installed at all, shows Gamma Cephei's
|
||||
civilian population going **500,000,000 → 520,000,000** across the End Turn: exactly the
|
||||
**+20,000,000** step cap that lane G3 read out of the population-type table. That is the clamp
|
||||
claim confirmed on the game's own readout, independent of any instrument.
|
||||
|
||||
It is *not* a verification of G3's rescale, which is the part G3 flagged as "a measurement, not a
|
||||
theorem". The rescale is exact by construction in the one-species case, and every colony on this
|
||||
save is single-species. §6 says what workload would settle it.
|
||||
|
||||
---
|
||||
|
||||
# 6. What the `verified` column reads after this lane, and why
|
||||
|
||||
**It still reads 0, and moving it would have been the wrong answer.**
|
||||
|
||||
In this table `verified` means "implemented here **and** compared against the live game", and
|
||||
`app_test_catalog` asserts it stays 0 so the claim cannot drift upward by accident. Every phase
|
||||
this lane's compare touches is `Partial` — `P01 ComputeBudget` because the repair demand of ships
|
||||
in orbit is still taken as 0; `P02 ApplyNetToSavings` because an AI's research rate is set by its
|
||||
own orders during the turn; `T31 UpdateBankruptcyLimits` because `BnkPr` needs a tuning constant
|
||||
that is not wired. Those are all blocked **upstream of the compare**, and none of them is
|
||||
something a VM can fix. Promoting a `Partial` phase to `verified` because part of it was checked
|
||||
is exactly the drift the assertion exists to catch.
|
||||
|
||||
So the ladder's two conditions — *complete* and *compared* — are currently blocked by different
|
||||
things, and the column measures the conjunction. What this lane moved is **models**, and those are
|
||||
listed with their coverage in §5. The catalog notes for `P01`, `P02` and `T31` have been updated
|
||||
to carry the live evidence and its measured coverage instead of the older, weaker call counts.
|
||||
|
||||
# 7. The workload the next lane needs
|
||||
|
||||
Not more analysis. Two saves that the corpus does not contain:
|
||||
|
||||
1. **A multi-species colony**, which is the only thing that exercises G3's rescale
|
||||
`trunc(applied x (clamped/total))` outside its degenerate exact form — the one float G3 says is
|
||||
"a measurement, not a theorem". Also a blockaded colony (`haltv[1]`), a colony over its settle
|
||||
limit (the −50,000,000 floor) and a newly landed colony ship (the seeding loop): four rows of
|
||||
G3 §4.4's falsification table that read "invisible here" today.
|
||||
2. **A treasury that is a multiple of 40,000 in research money**, which is the only thing that can
|
||||
observe the yield-factor width live. It is reachable without editing memory: the research
|
||||
slider changes `ResRate` and therefore the research money, and each slider position is a
|
||||
separate `ComputeBudget` call — sweeping it turns the UI into a boundary generator, which is
|
||||
the cheapest way to raise the distinct-state count that §5.1 shows is the thing that actually
|
||||
decides whether a compare means anything.
|
||||
|
||||
Both are game-play sessions, not instrument changes.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||
// Source: sots-re ghidra/addresses.json @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 2d61848, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -1913,6 +1913,50 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0;
|
|||
constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0;
|
||||
// note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified]
|
||||
constexpr uint32_t SNMAllCombatDone_layout = 0x00624758;
|
||||
// site call RNG_NextInt (0x004271c0) inside SVSOSwarmQueen_RegisterHives, ECX = GetGame()->RNG(+0x16c) + 4, bound = HI - LO passed BY POINTER at [ebp-0x14]; return address 0x00527714, then `add eax,edi` where edi = frame + LO. ONE STRATEGIC-GENERATOR WORD PER NEW HIVE, taken inside StrategyServer::BeginProcessTurn -- outside BOTH turn drivers and before either. Corrects svsctob-writers.md, which put the call at 0x0052770c (that is the `mov [ebp-0x14],esi` storing the bound) [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_RegisterHives_DrawSite = 0x0012770f;
|
||||
// site `inc DWORD PTR [esi+0x8]` -- the NextQ slip. FOUR gate-failure edges converge here: 0x005277e3 (frame <= *SwarmQueen_GateFrameFloor_ptr), 0x005277fe (queens count >= the per-turn allowance), 0x00527848 (age < *SwarmQueen_GateQueenAge_ptr), 0x0052784f (hive's system handle is 0). Consumes NO generator word on any of them; TickHives calls no RNG primitive anywhere in its body (0x00527770..0x005279a1 read whole) [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_TickHives_SlipSite = 0x0012785a;
|
||||
// data int* -- pointer to the LOW end of the new-hive queen-countdown offset. RegisterHives computes nextQ = frame + *this + RNG_NextInt(*SwarmQueen_HiveNextQOffsetHi_ptr - *this). Behind a pointer, so no .text or .data reference initialises it in a form a static reader can follow; the value is read live by the shim at RegisterHives entry [mapped]
|
||||
constexpr uint32_t SwarmQueen_HiveNextQOffsetLo_ptr = 0x006e0204;
|
||||
// data int* -- pointer to the HIGH end of the new-hive queen-countdown offset. The RNG_NextInt bound is (*this - *SwarmQueen_HiveNextQOffsetLo_ptr) and NextInt is INCLUSIVE of its bound, so the countdown lands in [frame+LO, frame+HI] [mapped]
|
||||
constexpr uint32_t SwarmQueen_HiveNextQOffsetHi_ptr = 0x006e0208;
|
||||
// data int* -- TickHives gate 1: `if (GetGame()->Frame(+0xc) <= *this) slip`. No queen can spawn at or below this frame [mapped]
|
||||
constexpr uint32_t SwarmQueen_GateFrameFloor_ptr = 0x006e0210;
|
||||
// data int* -- read in TickHives' SPAWN arm (0x005278d8), after a queen has been created: when *this <= 0 the vector at queen+0x30 is trimmed to 0 entries, otherwise to *this. Never exercised on any corpus save (no hive has ever had a queen) [mapped]
|
||||
constexpr uint32_t SwarmQueen_SpawnListCap_ptr = 0x006e0220;
|
||||
// data int* -- TickHives gate 3: an age in turns, compared against `GetGame()->Frame - resolvedQueen->+0xc`; below it the hive slips instead of spawning [mapped]
|
||||
constexpr uint32_t SwarmQueen_GateQueenAge_ptr = 0x006e0228;
|
||||
// field std::vector<HiveInfo> -- {_Myfirst 0x10, _Mylast 0x14, _Myend 0x18, _Alval 0x1c}, element stride 0x10: {void* vptr = 0x009f1a68, StarSystem* sys(+4), int nextQ(+8), Queen* queen(+0xc)}. The WIRE order is HiveID / QueenID / NextQ, which is NOT the member order [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_off_Hives = 0x00000010;
|
||||
// field std::vector<T> at +0x20..+0x24, element stride 0xc -- TickHives' second gate divides the byte span by 12. Empty on every corpus save [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_off_Queens = 0x00000020;
|
||||
// field int -- the difficulty tier, wire tag "CDiff". Set to -1 by the ctor 0x0051a820 and written ONLY by SVSOSlaversRefuel_UpdateDifficultyTier, which stores (scanIndex - 1) and only when it differs. Reachable values are 0 (frame 1..49) and 1 (frame 50..99) ONLY: frame <= 0 exits at index 0 and frame >= 100 walks off the end of the 3-entry threshold table, so BOTH edges store nothing and the tier can never reach 2 [verified]
|
||||
constexpr uint32_t SVSOSlaversRefuel_off_CDiff = 0x00000038;
|
||||
// thiscall void __thiscall Game::AITColonize::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITColonizeGoal::Execute is the same address. 48 bytes; a forwarder to the parameterised worker 0x0068b280 called as worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10) with `edi = this->+0xc` passed as an IMPLICIT REGISTER ARGUMENT (whole-program-optimised custom convention; a reimplementation that ports only the stack arguments passes garbage). Reaches the colonize order (list 7) at depth 4 via 0x0068b280 -> 0x006930f0 -> 0x00578ff0 -> 0x00769640 [unverified]
|
||||
constexpr uint32_t AITColonize_Execute = 0x0028b400;
|
||||
// thiscall void __thiscall Game::AITEscortGateInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITEscortGateInvadeGoal::Execute is the same address. 80 bytes; forwards to 0x0068c5d0 as __fastcall(ecx = this->+0xc, edx = agent) plus 8 stack arguments. Reaches list 7 at depth 4 [unverified]
|
||||
constexpr uint32_t AITEscortGateInvade_Execute = 0x0028c7c0;
|
||||
// thiscall void __thiscall Game::AITInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITInvadeGoal::Execute is the same address. 160 bytes; forwards to 0x0068d460 with 11 arguments, then maintains this->+0x38 and this->+0x39. +0x39 is computed at 0x0068d80f-0x0068d82a as (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc)) and is what IAITask slot 11 returns for this class. Reaches list 14 at depth 5 [unverified]
|
||||
constexpr uint32_t AITInvade_Execute = 0x0028d7a0;
|
||||
// thiscall void __thiscall Game::AITNodeBore::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Does its setup (0x00685810) and its finaliser (0x0068e090) only when pass == 0, and forwards `pass` to 0x0068a520 in both passes. Reaches list 14 at depth 5 via 0x0068a520 -> 0x006ceef0 -> 0x006c16c0 -> 0x006987e0 -> 0x007634d0. Zuul-flavoured task (the node-bore is a Zuul mechanic), so on a non-Zuul board it may be absent from the task list entirely [unverified]
|
||||
constexpr uint32_t AITNodeBore_Execute = 0x0028e590;
|
||||
// thiscall void __thiscall Game::AITRaid::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. THE ONE TASK WHOSE PASS-0 BEHAVIOUR IS NOT SETTLED: at 0x0068e89e it calls 0x006b76a0 and then at 0x0068e8b8 loops the list-16 order method 0x007635f0(client, fleetId, 1) over the returned fleets, and NEITHER the call nor the loop carries a `pass` guard of its own. AI3 §2.3's argument says the fleet vector at [ebp-0x28] is empty on pass 0 because it is downstream of the same hub, but [ebp-0x28] was never traced to closure. This address exists as an address so that a zero at 0x007635f0 can be read: entered-and-silent and never-entered are opposite answers (method rule 20) [unverified]
|
||||
constexpr uint32_t AITRaid_Execute = 0x0028e670;
|
||||
// thiscall void __thiscall Game::AITAdvanceIdleShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Whole body is pass-1 only: `if (pass != 1) return` at 0x0068f25a. Table priority 0, so it is always the LAST task in the descending-priority sweep -- the AI sweeps up whatever is still idle only after every other task has taken both its minimum and its desired force, which is what the two-tier quota model predicts [unverified]
|
||||
constexpr uint32_t AITAdvanceIdleShips_Execute = 0x0028f230;
|
||||
// thiscall void __thiscall Game::AITBuildDeepScanShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6 via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0, the same chain as AITBuildPoliceShips [unverified]
|
||||
constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0;
|
||||
// thiscall void __thiscall Game::AITBuildPoliceShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6, identical chain to AITBuildDeepScanShips. One of the two candidate producers of the single list-3 element the reference AI emits on turn 1 [unverified]
|
||||
constexpr uint32_t AITBuildPoliceShips_Execute = 0x00290380;
|
||||
// thiscall bool __thiscall Game::StrategyClient::OrderList16(int objectId, bool flag) -- RET 8. THE LIST-16 PRODUCER. Opens with `cmp byte [this+0x15c], 0` -- the End-Turn latch StrategyClient::EndTurn 0x00783be0 sets at 0x00783c59 before BuildTurnCommands runs -- so like every other order method it refuses once the turn has been submitted. Element record for list 16 is {i32, bool} (lane Q), matching the (objectId, flag) pair; list 16 is in the PAYING half of the ModCount table (bump at 0x00821e23 in its applier 0x00821e20). Reached from AITRaid::Execute at depth 1. The method is named for the list it fills, NOT for what the flag means: no save has ever carried a list-16 element and nothing has been observed [unverified]
|
||||
constexpr uint32_t StrategyClient_OrderList16 = 0x003635f0;
|
||||
// offset 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 [verified]
|
||||
constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368;
|
||||
// offset 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 [hypothesis]
|
||||
constexpr uint32_t ServerPlayer_off_DiffModsPtr = 0x0000036c;
|
||||
// data 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 [verified]
|
||||
constexpr uint32_t g_ResearchYieldFactor = 0x0062d818;
|
||||
// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified]
|
||||
constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480;
|
||||
// thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified]
|
||||
|
|
|
|||
|
|
@ -163,7 +163,17 @@ constexpr PhaseDesc kStrategic[] = {
|
|||
// ---------------------------------------------------------------------------------------
|
||||
constexpr PhaseDesc kPlayer[] = {
|
||||
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial,
|
||||
"the formula is verified (0 divergences over 4,284 live calls) and the per-system money "
|
||||
"the formula is compared clean against the live game -- lane L5, 3,895 calls on VM146, 0 "
|
||||
"divergences, 0 undeclared writes -- over 5 distinct treasury states, TWO of them on the "
|
||||
"interest rounding boundary, and with a control build that proves the compare can see "
|
||||
"the boundary (the exact-decimal constants produce 1,359 divergences on the same run). "
|
||||
"The game fills savingsInterest with 499 at a treasury of 50,000, so G3's widened-float "
|
||||
"reading is now measured, not inferred; the one-money error also moves `available` and "
|
||||
"`researchMoney`. TWO further rule-23 constants in this same routine are corrected and "
|
||||
"UNVERIFIED: the research-yield factor is a widened 0.85f (needs a research money that "
|
||||
"is a multiple of 40,000; the run presented 9 distinct values and none is) and the three "
|
||||
"research modifiers are summed in single precision (needs shrm or TRM non-zero; the "
|
||||
"corpus has both at 0). The per-system money "
|
||||
"input is now modelled on the TURN path -- ComputeOutput with the system's own rate "
|
||||
"sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform "
|
||||
"-> money cascade are all live, none of which is the max-income form T31 sums. What is "
|
||||
|
|
@ -179,7 +189,9 @@ constexpr PhaseDesc kPlayer[] = {
|
|||
"the independent colony on BOTH reference pairs now that S11 commits civilian growth "
|
||||
"and the two interest rates are the widened float literals the image holds. Still "
|
||||
"wrong for an AI whose research rate and target are set by its own orders during the "
|
||||
"turn (Rung B)"},
|
||||
"turn (Rung B). The two interest rates are now verified against the running game "
|
||||
"(lane L5): the game pays 499 on 50,000 and 380 on 38,100, and the exact decimals pay "
|
||||
"500 and 381"},
|
||||
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
|
||||
"trade income, savings-given-away and research-points-given-away land on the turn record "
|
||||
"and on two player words that are not identified on the wire"},
|
||||
|
|
@ -307,9 +319,18 @@ constexpr PhaseDesc kTail[] = {
|
|||
"non-AI on every corpus save, the rest ambiguous because both columns agree there (zero "
|
||||
"income, or an NPC, whose row is gated on `isAI && !npc`). A player neither column "
|
||||
"reproduces abstains and keeps its pass-through leaf. Measured with NO operator input: 2 "
|
||||
"closed, 0 regressed on EACH reference pair. What is left is BnkPr, which needs "
|
||||
"BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files -- it is read `fmul dword ptr`, "
|
||||
"so it is a float32 in the image and the engine now narrows it (rule 23)"},
|
||||
"closed, 0 regressed on EACH reference pair. VERIFIED AGAINST THE RUNNING GAME (lane "
|
||||
"L5, VM146): the live `ServerPlayer+0xf9` and NPC flags, read on all eight players of "
|
||||
"turn1-state across 3,895 calls, are exactly what this inversion claims -- one player "
|
||||
"is AI-and-not-NPC, the human is neither, six are NPCs, and the awkward case (a player "
|
||||
"that owns a system and is still ambiguous) reads npc=true, which is the reason lane "
|
||||
"PL gave after its own prediction missed it. aidf (+0x368) reads 1 on all eight. What "
|
||||
"is left is BnkPr, which needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files "
|
||||
"-- it is read as a 32-bit operand, so it is a float32 in the image and the engine now "
|
||||
"narrows it (rule 23). Its RUN-TIME VALUE is no longer an assumption either: read live "
|
||||
"through its pointer slot it is 3.29999995231628, i.e. (float)3.3, so the data file "
|
||||
"carries 3.3 and the narrowing is real. The file image of that slot is zero, so this "
|
||||
"is a fact only a running process could supply"},
|
||||
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
||||
|
|
|
|||
|
|
@ -68,8 +68,22 @@ int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeE
|
|||
int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod,
|
||||
double shrm, double trm, double techMult, double serverResMod,
|
||||
double resScl) {
|
||||
const double base = (static_cast<double>(researchMoney) / 50.0) * 1.15 * 0.5 * 0.85;
|
||||
const double rp = difficultyMult * base * (resMod + shrm + trm) * techMult * serverResMod * resScl;
|
||||
// Rule 23, from the operand widths rather than from a decompilation. The four constants in
|
||||
// this one expression are not all stored the same way: 50, 1.15 and 0.5 are exact doubles
|
||||
// in the image, and the 0.85 is not -- it is the widened float `(double)0.85f`. Writing all
|
||||
// four as decimals, which is what a C++ port naturally does and what this line used to do,
|
||||
// is one research point LOW at every research money that is a multiple of 40,000 (the
|
||||
// period of the exact-decimal product's integrality).
|
||||
const double base = (static_cast<double>(researchMoney) / 50.0) * 1.15 * 0.5 *
|
||||
kResearchYieldFactor;
|
||||
// The three research modifiers are NOT summed in double. The image loads each at 32-bit
|
||||
// width and stores the running sum back into a 32-bit local after every add -- three such
|
||||
// stores -- so the sum is rounded to single precision twice before it is used. Summing in
|
||||
// double disagrees whenever the three do not add exactly.
|
||||
const double modSum = static_cast<double>(static_cast<float>(
|
||||
static_cast<float>(static_cast<float>(resMod) + static_cast<float>(shrm)) +
|
||||
static_cast<float>(trm)));
|
||||
const double rp = difficultyMult * base * modSum * techMult * serverResMod * resScl;
|
||||
return Ftol(rp);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,15 @@ struct Budget {
|
|||
constexpr double kSavingsInterestRate = 0.009999999776482582; // (double)0.01f
|
||||
constexpr double kDebtInterestRate = 0.15000000596046448; // (double)0.15f
|
||||
|
||||
// The research-yield factor in ResearchPointsFromMoney, and the third rule-23 constant found
|
||||
// in this one routine. The image stores it as the widened float `(double)0.85f`, while its two
|
||||
// neighbours in the same expression -- the 1.15 and the 0.5 -- are exact doubles. Do NOT
|
||||
// "correct" those two to widened floats as well: the image really does store them exactly, and
|
||||
// three constants at two different widths inside one product is the whole point.
|
||||
// Not visible on the corpus: the exact-decimal product is integral only when the research money
|
||||
// is a multiple of 40,000, and no reference turn is.
|
||||
constexpr double kResearchYieldFactor = 0.85000002384185791; // (double)0.85f
|
||||
|
||||
// Savings interest: 1 % of a non-negative treasury, only for players who own systems.
|
||||
// CONFIDENCE: high.
|
||||
int SavingsInterest(int savings, bool ownsSystems);
|
||||
|
|
@ -141,9 +150,14 @@ int MaintenanceCost(int maintenance, double difficultyDivisor);
|
|||
int ExpenseTotal(const std::vector<ExpenseSlider>& sliders, int availableBeforeExpenses);
|
||||
|
||||
// Research points bought with `researchMoney`:
|
||||
// RP = ftol( difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM)
|
||||
// RP = ftol( difficulty x (money/50 x 1.15 x 0.5 x 0.85f) x f32(f32(f32(ResMod)+shrm)+TRM)
|
||||
// x techMult x serverResMod x ResScl )
|
||||
// i.e. about 0.009775 RP per unit of money before multipliers. CONFIDENCE: high.
|
||||
// Two rule-23 corrections live in that line and neither is visible on the corpus (lane L5):
|
||||
// the 0.85 is a widened float (see kResearchYieldFactor) and the three modifiers are summed
|
||||
// in SINGLE precision with a float32 store after every add. The reference turn exercises the
|
||||
// boundary of neither -- the first needs a research money that is a multiple of 40,000 and the
|
||||
// second needs two of the three modifiers to be non-zero, and the corpus has shrm = TRM = 0.
|
||||
int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double resMod,
|
||||
double shrm, double trm, double techMult, double serverResMod,
|
||||
double resScl);
|
||||
|
|
|
|||
|
|
@ -225,6 +225,24 @@ Tv DescribeSnapshot(const void* data, std::size_t size, unsigned) {
|
|||
s.add("researchDifficultyMult", trace::tv::f64(v.researchDifficultyMult));
|
||||
s.add("serverResMod", trace::tv::f64(v.serverResMod));
|
||||
|
||||
// Lane L5. `live_difficulty` is the record the game holds, next to the fitted constants
|
||||
// above so a reader can see whether the fit was right rather than being told. `live_consts`
|
||||
// carries the four float widths this lane is about; `protectionFactor` is the only one of
|
||||
// them that no static reading could have produced.
|
||||
if (v.haveLiveDifficulty) {
|
||||
Tv d = trace::tv::struct_();
|
||||
d.add("aidf", trace::tv::i32(v.aidf));
|
||||
d.add("diffModsPtr", trace::tv::u32(v.diffModsPtr));
|
||||
s.add("live_difficulty", std::move(d));
|
||||
}
|
||||
Tv c = trace::tv::struct_();
|
||||
c.add("savingsInterestRate", trace::tv::f64(v.liveSavingsInterestRate));
|
||||
c.add("debtInterestRate", trace::tv::f64(v.liveDebtInterestRate));
|
||||
c.add("researchYieldFactor", trace::tv::f64(v.liveResearchYieldFactor));
|
||||
c.add("haveProtectionFactor", trace::tv::boolean(v.haveProtectionFactor));
|
||||
c.add("protectionFactor", trace::tv::f32(v.protectionFactor));
|
||||
s.add("live_consts", std::move(c));
|
||||
|
||||
std::vector<Tv> exp;
|
||||
for (int i = 0; i < v.expenseCount && i < kMaxExpenseEntries; ++i) {
|
||||
Tv e = trace::tv::struct_();
|
||||
|
|
|
|||
|
|
@ -131,9 +131,34 @@ struct BudgetSnapshot {
|
|||
std::int32_t aggSecondaryManagerIncome = 0;
|
||||
std::int32_t aggConstructionSpend = 0; // what the build queues took
|
||||
|
||||
// --- the difficulty-mods row (not reachable from a ServerPlayer; see the header note) ---
|
||||
// --- the difficulty-mods row --------------------------------------------------------
|
||||
// These two are still the FITTED constants, kept so a run can be compared against the
|
||||
// record the game actually holds (below) rather than silently switched over to it.
|
||||
double maintenanceDivisor = 1.0;
|
||||
double researchDifficultyMult = 1.0;
|
||||
|
||||
// --- lane L5: what IS reachable from the player, and what is one hop further -----------
|
||||
// The old note here said the difficulty row is "not reachable from a ServerPlayer". That
|
||||
// is wrong: DifficultyMods_Select(0x0059b490) is called as Select(p->+0x36c, p) by all
|
||||
// three consumers. But the record is not AT +0x36c -- lane L5 read it there and got a heap
|
||||
// pointer plus denormal garbage on all eight players -- so +0x36c is a POINTER and the row
|
||||
// is behind it. The pointer is logged; it is deliberately not dereferenced (see the note
|
||||
// in compute_budget.cpp). The fitted pair above is therefore still UNVERIFIED live.
|
||||
bool haveLiveDifficulty = false;
|
||||
std::int32_t aidf = -1; // ServerPlayer+0x368, the row selector. Measured: 1.
|
||||
std::uint32_t diffModsPtr = 0; // ServerPlayer+0x36c, logged as a pointer, not followed
|
||||
|
||||
// --- lane L5: the constant whose file image is zero -----------------------------------
|
||||
// BANKRUPTCY_PROTECTION_LIMIT_FACTOR. Lane PL-3 read its WIDTH off the instruction stream
|
||||
// (a 32-bit multiply operand) and could not read its VALUE at all, because the data
|
||||
// file loader fills the slot at run time. This is the one place in the campaign that can.
|
||||
bool haveProtectionFactor = false;
|
||||
float protectionFactor = 0.f;
|
||||
// The two interest doubles, read out of the RUNNING image rather than the file, so a run
|
||||
// also proves nothing patched or relocated them.
|
||||
double liveSavingsInterestRate = 0.0;
|
||||
double liveDebtInterestRate = 0.0;
|
||||
double liveResearchYieldFactor = 0.0;
|
||||
// The game-option research modifier lives on the StrategyServer. The reference game was
|
||||
// created at research 100 %, which the save records as ResMod = 1.0, and the two players on
|
||||
// the human row reproduce their research points with this at 1.0.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,49 @@ std::int32_t VectorCount(const void* base, std::uint32_t off, std::size_t stride
|
|||
return static_cast<std::int32_t>(n > max ? max + 1 : n); // max+1 signals truncation
|
||||
}
|
||||
|
||||
// ---- lane L5: reads out of the RUNNING image -----------------------------------------------
|
||||
//
|
||||
// Two different kinds of thing live here and the difference is the whole point.
|
||||
//
|
||||
// The three interest/yield doubles are `.rdata` literals: they are in the file, a disassembler
|
||||
// can read them, and reading them again from the live process only proves that nothing patched
|
||||
// or relocated them. They are logged for that reason and no other.
|
||||
//
|
||||
// The bankruptcy protection factor is not like that. Its slot is filled by the data-file
|
||||
// loader at run time and the file image of the storage word is ZERO, so lane PL-3 could read
|
||||
// its WIDTH off the instruction stream (a 32-bit multiply operand) and could not read
|
||||
// its VALUE at all -- PL-3 assumed 3.3 to estimate a disagreement rate and said so. A running
|
||||
// process is the only instrument that can answer it.
|
||||
|
||||
std::uintptr_t g_exe_base = 0;
|
||||
|
||||
template <class T>
|
||||
bool ReadImage(std::uintptr_t rva, T* out) {
|
||||
if (!g_exe_base) return false;
|
||||
std::memcpy(out, reinterpret_cast<const void*>(g_exe_base + rva), sizeof(T));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReadImageConstants(BudgetSnapshot& s) {
|
||||
using namespace sots::addr;
|
||||
ReadImage(g_SavingsInterestRate, &s.liveSavingsInterestRate);
|
||||
ReadImage(g_DebtInterestRate, &s.liveDebtInterestRate);
|
||||
ReadImage(g_ResearchYieldFactor, &s.liveResearchYieldFactor);
|
||||
|
||||
// The factor is reached through a POINTER slot, so the read is two hops and either can
|
||||
// fail before the loader has run. `haveProtectionFactor` distinguishes "zero" from
|
||||
// "not read", which matters: a zero here would mean the shipped data files do not carry
|
||||
// the key at all, and PL-3's whole disagreement analysis would be about a constant this
|
||||
// install does not have.
|
||||
if (!g_exe_base) return;
|
||||
const void* const* slot = reinterpret_cast<const void* const*>(
|
||||
g_exe_base + PTR_g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR);
|
||||
const void* storage = *slot;
|
||||
if (!storage) return;
|
||||
std::memcpy(&s.protectionFactor, storage, sizeof s.protectionFactor);
|
||||
s.haveProtectionFactor = true;
|
||||
}
|
||||
|
||||
// ---- per-call capture ----------------------------------------------------------------------
|
||||
//
|
||||
// ComputeBudget runs on the server thread, one player at a time, and never re-enters itself, so
|
||||
|
|
@ -100,6 +143,26 @@ void CaptureSnapshot(const void* self, bool projected) {
|
|||
s.maintenanceDivisor = row.maintenanceDivisor;
|
||||
s.researchDifficultyMult = row.researchMult;
|
||||
|
||||
// Lane L5: the difficulty level, read live out of the player. `aidf` is the row selector
|
||||
// and it IS an int at +0x368 -- measured 1 on all eight players of the reference save.
|
||||
//
|
||||
// The row itself is deliberately NOT read here. The first attempt read 0x1c bytes at
|
||||
// +0x36c expecting {int id; float ai[3]; float other[3]} and got a heap pointer followed
|
||||
// by denormal garbage on all eight players, so the record is one dereference further on
|
||||
// (ServerPlayer_off_DiffModsPtr, recorded as a HYPOTHESIS in the address table). Following
|
||||
// an unvalidated pointer inside a hook that runs in the turn pipeline is how a shim
|
||||
// crashes the game, and this lane cannot re-run to validate it, so the pointer is logged
|
||||
// as a pointer and the dereference is left to a lane that can.
|
||||
//
|
||||
// What the failed read did establish is the thing it was for: the row is REACHABLE from a
|
||||
// ServerPlayer. The fitted constants above still drive `ours` and are still unverified
|
||||
// against the running game.
|
||||
s.aidf = Peek<std::int32_t>(self, ServerPlayer_off_Aidf);
|
||||
s.diffModsPtr = Peek<std::uint32_t>(self, ServerPlayer_off_DiffModsPtr);
|
||||
s.haveLiveDifficulty = true;
|
||||
|
||||
ReadImageConstants(s);
|
||||
|
||||
const char* items = nullptr;
|
||||
s.ownedSystems = VectorCount(self, ServerPlayer_off_OwnedSystems, 4, 4096, &items);
|
||||
|
||||
|
|
@ -134,7 +197,10 @@ void CaptureSnapshot(const void* self, bool projected) {
|
|||
|
||||
} // namespace
|
||||
|
||||
void init_compute_budget(void (*log_line)(const char* line)) { g_log_line = log_line; }
|
||||
void init_compute_budget(std::uintptr_t exe_base, void (*log_line)(const char* line)) {
|
||||
g_exe_base = exe_base;
|
||||
g_log_line = log_line;
|
||||
}
|
||||
|
||||
void ComputeBudgetHook::describe_args(std::vector<Tv>& out, void* self, std::int32_t* budget,
|
||||
bool projected) {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,28 @@ struct ComputeBudgetHook {
|
|||
"the six unmodelled slots, so that repair happens TWICE per turn in replace "
|
||||
"mode and nothing in the trace would show it",
|
||||
"guard:budget_object does not reach the ships; unverified");
|
||||
c.unmodelled("the difficulty-mods row from StrategyServer::GetDifficultyMods",
|
||||
c.unmodelled("the difficulty-mods row still DRIVES `ours` as two fitted constants",
|
||||
trace::Risk::Low,
|
||||
"the old note here said the row was 'not reachable from a ServerPlayer'. "
|
||||
"That was wrong: the record is at ServerPlayer+0x36c and every consumer "
|
||||
"calls DifficultyMods_Select(p->+0x36c, p). Lane L5 now snapshots it and "
|
||||
"emits it as `inputs.live_difficulty` alongside the fitted pair, so a run "
|
||||
"SHOWS whether the fit is right instead of assuming it. `ours` was "
|
||||
"deliberately left on the fitted constants so that this run is evidence "
|
||||
"about them; switching it over is the next lane's one-line change",
|
||||
"logged as `inputs.live_difficulty` on every record");
|
||||
c.unmodelled("three of the four float widths this routine turns on are unexercised by "
|
||||
"any reference turn",
|
||||
trace::Risk::Medium,
|
||||
"not reachable from a ServerPlayer, so the two relevant entries are fitted "
|
||||
"constants measured from the B1 trace rather than snapshotted inputs");
|
||||
"the savings-interest rate is a widened 0.01f and its boundary IS in the "
|
||||
"corpus (a treasury of 50,000 earns 499, not 500). The research yield "
|
||||
"factor is a widened 0.85f whose boundary needs a research money that is a "
|
||||
"multiple of 40,000, and the three research modifiers are summed in "
|
||||
"SINGLE precision, which needs two of them non-zero -- the corpus has "
|
||||
"shrm = TRM = 0. So a green compare here verifies one of the three and "
|
||||
"says nothing about the other two",
|
||||
"the record carries `inputs.live_consts` so the widths are readable, and "
|
||||
"tests/game_sim/test_economy.cpp pins all three at their boundaries");
|
||||
c.unmodelled("the research-allocation vector's heap block",
|
||||
trace::Risk::Low,
|
||||
"only the element count is compared; the three words are heap pointers the "
|
||||
|
|
@ -65,6 +83,6 @@ struct ComputeBudgetHook {
|
|||
};
|
||||
|
||||
// Process facts the hook needs (a line logger for shim.log). Call once before installing.
|
||||
void init_compute_budget(void (*log_line)(const char* line));
|
||||
void init_compute_budget(std::uintptr_t exe_base, void (*log_line)(const char* line));
|
||||
|
||||
} // namespace shim::hooks
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
|||
shim::hooks::init_tech_effects(exeBase, &ShimLogLine);
|
||||
InstallTemplateHook<shim::hooks::ServerPlayerOnTechResearchedHook>(tracer, exeBase, sots::addr::ServerPlayer_OnTechResearched);
|
||||
// B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare.
|
||||
shim::hooks::init_compute_budget(&ShimLogLine);
|
||||
shim::hooks::init_compute_budget(exeBase, &ShimLogLine);
|
||||
InstallTemplateHook<shim::hooks::ComputeBudgetHook>(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget);
|
||||
|
||||
// B4: the per-system colony turn (once per system per turn) and the two movement entry
|
||||
|
|
|
|||
34
src/shim/shim.cfg.l5budget
Normal file
34
src/shim/shim.cfg.l5budget
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Lane L5 -- ComputeBudget in compare mode, and nothing else.
|
||||
#
|
||||
# `hooks=off` short-circuits before ANY hook is installed, so the default has to be `trace` and
|
||||
# every other hook is turned off by name. (Lane N's shim.cfg.output documents the same trap.)
|
||||
#
|
||||
# Paired with shim.cfg.l5control, which is byte-identical except that `hooks=off`, so the
|
||||
# rule-19 control and this run differ in exactly one key.
|
||||
hooks=trace
|
||||
hook.Shim::SelfTest::Fill=off
|
||||
hook.Mars::GlobalConsts::LoadFile=off
|
||||
hook.Game::WeaponDictionary::Init=off
|
||||
hook.Game::SectionDictionary::SectionDictionary=off
|
||||
hook.Game::StrategyServer::ProcessFleetMovement=off
|
||||
hook.Game::StrategyServer::MoveFleet=off
|
||||
hook.Game::TechTree::ProcessResearch=off
|
||||
hook.Game::ServerPlayer::OnTechResearched=off
|
||||
hook.Game::ServerSystem::ProcessTurn=off
|
||||
hook.Game::ServerSystem::GroupOutput=off
|
||||
hook.Game::ServerSystem::ComputeTotalOutput=off
|
||||
hook.Game::ServerPlayer::ProcessTurn=off
|
||||
hook.Game::StrategyHost::Autosave=off
|
||||
hook.Game::StrategyServer::ProcessTurn=off
|
||||
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
|
||||
hook.Game::StrategyServer::ApplyEncounterResult=off
|
||||
hook.Game::StrategyServer::NodeLineDecay=off
|
||||
hook.Game::EncounterDetect::AssignContacts=off
|
||||
hook.Game::EncounterDetect::ProcessTeamRecord=off
|
||||
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
|
||||
hook.Game::ServerPlayer::ComputeBudget=compare
|
||||
trace.path=C:\SOTS\shim.trace.jsonl
|
||||
trace.inline_max=256
|
||||
trace.flush=always
|
||||
probes=off
|
||||
watch=off
|
||||
6
src/shim/shim.cfg.l5control
Normal file
6
src/shim/shim.cfg.l5control
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Lane L5 rule-19 control: the SAME binary, no hooks installed at all.
|
||||
# The only difference from shim.cfg.l5budget is this one key.
|
||||
hooks=off
|
||||
trace.path=C:\SOTS\shim.trace.jsonl
|
||||
probes=off
|
||||
watch=off
|
||||
|
|
@ -41,6 +41,24 @@ static void test_research_points() {
|
|||
CHECK_EQ(ResearchPointsFromMoney(0, 1, 1, 0, 0, 1, 1, 1), 0);
|
||||
// 100 money -> 0.9775 -> 0
|
||||
CHECK_EQ(ResearchPointsFromMoney(100, 1, 1, 0, 0, 1, 1, 1), 0);
|
||||
|
||||
// --- rule 23, and the two boundaries the corpus cannot see (lane L5) ---------------------
|
||||
// The yield factor is (double)0.85f, not the decimal 0.85. The exact-decimal product is an
|
||||
// integer exactly when researchMoney is a multiple of 40,000, and the image's slightly
|
||||
// larger constant lands one ABOVE it there. Every case above is away from that boundary,
|
||||
// which is why none of them moved when the constant was corrected -- and why the live
|
||||
// compare stays green on a reference turn.
|
||||
CHECK_EQ(ResearchPointsFromMoney(40000, 1, 1, 0, 0, 1, 1, 1), 391); // decimal 0.85 -> 390
|
||||
CHECK_EQ(ResearchPointsFromMoney(80000, 1, 1, 0, 0, 1, 1, 1), 782); // decimal 0.85 -> 781
|
||||
// 120,000 is a multiple of 40,000 and still agrees: the exact-decimal product rounds UP to
|
||||
// 1173.0 there rather than landing below it. The boundary is a property of the product, not
|
||||
// of the multiple, so the period is necessary and not sufficient.
|
||||
CHECK_EQ(ResearchPointsFromMoney(120000, 1, 1, 0, 0, 1, 1, 1), 1173);
|
||||
|
||||
// The three modifiers are summed in SINGLE precision with a float32 store after every add.
|
||||
// 0.1 + 0.33 + 0.75 is 1.1800000667572021 in float32 and 1.180000014603138 in double, and
|
||||
// at 4,855 money that is the difference between 56 RP and 55.
|
||||
CHECK_EQ(ResearchPointsFromMoney(4855, 1, 0.1f, 0.33f, 0.75, 1, 1, 1), 56); // double -> 55
|
||||
}
|
||||
|
||||
static void test_expenses() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue