The capture lane needs the block dump L4 built; branching off main without it
would mean writing the same detour twice. Header regenerated from sots-re
(rule 14), not hand-resolved: 1,217 entries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
New shim module src/shim/hooks/ai_orders.{h,cpp}: one register-transparent entry
stub on StrategySim::ApplyTurnCommandBatch dumps every submitted TurnCommands block
(six gates, 27 list lengths, element bytes) at the point where all of them are
complete in memory; sixteen entry probes, with RunTaskList's stub recording the
pass so every later hit is pass-attributed.
Two workloads on VM145, one End Turn each. The rule-19 control passed with all
seventeen detours installed: both autosaves byte-identical to the published oracle.
What the AI actually emits, and three things no reading had produced:
- a list-23 element on EVERY turn, the first element ever observed in the free
half of the cost table -- and both turns still cost the measured 12;
- the ids in AI commands are client-allocated and travel in the command (design
18, fleet 34; neither exists in the input save);
- pass 0 emits nothing, measured from element counts rather than inferred.
tests/game_ai/test_live_blocks.cpp rebuilds both captured blocks through the public
OrderClient API and asserts the list profile, element values, gate counts and
ModCount total: 44 checks. Kept separate from test_orders.cpp, which stays the
record of what static reading predicted.
Gates: clean_room_check OK, host ctest 55/55, CT111 shim cross-build exit 0.
`ComputeBudget`'s savings-interest term is now compared against the running game at
a treasury the corpus actually contains. Three runs on VM146 from turn1-state.sav:
A (widened floats, as shipped) 3,895 calls, 0 diverged, 0 undeclared writes
B (exact decimals, the control) 2,718 calls, 1,359 diverged
The game fills savingsInterest with 499 at a treasury of 50,000, and with 380 at
38,100 -- the exact decimals pay 500 and 381. Every divergence in B lands on a
treasury that is a multiple of 100 and no other state diverges at all, which is
exactly the arithmetic. G3's rule-23 reading is now measured, not inferred, and the
one-money error is shown to propagate into `available` and `researchMoney` too.
The control also settles why the earlier 4,437-call green run was green: slot 5 IS
diffed and the harness CAN see it, so that run simply presented no boundary state.
Coverage is therefore reported as distinct states, not calls: 5 distinct treasuries,
2 of them on the boundary.
Two further rule-23 constants found in the same routine by an operand-width sweep,
corrected, and honestly marked UNVERIFIED because no reference turn can see them:
- the research-yield factor is a widened 0.85f while its two neighbours in the
same product are exact doubles. Boundary: research money a multiple of 40,000;
the run presented 9 distinct values and none is.
- the three research modifiers are summed in single precision, not double.
Boundary: two of the three non-zero; the corpus has shrm = TRM = 0.
Both are pinned by boundary cases in test_economy.cpp that fail with the decimals.
Also verified live, in the same run:
- T31's difficulty-column recovery. The live ServerPlayer+0xf9 / NPC flags on all
eight players are exactly what lane PL's save-only inversion claims, including
the awkward system-owning player that is still ambiguous because it is an NPC.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR reads 3.29999995 = (float)3.3. Its file image
is zero because the loader fills it at run time, so lane PL-3 had to assume the
value; it is now measured and the assumption was right.
Falsified, and recorded as such: the difficulty-mods record does NOT sit inline at
ServerPlayer+0x36c -- that field is a heap pointer on all eight players. The row IS
reachable from a ServerPlayer (which corrects the hook's standing coverage note),
but the fitted {3.0,1.5}/{1.0,1.0} pair remains unverified. The hook logs the
pointer and does not follow it.
The `verified` column stays 0, deliberately. Every phase this compare touches is
Partial for reasons upstream of it, and promoting one because part of it was checked
is the drift app_test_catalog exists to catch. What moved is models; see
docs/L5-live-verification.md for each one with its coverage.
Gates run separately: clean-room OK, host ctest 54/54, CT111 shim cross-build exit 0.
`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.
Closed 2 on the reference pair and 4 on pair 2, regressed 0, with no operator
input. Measured, both pairs, never netted.
- S00 stamps PvSav from Sav on every live player, before any phase can move it.
0 closed on pair 1 (a no-op there), 2 on pair 2.
- T31 recovers the per-player difficulty column by recomputing BnkEl from the
colony state the input save was written from and comparing against the BnkEl
the save carries. That removes the --ai-player flag as a blocker and turns the
phase's self-check into a real one: it used to compare its POST-turn result
against the PRE-turn stored value, so its 6-of-8 only ever covered the six
players whose limit does not move. The load-time check passes for every live
player of all eleven corpus saves. T31 Blocked -> Partial; BnkPr still needs
the tuning constant.
- BANKRUPTCY_PROTECTION_LIMIT_FACTOR is multiplied in as fmul dword ptr, so it
is a float32 in the image; the engine narrows it now. Zero leaves move on this
corpus -- all seven of its BnkPr records land where the two constants agree --
and three hand-written test expectations moved (rule 23).
docs/PL-players-residual.md carries the decomposition, the predictions written
before the build, where they were wrong, and the ranked remainder.
Adds the half of the AI's turn that is arithmetic rather than judgement: what an
order looks like in the command block, which orders advance the save's
modification counter and by how much, and the phase/pass skeleton the decisions
hang in.
The counter's per-command cost turns out to have a sharp boundary. Applying an
element of command lists 1..16 advances it; applying an element of lists 17..27
does not, and one of the six flag-gated single commands is free as well. So a
uniform per-element cost model is wrong on any turn that touches the free half.
Two behaviours here are not conveniences and change the output:
* every submitted block costs at least one, because the send-buffer build sets
the research-rate gate unconditionally whatever the player did. On a quiet
board that is the largest term in the turn's delta -- four of the ten command
bumps on the reference turn are exactly this, and one of the four is the
human's;
* an AI fleet order costs three where the interface's costs two, because the
AI's bridge issues the fleet-task command twice, mode 0 then mode 1, and the
adder keys on (fleet, mode).
The phase spine records the one thing a literal port gets wrong: the turn submits
at phase 28 of 34, the submit latches the client closed before it builds the send
buffer, and every order the last five phases issue -- one of which is a colonize
order -- is refused. Tested through the client rather than by asserting a flag.
The task walk reproduces the two passes: rank once, walk twice, and refuse every
write in the first pass at the client rather than trusting the caller to check.
Nothing here decides anything. Which tasks exist and what each one wants are
questions about the board, and no part of this models the board; the module
supplies the order API, the pass gate and the cost function, and a caller
supplies the decisions.
game/ai tests 233 -> 423 checks; ctest 51/51 -> 53/53. Not linked into the
standalone driver, whose divergence on the reference pair is unchanged at 128.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
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.
`ComputeBudget` takes a system's money from two different functions. Projected mode
calls `ComputeMaxIncome`, which lane E1 closed 25/25 against the BnkEl oracle. The
TURN calls `ComputeOutput` with the system's own rate sliders, where the build queue,
the ship-repair pass and the infrastructure -> terraform -> money cascade are all
live and E1's proof that the cascades are zero does not apply.
Read from the instruction stream, both ranges disassembled to the next function start:
* `sim::ComputeSystemOutput` -- the channel algebra of `ComputeOutputFromRates`, with
every rounding site (round-half-even per channel, truncating for the construction
and money slots) and the association of every x87 sum as the original has them.
* `sim::IdealSuitability` -- the owner's own field, the server's species baseline for
an independent colony, and the per-system `dsu` override.
* `sim::RepairShipsInOrbit` -- the round robin, which is provably equivalent to
`points - min(points, demand)`: the per-pass share is at least 1, so the only early
exit needs every remaining cost to be zero.
* two corrections to `ConstructionPoints` and `SplitLeftover`: the station bonus is
ignored unless strictly positive and its association is `k x (b x cons) + cons`, and
the leftover weights sum as `wi + (wf + wt)`.
The load-bearing fact: the leftover construction points come back to the TRADE
channel, so a colony with an empty build queue earns the same money whichever way its
sliders point. The engine now runs both paths on every load and reports the
difference; on the 11-save corpus every delta decomposes to the unit into the build
queue's points priced through the money chain.
P01/P02 move from blocked to partial and are committed:
turn1-state -> turn2-state 209 -> 157 closed 52 regressed 0 (was 51 / 0)
turn2-state -> turn3-state 108 -> 86 closed 22 regressed 0 (was 21 / 0)
One leaf per pair, and it is the easy one: the independent colony, whose population
does not grow and whose orders the turn does not change. The human's savings are
still short by the civilian growth `S11` does not commit, and the AI's by its own
orders. The ship-repair demand is taken as 0 because `Ship::RepairCost` is unread.
sots-re: findings/subsystems/output-turn-path.md, ghidra/addresses.d/lane-c3.json
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.
First module of game/ai, and the first piece of Rung B that is not scaffolding. It is the two
halves of the AI's task selection that are pure: the 33-value task type enumeration and the
ranking that decides which goal the AI acts on first.
* the priority table, verbatim -- higher runs first, and it is the entire default policy;
* the five overrides, kept out of the table on purpose. The two artifact tasks ignore their
table entries (1 and 2) and return 1260/1261; a port that only copied the table would rank
them last instead of fourth and fifth. The two tuned invade priorities are INPUTS
(TaskPriorityPolicy), not constants, because their loader is not yet identified;
* Rank() as a stable descending sort. The original sorts a std::list, so stability is the
behaviour, not a choice -- ties keep creation order;
* CreationOrder(species, policyNonZero), because that is what breaks the ties. Four arms: the
NPC species builds nothing, Hiver is the only arm with the gate families, Zuul the only one
with NodeBore, everyone else shares a fourth. Both defensive families are gated on the
player's policy value and DefendGateIncoming is Hiver-only on top of that.
193 checks in tests/game_ai, every expected value read off the original's tables rather than
produced by running this code. ctest 46/46 -> 47/47; clean-room check OK.
Derivation: sots-re findings/subsystems/ai-task-system.md (lane AI2), sections 1-3.
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.
The second chain ComputeBudget needs. The output term (lane N) is a system's
OUTPUT; what the budget and the bankruptcy limits sum is its MONEY, which runs
that total through TradePointsToMoney. Read instruction by instruction, every
range disassembled to the next function start.
The income law is not the output law with a different constant: money per head
is typeIncomeMod / 14000 -- no 1.8, no 500000 -- and it truncates TWICE per
(group, species) row, once inside the per-row term and once after the morale and
addiction factors. Summing the species first and truncating once is wrong on any
colony with more than one species.
The multiplier that was "not on the wire" (formula-gaps.md Q3) resolves to a
three-row table the executable BUILDS IN CODE from .rdata float literals, exactly
like lane N's pop-type table: {int id; float ai[3]; float other[3]}, selected per
player by is-AI && !NPC. Every corpus save carries aidf == 1, whose AI income
column is 1.1f -- the x1.1 the BnkEl oracle measured. Its other two columns are a
fleet-maintenance divisor and a research multiplier, both of which already had a
home in BudgetInputs and no source.
Verified against the 25-record BnkEl oracle, which inverts the stored limit to
the true sum and therefore needs no VM: 6/25 before, 25/25 after. Falsified three
ways -- moving the AI flag off the AI players costs 11 records, moving it onto the
humans costs the other 11, and forcing one species' resource pair on all of them
costs every Zuul record.
app: T31 UpdateBankruptcyLimits now runs the whole roll-up and self-checks it
every run against the BnkEl the input save already carries -- 8 of 8 players on
turn1-state with --ai-player 1. It stays blocked on two things that are not the
formula: ServerPlayer+0xf9 (is this player AI?) is a game-setup input the save
does not carry, and BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data
files. Committing it closes nothing on the reference pair -- the limits move
because the CIVILIAN POPULATION grows and that growth is not committed -- so
measured with --commit-blocked=T31 --ai-player 1: 0 closed, 0 REGRESSED, i.e.
209 -> 204 and 108 -> 103 unchanged.
P01 is NOT unblocked, and the roadmap's item 1 was wrong about that: ComputeBudget
takes its per-system money from ComputeOutput with the system's OWN rate sliders,
not from ComputeMaxIncome. Only its projected mode uses the max-income form. On
the turn path the repair pass runs and the unspent-industry and
unspent-terraforming cascades into the money channel are live, so the proof that
both are zero does not apply. The catalog text says so now.
Two things the 25/25 does NOT cover, and they are labelled in the code: the
suitability money cost is multiplied by zero on every corpus colony (all sit at
their species' ideal), and the slave, addiction, morale, station and
capacity-surplus branches are unexercised.
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 design record's section array is [mission, command, engine]; the wire is
[command, mission, engine]. Hull size is an assignment inside the original's
per-section loop, and that loop runs over the array, so the last resolved
section in MEMORY order wins -- engine, else command, else mission.
The first cut of this walked the wire order, which gives a different answer for
a design with an empty engine slot and both other slots filled. Rule A3 makes
that shape invalid, so no design in the corpus can tell the two apart and the
480/480 census result is unchanged either way -- which is exactly why the order
is now a named constant with the reasoning attached instead of whichever loop
was already to hand.
derive_stats takes hull_size after its loop for the same reason; the
defence-platform flag is an OR and stays in the loop.
52 design unit tests (the mixed-class case now pins memory order and adds an
engine-slot case), census still 480/480 on 11 saves, realdata still 127/127 and
197/197. host ctest 43/43, clean-room OK.
The two derived words the per-player turn record's ship census counts by, and
the design serializers that three lanes had been told did not exist.
HOW DESIGNS PERSIST. Game::ShipDesign derives from Game::ShipDesignDef and
reaches IStreamable through adjustor thunks, so a design is written by TWO
serializers: the base emits FAIDes/DHide/DWep/DName and exactly three section
frames (command, mission, engine on the wire), the derived one appends Dtc, the
Dwgv flag and, only when that flag is set, a weapon-group frame. The earlier
"the writer makes no stream call at all" note named an address that is in no
vftable at all. Corrected in shapes.h.
THREE sections, not five. The "two reserved slots" were Dtc and Dwgv swept into
the section list by the reference reader's catch-all tail; the constructor
builds a three-element array. design.h's comment is corrected and the fixture
loader now accepts 3-5 raw_slots so old fixtures still load; the array keeps
five inert entries deliberately, since touching the slot enum reaches rules.cpp
and another lane's tests for no behavioural gain.
DWep and Dwgv are bools, not ints -- both writers call the bool primitive. With
four-character tags a bool item and an int item are the same size on the wire
and 0/1 the same bytes, so no save can tell them apart. Byte-neutral: the typed
round trip is still byte-identical on all 11 saves at 100% named coverage.
HULL SIZE is the section_class of the last resolved section in memory slot
order, mapped Destroyer/Cruiser/Dreadnought -> 0/1/2 case-insensitively, with
absent or unrecognised meaning 0 rather than an error. The DEFENCE-PLATFORM
flag is one bit of a 64-bit role-flag word OR-ed across the design's sections.
Neither is on the wire; both are rebuilt from the section catalog.
MEASURED, not assumed: the new game_design_census test rebuilds the six census
counters per player and compares them against the record the game archived for
each save's own frame. 11 saves, 503 designs, 480 leaves, 0 mismatched, 0 ships
with an unresolvable design, 0 designs where first- and last-resolved section
disagree on hull size. COVERAGE IS THIN AND THE TEST SAYS SO: only 32 of the
480 leaves are nonzero, and three of the six census leaves (both cruiser rows
and dreadnought platforms) are never exercised by any save in the corpus -- the
test prints the per-leaf nonzero counts and names them unexercised rather than
verified.
Nothing is wired into the turn record: src/app is another lane's this cycle, so
this is evaluated and reported, not written.
host ctest 43/43 (was 42/42; +1, skips cleanly without the env). With a data
root set, game_data_realdata and mars_text_realdata fail identically on main --
both are the absent Locale/EN/Strings.csv, not this change. clean-room OK.
Reference readers fixed openly in the RE repo: save_reader 49/49,
design rules 32/32, stock_designs.json regenerated (raw_slots 5->3 and dWep
int->bool are the only field changes across all 127 designs).
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 strategic layer does not search for a route: the player or the AI picks the
destinations and the engine classifies each consecutive pair, deciding the waypoint
kind and whether the order is legal. This models that classifier as pure functions.
The waypoint kind of any leg that is neither a gate transit nor a node route is a
pure function of the owning species -- which is the whole answer to why kind 2 has
never been observed. Kind 2 is the Liir drive; the two node-drive races are Human
and Zuul, both of which map to kind 3, and every observation so far was taken on
one of those two.
Also modelled: the three refusal bits versus the nine advisory ones, the gate
transit that waives the grounded-fleet refusal, the projection radius that splits
gate-to-gate from gate-to-gateless, the single-hop node line lookup and bore, and
the fuel check whose range is squared at full precision while the distance is
narrowed -- the one floating-point asymmetry here that flips a decision.
The leading-destination drop is reproduced with its original off-by-one behind an
explicit flag rather than silently fixed.
120 hand-computed checks. Host ctest 43/43; clean-room check OK.
Reads the whole colony output chain off the instruction stream (every range
disassembled to the next function start) and compares two of its functions
against the running game.
The population -> output law is linear and is carried by the executable:
output points per head are typeOutputModifier x 1.8 / 500000, and the
three-row population-type table is built in code rather than loaded, so the
imperial (1.0) and civilian (0.33f) modifiers are facts about the binary.
A system's total output is a SUM of three terms, not one multiplicative
chain. The station bonus scales only the imperial term and morale only the
civilian one, so OutputModifiers no longer carries either; they belong to
GroupOutputInputs. The function previously described as the base-output term
is the over-harvest RESOURCE demand, and it is corrected in place.
Live on VM140, both hooks in compare mode over two species and two workloads:
GroupOutput 13,105 calls / 0 divergences; ComputeTotalOutput 11,252 calls /
1 divergence of one ulp, in a value its caller rounds to an integer. Both
functions declare a whole-object Guard: 0 undeclared writes in 24,357 calls,
which is what makes the side-effect-free claim a measurement.
sim::Narrow forces the double rounding a 32-bit x87 build otherwise skips;
without it every civilian row came out one ulp low.
Also fixes ComputeBankruptcyLimits' elimination divisor, which was the
decimal -0.15 rather than the image's widened float -0.15000000596046448.
The two disagree for every maximum income divisible by 3 and for essentially
every empire above ~3,000,000.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
The strategic half of a battle: where beaten fleets go, which fleets split,
which are left empty, and who learns the system they were beaten at. Draw-free
end to end -- the whole sub-tree contains no random draw -- so this is a pure
function of its inputs and needs no generator.
What is modelled:
* the destination search: three independent nearest-system passes (owned /
no hostile presence / anything), each with its own best-so-far, over
squared float32 distances with a strict comparison. The independence is
load-bearing: a nearer system rejected by one predicate must not spoil
that pass's best, and a single-loop version gets it wrong.
* the hostility mask, including the rule that a system captured on the
current turn loses its owner's ceasefire cover.
* per-ship eligibility: already-departed, encounter-faction exclusions (one
hard-coded id plus a data-driven bitmask), and the dead-drive gate, which
tests against a single-precision epsilon rather than zero and which the
gate species skips because it does not fly out.
* grouping on all four key words (owner, destination, mode, variant).
* whole-versus-partial: a fleet runs whole only when every one of its ships
is in the group; otherwise the group gets one new fleet and the leftover
ships move into it, while ships of a wholly-retreating fleet stay put.
* the emptied-fleet list, which matters because destroying a fleet aborts
every intercept aimed at it.
Deliberately NOT modelled: applying the plan. Creating a fleet mints an object
id from a monotonic counter and appends to the master fleet list, and both of
those are saved state; that belongs above this layer, where the object store
lives. Keeping the decision separate is what makes it host-testable.
53 hand-computed checks. ctest 42/42, clean-room check OK.
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
A per-turn RNG budget is only as good as the entry-point table, and ours had
three of the seven. Adds the two that are modellable and documents the rest.
float_range(lo, hi) exactly one word. Narrows TWICE -- the scaled product
is stored to a 4-byte float before lo is added, and the
sum is stored again. Evaluating in double and narrowing
once disagrees on a measurable fraction of words, and the
test asserts the two models are distinguishable so the
shortcut cannot creep back.
int_range_bell(lo, hi) AT LEAST TWO words. Triangular, not uniform: the span is
split into h/2 and h - h/2 (truncating toward zero) and
each half drawn inclusively, first half first. The bounds
reach the draw as unsigned, so an inverted range yields a
huge first bound rather than an empty one; reproduced, not
corrected.
Documented but deliberately not modelled: a truncated-normal integer range built
on rejection sampling around a Box-Muller pair. It costs TWO WORDS PER ATTEMPT
and the attempt count is unbounded, and predicting its stream position needs log,
sqrt and cos to agree bit for bit with the original CRT. Nothing in the strategic
turn reaches it. It is recorded so a ledger that meets it does not score its two
words as one draw.
Also recorded in docs/mars-rng.md, because each is a way a word budget goes wrong:
* two calling conventions for one generator -- three entry points take the state
block (the object plus four bytes) and four take the object itself, and one
caller uses both within forty bytes of itself;
* two different divisors in the same image, 1/(2^32 - 1) for the unit draw and
2^-32 (with a +0.5 offset on the word) for the normal path;
* the unit draw is inlined at twenty-eight sites across eleven functions, so any
budget assembled by counting calls is a LOWER BOUND. Exactly one of those
eleven is reachable from the strategic turn driver.
Host ctest 36/36 and tools/clean_room_check.sh run as separate commands, both
clean. No src/shim change, so no cross-build is implicated.
The turn's RNG cost has never been measured end to end. combat-done-tail.md
found two draw sites in OnAllCombatDone_Tail that nothing models and that both
run before the autosave, so a reimplementation that reproduces both ProcessTurn
functions exactly still diverges the first turn a node line expires.
RngLedger recovers an ABSOLUTE WORD POSITION from (mt[624], left) alone, by
indexing the forward-only chain of blocks the twist generates. Word deltas
between any two observations are then exact -- across twists, across NextInt
rejection loops, and across draws nobody hooked. That last point is not
theoretical: the image has four draw entry points, one of which (NextUInt
0x004f7670) appears in no previous lane's primitive set, plus inlined draws in
twelve functions. A primitive-counting hook would have undercounted silently.
Six nested trace hooks bracket one End Turn between the two autosaves and
attribute the words: the two turn drivers, the two tail phases that can draw,
and ProcessNodeSpaceTravel because it runs twice a turn. NodeLineDecay carries
a real model -- one word per expired node line under NodePath::RemainingLife --
so compare mode checks the count rather than reporting it.
Corrections from the instruction stream, both load-bearing:
* StrategyHost::Autosave is ret 8, not ret 4, and returns the std::string* in
EAX. A void-returning hook would have dropped it at both call sites.
* node-line decay's 0x20000-fleet skip runs AFTER the Chance(0.5f) call, not
before, so it cannot change the draw count -- combat-done-tail.md reads as
if it gated the roll.
fpu.sample_turn releases StrategyServer::ProcessTurn, which the fpu sampler and
this ledger both want and MinHook grants to one of them. Default on: no
existing run changes behaviour.
Host ctest 37/37; shim cross-built on CT111; clean-room check OK.
The last opaque block of the save format. Reconciles lane W's provable negative
against lane O's issued-order saves: the two disagreed because three different
things were being compared as one.
Read out of Game::TurnCommands::Write (0x00842540, 764 bytes, no loops of its
own). Two halves:
* a PROLOGUE of six flag-gated groups. Each is a WriteBool on a member,
followed only when set by that command's payload. Write order is NOT offset
order -- each gate sits after its payload in the struct -- so the class is
one of the 89 whose offset-sorted layout view cannot be aligned to the wire.
That, not a branch, is why the sorted view showed an i32 where the save has
a bool at item 4.
* TWENTY-SEVEN std::list<T> members (0x70..0x1a8, stride 0xc, allocator-last),
each written by its own helper as WriteInt(size) then size element records.
All 27 are always written, so an empty list still costs one zero int.
So the recovered 44 items are 17 member writes plus ONE ITEM PER CONTAINER CALL
SITE: the linear recovery keeps the call site, guesses its kind from an element
field it could resolve, and drops the count word. 44 - 17 = 27 = the number of
lists. The "27 trailing ints with only 22 i32 slots" objection is that same 27
seen from both sides -- a kind mismatch in the table, not a structural
impossibility. And 8 prologue items + 27 zero counts = the 35-item block every
no-orders save carries bit-identically.
Item arithmetic closes to the unit on all five distinct workloads (35/38/41/61/
123). One correction to the provisional layout: the fleet-move element ends in a
COUNTED route vector {fleetId, nHops, nHops x systemId}, not a fixed quadruple --
with a four-item element the writer would need 26 lists on one save and 28 on
another, and it has 27.
Conformance: the generic check cannot be used here. Every tag is "." so the LCS
degenerates to a strict positional compare in which any primitive disagreement is
fatal, and the table's tail describes elements where the wire has counts. A
dedicated check states what is checkable instead: the 17-item prologue item for
item (17/17, and SchemaProbe takes every branch, so this is real evidence that
the conditional structure read from the instruction stream is the one the
recovery flattened) and the tail count (27 lists vs 27 table items). The tail is
reported as wire-only, never claimed as matched.
86 shapes / 838 items -> 87 / 856, still 0 MISMATCH.
Twenty-two of the 27 lists are HYPOTHESES: the scalar sequence comes straight off
the helper, but no save exercises them. Nested element bodies not otherwise
modelled here (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout,
RaidTargets) are carried as opaque Nodes rather than guessed at, so a wrong body
cannot desynchronise a reader. select() matches the exact ".TurnCommands_v5"
suffix, so a future _v6 falls back to the carried Node.
SECOND DEFECT, present at main and unrelated to this block: zuul-turn23-fleet23
is the first save with a non-empty NVs list, and its element's leading id was
typed positionally as "." where the real tag is PID. The typed round trip on that
save differed at 0x89c14. Rule 6 exactly -- a path no save exercised was a
hypothesis flying as a fact. Fixed, and independently corroborated: the recovered
table for Game::ServerSystem names that item PID, and the Sys row moves from
102 matched / 1 wire-only / 3 shape-only to 103 / 0 / 2.
Coverage (CoverageArchive typed-vs-carried, not round-trip success): all eleven
saves 99.7-99.9% -> 100.0%; opaque items 37/43/63/132 -> 2 everywhere, and those
two are the deliberately-carried MT19937 block. Nothing else in any save we hold
is untyped. Ratchet 99.8 -> 99.99. Round trip byte-identical on all eleven
(newly so on zuul-turn23-fleet23).
test_save now asserts, on every real save, that each TurnCommands_v5 block is
consumed by the prologue plus the 27 lists with nothing left over -- the
item-granular statement a wrong list count or element width breaks first.
clean-room OK; host ctest 36/36; test_save 11 saves 0 failures. src/shim/ not
touched, so no cross-build was needed.
Descriptor + pure adapter + host tests for the per-player turn driver. Not
deployed; the WIN32 half is unbuilt here (no cross-compiler on this host).
The declared boundary is narrower than the function on purpose. Phases 2, 3
and 6 -- the savings apply, the aid records and the research refund -- are pure
functions of ComputeBudget's 22 slots and ProcessResearch's overBudget, and
both live in the original's own stack frame. Reaching them would mean calling
ComputeBudget ourselves (it repairs ships in orbit, audit #6), reading the
nested B1/B3 hooks (audit #5, the self-fulfilling compare), or inferring them
from the Sav delta. So they are guarded, not checked, and the three formulas
are written and unit-tested but not wired into the verdict.
Declared: the phase-7 clear, the RebAI decay, the descending timed-bonus
sweep, plus roll_flags and rng as observations ours never writes. Guards over
the whole ServerPlayer and the TechTree header.
docs/T-turn-driver.md states, before any run: which regions must not diverge,
which checks are weak by construction on the reference save, what falsifies
the ResearchRollPending reading, and the save that would finally fire the
branch nobody has seen.
host ctest 36/36 (was 35/35); clean_room_check OK.
Game::StrategyAIAgent::Streamable and the ten shapes under it. The whole
writer is unconditional -- the branch the decompiler shows around lnat is an
inlined vector destructor whose operator delete is marked noreturn, and both
paths converge -- so the recovered sequence and a single record are the same
sequence, and all 36 items match with 0 wire-only and 0 shape-only.
CD blocks are now selected by the CDT id at the same ordinal, in both
directions; the one .TurnCommands_v5 block per save still falls to a Node.
Also: Sim's Attrib was not an empty frame, it was an AttribMap holding a count
of 0, and typing it closes those two items too. And StreamableEnum<T> writes a
frame containing one int, not a bare int, so SysMem/mts/nalat are arrays of
one-int frames -- byte-neutral, since all three have count 0 in every save,
but the previous typing was wrong.
Conformance 74 shapes/769 items -> 86/838, still 0 MISMATCH. Round trip
byte-identical on all four saves; ratchet 97.5 -> 99.8. Every container that
is empty in all four saves is named as such in the notes; the new unit test
populates each one, since nothing else exercises them.
SvSctOb is a StreamableHelper<SVScriptObject> -- a polymorphic pointer holding a
Game::SVSOSots, which writes two variant lists each dispatched by the key item
before it (xscn -> xsc, EncID -> EncObj). Neither map is on the wire; both were
read out of the game's factories (see the notes repo). The shapes apply the key
in both directions, so a body goes back out as whatever it came in as, and an
unmodelled key still round-trips as a Node. 18 new shapes: SVSOSots, the four
scenario bodies (traps / crowdefs / indsys / gmtrigger -- indsys really does
serialize nothing, its Read and Write are both the shared `ret 4` stub) and the
eight encounter bodies the saves exercise. The four factory ids no save carries
(7 SystemKiller, 8 PuppetMaster, 14 Locust, 21 Ortgay) are deliberately NOT
typed: their serializers are recovered but nothing could check a shape for them.
DOpts and SVSOVonNeumann::trev are VectorHelper<Mars::String>, so read_elem /
write_elem / SchemaBuilder::carr grew the std::string branch lane G listed as
missing. spies2 is VectorHelper<int>: the TYPE is certain from the helper's own
decorated name, but the count is 0 in all 28 systems of all four saves, so no
element value has ever been observed -- the shape is a hypothesis about
behaviour even though it is a fact about type. Same for SysMem and mts.
Conformance 56 shapes / 657 items -> 74 / 769, still 0 MISMATCH, and every new
binding is 0 wire-only and 0 shape-only. Coverage 97.1/97.2/97.2/97.6 ->
98.0/98.0/98.0/98.4 with the byte-identical round trip preserved; ratchet
95.0 -> 97.5. CD is now the only remaining region of size, and it stays opaque:
the recovered 44-item Game::TurnCommands sequence cannot be aligned to the
save's 35 items even as a subsequence (item 4 is 8 bytes, so a bool where the
recovery says i32; and the 27 trailing ints have only 22 i32 slots to come
from), which proves the no-orders diagnosis rather than assuming it.
Two unit tests added that need no saves: the string-array element branch
(including the empty string, which is four zero bytes and so looks like int 0)
and the SvSctOb variant dispatch round trip.
ctest 34/34, clean_room_check OK, test_save skips cleanly with SOTS_SAVES_DIR
unset. sots_stream_schema.h unchanged: streams.py and gen_stream_schema.py were
re-run and the output is byte-identical apart from the provenance line.
The serializer recovery reaches this repo as a generated wire schema
(include/generated/sots_stream_schema.h, 386 classes / 2042 items): for each
class, the ordered sequence of items its Write puts on the stream. Facts only —
no field offsets, no sizeof, no strides. This engine reads and writes the on-disk
format; it does not inherit the original's memory layout.
The table is a specification, not a program: the recovery is a linear pass over
Write, so it cannot see Write's branches (StarShip's BQ2 is gated on hbq but
listed unconditionally) and it flattens container loops. A codec driven off it
would desynchronise. The hand-written io() shapes stay the codec; SchemaProbe
(probe.h) walks them with every branch taken and test_wire_schema LCS-aligns
that against the table — 56 shapes bound, 657 items matched, 0 mismatches.
Four defects the check found, all invisible to a round-trip test:
- SystemParams field 1 is a string, not an int. It is the empty string in
every save, and an empty string is four zero bytes — byte-identical to the
int 0, so it round-tripped by luck. A named planet would have desynced.
- ObservedTech/ObservedWeapon odet is a bool, not an int. Byte-safe only
because a 4-char tag makes a bool item and an int item both 12 bytes.
- SpeciesRatios nv and ShipRecords srbd are counts, not fields.
CoverageArchive separates items a field names from items a Node merely carries,
because a byte-identical round trip is not a coverage claim. Typed coverage of a
real save goes 37.9% -> 97.1% (97.2/97.2/97.6 on the others) with the round trip
still byte-identical, by typing TechTree (both NumTechs sections), Events,
ShipRecs, sprjs, civr, comms, spy2, spymgr, aid, Ojvs, AIEnf, FNG, trdmgr and
the Des section/gun-bank tree. Ratchet at 95%.
trdmgr resolves a recorded trap: ServerTradeManager's Read/Write really are the
inherited no-op, but the call is virtual and ServerTradeManagerImpl has the real
serializer. Same shape resolves IServerSpyManager -> ServerSpyManager.
ctest 34/34, clean_room_check OK, test_save skips cleanly with SOTS_SAVES_DIR unset.
Pure module game/sim/techgraph: PrereqsMet (AND of ORs, empty group fails),
SetResearched (stamps, child-cost sweep, sticky turnAvailable, zero-cost
recursion) and the newly-available collector, all read out of 0x00581e10,
0x0057d8e0 and 0x00587cc3.
Wired into the B3 hook in compare mode only, over the scratch node copies:
four more node write-backs, the EVENT_TECHS_UNLOCKED list (still an input,
still nullptr when it could not be computed), the de-duplicating observed-tech
append and the one RNG word RollResearchEvent draws.
docs/U-unlock.md section 4 is the prediction, written before the build was staged.
TechTree::ProcessResearch's events region now compares a modelled value instead of
reporting a known defect. ours posts the pass's events into its own
sots::events::EventStorage, seeded from a scan of the owner's list taken BEFORE the
original runs, and writes only the counts into the region's scratch copy. The game's
PostEvent is never called and no live byte moves; replace mode still posts nothing,
because a bumped EvNxID with no record behind it would corrupt the oracle's save.
- game/events: PostResearchPassEvents (the decision half, pure) + KeylessEventText
- shim/hooks/event_inputs (new lib shim_events, host-tested): the live<->model adapter,
carrying game pointers as explicit uint32 so a 64-bit host build cannot alias them
- research hook: the wiring, a new observed_techs region for ServerPlayer+0x274, and
turn / events_next_id_in / events_dedup_risk in the args so the count model's own
assumption is measured rather than assumed
- EVENT_TECHS_UNLOCKED is NOT posted: its trigger is pinned but needs SetResearched's
unlock cascade, which ours does not run. The driver takes the unlock list as an input
and is handed 'no list', so a missing input cannot look like a modelled negative.
Predicted residual: next_id short by exactly 1 on a completion call.
ctest 33/33 (shim_events_unit is new), clean_room_check OK. The shim TU is
syntax-checked only: no MinGW cross toolchain on this box.
See docs/P-events-wiring.md for the exact prediction for the next VM run.
The behavioural compare found 8 of 45 StrategyServer::MoveFleet calls diverging by
one ULP on a position component. Read off the instruction stream, the cause is that
the engine's vector normalise narrows to float32 four separate times and we kept
everything in double:
delta.c = f32(dest.c - pos.c) stored back to a float32 slot before normalising
sumsq = f32(x*x + y*y + z*z) products/adds in 53-bit regs, only the SUM stored
len = f32(sqrt(sumsq))
inv = f32(1.0 / len) a reciprocal, MULTIPLIED through, not three divides
dir.c = f32(delta.c * inv)
and the same call returns the leg distance, so it is never recomputed in a wider
precision either. The position tail was already right, which is why the error was a
constant absolute ~1.2e-7 (half an ULP of the inputs) rather than a formula error.
Adds NormalizeVec3 / StraightLeg / StraightLegDistance / AdvanceAlongUnitDirection
and rebuilds AdvanceAlongDirection on them; the movement hook now takes both the
direction and the distance from one StraightLeg call, as the original does. The
arrival test is an exact float compare, so the distance has to be that same float32.
Tests pin float32 BIT PATTERNS, not tolerances: one case per narrowing plus four
independent legs component by component. A CHECK_NEAR would pass against the old
arithmetic.
sim::Distance is left in double on purpose and flagged at its declaration: it now
serves only the node-line/stutter geometry, which very likely needs the same
treatment but has zero behavioural coverage to correct it against.
Live, same VM/save/workload, run twice by this lane:
control recap-7584bad-20260908T0615Z 45 calls, 45 compared, 8 diverged, exit 1
fixed mf-45bdf7d-dirty-20260908T0721Z 45 calls, 45 compared, 0 diverged, exit 0
with identical arguments, identical pos.before and identical ORIGINAL pos.after on
all 45 calls. The control reproduced the eight divergent call_ids exactly.
Coverage unchanged and still thin: all 15 moving calls are the same straight-run
waypoint type; types 2-5 were attempted and could not be reached (the only player
that would travel a node line has no ships on this save). See docs/M-movefleet.md.
ctest 32/32; tools/clean_room_check.sh OK.
Recovers the game's event-posting API so the engine can post events and the
compare harness can see them. Until now the owner's event list was invisible to
every layer: B3's replace-mode oracle failed by exactly one item across 40,300
(an unposted EVENT_RESEARCH_OVERBUDGET) while its compare read clean, and B2's
clean compare bounds the economy fields only.
New pure module src/game/events:
* EventStorage / TurnEvents / PlayerEvent -- the list is bucketed by TURN, not
flat, which the save-editor struct notes had wrong.
* EventStorage::Post reproducing the original's rules, including the four that
change save bytes: the FLT_MAX (not infinity) default position; action 0 with
no subject and no position storing as 2; per-bucket dedup that compares
message/image/location/position/action but NOT summary; and EvNxID starting
at 0 and being promoted to 1 on the first post.
* PruneOldTurns reproduced with its off-by-one: of a leading run of buckets
older than turn-50 it erases n-1, so one stale bucket always survives. The
survivor is serialized, so correcting it would diverge.
* research_events: the five events the research path raises, their EvImg
identifiers and string-table keys, and the 0.8 completion split evaluated
against (double)0.8f rather than the decimal 0.8.
Localized text is deliberately absent: only the EVENTSUM_/EVENTMSG_ keys are
here and the text resolves through a caller-supplied lookup, as the game does.
The four event offsets the B3 hook carried as local literals now come from the
generated header; they are read off instructions rather than inferred from the
save schema.
tests/game_events: 112 checks including a replay of the event list
turn3-state.sav actually holds. ctest 31/31 -> 32/32.
docs/E-events.md carries the proposed region and Coverage wording for the next
B3 recapture.