# 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 `ServerPlayer_OnResearchRollSucceeded` — 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.