# `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 ``` 1. **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. 2. **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. 3. **Walks the whole published phase order of all three drivers** — the host steps, then `StrategyServer::ProcessTurn`'s 32 phases, with `ServerPlayer::ProcessTurn`'s 12 nested in at phase 13, then `OnAllCombatDone_Tail`'s 37 — running what we hold and printing what we do not. 4. **Writes** the post-turn state back through `mars::stream::write_save` + gzip. 5. **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 `nve` record 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 — `TShn` and `ltis` moving 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. 1. **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/app` can close this.** 2. **The population → output term.** One unresolved formula blocks `P01`, `P02`, `P05`, `P06` and `T31` — 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. 3. **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. 4. **`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. 5. **The `Player.Status` writer.** 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.