diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c05fbc..6fb9ded 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,6 +104,7 @@ if(WIN32) src/shim/hooks/tail_rng.cpp src/shim/hooks/draw_sites.cpp src/shim/hooks/probe_entry.cpp + src/shim/hooks/ai_orders.cpp src/shim/hooks/watchpoints.cpp) # `minhook` is here for its include directory: lane H's probe_entry.cpp installs its own # detours (MH_CreateHook/MH_EnableHook) rather than handing descriptors back to main.cpp, diff --git a/docs/L4-ai-orders.md b/docs/L4-ai-orders.md new file mode 100644 index 0000000..684170e --- /dev/null +++ b/docs/L4-ai-orders.md @@ -0,0 +1,114 @@ +# 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. diff --git a/docs/L4-predictions.md b/docs/L4-predictions.md new file mode 100644 index 0000000..7b782e1 --- /dev/null +++ b/docs/L4-predictions.md @@ -0,0 +1,338 @@ +# L4 — what the live AI will emit, written before the module was built + +Lane L4, 2026-09-08. Guest **VM145** (`sots-re-win10-145`, 192.168.10.145). Worktree `wip/l4` off +`main` `0117495`. + +Rule 2: this file is committed **before** the shim module is written, and it names the falsifier for +every claim. Nothing below is a summary of a run; there has been no run. + +The question this lane exists to answer: **does our emission model produce the same commands the +game does?** `src/game/ai` models the *shape* of a `TurnCommands` block and the *cost* of each +element, and decides nothing. Nobody has ever looked at a real AI block. + +--- + +## 0. The instrument, and why it is one detour + +`Game::StrategySim::ApplyTurnCommandBatch` **0x0088f9b0** receives `(blocks, n)` as two stack +arguments with `ecx = sim` — read straight off the prologue at `0x0088f9db`–`0x0088f9e7`, where +`n` is multiplied by `0x1b4` and added to `blocks` to make the end pointer. **At that instant every +player's submitted block is complete and in memory**, so one register-transparent entry stub dumps +the entire turn's command traffic: six gates and twenty-seven list lengths per player, plus the +element bytes. + +That is a strictly better instrument than counting `ModCount` bumps, which is what every previous +lane has had. A bump count says *how many* commands; the block says *which lists, how many +elements, and what values*. + +Second instrument: sixteen register-transparent entry counters (`probe_entry.cpp`'s pattern, its +own table so lane H's set is untouched), on the task bodies and the emission gates. One of them — +`StrategyAIAgent::RunTaskList` **0x006b3320** — additionally records its `pass` stack argument into +a global, so every later probe hit is **attributed to a pass**. That is what turns AI3's P2 from a +count into a measurement. + +**Rule 20 is the whole design.** `AITRaid`'s list-16 emit is the named open item, and a zero at +`0x007635f0` means nothing unless `AITRaid::Execute 0x0068e670` is also probed: "never ran" and +"ran and emitted nothing" are opposite answers. + +**Rule 19 control.** Both workloads have published oracle bytes. Every configuration is run against +them and any configuration that moves a byte has invalidated its own numbers. + +--- + +## 1. Workload W1 — `turn1-state.sav`, one End Turn (the turn 1→2 transition) + +Facts already in the corpus, from `turn1-state.sav` → `turn2-state.sav` (read this session with +`verify/save-reader`, not taken from a report): + +| | | +|---|---| +| players | 8: ids 16, 32, 496, 512 (`Species` 0/2/0/2) and 528/544/560/576 (`Species == 4`) | +| player 32 | `HomeSys` **288** = Ke'Dolarra, `PlyrIdx` 1 | +| `ModCount` | 0 → 12 | +| `ResRate` | 0.25 → **0.8** on 32, 496, 512; unchanged (0.25) on 16 | +| `ResTNm` | `''` → `IND_Waldo` (32), `DRV_PlsFiss` (496), `BIO_GnMod` (512); `''` on 16 | +| `Status` | 0 → 4 on 16/32/496/512; **0 on all four `Species == 4`** | +| player 32 designs | 5 → 6; the new one is `DesID` **18**, `"Honor Lance"` | +| `designIds` | gains **18** *and* **1712** — the second belongs to player 528, whose design count also goes 18 → 19 | +| `NMnx` | 106 → 109 (ship **1728**, fleet **1744**, and one more) | +| new fleet | **1744** `"Alpha Fleet"` at system 288, holding ship 1728 of design 18 | + +### P1 — the block set + +**Prediction: `n == 4`, and the four `block+0x04` player ids are 16, 32, 496, 512 in that order.** + +*Falsified if* `n == 8` — then the `Species == 4` factions **do** submit, they just submit empty +blocks, and AI4 §2's "no `StrategyClient` at all" is too strong (it would become "a client that +never sets the always-set gate", which is a different and stranger claim). *Falsified differently +if* the four ids are 0,1,2,3 — then `block+0x04` is a player **index**, not the save id, and every +published reading of that field needs the same correction. + +### P2 — the four blocks, list by list + +| block | gates set | lists non-empty | +|---|---|---| +| 16 (human) | rate only, value **0.25** | **none** | +| 32 (AI) | rate **0.8**, target (`IND_Waldo`) | **1, 3, 5 — one element each** | +| 496 (AI) | rate **0.8**, target (`DRV_PlsFiss`) | **none** | +| 512 (AI) | rate **0.8**, target (`BIO_GnMod`) | **none** | + +Boost, group-4, the three-float Hiver gate and `CivilianRatios` **clear on all four**. + +That sums to AI4's twelve: 4 rate + 3 target + 3 (player 32's lists) + 2 drivers. + +*Falsified if:* any list outside {1, 3, 5} is non-empty on any block; or a list in {1, 3, 5} carries +more than one element; or the human's block carries a list at all; or 496/512 carry a list (they own +nothing, so a list element from them would mean a task fired against objects they do not have). + +**The sharpest falsifier is list 8/10/14 non-empty.** Player 32 gets a brand-new fleet (1744) this +turn, and a fleet is exactly the thing lists 8/10/14 order. If the AI issues a fleet order on the +same turn it creates the fleet, AI4's P2 attribution is wrong by exactly the fleet-order group and +the twelve has to be re-derived. + +### P3 — the element values + +* **list 3** (`{i32 ordinal, i32 designId, i32 systemId, i32 w}`): `designId == 18`, + `systemId == 288`. The `w` word has never been observed and is not predicted. +* **list 5** (`{i32 systemId, OutputRates}`): `systemId == 288`, i.e. **the same system the build + order names**. +* **list 1** (`ShipDesignDef` frame + `i32`): the element carries the id **18** somewhere in its + first 48 bytes. + +*Why 18 matters.* 18 is not a multiple of 16 and does not come from the master `NMnx` counter — the +*other* new design that turn, player 528's, got **1712** from that counter, and 528 submits no +block. So there are two id spaces, and the prediction is that **a client-issued design carries a +small client-local id in the command itself**, while a server-created object gets a master id. +*Falsified if* the list-1 element's ids are all ≥ 512 / multiples of 16 — then 18 was assigned +server-side when the command was applied and the design command carries no id at all, which is a +materially different thing for a reimplementation to emit. + +### P4 — the probes on W1 + +| probe | prediction | what a miss means | +|---|---|---| +| `RunTaskList 0x006b3320` | **6** = 3 AI players × 2 passes | 2 ⇒ only one player's task list runs and 496/512 reach `ResTNm` by some other path; 0 ⇒ the instrument is broken, not the game | +| `BuildTurnCommands 0x00783780` | **4**, one per submitting block | ≠ 4 ⇒ P1 is wrong from the other side | +| `RequestBuildForTask 0x006cea50` | **> 0 and even-ish** — entered on both passes, emits only on pass 1 | entries only in pass 1 ⇒ the caller gates before it, and AI3 §2.3's "the hub returns an empty list on pass 0" is not the whole mechanism | +| `AssignFleetsAndIssueOrders 0x006c16c0`, `IssueRouteForFleets 0x006bbd50` | **entered, both passes, zero list-8/10/14 elements** | entered + elements ⇒ P2 falsified | +| `AITRaid::Execute 0x0068e670` | **0** | — | +| `ClientOrder 0x007635f0` (list 16) | **0** | — | +| the six shared planner bodies (`AITColonize` 0x0068b400, `AITEscortGateInvade` 0x0068c7c0, `AITInvade` 0x0068d7a0, `AITNodeBore` 0x0068e590, `AITBuildPoliceShips` 0x00690380, `AITBuildDeepScanShips` 0x006901a0) | **at least one build-shaped body entered in both passes; at most one produces the single list-3 element** | none entered ⇒ the list-3 element comes from a task nobody has named, and AI3 §5's path table is not the emission path | +| `AITAdvanceIdleShips::Execute 0x0068f230` | entered (priority 0, always last) | — | +| `IsClaimedByAnotherTask 0x006a8d20` | **> 0** | — | + +**Stated in advance so it cannot be claimed afterwards: W1 almost certainly cannot settle AI3 §2.4.** +A turn-1 AI with one colony and no fleet of its own has nothing to raid, so `AITRaid` is predicted +not to run at all, and a zero at `0x007635f0` will then be a *non-answer* about pass 0 — exactly the +"did not fire" vs "fired and found nothing" distinction rule 20 is about. The AITRaid question needs +`AITRaid::Execute` to be entered, and W2 is the better chance. + +**Also stated in advance: this probe set cannot settle AI3's P3.** An entry counter on +`IsClaimedByAnotherTask` measures the "called often" half only; the steal branch at `0x006a8ded` is +*inside* the function and an entry probe cannot see it. P3 stays open. + +--- + +## 2. Workload W2 — `ref-turn2.sav`, one End Turn (the turn 2→3 transition) + +`ref-turn2.sav` **is** `turn2-state.sav` (same sha256). Corpus facts for 2→3: + +* `ModCount` 12 → 24; `NMnx` 109 → 111 (ship **1760**, fleet **1776** `"Gamma Fleet"`); +* fleet **1744** `"Alpha Fleet"` is **gone**, replaced by fleet **34** `"Beta Fleet"`, in transit + (`FtTrans 1`), flight plan one waypoint `Wpt 272`, `pnd 288`, ETA 3, origin = Ke'Dolarra's + position; +* no `ResTNm` change on any player; `ResRate` already 0.8. + +AI4 §2.1 attributes the twelve as 4 rate + list 5 + list 3 + list 10 + **2 × list 14** + list 8 + 2 +drivers. + +### P5 — the block + +**Prediction:** `n == 4`; blocks 16, 496, 512 carry the rate gate and **nothing else** (no target +gate this turn); block 32 carries the rate gate and lists **3 = 1, 5 = 1, 8 = 1, 10 = 1, 14 = 2**, +everything else empty. + +### P6 — list 14 is two elements against one fleet, and the second field is the discriminator + +Lane Q read the element as `{i32, i32, bool}`; `human-turn2-orders.sav` (a UI fleet move) carries +exactly one, `{1456, 0, true}`. AI2's P1 says the AI's bridge calls the adder twice with mode 0 and +mode 1 and the adder keys on `(fleet, mode)`. + +**Prediction: the two list-14 elements are `{F, 0, b}` and `{F, 1, b}` with the same `F`, and `F` is +also the fleet id list 8's element names.** That converts AI2's P1 from an inference about bump +counts into a read of the values. + +*Falsified if:* both elements carry the same second word (then the adder does not key on mode and +the doubling has another cause); or the two fleet ids differ (then it is two orders against two +fleets and the "one order costs two elements" rule is wrong); or list 14 has one element (then +AI4's two list-14 bumps came from two different fleets and the reference turn moved two). + +### P7 — which fleet id the order names + +**Prediction: `F == 1744`** — the fleet that exists at submit time — and **34 does not appear +anywhere in the block.** 34 is created when the order is applied, from the same small-id space as +design 18. + +*Falsified if* `F == 34`: then the client creates the fleet object *before* submitting and ships the +new id in the command, which would make the small-id space **client-allocated and part of the wire +protocol** — a much bigger constraint on a reimplementation than the alternative, because our engine +would have to reproduce that counter exactly to get byte-identical saves. + +This is the one prediction I would most like to be wrong, because the falsifier is the more +interesting world. + +### P8 — list 10's first word + +Lane AI4 §4.5 refused to name list 10 (`{i32, i32, counted i32}`) on positional evidence alone. + +**Prediction: its first `i32` is `F`, and its counted vector holds ship ids** — on W2 that means the +single ship **1728**. If so, "assign these ships to this fleet" is supported by values rather than +by adjacency, and the list can be named. + +*Falsified if* the counted vector is empty or holds system ids (W2's route is a single hop to 272, +so a `{…, 272}` tail would make it a route-shaped command instead). + +### P9 — AITRaid, the second attempt + +Player 32 now owns a fleet, so `AITRaid` has a candidate. **Prediction: `AITRaid::Execute` is +entered on both passes and `ClientOrder 0x007635f0` is entered zero times**, i.e. list 16 stays +empty and AI3 §2.4's inference holds — but *for the reason the probe can see*, which is the point. + +*Falsified if* `0x007635f0` is entered with `pass == 0` recorded: then pass 0 **does** emit, AI3's P2 +is falsified, and every `ModCount` arithmetic in `ai-order-emission.md` that assumes one emitting +sweep is off by a factor. + +--- + +## 3. Rule 19 — the controls, and what they are compared against + +Published oracle bytes (sha256 prefixes, `findings/subsystems/running-the-game.md`): + +| workload | `(Autosave EndTurn).sav` | `(Autosave).sav` | +|---|---|---| +| W1 from `turn1-state.sav` | `a3f9dc4b…` | `ab4ac2d7…` | +| W2 from `ref-turn2.sav` | `bb4fd9ac…` | `978041ac…` | + +Three configurations per workload, each a separate launch: + +| run | `shim.cfg` | purpose | +|---|---|---| +| **C** | `hooks=off` | proves **this clone** reproduces the oracle at all. VM145 is a ZFS clone; nothing has ever checked that it is byte-faithful. If C fails, the lane reports that and stops — every later number would be measured against an unknown baseline. | +| **A** | `aiorders=on`, `aiprobes=off` | the block dump alone: one detour | +| **B** | `aiorders=on`, `aiprobes=all` | the dump plus sixteen entry probes | + +**Prediction:** C, A and B all reproduce both oracle hashes. **Falsified if** A or B moves a byte — +in which case that configuration's numbers are reported as unmeasured, not as results, and the +sixteen-probe set is bisected with `aiprobes=N`. Lane H's precedent is explicit that a MinHook +detour has changed this game's behaviour once already, and that the probe module is the one it +happened to. + +--- + +## 4. What this lane will not do, said now + +1. **It does not close AI3's P3** (§1, P4). An entry probe cannot see a branch inside the callee. +2. **It does not test the eleven "free" lists** (AI4 P1's untested half) unless one of them turns up + populated, which P2/P5 predict will not happen. If one does, that is the result. +3. **It does not settle the Hiver gate** (AI4 P3). The corpus has no Hiver player and this lane is + not manufacturing one. +4. **It reads element bytes, not element types.** The dump emits a fixed 48-byte window per element + plus the same bytes as ints and floats; decoding to lane Q's records happens offline, in the + report, where a wrong record is visible as a wrong value rather than being baked into the + instrument. Where a list's in-memory element is larger than 48 bytes the dump says so rather than + silently truncating. +5. **The dump's own coverage check**: for every list it walks the node chain *and* reads `_Mysize`, + and logs a `MISMATCH` line if they disagree. A wrong list layout would otherwise print a + confident, wrong zero (rule 1). + +--- + +# Addendum — the research-selection tie set + +Added after the first two runs, before the research instrument was written. The coordinator's +question: if the one varying AI decision is a **tie** broken by something per-process, the +original's possible outcomes form a small enumerable set, and a deterministic `game/ai` can pick +canonically and claim *"our choice is one of exactly k the original can produce, and here are all +k"*. That is a stronger claim than behavioural equivalence, and it needs the candidate set. + +## What the selection site actually is (read before predicting) + +Process Turn phase 18 is `0x006caf70`. It tries three producers in order and takes the first +non-null: + +``` +eax = 0x006a84f0(agent) ; producer A +if (!eax) eax = 0x006c27c0(agent) ; producer B +if (!eax) eax = 0x006c8890(agent, &agent+0x13c); producer C -- the candidate walk +if (eax && eax != player->+0x294) cl_SetResearchTarget() +``` + +`player->+0x294` is the current target, and `cl_SetResearchTarget 0x00578f60` takes the tech's +**name string**, not an id — the object's `+0x4` is a `std::string` and the caller resolves the +short-string union before pushing it. + +Producer C, `0x006c8890`, has two halves: + +**C1, the candidate walk.** `if (player->+0x294 != 0) return 0`. Otherwise build a `std::vector` +of 0x0c-stride candidates with `0x006c2490` — which constructs a working object, fills it +(`0x006bcca0`), emits the vector (`0x006bc500`, a nested walk over groups, **no `std::sort` at that +level**) and destroys it — then walk the vector **front to back** calling +`0x006c8580(ecx = the target slot, edx = cand[1], stack: agent, cand[0])` and **return the first +one that answers non-zero**. First-acceptable in arrival order. No score, no comparator, no tie +resolution: **whatever the vector's order is, is the answer.** + +**C2, the fallback**, reached only when the walk accepts nothing: `0x006b36e0(agent) & 0x80000007` +indexes one of two eight-entry `.data` tables, and the value there seeds a three-arm rotation +`(i + seed) % 3` over `0x006c8670`. Both tables are in the image and both hold values in {0,1,2}: + +``` +0x00a1a544 = 2 0 2 0 2 1 2 0 +0x00a1a564 = 0 2 0 2 1 0 2 0 +``` + +`0x006b36e0` is not an RNG call — it reads `player->+0xf4`, calls `0x0080da80` and computes. So C2's +"roll" is a **hash of player state**, and its outcome space is at most **three arms**. + +## Predictions + +### P10 — where the variation lives + +**Prediction: `0x006c8890` (producer C) is entered once per AI player on turn 1 and the variation is +in C1's candidate vector, not in C2.** Specifically, across two runs of the same workload: + +* players 32 and 496 (stable across five runs between lanes L4 and L5) produce **identical candidate + streams in identical order**, and the accepted candidate sits at the same position; +* player 512 produces a candidate stream that is **the same set in a different order**, and the + first-acceptable one is therefore a different tech. + +*Falsified if:* the streams are identical for 512 and the accepted position still differs ⇒ the +decision is inside `0x006c8580`, i.e. state or a seed, and arrival order is not the mechanism. +*Also falsified if:* `0x006b36e0` fires for 512 ⇒ the pick came from **C2**, which is not a tie at +all but a three-arm rotation seeded by a state hash, and `k <= 3` by construction. + +### P11 — k is enumerable, and small + +**Prediction: the tie set for player 512 is the set of candidates in its stream that +`0x006c8580` would accept, and the stream is short — tens, not thousands.** A player with no +colonies and no research history has few reachable techs. + +*Falsified if:* the stream is hundreds of entries ⇒ "name all k" is not a practical verification +claim for this decision and the honest move is to mask the leaf, as lane L5 already does. + +### P12 — the two stable players are the control + +**Prediction: 32 and 496 accept a candidate at position 0 or very near it**, because a player whose +choice never varies is one whose first candidate is always acceptable. If instead they accept deep +in the stream and are still stable, then order is stable for them and unstable for 512 — which +would point at 512's *container*, not at the walk. + +### P13 — what our engine should do + +If P10 holds, a deterministic `game/ai` sorts the candidate vector by a canonical key (the tech +name, which is what the original ships in the command anyway) before the walk. The verification +claim becomes: **our pick is the canonical member of the tie set; every observed original run picks +some member of the same set; here is the set.** If P10 is falsified in the C2 direction instead, the +claim is different and weaker in kind but stronger in size: three arms, both tables in the image, +so k <= 3 and all three are nameable without any capture at all. + +*Either way the capture names k.* That is the point of running it. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index d7b7281..332bf2a 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 940aeca, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 7924583, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include diff --git a/src/shim/hooks/ai_orders.cpp b/src/shim/hooks/ai_orders.cpp new file mode 100644 index 0000000..68ae3ae --- /dev/null +++ b/src/shim/hooks/ai_orders.cpp @@ -0,0 +1,710 @@ +#include "shim/hooks/ai_orders.h" + +#include +#include +#include +#include + +#define WIN32_LEAN_AND_MEAN +#include + +#include "MinHook.h" + +#include "generated/sots_addresses.h" + +namespace shim::hooks { +namespace { + +// ---- configuration --------------------------------------------------------------------------- + +bool g_enabled = false; +std::size_t g_probeInstallCount = 0; // `aiprobes=`; default off, so `aiorders=on` alone is ONE detour +bool g_research = false; // `airesearch=`; the three research-selection dump hooks +char g_outPath[MAX_PATH] = {}; +FILE* g_out = nullptr; +void (*g_log)(const char*) = nullptr; + +void LogF(const char* fmt, ...) { + char buf[1200]; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (g_log) g_log(buf); + if (g_out) { + std::fputs(buf, g_out); + std::fputc('\n', g_out); + std::fflush(g_out); + } +} + +// ---- guarded reads --------------------------------------------------------------------------- +// +// Every read below is of a pointer the game handed us or of a heap node we reached by walking one. +// A wrong offset must produce a logged zero, never a fault inside a detour, so nothing is read +// without a probe first. Four bytes at a time: an element window may run past the end of a small +// heap node, and probing per word bounds the damage to the word. + +inline bool Readable(std::uintptr_t p, std::size_t n) { + return p != 0 && !IsBadReadPtr(reinterpret_cast(p), n); +} + +inline std::uint32_t U32(std::uintptr_t p) { + return Readable(p, 4) ? *reinterpret_cast(p) : 0u; +} +inline std::uint8_t U8(std::uintptr_t p) { + return Readable(p, 1) ? *reinterpret_cast(p) : 0u; +} +inline float F32(std::uintptr_t p) { + const std::uint32_t v = U32(p); + float f; + std::memcpy(&f, &v, 4); + return f; +} + +// ---- the `TurnCommands` block ------------------------------------------------------------------ +// +// Offsets from `Game::TurnCommands::Write` 0x00842540 as read by lane Q, and from the twenty-seven +// per-list loops in `ApplyTurnCommandBatch` as read by lane AI4. Both are instruction-stream reads +// of the same class from opposite ends -- the writer and the applier -- and they agree, which is +// the only independent corroboration this layout has. +constexpr std::uint32_t kBlockStride = 0x1b4; +constexpr std::uint32_t kListBase = 0x70; // list 1's member +constexpr std::uint32_t kListStride = 0x0c; // {_Myhead, _Mysize, _Alval}, allocator LAST +constexpr int kListCount = 27; + +// How much of each element to record. The largest element record lane Q read is list 5's +// {i32, OutputRates frame}; 48 bytes covers every scalar-only record with room to spare and is +// short enough that a heap node's tail is unlikely to matter. Words that do not probe readable are +// printed as `????????` rather than as zero, so truncation is visible. +constexpr int kElemWords = 12; + +std::uint32_t g_batchSeq = 0; + +// ---- the entry probes --------------------------------------------------------------------------- + +constexpr std::size_t kMaxProbes = 24; +volatile std::uint32_t g_calls[kMaxProbes]; // since the last batch dump +volatile std::uint32_t g_total[kMaxProbes]; // since process start +volatile std::uint32_t g_byPass[kMaxProbes][3]; // pass 0, pass 1, anything else (incl. stale) +bool g_installed[kMaxProbes]; + +// Set by the RunTaskList stub from that function's third __cdecl stack argument. It is STALE once +// RunTaskList returns -- nothing can clear it from a tail-jumping stub -- so `pass` on a hit that +// falls outside a task sweep is the last pass that ran, not a measurement. The event ring's +// `run` column is what makes that readable: hits with the same `run` as the preceding RunTaskList +// entry are inside that sweep. +volatile LONG g_pass = -1; +volatile LONG g_runSeq = 0; +volatile LONG g_agent = 0; + +struct Event { + std::uint32_t seq; + std::uint8_t probe; + std::int8_t pass; + std::uint32_t run; + std::uint32_t agent; +}; +constexpr std::size_t kMaxEvents = 4096; +Event g_events[kMaxEvents]; +volatile LONG g_eventCount = 0; +std::size_t g_eventsWritten = 0; + +} // namespace + +// ---- the hit sinks, called from the asm stubs --------------------------------------------------- + +extern "C" void AiProbeHit(int index) { + if (index < 0 || static_cast(index) >= kMaxProbes) return; + ++g_calls[index]; + ++g_total[index]; + const LONG p = g_pass; + const int bucket = (p == 0) ? 0 : (p == 1) ? 1 : 2; + ++g_byPass[index][bucket]; + const LONG n = InterlockedIncrement(&g_eventCount) - 1; + if (n >= 0 && static_cast(n) < kMaxEvents) { + Event& e = g_events[n]; + e.seq = static_cast(n); + e.probe = static_cast(index); + e.pass = static_cast(p); + e.run = static_cast(g_runSeq); + e.agent = static_cast(g_agent); + } +} + +// The RunTaskList stub's sink. Records the pass BEFORE the function runs, so every probe hit +// inside the sweep sees it. +extern "C" void AiOnRunTaskList(void* agent, int pass) { + g_pass = pass; + g_agent = static_cast(reinterpret_cast(agent)); + InterlockedIncrement(&g_runSeq); + AiProbeHit(0); +} + +namespace { + +// ---- the probe table ------------------------------------------------------------------------ +// +// Order is the stub index, the report order and the `aiprobes=N` bisection order, so it is fixed. +// Index 0 MUST be RunTaskList: it is both the pass recorder and the control. Every other row is +// meaningless if row 0 reads zero, and the report says so rather than presenting a table of zeros. +namespace A = sots::addr; + +struct ProbeDef { + const char* name; + std::uint32_t rva; + void* stub; + void** trampoline; +}; + +} // namespace + +// One trampoline slot and one stub per probe. Written out rather than generated at runtime because +// a runtime thunk needs an executable allocation and a relocation, and MinHook already owns that. +#define AI_PROBE_STUB(i) \ + extern "C" void* g_aiTr##i; \ + void* g_aiTr##i = nullptr; \ + extern "C" void AiProbeStub##i(void); \ + asm(".text\n" \ + ".globl _AiProbeStub" #i "\n" \ + "_AiProbeStub" #i ":\n" \ + " pushfl\n" \ + " pushal\n" \ + " pushl $" #i "\n" \ + " call _AiProbeHit\n" \ + " addl $4, %esp\n" \ + " popal\n" \ + " popfl\n" \ + " jmp *_g_aiTr" #i "\n") + +// Index 0 is hand-written: it forwards RunTaskList's `agent` and `pass` stack arguments. After +// `pushfl` (4) + `pushal` (32) the return address is at esp+36 and the four __cdecl arguments at +// esp+40/44/48/52, so `pass` is esp+48 and `agent` is esp+40 -- and after the first push the +// latter has moved to esp+44. Nothing is written; the arguments are read where the callee will +// read them. +extern "C" void* g_aiTr0; +void* g_aiTr0 = nullptr; +extern "C" void AiProbeStub0(void); +asm(".text\n" + ".globl _AiProbeStub0\n" + "_AiProbeStub0:\n" + " pushfl\n" + " pushal\n" + " pushl 48(%esp)\n" + " pushl 44(%esp)\n" + " call _AiOnRunTaskList\n" + " addl $8, %esp\n" + " popal\n" + " popfl\n" + " jmp *_g_aiTr0\n"); + +AI_PROBE_STUB(1); +AI_PROBE_STUB(2); +AI_PROBE_STUB(3); +AI_PROBE_STUB(4); +AI_PROBE_STUB(5); +AI_PROBE_STUB(6); +AI_PROBE_STUB(7); +AI_PROBE_STUB(8); +AI_PROBE_STUB(9); +AI_PROBE_STUB(10); +AI_PROBE_STUB(11); +AI_PROBE_STUB(12); +AI_PROBE_STUB(13); +AI_PROBE_STUB(14); +AI_PROBE_STUB(15); +AI_PROBE_STUB(16); +AI_PROBE_STUB(17); +AI_PROBE_STUB(18); +AI_PROBE_STUB(19); +#undef AI_PROBE_STUB + +namespace { + +#define AI_PROBE(name, rva, i) \ + ProbeDef { name, rva, reinterpret_cast(&AiProbeStub##i), &g_aiTr##i } + +const ProbeDef kProbes[] = { + // 0: the pass recorder AND the control. 3 AI players x 2 passes on the reference game. + AI_PROBE("StrategyAIAgent::RunTaskList [control+pass]", A::StrategyAIAgent_RunTaskList, 0), + // 1-2: the AITRaid question (AI3 section 2.4). A zero on 1 means nothing without 2. + AI_PROBE("StrategyClient::OrderList16 [list 16 emit]", A::StrategyClient_OrderList16, 1), + AI_PROBE("AITRaid::Execute", A::AITRaid_Execute, 2), + // 3-8: the six shared bodies behind the nine tasks AI2 called planners and AI3 showed emit. + AI_PROBE("AITColonize::Execute [+Goal]", A::AITColonize_Execute, 3), + AI_PROBE("AITEscortGateInvade::Execute [+Goal]", A::AITEscortGateInvade_Execute, 4), + AI_PROBE("AITInvade::Execute [+Goal]", A::AITInvade_Execute, 5), + AI_PROBE("AITNodeBore::Execute", A::AITNodeBore_Execute, 6), + AI_PROBE("AITBuildPoliceShips::Execute", A::AITBuildPoliceShips_Execute, 7), + AI_PROBE("AITBuildDeepScanShips::Execute", A::AITBuildDeepScanShips_Execute, 8), + // 9: priority 0, pass-1 body, always last in the sweep -- a second control on the task list. + AI_PROBE("AITAdvanceIdleShips::Execute", A::AITAdvanceIdleShips_Execute, 9), + // 10-12: the three pass-1-gated emission exits. Entered on BOTH passes if the gate is theirs. + AI_PROBE("StrategyAIAgent::RequestBuildForTask [lists 3,1]", + A::StrategyAIAgent_RequestBuildForTask, 10), + AI_PROBE("StrategyAIAgent::AssignFleetsAndIssueOrders [lists 14,8,10]", + A::StrategyAIAgent_AssignFleetsAndIssueOrders, 11), + AI_PROBE("StrategyAIAgent::IssueRouteForFleets [list 14]", + A::StrategyAIAgent_IssueRouteForFleets, 12), + // 13-14: the hub and the claim test. + AI_PROBE("StrategyAIAgent::AcquireFleetsForTask", A::StrategyAIAgent_AcquireFleetsForTask, 13), + AI_PROBE("StrategyAIAgent::IsClaimedByAnotherTask [entry only]", + A::StrategyAIAgent_IsClaimedByAnotherTask, 14), + // 15: one per submitting block -- the block count seen from the client side. + AI_PROBE("StrategyClient::BuildTurnCommands [control]", A::StrategyClient_BuildTurnCommands, 15), + // 16-17: the two research producers phase 18 tries BEFORE the candidate walk. If either of + // these answers, the walk never runs and the candidate set is not where the answer comes from. + AI_PROBE("AIResearch::ProducerA", A::StrategyAIAgent_ResearchProducerA, 16), + AI_PROBE("AIResearch::ProducerB", A::StrategyAIAgent_ResearchProducerB, 17), + // 18-19: the FALLBACK, reached only when the candidate walk accepts nothing. Its index source + // reads player state rather than the generator, and it rotates over three arms -- so a hit + // here means the outcome space is <= 3 by construction and is NOT a tie in a candidate list. + // This pair is the discriminator between the two mechanisms, and it is why they are probed. + AI_PROBE("AIResearch::FallbackIndex", A::StrategyAIAgent_ResearchFallbackIndex, 18), + AI_PROBE("AIResearch::FallbackArm", A::StrategyAIAgent_ResearchFallbackArm, 19), +}; +#undef AI_PROBE + +constexpr std::size_t kProbeCount = sizeof kProbes / sizeof kProbes[0]; +static_assert(kProbeCount <= kMaxProbes, "add more AI_PROBE_STUB() slots"); + +// ---- the dump --------------------------------------------------------------------------------- + +void DumpElements(int blk, int pid, int list, std::uintptr_t head) { + // MSVC std::list node: {_Next, _Prev, _Myval}. begin() == _Myhead->_Next; the head is the nil + // sentinel and terminates the walk. + std::uintptr_t node = U32(head); + int idx = 0; + while (node && node != head && idx < 64) { + const std::uintptr_t val = node + 8; + char hex[kElemWords * 9 + 8] = {}; + char ints[kElemWords * 13 + 8] = {}; + int hp = 0, ip = 0; + for (int w = 0; w < kElemWords; ++w) { + const std::uintptr_t p = val + 4u * static_cast(w); + if (Readable(p, 4)) { + const std::uint32_t v = *reinterpret_cast(p); + hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v); + ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast(v)); + } else { + hp += std::snprintf(hex + hp, sizeof hex - hp, "???????? "); + ip += std::snprintf(ints + ip, sizeof ints - ip, "? "); + } + } + // The first two words as floats as well: several element records lead with or contain a + // rate/fraction, and reading 0x3f4ccccd as 1061997773 hides that. + LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x f0=%g f1=%g ints=[ %s] hex=[ %s]", + blk, pid, list, idx, static_cast(node), static_cast(F32(val)), + static_cast(F32(val + 4)), ints, hex); + node = U32(node); + ++idx; + } + if (idx >= 64) LogF("aielem blk=%d list=%d TRUNCATED at 64 elements", blk, list); +} + +void DumpBlock(int i, int n, std::uintptr_t b) { + const int pid = static_cast(U32(b + 0x04)); + const int gRate = U8(b + 0x0c), gTgt = U8(b + 0x14), gBoost = U8(b + 0x20); + const int gG4 = U8(b + 0x2c), gF3 = U8(b + 0x3c), gCiv = U8(b + 0x6c); + LogF("aiblk seq=%u blk=%d/%d at=0x%08x pid=%d " + "rate=%d:%g target=%d:%d boost=%d:%d,%g g4=%d:%d,%d f3=%d:%g,%g,%g civ=%d", + g_batchSeq, i, n, static_cast(b), pid, gRate, + static_cast(F32(b + 0x08)), gTgt, static_cast(U32(b + 0x10)), gBoost, + static_cast(U32(b + 0x18)), static_cast(F32(b + 0x1c)), gG4, + static_cast(U8(b + 0x24)), static_cast(U32(b + 0x28)), gF3, + static_cast(F32(b + 0x30)), static_cast(F32(b + 0x34)), + static_cast(F32(b + 0x38)), gCiv); + + char sizes[27 * 5 + 16] = {}; + int sp = 0; + int nonEmpty = 0; + for (int L = 1; L <= kListCount; ++L) { + const std::uintptr_t m = b + kListBase + kListStride * static_cast(L - 1); + const std::uint32_t size = U32(m + 4); + sp += std::snprintf(sizes + sp, sizeof sizes - sp, "%u ", size); + if (size) ++nonEmpty; + } + LogF("ailists seq=%u blk=%d pid=%d nonEmpty=%d sizes(1..27)=[ %s]", g_batchSeq, i, pid, + nonEmpty, sizes); + + for (int L = 1; L <= kListCount; ++L) { + const std::uintptr_t m = b + kListBase + kListStride * static_cast(L - 1); + const std::uintptr_t head = U32(m); + const std::uint32_t size = U32(m + 4); + // Measure the list twice. A wrong container layout would otherwise print a confident zero + // for all twenty-seven, which is exactly what an empty block looks like (method rule 1). + int walked = 0; + std::uintptr_t node = U32(head); + while (node && node != head && walked < 4096) { + ++walked; + node = U32(node); + } + if (static_cast(walked) != size) + LogF("ailist MISMATCH blk=%d pid=%d list=%d _Mysize=%u walked=%d head=0x%08x " + "-- the list layout is wrong and every size on this block is unmeasured", + i, pid, L, size, walked, static_cast(head)); + if (size) { + LogF("ailist blk=%d pid=%d list=%d off=0x%03x size=%u", i, pid, L, + kListBase + kListStride * (L - 1), size); + DumpElements(i, pid, L, head); + } + } +} + +} // namespace + +void ai_orders_flush(void (*log_line)(const char*)) { + if (log_line) g_log = log_line; + const LONG n = g_eventCount; + const std::size_t have = + static_cast(n) > kMaxEvents ? kMaxEvents : static_cast(n); + for (std::size_t i = g_eventsWritten; i < have; ++i) { + const Event& e = g_events[i]; + const char* nm = (e.probe < kProbeCount) ? kProbes[e.probe].name : "?"; + LogF("aievent seq=%u run=%u pass=%d agent=0x%08x probe=%u %s", e.seq, e.run, + static_cast(e.pass), e.agent, static_cast(e.probe), nm); + } + g_eventsWritten = have; + if (static_cast(n) > kMaxEvents) + LogF("aievent OVERFLOW -- %ld hits taken, only %u recorded; counters below are complete, " + "the ordering is not", + n, static_cast(kMaxEvents)); +} + +// ---- the batch detour --------------------------------------------------------------------------- + +extern "C" void* g_aiBatchOrig; +void* g_aiBatchOrig = nullptr; +extern "C" void AiBatchDetour(); + +extern "C" void AiOnBatch(void* blocksv, int n) { + ++g_batchSeq; + const std::uintptr_t blocks = reinterpret_cast(blocksv); + LogF("---- aibatch seq=%u blocks=0x%08x n=%d stride=0x%x ----", g_batchSeq, + static_cast(blocks), n, kBlockStride); + if (n < 0 || n > 64 || !Readable(blocks, kBlockStride)) { + LogF("aibatch seq=%u UNREADABLE (n=%d) -- nothing dumped, and this is a failure of the " + "instrument, not an empty turn", + g_batchSeq, n); + } else { + for (int i = 0; i < n; ++i) + DumpBlock(i, n, blocks + kBlockStride * static_cast(i)); + } + + // The probe window. Counters are reported and reset here, so a row's `turn` column covers the + // AI sweep that produced THIS batch: the AI runs on SEResumePlaying at the end of the previous + // turn's processing (and on load), and the batch is applied at the start of the next one. + ai_orders_flush(nullptr); + if (g_probeInstallCount == 0) { + LogF("aiprobe seq=%u none installed (aiprobes=off)", g_batchSeq); + } else { + for (std::size_t i = 0; i < kProbeCount; ++i) { + if (!g_installed[i] && i < g_probeInstallCount) + LogF("COVERAGE: aiprobe %s NOT INSTALLED -- its count is meaningless, not zero", + kProbes[i].name); + LogF("aiprobe seq=%u idx=%u installed=%d turn=%u total=%u pass0=%u pass1=%u other=%u %s", + g_batchSeq, static_cast(i), g_installed[i] ? 1 : 0, g_calls[i], + g_total[i], g_byPass[i][0], g_byPass[i][1], g_byPass[i][2], kProbes[i].name); + } + if (g_calls[0] == 0) + LogF("aiprobe seq=%u CONTROL ZERO -- RunTaskList was not entered in this window, so " + "every other row above is unmeasured rather than absent", + g_batchSeq); + } + for (std::size_t i = 0; i < kProbeCount; ++i) { + g_calls[i] = 0; + g_byPass[i][0] = g_byPass[i][1] = g_byPass[i][2] = 0; + } +} + + +// ---- the research-selection capture (lane L4 addendum) ------------------------------------------- +// +// WHY THIS IS A DUMPER AND NOT THREE MORE COUNTERS. +// +// Exactly one AI decision in the reference game is not reproducible run to run: one shadow empire's +// research target (lane L5). If that pick is a TIE broken by something per-process, the original's +// possible outcomes form a small enumerable set, and a deterministic reimplementation can pick +// canonically and claim membership of that set -- which is a stronger claim than "behaviourally +// equivalent" and keeps a byte match reachable whenever the tiebreaks agree. Naming the set needs +// the candidate list, in the order the selector sees it. A counter cannot give that. +// +// `SelectResearchTarget 0x006c8890` walks a vector of 0x0c-stride candidates FRONT TO BACK and +// takes the first one `TryResearchCandidate 0x006c8580` accepts. There is no sort and no score in +// the walk, so the vector's order IS the priority. One entry stub per candidate therefore records +// the whole stream in arrival order; the LAST call before the walk ends is the accepted one. +// +// Three stubs, all register-transparent: +// * SelectResearchTarget -- the per-player delimiter, and it prints the current target word so a +// player that returns immediately is distinguishable from one that walks an empty list; +// * TryResearchCandidate -- one line per candidate, with both candidate words and whatever a +// std::string at +4 of either resolves to, which is how a tech gets a name here; +// * cl_SetResearchTarget -- the outcome, which is the only place the chosen NAME is in a +// register (phase 18 resolves the short-string union and pushes the char*). +// +// What it cannot do: a tail-jumping stub never sees a return value, so "the last candidate tried" +// is the accepted one only when the walk actually accepted something -- and the fallback probes +// (rows 18/19) are what say whether it did. Read the three together. + +namespace { +std::uint32_t g_researchSeq = 0; +int g_candIdx = 0; + +// MSVC std::string (0x1c): union _Bx at +0, _Mysize +0x10, _Myres +0x14; short strings live in the +// union. Prints nothing rather than guessing when the shape does not validate. +void ReadStdString(std::uintptr_t s, char* out, std::size_t cap) { + out[0] = '\0'; + if (!Readable(s, 0x18)) return; + const std::uint32_t len = U32(s + 0x10); + const std::uint32_t res = U32(s + 0x14); + if (len == 0 || len > 0x80 || res < len) return; + const std::uintptr_t p = (res < 16) ? s : static_cast(U32(s)); + if (!Readable(p, len)) return; + std::size_t n = len < cap - 1 ? len : cap - 1; + for (std::size_t i = 0; i < n; ++i) { + const char c = static_cast(U8(p + i)); + out[i] = (c >= 32 && static_cast(c) < 127) ? c : '?'; + } + out[n] = '\0'; +} + +// A candidate word is either a small integer or a pointer to an object whose +4 is the tech's name +// string. Try the string; fall back to printing the word. +void DescribeWord(std::uint32_t w, char* out, std::size_t cap) { + out[0] = '\0'; + if (w > 0x10000) { + ReadStdString(static_cast(w) + 4, out, cap); + if (out[0]) return; + ReadStdString(static_cast(w), out, cap); + if (out[0]) return; + } + std::snprintf(out, cap, "-"); +} +} // namespace + +extern "C" void* g_aiSelectOrig; +void* g_aiSelectOrig = nullptr; +extern "C" void AiSelectDetour(); + +extern "C" void AiOnSelectResearch(void* agent) { + ++g_researchSeq; + g_candIdx = 0; + const std::uintptr_t a = reinterpret_cast(agent); + // agent->+0x10 is the StrategyClient, client->+0x150 the ClientPlayer, player->+0x294 the + // current research target. Read defensively: a wrong offset must print a zero, not fault. + const std::uintptr_t client = U32(a + 0x10); + const std::uintptr_t player = client ? U32(client + 0x150) : 0; + LogF("---- airesearch sel=%u agent=0x%08x client=0x%08x player=0x%08x curTarget=0x%08x " + "species=%d ----", + g_researchSeq, static_cast(a), static_cast(client), + static_cast(player), player ? U32(player + 0x294) : 0, + player ? static_cast(U32(player + 0x5c)) : -1); +} + +extern "C" void* g_aiCandOrig; +void* g_aiCandOrig = nullptr; +extern "C" void AiCandDetour(); + +extern "C" void AiOnResearchCandidate(std::uint32_t outSlot, std::uint32_t candWord1, + std::uint32_t agent, std::uint32_t candWord0) { + char n0[64], n1[64]; + DescribeWord(candWord0, n0, sizeof n0); + DescribeWord(candWord1, n1, sizeof n1); + LogF("aicand sel=%u idx=%d agent=0x%08x slot=0x%08x w0=0x%08x(%d) '%s' w1=0x%08x(%d) '%s'", + g_researchSeq, g_candIdx, agent, outSlot, candWord0, static_cast(candWord0), n0, + candWord1, static_cast(candWord1), n1); + ++g_candIdx; +} + +extern "C" void* g_aiSetTargetOrig; +void* g_aiSetTargetOrig = nullptr; +extern "C" void AiSetTargetDetour(); + +extern "C" void AiOnSetResearchTarget(std::uint32_t namePtr) { + char buf[96]; + buf[0] = '\0'; + if (Readable(namePtr, 1)) { + std::size_t i = 0; + for (; i < sizeof buf - 1; ++i) { + if (!Readable(namePtr + i, 1)) break; + const char c = static_cast(U8(namePtr + i)); + if (!c) break; + buf[i] = (c >= 32 && static_cast(c) < 127) ? c : '?'; + } + buf[i] = '\0'; + } + LogF("airesult sel=%u candidatesTried=%d chose='%s' (ptr=0x%08x)", g_researchSeq, g_candIdx, + buf, namePtr); +} + +// SelectResearchTarget is __thiscall with one stack argument: push ECX. +asm(R"( + .text + .globl _AiSelectDetour +_AiSelectDetour: + pushfl + pushal + pushl %ecx + call _AiOnSelectResearch + addl $4, %esp + popal + popfl + jmp *_g_aiSelectOrig +)"); + +// TryResearchCandidate: ECX = the out slot, EDX = candidate word 1, and two stack arguments +// (agent, candidate word 0). After pushfl+pushal the return address is at esp+36 and those two are +// at esp+40 and esp+44; each push shifts the rest by four, so the reads walk backwards. +asm(R"( + .text + .globl _AiCandDetour +_AiCandDetour: + pushfl + pushal + pushl 44(%esp) + pushl 44(%esp) + pushl %edx + pushl %ecx + call _AiOnResearchCandidate + addl $16, %esp + popal + popfl + jmp *_g_aiCandOrig +)"); + +// cl_SetResearchTarget is __cdecl with one stack argument, the tech NAME. +asm(R"( + .text + .globl _AiSetTargetDetour +_AiSetTargetDetour: + pushfl + pushal + pushl 40(%esp) + call _AiOnSetResearchTarget + addl $4, %esp + popal + popfl + jmp *_g_aiSetTargetOrig +)"); + +asm(R"( + .text + .globl _AiBatchDetour +_AiBatchDetour: + pushfl + pushal + pushl 44(%esp) + pushl 44(%esp) + call _AiOnBatch + addl $8, %esp + popal + popfl + jmp *_g_aiBatchOrig +)"); + +bool ai_orders_config(const char* key, const char* value, std::string* err) { + if (std::strcmp(key, "aiorders") == 0) { + if (std::strcmp(value, "on") == 0) g_enabled = true; + else if (std::strcmp(value, "off") == 0) g_enabled = false; + else if (err) *err = "expected on|off"; + return true; + } + if (std::strcmp(key, "aiorders.out") == 0) { + std::snprintf(g_outPath, sizeof g_outPath, "%s", value); + return true; + } + if (std::strcmp(key, "airesearch") == 0) { + if (std::strcmp(value, "on") == 0) g_research = true; + else if (std::strcmp(value, "off") == 0) g_research = false; + else if (err) *err = "expected on|off"; + return true; + } + if (std::strcmp(key, "aiprobes") == 0) { + if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) { + g_probeInstallCount = 0; + } else if (std::strcmp(value, "all") == 0 || std::strcmp(value, "on") == 0) { + g_probeInstallCount = kProbeCount; + } else { + char* end = nullptr; + const long v = std::strtol(value, &end, 10); + if (end == value || v < 0) { + if (err) *err = "expected off|all|N"; + return true; + } + g_probeInstallCount = + static_cast(v) < kProbeCount ? static_cast(v) : kProbeCount; + } + return true; + } + return false; +} + +bool ai_orders_enabled() { return g_enabled; } + +void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) { + g_log = log; + if (!g_enabled) { + if (g_log) g_log("aiorders: disabled (aiorders=off)"); + return; + } + if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.aiorders.txt", gameDir); + g_out = std::fopen(g_outPath, "w"); + if (!g_out) LogF("aiorders: cannot open %s -- output goes to shim.log only", g_outPath); + LogF("aiorders: out=%s probes=%u of %u", g_outPath, + static_cast(g_probeInstallCount), static_cast(kProbeCount)); + + void* target = + reinterpret_cast(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch); + MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast(&AiBatchDetour), &g_aiBatchOrig); + MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1; + LogF("aiorders: batch hook StrategySim::ApplyTurnCommandBatch rva=0x%08x va=%p create=%s " + "enable=%s", + sots::addr::StrategySim_ApplyTurnCommandBatch, target, MH_StatusToString(s1), + MH_StatusToString(s2)); + if (s2 != MH_OK) + LogF("COVERAGE: aiorders batch hook NOT INSTALLED -- no block will be dumped, and an empty " + "report means the instrument failed, not that the AI emitted nothing"); + + if (g_research) { + const struct { + const char* name; + std::uint32_t rva; + void* detour; + void** tramp; + } kResearch[3] = { + {"StrategyAIAgent::SelectResearchTarget", sots::addr::StrategyAIAgent_SelectResearchTarget, + reinterpret_cast(&AiSelectDetour), &g_aiSelectOrig}, + {"StrategyAIAgent::TryResearchCandidate", sots::addr::StrategyAIAgent_TryResearchCandidate, + reinterpret_cast(&AiCandDetour), &g_aiCandOrig}, + {"cl_SetResearchTarget", sots::addr::cl_SetResearchTarget, + reinterpret_cast(&AiSetTargetDetour), &g_aiSetTargetOrig}, + }; + for (const auto& r : kResearch) { + void* t = reinterpret_cast(exeBase + r.rva); + MH_STATUS r1 = MH_CreateHook(t, r.detour, r.tramp); + MH_STATUS r2 = r1 == MH_OK ? MH_EnableHook(t) : r1; + LogF("airesearch: %s rva=0x%08x va=%p create=%s enable=%s", r.name, r.rva, t, + MH_StatusToString(r1), MH_StatusToString(r2)); + if (r2 != MH_OK) + LogF("COVERAGE: airesearch hook %s NOT INSTALLED -- a silent capture below means " + "the instrument failed, not that the selector did nothing", + r.name); + } + } else { + LogF("airesearch: disabled (airesearch=off)"); + } + + for (std::size_t i = 0; i < g_probeInstallCount; ++i) { + void* t = reinterpret_cast(exeBase + kProbes[i].rva); + MH_STATUS p1 = MH_CreateHook(t, kProbes[i].stub, kProbes[i].trampoline); + MH_STATUS p2 = p1 == MH_OK ? MH_EnableHook(t) : p1; + g_installed[i] = (p2 == MH_OK); + LogF("aiprobe: %u %s rva=0x%08x va=%p create=%s enable=%s", static_cast(i), + kProbes[i].name, kProbes[i].rva, t, MH_StatusToString(p1), MH_StatusToString(p2)); + if (!g_installed[i]) + LogF("COVERAGE: aiprobe %s NOT INSTALLED -- its count is meaningless, not zero", + kProbes[i].name); + } +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/ai_orders.h b/src/shim/hooks/ai_orders.h new file mode 100644 index 0000000..54452c9 --- /dev/null +++ b/src/shim/hooks/ai_orders.h @@ -0,0 +1,66 @@ +// Lane L4 -- what the AI actually submits, read out of the live game. +// +// WHY A BLOCK DUMP AND NOT MORE BUMP COUNTING. +// +// Every previous lane that looked at AI emission looked at `ModCount`: a counter that advances +// once per applied command out of the paying half of the command table. That instrument answers +// "how many commands" and cannot answer "which lists, how many elements, what values" -- and the +// second question is the one a reimplementation has to answer, because our `game/ai` models the +// shape of a command block and the cost of an element and *decides nothing*. Nobody has ever +// looked at a real AI block. +// +// `Game::StrategySim::ApplyTurnCommandBatch` receives `(blocks, n)` as two stack arguments, and at +// its entry every submitted player block is complete and in memory, laid out at a fixed 0x1b4 +// stride. One register-transparent entry stub therefore dumps the entire turn's command traffic in +// one place: six flag-gated single commands and twenty-seven counted lists per player, with the +// element bytes. +// +// TWO INSTRUMENTS, AND WHY THE SECOND ONE EXISTS. +// +// The block says what was emitted. It does not say *which task* emitted it, and it cannot +// distinguish "this task never ran" from "this task ran and emitted nothing" -- opposite answers, +// and the campaign has already paid once for reading a zero as the first when it was the second +// (method rule 20). So the second instrument is a set of entry counters on the task bodies and the +// three pass-gated emission sites, in the register-transparent asm-stub style lane H established. +// +// One of them is special. `StrategyAIAgent::RunTaskList` takes `pass` as its third `__cdecl` stack +// argument, so its stub reads that argument and records it in a global before tail-jumping. Every +// later probe hit is then attributed to the pass that was running, which is what turns "pass 0 +// emits nothing" from an inference about bump arithmetic into a measurement. The global is stale +// once RunTaskList returns; the report says so rather than pretending otherwise, and the event ring +// carries the RunTaskList sequence number so a reader can see exactly where each hit fell. +// +// WHAT NEITHER INSTRUMENT CAN DO, stated because it is the price of the design: +// +// * An entry counter cannot see a branch *inside* the function. `IsClaimedByAnotherTask` is +// probed for its call count only; whether its steal branch fires is NOT settled here. +// * A tail-jumping stub never regains control, so nothing here reports a return value or a cost. +// * The dump reads element bytes, not element types. It emits a fixed window of raw bytes plus +// the same bytes as ints; decoding to the element records happens offline, in the report, so a +// wrong record shows up as a wrong value instead of being baked into the instrument. +// +// AND ONE SELF-CHECK, because a wrong container layout prints a confident zero (method rule 1): +// every list is measured twice, by walking its node chain and by reading its `_Mysize`, and a +// disagreement is logged as MISMATCH rather than being silently resolved in favour of either. +#pragma once + +#include +#include +#include + +namespace shim::hooks { + +// `aiorders=on|off`, `aiprobes=off|all|N`, `aiorders.out=`. Returns false if `key` is not +// ours. `aiprobes=N` installs the first N in the fixed table order, so the set is bisectable in one +// build if the rule-19 control ever fails. +bool ai_orders_config(const char* key, const char* value, std::string* err); + +bool ai_orders_enabled(); + +// Installs the batch-dump detour and the entry probes. Call after MH_Initialize. +void install_ai_orders(std::uintptr_t exe_base, const char* game_dir, void (*log_line)(const char*)); + +// Writes any event-ring entries not yet on disk. Safe to call from anywhere, including shutdown. +void ai_orders_flush(void (*log_line)(const char*)); + +} // namespace shim::hooks diff --git a/src/shim/main.cpp b/src/shim/main.cpp index 076a4e0..1802396 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -28,6 +28,7 @@ #include "shim/hooks/probe_entry.h" #include "shim/hooks/tail_rng.h" #include "shim/hooks/tech_effects.h" +#include "shim/hooks/ai_orders.h" #include "shim/hooks/watchpoints.h" #include "shim/trace/hook.h" #include "shim/trace/selftest.h" @@ -90,6 +91,9 @@ Config ReadConfig() { if (shim::hooks::probe_config(p, val, &probe_n)) { Log("config: %s=%s -> %u lane-H entry probes", p, val, static_cast(probe_n)); + } else if (shim::hooks::ai_orders_config(p, val, &err)) { + if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str()); + else Log("config: %s=%s", p, val); } else if (shim::hooks::watch_apply_config(p, val, &err)) { if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str()); else Log("config: %s=%s", p, val); @@ -310,6 +314,12 @@ void InstallHooks(shim::trace::Tracer& tracer) { // Lane W2: hardware data-write watchpoints. One MinHook detour (the arming point); the // watchpoints themselves modify no code at all. Off unless `watch=on` (rule 19). shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine); + // Lane L4: the AI command-block dump plus its entry probes. Off unless `aiorders=on`, and + // `aiorders=on` alone installs exactly ONE detour -- `aiprobes=` adds the rest, so the two + // halves of the instrument can be given separate rule-19 controls. Its batch target + // (ApplyTurnCommandBatch) is a different function from the watchpoint module's arming point + // (ApplyAllTurnCommands, its only caller), so the two never contend for a MinHook target. + shim::hooks::install_ai_orders(exeBase, g_dir, &ShimLogLine); // Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler. // Installed last so it is nowhere near the template hooks it is meant to measure. @@ -401,6 +411,7 @@ void Shim_Init(HMODULE self) { void Shim_Shutdown() { shim::hooks::watch_flush(&ShimLogLine); + shim::hooks::ai_orders_flush(&ShimLogLine); Log("%s", shim::fpu::summary().c_str()); shim::trace::Tracer& tracer = shim::trace::Tracer::instance(); if (tracer.is_open()) { diff --git a/src/shim/shim.cfg.l4control b/src/shim/shim.cfg.l4control new file mode 100644 index 0000000..6b0a4ae --- /dev/null +++ b/src/shim/shim.cfg.l4control @@ -0,0 +1,40 @@ +# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE. +# +# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off), +# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top +# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the +# pairwise single-key differences are what localise a failure to one half of the instrument. +# +# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have +# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make +# every byte comparison below meaningless. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=off +watch=off +aiorders.out=C:\SOTS\shim.aiorders.txt +aiorders=off +aiprobes=off +airesearch=off diff --git a/src/shim/shim.cfg.l4dump b/src/shim/shim.cfg.l4dump new file mode 100644 index 0000000..e3b6df4 --- /dev/null +++ b/src/shim/shim.cfg.l4dump @@ -0,0 +1,40 @@ +# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE. +# +# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off), +# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top +# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the +# pairwise single-key differences are what localise a failure to one half of the instrument. +# +# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have +# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make +# every byte comparison below meaningless. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=off +watch=off +aiorders.out=C:\SOTS\shim.aiorders.txt +aiorders=on +aiprobes=off +airesearch=off diff --git a/src/shim/shim.cfg.l4off b/src/shim/shim.cfg.l4off new file mode 100644 index 0000000..aea4df0 --- /dev/null +++ b/src/shim/shim.cfg.l4off @@ -0,0 +1,6 @@ +# Lane L4, the pure baseline: the shim DLL loads and forwards Bink, and installs NOTHING -- not the +# M0 hook, not the template hooks, not the draw-site detours, not lane H's probes, not this lane's. +# Its only job is to prove that VM145, which is a ZFS clone of the reference guest and has never +# been checked for byte fidelity, reproduces the campaign's published autosave oracle. If it does +# not, nothing measured on this guest can be compared with anything. +hooks=off diff --git a/src/shim/shim.cfg.l4probes b/src/shim/shim.cfg.l4probes new file mode 100644 index 0000000..2a428b1 --- /dev/null +++ b/src/shim/shim.cfg.l4probes @@ -0,0 +1,40 @@ +# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE. +# +# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off), +# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top +# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the +# pairwise single-key differences are what localise a failure to one half of the instrument. +# +# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have +# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make +# every byte comparison below meaningless. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=off +watch=off +aiorders.out=C:\SOTS\shim.aiorders.txt +aiorders=on +aiprobes=all +airesearch=off diff --git a/src/shim/shim.cfg.l4research b/src/shim/shim.cfg.l4research new file mode 100644 index 0000000..ae67ffb --- /dev/null +++ b/src/shim/shim.cfg.l4research @@ -0,0 +1,40 @@ +# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE. +# +# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off), +# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top +# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the +# pairwise single-key differences are what localise a failure to one half of the instrument. +# +# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have +# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make +# every byte comparison below meaningless. +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=off +watch=off +aiorders.out=C:\SOTS\shim.aiorders.txt +aiorders=on +aiprobes=all +airesearch=on diff --git a/tests/game_ai/CMakeLists.txt b/tests/game_ai/CMakeLists.txt index 4d02a51..ad2cb34 100644 --- a/tests/game_ai/CMakeLists.txt +++ b/tests/game_ai/CMakeLists.txt @@ -28,3 +28,12 @@ target_link_libraries(game_ai_test_agent PRIVATE sots_game_ai) target_include_directories(game_ai_test_agent PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(game_ai_test_agent PRIVATE -Wall -Wextra -pedantic) add_test(NAME game_ai_agent COMMAND game_ai_test_agent) + +# The two AI command blocks read out of the RUNNING game (lane L4). Distinct from test_orders.cpp +# on purpose: that file is what static reading predicted, this one is what the game did, and the +# agreement between them is only evidence while the two stay independent. +add_executable(game_ai_test_live_blocks test_live_blocks.cpp) +target_link_libraries(game_ai_test_live_blocks PRIVATE sots_game_ai) +target_include_directories(game_ai_test_live_blocks PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_options(game_ai_test_live_blocks PRIVATE -Wall -Wextra -pedantic) +add_test(NAME game_ai_live_blocks COMMAND game_ai_test_live_blocks) diff --git a/tests/game_ai/test_live_blocks.cpp b/tests/game_ai/test_live_blocks.cpp new file mode 100644 index 0000000..95bcc29 --- /dev/null +++ b/tests/game_ai/test_live_blocks.cpp @@ -0,0 +1,248 @@ +// The two AI command blocks that were read out of the running game, element by element. +// +// Every number in this file was DUMPED, not derived. Lane L4 put a register-transparent entry stub +// on the batch applier -- the point at which every player's submitted block is complete and in +// memory at a fixed stride -- and printed the six gates, the twenty-seven list lengths and the +// element bytes for all eight block slots, on two workloads, on a guest whose autosaves reproduced +// the campaign's published oracle byte for byte with the instrument installed. +// +// WHY THIS FILE IS SEPARATE FROM test_orders.cpp. That file's expectations were written from the +// instruction stream and the corpus saves before any of this code existed, and they must stay that +// way: it is the record of what static reading predicted. This file is the record of what the game +// did. Where they agree -- and on the arithmetic they agree exactly, twelve and twelve -- the +// agreement means something precisely because the two were produced by different instruments and +// neither was fitted to the other. +// +// The three things the live capture added that no amount of reading had produced: +// +// * A LIST-23 ELEMENT ON BOTH TURNS. Nothing in eleven corpus saves had ever populated a list in +// the free half of the table, so the whole 17..27 row of the cost model was a hypothesis in +// the rule-6 sense. It is now exercised: the AI submits one every turn, and the counter still +// lands on the measured twelve, so the element really is free. +// * THE IDS ARE CLIENT-ALLOCATED AND TRAVEL IN THE COMMAND. The build order names design 18 -- +// an id the server has not yet issued when the block is submitted -- and the fleet order names +// fleet 34, an object that does not exist in the input save at all. A reimplementation cannot +// assign these on apply; it has to allocate them where the original does or every id in the +// resulting save is wrong. +// * THE SAME SYSTEM IN THREE COMMANDS. Build, system rates and the population command all name +// system 288, the AI's home. They are one decision expressed three times. +#include "game/ai/orders.h" + +#include +#include +#include + +using namespace sots::ai; + +namespace { + +int g_checks = 0; +int g_fails = 0; + +void check(bool ok, const std::string& what) { + ++g_checks; + if (!ok) { + ++g_fails; + std::printf("FAIL: %s\n", what.c_str()); + } +} + +// The AI's home system, named by three different commands in the same block. +constexpr int kHomeSystem = 288; +// The design the AI created on turn 1 and built from on both turns. Not a multiple of sixteen and +// not from the save's master id counter -- the other new design that turn, a monster faction's, +// took 1712 from that counter while this one took 18 from somewhere the save does not show. +constexpr int kHonorLance = 18; +// The fleet the turn-2 order names. It is NOT the fleet that exists in the input save (1744); it +// is the one that exists in the OUTPUT save, so the client had already allocated it. +constexpr int kBetaFleet = 34; + +// --------------------------------------------------------------------------------------------- +// Turn 1 -> 2, captured from `turn1-state.sav` + one End Turn +// --------------------------------------------------------------------------------------------- + +std::vector CaptureTurnOneToTwo() { + std::vector blocks; + + // pid 16, the human. Ordered nothing all turn and still submits a block, because the send + // buffer sets the rate gate from live player state whatever the player did -- and the value it + // carried was 0.25, the rate the human never touched. + OrderClient human(16); + human.EndTurn(0.25f); + blocks.push_back(human.block()); + + // pid 32, the only AI with an empire. + OrderClient ai(32); + ai.SetResearchTarget(144); + ai.OrderUnmodelled(CommandList::NewDesigns, 1); // list 1: the "Honor Lance" hull itself + ai.OrderBuild(BuildOrder{1, kHonorLance, kHomeSystem, 0}); + ai.OrderSystemRates(SystemRatesOrder{kHomeSystem, 0, 1.0f, 0, 0, 0, 0, 0}); + ai.OrderUnmodelled(CommandList::PopulationCmds, 1); // list 23, free half + ai.EndTurn(0.8f); + blocks.push_back(ai.block()); + + // pid 496 and 512: the two AI players that own nothing. They still run a full two-pass task + // sweep and they still pick a research target. + OrderClient dormantA(496); + dormantA.SetResearchTarget(90); + dormantA.EndTurn(0.8f); + blocks.push_back(dormantA.block()); + + OrderClient dormantB(512); + // The one field in the whole turn that is not reproducible: three runs of this exact workload + // produced three different targets for this player. 288 is what the instrumented run recorded. + dormantB.SetResearchTarget(288); + dormantB.EndTurn(0.8f); + blocks.push_back(dormantB.block()); + + // The four monster factions occupy block slots and never touch them: player id zero, every + // gate clear, all twenty-seven lists empty. They are not modelled as blocks here because a + // block with no gate set is indistinguishable from an absent one for every purpose this + // module has -- which is itself the finding. + return blocks; +} + +void TestTurnOneToTwo() { + const std::vector blocks = CaptureTurnOneToTwo(); + const TurnCommandBlock& ai = blocks[1]; + + check(blocks.size() == 4, "four players submitted a block"); + check(ai.ElementCount(CommandList::NewDesigns) == 1, "1->2: one design command"); + check(ai.ElementCount(CommandList::Build) == 1, "1->2: one build order"); + check(ai.ElementCount(CommandList::SystemRates) == 1, "1->2: one system-rates command"); + check(ai.ElementCount(CommandList::PopulationCmds) == 1, "1->2: one population command"); + // The whole fleet-order group is absent on the turn the fleet is created. + check(ai.ElementCount(CommandList::FleetMove) == 0, "1->2: no fleet move"); + check(ai.ElementCount(CommandList::FleetTask) == 0, "1->2: no fleet task"); + check(ai.ElementCount(CommandList::List10) == 0, "1->2: no list-10 command"); + // Nothing else at all: four lists out of twenty-seven. + int nonEmpty = 0; + for (int n = 1; n <= kCommandListCount; ++n) + nonEmpty += ai.ElementCount(static_cast(n)) > 0 ? 1 : 0; + check(nonEmpty == 4, "1->2: exactly four of the twenty-seven lists are non-empty"); + + check(ai.build[0].designId == kHonorLance, "the build order names the design the block creates"); + check(ai.build[0].systemId == kHomeSystem, "and builds it at the home system"); + check(ai.build[0].ordinal == 1, "the ordinal is 1 on the AI's first build"); + check(ai.systemRates[0].systemId == kHomeSystem, "system rates name the same system"); + + // Three targets, not one: every AI player picks one on the first turn, the human does not. + int targets = 0, rates = 0; + for (const auto& b : blocks) { + targets += b.hasResearchTarget ? 1 : 0; + rates += b.hasResearchRate ? 1 : 0; + } + check(targets == 3, "three research-target gates, one per AI player"); + check(rates == 4, "four research-rate gates, one per submitted block"); + check(!blocks[0].hasResearchTarget, "the human set no research target"); + + const ModCountCost cost = TurnModCountDelta(blocks); + check(cost.exact, "1->2 cost is exact"); + check(cost.bumps == 12, "1->2 costs the measured 12"); + check(BlockModCountCost(blocks[2]).bumps == 2, "a dormant AI costs two: rate and target"); +} + +// --------------------------------------------------------------------------------------------- +// Turn 2 -> 3, captured from `ref-turn2.sav` + one End Turn +// --------------------------------------------------------------------------------------------- + +std::vector CaptureTurnTwoToThree() { + std::vector blocks; + + OrderClient human(16); + human.EndTurn(0.25f); + blocks.push_back(human.block()); + + OrderClient ai(32); + // No research target this turn: the gate is clear on all four blocks, and no player's saved + // target changes across the turn. + ai.OrderBuild(BuildOrder{2, kHonorLance, kHomeSystem, 0}); + ai.OrderSystemRates(SystemRatesOrder{kHomeSystem, 0, 1.0f, 0, 0, 0, 0, 0}); + ai.OrderUnmodelled(CommandList::List10, 1); + ai.IssueAiFleetOrder(kBetaFleet, {272}); + ai.OrderUnmodelled(CommandList::PopulationCmds, 1); + ai.EndTurn(0.8f); + blocks.push_back(ai.block()); + + OrderClient dormantA(496); + dormantA.EndTurn(0.8f); + blocks.push_back(dormantA.block()); + + OrderClient dormantB(512); + dormantB.EndTurn(0.8f); + blocks.push_back(dormantB.block()); + return blocks; +} + +void TestTurnTwoToThree() { + const std::vector blocks = CaptureTurnTwoToThree(); + const TurnCommandBlock& ai = blocks[1]; + + check(ai.ElementCount(CommandList::Build) == 1, "2->3: one build order"); + check(ai.ElementCount(CommandList::SystemRates) == 1, "2->3: one system-rates command"); + check(ai.ElementCount(CommandList::FleetMove) == 1, "2->3: one fleet move"); + check(ai.ElementCount(CommandList::List10) == 1, "2->3: one list-10 command"); + check(ai.ElementCount(CommandList::FleetTask) == 2, "2->3: TWO fleet-task elements"); + check(ai.ElementCount(CommandList::PopulationCmds) == 1, "2->3: one population command"); + check(ai.ElementCount(CommandList::NewDesigns) == 0, "2->3: no new design -- it reuses design 18"); + int nonEmpty = 0; + for (int n = 1; n <= kCommandListCount; ++n) + nonEmpty += ai.ElementCount(static_cast(n)) > 0 ? 1 : 0; + check(nonEmpty == 6, "2->3: exactly six of the twenty-seven lists are non-empty"); + + // The pair that AI2 predicted from the call site and that this capture read off the values: + // one fleet, two elements, modes 0 and 1, and the same fleet id the route names. + check(ai.fleetTasks.size() == 2, "two fleet-task elements"); + check(ai.fleetTasks[0].fleetId == kBetaFleet && ai.fleetTasks[1].fleetId == kBetaFleet, + "both name the SAME fleet"); + check(ai.fleetTasks[0].mode == 0 && ai.fleetTasks[1].mode == 1, "modes 0 and 1, in that order"); + check(ai.fleetMoves[0].fleetId == kBetaFleet, "and the route names that fleet too"); + check(ai.fleetMoves[0].route.size() == 1, "the route is one hop"); + check(ai.build[0].ordinal == 2, "the ordinal is 2 on the AI's second build"); + + int targets = 0; + for (const auto& b : blocks) targets += b.hasResearchTarget ? 1 : 0; + check(targets == 0, "no research target is set on this turn by anyone"); + + const ModCountCost cost = TurnModCountDelta(blocks); + check(cost.exact, "2->3 cost is exact"); + check(cost.bumps == 12, "2->3 costs the measured 12"); + check(BlockModCountCost(ai).bumps == 7, "seven of them are the AI's block"); +} + +// --------------------------------------------------------------------------------------------- +// What the capture proves about the cost table itself +// --------------------------------------------------------------------------------------------- + +void TestFreeHalfExercisedLive() { + // Both turns carry a list-23 element and both turns cost exactly twelve. Remove the element + // and the total does not move: that is the free half of the table being exercised by a real + // workload for the first time, from both directions. + for (int turn = 0; turn < 2; ++turn) { + std::vector with = + turn == 0 ? CaptureTurnOneToTwo() : CaptureTurnTwoToThree(); + std::vector without = with; + without[1].unmodelled[static_cast(CommandList::PopulationCmds) - 1] = 0; + check(with[1].ElementCount(CommandList::PopulationCmds) == 1, "the capture carries list 23"); + check(TurnModCountDelta(with).bumps == TurnModCountDelta(without).bumps, + "removing the list-23 element changes nothing -- list 23 is free, measured"); + check(TurnModCountDelta(with).bumps == 12, "and the total is the measured 12 either way"); + } + + // The counter-check, so the previous one is not vacuous: an element in the paying half does + // move the total. + std::vector b = CaptureTurnTwoToThree(); + const int before = TurnModCountDelta(b).bumps; + b[1].AddUnmodelled(CommandList::List16, 1); + check(TurnModCountDelta(b).bumps == before + 1, "a list-16 element does cost one"); +} + +} // namespace + +int main() { + TestTurnOneToTwo(); + TestTurnTwoToThree(); + TestFreeHalfExercisedLive(); + std::printf("%s: %d checks, %d failures\n", g_fails ? "FAILED" : "ok", g_checks, g_fails); + return g_fails ? 1 : 0; +} diff --git a/tests/game_ai/test_orders.cpp b/tests/game_ai/test_orders.cpp index 0d0f578..6923966 100644 --- a/tests/game_ai/test_orders.cpp +++ b/tests/game_ai/test_orders.cpp @@ -211,7 +211,11 @@ void TestReferenceTurnTwoToThree() { ai.OrderSystemRates(SystemRatesOrder{}); ai.OrderBuild(BuildOrder{}); ai.OrderUnmodelled(CommandList::List10, 1); - ai.IssueAiFleetOrder(1744, {288}); + // The ids here were placeholders when this case was written from the saves. The live capture + // (tests/game_ai/test_live_blocks.cpp) read the real ones: the order names fleet 34 -- an + // object the INPUT save does not contain -- and one hop to system 272. Corrected in place + // rather than left as an illustration, because a wrong id in a test is how a wrong id spreads. + ai.IssueAiFleetOrder(34, {272}); ai.EndTurn(0.8f); blocks.push_back(ai.block()); @@ -246,7 +250,7 @@ void TestReferenceTurnOneToTwo() { OrderClient ai(32); ai.SetResearchRate(0.8f); - ai.SetResearchTarget(1); // IND_Waldo + ai.SetResearchTarget(144); // IND_Waldo -- tech id read live, not a placeholder ai.OrderUnmodelled(CommandList::NewDesigns, 1); // the new hull ai.OrderBuild(BuildOrder{}); // and the order to build it ai.OrderSystemRates(SystemRatesOrder{}); @@ -255,13 +259,14 @@ void TestReferenceTurnOneToTwo() { OrderClient dormantA(496); dormantA.SetResearchRate(0.8f); - dormantA.SetResearchTarget(2); // DRV_PlsFiss + dormantA.SetResearchTarget(90); // DRV_PlsFiss, read live dormantA.EndTurn(0.8f); blocks.push_back(dormantA.block()); OrderClient dormantB(512); dormantB.SetResearchRate(0.8f); - dormantB.SetResearchTarget(3); // BIO_GnMod + dormantB.SetResearchTarget(288); // read live; and see test_live_blocks.cpp -- this + // one player's target is NOT reproducible run to run dormantB.EndTurn(0.8f); blocks.push_back(dormantB.block());