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.
296 lines
20 KiB
Markdown
296 lines
20 KiB
Markdown
# 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.
|