sots-re/findings/subsystems/nvo-tshn-visible-owner.md

15 KiB
Raw Blame History

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):

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 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

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