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
+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);
}