TShn is refreshed to Frame for every (system, player) that satisfies ServerSystem::IsKnownTo 0x00746390 -- IsVisibleTo OR (2-bit sensor contact at ServerSystem+0x24 AND CCC_AdvSens). Not AFlags alone, which is why lane E3 could not fit it. The writer chain was confirmed live, frame by frame, from a hardware watchpoint on Spica's NVO node; the refresh runs twice per End Turn (driver phase 24 and combat-done phase 25). Also: the trade and spy containers read out of a live game for the first time (both empty, zero traps -- the workload confirmation two lanes lacked); Player.Status's predicate named as ReqCL != 0 && Elim == 0, with Species != 4 shown to be a corpus coincidence; and all ten command ModCount handlers named, two of them only reachable on a turn-1 workload. Corrects objects/layouts.md's ServerSystem PID@0x274 row and closes system-visibility-record.md 7. Runs: build w3tshn-bb81d3d-20260908T2007Z, configs shim.cfg.w3tshn / w3mod / w3control. Rule 19 control passed -- the armed run reproduced the determinism oracle byte for byte.
197 lines
14 KiB
Markdown
197 lines
14 KiB
Markdown
# 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.
|