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