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