sots-re/findings/subsystems/ai-order-emission.md

21 KiB
Raw Blame History

What an AI turn emits, and what each command costs in ModCount

Lane AI4, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. Static reading of the image plus the corpus saves and lane W2's watchpoint log; no VM time of my own.

Continues ai-stepping-and-passes.md (AI3), ai-task-system.md (AI2), ai-turn-logic.md (AI1) on the emission side, and alliance-mask-and-modcount.md (A2) / watchpoints-modcount-status.md (W2) on the counting side. The join between those two halves is what this lane is.

Addresses: ghidra/addresses.d/lane-ai4.json (validated with tools/gen_addresses.py to a scratch path; 1,138 → 1,152, no duplicate names).


0. The three answers, up front

1. ModCount's per-command cost is a property of the command's list, and there is a clean boundary at sixteen: applying an element of lists 1-16 bumps the counter, applying an element of lists 17-27 does not. ApplyTurnCommandBatch 0x0088f9b0 is not a loop over players containing a loop over lists — it is a flat, hand-written sequence of twenty-seven per-list loops (for each player block: for each element: apply), followed by three per-player gate loops, in a fixed order that is neither list order nor offset order. Reading the sequence out and asking, at each of the 27 list sites and 6 gate sites, whether a ModCount bump is on that path gives a complete cost table (§1). A2's rule "one bump per command applied" is right in shape and wrong in detail: applying a list-17…27 element applies the command and bumps nothing, and one of the six prologue gates is free as well.

2. Lane W2's four unnamed handler EIPs are all named, and so are the two inlined ones — and the answer makes the ten measured bumps decompose exactly, with no residual. On ref-turn2.sav — which is turn2-state.sav under another name, so this is the reference game — the ten command bumps are:

bumps EIP W2 recorded what it is
4 0x00821a87 the research-rate gate, 0x00821a80, applied once per submitted block
1 0x0086c3e9 list 5, system rates — 0x0086c3e0
1 0x0084946e list 3, build order — 0x00849460
1 0x0088bf01 list 10 — 0x0088bed0
2 0x0089046f list 14, inlined at 0x0089046c — the AI's fleet order
1 0x008657ad list 8, fleet move — StrategySim::MoveFleetCommand

3. That confirms AI2's P1 from the ModCount side, at zero VM cost. AI2 predicted that AI_IssueFleetTask deposits two list-14 elements per fleet where the UI deposits one. List 14's applier fired exactly twice in a turn in which exactly one fleet was moved (one list-8 bump). The UI control is human-turn2-orders.sav: one UI fleet move, one list-14 element. Two producers, two different element counts, in the direction predicted. P1 holds.


1. ApplyTurnCommandBatch — the order the twenty-seven lists are applied in

ApplyAllTurnCommands 0x0078f6a0 computes n = (S->+0x178 - S->+0x174)/0x1b4 and tail-calls 0x0088f9b0(blocks, n) with ecx = S+4 (the StrategySim). The batch's body is a run of near-identical blocks:

edi = blocks + <listMemberOffset>
loop over player blocks (stride 0x1b4):
    for (node = list head; node != head; node = node->next)
        playerId = *(int*)(edi - (listMemberOffset - 4))    ; block+0x04
        <handler>(sim, playerId, &node->+0x8)
    edi += 0x1b4

so the member offset is written literally in an add edi, imm8/imm32, and the player id is fetched with the matching negative displacement. That pair identifies each block's list unambiguously. Applied order:

# list member per-element handler bumps ModCount?
1 6 +0xac 0x008784e0 yes @0x008784e3
2 11 +0xe8 0x00821c30 yes @0x00821c33
3 20 +0x154 0x008202e0 no
4 19 +0x148 0x00821ed0 no
5 17 +0x130 0x00865a70 no
6 18 +0x13c 0x00865bd0 no
7 5 +0xa0 0x0086c3e0 yes @0x0086c3e6
8 23 +0x178 0x008220b0 no
9 24 +0x184 0x00822180 no
— gate loop A +0x38 §1.1 mixed
10 1 +0x70 0x00882910 yes @0x0088291a
11 4 +0x94 0x00842f40 yes @0x00842f43
12 3 +0x88 0x00849460 yes @0x0084946b
13 21 +0x160 0x008659a0 no
14 2 +0x7c 0x0083d5d0 yes @0x0083d5d3
15 22 +0x16c (inline) no
16 9 +0xd0 0x0085b6d0 yes @0x0085b70a
17 10 +0xdc 0x0088bed0 yes @0x0088befe
18 12 +0xf4 (inline) yes @0x008902fe
19 13 +0x100 (inline) yes @0x008903b9
20 14 +0x10c (inline) yes @0x0089046c
21 15 +0x118 0x00821d70 yes @0x00821d73
22 16 +0x124 0x00821e20 yes @0x00821e23
23 7 +0xb8 (inline) + 0x00849280 yes @0x008905c8
24 8 +0xc4 0x00865780 MoveFleetCommand yes @0x008657aa
25 25 +0x190 0x00865d30 no
26 27 +0x1a8 0x00867980 no
27 26 +0x19c 0x00871030 no
— gate loops B, C +0x20, +0x24 §1.1 yes both

The rule is clean, and it is a boundary at sixteen: lists 1-16 each cost one bump per element, lists 17-27 cost nothing. Not one exception in either direction. Eleven free lists, not the ten a partial read suggested; the sixteen paying ones include four whose bump is inlined into the batch rather than sitting in a handler (7, 12, 13, 14), which is why a call-graph sweep for ModCount writers under-counts them.

The 1-16 / 17-27 split looks like a real design boundary rather than an accident — the paying half is the half whose members lane Q's element records describe as orders against a game object, and the free half is where the settings-shaped payloads (weapon groups, defence layouts, populations, raid targets, civilian ratios) live.

Honest bound: "no bump" is read from the handler's own body swept to the next function start (rule 17) plus the absence of an inline inc [reg+4] in that block. A bump inside a deeper callee of one of those handlers would be invisible to this reading; the live control is that on two measured turns exactly six distinct sites trapped and all six are in the "yes" column.

1.1 The three per-player gate loops, and which gates are free

The six prologue gates are not applied together. Three loops, at three different points in the batch:

loop at gate tested payload bumps?
A 0x0088fdb0, esi = block+0x38 +0x3c — the three floats written straight to player+0x224/+0x228/+0x22c no
A 0x0088fdfa +0x14 — research target +0x10 techId yes, inline @0x0088fe0a
A 0x0088ffb6 +0x0c — research rate +0x08 float → 0x00821a80 yes @0x00821a84
B 0x008907b1, esi = block+0x20 +0x20 — research boost +0x18 spend, +0x1c fraction → 0x00820560 yes, inline @0x008907bc
C 0x0089080a, esi = block+0x24 +0x2c — group 4 +0x24 bool, +0x28 int → 0x00821b90 yes @0x00821b93
— not found in this function +0x6c — CivilianRatios — unknown

Two things worth pulling out.

The +0x3c gate is free, it is AI-only, and it is Hiver-only. AI2 §4 records 0x00762ca0 (which sets it) as having no UI caller, and AI1 §3.1 puts its producer at Process Turn phase 2. Reading phase 2's body, 0x006c57e0, adds the third restriction: its fourth instruction after the prologue is

006c580a  mov eax,[esi+0x10]        ; the StrategyClient
006c580d  mov eax,[eax+0x150]       ; the ClientPlayer
006c5813  cmp DWORD PTR [eax+0x5c],1    ; Species
006c5817  jne 0x006c5a28                ; -> straight to the epilogue

player+0x5c is Species and 1 is Hiver, so the whole of phase 2 — including a cl_Chance draw at 0x006c592a and the 0x00762ca0 call at 0x006c59b6 — is skipped for every other species. That is a fifth independent cross-check on AI2's species reading (after the Hiver gate creators, the Zuul node-bore, the empty NPC arm and AI3's Zuul range exemption), and it explains cleanly why no save has ever carried the +0x3c gate: the corpus has no Hiver player.

The +0x6c CivilianRatios gate has no applier in ApplyTurnCommandBatch. AI2 §4 already found it is UI-only (0x007813b0, from 0x007f1820). Where it is consumed is open; its ModCount cost is unknown, not zero, and §4 flags it.


2. The reference game, decomposed

turn2-state.sav and ref-turn2.sav are the same file (determinism-oracle.md, and both sha256 ab4ac2d7e2977260…, 66,739 B). So W2's watchpoint run is a measurement of this game, and the corpus deltas and the trap log describe the same turns.

Eight players. Read straight off turn1-state.sav:

idx id name Species Elim Status plcy Status in turn2-state
0 16 re 0 F 0 2 4
1 32 Fane Lao 2 F 0 2 4
2 496 Singularity 0 F 0 2 4
3 512 Singularity 2 F 0 2 4
4-7 528/544/560/576 Alien Menace / Peacekeeper / Von Neumann / Independent Colony 4 F 0 2 0

Exactly four players end their turn, and exactly four research-rate bumps fire. The four that do are indices 0-3; the four that do not are exactly the four Species == 4 factions. That is a fourth independent cross-check on AI2's species reading, from a completely different instrument (W2 §4 recorded MarkPlayerTurnEnded for players 0 and 1 and no Status write for 4-7), and it is the first direct evidence that the NPC/monster factions have no StrategyClient at all — not merely an empty task list.

Three of those four are AI players, not one. ai-turn-logic.md and alliance-mask-and-modcount.md both describe the reference game as having "one AI player". It has three: 32, 496 and 512. Players 496 and 512 own nothing (Sav = 0, no colonies, no fleets, no systems — events.md §5 records that they never hold an event) but they run: on turn 1→2 all three set ResRate 0.25 → 0.8 and picked a research target (IND_Waldo, DRV_PlsFiss, BIO_GnMod). That is a correction to two published findings, and it is the term that makes the arithmetic close.

2.1 Turn 2→3 (W2's measured window), fully attributed

bumps source who
4 research-rate gate one per submitted block: 16, 32, 496, 512
1 list 5 — system rates 32
1 list 3 — build order 32
1 list 10 32
2 list 14 — one AI fleet order 32
1 list 8 — that order's route 32
2 ProcessTurn entry, OnAllCombatDone_Tail —
12

Zero residual. The human (16) issued nothing and still costs 1, because BuildTurnCommands 0x00783780 sets the research-rate gate unconditionally from live player state (AI1 §4.1). 496 and 512 cost 1 each for the same reason and nothing else — they have nothing to command.

The save agrees: turn2-state → turn3-state shows NMnx 109 → 111 (two ids: ship 1760, fleet 1776), NumDes unchanged, srb[0] 1 → 2, Maint 500 → 1000. A build of an existing design. No ResTNm change on any player, which is why the research-target gate never traps in W2's log.


3. Predictions (rule 2) — written before the engine module was built

P1 — the cost table generalises: lists 1-16 pay, lists 17-27 are free

Prediction: on any turn, ModCount delta = 2 + A + Σ_blocks [ rate + target + boost + group4 + Σ_{L = 1..16} |list L| ], where A is the number of systems with Abdn set, each gate term is 1 when set and 0 when clear, and lists 17-27 contribute nothing. Falsified if: a turn whose block carries an element in one of the eleven free lists moves ModCount by more than this. Cheapest test: not a VM run — a save whose block populates one of the eleven. None exists; lists 17-27 have never been observed non-empty (lane Q), so that half of the table is a hypothesis in the rule-6 sense even though it is read from the instruction stream. Boundary case to check by hand (rule 23): a block whose only content is the always-set rate gate must cost exactly 1, not 0 — and that is the case that carries four of the ten bumps on this save.

P2 — the composition of turn1-state's twelve

turn1-state → turn2-state is also 12, but it is not the same twelve, and that is the interesting part. Three terms are forced by the saves themselves:

  • 4 research-rate gates — the same four submitting players (all four go Status 0 → 4);
  • 3 research-target gates — 32, 496 and 512 all move ResTNm '' → <tech>; the human does not;
  • 2 drivers.

That is 9, leaving exactly three for player 32's real orders. Its observable outcome that turn is a new design (Des[18 "Honor Lance"], NumDes 5 → 6), one destroyer built at its home system Ke'Dolarra (srb[0] 0 → 1, Maint 0 → 500, PvSav 50,000 → 38,100, i.e. 11,900 spent before spine phase 0 — lane B6's missing money), and one new fleet (1744, "Alpha Fleet").

Prediction: the three are list 1 (the new design), list 3 (the build order) and list 5 (system rates) — and player 32 issues NO fleet order on turn 1: no list 8, no list 14, no list 10. Equivalently, the AI's turn-1 emission is its turn-2 emission minus the whole fleet-order group (list 8 + 2 × list 14 + list 10) plus the design command.

Test: the W2 watchpoint module, unchanged, armed on one End Turn from turn1-state.sav. The predicted trap multiset is {0x0088fe0a ×3, 0x00821a87 ×4, 0x0088291a ×1, 0x0084946e ×1, 0x0086c3e9 ×1, 0x007dc6f3 ×1, 0x007d92cd ×1} — note it contains two EIPs W2 has never seen (0x0088fe0a, the research-target gate, and 0x0088291a, list 1) and omits three it did (0x0088bf01, 0x0089046f, 0x008657ad). Falsified if: (a) 0x00821a87 fires other than 4× ⇒ "one block per turn-ending player" is wrong, and the whole per-player base cost is wrong with it; (b) 0x0088fe0a fires other than 3× ⇒ the AI's research target is not a TurnCommands gate, and ResTNm is being written by something server-side; (c) 0x0089046f or 0x008657ad fires ⇒ the AI did move a fleet on turn 1, and one of my three is wrong — most likely list 5, which is the softest of the three; (d) 0x0088291a does not fire ⇒ the AI's new design is not a command at all; it would then be created client-side and carried inside the build order, and a design costs zero. Why (d) is the valuable one: it is the difference between "the engine must emit a design command" and "the engine must emit a design object the build order names", which are very different amounts of work.

P3 — a Hiver AI writes a command every turn that ModCount cannot see

Process Turn phase 2 (0x006c57e0 → 0x00762ca0) sets prologue gate +0x3c; §1.1 shows that gate's applier does not bump, and that phase 2 returns immediately unless the player is Hiver. Prediction: a Hiver AI player's TurnCommands block carries gate +0x3c set with three floats, every turn, and no ModCount accounting will ever reveal it; a non-Hiver AI never sets it. Corollary: the corpus can never show this gate, because it has no Hiver player — which is why lane Q has it as "never seen set". The workload that would settle it is a Hiver AI game, and lane V has already shown (rule 6) that manufacturing a save for an unexercised path beats weakening the claim. Falsified if: a non-Hiver AI capture shows it set ⇒ some other producer reaches 0x00762ca0; or a Hiver AI capture shows it clear ⇒ there is a second gate inside phase 2 that I read past.

P4 — Process Turn phases 29-33 emit nothing, ever

StrategyClient::EndTurn 0x00783be0 sets this->+0x15c = 1 at 0x00783c59, before it calls BuildTurnCommands 0x00783780 (at 0x00783cd7) and SendEndTurn. Every order method opens if (this->+0x15c) return false (AI1 §4.2). cl_EndTurn is Process Turn phase 28. Prediction: phases 29-33 — including phase 32's 0x006af790 → 0x00769640, the colonize order — are dead: they run, they call the order method, and it refuses. The AI's colonise orders therefore come only from phase 20's task loop (AI3 §5's AITColonize path), never from phase 32. Falsified if: an AI-turn capture shows a list-7 element that can only have come from phase 32, or a hook on 0x00769640 shows it returning true after cl_EndTurn. Why it matters: a reimplementation that ports the phase list literally will emit an extra colonize command per AI per turn and be one ModCount over on every turn with a colony ship.

P5 — cl_SetResearchTarget is AI-only, and it is phase 18

0x00578f60 has exactly one caller in the image: 0x006caf70, which is Process Turn phase 18. The UI reaches the same order methods (0x00763110 / 0x00763180) directly from 0x005d20a0 / 0x005d2140. Prediction: extends AI1's P4 — the façade entry 0x00578f60 is AI-only surface, and the number of research-target bumps in a turn equals the number of AI players whose phase-18 gate opens. On the reference game that is 3 on turn 1 and 0 on turns 2 and 3. Falsified if: the trap count for 0x0088fe0a on turn 1 is not 3 (this is falsifier (b) of P2, seen from the other end).


4. What this lane did not do

  1. Nothing ran under an instrument. Every new claim is read from the instruction stream or from the corpus saves; the live inputs are lane W2's log, not a run of mine. §3 is the measurement plan and it reuses W2's module unchanged — one save swap and one End Turn.

  2. The +0x6c CivilianRatios gate has no located applier (§1.1). Its ModCount cost is unknown, and the engine's cost function reports "not exact" when that gate is set rather than guessing zero.

  3. The eleven "free" lists are free by direct reading only. No save populates any of them (rule 6), and a bump inside a deeper callee would be invisible. The claim is a lower bound on cost, stated as one.

  4. Which task emits which command is still not modelled — this lane closes the cost of a command and the shape of a block, not the decision that produces it. Every task's Execute body past its first few instructions remains unread (AI2 §10.1, AI3 §8), so the engine module emits nothing on its own: it provides the order API, the two-pass gate and the cost function, and a caller supplies the decisions.

  5. List 10 is named only by position. Its applier 0x0088bed0 sits next to DestroyFleet and its element is {i32, i32, counted i32}; AI3 §0 has AssignFleetsAndIssueOrders reaching lists 14, 8 and 10 together, and the turn that produced it also produced a brand-new fleet. "Assign these ships to this fleet" fits every one of those, and I am not naming it on that basis.

  6. turn3-state's successor was not measured. W2's second window (values 25→36) is turn 3→4 and no save of turn 4 exists in the corpus, so that window's composition is assumed to match turn 2→3 on W2's own statement that the two windows agree.

    NAMED 2026-09-09 (rule 11). It is StrategyServer::OnCommand_TransferShips, {systemId, fleetId, ships[]} — lane W3 named the applier some time ago and these AI-side notes were never updated, so two documents disagreed with a third. The consequence is worth more than the name: list 10 records the order in which the fleet-assignment pass visited each ship group, so every captured command block carries its own visit order for free. That is what makes C-exact recoverable as (save, seeds, visit order) -> byte match rather than merely canonicalisable — see findings/resolutions/2026-09-09-fleet-id-order-residue.md.


5. Corrections to earlier findings

  • alliance-mask-and-modcount.md §2.2 — "one per command applied out of every player's TurnCommands block". Right in shape, wrong in detail: eleven of the twenty-seven lists apply their command without bumping (§1), and four of the six prologue gates bump while a fifth does not and the sixth has no located applier.
  • alliance-mask-and-modcount.md §2.5 — "the 10 non-driver bumps are the AI's commands on a two-colony board". Four of the ten are the research-rate gate, one per submitted block, and one of those four is the human's. Six are AI commands, from one AI player of three.
  • ai-turn-logic.md §8.1 / watchpoints-modcount-status.md — the reference game has three AI players (32, 496, 512), not one; ref-turn2.sav is turn2-state.sav; and the four Species == 4 factions submit no TurnCommands block at all (§2).
  • watchpoints-modcount-status.md §5 — "four of the twelve ModCount writers are unnamed". All four are named (§0.2), as are the two inlined ones.
  • ai-turn-logic.md §3.1 — the Process Turn phase table. Phases 29-33 cannot emit; cl_EndTurn at phase 28 latches client+0x15c before BuildTurnCommands runs (§3, P4).