Commit graph

68 commits

Author SHA1 Message Date
alex
4a212e25b4 AS: the spy detection roll fires -- 1 word, gated on a usable asteroid belt; spies2 closed
Mars::RNG::Chance at 0x00887c8a, in the BODY of ServerSpyManager::vslot13 (tail
phase 23 call 9), measured at 1 call / 1 word / no_draw_calls=0 / strategic=true
on VM144 at probes=8, with OnAllCombatDone_Tail going 363 -> 364 against 0 on
every turn any lane has measured. The entry probe on SpyManager::Slot13RngCallee
read 0 on the same turn -- that zero was never about the subtree.

Predicate, decoded to save fields: spy.deat != 0 && spy.sdet == -1 &&
sys(deat).ARes2 > 0 && (sys(deat).TerrFl & 1). Corpus count 0 of 22; the failed
conjunct is deat != 0, not the belt -- all 22 corpus saves already had one.

Also: ServerSystem::AddSpy 0x007514c0 writes deat and pushes the spy id into
ServerSystem+0x1cc in the same two instructions, so spies2 is the per-system
DEPLOYED-spy vector; measured non-empty for the first time in the corpus.
And 0x0078c97f, a strategic NextFloat in the SHIPACTION_DEPLOYSPY handler, is in
no existing inventory -- decoded, not measured.
2026-09-09 00:10:49 -04:00
alex
6d33a748c5 AD: the trade-raid roll is per fleet - 2 raiders = 2 entries, 2 words; B returns without drawing; no oracle pair (AI-only variation) 2026-09-08 23:00:23 -04:00
alex
2e7ed8f288 lane AC: the tail DOES draw under active contents -- the trade-raid gate, decoded and fired
TradeManager::Slot13RngCalleeA, entered 0 times in every measurement any lane
had taken, is entered 1x/turn once a player fleet is parked on a trade-sector
node, and OnAllCombatDone_Tail's RNG word delta goes 0 -> 1. Lane W3's open
risk is realised, not retired: the RNG ledger and the standalone's generator
model need extending by one draw site.

The gate is neither freighters nor a deployed spy. It is a fleet whose LocID
resolves to a node of kind 2 (TradeSector), positioned bit-equal to the
sector, whose owner's bit is set in the sector's tscr mask -- which the
CCC_ComRaid tech sets, taking tscr from 252 to 253 and setting ServerPlayer
CnRad. Before the tech the game refuses in as many words: 'Alpha Fleet cannot
raid trade sector.'

Control: same guest, same build, same shim.cfg.l3probe, one End Turn on lane
L3's turn-15 save -> all three inner callees 0, tail delta 0. Guest adoption:
ref-turn2 hooks=off reproduces the published determinism oracle exactly.

Corrects lane L3 §3.5 (the turn-15 save already had a freighter in service --
the AI's -- so 'freighters' was falsified before this lane started) and lane
V2's 'trade slot 13 is draw-free'. The spy half is decoded but NOT reached:
no AI system in that game has an asteroid belt, so a deploy was impossible,
and spies2 is therefore still untested and stays a rule-6 hypothesis.

Two new corpus saves; expect the coverage ratchet to break on CnRad,
tscr=253, three rt records and a fleet whose LocID is a TradeSector.
2026-09-08 21:32:27 -04:00
alex
254ce7c156 SD: the ship-design composer 0x006ad700 -- a word-cost model, verified where it could be
Lane PAR localised the AI's RNG variance to this function and stopped. This
reads it from the instruction stream (0x006ad700..0x006ae61a, swept to the next
function start; the padding confirms Ghidra's size is right on this one) and
measures it live with a sub-bracket on VM145.

THE MODEL. Nine live draw sites plus two provably dead. Only three of the nine
can cost more than one word, and only through NextInt's rejection loop; the four
cl_Chance probabilities are all strictly inside (0,1), so neither zero-word
early-out is reachable anywhere in the function. The loop-carried draw fires
once per SMALL STANDARD WEAPON BANK selected by

  f  = 1.00 / 0.75 / 0.50 by request flags, hull size and one 0.3 coin
  M  = (int)(N * f);  D' = max(1, (N+1)/M)
  L  = #{ bank j : PointDefence section, or j mod D' == 0 }

which is not monotone in N -- at f=0.75, N=4 costs 4 words and N=5 costs 3.

VERIFIED. Client 32's seven turn-1 words decompose as TWO composer calls: a
costOnly=1 price query (3 words) and a costOnly=0 build (4). That was predicted
from two push literals at 0x006cda9a/0x006cdb17 and committed before the probe
existed. Two pinned runs in fresh processes agree row for row. The probe's bank
counts 3/1/2 match the Tarkas section catalog's bank counts exactly -- a hooked
pointer walk and a parsed data file agreeing from opposite directions. The
detour is behaviour-neutral: unpinned it reproduces the published
d59bb9f2fd0eb535.

NOT VERIFIED, and this is the part worth reading. The loop-carried draw has
NEVER FIRED -- six composer calls across four runs, zero. On turn 1 the weapon
lookups gate it; on turn 15 every composer call is a price query that returns
nine steps earlier. Five of the nine sites have never fired and two of nine
exits have ever been taken. The formulas for those are read, not measured, and
section 6 says so.

CORRECTS roll-parity.md: its site table omits a live draw site (0x00691e9b,
which the same lane measured live at 0x00691ea0), so the AI turn has 22 live
sites and not 21; and 'six of seven turn-1 words come from the composer' is
five from its body plus two from a helper it calls -- all seven are in its
subtree.

NEW: on turn 15 the composer is 10 of 16 words, not 16 of 16. The task
system's coin at 0x0069086a fires four times and never fired on turn 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 20:50:03 -04:00
alex
7d51767e87 lane L3: both containers filled, and filling them does not make the tail draw
Three lanes could not build this workload. VM144 built both halves from a cold main
menu on a stock, unmodified install -- no data file touched, no save edited -- and the
answer to the question they were built to ask is no.

TRADE. The premise everyone carried, that trade needs station construction plus its
tech, is wrong about the station: StrategyVars.txt makes a trade station a +2-routes,
+25%-income bonus, and NumTradeRoutesSupported has a floor of 1 per owned system, so
the population thresholds scale the count and do not gate it. The gate is one tech,
CCC_FTLEcon, and Zuul cannot research it at all. 20 routes and 6 trade sectors by
turn 4; 21 by turn 13.

SPY. Lane W3: "no lane has identified which UI produces a spy-program entry." It is
the fleet panel's Special menu, and the reason nobody found it is that the ship a spy
docks with is the Cruiser REPAIR AND SALVAGE mission section -- nothing in the UI
calls it a tender or mentions spies. Prediction S1 holds: the container grows at Build
Spy while the craft is still docked (tdep=-1, atto=<tender>), so no enemy colony and no
asteroid belt are on the critical path, which is what every earlier cost model assumed.

THE MEASUREMENT. With 21 routes, 6 sectors and 1 spy craft live, lane H's eleven entry
probes report the four outer tail callees entered exactly once per turn, as always, and
SpyManager::Slot13RngCallee, TradeManager::Slot13RngCalleeA and CalleeB entered ZERO
times -- this turn and across all seven instrumented turns, every probe installed=true.
The RNG ledger and the standalone's generator model do NOT need extending on account of
either container being non-empty. The next condition is active contents, not present
contents: tsnumflt=0 on every sector and spyon=0 on the spy.

WHAT ELSE FELL OUT. The trade-route vector is torn down and rebuilt every turn inside
ServerTradeManagerImpl::vslot9 (lane V2's phase-23 call 5 of 8, previously "draw-free"
and unexplained) -- an agreement, dropped rather than re-minted, as is the AI2 growth
helper. Two addresses are minted, both trapped live and both `mapped` not `verified`
because ReVa was unreachable: ShipAction_BuildSpy 0x00789620, a sibling of lane B6's
stack-built-fptr-table entry 0x120 bytes away, which is why no call-graph sweep ever
found this writer; and ServerSpyManager_CreateSpyCraft 0x008383c0.

And a correction with teeth: `spies2` is 0 in all 28 systems of a save that DOES have a
spy. The natural reading of lane W's count-0-in-11-saves was "no save has ever had one".
That reading is dead; spies2 is not the spy list, or it only fills for a deployed spy.

The research click path is written down for the first time (§4): clicking a tech node is
a PREVIEW, the bottom-left button confirms, Esc leaves without confirming, and the tree
pans only on a right-drag of about 600 px. That, not the tech cost, is what stopped
three lanes.

Saves: human-turn{5,8}-traderoutes, human-turn11-spytechs, human-turn15-spyprogram --
all --strict clean, 0 errors, 0 warnings.
2026-09-08 19:46:16 -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
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
7a41246450 RB: bind a .tcb capture to the save it was taken on
Replaying a turn's commands against a different board charges the counter happily and produces
a confidently wrong number, because the blocks name player ids that exist in both. The converter
now records the input save and sots_turn warns when they disagree.
2026-09-08 19:05:33 -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
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
alex
d1710e9676 standalone on the canonical pair turn2->turn3: 108 -> 63, closed 45, regressed 0 2026-09-08 18:06:30 -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
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
2d6184888f standalone 209 -> 124 (closed 85); rule 24: never reuse a build directory across trees 2026-09-08 17:01:57 -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
c810c552e5 standalone report from merged main: default 209 -> 131 closed 78; note the with-inputs 127 distinction 2026-09-08 15:49:04 -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
3fe74bc6a5 standalone report from merged main: 209 -> 131 leaves, closed 78 regressed 0 2026-09-08 15:10:17 -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
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
08da5a1a06 standalone report regenerated from main: 209 -> 158 leaves, closed 51 regressed 0, 16/44 phases 2026-09-08 13:46:17 -04:00
alex
6e10def1b5 dashboard refresh after visibility 2026-09-08 13:43:31 -04:00
alex
58c2cd14b4 lane E3: ten addresses for the visibility record, and the standalone's new distance
The NVE writer 0x00756300 and its four siblings, `ltis`'s writer 0x00743ec0, the
encounter-placement routine whose unserialised field feeds Eid, the tail phase that
calls the writer, and the resolution of an indirect edge lane B5 left open --
ServerSystem primary vftable 0x00a2044c slot 7 is 0x007480b0, and it writes only a
runtime mask, so it never reaches the wire.

None of the ten collides with an address already in the DB (checked against all 714
addressed entries across addresses.json and every fragment); gen_addresses.py
validated to a scratch path at 1019 entries, no duplicate names.

Standalone report regenerated from the lane worktree:
  turn1 -> turn2   209 -> 158   closed 51, regressed 0
  turn2 -> turn3   108 ->  87   closed 21, regressed 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:38:57 -04:00
alex
fbe3c7bd86 lane E2: T36 measured with the ship census wired in -- 29 closed / 7 regressed, and the two things it is still blocked on
standalone_report.py regenerated from sots-engine wip/turnrecord. The reference pair does
not move by design: T36 does not commit by default, so 209 -> 204, closed 5 / regressed 0
and 108 -> 103, closed 5 / regressed 0 are unchanged. What moved in section 6 is the phase
tally, 14/44 modelled and 8 committed -> 15/44 and 9, from the A2 and D2 merges. verified
stays 0 on both tables.

t36-turnrecord.md carries the measurement the default run cannot show: with
--commit-blocked=T36 and a data root, the reference pair closes 29 leaves and regresses 7
(was 9). Two of the three census leaves close; the third is short by exactly one destroyer
on both pairs, because no phase we run builds a ship. The census model itself reproduces
480/480 archived census leaves over 11 saves with 0 mismatches.

T36 stays blocked: sav and inc on lane E1's budget, and one ship count on unmodelled
construction. A field-granular commit knob is not implementable -- the archived record is
one struct on the wire and has no hole to leave.

Coverage: 32 of 480 census leaves are nonzero anywhere in the corpus; cls1 and cls2's
platform count are unexercised, not verified.
2026-09-08 13:25:31 -04:00
alex
a4aba6a9fb lane D2: the ship-design catalogue -- how designs persist, hull size, and the 0x400 flag
Blocker #4 on lane Y's path to a byte-matching turn.

HOW DESIGNS PERSIST, correcting a published finding. Game::ShipDesign::Write is
0x008325e0 and makes four stream calls. The recorded address 0x008747a0 is in
NO vftable at all (lane V2's inversion), so "ShipDesign::Write makes no stream
call" was a misattribution, not a fact about the class. Game::ShipDesign
derives from Game::ShipDesignDef and inherits IStreamable second, so its writer
is reached through an adjustor thunk -- which is what the slot-indexed
serializer sweep found instead. A design persists as two serializers, base and
derived: ShipDesignDef::Write 0x00827390 emits FAIDes/DHide/DWep/DName then
exactly three DSec frames (+0x4c command, +0x24 mission, +0x74 engine),
ShipDesign::Write appends Dtc, the Dwgv flag and a conditional Dwg frame.

THREE sections, not five. The campaign's "slots 3-4 reserved and always empty"
was save_reader.py's trailing Rest("sections") sweeping Dtc and Dwgv into the
section list, and stock_designs.py decoding them as two empty sections -- rule 8
in its exact form, reader and consumer agreeing with each other and both wrong.
Two independent enumerations say three: the writer's straight-line body, and the
ctor's eh_vector_constructor_iterator(this+0x24, 0x28, 3) closing at
0x9c = sizeof(ShipDesignDef).

DWep and Dwgv are BOOLs, not ints -- byte-indistinguishable from ints at a
four-character tag, the same class of defect as ObservedTech.odet.

THE 0x400 FLAG IS `defence_platform`, read off the .shipsection parser's own bit
setter at 0x005749b7. NOT lane B5's 0x400: that one is a fleet flag, on the wire
as FtFlg. The full role-flag table is in the finding. HULL SIZE is section_class
through a three-name stricmp table (Destroyer/Cruiser/Dreadnought -> 0/1/2),
absent or unrecognised meaning 0 with a log line rather than an error. Both
words are recomputed from the data files by ShipDesign::UpdateDerivedStats
0x0087e7c0 and neither is on the wire. Corroborated by the default hull-health
table the same bit picks: 500/3000/15000 without it, 100/500/1000 with.

MEASURED: the census rebuilt from each save's own state matches the record the
game archived, 480 leaves / 0 mismatched over 11 saves and 503 designs, computed
independently in Python and in C++. COVERAGE REPORTED AS LOUDLY: only 32 of the
480 leaves are nonzero, and three of the six census leaves (both cruiser rows,
dreadnought platforms) are unexercised by every save in the corpus.

Closed 0 / regressed 0 against the standalone's divergence list, reported
separately: the census leaves live in src/app's turn record, which lane A2 holds
this cycle, so this lane evaluated and reported rather than writing.

Oracles fixed openly (rule 12): save_reader.py's Des shape, 49/49 with three
corrected tests and one added that pins "exactly three DSec" against real saves;
stock_designs.json regenerated, whose diff is only raw_slots 5->3 and dWep
int->bool across all 127 designs with every other field identical;
test_design_rules.py still 32/32 with the same ground truth.

19 addresses in ghidra/addresses.d/lane-d2.json, no collision; the generated
header was validated to a scratch path, never written in place.
2026-09-08 12:45:36 -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
lane Y
c471a5515f lane Y: the standalone's generator against lane Z's calibrated oracle pairs, and the tail's turn record
The standalone now models the turn's dominant generator cost -- 16 of a measured 18-22
words -- and lands 4 and 2 short of the two calibrated pairs, which is exactly the
per-call-site ledger's split for those turns. The state block is byte-identical; only
left differs. The answer to 'does it match the oracle' is no, by a stated amount, and
tools/rng_oracle_check.py is the instrument that says so.

The tail's last phase is modelled for the six turn-record fields recoverable from the
wire and checked against the record the game itself archived: 480 fields over 80
player-records, 0 mismatches. It stays blocked; --commit-blocked shows exactly which
five fields are missing and what they cost.

By-product, and probably worth more than the phase: the stored bankruptcy elimination
limit is injective in the maximum-income sum it is built from, so every save states the
per-system output term that blocks ComputeBudget. tools/max_income_oracle.py inverts it
-- 25 player-records over the corpus -- and recovers the protection factor as 3.3 from
the saves rather than from the data files. It also shows the engine's -0.15 divisor
disagrees with the game on 6 of those 25.

divergence unchanged: 209->204 and 108->103, 5 closed / 0 regressed on both pairs.
2026-09-08 11:40:19 -04:00
alex
da823d3b36 Z: a second calibrated oracle pair -- three instruments agree on 20 words for turn 5 2026-09-08 10:45:18 -04:00
alex
e1735eea7e Z: per-call-site ledger -- every word of a turn attributed, nothing left over
Seven entry points detoured, each call keyed by __builtin_return_address(0)
with the word cost from left before/after. Three consecutive turns on
ref-turn2: site sums 19/18/20 against independently measured ProcessTurn totals
of 19/18/20, residual 0 every time. The 18-20 spread is now explained rather
than reported -- it is the two gated research draws.

The dominant consumer is FUN_00893290: two Chance calls per player across all
eight player-vector entries, 16 of every turn's 18-20 words, and it is NOT one
of lane I's 22 sites. Lane I said its closure covered direct edges only and
that indirect reachability was unsettled; this is that gap, measured. The
function is unidentified and is the highest-value target left.

Two bookkeeping corrections are in the report tool, not the shim: helper-
internal rows (Chance's own NextFloat) double-count, and 8 calls per turn are
on the StrategyClient's generator, not the strategic one. The first build did
not distinguish generators and reported 44 words against a bracket of 18 --
which is what caught it. A per-site ledger that cannot say which generator a
draw came from is not a ledger.
2026-09-08 10:41:54 -04:00
alex
c504729341 lane S2: the standalone scaffold, and the measured distance to the byte-match
tools/standalone_report.py drives sots-engine's sots_turn over each
consecutive-turn save pair and diffs the result against the game's own
post-turn save with state_checksum.py, which localises to named leaves and
proves its own coverage by re-serialisation.

  turn1-state -> turn2-state   baseline 209 diverging, after 204, closed 5
  turn2-state -> turn3-state   baseline 108 diverging, after 103, closed 5
  regressed 0 on both

`regressed` is reported next to `closed` and never netted off. It earned its
place immediately: committing the phase-31 player-status restore turned two
agreeing leaves into disagreeing ones, because the phase writes 1 and the file
carries 4.

The stable-system stand-in feeding the colony pass is a labelled hypothesis and
it survived a changed workload -- the same 3 ntdev leaves closed on both pairs,
six agreements, zero disagreements.

Two things deliberately NOT implemented: the TShn/ltis counters (18 leaves, a
`+1` would close them, but "+1 across one observed turn" is a hypothesis, not a
reading), and the RNG state write-back (an advanced-but-incomplete generator is
wrong in a different way from an untouched one).

dashboard.py gains section 6, reading verify/results/standalone/status.json:
phases modelled/committed per driver, baseline vs after, closed vs regressed,
the subsystem breakdown of what still differs, and the RNG gap. Sections 6-8
renumbered to 7-9; the delta footer tracks the two new counts.
DASHBOARD_README.md documents every number.

findings/control-flow/standalone-scaffold.md has the ranked blocker list.
2026-09-08 10:35:56 -04:00
alex
66fdf0f2df Z: P9 held on every clause -- the node-line draw landed on turn 64 and cost one word
Predicted at turn 34 with the run in flight, from min_life falling by exactly
1 per turn: the first phase-11 draw on turn 64, exactly 1 word, tail total 1,
bracket = ProcessTurn + 1. The game was played to turn 64 and every clause
held. predict_words, computed before the original ran, said 1 and the
measurement said 1 -- a real check of the model, against 63 preceding turns
where 0 matched 0 and checked nothing.

So the defect lane K warned about is no longer latent: on that turn a
reimplementation modelling ProcessTurn perfectly would have written an autosave
one word out of step.

And the instrument's thinnest part ran live on the same turn -- ProcessTurn
crossed a block boundary (left 11 -> 615, one twist, 20 words) and the bracket
still reconciled to residual 0.
2026-09-08 10:24:05 -04:00
alex
1d50f1edda lane Z: the RNG ledger for one strategic turn, measured end to end
We consume 18-22 generator words per turn and model none of them as a count.
All of it is inside StrategyServer::ProcessTurn; OnAllCombatDone_Tail costs 0
on every turn observed; the residual outside the two drivers is exactly 0. The
generator does not move between turns at all, so the interval a standalone has
to reproduce is closed at both ends.

The instrument reads generator STATE, not calls, and that choice paid: the
image has four draw entry points, not three (NextUInt 0x004f7670 is in no
lane's primitive set) plus inlined draws in twelve functions, two reachable
from the turn roots. A primitive-counting hook would have undercounted
silently.

Checked against the save files independently: the turn-6 autosave pair gives
18 words read from the two Sim.RNG blobs, and with twists == 0 that number
never passes through a twist implementation -- so the two instruments do not
share the hidden assumption they could have.

Corrections to combat-done-tail.md, in place:
  * the node-line 0x20000-fleet check runs AFTER the Chance(0.5f) call and
    cannot gate the draw; the expiry test is NodePath::RemainingLife 0x006e2130
    and is now a formula rather than a description
  * StrategyHost::Autosave is ret 8 and returns the std::string* in EAX
  * SNMAllCombatDone IS delivered every End Turn (8 of 8) -- lane K's inference
    was right; the stronger no-encounter reading is narrowed, not closed
  * S+0x8 advances 12-14 times per turn, not twice

Node-line decay still has not fired. The hook reports the distance instead of
the absence: 51 of 53 lines are permanent, the mortal ones are dug ~1/turn by
the Zuul, each ~40 turns from expiry. It stays a labelled hypothesis.
2026-09-08 09:45:06 -04:00
alex
f9b744e2ed lane Q: TurnCommands_v5 reconciled and typed; SAVE_FORMAT section 11 corrected
findings/objects/turncommands-block.md -- why lane W's 44-item recovery and the
save's 35/38/41/61/123 items disagreed, in mechanism terms, plus the per-list
element records read off all 22 distinct helper writers.

Three separate causes, only one of which was a branch:
 * layouts.md is OFFSET-SORTED and Game::TurnCommands is one of the 89 classes
   whose offset order is not write order, because each gate bool sits after the
   payload it gates. Align saves against streams.json, never layouts.md.
 * the writer's tail is 27 std::list members, each written by its own helper as
   WriteInt(size) then size element records. The linear recovery keeps one item
   per CALL SITE, guesses its kind from an element field and drops the count.
   44 - 17 members = 27 = the list count.
 * 8 prologue items + 27 zero counts = the 35-item empty block, which is why
   every earlier save was bit-identical here.

Corrects lane O's section 11 in place: the fleet-move element is
{fleetId, nHops, nHops x systemId} -- a counted route vector, not a fixed
{fleetId, 1, destSystemId, 0}. Only the three-item reading makes the item totals
close (a four-item element needs 26 lists on human-turn2 and 28 on zuul-turn15;
the writer has 27). Also records that neither noderoute save carries a fleet
move, so the node-route UI does not queue through that list.

22 of the 27 lists are labelled hypotheses: read off the instruction stream, but
no save exercises them and no element value has ever been observed. List 14 is
observed but not understood -- issuing one fleet move also queues
{sameFleetId, 0, true} there.

save_reader.py deliberately left alone, with the reasoning written down: it reads
CD generically for the AIAgent bodies too, state_checksum's digest tree is built
from the generic tree so typing CD would add no named leaf to any diff, dispatch
by CDT ordinal would need new machinery in the oracle, and rule 8 is pointed at
exactly this move -- a layout mirrored into both readers from one reading is not
two checks. coverage: PROVED, 0 error 0 warn on all eleven saves, unchanged.

ghidra/addresses.d/lane-q.json: 6 entries (the writer, its Read pair, and the
four observed list helpers). Validated by generating to a scratch path; the
tracked header was not regenerated.
2026-09-08 09:03:26 -04:00
alex
9de77f7e5e lane O: 7 purpose-built saves - issued turn commands, node routes, research-roll-pending
Produces the game states several board rows were blocked on, and reads each
one back out of the save before claiming it.

- CD/TurnCommands_v5 decoded: three saves with issued orders (research target,
  research boost, build orders, system rates, colonize, fleet move). Layout in
  SAVE_FORMAT.md section 11; ids cross-checked against the same file's Sys/Flt.
- Waypoint type 3 (NodeRoute) now has 16 instances in one save and the player's
  own fleet is a mover in two. Type 2 is shown NOT to be produced by a Move
  order for either node-drive race (Zuul and Human both emit type 3).
- research_roll_pending true at load in two saves; the ProcessTurn gate constant
  at 0x00a2c788 is a float 0.5, so the flag survives into ProcessResearch only
  while progress/cost <= 0.5 at the start of the turn.
- Budget tail: construction and a large maintenance are live; Nexp is empty in
  all 11 saves and the 1.8 UI has no expense slider, so slot 12 looks dead.

VM140 restored to the original 8-file SavedGames set (autosaves byte-identical)
and left at the main menu; VM140 row set FREE.
2026-09-08 08:35:13 -04:00
lane-w
2308b6edb9 lane W: SvSctOb variant factories; fix the four save_reader.py defects openly
Part 1 (notes side): findings/objects/svsctob-variants.md records the two maps
that are nowhere on the wire and were read out of the game -- EncID -> class
from a 23-entry dword jump table at 0x0052bf60 indexed by EncID-1 (0x0052bf00),
and xscn -> class from an exhaustive four-way _stricmp chain at 0x005a7050.
Twelve live EncIDs, four scenario names, and "indsys" =
Game::SVSOIndependentSystems whose Read and Write are both the shared `ret 4`
stub at 0x005f8ac0, so its empty frame is correct output rather than a
truncation. Also: SVSOSots::Read accepts NPCPlr and hastraps, which Write never
emits -- read-only backward compatibility, not a hole in the recovery. And a
correction to the recovery itself: SVSOCrowDefenders writes `dsys` INSIDE the
ndsys loop; layouts.json calls it a plain member and no save can settle it
because both counts are 0 everywhere. 13 addresses in ghidra/addresses.d/
lane-w.json; gen_addresses.py merges to 640 with no duplicate name.

Part 2: lane G found four defects in both readers and deliberately did not
patch the oracle mid-campaign. Fixed now, with tests, and byte-neutral.
  1. Game::SystemParams field 1 is a string, not an int (empty string == four
     zero bytes == int 0, so it round-tripped by luck).
  2. ObservedTech/ObservedWeapon odet is a bool, not an int (byte-safe only
     because a 4-char tag makes both items 12 bytes; 3 chars would not).
  3. SpeciesRatios nv is a count, not a field.
  4. ShipRecords srbd is a count, not a field -- and this one is behaviourally
     confirmed, not inferred: srbd takes 0, 1, 3 and 4 across the players and
     every non-zero count is followed by exactly srbd x 5 scalars.
Note that 3 and 4 were an ABSENCE in save_reader.py, not an error: ShipRecs and
civr were both A(..., "any"), so the fix had to add the shapes rather than
retype a field.

Byte-neutrality: every item's inflated offset is unchanged on all four saves
(38,933 / 39,843 / 40,300 / 35,771 offsets, sequences identical), so no item
boundary moved. state_checksum.py still reports coverage: PROVED on all four
with the same rebuilt byte counts. The /CreateParams and /Sim/players digests do
change, because they hash typed VALUES and two fixes change what a value is --
and the value-byte deltas balance exactly: odet items x 3 plus p1 items x 4.
--strict exit 0 on all four saves; tests 36 -> 48.

findings/objects/wire-schema-closeout.md carries the whole account, including
the proof that CD/TurnCommands_v5 cannot be typed without a save that has
issued orders.
2026-09-08 07:05:09 -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
9d385a7683 board: lane C state-checksum landed; fpu_cw experiment queued; event-posting lane 2026-09-08 03:47:21 -04:00
alex
fdd0b72b7a state-checksum: whole-state diagnostic checksum harness (lane C)
The complement to the per-function compare harness. Instead of "did this
function's declared outputs match", it asks "is the entire simulation state
still identical" -- so no region-declaration mistake can hide from it.

Coverage is PROVED, not declared: the digest tree is re-serialised and compared
byte-for-byte against the inflated save on every run. When that reconstruction
reproduces the stream, the whole file is a function of the digest's inputs. A
run that cannot account for the file says so and exits non-zero. This is the
direct answer to B4's three hooks that printed "0 diverged" over an empty
region set.

It localises. The root is the fold of a per-subsystem / per-object tree with
named objects, so the known load->re-save delta reports as exactly five leaves
-- /Summary/Checksum and four /Sim/players/Player[...]/Status 4->0 -- naming the
two Singularity players by id where the raw byte diff could only say "1st of
two". One real End Turn reports as 108 fully attributed differences.

Float-parity policy is explicit and strict by default (STATE_CHECKSUM.md 3):
raw IEEE-754 bits; a `canonical` policy for signed zero and NaN payloads only;
and deliberately NO tolerant hashing mode, because quantisation moves the cliff
rather than removing it and destroys the roll-up. Tolerance lives in the differ
as --ulps, applied after localisation. Corpus census: 0 NaN, 0 -0.0, 0
subnormals across 4,474 float leaves, so the strict default costs nothing today
and a test fails the day that changes.

Validated on the real saves (verify/results/state-checksum/): 10 files, 4
distinct contents, all STABLE + COVERED; chain record/verify works on the real
turn1-3 saves. The VM-driven replay loop is designed (section 5) but UNRUN.

Section 3.5 names the one question the host side cannot settle -- whether the
turn pipeline depends on x87 intermediate precision -- and the experiment that
would: force fpu_cw to 0x027f / 0x127f / 0x137f across End Turn and checksum
the three autosaves.

Also recorded: Summary.Checksum is NOT a byte sum over the inflated stream nor
a sum over the int leaves (both ruled out), so nobody repeats those two.

38 tests; sots-engine untouched, clean_room_check.sh OK.
2026-09-08 03:46:34 -04:00
alex
5b74b98778 board: claim VM140 for lane M; MoveFleet ULP + ObservedTech rows 2026-09-08 03:09:37 -04:00
alex
05095e93da lane R: golden-trace recapture with guards on the live game
Five hooks recaptured, build recap-7584bad-20260908T0615Z, no source change.

- ProcessResearch: the over-budget event is now a compare divergence
  (side.events.after.v.next_id orig=4 ours=3) instead of a 609 KB save diff.
  15 calls over 5 turns, 3 diverged, RNG matched 15/15.
- ComputeBudget: 4284 compared, 0 diverged, guard quiet - but only 20 distinct
  states and 13 of 22 slots always zero.
- MoveFleet: 8 of 45 calls diverge by 1 ULP of position. B4's clean verdict was
  a one-sample verdict.
- First guarded captures for OnTechResearched (RollResearchEvent draw fired and
  matched), the colony turn and MoveFleet.
- Guards mapped SetResearched live and found an undeclared ObservedTech append.

VM140 released at the main menu, hooks=trace.
2026-09-08 03:06:43 -04:00
alex
cdada28d78 harness: coverage reporting + --strict-coverage in tracecmp 2026-09-08 01:28:36 -04:00