sots-re/findings/subsystems/multiplayer-gamespy.md

495 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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 <a.b.c.d[:port]>` | 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`) | `<gamename>.available.gamespy.com` **UDP 27900** |
| **serverbrowsing / SB** (master list) | `0x0041c700`–`0x00421000` | yes | yes — via `ServerBrowserNew` `0x00420060` | `<gamename>.ms<N>.gamespy.com` **TCP 28910** |
| **QR2** (query & reporting, heartbeat) | `0x0041d000`–`0x0041f000` | yes | yes — `qr2_init_socket` `0x0041d1a0`, `qr2_register_key` `0x0041e730` ×16 | `<gamename>.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** — but see the note below: never actually bound on the two-player direct-join arm |
| `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). *Confirmed live twice: lanes W2 and L2 both saw zero TCP
sockets on either SOTS process at any point, including during combat.*
> **Live result on `CombatHostPort` (lane L2, `multiplayer-combat.md` §4).** The default is read
> correctly, but on the two-player direct-join arm **3370 is never bound** — the combat lockstep runs
> over the strategy connection the host already has open on 3369. `CombatHostPort` presumably serves
> the case where `SNMHostCombat` / `SNMHostCombatReply` elects a *different* player, or a dedicated
> server, to host the battle; that cannot happen when the strategy host is itself a combatant.
> Untested for three-plus players, for a battle between two clients, and for `sots_server.exe`.
---
## 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 <status : 4 bytes, big-endian>
```
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.