Sphenx asked me some days ago in Discord if i was planning a pyDM2. And to be honest, i had a copy of DM2 for Amiga, but never played it. I think at that time i was more into PSX. But when I started it here in DosBox-x, I remembered, the setup was odd. Lots of animation on screen, the walking was different and the "brown cave" with high tech reincarnation was not the kind of "feeling" i had with the original DM.
Nevertheless, i had a look at SKProject, and since I have more time now, my pyDM runs stable, I took the challenge and looked at the DM2 code. Then I thought, maybe i should take a look at the disassembly of the game, and then i thought, ok, how could we create a reDM2 Source, since DM2 seems to be not this high quality on the data / code on Wiki Side.
So, i created a new project on my ssd and thought about how I could achieve this disassembly project with the use of maybe some AI Agents. The first try was a complete failure. I just threw some "ideas" into the agent + "have a look". After some (3-4h) crunching on the assembly, i had an executable which showed the main menu, the about (staff) and new game was working only to see just a dungeon view in brown. I noticed I couldnt get further here, this way, how i approached this wouldnt work. so i changed my complete way and created some harnesses (against the halucinations, ai peeking at SKProject or at the website to cheat about the code).
Im not sure, and here i have to say: "RESPECT Christophe for reDMCSB!", if this task is really achievable, but I think with the possibilities we have now in LLMs, it could be. So here is my share on this topic:
The target
Dungeon Master II — The Legend of Skullkeep, FTL/Interplay 1995, DOS release. SKULL.EXE is 522,637 bytes: a DOS/4GW linear executable, 32-bit protected mode, compiled with Watcom C, statically linked, no symbols. The code object splits into three one-way regions established by call-direction analysis:
Code: Select all
0x10000-0x5c97a 313,722 B 1090 fn FTL game code
0x5c97a-0x62284 22,794 B 86 fn HMI SOS audio library (self-contained)
0x62284-0x695AC 29,480 B 204 fn Watcom CRT + HMI driver loaders
Why byte-matching failed
The original plan was matching decompilation: write C, compile it, and prove correctness by byte-identical output. That requires the original compiler.
The oldest obtainable Watcom is 11.0c (1998); the game is from 1995. Calibrating 11.0c against its own shipped clib3r.lib — roughly 150 flag combinations plus two hill-climbs — plateaued on a residual where every instruction matched in opcode, immediate and position, and only the register allocation differed: every ebx was ecx and vice versa. No command-line option controls that. The library objects in the archive were themselves built by an earlier compiler than the shipped binaries, which is also why zignatures from those objects match a 1995 game that 11.0c's own code generator cannot reproduce.
Byte identity was therefore unreachable, and the project goal became functional equivalence — which needed a different kind of evidence.
What I built
Tier 1 — differential testing (tools/difftest.py)
Unicorn-based. Both the original function and the C port run in separate emulator instances, from identical initial state, driven with the same inputs. Compared: EAX exactly, the complete set of memory writes (captured via UC_HOOK_MEM_WRITE), write ordering, stack balance, and callee-saved register preservation.
The write-set comparison is the important half. A port that returns the right value but writes one byte too many fails, naming the buffer and offset. Side effects cannot hide.
Ports are compiled with native Open Watcom against the original's calling convention (__watcall: EAX, EDX, EBX, ECX) and linked as a DOS/4G LE with a map file, so build-side addresses come from symbol lookup rather than being assumed identical to the original's.
Tier 2 — state snapshots (tools/objstate.py)
Most functions read global state, which made them untestable in isolation. Rather than synthesising that state, the fixture runs the original's own load path in the emulator: savegame_load → dungeon_load → seven packed-bitstream records → object_state_load, then the startup stretch through the first gameplay draw. Only five file-I/O leaves are stubbed — functions whose entire body is an int 21h.
The result is a real heap with real globals, dumped as a snapshot both difftest sides preload identically. Functions testable: 20 → 77.
Getting there required serving int 0xfc. IBMIOP.EXE was unpacked by loading its MZ image in 16-bit Unicorn and letting its own LZEXE stub decompress itself — no algorithm reimplemented. Its dispatcher accepts 28 codes, 21 of which are no-ops; the implemented handlers are 7–23 byte wrappers. The driver parks bare data pointers in unused interrupt vectors, which is why the game reads a mode-info struct through what looks like a null pointer.
Tier 3 — fixture runs
For functions too entangled to isolate, the original runs over a reconstructed fixture and the resulting heap is inspected. This establishes format understanding, not equivalence of any port — stated as a limitation rather than implied.
Discipline that turned out to matter
Four rules, each written after the corresponding failure.
Known answers. Original-versus-port proves agreement, not correctness — a systematic fault in the snapshot makes both sides wrong identically and the test stays green. Every spec carries at least one case whose expected value was derived independently of the emulation, ranked by independence: a data-file value beats a hand walk over snapshot bytes, which beats a second tool. Reading either emulated side counts for nothing. Adding this immediately exposed a test case that had been mislabelled since the harness's first day and had passed its entire life on agreement alone.
Exact-EAX policy. Watcom frequently leaves garbage in the upper half of return registers because callers only read AX. When those bits track program state — a pointer half, a count — reproducing them is a free assertion the return type doesn't expose, and it caught 31 of 569 cases in one break test. When they're a register-allocation artifact, no source-level C can match them. The discriminator is empirical: sweep and see whether the bits track anything.
Write order. Inverted default: order is artifact until shown observable, because nothing can observe the state between two stores in a non-interruptible function. Values and the final write set are never relaxed.
The analysis scripts themselves. All three tiers verify the original's behaviour; none verifies the script that summarises it. A tie-break in an aggregation script once attributed the wrong category on a single-element set, was recorded as an anomaly, and the next task was framed around explaining it. Nothing crashed, no test failed, no number looked wrong. Countermeasures: aggregation must report ambiguity rather than resolve it, and a surprising result gets checked against raw data before it becomes a finding. Retractions happen in place, where the claim was recorded — a finding that quietly disappears is worse than one never made, because the next reader can't tell which claims were checked.
-----
Crunching now for around 20h.... I found a first bug in the code, it seems FTL copied their blitter routines and modfied the copies, not chcking all paths:
Bug in blit4_copy_mirror_masked (fcn.00011a72)
The function performs a horizontally mirrored, masked 4bpp surface copy — one of four blit variants forming a {plain, mirrored} × {opaque, masked} quartet:
Code: Select all
Address Function Variant
0x11504 blit4_copy plain, opaque
0x116b0 blit4_copy_mirror mirrored, opaque
0x1185e blit4_copy_masked plain, masked
0x11a72 blit4_copy_mirror_masked mirrored, maskedEffect. The last odd pixel of a mirrored masked blit writes the key colour instead of leaving the destination untouched. Transparency fails exactly where it should apply, and applies exactly where it shouldn't.
Why it survived. It requires same parity and an odd trailing pixel count, so it manifests as a single incorrect pixel at one edge of a flipped sprite. The crossed-parity path is unaffected — it jumps into 0x1185e's correct je, so only one of the two paths carries the defect.
-----
So here I stop with so much text. Sorry, Ill keep you informed.
