sots-engine/docs/L4-ai-orders.md

7 KiB
Raw Blame History

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=<path> — 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_Trns<Species>2. 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.