// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The `as65` command. const std = @import("std"); const Io = std.Io; const aigrette65 = @import("aigrette65"); const banner = aigrette65.banner; const Options = aigrette65.Options; const Assembler = aigrette65.Assembler; const Image = aigrette65.Image; /// The one-line usage, printed whenever the command line was no good. const usage_line = "Usage: " ++ banner.program ++ " [-DIcghilmnopqstuvwxz] file\n"; /// The rest of what `-?` prints. const option_list = "\nOptions:\n\t-D : Define label, if no name given define DEBUG.\n\t-I : Specify include file path string.\n\t-c : Show number of cycles per instruction in listing.\n\t-g : Generate source-level debug information.\n\t-h : Specify height of page for listing.\n\t-i : Ignore case in opcodes.\n\t-l : Generate pass 2 listing.\n\t-l : Listing file name.\n\t-m : Show macro expansions in listing.\n\t-n : Disable optimizations.\n\t-o : Binary/hex output file name.\n\t-p : Generate pass 1 listing.\n\t-q : Quiet mode.\n\t-s1 -s : Write s-records instead of binary file.\n\t-s2 : Write intel-hex file instead of binary file.\n\t-t : Generate symbol table.\n\t-u : List memory usage map.\n\t-v : Verbose mode.\n\t-w : Specify listing width, 131 if no number given.\n\t-x1 : Use 6502 undefined instructions.\n\t-x2 -x : Use 65SC02 extensions.\n\t-z : Fill unused memory with zeros.\n"; /// The extensions tried in turn after the name as it was given, which is how /// `as65` finds `prog.a65` when told to assemble `prog`. const source_extensions = [_][]const u8{ ".a65", ".asm", ".s" }; /// Source files, read out of a directory. /// /// The library never opens a file: it asks for a name and is handed a reader /// or told there is nothing there. This is the command's answer, and the only /// place it reads input from. 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; } /// Whether a name can be opened at all, which is how the command finds /// out the source is missing before it has said anything. fn exists(self: *const Files, name: []const u8) bool { const file = self.dir.openFile(self.io, name, .{}) catch return false; file.close(self.io); return true; } }; /// The exit statuses `as65` documents. const Status = enum(u8) { ok = 0, bad_commandline = 1, file_error = 2, assembly_errors = 3, }; pub fn main(init: std.process.Init) !u8 { const gpa = init.gpa; const io = init.io; const arena = init.arena.allocator(); 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 {}; var stderr_buffer: [256]u8 = undefined; var stderr_file: Io.File.Writer = .init(.stderr(), io, &stderr_buffer); const stderr = &stderr_file.interface; defer stderr.flush() catch {}; const argv = try init.minimal.args.toSlice(arena); const args = if (argv.len > 1) argv[1..] else argv[0..0]; var diagnostic: Options.Diagnostic = .{}; const include_env = init.environ_map.get("INCLUDE"); const parsed = Options.parse(gpa, args, include_env, &diagnostic); // A page length that went missing is said before anything else, banner // included, and does not stop the run. if (diagnostic.page_length_missing) { try stdout.writeAll("Page length missing after -h.\n"); } var options = parsed catch |err| { switch (err) { // `-?` is the whole of the help. error.HelpRequested => { try stdout.print("{s}\n\n" ++ usage_line ++ option_list, .{banner.long}); }, // Nothing to assemble: the banner and how to say it properly. The // banner goes even when `-q` asked for silence, as as65's does. error.NoInputFile => { try stdout.print("{s}\n\n" ++ usage_line, .{banner.long}); }, error.UnknownOption => { try stdout.print("{s}\n\n{s}\n" ++ usage_line, .{ banner.long, diagnostic.text() }); }, // This one is said on its own, with no banner over it. error.MultipleInputFiles => try stdout.print("{s}\n", .{diagnostic.text()}), error.OutOfMemory => return err, } return @backingInt(Status.bad_commandline); }; defer options.deinit(gpa); // The name the object and listing files are built from is the one that was // typed; the name the listing header and the diagnostics show is the file // that was actually found. var files = Files{ .io = io, .dir = Io.Dir.cwd() }; const base = stemOf(options.input); options.input = resolveSource(&files, arena, options.input) catch |err| switch (err) { error.FileNotFound => { try stdout.print("Can't open source file {s}{s}.\n", .{ options.input, source_extensions[source_extensions.len - 1], }); return @backingInt(Status.file_error); }, else => return err, }; if (!options.quiet) try stdout.print("{s}\n", .{banner.long}); const listing_path = options.listing_path orelse try std.fmt.allocPrint(arena, "{s}.lst", .{base}); const output_path = options.output_path orelse try std.fmt.allocPrint(arena, "{s}{s}", .{ base, switch (options.format) { .binary => ".bin", .s_records => ".s19", .intel_hex => ".hex", }, }); var listing_buffer: [8192]u8 = undefined; var listing_sink: ?Io.File.Writer = null; var listing_writer: ?*Io.Writer = null; if (!isNul(listing_path)) { if (std.mem.eql(u8, listing_path, "-")) { listing_writer = stdout; } else { const file = Io.Dir.cwd().createFile(io, listing_path, .{}) catch { try stdout.print("Can't create output file {s}.\n", .{listing_path}); return @backingInt(Status.file_error); }; listing_sink = .init(file, io, &listing_buffer); listing_writer = &listing_sink.?.interface; } } defer if (listing_sink) |*sink| { sink.interface.flush() catch {}; sink.file.close(io); }; const image = try gpa.create(Image); defer gpa.destroy(image); image.* = .{}; var assembler = Assembler.init(gpa, files.source(), &options, image, stdout); assembler.listing = listing_writer; assembler.listing_is_console = listing_writer == stdout; if (!options.quiet) assembler.progress = stderr; defer assembler.deinit(); if (options.verbose) { // The blank line separates the banner from what follows, so it goes // only when there was a banner. if (!options.quiet) try stdout.writeByte('\n'); try stdout.print("Optimization {s}abled.\n", .{if (options.optimize) "en" else "dis"}); switch (options.cpu) { .mos6502 => {}, .mos6502x => try stdout.writeAll("Undefined 6502 extensions enabled.\n"), .g65sc02 => try stdout.writeAll("65SC02 extensions enabled.\n"), } } assembler.assemble() catch |err| switch (err) { // The file was there a moment ago, when its name was resolved. error.InputNotFound => { try stdout.print("Can't open source file {s}.\n", .{options.input}); return @backingInt(Status.file_error); }, else => return err, }; // The debug information is written whether the assembly worked or not. // One that fell over in pass one has the labels that pass managed to // define and no line table at all; one that got to pass two has both, // errors or no errors. if (options.debug_info) { writeDebugInfo(gpa, io, stdout, &assembler, &options, output_path, assembler.reached_pass2) catch |err| switch (err) { error.OutputFileNotCreated => return @backingInt(Status.file_error), else => return err, }; } if (assembler.failed()) { // No object file when the assembly failed, but the listing is still // closed off with its form feed. try assembler.finishListing(true); return @backingInt(Status.assembly_errors); } writeOutput(io, stdout, &assembler, &options, image, output_path) catch |err| switch (err) { error.OutputFileNotCreated => return @backingInt(Status.file_error), else => return err, }; return @backingInt(Status.ok); } fn writeOutput( io: Io, stdout: *Io.Writer, assembler: *Assembler, options: *const Options, image: *const Image, path: []const u8, ) !void { const kind = switch (options.format) { .binary => "binary", .s_records => "s-records", .intel_hex => "intel-hex", }; // A file called `nul` is thrown away, but the listing is still rounded off // the way the chosen format rounds it off. const wanted = !isNul(path); if (wanted and options.verbose and options.format == .binary) { try stdout.writeAll("Checking binary file boundaries.\n"); } if (wanted and options.verbose) { try stdout.print("Writing {s} file \"{s}\".\n", .{ kind, path }); } var buffer: [8192]u8 = undefined; var sink: ?Io.File.Writer = null; if (wanted) { const file = Io.Dir.cwd().createFile(io, path, .{}) catch { try stdout.print("Can't create output file {s}.\n", .{path}); return error.OutputFileNotCreated; }; sink = .init(file, io, &buffer); } defer if (sink) |*s| { s.interface.flush() catch {}; s.file.close(io); }; switch (options.format) { .binary => { if (sink) |*s| { try image.writeBinary(&s.interface, if (options.zero_fill) 0x00 else 0xff); var report: [256]u8 = undefined; var block: Io.Writer = .fixed(&report); if (!image.isEmpty()) { try block.print( "Wrote binary from address ${x:0>4} through ${x:0>4}.\nTotal size {d} bytes.\n", .{ image.lowest, image.highest, image.highest - image.lowest + 1 }, ); } if (assembler.start_address) |entry| { try block.print("Program start address is at ${x:0>4} ({d}).\n", .{ @as(u32, @bitCast(entry)) & 0xffff, entry, }); } if (block.buffered().len != 0) try assembler.report(block.buffered()); } // The form feed that closes the listing belongs to the binary // path; a run that writes s-records or intel-hex leaves it off. try assembler.finishListing(false); }, // Neither of these ends the listing with a form feed, but a page the // listing has already filled is still started. .s_records => { if (sink) |*s| { try image.writeSRecords(&s.interface, entryAddress(assembler), options.zero_fill); } try assembler.flushPendingPage(); }, .intel_hex => { if (sink) |*s| try image.writeIntelHex(&s.interface, entryAddress(assembler)); try assembler.flushPendingPage(); }, } } /// Write the `-g` file, named after the object file with `.dbg` in place of /// its extension. It is written even when the object file itself was thrown /// away with `-onul`. fn writeDebugInfo( gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, assembler: *Assembler, options: *const Options, output_path: []const u8, complete: bool, ) !void { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const path = try std.fmt.allocPrint(arena, "{s}.dbg", .{stemOf(output_path)}); const info = try assembler.debugInfo(arena); var buffer: [8192]u8 = undefined; const file = Io.Dir.cwd().createFile(io, path, .{}) catch { try stdout.print("Can't create output file {s}.\n", .{path}); return error.OutputFileNotCreated; }; defer file.close(io); var sink: Io.File.Writer = .init(file, io, &buffer); try aigrette65.debug.write(&sink.interface, switch (options.cpu) { .mos6502 => .mos6502, .mos6502x => .mos6502x, .g65sc02 => .g65sc02, }, info.sources, info.symbols, info.lines, complete); try sink.interface.flush(); } /// Find the source file, trying the name as it was given and then that name /// with each of `source_extensions` pasted on the end. The name that opened is /// what the listing header and the diagnostics show. fn resolveSource(files: *const Files, arena: std.mem.Allocator, given: []const u8) ![]const u8 { if (files.exists(given)) return given; for (source_extensions) |extension| { const candidate = try std.fmt.allocPrint(arena, "{s}{s}", .{ given, extension }); if (files.exists(candidate)) return candidate; } return error.FileNotFound; } /// The start address `end` named, as an address rather than a signed value. fn entryAddress(assembler: *const Assembler) ?u32 { const value = assembler.start_address orelse return null; return @as(u32, @bitCast(value)) & 0xffff; } /// The input file name with its extension removed, which is what the default /// listing and output names are built from. fn stemOf(path: []const u8) []const u8 { const slash = std.mem.lastIndexOfAny(u8, path, "/\\"); const start = if (slash) |i| i + 1 else 0; const dot = std.mem.lastIndexOfScalar(u8, path[start..], '.') orelse return path; if (dot == 0) return path; return path[0 .. start + dot]; } /// `as65` discards any output file called `nul`. fn isNul(path: []const u8) bool { return std.ascii.eqlIgnoreCase(path, "nul"); } test "the stem drops one extension and nothing else" { try std.testing.expectEqualStrings("pg", stemOf("pg.a65")); try std.testing.expectEqualStrings("pg", stemOf("pg")); try std.testing.expectEqualStrings("dir.d/pg", stemOf("dir.d/pg.a65")); try std.testing.expectEqualStrings("dir.d/pg", stemOf("dir.d/pg")); }