sots-re/findings/subsystems/formula-gaps.md
alex 0dff4941bd events: recover the player event-posting API (lane E)
Closes B3's oracle gap and B2's known gap. Both milestones write into the
owner's event list; nothing modelled it, so their clean compares bounded the
economy fields only.

Container: EventStorage embedded at ServerPlayer+0x29c (0x1c bytes), verified by
ServerPlayer::GetEventStorage 0x0080db00 whose whole body is 'lea eax,[ecx+0x29c];
ret'. EvNxID at +0x14 == player+0x2b0, exactly the byte run the harness guard
reports. The list is a vector<TurnEvents{int EvTurn; vector<PlayerEvent>}> --
bucketed by turn, not flat, which the save-editor struct note had wrong.

Entry point: int __thiscall EventStorage::PostEvent(this, std::string BY VALUE,
std::string BY VALUE, void* obj, Vector3* pos, int turn, const char* img,
int act), 0x008862b0, RET 0x4c. 161 call sites in 110 functions: this is the
whole simulation's event API, not a research helper. Dedup is per turn bucket on
message/image/location/position/action but NOT summary; act 0 with no subject and
no position is stored as 2; EvNxID starts at 0 and is promoted to 1 on first post;
PruneOldTurns drops buckets older than turn-50 with an off-by-one that always
leaves one stale bucket behind.

Record: 0x74 bytes, EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvLoc(+0x40) EvPos(+0x44)
EvImg(+0x50) EvAct(+0x6c) EvCID(+0x70) -- confirmed field by field against
turn3-state.sav, which contains the EVENT_RESEARCH_OVERBUDGET record itself.

B3's defect is fully explained: 0x00587b97, reached only from the
completion-roll-FAILED branch, under !wasDone && nowDone && owner.

Three note corrections: EvPos defaults to FLT_MAX, not infinity (writing +inf
changes the save bytes); the save array is turn-bucketed, not flat;
EVENT_TECHS_UNLOCKED has no 'parent researched' clause.

56 entries added to ghidra/addresses.json (header regenerated); 11 prototypes,
13 labels, 12 comments and 2 structs written back to the Ghidra project.
2026-09-08 02:47:11 -04:00

26 KiB
Raw Blame History

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/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 <tech> 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.