Commit graph

71 commits

Author SHA1 Message Date
alex
e966a72fd9 PAR: record the gates, and the one difference between the deployed and committed builds
clean_room_check OK, host ctest 55/55, CT111 shim cross-build OK on a fresh build directory, all
three as separate commands. The deployed instrument (par4) differs from the committed source only
by two RVA literals that the named constants resolve to identically -- said here rather than left
for a reader to find. Also flags that main moved under the branch, so the header must be
regenerated at merge, not hand-resolved.
2026-09-08 19:37:56 -04:00
alex
4e76ec24bb PAR: reconcile the turn1-state outcome set with lane CB's concurrent pinned runs
CB pinned the seeds and got d59bb9f2 three times; this lane left the seeds free, watched client
512's single research tie-break draw resolve with a different number of rejections in each process
(1, 3, 1 words), and still got d59bb9f2 three times. Different experiments, agreeing in a way that
constrains the mechanism: whatever maps that drawn word onto a research target is NOT a uniform
index into a six-member shortlist, or three unpinned runs agreeing would be 1-in-36 on top of CB's
1-in-36. One line resolves it -- log the chosen tech id beside the draw in a probe on 0x006a8390,
whose cl_RandRange bound IS the shortlist size the k question is about.
2026-09-08 19:37:31 -04:00
alex
198695c4cc PAR: the load-time stream offset also varies between processes, and the five-scope table
Adds the lifetime-census result: the three AI clients arrive at their first turn 9 / 411 / 421
words into their streams while the human's generator is at word 0, and client 32 started its turn
at block index 9 in one process and 11 in another FROM THE SAME SAVE -- the rejection loops it runs
during Prepare Turn resolve differently under different seeds. So a client's generator position at
the start of a turn is not a function of the save either.

Also records what pinning the seed showed (the same three words, and the oracle bytes unchanged --
so every one of those words is a draw whose result never reaches the save, measured directly for
the first time), the facade call-site attribution that names client 512's single draw as the
research-target tie-break at 0x006a8495, and one observation this lane could NOT resolve: three
fresh processes on turn1-state gave the same autosave although 512's stream demonstrably differed,
which does not reproduce lane L5's three-different-files result on that pair.
2026-09-08 19:36:10 -04:00
alex
1893751ffa PAR: roll parity -- an AI client's per-turn RNG word count is not fixed at any scope
The hypothesis under test was the lockstep discipline: that each run of an AI client consumes a
fixed number of draws regardless of the path it takes, so a reimplementation could keep the
generator aligned with the right COUNT and order of draws while getting the decisions wrong.

It is false, and it fails at four scopes. Measured with a new bracket on
StrategyClient::OnResumePlaying over the per-client generator at +0x134, six runs across VM140 and
VM145, five fresh processes; every unpinned run's autosaves are byte-identical to the published
oracle, so the instrument is behaviour-neutral (rules 19 and 26 both discharged).

  across clients   turn2->turn3: 3 / 0 / 0 words for AI players 32 / 496 / 512; human 0
  across turns     client 32: 3 words on turn 2, 7 on turn 1
  across processes client 512 makes ONE cl_RandRange call on turn 1 -- the research-target
                   tie-break at 0x006a8495, phase 18 -- and it cost 1 word in one process and
                   3 in another, because RNG_NextInt is an unbounded rejection loop
  per site         RNG_Chance costs ZERO words at p<=0 and p>=1

Twenty-one live draw sites in an AI turn, in twelve functions (plus two provably dead ones); two
fired on the reference turn, three on turn 1. Only three are unconditional, and all three only
given that their enclosing function was called. Six of client 32's seven turn-1 words come from
the ship-design composer 0x006ad700, which is also where the only loop-carried draw lives.

Also: cl_RandFloat 0x00579c70, a third cl_* RNG facade, found twice independently. It reaches
RNG_NextFloat by a TAIL JUMP, so no rel32 sweep for the entry points can see it -- which is why
ai-turn-logic.md 5's 'zero NextFloat calls from the AI module' reads as true and is not. All 29
call sites of the three facades are inside the AI band: the cl_* RNG facade is AI-only surface.

Positives for the engine: the AI draws from nothing but its own client's generator (foreign_words
0 on every bracket), the human client draws nothing at all, and the per-turn cost is single digits.
Rung B is unaffected. Rung C needs the decisions.
2026-09-08 19:33:00 -04:00
alex
97f4cc7f5c CB: record the two planned runs that were not made, and why
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:26:53 -04:00
alex
a64673378a CB: third pinned run -- three processes, nine natural seeds, one autosave
C5b confirms C5a: pinning the AI client seeds to an earlier run's makes the creation
turn reproducible. Three processes, each drawing its own three seeds, all pinned to
C3's, all producing d59bb9f2... with identical gates (including player 512's target
282, the value no other run has produced) and identical element records.

The only word that differs anywhere across the three is word 3 of list 23's unnamed
Population body. Three runs with byte-identical autosaves cannot differ in a word the
applier reads, so that word is noise -- localised using the pinned pair as an
instrument, which is what a control on a k>1 workload is for.

Two agreeing is a 1/k coincidence; three is 1/k^2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:26:20 -04:00
alex
954f3cec63 CB: the turn-command stream, captured and bound to the autosave it produced
Canonical pair turn2->turn3: the complete block set, the three heap payloads no
previous capture could read (route [272], list-10 [1728], the 24-byte Population
body), the three AI client seeds, and both output autosaves -- byte-identical to the
published oracle AND to this lane's own hooks=off control, so the stream and the save
come from the same run and the instrument did not change the turn it recorded.

Creation turn turn1->turn2: three runs. Pinning the AI client seeds to the values an
earlier run observed made a DIFFERENT process reproduce that run's block -- including
the research pick that varies -- and its autosave byte for byte. The workload three
lanes could not reproduce is reproducible given the seeds.

Two corrections to lane L4's list-23 reading (no trailing int; the body is not
turn-dependent) and one to my own list-5 record, the latter found by lane RB while
consuming this capture.

Format: JSON (raw words are ground truth, decoded is a typing) plus lane RB's own .tcb
grammar with the heap payloads filled in, so RB's reader consumes it unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 19:18:57 -04:00
alex
ce8b3e66b4 RB: lane CB's real capture replays to the same result, and brings the seeds and the heap payloads
CB's VM146 capture goes through the JSON->tcb adapter and the replayer unchanged and reproduces
this lane's canonical result from a separate run on a different guest: 108 -> 62, closed 46,
regressed 0, ModCount 24. It carries the first AI seeds any capture has held (32/496/512), and
its deep dump closes two gap rows: list 8's route is [272] and list 10's vector is [1728], a
ship the input save already contains.
2026-09-08 19:07:13 -04:00
alex
a4a1d373f1 RB: replay a recorded turn's commands -- ModCount is reachable, and the rates frame's memory order is not its wire order
sots_turn --turn-commands puts /Sim/ModCount on the original's 24 with zero residual, closing
the one leaf that has been unreachable from a save all campaign. Canonical pair 108 -> 62,
closed 46, regressed 0, fresh build directory.

The .tcb capture format (line-oriented, parser-free, '?' for a field the instrument could not
read, per-client AI seeds), a converter from lane L4's shim dump, and an adapter from lane CB's
JSON capture -- CB's stays the capture of record, .tcb stays the engine's input, and the two
paths produce byte-identical replays.

A falsified prediction paid for itself: the first run regressed two leaves because list 5's
element is decoded in MEMORY order, and the memory order of the rates frame is NOT its wire
order. Memory member 1 is wire member SRsc; six members unread. Lane CB's decoder has the same
defect and should drop its list-5 record.

Two new addresses (the second and third gate-loop heads) in ghidra/addresses.d/lane-rb.json.
2026-09-08 19:01:35 -04:00
alex
4ef630b266 L2: record the pgrep -f tap141i0 hazard - it kills the guest, not just tcpdump
QEMU's command line contains ifname=tap141i0, so pgrep -f on the tap name matches
the VM alongside the capture. Stopping the tcpdump that way SIGTERM'd VM141.
Nothing was lost (all artefacts were already off the guest and it restarted with
a clean volume) but it is a plug-pull on an exclusively-held guest. Use
pgrep -x tcpdump.
2026-09-08 18:31:00 -04:00
lane-l4
0c162e5831 L4: the research tie set -- k = 6, named, and the mechanism measured twice
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.
2026-09-08 18:30:05 -04:00
alex
79245837be board: lane L1 - hive creation draws in BeginProcessTurn; CDiff edges played forward; SnLv verified; VM140 free 2026-09-08 18:26:51 -04:00
alex
4f805e381a L1: CDiff crosses at frame 50, and the AI client seed is fresh in every process
Two results from the same VM session, both of which needed a workload the
corpus cannot supply.

CDiff, played forward. ref-turn2 loaded and 49 End Turns driven through the UI
helper to frame 51, with the entry hook emitting one record per turn:

  50 calls, frames 2..51, EXACTLY TWO STORES
    frame  2  -1 -> 0
    frame 50   0 -> 1     <- the modelled tier transition, at the modelled frame
  and 47 turns between them on which the writer ran and wrote NOTHING

predict_path was computed at entry from the transcribed threshold table on all
fifty turns and agreed with the cdiff region on every one. Frame >= 100 is still
a code read and is not claimed as a measurement.

The AI seed probe (asked for by the coordinator, ranked above CDiff). Hooks on
Mars::RNG::Seed and StrategyApp::RunAI, two launches from turn1-state, load
only. Every AI client seed differs between processes -- net 32, 496 and 512 all
move -- while the record structure is identical and one Seed call with seed=0
produces a byte-identical state in both runs.

So the turn1-state -> turn2 nondeterminism is a SEED effect, not the ordering
effect predicted, and the 'one of three varies' observation is explained by two
of the three empires having a research pick that is robust to the stream. This
falsifies lane AI1's 'every draw from the static generator returns 0', which
that lane had explicitly flagged as arithmetic rather than measurement.

Where the seed comes from is NOT established; the finding names the one hook
that would settle it and the six values it must reproduce.

VM140 left as found: the 8-file save set with its oracle bytes intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 18:23:19 -04:00
lane-l4
1824dae2f5 L4: credit lane L5 as owner of the turn-1 nondeterminism; add the command-block evidence that localises it to the client 2026-09-08 18:03:28 -04:00
alex
b3299979a7 determinism qualified: oracle holds only where no AI must choose; Rung B = command-stream replay, Rung C = behavioural AI; canonical pair now turn2->turn3 2026-09-08 18:02:58 -04:00
lane-l4
7e6bda3699 L4: the AI's command block, read out of the running game
findings/subsystems/ai-order-capture.md -- two workloads on VM145, every submitted
TurnCommands block dumped at the batch applier, plus sixteen pass-attributed entry
probes on the task bodies and the emission gates.

Headline: our emission model reproduces both real blocks exactly, list for list and
element for element, and both turns land on the measured ModCount of 12. What it did
not have: a list-23 element on every AI turn (the first element ever observed in the
free half of the cost table, and it really is free), client-allocated ids travelling
inside the commands (design 18, fleet 34 -- neither in the input save), and build,
rates and population all naming the same home system.

Two predictions falsified, both usefully: the batch is n = playerCount = 8 with the
four monster factions holding untouched slots, not n = 4; and the fleet order names
the fleet the client has ALREADY created, not the one in the save.

AITRaid's pass-0 question stays open and now says why -- the task never ran on either
board, so the list-16 zero is 'never entered', not 'entered and silent'.

Rule 19: the ref-turn2 control passed byte-identical to the published oracle with all
seventeen detours installed. Separately, the turn-1 workload is NOT reproducible --
three runs, three autosaves, differing in exactly one field: the research target of
the AI player that owns nothing.

ghidra/addresses.d/lane-l4.json: 9 entries (8 IAITask::Execute bodies + the list-16
order method). Raw logs and the two divergent autosaves in verify/results/shim/aiorders/.
2026-09-08 18:01:45 -04:00
alex
a3e6e1d415 L1: hive creation draws inside BeginProcessTurn -- lane Z's zero was the workload
Lane SV recovered the script-object subsystem statically and predicted that
SVSOSwarmQueen::RegisterHives takes one strategic-generator word per new hive
inside StrategyServer::BeginProcessTurn, which runs inside lane Z's autosave
bracket and outside both turn drivers. Nothing had ever been hooked in that
interval. Measured on VM140, and it is right.

  turn 1 -> 2 (hives created)   BeginProcessTurn 2   ProcessTurn 20   residual 2
  turn 2 -> 3 (hives exist)     BeginProcessTurn 0   ProcessTurn 19   residual 0

Two instruments that share no code path agree: the region ledger reads
170 -> 172 across RegisterHives, and the return-address draw-site table gains a
row at 0x00527714 (NextInt, 2 calls / 2 words) which is absent on the next turn.
The draws produce NextQ 30/28, TickHives' slip takes them to 31/29 -- exactly
turn2-state.sav -- and the next turn's slip gives 32/30, exactly turn3-state.sav.

So tail-rng-ledger.md's "all of it inside ProcessTurn, residual exactly zero" is
correct for every turn it measured and false as a statement about the code. The
interval a standalone must reproduce starts at BeginProcessTurn.

Also closed or corrected:
* LO/HI read live rather than fitted: NextQ = frame + 20 + NextInt(10),
  inclusive; the TickHives gates are 10 (a frame floor), 5 and 3, so no queen can
  spawn before frame 11 on any save.
* CDiff's threshold scan re-read independently: the tier can only ever be 0 or 1,
  and "entered and stored nothing" was observed live and distinguished from
  "did not run" (method rule 20).
* SnLv measured for the first time: Spica reads AFlags 0 with SnLv 0x200, so the
  sensor branch is what refreshes it -- nvo-tshn-visible-owner.md §6 goes [H] to
  [V]. ComputeContactLevel's documented "else 1" names one of four return tails;
  the observed non-visible level is 2.
* Rule 19: the oracle reproduced byte for byte with all five new detours live,
  and again with four watchpoints armed on top.
* turn1-state -> turn2 is not deterministic (lane L5 owns this). Two more runs
  here make it four distinct outcomes over six runs, including two DIFFERENT
  hooks=off results, and the strategic generator is provably not what varies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 17:59:32 -04:00
alex
e4195d47d1 board: lane L5 - reference pair non-deterministic; interest literals verified with a failing control; verified column held at 0 on principle 2026-09-08 17:55:47 -04:00
alex
ea7881acb3 L5: the money chain's float widths, measured live at a boundary
Four float constants in ComputeBudget have a width a C++ port gets wrong by
default, and the corpus exercises the boundary of exactly one. That one is now
measured against the running game, with a control build that must fail and does.

  savings interest   (double)0.01f   boundary: treasury a multiple of 100   IN CORPUS
  debt interest      (double)0.15f   boundary: a negative treasury          not in corpus
  research yield     (double)0.85f   boundary: research money mult of 40k   not in corpus
  the three ResMod   summed in f32   boundary: two of three non-zero        not in corpus

Three constants sitting beside those, in the same expressions, are EXACT doubles
and must not be "corrected" -- so "widen every literal" is its own defect and the
only safe procedure is to read each operand. Same pattern elsewhere in the chain:
ComputeOutputFromRates multiplies by an exact 1.5 then a widened 1.2f two
instructions later, and NormaliseOutputRates holds 1e-4 at BOTH widths.

Live result (VM146, turn1-state.sav, three runs): the game pays 499 interest on a
treasury of 50,000 and 380 on 38,100; the exact decimals pay 500 and 381. Every
divergence in the control build lands on a multiple of 100 and nothing else
diverges at all. Coverage stated as distinct states, not calls: 5 distinct
treasuries, 2 on the boundary -- against the earlier green run's 20 distinct
states, none on a boundary.

BANKRUPTCY_PROTECTION_LIMIT_FACTOR read live: 3.29999995 = (float)3.3. Its file
image is zero because the loader fills it at run time, so lane PL-3 could read the
width and had to assume the value. The assumption was right and is now measured.

Falsified: the difficulty-mods record does not sit inline at ServerPlayer+0x36c;
that field is a heap pointer on all eight players. Recorded as a hypothesis with
the measurement, not as a fact.

Second finding, from the rule-19 control: turn1-state -> turn2 is NOT a
deterministic pair. Three runs gave three post-turn autosaves differing in exactly
four leaves -- one Singularity shadow empire's research pick and the derived
checksum. Two of the three runs carried identical hooks, and the un-instrumented
run was a third value, so this is the game and not the instrument. The
determinism oracle stands for ref-turn2 -> turn3 and does not generalise to its
neighbour; no lane should use this pair as a byte-match oracle.
2026-09-08 17:49:35 -04:00
alex
471d6cb973 W3: NVO.TShn's writer trapped live, its gate named, and 158/158 on the corpus
TShn is refreshed to Frame for every (system, player) that satisfies ServerSystem::IsKnownTo
0x00746390 -- IsVisibleTo OR (2-bit sensor contact at ServerSystem+0x24 AND CCC_AdvSens). Not
AFlags alone, which is why lane E3 could not fit it. The writer chain was confirmed live, frame by
frame, from a hardware watchpoint on Spica's NVO node; the refresh runs twice per End Turn (driver
phase 24 and combat-done phase 25).

Also: the trade and spy containers read out of a live game for the first time (both empty, zero
traps -- the workload confirmation two lanes lacked); Player.Status's predicate named as
ReqCL != 0 && Elim == 0, with Species != 4 shown to be a corpus coincidence; and all ten command
ModCount handlers named, two of them only reachable on a turn-1 workload.

Corrects objects/layouts.md's ServerSystem PID@0x274 row and closes system-visibility-record.md 7.

Runs: build w3tshn-bb81d3d-20260908T2007Z, configs shim.cfg.w3tshn / w3mod / w3control.
Rule 19 control passed -- the armed run reproduced the determinism oracle byte for byte.
2026-09-08 16:41:09 -04:00
alex
91a04377d4 lane PL: decompose the /Sim/players residual by mechanism; the bankruptcy protection factor is a float32
findings/subsystems/players-residual.md is the deliverable: every one of the 54
leaves on the reference pair and the 24 on pair 2 attributed to a named
mechanism, with the rung split. 53 of 54 are pass-through -- the block is
unmodelled, not mismodelled, and only player 32's Sav is a number we compute and
get wrong.

Three corrections to the record:
- T31's self-check compared its post-turn result against the pre-turn stored
  value, so its 6-of-8 covered only the players whose limit never moves.
- the AI difficulty column IS recoverable from the save, by recomputing BnkEl
  under both columns against the value the save carries; 1 AI + 1 non-AI on all
  eleven corpus saves.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR is read fmul dword ptr, so it is a float32
  in the image; the decimal disagrees at every max income divisible by ten and
  the corpus' seven records land where the two agree (rule 23). formula-gaps Q1
  addendum + ghidra/addresses.d/lane-pl.json (3 data entries, fragment validated
  to a scratch path, no duplicate names).

verify/results/standalone/{status.json,report.txt} refreshed from a build of main
aabd8a3: the committed copy was stale at 131/55, the real number is 128/54.

T34 RecordObservedDesigns is now readable off the corpus and is NOT an intel
pass: a design's creator registers the design in its own odes, its weapons in
owep, and re-stamps otnL on its techs in otch -- odes on build, otch on creation.
Both are hypotheses until a save exists where a player observes someone else's
design.

Status stays open and is flagged as NOT identifiable: eighteen fields of the
player record split the roster the same way and only two saves carry a non-zero
value. The probe is an entry hook on W2's writer.
2026-09-08 16:36:45 -04:00
alex
ae47971316 AI4: what an AI turn emits, and what each command costs in ModCount
Joins the emission side (lanes AI1-AI3) to the counting side (A2, W2). Three
results.

The cost of applying a command is a property of its list, and the boundary is
sharp: lists 1-16 each advance ModCount once per element, lists 17-27 never do.
Four of the six prologue gates bump, one is free, and the sixth has no applier
anywhere in the application path -- its cost is unknown, not zero. The batch
applies the twenty-seven lists in a fixed order that is neither list nor offset
order, with the six gates split across three separate per-player loops at three
different points, and four of the sixteen bumps inlined into the batch rather
than living in a handler, which is why a call-graph sweep under-counts them.

Lane W2's four unnamed handler EIPs are named, and so are its two inlined ones,
and the ten measured command bumps then decompose with zero residual. Four of the
ten are the research-rate gate, one per submitted block, and one of those four is
the human's. Two are list 14, on a turn that moved exactly one fleet -- which
confirms lane AI2's P1 (an AI fleet order deposits two fleet-task elements where
the interface deposits one) from the counter side, at no VM cost.

The reference game is not what the record says. ref-turn2.sav IS turn2-state.sav;
there are THREE AI players, not one, and the two dormant ones do run -- all three
set a research rate and picked a research target on turn 1; and the four
monster-faction players submit no command block at all, which is the first direct
evidence that they have no client rather than an empty task list.

Prediction committed for turn1-state: the same 12, out of a different set of
commands -- 4 rate gates, 3 research targets, and three orders from the one AI
with an empire, which are predicted to be a new design, a build order and a
system-rates command, with NO fleet order on turn 1. The trap multiset it
predicts contains two EIPs W2 has never seen and omits three it did, so it is
cheap to falsify: one save swap on W2's unchanged watchpoint module.

Also: phase 2 of the AI's turn is Hiver-only (a fifth cross-check on the species
reading, and the reason one prologue gate has never been observed set), phases
29-33 are dead because the submit latches the client before it builds the send
buffer, and cl_SetResearchTarget is AI-only surface with exactly one caller.

14 addresses in ghidra/addresses.d/lane-ai4.json; validated to a scratch path,
1,138 -> 1,152, no duplicate names.
2026-09-08 16:27:58 -04:00
alex
d4f9404960 lane G3: civilian growth read and measured; out[6] is not a repair number
findings/subsystems/population-growth.md -- ServerSystem::GrowCivilianPops
0x00754220 read byte for byte, its whole chain, and Ship::RepairCost
0x00815180.

The headline is a correction to the brief's premise. The pass is NOT gated
on imperial carrying capacity. The whole system's civilian delta is clamped
to 20,000,000 -- POPTYPE[1]+0x08, an int64 literal in the executable -- and
on both reference pairs that clamp decides the value: the uncapped delta is
7.5x it and the capacity headroom 25x it. The imperial capacity is pinned
at exactly Size x 1e8 from the corpus alone, by two independent behaviours
of one colony across three turns, with no data files.

Two boundary corrections, both of the rule-17 shape. GrowCivilianPops is a
loop over group types whose back edge lies outside every decompiler `if`,
so it reads as straight-line code if you stop at the first `ret`; its real
end is 0x00754b59. MaxPopGeneric ends at 0x0074a6cd, so the 0x0074a6d0 that
lanes N and E1 both cite for the capacity-surplus pair is a different
function.

A table correction with teeth: InitPopTypeTable never writes the group
ceiling at +0x28/+0x2c. The CRT static initialiser at 0x009abe20 does, to
INT64_MAX, and nothing else touches it -- so the clamp that reads it is
always a no-op. A reader who opens only the obvious initialiser sees zero
there and would cap every carrying capacity in the game at nothing.
Lane N's whole table is otherwise reproduced independently from the
six-register fxch rotation.

output-turn-path.md: out[6] is NOT the ship-repair demand. 0x007460b0 sums
0x0081f8c0, which gates on the design's carried-population bit and computes
GroupIncome over the ship's own Population -- so it is the income of
population carried in slaver and colony hulls, a slot the engine already
had under a name nobody had connected to it. Corrected in place, with
lane-c3.json's three unread stubs superseded by lane-g3.json's read entries.

Ship::RepairCost is unexercised on the corpus and that is a measurement,
not an absence (rule 20): the independent colony keeps a ten-ship fleet in
orbit over Koa'Vo on both reference pairs and its Sav closes exactly with
the demand taken as zero, which it could not do if any hull had a cost.

Engine side: sots-engine wip/growth ed6602e -- reference pair 78 -> 81
closed, 0 regressed; pair 2 36 -> 39 closed, 0 regressed.
2026-09-08 15:53:08 -04:00
alex
c224ff2214 lane EV: what a turn actually posts, and two corrections to events.md
Measured over all eleven saves with state_checksum, not derived:

  * the reference pair posts TWO events (turn1->turn2) and THREE (turn2->turn3),
    and only two players in the whole corpus ever hold an event -- the human and
    the one AI empire that owns colonies. The dormant shadow empires pick research
    targets every turn and still post nothing.
  * order within a player is readable off the ids: the build pass posts before the
    research pass. Ids are per player, so no cross-player order is observable.
  * the event type is the EvImg string and it is composed at run time --
    EVENT_ENEMY_INCOMING_Human carries a species suffix, so the type space is not
    an enumeration.

Two corrections in place (rule 11):

  * the turn PostEvent is handed is the FRAME, not ModCount. turn2-state.sav has
    Frame 2 and ModCount 12, and every event it carries is in bucket EvTurn=2. It
    is the post-increment turn: a turn run from a save at turn N posts into N+1.
  * the EVENT_NO_RESEARCH gate: 0x00584e50 is TechTree::CollectResearchedTechs,
    not a ListAvailableTechs, and the middle test is "nothing was researched on
    this turn or later" -- not "no affordable tech". zuul-turn23 exercises it: the
    human posts RESEARCH_COMPLETE on turn 22 with no no-research event that turn,
    then NO_RESEARCH again on turn 23.

ghidra/addresses.d/lane-ev.json records the gate at 0x0089162a with the argument
order re-read from the instruction stream. Validated with tools/gen_addresses.py to
a scratch path (1121 entries, no duplicate); the shared header is NOT regenerated.

tools/standalone_report.py gains --engine-arg (repeatable), so a lane can feed the
standalone the operator inputs a save does not carry -- the data root, the AI
roster, which blocked phases may commit -- instead of hard-coding them. Every run
records what it was given, in the report header and in status.json.
2026-09-08 15:40:07 -04:00
alex
e9dec36d77 findings: lane AI3 -- the AI stepping order is save player order, and pass 0 writes nothing
Closes five of lane AI2's open items and corrects two published claims.

The stepping order: StrategyServer::ResumePlaying 0x007ddc90 walks the player
array in INDEX ORDER and raises SEResumePlaying at each live player; the app
callback StrategyApp::OnClientEvent 0x00838e10 delivers it inline for humans
and appends it, deduplicated, to the pending queue for AI players. So the AI
players are stepped in save player order, each at most once -- computable from
a save with no live measurement. AI2's search missed it because 0x00b29f98 is
not a pointer to the StrategyApp, it IS the StrategyApp: the enqueue writes the
absolute member address 0x00b29fb4 and never materialises the object base. The
enqueue also has zero direct callers and no vtable slot -- its address is stored
into StrategyServer+0x170 by CreateGame. Lane B6's third blind spot, twice over.

The two passes: `pass` is a tier index, not a plan/act switch. 0x006abb2a picks
between two per-candidate quota fields -- tier 0 takes +0x10, tier 1 takes +0x14
-- and the hub loops `for (i = 0; i <= pass; ++i)`. Every order-emitting exit is
gated pass == 1 (0x006bbd50, 0x006c16c0, 0x006cea50), so pass 0 claims each
task's minimum force in priority order and WRITES NOTHING. That halves the
ModCount arithmetic.

Corrections:
- AI2 §4.2: the nine "no order method" tasks are not planners. All nine emit
  orders at depth 4-9; the depth-4 cut hid it. AITColonize reaches list 7 and
  AITBuildPoliceShips reaches list 3, which is what their names promise.
- AI2 §3: the two priority overrides' flag polarity is inverted. The tunable
  applies when bit 0 of +0x4 is SET, and "committed" is not a supported name.
- AI2 §10.6: the .data invade tunables have no loader. 650 and 750, image
  constants, exactly one reader each and no writer anywhere.

Also: slot 12 named (a preemption permission, 0x006a8d20), slot 13's consumer
found (0x00696620, a range budget -- and the Zuul are exempt, a FOURTH
independent cross-check on AI2's species reading), slot 11's dispatch located.
ServerPlayer+0xf9/+0xfa -- the two bytes that decide whether a player is
AI-controlled -- sit in a hole in the serialised layout and are NOT in the save;
their writer is unfound and is the biggest remaining hole.

Static only; nothing here has run under an instrument. 160 indirect call sites
inside the AI closure are unresolved, so reachability is still a lower bound.
Four predictions with falsifiers in §7.

ghidra/addresses.d/lane-ai3.json: 15 entries, 1,105 -> 1,120, no duplicates,
validated to a scratch path.
2026-09-08 15:29:53 -04:00
alex
b77a6116e2 lane W2: VM housekeeping notes for the multiplayer recipe (SOTSUI2 left registered, SOTSB staged not registered, -ExecutionPolicy Bypass gotcha) 2026-09-08 15:26:42 -04:00
alex
92ad44efb0 lane W2: multiplayer Tier 0 played end to end with no server; ModCount/Frame/Status watchpoints; rcex explained
Multiplayer: two clients on one guest, joined by typed IP and by LAN browse, launched a
2-player game and played two turns in lockstep. 44,319 packets captured outside the guest,
ZERO to any GameSpy port. Availability check fails open exactly as lane G2 read it; Join
Manually is enabled with every gamespy.com name dead, falsifying G2's own caveat (a). The
shipped MOTD is fetched live from kerberos-productions.com and tells players to host in LAN
mode -- the developer's own statement of the same finding.

Watchpoints: a new shim module arms DR0-DR3 from StrategyServer::ApplyAllTurnCommands.
ModCount takes exactly 12 writes per End Turn (lane A2's prediction, confirmed twice, both
predicted addresses exact); Frame takes exactly one, from BeginProcessTurn+0x2a, settling the
ModCount-vs-Frame naming in A2's favour. The Player.Status writer between tail phase 31 and
the autosave EXISTS and is StrategyNetworkClient::OnMessage+0xa15 -- correcting lane T2.
Rule 19 control: the armed run reproduced the determinism oracle byte for byte.

rcex: closed from the corpus alone. It is sixteen 4-bit per-player counters; nibble p is set
to 1 on the turn the system enters player p's AFlags and ticked to 0 the next turn. 7/7
across two different games.
2026-09-08 15:23:52 -04:00
alex
4c21bbe19e lane T2: re-measure H02 on main 2fd0852 (C3 + B6 merged) -- same +26 / +14 / +12x3, 0 regressed, so the phase is orthogonal to both 2026-09-08 15:04:36 -04:00
lane-b6
5cc66f6fff findings: ship construction -- the build queue read byte for byte, and the missing destroyer is the AI's
BuildQueue::ProcessTurn 0x00890d50 disassembled to the next function start (Ghidra says 1230
bytes; the body is 1264 and its end lands inside the epilogue -- rule 17 again). Corrects the
recorded prototype: the first stack argument is the StrategyServer S frame, not the system, so
the build-completed event goes on the sim and the hull's turn stamp is Frame.

THE HEADLINE IS A CORRECTION. Lane E2's open census leaf (shpt[0], one destroyer short on both
reference pairs) cannot be closed by S11's build-queue sub-pass: measured over the corpus, both
reference saves carry three empty BQ frames, every hbq false, and the one TurnCommands_v5 block
empty. The order is created inside the turn by the AI -- PvSav shows 11,900 leaving the treasury
before spine phase 0. The blocker moves to game/ai.

Also: the per-class built counter has EXACTLY ONE writer in the image (image-wide byte scan);
ShipRecords sized by enumeration; the ship/fleet birth chain end to end, correcting lane B5 --
FtFlg 0x400 is set by EVERY fleet born through the create path, not by retreat; one home fleet
per system cached at ServerSystem+0x238; and a third indirection class vtable_map.py cannot see
(the ship-borne wrapper is dispatched from a stack-built pointer table).

21 addresses in ghidra/addresses.d/lane-b6.json, validated to a scratch path against the merged
set (1,077 entries, no duplicate names); two entries dropped as duplicates of lane B5's and
recorded as agreements instead.
2026-09-08 14:54:03 -04:00
alex
90c665a3be lane T2: record the rule-17 hit -- the caller's real body runs 0x48 bytes past Ghidra's reported end, and a clipped sweep would have found no caller at all 2026-09-08 14:53:40 -04:00
alex
49fa5501ba lane T2: the treaty-turn stamp, and Player.Status's real writer
findings/subsystems/treaty-turn-stamp.md. Briefed on the post-combat tail, ranked its
phases by leaves-closed-per-effort against the standalone's 158-leaf residual, and found
that every tail phase which moves a leaf in this corpus is blocked on another lane
(ship construction, the budget) or on the tail's vtable blind spot. Section 5 lists that
ranking with the numbers. The two items below are what a lane holding no VM could close.

1. StrategyServer_StampTreatyTurns 0x007898c0, read as instructions. The diplomacy
   ledger's per-turn stamp over every ordered pair of players holding a treaty, creating
   the entry on demand. Sole caller is ApplyTurnCommands, so it is a HOST step and not a
   tail phase. Three corrections to strategic-turn-internals.md 5.2, which is flagged in
   place: the relation codes are 3=ally / 2=NAP / 1=cease-fire and not the reverse (which
   combat-done-tail.md 2A already implied independently); the stamped value is Frame, not
   ModCount; and the bit is the PlyrIdx field, not the player's vector position -- the
   opposite convention from the shared-vision mask.

   Modelled in sots-engine as host phase H02. Predicted 26 leaves on the reference pair
   and 14 on pair 2 before building; measured exactly 26 and 14, 0 regressed, and +12 on
   each of three further pairs from a different game at turns 2, 15 and 16 that the model
   was never fitted to. 76 closed / 0 regressed over five pairs, 0 RNG words. The rule
   also reproduces the ledger of ten of the eleven corpus saves entry for entry.

2. Player.Status. backlog.md item 6 has it blocked on "the writer between tail 31 and the
   autosave -- watchpoint". There is no such writer. A whole-image scan for immediate
   stores to ServerPlayer+0x164 finds exactly three: ProcessTurn's encounter loop writes
   1, ResumePlaying writes 0 on load (the 4 -> 0 the determinism note recorded), and
   StrategyServer_MarkPlayerTurnEnded 0x00821a40 writes 4 from the three End Turn
   SUBMISSION paths, which run before the turn is processed. Item 6 needs one predicate,
   not a watchpoint -- and an entry probe on that function names the set directly.

   NOT written. The only two saves in the corpus with a non-zero Status agree with
   Species != 4 on all eight players, and that is eight observations on a 1-bit predicate
   that nine of the eleven saves do not exercise at all. Evaluated and reported.

Seven addresses in ghidra/addresses.d/lane-t2.json, validated to a scratch path against
the merged set (1,095 entries, no duplicate). verify/results/standalone/{report,status}
deliberately untouched: lane C3 published into them minutes before this run.
2026-09-08 14:52:44 -04:00
alex
3b99e255c7 lane C3: ComputeOutput on the turn path -- the money ComputeBudget actually sums
`ServerSystem::ComputeOutput` 0x00751fb0 and the whole of `ComputeOutputFromRates`
0x00751bb0, read from the instruction stream to the next function start. This is the
function lane E1 correctly identified as the real blocker on P01/P02/P03/P05/P06 after
its own chain came out 25/25 on BnkEl and did not unblock them.

The shape, and the fact everything turns on: construction points the build queue and
the ship-repair pass do not spend are redistributed over trade / terraform /
infrastructure, and the TRADE share is added to the money channel. Unspent
infrastructure points cascade into the terraform pool and unspent terraform points
cascade into money -- two hops, not one. So a colony with an empty build queue earns
the same money whichever way its sliders point, which is why the turn path's money
sits within one trade point of the projected path on this corpus.

Eleven addresses, five of them dropped as agreeing duplicates. New: the repair pass
0x00751590 (the B1 double-run side effect, and its round robin is provably a `min`),
the construction-point helper 0x00746830, the queue-demand walker 0x008251e0, the
ideal-suitability resolver 0x00745d60, and two helpers with non-standard conventions
that would be silently wrong read as thiscall -- 0x0074c6f0 takes `this` in ESI and
0x007460b0 takes the system in EBX.

Corrections in place: `income-term.md`'s "leftover science points" are leftover SHIP
CONSTRUCTION points (there is no science channel in this function), its
science-cascade bullet moves from inferred to instruction-verified, and
`output-term.md`'s 0x00751fa8 boundary is padding -- the `ret 8` is at 0x00751fa0.

Measured, with the engine change (sots-engine wip/output2 d3ee453):

    turn1-state -> turn2-state    209 -> 157   closed 52  regressed 0   (was 51 / 0)
    turn2-state -> turn3-state    108 ->  86   closed 22  regressed 0   (was 21 / 0)

Closed 1 per pair, regressed 0. Every prediction in the doc's section 4 held; the one
that was off is named and explained. The strongest check is not the leaf count but
the eleven-save self-check in section 5.3, where every difference between the turn
path and the projected path decomposes to the unit into the build queue's points.
2026-09-08 14:50:50 -04:00
alex
232397aa12 lane AI2: the AI's task selection loop, the priority table, and the order-method -> TurnCommands map
The strategic AI does not search or score. Once a turn (Process Turn phase 20, 0x006cf630) it
rebuilds a candidate task list -- which families it builds at all is a switch on the player's
SPECIES, four arms, and the NPC arm builds nothing -- sorts it by a per-task-type priority, and
walks it twice calling each task's Execute(agent, pass) with pass 0 then 1.

Closed from lane AI1's open list:
  * the selection loop (its item 2) and the whole ordering policy: a 33-entry priority table at
    0x00691f00 plus five named overrides;
  * IAITask's unnamed pure virtuals (item 1) -- seven, not eight: GetTypeId, GetTargetA/B,
    Execute, IsFinished, GetTypeName, Describe;
  * the order-method -> list mapping (item 5): all 27 of lane Q's lists and all six prologue
    gates now have a named producer, and list 14 -- lane Q's "observed but not understood" --
    is the AI's fleet order, two elements per fleet;
  * g_CurrentClientIndex (item 6): a stack pointer with exactly two writers, pushed around the
    whole AI turn by StrategyAIAgent::OnEvent;
  * the think-time throttle (item 7): AIProcessMinTime is a trailing Sleep, not a compute
    budget. Every pending AI player's turn runs back-to-back inside one Update. The clean
    "all AI orders in before the human's End Turn" ordering HOLDS, and Rung B is not at risk;
  * Broadcast -> OnAIPacket, read to the call -- AI1's one inferred hop is now verified.

Corrections: 31 concrete task classes, not 34; 26 order methods, not 21; seven pure virtuals,
not eight. lane-ai1.json's Broadcast and g_CurrentClientIndex entries upgraded in place.

Open and said so: slots 11/12/13 unnamed, the nine goal tasks' bodies unread, the two-pass
meaning inferred, the StrategyApp pending-AI enqueue site not found, nothing run under an
instrument. Four predictions in section 9, P1 being a ModCount prediction.

ghidra/addresses.d/lane-ai2.json: 25 entries, 1031 -> 1056, no duplicates.
2026-09-08 14:43:41 -04:00
alex
8ad9971f6e lane H: five live probes, the oracle restored, and a harness perturbation bisected to one hook
- determinism oracle regenerated and byte-identical (bb4fd9ac / 978041ac)
- the four phase-23/33 draw-bearing tail callees run EVERY turn; the three inner
  functions holding the draws run zero times -- the gate is inside each outer body
- CreateRaidEncounter is called (2 on one turn) and draws nothing: candidate list empty
- Zuul: 7 calls / 7 words per trade-raid Chance site, 14 not 16, as predicted
- EncounterDetect_Run receives an EMPTY record vector, so ProcessTeamRecord and
  AssignContacts never run; the 2-word detection residual is in 0x007d5150's subtree
- a MinHook detour on 0x00893290 changes the game's output; bisected over six runs.
  The un-instrumented game and lane Z's instrument agree, so lane Z's numbers stand
- lane AI1 insert: P2 held across two fresh processes, Rung B stays as written
2026-09-08 13:53:36 -04:00
alex
bebdee1ceb income-term: the output -> money chain read from the instruction stream; the BnkEl oracle goes 6/25 -> 25/25
`output-term.md` §6 said the verified output total did not unblock the budget
because a system's MONEY is a second chain. This is that chain, disassembled to
the next function start throughout.

The multiplier `formula-gaps.md` Q3 could not name is a three-row table the
executable BUILDS IN CODE from .rdata float literals -- no data-file key, no
GlobalConst slot, the same shape lane N found for the pop-type table.
ServerPlayer+0x36c is an unnamed, unsaved pointer to {int id; float ai[3];
float other[3]}, filled from that table by ServerPlayer::Read and selected per
player by `is-AI && !NPC`. Every corpus save carries aidf == 1, whose AI income
column is 1.1f. The record's other two columns are a fleet-maintenance DIVISOR
and a RESEARCH multiplier -- Q3 called the third a trade multiplier and it is not.

25/25 on the oracle, from 6/25. The prediction of WHERE the remaining misses were
was wrong and is written down as wrong: the twelve Zuul records were not missing
the suitability cost (every corpus colony sits at its species' ideal, so that
whole term is multiplied by zero and stays unexercised). They were missing
SpeciesDef +0x4c/+0x50, which are PER SPECIES and were carried as one global
pair -- 400 output points, 2000 money, per Zuul colony, and the observed 4400 and
5566 shortfalls fall out to the unit.

New wire fact: the Sim block's ISsp/ISsu pairs ARE server->IdealSuit[], the
float[7] CalcSuitMod indexes. The array is randomised per game by the map
generator and cross-checks against every ServerPlayer's own IdealSuit field in
all 11 saves, so the suitability cost needs no data file.

Also states plainly what this does NOT unblock: ComputeBudget's turn path takes
its per-system money from ComputeOutput with the system's OWN rate sliders, not
from ComputeMaxIncome, so P01/P02/P03/P05/P06 and the two research RNG words stay
blocked on a strictly larger function.
2026-09-08 13:49:48 -04:00
alex
d617285b0c board: lane AI1 - the AI is a StrategyClient running after the autosave; orders go through the TurnCommands we already own; P2 routed to the VM 2026-09-08 13:39:38 -04:00
alex
d639c9a112 lane E3: the prediction's outcome, leaf for leaf
204 -> 158 and 103 -> 87, closed 46 and 16, regressed 0 -- the predicted table
line for line, including the 32-leaf target in full. Three further pairs the
model was never fitted to close 42, 12 and 12 with zero regressions; 128 leaves
across five pairs.

The AFlags-vs-VFlags falsification is decided on a real save rather than in a
comment: running the engine on zuul-turn23-fleet23 reproduces the frozen stamp
at the one system whose active mask is clear while its sticky and explored masks
are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:39:30 -04:00
alex
55ea86f0d5 lane E3: the per-system visibility record read from the instruction stream, and the prediction before the build
NVE is a per-(system, player) map of {ETS = turn of sighting, Eid = encounter type
present}. Primary writer 0x00756300, called from tail phase 17 under the gate
(AFlags >> PlyrIdx) & 1 -- byte-decoded at 0x007cf7a7..0x007cf7ce, not from the
decompiler. Three further accessors named, including the intel-sharing copy that
keeps the older sighting stamp. operator[] has exactly three callers and none of
them erases, so the record is a memory, not a state.

Also names ltis's writer (driver phase 29, AFlags != 0 -> ltis = Frame), closing
half of board.md's 'TShn/ltis: NOTHING NAMES THEIR WRITER'. TShn's own gate is
still unnamed and demonstrably NOT AFlags -- Spica is the counterexample.

Resolves lane B5's flagged indirect edge: SetExploredBy's vft[0x1c] is
ServerSystem vtable slot 7 at 0x007480b0, and it writes only an unserialised
runtime mask.

Prediction committed before the engine change exists: 204 -> 158 (46 closed, 32
of them the brief's target) and 103 -> 87 (16 closed), 0 regressed, with a
falsification section naming the corpus save that separates AFlags from VFlags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:26:46 -04:00
alex
1754cc20ac board: lane P2 nav classifier; type 2 is the Liir drive, a naming error not a gap 2026-09-08 12:45:27 -04:00
alex
e30619630e lane P2: the path solver read from the instruction stream, and OrderFleetMove's three failure bits
FUN_007066c0 is not a path finder. It performs no graph search: it walks the caller's
already-chosen destination list and classifies each consecutive pair through
FUN_00703730, accumulating flags and the index of the first failing leg. The only graph
structure in the subtree is a single-hop adjacency query. A multi-hop node route in a save
is n waypoints, one per hop.

OrderFleetMove's three failure bits, from the 0x418 literal: 0x008 the destination fleet
is on a node route and no intercept could be solved, 0x010 a ship's drive is destroyed,
0x400 the destination point is not one the player may use. The other nine bits are
advisory or route-quality complaints the server commits anyway -- the UI's dry run tests
the whole word, which is what separates the two groups.

Waypoint type 2 is the Liir drive, not a node line. The species-to-drive jump table maps
Human and Zuul (the two node races) to 3 and Liir to 2, so lane O's 20+ all-type-3
observations were forced by the table. Nothing is unreachable and nothing needs fixing;
exercising type 2 needs a Liir fleet, not a node line.

Also: GFlags(+0xdc) is the per-player gate mask; CstR is the gate-projection radius and
type 5 is a gate throw at a gateless system, not the Zuul bore; pnd is the node transit's
origin id; FtTrans is a second saved copy of the first waypoint's type. Two original
defects recorded as shipped (a loop-invariant drive comparison, a loop-invariant node-line
ranking term) and one predicted (the leading-destination drop shifting the output arrays).

P1/P2/P5 written before the run and checked offline against all 11 saves: 58 waypoints,
46 flight plans, 0 failures. 37 addresses filed; merge generates 912, validated to a
scratch path.
2026-09-08 12:43:34 -04:00
alex
3c48d3a39e lane N: the population -> base-output term, read and live-verified
findings/subsystems/output-term.md is the whole reading: the call chain with
real function boundaries, the formula, the closed list of nine values the data
files supply (down from an unbounded fear), the advance prediction with its
falsification table, and the live result.

Corrects strategic-turn-internals.md 3.3 in place. That block had the SHAPE
wrong, not just the detail: a system's output is a sum of three terms, and the
function it named as the population base-output term is the over-harvest
resource demand.

Live on VM140, two builds, two species, both hooks in compare mode:
GroupOutput 13,105 calls / 0 divergences, ComputeTotalOutput 11,252 / 1 (one
ulp), 0 undeclared writes in 24,357 guarded calls. Thirteen distinct system
states, and every unexercised branch is listed rather than counted as covered.

tools/max_income_predict.py is the other half: it computes lane Y's
bankruptcy-limit oracle from colony state and reports 6 of 25 player-records
matching exactly, with the misses all AI-owned and the single-system ones short
by exactly the 1.1 difficulty income multiplier.
2026-09-08 12:11:52 -04:00
alex
bb1f8b4692 lane K: StrategyServer::OnAllCombatDone_Tail mapped, 36 phases from the instruction stream
The second turn driver, 0x007d92a0, read byte for byte. Corrects turn-driver.md
section 5: of the four subsystems it said live here, only bankruptcy does.

- 36-phase map with strides enumerated (EncounterResults 0x178, Encounter 0x74,
  member 0x44) and the arity check that logs but does not return.
- Phase 7 is encounters.clear(), not a filter: the erase pair is the same
  four-argument shape vector<Encounter>::operator= uses, and both arms converge
  three instructions later.
- Bankruptcy: ProcessBankruptcy at phase 15, UpdateBankruptcyLimits at phase 31.
  Three corrections to formula-gaps Q1 - the divisor is the double
  -0.15000000596046448 not -0.15, the per-system income term is clamped at 0
  before summing, and the 3.3 factor lives in .bss and is DB-loaded.
- Turn results are FILLED here (phases 6, 11, 18 write S+0x2f4[PlyrIdx]) but
  rotated by ApplyEncounterResults and dispatched by SynchronizePlayer as event
  0x25 afterwards. sizeof(SETurnResults) = 0x11c, enumerated five ways.
- BuildTurnEvents is misnamed: it is the setup/load/rejoin resync push, gated on
  a pending descriptor, and references no EVENT_ string at all.
- TurnEvents_Write and TurnEvents_Read are swapped in Ghidra (layouts.json is
  right). sizeof(TurnEvents) = 0x18, enumerated four ways.
- The autosave: StrategyHost::Autosave 0x00895210, its four localized paths, the
  rotation that fires only on the post-turn call, the connection detach around
  the write, and why the payload carries nothing time-, name- or machine-derived.
- Two RNG sources in the tail that nothing models: one NextFloat per expired node
  line, plus draws inside the combat resolver. Both run before the autosave.
- S+0x8 advances twice per turn, not once.

Repo-wide correction: the research-event roll costs one or two RNG words, not
one. Fixed in unlock-cascade.md, addresses.json and lane-u.json; the captured
compare artefacts under verify/results are left alone as run records.
2026-09-08 08:47:08 -04:00
Alex
5a3f986f38 lane U: the unlock cascade, implemented and live-verified
Reads: TechTree::PrereqsMet 0x0057d8e0 (AND of ORs; zero groups TRUE, an empty
group FALSE), the prerequisite layout at TechDef+0x88/+0x98, the tail collector
0x00587cc3, and the head of OnTechResearched (RecordObservedTech unconditional;
the research-event roll gated on ResT == def && ResearchRollPending).

Live: 35 compared calls over three workloads, 0 divergences, tracecmp exit 0,
End-Turn oracle hashes unchanged. The EVENT_TECHS_UNLOCKED residual lane P
predicted and lane V measured is closed by running the cascade, not by posting
on completion.

12 new addresses in ghidra/addresses.d/lane-u.json (header 615 -> 627).
2026-09-08 06:57:37 -04:00
alex
7eabfefff2 lane V: live verification of the event-posting model against an advance prediction
Lane P wrote the expected numbers into sots-engine docs/P-events-wiring.md §4 before
this run existed. Every one held.

First End Turn (ref-turn2 -> Launch -> End Turn, shim.cfg.recapb3 unchanged, build
eventlive-dd38117-20260908T0916Z, main dd38117 with no source change): 3 calls, 3
compared, 0 divergent, tracecmp exit 0 — where lane R's run exited 1 on
side.events.after.v.next_id orig=4 ours=3. Call 0 read turn=3,
events_turn_bucket_exists=true, events_next_id_in=3, events_in_turn_bucket=1,
events_dedup_risk=0, no events_scan_truncated, next_id 3->4 on both sides,
turns/turns_bytes 2/48 unchanged, node[144].progress 2879->5768, flag 1->2, rng
identical — the whole predicted list.

Five End Turns: exit 1 with 2 divergent calls instead of 3, each short by exactly 1
(next_id orig 7 ours 6, orig 12 ours 11) = the deliberately unmodelled
EVENT_TECHS_UNLOCKED. A 0-divergence result there would have been suspicious.

sizeof(Game::ObservedTech) = 44 measured live: observed_techs.bytes grew by exactly
44 on both completion calls (440->484, 484->528), confirming lane X's static pin
behaviourally. Non-researching players measured 880 = 20 x 44 and never moved.

One deviation from lane R, and it is a workload effect, not a defect: rng diverged on
call 9 because the completed tech had research_roll_pending set and the
OnTechResearched callback drew one word ours does not (left 374 vs 375). Lane R's
"RNG matched 15 of 15" was workload luck; the honest statement is that the rng region
matches on every call that does not complete a roll-triggering tech.

Zuul double roll CLOSED. No species-5 save existed, so this lane made one: a custom
game with only Zuul in the Available Species pool, 4 End Turns under the same compare
config. 8 calls, 8 compared, 0 divergent, exit 0; all four researching calls have
species=5 and advance the generator by two, not one (left 540->538, 522->520,
504->502, 485->483), and ours reproduced every post-state bit-for-bit. Save added as
verify/results/saves/zuul-turn5-species5.sav.

End-Turn oracle byte-identical to lane R's on the first turn, so none of this
perturbs the game. clean_room_check OK and host ctest 33/33, run as separate commands.
VM140 restored to the recap build at the main menu and released.
2026-09-08 05:59:22 -04:00
alex
f965c8c769 lane S: std::string is 0x1c binary-wide; ObservedTech element fully mapped
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.

ObservedTech+0x24 is that string's trailing empty-allocator word, not the
unaccounted data field it was read as. Three complete enumerations of the element
each skip it: ObservedTech::Write 0x00817cf0, the ctor 0x008562a0, and the copy
ctor inlined at 0x0079a184. Generalised with a new scanner, tools/strfootprint.py,
which recovers every (base, disp, tag) handed to the Mars::Stream string helpers:
65 std::string members off a non-stack base across every serializer in the exe,
ZERO with a sibling member inside the 0x1c span, and 51 of the 52 measurable
inter-member gaps exactly 0x1c. Corroborated by the vector<string> walk stride
(add esi,0x1c @0x00699c29), PostEvent's by-value strings at [ebp+8]/[ebp+0x24]
with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c.

Blast radius: zero recovered struct tables were wrong. Every string-bearing layout
already used 0x1c spans and 0x1c gaps -- ServerPlayer::pswd @0x2dc..0x2f7, the row
flagged for re-checking, included. Only prose carried the 0x18 number: the
loader-prototypes conventions line, the GlobalConst_ParseString prototype, and the
ObservedTech element table. struct-recovery S0 additionally had _Mysize/_Myres
transposed (size@0x14, res@0x18) while every table in the same file used the
correct offsets; fixed.

ObservedTech's four on-disk fields are now mapped rather than guessed, by reading
the serializer as lane X suggested: +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08
bool odet (ONE BYTE, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith
= 0x2c exactly. That matches save_reader.py's on-disk order already. Game::
ObservedWeapon (0x00817bc0/0x00817b10) is the same element with tag owep.

Oracles unaffected and re-run: save_reader 36/36 and --strict exit 0 on all three
real saves; state_checksum 38 tests OK, coverage PROVED byte-for-byte on turn1 and
turn3. sots-engine wip/strings 32d3e36 syncs the header and corrects two stale
"unpinned" comments: clean_room_check OK, host ctest 33/33.

Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.
2026-09-08 05:09:33 -04:00
alex
849c5069fc lane F: x87 precision sensitivity measured; STATE_CHECKSUM 3.5 closed
Seven End Turns from ref-turn2.sav on VM140, six control words, whole-state
checksum on every post-turn autosave.

53-bit and 64-bit x87 give byte-identical state across all 35,394 leaves, so
an x64/SSE port computing in IEEE double has NO double-rounding budget to
preserve and floats=bits is free. Two settings do move state, each reproduced
on a repeat run:

  0x007f (24-bit)   Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC 540000000 -> 540000002
  0x1a7f (round-up) Flt[34 "Beta Fleet"]/Pos/.[0] and /Pos/.[2], 1 ULP each

So the port must hold intermediates at 53 bits and use round-to-nearest --
both SSE defaults, now measured rather than assumed, each with a named
regression witness.

The briefed triple was under-powered: 0x027f is 53-bit (it differs from 0x127f
only in bit 12, infinity control, ignored since the 387) and 0x137f is 64-bit,
not a rounding change. Run as written all three come back identical, and that
would have "proved" something false on both axes that matter.

Evidence the forced word actually held: read-back at each force site plus 38
independent in-pipeline hook samples per run spanning turn phases 4, 6 and 8,
all reading the forced value. Mars::Application::Run calls
_controlfp(0x50000,0x3070300) at 0x0089f606 every frame, which is 0x127f, so
forcing at StrategyClient::EndTurn is wiped before the turn runs;
StrategyServer::BeginProcessTurn is the point that works.

Also re-confirms the End-Turn determinism oracle on engine cef889e:
bb4fd9ac... / 978041ac... unchanged.

New tools: verify/fpu-cw/cw_census.py, verify/fpu-cw/trace_bitdiff.py (the
latter exists because under a forced 24-bit word the CRT's own %g rendering
degrades, so trace text is not a valid comparison surface).
2026-09-08 05:07:53 -04:00
alex
460cb7ca2b lane X: x86 displacement xref scanner; pin sizeof(ObservedTech) and its append site
Ghidra does not index ModRM displacements, so `lea reg,[reg+disp]` -- the MSVC
idiom for taking a member's address -- is invisible to find-constant-uses. That
blind spot parked ServerPlayer+0x274 and covers every non-trivial member of the
~1,600 classes still to map.

tools/x86disp.py: full x86-32 length decoder (prefixes, 1/2/3-byte opcodes,
ModRM, SIB, sign-extended disp8, disp32, every immediate form) swept from
Ghidra's 41,089 function starts so decodes begin on real instruction boundaries.
2,174,504 instructions, 612,166 displacement sites, 100.0% code coverage, 70
desyncs (0.17%), zero unknown opcodes. Excludes no-base disp32 forms
(mod=0/rm=5, sib.base=5) which are absolute globals, not member offsets.
Commands: build/query/cohort/func/dis/stats/brute. Works off a gitignored local
cache in dumps/ rather than hammering CT111.

Validated before use: re-finds lea eax,[ecx+0x29c] in ServerPlayer::GetEventStorage
(0x0080db00) and both known OnTechResearched +0x29c sites, plus a new one in
ProcessTurn. Positive control: the ServerPlayer serializer scores 50/50 known
offsets.

sizeof(Game::ObservedTech) = 0x2c (44), proven three ways: the exact magic
divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c at 0x0087243a and
0x007b735b, and the search stride add edi,0x2c at 0x007ba257.

Append site: RecordObservedTech+0xdf (0x007ba27f) --
  lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320
RecordObservedTech (0x007ba1a0) is a direct callee of OnTechResearched and
de-duplicates by tech name before appending. The realloc through 0x007b5820 is
why lane R's guard saw all three vector words move. Element carries a vptr
(RTTI .?AVObservedTech@Game@@) at +0 and a 0x18-byte std::string at +0x0c; the
four on-disk ints map onto +0x04/+0x06/+0x08/+0x24/+0x28 in an order this read
does NOT determine, and is not guessed.

Also corrects harness-audit row 11: ComputeBudget has no store to Budget+0x64
(its only +0x64 accesses are loads off a different base), and ProcessResearch's
int* overbudget arg is a ProcessTurn stack local, not Budget+0x64. Agrees with
lane R's guard seeing 0 changes in 4284 calls.

Honest limits are recorded in the note and the board: this is a recall tool, not
an oracle. Class-level precision at 0x274 is ~13% by function, i.e. a ~900x
search-space cut that still needs one call-graph check. Cohort ranking must not
be used as a hard filter -- it would have discarded the correct answer here.

Ghidra writeback: labels + plate comments on RecordObservedTech,
vector_ObservedTech_push_back, ObservedTech_ctor, vector_ObservedTech_assign,
vector_44B_grow, vftable_ObservedTech.
2026-09-08 04:44:18 -04:00
lane P
0cd84690af lane P: SetResearched cascade read; 10 new addresses.json entries
Read TechTree::SetResearched (0x00581e10) end to end while wiring the research event
posts into ours (sots-engine wip/post-events). Two things it settles:

- SetResearched invokes the owner callback as vft+0x10(def, (flags>>2)&1), so bit 2 of
  flags IS OnTechResearched's "silent" argument. ProcessResearch passes flags = 2, so
  silent is FALSE and the completion event IS posted. Previously this was only
  inferable from lane R's observation that a completion call moved EvNxID by two.
- The unlock cascade in full: the node's child edges set child state 0->1 and
  child.costRP = min(child.costRP, edge.costRP); then a whole-tree sweep sets state 2
  and stamps turnAvailable from ModCount ONLY when it currently reads -1 (so it is
  sticky, and a node re-entering state 2 is not re-announced), and recurses into any
  zero-cost available node. Both loops test the node itself, not a parent -- there is
  no parent clause anywhere in the function, confirming lane E's correction.

Also records what is and is not known about vector<ObservedTech> at ServerPlayer+0x274:
the on-disk element shape is confirmed against turn3-state.sav (its string holds a tech
name), while sizeof(ObservedTech) and the append call site are still unpinned. The
cheapest route to the stride is now a measurement from the new observed_techs region,
not a search -- find-constant-uses does not index lea displacements.

New entries: ServerPlayer_off_ObservedTechs, TechNode_off_TurnAvailable /
TurnResearched / Order / Children, TechEdge_off_CostRP / ChildDef,
TechTree_off_OrderCounter, TechTree_SetResearched_flag_Force / _flag_Silent.
Generated header regenerated with tools/gen_addresses.py (386 -> 396 entries).
2026-09-08 04:16:59 -04:00
alex
f5b37c2b34 lane M: MoveFleet position rounding closed; VM140 released; types 2-5 still uncovered
The mechanism, read off the instruction stream rather than fitted: Mars_Vec3_Normalize
(0x00422520, 123 callers) narrows to float32 four separate times, and MoveFleet stores
each dest.c - pos.c back to a float32 slot before calling it and takes the leg distance
from that same call's return value. Live 8/45 -> 0/45 with a control run of the unchanged
build, identical inputs and identical original outputs on all 45 calls.

Board: the two MoveFleet rows go verified; VM140 exclusivity back to FREE with two new
click-path gotchas; a new backlog row for waypoint types 2-5, which this lane attempted
and could not reach - ref-turn2 structurally cannot produce a node-line move because the
only player that would travel one has no ships.

(Most of this lane's files were swept into 9d385a7 by another lane's `git add -A` on the
shared repo; this commit carries what was left.)
2026-09-08 03:51:39 -04:00
alex
9d385a7683 board: lane C state-checksum landed; fpu_cw experiment queued; event-posting lane 2026-09-08 03:47:21 -04:00