# 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` 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.