Some bugs do not live in GPL bytecode: they live in DSUN.EXE.
Combat AI loops, sprite culling, save/exit, sound-related bugs,
inventory removal of charged weapons. For those, we patch the
executable directly.
This is well-trodden territory (the entire ROM-hacking and DOS-game patching community has been doing this for thirty years). The risk is bounded; the practice is mature.
The binary layout, format disproof (no DOS/4GW; Borland/TLINK
VROOM overlaid 16-bit real mode), and hash-verification details
are the canonical domain of dsun-exe-re.md §1.
Both games' DSUN.EXE hashes are pinned in
source-hashes/ and enforced by the patch
manifest.
| Tool | Role |
|---|---|
radare2 (r2) / r2pm |
Primary disassembler, scriptable |
ghidra |
Heavier static analysis when r2 stalls |
dosbox-staging --debug |
Live debugging in the original engine |
nasm (16-bit mode) |
Assemble patch bytes (tools/exe-patch --asm shells out to it; pwntools/keystone are not used, see re-tooling.md) |
xxd, bvi, hexedit |
Manual hex inspection |
Most on Fedora via sudo dnf install radare2 hexedit (or pip /
uv tool for the Python ones). DOSBox-Staging via Flatpak:
flatpak install flathub io.github.dosbox-staging.
⚠ ghidra is NOT in the Fedora repos (verified against F44:
dnf list --available ghidra finds nothing). This line used to
say dnf install radare2 hexedit ghidra, which silently fails the
whole transaction. Ghidra is a manual install, and it needs
JDK 21 while Fedora 44 ships only 25 and 26. The working setup
on this machine, plus the three import traps specific to a
Borland-overlaid real-mode target, is documented in
dsun-exe-re.md §7 and summarised in the repo
CLAUDE.md under "Host RE tooling".
GPL fixes are tried first. If the bug behaves the same regardless of which GPL script is at the wheel: for example, a graphics glitch, an inventory state corruption, a crash without a discernible quest trigger: it's likely engine-side.
In r2:
r2 -A .games/ds2/DSUN.EXE
afll # list functions
fs strings; fs * # list strings
/r <symptom-string>
Symptom strings ("Saving...", "Combat", error messages, names of items involved in the bug) are the fastest entry points. The inventory-removal bug, for instance, is likely near a string like "depleted" or near the inventory-render path.
The DSO v1.0 client's debug symbols (per
upstream-projects.md) list function
names from the WotR codebase. Cross-reference DSO
function_<x>_<y> symbols against r2-discovered functions in
WotR's DSUN.EXE.
Use r2's visual mode (V) and graph view (VV) to read the
function. Set a breakpoint in dosbox-staging's debugger
(dosbox-staging -debug), trigger the bug, watch state.
Two patterns are common:
- One-byte fix: a JE → JNE (74→75) or vice versa. A wrong branch, easy to flip.
- NOP-out fix: a faulty call gets replaced with
90 90 90..., removing it.
Anything more complex (insert new code, call a new function) requires a code cave: find an unused area in the binary, write the new logic there, redirect a JMP. Standard fare for ROM hackers.
The patch artifact format is specified once, in
fix-format.md: a darkfix fix script
(fixes/NNN-<short-id>.py) whose EDITS carry
{"offset", "expect", "replace"}: the fingerprint-gated,
in-place-only byte edit this section used to sketch as a TOML
applier that was never built. Author EXE edits against
ndisasm -b 16 output and ovr-map's addressing; author GPL
edits with gpl-asm --patch and paste the resolved offsets into
the fix script.
Two options:
- Hex-pair format (the format, resolved 2026-09-06): the TOML format above. Human-readable, easy to review in a PR, easy to apply; per-edit fingerprints and composability are load-bearing properties a whole-file diff cannot carry.
bsdiffwas considered for larger fixes and is not adopted (seebuild-environment.md2). Darkfix edits are bytes-to-hundreds of bytes; the size win never applies.
We use the hex-pair format, period; and in-place only, always.
Three checks:
- Hash of the patched binary matches the post-patch hash recorded in the manifest.
- Disassembly of the patched binary in r2 reads sensibly (no garbled instructions).
- In-game: the bug repro fires the bug on unpatched, does not fire on patched.
The format disproof (no DOS/4GW; Borland/TLINK VROOM overlaid
16-bit real mode) and the full evidence chain are in
dsun-exe-re.md §1. Practical consequence for
patching: all code is 16-bit; ndisasm -b 16 or pwn disasm arch='i386', bits=16 is mandatory: a 32-bit decode produces
convincing garbage without erroring.
- Wrong-version binary. A patch built against DS2 1.10 will not apply cleanly to DS2 1.0 or 1.02. The manifest's source hash check is the line of defense.
- Compounding patches. Two fixes that touch nearby bytes can
conflict. The applier checks each
expectindependently: if patch A modified bytes patch B expected, patch B refuses. - Anti-debug. None known in DSUN.EXE; if any surfaces, we document and route around it.
- WotC IP. Binary patches are derivative works. Each ships only the byte deltas, never the full executable. The player provides their own legitimate copy.
A worked example will go here once the first DS2 binary fix
ships. Until then, see
patch-workflow.md for the end-to-end
authoring process.