sots-engine/docs/B1.md

14 KiB
Raw Blame History

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.