237 lines
14 KiB
Markdown
237 lines
14 KiB
Markdown
# B1 — `ServerPlayer::ComputeBudget` old-vs-new on the live game
|
||
|
||
**Result (2026-09-08):** the campaign's first *behavioural* verification — game logic, not data
|
||
loading. Trace mode: 4,623 real `ComputeBudget` calls on the reference save, `tracecmp.py`
|
||
exit 0. Compare mode: **4,437 compared, 0 divergences** over all 22 result slots and the
|
||
research allocation, including the 8 End-Turn calls (one per player). Replace mode: our
|
||
`game::sim::ComputeBudget` fed the game its budget for a whole turn and reproduced the
|
||
determinism oracle byte for byte (`(Autosave).sav` = `978041ac…`, `(Autosave EndTurn).sav` =
|
||
`bb4fd9ac…`). Host suite 27/27. Game left on VM140 at the main menu in `hooks=trace`.
|
||
|
||
Two things the binary told us that the RE notes had wrong, both fixed here:
|
||
|
||
1. **The out parameter is 22 ints, not 25.** The three words after slot 21 are a
|
||
`std::vector` — the research allocation the turn driver hands to
|
||
`TechTree::ProcessResearch`.
|
||
2. **`researchMoneyKept` is only charged when the player has a research target.** Our
|
||
`sim::ComputeBudget` charged it unconditionally; on the reference save that was a 60,670
|
||
error in the human player's turn net. Fixed (see "The formula bug").
|
||
|
||
## What was hooked
|
||
|
||
`Game::ServerPlayer::ComputeBudget(this, Budget* out, bool projected)` — `__thiscall`,
|
||
verified, so it goes through `Hook<Descriptor>` with `CallConv::Thiscall` (M2's addition);
|
||
no asm stub. Descriptor `src/shim/hooks/compute_budget.{h,cpp}`, installed from
|
||
`src/shim/main.cpp` via `InstallTemplateHook<>` at `sots::addr::ServerPlayer_ComputeBudget`.
|
||
|
||
It is called once per player from `ServerPlayer::ProcessTurn` during End Turn, and constantly
|
||
by the strategy screen (~35 calls/s) for the savings/research readout. In the reference save
|
||
that is 8 turn-driver calls and several thousand UI calls per session; the UI calls are real
|
||
verification data too — they cover the same player through the whole AI turn.
|
||
|
||
### Region model
|
||
|
||
| region | what | describer |
|
||
|---|---|---|
|
||
| `budget` | the 22-int result array at `out` | struct, one named field per slot |
|
||
| `research_alloc` | the 3-word `std::vector` at `out+0x58` | `{elements: (last-first)/8}` — the words are heap pointers, only the element count is meaningful and predictable |
|
||
| `inputs` | the `ServerPlayer` snapshot the hook takes before the original runs | struct: every field below, plus the aggregated aid figures |
|
||
|
||
`inputs` never changes across a call, so it costs nothing in the diff and makes every record
|
||
say which player state drove the numbers. Args are `player` (ptr), `budget` (ptr),
|
||
`projected` (bool) and `tail_before` — the three words after the allocation vector, which is
|
||
how the object's shape was established.
|
||
|
||
The snapshot is built in `regions()` into a static and read again in `ours()`; that is only
|
||
safe because `ComputeBudget` is single-threaded and never re-enters itself (same caveat as
|
||
M1 gotcha 4).
|
||
|
||
### Slot map (verified by this trace; fed back into `sots_addresses.h`)
|
||
|
||
```
|
||
0 Sav 6 bonusIncome 12 expenses 18 TRA
|
||
1 systemIncome+ 7 systemIncome- 13 researchMoneyGiven 19 researchPointsGiven
|
||
2 tradeIncome 8 maintenance 14 savingsGiven 20 TRP
|
||
3 shipCarriedPop 9 researchMoneyKept 15 available 21 totalResearchPoints
|
||
4 secondaryMgr 10 debtInterest 16 researchMoney
|
||
5 savingsInterest 11 construction 17 researchPoints
|
||
```
|
||
|
||
then `std::vector<{Tech* node, int points}> researchAlloc` at +0x58 and `int overBudget` at
|
||
+0x64. The vector holds exactly one 8-byte element when the player has a research target and
|
||
is empty otherwise — visible in the trace as `research_alloc.elements` 1 vs 0, and as the
|
||
`last - first` delta of exactly 8 on players 1, 2 and 3.
|
||
|
||
## What our side reads (`src/shim/hooks/budget_inputs.{h,cpp}`, lib `shim_budget`)
|
||
|
||
Host-buildable and unit-tested (`tests/shim_budget/`, ctest `shim_budget_unit`); the shim only
|
||
adds the memory reads. Fields come out of the `ServerPlayer` at the offsets now carried by
|
||
`include/generated/sots_addresses.h` (`ServerPlayer_off_*`, a new `offset` entry kind in the
|
||
RE repo's `addresses.json`):
|
||
|
||
`PlyrIdx`, `Species`, `isAI` (+0xf9), `NPC`, `RebAI`, `Elim`, `Sav`, `Maint`, `ResRate`,
|
||
`ResMod`, `ResScl`, `TRM`, `TRA`, `TRP`, `shrm`, `IncMod`, the two game-setup handicap floats
|
||
(+0x228 income, +0x22c research), the research target pointer (+0x294), the owned-system
|
||
vector (+0x30), the `Nexp` expense sliders (+0x204, 16-byte entries) and the aid vector
|
||
(+0x310, 0x18 stride, four words read). `ExpenseEntry` is pinned with a `static_assert`.
|
||
|
||
### The declared input boundary — say it out loud
|
||
|
||
Six slots are **not** produced by our code. They come from callees B1 does not model
|
||
(`ServerSystem::ComputeOutput` per owned system, the trade manager, ship-carried population,
|
||
the second server manager, and `ConstructionSpend`). The hook takes them as inputs — in
|
||
compare mode from the original's own output (the original has already run), in replace mode
|
||
from one scratch call to the original — and writes them straight back:
|
||
|
||
> slots **1, 2, 3, 4, 7, 11** match by construction and are excluded from the verdict.
|
||
|
||
In this save only slot 1 (system income) ever carried a value; 2, 3, 4, 7 and 11 were zero on
|
||
every one of the 4,437 calls.
|
||
|
||
Everything else — **16 slots plus the allocation element count** — is computed by
|
||
`sots::sim::ComputeBudget` from the snapshot.
|
||
|
||
### The one input we measured rather than snapshotted
|
||
|
||
`StrategyServer::GetDifficultyMods` returns a three-float row that is not reachable from a
|
||
`ServerPlayer`, and ComputeBudget reads two of its entries. Both were **measured from the B1
|
||
trace** and are supplied as named constants (`kDifficultyHuman`, `kDifficultyAI` in
|
||
`budget_inputs.h`), selected by the original's own gate (AI row iff `isAI && !NPC`):
|
||
|
||
| row | maintenance divisor | research multiplier | evidence |
|
||
|---|---|---|---|
|
||
| human / NPC | 1.0 | 1.0 | player 7: `Maint` 1000 → slot 8 = 1000; research points 366 = the plain formula |
|
||
| AI | 3.0 | 1.5 | player 1: `Maint` 500 → slot 8 = 166 (= 500/3) and 1000 → 333; research points 2889 / 1926.1 = 1.5 |
|
||
|
||
The game-option research modifier (`srv.ResMod`) is 1.0 — the save records it as such
|
||
(research 100 %), and the two players on the human row reproduce their research points with it
|
||
at 1.0. These are *inputs*, not results: a later milestone should hook `GetDifficultyMods` and
|
||
snapshot the row instead. They are the only numbers in this milestone that were fitted, and
|
||
they are difficulty-table constants, not formula terms.
|
||
|
||
## The formula bug this found
|
||
|
||
`sim::ComputeBudget` charged `researchMoneyKept = researchMoney - researchMoneyGiven`
|
||
unconditionally. The original writes that slot **inside the `if (ResT)` branch** that also
|
||
pushes the research allocation: a player with no research target reports its research money
|
||
and points (the UI shows them) but never spends the money.
|
||
|
||
Evidence, independent of the hook: player 0 of the reference save has no research target, and
|
||
End Turn takes its treasury from 289,688 to **532,369**. That delta, 242,681, is exactly
|
||
`systemIncome 239,785 + savingsInterest 2,896` with **nothing** subtracted for research; our
|
||
old code would have subtracted 60,670. The trace shows slot 9 = 0 for players 0, 4, 5, 6 and 7
|
||
(no target) and slot 9 = `researchMoney` for players 1, 2 and 3 (target set).
|
||
|
||
Fix: `src/game/sim/economy.cpp`
|
||
|
||
```
|
||
b.researchMoneyKept = in.hasResearchTarget ? b.researchMoney - b.researchMoneyGiven : 0;
|
||
```
|
||
|
||
`docs/game-sim.md` and the `ComputeBudget` header comment updated. Whether `researchMoneyGiven`
|
||
(slot 13, research aid) is gated the same way is **not** established — no player in this save
|
||
has an aid entry.
|
||
|
||
## Runs (`/bulk-storage/re-lab/shim/traces/`)
|
||
|
||
| file | mode | build | calls | result |
|
||
|---|---|---|---|---|
|
||
| `b1-trace-golden.jsonl` | trace | `81218c7-dirty-20260908T0248Z` | 4,623 + 1 selftest | `tracecmp.py` exit 0, 0 invalid |
|
||
| `b1-compare.jsonl` | compare | `81218c7-dirty-20260908T0302Z` | 4,437 compared | **0 diverged**, 0 errors, exit 0 |
|
||
| `b1-replace-shim.log` | replace | `81218c7-dirty-20260908T0312Z` | — | End Turn from `ref-turn2.sav` reproduced the oracle |
|
||
| `b1-endturn-table.txt` | | | 8 | the per-player End-Turn table below |
|
||
| `b1-*.png` | | | | turn 2 / turn 3 in each mode, plus the final main menu |
|
||
|
||
Workload each time: main menu → Load Game → Single Player → `ref-turn2.sav` → Launch →
|
||
strategy map turn 2 (savings 289,688) → **End Turn** → turn 3 (savings 532,369).
|
||
|
||
### The 8 End-Turn calls (compare mode; every slot original == ours)
|
||
|
||
```
|
||
call p AI NPC ResT savings sysInc+ savInt maint resKept available resMoney resPts totRP alloc
|
||
2512 0 False False False 289688 239785 2896 0 0 242681 60670 593 593 0
|
||
2513 1 True False True 80751 273031 807 166 218937 273672 218937 2889 2889 1
|
||
2514 2 True True True 0 0 0 0 0 0 0 0 0 1
|
||
2515 3 True True True 0 0 0 0 0 0 0 0 0 1
|
||
2516 4 True True False 0 0 0 0 0 0 0 0 0 0
|
||
2517 5 True True False 0 0 0 0 0 0 0 0 0 0
|
||
2518 6 True True False 0 0 0 0 0 0 0 0 0 0
|
||
2519 7 True True False 98871 99871 988 1000 0 99859 24964 366 366 0
|
||
```
|
||
|
||
### Per-slot coverage over the 4,437 compared calls
|
||
|
||
`nonzero` counts how often the original wrote a value other than 0 — a slot that was always
|
||
zero was only exercised in its zero branch and is **not** verified in any strong sense.
|
||
|
||
| slot | name | nonzero | distinct values seen | verdict |
|
||
|---|---|---|---|---|
|
||
| 0 | savings | 4380 | 0, 80751, 92651, 98871, 135486, 289688, … | matched |
|
||
| 1 | systemIncome+ | 4380 | 0, 99871, 239189, 239785, … | **input** |
|
||
| 2 | tradeIncome | 0 | 0 | **input**, never exercised |
|
||
| 3 | shipCarriedPop | 0 | 0 | **input**, never exercised |
|
||
| 4 | secondaryManager | 0 | 0 | **input**, never exercised |
|
||
| 5 | savingsInterest | 4380 | 0, 807, 926, 988, 1354, 2896, … | matched (`SavingsInterest`, both the `ownsSystems` gate and the 1 % truncation) |
|
||
| 6 | bonusIncome | 0 | 0 | matched at 0 only — the setup income multiplier is 1 for every player |
|
||
| 7 | systemIncome− | 0 | 0 | **input**, never exercised |
|
||
| 8 | maintenance | 60 | 0, 166, 333, 1000 | matched (`MaintenanceCost` across divisors 1 and 3) |
|
||
| 9 | researchMoneyKept | 59 | 0, 218508, 218937, 219241 | matched — **after** the fix above; both branches of the `ResT` gate |
|
||
| 10 | debtInterest | 0 | 0 | matched at 0 only — no player was in debt |
|
||
| 11 | construction | 0 | 0 | **input**, never exercised (nothing queued) |
|
||
| 12 | expenses | 0 | 0 | matched at 0 only — **no player has an `Nexp` entry**, so `ExpenseTotal` is untested |
|
||
| 13 | researchMoneyGiven | 0 | 0 | matched at 0 only — no aid entries |
|
||
| 14 | savingsGiven | 0 | 0 | matched at 0 only — no aid entries |
|
||
| 15 | available | 4380 | 0, 99859, 242085, 242681, 245108, 273135, … | matched — the running total and its `max(0, …)` |
|
||
| 16 | researchMoney | 4373 | 0, 24964, 60521, 60670, 61277, 218508, … | matched (`ftol(avail x ResRate)`, `ResRate` 0.25 and 0.8) |
|
||
| 17 | researchPoints | 4373 | 0, 366, 591, 593, 598, 2883, … | matched (`ResearchPointsFromMoney`, `ResMod` 0.9/1.0/1.5) |
|
||
| 18 | TRA | 0 | 0 | matched at 0 only |
|
||
| 19 | researchPointsGiven | 0 | 0 | matched at 0 only |
|
||
| 20 | TRP | 0 | 0 | matched at 0 only |
|
||
| 21 | totalResearchPoints | 4373 | 0, 366, 591, 593, 598, 2883, … | matched |
|
||
| — | `research_alloc.elements` | 113 | 0, 1 | matched — both branches |
|
||
|
||
**Honest summary:** 8 slots (0, 5, 8, 9, 15, 16, 17, 21) plus the allocation element count are
|
||
verified against real, varied values; 8 more (6, 10, 12, 13, 14, 18, 19, 20) only ever saw 0
|
||
because the reference save has no expense sliders, no aid entries, no debt and no handicap
|
||
multipliers; 6 are declared inputs. The reference save is a turn-2 two-empire game — a save
|
||
with expense sliders, a player in debt and a research-aid treaty would be the natural next
|
||
workload, and would exercise `ExpenseTotal` and the aid/bonus/savings-aid tail that this run
|
||
could not touch.
|
||
|
||
## Replace mode
|
||
|
||
Replace is possible here only because the hook is honest about the boundary. `ours()` runs the
|
||
original **once on a scratch `Budget` of its own** purely to harvest the six unmodelled slots
|
||
and the research-allocation vector, then computes every slot we do model from the snapshot and
|
||
writes them into the caller's object; the vector's three words are moved over from the scratch
|
||
object (the caller's is empty on entry — the trace shows all three words zero in every
|
||
`before` — so ownership transfers exactly once and nothing is double-freed or leaked).
|
||
|
||
That makes the End-Turn oracle a real test of our arithmetic: any wrong slot changes savings or
|
||
research and the autosave hash breaks. It did not.
|
||
|
||
```
|
||
978041acd168b56e… (Autosave).sav turn-3 post-turn state
|
||
bb4fd9ac89f41e3b… (Autosave EndTurn).sav turn-2 pre-turn state
|
||
```
|
||
|
||
## Gotchas
|
||
|
||
1. **The out parameter is not an `int[25]`.** Declaring a 100-byte region puts a live
|
||
`std::vector` header inside the compared range; ours would write zeros over it. The
|
||
compared region is 88 bytes and the vector is its own region compared by element count.
|
||
2. `research_alloc` is a second region, so its scratch buffer is a *separate* allocation from
|
||
the `budget` one — `ours` cannot reach it by pointer arithmetic off `budget`. `rebind()`
|
||
hands the scratch pointer over in a static.
|
||
3. In **replace** mode the template calls `ours` directly: `regions()` never runs, so `ours`
|
||
has to take the snapshot itself. Anything a descriptor stashes in `regions()` is absent on
|
||
the replace path.
|
||
4. The strategy screen calls `ComputeBudget` every frame, so `hook.…=compare` writes ~6 KB per
|
||
frame. A 4-minute session is a 26 MB trace; that is fine with `trace.flush=always`, but do
|
||
not leave a UI-path hook in compare mode unattended.
|
||
5. The click helper on VM140 executes `cmd.txt` once per `schtasks /Run /TN SOTSUI`. A long
|
||
batch drifts out of sync with the game's own animations — click the save-list row in a batch
|
||
of its own and screenshot to confirm the highlight before clicking OK. Clicking the row
|
||
twice **de-selects** it and leaves OK greyed.
|
||
6. `qm sendkey 140 esc` skips the intro; the main menu is ~45 s after launch on this VM, not
|
||
30 s, when the shim is hooking a hot function.
|