32 KiB
Multiplayer & GameSpy — what a private replacement server must answer
- Type: subsystem
- Address / RVA: GameSpy SDK occupies
0x00408000–0x00422000(ImageBase0x00400000); game-side callers in0x0076xxxx–0x007cxxxxand0x0089xxxx - RTTI / vftable:
Game::AutoJoin0x00a218f4,Game::StrategyJoin0x00a21904,Game::GameBrowserPanel::ManualJoinDialog0x00a22604 - 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, nogpsp.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
CDKEYsubstring anywhere in the 49,781 extracted strings; theNETERROR_*family is 13 entries and none of them is a key error, and there is noSTARTUPERROR_*family at all. This corrects two claims inui-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.CombatHostPortpresumably serves the case whereSNMHostCombat/SNMHostCombatReplyelects 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 forsots_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:
swordotsis 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 0for any gamename at all, including nonsense — so a successful availability check against the public instance proves nothing about whetherswordotsis really registered there. The real gates are the QR2 challenge and the SB v2 handshake, both keyed onZ5gR9Z, and peerchat'sCRYPT(which OpenSpy'shandle_crypt.cpprejects outright when the DB secret key is empty). Self-host; do not test against the public instance and conclude anything from it. - The seeded
keylistforswordotsis 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 FindWindowAs 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.
-
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.comname from instance B after/joinis parsed. All traffic is UDP on 3369 between the two instances. Falsified if: anygamespy.comDNS lookup or any packet to 27900/27901/28910/6667 appears on the join path, or the join fails withNETERROR_NOCONNECTION/NETERROR_TIMEDOUTwhile both instances are up. -
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 begins09 00 00 00 00 73 77 6f 72 64 6f 74 73 00, retried once at ~2 s. Either way the UI does not showMATCHINGSERVICE_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. -
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. -
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.comandpeerchat.gamespy.comall 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 thequeryportcolumn, a one-row fix, not a protocol problem. -
P5 — the mutex bypass works. Instance B started with
/concurrentreaches 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 from0x0089d3cain the command-line parser, so almost certainly yes — but the command-line arg is copied intoAutoJoinbefore validation, so the failure mode may differ.) - What sets
StrategyHost+0x2c/+0x30? Verified only that+0x2c == 1 && +0x30 != 0selects 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
ServerBrowserNewrequest? Undecoded here, and it decides whether the enctype-0/1-only emulators (gsmaster,mgmse, 333networks) are even theoretically usable. ThequeryVersionargument is [V]1on 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.
disabledservicessemantics differ between implementations (OpenSpy's SQL comment says1 = unavailable, 2 = temporarily unavailable; UniSpy's enum says0 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, so0is the only safe "available" value and1and2both 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.exeis unexamined and is the natural host for the two-client test.