# Multiplayer Tier 0, run on the real game: two players, one guest, no server - **Type:** subsystem (live verification) - **Status:** **verified** — a two-player multiplayer game was started, joined and played on VM140 with no GameSpy service of any kind, and the whole run was captured outside the guest - **Confidence:** high. Every claim below is a screenshot or a packet. - **Owner / date:** lane W2 · 2026-09-08 - **Tests:** `findings/subsystems/multiplayer-gamespy.md` §7 (lane G2's five predictions, written before any VM run) and `sots-engine/docs/W2-predictions.md` (this lane's restatement, committed before the run) --- ## 0. The headline **SOTS1 multiplayer is not dead, and restoring it needs no server.** Two clients on one Windows guest, joined by typed IP, played two full turns in lockstep. Over the entire session the capture on the host side of the guest's NIC shows **44,319 packets and exactly zero** to UDP 27900, UDP 27901, TCP 28910, TCP 28900, TCP 6667 or TCP 6500 — every GameSpy port the SDK in this binary knows about. Lane G2's Tier 0 was the cheapest item on the board and it landed exactly as G2 read it out of the instruction stream. Four of its five predictions are confirmed; the fifth (Tier 3, the self-hosted master server) was deliberately not attempted and is reported as not-run. **And the game says so itself.** The Message of the Day panel on the main menu is fetched live, on every launch, from `www.kerberos-productions.com/motd/motd_EN.txt` — the server still answers today, `Last-Modified: Fri, 16 Jun 2017`, served by LiteSpeed at 106.0.62.78. Its text, in full, is Kerberos's own 2012 notice: > Attention SolForce personnel; > > It is with wide, astonished eyes that we have to report that Gamespy was recently purchased by a > third-party. This party then began shutting off game servers and informing developers and > publishers that they would have to pay tens of thousdands of dollars a month in upkeep, in order > to keep the servers on. This despite the fact that SotS1 used Gamespy thanks to a contracted and > not-cheap agreement between Lighthouse Interactive and Gamespy. > > We are looking into our options. In the meantime, you will not be able to play via Internet. > However, you can still play the game via direct connect with your friends. **Game hosts please > note they have to host the game in LAN mode, not Internet mode, or direct connect will not > work.** That is the `this+0x2c == 1 && this+0x30 != 0` predicate G2 recovered from `0x007c2dd0`, written by the developer, and it is shipped inside the client we are testing. The fetch uses `User-Agent: GameSpyHTTP/1.0` — the ghttp SDK component — which is the one piece of GameSpy code on any live path, and it talks to Kerberos, not to GameSpy. --- ## 1. What was run VM140, GOG 1.8.1, single Win10 guest, `hooks=off` — **the instrument was removed** (method rule 19), because the question is what the shipped game does, and because two processes sharing one trace file is not a measurement. Capture: `tcpdump -i tap140i0` on **spicy**, i.e. on the host side of the guest's tap device. That is outside the guest, so it cannot perturb it, and it sees everything the VM emits. **The blind spot, stated up front:** loopback traffic between the two instances never reaches the tap, so this capture cannot show the game's own UDP 3369 exchange. That is the right trade — the predictions that matter are all about traffic that *leaves*, and the join working is evidence enough that the loopback path carried it. The socket table is the corroboration (§3). Established before the run, and it is what makes §4 a real test: from the guest, `swordots.available.gamespy.com` returns **NXDOMAIN** while `www.google.com` resolves. There is no stale wildcard record at `gamespy.com`; the SOA in the negative answers is `ns-889.awsdns-47.net` / `awsdns-hostmaster.amazon.…`. ## 2. P5 — `/concurrent` — **confirmed** Instance A launched normally. Instance B launched as ``` "C:\SOTS\Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369 ``` Both reached their own window and their own main loop: `Get-Process` showed **two** PIDs (4728 and 8780), and B's own stdout log records the full startup — VFS mount, D3D adapter, `ver.1.8.1 Wed Dec 13 03:38:31 2017`, profile switch — rather than the foreground-and-exit path. The `CreateMutexA` / `ERROR_ALREADY_EXISTS` / argv-compare chain G2 read at `0x0089ddaf` behaves exactly as read. **A second instance on one guest is a supported, shipped configuration**, and the two-client test needs one VM, not two. ## 3. P1 — direct join with zero GameSpy traffic — **confirmed, both halves** Instance A: *Host Multi-Player* → **Custom** + **LAN** → OK → Custom Game Setup (2 players, 28 stars, defaults) → *Create Game* → game name `MyGame` → lobby. At that moment Windows Defender Firewall raised its "has blocked some features of this app" prompt for `C:\sots\sword of the stars.exe` — the host had just opened its listening socket. Allowed for private networks. **This is worth knowing for anyone reproducing the setup: the host, not the joiner, triggers the firewall prompt, and it appears at *Create Game*, not at launch.** Socket table with the host in the lobby (`Get-NetUDPEndpoint`, filtered by PID): | PID | role | UDP bindings | |---|---|---| | 4728 | host | **0.0.0.0:3369**, 0.0.0.0:60924 | | 8780 | joiner | 0.0.0.0:60925 | Three facts fall out. The host binds `HostPort` **3369** exactly as `Game_LoadNetworkConfig` defaults it (there is no `[Network]` section in `sots.ini` at all, so every value in G2 §3 is a compiled-in default in this install). **No TCP anywhere**, confirming G2's import-table reading (no `listen`, no `accept`). And `CombatHostPort` **3370 is not bound at lobby time** — it must be opened later, when a combat starts, which no lane has yet observed. > **Corrected by lane L2 (`multiplayer-combat.md` §4).** The observation is right; the inference is > wrong. L2 fought a two-human battle with the socket table polled throughout and **3370 is never > bound at all** — not at lobby time, not during combat loading, not while the tactical simulation > was running. The combat lockstep rides the already-open strategy connection. Port-forwarding > advice should say **3369 and only 3369** for a two-player direct join. The joiner appeared in slot 2 of the host's lobby within ~50 s of launch. Both instances then showed two player cards, the client's view carrying *Ready* / *Leave Game* and the host's *Launch* / *Cancel Game*. Client pressed Ready; host pressed Launch. **The game started.** Turn 1, two distinct human players — the host is `Venkman`, the client is `Hitomi`, different portraits, different homeworlds, different treasuries. Each pressed *End Turn*; the turn resolved and **both** advanced to Turn 2 (host Imperial Savings 11,200,596; client 11,299,146 — different players, same turn). A second round took both to **Turn 3**. Capture over the whole session: **zero** packets to any GameSpy port; **zero** DNS queries for any `*.gamespy.com` name during the hosting and joining sequence. The only names resolved in that window were Windows telemetry. **This is the multiplayer revival, demonstrated end to end.** Not "the code path exists" — a game was created, joined, launched and played. ## 4. P2 — the availability check fails open — **confirmed** Fresh single instance, DNS cache flushed, *Join Multi-Player* → **Internet** → OK. The Game Browser opened on the Internet tab with an empty *Available Games* list, the MOTD panel below it, and **no `MATCHINGSERVICE_UNSUPPORTED` dialog** — no "Online support … is no longer available", nothing. G2's fail-open reading of `GSIStartAvailableCheck` / `GSIAvailableCheckThink` is correct on the running game. Exactly **one** A query each, all answered NXDOMAIN, and **no retry**: ``` 14:39:35.670 A? swordots.available.gamespy.com. -> NXDOMAIN 14:39:35.940 A? swordots.ms5.gamespy.com. -> NXDOMAIN 14:39:36.108 A? peerchat.gamespy.com. -> NXDOMAIN ``` Three refinements to G2 §7's P2. **(a)** One query, not one-or-two: the two-attempt/2000 ms retry at `0x0040a2bb` is a *socket* retry, and it never runs because the socket was never created. **(b)** The browser resolves the **server-list** and **peerchat** hostnames on the same screen entry, not just the availability host — so a `hosts`-file redirect for Tier 1 must cover `ms5` and `peerchat` as well or those two will still fail. **(c)** `swordots.master.gamespy.com` and the two `natneg` names were **not** resolved: they belong to the host/reporting path, not the join path. **Consequence for the revival plan: Tier 1 is confirmed unnecessary.** G2 hoped finding it a no-op would be a good outcome; it is a no-op. Nothing has to answer the availability check for a client to reach the browser, the manual-join dialog, or a game. ## 5. G2 §7 caveat (a) — falsified G2 flagged, honestly, that it had not read the *Join Manually* button's enable predicate and that the button might be disabled without a browser object. It is not. On the Internet page with an empty list and every GameSpy name dead, **Join Manually is enabled**, and it opens a dialog with *Enter Host's IP Address*, *Enter Password*, a *Favorites* list, and Join/Cancel. The GameSpy-free arm G2 traced from `Game_ManualJoin_OnAccept` is reachable in the shipped UI in the state the world is actually in. ## 6. P3 — LAN discovery with no server — **confirmed, and my own prediction was wrong** I rated this *lower* than G2 did, and wrote down why: both instances are on one host, so a broadcast to `255.255.255.255:3369` and the host's QR2 socket might contend for one UDP port. Written before the run, and wrong. The socket table in §3 shows why: **the client never binds 3369** — it broadcasts from an ephemeral port (60925) — so there is no contention to have. With A hosting in LAN mode, B's *Join Multi-Player* → **LAN** page listed the game **on first open, with no manual Refresh**: | Game Name | Players | Ping | Password | Status | Version | |---|---|---|---|---|---| | `re : MyGame` | 1/2 | 32 | No | Staging | 1.8.1 | That row is the QR2 custom-key set of G2 §4.2 rendered by the client, answered by the host's own socket, with no master server in existence. Selecting the row and pressing **Join** also worked: B went straight into the lobby. So both join paths — manual address and browser-mediated — work GameSpy-free. One thing G2 could not settle and this run does: **the browser-mediated LAN join makes no NAT negotiation attempt.** No `natneg1`/`natneg2` name was ever resolved and nothing was sent to UDP 27901. G2's reading that NatNeg hangs off the *browser* join is right about the code, but on the LAN path (`queryVersion = 1`, `lanBrowse = 1`) it is not reached. ## 7. P4 — the self-hosted master server — **not run** Deliberately. Tier 3 is a container deployment plus a DNS redirect; it is a lane's work on its own, it is not on the path to *playing*, and running it before Tier 0 was proved would have been the wrong order. Nothing here confirms or disconfirms the OpenSpy / UniSpyServer plan, and §4's finding that Tier 1 is unnecessary does **not** generalise to Tier 3 — the Internet list stays empty because nothing answers, and only a real SB v2 + QR2 backend can change that. Reported as not-run rather than unknown: the experiment is fully specified in `multiplayer-gamespy.md` §5 and §7, and the two extra hostnames in §4(b) above are a correction to its `hosts`-file list. ## 8. What this run did NOT cover (rule 15) Loudly, because the headline is a strong one and it was established on a narrow workload. - **One guest, loopback only.** No two-machine LAN test, no VPN, no NAT, no port forwarding. The claim "two boxes on a LAN work" remains G2's inference plus Kerberos's MOTD; what is *measured* is two processes on one box over 127.0.0.1. - **No combat.** Both turns resolved without a battle, so `CombatHostPort` 3370 was never bound and the combat lockstep — the part with a 1000 ms `CombatLatency` and its own sync checking — is entirely untested. This is the most likely place for the direct path to still be broken. **Closed by lane L2 on VM141 (`multiplayer-combat.md`): it is not broken.** Two humans fought a player-versus-player battle at a contested colony off one shared lockstep clock, both reached the combat report, and both resumed on the same turn — with zero GameSpy packets and 3370 never bound. - **Two turns.** Long-run desync, the `SyncCheckStrategy` machinery, and reconnection are untested. - **Two players, both Human, both on defaults.** No password, no teams, no alliances, no scenario, no more than two slots. - **`sots_server.exe` was not run.** It is present in `C:\SOTS` alongside `Dedicated Server Launchpad.exe`. The community bug G2 quotes ("the dedicated server does not seem to work") is still unreproduced and unexplained, and it is now the cheapest remaining multiplayer item. - **The shim was `hooks=off`**, so nothing internal was observed — no message types, no wire formats, no sync-check payloads. Everything above is UI, sockets and packets. ## 9. Files - Capture (filtered to DNS, every GameSpy port, the game's own ports and the MOTD host): `verify/results/multiplayer/w2-tier0-filtered.pcap` - Screenshots: `verify/results/multiplayer/w2-lobby-client.png`, `w2-mp-turn1.png`, `w2-mp-turn2-host.png`, `w2-mp-turn2-client.png`, `w2-internet-browser-no-error.png`, `w2-join-manually-dialog.png`, `w2-lan-browse-listing.png` - Predictions, committed before the run: `sots-engine/docs/W2-predictions.md` ## 10. Recipe, so nobody has to re-derive it At 1024x768, with the game at the main menu: - *Host Multi-Player* (512,478) → **Custom** (446,287) → **LAN** (446,428) → OK (551,522) - Custom Game Setup: *Create Game* (835,707) → name dialog OK (600,392) - Firewall prompt: *Allow access* (623,550) — appears once, on the first host - Host lobby: *Launch* (931,663). Client lobby: *Ready* (931,663), *Leave Game* (931,692) - *Join Multi-Player* (512,506) → **LAN** (446,357) / **Internet** (577,357) → OK (551,451) - Game Browser: *Join Manually* (781,245), *Refresh* (874,245), *Join* (965,443), first row (200,159), *Exit* (989,716) - Second instance: `schtasks` task running `"C:\SOTS\Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369`. It must be a scheduled task with `LogonType=InteractiveToken`; a process started straight from the SSH session lands in a different window station and cannot be seen or clicked. - Clicking a *specific* instance needs a helper that resolves the window by **PID** (`Get-Process -Id`), not `Select -First 1` — the existing `C:\SOTS\ui\click_helper.ps1` picks whichever instance it finds first. Lane W2 left `C:\SOTS\w2\ui.ps1` (takes a PID, or `-1` for "do not foreground anything", which is how the firewall dialog gets clicked); it reads the PID from `C:\SOTS\w2\pid.txt` and the commands from `C:\SOTS\w2\cmd.txt`, and is run by the **SOTSUI2** scheduled task, which is registered and left in place. - **The second-instance launcher is staged but NOT registered.** `C:\SOTS\w2\sotsb.xml` and `C:\SOTS\w2\argsB.txt` are on disk; re-register with `schtasks /Create /TN SOTSB /XML C:\SOTS\w2\sotsb.xml /F` and put the arguments in `argsB.txt`. It was deliberately left unregistered so a stray run cannot start a second game under a later lane. - **PowerShell over SSH needs `-ExecutionPolicy Bypass`** even for `-EncodedCommand`, or any `& … .ps1` inside it dies with `running scripts is disabled on this system` — and, because the failure is inside the encoded block, it looks like the script ran and did nothing. This cost one wasted launch here. ## 11. Corrections filed - Against `multiplayer-gamespy.md` §7: caveat (a) is falsified (§5); P2's "exactly one DNS query" is right but for a different reason, and the browser resolves **three** names not one (§4); P3's mechanism is confirmed but the single-guest port-contention worry (this lane's, not G2's) was unfounded (§6). - The MOTD is **live**, not canned: this corrects any assumption that a 2026 launch is offline. One outbound HTTP GET to `www.kerberos-productions.com` happens on every launch and on every entry to the Game Browser.