sots-re/findings/control-flow/w3-containers-status-handlers.md
alex 471d6cb973 W3: NVO.TShn's writer trapped live, its gate named, and 158/158 on the corpus
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.
2026-09-08 16:41:09 -04:00

197 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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