Source: https://dsii.uk/how-it-works

# How it works

Updated 23 October 2026

The technical side: what each plugin changes in the game and why, how the relay and the party vault work, and what runs on the server. Everything here comes from the project's reverse-engineering notes and source code (file names at the end); addresses are virtual addresses in the Steam v2.3 `DungeonSiege2.exe` unless noted.

## The problem

Dungeon Siege II's online play is built on GameSpy's SDKs, linked statically into the exe: availability check and hosting (QR2) on UDP 27900, the server browser on TCP 28910, IRC-style chat ("peerchat") on TCP 6667 for the portals and staging rooms, GameSpy Presence (accounts) on TCP 29900/29901, and gstats persistent storage on TCP 29920 for the online party vault. GameSpy shut its platform down on 2014-05-31. The game traffic itself is DirectPlay 8 client/server (UDP 2302 on the host), not GameSpy.

On top of that the Steam release greys out the Multiplayer button. `UIFrontend::TransitionToMain` (0x44c2d3) looks up `button_multiplayer` and calls `UIButton::DisableButton` on it unconditionally (call at 0x44c37a), so no hosts-file or DNS trick can bring it back in v2.3. The whole multiplayer stack is still in the image.

## Approach: plugins, not a patched exe

Every fix is an `.asi` plugin loaded by the Ultimate ASI Loader (as `version.dll`). Plugins patch the game in memory at start-up. Each patch is version-gated and byte-checked: it is applied only to the exe build it was written for (identified by PE timestamp) and only if the original bytes at the patch site are what the analysis found; otherwise it is skipped and logged. Patches that must also work in Broken World locate their site by a byte signature instead of a fixed address. No game file is written, no code from the game is copied into the plugins.

## DS2Multiplayer

### Multiplayer button

The call at 0x44c37a is retargeted from `UIButton::DisableButton` (0x7663b0) to `UIButton::EnableButton` (0x766440): same calling convention, same `this`.

### Pointing the game at this server

Every GameSpy component resolves `<something>.gamespy.com` through WSOCK32 `gethostbyname` (ordinal 52, import slot 0xa8e650). The plugin hooks that import and answers any `*.gamespy.com` lookup with the address of `[Network] Server`. No hosts file, no DNS changes.

### A CD-key identity for Steam copies

Online play needs the CD-key hash the retail installer stored as `DoubleHash` under `HKLM\Software\Microsoft\Microsoft Games\DungeonSiege2`; Steam installs have none ("Could not find a valid CDKey"). When the real value is missing the plugin serves a virtual one to the game's registry reads: a random 63-bit identity generated once per Windows user and kept in `HKCU\Software\DS2Revival`. A real key always wins. Broken World reads its online key from its own install key (`PID`), which is served the same way, derived from the same identity.

### The START button that did nothing

The staging-area START handler (0x4e32c3) returns early unless `WorldMap+0x1c` is at least the number of human players (`jb` at 0x4e32e1). That field counts the *local* player's party members placed on start positions, cumulatively, and nothing raises it before START in a fresh session; in retail the check only passed on a count left over from an earlier world in the same process. The plugin removes the early return.

### "Session is locked"

The world-state handler (vtable slot 0xa905ec) only creates a multiplayer `Server` (`World::InitServer(1)`) when a state change leaves a game back into the lobby. In a fresh session (main menu → connection → lobby) the server stayed single-player, START loaded a single-player world, and the DirectPlay session was never unlocked, so joiners were refused. The plugin creates the multiplayer server on the connection-select → lobby transition, exactly as the handler does when returning from a game. In Broken World the same bug made joiners crash on READY.

### Join window

A joiner gives up on the direct connection after a hard-coded 10 seconds (compare at 0x4ed86c), but the host only unlocks its session once it has loaded into the world, which takes longer than that. The plugin points that one compare at its own constant, 60 seconds by default (`DirectConnectTimeout`).

### The firewall question

On entering Multiplayer the game asks Windows Firewall (through `CLSID_NetFwMgr`) whether it is in the exception list and offers to add itself, which needs administrator rights the game does not have, so both answers just continue. The plugin answers "class not registered" for that one COM class, which makes the game skip the question; the package's *Firewall setup.cmd* adds proper rules instead.

### Leaving the lobby

Exit → Yes in the lobby went back to the connection menu without the game's lobby-close routine, so the lobby and party-selection screens stayed on screen, and clickable, behind the menus. The plugin hides the lobby (`UIMultiplayer::HideLobbyInterface`, found by export name) and closes the party selection on that transition.

### Eight players

The stock data defines five modes: 4x1, 3x2, 1x4 and two flagged `oldschool`: 8x1 "Old School" and 6x2. The engine is built for 8 (`Server::GetMaxPlayers` returns 8, `CGameOptions::SetPlayerLimit` clamps to 2..8), but both mode lists, the host's and the game-list filter, skip `oldschool` modes online. The plugin lets them be listed. Only the host needs it.

### LAN: "nickname already in use"

LAN mode runs a peer-to-peer chat network in which every instance hosts a small chat server and nicknames are verified across the LAN. The game resolves its own host name and uses every address it gets, so on PCs with extra adapters (Hyper-V, WSL, Docker, VPNs) it meets its own server again and rejects its own nickname. The plugin answers that one lookup with the primary IPv4 address only.

## The relay

DS2 has no working NAT traversal of its own ([below](https://dsii.uk/how-it-works#natneg)): a joiner talks to the host's public address and gives up if that fails. Most players are behind a router, so DS2Multiplayer tunnels DirectPlay through the server instead (UDP 27910):

- **Host.** When `IDirectPlay8Server::Host` succeeds (DS2Multiplayer hooks `CoCreateInstance` for the DirectPlay 8 client and server), the plugin registers (its public IP as the server sees it, its DirectPlay port) with the relay every 10 seconds, and forwards each relayed joiner to the local DirectPlay port through a socket of its own, so DirectPlay sees one peer per joiner.
- **Joiner.** When `IDirectPlay8Client::EnumHosts` targets a host outside the LAN, the plugin points DirectPlay at a local tunnel socket. The enumeration reply comes back through the tunnel, so the following connect goes through it as well.
- **Modes.** `Relay=auto` relays only hosts outside the LAN, `always` relays every game, `off` never (the host must forward UDP 2302).

The relay is built to be safe on a public address: every reply is smaller than its request (no amplification), data is forwarded only between the two registered endpoints of a connection, a host owns its registration through a random per-process token (another PC behind the same NAT cannot take it over), and hosts, connections, connection set-up rate and bandwidth are capped per source address and globally (256 KB/s per connection; DS2 uses roughly 10-30 KB/s per player).

## Accounts and the party vault

**Accounts** use GameSpy Presence (TCP 29900, search on 29901): plain-text `\key\value\...\final\` messages. At login the client proves the password with `md5(md5(password) + 48 spaces + user + client challenge + server challenge + md5(password))` and checks the server's proof, built the same way with the challenges swapped.

**The vault** is GameSpy's gstats persistent storage (TCP 29920): every message is XORed with `GameSpy3D` and terminated by a plain `\final\`. After the storage handshake the client logs in with `\authp\`, proving `md5(password + challenge)`. That is why the server has to keep account passwords as they are. Then:

- record 9 is the vault **index** (`ds2_party_count`, `ds2_save_ver`, `ds2_active_party` and per-party name, size, level, CRC, file size...);
- records 10, 110, ... 710 are the bases of the 8 party slots (free slots hold `Empty`), and each `.ds2party` file is stored in chunks of 15,360 bytes at base + *j*;
- Broken World keeps its own vault next to it (index record 8, slots from 1100).

The client wipes its whole vault (8 slots → Empty) whenever the index reply fails its checks, for example if `ds2_save_ver` differs from its own (62 in v2.3). In the session that creates an account the client writes `ds2_save_ver` 0, which would wipe the vault at the next login, so the server never stores or serves a 0. As a safety net the server also keeps the previous 5 versions of every record, so a wiped party can be restored. Storage is capped at 32 MB per profile.

## DS2Widescreen

The game's menus are an 800×600 layout; in the world the 3D view can take any size. Its window code compares the window plus the border it intends to have with the screen size and, if it does not fit, shrinks the window or falls back to a 4:3 fullscreen mode, so a desktop-sized window with a title bar could never run windowed.

In the default `Mode=borderless` the plugin adds `fullscreen=false width=<desktop> height=<desktop>` to the command line (unless you set them), creates the window without a frame and resizes it to cover the monitor. The game sees a **virtual client** of the size it asked for: window moves are recorded instead of applied, `GetClientRect` and `WM_SIZE` report that size, and only the boundary to the real screen scales (cursor position, mouse messages). The game's three `Present` calls are redirected so the frame is drawn into a centred, aspect-correct rectangle with black bars. Result: menus are the 800×600 art scaled to fit, the world renders natively (3440×1440 tested), clicks land correctly.

`RetryDeviceReset=1` patches the failed-`GetBackBuffer` branch after a device reset (the "Video initialization failure! ... GetBackBuffer" error seen with the Steam overlay): the frame is skipped and the reset retried next frame, and only after 10 seconds of failures does the game take its fatal path.

Frame cap: when it runs in a window the game waits out every frame to its `maxfps` setting, 75 by default (the frame function's limiter, flag 0x400000 for windowed play). The borderless window is a window, so the plugin passes `maxfps` = your monitor's refresh rate on the command line (`[Display] MaxFps=refresh`; `0` = no cap, or a number). A `maxfps` you set yourself wins.

## DS2Fixes

At start-up the game installs a system-wide low-level keyboard hook (`SetWindowsHookExA(WH_KEYBOARD_LL)` at 0x5ecee5) on its frame thread and never removes it. Windows then routes every keystroke of every program through that thread, so whenever a frame is slow, typing anywhere stalls. The game only stores the hook handle, so the plugin refuses that one hook; in a borderless window the Windows key it was meant to suppress is harmless.

Stutter while moving or clicking the mouse: Windows sends `WM_SETCURSOR` for every mouse movement and click over the window, and the game answers each one with `SetCursor(NULL)` followed by `IDirect3DDevice9::ShowCursor(TRUE)` - two real cursor changes per mouse event, thousands per second with a modern mouse. Each can block on the window manager's lock: in a recording of real play the slow frames (25-125 ms) spent about a quarter of their time there. The plugin wraps the game's window procedure and lets `WM_SETCURSOR` through only when the cursor is not already the one the game set (or at most every 100 ms), so cursor changes still show (`[Fixes] QuietCursor=1`). Spam-clicking afterwards: no frame over 14 ms, against 62 ms before.

## DS2BrokenWorld

Broken World is its own exe (v2.30.0.4277). Every available copy is a cracked build of the SecuROM-protected retail exe; the crack's rebuilt code is what runs.

- **Launching.** When `Broken World\DungeonSiege2.exe` exists inside the game folder, starting the Steam game starts Broken World instead (hold Shift for the original). The expansion finds the base game through registry values (`AppPath`, `InstallationDirectory`) that Steam installs do not have; the plugin serves them to the game's registry reads, so no registry script is needed.
- **Steam heroes.** Broken World rejected parties made in the Steam game as "corrupted". Two checks were isolated with hybrid test files: the party format (Steam writes 0x3e; the reader accepts 0x3d, 0x3f and a constant the crack left at 0) and the content CRC (Steam data's 0xD4EE6791). The plugin fills in 0x3e and wraps the CRC check, which is lazily bound and has to be re-hooked after every call. A Steam hero then converts, uploads to the Broken World vault and loads into a hosted game.
- **Camera spin.** Some downloads carry hand edits on top of the crack that broke middle-mouse rotation (the cursor re-centring call removed, so the distance from the centre keeps growing). The plugin undoes each edit only where the exe holds exactly that edit.

## The Broken World frame-rate finding

Players report Broken World at roughly half the frame rate of DS2 in the same scene, and "turn off Memory Integrity" helps some of them but not others. Profiling a test instance showed:

- The main thread used 0.88 of a core; 82% of its samples were in the crack's `.rld` section and 76% sat on the instruction right after a `CPUID` (26 distinct sites).
- These are SecuROM virtual-machine dispatchers the crack kept. Each runs `CPUID` leaf 1 and throws the result away.
- Whenever Windows runs its hypervisor, every `CPUID` traps to it: about 405 ns each on the test PC, against tens of nanoseconds on bare metal.
- Memory Integrity is only one thing that starts the hypervisor. Hyper-V, WSL2, Docker Desktop, Virtual Machine Platform, Windows Sandbox and Credential Guard do too, which explains the mixed reports.

The fix: 187 dispatcher `CPUID` sites, each identified by a CRC, jump to a small stub that preserves the flags, returns the leaf-1 values read once at start-up (what the hardware returns anyway) and runs a real `CPUID` for any other leaf. The site table holds no code bytes; the displaced instructions are copied from the running exe.

| Same scene (the prologue jail) | Before | After | Steam DS2 |
|---|---|---|---|
| Uncapped (`maxfps=0`), test PC, 3440×1440 | ~60 fps | ~350 fps | ~350 fps |
| A player's 165 Hz screen, borderless | ~45 fps | 160+ fps (refresh-rate cap), 170-200 uncapped | - |
| Main thread (at the game's default 75 fps cap) | 0.88 core, 82% in `.rld` | 0.36 core, 2.5% in `.rld` | 0.28 core |

Uncapped the game is no longer limited by its own code: each frame costs about 3-4 ms of mostly Direct3D 9 driver work whatever the resolution (about the same at 640×480), and on screen the desktop compositor and the driver's frame pacing add the rest.

`[BrokenWorld] FastCpuid=0` turns it off.

## The server

One Python process using only the standard library (asyncio, sqlite3); no third-party packages. Load is tiny: tens of MB of RAM. Gameplay traffic goes between players and only passes through the server when it is relayed.

| Port | Service | Module |
|---|---|---|
| UDP 27900 | Availability check (`09 00000000 dsiege2` → `FE FD 09` + status) and QR2 heartbeats from hosts, with the challenge | `udp27900.py`, `browser.py` |
| TCP 28910 | Server browser (SB v2, GOA list cipher): the portal list and per-portal game lists | `browser.py` |
| TCP 6667 | Chat (peerchat): IRC with GameSpy's encrypted handshake and key/value extensions; portals are rooms `#GPG!<id>`, staging rooms `#GSP!dsiege2!...` | `peerchat.py`, `crypto.py` |
| TCP 29900/29901 | Accounts: GameSpy Presence login and profile search | `gp.py` |
| TCP 29920 | Party vault (gstats persistent storage) and game-stats records | `gstats.py` |
| UDP 27910 | DirectPlay relay | `relay.py` |
| TCP 8080 | This website and its JSON API | `web.py` |

- **Portals.** Nine names (Agarrus, Artech, Azunai, Calix, Culahn, Dalziel, Ethaniel, Feandan, Gregor), twice: the client shows a portal only if its `registered` flag matches the mode, so Internet mode and GameSpy mode each get their own set.
- **Staging rooms.** The room name both sides compute encodes the host's public IP, private IP and private port, so the server list carries each host's private address as well. For the same reason this website never shows staging-room names.
- **Room settings for late joiners.** Broadcast room keys (map, game mode...) are sent to players who join a room later, as GameSpy did. Broken World's joiner relies on them: without the map name it crashes.
- **Addresses behind NAT.** The game uses the public IP the server reports both for the staging-room name and as the address a joiner dials. A server behind NAT is given its public address (`--public-ip`) so players in the same house are reported with it; same-machine test clients on 127.0.0.1 are reported with the machine's LAN address, because DirectPlay does not bind loopback.
- **Data.** Accounts in `gamespy.sqlite3`, the vault and game-stats records in `gstats.sqlite3`, this website's history in `web.sqlite3`.

## Why not NAT negotiation

DS2 links GameSpy's NAT-negotiation client, but static analysis shows nothing in the exe ever starts a negotiation: the code that would send a negotiation cookie has no callers. When a joiner's DirectPlay connection fails it shows `nat_negotiation_error` and gives up. Even a successful negotiation would not help, because it runs on its own UDP socket and its result is stored in fields nothing reads, so DirectPlay never sees it. The server code includes a complete NAT-negotiation implementation (`natneg.py`), but the standard launcher does not start it; the relay replaces it.

## Sources

In the project repository: `docs/multiplayer/` (`menu-button.md`, `start-guard.md`, `status.md`, `flow.md`, `natneg.md`, `gamespy-gp.md`, `gamestats.md`), `docs/architecture/display.md`, `docs/brokenworld.md`, `docs/hosting.md`, the plugin sources in `mods/` and the server in `server/ds2master/`.
