# The standalone's generator, measured against the oracle — and the tail's turn record Lane Y, 2026-09-08. Host work, no VM held. Engine worktree `wip/yield` (`sots-engine/docs/Y-standalone-rng-and-tail.md` carries the predictions, committed before the build). Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. **The headline, and it is binary. The standalone's generator does NOT land on the oracle's state.** It lands **4 words short** on lane Z's turn-4→5 pair and **2 words short** on the turn-5→6 pair, and those two numbers are exactly the per-call-site ledger's split for those turns. The 624-word state block is byte-identical in both cases (no boundary is crossed); only `left` differs, 379 against 375 and 359 against 357. | calibrated pair | oracle words | standalone | residual | `left` ours / oracle | state matches | |---|---|---|---|---|---| | `z2-endturn` → `z2-autosave` (turn 4→5) | 20 | **16** | **4** | 379 / 375 | **no** | | `z-t6-endturn` → `z-t6-autosave` (turn 5→6) | 18 | **16** | **2** | 359 / 357 | **no** | Tool: `tools/rng_oracle_check.py`; output kept at `verify/results/standalone/rng-oracle.{txt,json}`. --- ## 1. What is now modelled: the trade-raid block, 16 words Lane Z established that `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290 loops the server's player vector and rolls two `Mars::RNG::Chance` calls per player, at 0x00893426 (`TRADE_RAID_ODDS_PLAYER`, 0.2f) and 0x00893513 (`TRADE_RAID_ODDS_NPC`, 0.05f), with **no back edge containing either site**. That is what makes it modellable by a lane holding no game: the cost is a **bound**, not a fitted observation, and it does not depend on the probabilities — both are strictly inside (0, 1), so `Chance` takes neither early-out and spends exactly one word. The player vector's size is on the wire: it is the `NumPlrs`-counted array, **8** on the Human saves and 7 on the Zuul ones. `sots-engine/src/app/trade_raid.{h,cpp}` implements the loop, and `sots-engine/tests/app/test_trade_raid.cpp` pins the count against a scripted generator. **The word cost is counted, not assumed.** The implementation increments its counter *inside* the chance helper, past the two early-outs, rather than once per call. That matters because the three odds are game-data constants: a data file that pushes one of them to 0 or 1 removes the **draw**, not just the outcome, and a count-the-calls implementation would be silently one word per player out. One test case pins exactly that. ### 1.1 The 16 is now confirmed by a third instrument Lane Z's 16 came from two instruments that both watch the running game (a boundary hook and a set of return-address detours). Subtracting the standalone's structurally-bounded 16 from the **file** oracle's 20 and 18 leaves **4 and 2** — which is precisely what the per-site table predicts for those two turns (2 detection words on both, plus 2 research words on turn 5 and 0 on turn 6). So the trade-raid count is now corroborated by a route that touches no hook at all: file bytes minus a static bound. This is weaker than it looks in one specific way, and it is worth saying: the residual is a *subtraction*, so it confirms the **sum** 16 + (detection + research), not each term separately. If the trade-raid block cost 15 and something unmodelled cost 5, the same residual would appear. What rules that out is the static bound, not this arithmetic. ## 2. What is not modelled, named rather than netted off `TurnResult` now carries `rngUnaccounted` next to `rngWords`, and the report prints them as two separate facts. The same convention as `closed` / `regressed`, for the same reason. | site | owner | cost | why it is not modelled | |---|---|---|---| | 0x0050329d | ← `DetectEncounters`, depth 4 | 1/turn on 3 of 3 measured turns | no derived rule; lane I bounds the containing family at `\|contacts\| × \|detectors\|`. Fitting 2/turn to three observations is not a model | | 0x007929a4 | ← `DetectEncounters`, depth 3 | 1/turn on 3 of 3 | same | | 0x00587888 | `TechTree::ProcessResearch`+0x1c8 | 0–1 | downstream of the research allocation, which is downstream of `ComputeBudget`, which is blocked on the population → base-output term | | 0x0088df4f | `ServerPlayer::RollResearchEvent`+0x2f | 0–1 | the standalone's phase P10 holds the gate but the *threshold* reads research progress after the same blocked allocation, so it evaluates false where the game evaluates true | | 0x008939ee | `ServerTradeManager::CreateRaidEncounter` | 0 or 1 per **successful** roll | undetermined; no roll succeeded on any measured turn | **The last row is the one that will bite.** Because the standalone skips the two research draws that happen *earlier* in the turn than the trade-raid block, its raid rolls consume the wrong words, so its hit decisions are not the game's. The run reports the hit count and says explicitly that it is not a prediction. On the two calibrated pairs no roll succeeded in our stream either, so no target-selection draw was reached; that is luck, not a property. **Ordering, stated for the record.** The generator state a save carries encodes only the *count*, so the residual above is order-independent. Order still matters for two things: the *values* drawn (and hence every decision taken from them), and the word cost of any bounded-integer draw whose rejection loop can run more than once. Getting the count right is necessary, not sufficient. ## 3. `--commit-rng` now has something to commit — and still tells the truth Lane S2 left the write-back unimplemented on the reasoning that an advanced-but-incomplete state is wrong differently from an untouched one. That reasoning is satisfied only for the accounted words, so the flag discipline is kept and the run now prints, every time: * the words consumed, and whether they were written back; * the unaccounted sites, as a list, never subtracted from the total; * a standing warning that the committed state is a **lower bound** and that its drawn values are not the game's. By default the generator is still untouched, and `tests/app/test_turn.cpp` still asserts that. --- ## 4. The tail's last phase — the turn record, and it is testable without a game Lane K read `StrategyServer::FinalizeTurnRecords` (tail phase 36, `FUN_0078a0e0`, 416 B) field by field and noted that it also runs on load, so it is "testable without a turn". It is better than that: **the archive it writes into is on the wire.** It is `/Sim/turnstats/nply[]/hist/stats[]` — `PlayerTurnStats` in the engine's schema — one element per turn per player, and **every save carries an element for its own frame**. So the phase can be checked against bytes the original produced. ### 4.1 Six fields, recovered and confirmed on 80 player-records | record field (wire) | lane K's offset | rule | status | |---|---|---|---| | `trn` | — | the frame counter | confirmed | | `col` | `+0x18` (int16 in memory) | the number of owned-system ids | confirmed | | `sav` | `+0x14` | the player's savings | confirmed | | `inc` | `+0x0c` | savings **minus previous-turn savings** | confirmed | | `tch` | `+0x28` (int16) | tech-tree states equal to 4 | confirmed | | `pop` | `+0x20`/`+0x24` (int64) | Σ over owned systems of `Pop + pbon` | confirmed | `sots-engine/tests/app/test_turn_record.cpp` runs this over `$SOTS_SAVES_DIR`: **11 saves, 80 player-records, 480 fields compared, 0 mismatches, 0 dangling owned-system ids.** The standalone also runs the same check *inside every turn* and prints it as a phase note, so a future schema change that breaks the mapping announces itself on the next run rather than at the next audit. **Two corrections to `combat-done-tail.md` §5, both from the corpus.** 1. **`+0x0c` is not "net savings change" in the sense of a budget line** — it is literally `Sav − PvSav`, and `PvSav` is a wire field stamped before this turn's savings are written. The distinction matters because it makes the field computable from the save alone. 2. **The wire names `inc` and `tdinc` are the opposite way round from the obvious reading.** `inc` is `+0x0c` (the savings delta) and `tdinc` is `+0x10` (the trade income lane T found). Reading `tdinc` as the turn's delta income and `inc` as a general income would put both fields on the wrong offsets. **And one term lane K's reading left open is now pinned.** `Σ (sys->+0x194 + sys->+0x18c)` maps to the wire pair `Pop + pbon` — the live population plus the pending population bonus. The competing candidate, `Pop + dcs`, matched **101 of 112** player-records and was rejected; `Pop + pbon` matched **112 of 112**. Neither `Pop` alone nor `PvPop` alone matches: `PvPop` fits the Human saves and fails the Zuul ones, and `Pop` does the reverse. A one-save check would have concluded either. ### 4.2 Five fields it cannot fill, and what each is blocked on | field | blocked on | |---|---| | `almem` (`+0x08`) | the alliance/vision mask rebuilt by `ProcessTurn` phase 4 — a stub | | `tdinc` (`+0x10`) | trade income from `ComputeBudget` — blocked behind the per-system money output | | `bat` (`+0x1a`) | the tail's battle tally, phase 4 — a stub | | `sacq` / `slost` | counted lists, **count 0 in every save in the corpus** — rule 6 applies: the element meaning is unobserved, not known | | the per-hull-class census (`+0x2a`..`+0x34`) | each design's hull size and its `0x400` class flag; neither is on the wire, both come from the game data | ### 4.3 The block is a measurement now, not an argument The phase builds the record and does **not** commit it. `--commit-blocked` writes it anyway, so the claim can be checked. On `turn1-state` → `turn2-state`: | run | closed | regressed | |---|---|---| | default | **5** | **0** | | `--commit-blocked` | 29 | 17 | and every one of the 17 is in the unmodelled set: `almem` ×8, `inc` ×3, `sav` ×3, and three leaves of the ship census. Nothing else regresses. On `turn2` → `turn3` the same run is 13 closed / 19 regressed (17 turnstats + the two `Status` leaves lane S2 already knew about). So the honest statement is **not** "committing this makes everything worse" — on the first pair it is net +7 for the turnstats subsystem and on the second it is net −9. It is: *the record is right in six fields and wrong in five, and which way the net falls depends on the save*. That is precisely what netting `closed` and `regressed` into one number would have hidden, and it is why the phase stays blocked until `ProcessTurn` phase 4 and `ComputeBudget` land. **When they do, phase 36 closes 24 leaves on the reference pair with nothing left over.** --- ## 5. A by-product worth more than the phase: `BnkEl` inverts to the blocked input `ServerPlayer::UpdateBankruptcyLimits` computes `BnkEl = max(trunc(maxIncome / -0.15000000596046448), -2000000000)` where `maxIncome = Σ over owned systems of max(ComputeMaxIncome(s), 0)` — the *same* per-system money output that blocks `ComputeBudget`, phase P01, phase P02 and half the turn record. The map from `maxIncome` to `BnkEl` has a slope of about 6.67, so **it is injective and invertible**: each stored `BnkEl` has at most one integer preimage. Inverted over the corpus: | save | player | `BnkEl` | recovered `maxIncome` | `BnkPr / maxIncome` | |---|---|---|---|---| | turn1-state | 16 | −1590613 | **238592** | 3.299997485 | | turn2-state | 16 | −1594593 | **239189** | 3.299997073 | | turn3-state | 16 | −1598566 | **239785** | 3.299997915 | | turn1-state | 32 | −1811273 | **271691** | 3.299998896 | | human-turn2 | 16 | −9064672 | **1359701** | 3.299999779 | | zuul-turn16 | 16 | −1270619 | **190593** | 3.299995278 | 25 player-records over the corpus invert cleanly; the 55 players that own nothing carry `BnkEl = 0` and are reported as such rather than inverted. `tools/max_income_oracle.py` does this over any save, with `verify/results/standalone/max-income-oracle.json` as the corpus run. **Two things follow:** 1. **Lane N has a per-save oracle for the term it is chasing.** Every save in the corpus states, for every player that owns anything, the exact sum of its systems' maximum money outputs — no VM, no hook, no game. A candidate output formula can be summed over the owned systems and compared to a number the original wrote. 2. **`BANKRUPTCY_PROTECTION_LIMIT_FACTOR` is 3.3**, recovered from the saves rather than from the data files. All 25 ratios land in [3.299994751, 3.299999872] — 3.3 minus one truncation. `combat-done-tail.md` §4.1 correctly flagged the constant as DB-sourced and not a binary literal; this is an independent measurement of its value. ### 5.1 The divisor defect is live on the corpus, and it is not one ulp `sots-engine/src/game/sim/economy.cpp` divides by `-0.15`. Lane K's correction — the constant in the image is the double `-0.15000000596046448`, i.e. `(double)(float)-0.15f` — was described as being "one ulp out on large empires". **That understates it, and this lane's own prediction repeated the understatement and is corrected here.** `trunc(m / -0.15)` and `trunc(m / -0.15000000596046448)` differ for **every `m` divisible by 3**, starting at `m = 3` (−20 against −19). The cause is not precision loss at scale: `m / 0.15` is an exact integer whenever `3 | m`, and the two constants straddle it, so one truncates down and the other does not. On top of that floor, ordinary drift adds more as the value grows: | `maxIncome` around | fraction of integers where the two constants disagree | |---|---| | 10³ … 1.2 × 10⁶ | **33.3 %** (exactly the multiples of 3) | | 1.36 × 10⁶ … 2 × 10⁶ | 66.7 % | | ≥ 3 × 10⁶ | **100 %** | On the corpus this is not hypothetical: **6 of the 25 player-records above would get a different `BnkEl` from the engine's current constant than the game wrote** — both players of `human-turn2` (`maxIncome` 1359701 and 1556834, neither divisible by 3, both in the 66.7 % band) and of `human-turn3`, plus `zuul-turn15` player 32 and `zuul-turn16` player 16 (both multiples of 3). `tools/max_income_oracle.py` flags them. **This lane did not fix it**: `src/game/sim/economy.cpp` belongs to another lane in flight. The fix is one character class — replace the literal `-0.15` in `ComputeBankruptcyLimits` with the round-tripped float constant — and it wants a test case at `maxIncome = 3`. --- ## 6. What still stands between the standalone and a byte-match, re-ranked Ranked by *leaves closed per unit of work*, with what each unblocks. | # | blocker | what it costs | what it closes | |---|---|---|---| | 1 | **the per-system money output** (population → base output) | one formula, and §5 now hands it a per-save oracle | P01, P02, P05, P06, T31, and 5 of the 11 fields of the turn record. It is upstream of ~82 of the 204 remaining leaves and of 2 of the 4 unaccounted generator words | | 2 | **`ProcessTurn` phase 4, the alliance/vision mask** | one bit-mask rebuild, no RNG, no events | `almem` on every player's turn record — 8 of the 17 leaves that stop phase 36 committing | | 3 | **encounter detection** (`ProcessTurn` phase 31) | the largest remaining *generator* gap: 2 words a turn with no derived rule | the last non-research residual against the oracle. Cheapest experiment: bracket `DetectEncounters` with the contact and detector counts in the record, which lane I already specified | | 4 | **the ship-design catalogue** (hull size + the `0x400` flag) | a data-file read, not a code read | the turn record's census, the ship-record leaves, and part of `T24` | | 5 | **`ModCount`** | it advances 12–44 times a turn from writers spread across both drivers | 1 leaf, but it is in `Summary.Checksum`'s input, so no byte-match is possible without it | | 6 | the tail's remaining 34 stubs | large | the rest | **The generator specifically.** After the trade-raid block there are exactly **two** classes of missing word left on a quiet turn: the detection pair (#3) and the research pair (#1). Close either and the residual halves; close both and the standalone's generator state matches the oracle's on these two pairs, at which point `/Sim/RNG` — currently 1 leaf of the 204 — closes, and it is the leaf that gates every *subsequent* turn. ## 7. What this lane did not do, listed as loudly as the results * **Nothing here was compared against the live game.** `verified` in the completion metric stays 0 for everything this lane touched, and the catalog test enforces that. The turn-record check compares against **bytes the original wrote**, which is strictly weaker than a live compare and strictly stronger than nothing; it is labelled that way in the source. * **The 16-word count is exercised on 8-player saves only.** The two calibrated pairs are the same game. Lane Z's free prediction — that a 7-player Zuul save should cost 14 rather than 16 at these two sites — is still untested, and this model would make it testable the moment a Zuul pair with a known word cost exists. * **The two raid gates are hypotheses.** The NPC-raid gate is player-independent and was open on 8 of 8 measured turns; the refugee gate was shut on all 8. Neither is on the wire. If either flips, the turn costs ±8 words on an 8-player save and the residual moves by that much — which is how it will announce itself. * **`sacq` / `slost` have count 0 in every save.** Rule 6: their element meaning is unobserved, and the turn record model treats them as absent rather than as empty-and-understood. * **The turn-record self-check compares six fields, not eleven.** A green run over 480 fields is 480 comparisons of the six fields we hold; it says nothing about the five we do not, and the phase note lists them every run for that reason. * **The `BnkEl` inversion assumes the stored value was written by this formula and not clamped.** Any player whose `maxIncome` is large enough to hit the −2,000,000,000 floor inverts to nothing; none in the corpus does, and the tool would return an empty preimage rather than a wrong one. * No shim TU was touched, so no CT111 cross-build was required for this work. --- ## 8. Three questions for whoever holds the VM (earned rule 18) Rule 18 landed while this work was in flight and it applies to every residual above. This lane holds no VM, so everything here was measured against the **files** — the two calibrated pairs and the eleven-save corpus — rather than derived, but three of the open items are hook-shaped and would take minutes on the game. 1. **Bracket `StrategyServer::DetectEncounters` 0x007d7f70** the way lane Z bracketed the tail, with the contact and detector counts in the record. That settles the 2-word detection pair, which is the *last* non-research residual between the standalone's generator and the oracle's on a quiet turn. Lane I already specified the experiment and its falsifiers. 2. **Hook `ServerTradeManager::CreateRaidEncounter` 0x008938a0.** A word count cannot separate "no roll succeeded" from "the candidate list was empty"; the callee separates them instantly. Until it does, the trade-raid model's 16 is a bound on a *quiet* turn only, and the first successful raid moves the count by an unknown 0 or 1. 3. **Run one End Turn on any Zuul save with the site instrument.** The player vector holds 7 there, so the two trade-raid sites should cost **14** rather than 16. That is the cheapest available check on what the loop actually iterates, and this lane's model makes it a prediction rather than an observation: the standalone will report 14 on those saves today. And one that is not mine but is next to my work: **the `Player.Status` writer between tail phase 31 and the autosave** — the phase writes 1, the file carries 4, and it is the leaf that blocked lane S2's first committed phase. A watchpoint on `ServerPlayer+0x164` across the tail names the writer.