An assembler for the 6502 and 65SC02, written in Zig, in the dialect of Frank A. Kingswood's as65.
  • Zig 93.8%
  • Shell 2.7%
  • Nix 2.3%
  • Python 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie cd150eb802
All checks were successful
test / test (push) Successful in 2m16s
Package the assembler in the flake
`nix build` and `nix run` work on the repository now, and `nix flake
check` builds the package, which runs `zig build test` on the way -- the
unit tests and the golden ones both -- so a build that finishes is one
whose output still matches as65's.

It is built with the plain `zig_0_16` from nixpkgs and its build hook.
The devshell keeps the patched Zig, since that patch buys `--fuzz` and
nothing else and nothing is fuzzed in a package build.

The version comes out of build.zig.zon, which is already where the build
reads it from, so there is still only one place to change it. The source
is narrowed with `lib.fileset` to what the build actually reads, the
tests and their recorded output included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XL9YYos6VdJAQhQJsZcJr5
2026-09-05 14:39:47 -05:00
.forgejo/workflows Pin the assembler to recorded as65 output 2026-09-05 10:35:52 -05:00
LICENSES Add a 6502 assembler in the as65 dialect 2026-09-05 09:31:01 -05:00
src Ask the caller for source text rather than opening files 2026-09-05 14:23:06 -05:00
tests Ask the caller for source text rather than opening files 2026-09-05 14:23:06 -05:00
tools Write the -g debug information file 2026-09-05 12:46:48 -05:00
.gitignore Add a 6502 assembler in the as65 dialect 2026-09-05 09:31:01 -05:00
build.zig Call the original as65 rather than as65 [1.42] 2026-09-05 11:11:39 -05:00
build.zig.zon Rename the project to aigrette65 2026-09-05 10:48:48 -05:00
flake.lock first 2026-09-05 08:15:27 -05:00
flake.nix Package the assembler in the flake 2026-09-05 14:39:47 -05:00
README.md Package the assembler in the flake 2026-09-05 14:39:47 -05:00
REUSE.toml Call the original as65 rather than as65 [1.42] 2026-09-05 11:11:39 -05:00

aigrette65

An assembler for the 6502 and 65SC02, written in Zig, in the dialect of Frank Kingswood's as65.

as65 is MIT licensed but has never had its source published. This is a reimplementation from the outside: the original binary is used as an oracle, and the goal is output that is byte-for-byte identical to it — the binary, the s-records, the intel-hex, and the listing down to the column. With one deliberate exception, described below.

Building

git clone https://git.ocjtech.us/jeff/aigrette65.git
cd aigrette65
nix develop        # zig, reuse and the rest
zig build          # writes zig-out/bin/aigrette65
zig build test     # unit tests, and the golden tests below

The flake packages it as well, so nothing has to be cloned to run it:

nix build git+https://git.ocjtech.us/jeff/aigrette65.git   # result/bin/aigrette65
nix run git+https://git.ocjtech.us/jeff/aigrette65.git -- -q -h0 prog.a65
nix flake check      # in a clone: builds the package, tests and all

The package is built with the plain Zig 0.16 from nixpkgs and runs zig build test on the way, so a build that finishes is one whose output still matches as65's. The devshell's Zig is patched — one line of its standard library, which is what --fuzz needs to compile at all — and nothing but the devshell wants that.

Using it

aigrette65 [-DIcghilmnopqstuvwxz] file

The options are as65's own, and so is the way they are written. Letters may be run together in one argument, but an option that takes a string swallows the rest of that argument, so nothing can follow it: -tlfile is a symbol table and a listing called file. An option that takes a number does not, so -h80t is eighty lines to a page and a symbol table as well. aigrette65 -? prints the list below, and so does the command with nothing after it, in short.

An option given without the value it wanted is not an error. -h with no number says Page length missing after -h. and leaves the page length as it was; -o with no name leaves the object file the name the source gives it; -w with no number means 131, and -D with no name defines DEBUG.

If the named source is not found, .a65, .asm and .s are tried in turn on the end of it. Naming an output file nul throws that output away.

Choosing a processor

(none) 6502. Defines __6502__.
-x1 6502 with the undocumented NMOS opcodes — aso, lax, dcm and the rest. Defines __6502__ and __6502X__.
-x2, -x 65SC02: bra, phx, stz, trb, bbr, the extra addressing modes. Defines __65SC02__.

These are alternatives rather than flags, and the last one on the command line wins. The __…__ labels are there for if to test.

What comes out

-o<name> Object file name. Defaults to the source name with .bin, .s19 or .hex on it.
(none) A binary: every byte from the lowest address assembled to the highest, with the gaps filled.
-s, -s1 Motorola s-records instead, carrying only the bytes actually assembled.
-s2 Intel hex instead, likewise.
-z Fill the gaps in a binary with $00 rather than $ff, which is what an erased EPROM reads as. With s-records, write a start-address record even when the source named no start address.
-g Write a source-level debug information file, <output>.dbg, in Kingswood's format — see The debug information file.

The listing

-l Produce a pass 2 listing.
-l<name> Write the listing to that file, - for standard output. Naming a file implies -l unless -p or -t was also given, in which case say -l as well to get both.
-p Produce a pass 1 listing, which shows what the first pass made of the source before any label was resolved.
-t Print a symbol table between the passes: name, value in hex, value in decimal, and an asterisk on anything defined with set rather than equ.
-c Show a cycle count in brackets against each instruction. Costs five columns.
-m Show macro expansions, each line marked with > in the margin.
-u Add a map of which parts of the address space were assembled into, sixty-four bytes to a row.
-h<num> Lines to a printed page. Each page gets a header and ends with a form feed. -h0 means one endless page. Anything between 1 and 19 is treated as 20.
-w<num> Columns to a line, between 60 and 200. -w with no number means 131. The default is 79.

A listing file is written whether or not any of these were given; without them it holds the page header and the summaries alone. The title directive sets what the page header says.

Everything else

-d<name>, -D<name> Define a label as 1 before the first line is read. With no name, defines DEBUG.
-I<path> A prefix tried in front of an include file name. May be given more than once, and each is tried in turn. The prefix is pasted straight on, so it needs its own trailing slash. The INCLUDE environment variable does the same and is tried first.
-i Ignore case in mnemonics, so LDA and lda are the same instruction. Labels stay case-sensitive whatever this says.
-n Disable optimization, and make opt in the source do nothing.
-q Quiet: no banner, and no running line counter on standard error.
-v Verbose: name each pass, and each file written, on standard output.

Exit status

0 Assembled without errors.
1 Something wrong on the command line.
2 An input or output file could not be opened: Can't open source file <name>.s. names the last name tried, Can't create output file <name>. the one that would not open.
3 The source had errors.

The debug information file

-g writes <output>.dbg, a "Kingswood source-level debug information file [0.60]": the names of the source files, the symbol table with each symbol placed in a segment and given a size, and a table mapping addresses to source lines. Kingswood publishes READDBG, sample code for reading these files, alongside the assemblers themselves, and it reads ours as readily as his.

The format was worked out from the files as65 produces, and is described in the doc comment at the top of src/debug.zig. In outline: a 120-byte header, then a four-byte CPU identifier, then three lists, each of length-prefixed records ended by a zero byte — the source file names, the symbols, and the lines. Everything multi-byte is big-endian.

Three of its habits are worth knowing about, since all three look like bugs until you have seen as65 do them:

  • A label on a jmp or a bra is not marked as naming an instruction, the way a label on any other instruction is. Those two are what the optimizer turns into each other, and it seems not to commit to what it will have left there.
  • The values are the ones the symbol table held between the passes, which is also what -t prints. Only a set can tell the difference, its value being worked out afresh each time it is written: with -n and a shrinking instruction above it, S set * is worth one address in the debug file and another to the code that reads it in pass two.
  • A failed assembly still gets a debug file. One that fell over in pass one holds the labels that pass managed to define and stops there — pass two never ran, so there is no line table, and not even the zero byte that would end an empty one. One that got as far as pass two has its line table like any other.

The one part that is deliberately not identical is the 38-byte producer line in the header, which says who wrote the file. Ours says so; see below.

Using it from Zig

Everything the command does is in a module, and src/main.zig is a thin driver over it: it opens the files, parses the options, hands them to the assembler, and writes out what came back. A program that wants to assemble something can do the same.

The module opens nothing and writes nothing itself. Source text is asked for by name and comes back as a std.Io.Reader, and everything that comes out — the listing, the diagnostics, the object file, the debug information — is written to a std.Io.Writer the caller passed in. What those readers and writers are attached to is the caller's business: files, memory, a socket, a test's buffer.

It needs Zig 0.16 and has no dependencies of its own.

Adding it

zig fetch --save git+https://git.ocjtech.us/jeff/aigrette65.git

and in build.zig:

const aigrette65 = b.dependency("aigrette65", .{
    .target = target,
    .optimize = optimize,
});

const exe = b.addExecutable(.{
    .name = "yours",
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "aigrette65", .module = aigrette65.module("aigrette65") },
        },
    }),
});

Assembling something

const std = @import("std");
const Io = std.Io;
const aigrette65 = @import("aigrette65");

/// Where the assembler's source text comes from. Nothing in the library opens
/// a file: it asks for a name and is handed a reader, or told there is
/// nothing there.
const Files = struct {
    io: Io,
    dir: Io.Dir,
    buffer: [4096]u8 = undefined,
    open_file: ?Io.File = null,
    reader: Io.File.Reader = undefined,

    fn source(self: *Files) aigrette65.Source {
        return .{ .context = self, .open = open, .close = close };
    }

    fn open(context: ?*anyopaque, name: []const u8) aigrette65.Source.OpenError!?*Io.Reader {
        const self: *Files = @ptrCast(@alignCast(context.?));
        const file = self.dir.openFile(self.io, name, .{}) catch return null;
        self.open_file = file;
        self.reader = .init(file, self.io, &self.buffer);
        return &self.reader.interface;
    }

    fn close(context: ?*anyopaque, _: *Io.Reader) void {
        const self: *Files = @ptrCast(@alignCast(context.?));
        if (self.open_file) |file| file.close(self.io);
        self.open_file = null;
    }
};

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;

    var stdout_buffer: [4096]u8 = undefined;
    var stdout_file: Io.File.Writer = .init(.stdout(), io, &stdout_buffer);
    const stdout = &stdout_file.interface;
    defer stdout.flush() catch {};

    // What to assemble and how. Every field holds the default `as65` starts
    // with, so only what differs from it has to be named.
    var options: aigrette65.Options = .{
        .input = "prog.a65",
        .cpu = .g65sc02,
        .symbol_table = true,
    };
    defer options.deinit(gpa);

    // The 64K image the assembly goes into. It is a big struct, so it wants
    // the heap rather than the stack.
    const image = try gpa.create(aigrette65.Image);
    defer gpa.destroy(image);
    image.* = .{};

    var files = Files{ .io = io, .dir = Io.Dir.cwd() };
    var assembler = aigrette65.Assembler.init(gpa, files.source(), &options, image, stdout);
    defer assembler.deinit();
    assembler.listing = stdout;
    assembler.listing_is_console = true;

    try assembler.assemble();
    if (assembler.failed()) return error.AssemblyFailed;
    try assembler.finishListing(false);

    for (assembler.symbols.keys(), assembler.symbols.values()) |name, symbol| {
        if (!symbol.defined or symbol.is_macro) continue;
        try stdout.print("{s} = ${x:0>4}\n", .{
            name,
            @as(u32, @bitCast(symbol.value)) & 0xffff,
        });
    }

    if (!image.isEmpty()) {
        var buffer: [4096]u8 = undefined;
        const file = try Io.Dir.cwd().createFile(io, "prog.bin", .{});
        defer file.close(io);
        var sink: Io.File.Writer = .init(file, io, &buffer);
        try image.writeBinary(&sink.interface, 0xff);
        try sink.interface.flush();
    }
}

What is on the module

Assembler The two passes. Everything below is either something it needs or something it produces.
Options The command-line options as a struct of plain fields, with parse for a front end that takes an as65 command line.
Source Where source text comes from: the one thing the assembler asks the caller for while it is running.
Image The 64K address space, and the three shapes it can be written out in.
debug The -g file: the layout, and write to produce one.
isa The instruction table: find, and lookup on what it hands back.
expr The expression grammar, usable against any context that can name a location counter and look a label up.
Scanner The line scanner the rest is built on.
collate The order the symbol table is printed in.
banner The line the program names itself with, in its long and short forms.

The pieces of a run

Options is a struct of plain fields, one for each option in the table above, each holding what as65 starts with; .{} is a plain 6502 assembly of nothing, with no listing asked for. Options.parse(gpa, args, include_env, &diagnostic) fills one in from an as65 command line instead, args being the arguments without the program name and include_env the value of the INCLUDE environment variable or null. It returns error.UnknownOption, error.MultipleInputFiles, error.NoInputFile or error.HelpRequested, with the text to print in the diagnostic. deinit frees the lists behind -d and -I.

Assembler.init(gpa, source, &options, image, console) takes where source text comes from, the image to assemble into, and the writer diagnostics go to. Three more fields are worth setting before assemble:

listing Where the listing goes, or null for none. A listing is produced whenever this is set, even if no listing option was asked for; without them it holds the page header and the summaries alone.
listing_is_console Says the listing and the console are the same writer, which is what -l- does. It changes how a failed assembly is closed off.
progress Where the running line counter goes, or null for none. The command sends it to standard error unless -q.

assemble runs both passes, writing the listing as it goes; it returns an error only if something outside the assembly went wrong, such as the source not being there. Whether the source was good is failed(), and reached_pass2 says whether the second pass ran at all — the first stops the assembly when it finds an error. Afterwards:

symbols The symbol table, keyed by name. defined, value, redefinable (a set rather than an equ), is_macro, and the segment and kind the -g file reports.
start_address What end named, or null.
debugInfo(gpa) The sources, symbols and lines for a -g file, in the order that file wants them.
finishListing(abandoned) Closes the listing with the form feed one always ends with. Pass true when the assembly failed.
report(text) Writes a block of lines to the listing the way the "Wrote binary from…" block is written, counting as one line against the page however long it runs.
flushPendingPage() Starts a page that a full one has been waiting for. This is what the s-record and intel-hex paths do instead of finishListing, neither of them ending the listing with a form feed.

Source is how the assembler gets at source text, and the only thing it asks the caller for while it is running. It is two function pointers and a context:

open: *const fn (context: ?*anyopaque, name: []const u8) OpenError!?*Io.Reader,
close: *const fn (context: ?*anyopaque, reader: *Io.Reader) void = ignore,

open is asked for the source that was named, for every include, and for each of the names tried around them — prog, then prog.a65, prog.asm, prog.s, and an include name with each -I prefix in front of it. A name that is not there is null, which is how the search moves on to the next candidate; error.ReadFailed is for one that is there and will not open. The reader is read to the end and then handed to close, and the assembler never has two open at once, so one reader's worth of state is enough to implement this. The worked example above is a Source over a directory.

Source.Memory is one that is already written, for text the caller has in hand:

var files: aigrette65.Source.Memory = .{ .files = &.{
    .{ .name = "prog.a65", .text = program_text },
    .{ .name = "hardware.i", .text = hardware_text },
} };
var assembler = Assembler.init(gpa, files.source(), &options, image, console);

Source.none has nothing in it, which is what to hand over when the point is to watch the assembler fail to find anything.

Image holds bytes and the used map beside it, lowest and highest of everything either pass wrote, and isEmpty(). writeBinary(w, fill) writes the whole span with the gaps filled, writeSRecords(w, entry, always_start) and writeIntelHex(w, entry) only the runs that were assembled. The entry is the start address to put in the last record, or null.

debug.write(w, cpu, sources, symbols, lines, complete) writes a -g file from what debugInfo returned; complete is reached_pass2. The line table is only collected when options.debug_info was set, so a caller that wants one has to say so before assembling, whether or not it means to write the file out.

The smaller pieces

None of them needs an assembler:

const isa = aigrette65.isa;
const expr = aigrette65.expr;

// The instruction table.
const lda = isa.find("lda").?;
const zp = lda.lookup(.zp, .mos6502).?;         // .op is $a5, .cycleCount(.mos6502) is 3
const no_bra = isa.find("bra").?.unavailable(.mos6502);   // true

// The expression evaluator, against a context of your own.
const Context = struct {
    pc: expr.Value,

    pub fn locationCounter(self: *const Context) expr.Value {
        return self.pc;
    }

    pub fn lookupSymbol(_: *const Context, name: []const u8) ?expr.Value {
        if (std.mem.eql(u8, name, "Screen")) return 0x0400;
        return null;   // null is a label with no value yet, which is worth zero
    }
};
var scanner = aigrette65.Scanner.init("hi Screen+40*3");
const value = try expr.parse(&scanner, &Context{ .pc = 0x1000 });   // 4

// The symbol table's collation: `.`, then the letters case-insensitively with
// each capital before its small letter, then the digits, then `?` and `_`.
std.mem.sort([]const u8, &names, {}, aigrette65.collate.lessThan);

The source format

A line

[label][:]  [opcode  [operand]]  [; comment]

A label starts in the first column; anything indented is read as an opcode, so Indented equ 1 is not a definition but an unknown mnemonic. The colon after a label is optional. A comment runs from a ; to the end of the line, and a * in the first column comments out the whole line — indented, * is the location counter instead.

Mnemonics and directives are lower case unless -i says otherwise. Labels are case-sensitive whatever -i says.

A label may hold letters, digits, _, . and ?, and may begin with any of them but a digit. a, x and y name registers and hi and lo are operators, so none of the five can be a label — in those spellings only, Y and HI being ordinary labels — and neither can a mnemonic. The last line of a file — whatever follows its final newline — appears in the listing but is not assembled.

Numbers

1000 decimal
$1000, 0x1000 hexadecimal
%1000, 0b1000 binary
@1000 octal
<base>#<digits> any base from 2 to 36, 36#zz being 1295

Above base ten the extra digits are letters, in either case.

Strings

Either quote will do, "like this" or 'like this'. A backslash escapes the next character:

\a 7 \t 9
\b 8 \v 11
\e 27 \x 0
\f 12 \0\9 0 to 9
\n 10 \\ \' \" the character itself
\r 13

Anything else after a backslash stands for itself. There is no \x41 or \101 form: \x is a nul and \1 is the byte 1.

Where a string is wanted it is its bytes. Where a number is wanted it is those bytes packed most significant first, and only one, two or four of them have a size — "abc" is an error, "quad" is $71756164.

Expressions

Arithmetic is 32-bit two's complement throughout, and wraps rather than complaining. Division truncates toward zero and >> carries the sign down. A comparison is 1 or 0.

Operators, tightest first:

( ) grouping
*, $ the location counter
+ - ! ~ unary plus, negation, logical not, bitwise not
* / % multiply, divide, remainder
+ - add, subtract
<< >> shift
< > <= >= compare
= != compare for equality
& bitwise and
^ bitwise exclusive or
| bitwise or
&& logical and
|| logical or
hi lo high byte, low byte

hi and lo bind loosest of the lot, so hi $12ff+2 is hi ($1301), which is $13, and (hi $12ff)+2 is $14. Both && and || evaluate each side whatever the other says.

* and $ are the location counter as the statement began, not as it stands: db 1,*,2,* puts the same address in both slots. Which of the two meanings a $ or a % has depends on where it stands — a % where a value belongs starts a binary number, and a % after one is the remainder operator, so 1010101%%1010101 is 1010101 modulo %1010101.

An expression that mentions a label with no value yet is worth zero, not zero plus the rest of it: dw abs1+1 in pass one assembles as zero.

Addressing modes

implied
a the accumulator
#<expr> immediate
<expr> zero page or absolute, whichever the value fits, or a branch target
<expr>,x, <expr>,y indexed, zero page or absolute
(<expr>) indirect
(<expr>,x) indexed indirect
(<expr>),y indirect indexed
<bit>,<zp> rmb, smb
<bit>,<zp>,<target> bbr, bbs

Zero page is chosen over absolute whenever the value is known to fit in a byte and the instruction has such a form; where it has none, the long form is used instead, which is why adc $12,y is three bytes.

Directives

Where the bytes go. code, data and bss choose which of the three location counters is in use; the assembler starts in code. org <expr> sets the one in use. bss holds no bytes at all — assembling into it moves the counter and nothing else, which is what makes it usable for RAM in a ROM-based program. align [<expr>] pads with zeros to the next boundary, or to an even address if the boundary is left out. memsize <expr> says how much address space there is: one to sixteen is a power of two, 256 and up a count of bytes.

Bytes and words. db takes a list of expressions and strings, and a string becomes its bytes. fcb takes expressions only, so a string in one is a number — fcb "xy" is $7879, which does not fit. fcc takes exactly one string. dw, fcw and fdb are words, low byte first; dd is four bytes the same way. ds <expr> and rmb <expr> reserve that many zeroed bytes. nop <expr> writes that many nop instructions.

Labels. <label> equ <expr> gives a label a value once and for all; <label> set <expr> and <label> = <expr> may be written again as often as they like, and the symbol table marks them with an asterisk. A label on any other line takes the location counter — but not until the line has been assembled, so Target jsr Target refers to whatever Target was before, which in pass one is nothing.

Conditionals. if <expr>elseendif, nested as deeply as you like. A label with no value counts as zero here rather than an error, which is how if __65SC02__ works on a processor that has not defined it. Nothing inside a branch that was not taken is looked at at all.

Structures. Between struct <name>[,<expr>] and end struct, db, dw, dd, ds and label name members and give each one its offset rather than laying any bytes down, and align rounds the offset up. The structure's own name ends up holding its total size.

Macros. See below.

Files. include "<name>" reads another file in at that point. A .bin is taken as raw bytes, and a .s19 or .hex for the records in it — in both cases the bytes land at the location counter, and the address in a record is ignored. Anything else is read as source.

The listing. title "<text>" sets what the page header says. page breaks the page, and page <n> breaks it only if n more lines will not fit; n is one to ninety-nine. page <n> takes one to ninety-nine lines and refuses anything else, listing or no listing. list and nolist turn the listing off and on, and count, so two nolists need two lists. opt and noopt turn optimization on and off, and do nothing at all if -n was given.

Ending. end [<expr>] names the start address. It does not stop the assembly: everything after it is assembled as usual.

Characters. cmap on its own puts the character map back to the identity. cmap <expr> maps every character to that one value. cmap <char>,<bytelist> maps <char> to the first byte of the list, the character after it to the second, and so on. The map applies to strings in db and fcc, and to nothing else.

Macros

name            macro   first,second
                local   scratch
                dw      scratch
                db      first,second
scratch         db      "\1 and \2",0
                endm

A macro is <name> macro [<parameters>] down to endm, and is expanded by writing its name with arguments after it. Inside the body:

  • a parameter name stands for the argument, anywhere but inside a double-quoted string;
  • \1 through \9 stand for the first through ninth argument, everywhere, double-quoted strings included;
  • \0 is how many arguments there were;
  • \? is a four-digit number, different for every expansion, for building a label that has to be unique;
  • local <names> does the same thing more tidily: each name listed gets that number stuck on the end of it wherever it appears in the body.

Arguments are separated by commas, and the spaces before one are dropped while the spaces after it are part of it — which is how I_clr IRQ_bit ;comment substitutes IRQ_bit with its trailing space and keeps the body lined up.

exitm abandons the rest of an expansion. A macro may call another, or itself; -m shows what any of it expanded to.

Predefined labels

One of these is defined for if to test, according to the processor asked for: __6502__, __6502X__ (with __6502__ beside it), or __65SC02__.

Where the output differs, on purpose

Everything is reproduced byte for byte except the text in which the program names itself. Printing

AS65 Assembler for R6502 [1.42].  Copyright 1994-2007, Frank A. Kingswood

over a listing this program produced would be claiming to be a program it is not, so it prints its own name and version instead. That covers three places: the header over each page of a listing, the banner on start-up, and the prefix on a diagnostic that has no line to point at (aigrette65 : If without endif.). The Usage: line names aigrette65 too.

The shape is kept exactly — a short form for a narrow page, a long form with a copyright once there is room, the page number in the same nine columns, and the same eight columns of daylight before it decides there is room — so a listing still lays out the way as65's does. tests/golden records as65's own output with that substitution applied and nothing else, by tools/rename-in-recorded-output.py.

One construction is known not to match, and it is not on purpose. bra on a processor that has none is turned into a jmp, which is what as65 does — but with -x1 and a target that is not known yet, as65 sizes it as a short branch in pass one and emits the undocumented $80 in pass two, complaining if the target turned out to be out of reach. This does the substitution in both passes instead. It takes bra on a 6502X, forward-referenced, to see it.

The -g file has a fourth such place: a 38-byte producer line in its header, where as65 writes its copyright and this writes Written by aigrette65. MIT licensed. The two are the same length, so everything after it — which is to say every byte that carries information — is at the same offset and has the same value, header checksum included.

Fidelity

The original is quirky in places, and this assembler is quirky in the same places on purpose, because that is what byte-identical means:

  • sty <abs>,x assembles to $9c, which is stz <abs>, and jsr (<abs>) to a plain jsr <abs>.
  • bit #<imm> is accepted without -x, though it is a 65SC02 instruction.
  • align computes its fill count as (boundary - pc) in unsigned 32-bit arithmetic, so past the boundary the subtraction wraps: align 79 settles on addresses congruent to 2^32 mod 79 rather than to zero.
  • fcb and db differ in a way the manual calls "similar": a string in a db is its bytes, but in an fcb it is a number, so fcb "xy" is $7879 and too big for the byte it was asked for.
  • With optimization off (-n), pass two re-chooses instruction sizes that pass one had already fixed, which can leave branches pointing at the wrong place.
  • A label, and a label given a value with equ, keeps the value pass one gave it. Pass two works out a value and shows it in the listing, but what every reference to that label is worth was settled in pass one. Only set is re-evaluated. It takes -n to see this: with a shrinking instruction ahead of it, a label is worth its pass one address all through pass two, which is also why as65 never reports the "Phase error between passes" it has a message for.
  • org is the one directive that takes a label and does nothing with it: no complaint, and nothing entered into the symbol table.
  • a, x and y name the registers, so a bare one of them where a value belongs is a syntax error rather than an unknown label. Only the small letters, and only on their own: Y and y1 are ordinary labels.
  • A branch whose target is not known yet points at itself; a jmp through a vector does too, and a plain jmp does when optimization is on. Every other expression touching a label with no value comes out as zero.
  • jmp (<abs>) through a vector on a page boundary is costed one cycle more on every processor except the plain 6502.
  • The symbol table is sorted by a collation of its own: ., then the letters case-insensitively with each capital before its small letter, then the digits, then ? and _.
  • A control character below a space is white space wherever it falls in a line, so db 1 with a stray ^A after it assembles, and the listing echoes the line with every such character taken out, comments included. del and anything above it is "Invalid format", and so are [, ], { and } wherever they turn up in an expression, including after it has finished.
  • org must fit in sixteen bits, sign and all: org $ffff and org -1 are allowed and org $10000 is "Value not within allowed range".
  • A label on a line that failed is still entered into the table, holding the location counter — a bad equ, an unknown mnemonic, or a mnemonic that could not be read at all. The exception is the directives that refuse a label outright, align and the rest, which enter nothing.
  • The image is not cleared between the passes, only the record of what the current pass has written, so code that came out shorter in pass two leaves its longer pass one tail behind in the object file. Under -n, three sta <zp> on a forward-referenced label are nine bytes in pass one and six in pass two, and the file holds nine.
  • Not every line printed has a say in where the page breaks. The three that close the symbol table count as one between them, and the block reporting what was written counts as one however long it runs, which is how a listing at -h60 comes to hold sixty-three lines and stop there.
  • A page break still waiting when the listing ends is dropped rather than starting a page with nothing on it — unless the page it would follow ran two lines or more past its length, which is what happens when that three-line block goes down on a page with one line left. Then the empty page appears.

Each of these is commented where it is implemented, with what was measured.

Tests

tests/cases holds a source for each corner of the language — the operators and number bases, every addressing mode, the 65SC02 additions, the directives, macros, the character map, the four kinds of include, and one line for each diagnostic. tests/golden holds what as65 printed for them — the object file, the listing, the console output, the exit status, and the debug information file where -g was given — with the program's own name substituted into the places named above and nothing else changed. zig build test assembles each source and compares them all, so the original's behaviour is pinned without needing the original to hand. The debug file is recorded without its 120-byte header, that being the one part meant to differ; src/debug.zig tests the header on its own. The sources are read into memory and handed to the assembler as text, which is both how a caller would do it and a standing check that the library needs no filesystem to work.

Regenerate the golden files with tools/generate-golden.sh, which does need a copy of as65 to hand.

Running the original on a modern system

as65 ships as a 32-bit ELF from 2007, and running one in 2026 turns out to need less than you would think. An x86-64 kernel will still execute a 32-bit binary; what is missing is the loader it asks for, /lib/ld-linux.so.2, and the libraries beside it — it wants libc, libm, libstdc++, libgcc_s and libpthread. Nothing needs to be emulated, and the binary itself needs no patching.

Rather than install a 32-bit runtime system-wide, tools/run-as65.sh fetches one from nixpkgs and calls the loader directly, handing it the library path:

glibc=$(nix eval --raw 'nixpkgs#pkgsi686Linux.glibc.outPath')
gcc=$(nix eval --raw 'nixpkgs#pkgsi686Linux.gcc-unwrapped.lib.outPath')

"$glibc/lib/ld-linux.so.2" --library-path "$glibc/lib:$gcc/lib" ./as65 -q file.a65

The script does that for you, and looks for the assembler at ~/dev/as65_142/as65 unless AS65_BINARY says otherwise, so tools/generate-golden.sh with no arguments is usually enough. On a machine that is not x86, set AS65_EMULATOR=qemu-i386 and the same call goes through that instead — which works, and is what this started out doing before it turned out to be unnecessary.

There are two things to know before trusting what comes back. as65 segfaults when asked to write a binary for a program that assembled no bytes at all (as65 -oout.bin empty.a65), so a crash there is the original's and not something to reproduce; with -onul it is fine. It also segfaults on a \" inside a double-quoted string (db "a\"z"), though in a longer string the same escape sometimes comes back as "Invalid format" instead. Neither is reproduced here: \" is the quote character, as the manual says.

Beyond that, as65's own testcase.a65, all four sources of Klaus Dormann's 6502/65C02 functional tests, the sources under tests/cases, and a hundred-odd small files written to corner one behaviour each have all been assembled both ways and compared file by file — object, listing, console, exit status and -g file — after the same substitution.

The last sweep of that ran 2 521 combinations of source and options. Every one agrees byte for byte except the twenty-four that are the two behaviours named above: nineteen on the \" that as65 will not read, and five on bra with -x1. Alongside it, 360 combinations of page length and page width agree, as do 1 155 shapes of listing built to land the last line of a page in every position it can land in.

Acknowledgements

as65 is Frank A. Kingswood's, one of a family of cross assemblers he has kept on his site for decades:

http://www.kingswood-consulting.co.uk/assemblers/

There is one for the 6800, the 6805, the 6809, the 8048 and everything from the 8080 to the Z180, and as65 for the 6502 and 65SC02. Everything this program knows how to do is his design — the directives, the expression grammar, the option letters, the shape of the listing, and the handful of quirks catalogued above, which are here on purpose because a drop-in replacement that is nearly the same is no use to anybody.

Alongside them he publishes READDBG, sample code for reading the debug information files, and it is what settled the meaning of the -g format here: it names the fields, and running it against both assemblers' output is how the two were held to the same answer.

He released them under the MIT licence, with the note "sorry, no source available" beside them. That combination is the whole reason this exists: the terms invite a reimplementation, and the absence of source means it has to be done from the outside, by asking the original what it does and writing down the answers. Note that the manual bundled with [1.42] carries an older and stricter statement of terms than the site does; the site is the later word.

Klaus Dormann's 6502/65C02 functional tests are written in this dialect, and made the differential testing here what it is: six thousand lines of source that leans on nearly every corner of the assembler at once, with the assembler itself bundled alongside so that anyone can check the answer.