lane BU: the fleet-visit-order prediction, committed before the run

Stage 2 of the chain lane BR unblocked. H2 (an RNG draw) is dead by
measurement, so the visit order over the ship groups is either a function of
the walked elements' addresses (H1) or of nothing in particular (H1b).

The committed prediction is P3: the visit order is ascending element address,
and across two processes the element order differs exactly where the visit
order differs.

Also the bounded static read this lane took to place the hook: the walk is a
plain index walk of a std::vector<StarFleet*> whose _Mylast is refetched every
iteration, bracketed by two loops over the same vector that call one function
each -- and those two functions have exactly ONE caller each in the whole
image. So the hook is two ordinary function entries plus a bracket, not a
mid-function patch of a loop body whose first instruction is a branch target.
Rule 17 discharged: the body is 0x006c16c0-0x006c247a ret, with a four-entry
jump table living past the ret at 0x006c247c.

ghidra/addresses.d/bu.json carries the two new names and the agent offset; it
deliberately does not re-declare AssignFleetsAndIssueOrders, which lane AI3
owns.
This commit is contained in:
alex 2026-09-09 03:15:19 -04:00
parent 51243ef8b3
commit ca6782f4ff
2 changed files with 310 additions and 0 deletions

View file

@ -0,0 +1,281 @@
# 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.)*

View file

@ -0,0 +1,29 @@
{
"_note": "Lane BU 2026-09-09. Stage 2 of the fleet-visit-order chain: the two per-element callees of the walk inside StrategyAIAgent::AssignFleetsAndIssueOrders 0x006c16c0, which are this lane's hook targets. Both were reached from the disassembly of 0x006c16c0 read to the NEXT function start (rule 17): the real body is 0x006c16c0-0x006c247a `ret`, followed by a FOUR-ENTRY JUMP TABLE AT 0x006c247c (target of `jmp [eax*4+0x6c247c]` at 0x006c22fb) and int3 padding to the next function start 0x006c2490. Ghidra's reported size (3504 B, end 0x006c247a) is right about the ret and stops one instruction before the jump table, so anything that sweeps `fva + sizeInBytes` loses the table. AssignFleetsAndIssueOrders itself is already carried by lane AI3 and is deliberately NOT re-declared here (the generator rejects a same-address-different-name collision). The walk this lane hooks: `param_4` is a std::vector<StarFleet*>; the cursor lives at [ebp-0x38], the element loop body starts at 0x006c17e0 (`mov ebx,[ecx]` at 0x006c17e6 loads the element), the advance is `add eax,4` at 0x006c21b5 and the back-edge `jne 0x6c17e0` at 0x006c21be, with `_Mylast` REFETCHED from [param_4+4] every iteration. So the visit order is the vector's index order, and it is bracketed by two loops over the same vector that call one function each -- which is why those two functions, and not a mid-function patch, are the instrument.",
"entries": [
{
"name": "StrategyAIAgent_ClaimShipsOfFleet",
"addr": "0x006a4290",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIAgent::ClaimShipsOfFleet(StrategyAIAgent* agent /*ecx*/, StarFleet* fleet /*stack*/) -- EXACTLY ONE CALLER in the whole image, 0x006c1735, the FIRST loop of AssignFleetsAndIssueOrders (0x006c1730-0x006c1740), which walks `param_4` once at function entry before any pass gate. Body: n = ([fleet+0xa8] - [fleet+0xa4]) (the ships vector, StarFleet_off_Ships), and for each StarShip* s in it, push_back(s ? [s+4] : 0) onto the agent's int vector at agent+0x2d8 -- a linear scan of that vector first (0x006a42e0) so an id already present is not added twice, then the MSVC push_back grow path (capacity check against 0x3ffffffe, 0x00483410 to reallocate). `[s+4]` is StarShip's id, the same word StarFleet_off_Id names on a fleet. BECAUSE IT HAS ONE CALLER AND IS CALLED ONCE PER ELEMENT AT THE HEAD OF THE WALK, a detour on it records the acquired-fleet vector in visit order with no return-address filter and no mid-function patch: it is the whole instrument of lane BU's stage-2 probe",
"status": "verified",
"source": "findings/control-flow/fleet-visit-order-mechanism.md (lane BU 2026-09-09); disassembled from dumps/sots.exe to the next function start; single caller confirmed by Ghidra cross-references"
},
{
"name": "StrategyAIAgent_ReleaseShipsOfFleet",
"addr": "0x0069da10",
"convention": "thiscall",
"prototype": "void __thiscall Game::StrategyAIAgent::ReleaseShipsOfFleet(StrategyAIAgent* agent /*ecx*/, StarFleet* fleet /*stack*/) -- RET 4. EXACTLY ONE CALLER, 0x006c1765, the second loop of AssignFleetsAndIssueOrders (0x006c1760-0x006c1770), which is entered at 0x006c1753 AFTER the element loop has walked the vector to exhaustion (`jmp 0x6c1753` at 0x006c21c4). The exact inverse of ClaimShipsOfFleet: for each StarShip* s in the fleet's ships vector it finds [s+4] in the agent's int vector at agent+0x2d8, memmoves the tail down (import 0x009dd30c) and does `[agent+0x2dc] -= 4`. Same one-call-per-element shape over the SAME vector in the SAME order, so it is a free second witness of the visit order taken after the loop rather than before it",
"status": "verified",
"source": "findings/control-flow/fleet-visit-order-mechanism.md (lane BU 2026-09-09); disassembled from dumps/sots.exe to the next function start; single caller confirmed by Ghidra cross-references"
},
{
"name": "StrategyAIAgent_off_ClaimedShipIds",
"offset": "0x000002d8",
"convention": "offset",
"prototype": "std::vector<int> claimed ship ids (_Myfirst @+0x2d8, _Mylast @+0x2dc, _Myend @+0x2e0). Read and written ONLY by the claim/release pair above, both of which are exclusive to AssignFleetsAndIssueOrders: the fleets acquired for a task have their ships' ids parked here for the duration of the assignment walk and removed when it is done. Enumerated from the push_back grow path at 0x006a4338-0x006a4356 (which reads +0x2e0 as _Myend via `mov ecx,[esi+8]` with esi = agent+0x2d8) and the erase path at 0x0069da63-0x0069da81",
"status": "verified",
"source": "findings/control-flow/fleet-visit-order-mechanism.md (lane BU 2026-09-09)"
}
]
}