Using it
The command line
asm198x assembles retro-CPU source to a flat binary, disassembles one back, or
reformats source in place. One binary, no runtime dependencies, the same
interface on macOS, Linux and Windows.
This page is the reference. asm198x --help gives the same information in one
screen.
Installing
brew install asm198x/tap/asm198x
Installer scripts, platform archives and the reason cargo install will not
find it are on Install.
Operations
The operation is a subcommand:
asm198x [asm|disasm|fmt] [options] <input>
dialects and version are queries rather than operations: they take no input
and answer immediately.
Assembling is the default, so a bare invocation assembles and asm is the
explicit spelling:
asm198x prog.asm -o prog.bin # assemble
asm198x asm prog.asm -o prog.bin # identical
asm198x disasm prog.bin # disassemble to stdout
asm198x fmt prog.asm # reformat, to stdout
asm198x --version # which build is this
Before v0.0.12 the operations were the
--disasmand--fmtflags. Those are withdrawn; using one now tells you the subcommand to use instead.
asm — assemble
asm198x [asm] [--dialect <name>] [--cpu <target>] [-I <dir>]... <input> [-o <out.bin>]
Reads one source file and writes a flat binary. With no -o, the output takes
the input’s name with a .bin extension.
disasm — disassemble
asm198x disasm [-d <dialect>] [--org <addr>] <input.bin>
Writes a listing to stdout. The CPU follows the dialect: a 6502 dialect
disassembles as 6502, otherwise Z80 — so pass -d when the default is wrong.
--org sets the address the first byte is placed at, which changes how branch
and absolute operands render.
* = $c000
lda #$51
rts
Assembled and read back with asm198x disasm -d acme --org 0xc000 fill.a.bin:
*= $C000
LDA #$51
RTS
The origin is not in the binary — a flat binary is bytes and nothing else — so
--org is how you tell the disassembler where they were meant to live. Leave it
out and the same bytes read as if they sat at $0000.
fmt — reformat
asm198x fmt [--cpu <target>] <input.asm> [-o <out.asm>]
Canonical layout: labels at column 0, operations indented, own-line comments on their own lines. Comments and operand spelling are preserved verbatim — the formatter canonicalises layout, never the text of an operation. Formatting is idempotent, and formatted source reassembles to the same bytes.
Writes to stdout unless -o is given; it never rewrites the input in place.
To format a file over itself, write to a new path and move it.
; Set the border colour.
start: ld a,1
out ($fe),a
ret
asm198x fmt -d pasmo border.asm writes:
; Set the border colour.
start:
ld a,1
out ($fe),a
ret
The label takes its own line, the operations line up, and the comment is untouched — including its wording and its position above the code.
dialects — list what --dialect accepts
asm198x dialects # the table below, as text
asm198x dialects --markdown # the same table as markdown, on stdout
The --markdown form is what generates this page’s dialect table, so the two
cannot disagree. It writes to stdout to be redirected; everything informational
goes to stderr.
version — report the build
asm198x --version # also -V, or `asm198x version`
Prints asm198x v<version> — the same v-prefixed spelling the site, the
docs and the release tags use. The version is compiled in from the crate
version, so it names the build you are holding.
Added after v0.0.12. Earlier binaries answer none of the three spellings, so if
asm198x --version reports an unknown flag, you are on v0.0.12 or older.
Options
| Option | Applies to | Meaning |
|---|---|---|
-o, --output <path> |
asm, fmt | Output path. asm defaults to the input with a .bin extension; fmt writes to stdout |
-d, --dialect <name> |
all | Source syntax — see Dialects |
--cpu, --target <name> |
all | CPU target where a dialect serves more than one (z80, z80n); with no --dialect, names a chip directly — see Targets |
-I <dir> |
asm | Add an include-search directory. Repeatable; order is search order |
--equ NAME=VALUE |
asm (pasmo/pasmonext) | Define a command-line constant before conditional assembly. Repeatable; matches Pasmo’s spelling |
--org <addr> |
disasm | Address of the first byte |
--message-format <human|json> |
asm | human (default) or a machine-readable result plus diagnostics on stdout |
-h, --help |
— | This surface, in one screen |
Output containers
By default asm writes a flat binary. These wrap it for a machine’s loader:
| Option | Produces | Requires |
|---|---|---|
--sna |
Spectrum 48K snapshot | Z80 dialect; end <addr> for the entry point; code at or above $4000, since below that is ROM |
--prg |
C64 program (2-byte load address prepended) | acme |
--gb-rom |
Game Boy cartridge ROM (RGBLINK layout, padding and checksums) | rgbasm |
--exe, --hunkexe |
Amiga hunk executable | vasm |
Debug artifacts
| Option | Writes | Default path |
|---|---|---|
--debug[=path] |
Debug198x NDJSON sidecar | input + .debug198x |
--sym[=path] |
Sorted name = $hex symbol table |
input + .sym |
--listing[=path] |
Address / bytes / cycles / source rows, with per-label cycle totals | input + .lst |
--listing-json[=path] |
The same per-line and per-label data as JSON | input + .lst.json |
--map[=path] |
Memory map: used/free per area of the active layout | input + .map |
Available on the flat dialects, plus the ca65 and vasm linked paths for
--debug and --sym. They describe an assembly, so combining them with fmt
or disasm is an error rather than a silent no-op.
Use --sym-format=vice with --sym for a VICE monitor label file (default
extension .vs), or --sym-format=nocash for Game Boy bank:address labels
(default extension .sym):
asm198x --dialect acme --sym --sym-format=vice program.asm
asm198x --dialect rgbasm --sym --sym-format=nocash game.asm
Both formats export address labels, not constants. VICE requires resolved
16-bit, unbanked locations; the Game Boy format requires captured RGBASM bank
metadata. Unsupported names and locations are errors, not silently omitted
symbols. --sym-format=native is the default and preserves the original
symbol table. --sym=path overrides the filename for any format.
convert — dialect conversion
asm198x convert --from pasmo --to sjasmplus game.asm -o game.sjasm
Reads source in one dialect of a CPU and re-emits it in another, through the source-preserving AST — labels, names, and structure survive; this is source migration, not disassembly. The conversion is self-verifying: output is written only when assembling the input under the source dialect and the output under the target dialect produce byte-identical images, so a conversion is correct by construction or it is a reported error naming what diverged — never silent plausible output. Both ends are real reference dialects; nothing is invented.
v1 converts pasmo and pasmonext source to sjasmplus, at instruction and
directive level (comments ride along; macros and includes convert as written
where the dialects agree). The emblematic rewrite: pasmo closes REPT with
ENDM, which sjasmplus refuses — the converter rewrites the repetition
closer to ENDR and leaves a macro’s ENDM alone.
Project linker configurations (ca65)
asm198x --dialect ca65 -C project.cfg game.s -o game.nes
-C hands the ca65 path a project’s own ld65 configuration instead of the
built-in curriculum layout. The reader is bounded to the shapes real projects
use — MEMORY (start, size, type, file, fill, fillval) and
SEGMENTS (load, type, align, start) — and refuses anything outside
that bound by name rather than linking wrong. Placement follows ld65’s rule:
segments fill their memory area in declaration order, align rounds up,
start pins, and a segment with neither lands directly after whatever its
area already holds. Without -C nothing changes: the curriculum default
applies exactly as before.
Cycles in the listing
Where the instruction spec carries validated cycle data (the form-model CPUs
— 6502, Z80, SM83, and their relatives), the listing annotates each
instruction with its cost from that spec: one number when the cost is fixed,
min/max when a page-cross or branch-taken extra makes it a range. A tail
sums each label’s straight-line span — the instructions from the label to the
next label, in address order; it is static cost, not execution. A CPU whose
spec has no cycle data yet says no cycle data (backfill pending) instead of
inventing figures, and a dialect that pre-encodes some instructions marks its
figures as lower bounds.
The memory map
--map reports each memory area of the active layout — capacity, units used,
units free, and the largest contiguous free run, which a pinned segment
(VECTORS at $FFFA) can make smaller than the free total suggests. On the
ca65 path the areas are the linker configuration’s (the curriculum default,
or a -C config); a flat dialect reports its one implicit area, the 64K
address space. The same accounting rides the JSON listing and the JSON
result as areas. The figures measure the program against the layout — what
a booted machine actually leaves free is hardware fact, and arrives with
the reference work that records it.
Budgets
A comment of either form
; asm198x: cycles(irq) <= 224
; asm198x: free(PRG) >= $1000
is an assertion the assembler enforces. cycles holds a routine’s
straight-line worst case under a ceiling, counting every conditional extra;
free holds a memory area’s headroom above a floor. Assembly fails when
a budget is broken, naming what was required and what is actual. The line is
an ordinary comment to every reference assembler, so the source still
assembles byte-identically elsewhere; only asm198x acts on it. Because it is
an assertion, a malformed spelling, an unknown label or area, or a cycles
budget on a CPU without cycle data is an error rather than a silently
ignored comment.
The native ca65 linker checks cycle budgets too, with either its default NES layout or a project configuration. A routine ends at the next greater labelled offset in its own segment; equal offsets in other segments do not mix. Data and reservations carry no execution cost. Includes and macro expansions count the instructions they emit. Native ca65 listings group emitted source records by segment, with cycle columns and per-label totals. Segment headings distinguish CPU bases from file offsets; CHR offsets are section-relative, not CPU addresses. Header metadata and address-only reservations have no source rows. Repeated includes and macro expansions list each emitted instance.
The ca65 JSON result carries section_debug: each entry composes a Debug198x
section, an optional file_offset, and the existing debug record with
section-relative offsets. The JSON listing carries a sections table and
section-qualified lines and labels; an unavailable CPU address is null.
Flat results and listings retain their existing shape. The native vasm path still rejects cycle budgets
because its instruction timing data is not available.
The sidecar format is specified in
debug198x.md and is frozen
at v1.
Dialects
--dialect selects the source syntax, not the CPU. Each front-end matches
an existing assembler, so pick the assembler the source was written for.
The full table — every dialect, what its syntax is for, and every spelling
accepted — is on its own page: Dialects. It is generated from the
binary, so it cannot fall behind what --dialect does.
Targets
--cpu picks a CPU where a dialect serves more than one. Today that is Z80:
z80 (the pasmo default) and z80n (Spectrum Next, the pasmonext default).
Z80N opcodes follow the target, not the dialect — sjasmplus --cpu z80n
gets them, pasmonext --cpu z80 does not.
--cpu also names a chip directly when no --dialect is given, so
asm198x --cpu 6809 prog.asm is lwasm syntax and --cpu 6502 is ACME’s. Any
name from the dialect table works there. With both given, --dialect chooses
the syntax and --cpu the target.
Exit status and diagnostics
0 on success, non-zero on failure. A diagnostic carries a severity, a
message, a (file, line, column) span and a code. The human form prints what it
knows — asm198x: file:line:col: error: message, dropping the column where the
parse did not record one — and the full record is on the
--message-format=json path.
Use asm198x --explain <code> for a longer explanation of a diagnostic.
The diagnostic reference carries the same text as the binary.
Codes are assigned at error sites without renumbering existing ones;
AssemblyError remains the catch-all for sites not yet classified. JSON
consumers should allow for new code names instead of parsing message wording.
stdout carries output; stderr carries everything else. disasm and fmt
write their result to stdout, asm writes bytes to a file, and the summary line
and diagnostics go to stderr, so a pipeline gets the artifact and nothing else.
--message-format=json is the exception: it puts a machine-readable result on
stdout — bytes, symbols and the full diagnostic list — for a build script or an
editor. The human form stays on stderr.
* = $c000
!byte $1234
That source is refused, and --message-format=json reports it like this. The
payload is one line on the wire; it is shown indented here:
[
{
"span": {
"file": 0,
"line": 2,
"col": 15,
"expansion_frames": [],
"path": "fill.a"
},
"code": "AssemblyError",
"severity": "Error",
"message": "value 4660 does not fit in a byte",
"fix": null
}
]
file is a file id; v1 assembles a single file, so it is always 0. A col of
0 means the raising site knew no column — treat the span as the whole line.
expansion_frames records the expansions a location came through, innermost
first, and stays empty until a dialect expands macros. fix carries a suggested
edit where one is available: a description, plus a replacement when the fix
is a concrete piece of text to apply at the span.