sots-re/findings/control-flow/standalone-generator-and-turn-record.md
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

20 KiB
Raw Blame History

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.