Two processes, same workload. Phase 18 tries three producers; only ONE of the
three AI players reaches the candidate walk at all, which is why the other two
are stable across every run of both lanes -- different code path, not better luck.
For that player the candidate stream is ONE entry, {2, 12}, in BOTH runs -- a
category, not a tech. So arrival order in the candidate vector is not the
mechanism (a vector of one has no order), and the three-arm fallback never ran
(both probes zero, both runs). The variation is inside the resolver that turns a
category into a tech, and it produced XNC_TrnsMorr2 (techId 288) in one process
and XNC_TrnsHum2 (techId 282) in the next.
k is nameable from the shipped tech data: XNC_ROOT allows six tier-1 techs at an
identical 2000 RP, one per species, each allowing exactly one tier-2 successor --
the six XNC_Trns<Species>2. Four of the six have been observed across six runs
between lanes L4 and L5, and their costs differ (13000-30000), so the resolver is
not ranking by cost; it takes whichever member of the available set it reaches
first.
Bonus rule-19 result: run R2's autosave is byte-identical to the hooks=off control
of the same workload. Twenty detours installed, same tiebreak, same bytes -- the
only free variable in the whole turn is the tiebreak itself.
Still open and flagged: BIO_GnMod, the one observed value outside that family.
Seven more addresses in ghidra/addresses.d/lane-l4.json; the eighth collided with
lane AI4's cl_SetResearchTarget and was dropped with the agreement recorded.
31 KiB
The AI's command block, read out of the running game
Lane L4, 2026-09-08. Guest VM145 (sots-re-win10-145, 192.168.10.145), build
l4-45bf085-dirty-20260908T2113Z. Predictions committed before the module was written:
sots-engine docs/L4-predictions.md (commit 45bf085, before src/shim/hooks/ai_orders.cpp
existed).
Closes the live half of ai-order-emission.md (AI4), ai-stepping-and-passes.md (AI3),
ai-task-system.md (AI2) and ai-turn-logic.md (AI1). Everything in those four documents was
static reading. This is the first time anything in src/game/ai has run under an instrument.
Raw logs: verify/results/shim/aiorders/l4-turn{1to2,2to3}-aiorders.txt.
0. Lead: what the real AI emitted, and what our model emits
Two workloads, one End Turn each, every submitted block dumped at
StrategySim::ApplyTurnCommandBatch 0x0088f9b0 where all of them are complete in memory.
Turn 1 → 2 (turn1-state.sav). Player 32, the only AI with an empire:
| list | n | element (wire order) |
|---|---|---|
| 1 new design | 1 | a ShipDesignDef object carrying the name string "Honor Lance" inline |
| 3 build | 1 | {ordinal 1, designId 18, systemId 288, 0} |
| 5 system rates | 1 | {systemId 288, OutputRates{0, 1.0f, 0, 0, 0, 0, 0}} |
| 23 population | 1 | {systemId 288, Population{vptr, vector(24 B), 1}} |
plus gates rate = 0.8 and target = techId 144. All twenty-three other lists empty.
Turn 2 → 3 (ref-turn2.sav). Same player:
| list | n | element (wire order) |
|---|---|---|
| 3 build | 1 | {ordinal 2, designId 18, systemId 288, 0} |
| 5 system rates | 1 | {systemId 288, OutputRates{0, 1.0f, 0, …}} |
| 8 fleet move | 1 | {fleetId 34, route[1]} |
| 10 | 1 | {systemId 288, fleetId 34, counted[1]} |
| 14 fleet task | 2 | {34, 0, true} and {34, 1, true} |
| 23 population | 1 | {systemId 288, Population{vptr, vector(24 B), −1}} |
plus gate rate = 0.8; no research target on any player this turn.
Against our model. sots-engine's src/game/ai/orders.h reproduces both blocks exactly —
list for list, element for element, and both turns land on the measured ModCount delta of 12.
That is now a test, tests/game_ai/test_live_blocks.cpp, 44 checks, built from the dumped values
and kept deliberately separate from test_orders.cpp (which is the record of what static reading
predicted, and must not be fitted to this).
The model was right about the arithmetic and incomplete about the content. Three things it did not have:
- A list-23 element, on every turn. No save in eleven has ever carried an element in the free half of the table (lists 17–27), so that whole row of the cost model was a hypothesis in the rule-6 sense. It is now exercised twice, and the counter still lands on 12 — the free half is free, measured, from the first workload that ever populated it. This is the single most valuable thing the capture produced, and nobody predicted it.
- The ids in the commands are client-allocated. The build order names design 18 before the server has issued it, and the fleet order names fleet 34, an object that does not exist in the input save. Our model treated ids as opaque; a reimplementation has to allocate them where the original does or every id in the resulting save is wrong.
- Build, rates and population all name system 288 — the AI's home. One decision, three commands.
1. Predictions, then outcomes
docs/L4-predictions.md §1–§3, in order. Four held, two were falsified, and both falsifiers are
worth more than the predictions were.
P1 — the block set. FALSIFIED, usefully.
Predicted n == 4, the four submitting players. Measured n == 8: the batch is sized to
playerCount, and the four Species == 4 monster factions occupy slots 4–7 with
playerId == 0, every gate clear and all twenty-seven lists empty.
AI3 §1.2 already read the mechanism — ResumePlaying does clear(&S->+0x174) then
resize(&S->+0x174, playerCount) — and I did not join it to AI4's "the monster factions submit no
block at all". Both are right: the vector has eight slots, the submissions are four. The
untouched slots are not merely empty, they are uninitialised: the rate-gate payload reads as
garbage floats (8.97e-44, 2.62e+33 on the two runs) with the gate bit clear.
Consequence for a reimplementation: iterate playerCount slots and let the clear gates do the
filtering; do not build a list of "submitting players".
The four ids that are set are 16, 32, 496, 512 in save-player order, so block+0x04 is the save
player id, not an index. That half of P1 held.
P2 — the turn-1 block. HELD, plus one list nobody predicted.
Predicted lists {1, 3, 5} on player 32 and nothing on 16/496/512. Measured {1, 3, 5, 23} on 32 and nothing on the other three. The sharp falsifier was list 8/10/14 being non-empty on the turn the AI creates its first fleet — they were all empty, so AI4's P2 attribution of the twelve stands.
Four research-rate gates and three research-target gates (144, 90, 288), the human's
target gate clear. Exactly AI4's P2 and P5.
P3 — the element values. HELD for the build order; the design id is not in the window.
designId == 18 and systemId == 288 in list 3, and list 5 names the same system: both as
predicted. The list-1 element is a polymorphic object whose first 48 bytes are a vftable pointer,
a word, and a std::string holding "Honor Lance" (_Mysize 11, _Myres 15 — short-string
optimisation, so the name is inline). The id is past the dump window, so "the design command
carries its own id" is not proved from list 1 directly — but it is proved from list 3, which names
design 18 in the same block, before the server has issued anything.
P4 — the probes on turn 1. Held on the control, wrong on one row.
| probe | predicted | measured (1→2) | measured (2→3) |
|---|---|---|---|
RunTaskList |
6 = 3 AI × 2 passes | 6 (3+3) | 6 (3+3) |
BuildTurnCommands |
4 | 5 | 5 |
RequestBuildForTask |
>0, both passes | 10 (5+5) | 8 (4+4) |
AssignFleetsAndIssueOrders |
entered, no elements | 0 | 2 (1+1) |
IssueRouteForFleets |
entered, no elements | 0 | 2 (1+1) |
AITRaid::Execute |
0 | 0 | 0 |
StrategyClient::OrderList16 |
0 | 0 | 0 |
AITAdvanceIdleShips::Execute |
entered | 6 (3+3) | 6 (3+3) |
IsClaimedByAnotherTask |
>0 | 0 | 10 (9+1) |
RunTaskList == 6 is the headline: three AI agents, each stepped once, two passes each, which
is AI3's P1 confirmed live at the agent level and AI4's "three AI players, not one" confirmed from a
second instrument.
IsClaimedByAnotherTask == 0 on turn 1 falsifies my "called often" — on a board with no fleets it
is never reached at all. Workload-dependent, and my prediction did not say so.
BuildTurnCommands == 5 where four blocks are submitted: three are the AI clients (one after each
agent's pass 1), one fires before any AI has run (game setup / load), and one more at the end. The
pass and agent columns on those two are stale globals and cannot attribute them, so I am not
claiming which is the human's.
P5 — the turn-2 block. HELD, plus list 23 again.
Predicted {3:1, 5:1, 8:1, 10:1, 14:2}; measured exactly that, plus list 23.
P6 — list 14 is two elements against one fleet, keyed on mode. HELD, at the values.
{34, 0, true} and {34, 1, true}: same fleet, modes 0 then 1, and it is the same fleet id list 8's
route names. AI2's P1 — inferred from a call site, then supported by two ModCount bumps — is now
read off the element values. The interface's single element (human-turn2-orders.sav,
{1456, 0, true}) is the same record with mode 0 only.
P7 — the fleet id. FALSIFIED, and this is the important one.
Predicted F == 1744, the fleet that exists at submit time, with the new fleet 34 assigned by the
server on apply. Measured F == 34.
Fleet 34 does not exist in ref-turn2.sav. It exists in turn3-state.sav, as "Beta Fleet". So
the client allocates the object and its id before it submits, and ships the id in the command.
Design 18 is the same story from the other turn: the other new design that turn — a monster
faction's, created server-side with no command block — took 1712 from the save's master id
counter (NMnx 106 → 109), while the AI's took 18.
There are therefore two id spaces, and the small one is client-allocated and part of the wire protocol. For Rung B this is a hard constraint: a reimplementation that assigns ids on apply produces a structurally correct save with every AI-created id wrong.
I do not know the client counter's rule. 18 and 34 differ by 16, which is the master counter's
stride, so it looks like the same id = index * 16 scheme running off a different, small base
(index 1 and index 2 plus 2). Unread; it is the first thing the next lane should chase, and it
is a watchpoint, not a week of reading.
P8 — list 10's first word. HALF-FALSIFIED, and the name is now supportable.
Predicted the first i32 is the fleet. Measured {systemId 288, fleetId 34, counted vector of 1} —
system first, fleet second. Lane Q's record {i32, i32, counted i32} is right; the reading is
"at system 288, fleet 34, [one object]". The counted element's value is in the heap vector and the
dump does not follow it (§5.3), so the payload is still not named. AI4 §4.5 declined to name list 10
on adjacency alone and was right to; it now has values, and "assign these ships to this fleet at
this system" fits all three words, with the tail unread.
P9 — AITRaid. NOT SETTLED, and the probe says exactly why.
StrategyClient::OrderList16 0x007635f0 was entered zero times on both turns. That is a
non-answer about pass 0 — and the companion probe says which non-answer: AITRaid::Execute was
also entered zero times, on both turns. The task never ran. AI3 §2.4 stays open, and it stays open
for a stated reason instead of an assumed one, which is the whole point of rule 20.
The workload that would settle it needs AITRaid in a task list. Neither of the corpus's reachable
turns has one, and the two boards differ only in whether the AI owns a fleet — so owning a fleet is
not the trigger.
Pass 0 writes nothing — confirmed by element count, which is stronger than the entry count.
The three pass-1-gated emission exits were entered in both passes, in equal numbers:
- turn 1:
RequestBuildForTask5 in pass 0 and 5 in pass 1 → one list-1 and one list-3 element in the block; - turn 2: the same, plus
AssignFleetsAndIssueOrdersandIssueRouteForFleetsonce per pass → one list-8, one list-10 and two list-14 elements.
If pass 0 emitted, every count would double. AI3's P2 holds, measured from the output rather than inferred from the gate.
The pass sweeps are otherwise symmetric: every probe's pass-0 count equals its pass-1 count, with
one exception — IsClaimedByAnotherTask runs 9 times in pass 0 and once in pass 1. The claim
registry is already populated by the time the second sweep runs, so most candidates are filtered
before the test is reached. That is consistent with AI3's two-tier quota model and is the only
asymmetry in either run.
2. Which tasks actually fire (AI3 §5, live)
Eight Execute bodies probed, covering the nine classes AI2 called planners plus AITRaid and
AITAdvanceIdleShips. On both turns:
| body | 1→2 | 2→3 | which agents |
|---|---|---|---|
AITAdvanceIdleShips::Execute |
6 | 6 | all three, both passes |
AITBuildDeepScanShips::Execute |
4 | 4 | only 496 and 512, both passes |
AITColonize, AITEscortGateInvade, AITInvade, AITNodeBore, AITBuildPoliceShips, AITRaid |
0 | 0 | — |
Two things follow, and the second is uncomfortable.
AITAdvanceIdleShips is in every agent's list and is entered on both passes, exactly as its
priority-0, pass-1-body shape predicts. It is a good control and it read non-zero on every run.
Player 32's task list contains none of the six named planner bodies. Its build orders came from
RequestBuildForTask, entered 5 times per pass on turn 1 and 4 times per pass on turn 2, under
tasks whose Execute bodies were not in my probe set. So the answer to "which of the nine planner
tasks fire on a real turn" is one of them, AITBuildDeepScanShips, and only for the two AI players
that own nothing — and the one AI that actually plays is driven by tasks nobody has probed yet.
The nine were the wrong nine to probe. AI3's §5 correction of AI2 stands on the call graph; this
lane cannot add to it, and says so.
The event ring gives the exact per-agent sequence for both turns
(aievent lines in the raw logs). Turn 2→3, agent 0x335c1040 (player 32), pass 0, in order:
RunTaskList → Acquire → IsClaimed → IssueRouteForFleets → AssignFleetsAndIssueOrders
→ Acquire → IsClaimed ×2 → RequestBuild (×4 more of this pair)
→ AITAdvanceIdleShips
and pass 1 is the same walk with the claim tests gone.
3. Rule 19: the control, and what it cost to take it
ref-turn2.sav + one End Turn, with seventeen MinHook detours installed (the batch dump plus
sixteen entry probes):
| file | measured | published oracle |
|---|---|---|
(Autosave EndTurn).sav |
66,732 B bb4fd9ac89f41e3b |
bb4fd9ac89f41e3b ✓ |
(Autosave).sav |
67,219 B 978041acd168b56e |
978041acd168b56e ✓ |
Byte-identical. Two things at once: VM145, which is a ZFS clone nobody had checked, reproduces
the reference guest exactly; and this lane's instrument is behaviour-neutral. Lane H's own entry
probes were explicitly probes=off in every configuration here, because that set is the one
measured to move an autosave by four bytes.
3.1 And a control that did not pass — the turn-1 workload is not reproducible
Lane L5 got here first, on VM146, from the other end. turn1-to-turn2-nondeterminism.md is the
owner of this result and it is the better-designed experiment: three runs including a pair with
identical hooks that still disagreed, which rules out the instrument in a way my configurations
cannot. What follows is an independent third-instrument corroboration and one thing it adds.
turn1-state.sav + one End Turn does not reproduce turn2-state.sav, on this build, with or
without instruments. Three runs, three different files:
| run | (Autosave).sav |
|---|---|
reference turn2-state.sav |
66,739 B ab4ac2d7e2977260 |
hooks=off |
66,746 B d59bb9f2fd0eb535 |
| full instrument | 66,740 B e43ec1d2b443c101 |
Diffed field by field through the save reader, exactly one field differs across all three:
p512.ResTNm: ref = BIO_GnMod hooks=off = XNC_TrnsHum2 instrumented = XNC_TrnsMorr2
NMnx, ModCount, every id list, every design, every fleet, every other player's target and rate:
identical. And the command block shows it at the source — player 512's research-target gate carried
techId 288 on the instrumented run, a different id on the others.
So: the research-target choice of an AI player that owns nothing is not reproducible run to run. Players 16, 32 and 496 are stable across all three; only 512 moves. Three samples, one field.
What this instrument adds to L5's result. L5 measured the divergence in the save. This lane
sees it in the TurnCommands block: player 512's research-target gate carried techId 288 on
the instrumented run and a different id on the others, in the block the client submits. So the
divergent decision is made client-side, before submission, and reaches the save as a command
like any other — it is not the sim diverging on identical input. That is consistent with L5's
inference (a tie broken by per-process iteration order over a pointer-keyed container) and it
removes the sim from the list of suspects, which their instrument could not do.
Second corroborating detail: across L5's three runs and this lane's two, the observed values are
BIO_GnMod, XNC_TrnsLir2, XNC_TrnsHvr2, XNC_TrnsHum2, XNC_TrnsMorr2 — five distinct
values in five runs, and four of the five are the same tech with a different species suffix.
A selection walking a species- or player-keyed container and taking whichever arm it reaches first
fits that shape exactly; a numeric roll over a flat tech list does not.
The right probe is the one L5 names: read the AI client's generator state after construction in two
processes, and — from this side — put a write watchpoint on the block's research-target payload at
block+0x10 to catch the writer with its call chain.
3.2 The tie set — where the one non-reproducible decision actually comes from
The coordinator's question: if that pick is a tie broken by something per-process, the
original's outcomes form a small enumerable set, and a deterministic game/ai can pick canonically
and claim "our choice is one of exactly k, and here are all k". Naming k needs the candidate list.
Predictions are in sots-engine docs/L4-predictions.md (addendum, committed before the research
instrument was written). P10 is falsified, in the direction that makes k easy.
The structure, read before the run
Process Turn phase 18 (0x006caf70) tries three producers and takes the first non-null:
eax = 0x006a84f0(agent) ; producer A
if (!eax) eax = 0x006c27c0(agent) ; producer B
if (!eax) eax = SelectResearchTarget 0x006c8890(...) ; producer C, the candidate walk
if (eax && eax != player->+0x294) cl_SetResearchTarget(<eax+4 resolved as a C string>)
cl_SetResearchTarget 0x00578f60 takes the tech's name, not an id — phase 18 resolves the
chosen object's std::string at +0x4 through the short-string union and pushes the char*. That
detail is what made the outcome directly loggable.
Producer C walks a vector of 0x0c-stride candidates front to back and returns the first that
TryResearchCandidate 0x006c8580 accepts. No sort, no score, no comparator — the vector's order
is the priority. If nothing is accepted it falls through to a three-arm rotation seeded by
0x006b36e0(agent) & 7 over two eight-entry .data tables (0x00a1a544 = 2 0 2 0 2 1 2 0,
0x00a1a564 = 0 2 0 2 1 0 2 0).
What ran
One End Turn from turn1-state.sav, three dump hooks and four reachability probes:
| count | |
|---|---|
producer A 0x006a84f0 entered |
3 — once per AI player |
producer B 0x006c27c0 entered |
3 |
SelectResearchTarget entered |
1 |
candidates offered to TryResearchCandidate |
1, {w0 = 2, w1 = 12} |
fallback index 0x006b36e0 / arm 0x006c8670 |
0 / 0 |
Outcomes, in the order they were set: IND_Waldo (player 32), DRV_PlsFiss (player 496),
XNC_TrnsMorr2 (player 512, species = 2, curTarget = 0).
Three things follow immediately.
1. Only one of the three AI players reaches the candidate walk. Players 32 and 496 got their
targets from producer A or B and never entered SelectResearchTarget at all. That is why they are
stable across every run of both lanes and 512 is not: they are on a different code path. P12's
control holds in substance and for a better reason than predicted — it is not that their first
candidate is always acceptable, it is that they have no candidate walk.
2. The candidate stream has length one, so arrival order is not the mechanism. P10 falsified.
The single candidate is {2, 12} — small integers, not pointers, and not a tech: it is a
category. TryResearchCandidate resolves that category to a concrete tech, and that
resolution is what varies. A vector of one has no order to scramble.
3. It is not the fallback either. Both fallback probes read zero, so the three-arm rotation —
the other candidate mechanism, with k <= 3 by construction — did not run. Both of P10's named
falsifiers fired against it, which is the useful outcome: the variation is inside 0x006c8580.
k, named
TechTree/MasterTechList.tech settles the size without another run. XNC_ROOT allows exactly six
tier-1 techs, one per species, all at RP 2000:
CCC_TrnsHum CCC_TrnsLir CCC_TrnsTrk CCC_TrnsHvr CCC_TrnsZul CCC_TrnsMorr
and each of those allows exactly one tier-2 successor in the same XNC family:
| tech | RP |
|---|---|
XNC_TrnsTrk2 |
13000 |
XNC_TrnsHum2 |
15000 |
XNC_TrnsHvr2 |
15000 |
XNC_TrnsMorr2 |
16000 |
XNC_TrnsLir2 |
25000 |
XNC_TrnsZuul2 |
30000 |
So for a player whose frontier is that family, k = 6 and all six are nameable. Across six runs
of this workload between lanes L4 and L5 the observed picks are XNC_TrnsHum2, XNC_TrnsHvr2,
XNC_TrnsLir2, XNC_TrnsMorr2 — four of the six, and every one inside the set. Neither of the
two unobserved members has been contradicted; they are simply not yet drawn.
The costs are not equal, which is the load-bearing detail: 13000 to 30000. A cost-ranked
selection would pick XNC_TrnsTrk2 every time. It does not. So the resolver is not ranking — it is
taking whichever member of the available set it reaches first, and that is the per-process
quantity. Exactly the shape lane L5 inferred, now with the container identified as the player's
available-tech set rather than the task list.
The second capture, which closes the category question
A second process, same build, same workload, same click path:
| run R1 | run R2 | |
|---|---|---|
| producers A / B entered | 3 / 3 | 3 / 3 |
SelectResearchTarget entered |
1 | 1 |
| candidate stream | one entry, {2, 12} |
one entry, {2, 12} |
| fallback index / arm | 0 / 0 | 0 / 0 |
| player 32 target | IND_Waldo |
IND_Waldo |
| player 496 target | DRV_PlsFiss |
DRV_PlsFiss |
| player 512 target | XNC_TrnsMorr2, techId 288 |
XNC_TrnsHum2, techId 282 |
(Autosave).sav |
e43ec1d2b443c101 |
d59bb9f2fd0eb535 |
The category is stable and the outcome is not. Two processes, the same single candidate {2, 12},
two different techs — so the variation is inside TryResearchCandidate and nowhere else. The
candidate vector is not the tie set; the tie set is what the resolver can return for that category.
Both observed values are in the six-member XNC_Trns<Species>2 family, and their ids differ by six
(282 and 288), consistent with the family occupying a short run of consecutive ids.
And the instrument is exonerated on this workload too. R2's post-turn autosave is
d59bb9f2fd0eb535 — byte-identical to the hooks=off control run of §3.1, which produced the
same tech. Twenty detours installed, and when the tiebreak lands the same way the bytes land the
same way. That is a cleaner statement than a single-hash rule-19 control could have made here: the
only free variable in the whole turn is the tiebreak.
The one thing that does not fit, said plainly
The fifth observed value is BIO_GnMod (lane L5's run B, and the value in the reference
turn2-state.sav). It is family BIO, RP 4000, allowed by BIO_ROOT — not in the XNC set. So
either the category candidate {2, 12} is not constant across processes, or the resolver can leave
the family. This lane captured the candidate stream once; one sample cannot show a category is
constant.
That run was taken and {2, 12} repeated while the outcome moved, so the category is not what
varies. BIO_GnMod therefore came from a run whose available set differed — a player whose
frontier included a BIO tech as well — or the resolver's set is broader than one family. Two
captures cannot separate those.
Honest status of the claim: the mechanism is measured and measured twice — a resolver picking
from an unordered available set, not a tie in the candidate vector (length one, both runs) and not
the fallback rotation (zero hits, both runs). The set is named exactly for the arm observed in
both captures. Whether the resolver's set is ever wider than that family is not settled, and
BIO_GnMod is the standing reason to doubt it. The probe that settles it is one more capture on a
run that produces a non-XNC value, or an entry probe inside 0x006c8580 on whatever container it
walks.
What our engine should do
The verification claim the coordinator asked for is available and it is worth restating precisely:
A deterministic
game/aipicks the canonical member of the tie set — canonical by tech id, which is what the command carries anyway. The claim is then "our pick is a member of a set of six we can name from the shipped tech data, and every observed original run picked a member of that set", which is checkable against any number of original runs without ever needing them to agree with each other.
That is strictly stronger than behavioural equivalence and it keeps a byte match reachable on every run where the original happens to land on the canonical member. It also costs nothing to implement: the set is a query on data the engine already loads.
The second capture is done and it supports this: the category is stable, the outcome is not, and
both outcomes are in the named set. What is still missing before the claim can be asserted is
whether the resolver's set is ever wider than the one family — BIO_GnMod says it might be. Until
that is closed the engine should keep masking the leaf, as lane L5 already has it, and carry the
tie-set claim as the thing to prove rather than as a proved thing.
4. Corrections to earlier findings
ai-order-emission.md§2 — "the fourSpecies == 4factions submit no command block at all". Right about the submissions, wrong about the block array: the batch isn = playerCount = 8and those four occupy slots withplayerId == 0and uninitialised gate payloads. AI3 §1.2 had already read theresize(playerCount)that forces it.ai-order-emission.md§1 / §3 P1 — the 17..27 half of the cost table was read from the instruction stream only, with no workload. It now has one: list 23 carries one element on every AI turn measured, and both turns still cost exactly 12. The free half is confirmed free.ai-order-emission.md§4.5 — list 10 "named only by position". It now has values:{systemId, fleetId, counted vector}, with the system leading. Still not named; the vector's contents are unread.turncommands-block.md§3, list 3. The wire record{ordinal, designId, systemId, w}is correct — but the in-memory element is in the opposite order, because that list's writer (0x00822870) emits+0x14, +0x10, +0x0c, +0x08, descending. It is the only one of the five writers checked that reverses; lists 5, 8, 10, 14 and 23 all write ascending. Anyone reading these elements out of memory needs that per-list, not as a rule.ai-task-system.md/ai-stepping-and-passes.md— "the AI's fleet order names the fleet it moves". It names a fleet the input save does not contain (§1, P7).
5. What this lane did not do
- AI3 §2.4 is still open.
AITRaidnever ran on either workload, so the list-16 pass-0 question is untouched — but now for a measured reason rather than an assumed one (§1, P9). - AI3's P3 is untouched. An entry counter cannot see the steal branch inside
IsClaimedByAnotherTask; this lane measured only that it is called (10 times on turn 2, 0 on turn 1). Stated in advance indocs/L4-predictions.md§4. - The dump reads 48 bytes per element and does not follow pointers. Three payloads are
therefore unread: the route in list 8 (one hop, id unknown —
turn3-state.sav's waypoint says 272 but the capture does not prove it), the counted vector in list 10 (one element), and thePopulationbody in list 23 (24 bytes behind a vftable). Each is one more indirection in the dumper. - The design id is not visible in the list-1 element (§1, P3). It is inferred from list 3 naming design 18 in the same block.
- The client id counter is not located (§1, P7). This is the largest remaining hole and it is a watchpoint: break on the write that produces 18 and 34.
- Which tasks drive player 32 is not known (§2). Five build-shaped tasks per pass reached
RequestBuildForTaskand none of them is one of the eightExecutebodies probed. The probe set was chosen from AI3 §5's list of nine and that list is not where this AI's decisions come from. - Two workloads, one AI empire, 28 stars, no contact. Every count here is a count on a very quiet board: no colonise order, no invade, no raid, no diplomacy, no combat. Lists 2, 4, 6, 7, 9, 11–22 and 24–27 were empty on both turns and remain unexercised (rule 6). The distinct-state count for this lane is two turns and one real AI player — that is thin, and it is the caveat that matters most for anything generalised from here.
- The
+0x6cCivilianRatiosgate and the+0x3cHiver gate were clear on all eight blocks on both turns, as expected; nothing new about either.
6. The instrument, for the next lane
sots-engine src/shim/hooks/ai_orders.{h,cpp}, configured by three keys:
aiorders=on|off— the block dump: one register-transparent entry stub onStrategySim::ApplyTurnCommandBatch, which receives(blocks, n)as stack arguments with every submitted block complete at a fixed 0x1b4 stride. Prints six gates, twenty-seven list lengths and 48 bytes per element per block.aiprobes=off|all|N— sixteen entry counters, lane H's asm-stub pattern with its own table so lane H's set is untouched.Ninstalls the first N, so the set bisects in one build.aiorders.out=<path>.
Two design points worth keeping:
Row 0 is RunTaskList, and it is both the control and the pass recorder. Its stub reads the
pass stack argument before tail-jumping, so every later probe hit is attributed to a pass. That is
what turned "pass 0 emits nothing" from an inference into a measurement. The global is stale once
RunTaskList returns and the report says so; the run column in the event ring is what makes the
staleness readable.
Every list is measured twice — walked, and read from _Mysize — and a disagreement prints
MISMATCH. Nothing printed it on 8 blocks × 27 lists × 2 runs, which is the evidence that the
container layout is right rather than that the block is empty (rule 1). And a run whose
RunTaskList count is zero prints CONTROL ZERO and says every other row is unmeasured, not
absent — which is exactly what the load-time batch (seq=1, n=1, the local client's block alone)
does print.
6.1 Lab notes
move X Ythenclick X Yin the same click-helper batch is reliable; a bareclickis not. Roughly half of bare clicks were delivered at the previous cursor position, which reads as "the click did nothing" and then as "the next click did the previous thing". Two runs were nearly lost to it before the pattern was clear.- Reset
SavedGamesto a fixed two-file set before every run. With onlyref-turn2.savandturn1-state.savpresent the Load dialog rows are always y=262 and y=291 and the click path never has to be re-derived.C:\SOTS\ui\l4deploy.ps1does it. - Startup to main menu on VM145 was 80–95 s. Verify by screenshot; never sleep and click.
- VM145 left restored:
SavedGamesback to the 9-file pre-L4 set (autosaves byte-identical to the oracle),binkw32.dllandshim.cfgback to the W3 build andw3modconfig, game stopped.C:\SOTS\shimdist-l4,C:\SOTS\ui\l4\andC:\SOTS\ui\l4{deploy,click,grab,release}.ps1left in place — they are a working template for the next lane.