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?
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 says | What it is | Ghidra | Emulate one function | Run it all |
|---|---|---|---|---|
MZ | Real-mode DOS (Turbo Pascal, Turbo C, Microsoft C, QuickBASIC) | "Old-style DOS Executable", loaded at segment 1000 | dos_emu (real mode) | DOSBox-X |
NE | 16-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 / LX | 32-bit flat code behind a DOS extender (Watcom + DOS/4GW, PMODE/W) | needs an LE/LX loader extension | not yet in the toolkit | DOSBox-X |
PE | A Windows program | use tools/pe/peinfo.py | pe_emu.py | Windows 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 is1000:, segment 10 is1048:. Imports go after the last segment. - MZ: the load image starts at segment
1000, so1000:0000is the first byte after the header; a second segment in the image is1000 + 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.
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.EXEWith --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 21hwithAH=4Ch(exit) or Pascal'sHalt/RunErrornever 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__stdcall16farwithparam_1being 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_Nhalves, so every later number is off by one;in_stack_Xandlocal_Xnames 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 diis 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
varparameters 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 scasbover 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 (oftenor cl, clon 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
tempi2left 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 --pstringsandreadseg.py BINARY pstr ADDR. - Segment arithmetic.
DAT_1048_12deis offset0x12dein 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 --pstringsprints. - Constants that look like globals. A 16-bit constant equal to an offset in DS shows up in pseudocode as
&someGlobal(aBlockReadcount 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-3Dhfollowed by the instruction's bytes; disassemblers showint 0x3cand 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 LENapplies the relocations and prints label names for call targets and DS globals. - Text files are buffered in the record.
ReadChartakes the next byte of the 128-byte buffer inside theTextRec(BufPos,BufEnd), and refills it with one DOS read ofBufSizebytes 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
Textfile,Eofis true when the next byte is 0x1A, not only at the real end, andReadat 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 positionsEofcan 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 forSetIntVec(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:
Realis 6 bytes and not IEEE (readseg.py ... real48),Compis a 64-bit integer kept in the FPU,string[N]takes N+1 bytes, sets are bit arrays of up to 32 bytes,Booleanis one byte. Records are not padded. - Real arithmetic is in the runtime library, with registers. Without
{$N+},Realmaths calls the System unit's helpers: the first operand and the result inDX:BX:AX, the second inCX:SI:DI(the low byte ofAXorCXis the exponent). A typical block iscall int-to-real; mov cx/si/di, constant; call multiply; call round. Two helpers that differ only bymov ch, 0(Trunc) ormov 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
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}orinterrupts={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:
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 isCarry on with
run_to, neverrun_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 tooverlay.env.loglists 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 forGlobals::load; any segment a pointer names can be dumped the same way.tools/emu/fragments.pydoes 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).
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-placemounts the folder itself. --fileiologs every open, read, seek and write.fileio_trace.pyturns 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
Textvariable) is read in 128-byte blocks: the log shows the buffer, not the fields. Read the code instead. --cycles Nfixes 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 falseIFstill creates the file). - Windowless runs use
-silentwith 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.jsonasdosbox. Install it withcargo install --git https://github.com/caiiiycuk/dosbox-x-mcp-server --tag v0.1.2 --locked. It listens on 58991; ask it with itsdosbox_mcp_porttool, 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.pyspeaks 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.