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

32 KiB
Raw Blame History

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

  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.