b1: ComputeBudget hook + adapter; fix researchMoneyKept to be charged only with a research target; budget out-param is int[22]

This commit is contained in:
alex 2026-09-07 23:24:10 -04:00
parent 81218c732c
commit 16f3aa90e5
14 changed files with 1363 additions and 13 deletions

View file

@ -41,6 +41,11 @@ if(MINGW)
target_compile_definitions(shim_trace PUBLIC __USE_MINGW_ANSI_STDIO=1) # C99 %lld/%.17g target_compile_definitions(shim_trace PUBLIC __USE_MINGW_ANSI_STDIO=1) # C99 %lld/%.17g
endif() 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) if(WIN32)
# ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ---- # ---- shim: proxy binkw32.dll that the original game loads (Phase 2 frontend) ----
add_library(minhook STATIC add_library(minhook STATIC
@ -51,8 +56,9 @@ if(WIN32)
target_include_directories(minhook PUBLIC third_party/minhook/include) target_include_directories(minhook PUBLIC third_party/minhook/include)
# ---- hooks: one descriptor per hooked game function (src/shim/hooks/*) ---- # ---- 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) add_library(shim_hooks STATIC src/shim/hooks/global_consts.cpp src/shim/hooks/dictionaries.cpp
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config) src/shim/hooks/compute_budget.cpp)
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config shim_budget)
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror) target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def) add_library(binkw32 SHARED src/shim/main.cpp src/shim/binkw32.def)
@ -68,7 +74,7 @@ else()
add_executable(addr_smoke tests/addr_smoke.cpp) add_executable(addr_smoke tests/addr_smoke.cpp)
target_link_libraries(addr_smoke PRIVATE sots_addresses) target_link_libraries(addr_smoke PRIVATE sots_addresses)
add_test(NAME addr_smoke COMMAND addr_smoke) 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) if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t}) add_subdirectory(tests/${_t})
endif() 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 | | `SavingsInterest` | `Sav >= 0 && ownsSystems ? ftol(Sav x 0.01) : 0` | high |
| `DebtInterest` | `Sav < 0 ? ftol(-Sav x 0.15) : 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 | | `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 | | `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 | | `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 | | `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 | | `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). // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 87a39b6, 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). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>
@ -23,9 +23,9 @@ constexpr uint32_t StrategyClient_EndTurn = 0x00383be0;
constexpr uint32_t StrategyServer_BeginProcessTurn = 0x003d98e0; constexpr uint32_t StrategyServer_BeginProcessTurn = 0x003d98e0;
// thiscall void (StrategyServer* this) [unverified] // thiscall void (StrategyServer* this) [unverified]
constexpr uint32_t StrategyServer_ProcessTurn = 0x003dc6c0; constexpr uint32_t StrategyServer_ProcessTurn = 0x003dc6c0;
// thiscall void (ServerPlayer* this, int out[25], bool projected) [verified] // thiscall void (ServerPlayer* this, Budget* out, bool projected) /* Budget = { int slot[22]; std::vector<{Tech* node, int points}> researchAlloc (3 words @+0x58); int overBudget @+0x64 }. The array is 22 ints, not 25: the three words after slot 21 are the allocation vector and grow by one 8-byte element exactly when the player has a research target. Slots: 0 Sav, 1 systemIncome+, 2 trade, 3 shipCarriedPop, 4 secondaryManager, 5 savingsInterest, 6 bonusIncome, 7 systemIncome-, 8 maintenance, 9 researchMoneyKept (written only when ResT is set), 10 debtInterest, 11 construction, 12 expenses, 13 researchMoneyGiven, 14 savingsGiven, 15 available, 16 researchMoney, 17 researchPoints, 18 TRA, 19 researchPointsGiven, 20 TRP, 21 totalResearchPoints */ [verified-by-trace]
constexpr uint32_t ServerPlayer_ComputeBudget = 0x00463030; constexpr uint32_t ServerPlayer_ComputeBudget = 0x00463030;
// thiscall void (TechTree* this, ?, vector<{node,int}>* alloc, int* overbudget) /* decay loop hits current target */ [verified] // thiscall void (TechTree* this, Mars::RNG* rng, std::vector<{TechDef* target, int points}>* alloc, int* overbudget) /* args confirmed at the single call site: rng = StrategyServer+0x16c object, passed as the RNG *object* and re-based to &mt with lea ecx,[rng+4] before each NextFloat. Loop: node = this->nodes[*(int*)entry.target]; lo/hi from Cost x 50/150 /100 (32-bit imul, signed /100); spend = min(points, hi-progress) with NO clamp at 0; *overbudget += points-spend; odds and roll are both narrowed to float32 before the compare; decay loop hits every state-2 node with progress != 0; the trailing unlock-event loop makes no RNG draw and writes no node */ [verified]
constexpr uint32_t TechTree_ProcessResearch = 0x001876c0; constexpr uint32_t TechTree_ProcessResearch = 0x001876c0;
// thiscall void (ServerSystem* this /*+8 IStreamable*/, Stream* s) [verified-by-save] // thiscall void (ServerSystem* this /*+8 IStreamable*/, Stream* s) [verified-by-save]
constexpr uint32_t ServerSystem_Read = 0x0035d4b0; constexpr uint32_t ServerSystem_Read = 0x0035d4b0;
@ -49,14 +49,26 @@ constexpr uint32_t StrategyServer_Read = 0x003d27a0;
constexpr uint32_t StrategyServer_Write = 0x0039fa70; constexpr uint32_t StrategyServer_Write = 0x0039fa70;
// thiscall void (Stream* this, const char* tag, NetworkObject* obj) [unverified] // thiscall void (Stream* this, const char* tag, NetworkObject* obj) [unverified]
constexpr uint32_t Stream_WriteNetworkObjectId = 0x00416490; constexpr uint32_t Stream_WriteNetworkObjectId = 0x00416490;
// thiscall void (RNG* this) // this in ECX, no stack args, RET 0; 227/396 split [verified] // thiscall void (uint32_t* mtBlock) // ECX = &mt = RNG object + 4 (NOT the object). N=624 M=397 MATRIX_A 0x9908b0df, 227/396 split; ends with left(&mt+0x9c4) = 624 and next(&mt+0x9c0) = &mt[0]. RET 0 [verified]
constexpr uint32_t RNG_Twist = 0x00026e00; constexpr uint32_t RNG_Twist = 0x00026e00;
// thiscall RNG* (RNG* this, uint32_t seed) // RET 4; MT19937 init mt[i]=(mt[i-1]^(mt[i-1]>>30))*0x6c078965+i for i=1..623, then twists. this: vftable@0, mt[624]@+4, left@+0x9c4 [verified] // thiscall RNG* (RNG* this, uint32_t seed) // RET 4; writes vftable 0x009e9aec, seeds from &mt = this+4: mt[0]=seed, mt[i]=0x6c078965*(mt[i-1]^(mt[i-1]>>30))+i for i=1..623, then Twist(&mt). OBJECT layout: {vftable @+0; uint32 mt[624] @+4 .. +0x9c3; uint32* next @+0x9c4; int left @+0x9c8} = 0x9cc bytes. Beware: NextFloat/NextInt/Twist take &mt, so *their* this+0x9c0/+0x9c4 are next/left [verified]
constexpr uint32_t RNG_Seed = 0x0009fdf0; constexpr uint32_t RNG_Seed = 0x0009fdf0;
// thiscall float (RNG* this) // returns via x87 (float10 in decompile) -- FLOAT PARITY RISK; lazy twist: if (left==0) Twist() [verified] // thiscall float (uint32_t* mtBlock) // ECX = &mt = RNG object + 4. Lazy twist (if left==0 Twist()), y = *next++, left--, standard MT tempering, then value = (double)(uint32)y * 1/(2^32-1) -- the multiplier at 0x009e61b0 is 0x3df0000000001000 = 1/4294967295.0, NOT 2^-32; the unsigned fix-up at 0x009e61b8 is +2^32 after a signed fild. Left in st(0) at the current x87 precision and narrowed by the caller; range [0,1] INCLUSIVE [verified]
constexpr uint32_t RNG_NextFloat = 0x0007d830; constexpr uint32_t RNG_NextFloat = 0x0007d830;
// thiscall uint32_t (RNG* this) [unverified] // thiscall uint32_t (uint32_t* mtBlock, const uint32_t* n) // ECX = &mt; the bound is passed BY POINTER, RET 4. mask = smallest 2^k-1 >= *n via the or/shift cascade; draws (lazy twist + tempering) until (y & mask) <= *n -- note the loop condition is `ja`, so the result is uniform on [0, *n] INCLUSIVE, not [0, n) [verified]
constexpr uint32_t RNG_NextInt = 0x000271c0; constexpr uint32_t RNG_NextInt = 0x000271c0;
// offset sizeof(Mars::RNG) -- {vftable @+0; uint32 mt[624] @+4; uint32* next @+0x9c4; int left @+0x9c8} [verified]
constexpr uint32_t RNG_size = 0x000009cc;
// offset uint32 mt[624]; also the pointer Twist/NextFloat/NextInt receive in ECX [verified]
constexpr uint32_t RNG_off_State = 0x00000004;
// offset uint32* next; always &mt[624 - left], and recomputed from left on load, so `left` alone pins the stream position [verified]
constexpr uint32_t RNG_off_Next = 0x000009c4;
// offset int left; words still unread in the current block [verified]
constexpr uint32_t RNG_off_Left = 0x000009c8;
// offset int tech id -- the index into TechTree_off_Nodes [verified]
constexpr uint32_t TechDef_off_TechId = 0x00000000;
// offset sizeof({TechDef* target, int points}) -- the element of the vector ProcessResearch walks [verified]
constexpr uint32_t ResearchAlloc_stride = 0x00000008;
// cdecl void (void) /* sots.ini [Modules] Mount<N> -> gobio::Init */ [verified] // cdecl void (void) /* sots.ini [Modules] Mount<N> -> gobio::Init */ [verified]
constexpr uint32_t Mars_Application_MountModules = 0x004a0a20; constexpr uint32_t Mars_Application_MountModules = 0x004a0a20;
// cdecl void (int* count, const char*** table) /* count 3 @0x00b2d514, table @0x00a35e98 */ [verified] // cdecl void (int* count, const char*** table) /* count 3 @0x00b2d514, table @0x00a35e98 */ [verified]
@ -227,6 +239,28 @@ constexpr uint32_t MasterTechTree_IsTech = 0x0017d5d0;
constexpr uint32_t MasterTechTree_GetTechDef = 0x0017d610; constexpr uint32_t MasterTechTree_GetTechDef = 0x0017d610;
// thiscall bool (TechTree* this, int techId) [verified] // thiscall bool (TechTree* this, int techId) [verified]
constexpr uint32_t TechTree_HasResearched = 0x0017d810; constexpr uint32_t TechTree_HasResearched = 0x0017d810;
// thiscall int (TechTree* this, TechNode* node) // RET 4. node==0 -> INT_MAX; node->costRP(+0x18)==INT_MAX -> INT_MAX; costRP<=0 or this->owner(+0xc)==0 or mult<=0 -> 0; else max(1, _ftol2((float)TechCostMult(owner, node->def) * costRP)). Reads only (no RNG, no writes), so a reimplementation may call it on a scratch tree whose +0xc is the real owner [verified]
constexpr uint32_t TechTree_Cost = 0x0017da00;
// thiscall float (ServerPlayer* this, TechDef* def) // 1.0 - 0.25 per applicable species research-bonus tech owned; read-only [verified]
constexpr uint32_t ServerPlayer_TechCostMult = 0x0040db50;
// thiscall void (TechTree* this, TechDef* def, int flags) // state 4 + turn/order stamps + owner callback + child unlock cascade. Makes no direct RNG draw; the owner callback is not audited, so a compare that runs it is out of scope [verified]
constexpr uint32_t TechTree_SetResearched = 0x00181e10;
// offset ServerPlayer* owner (0 for a tree with no player) [verified]
constexpr uint32_t TechTree_off_Owner = 0x0000000c;
// offset std::vector<TechNode*> indexed by tech id (MSVC2010: 3 words {first@+0x10, last@+0x14, end@+0x18}); entries may be NULL [verified]
constexpr uint32_t TechTree_off_Nodes = 0x00000010;
// offset sizeof(TechNode) -- the ctor's operator new argument [verified]
constexpr uint32_t TechNode_size = 0x00000034;
// offset TechDef* def; *(int*)def is the tech id used to index TechTree_off_Nodes [verified]
constexpr uint32_t TechNode_off_Def = 0x00000000;
// offset int state (0 hidden, 1 parent researched, 2 available, 4 researched); ctor writes 0 [verified]
constexpr uint32_t TechNode_off_State = 0x00000014;
// offset int costRP, INT_MAX = no researched parent yet; ctor writes 0x7fffffff [verified]
constexpr uint32_t TechNode_off_CostRP = 0x00000018;
// offset int progress in RP; the only node word ProcessResearch itself writes besides the flag [verified]
constexpr uint32_t TechNode_off_Progress = 0x0000001c;
// offset int flag (1 default from the ctor, 0 completed below 80% of cost, 2 over-budget event raised) [verified]
constexpr uint32_t TechNode_off_Flag = 0x0000002c;
// thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified] // thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified]
constexpr uint32_t MasterTechTree_ResolveTechIds = 0x00181c10; constexpr uint32_t MasterTechTree_ResolveTechIds = 0x00181c10;
// cdecl int (std::string* name) /* membership in g_CombatTechNames (116) */ [verified] // cdecl int (std::string* name) /* membership in g_CombatTechNames (116) */ [verified]
@ -281,5 +315,49 @@ constexpr uint32_t g_AITechValueTable = 0x00617888;
constexpr uint32_t g_PERGATETRAFFIC_DRV_TpGate = 0x00723e2c; constexpr uint32_t g_PERGATETRAFFIC_DRV_TpGate = 0x00723e2c;
// data int (config storage; PTR slot 0x00aedfe4) [verified] // data int (config storage; PTR slot 0x00aedfe4) [verified]
constexpr uint32_t g_PERGATETRAFFIC_DRV_GatAmp = 0x00723e30; constexpr uint32_t g_PERGATETRAFFIC_DRV_GatAmp = 0x00723e30;
// offset int PlyrIdx [verified-by-save]
constexpr uint32_t ServerPlayer_off_PlyrIdx = 0x00000028;
// offset std::vector<ServerSystem*> owned systems (MSVC2010 release layout: 3 words {first@+0, last@+4, end@+8}, handle ids, 4 B stride); save tag NumOwn/OwnId [verified-by-save]
constexpr uint32_t ServerPlayer_off_OwnedSystems = 0x00000030;
// offset int Species (0 Human .. 6 Morrigi) [verified-by-save]
constexpr uint32_t ServerPlayer_off_Species = 0x0000005c;
// offset float ResRate (research share of available money, 0..1) [verified-by-save]
constexpr uint32_t ServerPlayer_off_ResRate = 0x000000bc;
// offset float ResMod [verified-by-save]
constexpr uint32_t ServerPlayer_off_ResMod = 0x000000c0;
// offset float ResScl [verified-by-save]
constexpr uint32_t ServerPlayer_off_ResScl = 0x000000c4;
// offset float TRM (timed research multiplier bonuses) [verified-by-save]
constexpr uint32_t ServerPlayer_off_TRM = 0x000000d0;
// offset int TRA (per-turn research-point contribution) [verified-by-save]
constexpr uint32_t ServerPlayer_off_TRA = 0x000000d4;
// offset int TRP (per-turn research-point contribution) [verified-by-save]
constexpr uint32_t ServerPlayer_off_TRP = 0x000000d8;
// offset bool Elim [verified-by-save]
constexpr uint32_t ServerPlayer_off_Elim = 0x000000f8;
// offset bool isAI /* gate ComputeBudget reads for the construction slot and for picking the AI difficulty-mod row */ [verified]
constexpr uint32_t ServerPlayer_off_IsAI = 0x000000f9;
// offset bool NPC [verified-by-save]
constexpr uint32_t ServerPlayer_off_NPC = 0x000000fb;
// offset bool RebAI [verified-by-save]
constexpr uint32_t ServerPlayer_off_RebAI = 0x000000fc;
// offset int Maint (raw fleet upkeep, before the difficulty divisor) [verified-by-save]
constexpr uint32_t ServerPlayer_off_Maint = 0x0000015c;
// offset float shrm (shared research modifier) [verified-by-save]
constexpr uint32_t ServerPlayer_off_shrm = 0x00000160;
// offset std::vector<ExpenseEntry> expense sliders (3 words; entry 16 B {int xid, int xmin, int xmax, float xper}); save tag Nexp [verified-by-save]
constexpr uint32_t ServerPlayer_off_Nexp = 0x00000204;
// offset float income multiplier from the game-setup handicap block; ComputeBudget bonus slot = ftol((this - 1.0) x net) [verified]
constexpr uint32_t ServerPlayer_off_SetupIncomeMult = 0x00000228;
// offset float research multiplier from the game-setup handicap block; a factor of the research-points slot [verified]
constexpr uint32_t ServerPlayer_off_SetupResearchMult = 0x0000022c;
// offset int Sav (treasury) [verified-by-save]
constexpr uint32_t ServerPlayer_off_Sav = 0x00000284;
// offset Tech* current research target (ResT); NULL = none [verified-by-save]
constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294;
// offset float IncMod [verified-by-save]
constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c;
// offset std::vector<PlayerAid> (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified]
constexpr uint32_t ServerPlayer_off_Aid = 0x00000310;
} // namespace sots::addr } // namespace sots::addr

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.researchPointsGiven = static_cast<int>(static_cast<std::int64_t>(b.totalResearchPoints) * pct / 100);
b.totalResearchPoints -= b.researchPointsGiven; b.totalResearchPoints -= b.researchPointsGiven;
b.hasResearchAllocation = in.hasResearchTarget; 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. // Tech income bonus: a share of the full net so far, only when that net is positive.
const std::int64_t netBeforeBonus = running(); 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 -> // the running totals: interest -> system income -> trade/other income -> maintenance ->
// expenses -> available -> construction -> research money/points -> aid -> bonus -> // expenses -> available -> construction -> research money/points -> aid -> bonus ->
// savings aid -> net. // 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 // The tech income bonus reads the full net (every income line including interest and
// trade, minus maintenance, research money, construction, expenses and research aid) // 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)). // 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 "MinHook.h"
#include "generated/sots_addresses.h" #include "generated/sots_addresses.h"
#include "shim/hooks/dictionaries.h" #include "shim/hooks/dictionaries.h"
#include "shim/hooks/compute_budget.h"
#include "shim/hooks/global_consts.h" #include "shim/hooks/global_consts.h"
#include "shim/trace/hook.h" #include "shim/trace/hook.h"
#include "shim/trace/selftest.h" #include "shim/trace/selftest.h"
@ -149,6 +150,7 @@ void InstallTemplateHook(shim::trace::Tracer& tracer, uintptr_t exeBase, uint32_
using LoadFileHook = shim::trace::Hook<shim::hooks::GlobalConstsLoadFileHook>; using LoadFileHook = shim::trace::Hook<shim::hooks::GlobalConstsLoadFileHook>;
using WeaponInitHook = shim::trace::Hook<shim::hooks::WeaponDictionaryInitHook>; using WeaponInitHook = shim::trace::Hook<shim::hooks::WeaponDictionaryInitHook>;
using SectionCtorHook = shim::trace::Hook<shim::hooks::SectionDictionaryCtorHook>; using SectionCtorHook = shim::trace::Hook<shim::hooks::SectionDictionaryCtorHook>;
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
void InstallHooks(shim::trace::Tracer& tracer) { void InstallHooks(shim::trace::Tracer& tracer) {
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr)); const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
@ -176,6 +178,10 @@ void InstallHooks(shim::trace::Tracer& tracer) {
shim::hooks::init_dictionaries(exeBase, &ShimLogLine); shim::hooks::init_dictionaries(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::WeaponDictionaryInitHook>(tracer, exeBase, sots::addr::WeaponDictionary_Init); InstallTemplateHook<shim::hooks::WeaponDictionaryInitHook>(tracer, exeBase, sots::addr::WeaponDictionary_Init);
InstallTemplateHook<shim::hooks::SectionDictionaryCtorHook>(tracer, exeBase, sots::addr::SectionDictionary_ctor); InstallTemplateHook<shim::hooks::SectionDictionaryCtorHook>(tracer, exeBase, sots::addr::SectionDictionary_ctor);
// 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 ----------------------------------------------------------------------------- // ---- lifecycle -----------------------------------------------------------------------------
@ -214,6 +220,7 @@ void Shim_Init(HMODULE self) {
tracer.configure(cfg.trace); tracer.configure(cfg.trace);
shim::trace::Hook<shim::selftest::FillHook>::register_policy(tracer); shim::trace::Hook<shim::selftest::FillHook>::register_policy(tracer);
LoadFileHook::register_policy(tracer); LoadFileHook::register_policy(tracer);
ComputeBudgetHook::register_policy(tracer);
WeaponInitHook::register_policy(tracer); WeaponInitHook::register_policy(tracer);
SectionCtorHook::register_policy(tracer); SectionCtorHook::register_policy(tracer);
char exeSha[65] = {}; char exeSha[65] = {};

View file

@ -103,6 +103,22 @@ static void test_budget_hand_case() {
CHECK_EQ(b.net, 3200); 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() { static void test_budget_projected() {
Budget b = ComputeBudget(base_inputs(), true); Budget b = ComputeBudget(base_inputs(), true);
CHECK_EQ(b.researchMoney, 0); CHECK_EQ(b.researchMoney, 0);
@ -370,6 +386,7 @@ int main() {
test_research_points(); test_research_points();
test_expenses(); test_expenses();
test_budget_hand_case(); test_budget_hand_case();
test_budget_without_research_target();
test_budget_projected(); test_budget_projected();
test_budget_debt(); test_budget_debt();
test_budget_aid_and_bonus(); 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");
}