sots-engine/docs/W3-watchpoints.md
alex 6699f543bc watch.mode=cont: both containers, both ends, re-armed every turn; and hooks=off no longer disarms the watchpoints
Lane L3 needed to watch the trade-route and spy-program vectors across a whole game
played forward, not one turn of one save. `modcount` and `tshn` arm once on purpose --
their targets are picked from one turn's state and re-picking them would move the
measurement -- so this is a third mode rather than a change to either.

`cont` puts all four debug slots on the two containers, `_Myfirst` as well as `_Mylast`.
Both vectors are default-constructed with all three pointers zero, so the first element
writes all three: watching only `_Mylast` cannot separate "allocated for the first time"
from "appended to an existing buffer", and those are different events in the model this
lane set out to falsify. It re-arms and re-logs on every End Turn, and the canary
self-test's counter is therefore read as a delta -- on the arm-once modes the delta is
the old value, so their log lines are byte-identical.

`ReportContainer` is factored out of `ArmTshnSlots` so both modes emit the same container
line. Lane W3's published count=0 is the control every later count is compared against,
and a reformatted line would have made that comparison a judgement call.

The defect: `Shim_Init` returned before `install_watchpoints` whenever the trace mode was
`off`, so `hooks=off watch=on` printed `watch=on` in the banner and armed absolutely
nothing -- a config that reports a confident zero, which is the failure method rule 1
exists to catch. The watchpoints are an independent instrument with their own arming
detour and no trace records, and a long play session wants them without paying 30-45 s
per End Turn for template hooks that measure nothing it is asking about. MinHook is now
initialised and the module installed on the `hooks=off` path when `watch=on`.

Configs: shim.cfg.l3cont / .l3control differ in exactly one key for rule 19;
.l3probe is lane H's hp11 verbatim plus the three watch keys, so the entry counts stay
comparable to lane H's empty-container baseline line for line.

Gates: clean_room_check OK; host ctest 54/54; CT111 shim cross-build OK, exports 66
names identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:10:10 -04:00

6.2 KiB

W3 — a second watch mode, and what the two runs answered

Companion to docs/W3-predictions.md (written and committed before the build) and to sots-re/findings/subsystems/nvo-tshn-visible-owner.md + sots-re/findings/control-flow/w3-containers-status-handlers.md (the full reports).

Builds on lane W2's module without changing how it works.

What was added

watch.mode=modcount|tshn in src/shim/hooks/watchpoints.{h,cpp}. No second hook, no second detour, no change to the VEH or to the arming mechanism — only different arithmetic on the S the existing ApplyAllTurnCommands detour already holds. modcount is W2's set and stays the default so its run remains reproducible; tshn arms:

slot target
0 the TShn word of an NVO map record on a system with AFlags == 0
1 that map's _Mysize
2 trade-route vector _Mylast (*(S+4+0x154) + 0x40)
3 spy-program vector _Mylast (*(S+4+0x158) + 0x14)

Two things about the tshn arming are worth keeping if the module is extended again:

  • The target is chosen by predicate at arm time, not hard-coded, and all 28 systems are logged with name, AFlags and both map sizes. A hard-coded pointer that lands on the wrong object produces the same empty hit list as "nothing writes this" (method rule 1).
  • The ServerSystem base is probed, not assumed. Two published accounts disagreed by 8 about where the NVO map lives, because ServerSystem::Write runs on the IStreamable subobject at +0x8. The code tries both candidate bases across every system and logs how many validated under each (+0x274: 9 systems; +0x26c: 0). That turned a documentation dispute into a measurement.

Configs: shim.cfg.w3tshn, shim.cfg.w3mod, shim.cfg.w3control. w3tshn and w3control differ in exactly one key (watch=), which is what makes the rule-19 control real.

Rule 19: the control was taken again, and it passed again

One End Turn from ref-turn2.sav with the four tshn watchpoints armed reproduced the determinism oracle byte for byte — (Autosave EndTurn).sav bb4fd9ac…, (Autosave).sav 978041ac…, both identical to the pre-run files. So the four new addresses are as neutral as W2's four were. That mattered: W2 proved a data breakpoint is neutral for one address set, not for all of them, and this lane's set includes a heap node that the game reallocates freely.

The second run (turn-1 workload, watch.mode=modcount) has no oracle of its own and says so.

What the two runs answered

  1. NVO.TShn's writer, trapped live, with its whole call chain confirmed frame by frame from the recorded ebp chain: RefreshVisibleOwnerIfKnown 0x0075bd70 → RecordVisibleOwner 0x0075bca0 → SetVisibleOwner 0x0075b880 → NVO::operator[] 0x0075a890 → the store 0x0075b961. The gate is ServerSystem::IsKnownTo 0x00746390, which is IsVisibleTo or (sensor contact ∧ CCC_AdvSens) — not AFlags alone, which is why lane E3 could not fit it. The resulting model predicts all 158 NVO records in the 11-save corpus with zero mismatches, including the single frozen one.
  2. Four traps, not one, and the extra pair is a result: the refresh runs twice per End Turn, in driver phase 24 and again in combat-done phase 25. Both phase attributions confirmed for free.
  3. The trade and spy containers, read out of a live game for the first time. Both managers non-null, both vectors default-constructed with all three pointers zero, zero traps on either _Mylast across the turn. The workload is absent, measured — which is the confirmation instrument two earlier lanes needed and did not have.
  4. All ten command ModCount handlers are now named, including two that only exist on a turn-1 workload (OnCommand_SetResearchProject, inlined at 0x0088fe0a, and OnCommand_CreateDesign 0x00882910).

Reusing it

watch.mode is the extension point. To watch something else, add a mode and one arming function; everything else — the canary, the VEH, the flusher, the hit format — is unchanged. 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.

Mode cont (lane L3)

A third watch.mode, added when lane L3 needed to watch the trade-route and spy-program containers across a whole game played forward rather than one turn of one save.

  • All four debug slots go on the two containers: trade _Myfirst (mgr+0x3c), trade _Mylast (mgr+0x40), spy _Myfirst (mgr+0x10), spy _Mylast (mgr+0x14). _Myfirst is armed as well as _Mylast because these vectors are default-constructed with all three pointers zero: the first element writes all three, and watching only _Mylast cannot tell "allocated for the first time" from "appended to an existing buffer". Those are different events in the model the lane was trying to falsify.
  • It re-arms and re-logs on every End Turn. modcount and tshn arm once on purpose - their targets are chosen from one turn's state and re-picking them would move the measurement. cont has no such target selection, the two manager pointers are stable for the life of the server, and the per-turn count line is the signal.
  • The canary self-test therefore runs every turn too, and its counter is read as a delta. On the arm-once modes the delta is the old value, so their log lines are unchanged.

ReportContainer is factored out of ArmTshnSlots so both modes emit a byte-identical container line; lane W3's published counts are the control every later count is compared against.

hooks=off watch=on used to arm nothing. Shim_Init returned before install_watchpoints whenever the trace mode was off, so that config printed watch=on in the banner and then measured a confident zero - the exact failure mode method rule 1 exists to catch. The watchpoints are an independent instrument (one arming detour of their own, no trace records), and a long play session wants them without paying 30-45 s per End Turn for template hooks that measure nothing it is asking about. main.cpp now initialises MinHook and installs the watchpoint module on the hooks=off path when watch=on.