Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ zig-out/
*.exe
.DS_Store
*.gb
!fixtures/roms/*.gb
*.sav
*.swp
39 changes: 37 additions & 2 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ fn sdl2PrefixFound(b: *std.Build, prefix: []const u8) bool {
return true;
}

fn sdl2ImportLibFound(b: *std.Build, prefix: []const u8) bool {
const library = std.fmt.allocPrint(b.allocator, "{s}/lib/libSDL2.dll.a", .{prefix}) catch return false;
defer b.allocator.free(library);
b.build_root.handle.access(b.graph.io, library, .{}) catch return false;
return true;
}

pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
Expand Down Expand Up @@ -84,9 +91,22 @@ pub fn build(b: *std.Build) void {
.link_libc = true,
}),
});
exe.root_module.linkSystemLibrary("SDL2", .{});
if (target.result.os.tag == .windows) {
// Link the DLL import library so the mingw static archive
// (built against a different CRT) is not pulled in.
if (sdl_prefix) |prefix| {
if (sdl2ImportLibFound(b, prefix)) {
const import_lib = b.pathJoin(&.{ prefix, "lib", "libSDL2.dll.a" });
exe.root_module.addObjectFile(.{ .cwd_relative = import_lib });
} else {
exe.root_module.linkSystemLibrary("SDL2", .{});
}
} else {
exe.root_module.linkSystemLibrary("SDL2", .{});
}
for (sdl_deps) |lib| exe.root_module.linkSystemLibrary(lib, .{});
} else {
exe.root_module.linkSystemLibrary("SDL2", .{});
}
if (sdl_prefix) |prefix| {
exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) });
Expand Down Expand Up @@ -123,14 +143,29 @@ pub fn build(b: *std.Build) void {

const asm_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("tools/gbasm.zig"),
.root_source_file = b.path("fixtures_tests.zig"),
.target = target,
.optimize = optimize,
}),
});
const run_asm_tests = b.addRunArtifact(asm_tests);
test_step.dependOn(&run_asm_tests.step);

const smoke_rom = b.path("fixtures/roms/smoke.gb");
const run_smoke = b.addRunArtifact(headless);
run_smoke.addFileArg(smoke_rom);
run_smoke.addArgs(&.{ "--expect", "pass", "--max-cycles", "1000000" });
test_step.dependOn(&run_smoke.step);

const smoke_step = b.step("smoke", "Run the bundled smoke ROM headlessly and require a pass verdict");
smoke_step.dependOn(&run_smoke.step);

const frame_run = b.addRunArtifact(headless);
frame_run.addFileArg(smoke_rom);
frame_run.addArgs(&.{ "--dump-frame", "frame.ppm", "--max-cycles", "1000000" });
const frame_step = b.step("frame", "Render one frame of the smoke ROM to frame.ppm in the project root");
frame_step.dependOn(&frame_run.step);

const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources");
{
const run = b.addRunArtifact(gbasm);
Expand Down
73 changes: 73 additions & 0 deletions fixtures/asm/smoke.asm
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
; smoke.asm - minimal SM83 self-test for the dot-matrix-deck emulator.
;
; The ROM runs a few arithmetic and stack checks. On success it prints
; "Passed" over the serial port and hangs. On failure it prints 'F' in a
; tight loop. The headless runner and CI use this ROM to verify the core.
; Regenerate the matching smoke.gb with `zig build fixtures`.

ORG $0000 ; image base so the ROM is a full $8000 bytes
ORG $0100
JP start ; entry point

ORG $0134
DB "SMOKE TEST" ; title

ORG $0143
DB $80 ; CGB flag: works on Game Boy Color

ORG $0150
start:
LD A,$12
ADD A,$21
CP $33
JR NZ, fail_print
LD A,$05
LD B,$02
SUB B
CP $03
JR NZ, fail_print
LD HL,$1234
PUSH HL
POP DE
LD A,D
CP $12
JR NZ, fail_print
LD A,E
CP $34
JR NZ, fail_print
LD A,$00
CALL NZ, fail ; must not be taken
LD A,$01
CALL Z, print_message ; Z is set, so this prints the message
JP fail_print ; unreachable

print_message:
LD HL, message
loop:
LD A,(HL+)
OR A
JR Z, done
CALL print_char
JR loop

done:
JR done ; success: hang here

fail:
JP fail_print

print_char:
LDH ($01),A ; SB = character
LD A,$81
LDH ($02),A ; SC = start transfer
RET

fail_print:
LD A,$46 ; 'F'
CALL print_char
JR fail_print

message:
DB "Passed", $0A, $00

PAD $8000
Binary file added fixtures/roms/smoke.gb
Binary file not shown.
13 changes: 13 additions & 0 deletions fixtures_tests.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const std = @import("std");
const gbasm = @import("tools/gbasm.zig");

// Package-root shim for the gbasm test suite.
//
// `zig test` roots a package at the directory of its root source file.
// gbasm's fixture round-trip test embeds files under fixtures/, so this
// module must live at the project root to keep those paths inside the
// package. The build.zig `test` step uses this file as its root source.

test {
std.testing.refAllDecls(gbasm);
}
2 changes: 1 addition & 1 deletion src/emulator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub const Emulator = struct {
pub fn runFrame(self: *Emulator, cap: u64) void {
var remaining = cap;
while (!self.bus.ppu.takeFrame()) {
self.step();
_ = self.step();
remaining -= 1;
if (remaining == 0) return;
}
Expand Down
33 changes: 32 additions & 1 deletion src/headless.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const std = @import("std");
const Emulator = @import("emulator.zig").Emulator;
const serial = @import("serial.zig");
const disasm = @import("disasm.zig");
const ppu = @import("ppu.zig");

const default_max_cycles: u64 = 2_000_000_000;

Expand All @@ -11,6 +12,7 @@ const Options = struct {
max_cycles: u64 = default_max_cycles,
trace: bool = false,
expect: ?serial.Verdict = null,
dump_frame: ?[]const u8 = null,
timeout_ms: u64 = 120_000,
};

Expand All @@ -19,10 +21,12 @@ fn usage(program: []const u8) void {
\\Dot Matrix Deck - headless runner
\\Usage:
\\ {s} <rom.gb> [--max-cycles N] [--trace] [--expect pass|fail|any]
\\ [--dump-frame out.ppm]
\\ {s} --blargg-suite <directory>
\\
\\Runs a ROM without a window, prints its serial output, and sets the
\\exit code from the test verdict.
\\exit code from the test verdict. --dump-frame writes the final
\\screen as a binary PPM image.
\\
, .{ program, program });
}
Expand Down Expand Up @@ -77,6 +81,22 @@ const Runner = struct {
}
};

fn writeFrame(io: std.Io, allocator: std.mem.Allocator, path: []const u8, frame: *const [ppu.ScreenWidth * ppu.ScreenHeight]u32) !void {
const header = try std.fmt.allocPrint(allocator, "P6\n{d} {d}\n255\n", .{ ppu.ScreenWidth, ppu.ScreenHeight });
defer allocator.free(header);
const data = try allocator.alloc(u8, header.len + ppu.ScreenWidth * ppu.ScreenHeight * 3);
defer allocator.free(data);
@memcpy(data[0..header.len], header);
for (frame, 0..) |pixel, i| {
const offset = header.len + i * 3;
data[offset + 0] = @truncate(pixel >> 16);
data[offset + 1] = @truncate(pixel >> 8);
data[offset + 2] = @truncate(pixel);
}
const dir = std.Io.Dir.cwd();
try dir.writeFile(io, .{ .sub_path = path, .data = data });
}

fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 {
const rom = try readFile(io, allocator, options.rom_path.?);
defer allocator.free(rom);
Expand All @@ -92,6 +112,10 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 {
};
runner.run();

if (options.dump_frame) |path| {
try writeFrame(io, allocator, path, &emulator.bus.ppu.frame);
}

const output = emulator.serialOutput();
const verdict = serial.verdictOf(output);
const printable = serial.trim(serial.printable(output));
Expand Down Expand Up @@ -203,6 +227,13 @@ pub fn main(init: std.process.Init) !void {
std.process.exit(2);
}
options.blargg_suite = argv[index];
} else if (std.mem.eql(u8, arg, "--dump-frame")) {
index += 1;
if (index >= argv.len) {
std.debug.print("--dump-frame needs a path\n", .{});
std.process.exit(2);
}
options.dump_frame = argv[index];
} else if (options.rom_path == null) {
options.rom_path = arg;
} else {
Expand Down
Loading