Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
5.7 KiB
CB — the capture instrument, and what it measured
Lane CB, 2026-09-08. Guest VM146, build cb2-20260908T2251Z. Predictions and their outcomes are in
docs/CB-predictions.md; the RE-side write-up, the captures and the raw logs are in sots-re
(findings/subsystems/turncommands-capture.md, verify/results/turncommands/).
This document is the engine-side record: what changed in the shim, and the two facts the change bought that a replayer needs.
1. What changed
Three additions to lane L4's src/shim/hooks/ai_orders.cpp, and nothing else. L4's design point is
kept unchanged: the instrument reads element bytes, not element types, and the typing happens
offline in sots-re/tools/turncommands_capture.py, so a corrected record costs a re-parse instead
of a rebuild, a redeploy and a turn. That paid for itself twice in one session — see §4.
| key | default | what |
|---|---|---|
aiorders.words=<N> |
12 | the element window, ≤ 64. List 1's ShipDesignDef puts its name past 48 bytes. |
aiorders.deep=on|off |
off | follow the heap: {_Myfirst,_Mylast,_Myend} and MSVC std::string shapes inside the window. |
aiseed=off|log|pin + aiseed.values=<netid>=<hex>,… |
off | read — or replace — the fourth stack argument of Game::StrategyApp::RunAI. |
aiorders=on aiprobes=off is still one detour; aiseed= adds a second. Configs:
shim.cfg.cboff (the control, installs nothing), shim.cfg.cbcapture, shim.cfg.cbtrap,
shim.cfg.cbpin.
Three payloads in the canonical block are behind pointers — list 8's route, list 10's counted
vector, list 23's Population body. Without deep they are absent, not empty, and a fleet
move with no route reads exactly like a fleet move that goes nowhere. That is why aicfg words= deep=
is printed into every capture: a reader must never have to guess which of the two it is looking at.
2. aiseed=pin is an intervention, not an instrument, and is logged as one
Every call logs observed, used and pinned, so a pinned run says so in its own capture and can
never be read as a natural one.
It exists because of a control problem, not a curiosity. turn1-state → turn2 has an outcome set
of size k > 1: three lanes ran it with hooks=off and got three different files. On such a workload
no single un-instrumented run is a control, and an instrumented run agreeing with one of them is
a 1/k coincidence — at lane L4's k = 6 that is a 17 % chance of a clean bill of health that means
nothing. Pinning removes the only per-process input and makes a control possible.
It worked. A process whose natural seeds were 0x227235b9 / 0x762d125e / 0x9d77d4e6, pinned to an
earlier run's 0xe70a4703 / 0x0c63ca36 / 0x372be4df, reproduced that run's entire command block
— including the research pick that varies — and its autosave byte for byte.
3. What a replayer needs from this, in one place
- The seeds are part of the stream.
game/aiis a deterministic function of (save, per-client seed). A capture without them records the AI's answer with no record of its input. - Element sizes, measured (not read): list 3 = 4 words; list 5 = 8; list 8 = 1 + vector; list 10 = 2 + vector; list 14 = 2 words + one byte; list 23 = 1 + vptr + vector. Established by comparing two runs that wrote byte-identical autosaves — a word that differs between them is not a word the applier reads — and agreeing with the writer's own instruction stream on all six.
- List 14's bool is one byte. The other three bytes of its word are heap garbage that differs run to run. Compare the byte, not the word.
- List 3's element is stored backwards relative to its wire order. Per-list, not a rule.
4. Two records corrected, one of them mine
Lane L4's list-23 reading, twice. Population{vptr, vector(24 B), −1} has no trailing int —
the word after the vector differs between two runs whose autosaves are byte-identical, so it is an
allocator cookie. And the body is not turn-dependent: five of its six words are the same on
turn 1 and turn 2, and the sixth is a per-process pointer.
My own list-5 record, found by lane RB while consuming the capture. The element is dumped in
memory order and its memory field order is not its wire order; memory member 1 is wire member
SRsc, six members unread. RB's first replay predicted zero regressions and got two. The record
is now dropped rather than relabelled: the values ship as rates_memory_order_{u32,f32} with
wire: null, and the .tcb emits the seven ? tokens RB's own converter emits, so a replayer
refuses rather than guesses. A missing field is honest; a mislabelled one propagates — and it would
have propagated inside the artefact that is meant to outlive the adapter that read it.
That is the capture/replay pairing doing its job in both directions, which is the argument for having run the two lanes together.
5. Gates
Run as separate commands (rule 13).
tools/clean_room_check.sh— OK- host
ctest --preset host— 55/55 - CT111 shim cross-build (
/srv/re-lab/build/sots-engine-cb,DIST=/srv/re-lab/shim/dist-cb), on a freshly extracted tree withbuild-shimremoved (rule 24) — exit 0, exports 66 names identical to the realbinkw32.dll
The generated header was regenerated from sots-re/ghidra/addresses.json plus every
ghidra/addresses.d/*.json fragment (rule 14), never hand-resolved — the merge of lane L4's
instrument conflicted there and regeneration is what resolved it. This lane adds no addresses:
it used StrategySim_ApplyTurnCommandBatch and StrategyApp_RunAI, both already carried by lanes
A2 and AI1, so there is no addresses.d/lane-cb.json and there should not be one.