diff --git a/README.md b/README.md index 91cd471..4afc896 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/campaign/board.md b/campaign/board.md index 7511c4d..006c7b8 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -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 | diff --git a/campaign/open-questions.md b/campaign/open-questions.md index 98be33a..2e5c36c 100644 --- a/campaign/open-questions.md +++ b/campaign/open-questions.md @@ -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]]) diff --git a/findings/subsystems/strategic-turn-internals.md b/findings/subsystems/strategic-turn-internals.md new file mode 100644 index 0000000..b2a7c31 --- /dev/null +++ b/findings/subsystems/strategic-turn-internals.md @@ -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_` 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}/.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_` 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 ; 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_` 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 @+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` indexed by tech id, `+0x20` research +order counter. Node (0x34 B, ctor in 0x005822d0): `[0] TechDef*`, `+4..+0xc vector 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` 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`) 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_` labels (Save succeeded). diff --git a/ghidra/addresses.json b/ghidra/addresses.json new file mode 100644 index 0000000..53b4104 --- /dev/null +++ b/ghidra/addresses.json @@ -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"} + ] +} diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h new file mode 100644 index 0000000..0f30196 --- /dev/null +++ b/ghidra/generated/sots_addresses.h @@ -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 + +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 diff --git a/tools/gen_addresses.py b/tools/gen_addresses.py new file mode 100755 index 0000000..e885f51 --- /dev/null +++ b/tools/gen_addresses.py @@ -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 ", "", "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)")