49 KiB
Strategic-layer turn internals — Sword of the Stars (2006), breadth pass
Program sots / "Sword of the Stars.exe" (ImageBase 0x00400000, MSVC-2010, 32-bit). Ghidra 12.0.4 headless.
Inputs: findings/control-flow/turn-spine.md, findings/objects/struct-recovery.md, findings/subsystems/data-model.md.
Scripts (CT111 /root/, copies in /srv/re-lab/handoff/scripts/): TurnDump.java (spine + direct callees),
TurnDump2.java (config-key → global map with g_<KEY> labels saved in the project, RNG class, species,
tech-name xrefs, float-constant table), TurnDump3.java (third-tier economy/colony), TurnDump4.java (.tech
parser). Raw decompiles with string-literal substitution: /srv/re-lab/handoff/turn-decompiles/{turn,turn2,turn3,turn4}/<addr>.c
(also constants.txt, floatconsts.txt, rng.txt, species.txt, technames.txt, techtable.txt, disasm.txt).
All addresses are VAs. Function names in Game:: are the ones already in the project; FUN_xxxxxxxx are still
unnamed (my proposed names are given in italics and were not written back — only the g_<KEY> data labels were).
Conventions used below: p = Game::ServerPlayer*, sys = Game::ServerSystem*, srv = Game::StrategyServer*,
ftol() = MSVC _ftol2 (FUN_00925220, truncation), ftoi64() = FUN_00925256, rand01() = Mars::RNG::NextFloat,
randint(n) = Mars::RNG::NextInt. Field names are the save-tag names from struct-recovery.md.
0. How data-file constants reach the code (applies to every section)
Every KEY value line of Data/Strategy/StrategyVars.txt, Data/globals.txt, Data/species.txt,
Data/encounters.txt is registered by a 32-byte static-initialiser stub in the still-undisassembled region
0x009ab000–0x009c1400:
push <parser> ; 0x008b7000 = int, 0x008b7020 = float, 0x008b70e0 = (angular/deg float), 0x008b7110 = colour "r g b"
push "KEY"
push &g_KEY ; the storage word (int or float) the code reads
mov ecx, &cfgvar_KEY ; a 16-byte ConfigVar object
call 0x008b76a0 ; ConfigVar::Register(name, &storage, parser) (proposed name)
The file loader later walks the registry and overwrites the storage words, so the data file is authoritative; the
image contains fallback defaults, several of which differ from the shipped file (image → file):
TRADE_SECTOR_SIZE 7.0→10.0, INDSYS_SYSTEM_ODDS 0.1→0.04, TRADE_RAID_ODDS_PLAYER 0.2→0.07,
POPULATION_GROWTH_MOD 1.0→1.2, POPULATION_GROWTH_EXP 2.0→1.85, MORALE_EVENT_HISTORY_MAXEVENTS 5→10,
DEFENCEFLEET_PLANET_RADIUS 5000→1800, DEFENCEFLEET_STATION_RADIUS 3000→1000, TRADE_FREIGHTER_*_BONUS_*
(50000/50000/25000/25000 → 50000/75000/25000/50000). A reimplementation must read the file, not trust "defaults".
Code reads the value through a pointer slot next to the storage (*PTR_g_KEY), e.g. *(int*)PTR_g_SYSTEMBONUS_MINTURNS_00aeca10.
Every storage word now carries a g_<KEY> label in the Ghidra project (≈600 labels), so any future decompile shows
which constant a function reads. The full key → storage → reader-function map is in
turn-decompiles/turn2/constants.txt; the strategic subset is folded into the tables of §1–§5.
Species enum (from FUN_0053b030 / FUN_0053b110, the name↔index switch used by StrategyServer::Read/Write):
0 Human, 1 Hiver, 2 Tarkas, 3 Liir, 4 _NPC, 5 Zuul, 6 Morrigi. Every per-species table in the sim
(Morale.int[7], adt[7], ServerPlayer+0x348[7], tech-edge avail[7], PrisonerHold counts) is indexed by this
7-value enum; index 4 is the NPC/independent race, which is why "species 4" gets no imperial growth and the
INDSYS_IMPERIAL_POPULATION_MOD cap (§3.2). (The .rdata name-table order Human..Morrigi is not the enum.)
1. Economy (per-player income → budget → savings)
1.1 Function map
| addr | name (existing / proposed) | role |
|---|---|---|
| 0x00891340 | Game::ServerPlayer::ProcessTurn |
per-player turn driver (order in 1.2) |
| 0x00863030 | ServerPlayer::ComputeBudget(int out[25], bool projected) |
THE income/expense formula; also builds the research allocation |
| 0x00817990 | SatAdd(a,b) |
saturating add clamped to ±2,000,000,000 — Sav = SatAdd(Sav, budget.net) |
| 0x00751fb0 / 0x007521c0 | ServerSystem::ComputeOutput(int out[12]) (projected / max) |
per-system output & income (see 3.3) |
| 0x00751bb0 | ServerSystem::ComputeOutputFromRates(out, rates[7]) |
the actual split formula |
| 0x0083a520 | ServerTradeManager::GetPlayerTradeIncome(p) |
Σ route incomes |
| 0x00833790 | TradeRoute::Income(route, asOwner) |
per-route income formula |
| 0x00819d20 | ServerSystem::NumTradeRoutesSupported |
ceil(civ/REQ_CIV)+ceil(imp/REQ_IMP), min 1 |
| 0x0086b300 | ServerTradeManager::ProcessTurn |
ProcessTurn phase 2 is trade, not diplomacy (freighter→route allocation) |
| 0x007adc80 | StrategyServer::RegisterTradeSystems |
phase 2b: every owned system whose owner has CnTrd registers NumTradeRoutesSupported routes |
| 0x00817f90 | ServerPlayer::ConstructionSpend(limit) |
Σ over build orders in state 1/2 of min(cost, ftol(order.+0x10 × …)) |
| 0x00840fe0 | ServerPlayer::ProcessSpecialProjects |
the spine's "income/savings (FUN_00840fe0)" is wrong — this is special projects ("SpecialProject: %s completed investigation phase…") |
| 0x0059b490 | StrategyServer::GetDifficultyMods(p) |
returns srv+0x10 for humans/NPC, srv+4 for AI (p+0xf9≠0 && !NPC): a 3-float table {maintenance divisor @+0, trade mult @+4, research mult @+8} |
| 0x00818600 | ServerPlayer::UpdateBankruptcyLimits |
BnkEl, BnkPr from Σ max system income |
| 0x0080db10 | ServerPlayer::BankruptcyLevel |
2 if Sav < BnkEl, else (1 if Sav < BnkPr, else 0 — tail not decompiled) |
| 0x007c0a50 | StrategyServer::ProcessBankruptcy |
warnings, cost cutting (0x00889500), elimination |
| 0x007d92a0 | StrategyServer::OnAllCombatDone_Tail |
the deferred end-of-turn tail (calls bankruptcy, ProcessNodeSpaceTravel, UpdateBankruptcyLimits, …) |
1.2 ServerPlayer::ProcessTurn order
ComputeBudget(this, &B, false);Sav = SatAdd(Sav, B.net).- zero per-turn accumulators (
(+0x3d8)->+0x10,+0xcc,+0xc8); if+0xf9→ConstructionSpend(AI path). ProcessSpecialProjects(0x00840fe0).- research (§2):
if (ResT && !RollResearchAccident()) TechTree::ProcessResearch(tree, ?, B.researchAlloc, &B.overbudget). TRA = TRM = TRP = 0,+0xe0 = +0xdc = 0(per-turn contributions consumed).RebAIplayers:RebOutMod = clamp(RebOutMod − 0.04, 1.0, 2.0)(0x00a17870/68/6c).PRvector{float PRm; int PRBt}:TRM += PRm; if (--PRBt <= 0) erase— timed research-multiplier bonuses.if (ResT && ResErrRoll && progress/cost > 0.5) { RollPlagueContainment (0x0088df20); ResErrRoll = false; }(0x00820380 logs "Plague:/Base containment/Resch containment/Aggression/Odds" — the research-error roll is the plague-cure research mechanic).- no
ResT: list available techs (0x00584e50); if none but some tech is state 2 → "no research" event. - 0x00863cf0 (special-project tail).
1.3 The budget (ComputeBudget, out[] indices as the code uses them)
[0] Sav
[5] savingsInterest = Sav >= 0 && ownsSystems ? ftol(Sav * 0.01) : 0 // 0x009e31c0 (double 0.01)
[10] debtInterest = Sav < 0 ? ftol(-Sav * 0.15) : 0 // 0x009ed188 (double 0.15)
for each owned system s with !s.Abdn:
inc = ComputeOutput(s)[3] (projected ? from current OutputRates : max) // money from the system
[1] += max(inc,0); [7] += max(-inc,0)
[2] tradeIncome = GetPlayerTradeIncome(p) // Σ TradeRoute::Income(r, owner)
[4] (srv->vft[2]())->vft[1](p) // second manager's income — unidentified (spy/trade partner share?)
[3] shipPopIncome = Σ fleets(p) Σ ships whose design.flags & 0x4000000 : Σ_type income of carried Population // slaver/colony ships
[8] maintenance = Maint / ftol(DifficultyMods(p)->+0)
[12] expenses = Σ Nexp.xmin + min( Σ clamp(ftol(?)-xmin, 0, xmax-xmin) , avail - Σxmin ) // Nexp = {xid,xmin,xmax,xper} entries (per-category expense sliders)
[15] avail = max(0, [1]+[2]+[3]+[4]+[5]+[6] - [7]-[8]-[9]-[10]-[11]-[12]-[13]-[14])
[11] construction = (p+0xf9==0 && avail>0) ? min(ConstructionSpend(p, avail), avail) : 0
[16] researchMoney = max(0, ftol(avail' * ResRate)) // ResRate = research slider (0..1), 0 when 'projected'
[17] researchPoints = ftol( DifficultyMods(p)->+8
* ((researchMoney / 50.0) * 1.15 * 0.5 * 0.85) // 0x009e2328, 0x00a1a4b8, 0x009e20a0, 0x00a2d818
* (ResMod + shrm + TRM) // player research multipliers (+0xc0,+0x160,+0xd0)
* p->+0x22c * srv->ResMod // tech multiplier (float @0x22c) × game-option research modifier
* ResScl ) // +0xc4
i.e. RP ≈ money × 0.009775 × (ResMod+shrm+TRM) × techMult × srv.ResMod × ResScl × difficulty
[18] = TRA, [20] = TRP, [21] totalRP = max(0, [17] + TRA + TRP) // TRA/TRP: per-turn RP contributions (trade research)
aid (vector<PlayerAid> @+0x310, 0x18 stride): pctRes = clamp(Σ entries with +0xc>0 of +0x8, 0..100), savAid = Σ entries with +0x14>0 of +0x10
[13] researchMoneyGiven = researchMoney * pctRes/100 ; [19] RPgiven = totalRP * pctRes/100 ; [21] -= [19]
if (ResT) researchAlloc.push_back({ResT, [21]}); [9] = researchMoney - [13]
[6] bonusIncome = ftol((p->+0x228 - 1.0) * remaining) // second tech multiplier (float @0x228) applied to remaining net
[14] savingsGiven = min(max(remaining,0), savAid)
net = [1]+[2]+[3]+[4]+[5]+[6] - [7]-[8]-[9]-[10]-[11]-[12]-[13]-[14]
Corrections from the B1 live trace (2026-09-08, sots-engine docs/B1.md, 4,623 traced +
4,437 compared calls on ref-turn2.sav):
- The out parameter is
int[22], notint[25]. It is aBudgetobject:{ int slot[22]; std::vector<{Tech* node, int points}> researchAlloc @+0x58; int overBudget @+0x64 }. The three words after slot 21 are the vector's{first, last, end}; they grow by exactly one 8-byte element for a player withResTand stay{0,0,0}otherwise. (What was read as "[22]..[24]" is that header.) [9] researchMoneyKeptis written inside theif (ResT)branch, together with theresearchAlloc.push_back. A player with no research target still gets[16] researchMoneyand[17]/[21]research points, but[9]stays 0 and the money is never subtracted from the net. Independent evidence: player 0 of the reference save has noResT, and its End Turn movesSav289,688 -> 532,369, i.e.+[1] 239,785 +[5] 2,896with nothing deducted.GetDifficultyModsrows, measured: AI (isAI && !NPC) = {maintenance divisor 3, research multiplier 1.5}; human/NPC = {1, 1} for this game (AI Normal, econ/research 100 %).[8] = Maint / ftol(divisor)confirmed at 500->166, 1000->333 (AI) and 1000->1000 (human row).p+0xf9is the AI gate as described: player 7 isisAI && NPCand takes the human row.
So the "budget sliders" are ResRate (research share of net) and the Nexp entries; construction is not a slider —
it is whatever the build queues demand up to avail. The UI pie chart (PIECHARTCOLOR_INCOME/COST_FLEETS/COST_RESEARCH/ COST_RESEARCH_AID/COST_FLEETS_QUEUED/COST_FLEETS_TOTAL) is drawn from this same array.
1.4 Trade (StrategyVars → code)
| key | reader | use |
|---|---|---|
TRADE_ROUTE_REQ_CIVPOPULATION, _IMPPOPULATION |
0x00819d20 | routes a system supports = ceil(civ/REQ_CIV) + ceil(imp/REQ_IMP), min 1 |
TRADE_ROUTE_STARTUP_TURNS, _STARTUP_INCOME |
0x00833790 | route younger than N turns pays STARTUP_INCOME flat |
TRADE_ROUTE_MIN_INCOME, _MAX_FREIGHTERS, _INCOME_PERFREIGHTER_{CRQ,CR,DE} |
0x00833790, 0x0081a3e0 | income = MIN_INCOME + Σ_class min(n_class, capLeft) × PERFREIGHTER[class], class 0=CRQ,1=CR,2=DE, capLeft starts at MAX_FREIGHTERS |
STATION_BONUS_TRADE_INCOME |
0x00833790 | × (1 + bonus × #trade stations at the system (0x00815b10 type 4)) |
ADDICTION_TRADE_MOD |
0x00833790 | × mod when the partner is addicted |
TRADE_ROUTE_OWNERS_SHARE |
0x00833790 | owner gets share, partner (1−share), clamped 0..1; AI multiplier DifficultyMods->+4 |
TRADE_ROUTE_MAX_FREIGHTERS, STATION_TRADE_NUMROUTES |
0x0086b300, 0x008209e0, 0x0083a5e0 | freighter allocation per turn; trade station adds NUMROUTES |
TRADE_ROUTE_FREIGHTER_WARNING_TURNS |
0x0088ad60 | warning event |
TRADE_RAID_ODDS_{PLAYER,NPC,REFUGEE}, TRADE_{MIN,MAX}_NPCRAIDERS |
0x00893290, 0x0088f070 | raid encounter generation |
TRADE_FREIGHTER_{CAPTURED,KILLED}_BONUS_{DE,CR} |
0x0080edd0, 0x0080ee00 | combat-result payouts |
TRADE_SECTOR_SIZE |
0x007c4140, 0x00841bf0, 0x008483c0, BuildTurnEvents |
sector partition of the map |
SLAVES_INCOME_MOD, _OUTPUT_MOD, _REPAIR_MOD |
0x00535ca0 | fills the 3-row PopulationGroup-type table (0 imperial, 1 civilian, 2 slaves) at 0x00b104e8 (48-byte rows: caps 50M/20M, mult columns) |
1.5 Bankruptcy
UpdateBankruptcyLimits(end-of-turn tail and on load):maxIncome = Σ_systems ComputeOutput_max(s)[3];BnkEl = clamp(ftol(f1(maxIncome))),BnkPr = max(BnkEl, −ftol(f2(maxIncome)))where one of f1/f2 multiplies byBANKRUPTCY_PROTECTION_LIMIT_FACTOR(3.3) — the decompiler dropped the FP ops; shape: the debt floor is −3.3 × maximum possible income (matches the data-file comment).BankruptcyLevel: 2 whenSav < BnkEl(elimination pending), 1 when below the protection limit, 0 otherwise.ProcessBankruptcy(0x007c0a50, every non-eliminated non-NPC player, in the deferred tail): level≠0 → cost-cutting 0x00889500 (cancel build orders 0x0082be30, scrap ships 0x00839b70/0x008574a0, eventsPTR_PTR_00af0a4c..), setsBnkWrn(state) /BnkTrn(turn it started); state 2 andModCount − BnkTrn ≥ BANKRUPTCY_ELIMINATION_TURNS→ eliminate (0x007bd930(p,1,0,0)) + event. Debt interest 15 %/turn (above) is the other pressure.
Confidence: HIGH on the budget line items, the research-points formula (verified in disassembly 0x008635b5–0x00863629),
trade income and route counts; MEDIUM on the meaning of [4], [6], Nexp, and the exact BnkEl/BnkPr expression.
Open: what sets TRA/TRP/TRM (trade research?) and p+0x228/+0x22c (searched: written by the tech callback; not traced).
Note for struct-recovery: ServerPlayer+0xf9 behaves as bIsAI (selects AI difficulty table, skips human resume),
not "bTurnDone".
2. Research
2.1 Function map
| addr | name | role |
|---|---|---|
| 0x005876c0 | Game::TechTree::ProcessResearch(tree, **Mars::RNG\* rng**, vector<{TechDef*, int points}>*, int* overbudget) |
progress + completion roll + decay + unlock events. B3: arg 2 is the server RNG object (StrategyServer+0x16c), confirmed at the single call site 0x008914a5; the trailing unlock-event loop makes no draw and writes no node |
| 0x0057da00 | TechTree::Cost(def) |
INT_MAX if none; else max(1, ftol(baseCost × CostMult(def))) |
| 0x0080db50 | ServerPlayer::TechCostMult(def) |
1.0 − 0.25 × n, n = how many of three species-table "research bonus" techs (speciesdef[6]+0x78/+0x7c/+0x80) the player owns that apply to def (0x0057d220) — floor 0.25 |
| 0x00581e10 | TechTree::SetResearched(def, flags) |
state 4, turn/order stamps, player callback vft+0x10(def, flags&4), unlock children |
| 0x0057d8e0 | TechTree::PrereqsMet(def+0x88) |
AND over groups of OR'd techs (each must contain a state-4 tech) — NOT a race roll |
| 0x005822d0 | TechTree::BuildForSpecies(species, ?, mode, allowedList, rootDef) |
the per-race availability roll, done once at tree creation |
| 0x00889dc0 | ServerPlayer::RollResearchAccident |
"ResearchBoost" lab accident |
| 0x0047d830 / 0x004271c0 | Mars::RNG::NextFloat / NextInt |
rolls (§6) |
| 0x0058b050 | TechDef::Parse (.tech brace block: allows, strategy, threat, requires) |
see 2.5 |
TechTree object: +0xc owner ServerPlayer*, +0x10/+0x14 vector<Node*> indexed by tech id, +0x20 research
order counter. Node (0x34 B, ctor in 0x005822d0): [0] TechDef*, +4..+0xc vector<Edge*> children,
+0x14 state (0 hidden, 1 parent researched, 2 available, 3 available and selected as the current research target, 4 researched — state 3 confirmed by the B3 live trace and it is why the decay loop, which tests == 2, skips the funded node), +0x18 costRP (0x7fffffff = none;
min over researched parents' edge cost), +0x1c progress, +0x20 turnAvailable, +0x24 turnResearched,
+0x28 order, +0x2c flag (1 default, 0 "completed early", 2 "over-budget notified"). Edge: +0x1c RP cost,
+0x20 + 4×species float availability (0..1), +0x40 child TechDef*.
2.2 Tree creation — the race probability
BuildForSpecies walks from the roots; for every allows edge:
p = edge.avail[species] // parsed from "Human:50 Zuul:0 …" as percent/100
include child if mode==2 (everything)
or (p > 0 && (mode==1 || p >= 1.0 || rand01() <= p)) // mode 0 = normal game
then recurses into the child. Default for a species not listed on the allows line: see 2.5 (turn4 result).
mode comes from the caller 0x00586600 (scenario/"all techs" option). So the famous per-game random tech tree
is a single pass of rand01() <= pct/100 per (edge, species) at game start; the strategic RNG is the server RNG (§6).
2.3 Per-turn progress (ProcessResearch)
for each {node, points} in alloc: // one entry: the current ResT with totalRP (from budget[21])
cost = Cost(node); lo = cost*50/100; hi = cost*150/100 (32-bit imul, /100 truncating; lo≥0, hi≥lo)
wasDone = cost <= node.progress
spend = min(points, hi - node.progress); *overbudget += points - spend // signed min, NOT floored at 0
node.progress += spend; nowDone = cost <= node.progress
if (node.progress < hi) {
if (spend == 0) { odds = 0; roll = 1 } // nothing spent → cannot complete
else { odds = (float)((progress - lo) / hi); // 0 at 50 %, 1/3 at 100 %, 2/3 at 150 %
roll = (float)(rand01() * (1 - eps) + eps); // eps 0x009e1e68 == 0.0, so roll = (float)rand01()
if (owner.Species == 5 /*Zuul*/) roll = min(roll, (float)rand01()); } // Zuul roll twice, keep the lower
} else { odds = 1; roll = 0 } // ≥150 % of cost: guaranteed
if (odds < roll) { // not complete
if (!wasDone && nowDone && owner) { EVENT_RESEARCH_OVERBUDGET; node.flag = 2 }
} else {
log "Research completed at %d of %d (%.1f%%). (Odds: %.2f, Roll: %.2f)" // all three ×100 (0x009e20e8)
if ((float)(progress/cost) < 0.8f) node.flag = 0 // 0x009e20c8 = (double)0.8f
SetResearched(node.def, 2)
}
for each node with state==2 && progress!=0: progress = max(0, progress - ftol(Cost(node)*0.05f)) // 0x009e5060 = (double)0.05f
// NB the funded node is state 3, so it is NOT in this sweep (B3 live trace; formula-gaps Q8 corrected)
for each node with state==2 && turnAvailable == currentTurn && parent researched → EVENT_TECHS_UNLOCKED (names list)
Net effect: the current tech gains spend − 5 %·cost; every other partially researched tech decays 5 % of its cost per turn.
2.4 Unlocking (SetResearched)
state←4, turnResearched = ModCount, order = tree.counter++, owner callback vft+0x10(def, flags>>2&1) (this is
where hard-coded tech effects are applied — see 2.6); for each child edge: child state 0→1, child.cost = min(child.cost, edge.cost); then every node in state 1 whose PrereqsMet(def+0x88) holds becomes state 2 with turnAvailable = ModCount; a state-2 node with Cost()==0 is researched immediately (recursive).
2.5 .tech parsing (TechDef::Parse 0x0058b050)
Handles name/family/type/threat/requires/allows/strategy/ship. allows "CHILD RP:cost Human:% …" → Edge with
cost and avail[species] = pct/100. Default for unlisted species: 1.0 (always available). The edge parser
FUN_00584160 allocates the 0x44-byte Edge with all seven avail[] floats (+0x20..+0x3b) preset to 0x3f800000
= 1.0 and cost (+0x1c) = 0, then overwrites only the species actually named on the allows line (name → index via
FUN_0053b110, value = pct/100). So a race omitted from allows is guaranteed the tech; an explicit Race:0
excludes it. Unknown child name → log "%s cannot allow %s. %s not found.". +0x3c = parent TechDef, +0x40 =
child TechDef.
strategy { inc/dec TECHBEN_X } is stored as a vector<std::string> on the TechDef.
2.6 TECHBEN_* — dead data; effects are hard-coded
Only one TECHBEN_ string exists in the executable: TECHBEN_STRATRANGE (0x00a1a924), compared by
0x00699bd0 (_stricmp over the TechDef's strategy-string vector). The data file spells it TECHBEN_STRATRNGE, so
it never matches; the other 22 benefit names (TECHBEN_INDOUTPUT, _HULLSTR, _SHIPCONCOST, _POPGROW, …) have
no consumer at all. The real strategic effects are keyed on tech names in code:
- a 116-entry tech-name pointer table at 0x00a19718 (
IND_Waldo, IND_RefCoat, IND_ImpRfCt, IND_PlyAlloy, IND_StlthArm, …; dump inturn3/techtable.txt) — index = internalTechIDenum; 0x006965c0 looks a name up by_stricmpover the 116 entries; - a second 196-entry table at 0x009ff9e4 (starts
CCC_AdvSens) resolved at load by 0x00581c10 into per-player TechDef pointers; - per-player species-tech flag words
ServerPlayer+0x348 .. +0x364(7 × uint32, one per species index) set by the research callback and tested all over the colony code: bit0/1/2 slave-death techs, bit3 civilian-growth permission, bit5 addiction immunity, bit7 hazard-immunity (no suitability penalty), bit8 "may seed civilians"; - explicit checks by name:
CCC_FCCom(0x005744e0),WEP_GrnLas/UvLas/XryLas(0x006962b0, 0x0069d2c0),IND_Waldo/IND_RefCoat(0x006965c0); and the readers ofTRKSTL_REGENERATION_MOD(0x007870d0),ADVSENS_SENSORS_MOD(0x0080b730, 0x0081e9d0, 0x008672c0),STATION_BONUS_*,FLOCKDRIVE_*. For a reimplementation: ignorestrategy{}blocks; port the effect table by tech name.
2.7 Lab accidents (RollResearchAccident)
Only when ResT is set and the research boost (extra funding, ftol(...) > 0) is non-zero:
roll = randint(100); odds computed from the boost; log "ResearchBoost: Rolling for lab accident. Rolled %d. Odds %d. %s";
no accident if roll ≥ odds (or the plague/containment path 0x00889d60 says so). Accident: if 0x0057d480 flags the
tech as catastrophic → applied to every system (0x0059f1a0 loop); otherwise progress loss
ceil(clamp(rand01()×(maxLoss−minLoss)+minLoss, 0..1) × 100) %, event EVENT_LABACCIDENT_{SMALL,MEDIUM,LARGE}
by def+0xac size class (0/1/2). Research skipped that turn.
Confidence: HIGH (progress/odds/decay verified against disassembly 0x00587846–0x005878f0 and 0x00587c20–0x00587c90; tree roll from 0x005822d0). MEDIUM: cost-multiplier trio identity, accident odds function.
3. Colonies (Game::ServerSystem::ProcessTurn 0x007598e0)
B4 CORRECTIONS (2026-09-08). This section was re-read instruction by instruction for the old-vs-new milestone; the full list is in
formula-gaps.md("B4"). The load-bearing ones:ProcessTurntakes no arguments (the decompile'svoid* streamis a Ghidra guess); the growth curve in §3.2 has nopop/capterm (the base is a suitability ratio); the secondSYSTEMBONUS_MINTURNSgate isntdev, notrbtn, and both bonus-apply helpers resetntdevon a non-home colony; the0x007514f0row's globals are wrong (it reads a hard-codedfloat 0.05fat 0x00aeca80 and takes four arguments); the addiction row is misleading (temperance still raises event 0x1b, and phase 2 raises nothing); the unowned decay constant is(double)0.02f; every capacity is quantised down to a multiple of 10; and the only RNG consumer in a colony turn isProcessRebellion—ProcessTurn,ProcessPlague,GrowCivilianPopsandProcessSlavesare all draw-free. §3.1's call table also omitsTRes = 0(+0x74) and thehaltv[0..2] = falseclear (+0x78).
3.1 Function map (in call order)
| addr | name / proposed | what |
|---|---|---|
| — | inline | unowned: Infra −= 0.02 (0x009e9170), floor 0 |
| 0x00746780 | ApplyInfraBonus |
Infra += min(ibon, 1−Infra), ibon −= same; non-home system resets rbtn |
| 0x0074b510 | ApplyPopBonus |
Pop += min(pbon, MaxPop(0,0) − Pop), pbon −= |
| 0x007514f0 ×2 | IndependentPopDrift(0→1 / 1→0, indsp, mod) |
independent systems: imperial↔civilian transfer (params DAT_00aeca78=10000, DAT_00aeca7c=0) |
| 0x0074ad90 | IsStable |
→ ntdev (turns developing) ++ / 0 |
| 0x0074d4f0 | AccrueSystemBonus |
SYSTEMBONUS_* (below) |
| 0x00756a90 | ProcessPlague |
outbreak/cure/colony destroyed (no constants) |
| 0x00752500 → 0x00890d50 | ProcessBuildQueue → BuildQueue::ProcessTurn |
3.4 |
| 0x0074b230 | GrowImperialPop |
3.2 |
| 0x00754220 | GrowCivilianPops + morale events |
3.2 |
| 0x00745f30 | AdjustResources(delta, mode) |
Res/ARes2/MRes (mode 3 = normal, 0 = AMine asteroid mining split) |
| 0x007463f0 | RefuelInOrbit(1) |
ships of fleets at the system in state 3 |
| — | inline | Bats2 / rcex 64-bit nibble arrays: per-player 4-bit countdowns (battle / recon cooldown) |
| 0x007537b0 | ProcessSlaves |
3.5 |
| 0x007583b0 | ProcessRebellion |
3.6 |
| — | inline | addiction: per species adt[i] start turn vs ADDICTION_PHASE2_START/PHASE3_START → morale events 0x1b/0x1c/0x1d (skipped if player flag bit5) |
3.2 Population
Carrying capacity (0x0074ab20 / 0x0074a4a0, species sp, group type t 0 imperial / 1 civilian / 2 slaves):
if sp == 4 → 0 // see open question
if !SpeciesCanLive(sp) (0x0053bb40) → 0
cap = ftoi64( Size × 1e8 × groupdef[t].+0x20 × SpeciesGrowthFactor(sp) (0x0053bb00)
× (owner species ≠ sp ? CrossSpeciesMod (0x0053bb20) : 1)
× HazardMod(Suit, IdealSuit(p), SuitTol) (0x00747ae0; 1.0 if player flag bit7 or RebAI) )
+ (p.harcc(+0x104) ? 1e8 (imperial) / 2e8 (civilian) : 0) // 0x0080dd30
cap = min(cap, groupdef[t].max @+0x28 if @+0x2c >= 0)
if owner species == 4: cap *= INDSYS_IMPERIAL_POPULATION_MOD
Growth per turn (0x00748100 → 0x00537140 → 0x00536fb0):
if haltv[t] (blockade) → 0 ; if system flagged in player's +0xdc mask → factor 0x00a1b000 instead of 1
g = clamp01( (1 − clamp01(Pop/cap)) ^ POPULATION_GROWTH_EXP )
if g > 0: g *= POPULATION_GROWTH_MOD ; g *= p.PopMod ; g *= hazard/species factor ; g *= groupdef[t].+4 (if >0)
delta = ftoi64(Pop × g) (min 1 if g>0), capped at 50,000,000
imperial: Pop = min(cap, Pop + delta); if cap < Pop: Pop −= min(50,000,000, Pop − cap), floor min(Pop,100)
Civilians (0x00754220): per species with +0x348 flag bit8 clear: if no group yet and cap > 0 → seed 1000 (own species
via 0x007502d0, others 0x0074ec50 with MORALE_DEFAULT_VALUE); growth as above per group; totals capped at
0xfffffffffd050f80-style guards; morale events 0x10 (growth) / 0x11 (decline) with the inlined event-id→delta table
(ids 1,3,7,0xe,0x12,0x21 → −5; 2,9,0x18,0x25 → −10; 4,5,0xa,0xb,0x23 → +10; 6,0x11,0x1d,0x26 → −2; 8,0x20 → +5;
0xc,0xd,0x14,0x24 → +2; 0xf,0x1a → −3; 0x10,0x19,0x1b → −1; 0x13,0x16,0x1c → +1; 0x15 → +3; 0x17 → −15; 0x1e → +30).
System bonus (0x0074d4f0; keys SYSTEMBONUS_*): when IsStable and ModCount − TAcq > MINTURNS and
rbtn > MINTURNS: pbon += min(POPBONUS_INC-derived amount, cap×POPBONUS(_HOME) − pbon), ibon += min(INFRABONUS_INC, INFRABONUS(_HOME) − ibon); applied next turn by ApplyPopBonus/ApplyInfraBonus. pbon2 is the civilian analogue.
3.3 Output → construction / infra / terraform / money (ComputeOutputFromRates 0x00751bb0)
rates = normalise(OutputRates{SRt trade, SRsc construction, SRtf terraform, SRi infra}) (0x00747390):
entries < 0x009e22c8 → 0; SRtf → 0 if Suit == IdealSuit; SRi → 0 if Infra + ibon ≥ 1; rescale to Σ=1 (all-zero → equal split)
total = round( BaseOutput × mods ) // 0x00750480:
BaseOutput from pop (0x007483b0: (pbon+Pop)/… resource-availability factor, capped by species +0x4c)
× morale (0x00746910: ≥MORALE_INCREASE_OUTPUT → ×MORALE_INCREASE_OUTPUT_MOD, ≤MORALE_DECREASE_OUTPUT → ×DECREASE_MOD)
× (1 + STATION_BONUS_IMPERIAL_OUTPUT × stations) (0x0074b7a0)
× ADDICTION_OUTPUT_MOD (phase ≥ 3)
× p.ScOutMod(+0x12c) × p.RebOutMod(+0x128) × p.+0x224 × sys.OutMod × p.OutMod(+0x124)
trade = round(total×SRt); cons = round(total×SRsc); terra = round(total×SRtf); infra = round(total×SRi)
resources: need = ftol(f(total)) + CivilianConsumption (0x0074c6f0: CIVILIAN_RESOURCES_CONSUMED × civ-share curve)
out[2] = min(need, Res [+ MRes + ARes2 if p.AMine]) → consumed by AdjustResources; out[1] = shortfall term
construction: out[7] = cons × (1 + STATION_BONUS_SHIPCON × stations) (0x00746830); out[8] = min(out[7], queue demand);
leftover → repair ships in orbit (0x00751590); out[9] = remaining
unspent leftover L split back over trade/terra/infra by rates (or 1 / (Suit≠ideal) / (Infra≠1) when SRsc==1)
infra: need = ceil((1 − Infra) / 3.3e-5); pool = infra + L_infra; spend = min(pool, need)
out[10] = (spend / 500) × 0.01 × 1.65 = spend × 3.3e-5 → Infra += out[10] (≈30 300 points for 0→1)
terraform: need = |Ideal − Suit| / (1.5 × 1.2 / 20000); pool = terra + L_terra + carry; spend = min
out[11] = spend × 1.5 × 1.2 × p.TerraMod × (Suit > Ideal ? −1 : 1) / 20000 → Suit += out[11] (0x00748270)
money: out[3] = ftol( Income(trade + L_trade) ) (0x007505b0: × species income factor (speciesdef+0x18) × p income mult (0x0080dd10) − p cost term (0x0080dd20))
TnsOH (turns over-harvesting) increments while SRoh > 0 && out[1] > 0 (ProcessBuildQueue).
3.4 Build queue (BuildQueue::ProcessTurn(queue, sys, points) 0x00890d50)
Orders are a std::list of ShipBuildOrder{desID, con, sav, conleft, ordID, ShipDesign*} processed FIFO:
if points < conleft { conleft −= points; break } else (if design.cost(+0xc0) > 0 the queue's owner must pay via
vft[9](sys, cost)) create the ship (0x004f41a0), attach to the system's fleet (0x0059f1a0), refuel/repair/initialise,
bump ShipRecords (per-class counters at p+0x1b4, per-design list at p+0x1e4), raise SEBuildCompleted with
the ShipBuildOrderDef, points −= conleft; conleft = 0; finished orders are unlinked afterwards. Ship-borne queues
(0x00789500) reuse it.
3.5 Slaves (ProcessSlaves; SLAVES_*)
rate = ( SRs × SLAVES_DEATH_RATE_BYOUTPUT + |IdealSuit − Suit| × SLAVES_DEATH_RATE_BYHAZARD + SLAVES_DEATH_RATE )
× mod, mod = (flag bit0 ? 0.8 : 1) − 0.2×bit1 − 0.2×bit2 // 0x0074b110; flags = p+0x348[sp]
deaths(sp) = clamp(ftoi64(slaves(sp) × rate), SLAVES_MIN_DEATHS, SLAVES_MAX_DEATHS (−1 = none))
when the last slave dies → EVENT_SLAVES_DEAD; pending slave-transfer object at sys+0x2c0 consumed.
SLAVES_MAX_PERSYSTEM is registered but has no code reader (dead key).
3.6 Rebellion / independence
ProcessRebellion: rbfl (bitmask of rebelling species) — 0x0074fbe0 computes rebels per species (units of 1e6),
0x00753c60 rolls new rebellions (per-species roll count local_f4 × 0x008e6dd0), events EVENT_SYSTEM_REBELLION_CONTINUES
with the same morale table; outcome 0 → 0x00756350 (suppressed), 1/2 → continues / 0x007522b0 (system lost).
Independence: 0x00748fa0 = unowned system with civilians isolated ≥ INDSYS_MINTURNS_ISOLATED turns may turn
independent; 0x0075fa10 drafts INDSYS_DRAFTED_PERCENTAGE of civilians into imperial pop; INDSYS_MIN_CHAOS_POPULATION
in 0x007b9df0 (abandon/chaos); INDSYS_SYSTEM_ODDS at map generation (0x007471b0); INDSYS_IMPERIAL_POPULATION_MOD in
the cap formula; INDSYS_SURRENDER_MAXIMPERIAL has no reader (dead key).
3.7 Morale
Morale = int[7] per species at sys+0x120 (default MORALE_DEFAULT_VALUE); colonies below
MORALE_DISABLED_POPULATION are pinned at MORALE_DISABLED_VALUE (0x00749460, 0x0075b5c0); history capped at
MORALE_EVENT_HISTORY_MAXEVENTS (MORALE_EVENT_HISTORY_MINTURNS has no reader). Output effect in 3.3.
Confidence: HIGH on the order of operations, growth curve, slave deaths, system bonus, infra/terraform point
conversions; MEDIUM on the money-income tail (0x007505b0 lost its FP chain) and civilian seeding rules.
Species index 4 (no imperial growth, cap × INDSYS_IMPERIAL_POPULATION_MOD) is _NPC — the independent/NPC race —
per the enum in §0 (resolved).
4. Movement
B4 CORRECTIONS (movement) (2026-09-08). Re-read instruction by instruction; full list in
formula-gaps.md("B4"). §4.2's bucketing is wrong — the schedule is a pursuit model (prey 0.5, pursuers 0.5 with an arrival retiring the pair, surviving prey 0.5, everything else 1.0 with an uncaught pursuer taking another 0.5, followers 1.0).range = MinRange + 0.05f(added, not subtracted), and the out-of-range case zeroes the range, not the step.movehas no floor at 0. The type-5 jump scatters the fleet around the destination byfloat32(roll x CstE)on a miss, at the cost of a second draw — it does not stop part-way along the vector — and it arrives on equality.IsNodeWaypointis true for type 3 only. The recursion threshold is(double)0.9999fand the test is strict.fleet+0xecisFPdpos, notFPogn2. Gate traffic is a signed int16 per fleet. The stutter overlap rule is not a midpoint (see Q7 informula-gaps.md), andSTUTTER_MIN_SPEED == STUTTER_MAX_SPEEDin the shipped data, so the ramp is a constant 0.33x.
4.1 Function map
| addr | name | role |
|---|---|---|
| 0x007da9a0 | StrategyServer::ProcessFleetMovement |
buckets fleets, several MoveFleet passes, gate traffic, OnFleetArrived |
| 0x007d9ee0 | StrategyServer::MoveFleet(fleet, dt) |
one movement step (recursive for leftover dt) |
| 0x00705510 | NodeLine::Step |
node-line travel with the "stutter" speed profile |
| 0x00702e20 | ServerNodeGraph::FindNodeLines |
candidate node lines from the fleet position |
| 0x007b6700 | ProbabilisticJump |
waypoint type 5 (uses CstE/CstT, RNG) |
| 0x006ff6a0 | StarFleet::MinRange |
min over ships of StarShip.Range |
| 0x0074d1c0 | ServerSystem::FleetDeparts |
removes fleet from system, ownership bits |
| 0x007a0e20 | ProcessNodeSpaceTravel |
node-space loss / multipoint checks (deferred tail) |
| 0x0056e6e0 / 0x0056e720 | IsGateTransitWaypoint / IsNodeWaypoint |
waypoint-type predicates |
4.2 ProcessFleetMovement
Fleets are sorted into std::sets by state (departing, in-transit with node route, arriving, other). Passes:
MoveFleet(f, 0.5) for set A; set B: 0x00706f10 + MoveFleet(f, 0.5) and arrival bookkeeping; MoveFleet(f, 0.5)
again for A; every fleet in no set → MoveFleet(f, 1.0); remaining set → MoveFleet(f, 1.0) (dt constants
0x009e2ea0 = 0.5, 0x3f800000 = 1.0). Then FPogn2 (fleet+0xec) := current waypoint's target position; per-player
gate traffic GTraf(+0x14c) = Σ fleet.+0xc0 (int16) over fleets whose current waypoint is a gate transit;
OnFleetArrived (EVENT_FLEET_ARRIVED); flag 0x100 ("moved this turn") cleared.
4.3 MoveFleet(fleet, dt)
if flag 0x100 or no waypoints → 0
dest = ResolveWaypoint(wpts[0]) (0x00701390) ; 0 → log "Destination of fleet doesn't exist. Stopping fleet."
if not departed yet (0x0078c9e0==0): for each ship still acting: log "Ship leaving %s is still doing %s. Cancelling action." + cancel (0x00849280); FleetDeparts(sys)
switch wpt.Tp:
5: ProbabilisticJump(fleet, dest): roll = rand01()×p.CstE; if roll > p.CstT → stop at fraction `roll` along the vector (event, pnd set) else arrive exactly
4: teleport: pos = dest.pos (gate)
2: node line: lines = FindNodeLines(fleet.pos, dest.pos, STUTTER_SYSTEM_INFLUENCE_RADIUS)
step = NodeLine::Step(…, nodespeed = FPsp2, dt): along the line
v = nodespeed × ((STUTTER_MAX_SPEED − STUTTER_MIN_SPEED) × (distToLineSystem / STUTTER_SYSTEM_INFLUENCE_RADIUS) + STUTTER_MIN_SPEED)
else: step = FPsp2 × dt // FPsp2 = fleet strategic speed (+0xd8)
range = MinRange(fleet) − 0.05 (0x00a1d2c0); if range < distance and MinRange(fleet)==0 → step 0 // out of fuel
move = min(step, range, distance); pos += dir × move (snap to dest when move == distance)
if !IsNodeWaypoint: (fraction used for recursion); for each ship without flag 0x1000: ship.Range −= move (floor 0); tankers (0x00703500/0x00829550/0x00854ad0) refuel
if pos == dest.pos: SEFleetArrived {playerId, fleetId, destId};
dest kind (+0x14): 0 system → 0x0074f240 enter, 0x007ac630, 0x007b4a30; more waypoints → continue via 0x00703730 (3 = blocked → stop) / 0x007d5080
1 fleet → 0x0074f240/0x007ac630/0x00706f10; 2 point → 0x0083a3b0, 0x007b4920
PrvPos = old pos if moved; if fraction < 0.9999 (0x00a261f0): MoveFleet(fleet, (1 − fraction) × dt) // multi-waypoint in one turn
Speed model: FPsp2 is set when the flight plan is built (ApplyTurnCommands/SetDestination, not decompiled this
round) from the design's engine section ftlspeed (straight-line races) or nodespeed (Human node lines; Hiver
gates are type 4 teleports plus PERGATETRAFFIC_* capacity), with Morrigi flock bonus FLOCKDRIVE_CLASSBONUS_{DE,CR,DN}
(+0.05/0.10/0.15 per class up to _SHIPLIMIT, × _GRAVSYN_MOD) applied in 0x006fe0e0 / 0x006ff370.
Fuel: StarShip.Range is remaining strategic range; fleets cannot overshoot the slowest tank; DefaultAutoRefuel
(game option) handles refuelling at friendly systems (0x007463f0). Node-space hazards (EVENT_LOSTINNODESPACE_NOBORE/ ENGINES, EVENT_FLEET_MULTIPOINT_NONODE) are evaluated in ProcessNodeSpaceTravel from the deferred tail.
Confidence: HIGH on the step/clamp/arrival logic and the stutter formula; MEDIUM on the pass bucketing semantics and
the identity of waypoint type 5 (a probabilistic jump using the player's CstE/CstT — consistent with the Zuul
node-bore / Morrigi gravity casting; CstR unused here).
5. Diplomacy and per-turn upkeep
5.1 What actually changes per turn
Diplomatic state is just PlayerAlliances {ALid, AL, NA, CF} at ServerPlayer+0x168..0x174 (alliance id + three
per-player bitmasks). It is changed only by ApplyTurnCommands (0x007b18b0): each player's 16-byte alliance record
from the SNMUpdate TurnCommands is diffed against the current one — leave alliance (ALid → −1, events
PTR_PTR_00aee290/2a0), join (PTR_PTR_00aee2b0; if the alliance now holds more than half the players a morale
event fires), NAP bit set/cleared and CF bit set/cleared per pair (events at 0x007b1cxx/0x007b1exx) — then the four
words are copied in. Nothing in StrategyServer::ProcessTurn itself is diplomacy: the spine's "phase 2 alliance/
diplomacy upkeep" (0x0086b300 / 0x007adc80) is trade (§1.4).
5.2 DiplomacyStats (vector at +0x230, one entry per other player, 0x24 B)
Updated by 0x007898c0 right after the alliance diff (called from ApplyTurnCommands):
- for every pair with a current relation (0x0080e050: 1 ally, 2 NAP, 3 cease-fire) the matching
last*int16 (lastally@+0x10,lastnap@+8,lastcf@+0x18) is set to the currentModCount; - for every pair whose relation was broken this turn (three input bitmask vectors: alliance-broken, NAP-broken,
CF-broken) the entry is fetched/created (0x008180e0) and, if the corresponding
last*equalsModCount − 1(treaty made last turn and broken immediately) the betrayal counterbty*/bkn*is incremented. deadhome(+0x20) is set elsewhere (home-system loss). 0x007acb50 dumps the "Alliances / Non Aggression / Cease fires" tables to the log. SoDiplomacyStatsis a ledger for the AI (consumed withData/Strategy/AI/stock_diplomacy_messages.csv); there is no attitude/relationship score in the sim.
5.3 Other per-turn upkeep in the deferred tail (OnAllCombatDone_Tail 0x007d92a0 → ProcessTurn tail)
ProcessAid 0x007ad100 (transfers from the aid vector, EVENT_GIVE_SAVINGS/RESEARCH, 100 % const 0x009e20e8),
ProcessSpecialProjects 0x007a3310, ProcessSurrenders 0x007d0d10 (EVENT_PLAYER_SURRENDERED_, EVENT_SYSTEM_SURRENDERED,
threshold 0.2 at 0x009e1fc0), ProcessBankruptcy (§1.5), UpdateBankruptcyLimits, ProcessNodeSpaceTravel,
encounter-result application (per-encounter 0x74-byte records; 0x007d7f70 is encounter detection, not diplomacy:
pairs of fleets at one location with relation 0 = war build the records), ProcessStations / ProcessDefenceSats
(scuttle events, STATION_MAINTENANCE_PENALTY in 0x00814c80, STATION_MINPOP_* in 0x00815ce0/0x00815da0/0x0084be30).
Confidence: HIGH on where diplomacy state lives and changes; MEDIUM on the exact DiplomacyStats field ↔ relation
mapping (index 8/0/4 → ally/nap/cf inferred from the int16 layout).
6. RNG (deterministic lockstep)
Mars::RNG (vftable 0x009e9aec = {dtor 0x004f7230, Read 0x008e6c30, Write 0x008e6ca0}) is a textbook MT19937:
| addr | proposed name | detail |
|---|---|---|
| 0x0049fdf0 | Mars::RNG::RNG(uint seed) |
mt[0]=seed; mt[i]=1812433253×(mt[i−1]^(mt[i−1]>>30))+i (0x6c078965), then twist |
| 0x0049cd20 | Mars::RNG::SeedFromClock |
seed from _time64/clock |
| 0x00426e00 | Mars::RNG::Twist (__fastcall this=&mt) |
N=624 (0x270), M=397 (0x18d), MATRIX_A 0x9908b0df, three-loop form; resets next=&mt[0], left=624 |
| 0x0047d830 | Mars::RNG::NextFloat |
y = *next++; left-- (twist when 0); tempering y^=y>>11; y^=(y<<7)&0x9d2c5680; y^=(y<<15)&0xefc60000; y^=y>>18; returns y × (1/(2^32−1)) ∈ [0,1] closed. CORRECTED 2026-09-08 (B3): 0x009e61b0 is 0x3df0000000001000 = 1/4294967295, not 2^−32 (0x009e61b8 = +2^32, the unsigned fix-up after a signed fild). The product is left in st(0) at the current x87 precision; every consumer narrows it to float32. |
| 0x004271c0 | Mars::RNG::NextInt(const uint* n) |
same tempering; mask = smallest 2^k−1 ≥ n (built from n, not n−1), redraw while masked > *n → uniform [0, n] inclusive. CORRECTED 2026-09-08 (B3): the bound is passed by pointer, and the range is closed, one value wider than stated here before. |
Layout: {vptr @0; uint32 mt[624] @+4; uint32* next @+0x9c4; int left @+0x9c8} = 0x9cc = 2508 bytes; the
NextFloat/NextInt methods take this = &mt (object+4) — that is why they index +0x9c0/+0x9c4. Twist takes
&mt too. (B3 confirms this layout instruction by instruction; note the address contract briefly carried the
two this conventions merged into one line, which puts left a word out — left is object+0x9c8 and &mt+0x9c4.) Serialisation
("RNG" tag in StrategyServer::Write, StreamableHelper<Mars::RNG>) writes 0x9c4 raw bytes = mt[624] + left;
Read restores both and recomputes next = &mt[624 − left] — the ~2500 B blob in saves is exactly this, so a
reimplementation must reproduce MT19937 bit-for-bit and consume the stream in the same order. The strategy sim
uses one instance at StrategyServer+0x16c (created in the ctor 0x007d78d0); every roll listed in this document
(tree creation, research completion, lab accident, plague, rebellion, jump scatter, encounters, raids) draws from it,
so given identical commands all lockstep peers stay in sync. The CRT rand() import exists but is not used by the sim.
Consumers of the twist (19 functions, turn2/rng.txt) give the full RNG API surface (e.g. 0x007c2fa0, 0x007a7f30,
0x0079f7d0, 0x007c4140, 0x007aa240 are StrategyServer-side users: map generation, encounters, raids).
7. Constant → code map (strategic keys; turn2/constants.txt has all ~600)
| key | storage | readers (addr) |
|---|---|---|
| BANKRUPTCY_ELIMINATION_TURNS | 0x00b23e24 | 0x007c0a50 ProcessBankruptcy |
| BANKRUPTCY_PROTECTION_LIMIT_FACTOR | 0x00b23e28 | 0x00818600 UpdateBankruptcyLimits |
| SLAVES_DEATH_RATE / _BYHAZARD / _BYOUTPUT | 0x00b21f84/88/8c | 0x0074b110 |
| SLAVES_MIN_DEATHS / MAX_DEATHS | 0x00b21f90/94 | ProcessSlaves |
| SLAVES_INCOME/OUTPUT/REPAIR_MOD | 0x00b0e9ac/b0/b4 | 0x00535ca0 (pop-type table), 0x007525c0 |
| SLAVES_MAX_PERSYSTEM | 0x00b0e9a8 | none |
| SYSTEMBONUS_MINTURNS / POPBONUS_INC / INFRABONUS_INC | 0x00aeca0c/34/3c | 0x0074d4f0 |
| SYSTEMBONUS_POPBONUS(_HOME) / INFRABONUS(_HOME) | 0x00aeca14/24 / 1c/2c | 0x0074c680, 0x0074f1e0 / 0x00747740, 0x007477a0 |
| POPULATION_GROWTH_MOD / _EXP | 0x00ae2e90/98 | 0x00536fb0 |
| CIVILIAN_RESOURCES_CONSUMED | 0x00ae2ea0 | 0x0074c6f0 |
| CIVILIAN_BURDEN_RATIO | 0x00aeca64 | 0x0057b6c0, 0x0074a6d0, 0x007fe470, 0x0080f570 |
| MORALE_* | 0x00aec758.. | 0x0074ec50, 0x0074fbe0, 0x0075bde0 (default), 0x00749460/0x0075b5c0/0x00792750 (disabled), 0x00746910 (output) |
| ADDICTION_PHASE2/3_START | 0x00aeca54/5c | ServerSystem::ProcessTurn, 0x00743b70, 0x00745e40 |
| ADDICTION_INCOME_MOD / OUTPUT_MOD / TRADE_MOD / SPREAD_ODDS | 0x00aeca44/4c, 0x00af0c54/4c | 0x0074b700+0x0074d760 / 0x00750480 / 0x00833790 / 0x0082cca0 |
| STATION_BONUS_IMPERIAL_OUTPUT / SHIPCON / RESEARCH / TRADE_INCOME | 0x00af08f0/e8, 0x00af0878, 0x00af0c3c | 0x0074b7a0 / 0x00746830 / 0x0081f930 / 0x00833790 |
| STATION_MAINTENANCE_PENALTY, STATION_TRADE_NUMROUTES, STATION_MINPOP_* | 0x00af0880, 0x00af0c44, 0x00af08f8/900 | 0x00814c80; 0x008209e0, 0x0083a5e0; 0x00815ce0/0x00815da0/0x0084be30 |
| TRADE_* | 0x00af0b90.. | see §1.4 |
| INDSYS_* | 0x00b21f80, 0x00aec6c4.. | §3.6 (SURRENDER_MAXIMPERIAL: none) |
| FLOCKDRIVE_* | 0x00b212b8.. | 0x006fe0e0, 0x006ff370 |
| STUTTER_SYSTEM_INFLUENCE_RADIUS / MIN_SPEED / MAX_SPEED (globals.txt) | 0x00b212cc/d0/d4 | 0x00705280, 0x00705510, 0x00705940, MoveFleet |
| SHIPSCUTTLE_*_OUTPUT_BOOST_FACTOR | 0x00b23e40.. | 0x00792480 |
| SYSTEM_MIN/MAX_ASTEROID/MISC_RESOURCES | 0x00b23e4c.. | 0x00788850, 0x00798040 (map gen) |
| TRKSTL_REGENERATION_MOD, ADVSENS_SENSORS_MOD | 0x00aeddd0, 0x00af0678 | 0x007870d0; 0x0080b730/0x0081e9d0/0x008672c0 |
| DEFENCEFLEET_PLANET/STATION_RADIUS | 0x00af05a0/a8 | 0x00437140, 0x007f9080 (combat placement) |
| STARMAP_NUMSYSTEMS_* | 0x00b23e34.. | 0x005ade80, 0x005bdae0, 0x00711fa0, 0x007171f0, 0x00719500/910/db0 |
| PERGATETRAFFIC_DRV_TpGate / GatAmp | (not resolved this round) | gate capacity vs GTraf |
| species.txt SENSORMOD_* | see constants.txt | sensor range |
Hard-coded literals worth knowing (.rdata): 0x009e1e68 = 0.0 (the "eps" everywhere), 0x009e1ef0 = 1.0,
0x009e20e8 = 100.0, 0x009e2328 = 50.0, 0x009e20a0 = 0.5, 0x00a1a4b8 = 1.15, 0x00a2d818 = 0.85,
0x009e20c8 = 0.8, 0x009e5060 = 0.05, 0x009e31c0 = 0.01, 0x009ed188 = 0.15, 0x009e9170 = 0.02,
0x00a1f930 = 3.3e-5, 0x009e2348 = 500, 0x00a1a448 = 0.01, 0x00a1f920 = 1.65, 0x009e90b8 = 1.5,
0x009e62b8 = 1.2, 0x00a0ffa8 = 20000, 0x009e5d70 = 0.25, 0x009e3030 = 0.8, 0x009e20d8 = 0.2,
0x00a1d2c0 = 0.05, 0x00a261f0 = 0.9999, 0x009e2ea0 = 0.5.
8. Corrections to earlier notes / open questions
turn-spine.md§2.4: phase 2 (0x0086b300/0x007adc80) is trade, not alliance/diplomacy upkeep; phase 8's "income/savings (FUN_00840fe0)" is special projects — income is 0x00863030 (ComputeBudget).turn-spine.md§2.4 step 12 / §1.1: 0x007d7f70 is encounter detection (pairs of co-located fleets whose relationFUN_0080e050== 0 (war) produce the 0x74-byte encounter records consumed by the combat round), not a per-team diplomacy pass.struct-recovery.md§2 /turn-spine.md§1.1:ServerPlayer+0xf9is the AI-controlled flag (bIsAI), not "bTurnDone_nonser": it selects the AI difficulty-modifier table inGetDifficultyMods(0x0059b490), gates the AI construction path inServerPlayer::ProcessTurn, and is why+0xf9==0players getStatus=1(resume) at turn end.struct-recovery.md: the species enum has 7 values (index 4 =_NPC), soSpecies5 = Zuul, 6 = Morrigi.data-model.md§4.1: thestrategy{TECHBEN_*}block is inert; race gating is rolled once at tree creation with unlisted races defaulting to 100 %.- Open: (a)
TRA/TRP/TRMandp+0x228/+0x22cproducers; (b) budget[4]/[6]semantics; (c)FPsp2derivation fromftlspeed/nodespeed; (d)PERGATETRAFFIC_*readers; (e) the money-income tail 0x007505b0; (f) lab-accident odds function; (g) exact BnkEl/BnkPr expression. - reva-server was stopped for each headless run and restarted after; no git commit was made; the only project
write-back is the
g_<KEY>labels (Save succeeded).