dbxdebug
Drive a DOSBox-X emulator from Python: launch one, break on an address, read its memory and registers, type at it, and read its screen back.
DosboxSession is the entry point. It launches the emulator on ports nobody
else is using, in a workdir nobody else shares, connects both debug clients,
and guarantees teardown. The bare GDBClient / QMPClient are still there
for attaching to an emulator you started some other way.
Requirements
An emulator build with the gdbserver and qmpserver remote-debug features
compiled in. These are not upstream DOSBox-X -- they are additions to this
project's fork, and nothing here works against a stock build. dbxdebug doctor tells you whether the binary it would launch has them.
Python 3.11 or newer.
Installation
uv add dbxdebug
Check what that gave you before writing any of the code below. The version
on PyPI at the time of writing is 0.2.1, and its sdist ships the original
client surface only -- no session, no addressing, no frames, no
registry, no paths, no doctor. Everything this README describes needs a
release newer than that, which has not been published yet. Until it is, depend
on the repository directly (uv add --editable ../dbxdebug, or a git
dependency), and confirm:
uv run python -c "import dbxdebug.session, dbxdebug.addressing, dbxdebug.frames; print('ok')"
An ImportError there means you are on the published release, and the rest of
this page does not apply to it yet.
For working on dbxdebug itself, uv sync.
Quick start
from dbxdebug.session import DosboxSession
# Without these DOSBox-X opens a real window and takes your keyboard focus.
HEADLESS = {"SDL_VIDEODRIVER": "dummy", "SDL_AUDIODRIVER": "dummy"}
with DosboxSession(env=HEADLESS) as session:
print(f"pid={session.pid} gdb={session.gdb_port} qmp={session.qmp_port}")
gdb = session.gdb
assert gdb is not None # connect=True is the default
regs = gdb.read_registers()
# regs["eip"] is an OFFSET within CS, not a linear address.
print(f"cs={regs['cs']:#06x} eip={regs['eip']:#06x} linear_pc={gdb.linear_pc():#07x}")
# Memory takes a LINEAR address. 0xB8000 is the VGA text framebuffer.
cell = gdb.read_memory(0xB8000, 4)
print(f"first two cells at 0xb8000: {cell.hex()}")
print(session.screen_lines()[0].rstrip())
pid=3549359 gdb=55847 qmp=44097
cs=0xf000 eip=0xd186 linear_pc=0xfd186
first two cells at 0xb8000: ba1f201f
º Welcome to DOSBox-X ! v2025.12.01, Linux SDL1 64-bit º
Leaving the with block kills the emulator's whole process group, deletes the
scratch workdir, and removes the registry entry. atexit and SIGINT/SIGTERM
handlers repeat that teardown for a process that leaves by some other door.
Useful DosboxSession arguments: mounts={"c": path}, program= and
files= to stage host files onto the first mounted drive, autoexec=,
conf= for your own conf template, cycles=, connect=False for the handle
without clients, boot_settle= (default 2.5s -- the debug ports accept long
before the guest reaches a prompt), and label= to name the scratch workdir.
Conveniences on the handle: screen_lines(), wait_for_text(),
assert_screen_readable(), running, set_breakpoint(),
remove_breakpoint().
Addressing -- read this once
This is the most consequential behaviour in the package, and the thing most likely to silently break code written against an older emulator build.
Breakpoints and memory take LINEAR addresses. seg * 16 + off, which is
what addressing.linear(seg, off) computes. Z0/z0 (breakpoints) and
m/M (memory) all use the same encoding.
addressing.bp_addr raises rather than packing a far pointer. It exists
only to fail loudly, because the historical helper it replaces packed
(seg << 16) | off, and the stub answers OK to both readings -- a silently
misplaced breakpoint above 64 KB is indistinguishable from a correct one by
its response. addressing.parse_address rejects an integer that looks packed
for the same reason.
read_registers()["eip"] is an OFFSET within CS, not a linear address.
Use gdb.linear_pc() (or addressing.linear_pc(register_list)) for the
linear program counter.
from dbxdebug.addressing import linear, bp_addr, parse_address, PackedAddressError
linear(0x1000, 0x0020) # -> 0x10020
bp_addr(0x1000, 0x0020) # -> PackedAddressError, always
parse_address(0x10000020) # -> PackedAddressError: looks like a packed far pointer
Note the signature difference between the two set_breakpoints:
| Call | Takes |
|---|---|
GDBClient.set_breakpoint(address) |
ONE address: a linear int, a "seg:off" string, or a bare hex string -- "1000" means 0x1000, never decimal 1000 |
DosboxSession.set_breakpoint(seg, off) |
a (seg, off) pair, converted with addressing.linear for you |
If you are porting code written against an older build, read docs/migration.md -- it covers what breaks, how each failure presents, and what to write instead.
The capability handshake
A current emulator build advertises two vendor tokens in its qSupported
reply:
| Token | Means |
|---|---|
dosbox-x-linear-bp+ |
Z0/z0 take a linear address. Builds without it split the argument as a packed far pointer (seg = addr >> 16), so a breakpoint above 64 KB answers OK and never fires |
dosbox-x-eip-offset+ |
The g packet reports EIP as an offset within CS. Builds without it returned SegPhys(cs) + reg_eip, so a g/G round-trip silently moved the program counter |
Neither semantics change is detectable by probing -- Z0 answers OK under
either reading, and both eip conventions produce a plausible integer -- so
the advertisement is the only signal, and GDBClient treats it as
load-bearing. By default GDBClient.__init__ raises IncompatibleStubError
unless dosbox-x-linear-bp+ is present. Pass
GDBClient(require_capabilities=False) to drive an older build anyway, and
client.require_linear_breakpoints() to re-run the check by hand.
dosbox-x-eip-offset+ is not separately enforced; it lands in
client.capabilities alongside everything else the stub advertised, for a
caller that wants to branch on it.
DosboxSession builds its clients with the default, so a session against an
old build fails at start() rather than at the first breakpoint.
What is in the package
| Module | For |
|---|---|
dbxdebug.session |
DosboxSession -- launch, connect, tear down. Also DEFAULT_CONF, render_conf, DosboxLaunchError |
dbxdebug.gdb |
GDBClient, IncompatibleStubError, REGISTER_NAMES |
dbxdebug.qmp |
QMPClient, QMPError -- keys, memdump, screendump, save/load state, stop/cont, debug_break_on_exec |
dbxdebug.addressing |
linear, linear_pc, parse_address, bp_addr, PackedAddressError |
dbxdebug.frames |
walk_frames, steps_out, Frame, FrameWalkError |
dbxdebug.registry |
list_sessions, reap, format_table, free_port, kill_group |
dbxdebug.paths |
find_dosbox_x, configured_dosbox_x_path |
dbxdebug.doctor |
run(), DoctorReport -- host readiness; never starts an emulator |
dbxdebug.video / .html / .capture_io |
DOSVideoTools, HTML rendering, ScreenRecorder, load_capture |
dbxdebug.keyboard / .dbx_kbd |
key-chord helpers and constants (CTRL_C, ctrl_key, DBX_KEY, ...) |
dbxdebug/__init__.py re-exports the clients, the video tools, and the
keyboard helpers. It does not yet re-export DosboxSession, addressing,
frames, registry, paths or doctor -- import those from their modules,
as every example here does (#7).
Locating the emulator
paths.find_dosbox_x() and paths.configured_dosbox_x_path() resolve the
binary in one order, used by both DosboxSession and doctor so the two can
never disagree: the DBXDEBUG_DOSBOX environment variable first (trusted
exactly as given, never silently swapped for something found on PATH), then
a conventional checkout path, then PATH.
export DBXDEBUG_DOSBOX=/path/to/your/dosbox-x
DBXDEBUG_REGISTRY likewise overrides the session registry directory, which
defaults to ~/.cache/dbxdebug-sessions.
Stack frames
frames walks the real-mode BP chain -- [BP] saved BP, [BP+2] return
offset, [BP+4] return segment if the call was far -- reading through SS.
It sets no breakpoints.
from dbxdebug.frames import walk_frames, steps_out
for frame in walk_frames(gdb): # innermost outward, bounded by max_depth
print(frame.depth, hex(frame.bp), hex(frame.return_off))
steps_out(gdb) # single-step until the current frame returns
walk_frames never raises: it stops and returns what it has on a zero saved
BP, a saved BP that is not strictly above the current one (which is also what
terminates a cyclic chain), a short or failed read, or max_depth.
steps_out is a heuristic over SP, with bounds worth knowing before you
rely on it. It records the entry BP and steps until SP & 0xFFFF is strictly
greater than BP + 2 -- past the return-address slot, which only the ret
itself reaches, not the pop bp or leave before it. Consequences:
- it raises
FrameWalkErrorifSP > BPon entry (no frame pointer established, or a stale BP) rather than returning after a single step; - a callee that pops BP and jumps to a shared epilogue popping further
registers raises SP past
BP+2while still inside the callee, and this stops there, early. Telling that apart from a real return needs instruction decoding, which this does not do; - called at a procedure's first instruction, before the prologue has run, BP still belongs to the caller and this measures the caller's frame;
- clear every breakpoint first. A breakpoint hit during one of these steps makes the stub emit an unsolicited stop reply, which permanently desyncs the GDB connection -- see Known hazards.
CLI
dbxdebug
├── mem # Memory operations (alias: gdb)
│ ├── read # Read LENGTH bytes from ADDRESS
│ └── write # Write hex bytes to ADDRESS
│
├── cpu # CPU registers and execution control
│ ├── regs # Display registers (also prints the linear PC)
│ ├── break # Set breakpoint at ADDRESS
│ ├── delete # Remove breakpoint at ADDRESS
│ ├── step # Single step
│ ├── cont # Continue execution
│ └── halt # Break into the debugger
│
├── key # Keyboard input (alias: qmp)
│ ├── send # Key chord (e.g. ctrl c)
│ ├── type # Type a text string
│ ├── down # Press and hold a key
│ ├── up # Release a key
│ └── list # List QMP commands the server offers
│
├── screen # Screen capture
│ ├── show # Display the 80x25 text screen on stdout
│ ├── capture # Save one frame to a file (-f raw|html|text)
│ ├── record # Multi-frame timed capture
│ ├── watch # Real-time display
│ ├── info # Video mode, BIOS ticks
│ └── colors # Analyze the color palette
│
├── session # Sessions tracked in the local registry
│ ├── list # What the registry knows, and whether it is orphaned
│ └── reap # Kill orphans and delete their workdirs
│
└── doctor # Host readiness check; never starts an emulator
mem, cpu and screen talk to the GDB server (--port, default 2159);
key talks to QMP (--port, default 4444). A DosboxSession picks ephemeral
ports instead, so pass --port its gdb_port / qmp_port to point the CLI
at one -- and see the single-client hazard below before you do.
dbxdebug doctor
dbxdebug session list
dbxdebug session reap --dry-run
dbxdebug mem read b800:0000 16 --hex
dbxdebug mem write 0x1000 90909090
dbxdebug cpu regs
dbxdebug cpu break 1000 # bare hex: 0x1000, not decimal 1000
dbxdebug cpu step
dbxdebug key send ctrl c
dbxdebug key type "Hello World!"
dbxdebug screen show
dbxdebug screen capture -f html -o snapshot
dbxdebug screen record -d 60 -r 30 -o session.capture.gz
screen record takes -d x -r samples rather than watching the clock, and
each sample is a GDB round-trip. When those cannot keep up with -r the
recorder never sleeps and the run overruns: -d 60 -r 30 captured its 1800
frames in about 149 wall seconds here. Lower -r if wall time matters.
dbxdebug doctor on a ready host:
[ok] dosbox-x binary found: /path/to/dosbox-x
[ok] remote debugging: appears compiled in (gdbserver/qmpserver strings found)
[info] host CPUs: 16 (rough concurrency ceiling)
[ok] registry dir writable: /home/you/.cache/dbxdebug-sessions
[ok] orphaned sessions: none
Attaching to an emulator you started yourself
DosboxSession writes its own conf. If you are launching DOSBox-X by hand
instead, the servers need these keys:
[dosbox]
gdbserver = true
gdbserver port = 2159
qmpserver = true
qmpserver port = 4444
The port keys need the space. gdbserver port, not gdbport. DOSBox-X
silently ignores the no-space form, leaves the server on its compiled-in
default port, and your client then connects to whatever else happens to be
listening there -- possibly a different emulator entirely.
Then:
from dbxdebug import GDBClient, QMPClient, DOSVideoTools, CTRL_C
with GDBClient() as gdb: # localhost:2159
gdb.read_registers()
gdb.read_memory("b800:0000", 4000)
gdb.set_breakpoint(0x10020) # LINEAR
gdb.set_breakpoint("1000:0020") # the same address, seg:off form
gdb.step()
gdb.continue_execution()
with QMPClient() as qmp: # localhost:4444
qmp.send_key(CTRL_C)
qmp.type_text("Hello World!")
with DOSVideoTools() as video:
lines = video.screen_dump()
lines, ticks = video.screen_dump_with_ticks()
Known hazards
Three open defects. All three are reproduced, all have tests pinning today's behaviour, and none is fixed. Plan around them.
No read timeout (#4).
GDBClient never calls settimeout, so any packet the stub does not answer
hangs the caller forever with no diagnostic. This is easy to reach by
accident, because the two protocols interact: while the emulator is
QMP-stopped the GDB stub does not answer at all, so qmp.stop() followed by
any GDB request is a deadlock. Arm the socket yourself right after start():
if session.gdb is not None and session.gdb.sock is not None:
session.gdb.sock.settimeout(30.0)
Desync after a timeout or an unsolicited stop reply
(#5 -- confirmed and
reproduced). GDBClient assumes strict request/response and never
resynchronises. Once a reply is left unread, every later request returns the
previous request's payload, silently and permanently. Both triggers are
confirmed against a live build: an unsolicited $S05 stop reply (QMP
break-on-exec fires one nobody asked for), and a timed-out request leaving its
reply in the stream -- so the settimeout above converts a hang into a
TimeoutError that lands you here instead. Keep GDB traffic serialised on one
thread, treat TimeoutError as fatal to the connection rather than retryable,
and check the length of every read_memory result against what you asked for:
it is the one cheap symptom visible from outside. Do not add a read-retry
loop -- two identical consecutive requests mask a one-packet lag perfectly, so
it would look like it worked whether or not the stream had shifted.
One GDB client at a time
(#8). The stub serves a single
GDB client. A second one completes the TCP connect and then blocks forever in
the qSupported handshake -- no refusal, no error, just a hang. In particular,
pointing a dbxdebug mem / cpu / screen command at a session that already
holds its own GDB client hangs that command. Use
DosboxSession(connect=False) if you want the CLI to be the one client, or
drive the session's own session.gdb from Python.
QMP is a separate socket and is undisturbed by any of this, which is why
qmp.query_status() is the way to learn that the CPU stopped.
Testing
uv run pytest # launches no emulator; what CI runs
uv run pytest -m integration tests # every test that launches one
uv run pytest -m integration tests/integration -v # just the live suite
The integration marker means "this test launches a real emulator". Every
test carrying it is deselected from the default run -- CI has no emulator, and
a developer machine has one that must not be started unasked. Nearly all of
them live in tests/integration/; tests/test_session.py carries one more,
for the start()/stop() lifecycle.
The live tests in tests/integration/ launch a headless DOSBox-X per test and
prove the library actually drives one: the vendor GDB capabilities, eip as an
offset rather than a linear address, a breakpoint above 64 KB firing, memdump
agreeing with GDB reads and refusing while the CPU runs, frames.steps_out
stopping after a real 16-bit ret, and what the GDB client does when the
stream is disturbed -- which today is desync, pinned by tests that fail the
moment it is fixed. The binary is located with dbxdebug.paths.find_dosbox_x
-- set DBXDEBUG_DOSBOX to choose a specific build -- and the tests skip when
none is found.
The other gates:
uv run ruff check .
uv run ruff format --check .
uv run pyright
License
Metadata
Release files for dbxdebug 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dbxdebug-0.3.0.tar.gz | 168.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dbxdebug-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 242.2 kB
Release files / dbxdebug-0.3.0.tar.gz
| Download URL | dbxdebug-0.3.0.tar.gz |
|---|---|
| Size | 168.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
62137b02452b539eb027316cdfce8433c00c781cc9a5db0c15571390ff5addd8
|
|
BLAKE2b-256 checksum How to use checksums |
5f7b55ed8b7f4b558eb27f9a0e411953f6b5b21fee846676016b1d20c5c8a3c1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.
Transparency logRelease files / dbxdebug-0.3.0-py3-none-any.whl
| Download URL | dbxdebug-0.3.0-py3-none-any.whl |
|---|---|
| Size | 73.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
55f704b9e3c074f285f0cd29ba60eaf7f7236dbcd6d308c06e5107d453640345
|
|
BLAKE2b-256 checksum How to use checksums |
f0f85e3ac3104b12ea9fa721a9bf131b35982d322064638f4aa9e43dd37aac2d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.
Transparency log