sots-re/findings/subsystems/population-growth.md
alex d4f9404960 lane G3: civilian growth read and measured; out[6] is not a repair number
findings/subsystems/population-growth.md -- ServerSystem::GrowCivilianPops
0x00754220 read byte for byte, its whole chain, and Ship::RepairCost
0x00815180.

The headline is a correction to the brief's premise. The pass is NOT gated
on imperial carrying capacity. The whole system's civilian delta is clamped
to 20,000,000 -- POPTYPE[1]+0x08, an int64 literal in the executable -- and
on both reference pairs that clamp decides the value: the uncapped delta is
7.5x it and the capacity headroom 25x it. The imperial capacity is pinned
at exactly Size x 1e8 from the corpus alone, by two independent behaviours
of one colony across three turns, with no data files.

Two boundary corrections, both of the rule-17 shape. GrowCivilianPops is a
loop over group types whose back edge lies outside every decompiler `if`,
so it reads as straight-line code if you stop at the first `ret`; its real
end is 0x00754b59. MaxPopGeneric ends at 0x0074a6cd, so the 0x0074a6d0 that
lanes N and E1 both cite for the capacity-surplus pair is a different
function.

A table correction with teeth: InitPopTypeTable never writes the group
ceiling at +0x28/+0x2c. The CRT static initialiser at 0x009abe20 does, to
INT64_MAX, and nothing else touches it -- so the clamp that reads it is
always a no-op. A reader who opens only the obvious initialiser sees zero
there and would cap every carrying capacity in the game at nothing.
Lane N's whole table is otherwise reproduced independently from the
six-register fxch rotation.

output-turn-path.md: out[6] is NOT the ship-repair demand. 0x007460b0 sums
0x0081f8c0, which gates on the design's carried-population bit and computes
GroupIncome over the ship's own Population -- so it is the income of
population carried in slaver and colony hulls, a slot the engine already
had under a name nobody had connected to it. Corrected in place, with
lane-c3.json's three unread stubs superseded by lane-g3.json's read entries.

Ship::RepairCost is unexercised on the corpus and that is a measurement,
not an absence (rule 20): the independent colony keeps a ten-ship fleet in
orbit over Koa'Vo on both reference pairs and its Sav closes exactly with
the demand taken as zero, which it could not do if any hull had a cost.

Engine side: sots-engine wip/growth ed6602e -- reference pair 78 -> 81
closed, 0 regressed; pair 2 36 -> 39 closed, 0 regressed.
2026-09-08 15:53:08 -04:00

33 KiB

Civilian population growth — the S11 sub-pass (lane G3, 2026-09-08)

ServerSystem::GrowCivilianPops 0x00754220 and its chain, plus Ship::RepairCost 0x00815180.

Evidence: own objdump -d pass over Sword of the Stars.exe via dumps/b6dis.py, every range disassembled to the next function start and the real boundary found (rule 17). Field names per findings/objects/struct-recovery.md. Everything marked instruction-verified is read off the instruction stream; everything marked inferred or hypothesis is not.


0. The headline

The brief said the growth pass is gated on imperial carrying capacity. It is not — not on this corpus, and the reason is worth stating before anything else:

On both reference pairs the civilian population of every growing colony moves by exactly +20,000,000, and 20,000,000 is POPTYPE[1] +0x08, an int64 literal in the executable (mov DWORD PTR [ecx+0x8], 0x1312d00 at 0x00535d20). It is the per-turn step cap on the whole system's civilian delta, and it is what decides the value. The uncapped delta is 150,000,005 — seven and a half times the cap — and the carrying-capacity headroom is 500,000,000, twenty-five times the cap. Neither the growth curve nor the capacity chain can change the answer without being wrong by more than an order of magnitude.

So the pass can be committed without a tuning table and without a verified carrying capacity, and the report says by how much each of them would have to be wrong before it mattered.


1. The call chain

addr name conv / boundary note
0x00754220 ServerSystem::GrowCivilianPops() thiscall, plain ret, real end 0x00754b59 loops group type 0,1,2 and does work only for 1
0x00748100 ServerSystem::PopGrowthDelta(int group, int species) → int64 thiscall ret 8, ends 0x007481b7
0x00537140 PopGrowthDelta(ServerPlayer*, int group, int species, float suit, float factor, int64 pop) → int64 cdecl, add esp,0x1c 7 dwords of args
0x00536fb0 PopGrowthFraction(ServerPlayer*, int group, int species, float suit, float factor) → float cdecl, ends 0x00537136
0x00536eb0 SuitabilityDistance(ServerPlayer*, int species, float suit) → float cdecl, ends 0x00536f8b re-read: §2.6a
0x0074a4a0 ServerSystem::MaxPopGeneric(int group, int species, ServerPlayer*, float* suitOverride) → int64 thiscall ret 0x10, real end 0x0074a6cd 0x0074a6d0 is a different function
0x0074a9a0 ServerSystem::CivilianSettleLimit(int species) → int64 thiscall ret 4, ends 0x0074aa2d min(idealMaxPop, dcs) — §2.3
0x00535e00 PopTypeRow(int t) cdecl table 0x00b104e8, stride 0x30
0x00535ca0 InitPopTypeTable() — re-read independently, §3
0x009abe20 PopTypeTableStaticInit() CRT static initialiser writes +0x28/+0x2c and nothing else does — §3.1
0x00536c80 Population::Count(int group, int species) → int64 thiscall ret 8
0x00539070 Population::SetCount(int group, int species, int64 n) thiscall the write-back
0x0074ec50 / 0x007502d0 civilian seeding helpers §2.5, unexercised
0x00815180 Ship::RepairCost(bool includeRefit) → int thiscall ret 4, real end 0x008151b7 §5
0x007460b0 SystemRepairDemandForOwner SystemShipCarriedPopIncome(ServerPlayer*) → int sys arrives in EBX not a repair function — §5.2

2. The formula (instruction-verified unless marked)

2.1 GrowCivilianPops — the shape

The function is a loop over t = 0, 1, 2 (inc esi; cmp esi,3; jl 0x7543e9 at 0x00754b2e — a back edge outside any decompiler if, and the reason this reads as straight-line code if you stop at the first ret). Two gates inside the loop head:

row = PopTypeRow(t)
if (Population::TotalOfType(sys->Pop2, t) <= 0) continue        // 0x00536c30
if (t != 1) continue                                            // `mov eax,esi; dec eax; jne`

so only civilians grow here; the imperial pass is inlined in ServerSystem::ProcessTurn and slaves are handled by ProcessSlaves.

2.2 The per-species pass

int64 applied[7]; bool hitLimit[7]; bool declined[7];           // all zeroed
int64 total = 0
for sp in 0..6:
    delta = PopGrowthDelta(sys, 1, sp)                          // 0x00748100
    cur   = Count(sys->Pop2, 1, sp) + Count(sys->pbon2, 1, sp)  // the PENDING BONUS COUNTS
    cap   = MaxPopGeneric(1, sp, sys->PID, NULL)                // the real-suitability capacity
    soft  = CivilianSettleLimit(sp)                             // 0x0074a9a0, §2.3
    if (soft < cap):
        limit = soft
        if (cur + delta > soft) hitLimit[sp] = true             // raises a morale event later
    else:
        limit = cap
    a = min(delta, limit - cur)
    if (sys->haltv[1] && a > 0) { a = 0; hitLimit[sp] = false }  // byte at sys+0x79
    applied[sp] = a
    total += a

Note cur includes pbon2, the pending civilian bonus pool — a colony with a pending pool is already treated as that large for the purpose of headroom.

2.3 CivilianSettleLimit(sp) — 0x0074a9a0

if (!sys->PID) return 0
idealSuit = StrategyServer::IdealSuit(sys->server, sp)          // 0x0080f4b0
A = MaxPopGeneric(1, sp, sys->PID, &idealSuit)                  // capacity AS IF perfectly suited
B = Population::Count(sys->dcs /*+0x104*/, 1, sp)
return min(A, B)

sys+0x104 is dcs (struct-recovery.md §1). On Gamma Cephei dcs is {type 1, species 0, count 1,000,000,000} and it is the binding limit there, not either capacity.

2.4 The system-wide clamp and the proportional rescale — the part that decides the value

maxStep = (int64) row->+0x08          // POPTYPE[1] = 20,000,000; see §3
clamped = min( max(total, -50,000,000), maxStep )      // -50,000,000 is a LITERAL here,
                                                       // not POPTYPE[0].maxStep
if (clamped != total):
    scale = (double)clamped / (double)total            // x87, 80-bit
    for sp in 0..6:
        if (total > maxStep):        if (!(applied[sp] > 0)) continue    // scale only gains
        else if (total < -50000000): if (!(applied[sp] < 0)) continue    // scale only losses
        applied[sp] = trunc( (double)applied[sp] * scale )
        hitLimit[sp] = false
for sp in 0..6:
    n = Count(sys->Pop2, 1, sp)
    if (n + applied[sp] < n) declined[sp] = true
    Population::SetCount(sys->Pop2, 1, sp, n + applied[sp])

Two things a reimplementation gets wrong by default:

  1. The clamp is on the system total, not per species, and the redistribution is a truncating proportional rescale — so the species shares do not have to add back up to clamped, and the original does not renormalise. With one species present they do add up, and that is the only case the corpus exercises.
  2. The write-back adds applied to Pop2 alone, while the headroom test used Pop2 + pbon2. So a colony with a pending bonus pool grows less than its Pop2 headroom would allow.

2.5 Seeding (unexercised — HYPOTHESIS)

The first loop of the pass (0x00754280..0x007543db, gated on sys->indi == 0) seeds species that are absent: for each sp with Count(Pop2,1,sp) + Count(pbon2,1,sp) == 0 and a positive settle limit, it plants min(limit, 1000) civilians — through 0x007502d0 with the two MORALE_DEFAULT_* slots (0x00aeca78 = 10000, 0x00aeca7c = 0) when the species is the system's own, and through 0x0074ec50 otherwise, keyed on bit 8 of ServerPlayer+0x348[sp]. No corpus save has an absent-then-seeded species, so every branch of this loop is a hypothesis. The workload that would decide it: a newly colonised system on the turn after the colony ship lands.

2.6 PopGrowthDelta and PopGrowthFraction

Re-read from the bytes this lane. Lane B4's model is confirmed on every rule, including the three that a plausible reimplementation gets wrong: the strict > 0 gate on each of the four multipliers, the float32 store-back after every one of them, and the delta == 0 && g > 0 -> 1 special case (0x005371b4). One addition B4's note does not carry:

factor = 1.0
if (sys->GFlags /*+0xdc*/ & (1 << owner->PlyrIdx /*+0x28*/))  factor = 1.5   // 0x00a1b000
if (owner->Species == 5) factor = ZuulHordeFactor(sys)                       // 0x0078f970

GFlags is struct-recovery.md's system flag word; the bit is per player index, and the factor it selects is 1.5 — a growth bonus, not a penalty. Every corpus system carries GFlags = 0 for the growing player, so this is unexercised and stays a hypothesis. The Zuul override at 0x0078f970 is also unexercised for civilians (Zuul have no civilian population).

2.6a SuitabilityDistance — 0x00536eb0, re-read

if (p == 0)          { log; return 20.0 }                 // 0.0 @0x00a1f6ec, 20.0 @0x00a1f6f0
if (p->RebAI /*+0xfc*/) return 0.0                        // this is B4's "accommodated"
d = float32( | float32( StrategyServer::IdealSuit(p->server /*+0x8*/, species)
                        - clamp(suit, 0.0, 20.0) ) | )
return min(d, p->SuitTol /*+0xb4*/)

B4's model is confirmed, with one thing its note does not say: the ideal is the SERVER's per-species baseline — the save's ISsu array — not the player's own IdealSuit field. They agree for every player of that species in all 11 saves, which is why the difference is instruction-verified only. Every owned colony in the corpus sits at Suit == ISsu[species] exactly, so d = 0, base = 1.0, and the growth exponent is unreachable.

POPULATION_GROWTH_MOD and _EXP are read through the pointer slots 0x00ae2e94 → 0x00ae2e90 and 0x00ae2e9c → 0x00ae2e98. The image defaults, read out of the file, are 1.0 and 2.0; the shipped data file overrides them to 1.2 and 1.85 (strategic-turn-internals.md §1).


3. InitPopTypeTable re-read — the six-register fxch rotation, independently

Lane N read this table by tracking six values rotated with fxch. Because the whole growth answer hangs off one of its columns, it was re-derived from scratch here. The initial stack is

st0 = *0x009e5840 = 2.0     st1 = *0x009e2ea0 = 0.5    st2 = *0x009f8d48 = 0.33f
st3 = 1.0                   st4 = *0x009e5ac0 = 0.25   st5 = 0.0

and the loop head fxch st(3) (0x00535cce, skipped on the first iteration) rotates it. Tracking that yields:

row +0 +4 growth +8/+0xc int64 step cap +0x10 out +0x14 income +0x18 +0x1c +0x20 capacity
0 imperial 0 1.0 50,000,000 1.0 1.0 1.0 1.0 1.0
1 civilian 1 0.25 20,000,000 0.33f 0.33f 1.0 0.5 2.0
2 slaves 2 0.0 0 SLAVES_OUTPUT_MOD SLAVES_INCOME_MOD SLAVES_REPAIR_MOD 0.0 0.0

Every value lane N published is reproduced, and lane N's live run had already confirmed four of them in the running process. The +8 column is confirmed twice over: it is a plain integer immediate (0x2faf080 / 0x1312d00 / edi) with +0xc always edi = 0, so it is an int64, and it is not a "max population" — it is the maximum step one turn may take, which is how it is used at 0x007545b5 and in ApplyImperialGrowth.

3.1 A correction to strategic-turn-internals.md §3.2

That section has cap = min(cap, groupdef[t].max @+0x28 if @+0x2c >= 0). The gate reading is right and the consequence was never stated: InitPopTypeTable never writes +0x24, +0x28 or +0x2c. They are written once, by the CRT static initialiser at 0x009abe20, to

row->+0x28 = 0xffffffff ; row->+0x2c = 0x7fffffff        // all three rows

i.e. the int64 INT64_MAX. So the group-max clamp is present, always enabled, and always a no-op. Read as "the table field is zero" — which is what a reader who only opens InitPopTypeTable will conclude — it would zero every carrying capacity in the game. This is the second time this campaign that a .bss table's real contents were not where the obvious initialiser is.


4. PREDICTION — written before the build

Baseline for this lane, measured on ~/sots-engine main = eafbc5f (not the db99971 the brief names; lane T2 merged in between and moved the reference pair well past the brief's numbers). Measured with tools/standalone_report.py before a line was written:

pair baseline after one turn closed regressed
turn1 -> turn2 209 131 78 0
turn2 -> turn3 108 72 36 0

The human's savings came out 289,092 against the oracle's 289,688 — the brief's 596 exactly.

4.1 What the model says the corpus does

Gamma Cephei (Sys[112], human, turn1-state.sav), every number off the wire: Size 10, Suit 11.106206893920898, Pop 1e9, pbon 1e9, Pop2 {1,0,5e8}, pbon2 empty, dcs {1,0,1e9}, GFlags 0, haltv[1] false, cm 75; owner IdealSuit 11.106206893920898 (exactly Suit), SuitTol 6.0, PopMod 1.0, harcc false.

  1. distance = min(|ideal - clamp(suit,0,20)|, tol) = 0, so base = 1 - 0/6 = 1.0 and pow(1, EXP) = 1.0 for every value of EXP — the exponent cannot matter here.
  2. g = float32(1.0 x MOD x 1.0 x 1.0 x 0.25). With the shipped MOD = 1.2 that is 0.30000001192092896; with the image default MOD = 1.0 it is 0.25; with no tuning table loaded at all MOD = 0, the strict > 0 gate skips the multiply, and it is also 0.25.
  3. delta = trunc(5e8 x g) = 150,000,005 (or 125,000,000 at g = 0.25).
  4. limit = min(cap, min(idealCap, dcs)) = min(2e9, min(2e9, 1e9)) = 1e9; headroom = 5e8.
  5. applied = min(delta, headroom) = delta; total = delta > 20,000,000 = maxStep.
  6. rescale: applied = trunc(delta x (20,000,000 / delta)) = 20,000,000.

So the answer is 20,000,000 under all three tuning tables and both plausible species factors. The prediction I am actually committing is the pair of numbers the oracle already holds: Sys[112]/Pop2/PopG/PopC 500000000 -> 520000000 and Sys[288]/Pop2/PopG/PopC 500000000 -> 520000000 on pair 1, and 520000000 -> 540000000 on both on pair 2.

4.2 The carrying capacity, measured rather than assumed

The imperial capacity at Gamma Cephei is pinned from the corpus, without the data files, by two independent behaviours of the same colony across three consecutive turns:

  • pbon = 1,000,000,000 never drains, and ApplyPopBonus returns early exactly when Pop >= MaxPop. Therefore MaxPop <= Pop = 1e9.
  • Pop never shrinks, and the imperial apply shrinks by up to 50,000,000 a turn exactly when pop > cap. Therefore MaxPop >= Pop = 1e9.

So imperial MaxPop = 1e9 = Size x 1e8 x POPTYPE[0].+0x20 (= 1.0), which forces speciesGrowthFactor(Human) x crossSpeciesMod x hazard == 1.0 exactly. hazard is 1.0 from the wire (Suit == IdealSuit) and crossSpeciesMod is not applied (owner species == population species), so speciesGrowthFactor(Human) = 1.0, measured. The civilian capacity is then 1e9 x POPTYPE[1].+0x20 (= 2.0) = 2e9, and dcs = 1e9 is what binds.

4.3 What I predict for the standalone

  1. Closed on pair 1: the two Pop2/PopG/PopC leaves, plus Player[16]/Sav. C3's §4.1 predicted our human Sav is short by exactly 597 on pair 1 and 596 on pair 2 for want of this growth; if that decomposition is right, committing growth closes Sav outright on both pairs. I predict 3 closed on pair 1 from S11 alone (2 population + 1 Sav) and the same 3 on pair 2, with PvSav on pair 2 a fourth only if S00's snapshot is modelled (it is not — S00 only bumps ModCount), so I predict PvSav does not close.
  2. 0 regressed on both pairs. The pass writes exactly one leaf per system (Pop2/PopG/PopC) and both of the systems it writes are already diverging.
  3. The AI's Sav does not close. Player 32's savings move by its own in-turn orders (lane B6: 11,900 leaves the treasury before phase 0), so growth cannot fix it.
  4. BnkEl/BnkPr do not close without --commit-blocked=T31 --ai-player ..., and T31 needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR for BnkPr. I will measure T31 separately and report it on its own line rather than folding it in.
  5. No new RNG words. The pass is draw-free (B4 swept it to depth 1); the ledger must not move.
  6. Ship::RepairCost closes nothing. No fleet sits at either growing colony (Sys[112]/NumFlts = 0 in turn1-state.sav), so the repair demand is 0 whether or not it is modelled. I predict wiring it moves 0 leaves and 0 money on all four corpus pairs, and its value is that it removes the last named unmodelled input from the output turn path rather than that it closes anything.

4.4 Falsification — how this could be wrong, and the symptom of each

way it could be wrong symptom
+0x08 is a capacity column and not a step cap, so the 20,000,000 coincidence is really min(cap, ...) landing there our delta would not be 20,000,000 on the second pair, where the colony starts at 520,000,000 — a capacity of 20,000,000 would make it shrink, a step cap makes it +20,000,000 again. Pair 2 is the discriminator and it is already in the corpus.
the rescale truncation lands one ulp low Pop2 comes out 519,999,999 and both population leaves stay open while Sav moves by a few money. This is the one genuinely precision-critical float and it is checked explicitly, §4.5
cur does not include pbon2 invisible here: pbon2 is empty on every corpus system. Hypothesis.
the headroom uses Pop2 alone and the write-back uses Pop2 + pbon2 (i.e. I have the two backwards) same — invisible, hypothesis
the clamp is per species rather than per system invisible: one species per colony in the whole corpus
the -50,000,000 floor is POPTYPE[0].+8 rather than a literal invisible: no colony shrinks
C3's 597/596 decomposition is wrong and Sav needs something else too the two population leaves close and Sav stays open with a smaller residual, which would name the remainder exactly
growth is committed but ComputeBudget reads a stale copy Sav does not move at all while the population leaves close

4.5 The float that decides the value (rule: rank precision by consequence)

Ranked by consequence, not by count:

  1. trunc( applied x (clamped / total) ) — the rescale. clamped = 20,000,000 is exactly representable; total = 150,000,005 is not a power of two, so fl(clamped/total) carries a relative error up to 2^-53 and the product's error, ~2.2e-9, is larger than half an ulp of 20,000,000 (1.9e-9). It is therefore not a theorem that the product rounds back to 20,000,000; it has to be checked. Computed both at 53-bit and at x87's 64-bit significand it comes out exactly 20,000,000 on both reference pairs, and a test pins it. One ulp the other way costs one person and moves the human's money.
  2. float32(g) — three narrowings, each after a strictly->0-gated multiply. Consequential only through step 3, and step 3 is 7.5x over the cap, so no value of g in [0.04, 1.0] changes the answer. g is the term everyone will look at first and it is the one that does not matter here.
  3. hazard and the capacity chain — a 25x margin. Irrelevant unless wrong by more than an order of magnitude.

5. Ship::RepairCost — 0x00815180 (instruction-verified)

__thiscall, ret 4, 56 bytes, real end 0x008151b7 (0x008151ba..0x008151bf is int3).

Ship::RepairCost(StarShip* sh, bool includeRefit):
    d = sh->DesID   /*+0x14, the ShipDesign POINTER*/
    c = sh->ConCap  /*+0x68, int*/
    if (includeRefit) c += d->+0xd0
    r = d->+0xcc - c
    return r > 0 ? r : 0

There is no floating point, no clamp other than the floor at zero, and no fleet or system input: it is a two-field integer subtraction on the ship and its design. d->+0xd0 is the field lane B6 found StarShip_Create copying into ConCap at birth, so a newly built, undamaged hull has ConCap == d->+0xd0 and RepairCost(true) = d->+0xcc - 2 x d->+0xd0; RepairCost(false) = d->+0xcc - d->+0xd0. Both are floored at zero. The identity of +0xcc and +0xd0 on ShipDesign is not settled by this lane and is the open item (§5.3).

5.1 Where it is consumed

ServerSystem::RepairShipsInOrbit 0x00751590 (lane C3 §2.4) calls it with includeRefit = 1 both to size the demand and, in the round robin, to size each ship's take. ComputeOutputFromRates passes estimateOnly = 0, so calling the original to harvest a repair number repairs ships — the unresolved B1 replace double-run defect. Nothing in the engine calls it.

5.2 out[6] is not a repair number at all — a correction to output-turn-path.md

out[6] was published as SystemRepairDemandForOwner 0x007460b0. Read to its callee, it is not a repair function. It takes the system in EBX, iterates the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship — and 0x0081f8c0

  • returns 0 unless design->+0xb8 & 0x04000000, the carried-population bit lane B6 named, and
  • then computes GroupIncome over the ship's own Population at ship+0x9c.

So out[6] is the income of population carried in slaver and colony hulls in orbit — which is a slot sots-engine's BudgetInputs already carries as shipCarriedPopIncome, under a name nobody had connected to it. It is reporting-only and reaches no save leaf, so nothing downstream moves; the name does. Corrected in place in output-turn-path.md §1, §2.1 and a new §5.

addresses.d/lane-c3.json's three stubs (Ship_RepairCost, Ship_ApplyRepair, SystemRepairDemandForOwner, all marked "body not read") are superseded by lane-g3.json's read entries, which carry a _G3 suffix so the duplicate-name check stays a hard error rather than a silent last-wins. The integrator should drop C3's three when folding.

5.3 What is not settled, and the workload

  • ShipDesign+0xcc and +0xd0 are read but not named. Ship::ApplyRepair 0x008151c0 settles half of it: it does ConCap += max(points, 0) and then clamps ConCap into [0, +0xcc], so ConCap is the construction invested in the hull so far — not a per-turn capacity, despite the save-format name — and +0xcc is its ceiling. +0xd0 is an allowance subtracted only when the caller asks for it. Naming the two needs the design catalogue's cached-stat block, which is lane B6's territory. Note also that ApplyRepair silently does nothing unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000, while the pass's candidate filter tests a different bit — so a ship can be charged points that never reach it. Read, not explained.
  • No corpus save produces a non-zero value — and that is a measurement, not an absence. Rule 20: a zero cost cannot be told from "never entered" by a count. Here the entry is observable from the wire. The human's Gamma Cephei has NumFlts = 0 on both reference pairs, so the demand is structurally zero there. The independent colony's Koa'Vo carries a fleet of ten ships in orbit on both pairs, and that player's Sav closes exactly with the demand taken as zero — which it could not do if any of those hulls had a positive cost. So Ship::RepairCost is entered on this corpus and returns 0, which is a much stronger negative than reachability. What is still unexercised is a hull with ConCap < +0xcc. The workload that would decide that: take a fleet into combat, lose structure, park it over a colony with an empty build queue, end a turn.

6. MEASURED

Engine work on sots-engine wip/growth off main eafbc5f. Gates run as separate commands: tools/clean_room_check.sh OK; host ctest 49/49; and — because src/game/sim/* is compiled into the shim — the CT111 cross-build was run by this lane (/srv/re-lab/build/sots-engine-g3, cmake --preset shim + --build, exit 0, binkw32.dll and sots_turn.exe linked). The host build and ctest were also re-run on CT111 (49/49) and tools/standalone_report.py produced identical numbers there and on the WSL host, which is a free check that nothing here depends on the toolchain.

6.1 Closed and regressed, never netted

pair baseline before this lane after closed regressed
turn1 -> turn2 209 131 (closed 78) 128 81 0
turn2 -> turn3 108 72 (closed 36) 69 39 0

So +3 closed on each pair, 0 regressed on each pair, by default with no flags. The three are the same on both pairs:

  • Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC — the human's colony;
  • Sys[288 "Ke'Dolarra"]/Pop2/PopG/PopC — the AI's;
  • Player[16 "re"]/Sav — the human's savings.

With --commit-blocked=T31 --ai-player 1 (reported separately, as a blocked phase should be): pair 1 closed 83, regressed 0; pair 2 closed 41, regressed 0. The extra two per pair are Player[16]/BnkEl and Player[32]/BnkEl. T31 used to close nothing with those same flags, for exactly the reason its own catalogue note gave — the limits move with the civilian population and that growth was not committed. BnkPr still needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR.

6.2 Every prediction in §4.3, checked

predicted measured verdict
the two Pop2/PopG/PopC leaves close on both pairs closed on both HELD
Player[16]/Sav closes on both pairs closed on pair 2 immediately; on pair 1 it came out 289,689 against 289,688 — one high FALSIFIED, and it found a defect: §6.3
3 closed per pair from S11 3 per pair — after §6.3 HELD
0 regressed on both pairs 0 and 0 HELD
PvSav does not close it does not HELD
the AI's Sav does not close it does not (its own in-turn orders move it) HELD
BnkEl/BnkPr need --commit-blocked=T31 BnkEl closes with it, BnkPr does not HELD
no new RNG words the ledger did not move HELD
Ship::RepairCost closes nothing 0 leaves, 0 money, all four pairs HELD
the +0x08 column is a step cap, not a ceiling pair 2 takes the same +20,000,000 step from a higher starting population, which a ceiling of 20,000,000 could not do HELD — and this was the falsification test §4.4 named

6.3 The falsified prediction, and what it found

Sav was one money high on pair 1 and exact on pair 2. That is not a growth error: growth closed 596 of the 596-money gap. The remaining one is ComputeBudget's savings interest.

Both interest rates in ComputeBudget 0x00863030 are widened float literals, not the exact decimals:

0x008631ce  fild  [savings] ; fmul QWORD PTR ds:0x009e31c0 ; call _ftol2   ; 0.009999999776482582 = (double)0.01f
0x008631b0  fild  [debt]    ; fmul QWORD PTR ds:0x009ed188 ; call _ftol2   ; 0.15000000596046448 = (double)0.15f

sots-engine's game/sim/economy.cpp used exact 0.01 and 0.15, so a treasury of exactly 50,000 earned 500 where the game pays trunc(50000 x 0.009999999776482582) = 499. Pair 2's treasury is 289,688, where the two literals agree, which is why only one of the two pairs showed it. Corrected, with the constants named in economy.h and sixteen hand-computed expectations in tests/game_sim/test_economy.cpp moved by exactly one — they were derived from the exact decimal, not measured, and are now derived from the literal.

Two things follow that matter beyond this lane:

  • A live-verified module was wrong. ComputeBudget compared 4,437 calls with 0 divergences. It did not catch this because — rule 15, and that run's own report says so — it presented only 20 distinct states and none of them sat on a rounding boundary. This is the second campaign example of rule 3: static reading found what behavioural comparison could not.
  • kBankruptcyInterestDivisor, which lane E1 already carries as -0.15000000596046448, is the same constant at the same address, negated. The campaign had half of this fact for a day.

6.4 What this run did NOT cover — read this before quoting the zero

  • One species per colony, everywhere. The proportional rescale of §2.4 is exercised only in its degenerate one-species form, where it is exact. Its truncation and its lack of renormalisation are read from the bytes and pinned by a unit test, not measured.
  • Nothing shrinks. The -50,000,000 decline floor, the negative-side rescale and the declined[] flag are all unexercised.
  • pbon2 is empty on every corpus system, so the asymmetry between the headroom (which counts the pending bonus pool) and the write-back (which does not) is invisible. If I have those two sides backwards, no corpus save can tell.
  • haltv[1] is false everywhere — the blockade gate never fires.
  • GFlags is 0 for the growing player everywhere — the 1.5x factor never fires.
  • No species is ever seeded (§2.5): the whole first loop of the pass is a hypothesis.
  • The morale-event half is not modelled. One species per turn (Koa'Vo's Tarkas) does hit its settle limit and the original raises a growth/decline morale event for it; we raise none. No corpus morale leaf moves, so this costs nothing today — but it is a known omission, not an absence, and it belongs to the events lane.
  • Imperial growth is still not committed. It is a no-op on this corpus (the homeworld sits exactly at its cap) and committing it would need a capacity the corpus can bound from below but not from above — see §4.2. Committing it would risk a regression to buy nothing.
  • The per-species civilian capacity factor is a data-file value taken as 1.0. The run reports the threshold: it would have to fall below 0.260 before any committed value changed, against a measured lower bound of 0.27 from the growth the oracle shows. The commit is additionally gated on the pass agreeing with itself when the modelled capacity is replaced by the system's own wire-known dcs limit.

6.5 The margin, reported by the run itself

civilian growth: 3 owned system(s) with civilians, 2 grew, 2 leaf write(s)
civilian growth: 2 system(s) had the value decided by the 20,000,000 per-turn step cap
civilian growth: smallest capacity headroom on any growing species: 500000000, against a step
                 cap of 20000000 -- a 25.0x margin
civilian growth: the per-species civilian capacity factor would have to fall below 0.260 before
                 any committed value changed
civilian growth: NO tuning table ... the curve's base is 1.0 and the exponent cannot matter
ship-repair demand taken as 0: 1 owned colony(ies) carry a fleet in orbit at all, 10 ship(s)
                 between them

7. Engine changes

  • src/game/sim/colony.{h,cpp} — GrowCivilianPopulations, ShipRepairCost, and the population-type table's own columns as named constants (kCivilianGrowthStepCap and friends).
  • src/game/sim/economy.{h,cpp} — the two interest literals (§6.3).
  • src/game/sim/species.h — growthFactor, with the corpus measurement for Human in the comment.
  • src/app/growth_phase.{h,cpp} — the wiring, the twice-run capacity-insensitivity gate and the margin reporting. Reported on its own line under S11 rather than folded into it.
  • src/app/turn.cpp — S11 runs it; S13 reports the repair-demand candidate set so the zero is evidenced rather than silent.
  • tests/game_sim/test_colony.cpp — six growth cases including both reference pairs, the step-cap-vs-ceiling discriminator, the zero-headroom colony, the shrink floor and the two-species rescale; plus ShipRepairCost.
  • tests/game_sim/test_economy.cpp — the sixteen corrected expectations.

8. Open, and named

  1. ShipDesign+0xcc and +0xd0 — read but not identified. Ship::ApplyRepair names +0xcc as the ceiling on the hull's invested construction; +0xd0 is an allowance subtracted only when the caller asks, and lane B6 has it copied into a new hull's ConCap at birth, which would make RepairCost(true) at birth +0xcc - 2 x +0xd0. One of those two readings is probably off by a field. The design cached-stat block is lane B6's territory.
  2. Ship::ApplyRepair's +0x400000 gate. The pass's candidate filter tests one bit and the apply tests another, so a ship can be charged points that never reach it. Read, not explained.
  3. The dcs Population — who writes it, and when. It is the binding civilian limit on every corpus colony and it does not move across three turns, so nothing in the corpus constrains its writer. Until that is known, "the settle limit is dcs" is a fact about the read and a hypothesis about the game.
  4. ServerPlayer+0x348[species], the per-species xenotech flag word. Bit 3 gates civilians existing at all, bit 7 gates the hazard modifier and bit 8 selects a cross-species capacity constant. It is not on the wire and is derived from the tech tree; the engine currently assumes a species with a population present passed the gate.
  5. The morale events the settle-limit and decline flags raise (ids 0x10 / 0x11) — structure read, deltas already tabulated in strategic-turn-internals.md §3.2, wiring not done.
  6. Imperial growth, which needs a capacity bounded from above. The workload that would give one: a colony below its cap that grows for two consecutive turns, which no corpus save has.