CB: take lane L4's ai_orders instrument as the base for the command-stream capture
The capture lane needs the block dump L4 built; branching off main without it would mean writing the same detour twice. Header regenerated from sots-re (rule 14), not hand-resolved: 1,217 entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
This commit is contained in:
commit
c67374d2e3
15 changed files with 1673 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
114
docs/L4-ai-orders.md
Normal file
114
docs/L4-ai-orders.md
Normal 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
338
docs/L4-predictions.md
Normal 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.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||
// Source: sots-re ghidra/addresses.json @ 940aeca, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 7924583, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
|
|||
710
src/shim/hooks/ai_orders.cpp
Normal file
710
src/shim/hooks/ai_orders.cpp
Normal 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
|
||||
66
src/shim/hooks/ai_orders.h
Normal file
66
src/shim/hooks/ai_orders.h
Normal 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
|
||||
|
|
@ -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<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)) {
|
||||
if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str());
|
||||
else Log("config: %s=%s", p, val);
|
||||
|
|
@ -310,6 +314,12 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
|||
// Lane W2: hardware data-write watchpoints. One MinHook detour (the arming point); the
|
||||
// watchpoints themselves modify no code at all. Off unless `watch=on` (rule 19).
|
||||
shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine);
|
||||
// Lane L4: the AI command-block dump plus its entry probes. Off unless `aiorders=on`, and
|
||||
// `aiorders=on` alone installs exactly ONE detour -- `aiprobes=` adds the rest, so the two
|
||||
// halves of the instrument can be given separate rule-19 controls. Its batch target
|
||||
// (ApplyTurnCommandBatch) is a different function from the watchpoint module's arming point
|
||||
// (ApplyAllTurnCommands, its only caller), so the two never contend for a MinHook target.
|
||||
shim::hooks::install_ai_orders(exeBase, g_dir, &ShimLogLine);
|
||||
|
||||
// Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler.
|
||||
// Installed last so it is nowhere near the template hooks it is meant to measure.
|
||||
|
|
@ -401,6 +411,7 @@ void Shim_Init(HMODULE self) {
|
|||
|
||||
void Shim_Shutdown() {
|
||||
shim::hooks::watch_flush(&ShimLogLine);
|
||||
shim::hooks::ai_orders_flush(&ShimLogLine);
|
||||
Log("%s", shim::fpu::summary().c_str());
|
||||
shim::trace::Tracer& tracer = shim::trace::Tracer::instance();
|
||||
if (tracer.is_open()) {
|
||||
|
|
|
|||
40
src/shim/shim.cfg.l4control
Normal file
40
src/shim/shim.cfg.l4control
Normal 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
40
src/shim/shim.cfg.l4dump
Normal 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
6
src/shim/shim.cfg.l4off
Normal 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
|
||||
40
src/shim/shim.cfg.l4probes
Normal file
40
src/shim/shim.cfg.l4probes
Normal 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
|
||||
40
src/shim/shim.cfg.l4research
Normal file
40
src/shim/shim.cfg.l4research
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
248
tests/game_ai/test_live_blocks.cpp
Normal file
248
tests/game_ai/test_live_blocks.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue