sync header after L4 merge

This commit is contained in:
alex 2026-09-08 18:32:19 -04:00
commit 7c0c395bea
15 changed files with 1673 additions and 5 deletions

View file

@ -104,6 +104,7 @@ if(WIN32)
src/shim/hooks/tail_rng.cpp src/shim/hooks/tail_rng.cpp
src/shim/hooks/draw_sites.cpp src/shim/hooks/draw_sites.cpp
src/shim/hooks/probe_entry.cpp src/shim/hooks/probe_entry.cpp
src/shim/hooks/ai_orders.cpp
src/shim/hooks/watchpoints.cpp) src/shim/hooks/watchpoints.cpp)
# `minhook` is here for its include directory: lane H's probe_entry.cpp installs its own # `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, # detours (MH_CreateHook/MH_EnableHook) rather than handing descriptors back to main.cpp,

114
docs/L4-ai-orders.md Normal file
View file

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

338
docs/L4-predictions.md Normal file
View file

@ -0,0 +1,338 @@
# L4 — what the live AI will emit, written before the module was built
Lane L4, 2026-09-08. Guest **VM145** (`sots-re-win10-145`, 192.168.10.145). Worktree `wip/l4` off
`main` `0117495`.
Rule 2: this file is committed **before** the shim module is written, and it names the falsifier for
every claim. Nothing below is a summary of a run; there has been no run.
The question this lane exists to answer: **does our emission model produce the same commands the
game does?** `src/game/ai` models the *shape* of a `TurnCommands` block and the *cost* of each
element, and decides nothing. Nobody has ever looked at a real AI block.
---
## 0. The instrument, and why it is one detour
`Game::StrategySim::ApplyTurnCommandBatch` **0x0088f9b0** receives `(blocks, n)` as two stack
arguments with `ecx = sim` — read straight off the prologue at `0x0088f9db`–`0x0088f9e7`, where
`n` is multiplied by `0x1b4` and added to `blocks` to make the end pointer. **At that instant every
player's submitted block is complete and in memory**, so one register-transparent entry stub dumps
the entire turn's command traffic: six gates and twenty-seven list lengths per player, plus the
element bytes.
That is a strictly better instrument than counting `ModCount` bumps, which is what every previous
lane has had. A bump count says *how many* commands; the block says *which lists, how many
elements, and what values*.
Second instrument: sixteen register-transparent entry counters (`probe_entry.cpp`'s pattern, its
own table so lane H's set is untouched), on the task bodies and the emission gates. One of them —
`StrategyAIAgent::RunTaskList` **0x006b3320** — additionally records its `pass` stack argument into
a global, so every later probe hit is **attributed to a pass**. That is what turns AI3's P2 from a
count into a measurement.
**Rule 20 is the whole design.** `AITRaid`'s list-16 emit is the named open item, and a zero at
`0x007635f0` means nothing unless `AITRaid::Execute 0x0068e670` is also probed: "never ran" and
"ran and emitted nothing" are opposite answers.
**Rule 19 control.** Both workloads have published oracle bytes. Every configuration is run against
them and any configuration that moves a byte has invalidated its own numbers.
---
## 1. Workload W1 — `turn1-state.sav`, one End Turn (the turn 1→2 transition)
Facts already in the corpus, from `turn1-state.sav` → `turn2-state.sav` (read this session with
`verify/save-reader`, not taken from a report):
| | |
|---|---|
| players | 8: ids 16, 32, 496, 512 (`Species` 0/2/0/2) and 528/544/560/576 (`Species == 4`) |
| player 32 | `HomeSys` **288** = Ke'Dolarra, `PlyrIdx` 1 |
| `ModCount` | 0 → 12 |
| `ResRate` | 0.25 → **0.8** on 32, 496, 512; unchanged (0.25) on 16 |
| `ResTNm` | `''` → `IND_Waldo` (32), `DRV_PlsFiss` (496), `BIO_GnMod` (512); `''` on 16 |
| `Status` | 0 → 4 on 16/32/496/512; **0 on all four `Species == 4`** |
| player 32 designs | 5 → 6; the new one is `DesID` **18**, `"Honor Lance"` |
| `designIds` | gains **18** *and* **1712** — the second belongs to player 528, whose design count also goes 18 → 19 |
| `NMnx` | 106 → 109 (ship **1728**, fleet **1744**, and one more) |
| new fleet | **1744** `"Alpha Fleet"` at system 288, holding ship 1728 of design 18 |
### P1 — the block set
**Prediction: `n == 4`, and the four `block+0x04` player ids are 16, 32, 496, 512 in that order.**
*Falsified if* `n == 8` — then the `Species == 4` factions **do** submit, they just submit empty
blocks, and AI4 §2's "no `StrategyClient` at all" is too strong (it would become "a client that
never sets the always-set gate", which is a different and stranger claim). *Falsified differently
if* the four ids are 0,1,2,3 — then `block+0x04` is a player **index**, not the save id, and every
published reading of that field needs the same correction.
### P2 — the four blocks, list by list
| block | gates set | lists non-empty |
|---|---|---|
| 16 (human) | rate only, value **0.25** | **none** |
| 32 (AI) | rate **0.8**, target (`IND_Waldo`) | **1, 3, 5 — one element each** |
| 496 (AI) | rate **0.8**, target (`DRV_PlsFiss`) | **none** |
| 512 (AI) | rate **0.8**, target (`BIO_GnMod`) | **none** |
Boost, group-4, the three-float Hiver gate and `CivilianRatios` **clear on all four**.
That sums to AI4's twelve: 4 rate + 3 target + 3 (player 32's lists) + 2 drivers.
*Falsified if:* any list outside {1, 3, 5} is non-empty on any block; or a list in {1, 3, 5} carries
more than one element; or the human's block carries a list at all; or 496/512 carry a list (they own
nothing, so a list element from them would mean a task fired against objects they do not have).
**The sharpest falsifier is list 8/10/14 non-empty.** Player 32 gets a brand-new fleet (1744) this
turn, and a fleet is exactly the thing lists 8/10/14 order. If the AI issues a fleet order on the
same turn it creates the fleet, AI4's P2 attribution is wrong by exactly the fleet-order group and
the twelve has to be re-derived.
### P3 — the element values
* **list 3** (`{i32 ordinal, i32 designId, i32 systemId, i32 w}`): `designId == 18`,
`systemId == 288`. The `w` word has never been observed and is not predicted.
* **list 5** (`{i32 systemId, OutputRates}`): `systemId == 288`, i.e. **the same system the build
order names**.
* **list 1** (`ShipDesignDef` frame + `i32`): the element carries the id **18** somewhere in its
first 48 bytes.
*Why 18 matters.* 18 is not a multiple of 16 and does not come from the master `NMnx` counter — the
*other* new design that turn, player 528's, got **1712** from that counter, and 528 submits no
block. So there are two id spaces, and the prediction is that **a client-issued design carries a
small client-local id in the command itself**, while a server-created object gets a master id.
*Falsified if* the list-1 element's ids are all ≥ 512 / multiples of 16 — then 18 was assigned
server-side when the command was applied and the design command carries no id at all, which is a
materially different thing for a reimplementation to emit.
### P4 — the probes on W1
| probe | prediction | what a miss means |
|---|---|---|
| `RunTaskList 0x006b3320` | **6** = 3 AI players × 2 passes | 2 ⇒ only one player's task list runs and 496/512 reach `ResTNm` by some other path; 0 ⇒ the instrument is broken, not the game |
| `BuildTurnCommands 0x00783780` | **4**, one per submitting block | ≠ 4 ⇒ P1 is wrong from the other side |
| `RequestBuildForTask 0x006cea50` | **> 0 and even-ish** — entered on both passes, emits only on pass 1 | entries only in pass 1 ⇒ the caller gates before it, and AI3 §2.3's "the hub returns an empty list on pass 0" is not the whole mechanism |
| `AssignFleetsAndIssueOrders 0x006c16c0`, `IssueRouteForFleets 0x006bbd50` | **entered, both passes, zero list-8/10/14 elements** | entered + elements ⇒ P2 falsified |
| `AITRaid::Execute 0x0068e670` | **0** | — |
| `ClientOrder 0x007635f0` (list 16) | **0** | — |
| the six shared planner bodies (`AITColonize` 0x0068b400, `AITEscortGateInvade` 0x0068c7c0, `AITInvade` 0x0068d7a0, `AITNodeBore` 0x0068e590, `AITBuildPoliceShips` 0x00690380, `AITBuildDeepScanShips` 0x006901a0) | **at least one build-shaped body entered in both passes; at most one produces the single list-3 element** | none entered ⇒ the list-3 element comes from a task nobody has named, and AI3 §5's path table is not the emission path |
| `AITAdvanceIdleShips::Execute 0x0068f230` | entered (priority 0, always last) | — |
| `IsClaimedByAnotherTask 0x006a8d20` | **> 0** | — |
**Stated in advance so it cannot be claimed afterwards: W1 almost certainly cannot settle AI3 §2.4.**
A turn-1 AI with one colony and no fleet of its own has nothing to raid, so `AITRaid` is predicted
not to run at all, and a zero at `0x007635f0` will then be a *non-answer* about pass 0 — exactly the
"did not fire" vs "fired and found nothing" distinction rule 20 is about. The AITRaid question needs
`AITRaid::Execute` to be entered, and W2 is the better chance.
**Also stated in advance: this probe set cannot settle AI3's P3.** An entry counter on
`IsClaimedByAnotherTask` measures the "called often" half only; the steal branch at `0x006a8ded` is
*inside* the function and an entry probe cannot see it. P3 stays open.
---
## 2. Workload W2 — `ref-turn2.sav`, one End Turn (the turn 2→3 transition)
`ref-turn2.sav` **is** `turn2-state.sav` (same sha256). Corpus facts for 2→3:
* `ModCount` 12 → 24; `NMnx` 109 → 111 (ship **1760**, fleet **1776** `"Gamma Fleet"`);
* fleet **1744** `"Alpha Fleet"` is **gone**, replaced by fleet **34** `"Beta Fleet"`, in transit
(`FtTrans 1`), flight plan one waypoint `Wpt 272`, `pnd 288`, ETA 3, origin = Ke'Dolarra's
position;
* no `ResTNm` change on any player; `ResRate` already 0.8.
AI4 §2.1 attributes the twelve as 4 rate + list 5 + list 3 + list 10 + **2 × list 14** + list 8 + 2
drivers.
### P5 — the block
**Prediction:** `n == 4`; blocks 16, 496, 512 carry the rate gate and **nothing else** (no target
gate this turn); block 32 carries the rate gate and lists **3 = 1, 5 = 1, 8 = 1, 10 = 1, 14 = 2**,
everything else empty.
### P6 — list 14 is two elements against one fleet, and the second field is the discriminator
Lane Q read the element as `{i32, i32, bool}`; `human-turn2-orders.sav` (a UI fleet move) carries
exactly one, `{1456, 0, true}`. AI2's P1 says the AI's bridge calls the adder twice with mode 0 and
mode 1 and the adder keys on `(fleet, mode)`.
**Prediction: the two list-14 elements are `{F, 0, b}` and `{F, 1, b}` with the same `F`, and `F` is
also the fleet id list 8's element names.** That converts AI2's P1 from an inference about bump
counts into a read of the values.
*Falsified if:* both elements carry the same second word (then the adder does not key on mode and
the doubling has another cause); or the two fleet ids differ (then it is two orders against two
fleets and the "one order costs two elements" rule is wrong); or list 14 has one element (then
AI4's two list-14 bumps came from two different fleets and the reference turn moved two).
### P7 — which fleet id the order names
**Prediction: `F == 1744`** — the fleet that exists at submit time — and **34 does not appear
anywhere in the block.** 34 is created when the order is applied, from the same small-id space as
design 18.
*Falsified if* `F == 34`: then the client creates the fleet object *before* submitting and ships the
new id in the command, which would make the small-id space **client-allocated and part of the wire
protocol** — a much bigger constraint on a reimplementation than the alternative, because our engine
would have to reproduce that counter exactly to get byte-identical saves.
This is the one prediction I would most like to be wrong, because the falsifier is the more
interesting world.
### P8 — list 10's first word
Lane AI4 §4.5 refused to name list 10 (`{i32, i32, counted i32}`) on positional evidence alone.
**Prediction: its first `i32` is `F`, and its counted vector holds ship ids** — on W2 that means the
single ship **1728**. If so, "assign these ships to this fleet" is supported by values rather than
by adjacency, and the list can be named.
*Falsified if* the counted vector is empty or holds system ids (W2's route is a single hop to 272,
so a `{…, 272}` tail would make it a route-shaped command instead).
### P9 — AITRaid, the second attempt
Player 32 now owns a fleet, so `AITRaid` has a candidate. **Prediction: `AITRaid::Execute` is
entered on both passes and `ClientOrder 0x007635f0` is entered zero times**, i.e. list 16 stays
empty and AI3 §2.4's inference holds — but *for the reason the probe can see*, which is the point.
*Falsified if* `0x007635f0` is entered with `pass == 0` recorded: then pass 0 **does** emit, AI3's P2
is falsified, and every `ModCount` arithmetic in `ai-order-emission.md` that assumes one emitting
sweep is off by a factor.
---
## 3. Rule 19 — the controls, and what they are compared against
Published oracle bytes (sha256 prefixes, `findings/subsystems/running-the-game.md`):
| workload | `(Autosave EndTurn).sav` | `(Autosave).sav` |
|---|---|---|
| W1 from `turn1-state.sav` | `a3f9dc4b…` | `ab4ac2d7…` |
| W2 from `ref-turn2.sav` | `bb4fd9ac…` | `978041ac…` |
Three configurations per workload, each a separate launch:
| run | `shim.cfg` | purpose |
|---|---|---|
| **C** | `hooks=off` | proves **this clone** reproduces the oracle at all. VM145 is a ZFS clone; nothing has ever checked that it is byte-faithful. If C fails, the lane reports that and stops — every later number would be measured against an unknown baseline. |
| **A** | `aiorders=on`, `aiprobes=off` | the block dump alone: one detour |
| **B** | `aiorders=on`, `aiprobes=all` | the dump plus sixteen entry probes |
**Prediction:** C, A and B all reproduce both oracle hashes. **Falsified if** A or B moves a byte —
in which case that configuration's numbers are reported as unmeasured, not as results, and the
sixteen-probe set is bisected with `aiprobes=N`. Lane H's precedent is explicit that a MinHook
detour has changed this game's behaviour once already, and that the probe module is the one it
happened to.
---
## 4. What this lane will not do, said now
1. **It does not close AI3's P3** (§1, P4). An entry probe cannot see a branch inside the callee.
2. **It does not test the eleven "free" lists** (AI4 P1's untested half) unless one of them turns up
populated, which P2/P5 predict will not happen. If one does, that is the result.
3. **It does not settle the Hiver gate** (AI4 P3). The corpus has no Hiver player and this lane is
not manufacturing one.
4. **It reads element bytes, not element types.** The dump emits a fixed 48-byte window per element
plus the same bytes as ints and floats; decoding to lane Q's records happens offline, in the
report, where a wrong record is visible as a wrong value rather than being baked into the
instrument. Where a list's in-memory element is larger than 48 bytes the dump says so rather than
silently truncating.
5. **The dump's own coverage check**: for every list it walks the node chain *and* reads `_Mysize`,
and logs a `MISMATCH` line if they disagree. A wrong list layout would otherwise print a
confident, wrong zero (rule 1).
---
# Addendum — the research-selection tie set
Added after the first two runs, before the research instrument was written. The coordinator's
question: if the one varying AI decision is a **tie** broken by something per-process, the
original's possible outcomes form a small enumerable set, and a deterministic `game/ai` can pick
canonically and claim *"our choice is one of exactly k the original can produce, and here are all
k"*. That is a stronger claim than behavioural equivalence, and it needs the candidate set.
## What the selection site actually is (read before predicting)
Process Turn phase 18 is `0x006caf70`. It tries three producers in order and takes the first
non-null:
```
eax = 0x006a84f0(agent) ; producer A
if (!eax) eax = 0x006c27c0(agent) ; producer B
if (!eax) eax = 0x006c8890(agent, &agent+0x13c); producer C -- the candidate walk
if (eax && eax != player->+0x294) cl_SetResearchTarget(<eax+4 as a C string>)
```
`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.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ 940aeca, generated 2026-09-08 by tools/gen_addresses.py // Source: sots-re ghidra/addresses.json @ f761fb1, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>

View file

@ -0,0 +1,710 @@
#include "shim/hooks/ai_orders.h"
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include "MinHook.h"
#include "generated/sots_addresses.h"
namespace shim::hooks {
namespace {
// ---- configuration ---------------------------------------------------------------------------
bool g_enabled = false;
std::size_t g_probeInstallCount = 0; // `aiprobes=`; default off, so `aiorders=on` alone is ONE detour
bool g_research = false; // `airesearch=`; the three research-selection dump hooks
char g_outPath[MAX_PATH] = {};
FILE* g_out = nullptr;
void (*g_log)(const char*) = nullptr;
void LogF(const char* fmt, ...) {
char buf[1200];
va_list ap;
va_start(ap, fmt);
std::vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
if (g_log) g_log(buf);
if (g_out) {
std::fputs(buf, g_out);
std::fputc('\n', g_out);
std::fflush(g_out);
}
}
// ---- guarded reads ---------------------------------------------------------------------------
//
// Every read below is of a pointer the game handed us or of a heap node we reached by walking one.
// A wrong offset must produce a logged zero, never a fault inside a detour, so nothing is read
// without a probe first. Four bytes at a time: an element window may run past the end of a small
// heap node, and probing per word bounds the damage to the word.
inline bool Readable(std::uintptr_t p, std::size_t n) {
return p != 0 && !IsBadReadPtr(reinterpret_cast<void*>(p), n);
}
inline std::uint32_t U32(std::uintptr_t p) {
return Readable(p, 4) ? *reinterpret_cast<volatile std::uint32_t*>(p) : 0u;
}
inline std::uint8_t U8(std::uintptr_t p) {
return Readable(p, 1) ? *reinterpret_cast<volatile std::uint8_t*>(p) : 0u;
}
inline float F32(std::uintptr_t p) {
const std::uint32_t v = U32(p);
float f;
std::memcpy(&f, &v, 4);
return f;
}
// ---- the `TurnCommands` block ------------------------------------------------------------------
//
// Offsets from `Game::TurnCommands::Write` 0x00842540 as read by lane Q, and from the twenty-seven
// per-list loops in `ApplyTurnCommandBatch` as read by lane AI4. Both are instruction-stream reads
// of the same class from opposite ends -- the writer and the applier -- and they agree, which is
// the only independent corroboration this layout has.
constexpr std::uint32_t kBlockStride = 0x1b4;
constexpr std::uint32_t kListBase = 0x70; // list 1's member
constexpr std::uint32_t kListStride = 0x0c; // {_Myhead, _Mysize, _Alval}, allocator LAST
constexpr int kListCount = 27;
// How much of each element to record. The largest element record lane Q read is list 5's
// {i32, OutputRates frame}; 48 bytes covers every scalar-only record with room to spare and is
// short enough that a heap node's tail is unlikely to matter. Words that do not probe readable are
// printed as `????????` rather than as zero, so truncation is visible.
constexpr int kElemWords = 12;
std::uint32_t g_batchSeq = 0;
// ---- the entry probes ---------------------------------------------------------------------------
constexpr std::size_t kMaxProbes = 24;
volatile std::uint32_t g_calls[kMaxProbes]; // since the last batch dump
volatile std::uint32_t g_total[kMaxProbes]; // since process start
volatile std::uint32_t g_byPass[kMaxProbes][3]; // pass 0, pass 1, anything else (incl. stale)
bool g_installed[kMaxProbes];
// Set by the RunTaskList stub from that function's third __cdecl stack argument. It is STALE once
// RunTaskList returns -- nothing can clear it from a tail-jumping stub -- so `pass` on a hit that
// falls outside a task sweep is the last pass that ran, not a measurement. The event ring's
// `run` column is what makes that readable: hits with the same `run` as the preceding RunTaskList
// entry are inside that sweep.
volatile LONG g_pass = -1;
volatile LONG g_runSeq = 0;
volatile LONG g_agent = 0;
struct Event {
std::uint32_t seq;
std::uint8_t probe;
std::int8_t pass;
std::uint32_t run;
std::uint32_t agent;
};
constexpr std::size_t kMaxEvents = 4096;
Event g_events[kMaxEvents];
volatile LONG g_eventCount = 0;
std::size_t g_eventsWritten = 0;
} // namespace
// ---- the hit sinks, called from the asm stubs ---------------------------------------------------
extern "C" void AiProbeHit(int index) {
if (index < 0 || static_cast<std::size_t>(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<std::size_t>(n) < kMaxEvents) {
Event& e = g_events[n];
e.seq = static_cast<std::uint32_t>(n);
e.probe = static_cast<std::uint8_t>(index);
e.pass = static_cast<std::int8_t>(p);
e.run = static_cast<std::uint32_t>(g_runSeq);
e.agent = static_cast<std::uint32_t>(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<LONG>(reinterpret_cast<std::uintptr_t>(agent));
InterlockedIncrement(&g_runSeq);
AiProbeHit(0);
}
namespace {
// ---- the probe table ------------------------------------------------------------------------
//
// Order is the stub index, the report order and the `aiprobes=N` bisection order, so it is fixed.
// Index 0 MUST be RunTaskList: it is both the pass recorder and the control. Every other row is
// meaningless if row 0 reads zero, and the report says so rather than presenting a table of zeros.
namespace A = sots::addr;
struct ProbeDef {
const char* name;
std::uint32_t rva;
void* stub;
void** trampoline;
};
} // namespace
// One trampoline slot and one stub per probe. Written out rather than generated at runtime because
// a runtime thunk needs an executable allocation and a relocation, and MinHook already owns that.
#define AI_PROBE_STUB(i) \
extern "C" void* g_aiTr##i; \
void* g_aiTr##i = nullptr; \
extern "C" void AiProbeStub##i(void); \
asm(".text\n" \
".globl _AiProbeStub" #i "\n" \
"_AiProbeStub" #i ":\n" \
" pushfl\n" \
" pushal\n" \
" pushl $" #i "\n" \
" call _AiProbeHit\n" \
" addl $4, %esp\n" \
" popal\n" \
" popfl\n" \
" jmp *_g_aiTr" #i "\n")
// Index 0 is hand-written: it forwards RunTaskList's `agent` and `pass` stack arguments. After
// `pushfl` (4) + `pushal` (32) the return address is at esp+36 and the four __cdecl arguments at
// esp+40/44/48/52, so `pass` is esp+48 and `agent` is esp+40 -- and after the first push the
// latter has moved to esp+44. Nothing is written; the arguments are read where the callee will
// read them.
extern "C" void* g_aiTr0;
void* g_aiTr0 = nullptr;
extern "C" void AiProbeStub0(void);
asm(".text\n"
".globl _AiProbeStub0\n"
"_AiProbeStub0:\n"
" pushfl\n"
" pushal\n"
" pushl 48(%esp)\n"
" pushl 44(%esp)\n"
" call _AiOnRunTaskList\n"
" addl $8, %esp\n"
" popal\n"
" popfl\n"
" jmp *_g_aiTr0\n");
AI_PROBE_STUB(1);
AI_PROBE_STUB(2);
AI_PROBE_STUB(3);
AI_PROBE_STUB(4);
AI_PROBE_STUB(5);
AI_PROBE_STUB(6);
AI_PROBE_STUB(7);
AI_PROBE_STUB(8);
AI_PROBE_STUB(9);
AI_PROBE_STUB(10);
AI_PROBE_STUB(11);
AI_PROBE_STUB(12);
AI_PROBE_STUB(13);
AI_PROBE_STUB(14);
AI_PROBE_STUB(15);
AI_PROBE_STUB(16);
AI_PROBE_STUB(17);
AI_PROBE_STUB(18);
AI_PROBE_STUB(19);
#undef AI_PROBE_STUB
namespace {
#define AI_PROBE(name, rva, i) \
ProbeDef { name, rva, reinterpret_cast<void*>(&AiProbeStub##i), &g_aiTr##i }
const ProbeDef kProbes[] = {
// 0: the pass recorder AND the control. 3 AI players x 2 passes on the reference game.
AI_PROBE("StrategyAIAgent::RunTaskList [control+pass]", A::StrategyAIAgent_RunTaskList, 0),
// 1-2: the AITRaid question (AI3 section 2.4). A zero on 1 means nothing without 2.
AI_PROBE("StrategyClient::OrderList16 [list 16 emit]", A::StrategyClient_OrderList16, 1),
AI_PROBE("AITRaid::Execute", A::AITRaid_Execute, 2),
// 3-8: the six shared bodies behind the nine tasks AI2 called planners and AI3 showed emit.
AI_PROBE("AITColonize::Execute [+Goal]", A::AITColonize_Execute, 3),
AI_PROBE("AITEscortGateInvade::Execute [+Goal]", A::AITEscortGateInvade_Execute, 4),
AI_PROBE("AITInvade::Execute [+Goal]", A::AITInvade_Execute, 5),
AI_PROBE("AITNodeBore::Execute", A::AITNodeBore_Execute, 6),
AI_PROBE("AITBuildPoliceShips::Execute", A::AITBuildPoliceShips_Execute, 7),
AI_PROBE("AITBuildDeepScanShips::Execute", A::AITBuildDeepScanShips_Execute, 8),
// 9: priority 0, pass-1 body, always last in the sweep -- a second control on the task list.
AI_PROBE("AITAdvanceIdleShips::Execute", A::AITAdvanceIdleShips_Execute, 9),
// 10-12: the three pass-1-gated emission exits. Entered on BOTH passes if the gate is theirs.
AI_PROBE("StrategyAIAgent::RequestBuildForTask [lists 3,1]",
A::StrategyAIAgent_RequestBuildForTask, 10),
AI_PROBE("StrategyAIAgent::AssignFleetsAndIssueOrders [lists 14,8,10]",
A::StrategyAIAgent_AssignFleetsAndIssueOrders, 11),
AI_PROBE("StrategyAIAgent::IssueRouteForFleets [list 14]",
A::StrategyAIAgent_IssueRouteForFleets, 12),
// 13-14: the hub and the claim test.
AI_PROBE("StrategyAIAgent::AcquireFleetsForTask", A::StrategyAIAgent_AcquireFleetsForTask, 13),
AI_PROBE("StrategyAIAgent::IsClaimedByAnotherTask [entry only]",
A::StrategyAIAgent_IsClaimedByAnotherTask, 14),
// 15: one per submitting block -- the block count seen from the client side.
AI_PROBE("StrategyClient::BuildTurnCommands [control]", A::StrategyClient_BuildTurnCommands, 15),
// 16-17: the two research producers phase 18 tries BEFORE the candidate walk. If either of
// these answers, the walk never runs and the candidate set is not where the answer comes from.
AI_PROBE("AIResearch::ProducerA", A::StrategyAIAgent_ResearchProducerA, 16),
AI_PROBE("AIResearch::ProducerB", A::StrategyAIAgent_ResearchProducerB, 17),
// 18-19: the FALLBACK, reached only when the candidate walk accepts nothing. Its index source
// reads player state rather than the generator, and it rotates over three arms -- so a hit
// here means the outcome space is <= 3 by construction and is NOT a tie in a candidate list.
// This pair is the discriminator between the two mechanisms, and it is why they are probed.
AI_PROBE("AIResearch::FallbackIndex", A::StrategyAIAgent_ResearchFallbackIndex, 18),
AI_PROBE("AIResearch::FallbackArm", A::StrategyAIAgent_ResearchFallbackArm, 19),
};
#undef AI_PROBE
constexpr std::size_t kProbeCount = sizeof kProbes / sizeof kProbes[0];
static_assert(kProbeCount <= kMaxProbes, "add more AI_PROBE_STUB() slots");
// ---- the dump ---------------------------------------------------------------------------------
void DumpElements(int blk, int pid, int list, std::uintptr_t head) {
// MSVC std::list node: {_Next, _Prev, _Myval}. begin() == _Myhead->_Next; the head is the nil
// sentinel and terminates the walk.
std::uintptr_t node = U32(head);
int idx = 0;
while (node && node != head && idx < 64) {
const std::uintptr_t val = node + 8;
char hex[kElemWords * 9 + 8] = {};
char ints[kElemWords * 13 + 8] = {};
int hp = 0, ip = 0;
for (int w = 0; w < kElemWords; ++w) {
const std::uintptr_t p = val + 4u * static_cast<unsigned>(w);
if (Readable(p, 4)) {
const std::uint32_t v = *reinterpret_cast<volatile std::uint32_t*>(p);
hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v);
ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast<int>(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<unsigned>(node), static_cast<double>(F32(val)),
static_cast<double>(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<int>(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<unsigned>(b), pid, gRate,
static_cast<double>(F32(b + 0x08)), gTgt, static_cast<int>(U32(b + 0x10)), gBoost,
static_cast<int>(U32(b + 0x18)), static_cast<double>(F32(b + 0x1c)), gG4,
static_cast<int>(U8(b + 0x24)), static_cast<int>(U32(b + 0x28)), gF3,
static_cast<double>(F32(b + 0x30)), static_cast<double>(F32(b + 0x34)),
static_cast<double>(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<unsigned>(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<unsigned>(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<std::uint32_t>(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<unsigned>(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<std::size_t>(n) > kMaxEvents ? kMaxEvents : static_cast<std::size_t>(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<int>(e.pass), e.agent, static_cast<unsigned>(e.probe), nm);
}
g_eventsWritten = have;
if (static_cast<std::size_t>(n) > kMaxEvents)
LogF("aievent OVERFLOW -- %ld hits taken, only %u recorded; counters below are complete, "
"the ordering is not",
n, static_cast<unsigned>(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<std::uintptr_t>(blocksv);
LogF("---- aibatch seq=%u blocks=0x%08x n=%d stride=0x%x ----", g_batchSeq,
static_cast<unsigned>(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<unsigned>(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<unsigned>(i), g_installed[i] ? 1 : 0, g_calls[i],
g_total[i], g_byPass[i][0], g_byPass[i][1], g_byPass[i][2], kProbes[i].name);
}
if (g_calls[0] == 0)
LogF("aiprobe seq=%u CONTROL ZERO -- RunTaskList was not entered in this window, so "
"every other row above is unmeasured rather than absent",
g_batchSeq);
}
for (std::size_t i = 0; i < kProbeCount; ++i) {
g_calls[i] = 0;
g_byPass[i][0] = g_byPass[i][1] = g_byPass[i][2] = 0;
}
}
// ---- the research-selection capture (lane L4 addendum) -------------------------------------------
//
// WHY THIS IS A DUMPER AND NOT THREE MORE COUNTERS.
//
// Exactly one AI decision in the reference game is not reproducible run to run: one shadow empire's
// research target (lane L5). If that pick is a TIE broken by something per-process, the original's
// possible outcomes form a small enumerable set, and a deterministic reimplementation can pick
// canonically and claim membership of that set -- which is a stronger claim than "behaviourally
// equivalent" and keeps a byte match reachable whenever the tiebreaks agree. Naming the set needs
// the candidate list, in the order the selector sees it. A counter cannot give that.
//
// `SelectResearchTarget 0x006c8890` walks a vector of 0x0c-stride candidates FRONT TO BACK and
// takes the first one `TryResearchCandidate 0x006c8580` accepts. There is no sort and no score in
// the walk, so the vector's order IS the priority. One entry stub per candidate therefore records
// the whole stream in arrival order; the LAST call before the walk ends is the accepted one.
//
// Three stubs, all register-transparent:
// * SelectResearchTarget -- the per-player delimiter, and it prints the current target word so a
// player that returns immediately is distinguishable from one that walks an empty list;
// * TryResearchCandidate -- one line per candidate, with both candidate words and whatever a
// std::string at +4 of either resolves to, which is how a tech gets a name here;
// * cl_SetResearchTarget -- the outcome, which is the only place the chosen NAME is in a
// register (phase 18 resolves the short-string union and pushes the char*).
//
// What it cannot do: a tail-jumping stub never sees a return value, so "the last candidate tried"
// is the accepted one only when the walk actually accepted something -- and the fallback probes
// (rows 18/19) are what say whether it did. Read the three together.
namespace {
std::uint32_t g_researchSeq = 0;
int g_candIdx = 0;
// MSVC std::string (0x1c): union _Bx at +0, _Mysize +0x10, _Myres +0x14; short strings live in the
// union. Prints nothing rather than guessing when the shape does not validate.
void ReadStdString(std::uintptr_t s, char* out, std::size_t cap) {
out[0] = '\0';
if (!Readable(s, 0x18)) return;
const std::uint32_t len = U32(s + 0x10);
const std::uint32_t res = U32(s + 0x14);
if (len == 0 || len > 0x80 || res < len) return;
const std::uintptr_t p = (res < 16) ? s : static_cast<std::uintptr_t>(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<char>(U8(p + i));
out[i] = (c >= 32 && static_cast<unsigned char>(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<std::uintptr_t>(w) + 4, out, cap);
if (out[0]) return;
ReadStdString(static_cast<std::uintptr_t>(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<std::uintptr_t>(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<unsigned>(a), static_cast<unsigned>(client),
static_cast<unsigned>(player), player ? U32(player + 0x294) : 0,
player ? static_cast<int>(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<int>(candWord0), n0,
candWord1, static_cast<int>(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<char>(U8(namePtr + i));
if (!c) break;
buf[i] = (c >= 32 && static_cast<unsigned char>(c) < 127) ? c : '?';
}
buf[i] = '\0';
}
LogF("airesult sel=%u candidatesTried=%d chose='%s' (ptr=0x%08x)", g_researchSeq, g_candIdx,
buf, namePtr);
}
// SelectResearchTarget is __thiscall with one stack argument: push ECX.
asm(R"(
.text
.globl _AiSelectDetour
_AiSelectDetour:
pushfl
pushal
pushl %ecx
call _AiOnSelectResearch
addl $4, %esp
popal
popfl
jmp *_g_aiSelectOrig
)");
// TryResearchCandidate: ECX = the out slot, EDX = candidate word 1, and two stack arguments
// (agent, candidate word 0). After pushfl+pushal the return address is at esp+36 and those two are
// at esp+40 and esp+44; each push shifts the rest by four, so the reads walk backwards.
asm(R"(
.text
.globl _AiCandDetour
_AiCandDetour:
pushfl
pushal
pushl 44(%esp)
pushl 44(%esp)
pushl %edx
pushl %ecx
call _AiOnResearchCandidate
addl $16, %esp
popal
popfl
jmp *_g_aiCandOrig
)");
// cl_SetResearchTarget is __cdecl with one stack argument, the tech NAME.
asm(R"(
.text
.globl _AiSetTargetDetour
_AiSetTargetDetour:
pushfl
pushal
pushl 40(%esp)
call _AiOnSetResearchTarget
addl $4, %esp
popal
popfl
jmp *_g_aiSetTargetOrig
)");
asm(R"(
.text
.globl _AiBatchDetour
_AiBatchDetour:
pushfl
pushal
pushl 44(%esp)
pushl 44(%esp)
call _AiOnBatch
addl $8, %esp
popal
popfl
jmp *_g_aiBatchOrig
)");
bool ai_orders_config(const char* key, const char* value, std::string* err) {
if (std::strcmp(key, "aiorders") == 0) {
if (std::strcmp(value, "on") == 0) g_enabled = true;
else if (std::strcmp(value, "off") == 0) g_enabled = false;
else if (err) *err = "expected on|off";
return true;
}
if (std::strcmp(key, "aiorders.out") == 0) {
std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
return true;
}
if (std::strcmp(key, "airesearch") == 0) {
if (std::strcmp(value, "on") == 0) g_research = true;
else if (std::strcmp(value, "off") == 0) g_research = false;
else if (err) *err = "expected on|off";
return true;
}
if (std::strcmp(key, "aiprobes") == 0) {
if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) {
g_probeInstallCount = 0;
} else if (std::strcmp(value, "all") == 0 || std::strcmp(value, "on") == 0) {
g_probeInstallCount = kProbeCount;
} else {
char* end = nullptr;
const long v = std::strtol(value, &end, 10);
if (end == value || v < 0) {
if (err) *err = "expected off|all|N";
return true;
}
g_probeInstallCount =
static_cast<std::size_t>(v) < kProbeCount ? static_cast<std::size_t>(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<unsigned>(g_probeInstallCount), static_cast<unsigned>(kProbeCount));
void* target =
reinterpret_cast<void*>(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch);
MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast<void*>(&AiBatchDetour), &g_aiBatchOrig);
MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1;
LogF("aiorders: batch hook StrategySim::ApplyTurnCommandBatch rva=0x%08x va=%p create=%s "
"enable=%s",
sots::addr::StrategySim_ApplyTurnCommandBatch, target, MH_StatusToString(s1),
MH_StatusToString(s2));
if (s2 != MH_OK)
LogF("COVERAGE: aiorders batch hook NOT INSTALLED -- no block will be dumped, and an empty "
"report means the instrument failed, not that the AI emitted nothing");
if (g_research) {
const struct {
const char* name;
std::uint32_t rva;
void* detour;
void** tramp;
} kResearch[3] = {
{"StrategyAIAgent::SelectResearchTarget", sots::addr::StrategyAIAgent_SelectResearchTarget,
reinterpret_cast<void*>(&AiSelectDetour), &g_aiSelectOrig},
{"StrategyAIAgent::TryResearchCandidate", sots::addr::StrategyAIAgent_TryResearchCandidate,
reinterpret_cast<void*>(&AiCandDetour), &g_aiCandOrig},
{"cl_SetResearchTarget", sots::addr::cl_SetResearchTarget,
reinterpret_cast<void*>(&AiSetTargetDetour), &g_aiSetTargetOrig},
};
for (const auto& r : kResearch) {
void* t = reinterpret_cast<void*>(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<void*>(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<unsigned>(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

View file

@ -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 <cstddef>
#include <cstdint>
#include <string>
namespace shim::hooks {
// `aiorders=on|off`, `aiprobes=off|all|N`, `aiorders.out=<path>`. 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

View file

@ -28,6 +28,7 @@
#include "shim/hooks/probe_entry.h" #include "shim/hooks/probe_entry.h"
#include "shim/hooks/tail_rng.h" #include "shim/hooks/tail_rng.h"
#include "shim/hooks/tech_effects.h" #include "shim/hooks/tech_effects.h"
#include "shim/hooks/ai_orders.h"
#include "shim/hooks/watchpoints.h" #include "shim/hooks/watchpoints.h"
#include "shim/trace/hook.h" #include "shim/trace/hook.h"
#include "shim/trace/selftest.h" #include "shim/trace/selftest.h"
@ -90,6 +91,9 @@ Config ReadConfig() {
if (shim::hooks::probe_config(p, val, &probe_n)) { if (shim::hooks::probe_config(p, val, &probe_n)) {
Log("config: %s=%s -> %u lane-H entry probes", p, val, Log("config: %s=%s -> %u lane-H entry probes", p, val,
static_cast<unsigned>(probe_n)); static_cast<unsigned>(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)) { } else if (shim::hooks::watch_apply_config(p, val, &err)) {
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str()); if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
else Log("config: %s=%s", p, val); else Log("config: %s=%s", p, val);
@ -310,6 +314,12 @@ void InstallHooks(shim::trace::Tracer& tracer) {
// Lane W2: hardware data-write watchpoints. One MinHook detour (the arming point); the // 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). // watchpoints themselves modify no code at all. Off unless `watch=on` (rule 19).
shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine); 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. // 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. // Installed last so it is nowhere near the template hooks it is meant to measure.
@ -401,6 +411,7 @@ void Shim_Init(HMODULE self) {
void Shim_Shutdown() { void Shim_Shutdown() {
shim::hooks::watch_flush(&ShimLogLine); shim::hooks::watch_flush(&ShimLogLine);
shim::hooks::ai_orders_flush(&ShimLogLine);
Log("%s", shim::fpu::summary().c_str()); Log("%s", shim::fpu::summary().c_str());
shim::trace::Tracer& tracer = shim::trace::Tracer::instance(); shim::trace::Tracer& tracer = shim::trace::Tracer::instance();
if (tracer.is_open()) { if (tracer.is_open()) {

View file

@ -0,0 +1,40 @@
# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE.
#
# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off),
# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top
# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the
# pairwise single-key differences are what localise a failure to one half of the instrument.
#
# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have
# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make
# every byte comparison below meaningless.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerSystem::GroupOutput=off
hook.Game::ServerSystem::ComputeTotalOutput=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::EncounterDetect::ProcessTeamRecord=off
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=off
watch=off
aiorders.out=C:\SOTS\shim.aiorders.txt
aiorders=off
aiprobes=off
airesearch=off

40
src/shim/shim.cfg.l4dump Normal file
View file

@ -0,0 +1,40 @@
# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE.
#
# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off),
# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top
# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the
# pairwise single-key differences are what localise a failure to one half of the instrument.
#
# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have
# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make
# every byte comparison below meaningless.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerSystem::GroupOutput=off
hook.Game::ServerSystem::ComputeTotalOutput=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::EncounterDetect::ProcessTeamRecord=off
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=off
watch=off
aiorders.out=C:\SOTS\shim.aiorders.txt
aiorders=on
aiprobes=off
airesearch=off

6
src/shim/shim.cfg.l4off Normal file
View file

@ -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

View file

@ -0,0 +1,40 @@
# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE.
#
# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off),
# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top
# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the
# pairwise single-key differences are what localise a failure to one half of the instrument.
#
# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have
# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make
# every byte comparison below meaningless.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerSystem::GroupOutput=off
hook.Game::ServerSystem::ComputeTotalOutput=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::EncounterDetect::ProcessTeamRecord=off
trace.path=C:\SOTS\shim.trace.jsonl
trace.flush=always
probes=off
watch=off
aiorders.out=C:\SOTS\shim.aiorders.txt
aiorders=on
aiprobes=all
airesearch=off

View file

@ -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

View file

@ -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_include_directories(game_ai_test_agent PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(game_ai_test_agent PRIVATE -Wall -Wextra -pedantic) target_compile_options(game_ai_test_agent PRIVATE -Wall -Wextra -pedantic)
add_test(NAME game_ai_agent COMMAND game_ai_test_agent) 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)

View file

@ -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 <cstdio>
#include <string>
#include <vector>
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<TurnCommandBlock> CaptureTurnOneToTwo() {
std::vector<TurnCommandBlock> 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<TurnCommandBlock> 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<CommandList>(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<TurnCommandBlock> CaptureTurnTwoToThree() {
std::vector<TurnCommandBlock> 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<TurnCommandBlock> 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<CommandList>(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<TurnCommandBlock> with =
turn == 0 ? CaptureTurnOneToTwo() : CaptureTurnTwoToThree();
std::vector<TurnCommandBlock> without = with;
without[1].unmodelled[static_cast<int>(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<TurnCommandBlock> 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;
}

View file

@ -211,7 +211,11 @@ void TestReferenceTurnTwoToThree() {
ai.OrderSystemRates(SystemRatesOrder{}); ai.OrderSystemRates(SystemRatesOrder{});
ai.OrderBuild(BuildOrder{}); ai.OrderBuild(BuildOrder{});
ai.OrderUnmodelled(CommandList::List10, 1); 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); ai.EndTurn(0.8f);
blocks.push_back(ai.block()); blocks.push_back(ai.block());
@ -246,7 +250,7 @@ void TestReferenceTurnOneToTwo() {
OrderClient ai(32); OrderClient ai(32);
ai.SetResearchRate(0.8f); 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.OrderUnmodelled(CommandList::NewDesigns, 1); // the new hull
ai.OrderBuild(BuildOrder{}); // and the order to build it ai.OrderBuild(BuildOrder{}); // and the order to build it
ai.OrderSystemRates(SystemRatesOrder{}); ai.OrderSystemRates(SystemRatesOrder{});
@ -255,13 +259,14 @@ void TestReferenceTurnOneToTwo() {
OrderClient dormantA(496); OrderClient dormantA(496);
dormantA.SetResearchRate(0.8f); dormantA.SetResearchRate(0.8f);
dormantA.SetResearchTarget(2); // DRV_PlsFiss dormantA.SetResearchTarget(90); // DRV_PlsFiss, read live
dormantA.EndTurn(0.8f); dormantA.EndTurn(0.8f);
blocks.push_back(dormantA.block()); blocks.push_back(dormantA.block());
OrderClient dormantB(512); OrderClient dormantB(512);
dormantB.SetResearchRate(0.8f); 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); dormantB.EndTurn(0.8f);
blocks.push_back(dormantB.block()); blocks.push_back(dormantB.block());