Something went wrong. Try again.
An assembler for the 6502 and 65SC02 written in Zig, in the dialect of Frank Kingswood's as65.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The source-level debug information file `-g` writes.//!//! `as65` calls this a "Kingswood source-level debug information file [0.60]"//! and puts it beside the object file. The format is not written down anywhere,//! but Kingswood publishes `READDBG`, sample code for reading one, and between//! that and a pile of files assembled from sources built to provoke each corner//! it comes apart cleanly. What follows is the whole of it.//!//! A file is a text header, then four things in order: a word identifying the//! processor, the list of source files, the symbol table, and a table mapping//! addresses to source lines. Everything multi-byte is big-endian.//!//! The header exists so that `type`ing the file at a DOS prompt says what it//! is: six backspaces rub out the `INFO` and the length, and a `^Z` stops the//! listing before the binary starts.//!//! "INFO" magic//! two bytes header length, each '0' + six bits//! six 0x08 backspaces//! 55 bytes the description a reader checks for//! " [0.60]\r\n" the format version//! a line of text free, ending "\r\n"//! 0x1a end of text//! zero or more 0x00 padding//! two bytes flags, always 1//! two bytes checksum of everything before it//!//! The body is then://!//! four bytes processor: 'R', 6502 << 4, and a revision//! source files each a length byte and that many bytes,//! ended by a zero length//! symbols a length-prefixed name, a flags byte, and a//! value of two bytes, or four if the flags say//! so; ended by a zero-length name//! lines see `writeLines`; ended by a zero byte
const std = @import("std");const Io = std.Io;const banner = @import("banner.zig");
/// Where a symbol lives. `register` is in the format but `as65` never uses it.pub const Segment = enum(u3) { code = 0, data = 1, bss = 2, register = 3, structure = 4, constant = 5,};
/// What a symbol names, which `as65` takes from the directive it was written/// on: an instruction, one of the data sizes, a block of bytes of no/// particular size, or nothing at all.pub const Kind = enum(u4) { none = 0, byte = 1, word = 2, block = 3, long = 4, instruction = 5,};
pub const Symbol = struct { name: []const u8, segment: Segment, kind: Kind, value: i32,};
/// One row of the address-to-source table.pub const Line = struct { file: u8, line: u32, address: u32,};
/// The processor a file was assembled for, in the shape the format wants it:/// the letter, the number, and a revision that says which variant.pub const Cpu = enum(u32) { mos6502 = ('R' << 24) | (6502 << 4) | 0, mos6502x = ('R' << 24) | (6502 << 4) | 1, g65sc02 = ('R' << 24) | (6502 << 4) | 2,};
const magic = "INFO";const rubout: *const [6]u8 = &@as([6]u8, @splat(0x08));
/// The description a reader compares against; it has to be exactly this or the/// file is not recognised.const description = "This is a Kingswood source-level debug information file";const version = " [0.60]\r\n";
/// The line `as65` puts its own copyright on. Ours says who wrote the file/// instead — the same exception the banner makes, for the same reason — and is/// kept to the same length, so that the header stays 120 bytes and a reader/// that assumes as much is none the wiser.const producer = "Written by " ++ banner.program ++ ". MIT licensed.\r\n";
comptime { std.debug.assert(description.len == 55); std.debug.assert(producer.len == "Copyright 1997 Kingswood Consulting.\r\n".len);}
/// The checksum over the header: a sixteen-bit rotate left, then add, per byte.fn checksum(bytes: []const u8) u16 { var c: u16 = 0; for (bytes) |b| c = (std.math.rotl(u16, c, 1)) +% b; return c;}
fn writeHeader(w: *Io.Writer) Io.Writer.Error!void { const text_len = magic.len + 2 + rubout.len + description.len + version.len + producer.len + 1; // The four bytes of flags and checksum sit at the end, and the whole thing // is padded to an even length. const unpadded = text_len + 4; const total = unpadded + unpadded % 2; comptime std.debug.assert(total < 256);
var header: [total]u8 = @splat(0); var fixed = Io.Writer.fixed(&header); fixed.writeAll(magic) catch unreachable; fixed.writeByte('0' + @as(u8, total >> 6)) catch unreachable; fixed.writeByte('0' + @as(u8, total & 0x3f)) catch unreachable; fixed.writeAll(rubout) catch unreachable; fixed.writeAll(description) catch unreachable; fixed.writeAll(version) catch unreachable; fixed.writeAll(producer) catch unreachable; fixed.writeByte(0x1a) catch unreachable;
header[total - 4] = 0; header[total - 3] = 1; const sum = checksum(header[0 .. total - 2]); header[total - 2] = @intCast(sum >> 8); header[total - 1] = @truncate(sum); try w.writeAll(&header);}
fn writeString(w: *Io.Writer, text: []const u8) Io.Writer.Error!void { const len: u8 = @intCast(@min(text.len, 255)); try w.writeByte(len); try w.writeAll(text[0..len]);}
/// The address-to-source table, delta encoded.////// A row whose top bit is set starts afresh: the low seven bits are the source/// file, and a line number and an address follow as two bytes each. Any other/// row is a byte of line delta, from one to 127, and a byte of address delta./// Rows are in address order, so a jump backwards needs a fresh row, and so/// does a change of file or a gap too large for a byte.fn writeLines(w: *Io.Writer, lines: []const Line) Io.Writer.Error!void { var previous: ?Line = null; for (lines) |entry| { if (previous) |last| { const line_step = @as(i64, entry.line) - @as(i64, last.line); const address_step = @as(i64, entry.address) - @as(i64, last.address); if (entry.file == last.file and line_step >= 1 and line_step <= 0x7f and address_step >= 0 and address_step <= 0xff) { try w.writeByte(@intCast(line_step)); try w.writeByte(@intCast(address_step)); previous = entry; continue; } } try w.writeByte(0x80 | (entry.file & 0x7f)); try w.writeInt(u16, @truncate(entry.line), .big); try w.writeInt(u16, @truncate(entry.address), .big); previous = entry; } try w.writeByte(0);}
/// Write a whole debug information file.////// `symbols` must already be in the order the symbol table is printed in, and/// `lines` in address order.pub fn write( w: *Io.Writer, cpu: Cpu, sources: []const []const u8, symbols: []const Symbol, lines: []const Line, /// Whether pass two ran at all. as65 writes a file for a failed assembly /// too, but one that fell over in pass one stops after the symbols: the /// line table and even its terminating zero are absent. complete: bool,) Io.Writer.Error!void { try writeHeader(w); try w.writeInt(u32, @backingInt(cpu), .big);
for (sources) |name| try writeString(w, name); try w.writeByte(0);
for (symbols) |symbol| { try writeString(w, symbol.name); const bits: u32 = @bitCast(symbol.value); const long = bits > 0xffff; try w.writeByte((if (long) @as(u8, 0x80) else 0) | (@as(u8, @backingInt(symbol.segment)) << 4) | @backingInt(symbol.kind)); if (long) { try w.writeInt(u32, bits, .big); } else { try w.writeInt(u16, @truncate(bits), .big); } } try w.writeByte(0);
if (complete) try writeLines(w, lines);}
const testing = std.testing;
test "the checksum is the one as65 puts on its own headers" { // Taken from a file as65 wrote: 120 bytes, of which the last two are the // checksum itself, which came to $1d60. const sample = magic ++ "1h" ++ rubout ++ description ++ version ++ "Copyright 1997 Kingswood Consulting.\r\n\x1a\x00\x00\x01"; try testing.expectEqual(@as(usize, 118), sample.len); try testing.expectEqual(@as(u16, 0x1d60), checksum(sample));}
test "the header is the same size as as65's, and says its length" { var buf: [256]u8 = undefined; var w = Io.Writer.fixed(&buf); try writeHeader(&w); const header = w.buffered(); try testing.expectEqual(@as(usize, 120), header.len); const stated = (@as(usize, header[4] - '0') << 6) | (header[5] - '0'); try testing.expectEqual(header.len, stated); try testing.expectEqualStrings(description, header[12..][0..description.len]); try testing.expectEqual(@as(u8, 0), header[116]); try testing.expectEqual(@as(u8, 1), header[117]); const sum = checksum(header[0..118]); try testing.expectEqual(@as(u8, @intCast(sum >> 8)), header[118]); try testing.expectEqual(@as(u8, @truncate(sum)), header[119]);}
test "a line table takes the short form where the steps are small enough" { var buf: [64]u8 = undefined; var w = Io.Writer.fixed(&buf); try writeLines(&w, &.{ .{ .file = 0, .line = 3, .address = 0x1000 }, .{ .file = 0, .line = 4, .address = 0x1002 }, .{ .file = 0, .line = 5, .address = 0x1005 }, }); try testing.expectEqualSlices( u8, &.{ 0x80, 0x00, 0x03, 0x10, 0x00, 0x01, 0x02, 0x01, 0x03, 0x00 }, w.buffered(), );}
test "a line table starts afresh where a step will not fit" { var buf: [64]u8 = undefined; var w = Io.Writer.fixed(&buf); try writeLines(&w, &.{ .{ .file = 0, .line = 1, .address = 0x0000 }, // 128 lines on is one too many for the short form. .{ .file = 0, .line = 129, .address = 0x0001 }, // A different file always starts afresh. .{ .file = 1, .line = 130, .address = 0x0002 }, // So does an address more than 255 on. .{ .file = 1, .line = 131, .address = 0x0202 }, }); try testing.expectEqualSlices(u8, &.{ 0x80, 0x00, 0x01, 0x00, 0x00, 0x80, 0x00, 0x81, 0x00, 0x01, 0x81, 0x00, 0x82, 0x00, 0x02, 0x81, 0x00, 0x83, 0x02, 0x02, 0x00, }, w.buffered());}
test "a symbol too large for two bytes is written as four" { var buf: [256]u8 = undefined; var w = Io.Writer.fixed(&buf); try write(&w, .mos6502, &.{}, &.{ .{ .name = "Small", .segment = .constant, .kind = .none, .value = 0x1234 }, .{ .name = "Big", .segment = .constant, .kind = .none, .value = 0x12345678 }, .{ .name = "Neg", .segment = .constant, .kind = .none, .value = -5 }, }, &.{}, true); const body = w.buffered()[120..]; try testing.expectEqualSlices(u8, &.{ 'R', 0x01, 0x96, 0x60, 0x00, 5, 'S', 'm', 'a', 'l', 'l', 0x50, 0x12, 0x34, 3, 'B', 'i', 'g', 0xd0, 0x12, 0x34, 0x56, 0x78, 3, 'N', 'e', 'g', 0xd0, 0xff, 0xff, 0xff, 0xfb, 0x00, 0x00, }, body);}
test "a failed assembly stops after the symbols" { var buf: [256]u8 = undefined; var w = Io.Writer.fixed(&buf); try write(&w, .mos6502, &.{"a.a65"}, &.{ .{ .name = "Good", .segment = .code, .kind = .instruction, .value = 0 }, }, &.{}, false); const body = w.buffered()[120..]; try testing.expectEqualSlices(u8, &.{ 'R', 0x01, 0x96, 0x60, 5, 'a', '.', 'a', '6', '5', 0x00, 4, 'G', 'o', 'o', 'd', 5, 0x00, 0x00, 0x00, }, body);}