merge lane L5: interest-literal boundary verified live with a failing control; two more widened-float defects; reference pair shown non-deterministic

This commit is contained in:
alex 2026-09-08 17:51:30 -04:00
commit 7444c3b85c
13 changed files with 722 additions and 16 deletions

View 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.

View file

@ -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]

View file

@ -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, ""},

View file

@ -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);
}

View file

@ -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);

View file

@ -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_();

View file

@ -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.

View file

@ -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) {

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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() {