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.
20 KiB
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.
+0x0cis not "net savings change" in the sense of a budget line — it is literallySav − PvSav, andPvSavis a wire field stamped before this turn's savings are written. The distinction matters because it makes the field computable from the save alone.- The wire names
incandtdincare the opposite way round from the obvious reading.incis+0x0c(the savings delta) andtdincis+0x10(the trade income lane T found). Readingtdincas the turn's delta income andincas 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:
- 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.
BANKRUPTCY_PROTECTION_LIMIT_FACTORis 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.
verifiedin 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/slosthave 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
BnkElinversion assumes the stored value was written by this formula and not clamped. Any player whosemaxIncomeis 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.
- Bracket
StrategyServer::DetectEncounters0x007d7f70 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. - Hook
ServerTradeManager::CreateRaidEncounter0x008938a0. 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. - 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.