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

14 KiB
Raw Blame History

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:
    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 fcomps 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.