T: hook for Game::ServerPlayer::ProcessTurn, with the prediction committed first

Descriptor + pure adapter + host tests for the per-player turn driver. Not
deployed; the WIN32 half is unbuilt here (no cross-compiler on this host).

The declared boundary is narrower than the function on purpose. Phases 2, 3
and 6 -- the savings apply, the aid records and the research refund -- are pure
functions of ComputeBudget's 22 slots and ProcessResearch's overBudget, and
both live in the original's own stack frame. Reaching them would mean calling
ComputeBudget ourselves (it repairs ships in orbit, audit #6), reading the
nested B1/B3 hooks (audit #5, the self-fulfilling compare), or inferring them
from the Sav delta. So they are guarded, not checked, and the three formulas
are written and unit-tested but not wired into the verdict.

Declared: the phase-7 clear, the RebAI decay, the descending timed-bonus
sweep, plus roll_flags and rng as observations ours never writes. Guards over
the whole ServerPlayer and the TechTree header.

docs/T-turn-driver.md states, before any run: which regions must not diverge,
which checks are weak by construction on the reference save, what falsifies
the ResearchRollPending reading, and the save that would finally fire the
branch nobody has seen.

host ctest 36/36 (was 35/35); clean_room_check OK.
This commit is contained in:
alex 2026-09-08 08:08:47 -04:00
parent c75931f592
commit a7ca208b63
12 changed files with 1434 additions and 4 deletions

View file

@ -62,6 +62,11 @@ add_library(shim_movement STATIC src/shim/hooks/movement_inputs.cpp)
target_link_libraries(shim_movement PUBLIC shim_trace sots_game_sim)
target_compile_options(shim_movement PRIVATE -Wall -Wextra -Werror)
# ---- T turn-driver adapter: ServerPlayer snapshot <-> the driver's own writes (pure, host-tested) ----
add_library(shim_player_turn STATIC src/shim/hooks/player_turn_inputs.cpp)
target_link_libraries(shim_player_turn PUBLIC shim_trace)
target_compile_options(shim_player_turn PRIVATE -Wall -Wextra -Werror)
# ---- P events adapter: live Game::EventStorage <-> sots::events model (pure, host-tested) ----
add_library(shim_events STATIC src/shim/hooks/event_inputs.cpp)
target_link_libraries(shim_events PUBLIC shim_trace sots_addresses sots_game_events)
@ -82,10 +87,12 @@ if(WIN32)
src/shim/hooks/tech_effects.cpp
src/shim/hooks/compute_budget.cpp
src/shim/hooks/colony_turn.cpp
src/shim/hooks/fleet_movement.cpp)
src/shim/hooks/fleet_movement.cpp
src/shim/hooks/player_turn.cpp)
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
sots_game_effects mars_rng shim_budget shim_techfx
shim_colony shim_movement shim_events)
shim_colony shim_movement shim_events
shim_player_turn)
target_compile_options(shim_hooks PRIVATE -Wall -Wextra -Werror)
add_library(binkw32 SHARED src/shim/main.cpp src/shim/fpu_force.cpp src/shim/binkw32.def)
@ -101,7 +108,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 game_events shim_budget shim_techfx shim_colony shim_movement shim_events)
foreach(_t mars_parse game_config game_data game_design game_sim mars_stream mars_text mars_vfs shim_trace game_effects game_events shim_budget shim_techfx shim_colony shim_movement shim_events shim_player_turn)
if(EXISTS ${CMAKE_SOURCE_DIR}/tests/${_t}/CMakeLists.txt)
add_subdirectory(tests/${_t})
endif()

148
docs/T-turn-driver.md Normal file
View file

@ -0,0 +1,148 @@
# T — the per-player turn driver, and a prediction written before the run
`Game::ServerPlayer::ProcessTurn` is the per-player half of the turn. It is where `ComputeBudget`
(B1), `TechTree::ProcessResearch` (B3/U) and — through `SetResearched` — `ServerPlayer::OnTechResearched`
(B2) meet, and it is the function that decides the `ResearchRollPending` question the campaign has
been unable to close for three sessions.
The RE side is `sots-re/findings/control-flow/turn-driver.md`; the addresses are
`sots-re/ghidra/addresses.d/lane-t.json` (45 entries, folded into the generated header, 722 total).
Nothing here has been run. **Lane T holds no VM.**
---
## 1. What the hook declares, and what it refuses to declare
Twelve phases; three are Result regions.
| declared | phase | region |
|---|---|---|
| yes | 7 — `TRM/TRA/TRP/+0xdc/+0xe0 = 0` | `per_turn_research` (`player+0xd0`, 0x14 B) |
| yes | 8 — `RebOutMod = clamp(RebOutMod − 0.04f, 1.0f, 2.0f)` when `RebAI` | `reb_out_mod` (`player+0x128`, 4 B) |
| yes | 9 — the descending timed-bonus sweep | `bonus_header` (`player+0x3a4`, 12 B) + one 8-byte `bonus_NN` per pre-call element |
| observed, ours never writes | 10 — the research roll | `roll_flags` (`player+0x3b4`, 2 B: `ResErrRoll`, `cta`) and `rng` (0x9cc B) |
| context | — | `inputs` (the snapshot) |
| guard | — | `player` (whole 0x3e0 object) and `tree_header` (TechTree, 0x24 B) |
**Phases 2, 3 and 6 are deliberately not declared** — the savings apply, the aid/trade records and
the research refund. All three are pure functions of `ComputeBudget`'s 22 slots and of
`ProcessResearch`'s `overBudget`, and **both live in the original's own stack frame** (`[ebp-0x90]`
and `[ebp-0x14]`). There are exactly three ways to reach them and each is worse than not having them:
1. call `ComputeBudget` ourselves — it is not read-only; `ServerSystem::ComputeOutput` repairs
damaged ships in orbit (harness-audit #6). A compare run would double-repair, once per system.
2. read them out of the nested B1/B3 hooks — harness-audit #5, the self-fulfilling compare.
3. infer them from the observed `Sav` delta — the same trap in a coat.
So they are *watched by the guard, not checked*. The formulas (`SatAdd`, `BudgetNet`,
`ResearchRefund`) are written and unit-tested anyway, because they were read off the instruction
stream and the moment a hook on `ComputeBudget`'s **return** exists they can be wired in without
re-deriving anything.
This is the audit's rule taken seriously: **a green verdict on a region set that compares the
interesting thing by construction is worse than a smaller honest one.** Three of B4's hooks printed
"0 diverged" while comparing nothing.
---
## 2. The prediction (committed before the run)
### 2.1 Shape
- **8 calls per End Turn** on the reference save, one per player, all at `depth ≥ 1` nested inside
`StrategyServer::ProcessTurn`. If the count is not the player count, the base pointer is wrong —
check `player_index` against `slot`, and check that `mod_count` is the same on every call of one turn.
- `dt_unused` is `1.0` on every call (the driver's own argument). It is recorded and never used.
- `fpu_cw` should be identical on all 8 calls and match what lane F's gate forces.
### 2.2 Regions
- **`per_turn_research` diverges on no call.** Phase 7 is an unconditional five-word clear and phase 9
refills only `TRM`. `TRA`/`TRP`/`+0xdc`/`+0xe0` must be `0` after every call, on every player.
*If `TRA` or `TRP` is non-zero after a call, something between phase 7 and the end of the function
writes them and I have missed a phase.* That is the single most falsifiable claim here.
- **`reb_out_mod` diverges on no call.** On the reference save no player has `RebAI`, so the region is
unchanged on all 8 calls and ours copies it through. This is a weak check by construction and is
labelled as such: it only becomes a real check on a save with a rebel-AI player.
- **`bonus_header` and every `bonus_NN` diverge on no call, and on the reference save there are
probably zero `bonus_NN` regions at all** — `NumPR` is empty in every save read so far. **A run
that reports `bonus_count: 0` on all 8 calls has verified nothing about phase 9**; say so in the
result rather than counting it as a pass. The host tests carry the arithmetic; only a save with a
live `PRm`/`PRBt` entry can confirm the descending order on the game.
- **`rng` diverges on exactly the calls where the original drew and ours did not.** Ours never draws.
Expected draws per call: `RollResearchAccident`'s `NextInt(100)` when the research boost is
non-zero, `ProcessResearch`'s completion roll(s), and the `RollResearchEvent` `NextFloat` when
`predict_roll` is true. Every one of these is unmodelled and declared so.
- **`roll_flags` diverges on exactly the calls where `predict_roll` is true** (the original clears
`ResErrRoll`, ours leaves it) **and on the calls where a completion consumed it in
`OnTechResearched`**. On any other call it must be byte-identical.
### 2.3 The `ResearchRollPending` answer the run should produce
For each call the record carries `research_target`, `roll_pending_in`, `rebellion_armed_in`,
`progress_ratio_in`, `have_ratio` and `predict_roll`, all read **before** the original runs.
Predicted pattern on a normal save:
| situation | `roll_pending_in` | `progress_ratio_in` | `predict_roll` | `roll_flags` |
|---|---|---|---|---|
| player with no research target | anything | 0, `have_ratio` false | false | unchanged |
| target early in its progress | true | ≤ 0.5 | **false** | **unchanged — the flag survives to next turn** |
| target past halfway, no completion | true | > 0.5 | **true** | **`ResErrRoll` 1 → 0** |
| target completes this turn | true | anything | either | `ResErrRoll` 1 → 0, and `research_target` → 0 |
| flag already spent | false | anything | false | unchanged |
`progress_ratio_in` is measured by calling the game's own `TechTree::ResearchProgressRatio`
(0x0057e950, read-only, the same delegation B3 makes to `TechTree::Cost`), so `predict_roll` is a
prediction about the *original*, not a re-derivation of our own.
**The claim the run tests:** the flag is consumed at the **end** of turn N — after `ProcessResearch`,
not before it — so it survives into turn N+1's `ProcessResearch` **iff `progress/Cost ≤ 0.5` at the
end of turn N**. That is why lane V's call-9 draw was rare and lane U's 35 calls saw none.
### 2.4 What would falsify the whole reading
- `roll_flags` changing on a call where `predict_roll` is false **and** no completion happened →
there is a third consumer of `+0x3b4` that the displacement scan missed.
- `roll_flags` **not** changing on a call where `predict_roll` is true and the ratio is comfortably
above 0.5 → the threshold constant or the comparison sense is wrong.
- `rng` clean on a call where `predict_roll` is true → `RollResearchEvent` did not draw, i.e. the
site is not the one that fires.
---
## 3. The save lane O should build
`ResErrRoll` is a serialized tag (immediately after `ResTNm`), so it can be set **directly in a save**
rather than played to. The state that has never once been observed is *a turn that BEGINS with the
flag true and completes its research inside that turn*, which is the only way to reach the roll from
`OnTechResearched` rather than from the driver.
Recipe: pick a player, set `ResErrRoll = true`, point `ResTNm` at a tech with **large cost and
near-zero progress**, and give that player savings and a research slider that deliver research points
**≥ 1.5 × cost** in one turn — the guaranteed-completion path, so the outcome does not itself depend
on a roll. One End Turn then fires the `OnTechResearched` site with the flag set.
That save would also, for the first time, be able to reach `FUN_00889d60` — the branch behind the
roll. Note what it costs if it fires: the plague branch draws a **second** RNG word (`NextInt`) and
posts `EVENT_PLAGUE_OUTBREAK`; the rebellion branch allocates an `AIRebellion` at `ServerPlayer+0x3b8`
and **cancels the current research**. Every coverage note in this repo up to now says "exactly one
`NextFloat`". That is the cost of *reaching* the branch, not of taking it.
**One thing is still open and is not papered over:** nothing was found that *arms* `ResErrRoll` during
play. The only writes in the image are the two clears, the constructor (`mov WORD [esi+0x3b4], 1` —
the flag is born **true**), `ServerPlayer::Read`, and a virtual setter pair with no locatable caller.
"Born true, consumed once, re-armed only through the interface" fits every observation including lane
U's Zuul run, but it is a **hypothesis**. Editing the save does not depend on it.
---
## 4. Build state
- Host: `36/36` ctest (was 35/35); the new test is `shim_player_turn_unit`.
- `src/shim/hooks/player_turn.cpp` is `WIN32`-only and is **not built on this host** — there is no
mingw cross-compiler here. It is wired into `shim_hooks` and registered in `main.cpp`, and it must
be built on CT111 before anything is copied to the VM.
- Configs: `src/shim/shim.cfg.tturn` (trace) and `src/shim/shim.cfg.tturncmp` (compare), both with
every other hook off.
- **Run trace before compare.** The trace alone answers §2.3, because the four deciding values are
arguments.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 805409e, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ afa8620, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1295,6 +1295,112 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310;
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
// thiscall void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]
constexpr uint32_t ServerPlayer_ProcessTurn_ResearchCall = 0x00491461;
// site site in ServerPlayer::ProcessTurn, immediately after ProcessResearch returns: `if (overBudget > 0 && budget[17] > 0) Sav = SatAdd(Sav, ftol( (double)min(overBudget, budget[17]) / budget[17] * budget[16] ))`. budget[17] = researchPoints, budget[16] = researchMoney. Unspent research points are refunded to savings at the turn's own points-per-credit rate. PREVIOUSLY UNMODELLED - it is not in strategic-turn-internals.md §1.2. The min is selected by `cmp ecx,eax; lea eax,[ebp-0x18]; jl; lea eax,[ebp-0x10]`, then `fild; fidiv budget[17]; fimul budget[16]; call _ftol2` [verified]
constexpr uint32_t ServerPlayer_ProcessTurn_ResearchRefund = 0x004914aa;
// site site in ServerPlayer::ProcessTurn, AFTER ProcessResearch (ProcessTurn+0x2ac vs +0x165): `if (ResT(+0x294) && ResErrRoll(+0x3b4) && 0.5f < TechTree::ResearchProgressRatio(tree, ResT)) { RollResearchEvent(this); ResErrRoll = false; }`. THE CLEAR IS INSIDE THE BRANCH - when the ratio test fails the flag is left set, which is how it survives to the next turn. The threshold is a strict `<` (MSVC `fld 0.5f; fcomp ratio; fnstsw; test ah,5; jp skip`), constant 0x00a2c788 = 0.5f. This is the site campaign/board.md describes as running BEFORE ProcessResearch; it runs after [verified]
constexpr uint32_t ServerPlayer_ProcessTurn_ResearchRollBlock = 0x004915ec;
// thiscall void (ServerPlayer* this) // `mov byte [ecx+0x3b4], 1; ret` - the only ARMING write of ResErrRoll in the image other than the constructor. Referenced ONLY from the vtable word at 0x00a327bc (slot 6 of the 0x00a327a4 ServerPlayer vptr), so every caller is an indirect `call [vft+0x18]`. find-vtable-callers returns no genuine caller: all 18 hits are other classes' slot at byte offset 0x70 and pass arguments. WHO RE-ARMS THE FLAG DURING PLAY IS UNRESOLVED [verified]
constexpr uint32_t ServerPlayer_SetResearchRollPending = 0x0040e300;
// thiscall void (ServerPlayer* this) // `mov byte [ecx+0x3b4], 0; ret`. Vtable word 0x00a327c0, the slot after SetResearchRollPending [verified]
constexpr uint32_t ServerPlayer_ClearResearchRollPending = 0x0040e310;
// site site in the ServerPlayer constructor: `mov WORD PTR [esi+0x3b4], 1` - a two-byte store that sets ResErrRoll(+0x3b4) = 1 and +0x3b5 = 0. THE FLAG IS BORN TRUE. Consistent with (but not proof of) the hypothesis that ResErrRoll is armed once at construction and consumed once, never re-armed except through the vtable pair [verified]
constexpr uint32_t ServerPlayer_ctor_ResearchRollPendingInit = 0x00480474;
// thiscall float (TechTree* this, TechDef* def) // RET 4, result in st(0). node = this->nodes(+0x10)[def->id(+0x0)]; if (!node) return 0.0f; return (float)((double)node->progress(+0x1c) / (double)TechTree::Cost(node)). Whole 59-byte body read. The divide is done in double and narrowed once on the fstp. Called from exactly one place: the ResearchRollPending block in ServerPlayer::ProcessTurn [verified]
constexpr uint32_t TechTree_ResearchProgressRatio = 0x0017e950;
// thiscall TechDef* (TechTree* this) // plain RET. Walks this->nodes(+0x10/+0x14) in index order; for each non-null node resolves node->def(+0x0) and re-indexes nodes by that def's id (the same self-resolving idiom lane E corrected in ProcessResearch), and returns the FIRST def whose node state(+0x14) == 2 (available). NULL when none. Used only as the second half of the EVENT_NO_RESEARCH test [verified]
constexpr uint32_t TechTree_FindFirstAvailableTech = 0x0017da90;
// thiscall void (TechTree* this, std::vector<ResearchedTechRecord>* out, int minTurn, int maxTurn, bool sort) // RET 0x10. Clears *out, walks this->master(+0x4)->defs(+0x24/+0x28); SKIPS any def whose name (std::string at def+0x4) tail from its first '_' matches "_Root" case-insensitively; requires nodes[def->id]->state(+0x14) == 4 (researched) and minTurn <= node->turnResearched(+0x24) <= maxTurn; pushes {TechDef*, turnResearched, node->flag(+0x2c)} (12-byte record). When `sort` is true it finally calls 0x00582cd0(first, last, count, this). ServerPlayer::ProcessTurn calls it with (minTurn = server ModCount, maxTurn = INT_MAX, sort = true), i.e. "techs this player completed on THIS turn". strategic-turn-internals.md §1.2 step 9 described it as a list of AVAILABLE techs; it is the opposite [verified]
constexpr uint32_t TechTree_CollectResearchedTechs = 0x00184e50;
// field sizeof(ResearchedTechRecord) = { TechDef* def; int turnResearched; int flag } - the element TechTree::CollectResearchedTechs pushes. Stride confirmed by the vector-clear loop at 0x00584e75 (three dword copies, `add eax,0xc`) [verified]
constexpr uint32_t ResearchedTechRecord_stride = 0x0000000c;
// cdecl int (int a, int b) // saturating add. Overflow-checked: b>0 and a+b<=a -> +2000000000 (0x77359400); b<0 and a+b>=a -> -2000000000 (0x88ca6c00); otherwise clamp(a+b, -2000000000, +2000000000). Whole 94-byte body read. Used for every write to ServerPlayer::Sav in ProcessTurn (the net apply and the research refund) [verified]
constexpr uint32_t ServerPlayer_SatAdd = 0x00417990;
// cdecl int (ServerPlayer* p, int available) // the AI branch of ProcessTurn phase 4: a human player uses budget[11], which ComputeBudget fills only when p->IsAI(+0xf9)==0, and an AI recomputes it here. Body not read by lane T; the call shape (two pushes, `add esp,8`) is verified [mapped]
constexpr uint32_t ServerPlayer_ConstructionSpend = 0x00417f90;
// thiscall void (ServerPlayer* this, int constructionSpend, int available) // TWO stack arguments, pushed as (edi = budget[15] available) then (eax = construction spend), so the callee sees (spend, available). turn-spine.md called this "income/savings"; strategic-turn-internals.md §1.1 already corrected it to special projects. Body not read by lane T [mapped]
constexpr uint32_t ServerPlayer_ProcessSpecialProjects = 0x00440fe0;
// thiscall void (ServerPlayer* this) // the unconditional tail of ServerPlayer::ProcessTurn. Sweeps the vector<RaidTargets> at +0x338/+0x33c (0x20 stride) forward, erasing an element when: its inner range rec[0xc]==rec[0x10] (empty); or ModCount - rec[0x1c] > 20; or the entity lookup 0x008b9240(server EntityHash, rec[0x4]) fails; or the resolved object's +0x90 mask lacks bit (1 << this->PlyrIdx(+0x28)). Erase shifts the tail down 0x20 and calls the trailing element's vft[0] scalar destructor. Whole 239-byte body read [verified]
constexpr uint32_t ServerPlayer_PruneRaidTargets = 0x00463cf0;
// thiscall void (ServerTradeManager* this) // StrategyServer::ProcessTurn phase 2, called on StrategyServer+0x158 (S frame) / +0x154 (raw frame). 1494 bytes. Freighter -> route allocation; the formulas are in strategic-turn-internals.md §1.4 and have never been run against the game [mapped]
constexpr uint32_t ServerTradeManager_ProcessTurn = 0x0046b300;
// thiscall void (StrategyServer* this) // StrategyServer::ProcessTurn phase 3, 192 bytes. Every owned system whose owner has CnTrd registers NumTradeRoutesSupported routes [mapped]
constexpr uint32_t StrategyServer_RegisterTradeSystems = 0x003adc80;
// thiscall void (StrategyServer* this) // StrategyServer::ProcessTurn phase 28, 185 bytes. For every player, for every species index 0..6 except 4 and the player's own: if the species is known (0x0080de60) and SpeciesDef::Get(sp)->+0x78 is a tech id != 0xc5 whose def resolves and is not already researched, make it visible via 0x00586200(def, 0). The "you have met this race, its racial tech appears in your tree" rule. Draw-free, event-free, three callees [verified]
constexpr uint32_t StrategyServer_UnlockMetSpeciesTechs = 0x0038aa70;
// site site in StrategyServer::ProcessTurn: the per-system `ServerSystem::ProcessTurn` loop. `mov ecx,[Systems._Myfirst + i*4]; call 0x007598e0` - NO stack arguments and no returned value, confirming B4's correction that the decompile's `void* stream` parameter is a Ghidra guess. Loop bound recomputed every iteration as `(Systems._Mylast - _Myfirst) >> 2` [verified]
constexpr uint32_t StrategyServer_ProcessTurn_SystemLoop = 0x003dc9c8;
// site site in StrategyServer::ProcessTurn: the per-player `ServerPlayer::ProcessTurn` loop. `fld DWORD [ebp+8]; mov ecx,player; push ecx; fstp DWORD [esp]; call 0x00891340` - the driver's own float dt is forwarded as the callee's single stack argument (the `push ecx` only reserves the slot). Loop bound recomputed every iteration as `(Players._Mylast - _Myfirst) >> 2` [verified]
constexpr uint32_t StrategyServer_ProcessTurn_PlayerLoop = 0x003dca00;
// site site in StrategyServer::ProcessTurn: the first instruction of the end-of-turn tail (ProcessAid, ProcessSpecialProjects, ProcessSurrenders, the per-player 0x00818530 sweep, the two SVScriptObject hook pairs, 0x0086a8d0, 0x0078ab30, 0x00799380, UnlockMetSpeciesTechs, the per-system 0x00743ec0 sweep, 0x007b4c00 and the team/Status sweep). IT IS UNCONDITIONAL. turn-spine.md §2.4 step 11 says the tail is deferred when encounters are pending; that came from reading the INLINED std::vector DESTRUCTOR at 0x007dcb15 as a branch - `je 0x7dcb38` skips only the `operator delete`, and both arms converge here. There is no branch on the encounter snapshot anywhere in this function [verified]
constexpr uint32_t StrategyServer_ProcessTurn_EndOfTurnTail = 0x003dcb38;
// field THE TWO BASES, stated once. Let S = the `this` StrategyServer::ProcessTurn receives, which is also what ServerPlayer::GetServer (0x0080e320) returns. Then ServerPlayer+0x8 holds S+4, and EVERY `StrategyServer_off_*` entry in addresses.json except `StrategyServer_off_RNG` is expressed in the S+4 frame (Players 0x50 = S+0x54, Fleets 0x60 = S+0x64, EntityHash 0x80 = S+0x84, RNGPtr 0x168 = S+0x16c). `StrategyServer_off_RNG = 0x16c` is the same word in the S frame. Convert S-frame -> stored frame by subtracting 4. Reading the wrong base yields an empty Players vector and zero declared regions [verified]
constexpr uint32_t StrategyServer_base_delta = 0x00000004;
// field int, S+4 frame (== S+0x8). Incremented by the FIRST instruction of StrategyServer::ProcessTurn (`inc [esi+8]` @0x007dc6f0). This is a SECOND per-turn counter, distinct from StrategyServer_off_ModCount (0x8 in this frame == S+0xc), which BeginProcessTurn increments and which the research code stamps and reads. Both advance once per turn in different functions. Nobody has named this one [verified]
constexpr uint32_t StrategyServer_off_PhaseCounter = 0x00000004;
// field std::vector<ServerSystem*> (begin @+0x40, end @+0x44) in the S+4 frame, i.e. S+0x44/S+0x48. Iterated three times per turn: the morale/abandon pre-pass, the ServerSystem::ProcessTurn loop, and the tail's 0x00743ec0 sweep [verified]
constexpr uint32_t StrategyServer_off_Systems = 0x00000040;
// field ServerTradeManager* in the S+4 frame (S+0x158). The `this` for ServerTradeManager::ProcessTurn, phase 2 of the turn [verified]
constexpr uint32_t StrategyServer_off_TradeManager = 0x00000154;
// field SVScriptObject* in the S+4 frame (S+0x1b4), null in a normal game. The end-of-turn tail calls two pairs on it when non-null: vft[0x10](6, 0) then vft[0x2c](), and later vft[0x10](0x1c, 0) then vft[0x78]() [verified]
constexpr uint32_t StrategyServer_off_ScriptObject = 0x000001b0;
// field std::vector<TeamRecord> in the S+4 frame (S+0x1e8), element stride 0x74. Rebuilt each turn by 0x007d7f70(this, &teamRecords) and then swept: for every member of every record, if (player->IsAI(+0xf9)==0 || player->+0xfa != 0) player->Status(+0x164) = 1. Strides verified from the signed-division magics: 0x8d3dcb09 with `sar 6` is /0x74, 0x78787879 with `sar 5` is /0x44 [verified]
constexpr uint32_t StrategyServer_off_TeamRecords = 0x000001e4;
// field sizeof(TeamRecord), the element of StrategyServer's team vector at +0x1e4 (S+4 frame) [verified]
constexpr uint32_t TeamRecord_stride = 0x00000074;
// field std::vector<TeamMember> inside a TeamRecord (begin @+0x28, end @+0x2c), element stride 0x44, first word of each element a ServerPlayer* [verified]
constexpr uint32_t TeamRecord_off_Members = 0x00000028;
// field sizeof(TeamMember). Only its first word (ServerPlayer*) is read by the turn's Status sweep; the other 0x40 bytes are unread by that pass [verified]
constexpr uint32_t TeamMember_stride = 0x00000044;
// field void* to a per-player per-turn record. StrategyServer::ProcessTurn's player pre-pass writes rec->+0x8 = (1 << playerSlot) and ORs in the player's alliance mask AL(+0x16c) when ALid(+0x168) != -1; ServerPlayer::ProcessTurn then writes rec->+0x10 = budget[2] (trade income). NOTE the bit index is the player's POSITION in the server's Players vector, not PlyrIdx [verified]
constexpr uint32_t ServerPlayer_off_TurnRecord = 0x000003d8;
// field int, written by ServerPlayer::ProcessTurn from budget[19] (research points given away as aid). Not serialized. strategic-turn-internals.md §1.2 step 2 calls this a per-turn accumulator that is ZEROED; it is ASSIGNED [verified]
constexpr uint32_t ServerPlayer_off_ResearchAidGiven = 0x000000c8;
// field int, written by ServerPlayer::ProcessTurn from budget[14] (savings given away as aid). Not serialized [verified]
constexpr uint32_t ServerPlayer_off_SavingsAidGiven = 0x000000cc;
// field float RebOutMod. When RebAI(+0xfc) is set, ServerPlayer::ProcessTurn does RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f) each turn. The three constants are image floats: 0x00a17870 = 0.04f, 0x00a17868 = 1.0f, 0x00a1786c = 2.0f [verified]
constexpr uint32_t ServerPlayer_off_RebOutMod = 0x00000128;
// field int Status (serialized). 1 = playing, 4 = turn done (SNMSetPlayerStatus). Set back to 1 by the end-of-turn team sweep for every member with (IsAI==0 || +0xfa != 0) [verified]
constexpr uint32_t ServerPlayer_off_Status = 0x00000164;
// field int ALid, the first word of the PlayerAlliances block {ALid, AL, NA, CF} at +0x168. -1 means no alliance; the turn's per-player pre-pass tests it before OR-ing AL into the turn record's visibility mask [verified]
constexpr uint32_t ServerPlayer_off_AllianceId = 0x00000168;
// field int AL, the alliance member bitmask at PlayerAlliances+4. OR-ed into (player->+0x3d8)->+0x8 once per turn when ALid != -1 [verified]
constexpr uint32_t ServerPlayer_off_AllianceMask = 0x0000016c;
// field std::vector<RaidTarget> (begin @+0x338, end @+0x33c), element stride 0x20, save tags `rdtc` + n x `rdt`. Pruned every turn by ServerPlayer_PruneRaidTargets. Fields the prune reads: +0x4 entity id, +0xc/+0x10 an inner range, +0x1c the turn the record was created [verified]
constexpr uint32_t ServerPlayer_off_RaidTargets = 0x00000338;
// field sizeof(RaidTarget), the element of ServerPlayer+0x338. Confirmed by the prune's `add edi,0x20` step and its `sar ecx,5` count [verified]
constexpr uint32_t RaidTarget_stride = 0x00000020;
// field std::vector<TimedResearchBonus> (begin @+0x3a4, end @+0x3a8), element {float PRm; int PRBt}, save tags `NumPR` + n x (`PRm`,`PRBt`). Swept once per turn by ServerPlayer::ProcessTurn: `for (i = count-1; i >= 0; --i) { TRM += e[i].PRm; if (--e[i].PRBt <= 0) erase(i); }`. THE ITERATION IS DESCENDING - float addition is not associative, so the order is load-bearing for bit-exactness [verified]
constexpr uint32_t ServerPlayer_off_TimedResearchBonuses = 0x000003a4;
// field sizeof(TimedResearchBonus) = { float PRm; int PRBt }. Confirmed by `sar edi,3` on the byte count and `[ecx+edi*8]` addressing [verified]
constexpr uint32_t TimedResearchBonus_stride = 0x00000008;
// thiscall bool (ServerPlayer* this) // the branch RollResearchEvent takes when odds > roll -- the one the campaign has never observed firing. Splits on the CURRENT RESEARCH TARGET: (a) ResT is one of five hard-coded plague-cure TechDefs (ids 0x273c..0x2740, resolved by 0x00535480) -> 0x00889bb0, which draws ONE FURTHER Mars::RNG::NextInt to pick a random element of this->OwnId (+0x30/+0x34) and posts EVENT_PLAGUE_OUTBREAK naming it; (b) ResT is in the aggression tech table (0x00690f70) AND this->+0x3b5 is set -> 0x008206a0, which allocates a Game::AIRebellionImpl into +0x3b8 if absent and then CANCELS the current research (progress reset 0x0057e7c0, state back to 2 via 0x0057e8d0, vtable hook, ResT = NULL), with no further RNG; (c) otherwise nothing. SO A FIRED ROLL COSTS 1 OR 2 RNG WORDS, not 1 -- every existing coverage note says 'exactly one NextFloat', which is the cost of REACHING here [mapped]
constexpr uint32_t ServerPlayer_OnResearchRollSucceeded = 0x00489d60;
// field bool `cta` -- the AI-rebellion gate of the succeeded-roll branch (0x00889d60 path b). The ServerPlayer constructor writes it in the SAME two-byte store that arms ResErrRoll: `mov WORD [esi+0x3b4], 1` sets +0x3b4 = 1 and +0x3b5 = 0. The two research-roll flags are adjacent and initialised together [mapped]
constexpr uint32_t ServerPlayer_off_RebellionRollArmed = 0x000003b5;
// thiscall void (StrategyServer* this, std::vector<int>* actionTypes, bool force) // the ship-action dispatcher, run THREE TIMES per turn with different type sets -- instruction-verified at the three call sites 0x007dc92b, 0x007dc952, 0x007dcb0c. Walks the queued-ship-action list at S+0x78/0x7c and dispatches each action whose type is in `actionTypes` through a 12-entry function-pointer table indexed by type (targets include 0x00789500, the ship-borne BuildQueue::ProcessTurn wrapper); with `force` it validates (0x0083cbb0) and cancels (0x00849280) instead. Sets: {0..14} minus {2} before movement, {2} after movement, {0..14} with force at the end. Type 2 is the action that requires the fleet to have arrived [mapped]
constexpr uint32_t StrategyServer_ProcessShipActions = 0x003b9b90;
// cdecl std::vector<int>* (std::vector<int>* out) // fills *out with the fifteen ship-action type ids 0..14 and returns it. IT IS NOT AN ENCOUNTER SNAPSHOT -- turn-spine.md §2.4 steps 4 and 11 read it as one, which is where the 'the tail is deferred when encounters exist' error came from. Encounter detection is 0x007d7f70 at the very end of the turn [mapped]
constexpr uint32_t StrategyServer_BuildShipActionTypeList = 0x00394ad0;
// thiscall void (StrategyServer_secondBase* this) // phase 0, called on S+4. Per system copies current words into shadow words (+0x20c/0x210/0x214 <- +0x68/0x6c/0x70, +0x150.. <- +0x120.., +0xc9 <- +0xc8) via 0x00753530; per player +0x188 <- Sav(+0x284) and +0x18c <- +0x138. Also called from BuildTurnEvents, so it is the baseline 'changed since last turn' is diffed against. Draw-free, event-free [mapped]
constexpr uint32_t StrategyServer_SnapshotPreviousTurn = 0x0041b390;
// cdecl void (StrategyServer* server) // the end-of-turn fog-of-war update, timed with QueryPerformanceCounter and logged as "Sensors: %f seconds to update sensors for all players.". Wraps 0x0086a6d0: per player x per system (predicate 0x00850cf0) and per player x per fleet (predicate 0x0081e630), packing 2-bit visibility results into system+0x24 and fleet+0x54 for up to 15 players. Deterministic, no events [mapped]
constexpr uint32_t StrategyServer_UpdateSensors = 0x0046a8d0;
// thiscall void (ServerPlayer* this, bool flag) // no-ops unless this->AIRebellion(+0x3b8) is non-null; steps it (0x006d1fd0(flag)), and if it signals completion (0x00690ff0) calls its vft[0](1) and nulls +0x3b8. Called per player with flag=0 from StrategyServer::ProcessTurn's tail and with flag=1 from the deferred combat tail [mapped]
constexpr uint32_t StrategyServer_StepAIRebellion = 0x00418530;
// thiscall void (StrategyServer* this, std::vector<TeamRecord>* out) // the LAST phase of the turn: per system with combatants present (0x0078cb10), builds the pairwise 0x74-byte team records for fleet pairs whose relation (0x0080e050) is war, into StrategyServer+0x1e4 (S+4 frame). The turn's Status-back-to-playing sweep then walks the records it produced [mapped]
constexpr uint32_t StrategyServer_DetectEncounters = 0x003d7f70;
// data const float = 0.5f. The ONLY consumer is the ResearchRollPending block in ServerPlayer::ProcessTurn: the roll fires when 0.5f < progress/Cost, strictly. Not a registered config key - it is an image literal [verified]
constexpr uint32_t g_flt_ResearchRollProgressThreshold = 0x0062c788;
// data const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player [verified]
constexpr uint32_t g_flt_RebOutModDecay = 0x00617870;
// data const float = 1.0f, the lower clamp of RebOutMod [verified]
constexpr uint32_t g_flt_RebOutModMin = 0x00617868;
// data const float = 2.0f, the upper clamp of RebOutMod [verified]
constexpr uint32_t g_flt_RebOutModMax = 0x0061786c;
// thiscall bool (TechTree* this, TechPrereqs* prereqs /* = TechDef + 0x88 */) // RET 4. An AND over groups, each group an OR over techs: a group is satisfied by any listed tech whose node exists in this->nodes AND whose state (+0x14) is 4. Zero groups -> TRUE (the function returns satisfied==total with both 0); a group with ZERO entries -> FALSE and the whole test fails, because the inner loop cannot break and the outer one then exits with that group uncounted. Reads only. Called from SetResearched twice: the unforced completion gate on the argument def, and the availability sweep on each node's self-resolved def [verified]
constexpr uint32_t TechTree_PrereqsMet = 0x0017d8e0;
// field TechPrereqs prereqs -- the block TechTree::PrereqsMet is called on. Two MSVC vectors back to back: the flat entry array at +0x00 and the group array at +0x10. SetResearched passes `def + 0x88` at both call sites [verified]

View file

@ -0,0 +1,450 @@
#include "shim/hooks/player_turn.h"
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <vector>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
#include "generated/sots_addresses.h"
#include "shim/hooks/player_turn_inputs.h"
namespace shim::hooks {
using trace::Tv;
namespace tv = trace::tv;
namespace {
namespace A = sots::addr;
constexpr std::size_t kRngSize = A::RNG_size; // 0x9cc
// Whole-object guard span. ServerPlayer is 0x3e0 bytes
// (sots-re/findings/control-flow/turn-spine.md object table).
constexpr std::size_t kPlayerGuardSize = 0x3e0;
constexpr std::size_t kTreeHeaderSize = 0x24;
constexpr std::size_t kBonusStride = A::TimedResearchBonus_stride; // 8
// TechTree::ResearchProgressRatio (0x0057e950). Read-only -- it indexes the node vector and
// divides by TechTree::Cost, which B3 already delegates to. Calling the game's own copy is the
// same concession colony_turn makes for MaxPop, and it is what lets `progress_ratio_in` be a
// measurement rather than a re-derivation.
using RatioFn = float(SHIM_THISCALL*)(void* tree, void* def);
struct Env {
std::uintptr_t exe_base = 0;
void (*log_line)(const char*) = nullptr;
RatioFn ratio = nullptr;
};
Env g_env;
void logf(const char* fmt, ...) {
if (!g_env.log_line) return;
char line[512];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(line, sizeof line, fmt, ap);
va_end(ap);
g_env.log_line(line);
}
bool readable(const void* p, std::size_t n) {
if (!p) return false;
if (n == 0) return true;
#if defined(_WIN32)
const char* c = static_cast<const char*>(p);
const char* const end = c + n;
while (c < end) {
MEMORY_BASIC_INFORMATION mbi;
if (!VirtualQuery(c, &mbi, sizeof mbi)) return false;
if (mbi.State != MEM_COMMIT) return false;
if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false;
const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
if (!(mbi.Protect & ok)) return false;
c = static_cast<const char*>(mbi.BaseAddress) + mbi.RegionSize;
}
return true;
#else
return true;
#endif
}
template <class T>
T peek(const void* base, std::size_t off) {
T v{};
std::memcpy(&v, static_cast<const char*>(base) + off, sizeof v);
return v;
}
void* ptr_at(const void* base, std::size_t off) { return peek<void*>(base, off); }
std::uint32_t fpu_control_word() {
#if defined(__i386__) || defined(__x86_64__)
unsigned short cw = 0;
__asm__ __volatile__("fnstcw %0" : "=m"(cw));
return cw;
#else
return 0;
#endif
}
// THE TWO BASES. ServerPlayer+0x8 holds S+4, where S is the base StrategyServer::ProcessTurn
// receives; every StrategyServer_off_* in the generated header is expressed in the S+4 frame.
// Reading the wrong one here is the failure that produced "0 diverged" on an empty region set.
void* server_of(void* player) {
if (!readable(player, A::ServerPlayer_off_ServerLink + 4)) return nullptr;
return ptr_at(player, A::ServerPlayer_off_ServerLink); // == S+4, the off_* frame
}
void* rng_of(void* player) {
void* srv = server_of(player);
if (!readable(srv, A::StrategyServer_off_RNGPtr + 4)) return nullptr;
void* r = ptr_at(srv, A::StrategyServer_off_RNGPtr);
return readable(r, kRngSize) ? r : nullptr;
}
// ---- per-call state --------------------------------------------------------------------------
//
// The turn pass is single-threaded and ProcessTurn never nests on the same player, so the
// mapping regions() builds is handed to rebind()/ours() in statics (M1's concession). Do not
// copy this into a re-entrant hook.
struct BonusSlot {
void* live = nullptr;
void* scratch = nullptr;
};
struct CallState {
PlayerTurnSnapshot snap;
void* player = nullptr;
std::uintptr_t rng_base = 0;
bool have_rng = false;
std::int32_t bonus_regions = 0;
BonusSlot bonus[kMaxTimedBonuses];
void* trade_out = nullptr;
// scratch pointers, filled by rebind
void* s_per_turn = nullptr; // +0xd0 .. +0xe3
void* s_reb = nullptr; // +0x128
void* s_bonus_hdr = nullptr; // +0x3a4 .. +0x3af
};
CallState g_call;
void capture(void* player, float dt) {
CallState c;
c.player = player;
PlayerTurnSnapshot& s = c.snap;
s.dt = dt;
s.fpuControlWord = fpu_control_word();
if (!readable(player, kPlayerGuardSize)) {
g_call = c;
return;
}
s.playerIndex = peek<std::int32_t>(player, A::ServerPlayer_off_PlyrIdx);
s.species = peek<std::int32_t>(player, A::ServerPlayer_off_Species);
s.isAI = peek<std::uint8_t>(player, A::ServerPlayer_off_IsAI);
s.isNPC = peek<std::uint8_t>(player, A::ServerPlayer_off_NPC);
s.rebAI = peek<std::uint8_t>(player, A::ServerPlayer_off_RebAI);
s.trm = peek<float>(player, A::ServerPlayer_off_TRM);
s.tra = peek<std::int32_t>(player, A::ServerPlayer_off_TRA);
s.trp = peek<std::int32_t>(player, A::ServerPlayer_off_TRP);
s.perTurnDc = peek<std::int32_t>(player, A::ServerPlayer_off_TRP + 4);
s.perTurnE0 = peek<float>(player, A::ServerPlayer_off_TRP + 8);
s.rebOutMod = peek<float>(player, A::ServerPlayer_off_RebOutMod);
s.savings = peek<std::int32_t>(player, A::ServerPlayer_off_Sav);
s.researchAidGiven = peek<std::int32_t>(player, A::ServerPlayer_off_ResearchAidGiven);
s.savingsAidGiven = peek<std::int32_t>(player, A::ServerPlayer_off_SavingsAidGiven);
// THE FOUR PRE-CALL READS. Each is rewritten by the original inside this very call.
void* target = ptr_at(player, A::ServerPlayer_off_ResearchTarget);
s.researchTarget = static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(target));
s.rollPendingIn = peek<std::uint8_t>(player, A::ServerPlayer_off_ResearchRollPending);
s.rebellionArmedIn = peek<std::uint8_t>(player, A::ServerPlayer_off_RebellionRollArmed);
void* tree = ptr_at(player, A::ServerPlayer_off_TechTree);
if (g_env.ratio && target && readable(tree, 0x24) && readable(target, 4)) {
s.progressRatioIn = g_env.ratio(tree, target);
s.haveRatio = 1;
}
s.predictRoll = PredictResearchRoll(s.researchTarget, s.rollPendingIn != 0,
s.progressRatioIn) ? 1 : 0;
// the turn record the driver's pre-pass and phase 3 both write through
void* rec = ptr_at(player, A::ServerPlayer_off_TurnRecord);
if (readable(rec, 0x14)) {
c.trade_out = static_cast<char*>(rec) + 0x10;
s.tradeIncomeOut = peek<std::int32_t>(rec, 0x10);
}
// the server's two per-turn counters
void* srv = server_of(player);
if (readable(srv, A::StrategyServer_off_ModCount + 4)) {
s.modCount = peek<std::int32_t>(srv, A::StrategyServer_off_ModCount);
s.phaseCounter = peek<std::int32_t>(srv, A::StrategyServer_off_PhaseCounter);
}
// the timed-research-bonus vector
char* first = static_cast<char*>(ptr_at(player, A::ServerPlayer_off_TimedResearchBonuses));
char* last = static_cast<char*>(ptr_at(player, A::ServerPlayer_off_TimedResearchBonuses + 4));
if (first && last && last >= first) {
const std::size_t bytes = static_cast<std::size_t>(last - first);
if (bytes % kBonusStride == 0 && readable(first, bytes)) {
std::size_t n = bytes / kBonusStride;
if (n > kMaxTimedBonuses) {
s.bonusOverflow = static_cast<std::int32_t>(n - kMaxTimedBonuses);
n = kMaxTimedBonuses;
}
s.bonusCount = static_cast<std::int32_t>(n);
for (std::size_t i = 0; i < n; ++i) {
char* e = first + i * kBonusStride;
s.bonuses[i].amount = peek<float>(e, 0);
s.bonuses[i].turns = peek<std::int32_t>(e, 4);
c.bonus[i].live = e;
}
c.bonus_regions = s.bonusCount;
}
}
void* rng = rng_of(player);
if (rng) {
c.rng_base = reinterpret_cast<std::uintptr_t>(rng);
c.have_rng = true;
s.rngLeftIn = peek<std::int32_t>(rng, 0x9c8);
}
g_call = c;
}
Tv describe_f32(const void* d, std::size_t n, unsigned) {
return n >= 4 ? tv::f32(*static_cast<const float*>(d)) : tv::struct_();
}
Tv describe_i32(const void* d, std::size_t n, unsigned) {
return n >= 4 ? tv::i32(*static_cast<const std::int32_t*>(d)) : tv::struct_();
}
Tv describe_per_turn(const void* d, std::size_t n, unsigned) {
Tv s = tv::struct_();
if (n < 0x14) return s;
const char* p = static_cast<const char*>(d);
s.add("TRM", tv::f32(peek<float>(p, 0)));
s.add("TRA", tv::i32(peek<std::int32_t>(p, 4)));
s.add("TRP", tv::i32(peek<std::int32_t>(p, 8)));
s.add("w_dc", tv::i32(peek<std::int32_t>(p, 0xc)));
s.add("w_e0", tv::f32(peek<float>(p, 0x10)));
return s;
}
Tv describe_roll_flags(const void* d, std::size_t n, unsigned) {
Tv s = tv::struct_();
if (n < 2) return s;
const std::uint8_t* p = static_cast<const std::uint8_t*>(d);
s.add("ResErrRoll", tv::boolean(p[0] != 0));
s.add("cta", tv::boolean(p[1] != 0));
return s;
}
Tv describe_bonus_header(const void* d, std::size_t n, unsigned) {
Tv s = tv::struct_();
if (n < 12) return s;
const char* p = static_cast<const char*>(d);
const std::uintptr_t f = peek<std::uintptr_t>(p, 0);
const std::uintptr_t l = peek<std::uintptr_t>(p, 4);
s.add("count", tv::i32(l >= f ? static_cast<std::int32_t>((l - f) / kBonusStride) : -1));
s.add("bytes", tv::i32(l >= f ? static_cast<std::int32_t>(l - f) : -1));
return s;
}
Tv describe_bonus(const void* d, std::size_t n, unsigned) {
Tv s = tv::struct_();
if (n < 8) return s;
const char* p = static_cast<const char*>(d);
s.add("PRm", tv::f32(peek<float>(p, 0)));
s.add("PRBt", tv::i32(peek<std::int32_t>(p, 4)));
return s;
}
Tv describe_rng(const void* d, std::size_t n, unsigned) {
Tv s = tv::struct_();
if (n < kRngSize) return s;
const char* p = static_cast<const char*>(d);
s.add("left", tv::i32(peek<std::int32_t>(p, 0x9c8)));
const std::uintptr_t next = peek<std::uintptr_t>(p, 0x9c4);
const std::uintptr_t base = reinterpret_cast<std::uintptr_t>(p) + 4;
s.add("next_index",
tv::i32(next >= base ? static_cast<std::int32_t>((next - base) / 4) : -1));
return s;
}
Tv describe_opaque(const void* d, std::size_t n, unsigned inline_max) {
return tv::bytes(d, n, inline_max);
}
void add_region(std::vector<trace::Region>& out, const char* name, void* base, std::size_t off,
std::size_t size, Tv (*describe)(const void*, std::size_t, unsigned)) {
char* p = static_cast<char*>(base) + off;
if (!readable(p, size)) return;
trace::Region r;
r.name = name;
r.ptr = p;
r.size = size;
r.describe = describe;
out.push_back(r);
}
// Region names for the per-element bonus regions. Region::name is a `const char*`, so the
// strings have to outlive the call.
const char* bonus_name(std::size_t i) {
static char names[kMaxTimedBonuses][16];
static bool init = false;
if (!init) {
for (std::size_t k = 0; k < kMaxTimedBonuses; ++k)
std::snprintf(names[k], sizeof names[k], "bonus_%02u", static_cast<unsigned>(k));
init = true;
}
return names[i];
}
} // namespace
void init_player_turn(std::uintptr_t exe_base, void (*log_line)(const char* line)) {
g_env.exe_base = exe_base;
g_env.log_line = log_line;
g_env.ratio = reinterpret_cast<RatioFn>(exe_base + A::TechTree_ResearchProgressRatio);
logf("player_turn: ServerPlayer::ProcessTurn hook armed (ratio helper at %p)",
reinterpret_cast<void*>(g_env.ratio));
}
void ServerPlayerProcessTurnHook::describe_args(std::vector<Tv>& out, void* self, float dt) {
// capture() runs here, not in regions(): regions() reuses it, and capturing there left
// every argument one call stale (B4's lesson).
capture(self, dt);
out.push_back(tv::ptr(self).named("player"));
out.push_back(tv::f32(dt).named("dt_unused"));
out.push_back(tv::i32(g_call.snap.playerIndex).named("player_index"));
out.push_back(tv::i32(g_call.snap.modCount).named("mod_count"));
out.push_back(tv::u32(g_call.snap.researchTarget).named("research_target"));
out.push_back(tv::boolean(g_call.snap.rollPendingIn != 0).named("roll_pending_in"));
out.push_back(tv::boolean(g_call.snap.rebellionArmedIn != 0).named("rebellion_armed_in"));
out.push_back(tv::f32(g_call.snap.progressRatioIn).named("progress_ratio_in"));
out.push_back(tv::boolean(g_call.snap.haveRatio != 0).named("have_ratio"));
out.push_back(tv::boolean(g_call.snap.predictRoll != 0).named("predict_roll"));
out.push_back(tv::i32(g_call.snap.rngLeftIn).named("rng_left_in"));
out.push_back(tv::u32(g_call.snap.fpuControlWord).named("fpu_cw"));
}
void ServerPlayerProcessTurnHook::regions(std::vector<trace::Region>& out, void* self, float) {
if (!self) return;
// ---- Result regions: only what ProcessPlayerTurnBody models -------------------------------
add_region(out, "per_turn_research", self, A::ServerPlayer_off_TRM, 0x14, &describe_per_turn);
add_region(out, "reb_out_mod", self, A::ServerPlayer_off_RebOutMod, 4, &describe_f32);
add_region(out, "bonus_header", self, A::ServerPlayer_off_TimedResearchBonuses, 12,
&describe_bonus_header);
for (std::int32_t i = 0; i < g_call.bonus_regions; ++i) {
void* e = g_call.bonus[i].live;
if (!readable(e, kBonusStride)) continue;
trace::Region r;
r.name = bonus_name(static_cast<std::size_t>(i));
r.ptr = e;
r.size = kBonusStride;
r.describe = &describe_bonus;
out.push_back(r);
}
// The two research flags are a Result region even though ours never writes them: on a call
// where the roll fires the original clears ResErrRoll and ours does not, and that
// divergence is the observation the campaign has been chasing.
add_region(out, "roll_flags", self, A::ServerPlayer_off_ResearchRollPending, 2,
&describe_roll_flags);
if (g_call.have_rng) {
trace::Region r;
r.name = "rng";
r.ptr = reinterpret_cast<void*>(g_call.rng_base);
r.size = kRngSize;
r.describe = &describe_rng;
out.push_back(r);
}
{
trace::Region in;
in.name = "inputs";
in.ptr = &g_call.snap;
in.size = sizeof(PlayerTurnSnapshot);
in.describe = &DescribePlayerTurnSnapshot;
out.push_back(in);
}
// ---- Guard regions: LAST, always (a Result after a Guard is a descriptor bug) -------------
if (readable(self, kPlayerGuardSize)) {
trace::Region g;
g.name = "player";
g.ptr = self;
g.size = kPlayerGuardSize;
g.describe = &describe_opaque;
g.kind = trace::Region::Kind::Guard;
out.push_back(g);
}
void* tree = readable(self, A::ServerPlayer_off_TechTree + 4)
? ptr_at(self, A::ServerPlayer_off_TechTree)
: nullptr;
if (readable(tree, kTreeHeaderSize)) {
trace::Region g;
g.name = "tree_header";
g.ptr = tree;
g.size = kTreeHeaderSize;
g.describe = &describe_opaque;
g.kind = trace::Region::Kind::Guard;
out.push_back(g);
}
}
ServerPlayerProcessTurnHook::Args ServerPlayerProcessTurnHook::rebind(trace::Scratch& s,
void* self, float dt) {
// Region order in regions() is: per_turn_research, reb_out_mod, bonus_header,
// bonus_00..bonus_NN, roll_flags, [rng], inputs, then the guards. Only the ones ours
// writes need a scratch pointer.
std::size_t i = 0;
g_call.s_per_turn = s.count() > i ? s.ptr(i) : nullptr;
++i;
g_call.s_reb = s.count() > i ? s.ptr(i) : nullptr;
++i;
g_call.s_bonus_hdr = s.count() > i ? s.ptr(i) : nullptr;
++i;
for (std::int32_t k = 0; k < g_call.bonus_regions; ++k, ++i)
g_call.bonus[k].scratch = s.count() > i ? s.ptr(i) : nullptr;
return Args{self, dt};
}
void ServerPlayerProcessTurnHook::ours(void*, float) {
const PlayerTurnResult r = ProcessPlayerTurnBody(g_call.snap);
if (g_call.s_per_turn) {
char* p = static_cast<char*>(g_call.s_per_turn);
std::memcpy(p + 0x00, &r.trm, 4);
std::memcpy(p + 0x04, &r.tra, 4);
std::memcpy(p + 0x08, &r.trp, 4);
std::memcpy(p + 0x0c, &r.perTurnDc, 4);
std::memcpy(p + 0x10, &r.perTurnE0, 4);
}
if (g_call.s_reb) std::memcpy(g_call.s_reb, &r.rebOutMod, 4);
// The header's _Mylast moves by exactly (erased * stride); _Myfirst and _Myend do not.
if (g_call.s_bonus_hdr) {
char* h = static_cast<char*>(g_call.s_bonus_hdr);
std::uintptr_t first = 0;
std::memcpy(&first, h, 4);
const std::uintptr_t last =
first + static_cast<std::uintptr_t>(r.bonusCount) * kBonusStride;
std::memcpy(h + 4, &last, 4);
}
// Surviving elements were compacted down; elements past the new count keep whatever the
// original left there, so they are copied straight through from the pre-call snapshot.
for (std::int32_t k = 0; k < g_call.bonus_regions; ++k) {
void* d = g_call.bonus[k].scratch;
if (!d) continue;
if (k < r.bonusCount) {
std::memcpy(static_cast<char*>(d) + 0, &r.bonuses[k].amount, 4);
std::memcpy(static_cast<char*>(d) + 4, &r.bonuses[k].turns, 4);
}
}
// roll_flags and rng are deliberately left untouched: ours never rolls. A divergence on
// either is the measurement, not a defect. See the coverage notes.
}
} // namespace shim::hooks

View file

@ -0,0 +1,148 @@
// Hook descriptor for the per-player turn driver (lane T):
//
// Game::ServerPlayer::ProcessTurn(this, float dt)
//
// A verified `__thiscall` with ONE stack argument (`ret 4`) and no return value. The argument
// is the `dt` StrategyServer::ProcessTurn forwards and the original never reads it: the full
// instruction decode of 0x00891340..0x00891783 contains zero `[ebp+N]` references. It is
// declared in Args because the calling convention requires it, described in the record, and
// otherwise ignored.
//
// Called once per player per turn -- 8 calls on the reference save's End Turn -- from
// StrategyServer::ProcessTurn's player loop at 0x007dca00.
//
// WHY IT IS WORTH A COMPARE. This is the spine that joins three verified slices: it calls
// `ComputeBudget` (B1), `TechTree::ProcessResearch` (B3/U) and, through `SetResearched`,
// `ServerPlayer::OnTechResearched` (B2). It is also the function that decides the
// `ResearchRollPending` question: the block at 0x008915ec runs AFTER ProcessResearch, tests
// `0.5f < progress/Cost` strictly, and clears `ResErrRoll` ONLY when the roll fires.
//
// WHAT IS DECLARED. Three phases, and only three -- see the boundary note in
// player_turn_inputs.h for why the other three writing phases cannot be checked from outside
// the original's stack frame:
//
// phase 7 TRM / TRA / TRP / +0xdc / +0xe0 = 0
// phase 8 RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f), for a RebAI player
// phase 9 the descending timed-research-bonus sweep, which refills TRM and erases expired
// elements from the serialized `NumPR` vector
//
// plus the generator, plus the pre-call reads that decide the roll (`research_target`,
// `roll_pending_in`, `rebellion_armed_in`, `progress_ratio_in`) reported as ARGUMENTS together
// with our prediction `predict_roll`, so a run is auditable without trusting `ours`. `Sav`,
// the two aid records and the trade-income write-through are deliberately NOT Result regions:
// the whole-player guard reports them moving, which is an honest "we watched it" rather than a
// green tick on a check that compares nothing.
//
// REPLACE MODE IS REFUSED. Our side models the driver's own writes and none of its callees; a
// replace run would skip a player's whole economy, research and special projects.
#pragma once
#include <cstdint>
#include <tuple>
#include <vector>
#include "shim/trace/hook.h"
namespace shim::hooks {
struct ServerPlayerProcessTurnHook {
static constexpr const char* name = "Game::ServerPlayer::ProcessTurn";
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
using Ret = void;
using Args = std::tuple<void*, float>; // this (ServerPlayer*), dt (never read)
static void describe_args(std::vector<trace::Tv>& out, void* self, float dt);
static void regions(std::vector<trace::Region>& out, void* self, float dt);
static Args rebind(trace::Scratch& s, void* self, float dt);
static void ours(void* self, float dt);
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
static void coverage(trace::Coverage& c) {
c.unmodelled("phases 2, 3 and 6 -- the savings apply Sav = SatAdd(budget[0], net), the "
"three aid/trade records copied out of the budget, and the research refund "
"-- are NOT declared and NOT compared",
trace::Risk::High,
"all three are pure functions of ComputeBudget's 22-slot output and of "
"ProcessResearch's overBudget out-parameter, and both live in the "
"original's own stack frame ([ebp-0x90] and [ebp-0x14]). The three ways to "
"reach them are each worse than not having them: call ComputeBudget "
"ourselves (it repairs damaged ships in orbit -- harness-audit #6), read "
"them out of the nested B1/B3 hooks (harness-audit #5, the self-fulfilling "
"compare), or infer them from the observed Sav delta (the same trap). The "
"formulas ARE written and unit-tested in player_turn_inputs.cpp; they are "
"simply not wired into the verdict",
"guard:player reports Sav, +0xc8 and +0xcc moving");
c.unmodelled("ComputeBudget (0x00863030) itself, including ServerSystem::ComputeOutput, "
"which REPAIRS DAMAGED SHIPS IN ORBIT",
trace::Risk::High,
"B1 is the hook that checks the budget; its ship-repair side effect happens "
"inside this call too, once per system per player per turn",
"run hook.Game::ServerPlayer::ComputeBudget=compare alongside");
c.unmodelled("TechTree::ProcessResearch and the whole SetResearched / OnTechResearched "
"cascade it can trigger: progress, the completion roll, the 5% decay sweep, "
"the child-unlock cascade, ~90 tech-effect field writes, EVENT_RESEARCH_* "
"and EVENT_TECHS_UNLOCKED",
trace::Risk::High,
"declared input boundary. B3/U own it; this hook reports the pre-call "
"research state and lets the player guard say how much moved",
"guard:player, and hook.Game::TechTree::ProcessResearch=compare");
c.unmodelled("RollResearchAccident (0x00889dc0) draws Mars::RNG::NextInt(100) whenever "
"the research boost is non-zero, can apply progress loss across EVERY "
"system, and posts EVENT_LABACCIDENT_{SMALL,MEDIUM,LARGE}",
trace::Risk::High,
"it runs before ProcessResearch and gates it entirely. Its draws move the "
"declared `rng` region, so they are visible -- but as an unexplained RNG "
"divergence, not as their cause",
"region:rng shows the draws; nothing shows the systems it wrote");
c.unmodelled("the ResearchRollPending roll: RollResearchEvent draws one NextFloat, and "
"when it BEATS the odds the plague branch draws a SECOND word (NextInt) and "
"posts EVENT_PLAGUE_OUTBREAK, while the rebellion branch allocates an "
"AIRebellion at ServerPlayer+0x3b8 and cancels the current research",
trace::Risk::High,
"every previous coverage note in this repo says 'exactly one NextFloat'. "
"That is the cost of REACHING the branch; a fired roll costs one or two "
"words. The branch has never been observed firing. `predict_roll` says "
"whether we expected the roll, `region:rng` says what it actually cost, and "
"`roll_flags` says whether ResErrRoll was consumed",
"region:rng + region:roll_flags + guard:player; ours never rolls");
c.unmodelled("ServerPlayer::ProcessSpecialProjects (0x00840fe0) and, for an AI player, "
"ConstructionSpend (0x00817f90)",
trace::Risk::Medium,
"1015 bytes of special-project state, plus the log line "
"\"SpecialProject: %s completed investigation phase\"",
"guard:player");
c.unmodelled("the EVENT_NO_RESEARCH post at 0x0089168c, which appends to the player's "
"EventStorage (+0x29c) and bumps EvNxID (+0x2b0)",
trace::Risk::Medium,
"B3's exact failure mode: a list append outside every Result region. Its "
"condition is fully known (ResT == 0, nothing completed this turn, and at "
"least one state-2 tech exists) but ours posts nothing",
"guard:player reports the EvNxID bump");
c.unmodelled("PruneRaidTargets (0x00863cf0) erases from the heap vector at +0x338",
trace::Risk::Low,
"the vector header is inside the player guard, but its elements are heap "
"memory no region reaches, and the erase runs each removed element's "
"scalar destructor",
"guard:player sees the header move only");
c.unmodelled("the timed-bonus element regions are captured from the PRE-CALL element "
"addresses, and the sweep may shrink the vector under them",
trace::Risk::Low,
"erase moves data down inside the same buffer and only lowers _Mylast, so "
"the addresses stay valid and readable. Elements past the new _Mylast are "
"compared as stale bytes; `bonus_header` is the region that carries the "
"count, and ours writes the same stale tail the original leaves",
"region:bonus_header");
c.unmodelled("replace mode is refused for this hook",
trace::Risk::Medium,
"ours models the driver's own writes and none of ComputeBudget, "
"ProcessResearch, ProcessSpecialProjects or the raid-target prune. A "
"replace run would leave a player in a state no code path produces, and "
"there is therefore no oracle layer behind this compare");
}
};
// Process facts the hook needs (exe base for the RVAs, a line logger). Call once before
// installing.
void init_player_turn(std::uintptr_t exe_base, void (*log_line)(const char* line));
} // namespace shim::hooks

View file

@ -0,0 +1,161 @@
#include "shim/hooks/player_turn_inputs.h"
#include <cstring>
#include <utility>
#include <vector>
namespace shim::hooks {
using trace::Tv;
namespace tv = trace::tv;
namespace {
constexpr std::int32_t kSatMax = 2000000000; // 0x77359400
constexpr std::int32_t kSatMin = -2000000000; // 0x88ca6c00
} // namespace
std::int32_t SatAdd(std::int32_t a, std::int32_t b) {
// 0x00817990: the original computes a+b with wraparound and then uses the sign of b plus
// the wrap test to pick a rail, before a final clamp on the non-wrapping path.
const std::int32_t sum = static_cast<std::int32_t>(static_cast<std::uint32_t>(a) +
static_cast<std::uint32_t>(b));
if (b > 0 && sum <= a) return kSatMax;
if (b < 0 && sum >= a) return kSatMin;
if (sum < kSatMin) return kSatMin;
if (sum > kSatMax) return kSatMax;
return sum;
}
std::int32_t BudgetNet(const std::int32_t b[kBudgetSlots]) {
// 0x008913d6..0x0089140f, in the original's own operand order so a future wraparound
// question can be answered by reading this next to the disassembly.
std::int32_t v = b[6];
v -= b[13];
v -= b[12];
v -= b[10];
v -= b[9];
v -= b[8];
v -= b[7];
v -= b[11];
v -= b[14];
v += b[5];
v += b[3];
v += b[4];
v += b[1];
v += b[2];
return v;
}
std::int32_t ResearchRefund(std::int32_t overBudget, std::int32_t researchPoints,
std::int32_t researchMoney) {
if (overBudget <= 0) return 0;
if (researchPoints <= 0) return 0;
const std::int32_t used = researchPoints < overBudget ? researchPoints : overBudget;
// fild used; fidiv researchPoints; fimul researchMoney; _ftol2 (truncates toward zero).
const double q = static_cast<double>(used) / static_cast<double>(researchPoints);
return static_cast<std::int32_t>(q * static_cast<double>(researchMoney));
}
float DecayRebOutMod(float rebOutMod) {
// The original stores and reloads a float32 between every step; doing the same keeps the
// double rounding identical.
float v = static_cast<float>(rebOutMod - 0.04f);
if (v < 1.0f) v = 1.0f;
if (v > 2.0f) v = 2.0f;
return v;
}
bool PredictResearchRoll(std::uint32_t researchTarget, bool rollPending, float progressRatio) {
if (researchTarget == 0) return false;
if (!rollPending) return false;
return 0.5f < progressRatio; // strict, and NaN answers false, like the fcomp/jp pair
}
PlayerTurnResult ProcessPlayerTurnBody(const PlayerTurnSnapshot& s) {
PlayerTurnResult r;
// phase 7 -- the per-turn research accumulators are cleared before the sweep refills TRM.
r.trm = 0.0f;
r.tra = 0;
r.trp = 0;
r.perTurnDc = 0;
r.perTurnE0 = 0.0f;
// phase 8 -- the rebel-AI output decay.
r.rebOutMod = s.rebAI ? DecayRebOutMod(s.rebOutMod) : s.rebOutMod;
// phase 9 -- the timed research bonuses, LAST TO FIRST. Float addition is not associative,
// so the direction is part of the answer, not a detail.
std::int32_t n = s.bonusCount;
const std::int32_t cap = static_cast<std::int32_t>(kMaxTimedBonuses);
if (n > cap) n = cap;
if (n < 0) n = 0;
TimedResearchBonus work[kMaxTimedBonuses];
for (std::int32_t i = 0; i < n; ++i) work[i] = s.bonuses[i];
for (std::int32_t i = n - 1; i >= 0; --i) {
r.trm = static_cast<float>(r.trm + work[i].amount);
work[i].turns -= 1;
if (work[i].turns <= 0) {
for (std::int32_t j = i; j + 1 < n; ++j) work[j] = work[j + 1];
--n;
}
}
r.bonusCount = n;
for (std::int32_t i = 0; i < n; ++i) r.bonuses[i] = work[i];
return r;
}
Tv DescribeTimedBonuses(const TimedResearchBonus* v, std::int32_t n) {
std::vector<Tv> items;
if (n < 0) n = 0;
const std::int32_t cap = static_cast<std::int32_t>(kMaxTimedBonuses);
if (n > cap) n = cap;
for (std::int32_t i = 0; i < n; ++i) {
Tv one = tv::struct_();
one.add("PRm", tv::f32(v[i].amount));
one.add("PRBt", tv::i32(v[i].turns));
items.push_back(std::move(one));
}
return tv::list(std::move(items));
}
Tv DescribePlayerTurnSnapshot(const void* data, std::size_t size, unsigned) {
Tv s = tv::struct_();
if (size < sizeof(PlayerTurnSnapshot)) return s;
PlayerTurnSnapshot c;
std::memcpy(&c, data, sizeof c);
s.add("player", tv::i32(c.playerIndex));
s.add("slot", tv::i32(c.playerSlot));
s.add("species", tv::i32(c.species));
s.add("is_ai", tv::boolean(c.isAI != 0));
s.add("is_npc", tv::boolean(c.isNPC != 0));
s.add("reb_ai", tv::boolean(c.rebAI != 0));
s.add("mod_count", tv::i32(c.modCount));
s.add("phase_counter", tv::i32(c.phaseCounter));
s.add("dt", tv::f32(c.dt));
s.add("trm_in", tv::f32(c.trm));
s.add("tra_in", tv::i32(c.tra));
s.add("trp_in", tv::i32(c.trp));
s.add("reb_out_mod_in", tv::f32(c.rebOutMod));
// Watched, not modelled: these move because of ComputeBudget, which this hook cannot see.
s.add("sav_in", tv::i32(c.savings));
s.add("res_aid_given_in", tv::i32(c.researchAidGiven));
s.add("sav_aid_given_in", tv::i32(c.savingsAidGiven));
s.add("trade_income_out_in", tv::i32(c.tradeIncomeOut));
// The pre-call reads that decide the research roll. Each is rewritten by the original, so
// a post-call read would give a plausible wrong answer (lane U's lesson, one hook over).
s.add("research_target", tv::u32(c.researchTarget));
s.add("roll_pending_in", tv::boolean(c.rollPendingIn != 0));
s.add("rebellion_armed_in", tv::boolean(c.rebellionArmedIn != 0));
s.add("progress_ratio_in", tv::f32(c.progressRatioIn));
s.add("have_ratio", tv::boolean(c.haveRatio != 0));
s.add("predict_roll", tv::boolean(c.predictRoll != 0));
s.add("bonus_count", tv::i32(c.bonusCount));
s.add("bonus_overflow", tv::i32(c.bonusOverflow));
s.add("bonuses", DescribeTimedBonuses(c.bonuses, c.bonusCount));
s.add("rng_left_in", tv::i32(c.rngLeftIn));
s.add("fpu_cw", tv::u32(c.fpuControlWord));
return s;
}
} // namespace shim::hooks

View file

@ -0,0 +1,169 @@
// Lane T adapter: the ServerPlayer state `Game::ServerPlayer::ProcessTurn` reads and writes.
//
// `ServerPlayer::ProcessTurn(float dt)` is the per-player half of the turn driver. It is
// called once per player from `StrategyServer::ProcessTurn`'s player loop, and it is where
// three separately verified slices meet: `ComputeBudget` (B1), `TechTree::ProcessResearch`
// (B3/U) and, through `SetResearched`, `ServerPlayer::OnTechResearched` (B2). Between them it
// performs a handful of small steps nothing has modelled yet.
//
// This file is the pure half: it turns a snapshot of a ServerPlayer into the post-state the
// driver's own body would write, and describes both for the trace. No OS or game dependency,
// so it builds and is unit-tested on the host.
//
// ---------------------------------------------------------------------------------------------
// THE DECLARED BOUNDARY, and why it is narrower than the function.
//
// The twelve phases are listed in sots-re/findings/control-flow/turn-driver.md §2.1. Three of
// them -- the savings apply (2), the aid/trade records (3) and the research refund (6) -- are
// pure functions of `ComputeBudget`'s 22-slot output and of `ProcessResearch`'s `overBudget`
// out-parameter. **Both live in the original's own stack frame** ([ebp-0x90] and [ebp-0x14]),
// and nothing outside that frame can read them. There are exactly three ways to get them and
// each is worse than not having them:
//
// * call `ComputeBudget` ourselves -- it is NOT read-only: `ServerSystem::ComputeOutput`
// repairs damaged ships in orbit (harness-audit #6). A compare run would double-repair.
// * read them back out of the nested B1 / B3 hooks -- cross-hook state, and it makes our
// answer a function of the original's own output. That is harness-audit #5, the
// self-fulfilling compare, which the audit already flags as `risk: high`.
// * infer them from the observed `Sav` delta -- the same trap, with a coat on.
//
// So this adapter models **only the three phases that need nothing but the player**:
//
// phase 7 TRM / TRA / TRP / +0xdc / +0xe0 = 0
// phase 8 RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f), for a RebAI player
// phase 9 the descending timed-research-bonus sweep, which refills TRM and erases expired
// elements from the `NumPR` vector
//
// Everything else -- ComputeBudget, ProcessSpecialProjects, ConstructionSpend,
// RollResearchAccident, ProcessResearch and its whole cascade, the ResearchRollPending roll,
// the EVENT_NO_RESEARCH post and PruneRaidTargets -- stays an input, exactly as B4 leaves
// ServerSystem::ProcessTurn's callees. `Sav`, the two aid records and the trade-income
// write-through are deliberately NOT Result regions: the whole-player guard reports them
// moving, which is an honest "we watched it" rather than a green tick on an empty check.
//
// The three budget-driven formulas ARE written and unit-tested below, because they were read
// off the instruction stream and the next lane will need them the moment the budget becomes
// reachable (a hook on ComputeBudget's *return* would do it). They are simply not wired into
// the compare.
// ---------------------------------------------------------------------------------------------
//
// dt IS IGNORED ON PURPOSE. The original takes one float argument (`ret 4`) and never reads
// it: the full instruction decode of 0x00891340..0x00891783 contains zero `[ebp+N]`
// references. It is recorded as an argument and nothing more.
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#include "shim/trace/emitter.h"
namespace shim::hooks {
// Budget slots ComputeBudget produces. Indices are B1's; see the boundary note above for why
// this hook cannot see them.
constexpr int kBudgetSlots = 22;
// One element of ServerPlayer's timed-research-bonus vector (+0x3a4), stride 8.
struct TimedResearchBonus {
float amount = 0; // PRm
std::int32_t turns = 0; // PRBt
};
// How many bonus elements the snapshot carries before it reports a count only.
constexpr std::size_t kMaxTimedBonuses = 64;
struct PlayerTurnSnapshot {
// identity / context (recorded, never compared)
std::int32_t playerIndex = -1;
std::int32_t playerSlot = -1; // position in the server's Players vector
std::int32_t species = -1;
std::uint8_t isAI = 0; // +0xf9
std::uint8_t isNPC = 0; // +0xfb
std::uint8_t rebAI = 0; // +0xfc
std::uint8_t pad0 = 0;
std::int32_t modCount = 0; // the server turn counter the research code stamps
std::int32_t phaseCounter = 0; // the other per-turn counter
float dt = 0; // forwarded by the driver, never read by the original
// the words phases 7-9 own -- these ARE the compare
float trm = 0; // +0xd0
std::int32_t tra = 0; // +0xd4
std::int32_t trp = 0; // +0xd8
std::int32_t perTurnDc = 0; // +0xdc
float perTurnE0 = 0; // +0xe0
float rebOutMod = 0; // +0x128
// watched but not modelled (see the boundary note)
std::int32_t savings = 0; // +0x284 Sav
std::int32_t researchAidGiven = 0; // +0xc8
std::int32_t savingsAidGiven = 0; // +0xcc
std::int32_t tradeIncomeOut = 0; // (+0x3d8)->+0x10
// the research state that decides the roll. READ PRE-CALL -- every one of these is
// rewritten by the original, and reading them afterwards gives a plausible wrong answer.
std::uint32_t researchTarget = 0; // +0x294 ResT, as an opaque pointer value
std::uint8_t rollPendingIn = 0; // +0x3b4 ResErrRoll
std::uint8_t rebellionArmedIn = 0; // +0x3b5 cta, the rebellion branch's own gate
std::uint8_t predictRoll = 0; // our prediction that site A fires this call
std::uint8_t haveRatio = 0; // false when the ratio could not be measured
float progressRatioIn = 0; // TechTree::ResearchProgressRatio(tree, ResT)
// the timed-bonus vector
std::int32_t bonusCount = 0;
std::int32_t bonusOverflow = 0; // count beyond kMaxTimedBonuses, reported not carried
TimedResearchBonus bonuses[kMaxTimedBonuses] = {};
// generator + FPU context (the rng itself is a declared region)
std::int32_t rngLeftIn = 0;
std::uint32_t fpuControlWord = 0;
};
// Pinned: the snapshot is memcpy'd into a declared region, so a padding surprise would shift
// every field the describer reports (M2's +0x14 lesson).
static_assert(sizeof(PlayerTurnSnapshot) % 8 == 0,
"PlayerTurnSnapshot has no tail padding surprise");
// ---- verified formulas, not wired into the compare (see the boundary note) -------------------
// ServerPlayer::SatAdd (0x00817990), verified instruction by instruction: an overflow-checked
// add clamped to +/-2,000,000,000.
std::int32_t SatAdd(std::int32_t a, std::int32_t b);
// The net the driver applies to savings, verified operand by operand at 0x008913d6..0x0089140f:
// [1]+[2]+[3]+[4]+[5]+[6] - [7]-[8]-[9]-[10]-[11]-[12]-[13]-[14]
std::int32_t BudgetNet(const std::int32_t budget[kBudgetSlots]);
// The research refund (phase 6, 0x008914aa): unspent research points converted back to money
// at the turn's own points-per-credit rate. Returns 0 when the branch is not taken.
std::int32_t ResearchRefund(std::int32_t overBudget, std::int32_t researchPoints,
std::int32_t researchMoney);
// ---- the modelled arithmetic -----------------------------------------------------------------
// RebOutMod = clamp(RebOutMod - 0.04f, 1.0f, 2.0f), in float32 at every step.
float DecayRebOutMod(float rebOutMod);
// Site A's predicate, so the hook can state BEFORE the call whether it expects a draw:
// ResT != 0 && rollPending && 0.5f < progressRatio (strict <)
bool PredictResearchRoll(std::uint32_t researchTarget, bool rollPending, float progressRatio);
struct PlayerTurnResult {
float trm = 0;
std::int32_t tra = 0;
std::int32_t trp = 0;
std::int32_t perTurnDc = 0;
float perTurnE0 = 0;
float rebOutMod = 0;
std::int32_t bonusCount = 0;
TimedResearchBonus bonuses[kMaxTimedBonuses] = {};
};
// Phases 7, 8 and 9 of ServerPlayer::ProcessTurn -- everything the hook can check.
PlayerTurnResult ProcessPlayerTurnBody(const PlayerTurnSnapshot& s);
// Trace describers.
trace::Tv DescribePlayerTurnSnapshot(const void* data, std::size_t size, unsigned inline_max);
trace::Tv DescribeTimedBonuses(const TimedResearchBonus* v, std::int32_t n);
} // namespace shim::hooks

View file

@ -18,6 +18,7 @@
#include "shim/fpu_force.h"
#include "shim/hooks/dictionaries.h"
#include "shim/hooks/colony_turn.h"
#include "shim/hooks/player_turn.h"
#include "shim/hooks/compute_budget.h"
#include "shim/hooks/fleet_movement.h"
#include "shim/hooks/global_consts.h"
@ -162,6 +163,7 @@ using ProcessResearchHook = shim::trace::Hook<shim::hooks::TechTreeProcessResear
using OnTechResearchedHook = shim::trace::Hook<shim::hooks::ServerPlayerOnTechResearchedHook>;
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
using ColonyTurnHook = shim::trace::Hook<shim::hooks::ServerSystemProcessTurnHook>;
using PlayerTurnHook = shim::trace::Hook<shim::hooks::ServerPlayerProcessTurnHook>;
using MoveFleetHook = shim::trace::Hook<shim::hooks::StrategyServerMoveFleetHook>;
using FleetMovementHook = shim::trace::Hook<shim::hooks::StrategyServerProcessFleetMovementHook>;
@ -208,6 +210,10 @@ void InstallHooks(shim::trace::Tracer& tracer) {
// points. All three are verified thiscall prototypes with no stack-argument surprises.
shim::hooks::init_colony_turn(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::ServerSystemProcessTurnHook>(tracer, exeBase, sots::addr::ServerSystem_ProcessTurn);
// Lane T: the per-player turn driver (once per player per turn). Verified thiscall with
// one ignored float argument; see docs/T-turn-driver.md.
shim::hooks::init_player_turn(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::ServerPlayerProcessTurnHook>(tracer, exeBase, sots::addr::ServerPlayer_ProcessTurn);
shim::hooks::init_fleet_movement(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::StrategyServerMoveFleetHook>(tracer, exeBase, sots::addr::StrategyServer_MoveFleet);
InstallTemplateHook<shim::hooks::StrategyServerProcessFleetMovementHook>(tracer, exeBase, sots::addr::StrategyServer_ProcessFleetMovement);
@ -259,6 +265,7 @@ void Shim_Init(HMODULE self) {
ProcessResearchHook::register_policy(tracer);
OnTechResearchedHook::register_policy(tracer);
ColonyTurnHook::register_policy(tracer);
PlayerTurnHook::register_policy(tracer);
MoveFleetHook::register_policy(tracer);
FleetMovementHook::register_policy(tracer);
// A hook that never stated what it does not check is a defect, not a detail: say so in

19
src/shim/shim.cfg.tturn Normal file
View file

@ -0,0 +1,19 @@
# Lane T -- the per-player turn driver, TRACE first. Copy over C:\SOTS\shim.cfg.
# Every other hook is off so the trace holds nothing but this milestone. Run this before the
# compare config below: the trace answers the ResearchRollPending question on its own, because
# `roll_pending_in` / `progress_ratio_in` / `predict_roll` are recorded per call.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::ServerPlayer::ProcessTurn=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always

View file

@ -0,0 +1,19 @@
# Lane T -- the per-player turn driver, COMPARE. Copy over C:\SOTS\shim.cfg.
# Every other hook is off so the trace holds nothing but this milestone. Run this before the
# compare config below: the trace answers the ResearchRollPending question on its own, because
# `roll_pending_in` / `progress_ratio_in` / `predict_roll` are recorded per call.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::ServerPlayer::ProcessTurn=compare
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always

View file

@ -0,0 +1,6 @@
# Lane T: the per-player turn-driver adapter (ServerPlayer snapshot -> the driver's own writes).
add_executable(shim_player_turn_unit_tests unit_tests.cpp)
target_link_libraries(shim_player_turn_unit_tests PRIVATE shim_player_turn sots_game_sim)
target_include_directories(shim_player_turn_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests/game_sim)
target_compile_options(shim_player_turn_unit_tests PRIVATE -Wall -Wextra -Werror)
add_test(NAME shim_player_turn_unit COMMAND shim_player_turn_unit_tests)

View file

@ -0,0 +1,190 @@
// Lane T adapter tests: the three phases of Game::ServerPlayer::ProcessTurn this hook
// declares, plus the three budget-driven formulas that were read off the instruction stream
// but are deliberately NOT wired into the compare (see player_turn_inputs.h).
//
// Hand-written fixtures only. Every expected value below is derived from the disassembly in
// sots-re/findings/control-flow/turn-driver.md §2.1, not from a run.
#include "shim/hooks/player_turn_inputs.h"
#include <limits>
#include "check.h"
using namespace shim::hooks;
namespace {
void test_satadd() {
// 0x00817990: clamps to +/-2,000,000,000, and detects wraparound before clamping.
CHECK_EQ(SatAdd(0, 0), 0);
CHECK_EQ(SatAdd(289688, 242681), 532369); // B1's observed turn on player 0
CHECK_EQ(SatAdd(-5, 3), -2);
CHECK_EQ(SatAdd(1900000000, 1900000000), 2000000000);
CHECK_EQ(SatAdd(-1900000000, -1900000000), -2000000000);
// b > 0 but the sum wrapped negative -> the positive rail, not the negative one.
CHECK_EQ(SatAdd(std::numeric_limits<std::int32_t>::max(), 1), 2000000000);
CHECK_EQ(SatAdd(std::numeric_limits<std::int32_t>::min(), -1), -2000000000);
// b == 0 is neither wrap case; the value still gets clamped.
CHECK_EQ(SatAdd(2100000000, 0), 2000000000);
}
void test_budget_net() {
// net = [1]+[2]+[3]+[4]+[5]+[6] - [7]-[8]-[9]-[10]-[11]-[12]-[13]-[14]
std::int32_t b[kBudgetSlots] = {};
for (int i = 0; i < kBudgetSlots; ++i) b[i] = 0;
b[0] = 999999; // slot 0 is Sav and must NOT appear in the net
b[1] = 100;
b[2] = 20;
b[3] = 3;
b[4] = 4;
b[5] = 5;
b[6] = 6;
b[7] = 7;
b[8] = 8;
b[9] = 9;
b[10] = 10;
b[11] = 11;
b[12] = 12;
b[13] = 13;
b[14] = 14;
b[15] = 500000; // avail is not in the net either
b[17] = 1234;
CHECK_EQ(BudgetNet(b), 100 + 20 + 3 + 4 + 5 + 6 - 7 - 8 - 9 - 10 - 11 - 12 - 13 - 14);
// The reference-save shape: income and interest only, nothing deducted (B1's player 0,
// which has no research target, so slot 9 stays 0).
std::int32_t r[kBudgetSlots] = {};
r[0] = 289688;
r[1] = 239785;
r[5] = 2896;
CHECK_EQ(BudgetNet(r), 242681);
CHECK_EQ(SatAdd(r[0], BudgetNet(r)), 532369);
}
void test_research_refund() {
// if (over > 0 && RP > 0) refund = ftol(min(over,RP)/RP * money)
CHECK_EQ(ResearchRefund(0, 1000, 5000), 0); // nothing over budget
CHECK_EQ(ResearchRefund(-5, 1000, 5000), 0); // signed: over can be negative
CHECK_EQ(ResearchRefund(100, 0, 5000), 0); // no research points -> no rate
CHECK_EQ(ResearchRefund(100, 1000, 5000), 500); // 10% of the money back
CHECK_EQ(ResearchRefund(5000, 1000, 5000), 5000); // min() caps at the full allocation
// truncation toward zero, not rounding: 3/7 * 10 = 4.28...
CHECK_EQ(ResearchRefund(3, 7, 10), 4);
}
void test_reb_out_mod() {
CHECK_NEAR(DecayRebOutMod(2.0f), 1.96f, 1e-6f);
CHECK_NEAR(DecayRebOutMod(1.0f), 1.0f, 0.0f); // already at the floor
CHECK_NEAR(DecayRebOutMod(1.02f), 1.0f, 0.0f); // clamped up to the floor
CHECK_NEAR(DecayRebOutMod(5.0f), 2.0f, 0.0f); // clamped down to the ceiling
}
void test_predict_roll() {
// ResT != 0 && ResErrRoll && 0.5f < ratio, strictly.
CHECK(!PredictResearchRoll(0, true, 0.9f)); // no research target
CHECK(!PredictResearchRoll(0x1000, false, 0.9f)); // flag not set
CHECK(!PredictResearchRoll(0x1000, true, 0.5f)); // exactly at the threshold: NO roll
CHECK(!PredictResearchRoll(0x1000, true, 0.49f));
CHECK(PredictResearchRoll(0x1000, true, 0.5001f));
CHECK(PredictResearchRoll(0x1000, true, 1.0f));
// NaN must answer false, the way `fcomp` + `test ah,5` + `jp skip` does.
CHECK(!PredictResearchRoll(0x1000, true, std::numeric_limits<float>::quiet_NaN()));
}
void test_clears_and_decay() {
PlayerTurnSnapshot s;
s.trm = 3.5f;
s.tra = 11;
s.trp = 22;
s.perTurnDc = 33;
s.perTurnE0 = 4.5f;
s.rebAI = 0;
s.rebOutMod = 1.5f;
PlayerTurnResult r = ProcessPlayerTurnBody(s);
CHECK_NEAR(r.trm, 0.0f, 0.0f); // no bonuses -> the clear is the whole story
CHECK_EQ(r.tra, 0);
CHECK_EQ(r.trp, 0);
CHECK_EQ(r.perTurnDc, 0);
CHECK_NEAR(r.perTurnE0, 0.0f, 0.0f);
CHECK_NEAR(r.rebOutMod, 1.5f, 0.0f); // not a RebAI player: untouched
s.rebAI = 1;
r = ProcessPlayerTurnBody(s);
CHECK_NEAR(r.rebOutMod, 1.46f, 1e-6f);
}
void test_bonus_sweep() {
// The sweep runs LAST TO FIRST, decrements every PRBt, and erases the ones that hit 0.
PlayerTurnSnapshot s;
s.bonusCount = 3;
s.bonuses[0] = {0.25f, 1}; // expires this turn
s.bonuses[1] = {0.50f, 2};
s.bonuses[2] = {0.125f, 1}; // expires this turn
PlayerTurnResult r = ProcessPlayerTurnBody(s);
// every element contributes, including the ones erased on the same pass
CHECK_NEAR(r.trm, 0.875f, 1e-6f);
CHECK_EQ(r.bonusCount, 1);
CHECK_NEAR(r.bonuses[0].amount, 0.50f, 0.0f);
CHECK_EQ(r.bonuses[0].turns, 1);
// A single long-lived bonus survives with its counter decremented.
PlayerTurnSnapshot t;
t.bonusCount = 1;
t.bonuses[0] = {2.0f, 5};
PlayerTurnResult u = ProcessPlayerTurnBody(t);
CHECK_NEAR(u.trm, 2.0f, 0.0f);
CHECK_EQ(u.bonusCount, 1);
CHECK_EQ(u.bonuses[0].turns, 4);
// Empty vector: TRM ends at the phase-7 clear.
PlayerTurnSnapshot e;
PlayerTurnResult v = ProcessPlayerTurnBody(e);
CHECK_EQ(v.bonusCount, 0);
CHECK_NEAR(v.trm, 0.0f, 0.0f);
}
void test_bonus_sweep_order_matters() {
// Float addition is not associative, so the descending order is part of the answer. Pick
// magnitudes where forward and backward summation genuinely differ in float32.
PlayerTurnSnapshot s;
s.bonusCount = 3;
s.bonuses[0] = {1.0f, 9};
s.bonuses[1] = {1e-8f, 9};
s.bonuses[2] = {1e-8f, 9};
PlayerTurnResult r = ProcessPlayerTurnBody(s);
// last-to-first: (0 + 1e-8) + 1e-8 = 2e-8, then + 1.0 -> 1.0f (the small terms are lost
// only at the final add). Forward would lose them one at a time against 1.0.
float back = 0.0f;
back = static_cast<float>(back + 1e-8f);
back = static_cast<float>(back + 1e-8f);
back = static_cast<float>(back + 1.0f);
CHECK_NEAR(r.trm, back, 0.0f);
CHECK_EQ(r.bonusCount, 3);
for (int i = 0; i < 3; ++i) CHECK_EQ(r.bonuses[i].turns, 8);
}
void test_snapshot_describer_is_size_safe() {
PlayerTurnSnapshot s;
s.playerIndex = 4;
// A short buffer must not read past it.
shim::trace::Tv small = DescribePlayerTurnSnapshot(&s, sizeof(s) - 1, 256);
shim::trace::Tv full = DescribePlayerTurnSnapshot(&s, sizeof(s), 256);
(void)small;
(void)full;
CHECK(sizeof(PlayerTurnSnapshot) % 8 == 0);
}
} // namespace
int main() {
test_satadd();
test_budget_net();
test_research_refund();
test_reb_out_mod();
test_predict_roll();
test_clears_and_decay();
test_bonus_sweep();
test_bonus_sweep_order_matters();
test_snapshot_describer_is_size_safe();
return simtest::finish("shim_player_turn_unit");
}