add nes conformance test suite and CLI runner benchmark tool

This commit is contained in:
2026-08-14 06:48:19 +02:00
parent 5dc2db0a8b
commit 77f29d9618
4 changed files with 574 additions and 2 deletions
+157 -1
View File
@@ -1 +1,157 @@
pub fn main() !void {}
const std = @import("std");
const Nes = @import("system").nes;
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const cwd_dir: std.Io.Dir = .{ .handle = std.posix.AT.FDCWD };
var args_it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator);
defer args_it.deinit();
var args: [32][]const u8 = undefined;
var args_count: usize = 0;
while (args_it.next()) |arg| {
if (args_count < args.len) {
args[args_count] = arg;
args_count += 1;
}
}
if (args_count < 2) {
printUsage();
return;
}
const command = args[1];
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help")) {
printUsage();
return;
}
if (!std.mem.eql(u8, command, "run")) {
std.debug.print("Unknown command '{s}'. Use '6soz --help' for usage.\n", .{command});
return;
}
if (args_count < 3) {
std.debug.print("Error: missing ROM file path.\nUsage: 6soz run <path/to/rom.nes>\n", .{});
return;
}
const rom_path = args[2];
var target_frames: u64 = 60;
var save_state_path: ?[]const u8 = null;
var load_state_path: ?[]const u8 = null;
var save_sram_path: ?[]const u8 = null;
var load_sram_path: ?[]const u8 = null;
var i: usize = 3;
while (i < args_count) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--frames") and i + 1 < args_count) {
i += 1;
target_frames = try std.fmt.parseInt(u64, args[i], 10);
} else if (std.mem.eql(u8, arg, "--save-state") and i + 1 < args_count) {
i += 1;
save_state_path = args[i];
} else if (std.mem.eql(u8, arg, "--load-state") and i + 1 < args_count) {
i += 1;
load_state_path = args[i];
} else if (std.mem.eql(u8, arg, "--save-sram") and i + 1 < args_count) {
i += 1;
save_sram_path = args[i];
} else if (std.mem.eql(u8, arg, "--load-sram") and i + 1 < args_count) {
i += 1;
load_sram_path = args[i];
}
}
const rom_bytes = cwd_dir.readFileAlloc(init.io, rom_path, allocator, @enumFromInt(16 * 1024 * 1024)) catch |err| {
std.debug.print("Error reading ROM file '{s}': {s}\n", .{ rom_path, @errorName(err) });
return;
};
defer allocator.free(rom_bytes);
var nes: Nes = undefined;
nes.init(rom_bytes) catch |err| {
std.debug.print("Failed to initialize NES cartridge: {s}\n", .{@errorName(err)});
return;
};
if (load_sram_path) |path| {
if (cwd_dir.readFileAlloc(init.io, path, allocator, @enumFromInt(8192))) |sram_bytes| {
defer allocator.free(sram_bytes);
nes.loadStorage(0, sram_bytes) catch {};
std.debug.print("[6soz] Loaded SRAM from '{s}'\n", .{path});
} else |err| {
std.debug.print("Warning: Failed to read SRAM file '{s}': {s}\n", .{ path, @errorName(err) });
}
}
if (load_state_path) |path| {
if (cwd_dir.readFileAlloc(init.io, path, allocator, @enumFromInt(64 * 1024))) |state_bytes| {
defer allocator.free(state_bytes);
nes.loadState(state_bytes) catch |err| {
std.debug.print("Failed to load save state: {s}\n", .{@errorName(err)});
};
std.debug.print("[6soz] Loaded Save State from '{s}'\n", .{path});
} else |err| {
std.debug.print("Warning: Failed to read state file '{s}': {s}\n", .{ path, @errorName(err) });
}
}
std.debug.print("[6soz] Running NES emulator for {d} frames...\n", .{target_frames});
const start_time = std.Io.Clock.Timestamp.now(init.io, .awake);
var frame: u64 = 0;
while (frame < target_frames) : (frame += 1) {
nes.runFrame();
}
const end_time = std.Io.Clock.Timestamp.now(init.io, .awake);
const elapsed_ns = end_time.raw.nanoseconds - start_time.raw.nanoseconds;
const elapsed_sec = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000_000.0;
const fps = if (elapsed_sec > 0) @as(f64, @floatFromInt(target_frames)) / elapsed_sec else 0.0;
std.debug.print("[6soz] Benchmark Complete:\n", .{});
std.debug.print(" - Frames: {d}\n", .{target_frames});
std.debug.print(" - Elapsed Time: {d:.3}s\n", .{elapsed_sec});
std.debug.print(" - Performance: {d:.1} FPS\n", .{fps});
if (save_state_path) |path| {
var state_buf: [64 * 1024]u8 = undefined;
if (nes.saveState(&state_buf)) |written| {
cwd_dir.writeFile(init.io, .{ .sub_path = path, .data = state_buf[0..written] }) catch |err| {
std.debug.print("Error writing save state to '{s}': {s}\n", .{ path, @errorName(err) });
};
std.debug.print("[6soz] Saved State to '{s}'\n", .{path});
} else |err| {
std.debug.print("Error serializing save state: {s}\n", .{@errorName(err)});
}
}
if (save_sram_path) |path| {
const sram_view = nes.storageView(0);
cwd_dir.writeFile(init.io, .{ .sub_path = path, .data = sram_view.data }) catch |err| {
std.debug.print("Error writing SRAM to '{s}': {s}\n", .{ path, @errorName(err) });
};
std.debug.print("[6soz] Saved SRAM to '{s}'\n", .{path});
}
}
fn printUsage() void {
std.debug.print(
\\6soz - 6502 / 6510 & NES Emulator Engine
\\Usage:
\\ 6soz run <path/to/rom.nes> [options]
\\
\\Options:
\\ --frames <N> Number of frames to execute (default: 60)
\\ --save-state <file> Save state binary file output
\\ --load-state <file> Load state binary file input
\\ --save-sram <file> Save battery PRG RAM output
\\ --load-sram <file> Load battery PRG RAM input
\\
, .{});
}
+345
View File
@@ -0,0 +1,345 @@
// minimal system test runner for nes-test-roms & NES system contract conformance
const std = @import("std");
const testing = std.testing;
const build_options = @import("build_options");
const contract = @import("contract");
const Nes = @import("root.zig");
fn loadRomAlloc(allocator: std.mem.Allocator, relative_path: []const u8) ![]u8 {
const base_dir = build_options.nes_test_roms_dir orelse return error.TestRomsNotFetched;
var dir = try std.Io.Dir.openDirAbsolute(testing.io, base_dir, .{});
defer dir.close(testing.io);
return dir.readFileAlloc(testing.io, relative_path, allocator, @enumFromInt(10 * 1024 * 1024));
}
/// Runs a blargg test ROM on the full NES system implementation.
/// Blargg test ROMs signal completion via $6000 (0x80 = running, 0x00 = passed)
/// and output result text starting at $6004.
pub fn runBlarggTest(relative_path: []const u8, max_instructions: u64) !void {
const rom_bytes = try loadRomAlloc(testing.allocator, relative_path);
defer testing.allocator.free(rom_bytes);
var nes: Nes = undefined;
try nes.init(rom_bytes);
var i: u64 = 0;
var result_ready = false;
while (i < max_instructions) : (i += 1) {
_ = nes.step() orelse break;
const status = nes.bus.read(0x6000);
if (status != 0x80) {
const sig0 = nes.bus.read(0x6001);
const sig1 = nes.bus.read(0x6002);
const sig2 = nes.bus.read(0x6003);
if (sig0 == 0xDE and sig1 == 0xB0 and sig2 == 0x61) {
result_ready = true;
break;
}
}
}
const status = nes.bus.read(0x6000);
var msg_buf: [256]u8 = undefined;
var msg_len: usize = 0;
while (msg_len < msg_buf.len) : (msg_len += 1) {
const char = nes.bus.read(@intCast(0x6004 + msg_len));
if (char == 0) break;
msg_buf[msg_len] = char;
}
const msg = msg_buf[0..msg_len];
if (!result_ready and status == 0x80) {
std.debug.print(
"\n[TIMEOUT] Test '{s}' timed out after {d} instructions. Text output: {s}\n",
.{ relative_path, i, msg },
);
return error.TestTimeout;
}
if (status != 0) {
std.debug.print(
"\n[FAIL] Test '{s}' failed with status 0x{X:0>2}: {s}\n",
.{ relative_path, status, msg },
);
return error.TestFailed;
}
}
/// Runs Kevtris' nestest.nes ROM on full NES system (PC = $C000).
pub fn runNesTestSystem(relative_path: []const u8, max_instructions: u64) !void {
const rom_bytes = try loadRomAlloc(testing.allocator, relative_path);
defer testing.allocator.free(rom_bytes);
var nes: Nes = undefined;
try nes.init(rom_bytes);
nes.cpu.registers.pc = 0xC000;
var previous_pc: u16 = 0;
var i: u64 = 0;
while (i < max_instructions) : (i += 1) {
previous_pc = nes.cpu.registers.pc;
_ = nes.step() orelse break;
if (nes.cpu.registers.pc == previous_pc) break;
}
const err1 = nes.bus.read(0x0002);
const err2 = nes.bus.read(0x0003);
if (err1 != 0 or err2 != 0) {
std.debug.print(
"\n[FAIL] Nestest system test failed with error codes $0002: 0x{X:0>2}, $0003: 0x{X:0>2} at PC 0x{X:0>4}\n",
.{ err1, err2, nes.cpu.registers.pc },
);
return error.NestestFailed;
}
}
test "NES System Conformance - Contract SystemSpec" {
comptime contract.validateSystem(Nes);
try testing.expectEqualStrings("Nintendo Entertainment System", Nes.spec.name);
try testing.expectEqual(@as(usize, 1), Nes.spec.video_outputs.len);
try testing.expectEqual(@as(usize, 1), Nes.spec.audio_outputs.len);
try testing.expectEqual(@as(usize, 2), Nes.spec.input_devices.len);
try testing.expectEqual(@as(usize, 1), Nes.spec.storage_devices.len);
}
test "NES System Conformance - Video Frame Output" {
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1;
rom[5] = 1;
var nes: Nes = undefined;
try nes.init(&rom);
nes.runFrame();
const frame = nes.videoFrame(0);
try testing.expectEqual(@as(u32, 256), frame.width);
try testing.expectEqual(@as(u32, 240), frame.height);
try testing.expectEqual(contract.PixelFormat.rgb565, frame.format);
try testing.expectEqual(256 * 240 * 2, frame.data.len);
}
test "NES System Conformance - Audio Buffer Output" {
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1;
rom[5] = 1;
var nes: Nes = undefined;
try nes.init(&rom);
nes.runFrame();
const audio_buf = nes.audioBuffer(0);
try testing.expectEqual(contract.AudioSampleFormat.i16, audio_buf.format);
try testing.expectEqual(@as(u8, 1), audio_buf.channels);
try testing.expect(audio_buf.data.len > 0);
}
test "NES System Conformance - Controller Input Shift & Strobe" {
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1;
rom[5] = 1;
var nes: Nes = undefined;
try nes.init(&rom);
// Press Button A (bit 0) and Button Select (bit 2) -> 0b00000101
nes.setInput(0, .{ .buttons = 0x05 });
// Strobe controller
nes.bus.write(0x4016, 1);
nes.bus.write(0x4016, 0);
// Read serial bits
try testing.expectEqual(@as(u8, 1), nes.bus.read(0x4016) & 1); // Button A (pressed)
try testing.expectEqual(@as(u8, 0), nes.bus.read(0x4016) & 1); // Button B
try testing.expectEqual(@as(u8, 1), nes.bus.read(0x4016) & 1); // Button Select (pressed)
}
test "NES System Conformance - Storage & Save State Integrity" {
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1;
rom[5] = 1;
var nes: Nes = undefined;
try nes.init(&rom);
// Storage SRAM test
const sram_data = [_]u8{ 0xDE, 0xAD, 0xBE, 0xEF };
try nes.loadStorage(0, &sram_data);
const view = nes.storageView(0);
try testing.expectEqualSlices(u8, &sram_data, view.data[0..4]);
// Save state test
var save_buf: [64 * 1024]u8 = undefined;
const written = try nes.saveState(&save_buf);
nes.reset();
try nes.loadState(save_buf[0..written]);
const view_after = nes.storageView(0);
try testing.expectEqualSlices(u8, &sram_data, view_after.data[0..4]);
}
test "NES System Conformance - APU DMC Sample Playback & DAC" {
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1;
rom[5] = 1;
var nes: Nes = undefined;
try nes.init(&rom);
// Enable DMC in APU status ($4015 bit 4)
nes.bus.write(0x4015, 0x10);
// Write DMC output level ($4011) to 64
nes.bus.write(0x4011, 64);
// Setup rate ($4010)
nes.bus.write(0x4010, 0x05);
nes.runFrame();
const audio_buf = nes.audioBuffer(0);
try testing.expect(audio_buf.data.len > 0);
}
test "NES System Conformance - PPU PPUMASK Grayscale & Color Emphasis" {
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1;
rom[5] = 1;
var nes: Nes = undefined;
try nes.init(&rom);
// Enable Grayscale (bit 0) and Red/Green/Blue Emphasis (bits 5,6,7 -> 0xE1) in PPUMASK ($2001)
nes.bus.write(0x2001, 0xE1);
nes.runFrame();
const frame = nes.videoFrame(0);
try testing.expectEqual(256 * 240 * 2, frame.data.len);
}
test "NES System Conformance - All Mappers iNES Header Support" {
const mappers = [_]struct { id: u8, prg_banks: u8, chr_banks: u8 }{
.{ .id = 0, .prg_banks = 1, .chr_banks = 1 }, // NROM
.{ .id = 1, .prg_banks = 8, .chr_banks = 8 }, // MMC1
.{ .id = 2, .prg_banks = 8, .chr_banks = 0 }, // UxROM
.{ .id = 3, .prg_banks = 2, .chr_banks = 4 }, // CNROM
.{ .id = 4, .prg_banks = 16, .chr_banks = 16 }, // MMC3
.{ .id = 5, .prg_banks = 16, .chr_banks = 16 }, // MMC5
.{ .id = 7, .prg_banks = 8, .chr_banks = 0 }, // AxROM
.{ .id = 9, .prg_banks = 8, .chr_banks = 16 }, // MMC2
.{ .id = 10, .prg_banks = 8, .chr_banks = 16 }, // MMC4
.{ .id = 11, .prg_banks = 4, .chr_banks = 16 }, // Color Dreams
.{ .id = 13, .prg_banks = 2, .chr_banks = 0 }, // CPROM
.{ .id = 21, .prg_banks = 8, .chr_banks = 16 }, // Konami VRC4a/c
.{ .id = 22, .prg_banks = 8, .chr_banks = 16 }, // Konami VRC2a
.{ .id = 23, .prg_banks = 8, .chr_banks = 16 }, // Konami VRC2b/VRC4e
.{ .id = 24, .prg_banks = 16, .chr_banks = 16 }, // Konami VRC6a
.{ .id = 25, .prg_banks = 8, .chr_banks = 16 }, // Konami VRC4b/d
.{ .id = 26, .prg_banks = 16, .chr_banks = 16 }, // Konami VRC6b
.{ .id = 34, .prg_banks = 8, .chr_banks = 8 }, // BNROM / NINA-01
.{ .id = 65, .prg_banks = 8, .chr_banks = 16 }, // Irem H3001
.{ .id = 66, .prg_banks = 4, .chr_banks = 4 }, // GxROM
.{ .id = 68, .prg_banks = 8, .chr_banks = 16 }, // Sunsoft-4
.{ .id = 69, .prg_banks = 8, .chr_banks = 16 }, // Sunsoft FME-7
.{ .id = 71, .prg_banks = 8, .chr_banks = 0 }, // Camerica
.{ .id = 73, .prg_banks = 8, .chr_banks = 0 }, // Konami VRC3
.{ .id = 75, .prg_banks = 8, .chr_banks = 16 }, // Konami VRC1
.{ .id = 76, .prg_banks = 8, .chr_banks = 16 }, // Namco 109
.{ .id = 78, .prg_banks = 8, .chr_banks = 16 }, // Irem 74HC161/32
.{ .id = 79, .prg_banks = 4, .chr_banks = 8 }, // NINA-03
.{ .id = 85, .prg_banks = 8, .chr_banks = 16 }, // Konami VRC7
.{ .id = 87, .prg_banks = 2, .chr_banks = 4 }, // Jaleco 74x139
.{ .id = 88, .prg_banks = 8, .chr_banks = 16 }, // Namco 118
.{ .id = 89, .prg_banks = 8, .chr_banks = 16 }, // Sunsoft-2
.{ .id = 93, .prg_banks = 8, .chr_banks = 0 }, // Sunsoft-3
.{ .id = 94, .prg_banks = 16, .chr_banks = 0 }, // UN1ROM
.{ .id = 118, .prg_banks = 8, .chr_banks = 16 }, // TxSROM
.{ .id = 119, .prg_banks = 8, .chr_banks = 16 }, // TQROM
.{ .id = 140, .prg_banks = 4, .chr_banks = 16 }, // Jaleco Command
.{ .id = 180, .prg_banks = 8, .chr_banks = 0 }, // Crazy Climber
.{ .id = 206, .prg_banks = 8, .chr_banks = 16 }, // DxROM / Namco 108
.{ .id = 210, .prg_banks = 8, .chr_banks = 16 }, // Namco 175/340
.{ .id = 232, .prg_banks = 16, .chr_banks = 0 }, // Camerica Quattro
};
for (mappers) |m| {
var rom: [16 + 256 * 1024 + 256 * 1024]u8 = [_]u8{0} ** (16 + 256 * 1024 + 256 * 1024);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = m.prg_banks;
rom[5] = m.chr_banks;
rom[6] = (m.id & 0x0F) << 4;
rom[7] = m.id & 0xF0;
var nes: Nes = undefined;
try nes.init(&rom);
nes.runFrame();
}
}
test "NES System - Nestest Conformance" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runNesTestSystem("other/nestest.nes", 100_000);
}
test "NES System - Instr Test: 01-implied" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/01-implied.nes", 10_000_000);
}
test "NES System - Instr Test: 02-immediate" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/02-immediate.nes", 10_000_000);
}
test "NES System - Instr Test: 03-zero_page" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/03-zero_page.nes", 10_000_000);
}
test "NES System - Instr Test: 04-zp_xy" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/04-zp_xy.nes", 10_000_000);
}
test "NES System - Instr Test: 05-absolute" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/05-absolute.nes", 10_000_000);
}
test "NES System - Instr Test: 06-abs_xy" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/06-abs_xy.nes", 10_000_000);
}
test "NES System - Instr Test: 07-ind_x" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/07-ind_x.nes", 10_000_000);
}
test "NES System - Instr Test: 08-ind_y" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/08-ind_y.nes", 10_000_000);
}
test "NES System - Instr Test: 09-branches" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/09-branches.nes", 10_000_000);
}
test "NES System - Instr Test: 10-stack" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/10-stack.nes", 10_000_000);
}
test "NES System - Instr Test: 11-special" {
if (build_options.nes_test_roms_dir == null) return error.SkipZigTest;
try runBlarggTest("nes_instr_test/rom_singles/11-special.nes", 10_000_000);
}