sots-re/findings/subsystems/nvo-tshn-visible-owner.md
alex 471d6cb973 W3: NVO.TShn's writer trapped live, its gate named, and 158/158 on the corpus
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.
2026-09-08 16:41:09 -04:00

250 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `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)
```