From d4f94049605c37c5b5bfe9fe86a83dfd2f1e5bb2 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 15:53:08 -0400 Subject: [PATCH] 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. --- findings/subsystems/output-turn-path.md | 33 +- findings/subsystems/population-growth.md | 557 +++++++++++++++++++++++ ghidra/addresses.d/lane-g3.json | 140 ++++++ 3 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 findings/subsystems/population-growth.md create mode 100644 ghidra/addresses.d/lane-g3.json diff --git a/findings/subsystems/output-turn-path.md b/findings/subsystems/output-turn-path.md index ff4aa01..649a94f 100644 --- a/findings/subsystems/output-turn-path.md +++ b/findings/subsystems/output-turn-path.md @@ -42,7 +42,7 @@ This supersedes two labels in earlier notes: | 0x00925220 | `_ftol2` | — | truncating | | 0x009dd1e4 | `ceil` (MSVCR100) | IAT | | | 0x0083a5b0 | `TradeManager::SystemRouteIncome(mgr, sys, ServerPlayer*, int* a, int* b)` | `ret 0x10` | fills `out[4]`,`out[5]` | -| 0x007460b0 | `SystemRepairDemandForOwner(ServerPlayer*)` → int | **reads `ebx` as `sys`** | fills `out[6]` | +| 0x007460b0 | ~~`SystemRepairDemandForOwner`~~ **`SystemShipCarriedPopIncome(ServerPlayer*)`** → int | **reads `ebx` as `sys`** | fills `out[6]` — **not a repair function**, see §5 | Two non-standard conventions worth flagging (both would be silently wrong if taken as `__thiscall`): 0x0074c6f0 takes its `this` in **ESI** and 0x007460b0 takes the system in @@ -63,7 +63,8 @@ if (sys->[0x230] != 0) return // a byte; NOT on the wire mgr = server->vtbl[2]() // server = sys->+0x10, the RAW StrategyServer base TradeManager::SystemRouteIncome(mgr, sys, sys->PID, &a, &b) out[4] = a ; out[5] = b -out[6] = SystemRepairDemandForOwner(sys->PID) // 0x007460b0, `sys` arrives in EBX +out[6] = SystemShipCarriedPopIncome(sys->PID) // 0x007460b0, `sys` arrives in EBX + // NOT repair demand -- G3 correction, §5 ``` **`out[3]` — the money — is written entirely inside `ComputeOutputFromRates`.** Nothing after @@ -503,3 +504,31 @@ what stands between `BnkEl`/`BnkPr` and the oracle (four more leaves), and it is `Pop2/PopG/PopC` and `PvPop2/PopG/PopC` leaves that are already on the remaining list. One input — the imperial carrying capacity — gates all of it. +--- + +## 5. Correction (lane G3, 2026-09-08): `out[6]` is not a repair number + +`0x007460b0` was named `SystemRepairDemandForOwner` here on the strength of its position next +to the repair pass. Read to its callee it is **not a repair function at all**. The per-ship +summand it calls is `0x0081f8c0`, which + +* gates on the design's **carried-population** bit (`design->+0xb8 & 0x04000000`, the bit lane + B6 named), and +* computes `GroupIncome` over the ship's own `Population` at `ship+0x9c`. + +So `out[6]` is **the income of population carried in slaver/colony hulls in orbit** — a slot +`sots-engine`'s `BudgetInputs` already carries as `shipCarriedPopIncome`. It is reporting-only +and reaches no save leaf, so nothing downstream changes; the name does. + +The real per-ship repair term is `Ship::RepairCost` 0x00815180, which §2.4 correctly attributes +to `RepairShipsInOrbit`. Its body, and the one that names its two design fields +(`Ship::ApplyRepair` 0x008151c0), are read in +`findings/subsystems/population-growth.md` §5. `addresses.d/lane-c3.json`'s three stubs +(`Ship_RepairCost`, `Ship_ApplyRepair`, `SystemRepairDemandForOwner`) are superseded by +`lane-g3.json`'s read entries; the integrator should drop C3's when folding, rather than keep +both. + +§3's "one input of nine is missing" still stands, and the missing one is still the repair +demand — but its exposure is now **measured** rather than argued: the independent colony keeps a +fleet in orbit over Koa'Vo on both reference pairs and its `Sav` closes **exactly** with the +demand taken as zero, which cannot happen if any of those hulls carried a positive cost. diff --git a/findings/subsystems/population-growth.md b/findings/subsystems/population-growth.md new file mode 100644 index 0000000..6f0c615 --- /dev/null +++ b/findings/subsystems/population-growth.md @@ -0,0 +1,557 @@ +# 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. diff --git a/ghidra/addresses.d/lane-g3.json b/ghidra/addresses.d/lane-g3.json new file mode 100644 index 0000000..b77b04a --- /dev/null +++ b/ghidra/addresses.d/lane-g3.json @@ -0,0 +1,140 @@ +{ + "entries": [ + { + "name": "ServerSystem_GrowCivilianPops_G3", + "addr": "0x00754220", + "convention": "thiscall", + "prototype": "void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "ServerSystem_CivilianSettleLimit", + "addr": "0x0074a9a0", + "convention": "thiscall", + "prototype": "int64 (ServerSystem* sys, int species) // `ret 4`, ends 0x0074aa2d. Returns 0 without an owner. Otherwise min( MaxPopGeneric(1, sp, PID, &StrategyServer::IdealSuit(server, sp)) , Population::Count(sys->dcs /*+0x104*/, 1, sp) ). The first term is the capacity the colony would have if the planet were perfectly suited; the second is the system's own `dcs` Population, which is on the wire. On Gamma Cephei `dcs` = 1e9 and it is the binding limit, not either capacity", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "ServerSystem_MaxPopGeneric_G3", + "addr": "0x0074a4a0", + "convention": "thiscall", + "prototype": "int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "SpeciesDef_GroupCapacityFactor", + "addr": "0x0053bb00", + "convention": "thiscall", + "prototype": "float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x168 + groupType*4]. The per-species, per-population-group capacity factor -- a DATA FILE value. Its imperial entry for Human is pinned at exactly 1.0 by the corpus (Gamma Cephei's pbon never drains and its Pop never shrinks, which bracket the imperial capacity at Size x 1e8); the civilian entry is bounded below at 0.27 by the observed growth and is otherwise unmeasured", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "SpeciesDef_GroupCrossSpeciesMod", + "addr": "0x0053bb20", + "convention": "thiscall", + "prototype": "float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x174 + groupType*4]; applied by MaxPopGeneric only when the system owner's species differs from the population species and is not 4", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "SpeciesDef_HasPopulationGroup", + "addr": "0x0053bb40", + "convention": "thiscall", + "prototype": "bool (SpeciesDef* d, int groupType) // `ret 4`. Returns `0.0 < d->float[0x168 + groupType*4]` -- the SAME array SpeciesDef_GroupCapacityFactor reads, so 'this species has this population group' and 'its capacity factor is positive' are one fact. MaxPopGeneric asks it about the system OWNER's species, not the population's", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "PopGrowthSuitabilityDistance", + "addr": "0x00536eb0", + "convention": "cdecl", + "prototype": "float (ServerPlayer* p, int species, float suit) // ends 0x00536f8b. Returns 20.0 and logs when p is null; 0.0 when p->RebAI (+0xfc) -- the 'accommodated' case; otherwise min( float32(|float32(StrategyServer::IdealSuit(p->server /*+0x8*/, species) - clamp(suit, 0, 20))|) , p->SuitTol /*+0xb4*/ ). The ideal is the SERVER's per-species baseline (the save's ISsu array), NOT the player's own IdealSuit field", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "PopGrowthDeltaHelper", + "addr": "0x00537140", + "convention": "cdecl", + "prototype": "int64 (ServerPlayer* p, int groupType, int species, float suit, float factor, int64 pop) // `add esp,0x1c`, 7 dwords of arguments, ends 0x005371ca. Returns 0 unless pop > 0; g = PopGrowthFraction(p, groupType, species, suit, factor); returns 0 when g == 0 (an fucomp against zero, so a NaN g also proceeds); d = ftoi64(g x pop); returns d, or exactly 1 when d == 0 and g is strictly positive, or 0 when d == 0 and g is negative", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "PopTypeTableStaticInit", + "addr": "0x009abe20", + "convention": "cdecl", + "prototype": "void () // the CRT static initialiser for the population-type table at 0x00b104e8, and THE ONLY WRITER of the group-ceiling field. It sets every row's +0x28 to 0xffffffff and +0x2c to 0x7fffffff -- the int64 INT64_MAX -- along with +0x00/+0x04/+0x08/+0x0c to -1/1.0/-1/-1 and the float columns to 1.0. InitPopTypeTable 0x00535ca0 runs later and overwrites +0x00 through +0x20 but NEVER touches +0x24/+0x28/+0x2c. So MaxPopGeneric's group-ceiling clamp is present, always enabled, and always a no-op; a reader who opens only InitPopTypeTable sees zero there and would cap every carrying capacity in the game at nothing", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "Ship_RepairCost_G3", + "addr": "0x00815180", + "convention": "thiscall", + "prototype": "int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "Ship_ApplyRepair_G3", + "addr": "0x008151c0", + "convention": "thiscall", + "prototype": "void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "ServerSystem_ShipCarriedPopIncome", + "addr": "0x007460b0", + "convention": "cdecl-through-register", + "prototype": "int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks 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 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "ShipCarriedPopIncomePerShip", + "addr": "0x0081f8c0", + "convention": "thiscall", + "prototype": "int (StarShip* sh) // returns 0 unless design->+0xb8 & 0x04000000 (the carried-population bit lane B6 named); otherwise sums GroupIncome over the ship's `hsp` Population at ship+0x9c for group types 0 and 1. Body read to the second GroupIncome call only", + "status": "unverified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "g_SavingsInterestRate", + "addr": "0x009e31c0", + "convention": "data", + "prototype": "double 0.009999999776482582 = (double)0.01f -- the savings-interest multiplier ComputeBudget uses at 0x008631ce, then truncated by _ftol2. It is a WIDENED FLOAT, not the exact decimal, and the difference is not cosmetic: a treasury of exactly 50,000 earns 499, not 500. That one money was the whole residual on the human's Sav after civilian growth landed", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "g_DebtInterestRate", + "addr": "0x009ed188", + "convention": "data", + "prototype": "double 0.15000000596046448 = (double)0.15f -- the debt-interest multiplier ComputeBudget uses at 0x008631b0. The same constant lane E1 already carries, negated, as the bankruptcy-elimination divisor", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "g_FlaggedSystemGrowthFactor", + "addr": "0x00a1b000", + "convention": "data", + "prototype": "float 1.5 -- the per-call growth factor PopGrowthDelta 0x00748100 substitutes for 1.0 when the system's GFlags (+0xdc) carries the owner's PlyrIdx bit. It is a BONUS, not a penalty. UNEXERCISED: GFlags is 0 for the growing player on every corpus system", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + }, + { + "name": "g_PopTypeCivilianGrowthMod", + "addr": "0x009e5ac0", + "convention": "data", + "prototype": "float 0.25 -- POPTYPE[1]+0x04, the civilian growth column, read out of InitPopTypeTable's six-register fxch rotation. Independently re-derived this lane; lane N's whole table is reproduced (imperial +0x04 = 1.0 from 0x009e2ea0's neighbours, civilian +0x10/+0x14 = 0.33f from 0x009f8d48, civilian +0x1c = 0.5, civilian +0x20 = 2.0 from 0x009e5840)", + "status": "verified", + "source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)" + } + ] +}