Quality-of-life and content patches for the original Dungeon Siege (2002) — the first game, by Gas Powered Games, as sold by GOG and Steam. Not Dungeon Siege II, not Dungeon Siege III, and not the Legends of Aranna expansion.
Built and run on Linux, against the Windows game under Wine/Proton, with no
Siege Editor and no Windows toolchain required. The tools read and rewrite the
game's own .dsres/.dsmap tank archives directly. Most of what this adds is
content the game already ships and does not offer you.
It is an unofficial fan project with no affiliation to Gas Powered Games, Microsoft, Square Enix or GOG. It ships no game assets — you need your own copy of Dungeon Siege.
Evidence for every change is in docs/qol-backlog.md; recon notes are in docs/findings.md.
Dungeon Siege's ZoneMatch matchmaking service was shut down years ago.
Bringing it back — a self-hosted lobby, the MOTD server, the DirectPlay work
and the proxy DLL — lives in a companion repo, zonematch, not here.
The split is by who installs it: this repo is what a player installs,
zonematch is what runs on a machine other people connect to.
point-at-server.sh and both exe patches are here rather than there, because a
player installs them and a server operator does not.
Every tool reads your install location from one environment variable. The GOG,
Steam and disc installs all land somewhere different, so nothing here guesses:
each tool checks that the path it was given actually holds a Logic.dsres (or
a DungeonSiege.exe) and refuses with a message naming what it wanted.
There is a last-resort fallback to ~/Games/Dungeon Siege, which fails that
check on most machines and is therefore not a working default. It is a real one
on a machine where the game is there — so if you keep a second install for
testing (second-install.sh), set the variable rather than relying on it:
export DS_GAME_DIR="/path/to/Dungeon Siege"
export DS_PREFIX="$HOME/Games/Heroic/Prefixes/default/Dungeon Siege" # Wine prefixOr put both in a .dsrc at the repo root, which game-mode.sh sources and
which is gitignored — it is the one file here allowed to name a real directory
on a real machine:
# .dsrc
: "${DS_GAME_DIR:=/path/to/Dungeon Siege}"
: "${DS_PREFIX:=$HOME/Games/Heroic/Prefixes/default/Dungeon Siege}"The := form means an existing environment value still wins, so a one-off
DS_GAME_DIR=/other ./tools/game-mode.sh … overrides the file for one command.
Then:
./tools/game-mode.sh choose # build and install
./tools/game-mode.sh status # what is actually installed right now
./tools/game-mode.sh classic # the world as shippedRequirements: Python 3.11+ and bash. That is all. Every tool runs on the
standard library, so you can clone this and use it with no package manager at
all. python-lzo is optional and needed only for an LZO-compressed tank — the
shipped ones are zlib, and tank.py turns the missing import into an
explanation rather than a traceback. If you use uv:
uv sync # dev tools (ruff, pyright)
uv sync --extra lzo # ...and python-lzo, if you hit an LZO tankNothing here contacts the network, with exactly one opt-in exception: if you
pass a URL to build_qol_mod.py --seed, it fetches that URL to read a world
seed your group publishes. No telemetry, no update check, no downloads.
game-mode.sh is the only supported way to build. Anything not in its
BASE array or in a build default is not shipping, whatever a module docstring
says — that rule was learned twice, from features that silently un-shipped
themselves.
The build patches the tanks in your own install, in place, keeping a
pristine *.orig beside each and always building from that. It ships no game
assets and redistributes nothing copyrighted — you need your own copy of the
game, and that is deliberate.
Add-on tanks in Mods/ and Maps/ are silently ignored by the engine, and a
same-path file in a higher-priority .dsres does not override
Logic.dsres (findings 28) — which is why in-place patching is the delivery
route rather than a choice.
tools/install_state.py prints what differs from stock and which repo artifact
explains it.
Every byte this project puts into your install is auditable, and there is a command that proves it rather than a promise that it is so.
Exe patches ship as annotated assembly, not hex — docs/patches.md is the disassembly with the reasoning for each one, and it is generated, not hand-maintained:
tools/patch_exe.py --status # which patches are applied to your exe
tools/patch_exe.py --audit # regenerate the reviewable patch document
tools/patch_exe.py --verify-asm # disassemble each patch's bytes and check
# them against the listings in the documentEvery patch is reversible with --revert.
The plugin DLL (tools/dsdll/dsr_fubi.dsdll) is the one compiled artifact
in the repo, and it is committed next to its source — 163 lines of
hand-written x86 in dsr_fubi.S, a nine-name .def, and a build script — so
that you do not need a Windows cross-compiler just to use the camera sliders.
Two commands settle whether the committed bytes are what the committed source
says:
cd tools/dsdll
./verify_offsets.py # read the offsets back OUT of the DLL and check each
# one against your own DungeonSiege.exe. No toolchain
# needed -- this is the check that matters.
./build.sh # rebuild from source and diff against the committed
# binary. Needs mingw-w64 (Arch: mingw-w64-gcc,
# Debian/Ubuntu: gcc-mingw-w64-i686).The build is deliberately reproducible — the link pins the image base,
zeroes the PE timestamp and disables the build-id, because a binary you cannot
rebuild bit-for-bit is a binary nobody can check. A clean ./build.sh ends in
reproduces the committed binary byte for byte; if it does not, it replaces the
binary and tells you, and verify_offsets.py prints the disassembly that
settles which side was wrong.
- Companions in multiplayer. Kingdom of Ehb shipped ten recruitable companions with the join offer stripped out; the Utraean Peninsula shipped none. Both fixed, the second by re-templating a standing townsperson.
- Conscripts — filler party members placed in the world and recruited by talking to them, so a party can be filled without eight named companions existing.
- Monster variation at runtime. Multiplayer keeps no world state, so every session restarts and the road between towns is the same road every time. A component baked onto 117 monster templates picks a power-and-role-matched substitute as each actor enters the world, covering ~9,100 placed actors across both maps. Host-only, and the choice replicates — so there is no seed to agree on, no rebuild between sessions, and nothing for joiners to install. The Peninsula's World Difficulty dropdown gains a varied twin of every setting, so the host chooses in the create-game screen.
- Content the game ships and does not offer. The character creator was
reading a hand-written shortlist, not enumerating the art: 76 of 207 shipped
textures. Five more playable models cost nothing. 113 of the 127 gated
monster spells are made holdable and priced by fitting
required_levelagainst the shipped start gates rather than by taste. 62 gated equipment templates — the Gobbot arsenal, the Seck/Ice/Droog lines, named boss weapons — are unlocked and priced with GPG's owncalc_gold_value$formula. - Draw distance. The camera pullback, the distance fog and the streaming
radius are one system: raise the fog alone and the horizon goes black, pull
the camera back alone and the frame washes out.
--see-farsets all three from one number and refuses the combinations that do not hold. - Camera controls in the game's own options menu. Options > Game > More
gains Minimum/Maximum Camera Azimuth and Distance, and they move the live
camera. This is the first thing here that needs code rather than data: the
exe exports the four camera getters and no setter, and
UISlideris the one UI control class it does not register with FuBi.tools/dsdll/is a nine- export plugin DLL that supplies both, as annotated assembly with every offset cross-checked against the exe's own getters. The engine has scanned for*.dsdllsince 2002 — see docs/findings.md section 47. - Render resolution, which turned out to be an INI setting rather than a patch, plus the resolutions the video menu never offered.
- Modern GPUs, recognised as such.
config/system_detail.gasis a hardware database from 2002, and a card that is not in it inherits vendor defaults written for pre-Radeon ATI parts: a reduced render path, downgraded shadows, no trilinear filtering, "below minimum spec". Every real Radeon in the file overrides all of it; a modern one gets the Rage profile only because it is not in the list. Unrecognised cards now get the profile GPG gave the newest card they supported. Measured effect on frame rate: none so far — it is on because what it says about the hardware is true, not because it bought anything. docs/performance.md has that measurement, the flag whose name means the opposite of what it looks like, and theDungeonSiege.inikeys that override the database outright. - Exe patches for the ZoneMatch update nag and multiplayer portrait selection, distributed as annotated assembly rather than opaque binaries.
Known dead ends are recorded as such in the backlog rather than deleted, because several of them looked correct for days first.
LICENSE MIT, plus what it does and does not cover
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
typings/lzo.pyi stub for python-lzo, which ships none
.dsrc YOUR install paths. Gitignored; see Setup
docs/qol-backlog.md what we want to build, with the evidence behind it
docs/findings.md recon evidence — tanks, content database, the client
docs/performance.md why the draw distance costs what it does, and the
knobs that are not game settings
docs/patches.md the exe patches, as annotated assembly
docs/install-baseline.json sha256 of a stock install, for drift detection
tools/game-mode.sh THE ENTRY POINT. Build, install, and report status
tools/tank.py read, rewrite and add to .dsres/.dsmap tanks (no Wine)
tools/build_qol_mod.py the content mod: patches the shipped tanks in place
tools/patch_exe.py exe patches, as annotated assembly rather than hex
tools/install_state.py what differs from stock, and what explains it
tools/perf.py render-path knobs: which D3D device, CPU pinning, DXVK
tools/hotstacks.py where the CPU time goes: per-thread load, sampled stacks
tools/dxvk.conf DXVK tuning for the ddraw->d3d9 wrapper path
tools/party_saves.py inspect and tidy the multiplayer character list
tools/dsdll/ the FuBi plugin DLL: what the engine cannot be told
in data alone. Assembly, a .def, and a verifier that
checks every offset against your own exe
tools/monsters.py monster power/role/size model + the substitution plan
tools/monster_census.py what each map places, how strong it is, what is wired
tools/spells.py the spell model: triage and pricing for monster spells
tools/items.py the item model: level, band power, GPG's gold formula
tools/characters.py the creator's skin lists, and what a playable needs
tools/unlock_census.py what the game ships and does not offer you
tools/point-at-server.sh repoints DungeonSiege.ini at a lobby, with backup
tools/second-client.sh launch a second client for two-player testing
tools/second-install.sh a hardlinked 2nd install, for testing MISMATCHED content
Artifacts this build writes into your install are prefixed dsr —
Resources/zz-dsr-qol.dsres, .dsr-build.json, dsr_fubi.dsdll, and a
// dsr: comment on every generated line of .gas. It stands for Dungeon
Siege Resiege, and it is there so that install_state.py — and you —
can tell at a glance which files in a 1.1 GB install came from this repo.
uv sync # once: ruff and pyright, pinned by uv.lock
uv run ruff check tools/ # lint
uv run pyright # type checkWithout uv, uvx ruff check tools/ and uvx --from pyright pyright do the same
thing without installing anything, and the tools themselves need neither.
Both are clean, and are meant to stay that way. Type checking runs at pyright's
standard level across tools/, with a per-file strict list in
pyrightconfig.json that files join once they are fully annotated — tank.py
is on it. strict across the whole tree reports ~5,500 problems, of which
about 130 are real; standard reports the real ones and nothing else, which is
why the strictness is ratcheted per file rather than set globally.
Every lint waiver in pyproject.toml carries the reason it is waived. The
large one is UP031: these modules generate .gas and .skrit, both
brace-delimited, so f-strings would mean doubling every brace in the emitted
text.
Ruff's target-version and pyright's pythonVersion are both pinned to the
requires-python in pyproject.toml (3.11), not to whatever is installed —
otherwise the linters would happily suggest idioms that break for anyone on the
oldest version this claims to support.
Mods/ scanning does not work (findings 9c). It is the difference between
a distributable mod and patching the stock tanks in place. Everything here
becomes shippable to other people the moment that is solved, so it gates the
rest.
MIT, for this project's own source: the Python tools, the shell
scripts, the hand-written x86 in tools/dsdll/, and the documentation.
It covers nothing else. No game assets are included or redistributed —
every tool here operates on a copy of Dungeon Siege that you already own and
installed yourself, and docs/install-baseline.json holds SHA-256 hashes of a
stock install, not any of its contents. Dungeon Siege is a trademark of its
respective owners; this project is unofficial, non-commercial, and unaffiliated
with Gas Powered Games, Microsoft, Square Enix or GOG.