sots-re/findings/control-flow/turn-spine.md

250 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Ghidra write-back + turn/main-loop spine — Sword of the Stars (2006)
Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000. All addresses are VAs. Ghidra 12.0.4 headless,
project `/srv/re-lab/ghidra-projects/sots`; all changes below are **saved in the project** (four headless runs, each
"Save succeeded"). Scripts (CT111 `/root/`, copies in `/srv/re-lab/handoff/scripts/`): `SpineRecon.java`..`SpineRecon4.java`
(read-only recon), `WriteBack.java` (types + renames + labels), `FixThis.java` (class-struct merge + serializer `this`
views), `FixPlayer.java`. Raw decompiles used for the analysis: CT111 `/tmp/spine/*.c`; the verification decompiles are in
`/srv/re-lab/handoff/ghidra-verify/`.
Inputs: `findings/objects/struct-recovery.md` (member tables), `findings/objects/ghidra-recon.md` (affinity, vftables),
`findings/subsystems/ui-screen-map.md` §3 (SE*/SNM* names).
---
## 1. Type write-back
### 1.1 Where the types live (important for anyone continuing in Ghidra)
Ghidra resolves the `this` type of a `__thiscall` function in class namespace `Game::X` to the structure
**`/Game/X`** (`VariableUtilities.findOrCreateClassStruct`). The recovered structs were therefore merged *into those
class structs*, so every function placed in the class namespace decompiles with field names automatically:
| Ghidra data type | size | fields | note |
|---|---|---|---|
| `/Game/ServerSystem` | 0x2d8 | 87 | abs offsets from struct-recovery §1 (+ base vptrs/netId/owner) |
| `/Game/ServerPlayer` | 0x3e0 | 110 | §2; IStreamable sub-object at +0x3a0 |
| `/Game/StarFleet` | 0x120 | 21 | §3 |
| `/Game/StarShip` | 0xb0 | 26 | §4 |
| `/Game/StrategyServer` | 0x320 | 41 | **partial**: offsets from `StrategyServer::Write` + the spine functions (Frame/turn @+0xc, Systems @+0x44, Players @+0x54, Fleets @+0x64, Acts @+0x74, OnEventCallback @+0x170, StrategyEvents list @+0x2b8, bProcessingTurn @+0x2f1, …) |
| `/Game/Population` 0x14, `PopulationGroup` 0x18, `Morale` 0x20, `MoraleEvent` 0x50, `IndependenceInfo` 0x70, `PlayerColorID` 4, `ShipBuildOrder` 0x18, `DiplomacyStats` 0x24, `PlayerReport` 0x30, `PlayerAlliances` 0x10, `NodeRoute` 0x10, `FlightPlan` 0x38, `FlightPlan/Waypoint` 0x1c, `ShipHealth` 0x10, `PrisonerHold` 0x18, `EventStorage` 0x1c, `CivilianRatios` 0x2c, `ShipRecords` 0x44, `SpyReport` 0x34, `/Game/StarSystem/OutputRates` 0x1c, `/Game/StarSystem/PlayerView` 0x9c, `/Mars/Vector3` 12 | | | nested types from §1.1–§2.5 / §3.1 |
| `/SOTS/std::string` 0x1c, `std::vector` 12, `std::list` 8, `std::map` 8, `Game::FleetLayout` 0x24 | | | helpers. `std::string` layout **verified** from `Stream::WriteString` (`if (s->_Myres > 15) p = s->_Ptr`): `_Bx`@0 (16), `_Mysize`@0x10, `_Myres`@0x14, `_Alval`@0x18 |
| `/SOTS/Game::ServerSystem_ser8`, `StarFleet_ser8`, `StarShip_ser8`, `ServerPlayer_ser928` | | | **serializer views**: the same fields shifted by the IStreamable COL offset (8 / 0x3a0), because inside `X::Read/Write` `this = object + COL`. Applied (custom storage, `this` in ECX) to the eight Read/Write functions. For ServerPlayer only the +0x3a0.. tail is visible; the rest stays as `this + -0x378` etc. |
| `/Game/BuildQueue`, `TechTree`, `ShipDesign`, `Tech`, `CommMessageContainer`, `FleetNameGenerator`, `AIRebellion`, `AIEncounterFlags`, `ServerNodeGraph`, `ServerTradeManager`, `IServerSpyManager`, `AttribMap`, `SVScriptObject`, `StrategyEvent`, `/Mars/Stream` | 0 | | opaque, pointer targets only |
Every struct carries a description citing `struct-recovery.md`; gaps are left undefined. One extra member was added from
the spine work: `ServerPlayer+0xf9 bTurnDone_nonser` (tested by `StrategyServer::OnPlayerEndTurn`, not serialized), and
`ServerPlayer+0x164 Status` is the field `SNMSetPlayerStatus` writes (4 = "done", 1 = playing).
### 1.2 Renames (all with plate comments citing the finding doc)
*Serializers* (52): `Game::ServerSystem::Read/Write` (0x0075d4b0/0x00749630), `Game::ServerPlayer::Read/Write`
(0x008804d0/0x008563e0), `Game::StarFleet::Read/Write` (0x00702470/0x00701070), `Game::StarShip::Read/Write`
(0x00853fa0/0x008291f0), `Game::StrategyServer::Read/Write` (0x007d27a0/0x0079fa70), `Game::StarMapNode::Read/Write`
(0x00727790/0x00727820), `Game::StarSystem::PlayerView::Read/Write`, `::OutputRates::Read/Write`, `Population`,
`PopulationGroup`, `IndependenceInfo`, `Morale`, `MoraleEvent`, `ShipBuildOrder`, `PlayerNotes`, `SpyReport`,
`PlayerReport`, `DiplomacyStats::Write`, `FlightPlan`, `FlightPlan::Waypoint`, `NodeRoute`, `PrisonerHold`,
`EventStorage::Write`, `PlayerAlliances::Write`, `ShipHealth::Write`, `PlayerColorID::Write`, `Mars::Vector3::Write`,
`TechTree::Write`, `ShipRecords::Write`, `CivilianRatios::Write` — exactly the addresses in struct-recovery §0.
*Stream primitives* (`Mars::Stream::…`): `WriteString` 0x008b9d70, `WriteBool` 0x008b9c20, `WriteFloat` 0x008b9be0,
`WriteInt` 0x008b9d50, `WriteInt16AsInt` 0x008b9d00, `WriteInt8AsInt` 0x008b9cb0, `WriteInt64` 0x008b9c60,
`WriteHandleId` 0x00816490, `ReadFloat` 0x008b9bc0, `ReadInt` 0x008b9d20, `ReadBool` 0x008b9c00, `ReadString`
0x008b9d90, `ReadInt64` 0x008b9c40, `ReadInt16` 0x008b9cd0, `ReadHandle` 0x008164d0. Each plate comment lists the Stream
vftable slot map (+0x10 ReadIntRef, +0x14 ReadNested, +0x18 String, +0x1c Bool, +0x20 Float, +0x24 Int, +0x28 Nested,
+0x30 Raw). The `Mars::Stream` vftable itself was not located this round (the wrappers are the only anchor).
*Affinity / app*: `Process_PinAffinity` 0x0089ee70; `Mars::Application::Initialize` 0x008a0e50 (+ secondary label
`AppStartup_ReadConfig` at the same address, as requested); `WinMain` 0x0089dd30; `Mars::Application::Run` 0x0089f5b0;
`Mars::Application::PumpMessages` 0x0089f1c0; `Mars::Application::Application` 0x008a0170; `CreateAppWindow` 0x0089fe70;
`Mars::FrameTimer::Update` 0x0090c700; `Mars::TimerList::Dispatch` 0x008e5ac0; `Mars::AppStartup::OnConfigToken`
0x0089f4d0; `Mars::ConfigParser::ParseFile` 0x008cd820; `Game::DemoApp::{DemoApp 0x0089c950, OnStartup 0x0089d610,
OnShutdown 0x0089dfb0, OnUpdate 0x00898800, OnTick 0x0089a640, OnRender 0x00899210, ShouldSleepWhenInactive 0x008986a0,
CreateStrategyGame 0x00898b00}`. Globals: `g_pApplication` 0x00b2d540, `g_pDemoApp` 0x00b2d0bc.
*Threads, game creation, turn spine*: see §2/§3 (every function named there was renamed; ~60 functions).
*Network message registry*: 82 registry entries labelled `NetMsgReg_<name>` (EOL comment = id/factory), the 21 unnamed
factory functions renamed `Game::<Msg>::Create`, `Mars::NetMessageRegistry::Register` 0x008d2290,
`g_NetMsgRegistryById` 0x00b2ddf8. EOL comments on the three `CreateThread` sites.
### 1.3 Verification (re-decompile with the new types)
`Game::ServerSystem::Write` (0x00749630), `this` typed `ServerSystem_ser8*` — field names appear for every tag
(`/srv/re-lab/handoff/ghidra-verify/verify_ServerSystem_Write.c`, 125 `this->` references; Read: 205):
```c
void __thiscall Game::ServerSystem::Write(ServerSystem_ser8 *this, void *stream)
...
StarMapNode::Write(stream);
Mars::Stream::WriteFloat(pvVar3,"R",&this->R,0xffffffff,uVar5);
Mars::Stream::WriteFloat(pvVar3,"G",&this->G,0xffffffff);
Mars::Stream::WriteFloat(pvVar3,"B",&this->B,0xffffffff);
Mars::Stream::WriteFloat(pvVar3,"A",&this->A,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"Idx",&this->Idx,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"Size",&this->Size,0xffffffff);
Mars::Stream::WriteFloat(pvVar3,"Suit",&this->Suit,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"Res",&this->Res,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"ARes2",&this->ARes2,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"MRes",&this->MRes,0xffffffff);
Mars::Stream::WriteBool(pvVar3,"NoRebAI",&this->NoRebAI,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"TRes",&this->TRes,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"Pop",&this->Pop,0xffffffff);
local_1c = &this->Pop2; local_24 = Mars::StreamableHelper<class_Game::Population>::vftable;
(**(code **)(*(int *)pvVar3 + 0x28))("Pop2",&local_24);
Mars::Stream::WriteFloat(pvVar3,"Infra",&this->Infra,0xffffffff);
Mars::Stream::WriteInt(pvVar3,"PvPop",&this->PvPop,0xffffffff);
...
```
`Game::StrategyServer::Write` (COL 0, plain `StrategyServer*`): `WriteString(stream,"KeyPath",&this->KeyPath)`,
`local_60 = (this->NodeMapLines._Mylast - _Myfirst)/0x14` (NMSz), `WriteInt(stream,"ModCount",&this->ModCount)`,
`WriteInt(stream,"Frame",&this->Frame)`, `("GameID",this->GameID)`, `this->Attrib`, `&this->GameName`, `this->Map`,
`&this->IncMod`, `&this->ResMod` … (69 refs). Spine functions after the merge: `BeginProcessTurn`:
`this->Frame = this->Frame + 1; this->bProcessingTurn = true; log("Begin processing turn %d", this->Frame)`;
`OnPlayerEndTurn`: `piVar10 = this->Players._Myfirst … if (this->OnEventCallback == NULL) … (*this->OnEventCallback)(id,0x27,&ev)`;
`ServerSystem::ProcessTurn`: `if (this->PID == NULL) this->Infra -= …; if (this->indi) iVar4 = this->indi->indsp; this->ntdev = 0`.
Every save tag in the decompile lines up with the field of the same name — the offset tables in struct-recovery.md are
confirmed by the decompiler itself.
---
## 2. Turn / main-loop spine
### 2.1 Entry → init → main loop
```
entry 0x00925794 → ___tmainCRTStartup 0x00925501 → WinMain 0x0089dd30 (hInst=0x400000, cmdline, nShow)
├ single-instance mutex "Kerberos_SwordOfTheStars_Mutex" (bypass: /concurrent); FindWindow+SetForegroundWindow otherwise
├ new Game::DemoApp (0x1b8) 0x0089c950 → Mars::Application::Application 0x008a0170 (g_pApplication = this)
├ Mars::Application::Initialize 0x008a0e50 ("AppStartup_ReadConfig")
│ CPU/ForceSingleCore > 0 → Process_PinAffinity 0x0089ee70 (SetProcessAffinityMask 1<<core)
│ "/startup:" cmdline → Mars::ConfigParser::ParseFile 0x008cd820 with Mars::AppStartup (vft[1] = OnConfigToken 0x0089f4d0; "conlevel")
│ display.cfg, audio.cfg, CoInitialize, Direct3DCreate9(0x20|0x1f), CreateAppWindow 0x0089fe70, DrawDevice 0x008df760, ClipCursor
│ timers → CreateEvent×2 + CreateThread(SoundSystem::StreamingUpdateThreadProc) prio 15 ← THREAD 2
│ GlobalConsts, textures, effect pool, sprites, keymap, font, SimplePainter, PanelManager 0x0090f1e0
│ IApplication::OnStartup (vft+0x10) = Game::DemoApp::OnStartup 0x0089d610
│ banner "Sword of the Stars 1.8.1 (Wed Dec 13 03:38:31 2017)", profiles, Mars::Network::Startup 0x00902470 → NetworkManager::Create 0x00902350 ← THREAD 1
│ ParticleSystemManager, Mesh, Model, StringTable, GUIResources, SoundSystem, sound_ui.csv, SpeechEvents, GUIAppearances, colour/badge/avatar tables, main menu / "/join"
└ Mars::Application::Run 0x0089f5b0 ← MAIN LOOP
do {
Mars::FrameTimer::Update 0x0090c700
PanelManager->vft+0x60() (UI/input update)
app->vft+0x18 = DemoApp::OnUpdate 0x00898800 (Mars network pump FUN_00902ad0; current game object(+0x158)->Update; strategy game (+0x150) → FUN_007879c0)
if (!focused && app->vft+0xc ShouldSleepWhenInactive) Sleep(20)
_controlfp(...); every _DAT_00a36860 s → Mars::TimerList::Dispatch 0x008e5ac0
ok = app->vft+0x1c = DemoApp::OnTick 0x0089a640 (sound/console/panels + top-level state machine: loads/unloads strategy game @+0x150 and combat @+0x158; 0 when quitting)
if (ok && DrawDevice) { dev->Begin; if (!dev->IsLost) { dev->+8; app->vft+0x20 = DemoApp::OnRender 0x00899210 (PanelManager->vft+0x64 draw / movie player); dev->+0xc; dev->Present } }
} while (Mars::Application::PumpMessages 0x0089f1c0 (PeekMessage/Translate/Dispatch, 0 on WM_QUIT) && ok);
```
The strategy game is created from the lobby via `DemoApp::CreateStrategyGame` 0x00898b00 → `StrategyApp::CreateGame`
0x00888e80 (GameOptions/AIProcessMinTime, DefaultAutoRefuel, starcolors.txt, `TurnCommands_v5`) → `StrategyServer`
ctor 0x007d78d0 (+0x170 OnEventCallback from a ctor argument), `StrategyServer::InitGame` 0x007c8d90 (SEInitGame,
SEAddDesign, SynchronizePlayer), `StrategyServer::LoadGame` 0x007dd530 ("loaded from file"), star-map UI
(FUN_00778f40 → … → `StarMapPanelBase` ctor 0x00741cd0 ← THREAD 3).
### 2.2 Event plumbing (how `SE*` reach the client)
`Game::SE*` are `Game::StrategyEvent` subclasses with a single virtual (dtor) — there is **no dynamic_cast dispatch**;
the server hands each event to a callback with an **integer event type**: `StrategyServer+0x170 OnEventCallback
(playerNetId, int type, StrategyEvent** ev)`; `StrategyServer::BroadcastEvent` 0x00789710 loops over `Players`.
Missing callback → "StrategyServer: OnEvent() called, but no callback function specified." (31 sites). Types seen:
`0x21 SETurnEndPending` (client side, `StrategyClient::RaiseEvent` 0x00783ee0), `0x24 SEProcessTurn`, `0x26
SEResumePlaying`, `0x27 SELastPlaying`. The client counterpart is the `StrategyClient` (vftable 0x00a2298c).
`SNM*` are network messages registered by static initialisers (`.text` 0x009be0cc–0x009c138c, code Ghidra had not
disassembled) via `Mars::NetMessageRegistry::Register` 0x008d2290 → `g_NetMsgRegistryById[id]` 0x00b2ddf8. SNM entry
address = 0x00b2bc94 + 12·id (`{name, id, factory}`), so a handler comparing `msg->GetType()->id` against
`DAT_00b2xxxx` is comparing against `entry+4`. Ids that matter for the turn:
| id | message | id | message |
|---|---|---|---|
| 0x29 | SNMUpdate (host → all: every player's TurnCommands) | 0x3f | SNMEndTurn (client → host) |
| 0x2a/0x2b | SNMHostCombat / Reply | 0x40 | SNMUpdateComplete |
| 0x2c/0x2d/0x2e | SNMLaunchCombat / JoinCombat / JoinCombatReply | 0x43/0x44 | SNMResumePlaying / …Received |
| 0x2f | SNMAllCombatDone | 0x3d / 0x3e | SNMRunAI / SNMKillAI |
| 0x30 / 0x31 | SNMEncounterQueryResults / SNMEncounterResults | 0x53 | SNMTurnInfo |
| 0x32 | SNMSetPlayerStatus | 0x5b | SNMQueryEndTurnDone |
| 0x3c | SNMDoEncounterQuery | 0x52 | SNMSetTimers |
(full table of 82 ids: `NetMsgReg_*` labels in Ghidra; ids 0x00–0x0f are the Mars transport `TNM*`/`FNM*`/`NM*`.)
### 2.3 The turn, end to end (addresses = renamed functions)
```
[strategic round] UI End Turn (FUN_005e4f80 / FUN_00579310)
→ Game::StrategyClient::EndTurn 0x00783be0 (marks +0x57/+0x132, QPC timestamp, EndTurnDelay 0x0076ab90)
→ RaiseEvent(0x21, SETurnEndPending) 0x00783ee0 [SETurnEndPending: "Done", cancellable → CancelEndTurn 0x007856f0 → SETurnEndCancelled]
→ SendEndTurn 0x00783980 → SNMEndTurn (factory 0x0088c7b0; TurnCommands @+4, AIEncounterFlags @+0x1b8)
StrategyClient::Update 0x007842b0: strategy timer → SETurnTimeExpired / EndTurnForced 0x00783d30 / SNMQueryEndTurnDone
[host] Game::StrategyNetworkClient::OnMessage 0x00784640 case 0x3f SNMEndTurn
→ StrategyServer::OnPlayerEndTurn 0x007d9af0 (if exactly one human still not done → SELastPlaying 0x27 to him)
→ StrategyServer::StorePlayerTurnCommands 0x007893c0
host StrategyNetworkServer::Update 0x007cda40 → when all done broadcasts SNMUpdate (0x29) carrying ALL TurnCommands
[every machine — lock-step sim] OnMessage case 0x29 SNMUpdate ("Processing turn update, but the local player is not done" guard)
→ StrategyServer::BeginProcessTurn 0x007d98e0 Frame++, bProcessingTurn=1, "Begin processing turn %d", reset fleet transients, BroadcastEvent(0x24 SEProcessTurn)
→ StrategyServer::ApplyTurnCommands 0x007b18b0 "set for turn processing": fleet orders, builds, research, alliances (EVENT_ALLIANCE_*)
→ FUN_007ad0f0, FUN_0078f6a0, FUN_0081ff40 (small, unnamed)
→ StrategyServer::ProcessTurn 0x007dc6c0 (dt = 1.0f) ← §2.4
→ FUN_008cfe20(server->GameID), FUN_007cf540; if local clients: StrategyApp::SyncLocalClients(1) 0x00815fd0 → StrategyServer::SynchronizePlayer 0x007c6220 per local client
→ client state (+0x58) = 5; SNMUpdateComplete (0x40) to host
[querying / combat round — host] StrategyNetworkServer::RunCombatRound 0x007cbe80
→ per encounter: SendEncounterQuery 0x007bfe60 → SNMDoEncounterQuery (0x3c) → clients: state 4 (C1 query UI, FUN_00847320)
→ SNMEncounterQueryResults (0x30) → "Notifying %s to host encounter %d" SNMHostCombat (0x2a) / Reply (0x2b) / SNMLaunchCombat (0x2c) / SNMJoinCombat (0x2d)
(client side: StrategyClient::Update logs "All(%d) clients connected to combat server", "Launching combat for encounter %d"; combat itself = DemoApp+0x158 object driven from DemoApp::OnTick)
→ SNMEncounterResults (0x31) … → all resolved: SNMAllCombatDone (0x2f) "All combat complete, waiting for clients to process results"
[every machine] OnMessage case 0x2f SNMAllCombatDone
→ FUN_00789330, FUN_007c0600 (1089 B, post-combat bookkeeping), StrategyServer::ApplyEncounterResults 0x007d4400 (EVENT_STATION_ENABLED/DISABLED …)
→ DispatchTurnResults 0x007cd2a0 → SendTurnResultsToPlayers 0x007c5850 → per player SETurnResults::Create 0x007a7ae0 + fill 0x007c24d0 + dispatch 0x0079ac10 [SETurnResults]
→ FUN_00761310, StrategyApp::SyncLocalClients(0)
→ StrategyServer::GenerateTurnEvents 0x007dc640 → BuildTurnEvents 0x007db780 (EventStorage::TurnEvents per player, SynchronizePlayer) [SETurnEvents]
→ autosave "(Autosave EndTurn)" (FUN_00895210 / "Auto saving of game failed."), FUN_0076c610, FUN_00769340, FUN_00781cf0
→ state = 6, "StrategyNetworkClient: All combat complete msg received. New turn begins..."
[resume] host StrategyNetworkServer::SendResumePlaying 0x00794770 → SNMResumePlaying (0x43)
→ OnMessage case 0x43 → StrategyServer::ResumePlaying 0x007ddc90: every player with Status(+0x164)==0 gets SEResumePlaying (0x26); reply SNMResumePlayingReceived (0x44); state = 1 (playing)
→ (SELastPlaying / SEResumePlaying / SETurnEvents drive the UI_LAST_PLAYING "new turn" sound and the S16 events list)
[AI] SNMRunAI (0x3d) → StrategyApp::RunAI 0x008706f0 ("RunAI: No StrategyServer created") → RaiseAIPrepareTurn 0x00815f20 → SEAIPrepareTurn; AI players' TurnCommands enter the same SNMEndTurn/SNMUpdate path.
```
Client state machine (`StrategyNetworkClient+0x58`): 1 playing → (End Turn) → 5 turn processed / waiting for combat →
4 querying (per encounter) → 6 all-combat-done → 1.
### 2.4 `Game::StrategyServer::ProcessTurn` 0x007dc6c0 — phases (decompiled with the new types, `ghidra-verify/verify_ProcessTurn.c`)
1. **Per-system pre-pass** over `Systems`: colony lookup (FUN_007437e0), morale-event push (FUN_00752a10), string-table text (FUN_00743530/FUN_008c97f0), `SESystemAbandoned` via FUN_007b9df0.
2. FUN_0086b300 + FUN_007adc80 (alliance/diplomacy upkeep; no strings).
3. Per-player pre-pass over `Players` (small).
4. **Movement**: fleet snapshot (FUN_00794ad0 / FUN_007b9b90), `ProcessNodeSpaceTravel` 0x007a0e20 (EVENT_FLEET_MULTIPOINT_NONODE, EVENT_LOSTINNODESPACE_NOBORE/ENGINES), **`ProcessFleetMovement` 0x007da9a0 → `MoveFleet` 0x007d9ee0 per fleet ("Destination of fleet doesn't exist. Stopping fleet.", cancels ship actions) → `SEFleetArrived`; `OnFleetArrived` 0x007ccb10 (EVENT_FLEET_ARRIVED)**.
5. Per-fleet → per-ship FUN_00814ea0 (ship upkeep).
6. **Per-system `ServerSystem::ProcessTurn` 0x007598e0**: infra decay if unowned, independence, pop/morale growth, `ProcessPlague` 0x00756a90 (EVENT_PLAGUE_OUTBREAK/CURED, COLONY_DESTROYEDBYPLAGUE), **`ProcessBuildQueue` 0x00752500 → `BuildQueue::ProcessTurn` 0x00890d50 → `SEBuildCompleted`** (with ShipBuildOrderDef), resources/terraforming (FUN_0074b230/FUN_00754220), `ProcessSlaves` 0x007537b0 (EVENT_SLAVES_DEAD), `ProcessRebellion` 0x007583b0 (EVENT_SYSTEM_REBELLION_CONTINUES).
7. FUN_0078a7c0.
8. **Per-player `ServerPlayer::ProcessTurn` 0x00891340 (dt)**: income/savings (FUN_00840fe0), **research**: if `ResT` set → `RollResearchAccident` 0x00889dc0 ("ACCIDENT!!"/"All okay.", EVENT_LABACCIDENT_*) then `TechTree::ProcessResearch` 0x005876c0 (EVENT_RESEARCH_OVERBUDGET, EVENT_TECHS_UNLOCKED), else EVENT_NO_RESEARCH; special projects FUN_00863cf0.
9. `ProcessMissions` 0x007999a0, `ProcessStations` 0x007ae480 (EVENT_STATIONS_SCUTTLED), `ProcessDefenceSats` 0x007af0b0 (EVENT_DEFSATS_SCUTTLED).
10. Per-ship flag pass: flag 4 → FUN_0080caf0(1); flag 0x400000 → FUN_00815230.
11. **Encounter detection**: second snapshot FUN_00794ad0 → `local_98` = pending encounters. **If none**: end-of-turn tail runs now — `ProcessAid` 0x007ad100 (EVENT_GIVE_SAVINGS/RESEARCH), `ProcessSpecialProjects` 0x007a3310 (EVENT_SPRJTECHOFFER_STARTED), `ProcessSurrenders` 0x007d0d10 (EVENT_PLAYER_SURRENDERED_, EVENT_SYSTEM_SURRENDERED), per-player FUN_00818530(0), script hooks `SvSctOb->vft+0x10(6)` / `(0x1c)`, FUN_0086a8d0, FUN_0078ab30, FUN_00799380, FUN_0078aa70, per-system FUN_00743ec0, FUN_007b4c00, FUN_007d7f70 (2.4 KB, per-team 0x74-byte records), and every player with `+0xf9==0 || +0xfa!=0` gets `Status(+0x164) = 1` (back to playing). **If encounters exist** the tail is deferred to the `SNMAllCombatDone` handler (§2.3), i.e. the turn's results/events are only produced after the combat round.
---
## 3. Threads (the three `CreateThread` sites)
| # | site | creator | thread proc | what it does |
|---|---|---|---|---|
| 1 | 0x0090242d | `Mars::NetworkManager::Create` 0x00902350 ← `Mars::Network::Startup` 0x00902470 ← `DemoApp::OnStartup` | `Mars::NetworkManager::WatchdogThreadProc` 0x00901f40 | `Sleep(10)` loop under `g_netCS` 0x00b2e5f8: pings host link (+0x20) and listener (+0x98), detects timeouts — "Network(%f): No response from host %s." / "...from client %s." — and drops the link. Pure liveness watchdog; never touches the sim. Globals `g_pNetworkManager` 0x00b2e61c, `g_hNetworkWatchdogThread` 0x00b2e620. |
| 2 | 0x008a14ef | `Mars::Application::Initialize` 0x008a0e50 ("Initializing Streaming sound update thread...") | `Mars::SoundSystem::StreamingUpdateThreadProc` 0x008ef1d0, **SetThreadPriority 15 = TIME_CRITICAL** | `WaitForMultipleObjects({app+0x84 wake, app+0x88 quit})`; under `g_musicCS` 0x00b2e4a8: if the current music stream (`g_pCurrentMusicStream` 0x00b2e4c4) has ended/faded (float +0x388 ≤ 0) → stop it and open the next queued track (`MusicPlayer::OpenMusicFile` 0x008ef040, "[%s] cannot open music file for playback"), else `StreamingSound::FillBuffer` 0x0091b5a0 (DirectSound GetCurrentPosition/Lock/decode; "Couldn't restore buffer"); then FUN_008b65d0 (sound-system update). Music/DirectSound streaming only — but it is the **only elevated-priority thread** in the process. |
| 3 | 0x00736e84 | `Game::BackgroundWorker::Start` 0x00736e30, first statement of `Game::StarMapPanelBase` ctor 0x00741cd0 (Render/StarMapBlobs_Solid.fx, StarMapBlobs_Glow.fx, POLMAP_* colours, Skysphere) ← `PoliticalMapPanel` ctor 0x007424b0 ← strategy-map screen ctor FUN_005e9780 ← … ← `StrategyApp::CreateGame` | `Game::BackgroundWorker::ThreadProc` 0x00735bb0 | `Sleep(10)` poll loop; struct `{CRITICAL_SECTION@0; HANDLE thread@0x18; job*@0x54; flags@0x58..0x5c (0x5b quit, 0x5c exited)}`; when a job is posted runs `StarMapBlobs::BuildBlobMesh_Job` 0x00732ab0 = political-map territory "blob" mesh (point cloud → centroid/spread → implicit-surface polygoniser FUN_008fc160 with callbacks FUN_00722010/FUN_0071ea60 → mesh FUN_008fa5b0), then sets done. **Strategic-map overlay only — not the battle loader** (prior recon's "prime suspect" is cleared). |
Consequences for the battle-load / many-core lead: the combat load and the whole sim run on the **main thread** (DemoApp::OnTick
state machine → combat object at `DemoApp+0x158`, `OnUpdate` → `+0x158->vft+4`). The only application threads are two 10 ms
pollers and a TIME_CRITICAL audio streamer; everything else multi-threaded is inside D3D9/DirectSound/driver. If the
slowdown is contention, the candidates inside the game's own code are the `g_musicCS` critical section (held by the
TIME_CRITICAL thread while decoding) and the `Sleep(10)` pollers — not a sim worker.
---
## 4. Caveats / open items
- Names are inferred from strings/callers (no symbols). `StrategyNetworkClient::OnMessage` (0x00784640) is the combined
host+client message handler (host branches test `+0x54 = StrategyServer*`); the "StrategyNetworkServer" functions live
on the host object driven from `DemoApp::OnUpdate`.
- `StrategyServer` struct is partial (41 fields); `ServerPlayer::Read/Write` show only the +0x3a0.. tail by name.
- Not named: FUN_007ad0f0/FUN_0078f6a0/FUN_0081ff40/FUN_007cf540 (between ApplyTurnCommands and ProcessTurn),
FUN_00789330/FUN_007c0600 (AllCombatDone pre-pass), FUN_0086b300/FUN_007adc80/FUN_0078a7c0/FUN_007d7f70 (turn phases
without strings), the client-side event sink behind `StrategyClient::RaiseEvent`, and the `Mars::Stream` vftable.
- The static-initialiser region 0x009be000–0x009c1400 is still undisassembled code in the project (only labels were
added); running "Disassemble" there would expose all 82 registrations as functions.
- reva-server was stopped for the headless runs and restarted at the end. No git commit was made.