495 lines
32 KiB
Markdown
495 lines
32 KiB
Markdown
# 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.
|