diff --git a/docs/CB-capture.md b/docs/CB-capture.md new file mode 100644 index 0000000..14798e9 --- /dev/null +++ b/docs/CB-capture.md @@ -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=` | 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==,…` | 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. diff --git a/docs/CB-predictions.md b/docs/CB-predictions.md new file mode 100644 index 0000000..57ede20 --- /dev/null +++ b/docs/CB-predictions.md @@ -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 `. 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=`** — 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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 1bdadd2..5c28af6 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 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). #pragma once #include @@ -2083,8 +2083,10 @@ constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0; 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] 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; +// 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] 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] diff --git a/src/shim/hooks/ai_orders.cpp b/src/shim/hooks/ai_orders.cpp index 68ae3ae..ea009f1 100644 --- a/src/shim/hooks/ai_orders.cpp +++ b/src/shim/hooks/ai_orders.cpp @@ -25,7 +25,9 @@ FILE* g_out = nullptr; void (*g_log)(const char*) = nullptr; 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_start(ap, fmt); 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 // 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; +// +// 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; @@ -270,17 +325,91 @@ static_assert(kProbeCount <= kMaxProbes, "add more AI_PROBE_STUB() slots"); // ---- 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(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(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(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(U32(base)); + if (res != 15u && !PlausibleHeap(static_cast(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(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) { // 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; + const int elemWords = g_elemWords; while (node && node != head && idx < 64) { const std::uintptr_t val = node + 8; - char hex[kElemWords * 9 + 8] = {}; - char ints[kElemWords * 13 + 8] = {}; + char hex[kMaxElemWords * 9 + 8] = {}; + char ints[kMaxElemWords * 13 + 8] = {}; 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(w); if (Readable(p, 4)) { const std::uint32_t v = *reinterpret_cast(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 // rate/fraction, and reading 0x3f4ccccd as 1061997773 hides that. - LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x f0=%g f1=%g ints=[ %s] hex=[ %s]", - blk, pid, list, idx, static_cast(node), static_cast(F32(val)), - static_cast(F32(val + 4)), ints, hex); + LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x words=%d f0=%g f1=%g ints=[ %s] " + "hex=[ %s]", + blk, pid, list, idx, static_cast(node), elemWords, + static_cast(F32(val)), static_cast(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); ++idx; } @@ -602,7 +736,111 @@ _AiBatchDetour: 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 +)"); + +// `=` or `*=`, 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(std::strtol(p, &end, 10)); + if (end == p) { + if (err) *err = "expected = or *="; + 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(v); + ++g_seedPinCount; + } + return true; +} + 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(value, "on") == 0) g_enabled = true; 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); 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(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(value, "on") == 0) g_research = true; 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); LogF("aiorders: out=%s probes=%u of %u", g_outPath, static_cast(g_probeInstallCount), static_cast(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(g_seedPinCount), + static_cast(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 = reinterpret_cast(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 " "report means the instrument failed, not that the AI emitted nothing"); + if (g_seedMode != SeedMode::Off) { + void* t = reinterpret_cast(exeBase + sots::addr::StrategyApp_RunAI); + MH_STATUS q1 = MH_CreateHook(t, reinterpret_cast(&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) { const struct { const char* name; diff --git a/src/shim/shim.cfg.cbcapture b/src/shim/shim.cfg.cbcapture new file mode 100644 index 0000000..f7112ad --- /dev/null +++ b/src/shim/shim.cfg.cbcapture @@ -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 diff --git a/src/shim/shim.cfg.cboff b/src/shim/shim.cfg.cboff new file mode 100644 index 0000000..798be1a --- /dev/null +++ b/src/shim/shim.cfg.cboff @@ -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 diff --git a/src/shim/shim.cfg.cbpin b/src/shim/shim.cfg.cbpin new file mode 100644 index 0000000..042542a --- /dev/null +++ b/src/shim/shim.cfg.cbpin @@ -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 diff --git a/src/shim/shim.cfg.cbtrap b/src/shim/shim.cfg.cbtrap new file mode 100644 index 0000000..a5eb044 --- /dev/null +++ b/src/shim/shim.cfg.cbtrap @@ -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