From 55ea86f0d5ee9f5738ad59496291c50a436a35fa Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 13:26:46 -0400 Subject: [PATCH] lane E3: the per-system visibility record read from the instruction stream, and the prediction before the build NVE is a per-(system, player) map of {ETS = turn of sighting, Eid = encounter type present}. Primary writer 0x00756300, called from tail phase 17 under the gate (AFlags >> PlyrIdx) & 1 -- byte-decoded at 0x007cf7a7..0x007cf7ce, not from the decompiler. Three further accessors named, including the intel-sharing copy that keeps the older sighting stamp. operator[] has exactly three callers and none of them erases, so the record is a memory, not a state. Also names ltis's writer (driver phase 29, AFlags != 0 -> ltis = Frame), closing half of board.md's 'TShn/ltis: NOTHING NAMES THEIR WRITER'. TShn's own gate is still unnamed and demonstrably NOT AFlags -- Spica is the counterexample. Resolves lane B5's flagged indirect edge: SetExploredBy's vft[0x1c] is ServerSystem vtable slot 7 at 0x007480b0, and it writes only an unserialised runtime mask. Prediction committed before the engine change exists: 204 -> 158 (46 closed, 32 of them the brief's target) and 103 -> 87 (16 closed), 0 regressed, with a falsification section naming the corpus save that separates AFlags from VFlags. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- .../system-visibility-prediction.md | 106 ++++++++++++ .../subsystems/system-visibility-record.md | 160 ++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 findings/subsystems/system-visibility-prediction.md create mode 100644 findings/subsystems/system-visibility-record.md diff --git a/findings/subsystems/system-visibility-prediction.md b/findings/subsystems/system-visibility-prediction.md new file mode 100644 index 0000000..719a800 --- /dev/null +++ b/findings/subsystems/system-visibility-prediction.md @@ -0,0 +1,106 @@ +# Prediction: the visibility phase, before it is built + +Lane E3, 2026-09-08. Written and committed **before** the engine change exists (rule 2). +Model: `findings/subsystems/system-visibility-record.md`. + +Baseline, measured on `sots-engine` main `0592104` this morning: + +``` +turn1-state -> turn2-state 204 leaf differences +turn2-state -> turn3-state 103 leaf differences +``` + +## The model, as three rules + +1. **T17 `RecordObservation`.** For every player `p` and system `s` with + `s.AFlags & (1 << p.PlyrIdx)`: set `NVE[p] = { EPid = p's handle id, ETS = frame, + Eid = the encounter id at s }`. `frame` is the post-increment frame, i.e. the turn number + the autosave carries. Entries are ordered by `PlyrIdx` ascending (a `std::map`). Nothing is + ever erased. +2. **T21 `SetExploredBy`.** `s.EFlags |= s.AFlags`. +3. **S29 `ltis`.** If `s.AFlags != 0` then `s.ltis = frame`. + +`Eid` is taken from an existing `NVE` entry when the system already has one (the field it +mirrors is fixed for the life of the game); otherwise from a fleet at the system with +`FtEnc != 0`; otherwise `-1`. + +`AFlags` is **read from the save, not recomputed**. Justification, and it is checkable: neither +reference pair moves an `AFlags` leaf, so the loaded value is the value the phase would see. + +## Predicted leaves closed + +### `turn1-state -> turn2-state` (the 32-leaf target) + +Eight systems — 64 Hyperion, 112 Gamma Cephei, 288 Ke'Dolarra, 304 Koa'Vo, 336 Kaa'Vaalu, +400 Markab, 448 Kea'Pono, 480 Ko'Rorkor — are exactly the systems with `AFlags != 0`. + +| leaves | rule | count | +|---|---|---:| +| `Sys[*]/NVE` count `0 -> 1` | 1 | 8 | +| `Sys[*]/EPid` only-in-B | 1 | 8 | +| `Sys[*]/ETS` only-in-B | 1 | 8 | +| `Sys[*]/Eid` only-in-B | 1 | 8 | +| **the brief's target** | | **32** | +| `Sys[*]/EFlags` `0 -> {16,128,16,16,16,16}` on 64, 304, 336, 400, 448, 480 | 2 | 6 | +| `Sys[*]/ltis` `1 -> 2` on all eight | 3 | 8 | +| **total predicted closed** | | **46** | + +Predicted `EPid` values, per system, in order: 528, 16, 32, 576, 528, 528, 528, 528. +Predicted `Eid` values: 5, -1, -1, -1, 3, 3, 4, 4. Predicted `ETS`: 2 everywhere. + +Expected result: **204 -> 158, closed 46, regressed 0.** + +### `turn2-state -> turn3-state` + +The entries already exist, so only their stamps move. + +| leaves | rule | count | +|---|---|---:| +| `Sys[*]/ETS` `2 -> 3` on the same eight | 1 | 8 | +| `Sys[*]/EFlags` | 2 | 0 (already equal to `AFlags`) | +| `Sys[*]/ltis` `2 -> 3` on the same eight | 3 | 8 | +| **total predicted closed** | | **16** | + +Expected result: **103 -> 87, closed 16, regressed 0.** + +`EPid` and `Eid` must be **re-emitted unchanged**; if either moves, the model has regressed a +leaf that agreed by construction, and that is a failure of rule 3, not a rounding detail. + +## Falsification + +Each of these is a way the model is wrong and the symptom that would show it. + +* **The gate is `VFlags`, not `AFlags`.** On both reference pairs `VFlags == AFlags` on every + system, so the pairs cannot separate them. The discriminator already in the corpus is + `zuul-turn23-fleet23.sav` Bismol: `VFlags = 2`, `AFlags = 0`, `ETS` frozen at 22 while the + save is turn 23. A `VFlags` model refreshes it to 23. **Symptom:** a host test that replays + Bismol's row fails. That test is written. +* **The gate is `EFlags`.** Same corpus discriminator: Bismol has `EFlags = 2`. +* **`ETS` is the sighting turn, set once, not refreshed.** Then `turn2 -> turn3` closes 0 of + its 8, and the eight `ETS 2 -> 3` leaves stay. **Symptom:** pair 2 closes 8 instead of 16. +* **`Eid` is re-derived from the fleet every turn rather than parked on the system.** Invisible + on this corpus — no encounter fleet dies in it. **Symptom:** none available; that is why §8 + of the model doc names the workload instead of claiming the rule. +* **The `Eid` test is `FtFlg & 0x10`, not `FtEnc != 0`.** Invisible on this corpus; the six + encounter fleets agree on both. **Symptom:** none available. +* **`EPid` is the player index, not the handle id.** Then all eight `EPid` leaves regress to + 0..7 instead of closing. **Symptom:** 8 closed becomes 8 regressed, and the run's closed + count drops to 38 / 16. +* **The map is ordered by insertion, not by key.** Invisible: every system in the corpus has + exactly one entry. **Symptom:** none available; the ordering is asserted from the container + type, and §8 names the two-empire-contact workload that would exercise it. +* **`ltis` is not the frame but something that merely equals it here.** `ltis` is `1` at + turn 1 and `2` at turn 2 on the same systems, which is consistent with a great many + counters. The reading is a 41-byte function body; if it is wrong the 8 `ltis` leaves regress + rather than close. +* **The phase runs before the frame increment.** Then every stamp is one low and 24 leaves + regress across the two pairs. + +## What the model deliberately does not write + +* `NVO.TShn` (10 leaves per pair). Its gate is not `AFlags` — Spica has `AFlags == 0` and + `TShn` still moves. Formula not held; **evaluated and reported, not written** (lane S2's + rule). +* `rcex` (6 leaves per pair). Unexplained. +* `NVs` / `PlayerView`. Count 0 on both reference pairs; the `PlayerView` refresh loop is a + separate phase and no leaf of it diverges on either pair. diff --git a/findings/subsystems/system-visibility-record.md b/findings/subsystems/system-visibility-record.md new file mode 100644 index 0000000..a19c352 --- /dev/null +++ b/findings/subsystems/system-visibility-record.md @@ -0,0 +1,160 @@ +# The per-system visibility record: `NVE`, `ltis`, and the explored mask + +Lane E3, 2026-09-08. Target: the 32 diverging `nve` leaves on the reference pair. + +Status of each claim is marked **[V] instruction-verified**, **[D] decompiler-only**, or +**[H] hypothesis** (rule 6). + +--- + +## 1. What the record is + +`Sys.NVE` is a `std::map` at `ServerSystem+0x284/+0x288`. +It is **"what this player last saw at this system"**: the turn of the sighting and the +random-encounter type that was there. Its sibling `NVO` is the same idea for *ownership* +(`TShn` = turn, `OID` = owner as last seen), and `NVs`/`PlayerView` is the same idea for the +*colony's numbers*. Three maps, one concept, one key. + +Node layout, reconciling `struct-recovery.md` §1.2 with the accessors below: MSVC `_Tree` nodes +put `_Myval` at `+0xc`, so the key (player index) is `node+0xc` and the **8-byte value is +`node+0x10`**: + +| value offset | wire name | type | meaning | +|---|---|---|---| +| +0x0 | *(not serialised)* | int16 | the turn the record was last **touched** | +| +0x2 | `ETS` | int16 | the turn the **sighting** was made | +| +0x4 | `Eid` | int32 | the random-encounter type id present, `-1` for none | + +The key is written to the wire as `EPid`, the player **handle id** (16, 32, 528 …), not the +index. That reconciles the isnil byte at `+0x19` with an 8-byte value. **[V]** (arithmetic +over the accessors in §2, each of which reads a named offset). + +## 2. Every writer and reader of the map + +| addr | what it is | body | +|---|---|---| +| `0x00756300` | **`ServerSystem::RecordObservation(p, encId)`** — the primary writer | `f = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE[p->PlyrIdx(+0x28)]; rec[0] = (f<<16)|f; rec[1] = encId;` **[D]** | +| `0x007536a0` | `ServerSystem::CopyObservationTo(p, srcRec)` — intel sharing | `rec = NVE[p->PlyrIdx]; rec.touched = (int16)Frame; rec.ETS = src.ETS; rec.Eid = src.Eid;` — the *sighting* stamp is copied, only the touch stamp is refreshed **[D]** | +| `0x00754d90` | `ShareObservation(systhis, from, to)` | `a = Find(from); b = Find(to); if (a && (!b || b->ETS < a->ETS)) CopyObservationTo(to, a);` — newer sighting wins **[D]** | +| `0x0074d360` | `ServerSystem::FindObservation(p)` | map find on `p->PlyrIdx`, returns `node+0x10` or null **[D]** | +| `0x0074f830` | `ServerSystem::LastSeenEncounterId(p)` | map find, returns `node+0x14` (`Eid`) or `-1` **[D]** | +| `0x00752730` | the map's `operator[]` | 3 callers only: the two writers above and `ServerSystem::Read` **[V]** (cross-reference query) | + +**There is no eraser.** `operator[]` has exactly three callers and none of them removes an +entry, so once a player has an entry at a system it keeps it for the rest of the game. That is +what makes the record a *memory* rather than a *state*. + +## 3. Where the primary writer is called from + +Tail phase 17, `FUN_007cf560` (the "rebuild every `PlayerView`" phase). Disassembled at +`0x007cf7a7..0x007cf7ce` — **[V]**, byte-decoded, not from the decompiler: + +``` +mov edx,[esi+0x44] ; systems.begin +mov edi,[edx+ebx*4] ; edi = sys +mov eax,[ebp-0x14] ; player +push eax +mov ecx,edi ; this = sys +call 0x00743fb0 ; ServerSystem::IsVisibleTo(sys, player) +test al,al +jz skip +mov eax,[edi+0x184] ; the encounter type id parked on the system +mov ecx,[ebp-0x14] ; player +push eax ; arg: encId +push ecx ; arg: player +mov ecx,edi ; this = sys +call 0x00756300 ; RecordObservation +``` + +Outer loop is over **players**, inner over **systems**. The gate is one instruction: + +* `FUN_00743fb0(sys, p)` = `(sys->AFlags(+0xd4) >> p->PlyrIdx(+0x28)) & 1` **[V]**. + +`AFlags` — not `VFlags`, not `EFlags`. `combat-retreat-pipeline.md` §5 already had `AFlags` as +the *derived, non-sticky* union `FFlags | GFlags | isOwner`, recomputed on every fleet arrival +and departure. So the record is refreshed exactly while the player still has something at the +system, and freezes the moment the last fleet leaves and the player does not own it. + +## 4. `Eid`'s source + +`sys->+0x184` is a `StarSystem` member initialised to `-1` in the constructor +(`FUN_00752ea0`, `param_1[0x61] = 0xffffffff`) **[D]**, and set by +`FUN_007887c0(sys, encId)` — the encounter-placement routine, which refuses unless the system +is unowned, planetless and still `-1` **[D]**. **It is not on the wire.** It is fixed for the +life of the game, so the only observable it can be recovered from is the encounter fleet +sitting at the system, whose `FtEnc` carries the same id. + +Across all 11 saves, `Eid == FtEnc` of the fleet at the system, for every one of the six +encounter fleets in the corpus, and `-1` everywhere else. The `Defenses` fleet at Koa'Vo has +`FtEnc == 0` and `FtFlg == 0x400`, and its system's `Eid` is `-1`; every real encounter fleet +has `FtEnc > 0` and `FtFlg & 0x10`. **Nothing in the corpus separates "`FtEnc > 0`" from +"`FtFlg & 0x10`" as the test, and nothing separates either from the true rule, which reads a +field that is not saved at all.** Labelled **[H]**; §8 names the workload. + +## 5. `ltis` — a second writer, in the other driver + +Driver phase 29, `FUN_00743ec0` (41 B, whole body) **[D]**: + +```c +if (this->AFlags(+0xd4) != 0) + this->ltis(+0x2c8) = this->owner(+0x10)->Frame(+0x8); +``` + +`ltis` is the system-level twin of `ETS`: same clock, same gate, but the gate is +`AFlags != 0` for **any** player rather than for a particular one, and the value is a full +int rather than an int16. `board.md` records `TShn`/`ltis` as *"NOTHING NAMES THEIR WRITER"* — +this names `ltis`'s. `TShn` is still unnamed and is **not** the same rule (§7). + +## 6. The explored mask, and the one thing `OnExploredChanged` does + +Tail phase 21, `FUN_007a3c60`, per system × per player **[D]**: + +```c +if (IsVisibleTo(sys, p) && !IsExploredBy(sys, p)) { + SetExploredBy(sys, p, true); // 0x007438b0: EFlags |= 1 << PlyrIdx + NodeGraphRegister(...); // 0x006e4620 + PostEvent("EVENT_FLEET_EXPLORED", ...); +} +``` + +so **`EFlags |= AFlags`**, once per turn, in the tail. + +`SetExploredBy`'s trailing `vft[0x1c](p, wasSet, on)` — the indirect edge `lane-b5.json` flagged +as unresolved — resolves through `Game::ServerSystem`'s primary vftable `0x00a2044c` slot 7 to +**`0x007480b0`** (`tools/vtable_map.py`). Its whole body **[D]**: + +```c +if (!wasSet && on && server->Frame(+0x8) > 1) { + FUN_00747a20(p->PlyrIdx, 1); + if (p->PlyrIdx < 15) this->+0x2a4 |= 1 << p->PlyrIdx; +} +``` + +`+0x2a4` is past `NVs` and is not in the serialised table: a runtime "newly explored this turn" +mask, not save state. **The edge is resolved and it writes nothing that reaches the wire.** +Note the `Frame > 1` guard — turn 1 is special-cased throughout this subsystem. + +## 7. What this does NOT explain + +`NVO.TShn` moves on **more** systems than `NVE.ETS` does. On `turn1 -> turn2`, Spica has +`AFlags == 0`, no `NVE` entry and no `ltis` move — and its `TShn` still goes 1 -> 2. So `NVO` +has a looser gate than `AFlags`, and it is **not** `VFlags`, `EFlags` or `FFlags` either (all +zero at Spica). `TShn`'s writer remains unnamed. Conversely `zuul-turn23-fleet23.sav`'s Bismol +has `AFlags == 0` and freezes `TShn`, `ETS` **and** `ltis` at 22 together — so on that save the +two rules agree. Whatever `NVO`'s gate is, it admits Spica and excludes Bismol. + +`rcex` (int64) moves `0 -> 65536` on the same six systems on `turn1 -> turn2` and back +`65536 -> 0` on `turn2 -> turn3`. `65536 = 1 << 16`, and Koa'Vo takes `1 << 28`. It is a +per-system word that toggles with the encounter/visibility cluster and it is **not** modelled +here. + +## 8. Workloads that would settle the open items + +| open item | workload | +|---|---| +| whether `Eid` is `sys->+0x184` (fixed at generation) or re-read from the fleet each turn | a save where an encounter fleet is **destroyed** at a system that stays visible: `+0x184` keeps the id, a fleet-derived rule drops to `-1` | +| whether the `Eid` source test is `FtEnc > 0` or `FtFlg & 0x10` | any save with a fleet carrying `FtEnc > 0` and `FtFlg & 0x10` clear, or the converse | +| `NVO.TShn`'s gate | a watchpoint on `ServerSystem+0x274`'s map during one turn — rule 18; Spica vs Bismol is already the discriminating pair | +| more than one player observing one system | every save in the corpus has **single-bit** `AFlags`. A two-empire contact save would exercise the map ordering (ascending `PlyrIdx`) and the `EPid` handle lookup for a second entry | +| `ShareObservation` (alliance intel) | no save has two players in an alliance; the newer-sighting-wins rule has never executed | +| `rcex` | unassigned |