sots-engine/docs/CB-capture.md
alex cd92076b39 CB: the engine-side record of the capture instrument and what it measured
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:22:16 -04:00

5.7 KiB
Raw Blame History

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/ai is 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 with build-shim removed (rule 24) — exit 0, exports 66 names identical to the real binkw32.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.