The tail's phase 34 sweeps every player over every ship of every fleet and
records the design of each ship it can see. Three of the recorder's rules are
invisible from a save and every one of them changes the answer:
* the lookup key is the DESIGN ID ALONE, not the (design, owner) pair;
* a re-observation ERASES the record and PUSHES A COPY ON THE BACK rather than
updating in place, so the list ends in last-observation order and the record's
first-seen turn survives the move. This is why a turn's output is a
PERMUTATION of its input and not an append -- which is what made the group
look like 55 unrelated leaves;
* the list is CAPPED AT 20 RECORDS PER DESIGN OWNER, counted from the most
recent end, and the cap is applied after EVERY record call rather than once
per sweep. On an empire with more than twenty designs in service that thrashes
inside the one sweep: records are evicted and re-created, and a re-created
record's first-seen turn is reset.
A fourth rule is a guard on the DESIGN'S OWNER and not on the observer: a design
owned by an NPC that is not a rebel AI is never observed by anyone. Every
observation record in every corpus save agrees -- the NPC factions' 26 designs
appear in nobody's list -- and removing the guard costs 9 leaves on the canonical
pair while costing nothing on the rich turn, so the two pairs test different rules
and both were run.
TWO THINGS ARE NOT MODELLED AND BOTH ARE NAMED IN THE CATALOG TEXT. The gate is a
two-bit-per-player word on the ship that is NOT serialised; this phase stands it
in with ownership, which is the smallest rule correct on every state the corpus
holds, because on both reference pairs no player observes another player's ship.
That is a hypothesis with a cheap falsifier and it is labelled as one. And the
phase's tech and weapon arms are not implemented: the original feeds the design
through three set builders into the observed-tech and observed-weapon lists, and
those builders are not decoded.
Measured, closed and regressed never netted:
canonical pair turn2 -> turn3 62 -> 61 leaves 1 closed / 0 regressed
rich turn ad-turn27 -> pinB 1092 -> 1058 36 closed / 2 regressed
The 2 are two `otnF` words at positions whose `odid` also moves, i.e. positions
that now hold a different record; and the 21 design leaves that remain are the
shadow of ship construction, not of this phase -- two designs receive their first
ships during that turn and no phase here builds a ship. Fed the true post-turn
ship list, the same code leaves 1 leaf of 55.
Host build 255/255; host ctest 57/59 with the two pre-existing corpus-writer
failures unchanged (12 of 43 saves, the SpecialProjectNameGen `usp` item, not
this lane's); shim cross-build green; clean-room and shim-config checks green.
The milestone needs a second input. The AI is a client, not part of the sim: it decides once,
on one machine, and its decisions reach the server as commands. A save carries the board and
half the input, which is why our turn wrote ModCount 14 where the original writes 24 -- the
missing ten ARE the turn's command stream.
* `game/ai/apply_order` -- the thirty-step schedule the original drains a batch in: twenty-
seven per-LIST steps (every player's elements of one list before the next list starts) with
three per-PLAYER gate loops spliced in at step 10, 29 and 30. Neither list order nor member
order, and both facts are asserted so a port that sorted cannot pass.
* `game/ai/command_capture` -- a `.tcb` recorded turn: gates, list lengths, elements in wire
order, per-client seeds, and `?` for a field the instrument could not read. An element count
that disagrees with its declaration is REJECTED, because a counter quietly one short is
indistinguishable from a turn that issued one fewer command.
* `app/command_replay` -- applies it before the drivers, where the End-Turn dispatcher does.
Every command is CHARGED; only the ones whose subsystem we hold are APPLIED; the rest are
declined with the named gap, or marked incomplete when the capture itself lacks the payload.
* `--turn-commands`, `--replay-count-only`, `--replay-recorded-names`, `--ai-seed`.
Measured on a fresh build directory, canonical pair turn2-state -> turn3-state:
108 -> 62, closed 46, regressed 0 (was 108 -> 63, closed 45) -- /Sim/ModCount now reads the
original's 24, decomposed as 2 drivers + 4 research-rate gates + build + rates + list 10 +
two list-14 + fleet move, with the list-23 population element free.
turn1-state replayed against the SAME run's autosave closes 7 (ModCount and all six research
leaves); against the historical turn2-state it closes 6 and leaves player 512's research pick
diverging -- which is correct, because that recording is from a process that picked differently.
One prediction was falsified and it paid for itself: the first run regressed two leaves because
the rates element's MEMORY field order is not its wire order. The converter no longer claims a
mapping it cannot support.
Two new addresses (the second and third gate-loop heads) via ghidra/addresses.d/lane-rb.json;
header regenerated, never hand-resolved.
`SvSctOb` is not written by direct calls. Every update goes through an event bus: a
driver notifies the root object with an integer id, the root fans the delivery out to
every child, and each delivery is two steps -- a generic handler that takes the id, then
one event-specific vtable slot that does not. The id -> slot map is a 33-entry jump table
in the image, so which class reacts to which event is recovered and exhaustive rather than
inferred from what the saves happen to show. Five of the 33 rows are not in slot order,
including two the tail sends.
Three handlers write the eight leaves that diverged:
* the slavers' difficulty tier, on the tail's end-of-turn delivery -- a three-record
stack table scanned against the frame, boundaries 1/50/100, stored only on a change,
and at frame 100 and above the scan runs off the end and stores nothing, so the tier
can never reach 2;
* the refugees' one-shot latch, on the turn-begin delivery, with a design instantiation
behind the same latch that nothing here can do;
* the swarm queen's hives, also at turn begin, registered on the systems carrying the
SWARM's scenario tag (the queen's constructor stores 3 for that and 10 for its own
encounter id) and then ticked -- and the tick is the whole explanation of a target
turn that reads 31 after one turn and 32 after the next. It is not re-rolled; it slips
forward by one every turn the spawn gates stay shut.
New host phase H03 for the turn-begin delivery, run right after the frame counter where
the original sends it, and tail phase T20 implemented. Rules are pure in game/sim.
Measured on CT111, closed and regressed stated separately:
default turn1->turn2 209 -> 126 (was 128) closed 83, regressed 0
turn2->turn3 108 -> 67 (was 69) closed 41, regressed 0
--commit-blocked=H03 turn1->turn2 209 -> 124 closed 87, regressed 2
turn2->turn3 108 -> 67 closed 41, regressed 0
Registering a hive closes the four leaves that say which systems have hives and that they
have no queens, and opens two carrying a target turn known to be wrong: the original draws
it from the strategic generator inside the turn-begin step, outside both turn drivers, and
neither the two data-file constants nor the generator's position there is settled. That
trade is a flag, not a default.
The prediction in docs/SV-script-objects.md was committed before the build, and P5 was
wrong: it called the second pair a null control, and the second pair is where the slip
rule is tested EXACTLY -- two hives, two target turns, both landing on the oracle with no
draw and no fitting.
Gates run separately: clean-room OK, host ctest 51/51, CT111 shim cross-build clean.
Reference pair turn1->turn2: 81 leaves closed, 0 regressed (was 78/0).
Pair turn2->turn3: 39 closed, 0 regressed (was 36/0). With
--commit-blocked=T31 --ai-player 1: 83/0 and 41/0.
game/sim/colony: GrowCivilianPopulations models ServerSystem's civilian
growth sub-pass. The whole system's delta is clamped to 20,000,000 -- an
int64 column of the population-type table, built in the executable from
its own literals -- and on both reference pairs that clamp, not the growth
curve and not any carrying capacity, is what decides the value: the
uncapped delta is 7.5x it and the capacity headroom 25x it. So the pass
commits with no tuning table loaded, and says by how much each unmodelled
input would have to be wrong before it mattered.
The one input genuinely off the wire is the per-species civilian capacity
factor. It is handled by running the pass twice, once with the modelled
capacity and once with the system's own wire-known dcs limit, and
committing only when the two agree. Imperial growth is deliberately NOT
committed: it is a no-op on this corpus and would need a capacity the
corpus can bound from below but not from above.
game/sim/economy: both interest rates in ComputeBudget are WIDENED FLOAT
literals, (double)0.01f and (double)0.15f, and are then truncated -- so a
treasury of exactly 50,000 earns 499, not 500. This module used the exact
decimals, which left the human's savings one money high on the first
reference pair and exact on the second. Sixteen hand-computed test
expectations moved by one; they were derived from the model, not measured.
The live ComputeBudget compare (4,437 calls, 0 divergences) did not catch
this because it presented only 20 distinct states and none sat on a
rounding boundary.
game/sim/colony: ShipRepairCost, the last unmodelled input of the output
turn path. The demand is still 0 -- its two design fields are cached stats
the save does not carry -- but the zero is now evidenced rather than
silent: S13 reports the candidate set, and the independent colony keeps a
ten-ship fleet over a colony whose savings close exactly at zero demand.
Gates run as separate commands: clean-room OK, host ctest 49/49, and the
CT111 shim cross-build exit 0 (required: game/sim is compiled into the
shim). The host build and report were also re-run on CT111 and produced
identical numbers.
docs/G3-civilian-growth.md; notes repo
findings/subsystems/population-growth.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
The engine has modelled the event log since lane E, but the standalone never wrote
any of it into the save it produces. This wires the two together for the one event
on the reference pair the standalone can compute, and corrects the condition.
What a turn actually posts, measured over all eleven saves: two events on
turn1 -> turn2 and three on turn2 -> turn3, and only two players in the whole
corpus ever hold an event at all. Order is readable off the ids -- the build pass
posts before the research pass. See docs/EV-events.md section 1.
The condition had two of the original's three tests. The missing one is "no tech
finished on this turn or later", and it is what keeps the event off the turn a tech
lands; zuul-turn23 exercises it. The third input, whether the player holds a target
at the moment of the check, is not on the wire -- three of the four real players
acquire one during the turn, which is AI research selection -- so the operator's
--ai-player roster stands in for it as a stated hypothesis and the phase stays
blocked without it.
No prose in the engine: the record's text is resolved through a caller-supplied
lookup over the operator's own installed string table, and a run without a data
root posts nothing rather than writing a record it cannot fill.
Measured (CT111 host build, real data root), closed and regressed never netted:
turn1 -> turn2 209 -> 127 closed 82 (+4 this lane), regressed 0
turn2 -> turn3 108 -> 69 closed 39 (+3 this lane), regressed 0
The posted record agrees with the oracle on all eight of its fields. Controls: with
the roster withheld the phase over-fires and regresses 16 leaves on pair 1; with the
string table withheld it posts nothing and regresses none.
Gates run separately: tools/clean_room_check.sh OK; host ctest 50/50; CT111 shim
cross-build OK (exports 66 names identical to binkw32.dll, staged in
/srv/re-lab/shim/dist-ev); CT111 host ctest 50/50.
H02 StampTreatyTurns -- the diplomacy ledger's "this treaty was last in force on turn
N" stamp, over every ordered pair of players that holds one, creating the entry on
demand. It is a host step, not a phase of either turn driver: its sole caller is the
command-application step, which runs after the frame counter has advanced and before
both drivers. On a turn with no combat and no diplomatic command it is the only writer
of these fields, which is why a whole per-player dipstats vector is its output.
Read from the instruction stream; three things an earlier reading had wrong are
corrected in findings/subsystems/treaty-turn-stamp.md: the stamped value is the TURN and
not the modification counter, the relation codes are 3=allied / 2=NAP / 1=cease-fire and
not the reverse, and the bit is the player's index field rather than its position in the
player vector -- the opposite convention from the shared-vision mask two files away.
Measured, closed and regressed reported separately and never netted:
turn1 -> turn2 (reference) 209 -> 132 closed 77 (was 51), regressed 0
turn2 -> turn3 108 -> 73 closed 35 (was 21), regressed 0
human-turn2 -> turn3 closed 76 (was 64), regressed 0
zuul-turn15 -> turn16 closed 30 (was 18), regressed 0
zuul-turn16 -> turn17 closed 29 (was 17), regressed 0
The last three are pairs from a different game at turns 2, 15 and 16 that the model was
never fitted to, and it closes exactly the twelve ordered treaty pairs each of them holds.
The rule also reproduces the ledger of ten of the eleven corpus saves entry for entry,
including each entry's order and every stamped value; the eleventh is the turn-1 save
whose ledger no turn has yet written, and it is the reference pair's input.
app_test_treaty pins the five things the corpus cannot separate: the relation codes (no
save exercises cease-fire), the bit's source, the missing alliance-id guard, the -1
initialiser on a fresh entry, and the append-at-the-end order that makes re-running the
step idempotent instead of duplicating rows.
The betrayal half of the same function needs the turn's diplomatic commands; with no
command stream it is provably a no-op and is not modelled.
game/sim/construction.{h,cpp}: Game::ShipRecords (four per-hull-class arrays plus the
per-design vector, sized by ENUMERATION against the wire, not by what the code touches)
and the completion bookkeeping BuildQueue::ProcessTurn performs -- the per-class built
counter, whose indexed increment has EXACTLY ONE writer in the whole image, and the
find-or-append per-design record keyed by the design's object id. RunSystemConstruction
wraps the point pass and keeps each completion's design id, which the point pass alone
does not report.
game/sim/colony: corrected from the instruction stream -- with points <= 0 the entry test
branches to the epilogue, so the REMOVAL SWEEP IS SKIPPED TOO. Carried as a labelled
hypothesis: no corpus save can reach the state that shows it.
app/construction_phase.{h,cpp}: S11's build-queue sub-pass, reported on its own line
because what blocks it is not what blocks the rest of the colony turn. It is blocked on
the per-system output term for points; it is NOT what the archived ship census waits on.
tests/game_sim/test_construction.cpp: 65 checks, including a corpus oracle the campaign
already owned and had not noticed -- zuul-turn16-noderoute -> zuul-turn17-rollpending is a
real consecutive-turn pair in which six orders complete and one is partially advanced.
The test SOLVES for the point total rather than assuming it, so a non-FIFO order, a
per-order budget or skip-instead-of-stop each falsify it.
Three phases, one input. A star system carries four per-player masks and three of
them agree on nearly every system of every save the corpus holds, so a model built
on the wrong one looks right until it does not. The gate is the DERIVED
active-presence mask -- fleet-here OR gate-here OR owner, recomputed on every
arrival and departure -- not the sticky one and not the explored one.
S29 SystemObservedStamp the system's own last-observed turn (whole function)
T17 RebuildPlayerViewTree the per-(system, player) observation record: who saw
the system, on what turn, and what encounter was there
T21 UpdateSurveyAndStats the explored sweep: seen this turn implies surveyed
game/sim/visibility is pure and knows nothing about save shapes; app/visibility_phase
wires it to them. The mask is READ FROM THE SAVE and never rebuilt: neither reference
pair moves a mask leaf, so the loaded value is the value these phases would see, and
rebuilding it from an unmodelled movement pass would be a change with no evidence.
Measured, closed and regressed reported separately and never netted:
turn1-state -> turn2-state 209 -> 158 closed 51, regressed 0
turn2-state -> turn3-state 108 -> 87 closed 21, regressed 0
of which this lane closed 46 and 16 (the rest were already closed at main). The 46
are the brief's 32-leaf target in full -- 8 record counts, 8 player ids, 8 turn
stamps, 8 encounter ids -- plus 8 system stamps and 6 explored masks.
Three further pairs the model was never fitted to, all zero regressions:
human-turn2 -> human-turn3 353 -> 311 closed 42 (a different game, 21 systems)
zuul15 -> zuul16 276 -> 264 closed 12
zuul16 -> zuul17 341 -> 329 closed 12
The corpus's one discriminating row is a host test rather than a comment: a system
whose last visiting fleet has gone carries the sticky and explored bits set, the
active bit clear, and a stamp frozen a turn behind. The test asserts the freeze AND
asserts what the wrong gate would have produced, so a future edit that swaps the
mask fails loudly instead of quietly agreeing with five saves.
Labelled hypothesis, with the workload named in the header: the encounter id is
recovered from the encounter fleet at the system, because the field the original
reads is set once at map generation and is not on the wire. It agrees on all six
encounter fleets in the corpus and no save can separate it -- none kills an
encounter while leaving its system visible.
Not written, deliberately: the colony-ownership stamp that moves beside these.
Its gate is demonstrably NOT the active mask (one system in the corpus has a zero
mask and moves it anyway), the formula is not held, so it is reported, not written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
The tail's last phase archives a per-player record whose 13 modelled fields were 7.
The six ship counts join them: every player's designs (normal and legacy, one id space)
are classified against the section catalog, and the global fleet list is walked keyed by
Flt.PID against each player's OBJECT id, not its vector position.
The census cannot come from a save. A design's hull size and its defence-platform flag
are recomputed from the section catalog whenever the design changes and are never written
down, so the standalone grows a data root -- `--data DIR`, or $SOTS_DATA_DIR. No game data
is embedded, and without a root the six counters report themselves unmodelled instead of
being written as six zeros that a wrong model would also produce.
`--commit-blocked=IDS` and `--commit-blocked-except=IDS` narrow the commit switch to named
phase ids. All-or-nothing across every blocked phase reports one closed count and one
regressed count for all of them at once, which is the netting the campaign does not do.
MEASURED, closed and regressed never netted, state_checksum leaves:
turn1 -> turn2 default 209 -> 204 closed 5 regressed 0 (unchanged)
turn1 -> turn2 --commit-blocked=T36 no data closed 29 regressed 9
turn1 -> turn2 --commit-blocked=T36 with data closed 29 regressed 7
turn2 -> turn3 default 108 -> 103 closed 5 regressed 0 (unchanged)
turn2 -> turn3 --commit-blocked=T36 with data closed 13 regressed 7
The prediction written before the code said the regressed list would fall to 6 and 8 under
a full --commit-blocked. It fell to 7 and 9. The prediction's first falsification case is
what happened: two census leaves closed and the third did not, because the census is of the
fleet list as it stands and no phase the standalone runs creates a ship. The archived count
is higher than ours by exactly one destroyer on BOTH pairs for the one player whose build
queue completes that turn, while the self-check on the input turn is exact. That leaf is
short by the turn's construction, not wrong about classification.
app_test_turn_record now compares the six counters against the record the game archived:
11 saves, 80 player-records, 1040 fields, 480 of them census leaves, 0 mismatches. That is
lane D2's 480/480 reproduced through this code path, which visits a design's slots in the
original's in-memory order (mission, command, engine) rather than the wire's.
T36 stays Blocked, and on two named things, neither of them in this phase:
sav and inc come from P01/P02, blocked on the per-system money output; and shpt[0] is short
by the ships the turn builds. The archived record is one struct on the wire, so those words
cannot be left out while the rest is written -- committing is all-or-nothing at the record,
and there is no field-granular knob that could change that. Seven confidently-wrong leaves
are not worth 29 that later lanes close for free.
COVERAGE, as loudly as the verdict: only 32 of the 480 archived census leaves are nonzero
anywhere in the corpus -- per leaf (cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) =
18/3, 0/0, 11/0. cls1 entirely and satt for cls2 have never been observed nonzero: three of
the six counters are unexercised hypotheses. The four loss/kill words of each group are zero
throughout and are written as zeros with no model behind them. Hull size is an assignment in
slot order, and design rule A6 means no save can tell the memory order from the wire order.
verified stays 0: nothing here was compared against a running game.
Gates, separately: clean_room_check OK; host ctest 45/45 without SOTS_SAVES_DIR and 45/45
with it. No src/shim file touched; the shim cross-build was NOT run (no i686 mingw here).
The spine's fourth phase, read byte-for-byte and implemented:
almem[i] = (1 << i) | (ALid != -1 ? AL : 0)
with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both
inputs are on the wire and so is the output, through the turn-record archive,
so the phase is checkable against bytes the original wrote:
app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches
(was 480 fields over six fields; almem is the seventh)
The eight zero masks of the corpus's earliest archived turn are PREDICTED, not
excluded: the archiving phase also runs on load, and the load path does not run
the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are
compared.
Three parts of the rule the corpus cannot separate -- the bit index, the OR,
and the ALid guard -- are pinned in app_alliance with the separating inputs no
save provides, and app_turn_record prints that it could not separate them.
Divergence, closed and regressed reported separately:
turn1->turn2 default 209 -> 204 closed 5, regressed 0
turn1->turn2 --commit-blocked 209 -> 189 closed 29, regressed 9 (was 17)
turn2->turn3 default 108 -> 103 closed 5, regressed 0
turn2->turn3 --commit-blocked 108 -> 106 closed 13, regressed 11 (was 19)
T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the
budget; three census leaves behind the design catalogue). It now closes all 24
turnstats leaves on the reference pair, so it becomes a clean +24 once those
two land.
Prediction and falsification committed first in 49ae628.
Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched.
The trade-raid block is 16 of a measured turn's 18-22 generator words and it is the
first thing the standalone can model as a COUNT rather than as a formula: two chance
rolls per entry of the player vector, neither site inside a back edge, both
probabilities strictly inside (0,1) so neither early-out fires. src/app/trade_raid
implements it with the word cost reported rather than assumed, so a tuning table that
pushes an odds value to 0 or 1 removes the draw and the ledger says so.
Against lane Z's two calibrated oracle pairs the standalone now consumes 16 words and
lands 4 and 2 short, which is exactly the per-call-site ledger's split for those two
turns. It does NOT match the oracle's state, and the report says which sites are
missing instead of netting them off the total.
The tail's last phase -- the per-player turn record -- is modelled for the six fields
that are recoverable from the wire, and self-checked every run against the record the
input save already carries for its own turn: 480 fields over 80 player-records across
the corpus, 0 mismatches. It is not committed. Under --commit-blocked it closes 24
container-shaped divergences on the reference pair and opens 17 leaf-shaped ones, all
of them in the five fields the model does not hold, so the block is a measurement now
rather than an argument.
phases 14/44 of the two turn drivers (8 committed), 3/37 of the tail.
divergence unchanged: 209->204 and 108->103, 5 closed / 0 regressed on both.
`sots_turn` loads a save through the engine's own reader, walks the published
phase order of all three turn drivers, runs what we hold, prints what we do
not, and writes the result back through the engine's own writer.
The phase catalog carries all 32 + 12 + 37 phases whether or not they are
implemented, so an unimplemented phase is a named no-op that appears in the run
log rather than a silent absence. 14 of the 44 turn-driver phases are modelled,
7 commit anything, 2 of the 37 tail phases are modelled.
Modelled but NOT committed is a first-class state. A phase whose formula we hold
and whose inputs we do not is evaluated, reported, and left unwritten unless
--commit-blocked is passed. That distinction was earned: committing phase 31's
player-status restore regressed two leaves that had agreed with the oracle
before the turn, because the phase writes 1 and the file carries 4.
Measured against the game's own post-turn saves, leaves localised by
state_checksum.py with coverage proved by re-serialisation:
turn1-state -> turn2-state 209 -> 204 diverging, closed 5, regressed 0
turn2-state -> turn3-state 108 -> 103 diverging, closed 5, regressed 0
Two tests: app_catalog (the tables stay complete and nothing claims to be
verified against a live game) and app_turn (11 saves driven; an untouched load
re-serialises byte-identically, a turn leaves the file re-readable, and no
blocked or stub phase writes anything). Skips cleanly without SOTS_SAVES_DIR.
ctest 38/38, clean-room OK. src/shim untouched. docs/S-standalone.md has the
full gap list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ