DS2 Revival

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):

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:

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.

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 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)BeforeAfterSteam DS2
Uncapped (maxfps=0), test PC, 3440×1440~60 fps~350 fps~350 fps
A player's 165 Hz screen, borderless~45 fps160+ fps (refresh-rate cap), 170-200 uncapped-
Main thread (at the game's default 75 fps cap)0.88 core, 82% in .rld0.36 core, 2.5% in .rld0.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.

PortServiceModule
UDP 27900Availability check (09 00000000 dsiege2 → FE FD 09 + status) and QR2 heartbeats from hosts, with the challengeudp27900.py, browser.py
TCP 28910Server browser (SB v2, GOA list cipher): the portal list and per-portal game listsbrowser.py
TCP 6667Chat (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/29901Accounts: GameSpy Presence login and profile searchgp.py
TCP 29920Party vault (gstats persistent storage) and game-stats recordsgstats.py
UDP 27910DirectPlay relayrelay.py
TCP 8080This website and its JSON APIweb.py

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