phase 2: address contract + generator, turn-internals filed, M0-M4 rows; sibling repo sots-engine

This commit is contained in:
alex 2026-09-07 17:08:40 -04:00
parent daea98feba
commit 2d298b1e98
7 changed files with 678 additions and 1 deletions

View file

@ -46,3 +46,7 @@ A 4-agent crew (defined in `~/.claude/agents/re-*.md`) runs the exploration:
- `ghidra/` — exported scripts + datatype archives.
Approach & north star: `findings/00-strategy.md`. Binary facts: `findings/01-fingerprint.md`.
## Sibling repo
`alex/sots-engine` — the from-scratch engine source (clean-room, public-capable). This repo keeps the
evidence + planning for both; binary facts cross over only via `ghidra/addresses.json` → `tools/gen_addresses.py`.

View file

@ -32,4 +32,10 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| real save for verification | verify | verified | high | 100% | 2026-09-07 | 6 saves, 3 distinct turn states in verify/results/saves/ (byte-deterministic per state). Game runs on VM140 via DXVK 3.1 + lavapipe; recipe findings/subsystems/running-the-game.md |
| save_reader.py | verify | verified | high | 100% | 2026-09-07 | --strict exit 0 on all 3 real saves; 0 resyncs / 0 hint-failures; only raw = RNG blob (2503 B). 29 tests. SAVE_FORMAT.md confirmed |
| Ghidra type write-back | meta | verified | high | 100% | 2026-09-07 | structs saved in project (ServerSystem 87f, ServerPlayer 110f, StarFleet, StarShip, StrategyServer partial, 22 nested); 52 serializers + primitives + ~60 spine fns renamed; decompile shows field names |
| strategic turn internals (economy/research/colony/movement/diplomacy) | subsystem | in-progress | — | 0% | 2026-09-07 | R5 breadth: formulas + constant->code map from ServerPlayer/ServerSystem::ProcessTurn, TechTree::ProcessResearch, MoveFleet |
| strategic turn internals (economy/research/colony/movement/diplomacy) | subsystem | mapped | high | 90% | 2026-09-07 | findings/subsystems/strategic-turn-internals.md: budget/RP/trade/bankruptcy formulas, research rolls (unlisted race=1.0, Zuul x2), colony growth, movement, RNG=MT19937; ~600 g_KEY labels in Ghidra |
| P2-M0 shim bootstrap | phase2 | in-progress | — | 0% | 2026-09-07 | sots-engine: binkw32 proxy + MinHook + trace hook on Application::Initialize; game to menu |
| P2-M1 flat-KV config loader | phase2 | backlog | — | 0% | 2026-09-07 | CfgVar_RegisterKey 0x008b76a0; trace->compare->replace; save byte-identical oracle |
| P2-M2 manifest/id registry | phase2 | backlog | — | 0% | 2026-09-07 | _weapons.txt/_shipsections.txt stable ids |
| P2-M3 Mars brace-block parser | phase2 | backlog | — | 0% | 2026-09-07 | + .effect TXT reader; compare over full catalog load |
| P2-M4 gobio VFS read | phase2 | backlog | — | 0% | 2026-09-07 | ZIP entries + NativeFileSystem override order |
| loader prototypes (Ghidra) | meta | in-progress | — | 0% | 2026-09-07 | R6: cfgvar parser, manifest loader, brace-block parser entry+node model, gobio read -> ghidra/addresses.json |

View file

@ -28,3 +28,4 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re-
- **RESOLVED: save framing** — padding is joint; `"."` = NULL tag (VectorHelper count+elements, Vector3 bodies); empty strings are 4 zero bytes; `CDT` is a tagged frame + opaque `CD` frames. All three real saves parse `--strict` clean. Note: `SvSctOb` IS present in all saves (patch doc §7 was wrong). (from [[SAVE_FORMAT]])
- **(parked) Combat loader job API** — does tactical-battle loading go through the job helpers `0x8fc160`/`0x8fa5b0` with the `TryEnterCriticalSection→Sleep(1000)` completion poll (`0x0071ea60`)? If yes, that is a plausible many-core stall (lost try-lock = +1 s per poll). End Turn also has a deliberate `Sleep(computed)` ≈0.85 s at `0x00838da9`. (from [[battle-load-profile]])
- **Ship-design code rules (Ghidra, from SHIP_DESIGN_RULES.md §8)** — hidden default rider designs are built WITHOUT tech gating (where?); is section class-equality enforced or merely unoffered; confirm the turret class-accept table (`standard ← {standard,missile,grapple}`, `strafe ← {standard}`; grapple weakest); hull-class tech injection (`IND_CruisCon`/`IND_DreadCon`) + station exemption; option defaults/auto-upgrade and what `option_cost` multiplies; weapon cost per bank vs per mount; mass/speed formulas; `faiDes`/`dWep` meaning. (from [[SHIP_DESIGN_RULES]])
- **Corrections from turn internals** — `ServerPlayer+0xf9` is the AI flag (not bTurnDone); `FUN_007d7f70` is encounter detection; ProcessTurn phase 2 is trade, not diplomacy; the spine's `FUN_00840fe0` 'income' is special projects — income is `FUN_00863030` ComputeBudget. `TECHBEN_*` data blocks are inert (effects hard-coded via the 116-entry tech-name table @0x00a19718). Open: TRA/TRP producers, `FPsp2` derivation, `PERGATETRAFFIC_*` readers, exact BnkEl/BnkPr expression. (from [[strategic-turn-internals]])

View file

@ -0,0 +1,554 @@
# 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]
```
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, ?, vector<{techIdx,int points}>&, int* overbudget)` | progress + completion roll + decay + unlock events |
| 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, 4 researched), `+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.def); lo = cost*50/100; hi = cost*150/100 (lo≥0, hi≥lo)
wasDone = cost <= node.progress
spend = min(points, hi - node.progress); *overbudget += points - spend
node.progress += spend; nowDone = cost <= node.progress
if (node.progress < hi) {
if (spend == 0) { odds = 0; roll = 1 } // nothing spent → cannot complete
else { odds = (progress - lo) / hi; // 0 at 50 %, 1/3 at 100 %, 2/3 at 150 %
roll = rand01(); // eps 0x009e1e68 == 0.0
if (owner.Species == 5 /*Zuul*/) roll = min(roll, rand01()); } // Zuul roll twice, keep the better
} 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)"
if (progress/cost < 0.8) node.flag = 0 // 0x009e20c8
SetResearched(node.def, 2)
}
for each node with state==2 && progress>0: progress = max(0, progress - ftol(Cost(node)*0.05)) // 0x009e5060: 5 %/turn decay
for each node with state==2 && turnAvailable == currentTurn && parent researched → EVENT_TECHS_UNLOCKED (names list)
```
Net effect: the current tech gains `spend − 5 %·cost`; every other partially researched tech decays 5 % of its cost per turn.
### 2.4 Unlocking (`SetResearched`)
state←4, `turnResearched = ModCount`, `order = tree.counter++`, owner callback `vft+0x10(def, flags>>2&1)` (this is
where hard-coded tech effects are applied — see 2.6); for each child edge: child state 0→1, `child.cost = min(child.cost,
edge.cost)`; then every node in state 1 whose `PrereqsMet(def+0x88)` holds becomes state 2 with `turnAvailable =
ModCount`; a state-2 node with `Cost()==0` is researched immediately (recursive).
### 2.5 `.tech` parsing (`TechDef::Parse` 0x0058b050)
Handles `name/family/type/threat/requires/allows/strategy/ship`. `allows "CHILD RP:cost Human:% …"` → Edge with
`cost` and `avail[species] = pct/100`. **Default for unlisted species: 1.0 (always available).** The edge parser
`FUN_00584160` allocates the 0x44-byte Edge with all seven `avail[]` floats (`+0x20..+0x3b`) preset to `0x3f800000`
= 1.0 and `cost` (+0x1c) = 0, then overwrites only the species actually named on the `allows` line (name → index via
`FUN_0053b110`, value = pct/100). So a race omitted from `allows` is guaranteed the tech; an explicit `Race:0`
excludes it. Unknown child name → log `"%s cannot allow %s. %s not found."`. `+0x3c` = parent TechDef, `+0x40` =
child TechDef.
`strategy { inc/dec TECHBEN_X }` is stored as a `vector<std::string>` on the TechDef.
### 2.6 `TECHBEN_*` — dead data; effects are hard-coded
Only one `TECHBEN_` string exists in the executable: `TECHBEN_STRATRANGE` (0x00a1a924), compared by
0x00699bd0 (`_stricmp` over the TechDef's strategy-string vector). The data file spells it `TECHBEN_STRATRNGE`, so
it never matches; the other 22 benefit names (`TECHBEN_INDOUTPUT`, `_HULLSTR`, `_SHIPCONCOST`, `_POPGROW`, …) have
**no consumer at all**. The real strategic effects are keyed on tech *names* in code:
* a 116-entry tech-name pointer table at **0x00a19718** (`IND_Waldo, IND_RefCoat, IND_ImpRfCt, IND_PlyAlloy,
IND_StlthArm, …`; dump 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)
### 3.1 Function map (in call order)
| addr | name / *proposed* | what |
|---|---|---|
| — | inline | unowned: `Infra −= 0.02` (0x009e9170), floor 0 |
| 0x00746780 | *`ApplyInfraBonus`* | `Infra += min(ibon, 1−Infra)`, `ibon −=` same; non-home system resets `rbtn` |
| 0x0074b510 | *`ApplyPopBonus`* | `Pop += min(pbon, MaxPop(0,0) − Pop)`, `pbon −=` |
| 0x007514f0 ×2 | *`IndependentPopDrift(0→1 / 1→0, indsp, mod)`* | independent systems: imperial↔civilian transfer (params `DAT_00aeca78`=10000, `DAT_00aeca7c`=0) |
| 0x0074ad90 | *`IsStable`* | → `ntdev` (turns developing) ++ / 0 |
| 0x0074d4f0 | *`AccrueSystemBonus`* | `SYSTEMBONUS_*` (below) |
| 0x00756a90 | `ProcessPlague` | outbreak/cure/colony destroyed (no constants) |
| 0x00752500 → 0x00890d50 | `ProcessBuildQueue` → `BuildQueue::ProcessTurn` | 3.4 |
| 0x0074b230 | *`GrowImperialPop`* | 3.2 |
| 0x00754220 | *`GrowCivilianPops`* + morale events | 3.2 |
| 0x00745f30 | *`AdjustResources(delta, mode)`* | `Res/ARes2/MRes` (mode 3 = normal, 0 = `AMine` asteroid mining split) |
| 0x007463f0 | *`RefuelInOrbit(1)`* | ships of fleets at the system in state 3 |
| — | inline | `Bats2` / `rcex` 64-bit nibble arrays: per-player 4-bit countdowns (battle / recon cooldown) |
| 0x007537b0 | `ProcessSlaves` | 3.5 |
| 0x007583b0 | `ProcessRebellion` | 3.6 |
| — | inline | addiction: per species `adt[i]` start turn vs `ADDICTION_PHASE2_START/PHASE3_START` → morale events 0x1b/0x1c/0x1d (skipped if player flag bit5) |
### 3.2 Population
**Carrying capacity** (0x0074ab20 / 0x0074a4a0, species `sp`, group type `t` 0 imperial / 1 civilian / 2 slaves):
```
if sp == 4 → 0 // see open question
if !SpeciesCanLive(sp) (0x0053bb40) → 0
cap = ftoi64( Size × 1e8 × groupdef[t].+0x20 × SpeciesGrowthFactor(sp) (0x0053bb00)
× (owner species ≠ sp ? CrossSpeciesMod (0x0053bb20) : 1)
× HazardMod(Suit, IdealSuit(p), SuitTol) (0x00747ae0; 1.0 if player flag bit7 or RebAI) )
+ (p.harcc(+0x104) ? 1e8 (imperial) / 2e8 (civilian) : 0) // 0x0080dd30
cap = min(cap, groupdef[t].max @+0x28 if @+0x2c >= 0)
if owner species == 4: cap *= INDSYS_IMPERIAL_POPULATION_MOD
```
**Growth per turn** (0x00748100 → 0x00537140 → 0x00536fb0):
```
if haltv[t] (blockade) → 0 ; if system flagged in player's +0xdc mask → factor 0x00a1b000 instead of 1
g = clamp01( (1 − clamp01(Pop/cap)) ^ POPULATION_GROWTH_EXP )
if g > 0: g *= POPULATION_GROWTH_MOD ; g *= p.PopMod ; g *= hazard/species factor ; g *= groupdef[t].+4 (if >0)
delta = ftoi64(Pop × g) (min 1 if g>0), capped at 50,000,000
imperial: Pop = min(cap, Pop + delta); if cap < Pop: Pop −= min(50,000,000, Pop − cap), floor min(Pop,100)
```
Civilians (0x00754220): per species with `+0x348` flag bit8 clear: if no group yet and cap > 0 → seed 1000 (own species
via 0x007502d0, others 0x0074ec50 with `MORALE_DEFAULT_VALUE`); growth as above per group; totals capped at
`0xfffffffffd050f80`-style guards; morale events 0x10 (growth) / 0x11 (decline) with the inlined event-id→delta table
(ids 1,3,7,0xe,0x12,0x21 → −5; 2,9,0x18,0x25 → −10; 4,5,0xa,0xb,0x23 → +10; 6,0x11,0x1d,0x26 → −2; 8,0x20 → +5;
0xc,0xd,0x14,0x24 → +2; 0xf,0x1a → −3; 0x10,0x19,0x1b → −1; 0x13,0x16,0x1c → +1; 0x15 → +3; 0x17 → −15; 0x1e → +30).
**System bonus** (0x0074d4f0; keys `SYSTEMBONUS_*`): when `IsStable` and `ModCount − TAcq > MINTURNS` and
`rbtn > MINTURNS`: `pbon += min(POPBONUS_INC-derived amount, cap×POPBONUS(_HOME) − pbon)`, `ibon += min(INFRABONUS_INC,
INFRABONUS(_HOME) − ibon)`; applied next turn by `ApplyPopBonus/ApplyInfraBonus`. `pbon2` is the civilian analogue.
### 3.3 Output → construction / infra / terraform / money (`ComputeOutputFromRates` 0x00751bb0)
```
rates = normalise(OutputRates{SRt trade, SRsc construction, SRtf terraform, SRi infra}) (0x00747390):
entries < 0x009e22c8 → 0; SRtf → 0 if Suit == IdealSuit; SRi → 0 if Infra + ibon ≥ 1; rescale to Σ=1 (all-zero → equal split)
total = round( BaseOutput × mods ) // 0x00750480:
BaseOutput from pop (0x007483b0: (pbon+Pop)/… resource-availability factor, capped by species +0x4c)
× morale (0x00746910: ≥MORALE_INCREASE_OUTPUT → ×MORALE_INCREASE_OUTPUT_MOD, ≤MORALE_DECREASE_OUTPUT → ×DECREASE_MOD)
× (1 + STATION_BONUS_IMPERIAL_OUTPUT × stations) (0x0074b7a0)
× ADDICTION_OUTPUT_MOD (phase ≥ 3)
× p.ScOutMod(+0x12c) × p.RebOutMod(+0x128) × p.+0x224 × sys.OutMod × p.OutMod(+0x124)
trade = round(total×SRt); cons = round(total×SRsc); terra = round(total×SRtf); infra = round(total×SRi)
resources: need = ftol(f(total)) + CivilianConsumption (0x0074c6f0: CIVILIAN_RESOURCES_CONSUMED × civ-share curve)
out[2] = min(need, Res [+ MRes + ARes2 if p.AMine]) → consumed by AdjustResources; out[1] = shortfall term
construction: out[7] = cons × (1 + STATION_BONUS_SHIPCON × stations) (0x00746830); out[8] = min(out[7], queue demand);
leftover → repair ships in orbit (0x00751590); out[9] = remaining
unspent leftover L split back over trade/terra/infra by rates (or 1 / (Suit≠ideal) / (Infra≠1) when SRsc==1)
infra: need = ceil((1 − Infra) / 3.3e-5); pool = infra + L_infra; spend = min(pool, need)
out[10] = (spend / 500) × 0.01 × 1.65 = spend × 3.3e-5 → Infra += out[10] (≈30 300 points for 0→1)
terraform: need = |Ideal − Suit| / (1.5 × 1.2 / 20000); pool = terra + L_terra + carry; spend = min
out[11] = spend × 1.5 × 1.2 × p.TerraMod × (Suit > Ideal ? −1 : 1) / 20000 → Suit += out[11] (0x00748270)
money: out[3] = ftol( Income(trade + L_trade) ) (0x007505b0: × species income factor (speciesdef+0x18) × p income mult (0x0080dd10) − p cost term (0x0080dd20))
```
`TnsOH` (turns over-harvesting) increments while `SRoh > 0 && out[1] > 0` (ProcessBuildQueue).
### 3.4 Build queue (`BuildQueue::ProcessTurn(queue, sys, points)` 0x00890d50)
Orders are a `std::list` of `ShipBuildOrder{desID, con, sav, conleft, ordID, ShipDesign*}` processed FIFO:
`if points < conleft { conleft −= points; break }` else (if `design.cost(+0xc0) > 0` the queue's owner must pay via
`vft[9](sys, cost)`) create the ship (0x004f41a0), attach to the system's fleet (0x0059f1a0), refuel/repair/initialise,
bump `ShipRecords` (per-class counters at `p+0x1b4`, per-design list at `p+0x1e4`), raise `SEBuildCompleted` with
the `ShipBuildOrderDef`, `points −= conleft; conleft = 0`; finished orders are unlinked afterwards. Ship-borne queues
(0x00789500) reuse it.
### 3.5 Slaves (`ProcessSlaves`; `SLAVES_*`)
```
rate = ( SRs × SLAVES_DEATH_RATE_BYOUTPUT + |IdealSuit − Suit| × SLAVES_DEATH_RATE_BYHAZARD + SLAVES_DEATH_RATE )
× mod, mod = (flag bit0 ? 0.8 : 1) − 0.2×bit1 − 0.2×bit2 // 0x0074b110; flags = p+0x348[sp]
deaths(sp) = clamp(ftoi64(slaves(sp) × rate), SLAVES_MIN_DEATHS, SLAVES_MAX_DEATHS (−1 = none))
```
when the last slave dies → `EVENT_SLAVES_DEAD`; pending slave-transfer object at `sys+0x2c0` consumed.
`SLAVES_MAX_PERSYSTEM` is registered but has **no code reader** (dead key).
### 3.6 Rebellion / independence
`ProcessRebellion`: `rbfl` (bitmask of rebelling species) — 0x0074fbe0 computes rebels per species (units of 1e6),
0x00753c60 rolls new rebellions (per-species roll count `local_f4` × 0x008e6dd0), events `EVENT_SYSTEM_REBELLION_CONTINUES`
with the same morale table; outcome 0 → 0x00756350 (suppressed), 1/2 → continues / 0x007522b0 (system lost).
Independence: 0x00748fa0 = unowned system with civilians isolated ≥ `INDSYS_MINTURNS_ISOLATED` turns may turn
independent; 0x0075fa10 drafts `INDSYS_DRAFTED_PERCENTAGE` of civilians into imperial pop; `INDSYS_MIN_CHAOS_POPULATION`
in 0x007b9df0 (abandon/chaos); `INDSYS_SYSTEM_ODDS` at map generation (0x007471b0); `INDSYS_IMPERIAL_POPULATION_MOD` in
the cap formula; `INDSYS_SURRENDER_MAXIMPERIAL` **has no reader** (dead key).
### 3.7 Morale
`Morale` = `int[7]` per species at `sys+0x120` (default `MORALE_DEFAULT_VALUE`); colonies below
`MORALE_DISABLED_POPULATION` are pinned at `MORALE_DISABLED_VALUE` (0x00749460, 0x0075b5c0); history capped at
`MORALE_EVENT_HISTORY_MAXEVENTS` (`MORALE_EVENT_HISTORY_MINTURNS` has no reader). Output effect in 3.3.
Confidence: HIGH on the order of operations, growth curve, slave deaths, system bonus, infra/terraform point
conversions; MEDIUM on the money-income tail (0x007505b0 lost its FP chain) and civilian seeding rules.
Species index 4 (no imperial growth, cap × `INDSYS_IMPERIAL_POPULATION_MOD`) is `_NPC` — the independent/NPC race —
per the enum in §0 (resolved).
---
## 4. Movement
### 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)
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 `(float)(y × 2^−32)` ∈ [0,1) (0x009e61b0 = 2^−32, 0x009e61b8 = 2^32 unsigned fix-up) |
| 0x004271c0 | `Mars::RNG::NextInt(uint n)` | same tempering; rejection sampling with a power-of-two mask ≥ n → uniform [0, n) |
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`. 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).

31
ghidra/addresses.json Normal file
View file

@ -0,0 +1,31 @@
{
"_schema": "facts about Sword of the Stars.exe (GOG 1.8.1, build 19833). rva = file address - 0x00400000. Runtime address = GetModuleHandle(NULL) + rva (exe is ASLR-relocated). Prototypes marked 'unverified' are best current reading; a Ghidra round confirms them.",
"image_base": "0x00400000",
"entries": [
{"name":"Mars_Application_Initialize","addr":"0x008a0e50","convention":"thiscall","prototype":"void (Application* this)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"Mars_Application_Run","addr":"0x0089f5b0","convention":"thiscall","prototype":"int (Application* this)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"Process_PinAffinity","addr":"0x0089ee70","convention":"cdecl","prototype":"void (int coreIndex)","status":"unverified","source":"findings/objects/ghidra-recon.md"},
{"name":"CfgVar_RegisterKey","addr":"0x008b76a0","convention":"thiscall","prototype":"void (CfgVar* this, void* parser, const char* key, void* storage)","status":"unverified","source":"findings/subsystems/strategic-turn-internals.md#config-plumbing"},
{"name":"DemoApp_OnTick","addr":"0x0089a640","convention":"thiscall","prototype":"void (DemoApp* this)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"StrategyClient_EndTurn","addr":"0x00783be0","convention":"thiscall","prototype":"void (StrategyClient* this)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"StrategyServer_BeginProcessTurn","addr":"0x007d98e0","convention":"thiscall","prototype":"void (StrategyServer* this)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"StrategyServer_ProcessTurn","addr":"0x007dc6c0","convention":"thiscall","prototype":"void (StrategyServer* this)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"ServerPlayer_ComputeBudget","addr":"0x00863030","convention":"thiscall","prototype":"void (ServerPlayer* this, int* budget25)","status":"unverified","source":"findings/subsystems/strategic-turn-internals.md"},
{"name":"TechTree_ProcessResearch","addr":"0x005876c0","convention":"thiscall","prototype":"void (TechTree* this, ...)","status":"unverified","source":"findings/control-flow/turn-spine.md"},
{"name":"ServerSystem_Read","addr":"0x0075d4b0","convention":"thiscall","prototype":"void (ServerSystem* this /*+8 IStreamable*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"ServerSystem_Write","addr":"0x00749630","convention":"thiscall","prototype":"void (ServerSystem* this /*+8*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"ServerPlayer_Read","addr":"0x008804d0","convention":"thiscall","prototype":"void (ServerPlayer* this /*+0x3a0*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"ServerPlayer_Write","addr":"0x008563e0","convention":"thiscall","prototype":"void (ServerPlayer* this /*+0x3a0*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"StarFleet_Read","addr":"0x00702470","convention":"thiscall","prototype":"void (StarFleet* this /*+8*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"StarFleet_Write","addr":"0x00701070","convention":"thiscall","prototype":"void (StarFleet* this /*+8*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"StarShip_Read","addr":"0x00853fa0","convention":"thiscall","prototype":"void (StarShip* this /*+8*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"StarShip_Write","addr":"0x008291f0","convention":"thiscall","prototype":"void (StarShip* this /*+8*/, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"StrategyServer_Read","addr":"0x007d27a0","convention":"thiscall","prototype":"void (StrategyServer* this, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"StrategyServer_Write","addr":"0x0079fa70","convention":"thiscall","prototype":"void (StrategyServer* this, Stream* s)","status":"verified-by-save","source":"findings/objects/struct-recovery.md"},
{"name":"Stream_WriteNetworkObjectId","addr":"0x00816490","convention":"thiscall","prototype":"void (Stream* this, const char* tag, NetworkObject* obj)","status":"unverified","source":"findings/objects/struct-recovery.md"},
{"name":"RNG_Twist","addr":"0x00426e00","convention":"thiscall","prototype":"void (RNG* this)","status":"unverified","source":"findings/subsystems/strategic-turn-internals.md#rng"},
{"name":"RNG_Seed","addr":"0x0049fdf0","convention":"thiscall","prototype":"void (RNG* this, uint32_t seed)","status":"unverified","source":"findings/subsystems/strategic-turn-internals.md#rng"},
{"name":"RNG_NextFloat","addr":"0x0047d830","convention":"thiscall","prototype":"float (RNG* this)","status":"unverified","source":"findings/subsystems/strategic-turn-internals.md#rng"},
{"name":"RNG_NextInt","addr":"0x004271c0","convention":"thiscall","prototype":"uint32_t (RNG* this)","status":"unverified","source":"findings/subsystems/strategic-turn-internals.md#rng"}
]
}

View file

@ -0,0 +1,61 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ daea98f, generated 2026-09-07 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
namespace sots::addr {
constexpr uint32_t IMAGE_BASE = 0x00400000;
// thiscall void (Application* this) [unverified]
constexpr uint32_t Mars_Application_Initialize = 0x004a0e50;
// thiscall int (Application* this) [unverified]
constexpr uint32_t Mars_Application_Run = 0x0049f5b0;
// cdecl void (int coreIndex) [unverified]
constexpr uint32_t Process_PinAffinity = 0x0049ee70;
// thiscall void (CfgVar* this, void* parser, const char* key, void* storage) [unverified]
constexpr uint32_t CfgVar_RegisterKey = 0x004b76a0;
// thiscall void (DemoApp* this) [unverified]
constexpr uint32_t DemoApp_OnTick = 0x0049a640;
// thiscall void (StrategyClient* this) [unverified]
constexpr uint32_t StrategyClient_EndTurn = 0x00383be0;
// thiscall void (StrategyServer* this) [unverified]
constexpr uint32_t StrategyServer_BeginProcessTurn = 0x003d98e0;
// thiscall void (StrategyServer* this) [unverified]
constexpr uint32_t StrategyServer_ProcessTurn = 0x003dc6c0;
// thiscall void (ServerPlayer* this, int* budget25) [unverified]
constexpr uint32_t ServerPlayer_ComputeBudget = 0x00463030;
// thiscall void (TechTree* this, ...) [unverified]
constexpr uint32_t TechTree_ProcessResearch = 0x001876c0;
// thiscall void (ServerSystem* this /*+8 IStreamable*/, Stream* s) [verified-by-save]
constexpr uint32_t ServerSystem_Read = 0x0035d4b0;
// thiscall void (ServerSystem* this /*+8*/, Stream* s) [verified-by-save]
constexpr uint32_t ServerSystem_Write = 0x00349630;
// thiscall void (ServerPlayer* this /*+0x3a0*/, Stream* s) [verified-by-save]
constexpr uint32_t ServerPlayer_Read = 0x004804d0;
// thiscall void (ServerPlayer* this /*+0x3a0*/, Stream* s) [verified-by-save]
constexpr uint32_t ServerPlayer_Write = 0x004563e0;
// thiscall void (StarFleet* this /*+8*/, Stream* s) [verified-by-save]
constexpr uint32_t StarFleet_Read = 0x00302470;
// thiscall void (StarFleet* this /*+8*/, Stream* s) [verified-by-save]
constexpr uint32_t StarFleet_Write = 0x00301070;
// thiscall void (StarShip* this /*+8*/, Stream* s) [verified-by-save]
constexpr uint32_t StarShip_Read = 0x00453fa0;
// thiscall void (StarShip* this /*+8*/, Stream* s) [verified-by-save]
constexpr uint32_t StarShip_Write = 0x004291f0;
// thiscall void (StrategyServer* this, Stream* s) [verified-by-save]
constexpr uint32_t StrategyServer_Read = 0x003d27a0;
// thiscall void (StrategyServer* this, Stream* s) [verified-by-save]
constexpr uint32_t StrategyServer_Write = 0x0039fa70;
// thiscall void (Stream* this, const char* tag, NetworkObject* obj) [unverified]
constexpr uint32_t Stream_WriteNetworkObjectId = 0x00416490;
// thiscall void (RNG* this) [unverified]
constexpr uint32_t RNG_Twist = 0x00026e00;
// thiscall void (RNG* this, uint32_t seed) [unverified]
constexpr uint32_t RNG_Seed = 0x0009fdf0;
// thiscall float (RNG* this) [unverified]
constexpr uint32_t RNG_NextFloat = 0x0007d830;
// thiscall uint32_t (RNG* this) [unverified]
constexpr uint32_t RNG_NextInt = 0x000271c0;
} // namespace sots::addr

20
tools/gen_addresses.py Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Emit include/generated/sots_addresses.h for sots-engine from ghidra/addresses.json.
Only facts (name, RVA, convention, prototype, status) — never decompiler text."""
import json, os, subprocess, sys, datetime
root = os.path.join(os.path.dirname(__file__), "..")
j = json.load(open(os.path.join(root, "ghidra", "addresses.json")))
base = int(j["image_base"], 16)
try: rev = subprocess.check_output(["git","-C",root,"rev-parse","--short","HEAD"]).decode().strip()
except Exception: rev = "unknown"
out = [ "// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).",
f"// Source: sots-re ghidra/addresses.json @ {rev}, generated {datetime.date.today()} by tools/gen_addresses.py",
"// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).",
"#pragma once", "#include <cstdint>", "", "namespace sots::addr {", f"constexpr uint32_t IMAGE_BASE = 0x{base:08x};", "" ]
for e in j["entries"]:
rva = int(e["addr"],16) - base
out.append(f"// {e['convention']:8s} {e['prototype']} [{e['status']}]")
out.append(f"constexpr uint32_t {e['name']} = 0x{rva:08x};")
out += ["", "} // namespace sots::addr", ""]
dest = sys.argv[1] if len(sys.argv) > 1 else os.path.join(root, "ghidra", "generated", "sots_addresses.h")
os.makedirs(os.path.dirname(dest), exist_ok=True); open(dest, "w").write("\n".join(out)); print("wrote", dest, f"({len(j['entries'])} entries)")