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

25 KiB
Raw Permalink Blame History

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).


std::string layout note (2026-09-07): §1.1's offsets are wrong; the verified MSVC-2010 layout is _Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18, sizeof 0x1c (from Mars::Stream::WriteString 0x008b9d70). See findings/subsystems/tech-effects.md.

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):

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.