A cross-assembler for the 6502, 65C02 and 65816 that accepts the source syntax of MERLIN — The Macro Assembler For The Apple, by Glen Bredon (Roger Wagner Publishing, 1984). Period Apple II source files assemble unmodified on a modern host, down to the bytes: Glen Bredon's own APPLE PI demo, five modules on the Merlin Pro disk, assembles and links here to the binary he shipped in 1982.
  • Zig 88.6%
  • C 7.1%
  • JavaScript 1.1%
  • Nix 1.1%
  • C++ 0.9%
  • Other 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 704db1b26d
All checks were successful
test / test (push) Successful in 8m45s
test / nix (push) Successful in 9m7s
test / docs (push) Successful in 5m0s
Say what zon2nix does when zig is missing
The comment on the input claimed it prints "unable to execute zig" and
then exits zero, leaving the old build.zig.zon.nix in place. It does not:
the current build reports `unable to execute zig, is it in your PATH?`
and exits 1. What is worth warning about survives the correction, since
it fails before writing anything -- the stale file is still sitting there
looking untouched, which is why the devshell wraps zon2nix with this
flake's Zig on PATH rather than trusting the caller's.

Also names why it wants zig at all: `zig env` for the version, and `zig
fetch` for each dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2s8hFZpt353Q1t1Y35LpG
2026-09-07 19:22:26 -05:00
.forgejo/workflows Run the workflows on nixos-do-s 2026-09-04 19:49:35 -05:00
LICENSES Merlin-syntax cross-assembler for the 6502 and 65C02 2026-08-31 14:19:08 -05:00
src Parse file: URIs with zig-uri 2026-09-04 22:10:58 -05:00
tests Fuzz what a source may reach 2026-09-04 18:03:19 -05:00
tools Fuzz the choices, not just the bytes 2026-09-04 15:48:31 -05:00
tree-sitter-merlin Teach the grammar IF, ERR and a quoted macro argument 2026-09-04 11:23:05 -05:00
.gitignore asm --disk writes a disk the machine boots 2026-09-04 03:01:05 -05:00
build.zig Parse file: URIs with zig-uri 2026-09-04 22:10:58 -05:00
build.zig.zon Parse file: URIs with zig-uri 2026-09-04 22:10:58 -05:00
build.zig.zon.nix Parse file: URIs with zig-uri 2026-09-04 22:10:58 -05:00
CLAUDE.md The grammar meets Glen Bredon's own source, and loses 2026-09-04 10:44:37 -05:00
flake.lock Take zon2nix from the flake, with Zig wrapped in 2026-09-01 17:39:58 -05:00
flake.nix Say what zon2nix does when zig is missing 2026-09-07 19:22:26 -05:00
package.nix Refresh the README, and implement what it turned out to be lying about 2026-09-01 12:14:26 -05:00
README.md Parse file: URIs with zig-uri 2026-09-04 22:10:58 -05:00
REUSE.toml Check in the generated tree-sitter parser 2026-09-01 03:41:42 -05:00

merle65

A cross-assembler for the 6502, 65C02 and 65816 that accepts the source syntax of MERLIN — The Macro Assembler For The Apple, by Glen Bredon (Roger Wagner Publishing, 1984). Period Apple II source files assemble unmodified on a modern host, down to the bytes: Glen Bredon's own APPLE PI demo, five modules on the Merlin Pro disk, assembles and links here to the binary he shipped in 1982.

$ zig build
$ ./zig-out/bin/merle65 asm prog.S -l -

It grew a little past an assembler. In the box, and each of them usable as a library rather than only a program:

merle65 asm the assembler, with a listing, a symbol table and a cross-reference
merle65 dis a disassembler that writes Merlin source and checks itself by reassembling it
merle65 fmt a formatter, stdin to stdout
merle65 link a linker for the relocatable modules REL writes
merle65 lsp a language server — diagnostics, navigation, rename, quick fixes, inlay hints
tree-sitter-merlin/ a tree-sitter grammar for Merlin source
zig build oracle the real Merlin, on an emulated Apple IIe or IIgs, as the authority on what it should do

Where the manuals are silent, the oracle is asked and the answer written down. That is how the relocatable object format was recovered, and how a dozen smaller questions were settled — what it has said lists them.

Three releases of Merlin are covered, selected with --merlin: the 1984 one the manual documents, Merlin 8, and Merlin 16. Everything those manuals document is implemented — macros, conditionals, LUP, dummy sections, relocatable modules and the linker included. --cpu reaches from the 6502 to the 65816, and SWEET 16 is assembled inline once SW turns it on, as Merlin required.

Written in Zig 0.16. It depends on zf for the fuzzy matching behind two language server features, on zig-uri for the file: URIs the language server is addressed in, and on zig65 for the 6502 that runs USR routines and the Apple IIe that runs the oracle.

Home page: https://git.ocjtech.us/jeff/merle65

Using it as a library

Everything the command-line program does is a library first. A build tool, an editor plugin or a game's asset pipeline can assemble, disassemble, format and link without a process boundary.

$ zig fetch --save git+https://git.ocjtech.us/jeff/merle65
const merle65 = b.dependency("merle65", .{
    .target = target,
    .optimize = optimize,
}).module("merle65");

The assembler needs one thing from the outside world: something that can fetch a file's contents by name. That is a Loader, so a caller whose sources are in memory, in a zip, or on the far end of a network supplies its own rather than being tied to a filesystem.

var a: merle65.Assembler = .init(gpa, .{ .cpu = .@"65c02" }, loader);
defer a.deinit();
try a.assemble("prog.S");
if (a.diags.hasErrors()) return error.AssemblyFailed;

for (a.segments.items) |seg| {
    // seg.load_addr, seg.bytes, seg.name
}

LST FILE works the same way as SAV and DSK: the assembler collects what was listed and says what the file should be called, in listing_files, and writing it is the caller's. Nothing in the library touches a filesystem, which is what a Loader is for on the way in.

Afterwards segments holds the object code, listing the assembly listing, symbols the symbol table and diags everything that went wrong. objfile.writeSegment turns a segment into a raw binary, a DOS 3.3 file, Intel HEX or S-records; xref.build produces a cross-reference; disasm.disassemble goes the other way and disasm.verify checks it by reassembling; format.formatSource realigns source without changing what it assembles to; and link.read and link.link handle relocatable modules.

Four modules, so that nothing is carried for nothing

Module Needs For
merle65 nothing the assembler, disassembler, formatter and linker
merle65_lsp zf, zig-uri the language server
merle65_usr zig65 a 6502 for the USR directive
merle65_cli the three above the command-line program, which is not something to embed

The core has no dependencies at all, which is the point of the split: a project that wants to assemble should not have to fetch a fuzzy matcher, a URI parser and an Apple II emulator.

USR is why the assembler and the emulator can be separated at all. The directive hands assembly over to a 6502 routine the programmer wrote, and there is no way to honour that except to run it — so the assembler asks a usr.Runner and merle65_usr provides one:

const emulator = @import("merle65_usr");

var a: merle65.Assembler = .init(gpa, .{ .usr = emulator.runner() }, loader);

Without one, a source that reaches USR is told the directive is unsupported and assembly carries on.

Where things are

src/syntax/ the source language: lines, expressions, symbols, strings, and the formatter
src/isa/ the processors: opcode tables, addressing modes, cycle counts, SWEET 16
src/assembler/ the assembler and its directives
src/object/ object files, relocatable modules, the linker, the cross-reference, the disassembler
src/lsp/ the language server
src/emulator.zig the 6502 behind USR
src/cli/ the command-line program

Installing

With Nix, from the flake in this repository:

$ nix run git+https://git.ocjtech.us/jeff/merle65 -- asm prog.S -l -
$ nix profile install git+https://git.ocjtech.us/jeff/merle65

nix build produces result/bin/merle65, and nix flake check builds the package — which runs the full test suite as its check phase. To use merle65 from another flake, either take packages.default or apply overlays.default, which adds a merle65 attribute to nixpkgs:

{
  inputs.merle65.url = "git+https://git.ocjtech.us/jeff/merle65";

  outputs = { nixpkgs, merle65, ... }: {
    # ...
    environment.systemPackages = [ merle65.packages.x86_64-linux.default ];
  };
}

nix develop gives a shell with the Zig toolchain and the repository's other tooling, for working on merle65 itself.

Shell completions

zig build writes completions for fish and bash into zig-out/share, and the Nix package installs them, so they are picked up automatically from a profile:

share/fish/vendor_completions.d/merle65.fish
share/bash-completion/completions/merle65

They complete the subcommands, each command's own options, and the values of --format and --cpu; --include offers directories, and options taking an address or a range offer nothing rather than a misleading list of files.

They are generated rather than checked in, from the same option table the parser and --help read. Completions are otherwise a third copy of the option list, and the copy nobody looks at is the one that goes stale.

Usage

Five subcommands, each with its own options.

merle65 -- a Merlin-syntax assembler for the 6502, 65C02 and 65816

usage: merle65 <command> [options]

commands:
  asm    assemble Merlin source into object code
  dis    disassemble object code back into Merlin source
  fmt    realign source to Merlin's columns, stdin to stdout
  link   link relocatable object modules into a binary
  lsp    run as a language server on stdin and stdout

Run `merle65 <command> --help` for that command's options.

asm

usage: merle65 asm [options] <source.S>

  -o, --output FILE          where to write the result ('-' for stdout;
                             default: the input's basename)
  -f, --format FORMAT        bin | dos33 | ihex | srec      (default: bin)
  -l, --listing FILE         assembly listing ('-' for stdout)
  -s, --symbols FILE         write the symbol table ('-' for stdout)
  -x, --xref FILE            write a cross-reference listing ('-' for stdout)
  -I, --include DIR          directory searched by PUT (repeatable)
  -D, --define NAME=EXPR     predefine a symbol, or answer a KBD prompt
      --cpu CPU              6502 | 65c02 | 65816           (default: 6502)
      --org ADDR             origin address               (default: $8000)
      --merlin MERLIN        1984 | 8 | 16 | 16+            (default: 8);
                             which Merlin's directives to accept
      --strict               warnings become errors; enforce Merlin's
                             compatibility restrictions
      --sweet16              recognise SWEET 16 mnemonics from the start,
                             without the source saying SW
      --no-sweet16           do not recognise SWEET 16 mnemonics
      --usr FILE             a 6502 routine for USR to call, run on an
                             emulated processor ('ADDR:FILE' to place it)
      --no-interactive       fail on KBD instead of prompting
      --no-progress          do not draw a progress bar
  -h, --help                 show this message

dis

usage: merle65 dis [options] <object file>

  -o, --output FILE          where to write the result ('-' for stdout;
                             default: the input's basename)
  -f, --format FORMAT        the object file's format      (default: bin)
  -s, --symbols FILE         write the labels it invented ('-' for stdout)
      --cpu CPU              6502 | 65c02 | 65816           (default: 6502)
      --org ADDR             where the image loads; required for a raw binary
      --merlin MERLIN        1984 | 8 | 16 | 16+            (default: 8);
                             which Merlin's directives to accept
      --sweet16              recognise SWEET 16 mnemonics from the start,
                             without the source saying SW
      --no-sweet16           do not recognise SWEET 16 mnemonics
      --entry ADDR           another address code starts at (repeatable)
      --code RANGE           force a range to 6502 code     (repeatable)
      --sweet RANGE          force a range to SWEET 16 code
      --hex RANGE            force a range to hex data
      --text RANGE           force a range to text
      --word RANGE           force a range to addresses
      --symbols-in FILE      read names for addresses, as Merlin equates
      --no-equates           do not use the built-in Apple II names
      --no-verify            skip reassembling the result to check it
  -h, --help                 show this message

A RANGE is START:END or START:+LENGTH, in Merlin number syntax,
so +$14 is twenty bytes and +14 is fourteen.

fmt

usage: merle65 fmt [options] < source.S

  -h, --help                 show this message

Source is read from stdin and written to stdout. Only the
whitespace between fields changes, so the result assembles to
exactly the same bytes.

This is the formatter the language server offers, for editors that drive a formatting command rather than speak the protocol, and for a shell:

$ merle65 fmt < ragged.S > tidy.S
$ printf 'START LDA #1\n RTS\n' | merle65 fmt
START    LDA  #1
         RTS

It parses each line but assembles nothing, so it takes none of the options about the machine — no --cpu, no --merlin, no -I. Field text is copied verbatim and only the whitespace between fields moves.

usage: merle65 link [options] <module.REL> ...

  -o, --output FILE          where to write the result ('-' for stdout;
                             default: the input's basename)
  -f, --format FORMAT        bin | dos33 | ihex | srec      (default: bin)
      --org ADDR             origin address               (default: $8000)
  -h, --help                 show this message

Modules are placed in the order given, from --org, and every
EXT is resolved against the ENTs of all of them. A module is
what `REL` and `DSK` write.

lsp

usage: merle65 lsp [options]

  -I, --include DIR          directory searched by PUT (repeatable)
  -D, --define NAME=EXPR     predefine a symbol, or answer a KBD prompt
      --cpu CPU              6502 | 65c02 | 65816           (default: 6502)
      --org ADDR             origin address               (default: $8000)
      --merlin MERLIN        1984 | 8 | 16 | 16+            (default: 8);
                             which Merlin's directives to accept
      --strict               warnings become errors; enforce Merlin's
                             compatibility restrictions
      --sweet16              recognise SWEET 16 mnemonics from the start,
                             without the source saying SW
      --no-sweet16           do not recognise SWEET 16 mnemonics
  -h, --help                 show this message

No file is given: the editor supplies the documents over stdio.

Numbers on the command line use Merlin's own notation, so --org $300 and -DSLOT=%0110 mean what they would in the source.

Progress

Assembly reports progress through std.Progress, which draws a tree on stderr:

merle65
└─ pass 1 [231192/240002]
   └─ sub [113702/120000]

A node per pass, and a child node for each file PUT pulls in. The denominator counts source lines and grows as more files are included; a macro expansion or a LUP iteration is work but is not more of the file to get through, so it does not advance the count.

It draws only when stderr is a terminal, so piped or redirected output is unaffected without asking, and it never runs under lsp. --no-progress turns it off outright, including the IPC channel that reports to a parent build system.

Listing state and the address column

LST RTN returns the listing to the state it was in before the last LST, which is what lets a macro library turn listing off at its start and restore the caller's setting at its end without knowing what that setting was. Merlin remembers one value rather than a stack, so nesting two of them does not nest: both return to the same setting. The manual only implies as much — "previous to the last instance of LST" — but Merlin 16's own source says it outright, keeping the remembered value in a single zero page byte, LASTLST at $59, written by every LST that is not RTN and never by one that is. See what Merlin's source settled.

Merlin decides that an operand means RTN by one character: it takes the third character, masks it with $5F — which folds case — and compares it against N. Nothing else is looked at, so LST RUN and LST FIN restore the setting exactly as LST RTN does, and merle65 reads them the same way.

It is Merlin 16 only, and merle65 says so rather than following Merlin 8, which accepts the line and turns the listing off: Merlin 8's LST has no test for RTN at all, so it is simply an operand of three characters, and LST reads any operand of three characters or more as off. A source written for Merlin 16 and assembled by Merlin 8 loses its listing at the first library that tries to be polite about it.

No line that only controls the listing appears in the listing — not LST, TR, EXP, LSTDO, CYC, AST, SKP or PAG. AST and SKP still emit their asterisks and blank lines; what is dropped is the source line that asked for them.

TR ADR asks for the bank byte to be left off the addresses down the left of the listing, which under --cpu 65816 are six hex digits rather than four. A listing of a program that never leaves its bank has no use for two columns of repeated zeroes.

Setting the origin

A bare ORG "re-establishes the correct address pointer after a segment of code which has a different ORG" (8/16 manual, p. 92) — the counter goes to where it would have been had the displaced segment been assembled in place, so the bytes written down there still count against it. It is what lets a routine meant to be moved elsewhere be assembled at the address it will run from, without the code after it having to know how long the routine was:

         ORG  $2000
         ...              ;the program
         ORG  $0300       ;a routine that will be moved to $300
         ...
         ORG              ;carry on as though it had been assembled in place

The manual leaves the arithmetic to an example, so the oracle was asked for the rest. Only the most recent displacement is remembered: a second bare ORG in a row moves nothing. DUM records no displacement of its own, so a bare ORG inside a dummy section hands back the one the last real ORG left. And with no displacement to hand back the counter goes to the default origin plus what has been written, $8000 being where both Merlin and merle65 start.

Selecting the instruction set from the source

XC extends the instruction set one step per use — once for the 65C02, twice for the 65816 — and SW turns SWEET 16 on. Merlin required them because it cannot know what machine a program is destined for: using a 65C02 opcode is not by itself evidence of a mistake, so the programmer says which part they mean.

merle65 has --cpu and --sweet16 for the same job, and the two compose rather than compete: the command line says where the instruction set starts and the source raises it. So XC on a --cpu 65c02 assembly reaches the 65816, and a Merlin 16 source needs no XC at all — which is what Merlin 16 itself does.

The reason to have them is the other half of the manual's sentence: "if SW, and similarly for XC, is not selected then those opcode names can be used for macros". An instruction that is not selected leaves its name free:

         DO   0
PHX      MAC             on a 6502, PHX is a name like any other
         NOP
         <<<
         FIN
         PHX             the macro
         XC
         PHX             now the 65C02 instruction

This changed how the opcode column resolves. merle65 used to prefer a macro to an instruction of the same name; it now prefers the instruction, and falls through to the macro table only for a name no selected instruction claims — which is what a real Merlin does, and what makes XC and SW mean anything. The same rule already governed directives, where one the chosen release lacks leaves its name to the macro table.

The 65816

--cpu 65816 assembles for the 16-bit part, and for the 65802 that shares its instruction set in a 6502 pinout. Everything the processor adds is here: long addressing and its bracket indirection, the stack-relative modes, block moves, long branches and calls, and the register and flag instructions.

         LDA  $123456      long, bank byte last
         LDA  [$12],Y      long indirection, in brackets
         LDA  (3,S),Y      through an offset into the stack
         JSL  $018000      a call into another bank
         MVN  $01,$02      source first as written, destination first as encoded
         BRL  FAR          a branch that reaches the whole bank

In Merlin 16 a trailing L on the mnemonic asks for the long form, as a trailing anything else asks for the absolute form of a zero page one. Asking for it on a processor that has none is an error rather than a quiet fall back.

The 65816 is not a superset of the 65C02: it spends Rockwell's RMB, SMB, BBR and BBS opcodes on its long addressing modes instead. $07 is RMB0 on one part and ORA [$12] on the other, so those names simply do not exist under --cpu 65816.

Register widths

The 65816's registers are eight or sixteen bits wide according to two status bits, which REP and SEP change at run time. An assembler cannot follow that without interpreting the program, so MX is how it is told:

         MX   %00          both sixteen
         LDA  #$1234       three bytes
         MX   %11          both eight, which is the startup state
         LDA  #$12         two bytes

The high bit is M, the accumulator and memory, and the low bit is X, the index registers; a set bit means eight. Only immediate operands are affected, and only those that are values: REP, SEP and the signature bytes take a mask or a marker and stay one byte whatever the widths are.

MX %11 is where an assembly starts, which is why a IIGS program written for the full sixteen-bit mode opens with MX %00: "the start-assembly default of Merlin is for the 8-bit mode" (Machine Language for Beginners, p. 274, whose P16.SYSTEM does exactly that). merle65 starts there too.

A < or > picks a byte out of the value, and the operand's width is still the register's — so LDA #<LABEL with a sixteen-bit accumulator stores the low byte in the two bytes the processor is going to read.

A disassembly cannot recover any of this, since the widths are not in the object code, so merle65 dis reads every immediate as one byte.

Banks

ORG takes a full 24-bit address, so a program can be assembled into a bank of its own:

         ORG  $018000
HERE     LDA  HERE         absolute: it is in this bank
         LDA  $028000      long: it is not

The long form has to be asked for. Merlin does not widen an operand because its value has a bank of its own: an expression needing three bytes is cut back to two and the instruction reads that offset in the current bank. Merlin 16 takes two spellings of the request, and a third prefix for the opposite:

LDA LABEL direct page if it fits, absolute otherwise
LDA |LABEL (or !) absolute — two bytes, never the direct page
LDA >LABEL long — three bytes, whatever bank it names
LDAL LABEL the same, written as a fourth character

JML and JSL are always long and JMP and JSR always short, "whatever the bank" being the point: the manual says so outright (p. 85).

The prefixes are Merlin 16's. Under --merlin 8 a > in an operand is part of the name, which is what Merlin 8 v2.48 makes of it, with or without the 65802 selected. #> is untouched either way: that is the high byte of an immediate, told apart by the #.

Three sources agree on the rule and disagree on the spelling, which is worth recording because the disagreement is in the manual. The 8/16 manual's own table (p. 84) gives > as "two bytes (low word)" and a fourth prefix as three. Roger Wagner's listing (Machine Language for Beginners, p. 76) has LDA >LABEL1 assembling to AF 00 03 05 — long — beside LDA LABEL1 giving AD 00 03, and Merlin 32's source does the same, with a comment saying long addressing is only had "si on a mis du L ou du >". merle65 follows the two that agree, and takes | and ! for the absolute form as Merlin 32 does.

The listing's address column carries the bank, and TR ADR now has something to suppress.

Not every output format can record a bank. ihex writes an extended-address record when one changes and srec uses its three-byte address form, but a DOS 3.3 header has sixteen bits for the load address and nowhere to put more, so -f dos33 refuses a segment above $FFFF rather than writing it to the wrong place.

Running a USR routine

USR is the one directive that cannot be adapted to a host, only emulated: it hands assembly to a 6502 routine the programmer wrote, which reads the operand out of the assembler's workspace and emits object bytes by calling back into it (1984 manual, pp. 64-65). There is nothing to translate — the routine is machine code — so merle65 runs it, on zig65.

$ merle65 asm prog.S --usr scramble.bin

The routine sees the interface Merlin gave it: the operand at WORKSP ($280) in positive ASCII, PASSNUM, ERRCNT and OPNDLEN beside it in zero page, EVAL at $E5F9 to evaluate an expression and PUTBYTE at $E5F6 to emit a byte.

EVAL and PUTBYTE are Merlin's own code — the assembler merle65 replaces — so there is nothing to load at those addresses. They are trapped instead: execution reaching either is caught before an instruction runs there, the work is done against merle65's own evaluator and emitter, and a return is simulated. A routine cannot tell the difference.

The routine runs on both passes, as Merlin's did, so what it emits sizes the line the same way each time and a label after a USR lands where it should. PASSNUM says which pass, for a routine that cares.

One thing merle65 does that Merlin did not: a routine that runs a million instructions without returning is stopped and the assembly fails. Merlin would have hung, and on a host that is not worth reproducing.

ADDR:FILE places the routine somewhere other than $300.

Ten of them, from Merlin 16

"In Merlin 16, the USR opcode has been extended to allow up to 10 USR opcodes, USR0 through USR9. The Merlin 8 USR is equivalent to USR0 and is upward compatible. The number 0-9 is doubled and placed in the X Register and then a JSR $B6DA is done, the standard USR vector. At $B6DA you can place a JMP (VECTORTBL,X) instruction" (8/16 manual, p. 125). The doubling is what makes the digit an index into a table of addresses, and merle65 hands it over in X the same way, so one routine can serve all ten:

      USR1 something          ; entered with X=2
      USR5 something          ; entered with X=10

Merlin matches an opcode on its first three characters, so a release without the extension reads USR5 as USR and calls the one routine it has — which the oracle shows Merlin 8 doing, assembling USR5 1 without a word. merle65 does the same under --merlin 8 and says so, since a source writing a digit almost certainly meant it to count.

Reading Merlin's own source files

Merlin saved source two ways and its main menu kept them apart: L:LOAD SOURCE and S:SAVE SOURCE for its own format, R:READ TEXT FILE and W:WRITE TEXT FILE for plain text. Only the second is a text file in the sense a host means, so a source lifted straight off a disk image is not one — which undercut the premise that period sources assemble unmodified more than anything else here.

merle65 reads both, and needs no telling which is which:

$ merle65 asm KEYMAC.S -l -

The format is text with the high bit set, lines ended by $8D, and nothing else — a saved file is an image of the editor's buffer, which is why it loads at $0901 where Merlin keeps it. Spaces turn up both as $A0 and as plain $20, so the high bit comes off everything rather than the reader having to know which wrote it.

The manual describes the two kinds of file and never the byte layout, so this was recovered rather than read: from Merlin's own memory while driving it for the oracle, then confirmed against KEYMAC.S on a Merlin 8 disk.

A $8D line ending is the signature, since no text file has one. A high bit alone is not enough: a comment holding anything above ASCII would be corrupted by a reader that masked it off.

Cross-reference listing

-x writes every symbol, its value, and every line it appears on:

Cross referenced symbol table - alphabetical order:
COUT          =FDED 5* 14
DONE          =0310 13 17*
LOOP          =0305 12* 16

Cross referenced symbol table - numerical order:
START         =0300 10*
LOOP          =0305 12* 16

A * marks a line that defines the symbol; the rest are uses. The table is printed twice, alphabetically and by value, as Merlin's own XREF utility printed it: the first answers "where is this name used", the second "what lives at this address".

A reference inside a PUT or USE file is written as two numbers joined by a dot — 4.6 is line 6 of a file brought in by the directive on line 4 — since a bare line number would be ambiguous across the files of one assembly.

A local label is listed under the global governing it, as PRINT:LOOP, which is both how it would have to be written to name it and what tells two like-named locals apart. ] variables are left out, as they are from the symbol table: one is reassigned as often as its author likes and has no single value to report.

Merlin shipped this as a separate utility, XREF, because an Apple II had nowhere to keep the table (1984 manual, p. 148). Here the assembler already records every use and definition as it resolves them, so it is one more thing it can write.

Asking for one from the source

Merlin 16+ turned the two utilities into directives — "the cross-referencers are now selectable by directives in the assembler. The directives are XREF and XREFA, the latter giving an address cross-reference" (16+ supplement, p. 14) — and merle65 takes both under --merlin 16+:

      XREF                    a cross-reference by line, at the end
      XREFA                   the same, naming addresses instead

XREFA is what Glen Bredon's Xrefa.S heads "Address version": each mention is named by the address the line was assembled at rather than by its line number, so START =2000 2000* 2003 says the label is defined at $2000 and used by the instruction at $2003.

Three things differ from -x, and all three are the manual's:

  • It goes to the listing, not to a file of its own, since the manual files these among the listing commands.
  • Only the alphabetic list — "in contrast with the older versions, there is only the alphabetic list generated".
  • The listing has to be on when the assembly ends or nothing is printed, so a source that turns it off and does not turn it back on gets no table.

Both accept an optional operand of 0 or 1, 0 being the default. An operand of 1 asks for DO OFF areas to be cross-referenced as well, which merle65 warns it cannot do: it records a symbol as it resolves one, and a false DO region is never assembled, so there is nothing to record.

Output formats

Format Description
bin Raw object bytes, as Merlin's SAV wrote them.
dos33 Apple DOS 3.3 binary: load address and length, little-endian, then the bytes — a BLOADable B file.
ihex Intel HEX.
srec Motorola S-record.

ihex and srec carry addresses in-record, so all segments go into one file. bin and dos33 write one file per segment; a segment closed by SAV or DSK takes that directive's filename, and the rest are numbered from the output base.

Writing a disk that boots

--disk puts the assembly on a disk image the machine will start from:

$ merle65 asm hello.S -o HELLO --disk hello.dsk --donor dos33.dsk
$ merle65 asm boot.S -o BOOT.SYSTEM --disk boot.po --donor prodos.po

The donor is where the boot code comes from, and there has to be one. Neither filesystem's is anything merle65 could write: a DOS 3.3 disk boots because tracks 0 to 2 hold a boot sector and a copy of DOS behind it, and a ProDOS volume boots because blocks 0 and 1 hold a loader that goes looking for a file called PRODOS. Both are Apple's, neither can be reconstructed, and neither can ship here — so you supply a disk that already boots and merle65 takes the boot code off it, which is what INIT and the FILER did on the machine.

The donor also decides which filesystem you get: one holding a DOS 3.3 volume table of contents gives a DOS 3.3 disk, one holding a ProDOS volume directory gives a ProDOS volume. Sector order is read from each file's own name — .dsk and .do are DOS order, .po is ProDOS order — at both ends, so a .po donor can make a .dsk.

.2mg works at both ends too, and says what it is rather than being guessed at. A donor named that way is read through its sixty-four byte header, whatever the file is called; an output named that way is written with one, carrying the sector order, the block count and — for a DOS 3.3 disk — the volume number, so nothing downstream has to infer any of them from a name. That is the whole reason the format exists: an image that has been renamed once still says which way round it is. An 800K ProDOS .2mg donor gives an 800K disk, since the output is made the size of the donor.

$ merle65 asm hello.S -o PROG --disk hello.2mg --donor 'Merlin-8 v2.48 (DOS 3.3).dsk'
$ xxd -l 32 hello.2mg
00000000: 3249 4d47 4d52 4c36 4000 0100 0000 0000  2IMGMRL6@.......
00000010: fe01 0000 0000 0000 4000 0000 0030 0200  ........@....0..

MRL6 is merle65's creator code, $0000 the DOS sector order, and $01FE the flags: the low byte is volume 254 and the bit above it is what says the byte was filled in rather than left at nought.

Nothing else comes across. The disk is formatted fresh and holds the donor's boot code, the assembly's files and nothing besides: a BASIC.SYSTEM on the donor is not something an assembly asked for. Each segment becomes a file under the name its SAV or DSK gave it, or the output's own name for an assembly with neither.

What runs at boot is --boot NAME, or the only file there is when there is only one. The two filesystems arrange it differently, and merle65 does what the disks of the time do:

  • DOS 3.3 RUNs its greeting program, whose name lives inside the copy of DOS on tracks 0 to 2 rather than in the catalog. RUN wants a BASIC program, so pointing it at a binary boots as far as FILE TYPE MISMATCH — which is what happened here before the machine was asked. What a disk of the period does instead is greet a one-line Applesoft program that BRUNs the binary, and that is what merle65 writes: a HELLO of 10 PRINT CHR$(4)"BRUN NAME", greeted in the donor's DOS. It is called BOOT instead if the assembly has already used the name HELLO.
  • ProDOS runs the first .SYSTEM file in the volume directory. Nothing is patched: PRODOS is written first and the boot program second. The program has to be called something.SYSTEM and has to be a ProDOS 8 system program — ORG $2000, since that is where the loader puts a SYS file and where it jumps — and merle65 says so rather than writing a disk that stops at the loader.

--volume names the ProDOS volume or gives the DOS 3.3 volume number; without it the volume takes its name from the first file and the number is 254.

zig build bootdisk reads a .2mg the same way. It has one 5.25-inch drive, so an 800K image is not something it can be handed — it says so rather than failing obscurely.

The proof that any of this works is the machine, not the bytes: zig build bootdisk -- hello.dsk boots the image on an emulated IIe and prints what it put on the screen, and it is what both of the arrangements above were settled by. It needs MERLE65_APPLE2E_ROM and MERLE65_DISK_ROM, the same two files the oracle does.

$ zig build bootdisk -- hello.dsk
hello.dsk: after 60000002 cycles, 40 columns

  |]
  |MERLE65 BOOTED THIS DISK

Cycle counting

CYC prints what each instruction costs and a running total, to the right of the comment, as Merlin's own cycle timer did:

20FF: BD FF 10    19          LDA  $10FF,X                          4',0013
2102: 9D FF 10    20          STA  $10FF,X                          5 ,0018
2105: D0 F1       25 BACK     BNE  START                            2",0020
2107: D0 FC       26          BNE  BACK                             2',0022

CYC OFF stops; a bare CYC starts the total over. Two cycles cannot be known when a program is assembled, and Merlin marks both rather than guessing:

  • ' — one more cycle may be spent. On an indexed instruction that means the index crossing a page, which depends on a register; on a branch it means the branch being taken.
  • " — a branch that would also leave its page if taken, and so costs two more rather than one. Whether it is taken is a run-time matter, but where it would land is not, so this much can be told.

CYC AVE averages those into the total instead of leaving them out: half a cycle for a ', a whole one for a ". The total is kept in halves and shown rounded down.

CYC FLAGS is Merlin 16's, and shows the 65816's M and X register widths — "useful for verifying that register states are as you want them throughout a listing". They go where the running total goes, rather than beside it, and as two bare binary digits:

                     3          MX %00            0 ,00
002000: A9 34 12     4          LDA #$1234        3 ,00
                     5          MX %11            0 ,11
002003: A9 12        6          LDA #$12          2 ,11

The manual gives no sample of the format, so this came from the machine: it is what Merlin 16 3.53 prints, to the character.

Two other things came with it. Merlin 16 gives every listed line but a comment a cycle column, where Merlin 8 gives one only to an instruction — so an ORG and an EQU read 0 ,0000, and a DFB $EA reads 0 ,0002, its byte emitted and its cycles none. And under CYC FLAGS those lines carry the widths in force at them, which is what makes the column worth reading: the MX that changes them shows the value it set.

The timings are the processors' own, taken from MOS Technology's cycle-by-cycle account of every instruction (Hardware Manual, Appendix A; see the bibliography). They are stated in src/cycles.zig as rules rather than as a table of two hundred numbers, because that is how Appendix A states them: a section per class of operation — read, store, read-modify-write — and a subsection per addressing mode within it. It is also where the two marked cycles come from. A read indexed across a page fetches the wrong page first and pays for it, so A.2.5 gives "4 or 5"; a store forms the whole address before writing, so A.3.4 gives a flat 5 and merle65 never marks it. What no rule covers, Appendix A lists one instruction at a time, and so does merle65.

That includes the two things the 65C02 retimed while fixing them: the indirect jump lost the page-boundary bug and gained a cycle, and ASL, LSR, ROL and ROR indexed by X stopped writing to the wrong address first, so they cost six unless the index crosses a page. SWEET 16 is interpreted rather than executed and has no cycle count of its own; only the 6502 around it is counted.

Relocatable modules and the linker

A program too big to hold in the editor at once is assembled in pieces and joined afterwards. REL makes an assembly produce a relocatable object module instead of a binary, and merle65 link puts the modules together:

$ merle65 asm main.S -o MAIN.REL
$ merle65 asm print.S -o PRINT.REL
$ merle65 link --org '$0800' -o PROGRAM MAIN.REL PRINT.REL

Merlin 8 and later, which two manuals settle by omission: neither the 1982 SDS manual nor the 1984 one has REL, ENT, EXT or any mention of a linker. Glen Bredon's APPLE PI demo is dated 1982 and uses all three, but it shipped on a Merlin Pro disk, which is a different product from the Merlin those manuals describe.

Modules are placed in the order given, from --org, which is how a program says which of them holds its entry point.

Merlin's own linkers took a source file instead — "the linker uses a list, usually a text file, of all the modules to be combined" (Machine Language for Beginners, p. 281). On the DOS 3.3 disks that list is a plain file of names, which is what PI.NAMES is; by Merlin 16+ it had become a small language of its own, tabulated with a column per linker in the 16+ supplement (p. 24), where LNK names a module and SAV the output and ORG, TYP, DS, KND and VER say how to build it. merle65 takes the names on the command line, where a shell or a build system can supply them.

Why split a program up at all: "while the programmer is working on module A, he just tells the assembler that some other module will eventually have the external label READ in it", and the linker later "reconciles the calls to the READ and WRITE entry points" (pp. 280-281).

Inside a module the code is assembled as though it began at $8000. ENT names a label other modules may reach and EXT one this module expects to find elsewhere; both take the name in the label field and no operand:

PRINT    EXT
RNGERR   EXT
START    ENT

No manual to hand describes the file, so the oracle was asked, with --files, for the object of a source written to provoke one of each kind of reference. Glen Bredon's own APPLE PI linker demo, five modules and their objects, is on the Merlin Pro disk and served as the check.

A module is four parts, one after another:

header two bytes, the length of the code
code assembled at $8000
relocation dictionary four bytes per record, ended by $00
external symbol dictionary one entry per ENT and EXT, ended by $00

A relocation record is a flag byte, a two-byte offset into the code of the field to fix up, and a fourth byte. The flag says how much of the field to write and where the value comes from:

internal external
whole address, two bytes $8F $9F
low byte, from #< or DFB $0F $1F
high byte, from #> $4F $50

That last pair is not a typo. Every other external flag is its internal one plus $10, which would make the external high byte $5F; Merlin writes $50, and does so for every external whatever its index. It is reproduced as observed.

The fourth byte says which: for an external record, the index of the symbol in the dictionary; for an internal one, the low byte of the target's offset within the module. Records run in descending order of offset.

An external symbol dictionary entry is a flag byte of $40 | length for an ENT or $80 | length for an EXT, the name, and a two-byte value. An ENT's value is where it sits, $8000 plus its offset. An EXT's is $8000 plus the index by which the relocation records refer to it — the code itself always holds $8000 plus the addend, whichever external it means, so the record is the only thing that says which.

Only what is relocatable gets a record. A symbol given a value with EQU, a zero page address, an immediate constant and a relative branch are all left alone, which is why a module of nothing but those has an empty dictionary.

DSK streams the object as it assembles and patches both lengths — the module's own and the DOS file's — on the way out, so a module read before the file is closed claims to be one byte long.

The syntax

Source lines

LABEL OPCODE OPERAND ; COMMENT

The amount of whitespace between fields does not matter. A label must start in column one; a line beginning with whitespace has no label. A * or ; in column one makes the whole line a comment. An empty line is legal and behaves as SKP 1.

A label is at most 13 characters, its first character at least : in ASCII value and none below 0 — which is what admits ]VAR and :LOCAL while excluding + - * / . , # $ % & ( ). A label alone on a line equates it to the current address counter.

Only the first three characters of an opcode are examined, so PAGE is PAG. A fourth character of D marks a different name entirely (this is how a macro called INCD avoids colliding with INC); the Rockwell bit instructions carry a bit number there instead. Any other trailing character forces non-zero-page addressing:

        LDA  $10        ; 2 bytes, zero page
        LDA: $10        ; 3 bytes, forced absolute

BLT and BGE are accepted as pseudonyms for BCC and BCS.

Numbers and expressions

Form Meaning
100 decimal
$64 hexadecimal
%1100100 binary
'K' ASCII value, high bit off
"K" ASCII value, high bit on
* the present address

Operators are +, -, * (multiply), / (integer divide), ! (exclusive or), . (or) and & (and). Evaluation is strictly left to right with no precedence, and parentheses are not allowed — they mean indirect addressing. The manual's own example: 2+3*5 assembles as 25, not 17.

That rule held for every release merle65 assembles and stopped holding after them. Merlin 16+ added braces, which group and bring a precedence table with them, while leaving unbraced expressions left to right "for the reason of compatibility with old source files, and also because that is slightly faster" (16+ supplement, p. 21). So 1+2*3 is 9 and {1+2*3} is 7 — and merle65 agrees under --merlin 16+, where the table runs from the comparisons (loosest) through + -, * / and & . ! to unary minus (tightest), equal priorities associating leftwards.

The same release added <, =, > and # (not equal), which return 1 or 0, and took those three characters out of what a label may contain to make room. That is what --merlin 16+ is for: a source written before it may use them in a name, and one written after it may not.

Immediate operands select a byte with #<expr (low), #>expr or #/expr (high); a bare #expr takes the low byte.

Addressing modes

Mode Syntax
Implied / accumulator CLC, ROR — no A operand
Immediate LDA #$12
Zero page / absolute LDA expr — chosen automatically
Indexed LDA expr,X, LDX expr,Y
Indirect JMP (expr)
Pre-indexed LDA (expr,X) — zero page only
Post-indexed LDA (expr),Y — zero page only
Zero page indirect LDA (expr) — 65C02
Absolute indexed indirect JMP (expr,X) — 65C02
Bit branch BBR0 $12,LABEL, RMB7 $12 — 65C02 (Rockwell)

SWEET 16

SWEET 16 is Steve Wozniak's 16-bit interpreter for the Apple II: 6502 code calls into it and the bytes that follow are its instructions, operating on sixteen 16-bit registers. Merlin assembles it inline, and so does merle65.

SWEET16  EQU  $F689
         JSR  SWEET16     ;enter the interpreter
         SET  R1,$A034    ;pointers
         SET  R2,$9022
LOOP     LD   @R1         ;move a byte
         ST   @R2
         DCR  R3
         BNZ  LOOP
         RTN              ;back to 6502

Fifteen register operations, the register riding in the low nibble of the opcode:

1n SET Rn,expr 6n LDD @Rn Bn SUB Rn
2n LD Rn 7n STD @Rn Cn POPD @Rn
3n ST Rn 8n POP @Rn Dn CPR Rn
4n LD @Rn 9n STP @Rn En INR Rn
5n ST @Rn An ADD Rn Fn DCR Rn

and thirteen non-register operations: 00 RTN, 01 BR, 02 BNC, 03 BC, 04 BP, 05 BM, 06 BZ, 07 BNZ, 08 BM1, 09 BNM1, 0A BK, 0B RS, 0C BS. Branches take a one-byte relative displacement; everything else but SET is a single byte.

Registers may be written R3, r3 or plain 3 — "the R is optional" — and SET takes a space or a comma between its operands, so SET R3,LABEL and SET R3 LABEL are the same instruction.

NUL assembles as one byte of $0D, following Merlin rather than ROM SWEET 16, which would read $0D as a two-byte skip. The manual is explicit that this is intentional.

SWEET 16 needs SW before its mnemonics mean anything, which is Merlin's own rule and the point of the manual's remark that "if SW ... is not selected then those opcode names can be used for macros". Both are Merlin 8 and earlier: Merlin 16 has neither the mnemonics nor the directive, as the oracle found. Glen Bredon's PI.ADD, on the Merlin Pro disk, defines a macro called ADD and relies on it. --sweet16 starts an assembly with it already on, for a source that assumes it.

Disassembling is the other way round and defaults to on. There is no source to say SW, the question is what the bytes are rather than what an assembler was told, and the disassembler has its own evidence — a JSR to the interpreter. What it writes out carries an SW, so the source it produces assembles back. --no-sweet16 turns that off.

Either way it is refused under --cpu 65c02: Merlin's 65C02 module withdrew SWEET 16, and merle65 keeps that behaviour.

Which Merlin

Merlin grew over its life, and not every source is written for the same one. A file using ADR wants Merlin 16; a file using braces wants Merlin 16+; a file using SWEET 16 wants Merlin 8, the last release to have it. --merlin says which to assemble for, and assembling one as the other says so rather than quietly doing something else.

1984 8 16
the 1984 manual's 39 directives
REL, ENT, EXT, ERR \
STR, TYP, DS expr,value, DS \, EXP ONLY
USE, LSTDO, CYC, MX, XC, :-prefixed local labels
DAT, TTL, ADR, ADRL, LST RTN, TR ADR
@ in a LUP label
SWEET 16, and the SW that switches it on
a trailing L forces absolute addressing
a trailing L, or a > before the operand, asks for the 65816's long addressing

The default is 8, which differs from the 1984 release only by adding, so nothing written for the older manual stops assembling. That leaves 1984 for what it is good for: checking that a source really does keep to the older manual.

$ merle65 asm prog.S
prog.S:5:10: error: BAD OPCODE: "TTL" is a Merlin 16 directive; assembling as Merlin 8

A directive the chosen release does not have is not a directive at all, so its name is free to be a macro — which is what Merlin intends when a feature is off. The 8/16 manual makes the point about SW: with SWEET 16 unused, ADD and SUB are available as macro names.

Merlin 16 dropped SWEET 16 and gave a trailing L to long addressing, so under --merlin 16 neither SET R1,$3000 nor LDAL $10 is what it was. SW went with the mnemonics — it is the one directive a later release took away, and the only reason the table of them records a last release as well as a first — and what Merlin 16 ships instead is SW16.MACROS, a macro library spelling the SWEET 16 opcodes out. That is only possible because their names are free again, which is the manual's own point about SW arriving at its conclusion.

Pseudo-opcodes

All of the 1984 manual's directives are implemented:

  • Symbols and originEQU (=), ORG, OBJ, VAR, DUM, DEND, END
  • FilesPUT, SAV, DSK
  • FormattingLST, EXP, TR, PAU, PAG, AST, SKP
  • StringsASC, DCI, INV, FLS, REV
  • DataDA, DDB, DFB, HEX, DS, KBD, LUP/--^, CHK, ERR, USR
  • ConditionalsDO, ELSE, IF, FIN
  • MacrosMAC, EOM (<<<), PMC (>>>)

and some from later releases, which --merlin gates:

  • Merlin 8 and laterREL, ENT, EXT, ERR \, STR, TYP, DS expr,value, DS \, EXP ONLY, USE, LSTDO, CYC, MX, XC
  • Merlin 8 onlySW, which Merlin 16 dropped along with SWEET 16 itself
  • Merlin 16 and laterDAT, TTL, ADR, ADRL, LST RTN, TR ADR
  • Merlin 16+ onlySTRL, FLO, EXD, CAS, XREF, XREFA, XC OFF, braces with the comparison operators <, =, > and #, ASC #expression, DAT with an operand, LST FILE, hex data between strings rather than only after them, INV's lower case moved to the 80-column range, and @ in an operand

String opcodes take the operand's first character as the delimiter. A delimiter below ' (0x27) produces high bits on, anything else high bits off — so " and ' behave as they do in expressions, and /, (, ? and friends let a string contain quotes. INV and FLS are the exception and ignore the delimiter entirely: inverse and flashing characters live in the bottom half of the character set, and Merlin will not put them anywhere else.

Merlin 16+ moved three things here. Hex data may sit between strings rather than only after them, so ASC 'AB',8D,'CD' is five bytes; INV maps lower case to $60-7F rather than folding it to $20-3F, which is what the 80-column character set wants; and ASC gained a second job, writing a value out as decimal digits rather than a string:

         ASC  #123        31 32 33
         ASC  #"123       B1 B2 B3        negative ASCII
         ASC  #>123       20 20 31 32 33  right justified in five

STR is ASC with a leading byte counting the characters, "mainly intended for use with ProDOS which uses this type of data extensively" (8/16 manual, p. 109), with STRL beside it under --merlin 16+ writing a two-byte length word instead, for GS/OS (16+ supplement, p. 21). Hex appended after the string is not counted by either, which the manual warns about rather than refuses: STR 'HI',8D assembles to 02 48 49 8D, and a routine reading the length byte will not see the 8D.

Macros bind their arguments to ]1 .. ]8 by textual substitution, separated by ;:

        DO   0
POKE    MAC
        LDA  #]2
        STA  ]1
        <<<
        FIN

        >>>  POKE.$24;10

]-prefixed labels are variables: freely redefinable, with no forward references.

:-prefixed labels, from Merlin 8 onwards, are locals. A local attaches to the last ordinary label above it and can be referred to only until the next one, so the same name may be reused under as many globals as you like:

ONE     LDX  #8
:LOOP   DEX             ; :LOOP under ONE
        BNE  :LOOP
        RTS
TWO     LDY  #4
:LOOP   DEY             ; a different :LOOP, under TWO
        BNE  :LOOP
        RTS

Unlike a ] variable, a local may be branched to forwards as well as back. The manual's three restrictions hold: a local cannot appear inside a macro, cannot label a MAC or be EQUated, and cannot be the first label in a program — there would be no global for it to attach to. merle65 names the rule it broke rather than reporting the confusing error that would otherwise follow.

Labels inside a LUP

A LUP repeats its body, and every iteration writing the same label would be a duplicate. @ in the label column is Merlin's way out: "if the @ character appears in the label column, it will be increased by the loop count, thus A,B,C…etc. Since the loop count is a countdown, these labels will go backwards, i.e. the last label has the A" (8/16 manual, p. 122). So a LUP 3 around a LAB@ defines LABC, LABB and LABA, in that order and at ascending addresses — which the oracle confirms byte for byte.

The count is what limits it. Past 26 the letter would run off the end of the alphabet, and Merlin answers Bad label; a LUP 28 around a LAB@ stops at the first iteration. merle65 says the same thing in more words.

Merlin 16+ handles @ in the operand too: DFB "@" assembles as DFB "A", "just as you could use LABEL@" (16+ supplement, p. 22). Only from 16+ — both Merlin 8 and Merlin 16 3.53 leave DFB "@" as $C0, which the two halves of the oracle each confirm — so merle65 substitutes in the operand under --merlin 16+ and nowhere else. The comment after the operand is left alone.

Whether labels match by case

Merlin files labels case sensitively: Foo and FOO are two symbols, and the oracle shows Merlin 8 answering Unknown label to the second when only the first is defined. That is the setting Merlin ships — the PARMS bit Glen Bredon's Parms.S describes as "assembler is label case insensitive if on" is clear — and it is merle65's default.

Merlin 16+ added a directive to choose within a source: "CAS SE sets case sensitive; CAS IN sets case insensitive. The setting applies only for that assembly, and otherwise defaults to the PARMS file setting" (16+ supplement, p. 20). merle65 takes both, anywhere in a source, and does with them what the oracle says Merlin 16+ does — which is not what "case insensitive" sounds like.

The setting changes how a name is read, not how names are compared. With it on, Merlin uppercases a label as it reads it, where it is defined as much as where it is used, and the symbol table itself never changes. Three things follow, and Merlin 16+ was asked all three:

  • Bar EQU $55 written after a CAS IN is BAR in the symbol table, and LDA BAR and LDA Bar both find it.
  • A second spelling of a name already defined under the setting — bar after BAR — is Duplicate symbol, since both are BAR.
  • A name defined before the CAS IN keeps the spelling it went in under. So of a FOO and a Foo defined earlier, every later spelling finds FOO and the mixed-case one can no longer be reached at all — whichever of the two was written first.

And the setting is not put back between the two passes, because Merlin never puts it back: a LDA foo on a line above the CAS IN resolves to FOO on the second pass. merle65 agrees on the bytes for each of those, which is what the --gs+ oracle was used to establish.

Labels inside a macro

A label written in a macro body belongs to the expansion, not to the program. Two expansions of a body containing BNE NC and NC each branch to their own copy, and a JMP NC after them is an UNKNOWN LABEL — which is what Merlin 8 v2.48 does, and what lets a macro library define a label at all. It also means such a label does not govern the : locals around the call: the local after a macro invocation still belongs to the global above it.

Where a source may reach

Five directives take a path from the source rather than from whoever ran merle65: PUT and USE read a file, and SAV, DSK and LST FILE name one to be written. On an Apple II that was harmless — a ProDOS pathname reached the floppy in the drive. On a host it is not. Assembling somebody else's source with no restriction means PUT /etc/passwd puts a file you can read into your listing, and SAV ../../../../tmp/x writes where the source says.

So a path a source names is confined to the directory the source is in, plus any directory named with -I, and one that resolves outside them is refused:

$ merle65 asm proj/prog.S
proj/prog.S:2:14: error: BAD "PUT": "/etc/hostname" is outside the directory
being assembled; name it with -I, or allow any path with --any-path

The point of the split is that a directory reached from the command line was chosen by the person running merle65, and a path inside a source was not. A library equates file one directory over — which is what Merlin 16+'s ../ syntax exists for — is reached by naming it: -I ../lib. --any-path gives the restriction up altogether and returns the freedom Merlin had.

Only the names a source chose are confined. -o, -l, -x and the rest came from the command line and go where they are told.

A source-named output is written beside the source, not beside the working directory, so that a SAV FOO and a PUT FOO mean the same file as they did on Merlin's one floppy. For the ordinary merle65 asm prog.S those are the same place; they differ only when the source is somewhere else, and there the source's own directory is the useful answer.

The check is lexical: the root and the path are resolved with .. and . collapsed, and the result must be inside the root. Both are anchored at the same made-up directory first, because the two have to be compared in one frame of reference and neither is required to be absolute — the root is often just ., since that is where merle65 asm prog.S puts the source. The anchor's one job is to be somewhere .. cannot climb out of, so that ../etc/passwd lands outside it and is refused rather than being pulled back in the way a plain / would pull it. A symlink already in the tree that points out of it is not caught, because catching that means asking the filesystem what a path really is, and for a file about to be written there is nothing there to ask yet. That is the right trade for what this defends against — the attacker is a source file, which chooses names and cannot plant a symlink, and anyone who can already write one into your tree can do worse than redirect a SAV.

A library embedder gets none of this unless it asks: FileLoader confines nothing until its roots are set, and a caller supplying a Loader of its own has its own policy to keep. Assembler.LoadError carries PathNotAllowed so that one can say which it meant.

Deviations from Merlin

Some of Merlin's behaviour was inseparable from the Apple II it ran on. Where a faithful implementation is impossible, merle65 does the nearest useful thing:

Directive On the Apple II Here
PUT f reads T.f from disk resolves f, then T.f, then f.S, against each -I directory and then the working directory
USE f reads f as PUT does, but keeps it in memory identical to PUT — merle65 caches loaded files anyway, and its PUT already allows the macro definitions and nesting that made USE necessary
SAV f writes the object so far to disk closes the current segment and names it f
DSK f streams following object code to disk directs following code to a segment named f
KBD prompts the operator during assembly prompts on stdin; answer it ahead of time with -DNAME=VALUE, or fail with --no-interactive
USR JSRs a user-supplied 6502 routine runs it on an emulated 6502 given one by --usr; warns without one
PAU waits for a keypress no-op
OBJ chose where Merlin buffered object code in Apple RAM no-op — it affected neither addresses nor bytes
ERR (expr) read Apple memory at assembly time warns; unsupported
CHK a checksum byte the running exclusive-or of the segment's object bytes
operand + comment an error past 64 characters a warning, since that was a buffer size on the Apple II (an error under --strict)
DUM * Merlin 16+ only; earlier releases wanted an absolute address accepted in every dialect, since DUM here takes any expression and * is one
PUT ../f Merlin 16+ only, and defeated past a 64-character path resolved by the host filesystem in every dialect, relative to the file that wrote the PUT

The T. above is Merlin's prefix for a text file. Merlin kept source in a binary format of its own — L:LOAD SOURCE and S:SAVE SOURCE handled that, appending .S — but PUT requires plain text, which is what the separate R:READ TEXT FILE and W:WRITE TEXT FILE commands existed to produce. So PUT SUB looked for a DOS file actually named T.SUB. Merlin let you suppress the prefix by writing a punctuation character in front of the name, which it then discarded.

merle65 has no such distinction — everything it reads is text — so it tries the name as written first, and falls back to T.f and f.S so that a file carrying either period convention is still found.

PUT also searches the directory of the file doing the including, before the -I directories and the working directory. Merlin had a single flat disk and no such notion, but on a host a PUT written inside a subdirectory plainly means the file next to it.

Two of Merlin's restrictions were memory-management artifacts and are relaxed here: a PUT file may contain macro definitions, and may itself PUT another file. Merlin's 65C02 module also withdrew the shorthand of writing a macro name in the opcode column; merle65 allows it on both CPUs unless --strict is given.

Disassembling

merle65 dis turns an object file back into Merlin source, the way the bundled SOURCEROR did.

$ merle65 dis prog.bin -f dos33 -o prog.S
*
* Disassembled by merle65.
*
HOME     EQU  $FC58
COUT     EQU  $FDED
*
         ORG  $0300
         JSR  HOME
         LDY  #$00
L0305    LDA  L0311,Y
         BEQ  L0310
         JSR  COUT
         INY
         BNE  L0305
L0310    RTS
L0311    ASC  "HELLO, WORLD!"

It reads any format merle65 writes. dos33, ihex and srec carry their own load addresses, so only a raw binary needs --org. A discontiguous input gets an ORG per run.

Banks

--org may name a bank, and an image may occupy several: a 128K Apple IIgs ROM is banks $FE and $FF, and it disassembles in one go.

$ merle65 dis 'Apple IIGS ROM 01.bin' --org '$FE0000' --cpu 65816 --merlin 16 -o rom.S

Only the banks an image touches are held — allocating all two hundred and fifty-six would be three hundred megabytes to read a IIgs ROM — so what it costs is proportional to the image. A label carries its bank when it has one, LFE0002 rather than L0002, so the same offset in two banks does not give two labels the same name; a bank-zero image is spelled the way it always was. A JSL from one bank into another is followed and named, which the one-bank map could do neither of.

Two arithmetics, and they really are different. An image is laid out straight: 128K at $FE0000 reaches into $FF0000, because that is where the bytes sit. A program counter does not carry — the byte after $FEFFFF is $FE0000 — so tracing stops at the top of a bank, which is what the processor does.

Which bank a sixteen-bit operand names is three answers, and the data sheet gives all three (W65C816S data sheet, 2018, pp. 1617). A JMP or JSR loads the program counter and leaves the program bank register alone, so its target is in the bank the instruction is in. Anything else using a, a,x or a,y is a data reference, where "the Data Bank Register contains the high order 8 bits of the operand address" — a register that cannot be followed without running the program, so bank zero is assumed. Both indirect forms read their pointer from bank zero, which the data sheet says outright.

Bank zero for a data reference is the assumption that makes LDA $FDED in a IIgs ROM read as COUT rather than as a byte of the ROM itself, and it is what the register holds unless a program has gone out of its way. Where it is wrong the operand still reassembles to the same three bytes — only the name would be — and --symbols-in says otherwise.

It says what it needs

A disassembly is only useful if it assembles back, so it carries what a source can carry to say what it needs:

  • XC, once for a 65C02 and twice for a 65816 — "XC [ enable the 65C02 option ] / XC (twice in a row) [ enable the 65802/65816 option ]" (8/16 manual, p. 126). Without it a 65C02 mnemonic is a macro name, and the reassembly fails on the first one.
  • SW, if any SWEET 16 was found, for the same reason.
  • A comment naming the Merlin, when it is not the Merlin 8 that asm assembles as by default. That one cannot be a directive: nothing in a Merlin source names the assembler reading it, so a 65816 disassembly — which needs the > prefix, and therefore Merlin 16 — can only say so in a comment and leave --merlin 16 to the person running it.

Telling code from data

Nothing can do this perfectly — a jump table reached only through an indirect jump looks exactly like data — so merle65 traces control flow from the entry points and lets you correct it. Tracing follows jumps, calls and branches, stops at RTS, RTI, BRK, JMP and BRA, and treats whatever it never reaches as data. --entry adds a starting point; --code, --sweet, --hex, --text and --word force a range outright:

$ merle65 dis prog.bin -f dos33 --text '$311:+$14' --word '$400:$420'

A range is START:END or START:+LENGTH in Merlin's number syntax — so +$14 is twenty bytes and +14 is fourteen.

Two rules come straight from SOURCEROR: a JSR to the SWEET 16 interpreter switches into SWEET 16, and the SWEET 16 RTN switches back. Text picks ASC or DCI by the high bit, exactly as the assembler reads them.

The vectors

A ROM says where its code starts, and it is the only thing in the file that does: "The Reset vector address is 00FFFC,D", with the rest of the table beside it (W65C816S data sheet, 2018, p. 30, tables 5-2 and 5-3). So tracing starts from them as well as from the image's own beginning, which for a ROM is often not code at all — the Apple II Plus ROM opens on four kilobytes of nothing, and tracing it from $B000 reaches one BRK and stops. With the vectors it comes out as three thousand lines of the Monitor and Applesoft instead of twenty kilobytes of HEX.

They are looked for at the top of every bank the image holds, not only bank zero, because a machine may map another bank there: an Apple IIgs keeps the Apple II firmware in bank $FF and maps it as bank zero, so the vector a reset reads is that bank's $FFFC. Nothing in a raw image says which bank will go at the bottom, so each is asked.

A vector is only followed where what it points at is in the image, and it is only written as an address — DA LFA62 rather than HEX 62FA, which is the one thing worth knowing about the top of a ROM — where tracing then reached that address. Four bytes that happen to sit at $FFFE are not an interrupt vector. --no-vectors turns the whole thing off.

Reading straight through

Tracing only claims what it can show is code, which is the right default: what comes out as an instruction really was reached from somewhere. For a ROM with no way in — no vectors, entered from a slot page, dispatched through a table the tracer cannot follow — it can also claim nothing at all, and a card's firmware as eight kilobytes of accurate and useless HEX is not a disassembly.

--linear is the other way of doing it, and it is what a disassembler that does not trace does: decode whatever tracing never reached, straight through. What is really data comes out as instructions and is wrong; the round trip still holds, because the bytes are the bytes, and a --hex, --text or --word region says where not to do it. It runs after tracing rather than instead of it, so everything the tracer proved keeps its place.

Use it where tracing finds nothing. Where tracing works, what it leaves alone is usually real data — Applesoft's error messages, a jump table — and reading those as instructions is a step backwards.

Names

Every address the image refers to gets a label, so control flow reads as names. Built-in equates cover the well-known Apple II monitor entry points, soft switches and zero page locations, which is what SOURCEROR's label files were for; --no-equates turns them off. --symbols-in reads your own names, written as ordinary Merlin equates so the same file can be PUT into a program, and -s writes out the labels the disassembler invented in that same form — so a first pass can be edited and fed back into a second.

It checks itself

Every disassembly is reassembled and compared against the input before it is written out. A byte decoded wrongly, or a label that turns out to sit inside an instruction, shows up here and nowhere else. --no-verify skips it.

The reassembly starts from a plain 6502, whatever was disassembled, so that the XC above is doing its job: handing the assembler the processor would prove only that the decoding was self-consistent, which is the weaker half of what this is for.

Every ROM in the collection

The check above is run over every ROM in the ~/dev/Apple ROMs collection, which is a hundred and nine files of somebody else's code: Apple II, IIe, IIc, IIgs and III system ROMs, Disk II and Super Serial and mouse and clock and memory card firmware, four clones, an Applicard's Z80, a mouse card's 6805, several character generators, keycode tables and a megabyte of Epson printer ROM. Each is disassembled and then reassembled through the command line — not by the internal check — and compared against the ROM.

All of them come back byte for byte. The ones that are not 6502 code at all come back too, as HEX, which is the right answer for them.

Between them they come to two hundred and twenty thousand lines of instructions. They came to about four thousand before the vectors were followed.

That collection keeps the disassemblies beside the dumps, and bin/disassemble there regenerates and rechecks the lot; its README gives the invocation for each. It is worth running against a merle65 that has changed anything in the disassembler.

It found four things, none of them in the decoding:

  • merle65 asm prog.S was refused outright when the source was in the working directory, because the confinement check resolved the root and the path separately and std.fs.path.resolve leaves a relative path relative — so prog.S was not under .. Both sides are now anchored at the same made-up directory, which is somewhere .. cannot climb out of.
  • A 65C02 or 65816 disassembly did not carry XC, so it would not reassemble.
  • --org $FE0000 was evaluated at sixteen bits and silently became $0000.
  • The map held one bank, so a IIgs ROM had to be cut in two before it could be read at all — and cutting it lost every call between the halves. See banks.
  • Nothing followed the hardware vectors, so a ROM whose image does not begin with code — which is most of them — disassembled to HEX and nothing else. Accurate, and of no use to anybody. See the vectors.

Language server

merle65 lsp speaks the Language Server Protocol on stdin and stdout, so an editor can drive the same assembler interactively. No source file is given — the editor supplies the documents. --cpu, -I, --org, -D and --strict still apply.

Capability What it does
Diagnostics Assembles as you type and reports errors and warnings under Merlin's own names — BAD OPCODE, UNKNOWN LABEL, BAD ADDRESS MODE — spanning the offending field
Go to definition Jumps to where a label, equate, variable or macro is defined
Find references Every place a symbol is used or defined, from the assembly rather than a text search
Document highlight The same, narrowed to the open file, with uses and definitions distinguished
Inlay hints Each instruction's cycle count at the end of its line
Workspace symbols Every symbol, fuzzy-matched against the query, a local labelled with the global it belongs to
Rename Renames every occurrence, with a local's scope respected
Document symbols An outline of the labels, equates and macros in a file
Hover A symbol's value and definition site; an instruction's addressing modes with their encodings; a directive's meaning, from the manual
Completion Instructions legal for the selected CPU and directives in the opcode column, symbols and macros in the operand — locals only from the scope the cursor is in
Quick fixes On an UNKNOWN LABEL, the defined names it might have been and an EQU for it; on a BAD BRANCH, a way to reach the target; on a NESTING ERROR, the closer to add or the stray one to delete
Formatting Realigns a document to Merlin's columns, as merle65 fmt does from the command line

A minimal client configuration is merle65 lsp as the command, with .S files given a language id of your choosing.

How it decides what to assemble

A PUT-included fragment usually cannot assemble alone, because the symbols it needs come from the file that includes it. The server therefore indexes PUT directives across the workspace and, when the file you are editing is included by another, assembles that ancestor instead and routes the diagnostics back to each file. A file nothing includes is assembled on its own. The index is rebuilt when a document is opened or saved, so a PUT added mid-edit is picked up on the next save.

Where find-references and highlighting get their answers

Neither is a search of the text. The assembler records each name as it resolves or defines it, so what comes back is what was actually assembled:

  • A :LOOP is listed only under the global it belongs to. Two globals with a like-named local each get their own answer, which a text search cannot do.
  • A name inside a false DO, or in a macro body never invoked, is absent — it was never assembled, so it refers to nothing.
  • A name in a PUT file is reported against that file, not the one assembled.
  • A reference the assembler saw only inside a macro expansion is dropped rather than misreported. The expanded line is synthesised, so its columns correspond to nothing in any file, and a location that cannot be pointed at is worse than none.

Every occurrence knows whether it read a symbol's value or set it, which document highlighting hands to the editor so the two colour differently. That distinction matters most for a ] variable, which may be assigned as often as its author liked:

]COUNT   EQU  0              ; write
]COUNT   EQU  ]COUNT+1       ; read, and write
         LDA  #]COUNT        ; read

The symbol table keeps only the latest definition of a name, so the assignments are recorded as they happen rather than being recovered from it afterwards. includeDeclaration is honoured on find-references, and takes every assignment with it, not just the last.

Cycle counts as inlay hints

Each instruction's cost appears at the end of its line, in the same notation the listing uses:

FAR      NOP                2
BACK     BNE  FAR           2"
         LDA  $10FF,X       4'
         CYC
         JSR  $FDED         6 ,0006
         RTS                6 ,0012

Unlike the listing, the hints need no CYC in the source: an editor should not have to have the file edited before it can show them. The running total is the exception, having no point to count from without one, so it joins the hint only where a CYC is counting.

4' is only obvious to someone who has read the manual, so each hint carries a tooltip saying what its marker means. Code the assembler never assembled — a false DO, an uninvoked macro body — has no hint, and neither does a line a macro synthesised, which has no column of its own to hang one on.

Renaming

Rename edits the occurrences the assembler recorded, so renaming one global's :LOOP leaves every other global's alone — which find-and-replace cannot promise, the name being identical in each.

A rename must keep the kind of label it started as. Merlin's three kinds are told apart by their first character and each is scoped differently, so :LOOP to LOOP is not a rename: it would leave the scope it belongs to, and the locals after it would attach to something else. The same goes for adding or removing a ]. The editor is told why rather than being allowed to make either mess, and prepareRename answers first, so an editor need not prompt for a name it is going to refuse.

Merlin's own label rules still apply: at most 13 characters, and none below 0.

Workspace symbols

Every other feature works from one analysis: the file you are editing, or the one that PUTs it, assembled together. Workspace symbols cannot, because the workspace holds programs you are not editing — so this assembles each of them.

An entry point is a file nothing includes, and the index knows them all: it walks the workspace for .S files and T. files as well as tracking what is open, so a program never opened still contributes its symbols. A file included by two programs is assembled under each but listed once.

Assembling everything is too much to do on every keystroke, so the result is built when first asked for and kept until a document changes. Typing in a symbol picker does not change a document, which is exactly the run of requests that needs it.

The query is matched by the same fuzzy ranking that suggests a correction for a misspelled label, so the two behave alike, with the same subsequence rule. An empty query, which is what a picker sends when it opens, returns everything. Locals are included and carry the global they belong to as their container name, since that is all that tells five identically named :LOOPs apart.

Quick fixes for a branch that cannot reach

A 6502 branch reaches 128 bytes either way, and a program that outgrows that has one standard repair: invert the condition and jump over a JMP to where the branch was going.

LOOP     BEQ  FAR      ;go when equal      →   LOOP     BNE  *+5   ;go when equal
                                                        JMP  FAR

Five bytes past the branch is the instruction after the jump — two for the branch and three for the jump — so *+5 lands exactly where falling through should. The label stays because something branches to it, and the comment because it is the author's.

It costs two bytes and a cycle, so it is offered and never marked preferred: that is a decision for whoever is reading the code, not for the editor.

BRA is unconditional, so there is nothing to invert and a JMP goes where it was going. On the 65816 BRL reaches the whole bank in one instruction, and is offered first where the processor has it, with the inversion behind it.

Quick fixes for an unknown label

A label that resolves to nothing is usually a misspelling of one that exists, so UNKNOWN LABEL carries code actions offering the names it might have been:

START    JSR  CUT          ; UNKNOWN LABEL: "CUT"
                           ; Change "CUT" to "COUT"

The ranking is zf's, the fuzzy finder's, which suits assembly better than it might sound — labels are short, upper case and often abbreviated, which is what a fuzzy finder is tuned for.

What zf finds is worth stating plainly, because it decides which typos get a suggestion. zf requires the mistyped name to appear in the candidate as a subsequence: every character present, in order. So it catches a name typed short of its full spelling — CUT finds COUT, DELY finds DELAY, STAR finds START — and one typed in the wrong case, since Merlin folds case in labels. It does not catch a transposition or a substituted character: COTU and PRIMT return nothing, because their characters are no longer in order. An edit-distance measure would be the other way round, catching those and missing the abbreviations.

A : local is only ever matched against locals under the same global. One from another scope is not a correction, because editing to it would still not assemble. At most five are offered, best first; a single candidate is marked preferred, so an editor may apply it without showing a menu.

Behind the spellings is one more offer, never preferred: the name may be no misspelling at all but an address the source has not got round to naming, so an EQU for it is written into the equate block at the top — after the last EQU if there is one, above the ORG otherwise, which is where Merlin sources put them and where the assembler's first pass wants them. The value is $0000, for the programmer to fill in. A : local gets no such offer: "you cannot EQUate a local label" (8/16 manual, p. 79), so it would trade one error for another.

Quick fixes for a construct that is not balanced

A NESTING ERROR comes in two shapes and each has one repair.

A closer with nothing to close — FIN, ELSE, DEND, --^, <<< or EOM where nothing is open — is reported on its own line, and deleting that line is the whole fix: it closes nothing, so nothing depends on it. The edit runs through to the start of the next line so that no blank line is left behind. This one is marked preferred.

An opener never closed is reported against the file rather than a line, and the missing closer is offered at the end of it. Where it goes is a guess, so it is never preferred — but a short source usually wanted it there, and a long one at least gets to see what is missing. It is offered only when exactly one thing is open: DO and IF nest, so with two unclosed, which one a new FIN closes is the programmer's decision.

Notes

  • Documents synchronise in full: assembly sources are small and the assembler is fast enough to re-run on every change.
  • The server negotiates UTF-8 position encoding when the client offers it, since Merlin source is ASCII and byte offsets then need no conversion. It falls back to the specification's default of UTF-16.
  • Diagnostics arising inside a macro expansion span the whole line rather than a column range: the expanded text is synthesised, so its columns do not correspond to anything in the file.
  • Formatting only ever rewrites the whitespace between fields. A formatted file assembles to exactly the same bytes as the original, which the tests check directly.

A tree-sitter grammar

tree-sitter-merlin/ holds a tree-sitter grammar for Merlin source, for editors that syntax-highlight through one.

What shapes it is that Merlin's columns carry meaning: a label exists only where the line begins with one. Whitespace is therefore deliberately not in the grammar's extras, so that "column one" is simply "the first token on the line" and no external scanner is needed. The rules follow Line.parse exactly, because a grammar that disagreed with the assembler about where a field ends would highlight one program and assemble another.

The other Merlin peculiarity it has to honour is that an operand ends at the first space, and everything after it is commentary whether or not a ; says so:

         LDA  #1 + 2      assembles LDA #1, with "+ 2" for a comment

The exception is a string, whose first character is its delimiter and which may contain spaces. Merlin takes any character as a delimiter, so a string operand can only be recognised by knowing the opcode is one of the string directives — which is why those are named in the grammar, and the rest of the vocabulary is not.

Inside the operand it parses Merlin's expressions, which is easy in exactly the way Merlin makes it easy: there is no operator precedence, evaluation runs strictly left to right, and there are no parentheses to change it, so an expression is a flat sequence rather than a tree. Numbers in all three bases, character constants, * for the present address, the seven operators and the addressing modes around them all get nodes of their own.

The addressing modes are named, including the 65816's long indirection: Merlin writes that in brackets, LDA [PTR], to keep it apart from the sixteen-bit form in parentheses. Brackets cost the grammar something, because ] is also what starts a variable's name and the manual allows one anywhere in a label after the first character. A symbol here may therefore begin with ] but not contain one — enough for [ ]COUNT ], which is legal, and not enough for a name with a ] in the middle, which the assembler accepts and nobody writes.

Three directives take operands that are not expressions and would be misread as them, so the grammar names those too: PUT and its kind take a bare filename (T.EQUATES is not a symbol and an operator and another symbol), HEX takes digit pairs, and a macro call's arguments are separated by whatever character follows the macro name — commonly a ;, which is therefore not a comment there.

The strongest test of it is not the corpus but somebody else's source. Glen Bredon's own — INTERPRETER.S, MACED.S, MACASS.S, MACEXEC.S, ASS.BNK1.S and ASSMAIN.S, the Merlin assembler written in itself, seven thousand seven hundred lines of it on the source disk — parsed with eighty-three error nodes in it the first time it was tried. Four causes, each a real Merlin idiom the grammar had no rule for, and each one merle65 itself assembles:

  • STR, STRL and KBD take a delimited string and were not in the list of directives that do. STR 'ED' and KBD "Assemble from M-16" are his.
  • Hex may follow a string with no comma between. ASC "text"8D00 is how a message and its terminator are written, and the grammar wanted the comma.
  • A character constant may be its own delimiter, so # followed by three double quotes is the double quote itself, and the same with apostrophes is the apostrophe.
  • A macro parameter may stand where the string or the hex bytes go, since Merlin substitutes before it parses: ASC ]1 and HEX ]2 inside a macro body.

That took it to thirty-six, and what is left is written up under what the grammar still cannot parse.

Five sets of queries come with it. queries/highlights.scm is generated from the assembler's own instruction and directive tables by zig build tsquery, for the same reason the shell completions are: a hand-written copy of the opcode list is the copy that goes stale. The other four are written by hand, because they describe the shape of the language rather than its vocabulary.

highlights.scm what each field and each opcode is
textobjects.scm comments, macro arguments and the items of a data directive's list
indents.scm one unit, which is the opcode column
tags.scm every label as a definition, every use of a name as a reference
rainbows.scm the parentheses of the indirect addressing modes

What they can say is limited by the grammar being flat, and that is deliberate: a line is not a node, because nothing in Merlin nests. So there is no function or block to select, no body to indent, and the parentheses that get rainbow colours never contain another pair. Each file says as much where it would otherwise look thin by accident.

$ nix develop                      # tree-sitter, node and a C compiler
$ cd tree-sitter-merlin
$ tree-sitter generate && tree-sitter test

The generated parser is checked in, under tree-sitter-merlin/src/: editors compile it themselves and do not run tree-sitter generate, so a checkout without it is of no use to them. CI regenerates it and diffs, which is what keeps it honest.

What the grammar still cannot parse

One shape out of Glen Bredon's sources, seven error nodes:

 PRINT 8D87;"Insert"

PRINT is a macro of his own, called by name with no >>> in front of it, and its arguments are separated by the semicolons. The grammar cannot see that. A bare macro call is spelled exactly like an instruction — the name is the whole of the difference, and the grammar has no macro table to look it up in — so the line is read as an opcode and an operand, and in an operand a semicolon starts a comment. That reading is right for every other line in the language and wrong only here.

Fixing it would mean either an external scanner that tracks the macros a file defines, or making a semicolon an argument separator everywhere, which would take the comment on every LDA LABEL;count with it. Neither is worth seven lines, so the grammar recovers at the next line and the rest of the file highlights.

Three other shapes used to be listed here, thirty-six error nodes between them. They are gone:

  • ERR and IF take their operand whole. Both were being read as expressions, and neither is one: ERR \$300-* is a limit for the placed program, ERR (*-BASE)-256 puts parentheses where the rest of the language starts an addressing mode with them, and IF X.Xyz compares two characters by position with the one between them ignored. The grammar now names them opaque_directive and takes the rest of the field as one text node, which is what the assembler does with it.
  • A macro call's argument may be quoted, and then it may hold spaces and semicolons: "If such strings contain spaces or semicolons, they must be delimited by single or double quotes" (8/16 manual, p. 132). Quotes and nothing else, which is what that sentence says and what makes the rule safe — a string directive's delimiter may be any character at all, and admitting those in an operand would read LDA $2000 as a string delimited by dollar signs. Hex bytes may follow the closing quote as they may follow ASC's string.

Chasing the second of those turned up a bug in the assembler rather than the grammar: IF was reading its operand as though the separator meant something. It does not, and the oracle said so in four sources — see what it has said.

Using it from Helix

Helix wants three things: where the language server is, where the grammar is, and where the highlight queries are. All of it goes in ~/.config/helix/languages.toml:

[[language]]
name = "merlin"
scope = "source.merlin"
file-types = ["S", { glob = "T.*" }]
comment-tokens = ["*", ";"]
language-servers = ["merle65"]

[language-server.merle65]
command = "merle65"
args = ["lsp"]

[[grammar]]
name = "merlin"
source = { path = "/path/to/merle65/tree-sitter-merlin" }

The two file types are Merlin's own conventions: sources end in .S, and a file copied from an Apple II carries the T. prefix that marked it as text.

The queries are not part of the grammar as far as Helix is concerned — they live in its runtime directory, under the language's name:

$ mkdir -p ~/.config/helix/runtime/queries/merlin
$ cp tree-sitter-merlin/queries/*.scm ~/.config/helix/runtime/queries/merlin/
$ mkdir -p ~/.config/helix/runtime/grammars
$ hx --grammar build

indents.scm puts a line one indent unit in, which is the opcode column when the unit is set to match — so add that to the language entry above:

indent = { tab-width = 9, unit = "         " }

A line that carries a label starts in column one instead, which is a single outdent: the same keystroke it cost in Merlin's own editor, where tab moved between these columns and shift-tab moved back.

Then check it:

$ hx --health merlin
Configured language servers:
  ✓ merle65: /path/to/merle65
Tree-sitter parser: ✓
Highlight queries: ✓

Three things that trip this up:

  • hx --grammar build shells out to cc, which a NixOS system has no reason to provide. Run it from nix develop in a merle65 checkout, which has a compiler.
  • runtime/grammars/ has to exist. Helix links the parser into it and will not create it, failing with a linker error that does not say so.
  • It builds every configured grammar, not just this one, so it reports a couple of hundred failures for the bundled grammars you have not fetched. Those are unrelated; look for the merlin line.

For an editor other than Helix, the same three pieces apply: run merle65 lsp as the server, point the editor at tree-sitter-merlin for the grammar, and give it queries/highlights.scm.

The real-Merlin oracle

Where the manuals are silent, merle65 has had to choose, and the README says so each time. zig build oracle settles those questions by asking Merlin: it boots Merlin 8 on an emulated Apple IIe — zig65 provides the machine, a Merlin disk image provides the assembler — assembles a source with both, and compares the object code.

$ export MERLE65_APPLE2E_ROM=~/roms/APPLE2E.ROM
$ export MERLE65_DISK_ROM=~/roms/341-0027-a.p5
$ export MERLE65_MERLIN_DISK=~/disks/Merlin-8.dsk
$ zig build oracle -- tests/cases/msgout.S
tests/cases/msgout.S: agreed, 33 bytes

The three files are named by the environment because none of them can be shipped with merle65. The disk ROM is the P5 boot ROM; the P6 sequencer, which sits next to it in a ROM collection and is the same size, is not 6502 code and boots nothing.

--gs asks the other Merlin instead, on the other machine:

$ export MERLE65_IIGS_ROM=~/roms/'Apple IIGS ROM 01.bin'
$ export MERLE65_MERLIN16_DISK=~/disks/merlin16.po
$ zig build oracle -- --gs tests/cases/merlin16.S
tests/cases/merlin16.S: agreed, 27 bytes

With --gs merle65 assembles as --merlin 16, since comparing a Merlin 16 assembly against an 8-bit dialect would be comparing the wrong two things. See a Merlin 16 to boot for where the disk comes from, since no release image of one seems to have survived.

--gs+ is the third half, and it is the same machine with a megabyte in it:

$ export MERLE65_IIGS_ROM=~/roms/'Apple IIGS ROM 01.bin'
$ export MERLE65_MERLIN16PLUS_DISK=~/disks/'MERLIN 16+ 4.68 auto.2mg'
$ zig build oracle -- --gs+ tests/cases/merlin16.S
tests/cases/merlin16.S: agreed, 27 bytes

That one boots a disk somebody pressed after all — the Merlin 16+ release that boots itself, which is a GS/OS application rather than a ProDOS 8 one and carries GS/OS to start it. The memory is what that costs: GS/OS will not run in the 256K a IIgs came with, so the emulated machine is given the megabyte expansion card a real one needed, and the assembler comes up on its own menu. With --gs+ merle65 assembles as --merlin 16+. See what Merlin 16+ has said for the questions that half was built for.

The source is not typed in. Merlin keeps it between two zero page pointers — $0A-$0B for the start and $0E-$0F for the terminator past the end — as high-bit ASCII with each line ended by $8D, so the harness writes it there directly. That has to happen after E enters the editor, which resets those pointers. An OBJ $6000 is prepended so the object lands somewhere known; merle65 treats OBJ as a no-op, so the same line is harmless to the side being checked. Which memory the object lands in differs: on a 128K IIe it is buffered in auxiliary memory, so reading it back does not go through the address the processor would see, and on a IIgs Merlin 16 keeps the buffer in bank $01, so OBJ $6000 means $016000. Both were settled by assembling something short and looking for its bytes in every bank; moving the OBJ moved them.

Merlin 16+ moved all of that and had to be found again the same way. Its editor is the same program with the same three SWEET 16 registers, moved wholesale to a direct page at $0800, and the source it points at is in a bank the Memory Manager handed it: a line typed into the editor turned up at $05:0040, which is the Source: A$0040 the main menu prints, and the only long pointer to it anywhere in memory was at $00:0812TXTP, R9, where it has always been. OBJ it ignores altogether, buffering the object at the start of a bank of its own. Because that bank is the Memory Manager's to choose rather than anybody's to state, the harness proves the buffer is the empty one the editor just made before it writes a source into it, and says so plainly if it is not.

One more change is made to the disk, in memory: bit 0 of the flags byte in PARMS, which is what puts E :Editor, cmd mode on the main menu. Merlin 16+ ships with it clear, as Merlin 16 does — a Merlin 16 to boot has why that matters — and setting it is how the byte was confirmed to be the same one Glen Bredon's Parms.S documents, since no Merlin 16+ source survives to read it off. The file on disk is never written.

Where the two differ, Merlin is right by definition. That is what an oracle is.

A Merlin 16 to boot

The IIe half boots a disk somebody pressed, and so does the Merlin 16+ half. Merlin 16 has none to boot: no release image of that one seems to have survived, and it is not a matter of looking harder. What did survive is the Merlin 16 3.53 distribution as a ShrinkIt archive — MERLIN.SYSTEM and its overlays alongside the sources they were built from — so the disk is built rather than found:

$ zig build merlin16disk -- work.po MER16.Src.353.SHK merlin16.po
merlin16.po: /MERLIN16, 20 files, 112 of 280 blocks free

The donor is any bootable ProDOS volume in block order, for the two things that cannot be written from nothing: its loader blocks and its copy of PRODOS. Everything at the root of the archive that is not a source goes on — the assembler, the two editors, the three linkers and the utilities — and the volume is a 140K image named /MERLIN16. It boots as the machine's hard disk rather than a floppy, which is what the IIgs's firmware finds first and what reads a block at a time instead of a track.

One thing is changed on the way. PARMS is written with bit 0 of its flags byte set, which Glen Bredon's own Parms.S documents as "if set, enables command line editor access". Merlin ships with it clear, and with it clear E at the main menu opens the full screen editor, where ASM is three characters of source rather than a command. It is a setting a user of the real thing would change in exactly this way, and it changes nothing about how the assembler assembles.

What it has said

The questions it was built for are settled, each by a source written to provoke one, and merle65 was right about most of them:

  • LSTDO starts on. A line inside a false DO is listed, with no address or bytes beside it. merle65 defaults to listing them because that is what it did before LSTDO existed; that turns out to be Merlin's default too.

  • CYC AVE accumulates half cycles and truncates. Three indexed reads and a NOP give 4',0004, 4',0009, 4',0013, 2 ,0015 — merle65's listing to the character.

  • CHK skips a DUM section, which a source with one inside it proves by checksumming to the same byte as one without.

  • A second CHK covers only what followed the first, and SAV restarts it too. Given 01 02 CHK 03 04 CHK, Merlin writes 03 then 07, which rules out a running total that skips the checksum bytes — that would give 04. It does not distinguish restarting from including the checksum byte in the total, and no experiment can: the running total up to a CHK is that byte, so exclusive-oring it in gives zero. The two are the same operation. Across a SAV, Merlin saved 01 02 and 03 04 07, so each segment is checksummed on its own.

  • A line that only controls the listing is not listed. Put LST, TR, EXP, LSTDO, CYC, AST, SKP or PAG between two HEX lines and Merlin lists the two HEX lines and nothing else. AST and SKP still print what they were asked to print — the asterisks, the blank lines — but alone, with no source line above. merle65 listed all of them and now lists none, which was worth four fewer lines in two golden listings.

  • LST takes any operand of three characters or more as off. LST NO leaves the listing on and LST OFFX turns it off, which is the rule merle65 already had, and it is why Merlin 8 makes LST RTN mean LST OFF.

  • DAT is Merlin 16 only. Merlin 8 v2.48 answers Bad opcode in line: 3. merle65 had it as Merlin 8 and later, and now refuses it below --merlin 16.

  • That the long addressing forms have to be asked for. Not the oracle's doing -- this is a Merlin 16 question, so it took a printed listing and a reimplementation to settle. It is recorded here because the manual's own table disagrees with both; see banks.

  • What a bare ORG returns the counter to, which the manual leaves to an example. Three sources settled it: the displaced segment's bytes still count, only the most recent displacement is remembered, and DUM records none of its own. See re-ORG for what each one showed.

  • The whole of the relocatable object format, which no manual to hand describes at all. --files dumps what an assembly wrote to disk, and one source per kind of reference was enough to read the file off it. See relocatable modules.

  • INV and FLS ignore the delimiter. INV "A" and INV 'A' are both $01 where ASC "A" is $C1, so the rule that a delimiter below ' turns the high bits on — which the manual states for the string opcodes as a group — does not reach these two. merle65 was applying it, and putting inverse characters in the half of the character set that has none.

  • A label written in a macro body belongs to the expansion. Two expansions of a body containing BNE NC and NC each branch to their own copy, and a JMP NC after them is Unknown label, though an ordinary duplicate label is still Duplicate symbol. merle65 shared one, which made a macro invoked twice a duplicate — and made the macro's label govern the : locals around the call. It is what stopped Glen Bredon's PI.DIV from assembling.

  • @ in a LUP counts down, and stops at 26. A LUP 3 around a LAB@ gives LABC, LABB, LABA at ascending addresses, so the last iteration is the one that gets the A; a LUP 28 around the same line is Bad label in line: 4. merle65 had no @ handling at all, in labels or operands, and now agrees byte for byte on the first of those.

  • @ in an operand is not touched. DFB "@" inside a LUP is three $C0 under Merlin 8, so the operand handling really is the Merlin 16+ addition the supplement says it is.

  • Labels match case sensitively. Foo EQU $12 followed by LDA FOO is Unknown label, which is what Merlin ships and what merle65 already did — and what CAS IN exists to change.

  • A branch may cross the wrap at the top of the address space. BVS $FFAA assembled at $0000 is 70 A8 under Merlin 8 — eighty-eight bytes back, measured in the sixteen bits the processor does the arithmetic in — where merle65 measured it in a wider type, made it sixty-five thousand forward and called it out of range. A branch that really is too far is still Bad branch there, so only the wrap changed. The fuzzer found it by disassembling random bytes; the oracle said which of the two was right.

  • IF compares two characters by position, and the separator between them means nothing. The manual writes the directive as IF ]1=x, and merle65 read the = as the comparison it looks like. It is not one: Merlin takes the first character of the operand and the third and asks whether they are equal, whatever the second happens to be. Four sources settled it — IF X.Xyz is true, IF X.Ayz false, IF XXXyz true, and IF AB,Ayz false, which is 11 33 FF out of Merlin 8. The last of those is the one that rules out any reading where the separator selects the operands. The tree-sitter grammar found this, by not being able to parse the line.

It has also confirmed two deviations the README already named, which is worth as much as a new answer: Merlin errors on a source line past 64 characters where merle65 warns, so --strict is the faithful setting; and SWEET 16 mnemonics are Bad opcode without SW first, which merle65 now starts an assembly with off rather than on.

One limitation of the harness rather than a finding: a source using PUT or USE needs the included files on the disk, and the oracle writes one source into memory with nowhere to put the others.

What the IIgs half has said

Merlin 16 on an emulated IIgs answers the questions the eight-bit machine could not be asked at all:

  • LST RTN remembers one value, not a stack. Given LST OFF, LST ON, LST RTN, a line, LST RTN, a line, Merlin 16 lists neither line. A stack would have unwound to the setting before the LST OFF and listed the second. This is the question the README carried as its one open to-do; the source said so first and the machine confirmed it. See listing state.

  • RTN is one character, and it is the third one. LST RUN restores the setting exactly as LST RTN does: Merlin reads OPERAND+2, masks it with $5F and compares it against N, and looks at nothing else. merle65 matched the word and now reads the operand the same way.

  • Merlin 16 has neither SWEET 16 nor SW. SW alone is Bad opcode in line: 10, and so is a SET R1,SOURCE with no SW in front of it, so the mnemonics are not merely switched off but gone. merle65 already knew the mnemonics were — and had them wrongly available again under --merlin 16+ — but was still accepting the directive. SW is now the one entry in the directive table with a last release as well as a first. What Merlin 16 ships instead is SW16.MACROS, a macro library spelling the SWEET 16 opcodes out, which is only possible because their names are free again.

  • Merlin 16 does not touch @ in an operand either. DFB "@" inside a LUP is three $C0 there too, which puts the operand form squarely in 16+ and nowhere earlier.

  • BRL reaches across the wrap as well, and reaches everywhere. BRL $FF00 at $0000 is 82 FD FE, a displacement of minus two hundred and fifty-nine, so the sixteen-bit displacement is added with a sixteen-bit wrap and every address in the bank is in range from every other. merle65 had a range check on it that could only ever be wrong; it is gone.

And two agreements worth as much: tests/cases/merlin16.S, which is 65816 code, assembles to the same twenty-seven bytes under Merlin 16 on a real 65816 as merle65 produces, and cycles.S and msgout.S agree under both assemblers at once.

What Merlin 16+ has said

Everything --merlin 16+ does came from the 16+ supplement's prose and nothing else until the --gs+ half of the harness existed, so the first thing asked of it was whether the prose had been read correctly. It had:

  • FLO is the SANE extended format, to the byte. 1.0, 2.3852e-10, -0.5, 0 and π to fifteen digits assemble to the same fifty bytes under Merlin 16+ as merle65 writes. This is the one merle65 had the least to go on: the supplement says only that the operand is "converted to the 10-byte SANE extended floating point number" (16+ supplement, p. 22) and leaves the format, the rounding and the decimal conversion to Apple's numerics manual.
  • Braces group and bring their precedence with them, and the comparisons return 1 or 0. 1+2*3 is 9, {1+2*3} is 7, 1+{2*3} is 7, {2+3*5} is 17; 1=1, 1<2, 2>1 and 1#2 are each 1, and {1+1=2} is 1. With STRL "HI" — a two-byte length word, 02 00, then the string — and a DFB "@" inside a LUP, merle65 agrees on all nineteen bytes.
  • The six ASC # forms are what the manual prints, 31 32 33 through A0 A0 B1 B2 B3, which settles a help file on the 16+ disk itself that glosses > as the high byte and puts the quote on the wrong side of it. See strings.
  • ASC #>"123 is not a form. Merlin 16+ answers Illegal char in operand, which is what merle65 does with it, and the code carried a note to ask this the day a Merlin 16+ could be run. It has been asked.

And one it disagreed with, which merle65 has since been changed to match:

  • CAS may be written after a label is defined, and what it does then is not case-blind matching. merle65 used to refuse it there, reasoning that changing how names match once the table holds some would have to rehash it. Six sources put to Merlin 16+ show it never rehashes anything: it uppercases a label as it reads it, so the switch costs nothing, a name defined earlier in mixed case is stranded rather than merged, and a reference above the CAS line still resolves under it on pass two. merle65 now does the same and agrees on the bytes of all six. See whether labels match by case.

What Merlin's own source settled

Glen Bredon's source for both assemblers survives — the Merlin 8 2.59 and Merlin 16 3.53 distributions, each a ShrinkIt archive of the .S files the assembler was built from — and where a behaviour is about the listing rather than the object, reading it is worth more than another experiment.

  • LST RTN remembers exactly one value. Merlin 16 keeps it in a single zero page byte, LASTLST at $59. The LST handler in Ass.Bnk1.S writes it on every LST whose operand is not RTN, and never on one that is, so two nested LST RTNs both return to the same setting rather than unwinding one each. That is the reading merle65 already had, on the strength of the manual's singular wording; it is now the assembler's own.
  • RTN is recognised by one character. The handler reads OPERAND+2 — the third character of the operand — masks it with %01011111 and compares it against N, and looks at nothing else. So LST RUN and LST FIN restore the setting too, an operand of fewer than three characters cannot mean RTN at all, and case does not matter because the mask folds it. merle65 now reads the operand the same way instead of matching the word.
  • Merlin 8 has no RTN. Its LST is three instructions with no test for it, which is why LST RTN there means off, exactly as the oracle found by experiment.

The same archives hold Merlin 16's Opcodes.S, whose directive table has DAT, STR, ADR, ADRL, TYP, LSTL and the rest of what the 8/16 manual documents — and none of STRL, FLO or EXD. So this is Merlin 16, not Merlin 16+, and no source for that one has turned up. What it does is asked of the release itself instead; see what Merlin 16+ has said.

Documentation

Zig writes the API documentation out of the source, so it says what the code says:

$ zig build docs          # into zig-out/docs
$ zig build docs-serve    # and read it at http://127.0.0.1:8000/

One bundle per module, under an index saying which is which and what each costs to depend on. A server rather than a file, because the generated viewer fetches sources.tar and main.wasm at run time and a browser refuses those from a file:// page — the same reason zig std runs one. -Ddocs-port=N moves it.

The published copy is at https://jeff.ocj.page/merle65/, rebuilt from main on every push.

Testing

$ zig build test

This runs the unit tests and a set of golden-file comparisons that drive the real command line tool over the fixtures in tests/cases — assembling each, disassembling the result back to source, and linking a pair of relocatable modules that need each other, which is the only way to prove the two halves of the module format agree.

The language server is covered by full sessions driven through in-memory buffers: messages in, messages out, with the responses parsed and asserted. The formatter carries a property test — every fixture must assemble to identical bytes before and after formatting.

The unit tests reproduce the manual's own worked examples, whose expected output the manual prints: the DFB $34,100,LAB1-LAB2,%1011,>LAB1-LAB234 64 DE 0B 09 example (p. 60), the LUP table builder (p. 62), the DUM/DEND IOB layout (p. 55), the MOVD conditional macro (p. 69), the nested TRDB/TR and POKE/HTAB macros (p. 74), the SWAP macro (p. 76), and the 65C02 command processor (p. 168). The opcode tables are checked to contain exactly the 151 documented 6502 opcodes and the 59 the 65C02 addendum adds, with no collisions.

Every push runs all of that, plus zig fmt --check, reuse lint, a build of each --merlin dialect and each --cpu, the Nix package, and the tree-sitter grammar — regenerated and diffed, so a directive added to the assembler and not to the highlight queries is caught rather than noticed months later.

Beyond the fixtures there is the oracle, which assembles a source with the real Merlin and compares, and one check that needs neither: Glen Bredon's APPLE PI linker demo. Its five sources, five object modules and linked binary are all on the Merlin Pro disk, so merle65 can be held to producing his bytes from his source — which it does, module by module and then linked.

Fuzzing

Everything above is input somebody wrote. tests/fuzz.zig is the other half: properties that have to hold for input nobody wrote at all, one per thing that reads or writes a file.

  • An assembly of anything terminates, reports and frees. Whatever the source says, the assembler finishes, says what was wrong with it, and gives back what it took. It may refuse; it may not crash, hang or leak.

  • Formatting is idempotent and keeps the meaning. format(format(x)) is format(x), and where x assembles, format(x) assembles to the same bytes. That is the formatter's whole contract.

  • A disassembly reassembles. disasm.verify puts the generated source back through the assembler and compares it against the image it came from; for any bytes at all it must find no difference.

  • A module that reads can be linked. link.read takes its lengths and offsets out of the file it is reading, and the linker rather than the reader is what validates them, so the pair is fuzzed together.

  • Several segments in one file read back where they were put. writeCombined puts them all in one Intel HEX or S-record file, which means the records carry their own addresses — including the bank, which each format says differently. Read back, every byte has to be where it was written.

  • Modules an assembly wrote link, or are told why not. The other end of the same code, and the end that matters: a module a fuzzer writes out of whole cloth is refused before any linking happens, so these are assembled — up to three fuzzer-chosen REL sources, whose ENT and EXT dictionaries and relocation records are merle65's own. What comes back has to be the modules laid end to end from the origin, each told where it went. A unit test beside it links a pair by hand, one exporting a name and one importing it, and requires the reference to come out pointing at the right place.

  • An object file reads back as the bytes that were written. Raw binary, DOS 3.3, Intel HEX and Motorola S-records, each written and then read: the checksums, the record splitting and the DOS header are checked by using them rather than by looking at the text. The comparison is by address, since a format may divide a segment into records however it likes.

  • Reading an object file of anything terminates. The other direction, and the one that reads what somebody else's tool wrote.

  • A cross-reference of any assembly can be built and written, in either order and either style, however badly the source assembled — what xref walks is the occurrences the assembler recorded, and a line it could not assemble has recorded some of them anyway.

  • A donor and an assembly make a disk, or a reason why not. The donor is laid out properly and then poked at a few bytes, so most inputs get past the first check and into the filesystems; the files, their names, their sizes and the boot arrangement are the fuzzer's. What comes back has to be a disk of the kind it says it is, with every file on it — through the 2IMG wrapper and the sector order, if those were asked for.

  • A USR routine runs, stops, and says how it ended. The one place merle65 executes what it was handed rather than reading it: USR gives assembly over to a 6502 routine, and honouring that means running one. The routine is the fuzzer's, the address it loads at is the fuzzer's, and the source that calls it is written by the target so that a USR is certainly reached — up to four of them, USR or USR0 through USR9, with operands for EVAL to read. It may run away, execute something that is not an opcode, rewrite its own return address, or ask EVAL for an expression past the end of the operand; none of that may crash, hang or leak the assembler.

    The routine is assembled from the fuzzer's choices rather than taken as raw bytes, and the difference is the target working or not. Made of random bytes, 199,987 of 200,000 runs stopped on an illegal instruction, eight returned, and not one byte was ever emitted — so PUTBYTE, EVAL and the return path, which are the whole of the interface, went untested. Built out of real opcodes from merle65's own table, with the two trapped calls and RTS given a share of their own, the same budget returns twelve thousand times and emits four million bytes. A raw byte is still one of the choices, so what a routine may do with the processor is no more constrained than it was.

  • No name a source writes reaches outside the roots. The security property of where a source may reach, checked the way a security property has to be: a file is put where nothing is allowed to look, and the target watches whether anything looks there. It does not work out whether a path escaped — that would be the containment check marking its own homework — it places the file above the working directory, which is the one spot no root can legitimately hold, and the operand of every PUT is the fuzzer's. Both shapes the command line makes are covered: merle65 asm main.S, where the root is . and an escape is a single .., and merle65 asm proj/main.S, where it is a longer climb.

  • Merlin's own source format reads as text or not at all. A file lifted off a disk image is high-bit ASCII ended by $8D, and what comes out the other side must have neither the high bits nor the returns left in it.

  • A symbol file reads back as the equates it holds, and writes back out again, whatever was in it.

  • The language server answers a stream of anything or refuses it, and every reply it writes is framed and parses as JSON. That second half is the point: a hover, a rename's edits and a diagnostic all carry the document's own text back out, so a byte from the source has to survive being put in a JSON string.

  • A session of real requests at made-up positions. The editor's half of a conversation, built properly — initialize, didOpen with a fuzzer-chosen source, then hovers, definitions, references, completions, highlights, renames, code actions, inlay hints, symbols and formatting — with the positions coming from the fuzzer. A line and a character that are not in the document are what an editor sends when it is a message behind, and are the likeliest way into an index nobody bounds-checked. A plain unit test beside it builds the same shape of session with a position that is in the document and requires an answer, so that the target cannot quietly become vacuous.

$ zig build fuzz                                    # the checked-in corpus
$ zig build fuzz-run -- --seconds 300               # fuzz them
$ zig build fuzz-run -- --target format --seed 12345
$ zig build fuzz --fuzz=1M -Dfuzz-filter=format     # Zig's fuzzer, one target

Each fuzz test is named fuzz <target>: …, so the word after fuzz names one for either fuzzer: -Dfuzz-filter matches on it and so does --target.

zig build test runs them all against a small corpus of sources, images, modules and object files kept beside them, so the properties are checked on every push even where no fuzzer is running.

That last one needs the devshell's Zig, which is patched, and the patch is worth writing down: Zig 0.16.0 cannot build a test executable in fuzz mode at all. Its own compiler/test_runner.zig hands what @errorReturnTrace() returned — a builtin.StackTrace — to std.debug.writeStackTrace, which wants a debug.StackTrace; the two are different types, and the call is on the path taken only under -ffuzz, so zig build --fuzz fails to compile for any project with a fuzz test in it. The fix is one word, writeErrorReturnTrace, which takes exactly the type in hand and is what the other three call sites in that file use. flake.nix applies it to a symlink farm of the standard library and points ZIG_LIB_DIR at that, with --replace-fail so that the day Zig ships the fix this fails loudly rather than patching something else.

A second defect in the same release is left alone, and it is worth knowing about before reading the output. Nothing populates the fuzzer's table of program counters — the coverage file comes back with pcs_len of zero whether or not the library module is built with -ffuzz, which build.zig asks for anyway. So --fuzz=N always ends with corrupted coverage file: pcs_len was zero, and --fuzz on its own eventually panics in the build runner's coverage thread on pcs[1..] of an empty slice. Neither is a finding. A finding says input saved to '.zig-cache/f/crash' and prints the error above the report; the crash file is the thing to look at, and zig build fuzz-run -- --input reads it. What the patched toolchain buys, then, is Zig's structure-aware input generation — std.testing.Smith deciding values at each call site rather than bytes being flipped — which is what found three of the five bugs below. It is not coverage-guided fuzzing until that second defect is fixed too.

zig build fuzz-run is a loop of merle65's own over the same four targets, for running without that patched toolchain and for looking at a saved input again. It has no coverage feedback; what it has instead is mutations of the corpus, a watchdog thread to catch a source that never finishes assembling, and a memory cap so that a LUP 65535 is an error.OutOfMemory rather than a dead machine. Both fuzzers read the same input format, so a finding from one is reproduced by the other with --input.

It has found five things so far, and the first two came out of the loop above before Zig's fuzzer would build at all. The first is a branch across the address space's wrap, which the oracle then settled. merle65 measured the distance to a branch target in a type wider than sixteen bits, so BVS $FFAA at $0000 — eighty-eight bytes back, and 70 A8 on the machine — looked like sixty-five thousand forward and was refused. Merlin 8 assembles it, and Merlin 16 assembles the same shape of BRL across the wrap as 82 FD FE; both now agree with merle65 byte for byte. The fuzzer found it by disassembling random bytes and failing to reassemble them, which is exactly what that property is for.

The second is a carriage return that is not the last character on its line, which broke the formatter's idempotence. formatLine trimmed a trailing \r and then trimmed trailing spaces separately, so given BN\r it removed the space, left the return at the end of the line, and trimmed that on the next pass — two passes to reach a fixed point where the contract says one. It trims the whole run of trailing whitespace now. Nothing a well-formed file contains provoked it; a half-converted Apple II text file would, since those end their lines with a return and nothing else.

Three more came from Zig's fuzzer within a minute of the toolchain patch making it runnable, all three in the disassembler and all three on the 65816:

  • A long address was written without the > that asks for one. Six digits of address do not make an operand long — Merlin cuts one back to two bytes unless the source says otherwise, which is what banks sets out — so LDA $E36A86,X reassembled three bytes shorter than the instruction it came from. Fixing it turned up a second bug behind it: --merlin 16+ took neither spelling of the request, because two dialect tests were written as equality against Merlin 16 where the ordering wants includes, so LDAL there quietly assembled the short form.
  • BRL disassembled as a branch to its own address. The long branch fell through to the case that gives up on working a target out, so a displacement of minus 2487 came back as zero.
  • An image that runs past the top of the bank crashed it. Segment.load_addr is twenty-four bits wide because a segment may name a bank, and adding the offset to it in that width indexed a sixty-four kilobyte map at 65536. merle65 dis --org '$FF00' over a 512-byte file was enough to reach it from the command line. Bytes past the top wrap into the bottom now, which is what the processor does with an address.

And the sixth was an ownership trap, found on the first run of the target written for it: readSymbols handed back an ArrayList's items, a slice shorter than the allocation behind it, which no caller could free unless it happened to be passing an arena — as the command line does, which is why nothing had noticed. It returns an owned slice now, and freeSymbols releases it and the names in it, the way link.free and xref.free already did for their own results.

The seventh and eighth are the same bug in two formats: merle65 wrote object files it would not read back. A segment above $FFFF — which --cpu 65816 and an ORG $018000 are enough for — goes into Intel HEX as a type 4 extended-address record and into S-records as an S2, and the reader took neither: it accepted only type 0 records and only S1, both with a comment about not wanting to mislocate anything. So merle65 asm --format ihex could write a file merle65 dis --format ihex called invalid. The reader honours both now, and a banked file round-trips through either format.

Four more came out of pointing it at the disassembler after it learnt about banks, and the first of them was in the harness rather than in merle65:

  • SW was written into source for a Merlin that has no such directive. The disassembler turns SWEET 16 recognition off for a 65816 because Merlin 16 dropped it, and stopped there — but SW is since 8, until 8, so the 1984 release has none of it either, and a --merlin 1984 disassembly of SWEET 16 came back from its own assembler as "SW is a Merlin 8 directive". It asks the directive table now, which is the same question without restating the answer.
  • A HEX line of eighty characters, where Merlin's operand field is sixty-four. emitText falls back to hex for bytes that cannot go in a string, and capped that run at the string limit of fifty-six bytes — which is a hundred and twelve characters of hex. emitWord had the same shape of problem waiting: eight DA entries fit while they are numbers and do not once they are names, so it now ends the line where the next entry would not fit.
  • An instruction written past the end of its own segment. Two segments that happen to sit next to each other are both present in the map, so an instruction at the end of the first could take its operand from the second and be written into a segment with no room for it. A one-byte segment came out as a two-byte BNE and the reassembly's segment was the wrong length.
  • A segment crossing a bank boundary was written wrong in both combined formats. Intel HEX emitted its bank record once per segment and then computed each record's address in sixteen bits, so it wrapped: thirty-two bytes from $00FFF0 read back as sixteen at $00FFF0 and sixteen at $000000. S-records chose S1 or S2 from where the segment started, so every record was an S1 with the address cut to sixteen bits. Records now carry the bank they are actually in, and no Intel HEX record crosses the boundary — one cannot, since it holds sixteen bits of address and takes its bank from the record before it. This mattered the moment an image could legitimately span banks; a 128K Apple IIgs ROM does.

Finding those took fixing the harness first, twice, which is worth writing down because it is the failure mode a fuzz target has.

std.testing.Smith reads a corpus entry a decision at a time, and what it does when the entry runs out is the first trap: every decision after the end comes back as the smallest value that decision allows. The seeds were written without knowing that, so the combined target read its output format off the end of a short seed, got bin five thousand times running, and returned early before reaching the formats the property was about. The corpus is encoded properly now and tools/fuzz.zig grows an input as well as nudging it.

The second trap is worse, because it does not need the input to run out at all. Smith.value reads eight bytes as a little-endian u64 and returns the minimum of the asked-for range unless that u64 is already inside it. It does not reduce modulo the range. That is right for the fuzzer it was built for, which chooses in-range values at each call site; for a byte string it means random input makes value(bool) false every time — the u64 has to be 0 or 1 — every enum its first tag, and every value(u16) a zero, at odds of 2⁻⁴⁸. Only slice behaves as you would hope, taking a length and then copying the bytes across.

So the loop had been fuzzing the content of every source and image properly — which is where all eight bugs above came from — and fuzzing the choices around them not at all. Every disassembly was of one segment in bank zero with no regions, no entry points and no options set, 50,000 runs at a time. Two things put it right: every input now begins with a run of eight-byte words holding small numbers, which is what those questions can answer with, and every target asks its questions before it asks for its bytes, because a slice takes the rest of the stream. Six of the fourteen had them the other way round. The disassembly target went from nothing to 41% of runs banked, 26% with a region and 7% reading straight through, and the four findings above followed within a minute.

Both traps were proved the same way they were fixed: put the bug back, watch the target fail, and count what the target is actually generating rather than trusting that it generates anything.

The confinement target found nothing, which is worth as much as a finding only because of what it took to be able to. Written the obvious way it ran a quarter of a million times a minute and tested nothing at all: its fake filesystem served canned text for the main source as well as for the includes, so the generated PUT lines never reached the assembler and every run assembled a DFB with no PUT in it. That was not visible from the outside — the target passed, quickly, and looked busy. What showed it was breaking the containment check on purpose and watching the target keep passing; and the first repair was not enough either, because the secret had been put inside the working directory, where -I . reaches it legitimately and the property was simply false. Moved above the working directory, with the broken check still in, it fails on the first PUT ./../secret it generates. Then, and only then, is it worth anything with the check restored.

The ninth finding came from the USR target within seconds of its first run, and it is in zig65 rather than here: BRK at the top of memory panicked on integer overflow. The 6502 pushes the address after the padding byte, and the emulator computed it as value + 1 on a u16 — so a routine whose BRK sits at $fffe asked for $10000 and a safe build stopped the process. A one-byte routine loaded there is the whole reproduction. The program counter wraps on the real part and the arithmetic has to as well, which is one character: +%. The 65816's BRK in the same repository was already right, because it goes through interrupt rather than doing the arithmetic itself.

To do

Everything the Merlin 8/16 manual documents is implemented — the dialect axis, TYP, DAT, TTL, ADR, ADRL, the DS extensions, EXP ONLY, USE, LSTDO, CYC, MX, XC, SW, LST RTN, TR ADR, :-prefixed local labels, the 65816, and — last and largest — REL, ENT, EXT, ERR \ and the linker. The last question the manuals left open, how deep LST RTN remembers, is settled by Merlin's own source.

What is left is what asking a Merlin 16+ has begun to turn up.

Asking Merlin 16+

The oracle has all three halves now: Merlin 8 on an emulated IIe, Merlin 16 on an emulated IIgs, and Merlin 16+ on a IIgs with a megabyte in it. Until it did, every --merlin 16+ behaviour merle65 has — the string changes, ASC #, FLO, EXD, XC OFF, LST FILE, braces and the comparison operators — came from the supplement's prose and nothing else. Most of them have now been checked against the release and agree; what Merlin 16+ has said lists what was asked.

One thing it turned up has been fixed already: CAS is no longer refused after a label, and what it does instead is what Merlin 16+ does with it. What is left:

  • The rest of the supplement. EXD and the direct page externals it declares, LST FILE, XC OFF, DAT with an operand and the XREF directives have not been put to the machine yet, and the relocatable modules a 16+ REL writes have not been compared against merle65's at all.
  • What its symbol table listing leaves out. Asked to assemble three equates of which one is referenced, Merlin 16+ counts three symbols and prints one: the referenced one. Whether that is about references, about equates, or about the "change in the way the symbol table is stored" the supplement warns VAL about (16+ supplement, p. 13) is not established, and merle65 prints all three.

Other assemblers

merle65 assembles Merlin syntax and only Merlin syntax. Where that is not what you want:

Merlin syntax elsewhere

Two others read the same source merle65 does, and both are worth knowing about.

  • Merlin 32, by Brutal Deluxe — a cross-assembler for the 6502 family and the 65816, compatible with Merlin 16+ and able to write the OMF files an Apple IIGS loads. Its source settled two questions merle65 could not: that long addressing has to be asked for, and what >, | and ! do in front of an operand. Where the manuals disagree with themselves, a second implementation is worth more than a third reading of the prose.
  • Merlin, by Market Ideas — a cross-assembler and linker for the same three processors, written fresh in C rather than ported from Glen Bredon's code, with Merlin 32 as a behavioural reference. It takes Merlin 16+ as its default dialect and accepts Merlin 16 and Merlin 8 sources behind a --dialect flag, which is the same axis merle65 calls --merlin. It writes absolute binaries today; relocatable output and the linker are what it is working towards, which is the one place the two projects have reached opposite corners first.

Other syntaxes

None of these read Merlin source, and merle65 does not read theirs — Merlin's column rules, its strictly left-to-right expressions and its ]1 macro parameters do not carry across. They are listed because a project that is not already in Merlin syntax is better served by one of them.

ACME 6502, 65C02 and 65816; free-form syntax, and the one most often used for new Commodore and Atari work
cc65 a C compiler with the ca65 macro assembler and ld65 linker beneath it; segments, relocation and target-specific runtimes for a dozen 6502 machines
SBASM a cross-assembler in Python for a wide range of 8-bit processors, not only the 6502 family
Retro Assembler 6502, 65C02, 65816, Z80 and more, with editor integrations and a syntax of its own

Bibliography

Where a doc comment or a paragraph here cites a page, this is what it is citing.

The syntax

  • Glen Bredon, MERLIN Instruction Manual. Southwestern Data Systems, 1982. Cited as 1982 manual. Scan at the Internet Archive. The first Merlin, two years before the release merle65 takes as its baseline. Read here mostly for what is absent from it: it has no REL, ENT, EXT and no linker, which is half of what dates those. It numbers its sections rather than its pages, so a citation names one.
  • Glen Bredon, MERLIN — The Macro Assembler For The Apple. Roger Wagner Publishing, 1984. Cited as 1984 manual. Scan at the Internet Archive. The syntax merle65 accepts, the 39 directives of --merlin 1984, and most of the worked examples the unit tests reproduce.
  • Glen Bredon, Merlin 16+ Supplementary Manual. Roger Wagner Publishing, 1988-89; ISBN 0-927796-28-7. Scan at the Internet Archive. Cited as 16+ supplement. A release later than any merle65 assembles, and read here for what it says changed: braces and operator precedence, comparison operators, STRL, LST FILE, and the linker's own command language. Where merle65 documents a rule as absolute, this is often the manual that says when it stopped being one.
  • Glen Bredon, Merlin 8/16 — Macro Assembler for the Apple II Family. Roger Wagner Publishing, 1987. Cited as 8/16 manual. Covers two assemblers at once, which is why --merlin has three settings and not two: what this manual documents without a Merlin 16 marking is Merlin 8, and what it marks is Merlin 16.

Both are silent in places, and where they are, the oracle is asked instead and the answer written down.

The processors

  • MOS Technology, Inc., MCS6500 Microcomputer Family Hardware Manual. Second edition, January 1976; publication 6500-10A. Scan at the Internet Archive. Appendix A, Summary of Single Cycle Execution (pp. A-1 to A-13), gives the cycle-by-cycle behaviour of every instruction, grouped by what the operation does with memory and then by addressing mode. src/cycles.zig is built the same way round, and cites it section by section: A.2 for reads and the page-crossing cycle, A.3 for stores and why they always pay it, A.4 for read-modify-write, A.5 for the branches, BRK and the rest that no rule covers.
  • Merlin 32, Brutal Deluxe. Source and downloads. Not a document, but read like one: a modern cross-assembler for Merlin 16+ syntax, whose Source/a65816_Code.c says in code what the manual says ambiguously in prose about the operand prefixes and about when an address takes its long form.
  • Roger Wagner, COMPUTE!'s Apple IIGS Machine Language for Beginners. COMPUTE! Publications, 1987; ISBN 0-87455-097-1. Scan at the Internet Archive. A tutorial rather than a reference, and useful here for exactly that reason: it teaches the 65816 through Merlin, so it shows the assembler being used where the manuals only describe it. Chapter 4 assembles a program with Merlin 8/16 and Appendix A is a 65816 instruction reference. Cited for the 8-bit start-assembly default that MX exists to change (p. 274), the two spellings of long addressing (p. 76), and what ENT, EXT and a linker's module list are for (pp. 280-281).
  • Steve Wozniak, SWEET16: The 6502 Dream Machine. Byte, November 1977. The interpreter --merlin 1984 and --merlin 8 assemble inline, described by the person who wrote it; the 1984 manual's own account (pp. 64-65) is the assembler's side of the same thing.

The 65C02 and 65816 additions are described in the 8/16 manual as Merlin sees them, which is the level merle65 needs: which mnemonics exist, in which modes, and what they encode to.

The protocols and the tools

  • Language Server Protocol, version 3.17. Specification. What merle65 lsp implements, request by request.
  • tree-sitter. Documentation. The grammar in tree-sitter-merlin/.
  • zf, by Nathan Craddock. Source. The fuzzy matching behind the unknown-label quick fix and the workspace symbol picker.
  • zig-uri. Source. RFC 3986, which is what a file: URI is: LSP names every document with one and the assembler works in paths, so every request crosses that boundary twice. Built with -Dunicode=false — what arrives is a path an editor made out of a path this program gave it, so there are no internationalised domain names in it and no reason to carry the tables for them.
  • zig65. Source. The Apple IIe the oracle runs Merlin on, the 6502 behind USR, and the DOS 3.3 reader that gets a SAV'd object back off the oracle's disk.

Licence

MIT. The project follows the REUSE specification; run reuse lint in a checkout to verify.

merle65 depends on zf, zig-uri and zig65, all MIT too, and none of them needed by the assembler itself — see four modules. The Zig package manager fetches them from build.zig.zon.

A Nix build has no network, so the dependencies are made available to it through build.zig.zon.nix, generated from the manifest by zon2nix and passed to Zig as a system package directory. Regenerate it whenever build.zig.zon changes:

$ nix develop -c zon2nix --16 --nix=build.zig.zon.nix build.zig.zon

zon2nix is an input of the flake rather than something fetched on the spot, and the devshell wraps it with this flake's Zig on its PATH. It shells out to zig env to read the manifest, and without a Zig to find it prints unable to execute zig and then exits zero, leaving the old file untouched — a regeneration that silently did nothing looks exactly like one that found no changes.