How it works
Updated
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): 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::Hostsucceeds (DS2Multiplayer hooksCoCreateInstancefor 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::EnumHoststargets 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=autorelays only hosts outside the LAN,alwaysrelays every game,offnever (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_partyand 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.ds2partyfile 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.exeexists 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
.rldsection and 76% sat on the instruction right after aCPUID(26 distinct sites). - These are SecuROM virtual-machine dispatchers the crack kept. Each runs
CPUIDleaf 1 and throws the result away. - Whenever Windows runs its hypervisor, every
CPUIDtraps 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
registeredflag 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 ingstats.sqlite3, this website's history inweb.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/.