sots-engine/docs/W2-watchpoints.md

3.2 KiB

W2 — the watchpoint module, and what it answered

Companion to docs/W2-predictions.md (written first) and to sots-re/findings/control-flow/watchpoints-modcount-status.md (the full report).

What was added

src/shim/hooks/watchpoints.{h,cpp} — hardware data-write watchpoints on the live game.

  • One MinHook detour in the whole module, on StrategyServer::ApplyAllTurnCommands (0x0078f6a0), used only to learn S on the turn thread. Debug registers are per-thread, so the arming point has to run on that thread and precede the writes; A2 established that this function is the End-Turn command flush and that its this is the S frame.
  • DR0-DR3 as 4-byte data-write breakpoints, set with GetThreadContext/SetThreadContext on the calling thread and read back and logged, plus a vectored exception handler that records EIP, the written value, DR6, the thread id, the EBP chain and the first four code-looking words above ESP.
  • A canary self-test: DR3 is pointed at a word the shim owns, that word is written once, and the handler must report exactly one trap before any game address is believed. An arm that silently failed is indistinguishable from "nothing writes this" (method rule 1).
  • A background flusher, because the game is usually killed rather than quit and DLL_PROCESS_DETACH is not guaranteed.
  • Config: watch=on|off, watch.players=0..2, watch.out=<path>. shim.cfg.w2watch and shim.cfg.w2control differ only in watch=, so the pair is a real rule-19 control.

The watchpoints modify no code, so the instrument's only patched bytes are the single arming detour's five.

Rule 19: the control was taken and it passed

One End Turn from ref-turn2.sav with all four watchpoints armed reproduced the determinism oracle byte for byte — (Autosave EndTurn).sav bb4fd9ac…, (Autosave).sav 978041ac…. The prediction in W2-predictions.md Part 2 ("the watchpoint is byte-neutral, unlike a detour, because a #DB on a data write is a trap taken after the store retires and no code is modified") held.

What it answered

  1. ModCount (S+0x8): exactly 12 writes per End Turn on this save, twice, values 13→24 and 25→36 with no gap. Lane A2's prediction confirmed, including both predicted addresses (0x007dc6f0, 0x007d92ca), the zero at 0x007b9e20, and the whole OnMessage → ApplyAllTurnCommands → ApplyTurnCommandBatch call chain.
  2. Frame (S+0xc): exactly one write per End Turn, from BeginProcessTurn + 0x2a, value becoming the turn number. This settles the ModCount-vs-Frame naming dispute in lane A2's favour against both lane T and addresses.json.
  3. Player.Status: the writer the S31 regression was missing is StrategyNetworkClient::OnMessage + 0xa15, writing 4 after ProcessTurn returns and before the autosave. Lane T2's "there is no writer between tail phase 31 and the autosave" is falsified.

Reusing it

Change the four addresses computed in WatchOnApplyAll; anything reachable from S at the flush is one line. Keep the canary self-test and keep the ref-turn2 oracle control — together they cost about two minutes and they are what makes the numbers evidence rather than output.