14 KiB
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:
- 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 toTechTree::ProcessResearch. researchMoneyKeptis only charged when the player has a research target. Oursim::ComputeBudgetcharged 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
- The out parameter is not an
int[25]. Declaring a 100-byte region puts a livestd::vectorheader 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. research_allocis a second region, so its scratch buffer is a separate allocation from thebudgetone —ourscannot reach it by pointer arithmetic offbudget.rebind()hands the scratch pointer over in a static.- In replace mode the template calls
oursdirectly:regions()never runs, soourshas to take the snapshot itself. Anything a descriptor stashes inregions()is absent on the replace path. - The strategy screen calls
ComputeBudgetevery frame, sohook.…=comparewrites ~6 KB per frame. A 4-minute session is a 26 MB trace; that is fine withtrace.flush=always, but do not leave a UI-path hook in compare mode unattended. - The click helper on VM140 executes
cmd.txtonce perschtasks /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. qm sendkey 140 escskips the intro; the main menu is ~45 s after launch on this VM, not 30 s, when the shim is hooking a hot function.