merge b1 (ComputeBudget verified: 4437 compares, 0 divergences, oracle byte-identical); union shim hooks; regenerate header

This commit is contained in:
alex 2026-09-07 23:24:59 -04:00
commit 9cd997da93
14 changed files with 1278 additions and 7 deletions

View file

@ -41,6 +41,11 @@ if(MINGW)
target_compile_definitions(shim_trace PUBLIC __USE_MINGW_ANSI_STDIO=1) # C99 %lld/%.17g
endif()
# ---- B1 budget adapter: ServerPlayer snapshot <-> game::sim economy (pure, host-tested) ----
add_library(shim_budget STATIC src/shim/hooks/budget_inputs.cpp)
target_link_libraries(shim_budget PUBLIC shim_trace sots_game_sim)
target_compile_options(shim_budget PRIVATE -Wall -Wextra -Werror)
if(WIN32)
# ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ----
add_library(minhook STATIC
@ -52,8 +57,9 @@ if(WIN32)
# ---- hooks: one descriptor per hooked game function (src/shim/hooks/*) ----
add_library(shim_hooks STATIC src/shim/hooks/global_consts.cpp src/shim/hooks/dictionaries.cpp
src/shim/hooks/research.cpp)
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim mars_rng)
src/shim/hooks/research.cpp
src/shim/hooks/compute_budget.cpp)
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim mars_rng shim_budget)
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def)
@ -69,7 +75,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke)
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects)
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects shim_budget)
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t})
endif()

237
docs/B1.md Normal file
View file

@ -0,0 +1,237 @@
# 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.

View file

@ -32,10 +32,10 @@ Confidence legend — **high**: formula verified in the RE notes against the cod
|---|---|---|
| `SavingsInterest` | `Sav >= 0 && ownsSystems ? ftol(Sav x 0.01) : 0` | high |
| `DebtInterest` | `Sav < 0 ? ftol(-Sav x 0.15) : 0` | high |
| `MaintenanceCost` | `Maint / ftol(difficultyDivisor)` | high |
| `MaintenanceCost` | `Maint / ftol(difficultyDivisor)` | high — matched live at divisors 1 and 3 |
| `ExpenseTotal` | per entry `minC = max(min, 0)`, `maxC = clamp(max, 0, 2e9)` with 0 → 2e9, `req = ftol(fraction x float(availPre)) − minC`, `take = min(max(req, 0), maxC − minC)`; total `Σ minC + min(max(Σ take, 0), availPre − Σ minC)` | high — the request is the slider fraction of the pre-expense available income (single-precision product) minus the mandatory minimum |
| `ResearchPointsFromMoney` | `ftol(difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM) x techMult x srv.ResMod x ResScl)` ≈ money x 0.009775 x multipliers | high |
| `ComputeBudget` | line items: +system income (positive part), +trade, +ship-carried population, +secondary manager, +savings interest, +tech bonus; −negative system income, −maintenance, −research kept, −debt interest, −construction, −expenses, −research aid, −savings aid. `avail = max(0, running)`; construction `= min(demand, avail)` for humans; `researchMoney = max(0, ftol((avail − construction) x ResRate))` (0 when projected); `totalRP = max(0, RP + TRA + TRP)`; aid: `given = x pct/100`; `bonus = net > 0 ? max(0, ftol((techIncomeMult − 1) x net)) : 0` where `net` is the full net so far (all income incl. interest and trade, minus maintenance, research money, construction, expenses, research aid); `savingsGiven = min(max(SatAdd(Sav, net + bonus), 0), max(aid, 0))` — capped by the projected treasury, not the turn net | high on the items, signs and both running-total readers; medium on the meaning of the secondary-manager slot |
| `ComputeBudget` | line items: +system income (positive part), +trade, +ship-carried population, +secondary manager, +savings interest, +tech bonus; −negative system income, −maintenance, −research kept, −debt interest, −construction, −expenses, −research aid, −savings aid. `avail = max(0, running)`; construction `= min(demand, avail)` for humans; `researchMoney = max(0, ftol((avail − construction) x ResRate))` (0 when projected); `totalRP = max(0, RP + TRA + TRP)`; `researchMoneyKept` (the money the turn actually spends) is charged **only when the player has a research target** — the slot and the research allocation are written in the same branch; aid: `given = x pct/100`; `bonus = net > 0 ? max(0, ftol((techIncomeMult − 1) x net)) : 0` where `net` is the full net so far (all income incl. interest and trade, minus maintenance, research money, construction, expenses, research aid); `savingsGiven = min(max(SatAdd(Sav, net + bonus), 0), max(aid, 0))` — capped by the projected treasury, not the turn net | high — old-vs-new on the live game (`docs/B1.md`): 4,437 compare calls, 0 divergences, and replace mode reproduced the End-Turn oracle. The research-target gate on `researchMoneyKept` was found by that run. Untested by the reference save: expense sliders, research/savings aid, debt interest, the tech income bonus. The secondary-manager slot is still only a name |
| `TradeRoutesSupported` | `max(1, ceil(civ/REQ_CIV) + ceil(imp/REQ_IMP))` | high |
| `TradeRouteGrossIncome` | age < `STARTUP_TURNS` → `STARTUP_INCOME`; else `MIN_INCOME + Σ_class min(n, capLeft) x PERFREIGHTER[class]` (CRQ, CR, DE; capLeft from `MAX_FREIGHTERS`) x `(1 + STATION_BONUS_TRADE_INCOME x stations)` x `ADDICTION_TRADE_MOD` if addicted | high on the sum; medium on truncation order of the multipliers |
| `TradeRouteIncome` | owner `x OWNERS_SHARE` (clamped 0..1), partner `x (1 − share)`, `x` AI difficulty trade multiplier | high |

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 73f5f1e, generated 2026-09-07 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 1e7428d, generated 2026-09-07 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>

View file

@ -100,7 +100,10 @@ Budget ComputeBudget(const BudgetInputs& in, bool projected) {
b.researchPointsGiven = static_cast<int>(static_cast<std::int64_t>(b.totalResearchPoints) * pct / 100);
b.totalResearchPoints -= b.researchPointsGiven;
b.hasResearchAllocation = in.hasResearchTarget;
b.researchMoneyKept = b.researchMoney - b.researchMoneyGiven;
// The research money is only actually charged when there is something to research: the
// "kept" line and the allocation are written in the same branch, so a player with no
// research target keeps the money in the treasury (B1 trace, docs/B1.md).
b.researchMoneyKept = in.hasResearchTarget ? b.researchMoney - b.researchMoneyGiven : 0;
// Tech income bonus: a share of the full net so far, only when that net is positive.
const std::int64_t netBeforeBonus = running();

View file

@ -114,6 +114,9 @@ int ResearchPointsFromMoney(int researchMoney, double difficultyMult, double res
// the running totals: interest -> system income -> trade/other income -> maintenance ->
// expenses -> available -> construction -> research money/points -> aid -> bonus ->
// savings aid -> net.
// `researchMoneyKept` -- the money the turn actually spends on research -- is only charged
// when the player has a research target; the research money and points are still reported
// (the UI shows them) but a player with no target keeps the money.
// The tech income bonus reads the full net (every income line including interest and
// trade, minus maintenance, research money, construction, expenses and research aid)
// and is only granted when that net is positive: bonus = max(0, ftol((mult - 1) x net)).

View file

@ -0,0 +1,258 @@
#include "shim/hooks/budget_inputs.h"
#include <algorithm>
#include <cstring>
namespace shim::hooks {
using sots::sim::Budget;
using sots::sim::BudgetInputs;
using sots::sim::ExpenseSlider;
using trace::Tv;
int AidResearchPercent(const BudgetSnapshot& s) {
long long pct = 0;
for (int i = 0; i < s.aidCount && i < kMaxAidEntries; ++i)
if (s.aid[i].researchActive > 0) pct += s.aid[i].researchPercent;
return static_cast<int>(std::min<long long>(std::max<long long>(pct, 0), 100));
}
int AidSavings(const BudgetSnapshot& s) {
long long sum = 0;
for (int i = 0; i < s.aidCount && i < kMaxAidEntries; ++i)
if (s.aid[i].savingsActive > 0) sum += s.aid[i].savings;
return static_cast<int>(std::min<long long>(std::max<long long>(sum, -2000000000LL), 2000000000LL));
}
BudgetInputs ToBudgetInputs(const BudgetSnapshot& s) {
BudgetInputs in;
in.savings = s.savings;
// Savings interest only accrues to a player that still holds a system; the owned-system
// vector is the cheapest reading of that gate.
in.ownsSystems = s.ownedSystems > 0;
// The per-system money outputs are an input at B1 (ServerSystem::ComputeOutput is not
// modelled): the two aggregate sums are handed back as a two-element list whose positive
// and negative parts reproduce them.
in.systemIncome.clear();
if (s.aggSystemIncomePositive != 0) in.systemIncome.push_back(s.aggSystemIncomePositive);
if (s.aggSystemIncomeNegative != 0) in.systemIncome.push_back(-s.aggSystemIncomeNegative);
in.tradeIncome = s.aggTradeIncome;
in.secondaryManagerIncome = s.aggSecondaryManagerIncome;
in.shipCarriedPopIncome = s.aggShipCarriedPopIncome;
in.maintenance = s.maintenance;
in.maintenanceDivisor = s.maintenanceDivisor;
in.expenses.clear();
for (int i = 0; i < s.expenseCount && i < kMaxExpenseEntries; ++i) {
ExpenseSlider e;
e.minimum = s.expenses[i].xmin;
e.maximum = s.expenses[i].xmax;
e.fraction = s.expenses[i].xper;
in.expenses.push_back(e);
}
in.isAI = s.isAI;
in.constructionDemand = s.aggConstructionSpend;
in.researchRate = static_cast<double>(s.resRate);
in.resMod = static_cast<double>(s.resMod);
in.shrm = static_cast<double>(s.shrm);
in.trm = static_cast<double>(s.trm);
in.techResearchMult = static_cast<double>(s.setupResearchMult);
in.resScl = static_cast<double>(s.resScl);
in.researchDifficultyMult = s.researchDifficultyMult;
in.serverResMod = s.serverResMod;
in.tra = s.tra;
in.trp = s.trp;
in.aidResearchPercent = AidResearchPercent(s);
in.aidSavings = AidSavings(s);
in.techIncomeMult = static_cast<double>(s.setupIncomeMult);
in.hasResearchTarget = s.hasResearchTarget;
return in;
}
bool IsInputSlot(int slot) {
switch (slot) {
case kSlotSystemIncomePositive:
case kSlotTradeIncome:
case kSlotShipCarriedPopIncome:
case kSlotSecondaryManagerIncome:
case kSlotSystemIncomeNegative:
case kSlotConstruction:
return true;
default:
return false;
}
}
void FillSlots(const Budget& b, const BudgetSnapshot& s, std::int32_t out[kBudgetSlots]) {
for (int i = 0; i < kBudgetSlots; ++i) out[i] = 0;
out[kSlotSavings] = s.savings;
// Declared inputs, written back verbatim.
out[kSlotSystemIncomePositive] = s.aggSystemIncomePositive;
out[kSlotTradeIncome] = s.aggTradeIncome;
out[kSlotShipCarriedPopIncome] = s.aggShipCarriedPopIncome;
out[kSlotSecondaryManagerIncome] = s.aggSecondaryManagerIncome;
out[kSlotSystemIncomeNegative] = s.aggSystemIncomeNegative;
out[kSlotConstruction] = b.construction;
// Ours.
out[kSlotMaintenance] = b.maintenance;
out[kSlotSavingsInterest] = b.savingsInterest;
out[kSlotBonusIncome] = b.bonusIncome;
out[kSlotResearchMoneyKept] = b.researchMoneyKept;
out[kSlotDebtInterest] = b.debtInterest;
out[kSlotExpenses] = b.expenses;
out[kSlotResearchMoneyGiven] = b.researchMoneyGiven;
out[kSlotSavingsGiven] = b.savingsGiven;
out[kSlotAvailable] = b.available;
out[kSlotResearchMoney] = b.researchMoney;
out[kSlotResearchPoints] = b.researchPoints;
out[kSlotTRA] = s.tra;
out[kSlotResearchPointsGiven] = b.researchPointsGiven;
out[kSlotTRP] = s.trp;
out[kSlotTotalResearchPoints] = b.totalResearchPoints;
}
int AllocElementCount(const void* threeWords) {
std::uint32_t w[3];
std::memcpy(w, threeWords, sizeof w);
if (w[1] < w[0]) return -1;
const std::uint32_t bytes = w[1] - w[0];
if (bytes % kAllocElementSize != 0) return -1;
return static_cast<int>(bytes / kAllocElementSize);
}
void FillAllocVector(const Budget& b, void* threeWords) {
// Only the element count is compared, so any base works; 0 keeps the words readable.
const std::uint32_t n = b.hasResearchAllocation ? 1u : 0u;
const std::uint32_t bytes = n * static_cast<std::uint32_t>(kAllocElementSize);
const std::uint32_t w[3] = {0u, bytes, bytes};
std::memcpy(threeWords, w, sizeof w);
}
// ---- trace describers -------------------------------------------------------------------
namespace {
const char* const kSlotNames[kBudgetSlots] = {
"savings", // 0
"systemIncomePos", // 1 (input)
"tradeIncome", // 2 (input)
"shipCarriedPop", // 3 (input)
"secondaryManager", // 4 (input)
"savingsInterest", // 5
"bonusIncome", // 6
"systemIncomeNeg", // 7 (input)
"maintenance", // 8
"researchMoneyKept", // 9
"debtInterest", // 10
"construction", // 11 (input)
"expenses", // 12
"researchMoneyGiven", // 13
"savingsGiven", // 14
"available", // 15
"researchMoney", // 16
"researchPoints", // 17
"tra", // 18
"researchPointsGiven",// 19
"trp", // 20
"totalResearchPoints",// 21
};
} // namespace
Tv DescribeBudgetSlots(const void* data, std::size_t size, unsigned) {
Tv s = trace::tv::struct_();
const std::size_t n = std::min<std::size_t>(size / 4, kBudgetSlots);
for (std::size_t i = 0; i < n; ++i) {
std::int32_t v;
std::memcpy(&v, static_cast<const char*>(data) + i * 4, sizeof v);
s.add(kSlotNames[i], trace::tv::i32(v));
}
return s;
}
Tv DescribeAllocVector(const void* data, std::size_t size, unsigned) {
Tv s = trace::tv::struct_();
if (size < kAllocVectorSize) {
s.add("elements", trace::tv::i32(-1));
return s;
}
// The words themselves are heap pointers; only how many elements they span is meaningful.
s.add("elements", trace::tv::i32(AllocElementCount(data)));
return s;
}
Tv DescribeSnapshot(const void* data, std::size_t size, unsigned) {
Tv s = trace::tv::struct_();
if (size < sizeof(BudgetSnapshot)) {
s.add("error", trace::tv::str("short snapshot"));
return s;
}
BudgetSnapshot v;
std::memcpy(&v, data, sizeof v);
s.add("playerIndex", trace::tv::i32(v.playerIndex));
s.add("species", trace::tv::i32(v.species));
s.add("isAI", trace::tv::boolean(v.isAI));
s.add("npc", trace::tv::boolean(v.npc));
s.add("rebAI", trace::tv::boolean(v.rebAI));
s.add("eliminated", trace::tv::boolean(v.eliminated));
s.add("projected", trace::tv::boolean(v.projected));
s.add("hasResearchTarget", trace::tv::boolean(v.hasResearchTarget));
s.add("savings", trace::tv::i32(v.savings));
s.add("maint", trace::tv::i32(v.maintenance));
s.add("ownedSystems", trace::tv::i32(v.ownedSystems));
s.add("tra", trace::tv::i32(v.tra));
s.add("trp", trace::tv::i32(v.trp));
s.add("resRate", trace::tv::f32(v.resRate));
s.add("resMod", trace::tv::f32(v.resMod));
s.add("resScl", trace::tv::f32(v.resScl));
s.add("trm", trace::tv::f32(v.trm));
s.add("shrm", trace::tv::f32(v.shrm));
s.add("incMod", trace::tv::f32(v.incMod));
s.add("setupIncomeMult", trace::tv::f32(v.setupIncomeMult));
s.add("setupResearchMult", trace::tv::f32(v.setupResearchMult));
s.add("maintenanceDivisor", trace::tv::f64(v.maintenanceDivisor));
s.add("researchDifficultyMult", trace::tv::f64(v.researchDifficultyMult));
s.add("serverResMod", trace::tv::f64(v.serverResMod));
std::vector<Tv> exp;
for (int i = 0; i < v.expenseCount && i < kMaxExpenseEntries; ++i) {
Tv e = trace::tv::struct_();
e.add("xid", trace::tv::i32(v.expenses[i].xid));
e.add("xmin", trace::tv::i32(v.expenses[i].xmin));
e.add("xmax", trace::tv::i32(v.expenses[i].xmax));
e.add("xper", trace::tv::f32(v.expenses[i].xper));
exp.push_back(std::move(e));
}
s.add("expenseCount", trace::tv::i32(v.expenseCount));
s.add("expenses", trace::tv::list(std::move(exp)));
if (v.expensesTruncated) s.add("expensesTruncated", trace::tv::boolean(true));
std::vector<Tv> aid;
for (int i = 0; i < v.aidCount && i < kMaxAidEntries; ++i) {
Tv e = trace::tv::struct_();
e.add("researchPercent", trace::tv::i32(v.aid[i].researchPercent));
e.add("researchActive", trace::tv::i32(v.aid[i].researchActive));
e.add("savings", trace::tv::i32(v.aid[i].savings));
e.add("savingsActive", trace::tv::i32(v.aid[i].savingsActive));
aid.push_back(std::move(e));
}
s.add("aidCount", trace::tv::i32(v.aidCount));
s.add("aid", trace::tv::list(std::move(aid)));
if (v.aidTruncated) s.add("aidTruncated", trace::tv::boolean(true));
s.add("aidResearchPercent", trace::tv::i32(AidResearchPercent(v)));
s.add("aidSavings", trace::tv::i32(AidSavings(v)));
return s;
}
} // namespace shim::hooks

View file

@ -0,0 +1,187 @@
// B1 adapter: the ServerPlayer state ComputeBudget reads <-> our game::sim economy call.
//
// The hook (src/shim/hooks/compute_budget.cpp) snapshots a ServerPlayer into a BudgetSnapshot
// using only the field offsets in the generated header. This file is the pure half: it turns a
// snapshot into sim::BudgetInputs, lays a sim::Budget back out into the 25-int array the game
// writes, and describes both for the trace. It has no OS or game dependencies so it builds and
// is unit-tested on the host.
//
// INPUT BOUNDARY. ComputeBudget also folds in five aggregates produced by callees this
// milestone does not model (per-system money output, the trade manager, ship-carried
// population, a second server manager, and the build-queue spend). Those arrive in the
// snapshot as `agg*` values -- read out of the original's own output in compare mode -- and are
// copied straight back into their slots. They are inputs, not results: the slots they fill
// (1, 2, 3, 4, 7, 11) match by construction and are excluded from the verdict.
//
// The one remaining unreachable input is the difficulty-mods row (a three-float table on the
// StrategyServer, picked per player). Its two relevant entries were measured from the B1 trace
// and are supplied as named constants below; see docs/B1.md.
#pragma once
#include <cstddef>
#include <cstdint>
#include <type_traits>
#include "game/sim/economy.h"
#include "shim/trace/emitter.h"
namespace shim::hooks {
// The out parameter is a Budget object: 22 ints, then a std::vector<{Tech*, int}> holding the
// research allocation (three words at +0x58), then the over-budget int. The trace proved the
// width: the three words after slot 21 are a heap pointer triple that grows by exactly one
// 8-byte element for a player with a research target. Only the 22 ints are results.
enum BudgetSlot : int {
kSlotSavings = 0,
kSlotSystemIncomePositive = 1,
kSlotTradeIncome = 2,
kSlotShipCarriedPopIncome = 3,
kSlotSecondaryManagerIncome = 4,
kSlotSavingsInterest = 5,
kSlotBonusIncome = 6,
kSlotSystemIncomeNegative = 7,
kSlotMaintenance = 8,
kSlotResearchMoneyKept = 9,
kSlotDebtInterest = 10,
kSlotConstruction = 11,
kSlotExpenses = 12,
kSlotResearchMoneyGiven = 13,
kSlotSavingsGiven = 14,
kSlotAvailable = 15,
kSlotResearchMoney = 16,
kSlotResearchPoints = 17,
kSlotTRA = 18,
kSlotResearchPointsGiven = 19,
kSlotTRP = 20,
kSlotTotalResearchPoints = 21,
kBudgetSlots = 22,
};
// Byte offset and size of the research-allocation vector inside the same object.
constexpr std::size_t kAllocVectorOffset = kBudgetSlots * 4;
constexpr std::size_t kAllocVectorSize = 12;
constexpr std::size_t kAllocElementSize = 8; // {Tech* node, int points}
constexpr int kMaxExpenseEntries = 24;
constexpr int kMaxAidEntries = 24;
struct ExpenseEntry { // ServerPlayer Nexp entry, 16 bytes
std::int32_t xid = 0;
std::int32_t xmin = 0;
std::int32_t xmax = 0;
float xper = 0.f;
};
// Pinned: the entry is read straight out of the game's vector, so a padding surprise would
// silently shift every field (M2's +0x14 lesson).
static_assert(sizeof(ExpenseEntry) == 16, "Nexp entry is 16 bytes");
// Stride of one ServerPlayer aid entry in the game's vector. Only four of its words are read.
constexpr std::size_t kAidEntryStride = 0x18;
struct AidEntry { // ServerPlayer aid entry, the four words ComputeBudget reads
std::int32_t researchPercent = 0;
std::int32_t researchActive = 0;
std::int32_t savings = 0;
std::int32_t savingsActive = 0;
};
// Everything the adapter needs. The `p*` group is read straight out of the ServerPlayer; the
// `agg*` group is the declared input boundary described above.
struct BudgetSnapshot {
// --- identity / gates ---
std::int32_t playerIndex = -1;
std::int32_t species = -1;
bool isAI = false;
bool npc = false;
bool rebAI = false;
bool eliminated = false;
bool projected = false;
bool hasResearchTarget = false;
// --- money / research state ---
std::int32_t savings = 0;
std::int32_t maintenance = 0; // Maint, before the difficulty divisor
std::int32_t ownedSystems = 0; // size of the owned-system vector
std::int32_t tra = 0;
std::int32_t trp = 0;
float resRate = 0.f;
float resMod = 1.f;
float resScl = 1.f;
float trm = 0.f;
float shrm = 0.f;
float incMod = 1.f;
float setupIncomeMult = 1.f;
float setupResearchMult = 1.f;
std::int32_t expenseCount = 0;
bool expensesTruncated = false;
ExpenseEntry expenses[kMaxExpenseEntries] = {};
std::int32_t aidCount = 0;
bool aidTruncated = false;
AidEntry aid[kMaxAidEntries] = {};
// --- declared inputs: aggregates from callees not modelled at B1 ---
bool haveAggregates = false;
std::int32_t aggSystemIncomePositive = 0;
std::int32_t aggSystemIncomeNegative = 0;
std::int32_t aggTradeIncome = 0;
std::int32_t aggShipCarriedPopIncome = 0;
std::int32_t aggSecondaryManagerIncome = 0;
std::int32_t aggConstructionSpend = 0; // what the build queues took
// --- the difficulty-mods row (not reachable from a ServerPlayer; see the header note) ---
double maintenanceDivisor = 1.0;
double researchDifficultyMult = 1.0;
// The game-option research modifier lives on the StrategyServer. The reference game was
// created at research 100 %, which the save records as ResMod = 1.0, and the two players on
// the human row reproduce their research points with this at 1.0.
double serverResMod = 1.0;
};
// The difficulty-mods row ComputeBudget reads, measured from the B1 trace rather than
// snapshotted: an AI empire's fleet upkeep was divided by 3 (500 -> 166) and its research points
// came out 1.5x the plain formula, while the human player and the NPC empires used 1 and 1.
// StrategyServer::GetDifficultyMods picks the AI row for a player that is AI and not NPC.
struct DifficultyRow {
double maintenanceDivisor;
double researchMult;
};
constexpr DifficultyRow kDifficultyHuman{1.0, 1.0};
constexpr DifficultyRow kDifficultyAI{3.0, 1.5};
inline DifficultyRow DifficultyRowFor(bool isAI, bool npc) {
return (isAI && !npc) ? kDifficultyAI : kDifficultyHuman;
}
// Elements in the research-allocation vector at the tail of the Budget object.
int AllocElementCount(const void* threeWords);
static_assert(std::is_trivially_copyable<BudgetSnapshot>::value,
"the snapshot is memcpy-ed into a trace region");
// Sum the active aid entries the way ComputeBudget does.
int AidResearchPercent(const BudgetSnapshot& s);
int AidSavings(const BudgetSnapshot& s);
// Map a snapshot onto the economy module's inputs.
sots::sim::BudgetInputs ToBudgetInputs(const BudgetSnapshot& s);
// Lay a computed budget back out into the game's 25-int array. Slots inside the declared input
// boundary are written from the snapshot's aggregates, so they reproduce their inputs exactly.
void FillSlots(const sots::sim::Budget& b, const BudgetSnapshot& s, std::int32_t out[kBudgetSlots]);
// True when slot `i` is inside the declared input boundary (matches by construction).
bool IsInputSlot(int slot);
// Write our prediction of the research-allocation vector into `threeWords`: an empty vector
// when the player has no research target, one element when it has. Only the element count is
// compared -- the original's words are heap pointers.
void FillAllocVector(const sots::sim::Budget& b, void* threeWords);
// Trace describers.
trace::Tv DescribeBudgetSlots(const void* data, std::size_t size, unsigned inline_max);
trace::Tv DescribeSnapshot(const void* data, std::size_t size, unsigned inline_max);
trace::Tv DescribeAllocVector(const void* data, std::size_t size, unsigned inline_max);
} // namespace shim::hooks

View file

@ -0,0 +1,244 @@
#include "shim/hooks/compute_budget.h"
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include "generated/sots_addresses.h"
#include "shim/hooks/budget_inputs.h"
namespace shim::hooks {
using trace::Tv;
namespace {
void (*g_log_line)(const char*) = nullptr;
void logf(const char* fmt, ...) {
if (!g_log_line) return;
char line[1024];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(line, sizeof line, fmt, ap);
va_end(ap);
g_log_line(line);
}
// ---- raw reads out of the game's objects (offsets come from the generated header) ----------
template <class T>
T Peek(const void* base, std::uint32_t off) {
T v{};
std::memcpy(&v, static_cast<const char*>(base) + off, sizeof v);
return v;
}
// MSVC 2010 release std::vector is three words {first, last, end}; the element count is the
// pointer difference over the element size. `max` guards against a stale/garbage header.
std::int32_t VectorCount(const void* base, std::uint32_t off, std::size_t stride, std::size_t max,
const char** items) {
const char* first = Peek<const char*>(base, off);
const char* last = Peek<const char*>(base, off + 4);
*items = first;
if (!first || !last || last < first) return 0;
const std::size_t bytes = static_cast<std::size_t>(last - first);
if (bytes % stride != 0) return -1; // not a vector of this element type
const std::size_t n = bytes / stride;
return static_cast<std::int32_t>(n > max ? max + 1 : n); // max+1 signals truncation
}
// ---- per-call capture ----------------------------------------------------------------------
//
// ComputeBudget runs on the server thread, one player at a time, and never re-enters itself, so
// the snapshot the region declares and the pointer `ours` needs can live in statics between
// regions() -> rebind() -> ours() (docs/M1.md gotcha 4). Do not copy this pattern into a
// re-entrant hook.
BudgetSnapshot g_snap;
std::int32_t* g_live_budget = nullptr;
// Where `ours` writes its research-allocation prediction. The vector sits 12 bytes past the
// end of the `budget` region, so it is a region of its own and its scratch copy is a separate
// buffer: rebind hands the pointer over rather than letting ours walk off the first one.
void* g_alloc_out = nullptr;
bool g_replace_logged = false;
void CaptureSnapshot(const void* self, bool projected) {
using namespace sots::addr;
BudgetSnapshot s;
s.projected = projected;
if (!self) {
g_snap = s;
return;
}
s.playerIndex = Peek<std::int32_t>(self, ServerPlayer_off_PlyrIdx);
s.species = Peek<std::int32_t>(self, ServerPlayer_off_Species);
s.isAI = Peek<std::uint8_t>(self, ServerPlayer_off_IsAI) != 0;
s.npc = Peek<std::uint8_t>(self, ServerPlayer_off_NPC) != 0;
s.rebAI = Peek<std::uint8_t>(self, ServerPlayer_off_RebAI) != 0;
s.eliminated = Peek<std::uint8_t>(self, ServerPlayer_off_Elim) != 0;
s.hasResearchTarget = Peek<const void*>(self, ServerPlayer_off_ResearchTarget) != nullptr;
s.savings = Peek<std::int32_t>(self, ServerPlayer_off_Sav);
s.maintenance = Peek<std::int32_t>(self, ServerPlayer_off_Maint);
s.tra = Peek<std::int32_t>(self, ServerPlayer_off_TRA);
s.trp = Peek<std::int32_t>(self, ServerPlayer_off_TRP);
s.resRate = Peek<float>(self, ServerPlayer_off_ResRate);
s.resMod = Peek<float>(self, ServerPlayer_off_ResMod);
s.resScl = Peek<float>(self, ServerPlayer_off_ResScl);
s.trm = Peek<float>(self, ServerPlayer_off_TRM);
s.shrm = Peek<float>(self, ServerPlayer_off_shrm);
s.incMod = Peek<float>(self, ServerPlayer_off_IncMod);
s.setupIncomeMult = Peek<float>(self, ServerPlayer_off_SetupIncomeMult);
s.setupResearchMult = Peek<float>(self, ServerPlayer_off_SetupResearchMult);
// The difficulty-mods row the original picks for this player (measured; see budget_inputs.h).
const DifficultyRow row = DifficultyRowFor(s.isAI, s.npc);
s.maintenanceDivisor = row.maintenanceDivisor;
s.researchDifficultyMult = row.researchMult;
const char* items = nullptr;
s.ownedSystems = VectorCount(self, ServerPlayer_off_OwnedSystems, 4, 4096, &items);
std::int32_t n = VectorCount(self, ServerPlayer_off_Nexp, sizeof(ExpenseEntry),
kMaxExpenseEntries, &items);
if (n > kMaxExpenseEntries) {
s.expensesTruncated = true;
n = kMaxExpenseEntries;
}
if (n < 0) logf("budget: player %d Nexp vector is not a multiple of the entry size", s.playerIndex);
s.expenseCount = n < 0 ? 0 : n;
for (std::int32_t i = 0; i < s.expenseCount; ++i)
std::memcpy(&s.expenses[i], items + i * sizeof(ExpenseEntry), sizeof(ExpenseEntry));
n = VectorCount(self, ServerPlayer_off_Aid, kAidEntryStride, kMaxAidEntries, &items);
if (n > kMaxAidEntries) {
s.aidTruncated = true;
n = kMaxAidEntries;
}
if (n < 0) logf("budget: player %d aid vector is not a multiple of the entry stride", s.playerIndex);
s.aidCount = n < 0 ? 0 : n;
for (std::int32_t i = 0; i < s.aidCount; ++i) {
const char* e = items + i * kAidEntryStride;
std::memcpy(&s.aid[i].researchPercent, e + 0x08, 4);
std::memcpy(&s.aid[i].researchActive, e + 0x0c, 4);
std::memcpy(&s.aid[i].savings, e + 0x10, 4);
std::memcpy(&s.aid[i].savingsActive, e + 0x14, 4);
}
g_snap = s;
}
} // namespace
void init_compute_budget(void (*log_line)(const char* line)) { g_log_line = log_line; }
void ComputeBudgetHook::describe_args(std::vector<Tv>& out, void* self, std::int32_t* budget,
bool projected) {
out.push_back(trace::tv::ptr(self).named("player"));
out.push_back(trace::tv::ptr(budget).named("budget"));
out.push_back(trace::tv::boolean(projected).named("projected"));
// The words past the research-allocation vector, read before the call: the over-budget int
// the turn driver hands to TechTree::ProcessResearch lives there.
Tv tail = trace::tv::struct_();
if (budget) {
for (int i = 0; i < 3; ++i) {
char key[8];
std::snprintf(key, sizeof key, "w%d", i);
std::int32_t v;
std::memcpy(&v, reinterpret_cast<const char*>(budget) + kAllocVectorOffset +
kAllocVectorSize + i * 4, 4);
tail.add(key, trace::tv::i32(v));
}
}
out.push_back(std::move(tail).named("tail_before"));
}
void ComputeBudgetHook::regions(std::vector<trace::Region>& out, void* self, std::int32_t* budget,
bool projected) {
CaptureSnapshot(self, projected);
g_live_budget = budget;
g_alloc_out = nullptr;
trace::Region r;
r.name = "budget";
r.ptr = budget;
r.size = kBudgetSlots * sizeof(std::int32_t);
r.describe = &DescribeBudgetSlots;
out.push_back(r);
trace::Region alloc;
alloc.name = "research_alloc";
alloc.ptr = budget ? reinterpret_cast<const char*>(budget) + kAllocVectorOffset : nullptr;
alloc.size = kAllocVectorSize;
alloc.describe = &DescribeAllocVector;
out.push_back(alloc);
trace::Region in;
in.name = "inputs";
in.ptr = &g_snap;
in.size = sizeof(BudgetSnapshot);
in.describe = &DescribeSnapshot;
out.push_back(in);
}
ComputeBudgetHook::Args ComputeBudgetHook::rebind(trace::Scratch& s, void* self, std::int32_t*,
bool projected) {
// `ours` never dereferences the player; it works from the snapshot taken in regions().
g_alloc_out = s.count() > 1 && s.size(1) >= kAllocVectorSize ? s.ptr(1) : nullptr;
return Args(self, s.as<std::int32_t>(0), projected);
}
void ComputeBudgetHook::ours(void* self, std::int32_t* budget, bool projected) {
using H = trace::Hook<ComputeBudgetHook>;
const bool replace = H::mode == trace::Mode::Replace;
// In replace mode the template calls `ours` directly, so regions() never ran: take the
// snapshot here. It also means the aggregates have to come from somewhere. Six slots and
// the research allocation are produced by callees B1 does not model, so the original is run
// once on a scratch Budget of our own purely to harvest them; every slot we do model is
// then computed from the snapshot and written into the caller's object. The scratch object
// is abandoned afterwards, and the caller's allocation vector is empty on entry (the trace
// shows all three words zero in every `before`), so handing the vector over transfers
// ownership of the heap block exactly once.
unsigned char scratch[256];
std::int32_t* harvest = nullptr;
if (replace) {
CaptureSnapshot(self, projected);
if (!H::original || !budget) return;
std::memset(scratch, 0, sizeof scratch);
harvest = reinterpret_cast<std::int32_t*>(scratch);
H::original(self, harvest, projected);
}
BudgetSnapshot s = g_snap;
// The declared inputs: the aggregate line items. In compare mode the original has already
// run on the caller's own object, so they are read straight off it.
const std::int32_t* src = replace ? harvest : g_live_budget;
if (src) {
s.haveAggregates = true;
s.aggSystemIncomePositive = src[kSlotSystemIncomePositive];
s.aggSystemIncomeNegative = src[kSlotSystemIncomeNegative];
s.aggTradeIncome = src[kSlotTradeIncome];
s.aggShipCarriedPopIncome = src[kSlotShipCarriedPopIncome];
s.aggSecondaryManagerIncome = src[kSlotSecondaryManagerIncome];
s.aggConstructionSpend = src[kSlotConstruction];
}
const sots::sim::BudgetInputs in = ToBudgetInputs(s);
const sots::sim::Budget b = sots::sim::ComputeBudget(in, projected);
if (budget) FillSlots(b, s, budget);
if (g_alloc_out) FillAllocVector(b, g_alloc_out);
if (replace) {
// The allocation vector (3 words) and the over-budget int that follows it.
std::memcpy(reinterpret_cast<char*>(budget) + kAllocVectorOffset,
scratch + kAllocVectorOffset, kAllocVectorSize + 4);
if (!g_replace_logged) {
g_replace_logged = true;
logf("budget: replace mode active; slots %s and the research allocation are "
"harvested from one scratch call to the original, every other slot is ours",
"1,2,3,4,7,11");
}
}
}
} // namespace shim::hooks

View file

@ -0,0 +1,47 @@
// Hook descriptor for Game::ServerPlayer::ComputeBudget(this, int out[25], bool projected)
// -- the per-player income/expense roll-up (B1, the first behavioural verification).
//
// Verified __thiscall, so the Hook<> template applies with CallConv::Thiscall.
// Called once per player from ServerPlayer::ProcessTurn during End Turn, and by the UI with
// projected = true for the budget readout.
//
// Side-effect model: one region, `budget`, the 25-int array the function fills, described as a
// struct with one named field per slot. A second region, `inputs`, is the snapshot the hook
// takes of the ServerPlayer before the original runs -- it never changes across the call, so it
// costs nothing in the diff and makes every record say which player state drove the numbers.
//
// The out parameter is the head of a larger Budget object (the research allocation the turn
// driver hands to TechTree::ProcessResearch lives past the array); only the 25 ints are
// declared, so nothing our side does can touch the container behind them.
//
// INPUT BOUNDARY: see src/shim/hooks/budget_inputs.h. Slots 1, 2, 3, 4, 7, 8 and 11 come from
// callees this milestone does not model and are consumed as inputs.
#pragma once
#include <cstdint>
#include <tuple>
#include <vector>
#include "shim/trace/hook.h"
namespace shim::hooks {
struct ComputeBudgetHook {
static constexpr const char* name = "Game::ServerPlayer::ComputeBudget";
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
using Ret = void;
using Args = std::tuple<void*, std::int32_t*, bool>;
static void describe_args(std::vector<trace::Tv>& out, void* self, std::int32_t* budget,
bool projected);
static void regions(std::vector<trace::Region>& out, void* self, std::int32_t* budget,
bool projected);
static Args rebind(trace::Scratch& s, void* self, std::int32_t* budget, bool projected);
static void ours(void* self, std::int32_t* budget, bool projected);
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
};
// Process facts the hook needs (a line logger for shim.log). Call once before installing.
void init_compute_budget(void (*log_line)(const char* line));
} // namespace shim::hooks

View file

@ -16,6 +16,7 @@
#include "MinHook.h"
#include "generated/sots_addresses.h"
#include "shim/hooks/dictionaries.h"
#include "shim/hooks/compute_budget.h"
#include "shim/hooks/global_consts.h"
#include "shim/hooks/research.h"
#include "shim/trace/hook.h"
@ -151,6 +152,7 @@ using LoadFileHook = shim::trace::Hook<shim::hooks::GlobalConstsLoadFileHook>;
using WeaponInitHook = shim::trace::Hook<shim::hooks::WeaponDictionaryInitHook>;
using SectionCtorHook = shim::trace::Hook<shim::hooks::SectionDictionaryCtorHook>;
using ProcessResearchHook = shim::trace::Hook<shim::hooks::TechTreeProcessResearchHook>;
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
void InstallHooks(shim::trace::Tracer& tracer) {
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
@ -182,6 +184,9 @@ void InstallHooks(shim::trace::Tracer& tracer) {
// B3: the per-turn research pass (verified thiscall). One call per player per turn.
shim::hooks::init_research(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::TechTreeProcessResearchHook>(tracer, exeBase, sots::addr::TechTree_ProcessResearch);
// B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare.
shim::hooks::init_compute_budget(&ShimLogLine);
InstallTemplateHook<shim::hooks::ComputeBudgetHook>(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget);
}
// ---- lifecycle -----------------------------------------------------------------------------
@ -220,6 +225,7 @@ void Shim_Init(HMODULE self) {
tracer.configure(cfg.trace);
shim::trace::Hook<shim::selftest::FillHook>::register_policy(tracer);
LoadFileHook::register_policy(tracer);
ComputeBudgetHook::register_policy(tracer);
WeaponInitHook::register_policy(tracer);
SectionCtorHook::register_policy(tracer);
ProcessResearchHook::register_policy(tracer);

View file

@ -103,6 +103,22 @@ static void test_budget_hand_case() {
CHECK_EQ(b.net, 3200);
}
// A player with no research target still reports its research money and points -- the UI
// shows them -- but never spends the money: the "kept" line and the research allocation are
// written in the same branch. (Verified against the live game, docs/B1.md.)
static void test_budget_without_research_target() {
BudgetInputs in = base_inputs();
in.hasResearchTarget = false;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoney, 3200);
CHECK_EQ(b.researchPoints, 31);
CHECK_EQ(b.totalResearchPoints, 31);
CHECK(!b.hasResearchAllocation);
CHECK_EQ(b.researchMoneyKept, 0);
// The research money stays in the treasury: 6900 - 500 construction.
CHECK_EQ(b.net, 6400);
}
static void test_budget_projected() {
Budget b = ComputeBudget(base_inputs(), true);
CHECK_EQ(b.researchMoney, 0);
@ -370,6 +386,7 @@ int main() {
test_research_points();
test_expenses();
test_budget_hand_case();
test_budget_without_research_target();
test_budget_projected();
test_budget_debt();
test_budget_aid_and_bonus();

View file

@ -0,0 +1,6 @@
# B1: the ComputeBudget adapter (ServerPlayer snapshot <-> game::sim economy <-> 25-int array).
add_executable(shim_budget_unit_tests unit_tests.cpp)
target_link_libraries(shim_budget_unit_tests PRIVATE shim_budget)
target_include_directories(shim_budget_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim)
target_compile_options(shim_budget_unit_tests PRIVATE -Wall -Wextra -Werror)
add_test(NAME shim_budget_unit COMMAND shim_budget_unit_tests)

View file

@ -0,0 +1,257 @@
// B1 adapter tests: ServerPlayer snapshot -> sim::BudgetInputs -> the game's 22-int budget array.
//
// Hand-written fixtures only. Two of them are the turn-2 state of the reference save's two
// landed players, taken from the save dump's own field names, so the mapping is exercised on
// numbers the live compare will also see.
#include "shim/hooks/budget_inputs.h"
#include <cstring>
#include "check.h"
using namespace shim::hooks;
using sots::sim::Budget;
using sots::sim::BudgetInputs;
namespace {
BudgetSnapshot Blank() {
BudgetSnapshot s;
s.playerIndex = 0;
s.species = 0;
s.ownedSystems = 1;
return s;
}
void test_aid_aggregation() {
BudgetSnapshot s = Blank();
s.aidCount = 3;
s.aid[0] = {30, 1, 500, 1}; // active on both channels
s.aid[1] = {40, 0, 700, 0}; // inactive: contributes nothing
s.aid[2] = {50, 2, 900, 3}; // active (gate is "> 0", not "== 1")
CHECK_EQ(AidResearchPercent(s), 80);
CHECK_EQ(AidSavings(s), 1400);
s.aid[0].researchPercent = 90; // 90 + 50 = 140 clamps to 100
CHECK_EQ(AidResearchPercent(s), 100);
s.aidCount = 0;
CHECK_EQ(AidResearchPercent(s), 0);
CHECK_EQ(AidSavings(s), 0);
}
void test_input_mapping() {
BudgetSnapshot s = Blank();
s.savings = 289688;
s.resRate = 0.25f;
s.resMod = 1.0f;
s.resScl = 1.0f;
s.trm = 0.f;
s.shrm = 0.f;
s.tra = 3;
s.trp = 4;
s.setupIncomeMult = 1.5f;
s.setupResearchMult = 2.0f;
s.isAI = true;
s.hasResearchTarget = true;
s.aggSystemIncomePositive = 239189;
s.aggSystemIncomeNegative = 17;
s.aggTradeIncome = 11;
s.aggShipCarriedPopIncome = 22;
s.aggSecondaryManagerIncome = 33;
s.maintenance = 500;
s.maintenanceDivisor = 2.0;
s.aggConstructionSpend = 4000;
s.expenseCount = 2;
s.expenses[0] = {7, 100, 900, 0.10f};
s.expenses[1] = {8, 0, 0, 0.25f};
const BudgetInputs in = ToBudgetInputs(s);
CHECK_EQ(in.savings, 289688);
CHECK(in.ownsSystems);
CHECK_EQ(static_cast<int>(in.systemIncome.size()), 2);
CHECK_EQ(in.systemIncome[0], 239189);
CHECK_EQ(in.systemIncome[1], -17);
CHECK_EQ(in.tradeIncome, 11);
CHECK_EQ(in.shipCarriedPopIncome, 22);
CHECK_EQ(in.secondaryManagerIncome, 33);
CHECK_EQ(in.maintenance, 500);
CHECK_NEAR(in.maintenanceDivisor, 2.0, 1e-12);
CHECK_EQ(in.constructionDemand, 4000);
CHECK(in.isAI);
CHECK(in.hasResearchTarget);
CHECK_NEAR(in.researchRate, 0.25, 1e-9);
CHECK_NEAR(in.techIncomeMult, 1.5, 1e-9);
CHECK_NEAR(in.techResearchMult, 2.0, 1e-9);
CHECK_EQ(in.tra, 3);
CHECK_EQ(in.trp, 4);
CHECK_EQ(static_cast<int>(in.expenses.size()), 2);
CHECK_EQ(in.expenses[0].minimum, 100);
CHECK_EQ(in.expenses[0].maximum, 900);
CHECK_NEAR(in.expenses[0].fraction, 0.10f, 1e-7);
// No systems -> no savings interest.
s.ownedSystems = 0;
CHECK(!ToBudgetInputs(s).ownsSystems);
// A zero aggregate must not become a phantom system in the list.
s.ownedSystems = 1;
s.aggSystemIncomeNegative = 0;
CHECK_EQ(static_cast<int>(ToBudgetInputs(s).systemIncome.size()), 1);
}
void test_slot_layout() {
// The six slots the milestone consumes as inputs.
const int inputs[] = {1, 2, 3, 4, 7, 11};
for (int i = 0; i < kBudgetSlots; ++i) {
bool expected = false;
for (int k : inputs) expected = expected || (k == i);
CHECK_EQ(IsInputSlot(i), expected);
}
}
// Player 0 of the reference save at turn 2: human, one system, no research target, no expense
// sliders, no aid, treasury 289,688, ResRate 0.25.
void test_reference_player0() {
BudgetSnapshot s = Blank();
s.savings = 289688;
s.resRate = 0.25f;
s.resMod = 1.0f;
s.resScl = 1.0f;
s.isAI = false;
s.hasResearchTarget = false;
s.aggSystemIncomePositive = 239189;
s.aggConstructionSpend = 0;
const Budget b = sots::sim::ComputeBudget(ToBudgetInputs(s), s.projected);
std::int32_t out[kBudgetSlots];
FillSlots(b, s, out);
CHECK_EQ(out[kSlotSavings], 289688);
CHECK_EQ(out[kSlotSystemIncomePositive], 239189);
CHECK_EQ(out[kSlotSavingsInterest], 2896); // ftol(289688 x 0.01)
CHECK_EQ(out[kSlotDebtInterest], 0);
CHECK_EQ(out[kSlotExpenses], 0);
// available = 239189 + 2896
CHECK_EQ(out[kSlotAvailable], 242085);
CHECK_EQ(out[kSlotConstruction], 0);
CHECK_EQ(out[kSlotResearchMoney], 60521); // ftol(242085 x 0.25)
// 60521/50 x 1.15 x 0.5 x 0.85 = 591.59... -> 591
CHECK_EQ(out[kSlotResearchPoints], 591);
CHECK_EQ(out[kSlotResearchMoneyGiven], 0);
CHECK_EQ(out[kSlotTotalResearchPoints], 591);
CHECK_EQ(out[kSlotBonusIncome], 0); // setup income multiplier is 1
CHECK_EQ(out[kSlotSavingsGiven], 0);
// No research target: the research money is reported but never charged, so the whole
// income lands in the treasury (the B1 trace's player 0).
CHECK_EQ(out[kSlotResearchMoneyKept], 0);
CHECK_EQ(b.net, 242085);
}
// Player 1: the AI, treasury 92,651, ResRate 0.8, ResMod 0.9, Maint 500, a research target.
void test_reference_player1() {
BudgetSnapshot s = Blank();
s.playerIndex = 1;
s.species = 2;
s.isAI = true;
s.savings = 92651;
s.resRate = 0.800000011920929f;
s.resMod = 0.8999999761581421f;
s.resScl = 1.0f;
s.hasResearchTarget = true;
s.aggSystemIncomePositive = 100000;
s.maintenance = 500;
s.maintenanceDivisor = kDifficultyAI.maintenanceDivisor;
s.researchDifficultyMult = kDifficultyAI.researchMult;
const Budget b = sots::sim::ComputeBudget(ToBudgetInputs(s), s.projected);
std::int32_t out[kBudgetSlots];
FillSlots(b, s, out);
CHECK_EQ(out[kSlotSavingsInterest], 926);
CHECK_EQ(out[kSlotMaintenance], 166); // 500 / ftol(3.0)
CHECK_EQ(out[kSlotAvailable], 100760); // 100000 + 926 - 166
CHECK_EQ(out[kSlotConstruction], 0); // AI: the construction slot stays 0 here
CHECK(b.hasResearchAllocation);
CHECK(out[kSlotResearchMoney] > 0);
CHECK_EQ(out[kSlotResearchMoneyKept], out[kSlotResearchMoney]);
}
// The difficulty row the original picks: the AI row only for a player that is AI and not NPC.
void test_difficulty_row() {
CHECK_NEAR(DifficultyRowFor(false, false).maintenanceDivisor, 1.0, 1e-12);
CHECK_NEAR(DifficultyRowFor(false, false).researchMult, 1.0, 1e-12);
CHECK_NEAR(DifficultyRowFor(true, false).maintenanceDivisor, 3.0, 1e-12);
CHECK_NEAR(DifficultyRowFor(true, false).researchMult, 1.5, 1e-12);
CHECK_NEAR(DifficultyRowFor(true, true).maintenanceDivisor, 1.0, 1e-12); // NPC: human row
CHECK_NEAR(DifficultyRowFor(false, true).researchMult, 1.0, 1e-12);
}
// The research-allocation vector at the tail of the Budget object: one 8-byte element exactly
// when the player has a research target.
void test_alloc_vector() {
BudgetSnapshot s = Blank();
s.aggSystemIncomePositive = 1000;
std::uint32_t words[3] = {0xdeadbeef, 0xdeadbeef, 0xdeadbeef};
s.hasResearchTarget = false;
FillAllocVector(sots::sim::ComputeBudget(ToBudgetInputs(s), false), words);
CHECK_EQ(AllocElementCount(words), 0);
s.hasResearchTarget = true;
FillAllocVector(sots::sim::ComputeBudget(ToBudgetInputs(s), false), words);
CHECK_EQ(AllocElementCount(words), 1);
// The original's words are heap pointers; only the span matters.
const std::uint32_t live[3] = {888397232u, 888397240u, 888397240u};
CHECK_EQ(AllocElementCount(live), 1);
const std::uint32_t empty[3] = {0u, 0u, 0u};
CHECK_EQ(AllocElementCount(empty), 0);
const std::uint32_t bad[3] = {8u, 0u, 0u};
CHECK_EQ(AllocElementCount(bad), -1);
const shim::trace::Tv d = DescribeAllocVector(live, sizeof live, 256);
CHECK_EQ(static_cast<int>(d.items[0].i), 1);
}
void test_describers() {
std::int32_t slots[kBudgetSlots];
for (int i = 0; i < kBudgetSlots; ++i) slots[i] = i * 10;
const shim::trace::Tv t = DescribeBudgetSlots(slots, sizeof slots, 256);
CHECK_EQ(static_cast<int>(t.keys.size()), static_cast<int>(kBudgetSlots));
CHECK(t.keys[kSlotResearchPoints] == "researchPoints");
CHECK_EQ(t.items[kSlotResearchPoints].i, 170);
BudgetSnapshot s = Blank();
s.savings = 42;
s.expenseCount = 1;
s.expenses[0] = {1, 2, 3, 0.5f};
s.aidCount = 1;
s.aid[0] = {25, 1, 60, 1};
const shim::trace::Tv d = DescribeSnapshot(&s, sizeof s, 256);
bool sawSavings = false, sawAidPct = false;
for (std::size_t i = 0; i < d.keys.size(); ++i) {
if (d.keys[i] == "savings") { sawSavings = true; CHECK_EQ(d.items[i].i, 42); }
if (d.keys[i] == "aidResearchPercent") { sawAidPct = true; CHECK_EQ(d.items[i].i, 25); }
}
CHECK(sawSavings);
CHECK(sawAidPct);
// A short region must not read past the buffer.
const shim::trace::Tv bad = DescribeSnapshot(&s, 4, 256);
CHECK_EQ(static_cast<int>(bad.keys.size()), 1);
}
} // namespace
int main() {
test_aid_aggregation();
test_input_mapping();
test_slot_layout();
test_reference_player0();
test_reference_player1();
test_difficulty_row();
test_alloc_vector();
test_describers();
return simtest::finish("shim_budget");
}