sots-engine/docs/B1.md

237 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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