Skip to main content

dbxdebug

CI PyPI Python License

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 FrameWalkError if SP > BP on 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+2 while 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

Polyform Shield 1.0.0.

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)

Source distribution for dbxdebug 0.3.0
File Size Uploaded
dbxdebug-0.3.0.tar.gz 168.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dbxdebug 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.5.0

2 release files

0.4.0

2 release files

This release

0.3.0 This release

2 release files

0.2.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page