From d968044dc93df2478a7ffb75413a236834380398 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:51:14 -0700 Subject: [PATCH] feat: extend dot matrix deck --- build.zig | 51 +++++++++- src/emulator.zig | 38 ++++++++ src/joypad.zig | 89 +++++++++++++++++ src/main.zig | 249 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 src/joypad.zig diff --git a/build.zig b/build.zig index 815819d..aa3f102 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,39 @@ fn sdl2PrefixFound(b: *std.Build, prefix: []const u8) bool { return true; } +// A step that prints a notice and does nothing else. It lets optional +// steps like the fixture regenerator skip cleanly when their inputs are +// not present in the checkout. +const NoticeStep = struct { + step: std.Build.Step, + message: []const u8, + + fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) !void { + _ = options; + const self: *NoticeStep = @fieldParentPtr("step", step); + std.debug.print("SKIP: {s}\n", .{self.message}); + } + + fn create(b: *std.Build, message: []const u8) *std.Build.Step { + const self = b.allocator.create(NoticeStep) catch @panic("OOM"); + self.* = .{ + .step = std.Build.Step.init(.{ + .id = .custom, + .name = "notice", + .owner = b, + .makeFn = make, + }), + .message = message, + }; + return &self.step; + } +}; + +fn dirExists(b: *std.Build, path: []const u8) bool { + b.build_root.handle.access(b.graph.io, path, .{}) catch return false; + return true; +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -131,19 +164,33 @@ pub fn build(b: *std.Build) void { const run_asm_tests = b.addRunArtifact(asm_tests); test_step.dependOn(&run_asm_tests.step); + const joypad_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/joypad.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_joypad_tests = b.addRunArtifact(joypad_tests); + test_step.dependOn(&run_joypad_tests.step); + const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); - { + if (dirExists(b, "fixtures/asm")) { const run = b.addRunArtifact(gbasm); run.addDirectoryArg(b.path("fixtures/asm")); run.addDirectoryArg(b.path("fixtures/roms")); fixtures_step.dependOn(&run.step); + } else { + fixtures_step.dependOn(NoticeStep.create(b, "fixtures/asm is not present; nothing to regenerate")); } const blargg_step = b.step("test-blargg", "Run the blargg cpu_instrs ROMs in headless mode and check their serial output"); - { + if (dirExists(b, "fixtures/blargg")) { const run = b.addRunArtifact(headless); run.addArg("--blargg-suite"); run.addDirectoryArg(b.path("fixtures/blargg")); blargg_step.dependOn(&run.step); + } else { + blargg_step.dependOn(NoticeStep.create(b, "fixtures/blargg is not present; add the ROMs to run the suite")); } } diff --git a/src/emulator.zig b/src/emulator.zig index 4639f75..bb67c24 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -89,3 +89,41 @@ test "page size sanity" { try std.testing.expectEqual(@as(usize, 160), ppu.ScreenWidth); try std.testing.expectEqual(@as(usize, 144), ppu.ScreenHeight); } + +fn frameChecksum(frame: []const u32) u64 { + var hash: u64 = 0xcbf29ce484222325; + for (frame) |pixel| { + var bytes = [4]u8{ @truncate(pixel), @truncate(pixel >> 8), @truncate(pixel >> 16), @truncate(pixel >> 24) }; + for (&bytes) |byte| { + hash ^= byte; + hash *%= 0x100000001b3; + } + } + return hash; +} + +test "a frame completes within the frame cycle budget" { + const allocator = std.testing.allocator; + var rom: [0x8000]u8 = [_]u8{0x00} ** 0x8000; + var emulator = try Emulator.init(allocator, &rom); + defer emulator.deinit(); + emulator.runFrame(70_224); + try std.testing.expectEqual(@as(u64, 0), emulator.total_cycles % 4); + try std.testing.expectEqual(@as(u64, 65_664), emulator.total_cycles); +} + +test "frame output is deterministic across runs" { + const allocator = std.testing.allocator; + var rom: [0x8000]u8 = [_]u8{0x00} ** 0x8000; + var first = try Emulator.init(allocator, &rom); + defer first.deinit(); + first.runFrame(70_224); + const hash_a = frameChecksum(&first.bus.ppu.frame); + + var second = try Emulator.init(allocator, &rom); + defer second.deinit(); + second.runFrame(70_224); + const hash_b = frameChecksum(&second.bus.ppu.frame); + + try std.testing.expectEqual(hash_a, hash_b); +} diff --git a/src/joypad.zig b/src/joypad.zig new file mode 100644 index 0000000..2ae7fe5 --- /dev/null +++ b/src/joypad.zig @@ -0,0 +1,89 @@ +const std = @import("std"); + +// Joypad key model for the frontend. +// +// The Game Boy joypad register is low-active. A pressed button reads 0 +// and a released button reads 1. The windowed frontend converts SDL +// scancodes into `Key` values, then `apply` updates the button byte that +// the memory bus exposes at 0xff00. + +pub const Key = enum { + a, + b, + start, + select, + up, + down, + left, + right, +}; + +pub const Button = struct { + pub const right: u8 = 1 << 0; + pub const left: u8 = 1 << 1; + pub const up: u8 = 1 << 2; + pub const down: u8 = 1 << 3; + pub const a: u8 = 1 << 4; + pub const b: u8 = 1 << 5; + pub const select: u8 = 1 << 6; + pub const start: u8 = 1 << 7; +}; + +pub fn mask(key: Key) u8 { + return switch (key) { + .a => Button.a, + .b => Button.b, + .start => Button.start, + .select => Button.select, + .up => Button.up, + .down => Button.down, + .left => Button.left, + .right => Button.right, + }; +} + +// Updates the button byte for one key. A press clears the low-active bit. +// A release sets the bit back to 1. +pub fn apply(key: Key, pressed: bool, buttons: *u8) void { + const bit = mask(key); + if (pressed) { + buttons.* &= ~bit; + } else { + buttons.* |= bit; + } +} + +test "each key maps to its own bit" { + try std.testing.expectEqual(@as(u8, 0x01), mask(.right)); + try std.testing.expectEqual(@as(u8, 0x02), mask(.left)); + try std.testing.expectEqual(@as(u8, 0x04), mask(.up)); + try std.testing.expectEqual(@as(u8, 0x08), mask(.down)); + try std.testing.expectEqual(@as(u8, 0x10), mask(.a)); + try std.testing.expectEqual(@as(u8, 0x20), mask(.b)); + try std.testing.expectEqual(@as(u8, 0x40), mask(.select)); + try std.testing.expectEqual(@as(u8, 0x80), mask(.start)); +} + +test "a press clears the button bit" { + var buttons: u8 = 0xff; + apply(.a, true, &buttons); + try std.testing.expectEqual(@as(u8, 0xef), buttons); + apply(.right, true, &buttons); + try std.testing.expectEqual(@as(u8, 0xee), buttons); +} + +test "a release restores the button bit" { + var buttons: u8 = 0x00; + apply(.start, false, &buttons); + try std.testing.expectEqual(@as(u8, 0x80), buttons); + apply(.down, false, &buttons); + try std.testing.expectEqual(@as(u8, 0x88), buttons); +} + +test "keys do not disturb each other" { + var buttons: u8 = 0xff; + apply(.b, true, &buttons); + apply(.select, true, &buttons); + apply(.up, false, &buttons); + try std.testing.expectEqual(@as(u8, 0x9b), buttons); +} diff --git a/src/main.zig b/src/main.zig index 643116a..181bbea 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,250 @@ const std = @import("std"); +const joypad = @import("joypad.zig"); -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +const c = @cImport({ + @cDefine("SDL_MAIN_HANDLED", "1"); + @cInclude("SDL2/SDL.h"); +}); + +const Emulator = @import("emulator.zig").Emulator; +const ppu = @import("ppu.zig"); + +// The windowed frontend. It opens an SDL2 window, blits the PPU frame +// through a streaming texture, and maps keyboard input to the joypad. +// The emulation core is shared with the headless runner; only input and +// presentation live here. + +const frame_cycles: u64 = 70_224; +const target_fps: f64 = 59.7275; +const default_scale: u32 = 3; + +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - windowed frontend + \\Usage: + \\ {s} [--scale N] + \\ + \\Controls: + \\ Z B button + \\ X A button + \\ Enter Start + \\ Backspace Select + \\ Arrow keys D-pad + \\ Esc Quit + \\ + , .{program}); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +// Converts an SDL scancode to a joypad key. Returns null for keys that +// do not control the Game Boy. +fn keyOf(scancode: c.SDL_Scancode) ?joypad.Key { + return switch (scancode) { + c.SDL_SCANCODE_Z => .b, + c.SDL_SCANCODE_X => .a, + c.SDL_SCANCODE_RETURN => .start, + c.SDL_SCANCODE_BACKSPACE => .select, + c.SDL_SCANCODE_RIGHT => .right, + c.SDL_SCANCODE_LEFT => .left, + c.SDL_SCANCODE_UP => .up, + c.SDL_SCANCODE_DOWN => .down, + else => null, + }; +} + +const Frontend = struct { + emulator: *Emulator, + window: *c.SDL_Window, + renderer: *c.SDL_Renderer, + texture: *c.SDL_Texture, + frame_time: i96 = 0, + elapsed_frames: u32 = 0, + + fn fail(prefix: []const u8) noreturn { + std.debug.print("{s}: {s}\n", .{ prefix, c.SDL_GetError() }); + std.process.exit(1); + } + + fn init(emulator: *Emulator, scale: u32) Frontend { + _ = c.SDL_SetMainReady(); + if (c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_EVENTS) != 0) fail("SDL_Init failed"); + _ = c.SDL_SetHint(c.SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); + + const width: c_int = @intCast(@as(u32, @intCast(ppu.ScreenWidth)) * scale); + const height: c_int = @intCast(@as(u32, @intCast(ppu.ScreenHeight)) * scale); + + const title = "Dot Matrix Deck"; + const window = c.SDL_CreateWindow( + title, + c.SDL_WINDOWPOS_UNDEFINED, + c.SDL_WINDOWPOS_UNDEFINED, + width, + height, + c.SDL_WINDOW_SHOWN, + ) orelse fail("SDL_CreateWindow failed"); + + const renderer = c.SDL_CreateRenderer( + window, + -1, + c.SDL_RENDERER_ACCELERATED, + ) orelse c.SDL_CreateRenderer(window, -1, 0) orelse fail("SDL_CreateRenderer failed"); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(ppu.ScreenWidth), + @intCast(ppu.ScreenHeight), + ) orelse fail("SDL_CreateTexture failed"); + + return .{ .emulator = emulator, .window = window, .renderer = renderer, .texture = texture }; + } + + fn deinit(self: *Frontend) void { + c.SDL_DestroyTexture(self.texture); + c.SDL_DestroyRenderer(self.renderer); + c.SDL_DestroyWindow(self.window); + c.SDL_Quit(); + } + + // Returns true when the loop should stop. + fn handleEvents(self: *Frontend) bool { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => return true, + c.SDL_KEYDOWN => { + if (event.key.keysym.scancode == c.SDL_SCANCODE_ESCAPE) return true; + if (keyOf(event.key.keysym.scancode)) |key| { + joypad.apply(key, true, &self.emulator.bus.joypad); + } + }, + c.SDL_KEYUP => { + if (keyOf(event.key.keysym.scancode)) |key| { + joypad.apply(key, false, &self.emulator.bus.joypad); + } + }, + else => {}, + } + } + return false; + } + + fn presentFrame(self: *Frontend) void { + var pixels: ?*anyopaque = null; + var pitch: c_int = 0; + if (c.SDL_LockTexture(self.texture, null, &pixels, &pitch) == 0) { + const dst = @as([*]u8, @ptrCast(pixels.?)); + const src = std.mem.sliceAsBytes(self.emulator.bus.ppu.frame[0..]); + @memcpy(dst[0..src.len], src); + c.SDL_UnlockTexture(self.texture); + } + _ = c.SDL_RenderClear(self.renderer); + _ = c.SDL_RenderCopy(self.renderer, self.texture, null, null); + c.SDL_RenderPresent(self.renderer); + } + + fn updateTitle(self: *Frontend) void { + var buffer: [48]u8 = undefined; + const fps: f64 = if (self.frame_time != 0) @as(f64, @floatFromInt(std.time.ns_per_s)) / @as(f64, @floatFromInt(self.frame_time)) else 0; + const text = std.fmt.bufPrint(&buffer, "Dot Matrix Deck - {d:.1} fps", .{fps}) catch return; + c.SDL_SetWindowTitle(self.window, text.ptr); + } + + fn run(self: *Frontend, io: std.Io) !void { + var last_frame = std.Io.Clock.Timestamp.now(io, .awake); + var last_title = last_frame; + const frame_ns: i96 = @intFromFloat(@as(f64, std.time.ns_per_s) / target_fps); + + var running = true; + while (running) { + running = !self.handleEvents(); + if (!running) break; + + self.emulator.runFrame(frame_cycles); + self.presentFrame(); + self.elapsed_frames += 1; + + const now = std.Io.Clock.Timestamp.now(io, .awake); + const elapsed = last_frame.durationTo(now).raw.nanoseconds; + const slack = frame_ns - elapsed; + if (slack > 0) { + const millis: u32 = @intCast(@divTrunc(slack, std.time.ns_per_ms)); + c.SDL_Delay(millis); + } + const paced = std.Io.Clock.Timestamp.now(io, .awake); + self.frame_time = last_frame.durationTo(paced).raw.nanoseconds; + last_frame = paced; + + if (paced.durationTo(last_title).raw.nanoseconds >= std.time.ns_per_s) { + self.updateTitle(); + last_title = paced; + } + } + } +}; + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 2) { + usage(argv[0]); + std.process.exit(2); + } + + var rom_path: ?[]const u8 = null; + var scale: u32 = default_scale; + var index: usize = 1; + while (index < argv.len) : (index += 1) { + const arg = argv[index]; + if (std.mem.eql(u8, arg, "--scale")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--scale needs a value\n", .{}); + std.process.exit(2); + } + scale = std.fmt.parseInt(u32, argv[index], 10) catch { + std.debug.print("bad scale value: {s}\n", .{argv[index]}); + std.process.exit(2); + }; + if (scale == 0) scale = 1; + } else if (rom_path == null) { + rom_path = arg; + } else { + std.debug.print("unknown argument: {s}\n", .{arg}); + usage(argv[0]); + std.process.exit(2); + } + } + + const rom = try readFile(io, allocator, rom_path.?); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + var frontend = Frontend.init(&emulator, scale); + defer frontend.deinit(); + + try frontend.run(io); }