From 5dc2db0a8b443ba7c5830a46a026bc5ae2de83dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20=C5=9Een?= Date: Fri, 14 Aug 2026 06:48:18 +0200 Subject: [PATCH] add nes core system implementation with ppu 2c02 and apu 2a03 --- src/system/nes/apu.zig | 276 ++++++++++++ src/system/nes/bus.zig | 157 +++++++ src/system/nes/controller.zig | 70 +++ src/system/nes/ppu.zig | 779 ++++++++++++++++++++++++++++++++++ src/system/nes/root.zig | 373 ++++++++++++++++ src/system/root.zig | 5 + 6 files changed, 1660 insertions(+) create mode 100644 src/system/nes/apu.zig create mode 100644 src/system/nes/bus.zig create mode 100644 src/system/nes/controller.zig create mode 100644 src/system/nes/ppu.zig diff --git a/src/system/nes/apu.zig b/src/system/nes/apu.zig new file mode 100644 index 0000000..eab9f36 --- /dev/null +++ b/src/system/nes/apu.zig @@ -0,0 +1,276 @@ +const std = @import("std"); + +const Apu = @This(); + +pub const sample_rate: u32 = 44_100; + +sample_buffer: [2048]i16 = [_]i16{0} ** 2048, +sample_count: usize = 0, +cycle_counter: u32 = 0, + +// Pulse 1 +pulse1_enabled: bool = false, +pulse1_timer_reload: u11 = 0, +pulse1_timer: u11 = 0, +pulse1_duty: u2 = 0, +pulse1_duty_pos: u3 = 0, +pulse1_volume: u4 = 0, + +// Pulse 2 +pulse2_enabled: bool = false, +pulse2_timer_reload: u11 = 0, +pulse2_timer: u11 = 0, +pulse2_duty: u2 = 0, +pulse2_duty_pos: u3 = 0, +pulse2_volume: u4 = 0, + +// Triangle +triangle_enabled: bool = false, +triangle_timer_reload: u11 = 0, +triangle_timer: u11 = 0, +triangle_seq_pos: u5 = 0, + +// Noise +noise_enabled: bool = false, +noise_timer_reload: u12 = 0, +noise_timer: u12 = 0, +noise_lfsr: u16 = 1, +noise_volume: u4 = 0, + +// DMC +dmc_enabled: bool = false, +dmc_loop: bool = false, +dmc_rate_index: u4 = 0, +dmc_timer: u12 = 0, +dmc_timer_reload: u12 = 0, +dmc_output_level: u7 = 0, +dmc_sample_address: u16 = 0xC000, +dmc_sample_length: u16 = 1, +dmc_current_address: u16 = 0xC000, +dmc_bytes_remaining: u16 = 0, +dmc_shift_register: u8 = 0, +dmc_bits_remaining: u4 = 0, +dmc_silence: bool = true, + +const duty_tables: [4][8]u8 = .{ + .{ 0, 1, 0, 0, 0, 0, 0, 0 }, // 12.5% + .{ 0, 1, 1, 0, 0, 0, 0, 0 }, // 25% + .{ 0, 1, 1, 1, 1, 0, 0, 0 }, // 50% + .{ 1, 0, 0, 1, 1, 1, 1, 1 }, // 75% +}; + +const triangle_table: [32]u4 = .{ + 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, +}; + +const noise_periods: [16]u12 = .{ + 4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068, +}; + +const dmc_periods: [16]u12 = .{ + 428, 380, 340, 320, 286, 254, 226, 214, 190, 160, 142, 128, 106, 84, 72, 54, +}; + +pub fn reset(self: *Apu) void { + self.sample_count = 0; + self.cycle_counter = 0; + self.pulse1_enabled = false; + self.pulse2_enabled = false; + self.triangle_enabled = false; + self.noise_enabled = false; + self.noise_lfsr = 1; + self.dmc_enabled = false; + self.dmc_output_level = 0; +} + +pub inline fn tick(self: *Apu) void { + // Pulse 1 timer + if (self.pulse1_timer == 0) { + self.pulse1_timer = self.pulse1_timer_reload; + self.pulse1_duty_pos +%= 1; + } else { + self.pulse1_timer -= 1; + } + + // Pulse 2 timer + if (self.pulse2_timer == 0) { + self.pulse2_timer = self.pulse2_timer_reload; + self.pulse2_duty_pos +%= 1; + } else { + self.pulse2_timer -= 1; + } + + // Triangle timer + if (self.triangle_timer == 0) { + self.triangle_timer = self.triangle_timer_reload; + self.triangle_seq_pos +%= 1; + } else { + self.triangle_timer -= 1; + } + + // Noise timer + if (self.noise_timer == 0) { + self.noise_timer = self.noise_timer_reload; + const feedback = (self.noise_lfsr & 1) ^ ((self.noise_lfsr >> 1) & 1); + self.noise_lfsr = (self.noise_lfsr >> 1) | (feedback << 14); + } else { + self.noise_timer -= 1; + } + + // DMC timer + if (self.dmc_timer == 0) { + self.dmc_timer = self.dmc_timer_reload; + if (!self.dmc_silence) { + if ((self.dmc_shift_register & 1) != 0) { + if (self.dmc_output_level <= 125) self.dmc_output_level += 2; + } else { + if (self.dmc_output_level >= 2) self.dmc_output_level -= 2; + } + self.dmc_shift_register >>= 1; + } + + if (self.dmc_bits_remaining > 0) { + self.dmc_bits_remaining -= 1; + } + if (self.dmc_bits_remaining == 0) { + self.dmc_silence = true; + } + } else { + self.dmc_timer -= 1; + } + + // Sample downsampling at ~44.1 kHz (every ~40 CPU cycles) + self.cycle_counter += 1; + if (self.cycle_counter >= 40) { + self.cycle_counter = 0; + if (self.sample_count < self.sample_buffer.len) { + const p1_val: i32 = if (self.pulse1_enabled and duty_tables[self.pulse1_duty][self.pulse1_duty_pos] != 0 and self.pulse1_timer_reload > 8) + @as(i32, self.pulse1_volume) + else + 0; + + const p2_val: i32 = if (self.pulse2_enabled and duty_tables[self.pulse2_duty][self.pulse2_duty_pos] != 0 and self.pulse2_timer_reload > 8) + @as(i32, self.pulse2_volume) + else + 0; + + const tri_val: i32 = if (self.triangle_enabled and self.triangle_timer_reload > 2) + @as(i32, triangle_table[self.triangle_seq_pos]) + else + 0; + + const noise_val: i32 = if (self.noise_enabled and (self.noise_lfsr & 1) == 0) + @as(i32, self.noise_volume) + else + 0; + + const dmc_val: i32 = if (self.dmc_enabled) @as(i32, self.dmc_output_level) else 0; + + const sample_i32 = (p1_val + p2_val) * 400 + tri_val * 350 + noise_val * 300 + dmc_val * 150; + self.sample_buffer[self.sample_count] = @intCast(std.math.clamp(sample_i32, -32000, 32000)); + self.sample_count += 1; + } + } +} + +pub fn write(self: *Apu, address: u16, value: u8) void { + switch (address) { + // Pulse 1 + 0x4000 => { + self.pulse1_duty = @truncate((value >> 6) & 3); + self.pulse1_volume = @truncate(value & 0x0f); + }, + 0x4002 => { + self.pulse1_timer_reload = (self.pulse1_timer_reload & 0x0700) | value; + }, + 0x4003 => { + self.pulse1_timer_reload = (self.pulse1_timer_reload & 0x00ff) | (@as(u11, value & 7) << 8); + self.pulse1_duty_pos = 0; + }, + + // Pulse 2 + 0x4004 => { + self.pulse2_duty = @truncate((value >> 6) & 3); + self.pulse2_volume = @truncate(value & 0x0f); + }, + 0x4006 => { + self.pulse2_timer_reload = (self.pulse2_timer_reload & 0x0700) | value; + }, + 0x4007 => { + self.pulse2_timer_reload = (self.pulse2_timer_reload & 0x00ff) | (@as(u11, value & 7) << 8); + self.pulse2_duty_pos = 0; + }, + + // Triangle + 0x400a => { + self.triangle_timer_reload = (self.triangle_timer_reload & 0x0700) | value; + }, + 0x400b => { + self.triangle_timer_reload = (self.triangle_timer_reload & 0x00ff) | (@as(u11, value & 7) << 8); + }, + + // Noise + 0x400c => { + self.noise_volume = @truncate(value & 0x0f); + }, + 0x400e => { + self.noise_timer_reload = noise_periods[value & 0x0f]; + }, + + // DMC + 0x4010 => { + self.dmc_loop = (value & 0x40) != 0; + self.dmc_rate_index = @truncate(value & 0x0f); + self.dmc_timer_reload = dmc_periods[self.dmc_rate_index]; + }, + 0x4011 => { + self.dmc_output_level = @truncate(value & 0x7f); + }, + 0x4012 => { + self.dmc_sample_address = 0xC000 + (@as(u16, value) << 6); + }, + 0x4013 => { + self.dmc_sample_length = (@as(u16, value) << 4) + 1; + }, + else => {}, + } +} + +pub fn readStatus(_: *Apu) u8 { + return 0; +} + +pub fn writeStatus(self: *Apu, value: u8) void { + self.pulse1_enabled = (value & 0x01) != 0; + self.pulse2_enabled = (value & 0x02) != 0; + self.triangle_enabled = (value & 0x04) != 0; + self.noise_enabled = (value & 0x08) != 0; + self.dmc_enabled = (value & 0x10) != 0; +} + +pub fn writeFrameCounter(_: *Apu, _: u8) void {} + +pub fn irqAsserted(_: *const Apu) bool { + return false; +} + +test "NES APU - Sound Synthesis & Sample Generation" { + var apu = Apu{}; + apu.reset(); + + // Enable Pulse 1 and Pulse 2 + apu.writeStatus(0x03); + + // Setup Pulse 1 volume and frequency + apu.write(0x4000, 0xbf); // 50% duty, max volume 15 + apu.write(0x4002, 100); + apu.write(0x4003, 0); + + // Tick APU for 100 cycles to produce PCM samples + for (0..100) |_| { + apu.tick(); + } + + try std.testing.expect(apu.sample_count > 0); +} diff --git a/src/system/nes/bus.zig b/src/system/nes/bus.zig new file mode 100644 index 0000000..09f0967 --- /dev/null +++ b/src/system/nes/bus.zig @@ -0,0 +1,157 @@ +const Cartridge = @import("cartridge.zig"); + +const Controller = @import("controller.zig"); + +const Ppu = @import("ppu.zig"); + +const Apu = @import("apu.zig"); + +const Bus = @This(); + +ram: [0x800]u8 = + [_]u8{0} ** 0x800, + +cartridge: Cartridge, + +ppu: Ppu = .{}, +apu: Apu = .{}, + +controllers: [2]Controller = .{ + .{}, + .{}, +}, + +open_bus: u8 = 0, + +oam_dma_page: ?u8 = null, + +pub fn init( + rom: []const u8, +) Cartridge.Error!Bus { + return .{ + .cartridge = try Cartridge.init(rom), + }; +} + +pub fn reset( + self: *Bus, +) void { + self.ppu.reset(); + self.apu.reset(); + + for (&self.controllers) |*controller| + controller.reset(); + + self.oam_dma_page = null; +} + +pub inline fn read( + self: *Bus, + address: u16, +) u8 { + const value: u8 = switch (address) { + // 2 KiB internal RAM + mirrors. + 0x0000...0x1fff => self.ram[address & 0x07ff], + + // PPU registers + mirrors. + 0x2000...0x3fff => self.ppu.cpuRead( + &self.cartridge, + address & 7, + ), + + // APU status. + 0x4015 => self.apu.readStatus(), + + // Controller 1. + 0x4016 => self.controllers[0].read(), + + // Controller 2. + 0x4017 => self.controllers[1].read(), + + // Cartridge space. + 0x4020...0xffff => self.cartridge.cpuRead(address), + + else => self.open_bus, + }; + + self.open_bus = value; + + return value; +} + +pub inline fn write( + self: *Bus, + address: u16, + value: u8, +) void { + self.open_bus = value; + + switch (address) { + 0x0000...0x1fff => self.ram[address & 0x07ff] = + value, + + 0x2000...0x3fff => self.ppu.cpuWrite( + &self.cartridge, + address & 7, + value, + ), + + 0x4000...0x4013 => self.apu.write( + address, + value, + ), + + // OAMDMA + 0x4014 => self.oam_dma_page = value, + + 0x4015 => self.apu.writeStatus(value), + + 0x4016 => { + self.controllers[0].write(value); + self.controllers[1].write(value); + }, + + 0x4017 => self.apu.writeFrameCounter(value), + + else => self.cartridge.cpuWrite( + address, + value, + ), + } +} + +/// Called exactly once for every CPU cycle. +pub inline fn tick( + self: *Bus, +) void { + self.apu.tick(); + + // NTSC NES: PPU runs at 3× CPU clock. + self.ppu.tick(&self.cartridge); + self.ppu.tick(&self.cartridge); + self.ppu.tick(&self.cartridge); +} + +pub fn nmiLine( + self: *Bus, +) bool { + return self.ppu.nmiAsserted(); +} + +pub fn irqLine( + self: *Bus, +) bool { + return self.apu.irqAsserted() or + self.cartridge.irqAsserted(); +} + +pub fn takeOamDmaPage( + self: *Bus, +) ?u8 { + const page = + self.oam_dma_page; + + self.oam_dma_page = null; + + return page; +} diff --git a/src/system/nes/controller.zig b/src/system/nes/controller.zig new file mode 100644 index 0000000..cd1b587 --- /dev/null +++ b/src/system/nes/controller.zig @@ -0,0 +1,70 @@ +pub const Button = enum(u3) { + a = 0, + b = 1, + select = 2, + start = 3, + up = 4, + down = 5, + left = 6, + right = 7, +}; + +const Controller = @This(); + +buttons: u8 = 0, + +shift_register: u8 = 0, +strobe: bool = false, + +pub fn reset( + self: *Controller, +) void { + self.shift_register = 0; + self.strobe = false; +} + +pub fn setButtons( + self: *Controller, + buttons: u8, +) void { + self.buttons = buttons; + + if (self.strobe) + self.shift_register = buttons; +} + +pub fn write( + self: *Controller, + value: u8, +) void { + const new_strobe = + (value & 1) != 0; + + if (new_strobe) { + self.shift_register = + self.buttons; + } else if (self.strobe) { + // Falling edge latches controller. + self.shift_register = + self.buttons; + } + + self.strobe = new_strobe; +} + +pub fn read( + self: *Controller, +) u8 { + if (self.strobe) + return self.buttons & 1; + + const value = + self.shift_register & 1; + + // Real controllers return 1 after all + // eight buttons have shifted out. + self.shift_register = + (self.shift_register >> 1) | 0x80; + + return value; +} diff --git a/src/system/nes/ppu.zig b/src/system/nes/ppu.zig new file mode 100644 index 0000000..2cc1cb1 --- /dev/null +++ b/src/system/nes/ppu.zig @@ -0,0 +1,779 @@ +const std = @import("std"); +const Cartridge = @import("cartridge.zig"); + +const mapper = @import("mapper/root.zig"); +const Mirroring = mapper.Mirroring; + +const Ppu = @This(); + +pub const width = 256; +pub const height = 240; + +// NTSC 2C02 64-color system palette mapped directly to RGB565 format +pub const nes_ntsc_palette_rgb565: [64]u16 = .{ + 0x52AA, 0x00EE, 0x0892, 0x3011, 0x400C, 0x5806, 0x5020, 0x38C0, + 0x2140, 0x09C0, 0x0200, 0x01E0, 0x0187, 0x0000, 0x0000, 0x0000, + 0x9CB3, 0x0A78, 0x319D, 0x58FC, 0x88B6, 0xA08C, 0x9904, 0x79E0, + 0x52C0, 0x2B80, 0x0BE0, 0x03A5, 0x032F, 0x0000, 0x0000, 0x0000, + 0xEF7D, 0x4CDD, 0x7BFD, 0xB31D, 0xE2BD, 0xEACE, 0xEB4C, 0xD444, + 0xA540, 0x7620, 0x4E84, 0x3E6D, 0x3DA9, 0x3DF7, 0x0000, 0x0000, + 0xEF7D, 0xAE6D, 0xBD7D, 0xD59D, 0xED7D, 0xED7A, 0xEDB6, 0xE632, + 0xCE8F, 0xB6EF, 0xAF12, 0x9F16, 0xA6BD, 0xA514, 0x0000, 0x0000, +}; + +framebuffer: [width * height]u16 = + [_]u16{0} ** (width * height), + +/// Four-screen sized so the same structure +/// can handle that mode without allocation. +nametable: [0x1000]u8 = + [_]u8{0} ** 0x1000, + +palette: [32]u8 = + [_]u8{0} ** 32, + +oam: [256]u8 = + [_]u8{0} ** 256, + +ctrl: u8 = 0, +mask: u8 = 0, +status: u8 = 0, + +oam_addr: u8 = 0, + +vram_addr: u16 = 0, +temp_addr: u16 = 0, + +fine_x: u3 = 0, +write_latch: bool = false, + +read_buffer: u8 = 0, +io_latch: u8 = 0, + +dot: u16 = 0, +scanline: u16 = 261, + +frame_number: u64 = 0, +frame_ready: bool = false, + +// internal shift registers & tile latches for background rendering +bg_shift_pattern_lo: u16 = 0, +bg_shift_pattern_hi: u16 = 0, +bg_shift_attrib_lo: u16 = 0, +bg_shift_attrib_hi: u16 = 0, + +next_tile_id: u8 = 0, +next_tile_attrib: u8 = 0, +next_tile_lsb: u8 = 0, +next_tile_msb: u8 = 0, + +// secondary OAM scanline sprite evaluation buffer +sec_oam_pattern_lo: [8]u8 = [_]u8{0} ** 8, +sec_oam_pattern_hi: [8]u8 = [_]u8{0} ** 8, +sec_oam_attribute: [8]u8 = [_]u8{0} ** 8, +sec_oam_x: [8]u8 = [_]u8{0} ** 8, +sec_oam_is_zero: [8]bool = [_]bool{false} ** 8, +sec_oam_count: u8 = 0, + +pub fn reset( + self: *Ppu, +) void { + self.ctrl = 0; + self.mask = 0; + self.status = 0; + + self.oam_addr = 0; + + self.vram_addr = 0; + self.temp_addr = 0; + + self.fine_x = 0; + self.write_latch = false; + + self.read_buffer = 0; + self.io_latch = 0; + + self.dot = 0; + self.scanline = 261; + + self.frame_ready = false; + + self.bg_shift_pattern_lo = 0; + self.bg_shift_pattern_hi = 0; + self.bg_shift_attrib_lo = 0; + self.bg_shift_attrib_hi = 0; + self.next_tile_id = 0; + self.next_tile_attrib = 0; + self.next_tile_lsb = 0; + self.next_tile_msb = 0; + self.sec_oam_count = 0; +} + +pub fn cpuRead( + self: *Ppu, + cartridge: *Cartridge, + register: u16, +) u8 { + const reg = register & 7; + + const value: u8 = switch (reg) { + // PPUSTATUS + 2 => blk: { + const result = + (self.status & 0xe0) | + (self.io_latch & 0x1f); + + self.status &= ~@as(u8, 0x80); + self.write_latch = false; + + break :blk result; + }, + + // OAMDATA + 4 => self.oam[self.oam_addr], + + // PPUDATA + 7 => self.readData(cartridge), + + else => self.io_latch, + }; + + self.io_latch = value; + + return value; +} + +pub fn cpuWrite( + self: *Ppu, + cartridge: *Cartridge, + register: u16, + value: u8, +) void { + const reg = register & 7; + + self.io_latch = value; + + switch (reg) { + // PPUCTRL + 0 => { + self.ctrl = value; + + self.temp_addr = + (self.temp_addr & 0xf3ff) | + (@as(u16, value & 0x03) << 10); + }, + + // PPUMASK + 1 => { + self.mask = value; + }, + + // OAMADDR + 3 => { + self.oam_addr = value; + }, + + // OAMDATA + 4 => { + self.writeOamDma(value); + }, + + // PPUSCROLL + 5 => { + if (!self.write_latch) { + self.fine_x = + @truncate(value & 7); + + self.temp_addr = + (self.temp_addr & 0xffe0) | + @as(u16, value >> 3); + + self.write_latch = true; + } else { + self.temp_addr = + (self.temp_addr & 0x8c1f) | + (@as(u16, value & 0x07) << 12) | + (@as(u16, value & 0xf8) << 2); + + self.write_latch = false; + } + }, + + // PPUADDR + 6 => { + if (!self.write_latch) { + self.temp_addr = + (self.temp_addr & 0x00ff) | + (@as(u16, value & 0x3f) << 8); + + self.write_latch = true; + } else { + self.temp_addr = + (self.temp_addr & 0x7f00) | + @as(u16, value); + + self.vram_addr = + self.temp_addr; + + self.write_latch = false; + } + }, + + // PPUDATA + 7 => { + self.writeMemory( + cartridge, + self.vram_addr, + value, + ); + + self.incrementVramAddress(); + }, + + else => {}, + } +} + +pub fn writeOamDma( + self: *Ppu, + value: u8, +) void { + self.oam[self.oam_addr] = value; + self.oam_addr +%= 1; +} + +pub fn tick( + self: *Ppu, + cartridge: *Cartridge, +) void { + const rendering_enabled = (self.mask & 0x18) != 0; + const is_visible_scanline = (self.scanline < 240); + const is_prerender_scanline = (self.scanline == 261); + + // Start of VBlank. + if (self.scanline == 241 and + self.dot == 1) + { + self.status |= 0x80; + } + + // Pre-render line. + if (is_prerender_scanline and + self.dot == 1) + { + // Clear: VBlank, sprite zero hit, sprite overflow + self.status &= ~@as(u8, 0xe0); + } + + // --- Rendering Pipeline --- + if (rendering_enabled and (is_visible_scanline or is_prerender_scanline)) { + if ((self.dot >= 1 and self.dot <= 256) or (self.dot >= 321 and self.dot <= 336)) { + self.shiftBackgroundRegisters(); + + const cycle_in_tile = (self.dot - 1) % 8; + switch (cycle_in_tile) { + 0 => { + self.loadShiftRegisters(); + const nt_addr = 0x2000 | (self.vram_addr & 0x0fff); + self.next_tile_id = self.readMemory(cartridge, nt_addr); + }, + 2 => { + const attr_addr = 0x23c0 | + (self.vram_addr & 0x0c00) | + ((self.vram_addr >> 4) & 0x38) | + ((self.vram_addr >> 2) & 0x07); + const attr_byte = self.readMemory(cartridge, attr_addr); + const shift: u3 = @intCast(((self.vram_addr >> 4) & 4) | (self.vram_addr & 2)); + self.next_tile_attrib = (attr_byte >> shift) & 0x03; + }, + 4 => { + const bg_table: u16 = if ((self.ctrl & 0x10) != 0) 0x1000 else 0x0000; + const fine_y = (self.vram_addr >> 12) & 7; + const pattern_addr = bg_table + (@as(u16, self.next_tile_id) << 4) + fine_y; + self.next_tile_lsb = self.readMemory(cartridge, pattern_addr); + }, + 6 => { + const bg_table: u16 = if ((self.ctrl & 0x10) != 0) 0x1000 else 0x0000; + const fine_y = (self.vram_addr >> 12) & 7; + const pattern_addr = bg_table + (@as(u16, self.next_tile_id) << 4) + fine_y + 8; + self.next_tile_msb = self.readMemory(cartridge, pattern_addr); + }, + 7 => { + self.incrementCoarseX(); + }, + else => {}, + } + } + + if (self.dot == 256) { + self.incrementY(); + } + + if (self.dot == 257) { + self.loadShiftRegisters(); + self.copyHorizontal(); + } + + if (is_prerender_scanline and self.dot >= 280 and self.dot <= 304) { + self.copyVertical(); + } + + if (self.dot == 257 and is_visible_scanline) { + self.evaluateSprites(cartridge); + } + + if (self.dot == 260 and is_visible_scanline) { + cartridge.handleScanline(); + } + } + + // --- Pixel Output --- + if (is_visible_scanline and self.dot >= 1 and self.dot <= 256) { + self.renderPixel(cartridge); + } + + // --- Dot & Scanline Counters --- + self.dot += 1; + + if (self.dot == 341) { + self.dot = 0; + self.scanline += 1; + + if (self.scanline == 262) { + self.scanline = 0; + + self.frame_number +%= 1; + self.frame_ready = true; + } + } +} + +pub fn nmiAsserted( + self: *const Ppu, +) bool { + const nmi_enabled = + (self.ctrl & 0x80) != 0; + + const in_vblank = + (self.status & 0x80) != 0; + + return nmi_enabled and in_vblank; +} + +fn shiftBackgroundRegisters(self: *Ppu) void { + if ((self.mask & 0x08) != 0) { + self.bg_shift_pattern_lo <<= 1; + self.bg_shift_pattern_hi <<= 1; + self.bg_shift_attrib_lo <<= 1; + self.bg_shift_attrib_hi <<= 1; + } +} + +fn loadShiftRegisters(self: *Ppu) void { + self.bg_shift_pattern_lo = (self.bg_shift_pattern_lo & 0xff00) | self.next_tile_lsb; + self.bg_shift_pattern_hi = (self.bg_shift_pattern_hi & 0xff00) | self.next_tile_msb; + + const attr_lo: u16 = if ((self.next_tile_attrib & 1) != 0) 0x00ff else 0x0000; + const attr_hi: u16 = if ((self.next_tile_attrib & 2) != 0) 0x00ff else 0x0000; + self.bg_shift_attrib_lo = (self.bg_shift_attrib_lo & 0xff00) | attr_lo; + self.bg_shift_attrib_hi = (self.bg_shift_attrib_hi & 0xff00) | attr_hi; +} + +inline fn incrementCoarseX(self: *Ppu) void { + if ((self.vram_addr & 0x001f) == 31) { + self.vram_addr &= ~@as(u16, 0x001f); + self.vram_addr ^= 0x0400; + } else { + self.vram_addr += 1; + } +} + +inline fn incrementY(self: *Ppu) void { + if ((self.vram_addr & 0x7000) != 0x7000) { + self.vram_addr += 0x1000; + } else { + self.vram_addr &= ~@as(u16, 0x7000); + var y = (self.vram_addr & 0x03e0) >> 5; + if (y == 29) { + y = 0; + self.vram_addr ^= 0x0800; + } else if (y == 31) { + y = 0; + } else { + y += 1; + } + self.vram_addr = (self.vram_addr & ~@as(u16, 0x03e0)) | (y << 5); + } +} + +inline fn copyHorizontal(self: *Ppu) void { + self.vram_addr = (self.vram_addr & ~@as(u16, 0x041f)) | (self.temp_addr & 0x041f); +} + +inline fn copyVertical(self: *Ppu) void { + self.vram_addr = (self.vram_addr & ~@as(u16, 0x7be0)) | (self.temp_addr & 0x7be0); +} + +inline fn evaluateSprites( + self: *Ppu, + cartridge: *Cartridge, +) void { + self.sec_oam_count = 0; + const sprite_height: u16 = if ((self.ctrl & 0x20) != 0) 16 else 8; + + var oam_idx: usize = 0; + var i: usize = 0; + while (i < 64) : ({ + i += 1; + oam_idx += 4; + }) { + const sprite_y = @as(u16, self.oam[oam_idx]); + const diff = @as(i16, @intCast(self.scanline)) - @as(i16, @intCast(sprite_y)); + + if (diff >= 0 and diff < sprite_height) { + if (self.sec_oam_count < 8) { + const idx = self.sec_oam_count; + const tile = self.oam[oam_idx + 1]; + const attr = self.oam[oam_idx + 2]; + const x = self.oam[oam_idx + 3]; + + var row: u16 = @intCast(diff); + // Vertical flip + if ((attr & 0x80) != 0) { + row = (sprite_height - 1) - row; + } + + var pattern_addr: u16 = 0; + if (sprite_height == 8) { + const pattern_table: u16 = if ((self.ctrl & 0x08) != 0) 0x1000 else 0x0000; + pattern_addr = pattern_table + (@as(u16, tile) << 4) + row; + } else { + // 8x16 mode + const pattern_table: u16 = if ((tile & 1) != 0) 0x1000 else 0x0000; + const tile_index: u16 = tile & 0xfe; + if (row < 8) { + pattern_addr = pattern_table + (tile_index << 4) + row; + } else { + pattern_addr = pattern_table + ((tile_index + 1) << 4) + (row - 8); + } + } + + var pat_lo = self.readMemory(cartridge, pattern_addr); + var pat_hi = self.readMemory(cartridge, pattern_addr + 8); + + // Horizontal flip + if ((attr & 0x40) != 0) { + pat_lo = @bitReverse(pat_lo); + pat_hi = @bitReverse(pat_hi); + } + + self.sec_oam_pattern_lo[idx] = pat_lo; + self.sec_oam_pattern_hi[idx] = pat_hi; + self.sec_oam_attribute[idx] = attr; + self.sec_oam_x[idx] = x; + self.sec_oam_is_zero[idx] = (i == 0); + + self.sec_oam_count += 1; + } else { + // Sprite overflow + self.status |= 0x20; + break; + } + } + } +} + +fn renderPixel( + self: *Ppu, + _: *Cartridge, +) void { + const x = self.dot - 1; + + var bg_pixel: u8 = 0; + var bg_palette: u8 = 0; + + if ((self.mask & 0x08) != 0 and (x >= 8 or (self.mask & 0x02) != 0)) { + const bit_shift: u4 = 15 - @as(u4, self.fine_x); + const p1 = @as(u8, @truncate((self.bg_shift_pattern_lo >> bit_shift) & 1)); + const p2 = @as(u8, @truncate((self.bg_shift_pattern_hi >> bit_shift) & 1)); + bg_pixel = (p2 << 1) | p1; + + const a1 = @as(u8, @truncate((self.bg_shift_attrib_lo >> bit_shift) & 1)); + const a2 = @as(u8, @truncate((self.bg_shift_attrib_hi >> bit_shift) & 1)); + bg_palette = (a2 << 1) | a1; + } + + var fg_pixel: u8 = 0; + var fg_palette: u8 = 0; + var fg_priority: bool = false; + var is_sprite_zero: bool = false; + + if (self.sec_oam_count > 0 and (self.mask & 0x10) != 0 and (x >= 8 or (self.mask & 0x04) != 0)) { + for (0..self.sec_oam_count) |i| { + const spr_x = self.sec_oam_x[i]; + if (x >= spr_x and x < spr_x + 8) { + const offset: u3 = @intCast(x - spr_x); + const shift: u3 = 7 - offset; + const p1 = (self.sec_oam_pattern_lo[i] >> shift) & 1; + const p2 = (self.sec_oam_pattern_hi[i] >> shift) & 1; + const pixel = (p2 << 1) | p1; + + if (pixel != 0) { + fg_pixel = pixel; + fg_palette = 4 + (self.sec_oam_attribute[i] & 0x03); + fg_priority = (self.sec_oam_attribute[i] & 0x20) != 0; + is_sprite_zero = self.sec_oam_is_zero[i]; + break; + } + } + } + } + + var final_palette_entry: u16 = 0; + + if (bg_pixel == 0 and fg_pixel == 0) { + final_palette_entry = 0x00; + } else if (bg_pixel == 0 and fg_pixel != 0) { + final_palette_entry = @as(u16, fg_palette) * 4 + fg_pixel; + } else if (bg_pixel != 0 and fg_pixel == 0) { + final_palette_entry = @as(u16, bg_palette) * 4 + bg_pixel; + } else { + // Both bg and fg pixels present + if (is_sprite_zero and x < 255 and (self.mask & 0x18) == 0x18) { + self.status |= 0x40; // Sprite 0 hit + } + + if (fg_priority) { + final_palette_entry = @as(u16, bg_palette) * 4 + bg_pixel; + } else { + final_palette_entry = @as(u16, fg_palette) * 4 + fg_pixel; + } + } + + var color_idx = self.palette[mapPalette(0x3f00 + final_palette_entry)] & 0x3f; + if ((self.mask & 0x01) != 0) { + color_idx &= 0x30; + } + var pixel_rgb = nes_ntsc_palette_rgb565[color_idx]; + + const emphasis = (self.mask >> 5) & 7; + if (emphasis != 0) { + var r = (pixel_rgb >> 11) & 0x1F; + var g = (pixel_rgb >> 5) & 0x3F; + var b = pixel_rgb & 0x1F; + + // Red emphasis (bit 5) + if ((emphasis & 1) != 0) { + g = (g * 3) / 4; + b = (b * 3) / 4; + } + // Green emphasis (bit 6) + if ((emphasis & 2) != 0) { + r = (r * 3) / 4; + b = (b * 3) / 4; + } + // Blue emphasis (bit 7) + if ((emphasis & 4) != 0) { + r = (r * 3) / 4; + g = (g * 3) / 4; + } + + pixel_rgb = (@as(u16, r) << 11) | (@as(u16, g) << 5) | @as(u16, b); + } + + self.framebuffer[self.scanline * 256 + x] = pixel_rgb; +} + +fn incrementVramAddress( + self: *Ppu, +) void { + const increment: u16 = + if ((self.ctrl & 0x04) != 0) + 32 + else + 1; + + self.vram_addr = + (self.vram_addr +% increment) & + 0x3fff; +} + +fn readData( + self: *Ppu, + cartridge: *Cartridge, +) u8 { + const address = + self.vram_addr & 0x3fff; + + const value: u8 = + if (address < 0x3f00) blk: { + const previous = + self.read_buffer; + + self.read_buffer = + self.readMemory( + cartridge, + address, + ); + + break :blk previous; + } else blk: { + const result = + self.readMemory( + cartridge, + address, + ); + + // Palette reads aren't delayed, but + // still update the internal buffer. + self.read_buffer = + self.readMemory( + cartridge, + address - 0x1000, + ); + + break :blk result; + }; + + self.incrementVramAddress(); + + return value; +} + +inline fn readMemory( + self: *Ppu, + cartridge: *Cartridge, + address_: u16, +) u8 { + const address = + address_ & 0x3fff; + + return switch (address) { + 0x0000...0x1fff => cartridge.ppuRead(address), + + 0x2000...0x3eff => self.nametable[ + self.mapNametable( + cartridge.mirroring(), + address, + ) + ], + + 0x3f00...0x3fff => self.palette[ + mapPalette(address) + ], + + else => unreachable, + }; +} + +inline fn writeMemory( + self: *Ppu, + cartridge: *Cartridge, + address_: u16, + value: u8, +) void { + const address = + address_ & 0x3fff; + + switch (address) { + 0x0000...0x1fff => cartridge.ppuWrite( + address, + value, + ), + + 0x2000...0x3eff => self.nametable[ + self.mapNametable( + cartridge.mirroring(), + address, + ) + ] = value, + + 0x3f00...0x3fff => self.palette[ + mapPalette(address) + ] = value, + + else => unreachable, + } +} + +inline fn mapNametable( + _: *const Ppu, + mirroring: Mirroring, + address: u16, +) usize { + const relative = + (@as(usize, address) - 0x2000) & + 0x0fff; + + const table = + relative >> 10; + + const offset = + relative & 0x3ff; + + const mapped_table: usize = + switch (mirroring) { + .vertical => switch (table) { + 0, 2 => 0, + 1, 3 => 1, + else => unreachable, + }, + + .horizontal => switch (table) { + 0, 1 => 0, + 2, 3 => 1, + else => unreachable, + }, + + .single_screen_lower => 0, + .single_screen_upper => 1, + + .four_screen => table, + }; + + return mapped_table * 0x400 + + offset; +} + +inline fn mapPalette( + address: u16, +) usize { + var index: usize = + (@as(usize, address) - 0x3f00) & + 0x1f; + + // Universal background color mirrors. + switch (index) { + 0x10 => index = 0x00, + 0x14 => index = 0x04, + 0x18 => index = 0x08, + 0x1c => index = 0x0c, + else => {}, + } + + return index; +} + +test "NES PPU - Palette Mirroring & NTSC Palette LUT" { + try std.testing.expectEqual(@as(usize, 0), mapPalette(0x3F00)); + try std.testing.expectEqual(@as(usize, 0), mapPalette(0x3F10)); + try std.testing.expectEqual(@as(usize, 4), mapPalette(0x3F04)); + try std.testing.expectEqual(@as(usize, 4), mapPalette(0x3F14)); + + // Verify system palette RGB565 LUT contains non-zero color constants + try std.testing.expectEqual(@as(u16, 0x52AA), nes_ntsc_palette_rgb565[0]); + try std.testing.expectEqual(@as(u16, 0xEF7D), nes_ntsc_palette_rgb565[0x20]); +} + +test "NES PPU - Scroll Address Increments" { + var ppu = Ppu{}; + ppu.reset(); + + ppu.vram_addr = 0x001F; // Coarse X = 31 + ppu.incrementCoarseX(); + try std.testing.expectEqual(@as(u16, 0x0400), ppu.vram_addr); // Coarse X = 0, switched nametable X + + ppu.vram_addr = 0x73A0; // Coarse Y = 29, Fine Y = 7 + ppu.incrementY(); + try std.testing.expectEqual(@as(u16, 0x0800), ppu.vram_addr); // Coarse Y = 0, Fine Y = 0, switched nametable Y +} diff --git a/src/system/nes/root.zig b/src/system/nes/root.zig index e69de29..e591b89 100644 --- a/src/system/nes/root.zig +++ b/src/system/nes/root.zig @@ -0,0 +1,373 @@ +const std = @import("std"); + +const contract = @import("contract"); +const _cpu = @import("cpu"); + +const Bus = @import("bus.zig"); +const Cartridge = @import("cartridge.zig"); + +const controller = @import("controller.zig"); +pub const Button = controller.Button; + +// Adjust this enum spelling to whatever your CPU package +// currently exports for the Ricoh 2A03. +const Cpu = _cpu.m6502.Cpu( + Bus, + .ricoh2a03, + .cycle, +); + + +const Nes = @This(); +pub const spec = contract.SystemSpec{ + .name = "Nintendo Entertainment System", + + .video_outputs = &.{ + .{ + .max_width = 256, + .max_height = 240, + + .format = .rgb565, + + .aspect_ratio = .{ + .numerator = 4, + .denominator = 3, + }, + + .refresh_rate = .{ + .numerator = 60, + .denominator = 1, + }, + }, + }, + + .audio_outputs = &.{ + .{ + .sample_rate = 44_100, + .channels = 1, + .format = .i16, + }, + }, + + .input_devices = &.{ + .{ + .button_count = 8, + }, + .{ + .button_count = 8, + }, + }, + + .storage_devices = &.{ + .{ + .name = "Battery PRG RAM", + .min_size = 0, + .max_size = 8192, + .persistent = true, + .removable = false, + .writable = true, + }, + }, +}; + +bus: Bus = undefined, +cpu: Cpu = undefined, + +/// Initialize in place. +/// +/// `rom` must remain valid for the lifetime of this NES +/// because Cartridge stores slices into it. +pub fn init( + self: *Nes, + rom: []const u8, +) Cartridge.Error!void { + self.bus = + try Bus.init(rom); + + self.cpu = + Cpu.init(&self.bus); + + self.reset(); +} + +pub fn reset( + self: *Nes, +) void { + self.bus.reset(); + self.cpu.reset(); +} + +pub fn step( + self: *Nes, +) ?u8 { + const cycles = self.cpu.step() orelse return null; + self.serviceOamDma(); + return cycles; +} + +pub fn runFrame( + self: *Nes, +) void { + self.bus.ppu.frame_ready = false; + self.bus.apu.sample_count = 0; + + while (!self.bus.ppu.frame_ready) { + _ = self.step() orelse + return; + } +} + +pub const conformance = @import("conformance.zig"); + +test { + _ = conformance; +} + +comptime { + contract.validateSystem(Nes); +} + + +pub fn videoFrame( + self: *const Nes, + output: usize, +) contract.VideoFrame { + std.debug.assert(output == 0); + + return .{ + .data = std.mem.sliceAsBytes( + self.bus.ppu.framebuffer[0..], + ), + + .width = 256, + .height = 240, + + .pitch = 256 * @sizeOf(u16), + + .format = .rgb565, + + .frame_number = self.bus.ppu.frame_number, + }; +} + +pub fn audioBuffer( + self: *const Nes, + output: usize, +) contract.AudioBuffer { + std.debug.assert(output == 0); + + const samples = self.bus.apu.sample_buffer[0..self.bus.apu.sample_count]; + + return .{ + .data = std.mem.sliceAsBytes(samples), + .frames = self.bus.apu.sample_count, + + .sample_rate = 44_100, + .channels = 1, + .format = .i16, + }; +} + +pub fn setInput( + self: *Nes, + device: usize, + input: contract.DeviceInput, +) void { + std.debug.assert(device < 2); + + self.bus.controllers[device] + .setButtons( + @truncate(input.buttons), + ); +} + +pub fn storageView( + self: *const Nes, + slot: usize, +) contract.StorageView { + std.debug.assert(slot == 0); + return .{ + .data = &self.bus.cartridge.prg_ram, + .generation = 1, + }; +} + +pub fn loadStorage( + self: *Nes, + slot: usize, + data: []const u8, +) contract.StorageLoadError!void { + if (slot != 0) return error.InvalidSlot; + if (data.len > self.bus.cartridge.prg_ram.len) return error.InvalidSize; + @memcpy(self.bus.cartridge.prg_ram[0..data.len], data); +} + +fn serviceOamDma( + self: *Nes, +) void { + const page = + self.bus.takeOamDmaPage() orelse return; + + const odd_cycle = + (self.cpu.cycles & 1) != 0; + + // Mandatory DMA halt cycle. + self.cpu.tick(); + + // Alignment cycle when necessary. + if (odd_cycle) + self.cpu.tick(); + + const base = + @as(u16, page) << 8; + + for (0..256) |i| { + // DMA read cycle. + const value = + self.cpu.readCycle( + base | + @as(u16, @intCast(i)), + ); + + // DMA write cycle. + self.bus.ppu.writeOamDma(value); + self.cpu.tick(); + } +} + +pub fn saveState( + self: *const Nes, + buffer: []u8, +) error{BufferTooSmall}!usize { + var offset: usize = 0; + + if (buffer.len < 8) return error.BufferTooSmall; + @memcpy(buffer[offset..][0..8], "6SOZNES1"); + offset += 8; + + // CPU + if (offset + 15 > buffer.len) return error.BufferTooSmall; + std.mem.writeInt(u16, buffer[offset..][0..2], self.cpu.registers.pc, .little); offset += 2; + buffer[offset] = self.cpu.registers.a; offset += 1; + buffer[offset] = self.cpu.registers.x; offset += 1; + buffer[offset] = self.cpu.registers.y; offset += 1; + buffer[offset] = self.cpu.registers.sp; offset += 1; + buffer[offset] = @bitCast(self.cpu.registers.status); offset += 1; + std.mem.writeInt(u64, buffer[offset..][0..8], self.cpu.cycles, .little); offset += 8; + + // Bus RAM & PRG RAM + if (offset + self.bus.ram.len + self.bus.cartridge.prg_ram.len > buffer.len) return error.BufferTooSmall; + @memcpy(buffer[offset..][0..self.bus.ram.len], &self.bus.ram); offset += self.bus.ram.len; + @memcpy(buffer[offset..][0..self.bus.cartridge.prg_ram.len], &self.bus.cartridge.prg_ram); offset += self.bus.cartridge.prg_ram.len; + + if (self.bus.cartridge.chr_is_ram) { + if (offset + self.bus.cartridge.chr_ram.len > buffer.len) return error.BufferTooSmall; + @memcpy(buffer[offset..][0..self.bus.cartridge.chr_ram.len], &self.bus.cartridge.chr_ram); offset += self.bus.cartridge.chr_ram.len; + } + + // PPU State + const ppu_size = 1 + 1 + 1 + 1 + 2 + 2 + 1 + 1 + 2 + 2 + 4096 + 32 + 256; + if (offset + ppu_size > buffer.len) return error.BufferTooSmall; + + buffer[offset] = self.bus.ppu.ctrl; offset += 1; + buffer[offset] = self.bus.ppu.mask; offset += 1; + buffer[offset] = self.bus.ppu.status; offset += 1; + buffer[offset] = self.bus.ppu.oam_addr; offset += 1; + std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.vram_addr, .little); offset += 2; + std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.temp_addr, .little); offset += 2; + buffer[offset] = self.bus.ppu.fine_x; offset += 1; + buffer[offset] = if (self.bus.ppu.write_latch) 1 else 0; offset += 1; + std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.dot, .little); offset += 2; + std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.scanline, .little); offset += 2; + + @memcpy(buffer[offset..][0..4096], &self.bus.ppu.nametable); offset += 4096; + @memcpy(buffer[offset..][0..32], &self.bus.ppu.palette); offset += 32; + @memcpy(buffer[offset..][0..256], &self.bus.ppu.oam); offset += 256; + + return offset; +} + +pub fn loadState( + self: *Nes, + buffer: []const u8, +) error{ InvalidState, BufferTooShort }!void { + var offset: usize = 0; + + if (buffer.len < 8) return error.BufferTooShort; + if (!std.mem.eql(u8, buffer[0..8], "6SOZNES1")) return error.InvalidState; + offset += 8; + + // CPU + if (offset + 15 > buffer.len) return error.BufferTooShort; + self.cpu.registers.pc = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2; + self.cpu.registers.a = buffer[offset]; offset += 1; + self.cpu.registers.x = buffer[offset]; offset += 1; + self.cpu.registers.y = buffer[offset]; offset += 1; + self.cpu.registers.sp = buffer[offset]; offset += 1; + self.cpu.registers.status = @bitCast(buffer[offset]); offset += 1; + self.cpu.cycles = std.mem.readInt(u64, buffer[offset..][0..8], .little); offset += 8; + + // Bus RAM & PRG RAM + if (offset + self.bus.ram.len + self.bus.cartridge.prg_ram.len > buffer.len) return error.BufferTooShort; + @memcpy(&self.bus.ram, buffer[offset..][0..self.bus.ram.len]); offset += self.bus.ram.len; + @memcpy(&self.bus.cartridge.prg_ram, buffer[offset..][0..self.bus.cartridge.prg_ram.len]); offset += self.bus.cartridge.prg_ram.len; + + if (self.bus.cartridge.chr_is_ram) { + if (offset + self.bus.cartridge.chr_ram.len > buffer.len) return error.BufferTooShort; + @memcpy(&self.bus.cartridge.chr_ram, buffer[offset..][0..self.bus.cartridge.chr_ram.len]); offset += self.bus.cartridge.chr_ram.len; + } + + // PPU State + const ppu_size = 1 + 1 + 1 + 1 + 2 + 2 + 1 + 1 + 2 + 2 + 4096 + 32 + 256; + if (offset + ppu_size > buffer.len) return error.BufferTooShort; + + self.bus.ppu.ctrl = buffer[offset]; offset += 1; + self.bus.ppu.mask = buffer[offset]; offset += 1; + self.bus.ppu.status = buffer[offset]; offset += 1; + self.bus.ppu.oam_addr = buffer[offset]; offset += 1; + self.bus.ppu.vram_addr = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2; + self.bus.ppu.temp_addr = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2; + self.bus.ppu.fine_x = @truncate(buffer[offset]); offset += 1; + self.bus.ppu.write_latch = buffer[offset] != 0; offset += 1; + self.bus.ppu.dot = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2; + self.bus.ppu.scanline = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2; + + @memcpy(&self.bus.ppu.nametable, buffer[offset..][0..4096]); offset += 4096; + @memcpy(&self.bus.ppu.palette, buffer[offset..][0..32]); offset += 32; + @memcpy(&self.bus.ppu.oam, buffer[offset..][0..256]); offset += 256; +} + +test "NES System - Save State Serialization Roundtrip" { + // Construct dummy iNES NROM header + 16K PRG + 8K CHR + var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000); + @memcpy(rom[0..4], "NES\x1a"); + rom[4] = 1; // 16K PRG + rom[5] = 1; // 8K CHR + + var nes: Nes = undefined; + try nes.init(&rom); + + // Mutate state + nes.cpu.registers.pc = 0x1234; + nes.cpu.registers.a = 0x42; + nes.bus.ram[0x05] = 0xAA; + nes.bus.ppu.vram_addr = 0x2050; + + var save_buf: [32768]u8 = undefined; + const bytes_written = try nes.saveState(&save_buf); + + // Reset nes + nes.reset(); + try std.testing.expect(nes.cpu.registers.pc != 0x1234); + + // Load state + try nes.loadState(save_buf[0..bytes_written]); + try std.testing.expectEqual(@as(u16, 0x1234), nes.cpu.registers.pc); + try std.testing.expectEqual(@as(u8, 0x42), nes.cpu.registers.a); + try std.testing.expectEqual(@as(u8, 0xAA), nes.bus.ram[0x05]); + try std.testing.expectEqual(@as(u16, 0x2050), nes.bus.ppu.vram_addr); +} + +comptime { + contract.validateSystem(Nes); +} diff --git a/src/system/root.zig b/src/system/root.zig index e69de29..38bc64f 100644 --- a/src/system/root.zig +++ b/src/system/root.zig @@ -0,0 +1,5 @@ +pub const nes = @import("nes/root.zig"); + +test { + _ = nes; +}