114 lines
7 KiB
Markdown
114 lines
7 KiB
Markdown
# 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.
|