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

637 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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::set`s 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).