sots-engine/docs/L4-predictions.md

19 KiB
Raw Blame History

L4 — what the live AI will emit, written before the module was built

Lane L4, 2026-09-08. Guest VM145 (sots-re-win10-145, 192.168.10.145). Worktree wip/l4 off main 0117495.

Rule 2: this file is committed before the shim module is written, and it names the falsifier for every claim. Nothing below is a summary of a run; there has been no run.

The question this lane exists to answer: does our emission model produce the same commands the game does? src/game/ai models the shape of a TurnCommands block and the cost of each element, and decides nothing. Nobody has ever looked at a real AI block.


0. The instrument, and why it is one detour

Game::StrategySim::ApplyTurnCommandBatch 0x0088f9b0 receives (blocks, n) as two stack arguments with ecx = sim — read straight off the prologue at 0x0088f9db–0x0088f9e7, where n is multiplied by 0x1b4 and added to blocks to make the end pointer. At that instant every player's submitted block is complete and in memory, so one register-transparent entry stub dumps the entire turn's command traffic: six gates and twenty-seven list lengths per player, plus the element bytes.

That is a strictly better instrument than counting ModCount bumps, which is what every previous lane has had. A bump count says how many commands; the block says which lists, how many elements, and what values.

Second instrument: sixteen register-transparent entry counters (probe_entry.cpp's pattern, its own table so lane H's set is untouched), on the task bodies and the emission gates. One of them — StrategyAIAgent::RunTaskList 0x006b3320 — additionally records its pass stack argument into a global, so every later probe hit is attributed to a pass. That is what turns AI3's P2 from a count into a measurement.

Rule 20 is the whole design. AITRaid's list-16 emit is the named open item, and a zero at 0x007635f0 means nothing unless AITRaid::Execute 0x0068e670 is also probed: "never ran" and "ran and emitted nothing" are opposite answers.

Rule 19 control. Both workloads have published oracle bytes. Every configuration is run against them and any configuration that moves a byte has invalidated its own numbers.


1. Workload W1 — turn1-state.sav, one End Turn (the turn 1→2 transition)

Facts already in the corpus, from turn1-state.sav → turn2-state.sav (read this session with verify/save-reader, not taken from a report):

players 8: ids 16, 32, 496, 512 (Species 0/2/0/2) and 528/544/560/576 (Species == 4)
player 32 HomeSys 288 = Ke'Dolarra, PlyrIdx 1
ModCount 0 → 12
ResRate 0.25 → 0.8 on 32, 496, 512; unchanged (0.25) on 16
ResTNm '' → IND_Waldo (32), DRV_PlsFiss (496), BIO_GnMod (512); '' on 16
Status 0 → 4 on 16/32/496/512; 0 on all four Species == 4
player 32 designs 5 → 6; the new one is DesID 18, "Honor Lance"
designIds gains 18 and 1712 — the second belongs to player 528, whose design count also goes 18 → 19
NMnx 106 → 109 (ship 1728, fleet 1744, and one more)
new fleet 1744 "Alpha Fleet" at system 288, holding ship 1728 of design 18

P1 — the block set

Prediction: n == 4, and the four block+0x04 player ids are 16, 32, 496, 512 in that order.

Falsified if n == 8 — then the Species == 4 factions do submit, they just submit empty blocks, and AI4 §2's "no StrategyClient at all" is too strong (it would become "a client that never sets the always-set gate", which is a different and stranger claim). Falsified differently if the four ids are 0,1,2,3 — then block+0x04 is a player index, not the save id, and every published reading of that field needs the same correction.

P2 — the four blocks, list by list

block gates set lists non-empty
16 (human) rate only, value 0.25 none
32 (AI) rate 0.8, target (IND_Waldo) 1, 3, 5 — one element each
496 (AI) rate 0.8, target (DRV_PlsFiss) none
512 (AI) rate 0.8, target (BIO_GnMod) none

Boost, group-4, the three-float Hiver gate and CivilianRatios clear on all four.

That sums to AI4's twelve: 4 rate + 3 target + 3 (player 32's lists) + 2 drivers.

Falsified if: any list outside {1, 3, 5} is non-empty on any block; or a list in {1, 3, 5} carries more than one element; or the human's block carries a list at all; or 496/512 carry a list (they own nothing, so a list element from them would mean a task fired against objects they do not have).

The sharpest falsifier is list 8/10/14 non-empty. Player 32 gets a brand-new fleet (1744) this turn, and a fleet is exactly the thing lists 8/10/14 order. If the AI issues a fleet order on the same turn it creates the fleet, AI4's P2 attribution is wrong by exactly the fleet-order group and the twelve has to be re-derived.

P3 — the element values

  • list 3 ({i32 ordinal, i32 designId, i32 systemId, i32 w}): designId == 18, systemId == 288. The w word has never been observed and is not predicted.
  • list 5 ({i32 systemId, OutputRates}): systemId == 288, i.e. the same system the build order names.
  • list 1 (ShipDesignDef frame + i32): the element carries the id 18 somewhere in its first 48 bytes.

Why 18 matters. 18 is not a multiple of 16 and does not come from the master NMnx counter — the other new design that turn, player 528's, got 1712 from that counter, and 528 submits no block. So there are two id spaces, and the prediction is that a client-issued design carries a small client-local id in the command itself, while a server-created object gets a master id. Falsified if the list-1 element's ids are all ≥ 512 / multiples of 16 — then 18 was assigned server-side when the command was applied and the design command carries no id at all, which is a materially different thing for a reimplementation to emit.

P4 — the probes on W1

probe prediction what a miss means
RunTaskList 0x006b3320 6 = 3 AI players × 2 passes 2 ⇒ only one player's task list runs and 496/512 reach ResTNm by some other path; 0 ⇒ the instrument is broken, not the game
BuildTurnCommands 0x00783780 4, one per submitting block ≠ 4 ⇒ P1 is wrong from the other side
RequestBuildForTask 0x006cea50 > 0 and even-ish — entered on both passes, emits only on pass 1 entries only in pass 1 ⇒ the caller gates before it, and AI3 §2.3's "the hub returns an empty list on pass 0" is not the whole mechanism
AssignFleetsAndIssueOrders 0x006c16c0, IssueRouteForFleets 0x006bbd50 entered, both passes, zero list-8/10/14 elements entered + elements ⇒ P2 falsified
AITRaid::Execute 0x0068e670 0 —
ClientOrder 0x007635f0 (list 16) 0 —
the six shared planner bodies (AITColonize 0x0068b400, AITEscortGateInvade 0x0068c7c0, AITInvade 0x0068d7a0, AITNodeBore 0x0068e590, AITBuildPoliceShips 0x00690380, AITBuildDeepScanShips 0x006901a0) at least one build-shaped body entered in both passes; at most one produces the single list-3 element none entered ⇒ the list-3 element comes from a task nobody has named, and AI3 §5's path table is not the emission path
AITAdvanceIdleShips::Execute 0x0068f230 entered (priority 0, always last) —
IsClaimedByAnotherTask 0x006a8d20 > 0 —

Stated in advance so it cannot be claimed afterwards: W1 almost certainly cannot settle AI3 §2.4. A turn-1 AI with one colony and no fleet of its own has nothing to raid, so AITRaid is predicted not to run at all, and a zero at 0x007635f0 will then be a non-answer about pass 0 — exactly the "did not fire" vs "fired and found nothing" distinction rule 20 is about. The AITRaid question needs AITRaid::Execute to be entered, and W2 is the better chance.

Also stated in advance: this probe set cannot settle AI3's P3. An entry counter on IsClaimedByAnotherTask measures the "called often" half only; the steal branch at 0x006a8ded is inside the function and an entry probe cannot see it. P3 stays open.


2. Workload W2 — ref-turn2.sav, one End Turn (the turn 2→3 transition)

ref-turn2.sav is turn2-state.sav (same sha256). Corpus facts for 2→3:

  • ModCount 12 → 24; NMnx 109 → 111 (ship 1760, fleet 1776 "Gamma Fleet");
  • fleet 1744 "Alpha Fleet" is gone, replaced by fleet 34 "Beta Fleet", in transit (FtTrans 1), flight plan one waypoint Wpt 272, pnd 288, ETA 3, origin = Ke'Dolarra's position;
  • no ResTNm change on any player; ResRate already 0.8.

AI4 §2.1 attributes the twelve as 4 rate + list 5 + list 3 + list 10 + 2 × list 14 + list 8 + 2 drivers.

P5 — the block

Prediction: n == 4; blocks 16, 496, 512 carry the rate gate and nothing else (no target gate this turn); block 32 carries the rate gate and lists 3 = 1, 5 = 1, 8 = 1, 10 = 1, 14 = 2, everything else empty.

P6 — list 14 is two elements against one fleet, and the second field is the discriminator

Lane Q read the element as {i32, i32, bool}; human-turn2-orders.sav (a UI fleet move) carries exactly one, {1456, 0, true}. AI2's P1 says the AI's bridge calls the adder twice with mode 0 and mode 1 and the adder keys on (fleet, mode).

Prediction: the two list-14 elements are {F, 0, b} and {F, 1, b} with the same F, and F is also the fleet id list 8's element names. That converts AI2's P1 from an inference about bump counts into a read of the values.

Falsified if: both elements carry the same second word (then the adder does not key on mode and the doubling has another cause); or the two fleet ids differ (then it is two orders against two fleets and the "one order costs two elements" rule is wrong); or list 14 has one element (then AI4's two list-14 bumps came from two different fleets and the reference turn moved two).

P7 — which fleet id the order names

Prediction: F == 1744 — the fleet that exists at submit time — and 34 does not appear anywhere in the block. 34 is created when the order is applied, from the same small-id space as design 18.

Falsified if F == 34: then the client creates the fleet object before submitting and ships the new id in the command, which would make the small-id space client-allocated and part of the wire protocol — a much bigger constraint on a reimplementation than the alternative, because our engine would have to reproduce that counter exactly to get byte-identical saves.

This is the one prediction I would most like to be wrong, because the falsifier is the more interesting world.

P8 — list 10's first word

Lane AI4 §4.5 refused to name list 10 ({i32, i32, counted i32}) on positional evidence alone.

Prediction: its first i32 is F, and its counted vector holds ship ids — on W2 that means the single ship 1728. If so, "assign these ships to this fleet" is supported by values rather than by adjacency, and the list can be named.

Falsified if the counted vector is empty or holds system ids (W2's route is a single hop to 272, so a {…, 272} tail would make it a route-shaped command instead).

P9 — AITRaid, the second attempt

Player 32 now owns a fleet, so AITRaid has a candidate. Prediction: AITRaid::Execute is entered on both passes and ClientOrder 0x007635f0 is entered zero times, i.e. list 16 stays empty and AI3 §2.4's inference holds — but for the reason the probe can see, which is the point.

Falsified if 0x007635f0 is entered with pass == 0 recorded: then pass 0 does emit, AI3's P2 is falsified, and every ModCount arithmetic in ai-order-emission.md that assumes one emitting sweep is off by a factor.


3. Rule 19 — the controls, and what they are compared against

Published oracle bytes (sha256 prefixes, findings/subsystems/running-the-game.md):

workload (Autosave EndTurn).sav (Autosave).sav
W1 from turn1-state.sav a3f9dc4b… ab4ac2d7…
W2 from ref-turn2.sav bb4fd9ac… 978041ac…

Three configurations per workload, each a separate launch:

run shim.cfg purpose
C hooks=off proves this clone reproduces the oracle at all. VM145 is a ZFS clone; nothing has ever checked that it is byte-faithful. If C fails, the lane reports that and stops — every later number would be measured against an unknown baseline.
A aiorders=on, aiprobes=off the block dump alone: one detour
B aiorders=on, aiprobes=all the dump plus sixteen entry probes

Prediction: C, A and B all reproduce both oracle hashes. Falsified if A or B moves a byte — in which case that configuration's numbers are reported as unmeasured, not as results, and the sixteen-probe set is bisected with aiprobes=N. Lane H's precedent is explicit that a MinHook detour has changed this game's behaviour once already, and that the probe module is the one it happened to.


4. What this lane will not do, said now

  1. It does not close AI3's P3 (§1, P4). An entry probe cannot see a branch inside the callee.
  2. It does not test the eleven "free" lists (AI4 P1's untested half) unless one of them turns up populated, which P2/P5 predict will not happen. If one does, that is the result.
  3. It does not settle the Hiver gate (AI4 P3). The corpus has no Hiver player and this lane is not manufacturing one.
  4. It reads element bytes, not element types. The dump emits a fixed 48-byte window per element plus the same bytes as ints and floats; decoding to lane Q's records happens offline, in the report, where a wrong record is visible as a wrong value rather than being baked into the instrument. Where a list's in-memory element is larger than 48 bytes the dump says so rather than silently truncating.
  5. The dump's own coverage check: for every list it walks the node chain and reads _Mysize, and logs a MISMATCH line if they disagree. A wrong list layout would otherwise print a confident, wrong zero (rule 1).

Addendum — the research-selection tie set

Added after the first two runs, before the research instrument was written. The coordinator's question: if the one varying AI decision is a tie broken by something per-process, the original's possible outcomes form a small enumerable set, and a deterministic game/ai can pick canonically and claim "our choice is one of exactly k the original can produce, and here are all k". That is a stronger claim than behavioural equivalence, and it needs the candidate set.

What the selection site actually is (read before predicting)

Process Turn phase 18 is 0x006caf70. It tries three producers in order and takes the first non-null:

eax = 0x006a84f0(agent)                        ; producer A
if (!eax) eax = 0x006c27c0(agent)              ; producer B
if (!eax) eax = 0x006c8890(agent, &agent+0x13c); producer C -- the candidate walk
if (eax && eax != player->+0x294) cl_SetResearchTarget(<eax+4 as a C string>)

player->+0x294 is the current target, and cl_SetResearchTarget 0x00578f60 takes the tech's name string, not an id — the object's +0x4 is a std::string and the caller resolves the short-string union before pushing it.

Producer C, 0x006c8890, has two halves:

C1, the candidate walk. if (player->+0x294 != 0) return 0. Otherwise build a std::vector of 0x0c-stride candidates with 0x006c2490 — which constructs a working object, fills it (0x006bcca0), emits the vector (0x006bc500, a nested walk over groups, no std::sort at that level) and destroys it — then walk the vector front to back calling 0x006c8580(ecx = the target slot, edx = cand[1], stack: agent, cand[0]) and return the first one that answers non-zero. First-acceptable in arrival order. No score, no comparator, no tie resolution: whatever the vector's order is, is the answer.

C2, the fallback, reached only when the walk accepts nothing: 0x006b36e0(agent) & 0x80000007 indexes one of two eight-entry .data tables, and the value there seeds a three-arm rotation (i + seed) % 3 over 0x006c8670. Both tables are in the image and both hold values in {0,1,2}:

0x00a1a544 = 2 0 2 0 2 1 2 0
0x00a1a564 = 0 2 0 2 1 0 2 0

0x006b36e0 is not an RNG call — it reads player->+0xf4, calls 0x0080da80 and computes. So C2's "roll" is a hash of player state, and its outcome space is at most three arms.

Predictions

P10 — where the variation lives

Prediction: 0x006c8890 (producer C) is entered once per AI player on turn 1 and the variation is in C1's candidate vector, not in C2. Specifically, across two runs of the same workload:

  • players 32 and 496 (stable across five runs between lanes L4 and L5) produce identical candidate streams in identical order, and the accepted candidate sits at the same position;
  • player 512 produces a candidate stream that is the same set in a different order, and the first-acceptable one is therefore a different tech.

Falsified if: the streams are identical for 512 and the accepted position still differs ⇒ the decision is inside 0x006c8580, i.e. state or a seed, and arrival order is not the mechanism. Also falsified if: 0x006b36e0 fires for 512 ⇒ the pick came from C2, which is not a tie at all but a three-arm rotation seeded by a state hash, and k <= 3 by construction.

P11 — k is enumerable, and small

Prediction: the tie set for player 512 is the set of candidates in its stream that 0x006c8580 would accept, and the stream is short — tens, not thousands. A player with no colonies and no research history has few reachable techs.

Falsified if: the stream is hundreds of entries ⇒ "name all k" is not a practical verification claim for this decision and the honest move is to mask the leaf, as lane L5 already does.

P12 — the two stable players are the control

Prediction: 32 and 496 accept a candidate at position 0 or very near it, because a player whose choice never varies is one whose first candidate is always acceptable. If instead they accept deep in the stream and are still stable, then order is stable for them and unstable for 512 — which would point at 512's container, not at the walk.

P13 — what our engine should do

If P10 holds, a deterministic game/ai sorts the candidate vector by a canonical key (the tech name, which is what the original ships in the command anyway) before the walk. The verification claim becomes: our pick is the canonical member of the tie set; every observed original run picks some member of the same set; here is the set. If P10 is falsified in the C2 direction instead, the claim is different and weaker in kind but stronger in size: three arms, both tables in the image, so k <= 3 and all three are nameable without any capture at all.

Either way the capture names k. That is the point of running it.