9.2 KiB
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:
- call
ComputeBudgetourselves — it is not read-only;ServerSystem::ComputeOutputrepairs damaged ships in orbit (harness-audit #6). A compare run would double-repair, once per system. - read them out of the nested B1/B3 hooks — harness-audit #5, the self-fulfilling compare.
- infer them from the observed
Savdelta — 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 ≥ 1nested insideStrategyServer::ProcessTurn. If the count is not the player count, the base pointer is wrong — checkplayer_indexagainstslot, and check thatmod_countis the same on every call of one turn. dt_unusedis1.0on every call (the driver's own argument). It is recorded and never used.fpu_cwshould be identical on all 8 calls and match what lane F's gate forces.
2.2 Regions
per_turn_researchdiverges on no call. Phase 7 is an unconditional five-word clear and phase 9 refills onlyTRM.TRA/TRP/+0xdc/+0xe0must be0after every call, on every player. IfTRAorTRPis 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_moddiverges on no call. On the reference save no player hasRebAI, 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_headerand everybonus_NNdiverge on no call, and on the reference save there are probably zerobonus_NNregions at all —NumPRis empty in every save read so far. A run that reportsbonus_count: 0on 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 livePRm/PRBtentry can confirm the descending order on the game.rngdiverges on exactly the calls where the original drew and ours did not. Ours never draws. Expected draws per call:RollResearchAccident'sNextInt(100)when the research boost is non-zero,ProcessResearch's completion roll(s), and theRollResearchEventNextFloatwhenpredict_rollis true. Every one of these is unmodelled and declared so.roll_flagsdiverges on exactly the calls wherepredict_rollis true (the original clearsResErrRoll, ours leaves it) and on the calls where a completion consumed it inOnTechResearched. 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_flagschanging on a call wherepredict_rollis false and no completion happened → there is a third consumer of+0x3b4that the displacement scan missed.roll_flagsnot changing on a call wherepredict_rollis true and the ratio is comfortably above 0.5 → the threshold constant or the comparison sense is wrong.rngclean on a call wherepredict_rollis true →RollResearchEventdid 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/36ctest (was 35/35); the new test isshim_player_turn_unit. src/shim/hooks/player_turn.cppisWIN32-only and is not built on this host — there is no mingw cross-compiler here. It is wired intoshim_hooksand registered inmain.cpp, and it must be built on CT111 before anything is copied to the VM.- Configs:
src/shim/shim.cfg.tturn(trace) andsrc/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.