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.
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.
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.
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).
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.
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).
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.
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.