Compare commits

...

2 commits

Author SHA1 Message Date
alex
58e3d85f3f board: lane V2 vtable inversion; 5,045 functions with no direct callers; tail draw surface is unobserved not absent 2026-09-08 12:06:22 -04:00
alex
648028db67 lane V2: vtable inversion — resolve indirect call edges image-wide
Every call-graph result in this repo was computed over direct (E8) edges.
5,045 of the 5,207 functions named by a vftable slot have zero direct call
sites, so all of those results were lower bounds. Lane Z's dominant RNG
consumer hung off exactly such an edge.

tools/vtable_map.py builds, from the RTTI walk plus a full sweep to the next
function start (rule 17):
  * vftable -> class -> sub-object offset -> slot -> target, and its inverse
  * the class hierarchy from the RTTI base lists, so an abstract interface
    with one concrete override resolves uniquely
  * constructor-derived member typing (ctor result -> [this+d])
  * the slot index at every indirect call site, with a backward register
    resolver that refuses to cross a branch target rather than guess
  * `this`-carrier spans and this/member call-graph propagation of class

Validation (12/12): rediscovers ServerTradeManagerImpl slot 10 ->
GenerateTradeRaidEncounters from the dispatch at 0x007d8469 with nothing
hand-fed, and re-derives the *Impl rule for both managers. Receiver-class
pinning reaches only 2.5% of the 6,398 virtual sites, at 0.6% out-of-range
against a 70% chance baseline; the displacement-only route measured worse
than random (81% vs 58%) and is rejected outright.

Closes lane K's tier-4 blind spot: all nine phase-23 calls and both phase-33
calls named. Four of the eleven reach a draw on the strategic generator
(StrategyServer+0x16c) at eight instruction-verified sites, none ever
observed firing — so "the tail draws nothing" is a property of eight turns,
not of the code. Also resolves the nine parked inlined-draw functions to
their vtable roots (correcting how that was recorded: none is itself in a
vftable; their topmost ancestors are), and finds 14,958 inter-function tail
jump edges without which three of them look like dead code.
2026-09-08 12:05:05 -04:00
7 changed files with 2108 additions and 1 deletions

View file

@ -200,3 +200,9 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| retreat pipeline: events and RNG | verify | verified | high | 100% | 2026-09-08 | EXACTLY TWO EVENT_* keys in the 327-function closure - EVENT_FLEET_RETREATED_VIA_TELEPORT (depth 1, and gate retreat is species 1 + mode 1 + a destination, the only path to it) and EVENT_FLEET_INTERCEPT_ABORTED (depth 3, posted to the INTERCEPTOR'S owner when a split empties a fleet). Both were already in lane J's 23 at the right depth; what is NEW is the condition for each and that these two are the ONLY ones this pipeline can post. DRAW-FREE CONFIRMED INDEPENDENTLY: zero RNG calls, zero inlined MT tempering immediates, including the fleet-name generator. The one 0x11c hit is a FALSE POSITIVE (FtMS = -1 in the fleet ctor) - no turn-results write here |
| new failure mode: tail-call thunk in an argument list | meta | verified | high | 100% | 2026-09-08 | Lane B5 correcting ITSELF: it first read the fleet-creation call's arguments wrong, because a `ret`-N TAIL-CALL THUNK IN THE MIDDLE OF AN ARGUMENT LIST makes three pushes look like they belong to the wrong call. CHECKING `ret N` AGAINST THE PUSH COUNT CATCHES IT. Worth a rules line - and note the same thunks HID the OID allocation chain from a closure pass entirely |
| duplicate-name check working as intended | meta | verified | high | 100% | 2026-09-08 | Two live demonstrations today. (1) Lane B5 found StarFleet_SetFlag ALREADY in addresses.json from lane B4, same address and same prototype DERIVED FROM A DIFFERENT PATH - entry dropped, agreement recorded as corroboration. (2) The generator HARD-ERRORED on my merge: `duplicate address entry 'StrategyServer_off_TradeManager': in lane-t.json and lane-v2.json`, catching an in-flight collision between a landed lane and a running one. Exactly the designed behaviour: two lanes disagreeing about an address surfaces instead of being silently last-wins |
| vtable inversion tool | meta | verified | high | 100% | 2026-09-08 | Lane V2, `tools/vtable_map.py`. VALIDATED BLIND FIRST: 12/12, inputs only the PE + Ghidra function starts + the existing RTTI walk, nothing hand-fed - site 0x007d8469 classified virtual, slot 10 recovered, receiver typed as member +0x158, class Game::ServerTradeManagerImpl, target 0x00893290, zero direct call sites. Member typing came from the StrategyServer CONSTRUCTOR (0x007d78d0), corroborated independently by a method entered on the +4 SUB-OBJECT storing the same two ctor results exactly 4 bytes lower - TWO FRAMES, ONE ANSWER, and it re-derives lane T's 0x154 from a different direction. THE MAP IS EXACT: 2,172 vftables -> class -> sub-object offset -> slot -> target plus the inverse, a vftable accepted only with a COL at [vftable-4] pointing at a real type descriptor; no inference, no FPs. `ghidra/vtable-owners.json` |
| SCALE OF THE INDIRECT BLIND SPOT | meta | verified | high | 100% | 2026-09-08 | **5,045 of the 5,207 functions named by a vtable slot have ZERO DIRECT CALL SITES.** That is the size of what every call-graph result in this campaign was blind to. Additionally, sweeping for jumps into another function's start finds **14,958 MORE EDGES** - and three of the nine parked inlined-draw functions have NO E8 CALLER AT ALL and would read as dead code without them (the same tail-call-thunk effect that hid the OID allocation chain from lane B5's first pass) |
| indirect resolution: honest precision | meta | verified | high | 100% | 2026-09-08 | Lane V2 reported this as two things with very different precision rather than one number. Of 17,577 indirect sites: 7,415 are `call [disp32]` import thunks (not dispatch), **6,398 are PROVEN virtual with an exact slot**, 2,948 REPORT UNRESOLVED because the backward resolver refuses to cross a branch target or an unmodelled opcode. RECEIVER TYPING IS NOT SOLVED: only 163 of 6,398 (2.5%) get a class - but on those the falsification test fires on 1 in 163 (0.6%) against a 70% random baseline. THE DISPLACEMENT-ONLY ROUTE MEASURED 81% OUT-OF-RANGE VS 58% FOR A RANDOM VTABLE - WORSE THAN CHANCE - SO IT IS REJECTED OUTRIGHT, NOT FLAGGED (rule 9 with the ranker off). Three real bugs the falsification test caught: [ebp+8] typed as a member of `this`; constructors given a vftable's sub-object offset instead of 0; carrier spans not ending at the epilogue's pop esi |
| lane K tier-4 blind spot CLOSED | control-flow | verified | high | 100% | 2026-09-08 | The tool reproduces lane K's byte-level transcription of phase 23 EXACTLY AND INDEPENDENTLY, then names all eleven targets: trade slots 14/8/12/11/9/7/13/15 -> 0x008590d0 0x0088e8d0 0x0088e920 0x00848570 0x00868060 0x0088ad60 0x0088ef80 0x0082cca0; the ninth call is on +0x15c = ServerSpyManager slot 13 -> 0x008877b0; phase 33 = spy slots 14/15. All eleven have ZERO direct call sites. ALSO: Game::ServerSpyManager has NO *Impl - it is itself concrete over IServerSpyManager/ISpyManager/IStreamable, the opposite of the ServerTradeManager shape, so the *Impl rule is a pattern to CHECK, not to assume |
| CORRECTION: "the tail draws nothing" is true of 8 TURNS, not of the code | verify | backlog | — | 0% | 2026-09-08 | FOUR of the eleven phase-23/33 targets REACH A DRAW on the strategic generator (StrategyServer+0x16c), verified at the instruction: 0x00887c8a, 0x00840929, 0x00840a3c, 0x008409c7, 0x0088dc43, 0x0082cdb8, 0x00820e18, 0x0088b613. NONE HAS EVER BEEN OBSERVED FIRING - lane Z measured 0 tail words across 8 turns. So tail-rng-ledger.md's "the tail draws nothing" is a PROPERTY OF EIGHT TURNS. RULE-18 ITEM: hook those four callees with a save that has LIVE TRADE ROUTES AND A SPY PROGRAM. Our corpus has neither |
| RNG re-check over indirect edges: nothing contradicted | verify | verified | high | 100% | 2026-09-08 | Lane I's 22-site inventory is intact and lane Z's residual stays zero. CORRECTION to how the nine parked functions were recorded: NONE of the eleven inlined-draw functions is itself in a vftable (no dword equal to any of them exists in the image) - their TOPMOST DIRECT-CALL ANCESTORS are, and all nine resolved. CrowRuinsEncounter/SwarmEncounter are CombatEncounterBase (16 slots), NOT SVScriptObject, so ProcessTurn's slot-11 dispatch is not a route to them. NEW DRAW SURFACE: the SVScriptObject hooks on StrategyServer+0x1b4 (lane T's field), 30 derived classes, 11 of 14 slot-4 overrides / 4 of 7 slot-11 / 2 of 8 slot-25 reach a draw - with TWO LIMITS STATED: the generator arrives as an ARGUMENT at depth 2-4 so its identity is unproven, and +0x1b4's class is a HYPOTHESIS here (no ctor store found; lane T had it verified, lane V2 records the WEAKER claim). STRENGTHENED: over direct + every pinnable indirect edge, ProcessTurn's closure grows 1,430 -> 1,486 and the tail's 1,424 -> 1,668, and the only draws those ~300 functions add are the eight above. The MAXIMAL over-approximation puts half the image (19,697/41,089) in the closure - USELESS, AND LANE V2 SAYS SO rather than dressing it up |

View file

@ -0,0 +1,380 @@
# Indirect call edges — the vtable inversion, and what it closes
Lane V2, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
**Why this lane exists.** Lane Z's live hooking found that the single largest RNG consumer of a strategic
turn, `ServerTradeManager::GenerateTradeRaidEncounters` 0x00893290, has **zero direct call sites** in the
41,411-function image. Its only reference is the `Game::ServerTradeManagerImpl` vftable entry at 0x00a31b9c,
dispatched by `call edx` at 0x007d8469 — **one instruction before** a direct call lane I's closure did
follow. Lane I said plainly that its closure was direct-edge only. That caveat turned out to be
load-bearing, and it is systemic, not a one-off.
**How systemic.** Measured here:
| | count |
|---|---|
| functions named by at least one vftable slot | **5,207** |
| of those, functions with **zero** direct (`E8 rel32`) call sites | **5,045** |
| indirect call sites image-wide | 17,577 |
| of those, proven virtual dispatches (a vptr load then a slot load) | **6,398** |
Every reachability claim, closure size and "no caller" result this campaign has published was computed over
direct edges. **All of them are lower bounds.** This document is the tool that lifts that, the honest
measurement of how far it lifts it, and the three questions it answers.
Tool: `tools/vtable_map.py` (build → `dumps/vtables.json`). Ghidra writeback:
`scripts/lane_v2_writeback.py`. Addresses: `ghidra/addresses.d/lane-v2.json`. Curated receiver assertions:
`ghidra/vtable-owners.json`.
---
## 1. Validation first — the known case, rediscovered blind
`uv run python3 tools/vtable_map.py validate`, 12 checks, 12 pass. Nothing below was given to the tool; the
only inputs are the PE, Ghidra's 41,089 function starts, and the RTTI walk `tools/rtti_map.py` already
produced.
```
V1 the known case -- lane Z's virtual edge, rediscovered blind
PASS site 0x007d8469 classified virtual virtual
PASS slot recovered = 10 10
PASS receiver = member +0x158 {'k':'field','base':'esi','disp':344,'this':True}
PASS receiver class = Game::ServerTradeManagerImpl
PASS target = 0x00893290 GenerateTradeRaidEncounters
PASS 0x00893290 named by exactly one vtable slot [0x00a31b74, ServerTradeManagerImpl, +0, slot 10]
PASS 0x00893290 has zero direct call sites 0
V2 the *Impl rule -- abstract interface, one concrete override
PASS Game::ServerTradeManager is abstract 21/22 purecall
PASS exactly one derived class ['Game::ServerTradeManagerImpl']
PASS Impl overrides every slot 22 slots
PASS Game::ServerSpyManager is itself concrete (no *Impl) 18 slots, derived=None
PASS ServerSpyManager derives IServerSpyManager/ISpyManager/IStreamable
```
The chain the tool walks, with nothing hand-fed:
```
0x007d845d mov ecx,[esi+0x158] <- receiver: member +0x158 of a proven `this` carrier
0x007d8463 mov eax,[ecx] <- vptr load: this is what makes it a virtual dispatch and not
a function pointer
0x007d8465 mov edx,[eax+0x28] <- slot 0x28/4 = 10
0x007d8469 call edx
```
and then the *member* typing, which is the part that turns a slot number into a function:
```
StrategyServer ctor 0x007d78d0 (identified by its vptr stores of 0x00a26084 at +0 and 0x00a26034 at +4)
0x007d7d81 call 0x00858f70 ; mov [esi+0x158],eax 0x00858f70 installs vftable 0x00a31b74
-> +0x158 : Game::ServerTradeManagerImpl*
0x007d7d8e call 0x00832a30 ; mov [esi+0x15c],eax 0x00832a30 installs vftable 0x00a3073c
-> +0x15c : Game::ServerSpyManager*
```
**Second, independent witness, under a non-trivial transform.** `0x007dcf90` is, per the RTTI inverse map,
`Game::StrategyServer` vftable 0x00a26034 slot 14 at sub-object **+4**. It calls the same two constructors
and stores their results at `[esi+0x154]` and `[esi+0x158]` — exactly four bytes lower, which is what a
`this` of `obj+4` requires. Two constructions, two frames, one answer. This also re-derives lane T's
`StrategyServer_off_TradeManager = 0x154` (S+4 frame) from a completely different direction.
**The `*Impl` rule, re-derived.** `Game::ServerTradeManager` (vftable 0x00a311a4, 22 slots) has 21 slots
pointing at `purecall` 0x00924fb0 — an abstract interface. The RTTI base lists give it exactly one derived
class, `Game::ServerTradeManagerImpl`, whose vftable 0x00a31b74 overrides all 22. So slot *N* of that
interface resolves **uniquely**. `Game::ServerSpyManager` is the opposite shape and is worth stating because
the naming misleads: there is no `ServerSpyManagerImpl`. `ServerSpyManager` is itself concrete (18 slots,
none purecall) over `IServerSpyManager` / `ISpyManager` / `Mars::IStreamable`; the `?$StreamableHelper@V`**`IServerSpyManager`**`@Game@@` template is the tell that the interface exists but the implementation is
not separately named.
---
## 2. False-positive rate, stated the way lane X stated its scanner's
Two things are being measured and they have very different precision. **Say which one a claim rests on.**
### 2a. The map (exact, no inference)
vftable → class → sub-object offset → slot → target, and its inverse, come from walking
`TypeDescriptor ← COL ← vftable[-1]`. 2,172 vftables, 1,924 type descriptors. **No false positives are
possible here** — a vftable is only recognised when a Complete Object Locator sits at `[vftable-4]` and its
`pTypeDescriptor` lands on a real mangled name. This half is complete and I make no hedged claims about it.
### 2b. Slot recovery at call sites (exact, but with declared refusals)
Of 17,577 indirect call sites:
| kind | n | what it is |
|---|---|---|
| `call-abs` | 7,415 | `call [disp32]` — import thunks and global function pointers, **not** vtable dispatch |
| **`virtual`** | **6,398** | vptr load proven, **slot index exact** |
| `vptr-unresolved` | 1,653 | the register holding the vptr has no provable definition (a branch target intervenes) |
| `call-reg-unresolved` | 1,295 | same, for `call reg` |
| `not-vptr` | 545 | the "vptr" register was not loaded from `[obj+0]` — a function-pointer member, not a vtable |
| `call-reg-nonmem`, `non-slot-disp`, `vptr-unmodelled`, `vptr-global` | 271 | declared refusals |
The backward resolver **refuses to cross an intra-function branch target** and stops at any opcode not in
its write-set model. That is why 2,948 sites report "unresolved" rather than a guess: a definition separated
from its use by a label is not a definition proven to reach it (rule 4's discipline applied to registers
instead of to `if`s).
### 2c. Receiver typing (hard; only 2.5% of virtual sites, at ~99% precision)
This is the genuinely difficult half — it is the devirtualization problem, and **it is not solved here.**
Of the 6,398 virtual sites:
| route | sites | slot out of the pinned class's vtable range | same test against a random vtable |
|---|---|---|---|
| `self` — receiver is a proven `this` carrier, enclosing class known | 144 | **0.7 %** (1) | 70.3 % |
| `field` — member of a proven `this` carrier, both classes known | 19 | **0.0 %** | 43.3 % |
| `field-nonthis` — member of a register not proven to be `this` | 850 | *not pinned* | |
| `stack` — receiver is `[ebp±d]`, an argument or local | 1,027 | *not pinned* | |
| `disp-only` — displacement matched image-wide, no owner class | 403 | **81.1 %** | 58.1 % |
| `unpinned` | 3,955 | | |
**163 of 6,398 sites (2.5%) get a receiver class.** On those the out-of-range falsification test fires on 1
site in 163 (**0.6%**) against a 70% chance baseline, so the routes that do resolve are trustworthy. The
`disp-only` route was **measured worse than picking a vtable at random** (81% vs 58% out-of-range) and is
therefore **rejected outright**, not merely flagged — this is rule 9 with the ranker turned off entirely
rather than used as a filter. Its 403 sites are counted and discarded.
Three bugs the falsification test caught, each of which had produced confident wrong answers before it ran:
* treating `[ebp+8]` as a member of `this` (it is argument 1) — accounted for **every** out-of-range result
in the first `field` run;
* giving a constructor the sub-object offset of whichever vftable it installs, when a ctor always receives
the **complete** object — this registered every member at both `+d` and `+d+4` and made the class's own
member typings ambiguous one slot away;
* ending a `this`-carrier's span at the function's end rather than at the `pop esi` in the epilogue, which
silently deleted every carrier in every function with a standard epilogue.
**Honest summary: the call-site side stays partial.** The complete vftable→slot→target map is delivered and
exact; the slot index at 6,398 sites is delivered and exact; the receiver's *class* is delivered for 163 of
them. `ghidra/vtable-owners.json` exists so that one hand-verified assertion propagates through the
`this`-passing call graph and lights up a whole subtree — two entries were enough for everything in §3.
---
## 3. `ServerTradeManager` phase 23 — lane K's tier-4 blind spot, closed
Lane K: *"Phase 23 is nine virtual calls in a row and not one of them is identified … the largest blind spot
in the map."* The tool reproduces lane K's byte-level transcription exactly and independently — same sites,
same order, same slot numbers, same two receivers — and then names the targets.
`uv run python3 tools/vtable_map.py resolve 0x007d92a0`:
| # | site | recv | slot | class | target |
|---|---|---|---|---|---|
| 1 | 0x007d97a7 | S+0x158 | 14 (+0x38) | ServerTradeManagerImpl | **0x008590d0** |
| 2 | 0x007d97b6 | S+0x158 | 8 (+0x20) | ServerTradeManagerImpl | **0x0088e8d0** (one pushed arg) |
| 3 | 0x007d97c3 | S+0x158 | 12 (+0x30) | ServerTradeManagerImpl | **0x0088e920** |
| 4 | 0x007d97d0 | S+0x158 | 11 (+0x2c) | ServerTradeManagerImpl | **0x00848570** |
| 5 | 0x007d97dd | S+0x158 | 9 (+0x24) | ServerTradeManagerImpl | **0x00868060** |
| 6 | 0x007d97ea | S+0x158 | 7 (+0x1c) | ServerTradeManagerImpl | **0x0088ad60** |
| 7 | 0x007d97f7 | S+0x158 | 13 (+0x34) | ServerTradeManagerImpl | **0x0088ef80** |
| 8 | 0x007d9804 | S+0x158 | 15 (+0x3c) | ServerTradeManagerImpl | **0x0082cca0** |
| 9 | 0x007d9811 | **S+0x15c** | 13 (+0x34) | **ServerSpyManager** | **0x008877b0** |
and phase 33, the two lane K also listed:
| | site | recv | slot | class | target |
|---|---|---|---|---|---|
| 10 | 0x007d989b | S+0x15c | 14 (+0x38) | ServerSpyManager | **0x0088db80** |
| 11 | 0x007d98a8 | S+0x15c | 15 (+0x3c) | ServerSpyManager | **0x00887f30** |
All eleven are now labelled and plate-commented in Ghidra (`ServerTradeManagerImpl_vslotN`,
`ServerSpyManager_vslotN`) with their provenance and their dispatch site.
**All eleven have zero direct call sites.** They were unreachable to every sweep this campaign has run.
### 3.1 Four of them can draw on the strategic generator
This is the part that matters beyond bookkeeping. Closures below are direct calls **plus inter-function tail
jumps** (see §5); "draws" means an RNG entry point or one of lane I's eleven inlined-draw functions is in the
closure.
| target | slot | closure | draws |
|---|---|---|---|
| 0x008590d0 | trade 14 | 143 | — |
| 0x0088e8d0 | trade 8 | 86 | — |
| 0x0088e920 | trade 12 | 193 | — |
| 0x00848570 | trade 11 | 188 | — |
| 0x00868060 | trade 9 | 185 | — |
| 0x0088ad60 | trade 7 | 189 | — |
| **0x0088ef80** | **trade 13** | 84 | **NextFloat, NextInt** |
| **0x0082cca0** | **trade 15** | 54 | **NextFloat, Chance** |
| **0x008877b0** | **spy 13** | 69 | **NextFloat, NextInt, Chance** |
| **0x0088db80** | **spy 14 (ph 33)** | 64 | **NextFloat, Chance** |
| 0x00887f30 | spy 15 (ph 33) | 230 | — |
And the draws are on **the strategic generator**, `StrategyServer+0x16c` — verified at the instruction, not
inferred from the call graph:
| draw site | in | generator load |
|---|---|---|
| 0x00887c8a `Chance` | 0x008877b0 (spy 13) | `mov ecx,[ecx+0x16c]` |
| 0x00840929 `Chance` | 0x008408e0 ← spy 13 | `mov ecx,[eax+0x16c]` |
| 0x00840a3c `Chance` | 0x008408e0 ← spy 13 | `mov ecx,[edx+0x16c]` |
| 0x008409c7 `NextInt` | 0x008408e0 ← spy 13 | (arg) |
| 0x0088dc43 `Chance` | 0x0088db80 (spy 14) | `mov ecx,[eax+0x16c]` |
| 0x0082cdb8 `Chance` | 0x0082cca0 (trade 15) | `mov eax,[eax+0x16c]` at 0x0082cda4, then `mov ecx,eax` |
| 0x00820e18 `NextFloat` | 0x00820ca0 ← trade 13 | `mov ecx,[reg+0x16c]`, `lea ecx,[ecx+4]` |
| 0x0088b613 `NextInt` | 0x0088b440 ← trade 13 | `mov ecx,[ecx+0x16c]` at 0x0088b5fc, `add ecx,4` |
**None of these has ever been observed firing.** Lane Z's boundary instrument measured **0 tail words on
every one of 8 turns**, so on those workloads every one of the four is gated off. That does not make them
absent; it makes them rule 6 — a path no save exercises is a hypothesis, and this is now a *named* one with
a hook site.
**This is the correction the tail ledger needs.** `tail-rng-ledger.md`'s "the tail draws nothing" is a
measured property of eight turns, not a property of the code. The tail contains at least **eight** draw
sites on the strategic generator behind four virtual slots, plus the node-line decay `Chance` lane Z already
found. Rule 18 applies directly: the next move is a hook on those four callees, not more reading — a word
count cannot distinguish "the trade/spy end-of-turn work found nothing to do" from "it never runs".
---
## 4. The RNG re-check over indirect edges
### 4.1 What did *not* change
Lane I's 22-site inventory and lane Z's zero-residual ledger are untouched. Nothing here adds a draw to the
turns lane Z measured; the residual is still zero and `GenerateTradeRaidEncounters` is still 16 of ~20 words.
The tool re-derives lane Z's edge from scratch (§1) rather than contradicting it.
### 4.2 The nine "unreachable" inlined-draw functions, resolved
Lane I parked nine of its eleven inlined-draw functions as reachable only through a vtable slot with no
direct caller. **Correction to how that was recorded:** none of the eleven is itself in a vftable — no dword
equal to any of those eleven addresses exists anywhere in the image. What is in a vftable is each one's
*topmost direct-call ancestor*. Resolved:
| inlined-draw fn | topmost ancestor | vtable slot it sits in |
|---|---|---|
| 0x004b1f20 | 0x00453e80 | `Game::CombatNetworkClient` slot 3 |
| 0x00507ac0 | 0x005348b0 | `Game::CrowRuinsEncounter` slot 11 |
| 0x005232a0 | 0x00533e10 | `Game::SwarmEncounter` slot 11 |
| 0x006ec720 / 0x006f65f0 / 0x006f7890 | 0x007cda40 / 0x007cf260 / 0x007cfd00 | `Game::StrategyNetworkServer` slots 4 / 7 / 3 |
| 0x0079f7d0 | 0x007612b0, 0x00784640 | `Game::StrategyNetworkClient` slots 3 and 6 |
| 0x007c2fa0 | 0x00664320 | `Game::StrategyLobbyScreen` slot 3 |
| 0x007c4140 | 0x007612b0 | `Game::StrategyNetworkClient` slot 3 |
| 0x007a7f30 | 0x00784640 | `Game::StrategyNetworkClient` slot 6 — **and in the tail's closure directly** |
| 0x007aa240 | 0x00784640 | — **in `ProcessTurn`'s closure directly** (lane I, depth 4) |
Three of these ancestries only exist once **inter-function jump edges** are included (§5); 0x007c2fa0 and
the `StrategyNetworkServer` group have no `E8` caller at all and are entered by a tail `jmp`.
`CrowRuinsEncounter` and `SwarmEncounter` are **not** `SVScriptObject`s — they derive from
`Game::CombatEncounterBase` and their offset-0 vtables have 16 slots. Their slot 11 is reachable only from a
dispatch on a `CombatEncounterBase`-family receiver, which is a different family from the scripted-scenario
hooks in §4.3. The slot-11 dispatch inside `ProcessTurn` is **not** a route to them.
### 4.3 The one genuinely new draw surface: the `SVScriptObject` hooks
`StrategyServer+0x1b4` (lane T's `StrategyServer_off_ScriptObject`, 0x1b0 in the S+4 frame) is dispatched:
* in `ProcessTurn`: 0x007dcb8e slot 4 (with `push 6` — a hook **id**), 0x007dcb97 slot 11, then
0x007dcbb6 slot 4, 0x007dcbbf slot 30;
* in `OnAllCombatDone_Tail`: 0x007d9767/0x007d9770 slots 4/25, 0x007d9783/0x007d978c slots 4/27,
0x007d9838/0x007d9841 slots 4/30.
Each block is guarded by `mov edi,[esi+0x1b4]; cmp edi,ebx; je` — the whole surface is optional, which is
why lane T and lane K both recorded it as null in a normal game.
30 classes derive from `Game::SVScriptObject`. The base's empty body is 0x0080c5a0, which makes the real
overrides countable:
| slot | non-stub overrides (of 30) | overrides whose closure reaches a draw |
|---|---|---|
| 4 | 14 | **11** — SVSOSlaversRefuel, SVSOPuppetMaster, SVSOSystemKiller, SVSOSwarm, SVSOTournament, SVSOJewelsOfTheCrown, SVSOCivilWar, SVSOUpstartApes, SVSOHolyLands, SVSOHiverInvasion, SVSOSots |
| 11 | 7 | **4** — SVSOCrowRuins, SVSOOrtgay, SVSOVonNeumann, SVSOCrowDefenders |
| 25 | 8 | **2** — SVSOSwarmQueen, SVSOProgressionWars |
| 27 | 0 | — |
| 30 | 1 | 0 |
Example path: `SVSOCrowRuins` slot 11 = 0x00518340 → 0x004f4210 → `Mars_RNG_IntRangeBell` 0x008e6d80 →
`RNG_NextInt` 0x004271c0.
**Two limits on this, stated plainly.** (a) The draws are at depth 2–4 and the generator arrives as an
argument (`lea ecx,[obj+4]`), so **which** generator these use is not established — it may be the strategic
one at `S+0x16c` or a different instance. (b) `StrategyServer+0x1b4`'s type is a hypothesis: the shape
(slot 30 exists, so ≥31 slots; the `push <id>` gate; lane T's independent reading) all point at
`SVScriptObject`, but **no constructor store into +0x1b4 was found**, so the class is inferred from the
dispatch, not proven. Lane T recorded it as `verified`; this lane could not re-derive that from the
constructor and records it as the weaker claim.
### 4.4 So what is the strengthened statement?
Not "there is no twenty-third mechanism". Honestly:
> Over direct edges **plus every indirect edge whose receiver this lane could pin**, `ProcessTurn`'s closure
> grows from 1,430 to 1,486 functions and `OnAllCombatDone_Tail`'s from 1,424 to 1,668, and the only draw
> sites those 300 added functions contribute are the eight in §3.1 — all inside the trade/spy end-of-turn
> block, all gated, none observed firing on the eleven turns lane Z instrumented.
The maximal over-approximation — every indirect site dispatching to every slot-matching target image-wide —
puts 19,697 of 41,089 functions in `ProcessTurn`'s closure. **That bound is useless and I am not going to
dress it up**: at half the image it says "maybe", not "yes". The tractable statement is the pinned one
above, plus the one-hop enumeration in §4.2/§4.3 of exactly which vtable slots could bridge into the parked
subtrees.
**Rule 18 stands.** The right instrument for "does the trade/spy tail block ever draw?" is a hook on
0x0088ef80 / 0x0082cca0 / 0x008877b0 / 0x0088db80 and a save with active trade routes and an active spy
program. That is ten minutes of the lab against another lane of reading.
---
## 5. A second lower bound found on the way: tail-jump edges
Every call graph in the repo is built from `E8 rel32`. Sweeping for jumps that land on **another function's
start** finds **14,958** more control-flow edges — tail calls, and Ghidra function splits. They matter:
| closure | E8 only | + tail jumps | + pinned indirect |
|---|---|---|---|
| `StrategyServer::ProcessTurn` 0x007dc6c0 | 1,395 | 1,430 | 1,486 |
| `OnAllCombatDone_Tail` 0x007d92a0 | 1,377 | 1,424 | 1,668 |
Lane I's closure was 1,426; with tail jumps this lane gets 1,430, which is the same closure to within edge
bookkeeping. Three of the nine parked inlined-draw functions in §4.2 have **no** `E8` caller anywhere and
are reachable only over these edges — 0x007c2fa0 is entered by a jump from 0x00898a50, and the whole
`StrategyNetworkServer` group through 0x006fcc60 from 0x006fde40. A sweep that had stopped at `E8` would
have called all four dead code.
---
## 6. What this lane did **not** do
* **Receiver type inference is not solved.** 2.5% of virtual sites get a class. The other 97.5% break down
into 1,027 stack receivers, 850 members of a register not proven to be `this`, and 3,955 where the object
pointer's definition could not be proven to reach the use. All are enumerated in `dumps/vtables.json` with
the reason; none is guessed at.
* **No callee bodies were read.** Every "draws / draw-free" verdict in §3.1 and §4.3 is a closure computation
over the call graph, not a reading. Rule 16 applies to the negative half of that: a function whose closure
contains no RNG *call* can still contain an **inlined** draw. The eleven functions lane I's tempering-
immediate scan found are checked for explicitly, so the verdict is "no call-graph draw and not one of the
eleven known inlined-draw functions" — which is a lower bound on drawing, not a proof of not drawing.
* **The `disp-only` route is discarded, not fixed.** 403 virtual sites whose receiver is a member of an
unknown object would resolve if their enclosing classes were typed. `ghidra/vtable-owners.json` is the
mechanism; it currently has two entries.
* **`StrategyServer+0x1b4`'s class was not proven** (§4.3b), and consequently the SVSO hook analysis is
conditional on lane T's identification being right.
* **Nothing was measured.** Every claim here is static. The four gated tail draw paths are the obvious next
hook, and until one runs, "never observed firing" means eight turns of one workload.
## 7. Reproducing
```
uv run python3 tools/rtti_map.py build # -> dumps/rtti.json (2,172 vftables)
uv run python3 tools/vtable_map.py build # -> dumps/vtables.json (~5 s)
uv run python3 tools/vtable_map.py validate # 12 checks, and the false-positive tables of 2c
uv run python3 tools/vtable_map.py who 0x00893290
uv run python3 tools/vtable_map.py vt 0x00a31b74
uv run python3 tools/vtable_map.py sites 0x007d92a0
uv run python3 tools/vtable_map.py resolve 0x007d92a0
uv run python3 tools/vtable_map.py field 0x158
uv run python3 tools/vtable_map.py impls Game::ServerTradeManager
```
`owner <funcVA> <reg>` also exists — an IDF-weighted ranker of candidate owner classes by overlap with each
class constructor's member-write footprint. It put `Game::StrategyServer` at rank 2 for
`OnAllCombatDone_Tail`'s `ebx` and nowhere useful for `ProcessTurn`'s `esi`. **It is a weak ranker and no
claim in this document rests on it** — the receiver typings in §3 come from constructors, not from it.

View file

@ -0,0 +1,102 @@
{
"entries": [
{
"name": "StrategyServer_off_SpyManager",
"offset": "0x158",
"convention": "member",
"prototype": "Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame)",
"status": "verified"
},
{
"name": "ServerSpyManager_vftable",
"addr": "0x00a3073c",
"convention": "data",
"prototype": "void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots",
"status": "verified"
},
{
"name": "ServerTradeManagerImpl_vslot7",
"addr": "0x0088ad60",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 7 (+0x1c). Reached ONLY virtually, from 0x007d97ea in StrategyServer::OnAllCombatDone_Tail phase 23 (call 6 of 8). Zero direct call sites. Body not read; its direct+tail-jump closure is 189 functions and contains no RNG entry point and none of the eleven inlined-draw functions",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot8",
"addr": "0x0088e8d0",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this, int arg1) // vftable 0x00a31b74 slot 8 (+0x20). Reached ONLY virtually, from 0x007d97b6 in OnAllCombatDone_Tail phase 23 (call 2 of 8); the site pushes one argument. Closure 86 functions, draw-free",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot9",
"addr": "0x00868060",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 9 (+0x24). Reached ONLY virtually, from 0x007d97dd (phase 23, call 5 of 8). Closure 185 functions, draw-free",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot11",
"addr": "0x00848570",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 11 (+0x2c). Reached ONLY virtually, from 0x007d97d0 (phase 23, call 4 of 8). Closure 188 functions, draw-free",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot12",
"addr": "0x0088e920",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 12 (+0x30). Reached ONLY virtually, from 0x007d97c3 (phase 23, call 3 of 8). Closure 193 functions, draw-free",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot13",
"addr": "0x0088ef80",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 13 (+0x34). Reached ONLY virtually, from 0x007d97f7 (phase 23, call 7 of 8). REACHES THE STRATEGIC GENERATOR: -> 0x00820ca0, NextFloat at 0x00820e18 with the generator loaded as [reg+0x16c] then `lea ecx,[ecx+4]`; and -> 0x0088b440, NextInt at 0x0088b613 with `mov ecx,[ecx+0x16c]; add ecx,4`. Neither was ever observed firing: lane Z measured 0 tail words on 8 turns",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot14",
"addr": "0x008590d0",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 14 (+0x38). Reached ONLY virtually, from 0x007d97a7 (phase 23, call 1 of 8). Closure 143 functions, draw-free",
"status": "mapped"
},
{
"name": "ServerTradeManagerImpl_vslot15",
"addr": "0x0082cca0",
"convention": "thiscall",
"prototype": "void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 15 (+0x3c). Reached ONLY virtually, from 0x007d9804 (phase 23, call 8 of 8). REACHES THE STRATEGIC GENERATOR: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 as `mov eax,[eax+0x16c]` then `mov ecx,eax`. Never observed firing",
"status": "mapped"
},
{
"name": "ServerSpyManager_vslot13",
"addr": "0x008877b0",
"convention": "thiscall",
"prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 13 (+0x34). Reached ONLY virtually, from 0x007d9811 in OnAllCombatDone_Tail phase 23 -- the ninth call of the block, and the only one whose receiver is StrategyServer+0x15c rather than +0x158. REACHES THE STRATEGIC GENERATOR: Chance at 0x00887c8a on `mov ecx,[ecx+0x16c]`, and through 0x008408e0 Chance at 0x00840929 and 0x00840a3c plus NextInt at 0x008409c7, all on [reg+0x16c]. Never observed firing",
"status": "mapped"
},
{
"name": "ServerSpyManager_vslot14",
"addr": "0x0088db80",
"convention": "thiscall",
"prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 14 (+0x38). Reached ONLY virtually, from 0x007d989b in OnAllCombatDone_Tail phase 33 (call 1 of 2). REACHES THE STRATEGIC GENERATOR: Chance at 0x0088dc43 on `mov ecx,[eax+0x16c]`. Never observed firing",
"status": "mapped"
},
{
"name": "ServerSpyManager_vslot15",
"addr": "0x00887f30",
"convention": "thiscall",
"prototype": "void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 15 (+0x3c). Reached ONLY virtually, from 0x007d98a8 in OnAllCombatDone_Tail phase 33 (call 2 of 2). Closure 230 functions, draw-free",
"status": "mapped"
},
{
"name": "SVScriptObject_EmptyOverride",
"addr": "0x0080c5a0",
"convention": "thiscall",
"prototype": "void (void* this) // the do-nothing body every Game::SVScriptObject-derived class inherits in the hook slots it does not override. Occupies most of slots 4/11/25/27/30 across the 30 SVSO classes, which is what makes the non-stub overrides countable: 14 at slot 4, 7 at slot 11, 8 at slot 25, 1 at slot 30, 0 at slot 27",
"status": "mapped"
}
]
}

View file

@ -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 @ 3d5f834, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 648028d, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1295,6 +1295,72 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310;
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]
constexpr uint32_t CombatRetreat_ChooseDestinations = 0x0039bb90;
// thiscall void (RetreatContext* this, Mars::Vector3* battlePos, ServerSystem* excludeSys, ServerPlayer* p, ServerSystem** out1, ServerSystem** out2, ServerSystem** out3) // ret 0x18. THE RETREAT DESTINATION FORMULA. THREE INDEPENDENT nearest-system searches sharing one pass over S->Systems (S->+0x44/+0x48, 4-byte stride), each with its own best-so-far seeded to the .rdata FLT_MAX at 0x009e23a8 (0x7f7fffff): out1 = nearest sys with FUN_007437e0(sys) == p (owner); out2 = nearest sys with FUN_00744070(sys, p) FALSE (no hostile presence); out3 = nearest sys, unconditional. Both skips: sys == excludeSys, and FUN_007469e0(sys). THE INDEPENDENCE IS LOAD-BEARING -- each 'best' store is INSIDE its predicate arm (0x007872c6 / 0x007872ec), so a nearer rejected system does not spoil that search; a single-loop 'find nearest then filter' gives a different answer. Distance is SQUARED: the three component deltas are each fstp'd to float32 first, the products and sum accumulate on the x87 stack, the total narrows to float32 once at 0x007872a3 (a double accumulator with one final narrowing is BIT-IDENTICAL, since a float32 delta squares exactly in double). Comparison is a strict < (fld best; fcomp d; test ah,0x41; jne skip), so an exact tie goes to the EARLIER system in the vector [verified]
constexpr uint32_t CombatRetreat_FindDestination = 0x00387210;
// thiscall bool (ServerSystem* this) // 33 B. return this->Dstyd(+0xc5) != 0 || (this->vnh(+0xc6) != 0 && this->vnex3(+0xc8) == 0). A destroyed system, or one hosting a Von Neumann hive that has not been cleared out. All three are SAVED bools (struct-recovery.md section 1), which is what identifies the class as Game::ServerSystem rather than a bare StarSystem. Note CombatResolve_NodeCannon 0x007bb530 uses only the +0xc5 half for its own destination filter [verified]
constexpr uint32_t ServerSystem_IsUnusableRetreatTarget = 0x003469e0;
// thiscall ServerPlayer* (ServerSystem* this) // 7 B: mov eax,[ecx+0x100]; ret. this->PID, the saved owner handle [verified]
constexpr uint32_t ServerSystem_GetOwner = 0x003437e0;
// thiscall bool (ServerSystem* this, ServerPlayer* p) // ret 4. return (ServerPlayer_HostileMaskAt(p, this) & this->AFlags(+0xd4)) != 0 [verified]
constexpr uint32_t ServerSystem_HasHostilePresence = 0x00344070;
// thiscall uint32 (ServerPlayer* this, ServerSystem* sys) // ret 4. THE HOSTILITY MASK, and it carries a diplomacy rule that is nowhere in findings/subsystems/: ownerBit = (sys && sys->PID(+0x100) && sys->TAcq(+0x80) == S->Frame) ? (1 << sys->PID->PlyrIdx) : 0; return ~( (1 << this->PlyrIdx(+0x28)) | (this->CF(+0x174) & ~ownerBit) | this->NA(+0x170) | this->AL(+0x16c) ). A SYSTEM WHOSE OWNER CAPTURED IT ON THE CURRENT TURN LOSES THAT OWNER'S CEASEFIRE COVER. The current turn is reached as this->+0x8->+0x8, which is S->+0xc = Frame, because ServerPlayer+0x8 == S+4. The complement is over all 32 bits, harmless only because it is immediately ANDed with a presence mask [verified]
constexpr uint32_t ServerPlayer_HostileMaskAt = 0x0040dff0;
// thiscall void (RetreatContext* this) // RETREAT PHASE 2. One loop over res->+0x68/+0x6c -- a 0x14-STRIDE VECTOR OF RETREAT REQUESTS (magic 0x66666667 / sar 3), a Game::EncounterResults member not previously mapped: req->+0x04 ship handle, req->+0x08 mode word, req->+0x0c requested-destination handle, req->+0x10 second key word. Per request: ship = HandleMap::Resolve(S->+0x84, req->+0x04); skip if null, if ship->Dep(+0x50) != 0, if ship->EncID(+0x7c) == 0x15, if FUN_004f47f0(ship->EncID) (a DB-driven encounter-type mask), or if ship->PlrID(+0x10) == S->Players[S->+0x1c0] (the locally-controlled player). dest = CombatRetreat_ResolveShipDestination(ctx, req, ship); on 0 it logs 'Retreat: Could not find retreat destination for %s.' at level 2 and skips. Then, UNLESS (req->+0x08 == 1 && owner->Species(+0x5c) == 1), skip the ship if StarShip_IsGroundedByDamage(ship). Finally find-or-create the group via CombatRetreat_FindGroup and push_back the ship [verified]
constexpr uint32_t CombatRetreat_BuildGroups = 0x0039bcd0;
// thiscall ServerSystem* (RetreatContext* this, void* req, StarShip* ship) // ret 8. Returns 0 when ship is null or ship->+0x04 (its handle id) is 0. Else: if req->+0x0c != 0, HandleMap::Resolve(S->+0x84, req->+0x0c) and RETURN IT if found -- the explicitly ordered destination; if not found, log 'Retreat: %s cannot retreat to specified location. Location not found.' at level 2 with ship->PlrID->+0x40 (the player's name std::string) and fall through. Fall-through: return this->dest[ship->PlrID(+0x10)->PlyrIdx(+0x28)], the phase-1 default [verified]
constexpr uint32_t CombatRetreat_ResolveShipDestination = 0x0039bc50;
// thiscall RetreatGroup* (RetreatContext* this, StarShip* ship, ServerSystem* dest, int mode, int variant) // ret 0x10. Linear scan of this->groups (+0x1c/+0x20) for the group matching ALL FOUR key words: g->+0x00 == ship->PlrID(+0x10), g->+0x04 == dest, g->+0x2c == mode, g->+0x30 == variant. Returns 0 when absent [verified]
constexpr uint32_t CombatRetreat_FindGroup = 0x003871c0;
// thiscall RetreatGroup* (RetreatGroup* this) // 41 B; ENUMERATES the 0x34-byte heap record (operator new(0x34) at 0x0079bdee). Writes: +0x00 owner ServerPlayer* = 0; +0x04 dest ServerSystem* = 0; +0x08/+0x0c/+0x10 vector<StarShip*> = 0 (allocator +0x14 never written, per earned-rule 5); +0x18/+0x1c/+0x20 vector<StarFleet*> = 0 (allocator +0x24); +0x28 BYTE partial = 0; +0x2c int mode = 0; +0x30 int variant = -1. Total 0x34 [verified]
constexpr uint32_t RetreatGroup_Ctor = 0x0038f1c0;
// thiscall bool (StarShip* this) // 46 B. design = this->DesID(+0x14); return (design->+0x74 != 0 || design->+0x78 != 0) && design->+0x12c < 2 && (FLT_EPSILON > this->+0x30). The .rdata float at 0x009e1ef8 is 0x34000000 = 1.1920929e-07, FLT_EPSILON -- the test is against an epsilon, NOT against zero, and it is strict, so a health exactly AT the epsilon is not grounded. this->+0x30 is the third float of the inline ShipHealth at 0x24..0x33 (R1 guesses drive). True means the ship CANNOT retreat. The species-1 / mode-1 gate path skips this test entirely [verified]
constexpr uint32_t StarShip_IsGroundedByDamage = 0x00415090;
// cdecl bool (int encounterType) // 67 B. DB-SOURCED, DO NOT HARD-CODE. Returns false for encounterType > 0x17. Otherwise lazily builds a bitmask once (flag at 0x00b0e988, cache at 0x00b0e984) by FUN_004f47b0, which ORs 1 << table[i] over a count at 0x00ae04e0 and an int array at 0x00adfe88 -- both .bss, filled from the game data files at load -- and returns (mask >> encounterType) & 1. The 0x17 bound is the same EncounterType range PickDominantEncounterType 0x004f4c40 returns over [verified]
constexpr uint32_t IsBlockedEncounterType = 0x000f47f0;
// thiscall void (RetreatContext* this) // RETREAT PHASE 3. Per group in this->groups: tally the group's ships by ship->FltID(+0x64) into a local 8-byte-stride vector<{StarFleet*, int}> (linear search, push {f,0} then ++count); then per distinct fleet f, if count == (f->+0xa8 - f->+0xa4)/4 (i.e. EVERY ship the fleet has is in this group) push f into g->fleets(+0x18), else set g->partial(+0x28) = 1. WARNING: the jb/jae pairs at 0x007b0504 and 0x007b0570 are MSVC's inlined vector::push_back aliasing check (_Inside(&_Val)), NOT a phase gate -- both arms converge on *_Mylast = value; _Mylast += 4 at 0x007b05ce. Draw-free, event-free, writes only the group records [verified]
constexpr uint32_t CombatRetreat_ClassifyFleets = 0x003b0320;
// thiscall void (RetreatContext* this) // RETREAT PHASE 4, and THE BIGGEST SAVED-STATE WRITER ON THE COMBAT PATH. Per group, gated on g->partial(+0x28) != 0: newFleet = StrategyServer_CreateFleet(S+4, IDMap_AllocateLocalID(S+4), g->owner, &enc->+0x1c, NULL) -- note the id mint is evaluated INSIDE the argument list, see IDMap_AllocateLocalID; push newFleet into g->fleets BEFORE the ship loop; then per ship in g->ships, skip it if ship->FltID(+0x64) is ALREADY in g->fleets (its whole fleet is retreating, leave it), else StarFleet_RemoveShip(old, ship), and if ((old->+0xa8 - old->+0xa4) & ~3) == 0 call StrategyServer_DestroyFleet(S+4, old, 0, 0), then StarFleet_AddShip(newFleet, ship). Finally if (enc->+0x0c) StarSystem_FleetArrives(enc->+0x0c, newFleet). PREDICTED ORIGINAL BUG: the fleet is created before the loop, so a group flagged partial creates a fleet even when every ship turns out to belong to a wholly-retreating fleet -- a zero-ship fleet in the save [verified]
constexpr uint32_t CombatRetreat_SplitFleets = 0x00390790;
// thiscall void (StarFleet* this, StarShip* ship) // ret 4. this->NShips(+0xa4).push_back(ship); SHIP->FltID(+0x64) = this (the saved handle); design = ship->DesID(+0x14); this->+0xb8 |= design->+0xb8 and this->+0xbc |= design->+0xbc (capability masks); this->+0xc0 (WORD, the fleet's gate-traffic cost) += FUN_0056f430(design->+0x12c); if bit 0x20000 of +0xb8 changed state, push_back this into (this->+0x10)->+0x114; FUN_00705c70(this) to recompute derived stats [verified]
constexpr uint32_t StarFleet_AddShip = 0x003062e0;
// thiscall void (StarFleet* this, StarShip* ship) // ret 4. Linear-search this->NShips(+0xa4/+0xa8) for ship; return if absent. If the fleet's flight plan is non-empty (+0xc8 != +0xcc) and IsGateTransitWaypoint(wpt->+0x08) 0x0056e6e0, this->PID(+0x58)->GTraf(+0x14c) -= (int16)this->+0xc0. Then SHIP->FltID(+0x64) = 0; erase the slot (memmove + _Mylast -= 4); FUN_00700090(this); FUN_00705070(this, ship); re-add the gate-traffic term under the same test; if bit 0x20000 of +0xb8 changed, find-and-erase this from (this->+0x10)->+0x114; if any ship remains, FUN_00705c70(this). GTraf is a SAVED ServerPlayer field [verified]
constexpr uint32_t StarFleet_RemoveShip = 0x003063a0;
// thiscall void (ServerSystem* this, StarFleet* f) // ret 4. f->Pos(+0x18/+0x1c/+0x20) = this->Pos (three raw dword copies); StarFleet_SetLocation(f, this); bit = 1 << f->PID(+0x58)->PlyrIdx(+0x28); this->FFlags(+0xd8) |= bit; this->+0x16c.push_back(f) (the SAVED NumFlts/Flt list); then recompute: if (FFlags & bit || GFlags(+0xdc) & bit || PID(+0x100) == owner) { AFlags(+0xd4) |= bit; VFlags(+0xcc) |= bit; } else AFlags &= ~bit -- on this path the else arm is unreachable because FFlags was just set [verified]
constexpr uint32_t StarSystem_FleetArrives = 0x0034f240;
// thiscall void (StarFleet* this, void* loc) // 16 B: this->LocID(+0xa0) = loc. Saved handle [verified]
constexpr uint32_t StarFleet_SetLocation = 0x002fe2f0;
// thiscall void* (StarFleet* this) // 19 B: eax = this->LocID(+0xa0); return (eax && eax->+0x14 == 0) ? eax : 0. The +0x14 word is a location-class tag; the sibling FUN_006fe320 returns it only when the tag is 2. The retreat path uses this one, whose class carries the fleet list at +0x16c and the name std::string at +0xa8 [verified]
constexpr uint32_t StarFleet_GetLocationIfNode = 0x002fe300;
// thiscall void (RetreatContext* this) // RETREAT PHASE 5. Two nested loops: over this->groups, then over g->fleets (whole fleets from phase 3 plus any new fleet from phase 4). Per fleet f: (a) if (enc->+0x0c && !ServerSystem_IsExploredBy(enc->+0x0c, f->PID(+0x58))) StrategyServer_GrantSystemIntel(S, S->+0x44[enc->+0x0c->Idx(+0x5c)], f->PID) -- RETREATING FROM AN UNEXPLORED SYSTEM REVEALS IT, writing the SAVED EFlags and PlayerView; (b) if CombatRetreat_UsesGate(g) && g->dest(+0x04) != 0, the GATE ARM: StarSystem_FleetDeparts(GetLocationIfNode(f), f), StarSystem_FleetArrives(g->dest, f), then two _snprintf'd strings from the .bss format-string pointers at 0x00aedf0c and 0x00aedf14 (destination Name(+0xa8), fleet FtName(+0x5c)) and EventStorage_PostEvent(ServerPlayer_GetEventStorage(f->PID), {msg, summary, f, 0, S->Frame, 'EVENT_FLEET_RETREATED_VIA_TELEPORT', 0}); (c) else the MOVE ARM: if (!StrategyServer_OrderFleetMove(S+4, f, &g->dest, 1)) log 'Retreat: Unable to set destination for retreat for %s.' at level 2 with f->PID->+0x40, else StarFleet_SetFlag(f, 2, true) and StarSystem_FleetDeparts(GetLocationIfNode(f), f) [verified]
constexpr uint32_t CombatRetreat_Execute = 0x003d5650;
// cdecl bool (RetreatGroup* g) // 48 B, ret 4 (ecx is set at the one call site but unused). return g != 0 && g->mode(+0x2c) == 1 && g->owner(+0x00) != 0 && g->owner->Species(+0x5c) == 1 && g->dest(+0x04) != 0. Species 1 is the gate-building species; this is the ONLY gate on EVENT_FLEET_RETREATED_VIA_TELEPORT, which is posted nowhere else in the image. The same (mode == 1 && species == 1) pair is what lets a ship with a dead drive past StarShip_IsGroundedByDamage in phase 2 [verified]
constexpr uint32_t CombatRetreat_UsesGate = 0x003859f0;
// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, ret 4: return (this->EFlags(+0xd0) & (1 << p->PlyrIdx(+0x28))) != 0. EFlags is a SAVED int [verified]
constexpr uint32_t ServerSystem_IsExploredBy = 0x00343880;
// thiscall void (ServerSystem* this, ServerPlayer* p, bool on) // 82 B, ret 8. bit = 1 << p->PlyrIdx(+0x28); wasSet = (this->EFlags(+0xd0) & bit) != 0; on ? EFlags |= bit : EFlags &= ~bit; then this->vft[0x1c](p, wasSet, on) -- AN INDIRECT EDGE, unresolved here, flagged for the vtable-inversion lane [verified]
constexpr uint32_t ServerSystem_SetExploredBy = 0x003438b0;
// thiscall void (StrategyServer* this /*the S frame*/, ServerSystem* sys, ServerPlayer* p) // 195 B, ret 8. Returns immediately if either pointer is null. ServerSystem_SetExploredBy(sys, p, true); FUN_006e4620(this->+0x154 /*the node-line/graph object*/, sys, p, 1); then builds a stack temp via FUN_00755ab0, fills it with FUN_007561d0(sys, &tmp, p), and applies it through FUN_007d1f00(this->+0x224, &sys->Idx(+0x5c), &p->PlyrIdx(+0x28), &tmp) -> FUN_0075cf50 -> FUN_00753430, destroying the temp with FUN_006bba00. this->+0x224 is INFERRED to be the per-(system, player) StarSystem::PlayerView map -- combat-done-tail.md phase 17 has its tree head at S+0x228 and its size word at S+0x22c, which puts the map object at S+0x224 -- but the final leg FUN_00753430 was NOT read, so the exact field written is a labelled hypothesis [hypothesis]
constexpr uint32_t StrategyServer_GrantSystemIntel = 0x003d5080;
// thiscall void (RetreatContext* this) // RETREAT PHASE 6, and it is only a destructor -- it writes no game state. FUN_00793090(this->+0x1c, this->+0x20) deletes every RetreatGroup; then vector::erase(begin,end) (a degenerate memmove of 0 bytes plus _Mylast = _Myfirst) and operator delete on the buffer, zeroing +0x1c/+0x20/+0x24; then _Tree::erase(head->_Left, head) via FUN_006a4e40 on the map at this->+0x0c and operator delete on its head node [verified]
constexpr uint32_t CombatRetreat_ContextDtor = 0x003a7cd0;
// thiscall StarFleet* (StrategyServer* this /*the S+4 frame*/, int oid, ServerPlayer* owner, Mars::Vector3* pos, const char* nameOverride) // 347 B, ret 0x10. operator new(0x120) -- SIZEOF(Game::StarFleet) = 0x120 BY ENUMERATION, agreeing with struct-recovery.md section 3 whose last member FtMS sits at +0x11c -- then ctor FUN_00706140; IDMap_Insert(this+0x80, obj, oid) which writes the id into obj->+0x04; obj->+0x10 = this (the S+4 pointer, NOT a ServerPlayer as it is on StarShip); obj->PID(+0x58) = owner; the name from FUN_00856310(owner) (-> FUN_00855f80 on owner+0x298) assigned into obj->FtName(+0x5c) unless nameOverride is given; pos copied to BOTH +0x18/+0x1c/+0x20 (Pos) and +0x4c/+0x50/+0x54 (PrvPos); StarFleet_SetFlag(obj, 0x400, true); push_back into the vector at this->+0x60 (= S+0x64), THE SAVED NumFlts/FltID/Flt MASTER LIST; then the virtual (*this)->vft[0x10](this, obj). Draw-free, including the name generator. DELEGATED instruction-level read; the call site is mine [verified]
constexpr uint32_t StrategyServer_CreateFleet = 0x0045b340;
// thiscall int (StrategyServer* this /*the S+4 frame*/) // AN 8-BYTE TAIL-CALL THUNK: sub ecx,0xffffff80 (ecx += 0x80) then jmp FUN_008b8b70, which is push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. IT TAKES NO STACK ARGUMENTS. At the one retreat call site 0x007907d8 three pushes sit immediately before it and belong to the LATER call to StrategyServer_CreateFleet -- the id mint is evaluated inside that call's argument list. Checking ret N against the push count is what catches this; see combat-retreat-pipeline.md section 7.3. The IDMap subobject therefore lives at (S+4)+0x80 [verified]
constexpr uint32_t IDMap_AllocateLocalID = 0x0040f710;
// thiscall int (IDMap* this, int nodeIndex) // ret 4. THE OBJECT-ID ALLOCATOR. Closes struct-recovery.md section 7's open item 'OID allocation (R2's x16)': R2's x16 is the shl 4 below. Layout: this->+0x08/+0x0c is a vector<NodeEntry> of STRIDE 0x14 (pinned twice -- FUN_008b8a70's 0x66666667 / sar 3 divide-by-20, and the lea eax,[edi+edi*4] with scale 4 here); NodeEntry is a std::map<int,void*> (0x10) followed by the counter at +0x10; this->+0x18 is the local node index. Body: returns 0 if nodeIndex == -1 (logs 'IDMap: Map not initialized.') or the node does not exist; else 0x008b8b16 'inc DWORD PTR [ecx+eax*4+0x10]' PRE-INCREMENTS the per-node counter, and on wrap to 0 logs 'IDMap: NextID wrapping for node %d.' and increments AGAIN (0x008b8b30), so 0 is never issued -- 0 is INVALID_NETWORK_ID; then id = (counter << 4) | (nodeIndex & 0xF) at 0x008b8b38/0x008b8b3e, with an overflow log if the counter no longer round-trips. THE COUNTER IS ALMOST CERTAINLY THE SAVE'S 'NMnx' TAG (StrategyServer block order: KeyPath, NMSz, NMLc, NMnx, ModCount, Frame) -- LABELLED HYPOTHESIS: StrategyServer::Write 0x0079fa70 was not read for it. DELEGATED instruction-level read [verified]
constexpr uint32_t IDMap_AllocateID = 0x004b8ae0;
// thiscall void (IDMap* this, void* obj, int id) // ret 8. Logs 'IDMap: Object already exists with id %d.' and continues if the id is taken; refuses id 0 with 'IDMap: Object with INVALID_NETWORK_ID not inserted.'; node = id & 0xF, bounds-checked; WRITES THE ID INTO obj->+0x04 (the NetworkObject handle slot every serialized object uses); then _Buynode + _Insert into the map at _Myfirst + node*0x14. DELEGATED instruction-level read [verified]
constexpr uint32_t IDMap_Insert = 0x004b9350;
// thiscall void (StrategyServer* this /*the S+4 frame*/, StarFleet* f, bool killRemainingShips, int shipDestroyArg) // 1352 B, ret 0xc. Called from the retreat split as (f, 0, 0), so the ship-massacre block 0x0088bbf1..0x0088bc82 is dead on that path. A REAL DELETE (vtable slot 0 with flag 1, the MSVC scalar deleting destructor, at 0x0088bea6). Order: (1) sweep the master fleet vector this->+0x60/+0x64 for any fleet whose flight plan (+0xc8/+0xcc, waypoint stride 0x1c) carries a waypoint whose field_0 is f's id -- for each, re-plan it via FUN_00707290 and POST 'EVENT_FLEET_INTERCEPT_ABORTED' to THAT fleet's owner, inlining player+0x29c rather than calling ServerPlayer::GetEventStorage; (2) optionally destroy remaining ships; (3) erase from the owner's fleet map at owner+0x2b4; (4) per player, FUN_00812110(player+0x29c, fleetId, -1) to purge queued events referencing it; (5) erase from this->+0x60 or log 'StrategySim: Can't delete %s (%i), not found in master list!'; (6) detach from its location; (7) vft[0x14](f), erase from this->+0x114, FUN_008b92a0(this+0x80, f); (8) delete. No RNG, no inlined MT draw, no SETurnResults stride. DELEGATED instruction-level read, full body to the next function start [verified]
constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
// thiscall bool (StrategyServer* this /*the S+4 frame*/, StarFleet* f, ServerSystem** dests, unsigned count) // 801 B, ret 0xc. Returns false if f is null, or if the path solver FUN_007066c0 sets any of bits 0x008 / 0x010 / 0x400 in its flag word (then logging 'StrategySim: %s (%s) move not permitted at this time.' at level 2) -- THE MEANING OF THOSE THREE BITS IS UNKNOWN, FUN_007066c0 was not opened. Before that gate it SNAPS the fleet's position onto its current system when they differ, tested by FUN_0080ec50 with fucompp EXACT IEEE EQUALITY and no epsilon; that snap is committed even on the false path. On success it builds a 0x1c-stride waypoint vector and commits it through FUN_00707080, which debits owner->GTraf(+0x14c) by (int16)f->+0xc0, writes f->+0xd8..+0xf8 and +0x100..+0x108 (cached source/destination positions and the leg record), then re-credits GTraf; finally it cancels conflicting ship actions via FUN_00849280. No RNG and no x87 in the function itself. DELEGATED instruction-level read, full body to the next function start [verified]
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified]
constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0;
// thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified]
constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670;
// thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified]
@ -1411,6 +1477,52 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0;
constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0;
// note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified]
constexpr uint32_t SNMAllCombatDone_layout = 0x00624758;
// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified]
constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480;
// thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified]
constexpr uint32_t ServerSystem_GroupOutput = 0x0034b7a0;
// thiscall double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074da01. Sums GroupOutput over species 0..6 using GroupPopulation(groupType, species). For groupType 1 and the owner's own species on a non-independent system it first adds a capacity surplus: two calls to 0x0074a6d0 with the int64 out-pointer in argument slot 4 and then in slot 6, surplus = max(0, B - A). The income analogue is 0x0074d760 [verified]
constexpr uint32_t ServerSystem_PopOutput = 0x0034d8f0;
// thiscall double (ServerSystem* sys) // real end 0x0074b901. Sums over species 0..6: max(0, POPTYPE[2].outputMod x 1.8 x (slaves / 500000)). Does NOT go through GroupOutput, so it carries neither the station factor nor the morale multiplier. Slave counts come from 0x0074b610 [verified]
constexpr uint32_t ServerSystem_SlaveOutput = 0x0034b880;
// thiscall float (ServerSystem* sys) // real end 0x00747ddd. R = clamp01(signed_cbrt((pbon + Pop) / 100) x 0.01); if 0.0001 + R >= 1 the infrastructure term SUBSTITUTES it outright (R := float32(ibon + Infra)); returns float32(min(R, float32(ibon + Infra))). The substitution is not a clamp: a colony with a pending infrastructure bonus can return a fraction above 1 [verified]
constexpr uint32_t ServerSystem_StripMineFraction = 0x00347d30;
// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007484c4. CORRECTS strategic-turn-internals.md 3.3, which called this the population base-output term: its population is the RESOURCE stock (Res, plus MRes + ARes2 when the owner strip-mines), and the species constant at +0x4c is ADDED, not a cap -- the cap is the resource stock. B = rate > 0 ? max(rate x available x clamp01((pbon + Pop) x 1e-5), 1.0) : 0; return min(available, max(speciesBaseDemand + B, 0)). Called twice per output pass: once inside ComputeTotalOutput and once by ComputeOutputFromRates as the resource ledger's strip-mine demand [verified]
constexpr uint32_t ServerSystem_OverHarvestDemand = 0x003483b0;
// thiscall int64 (ServerSystem* sys, int groupType, int species) // groupType 0 returns (pbon + Pop) sign-extended, but ONLY for the system's effective species (indi->+4 when independent, else owner->Species) and 0 for every other; otherwise Population::Count(pbon2) + Population::Count(Pop2) for that (type, species) [verified]
constexpr uint32_t ServerSystem_GroupPopulation = 0x00347ba0;
// thiscall double (ServerSystem* sys, int species) // `ret 4`. Returns 1.0 when the system has no owner, is independent, or its Morale int[7] entry for the species is exactly 0 -- the zero guard matters, because 0 <= MORALE_DECREASE_OUTPUT would otherwise apply the penalty to every species with no record. Otherwise m >= MORALE_INCREASE_OUTPUT -> MORALE_INCREASE_OUTPUT_MOD, m <= MORALE_DECREASE_OUTPUT -> MORALE_DECREASE_OUTPUT_MOD, each used only when strictly positive, else 1.0 [verified]
constexpr uint32_t ServerSystem_MoraleOutputMod = 0x00346910;
// cdecl PopTypeRow* (int groupType) // table base 0x00b104e8, stride 0x30, three rows; groupType > 2 falls back to a lazily-initialised sentinel row at 0x00b10578 that no caller reaches. Row fields used here: +0x10 output modifier, +0x14 income modifier, +8 maximum population [verified]
constexpr uint32_t PopTypeRow = 0x00135e00;
// cdecl void () // builds the three-row population-type table IN CODE from x87 literals, rotating six values with fxch rather than storing them. Imperial {+4 1.0, maxpop 50000000, out 1.0, income 1.0, +0x18 1.0, +0x1c 1.0, +0x20 1.0}; civilian {+4 0.25, maxpop 20000000, out 0.33, income 0.33, +0x18 1.0, +0x1c 0.5, +0x20 2.0}; slaves {+4 0.0, maxpop 0, out SLAVES_OUTPUT_MOD, income SLAVES_INCOME_MOD, +0x18 SLAVES_REPAIR_MOD, +0x1c 0.0}. So the whole population -> output law is carried by the executable except the three slave modifiers [verified]
constexpr uint32_t InitPopTypeTable = 0x00135ca0;
// cdecl int (int groupType, int64 count) // ftol(POPTYPE[groupType].incomeModifier x (count / 14000.0)). The income analogue of GroupOutput; note the divisor is 14000, not the output law's 500000, and there is no 1.8 factor [verified]
constexpr uint32_t GroupIncome = 0x00135e80;
// cdecl SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files [verified]
constexpr uint32_t SpeciesDefTable_Get = 0x00145cc0;
// cdecl double (double x) // x >= 0 ? pow(x, 1/3) : -pow(-x, 1/3), with the exponent taken from the double 0.3333333333333333 at 0x00a3a7c8. Used by StripMineFraction [verified]
constexpr uint32_t SignedCubeRoot = 0x004e5680;
// offset offset float ScOutMod -- the last of the five output multipliers ComputeTotalOutput applies, and the innermost in the x87 chain [verified]
constexpr uint32_t ServerPlayer_off_ScOutMod = 0x0000012c;
// offset offset float -- the game-setup handicap OUTPUT multiplier, the sibling of SetupIncomeMult (+0x228) and SetupResearchMult (+0x22c). Read by ComputeTotalOutput as the third of its five multipliers [verified]
constexpr uint32_t ServerPlayer_off_SetupOutputMult = 0x00000224;
// data PopTypeRow[3] -- .bss, filled by InitPopTypeTable at startup. Stride 0x30 [verified]
constexpr uint32_t PopTypeTable_base = 0x007104e8;
// data SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184 [verified]
constexpr uint32_t SpeciesDefTable_base = 0x00710a00;
// data float** -- pointer slot; storage 0x00af08f0, which unusually carries a value (0.1f) in the file image rather than being .bss [verified]
constexpr uint32_t GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT = 0x006f08f4;
// data int** -- pointer slot read by MoraleOutputMod; the threshold is an INT compared against the Morale int[7] entry [verified]
constexpr uint32_t GlobalConst_slot_MORALE_INCREASE_OUTPUT = 0x006ec784;
// data float** -- pointer slot read by MoraleOutputMod [verified]
constexpr uint32_t GlobalConst_slot_MORALE_INCREASE_OUTPUT_MOD = 0x006ec78c;
// data int** -- pointer slot read by MoraleOutputMod [verified]
constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT = 0x006ec794;
// data float** -- pointer slot read by MoraleOutputMod [verified]
constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD = 0x006ec79c;
// data float -- .bss storage reached through the slot at 0x00ae2e88; the output modifier of the slave row of the population-type table. SLAVES_INCOME_MOD is 0x00b0e9ac (slot 0x00ae2e84) and SLAVES_REPAIR_MOD is 0x00b0e9b4 (slot 0x00ae2e8c) [verified]
constexpr uint32_t GlobalConst_storage_SLAVES_OUTPUT_MOD = 0x0070e9b0;
// data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified]
constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788;
// thiscall void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player.<id>.TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper<Game::CivilianRatios> frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list<T> members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries [verified]
@ -1555,6 +1667,34 @@ constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790;
// site site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790;
// member Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame) [verified]
constexpr uint32_t StrategyServer_off_SpyManager = 0x00000158;
// data void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots [verified]
constexpr uint32_t ServerSpyManager_vftable = 0x0063073c;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 7 (+0x1c). Reached ONLY virtually, from 0x007d97ea in StrategyServer::OnAllCombatDone_Tail phase 23 (call 6 of 8). Zero direct call sites. Body not read; its direct+tail-jump closure is 189 functions and contains no RNG entry point and none of the eleven inlined-draw functions [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot7 = 0x0048ad60;
// thiscall void (Game::ServerTradeManagerImpl* this, int arg1) // vftable 0x00a31b74 slot 8 (+0x20). Reached ONLY virtually, from 0x007d97b6 in OnAllCombatDone_Tail phase 23 (call 2 of 8); the site pushes one argument. Closure 86 functions, draw-free [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot8 = 0x0048e8d0;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 9 (+0x24). Reached ONLY virtually, from 0x007d97dd (phase 23, call 5 of 8). Closure 185 functions, draw-free [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot9 = 0x00468060;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 11 (+0x2c). Reached ONLY virtually, from 0x007d97d0 (phase 23, call 4 of 8). Closure 188 functions, draw-free [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot11 = 0x00448570;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 12 (+0x30). Reached ONLY virtually, from 0x007d97c3 (phase 23, call 3 of 8). Closure 193 functions, draw-free [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot12 = 0x0048e920;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 13 (+0x34). Reached ONLY virtually, from 0x007d97f7 (phase 23, call 7 of 8). REACHES THE STRATEGIC GENERATOR: -> 0x00820ca0, NextFloat at 0x00820e18 with the generator loaded as [reg+0x16c] then `lea ecx,[ecx+4]`; and -> 0x0088b440, NextInt at 0x0088b613 with `mov ecx,[ecx+0x16c]; add ecx,4`. Neither was ever observed firing: lane Z measured 0 tail words on 8 turns [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot13 = 0x0048ef80;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 14 (+0x38). Reached ONLY virtually, from 0x007d97a7 (phase 23, call 1 of 8). Closure 143 functions, draw-free [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot14 = 0x004590d0;
// thiscall void (Game::ServerTradeManagerImpl* this) // vftable 0x00a31b74 slot 15 (+0x3c). Reached ONLY virtually, from 0x007d9804 (phase 23, call 8 of 8). REACHES THE STRATEGIC GENERATOR: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 as `mov eax,[eax+0x16c]` then `mov ecx,eax`. Never observed firing [mapped]
constexpr uint32_t ServerTradeManagerImpl_vslot15 = 0x0042cca0;
// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 13 (+0x34). Reached ONLY virtually, from 0x007d9811 in OnAllCombatDone_Tail phase 23 -- the ninth call of the block, and the only one whose receiver is StrategyServer+0x15c rather than +0x158. REACHES THE STRATEGIC GENERATOR: Chance at 0x00887c8a on `mov ecx,[ecx+0x16c]`, and through 0x008408e0 Chance at 0x00840929 and 0x00840a3c plus NextInt at 0x008409c7, all on [reg+0x16c]. Never observed firing [mapped]
constexpr uint32_t ServerSpyManager_vslot13 = 0x004877b0;
// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 14 (+0x38). Reached ONLY virtually, from 0x007d989b in OnAllCombatDone_Tail phase 33 (call 1 of 2). REACHES THE STRATEGIC GENERATOR: Chance at 0x0088dc43 on `mov ecx,[eax+0x16c]`. Never observed firing [mapped]
constexpr uint32_t ServerSpyManager_vslot14 = 0x0048db80;
// thiscall void (Game::ServerSpyManager* this) // vftable 0x00a3073c slot 15 (+0x3c). Reached ONLY virtually, from 0x007d98a8 in OnAllCombatDone_Tail phase 33 (call 2 of 2). Closure 230 functions, draw-free [mapped]
constexpr uint32_t ServerSpyManager_vslot15 = 0x00487f30;
// thiscall void (void* this) // the do-nothing body every Game::SVScriptObject-derived class inherits in the hook slots it does not override. Occupies most of slots 4/11/25/27/30 across the 30 SVSO classes, which is what makes the non-stub overrides countable: 14 at slot 4, 7 at slot 11, 8 at slot 25, 1 at slot 30, 0 at slot 27 [mapped]
constexpr uint32_t SVScriptObject_EmptyOverride = 0x0040c5a0;
// cdecl Game::SVScriptObject* (int encID) // The EncObj factory. `dec eax; cmp eax,0x16; ja <null>; jmp dword [eax*4 + 0x0052bf60]` -- a 23-entry dword jump table indexed by encID-1. Live ids: 1 VonNeumann, 3 Swarm, 4 Derelict, 5 Monitor, 7 SystemKiller, 8 PuppetMaster, 9 SlaversRefuel, 10 SwarmQueen, 14 Locust, 17 CrowRuins, 20 Refugees, 21 Ortgay. Ids 2, 6, 11-13, 15, 16, 18, 19, 22, 23 and everything outside 1..23 return NULL. Class names read off the vftable store in each ctor [verified]
constexpr uint32_t SVScriptObject_FactoryByEncID = 0x0012bf00;
// data void* [23] // the jump table SVScriptObject_FactoryByEncID indexes with encID-1 [verified]

38
ghidra/vtable-owners.json Normal file
View file

@ -0,0 +1,38 @@
{
"_readme": [
"Hand-asserted receiver typings for functions that appear in NO vftable and",
"are not constructors, so tools/vtable_map.py's automatic seeds cannot reach",
"them. Every entry must carry evidence a reader can re-derive. These are",
"seeds for the `this`-propagation fixpoint, so one entry types a whole",
"subtree -- which is also why a wrong entry is expensive. Keep it short."
],
"owners": [
{
"func": "0x007dc6c0",
"name": "StrategyServer::ProcessTurn",
"class": "Game::StrategyServer",
"offset": 0,
"evidence": [
"first member op is `inc [esi+0x8]` = ModCount; lane Z read S+0x8 as",
"ModCount from StrategyServer::Write's wire tag at 0x0079fb2f",
"hands its own `this` to 0x007d7f70, which dispatches on [this+0x158]",
"and [this+0x15c]; the StrategyServer ctor 0x007d78d0 constructs a",
"ServerTradeManagerImpl into +0x158 and a ServerSpyManager into +0x15c",
"(vtable_map field 0x158 / field 0x15c)"
]
},
{
"func": "0x007d92a0",
"name": "StrategyServer::OnAllCombatDone_Tail",
"class": "Game::StrategyServer",
"offset": 0,
"evidence": [
"lane K read [ebx+0x54]/[ebx+0x58] as vector<ServerPlayer*> Players",
"dispatches on [ebx+0x158] and [ebx+0x15c] in one straight-line block,",
"matching the two managers the StrategyServer ctor 0x007d78d0 builds",
"there; slot 15 needs >=16 slots and slot 13 needs >=14, which the two",
"concrete vtables 0x00a31b74 (22) and 0x00a3073c (18) supply"
]
}
]
}

View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Lane V2 -- write the resolved indirect edges back into Ghidra.
Everything here comes from tools/vtable_map.py (RTTI vftable map + slot recovery
+ constructor-derived member typing). Nothing is decompiler-derived.
uv run python3 scripts/lane_v2_writeback.py [--dry]
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
PROG = "/Sword of the Stars.exe"
DRY = "--dry" in sys.argv
def call(tool, args):
if DRY:
print(f" [dry] {tool} {json.dumps(args)[:150]}")
return
p = subprocess.run(
["uv", "run", "python3", os.path.join(REPO, "tools", "reva_call.py"),
tool, json.dumps(args)],
cwd=REPO, capture_output=True, text=True, timeout=300)
ok = '"success":true' in p.stdout or '"success": true' in p.stdout
print(f" {'ok ' if ok else 'ERR'} {tool} {args.get('addressOrSymbol', args.get('address', ''))}"
f" {'' if ok else p.stdout.strip()[:200] + p.stderr.strip()[:200]}")
def comment(addr, text):
call("set-comment", {"programPath": PROG, "addressOrSymbol": addr,
"comment": text, "commentType": "pre"})
def plate(addr, text):
call("set-comment", {"programPath": PROG, "addressOrSymbol": addr,
"comment": text, "commentType": "plate"})
def label(addr, name):
call("create-label", {"programPath": PROG, "addressOrSymbol": addr,
"labelName": name})
TRADE_VT = "0x00a31b74"
SPY_VT = "0x00a3073c"
# --------------------------------------------------------------- the targets
# vtable, class, slot, target, phase note, RNG note
TARGETS = [
(TRADE_VT, "ServerTradeManagerImpl", 10, "0x00893290",
"dispatched at 0x007d8469 (StrategyServer::DetectEncounters)",
"DRAWS: Chance x3 at 0x00893426 / 0x00893513 / 0x008935ce on the "
"strategic generator; 16 of ~20 words of a strategic turn (lane Z)"),
(TRADE_VT, "ServerTradeManagerImpl", 14, "0x008590d0",
"OnAllCombatDone_Tail phase 23, call 1 of 8, at 0x007d97a7", ""),
(TRADE_VT, "ServerTradeManagerImpl", 8, "0x0088e8d0",
"OnAllCombatDone_Tail phase 23, call 2 of 8, at 0x007d97b6 (arg 1)", ""),
(TRADE_VT, "ServerTradeManagerImpl", 12, "0x0088e920",
"OnAllCombatDone_Tail phase 23, call 3 of 8, at 0x007d97c3", ""),
(TRADE_VT, "ServerTradeManagerImpl", 11, "0x00848570",
"OnAllCombatDone_Tail phase 23, call 4 of 8, at 0x007d97d0", ""),
(TRADE_VT, "ServerTradeManagerImpl", 9, "0x00868060",
"OnAllCombatDone_Tail phase 23, call 5 of 8, at 0x007d97dd", ""),
(TRADE_VT, "ServerTradeManagerImpl", 7, "0x0088ad60",
"OnAllCombatDone_Tail phase 23, call 6 of 8, at 0x007d97ea", ""),
(TRADE_VT, "ServerTradeManagerImpl", 13, "0x0088ef80",
"OnAllCombatDone_Tail phase 23, call 7 of 8, at 0x007d97f7",
"REACHES A DRAW: -> 0x00820ca0 NextFloat at 0x00820e18 and "
"-> 0x0088b440 NextInt at 0x0088b613, both on [obj+0x16c] = the "
"StrategyServer strategic generator. Never observed firing (lane Z "
"measured 0 tail words on 8 turns)"),
(TRADE_VT, "ServerTradeManagerImpl", 15, "0x0082cca0",
"OnAllCombatDone_Tail phase 23, call 8 of 8, at 0x007d9804",
"REACHES A DRAW: Chance at 0x0082cdb8, generator loaded at 0x0082cda4 "
"as [eax+0x16c] = the strategic generator. Never observed firing"),
(SPY_VT, "ServerSpyManager", 13, "0x008877b0",
"OnAllCombatDone_Tail phase 23, the ninth call, at 0x007d9811 "
"(receiver is StrategyServer+0x15c, not +0x158)",
"REACHES A DRAW: Chance at 0x00887c8a on [ecx+0x16c]; and via "
"0x008408e0 Chance at 0x00840929 / 0x00840a3c and NextInt at "
"0x008409c7, all on [reg+0x16c] = the strategic generator. "
"Never observed firing"),
(SPY_VT, "ServerSpyManager", 14, "0x0088db80",
"OnAllCombatDone_Tail phase 33, call 1 of 2, at 0x007d989b",
"REACHES A DRAW: Chance at 0x0088dc43 on [eax+0x16c] = the strategic "
"generator. Never observed firing"),
(SPY_VT, "ServerSpyManager", 15, "0x00887f30",
"OnAllCombatDone_Tail phase 33, call 2 of 2, at 0x007d98a8", ""),
]
# dispatch site -> (class, slot, target)
SITES = [
("0x007d8469", "ServerTradeManagerImpl", 10, "0x00893290", TRADE_VT),
("0x007d97a7", "ServerTradeManagerImpl", 14, "0x008590d0", TRADE_VT),
("0x007d97b6", "ServerTradeManagerImpl", 8, "0x0088e8d0", TRADE_VT),
("0x007d97c3", "ServerTradeManagerImpl", 12, "0x0088e920", TRADE_VT),
("0x007d97d0", "ServerTradeManagerImpl", 11, "0x00848570", TRADE_VT),
("0x007d97dd", "ServerTradeManagerImpl", 9, "0x00868060", TRADE_VT),
("0x007d97ea", "ServerTradeManagerImpl", 7, "0x0088ad60", TRADE_VT),
("0x007d97f7", "ServerTradeManagerImpl", 13, "0x0088ef80", TRADE_VT),
("0x007d9804", "ServerTradeManagerImpl", 15, "0x0082cca0", TRADE_VT),
("0x007d9811", "ServerSpyManager", 13, "0x008877b0", SPY_VT),
("0x007d989b", "ServerSpyManager", 14, "0x0088db80", SPY_VT),
("0x007d98a8", "ServerSpyManager", 15, "0x00887f30", SPY_VT),
]
# the SVScriptObject hook block on StrategyServer+0x1b4
SVSO = [
("0x007dcb8e", 4, "ProcessTurn, hook id 6 pushed at 0x007dcb8c"),
("0x007dcb97", 11, "ProcessTurn, the hook body paired with the id-6 gate"),
("0x007dcbb6", 4, "ProcessTurn, second gate"),
("0x007dcbbf", 30, "ProcessTurn, the hook body paired with it"),
("0x007d9767", 4, "OnAllCombatDone_Tail, gate"),
("0x007d9770", 25, "OnAllCombatDone_Tail, hook body"),
("0x007d9783", 4, "OnAllCombatDone_Tail, gate"),
("0x007d978c", 27, "OnAllCombatDone_Tail, hook body"),
("0x007d9838", 4, "OnAllCombatDone_Tail, gate"),
("0x007d9841", 30, "OnAllCombatDone_Tail, hook body"),
]
SVSO_NOTE = (
"SVScriptObject hook: receiver is StrategyServer+0x1b4, null-checked "
"before the block (`cmp reg,ebx; je`). 30 classes derive from "
"Game::SVScriptObject; the base's empty override is 0x0080c5a0. "
"Lane V2: slot 4 has 14 non-stub overrides, slot 11 has 7, slot 25 has 8, "
"slot 30 has 1, slot 27 has 0. Thirteen of those overrides reach an RNG "
"draw at depth 2-4 (e.g. SVSOCrowRuins slot 11 = 0x00518340 -> "
"0x004f4210 -> IntRangeBell 0x008e6d80). Which generator object those "
"draws use is NOT established -- it arrives as an argument. "
"Scripted-scenario only; presumed inactive in a normal game and still "
"not proven so."
)
def main():
print("== vtable-slot plate comments and labels ==")
for vt, cls, slot, tgt, where, rng in TARGETS:
txt = (f"Game::{cls} vftable {vt} slot {slot} (+0x{4 * slot:x}).\n"
f"Reached ONLY through a virtual dispatch: {where}.\n"
f"Receiver typed from the constructor: StrategyServer ctor "
f"0x007d78d0 stores the {cls} constructor's result into "
f"StrategyServer+0x{'158' if cls.startswith('ServerTrade') else '15c'}"
f"; corroborated by 0x007dcf90 (a StrategyServer method entered "
f"on the +4 sub-object) storing the same two constructors 4 "
f"bytes lower. [lane V2]")
if rng:
txt += "\n" + rng
plate(tgt, txt)
label(tgt, f"{cls}_vslot{slot}")
print("== dispatch-site comments ==")
for site, cls, slot, tgt, vt in SITES:
comment(site, f"-> Game::{cls}::vslot{slot} = {tgt} "
f"(vftable {vt} + 0x{4 * slot:x}) [lane V2]")
print("== SVScriptObject hook block ==")
for site, slot, where in SVSO:
comment(site, f"SVScriptObject slot {slot} (+0x{4 * slot:x}) -- {where}."
f"\n{SVSO_NOTE} [lane V2]")
print("== the two managers ==")
plate("0x007d78d0",
"Game::StrategyServer constructor (vptr installs: 0x00a26084 at +0, "
"0x00a26034 at +4, Mars::IStreamable 0x009e22bc at +0).\n"
"Lane V2 member typings taken from here:\n"
" +0x158 = Game::ServerTradeManagerImpl* (ctor 0x00858f70, "
"stored 0x007d7d81)\n"
" +0x15c = Game::ServerSpyManager* (ctor 0x00832a30, "
"stored 0x007d7d8e)\n"
"Game::ServerTradeManager (vftable 0x00a311a4) is abstract -- 21 of "
"its 22 slots are purecall -- and ServerTradeManagerImpl is its "
"ONLY derived class, so slot N of that interface resolves uniquely. "
"Game::ServerSpyManager has no *Impl: it is itself the concrete "
"class (18 slots, none purecall) over IServerSpyManager / "
"ISpyManager / IStreamable. [lane V2]")
return 0
if __name__ == "__main__":
sys.exit(main())

1257
tools/vtable_map.py Normal file

File diff suppressed because it is too large Load diff