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 --disasm and --fmt flags. 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>] [--cpu <target>] [--org <addr>] <input.bin>

Writes a linear listing to stdout. The decoder follows --cpu, or the default CPU of --dialect when no target is given; see Targets for compatible selections. With neither option, the default is the 6502. See Dialects for the implemented front-ends and aliases. --org sets the address of the first byte, which changes how branch 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.

For a 68000 binary branch.bin containing bytes 60 02, this command places the branch at $10000:

asm198x disasm --dialect vasm --org 0x10000 branch.bin

Its listing is:

    bra.s $10004

For code/data ranges, labels and source verified by reassembly, use the separate opt-in 6502 recovery workflow.

fmt — reformat

asm198x fmt [-d <dialect>] [--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.

Choose the source syntax with --dialect, or its initial CPU with --cpu, using the same dialect and target guidance. For the ACME fill.a example above:

asm198x fmt --dialect acme fill.a -o formatted.a
asm198x --dialect acme formatted.a -o formatted.bin

The rebuilt binary contains the same A9 51 60 bytes as the original source.

; 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 one self-contained pasmo or pasmonext source file to sjasmplus, at instruction and directive level. It does not resolve INCLUDE or INCBIN files, even when they exist beside the input. Inline included source and replace binary inclusions with data directives before converting. Ordinary assembly resolves these files. Comments ride along, and macros are preserved where both dialect verifiers accept the resulting source. 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 (6502, Z80, SM83, 6809, 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. For native section listings, that order follows physical bytes within the source segment: ca65 .org and .reloc change logical addresses without splitting a routine’s total. Different source segments keep separate totals. 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 lwasm 6809 dialect resolves indexed and stack costs from the emitted operand bytes. RTI shows 6/15, since its cost depends on the saved state; SYNC and CWAI show >=4 and >=20, since they may wait indefinitely. A label containing either wait has no finite maximum. JSON listings express that as "max": null; complete timing coverage does not imply a finite cost. Undocumented instructions keep timing unknown and make coverage partial.

The JSON result’s computed-instruction cycle records carry authoritative bounds: {"min": ..., "max": ...}. When bounds is present, consumers use it instead of adding the legacy base, page_cross, and branch_taken fields. Rust consumers can call CycleRec::range() for either record shape. These are nominal processor cycles; external stalls and called routines or interrupt handlers are outside the straight-line account.

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. A cycle budget also refuses a routine containing an unbounded wait: a minimum cost cannot prove a ceiling. RTI budgets use its 15-cycle maximum.

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.

Unknown CPU names and incompatible CPU/dialect pairs fail before producing any output files, including with --message-format=json. --target is a synonym for --cpu; names are case-insensitive.

The table below lists initial CLI targets. Dialect aliases from the Dialect table also work in the first column. For compatibility, --cpu accepts those same names and aliases as shorthand for their default CPU (for example, --cpu nes means 6502 and --cpu pasmonext means Z80N).

CLI dialect Default CPU Accepted CLI CPUs
acme 6502 6502
ca65 6502 6502
65816 65816 65816
huc6280 huc6280 huc6280
vasm 68000 68000
lwasm 6809 6809
rgbasm sm83 sm83
pasmo z80 z80, z80n
pasmonext z80n z80, z80n
sjasmplus z80 z80, z80n
8080 8080 8080
6800 6800 6800
1802 1802 1802
8048 8048 8048
8035 8035 8035
scmp scmp scmp
f8 f8 f8
2650 2650 2650
tms7000 tms7000 tms7000
pdp11 pdp11 pdp11
tms9900 tms9900 tms9900
cp1610 cp1610 cp1610
z8000 z8000 z8000
z8001 z8001 z8001

next is also accepted for Z80N. The 8048 row includes its ROM-equipped aliases; the 8035 row includes the ROM-less 8039/8040 aliases. These two groups are distinct CLI targets.

These choices set the frontend’s initial ISA. Source-level CPU directives retain their lexical meaning: for example, ACME !cpu 65816 can enable instructions later in the source even with --cpu 6502. That does not make --dialect acme --cpu 65816 a supported CLI combination. For the ca65-family 65816 and HuC6280 frontends, select --dialect 65816 and --dialect huc6280 respectively, rather than combining those CPUs with --dialect ca65.

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.

JSON reporting still writes explicitly requested files: CLI output options, source-named images and symbol lists, and source artifacts such as SAVEBIN, CSPECTMAP and LABELSLIST. SAVE artifacts use the output directory, or --outprefix when supplied, just as in human mode. Each successful artifact write appears in the JSON result’s warnings array with kind: "Note" and its resolved destination; these informational notes are not advisory warnings. bytes remains the assembled machine code. A plain JSON query with no output request writes no file. A failed write returns a non-zero exit status; files written earlier in that invocation can remain.

SjASMPlus SAVENEX OPEN, CORE, AUTO and CLOSE produce a separate NEX V1.2 executable from DEVICE ZXSPECTRUMNEXT memory. AUTO saves nonzero banks as they stand when that command is reached; CLOSE finalizes the header. The library describes this file as an artifact with format nex, while bytes continues to hold the raw assembled code. Loading screens, appended files, other SAVENEX commands and explicit V1.3 output remain unsupported.

Artifact destinations are checked against source files, the primary image and sidecars before writing. This includes existing hard links and symbolic links. Repeated artifact names are written in source order, as in the reference.

* = $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.

Verified 6502 recovery

asm198x disasm --recover -d acme --org 0x2000 --code 0x2000:0x2014 input.bin -o recovered.a produces ACME source only after a byte-identical rebuild. --code START:END and --data START:END are repeatable half-open CPU address ranges. Unmarked bytes remain data; data ranges override code. --label ADDRESS:NAME supplies a name, and direct control-flow targets inside the image receive generated labels. -o must name a new file; omit it for stdout.

The worked recovery example shows how to identify a table, recover a subroutine, and change one instruction.