sots-re/findings/subsystems/multiplayer-tier0-verified.md

273 lines
16 KiB
Markdown

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