merge lane CB: capture with payloads and seeds (header regenerated, not hand-resolved)
This commit is contained in:
commit
9fcf220bae
8 changed files with 844 additions and 10 deletions
96
docs/CB-capture.md
Normal file
96
docs/CB-capture.md
Normal 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
290
docs/CB-predictions.md
Normal 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.
|
||||
|
|
@ -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 <cstdint>
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
// 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<unsigned>(w);
|
||||
if (Readable(p, 4)) {
|
||||
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
|
||||
// rate/fraction, and reading 0x3f4ccccd as 1061997773 hides that.
|
||||
LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x f0=%g f1=%g ints=[ %s] hex=[ %s]",
|
||||
blk, pid, list, idx, static_cast<unsigned>(node), static_cast<double>(F32(val)),
|
||||
static_cast<double>(F32(val + 4)), ints, hex);
|
||||
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<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);
|
||||
++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
|
||||
)");
|
||||
|
||||
// `<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) {
|
||||
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<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(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<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 =
|
||||
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 "
|
||||
"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) {
|
||||
const struct {
|
||||
const char* name;
|
||||
|
|
|
|||
51
src/shim/shim.cfg.cbcapture
Normal file
51
src/shim/shim.cfg.cbcapture
Normal 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
6
src/shim/shim.cfg.cboff
Normal 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
55
src/shim/shim.cfg.cbpin
Normal 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
53
src/shim/shim.cfg.cbtrap
Normal 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
|
||||
Loading…
Add table
Reference in a new issue