API reference

Every public symbol fantasm exposes is re-exported from the top-level fantasm.api package, so from fantasm.api import verify_round_trip, build_call_graph, … is the intended import path. The per-module documentation below is for readers who want the full picture.

The CLI surface is documented separately in CLI reference and lives under fantasm.cli (one module per command group); it is not covered here.

Top-level package

Programmatic API for fantasm.

Sub-modules will hold per-topic surfaces (labels, comments, cfg, audit, …). This package re-exports the stable, flat surface so callers can do either:

from fantasm.api import rename_labels from fantasm.api.labels import rename_labels

The flat re-exports are populated as topics are ported in from the sibling disasm_tools packages.

exception fantasm.api.InfParseError[source]

Bases: ValueError

A .inf file can’t be parsed, or holds no usable address.

class fantasm.api.InfRecord(filename, load_address, exec_address=None, length=None, access=None, locked=False, extras=<factory>)[source]

Bases: object

The fields of one .inf line, as written.

load_address and exec_address are the raw values (which may carry high-order bits); use load_address_16() / exec_address_16() for the 6502 address.

Parameters:
filename: str | None
load_address: int
exec_address: int | None = None
length: int | None = None
access: str | None = None
locked: bool = False
extras: Mapping[str, str]
load_address_16()[source]

The 16-bit load address. Raises InfParseError.

Return type:

int

exec_address_16()[source]

The 16-bit execution address, or None when absent.

Return type:

int | None

fantasm.api.inf_address(value, field_name='load')[source]

Reduce a .inf address field to a 16-bit 6502 address.

Values up to &FFFF are returned unchanged. Values whose high bits are all set (&FFxxxx / &FFFFxxxx) mark an I/O processor address and are reduced to their low 16 bits. Anything else — a second-processor address, or a RISC OS filetype-and-timestamp stamp (&FFFtttdd) — raises InfParseError.

Parameters:
  • value (int)

  • field_name (str)

Return type:

int

fantasm.api.parse_inf(text, *, source=None)[source]

Parse the text of a .inf file.

source names the file in error messages. Raises InfParseError when the text holds no line, or no load address can be found.

Parameters:
  • text (str)

  • source (str | None)

Return type:

InfRecord

fantasm.api.read_inf(inf_filepath)[source]

Read and parse a .inf file. Raises InfParseError.

Parameters:

inf_filepath (str | Path)

Return type:

InfRecord

fantasm.api.sidecar_inf_filepath(binary_filepath)[source]

Return the existing .inf sidecar for a binary, else None.

Tries NAME.inf / NAME.INF beside NAME, then — for a binary with an extension, such as FS3.bin — FS3.inf / FS3.INF.

Parameters:

binary_filepath (str | Path)

Return type:

Path | None

class fantasm.api.TargetSpec(id, binary, driver, asm, json, load_address=None, exec_address=None, metadata=None, inf_source=None)[source]

Bases: object

A [[targets]] entry to write into fantasm.toml.

Paths are as they should appear in the file: relative to the project root (or absolute). inf_source names the .inf file the addresses were read from, recorded as a comment.

Parameters:
  • id (str)

  • binary (str)

  • driver (str)

  • asm (str)

  • json (str)

  • load_address (int | None)

  • exec_address (int | None)

  • metadata (str | None)

  • inf_source (str | None)

id: str
binary: str
driver: str
asm: str
json: str
load_address: int | None = None
exec_address: int | None = None
metadata: str | None = None
inf_source: str | None = None
fantasm.api.append_target(fantasm_toml_filepath, spec)[source]

Append a [[targets]] entry to an existing fantasm.toml.

Raises ValueError if a target with the same id is already declared, or if the result would not parse (for example because the file declares targets as an inline array); the file is left unchanged in that case.

Parameters:
Return type:

None

exception fantasm.api.TargetConfigError[source]

Bases: ValueError

[layout] or [[targets]] configuration is invalid, or a target’s files can’t be resolved from it.

class fantasm.api.TargetFiles(version_id, prefix, version_dirpath, binary_filepath, metadata_filepath, asm_filepath, json_filepath, driver_filepath, load_address=None, exec_address=None, source='convention', image_name=None, inf_filepath=None, driver_cwd_dirpath=None)[source]

Bases: object

The resolved files and load address of one target.

version_id is the target id (the name is kept from when every target was a version). version_dirpath is the target’s version directory under the convention, or the project root for a target configured wholly by [layout] / [[targets]]; relative docs paths in the metadata file resolve against it. metadata_filepath is None when no metadata file is configured. load_address is None only on instances built by hand.

Parameters:
  • version_id (str)

  • prefix (str)

  • version_dirpath (Path)

  • binary_filepath (Path)

  • metadata_filepath (Path | None)

  • asm_filepath (Path)

  • json_filepath (Path)

  • driver_filepath (Path)

  • load_address (int | None)

  • exec_address (int | None)

  • source (str)

  • image_name (str | None)

  • inf_filepath (Path | None)

  • driver_cwd_dirpath (Path | None)

version_id: str
prefix: str
version_dirpath: Path
binary_filepath: Path
metadata_filepath: Path | None
asm_filepath: Path
json_filepath: Path
driver_filepath: Path
load_address: int | None = None
exec_address: int | None = None
source: str = 'convention'
image_name: str | None = None
inf_filepath: Path | None = None
driver_cwd_dirpath: Path | None = None
property target_id: str
exception fantasm.api.TargetNotFoundError(target_id, available)[source]

Bases: LookupError

No configured target has the requested id.

Parameters:
  • target_id (str)

  • available (Sequence[str])

Return type:

None

available: tuple[str, ...]
fantasm.api.default_target_id(project)[source]

The target commands use when none is named, or None.

[project] default_target when set (it must name a known target), else the only target when there is exactly one.

Parameters:

project (ProjectContext)

Return type:

str | None

fantasm.api.list_target_ids(project)[source]

Return every known target id, without duplicates.

Order: [[targets]] ids in config order; then, when [layout] is configured, the [[versions.entry]] ids and the ids found by [layout] discover; then the version directories on disk.

Parameters:

project (ProjectContext)

Return type:

list[str]

fantasm.api.resolve_target(project, target_id)[source]

Resolve a target’s files and load address.

Raises:

Parameters:
Return type:

TargetFiles

class fantasm.api.AssemblerConfig(command=('beebasm', '-i', '{asm}', '-o', '{output}'), cwd='{asm_dirpath}', output='{output}')[source]

Bases: object

How to assemble a listing for round-trip verification.

command is the argument list; cwd the directory to run it in; output the file to compare against the original binary, relative to cwd unless absolute. Each may use these tokens:

  • {asm} — absolute path of the listing;

  • {asm_dirpath} / {asm_filename} — its directory and name;

  • {output} — a fresh temporary file path fantasm reads back (and deletes) afterwards;

  • {output_dirpath} — the temporary directory holding it;

  • {binary} — absolute path of the original binary;

  • {project_root} — the project root, when known.

The defaults reproduce the built-in beebasm invocation: beebasm -i {asm} -o {output} run in the listing’s directory, comparing {output}. An assembler that always writes a fixed file — for example via a SAVE "NAME" directive — sets output to that file instead.

Parameters:
command: tuple[str, ...] = ('beebasm', '-i', '{asm}', '-o', '{output}')
cwd: str = '{asm_dirpath}'
output: str = '{output}'
property is_default: bool
property display_name: str

The assembler’s name, for messages.

classmethod from_mapping(settings)[source]

Build from a [verify] table; missing keys keep defaults.

command may be a list of arguments or a single string, which is split with shell-like quoting rules.

Parameters:

settings (Mapping[str, Any])

Return type:

AssemblerConfig

exception fantasm.api.AssemblerConfigError[source]

Bases: ValueError

A [verify] assembler configuration is invalid.

exception fantasm.api.AssemblerNotFoundError[source]

Bases: RuntimeError

Raised when the configured assembler can’t be found.

exception fantasm.api.AssemblerOutputMissingError[source]

Bases: RuntimeError

The assembler succeeded but the expected output file is absent.

class fantasm.api.AsmSection(start_line, end_line, lines)[source]

Bases: object

A slice of assembly lines extracted by extract_section().

start_line and end_line are 0-based; end_line is exclusive. lines is the captured text without line numbers.

Parameters:
start_line: int
end_line: int
lines: list[str]
exception fantasm.api.DotBinaryNotFoundError[source]

Bases: FileNotFoundError

The graphviz dot (or other engine) binary is not on PATH.

exception fantasm.api.VersionNotFoundError(version_id, versions_dirpath, available)[source]

Bases: LookupError

Raised when no version directory matches the requested ID.

Parameters:
  • version_id (str)

  • versions_dirpath (Path)

  • available (Sequence[str])

Return type:

None

fantasm.api.basic_blocks_to_dot(blocks, *, sub_name=None, sub_addr=None)[source]

Render a per-subroutine basic-block CFG as graphviz dot source.

Parameters:
Return type:

str

fantasm.api.basic_blocks_to_graph(blocks, *, sub_name=None, sub_addr=None)[source]

Build a DiGraph from a list of basic blocks.

Nodes are keyed by blk_NNNN (4-digit uppercase hex) and carry addr, label (HTML), is_entry, is_return. Edges carry kind (one of the BasicBlockExit kinds) and target_addr. External targets (branch / jump destinations outside the supplied blocks) are emitted as placeholder nodes with external=True.

Parameters:
Return type:

DiGraph

fantasm.api.basic_blocks_to_graphml(blocks, *, sub_name=None, sub_addr=None)[source]

Render a per-subroutine basic-block CFG as GraphML text.

Parameters:
Return type:

str

fantasm.api.build_index(asm_lines)[source]

Build (addr_to_line, label_to_line) indices.

For each line, looks for a ;NNNN: comment marker (the disassembler’s address tag) and a :NNNN[...] runtime-address annotation; both are recorded in addr_to_line. Lines starting with .label are recorded in label_to_line. The first occurrence wins for any given key.

Parameters:

asm_lines (Sequence[str])

Return type:

tuple[dict[int, int], dict[str, int]]

fantasm.api.build_memory_regions(meta, base_regions=())[source]

Build the full memory-region list for a single version.

Appends the ROM region (meta["load_addr"] to meta["end_addr"] - 1) onto base_regions. base_regions typically comes from fantasm.api.version_graph.VersionGraph.effective_regions(), converted from Region dataclasses to (start, end) tuples. Defaults to empty — ROM-only.

Raises KeyError if load_addr or end_addr are missing from meta.

Parameters:
Return type:

list[tuple[int, int]]

fantasm.api.build_target_refs(items)[source]

Return an addr -> referencing items index.

Indexes by each item’s target field (the address of an instruction’s operand target, when the operand is a code/data reference).

Parameters:

items (Iterable[dict])

Return type:

dict[int, list[dict]]

fantasm.api.call_graph_to_dot(graph, *, title=None)[source]

Render a call graph (DiGraph) as graphviz dot source.

Parameters:
Return type:

str

fantasm.api.call_graph_to_graphml(graph)[source]

Render a call graph as GraphML text.

Parameters:

graph (DiGraph)

Return type:

str

fantasm.api.classify_labels(auto_labels, items, target_refs, audit_subs, memory_regions)[source]

Classify each auto-generated label into a category.

Returns a list of dicts with keys: name, addr, category, parent_sub_name, parent_sub_addr, inbound_refs (each {addr, mnemonic, sub_name, sub_addr, cross_sub}), cross_sub_count.

Parameters:
Return type:

list[dict]

fantasm.api.collect_auto_labels(items)[source]

Return [(label_name, addr), ...] for every auto-generated label.

Walks items’ "labels" and "sub_labels" fields and keeps anything matching AUTO_LABEL_RE.

Parameters:

items (Iterable[dict])

Return type:

list[tuple[str, int]]

fantasm.api.collect_labels(data)[source]

Return every label declared in a disassembly, with source tag.

Walks data["items"]’s "labels" and "sub_labels" fields (tagged source="driver") and data["external_labels"] (tagged source="env"). Each entry is a dict with keys name, addr, source.

Driver-defined labels are emitted in item / declaration order; environment labels follow, in declaration order from the JSON. Duplicates are not collapsed — a label that appears as both an item label and a sub-label will yield two records. Callers that need uniqueness should dedupe on (name, addr).

Parameters:

data (dict)

Return type:

list[dict]

fantasm.api.end_type(sub)[source]

Short string describing how a subroutine ends.

Parameters:

sub (dict)

Return type:

str

fantasm.api.extract_section(asm_lines, start_target, end_target=None, default_window=40)[source]

Extract a section of assembly lines spanning two targets.

The section starts at the line resolved from start_target and ends at the line resolved from end_target (inclusive). When no end_target is given, default_window lines are taken.

The start is backed up across preceding blank, comment-only, and label-only lines so the captured section includes the natural “header” of the routine or block.

Raises LookupError if either target cannot be resolved.

Parameters:
Return type:

AsmSection

fantasm.api.filter_call_graph(graph, *, focus=None, up_depth=None, down_depth=None, exclude_external=False, include_pattern=None, exclude_pattern=None, min_degree=0)[source]

Return a filtered copy of a call graph.

The filters compose. When focus is supplied, the result is restricted to a neighbourhood: ancestors up to up_depth hops and descendants up to down_depth hops. Either may be None (unlimited). When focus is omitted, the depth options are ignored and the whole graph is considered.

include_pattern / exclude_pattern are regex strings matched against each node’s name attribute (or its id if no name). min_degree drops low-connectivity nodes; focus nodes are immune.

Parameters:
  • graph (DiGraph)

  • focus (Sequence[str] | None)

  • up_depth (int | None)

  • down_depth (int | None)

  • exclude_external (bool)

  • include_pattern (str | None)

  • exclude_pattern (str | None)

  • min_degree (int)

Return type:

DiGraph

fantasm.api.find_containing_sub(addr, rom_subs)[source]

Return the last subroutine in rom_subs whose addr <= addr.

rom_subs must be sorted by addr ascending.

Parameters:
Return type:

dict | None

fantasm.api.find_containing_sub_for_addr(addr, sorted_subs, memory_regions)[source]

Find the subroutine in the same memory region whose start ≤ addr.

Unlike fantasm.api.audit.find_containing_sub(), this variant is region-aware: a sub in a different memory region is never treated as containing addr.

Parameters:
Return type:

dict | None

fantasm.api.find_line_for_target(target, addr_to_line, label_to_line)[source]

Resolve a textual target to a 0-based line number.

Resolution order:

  1. If target parses as a hex address, return the line that maps to that address — or, failing an exact match, the line for the nearest indexed address less than or equal to target.

  2. If target matches a label exactly, return that label’s line.

  3. If target is a substring of exactly one label, return that label’s line. If it matches multiple labels, emits a UserWarning listing the matches and returns the lexically first match’s line so callers continue to make progress.

Returns None when nothing matches.

Parameters:
Return type:

int | None

fantasm.api.find_sub(subs, target)[source]

Find a subroutine by hex address or name.

Tries (1) hex address addr_str (with $/&/0x prefixes stripped), (2) exact name match, (3) substring match on name. When the substring match is ambiguous, emits a UserWarning listing the matches and returns None.

Parameters:
Return type:

dict | None

fantasm.api.find_undeclared_subs(json_filepath)[source]

Find JSR targets that lack subroutine() declarations.

Returns a list of dicts with: addr, name, range_str, code_count, data_count, caller_count, container.

Parameters:

json_filepath (str | Path)

Return type:

list[dict]

fantasm.api.inbound_refs_to(addr, items_by_addr, target_refs)[source]

Return ref-site records pointing at addr.

Combines two sources:

  • code-flow references — items whose target == addr (taken from target_refs).

  • data references — addresses listed in the references field of the item at addr (table / pointer references the disassembler couldn’t attribute to a specific instruction).

Each record carries {"addr", "mnemonic", "kind"}. Code-flow refs use the referencing item’s mnemonic; data refs use the mnemonic of the item at the referencing address when available, else "data_ref". kind is the dasmos >= 2.0 ReferenceKind (direct / indexed / pointer / …) of the access, joined from the references field of the item at addr by caller address; it is None for a caller the schema-v2 references field does not classify (including any JSON produced by pre-2.0 dasmos, which did not carry kinds). Sorted by addr; duplicates (same source addr) are collapsed, with the code-flow record taking precedence.

Parameters:
Return type:

list[dict]

fantasm.api.instruction_length(opcode, cpu='6502')[source]

Length in bytes of the instruction starting with opcode.

Returns 0 if opcode is invalid on the named CPU.

Parameters:
  • opcode (int)

  • cpu (str | None)

Return type:

int

fantasm.api.label_inventory(data)[source]

Build the full label inventory for one disassembly.

Each entry: {"name", "addr", "source", "length", "ref_count", "refs", "direct_count", "indexed_count", "other_count", "is_code", "index_base_only"}. length is len(name). refs is the list returned by inbound_refs_to() for the label’s address; ref_count is its length, broken out so sorts / filters don’t pay the indexing cost.

The three per-kind counts split refs by the dasmos >= 2.0 ReferenceKind of each site: direct_count (plain lda addr), indexed_count (indexing-base lda addr,X), and other_count (every other or unclassified site — pointer references, and any site left kind == None by pre-2.0 JSON).

is_code is true when the item at the label’s address is a type == "code" item — the caveat flag for the d.index_base() workflow, where a genuine code entry point a loop happens to read as lda entry,X must not be moved off the memory map. index_base_only is true when the label has at least one reference and every one is indexed — the exact d.index_base() conversion candidate.

Driver labels are deduped on (name, addr) (a label can show up as both an item label and a sub-label of the parent item); env labels are kept as-is.

Parameters:

data (dict)

Return type:

list[dict]

fantasm.api.load_subroutines(json_filepath, *, memory_regions=None)[source]

Load JSON output and compute per-subroutine extents and flags.

Returns a list of subroutine dicts augmented with: items, code_count, data_count, last_mnemonic, terminates, next_sub, entry_refs, branch_entry_refs, escaping_branches, flags. The flags entry is a set of strings drawn from ALL_FLAGS.

memory_regions constrains where subroutine extents may run. The list should already include both the ROM range and any workspace regions where small subs may live. If omitted, it defaults to the ROM range alone (derived from the JSON’s meta) — sub extents are then bounded by ROM only.

Parameters:
Return type:

list[dict]

fantasm.api.mnemonic(opcode, cpu='6502')[source]

Mnemonic for opcode on the named CPU.

Returns "???" for opcodes that are invalid on the named CPU.

Parameters:
  • opcode (int)

  • cpu (str | None)

Return type:

str

fantasm.api.opcode_tables(cpu='6502')[source]

Return (lengths, mnemonics) for the named CPU.

Recognised values (case-insensitive): "6502" (default, NMOS), "65c02" (CMOS, BBC Master 128). "nmos", "cmos", "65sc12" and "65c12" are accepted as aliases.

Raises ValueError for unknown CPUs.

The returned lists are independent for each CPU family but the same list objects are returned on each call — callers must not mutate them.

Parameters:

cpu (str | None)

Return type:

tuple[list[int], list[str]]

fantasm.api.parse_address(text)[source]

Parse a hex address in any of the conventional notations.

Recognises $8000, &8000, 0x8000 and bare 8000. Returns None if the text is not a valid hex integer.

Parameters:

text (str)

Return type:

int | None

fantasm.api.project_rom_prefixes(project)[source]

Return the configured ROM-name prefixes for the project.

Reads [versions] prefixes from fantasm.toml. If unset, falls back to [project] name. If neither is set, returns an empty tuple — callers should treat that as a configuration error.

Parameters:

project (ProjectContext)

Return type:

tuple[str, …]

fantasm.api.project_versions_dirpath(project)[source]

Return the project’s versions directory.

Reads [versions] directory from fantasm.toml (defaulting to "versions"), resolved against the project root.

Raises RuntimeError if the project root has not been resolved.

Parameters:

project (ProjectContext)

Return type:

Path

fantasm.api.reference_addr(ref)[source]

Return the caller runtime address of a single references entry.

Accepts the dasmos >= 2.0 {"addr", "kind", "move_id"?} object or the pre-2.0 bare-int address.

Parameters:

ref (int | dict)

Return type:

int

fantasm.api.reference_addrs(refs)[source]

Return the caller addresses of a whole references list.

None (the field absent) yields an empty list.

Parameters:

refs (Iterable[int | dict] | None)

Return type:

list[int]

fantasm.api.reference_kind(ref)[source]

Return the ReferenceKind of a references entry, if known.

dasmos >= 2.0 objects carry a kind; the pre-2.0 bare-int schema did not classify references, so this is None for those.

Parameters:

ref (int | dict)

Return type:

str | None

fantasm.api.region_for_addr(addr, memory_regions)[source]

Return the (start, end) region containing addr, or None.

Parameters:
Return type:

tuple[int, int] | None

fantasm.api.render_dot(dot_text, output_filepath, *, format='png', engine='dot')[source]

Pipe dot_text into engine and write the rendered output.

Returns the output path. Raises DotBinaryNotFoundError if the engine binary is missing, and propagates subprocess.CalledProcessError from the engine itself.

Parameters:
Return type:

Path

fantasm.api.resolve_version_dirpath(versions_dirpath, version_id, prefixes)[source]

Return the directory containing the named version.

Looks for {prefix}-{version_id} under versions_dirpath for each prefix in order, returning the first one that exists. Raises VersionNotFoundError when no candidate matches; the exception carries the list of available version directory names so a caller can render a helpful error.

Parameters:
Return type:

Path

fantasm.api.resolve_version_dirpath_for_project(project, version_id)[source]

Resolve a version directory using project-configured prefixes.

Parameters:
Return type:

Path

fantasm.api.rom_prefix(version_dirpath, prefixes)[source]

Return the ROM prefix matched by version_dirpath’s name.

For example, with prefixes ("anfs", "nfs") and a directory named anfs-3.10, returns "anfs". Multi-hyphen prefixes like tube-6502-client are matched eagerly, so tube-6502-client-1.10 returns "tube-6502-client".

Falls back to the substring before the last hyphen if no configured prefix matches — this preserves the old behaviour for callers operating on arbitrary directory names.

Parameters:
Return type:

str

fantasm.api.rom_prefix_for_project(project, version_dirpath)[source]

Extract a ROM prefix using project-configured prefixes.

Parameters:
Return type:

str

fantasm.api.scan_routine_range(addr, items_by_addr, sorted_addrs)[source]

Walk the items starting at addr until a terminating mnemonic.

Returns (range_end, code_count, data_count, falls_through). range_end is the address of the terminating instruction, or None if the routine falls through without terminating.

Parameters:
Return type:

tuple[int | None, int, int, bool]

fantasm.api.sort_labels(classified)[source]

Sort by category order, then parent-sub address, then label address.

Parameters:

classified (Sequence[dict])

Return type:

list[dict]

6502 / 65C02 opcodes and mnemonics

MOS 6502 / WDC 65C02 instruction tables and helpers.

This module is intentionally just CPU facts — opcode lengths, mnemonics, and a small dispatcher. Project-specific concerns like ROM base address and bank size belong in fantasm.toml, not here.

Tables for both the NMOS 6502 (the ubiquitous variant) and the CMOS 65C02 / 65SC12 (used in the BBC Master 128) are exposed. Use opcode_tables() for the common case, or read the OPCODE_* constants directly for the NMOS tables and the OPCODE_*_65C02 constants for the CMOS tables.

Source for the 65C02 overrides: py8dis/cpu65C02.py in the acornaeology py8dis fork (also reflected in dasmos’s ext/cpus/cpu65c02).

fantasm.api.mos6502.opcode_tables(cpu='6502')[source]

Return (lengths, mnemonics) for the named CPU.

Recognised values (case-insensitive): "6502" (default, NMOS), "65c02" (CMOS, BBC Master 128). "nmos", "cmos", "65sc12" and "65c12" are accepted as aliases.

Raises ValueError for unknown CPUs.

The returned lists are independent for each CPU family but the same list objects are returned on each call — callers must not mutate them.

Parameters:

cpu (str | None)

Return type:

tuple[list[int], list[str]]

fantasm.api.mos6502.instruction_length(opcode, cpu='6502')[source]

Length in bytes of the instruction starting with opcode.

Returns 0 if opcode is invalid on the named CPU.

Parameters:
  • opcode (int)

  • cpu (str | None)

Return type:

int

fantasm.api.mos6502.mnemonic(opcode, cpu='6502')[source]

Mnemonic for opcode on the named CPU.

Returns "???" for opcodes that are invalid on the named CPU.

Parameters:
  • opcode (int)

  • cpu (str | None)

Return type:

str

Project paths and version directory layout

Project-layout path resolution.

The four sibling projects each had a tiny paths.py that hard-coded a single ROM-name prefix (adfs, nfs, econet-bridge, …) — and NFS uniquely supported two (anfs / nfs). fantasm generalises that by reading the prefixes from fantasm.toml.

Schema (under [versions] in fantasm.toml):

[versions]
directory = "versions"          # default; relative to project root
prefixes  = ["anfs", "nfs"]     # ordered: first match wins

If prefixes is omitted the project’s [project] name is used as a single-element fallback.

The pure helpers (resolve_version_dirpath(), rom_prefix()) take prefix lists explicitly; the *_for_project wrappers obtain them from a fantasm.config.ProjectContext.

Library code in fantasm raises VersionNotFoundError rather than calling sys.exit; the CLI layer translates the exception into a clean exit code with a helpful message.

exception fantasm.api.paths.VersionNotFoundError(version_id, versions_dirpath, available)[source]

Bases: LookupError

Raised when no version directory matches the requested ID.

Parameters:
  • version_id (str)

  • versions_dirpath (Path)

  • available (Sequence[str])

Return type:

None

fantasm.api.paths.project_binary_base(project)[source]

Return the project’s default load address (base_address key).

Reads [binary] base_address (or [rom] base_address), defaulting to 0x8000 (the BBC sideways-ROM convention).

Parameters:

project (ProjectContext)

Return type:

int

fantasm.api.paths.project_binary_dirname(project)[source]

Return the per-version binary subdirectory name (dir key).

Reads [binary] dir (or [rom] dir), defaulting to "rom".

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_binary_extension(project)[source]

Return the binary file extension, without a leading dot.

Reads [binary] extension (or [rom] extension), defaulting to "rom". An empty string means the binary has no extension (e.g. a DFS *RUN program named KEYPAD).

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_binary_filename_template(project)[source]

Return the binary-basename template (filename key).

Reads [binary] filename (or [rom] filename), defaulting to "{prefix}-{version_id}". Rendered by render_binary_filename(); the extension (if any) is appended afterwards. Set e.g. "{version_id_upper}" to keep an Acorn DFS-style name like KEYPAD from the id keypad.

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_binary_metadata_filename(project)[source]

Return the per-version metadata filename (metadata key).

Reads [binary] metadata (or [rom] metadata), defaulting to "rom.json". The file lives in the binary subdirectory.

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_binary_section(project)[source]

Return the project’s binary-layout config section.

Reads [binary] from fantasm.toml when present, else falls back to the historical [rom] section, else an empty mapping. The two are not merged — whichever section is present wins — so a project migrating to [binary] should move all of its keys.

Parameters:

project (ProjectContext)

Return type:

Mapping[str, Any]

fantasm.api.paths.project_cpu(project)[source]

Return the project’s default CPU (cpu key).

Reads [binary] cpu (or [rom] cpu), defaulting to "6502".

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_driver_dirname(project)[source]

Return the project’s driver-script subdirectory name.

Reads [versions] driver_dirname from fantasm.toml, defaulting to "disassemble".

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_driver_filename_template(project)[source]

Return the project’s driver-filename template.

Reads [versions] driver_filename from fantasm.toml. Default: "disasm_{prefix}_{version_id_no_dots}.py".

Parameters:

project (ProjectContext)

Return type:

str

fantasm.api.paths.project_rom_prefixes(project)[source]

Return the configured ROM-name prefixes for the project.

Reads [versions] prefixes from fantasm.toml. If unset, falls back to [project] name. If neither is set, returns an empty tuple — callers should treat that as a configuration error.

Parameters:

project (ProjectContext)

Return type:

tuple[str, …]

fantasm.api.paths.project_versions_dirpath(project)[source]

Return the project’s versions directory.

Reads [versions] directory from fantasm.toml (defaulting to "versions"), resolved against the project root.

Raises RuntimeError if the project root has not been resolved.

Parameters:

project (ProjectContext)

Return type:

Path

fantasm.api.paths.render_binary_filename(template, prefix, version_id)[source]

Render a binary-basename template with project / version tokens.

Same token set as render_driver_filename() (see _filename_tokens()). The binary’s extension, if any, is appended by the caller after the rendered basename.

Parameters:
  • template (str)

  • prefix (str)

  • version_id (str)

Return type:

str

fantasm.api.paths.render_driver_filename(template, prefix, version_id)[source]

Render a driver-filename template with project / version tokens.

See _filename_tokens() for the recognised tokens.

Parameters:
  • template (str)

  • prefix (str)

  • version_id (str)

Return type:

str

fantasm.api.paths.resolve_version_dirpath(versions_dirpath, version_id, prefixes)[source]

Return the directory containing the named version.

Looks for {prefix}-{version_id} under versions_dirpath for each prefix in order, returning the first one that exists. Raises VersionNotFoundError when no candidate matches; the exception carries the list of available version directory names so a caller can render a helpful error.

Parameters:
Return type:

Path

fantasm.api.paths.resolve_version_dirpath_for_project(project, version_id)[source]

Resolve a version directory using project-configured prefixes.

Parameters:
Return type:

Path

fantasm.api.paths.rom_prefix(version_dirpath, prefixes)[source]

Return the ROM prefix matched by version_dirpath’s name.

For example, with prefixes ("anfs", "nfs") and a directory named anfs-3.10, returns "anfs". Multi-hyphen prefixes like tube-6502-client are matched eagerly, so tube-6502-client-1.10 returns "tube-6502-client".

Falls back to the substring before the last hyphen if no configured prefix matches — this preserves the old behaviour for callers operating on arbitrary directory names.

Parameters:
Return type:

str

fantasm.api.paths.rom_prefix_for_project(project, version_dirpath)[source]

Extract a ROM prefix using project-configured prefixes.

Parameters:
Return type:

str

Project-root discovery and fantasm.toml loading.

class fantasm.config.ProjectContext(root_dirpath, config_filepath=None, config=<factory>)[source]

Bases: object

The resolved project context for a single fantasm invocation.

Parameters:
root_dirpath: Path | None
config_filepath: Path | None = None
config: Mapping[str, Any]
property has_root: bool
fantasm.config.resolve_project_context(explicit_root_dirpath)[source]

Resolve the project root and load fantasm.toml if present.

Resolution order:

  1. explicit_root_dirpath — populated by Click from --project-root or, failing that, the FANTASM_PROJECT_ROOT environment variable.

  2. Walk upwards from the current working directory looking for fantasm.toml.

  3. Unresolved (returns a context with root_dirpath = None).

Parameters:

explicit_root_dirpath (Path | None)

Return type:

ProjectContext

Targets: resolving a target’s files

Disassembly targets and where their files live.

A target is one thing being disassembled: a program binary loaded at some address, the driver script that disassembles it, and the .asm / .json artefacts the driver writes. Every per-version command operates on a target; in a multi-version project each version is a target.

Where a target’s files live is resolved in three layers, each one overriding the one before it key by key:

  1. Convention — the versions/<prefix>-<id>/ tree, configured by [versions] and [binary]. This is the default, and the only layer a project without [layout] or [[targets]] uses.

  2. ``[layout]`` templates — path templates rendered per target id, for multi-version projects whose files don’t follow the versions/ convention:

    [layout]
    binary = "roms/{id}.rom"
    driver = "scripts/dis_{id_no_dots}.py"
    asm    = "build/{id}.asm"
    json   = "build/{id}.json"
    
  3. ``[[targets]]`` entries — explicit per-target paths, for adopting a project with its own layout as it stands:

    [[targets]]
    id     = "loader"
    binary = "orig/ELITE"        # load/exec from orig/ELITE.inf
    driver = "tools/dis_loader.py"
    asm    = "build/loader.asm"
    json   = "build/loader.json"
    

All configured paths are relative to the project root. A target’s load address comes from, in order: its load_address, its .inf sidecar (for [[targets]] entries, read automatically when present or named by inf), the [layout] load_address, and finally [binary] base_address.

The set of known targets is the [[targets]] ids, then (when [layout] is configured) the [[versions.entry]] ids and any ids discovered by [layout] discover, then the version directories on disk. With exactly one target — or a [project] default_target — commands can be run without naming it.

exception fantasm.api.targets.TargetConfigError[source]

Bases: ValueError

[layout] or [[targets]] configuration is invalid, or a target’s files can’t be resolved from it.

class fantasm.api.targets.TargetFiles(version_id, prefix, version_dirpath, binary_filepath, metadata_filepath, asm_filepath, json_filepath, driver_filepath, load_address=None, exec_address=None, source='convention', image_name=None, inf_filepath=None, driver_cwd_dirpath=None)[source]

Bases: object

The resolved files and load address of one target.

version_id is the target id (the name is kept from when every target was a version). version_dirpath is the target’s version directory under the convention, or the project root for a target configured wholly by [layout] / [[targets]]; relative docs paths in the metadata file resolve against it. metadata_filepath is None when no metadata file is configured. load_address is None only on instances built by hand.

Parameters:
  • version_id (str)

  • prefix (str)

  • version_dirpath (Path)

  • binary_filepath (Path)

  • metadata_filepath (Path | None)

  • asm_filepath (Path)

  • json_filepath (Path)

  • driver_filepath (Path)

  • load_address (int | None)

  • exec_address (int | None)

  • source (str)

  • image_name (str | None)

  • inf_filepath (Path | None)

  • driver_cwd_dirpath (Path | None)

version_id: str
prefix: str
version_dirpath: Path
binary_filepath: Path
metadata_filepath: Path | None
asm_filepath: Path
json_filepath: Path
driver_filepath: Path
load_address: int | None = None
exec_address: int | None = None
source: str = 'convention'
image_name: str | None = None
inf_filepath: Path | None = None
driver_cwd_dirpath: Path | None = None
property target_id: str
exception fantasm.api.targets.TargetNotFoundError(target_id, available)[source]

Bases: LookupError

No configured target has the requested id.

Parameters:
Return type:

None

available: tuple[str, ...]
fantasm.api.targets.default_target_id(project)[source]

The target commands use when none is named, or None.

[project] default_target when set (it must name a known target), else the only target when there is exactly one.

Parameters:

project (ProjectContext)

Return type:

str | None

fantasm.api.targets.discover_target_ids(project)[source]

Ids found on disk by the [layout] discover template.

The template is a path relative to the project root containing exactly one {id} token, for example "roms/{id}.rom"; every matching file or directory contributes its {id} part. Returns [] when discover isn’t set.

Parameters:

project (ProjectContext)

Return type:

list[str]

fantasm.api.targets.list_target_ids(project)[source]

Return every known target id, without duplicates.

Order: [[targets]] ids in config order; then, when [layout] is configured, the [[versions.entry]] ids and the ids found by [layout] discover; then the version directories on disk.

Parameters:

project (ProjectContext)

Return type:

list[str]

fantasm.api.targets.project_layout(project)[source]

Return the validated [layout] section (empty when absent).

Parameters:

project (ProjectContext)

Return type:

Mapping[str, Any]

fantasm.api.targets.project_target_entries(project)[source]

Return the validated [[targets]] entries, in config order.

Parameters:

project (ProjectContext)

Return type:

list[Mapping[str, Any]]

fantasm.api.targets.render_layout_template(template, target_id, prefix, *, key='path')[source]

Render a [layout] template. Raises TargetConfigError.

Parameters:
Return type:

str

fantasm.api.targets.resolve_target(project, target_id)[source]

Resolve a target’s files and load address.

Raises:

Parameters:
Return type:

TargetFiles

fantasm.api.targets.target_tokens(target_id, prefix)[source]

Token substitutions for [layout] templates.

{id}, {id_no_dots}, {id_upper}, {id_lower}, the {prefix} family and the {version_id} family (aliases of the {id} ones, matching [versions] driver_filename).

Parameters:
  • target_id (str)

  • prefix (str)

Return type:

dict[str, str]

fantasm.api.targets.target_verify_settings(project, target_id)[source]

Return [verify] merged with the target’s own verify table.

Parameters:
Return type:

Mapping[str, Any]

fantasm.api.targets.uses_target_config(project)[source]

True when [layout] or [[targets]] is configured.

Parameters:

project (ProjectContext)

Return type:

bool

Acorn .inf sidecar files

Parse Acorn .inf sidecar files.

Files extracted from Acorn DFS, ADFS and Econet discs usually travel with a .inf sidecar recording the attributes the host filing system can’t hold: load address, execution address, length and access. There is no single format — every archiving tool wrote its own variant — but they share a shape: one line holding the file’s name followed by whitespace-separated fields. Typical examples:

$.ELITE     FF1900 FF8023 000400 L
$.ELITE     001900 008023 Locked CRC=1A2B NEXT $.ELITE2
FS3         00001900 00001900 00004C00 33
"My File"   &1900 &8023 &400 WR/r CRC32=0123ABCD
$.LOADER    FFFF0E00 FFFF0E00 00000300 19 CRC=E4AB

The parser is deliberately tolerant:

  • the filename may be quoted; a leading & / 0x number in place of a filename is taken as “no filename”;

  • numbers are hexadecimal, with or without a &, $ or 0x prefix, and of any width (6-digit DFS and 8-digit ADFS fields are both common);

  • the numeric fields fill load, exec, length and access in that order; trailing ones may be absent;

  • L / Locked marks a locked file; ADFS-style access strings such as WR/r are recorded as the access field;

  • KEY=VALUE extras (CRC=, CRC32=, NEXT=, DATETIME= …) and the older CRC 1A2B / NEXT $.FILE keyword-then-value forms are collected and otherwise ignored.

Addresses are recorded as written; inf_address() reduces one to the 16-bit 6502 address it denotes, accepting the FF/FFFF “I/O processor” high bits and rejecting values that aren’t addresses at all (such as a RISC OS filetype-and-timestamp stamp).

exception fantasm.api.inf.InfParseError[source]

Bases: ValueError

A .inf file can’t be parsed, or holds no usable address.

class fantasm.api.inf.InfRecord(filename, load_address, exec_address=None, length=None, access=None, locked=False, extras=<factory>)[source]

Bases: object

The fields of one .inf line, as written.

load_address and exec_address are the raw values (which may carry high-order bits); use load_address_16() / exec_address_16() for the 6502 address.

Parameters:
filename: str | None
load_address: int
exec_address: int | None = None
length: int | None = None
access: str | None = None
locked: bool = False
extras: Mapping[str, str]
load_address_16()[source]

The 16-bit load address. Raises InfParseError.

Return type:

int

exec_address_16()[source]

The 16-bit execution address, or None when absent.

Return type:

int | None

fantasm.api.inf.inf_address(value, field_name='load')[source]

Reduce a .inf address field to a 16-bit 6502 address.

Values up to &FFFF are returned unchanged. Values whose high bits are all set (&FFxxxx / &FFFFxxxx) mark an I/O processor address and are reduced to their low 16 bits. Anything else — a second-processor address, or a RISC OS filetype-and-timestamp stamp (&FFFtttdd) — raises InfParseError.

Parameters:
  • value (int)

  • field_name (str)

Return type:

int

fantasm.api.inf.parse_inf(text, *, source=None)[source]

Parse the text of a .inf file.

source names the file in error messages. Raises InfParseError when the text holds no line, or no load address can be found.

Parameters:
  • text (str)

  • source (str | None)

Return type:

InfRecord

fantasm.api.inf.read_inf(inf_filepath)[source]

Read and parse a .inf file. Raises InfParseError.

Parameters:

inf_filepath (str | Path)

Return type:

InfRecord

fantasm.api.inf.sidecar_inf_filepath(binary_filepath)[source]

Return the existing .inf sidecar for a binary, else None.

Tries NAME.inf / NAME.INF beside NAME, then — for a binary with an extension, such as FS3.bin — FS3.inf / FS3.INF.

Parameters:

binary_filepath (str | Path)

Return type:

Path | None

Round-trip verification

Round-trip assembly verification.

Re-assembles a generated .asm file and compares the resulting bytes against the original binary. Used to catch regressions where a refactor changes the disassembler’s output in a way that no longer matches the source binary.

The assembler is beebasm by default. A project using another assembler (or a wrapper script) describes how to run it with an AssemblerConfig — typically read from fantasm.toml’s [verify] section by AssemblerConfig.from_mapping().

Sibling verify.py mixed the comparison with print/exit. The fantasm port returns a VerifyResult so callers (CLI, tests, scripts) can format the outcome themselves.

class fantasm.api.verify.AssemblerConfig(command=('beebasm', '-i', '{asm}', '-o', '{output}'), cwd='{asm_dirpath}', output='{output}')[source]

Bases: object

How to assemble a listing for round-trip verification.

command is the argument list; cwd the directory to run it in; output the file to compare against the original binary, relative to cwd unless absolute. Each may use these tokens:

  • {asm} — absolute path of the listing;

  • {asm_dirpath} / {asm_filename} — its directory and name;

  • {output} — a fresh temporary file path fantasm reads back (and deletes) afterwards;

  • {output_dirpath} — the temporary directory holding it;

  • {binary} — absolute path of the original binary;

  • {project_root} — the project root, when known.

The defaults reproduce the built-in beebasm invocation: beebasm -i {asm} -o {output} run in the listing’s directory, comparing {output}. An assembler that always writes a fixed file — for example via a SAVE "NAME" directive — sets output to that file instead.

Parameters:
command: tuple[str, ...] = ('beebasm', '-i', '{asm}', '-o', '{output}')
cwd: str = '{asm_dirpath}'
output: str = '{output}'
property is_default: bool
property display_name: str

The assembler’s name, for messages.

classmethod from_mapping(settings)[source]

Build from a [verify] table; missing keys keep defaults.

command may be a list of arguments or a single string, which is split with shell-like quoting rules.

Parameters:

settings (Mapping[str, Any])

Return type:

AssemblerConfig

exception fantasm.api.verify.AssemblerConfigError[source]

Bases: ValueError

A [verify] assembler configuration is invalid.

exception fantasm.api.verify.AssemblerNotFoundError[source]

Bases: RuntimeError

Raised when the configured assembler can’t be found.

exception fantasm.api.verify.AssemblerOutputMissingError[source]

Bases: RuntimeError

The assembler succeeded but the expected output file is absent.

exception fantasm.api.verify.BeebasmNotFoundError[source]

Bases: AssemblerNotFoundError

Raised when beebasm is required but not on PATH.

class fantasm.api.verify.VerifyResult(matched, rom_size, assembled_size, compared_size, first_diff_offset, beebasm_returncode, beebasm_stderr)[source]

Bases: object

Outcome of a round-trip verification.

matched is True when the assembled bytes equal the ROM bytes used for comparison. On a miss, first_diff_offset is the byte offset of the first differing byte (or, if one stream is shorter, the length of the shorter stream).

For sub-banked ROM images (where the file is larger than what is mapped at runtime — e.g. the Tube Client’s 4 KB image with only the upper 2 KB live at &F800-&FFFF), the comparison runs against the trailing portion of the ROM file matching the assembled output’s length. rom_size is then the file size while compared_size is the trailing-slice length actually compared. When no slice is needed the two are equal.

beebasm_returncode and beebasm_stderr capture the assembler’s exit info; both are populated even on success.

Parameters:
  • matched (bool)

  • rom_size (int)

  • assembled_size (int)

  • compared_size (int)

  • first_diff_offset (int | None)

  • beebasm_returncode (int)

  • beebasm_stderr (str)

matched: bool
rom_size: int
assembled_size: int
compared_size: int
first_diff_offset: int | None
beebasm_returncode: int
beebasm_stderr: str
fantasm.api.verify.verify_round_trip(rom_filepath, asm_filepath, beebasm_filepath=None, *, assembler=None, project_root_dirpath=None)[source]

Assemble asm_filepath and compare it to rom_filepath.

assembler describes how to assemble (default: beebasm, see AssemblerConfig). beebasm_filepath may be passed explicitly (e.g. from the beebasm_filepath test fixture) and replaces a beebasm command name; otherwise a bare command name is looked up on PATH, and a relative path with a directory part resolves against project_root_dirpath (when given).

Raises BeebasmNotFoundError (or, for another assembler, AssemblerNotFoundError) if the assembler isn’t found, FileNotFoundError for missing inputs, and AssemblerOutputMissingError if the assembler succeeded but didn’t write the expected output file.

When the binary is larger than the assembled output (sub-banked ROM images, like the Tube Client’s 4 KB file of which only the upper 2 KB is mapped at &F800-&FFFF), the comparison runs against the trailing portion of the file matching the assembled length. The unsliced file size is reported on the result as rom_size and the slice length as compared_size.

Parameters:
Return type:

VerifyResult

Subroutine audit and memory regions

Audit subroutine annotations in a ROM disassembly.

Pure-logic helpers and data-loading routines, factored out of the sibling disasm_tools.audit. Loads the JSON output from a disassembly run and computes per-subroutine extents, termination analysis, entry references, escaping branches, and a flag set describing potential issues.

The presentational format_* and top-level audit() entry from the sibling code are intentionally not yet ported — they print directly to stdout, which is a CLI concern. They will land alongside the fantasm audit Click sub-command.

Region awareness is project-aware: callers pass a version’s memory_regions (typically derived from fantasm.api.version_graph.VersionGraph.effective_regions() plus the ROM range from JSON metadata). When omitted, the default is the ROM range alone — sub extents are bounded by ROM only. The earlier hardcoded BBC ranges that the sibling code carried have been removed.

TERMINATING_MNEMONICS and BRANCH_MNEMONICS are 6502 facts; this module imports them from fantasm.api.mos6502 (the canonical home) and uses them internally.

class fantasm.api.audit.PlaceholderLabel(name, addr, kind, line_number)[source]

Bases: object

A tracer-auto-discovered routine carrying a hex-tail placeholder.

These names are visible in output/<ver>.asm but never reach the JSON’s declared subroutines list, so the regular audit can’t flag them. addr is parsed from the hex tail of name.

Parameters:
name: str
addr: int
kind: str
line_number: int
fantasm.api.audit.build_memory_regions(meta, base_regions=())[source]

Build the full memory-region list for a single version.

Appends the ROM region (meta["load_addr"] to meta["end_addr"] - 1) onto base_regions. base_regions typically comes from fantasm.api.version_graph.VersionGraph.effective_regions(), converted from Region dataclasses to (start, end) tuples. Defaults to empty — ROM-only.

Raises KeyError if load_addr or end_addr are missing from meta.

Parameters:
Return type:

list[tuple[int, int]]

fantasm.api.audit.end_type(sub)[source]

Short string describing how a subroutine ends.

Parameters:

sub (dict)

Return type:

str

fantasm.api.audit.find_containing_sub(addr, rom_subs)[source]

Return the last subroutine in rom_subs whose addr <= addr.

rom_subs must be sorted by addr ascending.

Parameters:
Return type:

dict | None

fantasm.api.audit.find_placeholder_labels(asm_lines)[source]

Scan asm output for hex-tail placeholder labels.

Returns the list in line-number order. Each match is one of:

  • .lXXXX / .cXXXX — pure auto-label (no prefix)

  • .sub_cXXXX — auto-discovered subroutine

  • .loop_cXXXX — auto-discovered loop entry

where XXXX is exactly four lowercase hex digits. Any other prefix is reported as "auto-label" (the structural shape is the same — only the convention differs).

Parameters:

asm_lines (Iterable[str])

Return type:

list[PlaceholderLabel]

fantasm.api.audit.find_sub(subs, target)[source]

Find a subroutine by hex address or name.

Tries (1) hex address addr_str (with $/&/0x prefixes stripped), (2) exact name match, (3) substring match on name. When the substring match is ambiguous, emits a UserWarning listing the matches and returns None.

Parameters:
Return type:

dict | None

fantasm.api.audit.find_undeclared_subs(json_filepath)[source]

Find JSR targets that lack subroutine() declarations.

Returns a list of dicts with: addr, name, range_str, code_count, data_count, caller_count, container.

Parameters:

json_filepath (str | Path)

Return type:

list[dict]

fantasm.api.audit.load_subroutines(json_filepath, *, memory_regions=None)[source]

Load JSON output and compute per-subroutine extents and flags.

Returns a list of subroutine dicts augmented with: items, code_count, data_count, last_mnemonic, terminates, next_sub, entry_refs, branch_entry_refs, escaping_branches, flags. The flags entry is a set of strings drawn from ALL_FLAGS.

memory_regions constrains where subroutine extents may run. The list should already include both the ROM range and any workspace regions where small subs may live. If omitted, it defaults to the ROM range alone (derived from the JSON’s meta) — sub extents are then bounded by ROM only.

Parameters:
Return type:

list[dict]

fantasm.api.audit.region_for_addr(addr, memory_regions)[source]

Return the (start, end) region containing addr, or None.

Parameters:
Return type:

tuple[int, int] | None

fantasm.api.audit.scan_routine_range(addr, items_by_addr, sorted_addrs)[source]

Walk the items starting at addr until a terminating mnemonic.

Returns (range_end, code_count, data_count, falls_through). range_end is the address of the terminating instruction, or None if the routine falls through without terminating.

Parameters:
Return type:

tuple[int | None, int, int, bool]

Comment-vs-code consistency checks

Mechanical comment-vs-code consistency checks.

Runs checks on a disassembly’s JSON output to find inline and block comments that contradict the instruction or surroundings. Each check returns None (no finding) or a list of finding dicts, each tagged with one of two confidence levels:

  • HIGH: the comment almost certainly disagrees with the code.

  • MEDIUM: flagged for human review.

Sibling disasm_tools.comment_check was byte-identical across all four forks at 677 lines. This port lifts the pure-logic surface (constants, individual check functions, run_checks()) into fantasm. The presentational format_findings() and the top-level comment_check() entry are deferred until CLI integration.

class fantasm.api.comment_check.ParentVersion(version_id, data, address_map=None)[source]

Bases: object

A parent version’s disassembly, for check_copied_comments().

address_map is an optional parent→child confidence map ({parent_addr: (child_addr, confidence)}, as produced by compose_chained_map()); when given, findings say where the parent’s annotated instruction lives in the child.

Parameters:
version_id: str
data: dict
address_map: dict[int, tuple[int, int]] | None = None
fantasm.api.comment_check.build_known_addrs(data, *, regions=())[source]

Build the set of “known” addresses for stale-addr checks.

Combines item addresses, subroutine addresses, external labels, and constants from data, plus every address inside regions. regions is (start, end) inclusive tuples, typically the union of effective_regions() and effective_external_regions() for the version being checked. Defaults to empty — only addresses that appear in the JSON output count as known.

The hardcoded BBC zero-page / OS / hardware ranges that the sibling code carried have been removed; pass them via regions instead.

Parameters:
Return type:

set[int]

comments_before carries a Markdown link inside a code span.

Parameters:

item (dict)

Return type:

list[dict]

fantasm.api.comment_check.check_block_stale_addr(item, known_addrs, seen)[source]

comments_before contains stale &XXXX addresses.

Excludes disassembler-generated reference lines and ***** separators. Skips (item_addr, stale) pairs already in seen to avoid duplicating findings already produced for descriptions.

Parameters:
Return type:

list[dict]

fantasm.api.comment_check.check_branch_phrasing(item, _context)[source]

Comment phrasing that belongs to a different kind of instruction.

“Branch …” or “Loop until …” on an instruction that neither branches nor sets up a loop test, or “Load/Push/Pull …” on a branch, usually means the comment sits one instruction away from where it belongs. MEDIUM confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_branch_target(item, _context)[source]

Comment explicitly claims a different branch/jump target.

Only fires when phrasing indicates the hex address IS the target (“branch to &XXXX”, “via &XXXX”, etc.). HIGH confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_byte_of_address(item, _context)[source]

Comment calls an immediate the lo/hi byte of an address it isn’t.

Fires on the first lo/low/hi/high word in the comment that is followed by either a four-digit &XXXX (whose matching half must equal the operand) or a parenthesised two-digit (&XX) (which must equal the operand). Typical cause: a comment copied from another version, where the address differs. HIGH confidence for an explicit &XXXX, MEDIUM for (&XX).

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_chain_comments(sorted_items, sub_range=None)[source]

Increment/decrement chains use consistent comment style.

For chains of 3+ instructions, each segment-first instruction (entry point) should describe the cumulative effect, and non-first instructions should say (continued). Enumeration comments like Add 1 (of 5) are flagged. Chains of exactly 2 are skipped. MEDIUM confidence.

Parameters:
Return type:

list[dict]

fantasm.api.comment_check.check_char_literal_operand(item, _context)[source]

Immediate rendered as a character but described as an address part.

LDA #')' with a comment about an offset, low byte or address usually means the value is really something like <(close_spool_cmd) and the character rendering is wrong. MEDIUM confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_code_read_as_data(sorted_items, *, image_range=None)[source]

Code items that are only ever read as a table, never executed.

A code item whose every cross-reference is an indexed read (LDA table,Y, CMP table,X) is usually a data table the tracer decoded as code, typically because it follows a branch that is in fact always taken. Excluded, because reading instruction bytes there is deliberate:

  • subroutine entry points;

  • items whose operand bytes carry their own labels (e.g. a ROM header JMP whose address bytes are copied elsewhere);

  • items outside image_range ((start, end) exclusive), such as code relocated into workspace;

  • reads that are immediately followed by an indexed store (a copy loop), or that use a biased base such as table - &81,Y, whose raw address deliberately falls before the table;

  • items also used as the base of an indexed store, which must be addressing RAM beyond the item (e.g. hazel_minus_1,Y on the ROM’s last byte), not a table in it.

MEDIUM confidence.

Parameters:
Return type:

list[dict]

fantasm.api.comment_check.check_copied_comments(sorted_items, parent)[source]

Inline comments copied by numeric address onto different code.

When a version is bootstrapped from its parent, an annotation can keep the parent’s address after the code beneath it has moved. The address is still a valid item start, so lint is satisfied, but the comment describes a different instruction. Flags inline comments whose text matches the parent’s comment at the same address while the instruction (or item type) there differs. Skipped when the comment fits both instructions: they differ only in register (DEX/DEY), or the child instruction is a store described as “Write …”/”Store …”.

MEDIUM confidence: either side may be the wrong one.

Parameters:
Return type:

list[dict]

fantasm.api.comment_check.check_cr_value(item, context)[source]

Comment says CR1=&XX / CR2=&XX but preceding LDA loaded a different value.

HIGH confidence.

Parameters:
Return type:

list[dict] | None

Subroutine description / title / on_entry / on_exit carry one.

Parameters:

sub (dict)

Return type:

list[dict]

fantasm.api.comment_check.check_desc_stale_addr(sub, known_addrs)[source]

Subroutine description / title / on_entry / on_exit contains stale &XXXX.

Parameters:
Return type:

list[dict]

Inline comment carries a Markdown link inside a code span.

HIGH confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_mid_item_comments(sorted_items, annotations)[source]

Inline comments keyed to an address inside an item.

The disassembler attaches such a comment to the item that covers the address, so it renders against the wrong line, usually one or two instructions away from what it describes. Typical cause: annotations carried over from a version whose instructions start a byte or two earlier. Needs the driver’s annotations, since the JSON records only the item the comment ended up on.

HIGH confidence inside an instruction, MEDIUM inside data.

Parameters:
Return type:

list[dict]

fantasm.api.comment_check.check_quoted_string_address(item, context)[source]

Comment places a quoted string at an address that holds something else.

Matches "SP." string at &8529 and similar, then compares the quoted text against the bytes at that address (top bit ignored, as Acorn strings often flag their last character). Addresses outside the disassembled image are not checked. MEDIUM confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_reg_value(item, _context)[source]

Comment claims A=/X=/Y= with a value not matching the instruction.

HIGH confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_stale_addr(item, context)[source]

Comment contains an &XXXX not in the version’s address space.

MEDIUM confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.check_tube_register(item, _context)[source]

Comment claims a Tube register access that doesn’t match the operand.

MEDIUM confidence.

Parameters:
Return type:

list[dict] | None

fantasm.api.comment_check.find_chains(sorted_items)[source]

Find chains of 2+ consecutive same-mnemonic increment/decrement ops.

Parameters:

sorted_items (Sequence[dict])

Return type:

list[list[dict]]

Find Markdown address-links sitting inside backtick code spans.

Each Markdown [label](address:HEX) link rendered inside a backtick code span (\`...\`) appears verbatim in the asm listing and the rendered HTML — the link syntax isn’t interpreted inside <code>. Authors typically slip into this when wrapping MNEMONIC + operand together inside one code span.

Returns the list of offending span substrings (with their enclosing backticks) in source order. Empty list when there’s nothing to report.

Parameters:

text (str)

Return type:

list[str]

fantasm.api.comment_check.find_stale_addrs(text, known_addrs)[source]

Find &XXXX hex addresses in text not in known_addrs.

Parameters:
Return type:

list[int]

fantasm.api.comment_check.parse_imm_value(operand)[source]

Parse an immediate operand like #&1C or #42. Returns int or None.

Parameters:

operand (str)

Return type:

int | None

fantasm.api.comment_check.run_checks(data, sub_target=None, *, regions=(), driver_annotations=None, parents=())[source]

Run every check against data and return a flat list of findings.

sub_target, if given, restricts checks to the subroutine starting at that hex address. Raises ValueError for an invalid address.

regions extends the “known address” set used by stale-addr checks; see build_known_addrs(). Pass the version’s effective + external regions for project-aware behaviour.

driver_annotations (from parse_driver_annotations()) enables check_mid_item_comments(); parents enables check_copied_comments() against each parent version.

Parameters:
Return type:

list[dict]

Comment suggestions

Pattern-based comment suggestions for uncommented instructions.

Two layers of heuristics:

  • Instruction patterns — generic 6502 facts. PHA always means “Save A on stack”; CLC means “Clear carry”; etc. These don’t vary per project and ship as DEFAULT_INSTRUCTION_HINTS.

  • Workspace label hints — project-specific. Maps from a label substring (e.g. "wksp_ch_flags") to a human description (e.g. "channel flags"). When an instruction’s operand contains a matching label, the suggestion combines the mnemonic and the hint ("Get channel flags", "Store in channel flags", etc.). Defaults to empty — users supply their project’s hints via fantasm.toml or CLI arguments.

Sibling ADFS tools/suggest_comments.py was the model. The fantasm port lifts the heuristics into pure helpers and parameterises the workspace hints so any project can adopt the workflow.

class fantasm.api.suggest.CommentSuggestion(addr, text)[source]

Bases: object

One suggested comment for an instruction.

Parameters:
addr: int
text: str
fantasm.api.suggest.suggest_comments(items, *, label_hints={}, declared_subs=frozenset({}), instruction_hints=None, address_range=None)[source]

Generate suggestions for every uncommented code item.

Skips non-code items, items already carrying comment_inline, and items where suggest_for_instruction() returns None. With address_range = (start, end), restricts to items in [start, end).

Parameters:
Return type:

list[CommentSuggestion]

fantasm.api.suggest.suggest_for_instruction(item, *, label_hints={}, declared_subs=frozenset({}), instruction_hints=None)[source]

Generate a comment suggestion for a single code item.

Logic order:

  1. JSR / JMP to a target in declared_subs returns None — the disassembler already emits the subroutine’s title.

  2. If the item’s operand contains any key from label_hints, and the mnemonic has a load/store template, returns the templated suggestion ("Get channel flags", etc.).

  3. Falls back to instruction_hints (default DEFAULT_INSTRUCTION_HINTS).

  4. Returns None if nothing matches.

Parameters:
Return type:

str | None

Lint

Lint disassembly driver scripts and project documentation.

Pure-logic helpers for validating that:

  1. Annotation addresses (comment/subroutine/label) in a driver script correspond to addresses the disassembler actually emitted.

  2. Doc addresses cited from rom.json address_links map to a covered range.

  3. Glossary links resolve.

  4. Generated assembly contains no double-comment lines.

NFS was the most capable fork (555 lines vs. 522 / 522 / 492). The fantasm port takes NFS as the base but adopts ADFS’s annotation regex, which captures the subroutine name as well as its address — strictly more information for downstream callers. NFS’s richer docstrings are preserved.

Defaults that were per-project hardcodes in the sibling code (e.g. +0x2000 ROM size) become parameters so a single fantasm install serves projects with different bank sizes.

The presentational lint_* and top-level lint() entry are deferred to CLI integration.

fantasm.api.lint.address_in_ranges(address, ranges)[source]

True iff address falls within any inclusive (start, end) range.

Parameters:
Return type:

bool

fantasm.api.lint.address_ranges_from_data(data, *, base_regions=(), rom_base_default=32768, rom_size_default=8192, block_padding=32, last_block_padding=16, block_gap_threshold=256)[source]

Build address ranges covered by the disassembly.

Returns (start, end) inclusive tuples. Components, in order:

  • base_regions (workspace + hardware/OS regions, typically derived from effective_regions() ∪ effective_external_regions());

  • the full ROM range from JSON metadata;

  • relocated-code clusters: addresses outside the ROM range are grouped into contiguous blocks (gaps larger than block_gap_threshold start a new block) and padded out by block_padding to cover data tails within relocated blocks.

rom_base_default / rom_size_default are fallbacks for when the JSON metadata lacks load_addr / end_addr (BBC 8K sideways-ROM defaults; pass different values for 16K ADFS, 32K-bank carts, etc.).

Parameters:
Return type:

list[tuple[int, int]]

fantasm.api.lint.extract_annotations(driver_text)[source]

Extract all annotation addresses from driver-script text.

Recognises comment, subroutine and label calls in both the py8dis (bare function) and dasmos (d.<method>) dialects, including calls that span several lines, via parse_driver_annotations(). Calls whose address isn’t a literal are skipped. Returns dicts with keys kind, address, line_number (1-based, of the opening line), detail, and name (None for comments, or when the name isn’t a string literal).

For subroutine calls, detail is "entry_point" unless the call passes is_entry_point=False, in which case it is "metadata_only" (the call is documentation-only and the address shouldn’t be expected to appear in the disassembler output).

Raises DriverParseError when the script is not valid Python.

Parameters:

driver_text (str)

Return type:

list[dict]

fantasm.api.lint.find_code_block_ranges(md_text)[source]

Find character offsets of fenced code blocks in Markdown.

Returns inclusive (start, end) character offsets covering each fenced block from its opening fence to its closing one. Recognises both \`\`\ and ~~~ fences.

Parameters:

md_text (str)

Return type:

list[tuple[int, int]]

Extract every inline label: / glossary: link in text.

Each result is {"scheme", "target", "name", "line_number"} where name is the bare label name (@version / ?flag stripped) for a label: link, or the lower-cased slug for a glossary: link.

Parameters:

text (str)

Return type:

list[dict]

fantasm.api.lint.find_nth_occurrence(text, pattern, n)[source]

Find the character offset of the n-th (0-based) occurrence of pattern.

Returns -1 if pattern does not occur n + 1 times.

Parameters:
Return type:

int

fantasm.api.lint.glossary_slugs_from_markdown(md_text)[source]

Parse GLOSSARY.md and return the set of term slugs, for validating glossary:SLUG references.

Parameters:

md_text (str)

Return type:

set[str]

fantasm.api.lint.load_address_ranges(json_filepath, *, base_regions=(), rom_base_default=32768, rom_size_default=8192)[source]

File-IO wrapper around address_ranges_from_data().

Parameters:
Return type:

list[tuple[int, int]]

fantasm.api.lint.load_valid_addresses(json_filepath)[source]

File-IO wrapper around valid_addresses_from_data().

Parameters:

json_filepath (str | Path)

Return type:

set[int]

fantasm.api.lint.offset_in_code_block(offset, code_block_ranges)[source]

True iff offset falls inside any fenced code block.

Parameters:
Return type:

bool

fantasm.api.lint.valid_addresses_from_data(data)[source]

Return the set of valid addresses derived from disassembly JSON.

Combines item addresses, sub-label addresses, external-label addresses, subroutine addresses, and the full ROM range (so labels at move() source addresses are accepted).

Parameters:

data (dict)

Return type:

set[int]

fantasm.api.lint.valid_label_names_from_data(data)[source]

Return every label name the disassembly defines, for validating label:NAME references.

Mirrors the site generator’s build_label_addrs name set so a label: the linter accepts is exactly one the generator can resolve: item labels, item sub-labels, subroutine names, memory-map and index-base names, and external-label names.

Parameters:

data (dict)

Return type:

set[str]

Call graph and basic blocks

Inter-procedural call-graph construction.

Builds a networkx.DiGraph whose nodes are subroutines and whose edges represent JSR (call) and JMP (tail-call) relationships.

The sibling disasm_tools.cfg carried a hardcoded MEMORY_REGIONS constant including the NFS-specific ROM range 0x8000-0x9FFF. The fantasm port reuses fantasm.api.audit.build_memory_regions(), which derives the ROM range from the disassembly’s JSON metadata — so projects with different ROM bank sizes get correct extents without special-casing.

The presentational format_* and top-level cfg() entry from the sibling code are deferred to CLI integration.

class fantasm.api.cfg.BasicBlock(addr, items, entries, exits, commented, total)[source]

Bases: object

One straight-line basic block.

items is the list of code items (from the JSON disassembly) in this block. commented is the number of items with a non-empty comment_inline; total is len(items).

Parameters:
addr: int
items: tuple[dict, ...]
entries: tuple[BasicBlockEntry, ...]
exits: tuple[BasicBlockExit, ...]
commented: int
total: int
class fantasm.api.cfg.BasicBlockEntry(source, kind)[source]

Bases: object

A predecessor of a basic block.

kind mirrors BasicBlockExit.kind.

Parameters:
source: int
kind: str
class fantasm.api.cfg.BasicBlockExit(target, kind)[source]

Bases: object

Where a basic block transfers control next.

kind is one of "branch", "fall", "jump", "return", "call". target is the destination address or None for "return" / dynamic jumps.

Parameters:
  • target (int | None)

  • kind (str)

target: int | None
kind: str
fantasm.api.cfg.build_call_graph(json_filepath, *, memory_regions=None)[source]

Build a call-graph DiGraph from a disassembler JSON output.

Nodes (keyed by "0xNNNN") represent ROM subroutines and external OS entry points. Edges carry call_sites (sorted list of caller addresses) and type ("jsr" or "jmp").

memory_regions constrains where subroutine extents may run. Defaults to the ROM range alone (derived from the JSON’s meta); pass effective regions from the version graph for project-aware behaviour.

Parameters:
Return type:

DiGraph

fantasm.api.cfg.find_basic_blocks(items)[source]

Identify basic blocks within a sequence of code items.

A basic block starts at:

  1. The first item;

  2. The target of any branch (within the items list);

  3. The item after any branch / jump / call;

  4. Any item that carries a label.

Each block ends at a branch / jump / return, or fall-through into the next block. Returns blocks sorted by start address, with entries populated by reverse-walking the exits.

Parameters:

items (Iterable[dict])

Return type:

list[BasicBlock]

fantasm.api.cfg.resolve_sub_node(graph, target)[source]

Resolve a textual target (hex address or name) to a node ID.

Tries hex address first, then exact name match, then unique substring match against node names. Returns None for unknown or ambiguous targets (callers can warn).

Parameters:
Return type:

str | None

Graphviz and GraphML rendering

Graphviz / GraphML rendering for call graphs and basic-block CFGs.

Pure-Python emission, no pydot dependency. The call graph is already a networkx.DiGraph (see fantasm.api.cfg); a basic-block CFG is built on demand from the find_basic_blocks() output. GraphML uses networkx.generate_graphml().

Output is designed for the dot layout engine — left-to-right for the call graph, top-to-bottom for per-routine CFGs. The render_dot() helper shells out to the dot binary when it is on PATH.

Styling vocabulary (cribbed from OWL BASIC’s CFG visualiser and the Solarized palette):

  • Hexagon: subroutine entry / root (call graph) and entry block (CFG).

  • Doubleoctagon: external / OS entry point (call graph only).

  • Rounded box: ordinary internal subroutine / basic block.

  • Double border: blocks ending in rts / rti / brk.

Edge styling:

  • Call graph: solid for jsr; dashed orange for jmp tail-calls.

  • CFG: blue for the taken side of a conditional branch; green for the fall-through; red for unconditional jmp; dashed grey for the rare call exit kind (currently unused by fantasm.api.cfg but reserved).

exception fantasm.api.dot.DotBinaryNotFoundError[source]

Bases: FileNotFoundError

The graphviz dot (or other engine) binary is not on PATH.

fantasm.api.dot.basic_blocks_to_dot(blocks, *, sub_name=None, sub_addr=None)[source]

Render a per-subroutine basic-block CFG as graphviz dot source.

Parameters:
Return type:

str

fantasm.api.dot.basic_blocks_to_graph(blocks, *, sub_name=None, sub_addr=None)[source]

Build a DiGraph from a list of basic blocks.

Nodes are keyed by blk_NNNN (4-digit uppercase hex) and carry addr, label (HTML), is_entry, is_return. Edges carry kind (one of the BasicBlockExit kinds) and target_addr. External targets (branch / jump destinations outside the supplied blocks) are emitted as placeholder nodes with external=True.

Parameters:
Return type:

DiGraph

fantasm.api.dot.basic_blocks_to_graphml(blocks, *, sub_name=None, sub_addr=None)[source]

Render a per-subroutine basic-block CFG as GraphML text.

Parameters:
Return type:

str

fantasm.api.dot.call_graph_to_dot(graph, *, title=None)[source]

Render a call graph (DiGraph) as graphviz dot source.

Parameters:
Return type:

str

fantasm.api.dot.call_graph_to_graphml(graph)[source]

Render a call graph as GraphML text.

Parameters:

graph (DiGraph)

Return type:

str

fantasm.api.dot.filter_call_graph(graph, *, focus=None, up_depth=None, down_depth=None, exclude_external=False, include_pattern=None, exclude_pattern=None, min_degree=0)[source]

Return a filtered copy of a call graph.

The filters compose. When focus is supplied, the result is restricted to a neighbourhood: ancestors up to up_depth hops and descendants up to down_depth hops. Either may be None (unlimited). When focus is omitted, the depth options are ignored and the whole graph is considered.

include_pattern / exclude_pattern are regex strings matched against each node’s name attribute (or its id if no name). min_degree drops low-connectivity nodes; focus nodes are immune.

Parameters:
  • graph (DiGraph)

  • focus (Sequence[str] | None)

  • up_depth (int | None)

  • down_depth (int | None)

  • exclude_external (bool)

  • include_pattern (str | None)

  • exclude_pattern (str | None)

  • min_degree (int)

Return type:

DiGraph

fantasm.api.dot.render_dot(dot_text, output_filepath, *, format='png', engine='dot')[source]

Pipe dot_text into engine and write the rendered output.

Returns the output path. Raises DotBinaryNotFoundError if the engine binary is missing, and propagates subprocess.CalledProcessError from the engine itself.

Parameters:
Return type:

Path

Code context (depth, sub-context, uncommented gaps)

Per-subroutine context computations.

Used by the inline-comment and rename workflows: depth ordering, calling-convention extraction (entry / call sites / exits / post-call context), and uncommented-region analysis.

Sibling disasm_tools.context was the inline-comment workflow’s input generator; ADFS tools/sub_context.py was a sister script focused on calling-convention extraction. The fantasm port lifts the pure-logic surface from both into one module.

class fantasm.api.context.CallSiteContext(addr, mnemonic, in_sub_name, in_sub_addr, asm_lines, call_line_index)[source]

Bases: object

One call site for a target subroutine.

in_sub_name and in_sub_addr identify the calling subroutine (when the caller could be located in the call graph). asm_lines is the assembly window around the call, with the call_line_index (relative to asm_lines) marking the call instruction itself.

Parameters:
  • addr (int)

  • mnemonic (str)

  • in_sub_name (str | None)

  • in_sub_addr (int | None)

  • asm_lines (tuple[str, ...])

  • call_line_index (int)

addr: int
mnemonic: str
in_sub_name: str | None
in_sub_addr: int | None
asm_lines: tuple[str, ...]
call_line_index: int
class fantasm.api.context.CoverageGroup(label, start_addr, end_addr, code_count, commented_count)[source]

Bases: object

One row of a coverage breakdown.

Carries the (commented, total) counts for a contiguous slice of the disassembly — a 0x100-byte ROM page or a single subroutine, according to the breakdown the report was built with.

Parameters:
  • label (str)

  • start_addr (int)

  • end_addr (int)

  • code_count (int)

  • commented_count (int)

label: str
start_addr: int
end_addr: int
code_count: int
commented_count: int
property percentage: float
class fantasm.api.context.CoverageReport(code_count, commented_count, subroutine_count, groups, group_by)[source]

Bases: object

Aggregate inline-comment coverage for a disassembly.

code_count / commented_count cover every code item in the JSON; subroutine_count is the number of declared subroutines. groups is empty for the top-level summary; populated when a group_by was supplied to compute_coverage().

Parameters:
code_count: int
commented_count: int
subroutine_count: int
groups: tuple[CoverageGroup, ...]
group_by: str | None
property percentage: float
class fantasm.api.context.ExitPointContext(addr, mnemonic, asm_lines, exit_line_index)[source]

Bases: object

One terminating instruction within a subroutine.

Parameters:
addr: int
mnemonic: str
asm_lines: tuple[str, ...]
exit_line_index: int
class fantasm.api.context.SubContext(addr, name, title, body_lines, body_start_line, call_sites, exit_points)[source]

Bases: object

Calling-convention context for a single subroutine.

Parameters:
addr: int
name: str
title: str
body_lines: tuple[str, ...]
body_start_line: int
call_sites: tuple[CallSiteContext, ...]
exit_points: tuple[ExitPointContext, ...]
class fantasm.api.context.UncommentedSubReport(addr, name, title, commented, total, density_pct, callees, workspace_refs)[source]

Bases: object

A subroutine that’s a candidate for annotation work.

Surfaces the subroutine’s named callees and any workspace label references found within its extent, so the user can infer the sub’s purpose from its relationships with already-understood code.

Parameters:
addr: int
name: str
title: str
commented: int
total: int
density_pct: float
callees: tuple[str, ...]
workspace_refs: tuple[str, ...]
fantasm.api.context.analyse_uncommented_subs(audit_subs, *, label_to_name=None, workspace_label_patterns=(), density_threshold_pct=30.0, min_items=20)[source]

Find subroutines below a comment-density threshold and report context.

For each subroutine in audit_subs (typically from fantasm.api.audit.load_subroutines()) with at least min_items code items and inline-comment density below density_threshold_pct, returns an UncommentedSubReport carrying:

  • the named callees (JSR / JMP targets that resolve via label_to_name);

  • the workspace label references whose name contains any of the workspace_label_patterns substrings (typically ("wksp_", "fsm_", "zp_", ...) for BBC-style projects).

With no workspace_label_patterns, no label filtering is applied and workspace_refs is empty. label_to_name maps addresses to their canonical names; pass the union of subroutine names and external-label names for best resolution.

Parameters:
Return type:

list[UncommentedSubReport]

fantasm.api.context.compute_call_depths(graph)[source]

Return {node_id -> depth} over the internal subgraph.

Depth 0 is a leaf (no internal callees). Depth N is one more than the maximum depth of its callees. Cycles are collapsed via strongly-connected-component condensation so a cycle’s nodes all share the same depth.

Only internal nodes (external=False) are included.

Parameters:

graph (DiGraph)

Return type:

dict[str, int]

fantasm.api.context.compute_coverage(data, *, audit_subs=None, group_by=None)[source]

Compute aggregate inline-comment coverage for data.

data is a parsed disassembler-emitted JSON (the same shape fantasm.cli_helpers.AnalysisContext.data produces — dasmos and py8dis both emit it). A code item counts as “commented” when its comment_inline field is non-empty; block-level comment_above / comment_below are deliberately ignored — the metric of interest is per-instruction inline density.

group_by is one of:

  • None — only the global counts; groups is empty.

  • "page" — group by 0x100-byte ROM page.

  • "sub" — group by enclosing subroutine. audit_subs is then required (typically passed from fantasm.api.audit.load_subroutines()).

Other values raise ValueError.

Parameters:
Return type:

CoverageReport

fantasm.api.context.extract_sub_context(sub, asm_lines, call_graph, *, body_window=20, caller_context=3, after_context=2, exit_context=3)[source]

Build a SubContext for sub.

sub is a single dict from fantasm.api.audit.load_subroutines(). asm_lines is the full assembly file split with splitlines(keepends=True). call_graph is from fantasm.api.cfg.build_call_graph().

The body window starts at the subroutine’s address and extends up to body_window lines (or to the next subroutine, whichever comes first). Call sites come from the call graph’s predecessors, with caller_context lines before and after_context lines after each. Exit points are every terminating mnemonic within the sub’s extent, with exit_context lines before each.

Parameters:
Return type:

SubContext

Auto-label classification and rename application

Auto-generated-label classification.

Pure-logic helpers for classifying labels emitted by the disassembler (c####, l####, loop_c####, sub_c####) into five categories: subroutine, shared_tail, data, internal_loop, internal_conditional. These categories drive the ordered rename workflow.

Sibling disasm_tools.labels had a single generate_labels() entry that mixed file IO, printing, and template rendering. The fantasm port lifts the pure helpers into a stable surface; the file-writing wrapper will land with the fantasm labels Click sub-command.

Names imported elsewhere in the sibling tree (and now re-exported publicly here):

fantasm.api.labels.DIRECT_KIND = 'direct'

The ReferenceKind value for a plain lda addr / sta addr.

fantasm.api.labels.INDEXED_KIND = 'indexed'

The ReferenceKind value dasmos assigns to an indexing-base access (lda addr,X / sta addr,Y where the byte touched is addr+X and the base byte itself is never read or written). A label whose references are exclusively this kind is a d.index_base() conversion candidate.

fantasm.api.labels.build_target_refs(items)[source]

Return an addr -> referencing items index.

Indexes by each item’s target field (the address of an instruction’s operand target, when the operand is a code/data reference).

Parameters:

items (Iterable[dict])

Return type:

dict[int, list[dict]]

fantasm.api.labels.classify_labels(auto_labels, items, target_refs, audit_subs, memory_regions)[source]

Classify each auto-generated label into a category.

Returns a list of dicts with keys: name, addr, category, parent_sub_name, parent_sub_addr, inbound_refs (each {addr, mnemonic, sub_name, sub_addr, cross_sub}), cross_sub_count.

Parameters:
Return type:

list[dict]

fantasm.api.labels.collect_auto_labels(items)[source]

Return [(label_name, addr), ...] for every auto-generated label.

Walks items’ "labels" and "sub_labels" fields and keeps anything matching AUTO_LABEL_RE.

Parameters:

items (Iterable[dict])

Return type:

list[tuple[str, int]]

fantasm.api.labels.collect_labels(data)[source]

Return every label declared in a disassembly, with source tag.

Walks data["items"]’s "labels" and "sub_labels" fields (tagged source="driver") and data["external_labels"] (tagged source="env"). Each entry is a dict with keys name, addr, source.

Driver-defined labels are emitted in item / declaration order; environment labels follow, in declaration order from the JSON. Duplicates are not collapsed — a label that appears as both an item label and a sub-label will yield two records. Callers that need uniqueness should dedupe on (name, addr).

Parameters:

data (dict)

Return type:

list[dict]

fantasm.api.labels.find_containing_sub_for_addr(addr, sorted_subs, memory_regions)[source]

Find the subroutine in the same memory region whose start ≤ addr.

Unlike fantasm.api.audit.find_containing_sub(), this variant is region-aware: a sub in a different memory region is never treated as containing addr.

Parameters:
Return type:

dict | None

fantasm.api.labels.inbound_refs_to(addr, items_by_addr, target_refs)[source]

Return ref-site records pointing at addr.

Combines two sources:

  • code-flow references — items whose target == addr (taken from target_refs).

  • data references — addresses listed in the references field of the item at addr (table / pointer references the disassembler couldn’t attribute to a specific instruction).

Each record carries {"addr", "mnemonic", "kind"}. Code-flow refs use the referencing item’s mnemonic; data refs use the mnemonic of the item at the referencing address when available, else "data_ref". kind is the dasmos >= 2.0 ReferenceKind (direct / indexed / pointer / …) of the access, joined from the references field of the item at addr by caller address; it is None for a caller the schema-v2 references field does not classify (including any JSON produced by pre-2.0 dasmos, which did not carry kinds). Sorted by addr; duplicates (same source addr) are collapsed, with the code-flow record taking precedence.

Parameters:
Return type:

list[dict]

fantasm.api.labels.label_inventory(data)[source]

Build the full label inventory for one disassembly.

Each entry: {"name", "addr", "source", "length", "ref_count", "refs", "direct_count", "indexed_count", "other_count", "is_code", "index_base_only"}. length is len(name). refs is the list returned by inbound_refs_to() for the label’s address; ref_count is its length, broken out so sorts / filters don’t pay the indexing cost.

The three per-kind counts split refs by the dasmos >= 2.0 ReferenceKind of each site: direct_count (plain lda addr), indexed_count (indexing-base lda addr,X), and other_count (every other or unclassified site — pointer references, and any site left kind == None by pre-2.0 JSON).

is_code is true when the item at the label’s address is a type == "code" item — the caveat flag for the d.index_base() workflow, where a genuine code entry point a loop happens to read as lda entry,X must not be moved off the memory map. index_base_only is true when the label has at least one reference and every one is indexed — the exact d.index_base() conversion candidate.

Driver labels are deduped on (name, addr) (a label can show up as both an item label and a sub-label of the parent item); env labels are kept as-is.

Parameters:

data (dict)

Return type:

list[dict]

fantasm.api.labels.sort_labels(classified)[source]

Sort by category order, then parent-sub address, then label address.

Parameters:

classified (Sequence[dict])

Return type:

list[dict]

Apply label-rename edits to a disassembly driver script.

Two rewrite strategies live here:

  • Inline (apply_renames_inline()) — find every existing label(0xXXXX, …) / d.label(0xXXXX, …) declaration and rewrite its name at that line, wherever it sits in the driver. This matches the natural shape of hand-written drivers where label calls are interleaved with the code they annotate.

  • Section (apply_renames_to_lines()) — find a dedicated # Code label renames cluster, then either update an existing entry inside it or insert a new one in address-sorted order. Useful for auto-generated drivers that group reversible renames in one override section.

Sibling disasm_tools.rename_labels mixed parsing with file IO; the fantasm port lifts the parsers and the text transformers and leaves the IO to the fantasm labels apply CLI command.

fantasm.api.rename_labels.apply_renames_inline(lines, renames)[source]

Rewrite label(…) / d.label(…) declarations in place.

For each addr -> new_name in renames: locate the matching declaration line and rewrite its name. Indentation and the label vs d.label callable form are preserved.

Returns (new_lines, name_map) where name_map is the addr -> (old_name, new_name) mapping actually applied — callers use this to drive optional textual reference rewrites on top of the declaration edits.

Raises LookupError (with every missing address listed) if any rename has no matching declaration. The driver is not partially rewritten — either every requested rename has a home or the function refuses to act.

Parameters:
Return type:

tuple[list[str], dict[int, tuple[str, str]]]

fantasm.api.rename_labels.apply_renames_to_lines(lines, renames)[source]

Return a new list of lines with the requested renames applied.

For each addr -> new_name in renames: if the address already has a label declaration in the rename section, its name is updated in place; otherwise a new label(0x{addr:04X}, "name") line is inserted in address-sorted order.

The input lines sequence is not mutated. Returns a list of lines suitable for writing back to the driver script.

Parameters:
Return type:

list[str]

fantasm.api.rename_labels.find_insert_position(lines, section_start, section_end, target_addr)[source]

Find the line at which to insert a new label() for target_addr.

Maintains address-sorted order within the rename section; falls back to section_end + 1 if the section is empty or has no label declarations within bounds.

Parameters:
Return type:

int

fantasm.api.rename_labels.find_rename_section(lines)[source]

Find the # Code label renames section’s body bounds.

Returns (section_start, section_end) 0-indexed. section_start is the line index of the header itself; section_end is the index of the line BEFORE the next # =+ separator (or the last line of the file). Both are None if the section is absent.

Parameters:

lines (Sequence[str])

Return type:

tuple[int | None, int | None]

fantasm.api.rename_labels.parse_label_decl_lines(lines)[source]

Parse every label(…) / d.label(…) declaration in lines.

Like parse_label_declarations() but recognises the d.label form (dasmos’s actual call style) as well as the bare label form. Each record carries the source line index, the matched addr and name, the prefix (the indentation captured at the start of the line), and the callable text (label or d.label) so callers can preserve it when rewriting.

Parameters:

lines (Sequence[str])

Return type:

list[dict]

fantasm.api.rename_labels.parse_label_declarations(lines)[source]

Parse all label() declarations from driver-script lines.

Returns dicts with addr, name, line (0-indexed).

Parameters:

lines (Sequence[str])

Return type:

list[dict]

fantasm.api.rename_labels.update_ref_strings(lines, name_map)[source]

Rewrite textual references to renamed labels.

Walks each line and replaces occurrences of every old_name with the corresponding new_name in three contexts: d.comment(...) string args, triple-quoted description= blocks, and Markdown [`name`](address:...) anchors. Other occurrences (raw identifiers, unrelated string text) are left alone.

Returns (new_lines, replacement_count). The count totals individual textual replacements, not lines touched.

Parameters:
Return type:

tuple[list[str], int]

Cross-reference field accessors

Accessors for the references field of a dasmos JSON item.

dasmos 2.0 (meta.schema_version 2) changed each incoming cross-reference from a bare int caller address to a structured object:

{"addr": int, "kind": str, "move_id": int?}

where kind is the ReferenceKind value (direct / pointer / indexed / indexed_pointer) and move_id is present only when non-zero. The pre-2.0 schema (through dasmos 1.14.0) emitted the bare address instead.

These accessors read either shape so fantasm can consume JSON produced by an old or new disassembler run without the caller having to branch on meta.schema_version.

fantasm.api.references.reference_addr(ref)[source]

Return the caller runtime address of a single references entry.

Accepts the dasmos >= 2.0 {"addr", "kind", "move_id"?} object or the pre-2.0 bare-int address.

Parameters:

ref (int | dict)

Return type:

int

fantasm.api.references.reference_addrs(refs)[source]

Return the caller addresses of a whole references list.

None (the field absent) yields an empty list.

Parameters:

refs (Iterable[int | dict] | None)

Return type:

list[int]

fantasm.api.references.reference_kind(ref)[source]

Return the ReferenceKind of a references entry, if known.

dasmos >= 2.0 objects carry a kind; the pre-2.0 bare-int schema did not classify references, so this is None for those.

Parameters:

ref (int | dict)

Return type:

str | None

Subroutine-declaration insertion

Compute insertion points for new subroutine() declarations.

Parses a disassembly driver script (whether built on dasmos or py8dis — both expose a subroutine() API with the same call shape) to locate existing subroutine() declarations, identify the main address-ordered block, and compute where a new declaration for a given target address should be inserted.

Sibling disasm_tools.insert_point was byte-identical across all four forks. The fantasm port lifts the pure-logic helpers (parse_subroutine_declarations(), find_main_block(), compute_insert_point()); the file-IO + printing wrapper that formed the sibling find_insert_point is deferred to CLI integration.

exception fantasm.api.insert_point.AlreadyDeclared(declaration)[source]

Bases: LookupError

Raised when the target address already has a declaration.

Parameters:

declaration (dict)

Return type:

None

class fantasm.api.insert_point.InsertPoint(insert_line, predecessor, successor, block_start_line, block_end_line)[source]

Bases: object

Resolved insertion point for a new subroutine() declaration.

insert_line is 0-based. predecessor and successor are the surrounding declaration dicts (or None).

Parameters:
  • insert_line (int)

  • predecessor (dict | None)

  • successor (dict | None)

  • block_start_line (int)

  • block_end_line (int)

insert_line: int
predecessor: dict | None
successor: dict | None
block_start_line: int
block_end_line: int
fantasm.api.insert_point.compute_insert_point(lines, target_addr)[source]

Compute where in lines a new subroutine() for target_addr belongs.

Raises AlreadyDeclared if the address already has a declaration. Raises LookupError if no declarations are present at all (so there’s no main block to anchor to).

Parameters:
Return type:

InsertPoint

fantasm.api.insert_point.find_main_block(lines, declarations)[source]

Identify the main address-ordered subroutine block.

Returns (block_start_line, block_end_line) (0-indexed). The block is identified by a # Subroutines section header or, as a fallback, by the position of the first declaration.

Parameters:
Return type:

tuple[int, int]

fantasm.api.insert_point.parse_subroutine_declarations(lines)[source]

Parse all subroutine() declarations from driver-script lines.

Returns a list of dicts with addr, name, start_line, end_line (all 0-indexed line numbers).

Parameters:

lines (Sequence[str])

Return type:

list[dict]

Promotion candidates

Find labels worth promoting to entry points or subroutines.

Scores every labelled code item by combining four signals:

  • whether the predecessor item is a terminating mnemonic or data-separated (no fall-through path);

  • in-degree (how many JSR / branch references reach the label);

  • whether those references are calls (JSR/JMP) or branches;

  • come-from distance (how far the call sites are).

Higher scores are stronger candidates for promoting to entry() or subroutine() declarations.

ADFS and EBR shipped this module identically; NFS and TUBE didn’t have it. The fantasm port lifts the pure analysis function out of the file-IO + print wrapper so it’s testable directly on a JSON data dict.

fantasm.api.promote.analyze_labels(data)[source]

Score every labelled code item in data for promotion.

Returns a list of dicts (sorted by descending score, then by address) with keys: addr, name, score, total_refs, jsr_refs, branch_refs, max_distance, mean_distance, after_terminal, is_entry, is_subroutine.

Parameters:

data (dict)

Return type:

list[dict]

fantasm.api.promote.load_and_analyze_labels(json_filepath)[source]

File-IO wrapper around analyze_labels().

Parameters:

json_filepath (str | Path)

Return type:

list[dict]

ROM comparison

ROM binary comparison via difflib.SequenceMatcher.

Compares two ROM images at three granularities — byte-level, opcode-only (structural), and full instruction (opcode + operands) — and produces a human-readable report.

Sibling disasm_tools.compare was the most diverged module (NFS at 470 lines vs. 319 in the others). NFS is taken as the base.

Refactors relative to the sibling:

  • ROM_BASE is no longer imported from mos6502 — it’s now a rom_base parameter on disassemble_linear(), format_address(), format_instruction(), and compare_roms(). The default of 0x8000 matches the BBC sideways-ROM convention.

  • The hardcoded "NFS" literal in the report header is gone — the caller provides labels that already identify the project.

  • Instruction carries rom_base so rom_address works without external plumbing.

  • File-reading + printing wrapper deferred to fantasm compare CLI sub-command.

class fantasm.api.compare.Instruction(offset, opcode, operand_bytes, length, is_valid, rom_base=32768, mnemonic_str='???')[source]

Bases: object

One instruction from a linear sweep.

Parameters:
offset: int
opcode: int
operand_bytes: bytes
length: int
is_valid: bool
rom_base: int = 32768
mnemonic_str: str = '???'
property rom_address: int
property all_bytes: bytes
property mnemonic: str
class fantasm.api.compare.RomHeader(language_entry, service_entry, rom_type, copyright_offset, binary_version, title, version_string, copyright)[source]

Bases: object

Parsed BBC Micro sideways-ROM header.

Parameters:
  • language_entry (int)

  • service_entry (int)

  • rom_type (int)

  • copyright_offset (int)

  • binary_version (int)

  • title (str)

  • version_string (str)

  • copyright (str)

language_entry: int
service_entry: int
rom_type: int
copyright_offset: int
binary_version: int
title: str
version_string: str
copyright: str
fantasm.api.compare.compare_headers(header_a, header_b, label_a, label_b)[source]

Return report lines comparing two ROM headers.

Parameters:
Return type:

list[str]

fantasm.api.compare.compare_roms(data_a, data_b, label_a, label_b, cpu_a='6502', cpu_b='6502', rom_base=32768)[source]

Generate the full comparison report as a single string.

cpu_a / cpu_b select the CPU variant for each ROM (the two ROMs may run different CPUs, e.g. NMOS vs. CMOS). Both ROMs share the same rom_base for address formatting.

Parameters:
Return type:

str

fantasm.api.compare.disassemble_linear(data, cpu='6502', rom_base=32768)[source]

Decompose data into instructions via linear sweep.

Invalid opcodes (length 0 in the table) are emitted as single one-byte ??? records. This is intentional: both ROMs in a comparison are swept identically, so misinterpreted data tables still align in the SequenceMatcher.

Parameters:
Return type:

list[Instruction]

fantasm.api.compare.format_address(offset, rom_base=32768)[source]

Format a ROM offset as $NNNN at rom_base + offset.

Parameters:
Return type:

str

fantasm.api.compare.format_instruction(inst)[source]

Format a single instruction for display.

Examples: "LDA #$42 (A9 42)", ".byte $FF (FF)".

Parameters:

inst (Instruction)

Return type:

str

fantasm.api.compare.parse_rom_header(data)[source]

Parse the BBC Micro sideways-ROM header out of data.

Header layout (offsets from start of ROM):

  • +0: JMP language_entry (3 bytes: $4C lo hi)

  • +3: JMP service_entry (3 bytes: $4C lo hi)

  • +6: ROM type byte

  • +7: copyright offset (NUL preceding "(C)")

  • +8: binary version

  • +9: title (NUL-terminated), then version string up to the copyright offset, then "(C)..." NUL-terminated

Parameters:

data (bytes)

Return type:

RomHeader

Cross-version address mapping

Opcode-level address mapping between two ROM versions.

Provides a primary LCS-based mapping (difflib.SequenceMatcher on opcode sequences — order-preserving, so it cannot follow code that has been reordered or relocated between versions) and a supplementary seed-and-extend pass (k-gram seeds + alignment, borrowed from bioinformatics) that catches moved blocks.

Sibling disasm_tools.blockmatch was NFS-only (the cross-version NFS work needed it). The fantasm port is essentially verbatim except that ROM_BASE was lifted out of mos6502 — the helpers now take rom_base as a parameter (default 0x8000).

Returns address mappings as dict[int, int] keyed by ROM address (e.g. 0xA000 -> 0xA1B4).

Anchoring contract. Every emitted mapping is anchored in a contiguous run of ≥ min_block_length matching opcodes (the LCS “equal” run for the primary map; a single get_matching_blocks() sub-block for the supplementary map). Source addresses that don’t lie inside such a run are absent from the dict, so consumers must explicitly handle “no mapping available” (KeyError / dict.get(...) is None). The default threshold is 5 — short runs are coincidental opcode coincidences that misleadingly suggest a per-instruction correspondence where the surrounding code has actually diverged. Lower the threshold deliberately when working on tiny test fixtures.

class fantasm.api.blockmatch.RelocatedBlock(a_start_addr, a_end_addr, b_start_addr, b_end_addr, ratio, matched_pairs, a_opcodes, b_opcodes)[source]

Bases: object

A region of ROM A matched to a moved region in ROM B.

Parameters:
  • a_start_addr (int)

  • a_end_addr (int)

  • b_start_addr (int)

  • b_end_addr (int)

  • ratio (float)

  • matched_pairs (int)

  • a_opcodes (int)

  • b_opcodes (int)

a_start_addr: int
a_end_addr: int
b_start_addr: int
b_end_addr: int
ratio: float
matched_pairs: int
a_opcodes: int
b_opcodes: int
fantasm.api.blockmatch.build_full_address_map(data_a, data_b, cpu_a='6502', cpu_b='6502', *, rom_base=32768, min_block_length=5, **kwargs)[source]

Primary + supplementary opcode-level address map in one call.

Every emitted mapping is anchored in a contiguous run of ≥ min_block_length matching opcodes (LCS run for primary, matching sub-block for supplementary). Source addresses outside such a run are absent from the dict — consumers must explicitly handle “no mapping available”. This is the contract that fantasm shared and fantasm backfill already follow; see issue #10 for the incident that motivated the threshold.

Returns (full_map, primary_map, supplementary_map, blocks). Primary mappings take precedence on conflict.

Tiny test fixtures (a handful of instructions) need min_block_length=1 to produce any mappings — the default of 5 is calibrated for real-ROM-sized inputs.

Parameters:
Return type:

tuple[dict[int, int], dict[int, int], dict[int, int], list[RelocatedBlock]]

fantasm.api.blockmatch.build_primary_map(insts_a, insts_b, rom_base=32768, *, min_block_length=5)[source]

LCS-based opcode mapping. Order-preserving; misses reorders.

Only equal runs of length ≥ min_block_length contribute mappings. Shorter runs are dropped because they tend to be coincidental opcode-value matches that produce misleading identity-like mappings in regions where the surrounding code has actually diverged (see issue #10). Pass min_block_length=1 deliberately when working on tiny test fixtures.

Parameters:
Return type:

dict[int, int]

fantasm.api.blockmatch.disassemble_to_opcodes(data, cpu='6502')[source]

Linear sweep returning (offset, opcode, length) tuples.

Invalid opcodes (length 0 in the table) advance one byte. cpu selects between "6502" and "65c02".

Parameters:
Return type:

list[tuple[int, int, int]]

fantasm.api.blockmatch.find_relocated_blocks(insts_a, insts_b, primary_addr_map, *, rom_base=32768, k=6, min_seeds=2, max_seed_gap=8, min_block_opcodes=8, min_match_length=5, min_ratio=0.85)[source]

Find supplementary opcode-level mappings via k-mer seed-and-extend.

Conflicts inside the supplementary pass (one A or B instruction claimed by two clusters) are resolved greedily by ratio. Conflicts with the primary map are not allowed: the primary map is authoritative.

min_block_opcodes filters out candidate windows shorter than that. min_match_length then filters individual matching sub-blocks within an aligned window — so even when a long candidate window is accepted, mappings only come from contiguous runs of ≥ min_match_length matching opcodes inside it (mirroring the LCS-run gate the primary map applies). This prevents stray single-opcode coincidences inside an aligned window from generating per-instruction mappings (see issue #10).

Returns (supplementary_map, blocks). supplementary_map has no overlap with primary_addr_map.

Parameters:
Return type:

tuple[dict[int, int], list[RelocatedBlock]]

Fingerprinting (block-level deduplication)

Build short structural “fingerprints” of ROM regions.

Used to spot near-duplicate code blocks across ROM versions, often as a cross-check for the fantasm.api.blockmatch results.

NFS-only sibling module. Ported with the same ROM_BASE lift-out as the rest of the suite (rom_base is now a parameter; default 0x8000).

fantasm.api.fingerprint.find_duplicate_blocks(fingerprints)[source]

Group addresses by fingerprint; keep groups with 2+ addresses.

Parameters:

fingerprints (dict[int, str])

Return type:

dict[str, list[int]]

fantasm.api.fingerprint.fingerprint_blocks(data, block_size=64, cpu='6502', rom_base=32768)[source]

Fingerprint each block_size-byte block of data.

Returns {rom_address -> fingerprint} keyed by the block’s starting ROM address.

Parameters:
Return type:

dict[int, str]

fantasm.api.fingerprint.fingerprint_rom_file(rom_filepath, block_size=64, cpu='6502', rom_base=32768)[source]

Convenience: load a ROM and fingerprint its blocks.

Parameters:
Return type:

dict[int, str]

fantasm.api.fingerprint.fingerprint_window(data, offset, length, cpu='6502')[source]

Fingerprint the opcodes covering data[offset:offset+length].

The window is sliced at the byte level; instructions whose first byte is outside the window are ignored.

Parameters:
Return type:

str

fantasm.api.fingerprint.opcode_fingerprint(opcodes)[source]

Stable short hash of a sequence of opcodes.

Parameters:

opcodes (bytes)

Return type:

str

Data-declaration review and heuristic classification

Review and reclassification of data declarations.

Two related capabilities, both serving the “Phase D” data-review workflow on annotated 6502 disassemblies:

  • find_data_runs() ranks contiguous runs of same-type data items (byte / word / string) so the longest ones — typical candidates for a closer look — surface first.

  • find_classification_candidates() applies three heuristic classifiers (padding, string, code) to runs of bytes the disassembler flagged as raw byte data, surfacing spans that might actually be strings, valid code, or ROM padding — candidates for reclassification with stricter declarations in the driver.

Both functions read the disassembler JSON items directly (dasmos and py8dis emit the same shape). The classifiers are designed to be used both as a suite (via the orchestrator) and individually (the looks_like_* functions accept a raw bytes span).

The code-likelihood classifier walks every starting alignment with the per-CPU fantasm.api.mos6502.OPCODE_LENGTHS / fantasm.api.mos6502.OPCODE_LENGTHS_65C02 table; conceptually this is equivalent to a regex of the form (L1|L2.|L3..)+ over the byte stream, but the explicit loop is faster, supports both CPU families cleanly via the existing tables, and produces per-alignment runs (the longest from any starting alignment within a span) rather than the regex’s left-to-right alternation, which biases toward shorter matches at each position.

class fantasm.api.data_review.Classification(addr, length, kind, confidence, preview)[source]

Bases: object

One reclassification candidate produced by the orchestrator.

Parameters:
addr: int
length: int
kind: str
confidence: float
preview: str
class fantasm.api.data_review.CodeClassification(start_offset, length, instruction_count, first_mnemonic, confidence)[source]

Bases: object

Longest valid 6502 instruction sweep found within a byte span.

Parameters:
  • start_offset (int)

  • length (int)

  • instruction_count (int)

  • first_mnemonic (str)

  • confidence (float)

start_offset: int
length: int
instruction_count: int
first_mnemonic: str
confidence: float
class fantasm.api.data_review.DataRun(start_addr, end_addr, item_type, item_count, byte_length, label, commented_count)[source]

Bases: object

One contiguous run of same-type data items.

end_addr is inclusive; item_count is the number of JSON items in the run, byte_length the total bytes spanned (often larger than item_count because a single byte / word item can carry multiple bytes).

Parameters:
  • start_addr (int)

  • end_addr (int)

  • item_type (str)

  • item_count (int)

  • byte_length (int)

  • label (str | None)

  • commented_count (int)

start_addr: int
end_addr: int
item_type: str
item_count: int
byte_length: int
label: str | None
commented_count: int
property is_annotated: bool

True if the run head has a label or any item is commented.

class fantasm.api.data_review.HiBytesTableClassification(start_offset, length, rom_page_range, confidence)[source]

Bases: object

A run of bytes all in the project’s ROM-page range.

The high-byte halves of PHA/PHA/RTS-style dispatch tables look like this: every entry’s high byte is a ROM page number, so the contiguous run sits entirely inside the band rom_base >> 8 .. (rom_end - 1) >> 8. Such runs happen to decode as long sequences of “valid” 6502 instructions (because most opcodes in the 0x80-0xBF range exist), which the code classifier would otherwise falsely flag as code.

Parameters:
start_offset: int
length: int
rom_page_range: tuple[int, int]
confidence: float
class fantasm.api.data_review.PaddingClassification(start_offset, length, fill_byte, pattern_length, confidence)[source]

Bases: object

Repeating-pattern run starting at offset 0 of a span.

fill_byte is the leading byte of the pattern; pattern_length is 1 for FF FF FF / 2 for AB CD AB CD / etc.

Parameters:
  • start_offset (int)

  • length (int)

  • fill_byte (int)

  • pattern_length (int)

  • confidence (float)

start_offset: int
length: int
fill_byte: int
pattern_length: int
confidence: float
class fantasm.api.data_review.StringClassification(start_offset, length, text, confidence)[source]

Bases: object

Best printable-ASCII run found within a byte span.

Parameters:
start_offset: int
length: int
text: str
confidence: float
fantasm.api.data_review.classify_run_bytes(span, *, run_addr=0, cpu='6502', rom_page_range=None, min_string=4, min_code=8, min_padding=4, min_hi_bytes=8)[source]

Apply padding → string → hi_bytes_table → code classifiers across span.

Walks span left-to-right, calling each classifier in priority order at the current cursor position. The first classifier that returns a match consumes those bytes; the cursor advances past the match before the next round. The hi_bytes_table classifier is skipped when rom_page_range is None.

Returned Classification addr values are run_addr + match_offset so callers can plot the findings back onto the ROM directly.

Parameters:
Return type:

list[Classification]

fantasm.api.data_review.find_classification_candidates(items, *, cpu='6502', target_types=('byte',), rom_page_range=None, min_string=4, min_code=8, min_padding=4, min_hi_bytes=8)[source]

Walk runs of target-type items and surface reclassification hints.

For each contiguous run of items whose type is in target_types (default ("byte",) — the catch-all bucket the disassembler emits for unclassified data), concatenate the run’s bytes and apply the classifiers in priority order: padding → string → hi_bytes_table → code. The first classifier to claim bytes at a given offset wins; the cursor advances past the match before the next classifier runs.

The hi_bytes_table step runs only when rom_page_range is provided (typically derived by the caller from the JSON’s meta.load_addr / meta.end_addr). It catches runs of bytes all in the project’s ROM-page band — the high-byte halves of dispatch tables, which would otherwise be misread as long valid-code sweeps by the code classifier.

Returns one Classification per claimed span, sorted by length descending so the most-interesting candidates surface first. Spans that no classifier claims do not appear in the output (the user is interested in candidates for reclassification, not in noting that the disassembler already got the rest right).

Parameters:
Return type:

list[Classification]

fantasm.api.data_review.find_data_runs(items, *, min_bytes=8, item_types=('byte', 'word', 'string'))[source]

Return contiguous runs of same-type items, longest first.

A run is a maximal stretch of consecutive items whose type matches one in item_types. Items whose type is not in item_types (most importantly code) break runs. Within a run, items keep their individual labels and comments; only the leading label is exposed on the returned DataRun (the “what is this region?” hint).

Runs whose total byte length is below min_bytes are dropped — by default 8 bytes, which lets a four-entry vector table (4 × 2 = 8) through while filtering out noise from the many short EQUB-pair runs in any real ROM.

Returns runs sorted by byte_length descending so the most-interesting candidates surface first.

Parameters:
Return type:

list[DataRun]

fantasm.api.data_review.looks_like_code(span, *, cpu='6502', min_length=8)[source]

Find the longest valid-opcode sweep within span.

Walks every starting alignment in span and consumes instructions according to the per-CPU opcode-length table. A sweep ends at the first byte that doesn’t decode to a declared opcode (length 0) or that would extend past the end of span. The longest sweep across all alignments wins.

cpu selects the opcode-length table — "6502" (NMOS, default) or "65c02" (CMOS).

Confidence is currently a coarse length heuristic (min(length / 32, 1.0)); fields are reserved for future operand-sanity scoring.

Parameters:
Return type:

CodeClassification | None

fantasm.api.data_review.looks_like_hi_bytes_table(span, *, rom_page_range, min_length=8)[source]

Find the longest run of bytes all in rom_page_range.

rom_page_range is an inclusive (low, high) tuple of byte values — typically derived from the project’s ROM extents (e.g. (0x80, 0xBF) for a 16 KB BBC sideways ROM at &8000, (0xE0, 0xFF) for the Acorn Econet Bridge at &E000). A contiguous run of bytes all within this band is a strong signal of a high-byte address table — the “lo bytes / hi bytes” half of a PHA/PHA/RTS dispatch.

Such runs happen to decode as long stretches of “valid” 6502 instructions (most opcodes in 0x80-0xBF exist), which is what makes looks_like_code() mis-fire on them. Running this classifier first in the priority chain claims the bytes before the code classifier sees them.

Confidence is 1.0 for an exact in-band run.

Parameters:
Return type:

HiBytesTableClassification | None

fantasm.api.data_review.looks_like_padding(span, *, min_length=4, max_pattern_length=4)[source]

Detect a repeating-byte-pattern run starting at offset 0.

Tries every pattern length from 1 up to max_pattern_length (default 4 — ROM fill is overwhelmingly 1-byte fills like FF FF FF… / 00 00 00… / EA EA EA…, sometimes 2-byte alternating fills like AB CD AB CD, very rarely longer). The match requires at least two full repetitions of the pattern, which avoids classifying coincidentally-repeated short instruction sequences as padding.

Confidence is 1.0 for an exact pattern match.

Parameters:
  • span (bytes)

  • min_length (int)

  • max_pattern_length (int)

Return type:

PaddingClassification | None

fantasm.api.data_review.looks_like_string(span, *, min_length=4)[source]

Find the longest printable-ASCII run within span.

A character is “printable” if it is in 0x20-0x7E or is one of the common whitespace controls (\t, \n, \r). A trailing \0 is allowed as a terminator — it ends the run and is not included in the returned text — but the run must still meet min_length.

Confidence is the printable density (printables / total run bytes) over the matched range; for a clean string this is 1.0 (or ~0.95 with one terminator).

Parameters:
Return type:

StringClassification | None

Print-inline hook discovery

Detect missing print-inline hook_subroutine() declarations.

A common cause of long unannotated byte runs in Acorn-style 6502 ROMs is a missing hook_subroutine() for a print-inline helper (stringz / stringcr / stringhi style). The hook_subroutine API and the bundled hook names are shared between the supported disassembler libraries (dasmos and py8dis), so the same detection logic applies regardless of which one the driver targets. The shape of the missing hook in driver JSON output is:

JSR <target>          ← code item, mnemonic="jsr"
EQUS "..."            ← string item with the inline text.
                        For stringz / stringcr the terminator
                        (0x00 / 0x0D) is the last byte of the
                        string item; for stringhi it is *not* —
                        see `_classify_terminator`.
EQUB ...              ← resume code, mis-emitted as raw byte
                        items because the hook wasn't wired up

Once hook_subroutine(<target>, name, <kind>_hook) is added to the driver, the disassembler treats the post-JSR bytes correctly and emits the resume code as code items. Until then, every call site contributes a mysterious unannotated byte run downstream.

This module surfaces the missing hook by looking at the pattern at every call site, not at the byte runs in isolation. A JSR target whose call sites consistently match the “string-then-byte-run-decoding-as-code” signature is almost certainly a print-inline helper that needs hooking.

class fantasm.api.hooks.HookCandidate(target, target_label, matching_call_sites, total_call_sites, hook_kind, sample_strings)[source]

Bases: object

A JSR target that looks like an unhooked print-inline helper.

matching_call_sites lists JSR addresses whose post-call pattern matches the missing-hook signature; total_call_sites is every JSR (and JMP, treated as tail-call) that targets target. confidence is the ratio — when 1.0, every call site matches the pattern, which is the strongest signal a hook is missing.

Parameters:
  • target (int)

  • target_label (str | None)

  • matching_call_sites (tuple[int, ...])

  • total_call_sites (int)

  • hook_kind (str)

  • sample_strings (tuple[str, ...])

target: int
target_label: str | None
matching_call_sites: tuple[int, ...]
total_call_sites: int
hook_kind: str
sample_strings: tuple[str, ...]
property confidence: float
property hook_function: str | None

Hook function name (e.g. stringz_hook), or None for unknown kind. The same names are used by dasmos and py8dis.

fantasm.api.hooks.find_hook_candidates(items, *, cpu='6502', min_call_sites=2, min_resume_code_bytes=4, min_confidence=0.5)[source]

Find JSR targets with the missing-print-inline-hook signature.

Walks items linearly. For each code item with mnemonic == "jsr" (or jmp, which the disassembler treats similarly when used as a tail-call), the next two items are inspected:

  • The first must be a string item.

  • The second must be a byte item whose bytes decode as valid 6502 instructions starting at offset 0 for at least min_resume_code_bytes bytes.

Matching call sites are grouped by target. A target is reported as a candidate when:

  • it has at least min_call_sites matching call sites;

  • the matching ratio (matching / total) is at least min_confidence.

The returned list is sorted by confidence descending then matching call-site count descending.

Parameters:
Return type:

list[HookCandidate]

fantasm.api.hooks.render_hook_subroutine_line(candidate, *, placeholder_name='FILL_ME')[source]

Render a paste-ready hook_subroutine(addr, name, hook) line.

Falls back to placeholder_name when the candidate has no target label, and to a comment marker when the kind is unknown (no hook-function name to emit).

Parameters:
Return type:

str

Cross-ROM shared-code matching

Find shared 6502 code fragments between ROM binaries.

Compares opcode sequences between a “primary” ROM and one or more “reference” ROMs via difflib.SequenceMatcher. Operand bytes are ignored, so instructions with different operands but the same opcode (e.g. LDA &1234 vs. LDA &5678) still register as structurally identical.

Originally an EBR-only sibling module. The fantasm port keeps the pure-logic surface (sweep, span finding, byte counting, spec parsing) and defers the print-and-argparse top-level entry to CLI integration.

class fantasm.api.find_shared.Instruction(offset: 'int', opcode: 'int', length: 'int')[source]

Bases: object

Parameters:
offset: int
opcode: int
length: int
class fantasm.api.find_shared.RomData(label, load_addr, data, instructions, opcodes)[source]

Bases: object

An opcode sweep of a ROM, paired with a load address.

Parameters:
label: str
load_addr: int
data: bytes
instructions: list[Instruction]
opcodes: list[int]
runtime_addr(instruction_index)[source]
Parameters:

instruction_index (int)

Return type:

int

fantasm.api.find_shared.find_matching_spans(primary, reference, min_len, *, reject_trivial=True)[source]

Return (primary_idx, reference_idx, length) for every matching block.

Only blocks of at least min_len instructions are returned. Trivial spans (per is_trivial_span()) are rejected unless reject_trivial=False.

Parameters:
Return type:

list[tuple[int, int, int]]

fantasm.api.find_shared.is_trivial_span(opcodes, start, length, min_distinct=3)[source]

Span is trivial if it has fewer than min_distinct distinct opcodes.

Filters out spurious matches in ROM padding (runs of &FF or &00) and degenerate patterns like a single RTS surrounded by padding.

Parameters:
Return type:

bool

fantasm.api.find_shared.load_rom(label, filepath, load_addr)[source]

Load a ROM and prepare it for comparison.

Parameters:
Return type:

RomData

fantasm.api.find_shared.matching_byte_count(primary, matches)[source]

Sum of instruction byte-lengths across matching spans in primary.

Parameters:
Return type:

int

fantasm.api.find_shared.parse_rom_spec(spec)[source]

Parse a ROM spec [label=]path@load-addr into (label, path, addr).

Address accepts hex (&E000, 0xE000, $E000) or decimal. If label= is omitted, the file stem is used. Raises ValueError for malformed specs and FileNotFoundError when the path doesn’t exist.

Parameters:

spec (str)

Return type:

tuple[str, Path, int]

fantasm.api.find_shared.sweep_opcodes(data)[source]

Naive linear sweep: every byte is treated as an instruction start.

Robust across dispatch tables and relocated blocks. Matching spans in the output are suggestive, not authoritative.

Parameters:

data (bytes)

Return type:

tuple[list[Instruction], list[int]]

Annotation backfill across versions

Back-propagate annotations between ROM versions.

Builds an opcode-level confidence map between two ROM images and parses disassembly driver scripts to extract their annotations (comment/label/subroutine calls — the same call shape in dasmos and py8dis). Together these let annotations from a richly-annotated version flow back to less annotated ones via the high-confidence portion of the address map.

Sibling disasm_tools.backfill (621 lines, byte-identical across forks) carried two heavy NFS-specific pieces:

  • VERSION_CHAIN — adjacent-version pairs and their relocation blocks, hardcoded for NFS.

  • build_chained_map and the top-level backfill() — file-IO, printing, and version-chain walking that depends on the above.

The fantasm port lifts the pure helpers out: build_confidence_map(), build_confidence_map_for_block(), group_logical_statements(), parse_annotations(), translate_address_in_text(), translate_subroutine().

Project-specific concerns are parameters now: rom_base (was a module-level constant) and workspace_ranges (was hardcoded as range(0x0000, 0x0100) plus NFS’s range(0x0D00, 0x1000)). A future fantasm.toml schema will let projects declare their own version chains; build_chained_map will land then alongside the fantasm backfill Click sub-command.

class fantasm.api.backfill.AnnotationDiff(source_addr, target_addr, confidence, kind, source_value, target_value, status)[source]

Bases: object

A single annotation difference between two driver scripts.

status is one of:

  • "differs" — both source and target carry an annotation of this kind at the mapped addresses, but the values differ (different comment text, different label name, different sub title);

  • "missing_in_target" — source has an annotation; target’s mapped address has no annotation of this kind;

  • "no_mapping" — source’s address has no entry in the confidence map (or below threshold), so the annotation can’t be located in the target at all.

For "no_mapping", target_addr is None and confidence is 0.

Parameters:
  • source_addr (int)

  • target_addr (int | None)

  • confidence (int)

  • kind (str)

  • source_value (str)

  • target_value (str | None)

  • status (str)

source_addr: int
target_addr: int | None
confidence: int
kind: str
source_value: str
target_value: str | None
status: str
class fantasm.api.backfill.PropagationCandidate(source_addr, target_addr, confidence, kind, name, text)[source]

Bases: object

One annotation that backfill proposes to copy from source to target.

confidence is the composed block-length from the chain map. text carries the comment string (for kind == "comment") or the full subroutine() source text (for kind == "subroutine"); for labels it’s the label name.

Parameters:
  • source_addr (int)

  • target_addr (int)

  • confidence (int)

  • kind (str)

  • name (str | None)

  • text (str)

source_addr: int
target_addr: int
confidence: int
kind: str
name: str | None
text: str
class fantasm.api.backfill.PropagationReport(candidates, skipped_no_mapping, skipped_below_threshold, skipped_target_has_annotation, skipped_label_name_conflict)[source]

Bases: object

Result of propose_propagations() — accepted + skipped tallies.

Parameters:
candidates: tuple[PropagationCandidate, ...]
skipped_no_mapping: int
skipped_below_threshold: int
skipped_target_has_annotation: int
skipped_label_name_conflict: int
fantasm.api.backfill.build_confidence_map(data_a, data_b, reloc_blocks=(), *, rom_base=32768, workspace_ranges=((0, 256),), high_confidence=1000, cpu='6502')[source]

Full confidence map between two ROM versions.

reloc_blocks is an iterable of (src_a, src_b, runtime_dest, length) tuples describing relocated code — each block is matched separately at its runtime destination and merged in. workspace_ranges get identity mappings at high_confidence so labels for workspace variables propagate even when no opcodes anchor them.

Returns {addr_a: (addr_b, block_length)}.

Parameters:
Return type:

dict[int, tuple[int, int]]

fantasm.api.backfill.build_confidence_map_for_block(insts_a, insts_b, base_a, base_b)[source]

Build {addr_a: (addr_b, block_length)} from two instruction lists.

base_a/base_b are added to raw offsets to produce final addresses. block_length is the number of consecutive matching opcodes in the enclosing equal block — the confidence signal.

Parameters:
Return type:

dict[int, tuple[int, int]]

fantasm.api.backfill.diff_annotations(source_text, target_text, confidence_map, *, threshold=5)[source]

Compare annotations across two driver scripts.

For each annotation in source_text, looks up its mapped target address in confidence_map (filtered by threshold) and compares against the target driver’s annotations at that address. Returns a list of AnnotationDiff records covering every discrepancy.

“Discrepancy” includes addresses with no mapping, addresses where the target lacks the same kind of annotation, and addresses where both sides have annotations of the same kind but with different content.

Parameters:
Return type:

list[AnnotationDiff]

fantasm.api.backfill.group_logical_statements(lines)[source]

Group lines into balanced-paren statements.

Returns (start_line_idx, end_line_idx_exclusive, lines_list) tuples. Quoted strings and # comments are skipped when counting parens.

Parameters:

lines (Sequence[str])

Return type:

list[tuple[int, int, list[str]]]

fantasm.api.backfill.make_project_rom_loader(project)[source]

Return a memoised program-binary loader keyed by version id.

The loader resolves each version’s directory against the project config ([versions] prefixes / directory), then reads the binary from the configured [binary] layout (dir/extension), defaulting to versions/<prefix>-<id>/rom/<prefix>-<id>.rom. Bytes are cached for the lifetime of the returned closure.

Raises FileNotFoundError when a requested version’s binary file is missing — the API-layer counterpart to the CLI’s click.UsageError.

Parameters:

project (ProjectContext)

Return type:

Callable[[str], bytes]

fantasm.api.backfill.parse_annotations(script_text)[source]

Parse driver-script text and return its annotations.

Accepts both the py8dis (comment(0x..., inline=True)) and dasmos (d.comment(0x..., align=Align.INLINE)) call shapes via parse_driver_annotations(). Only inline comments are collected; block comments are not propagated.

Returns (inline_comments, labels, label_names, subroutines):

  • inline_comments: {addr -> [(text, full_line), ...]}

  • labels: {addr -> (name, full_line)} — last label wins

  • label_names: set of every label name encountered

  • subroutines: {addr -> full_statement_text} (may be multi-line)

Raises DriverParseError when the script is not valid Python.

Parameters:

script_text (str)

Return type:

tuple[dict[int, list[tuple[str, str]]], dict[int, tuple[str, str]], set[str], dict[int, str]]

fantasm.api.backfill.propose_propagations(source_text, target_text, confidence_map, *, threshold=5)[source]

Build the list of annotations worth propagating from source to target.

For each annotation in source_text:

  • drop it if its address has no mapping in confidence_map (or maps below threshold);

  • drop it if the target address already carries an annotation of the same kind (deduplication is conservative — exact text match for comments, any-label-at-addr for labels, any-sub-at-addr for subroutines);

  • drop a label if its name is already used elsewhere in the target driver script.

The remaining annotations are returned as PropagationCandidate records, alongside a tally of skip reasons for the user-facing report.

Parameters:
Return type:

PropagationReport

fantasm.api.backfill.propose_translations(project, *, source_version, target_version, source_driver, target_driver=None, threshold=5, cpu=None, rom_base=None, workspace_ranges=((0, 256),), high_confidence=1000)[source]

High-level translation proposal pipeline for generator scripts.

Glues together the version graph, opcode-level confidence-map composition, and annotation parsing — the same pipeline fantasm backfill runs on the CLI side, exposed as a public API so cross-version baseline generators (e.g. an acorn-nfs/scripts/generate_421_variant_1.py) can use the same anchored translation engine instead of reaching for fantasm.api.blockmatch.build_full_address_map() and inheriting its weaker contract (see issue #10).

Anchoring contract: every emitted candidate is anchored in a composed shared block of length ≥ threshold opcodes across the full source-to-target path through the version graph (the weakest hop is the binding constraint). Source addresses outside such a block produce no candidate — the consumer should route those to # UNMAPPED:.

Parameters:
  • project (ProjectContext) – The project context. Use fantasm.config.resolve_project_context() to obtain one (resolve_project_context(None) walks up from the current working directory).

  • source_version (str) – Version id whose annotations to translate.

  • target_version (str) – Version id to translate them to.

  • source_driver (Path) – Path to the source driver script.

  • target_driver (Path | None) – Optional path to an existing target driver. When given, candidates whose target address already carries an annotation of the same kind are skipped (the same dedup rule the CLI applies). When omitted (typical for “fresh baseline” use), all anchored translations are proposed.

  • threshold (int) – Minimum composed block length for a candidate. Default 5 matches fantasm backfill’s CLI default.

  • cpu (str | None) – Override for the project’s [binary]/[rom] cpu.

  • rom_base (int | None) – Override for the project’s [binary]/[rom] base_address.

  • workspace_ranges (Iterable[tuple[int, int]]) – Identity-mapped workspace regions.

  • high_confidence (int) – Confidence stamped on workspace mappings.

Raises:
Return type:

PropagationReport

fantasm.api.backfill.subroutine_name(full_text)[source]

Return the name argument of a subroutine() call’s source text.

Parameters:

full_text (str)

Return type:

str | None

fantasm.api.backfill.translate_address_in_text(text, old_addr, new_addr)[source]

Replace 0xOLDADDR with 0xNEWADDR everywhere in text.

Parameters:
Return type:

str

fantasm.api.backfill.translate_subroutine(full_text, old_addr, new_addr)[source]

Translate the address argument in a subroutine() call.

Only replaces the first occurrence of 0xOLDADDR: the address argument. Description text further inside the call is left untouched.

Parameters:
  • full_text (str)

  • old_addr (int)

  • new_addr (int)

Return type:

str

Driver-script annotation parsing

Parse annotation calls out of disassembly driver scripts.

Driver scripts annotate a binary with comment, label and subroutine calls. Two dialects are in use:

  • py8dis calls them as bare functions, flagging inline comments with inline=True:

    comment(0x8430, "Transfer to X", inline=True)
    subroutine(0x8430, "name", hook=None)
    
  • dasmos calls them as methods on a Disassembler (any receiver name is accepted), with comment placement given by an Align enum. Long calls are typically split one argument per line:

    d.comment(0x8430, "Transfer to X", align=Align.INLINE)
    d.subroutine(
        0x8430,
        "name",
        title="...",
    )
    

Rather than matching either surface syntax with regular expressions, the script is parsed with ast and every call to one of the three annotation functions is inspected. An annotation is reported only when its address argument is a literal integer (or a constant +/- expression of literals, such as 0x8000 + 3); calls whose address is computed at run time — typically inside a loop over a table — are skipped, as no static tool can place them.

class fantasm.api.driver_annotations.DriverAnnotation(kind, address, line_number, end_line_number, name, text, inline, is_entry_point, source)[source]

Bases: object

One annotation call found in a driver script.

name is the label or subroutine name, text the comment text; each is None when absent or not a string literal. inline is only meaningful for comments. is_entry_point is only meaningful for subroutines. source holds the exact source lines spanned by the call (lines line_number to end_line_number inclusive, 1-based).

Parameters:
  • kind (str)

  • address (int)

  • line_number (int)

  • end_line_number (int)

  • name (str | None)

  • text (str | None)

  • inline (bool)

  • is_entry_point (bool)

  • source (str)

kind: str
address: int
line_number: int
end_line_number: int
name: str | None
text: str | None
inline: bool
is_entry_point: bool
source: str
exception fantasm.api.driver_annotations.DriverParseError[source]

Bases: ValueError

The driver script is not valid Python and can’t be parsed.

fantasm.api.driver_annotations.parse_driver_annotations(driver_text)[source]

Return every literal-addressed annotation call in driver_text.

Results are in source order. Raises DriverParseError when the script has a syntax error.

Parameters:

driver_text (str)

Return type:

list[DriverAnnotation]

Driver-script address sorting

Sort top-level annotation calls in dasmos / py8dis driver scripts by address.

A driver script is a Python file that drives a tracing disassembler library through annotation calls — d.label(0x9000, "foo"), d.comment(0xA000, "..."), etc. As these files grow they tend to drift out of address order, so reading the file top-to-bottom doesn’t follow the address space. This module rewrites the file so the output is the unique address-ordered permutation that satisfies the script’s real ordering constraints.

The model is a partial-order topological sort. Each top-level statement is a node; ordering constraints (Python execution prerequisites, add_move translation requirements, the disassemble → render edge) are explicit edges. Where topology leaves multiple statements free, the tie is broken by primary address. The result is a single canonical ordering with no per-anchor heuristics.

## Hard edges

  • import / from / module-level assignments precede d = … Disassembler.create(…).

  • d = … Disassembler.create(…) precedes every d.X(…) call.

  • d.load(…) precedes every other d.X(…) call.

  • d.add_move(dest, src, length) precedes every annotation whose primary address falls inside [dest, dest+length) or [src, src+length).

  • Every annotation, setup, env, hook, hint, constant, and indented block precedes ir = d.disassemble().

  • ir = d.disassemble() precedes every subsequent statement (renders, file IO, prints).

  • Module-level name dependencies. For every module-scope name, a statement that writes it precedes later statements that read or write it, and a statement that reads it precedes a later statement that writes it (RAW / WAR / WAW). Read-after-read is free, so the d receiver — written once, read by every d.X(…) call — never orders annotations relative to each other. This edge is what keeps an accumulator setup (addr = 0x8579) ahead of the for / while loop that consumes and reassigns addr, and keeps a def ahead of the statement that calls it.

## Computed setup-assigns

A module-level assignment is normally setup (keyed -inf, pinned to the top). But an assignment whose value reads a name produced only by a non-setup statement — e.g. ENTRIES = _parse(…) calling a module-level def — is demoted to a soft anchor so the name edge from the def can order it after its definition. Pure-literal / constant assigns (addr = 0x8579, _table = [...], TOP = BASE + 0x100) read no such name and stay at the top.

## Unsortable inputs

Name edges can, in pathological inputs, close a cycle with the setup / render edges. When the DAG is cyclic there is no valid canonical order, so the sort is a no-op: sort_driver_text returns the input unchanged and is_sorted reports True. This guarantees the sort never changes whether the driver runs to completion.

## Tiebreak (sort key for free choices)

  • Setup (import, module assigns, Disassembler.create, d.load): -inf — topology pins them to the top.

  • d.use_environment(…): -1 — clusters ahead of the address-keyed run.

  • d.add_move(dest, src, length): min(dest, src).

  • d.hook_subroutine(addr, …) / d.format_hint(addr, …): addr.

  • d.constant(value, name) with literal int value: value.

  • d.label / comment / subroutine / banner / entry / byte / word / string / expr / expr_label / rts_code_ptr with literal first arg: the literal value.

  • Indented block (for / if / while / with / def) with a derivable first-iteration address: that address.

  • Indented block or unrecognised top-level call with no derivable address: a soft anchor — sort key inherited from the nearest preceding addressable statement.

  • Render block (ir = d.disassemble() and after): +inf, again topology-pinned.

  • Final tiebreak: original line number.

Indented blocks move as one atomic unit (header + body together). The block’s address is derived (in order of preference) from the first literal int in the loop iterable, then from the first literal-int positional arg of any sortable d.X(…) call in the body. If neither yields a literal, the block becomes a soft anchor — pinned to its source neighbourhood by edges to the immediately surrounding statements — and the rest of the file sorts around it.

## Byte-identity contract

Statement text is moved verbatim. We never re-emit numeric literals, never reformat keyword args, never normalise quoting. ast.parse is used to understand structure; the original source-text bytes are sliced by line range and concatenated in the new order. Hex literals stay hex.

class fantasm.api.driver_sort.Unit(leading_lines, statement_lines, trailing_lines, kind, address, original_index)[source]

Bases: object

One source-level unit: leading filler + one statement + trailing filler.

leading_lines and trailing_lines are blank or comment-only lines that travel with the statement when it moves. statement_lines are the lines of one top-level Python statement (possibly multi-line or a whole indented block). kind describes the statement’s role for the sort. address is the unit’s primary sort address (None for setup / render / soft-anchor units). original_index is the unit’s position in the original file (stable-sort tiebreaker).

Parameters:
leading_lines: tuple[str, ...]
statement_lines: tuple[str, ...]
trailing_lines: tuple[str, ...]
kind: str
address: int | None
original_index: int
fantasm.api.driver_sort.build_units(text)[source]

Parse text into a list of Unit records, in source order.

Parameters:

text (str)

Return type:

list[Unit]

fantasm.api.driver_sort.emit_units(units)[source]

Concatenate units back into source text.

Parameters:

units (Sequence[Unit])

Return type:

str

fantasm.api.driver_sort.is_sorted(text)[source]

Return True if text is already in canonical sorted order.

Equivalent to sort_driver_text(text) == text but slightly cheaper (skips the emit pass when the topology already matches).

Parameters:

text (str)

Return type:

bool

fantasm.api.driver_sort.sort_driver_text(text)[source]

Return text with statements topologically sorted by address.

See the module docstring for the full rule set. Statement text is moved verbatim — hex literals stay hex, multi-line calls keep their original layout.

Parameters:

text (str)

Return type:

str

Version graph (DAG) and chain composition

Version DAG and per-version memory-region resolution.

Loads [memory] and [[versions.entry]] from a project’s fantasm.toml and exposes a graph object that path-finds between versions and computes effective memory regions per version (project defaults plus the per-version override, plus reloc destinations auto-merged).

See docs/configuration.md for the full schema. Quick recap of the relevant TOML:

[memory]
regions          = [{ start = 0x0016, end = 0x0076 }, ...]
external_regions = [{ start = 0xFC00, end = 0xFFFF }, ...]

[[versions.entry]]
id           = "3.34"
parents      = []
reloc_blocks = [{ source = 0x9307, dest = 0x0016, length = 0x61 }, ...]

[[versions.entry]]
id           = "page7-variant"
parents      = ["3.34B"]
memory.regions = [...]   # complete replacement of project regions
reloc_blocks = [...]
class fantasm.api.version_graph.Edge(parent_id, child_id, walked_forward)[source]

Bases: object

An edge in the version DAG.

parent_id is always the older endpoint, child_id always the newer. walked_forward is True when a path traverses the edge in the canonical direction (parent → child) and False when traversing it backwards (child → parent), which callers like backfill use to decide whether to invert the per-hop confidence map.

Parameters:
  • parent_id (str)

  • child_id (str)

  • walked_forward (bool)

parent_id: str
child_id: str
walked_forward: bool
exception fantasm.api.version_graph.NoPathError(source_id, target_id)[source]

Bases: VersionGraphError

Raised when no path exists between two versions.

Parameters:
  • source_id (str)

  • target_id (str)

Return type:

None

class fantasm.api.version_graph.Region(start, end)[source]

Bases: object

An inclusive [start, end] address range.

Parameters:
start: int
end: int
contains(address)[source]
Parameters:

address (int)

Return type:

bool

class fantasm.api.version_graph.RelocBlock(source, dest, length)[source]

Bases: object

A move()-style relocation: source bytes → runtime destination.

Parameters:
source: int
dest: int
length: int
property dest_region: Region
class fantasm.api.version_graph.Version(id, parents, reloc_blocks, explicit_regions, notes=None, description=None, release_date=None, source=None)[source]

Bases: object

A node in the version graph.

explicit_regions is None if the entry didn’t override memory.regions (in which case the graph’s project defaults apply).

Free-form metadata fields (notes, description, release_date, source) are surfaced for tooling and documentation but don’t affect any analysis. release_date is expected to be an ISO-8601 date string but isn’t parsed.

Parameters:
id: str
parents: tuple[str, ...]
reloc_blocks: tuple[RelocBlock, ...]
explicit_regions: tuple[Region, ...] | None
notes: str | None = None
description: str | None = None
release_date: str | None = None
source: str | None = None
class fantasm.api.version_graph.VersionGraph(versions, project_regions=(), project_external_regions=())[source]

Bases: object

A version DAG with per-version effective-region resolution.

Parameters:
  • versions (Iterable[Version])

  • project_regions (Iterable[Region])

  • project_external_regions (Iterable[Region])

ids()[source]

Return all version ids in declaration order.

Return type:

tuple[str, …]

get(version_id)[source]

Return the Version for version_id.

Parameters:

version_id (str)

Return type:

Version

find_path(source_id, target_id)[source]

Return the shortest path of edges from source_id to target_id.

Walks the undirected projection of the DAG (each edge can be traversed in either direction). Same-source same-target returns an empty list. Raises VersionNotInGraphError for unknown endpoints and NoPathError when the endpoints are in disconnected components.

Parameters:
  • source_id (str)

  • target_id (str)

Return type:

list[Edge]

effective_regions(version_id)[source]

Effective non-ROM regions for version_id.

The version’s explicit memory.regions override (if set) otherwise the project default, plus reloc-block destinations from this version’s reloc_blocks. Overlapping or adjacent regions are merged; the result is sorted by start address.

Parameters:

version_id (str)

Return type:

list[Region]

effective_external_regions(version_id)[source]

Effective hardware-mapped regions for version_id.

Project-level only — there’s no per-version override (the BBC’s hardware memory map is invariant within a project). Validates that version_id exists in the graph.

Parameters:

version_id (str)

Return type:

list[Region]

reloc_pairs_for_edge(edge)[source]

Derive (src_parent, src_child, dest, length) tuples for edge.

Reloc blocks are matched across the edge’s two endpoints by (dest, length). When either side has multiple blocks with the same key (rare), they are paired off positionally in the order they appear in each version’s reloc_blocks list. Blocks that exist on only one side are silently dropped — a block whose dest/length differs across versions can’t contribute to opcode matching anyway.

Tuples are returned sorted by parent source address for determinism. The order (src_parent, src_child, dest, length) is independent of the edge’s traversal direction; callers walking child -> parent swap or invert the resulting hop map themselves.

Parameters:

edge (Edge)

Return type:

list[tuple[int, int, int, int]]

exception fantasm.api.version_graph.VersionGraphCycleError[source]

Bases: VersionGraphError

Raised when the parents declarations imply a cycle.

exception fantasm.api.version_graph.VersionGraphError[source]

Bases: LookupError

Base class for version-graph errors.

exception fantasm.api.version_graph.VersionNotInGraphError[source]

Bases: VersionGraphError

Raised when a referenced version isn’t declared in the graph.

fantasm.api.version_graph.compose_chained_map(graph, source_id, target_id, rom_loader, *, rom_base=32768, cpu='6502', workspace_ranges=((0, 256),), high_confidence=1000)[source]

Compose a confidence map from source_id to target_id.

Walks the shortest path through graph between the two versions, builds a per-hop confidence map for each edge using ROM bytes obtained from rom_loader, and composes them end-to-end with min-confidence (weakest link).

Per-hop maps are always built canonically (parent → child) and inverted on the fly when the path traverses an edge backward, so the chain composition is direction-aware.

Returns {source_addr: (target_addr, confidence)} covering addresses where a path exists; addresses that don’t compose through every hop are simply absent. Same-version returns {}.

Parameters:
Return type:

dict[int, tuple[int, int]]

fantasm.api.version_graph.load_version_graph(project_context)[source]

Build a VersionGraph from project_context.config.

Reads [memory] and [[versions.entry]]. Returns an empty graph (no versions, empty regions) when the config lacks either section — this is the right behaviour for a project that doesn’t use the version-DAG features.

Raises RuntimeError if the project root isn’t resolved.

Parameters:

project_context (ProjectContext)

Return type:

VersionGraph

Assembly-source extraction

Extract sections of a disassembly .asm file by address or label.

Pure helpers for indexing assembly lines and resolving address/label targets. CLI-facing wrappers (file open + print) live separately.

Refactors relative to the sibling copies:

  • find_line_for_target() no longer takes the unused lines parameter and emits ambiguous-match warnings via warnings instead of printing to stderr.

  • extract_section() returns the captured slice (start line and text) as a pure function; the file-based extract entry was a CLI-style mixed-IO helper and has been split out so the rest of the module is easier to test and re-use.

class fantasm.api.asm_extract.AsmSection(start_line, end_line, lines)[source]

Bases: object

A slice of assembly lines extracted by extract_section().

start_line and end_line are 0-based; end_line is exclusive. lines is the captured text without line numbers.

Parameters:
start_line: int
end_line: int
lines: list[str]
fantasm.api.asm_extract.build_index(asm_lines)[source]

Build (addr_to_line, label_to_line) indices.

For each line, looks for a ;NNNN: comment marker (the disassembler’s address tag) and a :NNNN[...] runtime-address annotation; both are recorded in addr_to_line. Lines starting with .label are recorded in label_to_line. The first occurrence wins for any given key.

Parameters:

asm_lines (Sequence[str])

Return type:

tuple[dict[int, int], dict[str, int]]

fantasm.api.asm_extract.extract_section(asm_lines, start_target, end_target=None, default_window=40)[source]

Extract a section of assembly lines spanning two targets.

The section starts at the line resolved from start_target and ends at the line resolved from end_target (inclusive). When no end_target is given, default_window lines are taken.

The start is backed up across preceding blank, comment-only, and label-only lines so the captured section includes the natural “header” of the routine or block.

Raises LookupError if either target cannot be resolved.

Parameters:
Return type:

AsmSection

fantasm.api.asm_extract.find_line_for_target(target, addr_to_line, label_to_line)[source]

Resolve a textual target to a 0-based line number.

Resolution order:

  1. If target parses as a hex address, return the line that maps to that address — or, failing an exact match, the line for the nearest indexed address less than or equal to target.

  2. If target matches a label exactly, return that label’s line.

  3. If target is a substring of exactly one label, return that label’s line. If it matches multiple labels, emits a UserWarning listing the matches and returns the lexically first match’s line so callers continue to make progress.

Returns None when nothing matches.

Parameters:
Return type:

int | None

fantasm.api.asm_extract.parse_address(text)[source]

Parse a hex address in any of the conventional notations.

Recognises $8000, &8000, 0x8000 and bare 8000. Returns None if the text is not a valid hex integer.

Parameters:

text (str)

Return type:

int | None

Project bootstrap (fantasm project init / add / list)

Project scaffolding.

Helpers to initialise a new fantasm project and to add or list its targets. Two layouts are supported:

  • versions (the default) — write fantasm.toml and create the standard versions/ directory, one {prefix}-{id}/ directory per version;

  • explicit — adopt an existing project as it stands: write fantasm.toml with a [[targets]] entry naming the files where they already are, and create nothing else.

Pure helpers; the fantasm project Click sub-commands wrap these.

class fantasm.api.project.ProjectInitConfig(name, prefixes, versions_dirname='versions', cpu='6502', layout='versions', targets=())[source]

Bases: object

Configuration for initialising a fantasm project.

prefixes must contain at least one entry. Each prefix matches the regex [A-Za-z0-9_-]+ (no spaces, no slashes).

Parameters:
name: str
prefixes: tuple[str, ...]
versions_dirname: str = 'versions'
cpu: str = '6502'
layout: str = 'versions'
targets: tuple[TargetSpec, ...] = ()
class fantasm.api.project.TargetSpec(id, binary, driver, asm, json, load_address=None, exec_address=None, metadata=None, inf_source=None)[source]

Bases: object

A [[targets]] entry to write into fantasm.toml.

Paths are as they should appear in the file: relative to the project root (or absolute). inf_source names the .inf file the addresses were read from, recorded as a comment.

Parameters:
  • id (str)

  • binary (str)

  • driver (str)

  • asm (str)

  • json (str)

  • load_address (int | None)

  • exec_address (int | None)

  • metadata (str | None)

  • inf_source (str | None)

id: str
binary: str
driver: str
asm: str
json: str
load_address: int | None = None
exec_address: int | None = None
metadata: str | None = None
inf_source: str | None = None
class fantasm.api.project.VersionInfo(prefix, version_id, dirpath)[source]

Bases: object

A discovered version directory.

Parameters:
prefix: str
version_id: str
dirpath: Path
fantasm.api.project.add_version(versions_dirpath, version_id, prefix, binary_dirname='rom')[source]

Create a new version directory {prefix}-{version_id}/.

Creates the binary subdirectory (binary_dirname, default rom/ per the historical ROM convention) and output/ (each with a .gitkeep). Returns the new version directory path. Raises FileExistsError if it already exists, ValueError for an invalid prefix.

Parameters:
  • versions_dirpath (Path)

  • version_id (str)

  • prefix (str)

  • binary_dirname (str)

Return type:

Path

fantasm.api.project.append_target(fantasm_toml_filepath, spec)[source]

Append a [[targets]] entry to an existing fantasm.toml.

Raises ValueError if a target with the same id is already declared, or if the result would not parse (for example because the file declares targets as an inline array); the file is left unchanged in that case.

Parameters:
Return type:

None

fantasm.api.project.binary_addresses(binary_filepath, load_address=None)[source]

Return (load, exec, inf_filepath) for a binary.

With load_address given, returns it (and no exec address or .inf). Otherwise reads the binary’s .inf sidecar. Raises InfParseError, with a hint to give the address explicitly, when there’s no sidecar or it can’t be parsed.

Parameters:
  • binary_filepath (Path)

  • load_address (int | None)

Return type:

tuple[int, int | None, Path | None]

fantasm.api.project.default_target_paths(target_id)[source]

Default driver / asm / json paths for a new explicit target.

Parameters:

target_id (str)

Return type:

dict[str, str]

fantasm.api.project.init_project(root_dirpath, config, *, force=False)[source]

Initialise a fantasm project at root_dirpath.

Writes fantasm.toml. For the versions layout, also ensures the versions directory exists (creating it with a .gitkeep if it didn’t); the explicit layout creates nothing else. Returns the fantasm.toml path.

Raises FileExistsError if fantasm.toml already exists and force=False. Other files in root_dirpath are left untouched, so it is safe to run inside an existing repository.

Parameters:
Return type:

Path

fantasm.api.project.list_versions(versions_dirpath, prefixes)[source]

List version directories matching any of the configured prefixes.

Only entries whose name is exactly {prefix}-{version_id} for one of the prefixes are reported. Sorted by directory name.

Parameters:
Return type:

list[VersionInfo]

fantasm.api.project.parse_binary_option(text)[source]

Split a PATH[@ADDR] option value.

ADDR is hexadecimal, with an optional &, $ or 0x prefix. A trailing @... that isn’t an address is left as part of the path. Raises ValueError for an address above &FFFF.

Parameters:

text (str)

Return type:

tuple[str, int | None]

fantasm.api.project.project_relative(path, root_dirpath)[source]

path relative to root_dirpath (POSIX form) when inside it, else absolute.

Parameters:
Return type:

str

fantasm.api.project.render_fantasm_toml(config)[source]

Render the fantasm.toml content for a new project.

Output is hand-formatted (with comments) rather than serialised by a TOML library, so the file is still readable when a human edits it later.

Parameters:

config (ProjectInitConfig)

Return type:

str

fantasm.api.project.render_target_entry(spec)[source]

Render one [[targets]] block (ending in a newline).

Parameters:

spec (TargetSpec)

Return type:

str