sots-re/findings/control-flow/watchpoints-modcount-status.md
alex cb1ea723e1 lane W2: the three queue items not reached, with the reason and the cheapest next step for each
TShn: the brief's address was wrong -- player+0x274 is observed_techs; the TShn map is
ServerSystem+0x274. The real obstacle is that a std::map's nodes are heap-allocated, so there is
no fixed leaf to arm; arm the map header to trap the insertion instead.

rcex: closed from the corpus, no VM time needed (see rcex-explained.md).

Trade/spy workload: not attempted, and lane H's reasons still hold. What is new is that the
watchpoint module is the instrument for confirming the workload BEFORE spending a turn on it --
both containers are one add from the arming point, and for the spy vector the trapped return
addresses would name the UI nobody has found.
2026-09-08 15:25:53 -04:00

20 KiB
Raw Permalink Blame History

Three questions, one armed run: ModCount, Frame, and the Player.Status regression

  • Type: control-flow (live measurement)
  • Status: verified — hardware data-write watchpoints on the running game, with a byte-identical oracle control
  • Confidence: high. Twenty-four consecutive ModCount values with no gap; every predicted address hit to the byte.
  • Owner / date: lane W2 · 2026-09-08
  • Instrument: src/shim/hooks/watchpoints.{h,cpp}, build w2watch-3512c9a-20260908T1900Z, config shim.cfg.w2watch
  • Tests: alliance-mask-and-modcount.md §3 (lane A2's probe 1 and its four falsifiers), treaty-turn-stamp.md §3 (lane T2), turn-driver.md §0.1 (lane T)

0. The four results

  1. ModCount gets exactly 12 writes per End Turn on this save, and lane A2's prediction is confirmed to the address. Two turns measured, 12 and 12, values running 13→24 and 25→36 with no gap. Ten of the twelve come from the command flush, one from ProcessTurn's first instruction, one from OnAllCombatDone_Tail. Zero from the abandon/chaos check. §2.
  2. The ModCount / Frame naming dispute is settled, and lane A2 was right. S+0x8 took 12 writes in the window; S+0xc took exactly one, from BeginProcessTurn + 0x2a, and its value became the turn number. addresses.json's StrategyServer_off_ModCount names the wrong word and lane T's StrategyServer_off_PhaseCounter is ModCount. §3.
  3. The Player.Status regression has a writer, and it is not where lane T2 looked. StrategyNetworkClient::OnMessage + 0xa15 writes Status = 4 after ProcessTurn returns and before the autosave, over the 1 the ProcessTurn tail had just written. T2's "there is NO writer between tail phase 31 and the autosave" is falsified by watching it happen, twice. §4.
  4. The instrument is byte-neutral, and this was checked rather than assumed (rule 19). With all four watchpoints armed, one End Turn from ref-turn2.sav reproduced the determinism oracle exactly: (Autosave EndTurn).sav bb4fd9ac89f41e3b…, (Autosave).sav 978041acd168b56e…. §1.

1. The instrument, and its control

One MinHook detour in the whole module. Debug registers are per-thread, so the watchpoints have to be armed from a point that runs on the turn thread and precedes the writes. StrategyServer::ApplyAllTurnCommands 0x0078f6a0 is both — lane A2 read it as the End-Turn command flush, called from StrategyNetworkClient::OnMessage immediately before that handler calls ProcessTurn, and its this is the S frame — so S+0x8 and S+0xc are one add away. The detour is the register-transparent asm stub the M0 hook already uses; the watchpoints themselves modify no code at all.

watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x0038f6a0 va=0120f6a0 create=MH_OK enable=MH_OK
watch: players vector @0e0b267c begin=0e163528 end=0e163548 count=8
watch: SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS
watch: slot 0 -> S+0x8   = 0x0e0b2630        slot 2 -> player[0]+0x164 Status = 0x0e0cc2d4
watch: slot 1 -> S+0xc   = 0x0e0b2634        slot 3 -> player[1]+0x164 Status = 0x0e0c5d44
watch: ARMED on tid 5928 dr7=0xdddd0055, S=0e0b2628

The self-test is not decoration. Method rule 1 says a green verdict is not evidence, and "the arm silently did nothing" looks exactly like "nothing writes this". So before any game address is believed, DR3 is pointed at a word the shim owns, that word is written once, and the handler is required to report exactly one trap. It did. dr7 = 0xdddd0055 is all four slots enabled, R/W = 01 (data write), LEN = 11 (4 bytes).

The control (rule 19). Lane H's finding is that a MinHook detour can change the game's output, so a run taken with a new detour installed proves nothing until the same run is taken without it. This lane's control is stronger than a same-day A/B: the armed run is the oracle run. One End Turn from ref-turn2.sav, watchpoints live, produced

file size sha256 prefix historical
(Autosave EndTurn).sav 66,732 bb4fd9ac89f41e3b identical
(Autosave).sav 67,219 978041acd168b56e identical

So the arming detour, four hardware watchpoints and ~36 exception deliveries changed nothing in the turn. That is worth stating as its own small result: a data breakpoint is a trap taken after the store retires, and unlike a code patch it has no relocation hazard — which is a point of contrast with §2 of tail-probes.md, where a detour on a clean prologue boundary did perturb.

What the instrument cannot see, stated up front. Debug registers are per-thread and these were armed on the turn thread (tid 5928) only. A write from another thread would be invisible. The evidence that there was none is indirect but strong: the 24 recorded ModCount values are contiguous — 13, 14, … 36, no gaps — so nothing incremented that word without being trapped.

2. ModCount — every writer, in order

Workload: ref-turn2.sav (turn 2, 8 players, ModCount = 12, Frame = 2), two End Turns. The window below is the second one, which is fully covered — arming happens inside the first End Turn, so the first window misses the three writes that precede the flush. Both windows agree on everything they share.

Addresses are Ghidra VAs; the trap reports the instruction after the store, so a 3-byte inc shows as site + 3.

# trap EIP site writer value return addresses
1 0x0086c3e9 0x0086c3e6 unresolved handler 25 0x0088fce2 → ApplyTurnCommandBatch+0x332
2-5 0x00821a87 0x00821a84 unresolved handler, ×4 26-29 0x0088ffcb → ApplyTurnCommandBatch+0x61b
6 0x0084946e 0x0084946b unresolved handler 30 0x008900a4 → ApplyTurnCommandBatch+0x6f4
7 0x0088bf01 0x0088befe unresolved (near DestroyFleet) 31 0x008902b4 → ApplyTurnCommandBatch+0x904
8-9 0x0089046f 0x0089046c inlined in ApplyTurnCommandBatch, ×2 32-33 0x0078f6e6 → ApplyAllTurnCommands+0x46
10 0x008657ad 0x008657aa StrategySim::MoveFleetCommand 34 0x00890652 → ApplyTurnCommandBatch+0xca2
11 0x007dc6f3 0x007dc6f0 StrategyServer::ProcessTurn, first instruction 35 —
12 0x007d92cd 0x007d92ca OnAllCombatDone_Tail + 0x2a 36 —

Against lane A2's prediction, item by item:

  • "exactly 12 hits" — confirmed, twice.
  • "two of them at 0x007dc6f0 and 0x007d92ca" — confirmed, both, to the byte. These are the only two predicted addresses in A2's probe and both landed exactly.
  • "zero of them at 0x007b9e20" — confirmed. Abdn is false on every system of this save and the abandon/chaos check's bump never fired. Note what kind of negative this is: rule 20's distinction. This is "the site never trapped", which on a watchpoint is stronger than a word count — but it still does not separate "the function was not called" from "it was called and the gate held". A2's static reading says the gate is sys->+0xc4; that remains a static claim.
  • "the other 10 … with a return address in ApplyTurnCommandBatch 0x0088f9b0 or its callers" — confirmed. Every one of the ten carries either an ApplyTurnCommandBatch return address or ApplyAllTurnCommands+0x46, and the outer frame is OnMessage+0x2c9 (0x00784909), which is the return of the ApplyAllTurnCommands call A2 located at 0x00784904. A2's whole call chain is confirmed live, address by address.
  • A2's inlined site 0x0089046c is confirmed — the trap at 0x0089046f is three bytes past it — and it fires twice in this window, so the six inlined sites are not one-shot.
  • 0x008657aa (MoveFleetCommand) confirmed to the byte.
  • A2's falsifier (c) fires, and it is a refinement rather than a refutation. A2 predicted all 12 would be "before ProcessTurn is entered". Ten are. The eleventh is ProcessTurn's own first instruction and the twelfth is after it. So the correct statement is: all ten command-application bumps precede ProcessTurn; the two driver bumps bracket the turn. The "queue is flushed first" reading survives intact.
  • Falsifier (b) fires and is the useful part. Four of the ten handler EIPs are not attributable to a named function — the nearest known symbol is between 0x581 and 0x10e9 away, which is not containment. They are recorded as addresses in ghidra/addresses.d/lane-w2.json with status mapped rather than dropped or guessed. The one to disassemble first is 0x00821a84: it fires four times per turn, the most of any handler on this save, and it sits immediately after StrategyServer::MarkPlayerTurnEnded (0x00821a40, 60 bytes, ending 0x00821a7c) — so it is that function's neighbour, not that function.
  • Falsifier (d) does not fire: no trap at 0x007850d5 / 0x0078514b / 0x00785224, so A2's exclusion of OnMessage's three inc [reg+4] sites was right. (OnMessage does appear in §4, but for Status, not for ModCount.)

12 is not a constant (rule 20). It is this save's command count. The corpus disagrees with itself usefully: turn1-state → turn2-state → turn3-state moves ModCount 0 → 12 → 24 (this game), human-turn2-orders.sav sits at 25 on frame 2 (a different game), and the Zuul line runs 63 @ f5 → 210 @ f15 → 412 @ f23, i.e. ~15-25 per turn. The structure is what generalises: two driver bumps plus one per applied command.

3. ModCount vs Frame — settled

Lane T (StrategyServer_off_PhaseCounter, turn-driver.md §0.1) and lane A2 (StrategySim_off_ModCount, alliance-mask-and-modcount.md §2.1) disagreed about which of S+0x8 and S+0xc is which, and addresses.json's StrategyServer_off_ModCount sits on the third opinion. One run separates them, because the two words behave completely differently:

word writes per End Turn writer value
S+0x8 12 ten command handlers + ProcessTurn + OnAllCombatDone_Tail monotonic, no relation to the turn
S+0xc 1 BeginProcessTurn + 0x2a (trap 0x007d990d) 3 → 4 on the turn that produced frame 4

A word written once per turn whose value is the turn number is Frame. A word written once per applied command is a modification counter. Lane A2 is right on both, and its correction of addresses.json stands. Lane T's StrategyServer_off_PhaseCounter — "nobody has named this one" — is ModCount, and the note that "both advance once per turn in different functions" is wrong about S+0x8: it advanced twelve times.

Lane A2 also predicted BeginProcessTurn's bump at 0x007d990a from the instruction stream. Trap 0x007d990d. Confirmed.

4. Player.Status — the complete ordered story

DR2 and DR3 watched players[0]+0x164 and players[1]+0x164 for the whole window. Every write of Status on those two players, in order, for one End Turn:

order site writer player value
1 0x00821a75 (store ends here) MarkPlayerTurnEnded + 0x35, called from OnPlayerEndTurn + 0x35 (return 0x007d9b2a) both, one call each 4
2 — BeginProcessTurn (Frame), ApplyAllTurnCommands (10 × ModCount), ProcessTurn entry — —
3 0x007dcc8a (trap 0x007dcc94) ProcessTurn + 0x5ca — the phase-31 site player 0 only 1
4 0x00785055 (store ends here) StrategyNetworkClient::OnMessage + 0xa15 player 0 only 4
5 — OnAllCombatDone_Tail (ModCount) — —
— (the post-turn autosave is written somewhere here)
6 0x007ddd41 (trap 0x007ddd49) ResumePlaying + 0xb1 both 0

Step 4 is the answer to the open item. The regression was stated as "the phase writes 1, the file carries 4, a load resets to 0, and the writer between tail phase 31 and the autosave is unaccounted". The writer is OnMessage + 0xa15 and it is a second, direct store site — not a call into MarkPlayerTurnEnded, because the trap EIP is inside OnMessage's body, not inside 0x00821a40. This corrects treaty-turn-stamp.md §3 on two points: MarkPlayerTurnEnded is not "THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE", and "there is NO writer between tail phase 31 and the autosave; backlog item 6 looks in the wrong place" is wrong — the backlog item was looking in exactly the right place, it just could not see the site by reading.

Two further refinements the watchpoint gives for free:

  • The phase-31 write of 1 hits the local player only, not every player. T2 read the site correctly (ProcessTurn + 0x5ca, value 1, inside a 0x44-stride loop); the loop selects one player on this save.
  • ResumePlaying's zeroing runs inside the End Turn, after the autosave, not only on load. The saved files confirm the ordering independently: (Autosave EndTurn).sav (pre-turn) carries Status = 0 for all eight players, (Autosave).sav (post-turn) carries 4, 4, 4, 4, 0, 0, 0, 0. Player 0's value in that file is the 4 from step 4, having been 4 → 1 → 4 inside one turn; players 1-3's is the untouched 4 from step 1; players 4-7 never receive one.

5. Coverage — what this run did NOT establish (rule 15)

  • One save, one game, two turns. ref-turn2.sav only. Nothing here is a claim about a turn with combat, with an abandoned system, or with more than two active players.
  • Two players watched, not eight. DR2/DR3 covered players[0] and players[1]. Players 2-7's Status was inferred from the save files, not watched. In particular, who calls MarkPlayerTurnEnded for players 2 and 3 was not observed.
  • Four of the twelve ModCount writers are unnamed (§2). The lane located them; it did not identify them.
  • 0x007b9e20 never fired, which is a real negative for this save and says nothing about a save where Abdn is true. Rule 6: that path is still a hypothesis.
  • Per-thread blind spot (§1). A ModCount write from a thread other than the turn thread would be invisible; the contiguity of the values argues there was none, but only for these two turns.
  • The autosave's exact position between steps 5 and 6 of §4 is inferred from the file contents, not watched. A fifth watchpoint is not available (four DRs), and the StrategyHost::Autosave hook already exists in the harness if anyone wants to bracket it precisely.

6. Files

  • Hit log, both turns: verify/results/shim/watchpoints/w2-modcount-status-2turns.txt
  • Instrument log incl. the self-test: verify/results/shim/watchpoints/w2-shim-log-excerpt.txt
  • Addresses: ghidra/addresses.d/lane-w2.json (10 entries)
  • Instrument: sots-engine src/shim/hooks/watchpoints.{h,cpp}, configs shim.cfg.w2watch / shim.cfg.w2control
  • Predictions, committed before the run: sots-engine/docs/W2-predictions.md

7. The instrument is reusable, and here is what it costs

watch=on in shim.cfg arms four 4-byte write watchpoints from StrategyServer::ApplyAllTurnCommands. To watch something else, change the four addresses computed in WatchOnApplyAll — anything reachable from S at that moment is one line. The run above cost one build and about six minutes of VM time for two turns, and it answered three questions that had consumed parts of four lanes of static reading. Method rule 18, again: what writes this? is a watchpoint.

Two things a future user should keep — and then §8 for the queue items this lane did not reach, each with the reason and the cheapest next step.

  • keep the canary self-test. It is four lines and it is the difference between "nothing writes this" and "the arm silently failed".
  • keep the oracle control. Loading ref-turn2.sav and ending one turn costs nothing extra and proves the run is measuring the un-instrumented game.

8. Not reached, and why — with the cheapest next step for each

Lane H's account of the trade workload was more useful than the attempt would have been. Same discipline here.

8.1 TShn — the brief's address is wrong, and that is the finding

The lane brief said "watchpoint on the player+0x274 map". That is the wrong object. player+0x274 is observed_techs (lane V, eventlive-verification.md §4.3). The TShn map is ServerSystem+0x274 — the NVO container — per system-visibility-record.md §8, whose own words are "a watchpoint on ServerSystem+0x274's map during one turn". Anyone who armed the briefed address would have watched the tech-observation array and reported a confident nothing.

The real obstacle is structural, and it is why this was not just a fifth line in WatchOnApplyAll: a std::map's nodes are heap-allocated, so TShn has no fixed address to arm before the node exists. Two ways round it, both cheap now that the module exists:

  1. Arm the map header (ServerSystem+0x274 … +0x27c) rather than a leaf. That traps the insertion, which is arguably the better question — E3's puzzle is which systems get an NVO entry, and Spica-vs-Bismol is a question about the gate, not about the value.
  2. Arm the leaf directly, by loading turn1-state.sav, walking the map for Spica's node once at ProcessTurn entry, and pointing DR0 at that node's TShn word. This needs the node layout, which objects/ has.

Either is one arming function away. What is not solved is the arming point: ApplyAllTurnCommands gives S, and ServerSystems hang off S+4+0x40, so reaching a named system needs a name compare in the arming code. About twenty lines.

8.2 rcex — closed without the VM

See findings/subsystems/rcex-explained.md. It is sixteen 4-bit per-player counters; nibble p is set to 1 on the turn the system enters player p's AFlags and ticked to 0 the next turn, 7/7 across two different games. The corpus had the answer; nobody had put the nibble index next to the AFlags bit index. The one part that still wants a watchpoint is the site of the decrement — the "rcex tick" named in ServerSystem::ProcessTurn's body-order note — and rcex is at a fixed offset in a fixed object, so it is the easiest target this module has.

8.3 The trade-route / spy workload — not attempted, deliberately

Lane H's reasons (tail-probes.md §3.2) all still hold: trade routes need trade-station construction plus its tech, which from ref-turn2 is tens of End Turns at 30-60 s each with a Build/Research click path per turn, and the turn-23 Zuul save still has none; and no lane has identified which UI produces a spy-program entry, so for spies there is still no click path to write down. This lane spent its VM time on the multiplayer demonstration and the watchpoint run because both had falsifiable predictions attached and this does not.

What is different now, and it is the thing that makes the next attempt materially cheaper: "confirm the workload took before spending a turn measuring it" is exactly what this module does, and both containers are one add from the arming point already in the code.

container address at ApplyAllTurnCommands entry (this = S)
trade-route vector (slot 15's body) *(void**)(S + 4 + 0x154) → tradeManager, then tradeManager + 0x3c (_Mylast at +0x40)
spy-program vector (slot 14's body) *(void**)(S + 4 + 0x158) → spyManager, then spyManager + 0x10 (_Mylast at +0x14)

Arm DR0/DR1 on the two _Mylast words and the game announces the moment either vector grows, with the EIP and two return addresses of whoever grew it. For the spy vector that is not just workload confirmation — it is the answer to "which UI produces a spy-program entry", because the return addresses name the caller. That is a better use of the next VM slot than playing twenty turns blind.