sots-re/findings/subsystems/turn-command-replay.md
alex a4a1d373f1 RB: replay a recorded turn's commands -- ModCount is reachable, and the rates frame's memory order is not its wire order
sots_turn --turn-commands puts /Sim/ModCount on the original's 24 with zero residual, closing
the one leaf that has been unreachable from a save all campaign. Canonical pair 108 -> 62,
closed 46, regressed 0, fresh build directory.

The .tcb capture format (line-oriented, parser-free, '?' for a field the instrument could not
read, per-client AI seeds), a converter from lane L4's shim dump, and an adapter from lane CB's
JSON capture -- CB's stays the capture of record, .tcb stays the engine's input, and the two
paths produce byte-identical replays.

A falsified prediction paid for itself: the first run regressed two leaves because list 5's
element is decoded in MEMORY order, and the memory order of the rates frame is NOT its wire
order. Memory member 1 is wire member SRsc; six members unread. Lane CB's decoder has the same
defect and should drop its list-5 record.

Two new addresses (the second and third gate-loop heads) in ghidra/addresses.d/lane-rb.json.
2026-09-08 19:01:35 -04:00

280 lines
20 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.

# Replaying a recorded turn: what a command stream reproduces, and what it does not
Lane RB, 2026-09-08. Host work, no VM. Engine worktree `wip/rb` off `main` `4f25f1e`; predictions
committed as `sots-engine` `docs/RB-predictions.md` at `1e6474b`, **before** the module existed
(rule 2). Every number below was taken on a **fresh `build-host`** created with `rm -rf` (rule 24).
Consumes: `ai-order-emission.md` (AI4, the apply order and the cost table), `ai-order-capture.md`
(L4, the live dumps), `turncommands-block.md` (Q, the wire shape),
`turn1-to-turn2-nondeterminism.md` (L5). Produces the first end-to-end **turn record** the
campaign holds.
---
## 0. Lead: `ModCount` is reachable, and it is the only leaf a stream closes on the reference turn
`/Sim/ModCount` has been unreachable from a save for the whole campaign, because it counts a
thing a save does not contain. Our standalone wrote **14** where the original writes **24**: the
two driver bumps and nothing else. Replaying the turn's recorded command blocks puts it on **24**,
exactly, with no fitting and no fudge term.
Canonical pair `turn2-state.sav -> turn3-state.sav`, fresh build:
| | leaves diverging | closed | regressed |
|---|---:|---:|---:|
| do nothing | 108 | — | — |
| standalone, no stream | 63 | 45 | 0 |
| **standalone + recorded stream** | **62** | **46** | **0** |
The one leaf the stream closes is `/Sim/ModCount`, and its twenty-four decompose with **zero
residual**:
```
2 turn drivers (S00, T00) -- ours already
4 research-rate gates, one per submitting block (16, 32, 496, 512)
1 list 5 system rates
1 list 3 build order
1 list 10 (unnamed)
2 list 14 fleet task, modes 0 and 1 against ONE fleet
1 list 8 fleet move
0 list 23 population -- the 17..27 half is free
--
12 delta, on a save that carried 12
```
**Nothing else closes, and that is the honest result rather than a disappointment.** Of the ten
commands, three have a handler this engine holds and all three are no-ops on this workload — the
AI re-issues the research rate and the planetary-budget sliders the save already carries. The
other seven need subsystems we do not have (§4).
The secondary pair is where the stream does work, and it produces the campaign's first **matched
triple** — an input save, the stream captured from the run that consumed it, and *that run's own
autosave*:
| oracle | closed by the stream | still diverging |
|---|---:|---|
| the recording's own autosave (`l4-turn1to2-instrumented-autosave.sav`) | **7** | — |
| the historical `turn2-state.sav`, from a different process | **6** | player 512's `ResTNm` |
Both at **regressed 0**. The seven are `ModCount` (0 → 12) plus `ResRate` and `ResTNm` on players
32, 496 and 512. Against the historical oracle only six close, and the one that does not is
**exactly** the leaf lanes L4 and L5 showed is decided per-process: the recording picked
`XNC_TrnsMorr2`, `turn2-state.sav` holds `BIO_GnMod`. That was written down as a prediction before
the run and it is the strongest thing in this lane — a replay that had closed it would have meant
something was copying the oracle instead of the capture.
---
## 1. The on-disk form: `.tcb`
Line-oriented, whitespace-separated, `#` comments, magic first. A shim can emit it with `fprintf`
and a lane can read it without a tool.
```
tcb 1
meta source l4-turn2to3-aiorders.txt
seed <netId> <32-bit word> -- one per AI client; absent means "not recorded"
block <idx> <playerId> -- the batch SLOT, ascending; playerId 0 = a slot no client wrote
gate <idx> rate|target|boost|group4|group5|civilian <payload...>
list <idx> <listNo> <count> -- required for every non-empty list
elem <idx> <listNo> <elemIdx> <field>... -- fields in WIRE order
```
A field is one token: `iN` int, `fN` float, `b0`/`b1` bool, `s:TEXT` string, `vN` a counted vector
whose length is known and whose values are not, `vN:a,b,c` one that is fully read, and `?` a scalar
the instrument could not reach.
Three parts of that are not decoration.
**`?` and bare `vN` are the point.** A dump reads a fixed window of each element and cannot follow
a pointer, so a route arrives as "one element, contents unknown". That is a different fact from
"no element" and from "an element of zeros": the command still costs its bump and its effect still
cannot be applied. Recording the ignorance is what lets the counter be right while the state is
honestly left alone. The reference turn has **four** such elements out of ten.
**A declared count that disagrees with the elements present is an ERROR, not a warning.** A capture
that lost an element would otherwise produce a counter quietly one short, and nothing downstream
could tell that from a turn that really issued one fewer command. Thirteen malformed captures are
in the engine's test as rejection cases.
**`seed` is carried even though nothing consumes it.** Lane L1 established that each AI client
seeds one MT19937 with a single word at construction, so a decision is a function of *(board,
seed)* and a capture that records the seeds can be **re-derived** rather than replayed. The field
exists now so a capture taken today is still the right file when `game/ai` lands. A reference save
whose seeds were never logged — `turn2-state.sav` — is not reproducible by any process, the
original included, and its commands can only ever be replayed.
### 1.1 Two capture tools, one reader — and a defect one of them shares with my first attempt
Lane CB was building `tools/turncommands_capture.py` in parallel, emitting **JSON**. That is the
better *capture of record* and it should stay: raw element words as ground truth, heap vectors and
strings the deep dump followed, the AI seeds, the container self-check, and — the part neither of
my files had — the input save's hash **bound to the output autosaves' hashes**, so a capture cannot
be silently used against the wrong save.
Rather than a second format in the engine, `tools/tcb_from_json.py` joins them: CB's JSON stays the
capture of record, `.tcb` stays the engine's parser-free input, and one narrow script knows both.
Fed CB's tool's own output over the L4 log, the adapter produces a replay **byte-identical** to the
one from `aiorders_to_tcb.py`. Honest bound on that agreement: both decoders read the *same log*,
so it checks the two decoders against each other and not the log.
**And it caught a defect in CB's decoder, which is the same one I shipped and measured (§3):** its
list-5 record maps the element's memory words straight onto the frame's wire order
(`{systemId, ship, terraform, sciences, …}`). That is wrong by at least one position. The adapter
overrides it to `?` and says why in its own docstring; **CB should drop the list-5 record from
`RECORDS` rather than rely on the adapter to mask it**, because the JSON is the capture of record
and a wrong typing in it will outlive this note. CB's `raw_words` are unaffected and remain right.
Two things CB's dump has that L4's does not, and that the `.tcb` format already has fields for:
`aiseed` (the per-client construction seeds) and `aivec` (the heap vectors — which turn list 8's
route from `v1` into `v1:<hop>` and remove one whole row from the gap list in §4).
**The converter.** `tools/aiorders_to_tcb.py` turns lane L4's shim dump into a `.tcb` mechanically:
it applies the per-list field mapping, undoes the one list whose writer runs backwards, reinterprets
the words the record says are floats, turns a vector's begin/end pair into a length, and writes `?`
where the window could not reach. Both existing captures are converted and checked in at
`verify/results/turncommands/l4-turn{1to2,2to3}.tcb`. **Lane CB does not need to write an emitter:**
the existing `aiorders=on` hook plus this converter already produces the file. If CB does emit
`.tcb` directly, the seed and `name` records are the two things the current dump has no field for.
---
## 2. Apply order, and how it was verified
The batch is a flat run of thirty steps: twenty-seven per-**list** steps, each looping over *every*
player's block before the next step begins, with three per-**player** gate loops spliced in.
```
lists 6 11 20 19 17 18 5 23 24
gate loop A { group5 (free), research target (bump), research rate (bump) } 0x0088fdb0
lists 1 4 3 21 2 22 9 10 12 13 14 15 16 7 8 25 27 26
gate loop B { research boost (bump) } 0x008907b1
gate loop C { group 4 (bump) } 0x0089080a
```
So **one player's list-6 commands are applied before another player's list-11 commands**, the list
sequence starts at 6 and is not 1..27, and the member offsets it walks are not ascending either.
**How it was verified, and what the verification cannot show.**
1. **Address monotonicity, nine of thirty positions.** Six appliers are inlined into the batch and
each writes `ModCount` in place, so a watchpoint run recovered their addresses; the three gate
loop heads are known too. In schedule order those nine are `0x0088fdb0`, `0x0088fe0a`,
`0x008902fe`, `0x008903b9`, `0x0089046c`, `0x008905c8`, `0x008907b1`, `0x008907bc`,
`0x0089080a` — **strictly increasing**, at steps 9, 9, 18, 19, 20, 23, 28, 28, 29. That chain is
an independent re-derivation. The other twenty-one lists' handlers are out of line and this lane
has no record of their call-site addresses inside the batch, so their relative order is
**inherited** from AI4's read of the `add edi, imm` chain, not re-derived. The test says nine.
2. **Bijection and non-sortedness**, asserted: every list exactly once, every located gate exactly
once, and neither the list sequence nor the offset sequence ascending. That is what rules out the
two obvious wrong implementations — `for (list = 1..27)` and walking the block in memory order.
3. **The civilian-ratios gate is absent from the schedule on purpose.** It has no located applier
anywhere in the routine, so including it would be claiming a cost of zero for something whose
cost is unknown. It is caught separately and makes the whole count report itself as a lower
bound.
**Stated plainly: apply order is unfalsifiable on every workload the campaign holds.** Both captures
put every non-empty list on one player and every command on one system, so any permutation produces
the same save and the same count. The order is implemented for the workload that will need it, and
tested against the instruction stream rather than against an outcome. The workload that would make
it falsifiable is **two players commanding the same object in one turn** — the cheapest is a
two-human `/concurrent` game (lane G2's Tier 0), where both clients order fleets at one system.
---
## 3. A falsified prediction, and the finding it paid for
**RB-P3 predicted `regressed 0`. The first run regressed two leaves**, and the cause is a real fact
about the original.
The converter mapped list 5's dumped words straight onto the wire order of the rates frame
(`SRs, SRt, SRsc, SRtf, SRi, SRoh, SRnr`). The replay then wrote the AI's single non-zero slider
into `SRt`, and `Sys[288 "Ke'Dolarra"]` came out with `SRt 1.0 / SRsc 0.0` against an oracle
holding `SRt 0.0 / SRsc 1.0`.
**The memory field order of `Game::StarSystem::OutputRates` is not its wire order.** What is known
precisely:
* the only non-zero word in every dumped element of list 5, on both turns, is at **memory index 2**
of the element (index 0 is the system id, so it is the frame's **second** member);
* the same command **on the wire**, in a save that carries issued orders, puts its only non-zero in
**`SRsc`**, the frame's **third** member (lane Q, `SAVE_FORMAT.md` §11, cross-checked to the
Planetary Budget slider pushed fully to Construction);
* `turn2-state.sav` and `turn3-state.sav` both hold `Sys[288] Rts = {SRs 0, SRt 0, SRsc 1.0, SRtf 0,
SRi 0, SRoh 0, SRnr 0}` — so the AI's command re-issues the state the save already holds, which is
why a *correct* applier is invisible and an incorrect one is immediately visible.
So **memory member 1 is wire member `SRsc`**: one correspondence pinned, six unread. One non-zero
slider cannot determine a permutation of seven, and the converter no longer pretends otherwise — it
emits the system id and seven `?`, and the replayer counts the command and declines it.
Two experiments settle it, both cheap:
* **one UI run** — push two *different* sliders to two *different* values on one system, End Turn
with `aiorders=on`, and read the permutation straight off the element;
* **cheaper, and no VM at all** — a save taken after issuing rates carries the same command on the
**wire**, where every field is NAMED. `zuul-turn17-orders2.sav` has one. A `.tcb` converted from a
save's own `TurnCommands_v5` block needs no memory mapping, and would also give the `.tcb` format
a second, independent producer. **This is the highest-value next step on the capture side** and it
is pure host work.
Two notes on how this was caught, because they generalise. The plain closed/regressed measurement
found it, before the control did — but the control (RB-P4, `--replay-count-only` vs a full replay
must be byte-identical when every modelled handler is a no-op) would have found it too, and it is
what now stands guard: on the canonical pair those two runs are byte-identical, which is the
evidence that the handlers we *do* run write where they claim to rather than agreeing with the
oracle by luck. And the reason a wrong write was visible at all is that the command re-issues
existing state: **a command that re-states the board is the best possible test of an applier**, and
the corpus is full of them.
---
## 4. What a *complete* replay needs that this lane does not have
This is the real Rung B backlog. Each row is the reason one command in the reference turn is counted
but not applied.
| # | needed for | what is missing | shape |
|---|---|---|---|
| 1 | **list 3 build, list 1 design** | **ship construction.** No phase in this engine builds a ship. The command carries a queue ordinal, a design id and a system; the effect is `srb`/`sri`, `Maint`, the savings debit, a hull id and `ShipIDs`. Nine leaves on the canonical pair. | engine work; lane B6/E2 have the map |
| 2 | **list 1, list 3, list 8, list 10, list 14** | **the client's id allocator.** The build order names design **18** and the fleet order names fleet **34** — objects that do not exist in the input save, allocated *client-side before submission*, while the server's own master counter (`NMnx`) issued 1712 and 1776 the same turn. **Two id spaces**, and the small one is part of the wire protocol. A reimplementation that allocates on apply produces a structurally correct save with every AI-created id wrong. | **a watchpoint**, not a week of reading: break on the write that produces 18 and 34 |
| 3 | **list 8 fleet move** | **the route's hops.** The capture records the route's length and not its contents, because the dump does not follow the vector. One more indirection in the dumper. Also needs (2): the route belongs to a fleet the save does not contain. | one dumper change |
| 4 | **list 5 system rates** | **the memory field order of the rates frame** (§3). One correspondence pinned, six unread. | one UI run, or one save-sourced capture |
| 5 | **list 10** | **a name and a meaning.** `{systemId, fleetId, counted vector}`; "assign these ships to this fleet at this system" fits and has never been tested. The vector's contents are unread. | a hook on `0x0088bed0` |
| 6 | **list 14 fleet task** | **what the two modes do.** Two elements per AI fleet order, modes 0 then 1; the interface deposits one, mode 0 only. The *cost* is settled; the *effect* is not read at all. | a hook on the inlined applier |
| 7 | **list 23 population** | **the `Population` body**, 24 bytes behind a vftable that the capture window does not follow. Free in `ModCount`, so it has never been forced. | one dumper change |
| 8 | **the research-target gate** | **the techId → tech-name map.** The wire carries an integer (144, 90, 288); the save carries a name. The client resolves it *off the command* and passes a `char*`. The ids are not `index * 16` and are not indices into anything we hold. Today the capture can carry the observed name and the replay TRANSCRIBES it — reported in its own column, because that is not a reimplementation. **Three data points now exist**: 144 → `IND_Waldo`, 90 → `DRV_PlsFiss`, 288 → `XNC_TrnsMorr2`. | a watchpoint at the gate payload, or a read of `0x006c8580` |
| 9 | **the research-boost gate** | savings spent to advance research; both halves unmodelled. Never observed set on an AI turn. | engine work |
| 10 | **the group-4 and group-5 gates** | no read semantics; group 5 is Hiver-only and no save carries it. | rule 6 — a manufactured Hiver workload |
| 11 | **the civilian-ratios gate** | **no applier located anywhere in the batch.** Its `ModCount` cost is *unknown*, not zero, and any turn that sets it reports a lower bound. | an image-wide search for the consumer |
| 12 | **the load-time batch** | the process applies a batch at LOAD as well as at End Turn (`seq=1`, `n=1`, the local client's block alone, with the rate gate SET). Whether it charges `ModCount` is **untested**, and this lane excludes it by construction. If it does charge, a save loaded and immediately re-saved reads one higher. | one run: load, save, compare |
| 13 | **the whole thing, on any interesting board** | **two turns, one AI empire, 28 stars, no contact.** Lists 2, 4, 6, 9, 11–13, 15–22 and 24–27 have never been non-empty. Apply order is unfalsifiable (§2). Nothing here generalises past a very quiet game. | manufacture the workload |
Two things NOT on that list, deliberately. `Summary.Checksum` moves whenever anything else does and
its inputs are unread — no command replay will touch it until the rest is right. And nothing in
either capture draws a generator word, so the RNG frame is untouched by the stream; the coordinator's
note that the replay interval begins at `BeginProcessTurn` rather than `ProcessTurn` is already
satisfied here — the batch is drained *before* the first phase runs, which is where the End-Turn
dispatcher calls it (`ApplyAllTurnCommands` at `+0x00784904`, `ProcessTurn` at `+0x0078491c`,
`OnAllCombatDone_Tail` at `+0x00784d07`) — but the **hive-registration draws lane L1 found inside
`BeginProcessTurn` are a separate interval and this lane does not model them.**
---
## 5. What this lane did not do
1. **No VM time and no new instrument.** Every live number here is lane L4's, re-read through a
converter. Nothing new ran under a hook.
2. **The CT111 shim cross-build was not run** — CT111 refused the key from this host. `src/game/ai`
is in the cross build, so per rule 13 **the integrator must run it before pushing**. The two
32-bit-specific hazards were audited by hand and one was real and is fixed: `strtoul` plus a
`> 0xffffffffUL` test is tautological where `unsigned long` is 32 bits, which would both accept
out-of-range input silently and trip `-Wextra`/`-Werror`; both sites now use `strtoull`. `%zu`
appears in the new code and also in `src/app/main.cpp` today, so it is not a new exposure.
3. **The apply order was not tested against an outcome** and cannot be on this corpus (§2).
4. **`ToTurnCommandBlock` reuses the existing, already-verified cost model** rather than counting a
second time — deliberately, so there is one implementation of the arithmetic and not two that
can agree with each other while both being wrong (rule 8).
5. **The `.tcb` format has exactly one producer.** Until the save-sourced converter of §3 exists,
every capture comes through one script and a bug in it is invisible.
6. **Seeds are carried and never used.** No capture in the corpus has any.