- Zig 88.6%
- C 7.1%
- JavaScript 1.1%
- Nix 1.1%
- C++ 0.9%
- Other 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| tree-sitter-merlin | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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.
link
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.RUNwants a BASIC program, so pointing it at a binary boots as far asFILE 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 thatBRUNs the binary, and that is what merle65 writes: aHELLOof10 PRINT CHR$(4)"BRUN NAME", greeted in the donor's DOS. It is calledBOOTinstead if the assembly has already used the nameHELLO. - ProDOS runs the first
.SYSTEMfile in the volume directory. Nothing is patched:PRODOSis written first and the boot program second. The program has to be calledsomething.SYSTEMand has to be a ProDOS 8 system program —ORG $2000, since that is where the loader puts aSYSfile 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 origin —
EQU(=),ORG,OBJ,VAR,DUM,DEND,END - Files —
PUT,SAV,DSK - Formatting —
LST,EXP,TR,PAU,PAG,AST,SKP - Strings —
ASC,DCI,INV,FLS,REV - Data —
DA,DDB,DFB,HEX,DS,KBD,LUP/--^,CHK,ERR,USR - Conditionals —
DO,ELSE,IF,FIN - Macros —
MAC,EOM(<<<),PMC(>>>)
and some from later releases, which --merlin gates:
- Merlin 8 and later —
REL,ENT,EXT,ERR \,STR,TYP,DS expr,value,DS \,EXP ONLY,USE,LSTDO,CYC,MX,XC - Merlin 8 only —
SW, which Merlin 16 dropped along with SWEET 16 itself - Merlin 16 and later —
DAT,TTL,ADR,ADRL,LST RTN,TR ADR - Merlin 16+ only —
STRL,FLO,EXD,CAS,XREF,XREFA,XC OFF, braces with the comparison operators<,=,>and#,ASC #expression,DATwith 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 $55written after aCAS INisBARin the symbol table, andLDA BARandLDA Barboth find it.- A second spelling of a name already defined under the setting —
barafterBAR— isDuplicate symbol, since both areBAR. - A name defined before the
CAS INkeeps the spelling it went in under. So of aFOOand aFoodefined earlier, every later spelling findsFOOand 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. 16–17). 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
asmassembles 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 16to 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.Swas refused outright when the source was in the working directory, because the confinement check resolved the root and the path separately andstd.fs.path.resolveleaves a relative path relative — soprog.Swas 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 $FE0000was 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
HEXand 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
:LOOPis 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
PUTfile 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,STRLandKBDtake a delimited string and were not in the list of directives that do.STR 'ED'andKBD "Assemble from M-16"are his.- Hex may follow a string with no comma between.
ASC "text"8D00is 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 ]1andHEX ]2inside 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:
ERRandIFtake their operand whole. Both were being read as expressions, and neither is one:ERR \$300-*is a limit for the placed program,ERR (*-BASE)-256puts parentheses where the rest of the language starts an addressing mode with them, andIF X.Xyzcompares two characters by position with the one between them ignored. The grammar now names themopaque_directiveand takes the rest of the field as onetextnode, 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 $2000as a string delimited by dollar signs. Hex bytes may follow the closing quote as they may followASC'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 buildshells out tocc, which a NixOS system has no reason to provide. Run it fromnix developin 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
merlinline.
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:0812 — TXTP, 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:
-
LSTDOstarts on. A line inside a falseDOis listed, with no address or bytes beside it. merle65 defaults to listing them because that is what it did beforeLSTDOexisted; that turns out to be Merlin's default too. -
CYC AVEaccumulates half cycles and truncates. Three indexed reads and aNOPgive4',0004,4',0009,4',0013,2 ,0015— merle65's listing to the character. -
CHKskips aDUMsection, which a source with one inside it proves by checksumming to the same byte as one without. -
A second
CHKcovers only what followed the first, andSAVrestarts it too. Given01 02 CHK 03 04 CHK, Merlin writes03then07, which rules out a running total that skips the checksum bytes — that would give04. It does not distinguish restarting from including the checksum byte in the total, and no experiment can: the running total up to aCHKis that byte, so exclusive-oring it in gives zero. The two are the same operation. Across aSAV, Merlin saved01 02and03 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,SKPorPAGbetween twoHEXlines and Merlin lists the twoHEXlines and nothing else.ASTandSKPstill 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. -
LSTtakes any operand of three characters or more as off.LST NOleaves the listing on andLST OFFXturns it off, which is the rule merle65 already had, and it is why Merlin 8 makesLST RTNmeanLST OFF. -
DATis Merlin 16 only. Merlin 8 v2.48 answersBad 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
ORGreturns 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, andDUMrecords 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.
--filesdumps what an assembly wrote to disk, and one source per kind of reference was enough to read the file off it. See relocatable modules. -
INVandFLSignore the delimiter.INV "A"andINV 'A'are both$01whereASC "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 NCandNCeach branch to their own copy, and aJMP NCafter them isUnknown label, though an ordinary duplicate label is stillDuplicate 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'sPI.DIVfrom assembling. -
@in aLUPcounts down, and stops at 26. ALUP 3around aLAB@givesLABC,LABB,LABAat ascending addresses, so the last iteration is the one that gets theA; aLUP 28around the same line isBad 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 aLUPis three$C0under Merlin 8, so the operand handling really is the Merlin 16+ addition the supplement says it is. -
Labels match case sensitively.
Foo EQU $12followed byLDA FOOisUnknown label, which is what Merlin ships and what merle65 already did — and whatCAS INexists to change. -
A branch may cross the wrap at the top of the address space.
BVS $FFAAassembled at$0000is70 A8under 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 stillBad branchthere, so only the wrap changed. The fuzzer found it by disassembling random bytes; the oracle said which of the two was right. -
IFcompares two characters by position, and the separator between them means nothing. The manual writes the directive asIF ]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.Xyzis true,IF X.Ayzfalse,IF XXXyztrue, andIF AB,Ayzfalse, which is11 33 FFout 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 RTNremembers one value, not a stack. GivenLST 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 theLST OFFand 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. -
RTNis one character, and it is the third one.LST RUNrestores the setting exactly asLST RTNdoes: Merlin readsOPERAND+2, masks it with$5Fand compares it againstN, 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.SWalone isBad opcode in line: 10, and so is aSET R1,SOURCEwith noSWin 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.SWis now the one entry in the directive table with a last release as well as a first. What Merlin 16 ships instead isSW16.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 aLUPis three$C0there too, which puts the operand form squarely in 16+ and nowhere earlier. -
BRLreaches across the wrap as well, and reaches everywhere.BRL $FF00at$0000is82 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:
FLOis the SANE extended format, to the byte.1.0,2.3852e-10,-0.5,0and π 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*3is 9,{1+2*3}is 7,1+{2*3}is 7,{2+3*5}is 17;1=1,1<2,2>1and1#2are each 1, and{1+1=2}is 1. WithSTRL "HI"— a two-byte length word,02 00, then the string — and aDFB "@"inside aLUP, merle65 agrees on all nineteen bytes. - The six
ASC #forms are what the manual prints,31 32 33throughA0 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 #>"123is not a form. Merlin 16+ answersIllegal 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:
CASmay 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 theCASline 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 RTNremembers exactly one value. Merlin 16 keeps it in a single zero page byte,LASTLSTat$59. TheLSThandler inAss.Bnk1.Swrites it on everyLSTwhose operand is notRTN, and never on one that is, so two nestedLST 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.RTNis recognised by one character. The handler readsOPERAND+2— the third character of the operand — masks it with%01011111and compares it againstN, and looks at nothing else. SoLST RUNandLST FINrestore the setting too, an operand of fewer than three characters cannot meanRTNat 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. ItsLSTis three instructions with no test for it, which is whyLST RTNthere 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-LAB2 →
34 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))isformat(x), and wherexassembles,format(x)assembles to the same bytes. That is the formatter's whole contract. -
A disassembly reassembles.
disasm.verifyputs 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.readtakes 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.
writeCombinedputs 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
RELsources, whoseENTandEXTdictionaries 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
xrefwalks 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
USRroutine runs, stops, and says how it ended. The one place merle65 executes what it was handed rather than reading it:USRgives 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 aUSRis certainly reached — up to four of them,USRorUSR0throughUSR9, with operands forEVALto read. It may run away, execute something that is not an opcode, rewrite its own return address, or askEVALfor 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,EVALand 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 andRTSgiven 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
PUTis 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.., andmerle65 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,didOpenwith 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 — soLDA $E36A86,Xreassembled 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 wantsincludes, soLDALthere quietly assembled the short form. BRLdisassembled 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_addris 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:
SWwas 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 — butSWissince 8, until 8, so the 1984 release has none of it either, and a--merlin 1984disassembly 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
HEXline of eighty characters, where Merlin's operand field is sixty-four.emitTextfalls 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.emitWordhad the same shape of problem waiting: eightDAentries 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
BNEand 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
$00FFF0read back as sixteen at$00FFF0and sixteen at$000000. S-records choseS1orS2from where the segment started, so every record was anS1with 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.
EXDand the direct page externals it declares,LST FILE,XC OFF,DATwith an operand and theXREFdirectives have not been put to the machine yet, and the relocatable modules a 16+RELwrites 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
VALabout (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
--dialectflag, 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,EXTand 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
--merlinhas 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.zigis 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,BRKand 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.csays 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
MXexists to change (p. 274), the two spellings of long addressing (p. 76), and whatENT,EXTand a linker's module list are for (pp. 280-281). - Steve Wozniak, SWEET16: The 6502 Dream Machine. Byte, November 1977.
The interpreter
--merlin 1984and--merlin 8assemble 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 lspimplements, 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 aSAV'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.