13 KiB
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?
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/GatAmpreaders:OnTechResearchedids 10018/10019 →PrGtTrf = max(PrGtTrf, value)(storage 0x00b23e2c / 0x00b23e30).TRKSTL_REGENERATION_MODpath: 0x0079b980 → 0x0079b770 gated byHasResearched(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 theMars::RNGobject (StrategyServer+0x16c), which resolves the?in the prototype. The function re-bases it with+4before each draw. The allocation vector's element is{TechDef* target, int points}(stride 8) and the node istree->nodes[*(int*)target].NextFloatdivides by2^32 − 1, not2^32. 0x009e61b0 holds0x3df0000000001000=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.NextIntis inclusive and takes its bound by pointer. Mask = smallest2^k − 1≥*n(fromn, notn−1); redraw while the masked word is> *n. So the result is uniform on[0, *n]. Its status moves fromunverifiedtoverified.- Generator layout. Object =
{vftable @+0, mt[624] @+4, uint32* next @+0x9c4, int left @+0x9c8}= 0x9cc bytes, butTwist/NextFloat/NextIntall receive&mt(object + 4), so in their framenext/leftare at+0x9c0/+0x9c4. The save blob (0x9c4 bytes) ismt[624]thenleft, skippingnext— which is fine becausenext == &mt[624 − left]. - Rounding.
odds, the roll, the Zuul minimum and theprogress/costratio 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.05000000074505806and0.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 nearINT_MAX) and a truncating divide by 100; the decay guard isprogress != 0, not> 0;Costtakes the node, not the def, and returns 0 (not 1) whencostRP <= 0, when the tree has no owner, or when the cost multiplier is <= 0. Costis read-only (it only readscostRP, 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 (
_controlfpis imported and there are ~370fldcwsites, mostly CRT; a D3D9 device created withoutFPU_PRESERVEwould 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.
fnstcwinside the hooked call returns0x127f(PC = 10b = double, RC = nearest; bit 12 is the legacy infinity-control flag). At DLL init it is0x027f. So the FPU is not left in single precision by the D3D9 device, and theNextFloatproduct rounds to double before the caller narrows it to float32. The 24-bit contingency is moot. ServerPlayer::OnTechResearchedcan 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 inSetResearcheditself. Relevant to the B2 lane: at least one strategic effect rolls.EVENT_RESEARCH_OVERBUDGETis raised in the same branch that setsnode.flag = 2, withEvDsc "Research Over Budget",EvMsg "Research for <tech> has gone overbudget.",EvImg "EVENT_RESEARCH_OVERBUDGET",EvAct 1,EvPos {inf,inf,inf}, and it bumps the player'sEvNxID. 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.- Tech-tree size in this game: 293 nodes per player tree.