- Zig 93.8%
- Shell 2.7%
- Nix 2.3%
- Python 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 2m16s
`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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
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
jmpor abrais 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
-tprints. Only asetcan tell the difference, its value being worked out afresh each time it is written: with-nand 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> … else … endif, 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;
\1through\9stand for the first through ninth argument, everywhere, double-quoted strings included;\0is 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>,xassembles to$9c, which isstz <abs>, andjsr (<abs>)to a plainjsr <abs>.bit #<imm>is accepted without-x, though it is a 65SC02 instruction.aligncomputes its fill count as(boundary - pc)in unsigned 32-bit arithmetic, so past the boundary the subtraction wraps:align 79settles on addresses congruent to2^32 mod 79rather than to zero.fcbanddbdiffer in a way the manual calls "similar": a string in adbis its bytes, but in anfcbit is a number, sofcb "xy"is$7879and 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. Onlysetis re-evaluated. It takes-nto see this: with a shrinking instruction ahead of it, a label is worth its pass one address all through pass two, which is also whyas65never reports the "Phase error between passes" it has a message for. orgis the one directive that takes a label and does nothing with it: no complaint, and nothing entered into the symbol table.a,xandyname 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:Yandy1are ordinary labels.- A branch whose target is not known yet points at itself; a
jmpthrough a vector does too, and a plainjmpdoes 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 1with a stray^Aafter it assembles, and the listing echoes the line with every such character taken out, comments included.deland anything above it is "Invalid format", and so are[,],{and}wherever they turn up in an expression, including after it has finished. orgmust fit in sixteen bits, sign and all:org $ffffandorg -1are allowed andorg $10000is "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,alignand 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, threesta <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
-h60comes 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.