`sots_turn` loads a save through the engine's own reader, walks the published phase order of all three turn drivers, runs what we hold, prints what we do not, and writes the result back through the engine's own writer. The phase catalog carries all 32 + 12 + 37 phases whether or not they are implemented, so an unimplemented phase is a named no-op that appears in the run log rather than a silent absence. 14 of the 44 turn-driver phases are modelled, 7 commit anything, 2 of the 37 tail phases are modelled. Modelled but NOT committed is a first-class state. A phase whose formula we hold and whose inputs we do not is evaluated, reported, and left unwritten unless --commit-blocked is passed. That distinction was earned: committing phase 31's player-status restore regressed two leaves that had agreed with the oracle before the turn, because the phase writes 1 and the file carries 4. Measured against the game's own post-turn saves, leaves localised by state_checksum.py with coverage proved by re-serialisation: turn1-state -> turn2-state 209 -> 204 diverging, closed 5, regressed 0 turn2-state -> turn3-state 108 -> 103 diverging, closed 5, regressed 0 Two tests: app_catalog (the tables stay complete and nothing claims to be verified against a live game) and app_turn (11 saves driven; an untouched load re-serialises byte-identically, a turn leaves the file re-readable, and no blocked or stub phase writes anything). Skips cleanly without SOTS_SAVES_DIR. ctest 38/38, clean-room OK. src/shim untouched. docs/S-standalone.md has the full gap list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
12 KiB
src/app — the standalone, and exactly how far it is from the byte-match
Lane S2, 2026-09-08. Branch wip/standalone.
The campaign's north star is a functional reimplementation, and the first milestone that makes it falsifiable is:
the standalone loads a save, runs one strategic turn, and writes an autosave that byte-matches what the original produces from the same state.
This document says what sots_turn does today, what it deliberately does not do, and what
stands between it and that byte-match. The short answer is on the first line of the metric:
14 of the 44 turn-driver phases are modelled, 7 of them commit anything, and the reference
turn closes 5 of 209 diverging leaves. Everything below is the detail behind those numbers.
1. What it does end to end, today
$ sots_turn SAVE --roundtrip --phases --out POST.sav --metric metric.json
- Loads the save through
mars::stream::read_save_file— the engine's own reader, the one with 100 % named coverage on all eleven saves in the corpus. - Proves the foundation with
--roundtrip: re-serialises the untouched parse and compares byte for byte against the inflated stream. If that fails, nothing after it means anything, so the run stops. - Walks the whole published phase order of all three drivers — the host steps, then
StrategyServer::ProcessTurn's 32 phases, withServerPlayer::ProcessTurn's 12 nested in at phase 13, thenOnAllCombatDone_Tail's 37 — running what we hold and printing what we do not. - Writes the post-turn state back through
mars::stream::write_save+ gzip. - Emits a completion metric as JSON for the campaign dashboard.
It reads no game data. The save path is an argument; the test reads $SOTS_SAVES_DIR and
skips cleanly when it is unset. No .sav is in this repo and none is written into it.
2. The design decision that matters: blocked phases do not commit
Several phases are ones whose formula is verified and whose inputs are not modelled. The
budget roll-up is the type case: ComputeBudget compared clean over 4,284 live calls, but one
of its inputs is the money output of each owned system, and that needs the
population → base-output term the colony model explicitly declares unresolved.
Running such a phase and writing its result would be worse than not running it. It replaces a
leaf that may currently agree with the oracle by construction with one that is confidently
wrong, and the divergence count then measures how much code we ran rather than how much we
know. So a blocked phase is evaluated, reported, and not written unless the operator
passes --commit-blocked.
This is not theoretical. S31's player-status restore was implemented and committed in the
first version of this lane; the comparison tool immediately reported two regressed leaves
on the turn2 → turn3 pair — two Player.Status words that agreed with the oracle before the
turn and disagreed after it. The phase writes 1; the file carries 4; a writer between the
phase and the autosave is unaccounted. S31 is now blocked, and the measured fact is
recorded in its catalog note rather than papered over.
The same reasoning governs the generator. The turn consumes roughly 18–20 words that nothing
here models, so the advanced state would be wrong in a different way from the untouched
state. The untouched state at least tells the truth. --commit-rng exists for the day the
ledger closes.
3. The phase catalog
src/app/phase_catalog.{h,cpp} is the roadmap and the ledger at once. Every phase of every
driver is in it whether or not it is implemented, with one of five statuses:
| status | meaning | commits? |
|---|---|---|
verified |
implemented here and compared against the live game | yes |
implemented |
implemented from an instruction-verified reading, not yet compared live | yes |
partial |
part implemented and committed, part stubbed; the note says which | yes, the part |
blocked |
formula held, an input is not modelled | no (unless forced) |
stub |
a named no-op | no |
Nothing is verified. That is deliberate: in this table verified means "compared against
the live game", and lane S2 holds no VM. app_test_catalog asserts verified == 0 so the
claim cannot drift upward by accident.
3.1 Current state
| driver | phases | modelled | committed |
|---|---|---|---|
| host steps (outside the milestone's denominator) | 2 | 2 | 2 |
StrategyServer::ProcessTurn |
32 | 4 | 3 |
ServerPlayer::ProcessTurn |
12 | 10 | 4 |
| the milestone's denominator | 44 | 14 | 7 |
OnAllCombatDone_Tail |
37 | 2 | 1 |
3.2 What actually runs
Committed
| phase | what it does |
|---|---|
H00 BeginProcessTurn |
advances the frame counter — the turn number the whole game displays |
H01 SaveWriterInvariants |
the summary's turn number is the frame counter; an identity that holds across the whole corpus |
S00 SnapshotPreviousTurn |
the modification-counter bump (the shadow-word snapshot is not modelled) |
S11 SystemTurn |
game::sim::ProcessColonyTurn, committing the parts that need neither the tuning table nor a carrying capacity |
P07 ClearTimedResearchAccumulators |
zeroes the three timed-research accumulators that are on the wire |
P08 DecayRebellionOutputModifier |
rebel AI only: −0.04f per turn, clamped to [1, 2] |
P09 AccumulateTimedResearchBonuses |
the timed research-bonus vector, iterated last → first, which is load-bearing because float addition is not associative |
P10 ConsumeResearchRollPending |
the strict 0.5f < progress/cost test and the in-branch flag clear |
T00 IncrementModCount |
the tail's own bump of the same counter |
Evaluated and reported, not committed: P01 P02 P03 P05 P06 P11 S31 T31.
Everything else is a named no-op that prints itself in --phases.
3.3 One hypothesis, and how it is being tested
ProcessColonyTurn takes stable as an input — in the original it is a callee's verdict.
The standalone's stand-in is owned && !abandoned && !destroyed, and it is labelled a
hypothesis in the code and in the run log, because rule 6 says a path no evidence exercises is
a hypothesis.
It is a testable one: stable drives the turns-developing counter, which is a named leaf.
On the reference pair it judged 3 of 28 systems stable and closed exactly the 3 ntdev leaves
the oracle moved. It then closed the same 3 on the turn2 → turn3 pair — a different
workload, which is what makes it evidence about the model rather than about the recording.
Six independent agreements, zero disagreements, is not proof, and it is written down as such.
4. The divergence report
Measured by sots-re/tools/standalone_report.py, which drives sots_turn over each
consecutive-turn pair in the corpus and diffs the result against the game's own post-turn save
using verify/state-checksum/state_checksum.py. That tool localises to named leaves and
proves its own coverage by re-serialising the parse back to bytes, so nothing can hide
from it.
Reference pair turn1-state.sav → turn2-state.sav (a real End Turn):
baseline (a standalone that does nothing) 209 leaves diverge
after one standalone turn 204 leaves diverge
closed 5, regressed 0
Closed: /Summary/Turn, /Sim/Frame, and ntdev on Gamma Cephei, Ke'Dolarra and Koa'Vo.
The second pair turn2 → turn3 closes the same five out of a baseline of 108, with zero
regressions.
regressed is reported next to closed on purpose. A leaf that agreed before the turn and
disagrees after it is a phase doing damage, and netting it off against the closures would hide
exactly the failure this scaffold is built to catch.
4.1 Where the remaining 204 live
| subsystem | leaves | what it is |
|---|---|---|
/Sim/players |
82 | savings, bankruptcy limits, research state, the turn's event buckets, per-player AI bookkeeping |
/Sim/systems |
80 | see below |
/Sim/turnstats |
24 | the per-turn statistics archive — written by the tail's last phase |
/Sim/SvSctOb |
8 | the script-object encounter tree |
| singletons | 10 | ModCount, RNG, Checksum, NMnx, cmbtid, the four id lists, NumFlts |
The system side is not 80 unrelated facts. It is a handful of mechanisms:
- 32 leaves — one new
nverecord on each of 8 systems (NVE+EPid/ETS/Eid). One mechanism, eight repetitions: the per-player system-visibility record. Spine phase 24 or tail phase 21. - 18 leaves —
TShnandltismoving 1 → 2 on 8–10 systems. Both look like per-turn counters and both are unattributed; nothing in the campaign names their writer. They are the cheapest measured candidates on the board and they are deliberately not implemented, because "it went up by one across one turn" is a hypothesis, not a reading. - the rest: population growth, reputation, the output-rate re-normalisation, one new fleet.
The player side is dominated by three things: Sav (blocked behind the system-income term),
the bankruptcy limits (blocked behind the same term), and the event buckets (blocked behind
the localised event-text table).
5. What stands between this and the byte-match
In the order they must be solved, not in order of size.
- The RNG ledger. 18–20 words are consumed per turn and none are modelled. The generator
is saved state, so a byte-match is arithmetically impossible until every draw is
attributed — including the two draw sites in the post-combat tail, which happen after the
spine has finished and before the file is written. Lane I proved the search space closes
(exactly 22 draw sites in the spine's 1,426-function closure, plus an image-wide scan for
inlined draws); lane Z is measuring the attribution. Nothing in
src/appcan close this. - The population → output term. One unresolved formula blocks
P01,P02,P05,P06andT31— that is 5 of the 44 phases and the single largest cluster of player-side leaves. It is the highest-value unresolved formula in the campaign for this milestone. - The post-combat tail. 37 phases, none implemented, and it is the driver the autosave is
written from.
turnstats, the bankruptcy limits, the observed-design records and the player reports all live there. Summary.Checksum. Its algorithm is unknown. It is one leaf, and it is the last leaf: whatever it hashes, it cannot be right until everything it hashes is right.- The
Player.Statuswriter. The phase writes 1, the file carries 4, a load resets to 0. Four leaves, and a small, self-contained question.
There is also a floating-point requirement that is already measured rather than assumed:
intermediates must be held at 53 bits and narrowed to float32 only on store, with
round-to-nearest. 24-bit precision and round-up each move named leaves on this very corpus.
game::sim already follows that rule; anything added to src/app must too.
6. Tests
| test | what it holds |
|---|---|
app_catalog |
the three tables are complete, contiguous, uniquely named, and 32 + 12 = 44; every non-stub carries a note; nothing claims to be verified |
app_turn |
over every save in $SOTS_SAVES_DIR: an untouched load re-serialises byte-identically; a turn leaves the file re-readable and re-serialisable; the modelled counters moved; no blocked or stub phase wrote anything; the generator is untouched by default. Skips cleanly when the variable is unset |
7. What this lane did not do
- It did not run the game. Lane S2 holds no VM; every comparison here is against saves the game already wrote.
- It did not touch
src/shim/, so no cross-build was required. - It did not implement a phase on a guess. Two obvious "+1 per turn" system counters were left alone for exactly that reason, and they are named in §4.1 so the next lane can close them properly.