Driver-script API

A driver script is a Python file that builds up a disassembly by calling methods on a Disassembler. Where dasmos disassemble is one-shot, a driver is the long-lived artefact: as you analyse a ROM you add labels, name subroutines, attach comments, mark data regions, and re-run the script to get an ever-improving listing. The driver is the single source of truth — version it, diff it, share it.

This page walks through the API in roughly the order you’d reach for each piece of it during a real analysis. See API reference for the full reference.

The minimal driver

The smallest useful driver: load a ROM, declare its entry point, disassemble, render. dasmos init writes essentially this:

from pathlib import Path

import dasmos

ROM_PATH = Path("nfs365.rom")
LOAD_ADDR = 0x8000

d = dasmos.Disassembler.create(cpu="6502")
d.load(ROM_PATH, LOAD_ADDR)
d.entry(LOAD_ADDR)

ir = d.disassemble()
print(str(ir.render("beebasm")))

Run with python driver.py and the rendered listing appears on stdout. Every other thing in this guide is added between entry() and disassemble().

Building the disassembler

Disassembler.create is the classmethod that wires a CPU plug-in together with zero or more environment plug-ins. The CPU defines the instruction set; the environments contribute named labels, hardware-port names, OS-call analysers, and subroutine hooks for a specific target system.

d = dasmos.Disassembler.create(
    cpu="6502",
    environments=["acorn_mos", "acorn_model_b_hardware", "acorn_fdc_8271"],
)

With acorn_mos active, jsr &FFF4 resolves to jsr osbyte without you having to declare the label yourself; with acorn_model_b_hardware active, reads of &FE40 resolve to the System VIA’s via_system_orb instead of a bare hex literal; and with acorn_fdc_8271 active, accesses to &FE80-&FE84 resolve to the 8271 floppy-controller register names. Run dasmos list-environments to see what’s installed.

Environment plug-ins are deliberately split along orthogonal axes: the machine class (acorn_model_b_hardware / acorn_master_hardware), the floppy-disc-controller variant (acorn_fdc_8271 / acorn_fdc_1770), and so on. A retrofitted Model B with a 1770 upgrade is the combination acorn_model_b_hardware + acorn_fdc_1770; a Master is acorn_master_hardware + acorn_fdc_1770. Pick the combination that matches the system the ROM you’re disassembling targets.

Loading the binary:

d.load(ROM_PATH, LOAD_ADDR, md5sum="3a7f…")

The optional md5sum= argument pins the binary’s hash; mismatched binaries raise before any further analysis runs, catching the ROM-version-skew bug at the earliest possible moment.

Seeding the trace

Trace-driven classification needs at least one entry point. The common case is a single entry at the load address:

d.entry(LOAD_ADDR)

But many ROMs have several entry points — service-ROM headers, IRQ vectors, second-processor boot sequences. Declare each one:

d.entry(0x8000, "rom_init")
d.entry(0x8003, "rom_service")
d.entry(0x8006, "rom_workspace")

The optional second argument names the entry point, equivalent to a label() call at the same address. The trace follows reachable code from each seed; bytes that no seed can reach end up classified as data unless you intervene.

Naming things: labels and constants

Once you’ve identified what an address means, name it. Dasmos has three flavours of name, corresponding to three different commitments:

label() — a required label at this address. It always appears in the output, even if nothing references it. Use this when you want the label as a human-facing waypoint regardless of code-flow analysis.

d.label(0x9000, "main_dispatch")

optional_label() — only emitted if some piece of analysis ends up referencing the address. Use this when you’ve named a candidate location but you’re not yet sure it’s actually used; if it is, you get the named label, otherwise it stays out of the way.

d.optional_label(0x9100, "maybe_unused_helper")

constant() — names a value, not an address. Hex literals like #&80 get rewritten to the constant name in the operand position, which is the right call for OSBYTE action codes, magic numbers, bit masks, and the like:

d.constant(0x80, "osbyte_read_adc")

After which lda #&80 ; jsr osbyte renders as lda #osbyte_read_adc ; jsr osbyte — the intent jumps off the page.

For cases where the natural name involves arithmetic — most often “the high byte of a vector pair” — use expr_label(), which stores an expression as the address’s label form:

from dasmos.expr import ref

d.label(0x220, "irq1v")
d.expr_label(0x221, ref(0x220) + 1)   # renders as ``irq1v+1``

After which a reference to &221 renders as irq1v+1 instead of l0221. The argument is an assembler-neutral expression built with the dasmos.expr DSL — the same expressions accepted by expr() for operands and data bytes. They are the subject of the next section.

Assembler-neutral expressions

Wherever a driver supplies an operand or data value that isn’t a plain label — a byte extracted from an address, an offset, a mask, a computed constant — it does so with an Expr tree built from the dasmos.expr DSL. dasmos renders that one tree into each target assembler’s own syntax: & vs $ hex, AND vs &, <(x) byte-select, and — crucially — the correct parenthesisation for each assembler’s operator precedence. You write the intent once; the renderer gets the syntax right per backend.

The building blocks:

Builder

Meaning

ref(addr)

the label at a runtime address, resolved to its name at render time (falls back to a hex literal if unnamed)

sym(name)

a bare symbolic name, emitted verbatim (a constant, say)

lo(e) / hi(e)

low / high byte of e

hexlit(n) / declit(n) / char(n)

an integer literal forced to hex / decimal / a character literal

string(s)

a string literal — index (string("BRK")[0]) or slice it

Python operators

+ - * // & | ^ << >> and unary - / ~ compose sub-trees

ref and integer literals cover the vast majority of cases. A bare Python int is accepted anywhere and becomes a literal, so ref(0x8130) - 1 just works.

from dasmos.expr import ref, sym, lo, hi

d.constant(3, "num_lives")
d.expr(0x8001, sym("num_lives") + 1)   # lda #num_lives + 1
d.expr(0x8005, lo(ref(0x8130) - 1))    # lda #<(table - 1)

Backward compatibility: a plain string is still accepted and is parsed from the beebasm/py8dis dialect ("table-1", "HI(x)", "a AND &FF") into the same tree, so existing drivers need no changes. Prefer the DSL in new code — it is checked at build time and renders correctly to every backend.

The cookbook works through expressions (and macros) from simple to advanced.

Indexed-base addresses and regions

Some addresses are only ever the base of an indexed operand — the byte the instruction touches is base + X (or base + Y), and the base itself is never read or written. The classic case is a per-channel or per-control-block workspace pointer copied with lda base,X where X selects the block. Naming such a base with a plain label() is misleading: it appears on the memory map as a location the ROM owns, and its cross-reference count implies reads and writes that never happen at that exact byte.

index_base() names a base as a base. It keeps the name, description and group — so the ,X operand still resolves and stays documented — and asserts the base access flag, so its cross-reference reads used as index base N times rather than referenced N times:

d.index_base(0x0000, "zp_user_ptr_0", group="zero_page",
             description="Caller's zero-page pointer, byte 0; the "
                         "transfer copies base+0..+3 via lda ...,X.")

In the JSON renderer such a base is an ordinary memory_map row whose access array contains "b" (a ["b"]-only row: documented in place, but the literal byte is never touched, only indexed through). Access is modelled as orthogonal r / w / b flags, so an address that is both read and used as a base comes out ["r", "b"].

Note

For a base (or any map entry) to sit in place within the surrounding workspace layout it needs a group=, like every other entry. As an interim aid, the JSON renderer emits a UserWarning naming any memory_map entries authored without one — add a group= to each to silence it. This stop-gap is removed once the per-driver grouping audit lands (dasmos#43).

When several bases cluster around a named anchor — reached via indexed addressing with small displacements — declare an indexing region instead of naming each byte. index_region() takes the anchor’s address and name plus an inclusive offset window; in-window neighbours then render relative to the anchor:

d.index_region(0x0E00, "fsm_sector0", window=(-6, 0),
               description="Free-space-map sector 0; compaction "
                           "sweeps the notional entries just below it.")

An operand whose base falls three bytes below the anchor now renders as lda fsm_sector0-3,X — the anchor±k arithmetic reassembles to the same bytes — instead of needing a hand-written fsm_s0_pre3 label for each slot. Pass named_slots=True to give each gap a distinct identifier (fsm_sector0_m3 / fsm_sector0_p3) rather than the arithmetic form.

A region names only the gaps: an explicit label() you place inside the window always wins (precedence: explicit label → region form → auto-label → hex), so a region and individually-named locations coexist over the same address range. Region windows must be disjoint; an overlapping declaration raises at call time.

For the workflow and judgement of retrofitting these calls onto an already-annotated driver — how to find candidates, which labels to leave alone, and when not to reach for a region — see Cookbook: adopting index_base / index_region.

Marking subroutines

subroutine() is the high-density combo: it declares an entry point, a label, and a banner comment block in one call. The title and description are the heart of a self-documenting driver:

d.subroutine(
    0x9200,
    "send_packet",
    title="Send an Econet packet",
    description="""
Sends a packet whose control byte, source/destination station, and
payload are pre-loaded into the TX buffer at [tx_buffer](address:0E00).
""",
    on_entry={
        "A": "packet length (bytes)",
        "X": "TX buffer page",
        "Y": "retry count",
    },
    on_exit={
        "C": "set on transmit failure, clear on success",
    },
)

The on_entry and on_exit keywords are register-name → description dictionaries. Renderers format them into a structured “On entry” / “On exit” block beneath the title and description, so the calling convention stays in machine-readable form for the JSON renderer (downstream HTML can render it as a real table) and as a clean labelled block in the asm output. Use them for register / flag contracts; reserve description for free-form prose about what the subroutine does and why.

Both title and description accept full Markdown — paragraphs, bullet lists, tables, fenced code blocks, inline emphasis and code, plus the custom [label](address:HEX) cross-reference URI scheme. Asm renderers strip the markup down to plain text suitable for ; comment lines; the JSON renderer keeps the source verbatim for downstream HTML processors.

Comments

All driver-provided prose is Markdown. Every piece of free text you hand dasmos — comment() bodies, subroutine / banner descriptions (subroutine()), label() descriptions, constant() comments, and the file header (set_file_header()) — is CommonMark + GFM, plus the [label](address:…) cross-reference scheme below. dasmos renders it appropriately per output: assembler renderers flatten it to ;-prefixed comment plaintext (emphasis, code spans, lists and tables all reduced to readable text, paragraphs wrapped); the JSON renderer keeps the source Markdown verbatim so a downstream HTML processor can render it richly. Write Markdown once; each backend does the right thing.

comment() attaches such text at an address.

d.comment(0x9210, "Set carry to signal failure", inline=True)
d.comment(0x9220, """
## Reception loop

Polls the [System VIA](address:FE40) for an SR interrupt; on each
one, reads the byte, appends it to the buffer, and resets the
interrupt latch.
""")

The inline=True form (or align=Align.INLINE) renders as a trailing remark on the same line as the instruction. Plain comments (default align=Align.BEFORE) render as one or more ;-prefixed lines preceding the instruction.

Cross-references in comment text use a custom URI scheme:

Form

Renders as

[foo](address:E000)

foo (label-only)

[foo](address:E000?hex)

foo (&E000) (label and hex literal)

In the asm renderer the link collapses to its visible text; in the JSON renderer the URI is preserved so an HTML post-processor can turn it into a real anchor.

Comment authoring conventions follow the same rules as the acornaeology disassembly project — see AUTHORING.md in the acornaeology.github.io repo for the in-depth treatment, including the single-newline-is-a-soft-break CommonMark gotcha (use a blank line between paragraphs to get separate ; lines in the output).

Top-of-file preamble: header and build instructions

A generated listing can open with a provenance header — prose stating what the disassembly is and where it came from. It is backend-agnostic: you supply the text; each renderer applies only its own comment prefix. Set it with set_file_header().

Like every driver comment, the title and description are Markdown (see the note below). Assembler renderers flatten it to comment plaintext; the JSON renderer keeps the source Markdown verbatim. So a single newline is a soft break (a space) — use a blank line between paragraphs, or a Markdown list, to get separate provenance lines:

d.set_file_header(
    title="Acorn BBC BASIC II",
    description=(
        "Annotated disassembly of the 16 kB BASIC II language ROM.\n"
        "\n"
        "- Source md5: `2cc6…`\n"
        "- Source sha256: `45bd…`"
    ),
)

renders (beebasm, and identically on 64tass with its own prefix):

; Acorn BBC BASIC II
;
; Annotated disassembly of the 16 kB BASIC II language ROM.
;
; - Source md5: 2cc6…
; - Source sha256: 45bd…

The header also surfaces in the JSON renderer’s meta as optional title / description fields (the source Markdown, verbatim), so structured consumers get it too. dasmos stores only your text — it hardcodes no provenance, so the header stays reusable across projects.

Separately, a renderer can be asked to emit a “how to assemble this file” command in its own tool’s syntax — off by default, opt in with a renderer keyword:

ir.render("beebasm", include_build_instructions=True,
          listing_filename="basic-2.asm")
; Assemble with beebasm:
;   beebasm -i basic-2.asm

The command is the backend’s own (64tass emits its 64tass --nostart -o … … form); the driver only decides whether to include it. listing_filename names the .asm the command reads (the renderer doesn’t otherwise know it). Both the header and the build block are comment-only, so assembled bytes are unaffected and the round-trip still holds.

Note

Naming the output — and the beebasm save coupling.

How the concrete output name appears in the beebasm command depends on how the listing produces the binary, and the two interact:

  • set_output_filename makes the beebasm renderer emit a save "<file>", start, end directive; the command is then beebasm -i <listing> (the save writes the file). But beebasm ignores -o when a save filename is present — so an external harness that captures the binary via beebasm … -o out.rom (e.g. a verify/CI step) gets an empty capture and the round-trip check fails.

  • For an -o-based harness, therefore, leave the output filename unset (the save stays filename-less) and pass build_output_name to name the -o target for the command comment only, decoupled from the save directive:

    ir.render("beebasm", include_build_instructions=True,
              listing_filename="basic-2.asm",
              build_output_name="basic-2.rom")
    # →  ;   beebasm -i basic-2.asm -o basic-2.rom
    #    (and the emitted ``save`` remains filename-less)
    

64tass has no in-source save, so it always uses -o and this coupling doesn’t arise; build_output_name simply names its target.

Load-and-run programs (exec / reload addresses)

A ROM lives at a fixed address, but a DFS *RUN program loads at one address and may begin execution at another (its exec address), with the filesystem recording a reload address distinct from where the bytes sit. program() declares that metadata once, in the model, independent of any assembler syntax:

d.program(exec_addr=0x3906, reload_addr=0x1900)

Each renderer emits what it can express:

  • the beebasm renderer folds them into its save directive — save "NAME", start, end, &3906, &1900 — so the produced file is directly *RUN-able and its DFS header matches the original;

  • the JSON renderer surfaces them under meta.program;

  • 64tass, which saves via -o and has no exec/reload concept for a raw binary, warns and omits them. The raw payload — and so fantasm verify — is unaffected either way.

reload_addr defaults to the loaded range’s start when omitted; every argument is optional.

Classifying data

Code that the trace can’t reach — jump tables, parameter blocks, text strings, unused space — needs to be told what shape it has, otherwise the renderer emits raw bytes:

byte() declares a run of bytes:

d.byte(0x9300, length=16)        # 16 raw bytes

word() declares 16-bit words:

d.word(0x9400, length=8)         # 8 16-bit words = 16 bytes

string() declares a run of text:

d.string(0x9500, length=12)      # "HELLO BEEB!\r"

stringz() is the convenience for zero-terminated strings (length is found by scanning to the terminator):

d.stringz(0x9510)

fill() marks a region as filler that should render compactly (equb &00 [* N] or similar):

d.fill(0x9F00, length=0x100)

include_binary() marks a region whose source of truth lives outside the disassembly and is regenerated at build time — for example a block of detokenised BASIC re-encoded to bytes, or any generated data payload you would rather carry as an editable source file than as an opaque wall of equb lines:

d.include_binary(0x1b16, 0x1d52, "keypad-basic.dat")

The region still owns its loaded bytes (nothing is left unaccounted for and the tracer won’t enter it), but the renderer emits an include directive — beebasm incbin "keypad-basic.dat", 64tass .binary "keypad-basic.dat" — instead of the bytes. The owned bytes are the canonical payload: write them next to the listing with write_included_binaries() so the assembler reads back exactly what dasmos accounted for and the round-trip stays self-consistent:

ir = d.disassemble()
listing_dirpath = pathlib.Path("build")
(listing_dirpath / "keypad.asm").write_text(str(ir.render("beebasm")))
ir.write_included_binaries(listing_dirpath)   # writes keypad-basic.dat

A project whose own generator must reproduce the region can diff its output against that canonical payload. A backend with no file-include directive raises a clear error rather than mis-rendering the region.

For pointer tables that do contain code addresses but which the trace can’t follow (because they’re indirect-dispatched via rts push tricks or table indexing), use code_ptr() and rts_code_ptr() to declare each pointer as a code reference. Both also seed the trace at the pointed-to address, which often unlocks large stretches of previously-unreachable code.

Automatic string detection

After tracing classifies code and the explicit d.byte / d.word / d.string calls run, Dasmos sweeps the leftover unclassified bytes for runs of printable ASCII and promotes them to String classifications. This saves you from having to call string() on every text fragment hidden in the ROM — message tables, status strings, error text, and the like surface as equs "..." rows without manual annotation.

The threshold is the minimum run length that gets promoted, set on Disassembler.create() (or as a plain attribute on the disassembler):

# Default — promote runs of 3 or more printable bytes.
d = dasmos.Disassembler.create(cpu="6502")

# Tighter — only runs of 5 or more, useful for ROMs where short
# printable byte sequences are usually data, not text.
d = dasmos.Disassembler.create(cpu="6502", string_detection_min_length=5)

# Disabled — leave every printable run alone for the leftover
# ``Byte`` aggregator to handle. Useful for binary-data ROMs
# where the printable-ASCII heuristic would mostly misfire.
d = dasmos.Disassembler.create(cpu="6502", string_detection_min_length=None)

Runs break at any of: a labelled address, a non-printable byte, an already-classified byte, a move source-boundary, or a byte with any attached annotation. So an explicit d.label(...) / d.comment(...) at a mid-string address splits the detection at exactly the boundary you want, giving you per-segment control without turning the heuristic off entirely.

Operand format hints

Some operand bytes have a semantic intent the renderer can’t infer from the bytes alone. LDA #&41 could be loading the ASCII code for A, a hex-format register flag, or a binary bit pattern — the byte value is the same; the rendered form should differ. Format hints declare the intent in the IR, and each renderer translates the hint into its own assembler-syntax appropriate form.

The general form is Disassembler.format_hint taking a FormatHint enum value:

from dasmos import FormatHint

# Address is the OPERAND byte (one past the LDA opcode for a
# single-byte-opcode CPU like the 6502). With LDA #&aa at &e016,
# the operand is at &e017 — rendering it as %10101010 makes the
# alternating-bit intent read.
d.format_hint(0xe017, FormatHint.BINARY)

The current set of hints is CHAR, DECIMAL, HEX, BINARY, OCTAL and INKEY (BBC-specific keyboard scan code). The beebasm renderer translates each into its native form (ASC("A") or 'A' for CHAR; %01010101 for BINARY; (255 - inkey_key_<name>) EOR 128 for INKEY). The JSON renderer surfaces the hint in the per-operand record so downstream tooling can choose its own syntax.

Two sugar methods cover the cases used most often:

# The byte at &9412 is intended as an ASCII character literal —
# the renderer chooses ASC("c") / 'c' / similar by its
# char_literal_style.
d.char_literal(0x9412)

# The byte at &a3c7 is the negative-X-form INKEY scan code an
# OSBYTE &79 / &81 caller loads to scan for one specific key.
# Renders as ``(255 - inkey_key_ctrl) EOR 128`` etc.
d.inkey_code(0xa3c7)

When the acorn_mos env’s OSBYTE analyser detects a recognisable INKEY pattern (A=&79 or A=&81 with X carrying a known scan code), it registers FormatHint.INKEY automatically — call inkey_code() only when the analyser can’t infer the pattern (a scan code in a pre-computed table, or loaded into X through an indirect path). Driver-supplied expressions via expr() always win over hints, so d.expr(operand_addr, "my_text") overrides any analyser or driver hint at the same address.

Putting it together: a fuller driver

A more complete picture of how the pieces compose:

from pathlib import Path

import dasmos
from dasmos import Align

ROM_PATH = Path("nfs365.rom")
ROM_MD5 = "3a7f…"
LOAD_ADDR = 0x8000

d = dasmos.Disassembler.create(
    cpu="6502",
    environments=["acorn_mos", "acorn_model_b_hardware"],
)
d.load(ROM_PATH, LOAD_ADDR, md5sum=ROM_MD5)

# Service-ROM header entry vectors.
d.entry(0x8000, "service_rom_language")
d.entry(0x8003, "service_rom_service")

# Magic numbers.
d.constant(0x0F, "osbyte_read_input_status")
d.constant(0x80, "osbyte_read_adc")

# A named subroutine with a Markdown description and a
# cross-reference to a related routine.
d.subroutine(
    0x9200,
    "send_packet",
    title="Send an Econet packet",
    description="""
Sends a packet whose control byte and payload are pre-loaded into
the TX buffer. See [recv_packet](address:9300?hex) for the matching
receive path.
""",
)

# An inline note at the trickiest line of the routine.
d.comment(0x9215, "ACR mode 2: shift in under φ2", inline=True)

# A jump table the trace can't follow on its own.
for i in range(8):
    d.code_ptr(0x9F00 + 2 * i)

# Zero-padded tail of the ROM.
d.fill(0xBFE0, length=0x20)

ir = d.disassemble()
print(str(ir.render("beebasm")))

Switching renderers

The same intermediate representation feeds every renderer. The two that ship with Dasmos:

beebasm

Re-assemblable assembly source. The default. Targets the beebasm syntax that’s standard in the BBC Micro / Master ecosystem; the round-trip (binary → driver → beebasm-assembly → binary) is Dasmos’s correctness oracle.

json

Structured representation: every label, instruction, comment, and cross-reference as JSON. Use this when you’re feeding the disassembly into another tool — an HTML site generator, a diff utility, a parity-checker against a reference output.

To get both from one driver run, render twice:

ir = d.disassemble()
Path("nfs365.asm").write_text(str(ir.render("beebasm")))
Path("nfs365.json").write_text(str(ir.render("json")))

Third-party renderers register themselves via Stevedore entry points under the dasmos.renderer namespace; once installed, a renderer becomes available by name to every driver and to the --renderer flag of dasmos disassemble.

Hooking subroutines

When a ROM repeatedly calls a known subroutine and you’d like context-aware analysis at every call site (e.g., recognising the A register at the call as an OSBYTE action code and looking it up in an enum), hook_subroutine() registers a Python callable that runs against the disassembler state at every call site:

def my_hook(d, call_site, regs):
    # called once per JSR/JMP to the hooked address.
    ...

d.hook_subroutine(0xFFF4, "osbyte", my_hook)

Most users won’t write hooks directly — the acorn_mos environment ships hooks for OSBYTE, OSWORD, OSFIND, OSFILE, OSGBPB, and OSEVEN that already do A-register-action and X-register-secondary lookups into the enums catalogued in dasmos.ext.environments.acorn_mos. Reach for hook_subroutine when you need to encode call-site conventions specific to your particular ROM.

Where to go next

  • Command-line reference — the one-shot command and the init scaffolder.

  • API reference — the full autodoc reference for every public symbol.

  • acornaeology.github.io/AUTHORING.md — the comment-Markdown and memory-map metadata conventions used across the acornaeology disassembly project.