262 lines
15 KiB
Markdown
262 lines
15 KiB
Markdown
# `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.
|
||
|
||
> **SETTLED FOR SPICA BY LANE L1, 2026-09-08** (`findings/subsystems/snlv-measured.md`). The
|
||
> arming line was added (`watch.mode=snlv`) and run. Spica reads `AFlags = 0x0` with
|
||
> **`SnLv = 0x00000200`**, i.e. contact level **2** for player index 4 — the player its `NVO`
|
||
> record is keyed to. The `AFlags` term is false, so the sensor term is the only way `IsKnownTo`
|
||
> can be true: **"the sensor branch is what refreshes Spica" is now [V]**. Two further facts came
|
||
> free: level 3 occurs on exactly the eight (system, player) pairs whose `AFlags` bit is set, and
|
||
> level **2** — never 1 — occurs where it is not, which corrects
|
||
> `SensorPhase_ComputeContactLevel`'s prototype (it has four return tails, `{0,1}`, `{2,3}`, a
|
||
> hard 2 and a hard 3; the `else 1` reading names only one of them). **Bismol is still open** —
|
||
> it is not on this map. The same config on `zuul-turn23-fleet23.sav` answers it in one End Turn,
|
||
> because the arming sweep prints every system's `SnLv`.
|
||
- **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)
|
||
```
|