Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
5.8 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, three times over. Three processes on that save, each drawing its own three natural seeds
(nine distinct words in all), two of them pinned to the first's, produced one autosave
(d59bb9f2fd0eb535…) with identical gates — including player 512's research target 282, the value
that varies — and identical element records. Two runs agreeing on a k > 1 workload is a 1/k
coincidence; three is 1/k².
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.