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.
148 lines
9.1 KiB
Markdown
148 lines
9.1 KiB
Markdown
# 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.
|