lane K: StrategyServer::OnAllCombatDone_Tail mapped, 36 phases from the instruction stream
The second turn driver, 0x007d92a0, read byte for byte. Corrects turn-driver.md section 5: of the four subsystems it said live here, only bankruptcy does. - 36-phase map with strides enumerated (EncounterResults 0x178, Encounter 0x74, member 0x44) and the arity check that logs but does not return. - Phase 7 is encounters.clear(), not a filter: the erase pair is the same four-argument shape vector<Encounter>::operator= uses, and both arms converge three instructions later. - Bankruptcy: ProcessBankruptcy at phase 15, UpdateBankruptcyLimits at phase 31. Three corrections to formula-gaps Q1 - the divisor is the double -0.15000000596046448 not -0.15, the per-system income term is clamped at 0 before summing, and the 3.3 factor lives in .bss and is DB-loaded. - Turn results are FILLED here (phases 6, 11, 18 write S+0x2f4[PlyrIdx]) but rotated by ApplyEncounterResults and dispatched by SynchronizePlayer as event 0x25 afterwards. sizeof(SETurnResults) = 0x11c, enumerated five ways. - BuildTurnEvents is misnamed: it is the setup/load/rejoin resync push, gated on a pending descriptor, and references no EVENT_ string at all. - TurnEvents_Write and TurnEvents_Read are swapped in Ghidra (layouts.json is right). sizeof(TurnEvents) = 0x18, enumerated four ways. - The autosave: StrategyHost::Autosave 0x00895210, its four localized paths, the rotation that fires only on the post-turn call, the connection detach around the write, and why the payload carries nothing time-, name- or machine-derived. - Two RNG sources in the tail that nothing models: one NextFloat per expired node line, plus draws inside the combat resolver. Both run before the autosave. - S+0x8 advances twice per turn, not once. Repo-wide correction: the research-event roll costs one or two RNG words, not one. Fixed in unlock-cascade.md, addresses.json and lane-u.json; the captured compare artefacts under verify/results are left alone as run records.
This commit is contained in:
parent
f490d69695
commit
bb1f8b4692
8 changed files with 1072 additions and 7 deletions
742
findings/control-flow/combat-done-tail.md
Normal file
742
findings/control-flow/combat-done-tail.md
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
# The second turn driver, read from the instruction stream — `StrategyServer::OnAllCombatDone_Tail`
|
||||
|
||||
Lane K, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
||||
|
||||
**Method.** `StrategyServer::OnAllCombatDone_Tail` 0x007d92a0 (1587 B) and its caller's message case were
|
||||
disassembled **byte for byte** with `objdump -b binary -m i386 -M intel` over the raw image (file-offset mapping
|
||||
from the PE section table, call targets annotated from `dumps/functions.json`, immediates resolved against
|
||||
`.rdata`). No claim below about *control flow* comes from the decompiler. Callee **bodies** were swept with
|
||||
ReVa; every such claim is marked.
|
||||
|
||||
Companion to `findings/control-flow/turn-driver.md` (lane T), which mapped `StrategyServer::ProcessTurn` and
|
||||
`ServerPlayer::ProcessTurn` and named this function as the biggest unread block in the turn. This doc closes
|
||||
that gap and **corrects lane T's §0.1 and §5** — see §7.
|
||||
|
||||
---
|
||||
|
||||
## 0. Where it sits, and what its argument is
|
||||
|
||||
```
|
||||
End Turn
|
||||
-> StrategyHost::OnMessage 0x00784640, SNMEndTurn case (0x007848d8..0x00784979)
|
||||
BeginProcessTurn -> ApplyTurnCommands -> ... -> StrategyServer::ProcessTurn 0x007dc6c0
|
||||
host->+0x9c = S->encounters (FUN_007cf540 -> FUN_007cd100, vector assign)
|
||||
if (!host->+0x9c.empty()) SyncLocalClients(1); ...
|
||||
host->+0x58 = 5
|
||||
-> combat resolution (RunCombatRound 0x007cbe80 / the combat server FUN_007cfd00)
|
||||
both build a Game::SNMAllCombatDone (vptr 0x00a24758) and send it
|
||||
-> StrategyHost::OnMessage, SNMAllCombatDone case (0x00784cf8..0x00784efe)
|
||||
StrategyServer::OnAllCombatDone_Tail(S, msg+4) <-- THIS DOC
|
||||
...
|
||||
GenerateTurnEvents(S)
|
||||
autosave
|
||||
host->+0x58 = 6
|
||||
```
|
||||
|
||||
**Caller.** Exactly one, verified with a whole-image direct-call scan and confirmed by Ghidra
|
||||
(`callerCount: 1`): `StrategyHost::OnMessage` at 0x00784d07, `mov ecx,[esi+0x54]; push edi; call 0x7d92a0`.
|
||||
`esi` is the `StrategyHost`, `[host+0x54]` is the `StrategyServer`.
|
||||
|
||||
**Base, checked explicitly** (the campaign has paid for this once): `this` is **`S`**, the same frame
|
||||
`StrategyServer::ProcessTurn` uses, not `S+4`. Four independent reads in this function settle it —
|
||||
`[ebx+0x54]/[ebx+0x58]` is the `vector<ServerPlayer*> Players`, `[ebx+0x158]` the `ServerTradeManager`,
|
||||
`[ebx+0x1b4]` the `SVScriptObject`, `[ebx+0x1e8]` the encounter vector — all `S`-frame offsets lane T already
|
||||
established. Convert any `StrategyServer_off_*` from `addresses.json` by adding 4.
|
||||
|
||||
**Signature.** `void __thiscall StrategyServer::OnAllCombatDone_Tail(StrategyServer* S,
|
||||
std::vector<EncounterResult>* results)`, `ret 4`. Body 0x007d92a0..0x007d98d0.
|
||||
|
||||
**The argument's stride is enumerated, not guessed.** The prologue divides the argument's byte length by a
|
||||
constant and compares the quotient with the encounter count:
|
||||
|
||||
```
|
||||
007d92cd mov eax,[ebp+0x8] ; the argument
|
||||
007d92d0 mov ecx,[eax+0x4] ; _Mylast
|
||||
007d92d3 sub ecx,[eax] ; - _Myfirst
|
||||
007d92d5 mov eax,0xae4c415d
|
||||
007d92da imul ecx ; magic / add-back / sar 8 -> / 0x178
|
||||
...
|
||||
007d92de mov ecx,[ebx+0x1ec] ; encounters._Mylast
|
||||
007d92e4 sub ecx,[ebx+0x1e8] ; - _Myfirst
|
||||
007d92fa mov eax,0x8d3dcb09 ; magic / add-back / sar 6 -> / 0x74
|
||||
...
|
||||
007d930d cmp eax,edi
|
||||
007d930f je 0x7d9320
|
||||
007d9311 push 0xa26140 ; "Number of encounter results doesn't match number of encounters."
|
||||
007d9316 push 0x2
|
||||
007d9318 call 0x8b9ff0 ; Log
|
||||
```
|
||||
|
||||
Both magic/shift pairs were solved numerically over 0..200 multiples: `0xae4c415d`, add-back, `sar 8` is
|
||||
**`/ 0x178`**; `0x8d3dcb09`, add-back, `sar 6` is **`/ 0x74`** (lane T's team-record stride — the "team
|
||||
records" and the encounters are the same vector). So:
|
||||
|
||||
| type | stride | evidence |
|
||||
|---|---|---|
|
||||
| `EncounterResult` (the message payload element) | **0x178** | the division above |
|
||||
| `Encounter` (`S+0x1e8`) | **0x74** | the division above, and lane T's `ProcessTurn` phase 31 |
|
||||
| `Encounter`'s member record (`enc+0x28/+0x2c`) | **0x44** | `0x78787879 / sar 5`, four sites in this function |
|
||||
|
||||
**The mismatch is logged, not fatal.** The `je` at 0x007d930f skips only two pushes and the call; 0x007d9320
|
||||
is the fall-through. There is no early return anywhere in this function.
|
||||
|
||||
### 0.1 `Game::SNMAllCombatDone`
|
||||
|
||||
RTTI vtable at 0x00a24758 (`Game::SNMAllCombatDone`), four slots: 0x0079e590, 0x0082a100, 0x0082a170,
|
||||
0x0079e500 (the middle pair are the `Read`/`Write` of a network message). Three construction sites:
|
||||
|
||||
| site | what |
|
||||
|---|---|
|
||||
| `RunCombatRound` 0x007cc847 | builds it on the stack, `{vptr, 0, 0, 0}` |
|
||||
| `FUN_007cfd00`+0x541 (0x007d0241) — the combat server; Ghidra sizes this function at 384 B but the real body runs to `ret` at 0x007d02b9 | builds it on the stack, sends it to every player whose `+0x44` is 4 or 5, then sets `combatServer->+0x60 = 9` |
|
||||
| 0x008663b0 | `operator new(0x14)` + vptr — the deserialization factory |
|
||||
|
||||
Layout, from the stack constructors and from every offset this function reads:
|
||||
`struct SNMAllCombatDone { void* vptr; std::vector<EncounterResult> results; }` — the vector is at `msg+4`,
|
||||
which is why the tail is called with `msg+4` and not `msg`. **0x10 bytes by enumeration**; the factory's
|
||||
`operator new(0x14)` is 4 bytes larger and is not explained — flagged, not resolved.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase map
|
||||
|
||||
`this = ebx = S` throughout. "verified" below means read from the instruction stream in this lane; callee
|
||||
*bodies* are marked separately.
|
||||
|
||||
| # | VA | what runs | notes |
|
||||
|---|---|---|---|
|
||||
| 0 | 0x007d92ca | **`++S->+0x8`** | the **same word** `StrategyServer::ProcessTurn` increments at 0x007dc6f0. See §7.1 |
|
||||
| 1 | 0x007d92cd–0x007d931d | **arity check**, above. Logs and continues | |
|
||||
| 2 | 0x007d9320–0x007d94b3 | **first contact, over all ordered pairs of combatants.** Outer loop over encounters; two nested loops `i`,`j` over the encounter's member vector, skipping `i == j`. Per member: `id = m->player->+0x4` (0 when the player pointer is null); `X = HandleMap::Resolve(GetGame() + 0x84, id)`. When both `A` and `B` resolve: `A->MarkPlayerEncountered(B); B->MarkPlayerEncountered(A); A->MarkSpeciesDiscovered(B->Species); B->MarkSpeciesDiscovered(A->Species)` | see §2A. The `i == j` skip is `cmp [ebp-0x18],edi; je` at 0x007d93c9, with `edi` the inner index |
|
||||
| 3 | 0x007d94b3–0x007d9538 | **per (encounter, result), gate `result->+0x5 != 0 && result->+0x4 != 0`** (two adjacent BYTE flags): `AnnounceEncounterSighting(S, enc->+0xc, PickDominantEncounterType(enc->+0x38))` | `res->+0x4 != 0` is exactly the flag that makes `ApplyEncounterResult` a no-op in phase 6 — so **phase 3 is the *sighting* arm and phase 4 is the *battle* arm.** See §2A |
|
||||
| 4 | 0x007d9538–0x007d9610 | **a second, separate pass over the same pairs**, gate `result->+0x4 == 0`: for every member with `member->+0x4 != 2`, `++(WORD)S->Players[member->player->+0x28]->+0x3d8->+0x1a` | a 16-bit "battles fought this turn" statistic on the player turn record. Phases 3 and 4 are two full passes, not two arms of one loop |
|
||||
| 5 | 0x007d9610 | `FUN_00789d00(S, &S->encounters, results)` — **diplomacy: dead-homeworld and treaty-betrayal counters** | 979 B; see §2A |
|
||||
| 6 | 0x007d961c–0x007d9690 | **per (encounter, result): `StrategyServer::ApplyEncounterResult(S, enc, result)`** 0x007d8920 (2402 B), then `if (enc->+0xc) Node::ResupplyAlliedFleets(enc->+0xc, 1)` 0x007463f0 | the loop **re-reads `_Myfirst`/`_Mylast` every iteration**, so the callee is permitted to resize the vector under it. **This phase draws RNG through its subtree** — see §3 |
|
||||
| 7 | 0x007d9690–0x007d96bf | **`S->encounters.clear()`** | see §2 — this is the one place a reader is most likely to invent a filter that isn't there |
|
||||
| 8 | 0x007d96bf–0x007d96df | `if (S->+0x1b4) { script->vft[0x10](8, 0); script->vft[0x34](); }` | `SVScriptObject` hook, event id **8** |
|
||||
| 9 | 0x007d96df–0x007d970b | per player **`FUN_00818530(p, 1)`** — the in-progress `AIRebellion` step at `ServerPlayer+0x3b8` | lane T's `ProcessTurn` phase 22 calls the same function with **0**. This is the only site that passes 1 |
|
||||
| 10 | 0x007d970b | **`ProcessNodeSpaceTravel(S)` 0x007a0e20** | **run a second time this turn** — `ProcessTurn` phase 7 already ran it |
|
||||
| 11 | 0x007d9714 | `FUN_007ae010(S)` — **node-line decay. THE ONLY RNG DRAW IN THE TAIL** | 1117 B; see §3 |
|
||||
| 12 | 0x007d971b | `FUN_007be870(S)` — colony-loss queue drain (`S+0x254/+0x258`, 0xc stride) | 1706 B |
|
||||
| 13 | 0x007d9720 | `FUN_00798290(S)` **cdecl** — treasury / special-project morale | 895 B |
|
||||
| 14 | 0x007d9727 | `FUN_00798630(S)` **cdecl** — foreign-fleet-presence morale | 933 B |
|
||||
| 15 | 0x007d9731 | **`StrategyServer::ProcessBankruptcy` 0x007c0a50** | 2291 B; see §4 |
|
||||
| 16 | 0x007d9738 | `FUN_007bd260(S)` — arrived-colonizer resolution on unowned systems | 205 B |
|
||||
| 17 | 0x007d973f | `FUN_007cf560(S)` — rebuild every `StarSystem::PlayerView`; tears down and rebuilds the tree at `S+0x228`, resets `S+0x22c = 0` | 948 B |
|
||||
| 18 | 0x007d9746 | `FUN_007a4ff0(S)` — overharvest / no-engine / no-fuel warnings | 1865 B |
|
||||
| 19 | 0x007d974d | `FUN_007a3750(S)` — infra/terraform completion queue drain (`S+0x234/+0x238`) | 1257 B |
|
||||
| 20 | 0x007d9752–0x007d978e | `if (S->+0x1b4) { vft[0x10](0x14,0); vft[0x64](); vft[0x10](0x15,0); vft[0x6c](); }` | **both pairs are inside the one null test** — the `je` at 0x007d975a targets 0x007d978e, past both. The reload of `S+0x1b4` at 0x007d9772 is not a second test |
|
||||
| 21 | 0x007d9790 | `FUN_007a3c60(S)` — survey masks + `EVENT_FLEET_EXPLORED` + derived system stats | 1025 B |
|
||||
| 22 | 0x007d9797 | `FUN_0078a7c0(S)` | 254 B — **run a second time this turn** (`ProcessTurn` phase 12) |
|
||||
| 23 | 0x007d979c–0x007d9813 | **eight vtable calls on the `ServerTradeManager` at `S+0x158`**, in order `vft[0x38]()`, `vft[0x20](1)`, `vft[0x30]()`, `vft[0x2c]()`, `vft[0x24]()`, `vft[0x1c]()`, `vft[0x34]()`, `vft[0x3c]()`; then `S->+0x15c`'s `vft[0x34]()` | wholly unmodelled; see the gap list |
|
||||
| 24 | 0x007d9815 | `FUN_0078ace0(S)` — recompute per-player ship maintenance (`+0x15c`) and research bonus (`+0x160`), rebuild `ShipRecs` at `+0x1b0` | 431 B |
|
||||
| 25 | 0x007d981a | **`FUN_0086a8d0(S)` cdecl — the sensor / fog-of-war update** | 248 B — **run a second time this turn** (`ProcessTurn` phase 24) |
|
||||
| 26 | 0x007d9820–0x007d9843 | `if (S->+0x1b4) { vft[0x10](0x1c,0); vft[0x78](); }` | **byte-identical to `ProcessTurn` phase 25** |
|
||||
| 27 | 0x007d9845 | `FUN_0078ab30(S)` — `StarSystem::PlayerView` refresh | 110 B — **run a second time this turn** (`ProcessTurn` phase 26) |
|
||||
| 28 | 0x007d984c | `FUN_007ade00(S)` — node-line sighting masks | 519 B |
|
||||
| 29 | 0x007d9855 | `FUN_007a23e0(S, 1)` — abort intercept orders whose target went invisible, **and post `EVENT_FLEET_INTERCEPT_ABORTED`** | 784 B. `LoadGame` calls the same function with **0**, which suppresses only the event |
|
||||
| 30 | 0x007d985c | `FUN_0078aba0(S)` — rebuild the per-turn communication bitmask `ServerPlayer+0x198` | 295 B |
|
||||
| 31 | 0x007d9861–0x007d9889 | **per player `ServerPlayer::UpdateBankruptcyLimits(p)` 0x00818600** | see §4 |
|
||||
| 32 | 0x007d988b | `FUN_007a4070(S)` — `EVENT_ENEMY_INCOMING_<Species>` / `EVENT_ALIEN_INCOMING` | 1640 B |
|
||||
| 33 | 0x007d9890–0x007d98aa | `S->+0x15c`: `vft[0x38]()`, `vft[0x3c]()` | |
|
||||
| 34 | 0x007d98aa | `FUN_007c2350(S)` — record observed designs into `ServerPlayer+0x254/+0x258` | 222 B |
|
||||
| 35 | 0x007d98b1 | `FUN_007991a0(S)` — rebuild every `PlayerReport` in `ServerPlayer+0x244/+0x248` | 208 B |
|
||||
| 36 | 0x007d98ba | **`FUN_0078a0e0(S)` — fill every player's turn record `+0x3d8` and archive it** | 416 B; see §5. Must stay last |
|
||||
| — | 0x007d98bf–0x007d98d0 | SEH unlink, `ret 4` | no `__security_check_cookie`; the cookie is only the EH frame's |
|
||||
|
||||
### 1.1 It is straight-line
|
||||
|
||||
**Past 0x007d96bf there is not one conditional jump that skips a phase.** Every `jcc` in phases 8..36 is
|
||||
either a per-player loop bound or one of the three `test esi,esi` null tests on `S+0x1b4`. Phases 11 through
|
||||
36 run on **every** invocation, whatever the encounters were. The one branch that could be mistaken for a
|
||||
phase gate is the empty-vector test at 0x007d9697, and it converges three instructions later at 0x007d96bf.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 7 is `clear()`, and the shape that proves it
|
||||
|
||||
This is the trap this campaign keeps paying for, so it is worth the bytes:
|
||||
|
||||
```
|
||||
007d9690 mov eax,[esi+0x4] ; encounters._Mylast
|
||||
007d9693 mov ecx,[esi] ; encounters._Myfirst
|
||||
007d9695 cmp ecx,eax
|
||||
007d9697 je 0x7d96bf ; empty -> skip
|
||||
007d9699 mov edx,[ebp+0x8] ; results
|
||||
007d969c push edx
|
||||
007d969d push ecx ; first
|
||||
007d969e push eax ; last
|
||||
007d969f push eax ; last
|
||||
007d96a0 call 0x7c5780
|
||||
007d96a5 mov ecx,[ebp+0x8]
|
||||
007d96a8 push ecx
|
||||
007d96a9 mov edi,eax ; newEnd
|
||||
007d96ab mov eax,[esi+0x4]
|
||||
007d96ae lea edx,[esi+0xc]
|
||||
007d96b1 push edx ; push eax ; push edi
|
||||
007d96b4 call 0x679c80
|
||||
007d96b9 add esp,0x20
|
||||
007d96bc mov [esi+0x4],edi ; _Mylast = newEnd
|
||||
007d96bf ... ; BOTH PATHS ARRIVE HERE
|
||||
```
|
||||
|
||||
`FUN_007c5780(last, last, first, c)` followed by `FUN_00679c80(newEnd, last, &vec+0xc, c)` and
|
||||
`_Mylast = newEnd` is MSVC's `vector::erase(_First, _Last)` — `unchecked_move(_Last, _Mylast, _First)` then
|
||||
`_Destroy`. The **identical four-argument shape with the identical argument order** appears at 0x007cd147 /
|
||||
0x007cd15b inside `FUN_007cd100`, which is `vector<Encounter>::operator=` taking the empty-source path — and
|
||||
there it demonstrably clears the destination. With `_First == begin` and `_Last == end` this is
|
||||
`erase(begin, end)`, i.e. **`clear()`**.
|
||||
|
||||
Both callee bodies were then read independently and confirm it. `FUN_007c5780(first, last, dest, _Al)` is
|
||||
`std::_Uninit_move` over 0x74-byte `Encounter`s — and the range it is handed here is `[_Mylast, _Mylast)`,
|
||||
**empty**, so it copies nothing and returns `dest == _Myfirst`. `FUN_00679c80` is `std::_Destroy_range`,
|
||||
stepping 0x74 and per element destroying the sub-object at `enc+0x64`, the 0x38-stride vector at
|
||||
`enc+0x40/+0x44/+0x48`, and the 0x44-stride member vector at `enc+0x28/+0x2c/+0x30`. The trailing `results`
|
||||
pointer is the stateless-allocator reference slot and is unused by either body.
|
||||
|
||||
There is **no predicate and no filter**: the encounter list is emptied wholesale at the end of the tail.
|
||||
Anyone reading a decompile of phase 7 will see an `if` wrapping three calls and be tempted to describe it as
|
||||
"encounters are conditionally pruned". It is not: the `if` is the empty-vector guard `erase` always carries.
|
||||
|
||||
---
|
||||
|
||||
## 2A. Phases 2–6, identified
|
||||
|
||||
Names below are proposals; the *call shapes and argument orders* are instruction-verified from this function,
|
||||
and each callee body was read (mostly as instructions — Ghidra's decompile of `FUN_007a9db0` is badly broken,
|
||||
deleting the entire event-posting body as unreachable).
|
||||
|
||||
| addr | name | what |
|
||||
|---|---|---|
|
||||
| 0x00578050 | **`GetGame()`** | `mov ecx,0xb29f98; jmp 0x005f6450` where 0x005f6450 is `mov eax,[ecx+4]; ret` — i.e. `return *(void**)0xb29f9c`. **0x00b29f98 is the same global the autosave takes as its `this`, and `+0x4` is the same "strat game" pointer it null-checks and hands to the save writer** (§6). 670 xrefs |
|
||||
| 0x008b9240 | **`HandleMap::Resolve(id)`** | `if (!id) return 0; slot = id & 0xF; if (slot >= (map->+0xc − map->+0x8)/0x14) return 0; b = map->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+4 ? 0 : *(void**)(it + 0x10);` — a **16-bucket `hash_map<uint32, Object*>`**, buckets 0x14 bytes each, each bucket a red-black tree. `GetGame()+0x84` is the game's global handle map |
|
||||
| 0x0080df10 | **`ServerPlayer::MarkPlayerEncountered(other)`** | `this->HasEnc(+0x1a8) \|= 1 << other->PlyrIdx(+0x28)`; no-op if `other == 0` |
|
||||
| 0x0080dee0 | **`ServerPlayer::MarkSpeciesDiscovered(species)`** | `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) \|= 1 << species`. Species index 4 is permanently excluded |
|
||||
| 0x004f4c40 | **`PickDominantEncounterType(uint typeMask)`** | clears bit 0 (`Standard`), then narrows by group precedence — if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} it is **restricted to** that group; otherwise the ambient groups {Swarm, Derelict, Monitor, SlaversRefuel, CrowRuins}, {CrowsNest, GravTrap} and {GasCloud, Meteor, Pirate, TradeRaiders, 20, 23} are dropped **if anything else remains** — then returns the **index of the lowest surviving set bit** in [0, 0x18), else 0. `FUN_004f4970` is the id→name switch that fixes the return type as an `EncounterType` enum |
|
||||
| 0x007a9db0 | **`StrategyServer::AnnounceEncounterSighting(node, encType)`** | `mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return;` — that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills `mask` with the players who can see it. Then, per set player, **posts an event whose key is the literal `"EVENT_"` (0x00a24bf8, length 6) concatenated with the type name** — `EVENT_PIRATE`, `EVENT_TRADERAIDERS`, … Finally push_backs a 0x10-byte `{system, encType, turn, turn+1}` record into `S+0x2c8/+0x2cc/+0x2d0` |
|
||||
| 0x00789d00 | **`StrategyServer::UpdateDiplomacyStatsFromCombat(encounters, results)`** | two passes over `ServerPlayer+0x230 vector<DiplomacyStats>`. **A:** if the battle was at a player's own `HomeSys(+0x2c)`, was a real battle (`res->+4 == 0`), had planet stats, and the **int64 at `res+0x120` is ≤ 0** (population gone), then every participant that actually fought them gets `deadhome(+0x20)++`. **B:** for every ordered pair with `GetRelation < 1`, a treaty slot signed within the last 3 turns and not yet betrayed since signing, where the other side actually fought — `bty++` and `lastXbty = turn`, for NAP, alliance and ceasefire independently. No RNG, no events |
|
||||
| 0x007463f0 | **`Node::ResupplyAlliedFleets(mask=1)`** | `if (!node->+0x100) return;` then per fleet at the node whose owner has `GetRelation == 3` (allied/self), per ship: `StarShip::RefreshFromDesign(1)` 0x00854680 = `ship->+0x20 = design->+0xe8; ship->+0x6c = design->+0xd8` plus five recompute helpers. A repair/refuel/stat refresh, not a movement step. (`strategic-turn-internals.md` already calls it `RefuelInOrbit(1)`; that is the right idea) |
|
||||
| 0x007d8920 | **`StrategyServer::ApplyEncounterResult(Encounter*, EncounterResults*)`** | see below |
|
||||
|
||||
### 2A.1 `ApplyEncounterResult` — top-level shape only
|
||||
|
||||
Dispatch on three bytes of the result: **`res->+0x4 != 0` makes the whole function a no-op**; `res->+0x6 != 0`
|
||||
→ `FUN_007a06a0` (posts `EVENT_PEACEFUL_ENCOUNTER`); `res->+0x7 != 0` → `FUN_007d3eb0` (posts
|
||||
`EVENT_SYSTEM_SURRENDERED`); otherwise the full path: stamp `StarShip+0x5c = turn` on every participating
|
||||
ship; set the pairwise `HasEng(+0x1ac)` engagement bits; if `enc->+0x3c` stamp `ServerPlayer+0x3d4 = turn`;
|
||||
build a ~0xea0-byte combat report and run the **real resolver `FUN_007d5af0` (7499 B)**; append a
|
||||
`Game::CombatReport` to the `std::list` at `S+0x1fc`; fire the script hook `vt[0x10](7, …)` / `vt[0x30](…)`.
|
||||
All branches then run a **publication tail** that push_backs a 0x30-byte `Game::ClientEncounterResults` into
|
||||
`*(S+0x2f4) + PlyrIdx*0x11c + 0x24` — independently confirming both the `S+0x2f4` base and the 0x11c stride
|
||||
of §5A.
|
||||
|
||||
That `res->+0x4` gate is what makes phases 3 and 4 complementary: **`+0x4 != 0` is "no battle happened"**, so
|
||||
phase 3 announces a sighting and phase 4 counts a battle.
|
||||
|
||||
**`FUN_007d5af0` was not read.** It is 7499 bytes and it is where combat outcomes actually resolve. It was
|
||||
characterised only by its string table, its callees and its RNG/event reachability.
|
||||
|
||||
*Source: every identification in this section is a delegated read of the callee body — mostly as
|
||||
instructions, since the decompiler is unreliable on this family. The **call shapes** are mine, from this
|
||||
function's bytes. Field names (`HasEnc`, `HasDiscCl`, `HasEng`, `HomeSys`, `PlyrIdx`, `Species`, `deadhome`,
|
||||
`lastnap`/`btynap`, `PID`, `NShips`) come from `objects/layouts.md` and line up with the offsets in the code.*
|
||||
|
||||
---
|
||||
|
||||
## 3. The tail advances the strategic RNG — phases 6 and 11
|
||||
|
||||
Two phases draw, and neither is modelled anywhere in the repo.
|
||||
|
||||
**Phase 6, `ApplyEncounterResult`, through its subtree** (depth ≤ 4, callee sweep): `FUN_007d5af0` →
|
||||
`FUN_007bb530` → `RNG_NextInt` 0x004271c0 (the node-cannon path, which posts `EVENT_NODECANNON_FLINGS` /
|
||||
`EVENT_NODECANNON_KILLS`), and `FUN_007d5af0` → `FUN_007a7f30` → `RNG_Twist` 0x00426e00 plus
|
||||
`FUN_007a7f30` → `FUN_007a0540` → `FUN_00852d30` → `RNG_NextInt` (the salvage / back-engineering
|
||||
special-project path, `EVENT_SPRJBACKENG_UNLOCKED`). No `NextFloat` and no `Chance` in that subtree to
|
||||
depth 4. Draw counts are entirely combat-dependent and unknown.
|
||||
|
||||
**Phase 11, `FUN_007ae010` — instruction-verified.** It (1117 B) walks the 0x30-stride node-line records at
|
||||
`[S+0x154]+8/+0xc`. For each line whose remaining lifetime has run out it rolls:
|
||||
|
||||
```
|
||||
007ae095 mov ecx,[esi+0x16c] ; the strategic RNG object
|
||||
fld dword [0x009e2ea0] ; 0.5f
|
||||
push ecx ; fstp [esp]
|
||||
call 0x008e6dd0 ; Mars::RNG::Chance(float)
|
||||
```
|
||||
|
||||
`Mars::RNG::Chance` (0x008e6dd0) early-outs at `p <= 0` and `p >= 1` **without a draw**; `0.5f` takes neither
|
||||
early-out, so this is **exactly one `NextFloat` per expired node line per turn**. The count is
|
||||
state-dependent, so the number of words the tail consumes is not a constant.
|
||||
|
||||
**This matters more than its size suggests.** Every RNG accounting the campaign has done treats the
|
||||
strategic generator as advancing only inside `StrategyServer::ProcessTurn`. It also advances here, after
|
||||
combat — once per collapsing node line, plus whatever combat resolution spends — and the byte-identical
|
||||
autosave (§6) is written *after* that. Any reimplementation that reproduces `ProcessTurn` exactly and stops
|
||||
will still diverge on the first turn a node line expires or a node cannon fires.
|
||||
|
||||
Downstream of a successful roll (`FUN_007a92e0` 690 B then `FUN_007a4700` 2244 B) fleets are destroyed or
|
||||
halted and three events are posted: `EVENT_NODEDECAY_FLEET_DESTROYED_VIANODE`, `EVENT_NODEDECAY_FLEET_HALTED`,
|
||||
`EVENT_NODEDECAY_FLEET_HALTED_VIANODE`. The roll is skipped for a line if any fleet with flag `0x20000` is
|
||||
targeting it.
|
||||
|
||||
*Source: the `Chance(0.5f)` call site and its operand are instruction-verified; the surrounding record layout
|
||||
and the downstream event names are from a ReVa sweep.*
|
||||
|
||||
### 3.1 The RNG pointer is `S+0x16c`
|
||||
|
||||
Two independent instruction reads agree: `ServerPlayer::ProcessTurn` 0x0089147f
|
||||
(`eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and `ServerPlayer+0x8 == S+4`, so `eax == S`) and this call site
|
||||
(`mov ecx,[esi+0x16c]`, `esi == S`). **`S+0x16c`**, i.e. `(S+4)+0x168`, which is exactly what
|
||||
`addresses.json`'s `StrategyServer_off_RNGPtr = 0x168` says once the frame is applied. `off_RNG` is *not* an
|
||||
exception to the `S+4` rule; lane T's §0 note that it is should be read as "the value there is entered at
|
||||
`+4` by `NextFloat`", which is a different thing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bankruptcy — both halves, and the ordering is now instruction-verified
|
||||
|
||||
`findings/subsystems/formula-gaps.md` Q1 already derived the formula and asserted the ordering
|
||||
("`OnAllCombatDone_Tail` → … → `ProcessBankruptcy` → (later in the tail) `UpdateBankruptcyLimits`"). That
|
||||
assertion is now read off the instruction stream, with addresses:
|
||||
|
||||
* **`StrategyServer::ProcessBankruptcy` 0x007c0a50 is phase 15**, at 0x007d9731.
|
||||
* **`ServerPlayer::UpdateBankruptcyLimits` 0x00818600 is phase 31**, at 0x007d9876, **0x145 bytes later**.
|
||||
|
||||
So within one invocation the *decision* runs before the *limits are recomputed*: the limits a turn's
|
||||
bankruptcy check uses are the ones written at the end of the previous turn, or by `LoadGame` 0x007ddc16.
|
||||
Those two are the only call sites of `UpdateBankruptcyLimits` in the image.
|
||||
|
||||
### 4.1 Three refinements to `formula-gaps.md` §3.3
|
||||
|
||||
Read as instructions over the whole 173-byte body:
|
||||
|
||||
```
|
||||
maxIncome = Sum over s in P->OwnId (+0x30/+0x34) of max( ComputeMaxIncome(s), 0 ) // int32
|
||||
BnkEl = max( trunc( maxIncome / -0.15000000596046448 ), -2000000000 ) // P+0x2cc
|
||||
BnkPr = max( -trunc( maxIncome * BANKRUPTCY_PROTECTION_LIMIT_FACTOR ), BnkEl ) // P+0x2d0
|
||||
```
|
||||
|
||||
1. **The divisor is not `-0.15`.** The `.rdata` double at 0x00a2ec30 is `-0.15000000596046448` — that is
|
||||
`(double)(float)-0.15f`, the round-tripped float. A reimplementation that writes `-0.15` will be one ulp
|
||||
out on large empires.
|
||||
2. **The per-system term is clamped at 0 before summing.** `FUN_007521c0` = `ServerSystem::ComputeMaxIncome`
|
||||
returns `max(ComputeOutputRates(sys)[3], 0)` (a `jg`), so negative-income colonies contribute nothing
|
||||
rather than reducing the total.
|
||||
3. **3.3 is not a binary constant.** `0x00aedfdc` points to `0x00b23e28`, which is past `.data`'s raw end —
|
||||
it is `.bss`, zero-initialised, filled from the game data files at load. Only `-0.15…` and `-2000000000`
|
||||
(int32 at 0x00a2c638) are hard-coded. The 3.3 in `formula-gaps.md` came from the data, and should be
|
||||
labelled as DB-sourced.
|
||||
|
||||
`FUN_00925220` is **`_ftol2`** (SSE2 `cvttsd2si` fast path behind the flag at 0x00b2f55c, x87 `fistp` fixup
|
||||
otherwise) — truncation toward zero, not `pow` or `fmod`. Only two clamps exist, both one-sided lower bounds;
|
||||
there is no upper clamp, so a player with **zero owned systems has both limits at 0** and reads as
|
||||
bankruptcy level 2 on the next `BankruptcyLevel` call.
|
||||
|
||||
`ProcessBankruptcy` itself posts five events — `EVENT_PLAYER_BANKRUPT`, `EVENT_PLAYER_BANKRUPTCY_IMMINENT`,
|
||||
`EVENT_PLAYER_BANKRUPTCY_AVOIDED`, `EVENT_PLAYER_ECONOMY_WARNING`, `EVENT_PLAYER_ECONOMY_OK` — writes
|
||||
`+0x2c4`/`+0x2c8` through `SetBankruptcyState`, and can reach `FUN_007bd930` (2133 B,
|
||||
`EVENT_PLAYER_ELIMINATED_`). It **draws no RNG** and **never writes savings** (`+0x284`).
|
||||
|
||||
*Source: `UpdateBankruptcyLimits` and its constants are instruction-verified; `ProcessBankruptcy`'s body is a
|
||||
ReVa sweep with its event literals read from the raw `push imm32` operands.*
|
||||
|
||||
---
|
||||
|
||||
## 5. The turn record — phase 36, `FUN_0078a0e0`
|
||||
|
||||
`ServerPlayer+0x3d8` is a per-player per-turn summary record. Lane T found two of its fields
|
||||
(`+0x8` alliance/vision mask, set in `ProcessTurn`'s pre-pass; `+0x10` trade income, from `ComputeBudget`).
|
||||
Phase 4 of this function writes a third (`+0x1a`). Phase 36 fills the rest and archives the whole thing.
|
||||
|
||||
| rec offset | width | value |
|
||||
|---|---|---|
|
||||
| `+0x0c` | 32 | `P->Sav(+0x284) − P->PvSav(+0x188)` — net savings change this turn |
|
||||
| `+0x14` | 32 | `P->Sav(+0x284)` |
|
||||
| `+0x18` | **16** | owned-system count `(P->+0x34 − P->+0x30) >> 2`, truncated to `short` |
|
||||
| `+0x20`,`+0x24` | 64 | `Σ over owned systems (sys->+0x194 + sys->+0x18c)` — total population, accumulated with `cdq`/`add`/`adc` |
|
||||
| `+0x28` | **16** | completed-tech count (`FUN_0057d980` over the tree's `+0x10/+0x14`, `state == 4`) |
|
||||
| `+0x2a`,`+0x2c`,`+0x2e` | **16** | ship counts by hull size 0/1/2 for designs **without** flag `0x400` |
|
||||
| `+0x30`,`+0x32`,`+0x34` | **16** | ship counts by hull size 0/1/2 for designs **with** flag `0x400` |
|
||||
|
||||
The census is built by `FUN_00818a50(P, int[8])`; slots `[0]` and `[1]` (the two grand totals) are computed
|
||||
and **discarded** — only the six per-class breakdowns are stored. Then, per player at vector index `i`,
|
||||
`FUN_00894260(S->+0x200, i, S->+0xc, rec)` copies the record into the **history archive** keyed by turn.
|
||||
The archive's record copy-assign (`FUN_008712a0`) deliberately does **not** copy `+0x1c`.
|
||||
|
||||
`FUN_0078a0e0` is called from exactly two places: here (0x007d98ba) and `LoadGame` (0x007ddc40). The record
|
||||
is therefore rebuilt at the end of every turn *and* on load, which is why it never has to survive a save
|
||||
round-trip unchanged.
|
||||
|
||||
Per the campaign's rule, the on-disk primitive is not the memory kind: the `short` stores above are what the
|
||||
**memory** layout uses. Whether the archive's serializer writes them with `WriteInt` (four bytes on the wire)
|
||||
has not been checked here and must not be assumed.
|
||||
|
||||
*Source: field-by-field instruction read of the 416-byte body; the archive indexing chain
|
||||
(`FUN_00893fd0` / `FUN_00884e40` / `FUN_008712a0`) is a ReVa sweep.*
|
||||
|
||||
---
|
||||
|
||||
## 5A. Turn results — accumulated **here**, rotated and dispatched **after**
|
||||
|
||||
`SETurnResults` (RTTI vtable 0x00a24b00) is a per-player "what happened to you this turn" package.
|
||||
**`sizeof == 0x11c`**, enumerated five independent ways: the container stride in
|
||||
`vector<SETurnResults>::resize` 0x007cd2a0 (`imul esi,esi,0x11c`, and the reciprocal `0xe6c2b449 / sar 8`),
|
||||
the `add esi,0x11c` in `_Ufill` 0x007c5850, the accessor 0x00788cb0 (`base[PlyrIdx * 0x11c]`), the
|
||||
`operator new[]` in 0x0078b0c0, and the default constructor closing at `+0x118` (`_Alval` of the last member,
|
||||
a vector at `+0x10c`).
|
||||
|
||||
The pipeline, all four steps instruction-verified:
|
||||
|
||||
| step | where | what |
|
||||
|---|---|---|
|
||||
| **accumulate** | *during the turn, including three phases of this function* | writers index `S->+0x2f4` by `PlyrIdx * 0x11c` |
|
||||
| **rotate** | `ApplyEncounterResults` 0x007d4400 tail, 0x007d4fa0–0x007d505f | destroy every element of `S+0x304`; **swap the vector headers of `S+0x2f4` and `S+0x304`**; then `resize(S+0x2f4, 0)` and `resize(S+0x2f4, nPlayers)` — fresh empties for the next turn |
|
||||
| **dispatch** | `SynchronizePlayer` 0x007c6220, 0x007c865f–0x007c86e6 | `if (S->+0x304.size() == Players.size()) { r = S->+0x304 + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }` |
|
||||
| **discard** | `GenerateTurnEvents` 0x007dc640, first statement | `FUN_007c5610(&scratch, S->+0x304, S->+0x308)` — erase-to-empty of `S+0x304` |
|
||||
|
||||
So there are **two adjacent `vector<SETurnResults>` 0x10 apart**: `S+0x2f4` is the *accumulator* the turn
|
||||
writes into, `S+0x304` is the *outbox* the network reads from. `ApplyEncounterResults` swaps them.
|
||||
`SETurnResults` is **strategy-event id 0x25**, sent one record to one player, and it is **not serialized** —
|
||||
its vtable has no `Read`/`Write` pair and it appears in no save schema.
|
||||
|
||||
**Three phases of this function write turn-result records.** Found by a whole-image byte scan for
|
||||
`imul r32,r32,0x11c` and `add r32,0x11c` at real instruction boundaries, attributed to containing functions:
|
||||
|
||||
| phase | function | sites |
|
||||
|---|---|---|
|
||||
| 6 | `FUN_007d8920` the encounter-result applier | 0x007d8f9e |
|
||||
| 11 | `FUN_007ae010` node-line decay | 0x007ae286, 0x007ae3ce |
|
||||
| 18 | `FUN_007a4ff0` fleet warnings | 0x007a516f |
|
||||
|
||||
(Plus `FUN_007a4700`, reached from phase 11, and `FUN_007b9df0`, reached from `ProcessTurn` phase 1;
|
||||
`ProcessAid` writes three.) So lane T's "no turn results run here" is half right and half wrong: the
|
||||
*transport object* is neither constructed nor dispatched here, but **the records are filled here**, and the
|
||||
rotation that freezes them happens 0xf3 bytes after this function returns.
|
||||
|
||||
## 5B. Turn events — and `BuildTurnEvents` is not what its name says
|
||||
|
||||
`GenerateTurnEvents` 0x007dc640 is **122 bytes** and does four things:
|
||||
|
||||
```
|
||||
FUN_007c5610(&scratch, S->+0x304, S->+0x308) ; clear the SETurnResults outbox (5A)
|
||||
if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 ; clear a 0xc-stride vector at S+0x244
|
||||
; (the copy loop at 0x007dc680 is DEAD:
|
||||
; `cmp edx,edx; je` at 0x007dc676 always jumps)
|
||||
FUN_00792a20(S) ; prune two intrusive lists at S+0x2d8 / S+0x2e4
|
||||
if (S->+0x128 & 4) BuildTurnEvents(S) ; almost always false
|
||||
```
|
||||
|
||||
`BuildTurnEvents` 0x007db780 (3701 B) is **not a per-turn event builder**. Its whole body is under
|
||||
`if (S->+0x12c != 0)`, and bit 2 of `S+0x128` together with the `S+0x12c` descriptor is set in exactly one
|
||||
place in the image — `FUN_007bd1b0` at 0x007bd204/0x007bd23a, on the *setup / load / rejoin* path. What it
|
||||
does is a **full-state resync push**: `SEResetMap` (id 0x29), `SEAddPlayer` (0x01), `SEInitTrade` (0x2a),
|
||||
`SETurnEvents` (0x28), `SESyncDesign` (0x19), plus `SynchronizePlayer`. It **references no `EVENT_*` string
|
||||
at all** — the only string immediates in its 3701 bytes are `"vector<T> too long"` and
|
||||
`"StrategyServer: OnEvent() called, but no callback function specified."`.
|
||||
|
||||
It also **calls** `FUN_0081b390` (the previous-turn snapshot) at 0x007dbc7c — to *establish* the baseline, not
|
||||
to diff against it. Nothing in it compares a shadow word to a current word. `turn-spine.md` reads as if this
|
||||
were a per-turn diff step; it is not.
|
||||
|
||||
`SETurnEvents` (id **0x28**) is a **verbatim copy of one player's own `EventStorage` bucket for the current
|
||||
turn**, built at 0x007dc334–0x007dc3d0: `EventStorage::FindTurnBucket(GetEventStorage(p), S->+0xc)`, then
|
||||
`{ SE vptr, TurnEvents vptr, bucket->EvTurn, copy of bucket->Events }`, sent with
|
||||
`OnEventCallback(netId, 0x28, &ev)`. Not a diff, not a broadcast.
|
||||
|
||||
**`sizeof(EventStorage::TurnEvents) == 0x18`, four ways**: the serializer touches `+0x04` (`int EvTurn`) and
|
||||
`+0x08` (a 16-byte vector incl. `_Alval`); `SETurnResults`'s default ctor places the subobject at `+0x08` and
|
||||
the next member at `+0x20`; the copy ctor mirrors that; and `EventStorage::FindTurnBucket` 0x00811f70 strides
|
||||
by 0x18 (magic `0x2aaaaaab / sar 2`). Wire schema, in order: `"EvTurn"` by `WriteInt` (four bytes on the wire,
|
||||
default `-1`), then `"Events"` as a framed counted array of `Game::EventStorage::Event` bound through
|
||||
`Mars::VectorHelper<Game::EventStorage::Event>` (vtable 0x00a2da7c). This **agrees exactly** with
|
||||
`objects/layouts.json`, which already grades it `verified` at `sizeof 24`.
|
||||
|
||||
**Where the per-turn events actually reach the client** is worth stating: `SETurnResults` *embeds* an
|
||||
`EventStorage::TurnEvents` at `+0x08` (vptr `+0x08`, `EvTurn` `+0x0c`, `vector<Event>` `+0x10/+0x14/+0x18`),
|
||||
verified from both its constructors. So the bucket rides inside the id-0x25 record. **Whether that embedded
|
||||
bucket is filled on a normal turn was not established — labelled hypothesis.**
|
||||
|
||||
### 5B.1 `TurnEvents_Write` and `TurnEvents_Read` are swapped in Ghidra
|
||||
|
||||
0x00825bb0 (labelled `TurnEvents_Write`) calls `0x008b9d20`, which invokes stream vtable slot `+0x10`, passes
|
||||
a stack scratch as a destination and stores on success — that is `ReadInt`. 0x00825c40 (labelled
|
||||
`TurnEvents_Read`) calls `0x008b9d50`, which invokes slot `+0x24` and pushes the member's *value* — that is
|
||||
`WriteInt`, the same shape as the golden `Game::ObservedTech::Write` 0x00817cf0. **So 0x00825bb0 is the READ
|
||||
and 0x00825c40 is the WRITE.** The same swap affects `EventStorage_Read` 0x00825cc0, which is the Write.
|
||||
|
||||
`objects/layouts.json` and `objects/streams.json` have this **right** (`write: 0x825c40`, `read: 0x825bb0`).
|
||||
What is wrong is the Ghidra symbol names and the sentence in `findings/subsystems/events.md` §96, which
|
||||
repeats the swap. Corrected in Ghidra by this lane; the note is flagged for its owner rather than rewritten,
|
||||
since it is lane E's and the surrounding argument depends on it.
|
||||
|
||||
*Source: 5A is instruction-verified end to end (I re-read `GenerateTurnEvents`, the `ApplyEncounterResults`
|
||||
tail and the `SynchronizePlayer` dispatch from the bytes myself). 5B's `BuildTurnEvents` phase list and the
|
||||
`SETurnResults` field table are a delegated ReVa sweep with the call targets and immediates
|
||||
instruction-verified.*
|
||||
|
||||
---
|
||||
|
||||
## 6. The autosave — mechanism, and why it reproduces
|
||||
|
||||
**It is not in this function.** It is the second-to-last step of the `SNMAllCombatDone` handler, after the
|
||||
tail returns. The handler, read from the instruction stream (0x00784cf8..0x00784efe):
|
||||
|
||||
```
|
||||
1. StrategyServer::OnAllCombatDone_Tail(S, msg+4) 0x00784d07
|
||||
2. for each e in host->+0x114 (4-byte stride): FUN_00789360(S, e); then FUN_008320e0(host->+0x114)
|
||||
3. for i over host->+0x118 (0xc stride): e = FUN_005c3d10(vec, i);
|
||||
FUN_0083cb80(S+4, e->+4, e->+8)
|
||||
then FUN_00793810(&local, vec._Myfirst, vec._Mylast)
|
||||
4. FUN_00789330(S); FUN_007c0600(S); ApplyEncounterResults(S) 0x007d4400
|
||||
5. FUN_00761310(host); SyncLocalClients(0)
|
||||
6. GenerateTurnEvents(S) 0x007dc640 <-- TURN EVENTS
|
||||
7. if (HasNetworkSession()) StrategyHost::Autosave(&name, /*endTurn=*/0) <-- THE AUTOSAVE, 0x00784e59
|
||||
8. host->+0x58 = 6
|
||||
```
|
||||
|
||||
### 6.1 `StrategyHost::Autosave` 0x00895210
|
||||
|
||||
`void __thiscall (StrategyHost* this /* the global at 0x00b29f98 */, std::string* outName, bool endTurn)`.
|
||||
2364 B. Two call sites in the whole image:
|
||||
|
||||
| caller | `endTurn` | file |
|
||||
|---|---|---|
|
||||
| `SendEndTurn` 0x007839d7 | **1** | `(Autosave EndTurn).sav` — the **pre-turn** state |
|
||||
| `StrategyHost::OnMessage` 0x00784e59 | **0** | `(Autosave).sav` — the **post-turn** state |
|
||||
|
||||
Which is exactly the pair `findings/subsystems/determinism-oracle.md` observed on disk.
|
||||
|
||||
Body, read from the instruction stream:
|
||||
|
||||
1. `if (this->+0x4 == 0)` → `Log(2, "Can't autosave- Strat game doesn't exist.")`, clear `outName`, return.
|
||||
2. `Log("Autosaving strat game...")`; `dir = FUN_007a05a0(game)` (`"SavedGames"`), `ext = FUN_007a0620(game)`
|
||||
(`"sav"`).
|
||||
3. **Four** 0x400-byte path buffers, each `_snprintf(buf, 0x3ff, "%s/%s.%s", dir, name, ext)`. The four names
|
||||
are the localized strings registered at 0x009bed00..0x009bed7f:
|
||||
|
||||
| global | key | buffer |
|
||||
|---|---|---|
|
||||
| 0x00af092c | `SOTS_GAME_AUTOSAVE` | `[ebp-0x1410]` |
|
||||
| 0x00af0934 | `SOTS_GAME_AUTOSAVEBACKUP` | `[ebp-0x1010]` |
|
||||
| 0x00af093c | `SOTS_GAME_ENDTURN_AUTOSAVE` | `[ebp-0xc10]` |
|
||||
| 0x00af0944 | `SOTS_GAME_ENDTURN_AUTOSAVEBACKUP` | `[ebp-0x810]` |
|
||||
|
||||
4. `if (!IsSinglePlayerHost())` → `remove()` both ENDTURN files. (The pre-turn autosave is a single-player
|
||||
feature.)
|
||||
5. `cur, bak = endTurn ? (ENDTURN, ENDTURN_BACKUP) : (AUTOSAVE, AUTOSAVE_BACKUP)`; `mkdir(dir)`.
|
||||
6. **The rotation**, and it fires **only when `endTurn == 0`**: `remove(bak); rename(cur, bak)`. Read the
|
||||
flag carefully — 0x0089525f stores 1 into `[ebp-0x14a1]` and the `je` at 0x00895266 **jumps over** the
|
||||
store of 0, so the byte is `(endTurn == 0)`, not `(endTurn != 0)`. This is the `(Autosave Backup).sav`
|
||||
the determinism note recorded as "a rename, hash unchanged".
|
||||
7. **Gate:** `if (!(this->flags & 4) || this->+0x4 == 0)` nothing is written at all.
|
||||
8. **Transient state is detached before writing.** For each player in `this->+0xc/+0x10`, if its connection
|
||||
`pl->+0x12c` is non-null, `conn->vft[0x14](&slot)` moves it into a scratch vector; after the write
|
||||
`conn->vft[0x18](slot)` puts it back and the slot is nulled (0x008959ac). Per-player sidecar names are
|
||||
built with `FUN_0059a9e0` / `FUN_0059aa30` (`"Player"`/`"AIAgent"` + `"%s.%08i.%s"`) into a
|
||||
0x20-stride vector.
|
||||
9. **`FUN_00877070(this->+0x4, curPath, /*write=*/1, &names)`** at 0x0089595d — the write itself. Returns a
|
||||
bool.
|
||||
|
||||
### 6.2 What is actually captured, and what makes it reproducible
|
||||
|
||||
`FUN_00877070` is the file root `verify/save-reader/save_reader.py` already models (its comment at line 694
|
||||
names this address). Read from the instruction stream: it opens the stream with
|
||||
`FUN_00816510(path, &stream, write)` — with `write = 1` that is `operator new(0x118)` + ctor 0x008d10c0 +
|
||||
`FUN_008d1090(path, "wb")`, the gzip writer — and then writes **four named top-level sections** through
|
||||
`stream->vft[0x28](tag, &ref)`:
|
||||
|
||||
```
|
||||
"Summary" "CreateParams" "Sim" "CDT"
|
||||
```
|
||||
|
||||
followed by one `"CD"` record per entry of the `names` vector whose `+0x1c` is non-null. That is exactly the
|
||||
root `save_reader.py` parses.
|
||||
|
||||
Four properties, together, are the whole mechanism the byte-identical End Turn oracle rests on:
|
||||
|
||||
1. **Fixed position in the message sequence.** The autosave is step 7 of one message handler. Everything the
|
||||
turn computes — the tail's 36 phases, `ApplyEncounterResults`, `GenerateTurnEvents` — has already run and
|
||||
written to memory. Nothing is in flight.
|
||||
2. **Nothing identifying is passed to the writer.** The file *name* is built in step 3 of `Autosave` and
|
||||
never reaches `FUN_00877070`'s payload; the writer takes `(game, path, write, names)` and serialises game
|
||||
state alone. No timestamp, no machine id, no session salt, no save-name field — which is why the note's
|
||||
four differently-named files are one byte sequence.
|
||||
3. **Transient connection state is detached** (step 8) before the write and reattached after, so nothing
|
||||
session-scoped can leak into `Sim`.
|
||||
4. **The container is deterministic**, as the determinism note independently measured (MTIME 0, XFL 0,
|
||||
OS 11) — `FUN_008d1090(path, "wb")` never passes a filename or time to the gzip header.
|
||||
|
||||
The one known exception in the note — `Player.Status` resetting 4 → 0 on load, moving `Summary.Checksum` —
|
||||
is consistent with this: `Status` is `ServerPlayer+0x164`, which lane T showed `ProcessTurn` phase 31 writes
|
||||
to 1 and which the post-turn state leaves at 4; nothing in the write path normalises it.
|
||||
|
||||
**Labelled hypothesis.** I did not prove that `SNMAllCombatDone` is delivered on turns with no combat. The
|
||||
evidence that it is: `(Autosave).sav` is written on *every* End Turn (the determinism note, five runs), and
|
||||
this handler is the **only** reachable caller of `StrategyHost::Autosave(…, 0)` in the image. That is strong,
|
||||
but it is an inference from the oracle, not from the instruction stream. **A path you cannot exercise is a
|
||||
hypothesis** — this one is exercisable by lane O and worth one check.
|
||||
|
||||
---
|
||||
|
||||
## 7. Corrections
|
||||
|
||||
### 7.1 `S+0x8` advances **twice** per turn, not once
|
||||
|
||||
`turn-driver.md` §0.1 lists `S+0x8` as incremented by "`StrategyServer::ProcessTurn`, first instruction" and
|
||||
concludes that it and `S+0xc` "both advance once per turn … so they stay in lockstep".
|
||||
|
||||
`OnAllCombatDone_Tail`'s **first act** is `inc DWORD PTR [ebx+0x8]` at 0x007d92ca, on the same base and the
|
||||
same word. Given §6's inference that the message is delivered every turn, `S+0x8` advances **at least twice**
|
||||
per turn while `S+0xc` (`ModCount`) advances once. They are not in lockstep, and a reimplementation must not
|
||||
treat `S+0x8` as a turn number. It reads as a **server-phase / driver-invocation counter**.
|
||||
|
||||
### 7.2 Turn results and turn events are **not** in this function
|
||||
|
||||
`turn-driver.md` §5 says: *"no bankruptcy, no turn-results build, no turn-events build, no autosave. Those
|
||||
are all in `StrategyServer::OnAllCombatDone_Tail`."* One of the four is right.
|
||||
|
||||
| subsystem | where it actually is |
|
||||
|---|---|
|
||||
| **bankruptcy** | **here** — phase 15 (`ProcessBankruptcy`) and phase 31 (`UpdateBankruptcyLimits`) |
|
||||
| **turn results** | **filled here** (phases 6, 11, 18 write `S->+0x2f4[PlyrIdx]`), but rotated by `ApplyEncounterResults` and dispatched by `SynchronizePlayer` — both after this returns. See §5A |
|
||||
| **turn events** | not here, and mostly not per-turn either: `GenerateTurnEvents` 0x007dc640 is a 122-byte flush, and the `BuildTurnEvents` it may call is the load/rejoin resync. See §5B |
|
||||
| **autosave** | `StrategyHost::Autosave` 0x00895210, called from the **handler**, after this returns. See §6 |
|
||||
|
||||
A whole-image direct-call reachability search from 0x007d92a0 to each of `BuildTurnEvents` 0x007db780,
|
||||
`GenerateTurnEvents` 0x007dc640, `SETurnResults::Create` 0x007a7ae0, the copy ctor 0x007c24d0,
|
||||
`vector<SETurnResults>::resize` 0x007cd2a0, `SynchronizePlayer` 0x007c6220 and the `TurnEvents` serializers
|
||||
finds **no path** — computed twice, once at depth 6 and once as the full transitive closure (1420 functions
|
||||
reachable from 0x007d92a0). `EventStorage::PostEvent` 0x008862b0 and `EventStorage::FindTurnBucket`
|
||||
0x00811f70 *are* reachable, so the tail posts individual player events but never packages or ships one.
|
||||
Caveat: this is the **direct-call** closure; the tail does make indirect calls, so "no static path" is not a
|
||||
proof of "no dynamic path". What settles it is that every construction site of both classes is a direct call
|
||||
from a function outside that closure.
|
||||
|
||||
### 7.2a `DispatchTurnResults` and `SendTurnResultsToPlayers` are misnomers
|
||||
|
||||
Two names already in the Ghidra DB describe sends that do not exist. 0x007cd2a0 is
|
||||
`std::vector<SETurnResults>::resize` (shrink → `_Erase` 0x007c5610, grow → `_Reserve` 0x007cb340 + `_Ufill`),
|
||||
and 0x007c5850 is `_Ufill(dest, count)` — per element `Create()` a stack temp, copy-construct into `dest`
|
||||
via 0x007c24d0, destroy the temp via 0x0079ac10. Likewise `Game::SETurnResults::Create` 0x007a7ae0 is the
|
||||
**default constructor** and `FUN_007c24d0` is the **copy constructor**, not a factory and a raiser. Nothing
|
||||
in that group sends anything; the only send is `SynchronizePlayer` 0x007c86d1 (`push 0x25`).
|
||||
|
||||
Strategy-event ids recovered along the way: **0x01** `SEAddPlayer`, **0x19** `SESyncDesign`, **0x25**
|
||||
`SETurnResults`, **0x28** `SETurnEvents`, **0x29** `SEResetMap`, **0x2a** `SEInitTrade`.
|
||||
|
||||
### 7.3 `player->+0x8 + 0x60` is `S+0x64`, not a skew
|
||||
|
||||
A callee sweep flagged that `FUN_00818a50` reaches the fleet vector as `player[8] + 0x60/+0x64` while the
|
||||
drivers use `S+0x64/+0x68`. That is not an anomaly: lane T established `ServerPlayer+0x8 == S+4`, so
|
||||
`player[8] + 0x60 == S + 0x64`. Same field, `S+4` frame. Recorded here so the next lane does not re-open it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Object notes recovered here
|
||||
|
||||
```c
|
||||
// Game::SNMAllCombatDone -- 0x10 by enumeration (see 0.1)
|
||||
struct SNMAllCombatDone { void* vptr; std::vector<EncounterResult> results; };
|
||||
|
||||
// Encounter -- 0x74 stride (S+0x1e8), confirmed by two independent divisions
|
||||
// +0x0c StarSystem* (phase 3 arg, phase 6 -> RefuelInOrbit)
|
||||
// +0x28 vector<Member> _Myfirst (0x44 stride)
|
||||
// +0x2c vector<Member> _Mylast
|
||||
// +0x38 int/handle (phase 3 -> FUN_004f4c40)
|
||||
// Encounter::Member -- 0x44 stride
|
||||
// +0x00 ServerPlayer* (its +0x4 is the lookup id, its +0x28 is PlyrIdx)
|
||||
// +0x04 int role/type (== 2 is excluded from the phase-4 tally)
|
||||
|
||||
// EncounterResult -- 0x178 stride, index-parallel to Encounter
|
||||
// +0x04 BYTE flag (phase 3 requires != 0; phase 4 requires == 0)
|
||||
// +0x05 BYTE flag (phase 3 requires != 0)
|
||||
// ... the other 0x172 bytes are consumed by FUN_007d8920 and are NOT mapped here
|
||||
|
||||
// ServerPlayer turn record (ServerPlayer+0x3d8) -- see 5 for the fields this lane adds
|
||||
// +0x1a WORD, incremented per non-type-2 combatant per zero-flag encounter result (phase 4)
|
||||
```
|
||||
|
||||
New `ServerPlayer` offsets surfaced by the callee sweep and **not** in `struct-recovery.md`:
|
||||
`+0x198` (per-turn communication bitmask, indexed by **player-vector position**, not `PlyrIdx`; rebuilt every
|
||||
turn by phase 30, so almost certainly unsaved) and `+0x348[]` (per-species known/translatable table, 4-byte
|
||||
stride, bit 0). Both are decompiler-derived except `+0x198`, whose writes were read as instructions.
|
||||
|
||||
---
|
||||
|
||||
## 9. Gap list
|
||||
|
||||
Ranked the way lane T ranked its own. "RNG?" / "events?" come from a callee sweep; a `?` means unswept.
|
||||
|
||||
### Tier 1 — small, self-contained, and each closes something the campaign already half-models
|
||||
|
||||
| target | size | why |
|
||||
|---|---|---|
|
||||
| **`FUN_007ae010` node-line decay** (phase 11) | 1117 B | one `NextFloat` per expired line, count state-dependent, and it is instruction-verified. Every RNG account in the repo assumes the strategic generator only moves inside `ProcessTurn`. This is the cheapest of the two tail draws to close, and it sits *before* the autosave |
|
||||
| **`FUN_0078a0e0` turn records** (phase 36) | 416 B | fully read here (§5). Pure, draw-free, event-free, and it is the input to the history archive the UI graphs. Also runs on load, so it is testable without a turn |
|
||||
| **`UpdateBankruptcyLimits`** (phase 31) | 173 B | fully read here (§4). Three corrections to `formula-gaps.md` already banked; the only unknown left is the DB value of `BANKRUPTCY_PROTECTION_LIMIT_FACTOR` |
|
||||
| **`FUN_0078aba0` communication masks** (phase 30) | 295 B | fully read by the sweep; 30 lines of bit arithmetic, no RNG, no events, and it exposes the undocumented `ServerPlayer+0x348` species table |
|
||||
| **`FUN_0078ace0` maintenance / research bonus** (phase 24) | 431 B | writes `ServerPlayer+0x15c` (`Maint`) and `+0x160` (`shrm`) — two fields `ComputeBudget` consumes, so it closes a loop B1 currently copies out of the original. `ShipMaintenance` 0x00814c80 and `ShipResearchBonus` 0x0081f930 are both fully read |
|
||||
|
||||
### Tier 2 — medium, and each is a named game rule
|
||||
|
||||
| target | size | why |
|
||||
|---|---|---|
|
||||
| **`FUN_007d5af0` the combat resolver** (under phase 6) | **7499 B** | the largest unread thing reachable from this function, the second RNG source in the tail, and the source of `EVENT_VICTORY` / `EVENT_DEFEAT` / `EVENT_ENGAGED` / `EVENT_COMBAT_OBSERVED` / `EVENT_STATION_KILLED` / `EVENT_NODECANNON_*` / `EVENT_SPRJBACKENG_UNLOCKED`. It is its own milestone, but nothing about combat determinism can be settled without it |
|
||||
| `ProcessBankruptcy` 0x007c0a50 (phase 15) | 2291 B (+1682 +2133) | five events, a two-level threshold rule, player elimination. The formula half is done; the *decision* half is not |
|
||||
| `ApplyEncounterResult` 0x007d8920 (phase 6) | 2402 B | the dispatch and the publication tail are now mapped (§2A.1); the middle — the report construction around `FUN_007d5af0` — is not |
|
||||
| `AnnounceEncounterSighting` 0x007a9db0 (phase 3) | 978 B | the `"EVENT_" + typename` composition and the `S+0x2c8` sighting log are mapped; the visibility gate `FUN_00788cd0` is not. **Ghidra's decompile of this function is unusable** — it deletes the entire event body as unreachable — so it must be read as instructions |
|
||||
| `UpdateDiplomacyStatsFromCombat` 0x00789d00 (phase 5) | 979 B | fully described by the sweep; `DiplomacyStats` is already `verified` 13/13 in `objects/layouts.md`, so this is transcription plus one careful pass on the treaty-betrayal window (`< 3` turns) |
|
||||
| `FUN_007c0a50`'s `FUN_00889500` cost-cutting | 1682 B | what a bankrupt empire actually does |
|
||||
| `FUN_007a4070` incoming-fleet warnings (phase 32) | 1640 B | the `EVENT_ENEMY_INCOMING_<Species>` suffix rule is settled (species tag from `FUN_0053b030`, falling back to `EVENT_ALIEN_INCOMING` on a resource-table miss); the detection-mask half is not |
|
||||
| `FUN_007cf560` PlayerView rebuild (phase 17) | 948 B | draw-free but it destroys and rebuilds the whole `S+0x228` tree and emits stream writes — a serialization surface, not bookkeeping |
|
||||
| `FUN_007bd260` → `FUN_007b9190` (phase 16) | 205 + 301 B | tiny driver, real rule: it **founds colonies** on unowned systems. Best value-per-byte in the tail |
|
||||
| `FUN_00798290` + `FUN_00798630` morale (phases 13, 14) | 895 + 933 B | table-driven, no RNG, no events; together they hand over the morale-event id→delta and id→string tables (0x00743420 / 0x00743530) that several other subsystems reuse |
|
||||
|
||||
### Tier 3 — larger, or blind
|
||||
|
||||
`FUN_00789d00` (979 B, phase 5, unswept), `FUN_007be870` colony-loss drain (1706 B),
|
||||
`FUN_007a4ff0` fleet warnings (1865 B), `FUN_007a3750` infra/terra drain (1257 B),
|
||||
`FUN_007a3c60` survey + derived stats (1025 B), `FUN_007a23e0` intercept aborts (784 B),
|
||||
`FUN_007ade00` node-line sightings (519 B), `FUN_007c2350` → `FUN_007be340` observed designs (222 + ? B),
|
||||
`FUN_007991a0` → `FUN_00798c90` player reports (208 + 1285 B).
|
||||
|
||||
### Tier 4 — the vtable blocks, which are pure blind spots
|
||||
|
||||
**Phase 23 is nine virtual calls in a row and not one of them is identified.** Eight on the
|
||||
`ServerTradeManager` at `S+0x158` (`vft[0x38]`, `vft[0x20](1)`, `vft[0x30]`, `vft[0x2c]`, `vft[0x24]`,
|
||||
`vft[0x1c]`, `vft[0x34]`, `vft[0x3c]`) and one on `S+0x15c` (`vft[0x34]`); phase 33 adds two more on
|
||||
`S+0x15c`. `ServerTradeManager::ProcessTurn` is already `ProcessTurn` phase 2 and
|
||||
`strategic-turn-internals.md` §1.4 has its formulas, so the class is half-known — but this eight-call
|
||||
sequence is the *end-of-turn* half of trade and nothing in the repo touches it. `analyze-vtable` on the
|
||||
concrete `*Impl` behind `S+0x158` would close all nine cheaply.
|
||||
|
||||
The three `SVScriptObject` hook pairs (ids **8**, **0x14**, **0x15**, **0x1c**) are scripted-scenario
|
||||
callbacks, presumed dead in a normal game and **not proven so** — same status lane T gave the two in
|
||||
`ProcessTurn`.
|
||||
|
||||
### What is conspicuously absent from *this* function
|
||||
|
||||
The combat-done tail does **no** movement, **no** production, **no** research, and touches savings only
|
||||
through `ProcessBankruptcy`'s consequences. It is the *reconciliation* half of the turn: apply results, tell
|
||||
everyone what they now know, recompute derived state, snapshot. `ProcessTurn` decides; this decides nothing
|
||||
except bankruptcy and node-line decay.
|
||||
|
||||
---
|
||||
|
||||
## 10. What this lane did **not** read
|
||||
|
||||
The whole of `OnAllCombatDone_Tail` was read as instructions, and so were `StrategyHost::Autosave`,
|
||||
`SaveGame_WriteFile`, `OpenSaveStream`, `IsSinglePlayerHost`, `GenerateTurnEvents`, the
|
||||
`ApplyEncounterResults` rotation, the `SynchronizePlayer` dispatch and the `SNMAllCombatDone` case of
|
||||
`OnMessage`. **The boundary is the callee bodies.**
|
||||
|
||||
* **`FUN_007d5af0` (7499 B), the combat resolver under phase 6 — completely unread.** Everything §2A.1 says
|
||||
about `ApplyEncounterResult` describes the dispatch *around* it. This is the biggest hole left.
|
||||
* Twenty-eight callee bodies were characterised by delegated ReVa sweeps, not by me. Their **call shapes and
|
||||
argument orders** are instruction-verified from this function; their **contents** are decompiler-derived
|
||||
except where §2A / §3 / §4 / §5 say otherwise. `AnnounceEncounterSighting` 0x007a9db0 in particular is a
|
||||
function whose decompile is known-broken.
|
||||
* The 0x178-byte `EncounterResult` beyond `+0x4`, `+0x5`, `+0x6`, `+0x7`, `+0x10c` and `+0x120`.
|
||||
* The nine virtual slots of phase 23 and the two of phase 33 — the largest blind spot in the map.
|
||||
* Whether `SNMAllCombatDone` is delivered on turns with no combat (§6, labelled hypothesis), and whether the
|
||||
`TurnEvents` embedded in an `SETurnResults` is filled on a normal turn (§5B, labelled hypothesis).
|
||||
|
|
@ -186,7 +186,8 @@ base register is a `this` candidate, `tools/x86disp.py query 0x3b4`):
|
|||
00891613 fcomp [ebp-0x10] ; 0.5f vs ratio
|
||||
0089161b jp 0x89162a ; NOT (0.5f < ratio) -> skip
|
||||
0089161d mov ecx,esi
|
||||
0089161f call 0x88df20 ; RollResearchEvent (exactly one NextFloat)
|
||||
0089161f call 0x88df20 ; RollResearchEvent (one NextFloat, PLUS a second word
|
||||
; if the roll fires the plague path -- see §3.1)
|
||||
00891624 mov BYTE [esi+0x3b4],bl ; ResErrRoll = 0 <-- INSIDE the branch
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,29 @@ level changes (any change, 1↔2 included, resets it), and actions start the fol
|
|||
Call sequence: `OnAllCombatDone_Tail` → … → `ProcessBankruptcy` → (later in the tail) `UpdateBankruptcyLimits`, so the
|
||||
limits used by a turn's bankruptcy check are the ones computed at the end of the previous turn (and on load).
|
||||
|
||||
### Q1 addendum — lane K, 2026-09-08 (instruction-verified)
|
||||
|
||||
The call sequence above is now read off the instruction stream, with addresses:
|
||||
`ProcessBankruptcy` 0x007c0a50 is **phase 15** of `StrategyServer::OnAllCombatDone_Tail`, at 0x007d9731;
|
||||
per-player `UpdateBankruptcyLimits` 0x00818600 is **phase 31**, at 0x007d9876, 0x145 bytes later.
|
||||
Those two — and `LoadGame` 0x007ddc16 — are the only callers of `UpdateBankruptcyLimits` in the image.
|
||||
See `findings/control-flow/combat-done-tail.md` §4.
|
||||
|
||||
Three corrections to the formula block above:
|
||||
|
||||
1. **The divisor is not `-0.15`.** The `.rdata` double at 0x00a2ec30 is `-0.15000000596046448`, i.e.
|
||||
`(double)(float)-0.15f`. Writing `-0.15` in a reimplementation is one ulp out on large empires.
|
||||
2. **The per-system term is clamped at zero before summing.** `FUN_007521c0`
|
||||
(= `ServerSystem::ComputeMaxIncome`) ends `return max(ComputeOutputRates(sys)[3], 0)` — a `jg`, so a
|
||||
negative-income colony contributes 0 rather than reducing `maxIncome`.
|
||||
3. **3.3 is not a binary constant.** `0x00aedfdc` points to `0x00b23e28`, which lies past `.data`'s raw end
|
||||
(0x00b07600) — it is `.bss`, zero-initialised and filled from the game data files at load. The only
|
||||
hard-coded constants here are the `-0.15000000596046448` double and the int32 `-2000000000` at
|
||||
0x00a2c638. Treat 3.3 as DB-sourced, not as a fact about the binary.
|
||||
|
||||
Also: there are exactly two clamps and both are one-sided lower bounds, so **a player with zero owned
|
||||
systems has `BnkEl == BnkPr == 0`** and `BankruptcyLevel` reads 2 for any negative savings.
|
||||
|
||||
## Q2 (order as asked: 3). Suitability → carrying-capacity hazard curve
|
||||
|
||||
`HazardMod` 0x00747ae0: `clamp01(1 − |Suit − IdealSuit| / (SuitTol + 0.1))` (0x00a1a438 = 0.1). Linear, no exponent.
|
||||
|
|
|
|||
|
|
@ -93,12 +93,22 @@ hook's regions can see:
|
|||
```c
|
||||
RecordObservedTech(...); // FIRST statement, UNCONDITIONAL
|
||||
if (this->ResT /*+0x294*/ == def) {
|
||||
if (this->ResearchRollPending /*+0x3b4*/) RollResearchEvent(this); // exactly one NextFloat
|
||||
if (this->ResearchRollPending /*+0x3b4*/) RollResearchEvent(this); // ONE or TWO RNG words
|
||||
this->ResearchRollPending = 0;
|
||||
this->ResT = 0;
|
||||
}
|
||||
```
|
||||
|
||||
> **Correction (lane K, 2026-09-08).** This line used to read "exactly one NextFloat". That is the
|
||||
> cost of *reaching* the branch, not the cost of a fired roll. `RollResearchEvent` (0x0088df20)
|
||||
> draws one `NextFloat`; when the roll beats the odds it enters
|
||||
> `ServerPlayer_OnResearchRollSucceeded` (0x00889d60), whose **plague path draws a second word
|
||||
> (`NextInt`)** to pick an owned system and posts `EVENT_PLAGUE_OUTBREAK`, while the **rebellion
|
||||
> path** allocates an `AIRebellion` at `ServerPlayer+0x3b8` and **cancels the current research**
|
||||
> (no further draw). Any RNG accounting that assumes one word is wrong the first time that branch
|
||||
> fires; it has never fired in three sessions, which is why nothing caught it.
|
||||
> See `findings/control-flow/turn-driver.md` §3.1.
|
||||
|
||||
* `RecordObservedTech` (0x007ba1a0, lane X) de-duplicates by tech name, so "the vector did not
|
||||
grow" is a real outcome. Its unconditionality was not previously written down.
|
||||
* `RollResearchEvent` (0x0088df20) is
|
||||
|
|
|
|||
231
ghidra/addresses.d/lane-k.json
Normal file
231
ghidra/addresses.d/lane-k.json
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
{
|
||||
"entries": [
|
||||
{
|
||||
"name": "StrategyServer_OnAllCombatDone_Tail",
|
||||
"addr": "0x007d92a0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this /*base S*/, std::vector<EncounterResults>* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md (lane K 2026-09-08, whole function disassembled 0x007d92a0-0x007d98d0)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_OnAllCombatDone_Tail_ClearEncounters",
|
||||
"addr": "0x007d9690",
|
||||
"convention": "site",
|
||||
"prototype": "site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll",
|
||||
"addr": "0x007ae095",
|
||||
"convention": "site",
|
||||
"prototype": "site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §3 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_off_RNGPtr_S_frame",
|
||||
"addr": "0x0000016c",
|
||||
"convention": "offset",
|
||||
"prototype": "StrategyServer+0x16c in the S frame holds the Mars::RNG object pointer. Two independent instruction reads agree: ServerPlayer::ProcessTurn 0x0089147f (`eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and ServerPlayer+0x8 == S+4, so eax == S) and FUN_007ae010 0x007ae095 (`mov ecx,[esi+0x16c]` with esi == S). This equals (S+4)+0x168, i.e. exactly what StrategyServer_off_RNGPtr = 0x168 says once the S+4 frame is applied. off_RNG is NOT an exception to the S+4 rule -- turn-driver.md §0 should be read as saying that NextFloat enters the generator OBJECT at +4, which is a different thing",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §3.1 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyHost_Autosave",
|
||||
"addr": "0x00895210",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log \"Can't autosave- Strat game doesn't exist.\"; build FOUR paths as _snprintf(buf,0x3ff,\"%s/%s.%s\", dir, name, ext) with dir=FUN_007a05a0(game) (\"SavedGames\") and ext=FUN_007a0620(game) (\"sav\") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6 (lane K 2026-09-08, read from the instruction stream)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyHost_IsSinglePlayerHost",
|
||||
"addr": "0x00815fb0",
|
||||
"convention": "cdecl",
|
||||
"prototype": "bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6.1 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyHost_HasNetworkSession",
|
||||
"addr": "0x00898af0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "bool (void* this) // `return this->+0x148 != 0;`. Called on the global at 0x00b2d540 from StrategyHost::OnMessage 0x00784e3f -- this is the gate on the POST-turn autosave",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SaveGame_WriteFile",
|
||||
"addr": "0x00877070",
|
||||
"convention": "cdecl",
|
||||
"prototype": "bool (void* game, const char* path, bool write, std::vector<AgentName>* agentNames) // the save-file ROOT that verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Opens the stream with OpenSaveStream(path,&stream,write); with write=1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path,\"wb\"), the gzip writer. Then writes four named top-level sections through stream->vft[0x28](tag,&ref): \"Summary\", \"CreateParams\", \"Sim\", \"CDT\"; then one \"CD\" record (tag at 0x00a2b9d4) per entry of the 0x20-stride agentNames vector whose +0x1c is non-null. NOTHING TIME-, NAME- OR MACHINE-DEPENDENT ENTERS THE PAYLOAD: the file NAME is built by StrategyHost::Autosave and never reaches here",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6.2 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "OpenSaveStream",
|
||||
"addr": "0x00816510",
|
||||
"convention": "cdecl",
|
||||
"prototype": "bool (const char* path, Stream** out, bool write) // write -> operator new(0x118), ctor 0x008d10c0, open FUN_008d1090(path, \"wb\" @0x009e150c); read -> OpenFile(path, @0x00a2ec2c). Returns *out != 0. The \"wb\" is the gzip container the determinism note measured as header-deterministic (MTIME 0, XFL 0, OS 11)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §6.2 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_GenerateTurnEvents",
|
||||
"addr": "0x007dc640",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this) // 122 BYTES, AND IT GENERATES NOTHING. (1) FUN_007c5610(&scratch, S->+0x304, S->+0x308) with ecx = &S->+0x304 -- erase-to-empty of the vector<SETurnResults> OUTBOX at S+0x304. (2) if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 -- clear of a 0xc-stride vector; THE COPY LOOP AT 0x007dc680 IS DEAD CODE, `cmp edx,edx; je` at 0x007dc676 is unconditionally taken. (3) FUN_00792a20(S) prunes two intrusive lists at S+0x2d8 and S+0x2e4. (4) if (S->+0x128 & 4) BuildTurnEvents(S) -- normally FALSE. One caller: StrategyHost::OnMessage 0x00784e34",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5B (lane K 2026-09-08, read from the instruction stream)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_BuildTurnEvents_isResync",
|
||||
"addr": "0x007db780",
|
||||
"convention": "note",
|
||||
"prototype": "MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are \"vector<T> too long\" and \"StrategyServer: OnEvent() called, but no callback function specified.\" It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5B (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_off_TurnResultsPending",
|
||||
"addr": "0x000002f4",
|
||||
"convention": "offset",
|
||||
"prototype": "StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_off_TurnResultsOutbox",
|
||||
"addr": "0x00000304",
|
||||
"convention": "offset",
|
||||
"prototype": "StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SETurnResults_ctor",
|
||||
"addr": "0x007a7ae0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector<SETurnResults>::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector<ClientEncounterResults> (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "vector_SETurnResults_resize",
|
||||
"addr": "0x007cd2a0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 (\"SendTurnResultsToPlayers\") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §7.2a (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "TurnEvents_serializer_direction",
|
||||
"addr": "0x00825c40",
|
||||
"convention": "note",
|
||||
"prototype": "THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: \"EvTurn\" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then \"Events\" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5B.1 (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "GetGame",
|
||||
"addr": "0x00578050",
|
||||
"convention": "cdecl",
|
||||
"prototype": "void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "HandleMap_Resolve",
|
||||
"addr": "0x008b9240",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_MarkPlayerEncountered",
|
||||
"addr": "0x0080df10",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (ServerPlayer* this, ServerPlayer* other) // `if (other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28);`. Called twice symmetrically per ordered combatant pair in OnAllCombatDone_Tail phase 2",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_MarkSpeciesDiscovered",
|
||||
"addr": "0x0080dee0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (ServerPlayer* this, uint species) // `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species;`. Species index 4 is permanently excluded. Called in OnAllCombatDone_Tail phase 2 as MarkSpeciesDiscovered(other->Species(+0x5c))",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "PickDominantEncounterType",
|
||||
"addr": "0x004f4c40",
|
||||
"convention": "cdecl",
|
||||
"prototype": "int (uint typeMask) // A RANKER OVER GROUPS, NOT A FILTER OF ONE. Clears bit 0 (Standard); if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} the mask is RESTRICTED to that group; otherwise the ambient groups {Swarm 3, Derelict 4, Monitor 5, SlaversRefuel 9, CrowRuins 17}, {CrowsNest 12, GravTrap 13} and {GasCloud 11, Meteor 2, Pirate 6, TradeRaiders 18, 20, 23} are each dropped IF ANYTHING ELSE REMAINS. Returns the index of the lowest surviving set bit in [0,0x18), else 0. The four group masks are lazily built once into 0x00b0e96c..0x00b0e988. FUN_004f4970 is the id->name switch (Standard/VonNeumann/Meteor/Swarm/Derelict/Monitor/Pirate/SystemKiller/PuppetMaster/SlaversRefuel/SwarmQueen/GasCloud/CrowsNest/GravTrap/Locust/Berserker/CrowDefenders/CrowRuins/TradeRaiders), which fixes the return type as an EncounterType enum",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_AnnounceEncounterSighting",
|
||||
"addr": "0x007a9db0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this, Node* node /*= enc->+0xc*/, int encType /*= PickDominantEncounterType(enc->+0x38)*/) // GHIDRA'S DECOMPILE OF THIS FUNCTION IS UNUSABLE -- 19 'removing unreachable block' warnings delete the entire event-posting body; read it as instructions. mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return; -- that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills mask with the players who can see it. Then per set player it composes the event key as the LITERAL \"EVENT_\" (0x00a24bf8, length 6) CONCATENATED WITH THE TYPE NAME -- EVENT_PIRATE, EVENT_TRADERAIDERS, ... -- and posts through ServerPlayer_GetEventStorage + EventStorage_PostEvent. Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into the vector at S+0x2c8/+0x2cc/+0x2d0. No RNG",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08). Call shape instruction-verified; body from a delegated instruction read",
|
||||
"confidence": "med"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_UpdateDiplomacyStatsFromCombat",
|
||||
"addr": "0x00789d00",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this, std::vector<Encounter>* encounters, std::vector<EncounterResults>* results) // two passes over ServerPlayer+0x230 vector<DiplomacyStats>, no RNG, no events. PASS A (dead homeworld): if the battle was at a player's own HomeSys(+0x2c), was a real battle (result->+0x4 == 0), had planet stats (result->+0x10c != 0) and the INT64 at result+0x120 is <= 0, then every participant that actually fought them gets deadhome(+0x20)++. PASS B (treaty betrayal): for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing (`last != -1 && turn-last < 3 && (bty == -1 || bty < last)`), where the other side actually fought -- bty++ and lastXbty = turn, independently for NAP (+0x8/+0xa/+0xe), alliance (+0x10/+0x12/+0x16) and ceasefire (+0x18/+0x1a/+0x1e)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived",
|
||||
"confidence": "med"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_ApplyEncounterResult",
|
||||
"addr": "0x007d8920",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this, Encounter* enc, EncounterResults* res) // phase 6 of OnAllCombatDone_Tail. Dispatch on three result bytes: res->+0x4 != 0 makes the WHOLE FUNCTION A NO-OP (that flag means 'no battle happened', which is exactly what phase 3's sighting arm keys on); res->+0x6 != 0 -> FUN_007a06a0 (posts EVENT_PEACEFUL_ENCOUNTER); res->+0x7 != 0 -> FUN_007d3eb0 (posts EVENT_SYSTEM_SURRENDERED); otherwise the full path -- stamp StarShip+0x5c = turn on every participating ship; set the pairwise HasEng(+0x1ac) engagement bits; if enc->+0x3c stamp ServerPlayer+0x3d4 = turn; build a ~0xea0-byte combat report and run THE REAL RESOLVER FUN_007d5af0 (7499 B, UNREAD); append a Game::CombatReport to the std::list at S+0x1fc; fire the script hook vt[0x10](7,..)/vt[0x30](..). ALL branches then run a publication tail that push_backs a 0x30-byte Game::ClientEncounterResults into *(S+0x2f4) + PlyrIdx*0x11c + 0x24. DRAWS RNG through its subtree: FUN_007d5af0 -> FUN_007bb530 -> RNG_NextInt (node cannon), and -> FUN_007a7f30 -> RNG_Twist plus -> FUN_007a0540 -> FUN_00852d30 -> RNG_NextInt (salvage / back-engineering)",
|
||||
"status": "mapped",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A.1 (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived and FUN_007d5af0 unread",
|
||||
"confidence": "med"
|
||||
},
|
||||
{
|
||||
"name": "Node_ResupplyAlliedFleets",
|
||||
"addr": "0x007463f0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (Node* this /*= enc->+0xc, the encounter's system*/, byte mask) // `if (!this->+0x100 /*owner*/) return; for each fleet at the node (vt[8] count, vt[0x10] get): if (owner->GetRelation(fleet->PID(+0x58)) == 3) for each ship in fleet->NShips(+0xa4/+0xa8) StarShip::RefreshFromDesign(ship, mask);`. FUN_00854680(ship,1) copies ship->+0x20 = design->+0xe8 and ship->+0x6c = design->+0xd8 then runs five recompute helpers -- a repair/refuel/stat refresh, not a movement step. strategic-turn-internals.md line 320 already calls it RefuelInOrbit(1); called from OnAllCombatDone_Tail phase 6 with mask = 1",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_FinalizeTurnRecords",
|
||||
"addr": "0x0078a0e0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* this) // the LAST call of OnAllCombatDone_Tail (0x007d98ba), and also called from LoadGame 0x007ddc40 -- so the per-player turn record is rebuilt at the end of every turn AND on load, and never has to survive a save round-trip. Per player, rec = P->+0x3d8: rec+0x0c (32) = P->Sav(+0x284) - P->PvSav(+0x188); rec+0x14 (32) = P->Sav; rec+0x18 (16, mov WORD) = (P->+0x34 - P->+0x30)>>2 owned systems; rec+0x28 (16) = completed-tech count from FUN_0057d980 over the tree's +0x10/+0x14 with state == 4; rec+0x20/+0x24 (int64, cdq/add/adc) = SUM over owned systems of (sys->+0x194 + sys->+0x18c) total population; rec+0x2a/+0x2c/+0x2e (16) = ship counts by hull size 0/1/2 for designs WITHOUT flag 0x400; rec+0x30/+0x32/+0x34 (16) = the same for designs WITH flag 0x400. The census comes from FUN_00818a50(P, int[8]) whose slots [0] and [1] (the grand totals) are computed and DISCARDED. Second loop: FUN_00894260(S->+0x200, i, S->+0xc, rec) archives the record by turn; the archive's copy-assign FUN_008712a0 deliberately does NOT copy +0x1c",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §5 (lane K 2026-09-08, field-by-field instruction read)"
|
||||
},
|
||||
{
|
||||
"name": "ServerSystem_ComputeMaxIncome",
|
||||
"addr": "0x007521c0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "int (ServerSystem* sys) // float mods[7] = {1.0f,0,..}; FUN_00747390(mods, sys, 0); float out[12] = {0}; ServerSystem::ComputeOutputRates(sys, out, mods); return max(*(int*)&out[3], 0). Slot 3 is the money/income rate. THE max() IS A `jg` -- the clamp at zero is what makes a loss-making colony contribute nothing to the bankruptcy limits rather than reducing them, which formula-gaps.md Q1 did not say. Only caller: ServerPlayer::UpdateBankruptcyLimits",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/formula-gaps.md Q1 addendum (lane K 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "SNMAllCombatDone_layout",
|
||||
"addr": "0x00a24758",
|
||||
"convention": "note",
|
||||
"prototype": "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)",
|
||||
"status": "verified",
|
||||
"source": "findings/control-flow/combat-done-tail.md §0.1 (lane K 2026-09-08)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"name": "ServerPlayer_OnTechResearched_ResearchRollBlock",
|
||||
"addr": "0x00891790",
|
||||
"convention": "site",
|
||||
"prototype": "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 EXACTLY ONE NextFloat unconditionally and then enters 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. This is the one extra RNG word a completion consumes, and clearing ResT means a second completion in the same pass consumes none",
|
||||
"prototype": "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",
|
||||
"status": "verified",
|
||||
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 lines 79-85, 0x0088df20, 0x00889d60)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1774,7 +1774,7 @@
|
|||
"name": "ServerPlayer_RollResearchEvent",
|
||||
"addr": "0x0088df20",
|
||||
"convention": "fastcall",
|
||||
"prototype": "void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws EXACTLY ONE NextFloat from StrategyServer's generator, unconditionally, then fires 0x00889d60 when odds > roll. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion",
|
||||
"prototype": "void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws ONE NextFloat from StrategyServer's generator (entered at rng+4), unconditionally, then fires ServerPlayer_OnResearchRollSucceeded (0x00889d60) when odds > roll (fcompp + test ah,0x41, so equality also skips). THAT ONE WORD IS THE COST OF REACHING THE BRANCH, NOT OF A FIRED ROLL: the plague path inside draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK; the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the research with no further draw. So a fired roll costs one or two words. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion",
|
||||
"status": "verified",
|
||||
"source": "B2 own disassembly pass 2026-09-08 (ReVa read-memory + objdump -b binary -m i386): ServerPlayer::RollResearchEvent 0x0088df20, ResearchEventOdds 0x00820380, ServerPlayer::OnTechResearched 0x00891790"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 @ 834eb09, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ f490d69, 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>
|
||||
|
|
@ -449,7 +449,7 @@ constexpr uint32_t g_AITechValueCount = 0x006ea2ec;
|
|||
constexpr uint32_t ServerPlayer_off_ServerLink = 0x00000008;
|
||||
// offset Mars::RNG* -- the strategic generator (same object TechTree::ProcessResearch is handed) [verified]
|
||||
constexpr uint32_t StrategyServer_off_RNG = 0x0000016c;
|
||||
// fastcall void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws EXACTLY ONE NextFloat from StrategyServer's generator, unconditionally, then fires 0x00889d60 when odds > roll. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion [verified]
|
||||
// fastcall void (ServerPlayer* this) -- odds = ResearchEventOdds(this, this->ResT); draws ONE NextFloat from StrategyServer's generator (entered at rng+4), unconditionally, then fires ServerPlayer_OnResearchRollSucceeded (0x00889d60) when odds > roll (fcompp + test ah,0x41, so equality also skips). THAT ONE WORD IS THE COST OF REACHING THE BRANCH, NOT OF A FIRED ROLL: the plague path inside draws a SECOND word (NextInt) to pick an owned system and posts EVENT_PLAGUE_OUTBREAK; the rebellion path allocates an AIRebellion at ServerPlayer+0x3b8 and cancels the research with no further draw. So a fired roll costs one or two words. Called from OnTechResearched when the completing def is the current research target and the pending-roll byte at +0x3b4 is set. This is the extra RNG draw B3 observed on a completion [verified]
|
||||
constexpr uint32_t ServerPlayer_RollResearchEvent = 0x0048df20;
|
||||
// thiscall float (ServerPlayer* this, TechDef* def) -- 0 when def is null; a plague-family path via 0x00535480, else the AI-rebellion path via AITechRow (odds column) gated on !NPC. Read-only, makes no draw [verified]
|
||||
constexpr uint32_t ServerPlayer_ResearchEventOdds = 0x00420380;
|
||||
|
|
@ -1295,6 +1295,64 @@ 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 (StrategyServer* this /*base S*/, std::vector<EncounterResults>* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records [verified]
|
||||
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail = 0x003d92a0;
|
||||
// site site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf [verified]
|
||||
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_ClearEncounters = 0x003d9690;
|
||||
// site site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path) [verified]
|
||||
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll = 0x003ae095;
|
||||
// offset StrategyServer+0x16c in the S frame holds the Mars::RNG object pointer. Two independent instruction reads agree: ServerPlayer::ProcessTurn 0x0089147f (`eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and ServerPlayer+0x8 == S+4, so eax == S) and FUN_007ae010 0x007ae095 (`mov ecx,[esi+0x16c]` with esi == S). This equals (S+4)+0x168, i.e. exactly what StrategyServer_off_RNGPtr = 0x168 says once the S+4 frame is applied. off_RNG is NOT an exception to the S+4 rule -- turn-driver.md §0 should be read as saying that NextFloat enters the generator OBJECT at +4, which is a different thing [verified]
|
||||
constexpr uint32_t StrategyServer_off_RNGPtr_S_frame = 0x-03ffe94;
|
||||
// thiscall void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log "Can't autosave- Strat game doesn't exist."; build FOUR paths as _snprintf(buf,0x3ff,"%s/%s.%s", dir, name, ext) with dir=FUN_007a05a0(game) ("SavedGames") and ext=FUN_007a0620(game) ("sav") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d [verified]
|
||||
constexpr uint32_t StrategyHost_Autosave = 0x00495210;
|
||||
// cdecl bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature) [verified]
|
||||
constexpr uint32_t StrategyHost_IsSinglePlayerHost = 0x00415fb0;
|
||||
// thiscall bool (void* this) // `return this->+0x148 != 0;`. Called on the global at 0x00b2d540 from StrategyHost::OnMessage 0x00784e3f -- this is the gate on the POST-turn autosave [verified]
|
||||
constexpr uint32_t StrategyHost_HasNetworkSession = 0x00498af0;
|
||||
// cdecl bool (void* game, const char* path, bool write, std::vector<AgentName>* agentNames) // the save-file ROOT that verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Opens the stream with OpenSaveStream(path,&stream,write); with write=1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path,"wb"), the gzip writer. Then writes four named top-level sections through stream->vft[0x28](tag,&ref): "Summary", "CreateParams", "Sim", "CDT"; then one "CD" record (tag at 0x00a2b9d4) per entry of the 0x20-stride agentNames vector whose +0x1c is non-null. NOTHING TIME-, NAME- OR MACHINE-DEPENDENT ENTERS THE PAYLOAD: the file NAME is built by StrategyHost::Autosave and never reaches here [verified]
|
||||
constexpr uint32_t SaveGame_WriteFile = 0x00477070;
|
||||
// cdecl bool (const char* path, Stream** out, bool write) // write -> operator new(0x118), ctor 0x008d10c0, open FUN_008d1090(path, "wb" @0x009e150c); read -> OpenFile(path, @0x00a2ec2c). Returns *out != 0. The "wb" is the gzip container the determinism note measured as header-deterministic (MTIME 0, XFL 0, OS 11) [verified]
|
||||
constexpr uint32_t OpenSaveStream = 0x00416510;
|
||||
// thiscall void (StrategyServer* this) // 122 BYTES, AND IT GENERATES NOTHING. (1) FUN_007c5610(&scratch, S->+0x304, S->+0x308) with ecx = &S->+0x304 -- erase-to-empty of the vector<SETurnResults> OUTBOX at S+0x304. (2) if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 -- clear of a 0xc-stride vector; THE COPY LOOP AT 0x007dc680 IS DEAD CODE, `cmp edx,edx; je` at 0x007dc676 is unconditionally taken. (3) FUN_00792a20(S) prunes two intrusive lists at S+0x2d8 and S+0x2e4. (4) if (S->+0x128 & 4) BuildTurnEvents(S) -- normally FALSE. One caller: StrategyHost::OnMessage 0x00784e34 [verified]
|
||||
constexpr uint32_t StrategyServer_GenerateTurnEvents = 0x003dc640;
|
||||
// note MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are "vector<T> too long" and "StrategyServer: OnEvent() called, but no callback function specified." It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not [verified]
|
||||
constexpr uint32_t StrategyServer_BuildTurnEvents_isResync = 0x003db780;
|
||||
// offset StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn [verified]
|
||||
constexpr uint32_t StrategyServer_off_TurnResultsPending = 0x-03ffd0c;
|
||||
// offset StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema [verified]
|
||||
constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x-03ffcfc;
|
||||
// thiscall void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector<SETurnResults>::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector<ClientEncounterResults> (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8 [verified]
|
||||
constexpr uint32_t SETurnResults_ctor = 0x003a7ae0;
|
||||
// thiscall void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 ("SendTurnResultsToPlayers") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25) [verified]
|
||||
constexpr uint32_t vector_SETurnResults_resize = 0x003cd2a0;
|
||||
// note THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: "EvTurn" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then "Events" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70 [verified]
|
||||
constexpr uint32_t TurnEvents_serializer_direction = 0x00425c40;
|
||||
// cdecl void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs [verified]
|
||||
constexpr uint32_t GetGame = 0x00178050;
|
||||
// thiscall void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object [verified]
|
||||
constexpr uint32_t HandleMap_Resolve = 0x004b9240;
|
||||
// thiscall void (ServerPlayer* this, ServerPlayer* other) // `if (other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28);`. Called twice symmetrically per ordered combatant pair in OnAllCombatDone_Tail phase 2 [verified]
|
||||
constexpr uint32_t ServerPlayer_MarkPlayerEncountered = 0x0040df10;
|
||||
// thiscall void (ServerPlayer* this, uint species) // `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species;`. Species index 4 is permanently excluded. Called in OnAllCombatDone_Tail phase 2 as MarkSpeciesDiscovered(other->Species(+0x5c)) [verified]
|
||||
constexpr uint32_t ServerPlayer_MarkSpeciesDiscovered = 0x0040dee0;
|
||||
// cdecl int (uint typeMask) // A RANKER OVER GROUPS, NOT A FILTER OF ONE. Clears bit 0 (Standard); if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} the mask is RESTRICTED to that group; otherwise the ambient groups {Swarm 3, Derelict 4, Monitor 5, SlaversRefuel 9, CrowRuins 17}, {CrowsNest 12, GravTrap 13} and {GasCloud 11, Meteor 2, Pirate 6, TradeRaiders 18, 20, 23} are each dropped IF ANYTHING ELSE REMAINS. Returns the index of the lowest surviving set bit in [0,0x18), else 0. The four group masks are lazily built once into 0x00b0e96c..0x00b0e988. FUN_004f4970 is the id->name switch (Standard/VonNeumann/Meteor/Swarm/Derelict/Monitor/Pirate/SystemKiller/PuppetMaster/SlaversRefuel/SwarmQueen/GasCloud/CrowsNest/GravTrap/Locust/Berserker/CrowDefenders/CrowRuins/TradeRaiders), which fixes the return type as an EncounterType enum [verified]
|
||||
constexpr uint32_t PickDominantEncounterType = 0x000f4c40;
|
||||
// thiscall void (StrategyServer* this, Node* node /*= enc->+0xc*/, int encType /*= PickDominantEncounterType(enc->+0x38)*/) // GHIDRA'S DECOMPILE OF THIS FUNCTION IS UNUSABLE -- 19 'removing unreachable block' warnings delete the entire event-posting body; read it as instructions. mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return; -- that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills mask with the players who can see it. Then per set player it composes the event key as the LITERAL "EVENT_" (0x00a24bf8, length 6) CONCATENATED WITH THE TYPE NAME -- EVENT_PIRATE, EVENT_TRADERAIDERS, ... -- and posts through ServerPlayer_GetEventStorage + EventStorage_PostEvent. Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into the vector at S+0x2c8/+0x2cc/+0x2d0. No RNG [verified]
|
||||
constexpr uint32_t StrategyServer_AnnounceEncounterSighting = 0x003a9db0;
|
||||
// thiscall void (StrategyServer* this, std::vector<Encounter>* encounters, std::vector<EncounterResults>* results) // two passes over ServerPlayer+0x230 vector<DiplomacyStats>, no RNG, no events. PASS A (dead homeworld): if the battle was at a player's own HomeSys(+0x2c), was a real battle (result->+0x4 == 0), had planet stats (result->+0x10c != 0) and the INT64 at result+0x120 is <= 0, then every participant that actually fought them gets deadhome(+0x20)++. PASS B (treaty betrayal): for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing (`last != -1 && turn-last < 3 && (bty == -1 || bty < last)`), where the other side actually fought -- bty++ and lastXbty = turn, independently for NAP (+0x8/+0xa/+0xe), alliance (+0x10/+0x12/+0x16) and ceasefire (+0x18/+0x1a/+0x1e) [verified]
|
||||
constexpr uint32_t StrategyServer_UpdateDiplomacyStatsFromCombat = 0x00389d00;
|
||||
// thiscall void (StrategyServer* this, Encounter* enc, EncounterResults* res) // phase 6 of OnAllCombatDone_Tail. Dispatch on three result bytes: res->+0x4 != 0 makes the WHOLE FUNCTION A NO-OP (that flag means 'no battle happened', which is exactly what phase 3's sighting arm keys on); res->+0x6 != 0 -> FUN_007a06a0 (posts EVENT_PEACEFUL_ENCOUNTER); res->+0x7 != 0 -> FUN_007d3eb0 (posts EVENT_SYSTEM_SURRENDERED); otherwise the full path -- stamp StarShip+0x5c = turn on every participating ship; set the pairwise HasEng(+0x1ac) engagement bits; if enc->+0x3c stamp ServerPlayer+0x3d4 = turn; build a ~0xea0-byte combat report and run THE REAL RESOLVER FUN_007d5af0 (7499 B, UNREAD); append a Game::CombatReport to the std::list at S+0x1fc; fire the script hook vt[0x10](7,..)/vt[0x30](..). ALL branches then run a publication tail that push_backs a 0x30-byte Game::ClientEncounterResults into *(S+0x2f4) + PlyrIdx*0x11c + 0x24. DRAWS RNG through its subtree: FUN_007d5af0 -> FUN_007bb530 -> RNG_NextInt (node cannon), and -> FUN_007a7f30 -> RNG_Twist plus -> FUN_007a0540 -> FUN_00852d30 -> RNG_NextInt (salvage / back-engineering) [mapped]
|
||||
constexpr uint32_t StrategyServer_ApplyEncounterResult = 0x003d8920;
|
||||
// thiscall void (Node* this /*= enc->+0xc, the encounter's system*/, byte mask) // `if (!this->+0x100 /*owner*/) return; for each fleet at the node (vt[8] count, vt[0x10] get): if (owner->GetRelation(fleet->PID(+0x58)) == 3) for each ship in fleet->NShips(+0xa4/+0xa8) StarShip::RefreshFromDesign(ship, mask);`. FUN_00854680(ship,1) copies ship->+0x20 = design->+0xe8 and ship->+0x6c = design->+0xd8 then runs five recompute helpers -- a repair/refuel/stat refresh, not a movement step. strategic-turn-internals.md line 320 already calls it RefuelInOrbit(1); called from OnAllCombatDone_Tail phase 6 with mask = 1 [verified]
|
||||
constexpr uint32_t Node_ResupplyAlliedFleets = 0x003463f0;
|
||||
// thiscall void (StrategyServer* this) // the LAST call of OnAllCombatDone_Tail (0x007d98ba), and also called from LoadGame 0x007ddc40 -- so the per-player turn record is rebuilt at the end of every turn AND on load, and never has to survive a save round-trip. Per player, rec = P->+0x3d8: rec+0x0c (32) = P->Sav(+0x284) - P->PvSav(+0x188); rec+0x14 (32) = P->Sav; rec+0x18 (16, mov WORD) = (P->+0x34 - P->+0x30)>>2 owned systems; rec+0x28 (16) = completed-tech count from FUN_0057d980 over the tree's +0x10/+0x14 with state == 4; rec+0x20/+0x24 (int64, cdq/add/adc) = SUM over owned systems of (sys->+0x194 + sys->+0x18c) total population; rec+0x2a/+0x2c/+0x2e (16) = ship counts by hull size 0/1/2 for designs WITHOUT flag 0x400; rec+0x30/+0x32/+0x34 (16) = the same for designs WITH flag 0x400. The census comes from FUN_00818a50(P, int[8]) whose slots [0] and [1] (the grand totals) are computed and DISCARDED. Second loop: FUN_00894260(S->+0x200, i, S->+0xc, rec) archives the record by turn; the archive's copy-assign FUN_008712a0 deliberately does NOT copy +0x1c [verified]
|
||||
constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0;
|
||||
// thiscall int (ServerSystem* sys) // float mods[7] = {1.0f,0,..}; FUN_00747390(mods, sys, 0); float out[12] = {0}; ServerSystem::ComputeOutputRates(sys, out, mods); return max(*(int*)&out[3], 0). Slot 3 is the money/income rate. THE max() IS A `jg` -- the clamp at zero is what makes a loss-making colony contribute nothing to the bankruptcy limits rather than reducing them, which formula-gaps.md Q1 did not say. Only caller: ServerPlayer::UpdateBankruptcyLimits [verified]
|
||||
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;
|
||||
// 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 (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
|
||||
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
|
||||
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]
|
||||
|
|
@ -1423,7 +1481,7 @@ constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008;
|
|||
constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
|
||||
// site site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise [verified]
|
||||
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 EXACTLY ONE NextFloat unconditionally and then enters 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. This is the one extra RNG word a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
|
||||
// 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;
|
||||
// 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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue