Run 1 falsified P3 as written -- the walk is NOT ascending element address, 0 of 5 sequences -- and handed back a sharper key: it is lexicographically ascending in (fleet->Location*, fleet*), 5 of 5. Both are heap addresses. Run 1 also found something the static read had missed: the newly minted fleet ids are ALREADY on the fleet objects when the assignment walk claims them, so the id-to-group pairing is decided upstream of AssignFleetsAndIssueOrders. The cross-process test that separates 'the Location pointer is the key' from 'the Location pointer is a correlate' is committed here, while run 2 was still loading and before its autosave existed.
321 lines
21 KiB
Markdown
321 lines
21 KiB
Markdown
# What orders the fleet-assignment visit? — the container hook on `ad-turn27`
|
||
|
||
- **Owner / date:** lane BU · 2026-09-09 · guest **VM146** (`sots-re-win10-146`, `re@192.168.10.146`)
|
||
- **Held:** VM146, from the time this prediction was committed. VM140 (reference, exclusive),
|
||
VM141 (lane AZ) and VM144 (lane BS) untouched; lane BT is on the host in
|
||
`verify/state-checksum/` and is not touched either.
|
||
- **Trigger:** `findings/control-flow/ai-visit-order-probe.md` §4.1 — lane BR's closing paragraph,
|
||
which killed **H2** by measurement and named this lane's work in one sentence: *one bounded
|
||
static read of `AssignFleetsAndIssueOrders 0x006c16c0` and the gathering hub **to place a
|
||
hook**, log `(element address, systemId, minted fleetId)` in visit order, two fresh processes.*
|
||
Frame: `findings/resolutions/2026-09-09-fleet-id-order-residue.md` §2.2 / §3 / §4.
|
||
- **Build:** `bu-55408a9-20260909T071101Z`, built in `/srv/re-lab/build/sots-engine-bu` after
|
||
`ssh spicy 'pct exec 111 -- rm -rf …'` of the whole tree (rule 24), from a lane-private
|
||
`git worktree` at `~/sots-engine-bu` (branch `wip/bu`, commit `55408a9`) with no local `build*`
|
||
shipped (rule 21). `binkw32.dll` sha256
|
||
`117c150a4dbd7f240ada7b8416917ad67eb9f239682dc7370a8a521e0c0956d5`. Deployed to
|
||
`C:\SOTS\shimdist-bu`.
|
||
- **Input:** `C:\SOTS\SavedGames\ad-turn27-two-raiders.sav`, sha256
|
||
`1c8baa27680809d585ab1728391e82729642d32aaedffd5bf95b616f0a11a436`, 100,809 B — **re-verified on
|
||
the guest before the deploy**, equal to lane AD's, BP's and BR's published value.
|
||
- **Route:** **load** (`ad-turn27-two-raiders.sav` → one End Turn), held fixed, as in BP and BR, so
|
||
this lane is unconfounded by lane BQ's per-*history* input (resolution §9.1).
|
||
|
||
---
|
||
|
||
## 0. Where the chain stands, in one paragraph
|
||
|
||
Two processes with **identical pinned AI client seeds** write autosaves differing in exactly **35
|
||
of 61,147 leaves**, and all 35 are one transposition: two newly formed fleets exchange contents.
|
||
The command blocks differ in **exactly three words**. The fleet-id counter is deterministic — 1970,
|
||
1986, 2002 are minted in that order in every process. What varies is **the order in which the
|
||
fleet-assignment pass visits the ship groups that need a new fleet**. Lane BR then excluded RNG
|
||
*by measurement*: across all three AI clients' `OnResumePlaying` brackets `foreign_words=0`,
|
||
`foreign_calls=0`, `residual=0`, `left_delta == observed` (so not an inlined draw either), and the
|
||
per-process global generator at `0x00af6e58` was drawn **exactly three times in the whole process,
|
||
from one return address**, minting the three client seeds and doing nothing else. **H2 is dead.**
|
||
What is left is **H1** — an address-keyed container walked in heap order — or **H1b** — a
|
||
comparator reading an uninitialised word, where the order is a function of nothing in particular.
|
||
BR also measured that LFH randomisation is live here (four processes, four relative orders for one
|
||
identical allocation sequence) and that the **emitted command nodes' address order does not track
|
||
the visit order**, so the block cannot substitute for the hook. This lane places the hook.
|
||
|
||
---
|
||
|
||
## 1. The bounded static read, and what it bought
|
||
|
||
Read to place a hook, not to derive an answer (rule 18). Recorded here because the hook's validity
|
||
rests on it, and because two of its three facts are new to the record.
|
||
|
||
### 1.1 Rule 17, discharged first
|
||
|
||
`AssignFleetsAndIssueOrders` was disassembled from `dumps/sots.exe` **to the next function start**,
|
||
not to Ghidra's reported size. The real body is `0x006c16c0`–`0x006c247a ret`; Ghidra's
|
||
`sizeInBytes` (3504) and `endAddress` (`0x006c247a`) agree with the `ret` — but immediately after
|
||
it sits a **four-entry jump table at `0x006c247c`**, the target of `jmp [eax*4+0x6c247c]` at
|
||
`0x006c22fb`, followed by `int3` padding to the next function start `0x006c2490`. So a sweep
|
||
clipped at `fva + sizeInBytes` loses the table, which is the same class of defect rule 17 names
|
||
even though the `ret` itself was reported correctly this time.
|
||
|
||
### 1.2 The walk, and why the loop body is *not* the hook
|
||
|
||
`param_4` is the `std::vector<StarFleet*>` that `AcquireFleetsForTask` filled (its `[ebp-0x3c]`
|
||
result, per AI3). The function walks it **three times**:
|
||
|
||
| loop | range | per element it calls | order |
|
||
|---|---|---|---|
|
||
| head | `0x006c1730`–`0x006c1740`, once at entry, before the pass gate | `0x006a4290` | vector index order |
|
||
| element | body at `0x006c17e0`, cursor at `[ebp-0x38]` | the assignment work | vector index order |
|
||
| tail | `0x006c1760`–`0x006c1770`, entered at `0x006c1753` after the element loop is exhausted (`jmp 0x6c1753` at `0x006c21c4`) | `0x0069da10` | vector index order |
|
||
|
||
The element loop's cursor is advanced by `add eax,0x4` at `0x006c21b5` and compared against
|
||
`_Mylast` **refetched from `[param_4+4]` every iteration** (`0x006c21bb`), with the back-edge
|
||
`jne 0x6c17e0` at `0x006c21be`. So **the visit order is the vector's index order** and nothing
|
||
inside the loop can reorder it — which relocates the question, correctly, to whatever filled the
|
||
vector.
|
||
|
||
Patching the loop body would be a mid-function detour whose first instruction (`0x006c17e6`) **is a
|
||
branch target** — from `jne 0x6c17e6` at `0x006c174d`, the first iteration's entry — so a 5-byte
|
||
patch at the body head would corrupt the first pass through it. That is exactly the shape rule 19
|
||
exists for, and it is unnecessary:
|
||
|
||
> **`0x006a4290` and `0x0069da10` each have EXACTLY ONE CALLER in the whole image, and that caller
|
||
> is `AssignFleetsAndIssueOrders`.** (Ghidra cross-references: `totalToCount` = 1 for both.)
|
||
|
||
They are ordinary function entries, called once per element, in the same order the element loop
|
||
will follow. Two entry detours therefore record the walk with **no return-address filter, no
|
||
mid-function patch, and no traffic from anywhere else in the game**. A third detour on
|
||
`0x006c16c0` itself brackets them, so each burst of element rows is attributed to
|
||
`(agent, task, pass, vector)` rather than inferred from where the bursts fall — and `pass` matters,
|
||
because the emitting body is pass-1-only while the head loop runs on both passes.
|
||
|
||
### 1.3 What the two callees are, and the layout they confirm
|
||
|
||
Both read the element's ships vector at `[fleet+0xa8] − [fleet+0xa4]` and each ship's id at
|
||
`[ship+0x4]`, which independently confirms `StarFleet_off_Ships` (+0xa4/+0xa8) and gives the ship
|
||
id the same offset as `StarFleet_off_Id`. They are exact inverses over a `std::vector<int>` at
|
||
**`agent+0x2d8`** (`_Myfirst` +0x2d8, `_Mylast` +0x2dc, `_Myend` +0x2e0):
|
||
|
||
* `0x006a4290` = **`ClaimShipsOfFleet`** — for each ship, linear-scan the agent's id vector
|
||
(`0x006a42e0`) and `push_back` the id if absent, with the MSVC grow path (capacity check against
|
||
`0x3ffffffe`, reallocate through `0x00483410`);
|
||
* `0x0069da10` = **`ReleaseShipsOfFleet`** — find the id, `memmove` the tail down (import
|
||
`0x009dd30c`), `[agent+0x2dc] -= 4`. `RET 4`.
|
||
|
||
So the fleets acquired for a task have their ships' ids parked on the agent for the duration of the
|
||
assignment walk and removed when it is done. New names, filed in `ghidra/addresses.d/bu.json` with
|
||
the offset; `AssignFleetsAndIssueOrders` is **not** re-declared there (lane AI3 owns it, and
|
||
`gen_addresses.py` rejects a same-address-different-name collision).
|
||
|
||
### 1.4 What the read did not settle, stated so nobody reads more into it
|
||
|
||
* **Where the new fleet id is minted is still not located.** `IDMap::AllocateOnLocalNode`
|
||
`0x008b8b70` has six callers and `IDMap::AllocateLocalID 0x0080f710` twenty-four, none of them in
|
||
the AI band `0x0068`–`0x006e` on a direct edge from this function. So this lane logs the **minted
|
||
id by joining to the block**, not by hooking the mint. That is a gap in the read and it is
|
||
recorded as one.
|
||
* **The gathering hub was not read.** The resolution's §4 allows reading it; it is not needed to
|
||
place this hook, and rule 18 says stop. If the answer is H1, the container that ordered the
|
||
vector lives in `AcquireFleetsForTask 0x006ceef0` / `GatherFleetsForTier 0x006abf80` /
|
||
`0x006b7c90` / `0x006cb310` and is stage 3, not this lane.
|
||
* Nothing here says the four list-10 groups are visited inside **one** call. They may be one
|
||
vector in one task's call, or four bursts across four task calls. **The instrument is agnostic**:
|
||
the bracket row records the call, and the element rows record the order within it, so both
|
||
readings are visible in the same log. §2.3 predicts for both.
|
||
|
||
---
|
||
|
||
## 2. The predictions, committed before the run
|
||
|
||
Committed in their own commit, with no result in the file (rule 2).
|
||
|
||
> **P0 (the instrument, checked before any output file is opened).** `shim.log` reads
|
||
> `BUILD_ID bu-55408a9-20260909T071101Z`; **28 of 28** registered hooks `mode=off`; **zero**
|
||
> `ignoring unknown key`; **zero** `COVERAGE:`; `aiseed call=1/2/3 … pinned=1` with no `pinned=0`,
|
||
> no `PIN MODE WITH NO PINS` and no `used=0xdeadbeef` wildcard hit; and **three**
|
||
> `aivisit: … create=MH_OK enable=MH_OK` lines. Any miss and the run is **void** and nothing is
|
||
> read from it.
|
||
>
|
||
> **P1 (the outcome set, rule 19's own check).** `(Autosave).sav` ∈
|
||
> {`e913ff41188211c02d536da616337544e64133996cd7cc7a6521a8df1960226f`,
|
||
> `724528ffde9a7514b69ab9e82ed3b8c8ade63d622c0720de30503a73a92df11e`} in **both** runs, and the
|
||
> pre-turn `(Autosave EndTurn).sav` = `16f63adbf383946db17c381998957172bca803406b7df78f3b03805fef0bbcb8`
|
||
> in both. A **third** file is **ambiguous between `k ≥ 3` and the three `aivisit` detours
|
||
> perturbing the run** and does not immediately mean either; §2.4 names the disambiguation and this
|
||
> lane will run it rather than pick a story.
|
||
>
|
||
> **P2 (rule 26 (d), the pin's own control).** At least one of the two runs is **byte-identical to
|
||
> lane BP's unpinned run L** (`e913ff41…`), which is the natural run whose observed seeds this
|
||
> lane's pin uses. Until that holds, nothing is read from a pinned pair. If both runs land on
|
||
> `724528ff…` the control is unmet and this lane says so and takes a third process rather than
|
||
> reading the pair anyway.
|
||
>
|
||
> **P3 — THE HEADLINE, AND IT IS THE COMMITTED PREDICTION THE BRIEF ASKS FOR.**
|
||
> **The visit order is ascending element address.** Concretely, in each run and for the burst that
|
||
> covers the groups list 10 records:
|
||
>
|
||
> * `elem` in the `aivisit claim` rows is **strictly increasing** with `idx`;
|
||
> * the `aivisit slots` row (the vector's storage read straight out of `param_4`) is the same
|
||
> sequence, so the two independent readings agree;
|
||
> * the `aivisit rel` rows are the **same order** as the `claim` rows for the same call, i.e. the
|
||
> vector was not permuted during the walk;
|
||
> * and **across the two processes, the element order differs exactly where the visit order
|
||
> differs.** If run 1 lands on `e913ff41…` (list 10 = `(384,1970) (80,1986) (384,2002)
|
||
> (816,7072)`, system 384 first) and run 2 on `724528ff…` (`(80,1970) (384,1986) …`, system 80
|
||
> first), then the system-384 group's element address is **below** the system-80 group's in run 1
|
||
> and **above** it in run 2. If both runs land on the same file, both must show the same relative
|
||
> address order, which is the weaker but still falsifiable form.
|
||
>
|
||
> **P4 (the join).** Every `aivisit claim` row's `ships=[…]` id set **contains** the ship id list of
|
||
> exactly one list-10 element of the same block, and that mapping is injective. This is what makes
|
||
> the pairing of element address to `(systemId, fleetId)` a measurement rather than an assumption
|
||
> about position.
|
||
>
|
||
> **P5 (the block, inherited unchanged from BP and BR).** The `aiorders` dump has 9 blocks and 81
|
||
> elements, the same `(pid, list, idx)` key set and the same per-list counts (player 32 emitting
|
||
> `{1:3, 3:19, 5:15, 7:2, 8:3, 10:4, 12:12, 14:8, 23:15}`), and differs from BP's runs A and B only
|
||
> inside the proven noise mask plus, at most, the three transposition words.
|
||
|
||
### 2.1 How each of these could be wrong, and the symptom
|
||
|
||
| way | symptom | what it would mean |
|
||
|---|---|---|
|
||
| **H1 is right** | P3 holds: `elem` ascending with `idx`, and the two processes' address orders differ exactly where their visit orders do | **The container is address-keyed.** The engine's deliverable 2 (a canonical order that is a function of the save alone) is confirmed as the right fix, and stage 3 is naming the container inside the gathering hub. |
|
||
| **H1b is right** | P3 falsified: `elem` is *not* monotone in `idx`, and the order tracks neither address nor id | An **uninitialised-word comparator**, or some third key. The next separation is named in §2.4 and it is cheap. |
|
||
| a **third possibility this lane must not hide** | `elem` ascending in both runs **and both runs land on the same file**, so nothing distinguishes | P3 holds only in its weak form. It is consistent with H1 and does not test it. Reported as a weak result, not a confirmation, and the fix is a third process. |
|
||
| the walk is **one burst per task**, not one vector | the bracket rows show four calls with one element each | Then the varying order is the **task** order, not a within-vector order, and the address to test is the *task* pointer, which the bracket row also carries (`task=`). The instrument survives; the H1 test moves to that column and this file will say so. |
|
||
| a **wrong offset** | `fid=0`, `nships=0` or an empty `ships=[]` on rows whose fleets plainly have ships | The reads are probed, so this shows as a logged zero, never a fault. The join (P4) fails visibly rather than silently, and the run is reported as an instrument failure. |
|
||
| the **hook perturbs the turn** (rule 19) | a third autosave file | **Not separable from `k ≥ 3` by these runs alone.** §2.4. |
|
||
| the **pin is half-applied** | any `pinned=0`, a `PIN MODE WITH NO PINS` line, or a `used=deadbeef` hit | The run is **void**. Checked in `shim.log` before any output file is opened. |
|
||
| a **stale or wrong binary** (rule 24) | `BUILD_ID` ≠ `bu-55408a9-20260909T071101Z` | Void. The CT111 tree was `rm -rf`'d before the rsync and the build was re-run from the lane commit. |
|
||
| the **config** carries an inherited defect | a live line inside what was meant to be a comment; an unnamed registered hook silently installed under `hooks=trace` | Defended twice: `tools/check_shim_configs.py` passes with `shim.cfg.bupin` marked `# exhaustive`, and the non-comment diff against the file it was copied from is quoted in §2.2. |
|
||
|
||
### 2.2 The config, and the two diffs (BP's check, and it has caught a real defect)
|
||
|
||
`shim.cfg.bupin` is `shim.cfg.bppin` with **exactly three non-comment lines added**. Taken against
|
||
the real `shim.cfg.bppin` pulled off the guest, not against a local copy of it:
|
||
|
||
```
|
||
$ diff <(grep -v '^\s*#' shim.cfg.bppin | grep -v '^\s*$') \
|
||
<(grep -v '^\s*#' shim.cfg.bupin | grep -v '^\s*$')
|
||
7a8,10
|
||
> aivisit=on
|
||
> aivisit.out=C:\SOTS\shim.aivisit.txt
|
||
> aivisit.ships=8
|
||
```
|
||
|
||
`bppin` has 43 live lines, `bupin` 46. And against `shim.cfg.brprobe`, the file this lane actually
|
||
copied (BR's, which is `bppin` plus the three `airng` lines), the diff is six lines and shows the
|
||
`airng` bracket being taken back out:
|
||
|
||
```
|
||
$ diff <(grep -v '^\s*#' shim.cfg.brprobe | grep -v '^\s*$') \
|
||
<(grep -v '^\s*#' shim.cfg.bupin | grep -v '^\s*$')
|
||
6,8c6
|
||
< airng=on
|
||
< airng.pin_seed=off
|
||
< airng.out=C:\SOTS\shim.airng.txt
|
||
---
|
||
> airng=off
|
||
9a8,10
|
||
> aivisit=on
|
||
> aivisit.out=C:\SOTS\shim.aivisit.txt
|
||
> aivisit.ships=8
|
||
```
|
||
|
||
`airng` is **off** deliberately: BR already took the per-client ledger on this exact state and it
|
||
came back clean, so re-installing that bracket would be three more detours for an answer already
|
||
in hand. Everything else is byte-for-byte BP's: `hooks=trace`, all **28** registered hooks off *by
|
||
name*, `aiorders=on` with `deep=off` and `words=12`, `aiprobes=off`, `airesearch=off`,
|
||
`aiseed=pin` with `32=156ebbbd,496=fe7b2826,512=0ed341d1,*=deadbeef`.
|
||
|
||
**And the structural check the resolution asked for is now enforced, not remembered.**
|
||
`shim.cfg.bupin` carries a line reading exactly `# exhaustive`, so
|
||
`tools/check_shim_configs.py` requires it to name every registered hook:
|
||
|
||
```
|
||
$ tools/check_shim_configs.py
|
||
check_shim_configs: OK (27 registered hooks, 2 template(s) declared exhaustive)
|
||
```
|
||
|
||
(27 is the count excluding the self-test hooks the checker exempts; the file carries 28 `hook.*`
|
||
lines, the 28th being `Shim::SelfTest::Fill`.)
|
||
|
||
### 2.3 The instrument, stated in full (rule 19 wants the list, not the adjective)
|
||
|
||
Installed, and nothing else: the `Mars::Application::Initialize` detour; the seven draw-site
|
||
detours (unconditional whenever `hooks != off`); **one** `aiorders` detour on
|
||
`StrategySim::ApplyTurnCommandBatch`; **one** `aiseed` detour on `StrategyApp::RunAI`; and
|
||
**three** new `aivisit` detours. That is BP's instrument plus three. All three new targets are
|
||
function entries; two of the three are called from exactly one place in the image and that place is
|
||
the third. No `airng` bracket, no facades, no entry probes, no watchpoints, no research hooks.
|
||
|
||
### 2.4 The disambiguations, named before the run so they cannot be chosen after it
|
||
|
||
* **A third autosave file** → one plain run of `shim.cfg.bppin` (this lane's `aivisit` lines
|
||
removed, everything else identical). Under the `k ≥ 3` reading that run cannot land on a *third*
|
||
distinct file with probability better than 1/3; under the perturbation reading it lands on one of
|
||
BP's two. This is BR's own procedure and it is adopted verbatim.
|
||
* **P3 falsified (H1b)** → the next separation is whether the order tracks the *ids*: the element
|
||
rows already carry `fid` and the ship id set, so "ascending fleet id" and "ascending minimum ship
|
||
id" are testable from the same log with no further run. If the order tracks neither address nor
|
||
id, the key is a word nothing in the save determines, and the follow-up is a watchpoint on the
|
||
comparator inside the gathering hub — a different lane and a different instrument.
|
||
* **P2 unmet (both runs on `724528ff…`)** → a third process, and no reading of the pair until one
|
||
of them reproduces run L.
|
||
|
||
### 2.5 What this lane cannot decide, whatever it says
|
||
|
||
One save, one procedure, one build, one route (**load**), one turn. It can say what the visit order
|
||
*is a function of* on this turn; it cannot name the container that produced it (that is stage 3, in
|
||
the gathering hub), it cannot speak about a turn that acquires fleets by a different gather block,
|
||
and it says nothing about lane BQ's per-*history* residue, which is a different input in the same
|
||
layer. The 3–40-turn fuse at `0x0069dbb0` cannot fire inside one turn of a load and is untested
|
||
here, exactly as in BP and BR.
|
||
|
||
---
|
||
|
||
## 3. The result
|
||
|
||
*(To be filled in after the runs. This section is empty in the commit that carries the
|
||
prediction.)*
|
||
|
||
---
|
||
|
||
## 2.6 A second prediction, committed after run 1 and **before run 2's autosave existed**
|
||
|
||
Run 1 is in and it did two things at once: it **falsified P3 as written** and it named a **sharper
|
||
key** than P3 had. Rule 2 says a prediction exists to be disagreed with, and the honest move when a
|
||
measurement hands you a better model mid-lane is to commit the new model as a prediction *before*
|
||
the next run, not to present it afterwards as what was meant all along. So:
|
||
|
||
**What run 1 measured** (details and the full log in §3). The walk order over the acquired-fleet
|
||
vector is **not** ascending element address — 0 of 5 multi-element sequences. It is
|
||
**lexicographically ascending in `(fleet->Location*, fleet*)` — 5 of 5**, with the fleet pointer
|
||
breaking the tie in the one sequence where two fleets share a Location. Both keys are heap
|
||
addresses. And the newly minted fleet ids are **already on the fleet objects at claim time**, so
|
||
the id↔group pairing is decided *upstream* of this function and this walk inherits it.
|
||
|
||
**The committed prediction for run 2**, written at 07:26 UTC while run 2 was still loading:
|
||
|
||
> **Q1.** Run 2's `aivisit` log is again `(loc, elem)`-lexicographically ascending in every
|
||
> multi-element sequence, and again not ascending in `elem` alone.
|
||
>
|
||
> **Q2 — the cross-process test, and it is the one that matters.** Identify the two groups by
|
||
> their ships, which are process-independent: the **system-384 group is the fleet holding ship
|
||
> 6976**, the **system-80 group is the fleet holding ship 5264**. In run 1, `loc(384-group)` =
|
||
> `0x3205b040` < `loc(80-group)` = `0x3205eb90`, and the autosave was `e913ff41…` — the branch on
|
||
> which **384 is visited first and takes id 1970**. Therefore:
|
||
>
|
||
> * if run 2's autosave is `724528ff…` (the 80-first branch, `(80,1970) (384,1986)` in list 10),
|
||
> then in run 2 **`loc(80-group) < loc(384-group)`** — the two Location objects land in the
|
||
> opposite relative order;
|
||
> * if run 2's autosave is `e913ff41…` again, then **`loc(384-group) < loc(80-group)`** again.
|
||
>
|
||
> **Falsifier:** the ordering relation between the two Location addresses failing to agree with
|
||
> the branch. That would say the Location pointer is a correlate on one process and not the key,
|
||
> and would push the mechanism back toward H1b or toward some third key.
|
||
>
|
||
> **Q3.** The fleet ids carried on the elements at claim time follow the same relation: on the
|
||
> `e913ff41…` branch the fleet holding 6976 carries `fid=1970`; on the `724528ff…` branch it
|
||
> carries `fid=1986`.
|