Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZoneMatch

A self-hosted replacement for Dungeon Siege's matchmaking service, which Microsoft shut down years ago. Runs on Linux, speaks the game's own protocol, and needs no modification to the game client.

Recon evidence lives in docs/findings.md. The content and modding half of this project is a separate repo, resiege.

Why this is possible at all

The matchmaking endpoint is not hardcoded. It is a plain-text INI setting:

[multiplayer]
gun_server = dungeonsiege.zone.com
gun_server_port = 2300
news_server = motd.dungeonsiege.zone.com

So pointing a client at your own server takes an INI edit — no binary patch, no DNS hijack, no hosts file. That removes the obstacle that usually kills these projects before they start, and it is why "let players pick a server" is a realistic feature rather than a fantasy.

Status

The lobby works. Two clients find each other through it and play together (findings 23–26). The MOTD leg has worked since early on. What is left is robustness and NAT, not feasibility.

Three legs, wildly different costs:

Leg Transport State
News/MOTD plain HTTP working (tools/motd_server.py)
AutoUpdate HTTP stubbed
GUN lobby TCP 2300, custom binary + XML working (tools/gun_recon.py)

The caveat to internalise early

ZoneMatch was only ever discovery. The lobby hands out IpAddr/Ip2 and then gameplay runs peer-to-peer over DirectPlay 8. A revived lobby gets you a browsable game list; it does not make players reachable through NAT. Pair it with an overlay network (Tailscale, ZeroTier) or you will have a beautiful lobby full of games nobody can join.

Design principles

These are decisions, not preferences, and they shape the architecture — so they are settled here rather than discovered later:

  • Never hand a player's address to another player. The original protocol does exactly that, in IpAddr/Ip2. Any public deployment relays instead. This is the single biggest difference between "faithful to 2002" and "safe to run in 2026", and it has to be decided before the transport is finalised.

  • Credentials are protected by default, not by remembering to. ConInfo carries CdKey and Login carries a password — see docs/gun-messages.txt — and this server holds the private half of the handshake, so it is the first place either has existed in plaintext since the player typed it. tools/confidential.py is the one place that decides what happens to them:

    • On screen, secret-named fields render as a truncated SHA-256 handle rather than being printed. --show-secrets turns that off for a recon session. The default matters because run-servers.sh runs dp_server.py as a lobby, and a lobby's stdout is a terminal someone screenshots or a journal someone greps.
    • On disk, captures/ is created 0700 and every capture written 0600, explicitly rather than inheriting the operator's umask — a common 022 would otherwise leave CD keys world-readable. An existing directory is tightened too, because whoever needs this most has been running an older build.

    captures/ stays gitignored regardless. Committed test data goes in tools/fixtures/ and is checked for personal data first; see dp_dpl8cs.selftest() for the standard.

    The fingerprints are correlation handles for a human reading a terminal — the same password shows the same handle across two frames, which is what the recon work actually needs — not a defence against brute-forcing a short secret from its hash. A transcript full of them is low-sensitivity, not sanitised.

  • So is DungeonSiege.ini, and that one gets pasted into bug reports. A signed-in client writes its ZoneMatch credential straight into the INI:

    zone_userpassword = <base64-ish blob>

    This matters more than the capture rule above, because pointing a client at a server is an INI edit — so the one file this project asks every user to open is also the one holding their password. Any instruction to edit or share it must say "strip zone_userpassword first", and that includes issue templates and point-at-server.sh's own output.

  • Everything headless. Nothing in this repo launches the game, so the whole server can be tested without a Windows install or a Wine prefix. tools/gun_client.py is a headless client for exactly that.

Setup

export DS_GAME_DIR="/path/to/Dungeon Siege"   # only the DLL/INI readers need this
uv sync                                       # or: pip install cryptography
./tools/run-servers.sh

There is deliberately no default install path: the GOG, Steam and disc installs all land somewhere different. $DS_GAME_DIR is how gun_views.py finds the game's own match.ini, which defines the lobby's table schema — without it the Games tab has no columns and stays empty.

Requirements: Python 3.11+ and cryptography. That one dependency covers the RSA keypair and PKCS#1 v1.5 wrapping in the ZoneMatch handshake. RC4, the CryptoAPI blob formats and the GUN framing are implemented here against published vectors, because knowing what the bytes are is the point. It is imported lazily, so every decoder, fixture and selftest runs without it.

Development tools (ruff, pyright) come from uv sync, pinned by uv.lock:

uv run ruff check tools/
uv run pyright

Both are clean and meant to stay that way. Type checking is standard across tools/, with a per-file strict list in pyrightconfig.json — the same ratchet the resiege repo uses. confidential.py is the first file on it, on purpose: it decides whether a credential reaches a terminal, so a wrong type there is a disclosure rather than a traceback.

To point a client at your server, use point-at-server.sh from the resiege repo — client-side tools live client-side.

Layout

LICENSE                   MIT, plus what this does and does not include
pyproject.toml            uv project + ruff config, and why each waiver exists
uv.lock                   pinned dev tools, so `uv sync` is reproducible
pyrightconfig.json        type checking: standard everywhere, strict per file

docs/findings.md          recon evidence — protocol, crypto, DirectPlay
docs/gun-messages.txt     the GUN message/field catalogue

tools/gun_recon.py        the lobby: TCP 2300, framing, hexdump + serving
tools/gun_lobby.py        lobby state — rooms, game list, players
tools/gun_proto.py        framing and message types
tools/gun_crypto.py       the ZoneAuth handshake
tools/gun_views.py        view/field decoding
tools/gun_res_fields.py   resource field tables
tools/gun_client.py       headless client, for testing without the game
tools/motd_server.py      serves /news/news.txt (working)
tools/confidential.py     what happens to a credential: masked on screen
                          unless --show-secrets, 0600 on disk

tools/dp_proto.py         DirectPlay 8, reverse-engineered from captures
tools/dp_dpl8cs.py        DirectPlay 8, the published [MC-DPL8CS] spec,
                          checked against real capture bytes
tools/dp_server.py        DirectPlay host-side probing
tools/dp_learn.py         captures ground truth from the game's own stack
tools/dp_probe.py         DirectPlay enumeration probe
tools/dp_ports.sh         port helpers

tools/gunproxy/           proxy DLL (Zig) that forwards to the real GunDll.dll
                          while logging every call
tools/fixtures/           committed protocol reference bytes, personal-data
                          checked (see dp_dpl8cs.selftest)
tools/run-servers.sh      bring up MOTD + lobby together
tools/test-lobby.sh       exercise the lobby end to end

captures/                 raw dumps, written at runtime. Gitignored and NEVER
                          publishable: ConInfo carries CdKey.

Two paths to a working lobby

  • A: speak GUN on the wire — faithful, works with unmodified installs.
  • B: replace GunDll.dll — the game LoadLibrarys it by name and it is a COM in-proc server with only the four standard exports, so a drop-in replacement needs no registration at all. Behind our own DLL we could speak JSON over WebSocket instead of 2002 binary.

tools/gunproxy/ forwards to the real DLL while logging every call, which recovers the COM interface shape for B and gives A a plaintext view of the protocol, from one piece of work. B is the better destination; A's artifacts are the road there.

Tests

uv run python -c "import sys; sys.path.insert(0,'tools'); import dp_dpl8cs; dp_dpl8cs.selftest()"
./tools/test-lobby.sh    # end to end: two headless clients host, a third browses

test-lobby.sh needs $DS_GAME_DIR set, for the same match.ini reason as above — without it the header checks fail and the game list comes back empty.

A structure that only matches the documentation is a hypothesis; one that also matches a capture is a fact. The selftest checks both, and reports which fixtures were missing rather than quietly passing with half the checks gone.

License

MIT. Original work only: an independent implementation of protocols a retail Dungeon Siege client already speaks, so a client can be pointed at a server its owner runs. No code, assets or data from Dungeon Siege or from Microsoft's ZoneMatch service is included or redistributed — you need your own copy of the game.

The DirectPlay 8 session layer follows Microsoft's published [MC-DPL8CS] specification; the GUN lobby protocol was reconstructed by observing a retail client talk to software on the same machine, for the sole purpose of interoperating with it. LICENSE has the full notice, including the part worth reading twice before deploying anything: this software receives other people's passwords and CD keys in plaintext, because the 2002 protocol sends them that way.

About

A fresh way to host ZoneMatch lobbies for the original Dungeon Siege

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages