sots-re/findings/control-flow/watchpoints-modcount-status.md
alex 92ad44efb0 lane W2: multiplayer Tier 0 played end to end with no server; ModCount/Frame/Status watchpoints; rcex explained
Multiplayer: two clients on one guest, joined by typed IP and by LAN browse, launched a
2-player game and played two turns in lockstep. 44,319 packets captured outside the guest,
ZERO to any GameSpy port. Availability check fails open exactly as lane G2 read it; Join
Manually is enabled with every gamespy.com name dead, falsifying G2's own caveat (a). The
shipped MOTD is fetched live from kerberos-productions.com and tells players to host in LAN
mode -- the developer's own statement of the same finding.

Watchpoints: a new shim module arms DR0-DR3 from StrategyServer::ApplyAllTurnCommands.
ModCount takes exactly 12 writes per End Turn (lane A2's prediction, confirmed twice, both
predicted addresses exact); Frame takes exactly one, from BeginProcessTurn+0x2a, settling the
ModCount-vs-Frame naming in A2's favour. The Player.Status writer between tail phase 31 and
the autosave EXISTS and is StrategyNetworkClient::OnMessage+0xa15 -- correcting lane T2.
Rule 19 control: the armed run reproduced the determinism oracle byte for byte.

rcex: closed from the corpus alone. 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.
2026-09-08 15:23:52 -04:00

239 lines
16 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.

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