sots-engine/docs/CB-capture.md
alex cd17280f01 CB: record the third pinned run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:26:33 -04:00

5.8 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, 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/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.