diff --git a/CMakeLists.txt b/CMakeLists.txt index 36cfd7e..28052cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/docs/B1.md b/docs/B1.md new file mode 100644 index 0000000..844d609 --- /dev/null +++ b/docs/B1.md @@ -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` 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. diff --git a/docs/game-sim.md b/docs/game-sim.md index f8af576..133e8be 100644 --- a/docs/game-sim.md +++ b/docs/game-sim.md @@ -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 | diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index e619ed7..5bb917e 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -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 diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp index e649112..99f1891 100644 --- a/src/game/sim/economy.cpp +++ b/src/game/sim/economy.cpp @@ -100,7 +100,10 @@ Budget ComputeBudget(const BudgetInputs& in, bool projected) { b.researchPointsGiven = static_cast(static_cast(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(); diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h index 5c5b681..85ae86c 100644 --- a/src/game/sim/economy.h +++ b/src/game/sim/economy.h @@ -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)). diff --git a/src/shim/hooks/budget_inputs.cpp b/src/shim/hooks/budget_inputs.cpp new file mode 100644 index 0000000..b6ee7a1 --- /dev/null +++ b/src/shim/hooks/budget_inputs.cpp @@ -0,0 +1,258 @@ +#include "shim/hooks/budget_inputs.h" + +#include +#include + +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(std::min(std::max(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(std::min(std::max(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(s.resRate); + in.resMod = static_cast(s.resMod); + in.shrm = static_cast(s.shrm); + in.trm = static_cast(s.trm); + in.techResearchMult = static_cast(s.setupResearchMult); + in.resScl = static_cast(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(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(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(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(size / 4, kBudgetSlots); + for (std::size_t i = 0; i < n; ++i) { + std::int32_t v; + std::memcpy(&v, static_cast(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 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 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 diff --git a/src/shim/hooks/budget_inputs.h b/src/shim/hooks/budget_inputs.h new file mode 100644 index 0000000..78b07ca --- /dev/null +++ b/src/shim/hooks/budget_inputs.h @@ -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 +#include +#include + +#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::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 diff --git a/src/shim/hooks/compute_budget.cpp b/src/shim/hooks/compute_budget.cpp new file mode 100644 index 0000000..d83b373 --- /dev/null +++ b/src/shim/hooks/compute_budget.cpp @@ -0,0 +1,244 @@ +#include "shim/hooks/compute_budget.h" + +#include +#include +#include + +#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 +T Peek(const void* base, std::uint32_t off) { + T v{}; + std::memcpy(&v, static_cast(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(base, off); + const char* last = Peek(base, off + 4); + *items = first; + if (!first || !last || last < first) return 0; + const std::size_t bytes = static_cast(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(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(self, ServerPlayer_off_PlyrIdx); + s.species = Peek(self, ServerPlayer_off_Species); + s.isAI = Peek(self, ServerPlayer_off_IsAI) != 0; + s.npc = Peek(self, ServerPlayer_off_NPC) != 0; + s.rebAI = Peek(self, ServerPlayer_off_RebAI) != 0; + s.eliminated = Peek(self, ServerPlayer_off_Elim) != 0; + s.hasResearchTarget = Peek(self, ServerPlayer_off_ResearchTarget) != nullptr; + + s.savings = Peek(self, ServerPlayer_off_Sav); + s.maintenance = Peek(self, ServerPlayer_off_Maint); + s.tra = Peek(self, ServerPlayer_off_TRA); + s.trp = Peek(self, ServerPlayer_off_TRP); + s.resRate = Peek(self, ServerPlayer_off_ResRate); + s.resMod = Peek(self, ServerPlayer_off_ResMod); + s.resScl = Peek(self, ServerPlayer_off_ResScl); + s.trm = Peek(self, ServerPlayer_off_TRM); + s.shrm = Peek(self, ServerPlayer_off_shrm); + s.incMod = Peek(self, ServerPlayer_off_IncMod); + s.setupIncomeMult = Peek(self, ServerPlayer_off_SetupIncomeMult); + s.setupResearchMult = Peek(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& 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(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& 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(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(0), projected); +} + +void ComputeBudgetHook::ours(void* self, std::int32_t* budget, bool projected) { + using H = trace::Hook; + 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(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(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 diff --git a/src/shim/hooks/compute_budget.h b/src/shim/hooks/compute_budget.h new file mode 100644 index 0000000..70c1338 --- /dev/null +++ b/src/shim/hooks/compute_budget.h @@ -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 +#include +#include + +#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; + + static void describe_args(std::vector& out, void* self, std::int32_t* budget, + bool projected); + static void regions(std::vector& 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 diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 2847050..540a47a 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -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; using WeaponInitHook = shim::trace::Hook; using SectionCtorHook = shim::trace::Hook; using ProcessResearchHook = shim::trace::Hook; +using ComputeBudgetHook = shim::trace::Hook; void InstallHooks(shim::trace::Tracer& tracer) { const uintptr_t exeBase = reinterpret_cast(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(tracer, exeBase, sots::addr::TechTree_ProcessResearch); + // B1: ServerPlayer::ComputeBudget (verified thiscall) -- the first behavioural compare. + shim::hooks::init_compute_budget(&ShimLogLine); + InstallTemplateHook(tracer, exeBase, sots::addr::ServerPlayer_ComputeBudget); } // ---- lifecycle ----------------------------------------------------------------------------- @@ -220,6 +225,7 @@ void Shim_Init(HMODULE self) { tracer.configure(cfg.trace); shim::trace::Hook::register_policy(tracer); LoadFileHook::register_policy(tracer); + ComputeBudgetHook::register_policy(tracer); WeaponInitHook::register_policy(tracer); SectionCtorHook::register_policy(tracer); ProcessResearchHook::register_policy(tracer); diff --git a/tests/game_sim/test_economy.cpp b/tests/game_sim/test_economy.cpp index 764b1ca..08ccade 100644 --- a/tests/game_sim/test_economy.cpp +++ b/tests/game_sim/test_economy.cpp @@ -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(); diff --git a/tests/shim_budget/CMakeLists.txt b/tests/shim_budget/CMakeLists.txt new file mode 100644 index 0000000..f43fc8f --- /dev/null +++ b/tests/shim_budget/CMakeLists.txt @@ -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) diff --git a/tests/shim_budget/unit_tests.cpp b/tests/shim_budget/unit_tests.cpp new file mode 100644 index 0000000..6eac046 --- /dev/null +++ b/tests/shim_budget/unit_tests.cpp @@ -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 + +#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(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(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(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(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(t.keys.size()), static_cast(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(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"); +}