Disassembling a disc-loaded program
fantasm grew up around sideways ROMs, but nothing in it depends on the
code being a ROM. A program loaded from disc — a DFS *RUN utility, a
game loader, a file server split across several files — is just a
binary loaded at some address. This page covers bringing one under
fantasm, including a project that already has its own layout and
shouldn’t be reorganised.
Everything fantasm needs to know about a target (one thing being disassembled) is:
the binary, and the address it loads at;
the driver script that disassembles it;
where that driver writes the
.asmand.json.
Any metadata (rom.json-style docs lists), acornaeology website
files and documentation links are optional extras; every command runs
without them.
A single-file program in an existing project
Suppose a project already looks like this, with a working dasmos driver:
elite-loader/
orig/ELITE # the program, extracted from the disc image
orig/ELITE.inf # its .inf sidecar: $.ELITE FF1900 FF8023 ...
tools/dis_loader.py # the driver
build/ # where the driver writes loader.asm / .json
Adopt it as it stands:
cd elite-loader
uv run fantasm project init --name elite-loader --layout explicit \
--target loader \
--binary orig/ELITE \
--driver tools/dis_loader.py \
--asm build/loader.asm \
--json build/loader.json
This writes fantasm.toml and touches nothing else:
[project]
name = "elite-loader"
[binary]
cpu = "6502"
[[targets]]
id = "loader"
binary = "orig/ELITE"
load_address = 0x1900 # from orig/ELITE.inf
exec_address = 0x8023 # from orig/ELITE.inf
driver = "tools/dis_loader.py"
asm = "build/loader.asm"
json = "build/loader.json"
The load and execution addresses came from the .inf sidecar.
fantasm reads the classic DFS form and the extended ADFS / stardot
variants (8-digit fields, access bytes and strings, CRC= and
other extras); if a sidecar can’t be parsed, or there isn’t one, give
the address on the command line as --binary orig/ELITE@1900.
With one target, the target argument can be left out of every command:
uv run fantasm disassemble # run the driver
uv run fantasm verify # reassemble and byte-compare
uv run fantasm lint # check the driver's annotation addresses
uv run fantasm comments check
The driver doesn’t have to change. fantasm runs it with environment
variables describing the target (FANTASM_BINARY,
FANTASM_IMAGES with the load address, FANTASM_ASM,
FANTASM_JSON …; see fantasm.toml reference), but a driver that uses
its own paths keeps working, provided its outputs land where
fantasm.toml says. If the driver expects to be run from its own
directory, add driver_cwd = "tools" to the target.
A driver written with fantasm in mind can take everything from the environment:
import json
import os
from pathlib import Path
import dasmos
(image,) = json.loads(os.environ["FANTASM_IMAGES"])
d = dasmos.Disassembler.create(cpu="6502")
d.load(image["path"], image["load_addr"])
d.program(load_addr=image["load_addr"], exec_addr=image["exec_addr"])
d.entry(image["exec_addr"], name="start")
# ... labels, comments, subroutines ...
ir = d.disassemble()
Path(os.environ["FANTASM_ASM"]).write_text(str(ir.render("beebasm")))
Path(os.environ["FANTASM_JSON"]).write_text(str(ir.render("json")))
Verifying with another assembler
fantasm verify runs beebasm -i <asm> -o <tmp> and compares the
temporary output with the binary. If the listing names its own output
(SAVE "ELITE", ...), or the project uses a different assembler or
a wrapper script, say so in [verify]:
[verify]
command = ["beebasm", "-i", "{asm}"]
output = "ELITE" # relative to the listing's directory
See fantasm.toml reference for the tokens available.
Starting from scratch
For a new disassembly there are two choices.
Keep it explicit. Put the files wherever suits you and register
the program the same way, with project init --layout explicit.
fantasm will suggest disasm_<id>.py and output/<id>.asm /
.json for any path you don’t give.
Use the versions/ convention, which suits a program that exists in
several versions you want to compare and port annotations between.
Set [binary] so the per-version directory holds the binary under
its disc name:
[binary]
cpu = "6502"
base_address = 0x1900
dir = "binary"
extension = "" # KEYPAD, not KEYPAD.rom
metadata = "binary.json"
filename = "{version_id_upper}" # id "keypad" -> binary "KEYPAD"
A project that keeps several versions somewhere other than
versions/ can describe its layout with [layout] templates
instead (fantasm.toml reference).
A program made of several files
Larger disc programs are often split across several files, each loaded at its own address: a main body, utilities loaded on demand, overlays that share a window of memory. Disassemble these as one target per file:
[project]
name = "l3fs"
default_target = "fs3" # the file most commands are about
[[targets]]
id = "fs3"
binary = "disc/FS3" # load/exec from disc/FS3.inf
driver = "disasm/fs3.py"
asm = "out/fs3.asm"
json = "out/fs3.json"
[[targets]]
id = "fs3util"
binary = "disc/FS3UTIL"
load_address = 0x6800
driver = "disasm/fs3util.py"
asm = "out/fs3util.asm"
json = "out/fs3util.json"
[[targets]]
id = "ovl1"
binary = "disc/OVL1"
driver = "disasm/ovl1.py"
asm = "out/ovl1.asm"
json = "out/ovl1.json"
fantasm project add appends further entries without editing the
file by hand:
uv run fantasm project add ovl2 --binary disc/OVL2 \
--driver disasm/ovl2.py --asm out/ovl2.asm --json out/ovl2.json
Each file then gets its own disassembly, round-trip and checks —
fantasm verify --all verifies them all — and overlays that share
addresses are no problem, because each is disassembled on its own.
Cross-file references
The files call into one another, so each driver needs names for the
other files’ entry points. Declare each one as a label in every driver
that refers to it. A label at an address the driver hasn’t loaded
becomes an external label: the listing defines it as a constant, the
JSON records it under external_labels, and fantasm lint
accepts annotations that use it.
Keeping those shared names in one module avoids drift between the drivers:
# disasm/shared_labels.py — entry points other files call
ENTRY_POINTS = {
0x1903: "fs3_command_dispatch",
0x6800: "util_entry",
0x7400: "overlay_entry",
}
# in each driver, after d.load(...)
from shared_labels import ENTRY_POINTS
for addr, name in ENTRY_POINTS.items():
d.label(addr, name)
In the file that defines an entry point the same call names the real
code there; in every other file it names the external address. Set
driver_cwd = "disasm" on each target (or put disasm/ on
PYTHONPATH) so the import resolves.
What this approach doesn’t give you is a single view of the whole
program: the call graph, comments check and the other analyses
work one file at a time, and a reference into another file shows up
as an external label rather than being traced. Disassembling several
images in one target is planned for a later release.