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.
21 KiB
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 ofAssignFleetsAndIssueOrders 0x006c16c0and 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-buafterssh spicy 'pct exec 111 -- rm -rf …'of the whole tree (rule 24), from a lane-privategit worktreeat~/sots-engine-bu(branchwip/bu, commit55408a9) with no localbuild*shipped (rule 21).binkw32.dllsha256117c150a4dbd7f240ada7b8416917ad67eb9f239682dc7370a8a521e0c0956d5. Deployed toC:\SOTS\shimdist-bu. - Input:
C:\SOTS\SavedGames\ad-turn27-two-raiders.sav, sha2561c8baa27680809d585ab1728391e82729642d32aaedffd5bf95b616f0a11a436, 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:
0x006a4290and0x0069da10each have EXACTLY ONE CALLER in the whole image, and that caller isAssignFleetsAndIssueOrders. (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) andpush_backthe id if absent, with the MSVC grow path (capacity check against0x3ffffffe, reallocate through0x00483410);0x0069da10=ReleaseShipsOfFleet— find the id,memmovethe tail down (import0x009dd30c),[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::AllocateOnLocalNode0x008b8b70has six callers andIDMap::AllocateLocalID 0x0080f710twenty-four, none of them in the AI band0x0068–0x006eon 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/0x006cb310and 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.logreadsBUILD_ID bu-55408a9-20260909T071101Z; 28 of 28 registered hooksmode=off; zeroignoring unknown key; zeroCOVERAGE:;aiseed call=1/2/3 … pinned=1with nopinned=0, noPIN MODE WITH NO PINSand noused=0xdeadbeefwildcard hit; and threeaivisit: … create=MH_OK enable=MH_OKlines. 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=16f63adbf383946db17c381998957172bca803406b7df78f3b03805fef0bbcb8in both. A third file is ambiguous betweenk ≥ 3and the threeaivisitdetours 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 on724528ff…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:
elemin theaivisit claimrows is strictly increasing withidx;- the
aivisit slotsrow (the vector's storage read straight out ofparam_4) is the same sequence, so the two independent readings agree;- the
aivisit relrows are the same order as theclaimrows 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 on724528ff…((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 claimrow'sships=[…]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
aiordersdump 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'saivisitlines removed, everything else identical). Under thek ≥ 3reading 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
fidand 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
aivisitlog is again(loc, elem)-lexicographically ascending in every multi-element sequence, and again not ascending inelemalone.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 wase913ff41…— 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 2loc(80-group) < loc(384-group)— the two Location objects land in the opposite relative order;- if run 2's autosave is
e913ff41…again, thenloc(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 carriesfid=1970; on the724528ff…branch it carriesfid=1986.