diff --git a/findings/control-flow/w3-containers-status-handlers.md b/findings/control-flow/w3-containers-status-handlers.md new file mode 100644 index 0000000..61ae07a --- /dev/null +++ b/findings/control-flow/w3-containers-status-handlers.md @@ -0,0 +1,197 @@ +# The trade/spy containers measured, the `Player.Status` predicate named, and the last four `ModCount` handlers + +- **Type:** control-flow (live measurement + instruction stream) +- **Status:** **verified**, with one part explicitly labelled as an absence rather than a result +- **Confidence:** high. The container counts are a live reading; the three predicates are + byte-decoded, not inferred. +- **Owner / date:** lane W3 · 2026-09-08 +- **Companion to:** `nvo-tshn-visible-owner.md` (same run, same build) and + `watchpoints-modcount-status.md` (lane W2, whose §5 and §8 this closes) + +--- + +## 1. The trade-route and spy-program containers — measured, and both empty + +Two lanes have failed to build a trade/spy workload, and lane W2 left the right instrument for the +next attempt: both containers are one add from the arming point. This lane armed them. + +**At `ApplyAllTurnCommands` entry on `ref-turn2.sav`, live:** + +``` +watch: trade routes -- manager=0x0e4cdd38 (S+4+0x154) vector@mgr+0x3c + first=0x00000000 last=0x00000000 end=0x00000000 count=0 +watch: spy programs -- manager=0x0e512f68 (S+4+0x158) vector@mgr+0x10 + first=0x00000000 last=0x00000000 end=0x00000000 count=0 +``` + +Both managers exist and are non-null; both vectors are **entirely unallocated** — `_Myfirst`, +`_Mylast` and `_Myend` are all zero, which is a default-constructed `std::vector` that has never +held an element, not an emptied one. Watchpoints on both `_Mylast` words took **zero traps** across +the whole End Turn. + +**Why that is worth writing down rather than a shrug.** The reason two lanes could not build this +workload was never that the click path is long; it is that nobody could tell whether an attempt had +*taken*. `ServerTradeManagerImpl` and `ServerSpyManager` are both reachable from the `S` frame the +existing detour already holds, so the confirmation costs two lines of arithmetic and one log line. +The next lane to spend twenty turns on trade stations can now check after every turn whether the +container grew, instead of playing blind and reading the save afterwards. + +**What this does not say (rule 20).** It does not say the containers are never filled. It says: on +this save, in this turn, they were empty and nothing grew them. That is the *absence of the +workload*, measured — which is exactly what a workload confirmation is for, and it is the first +time either container has been read out of a live game. + +**What is still missing for spies specifically.** The plan was that if the spy vector ever grew, the +trapped return addresses would name the UI that creates a spy program — the question no lane has +answered. It did not grow, so that question is **still open**, and the instrument that would answer +it is now written and proven to arm. The next step is not more reading: it is one game in which a +spy program is created by any means, with `watch.mode=tshn` armed. + +This also bears on the RNG ledger. Lane V proved four tail callees can draw RNG and lane H's +predecessor showed they are entered every turn and gated inside on empty containers (rule 20's +worked example). This run confirms the containers are empty at the level of the vector's own +pointers, so the gating is not a mis-read of a partially-initialised object. If either container +ever fills, the ledger and the standalone's generator model both need extending — and the trigger +for that work is now detectable. + +## 2. `Player.Status` — the predicate is `ReqCL`, and `Species != 4` is a corpus coincidence + +Lane W2 found the missing writer (`OnMessage + 0xa15`). What was open was *which players get +which value*. Only two corpus saves carry a non-zero `Status`, and there `Species != 4`, +`!(NPC && !RebAI)` and "has a badge string" all fit the same 8 observations — a 1-bit predicate +against nine saves that never exercise it (rule 20). The code separates them. + +**Answer:** at the moment the post-turn autosave is written, + +> `Status == 4` ⟺ `ReqCL(+0xfd) != 0 && Elim(+0xf8) == 0`. +> Every other player still carries the `0` that the previous `ResumePlaying` wrote. + +**No species field is read on any path that writes or reads `Status`.** Neither is `NPC(+0xfb)` or +`RebAI(+0xfc)`. The four writers, decoded: + +| writer | value | gate | +|---|---|---| +| `MarkPlayerTurnEnded 0x00821a40`, store `0x00821a6b` | 4 | **none** — one call per `SNMPlayerEndTurn` message, the handle taken straight off the wire by `OnPlayerEndTurn 0x007d9af0` at `0x007d9b25`. The field tests at `0x007d9b5d`/`0x007d9ba2`/`0x007d9bb0` all follow the store and only decide whether to raise a "waiting on X" event | +| `ProcessTurn + 0x5ca`, store `0x007dcc8a` | 1 | `if (p->IsAI(+0xf9) == 0 \|\| p->+0xfa != 0)`, decoded at `0x007dcc78`, over the encounter records at `S+0x1e8` (0x74-byte outer records, 0x44-byte inner entries whose first dword is the `ServerPlayer*`) | +| `OnMessage + 0xa15`, store ends `0x00785055` | 4 | **none** — the `Game::SNMSetPlayerStatus` handler (vftable `0x00a2d718`, `{vptr, +4 player INDEX, +8 status}`) writes the value verbatim off the wire. The *sender* is `StrategyNetworkServer::Update 0x007cda40`, which computes `2*(flag != 0) + 2` at `0x007cdcf0` — 4 when the player has nothing pending, 2 when it does — after gating on `Status == 1` at `0x007cdc0b` | +| `ResumePlaying + 0xb1`, store `0x007ddd41` | 0 | a loop over **all** players, `0x007ddd18..0x007ddd55`, gated only on `Elim(+0xf8) == 0` | + +So `Status == 4` marks "this player's End Turn arrived", and the population is "players that own a +`StrategyClient`". `ReqCL` is the field that decides that, in two places: + +- `StrategyNetworkServer::SetupAIPlayers 0x007bc1c0`, gate at `0x007bc25c`: a player with + `ReqCL == 0` is skipped before the AI list is built, so it never gets a client and can never be + the subject of `MarkPlayerTurnEnded`. +- **The game's own definition of "everybody has ended their turn"**, + `StrategyNetworkServer::AllPlayersTurnEnded 0x007cfd00` (vftable slot 3), loop + `0x007cffb7..0x007d0002` with the literal `4` loaded at `0x007cffa3`: + ```c + for (p : players) { + if (!p->ReqCL(+0xfd)) continue; + if ( p->Elim (+0xf8)) continue; + if ( p->Status(+0x164) != 4) return false; + } + ``` + That is the predicate, written by the game, in one place. + +**Why the four special players have `ReqCL == 0`:** `FUN_00797b80`, the script binding that creates +them, calls the script `CreatePlayer 0x0057b6c0` with the literal name `"_NPC"` (`0x009f94e0`) and +`NPC = 1`, then clears `[esi+0xfd]` explicitly at `0x00797bcb`. + +**Why the corpus cannot see this:** `ReqCL` is serialised (tag `"ReqCL"`), and `ReqCL == (Species != 4)` +holds in all 88 player records of all 11 saves. Two independent-looking checks with one hidden +shared assumption is one check (rule 8) — here the assumption is that this game's four NPC slots are +the only players with `ReqCL == 0`, which is true of the corpus and is not the rule. + +This also **corrects `treaty-turn-stamp.md` §3 further than lane W2 did**: `MarkPlayerTurnEnded` is +not "the only writer of `Player.Status = 4` in the image" by three more sites, all client-side and +all acting on `client->+0x150`: `0x007773be` (writes 4 at player/client bind time), `CancelEndTurn + +0x4a` `0x0078573a` and `0x007774ee` (both write 0). + +## 3. The four unnamed `ModCount` handler EIPs — all four named + +Lane W2 measured exactly 12 `ModCount` bumps per End Turn on `ref-turn2` and could attribute eight +of the ten command handlers. The four it recorded as +`StrategySim_ModCountBump_unresolved_*` are: + +| trap EIP | function | name | evidence | +|---|---|---|---| +| `0x00821a87` (**×4/turn**) | `0x00821a80` | **`StrategyServer::OnCommand_SetResearchRate`** | `"OnCommand: Cannot set research rate for player %d(id). Player not found"` (`0xa2fbb0`), `"OnCommand: Research rate %f constrained to %f for %s."` (`0xa2fb78`); body sets `player->ResRate(+0xbc)` = the argument clamped to `[0,1]` by two `fcomp`s against `0x9e1ef0` / `0x9e1e68` | +| `0x0086c3e9` | `0x0086c3e0` | **`StrategyServer::OnCommand_SetSystemRates`** | `"OnCommand: Unable to set system rates for %d(id). System not found."` (`0xa32448`), `"OnCommand: Player setting system rates for system he does not own."` (`0xa32400`) | +| `0x0088bf01` | `0x0088bed0` | **`StrategyServer::OnCommand_TransferShips`** | `"OnCommand: Cannot transfer ships. Player %d(id) not found."` (`0xa33248`), `"TransferShips: Fleet flagged as empty, but still has ships."` | +| `0x0084946e` | `0x00849460` | **`StrategyServer::OnCommand_AddBuildOrder`** | no string of its own; identified from its callers — the Build Screen `0x00656540` → `0x00762fd0` (`"BuildQueue::DoAddOrder(): Requested design %s was not found…"`, `"Build Screen: Cannot build %s."`) and the command replay at `0x0089009f`. Body: pick `target->+0xa4` or `+0x98` by `cmd[0]`, validate with `0x0083ce50`, then `BuildQueue::AddOrder 0x00845260` push_backs onto the list at `queue+0x10` | + +**Why `SetResearchRate` fires four times.** The reference game has four players with a client +(indices 0–3 — the two humans and the two rebel-AI empires) and four `_NPC` pseudo-players without +one. Each client sends one research-rate command per turn. That is the same count, and the same +population, as §2's `ReqCL` predicate — the two results are the same fact seen from two sides, and +neither was derived from the other. + +**W2's caveat survives and should be repeated: 12 is not a constant.** It is this save's command +count. `turn1→turn2→turn3` moves `ModCount` 0 → 12 → 24 in this game; `human-turn2-orders.sav` sits +at 25 on frame 2; the Zuul line runs 63 @ f5 → 210 @ f15 → 412 @ f23, i.e. ~15–25 per turn. What +generalises is the structure — two driver bumps plus one per applied command — and now, with all ten +handlers named, the per-command half of that is enumerable rather than approximate. + +### 3.1 A second turn, a different command set, and two more handlers (lane AI4's probe) + +Lane AI4 committed a prediction for `turn1-state.sav` before this run: again exactly 12 bumps, but +out of a *different* command set, whose trap multiset **contains EIPs never seen on turn 2 and omits +three that were**. That is a much sharper test than "does the number match 12" — a wrong model fails +visibly instead of landing on 12 by coincidence. The same build, one config key changed +(`watch.mode=tshn` → `modcount`), one save swapped. + +**Result: 12 traps on `S+0x8`, values contiguous 1 → 12** (this save starts at `ModCount = 0`): + +| Ghidra VA | × | handler | on turn 2? | +|---|---|---|---| +| `0x0086c3e9` | 1 | `OnCommand_SetSystemRates` | yes, ×1 | +| `0x00821a87` | **4** | `OnCommand_SetResearchRate` | yes, ×4 | +| **`0x0088fe0d`** | **3** | **`OnCommand_SetResearchProject`** — a *second* bump **inlined** into `ApplyTurnCommandBatch` at `+0x45a`, distinct from lane W2's inlined site `0x0089046c`. String `"OnCommand: Cannot set research project for player %d(id). Player not found."` (`0xa30ed0`) | **never seen** | +| **`0x0088291d`** | **1** | **`StrategyServer::OnCommand_CreateDesign`** `0x00882910`. Strings `"OnCommand: Cannot create design for player %d(id), player does not exist."` (`0xa32c40`), `"OnCommand: Unable to create design. ID not specified."` (`0xa32c04`) | **never seen** | +| `0x0084946e` | 1 | `OnCommand_AddBuildOrder` | yes, ×1 | +| `0x007dc6f3` | 1 | `ProcessTurn`, first instruction (driver) | yes | +| `0x007d92cd` | 1 | `OnAllCombatDone_Tail + 0x2a` (driver) | yes | + +**Absent, and predicted absent:** `0x0089046f` (×2 on turn 2, the AI fleet order), `0x008657ad` +(fleet route) and `0x0088bf01` (transfer ships). No fleet moved on turn 1. + +**AI4's named falsifier is answered: `0x0088291a` DOES fire, once.** So command list 1 emits a +design **command**, not merely a design object that the build order names — the engine has to +implement the command. + +**A cross-check that neither model was fitted to.** Four rate-gate bumps and **three** research- +project bumps. The three are the AI empires; the human player set a rate but picked no project, +which is exactly why the reference game's map banner reads *"No Research Project Assigned"* in the +turn-2 screenshot. And the four-vs-three split is the same population §2 derives from `ReqCL`: +four players have a client and submit a command block, the four Species-4 pseudo-players submit +none. Lane AI4 reached that population by counting bumps; §2 reached it by decoding +`AllPlayersTurnEnded`. Neither was derived from the other. + +**Also confirmed independently this lane:** `ref-turn2.sav` on the VM and +`verify/results/saves/turn2-state.sav` in the repo are the same 66,739 bytes with sha256 +`ab4ac2d7e2977260…`. Lane AI4's correction stands, and it means the `TShn` measurement in +`nvo-tshn-visible-owner.md` was taken on the standalone's own reference pair. + +**Coverage caveat for this second run.** It has **no oracle control of its own** — the autosaves it +produced are a turn-1 workload for which no historical hash exists. What it rests on is that the +binary is bit-identical to run 1 (`w3tshn-bb81d3d-20260908T2007Z`), whose armed run *did* reproduce +the oracle byte for byte, and that the two configs differ only in `watch.mode`. The canary self-test +passed in both. + +**Not determined:** which build queue `cmd[0] == 1` selects. `Game::ServerSystem` has exactly one +`BQ` pointer (`+0xa4`), which fits `cmd[0] == 0`, but the handle map's value type is a base +subobject whose offset differs per class (the design handle gets a `-0xa0` adjust at `0x008494ca`), +so the frame those two offsets are measured in was not pinned down. + +## 4. Coverage — what this run did not establish (rule 15) + +- **One save, one game, one turn.** Everything above is `ref-turn2.sav` plus the instruction stream. +- **The containers are empty, so nothing about their behaviour when full was observed.** Not one + element of either has ever been seen in this campaign, live or on the wire. +- **`Status` was not watched in this run at all** — §2 is entirely instruction-stream work, resting + on lane W2's live ordering. The one thing a run could still add is *membership*: whether players + 4–7 ever appear in the `S+0x1e8` encounter records that §2's phase-31 loop walks. That is + data-dependent and cannot be read. +- **`ReqCL` was verified as the gate in two functions**; it was not traced through every path that + could create a client. diff --git a/findings/subsystems/nvo-tshn-visible-owner.md b/findings/subsystems/nvo-tshn-visible-owner.md new file mode 100644 index 0000000..d639e15 --- /dev/null +++ b/findings/subsystems/nvo-tshn-visible-owner.md @@ -0,0 +1,250 @@ +# `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) +``` diff --git a/findings/subsystems/system-visibility-record.md b/findings/subsystems/system-visibility-record.md index a19c352..6b39fdf 100644 --- a/findings/subsystems/system-visibility-record.md +++ b/findings/subsystems/system-visibility-record.md @@ -136,6 +136,17 @@ Note the `Frame > 1` guard — turn 1 is special-cased throughout this subsystem ## 7. What this does NOT explain +> **CLOSED by lane W3, 2026-09-08 — see `nvo-tshn-visible-owner.md`.** `NVO.TShn`'s writer is +> `ServerSystem::SetVisibleOwner 0x0075b880` (trapped live on Spica), reached from the sensor phase +> via `RefreshVisibleOwnerIfKnown 0x0075bd70`. The looser gate this section correctly predicted is +> `ServerSystem::IsKnownTo 0x00746390` = `IsVisibleTo` **or** (2-bit sensor contact at +> `ServerSystem+0x24`, which is *not serialised*, **and** the player has researched `CCC_AdvSens`). +> Spica's entry belongs to a Species-4 NPC pseudo-player and every one of those starts the game with +> `CCC_AdvSens`; Bismol's belongs to a real empire and none of those has it in any corpus save. +> The resulting model predicts all 158 `NVO` records across the 11 saves, including Bismol. +> The section below stands as written — it is what made the case checkable. + + `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 diff --git a/ghidra/addresses.d/lane-w3.json b/ghidra/addresses.d/lane-w3.json new file mode 100644 index 0000000..23d5d42 --- /dev/null +++ b/ghidra/addresses.d/lane-w3.json @@ -0,0 +1,256 @@ +{ + "entries": [ + { + "name": "ServerSystem_off_AFlags", + "offset": "0xd4", + "convention": "offset", + "status": "verified", + "prototype": "int AFlags -- the per-player 'has something here' mask, bit = PlyrIdx. Confirmed from ServerSystem::Write 0x00749630 at 0x00749aec (`mov ecx,[edi+0xcc]` with the \"AFlags\" tag at 0xa1f3b0; Write runs on the IStreamable subobject at +0x8, so 0xcc + 8 = 0xd4). Lane E3's +0xd4 is right" + }, + { + "name": "ServerSystem_off_SnLv", + "offset": "0x24", + "convention": "offset", + "status": "verified", + "prototype": "int SnLv -- 2 bits per player (level = 0 none / 1 partial / 3 full), NOT SERIALISED. Written once per turn in the sensor phase at 0x0086a789 as `s->SnLv = (s->SnLv & ~(3<<2i)) | (level<<2i)`; read by ServerSystem::IsKnownTo 0x00746390 and RefreshVisibleOwnerIfKnown 0x0075bd70, and shipped to clients by SynchronizePlayer at 0x007c76a4. Because it is absent from every save, any NVO/TShn claim that depends on it is a hypothesis on the corpus alone (rule 6)" + }, + { + "name": "ServerSystem_off_NVO_Myhead", + "offset": "0x274", + "convention": "offset", + "status": "verified", + "prototype": "std::map::_Myhead for NVO (the visible-owner record). The map object starts at +0x270 and is 0x10 bytes: allocator +0x0, _Myhead +0x4, _Mysize +0x8, comparator +0xc. From ServerSystem::Write 0x0074a097/0x0074a195 (`[edi+0x26c]`, +8 for the IStreamable subobject) and from the live arming probe, which validated +0x274 on 9 of 28 systems and +0x26c on 0" + }, + { + "name": "ServerSystem_off_NVO_Mysize", + "offset": "0x278", + "convention": "offset", + "status": "verified", + "prototype": "std::map _Mysize for NVO. Write 0x0074a07d (`[edi+0x270]`) carries the \"NVO\" tag 0xa1f2c0, so the wire's NVO count is this word. CORRECTS objects/layouts.md, which lists NVO@0x278 as an int field and PID@0x274 as a handle: +0x274 is _Myhead and the \"PID\" tag belongs to the node KEY, not to the object" + }, + { + "name": "ServerSystem_off_NVE_Myhead", + "offset": "0x284", + "convention": "offset", + "status": "verified", + "prototype": "std::map _Myhead for NVE (the sighting record). Write 0x0074a1bb. Confirms lane E3's +0x284/+0x288" + }, + { + "name": "ServerSystem_off_NVE_Mysize", + "offset": "0x288", + "convention": "offset", + "status": "verified", + "prototype": "std::map _Mysize for NVE. Write 0x0074a1a1, tag \"NVE\"" + }, + { + "name": "ServerSystem_off_NVs_Myhead", + "offset": "0x294", + "convention": "offset", + "status": "verified", + "prototype": "std::map _Myhead for NVs (the per-player PlayerView record). Write 0x0074a27d" + }, + { + "name": "ServerSystem_off_NVs_Mysize", + "offset": "0x298", + "convention": "offset", + "status": "verified", + "prototype": "std::map _Mysize for NVs. Write 0x0074a263, tag \"NVs\"" + }, + { + "name": "ServerSystem_NVO_node_off_TShn", + "offset": "0x12", + "convention": "offset", + "status": "verified", + "prototype": "int16 TShn, offset within the NVO map NODE (not the value). Node is _Left +0x0, _Parent +0x4, _Right +0x8, key(PlyrIdx) +0xc, value +0x10 = {int16 touched +0x10, int16 TShn +0x12, int32 OID +0x14, bool isind +0x18, IndependenceInfo indi +0x1c}, _Color +0x8c, _Isnil +0x8d -- so an NVO node is ~0x8e bytes, unlike NVE's 0x20. From Write 0x0074a0ca (`lea ecx,[ebx+0x12]`, tag \"TShn\") and confirmed live: a 4-byte watchpoint on value+0x0 trapped both halves separately" + }, + { + "name": "ServerSystem_NVO_operator_index", + "offset": "0x0075a890", + "convention": "thiscall", + "status": "verified", + "prototype": "OwnRec* (NVO_map* this /* ServerSystem+0x270 */, const int* plyrIdx) // std::map::operator[]; returns node+0x10. Exactly ONE caller in the image: ServerSystem::SetVisibleOwner 0x0075b94b (NVE's operator[] 0x00752730 has three). That single-caller fact is what makes the writer inventory below complete" + }, + { + "name": "ServerSystem_SetVisibleOwner", + "offset": "0x0075b880", + "convention": "thiscall", + "status": "verified", + "prototype": "void (ServerSystem* this, ServerPlayer* p, const OwnRec* src) // ret 8. Stamps value.touched = (int16)this->owner(+0x10)->Frame(+0x8) and copies src's TShn/OID/isind/indi verbatim into NVO[p->PlyrIdx]. THE ONLY WRITER OF NVO.TShn IN THE IMAGE. Local gate (converge point 0x0075b983, the inlined IndependenceInfo dtor, so it is a real branch and not a rule-4 artifact): skip the whole write when src->OID == p->id but this->PID(+0x100)->id != p->id, logging \"VisibleOwner: %s sees their self as owner of %s, but they are not.\"" + }, + { + "name": "ServerSystem_SetVisibleOwner_TShnStore", + "offset": "0x0075b961", + "convention": "offset", + "status": "verified", + "prototype": "site `mov WORD PTR [eax+0x2],dx` -- the TShn store. Trapped live by a DR watchpoint on Spica's NVO root node: EIP 0x0075b965 (the trap is taken after the store retires), twice per End Turn. The touched-word store is the instruction before it at 0x0075b957 (trap EIP 0x0075b95a)" + }, + { + "name": "ServerSystem_RecordVisibleOwner", + "offset": "0x0075bca0", + "convention": "thiscall", + "status": "verified", + "prototype": "void (ServerSystem* this, ServerPlayer* p) // ret 4. Builds an OwnRec with TShn = (int16)server->Frame, OID = this->PID(+0x100)->id, isind/indi from this->indi(+0x1c8), then calls SetVisibleOwner at 0x0075bd45. THIS is where TShn = Frame is decided. Confirmed live: the trapped frame's [ebp+4] was 0x0075bd4a" + }, + { + "name": "ServerSystem_RecordVisibleOwnerNone", + "offset": "0x0075bc00", + "convention": "thiscall", + "status": "mapped", + "prototype": "void (ServerSystem* this, ServerPlayer* p) // TShn = Frame, OID = 0 -- 'you now see this system as unowned'. Frame read at 0x0075bc5f" + }, + { + "name": "ServerSystem_ShareVisibleOwner", + "offset": "0x0075b9b0", + "convention": "thiscall", + "status": "mapped", + "prototype": "void (ServerSystem* this, ServerPlayer* from, ServerPlayer* to) // the NVO analogue of ShareObservation 0x00754d90: passes the source player's record verbatim, so TShn is NOT refreshed. No corpus save has an alliance, so this path has never executed (rule 6)" + }, + { + "name": "ServerSystem_FindVisibleOwner", + "offset": "0x0074d2e0", + "convention": "thiscall", + "status": "verified", + "prototype": "OwnRec* (ServerSystem* this, ServerPlayer* p) // map find on NVO (0x0074d2f2 `lea ecx,[esi+0x270]`, 0x0074d300 `cmp edx,[esi+0x274]`); returns node+0x10 or NULL. Every caller only READS the record" + }, + { + "name": "ServerSystem_GetVisibleOwnerId", + "offset": "0x0074f710", + "convention": "thiscall", + "status": "mapped", + "prototype": "int (ServerSystem* this, ServerPlayer* p) // FindVisibleOwner then rec->OID" + }, + { + "name": "ServerSystem_IsKnownTo", + "offset": "0x00746390", + "convention": "thiscall", + "status": "verified", + "prototype": "bool (ServerSystem* this, ServerPlayer* p) // THE NVO GATE, and it is looser than IsVisibleTo 0x00743fb0. Whole body: `p && ( (this->AFlags(+0xd4) >> p->PlyrIdx(+0x28)) & 1 || ( p->PlyrIdx < 15 && ((this->SnLv(+0x24) >> 2*p->PlyrIdx) & 3) != 0 && TechTree::HasResearched(p->TechTree(+0xf4), 10000 /* CCC_AdvSens */) ) )`. Byte-decoded at 0x007463ae / 0x007463c0 / 0x007463cb / 0x007463da" + }, + { + "name": "ServerSystem_RefreshVisibleOwnerIfKnown", + "offset": "0x0075bd70", + "convention": "thiscall", + "status": "verified", + "prototype": "bool (ServerSystem* this, ServerPlayer* p) // ret 4. IsKnownTo inlined byte-for-byte (0x0075bd88 AFlags, 0x0075bd93 idx<15, 0x0075bd9e SnLv, 0x0075bdb2 HasResearched(0x2710)), then RecordVisibleOwner at 0x0075bdbe. Confirmed live: the trapped frame's [[ebp]+4] was 0x0075bdc3, the return of that call" + }, + { + "name": "SensorPhase_RefreshAllVisibleOwners", + "offset": "0x00813700", + "convention": "fastcall", + "status": "verified", + "prototype": "void (StrategyServer_S4* server) // for p in players(+0x50..+0x54) for s in systems(+0x40..+0x44): s->RefreshVisibleOwnerIfKnown(p) at 0x00813737. The player loop is UNFILTERED -- no NPC/species test anywhere on this path. Called from StrategyServer::UpdateSensors 0x0086a8d0 at 0x0086a91d" + }, + { + "name": "SensorPhase_ComputeContactLevel", + "offset": "0x00850cf0", + "convention": "thiscall", + "status": "mapped", + "prototype": "int (Sensors* this, ServerPlayer* p, Entity* e) // 3 if IsVisibleTo (AFlags), else 1 if inside p's sensor envelope, else 0 (0x00850d18 / 0x00850e1b). Its result is packed into ServerSystem::SnLv(+0x24) at 0x0086a789" + }, + { + "name": "ServerSystem_ChangeOwner", + "offset": "0x0075bde0", + "convention": "thiscall", + "status": "mapped", + "prototype": "void (ServerSystem* this, ServerPlayer* oldOwner, ServerPlayer* newOwner) // Game::ServerSystem vftable 0x00a2044c slot 6; records a visible-owner entry for both parties at 0x0075be77 / 0x0075bf2b" + }, + { + "name": "StrategyServer_OnCommand_SetResearchRate", + "offset": "0x00821a80", + "convention": "thiscall", + "status": "verified", + "prototype": "bool (StrategyServer_S4* this, int playerId, const float* rate) // ret 8. Bumps ModCount at 0x00821a84 then sets player->ResRate(+0xbc) = clamp(*rate, 0.0f, 1.0f), logging \"OnCommand: Research rate %f constrained to %f for %s.\" when it clamps. NAMES lane W2's `StrategySim_ModCountBump_unresolved_00821a84`, the handler that fires 4x per End Turn on ref-turn2 -- once per player that has a client" + }, + { + "name": "StrategyServer_OnCommand_SetSystemRates", + "offset": "0x0086c3e0", + "convention": "thiscall", + "status": "verified", + "prototype": "bool (StrategyServer_S4* this, int playerId, const SysRatesCmd* cmd) // ret 8. Bumps ModCount at 0x0086c3e6. Strings \"OnCommand: Unable to set system rates for %d(id). System not found.\" (0xa32448) and \"OnCommand: Player setting system rates for system he does not own.\" (0xa32400). NAMES lane W2's `..._unresolved_0086c3e6`" + }, + { + "name": "StrategyServer_OnCommand_TransferShips", + "offset": "0x0088bed0", + "convention": "thiscall", + "status": "verified", + "prototype": "int (StrategyServer_S4* this, int playerId, const TransferCmd* cmd, ...) // ret 0xc. Bumps ModCount at 0x0088befe. Strings \"OnCommand: Cannot transfer ships. Player %d(id) not found.\" (0xa33248) and \"TransferShips: Fleet flagged as empty, but still has ships.\". NAMES lane W2's `..._unresolved_0088befe`" + }, + { + "name": "StrategyServer_OnCommand_AddBuildOrder", + "offset": "0x00849460", + "convention": "thiscall", + "status": "mapped", + "prototype": "bool (StrategyServer_S4* this, int requesterId, const AddOrderCmd* cmd, int* outReason) // ret 0xc. Bumps ModCount at 0x0084946b. cmd = {int which, id target, id design, int arg}; picks target->+0xa4 (which==0) or +0x98 (which==1) as the BuildQueue, validates with 0x0083ce50, then BuildQueue::AddOrder 0x00845260 push_backs onto the list at queue+0x10. Reached from the Build Screen 0x00656540 via 0x00762fd0, and from the command replay at 0x0089009f. NAMES lane W2's `..._unresolved_0084946b`" + }, + { + "name": "StrategyServer_OnCommand_SetResearchProject", + "offset": "0x0088fe0a", + "convention": "offset", + "status": "verified", + "prototype": "site `inc DWORD PTR [ebx+0x4]` -- a SECOND ModCount bump INLINED into ApplyTurnCommandBatch 0x0088f9b0 (at +0x45a), distinct from the one lane W2 found at 0x0089046c. String \"OnCommand: Cannot set research project for player %d(id). Player not found.\" (0xa30ed0). Fires 3x on turn1-state and 0x on turn2-state -- once per AI player that picks a research target on turn 1; the human player set a rate but no project, which is why the reference game's map banner reads \"No Research Project Assigned\"" + }, + { + "name": "StrategyServer_OnCommand_CreateDesign", + "offset": "0x00882910", + "convention": "thiscall", + "status": "verified", + "prototype": "bool (StrategyServer_S4* this, int playerId, const DesignCmd* cmd) // ret 8. Bumps ModCount at 0x0088291a. Strings \"OnCommand: Cannot create design for player %d(id), player does not exist.\" (0xa32c40) and \"OnCommand: Unable to create design. ID not specified.\" (0xa32c04). Fires 1x on turn1-state, 0x on turn2-state. ANSWERS LANE AI4'S FALSIFIER: command list 1 emits a design COMMAND, not merely a design object that the build order names" + }, + { + "name": "BuildQueue_AddOrder", + "offset": "0x00845260", + "convention": "thiscall", + "status": "mapped", + "prototype": "void (BuildQueue* this, int arg, ShipDesign* design) // builds an order record with five fields pre-set to -1 and push_backs it onto the std::list at this+0x10/+0x14 (\"list too long\" guard at 0x008452e4)" + }, + { + "name": "StrategyServer_CanAddBuildOrder", + "offset": "0x0083ce50", + "convention": "cdecl", + "status": "mapped", + "prototype": "bool (ServerPlayer* who, BuildQueue* q, ShipDesign* d, int* outReason) // the 'can this be built' validator; writes a numbered reason (0,1,2,8,...) through arg 4 and calls BankruptcyLevel 0x0080db10" + }, + { + "name": "ServerPlayer_off_ReqCL", + "offset": "0xfd", + "convention": "offset", + "status": "verified", + "prototype": "bool ReqCL -- 'this player requires a client'. THE PREDICATE BEHIND Player.Status: a player with ReqCL == 0 never reaches SetupAIPlayers' AI list (gate at 0x007bc25c), so never gets a StrategyClient, so is never the subject of MarkPlayerTurnEnded and keeps the 0 that ResumePlaying wrote. The script binding that creates the four '_NPC' pseudo-players clears it explicitly at 0x00797bcb" + }, + { + "name": "StrategyNetworkServer_AllPlayersTurnEnded", + "offset": "0x007cfd00", + "convention": "thiscall", + "status": "verified", + "prototype": "bool (StrategyNetworkServer* this) // vftable slot 3. The game's OWN statement of which players must carry Status == 4: `for p in players: if (!p->ReqCL(+0xfd)) continue; if (p->Elim(+0xf8)) continue; if (p->Status(+0x164) != 4) return false;` -- loop 0x007cffb7..0x007d0002 with edi = 4 loaded at 0x007cffa3" + }, + { + "name": "StrategyNetworkServer_SetupAIPlayers", + "offset": "0x007bc1c0", + "convention": "thiscall", + "status": "verified", + "prototype": "void (StrategyNetworkServer* this) // the ReqCL gate at 0x007bc25c decides which players get an AI client at all; AddAIPlayer 0x007bc333 -> 0x007b8ab0" + }, + { + "name": "StrategyServer_OnPlayerEndTurn", + "offset": "0x007d9af0", + "convention": "thiscall", + "status": "verified", + "prototype": "void (StrategyServer* this, const SNMPlayerEndTurn* msg) // ONE call per network message, not a loop: handle = msg->+0x4 straight off the wire, MarkPlayerTurnEnded(this+4, handle) at 0x007d9b25 with NO gate before the store. Reached from StrategyNetworkClient::OnMessage 0x00784816. The field tests at 0x007d9b5d/0x007d9ba2/0x007d9bb0 all follow the store and only decide whether to raise a 'waiting on X' event" + }, + { + "name": "StrategyServer_ProcessTurn_StatusOneStore", + "offset": "0x007dcc8a", + "convention": "offset", + "status": "verified", + "prototype": "site `mov DWORD PTR [eax+0x164],1` -- the phase-31 Status write lane T2 located. Its gate, decoded at 0x007dcc78, is `if (p->IsAI(+0xf9) == 0 || p->+0xfa != 0)`, over the encounter records at S+0x1e8 (0x74-byte outer, 0x44-byte inner with the ServerPlayer* first). It reads NO species field" + }, + { + "name": "Game_SNMSetPlayerStatus_vftable", + "offset": "0x00a2d718", + "convention": "offset", + "status": "verified", + "prototype": "vftable for Game::SNMSetPlayerStatus (factory 0x0080f990, sizeof 0xc: vptr / +4 player INDEX / +8 status). Its handler is the OnMessage+0xa15 store lane W2 found: no gate, no field test -- the value comes verbatim off the wire. The sender is StrategyNetworkServer::Update 0x007cda40 at 0x007cdcf0, which computes `2*(flag!=0) + 2`, i.e. 4 when the player has nothing pending and 2 when it does" + } + ] +} diff --git a/verify/results/shim/watchpoints/w3-modcount-turn1.txt b/verify/results/shim/watchpoints/w3-modcount-turn1.txt new file mode 100644 index 0000000..89a29d0 --- /dev/null +++ b/verify/results/shim/watchpoints/w3-modcount-turn1.txt @@ -0,0 +1,16 @@ +watchhit seq=0 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012ec3e9 value=1(0x00000001) tid=948 ebpret=0x0130fce2 ebpret2=0x0120f6e6 scan=0x0130fce2,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=1 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=2(0x00000002) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=2 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130fe0d value=3(0x00000003) tid=948 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000 +watchhit seq=3 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=4(0x00000004) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=4 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130fe0d value=5(0x00000005) tid=948 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000 +watchhit seq=5 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=6(0x00000006) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=6 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130fe0d value=7(0x00000007) tid=948 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000 +watchhit seq=7 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=8(0x00000008) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=8 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130291d value=9(0x00000009) tid=948 ebpret=0x01310007 ebpret2=0x0120f6e6 scan=0x01310007,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=9 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012c946e value=10(0x0000000a) tid=948 ebpret=0x013100a4 ebpret2=0x0120f6e6 scan=0x013100a4,0x013dad98,0x0120f6e6,0x01204909 +watchhit seq=10 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0125c6f3 value=11(0x0000000b) tid=948 ebpret=0x723b7324 ebpret2=0x723b6ff4 scan=0x0141005c,0x012c946e,0x00000000,0x00000000 +watchhit seq=11 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125cc94 value=1(0x00000001) tid=948 ebpret=0x723b7324 ebpret2=0x723b6ff4 scan=0x0141005c,0x012c946e,0x00000000,0x00000000 +watchhit seq=12 mark=1 slot=2 dr6=0xffff0ff4 eip=0x01205055 value=4(0x00000004) tid=948 ebpret=0x0139dfa1 ebpret2=0x013a5651 scan=0x012317aa,0x01178f0f,0x0140c080,0x0124cac1 +watchhit seq=13 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012592cd value=12(0x0000000c) tid=948 ebpret=0x72389974 ebpret2=0x72389644 scan=0x0140fca0,0x01026c32,0x00e81000,0x01205055 +watchhit seq=14 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125dd49 value=0(0x00000000) tid=948 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x00eb4814,0x01214827 +watchhit seq=15 mark=1 slot=3 dr6=0xffff0ff8 eip=0x0125dd49 value=0(0x00000000) tid=948 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x00eb4814,0x01214827 diff --git a/verify/results/shim/watchpoints/w3-shim-log-excerpt-turn1.txt b/verify/results/shim/watchpoints/w3-shim-log-excerpt-turn1.txt new file mode 100644 index 0000000..0686762 --- /dev/null +++ b/verify/results/shim/watchpoints/w3-shim-log-excerpt-turn1.txt @@ -0,0 +1,29 @@ +19:16:30:24.311 [tid 948] config: watch=on +20:16:30:24.311 [tid 948] config: watch.players=2 +21:16:30:24.311 [tid 948] config: watch.mode=modcount +22:16:30:24.311 [tid 948] config: watch.out=C:\SOTS\shim.watch.txt +89:16:30:24.921 [tid 948] watch: VEH=01b37d58 text=[0x00e81000,0x0145ca00) out=C:\SOTS\shim.watch.txt +90:16:30:24.936 [tid 948] watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x0038f6a0 va=0120f6a0 create=MH_OK enable=MH_OK +108:16:33:53.478 [tid 948] watch: players vector @0e997424 begin=0e9b2940 end=0e9b2960 count=8 +109:16:33:53.478 [tid 948] watch: SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS +110:16:33:53.478 [tid 948] watch: slot 0 -> S+0x8 (A2:ModCount / T:PhaseCounter) = 0x0e9973d8 +111:16:33:53.478 [tid 948] watch: slot 1 -> S+0xc (A2:Frame / addresses.json:ModCount) = 0x0e9973dc +112:16:33:53.478 [tid 948] watch: slot 2 -> player[0]+0x164 Status = 0x0e9175f4 +113:16:33:53.478 [tid 948] watch: slot 3 -> player[1]+0x164 Status = 0x0e916a3c +114:16:33:53.478 [tid 948] watch: ARMED on tid 948 dr7=0xdddd0055, S=0e9973d0 (ApplyAllTurnCommands this) +115:16:33:54.368 [tid 9192] watchhit seq=0 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012ec3e9 value=1(0x00000001) tid=948 ebpret=0x0130fce2 ebpret2=0x0120f6e6 scan=0x0130fce2,0x013dad98,0x0120f6e6,0x01204909 +116:16:33:54.368 [tid 9192] watchhit seq=1 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=2(0x00000002) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +117:16:33:54.368 [tid 9192] watchhit seq=2 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130fe0d value=3(0x00000003) tid=948 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000 +118:16:33:54.368 [tid 9192] watchhit seq=3 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=4(0x00000004) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +119:16:33:54.368 [tid 9192] watchhit seq=4 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130fe0d value=5(0x00000005) tid=948 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000 +120:16:33:54.368 [tid 9192] watchhit seq=5 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=6(0x00000006) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +121:16:33:54.368 [tid 9192] watchhit seq=6 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130fe0d value=7(0x00000007) tid=948 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000 +122:16:33:54.368 [tid 9192] watchhit seq=7 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=8(0x00000008) tid=948 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909 +123:16:33:54.368 [tid 9192] watchhit seq=8 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130291d value=9(0x00000009) tid=948 ebpret=0x01310007 ebpret2=0x0120f6e6 scan=0x01310007,0x013dad98,0x0120f6e6,0x01204909 +124:16:33:54.368 [tid 9192] watchhit seq=9 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012c946e value=10(0x0000000a) tid=948 ebpret=0x013100a4 ebpret2=0x0120f6e6 scan=0x013100a4,0x013dad98,0x0120f6e6,0x01204909 +125:16:33:54.368 [tid 9192] watchhit seq=10 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0125c6f3 value=11(0x0000000b) tid=948 ebpret=0x723b7324 ebpret2=0x723b6ff4 scan=0x0141005c,0x012c946e,0x00000000,0x00000000 +126:16:33:54.368 [tid 9192] watchhit seq=11 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125cc94 value=1(0x00000001) tid=948 ebpret=0x723b7324 ebpret2=0x723b6ff4 scan=0x0141005c,0x012c946e,0x00000000,0x00000000 +127:16:33:54.368 [tid 9192] watchhit seq=12 mark=1 slot=2 dr6=0xffff0ff4 eip=0x01205055 value=4(0x00000004) tid=948 ebpret=0x0139dfa1 ebpret2=0x013a5651 scan=0x012317aa,0x01178f0f,0x0140c080,0x0124cac1 +128:16:33:54.368 [tid 9192] watchhit seq=13 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012592cd value=12(0x0000000c) tid=948 ebpret=0x72389974 ebpret2=0x72389644 scan=0x0140fca0,0x01026c32,0x00e81000,0x01205055 +129:16:33:54.368 [tid 9192] watchhit seq=14 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125dd49 value=0(0x00000000) tid=948 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x00eb4814,0x01214827 +130:16:33:54.368 [tid 9192] watchhit seq=15 mark=1 slot=3 dr6=0xffff0ff8 eip=0x0125dd49 value=0(0x00000000) tid=948 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x00eb4814,0x01214827 diff --git a/verify/results/shim/watchpoints/w3-shim-log-excerpt.txt b/verify/results/shim/watchpoints/w3-shim-log-excerpt.txt new file mode 100644 index 0000000..47f4397 --- /dev/null +++ b/verify/results/shim/watchpoints/w3-shim-log-excerpt.txt @@ -0,0 +1,48 @@ +19:16:11:26.261 [tid 2420] config: watch=on +20:16:11:26.261 [tid 2420] config: watch.players=2 +21:16:11:26.261 [tid 2420] config: watch.mode=tshn +22:16:11:26.261 [tid 2420] config: watch.out=C:\SOTS\shim.watch.txt +89:16:11:26.886 [tid 2420] watch: VEH=00b79158 text=[0x00e81000,0x0145ca00) out=C:\SOTS\shim.watch.txt +90:16:11:26.902 [tid 2420] watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x0038f6a0 va=0120f6a0 create=MH_OK enable=MH_OK +108:16:15:22.976 [tid 2420] watch: systems vector @0x0e4f7c9c first=0x1ee82490 last=0x1ee82500 end=0x1ee82500 count=28 (expect 28 on the reference game) +109:16:15:22.976 [tid 2420] watch: NVO header probe -- ServerSystem+0x274 validated on 9 systems, +0x26c on 0; using +0x274 (pointer delta 0) +110:16:15:22.976 [tid 2420] watch: sys[0] @0x0e4d2008 'Caladan' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +111:16:15:22.976 [tid 2420] watch: sys[1] @0x0e4d22e8 'Hyperion' AFlags=0x10 NVO=1 NVE=1 root=0x0e4e34f8 key=4 ok=1 +112:16:15:22.976 [tid 2420] watch: sys[2] @0x0e4d25c8 'Tully' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +113:16:15:22.976 [tid 2420] watch: sys[3] @0x0e4d28a8 'Foo' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +114:16:15:22.976 [tid 2420] watch: sys[4] @0x0e4d2b88 'Gamma Cephei' AFlags=0x1 NVO=1 NVE=1 root=0x1eea9490 key=0 ok=1 +115:16:15:22.976 [tid 2420] watch: sys[5] @0x0e4d2e68 'Biter' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +116:16:15:22.976 [tid 2420] watch: sys[6] @0x0e4d3148 'Flinx' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +117:16:15:22.976 [tid 2420] watch: sys[7] @0x0e4d3428 'Procyon' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +118:16:15:22.976 [tid 2420] watch: sys[8] @0x0e4d3708 'Kor?Voth' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +119:16:15:22.976 [tid 2420] watch: sys[9] @0x0e4e4be8 'Ke'Rassak' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +120:16:15:22.976 [tid 2420] watch: sys[10] @0x0e4e4ec8 'Koa?Tuun' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +121:16:15:22.976 [tid 2420] watch: sys[11] @0x0e4e51a8 'Spica' AFlags=0x0 NVO=1 NVE=0 root=0x1eea8c40 key=4 ok=1 +122:16:15:22.976 [tid 2420] watch: sys[12] @0x0e4e5488 'Junction' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +123:16:15:22.976 [tid 2420] watch: sys[13] @0x0e4e5768 'Nemesis' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +124:16:15:22.976 [tid 2420] watch: sys[14] @0x0e4e5a48 'Keu?Loko' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +125:16:15:22.976 [tid 2420] watch: sys[15] @0x0e4e5d28 'Ke'Dolarra' AFlags=0x2 NVO=1 NVE=1 root=0x1eea9360 key=1 ok=1 +126:16:15:22.976 [tid 2420] watch: sys[16] @0x0e4e6008 'Koa?Vo' AFlags=0x80 NVO=1 NVE=1 root=0x1eea9100 key=7 ok=1 +127:16:15:22.976 [tid 2420] watch: sys[17] @0x0e52f838 'Delphi' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +128:16:15:22.976 [tid 2420] watch: sys[18] @0x0e52ef80 'Kaa?Vaalu' AFlags=0x10 NVO=1 NVE=1 root=0x1eea9230 key=4 ok=1 +129:16:15:22.976 [tid 2420] watch: sys[19] @0x0e5300f0 'Mu Bootis' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +130:16:15:22.976 [tid 2420] watch: sys[20] @0x0e52f550 'Kor?Hakaan' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +131:16:15:22.976 [tid 2420] watch: sys[21] @0x0e5306c0 'Serpens' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +132:16:15:22.976 [tid 2420] watch: sys[22] @0x0e52ec98 'Markab' AFlags=0x10 NVO=1 NVE=1 root=0x1eea96f0 key=4 ok=1 +133:16:15:22.976 [tid 2420] watch: sys[23] @0x0e52f268 'Pascal' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +134:16:15:22.976 [tid 2420] watch: sys[24] @0x0e52fb20 'Kao?Lolto' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +135:16:15:22.976 [tid 2420] watch: sys[25] @0x0e52fe08 'Kea?Pono' AFlags=0x10 NVO=2 NVE=1 root=0x1eea8780 key=4 ok=1 +136:16:15:22.976 [tid 2420] watch: sys[26] @0x0e5303d8 'Gienah' AFlags=0x0 NVO=0 NVE=0 root=0x00000000 key=0 ok=0 +137:16:15:22.976 [tid 2420] watch: sys[27] @0x0e534ca0 'Ko'Rorkor' AFlags=0x10 NVO=1 NVE=1 root=0x1eea8f38 key=4 ok=1 +138:16:15:22.976 [tid 2420] watch: trade routes -- manager=0x0e4cdd38 (S+4+0x154) vector@mgr+0x3c first=0x00000000 last=0x00000000 end=0x00000000 count=0 +139:16:15:22.976 [tid 2420] watch: spy programs -- manager=0x0e512f68 (S+4+0x158) vector@mgr+0x10 first=0x00000000 last=0x00000000 end=0x00000000 count=0 +140:16:15:22.976 [tid 2420] watch: SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS +141:16:15:22.976 [tid 2420] watch: slot 0 -> 'Spica' NVO root+0x10 {touched:i16,TShn:i16} key=player 4 = 0x1eea8c50 +142:16:15:22.976 [tid 2420] watch: slot 1 -> 'Spica' NVO._Mysize (=1 at arm) = 0x0e4e5420 +143:16:15:22.976 [tid 2420] watch: slot 2 -> trade routes vector _Mylast (mgr+0x40, count 0 at arm) = 0x0e4cdd78 +144:16:15:22.976 [tid 2420] watch: slot 3 -> spy programs vector _Mylast (mgr+0x14, count 0 at arm) = 0x0e512f7c +145:16:15:22.976 [tid 2420] watch: ARMED on tid 2420 dr7=0xdddd0055, S=0e4f7c58 (ApplyAllTurnCommands this) +146:16:15:24.585 [tid 1892] watchhit seq=0 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db95a value=131075(0x00020003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 +147:16:15:24.585 [tid 1892] watchhit seq=1 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db965 value=196611(0x00030003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 +148:16:15:24.585 [tid 1892] watchhit seq=2 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db95a value=196611(0x00030003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 +149:16:15:24.585 [tid 1892] watchhit seq=3 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db965 value=196611(0x00030003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 diff --git a/verify/results/shim/watchpoints/w3-tshn-containers.txt b/verify/results/shim/watchpoints/w3-tshn-containers.txt new file mode 100644 index 0000000..53173b3 --- /dev/null +++ b/verify/results/shim/watchpoints/w3-tshn-containers.txt @@ -0,0 +1,4 @@ +watchhit seq=0 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db95a value=131075(0x00020003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 +watchhit seq=1 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db965 value=196611(0x00030003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 +watchhit seq=2 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db95a value=196611(0x00030003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806 +watchhit seq=3 mark=1 slot=0 dr6=0xffff0ff1 eip=0x011db965 value=196611(0x00030003) tid=2420 ebpret=0x011dbd4a ebpret2=0x011dbdc3 scan=0x011d29f6,0x014056db,0x011dbd4a,0x012ea806