sots-engine/docs/T-turn-driver.md

148 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.