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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The assembled memory image, and the three shapes it can be written out in.//!//! `as65` assembles into a flat 64K address space and remembers which bytes it//! has touched. A binary file spans the lowest touched address through the//! highest, with the gaps filled; s-records and intel-hex carry only the runs//! that were actually assembled.//!//! What was touched is remembered twice over. `used` is everything either pass//! wrote and is what the output is cut from, so code that shrank between the//! passes leaves its longer pass one tail behind in the file — as65 does not//! clear the image between passes, and that tail is in its output too. The//! `pass_used` map is cleared at the start of each pass, and is the one that//! decides whether a byte is being written twice or an `org` is landing on//! something, neither of which pass two would ever get past otherwise.
const std = @import("std");const Io = std.Io;
const Image = @This();
pub const size = 0x10000;
bytes: [size]u8 = @splat(0),used: [size]bool = @splat(false),pass_used: [size]bool = @splat(false),lowest: u32 = size,highest: u32 = 0,
/// Forget what this pass wrote, keeping the image itself.pub fn beginPass(self: *Image) void { self.pass_used = @splat(false);}
pub fn isEmpty(self: *const Image) bool { return self.lowest > self.highest;}
/// Record one byte. Returns false if that address had already been written,/// which `as65` reports as overwriting a used location.pub fn put(self: *Image, address: u32, byte: u8) bool { const a = address & (size - 1); const fresh = !self.pass_used[a]; self.bytes[a] = byte; self.used[a] = true; self.pass_used[a] = true; if (a < self.lowest) self.lowest = a; if (a > self.highest) self.highest = a; return fresh;}
/// Write the whole span from the lowest to the highest byte used, filling/// anything never assembled with `fill`.pub fn writeBinary(self: *const Image, w: *Io.Writer, fill: u8) Io.Writer.Error!void { if (self.isEmpty()) return; for (self.lowest..self.highest + 1) |a| { try w.writeByte(if (self.used[a]) self.bytes[a] else fill); }}
/// The next run of assembled bytes at or after `from`, or null when there are/// no more.pub fn nextRun(self: *const Image, from: u32) ?struct { start: u32, end: u32 } { var a = from; while (a <= self.highest and !self.used[a]) a += 1; if (a > self.highest) return null; var end = a; while (end + 1 <= self.highest and self.used[end + 1]) end += 1; return .{ .start = a, .end = end };}
/// How many bytes the record starting at `start` carries.////// At most thirty-two, and never past the next thirty-two byte boundary: `as65`/// lines its records up on those, so a run beginning at $000a opens with a/// twenty-two byte record and only then settles into full ones.fn recordLength(start: u32, end: u32) u32 { const to_boundary = 32 - (start % 32); return @min(to_boundary, end - start + 1);}
fn checksum(bytes: []const u8) u8 { var sum: u8 = 0; for (bytes) |b| sum +%= b; return sum;}
/// Motorola s-records: S1 data records of at most 32 bytes, then an S5 record/// counting them, and an S9 start-address record when the program has a start/// address or `-z` asked for one regardless.pub fn writeSRecords( self: *const Image, w: *Io.Writer, start_address: ?u32, force_start_record: bool,) Io.Writer.Error!void { var record: [32 + 3]u8 = undefined; var count: u16 = 0; if (!self.isEmpty()) { var at: u32 = self.lowest; while (self.nextRun(at)) |run| : (at = run.end + 1) { var a = run.start; while (a <= run.end) { const len = recordLength(a, run.end); record[0] = @intCast(len + 3); record[1] = @intCast((a >> 8) & 0xff); record[2] = @intCast(a & 0xff); @memcpy(record[3..][0..len], self.bytes[a..][0..len]); try w.print("S1{X:0>2}{X:0>4}", .{ record[0], a & 0xffff }); for (record[3..][0..len]) |b| try w.print("{X:0>2}", .{b}); try w.print("{X:0>2}\n", .{~checksum(record[0 .. 3 + len])}); count +%= 1; a += len; } } } const tally = [_]u8{ 3, @intCast((count >> 8) & 0xff), @intCast(count & 0xff) }; try w.print("S503{X:0>4}{X:0>2}\n", .{ count, ~checksum(&tally) }); if (start_address orelse (if (force_start_record) @as(u32, 0) else null)) |entry| { const rec = [_]u8{ 3, @intCast((entry >> 8) & 0xff), @intCast(entry & 0xff) }; try w.print("S903{X:0>4}{X:0>2}\n", .{ entry & 0xffff, ~checksum(&rec) }); }}
/// Intel hex: type 00 data records of at most 32 bytes, then an end-of-file/// record carrying the start address when there is one.pub fn writeIntelHex( self: *const Image, w: *Io.Writer, start_address: ?u32,) Io.Writer.Error!void { var record: [32 + 4]u8 = undefined; if (!self.isEmpty()) { var at: u32 = self.lowest; while (self.nextRun(at)) |run| : (at = run.end + 1) { var a = run.start; while (a <= run.end) { const len = recordLength(a, run.end); record[0] = @intCast(len); record[1] = @intCast((a >> 8) & 0xff); record[2] = @intCast(a & 0xff); record[3] = 0; @memcpy(record[4..][0..len], self.bytes[a..][0..len]); try w.print(":{X:0>2}{X:0>4}00", .{ record[0], a & 0xffff }); for (record[4..][0..len]) |b| try w.print("{X:0>2}", .{b}); try w.print("{X:0>2}\n", .{0 -% checksum(record[0 .. 4 + len])}); a += len; } } } const entry = start_address orelse 0; const rec = [_]u8{ 0, @intCast((entry >> 8) & 0xff), @intCast(entry & 0xff), 1 }; try w.print(":00{X:0>4}01{X:0>2}\n", .{ entry & 0xffff, 0 -% checksum(&rec) });}
const testing = std.testing;
fn render(image: *const Image, comptime which: enum { s19, hex }) ![]u8 { var buf: [4096]u8 = undefined; var w = Io.Writer.fixed(&buf); switch (which) { .s19 => try image.writeSRecords(&w, null, false), .hex => try image.writeIntelHex(&w, null), } return testing.allocator.dupe(u8, w.buffered());}
test "s-records match what as65 writes for a single byte at $0010" { var image = Image{}; _ = image.put(0x10, 0x09); const out = try render(&image, .s19); defer testing.allocator.free(out); try testing.expectEqualStrings("S104001009E2\nS5030001FB\n", out);}
test "intel-hex matches what as65 writes for a single byte at $0010" { var image = Image{}; _ = image.put(0x10, 0x09); const out = try render(&image, .hex); defer testing.allocator.free(out); try testing.expectEqualStrings(":0100100009E6\n:00000001FF\n", out);}
test "records are cut at thirty-two byte boundaries" { try testing.expectEqual(@as(u32, 22), recordLength(0x0a, 0xffff)); try testing.expectEqual(@as(u32, 32), recordLength(0x20, 0xffff)); try testing.expectEqual(@as(u32, 3), recordLength(0x1d, 0x1f));}
test "a binary spans the lowest through the highest byte used" { var image = Image{}; _ = image.put(0x10, 1); _ = image.put(0x13, 2); var buf: [16]u8 = undefined; var w = Io.Writer.fixed(&buf); try image.writeBinary(&w, 0xff); try testing.expectEqualSlices(u8, &.{ 1, 0xff, 0xff, 2 }, w.buffered());}
test "writing an address twice is reported" { var image = Image{}; try testing.expect(image.put(0x20, 1)); try testing.expect(!image.put(0x20, 2));}