273 lines
16 KiB
Markdown
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.
|