# Formula gaps from the game/sim port — answers from the binary Evidence: annotated disassembly/decompiles in `handoff/tech-decompiles/` (files named by address). `ftol` = `_ftol2` (0x00925220, truncation). Field names per `struct-recovery.md`. ## Q1. Which bankruptcy limit carries the 3.3 factor; when is the start turn stamped? `ServerPlayer::UpdateBankruptcyLimits` 0x00818600 (disasm 0x0081863e–0x0081869e): ``` maxIncome = Σ_owned systems ComputeOutputMax(s)[3] // FUN_007521c0, int BnkEl = ftol(maxIncome / -0.15) // FILD; FLD -0.15 (0x00a2ec30); FDIVR → -6.667 × maxIncome if (BnkEl < -2,000,000,000) BnkEl = -2,000,000,000 BnkPr = -ftol(maxIncome × BANKRUPTCY_PROTECTION_LIMIT_FACTOR) // FMUL [g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR] (3.3) if (BnkPr < BnkEl) BnkPr = BnkEl // i.e. BnkPr = max(-3.3·maxIncome, BnkEl) ``` So the **3.3 factor is on the protection limit `BnkPr`**; the elimination limit `BnkEl` is `maxIncome / −0.15` (the debt at which 15 %/turn interest equals maximum income). `BankruptcyLevel` (0x0080db10): 2 if `Sav < BnkEl`, 1 if `Sav < BnkPr`, else 0. Stamping: `ProcessBankruptcy` 0x007c0a50 per non-eliminated non-NPC player: `old = (BnkWrn, BnkTrn)`; `level = BankruptcyLevel()`; `SetBankruptcyState(level)` (0x0080e260) = `if (level != BnkWrn) { BnkWrn = level; BnkTrn = level ? ModCount : -1 }`; then decisions use **`old`**: cost-cutting (0x00889500) if `level != 0 && old.BnkWrn != 0`; elimination if `old.BnkWrn == 2 && ModCount − old.BnkTrn ≥ BANKRUPTCY_ELIMINATION_TURNS`. Net: the stamp is written on the turn the level changes (any change, 1↔2 included, resets it), and actions start the following turn. Call sequence: `OnAllCombatDone_Tail` → … → `ProcessBankruptcy` → (later in the tail) `UpdateBankruptcyLimits`, so the limits used by a turn's bankruptcy check are the ones computed at the end of the previous turn (and on load). ### Q1 addendum — lane PL, 2026-09-08: the two constants are stored at DIFFERENT WIDTHS The pseudo-code above is right about the shape and hides the thing that matters. The two constants of this one routine are loaded by two different opcodes: ``` DB 45 FC fild dword [ebp-4] ; maxIncome, left on the stack DD 05 30 EC A2 00 fld qword [0x00a2ec30] ; DD /0 = m64fp -- a DOUBLE D8 F9 fdivr st(0), st(1) E8 D2 CB 10 00 call _ftol2 ; -> BnkEl 3D 00 6C CA 88 cmp eax, -2000000000 ; the clamp 8B 0D DC DF AE 00 mov ecx, [0x00aedfdc] ; the loader's pointer slot D8 09 fmul dword [ecx] ; D8 /1 = m32fp -- a FLOAT32 E8 A5 CB 10 00 call _ftol2 F7 D8 neg eax ; -> BnkPr ``` * `0x00a2ec30` reads `00 00 00 40 33 33 C3 BF` = **-0.15000000596046448**, i.e. `(double)(float)-0.15f`. Already known (lane N) and already in the engine. * `g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR` at `0x00b23e28`, reached through the pointer slot `0x00aedfdc`, is read **`fmul dword ptr`**. So whatever decimal the data file carries for that key is **narrowed to a 4-byte float before the multiply**. Writing `-ftol(3.3 * m)` with a `double` 3.3 disagrees with the image at **every max income that is a multiple of ten**, starting at `maxIncome = 10` (decimal 33.0 truncates to 33; the image's 3.299999952316284 gives 32.99999952, truncating to 32). Measured over 230,000..250,000: 2,000 of 20,000 values disagree — exactly 10%. * The slot's file image is zero; the config loader fills it at run time, and `find-cross-references` shows exactly one read site in the whole binary (this one) plus the loader's own table entry at `0x009ba5df`. So the *width* is a binary fact and the *value* is a data-file fact. * **The corpus cannot see this.** All seven non-zero `BnkPr` records in the eleven saves invert to a max income where the two constants truncate the same. It is rule 23's exact shape: a difference that survives every observation we have and is still real. Fixed in `sots-engine` (`ComputeBankruptcyLimits` narrows through `F32`), which moved three hand-written test expectations and zero leaves. Addresses filed in `ghidra/addresses.d/lane-pl.json`. ### Q1 addendum — lane K, 2026-09-08 (instruction-verified) The call sequence above is now read off the instruction stream, with addresses: `ProcessBankruptcy` 0x007c0a50 is **phase 15** of `StrategyServer::OnAllCombatDone_Tail`, at 0x007d9731; per-player `UpdateBankruptcyLimits` 0x00818600 is **phase 31**, at 0x007d9876, 0x145 bytes later. Those two — and `LoadGame` 0x007ddc16 — are the only callers of `UpdateBankruptcyLimits` in the image. See `findings/control-flow/combat-done-tail.md` §4. Three corrections to the formula block above: 1. **The divisor is not `-0.15`.** The `.rdata` double at 0x00a2ec30 is `-0.15000000596046448`, i.e. `(double)(float)-0.15f`. Writing `-0.15` in a reimplementation is one ulp out on large empires. 2. **The per-system term is clamped at zero before summing.** `FUN_007521c0` (= `ServerSystem::ComputeMaxIncome`) ends `return max(ComputeOutputRates(sys)[3], 0)` — a `jg`, so a negative-income colony contributes 0 rather than reducing `maxIncome`. 3. **3.3 is not a binary constant.** `0x00aedfdc` points to `0x00b23e28`, which lies past `.data`'s raw end (0x00b07600) — it is `.bss`, zero-initialised and filled from the game data files at load. The only hard-coded constants here are the `-0.15000000596046448` double and the int32 `-2000000000` at 0x00a2c638. Treat 3.3 as DB-sourced, not as a fact about the binary. Also: there are exactly two clamps and both are one-sided lower bounds, so **a player with zero owned systems has `BnkEl == BnkPr == 0`** and `BankruptcyLevel` reads 2 for any negative savings. ## Q2 (order as asked: 3). Suitability → carrying-capacity hazard curve `HazardMod` 0x00747ae0: `clamp01(1 − |Suit − IdealSuit| / (SuitTol + 0.1))` (0x00a1a438 = 0.1). Linear, no exponent. `SuitTol` starts at the species value and is raised by BIO_AtmoAd (+0.75) and BIO_GrvAdpt (+1.5) (tech-effects §1). Skipped (=1.0) when species flag bit 7 (accommodate xenotech) or RebAI. ## Q3. Trade-points → money system-income tail `ServerSystem::TradePointsToMoney(trade)` 0x007505b0 (disasm complete): ``` t = (trade − fmod(trade, 5.0)) × 5.0 // whole 5-point blocks, ×5 t += PopIncome(0) // 0x0074d760(sys, 0): Σ_species ftol(GroupIncome(0, pop_imperial(sp))) (0x00535e80 group-type table row 0) t += PopIncome(1) // civilians (row 1); own species adds (cap − pop) surplus term when at cap t += SlaveIncome() // 0x0074b700: Σ_species ftol(GroupIncome(2, slaves(sp))) (row 2, SLAVES_INCOME_MOD) t *= SpeciesDef(owner).incomeFactor (+0x18: Zuul 1.1, Morrigi 0.8, else 1) [1 if unowned] t *= owner.IncMod (+0x30c) [1 if unowned] t *= srv.+0xbc × DifficultyMods(owner)->+4 // 0x0080f470: server income modifier × AI trade/income difficulty mult cost = SpeciesDef(owner).costFactor (+0x24: Zuul 0.7) × CalcSuitMod × 10000 × 1.5 CalcSuitMod (0x007484d0) = min(|IdealSuit(species) − Suit|, owner.SuitTol) (0 if RebAI; 20 if no owner) money = ftol(t − cost) ``` Note `SuitTol` therefore caps the hazard **cost** as well as extending the habitable range. `ADDICTION_INCOME_MOD` is applied inside PopIncome (0x0074d760 → 0x00746910 morale/addiction factor) — not verified line by line (MEDIUM). ### Q3 addendum — lane E1, 2026-09-08 (instruction-verified, and the block above is now superseded) The whole chain is read instruction by instruction in `findings/subsystems/income-term.md`, which replaces the MEDIUM-confidence sketch above. Five corrections: 1. **`TradePointsToMoney` returns a double, not an int.** The `ftol` is in the caller (`ComputeOutputFromRates`, `out[3] = _ftol2(...)`), not here. 2. **The block term is `(trade − fmod(trade,5)) × 5`** — the literal at 0x009e2398 is used twice, as the modulus *and* as the multiplier, so a whole five-point block is worth **25** money. 3. **`PopIncome` truncates twice per (group, species) row** — once inside `GroupIncome` (`ftol(typeIncomeMod × count/14000)`) and again after the morale and addiction factors. Summing the species and truncating once is wrong on any multi-species colony. 4. **`DifficultyMods(owner)->+4` resolves.** `ServerPlayer+0x36c` is an unnamed, unsaved pointer to a 0x1c record `{int id; float ai[3]; float other[3]}` filled by `LoadDifficultyRow` 0x005a3990 from a three-row table **built in code** by 0x005a3870 (`BuildDifficultyTable`) — no data-file key. The selector 0x0059b490 takes the AI triple iff `p->[0xf9] && !p->NPC`. Every corpus save carries `aidf = 1`, whose AI income column is **1.1f** — the multiplier the `BnkEl` oracle measured. The record's other two columns are a fleet-maintenance **divisor** and a **research** multiplier, not a trade multiplier as this note said. 5. **`CalcSuitMod`'s ideal is the SERVER's per-species array**, `server->IdealSuit[species]` (0x0080f4b0, raw base +0xf8), which is on the wire as the Sim block's `ISsp`/`ISsu` pairs — not the owner's `IdealSuit` field. The species is the system's population species (`indi->indsp` on an independent colony), and a `vnh` system pays no cost at all. ## Q4. `POPBONUS_INC` population increment `ServerSystem::AccrueSystemBonus` 0x0074d4f0 (disasm 0x0074d53b–0x0074d5be): ``` if owner && IsStable && ModCount − TAcq > SYSTEMBONUS_MINTURNS && ntdev > SYSTEMBONUS_MINTURNS: cap = MaxPop(imperial) // 0x0074ab20(0,0) target = SystemBonusPopTarget(POPBONUS) // 0x0074b5a0: ftol(max(POPBONUS,0) × cap), 0 for species with SpeciesDef+0x5c == 0 (Zuul) inc = ftol(POPBONUS_INC × cap) // FIMUL: 0.005 × cap pbon += min(max(inc, 0), max(target − pbon, 0)) ibon += min(max(INFRABONUS_INC, 0), max(INFRABONUS − ibon, 0)) // INFRABONUS target 0 for Zuul ``` (`POPBONUS_HOME`/`INFRABONUS_HOME` are only used when the bonus is (re)initialised for a home system, 0x0074c680/ 0x007477a0; the per-turn accrual always uses the non-HOME keys.) Applied next turn by `ApplyPopBonus` (`Pop += min(pbon, cap − Pop)`). ## Q5. Expense-slider request term `ComputeBudget` 0x00863030, loop 0x00863431–0x0086349f over `Nexp` entries `{xid, xmin, xmax, xper}` (16 B): ``` availPre = max(0, [6]−[14]−[13]−[11]−[9]−[7]+[3]+[1]−[12]−[8]−[10]+[5]+[4]+[2]) // net before expenses ([12] still 0) for each entry: xminC = max(xmin, 0) xmaxC = clamp(xmax, 0, 2e9); if (xmaxC == 0) xmaxC = 2e9 // 0 = unlimited room = xmaxC − xminC req = ftol(xper × (float)availPre) − xminC // FLD [entry+8]; FMUL ST1 (float(availPre)) take = min(max(req, 0), room) ΣXmin += xminC; ΣTake += take [12] += ΣXmin + min(max(ΣTake, 0), availPre − ΣXmin) ``` So `xper` is a fraction of the pre-expense available income, the request is `xper × avail` minus the mandatory minimum, clamped to `[0, xmax − xmin]`, and the total is capped by what is left after all minimums. ## Q6. Which running total the tech-income bonus and the savings aid read Disasm 0x0086382b–0x00863899: ``` net1 = [5]−[9]−[10]−[11]−[12]−[13]−[14]−[8]−[7]+[3]+[4]+[2]+[1]+[6] // [6] = 0, [14] = 0 at this point if (net1 > 0) [6] = max(0, ftol((p.+0x228 − 1.0) × net1)) // FLD [ESI+0x228]; FSUB 1.0; FIMUL net1 if (savAid != 0): net2 = same sum, now including the new [6] newSav = SatAdd(Sav, net2) // clamped ±2e9 [14] = min(max(newSav, 0), max(savAid, 0)) ``` The bonus is a share of the **full net** (all income incl. interest and trade, minus maintenance, research money, construction, expenses, research aid); savings aid is capped by **projected savings after this turn**, not by the turn net. `p.+0x228` (and `+0x224` = output multiplier read by `ComputeOutputFromRates`, `+0x22c` = research multiplier) are copied by 0x0077b620 from a per-player 0x50-byte setup record (`+0x48/+0x4c/+0x50`) at game creation/sync — the game-setup handicap block, not a tech (MEDIUM). ## Q7. Node-line speed clamp at the influence radius `NodeLine::Step` 0x00705510 + `BuildStutterSegments` 0x00705280: the travel line is intersected with every system's sphere of radius `STUTTER_SYSTEM_INFLUENCE_RADIUS` (0x008a64f0 ray/sphere → `[t0,t1] × len`, entries dropped when shorter than 0.01, sorted, overlaps merged at the midpoint). Per segment: ``` dist = DistPointToSegment(system.pos, segStart, segEnd) // 0x008e8eb0, t clamped to [0,1] v = nodespeed × ((STUTTER_MAX_SPEED − STUTTER_MIN_SPEED) × (dist / RADIUS) + STUTTER_MIN_SPEED) ``` There is **no explicit clamp**: `dist ≤ RADIUS` holds by construction (the segment lies inside the sphere), so `v ∈ [MIN, MAX] × nodespeed`; outside every sphere the remainder of the step moves at plain `nodespeed` (0x006fe3b0). Also note the speed is per **segment** (closest approach of the whole chord), not re-evaluated per position. ## Q8. Does `DecayAllResearch` also hit the current target? **No — corrected 2026-09-08 by the B3 live trace.** The loop reading is right: `TechTree::ProcessResearch` 0x005876c0, loop 0x00587c20–0x00587c90, for every node with `state == 2 && progress != 0`, `progress = max(0, progress − ftol(Cost(node) × 0.05f))`. What was wrong is the assumption that the funded node is in state 2. **The selected research target carries state 3**, so the equality test skips it and the current tech keeps its whole gain; only *idle* partially-researched techs decay. Observed directly in `b3-trace-golden.jsonl` / `b3-compare.jsonl`: the funded nodes (144, 142, 9) are state 3 before and after, and one traced tree's states are 164×0, 7×1, 23×2, 1×3, 22×4. Net gain of the current tech per turn is therefore `spend`, not `spend − 5 %·cost`. (State 3 is presumably "available and selected"; nothing in the traced runs had a state-2 node with non-zero progress, so the decay branch itself is still unexercised behaviourally.) ## Extras resolved on the way * `PERGATETRAFFIC_DRV_TpGate/GatAmp` readers: `OnTechResearched` ids 10018/10019 → `PrGtTrf = max(PrGtTrf, value)` (storage 0x00b23e2c / 0x00b23e30). * `TRKSTL_REGENERATION_MOD` path: 0x0079b980 → 0x0079b770 gated by `HasResearched(IND_TRKSTL)`. * Sensor range (system): `SENSORMOD[species] × (hadvs ? ADVSENS_SENSORS_MOD : 1) × 4.0` (0x0080b730). --- ## B3 (2026-09-08) — `ProcessResearch` re-read instruction by instruction Prompted by the old-vs-new milestone for `TechTree::ProcessResearch` (engine repo `docs/B3.md`). Evidence: own `objdump -d` pass over `Sword of the Stars.exe` at 0x005876c0, 0x0057da00, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0 and the single call site 0x008914a5. Everything below is now in `ghidra/addresses.json` (status `verified`) and folded into `strategic-turn-internals.md` §2.3 / §6, replacing what was there. * **`ProcessResearch`'s second argument is the `Mars::RNG` object** (`StrategyServer+0x16c`), which resolves the `?` in the prototype. The function re-bases it with `+4` before each draw. The allocation vector's element is `{TechDef* target, int points}` (stride 8) and the node is `tree->nodes[*(int*)target]`. * **`NextFloat` divides by `2^32 − 1`, not `2^32`.** 0x009e61b0 holds `0x3df0000000001000` = `1/4294967295`. The range is therefore closed at 1.0. Measured effect of the old mapping: a different float32 for 0.78 % of words, and a different research completion decision for about one draw in two billion — real, but not something a behavioural compare can catch. * **`NextInt` is inclusive and takes its bound by pointer.** Mask = smallest `2^k − 1` ≥ `*n` (from `n`, not `n−1`); redraw while the masked word is `> *n`. So the result is uniform on `[0, *n]`. Its status moves from `unverified` to `verified`. * **Generator layout.** Object = `{vftable @+0, mt[624] @+4, uint32* next @+0x9c4, int left @+0x9c8}` = 0x9cc bytes, but `Twist`/`NextFloat`/`NextInt` all receive `&mt` (object + 4), so in *their* frame `next`/`left` are at `+0x9c0`/`+0x9c4`. The save blob (0x9c4 bytes) is `mt[624]` then `left`, skipping `next` — which is fine because `next == &mt[624 − left]`. * **Rounding.** `odds`, the roll, the Zuul minimum and the `progress/cost` ratio are each stored to a 4-byte float before they are compared. The decay fraction (0x009e5060) and the early-completion threshold (0x009e20c8) are widened *float* literals — `0.05000000074505806` and `0.800000011920929` — not the exact decimals. * **Smaller corrections.** `spend = min(points, hi − progress)` is a plain signed min with no floor at 0; the 50/150 % bounds use a 32-bit multiply (it wraps near `INT_MAX`) and a truncating divide by 100; the decay guard is `progress != 0`, not `> 0`; `Cost` takes the **node**, not the def, and returns 0 (not 1) when `costRP <= 0`, when the tree has no owner, or when the cost multiplier is <= 0. * **`Cost` is read-only** (it only reads `costRP`, the def and the owner, then calls the read-only multiplier helper 0x0080db50), so a compare harness may call it on a scratch tree. * **Open.** The x87 precision-control field in force at run time is not decidable statically here (`_controlfp` is imported and there are ~370 `fldcw` sites, mostly CRT; a D3D9 device created without `FPU_PRESERVE` would leave 24-bit precision). It changes only the last bit of a draw (0.094 % of words) and of the odds. The B3 shim records the control word with every call, so the first trace settles it. ## B3 live verification (2026-09-08) — three more facts From the `ProcessResearch` trace/compare/replace runs (engine repo `docs/B3.md`; artefacts `/srv/re-lab/shim/traces/b3-*`): * **x87 precision control is 53-bit.** `fnstcw` inside the hooked call returns `0x127f` (PC = 10b = double, RC = nearest; bit 12 is the legacy infinity-control flag). At DLL init it is `0x027f`. So the FPU is *not* left in single precision by the D3D9 device, and the `NextFloat` product rounds to double before the caller narrows it to float32. The 24-bit contingency is moot. * **`ServerPlayer::OnTechResearched` can consume an RNG draw.** Two techs completed during the compare run; one cascade consumed no word and the other consumed exactly one more than the research arithmetic accounts for. The draw is inside the owner's tech-effect callback, not in `SetResearched` itself. Relevant to the B2 lane: at least one strategic effect rolls. * **`EVENT_RESEARCH_OVERBUDGET` is raised in the same branch that sets `node.flag = 2`**, with `EvDsc "Research Over Budget"`, `EvMsg "Research for has gone overbudget."`, `EvImg "EVENT_RESEARCH_OVERBUDGET"`, `EvAct 1`, `EvPos {FLT_MAX,FLT_MAX,FLT_MAX}`, and it bumps the player's `EvNxID`. A replace-mode run that sets only the flag differs from the oracle by exactly this one event and nothing else in 40,300 save items. **Corrected 2026-09-08 (lane E):** `EvPos` was recorded here as `{inf,inf,inf}`. It is **`FLT_MAX` (0x7f7fffff), not infinity** — the `PlayerEvent` constructor at 0x0084ee30 copies the `Vector3` global at 0x00af0dc8, whose bytes are `FF FF 7F 7F` x3, and the save carries 2139095039 in all three slots. Writing `+inf` (0x7f800000) would change the save bytes. Full API: `findings/subsystems/events.md`. * Tech-tree size in this game: **293 nodes** per player tree. --- ## B4 (2026-09-08) — colony turn + fleet movement re-read instruction by instruction Prompted by the old-vs-new milestone for `ServerSystem::ProcessTurn` and `MoveFleet`/`ProcessFleetMovement` (engine repo `docs/B4.md`). Evidence: own `objdump -d` pass over `Sword of the Stars.exe`. Every entry below is now in `ghidra/addresses.json` (status `verified`, 113 new entries) and the three target prototypes plus six helpers were written back into the Ghidra project. ### Prototypes (all three were `unverified`; all three are now pinned) * **`ServerSystem::ProcessTurn` 0x007598e0 takes NO arguments.** Plain `ret`, nothing reads `[ebp+8]`. `turn-decompiles/*/007598e0.c` shows a second parameter `void* stream`; that is a Ghidra guess and it is wrong. Hooking it as a one-argument function corrupts the stack. * **`MoveFleet` 0x007d9ee0 is `bool __thiscall (StrategyServer*, StarFleet*, float dt)`, `ret 8`.** `dt` is a 4-byte float (`fld DWORD`), pushed at every call site with `push ecx; fstp DWORD PTR [esp]`. Returns AL, and the caller tests it. * **`ProcessFleetMovement` 0x007da9a0 is `void __thiscall (StrategyServer*)`**, plain `ret`. ### Movement — where §4 was wrong * **Q7 revisited.** `BuildStutterSegments` does **not** merge overlaps at the midpoint. When `seg[i].end > seg[i+1].start` it sets *both* boundaries to `float32(end_i + 0.5 x (end_i − start_{i+1}))` — the mirror of the midpoint about `end_i`, pushed forward past both chords. Nothing is dropped and nothing is clipped back, so a chord swallowed by its predecessor comes out inverted (`start > end`). The chord parameters are also clamped to `[0, length]` before the drop test (the note omitted this; without it the ray/sphere routine's `±FLT_MAX` sentinels poison the list), the drop threshold is `fabs(start−end) <= 0.01f` (float32, inclusive), and the sort is a real `std::sort` on `start` alone. There is genuinely no clamp on `dist/RADIUS`, as the note said — but after the merge `dist <= RADIUS` is no longer guaranteed, so the ramp can exceed MAX. * **`STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33` in the shipped data** (radius 2), so the ramp collapses to a constant 0.33x inside any sphere and 1.0x outside. The image bytes for all three constants are zero (they live past `.data`'s file image), so a run that skips the config load divides by zero. * **`range = MinRange(fleet) + 0.05f`, not `− 0.05`.** 0x00a1d2c0 is `0x3d4ccccd` (sign bit clear) and `MinRange` *adds* its argument. The out-of-range case zeroes the **range**, not the step — `step` remains the divisor of the pass fraction. `move = min(min(range, step), distance)` with **no floor at 0**. `MinRange` seeds with `FLT_MAX` (an empty fleet is unconstrained) and skips no ship, so a range-exempt tanker still clamps the fleet. * **The probabilistic jump (type 5) scatters, it does not stop part-way.** `v = float32(roll x CstE)`; it arrives iff `!(v > CstT)` (equality arrives); on a miss the fleet is placed at `dest + randomUnitVector x v`, which costs a **second** raw draw. 1 word on success, 2 on a miss. `CstE` is `player+0x154`, `CstT` `player+0x158`. No fuel and no clamp on this path, and the pass fraction is 1.0 either way. * **`IsNodeWaypoint` (0x0056e720) is true for type 3 only**; the node-*line* case of the switch is type 2. `IsGateTransitWaypoint` (0x0056e6e0) is true for 4 and 5. Only a type-3 waypoint reports a partial pass fraction (`clamp01(distance/step)`); a blocked move reports `clamp01(move/step)`; everything else reports 1.0. * **The recursion threshold at 0x00a261f0 is an 8-byte double whose value is exactly `(double)0.9999f`** = 0.9998999834060669, and the test is strict, so `fraction == threshold` does not recurse. `dt' = float32((1 − fraction) x dt)`. * **§4.2's bucketing is wrong.** The schedule is a **pursuit model**: classify every fleet whose current waypoint targets another *fleet* by the owner-to-owner relation (0 = pursuer, else follower); prey move 0.5, pursuers move 0.5 and a pursuer that arrives retires itself and its prey, surviving prey take a second 0.5, everything unscheduled takes 1.0 (an uncaught pursuer takes another 0.5), followers take 1.0. One of the six local containers is never inserted into and its two guards are vacuous — it can be deleted from any reimplementation. * **`FPogn2` at `fleet+0xec` is really `FPdpos`** (FlightPlan+0x28); `FPogn2` is at `+0xe0`. The pass writes the current waypoint target's position there, with no waypoint-type and no entity-kind check, and clears flag `0x2` on every fleet in the same loop. Flag `0x100` is cleared for every fleet in a final loop. * **Gate traffic** sums a **signed int16** at `fleet+0xc0` over fleets whose *front* waypoint is a gate transit, into a fixed 32-int accumulator indexed by `player+0x28`, then **assigns** it to `player+0x14c` indexed by the player's *position* in the server's vector — the two agree only while `players[j]->index == j`. `ProcessFleetMovement` makes no RNG draw of its own. ### Colony — where §3 was wrong * **The population growth curve has no `pop/cap` term.** §3.2's `g = clamp01((1 − clamp01(pop/cap))^EXP)` is wrong: the capacity never enters the chain (0x00748100 → 0x00537140 → 0x00536fb0). The base is a suitability term, `1 − clamp01(min(|ideal − clamp(suit, 0, 20)|, SuitTol) / SuitTol)`, the exponent is clamped into `[0.01f, 1000]` before a real `__CIpow`, and every modifier after it is gated on a strict `> 0` and stored back to float32. `delta = trunc(pop x g)`, forced to 1 only when that truncates to 0 with `g > 0`. A zero `SuitTol` makes the quotient 0/0 and the NaN wipes the colony — a real hazard. * **The 50,000,000 cap and the 100 floor live in the apply (0x0074b230), not in the growth.** The over-cap shrink runs only when the colony was *already* over the cap and is computed from the **old** population; a colony that merely grows past the cap lands exactly on it. * **`AccrueSystemBonus`'s second gate is `ntdev` (+0x2c4), not `rbtn`** — §3.2 said `rbtn`; Q4 said `ntdev` and Q4 was right. Both bonus-apply helpers (0x00746780, 0x0074b510) **reset `ntdev` to 0** on a colony that is not the owner's home system (`player+0x2c`), which is a real feedback loop: a colony still absorbing a bonus never reaches the gate. `ApplyInfraBonus` snaps `Infra` to **exactly 1.0f** when the pool covers the remainder; `ApplyPopBonus` drops the whole pool on an unowned system; `pbon` (+0x194) is an **int32**. * **The unowned infra decay constant at 0x009e9170 is `(double)0.02f`** = 0.019999999552965164, not the decimal, and the floor is `Infra <= 0 → 0` (inclusive; NaN also collapses to 0). * **§3.1's `0x007514f0` row is wrong.** The globals are not `DAT_00aeca78`/`7c`; the only global read is **0x00aeca80, a hard-coded `float 0.05f`** with no GlobalConst key. The helper takes four arguments — `(0, 1, indi->indsp, 0.05f)` then `(1, 0, indi->indsp, 0.05f)`. * **§3.1's addiction row is misleading.** Player flag bit 5 (temperance) does not skip the block: it takes the other branch and still raises morale event `0x1b` (−1). Bit 5 *clear* runs the phase path: `0x1c` (+1) at onset, **nothing at all** in phase 2, `0x1d` (−2) at terminal. Both phase comparisons are strict `>`. `0x00752a10` is the MoraleEvent **constructor**; the apply is **0x00839d60**, and the delta travels in `ev->deltas[species]` (int[7] at ev+0x18), not as an argument. * **§3.1's call table omits `TRes = 0` (+0x74) and the `haltv[0..2] = false` clear (+0x78).** * **RNG, and this is the load-bearing one for any oracle:** `ProcessTurn` itself, `ProcessPlague`, `GrowCivilianPops` and `ProcessSlaves` are **all draw-free** (swept to call depth one). **`ProcessRebellion` is the sole consumer in a colony turn**, and its count is data-dependent: one `RandChance` per iteration of a 64-bit rebel counter (0x0074fbe0), a short-circuiting per-species roll loop (0x00753c60), and one outcome roll (0x00756350), plus one 0.2f continuation roll. `RNG::Chance` (0x008e6dd0) makes **no** draw for `p <= 0` or `p >= 1`. The generator is reached as `*(RNG**)((char*)sys->owner + 0x168)` — Ghidra's "StrategyServer+0x16c" is relative to the −4-adjusted base. * **`ServerSystem::MaxPop` 0x0074ab20 is `(ServerPlayer* p, int flag)`, not `(species, group)`.** Species comes from `p->Species` and the group type is hard-coded 0, which makes the cross-species and INDSYS branches dead in that specialisation. The result is clamped to `[0, INT32_MAX]`. **Every capacity is rounded down to a multiple of 10** by the shared helper 0x00535eb0, and `Size x 1e8` is an exact 64-bit *integer* product (the 1e8 is the immediate 0x05f5e100). The arcology bonus is 0 for slaves. * **`ComputeOutputFromRates` corrections.** `NormaliseOutputRates` (0x00747390) is `__cdecl` with a third *pinned channel* argument that every call site leaves null, selecting trade: only trade is clamped to `[0,1]`, the other three are summed in float32 (trade excluded) and rescaled to `1 − trade`, and the all-zero fallback is an equal split over **three** channels. The engine's "round" (0x008e5660) is `fistp`/`fild` — **ties to even** — while `out[0]`, `out[3]` and the construction points use the truncating `_ftol2`. `out[1]` is `min(out[2], stripMineDemand)`, not a shortfall; `out[9]` is the amount **spent** on repairs. **Unspent terraforming points cascade into the money channel** (an edge the notes missed entirely). The terraforming modifier is inside the *point count* (0x00746890), the sign is `-1` only for `suit > ideal` strictly, and the infra chain is `spend/500 x 0.01 x 1.65` in three 80-bit steps — not one `x3.3e-5`. **The function repairs damaged ships in orbit** (0x00751590 with its estimate flag clear), so it is not side-effect free and cannot be compared on a scratch system. * **`BuildQueue::ProcessTurn` 0x00890d50** takes `points` by value and **returns the leftover**. A money refusal **skips** that order and continues rather than stopping the pass; removal is a separate sweep afterwards that unlinks every order with `conleft <= 0`. * **Slaves.** The term order is `((|Δsuit| x BYHAZARD + DEATH_RATE) + SRs x BYOUTPUT) x mod`, every step narrowed to float32; an unowned system returns **1.0**, not 0; the worst plague at the system contributes an **additive** rate term; and `SLAVES_MIN/MAX_DEATHS` are each disabled by **any** negative value, with the result clamped into `[0, adjustedSlaveCount]`. * **`HazardMod`'s 0.1 (0x00a1a438) is a true double**, not `(double)0.1f`, and a zero band at the ideal returns NaN (which the capacity's `max(v,0)` then turns into 0). ### B4 LIVE (2026-09-08) — what the game confirmed, and three offset traps 36 calls compared, 0 divergences. Confirmations: * **`ProcessRebellion` really is the only RNG consumer in a colony turn.** Over 28 systems the generator's `left` did not move and the `mt[624]` hash was identical before and after, on every single record. The static sweep was right. * `fpu_cw = 0x127f` (53-bit precision) on every colony record. * `ServerSystem::ProcessTurn` really does take no arguments — hooked as a one-argument `__thiscall` for 28 calls per turn across three full runs with no corruption. * The movement step reproduced bit for bit on the one fleet that moved, and the pass schedule matched `PlanFleetMovement`'s prediction exactly (no pursuits in this save). **Offset traps, all three found live and all three now fixed in the contract:** 1. **The StrategyServer has two bases four bytes apart.** `ServerSystem+0x10` (`owner`) points at a base four bytes ABOVE the one the class's own methods get in ECX — 0x007437f0 does `owner - 4` before reading the generator. Every `StrategyServer_off_*` entry here is relative to the RAW (owner) base; a hook whose `this` is the method base must add 4 first. 2. `StrategyServer_off_Fleets` is **0x60** relative to the raw base (Ghidra `+0x64`). The earlier 0x64 made a hook enumerate the vector's spare capacity: `ProcessFleetMovement` reported 1 fleet in a turn where `MoveFleet` was called 7 times. 3. `off_EntityHash` 0x80, `off_ArrivedSet` 0x200, `off_InMotionSet` 0x210 — same −4 correction.