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

296 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.