From 6600382e122ce2c434189c77ef27cc527a8a4443 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Sep 2026 23:14:51 -0400 Subject: [PATCH] b3 static findings: rng draw mapping, NextInt inclusive, float32 odds; address contract +17 --- campaign/DASHBOARD.md | 44 +-- campaign/board.md | 5 +- campaign/open-questions.md | 2 + findings/subsystems/formula-gaps.md | 43 +++ .../subsystems/strategic-turn-internals.md | 26 +- ghidra/addresses.json | 342 +++++++++++++++++- ghidra/generated/sots_addresses.h | 92 ++++- tools/gen_addresses.py | 6 +- 8 files changed, 500 insertions(+), 60 deletions(-) diff --git a/campaign/DASHBOARD.md b/campaign/DASHBOARD.md index d5a3aff..e289c35 100644 --- a/campaign/DASHBOARD.md +++ b/campaign/DASHBOARD.md @@ -1,18 +1,18 @@ # SotS RE campaign — coverage dashboard -Generated 2026-09-08 02:43 UTC · `sots-re` @ 01c3217,2026-09-07 · `sots-engine` @ 81218c7,2026-09-07 (45 commits) · regenerate with `tools/dashboard.py` +Generated 2026-09-08 03:14 UTC · `sots-re` @ 73f5f1e,2026-09-07 · `sots-engine` @ 3ebc8bf,2026-09-07 (47 commits) · regenerate with `tools/dashboard.py` > **North star:** A functional reimplementation of the engine — behavior-equivalent, NOT byte-for-byte ## 1. Map coverage (campaign/board.md) -54 targets · mapped-or-better **43/54** `[████████░░] 80%` · verified **26/54** `[█████░░░░░] 48%` +55 targets · mapped-or-better **43/55** `[████████░░] 78%` · verified **27/55** `[█████░░░░░] 49%` | Status | Count | % | |---|---:|---:| -| verified | 26 | 48% | -| mapped | 17 | 31% | -| in-progress | 3 | 6% | +| verified | 27 | 49% | +| mapped | 16 | 29% | +| in-progress | 4 | 7% | | backlog | 7 | 13% | | blocked | 1 | 2% | @@ -22,15 +22,15 @@ Generated 2026-09-08 02:43 UTC · `sots-re` @ 01c3217,2026-09-07 · `sots-engine | control-flow | 0 | 2 | 0 | 0 | 0 | 2 | | subsystems | 2 | 6 | 1 | 3 | 1 | 13 | | engine | 10 | 0 | 0 | 0 | 0 | 10 | -| verify | 4 | 2 | 0 | 0 | 0 | 6 | -| phase2 | 2 | 0 | 2 | 4 | 0 | 8 | -| meta | 3 | 5 | 0 | 0 | 0 | 8 | +| verify | 4 | 2 | 0 | 1 | 0 | 7 | +| phase2 | 2 | 0 | 3 | 3 | 0 | 8 | +| meta | 4 | 4 | 0 | 0 | 0 | 8 | ## 2. Binary understanding - RTTI type descriptors: **1,924** (`Game::` 1,404, `Mars::` 194; serializable types 179) - Classes with recovered member layouts: **21** / 179 serializable types `[█░░░░░░░░░] 12%` — heuristic: distinct `Game::X`/`Mars::X` in `##`–`####` headings of `struct-recovery.md` + `schema-gaps-resolved.md` -- Functions: **41,411** (parsed from `01-fingerprint.md`); named/annotated in the **address contract** (`ghidra/addresses.json`, not Ghidra's full rename count): **137**, verified **126** `[█████████░] 92%` +- Functions: **41,411** (parsed from `01-fingerprint.md`); named/annotated in the **address contract** (`ghidra/addresses.json`, not Ghidra's full rename count): **176**, verified **166** `[█████████░] 94%` ## 3. Data layer @@ -48,16 +48,16 @@ Generated 2026-09-08 02:43 UTC · `sots-re` @ 01c3217,2026-09-07 · `sots-engine | `game/data` | 2,053 | 12 | 462 | yes | game-data.md | | `game/design` | 1,024 | 17 | 250 | yes | game-design.md | | `game/effects` | 811 | 3 | 160 | yes | game-effects.md | -| `game/sim` | 1,618 | 9 | 466 | yes | game-sim.md | +| `game/sim` | 1,714 | 9 | 506 | yes | game-sim.md | | `mars/parse` | 875 | 12 | 277 | yes | mars-parse.md | -| `mars/rng` | 166 | 0 | 0 | yes | mars-rng.md | -| `mars/stream` | 3,703 | 6 | 195 | yes | mars-stream.md | +| `mars/rng` | 206 | 0 | 0 | yes | mars-rng.md | +| `mars/stream` | 3,703 | 6 | 222 | yes | mars-stream.md | | `mars/text` | 899 | 8 | 245 | yes | mars-text.md | | `mars/vfs` | 788 | 9 | 140 | yes | mars-vfs.md | -| `shim` | 2,981 | 0 | 0 | direct (WIN32) | M0.md | -| `shim/hooks` | 875 | 0 | 0 | direct (WIN32) | M0.md | +| `shim` | 3,487 | 0 | 0 | direct (WIN32) | M0.md | +| `shim/hooks` | 1,374 | 0 | 0 | direct (WIN32) | M0.md | | `shim/trace` | 1,836 | 8 | 248 | direct (WIN32) | shim-trace.md | -| **total** | **18,187** | **88** | **2574** | | | +| **total** | **19,328** | **88** | **2641** | | | Board `engine:` rows: verified **10**, mapped 0, in flight 0 (of 10) — verified & merged `[██████████] 100%` @@ -82,22 +82,22 @@ Board `engine:` rows: verified **10**, mapped 0, in flight 0 (of 10) — verifie ## 7. Open questions -Open **25** · resolved/parked 10 · backlog items: Now 4, Next 3, Later 2, Breadth queue 7, Parked 1, From the RE how-to 4, Behavioral slice 4 +Open **27** · resolved/parked 10 · backlog items: Now 4, Next 3, Later 2, Breadth queue 7, Parked 1, From the RE how-to 4, Behavioral slice 4 Most recent open: +- Some truths are unreachable by compare — B3's draw-divisor bug differed on 0.78% of draws yet flip… +- x87 precision-control mode at runtime — 53-bit MSVC default vs 24-bit if D3D9 grabbed the FPU; mov… - Struct-modelling hazard (found by M2) — an MSVC-2010 `std::vector` member is three words, so a… - Notes disagree on MSVC-2010 `std::string` layout — `struct-recovery.md` §0 vs `turn-spine.md` §1.1… - SAVE_FORMAT tag corrections (fix Python reader + spec) — real on-disk tags: `otnF` (not `ontF`) in… -- Not traced end-to-end — `Species/_NPC/weapons/*.weapon` loading and the `.effect` dictionary entry… -- PARITY BUG (fix in mars/text + Python flat_kv): duplicate config keys are FIRST-occurrence-wins in t… ## 8. Delta since previous dashboard -- verified targets: 25 → 26 (+1) · mapped-or-better: 42 → 43 (+1) -- engine LOC: 16,896 → 18,187 (+1,291) · test files: 87 → 88 (+1) · checks: 2,532 → 2,574 (+42) -- addresses verified: 126 → 126 (+0) · recovered layouts: 21 → 21 (+0) · open questions: 24 → 25 (+1) +- verified targets: 26 → 27 (+1) · mapped-or-better: 43 → 43 (+0) +- engine LOC: 18,187 → 19,328 (+1,141) · test files: 88 → 88 (+0) · checks: 2,574 → 2,641 (+67) +- addresses verified: 126 → 166 (+40) · recovered layouts: 21 → 21 (+0) · open questions: 25 → 27 (+2) --- warnings: mars-rng.md: no oracle total row parsed; mars-stream.md: no oracle total row parsed; mars-vfs.md: no oracle total row parsed - + diff --git a/campaign/board.md b/campaign/board.md index b3c7c2c..6060938 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -55,7 +55,8 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | engine: game/design | engine | verified | high | 100% | 2026-09-07 | merged; see ShipDesignDef row | | P2-B1 ComputeBudget (behavioral) | phase2 | in-progress | — | 0% | 2026-09-08 | FIRST BEHAVIORAL BRICK: hook ServerPlayer::ComputeBudget 0x00863030 (verified thiscall, pure, 8 calls/turn) vs game/sim/economy.cpp; workload = ref-turn2.sav + End Turn | | P2-B2 OnTechResearched (behavioral) | phase2 | backlog | — | 0% | 2026-09-08 | hook ServerPlayer::OnTechResearched 0x00891790 (verified) vs the tech-effects layer from sim-pin | -| P2-B3 ProcessResearch (behavioral, RNG) | phase2 | in-progress | — | 0% | 2026-09-08 | offline lane (no VM): RNG-snapshot compare design - snapshot mt[624]+left pre-call, drive ours from a copy, diff outputs AND post-RNG state; verify the float mapping. Builds in /srv/re-lab/build/sots-engine-b3, stages dist-b3. Queued for a VM window behind M2 | -| RNG signatures (Ghidra) | meta | mapped | high | 75% | 2026-09-08 | Seed/Twist/NextFloat verified (own pass): lazy twist, left@+0x9c4, NextFloat via x87 = float-parity risk. NextInt still unverified | +| P2-B3 ProcessResearch (behavioral, RNG) | phase2 | in-progress | med | 70% | 2026-09-08 | CODE-COMPLETE (engine 3ebc8bf), dist-b3 staged. Static analysis found 5 REAL BUGS in our code: draw = y/(2^32-1) not y*2^-32 (constant 1/4294967295; compare could NOT have caught this - 0 decision flips in 1e6); NextInt is [0,n] INCLUSIVE by pointer; spend has no floor at 0; odds/roll/ratio are float32 not double; decay constants are widened float literals. ProcessResearch arg2 = the RNG object. ctest 26/26. NEEDS VM window (queued behind M2) | +| RNG signatures (Ghidra) | meta | verified | high | 100% | 2026-09-08 | Seed/Twist/NextFloat verified (own pass) + NextInt pinned by B3: [0,n] inclusive, bound by pointer. Draw mapping = y/(2^32-1). Lazy twist, left@+0x9c4. Open: x87 precision-control mode at runtime (instrumented - hook logs fpu_cw) | | engine: game/effects | engine | verified | high | 100% | 2026-09-08 | merged: TechId enum (196 slots @10000+i), 44 ids with typed strategic effects, species flag bits, ApplyTechEffect; 254 checks | | VM140 exclusivity (lab rule) | meta | verified | high | 100% | 2026-09-08 | only ONE agent may deploy a shim DLL / stop-start the game at a time; interleaved deploys silently corrupt compare results. Holder: B1. QUEUE: M2 (ready, ~20min), then B3. Non-holders build in /srv/re-lab/build/sots-engine- and stage to /srv/re-lab/shim/dist- | +| Zuul double-roll (behavioural) | verify | backlog | — | 0% | 2026-09-08 | the reference save may have no species-5 player; if so the Zuul double roll is verified only by disassembly. Needs one extra compare from a Zuul save | diff --git a/campaign/open-questions.md b/campaign/open-questions.md index 7da02c1..19c3658 100644 --- a/campaign/open-questions.md +++ b/campaign/open-questions.md @@ -42,3 +42,5 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re- - **RESOLVED: formula gaps (all 8)** — see [[formula-gaps]]; game/sim's low-confidence functions can now be pinned. **RESOLVED: std::string** = `_Bx@0,_Mysize@0x10,_Myres@0x14,_Alval@0x18`, sizeof 0x1c (struct-recovery §0 right; turn-spine §1.1 WRONG — annotated). **CORRECTION:** the 116-entry table @0x00a19718 is a name-membership list, not the effects table; use `g_TechIdNames` (196) + `OnTechResearched`. Open: values of the 6-entry AI-tech bonus table (0x00a17888); producer of ServerPlayer +0x224/+0x228/+0x22c beyond the setup-record copy (0x0077b620). (from [[tech-effects]]) - **RESOLVED: RNG semantics** — `Seed` is `thiscall(this, uint32)` RET 4 (MT19937 init + immediate twist); `Twist` takes `this` in ECX only; **twist is LAZY** (`if (left==0) Twist()` inside the draw), `left` lives at `+0x9c4`, confirming the save blob layout. **float mapping, needs a targeted check (not a red flag):** `NextFloat` decompiles as returning `float10`, but that is simply how x86 float returns look (value in `ST(0)`), so it is weak evidence of extended-precision *computation*; a single `y * 2^-32` multiply rounds once either way. B3 should still compare the mapping explicitly (exact constant and whether the draw is `y*2^-32`, `(y>>8)*2^-24`, or divided by 2^32-1), and the x87-vs-SSE float-parity policy from the RE how-to is still needed before the x64 standalone. `RNG_NextInt` signature still unverified. (own Ghidra pass) - **Struct-modelling hazard (found by M2)** — an MSVC-2010 `std::vector` member is **three words**, so a naive C translation put the next pointer at `+0x18` when it is really at `+0x14`; the hook silently reported the unmodelled word (string bytes `"TION"`) as a real field. Any hand-modelled game struct must pin its size with `static_assert` and account for 3-word vectors. (from [[M2]]) +- **x87 precision-control mode at runtime** — 53-bit MSVC default vs 24-bit if D3D9 grabbed the FPU; moves only the last bit (~0.094% of words). `float_from_pc24()` models it and the B3 hook logs `fpu_cw` per call, so the first B3 trace settles it. (from [[B3]]) +- **Some truths are unreachable by compare** — B3's draw-divisor bug differed on 0.78% of draws yet flipped 0 decisions in 1e6, so a behavioural compare would have passed while the code was wrong. Static reading of the instruction stream remains necessary; compare validates, it does not discover. (from [[B3]]) diff --git a/findings/subsystems/formula-gaps.md b/findings/subsystems/formula-gaps.md index 3fb0cdb..8c692e3 100644 --- a/findings/subsystems/formula-gaps.md +++ b/findings/subsystems/formula-gaps.md @@ -123,3 +123,46 @@ escapes by completing (state 4) in the same call. Net gain of the current tech p (storage 0x00b23e2c / 0x00b23e30). * `TRKSTL_REGENERATION_MOD` path: 0x0079b980 → 0x0079b770 gated by `HasResearched(IND_TRKSTL)`. * Sensor range (system): `SENSORMOD[species] × (hadvs ? ADVSENS_SENSORS_MOD : 1) × 4.0` (0x0080b730). + +--- + +## B3 (2026-09-08) — `ProcessResearch` re-read instruction by instruction + +Prompted by the old-vs-new milestone for `TechTree::ProcessResearch` (engine repo `docs/B3.md`). +Evidence: own `objdump -d` pass over `Sword of the Stars.exe` at 0x005876c0, 0x0057da00, +0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0 and the single call site 0x008914a5. Everything +below is now in `ghidra/addresses.json` (status `verified`) and folded into +`strategic-turn-internals.md` §2.3 / §6, replacing what was there. + +* **`ProcessResearch`'s second argument is the `Mars::RNG` object** (`StrategyServer+0x16c`), + which resolves the `?` in the prototype. The function re-bases it with `+4` before each draw. + The allocation vector's element is `{TechDef* target, int points}` (stride 8) and the node is + `tree->nodes[*(int*)target]`. +* **`NextFloat` divides by `2^32 − 1`, not `2^32`.** 0x009e61b0 holds + `0x3df0000000001000` = `1/4294967295`. The range is therefore closed at 1.0. Measured effect + of the old mapping: a different float32 for 0.78 % of words, and a different research + completion decision for about one draw in two billion — real, but not something a behavioural + compare can catch. +* **`NextInt` is inclusive and takes its bound by pointer.** Mask = smallest `2^k − 1` ≥ `*n` + (from `n`, not `n−1`); redraw while the masked word is `> *n`. So the result is uniform on + `[0, *n]`. Its status moves from `unverified` to `verified`. +* **Generator layout.** Object = `{vftable @+0, mt[624] @+4, uint32* next @+0x9c4, int left + @+0x9c8}` = 0x9cc bytes, but `Twist`/`NextFloat`/`NextInt` all receive `&mt` (object + 4), so + in *their* frame `next`/`left` are at `+0x9c0`/`+0x9c4`. The save blob (0x9c4 bytes) is + `mt[624]` then `left`, skipping `next` — which is fine because `next == &mt[624 − left]`. +* **Rounding.** `odds`, the roll, the Zuul minimum and the `progress/cost` ratio are each stored + to a 4-byte float before they are compared. The decay fraction (0x009e5060) and the + early-completion threshold (0x009e20c8) are widened *float* literals — `0.05000000074505806` + and `0.800000011920929` — not the exact decimals. +* **Smaller corrections.** `spend = min(points, hi − progress)` is a plain signed min with no + floor at 0; the 50/150 % bounds use a 32-bit multiply (it wraps near `INT_MAX`) and a + truncating divide by 100; the decay guard is `progress != 0`, not `> 0`; `Cost` takes the + **node**, not the def, and returns 0 (not 1) when `costRP <= 0`, when the tree has no owner, + or when the cost multiplier is <= 0. +* **`Cost` is read-only** (it only reads `costRP`, the def and the owner, then calls the + read-only multiplier helper 0x0080db50), so a compare harness may call it on a scratch tree. +* **Open.** The x87 precision-control field in force at run time is not decidable statically + here (`_controlfp` is imported and there are ~370 `fldcw` sites, mostly CRT; a D3D9 device + created without `FPU_PRESERVE` would leave 24-bit precision). It changes only the last bit of + a draw (0.094 % of words) and of the odds. The B3 shim records the control word with every + call, so the first trace settles it. diff --git a/findings/subsystems/strategic-turn-internals.md b/findings/subsystems/strategic-turn-internals.md index b2a7c31..6a71763 100644 --- a/findings/subsystems/strategic-turn-internals.md +++ b/findings/subsystems/strategic-turn-internals.md @@ -162,7 +162,7 @@ not "bTurnDone". ### 2.1 Function map | addr | name | role | |---|---|---| -| 0x005876c0 | `Game::TechTree::ProcessResearch(tree, ?, vector<{techIdx,int points}>&, int* overbudget)` | progress + completion roll + decay + unlock events | +| 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 | @@ -193,24 +193,24 @@ is a single pass of `rand01() <= pct/100` per (edge, species) at game start; the ### 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) + 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 + 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 = (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 = (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)" - if (progress/cost < 0.8) node.flag = 0 // 0x009e20c8 + 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.05)) // 0x009e5060: 5 %/turn decay +for each node with state==2 && progress!=0: progress = max(0, progress - ftol(Cost(node)*0.05f)) // 0x009e5060 = (double)0.05f 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. @@ -478,11 +478,13 @@ mapping (index 8/0/4 → ally/nap/cf inferred from the int16 layout). | 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) | +| 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`. Serialisation +`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`) 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 diff --git a/ghidra/addresses.json b/ghidra/addresses.json index 456bf89..ef38f28 100644 --- a/ghidra/addresses.json +++ b/ghidra/addresses.json @@ -70,17 +70,17 @@ "name": "ServerPlayer_ComputeBudget", "addr": "0x00863030", "convention": "thiscall", - "prototype": "void (ServerPlayer* this, int out[25], bool projected)", - "status": "verified", - "source": "handoff/formula-gaps.md#q5" + "prototype": "void (ServerPlayer* this, Budget* out, bool projected) /* Budget = { int slot[22]; std::vector<{Tech* node, int points}> researchAlloc (3 words @+0x58); int overBudget @+0x64 }. The array is 22 ints, not 25: the three words after slot 21 are the allocation vector and grow by one 8-byte element exactly when the player has a research target. Slots: 0 Sav, 1 systemIncome+, 2 trade, 3 shipCarriedPop, 4 secondaryManager, 5 savingsInterest, 6 bonusIncome, 7 systemIncome-, 8 maintenance, 9 researchMoneyKept (written only when ResT is set), 10 debtInterest, 11 construction, 12 expenses, 13 researchMoneyGiven, 14 savingsGiven, 15 available, 16 researchMoney, 17 researchPoints, 18 TRA, 19 researchPointsGiven, 20 TRP, 21 totalResearchPoints */", + "status": "verified-by-trace", + "source": "handoff/formula-gaps.md#q5 + sots-engine docs/B1.md (live trace, 4623 calls)" }, { "name": "TechTree_ProcessResearch", "addr": "0x005876c0", "convention": "thiscall", - "prototype": "void (TechTree* this, ?, vector<{node,int}>* alloc, int* overbudget) /* decay loop hits current target */", + "prototype": "void (TechTree* this, Mars::RNG* rng, std::vector<{TechDef* target, int points}>* alloc, int* overbudget) /* args confirmed at the single call site: rng = StrategyServer+0x16c object, passed as the RNG *object* and re-based to &mt with lea ecx,[rng+4] before each NextFloat. Loop: node = this->nodes[*(int*)entry.target]; lo/hi from Cost x 50/150 /100 (32-bit imul, signed /100); spend = min(points, hi-progress) with NO clamp at 0; *overbudget += points-spend; odds and roll are both narrowed to float32 before the compare; decay loop hits every state-2 node with progress != 0; the trailing unlock-event loop makes no RNG draw and writes no node */", "status": "verified", - "source": "handoff/formula-gaps.md#q8" + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, { "name": "ServerSystem_Read", @@ -174,33 +174,81 @@ "name": "RNG_Twist", "addr": "0x00426e00", "convention": "thiscall", - "prototype": "void (RNG* this) // this in ECX, no stack args, RET 0; 227/396 split", + "prototype": "void (uint32_t* mtBlock) // ECX = &mt = RNG object + 4 (NOT the object). N=624 M=397 MATRIX_A 0x9908b0df, 227/396 split; ends with left(&mt+0x9c4) = 624 and next(&mt+0x9c0) = &mt[0]. RET 0", "status": "verified", - "source": "own Ghidra pass 2026-09-08 (RngSig)" + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, { "name": "RNG_Seed", "addr": "0x0049fdf0", "convention": "thiscall", - "prototype": "RNG* (RNG* this, uint32_t seed) // RET 4; MT19937 init mt[i]=(mt[i-1]^(mt[i-1]>>30))*0x6c078965+i for i=1..623, then twists. this: vftable@0, mt[624]@+4, left@+0x9c4", + "prototype": "RNG* (RNG* this, uint32_t seed) // RET 4; writes vftable 0x009e9aec, seeds from &mt = this+4: mt[0]=seed, mt[i]=0x6c078965*(mt[i-1]^(mt[i-1]>>30))+i for i=1..623, then Twist(&mt). OBJECT layout: {vftable @+0; uint32 mt[624] @+4 .. +0x9c3; uint32* next @+0x9c4; int left @+0x9c8} = 0x9cc bytes. Beware: NextFloat/NextInt/Twist take &mt, so *their* this+0x9c0/+0x9c4 are next/left", "status": "verified", - "source": "own Ghidra pass 2026-09-08 (RngSig)" + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, { "name": "RNG_NextFloat", "addr": "0x0047d830", "convention": "thiscall", - "prototype": "float (RNG* this) // returns via x87 (float10 in decompile) -- FLOAT PARITY RISK; lazy twist: if (left==0) Twist()", + "prototype": "float (uint32_t* mtBlock) // ECX = &mt = RNG object + 4. Lazy twist (if left==0 Twist()), y = *next++, left--, standard MT tempering, then value = (double)(uint32)y * 1/(2^32-1) -- the multiplier at 0x009e61b0 is 0x3df0000000001000 = 1/4294967295.0, NOT 2^-32; the unsigned fix-up at 0x009e61b8 is +2^32 after a signed fild. Left in st(0) at the current x87 precision and narrowed by the caller; range [0,1] INCLUSIVE", "status": "verified", - "source": "own Ghidra pass 2026-09-08 (RngSig)" + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, { "name": "RNG_NextInt", "addr": "0x004271c0", "convention": "thiscall", - "prototype": "uint32_t (RNG* this)", - "status": "unverified", - "source": "findings/subsystems/strategic-turn-internals.md#rng" + "prototype": "uint32_t (uint32_t* mtBlock, const uint32_t* n) // ECX = &mt; the bound is passed BY POINTER, RET 4. mask = smallest 2^k-1 >= *n via the or/shift cascade; draws (lazy twist + tempering) until (y & mask) <= *n -- note the loop condition is `ja`, so the result is uniform on [0, *n] INCLUSIVE, not [0, n)", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "RNG_size", + "offset": "0x9cc", + "convention": "offset", + "prototype": "sizeof(Mars::RNG) -- {vftable @+0; uint32 mt[624] @+4; uint32* next @+0x9c4; int left @+0x9c8}", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "RNG_off_State", + "offset": "0x4", + "convention": "offset", + "prototype": "uint32 mt[624]; also the pointer Twist/NextFloat/NextInt receive in ECX", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "RNG_off_Next", + "offset": "0x9c4", + "convention": "offset", + "prototype": "uint32* next; always &mt[624 - left], and recomputed from left on load, so `left` alone pins the stream position", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "RNG_off_Left", + "offset": "0x9c8", + "convention": "offset", + "prototype": "int left; words still unread in the current block", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechDef_off_TechId", + "offset": "0x0", + "convention": "offset", + "prototype": "int tech id -- the index into TechTree_off_Nodes", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "ResearchAlloc_stride", + "offset": "0x8", + "convention": "offset", + "prototype": "sizeof({TechDef* target, int points}) -- the element of the vector ProcessResearch walks", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" }, { "name": "Mars_Application_MountModules", @@ -882,6 +930,94 @@ "status": "verified", "source": "handoff/tech-effects.md#0" }, + { + "name": "TechTree_Cost", + "addr": "0x0057da00", + "convention": "thiscall", + "prototype": "int (TechTree* this, TechNode* node) // RET 4. node==0 -> INT_MAX; node->costRP(+0x18)==INT_MAX -> INT_MAX; costRP<=0 or this->owner(+0xc)==0 or mult<=0 -> 0; else max(1, _ftol2((float)TechCostMult(owner, node->def) * costRP)). Reads only (no RNG, no writes), so a reimplementation may call it on a scratch tree whose +0xc is the real owner", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "ServerPlayer_TechCostMult", + "addr": "0x0080db50", + "convention": "thiscall", + "prototype": "float (ServerPlayer* this, TechDef* def) // 1.0 - 0.25 per applicable species research-bonus tech owned; read-only", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechTree_SetResearched", + "addr": "0x00581e10", + "convention": "thiscall", + "prototype": "void (TechTree* this, TechDef* def, int flags) // state 4 + turn/order stamps + owner callback + child unlock cascade. Makes no direct RNG draw; the owner callback is not audited, so a compare that runs it is out of scope", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechTree_off_Owner", + "offset": "0xc", + "convention": "offset", + "prototype": "ServerPlayer* owner (0 for a tree with no player)", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechTree_off_Nodes", + "offset": "0x10", + "convention": "offset", + "prototype": "std::vector indexed by tech id (MSVC2010: 3 words {first@+0x10, last@+0x14, end@+0x18}); entries may be NULL", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechNode_size", + "offset": "0x34", + "convention": "offset", + "prototype": "sizeof(TechNode) -- the ctor's operator new argument", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechNode_off_Def", + "offset": "0x0", + "convention": "offset", + "prototype": "TechDef* def; *(int*)def is the tech id used to index TechTree_off_Nodes", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechNode_off_State", + "offset": "0x14", + "convention": "offset", + "prototype": "int state (0 hidden, 1 parent researched, 2 available, 4 researched); ctor writes 0", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechNode_off_CostRP", + "offset": "0x18", + "convention": "offset", + "prototype": "int costRP, INT_MAX = no researched parent yet; ctor writes 0x7fffffff", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechNode_off_Progress", + "offset": "0x1c", + "convention": "offset", + "prototype": "int progress in RP; the only node word ProcessResearch itself writes besides the flag", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, + { + "name": "TechNode_off_Flag", + "offset": "0x2c", + "convention": "offset", + "prototype": "int flag (1 default from the ctor, 0 completed below 80% of cost, 2 over-budget event raised)", + "status": "verified", + "source": "B3 own disassembly pass 2026-09-08 (objdump/pei-i386): 0x005876c0, 0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0, 0x0057da00, 0x008914a5" + }, { "name": "MasterTechTree_ResolveTechIds", "addr": "0x00581c10", @@ -1097,6 +1233,182 @@ "prototype": "int (config storage; PTR slot 0x00aedfe4)", "status": "verified", "source": "handoff/tech-effects.md#1" + }, + { + "name": "ServerPlayer_off_PlyrIdx", + "offset": "0x28", + "convention": "offset", + "prototype": "int PlyrIdx", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_OwnedSystems", + "offset": "0x30", + "convention": "offset", + "prototype": "std::vector owned systems (MSVC2010 release layout: 3 words {first@+0, last@+4, end@+8}, handle ids, 4 B stride); save tag NumOwn/OwnId", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_Species", + "offset": "0x5c", + "convention": "offset", + "prototype": "int Species (0 Human .. 6 Morrigi)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_ResRate", + "offset": "0xbc", + "convention": "offset", + "prototype": "float ResRate (research share of available money, 0..1)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_ResMod", + "offset": "0xc0", + "convention": "offset", + "prototype": "float ResMod", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_ResScl", + "offset": "0xc4", + "convention": "offset", + "prototype": "float ResScl", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_TRM", + "offset": "0xd0", + "convention": "offset", + "prototype": "float TRM (timed research multiplier bonuses)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_TRA", + "offset": "0xd4", + "convention": "offset", + "prototype": "int TRA (per-turn research-point contribution)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_TRP", + "offset": "0xd8", + "convention": "offset", + "prototype": "int TRP (per-turn research-point contribution)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_Elim", + "offset": "0xf8", + "convention": "offset", + "prototype": "bool Elim", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_IsAI", + "offset": "0xf9", + "convention": "offset", + "prototype": "bool isAI /* gate ComputeBudget reads for the construction slot and for picking the AI difficulty-mod row */", + "status": "verified", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_NPC", + "offset": "0xfb", + "convention": "offset", + "prototype": "bool NPC", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_RebAI", + "offset": "0xfc", + "convention": "offset", + "prototype": "bool RebAI", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_Maint", + "offset": "0x15c", + "convention": "offset", + "prototype": "int Maint (raw fleet upkeep, before the difficulty divisor)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_shrm", + "offset": "0x160", + "convention": "offset", + "prototype": "float shrm (shared research modifier)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_Nexp", + "offset": "0x204", + "convention": "offset", + "prototype": "std::vector expense sliders (3 words; entry 16 B {int xid, int xmin, int xmax, float xper}); save tag Nexp", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_SetupIncomeMult", + "offset": "0x228", + "convention": "offset", + "prototype": "float income multiplier from the game-setup handicap block; ComputeBudget bonus slot = ftol((this - 1.0) x net)", + "status": "verified", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_SetupResearchMult", + "offset": "0x22c", + "convention": "offset", + "prototype": "float research multiplier from the game-setup handicap block; a factor of the research-points slot", + "status": "verified", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_Sav", + "offset": "0x284", + "convention": "offset", + "prototype": "int Sav (treasury)", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_ResearchTarget", + "offset": "0x294", + "convention": "offset", + "prototype": "Tech* current research target (ResT); NULL = none", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_IncMod", + "offset": "0x30c", + "convention": "offset", + "prototype": "float IncMod", + "status": "verified-by-save", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" + }, + { + "name": "ServerPlayer_off_Aid", + "offset": "0x310", + "convention": "offset", + "prototype": "std::vector (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive})", + "status": "verified", + "source": "findings/objects/struct-recovery.md#2 (Read/Write serializers) + findings/subsystems/strategic-turn-internals.md#1.3" } ] -} \ No newline at end of file +} diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index e575f92..e619ed7 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 87a39b6, generated 2026-09-07 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 73f5f1e, generated 2026-09-07 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -23,9 +23,9 @@ constexpr uint32_t StrategyClient_EndTurn = 0x00383be0; constexpr uint32_t StrategyServer_BeginProcessTurn = 0x003d98e0; // thiscall void (StrategyServer* this) [unverified] constexpr uint32_t StrategyServer_ProcessTurn = 0x003dc6c0; -// thiscall void (ServerPlayer* this, int out[25], bool projected) [verified] +// thiscall void (ServerPlayer* this, Budget* out, bool projected) /* Budget = { int slot[22]; std::vector<{Tech* node, int points}> researchAlloc (3 words @+0x58); int overBudget @+0x64 }. The array is 22 ints, not 25: the three words after slot 21 are the allocation vector and grow by one 8-byte element exactly when the player has a research target. Slots: 0 Sav, 1 systemIncome+, 2 trade, 3 shipCarriedPop, 4 secondaryManager, 5 savingsInterest, 6 bonusIncome, 7 systemIncome-, 8 maintenance, 9 researchMoneyKept (written only when ResT is set), 10 debtInterest, 11 construction, 12 expenses, 13 researchMoneyGiven, 14 savingsGiven, 15 available, 16 researchMoney, 17 researchPoints, 18 TRA, 19 researchPointsGiven, 20 TRP, 21 totalResearchPoints */ [verified-by-trace] constexpr uint32_t ServerPlayer_ComputeBudget = 0x00463030; -// thiscall void (TechTree* this, ?, vector<{node,int}>* alloc, int* overbudget) /* decay loop hits current target */ [verified] +// thiscall void (TechTree* this, Mars::RNG* rng, std::vector<{TechDef* target, int points}>* alloc, int* overbudget) /* args confirmed at the single call site: rng = StrategyServer+0x16c object, passed as the RNG *object* and re-based to &mt with lea ecx,[rng+4] before each NextFloat. Loop: node = this->nodes[*(int*)entry.target]; lo/hi from Cost x 50/150 /100 (32-bit imul, signed /100); spend = min(points, hi-progress) with NO clamp at 0; *overbudget += points-spend; odds and roll are both narrowed to float32 before the compare; decay loop hits every state-2 node with progress != 0; the trailing unlock-event loop makes no RNG draw and writes no node */ [verified] constexpr uint32_t TechTree_ProcessResearch = 0x001876c0; // thiscall void (ServerSystem* this /*+8 IStreamable*/, Stream* s) [verified-by-save] constexpr uint32_t ServerSystem_Read = 0x0035d4b0; @@ -49,14 +49,26 @@ constexpr uint32_t StrategyServer_Read = 0x003d27a0; 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) // this in ECX, no stack args, RET 0; 227/396 split [verified] +// thiscall void (uint32_t* mtBlock) // ECX = &mt = RNG object + 4 (NOT the object). N=624 M=397 MATRIX_A 0x9908b0df, 227/396 split; ends with left(&mt+0x9c4) = 624 and next(&mt+0x9c0) = &mt[0]. RET 0 [verified] constexpr uint32_t RNG_Twist = 0x00026e00; -// thiscall RNG* (RNG* this, uint32_t seed) // RET 4; MT19937 init mt[i]=(mt[i-1]^(mt[i-1]>>30))*0x6c078965+i for i=1..623, then twists. this: vftable@0, mt[624]@+4, left@+0x9c4 [verified] +// thiscall RNG* (RNG* this, uint32_t seed) // RET 4; writes vftable 0x009e9aec, seeds from &mt = this+4: mt[0]=seed, mt[i]=0x6c078965*(mt[i-1]^(mt[i-1]>>30))+i for i=1..623, then Twist(&mt). OBJECT layout: {vftable @+0; uint32 mt[624] @+4 .. +0x9c3; uint32* next @+0x9c4; int left @+0x9c8} = 0x9cc bytes. Beware: NextFloat/NextInt/Twist take &mt, so *their* this+0x9c0/+0x9c4 are next/left [verified] constexpr uint32_t RNG_Seed = 0x0009fdf0; -// thiscall float (RNG* this) // returns via x87 (float10 in decompile) -- FLOAT PARITY RISK; lazy twist: if (left==0) Twist() [verified] +// thiscall float (uint32_t* mtBlock) // ECX = &mt = RNG object + 4. Lazy twist (if left==0 Twist()), y = *next++, left--, standard MT tempering, then value = (double)(uint32)y * 1/(2^32-1) -- the multiplier at 0x009e61b0 is 0x3df0000000001000 = 1/4294967295.0, NOT 2^-32; the unsigned fix-up at 0x009e61b8 is +2^32 after a signed fild. Left in st(0) at the current x87 precision and narrowed by the caller; range [0,1] INCLUSIVE [verified] constexpr uint32_t RNG_NextFloat = 0x0007d830; -// thiscall uint32_t (RNG* this) [unverified] +// thiscall uint32_t (uint32_t* mtBlock, const uint32_t* n) // ECX = &mt; the bound is passed BY POINTER, RET 4. mask = smallest 2^k-1 >= *n via the or/shift cascade; draws (lazy twist + tempering) until (y & mask) <= *n -- note the loop condition is `ja`, so the result is uniform on [0, *n] INCLUSIVE, not [0, n) [verified] constexpr uint32_t RNG_NextInt = 0x000271c0; +// offset sizeof(Mars::RNG) -- {vftable @+0; uint32 mt[624] @+4; uint32* next @+0x9c4; int left @+0x9c8} [verified] +constexpr uint32_t RNG_size = 0x000009cc; +// offset uint32 mt[624]; also the pointer Twist/NextFloat/NextInt receive in ECX [verified] +constexpr uint32_t RNG_off_State = 0x00000004; +// offset uint32* next; always &mt[624 - left], and recomputed from left on load, so `left` alone pins the stream position [verified] +constexpr uint32_t RNG_off_Next = 0x000009c4; +// offset int left; words still unread in the current block [verified] +constexpr uint32_t RNG_off_Left = 0x000009c8; +// offset int tech id -- the index into TechTree_off_Nodes [verified] +constexpr uint32_t TechDef_off_TechId = 0x00000000; +// offset sizeof({TechDef* target, int points}) -- the element of the vector ProcessResearch walks [verified] +constexpr uint32_t ResearchAlloc_stride = 0x00000008; // cdecl void (void) /* sots.ini [Modules] Mount -> gobio::Init */ [verified] constexpr uint32_t Mars_Application_MountModules = 0x004a0a20; // cdecl void (int* count, const char*** table) /* count 3 @0x00b2d514, table @0x00a35e98 */ [verified] @@ -227,6 +239,28 @@ constexpr uint32_t MasterTechTree_IsTech = 0x0017d5d0; constexpr uint32_t MasterTechTree_GetTechDef = 0x0017d610; // thiscall bool (TechTree* this, int techId) [verified] constexpr uint32_t TechTree_HasResearched = 0x0017d810; +// thiscall int (TechTree* this, TechNode* node) // RET 4. node==0 -> INT_MAX; node->costRP(+0x18)==INT_MAX -> INT_MAX; costRP<=0 or this->owner(+0xc)==0 or mult<=0 -> 0; else max(1, _ftol2((float)TechCostMult(owner, node->def) * costRP)). Reads only (no RNG, no writes), so a reimplementation may call it on a scratch tree whose +0xc is the real owner [verified] +constexpr uint32_t TechTree_Cost = 0x0017da00; +// thiscall float (ServerPlayer* this, TechDef* def) // 1.0 - 0.25 per applicable species research-bonus tech owned; read-only [verified] +constexpr uint32_t ServerPlayer_TechCostMult = 0x0040db50; +// thiscall void (TechTree* this, TechDef* def, int flags) // state 4 + turn/order stamps + owner callback + child unlock cascade. Makes no direct RNG draw; the owner callback is not audited, so a compare that runs it is out of scope [verified] +constexpr uint32_t TechTree_SetResearched = 0x00181e10; +// offset ServerPlayer* owner (0 for a tree with no player) [verified] +constexpr uint32_t TechTree_off_Owner = 0x0000000c; +// offset std::vector indexed by tech id (MSVC2010: 3 words {first@+0x10, last@+0x14, end@+0x18}); entries may be NULL [verified] +constexpr uint32_t TechTree_off_Nodes = 0x00000010; +// offset sizeof(TechNode) -- the ctor's operator new argument [verified] +constexpr uint32_t TechNode_size = 0x00000034; +// offset TechDef* def; *(int*)def is the tech id used to index TechTree_off_Nodes [verified] +constexpr uint32_t TechNode_off_Def = 0x00000000; +// offset int state (0 hidden, 1 parent researched, 2 available, 4 researched); ctor writes 0 [verified] +constexpr uint32_t TechNode_off_State = 0x00000014; +// offset int costRP, INT_MAX = no researched parent yet; ctor writes 0x7fffffff [verified] +constexpr uint32_t TechNode_off_CostRP = 0x00000018; +// offset int progress in RP; the only node word ProcessResearch itself writes besides the flag [verified] +constexpr uint32_t TechNode_off_Progress = 0x0000001c; +// offset int flag (1 default from the ctor, 0 completed below 80% of cost, 2 over-budget event raised) [verified] +constexpr uint32_t TechNode_off_Flag = 0x0000002c; // thiscall void (MasterTechTree* this) /* fills TechDef*[196] at this+0 from g_TechIdNames */ [verified] constexpr uint32_t MasterTechTree_ResolveTechIds = 0x00181c10; // cdecl int (std::string* name) /* membership in g_CombatTechNames (116) */ [verified] @@ -281,5 +315,49 @@ constexpr uint32_t g_AITechValueTable = 0x00617888; constexpr uint32_t g_PERGATETRAFFIC_DRV_TpGate = 0x00723e2c; // data int (config storage; PTR slot 0x00aedfe4) [verified] constexpr uint32_t g_PERGATETRAFFIC_DRV_GatAmp = 0x00723e30; +// offset int PlyrIdx [verified-by-save] +constexpr uint32_t ServerPlayer_off_PlyrIdx = 0x00000028; +// offset std::vector owned systems (MSVC2010 release layout: 3 words {first@+0, last@+4, end@+8}, handle ids, 4 B stride); save tag NumOwn/OwnId [verified-by-save] +constexpr uint32_t ServerPlayer_off_OwnedSystems = 0x00000030; +// offset int Species (0 Human .. 6 Morrigi) [verified-by-save] +constexpr uint32_t ServerPlayer_off_Species = 0x0000005c; +// offset float ResRate (research share of available money, 0..1) [verified-by-save] +constexpr uint32_t ServerPlayer_off_ResRate = 0x000000bc; +// offset float ResMod [verified-by-save] +constexpr uint32_t ServerPlayer_off_ResMod = 0x000000c0; +// offset float ResScl [verified-by-save] +constexpr uint32_t ServerPlayer_off_ResScl = 0x000000c4; +// offset float TRM (timed research multiplier bonuses) [verified-by-save] +constexpr uint32_t ServerPlayer_off_TRM = 0x000000d0; +// offset int TRA (per-turn research-point contribution) [verified-by-save] +constexpr uint32_t ServerPlayer_off_TRA = 0x000000d4; +// offset int TRP (per-turn research-point contribution) [verified-by-save] +constexpr uint32_t ServerPlayer_off_TRP = 0x000000d8; +// offset bool Elim [verified-by-save] +constexpr uint32_t ServerPlayer_off_Elim = 0x000000f8; +// offset bool isAI /* gate ComputeBudget reads for the construction slot and for picking the AI difficulty-mod row */ [verified] +constexpr uint32_t ServerPlayer_off_IsAI = 0x000000f9; +// offset bool NPC [verified-by-save] +constexpr uint32_t ServerPlayer_off_NPC = 0x000000fb; +// offset bool RebAI [verified-by-save] +constexpr uint32_t ServerPlayer_off_RebAI = 0x000000fc; +// offset int Maint (raw fleet upkeep, before the difficulty divisor) [verified-by-save] +constexpr uint32_t ServerPlayer_off_Maint = 0x0000015c; +// offset float shrm (shared research modifier) [verified-by-save] +constexpr uint32_t ServerPlayer_off_shrm = 0x00000160; +// offset std::vector expense sliders (3 words; entry 16 B {int xid, int xmin, int xmax, float xper}); save tag Nexp [verified-by-save] +constexpr uint32_t ServerPlayer_off_Nexp = 0x00000204; +// offset float income multiplier from the game-setup handicap block; ComputeBudget bonus slot = ftol((this - 1.0) x net) [verified] +constexpr uint32_t ServerPlayer_off_SetupIncomeMult = 0x00000228; +// offset float research multiplier from the game-setup handicap block; a factor of the research-points slot [verified] +constexpr uint32_t ServerPlayer_off_SetupResearchMult = 0x0000022c; +// offset int Sav (treasury) [verified-by-save] +constexpr uint32_t ServerPlayer_off_Sav = 0x00000284; +// offset Tech* current research target (ResT); NULL = none [verified-by-save] +constexpr uint32_t ServerPlayer_off_ResearchTarget = 0x00000294; +// offset float IncMod [verified-by-save] +constexpr uint32_t ServerPlayer_off_IncMod = 0x0000030c; +// offset std::vector (3 words; entry 0x18 B, {+0x8 int researchPercent, +0xc int researchActive, +0x10 int savings, +0x14 int savingsActive}) [verified] +constexpr uint32_t ServerPlayer_off_Aid = 0x00000310; } // namespace sots::addr diff --git a/tools/gen_addresses.py b/tools/gen_addresses.py index e885f51..eda3c3d 100755 --- a/tools/gen_addresses.py +++ b/tools/gen_addresses.py @@ -12,9 +12,11 @@ out = [ "// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1 "// 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 + # Two entry shapes: an "addr" (absolute VA -> RVA) or an "offset" (a field offset inside a + # struct, emitted verbatim). Offsets are facts about layout, not load addresses. + val = int(e["offset"], 16) if "offset" in e else 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.append(f"constexpr uint32_t {e['name']} = 0x{val: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)")