merge lane CB: capture with payloads and seeds (header regenerated, not hand-resolved)

This commit is contained in:
alex 2026-09-08 19:27:40 -04:00
commit 9fcf220bae
8 changed files with 844 additions and 10 deletions

96
docs/CB-capture.md Normal file
View file

@ -0,0 +1,96 @@
# CB — the capture instrument, and what it measured
Lane CB, 2026-09-08. Guest VM146, build `cb2-20260908T2251Z`. Predictions and their outcomes are in
`docs/CB-predictions.md`; the RE-side write-up, the captures and the raw logs are in `sots-re`
(`findings/subsystems/turncommands-capture.md`, `verify/results/turncommands/`).
This document is the engine-side record: what changed in the shim, and the two facts the change
bought that a replayer needs.
---
## 1. What changed
Three additions to lane L4's `src/shim/hooks/ai_orders.cpp`, and nothing else. L4's design point is
kept unchanged: **the instrument reads element bytes, not element types**, and the typing happens
offline in `sots-re/tools/turncommands_capture.py`, so a corrected record costs a re-parse instead
of a rebuild, a redeploy and a turn. That paid for itself twice in one session — see §4.
| key | default | what |
|---|---|---|
| `aiorders.words=<N>` | 12 | the element window, ≤ 64. List 1's `ShipDesignDef` puts its name past 48 bytes. |
| `aiorders.deep=on\|off` | off | follow the heap: `{_Myfirst,_Mylast,_Myend}` and MSVC `std::string` shapes inside the window. |
| `aiseed=off\|log\|pin` + `aiseed.values=<netid>=<hex>,…` | off | read — or **replace** — the fourth stack argument of `Game::StrategyApp::RunAI`. |
`aiorders=on aiprobes=off` is still one detour; `aiseed=` adds a second. Configs:
`shim.cfg.cboff` (the control, installs nothing), `shim.cfg.cbcapture`, `shim.cfg.cbtrap`,
`shim.cfg.cbpin`.
**Three payloads in the canonical block are behind pointers** — list 8's route, list 10's counted
vector, list 23's `Population` body. Without `deep` they are **absent**, not empty, and a fleet
move with no route reads exactly like a fleet move that goes nowhere. That is why `aicfg words= deep=`
is printed into every capture: a reader must never have to guess which of the two it is looking at.
## 2. `aiseed=pin` is an intervention, not an instrument, and is logged as one
Every call logs `observed`, `used` and `pinned`, so a pinned run says so in its own capture and can
never be read as a natural one.
It exists because of a control problem, not a curiosity. `turn1-state → turn2` has an outcome set
of size k > 1: three lanes ran it with `hooks=off` and got three different files. On such a workload
**no single un-instrumented run is a control**, and an instrumented run agreeing with one of them is
a 1/k coincidence — at lane L4's k = 6 that is a 17 % chance of a clean bill of health that means
nothing. Pinning removes the only per-process input and makes a control possible.
It worked, three times over. Three processes on that save, each drawing its own three natural seeds
(nine distinct words in all), two of them pinned to the first's, produced **one** autosave
(`d59bb9f2fd0eb535…`) with identical gates — including player 512's research target `282`, the value
that varies — and identical element records. Two runs agreeing on a `k > 1` workload is a `1/k`
coincidence; three is `1/k²`.
## 3. What a replayer needs from this, in one place
* **The seeds are part of the stream.** `game/ai` is a deterministic function of
(save, per-client seed). A capture without them records the AI's answer with no record of its
input.
* **Element sizes, measured** (not read): list 3 = 4 words; list 5 = 8; list 8 = 1 + vector;
list 10 = 2 + vector; list 14 = 2 words **+ one byte**; list 23 = 1 + vptr + vector. Established
by comparing two runs that wrote byte-identical autosaves — a word that differs between them is
not a word the applier reads — and agreeing with the writer's own instruction stream on all six.
* **List 14's bool is one byte.** The other three bytes of its word are heap garbage that differs
run to run. Compare the byte, not the word.
* **List 3's element is stored backwards** relative to its wire order. Per-list, not a rule.
## 4. Two records corrected, one of them mine
**Lane L4's list-23 reading, twice.** `Population{vptr, vector(24 B), −1}` has no trailing int —
the word after the vector differs between two runs whose autosaves are byte-identical, so it is an
allocator cookie. And the body is **not** turn-dependent: five of its six words are the same on
turn 1 and turn 2, and the sixth is a per-process pointer.
**My own list-5 record, found by lane RB while consuming the capture.** The element is dumped in
*memory* order and its memory field order is not its wire order; **memory member 1 is wire member
`SRsc`**, six members unread. RB's first replay predicted zero regressions and got two. The record
is now **dropped** rather than relabelled: the values ship as `rates_memory_order_{u32,f32}` with
`wire: null`, and the `.tcb` emits the seven `?` tokens RB's own converter emits, so a replayer
refuses rather than guesses. A missing field is honest; a mislabelled one propagates — and it would
have propagated inside the artefact that is meant to outlive the adapter that read it.
That is the capture/replay pairing doing its job in both directions, which is the argument for
having run the two lanes together.
## 5. 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-cb`, `DIST=/srv/re-lab/shim/dist-cb`), on a
**freshly extracted tree with `build-shim` removed** (rule 24) — **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 (rule 14), never hand-resolved — the merge of lane L4's
instrument conflicted there and regeneration is what resolved it. **This lane adds no addresses**:
it used `StrategySim_ApplyTurnCommandBatch` and `StrategyApp_RunAI`, both already carried by lanes
A2 and AI1, so there is no `addresses.d/lane-cb.json` and there should not be one.

290
docs/CB-predictions.md Normal file
View file

@ -0,0 +1,290 @@
# CB — predictions for the command-stream capture, written before the module exists
Lane CB, 2026-09-08. Worktree `wip/cb` off `main` `4f25f1e` (the brief said `7444c3b`; the
integrator merged L1 in the meantime, so this branch is based on the newer main and says so).
Guest **VM146** (`192.168.10.146`). Committed **before** `ai_orders.cpp` is touched and before
anything is built.
Lane RB is building `sots_turn --turn-commands <blocks>`. This lane supplies its input. A replay
with no real stream is untested and a stream with no autosave to check it against is a log file, so
every prediction below is either *about the stream* or *about the binding between the stream and
the autosave of the run that produced it*.
---
## 0. What is being changed, and why it is small
Lane L4 already dumps the block set at `StrategySim::ApplyTurnCommandBatch` — one
register-transparent entry stub, read-only, measured behaviour-neutral on the canonical pair. This
lane does **not** rebuild that. It adds three things and nothing else:
1. **`aiorders.words=<N>`** — the element window, today a fixed 12 words. List 1 carries a
polymorphic `ShipDesignDef` whose id sits past 48 bytes, so the turn-1 workload cannot be
captured completely at 12.
2. **`aiorders.deep=on`** — follow the heap. Three payloads in the canonical block are behind
pointers and are therefore *absent* from L4's capture: list 8's route vector, list 10's counted
vector, and list 23's `Population` body. A replayer cannot reconstruct a fleet move without the
route. The follower is deliberately **generic** — it scans the element window for anything
shaped like `{_Myfirst,_Mylast,_Myend}` and for anything shaped like an MSVC `std::string`, and
dumps the bytes. It does **not** know which list it is looking at.
3. Nothing in the instrument types an element. L4's design point — *the dump reads element bytes,
not element types; decoding happens offline so a wrong record shows up as a wrong value instead
of being baked into the instrument* — is kept, and the typed decode moves to a host-side Python
step that can be corrected without another VM run.
So `aiorders=on aiprobes=off` remains **exactly one MinHook detour**, and the added work inside it
is guarded reads of memory the game already owns.
---
## 1. The capture format (defined here because RB may not have landed first)
One JSON file per run under `verify/results/turncommands/`. Mechanical, and the raw words are the
ground truth:
* `binding` — the input save and both output autosaves, each with size and sha256, plus whether
they match the published determinism oracle. **A capture whose `binding.outputs` is absent or
whose hashes do not match the run is worthless for a byte-match and must be treated as such.**
* `batches[].blocks[]` — one entry per block slot, `playerId`, the six gates with their payloads,
and all 27 lists.
* Each element carries `raw_words` (the window, verbatim), `vectors` (each followed heap vector,
with its element words), `strings` (each decoded `std::string`), and `decoded` — a named record
**only where the campaign has one**, with a `record` field naming it and a `wire` array giving
the values in the order the writer emits them.
* `decoded` is regenerable from `raw_words` by `tools/turncommands_capture.py`. RB should prefer
`decoded.wire` and fall back to `raw_words`; if the two ever disagree, `raw_words` wins.
The one thing the decoder must get right and that is easy to get wrong: **list 3's in-memory
element is in the opposite order from its wire record.** Lane L4 read that off the writer
(`0x00822870` emits `+0x14, +0x10, +0x0c, +0x08`, descending); lists 5, 8, 10, 14 and 23 all write
ascending. That is per-list, not a rule, and the decoder encodes it as such.
---
## 2. Runs, and what each is for
| run | save | `hooks` | `aiorders` | `watch` | purpose |
|---|---|---|---|---|---|
| **C0** | `ref-turn2.sav` | `off` | — | `off` | rule-19 control: the oracle bytes with no instrument at all |
| **C1** | `ref-turn2.sav` | `trace` | `on`, words=32, deep | `off` | **the deliverable** — canonical pair capture |
| **C2** | `ref-turn2.sav` | `trace` | `on`, words=32, deep | `on` (modcount) | the trap multiset, as RB's second check |
| **C3** | `turn1-state.sav` | `trace` | `on`, words=32, deep | `off` | the non-deterministic pair, block + *its own* autosave |
| **C4** | `turn1-state.sav` | `trace` | `on`, words=32, deep | `off` | the same turn twice |
---
## 3. Predictions
### P1 — C0 reproduces the oracle. (control)
`(Autosave EndTurn).sav` = 66,732 B, `bb4fd9ac89f41e3b`; `(Autosave).sav` = 67,219 B,
`978041acd168b56e`. If this fails, **the guest is not the reference guest** and nothing else in
this lane means anything; lane L4 already reproduced these on VM145 and lane L5 held VM146, so a
failure here is a lab fact, not a finding.
### P2 — C1's autosaves are byte-identical to C0's, so the capture binds.
The deliverable stands or falls on this. Lane L4 ran **seventeen** detours on this same workload
and got the oracle bytes; this run has **one**, doing strictly more reads inside it. If C1's
autosaves differ from C0's, the capture is a stream that changed the turn it recorded, it is
unusable for a byte-match, and the report must lead with that (rule 19).
### P3 — the block set on the canonical pair is exactly lane L4's, plus the three payloads.
Two batches: `seq=1` at load (`n=1`, pid 16, all 27 lists empty) and `seq=2` at End Turn (`n=8`).
Slots 0–3 are pids **16, 32, 496, 512**; slots 4–7 are pid **0** with uninitialised gate payloads.
Only pid 32 is non-empty, with list sizes `3:1, 5:1, 8:1, 10:1, 14:2, 23:1`. Every rate gate on the
four real players is **set**; no research-target gate is set on any player this turn.
New, and the reason for the run:
* **P3a — list 8's route vector has count 1.** Its single value is the destination system id.
L4 could not read it and noted that `turn3-state.sav`'s waypoint says **272**. Predict **272**.
*Falsifier:* any other id, in which case the waypoint in the output save is not the route in the
command and the replayer has to carry both.
* **P3b — list 10's counted vector has count 1.** L4's reading is "at system 288, fleet 34, [one
object]". Predict the value is a **ship id that already exists in `ref-turn2.sav`** — the
pre-existing ship being folded into the newly allocated fleet 34. *Falsifier:* a small
client-allocated id in the 18/34 space, which would mean the client allocates the ship too and
the id-allocation hole L4 left open is wider than one counter.
* **P3c — list 23's `Population` vector is 24 bytes, i.e. six words**, at a stride the dump does
not name. Predict the six words are **not all zero** and that they **differ between the turn-2
and turn-1 captures** (L4 saw the element's trailing int go `+1` on turn 1 and `−1` on turn 2, so
the body is turn-dependent and not a constant the replayer can ignore).
### P4 — C2's trap multiset is exactly ten bumps, and they attribute.
The paying half of pid 32's block is `3:1 + 5:1 + 8:1 + 10:1 + 14:2` = **6 elements**, list 23
being free; plus **4** research-rate gates, one per submitted block, set unconditionally. Total
**10** — which is the number lane W2 measured on this turn from the other end, before anyone had
seen the block. Predicted per-EIP:
| trap EIP | count | what |
|---|---:|---|
| `0x00821a87` | 4 | prologue gate `+0x0c`, research rate, one per submitted block |
| `0x0084946e` | 1 | list 3, build |
| `0x0086c3e9` | 1 | list 5, system rates |
| `0x0088bf01` | 1 | list 10 |
| inlined at `ApplyTurnCommandBatch+0xabc` | 2 | list 14, the AI's two `(fleet, mode)` elements |
| list 8's site | 1 | fleet move |
*Falsifier:* any total other than 10, or any bump attributed to a list this capture reports empty.
That would mean the counter is not the length of the command stream and the whole
`ModCount`-from-the-block claim in `src/game/ai/orders.h` is wrong.
*Rule 19 for C2 specifically:* the watchpoint module has never been run in the same process as the
block dump. If C2's autosaves move off the oracle, C2 is reported as perturbing and **C1 remains
the deliverable** — the trap set is a bonus check, not the capture.
### P5 — C3 and C4 differ in exactly one word of the stream.
Both from `turn1-state.sav`. Predicted block: pid 32 with lists `1:1, 3:1, 5:1, 23:1`, three
research-**target** gates set (pids 32, 496, 512) and the human's clear, four rate gates set.
The prediction that matters: **C3's and C4's block sets are byte-identical except for player 512's
research-target payload at `block+0x10`**, and each run's autosave differs from the other's only in
`p512.ResTNm` and the derived checksum. That is Rung B's claim in its sharpest form — if the two
streams differ *only* where the two autosaves differ, then the sim is a function of the stream and
the decision layer is the only thing that is not.
*Falsifiers, and each is worth more than the prediction:*
* *The two blocks are identical and the two autosaves differ.* Then the stream does **not**
determine the save, Rung B is not reachable by replay alone, and the divergence is downstream of
the command — which contradicts L4 §3.1, which saw it in the block.
* *The two blocks differ somewhere else as well* (an id, an ordinal, a route). Then the
client-allocated id space is also per-process and a replay has to carry ids it cannot derive.
* *Player 512's target lands on a tech outside the six-member `XNC` set L4 named.* Then k is larger
than six and Rung C's tie-set claim has to start one level up. `BIO_GnMod` has already been
observed once, so this is not unlikely; a second `BIO` value would settle that the resolver
leaves the family.
### P6 — list 1's design element yields its id and its name at words=32.
`"Honor Lance"` is a short-string-optimised `std::string` inside the element, and design id **18**
is named by list 3 in the same block. Predict the widened window shows the string *and* a word
equal to **18** inside the list-1 element. *Falsifier:* no 18 anywhere in 128 bytes, in which case
L4 §1 P3 stands as it is — the design command does not carry its own id and the id is inferred.
### P7 — what this lane will **not** be able to say.
Stated in advance so a green capture is not read as more than it is (rule 15):
* Two turns, one AI empire with anything to do, a board with no contact. Lists 2, 4, 6, 7, 9,
11–22, 24–27 will be **empty in every block of every run**, exactly as they have been in all
eleven corpus saves and both of L4's runs. The capture exercises **six** of twenty-seven lists
and **one** of six gates on the canonical pair, two of six on turn 1. Everything the replayer
does with the other twenty-one lists is untested by this lane and must be labelled as such.
* The generic vector follower can produce a **false positive** — three consecutive words that
happen to look like a vector. The count bound and the readability probe make that unlikely, not
impossible. Any `aivec` line on a list whose record has no vector is to be read as noise until a
second run reproduces it.
* Nothing here says what list 10 or list 23 *mean*. It says what bytes they carry.
---
# Addendum — the seeds are part of the stream (written before C3/C4/C5, after C0/C1 were driven)
The coordinator relayed lane L1's seed probe mid-lane: **every AI client's generator is seeded with
a fresh per-process 32-bit word**, three per process, none shared between processes. So the AI is
not non-deterministic — it is MT19937 from one word per client, and MT19937 is a generator this
campaign owns bit for bit. `game/ai` is a deterministic function of **(save, per-client seed)**.
That changes what a capture *is*. A block set without its seeds records the AI's **answer** without
its **input**: replayable, but not re-derivable and not re-runnable. Three words turn a log file
into a reproducible pair. The capture format therefore grows one field, and the instrument grows
one detour:
```
"ai_seeds": [ {"call": 1, "netId": 32, "observed": "0x…", "used": "0x…", "pinned": false}, … ]
```
`observed` is what the game chose; `used` is what the client actually got; `pinned` says the run
**forced** it. Every call logs all three, so a pinned run can never be mistaken for a natural one —
which matters because pinning is an intervention, not an instrument.
`aiseed=log` reads the fourth stack argument of `Game::StrategyApp::RunAI` where the callee will
read it and changes nothing. `aiseed=pin` **overwrites** it before the callee sees it.
## P8 — the new rule-26 control, stated as a prediction about method
Three lanes ran `hooks=off` on `turn1-state` and got **three different files**. So no single
un-instrumented run of that workload is a control, and an instrumented run agreeing with one of
them is a **~1/k coincidence** rather than evidence — with k = 6 by lane L4's naming of the `XNC`
set, that is a 17% chance of a false clean bill of health. **C0 is a valid control for
`ref-turn2 → turn3` and for nothing else.** Any rule-19 claim this lane makes about the turn-1
workload must come from a *pinned* pair, or it must not be made.
## P9 — C5: pinned, the same seeds, twice.
C5 is C3 re-run with `aiseed=pin` set to the seeds C3 itself observed. Predicted: **C5a and C5b
produce byte-identical autosaves**, and both are byte-identical to C3's, because the seed is the
only per-process input and it has been removed. If that holds, it is the first fully reproducible
creation-turn record this campaign has: block set, seeds and autosave, all from runs that agree.
*Falsifiers, each worth more than the prediction:*
* *C5a and C5b still differ.* Then the seed is **not** the only per-process input, L1's mechanism is
incomplete, and Rung B needs whatever else varies captured too. This is the single most valuable
outcome available in this lane.
* *C5 is reproducible but differs from C3.* Then pinning perturbs beyond the seed value — most
likely because the detour changes *when* the word is consumed, not only what it is.
* *`RunAI` is never entered.* Then the seed does not reach the client by this path on this workload
and the pin is a no-op that would look like a successful control. The instrument prints its own
entry count precisely so this is visible; a silent empty seed list would be exactly the rule-1
failure of a confident zero.
## P10 — the target gate's resolved name (lane RB's request)
RB's §163 asks the format to carry the **observed tech name** next to the target id, because the
target-gate handler cannot be modelled from the id alone. `airesearch=on` resolves it — phase 18
pushes the tech's `std::string` as a `char*`, which is why it is loggable at all. Predict the three
turn-1 target gates (ids 144, 90, 288) resolve to three names, of which player 512's is the one that
moves between runs. Adding those three detours is a **rule-19 risk on a workload that has no valid
control**, so it goes on the turn-1 runs only, never on the canonical pair.
---
# Outcomes recorded against the predictions above
## P1 held. C0, `hooks=off`, reproduced the oracle exactly
`bb4fd9ac89f41e3b…` / 66,732 B and `978041acd168b56e…` / 67,219 B. VM146 is a faithful reference
guest and the control is real.
## P2 held. C1's autosaves are byte-identical to C0's, so the capture binds
One detour (the block dump) plus one (the seed reader), both read-only, and both oracle hashes
came back. The stream and the autosave are from the same run.
## P3 held, with P3b half-falsified
`272` for the route (P3a, exactly as predicted from the output save's waypoint). **`1728` for list
10's counted vector — a master-counter id, not a client-allocated one (P3b's falsifier).** The
`Population` body is 24 bytes and turn-dependent (P3c).
## P7's false-positive warning was right, and it cost nothing
At `words=32` the generic vector follower fires on neighbouring heap: 18 `aivec` lines on the
canonical block, of which **three** sit at a word index a record names. The decoder reads only those
three, so the noise is visible and inert — which is the outcome the warning was written for.
## A defect found by the consumer, not by me — list 5
Lane RB consumed this capture and reported that my list-5 record was **wrong**: the element is
dumped in *memory* order and its memory field order is not its wire order. **Memory member 1 is
wire member `SRsc`**; six members are unread. RB predicted zero regressions on its first replay and
got two, the AI's one non-zero slider landing in `SRt` where the oracle holds `SRsc = 1.0`.
The record is now **dropped**, per RB's recommendation and against my own first instinct to
relabel it positionally: the values ship as `rates_memory_order_{u32,f32}` with `wire: null`, and
the `.tcb` emits the seven `?` tokens lane RB's own converter emits. A replayer that needs them
must refuse rather than guess. **A missing field is honest; a mislabelled one propagates** — and it
would have propagated inside the artefact that is supposed to outlive the adapter that read it.
## What the capture bought, reported by the consumer
`sots_turn --turn-commands` puts `/Sim/ModCount` on **24 exactly, zero residual**: 2 drivers + 4
research-rate gates + list 3 + list 5 + list 10 + 2 × list 14 + list 8, with the list-23 population
element **free**. That leaf has been "not derivable from a save" since lane A2. The 1–16 / 17–27
cost boundary in `src/game/ai/orders.h` held on the first workload that ever populated the free
half.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ ce8b3e6, generated 2026-09-08 by tools/gen_addresses.py // Source: sots-re ghidra/addresses.json @ 97f4cc7, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once #pragma once
#include <cstdint> #include <cstdint>
@ -2083,8 +2083,10 @@ constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0;
constexpr uint32_t NodeRoute_Construct = 0x002e1b20; constexpr uint32_t NodeRoute_Construct = 0x002e1b20;
// cdecl bool (StarFleet* f) // 90 B. FUN_006fe320(f) returns f->LocID(+0xa0) ONLY when the location's kind tag (+0x14) is 2, a DEEP-SPACE POINT -- never a system. Returns true iff any of the three position components differs by exact IEEE comparison (fucompp, test ah,0x44, jp), no epsilon. OrderFleetMove's opening snap is therefore point-only; combat-retreat-pipeline.md 2.5's 'snaps the fleet's position onto its current system' is corrected here -- a fleet parked at a system is never snapped [verified] // cdecl bool (StarFleet* f) // 90 B. FUN_006fe320(f) returns f->LocID(+0xa0) ONLY when the location's kind tag (+0x14) is 2, a DEEP-SPACE POINT -- never a system. Returns true iff any of the three position components differs by exact IEEE comparison (fucompp, test ah,0x44, jp), no epsilon. OrderFleetMove's opening snap is therefore point-only; combat-retreat-pipeline.md 2.5's 'snaps the fleet's position onto its current system' is corrected here -- a fleet parked at a system is never snapped [verified]
constexpr uint32_t StarFleet_PosDiffersFromPointLocation = 0x0040ec50; constexpr uint32_t StarFleet_PosDiffersFromPointLocation = 0x0040ec50;
// offset int -- the owning player's NET id on a Game::StrategyClient (0x708 bytes). Verified from the instruction stream in Game::StrategyApp::RunPendingAITurns 0x00838c60: the pending-AI vector at app+0x1c..+0x20 is walked in index order and each entry `edi` is matched against the client vector at app+0xc..+0x10 with `mov esi,[edx]; cmp DWORD PTR [esi+0x148],edi; je ...` at 0x00838cf0-0x00838cf8, where esi is a StrategyClient* and edi is a player net id. Corroborated by two independent published readings that both call it the player id without naming the constant: ClientOrder_FleetTask 0x007634d0 passes `this->+0x148` as the first argument of the local validate 0x00821cf0, and cl_EndTurn's family indexes the same field. The value matches the `pid=` column the lane-L4 aiorders dump prints from the submitted TurnCommands block (16, 32, 496, 512 on the reference board), which is the live cross-check [verified] // offset int -- the owning player's NET id on a Game::StrategyClient (0x708 bytes). Verified from the instruction stream in Game::StrategyApp::RunPendingAITurns 0x00838c60: the pending-AI vector at app+0x1c..+0x20 is walked in index order and each entry `edi` is matched against the client vector at app+0xc..+0x10 with `mov esi,[edx]; cmp DWORD PTR [esi+0x148],edi; je ...` at 0x00838cf0-0x00838cf8, where esi is a StrategyClient* and edi is a player net id. Corroborated by ClientOrder_FleetTask 0x007634d0, which passes `this->+0x148` as the first argument of the local validate 0x00821cf0. LIVE CROSS-CHECK: read out of the running game by lane PAR's OnResumePlaying bracket, it gives 16 / 32 / 496 / 512 on the reference board -- exactly the player ids the lane-L4 aiorders dump prints from the submitted TurnCommands blocks [verified]
constexpr uint32_t StrategyClient_off_PlayerId = 0x00000148; constexpr uint32_t StrategyClient_off_PlayerId = 0x00000148;
// cdecl float () -- the THIRD member of the cl_* RNG facade, alongside cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. Whole 0x22-byte body: `eax = g_StrategyClients[g_CurrentClientIndex]; fldz; if (!eax) ret 0.0f; ecx = eax->+0x134; fstp st(0); lea ecx,[ecx+4]; JMP RNG_NextFloat` -- it reaches the generator by a TAIL JUMP, not a call, which is why an image-wide rel32 scan for the seven RNG entry points does not see it and why ai-turn-logic.md 5's \"zero calls from the AI module to NextFloat\" reads as true when it is not. Exactly ONE caller in the image, at 0x006ad873 inside the ship-design composer 0x006ad700, where the drawn unit is compared against 0.5. Because of the tail jump the draw-site detour records the return address 0x006ad878 -- the AI call site itself -- so this facade is the one whose consumer is directly visible in a draw-site table. Measured live: it spends exactly one MT word per call, 1 word on the reference turn 2->3 and 2 on turn 1->2 [verified]
constexpr uint32_t cl_RandFloat = 0x00179c70;
// data double 0xBFC3333340000000 = -0.15000000596046448 = (double)(float)-0.15f -- the elimination-limit divisor, loaded by ServerPlayer::UpdateBankruptcyLimits at 0x00818612 with `DD 05` (fld QWORD). It is the negation of g_DebtInterestRate (0x009ed188), and the two are stored separately. Named here because lane N's fix cited the value but not the address [verified] // data double 0xBFC3333340000000 = -0.15000000596046448 = (double)(float)-0.15f -- the elimination-limit divisor, loaded by ServerPlayer::UpdateBankruptcyLimits at 0x00818612 with `DD 05` (fld QWORD). It is the negation of g_DebtInterestRate (0x009ed188), and the two are stored separately. Named here because lane N's fix cited the value but not the address [verified]
constexpr uint32_t g_BankruptcyEliminationDivisor = 0x0062ec30; constexpr uint32_t g_BankruptcyEliminationDivisor = 0x0062ec30;
// data float* -> 0x00b23e28. The config loader's pointer slot for the key BANKRUPTCY_PROTECTION_LIMIT_FACTOR, the same indirection pattern as ADDICTION_INCOME_MOD's (0x00aeca48 -> 0x00aeca44). Read once in the image, by UpdateBankruptcyLimits at 0x0081866e [verified] // data float* -> 0x00b23e28. The config loader's pointer slot for the key BANKRUPTCY_PROTECTION_LIMIT_FACTOR, the same indirection pattern as ADDICTION_INCOME_MOD's (0x00aeca48 -> 0x00aeca44). Read once in the image, by UpdateBankruptcyLimits at 0x0081866e [verified]

View file

@ -25,7 +25,9 @@ FILE* g_out = nullptr;
void (*g_log)(const char*) = nullptr; void (*g_log)(const char*) = nullptr;
void LogF(const char* fmt, ...) { void LogF(const char* fmt, ...) {
char buf[1200]; // 4 KB, not 1200: lane CB's widened element window (`aiorders.words=64`) can put 64 hex words
// and 64 signed ints on one line, which is 1,408 characters before the prefix.
char buf[4096];
va_list ap; va_list ap;
va_start(ap, fmt); va_start(ap, fmt);
std::vsnprintf(buf, sizeof buf, fmt, ap); std::vsnprintf(buf, sizeof buf, fmt, ap);
@ -77,7 +79,60 @@ constexpr int kListCount = 27;
// {i32, OutputRates frame}; 48 bytes covers every scalar-only record with room to spare and is // {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 // 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. // printed as `????????` rather than as zero, so truncation is visible.
constexpr int kElemWords = 12; //
// LANE CB widened this to a configurable window (`aiorders.words=`), because list 1's element is a
// polymorphic `ShipDesignDef` whose id sits past 48 bytes -- L4 §1 P3 could not read it and said
// so. The default is still 12, so a run that does not ask for more gets L4's exact behaviour.
constexpr int kMaxElemWords = 64;
int g_elemWords = 12;
// ---- the AI client seeds (lane CB, on lane L1's finding) ---------------------------------------
//
// L1 probed `Game::StrategyApp::RunAI` across two processes and found each AI client's generator is
// seeded with a FRESH per-process 32-bit word -- three words per process, none shared between
// processes. That reframes the whole Rung-B problem: the AI is not non-deterministic, it is
// MT19937 from one word per client, and MT19937 is a generator this campaign owns bit for bit. So
// `game/ai` is a deterministic function of (save, per-client seed), and
//
// THE SEEDS ARE PART OF THE STREAM.
//
// A capture that records the command block but not the seeds records the AI's *answer* without its
// *input*; it can be replayed but it cannot be re-derived, and it cannot be re-run. Three words
// turn a log file into a reproducible pair.
//
// TWO MODES, AND THE SECOND IS NOT AN INSTRUMENT -- IT IS AN INTERVENTION.
// `aiseed=log` reads the seed argument where the callee will read it and changes nothing.
// `aiseed=pin` OVERWRITES the caller's pushed argument before the callee sees it. That is the
// only way to take a control on a workload whose outcome set has size k > 1: three lanes ran
// `hooks=off` on `turn1-state` and got three different files, so no single un-instrumented run
// on that workload is a control at all, and an instrumented run agreeing with one of them is a
// ~1/k coincidence rather than evidence.
// Both are logged with the observed AND the used value on every call, so a pinned run says so in
// its own capture and can never be mistaken for a natural one.
enum class SeedMode { Off, Log, Pin };
SeedMode g_seedMode = SeedMode::Off;
constexpr std::size_t kMaxSeedPins = 8;
struct SeedPin {
int netId; // -1 == the wildcard `*`, applied to any client with no exact pin
std::uint32_t value;
};
SeedPin g_seedPins[kMaxSeedPins];
std::size_t g_seedPinCount = 0;
std::uint32_t g_seedCalls = 0;
// `aiorders.deep=` -- follow the heap out of the element window (lane CB).
//
// THREE PAYLOADS IN THE CANONICAL BLOCK ARE BEHIND POINTERS and are therefore simply ABSENT from a
// window dump: list 8's route vector, list 10's counted vector and list 23's `Population` body.
// A replayer cannot reconstruct a fleet move without the route, so "the capture is complete" was
// false while these were unread.
//
// The follower is deliberately GENERIC: it scans the window for anything shaped like an MSVC
// `{_Myfirst,_Mylast,_Myend}` and anything shaped like an MSVC `std::string`, and dumps the bytes.
// It does NOT know which list it is looking at, and it types nothing -- which keeps L4's design
// point (a wrong record shows up as a wrong value offline, instead of being baked into the
// instrument) and means a false positive is a visible extra line rather than a silent mis-decode.
bool g_deep = false;
std::uint32_t g_batchSeq = 0; std::uint32_t g_batchSeq = 0;
@ -270,17 +325,91 @@ static_assert(kProbeCount <= kMaxProbes, "add more AI_PROBE_STUB() slots");
// ---- the dump --------------------------------------------------------------------------------- // ---- the dump ---------------------------------------------------------------------------------
// ---- the deep scan (lane CB) --------------------------------------------------------------------
//
// Both detectors below are SHAPE tests on words the game handed us, and both can fire on a
// coincidence. That is stated in the report rather than tuned away: the thresholds bound how often
// it happens, and a `aivec`/`aistr` line on a list whose record has no vector or string is noise
// until a second run reproduces it.
// A plausible heap address. Below 64 KB is the null page; above 2 GB is kernel space on a 32-bit
// user process without /3GB, and no element in this block has ever pointed there.
inline bool PlausibleHeap(std::uint32_t p) { return p >= 0x00010000u && p < 0x80000000u; }
// `{_Myfirst, _Mylast, _Myend}` -- allocator LAST, so the three pointers are the first three words
// of the member and a fourth word is not needed to recognise it.
void ScanForVectors(int blk, int pid, int list, int idx, std::uintptr_t val, int words) {
int found = 0;
for (int i = 0; i + 2 < words && found < 8; ++i) {
const std::uintptr_t base = val + 4u * static_cast<unsigned>(i);
if (!Readable(base, 12)) continue;
const std::uint32_t first = U32(base), last = U32(base + 4), end = U32(base + 8);
if (!PlausibleHeap(first) || last < first || end < last) continue;
const std::uint32_t span = last - first, cap = end - first;
if ((span & 3u) || (cap & 3u) || cap > 4096u) continue;
const std::uint32_t count = span / 4u;
if (count > 256u) continue;
if (count && !Readable(first, span)) continue;
char hex[32 * 9 + 8] = {};
char ints[32 * 13 + 8] = {};
int hp = 0, ip = 0;
const std::uint32_t show = count < 32u ? count : 32u;
for (std::uint32_t w = 0; w < show; ++w) {
const std::uint32_t v = U32(first + 4u * w);
hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v);
ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast<int>(v));
}
LogF("aivec blk=%d pid=%d list=%d idx=%d at=w%d first=0x%08x cap=%u count=%u ints=[ %s] "
"hex=[ %s]%s",
blk, pid, list, idx, i, first, cap / 4u, count, ints, hex,
count > show ? " TRUNCATED" : "");
++found;
i += 2; // a match consumes its three words; overlapping reports are noise, not evidence
}
}
// MSVC `std::string`: `{union { char buf[16]; char* ptr }, _Mysize, _Myres, _Alval}` = 0x1c bytes,
// allocator last (method rule 5). `_Myres == 15` is the short-string case and the name is inline.
void ScanForStrings(int blk, int pid, int list, int idx, std::uintptr_t val, int words) {
int found = 0;
for (int i = 0; i + 5 < words && found < 4; ++i) {
const std::uintptr_t base = val + 4u * static_cast<unsigned>(i);
if (!Readable(base, 24)) continue;
const std::uint32_t size = U32(base + 16), res = U32(base + 20);
if (size > res || res > 0x1000u || res < 15u) continue;
char text[257] = {};
const std::uintptr_t chars =
(res == 15u) ? base : static_cast<std::uintptr_t>(U32(base));
if (res != 15u && !PlausibleHeap(static_cast<std::uint32_t>(chars))) continue;
const std::uint32_t n = size < 256u ? size : 256u;
if (n && !Readable(chars, n)) continue;
bool printable = true;
for (std::uint32_t c = 0; c < n; ++c) {
const std::uint8_t ch = U8(chars + c);
text[c] = static_cast<char>(ch);
if (ch < 0x20 || ch > 0x7e) printable = false;
}
if (!printable || n == 0) continue; // a non-printable "string" is a coincidence
LogF("aistr blk=%d pid=%d list=%d idx=%d at=w%d sso=%d len=%u text=\"%s\"", blk, pid, list,
idx, i, res == 15u ? 1 : 0, size, text);
++found;
i += 6;
}
}
void DumpElements(int blk, int pid, int list, std::uintptr_t head) { 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 // MSVC std::list node: {_Next, _Prev, _Myval}. begin() == _Myhead->_Next; the head is the nil
// sentinel and terminates the walk. // sentinel and terminates the walk.
std::uintptr_t node = U32(head); std::uintptr_t node = U32(head);
int idx = 0; int idx = 0;
const int elemWords = g_elemWords;
while (node && node != head && idx < 64) { while (node && node != head && idx < 64) {
const std::uintptr_t val = node + 8; const std::uintptr_t val = node + 8;
char hex[kElemWords * 9 + 8] = {}; char hex[kMaxElemWords * 9 + 8] = {};
char ints[kElemWords * 13 + 8] = {}; char ints[kMaxElemWords * 13 + 8] = {};
int hp = 0, ip = 0; int hp = 0, ip = 0;
for (int w = 0; w < kElemWords; ++w) { for (int w = 0; w < elemWords; ++w) {
const std::uintptr_t p = val + 4u * static_cast<unsigned>(w); const std::uintptr_t p = val + 4u * static_cast<unsigned>(w);
if (Readable(p, 4)) { if (Readable(p, 4)) {
const std::uint32_t v = *reinterpret_cast<volatile std::uint32_t*>(p); const std::uint32_t v = *reinterpret_cast<volatile std::uint32_t*>(p);
@ -293,9 +422,14 @@ void DumpElements(int blk, int pid, int list, std::uintptr_t head) {
} }
// The first two words as floats as well: several element records lead with or contain a // 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. // 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]", LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x words=%d f0=%g f1=%g ints=[ %s] "
blk, pid, list, idx, static_cast<unsigned>(node), static_cast<double>(F32(val)), "hex=[ %s]",
static_cast<double>(F32(val + 4)), ints, hex); blk, pid, list, idx, static_cast<unsigned>(node), elemWords,
static_cast<double>(F32(val)), static_cast<double>(F32(val + 4)), ints, hex);
if (g_deep) {
ScanForVectors(blk, pid, list, idx, val, elemWords);
ScanForStrings(blk, pid, list, idx, val, elemWords);
}
node = U32(node); node = U32(node);
++idx; ++idx;
} }
@ -602,7 +736,111 @@ _AiBatchDetour:
jmp *_g_aiBatchOrig jmp *_g_aiBatchOrig
)"); )");
// ---- the RunAI seed detour ----------------------------------------------------------------------
//
// `RunAI` is `__thiscall` with FOUR stack arguments and `ret 0x10`, so the caller pushed them and
// the callee reads them out of the caller's frame. At the stub's entry the return address is at
// esp+0 and `rngSeed` -- the fourth argument -- is at esp+0x10. After `pushfl` (4) + `pushal` (32)
// that is esp+52, and `netId` is esp+40. The stub hands the C function the NET ID BY VALUE and the
// SEED SLOT BY ADDRESS, which is what lets one function both read it and, in pin mode, replace it
// where the callee will look.
extern "C" void* g_aiSeedOrig;
void* g_aiSeedOrig = nullptr;
extern "C" void AiSeedDetour();
extern "C" void AiOnRunAI(int netId, std::uint32_t* seedSlot) {
++g_seedCalls;
const std::uint32_t observed = *seedSlot;
std::uint32_t used = observed;
if (g_seedMode == SeedMode::Pin) {
const SeedPin* chosen = nullptr;
for (std::size_t i = 0; i < g_seedPinCount; ++i)
if (g_seedPins[i].netId == netId) { chosen = &g_seedPins[i]; break; }
if (!chosen)
for (std::size_t i = 0; i < g_seedPinCount; ++i)
if (g_seedPins[i].netId < 0) { chosen = &g_seedPins[i]; break; }
if (chosen) {
*seedSlot = chosen->value;
used = chosen->value;
}
}
// `call` is the ordinal of this RunAI within the process, so a reader can bind the three seeds
// to the three clients even if two clients share a net id.
LogF("aiseed call=%u netId=%d observed=0x%08x used=0x%08x pinned=%d", g_seedCalls, netId,
observed, used, used != observed ? 1 : 0);
}
asm(R"(
.text
.globl _AiSeedDetour
_AiSeedDetour:
pushfl
pushal
leal 52(%esp), %eax
pushl %eax
pushl 44(%esp)
call _AiOnRunAI
addl $8, %esp
popal
popfl
jmp *_g_aiSeedOrig
)");
// `<netid>=<hex>` or `*=<hex>`, comma separated. A malformed entry is reported and the whole key
// is refused: a half-applied pin set is worse than none, because the run would look pinned.
bool ParseSeedPins(const char* value, std::string* err) {
g_seedPinCount = 0;
const char* p = value;
while (*p) {
while (*p == ' ' || *p == ',') ++p;
if (!*p) break;
if (g_seedPinCount >= kMaxSeedPins) {
if (err) *err = "too many seed pins";
return false;
}
int netId = -1;
if (*p == '*') {
++p;
} else {
char* end = nullptr;
netId = static_cast<int>(std::strtol(p, &end, 10));
if (end == p) {
if (err) *err = "expected <netid>=<hex> or *=<hex>";
return false;
}
p = end;
}
if (*p != '=') {
if (err) *err = "expected '=' after the net id";
return false;
}
++p;
char* end = nullptr;
const unsigned long v = std::strtoul(p, &end, 16);
if (end == p) {
if (err) *err = "expected a hex seed";
return false;
}
p = end;
g_seedPins[g_seedPinCount].netId = netId;
g_seedPins[g_seedPinCount].value = static_cast<std::uint32_t>(v);
++g_seedPinCount;
}
return true;
}
bool ai_orders_config(const char* key, const char* value, std::string* err) { bool ai_orders_config(const char* key, const char* value, std::string* err) {
if (std::strcmp(key, "aiseed") == 0) {
if (std::strcmp(value, "off") == 0) g_seedMode = SeedMode::Off;
else if (std::strcmp(value, "log") == 0) g_seedMode = SeedMode::Log;
else if (std::strcmp(value, "pin") == 0) g_seedMode = SeedMode::Pin;
else if (err) *err = "expected off|log|pin";
return true;
}
if (std::strcmp(key, "aiseed.values") == 0) {
if (!ParseSeedPins(value, err)) g_seedPinCount = 0;
return true;
}
if (std::strcmp(key, "aiorders") == 0) { if (std::strcmp(key, "aiorders") == 0) {
if (std::strcmp(value, "on") == 0) g_enabled = true; if (std::strcmp(value, "on") == 0) g_enabled = true;
else if (std::strcmp(value, "off") == 0) g_enabled = false; else if (std::strcmp(value, "off") == 0) g_enabled = false;
@ -613,6 +851,22 @@ bool ai_orders_config(const char* key, const char* value, std::string* err) {
std::snprintf(g_outPath, sizeof g_outPath, "%s", value); std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
return true; return true;
} }
if (std::strcmp(key, "aiorders.words") == 0) {
char* end = nullptr;
const long v = std::strtol(value, &end, 10);
if (end == value || v < 1) {
if (err) *err = "expected a word count >= 1";
return true;
}
g_elemWords = static_cast<int>(v < kMaxElemWords ? v : kMaxElemWords);
return true;
}
if (std::strcmp(key, "aiorders.deep") == 0) {
if (std::strcmp(value, "on") == 0) g_deep = true;
else if (std::strcmp(value, "off") == 0) g_deep = false;
else if (err) *err = "expected on|off";
return true;
}
if (std::strcmp(key, "airesearch") == 0) { if (std::strcmp(key, "airesearch") == 0) {
if (std::strcmp(value, "on") == 0) g_research = true; if (std::strcmp(value, "on") == 0) g_research = true;
else if (std::strcmp(value, "off") == 0) g_research = false; else if (std::strcmp(value, "off") == 0) g_research = false;
@ -652,6 +906,20 @@ void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)(
if (!g_out) LogF("aiorders: cannot open %s -- output goes to shim.log only", g_outPath); 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, LogF("aiorders: out=%s probes=%u of %u", g_outPath,
static_cast<unsigned>(g_probeInstallCount), static_cast<unsigned>(kProbeCount)); static_cast<unsigned>(g_probeInstallCount), static_cast<unsigned>(kProbeCount));
// The capture has to be self-describing: an offline reader must not have to be told what
// window the words were taken at, and a capture with `deep=0` is INCOMPLETE rather than
// "a turn with no route", which is exactly the confusion method rule 20 is about.
LogF("aicfg words=%d deep=%d research=%d seed=%s pins=%u detours=%u", g_elemWords,
g_deep ? 1 : 0, g_research ? 1 : 0,
g_seedMode == SeedMode::Off ? "off" : (g_seedMode == SeedMode::Pin ? "pin" : "log"),
static_cast<unsigned>(g_seedPinCount),
static_cast<unsigned>(1 + g_probeInstallCount + (g_research ? 3u : 0u) +
(g_seedMode == SeedMode::Off ? 0u : 1u)));
if (g_seedMode == SeedMode::Pin && g_seedPinCount == 0)
LogF("aiseed: PIN MODE WITH NO PINS -- every seed passes through unchanged, so this run is "
"NOT pinned and must not be reported as one");
for (std::size_t i = 0; i < g_seedPinCount; ++i)
LogF("aiseed pin netId=%d value=0x%08x", g_seedPins[i].netId, g_seedPins[i].value);
void* target = void* target =
reinterpret_cast<void*>(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch); reinterpret_cast<void*>(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch);
@ -665,6 +933,19 @@ void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)(
LogF("COVERAGE: aiorders batch hook NOT INSTALLED -- no block will be dumped, and an empty " 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"); "report means the instrument failed, not that the AI emitted nothing");
if (g_seedMode != SeedMode::Off) {
void* t = reinterpret_cast<void*>(exeBase + sots::addr::StrategyApp_RunAI);
MH_STATUS q1 = MH_CreateHook(t, reinterpret_cast<void*>(&AiSeedDetour), &g_aiSeedOrig);
MH_STATUS q2 = q1 == MH_OK ? MH_EnableHook(t) : q1;
LogF("aiseed: StrategyApp::RunAI rva=0x%08x va=%p create=%s enable=%s",
sots::addr::StrategyApp_RunAI, t, MH_StatusToString(q1), MH_StatusToString(q2));
if (q2 != MH_OK)
LogF("COVERAGE: aiseed hook NOT INSTALLED -- the capture will carry NO seeds, and an "
"absent seed list means the instrument failed, not that the clients were unseeded");
} else {
LogF("aiseed: disabled (aiseed=off) -- the capture will carry no seeds");
}
if (g_research) { if (g_research) {
const struct { const struct {
const char* name; const char* name;

View file

@ -0,0 +1,51 @@
# Lane CB -- THE CAPTURE. The deliverable config: ONE MinHook detour, the block dump at
# `StrategySim::ApplyTurnCommandBatch`, with the element window widened to 32 words and the heap
# follower on.
#
# WHY DEEP MATTERS AND WHY IT IS NAMED IN THE LOG. Three payloads in the canonical block are behind
# pointers -- list 8's route vector, list 10's counted vector, list 23's `Population` body. Without
# `aiorders.deep=on` they are ABSENT from the capture, and an absent route reads exactly like a
# fleet order with nowhere to go. The instrument prints `aicfg words= deep=` for that reason: a
# reader must never have to guess which of the two it is looking at (method rule 20).
#
# Everything else is off, including lane H's probe set (the one measured to move an autosave by four
# bytes) -- rule 19 is run against `shim.cfg.cboff`, which installs nothing at all.
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
# The trace framework installs its OWN template hook on this function (lane L1's seed probe), and
# MinHook refuses a second detour on the same target -- `MH_ERROR_ALREADY_CREATED`. The COVERAGE
# line the module prints when that happens is the only reason this was not a capture with a
# silently empty seed list, which is precisely the failure method rule 1 is about. Turning the
# template hook off leaves the detour count unchanged: one instrument on RunAI, not two.
hook.Game::StrategyApp::RunAI=off
aiorders.out=C:\SOTS\shim.aiorders.txt
aiorders=on
aiprobes=off
airesearch=off
aiorders.words=32
aiorders.deep=on
aiseed=log

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

@ -0,0 +1,6 @@
# Lane CB, the rule-19 control: the shim DLL loads and forwards Bink and installs NOTHING.
# Identical in every other respect to the capture runs -- same binary, same guest, same workload,
# same click path -- so a difference between this run's autosaves and `shim.cfg.cbcapture`'s is the
# instrument and nothing else. A capture that changes the turn it records is worthless for a
# byte-match, and the only way to know is to take the same turn with the instrument removed.
hooks=off

55
src/shim/shim.cfg.cbpin Normal file
View file

@ -0,0 +1,55 @@
# Lane CB -- the pinned creation-turn capture. `shim.cfg.cbcapture` with `aiseed=pin
# The seeds run C3 (unpinned, same save, same guest, same build) observed for itself. Pinning
# them reproduces a turn that actually happened rather than inventing one.
aiseed.values=32=e70a4703,496=0c63ca36,512=372be4df`.
#
# WHY PINNING IS NOT CHEATING. Lane L1 showed every AI client's generator is seeded with a
# fresh per-process word, so `turn1-state -> turn2` has an outcome set of size k > 1 and NO
# un-instrumented run of it is a control -- three lanes ran `hooks=off` on it and got three
# different files. Pinning the seed removes the only per-process input, which is what makes a
# control possible at all on this workload. The seed values below are the ones a previous
# UNPINNED run of this same save observed, so the pinned run reproduces a turn that happened.
#
# `aiseed.values` is filled in per run; with none set the run is NOT pinned and says so.
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
# The trace framework installs its OWN template hook on this function (lane L1's seed probe), and
# MinHook refuses a second detour on the same target -- `MH_ERROR_ALREADY_CREATED`. The COVERAGE
# line the module prints when that happens is the only reason this was not a capture with a
# silently empty seed list, which is precisely the failure method rule 1 is about. Turning the
# template hook off leaves the detour count unchanged: one instrument on RunAI, not two.
hook.Game::StrategyApp::RunAI=off
aiorders.out=C:\SOTS\shim.aiorders.txt
aiorders=on
aiprobes=off
airesearch=off
aiorders.words=32
aiorders.deep=on
aiseed=pin
# The seeds run C3 (unpinned, same save, same guest, same build) observed for itself. Pinning
# them reproduces a turn that actually happened rather than inventing one.
aiseed.values=32=e70a4703,496=0c63ca36,512=372be4df

53
src/shim/shim.cfg.cbtrap Normal file
View file

@ -0,0 +1,53 @@
# Lane CB -- THE CAPTURE PLUS THE MODCOUNT TRAP. `shim.cfg.cbcapture` with lane W2's watchpoint
# module armed, and nothing else changed.
#
# TWO INSTRUMENTS, ONE PROCESS, FOR THE FIRST TIME. The watchpoint module arms DR0-3 from a detour
# on `StrategyServer::ApplyAllTurnCommands`, which is the CALLER of the function this lane's dump
# detours -- so the two are adjacent and have never run together. That is exactly why this is a
# SEPARATE config and a separate run: if its autosaves come off the oracle, this run is the one
# that perturbed and `shim.cfg.cbcapture` remains the capture (method rule 19).
#
# `watch.players=0` because this run wants the ModCount word and its writers' EIPs, not the two
# player Status words -- two fewer debug registers armed is two fewer ways to be wrong.
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=on
# The trace framework installs its OWN template hook on this function (lane L1's seed probe), and
# MinHook refuses a second detour on the same target -- `MH_ERROR_ALREADY_CREATED`. The COVERAGE
# line the module prints when that happens is the only reason this was not a capture with a
# silently empty seed list, which is precisely the failure method rule 1 is about. Turning the
# template hook off leaves the detour count unchanged: one instrument on RunAI, not two.
hook.Game::StrategyApp::RunAI=off
aiorders.out=C:\SOTS\shim.aiorders.txt
aiorders=on
aiprobes=off
airesearch=off
aiorders.words=32
aiorders.deep=on
watch.mode=modcount
watch.players=0
watch.out=C:\SOTS\shim.watch.txt
aiseed=log