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