// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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); }