# L4 — the AI command block, live Companion to `docs/L4-predictions.md` (written and committed **before** the module existed) and to `sots-re/findings/subsystems/ai-order-capture.md` (the full report, with the raw logs). ## What was added `src/shim/hooks/ai_orders.{h,cpp}` — two instruments behind three config keys. **`aiorders=on`** installs exactly one detour: a register-transparent entry stub on `Game::StrategySim::ApplyTurnCommandBatch`. That function takes `(blocks, n)` as stack arguments and multiplies `n` by `0x1b4` to make its end pointer, so at its entry every player's submitted `TurnCommands` block is complete, in memory, at a known stride. One hook there dumps the whole turn's command traffic: six gates, twenty-seven list lengths and 48 bytes per element, per block. **`aiprobes=all`** adds sixteen entry counters in lane H's asm-stub style, in their own table so `probe_entry.cpp`'s set is untouched and `probes=` still means what it meant. Row 0 is `StrategyAIAgent::RunTaskList`, and its stub is hand-written: it reads that function's `pass` stack argument into a global before tail-jumping, so **every later probe hit is attributed to a pass**. That is the difference between counting entries and measuring the two-pass model. **`aiorders.out=`** — the dump goes to its own file as well as `shim.log`. Both halves are separately switchable so they can be given separate rule-19 controls. In the end they did not need to be: the full seventeen-detour configuration reproduced the campaign's published End-Turn oracle byte for byte, on a guest (VM145) that had never been checked against it. ## What it found Full account in the findings document. The three that change this repo: 1. **The AI submits a list-23 element every turn.** Lists 17–27 had never been populated by any workload, so the free half of `ListAdvancesModCount` was read from the instruction stream and nothing else. It is now exercised twice and both turns still cost the measured 12. 2. **Ids in AI commands are client-allocated.** The build order names design `18` and the fleet order names fleet `34` — neither exists in the input save, and the *other* new design that turn (a monster faction's, made server-side) took `1712` from the save's master counter. Two id spaces; the small one travels in the command. `OrderClient` does not model id allocation and now has a named reason to. 3. **Pass 0 emits nothing, measured from the output.** All three pass-1-gated exits were entered in both passes in equal numbers, and the block carries one copy of each element. `OrderClient`'s `EnterTaskPass` gate is confirmed. ## Tests `tests/game_ai/test_live_blocks.cpp` — 44 checks, both captured blocks rebuilt through the public `OrderClient` API from the dumped values, asserting the list profile, the element values, the gate counts and the `ModCount` total for each turn, plus a two-sided check that the list-23 element is free while a list-16 element is not. It is deliberately a **separate binary** from `test_orders.cpp`. That file is the record of what static reading predicted before any of this ran, and it stays that way; this one is the record of what the game did. The agreement between them is evidence only while the two stay independent. Two placeholder ids in `test_orders.cpp` were corrected in place from the capture (the AI fleet order names fleet 34 with a hop to 272, not fleet 1744; the three research targets are techIds 144/90/288). Rule 11: a wrong id in a test is how a wrong id spreads. ## 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-l4`, `DIST=/srv/re-lab/shim/dist-l4`) — **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 (1,209 entries). This lane's fragment is `lane-l4.json`, nine entries: eight `IAITask::Execute` bodies and the list-16 order method. Nine other entries in the regenerated header belong to concurrent lanes' fragments and came along with the merge, as the per-lane fragment mechanism intends. ## One thing that did not work, recorded because it costs a run The turn-1 workload (`turn1-state.sav` + one End Turn) is **not reproducible**. Three runs produced three different post-turn autosaves, differing in exactly one field: the research target of the one AI player that owns nothing. Every other byte — ids, designs, fleets, `ModCount` — is identical. Lane L5 established this first and better, on VM146, and owns it (`sots-re/findings/subsystems/turn1-to-turn2-nondeterminism.md`); use `ref-turn2 → turn3` as the oracle, not this pair. What this lane adds is where the divergence lives: the differing value is in the **submitted command block**, in player 512's research-target gate, so the decision is made client-side before submission and the sim is not diverging on identical input. ## Addendum — the research-selection tie set `airesearch=on` adds three more register-transparent dump hooks: the per-player delimiter (`SelectResearchTarget 0x006c8890`, which also prints the player's current target so a player that returns immediately is distinguishable from one that walks an empty list), one line per candidate (`TryResearchCandidate 0x006c8580`, in the order the selector sees them), and the outcome (`cl_SetResearchTarget 0x00578f60`, which takes the tech's **name**, so the chosen tech is a string in a register and needs no id table). Four reachability probes go with them: the two producers phase 18 tries before the walk, and the two halves of the fallback rotation. Measured on one End Turn from `turn1-state.sav`: * **Only one of the three AI players reaches the candidate walk.** The other two get their target from a producer that runs first, which is why they are stable across every run of both lanes and the third is not — they are on a different code path, not a luckier one. * **The candidate stream has length one**, and the single entry is `{2, 12}` — small integers, a *category*, not a tech. A vector of one has no order to scramble, so arrival order is not the mechanism and the prediction that said it was is falsified. * **The fallback never ran** (both probes zero), so it is not the three-arm rotation either. The variation is therefore inside the resolver that turns a category into a tech. `k` is nameable from the shipped tech data for the arm that was observed: `XNC_ROOT` allows six tier-1 techs at an identical 2000 RP, one per species, and each allows exactly one tier-2 successor — the six `XNC_Trns2`. Four of those six are among the five values observed across six runs between lanes L4 and L5. **Their costs differ (13000–30000), so the resolver is not ranking by cost**; it is taking whichever member of the available set it reaches first. Full account, including the one observed value that is *not* in that family and what single run would settle it, in `sots-re/findings/subsystems/ai-order-capture.md` §3.2.