TShn is refreshed to Frame for every (system, player) that satisfies ServerSystem::IsKnownTo 0x00746390 -- IsVisibleTo OR (2-bit sensor contact at ServerSystem+0x24 AND CCC_AdvSens). Not AFlags alone, which is why lane E3 could not fit it. The writer chain was confirmed live, frame by frame, from a hardware watchpoint on Spica's NVO node; the refresh runs twice per End Turn (driver phase 24 and combat-done phase 25). Also: the trade and spy containers read out of a live game for the first time (both empty, zero traps -- the workload confirmation two lanes lacked); Player.Status's predicate named as ReqCL != 0 && Elim == 0, with Species != 4 shown to be a corpus coincidence; and all ten command ModCount handlers named, two of them only reachable on a turn-1 workload. Corrects objects/layouts.md's ServerSystem PID@0x274 row and closes system-visibility-record.md 7. Runs: build w3tshn-bb81d3d-20260908T2007Z, configs shim.cfg.w3tshn / w3mod / w3control. Rule 19 control passed -- the armed run reproduced the determinism oracle byte for byte.
14 KiB
NVO.TShn — the visible-owner record: one writer, one gate, and the gate is not AFlags
- Type: subsystem (live watchpoint + instruction stream + whole-corpus falsification)
- Status: verified — the writer was trapped on the running game; the gate was byte-decoded;
the resulting model was then tested against every
NVOrecord in all 11 saves - Confidence: high for the writer and the gate (both instruction-verified, one of them live). Medium for which conjunct fails at the corpus's single frozen record, because the field it turns on is not serialised — see §6.
- Owner / date: lane W3 · 2026-09-08
- Instrument:
src/shim/hooks/watchpoints.{h,cpp}watch.mode=tshn, buildw3tshn-bb81d3d-20260908T2007Z, configsshim.cfg.w3tshn/shim.cfg.w3control - Predictions, committed before the build:
sots-engine/docs/W3-predictions.md - Supersedes:
system-visibility-record.md§7 ("TShn's writer remains unnamed"), and one row ofobjects/layouts.md(§2 below)
0. The result in one line
NVO[p].TShnis set to the currentFramefor every(system, player)pair that satisfiesServerSystem::IsKnownTo(sys, p), once in driver phase 24 and again in combat-done phase 25.IsKnownToisIsVisibleToor a second branch — partial sensor contact plus theCCC_AdvSenstech — and that second branch is the whole reason lane E3 could not makeAFlagsfit.
IsKnownTo, whole body, byte-decoded at 0x00746390 (0x007463ae, 0x007463c0, 0x007463cb,
0x007463da) and again inlined at 0x0075bd70 (0x0075bd88, 0x0075bd93, 0x0075bd9e,
0x0075bdb2):
bool ServerSystem::IsKnownTo(ServerPlayer* p) {
if (!p) return false;
if ((this->AFlags(+0xd4) >> p->PlyrIdx(+0x28)) & 1) return true; // == IsVisibleTo
if (p->PlyrIdx >= 15) return false;
if (((this->SnLv(+0x24) >> (2 * p->PlyrIdx)) & 3) == 0) return false; // sensor contact level
return TechTree::HasResearched(p->TechTree(+0xf4), 10000); // CCC_AdvSens
}
tech-effects.md line 89 already carried the sentence "a player in 'partial contact' state (2-bit
field, +0x24) counts as known/visible when AdvSens is researched" against exactly these three
addresses. What was missing was never the identification — it was the connection to NVO. That is
method rule 18's corollary, for the second time this campaign.
1. What the run measured
Workload: ref-turn2.sav (Frame 2, 8 players), one End Turn, four hardware data-write watchpoints
armed from the single ApplyAllTurnCommands detour.
Target selection was done by predicate at arm time, not by a hard-coded address, and the log prints all 28 systems so the choice is auditable:
watch: systems vector @0x0e4f7c9c first=0x1ee82490 last=0x1ee82500 count=28 (expect 28 …)
watch: NVO header probe -- ServerSystem+0x274 validated on 9 systems, +0x26c on 0; using +0x274
watch: sys[11] @0x0e4e51a8 'Spica' AFlags=0x0 NVO=1 NVE=0 root=0x1eea8c40 key=4 ok=1
watch: SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS
watch: slot 0 -> 'Spica' NVO root+0x10 {touched:i16,TShn:i16} key=player 4 = 0x1eea8c50
watch: slot 1 -> 'Spica' NVO._Mysize (=1 at arm) = 0x0e4e5420
Prediction P1.0 was that the predicate "AFlags == 0 and NVO non-empty" has exactly one solution on this save and that solution is Spica. It named Spica.
Four traps, two store sites, two calls (Ghidra VAs; runtime base was 0x00e80000, so
VA = EIP - 0xa80000):
| seq | trap EIP | store | value after | reading |
|---|---|---|---|---|
| 0 | 0x0075b95a |
0x0075b957 mov WORD PTR [eax],cx |
0x00020003 |
touched 2 → 3, TShn still 2 |
| 1 | 0x0075b965 |
0x0075b961 mov WORD PTR [eax+0x2],dx |
0x00030003 |
TShn 2 → 3 |
| 2 | 0x0075b95a |
same | 0x00030003 |
second call, same values |
| 3 | 0x0075b965 |
same | 0x00030003 |
second call, same values |
Every trap carried [ebp+4] = 0x0075bd4a and [[ebp]+4] = 0x0075bdc3. Those are the returns of
the call 0x0075b880 at 0x0075bd45 and the call 0x0075bca0 at 0x0075bdbe — so the chain
RefreshVisibleOwnerIfKnown 0x0075bd70 → RecordVisibleOwner 0x0075bca0
→ SetVisibleOwner 0x0075b880
→ NVO::operator[] 0x0075a890
→ the TShn store 0x0075b961
is confirmed live, frame by frame, not inferred. The stack scan also caught 0x0086a806, a
stale return inside StrategyServer::UpdateSensors's sibling — the sensor phase, which is where
the loop lives.
Against the predictions: P1.1 (TShn ends at 3) held; P1.2 (not ltis's writer FUN_00743ec0,
not RecordObservation 0x00756300) held; P1.3 held — slot 1, NVO._Mysize, took zero traps,
which is what makes slot 0's four traps mean "this node was written" rather than "this address is
stale". The prediction said "at least one" trap and got four; the refinement is §3.
2. Two corrections to the record (rule 11)
objects/layouts.md, Game::ServerSystem. The rows PID@0x274 (handle) and NVO@0x278 (int)
are wrong as field rows. +0x274 is NVO._Myhead and +0x278 is NVO._Mysize; the "PID" tag on
the wire belongs to the map node's key, written by WriteHandleId at 0x0074a0c3 after
node+0xc is mapped through server->players[idx] — exactly as EPid does for NVE. Same for the
NVE@0x288 and NVs@0x298 rows: those offsets are the _Mysize count words.
The MSVC _Tree here is 0x10 bytes — allocator +0x0, _Myhead +0x4, _Mysize +0x8,
comparator +0xc — and the three maps sit at +0x270, +0x280, +0x290.
The +0x8 that made three lanes disagree. ServerSystem::Write 0x00749630 is entered on the
IStreamable subobject at ServerSystem+0x8 (the RTTI COL offset for vftable 0x00a2043c is
+0x8), so every displacement in its body is 8 less than the object-relative offset. That is the
whole explanation for +0x26c vs +0x274, and for AFlags reading as [edi+0xcc] there and
+0xd4 everywhere else. Lane E3's +0x284/+0x288 for NVE and its AFlags(+0xd4) were both
right. The arming code does not depend on the reconciliation being right: it probes both bases and
logs the count each validates on (+0x274 on 9 systems, +0x26c on 0).
NVO node layout (instruction-verified from Write's walk 0x0074a0b0..0x0074a195):
_Left +0x0, _Parent +0x4, _Right +0x8, key = player index +0xc, then
touched:int16 +0x10, TShn:int16 +0x12, OID:int32 +0x14, isind:bool +0x18,
indi (inline IndependenceInfo) +0x1c, _Color +0x8c, _Isnil +0x8d. An NVO node is
about 0x8e bytes; NVE's is 0x20, with _Isnil at +0x19. They are not the same shape.
3. Why four traps and not two: the two-phase refresh
SensorPhase_RefreshAllVisibleOwners 0x00813700 is called from
StrategyServer::UpdateSensors 0x0086a8d0 at 0x0086a91d, and UpdateSensors is called from
three places:
| caller | when |
|---|---|
StrategyServer::ProcessTurn 0x007dcb9a |
driver phase 24 (turn-driver.md) |
OnAllCombatDone_Tail 0x007d981b |
combat-done phase 25 (combat-done-tail.md) |
LoadGame 0x007ddba8 |
on load |
The watchpoints were armed during the End Turn, after the load, so the load call is outside the window and exactly two of the three should fire. Two did. That is a live confirmation of both phase attributions, taken for free from a watchpoint that was pointed at something else.
The inner loops are for p in players: for s in systems: and the player loop is unfiltered —
there is no NPC or species test anywhere on the write path. The only NPC test in the whole family is
in SVSOIndependentSystems::Update 0x0075cb20, and it excludes NPCs (p->NPC(+0xfb) != 0 → skip). So the corpus split described below is not a special case for monsters; it is the ordinary
gate resolving differently.
4. Every writer of NVO, and why the list is complete
NVO's operator[] is 0x0075a890 and it has exactly one caller in the image:
ServerSystem::SetVisibleOwner 0x0075b880 at 0x0075b94b. (NVE's operator[] 0x00752730 has
three.) Every caller of FindVisibleOwner 0x0074d2e0 only reads the record. So SetVisibleOwner is
the only function that can write a TShn, and its three record producers are:
| producer | TShn it supplies |
|---|---|
RecordVisibleOwner 0x0075bca0 |
(int16)server->Frame(+0x8) — the per-turn refresh |
RecordVisibleOwnerNone 0x0075bc00 |
(int16)Frame, with OID = 0 |
ShareVisibleOwner 0x0075b9b0 |
the source player's TShn, unrefreshed (alliance intel; no corpus save has an alliance, so this has never run — rule 6) |
plus ServerSystem::Read 0x0075f0bd reconstructing from the wire.
SetVisibleOwner always stamps touched with the current Frame and copies TShn from the
source, so touched is "refreshed this turn" and TShn is "the turn this ownership view was
taken". SynchronizePlayer 0x007c6220 corroborates from the reading side: it decides whether to
ship an NVO update to a client by comparing touched against the frame (0x007c761d) and puts
(SnLv >> 2*idx) & 3 in the same packet (0x007c76a4).
SetVisibleOwner's own gate is a sanity check, not a rule: skip the write when the caller asks to
record player p as owner of a system p does not own, logging "VisibleOwner: %s sees their self
as owner of %s, but they are not.". Its converge point is 0x0075b983, the inlined
IndependenceInfo destructor that both arms run — checked, per method rule 4, before calling it a
branch.
5. The model, and its falsification against the whole corpus
Model, as it would be implemented:
// once per (player, system) in the sensor phase, twice per turn
if (sys->IsKnownTo(p)) {
OwnRec* r = sys->NVO[p->PlyrIdx];
r->touched = (int16)server->Frame;
r->TShn = (int16)server->Frame;
r->OID = sys->PID ? sys->PID->id : 0;
r->isind = sys->indi != NULL; r->indi = *sys->indi;
}
Test: for every NVO record in all 11 saves, predict frozen vs refreshed from the record's own
player index, the system's AFlags, and whether that player has researched CCC_AdvSens, then
compare against TShn == Frame.
| predicted | observed | n |
|---|---|---|
refresh — AFlags bit set |
TShn == Frame |
103 |
| refresh — sensor branch (player has AdvSens) | TShn == Frame |
54 |
frozen — AFlags bit clear, player lacks AdvSens |
TShn == 22 at Frame == 23 |
1 |
158 records, 0 mismatches. The single frozen record is Bismol in zuul-turn23-fleet23.sav —
lane E3's named counter-example, and the model predicts it rather than excusing it. Script:
scripts/… is not needed; the whole test is 40 lines over verify/save-reader/save_reader.py and
is reproduced in §8.
The discriminator, measured. CCC_AdvSens (TNm == "CCC_AdvSens", tech id 10000) in every
save:
| players | St |
TAcq |
TiAcq |
|---|---|---|---|
every Species-4 pseudo-player (NPC=true, RebAI=false: Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony) |
4 | 1 | 37 or 43 |
| every real empire, in every save, at every turn | 0 | −1 | −1 |
That is why the seven AFlags == 0 systems whose TShn tracks Frame — Spica in the reference
game, Altair / Galifrey / Kithrup / Alversi / Dosadi / Hyrakius in the Zuul game — all key their
single NVO entry to a Species-4 player, and why Bismol, keyed to real empire index 1, freezes the
moment its AFlags bit clears. It is not a species rule. It is that the monsters start the game
with the entire tech tree.
6. What is still a hypothesis, and why (rules 6 and 15)
- Which conjunct fails at Bismol is not decidable from any save.
SnLv(ServerSystem+0x24) is not serialised — it is recomputed every turn in the sensor phase from live positions and sensor ranges. Bismol's record is consistent with either "player 1 was outside the sensor envelope" or "player 1 lacks AdvSens". The corpus proves the second is true; it cannot show the first is false. Both reduce to the same one-line model, so nothing downstream is blocked, but the claim "the sensor branch is what refreshes Spica" is [H], not [V]: no measurement ofSnLvexists. One arming line (sys+0x24in slot 1 instead of_Mysize) settles it on the next VM run. - The 54 "sensor branch" rows are unobserved in one conjunct each. They are consistent, not
proven. The honest statement of the corpus result is: no record contradicts the model, and the
one record that contradicted
AFlagsalone is explained. ShareVisibleOwnerhas never executed — no corpus save has an alliance.p->PlyrIdx < 15is required for the sensor branch, andRefreshVisibleOwnerIfKnownuses1 << PlyrIdxwhile the sensor phase indexesSnLvby loop position. Those agree only if the player vector is ordered byPlyrIdx. It is, on every corpus save, but that was not verified in code.- One system watched, not 28. Spica's writes were trapped; the other 27 systems'
TShnwrites are corpus evidence, not watchpoint evidence. - Per-thread blind spot. Debug registers are per-thread; the arm was on the turn thread only.
7. Files
- Hit log:
verify/results/shim/watchpoints/w3-tshn-containers.txt - Arming log (all 28 systems, the base probe, the canary, the container counts):
verify/results/shim/watchpoints/w3-shim-log-excerpt.txt - Addresses:
ghidra/addresses.d/lane-w3.json - Predictions:
sots-engine/docs/W3-predictions.md; instrument notessots-engine/docs/W3-watchpoints.md
8. Reproducing the corpus test
# for each save: Frame; per player (PlyrIdx, CCC_AdvSens St/TAcq); per system (Name, AFlags, PID,
# and each NVO entry's PID key + TShn). Map the wire's handle key back to a PlyrIdx using systems
# whose AFlags has exactly one bit, then:
bit = (AFlags >> idx) & 1
pred = 'refresh' if (bit or advsens[idx]) else 'frozen'
assert (pred == 'frozen') == (TShn != Frame)