sots-re/findings/subsystems/strategic-turn-internals.md
alex 49fa5501ba lane T2: the treaty-turn stamp, and Player.Status's real writer
findings/subsystems/treaty-turn-stamp.md. Briefed on the post-combat tail, ranked its
phases by leaves-closed-per-effort against the standalone's 158-leaf residual, and found
that every tail phase which moves a leaf in this corpus is blocked on another lane
(ship construction, the budget) or on the tail's vtable blind spot. Section 5 lists that
ranking with the numbers. The two items below are what a lane holding no VM could close.

1. StrategyServer_StampTreatyTurns 0x007898c0, read as instructions. The diplomacy
   ledger's per-turn stamp over every ordered pair of players holding a treaty, creating
   the entry on demand. Sole caller is ApplyTurnCommands, so it is a HOST step and not a
   tail phase. Three corrections to strategic-turn-internals.md 5.2, which is flagged in
   place: the relation codes are 3=ally / 2=NAP / 1=cease-fire and not the reverse (which
   combat-done-tail.md 2A already implied independently); the stamped value is Frame, not
   ModCount; and the bit is the PlyrIdx field, not the player's vector position -- the
   opposite convention from the shared-vision mask.

   Modelled in sots-engine as host phase H02. Predicted 26 leaves on the reference pair
   and 14 on pair 2 before building; measured exactly 26 and 14, 0 regressed, and +12 on
   each of three further pairs from a different game at turns 2, 15 and 16 that the model
   was never fitted to. 76 closed / 0 regressed over five pairs, 0 RNG words. The rule
   also reproduces the ledger of ten of the eleven corpus saves entry for entry.

2. Player.Status. backlog.md item 6 has it blocked on "the writer between tail 31 and the
   autosave -- watchpoint". There is no such writer. A whole-image scan for immediate
   stores to ServerPlayer+0x164 finds exactly three: ProcessTurn's encounter loop writes
   1, ResumePlaying writes 0 on load (the 4 -> 0 the determinism note recorded), and
   StrategyServer_MarkPlayerTurnEnded 0x00821a40 writes 4 from the three End Turn
   SUBMISSION paths, which run before the turn is processed. Item 6 needs one predicate,
   not a watchpoint -- and an entry probe on that function names the set directly.

   NOT written. The only two saves in the corpus with a non-zero Status agree with
   Species != 4 on all eight players, and that is eight observations on a 1-bit predicate
   that nine of the eleven saves do not exercise at all. Evaluated and reported.

Seven addresses in ghidra/addresses.d/lane-t2.json, validated to a scratch path against
the merged set (1,095 entries, no duplicate). verify/results/standalone/{report,status}
deliberately untouched: lane C3 published into them minutes before this run.
2026-09-08 14:52:44 -04:00

51 KiB
Raw Blame History

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

  1. ComputeBudget(this, &B, false); Sav = SatAdd(Sav, B.net).
  2. zero per-turn accumulators ((+0x3d8)->+0x10, +0xcc, +0xc8); if +0xf9 → ConstructionSpend (AI path).
  3. ProcessSpecialProjects (0x00840fe0).
  4. research (§2): if (ResT && !RollResearchAccident()) TechTree::ProcessResearch(tree, ?, B.researchAlloc, &B.overbudget).
  5. TRA = TRM = TRP = 0, +0xe0 = +0xdc = 0 (per-turn contributions consumed).
  6. RebAI players: RebOutMod = clamp(RebOutMod − 0.04, 1.0, 2.0) (0x00a17870/68/6c).
  7. PR vector {float PRm; int PRBt}: TRM += PRm; if (--PRBt <= 0) erase — timed research-multiplier bonuses.
  8. 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).
  9. no ResT: list available techs (0x00584e50); if none but some tech is state 2 → "no research" event.
  10. 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], not int[25]. It is a Budget object: { 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 with ResT and stay {0,0,0} otherwise. (What was read as "[22]..[24]" is that header.)
  • [9] researchMoneyKept is written inside the if (ResT) branch, together with the researchAlloc.push_back. A player with no research target still gets [16] researchMoney and [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 no ResT, and its End Turn moves Sav 289,688 -> 532,369, i.e. +[1] 239,785 +[5] 2,896 with nothing deducted.
  • GetDifficultyMods rows, 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+0xf9 is the AI gate as described: player 7 is isAI && NPC and 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 by BANKRUPTCY_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 when Sav < 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, events PTR_PTR_00af0a4c..), sets BnkWrn (state) / BnkTrn (turn it started); state 2 and ModCount − 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 → EVENT_TECHS_UNLOCKED (names list)

(Corrected 2026-09-08, lane E: there is no "parent researched" clause. The mov ecx,[ecx]; mov eax,[eax+ecx*4] pair at 0x00587d0d–0x00587d19 dereferences node->def and indexes tree->nodes by def->[0], which is the tech's own id — the same indirection the entry loop uses at 0x00587738 — so it resolves back to the node itself and the state test is on that node. The surrounding null checks are defensive. Event details: findings/subsystems/events.md.) 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 in turn3/techtable.txt) — index = internal TechID enum; 0x006965c0 looks a name up by _stricmp over 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 of TRKSTL_REGENERATION_MOD (0x007870d0), ADVSENS_SENSORS_MOD (0x0080b730, 0x0081e9d0, 0x008672c0), STATION_BONUS_*, FLOCKDRIVE_*. For a reimplementation: ignore strategy{} 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: ProcessTurn takes no arguments (the decompile's void* stream is a Ghidra guess); the growth curve in §3.2 has no pop/cap term (the base is a suitability ratio); the second SYSTEMBONUS_MINTURNS gate is ntdev, not rbtn, and both bonus-apply helpers reset ntdev on a non-home colony; the 0x007514f0 row's globals are wrong (it reads a hard-coded float 0.05f at 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 is ProcessRebellion — ProcessTurn, ProcessPlague, GrowCivilianPops and ProcessSlaves are all draw-free. §3.1's call table also omits TRes = 0 (+0x74) and the haltv[0..2] = false clear (+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( ComputeTotalOutput(SRoh) )                           // 0x00750480 -- CORRECTED, lane N
   *** The three lines this block used to carry were WRONG in shape, not only in detail. This is
   *** not `base × morale × stationFactor × ...`: it is a SUM of three independent terms, and
   *** morale and the station bonus apply to only some of them. 0x007483b0 is the over-harvest
   *** RESOURCE demand (its population is `Res`, not `Pop`; species +0x4c is ADDED, not a cap),
   *** and the real population→output law is linear and lives in 0x0074b7a0. Live-verified on
   *** VM140: 13,105 calls, 0 divergences, two species. See findings/subsystems/output-term.md.
          if (!owner || rbfl != 0) return 0
          resAvail = Res (+ MRes + ARes2 when the owner strip-mines, p+0x138)
          sum = OverHarvestDemand(SRoh) × SpeciesDef(sp)->f50(+0x50)     // 0x007483b0
              + (TRes + resAvail) × StripMineFraction() × 0.9            // 0x00747d30
              + PopOutput(0) + PopOutput(1) + SlaveOutput()              // 0x0074d8f0, 0x0074b880
          × ADDICTION_OUTPUT_MOD (phase ≥ 3)
          × p.OutMod(+0x124) × sys.OutMod(+0x7c) × p.+0x224 × p.RebOutMod(+0x128) × p.ScOutMod(+0x12c)

  where the per-capita term (0x0074b7a0) is
          GroupOutput(t, sp, n) = max(0, POPTYPE[t].out × (stationFactor × 1.8) × moraleMod × n/500000)
          stationFactor = 1 + stations × STATION_BONUS_IMPERIAL_OUTPUT   -- IMPERIAL groups only
          moraleMod     = 0x00746910                                     -- CIVILIAN groups only
          POPTYPE[0].out = 1.0f and POPTYPE[1].out = 0.33f are LITERALS IN THE EXECUTABLE
          (0x00535ca0 builds the table in code); only the slave row reads the data files
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. move has no floor at 0. The type-5 jump scatters the fleet around the destination by float32(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. IsNodeWaypoint is true for type 3 only. The recursion threshold is (double)0.9999f and the test is strict. fleet+0xec is FPdpos, not FPogn2. Gate traffic is a signed int16 per fleet. The stutter overlap rule is not a midpoint (see Q7 in formula-gaps.md), and STUTTER_MIN_SPEED == STUTTER_MAX_SPEED in 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)

CORRECTED by lane T2, 2026-09-08 — two of this paragraph's three facts. The caller and the location are right; the relation codes and the stamped value are not. (a) GetRelation 0x0080e050 forwards to the cdecl 0x006d2050, which reads 3 = ally (and self), 2 = NAP, 1 = cease-fire, 0 = war — 1 and 3 the other way round from the line below, which labelled itself MEDIUM confidence on exactly this mapping. combat-done-tail.md §2A agrees independently (ResupplyAlliedFleets is gated on GetRelation == 3). (b) The stamped value is S+0xc, which lane Z named Frame — the turn — not ModCount: the instruction is mov eax,[edi+0xc] with edi = S, and across the corpus every stamp equals the save's own Frame on ten of eleven saves while ModCount runs an order of magnitude higher (0/12/24 on the reference family). Also: the entry is created on demand, a fresh one starts its three last* at -1, and the relation's bit is the PlyrIdx field, not the position in the player vector. See findings/subsystems/treaty-turn-stamp.md. Modelled in sots-engine as host phase H02.

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 current ModCount;
  • 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* equals ModCount − 1 (treaty made last turn and broken immediately) the betrayal counter bty*/bkn* is incremented.
  • deadhome (+0x20) is set elsewhere (home-system loss). 0x007acb50 dumps the "Alliances / Non Aggression / Cease fires" tables to the log. So DiplomacyStats is a ledger for the AI (consumed with Data/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 relation FUN_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+0xf9 is the AI-controlled flag (bIsAI), not "bTurnDone_nonser": it selects the AI difficulty-modifier table in GetDifficultyMods (0x0059b490), gates the AI construction path in ServerPlayer::ProcessTurn, and is why +0xf9==0 players get Status=1 (resume) at turn end.
  • struct-recovery.md: the species enum has 7 values (index 4 = _NPC), so Species 5 = Zuul, 6 = Morrigi.
  • data-model.md §4.1: the strategy{TECHBEN_*} block is inert; race gating is rolled once at tree creation with unlisted races defaulting to 100 %.
  • Open: (a) TRA/TRP/TRM and p+0x228/+0x22c producers; (b) budget [4]/[6] semantics; (c) FPsp2 derivation from ftlspeed/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).