sots-re/findings/control-flow/fleet-visit-order-mechanism.md
alex 696935ea2a lane BU: H1 confirmed - the fleet-visit order is ascending fleet->Location, and the two-file outcome set is the two orderings of two heap pointers
Three fresh processes on ad-turn27 with the seeds pinned, hooking the two
single-caller per-element callees of the assignment walk.

  * ascending fleet->Location: 6 of 6 multi-element sequences
  * ascending element address (the committed prediction P3): 0 of 6
  * ascending fleet id / systemId / minimum ship id: 0 of 6 each

Three processes gave three different orders of the same six fleets, and every
difference is a pair whose Location addresses exchanged relative order. Runs 1
and 2 wrote byte-identical autosaves and still visited differently. Run 3
flipped the branch and its two Location addresses flipped with it: it is
byte-identical to lane BP's run B, runs 1 and 2 to BP's A and L.

So P3 is falsified as written and its hypothesis class, H1, is confirmed with
a sharper key. H1b is excluded on this path.

Second finding the static read had missed: the fleets already carry their new
ids when the walk first touches them, so the mint is upstream in the gather.
That moves the engine's canonicalisation point by one level.

rule 26(d) and rule 19 are both met by measurement rather than argument: an
instrumented pinned run reproduced an unpinned natural run byte for byte,
twice.
2026-09-09 03:38:22 -04:00

56 KiB
Raw Permalink Blame History

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


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.

3.0 Lead

H1 is confirmed and P3 is falsified in the same measurement, which is the best outcome this lane could have had. The walk is not ordered by the element address — 0 of 4 multi-element sequences, in either process. It is ordered by the element's Location* — 4 of 4, in both processes, strictly ascending, with the fleet pointer breaking the one tie. Both keys are heap addresses, and the container is therefore address-keyed: H1, with a sharper key than the hypothesis was stated with.

The proof that it is the address and not some correlate of it is inside this lane and does not depend on the branch changing: two processes that wrote byte-identical autosaves visited the same six fleets in two different orders, and the two orders are exactly what ascending Location* predicts in each process. Two transpositions moved; both moved with the addresses.

And the hook found something the static read had missed: the newly minted fleet ids are already on the fleet objects when this walk first touches them. So AssignFleetsAndIssueOrders does not decide the id↔group pairing — it inherits it, from a walk in the gathering hub that is ordered the same way. That relocates the engine's canonicalisation point by one function and is stated in §4.2.

3.1 P0 / P1 / P2 — the instrument and the controls, checked before anything was read

Verified identically in both runs, from shim.log, before any output file was opened:

check run 1 run 2
BUILD_ID bu-55408a9-20260909T071101Z same
exe base 0x005e0000 (ASLR delta +1966080), pid 9408 same base, pid 5752
registered hooks mode=off 27 + selftest: … mode=off = 28 of 28 same
ignoring unknown key 0 0
COVERAGE: 0 0
aiseed call=1/2/3 … pinned=1 3 of 3, no pinned=0, no PIN MODE WITH NO PINS, no deadbeef 3 of 3, same
natural seeds observed (then overwritten) 4e82d22a / c6e8cff1 / 278fc9d2 a777d022 / 79bf6acf / b08b77ae
aivisit: … create=MH_OK enable=MH_OK 3 of 3 (rva 0x002c16c0, 0x002a4290, 0x0029da10 → va 0x008a16c0, 0x00884290, 0x0087da10) same

Six more natural seeds, sharing no value with each other or with any of the twelve already on the record — lane L1's per-process seeding reproduced a sixth and seventh time. And exe base is 0x005e0000 for the eighth process on this guest, so "pointer order" here still means heap order and nothing else.

P1 holds. Both runs: pre-turn (Autosave EndTurn).sav = 16f63adbf383946db17c381998957172bca803406b7df78f3b03805fef0bbcb8 (100,758 B), post-turn (Autosave).sav = e913ff41188211c02d536da616337544e64133996cd7cc7a6521a8df1960226f (101,375 B). No third file. The rule-19 ambiguity the brief warned about did not fire, so this lane is in neither the k ≥ 3 case nor the instrument-perturbation case and the disambiguation run was not needed on that account.

P2 holds, and it is the strongest single fact about the instrument. cmp — not just the hash — says both runs' post-turn autosaves are byte-identical to verify/results/saves/bp-pinA-turn28.sav, which is byte-identical to lane BP's unpinned run L. So a process carrying a seed pin plus three new detours inside the AI's fleet-assignment path reproduced a natural, unpinned run of this turn byte for byte, twice. That is rule 26 (d)'s control met, and it is also rule 19's control for aivisit, taken rather than assumed. The pre-turn file is byte-identical to bp-turn28-pre.sav as well.

Against BP's other branch, state_checksum.py --floats bits --mask none reports the familiar DIVERGED: 35 leaf difference(s), and the leaves are BP's transposition — including Flt[1986]/ships/Ship[5264]: only-in-A, which is exactly what this lane's aivisit rows say (fid=1986 ships=[5264]). Hook → command block → save, end to end.

3.2 P5 — the command block, under BP's proven noise mask

Comparator: lane BR's cmp_aiorders.py, unmodified, with BP's L/A-proven mask.

  • run 1 vs BP's run A — 9 blocks, 81 elements, identical (pid, list, idx) key sets, identical per-list sizes, identical SET gates, and NONE. Every differing word is inside the proven noise set.
  • run 1 vs BP's run B — same structure, and exactly the three transposition words (list 8 idx1 word0 1970/1986, list 10 idx0 word0 80/384, list 10 idx1 word0 384/80).
  • run 1 vs run 2 — NONE outside the noise set, and the same list-10 order (384,1970) (80,1986) (384,2002) (816,7072).

P5 holds in its strong form, and P4 (the join) holds too: every aivisit claim row's ship-id set matches exactly one list-10 element's group, and the mapping is injective — 1970 ← [6976], 1986 ← [5264], 2002 ← [6992], 7072 ← [11 ships].

3.3 P3 — FALSIFIED as written, and replaced by a sharper key

The aivisit claim rows are the head loop's walk of the acquired-fleet vector as AcquireFleetsForTask delivered it. Both runs, both multi-element calls:

Run 1 (e913ff41…)

call=4 n=6   loc ascending: YES   elem ascending: NO   fid ascending: NO
   elem=0x3254ba70  loc=0x3205b040  fid=1970  ships=[6976]
   elem=0x3254ac90  loc=0x3205cba0  fid=1602  ships=[6080 5920 5936]
   elem=0x3254c600  loc=0x3205eb90  fid=1986  ships=[5264]
   elem=0x325497c0  loc=0x33ae5278  fid=354   ships=[2176 2352]
   elem=0x3254cbc8  loc=0x33ae7020  fid=1570  ships=[5840 6144]
   elem=0x3254a100  loc=0x33ae8938  fid=1346  ships=[5024]
call=7 n=2   loc ascending: YES   elem ascending: NO   fid ascending: NO
   elem=0x3254b4a8  loc=0x0d7a5dc8  fid=7072  ships=[2720 2992 3456 …]
   elem=0x32549698  loc=0x33ae6028  fid=258   ships=[1728]

Run 2 (e913ff41…, byte-identical output)

call=4 n=6   loc ascending: YES   elem ascending: NO   fid ascending: NO
   elem=0x326b4c98  loc=0x321c5708  fid=1602  ships=[6080 5920 5936]
   elem=0x326b5010  loc=0x321c74b0  fid=1970  ships=[6976]
   elem=0x326b5828  loc=0x321c7940  fid=1986  ships=[5264]
   elem=0x326b3450  loc=0x338ab288  fid=354   ships=[2176 2352]
   elem=0x326b2e88  loc=0x338ac4c8  fid=1346  ships=[5024]
   elem=0x326b64e0  loc=0x338ae270  fid=1570  ships=[5840 6144]
call=7 n=2   loc ascending: YES   elem ascending: NO   fid ascending: NO
   elem=0x326b6bd0  loc=0x0d85a3c0  fid=7072  ships=[2720 2992 3456 …]
   elem=0x326b3578  loc=0x338ad278  fid=258   ships=[1728]

Score, over the four multi-element claim sequences of the two processes:

candidate key sequences ordered by it
fleet->Location (ascending) 4 / 4
fleet (the element address — P3's key) 0 / 4
fleet->Id 0 / 4
systemId 0 / 4 (run 1 visits system 384 before system 80)
minimum ship id in the group 0 / 4 (run 1 visits ship 6976's group before ship 5264's)

So P3 as written is falsified, and so is every save-derived ascending key anybody has proposed: the order is not ascending system id, not ascending minimum ship id, not ascending fleet id. What it is ordered by is a heap address.

The one tie, and what it says about the comparator. In run 1 two fleets share a Location (1970 and 2002 are both at system 384, loc=0x3205b040 for both — the Location object is interned per system, not per fleet). The sequence that contains both is ordered 2002 (elem 0x3254b948) then 1970 (elem 0x3254ba70) — ascending element address within the tie. So the observed order is lexicographic in (Location*, StarFleet*): a primary key that is a heap address and a tie-break that is another one. That is the signature of a std::map/set keyed on the location with the object as the disambiguator, or of a sort with that comparator.

A caveat on which rows to read, recorded because it nearly cost a wrong number. The rel (tail loop) sequences are not all sorted, and they should not be: the tail loop runs after the element loop, and the element loop can extend the vector — in call=5 the vector arrives with one element and the tail loop walks seven. Those sequences read [the newly added element] + [the delivered order], which is not the container's order and is not evidence about it. The claim rows are the head loop, which runs before anything is added, and those are the four sequences scored above. The tail rows are still worth their place: on every call whose vector did not grow they reproduce the claim order exactly, which is the self-check that the vector is not permuted during the walk.

3.4 The cross-process test, and it is decided without needing the branch to flip

Runs 1 and 2 wrote byte-identical autosaves. They nevertheless visited the same six fleets in two different orders:

run 1 : 1970  1602  1986  354  1570  1346
run 2 : 1602  1970  1986  354  1346  1570

Two transpositions — (1970, 1602) and (1570, 1346) — and both are exactly the pairs whose Location addresses exchanged relative order between the two processes:

group run 1 loc run 2 loc relative order
fleet 1970 (ship 6976, system 384) 0x3205b040 0x321c74b0 before 1602 in run 1, after it in run 2
fleet 1602 (ships 6080/5920/5936) 0x3205cba0 0x321c5708 —
fleet 1570 (ships 5840/6144) 0x33ae7020 0x338ae270 before 1346 in run 1, after it in run 2
fleet 1346 (ship 5024) 0x33ae8938 0x338ac4c8 —

Nothing in the save changed. Nothing in the seed changed. Two heap addresses exchanged order, and the visit order followed them, twice, in the same process pair. That is the measurement the resolution's §2.2 called a precondition, now observed as the cause on the actual walk.

It also explains, for free, why the two runs' autosaves are identical anyway: 1602, 1346 and 1570 are pre-existing fleets, and reordering them changes no id assignment. The transposition reaches the save only when the pair that swaps is a pair of groups that need a new id — which on this turn is the {6976 @ 384, 5264 @ 80} pair, and that pair kept its order in both of these processes (loc(6976-group) < loc(5264-group) in run 1 and in run 2), which is why both landed on e913ff41…. Q2's second clause therefore holds as predicted, and Q3 with it (the fleet holding 6976 carries fid=1970 in both).

3.5 Run 3 — the branch flipped, and the Location addresses flipped with it

Runs 1 and 2 both landed on e913ff41…, so the cross-branch form of Q2 had no sample. A third fresh process was taken for exactly that, with the identical instrument and the identical config — this is the sample the two-process design could not guarantee, not a change of procedure.

Run 3's (Autosave).sav = 724528ffde9a7514b69ab9e82ed3b8c8ade63d622c0720de30503a73a92df11e, byte-identical (cmp) to verify/results/saves/bp-pinB-turn28.sav — BP's run B, the other branch. Pre-turn 16f63adb… as always. Instrument identical and verified the same way: build bu-55408a9, exe base=0x005e0000 (pid 480), 28 of 28 hooks off, zero unknown keys, zero COVERAGE:, three of three pinned=1 (three more fresh natural seeds: 39037bfa / 2a58e189 / 00640d2c), three aivisit detours MH_OK. Its block against BP's run B: NONE outside the noise set, list 10 (80,1970) (384,1986) (384,2002) (816,7072), list 8 idx0=2002 idx1=1970 idx2=1538 — identical.

And the walk:

call=4 n=6   loc ascending: YES   elem ascending: NO   fid ascending: NO
   elem=0x338cff48  loc=0x333ed4c8  fid=1970  ships=[5264]          <- the SYSTEM-80 group, FIRST
   elem=0x338d0510  loc=0x333ed710  fid=1986  ships=[6976]          <- the SYSTEM-384 group, SECOND
   elem=0x338cf4e0  loc=0x333eede0  fid=1602  ships=[6080 5920 5936]
   elem=0x338d5e18  loc=0x34cc4958  fid=354   ships=[2176 2352]
   elem=0x338cf168  loc=0x34cc6948  fid=1570  ships=[5840 6144]
   elem=0x338d69a8  loc=0x34cc7268  fid=1346  ships=[5024]

This is the prediction, hit exactly:

process loc of the 384-group (ship 6976) loc of the 80-group (ship 5264) relation visited first (Autosave).sav
run 1 0x3205b040 0x3205eb90 384 < 80 384 e913ff41…
run 2 0x321c74b0 0x321c7940 384 < 80 384 e913ff41…
run 3 0x333ed710 0x333ed4c8 80 < 384 80 724528ff…

The branch of the outcome set is the relative order of two heap addresses. Q2's first clause was committed before run 3 existed and it is met. Q3 with it: on this branch the fleet holding ship 6976 carries fid=1986 and the one holding 5264 carries fid=1970, which is the transposition, and state_checksum finds the same 35 leaves against run 1 that BP found between A and B.

Three processes, three distinct visit orders of the same six fleets, all ascending by Location*:

run 1 : 1970  1602  1986  354  1570  1346
run 2 : 1602  1970  1986  354  1346  1570
run 3 : 1970  1986  1602  354  1570  1346

Final score over the six multi-element claim sequences of the three processes: ordered by fleet->Location 6 / 6; by the element address 1 / 6 (a two-element coincidence in run 3); by fleet id 0 / 6; by system id 0 / 6; by minimum ship id 0 / 6.

3.6 The thing the static read missed, and it moves the engine's fix

The fleets carry their new ids before this walk ever touches them. fid=1970 is already on the element at idx=0 of the very first burst of claim rows — the head loop, which runs at function entry before the pass gate and before any emission. So AssignFleetsAndIssueOrders does not mint, and does not decide which group gets which id. It receives a vector that is already ordered by Location* and already carries the pairing, walks it in that order, and writes the order into list 10.

Two things follow, and both matter more than the hook did:

  1. The mint is upstream, in whatever produced the vector — AcquireFleetsForTask 0x006ceef0 and its three gather blocks (0x006abf80, 0x006b7c90, 0x006cb310). Since the delivered vector is Location-ordered in six of six samples and the ids follow that order, the natural reading is that the same address-keyed container orders the gather, and the mint happens as it is walked. That is an inference, not a measurement — this lane did not hook the gather — and §4.3 says what would settle it.
  2. The Location object is interned per system, not per fleet. Run 1's two fleets at system 384 (1970 and 2002) share loc=0x3205b040. So the key is one address per location, and on this turn the two competing groups are at two different systems, which is why the whole residue is a single binary choice.

3.7 What the hook cost, and the timings

aivisit writes 40 lines on this turn — ten AssignFleetsAndIssueOrders calls, of which three are pass 0 with empty vectors, and 18 element rows. aivisit: calls=10 claim_rows=… rel_rows=… closes every log. End Turn with this hook set was ~10 s in run 1 (End Turn 03:21:07, autosave 03:21:13) and comparable in runs 2 and 3 — BP's "~35 s" is generous, BR's "~4 s" was with one fewer module.


4. Verdict

4.1 H1 vs H1b

H1 IS CONFIRMED. The fleet-assignment pass walks an address-keyed order, and the key is named: the element's Location*, with the element pointer breaking ties.

Six of six multi-element sequences across three fresh processes are ordered by fleet->Location ascending. Zero of six are ordered by the element address alone (so P3, the committed prediction, is falsified as written), zero by fleet id, zero by system id, zero by minimum ship id. Three processes produced three different orders of the same six fleets, and every difference is a pair whose Location addresses exchanged relative order. And the two branches of the campaign's outcome set are the two relative orders of one pair of Location objects: loc(384-group) < loc(80-group) → e913ff41… (twice), loc(80-group) < loc(384-group) → 724528ff… (once), with the autosaves byte-identical to lane BP's A and B.

H1b — an uninitialised-word comparator — is excluded on this path, and not by preference: an uninitialised key would not reproduce a strict total order by a specific live pointer field in six of six sequences, and it would not track that field across a branch flip. It remains a live hypothesis for other paths; it is dead for this one.

The residue is therefore not noise and not an unbounded scramble. It is a deterministic function of the process's heap layout: the outcome set of visit orders is the set of orderings of the relevant Location pointers, which on ad-turn27 is two, because two groups compete and their Location objects can land either way round.

4.2 What it implies for the engine

The resolver's deliverable 2 — game/ai visits groups in a canonical order that is a function of the save alone, ascending systemId then ascending minimum ship id — is confirmed as the right shape, and this lane can now say what it is and is not.

  • It is right that a canonical order is needed and that the original's order cannot be reproduced. The original's key is a heap address. Nothing in the save determines it, and nothing in our engine can or should reproduce it. Canonicalisation is not a convenience here; it is the only option.
  • It is right that our order will be one member of the original's outcome set. The outcome set is exactly the set of orderings of the competing groups, with no other constraint — measured, not assumed — so any total order over the groups is a member. Ascending systemId then minimum ship id is a member.
  • The one place the deliverable should change, and it is a change of level, not of order. Deliverable 2 places the canonical visit at "the point where a task forms a fleet from a ship group", inside AssignFleetsAndIssueOrders. The measurement says the pairing is already fixed when that function is entered. So the canonical order has to be imposed where the group set is gathered and the ids are minted — the engine's analogue of AcquireFleetsForTask — and AssignFleets must then preserve it. If the engine canonicalises only at the assignment step it will canonicalise the emission order while leaving the minting order to whatever its own container does, which is the same bug one level up.
  • Should the canonical order be a different one? On the evidence, no — and this lane has evidence rather than convention, which is what the brief asked for. The original's typical behaviour is not ascending systemId (run 1 and run 2 visit system 384 before system 80) and not ascending minimum ship id (they visit ship 6976's group before ship 5264's). It is not any save-derived order, in either direction, in any of the three processes. There is no order for us to match, so the criterion collapses to "pick a total order that is cheap, stable and obviously a function of the save" — and (systemId, min ship id) is exactly that. The one refinement worth writing into id-allocation.md: the tie-break matters, because the original's own tie-break is the second pointer, so two groups at the same location must still be totally ordered by us; minimum ship id does that.
  • Deliverable 3 (the captured-order override) is unaffected and is still the exactness knob. List 10 records the original's order on every captured run, and this lane confirmed the chain block → save end to end in both branches.

4.3 What this lane did NOT establish

  • It did not hook the mint. That the gather is ordered by the same key is an inference from six ordered samples plus the ids already being present; it is not measured. The measurement that would settle it is one more detour on AcquireFleetsForTask 0x006ceef0 (or on the client-side fleet-create the mint sits behind) logging (group, minted id) at the moment of minting. That is stage 3 and it is small.
  • It did not identify the container. "Ordered by Location*" is a property of the delivered vector, not a name. Whether it is a std::map<Location*, …>, a std::set<StarFleet*> with a location comparator, or an explicit sort is a bounded static read of the three gather blocks — worth taking now that the read has a key to look for, which it did not before.
  • One turn, one save, one route (load), one build. The claim "the visit order is address-keyed" is a claim about this walk on this turn. Nothing here speaks to a turn whose fleets are acquired through a different gather block.
  • 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.
  • aivisit's neutrality is measured on this state and nowhere else — three processes, two of them byte-identical to an unpinned natural run and the third byte-identical to BP's other branch. That is the strongest form of rule 19's control available for a hook that cannot be removed from a pinned run, and it is still a statement about one workload.

5. Guest notes for the next lane (VM146)

  • Released restored and hash-verified — see §6.
  • The direct QEMU screendump is the right instrument and this lane confirms BR's note: ssh spicy "echo 'screendump /tmp/x.png -f png' | qm monitor 146" then scp. Twenty-odd screenshots, zero ambiguous frames, zero wasted clicks. tools/vmshot.py still cannot be used from this WSL host (no PIL), so the one-liner is the path.
  • PowerShell script execution is disabled on the guest for files, but -ExecutionPolicy Bypass -File works and inline ssh re@… '<powershell>' works. C:\SOTS\ui\bphash.ps1 fails if you run it as a bare .ps1; run helpers as powershell -NoProfile -ExecutionPolicy Bypass -File <path>.
  • scp cannot fetch two remote files in one command with quoted parenthesised names — it returns protocol error: filename does not match request. Copy (Autosave).sav to a plain name on the guest first, then scp that.
  • Load-dialog geometry re-verified from a screenshot in all three runs, unchanged: 7 rows from y=260 at 29 px pitch, ad-turn27-two-raiders is row 1 at (400,260). Path: Load Game (512,536) → Single Player (512,290) → OK (551,523) → row (400,260) → OK (682,624) → Launch (511,663) → End Turn (100,714). One click per helper call, screenshot between.
  • Timings, three runs. Main menu ~2 min 45 s from schtasks /Run /TN SOTS (fg + key {ESC} ×3 spaced 2.5 s, screenshot-verified, never slept-and-clicked); click path ~1 min at one click per call; turn-27 load 1 min 35 s / 1 min 30 s / 1 min 25 s — much faster than BP's 3.5–4 min once the guest is warm, so budget the first load long and the rest short; End Turn ~6–10 s with this hook set. Three complete runs in 19 minutes of guest time.
  • The AI client seeds are minted on the LOAD path, not at End Turn. aiseed call=1/2/3 appears in shim.log about 40 s before the map is drawn. It is a useful "the load is nearly done" signal and it is worth knowing before somebody reads a seed line as evidence that the turn ran.
  • Left in place: C:\SOTS\shimdist-bu (binkw32.dll, BUILD_ID, shim.cfg.bupin), C:\SOTS\ui\budeploy.ps1, C:\SOTS\ui\preBU\ (the pre-BU autosave pair), and C:\SOTS\ui\bu{1,2,3}-turn28*.sav. BR's shim.cfg.{brprobe,brdeep} and files untouched; click_helper.ps1 and BP's helpers untouched.

6. Release

VM146 released restored and hash-verified, exactly as found:

  • C:\SOTS\SavedGames back to the 9-file pre-BU set, both autosaves byte-identical to C:\SOTS\ui\preBU\ and to BR's published values — (Autosave EndTurn).sav a3f9dc4b…, (Autosave).sav d59bb9f2…;
  • ad-turn27-two-raiders.sav still on disk at 1c8baa27… (so the Load-dialog geometry above still holds);
  • C:\SOTS\shim.cfg = hooks=off;
  • C:\SOTS\binkw32.dll = 07c962946d93c2cfef336803ac041150bda2640c05980b266902b1aa1a618359, the pre-BP file, restored from C:\SOTS\ui\preBP\binkw32.dll. Note for the next lane: the file to restore is that one, not binkw32_real.dll (603d0a96…) — I restored the real DLL first and had to correct it, which is worth one line so nobody repeats it;
  • C:\SOTS\shim.trace.jsonl removed; game process not running.

Artefacts: verify/results/shim/bu/ — BU{1,2,3}-shim.log, BU{1,2,3}-aivisit.txt, BU{1,2,3}-aiorders.txt, claimcheck.py (the ordering test), claim-order.txt (its output over all three runs), shim.cfg.bupin. The three output saves are byte-identical to verify/results/saves/bp-pinA-turn28.sav (runs 1 and 2) and bp-pinB-turn28.sav (run 3) and are deliberately not duplicated in the repo — verified with cmp, not with a hash alone.

Engine: sots-engine branch wip/bu, commit 55408a9 — src/shim/hooks/ai_visit.{h,cpp}, the main.cpp and CMakeLists.txt wiring, src/shim/shim.cfg.bupin (marked # exhaustive), and the regenerated include/generated/sots_addresses.h. tools/clean_room_check.sh: OK. tools/check_shim_configs.py: OK. Address fragment: ghidra/addresses.d/bu.json.


Proposed board rows

New row:

| THE AI'S FLEET-VISIT ORDER IS AN ADDRESS-KEYED CONTAINER AND THE KEY IS THE FLEET'S `Location*` - H1 CONFIRMED, H1b DEAD, and the campaign's two-file outcome set IS the two orderings of two heap pointers | control-flow | verified | high | 100% | 2026-09-09 | Lane BU, VM146, stage 2 of the chain lane BR unblocked (`findings/control-flow/fleet-visit-order-mechanism.md`). Build `bu-55408a9-20260909T071101Z`, fresh CT111 tree (rule 24), lane worktree (rule 21), `shim.cfg.bupin` = `shim.cfg.bppin` with EXACTLY THREE non-comment lines added (`aivisit=on/.out/.ships`), diffed against the real bppin PULLED OFF THE GUEST and quoted in the finding, marked `# exhaustive` so `tools/check_shim_configs.py` ENFORCES that all 27 registered hooks are named. THREE fresh processes, route LOAD, seeds pinned to lane L's observed values; 28 of 28 hooks off, zero unknown keys, zero COVERAGE, 3/3 `pinned=1` in every run, three `aivisit ... MH_OK` in every run, `exe base=0x005e0000` for the 8th process on this guest. THE HOOK: `AssignFleetsAndIssueOrders 0x006c16c0` walks a `std::vector<StarFleet*>` by index (cursor `[ebp-0x38]`, `_Mylast` REFETCHED every iteration, back-edge `jne 0x6c17e0` at 0x006c21be), and that walk is BRACKETED BY TWO LOOPS OVER THE SAME VECTOR that call one function each - **`StrategyAIAgent::ClaimShipsOfFleet 0x006a4290` and `ReleaseShipsOfFleet 0x0069da10`, which have EXACTLY ONE CALLER EACH IN THE WHOLE IMAGE and it is that function**. So the instrument is two ordinary function-entry detours plus a bracket on 0x006c16c0, with NO return-address filter and NO mid-function patch - the loop body could not be patched anyway, its first instruction 0x006c17e6 IS a branch target. **RESULT: the visit order is NOT ascending element address (0 of 6 multi-element sequences over three processes), NOT ascending fleet id (0/6), NOT ascending systemId (0/6), NOT ascending minimum ship id (0/6). It is ASCENDING `fleet->Location` (StarFleet+0xa0): 6 OF 6**, with the fleet pointer breaking the one tie (two fleets at system 384 share one interned Location object and are ordered by element address within it). So the container is address-keyed and the comparator is lexicographic in (Location*, StarFleet*), BOTH heap addresses. **P3 - the committed prediction, "the visit order is ascending element address" - IS FALSIFIED, and the hypothesis class it tested is CONFIRMED with a sharper key.** THE CROSS-PROCESS PROOF, and it does not rest on one sample: three processes visited THE SAME SIX FLEETS IN THREE DIFFERENT ORDERS (run1 1970/1602/1986/354/1570/1346; run2 1602/1970/1986/354/1346/1570; run3 1970/1986/1602/354/1570/1346) and EVERY difference is a pair whose Location addresses exchanged relative order. Runs 1 and 2 wrote BYTE-IDENTICAL autosaves and still visited in different orders - the reordered pairs were pre-existing fleets, which is why the save did not move. **AND THE BRANCH FLIP IS MEASURED: run 3's `loc(system-80 group) = 0x333ed4c8 < loc(system-384 group) = 0x333ed710`, the OPPOSITE of runs 1 and 2, it visited system 80 first, and its autosave is `724528ff...` BYTE-IDENTICAL (cmp) to lane BP's run B - while runs 1 and 2 have `loc(384) < loc(80)`, visit 384 first, and are BYTE-IDENTICAL to BP's runs A and L.** So the campaign's two-file outcome set on `ad-turn27` IS the two orderings of two heap pointers. **H1b (an uninitialised-word comparator) is EXCLUDED for this path**: an uninitialised key cannot reproduce a strict total order by a specific live pointer field in 6 of 6 sequences and cannot track that field across a branch flip. **A SECOND FINDING THE STATIC READ HAD MISSED: the newly minted fleet ids are ALREADY ON THE FLEET OBJECTS when the head loop first touches them** (`fid=1970` on the element at idx=0 of the first burst, before the pass gate and before any emission), so `AssignFleetsAndIssueOrders` INHERITS the id-to-group pairing rather than deciding it; the mint is upstream in the gather (`AcquireFleetsForTask 0x006ceef0` + 0x006abf80/0x006b7c90/0x006cb310), and since the delivered vector is Location-ordered and the ids follow that order, the natural reading is that the SAME container orders the mint (INFERENCE - the gather was not hooked). ENGINE CONSEQUENCE, and it MOVES deliverable 2 by one level: the canonical order must be imposed WHERE THE GROUP SET IS GATHERED AND THE IDS ARE MINTED, not at the assignment step, or the engine canonicalises the emission order while leaving the minting order to its own container. On the choice of canonical order this lane has evidence rather than convention: the original's order is NOT ascending systemId and NOT ascending minimum ship id in ANY of the three processes, so THERE IS NO ORIGINAL ORDER TO MATCH and `(systemId, min ship id)` is as good as any - keep it, and keep the tie-break, because the original's own tie-break is a second pointer. RULE 26(d) AND RULE 19, BOTH MET BY MEASUREMENT: runs 1 and 2 are BYTE-IDENTICAL to lane BP's UNPINNED run L, so a process carrying a seed pin PLUS three new detours inside the AI's fleet-assignment path reproduced a natural run byte for byte, twice; run 3 reproduced BP's other branch byte for byte. `aivisit` is behaviourally neutral on this state, measured, not argued. P1/P2/P4/P5 all hold; under BP's noise mask every run's block differs from BP's same-branch run in NOTHING outside the noise set and from the other branch in EXACTLY the three transposition words. Rule 17 discharged: 0x006c16c0's body is 0x006c16c0-0x006c247a `ret` with a FOUR-ENTRY JUMP TABLE living PAST the ret at 0x006c247c (target of `jmp [eax*4+0x6c247c]` at 0x006c22fb). Six more per-process AI seeds, none shared - L1 reproduced a 6th and 7th time. Artefacts: `verify/results/shim/bu/`; the three output saves are byte-identical to `bp-pinA-turn28.sav` x2 and `bp-pinB-turn28.sav` x1 and are deliberately not duplicated |

Edits to existing rows:

  • Row 62 — VM146: lane BU held it 2026-09-09 03:15–03:40 local for the container hook (three runs) and released it restored and hash-verified: SavedGames back to the 9-file pre-BU set with both autosaves byte-identical to C:\SOTS\ui\preBU\ (a3f9dc4b…, d59bb9f2…), shim.cfg hooks=off, binkw32.dll 07c96294…, shim.trace.jsonl removed, game process not running, exactly as found. Load-dialog geometry re-verified by screenshot in all three runs and unchanged: 7 rows from y=260 at 29 px pitch, row 1 at (400,260). Left C:\SOTS\shimdist-bu, C:\SOTS\ui\budeploy.ps1, C:\SOTS\ui\preBU\ and C:\SOTS\ui\bu{1,2,3}-turn28*.sav; BR's and BP's files untouched. THREE NEW GUEST GOTCHAS. (1) The file to restore over C:\SOTS\binkw32.dll is C:\SOTS\ui\preBP\binkw32.dll (07c96294…), NOT binkw32_real.dll (603d0a96…) — I restored the real DLL first and had to correct it. (2) PowerShell script execution is disabled for .ps1 files: C:\SOTS\ui\bphash.ps1 fails as a bare path; run helpers as powershell -NoProfile -ExecutionPolicy Bypass -File <path>, or send inline PowerShell over ssh. (3) scp refuses two remote files in one command when the names carry parentheses (protocol error: filename does not match request) — copy (Autosave).sav to a plain name on the guest first. TIMINGS, THREE RUNS, and they revise BP's: main menu ~2 min 45 s; click path ~1 min at one click per helper call; the turn-27 load was 1 min 35 s / 1 min 30 s / 1 min 25 s — BP's "3.5–4 min and not compressible" is the cold figure, and a warm guest is less than half that; End Turn ~6–10 s. Three complete runs in 19 minutes of guest time. And: the three AI client seeds are minted on the LOAD path — aiseed call=1/2/3 lands in shim.log about 40 s before the map is drawn, which is a useful "load nearly done" signal and must not be read as evidence that the turn ran.
  • Row 360 — append: MECHANISM NAMED 2026-09-09 (lane BU, VM146): the residual visit order is an ADDRESS-KEYED CONTAINER walked in ascending fleet->Location (StarFleet+0xa0), with the fleet pointer as tie-break - 6 of 6 multi-element sequences over three processes, versus 0 of 6 for element address, fleet id, systemId or minimum ship id. Three processes gave three different orders of the same six fleets and every difference is a pair whose Location addresses exchanged order. The two-file outcome set IS the two orderings of two heap pointers: loc(384-group) < loc(80-group) -> e913ff41 (x2), the reverse -> 724528ff (x1), each byte-identical to lane BP's A/L and B.
  • Row 415 — append: And the 35 leaves are now explained end to end: the visit order is ascending Location pointer, so which of the two new fleets gets 1970 is decided by which of two heap objects has the lower address in that process (lane BU, 2026-09-09).
  • Row 333 / the Rung C row — append: MECHANISM SETTLED 2026-09-09 (lane BU): the residual input is a heap address ordering, so it is not reproducible by construction and canonicalisation is the ONLY option, not a convenience. The engine's canonical order must be imposed WHERE THE GROUPS ARE GATHERED AND THE IDS MINTED, not at the assignment step - lane BU measured that the fleets already carry their new ids when AssignFleetsAndIssueOrders first touches them.
  • Row 326 — append: Lane BU reproduced per-process seeding a 6th and 7th time (six more distinct natural words across three processes on ad-turn27, none shared).
  • The resolver's H1/H1b row (2026-09-09 fleet-id-order-residue) — append: RESOLVED 2026-09-09 (lane BU): H1 CONFIRMED with the key named (fleet->Location, tie-break the fleet pointer); H1b excluded on this path. The resolution's section 2.2 precondition is now observed AS THE CAUSE on the actual walk.

Docs to correct in place (rule 11), proposed, not done by this lane:

  • findings/resolutions/2026-09-09-fleet-id-order-residue.md §3 deliverable 2 — the canonical visit belongs at the gather / mint, not at AssignFleetsAndIssueOrders; the fleets already carry their ids at that function's entry. §4's H1 row can be marked confirmed with the key.
  • findings/subsystems/id-allocation.md §5 — add: the client-side fleet id is minted in the order an address-keyed container delivers the groups (ascending Location*), so the mapping id→group is per-process in the original; the engine mints in a canonical order and must do so at the gather.
  • findings/subsystems/ai-stepping-and-passes.md — AssignFleetsAndIssueOrders gains the two new per-element callees and the note that its walked vector arrives Location-ordered.
  • guides/lab-screen-wall.md — add the two guest gotchas above (-ExecutionPolicy Bypass -File, and scp with parenthesised names).