Skip to content

DOS and 16-bit games ​

Most of GameFrame was built on a 64-bit Windows game. DOS games (1985-2000) need different tools at three steps: reading the executable, emulating code, and running the original. This page covers every DOS step. The skills link here. The tools are tested against examples/dos-sample/ (our own 16-bit code) by tests/run.sh.

1. Which kind of DOS program is it? ​

bash
python3 tools/dos/dosinfo.py target/GAME.EXE              # format, segments, imports, fingerprints
python3 tools/dos/dosinfo.py target/GAME.EXE --pstrings 6 # Pascal strings, with their addresses
dosinfo saysWhat it isGhidraEmulate one functionRun it all
MZReal-mode DOS (Turbo Pascal, Turbo C, Microsoft C, QuickBASIC)"Old-style DOS Executable", loaded at segment 1000dos_emu (real mode)DOSBox-X
NE16-bit protected mode: Windows 3.x, OS/2 1.x, Borland Pascal 7 or Borland C++ with DPMI16 (RTM.EXE, DPMI16BI.OVL)"New Executable", selectors 1000, 1008, ...dos_emu (protected mode, same selectors)DOSBox-X (or Windows 3.1 inside it)
LE / LX32-bit flat code behind a DOS extender (Watcom + DOS/4GW, PMODE/W)needs an LE/LX loader extensionnot yet in the toolkitDOSBox-X
PEA Windows programuse tools/pe/peinfo.pype_emu.pyWindows or Wine

Packed executables (LZEXE, PKLITE, EXEPACK, DIET: dosinfo's fingerprints) must be unpacked first, for example with UNP or by dumping memory from DOSBox-X after the unpacker has run.

Data appended after the MZ load image is an overlay (Borland .OVR data starts with FBOV), a packed payload, or debug symbols.

2. Addresses: always Ghidra's numbering ​

Write every address as seg:off the way Ghidra prints it, in docs, labels and tests:

  • NE: segment N of the file has selector 0x1000 + 8 * (N - 1). Segment 1 is 1000:, segment 10 is 1048:. Imports go after the last segment.
  • MZ: the load image starts at segment 1000, so 1000:0000 is the first byte after the header; a second segment in the image is 1000 + paragraphs.

dosinfo.py prints Ghidra's selector for each segment. readseg.py, dos_emu, tdinfo.py and decomp_query.py all accept and print these addresses. The running program uses other numbers: real-mode segments where DOS loaded it, or the DPMI host's selectors. Only the DOSBox-X debugger shows those (section 7).

3. Debug symbols: check before anything else ​

Borland compilers wrote Turbo Debugger symbols into the program (TPC/BPC /V, TLINK /v), and many shipped games kept them. They give the original names of every procedure and global, the parameters and locals of each procedure, record layouts, and code-to-source-line maps.

bash
python3 tools/dos/tdinfo.py target/GAME.EXE summary          # units, counts
python3 tools/dos/tdinfo.py target/GAME.EXE procs makeenemy  # address, size, parameters, locals
python3 tools/dos/tdinfo.py target/GAME.EXE types enemy      # record fields with offsets
python3 tools/dos/tdinfo.py target/GAME.EXE labels > analysis/td-labels.tsv
scripts/ghidra-export.sh --labels analysis/td-labels.tsv target/GAME.EXE

With --labels, Ghidra names every function and global, and creates the functions it missed.

tdinfo.py turns the record types into reimplementation code. tdinfo.py BINARY rust TYPE ... writes Rust structs with readers in the original's field order, each field commented with its Pascal type and offset, and tdinfo.py BINARY rust-reader the PascalReader they use (little-endian integers, one-byte Boolean, 6-byte Real kept exact, string[N]). Generated names that clash with Rust's prelude get Rec (Optiontype -> OptionRec). Records also get write, the inverse of read.

tdinfo.py BINARY rust-globals writes one Globals struct holding every variable of the data segment at its original offset, with load and store to and from a 64 KB image of the segment (Booleans stay u8, so store writes every byte back unchanged). Ported code then works on Globals. A test can load the DS image from before an emulated run of the original, run the port, store, and compare with the DS image from after. One test then checks every variable.

tdinfo.py BINARY rust-locals PROC does the same for one procedure's local variables and parameters, at their BP offsets in the stack segment: for code nested in a big procedure (a main loop's helpers that use its locals through the static link).

The runtime library (System, Crt, Dos) has no symbols of its own. Borland Pascal 7's random generator, for example, is RandSeed := RandSeed * $08088405 + 1 (a near NextRand built from 16-bit multiplies and shifts), Random(n) = (new seed * n) >> 32 using all 32 bits, and Randomize seeds from INT 21h AH=2Ch (CX low word, DX high word). Find it by the constant 0x8405 and the global RandSeed, and measure it before trusting this. Record field offsets are summed from field sizes: tdinfo says when a record's fields do not add up to its size (a variant record), so check one offset in the code before relying on it.

4. Decompiling 16-bit code with Ghidra ​

scripts/ghidra-export.sh works for MZ and NE as it does for PE. Things that differ:

  • Library search crashes the import. An NE program that imports a module (RTM) whose file sits next to it (RTM.EXE, a plain DOS program) made Ghidra's loader throw "Invalid index". The export script passes -loader-libraryLoadDepth 0, so Ghidra never searches.
  • No-return calls. Ghidra does not know that INT 21h with AH=4Ch (exit) or Pascal's Halt/RunError never return: the next procedure's code is joined to the end of the caller. Split it by creating the function at the right address (labels do this).
  • Missed functions. Procedures reached only through relocations or tables are not found. Debug-symbol labels fix this; otherwise use scripts/ghidra-run.sh BINARY decompile.
  • Pascal calling convention. Arguments are pushed left to right and the callee removes them (retf N). Ghidra shows these as __stdcall16far with param_1 being the last Pascal argument. Read parameters from [bp+6] upwards: the first at the highest offset.
  • Parameter numbers drift. Ghidra may split one far-pointer parameter into two param_N halves, so every later number is off by one; in_stack_X and local_X names are offsets from a frame base two bytes away from BP. Take each parameter's BP offset from the disassembly, and name it from the caller's push list (mov di, 0x649e; push ds; push di is the global at 0x649e), not from how the pseudocode uses it: a "change" counter may really be a speed.
  • A big procedure's helpers see its frame. A nested procedure reads its parent's locals and var parameters through the static link (mov di, [bp+4], then [di+...]); map those offsets to the parent's names before reading the helper.
  • Scans with string instructions. repe scasb over an array finds the next element that differs from AL; the loop then resumes the scan with the saved CX and DI. Port it as a loop over the array. Check how it stops (often or cl, cl on the low byte only).
  • Which carry counts. In add dx, bx; add dx, 1180h; add dx, ax; jae ok, only the last add sets the carry the jump tests; the sum wraps freely before it. Port the test on the same instruction, not on the whole sum.
  • Stale globals are inputs. A routine may test a temporary that another routine set earlier in the frame (a tempi2 left by the movement code). Keep the read, because the original's result depends on it. A test catches it only if the port leaves the global as the original does.
  • Strings. Pascal strings (a length byte, then the text) are not found by Ghidra's string search. Use dosinfo.py --pstrings and readseg.py BINARY pstr ADDR.
  • Segment arithmetic. DAT_1048_12de is offset 0x12de in the data segment; a far pointer is offset then segment in memory (readseg.py ... ptr).
  • String constants sit just before their procedure. Borland Pascal emits a procedure's string literals right before its first instruction, so the procedure that starts after a string usually uses it. Code refers to a Pascal string by its length byte: search for the address one before the text that dosinfo.py --pstrings prints.
  • Constants that look like globals. A 16-bit constant equal to an offset in DS shows up in pseudocode as &someGlobal (a BlockRead count of 0x138a printed as &tempdat3). Check the instruction (tools/dos/nedis.py) before believing a pointer.
  • Floating point by interrupt. Borland's FPU emulator replaces each x87 instruction with INT 34h-3Dh followed by the instruction's bytes; disassemblers show int 0x3c and nonsense after it. Read those runs as floating-point code (Real, Double), not as interrupts.
  • Calls through placeholders. Disassembling the file directly shows every far call as lcall 0:ffff: the relocation fills it in. tools/dos/nedis.py BINARY SEG:OFF LEN applies the relocations and prints label names for call targets and DS globals.
  • Text files are buffered in the record. ReadChar takes the next byte of the 128-byte buffer inside the TextRec (BufPos, BufEnd), and refills it with one DOS read of BufSize bytes when it is empty. That buffer sits in the data segment, so a port that reads the file another way leaves different bytes there: port the runtime's routines over the record, with a table of open DOS handles and their positions.
  • Text files end at ^Z. On a Turbo Pascal Text file, Eof is true when the next byte is 0x1A, not only at the real end, and Read at the end returns 0x1A. A game that stores binary data in a Text file (demo recordings, scripts) stops early at a 0x1A byte in the right place: model it, and check which positions Eof can see.
  • Finding the game clock. Look for writes to ports 43h/40h (the PIT: mov al, 36h; out 43h, al, then the divisor's two bytes to 40h) and for SetIntVec(8, ...): the new IRQ 0 handler and the divisor give the tick rate (1,193,182 / divisor Hz), and the loop that waits for a counter the handler decrements gives ticks per frame. A handler that also calls the old vector when a running sum carries keeps the BIOS clock at 18.2 Hz.
  • Turbo Pascal types: Real is 6 bytes and not IEEE (readseg.py ... real48), Comp is a 64-bit integer kept in the FPU, string[N] takes N+1 bytes, sets are bit arrays of up to 32 bytes, Boolean is one byte. Records are not padded.
  • Real arithmetic is in the runtime library, with registers. Without {$N+}, Real maths calls the System unit's helpers: the first operand and the result in DX:BX:AX, the second in CX:SI:DI (the low byte of AX or CX is the exponent). A typical block is call int-to-real; mov cx/si/di, constant; call multiply; call round. Two helpers that differ only by mov ch, 0 (Trunc) or mov ch, 1 (Round) before the same near call tell Trunc from Round. Measured in one Borland Pascal 7 program: multiply rounds the 80-bit product to 40 bits, halves up; Round rounds halves away from zero. Do not use floats for this: emulate the helpers over every input the game can give (emu.call(helper, regs={'ax': ..., 'dx': ...})) and port the exact algorithm.

5. Emulating one function: dos_emu ​

python
import sys; sys.path.insert(0, 'tools/emu')
from dos_emu import DosEmu, Long, FarPtr
emu = DosEmu('target/GAME.EXE')               # NE: protected mode with Ghidra's selectors
emu.call('1000:873a', 5, Long(100000))         # Pascal order; returns AX (call_long: DX:AX)
emu.u16(FarPtr(0x1048, 0x12de))                # read a global after the call
emu.trace({'1040:0a12': 'Random'}, args=1)     # record calls (the random draws) with call sites
  • Relocations are applied: selector, far-pointer and offset fixups to the program's own segments, chained or additive. Imports become stubs.
  • DS and SS are the auto data segment, as in a Pascal program. Pass regs={'ds': ...} for code that expects something else, and other registers (regs={'ax': ..., 'cx': ...}) for runtime helpers that take their arguments in registers.
  • An import or interrupt without a handler stops the run with its name and the caller's address. Add stubs={'KERNEL.3': fn} or interrupts={0x21: fn}; a handler reads and sets registers (emu.reg('ax'), emu.set(ax=...)). An import handler returns the number of argument bytes to remove.
  • Not emulated: DOS itself, DPMI services, timers, VGA, sound, the x87 (Borland's FPU emulator fixups are left alone). Code that needs them runs in DOSBox-X instead.

5b. Running the whole program headless: dos_env ​

tools/emu/dos_env/ gives dos_emu a small DOS, BIOS, DPMI host and PC, so a whole Borland Pascal 7 DPMI16 program can start and run, with no DOSBox and no window:

python
from dos_emu import DosEmu, ProgramStopped
from dos_env import DosEnv
emu = DosEmu('target/game/GAME.EXE', max_steps=2_000_000_000)
env = DosEnv(emu, root='target/game', overlay='derived/run', program='C:\\GAME\\GAME.EXE')
emu.run_range(emu.entry, '1000:ca59')            # start-up, until the main loop
env.save_screen('derived/run/frame.png')         # VGA memory + the DAC palette
emu.run_to('1000:ca59')                          # the next frame: carry on as it is
  • Carry on with run_to, never run_range. run_range(start, end) starts afresh: it puts SS:SP back at the start-up stack. If you use it to continue a running program, every call moves the stack above the live frames. Deep calls then overwrite the main procedure's locals and return address (seen as stray file names in a local array, and the program "exiting" after some thousands of frames). run_to(end) keeps every register.

  • Files come from root (names without case); writes go to overlay. env.log lists what the program asked for, in order: compare it with DOSBox-X's file log.

  • The timer: IRQ 0 reaches the program's INT 8 handler at the rate it programs on the PIT, counted in instructions (ips, default 25 million per second), so frame-paced loops run.

  • Keys: env.keys (scan << 8 | ascii). A read with none queued stops the run and says so.

  • Each stop names what is missing (an interrupt, a DPMI call, an import): add it to dos_env, with a test against the sample. Things it needed for a real game: the NE header's own stack segment, a PSP with an environment, the BIOS data area (CRT port, mode, columns), a BIOS tick that advances, a PIT counter that counts, Borland RTM's memory calls (RTM.5/6/21).

  • Then capture state frame by frame: emu.read((0x1048, 0), 0x10000) is the data segment for Globals::load; any segment a pointer names can be dumped the same way. tools/emu/fragments.py does this for chosen fragments of the main loop: the state before and after each one, every frame, ready for the port's tests (re-verify).

6. Running the original: DOSBox-X ​

A store release usually ships its own DOSBox configuration (GOG: __support/app/*.conf). Start from it so runs use the publisher's settings: scripts/dosbox-run.sh --conf THAT.conf ... (the script turns fullscreen off and keeps its own logging and autoexec).

scripts/setup.sh --install installs DOSBox-X (on macOS brew install --formula dosbox-x; the cask fails Gatekeeper). Homebrew's build has the heavy debugger (CPU logs, memory breakpoints); brew install --HEAD --formula dosbox-x adds the newest debugger commands (STEPIN, SAVESTATE).

bash
scripts/dosbox-run.sh target/game GAME.EXE                   # no window; prints the exit code
scripts/dosbox-run.sh --fileio --time 20 target/game GAME.EXE
python3 tools/dos/fileio_trace.py analysis/dosbox/dosbox.log --game target/game
  • The game runs on a fresh copy of its folder (analysis/dosbox/drive), so saves and settings it writes never change your copy. --in-place mounts the folder itself.
  • --fileio logs every open, read, seek and write. fileio_trace.py turns that into a map per file: offsets and sizes in reading order, with repeated patterns collapsed ([2, 1, 4] x 120 (stride 7) is a table of 120 seven-byte records). When a program reads a file field by field, the read sizes give its record layout (measured).
  • A program that reads through a Pascal text file (Assign/Reset on a Text variable) is read in 128-byte blocks: the log shows the buffer, not the fields. Read the code instead.
  • --cycles N fixes the CPU speed. Use the same value for every run you compare.
  • Exit codes come back through IF ERRORLEVEL (DOSBox-X's shell does not expand %ERRORLEVEL%, and a redirect inside a false IF still creates the file).
  • Windowless runs use -silent with SDL's dummy drivers, so there is no window and no sound.

7. The debugger, from an agent or a script ​

DOSBox-X can connect its debugger to an external controller (setting mcp_server=PORT, the --mcp PORT option of dosbox-run.sh). Two controllers:

  • For agents: dosbox-x-mcp-server (https://github.com/caiiiycuk/dosbox-x-mcp-server), registered in .mcp.json as dosbox. Install it with cargo install --git https://github.com/caiiiycuk/dosbox-x-mcp-server --tag v0.1.2 --locked. It listens on 58991; ask it with its dosbox_mcp_port tool, then start DOSBox-X with --mcp 58991. Tools: debug_break, debug_exec (any debugger command), debug_snapshot, debug_run. It has no licence file: use it, do not copy its code.
  • For scripts and tests: tools/dos/dbx_control.py speaks the same line protocol (REQ id EXEC command -> BEGIN id OK, output, END id). Use another port than the MCP server's.

Useful debugger commands (type HELP for the build's own list): BP seg:off (breakpoint), BPINT 21 3D (break on an interrupt and AH), BPM seg:off (break when memory changes), D seg:off (dump memory), MEMDUMPBIN seg:off len (to a file), SR reg value (set a register), LDT/GDT (selectors), LOGS/LOGL (CPU trace to a file), STEPIN, RUN.

Finding a selector at run time. A protected-mode program's selectors are chosen by the DPMI host. To map Ghidra's 1000 to the live one: break anywhere in the program, read CS (the segment being executed), list LDT, and dump the first bytes at each code selector; compare them with the file's segment bytes (readseg.py BINARY hex 1000:0000 --count 2). Bytes at relocations differ; everything else matches.

Code: Apache 2.0. Content: CC0 1.0. No game material: bring your own copy.