From 45bf0859812601b1f7b5d7741a65afbf075f8ec3 Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 17:07:15 -0400 Subject: [PATCH 1/6] L4: predictions for the live AI order capture, before the module exists --- docs/L4-predictions.md | 246 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/L4-predictions.md diff --git a/docs/L4-predictions.md b/docs/L4-predictions.md new file mode 100644 index 0000000..19390e1 --- /dev/null +++ b/docs/L4-predictions.md @@ -0,0 +1,246 @@ +# 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). From 4a5c218a35a08e02030d8661aab9ff8d631b0738 Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 18:01:19 -0400 Subject: [PATCH 2/6] L4: read the AI's command block out of the running game New shim module src/shim/hooks/ai_orders.{h,cpp}: one register-transparent entry stub on StrategySim::ApplyTurnCommandBatch dumps every submitted TurnCommands block (six gates, 27 list lengths, element bytes) at the point where all of them are complete in memory; sixteen entry probes, with RunTaskList's stub recording the pass so every later hit is pass-attributed. Two workloads on VM145, one End Turn each. The rule-19 control passed with all seventeen detours installed: both autosaves byte-identical to the published oracle. What the AI actually emits, and three things no reading had produced: - a list-23 element on EVERY turn, the first element ever observed in the free half of the cost table -- and both turns still cost the measured 12; - the ids in AI commands are client-allocated and travel in the command (design 18, fleet 34; neither exists in the input save); - pass 0 emits nothing, measured from element counts rather than inferred. tests/game_ai/test_live_blocks.cpp rebuilds both captured blocks through the public OrderClient API and asserts the list profile, element values, gate counts and ModCount total: 44 checks. Kept separate from test_orders.cpp, which stays the record of what static reading predicted. Gates: clean_room_check OK, host ctest 55/55, CT111 shim cross-build exit 0. --- CMakeLists.txt | 1 + docs/L4-ai-orders.md | 81 +++++ include/generated/sots_addresses.h | 44 ++- src/shim/hooks/ai_orders.cpp | 490 +++++++++++++++++++++++++++++ src/shim/hooks/ai_orders.h | 66 ++++ src/shim/main.cpp | 11 + src/shim/shim.cfg.l4control | 39 +++ src/shim/shim.cfg.l4dump | 39 +++ src/shim/shim.cfg.l4off | 6 + src/shim/shim.cfg.l4probes | 39 +++ tests/game_ai/CMakeLists.txt | 9 + tests/game_ai/test_live_blocks.cpp | 248 +++++++++++++++ tests/game_ai/test_orders.cpp | 13 +- 13 files changed, 1081 insertions(+), 5 deletions(-) create mode 100644 docs/L4-ai-orders.md create mode 100644 src/shim/hooks/ai_orders.cpp create mode 100644 src/shim/hooks/ai_orders.h create mode 100644 src/shim/shim.cfg.l4control create mode 100644 src/shim/shim.cfg.l4dump create mode 100644 src/shim/shim.cfg.l4off create mode 100644 src/shim/shim.cfg.l4probes create mode 100644 tests/game_ai/test_live_blocks.cpp 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..4a1fa92 --- /dev/null +++ b/docs/L4-ai-orders.md @@ -0,0 +1,81 @@ +# 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. +`determinism-oracle.md` verifies the oracle for the `ref-turn2` workload and only for that one; this +lane is the first to run the turn-1 workload more than once, and it is now on the record that a +byte comparison on that workload is a comparison against noise. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index b38e10d..767ba52 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 @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 2d61848, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1913,6 +1913,48 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0; constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0; // note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified] constexpr uint32_t SNMAllCombatDone_layout = 0x00624758; +// site call RNG_NextInt (0x004271c0) inside SVSOSwarmQueen_RegisterHives, ECX = GetGame()->RNG(+0x16c) + 4, bound = HI - LO passed BY POINTER at [ebp-0x14]; return address 0x00527714, then `add eax,edi` where edi = frame + LO. ONE STRATEGIC-GENERATOR WORD PER NEW HIVE, taken inside StrategyServer::BeginProcessTurn -- outside BOTH turn drivers and before either. Corrects svsctob-writers.md, which put the call at 0x0052770c (that is the `mov [ebp-0x14],esi` storing the bound) [verified] +constexpr uint32_t SVSOSwarmQueen_RegisterHives_DrawSite = 0x0012770f; +// site `inc DWORD PTR [esi+0x8]` -- the NextQ slip. FOUR gate-failure edges converge here: 0x005277e3 (frame <= *SwarmQueen_GateFrameFloor_ptr), 0x005277fe (queens count >= the per-turn allowance), 0x00527848 (age < *SwarmQueen_GateQueenAge_ptr), 0x0052784f (hive's system handle is 0). Consumes NO generator word on any of them; TickHives calls no RNG primitive anywhere in its body (0x00527770..0x005279a1 read whole) [verified] +constexpr uint32_t SVSOSwarmQueen_TickHives_SlipSite = 0x0012785a; +// data int* -- pointer to the LOW end of the new-hive queen-countdown offset. RegisterHives computes nextQ = frame + *this + RNG_NextInt(*SwarmQueen_HiveNextQOffsetHi_ptr - *this). Behind a pointer, so no .text or .data reference initialises it in a form a static reader can follow; the value is read live by the shim at RegisterHives entry [mapped] +constexpr uint32_t SwarmQueen_HiveNextQOffsetLo_ptr = 0x006e0204; +// data int* -- pointer to the HIGH end of the new-hive queen-countdown offset. The RNG_NextInt bound is (*this - *SwarmQueen_HiveNextQOffsetLo_ptr) and NextInt is INCLUSIVE of its bound, so the countdown lands in [frame+LO, frame+HI] [mapped] +constexpr uint32_t SwarmQueen_HiveNextQOffsetHi_ptr = 0x006e0208; +// data int* -- TickHives gate 1: `if (GetGame()->Frame(+0xc) <= *this) slip`. No queen can spawn at or below this frame [mapped] +constexpr uint32_t SwarmQueen_GateFrameFloor_ptr = 0x006e0210; +// data int* -- read in TickHives' SPAWN arm (0x005278d8), after a queen has been created: when *this <= 0 the vector at queen+0x30 is trimmed to 0 entries, otherwise to *this. Never exercised on any corpus save (no hive has ever had a queen) [mapped] +constexpr uint32_t SwarmQueen_SpawnListCap_ptr = 0x006e0220; +// data int* -- TickHives gate 3: an age in turns, compared against `GetGame()->Frame - resolvedQueen->+0xc`; below it the hive slips instead of spawning [mapped] +constexpr uint32_t SwarmQueen_GateQueenAge_ptr = 0x006e0228; +// field std::vector -- {_Myfirst 0x10, _Mylast 0x14, _Myend 0x18, _Alval 0x1c}, element stride 0x10: {void* vptr = 0x009f1a68, StarSystem* sys(+4), int nextQ(+8), Queen* queen(+0xc)}. The WIRE order is HiveID / QueenID / NextQ, which is NOT the member order [verified] +constexpr uint32_t SVSOSwarmQueen_off_Hives = 0x00000010; +// field std::vector at +0x20..+0x24, element stride 0xc -- TickHives' second gate divides the byte span by 12. Empty on every corpus save [verified] +constexpr uint32_t SVSOSwarmQueen_off_Queens = 0x00000020; +// field int -- the difficulty tier, wire tag "CDiff". Set to -1 by the ctor 0x0051a820 and written ONLY by SVSOSlaversRefuel_UpdateDifficultyTier, which stores (scanIndex - 1) and only when it differs. Reachable values are 0 (frame 1..49) and 1 (frame 50..99) ONLY: frame <= 0 exits at index 0 and frame >= 100 walks off the end of the 3-entry threshold table, so BOTH edges store nothing and the tier can never reach 2 [verified] +constexpr uint32_t SVSOSlaversRefuel_off_CDiff = 0x00000038; +// thiscall void __thiscall Game::AITColonize::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITColonizeGoal::Execute is the same address. 48 bytes; a forwarder to the parameterised worker 0x0068b280 called as worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10) with `edi = this->+0xc` passed as an IMPLICIT REGISTER ARGUMENT (whole-program-optimised custom convention; a reimplementation that ports only the stack arguments passes garbage). Reaches the colonize order (list 7) at depth 4 via 0x0068b280 -> 0x006930f0 -> 0x00578ff0 -> 0x00769640 [unverified] +constexpr uint32_t AITColonize_Execute = 0x0028b400; +// thiscall void __thiscall Game::AITEscortGateInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITEscortGateInvadeGoal::Execute is the same address. 80 bytes; forwards to 0x0068c5d0 as __fastcall(ecx = this->+0xc, edx = agent) plus 8 stack arguments. Reaches list 7 at depth 4 [unverified] +constexpr uint32_t AITEscortGateInvade_Execute = 0x0028c7c0; +// thiscall void __thiscall Game::AITInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITInvadeGoal::Execute is the same address. 160 bytes; forwards to 0x0068d460 with 11 arguments, then maintains this->+0x38 and this->+0x39. +0x39 is computed at 0x0068d80f-0x0068d82a as (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc)) and is what IAITask slot 11 returns for this class. Reaches list 14 at depth 5 [unverified] +constexpr uint32_t AITInvade_Execute = 0x0028d7a0; +// thiscall void __thiscall Game::AITNodeBore::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Does its setup (0x00685810) and its finaliser (0x0068e090) only when pass == 0, and forwards `pass` to 0x0068a520 in both passes. Reaches list 14 at depth 5 via 0x0068a520 -> 0x006ceef0 -> 0x006c16c0 -> 0x006987e0 -> 0x007634d0. Zuul-flavoured task (the node-bore is a Zuul mechanic), so on a non-Zuul board it may be absent from the task list entirely [unverified] +constexpr uint32_t AITNodeBore_Execute = 0x0028e590; +// thiscall void __thiscall Game::AITRaid::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. THE ONE TASK WHOSE PASS-0 BEHAVIOUR IS NOT SETTLED: at 0x0068e89e it calls 0x006b76a0 and then at 0x0068e8b8 loops the list-16 order method 0x007635f0(client, fleetId, 1) over the returned fleets, and NEITHER the call nor the loop carries a `pass` guard of its own. AI3 §2.3's argument says the fleet vector at [ebp-0x28] is empty on pass 0 because it is downstream of the same hub, but [ebp-0x28] was never traced to closure. This address exists as an address so that a zero at 0x007635f0 can be read: entered-and-silent and never-entered are opposite answers (method rule 20) [unverified] +constexpr uint32_t AITRaid_Execute = 0x0028e670; +// thiscall void __thiscall Game::AITAdvanceIdleShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Whole body is pass-1 only: `if (pass != 1) return` at 0x0068f25a. Table priority 0, so it is always the LAST task in the descending-priority sweep -- the AI sweeps up whatever is still idle only after every other task has taken both its minimum and its desired force, which is what the two-tier quota model predicts [unverified] +constexpr uint32_t AITAdvanceIdleShips_Execute = 0x0028f230; +// thiscall void __thiscall Game::AITBuildDeepScanShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6 via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0, the same chain as AITBuildPoliceShips [unverified] +constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0; +// thiscall void __thiscall Game::AITBuildPoliceShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6, identical chain to AITBuildDeepScanShips. One of the two candidate producers of the single list-3 element the reference AI emits on turn 1 [unverified] +constexpr uint32_t AITBuildPoliceShips_Execute = 0x00290380; +// thiscall bool __thiscall Game::StrategyClient::OrderList16(int objectId, bool flag) -- RET 8. THE LIST-16 PRODUCER. Opens with `cmp byte [this+0x15c], 0` -- the End-Turn latch StrategyClient::EndTurn 0x00783be0 sets at 0x00783c59 before BuildTurnCommands runs -- so like every other order method it refuses once the turn has been submitted. Element record for list 16 is {i32, bool} (lane Q), matching the (objectId, flag) pair; list 16 is in the PAYING half of the ModCount table (bump at 0x00821e23 in its applier 0x00821e20). Reached from AITRaid::Execute at depth 1. The method is named for the list it fills, NOT for what the flag means: no save has ever carried a list-16 element and nothing has been observed [unverified] +constexpr uint32_t StrategyClient_OrderList16 = 0x003635f0; +// offset DifficultyMods rec -- the per-player difficulty record, 0x1c bytes: {int id; float ai[3]; float other[3]}. THE RECORD IS PER-PLAYER AND REACHABLE FROM A ServerPlayer, which is the correction this lane makes: src/shim/hooks/compute_budget.h's coverage note says the difficulty row is 'not reachable from a ServerPlayer, so the two relevant entries are fitted constants measured from the B1 trace rather than snapshotted inputs'. It is reachable. DifficultyMods_Select 0x0059b490 is called as `Select(p->+0x36c, p)` by every one of the three consumers (ComputeBudget 0x0086338b, StrategyServer_IncomeDifficultyMod 0x0080f470, 0x0080e229), and LoadDifficultyRow 0x005a3990 fills it from ServerPlayer::Read 0x008804d0 at 0x00880fa3 gated on 0 <= aidf < 3 [verified] +constexpr uint32_t ServerPlayer_off_DiffMods = 0x0000036c; +// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies into ServerPlayer+0x36c. It sits immediately below the DifficultyMods record it selects [verified] +constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368; // thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified] constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480; // thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified] diff --git a/src/shim/hooks/ai_orders.cpp b/src/shim/hooks/ai_orders.cpp new file mode 100644 index 0000000..c905ec9 --- /dev/null +++ b/src/shim/hooks/ai_orders.cpp @@ -0,0 +1,490 @@ +#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 +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 = 16; +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); +#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), +}; +#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; + } +} + +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, "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"); + + 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 be0ac29..45d6475 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); @@ -295,6 +299,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. @@ -386,6 +396,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..ffa64a3 --- /dev/null +++ b/src/shim/shim.cfg.l4control @@ -0,0 +1,39 @@ +# 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 diff --git a/src/shim/shim.cfg.l4dump b/src/shim/shim.cfg.l4dump new file mode 100644 index 0000000..e27c28e --- /dev/null +++ b/src/shim/shim.cfg.l4dump @@ -0,0 +1,39 @@ +# 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 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..768424a --- /dev/null +++ b/src/shim/shim.cfg.l4probes @@ -0,0 +1,39 @@ +# 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 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()); From c769394b57a90c6adfeec55afffc724d71bd3cb6 Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 18:03:18 -0400 Subject: [PATCH 3/6] L4: credit lane L5 for the turn-1 nondeterminism, and record what the block dump adds to it --- docs/L4-ai-orders.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/L4-ai-orders.md b/docs/L4-ai-orders.md index 4a1fa92..6d6ce0e 100644 --- a/docs/L4-ai-orders.md +++ b/docs/L4-ai-orders.md @@ -76,6 +76,9 @@ per-lane fragment mechanism intends. 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. -`determinism-oracle.md` verifies the oracle for the `ref-turn2` workload and only for that one; this -lane is the first to run the turn-1 workload more than once, and it is now on the record that a -byte comparison on that workload is a comparison against noise. + +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. From ec8d841dbad62c725c14c9912436c3c89533c558 Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 18:06:31 -0400 Subject: [PATCH 4/6] L4: predictions for the research-selection tie set, before the instrument --- docs/L4-predictions.md | 92 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/docs/L4-predictions.md b/docs/L4-predictions.md index 19390e1..7b782e1 100644 --- a/docs/L4-predictions.md +++ b/docs/L4-predictions.md @@ -244,3 +244,95 @@ happened to. 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. From ca0de05e07634119c67c71ab67aedd936a8cccdf Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 18:15:28 -0400 Subject: [PATCH 5/6] L4: research-selection capture -- candidate stream in arrival order, and the fallback probes that separate a tie from a rotation --- include/generated/sots_addresses.h | 24 +++- src/shim/hooks/ai_orders.cpp | 222 ++++++++++++++++++++++++++++- src/shim/shim.cfg.l4control | 1 + src/shim/shim.cfg.l4dump | 1 + src/shim/shim.cfg.l4probes | 1 + src/shim/shim.cfg.l4research | 40 ++++++ 6 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/shim/shim.cfg.l4research diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 767ba52..7ed643c 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 @ 2d61848, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ d1710e9, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1951,10 +1951,26 @@ constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0; constexpr uint32_t AITBuildPoliceShips_Execute = 0x00290380; // thiscall bool __thiscall Game::StrategyClient::OrderList16(int objectId, bool flag) -- RET 8. THE LIST-16 PRODUCER. Opens with `cmp byte [this+0x15c], 0` -- the End-Turn latch StrategyClient::EndTurn 0x00783be0 sets at 0x00783c59 before BuildTurnCommands runs -- so like every other order method it refuses once the turn has been submitted. Element record for list 16 is {i32, bool} (lane Q), matching the (objectId, flag) pair; list 16 is in the PAYING half of the ModCount table (bump at 0x00821e23 in its applier 0x00821e20). Reached from AITRaid::Execute at depth 1. The method is named for the list it fills, NOT for what the flag means: no save has ever carried a list-16 element and nothing has been observed [unverified] constexpr uint32_t StrategyClient_OrderList16 = 0x003635f0; -// offset DifficultyMods rec -- the per-player difficulty record, 0x1c bytes: {int id; float ai[3]; float other[3]}. THE RECORD IS PER-PLAYER AND REACHABLE FROM A ServerPlayer, which is the correction this lane makes: src/shim/hooks/compute_budget.h's coverage note says the difficulty row is 'not reachable from a ServerPlayer, so the two relevant entries are fitted constants measured from the B1 trace rather than snapshotted inputs'. It is reachable. DifficultyMods_Select 0x0059b490 is called as `Select(p->+0x36c, p)` by every one of the three consumers (ComputeBudget 0x0086338b, StrategyServer_IncomeDifficultyMod 0x0080f470, 0x0080e229), and LoadDifficultyRow 0x005a3990 fills it from ServerPlayer::Read 0x008804d0 at 0x00880fa3 gated on 0 <= aidf < 3 [verified] -constexpr uint32_t ServerPlayer_off_DiffMods = 0x0000036c; -// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies into ServerPlayer+0x36c. It sits immediately below the DifficultyMods record it selects [verified] +// thiscall void* __thiscall Game::StrategyAIAgent::SelectResearchTarget(void** outSlot) -- RET 4, the THIRD and last research producer tried by Process Turn phase 18 (0x006caf70), reached only when 0x006a84f0 and 0x006c27c0 both answer null. Body: (1) `if (agent->+0x10->+0x150 /*ClientPlayer*/ ->+0x294 != 0) return 0` -- a player that already has a target picks nothing; (2) build a std::vector of 0x0c-stride candidates with 0x006c2490(agent, &vec); (3) walk it FRONT TO BACK calling 0x006c8580(ecx = outSlot, edx = cand[1], stack: agent, cand[0]) and return the FIRST non-null answer; (4) if none answered, fall through to the three-arm rotation seeded by 0x006b36e0(agent) & 7 over the .data tables at 0x00a1a544 / 0x00a1a564. THERE IS NO SORT AND NO SCORE IN THE WALK: the vector's arrival order IS the priority, which is what makes a per-process ordering difference change the outcome [unverified] +constexpr uint32_t StrategyAIAgent_SelectResearchTarget = 0x002c8890; +// cdecl void __cdecl Game::StrategyAIAgent::BuildResearchCandidates(StrategyAIAgent* agent, std::vector* out) -- constructs a working object on the stack (0x006bcca0(agent)), emits the candidate vector into `out` (0x006bc500), destroys it (0x006bcef0). Element stride 0x0c. The emitter is a nested walk over groups with NO std::sort at that level, so the output order is the walk order [unverified] +constexpr uint32_t StrategyAIAgent_BuildResearchCandidates = 0x002c2490; +// fastcall void* __fastcall Game::StrategyAIAgent::TryResearchCandidate(void** outSlot /*ecx*/, int candWord1 /*edx*/, StrategyAIAgent* agent, int candWord0) -- called once per candidate in arrival order by SelectResearchTarget; the FIRST call that returns non-null ends the walk and that return value is the chosen tech object (its +0x4 is the std::string the order method is given). An entry probe on this address therefore records the whole candidate stream in the order the selector sees it [unverified] +constexpr uint32_t StrategyAIAgent_TryResearchCandidate = 0x002c8580; +// cdecl void* __cdecl (StrategyAIAgent* agent) -- the FIRST research producer tried by phase 18. Body unread; probed for reachability only, because a non-null answer here means the candidate walk never runs [unverified] +constexpr uint32_t StrategyAIAgent_ResearchProducerA = 0x002a84f0; +// thiscall void* __thiscall (StrategyAIAgent* this) -- the SECOND research producer tried by phase 18, entered with `mov eax,esi; call` (the receiver is in EAX at the call site, so the real convention is not confirmed). Body unread; probed for reachability only [unverified] +constexpr uint32_t StrategyAIAgent_ResearchProducerB = 0x002c27c0; +// thiscall int __thiscall (StrategyAIAgent* this) -- SelectResearchTarget's fallback index source, masked with 0x80000007 and used to index the eight-entry .data tables at 0x00a1a544 (2 0 2 0 2 1 2 0) and 0x00a1a564 (0 2 0 2 1 0 2 0). It is NOT an RNG draw: it reads ClientPlayer+0xf4 and calls 0x0080da80. A hit here means the research pick came from the three-arm rotation, not from the candidate list [unverified] +constexpr uint32_t StrategyAIAgent_ResearchFallbackIndex = 0x002b36e0; +// fastcall void* __fastcall (void** outSlot /*ecx*/, StrategyAIAgent* agent) -- one arm of SelectResearchTarget's three-arm fallback rotation; the arm index is (i + table[seed]) % 3 for i = 0,1,2 and the first non-null answer wins. Its outcome space is therefore at most three [unverified] +constexpr uint32_t StrategyAIAgent_ResearchFallbackArm = 0x002c8670; +// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies for this player. VERIFIED LIVE on VM146: read at ComputeBudget entry on every one of the 8 players of turn1-state across 3,895 calls, it is 1 on all eight, which is the difficulty level the whole corpus carries and the level whose AI column holds the 1.1x income modifier [verified] constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368; +// offset DifficultyMods* -- a POINTER to the per-player difficulty record, NOT the record inline. This entry exists to correct a lane L5 mistake and to save the next lane the run. addresses.json already records DifficultyMods_Select 0x0059b490 being called as Select(p->diffMods /*+0x36c*/, p), and lane L5 read the 0x1c bytes AT +0x36c expecting {int id; float ai[3]; float other[3]}. They are not that: the first dword is 0x0da0ec20..0x0da13360 on the eight players of turn1-state -- eight distinct heap addresses within 18 KB of each other and of the ServerPlayer allocations themselves (the player object was at 0x0da08600) -- and the floats behind it decode as denormal garbage (1.7e-38, 1.4e-43). So the field is a pointer and the record is one dereference further on. HYPOTHESIS, not verified: this run did not follow the pointer, so it is consistent with +0x36c being a pointer to the DifficultyMods record and equally consistent with its being some other per-player sub-object pointer. What IS established is the thing the reading was for: the difficulty row is reachable from a ServerPlayer, so compute_budget.h's old coverage note ('not reachable from a ServerPlayer, so the two relevant entries are fitted constants') is wrong as written. The fitted pair {3.0, 1.5} / {1.0, 1.0} remains UNVERIFIED against the running game; the next run should dereference this field behind a readability guard and compare the selected triple against those constants [hypothesis] +constexpr uint32_t ServerPlayer_off_DiffModsPtr = 0x0000036c; +// data double 0x3FEB333340000000 = 0.85000002384185791 = (double)0.85f -- the research-yield factor, multiplied in by ComputeBudget at 0x00863601 as a QWORD operand. THE WIDTH IS THE FINDING, and what makes it a rule-23 case rather than a curiosity is its two NEIGHBOURS in the same product: 1.15 at 0x00a1a4b8 and 0.5 at 0x009e20a0 are stored as EXACT doubles, while this one is a widened float. Three literals, one expression, two widths -- so a C++ port that writes all three as decimals is wrong in exactly one place. The exact-decimal product (money/50 x 1.15 x 0.5 x 0.85) is integral when researchMoney is a multiple of 40,000, and the image's larger constant lands one ABOVE it there: at 40,000 the game gives 391 research points and the decimal gives 390. NOT VISIBLE ON THE CORPUS, and that is now measured rather than argued: the live compare run presented 9 distinct researchMoney values across 3,895 calls and NONE of them is a multiple of 40,000. Read live out of the running process as well, so nothing patched or relocated it [verified] +constexpr uint32_t g_ResearchYieldFactor = 0x0062d818; // thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified] constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480; // thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified] diff --git a/src/shim/hooks/ai_orders.cpp b/src/shim/hooks/ai_orders.cpp index c905ec9..68ae3ae 100644 --- a/src/shim/hooks/ai_orders.cpp +++ b/src/shim/hooks/ai_orders.cpp @@ -19,6 +19,7 @@ namespace { 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; @@ -82,7 +83,7 @@ std::uint32_t g_batchSeq = 0; // ---- the entry probes --------------------------------------------------------------------------- -constexpr std::size_t kMaxProbes = 16; +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) @@ -212,6 +213,10 @@ 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 { @@ -247,6 +252,16 @@ const ProbeDef kProbes[] = { 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 @@ -402,6 +417,176 @@ extern "C" void AiOnBatch(void* blocksv, int n) { } } + +// ---- 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 @@ -428,6 +613,12 @@ bool ai_orders_config(const char* key, const char* value, std::string* err) { 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; @@ -474,6 +665,35 @@ void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)( 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); diff --git a/src/shim/shim.cfg.l4control b/src/shim/shim.cfg.l4control index ffa64a3..6b0a4ae 100644 --- a/src/shim/shim.cfg.l4control +++ b/src/shim/shim.cfg.l4control @@ -37,3 +37,4 @@ 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 index e27c28e..e3b6df4 100644 --- a/src/shim/shim.cfg.l4dump +++ b/src/shim/shim.cfg.l4dump @@ -37,3 +37,4 @@ watch=off aiorders.out=C:\SOTS\shim.aiorders.txt aiorders=on aiprobes=off +airesearch=off diff --git a/src/shim/shim.cfg.l4probes b/src/shim/shim.cfg.l4probes index 768424a..2a428b1 100644 --- a/src/shim/shim.cfg.l4probes +++ b/src/shim/shim.cfg.l4probes @@ -37,3 +37,4 @@ 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 From c842d44d62d9f05b6aaa9238b9bc51bf77d139ce Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 18:26:59 -0400 Subject: [PATCH 6/6] L4: engine doc for the research-selection capture --- docs/L4-ai-orders.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/L4-ai-orders.md b/docs/L4-ai-orders.md index 6d6ce0e..684170e 100644 --- a/docs/L4-ai-orders.md +++ b/docs/L4-ai-orders.md @@ -82,3 +82,33 @@ Lane L5 established this first and better, on VM146, and owns it 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.