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

9.2 KiB
Raw Permalink Blame History

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.