# `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 `NVO` record 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`, build `w3tshn-bb81d3d-20260908T2007Z`, configs `shim.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 of `objects/layouts.md` (§2 below) --- ## 0. The result in one line > `NVO[p].TShn` is set to the current `Frame` for every `(system, player)` pair that satisfies > **`ServerSystem::IsKnownTo(sys, p)`**, once in driver phase 24 and again in combat-done phase 25. > `IsKnownTo` is `IsVisibleTo` **or** a second branch — partial sensor contact plus the > `CCC_AdvSens` tech — and that second branch is the whole reason lane E3 could not make `AFlags` > fit. `IsKnownTo`, whole body, byte-decoded at `0x00746390` (`0x007463ae`, `0x007463c0`, `0x007463cb`, `0x007463da`) and again inlined at `0x0075bd70` (`0x0075bd88`, `0x0075bd93`, `0x0075bd9e`, `0x0075bdb2`): ```c 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: ```c // 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 of `SnLv` exists. One arming line (`sys+0x24` in 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 `AFlags` alone is explained.* - **`ShareVisibleOwner` has never executed** — no corpus save has an alliance. - **`p->PlyrIdx < 15`** is required for the sensor branch, and `RefreshVisibleOwnerIfKnown` uses `1 << PlyrIdx` while the sensor phase indexes `SnLv` by *loop position*. Those agree only if the player vector is ordered by `PlyrIdx`. 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' `TShn` writes 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 notes `sots-engine/docs/W3-watchpoints.md` ## 8. Reproducing the corpus test ```python # 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) ```