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 |
|---|---|
|
the label at a runtime address, resolved to its name at render time (falls back to a hex literal if unnamed) |
|
a bare symbolic name, emitted verbatim (a constant, say) |
|
low / high byte of |
|
an integer literal forced to hex / decimal / a character literal |
|
a string literal — index ( |
Python operators |
|
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.
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_filenamemakes the beebasm renderer emit asave "<file>", start, enddirective; the command is thenbeebasm -i <listing>(thesavewrites the file). But beebasm ignores-owhen asavefilename is present — so an external harness that captures the binary viabeebasm … -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 (thesavestays filename-less) and passbuild_output_nameto name the-otarget for the command comment only, decoupled from thesavedirective: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
savedirective —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
-oand has no exec/reload concept for a raw binary, warns and omits them. The raw payload — and sofantasm 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:
beebasmRe-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.
jsonStructured 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
initscaffolder.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.
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.The
inline=Trueform (oralign=Align.INLINE) renders as a trailing remark on the same line as the instruction. Plain comments (defaultalign=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.mdin 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).