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