# 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). ## 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). ## 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? Yes. `TechTree::ProcessResearch` 0x005876c0, loop 0x00587c20–0x00587c90: for **every** node with `state == 2 && progress != 0`: `progress = max(0, progress − ftol(Cost(node) × 0.05))`. The just-funded node is not excluded; it only escapes by completing (state 4) in the same call. Net gain of the current tech per turn is `spend − 5 %·cost`. ## 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.