From 1754cc20ac61505f0ddae4213652664a96ea5c62 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 12:45:27 -0400 Subject: [PATCH] board: lane P2 nav classifier; type 2 is the Liir drive, a naming error not a gap --- campaign/board.md | 7 + findings/subsystems/multiplayer-gamespy.md | 487 +++++++++++++++++++++ findings/subsystems/ui-screen-map.md | 4 +- ghidra/addresses.d/lane-g2.json | 348 +++++++++++++++ ghidra/generated/sots_addresses.h | 224 +++++++++- 5 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 findings/subsystems/multiplayer-gamespy.md create mode 100644 ghidra/addresses.d/lane-g2.json diff --git a/campaign/board.md b/campaign/board.md index a43dfae..076885b 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -212,3 +212,10 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | corrections from lane N | subsystems | verified | high | 100% | 2026-09-08 | strategic-turn-internals.md 3.3 had the SHAPE of the output formula wrong, and 0x007483b0 is the over-harvest RESOURCE demand (its population is Res, not Pop; species +0x4c is ADDED, not a cap) - both corrected in place. AND LANE N CORRECTED ITSELF BEFORE VM TIME: its own 4.2 prediction mis-evaluated the strip-mine branch as a min when it is a SUBSTITUTION, so Gamma Cephei's fraction is 2.0 and its resource term 9000, not 4500 - caught by the FIRST HOST TEST, before any VM run. Static-only and labelled hypotheses (unexercised): the over-harvest branch and its max(v,1) floor (SRoh is 0 on every save), the station factor, the slave term, both morale branches, addiction, the capacity surplus | | bankruptcy divisor FIXED | engine | verified | high | 100% | 2026-09-08 | kBankruptcyInterestDivisor = -0.15000000596046448 named in the header, with tests at maxIncome 3/6/9 (-19/-39/-59, WHERE IT FIRST BITES) and at 238592 and 3000001. Closes the defect lane Y found and correctly escalated from the repo's "one ulp on large empires" understatement to "6 of 25 corpus records get a different BnkEl than the game wrote" | | VM AUTOSAVE CONTENTS OVERWRITTEN (recoverable) | meta | verified | high | 100% | 2026-09-08 | Lane N reported this rather than papering over it: its two End Turns overwrote the three autosave CONTENTS on VM140 (they now hold its turn-3 Human and turn-6 Zuul states). THE FILE SET - names and count - IS UNCHANGED, so every documented Load-dialog row position still holds. Lane O's byte-identical autosaves are gone from the VM AND LANE N DID NOT FAKE A RESTORE from lane F's older, differently-sized snapshot. NOT A REAL LOSS: all 11 curated saves live in the REPO (verify/results/saves/), and the determinism-oracle hashes are REPRODUCIBLE by loading ref-turn2 and ending one turn - any VM lane can regenerate them. Record the regenerated hashes when someone next does | +| FUN_007066c0 is NOT a path finder | control-flow | verified | high | 95% | 2026-09-08 | Lane P2, instruction-verified over raw .text to the next function start (Ghidra's size was RIGHT on all four functions this time - recorded because rule 17 exists, not because it was violated). THERE IS NO GRAPH SEARCH: no frontier, no visited set, no relaxation, no recursion. It is a PER-LEG CLASSIFIER AND VALIDATOR over a destination list THE CALLER ALREADY CHOSE - a helper classifies each consecutive pair and returns the waypoint type; this walks the chain, ORs the flags, records the first failing index, draws down fuel and resets it at refuelling systems. CDECL WITH 8 ARGS (not thiscall), and it ALWAYS RETURNS TRUE past the null checks - all outcome data is in out-params. The only graph structure in the whole subtree is a SINGLE-HOP ADJACENCY QUERY over a triangular per-player bit matrix; a multi-hop node route in a save is n waypoints, one per hop. NEW FAILURE MODE: two helpers READ ebx/esi LIVE-IN WITHOUT EVER WRITING THEM, so reading them as plain cdecl gives nonsense (adjacent to lane B5's tail-call-thunk-in-an-argument-list trap) | +| WAYPOINT TYPE 2 EXPLAINED - it is a NAMING ERROR | verify | verified | high | 100% | 2026-09-08 | THREE LANES CIRCLED THIS AND THE ANSWER IS THAT THE NAME WAS WRONG. A 7-entry jump table re-verified from raw bytes maps SPECIES to waypoint type: **Human->3, Hiver->0, Tarkas->1, Liir->2, NPC->0, Zuul->3, Morrigi->6**. TYPE 2 IS THE LIIR DRIVE. Lane O's 20+ observations were all on the two NODE-DRIVE races, both forced to 3 - so nothing is unreachable and NOTHING NEEDS FIXING. "Node line" is the wrong name for type 2 (its speed profile is the STUTTER_* curve, slowest at a star), which is exactly why IsNodeWaypoint accepts only 3 - CONSISTENT, NOT ANOMALOUS. This also puts B4's "the type-2 node-line step is wrong by construction" in doubt: that claim was made about a type it had misidentified. Exercising type 2 needs a LIIR FLEET, not a node line - far cheaper than the save lane M was contemplating | +| OrderFleetMove's three failure bits | control-flow | verified | high | 100% | 2026-09-08 | `test DWORD [ebp-0x10],0x418` at 0x865499 = 0x400|0x010|0x008, exactly lane B5's three. 0x008 destination is a fleet on a node route and no intercept could be solved (you must sit at one end of its line); 0x010 a ship's drive is destroyed, CLEARED on a successful gate leg (a gate ignores dead drives - the same rule B5 found from the retreat side); 0x400 the destination point is not one this player may use. The OTHER NINE BITS are advisory route-quality complaints the server commits anyway. PROOF OF THE SPLIT: the UI caller does a DRY RUN and tests `flags != 0` to raise a confirm dialog, while the server tests `& 0x418`. Notably 0x004 (gate traffic exceeded) DOES NOT REFUSE - a plan can be installed over capacity with a type-0 waypoint | +| nav precision: one float decides a failure | verify | verified | high | 100% | 2026-09-08 | Lane P2 ranked precision by CONSEQUENCE rather than listing it. FUN_006ffa00 is the only float that decides a failure: three f32 deltas, sum-of-squares narrowed ONCE, range = min(rangeLeft, tankCap) from f32 slots - and then `fmul st(0),st` computes r*r AND NEVER STORES IT. The comparison is `f32(sumsq) <= (double)r^2`, NON-STRICT. Writing f32(r*r) - the natural mirror of every other narrowing here - DISAGREES EXACTLY AT THE BOUNDARY. Leg length is Mars_Vec3_Length INLINED (lane M's five narrowings verbatim, plus a third narrowing on the subtraction). fpu_cw=0x127f throughout | +| P2 predictions: written first, then checked offline | verify | verified | high | 100% | 2026-09-08 | Three predictions written from the disassembly BEFORE checking, then run against all 11 saves: **58 waypoints, 46 flight plans, 0 failures**. nrt == Wpt on all 57 type-3 records; the nrf chain closes through `pnd` across the three-hop plans; nrp splits exactly as the branch structure says - Human (cannot bore) has only real indices, Zuul has a MIX of real indices and -1, which is what the bore path writes. AND A BONUS: turn3-state.sav's Tp=1 with nrt{-1,0,0} can only be a TARKA fleet by species elimination - a live confirmation of a SECOND table row from a save nobody was looking at. Also corrected: type 5 is a HIVER GATE THROW at a gateless system within CstR, NOT the Zuul bore; CstR now has a reader; GFlags(+0xdc) is the per-player GATE MASK (closing lane B5's open item); `pnd` is the node transit's ORIGIN id; FtTrans is a SECOND SAVED COPY of wpts[0].Tp | +| P4: a ~1-minute VM test of a predicted ORIGINAL bug | verify | backlog | — | 0% | 2026-09-08 | Lane P2, for whoever holds VM140: order a fleet to the system it is ALREADY AT, then onward. The leading-destination drop happens in the solver's frame only, so types[]/routes[] end up SHIFTED BY ONE and the last slot is never written. PREDICTED SYMPTOM: the final waypoint saves as Tp 0 with an empty route. Note the framing - a CLEAN result FALSIFIES the reading rather than merely failing to confirm it | +| P2 honest boundary | meta | verified | high | 100% | 2026-09-08 | Fully read: FUN_007066c0, three siblings and 20 helpers. NOT read: FUN_00705d60's tanker fold past ~0x90 bytes (the input to EVERY range decision, the largest remaining hole in the NUMBERS), the node bore, the ranking term and hash walk inside FindNodeLine, the relation scale, and MoveFleet's two classifier call sites. THREE INDIRECT EDGES ON THE MAIN LINE UNRESOLVED, the most important being `(fleet->galaxy)->vft[1]()`, the node-graph getter the whole node branch hangs off - vtable_map.py plus an image-wide E8/E9 sweep confirm the function has exactly two direct callers and no tail-call thunks, but those three are OUTBOUND edges lane V2's inversion does not reach. AND NOT ONE LEG HAS EVER BEEN OBSERVED EXECUTING UNDER AN INSTRUMENT - only its saved output has. Also caught internally: one test check was VACUOUS (the float32-squaring probe never fired) - found and fixed with an unconditional witness | diff --git a/findings/subsystems/multiplayer-gamespy.md b/findings/subsystems/multiplayer-gamespy.md new file mode 100644 index 0000000..5927109 --- /dev/null +++ b/findings/subsystems/multiplayer-gamespy.md @@ -0,0 +1,487 @@ +# Multiplayer & GameSpy — what a private replacement server must answer + +- **Type:** subsystem +- **Address / RVA:** GameSpy SDK occupies `0x00408000`–`0x00422000` (ImageBase `0x00400000`); game-side callers in `0x0076xxxx`–`0x007cxxxx` and `0x0089xxxx` +- **RTTI / vftable:** `Game::AutoJoin` `0x00a218f4`, `Game::StrategyJoin` `0x00a21904`, `Game::GameBrowserPanel::ManualJoinDialog` `0x00a22604` +- **Status:** mapped (static); **no VM run yet** +- **Confidence:** high on everything marked **[V]**; the **[I]** items are inference +- **Owner / date:** lane G2 · 2026-09-08 + +Notation used throughout: **[V]** = read out of this binary. **[I]** = inferred, including from +public GameSpy SDK documentation. Rule 18's corollary was honoured — `strings-and-config.md` §5 and +`ui-screen-map.md` F12/F13 already had the architecture; this note is the code behind it. + +--- + +## 0. The headline: GameSpy is **not** on the critical path to joining a game + +Three GameSpy-free paths into a multiplayer session exist, all reachable in the shipped UI: + +| Path | Entry | GameSpy services touched | +|---|---|---| +| **Manual join / Favorites** | F12 → *Join Manually* → `Game::GameBrowserPanel::ManualJoinDialog` | **none** | +| **Command line** | `"Sword of the Stars.exe" /join ` | availability check only, and it fails open | +| **LAN browse + join** | F12 → *LAN* page | **none** — UDP broadcast on the game's own ports | + +**[V]** `Game_ManualJoin_OnAccept` (`0x0076ef90`) validates the typed text with +`Game_ParseHostAddress` (`0x008f64d0`) and then calls +`App_StartJoin(params, address, serverBrowser = NULL)` (`0x00899e30`). A NULL browser makes +`Game::StrategyJoin::ctor` (`0x00777b10`) take its `0x0077c71` arm, setting state `+0x80 = 1`; +`StrategyJoin::Think` (`0x00765810`) dispatches state 1 through the jump table at `0x00765948` to +`App_ConnectToStrategyHost` (`0x0089a7e0`), which opens the game's own UDP connection to the typed +address. Nothing in that chain calls the availability check, `peerInitialize`, the server browser, or +NAT negotiation. + +**[V]** Cross-check from the other side: `GameSpy_NNBeginNegotiationWithSocket` (`0x00412590`) has +exactly **two** call sites in the whole image — `Game_StrategyJoin_QueryViaServerBrowser` +(`0x00770e70`, the browser-mediated join) and the host side (`0x00787cb0`). The direct-address arm +reaches neither. **NAT negotiation is not on the direct-join path.** + +**[V]** LAN hosting skips GameSpy entirely. `Game_StrategyHost_StartReporting` (`0x007c2dd0`) branches +on `this+0x2c == 1 && this+0x30 != 0`; on that branch it calls `qr2_init_socket` (`0x0041d1a0`) +directly with `ispublic = 0` and never touches the Peer SDK. The same predicate at `0x007c5088` in +`0x007c4f90` **skips `GSIStartAvailableCheck` altogether**. So in that mode the host never resolves +any `gamespy.com` name. + +**[V]** LAN browsing is a local broadcast. `LANPage::CreateBrowser` (`0x0077dde0`) calls +`ServerBrowserNew(..., lanBrowse = 1)` with **no availability gate at all**, and +`LANPage::Refresh` (`0x0077de50`) calls +`ServerBrowserLANUpdate(sb, 1, g_LanScanPort, g_LanScanPort + g_LanScanPortRange)` — defaults +**3369..3370**, i.e. a UDP broadcast answered by the host's own QR2 socket. No master server. + +**Independent corroboration.** Kerberos staff (`castewarkp`) said the same thing on the official +forums on **2012-12-08**, when SOTS1's GameSpy backend died — 18 months *before* the general 2014 +GameSpy shutdown, because SOTS1's agreement ran through the defunct Lighthouse Interactive: + +> "you can still play MP SotS1 using direct connection in the game … For direct connect to work, host +> needs to start their game in LAN mode, then other can join direct connect. **It will not work in +> Internet mode.**" + +That is exactly the `this+0x2c == 1 && this+0x30 != 0` predicate, arrived at from the other +direction, and it is the strongest evidence available that the direct path still works. PCGamingWiki +independently lists SOTS as `online play = false, lan play = true, direct ip = true, dedicated = +true`, with the community workaround being "LAN mode + forward port **3369**" — the same port +`Game_LoadNetworkConfig` yields (§3). Same thread, user `Vinco`: *"The dedicated server does not seem +to work. Games must be hosted from the SOTS client."* — a reproducible bug worth its own lane. + +**Correct the date in the campaign's framing:** SOTS1's backend went dark in **December 2012**, not +May 2014. Any dated evidence should be read against that. + +**Consequence.** Restoring *finding* games needs a replacement GameSpy backend. Restoring *playing* +games needs nothing: two boxes on a LAN, or a typed IP over a VPN/tunnel, already work as shipped — +if the direct path is not broken by something we have not measured. That is the weekend-versus-project +fork, and it lands on "weekend", with the master server as an optional convenience layer on top. + +### The availability check fails **open**, not closed + +**[V]** `GSIStartAvailableCheck` (`0x0040a060`) sets its socket to `-1` up front, and on a DNS failure +(`gethostbyname` returns NULL at `0x0040a0f0`) it returns with the socket still `-1`. +`GSIAvailableCheckThink` (`0x0040a210`) then takes the `0x0040a2e5` arm and returns **1**, which +`AutoJoin::Think` (`0x00778a90`) records as *available* (`sete cl` on `eax == 1` at `0x00778b11`). +The two-attempt timeout path (`0x0040a2bb`, 2000 ms, one retry) also lands on the same **return 1**. + +So the string `MATCHINGSERVICE_UNSUPPORTED` fires only when a server actually **answers** with the +"unavailable" bit set — which is what GameSpy did to shut titles down in 2014, and which nothing does +today. **This corrects an inference in `ui-screen-map.md` §5**: the 2017 build does not "know" GameSpy +is dead; it carries the string GameSpy's own kill-switch would have triggered. Whether the dialog +appears today is a VM question, not a static one (see §7). + +--- + +## 1. Which GameSpy services are linked, and which are called + +The SDK sits in one contiguous run of `.text`. Attribution below is by the string constants each +function references **[V]**, with the SDK component names **[I]** from the public SDK layout. + +| Component | Code range | Linked | Called from game code | Endpoint | +|---|---|---|---|---| +| **gsAvailable** (availability check) | `0x0040a060`–`0x0040a320` | yes | yes — 3 sites (`AutoJoin`, `GameBrowserPanel`, `StrategyHost`) | `.available.gamespy.com` **UDP 27900** | +| **serverbrowsing / SB** (master list) | `0x0041c700`–`0x00421000` | yes | yes — via `ServerBrowserNew` `0x00420060` | `.ms.gamespy.com` **TCP 28910** | +| **QR2** (query & reporting, heartbeat) | `0x0041d000`–`0x0041f000` | yes | yes — `qr2_init_socket` `0x0041d1a0`, `qr2_register_key` `0x0041e730` ×16 | `.master.gamespy.com` **UDP 27900** | +| **NatNeg** | `0x00412200`–`0x00412a80` | yes | yes — but only on the browser-mediated join and on the host | `natneg1/natneg2.gamespy.com` **UDP 27901** | +| **Peer SDK** (rooms + reporting wrapper) | `0x00414700`–`0x0041a300` | yes | yes — `peerInitialize` `0x00416be0`, `peerSetTitle` `0x00416cc0`, `peerStartReportingWithSocket` `0x00416a50` | wraps peerchat + QR2 + SB | +| **peerchat** (IRC-derived chat) | `0x00408000`–`0x00409f00` | yes | yes — F12 *Internet Chat* page | `peerchat.gamespy.com` **TCP 6667** | +| **ghttp** (GameSpy HTTP client) | `0x0040b000`–`0x0040e000` | yes | yes — 5 sites, all in the App layer (`0x00898ce0`, `0x00899cd0`, `0x0089a1c0`, `0x0089cba0`, `OnTick`) | **[I]** the MOTD fetch from `www.kerberos-productions.com/motd`; no `gamespy.com` host is passed to it | +| **nonport / socket helpers** | `0x0040e900`–`0x0040ed50` | yes | yes — heavily, by the in-house `NetworkManager` | n/a | + +**Not present at all [V]** — no string, no host, no code: + +- **GP / GPCM / GPSP** (presence, profiles, account login). No `gpcm.gamespy.com`, no + `gpsp.gamespy.com`. **SOTS has no GameSpy account login.** A replacement backend does not need one. +- **motd.gamespy.com**, **gamestats**, **sake**, **atlas**, **keymaster / gcdkey**. +- **CD-key validation of any kind.** There is no `CDKEY` substring anywhere in the 49,781 extracted + strings; the `NETERROR_*` family is 13 entries and none of them is a key error, and there is no + `STARTUPERROR_*` family at all. **This corrects two claims in `ui-screen-map.md`** (F12's + "`NETERROR_*` (21 incl. 6 CD-key)", and §5's "CD-key strings … survive"). Nothing about CD keys is + on any path, so the brief's constraint about not touching key checks has nothing to bite on. + +Also present but unreferenced by game code **[V]**: the `gsi_am_rating` auto-match keys +(`0x0041abd0`, `0x0041aee0`) are SDK-internal only. + +--- + +## 2. The identity constants + +**[V]** A pointer table in `.rdata` at `0x00a35cc4`–`0x00a35d00` holds the app's global string +constants. The GameSpy identity sits in it: + +| Slot | Value | Role | +|---|---|---| +| `0x00a35cd4` | **`swordots`** | GameSpy **gamename** — passed as `peerSetTitle` `title` *and* `sbTitle`, as `ServerBrowserNew`'s `queryForGamename` *and* `queryFromGamename`, as `qr2_init_socket`'s `gamename`, and as the availability-check gamename | +| `0x00a35cd8` | **`Z5gR9Z`** | GameSpy **secret key** — passed as `peerSetTitle` `secretKey` *and* `sbSecretKey`, as `ServerBrowserNew`'s `queryFromKey`, and as `qr2_init_socket`'s `secret_key` | +| `0x00a35cdc` | `"1381"` | the GameSpy **game id** — see below. In this binary it is `atoi`'d once in `OnStartup` (`0x0089d6fb`) and passed to `Startup`→`Create`→`NetworkManager`, landing in `0x00b2e624`; the SDK itself never reads it. | +| `0x00a35ce0` | `"10759"` | **zero references anywhere in the image.** Dead constant. **[I]** a second GameSpy product id (GameSpy Arcade SKU), never read by this build. | +| `0x00a35ce4`..`0x00a35cf4` | `openwaiting`, `closedwaiting`, `openplaying`, `closedplaying`, `exiting` | the five values SOTS reports for the QR2 `gamemode` key | +| `0x00a35d00` | `"1.8.1"` | game version string, parsed into the packed version word | + +`swordots`/`Z5gR9Z` is the pair a replacement master server needs. Both are reachable only through +those two `.rdata` slots — the string literals themselves have no direct code xref, which is why a +naive "find the constant near the SDK init" sweep misses them. + +**External corroboration of the whole tuple.** The published GameSpy game table that every revival +project seeds from carries the row `id 1381 · gamename swordots · secretkey Z5gR9Z · "Sword of the +Stars" · queryport 6500`. It appears identically in OpenSpy's `openspy-web-backend/sql/Gamemaster.sql`, +UniSpyServer's `common/UniSpy_pg.sql`, 333networks' `data/SupportedGames.json` and Luigi Auriemma's +`gslist.cfg`. Those four are one lineage, not four witnesses — but they are independent of *this* +binary, and they agree with it on gamename, key **and** on `1381` being the game id rather than an +arbitrary Kerberos constant. Treat the binary as authoritative and the table as confirmation. + +Note the divergence worth watching: the seeded row says `queryport 6500` (the SDK default), whereas +SOTS reports on its own game socket. **[V]** `qr2_init_socket`'s `boundport` argument comes from +`getsockname` on the already-bound game socket (`0x0040aa30`), i.e. **3369**, and the heartbeat's +source port is the same. A master server should therefore learn the real port from the heartbeat and +ignore the 6500 default; if any implementation trusts the column instead, it will hand clients a dead +port. Flagged for the VM test. + +**Not the identity [V]:** `aFl4uOD9sfWq1vGp`, `qJ1h4N9cP3lzD0Ka` and `14saFv19` (`0x009e1b38`, +`0x009e1b4c`, `0x009e1b64`) are referenced only from **inside** the SDK's peerchat crypt functions +(`0x00417320`, `0x004173e0`) and never from game code. **[I]** They are the SDK's built-in peerchat +`CRYPT des` constants, identical in every GameSpy title. Do not mistake them for the game key. + +### The version word + +**[V]** `0x00b2d510` is a packed 32-bit build word assembled in `0x0089cc70`: bits 16..23 carry +`minor << 4 | major` parsed from `"1.8.1"`, byte 1 carries edition flags from `0x00496e00` +(Collector's Edition / Complete Collection / Argos Naval Yard), and the low nibble a build flavour. +It is passed as `peerSetTitle`'s `sbGameVersion`, and stamped into the join handshake by +`App_ConnectToStrategyHost` (`0x0089a881`). `NETERROR_INVALIDVERSION` exists. + +**Answer to "will a version check bite":** yes, but not against the *server* — it is a client-to-host +check, and both ends will be the same GOG 1.8.1 build. A replacement master server only has to carry +the value through as an opaque `gamever` field. **[I]** on the exact comparison site; I did not chase +the handshake comparison. + +--- + +## 3. Hostnames and ports — the hosts-redirect target list + +All **[V]** unless noted. Every hostname is built with `sprintf` from the gamename, so the concrete +set for `swordots` is: + +| Host | Port | Proto | Built at | Purpose | +|---|---|---|---|---| +| `swordots.available.gamespy.com` | **27900** | UDP | `0x0040a0ac` | availability check | +| `swordots.master.gamespy.com` | **27900** | UDP | `0x0041d38e` | QR2 heartbeat (server → master) | +| **`swordots.ms5.gamespy.com`** | **28910** | TCP | `0x004208cc` | server-list fetch (client → master) | +| `natneg1.gamespy.com` | **27901** | UDP | `0x00412540` | NAT negotiation | +| `natneg2.gamespy.com` | **27901** | UDP | `0x00412562` | NAT negotiation (secondary) | +| `peerchat.gamespy.com` | **6667** | TCP | `0x00417b9c` | chat rooms (F12 *Internet Chat*) | +| `www.kerberos-productions.com/motd` | 80 | TCP | — | MOTD, not GameSpy | + +**The `ms5` is derived, not guessed.** `SBServerListConnect` (`0x00420840`) folds the gamename to an +index: `h = 0` then per character `h = tolower(c) - h * 0x63306ce7` (32-bit wrap), and +`index = (unsigned)h % 20`. Re-implementing that fold over `"swordots"` gives `h = 0xfb2f91c5`, +`index = 5`. Only `swordots.ms5.gamespy.com` is ever contacted; the other nineteen never are. + +**Two override hooks exist [V]**, and are worth knowing about because they may make a hosts file +unnecessary: `0x00b085b0` overrides the availability hostname (checked at `0x0040a099`) and +`0x00b09440` overrides the master hostname (checked at `0x0042089d`). I did **not** find a game-side +writer for either — **[I]** they look like the SDK's `gsiSetAvailableCheckHostname` / +`SBSetMasterHostname` globals left settable but unused. They are, however, a clean patch/hook point. + +### SOTS's own ports (nothing to do with GameSpy) + +**[V]** `Game_LoadNetworkConfig` (`0x005a0610`) reads ini section **`[Network]`**: + +| Key | Default | +|---|---| +| `HostPort` | **3369** | +| `CombatHostPort` | **3370** | +| `LanScanPort` | **3369** | +| `LanScanPortRange` | **1** | +| `HeartbeatPeriod` | 15000 ms | +| `ConnectionTimeout` | 45000 ms | +| `MaxTxMessageSize` | 512 | +| `CombatLatency` | 1000 ms | +| `SyncCheckStrategy` / `SyncCheckCombat` | True | +| `SyncLogStrategy` / `SyncLogCombat` | False | + +**[V]** The import table has `connect`, `send`, `recv`, `sendto`, `recvfrom`, `bind` but **no +`listen` and no `accept`** — the game's own transport is **UDP only**. TCP appears only as client +connects (peerchat, the SB list, ghttp). + +--- + +## 4. Wire formats + +### 4.1 Availability check — fully specified, ~20 lines of server + +**[V]**, byte for byte, from `0x0040a060` and `0x0040a1a0`. + +*Request* (client → `swordots.available.gamespy.com:27900/UDP`), length `strlen(gamename) + 6`: + +``` +09 00 00 00 00 's' 'w' 'o' 'r' 'd' 'o' 't' 's' 00 +``` + +*Response* (must come from the same address:port the request went to, and be ≥ 7 bytes): + +``` +FE FD 09 +``` + +The client tests **only the low byte** of `status`: + +- `status & 1` → `GSIACUnavailable` (2) → `MATCHINGSERVICE_UNSUPPORTED` +- else `status & 2` → `GSIACTemporarilyUnavailable` (3) → `MATCHINGSERVICE_TEMP_UNAVAILABLE` +- else → `GSIACAvailable` (1) → proceed + +So **`FE FD 09 00 00 00 00` is "yes, this game is alive"**. Timeout is 2000 ms with one retry. + +### 4.2 QR2 — query, reporting and the custom keys + +**[I]** The query/heartbeat protocol itself is the documented GameSpy QR2 standard: `\status\`, +`\basic\\info\`, `\final\`, `\echo\test`, `splitnum`, `queryid`, and the challenge/response keyed on +the secret key. All those literals are **[V]** present at `0x0041ea00`, `0x0041eeb0`, `0x0041f560`, +`0x00421e90`, `0x0041cae0`. The client-visible failure string is **[V]** +`"No challenge value was received from the master server."` (`0x0041e550`). + +**Where SOTS is custom [V]:** `Game_RegisterQR2Keys` (`0x00898bf0`) registers sixteen game keys — +a replacement browser/master must carry these opaquely, and our own tooling can read them: + +| id | name | | id | name | +|---|---|---|---|---| +| 50–57 | `slot0` … `slot7` | | 61 | `turn` | +| 58 | `numslots` | | 62 | `scenario` | +| 59 | `mapshape` | | 63 | `settings` | +| 60 | `numsys` | | 64 | `slot_` | +| | | | 65 | `ranks` | + +Standard keys SOTS also reports **[V]** (strings present in the QR2 block): `hostname`, `gamename`, +`gamever`, `hostport`, `mapname`, `numplayers`, `maxplayers`, `password`, `gamemode`, `statechanged`, +`natneg`, `localip%d`, `localport`, `publicip`, `publicport`. `gamemode` takes one of +`openwaiting` / `closedwaiting` / `openplaying` / `closedplaying` / `exiting`. + +### 4.3 Server list (SB) + +**[I]** TCP 28910, the documented GameSpy serverbrowsing-v2 request/challenge/encrypted-list +protocol, keyed on `Z5gR9Z`. **[V]** only the endpoint construction, the port, and the fact that the +same gamename/key pair is what is handed to `ServerBrowserNew`. I did not decode the request framing +or confirm the encryption type — that is the one place a replacement server has real work, and it is +also the place the open-source reimplementations have already done it. + +**[V]** The fields the join path reads back off an `SBServer`: public IP/port (`0x0041f470` / +`0x0041f480`), private IP/port (`0x0041f4f0` / `0x0041f520`), a "same NAT" predicate (`0x0041f4b0`), +a "can connect directly" predicate (`0x0041f4d0`), and `SBServerGetIntValue(server, "password", 0)` +(`0x0041fd00`). + +### 4.4 The in-game protocol — entirely SOTS's own + +**[V]** Once connected, nothing is GameSpy. UDP on `HostPort` (3369), the in-house `Network:` group +layer (`DoHost` / `DoConnect` / `DoDisconnect`, host migration, proxies), `SNM*` strategy messages, +`FNM*` chunked file transfer. Documented already in `strings-and-config.md` §5. +`Game_OnJoinGame_ChooseAddress` (`0x00772f60`) logs the three cases: + +- *"Server %s:%d is behind same NAT."* → use the private address +- *"Server %s:%d is behind NAT, but can still connect directly to it."* → use the public address +- *"Server requires NAT negotiation."* → only then is NatNeg entered + +--- + +## 5. Ranked revival plan + +**Tier 0 — no server at all (hours).** Two clients on a routable path (LAN, or WireGuard/Tailscale), +host forwards UDP 3369 and 3370, joiner types the IP in *Join Manually*. Predicted to work with zero +GameSpy anything. This is the thing to try first and it is the thing most likely to just work. + +**Tier 1 — availability responder (an afternoon).** One `hosts` line +`127.0.0.1 swordots.available.gamespy.com` plus a ~20-line UDP server on port 27900 that answers +`FE FD 09 00 00 00 00`. This unblocks `AutoJoin`, the *Internet* page and `StrategyHost`'s Internet +mode from the "matching services not available" refusal. Because the check already fails open on DNS +failure, Tier 1 may turn out to be a **no-op** — that is exactly what the VM test in §7 settles, and +finding it unnecessary is a good outcome. + +**Tier 2 — LAN discovery instead of a master (an afternoon).** Nothing to build: LAN browse already +works. If the two boxes are on a routed VPN rather than a broadcast domain, either bridge the segment +(so `255.255.255.255:3369` reaches the host) or fall back to Tier 0's manual join. + +**Tier 3 — real master server: configuration, not code (a day).** This was expected to be the +weeks-long piece. It is not. Two maintained, self-hostable projects implement **exactly** the service +set SOTS needs, and **both already ship the `swordots` / `Z5gR9Z` row**: + +| | **OpenSpy** (`openspy/openspy-core`, C++) | **UniSpyServer** (`GameProgressive/UniSpyServer`, Python, AGPLv3) | +|---|---|---| +| availability check (UDP 27900) | `code/qr/server/v2/handle_available.cpp` — replies `FE FD 09` + `htonl(disabled_services)`, byte-identical to §4.1 | QR v2, `AVALIABLE_CHECK = 0x09` | +| QR2 heartbeat / challenge (UDP 27900) | `code/qr/server/v2/handle_{heartbeat,challenge,keepalive}.cpp` | `protocols/gamespy/query_report/v2` | +| server list, SB **v2** / TCP 28910 | `code/serverbrowsing/server/V2Peer.cpp` + `sb_crypt` (GOA/enctypex) | `protocols/gamespy/server_browser/v2` (v2 only — fine, SOTS is v2) | +| NatNeg (UDP 27901) | `code/natneg/server/handlers/*` (needs 3 IPs) | `protocols/gamespy/natneg` | +| peerchat (TCP 6667) | `code/peerchat/` incl. `handle_crypt.cpp` | `protocols/gamespy/chat` | +| deploy | `openspy/compose` docker-compose; Redis + RabbitMQ + MySQL + MongoDB + .NET 8 backend | docker-compose, Postgres + Redis, ships a dnsmasq compose for the DNS redirect | +| add a title | one row in `games` (`gamename`, `secretkey`, `queryport`, `keylist`, `disabledservices`) then `POST /v1/Game/SyncToRedis` — **no code** | one DB row | + +**So the procedure is: `docker compose up`, confirm the seeded `swordots` row, point DNS at it.** +No protocol work unless something diverges. + +Two caveats, both **[I]** from the projects' own source and both testable: + +- **`swordots` is in OpenSpy's *database dump*, not on its *supported-games* list** (132 tested + titles, no SOTS). Seeded ≠ verified. Expect to be the first to exercise it. +- **The public openspy.net availability responder answers `status 0` for any gamename at all**, + including nonsense — so a successful availability check against the public instance proves nothing + about whether `swordots` is really registered there. The real gates are the QR2 challenge and the + SB v2 handshake, both keyed on `Z5gR9Z`, and peerchat's `CRYPT` (which OpenSpy's + `handle_crypt.cpp` rejects outright when the DB secret key is empty). **Self-host; do not test + against the public instance and conclude anything from it.** +- The seeded `keylist` for `swordots` is the generic nine (`country/gamemode/gametype/gamever/ + hostname/mapname/maxplayers/numplayers/password`) and does **not** include SOTS's sixteen custom + keys from §4.2. Widen the row from a live heartbeat capture, or the browser will show games with + no slot/turn/scenario detail. + +**333networks is not a fit [V-by-their-docs].** It implements the **GameSpy v0** master only — UDP +27900 beacons in, TCP **28900** list out. No SB v2/28910, no NatNeg, no peerchat, no availability +responder. Its `SupportedGames.json` does contain `swordots`/`Z5gR9Z`, but every entry in that file +has `"port": 0` — it is a bulk import of the same leaked table, not a support claim. Likewise +`gsmaster` (enctype 0/1 only), `mgmse` (archived, v1 master), `PRMasterServer` (BF2-specific). + +**Two further assets worth knowing about:** `GameProgressive/UniSpySDK` is a cleaned, still-building +copy of the original GameSpy SDK source — the best available reference for the campaign's +functional-reimplementation north star, and a much better way to name the SDK functions in this +binary than guessing. `anzz1/openspy-client` is an in-memory client-side DNS shim (no file patching) +with per-title headers; **there is no `game_sots.h`**, so for our lab the hosts-file route is +simpler. + +**There is no SOTS-specific revival project.** Checked across OpenSpy's supported list, +openspy-client's headers, GitHub topic search, PCGamingWiki and the Steam/GOG threads. + +**Tier 4 — peerchat and NatNeg (optional, and skippable).** Chat rooms are cosmetic. NatNeg only +matters for two players who are *both* behind NAT and unwilling to port-forward — and it is bypassed +whenever the host is directly reachable, which a VPN guarantees. + +**Deliberately out of scope:** GP/GPCM/GPSP login and CD-key auth, because §1 shows this binary +contains neither. + +### Testing needs two clients on one Windows guest — and the game ships the switch for it + +**[V]** `WinMain` (`0x0089ddaf`) does `CreateMutexA(NULL, TRUE, "Kerberos_SwordOfTheStars_Mutex")` +and, on `GetLastError() == ERROR_ALREADY_EXISTS (0xb7)`, walks argv doing a case-insensitive compare +against the literal **`/concurrent`**. On a match it *continues*; otherwise it `FindWindowA`s the +existing `Kerberos_SwordOfTheStars_WndCls`, foregrounds it, and exits. + +So a second instance on the same box is a supported, shipped configuration. **[V]** the switches +present in the image are `/join`, `/concurrent`, `/startup:`, `/motd_`, `/tell`. Command-line parsing +is `Game_ParseJoinCommandLine` (`0x0089d280`), and `/join`'s argument goes into `Game::AutoJoin`. + +**[V]** `Game_ParseHostAddress` accepts **dotted quad only** — `sscanf("%d.%d.%d.%d:%d%1s")` must +yield 4 or 5 fields with every octet ≤ 255. **Hostnames are rejected.** Use `127.0.0.1:3369`, never +`localhost`. + +**[V]** A **dedicated server** (`sots_server.exe`, `Dedicated Server Launchpad.exe`, `SERVERERROR_*`) +ships alongside the client — the cleanest host for a two-client test, and it removes the mutex +question entirely. It is not in `dumps/` and has not been examined. + +--- + +## 6. Cross-refs + +- Callers/callees and the full address list: `ghidra/addresses.d/lane-g2.json` (43 entries). +- Related: [[strings-and-config]] §5 (the string-level architecture), [[ui-screen-map]] F12/F13 + (the screens), [[data-model]] / `objects/layouts.md` (`Game::StrategyHostParams`, + `Game::StrategySessionParams`). +- Corrections filed against [[ui-screen-map]]: the CD-key claim (§1 here) and the + "the 2017 build knows GameSpy is dead" inference (§0 here). + +--- + +## 7. Falsifiable prediction, written before the VM run + +Written 2026-09-08, before VM140 was available to this lane. Build: GOG 1.8.1, single Win10 guest. + +**Setup.** One guest. Instance A: launch normally, *Host Multiplayer* → **LAN**, create a 2-player +custom game, note the port from `sots.ini`/`[Network] HostPort` (expect 3369). Instance B: +`"Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369`. No hosts file, no server, no network +changes. Capture with Wireshark on the loopback and the guest NIC for the whole run. + +**Predictions.** + +1. **P1 — the join succeeds with zero GameSpy traffic.** Instance B reaches the lobby (F13) and + appears in a slot on instance A. The capture shows **no** packet to UDP 27900, UDP 27901, TCP + 28910 or TCP 6667, and **no** DNS query for any `*.gamespy.com` name from instance B after + `/join` is parsed. All traffic is UDP on 3369 between the two instances. + *Falsified if:* any `gamespy.com` DNS lookup or any packet to 27900/27901/28910/6667 appears on + the join path, or the join fails with `NETERROR_NOCONNECTION` / `NETERROR_TIMEDOUT` while both + instances are up. + +2. **P2 — the availability check runs, gets no answer, and reports *available* anyway.** On entering + F12 (Join Multi-Player) the capture shows exactly one DNS query for + `swordots.available.gamespy.com`; if it resolves, one UDP datagram to port 27900 whose payload + begins `09 00 00 00 00 73 77 6f 72 64 6f 74 73 00`, retried once at ~2 s. Either way the UI does + **not** show `MATCHINGSERVICE_UNSUPPORTED`; at most it shows an empty Internet list. + *Falsified if:* the "Online support … is no longer available" dialog appears, which would mean + something is answering the check with the unavailable bit — in which case §0's fail-open reading + is wrong, or a stale wildcard DNS record is still live, and Tier 1 becomes mandatory rather than + probably-unnecessary. + +3. **P3 — LAN discovery finds the host without any server.** With A hosting in LAN mode, B's F12 + *LAN* page lists the game after a refresh, and the capture shows a UDP broadcast to + `255.255.255.255:3369` (`\status\`-family QR2 query) answered by A on the same port. + *Falsified if:* the LAN list stays empty while a direct manual join to the same address succeeds + — which would mean the LAN sweep uses a port or a mechanism I have mis-read. + +4. **P4 — a self-hosted OpenSpy or UniSpyServer, with a hosts file and no code changes, makes the + *Internet* page work.** With `swordots.available.gamespy.com`, `swordots.master.gamespy.com`, + `swordots.ms5.gamespy.com`, `natneg1/2.gamespy.com` and `peerchat.gamespy.com` all pointed at the + container, a host started in Internet mode appears in the client's Internet list, and joining it + from the list succeeds. + *Falsified if:* the QR2 challenge or the SB v2 handshake fails — the visible symptom would be + `"No challenge value was received from the master server."` or an Internet list that stays empty + while the availability check reports available. Either would mean the seeded row or the enctype + assumption is wrong, and Tier 3 stops being configuration. + *Partial-credit case to watch:* the game appears in the list but joining hands back the wrong port + (6500 rather than 3369) — that is the `queryport` column, a one-row fix, not a protocol problem. + +5. **P5 — the mutex bypass works.** Instance B started with `/concurrent` reaches the main menu + rather than foregrounding instance A. + *Falsified if:* B exits immediately, in which case the two-client test needs a cloned guest and + the VM requirement is one machine larger than assumed. + +**How the model could be wrong.** (a) The manual-join arm may be reachable in the code but +unreachable in the UI — e.g. the *Join Manually* button could be disabled until the Internet page +has a browser object, which static reading of the button's enable predicate would catch and I did not +do. (b) `App_ConnectToStrategyHost` stamps the version word; if the host also demands a matching +GameSpy-side field the direct path never fills in, the join could fail with +`NETERROR_INVALIDGAMEDATA` — a symptom distinct from a timeout. (c) The host may only start its QR2 +socket when reporting succeeds, in which case a host whose Peer init failed would be unqueryable but +still directly connectable — P1 would pass and P3 would fail. + +## 8. Open questions + +- Is `Game_ParseHostAddress`'s dotted-quad-only restriction also enforced on `/join`? (Same function + is called from `0x0089d3ca` in the command-line parser, so almost certainly yes — but the + command-line arg is copied into `AutoJoin` *before* validation, so the failure mode may differ.) +- What sets `StrategyHost+0x2c` / `+0x30`? Verified only that `+0x2c == 1 && +0x30 != 0` selects the + GameSpy-free host mode; the enum behind `+0x2c` (**[I]** likely the F8 Single-Player/LAN/Internet + session type) is not confirmed. +- **Which SB encryption type does `ServerBrowserNew` request?** Undecoded here, and it decides + whether the enctype-0/1-only emulators (`gsmaster`, `mgmse`, 333networks) are even theoretically + usable. The `queryVersion` argument is **[V]** `1` on the LAN path (`0x0077de1c`); the Internet + path's value was not read. OpenSpy carries both enctype1 and GOA/enctypex, so it is covered either + way — this only matters for the narrower projects and for our own reimplementation. +- The SB list request framing on TCP 28910 — undecoded here. +- `disabledservices` semantics differ between implementations (OpenSpy's SQL comment says + `1 = unavailable, 2 = temporarily unavailable`; UniSpy's enum says `0 available, 1 waiting, + 2 permanent, 3 temporary`). §4.1 is the tiebreaker for **this client**: it tests bit 0 then bit 1 + of the status low byte, so `0` is the only safe "available" value and `1` and `2` both mean + something is wrong. Worth telling both projects if it bites. +- Does anything ever write the two hostname-override globals (`0x00b085b0`, `0x00b09440`)? If a + config key reaches them, a private server needs no hosts file. +- `sots_server.exe` is unexamined and is the natural host for the two-client test. diff --git a/findings/subsystems/ui-screen-map.md b/findings/subsystems/ui-screen-map.md index 69bb117..3269d3e 100644 --- a/findings/subsystems/ui-screen-map.md +++ b/findings/subsystems/ui-screen-map.md @@ -53,7 +53,7 @@ Format: **Screen** — evidence (textures / sprites / tokens) → key labels → | F9 | **Game setup (custom)** | `GUI/GameSetup/GameSetupElements.tga` (icons AI/Player/Options/Timer/Money; **30 star-map shape icons**; 6 species); `GAMESETUP_*` (59), `MAPSHAPE_*` (25); `Maps/*.csv` custom maps; `UI_GAMESETUP_ROLLOVER/CONFIRM` | Star Map Setup (shape, Star Density, Number of Stars, Distance ly, Size, Resources, Suitability) · Available Species · Economic/Research Efficiency · AI Easy/Normal/Difficult · Initial Treasury/Colonies/Technologies · Options: Alliances, Random Encounters %, Lobby/Player Passwords, Teams (+Grouped), Win Ratio · Time: Strategic Turn Length, Combat Turn Length, Combat Query Time Limit (+per-additional-round) · Create Game / Cancel Game | `GameSetupPanel`, `CustomSetupPanel`, `CustomSetup_Maps`, `GameSetup_AI/LSE/MapSelect(+Impl)/MapView(+IconOverlay)/Money/Name/Option/Options/Player/Species/Time`, `CustomMapRowParser`; legacy `OldGameSetupPanel` | `StrategyGameCreateParams`, `StrategyGameParams`, `StrategyTimerParams`, `StarMapParams`, `Data/Strategy/starmap.txt`, `RealSpace.csv` | | F10 | **Scenario setup** | `GUI/GameSetup/Scenario_Set_A…H.tga` (16 scenario images; `Set_C` is in the local gob); `SCENARIO_*` (149) | Description / Rules / Objectives pages; 14 scenarios: Jewels of the Crown, Hungry Children (HiverInvasion), Upstart Apes (key `EARTHVSTARKAS`), Holy Lands, A New Hope (CivilWar), His Master's Voice, Lords of a Broken Empire, The End of Flesh, Basic Tutorial, Tourney Space, Progression Wars, The Gathering, Land Grab (S/M/L), Antiquarians | `ScenarioSetupPanel`, `ScenarioSetup_Maps`, `GameSetup_ScenarioPages/ScenarioScene/ScenarioSelect`, `ScenarioPageBase`, `ScenarioDescPage`, `ScenarioObjPage`, `ScenarioRulesPage`, `ObjectivesPanel` | `SVScriptObject` → 33 `SVSO*` classes (one per scenario/encounter script) + `Scenarios/*.txt/.csv`, `StrategyScriptParams` | | F11 | **Load game** | `GAMESETUP_LOAD_*`, `GAMELIST_*`, `GAMECARD_*`, `GAMEINFO_*` (12); `SOTS_GAME_{AUTOSAVE,AUTOSAVEBACKUP,REJOINSAVE,ENDTURN_AUTOSAVE,ENDTURN_AUTOSAVEBACKUP}` | Select saved game; card shows Galaxy Type, Stars, Players, Turn, Version, Economy, Research, Turn/Combat Time, Alliances, Encounters, Teams; Delete | `StrategyGameList` (+`Item`), `GameInfoPanel`, `CustomDataTable::StrategyGameLoader` | `StrategyGameLoadParams`, `.sav` | -| F12 | **Game browser** (Join MP) | `GAMEBROWSER_*`, `SERVERPAGE_*`, `FILTERDLG_*`, `MANUALJOIN_*`, `CHAT_*`, `CHATCHANNEL_*`, `MATCHINGSERVICE_*`, `NETERROR_*` (21 incl. 6 CD-key), `AUTOJOINERR_*`; `GUI/CrossFireLoad.tga` (`AUTOJOIN`) | Pages LAN / Internet / Internet Chat; server list (Game Name, Players, Ping, Password, Status, Version); Edit/Apply Filter; Join Manually (Favorites); Refresh; Join; `/join addr[:port]` command | `GameBrowserScreen`, `GameBrowserPanel` + nested `LANPage`, `InternetPage`, `ChatPage`, `ServerPage`, `ServerList`, `GameDisplay`, `GamePlayers`, `GBMOTD`, `FilterDialog`, `ManualJoinDialog`, `EnterPasswordDialog`; `AutoJoin`, `AutoJoinBackground`; `ChatMonitor`, `ChatHistoryView` | GameSpy master/chat (IRC-style), NatNeg; `StrategyJoin` (+`ProgressDialog`) | +| F12 | **Game browser** (Join MP) | `GAMEBROWSER_*`, `SERVERPAGE_*`, `FILTERDLG_*`, `MANUALJOIN_*`, `CHAT_*`, `CHATCHANNEL_*`, `MATCHINGSERVICE_*`, `NETERROR_*` (13, **none CD-key** — corrected by lane G2, see [[multiplayer-gamespy]] §1), `AUTOJOINERR_*`; `GUI/CrossFireLoad.tga` (`AUTOJOIN`) | Pages LAN / Internet / Internet Chat; server list (Game Name, Players, Ping, Password, Status, Version); Edit/Apply Filter; Join Manually (Favorites); Refresh; Join; `/join addr[:port]` command | `GameBrowserScreen`, `GameBrowserPanel` + nested `LANPage`, `InternetPage`, `ChatPage`, `ServerPage`, `ServerList`, `GameDisplay`, `GamePlayers`, `GBMOTD`, `FilterDialog`, `ManualJoinDialog`, `EnterPasswordDialog`; `AutoJoin`, `AutoJoinBackground`; `ChatMonitor`, `ChatHistoryView` | GameSpy master/chat (IRC-style), NatNeg; `StrategyJoin` (+`ProgressDialog`) | | F13 | **Lobby** (host & join; also the MP *between-turns* room) | `GUI/Lobby/{LobbyBG,LobbyElementsA-C}.tga` (`SLOTPANEL_*`, `SLOT_BUTTON_{PASSWORD,KICK,OPEN,CUSTOMAI,AI}`, `SLOTUSER_{AI,DEAD}`, `SLOTSPECIES_*`, `LOBBY_COLOR_*`, `AVATAR_RANDOM`, `BADGE_RANDOM`); `LOBBY_*` (45), `LOBBYSTATUS_*`, `PLAYERSETUP_*` (17), `HOSTMIG*` (24), `TIMERSDLG_*`, `AIPOLICY_*`, `SYSTEMMSG_*` | Launch / Leave Game / Cancel Game / Ready; slots Open/Closed/AI/password/kick/lock; Player Setup (Name, Species, Color, Badge, Avatar, Team, Settings); Game Status (Strategic round… / Resolving combat… / Querying for combat…, "%s of %s players still playing", "%s battles in progress"); in-progress join ("will enter the game when the next turn begins"); Downloading game data; host migration | `StrategyLobbyScreen` (+`EnterPasswordDialog`, `MOTDDialog`, `PasswordStatus`, `SpeciesInfoPanel`, `TurnInfoPanel`), `SlotPanel`, `LobbySlotPanel`, `SlotDetailsPanel`, `PlayerSetupPanel`, `SpeciesSelectDialog`, `SpeciesDescPanel`, `LobbyChat`, `ChatDialog`, `AIPolicyDialog/Panel`, `SetTimersDialog`, `ServerMigrationStatusPanel` (+`ConfirmAbortDialog`), `ClientMigrationStatusPanel`, `StrategyHost` (+`ProgressDialog`, `ContinueDialog`, `SHMOTD`) | `SNMSlot*` (14 msgs), `SNMJoin*`, `SNMLobbyChat`, `SNMSetTimers`, `SNMSetAIPolicy`, `SNMMakePlayerAI`; `FNM*` chunked file transfer for migration/rejoin; `StrategySessionParams`, `StrategyHostParams` | ### 2.2 Strategy layer (the turn screen and its children) @@ -188,7 +188,7 @@ flowchart TD 2. **Debug overlays and dev toggles left in:** `DebugOverlay::StrategyMapScreen`, `DebugOverlay::BattleView`; `Lines.script` `DEBUG_UseSimpleLine / DEBUG_LockDestination / DEBUG_ForceOutOfRange / DEBUG_ReloadOnFleetChange` (hot-reload of the line script); hotkeys `UICSTR_TAC_FRAMERATE` ("Toggle Framerate") and `UICSTR_TAC_SINGLE_STEP`; `DemoApp` and `MutexScreen` classes; `ForceSingleCore` ini key. 3. **Legacy / cut screens:** `OldGameSetupPanel`; sprites `BARBUTTON_OLDTRADE` and `BARBUTTON_OLDALLIANCE` (pre-ANY trade & alliance buttons replaced by S11/S12); `ChatHistoryPanel2`, `WeaponPanel2`, `CombatReportPanelEx` (v2 rewrites kept beside v1); `Scenario_Set_D/E.tga` carry images for **`SCENARIO_KINGOFTHEHILL`** and **`SCENARIO_SLAVEMASTER`** which have no strings and no `Scenarios/*.txt` — cut scenarios; "Upstart Apes" is internally `EARTHVSTARKAS`; `SUPPORT_REPAIR_AVAIL/REQ` are empty strings. 4. **Localized gob carries UI art:** `Scenario_Set_C.tga` (Holy Lands + Tutorial images), `Tutorial/page9-11.tga` and `Data/credits.txt` are only in `sots_local_en.gob` — a loader that only mounts `sots.gob` will miss sprites the table references. -5. **GameSpy is dead and the 2017 build knows it:** `MATCHINGSERVICE_UNSUPPORTED` "Online support for Sword of the Stars is no longer available" + "players can still connect manually" — F12 degrades to LAN/manual IP; CD-key strings (`NETERROR_CDKEY_*`, `STARTUPERROR_CDKEY_*`) survive. +5. ~~**GameSpy is dead and the 2017 build knows it:**~~ **Corrected by lane G2 — see [[multiplayer-gamespy]] §0 and §1.** The `MATCHINGSERVICE_UNSUPPORTED` string ("Online support for Sword of the Stars is no longer available" + "players can still connect manually") is the *GameSpy kill-switch* message, fired only when a server answers the availability check with the unavailable bit set; the SDK **fails open** on DNS failure, so the 2017 build does not detect the shutdown on its own. F12 does degrade to LAN/manual IP, and that path is fully GameSpy-free. There are **no CD-key strings in this binary at all** — no `CDKEY` substring anywhere, no `STARTUPERROR_*` family, and all 13 `NETERROR_*` entries are connection/version/password errors. 6. **Standalone dedicated server** ships (`sots_server.exe`, `Dedicated Server Launchpad.exe`) with its own error vocabulary `SERVERERROR_*` (ini-section driven: map file, allowed species, defaults) — the strategy server is separable from the client UI, which is good news for a reimplementation split. 7. **Achievement/badge system predates Steam:** 56 `GUI/Achievements/*.tga`, `PlayerBadgeSet`, `PROFILEMAN_BADGES_*`, badge sprites `BADGE_*_GW100` — stored in the local profile. 8. **Sticky-note system:** per-system player notes (`PlayerNotePanel`, `StickyNoteElements.tga`, "Added by: %s, Turn %s") that can be *sent to other players* via `CommDeclareShareSystemNotes`. diff --git a/ghidra/addresses.d/lane-g2.json b/ghidra/addresses.d/lane-g2.json new file mode 100644 index 0000000..895aacc --- /dev/null +++ b/ghidra/addresses.d/lane-g2.json @@ -0,0 +1,348 @@ +{ + "entries": [ + { + "name": "GameSpy_GSIStartAvailableCheck", + "addr": "0x0040a060", + "convention": "cdecl", + "prototype": "void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf(\"%s.available.gamespy.com\"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\\x09\\0\\0\\0\\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_GSIAvailableCheckThink", + "addr": "0x0040a210", + "convention": "cdecl", + "prototype": "int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_AvailableCheckParseReply", + "addr": "0x0040a1a0", + "convention": "fastcall", + "prototype": "int (const uint8_t* pkt /*EAX*/, const sockaddr_in* from /*ECX*/, int len, uint32_t* out_status) /* requires len>=7, from.sin_addr/sin_port match, pkt[0..2]==FE FD 09, out = big-endian pkt[3..6]. Returns 0 on match, 1 otherwise. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_NatNeg_Resolve", + "addr": "0x00412530", + "convention": "cdecl", + "prototype": "void (void) /* resolves natneg1.gamespy.com / natneg2.gamespy.com; UDP port 27901 (htons 0x6cfd) bound in FUN_00412200 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_NNBeginNegotiationWithSocket", + "addr": "0x00412590", + "convention": "cdecl", + "prototype": "int (SOCKET s, int cookie, int clientindex, void* progresscb, void* completedcb, void* userdata) /* only reachable from the server-browser join path and the host side; the direct-address join never calls it */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_peerInitialize", + "addr": "0x00416be0", + "convention": "cdecl", + "prototype": "void* (PEERCallbacks* cbs /* 0x74 bytes */)", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_peerSetTitle", + "addr": "0x00416cc0", + "convention": "cdecl", + "prototype": "int (void* peer, const char* title, const char* secretKey, const char* sbTitle, const char* sbSecretKey, int sbGameVersion, int sbMaxUpdates, int natNegotiate, const int pingRooms[3], const int crossPingRooms[3]) /* SOTS passes title=sbTitle=\"swordots\", key=sbSecretKey=\"Z5gR9Z\", sbGameVersion=[0x00b2d510], sbMaxUpdates=30, natNegotiate=1 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_peerStartReportingWithSocket", + "addr": "0x00416a50", + "convention": "cdecl", + "prototype": "int (void* peer, SOCKET s, unsigned short port)", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_qr2_init_socket", + "addr": "0x0041d1a0", + "convention": "cdecl", + "prototype": "int (qr2_t* qrec, SOCKET s, int boundport, const char* gamename, const char* secret_key, int ispublic, int natnegotiate, void* serverkey_cb, void* playerkey_cb, void* teamkey_cb, void* keylist_cb, void* count_cb, void* adderror_cb, void* userdata) /* builds \"%s.master.gamespy.com\", UDP 27900 (htons 0x6cfc); also references 255.255.255.255 for LAN */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_qr2_register_key", + "addr": "0x0041e730", + "convention": "cdecl", + "prototype": "void (int keyid, const char* name)", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_ServerBrowserNew", + "addr": "0x00420060", + "convention": "cdecl", + "prototype": "void* (const char* queryForGamename, const char* queryFromGamename, const char* queryFromKey, int queryFromVersion, int maxConcUpdates, int queryVersion, int lanBrowse, void* callback, void* instance)", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_ServerBrowserLANUpdate", + "addr": "0x00420160", + "convention": "cdecl", + "prototype": "int (void* sb, int startSearch, unsigned short startSearchPort, unsigned short endSearchPort)", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_ServerBrowserHalt", + "addr": "0x00420110", + "convention": "cdecl", + "prototype": "void (void* sb)", + "status": "mapped", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_SBServerListConnect", + "addr": "0x00420840", + "convention": "thiscall", + "prototype": "int (SBServerList* this) /* master index = (unsigned)(fold over tolower(c): h = c - h*0x63306ce7) % 20; sprintf(\"%s.ms%d.gamespy.com\"), TCP port 28910 (htons 0x70ee). Override hostname global at 0x00b09440. For gamename \"swordots\" the index is 5. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "GameSpy_chatConnectPeerchat", + "addr": "0x00417ab0", + "convention": "cdecl", + "prototype": "int (...) /* passes \"peerchat.gamespy.com\", TCP port 6667 (0x1a0b) to the socket-connect helper at 0x00408fe0 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_AutoJoin_ctor", + "addr": "0x0076c6a0", + "convention": "thiscall", + "prototype": "void (AutoJoin* this /*0x90 bytes*/, const void* params, const char* address) /* vftable 0x00a218f4; address std::string at +0x68 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_AutoJoin_Think", + "addr": "0x00778a90", + "convention": "thiscall", + "prototype": "bool (AutoJoin* this) /* vftable slot 2. Runs GSIStartAvailableCheck/Think, stores availability at +5, then AutoJoin_InitServerBrowser */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_AutoJoin_InitPeer", + "addr": "0x007617f0", + "convention": "thiscall", + "prototype": "bool (AutoJoin* this) /* refuses when this->available (+5) == 0; peerInitialize + peerSetTitle(\"swordots\",\"Z5gR9Z\") */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_GameBrowserPanel_InitPeer", + "addr": "0x00778c40", + "convention": "thiscall", + "prototype": "bool (GameBrowserPanel* this) /* gated on this+0x8a (availability); peerInitialize + peerSetTitle */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_GameBrowserPanel_ctor_tail", + "addr": "0x0077e210", + "convention": "thiscall", + "prototype": "GameBrowserPanel* (GameBrowserPanel* this, ...) /* calls Game_RegisterQR2Keys then GSIStartAvailableCheck(gamename) */", + "status": "mapped", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_GameBrowserPanel_LANPage_CreateBrowser", + "addr": "0x0077dde0", + "convention": "thiscall", + "prototype": "void (LANPage* this) /* vftable slot 18. ServerBrowserNew(\"swordots\",\"swordots\",\"Z5gR9Z\", 0, 30, 1, lanBrowse=1, cb, this) into this+0xb9c. NO availability gate. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_GameBrowserPanel_LANPage_Refresh", + "addr": "0x0077de50", + "convention": "thiscall", + "prototype": "void (LANPage* this) /* ServerBrowserHalt then ServerBrowserLANUpdate(sb, 1, g_LanScanPort, g_LanScanPort + g_LanScanPortRange) */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_GameBrowserPanel_ShowManualJoinDialog", + "addr": "0x0077e590", + "convention": "thiscall", + "prototype": "void (GameBrowserPanel* this) /* news a 0x166c-byte ManualJoinDialog (ctor 0x0077c790, vftable 0x00a22604) and binds Game_ManualJoin_OnAccept as its handler */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_ManualJoin_OnAccept", + "addr": "0x0076ef90", + "convention": "cdecl", + "prototype": "void (void* sender, int action, GameBrowserPanel* self) /* validates the typed address with Game_ParseHostAddress then App_StartJoin(params, address, serverBrowser = NULL) -- the GameSpy-free join */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_ParseHostAddress", + "addr": "0x008f64d0", + "convention": "cdecl", + "prototype": "bool (std::string* out_host, int* out_port, const char* text) /* sscanf \"%d.%d.%d.%d:%d%1s\" must yield 4 or 5 fields with every octet <= 255, then sscanf \"%[^:]:%d\"; out_port = -1 when absent. Dotted-quad only -- hostnames are rejected. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "App_StartJoin", + "addr": "0x00899e30", + "convention": "thiscall", + "prototype": "bool (App* this, const void* params, const char* address, void* serverBrowser) /* news Game::StrategyJoin (0xc8 bytes) into this+0x158; serverBrowser may be NULL */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_StrategyJoin_ctor", + "addr": "0x00777b10", + "convention": "thiscall", + "prototype": "StrategyJoin* (StrategyJoin* this, const void* params /*0x58 by value*/, const char* address, void* serverBrowser) /* vftable 0x00a21904. serverBrowser == NULL => state (+0x80) = 1, direct connect; else state 0, ServerBrowserAuxUpdateIP + NAT negotiation */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_StrategyJoin_Think", + "addr": "0x00765810", + "convention": "thiscall", + "prototype": "bool (StrategyJoin* this) /* vftable slot 2; jump table at 0x00765948 for states 1..5 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_StrategyJoin_QueryViaServerBrowser", + "addr": "0x00770e70", + "convention": "thiscall", + "prototype": "bool (StrategyJoin* this, void* serverBrowser) /* ServerBrowserAuxUpdateIP-style direct query then NNBeginNegotiationWithSocket */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "App_ConnectToStrategyHost", + "addr": "0x0089a7e0", + "convention": "thiscall", + "prototype": "bool (App* this, const char* address, const char* playerName) /* the direct-connect entry reached from StrategyJoin state 1; news a 0x130-byte client into this+0x14c and stamps the version word from 0x00b2d510 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_OnJoinGame_ChooseAddress", + "addr": "0x00772f60", + "convention": "thiscall", + "prototype": "bool (GameBrowserPanel* this) /* picks private vs public SBServer address; logs \"same NAT\" / \"behind NAT, but can still connect directly\" / \"requires NAT negotiation\"; reads SBServerGetIntValue(server, \"password\", 0) */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_StrategyHost_InitPeer", + "addr": "0x007c1ee0", + "convention": "thiscall", + "prototype": "bool (StrategyHost* this) /* refuses when this+0x24e (availability) == 0; peerInitialize with a 0x74-byte callback table then peerSetTitle */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_StrategyHost_StartReporting", + "addr": "0x007c2dd0", + "convention": "thiscall", + "prototype": "void (StrategyHost* this) /* if this+0x2c == 1 && this+0x30 != 0: qr2_init_socket(..., ispublic = 0) -- LAN reporting, no master heartbeat. Otherwise StrategyHost_InitPeer then peerStartReportingWithSocket. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_RegisterQR2Keys", + "addr": "0x00898bf0", + "convention": "cdecl", + "prototype": "void (void) /* 16 qr2_register_key calls: 50..57 slot0..slot7, 58 numslots, 59 mapshape, 60 numsys, 61 turn, 62 scenario, 63 settings, 64 slot_, 65 ranks */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_LoadNetworkConfig", + "addr": "0x005a0610", + "convention": "thiscall", + "prototype": "void (Config* this) /* reads ini section [Network]: SyncCheckStrategy=True, SyncCheckCombat=True, SyncLogStrategy=False, SyncLogCombat=False, HostPort=3369, CombatHostPort=3370, LanScanPort=3369, LanScanPortRange=1, HeartbeatPeriod=15000, ConnectionTimeout=45000, MaxTxMessageSize=512, CombatLatency=1000 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "Game_ParseJoinCommandLine", + "addr": "0x0089d280", + "convention": "thiscall", + "prototype": "bool (App* this) /* GetCommandLineW + CommandLineToArgvW; matches the literal \"/join\", takes the next argv as the address and the one after as an optional second string, then constructs Game::AutoJoin into this+0x158 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_GameSpyGameName", + "addr": "0x00a35cd4", + "convention": "data", + "prototype": "const char* /* -> \"swordots\" at 0x00a35c50 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_GameSpySecretKey", + "addr": "0x00a35cd8", + "convention": "data", + "prototype": "const char* /* -> \"Z5gR9Z\" at 0x00a35c48 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_GameVersionWord", + "addr": "0x00b2d510", + "convention": "data", + "prototype": "uint32_t /* packed build word; bits 16..23 = minor<<4 | major parsed from \"1.8.1\", byte 1 = edition flags, low nibble = build flavour. Passed as peerSetTitle sbGameVersion and stamped into the join handshake. */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_LanScanPort", + "addr": "0x00b13c6c", + "convention": "data", + "prototype": "uint16_t /* [Network] LanScanPort, default 3369 */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_LanScanPortRange", + "addr": "0x00b13c70", + "convention": "data", + "prototype": "uint32_t /* [Network] LanScanPortRange, default 1; LAN sweep is [LanScanPort, LanScanPort+Range] */", + "status": "verified", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_GameSpyAvailableHostOverride", + "addr": "0x00b085b0", + "convention": "data", + "prototype": "char[] /* when non-empty, replaces \".available.gamespy.com\" in the availability check */", + "status": "mapped", + "source": "findings/subsystems/multiplayer-gamespy.md" + }, + { + "name": "g_GameSpyMasterHostOverride", + "addr": "0x00b09440", + "convention": "data", + "prototype": "const char* /* when non-NULL, replaces \".ms.gamespy.com\" in SBServerListConnect */", + "status": "mapped", + "source": "findings/subsystems/multiplayer-gamespy.md" + } + ] +} diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index eb7a7b7..5fada62 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 3c48d3a, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 728348b, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1295,6 +1295,30 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310; constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730; // thiscall void (Mars::StreamableHelper* 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; +// site site in StrategyServer::Write (0x0079fa70, already in addresses.json with the same address -- AGREEMENT, this lane's duplicate entry was dropped). THE DECISIVE EVIDENCE FOR THE ModCount / Frame NAMING: 0x0079fb2f `lea edx,[edi+0x08]; push "ModCount"` and 0x0079fb40 `lea eax,[edi+0x0c]; push "Frame"`, with edi = this. The frame is confirmed inside the same function by 0x0079fb90 `mov eax,[edi+0x16c]` under the tag "RNG", which is StrategyServer_off_RNGPtr (0x168 in the S+4 frame) -- so edi is S, not S+4. Tag order after the six id lists is ModCount, Frame, GameID ([edi+0x14] at 0x0079fb4f) [verified] +constexpr uint32_t StrategyServer_Write_ModCountFrameTags = 0x0039fb2f; +// field int ModCount, in the S+4 (Game::StrategySim sub-object) frame == S+0x8. THIS IS THE WIRE'S /Sim/ModCount. CORRECTION, and it matters: addresses.json's `StrategyServer_off_ModCount` puts that name on offset 0x8 of this frame (== S+0xc), which StrategyServer::Write tags "Frame". Lane T's `StrategyServer_off_PhaseCounter` -- 'nobody has named this one' -- is this word, and it IS ModCount. Written by an unconditional increment on ENTRY to every StrategySim command-application method (26 sites), plus once by StrategyServer::ProcessTurn's first instruction, once by OnAllCombatDone_Tail's first instruction, and once per call of the abandon/chaos check. It is a modification counter: not a turn number, not a phase counter, and not constant per turn (12-44 on measured turns) [verified] +constexpr uint32_t StrategySim_off_ModCount = 0x00000004; +// field int Frame, in the S+4 frame == S+0xc. THIS IS THE WIRE'S /Sim/Frame -- the turn number. Incremented once per turn by StrategyServer::BeginProcessTurn 0x007d990a; stamped into node.turnResearched by TechTree::SetResearched; used as minTurn by the EVENT_NO_RESEARCH sweep; and it is the KEY the turn-record archive is written under (FinalizeTurnRecords passes S->+0xc). The entry addresses.json calls `StrategyServer_off_ModCount` is this word, and the name is wrong on it [verified] +constexpr uint32_t StrategySim_off_Frame = 0x00000008; +// site site in StrategyServer::ProcessTurn, phase 4, 0x007dc871-0x007dc8c7, byte-for-byte: `for (i = 0; i < (Players.end - Players.begin) >> 2; ++i) { p = Players[i]; rec = p->+0x3d8; rec->+0x8 = 0; rec->+0x8 |= 1 << i; if (p->+0x168 != -1) rec->+0x8 |= p->+0x16c; }`. THREE separate stores to the same word (`mov [edx+8],ebx` with ebx=0, then two `or`), which is why it is an OR and not an assignment; the turn-record pointer is RELOADED from p->+0x3d8 before each. The shift is `mov edi,1; shl edi,cl` with cl = the loop counter, so the bit is the player's POSITION IN THE VECTOR and x86's 5-bit shift mask applies above 31 players. Loop guard is `test edx,0xfffffffc; jle` on the byte count [verified] +constexpr uint32_t StrategyServer_ProcessTurn_AllianceMaskPass = 0x003dc871; +// thiscall void (StrategySim* this /* S+4 */, Game::TurnCommands* first, int count) // applies a run of per-player command blocks: `last = first + count*0x1b4; for (b = first; b != last; b += 0x1b4) `. The 0x1b4 stride is sizeof(Game::TurnCommands) and independently confirms lane Q's layout (27 std::list members at 0x70..0x1a8, stride 0xc, so the class ends at 0x1b4). SIX command handlers are INLINED here and each bumps ModCount once (0x0088fe0a, 0x008902fe, 0x008903b9, 0x0089046c, 0x008905c8, 0x008907bc), each followed by `lea ecx,[base+0x80]; call HandleMap::Resolve` -- which is what proves the base is the StrategySim and not the block [verified] +constexpr uint32_t StrategySim_ApplyTurnCommandBatch = 0x0048f9b0; +// thiscall void (StrategyServer* this /* S frame */) // the End-Turn command flush: computes `count = (this->+0x178 - this->+0x174) / 0x1b4` (signed magic 0x964fda6d, sar 8 -- the reciprocal of 436) and calls StrategySim::ApplyTurnCommandBatch(this+4, this->+0x174, count). The `lea ecx,[esi+4]` at 0x0078f6de is a third independent sighting of the two-bases split. Called from StrategyNetworkClient::OnMessage 0x00784904, immediately before that handler calls StrategyServer::ProcessTurn at 0x0078491c [verified] +constexpr uint32_t StrategyServer_ApplyAllTurnCommands = 0x0038f6a0; +// field std::vector in the S frame (begin @+0x174, end @+0x178), element stride 0x1b4. One block per player; the End-Turn flush applies every command in every block, and each application bumps ModCount. This is the wire's `Player..TurnCommands_v5` custom-data block in memory [verified] +constexpr uint32_t StrategyServer_off_TurnCommandQueue = 0x00000174; +// field sizeof(Game::TurnCommands) = 436, recovered from the container stride in StrategyServer::ApplyAllTurnCommands (`imul ebx,ebx,0x1b4`) and from the signed-division reciprocal 0x964fda6d/sar 8 in the same function. Independent of, and agreeing with, lane Q's field walk, whose last list member sits at 0x1a8 with stride 0xc [verified] +constexpr uint32_t TurnCommands_sizeof = 0x000001b4; +// thiscall void (StrategyNetworkClient* this, Message* m) // vftable 0x00a229f4 slot 6. THE END-TURN DISPATCHER: it is the only direct caller of StrategyServer::ProcessTurn (0x0078491c), of OnAllCombatDone_Tail (0x00784d07) and of StrategyServer::ApplyAllTurnCommands (0x00784904), and it loads the server with `mov ecx,[esi+0x54]` before the first two. NOTE: this class's own `this` is NOT a StrategySim -- its entity hash is at +0x84, not +0x80 -- so the three `inc [reg+4]` sites in this body (0x007850d5, 0x0078514b, 0x00785224) are NOT ModCount and are listed as unresolved rather than counted [verified] +constexpr uint32_t StrategyNetworkClient_OnMessage = 0x00384640; +// field StrategyServer* in the S frame (the base ProcessTurn receives, not S+4). Read at 0x00784901 and 0x00784919 immediately before the command flush and the turn driver [verified] +constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054; +// thiscall void (StrategyServer* this /* S frame */, ServerSystem* sys, int mode, std::vector* out) // the abandon/chaos check called once per system from ProcessTurn phase 1. ITS FOURTH INSTRUCTION IS A ModCount BUMP: `inc [esi+0x8]` @0x007b9e20 with esi = this = S, so this is the only per-system ModCount writer in the turn. The phase-1 loop gate is `cmp BYTE [sys+0xc4],0; je` -- ServerSystem_off_Abdn -- so the call, and the bump, happen only for systems already flagged abandoned. Abdn is FALSE on all 28 systems of all 11 corpus saves, so this writer contributes 0 on every measured turn and is a rule-6 hypothesis for any turn where it does not [verified] +constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0; +// thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified] +constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780; // thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified] constexpr uint32_t CombatResolve_Retreat = 0x003d5a00; // thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified] @@ -1361,6 +1385,136 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980; constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0; // thiscall void** (std::map* this, const int* key) // 125 B, ret 4. MSVC std::map::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified] constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0; +// thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // THE DESIGN SERIALIZER LANE D SAID DID NOT EXIST. Slot 1 of the ShipDesignDef vftable 0x009fef64. Writes, in DISK order: WriteBool 'FAIDes' this+0x4, WriteBool 'DHide' this+0x5, WriteBool 'DWep' this+0x6 (a BOOL, not an int -- the campaign schema had it as int; byte-neutral because a 4-char tag makes both items 12 bytes), WriteString 'DName' this+0x8, then THREE 'DSec' frames through StreamableHelper at this+0x4c, this+0x24, this+0x74 in that order. THREE sections, not five: the ctor 0x00874c70 runs eh_vector_constructor_iterator(this+0x24, stride 0x28, count 3). MEMORY ORDER != WRITE ORDER: the array is [+0x24, +0x4c, +0x74] and the wire is [+0x4c (command), +0x24 (mission), +0x74 (engine)] [verified] +constexpr uint32_t Game_ShipDesignDef_Write = 0x00427390; +// thiscall void (Game_ShipDesignDef* this, Mars::Stream* s) // slot 0 of vftable 0x009fef64. Mirrors Write field for field, same tags, same three DSec frames in the same order [verified] +constexpr uint32_t Game_ShipDesignDef_Read = 0x00427240; +// thiscall void (Game_ShipDesign* this, Mars::Stream* s) // CORRECTS 'Game::ShipDesign::Write (0x008747a0) makes no stream call at all': 0x008747a0 is in NO vftable and is not this class's writer. Game::ShipDesign derives from Game::ShipDesignDef (RTTI 0x00a894a8: ShipDesign, ShipDesignDef, IStreamable, RefCounted, NetworkObject; IStreamable sub-object at +0x9c, NetworkObject at +0xa0). Primary vftable 0x00a32720 slot 1; the +0x9c IStreamable vftable 0x00a32710 reaches it through an adjustor thunk at 0x00874de0. Body: direct call to ShipDesignDef::Write 0x00827390 (the base part), then WriteInt 'Dtc' this+0x134, WriteBool 'Dwgv' this+0x16c, and ONLY IF that flag is set a 'Dwg' frame through StreamableHelper at this+0x170. Dwgv is false in all 11 saves, so the Dwg branch is unexercised (rule 6) [verified] +constexpr uint32_t Game_ShipDesign_Write = 0x004325e0; +// thiscall void (Game_ShipDesign* this, Mars::Stream* s) // primary vftable 0x00a32720 slot 0. Calls ShipDesignDef::Read, then ReadInt 'DRefCnt' into a NULL destination (read and discarded; the WRITER never emits it, so it is a network-stream field the tag-addressed reader tolerates), then 'Dtc', 'Dwgv' and the conditional 'Dwg' [verified] +constexpr uint32_t Game_ShipDesign_Read = 0x004324f0; +// thiscall Game_ShipDesign* (Game_ShipDesign* this) // installs ShipDesignDef::vftable at +0, then IStreamable/NetworkObject at +0x9c/+0xa0 and the three ShipDesign vftables. Constructs the DName string at +0x8 and eh_vector_constructor_iterator(this+0x24, 0x28, 3) -- the three section records. Enumerates sizeof(Game_ShipDesignDef) == 0x9c [verified] +constexpr uint32_t Game_ShipDesign_ctor = 0x00474c70; +// fastcall void (Game_ShipDesign* this) // THE RECOMPUTE THAT PRODUCES BOTH CENSUS WORDS. 16 call sites; one of them stamps this+0x134 (Dtc) and calls straight in. (1) resolves each of the three section records this+0x24/+0x4c/+0x74 to a ShipSectionDef* through the catalog lookup 0x0056edd0 and caches them at this+0xac/+0xb0/+0xb4, IN MEMORY SLOT ORDER; (2) builds a 3-element context array (stride 0x124) and hands it to the aggregator 0x00826af0 together with this+0x130; (3) copies the aggregator's ~0x7c-byte output struct into the design: struct+0x10 -> this+0xb8 (role flags, low dword), struct+0x14 -> this+0xbc (high dword), struct+0x18..+0x64 -> this+0xc0..+0x118, struct+0x74 -> this+0x12c (HULL SIZE), struct+0x78 -> this+0x1a4, struct+0x00..+0x0c -> this+0x138..+0x144. Neither this+0xb8 nor this+0x12c is on the wire: they are rebuilt from the data files whenever a design changes or is loaded [verified] +constexpr uint32_t Game_ShipDesign_UpdateDerivedStats = 0x0047e7c0; +// cdecl void (DesignStatBlock* out, SectionContext ctx[3], void* techCtx, char flag) // walks the three section contexts (stride 0x124, ctx[i]+0 = the ShipSectionDef*, null = empty slot). out[4]/out[5] (the 64-bit role-flag word) |= ShipSectionDef::GetRoleFlags 0x0056ee80 per section -- an OR, so ONE flagged section flags the whole design. out[0x1d] (hull size) = sectionDef+0x260 -- an ASSIGNMENT, so the LAST resolved section in memory slot order wins; every shipped design is section_class-homogeneous so first-wins and last-wins agree on all 503 design records in the corpus. Also: out[6] x5 when any section carries tech 0x2756; out[4] &= ~0x80 for tech 0x2757; out[4] |= 0x100000 when EVERY section carries tech 0x2742. Default hull health (out[2]) is chosen by the SAME 0x400 bit when no section overrides it at +0x2ac: without 0x400 hull 0/1/2 -> 500/3000/15000, with 0x400 -> 100/500/1000 [verified] +constexpr uint32_t Game_ShipDesign_AggregateSectionStats = 0x00426af0; +// thiscall unsigned __int64 (Game_ShipSectionDef* this, void* ctx) // returns CONCAT(this+0x29c, this+0x298) -- the section's 64-bit role-flag word straight out of the parsed .shipsection -- with bit 0x20 of the low dword OR-ed in when ctx is non-null, ctx+0xfc is set and this+0x304 > 0. That conditional bit is the only part of the word that is not pure file data [verified] +constexpr uint32_t Game_ShipSectionDef_GetRoleFlags = 0x0016ee80; +// thiscall Game_ShipSectionDef* (SectionCatalog* this, Game_ShipSectionID* id) // linear scan of the vector at this+0x8/+0xc matching def+0x4 == id->species and def+0x8 == id->sectionId. Returns null for the (0,0) empty slot without scanning [verified] +constexpr uint32_t Game_ShipSectionCatalog_FindByID = 0x0016edd0; +// cdecl bool (int* out, const char* name) // THE HULL-SIZE DEFINITION. _stricmp against "Destroyer", "Cruiser", "Dreadnought" in that order and stores the index it stopped at: 0, 1, 2. Returns false without writing on no match [verified] +constexpr uint32_t Game_ParseShipClassName = 0x0016e1c0; +// cdecl bool (int* out, const char* name) // wraps 0x0056e1c0 for the `section_class` key; on failure logs " [%s] unrecognized ship class" and stores 0, so an unknown OR ABSENT section_class is a destroyer, not an error [verified] +constexpr uint32_t Game_ShipSectionDef_ParseSectionClass = 0x0016e250; +// cdecl void (unsigned __int64* flags, unsigned int loMask, unsigned int hiMask, bool value) // the .shipsection parser's flag setter. Every boolean role key in the section parser 0x005744e0 is one call to this with its own mask pair; `defence_platform` is (lo 0x400, hi 0) at 0x005749b7, `monitor` is (lo 0, hi 0x4), `refinery` 0x8, `mining_capacity` 0x10, `scanrange`/`rebelai_scanrange` 0x20, `gateship` 0x40, `ewar` 0x800, `ramscoop` 0x1000, `aicontrol` 0x2000, `command_quota` 0x10000, `node_bore` 0x20000, `prisoner_capacity` 0x40000, `freighter` 0x80000, `gravboat_bonus` 0x200000, `construction_capacity` 0x400000, `science` 0x1000000, `tradingpost` 0x2000000, `freighterQ` 0x8000000, `police` 0x10000000, `spy` 0x40000000, `spytender` 0x80000000, `refueling_capacity` 0x2, `repair_capacity` 0x4; high dword: `colony_trap` 0x1, `mining_trap` 0x2, `monitor` 0x4, `propaganda` 0x10 [verified] +constexpr uint32_t Game_SetRoleFlagBit = 0x0016e780; +// cdecl void (ServerPlayer* p, int out[8]) // THE CENSUS. Zeroes out[0..7], then walks the server's fleet vector (p+0x8 -> S, S+0x60/+0x64), keeps fleets whose owner (fleet+0x58) is p, and for every ship in fleet+0xa4/+0xa8 takes design = ship+0x14. If (design+0xb8 & 0x400) == 0: ++out[0] and ++out[2 + design->hullSize(+0x12c)]; else ++out[1] and ++out[5 + hullSize]. So out[0]/out[1] are the two grand totals (computed and DISCARDED by the caller), out[2..4] are ships by hull size 0/1/2 and out[5..7] are defence platforms by hull size. The six land at turnRecord+0x2a..+0x34 and reach the wire as the three `cls` groups' `shpt` and `satt` [verified] +constexpr uint32_t Game_ServerPlayer_ShipCensusByHullClass = 0x00418a50; +// cdecl int (Game_ShipDesign* design) // a SECOND classifier over the same two words, kept because it shows the flag word is a role set and not a single bit: returns -1 when the design lacks flag 0x80000 (`freighter`), else for hull size 1 returns 0 when 0x8000000 (`freighterQ`) is set and 1 otherwise, and 2 for any other hull size. 0x0082c7c0 is the matching counter over a fleet list. NOT the census -- neither reads 0x400 [unverified] +constexpr uint32_t Game_ShipDesign_IsMobileWarshipClass = 0x0041a430; +// offset unsigned int // low dword of the design's 64-bit role-flag word, the OR of its sections'. Bit 0x400 = `defence_platform`. High dword at +0xbc. NOT on the wire; rebuilt by 0x0087e7c0 [verified] +constexpr uint32_t ShipDesign_off_RoleFlagsLow = 0x000000b8; +// offset int // 0 destroyer / 1 cruiser / 2 dreadnought, from the last resolved section's `section_class`. NOT on the wire; rebuilt by 0x0087e7c0 [verified] +constexpr uint32_t ShipDesign_off_HullSize = 0x0000012c; +// offset int // the wire field `Dtc`, written by ShipDesign::Write and stamped by at least one caller immediately before it calls the recompute 0x0087e7c0 [unverified] +constexpr uint32_t ShipDesign_off_Dtc = 0x00000134; +// offset int // parsed `section_class`: 0 destroyer / 1 cruiser / 2 dreadnought, 0 when absent or unrecognised [verified] +constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260; +// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified] +constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298; +// cdecl void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf("%s.available.gamespy.com"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\x09\0\0\0\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */ [verified] +constexpr uint32_t GameSpy_GSIStartAvailableCheck = 0x0000a060; +// cdecl int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */ [verified] +constexpr uint32_t GameSpy_GSIAvailableCheckThink = 0x0000a210; +// fastcall int (const uint8_t* pkt /*EAX*/, const sockaddr_in* from /*ECX*/, int len, uint32_t* out_status) /* requires len>=7, from.sin_addr/sin_port match, pkt[0..2]==FE FD 09, out = big-endian pkt[3..6]. Returns 0 on match, 1 otherwise. */ [verified] +constexpr uint32_t GameSpy_AvailableCheckParseReply = 0x0000a1a0; +// cdecl void (void) /* resolves natneg1.gamespy.com / natneg2.gamespy.com; UDP port 27901 (htons 0x6cfd) bound in FUN_00412200 */ [verified] +constexpr uint32_t GameSpy_NatNeg_Resolve = 0x00012530; +// cdecl int (SOCKET s, int cookie, int clientindex, void* progresscb, void* completedcb, void* userdata) /* only reachable from the server-browser join path and the host side; the direct-address join never calls it */ [verified] +constexpr uint32_t GameSpy_NNBeginNegotiationWithSocket = 0x00012590; +// cdecl void* (PEERCallbacks* cbs /* 0x74 bytes */) [verified] +constexpr uint32_t GameSpy_peerInitialize = 0x00016be0; +// cdecl int (void* peer, const char* title, const char* secretKey, const char* sbTitle, const char* sbSecretKey, int sbGameVersion, int sbMaxUpdates, int natNegotiate, const int pingRooms[3], const int crossPingRooms[3]) /* SOTS passes title=sbTitle="swordots", key=sbSecretKey="Z5gR9Z", sbGameVersion=[0x00b2d510], sbMaxUpdates=30, natNegotiate=1 */ [verified] +constexpr uint32_t GameSpy_peerSetTitle = 0x00016cc0; +// cdecl int (void* peer, SOCKET s, unsigned short port) [verified] +constexpr uint32_t GameSpy_peerStartReportingWithSocket = 0x00016a50; +// cdecl int (qr2_t* qrec, SOCKET s, int boundport, const char* gamename, const char* secret_key, int ispublic, int natnegotiate, void* serverkey_cb, void* playerkey_cb, void* teamkey_cb, void* keylist_cb, void* count_cb, void* adderror_cb, void* userdata) /* builds "%s.master.gamespy.com", UDP 27900 (htons 0x6cfc); also references 255.255.255.255 for LAN */ [verified] +constexpr uint32_t GameSpy_qr2_init_socket = 0x0001d1a0; +// cdecl void (int keyid, const char* name) [verified] +constexpr uint32_t GameSpy_qr2_register_key = 0x0001e730; +// cdecl void* (const char* queryForGamename, const char* queryFromGamename, const char* queryFromKey, int queryFromVersion, int maxConcUpdates, int queryVersion, int lanBrowse, void* callback, void* instance) [verified] +constexpr uint32_t GameSpy_ServerBrowserNew = 0x00020060; +// cdecl int (void* sb, int startSearch, unsigned short startSearchPort, unsigned short endSearchPort) [verified] +constexpr uint32_t GameSpy_ServerBrowserLANUpdate = 0x00020160; +// cdecl void (void* sb) [mapped] +constexpr uint32_t GameSpy_ServerBrowserHalt = 0x00020110; +// thiscall int (SBServerList* this) /* master index = (unsigned)(fold over tolower(c): h = c - h*0x63306ce7) % 20; sprintf("%s.ms%d.gamespy.com"), TCP port 28910 (htons 0x70ee). Override hostname global at 0x00b09440. For gamename "swordots" the index is 5. */ [verified] +constexpr uint32_t GameSpy_SBServerListConnect = 0x00020840; +// cdecl int (...) /* passes "peerchat.gamespy.com", TCP port 6667 (0x1a0b) to the socket-connect helper at 0x00408fe0 */ [verified] +constexpr uint32_t GameSpy_chatConnectPeerchat = 0x00017ab0; +// thiscall void (AutoJoin* this /*0x90 bytes*/, const void* params, const char* address) /* vftable 0x00a218f4; address std::string at +0x68 */ [verified] +constexpr uint32_t Game_AutoJoin_ctor = 0x0036c6a0; +// thiscall bool (AutoJoin* this) /* vftable slot 2. Runs GSIStartAvailableCheck/Think, stores availability at +5, then AutoJoin_InitServerBrowser */ [verified] +constexpr uint32_t Game_AutoJoin_Think = 0x00378a90; +// thiscall bool (AutoJoin* this) /* refuses when this->available (+5) == 0; peerInitialize + peerSetTitle("swordots","Z5gR9Z") */ [verified] +constexpr uint32_t Game_AutoJoin_InitPeer = 0x003617f0; +// thiscall bool (GameBrowserPanel* this) /* gated on this+0x8a (availability); peerInitialize + peerSetTitle */ [verified] +constexpr uint32_t Game_GameBrowserPanel_InitPeer = 0x00378c40; +// thiscall GameBrowserPanel* (GameBrowserPanel* this, ...) /* calls Game_RegisterQR2Keys then GSIStartAvailableCheck(gamename) */ [mapped] +constexpr uint32_t Game_GameBrowserPanel_ctor_tail = 0x0037e210; +// thiscall void (LANPage* this) /* vftable slot 18. ServerBrowserNew("swordots","swordots","Z5gR9Z", 0, 30, 1, lanBrowse=1, cb, this) into this+0xb9c. NO availability gate. */ [verified] +constexpr uint32_t Game_GameBrowserPanel_LANPage_CreateBrowser = 0x0037dde0; +// thiscall void (LANPage* this) /* ServerBrowserHalt then ServerBrowserLANUpdate(sb, 1, g_LanScanPort, g_LanScanPort + g_LanScanPortRange) */ [verified] +constexpr uint32_t Game_GameBrowserPanel_LANPage_Refresh = 0x0037de50; +// thiscall void (GameBrowserPanel* this) /* news a 0x166c-byte ManualJoinDialog (ctor 0x0077c790, vftable 0x00a22604) and binds Game_ManualJoin_OnAccept as its handler */ [verified] +constexpr uint32_t Game_GameBrowserPanel_ShowManualJoinDialog = 0x0037e590; +// cdecl void (void* sender, int action, GameBrowserPanel* self) /* validates the typed address with Game_ParseHostAddress then App_StartJoin(params, address, serverBrowser = NULL) -- the GameSpy-free join */ [verified] +constexpr uint32_t Game_ManualJoin_OnAccept = 0x0036ef90; +// cdecl bool (std::string* out_host, int* out_port, const char* text) /* sscanf "%d.%d.%d.%d:%d%1s" must yield 4 or 5 fields with every octet <= 255, then sscanf "%[^:]:%d"; out_port = -1 when absent. Dotted-quad only -- hostnames are rejected. */ [verified] +constexpr uint32_t Game_ParseHostAddress = 0x004f64d0; +// thiscall bool (App* this, const void* params, const char* address, void* serverBrowser) /* news Game::StrategyJoin (0xc8 bytes) into this+0x158; serverBrowser may be NULL */ [verified] +constexpr uint32_t App_StartJoin = 0x00499e30; +// thiscall StrategyJoin* (StrategyJoin* this, const void* params /*0x58 by value*/, const char* address, void* serverBrowser) /* vftable 0x00a21904. serverBrowser == NULL => state (+0x80) = 1, direct connect; else state 0, ServerBrowserAuxUpdateIP + NAT negotiation */ [verified] +constexpr uint32_t Game_StrategyJoin_ctor = 0x00377b10; +// thiscall bool (StrategyJoin* this) /* vftable slot 2; jump table at 0x00765948 for states 1..5 */ [verified] +constexpr uint32_t Game_StrategyJoin_Think = 0x00365810; +// thiscall bool (StrategyJoin* this, void* serverBrowser) /* ServerBrowserAuxUpdateIP-style direct query then NNBeginNegotiationWithSocket */ [verified] +constexpr uint32_t Game_StrategyJoin_QueryViaServerBrowser = 0x00370e70; +// thiscall bool (App* this, const char* address, const char* playerName) /* the direct-connect entry reached from StrategyJoin state 1; news a 0x130-byte client into this+0x14c and stamps the version word from 0x00b2d510 */ [verified] +constexpr uint32_t App_ConnectToStrategyHost = 0x0049a7e0; +// thiscall bool (GameBrowserPanel* this) /* picks private vs public SBServer address; logs "same NAT" / "behind NAT, but can still connect directly" / "requires NAT negotiation"; reads SBServerGetIntValue(server, "password", 0) */ [verified] +constexpr uint32_t Game_OnJoinGame_ChooseAddress = 0x00372f60; +// thiscall bool (StrategyHost* this) /* refuses when this+0x24e (availability) == 0; peerInitialize with a 0x74-byte callback table then peerSetTitle */ [verified] +constexpr uint32_t Game_StrategyHost_InitPeer = 0x003c1ee0; +// thiscall void (StrategyHost* this) /* if this+0x2c == 1 && this+0x30 != 0: qr2_init_socket(..., ispublic = 0) -- LAN reporting, no master heartbeat. Otherwise StrategyHost_InitPeer then peerStartReportingWithSocket. */ [verified] +constexpr uint32_t Game_StrategyHost_StartReporting = 0x003c2dd0; +// cdecl void (void) /* 16 qr2_register_key calls: 50..57 slot0..slot7, 58 numslots, 59 mapshape, 60 numsys, 61 turn, 62 scenario, 63 settings, 64 slot_, 65 ranks */ [verified] +constexpr uint32_t Game_RegisterQR2Keys = 0x00498bf0; +// thiscall void (Config* this) /* reads ini section [Network]: SyncCheckStrategy=True, SyncCheckCombat=True, SyncLogStrategy=False, SyncLogCombat=False, HostPort=3369, CombatHostPort=3370, LanScanPort=3369, LanScanPortRange=1, HeartbeatPeriod=15000, ConnectionTimeout=45000, MaxTxMessageSize=512, CombatLatency=1000 */ [verified] +constexpr uint32_t Game_LoadNetworkConfig = 0x001a0610; +// thiscall bool (App* this) /* GetCommandLineW + CommandLineToArgvW; matches the literal "/join", takes the next argv as the address and the one after as an optional second string, then constructs Game::AutoJoin into this+0x158 */ [verified] +constexpr uint32_t Game_ParseJoinCommandLine = 0x0049d280; +// data const char* /* -> "swordots" at 0x00a35c50 */ [verified] +constexpr uint32_t g_GameSpyGameName = 0x00635cd4; +// data const char* /* -> "Z5gR9Z" at 0x00a35c48 */ [verified] +constexpr uint32_t g_GameSpySecretKey = 0x00635cd8; +// data uint32_t /* packed build word; bits 16..23 = minor<<4 | major parsed from "1.8.1", byte 1 = edition flags, low nibble = build flavour. Passed as peerSetTitle sbGameVersion and stamped into the join handshake. */ [verified] +constexpr uint32_t g_GameVersionWord = 0x0072d510; +// data uint16_t /* [Network] LanScanPort, default 3369 */ [verified] +constexpr uint32_t g_LanScanPort = 0x00713c6c; +// data uint32_t /* [Network] LanScanPortRange, default 1; LAN sweep is [LanScanPort, LanScanPort+Range] */ [verified] +constexpr uint32_t g_LanScanPortRange = 0x00713c70; +// data char[] /* when non-empty, replaces ".available.gamespy.com" in the availability check */ [mapped] +constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0; +// data const char* /* when non-NULL, replaces ".ms.gamespy.com" in SBServerListConnect */ [mapped] +constexpr uint32_t g_GameSpyMasterHostOverride = 0x00709440; +// thiscall int (void* this, int, int) // the ONE callee through which Game::ServerSpyManager vftable 0x00a3073c slot 13 (0x008877b0) reaches the strategic generator. Exactly ONE incoming reference in the image: an unconditional call at 0x00887af2 inside that slot. Real body 0x008408e0..0x00840a59, 370 bytes. Contains three of lane V2's eight draw sites -- Chance 0x00840929, NextInt 0x008409c7, Chance 0x00840a3c -- all loading the generator as [reg+0x16c]. Named for the verified relationship (which slot reaches it, and that it draws), NOT for any semantics: its body was not read. Lane H hooks it with a register-transparent entry counter so that 'the spy path never fired' can be split into 'slot 13 was not entered' and 'slot 13 was entered and gated above this call' [mapped] +constexpr uint32_t SpyManager_Slot13RngCallee = 0x004408e0; +// thiscall uint (void* fleet, char) // first of the two callees through which Game::ServerTradeManagerImpl vftable 0x00a31b74 slot 13 (0x0088ef80) reaches the strategic generator. Exactly ONE incoming reference: an unconditional call at 0x0088f036. Real body 0x00820ca0..0x00820e53, 436 bytes, containing lane V2's NextFloat site 0x00820e18. Its own body loops the vector at arg+0xa4/+0xa8 testing per-element flags, and its boolean result is what gates the call to TradeManager_Slot13RngCalleeB one instruction later. THE CALLER'S LOOP IS OVER THE FLEETS VECTOR at GetServer()+0x64/+0x68, not over a trade-route list -- which is why 'the tail draws nothing because we have no trade routes' does not explain this slot [mapped] +constexpr uint32_t TradeManager_Slot13RngCalleeA = 0x00420ca0; +// thiscall void (void* this, int) // second of the two callees through which Game::ServerTradeManagerImpl slot 13 (0x0088ef80) reaches the strategic generator, called at 0x0088f042 ONLY when TradeManager_Slot13RngCalleeA returned non-zero. Exactly ONE incoming reference. Real body 0x0088b440..0x0088b976, 1323 bytes, containing lane V2's NextInt site 0x0088b613 (`mov ecx,[ecx+0x16c]; add ecx,4`). The two form a two-stage gate: A draws first and decides whether B runs, so a run where A fires and B does not is a MEASUREMENT, not a gap [mapped] +constexpr uint32_t TradeManager_Slot13RngCalleeB = 0x0048b440; // thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified] constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670; // thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified] @@ -1525,6 +1679,74 @@ constexpr uint32_t GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD = 0x006ec79c; constexpr uint32_t GlobalConst_storage_SLAVES_OUTPUT_MOD = 0x0070e9b0; // data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified] constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788; +// cdecl bool (StarFleet* fleet, MapObject* start, MapObject** dests, unsigned count, int* flagsOut, int* failIdxOut, int* typesOut, NodeRoute* routesOut) // 8 STACK ARGS, plain RET, esp cleaned by the caller (add esp,0x20 at both call sites) -- cdecl, NOT thiscall, even though ecx is loaded with the fleet for the range helpers. Real body 0x007066c0..0x00706907 then 8 int3 to the next start 0x00706910; Ghidra's 584 is correct. IT IS NOT A PATH FINDER: no frontier, no visited set, no relaxation, no recursion. It walks the caller's already-chosen destination list and calls ClassifyLeg 0x00703730 once per consecutive pair, accumulating flags (OR) and the index of the FIRST failing leg. RETURNS TRUE for every call with a non-null fleet and non-null start; all outcome information is in flagsOut/failIdxOut. Leading-destination drop at 0x00706722: if dests[0] is the fleet itself or the fleet's current SYSTEM (LocID with +0x14==0), dests is advanced and count decremented IN THE SOLVER'S OWN FRAME -- the caller's count is unchanged, so typesOut/routesOut end up shifted by one and the last element is never written. Three fuel figures: StarFleet_MinRange(fleet,0) for the pre-flight probe, FUN_00705d60(fleet,true) as the refuel reset, FUN_00705d60(fleet,false) as the running budget, clamped to >= 0 at the top of every leg. Per-leg draw-down uses an INLINED Mars_Vec3_Length (three f32 deltas, one narrowing on the sum of squares, one on the sqrt, one on the subtraction). The store of 0x009e22bc into the NodeRoute local on the back edge is the INLINED destructor (the Mars::IStreamable base vftable), not a branch and not a missing re-init -- the ctor runs again at the top of the next iteration. Two callers, both direct, none indirect: FUN_005e6d50 (UI, dry run: flags only, then a confirmation dialog if flags != 0) and StrategyServer_OrderFleetMove 0x008653c0 [verified] +constexpr uint32_t PathSolver = 0x003066c0; +// thiscall int (StarFleet* this, MapObject* from, MapObject* to, float* rangeInOut, int* flagsOut, NodeRoute* routeOut) // RET 0x14. Returns the WAYPOINT TYPE for one leg: 0 (no move possible), the owner's species drive type, 3 (node route), 4 or 5 (gate transit). Real body 0x00703730..0x00703bc9 then 6 int3 to 0x00703bd0. flagsOut may be null (a stack dummy is substituted and the whole flag block at 0x00703846 is skipped). Order of decision: (A) if `to` is a fleet, try to intercept it via FUN_00703650; (B) raise the flag bits; (C) if `to` is a deep-space point the player may not use, raise 0x400; (D) if the player has a gate at one end, check gate traffic against NGts*PrGtTrf and return 4 (gate->gate) or 5 (gate->gateless within CstR); (E) if the species drive type is not 3, return it unchanged -- every non-node race stops here with no range check and no route record; (F) otherwise solve the single node-line hop. Endpoint kinds from MapObject->+0x14: 0 system, 1 fleet, 2 deep-space point. The route record is written ONLY when the returned type is 3; for every other type it is left {nrp:-1, nrf:0, nrt:0} [verified] +constexpr uint32_t StarFleet_ClassifyLeg = 0x00303730; +// constant int // ClassifyLeg flag bit 0x001, set at 0x00703897. Some ship in the fleet is performing a cancellable action; OrderFleetMove cancels them all and proceeds. A WARNING, not a refusal -- the UI's dry run (flags != 0) shows it, the server's mask (flags & 0x418) ignores it [verified] +constexpr uint32_t PathFlag_ShipActionsWillCancel = 0x00000001; +// constant int // ClassifyLeg flag bit 0x002, ORed at 0x00703b10 from the errBits local seeded at 0x00703a0d. The leg's EXISTING node line is beyond the fleet's remaining fuel (LegInRange FUN_006ffa00 false). Not a refusal: OrderFleetMove installs the plan anyway [verified] +constexpr uint32_t PathFlag_NodeLegOutOfRange = 0x00000002; +// constant int // ClassifyLeg flag bit 0x004, set at 0x007039c5 when owner->GTraf(+0x14c) + fleet->+0xc0 would exceed owner->NGts(+0x144) * owner->PrGtTrf(+0x148). The leg then returns type 0. NOT one of OrderFleetMove's refusal bits, so a plan can be installed over gate capacity with a type-0 first waypoint. The fleet's own cost is zeroed first if its CURRENT waypoint is already a gate transit (it is already counted) [verified] +constexpr uint32_t PathFlag_GateTrafficExceeded = 0x00000004; +// constant int // ClassifyLeg flag bit 0x008, set at 0x00703859. THE FIRST OF OrderFleetMove's THREE REFUSAL BITS. The destination is a FLEET that is itself traversing a node route, and no interception point could be computed -- FUN_00703650 requires the mover to be sitting at one of the two ends of the target's node line and the whole line to be in range. Not raised when the target fleet is not node-travelling at all [verified] +constexpr uint32_t PathFlag_CannotInterceptFleet = 0x00000008; +// constant int // ClassifyLeg flag bit 0x010, set at 0x0070386d when ANY ship in the fleet satisfies StarShip_IsGroundedByDamage (destroyed drive). THE SECOND OF OrderFleetMove's THREE REFUSAL BITS. CLEARED again at 0x007039d3 on the successful gate-transit path -- a Hiver gate throw ignores dead drives, the same rule the retreat pipeline reaches from the other side via its species-1 bypass [verified] +constexpr uint32_t PathFlag_FleetGrounded = 0x00000010; +// constant int // ClassifyLeg flag bit 0x020, set at 0x00703b8b. No node line joins the two systems for this player and the fleet lacks the node-bore capability (StarFleet_HasFlagShips(fleet, 0x20000, 0) is false). Returns type 0. Not a refusal bit [verified] +constexpr uint32_t PathFlag_NoNodeLineAndCannotBore = 0x00000020; +// constant int // ClassifyLeg flag bit 0x040, ORed at 0x00703b10 from errBits after it is re-seeded at 0x00703b63. A node line was successfully bored but the leg is still out of fuel range. Distinguishes 'ran out of fuel on a line that already existed' (0x002) from 'ran out of fuel on a line we just made' (0x040) [verified] +constexpr uint32_t PathFlag_BoredLineOutOfRange = 0x00000040; +// constant int // ClassifyLeg flag bit 0x080, set at 0x00703b7d when FUN_006e4de0 (bore a node line between two systems) returns false. Not a refusal bit [verified] +constexpr uint32_t PathFlag_NodeBoreFailed = 0x00000080; +// constant int // ClassifyLeg flag bit 0x100, set at 0x00703a42. A node-drive leg from a deep-space POINT to a SYSTEM whose owner is neither the player nor a player with a positive relation (FUN_00817890). Not a refusal bit [verified] +constexpr uint32_t PathFlag_DestSystemNotFriendly = 0x00000100; +// constant int // ClassifyLeg flag bit 0x200, set at 0x00703a6a. The mirror of 0x100: a node-drive leg from a SYSTEM that is not friendly-owned to a deep-space POINT. Not a refusal bit [verified] +constexpr uint32_t PathFlag_SourceSystemNotFriendly = 0x00000200; +// constant int // ClassifyLeg flag bit 0x400. THE THIRD OF OrderFleetMove's THREE REFUSAL BITS. Two sites: 0x007038c5 (the destination point is in neither of the player's two per-point masks at point+0x8c and point+0x90, and the player is not species 4) and 0x00703ad3 (a node-drive leg to a point the player may not use). At the first site the leg then returns 0 for a gate or node drive and the plain drive type otherwise [verified] +constexpr uint32_t PathFlag_DestPointNotPermitted = 0x00000400; +// constant int // ClassifyLeg flag bit 0x800, set at 0x0070388a. The fleet contains a ship whose current action (ship+0x4c) is exactly 8. Singled out of the general 0x001 warning by masking bit 8 out of the action bitmask before the 0x001 test. A WARNING, not a refusal [verified] +constexpr uint32_t PathFlag_ShipActionEight = 0x00000800; +// constant int // 0x400|0x010|0x008. The literal in `test DWORD PTR [ebp-0x10],0x418` at 0x00865499 -- the only bits that make StrategyServer_OrderFleetMove refuse. On a hit it logs level 2 with the .rdata format at 0x00a31e44, "StrategySim: %s (%s) move not permitted at this time.", with the fleet's FtName(+0x5c) and the owner's name string (owner+0x40), both read through the MSVC std::string SSO test. Every other bit is either advisory or a route-quality complaint the server commits anyway. The UI dry run at 0x005e6da0 instead tests flags != 0, which is what surfaces the whole word to the player [verified] +constexpr uint32_t PathFlag_OrderRefusalMask = 0x00000418; +// cdecl int (int species) // 50 B. A 7-ENTRY JUMP TABLE at 0x0080c804, resolved byte by byte: Human(0)->3, Hiver(1)->0, Tarkas(2)->1, Liir(3)->2, _NPC(4)->0, Zuul(5)->3, Morrigi(6)->6; anything above 6 -> 0. THE ANSWER TO THE TYPE-2 QUESTION: waypoint type 2 is the LIIR drive, and it is unreachable for any node-drive race by construction. The value it returns IS the waypoint type for every leg the gate block and the node-route block decline, so a fleet's default waypoint type is a pure function of its owner's species -- no ship data, no terrain, no tech [verified] +constexpr uint32_t DriveTypeOfSpecies = 0x0040c7d0; +// thiscall int (StarFleet* this) // 118 B, no stack args. Returns 0 for an empty fleet, else DriveTypeOfSpecies(this->PID(+0x58)->Species(+0x5c)), else 0 if the fleet has more than one ship and any ship disagrees. ORIGINAL DEFECT: the disagreement loop at 0x006ff853 re-reads the FLEET's owner species on every iteration instead of indexing ship i, so the compared value is loop-invariant and the loop can never fail. As shipped it is dead code; reproduce it as written rather than 'fixing' it to read per-ship data [verified] +constexpr uint32_t StarFleet_GetDriveType = 0x002ff810; +// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, RET 4. return (this->GFlags(+0xdc) >> p->PlyrIdx(+0x28)) & 1. IDENTIFIES GFlags: combat-retreat-pipeline.md lists +0xdc as 'a second presence source (not read here)' -- it is the per-player GATE mask, and the whole waypoint-type-4/5 branch of ClassifyLeg is built on it [verified] +constexpr uint32_t ServerSystem_HasGate = 0x00344010; +// thiscall bool (ServerPlayer* this, ServerSystem* from, ServerSystem* to) // 150 B, RET 8. return from && to && 0.0f < this->CstR(+0x150) && ServerSystem_HasGate(from,this) && !ServerSystem_HasGate(to,this) && Mars_Vec3_Length(from->Pos - to->Pos) <= this->CstR. GIVES CstR A READER: strategic-turn-internals.md records it as unused; it is the GATE PROJECTION RADIUS -- how far past a gate a fleet can be thrown when the far end has no receiving gate. Its result is exactly the 4-vs-5 choice in ClassifyLeg (`add eax,4` after `setne`), so waypoint type 5 is a Hiver gate throw at a GATELESS system, not the Zuul node bore or Morrigi gravity casting. Length via Mars_Vec3_Length, so two float32 narrowings; the comparison is non-strict [verified] +constexpr uint32_t ServerPlayer_GateProjectionReaches = 0x00418040; +// thiscall int (ServerPlayer* this) // 14 B, no frame: `mov eax,[ecx+0x148]; imul eax,[ecx+0x144]` = this->PrGtTrf(+0x148) * this->NGts(+0x144) -- per-gate traffic times gate count, both saved ints. Compared against GTraf(+0x14c) + the fleet's own int16 cost at fleet+0xc0 [verified] +constexpr uint32_t ServerPlayer_GateTrafficCapacity = 0x0040dc50; +// thiscall float-free bool (StarFleet* this, MapObject* a, MapObject* b, float* rangeOpt) // 170 B, RET 0xc. THE ONLY FLOAT IN THIS SUBSYSTEM THAT DECIDES A FAILURE. dx,dy,dz each stored to a float32 slot; the sum of squares accumulated on the x87 stack and narrowed to float32 ONCE at 0x006ffa46; r = rangeOpt ? *rangeOpt : StarFleet_MinRange(this,0.0f); r = min(r, FUN_006ff710(this)) with both candidates read back from float32 slots; then `fmul st(0),st` computes r*r AND LEAVES IT IN THE REGISTER -- it is never stored. So the comparison is f32(sumsq) <= (double)r*(double)r, NOT f32(sumsq) <= f32(r*r). A reimplementation that narrows the square disagrees exactly at the boundary. Non-strict: equality returns true (test ah,0x41 then jp) [verified] +constexpr uint32_t StarFleet_LegInRange = 0x002ffa00; +// thiscall float (StarFleet* this) // 155 B, no stack args. Min over the fleet's ships of Ship_MaxRange 0x0080c820, seeded FLT_MAX from the .rdata word at 0x009e23a8, EXCEPT that it returns 0.0f (not FLT_MAX) for a fleet with no ships and EXITS EARLY the moment the running minimum is <= 0 -- so it is not a pure min if a zero-range ship precedes a negative one. Used only to cap the range in StarFleet_LegInRange [verified] +constexpr uint32_t StarFleet_MinTankCapacity = 0x002ff710; +// thiscall int (NodeGraph* this, ServerPlayer* p, ServerSystem* a, ServerSystem* b) // 303 B, RET 0xc. THE ONLY GRAPH STRUCTURE IN THE PATH SUBTREE, and it is a SINGLE-HOP ADJACENCY QUERY, never a search: it returns the path index of a node line joining a and b that player p has discovered, or -1. Rejects null args and a==b by system index (+0x5c). Gate: a TRIANGULAR adjacency array at this->+0x24 indexed (hi-1)*hi/2 + lo with one bit per player, so a pair the player has not discovered short-circuits to -1. Then it walks a hash bucket, accepting entries whose {+0xc,+0x10} pair matches in either order and whose +0x2c mask carries the player's bit, and returns entry->+0x8. ORIGINAL DEFECT: the ranking term FUN_006e2130((this->+0x4)->+0x8) depends only on `this`, so it is identical for every candidate; with best seeded at -1 the FIRST matching bucket entry always wins and every later one is dropped on the non-strict `score > best`. As shipped the tie-break is hash-bucket order [verified] +constexpr uint32_t NodeGraph_FindNodeLine = 0x002e4eb0; +// cdecl bool (MapObject* node, StarFleet* fleet) // 85 B. owner = MapObject_GetOwner(fleet); returns true if any fleet parked at the node and owned by that player carries a ship with capability mask 2 (the tanker bit), OR if the node has an owner whose relation to the fleet's owner is >= 3. NOTE the relation scale: strategic-turn-internals.md 5.2 records FUN_0080e050 as '1 ally, 2 NAP, 3 cease-fire', which would make this 'refuel at a cease-fire system but not at an ally's'; FUN_006d2050 was NOT read, and two call sites use the same scale with different thresholds (>= 3 here, > 0 in FUN_00817890), so 5.2's ordering should be re-checked. Called by PathSolver only when the destination's kind tag (+0x14) is 0, i.e. a system -- reaching one resets the running fuel budget to full tanks [verified] +constexpr uint32_t StarFleet_CanRefuelAt = 0x00303c90; +// thiscall ServerPlayer* (MapObject* this) // 26 B, no frame. switch on this->+0x14: 0 (system) -> this->PID(+0x100); 1 (fleet) -> this->PID(+0x58); anything else (2 = deep-space point) -> 0. Confirms the kind tag's three values from a third, independent site [verified] +constexpr uint32_t MapObject_GetOwner = 0x0031e280; +// thiscall MapObject* (MapObject* this) // 12 B, no frame: return (this->+0x14 != 0) ? 0 : this. A checked downcast to the kind-0 (system) case, written with the neg/sbb/not/and branchless idiom [verified] +constexpr uint32_t MapObject_AsSystem = 0x0031e340; +// register-live-in bool (/* ebx = StarFleet* mover, esi = StarFleet* target -- BOTH LIVE-IN, NEITHER WRITTEN */ float* rangeIn, MapObject** systemOut) // 214 B, cdecl stack frame but it TESTS ebx AND esi WITHOUT EVER WRITING THEM. Reading it as a plain two-argument cdecl function produces nonsense; its one caller (StarFleet_ClassifyLeg at 0x00703831) supplies both registers. Returns false unless the target has waypoints, its front waypoint is type 3, and FUN_00703520 accepts the geometry. On success *systemOut is the system to aim at: if the mover sits at the target's destination, aim at the target's node-transit ORIGIN; if it sits at the origin, aim at the destination; otherwise return true with *systemOut left 0. The transit origin is FUN_006ffab0, which resolves FlightPlan.pnd(+0xf8) through the entity hash at (fleet->galaxy(+0x10))+0x80 -- SO pnd IS THE NETWORK ID OF THE NODE TRANSIT'S ORIGIN OBJECT [verified] +constexpr uint32_t StarFleet_SolveFleetIntercept = 0x00303650; +// thiscall MapObject* (StarFleet* this) // 43 B, no frame. Returns 0 when the waypoint vector is empty, else IDMap resolve of this->FPlan.pnd(+0xf8) through (this->galaxy(+0x10))+0x80. Pairs with StarFleet_ResolveWaypoint 0x00701390, which resolves the front waypoint's Wpt id through the same map: origin and destination of the current node transit [verified] +constexpr uint32_t StarFleet_GetNodeTransitOrigin = 0x002ffab0; +// thiscall int (StarFleet* this) // 101 B, no stack args. OR of (1 << ship->+0x4c) over every ship satisfying FUN_0081f880, i.e. every ship whose current action is neither 0 nor 6 and is not action 8 with bit 3 of ship->+0x1c set. ship+0x4c IS THE SHIP'S CURRENT ACTION: OrderFleetMove open-codes the identical three-way predicate at 0x008655ed and calls the 'Ship leaving %s is still doing %s. Cancelling action.' cancel FUN_00849280 on every ship that passes it. The mask feeds ClassifyLeg's warning bits: bit 8 becomes 0x800, anything else becomes 0x001. Nothing bounds-checks the shift, so an action enum >= 32 would be UB [verified] +constexpr uint32_t StarFleet_PendingShipActionMask = 0x002ff990; +// thiscall bool (StarFleet* this) // 80 B, no stack args. True if ANY ship in the fleet satisfies StarShip_IsGroundedByDamage 0x00815090 (a destroyed drive, tested with FLT_EPSILON rather than zero). Sole producer of ClassifyLeg's 0x010 refusal bit [verified] +constexpr uint32_t StarFleet_AnyShipGroundedByDamage = 0x00300240; +// thiscall void (StarFleet* this, Waypoint* wpts, int count, int originId) // 514 B, RET 0xc. Real body 0x00707080..0x00707281 then 14 int3 to 0x00707290. EVERYTHING IT WRITES IS SAVED STATE: GTraf(+0x14c) debited by the int16 at fleet+0xc0 if the OLD front waypoint was a gate transit; FPlan.wpts assigned from a zeroed temp then the new list inserted; FPsp2(+0xd8) 0.0f then recomputed by FUN_00705c70; FPeta2(+0xdc) 0; FPogn2(+0xe0) zeroed then set to the fleet's Pos -- the position the order was given from; FPdpos(+0xec) zeroed then set to the FIRST waypoint target's Pos, resolved through the IDMap at (fleet->galaxy)+0x80 and left zero if it does not resolve; pnd(+0xf8) 0 then originId; FtTrans(+0xfc) = wpts[0].Tp, A SECOND SAVED COPY OF THE FIRST LEG'S WAYPOINT TYPE; FtOrig(+0x100) = the fleet's Pos; then GTraf re-credited if the NEW front waypoint is a gate transit. Checked on all 11 curated saves: FtTrans == wpts[0].Tp on 46 of 46 flight plans [verified] +constexpr uint32_t StarFleet_SetFlightPlan = 0x00307080; +// thiscall void (Waypoint* this, int Tp, const NodeRoute* r) // 34 B, RET 8: this->Tp(+0x8) = Tp; this->nrt.nrp(+0x10) = r->nrp(+0x4); this->nrt.nrf(+0x14) = r->nrf(+0x8); this->nrt.nrt(+0x18) = r->nrt(+0xc). Pins Waypoint = {vptr, int Wpt@+4, int Tp@+8, NodeRoute nrt@+0xc} at 0x1c bytes, cross-checked by the 0x92492493 divide-by-28 at 0x00865594 and the add edi,0x1c stride. The vptr of the destination is not touched [verified] +constexpr uint32_t FlightPlan_Waypoint_Set = 0x003006e0; +// thiscall NodeRoute* (NodeRoute* this) // 24 B, no frame, returns this in eax: vptr = 0x00a1cbdc (the Game::NodeRoute vftable), nrp = -1, nrf = 0, nrt = 0. THE DEFAULT nrp IS -1, NOT 0 -- and -1 is also what ClassifyLeg writes for a freshly bored node line, which is why the Zuul saves carry a mix of -1 and real path indices while the Human save carries only non-negative ones [verified] +constexpr uint32_t NodeRoute_Construct = 0x002e1b20; +// cdecl bool (StarFleet* f) // 90 B. FUN_006fe320(f) returns f->LocID(+0xa0) ONLY when the location's kind tag (+0x14) is 2, a DEEP-SPACE POINT -- never a system. Returns true iff any of the three position components differs by exact IEEE comparison (fucompp, test ah,0x44, jp), no epsilon. OrderFleetMove's opening snap is therefore point-only; combat-retreat-pipeline.md 2.5's 'snaps the fleet's position onto its current system' is corrected here -- a fleet parked at a system is never snapped [verified] +constexpr uint32_t StarFleet_PosDiffersFromPointLocation = 0x0040ec50; // thiscall void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player..TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries [verified] constexpr uint32_t TurnCommands_Write = 0x00442540; // thiscall void __thiscall Game::TurnCommands::Read(Mars::IStream* s) -- the reader paired with TurnCommands_Write (0x00842540). 1543 bytes; not decompiled by lane Q, listed so the pair is on the record [mapped]