diff --git a/src/system/nes/apu/dmc.zig b/src/system/nes/apu/dmc.zig new file mode 100644 index 0000000..a2e0eb8 --- /dev/null +++ b/src/system/nes/apu/dmc.zig @@ -0,0 +1,877 @@ +const std = @import("std"); + +pub const Dmc = @This(); + +pub const Region = @import("../common.zig").Region; + +const ntsc_rates = [16]u16{ + 428, 380, 340, 320, + 286, 254, 226, 214, + 190, 160, 142, 128, + 106, 84, 72, 54, +}; + +const pal_rates = [16]u16{ + 398, 354, 316, 298, + 276, 236, 210, 198, + 176, 148, 132, 118, + 98, 78, 66, 50, +}; + +region: Region, + +// $4010 +// IL--.RRRR + +irq_enabled: bool = false, +loop: bool = false, +rate_index: u4 = 0, + +/// Set when the final sample byte is fetched and IRQs are enabled. +irq_flag: bool = false, + +// $4011 +// -DDD.DDDD + +/// 7-bit DAC output level. +/// +/// This is always sent to the mixer, even if DMC playback is disabled. +output_level: u7 = 0, + +// $4012/$4013 configuration registers + +/// Starting CPU address derived from $4012. +sample_address: u16 = 0xC000, + +/// Number of bytes derived from $4013. +sample_length: u16 = 1, + +// Memory reader + +current_address: u16 = 0xC000, +bytes_remaining: u16 = 0, + +/// One-byte buffer between DMA reader and output unit. +sample_buffer: ?u8 = null, + +/// True once we've emitted a DMA request and are waiting for the +/// CPU/bus to deliver the byte. +dma_pending: bool = false, + +// Timer + +timer_counter: u16 = 0, + +// Output unit + +shift_register: u8 = 0, + +/// Number of bits left in the current 8-bit output cycle. +bits_remaining: u4 = 8, + +/// When true, timer clocks do not modify output_level. +/// +/// The shifter and bit counter still continue to clock. +silence: bool = true, + +pub fn init(region: Region) Dmc { + return .{ + .region = region, + }; +} + +// Register writes + +/// $4010 +/// +/// IL--.RRRR +/// +/// I = IRQ enabled +/// L = loop +/// R = rate index +pub fn writeControl(self: *Dmc, value: u8) void { + self.irq_enabled = (value & 0x80) != 0; + self.loop = (value & 0x40) != 0; + self.rate_index = @truncate(value & 0x0F); + + // + // Clearing IRQ enable immediately clears the DMC IRQ flag. + // + if (!self.irq_enabled) { + self.irq_flag = false; + } + + // + // Do NOT reset timer_counter here. + // +} + +/// $4011 +/// +/// Directly sets the 7-bit DAC. +pub fn writeDirectLoad(self: *Dmc, value: u8) void { + self.output_level = @truncate(value & 0x7F); +} + +/// $4012 +/// +/// address = $C000 + value * 64 +/// +/// = %11AAAAAA.AA000000 +pub fn writeSampleAddress(self: *Dmc, value: u8) void { + self.sample_address = + 0xC000 | (@as(u16, value) << 6); + + // + // Changing this register does NOT alter a sample already + // being played. + // +} + +/// $4013 +/// +/// length = value * 16 + 1 +pub fn writeSampleLength(self: *Dmc, value: u8) void { + self.sample_length = + (@as(u16, value) << 4) | 1; + + // + // Changing this register does NOT alter the current + // bytes_remaining counter. + // +} + +// $4015 + +/// Called when $4015 is written. +/// +/// This method also clears the DMC IRQ because every write to +/// $4015 acknowledges the DMC interrupt. +pub fn writeEnabled(self: *Dmc, enabled: bool) void { + self.irq_flag = false; + + if (!enabled) { + // + // Disabling DMC stops the memory reader by setting + // bytes remaining to zero. + // + // It does NOT: + // + // - clear sample_buffer + // - clear shift_register + // - clear output_level + // - reset bits_remaining + // + self.bytes_remaining = 0; + return; + } + + // + // Enabling restarts the sample ONLY if there isn't already + // a sample in progress. + // + if (self.bytes_remaining == 0) { + self.restartSample(); + } +} + +/// Bit 4 returned by $4015. +/// +/// This reflects bytes remaining, NOT whether the DAC is producing +/// a nonzero value. +pub fn active(self: *const Dmc) bool { + return self.bytes_remaining != 0; +} + +pub fn irqAsserted(self: *const Dmc) bool { + return self.irq_flag; +} + +// Sample restart + +fn restartSample(self: *Dmc) void { + self.current_address = self.sample_address; + self.bytes_remaining = self.sample_length; +} + +// DMC DMA interface + +/// The DMC needs a DMA whenever: +/// +/// - sample buffer is empty +/// - bytes remain in the sample +/// - there isn't already a DMA in progress +pub fn needsDma(self: *const Dmc) bool { + return self.sample_buffer == null and + self.bytes_remaining != 0 and + !self.dma_pending; +} + +/// Start a DMA request. +/// +/// Returns the CPU address that must be read. +/// +/// The CPU/Bus layer should: +/// +/// 1. stall the CPU appropriately +/// 2. read this address +/// 3. call completeDma(value) +/// +pub fn beginDma(self: *Dmc) ?u16 { + if (!self.needsDma()) + return null; + + self.dma_pending = true; + + return self.current_address; +} + +/// Called when the CPU DMA machinery has obtained the requested byte. +pub fn completeDma(self: *Dmc, value: u8) void { + std.debug.assert(self.dma_pending); + + self.dma_pending = false; + + // + // Reader fills the one-byte sample buffer. + // + self.sample_buffer = value; + + // + // Advance sample address. + // + // DMC has a special wrap: + // + // $FFFF -> $8000 + // + // NOT: + // + // $FFFF -> $0000 + // + if (self.current_address == 0xFFFF) { + self.current_address = 0x8000; + } else { + self.current_address += 1; + } + + std.debug.assert(self.bytes_remaining != 0); + self.bytes_remaining -= 1; + + if (self.bytes_remaining == 0) { + if (self.loop) { + // + // Looping immediately reloads address/length. + // + // sample_buffer is already full, so another DMA will + // not happen until the output unit consumes it. + // + self.restartSample(); + } else if (self.irq_enabled) { + // + // Important: + // + // The IRQ happens when the FINAL BYTE IS FETCHED, + // not after its final 8 bits have been played. + // + self.irq_flag = true; + } + } +} + +// Timer + +pub fn timerPeriod(self: *const Dmc) u16 { + const index: usize = @intCast(self.rate_index); + + return switch (self.region) { + .ntsc => ntsc_rates[index], + .pal => pal_rates[index], + }; +} + +/// Clock once per CPU cycle. +pub fn clockTimer(self: *Dmc) void { + if (self.timer_counter == 0) { + // + // NESdev's table gives the exact number of CPU cycles + // between output clocks. + // + // Therefore reload with period - 1. + // + self.timer_counter = self.timerPeriod() - 1; + + self.clockOutputUnit(); + } else { + self.timer_counter -= 1; + } +} + +// Output unit + +fn clockOutputUnit(self: *Dmc) void { + // + // Step 1: + // + // Modify DAC using bit 0 of the shift register, unless this + // output cycle is silent. + // + if (!self.silence) { + if ((self.shift_register & 0x01) != 0) { + // + // Delta bit 1 => +2 + // + // Don't wrap beyond 127. + // + if (self.output_level <= 125) { + self.output_level += 2; + } + } else { + // + // Delta bit 0 => -2 + // + // Don't wrap below zero. + // + if (self.output_level >= 2) { + self.output_level -= 2; + } + } + } + + // + // Step 2: + // + // Shift regardless of silence. + // + self.shift_register >>= 1; + + // + // Step 3: + // + // Advance bit counter. + // + self.bits_remaining -= 1; + + // + // An output cycle consists of exactly 8 timer clocks. + // + if (self.bits_remaining == 0) { + self.startOutputCycle(); + } +} + +fn startOutputCycle(self: *Dmc) void { + self.bits_remaining = 8; + + if (self.sample_buffer) |sample| { + // + // A buffered sample can only enter the shifter at an + // 8-bit output-cycle boundary. + // + self.shift_register = sample; + self.sample_buffer = null; + self.silence = false; + + // + // sample_buffer has just become empty. + // + // needsDma() will now become true if there are more bytes. + // + } else { + // + // No sample available. + // + // The output unit continues ticking, but it stops modifying + // the DAC. + // + self.silence = true; + } +} + +// Mixer + +/// Current DMC mixer input. +/// +/// This must NOT be gated by: +/// +/// - bytes_remaining +/// - $4015 enable +/// - silence +/// +/// The 7-bit DAC retains its value. +pub fn output(self: *const Dmc) u7 { + return self.output_level; +} + +// Tests + +test "$4010 decodes IRQ loop and rate" { + var dmc = Dmc.init(.ntsc); + + dmc.writeControl(0b1100_1010); + + try std.testing.expect(dmc.irq_enabled); + try std.testing.expect(dmc.loop); + try std.testing.expectEqual( + @as(u4, 10), + dmc.rate_index, + ); +} + +test "clearing IRQ enable clears IRQ flag" { + var dmc = Dmc.init(.ntsc); + + dmc.irq_flag = true; + + dmc.writeControl(0x00); + + try std.testing.expect(!dmc.irq_flag); +} + +test "$4011 directly sets output level" { + var dmc = Dmc.init(.ntsc); + + dmc.writeDirectLoad(0x55); + + try std.testing.expectEqual( + @as(u7, 0x55), + dmc.output(), + ); +} + +test "$4011 ignores bit seven" { + var dmc = Dmc.init(.ntsc); + + dmc.writeDirectLoad(0xFF); + + try std.testing.expectEqual( + @as(u7, 127), + dmc.output(), + ); +} + +test "$4012 sample address calculation" { + var dmc = Dmc.init(.ntsc); + + dmc.writeSampleAddress(0x00); + + try std.testing.expectEqual( + @as(u16, 0xC000), + dmc.sample_address, + ); + + dmc.writeSampleAddress(0x01); + + try std.testing.expectEqual( + @as(u16, 0xC040), + dmc.sample_address, + ); + + dmc.writeSampleAddress(0xFF); + + try std.testing.expectEqual( + @as(u16, 0xFFC0), + dmc.sample_address, + ); +} + +test "$4013 sample length calculation" { + var dmc = Dmc.init(.ntsc); + + dmc.writeSampleLength(0x00); + + try std.testing.expectEqual( + @as(u16, 1), + dmc.sample_length, + ); + + dmc.writeSampleLength(0x01); + + try std.testing.expectEqual( + @as(u16, 17), + dmc.sample_length, + ); + + dmc.writeSampleLength(0xFF); + + try std.testing.expectEqual( + @as(u16, 4081), + dmc.sample_length, + ); +} + +test "enabling starts sample if no bytes remain" { + var dmc = Dmc.init(.ntsc); + + dmc.writeSampleAddress(0x20); + dmc.writeSampleLength(0x02); + + dmc.writeEnabled(true); + + try std.testing.expectEqual( + dmc.sample_address, + dmc.current_address, + ); + + try std.testing.expectEqual( + dmc.sample_length, + dmc.bytes_remaining, + ); +} + +test "enabling does not restart active sample" { + var dmc = Dmc.init(.ntsc); + + dmc.current_address = 0xD123; + dmc.bytes_remaining = 5; + + dmc.writeEnabled(true); + + try std.testing.expectEqual( + @as(u16, 0xD123), + dmc.current_address, + ); + + try std.testing.expectEqual( + @as(u16, 5), + dmc.bytes_remaining, + ); +} + +test "disabling clears bytes remaining" { + var dmc = Dmc.init(.ntsc); + + dmc.bytes_remaining = 123; + + dmc.writeEnabled(false); + + try std.testing.expectEqual( + @as(u16, 0), + dmc.bytes_remaining, + ); +} + +test "disabling does not clear DAC output" { + var dmc = Dmc.init(.ntsc); + + dmc.output_level = 70; + dmc.bytes_remaining = 10; + + dmc.writeEnabled(false); + + try std.testing.expectEqual( + @as(u7, 70), + dmc.output(), + ); +} + +test "$4015 write clears DMC IRQ" { + var dmc = Dmc.init(.ntsc); + + dmc.irq_flag = true; + + dmc.writeEnabled(true); + + try std.testing.expect(!dmc.irq_flag); +} + +test "active is based only on bytes remaining" { + var dmc = Dmc.init(.ntsc); + + dmc.bytes_remaining = 0; + dmc.sample_buffer = 0xAA; + + try std.testing.expect(!dmc.active()); + + dmc.bytes_remaining = 1; + + try std.testing.expect(dmc.active()); +} + +test "DMC requests DMA when buffer is empty" { + var dmc = Dmc.init(.ntsc); + + dmc.current_address = 0xC123; + dmc.bytes_remaining = 4; + + try std.testing.expect(dmc.needsDma()); + + const address = dmc.beginDma(); + + try std.testing.expectEqual( + @as(?u16, 0xC123), + address, + ); + + try std.testing.expect(dmc.dma_pending); + try std.testing.expect(!dmc.needsDma()); +} + +test "DMA fills buffer and advances reader" { + var dmc = Dmc.init(.ntsc); + + dmc.current_address = 0xC123; + dmc.bytes_remaining = 4; + + _ = dmc.beginDma(); + + dmc.completeDma(0xAB); + + try std.testing.expectEqual( + @as(?u8, 0xAB), + dmc.sample_buffer, + ); + + try std.testing.expectEqual( + @as(u16, 0xC124), + dmc.current_address, + ); + + try std.testing.expectEqual( + @as(u16, 3), + dmc.bytes_remaining, + ); + + try std.testing.expect(!dmc.dma_pending); +} + +test "DMA address wraps $FFFF to $8000" { + var dmc = Dmc.init(.ntsc); + + dmc.current_address = 0xFFFF; + dmc.bytes_remaining = 2; + + _ = dmc.beginDma(); + dmc.completeDma(0x00); + + try std.testing.expectEqual( + @as(u16, 0x8000), + dmc.current_address, + ); +} + +test "final fetch raises IRQ" { + var dmc = Dmc.init(.ntsc); + + dmc.irq_enabled = true; + dmc.loop = false; + + dmc.current_address = 0xC000; + dmc.bytes_remaining = 1; + + _ = dmc.beginDma(); + dmc.completeDma(0xAA); + + try std.testing.expectEqual( + @as(u16, 0), + dmc.bytes_remaining, + ); + + try std.testing.expect(dmc.irq_flag); +} + +test "loop restarts sample instead of raising IRQ" { + var dmc = Dmc.init(.ntsc); + + dmc.sample_address = 0xD000; + dmc.sample_length = 17; + + dmc.current_address = 0xD010; + dmc.bytes_remaining = 1; + + dmc.loop = true; + dmc.irq_enabled = true; + + _ = dmc.beginDma(); + dmc.completeDma(0xAA); + + try std.testing.expectEqual( + @as(u16, 0xD000), + dmc.current_address, + ); + + try std.testing.expectEqual( + @as(u16, 17), + dmc.bytes_remaining, + ); + + try std.testing.expect(!dmc.irq_flag); +} + +test "sample buffer prevents another DMA" { + var dmc = Dmc.init(.ntsc); + + dmc.bytes_remaining = 10; + dmc.sample_buffer = 0xAA; + + try std.testing.expect(!dmc.needsDma()); +} + +test "NTSC rate table" { + var dmc = Dmc.init(.ntsc); + + dmc.rate_index = 0; + try std.testing.expectEqual( + @as(u16, 428), + dmc.timerPeriod(), + ); + + dmc.rate_index = 15; + try std.testing.expectEqual( + @as(u16, 54), + dmc.timerPeriod(), + ); +} + +test "PAL rate table" { + var dmc = Dmc.init(.pal); + + dmc.rate_index = 0; + try std.testing.expectEqual( + @as(u16, 398), + dmc.timerPeriod(), + ); + + dmc.rate_index = 15; + try std.testing.expectEqual( + @as(u16, 50), + dmc.timerPeriod(), + ); +} + +test "delta one increases output by two" { + var dmc = Dmc.init(.ntsc); + + dmc.output_level = 50; + dmc.shift_register = 0b0000_0001; + dmc.bits_remaining = 8; + dmc.silence = false; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u7, 52), + dmc.output_level, + ); +} + +test "delta zero decreases output by two" { + var dmc = Dmc.init(.ntsc); + + dmc.output_level = 50; + dmc.shift_register = 0; + dmc.bits_remaining = 8; + dmc.silence = false; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u7, 48), + dmc.output_level, + ); +} + +test "delta increment does not overflow" { + var dmc = Dmc.init(.ntsc); + + dmc.output_level = 126; + dmc.shift_register = 1; + dmc.bits_remaining = 8; + dmc.silence = false; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u7, 126), + dmc.output_level, + ); +} + +test "delta decrement does not underflow" { + var dmc = Dmc.init(.ntsc); + + dmc.output_level = 1; + dmc.shift_register = 0; + dmc.bits_remaining = 8; + dmc.silence = false; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u7, 1), + dmc.output_level, + ); +} + +test "silent cycle does not change DAC" { + var dmc = Dmc.init(.ntsc); + + dmc.output_level = 50; + dmc.shift_register = 1; + dmc.bits_remaining = 8; + dmc.silence = true; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u7, 50), + dmc.output_level, + ); +} + +test "output shifter shifts right" { + var dmc = Dmc.init(.ntsc); + + dmc.shift_register = 0b1010_1011; + dmc.bits_remaining = 8; + dmc.silence = false; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u8, 0b0101_0101), + dmc.shift_register, + ); +} + +test "new output cycle consumes sample buffer" { + var dmc = Dmc.init(.ntsc); + + dmc.bits_remaining = 1; + dmc.sample_buffer = 0b1010_1010; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u4, 8), + dmc.bits_remaining, + ); + + try std.testing.expectEqual( + @as(u8, 0b1010_1010), + dmc.shift_register, + ); + + try std.testing.expectEqual( + @as(?u8, null), + dmc.sample_buffer, + ); + + try std.testing.expect(!dmc.silence); +} + +test "new output cycle becomes silent when buffer empty" { + var dmc = Dmc.init(.ntsc); + + dmc.bits_remaining = 1; + dmc.sample_buffer = null; + dmc.silence = false; + + dmc.clockOutputUnit(); + + try std.testing.expectEqual( + @as(u4, 8), + dmc.bits_remaining, + ); + + try std.testing.expect(dmc.silence); +} diff --git a/src/system/nes/apu/envelope.zig b/src/system/nes/apu/envelope.zig new file mode 100644 index 0000000..fe86875 --- /dev/null +++ b/src/system/nes/apu/envelope.zig @@ -0,0 +1,132 @@ +const std = @import("std"); + +const Envelope = @This(); + +/// Bit 5: envelope loop / length-counter halt. +loop: bool = false, + +/// Bit 4: select constant volume instead of decay level. +constant_volume: bool = false, + +/// Bits 3-0. +/// +/// When constant_volume is true this is the output volume. +/// Otherwise this is the divider reload value. +volume: u4 = 0, + +/// Set by writes to: +/// +/// Pulse 1: $4003 +/// Pulse 2: $4007 +/// Noise: $400F +start: bool = false, + +/// Envelope divider counter. +/// +/// Reloaded with `volume`. +divider: u4 = 0, + +/// Current envelope volume, 0...15. +decay_level: u4 = 0, + +pub fn init() Envelope { + return .{}; +} + +/// Write the envelope portion of: +/// +/// $4000 - Pulse 1 +/// $4004 - Pulse 2 +/// $400C - Noise +/// +/// Register: +/// +/// --LC.VVVV +/// +/// L = loop envelope / halt length counter +/// C = constant volume +/// V = volume / envelope divider period +pub fn write(self: *Envelope, value: u8) void { + self.loop = (value & 0x20) != 0; + self.constant_volume = (value & 0x10) != 0; + self.volume = @truncate(value); +} + +/// Writing $4003/$4007/$400F does not immediately restart the +/// envelope. +/// +/// Instead it sets this flag. The restart occurs at the next +/// quarter-frame clock. +pub fn restart(self: *Envelope) void { + self.start = true; +} + +/// Clocked by the APU frame counter on every quarter-frame. +pub fn clockQuarterFrame(self: *Envelope) void { + // + // If the start flag is set: + // + // 1. clear start + // 2. set decay to 15 + // 3. reload divider + // + if (self.start) { + self.start = false; + self.decay_level = 15; + self.divider = self.volume; + return; + } + + // + // Clock the divider. + // + if (self.divider != 0) { + self.divider -= 1; + return; + } + + // + // Divider reached zero: + // + // 1. reload divider + // 2. clock decay counter + // + self.divider = self.volume; + + if (self.decay_level != 0) { + self.decay_level -= 1; + } else if (self.loop) { + self.decay_level = 15; + } +} + +/// Current 4-bit volume sent to the channel's output gate. +pub fn output(self: *const Envelope) u4 { + return if (self.constant_volume) + self.volume + else + self.decay_level; +} + +// ponytail: consolidated envelope generator unit test suite +test "envelope register write, quarter-frame clocking, decay and loop" { + var envelope = Envelope.init(); + envelope.write(0b0010_1010); // loop=1, const_vol=0, vol=10 + try std.testing.expect(envelope.loop and !envelope.constant_volume); + + // Restart & quarter-frame clocking + envelope.write(0b0000_0010); + envelope.restart(); + try std.testing.expect(envelope.start); + envelope.clockQuarterFrame(); + try std.testing.expect(!envelope.start and envelope.decay_level == 15 and envelope.divider == 2); + + // Decay countdown & loop + envelope.write(0b0010_0000); // loop enabled, vol=0 + envelope.restart(); + envelope.clockQuarterFrame(); + for (0..15) |_| envelope.clockQuarterFrame(); + try std.testing.expectEqual(@as(u4, 0), envelope.decay_level); + envelope.clockQuarterFrame(); + try std.testing.expectEqual(@as(u4, 15), envelope.decay_level); // wrapped back +} diff --git a/src/system/nes/apu/frame_counter.zig b/src/system/nes/apu/frame_counter.zig new file mode 100644 index 0000000..9964cba --- /dev/null +++ b/src/system/nes/apu/frame_counter.zig @@ -0,0 +1,237 @@ +const std = @import("std"); +const Region = @import("../common.zig").Region; + +pub const FrameCounter = @This(); + +pub const Mode = enum { + four_step, + five_step, +}; + +pub const Events = struct { + quarter: bool = false, + half: bool = false, +}; + +region: Region, + +mode: Mode = .four_step, +pending_mode: Mode = .four_step, + +irq_inhibit: bool = false, +irq_flag: bool = false, +irq_just_set: bool = false, + +/// CPU cycles since the current frame sequence started. +cycle: u32 = 0, + +/// $4017 reset delay. +/// +/// Hardware applies the reset after either 3 or 4 CPU cycles. +reset_delay: u3 = 0, + +pub fn init(region: Region) FrameCounter { + return .{ + .region = region, + }; +} + +/// Write $4017: +/// +/// MI--.---- +/// +/// M = 5-step mode +/// I = frame IRQ inhibit +pub fn write( + self: *FrameCounter, + value: u8, + cpu_cycle: u64, +) void { + self.pending_mode = if ((value & 0x80) != 0) + .five_step + else + .four_step; + + self.irq_inhibit = (value & 0x40) != 0; + + // + // Setting interrupt inhibit immediately clears frame IRQ. + // + if (self.irq_inhibit) { + self.irq_flag = false; + } + + // + // $4017 reset occurs after 3 or 4 CPU cycles depending on + // APU clock phase. + // + // This assumes even cpu_cycle values are our APU-cycle phase. + // + // If your CPU core defines cycle parity oppositely, flip this. + // + self.reset_delay = if ((cpu_cycle & 1) == 0) + 3 + else + 4; +} + +pub fn clockCpu(self: *FrameCounter) Events { + self.irq_just_set = false; + var events = self.clockSequence(); + + // + // The existing sequence keeps running during the delayed + // $4017 reset. + // + if (self.reset_delay != 0) { + self.reset_delay -= 1; + + if (self.reset_delay == 0) { + self.mode = self.pending_mode; + self.cycle = 0; + + // + // Entering 5-step mode immediately generates both + // quarter-frame and half-frame clocks. + // + if (self.mode == .five_step) { + events.quarter = true; + events.half = true; + } + } + } + + return events; +} + +fn clockSequence(self: *FrameCounter) Events { + self.cycle += 1; + + // NESdev timing constants (expressed in CPU cycles) + const Timing = struct { + s1: u32, + s2: u32, + s3: u32, + s4: u32, + s5: u32, + m0_irq1: u32, + m0_irq2: u32, + m0_reset: u32, + m1_reset: u32, + }; + + const t: Timing = switch (self.region) { + .ntsc => .{ + .s1 = 7457, + .s2 = 14913, + .s3 = 22371, + .s4 = 29829, + .s5 = 37281, + .m0_irq1 = 29828, + .m0_irq2 = 29829, + .m0_reset = 29830, + .m1_reset = 37282, + }, + .pal => .{ + .s1 = 8313, + .s2 = 16627, + .s3 = 24939, + .s4 = 33253, + .s5 = 41565, + .m0_irq1 = 33252, + .m0_irq2 = 33253, + .m0_reset = 33254, + .m1_reset = 41566, + }, + }; + + var events = Events{}; + + switch (self.mode) { + .four_step => { + if (self.cycle == t.s1) { + events.quarter = true; + } else if (self.cycle == t.s2) { + events.quarter = true; + events.half = true; + } else if (self.cycle == t.s3) { + events.quarter = true; + } else if (self.cycle == t.m0_irq1) { + if (!self.irq_inhibit) { + self.irq_flag = true; + self.irq_just_set = true; + } + } else if (self.cycle == t.m0_irq2) { + events.quarter = true; + events.half = true; + if (!self.irq_inhibit) { + self.irq_flag = true; + self.irq_just_set = true; + } + } else if (self.cycle >= t.m0_reset) { + if (!self.irq_inhibit) { + self.irq_flag = true; + self.irq_just_set = true; + } + self.cycle = 0; + } + }, + + .five_step => { + if (self.cycle == t.s1) { + events.quarter = true; + } else if (self.cycle == t.s2) { + events.quarter = true; + events.half = true; + } else if (self.cycle == t.s3) { + events.quarter = true; + } else if (self.cycle == t.s5) { + events.quarter = true; + events.half = true; + } else if (self.cycle >= t.m1_reset) { + self.cycle = 0; + } + }, + } + + return events; +} + +pub fn reset(self: *FrameCounter) void { + self.mode = .four_step; + self.pending_mode = .four_step; + self.irq_inhibit = false; + self.irq_flag = false; + self.irq_just_set = false; + self.cycle = 0; + self.reset_delay = 0; +} + +pub fn irqAsserted(self: *const FrameCounter) bool { + return self.irq_flag; +} + +pub fn clearIrq(self: *FrameCounter) void { + // ponytail: simultaneous read and IRQ set does not clear flag + if (!self.irq_just_set) { + self.irq_flag = false; + } +} + +// ponytail: consolidated frame counter unit test suite +test "frame counter 4-step and 5-step mode timings, irq window and write delays" { + var fc = FrameCounter.init(.ntsc); + + // 4-step mode timings and IRQ window + for (0..29828) |_| _ = fc.clockCpu(); + try std.testing.expect(fc.irqAsserted()); + fc.clearIrq(); // simultaneous set/read check + try std.testing.expect(fc.irqAsserted()); // remains set until next cycle + + // 5-step mode + fc = FrameCounter.init(.ntsc); + fc.write(0x80, 0); // 5-step write + for (0..4) |_| _ = fc.clockCpu(); + try std.testing.expect(!fc.irqAsserted()); + try std.testing.expectEqual(Mode.five_step, fc.mode); +} diff --git a/src/system/nes/apu/length_counter.zig b/src/system/nes/apu/length_counter.zig new file mode 100644 index 0000000..7716da2 --- /dev/null +++ b/src/system/nes/apu/length_counter.zig @@ -0,0 +1,78 @@ +const std = @import("std"); + +const LengthCounter = @This(); + +pub const table = [32]u8{ + 10, 254, 20, 2, + 40, 4, 80, 6, + 160, 8, 60, 10, + 14, 12, 26, 14, + + 12, 16, 24, 18, + 48, 20, 96, 22, + 192, 24, 72, 26, + 16, 28, 32, 30, +}; + +enabled: bool = false, +value: u8 = 0, + +pub fn init() LengthCounter { + return .{}; +} + +/// Corresponds to this channel's bit in $4015. +/// +/// Clearing the enable bit immediately clears the length counter. +/// Setting it does not reload the counter. +pub fn setEnabled(self: *LengthCounter, enabled: bool) void { + self.enabled = enabled; + + if (!enabled) { + self.value = 0; + } +} + +/// Load from bits 7-3 of $4003/$4007. +/// +/// The counter may only be loaded while the channel is enabled +/// through $4015. +pub fn load(self: *LengthCounter, index: u5) void { + if (!self.enabled) + return; + + self.value = table[@intCast(index)]; +} + +/// Clocked on a half-frame. +pub fn clock(self: *LengthCounter, halt: bool) void { + if (self.value != 0 and !halt) { + self.value -= 1; + } +} + +pub fn active(self: *const LengthCounter) bool { + return self.value != 0; +} + +// ponytail: consolidated length counter unit test suite +test "length counter enable, loading, immediate clearing, clocking and halt" { + try std.testing.expectEqual(@as(u8, 10), table[0]); + + var counter = LengthCounter.init(); + counter.load(0); + try std.testing.expectEqual(@as(u8, 0), counter.value); + + counter.setEnabled(true); + counter.load(0); + try std.testing.expectEqual(@as(u8, 10), counter.value); + + counter.clock(false); // decrement + try std.testing.expectEqual(@as(u8, 9), counter.value); + + counter.clock(true); // halt prevents decrement + try std.testing.expectEqual(@as(u8, 9), counter.value); + + counter.setEnabled(false); // clears immediately + try std.testing.expectEqual(@as(u8, 0), counter.value); +} diff --git a/src/system/nes/apu/noise.zig b/src/system/nes/apu/noise.zig new file mode 100644 index 0000000..b928d2d --- /dev/null +++ b/src/system/nes/apu/noise.zig @@ -0,0 +1,175 @@ +const std = @import("std"); + +const Envelope = @import("envelope.zig"); +const LengthCounter = @import("length_counter.zig"); +const Region = @import("../common.zig").Region; + +pub const Noise = @This(); + +const ntsc_periods = [16]u16{ + 4, 8, 16, 32, + 64, 96, 128, 160, + 202, 254, 380, 508, + 762, 1016, 2034, 4068, +}; + +const pal_periods = [16]u16{ + 4, 8, 14, 30, + 60, 88, 118, 148, + 188, 236, 354, 472, + 708, 944, 1890, 3778, +}; + +region: Region, + +envelope: Envelope = .{}, +length_counter: LengthCounter = .{}, + +/// $400E bit 7. +mode: bool = false, + +/// $400E bits 3..0. +period_index: u4 = 0, + +/// CPU-cycle timer. +timer_counter: u16 = 0, + +/// 15-bit LFSR. +/// +/// Must not be initialized to zero or it will remain there forever. +shift_register: u15 = 1, + +pub fn init(region: Region) Noise { + return .{ + .region = region, + }; +} + +// $400C +// --LC.VVVV + +pub fn writeControl(self: *Noise, value: u8) void { + self.envelope.write(value); +} + +// $400E +// M---.PPPP + +pub fn writePeriod(self: *Noise, value: u8) void { + self.mode = (value & 0x80) != 0; + self.period_index = @truncate(value & 0x0f); +} + +// $400F +// LLLL.L--- + +pub fn writeLength(self: *Noise, value: u8) void { + const index: u5 = @truncate(value >> 3); + + self.length_counter.load(index); + self.envelope.restart(); +} + +// $4015 + +pub fn setEnabled(self: *Noise, enabled: bool) void { + self.length_counter.setEnabled(enabled); +} + +pub fn active(self: *const Noise) bool { + return self.length_counter.active(); +} + +// Frame sequencer + +pub fn clockQuarterFrame(self: *Noise) void { + self.envelope.clockQuarterFrame(); +} + +pub fn clockHalfFrame(self: *Noise) void { + self.length_counter.clock(self.envelope.loop); +} + +// Timer + +pub fn timerPeriod(self: *const Noise) u16 { + const index: usize = @intCast(self.period_index); + + return switch (self.region) { + .ntsc => ntsc_periods[index], + .pal => pal_periods[index], + }; +} + +/// Called every CPU cycle. +pub fn clockCpu(self: *Noise) void { + if (self.timer_counter == 0) { + self.timer_counter = self.timerPeriod() - 1; + self.clockShiftRegister(); + } else { + self.timer_counter -= 1; + } +} + +fn clockShiftRegister(self: *Noise) void { + const tap: u4 = if (self.mode) 6 else 1; + + const feedback: u1 = @truncate( + (self.shift_register ^ + (self.shift_register >> tap)) & 1, + ); + + self.shift_register >>= 1; + self.shift_register |= @as(u15, feedback) << 14; +} + +// Output + +pub fn output(self: *const Noise) u4 { + if (!self.length_counter.active()) + return 0; + + // + // Noise channel is muted whenever LFSR bit 0 is 1. + // + if ((self.shift_register & 1) != 0) + return 0; + + return self.envelope.output(); +} + +pub fn reset(self: *Noise) void { + self.shift_register = 1; + self.timer_counter = 0; + self.period_index = 0; + self.mode = false; + self.length_counter.setEnabled(false); +} + +// ponytail: consolidated noise unit test suite +test "noise channel control, period, length and LFSR shift register" { + var noise = Noise.init(.ntsc); + noise.writeControl(0b0011_1010); + try std.testing.expect(noise.envelope.constant_volume); + try std.testing.expectEqual(@as(u4, 10), noise.envelope.volume); + + noise.writePeriod(0b1000_0101); // Mode 1, period index 5 + try std.testing.expect(noise.mode); + try std.testing.expectEqual(@as(u16, 96), noise.timerPeriod()); + + noise.setEnabled(true); + noise.writeLength(0b0000_1000); + try std.testing.expect(noise.length_counter.active()); + + // Output gating by bit 0 + noise.shift_register = 1; + try std.testing.expectEqual(@as(u4, 0), noise.output()); + noise.shift_register = 2; + try std.testing.expectEqual(@as(u4, 10), noise.output()); + + // Shift register mode 0 vs mode 1 + noise.shift_register = 1; + noise.mode = false; + noise.clockShiftRegister(); + try std.testing.expectEqual(@as(u15, 0x4000), noise.shift_register); +} diff --git a/src/system/nes/apu/pulse.zig b/src/system/nes/apu/pulse.zig new file mode 100644 index 0000000..da5ff12 --- /dev/null +++ b/src/system/nes/apu/pulse.zig @@ -0,0 +1,301 @@ +const std = @import("std"); + +const Envelope = @import("envelope.zig"); +const Sweep = @import("sweep.zig"); +const LengthCounter = @import("length_counter.zig"); + +pub const Pulse = @This(); +pub const Channel = Sweep.Channel; + +/// Internal lookup table. +/// +/// The hardware sequencer starts at index 0 and counts DOWN: +/// +/// 0, 7, 6, 5, 4, 3, 2, 1 +/// +/// Producing: +/// +/// duty 0: 0 1 0 0 0 0 0 0 +/// duty 1: 0 1 1 0 0 0 0 0 +/// duty 2: 0 1 1 1 1 0 0 0 +/// duty 3: 1 0 0 1 1 1 1 1 +const duty_table = [4][8]u1{ + .{ 0, 0, 0, 0, 0, 0, 0, 1 }, + .{ 0, 0, 0, 0, 0, 0, 1, 1 }, + .{ 0, 0, 0, 0, 1, 1, 1, 1 }, + .{ 1, 1, 1, 1, 1, 1, 0, 0 }, +}; + +channel: Channel, + +envelope: Envelope = .{}, +sweep: Sweep, +length_counter: LengthCounter = .{}, + +/// DD from $4000/$4004. +duty: u2 = 0, + +/// Internal 8-step sequencer position. +/// +/// Counts downward. +sequence_position: u3 = 0, + +/// 11-bit period from: +/// +/// HHHLLLLLLLL +/// +/// We intentionally keep this as u16 because the sweep target +/// calculation needs to detect values > $7FF. +timer_period: u16 = 0, + +/// Current timer divider value. +/// +/// This is separate from timer_period because writing $4003/$4007 +/// does NOT reset this divider. +timer_counter: u16 = 0, + +pub fn init(channel: Channel) Pulse { + return .{ + .channel = channel, + .sweep = Sweep.init(channel), + }; +} + +// $4015 + +/// Set this pulse channel's enable bit from $4015. +pub fn setEnabled(self: *Pulse, enabled: bool) void { + self.length_counter.setEnabled(enabled); +} + +/// Used when constructing $4015 reads. +pub fn active(self: *const Pulse) bool { + return self.length_counter.active(); +} + +// $4000 / $4004 +// DDLC.VVVV + +pub fn writeControl(self: *Pulse, value: u8) void { + self.duty = @truncate(value >> 6); + + self.envelope.write(value); + + // Important: + // + // Changing duty does NOT reset sequence_position. +} + +// $4001 / $4005 +// EPPP.NSSS + +pub fn writeSweep(self: *Pulse, value: u8) void { + self.sweep.write(value); +} + +// $4002 / $4006 +// LLLL.LLLL + +pub fn writeTimerLow(self: *Pulse, value: u8) void { + self.timer_period = + (self.timer_period & 0x0700) | + @as(u16, value); +} + +// $4003 / $4007 +// LLLL.LHHH + +pub fn writeTimerHigh(self: *Pulse, value: u8) void { + // + // Replace high 3 timer bits. + // + self.timer_period = + (self.timer_period & 0x00ff) | + (@as(u16, value & 0x07) << 8); + + // + // Load length counter from bits 7-3. + // + // LengthCounter itself checks whether $4015 has enabled + // this channel. + // + const length_index: u5 = @truncate(value >> 3); + self.length_counter.load(length_index); + + // + // Restart waveform sequencer immediately. + // + self.sequence_position = 0; + + // + // Restart envelope on next quarter-frame clock. + // + self.envelope.restart(); + + // + // IMPORTANT: + // + // Do NOT modify timer_counter here. + // + // $4003/$4007 reset waveform phase but don't reset the + // period divider. + // +} + +// Timer + +/// Clock once per APU cycle. +/// +/// For the NES pulse channels: +/// +/// 1 APU cycle = 2 CPU cycles +/// +/// This means the caller should invoke this every second CPU cycle. +pub fn clockTimer(self: *Pulse) void { + if (self.timer_counter == 0) { + // + // Timer runs: + // + // t, t-1, ... 1, 0 + // + // and then reloads with t. + // + self.timer_counter = self.timer_period; + + // + // Hardware sequencer counts downward. + // + self.sequence_position -%= 1; + } else { + self.timer_counter -= 1; + } +} + +// Frame sequencer +// +/// Quarter-frame clock: +/// +/// Pulse channels clock their envelopes. +pub fn clockQuarterFrame(self: *Pulse) void { + self.envelope.clockQuarterFrame(); +} + +/// Half-frame clock: +/// +/// Pulse channels clock: +/// +/// - sweep +/// - length counter +pub fn clockHalfFrame(self: *Pulse) void { + self.sweep.clockHalfFrame(&self.timer_period); + + // + // $4000/$4004 bit 5 serves BOTH purposes: + // + // envelope loop + // length-counter halt + // + self.length_counter.clock(self.envelope.loop); +} + +// Output +fn sequencerOutput(self: *const Pulse) u1 { + const duty: usize = @intCast(self.duty); + const position: usize = @intCast(self.sequence_position); + + return duty_table[duty][position]; +} + +/// Current raw pulse-channel output: 0...15. +/// +/// This is the value that should be passed to the APU pulse mixer. +pub fn output(self: *const Pulse) u4 { + // + // Length-counter gate. + // + if (!self.length_counter.active()) + return 0; + + // + // Sweep gate. + // + // Covers: + // + // timer_period < 8 + // target_period > $7FF + // + if (self.sweep.isMuted(self.timer_period)) + return 0; + + // + // Duty sequencer gate. + // + if (self.sequencerOutput() == 0) + return 0; + + // + // Everything is allowing output, so the current envelope + // volume reaches the mixer. + // + return self.envelope.output(); +} + +// Debug / inspection +pub fn timerPeriod(self: *const Pulse) u16 { + return self.timer_period; +} + +pub fn length(self: *const Pulse) u8 { + return self.length_counter.value; +} + +pub fn sequencePosition(self: *const Pulse) u3 { + return self.sequence_position; +} + +// ponytail: consolidated pulse unit test suite +test "pulse channel register writes, timing, duty sequences and output gating" { + var pulse = Pulse.init(.pulse1); + + // Period low and high writes + pulse.timer_period = 0x500; + pulse.writeTimerLow(0xab); + try std.testing.expectEqual(@as(u16, 0x5ab), pulse.timer_period); + pulse.writeTimerHigh(0x05); + try std.testing.expectEqual(@as(u16, 0x5ab), pulse.timer_period); + try std.testing.expectEqual(@as(u3, 0), pulse.sequence_position); + try std.testing.expect(pulse.envelope.start); + + // Length loading and clearing + pulse.setEnabled(true); + pulse.writeTimerHigh(0x00); + try std.testing.expectEqual(@as(u8, 10), pulse.length_counter.value); + pulse.setEnabled(false); + try std.testing.expectEqual(@as(u8, 0), pulse.length_counter.value); + + // Duty sequences (0..3) + const expected_sequences = [4][8]u1{ + .{ 0, 1, 0, 0, 0, 0, 0, 0 }, + .{ 0, 1, 1, 0, 0, 0, 0, 0 }, + .{ 0, 1, 1, 1, 1, 0, 0, 0 }, + .{ 1, 0, 0, 1, 1, 1, 1, 1 }, + }; + const positions = [8]u3{ 0, 7, 6, 5, 4, 3, 2, 1 }; + for (expected_sequences, 0..) |expected, duty_idx| { + pulse.duty = @truncate(duty_idx); + for (positions, expected) |pos, exp| { + pulse.sequence_position = pos; + try std.testing.expectEqual(exp, pulse.sequencerOutput()); + } + } + + // Output gating checks (zero length, timer < 8, envelope volume) + pulse.writeControl(0b0011_1010); + pulse.length_counter.value = 10; + pulse.timer_period = 100; + pulse.sequence_position = 7; + try std.testing.expectEqual(@as(u4, 10), pulse.output()); + + pulse.timer_period = 7; + try std.testing.expectEqual(@as(u4, 0), pulse.output()); +} diff --git a/src/system/nes/apu/root.zig b/src/system/nes/apu/root.zig new file mode 100644 index 0000000..04bc8ad --- /dev/null +++ b/src/system/nes/apu/root.zig @@ -0,0 +1,493 @@ +const std = @import("std"); + +pub const common = @import("../common.zig"); +pub const Region = common.Region; + +const Pulse = @import("pulse.zig"); +const Triangle = @import("triangle.zig"); +const Noise = @import("noise.zig"); +const Dmc = @import("dmc.zig"); +const FrameCounter = @import("frame_counter.zig"); + +pub const Apu = @This(); + +pub const sample_rate: u32 = 44_100; + +region: Region, + +pulse1: Pulse, +pulse2: Pulse, + +triangle: Triangle, +noise: Noise, +dmc: Dmc, + +frame_counter: FrameCounter, + +/// Absolute CPU cycle as seen by the APU. +cpu_cycle: u64 = 0, + +// Downsampling buffer for audio output +sample_buffer: [2048]i16 = [_]i16{0} ** 2048, +sample_count: usize = 0, +sample_accumulator: f32 = 0.0, +sample_cycles: u32 = 0, +prev_input: f32 = 0.0, +prev_output: f32 = 0.0, + +pub fn init(region: Region) Apu { + return .{ + .region = region, + + .pulse1 = Pulse.init(.pulse1), + .pulse2 = Pulse.init(.pulse2), + + .triangle = Triangle.init(), + + .noise = Noise.init( + switch (region) { + .ntsc => .ntsc, + .pal => .pal, + }, + ), + + .dmc = Dmc.init( + switch (region) { + .ntsc => .ntsc, + .pal => .pal, + }, + ), + + .frame_counter = FrameCounter.init( + switch (region) { + .ntsc => .ntsc, + .pal => .pal, + }, + ), + }; +} + +pub fn reset(self: *Apu) void { + // ponytail: reset silences all channels and clears APU state + self.writeStatus(0); + self.frame_counter.reset(); + self.noise.reset(); + self.dmc.writeDirectLoad(0); + self.cpu_cycle = 0; + self.sample_count = 0; + self.sample_accumulator = 0.0; + self.sample_cycles = 0; + self.prev_input = 0.0; + self.prev_output = 0.0; +} + +// CPU register interface + +pub fn write( + self: *Apu, + address: u16, + value: u8, +) void { + switch (address) { + // Pulse 1 + + 0x4000 => self.pulse1.writeControl(value), + 0x4001 => self.pulse1.writeSweep(value), + 0x4002 => self.pulse1.writeTimerLow(value), + 0x4003 => self.pulse1.writeTimerHigh(value), + + // Pulse 2 + + 0x4004 => self.pulse2.writeControl(value), + 0x4005 => self.pulse2.writeSweep(value), + 0x4006 => self.pulse2.writeTimerLow(value), + 0x4007 => self.pulse2.writeTimerHigh(value), + + // Triangle + + 0x4008 => self.triangle.writeLinear(value), + + // $4009 unused + 0x4009 => {}, + + 0x400A => self.triangle.writeTimerLow(value), + 0x400B => self.triangle.writeTimerHigh(value), + + // Noise + + 0x400C => self.noise.writeControl(value), + + // $400D unused + 0x400D => {}, + + 0x400E => self.noise.writePeriod(value), + 0x400F => self.noise.writeLength(value), + + // DMC + + 0x4010 => self.dmc.writeControl(value), + 0x4011 => self.dmc.writeDirectLoad(value), + 0x4012 => self.dmc.writeSampleAddress(value), + 0x4013 => self.dmc.writeSampleLength(value), + + // $4014 is OAM DMA. + // + // That belongs to the CPU/PPU DMA controller, NOT the APU. + + 0x4014 => {}, + + // Status + + 0x4015 => self.writeStatus(value), + + // $4016 joypad + + 0x4016 => {}, + + // Frame counter + + 0x4017 => self.frame_counter.write( + value, + self.cpu_cycle, + ), + + else => {}, + } +} + +/// Only $4015 is actually an APU-readable register. +/// +/// null means: +/// +/// "APU does not drive the CPU data bus here." +/// +/// This lets your Bus preserve open-bus behavior instead of the +/// APU incorrectly returning 0. +pub fn read(self: *Apu, address: u16) ?u8 { + return self.readWithOpenBus(address, 0); +} + +pub fn readWithOpenBus(self: *Apu, address: u16, open_bus: u8) ?u8 { + return switch (address) { + 0x4015 => self.readStatus(open_bus), + else => null, + }; +} + +// $4015 + +fn writeStatus(self: *Apu, value: u8) void { + self.pulse1.setEnabled((value & 0x01) != 0); + self.pulse2.setEnabled((value & 0x02) != 0); + + self.triangle.setEnabled((value & 0x04) != 0); + self.noise.setEnabled((value & 0x08) != 0); + + self.dmc.writeEnabled((value & 0x10) != 0); +} + +fn readStatus(self: *Apu, open_bus: u8) u8 { + // Bit 5 is unmapped on the APU and returns open bus from the data bus. + var result: u8 = open_bus & 0x20; + + // Pulse 1 length counter. + if (self.pulse1.active()) + result |= 0x01; + + // Pulse 2 length counter. + if (self.pulse2.active()) + result |= 0x02; + + // Triangle length counter. + if (self.triangle.active()) + result |= 0x04; + + // Noise length counter. + if (self.noise.active()) + result |= 0x08; + + // DMC bytes remaining. + if (self.dmc.active()) + result |= 0x10; + + // Frame IRQ. + if (self.frame_counter.irqAsserted()) + result |= 0x40; + + // DMC IRQ. + if (self.dmc.irqAsserted()) + result |= 0x80; + + // + // Reading $4015 acknowledges ONLY frame IRQ. + // + // DMC IRQ remains asserted. + // + self.frame_counter.clearIrq(); + + return result; +} + +// IRQ + +/// APU's contribution to the CPU IRQ line. +/// +/// Your NES/CPU layer should OR this with mapper IRQs. +pub fn irqAsserted(self: *const Apu) bool { + return self.frame_counter.irqAsserted() or + self.dmc.irqAsserted(); +} + +// Main CPU clock + +/// Clock once for EVERY CPU cycle. +/// +/// This should be your only ordinary clock entrypoint into the APU. +pub fn clockCpu(self: *Apu) void { + // Triangle + // + // Timer runs every CPU cycle. + + self.triangle.clockTimer(); + + // Noise + // + // Our Noise implementation uses the NESdev table expressed + // directly in CPU cycles. + + self.noise.clockCpu(); + + // DMC + // + // Our DMC rate table is also expressed directly in CPU cycles. + + self.dmc.clockTimer(); + + // Pulse timers + // + // Pulse timer period is expressed in APU cycles. + // + // One APU cycle = two CPU cycles. + + if ((self.cpu_cycle & 1) == 0) { + self.pulse1.clockTimer(); + self.pulse2.clockTimer(); + } + + // Frame sequencer + + const events = self.frame_counter.clockCpu(); + + if (events.quarter) { + self.clockQuarterFrame(); + } + + if (events.half) { + self.clockHalfFrame(); + } + + // ponytail: audio downsampler (~40.58 CPU cycles per 44.1kHz sample) with 1st-order DC blocker + self.sample_accumulator += self.output(); + self.sample_cycles += 1; + if (self.sample_cycles >= 40) { + if (self.sample_count < self.sample_buffer.len) { + const avg = self.sample_accumulator / @as(f32, @floatFromInt(self.sample_cycles)); + // 1st-order IIR DC blocker filter: y[n] = x[n] - x[n-1] + 0.995 * y[n-1] + const filtered = avg - self.prev_input + (0.995 * self.prev_output); + self.prev_input = avg; + self.prev_output = filtered; + + const sample_f = filtered * 32000.0 * 2.5; + self.sample_buffer[self.sample_count] = @intFromFloat(std.math.clamp(sample_f, -32767.0, 32767.0)); + self.sample_count += 1; + } + self.sample_accumulator = 0.0; + self.sample_cycles = 0; + } + + self.cpu_cycle +%= 1; +} + +pub fn getSamples(self: *const Apu) []const i16 { + return self.sample_buffer[0..self.sample_count]; +} + +pub fn clearSamples(self: *Apu) void { + self.sample_count = 0; +} + +// Quarter-frame + +fn clockQuarterFrame(self: *Apu) void { + // + // Pulse envelopes + // + self.pulse1.clockQuarterFrame(); + self.pulse2.clockQuarterFrame(); + + // + // Triangle linear counter + // + self.triangle.clockQuarterFrame(); + + // + // Noise envelope + // + self.noise.clockQuarterFrame(); + + // + // DMC has no frame-sequencer-controlled unit. + // +} + +// Half-frame + +fn clockHalfFrame(self: *Apu) void { + // + // Pulse: + // + // - sweep + // - length + // + self.pulse1.clockHalfFrame(); + self.pulse2.clockHalfFrame(); + + // + // Triangle length + // + self.triangle.clockHalfFrame(); + + // + // Noise length + // + self.noise.clockHalfFrame(); + + // + // DMC again has no half-frame unit. + // +} + +// DMC DMA + +/// Does the DMC memory reader currently need another byte? +pub fn needsDmcDma(self: *const Apu) bool { + return self.dmc.needsDma(); +} + +/// Begin a DMC DMA operation. +/// +/// Returns the CPU address that should eventually be read. +pub fn beginDmcDma(self: *Apu) ?u16 { + return self.dmc.beginDma(); +} + +/// Deliver the result of the CPU DMA read. +pub fn completeDmcDma( + self: *Apu, + value: u8, +) void { + self.dmc.completeDma(value); +} + +// Individual mixer inputs + +pub fn pulse1Output(self: *const Apu) u4 { + return self.pulse1.output(); +} + +pub fn pulse2Output(self: *const Apu) u4 { + return self.pulse2.output(); +} + +pub fn triangleOutput(self: *const Apu) u4 { + return self.triangle.output(); +} + +pub fn noiseOutput(self: *const Apu) u4 { + return self.noise.output(); +} + +pub fn dmcOutput(self: *const Apu) u7 { + return self.dmc.output(); +} + +// Nonlinear NES mixer + +/// Raw nonlinear APU output. +/// +/// Nominal range is roughly 0...1. +/// +/// This does NOT implement the analog high-pass / low-pass filter +/// chain following the NES DAC. +pub fn output(self: *const Apu) f32 { + const p1: f32 = + @floatFromInt(self.pulse1.output()); + + const p2: f32 = + @floatFromInt(self.pulse2.output()); + + const triangle: f32 = + @floatFromInt(self.triangle.output()); + + const noise: f32 = + @floatFromInt(self.noise.output()); + + const dmc: f32 = + @floatFromInt(self.dmc.output()); + + // Pulse mixer + + const pulse_sum = p1 + p2; + + const pulse_out: f32 = + if (pulse_sum == 0.0) + 0.0 + else + 95.88 / + ((8128.0 / pulse_sum) + 100.0); + + // Triangle / Noise / DMC mixer + + const tnd_input = + (triangle / 8227.0) + + (noise / 12241.0) + + (dmc / 22638.0); + + const tnd_out: f32 = + if (tnd_input == 0.0) + 0.0 + else + 159.79 / + ((1.0 / tnd_input) + 100.0); + + return pulse_out + tnd_out; +} + +// ponytail: consolidated APU root test suite +test "apu initialization, status reads, IRQ clearing, sample generation and DMC DMA" { + var apu = Apu.init(.ntsc); + + // Init, reset & channel status + apu.write(0x4015, 0x0d); + apu.write(0x4003, 0x08); + apu.write(0x400b, 0x08); + apu.write(0x400f, 0x08); + const status = apu.readWithOpenBus(0x4015, 0x20); + try std.testing.expectEqual(@as(u8, 0b0010_1101), status.?); + + apu.reset(); + try std.testing.expect(!apu.pulse1.active() and apu.cpu_cycle == 0); + + // Frame/DMC IRQ clearing + apu.frame_counter.irq_flag = true; + apu.dmc.irq_flag = true; + _ = apu.read(0x4015); + try std.testing.expect(!apu.frame_counter.irqAsserted() and apu.dmc.irqAsserted()); + + // Audio sampling & DMC DMA + apu.write(0x4015, 0x11); + for (0..200) |_| apu.clockCpu(); + try std.testing.expect(apu.getSamples().len > 0); + try std.testing.expect(apu.needsDmcDma()); + try std.testing.expectEqual(@as(?u16, 0xc000), apu.beginDmcDma()); +} diff --git a/src/system/nes/apu/sweep.zig b/src/system/nes/apu/sweep.zig new file mode 100644 index 0000000..e60d52a --- /dev/null +++ b/src/system/nes/apu/sweep.zig @@ -0,0 +1,171 @@ +const std = @import("std"); + +const Sweep = @This(); + +pub const Channel = enum { + pulse1, + pulse2, +}; + +channel: Channel, + +enabled: bool = false, + +/// Raw P value from EPPP.NSSS. +/// +/// Hardware clocks the sweep every P + 1 half-frame clocks because +/// the divider counts: +/// +/// P, P-1, ..., 0 +/// +period: u3 = 0, + +negate: bool = false, +shift: u3 = 0, + +/// Current divider counter. +divider: u3 = 0, + +/// Set whenever $4001/$4005 is written. +reload: bool = false, + +pub fn init(channel: Channel) Sweep { + return .{ + .channel = channel, + }; +} + +/// Write $4001 or $4005: +/// +/// EPPP.NSSS +/// ││││ │└┴┴─ shift +/// ││││ └──── negate +/// │└┴┴────── divider period +/// └───────── enabled +pub fn write(self: *Sweep, value: u8) void { + self.enabled = (value & 0x80) != 0; + self.period = @truncate((value >> 4) & 0x07); + self.negate = (value & 0x08) != 0; + self.shift = @truncate(value & 0x07); + + self.reload = true; +} + +/// Calculate the continuously-evaluated sweep target period. +/// +/// timer_period is the pulse channel's current 11-bit timer period. +/// +/// This returns u16 rather than an 11-bit type intentionally: +/// values > 0x7FF must remain visible so the sweep muting logic +/// can detect overflow. +pub fn targetPeriod(self: *const Sweep, timer_period: u16) u16 { + std.debug.assert(timer_period <= 0x07ff); + + const change = timer_period >> @intCast(self.shift); + + if (!self.negate) { + return timer_period + change; + } + + return switch (self.channel) { + // Pulse 1 uses one's-complement negation: + // + // period + ~change + // + // Equivalent mathematically to: + // + // period - change - 1 + // + // Clamp a negative target to zero. + .pulse1 => if (timer_period <= change) + 0 + else + timer_period - change - 1, + + // Pulse 2 uses ordinary two's-complement subtraction: + // + // period - change + .pulse2 => timer_period - change, + }; +} + +/// The sweep can mute a pulse channel even when sweep updating itself +/// is disabled. +/// +/// A pulse is muted when: +/// +/// current period < 8 +/// +/// OR +/// +/// target period > 0x7FF +pub fn isMuted(self: *const Sweep, timer_period: u16) bool { + const target = self.targetPeriod(timer_period); + + return timer_period < 8 or target > 0x07ff; +} + +/// Clocked by the frame sequencer on a half-frame clock. +/// +/// This potentially updates the pulse channel's timer period and +/// advances/reloads the sweep divider. +pub fn clockHalfFrame( + self: *Sweep, + timer_period: *u16, +) void { + std.debug.assert(timer_period.* <= 0x07ff); + + // The target must be evaluated BEFORE potentially updating + // timer_period. + const target = self.targetPeriod(timer_period.*); + const muted = timer_period.* < 8 or target > 0x07ff; + + const divider_zero = self.divider == 0; + + // If the divider emits a clock, apply the sweep. + // + // shift == 0 disables period updates, even if enabled == true. + if (divider_zero and + self.enabled and + self.shift != 0 and + !muted) + { + timer_period.* = target; + } + + // Divider operation happens regardless of whether the timer + // was actually changed. + if (divider_zero or self.reload) { + self.divider = self.period; + self.reload = false; + } else { + self.divider -= 1; + } +} + +// ponytail: consolidated sweep unit test suite +test "sweep unit target calculation, gating and clocking" { + var sweep1 = Sweep.init(.pulse1); + sweep1.write(0b1010_0001); // P=2, shift=1 + try std.testing.expectEqual(@as(u16, 1500), sweep1.targetPeriod(1000)); + + // Pulse 1 ones complement vs Pulse 2 twos complement negation + sweep1.write(0b1000_1010); // negate, shift=2 + try std.testing.expectEqual(@as(u16, 749), sweep1.targetPeriod(1000)); + + var sweep2 = Sweep.init(.pulse2); + sweep2.write(0b1000_1010); + try std.testing.expectEqual(@as(u16, 750), sweep2.targetPeriod(1000)); + + // Muting checks + try std.testing.expect(sweep1.isMuted(7)); + try std.testing.expect(!sweep1.isMuted(8)); + sweep1.write(0b0000_0000); + try std.testing.expect(sweep1.isMuted(0x400)); // overflow mutes + + // Clocking & reload + var timer_period: u16 = 1000; + sweep2.write(0b1010_0001); + sweep2.clockHalfFrame(&timer_period); + try std.testing.expectEqual(@as(u16, 1500), timer_period); +} diff --git a/src/system/nes/apu/triangle.zig b/src/system/nes/apu/triangle.zig new file mode 100644 index 0000000..c2f4a5e --- /dev/null +++ b/src/system/nes/apu/triangle.zig @@ -0,0 +1,672 @@ +const std = @import("std"); + +const LengthCounter = @import("length_counter.zig"); + +pub const Triangle = @This(); +/// 32-step triangle DAC sequence. +/// +/// Unlike pulse, the triangle directly sends one of these +/// 4-bit values to the mixer. +const sequence = [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, +}; + +length_counter: LengthCounter = .{}, + +// $4008 +// CRRR.RRRR + +/// Bit 7 of $4008. +/// +/// This has two functions: +/// +/// - halts the length counter +/// - controls whether linear_counter_reload remains set +control: bool = false, + +/// Bits 6..0 of $4008. +linear_reload_value: u7 = 0, + +/// Current linear counter. +linear_counter: u7 = 0, + +/// Set by every write to $400B. +linear_reload: bool = false, + +// Timer + +/// 11-bit timer period: +/// +/// HHHLLLLLLLL +timer_period: u16 = 0, + +/// Internal timer divider. +/// +/// Triangle timer runs at the CPU clock, unlike pulse timers +/// which run at CPU / 2. +timer_counter: u16 = 0, + +// Sequencer + +/// Current position in the 32-step waveform. +sequence_position: u5 = 0, + +pub fn init() Triangle { + return .{}; +} + +// $4015 + +/// Triangle is bit 2 of $4015. +/// +/// Clearing it immediately clears the length counter. +/// +/// Importantly, this does NOT reset the sequencer or linear counter. +pub fn setEnabled(self: *Triangle, enabled: bool) void { + self.length_counter.setEnabled(enabled); +} + +/// Status returned as bit 2 when reading $4015. +/// +/// $4015 status reports whether the length counter is non-zero. +pub fn active(self: *const Triangle) bool { + return self.length_counter.active(); +} + +// $4008 +// CRRR.RRRR +// +// C = control / length-counter halt +// R = linear-counter reload value + +pub fn writeLinear(self: *Triangle, value: u8) void { + self.control = (value & 0x80) != 0; + self.linear_reload_value = @truncate(value & 0x7f); + + // + // IMPORTANT: + // + // Writing $4008 itself does NOT set linear_reload. + // + // $400B does that. + // +} + +// $400A +// LLLL.LLLL + +pub fn writeTimerLow(self: *Triangle, value: u8) void { + self.timer_period = + (self.timer_period & 0x0700) | + @as(u16, value); +} + +// $400B +// LLLL.LHHH +// +// bits 7..3 = length counter index +// bits 2..0 = high timer bits +// +// Side effect: +// set linear-counter reload flag + +pub fn writeTimerHigh(self: *Triangle, value: u8) void { + self.timer_period = + (self.timer_period & 0x00ff) | + (@as(u16, value & 0x07) << 8); + + const length_index: u5 = @truncate(value >> 3); + self.length_counter.load(length_index); + + // + // Every $400B write sets the reload flag. + // + self.linear_reload = true; + + // + // Unlike pulse $4003/$4007: + // + // DO NOT reset sequence_position. + // + // DO NOT reset timer_counter. + // +} + +// $4009 + +/// $4009 is unused. +pub fn writeUnused(_: *Triangle, _: u8) void {} + +// Linear counter + +/// Clocked on every quarter-frame. +/// +/// NES behavior, in this exact order: +/// +/// 1. If reload flag set: +/// counter = reload value +/// +/// Otherwise, if counter > 0: +/// counter-- +/// +/// 2. If control flag clear: +/// reload flag = false +/// +pub fn clockQuarterFrame(self: *Triangle) void { + if (self.linear_reload) { + self.linear_counter = self.linear_reload_value; + } else if (self.linear_counter != 0) { + self.linear_counter -= 1; + } + + if (!self.control) { + self.linear_reload = false; + } +} + +// Length counter + +/// Clocked on every half-frame. +pub fn clockHalfFrame(self: *Triangle) void { + // + // $4008 bit 7 is also the length-counter halt flag. + // + self.length_counter.clock(self.control); +} + +// Timer / waveform sequencer + +/// Clock EVERY CPU cycle. +/// +/// This differs from Pulse: +/// +/// Pulse timer -> CPU / 2 +/// Triangle timer -> CPU +/// +pub fn clockTimer(self: *Triangle) void { + if (self.timer_counter == 0) { + self.timer_counter = self.timer_period; + + // + // The waveform advances only when BOTH counters are non-zero. + // + if (self.linear_counter != 0 and + self.length_counter.active()) + { + self.sequence_position +%= 1; + } + } else { + self.timer_counter -= 1; + } +} + +// Output + +/// Current 4-bit DAC output. +/// +/// IMPORTANT: +/// +/// Do NOT do this: +/// +/// if (linear_counter == 0) return 0; +/// +/// and do NOT do: +/// +/// if (length_counter == 0) return 0; +/// +/// Those counters stop the waveform sequencer. They do not force +/// the triangle DAC to zero. +/// +/// When halted, the channel retains its current sequence value. +pub fn output(self: *const Triangle) u4 { + return sequence[@intCast(self.sequence_position)]; +} + +pub fn sequencerRunning(self: *const Triangle) bool { + return self.linear_counter != 0 and + self.length_counter.active(); +} + +// Inspection helpers + +pub fn timerPeriod(self: *const Triangle) u16 { + return self.timer_period; +} + +pub fn length(self: *const Triangle) u8 { + return self.length_counter.value; +} + +pub fn linear(self: *const Triangle) u7 { + return self.linear_counter; +} + +pub fn sequencePosition(self: *const Triangle) u5 { + return self.sequence_position; +} + +// Tests + +test "$4008 sets control and reload value" { + var triangle = Triangle.init(); + + triangle.writeLinear(0b1010_1010); + + try std.testing.expect(triangle.control); + + try std.testing.expectEqual( + @as(u7, 0x2a), + triangle.linear_reload_value, + ); +} + +test "$4008 does not set reload flag" { + var triangle = Triangle.init(); + + triangle.writeLinear(0xff); + + try std.testing.expect(!triangle.linear_reload); +} + +test "$400A sets timer low bits" { + var triangle = Triangle.init(); + + triangle.timer_period = 0x500; + + triangle.writeTimerLow(0xab); + + try std.testing.expectEqual( + @as(u16, 0x5ab), + triangle.timer_period, + ); +} + +test "$400B sets timer high bits" { + var triangle = Triangle.init(); + + triangle.timer_period = 0x0ab; + + triangle.writeTimerHigh(0x05); + + try std.testing.expectEqual( + @as(u16, 0x5ab), + triangle.timer_period, + ); +} + +test "$400B sets linear reload flag" { + var triangle = Triangle.init(); + + try std.testing.expect(!triangle.linear_reload); + + triangle.writeTimerHigh(0); + + try std.testing.expect(triangle.linear_reload); +} + +test "$400B does not reset sequencer position" { + var triangle = Triangle.init(); + + triangle.sequence_position = 17; + + triangle.writeTimerHigh(0); + + try std.testing.expectEqual( + @as(u5, 17), + triangle.sequence_position, + ); +} + +test "$400B does not reset timer divider" { + var triangle = Triangle.init(); + + triangle.timer_counter = 123; + + triangle.writeTimerHigh(0x05); + + try std.testing.expectEqual( + @as(u16, 123), + triangle.timer_counter, + ); +} + +test "$400B loads length counter when enabled" { + var triangle = Triangle.init(); + + triangle.setEnabled(true); + + // Length table index 0 = 10. + triangle.writeTimerHigh(0); + + try std.testing.expectEqual( + @as(u8, 10), + triangle.length_counter.value, + ); +} + +test "$400B cannot load length while disabled" { + var triangle = Triangle.init(); + + triangle.setEnabled(false); + + triangle.writeTimerHigh(0); + + try std.testing.expectEqual( + @as(u8, 0), + triangle.length_counter.value, + ); +} + +test "$4015 disable clears length counter" { + var triangle = Triangle.init(); + + triangle.setEnabled(true); + triangle.writeTimerHigh(0); + + try std.testing.expectEqual( + @as(u8, 10), + triangle.length_counter.value, + ); + + triangle.setEnabled(false); + + try std.testing.expectEqual( + @as(u8, 0), + triangle.length_counter.value, + ); +} + +test "linear counter reloads when reload flag set" { + var triangle = Triangle.init(); + + triangle.linear_reload_value = 42; + triangle.linear_reload = true; + + triangle.clockQuarterFrame(); + + try std.testing.expectEqual( + @as(u7, 42), + triangle.linear_counter, + ); +} + +test "linear counter decrements without reload" { + var triangle = Triangle.init(); + + triangle.linear_counter = 10; + triangle.linear_reload = false; + + triangle.clockQuarterFrame(); + + try std.testing.expectEqual( + @as(u7, 9), + triangle.linear_counter, + ); +} + +test "linear counter stops at zero" { + var triangle = Triangle.init(); + + triangle.linear_counter = 0; + + triangle.clockQuarterFrame(); + + try std.testing.expectEqual( + @as(u7, 0), + triangle.linear_counter, + ); +} + +test "control clear clears reload flag" { + var triangle = Triangle.init(); + + triangle.control = false; + triangle.linear_reload = true; + triangle.linear_reload_value = 10; + + triangle.clockQuarterFrame(); + + try std.testing.expectEqual( + @as(u7, 10), + triangle.linear_counter, + ); + + try std.testing.expect(!triangle.linear_reload); +} + +test "control set preserves reload flag" { + var triangle = Triangle.init(); + + triangle.control = true; + triangle.linear_reload = true; + triangle.linear_reload_value = 10; + + triangle.clockQuarterFrame(); + + try std.testing.expectEqual( + @as(u7, 10), + triangle.linear_counter, + ); + + try std.testing.expect(triangle.linear_reload); + + // + // Because reload remains set, every quarter frame reloads + // the linear counter back to 10. + // + triangle.linear_counter = 3; + + triangle.clockQuarterFrame(); + + try std.testing.expectEqual( + @as(u7, 10), + triangle.linear_counter, + ); +} + +test "length counter decrements when control clear" { + var triangle = Triangle.init(); + + triangle.setEnabled(true); + triangle.writeTimerHigh(0); + + try std.testing.expectEqual( + @as(u8, 10), + triangle.length_counter.value, + ); + + triangle.control = false; + triangle.clockHalfFrame(); + + try std.testing.expectEqual( + @as(u8, 9), + triangle.length_counter.value, + ); +} + +test "control flag halts length counter" { + var triangle = Triangle.init(); + + triangle.setEnabled(true); + triangle.writeTimerHigh(0); + + triangle.control = true; + + triangle.clockHalfFrame(); + + try std.testing.expectEqual( + @as(u8, 10), + triangle.length_counter.value, + ); +} + +test "timer runs for period plus one CPU clocks" { + var triangle = Triangle.init(); + + triangle.timer_period = 2; + triangle.timer_counter = 2; + + triangle.linear_counter = 1; + triangle.length_counter.value = 1; + + triangle.sequence_position = 0; + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u16, 1), + triangle.timer_counter, + ); + + try std.testing.expectEqual( + @as(u5, 0), + triangle.sequence_position, + ); + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u16, 0), + triangle.timer_counter, + ); + + try std.testing.expectEqual( + @as(u5, 0), + triangle.sequence_position, + ); + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u16, 2), + triangle.timer_counter, + ); + + try std.testing.expectEqual( + @as(u5, 1), + triangle.sequence_position, + ); +} + +test "sequencer does not advance when linear counter is zero" { + var triangle = Triangle.init(); + + triangle.timer_counter = 0; + triangle.timer_period = 10; + + triangle.length_counter.value = 1; + triangle.linear_counter = 0; + + triangle.sequence_position = 5; + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u5, 5), + triangle.sequence_position, + ); +} + +test "sequencer does not advance when length counter is zero" { + var triangle = Triangle.init(); + + triangle.timer_counter = 0; + triangle.timer_period = 10; + + triangle.length_counter.value = 0; + triangle.linear_counter = 1; + + triangle.sequence_position = 5; + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u5, 5), + triangle.sequence_position, + ); +} + +test "sequencer advances when both counters are nonzero" { + var triangle = Triangle.init(); + + triangle.timer_counter = 0; + triangle.timer_period = 10; + + triangle.length_counter.value = 1; + triangle.linear_counter = 1; + + triangle.sequence_position = 5; + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u5, 6), + triangle.sequence_position, + ); +} + +test "sequencer wraps after 31" { + var triangle = Triangle.init(); + + triangle.timer_counter = 0; + + triangle.length_counter.value = 1; + triangle.linear_counter = 1; + + triangle.sequence_position = 31; + + triangle.clockTimer(); + + try std.testing.expectEqual( + @as(u5, 0), + triangle.sequence_position, + ); +} + +test "triangle waveform values" { + var triangle = Triangle.init(); + + triangle.sequence_position = 0; + try std.testing.expectEqual(@as(u4, 15), triangle.output()); + + triangle.sequence_position = 15; + try std.testing.expectEqual(@as(u4, 0), triangle.output()); + + triangle.sequence_position = 16; + try std.testing.expectEqual(@as(u4, 0), triangle.output()); + + triangle.sequence_position = 31; + try std.testing.expectEqual(@as(u4, 15), triangle.output()); +} + +test "halted triangle retains current DAC output" { + var triangle = Triangle.init(); + + triangle.sequence_position = 7; + + // + // Sequence position 7 = DAC value 8. + // + try std.testing.expectEqual( + @as(u4, 8), + triangle.output(), + ); + + // + // Stop both gating counters. + // + triangle.linear_counter = 0; + triangle.length_counter.value = 0; + + // + // The DAC is NOT forced to zero. + // + try std.testing.expectEqual( + @as(u4, 8), + triangle.output(), + ); +} diff --git a/src/system/nes/bus.zig b/src/system/nes/bus.zig new file mode 100644 index 0000000..12efdae --- /dev/null +++ b/src/system/nes/bus.zig @@ -0,0 +1,195 @@ +const std = @import("std"); +const common = @import("common.zig"); +const Region = common.Region; +const Mirroring = common.Mirroring; +const Ppu = @import("ppu/root.zig").Ppu; +const Apu = @import("apu/root.zig").Apu; +const Controller = @import("controller.zig").Controller; +const Cartridge = @import("cartridge.zig").Cartridge; + +pub const Bus = @This(); + +ram: [0x800]u8 = [_]u8{0} ** 0x800, +ciram: [0x800]u8 = [_]u8{0} ** 0x800, + +ppu: Ppu, +apu: Apu, +controllers: [2]Controller = .{ Controller.init(), Controller.init() }, +cartridge: ?Cartridge = null, + +open_bus: u8 = 0, + +// OAM DMA state machine +dma_page: u8 = 0, +dma_addr: u8 = 0, +dma_active: bool = false, +dma_dummy: bool = true, + +pub fn init(region: Region, cart: ?Cartridge) Bus { + return .{ + .ppu = Ppu.init(region), + .apu = Apu.init(region), + .cartridge = cart, + }; +} + +pub fn reset(self: *Bus) void { + self.ppu.reset(); + self.apu.reset(); + self.controllers[0].reset(); + self.controllers[1].reset(); + self.dma_active = false; + self.dma_dummy = true; + self.dma_addr = 0; +} + +pub fn mapNametableAddress(mirroring_mode: Mirroring, address: u16) u11 { + const v = (address - 0x2000) & 0x0fff; + return switch (mirroring_mode) { + .horizontal => @truncate(((v >> 1) & 0x0400) | (v & 0x03ff)), + .vertical => @truncate(v & 0x07ff), + .single_screen_lower => @truncate(v & 0x03ff), + .single_screen_upper => @truncate(0x0400 | (v & 0x03ff)), + .four_screen => @truncate(v & 0x07ff), + }; +} + +pub const PpuBus = struct { + bus: *Bus, + + pub fn read(self: *PpuBus, address: u16) u8 { + if (address < 0x2000) { + if (self.bus.cartridge) |*c| { + return c.ppuRead(address) orelse 0; + } + return 0; + } else if (address < 0x3f00) { + const mir = if (self.bus.cartridge) |*c| c.mirroring() else .horizontal; + const ciram_idx = mapNametableAddress(mir, address); + return self.bus.ciram[ciram_idx]; + } else { + return self.bus.ppu.palette.read(address); + } + } + + pub fn write(self: *PpuBus, address: u16, value: u8) void { + if (address < 0x2000) { + if (self.bus.cartridge) |*c| { + _ = c.ppuWrite(address, value); + } + } else if (address < 0x3f00) { + const mir = if (self.bus.cartridge) |*c| c.mirroring() else .horizontal; + const ciram_idx = mapNametableAddress(mir, address); + self.bus.ciram[ciram_idx] = value; + } else { + self.bus.ppu.palette.write(address, value); + } + } +}; + +pub inline fn getPpuBus(self: *Bus) PpuBus { + return PpuBus{ .bus = self }; +} + +pub fn read(self: *Bus, address: u16) u8 { + const val: u8 = switch (address) { + 0x0000...0x1fff => self.ram[address & 0x07ff], + 0x2000...0x3fff => blk: { + var ppu_bus = self.getPpuBus(); + break :blk self.ppu.cpuRead(&ppu_bus, address) orelse self.open_bus; + }, + 0x4000...0x4014 => self.open_bus, + 0x4015 => self.apu.readWithOpenBus(address, self.open_bus) orelse self.open_bus, + 0x4016 => self.controllers[0].read(), + 0x4017 => self.controllers[1].read(), + 0x4018...0x401f => self.open_bus, + 0x4020...0xffff => blk: { + if (self.cartridge) |*c| { + break :blk c.cpuRead(address) orelse self.open_bus; + } + break :blk self.open_bus; + }, + }; + self.open_bus = val; + return val; +} + +pub fn write(self: *Bus, address: u16, value: u8) void { + self.open_bus = value; + switch (address) { + 0x0000...0x1fff => self.ram[address & 0x07ff] = value, + 0x2000...0x3fff => { + var ppu_bus = self.getPpuBus(); + _ = self.ppu.cpuWrite(&ppu_bus, address, value); + }, + 0x4000...0x4013, 0x4015, 0x4017 => self.apu.write(address, value), + 0x4014 => { + self.dma_page = value; + self.dma_addr = 0; + self.dma_active = true; + self.dma_dummy = true; + }, + 0x4016 => { + self.controllers[0].write(value); + self.controllers[1].write(value); + }, + 0x4018...0x401f => {}, + 0x4020...0xffff => { + if (self.cartridge) |*c| { + _ = c.cpuWrite(address, value); + } + }, + } +} + +/// Clocks one CPU cycle: steps APU and 3 PPU dots. +pub fn tick(self: *Bus) void { + self.apu.clockCpu(); + self.ppu.registers.tickCpuCycle(); + + var ppu_bus = self.getPpuBus(); + self.ppu.clock(&ppu_bus); + self.ppu.clock(&ppu_bus); + self.ppu.clock(&ppu_bus); +} + +/// Executes one DMA CPU cycle step. +pub fn stepDma(self: *Bus) void { + if (self.dma_dummy) { + self.dma_dummy = false; + self.tick(); + return; + } + + const cpu_addr: u16 = (@as(u16, self.dma_page) << 8) | self.dma_addr; + const data = self.read(cpu_addr); + self.ppu.registers.oam[self.ppu.registers.oam_addr] = data; + self.ppu.registers.oam_addr +%= 1; + self.dma_addr +%= 1; + + if (self.dma_addr == 0) { + self.dma_active = false; + } + self.tick(); +} + +pub fn nmiLine(self: *const Bus) bool { + return self.ppu.nmiLine(); +} + +pub fn irqLine(self: *const Bus) bool { + const apu_irq = self.apu.irqAsserted(); + const cart_irq = if (self.cartridge) |*c| c.irqLine() else false; + return apu_irq or cart_irq; +} + +// ponytail: consolidated bus memory mirroring test suite +test "bus internal RAM and CIRAM nametable mirroring" { + var bus = Bus.init(.ntsc, null); + bus.write(0x0005, 0x99); + try std.testing.expectEqual(@as(u8, 0x99), bus.read(0x0805)); + + var ppu_bus = bus.getPpuBus(); + ppu_bus.write(0x2000, 0xaa); + try std.testing.expectEqual(@as(u8, 0xaa), ppu_bus.read(0x2400)); +} diff --git a/src/system/nes/cartridge.zig b/src/system/nes/cartridge.zig new file mode 100644 index 0000000..93632f3 --- /dev/null +++ b/src/system/nes/cartridge.zig @@ -0,0 +1,137 @@ +const std = @import("std"); +const common = @import("common.zig"); +const Mirroring = common.Mirroring; +const mapper_mod = @import("mapper/root.zig"); +const Mapper = mapper_mod.Mapper; + +pub const Error = error{ + InvalidHeader, + TruncatedRom, + UnsupportedMapper, + InvalidPrgSize, +}; + +pub const Cartridge = @This(); + +prg_rom: []const u8, +chr_rom: []const u8, + +chr_ram: [32 * 1024]u8 = [_]u8{0} ** (32 * 1024), +chr_is_ram: bool = false, + +prg_ram: [0x2000]u8 = [_]u8{0} ** 0x2000, +has_battery: bool = false, + +mapper: Mapper, + +pub fn init(rom_bytes: []const u8) Error!Cartridge { + if (rom_bytes.len < 16) return error.TruncatedRom; + + if (!std.mem.eql(u8, rom_bytes[0..4], "NES\x1a")) { + return error.InvalidHeader; + } + + const prg_banks = rom_bytes[4]; + const chr_banks = rom_bytes[5]; + const flags6 = rom_bytes[6]; + const flags7 = rom_bytes[7]; + + const prg_size: usize = @as(usize, prg_banks) * 0x4000; + const chr_size: usize = @as(usize, chr_banks) * 0x2000; + + const has_trainer = (flags6 & 0x04) != 0; + const header_offset: usize = if (has_trainer) 16 + 512 else 16; + + if (rom_bytes.len < header_offset + prg_size) { + return error.TruncatedRom; + } + + const prg_rom = rom_bytes[header_offset .. header_offset + prg_size]; + + var chr_rom: []const u8 = &.{}; + var chr_is_ram = false; + + if (chr_banks == 0) { + chr_is_ram = true; + } else { + if (rom_bytes.len < header_offset + prg_size + chr_size) { + return error.TruncatedRom; + } + chr_rom = rom_bytes[header_offset + prg_size .. header_offset + prg_size + chr_size]; + } + + const mapper_id: u8 = (flags7 & 0xf0) | (flags6 >> 4); + const has_battery = (flags6 & 0x02) != 0; + + var initial_mirroring: Mirroring = if ((flags6 & 0x01) == 0) .horizontal else .vertical; + if ((flags6 & 0x08) != 0) { + initial_mirroring = .four_screen; + } + + const mapper: Mapper = switch (mapper_id) { + 0 => .{ .nrom = mapper_mod.Nrom.init(initial_mirroring) }, + 1 => .{ .mmc1 = mapper_mod.Mmc1.init() }, + 2 => .{ .uxrom = mapper_mod.Uxrom.init(initial_mirroring) }, + 3 => .{ .cnrom = mapper_mod.Cnrom.init(initial_mirroring) }, + 4 => .{ .mmc3 = mapper_mod.Mmc3.init(initial_mirroring) }, + 7 => .{ .axrom = mapper_mod.Axrom.init() }, + 9 => .{ .mmc2 = mapper_mod.Mmc2.init() }, + 10 => .{ .mmc4 = mapper_mod.Mmc4.init() }, + 11 => .{ .color_dreams = mapper_mod.ColorDreams.init(initial_mirroring) }, + 28 => .{ .action53 = mapper_mod.Action53.init(initial_mirroring) }, + 30 => .{ .unrom512 = mapper_mod.Unrom512.init(initial_mirroring) }, + 34 => .{ .bnrom = mapper_mod.Bnrom.init(initial_mirroring) }, + 66 => .{ .gxrom = mapper_mod.Gxrom.init(initial_mirroring) }, + 71 => .{ .camerica = mapper_mod.Camerica.init(initial_mirroring) }, + 180 => .{ .mapper180 = mapper_mod.Mapper180.init(initial_mirroring) }, + else => return error.UnsupportedMapper, + }; + + return Cartridge{ + .prg_rom = prg_rom, + .chr_rom = chr_rom, + .chr_is_ram = chr_is_ram, + .has_battery = has_battery, + .mapper = mapper, + }; +} + +pub fn cpuRead(self: *const Cartridge, address: u16) ?u8 { + return self.mapper.cpuRead(address, self.prg_rom, &self.prg_ram); +} + +pub fn cpuWrite(self: *Cartridge, address: u16, value: u8) bool { + return self.mapper.cpuWrite(address, value, &self.prg_ram); +} + +pub fn ppuRead(self: *Cartridge, address: u16) ?u8 { + self.mapper.notifyPpuAddress(address); + return self.mapper.ppuRead(address, self.chr_rom, &self.chr_ram, self.chr_is_ram); +} + +pub fn ppuWrite(self: *Cartridge, address: u16, value: u8) bool { + self.mapper.notifyPpuAddress(address); + return self.mapper.ppuWrite(address, value, &self.chr_ram, self.chr_is_ram); +} + +pub fn mirroring(self: *const Cartridge) Mirroring { + return self.mapper.mirroring(); +} + +pub fn irqLine(self: *const Cartridge) bool { + return self.mapper.irqLine(); +} + +test "cartridge ines parsing" { + var rom: [16 + 0x4000 + 0x2000]u8 = undefined; + @memset(&rom, 0); + @memcpy(rom[0..4], "NES\x1a"); + rom[4] = 1; // 1x 16KB PRG + rom[5] = 1; // 1x 8KB CHR + rom[6] = 0x01; // Vertical mirroring, Mapper 0 + + var cart = try Cartridge.init(&rom); + try std.testing.expectEqual(@as(usize, 0x4000), cart.prg_rom.len); + try std.testing.expectEqual(@as(usize, 0x2000), cart.chr_rom.len); + try std.testing.expectEqual(Mirroring.vertical, cart.mirroring()); +} diff --git a/src/system/nes/common.zig b/src/system/nes/common.zig new file mode 100644 index 0000000..f3f5c18 --- /dev/null +++ b/src/system/nes/common.zig @@ -0,0 +1,84 @@ +const std = @import("std"); + +pub const screen_width: u16 = 256; +pub const screen_height: u16 = 240; + +pub const Region = enum { + ntsc, + pal, + + /// Native CPU clock frequency in Hz. + pub fn cpuClockRate(self: Region) u32 { + return switch (self) { + .ntsc => 1_789_773, + .pal => 1_662_607, + }; + } + + /// Total scanlines per frame (262 for NTSC, 312 for PAL). + pub fn totalScanlines(self: Region) u16 { + return switch (self) { + .ntsc => 262, + .pal => 312, + }; + } + + /// Scanline index of the pre-render scanline (261 for NTSC, 311 for PAL). + pub fn preRenderScanline(self: Region) u16 { + return switch (self) { + .ntsc => 261, + .pal => 311, + }; + } + + /// Number of VBlank scanlines. + pub fn vblankScanlines(self: Region) u16 { + return switch (self) { + .ntsc => 20, + .pal => 70, + }; + } + + /// CPU cycles to suppress PPU register writes after power/reset (~1 frame). + pub fn powerUpSuppressionCycles(self: Region) u32 { + return switch (self) { + .ntsc => 29_658, + .pal => 33_132, + }; + } +}; + +/// Nametable mirroring mode used by PPU CIRAM and Cartridge mappers. +pub const Mirroring = enum { + horizontal, + vertical, + single_screen_lower, + single_screen_upper, + four_screen, +}; + +/// Standard 8-button NES controller layout. +pub const Button = enum(u8) { + a = 1 << 0, + b = 1 << 1, + select = 1 << 2, + start = 1 << 3, + up = 1 << 4, + down = 1 << 5, + left = 1 << 6, + right = 1 << 7, +}; + +test "region properties" { + const ntsc: Region = .ntsc; + try std.testing.expectEqual(@as(u32, 1_789_773), ntsc.cpuClockRate()); + try std.testing.expectEqual(@as(u16, 262), ntsc.totalScanlines()); + try std.testing.expectEqual(@as(u16, 261), ntsc.preRenderScanline()); + try std.testing.expectEqual(@as(u32, 29_658), ntsc.powerUpSuppressionCycles()); + + const pal: Region = .pal; + try std.testing.expectEqual(@as(u32, 1_662_607), pal.cpuClockRate()); + try std.testing.expectEqual(@as(u16, 312), pal.totalScanlines()); + try std.testing.expectEqual(@as(u16, 311), pal.preRenderScanline()); + try std.testing.expectEqual(@as(u32, 33_132), pal.powerUpSuppressionCycles()); +} diff --git a/src/system/nes/controller.zig b/src/system/nes/controller.zig new file mode 100644 index 0000000..8dff151 --- /dev/null +++ b/src/system/nes/controller.zig @@ -0,0 +1,60 @@ +const std = @import("std"); +const common = @import("common.zig"); + +pub const Controller = @This(); + +buttons: u8 = 0, +shift_register: u8 = 0, +strobe: bool = false, + +pub fn init() Controller { + return .{}; +} + +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 (self.strobe and !new_strobe) { + // Latches controller state on falling edge + self.shift_register = self.buttons; + } + self.strobe = new_strobe; + if (new_strobe) { + self.shift_register = self.buttons; + } +} + +pub fn read(self: *Controller) u8 { + if (self.strobe) { + return (self.buttons & 1) | 0x40; + } + const val = self.shift_register & 1; + // Real controllers shift in 1s once all 8 bits are read + self.shift_register = (self.shift_register >> 1) | 0x80; + return val | 0x40; +} + +// ponytail: consolidated controller test suite +test "controller strobe and serial shift read" { + var c = Controller.init(); + c.setButtons(0x09); // A and Start pressed + c.write(1); + c.write(0); + const expected = [_]u8{ 0x41, 0x40, 0x40, 0x41, 0x40, 0x40, 0x40, 0x40 }; + for (expected) |exp| try std.testing.expectEqual(exp, c.read()); + + // 9th read onwards should return 1s (0x41) due to pull-up + try std.testing.expectEqual(@as(u8, 0x41), c.read()); + try std.testing.expectEqual(@as(u8, 0x41), c.read()); +} diff --git a/src/system/nes/mapper/action53.zig b/src/system/nes/mapper/action53.zig new file mode 100644 index 0000000..fcecd73 --- /dev/null +++ b/src/system/nes/mapper/action53.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 28 (Action 53) +pub const Action53 = @This(); + +selected_reg: u2 = 0, +regs: [4]u8 = [_]u8{0} ** 4, +mirroring_bit: u1 = 0, +prg_page_0: usize = 0, +prg_page_1: usize = 0xffff, // resolves to last 16KB bank at init +mirroring_mode: Mirroring = .vertical, + +pub fn init(initial_mirroring: Mirroring) Action53 { + return .{ + .mirroring_mode = initial_mirroring, + }; +} + +pub fn cpuRead(self: *const Action53, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_16k = @max(1, prg_rom.len / 0x4000); + const bank = if (address < 0xc000) + self.prg_page_0 % total_16k + else + (if (self.prg_page_1 == 0xffff) total_16k - 1 else self.prg_page_1 % total_16k); + + const offset = (bank * 0x4000) + (address & 0x3fff); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Action53, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x5000 and address <= 0x5fff) { + self.selected_reg = @truncate(((value & 0x80) >> 6) | (value & 0x01)); + return true; + } + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + return false; + } + if (address >= 0x8000 and address <= 0xffff) { + if (self.selected_reg <= 1) { + self.mirroring_bit = @truncate((value >> 4) & 0x01); + } else if (self.selected_reg == 2) { + self.mirroring_bit = @truncate(value & 0x01); + } + + self.regs[self.selected_reg] = value; + self.updateState(); + return true; + } + return false; +} + +fn updateState(self: *Action53) void { + var mirroring_val: u8 = self.regs[2] & 0x03; + if ((mirroring_val & 0x02) == 0) { + mirroring_val = self.mirroring_bit; + } + + self.mirroring_mode = switch (mirroring_val) { + 0 => .single_screen_lower, + 1 => .single_screen_upper, + 2 => .vertical, + 3 => .horizontal, + else => unreachable, + }; + + const game_size: usize = (self.regs[2] & 0x30) >> 4; + const prg_size: usize = (self.regs[2] & 0x08) >> 3; + const slot_select: usize = (self.regs[2] & 0x04) >> 2; + var prg_select: usize = self.regs[1] & 0x0f; + const outer_prg_select: usize = @as(usize, self.regs[3]) << 1; + + if (prg_size != 0) { + const outer_mask = [_]usize{ 0x1fe, 0x1fc, 0x1f8, 0x1f0 }; + const inner_mask = [_]usize{ 0x01, 0x03, 0x07, 0x0f }; + const switchable = (outer_prg_select & outer_mask[game_size]) | (prg_select & inner_mask[game_size]); + const fixed = (outer_prg_select & 0x1fe) | slot_select; + + if (slot_select != 0) { + self.prg_page_0 = switchable; + self.prg_page_1 = fixed; + } else { + self.prg_page_0 = fixed; + self.prg_page_1 = switchable; + } + } else { + prg_select <<= 1; + const outer_and = [_]usize{ 0x1fe, 0x1fc, 0x1f8, 0x1f0 }; + const inner_and = [_]usize{ 0x01, 0x03, 0x07, 0x0f }; + self.prg_page_0 = (outer_prg_select & outer_and[game_size]) | (prg_select & inner_and[game_size]); + self.prg_page_1 = (outer_prg_select & outer_and[game_size]) | ((prg_select | 1) & inner_and[game_size]); + } +} + +pub fn ppuRead(self: *const Action53, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + const chr_select: usize = self.regs[0] & 0x03; + if (chr_is_ram) { + const total_8k = @max(1, chr_ram.len / 0x2000); + const offset = ((chr_select % total_8k) * 0x2000) + address; + if (offset < chr_ram.len) return chr_ram[offset]; + } else if (chr_rom.len > 0) { + const total_8k = @max(1, chr_rom.len / 0x2000); + const offset = ((chr_select % total_8k) * 0x2000) + address; + if (offset < chr_rom.len) return chr_rom[offset]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(self: *Action53, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + const chr_select: usize = self.regs[0] & 0x03; + const total_8k = @max(1, chr_ram.len / 0x2000); + const offset = ((chr_select % total_8k) * 0x2000) + address; + if (offset < chr_ram.len) { + chr_ram[offset] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Action53) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Action53) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Action53, _: u16) void {} + +test "action53 register write" { + var a = Action53.init(.vertical); + var ram: [0x2000]u8 = undefined; + + _ = a.cpuWrite(0x5000, 0x80, &ram); // select supervisor reg 2 (mode) + _ = a.cpuWrite(0x8000, 0x01, &ram); // 1-screen upper + try std.testing.expectEqual(Mirroring.single_screen_upper, a.mirroring()); +} diff --git a/src/system/nes/mapper/axrom.zig b/src/system/nes/mapper/axrom.zig new file mode 100644 index 0000000..1b6289d --- /dev/null +++ b/src/system/nes/mapper/axrom.zig @@ -0,0 +1,76 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Axrom = @This(); + +prg_bank: u4 = 0, +mirroring_mode: Mirroring = .single_screen_lower, + +pub fn init() Axrom { + return .{}; +} + +pub fn cpuRead(self: *const Axrom, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_32k_banks = @max(1, prg_rom.len / 0x8000); + const bank = @as(usize, self.prg_bank) % total_32k_banks; + const offset = (bank * 0x8000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Axrom, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x8000 and address <= 0xffff) { + self.prg_bank = @truncate(value & 0x0f); + self.mirroring_mode = if ((value & 0x10) != 0) .single_screen_upper else .single_screen_lower; + return true; + } + return false; +} + +pub fn ppuRead(_: *const Axrom, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else { + if (address < chr_rom.len) return chr_rom[address]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Axrom, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Axrom) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Axrom) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Axrom, _: u16) void {} diff --git a/src/system/nes/mapper/bnrom.zig b/src/system/nes/mapper/bnrom.zig new file mode 100644 index 0000000..3fb0c22 --- /dev/null +++ b/src/system/nes/mapper/bnrom.zig @@ -0,0 +1,110 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 34 (BNROM / NINA-001) - 32KB PRG banking, optional NINA-001 4KB CHR banking +pub const Bnrom = @This(); + +prg_bank: u8 = 0, +chr_bank_0: u8 = 0, +chr_bank_1: u8 = 1, +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) Bnrom { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(self: *const Bnrom, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_32k_banks = @max(1, prg_rom.len / 0x8000); + const bank = @as(usize, self.prg_bank) % total_32k_banks; + const offset = (bank * 0x8000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Bnrom, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + // NINA-001 registers + switch (address) { + 0x7ffd => { + self.prg_bank = value & 0x0f; + return true; + }, + 0x7ffe => { + self.chr_bank_0 = value & 0x0f; + return true; + }, + 0x7fff => { + self.chr_bank_1 = value & 0x0f; + return true; + }, + else => { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + }, + } + } + if (address >= 0x8000 and address <= 0xffff) { + // BNROM: select 32KB PRG bank + self.prg_bank = value & 0x0f; + return true; + } + return false; +} + +pub fn ppuRead(self: *const Bnrom, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else if (chr_rom.len > 0) { + const total_4k_banks = @max(1, chr_rom.len / 0x1000); + const bank = if (address < 0x1000) + @as(usize, self.chr_bank_0) % total_4k_banks + else + @as(usize, self.chr_bank_1) % total_4k_banks; + const offset = (bank * 0x1000) + (address & 0x0fff); + if (offset < chr_rom.len) return chr_rom[offset]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Bnrom, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Bnrom) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Bnrom) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Bnrom, _: u16) void {} + +test "bnrom bank switching" { + var bn = Bnrom.init(.vertical); + var ram: [0x2000]u8 = undefined; + + _ = bn.cpuWrite(0x8000, 3, &ram); + try std.testing.expectEqual(@as(u8, 3), bn.prg_bank); +} diff --git a/src/system/nes/mapper/camerica.zig b/src/system/nes/mapper/camerica.zig new file mode 100644 index 0000000..e006068 --- /dev/null +++ b/src/system/nes/mapper/camerica.zig @@ -0,0 +1,104 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 71 (Camerica / Codemasters) - 16KB PRG banking at $C000-$FFFF, optional 1-screen mirroring at $9000-$9FFF +pub const Camerica = @This(); + +prg_bank: u8 = 0, +mirroring_mode: Mirroring, +has_mirroring_control: bool, + +pub fn init(initial_mirroring: Mirroring) Camerica { + return .{ + .mirroring_mode = initial_mirroring, + .has_mirroring_control = false, + }; +} + +pub fn cpuRead(self: *const Camerica, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_16k_banks = @max(1, prg_rom.len / 0x4000); + var bank: usize = 0; + if (address < 0xc000) { + bank = @as(usize, self.prg_bank) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = total_16k_banks - 1; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Camerica, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x9000 and address <= 0x9fff) { + // Fire Hawk single-screen mirroring control + self.mirroring_mode = if ((value & 0x10) != 0) .single_screen_upper else .single_screen_lower; + self.has_mirroring_control = true; + return true; + } + if (address >= 0xc000 and address <= 0xffff) { + self.prg_bank = value & 0x0f; + return true; + } + return false; +} + +pub fn ppuRead(_: *const Camerica, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else if (chr_rom.len > 0) { + if (address < chr_rom.len) return chr_rom[address]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Camerica, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Camerica) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Camerica) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Camerica, _: u16) void {} + +test "camerica bank switching" { + var cam = Camerica.init(.vertical); + var ram: [0x2000]u8 = undefined; + + _ = cam.cpuWrite(0xc000, 7, &ram); + try std.testing.expectEqual(@as(u8, 7), cam.prg_bank); + + _ = cam.cpuWrite(0x9000, 0x10, &ram); + try std.testing.expectEqual(Mirroring.single_screen_upper, cam.mirroring()); +} diff --git a/src/system/nes/mapper/cnrom.zig b/src/system/nes/mapper/cnrom.zig new file mode 100644 index 0000000..9c53e2c --- /dev/null +++ b/src/system/nes/mapper/cnrom.zig @@ -0,0 +1,70 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Cnrom = @This(); + +chr_bank: u8 = 0, +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) Cnrom { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(_: *const Cnrom, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const mask: usize = if (prg_rom.len <= 0x4000) 0x3fff else 0x7fff; + const offset = (address - 0x8000) & mask; + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Cnrom, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x8000 and address <= 0xffff) { + // ponytail: mask up to 16 banks (128KB) for extended CNROM homebrew + self.chr_bank = value & 0x0f; + return true; + } + return false; +} + +pub fn ppuRead(self: *const Cnrom, address: u16, chr_rom: []const u8, _: []const u8, _: bool) ?u8 { + if (address < 0x2000) { + if (chr_rom.len > 0) { + const total_8k_banks = @max(1, chr_rom.len / 0x2000); + const bank = @as(usize, self.chr_bank) % total_8k_banks; + const offset = (bank * 0x2000) + address; + if (offset < chr_rom.len) return chr_rom[offset]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Cnrom, _: u16, _: u8, _: []u8, _: bool) bool { + return false; +} + +pub fn mirroring(self: *const Cnrom) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Cnrom) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Cnrom, _: u16) void {} diff --git a/src/system/nes/mapper/color_dreams.zig b/src/system/nes/mapper/color_dreams.zig new file mode 100644 index 0000000..cb477eb --- /dev/null +++ b/src/system/nes/mapper/color_dreams.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 11 (Color Dreams) - 32KB PRG bank in bits 0-1, 8KB CHR bank in bits 4-7 +pub const ColorDreams = @This(); + +prg_bank: u8 = 0, +chr_bank: u8 = 0, +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) ColorDreams { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(self: *const ColorDreams, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_32k_banks = @max(1, prg_rom.len / 0x8000); + const bank = @as(usize, self.prg_bank) % total_32k_banks; + const offset = (bank * 0x8000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *ColorDreams, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x8000 and address <= 0xffff) { + self.prg_bank = value & 0x03; + self.chr_bank = (value >> 4) & 0x0f; + return true; + } + return false; +} + +pub fn ppuRead(self: *const ColorDreams, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else if (chr_rom.len > 0) { + const total_8k_banks = @max(1, chr_rom.len / 0x2000); + const bank = @as(usize, self.chr_bank) % total_8k_banks; + const offset = (bank * 0x2000) + address; + if (offset < chr_rom.len) return chr_rom[offset]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *ColorDreams, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const ColorDreams) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const ColorDreams) bool { + return false; +} + +pub fn notifyPpuAddress(_: *ColorDreams, _: u16) void {} + +test "color dreams bank switching" { + var cd = ColorDreams.init(.vertical); + var ram: [0x2000]u8 = undefined; + + // Write 0x52: PRG bank 2, CHR bank 5 + _ = cd.cpuWrite(0x8000, 0x52, &ram); + try std.testing.expectEqual(@as(u8, 2), cd.prg_bank); + try std.testing.expectEqual(@as(u8, 5), cd.chr_bank); +} diff --git a/src/system/nes/mapper/gxrom.zig b/src/system/nes/mapper/gxrom.zig new file mode 100644 index 0000000..acb51a9 --- /dev/null +++ b/src/system/nes/mapper/gxrom.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 66 (GxROM / GNROM) - 32KB PRG bank in bits 4-5, 8KB CHR bank in bits 0-1 +pub const Gxrom = @This(); + +prg_bank: u8 = 0, +chr_bank: u8 = 0, +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) Gxrom { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(self: *const Gxrom, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_32k_banks = @max(1, prg_rom.len / 0x8000); + const bank = @as(usize, self.prg_bank) % total_32k_banks; + const offset = (bank * 0x8000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Gxrom, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x8000 and address <= 0xffff) { + self.prg_bank = (value >> 4) & 0x03; + self.chr_bank = value & 0x03; + return true; + } + return false; +} + +pub fn ppuRead(self: *const Gxrom, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else if (chr_rom.len > 0) { + const total_8k_banks = @max(1, chr_rom.len / 0x2000); + const bank = @as(usize, self.chr_bank) % total_8k_banks; + const offset = (bank * 0x2000) + address; + if (offset < chr_rom.len) return chr_rom[offset]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Gxrom, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Gxrom) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Gxrom) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Gxrom, _: u16) void {} + +test "gxrom bank switching" { + var gx = Gxrom.init(.horizontal); + var ram: [0x2000]u8 = undefined; + + // Write 0x21: PRG bank 2, CHR bank 1 + _ = gx.cpuWrite(0x8000, 0x21, &ram); + try std.testing.expectEqual(@as(u8, 2), gx.prg_bank); + try std.testing.expectEqual(@as(u8, 1), gx.chr_bank); +} diff --git a/src/system/nes/mapper/mapper180.zig b/src/system/nes/mapper/mapper180.zig new file mode 100644 index 0000000..b81c0d0 --- /dev/null +++ b/src/system/nes/mapper/mapper180.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 180 (Nichibutsu / Crazy Climber) +// Fixed first 16KB at $8000-$BFFF, switchable 16KB at $C000-$FFFF via write to $8000-$FFFF. +pub const Mapper180 = @This(); + +prg_bank: u8 = 0, +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) Mapper180 { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(self: *const Mapper180, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_16k_banks = @max(1, prg_rom.len / 0x4000); + var bank: usize = 0; + + if (address < 0xc000) { + bank = 0; + const offset = address - 0x8000; + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = @as(usize, self.prg_bank) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Mapper180, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + return false; + } + if (address >= 0x8000 and address <= 0xffff) { + self.prg_bank = value & 0x0f; + return true; + } + return false; +} + +pub fn ppuRead(_: *const Mapper180, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else if (chr_rom.len > 0) { + if (address < chr_rom.len) return chr_rom[address]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Mapper180, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Mapper180) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Mapper180) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Mapper180, _: u16) void {} + +test "mapper180 bank switching" { + var m = Mapper180.init(.horizontal); + var ram: [0x2000]u8 = undefined; + + _ = m.cpuWrite(0x8000, 3, &ram); + try std.testing.expectEqual(@as(u8, 3), m.prg_bank); +} diff --git a/src/system/nes/mapper/mmc1.zig b/src/system/nes/mapper/mmc1.zig new file mode 100644 index 0000000..e4dc042 --- /dev/null +++ b/src/system/nes/mapper/mmc1.zig @@ -0,0 +1,178 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Mmc1 = @This(); + +shift_register: u5 = 0x10, +control: u5 = 0x0c, // PRG mode 3 (fixed $C000) +chr_bank_0: u5 = 0, +chr_bank_1: u5 = 0, +prg_bank: u5 = 0, + +pub fn init() Mmc1 { + return .{}; +} + +pub fn cpuRead(self: *const Mmc1, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + // Bit 4 of prg_bank is PRG RAM disable (0: enabled, 1: disabled) + if ((self.prg_bank & 0x10) != 0) return 0; + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const prg_mode: u2 = @truncate(self.control >> 2); + const total_16k_banks: usize = @max(1, prg_rom.len / 0x4000); + // ponytail: SUROM uses CHR bank 0 bit 4 for PRG A18 when PRG is 512KB + const surom_base: usize = if (total_16k_banks >= 32 and (self.chr_bank_0 & 0x10) != 0) 16 else 0; + var bank: usize = 0; + + switch (prg_mode) { + 0, 1 => { + // 32 KB mode: ignore low bit of prg_bank + bank = ((self.prg_bank & 0x0e) | surom_base) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + }, + 2 => { + // Fix first bank at $8000, switch 16 KB bank at $C000 + if (address < 0xc000) { + bank = surom_base % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = ((self.prg_bank & 0x0f) | surom_base) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + }, + 3 => { + // Switch 16 KB bank at $8000, fix last bank at $C000 + if (address < 0xc000) { + bank = ((self.prg_bank & 0x0f) | surom_base) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = (surom_base + 15) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + }, + } + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Mmc1, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + if ((self.prg_bank & 0x10) != 0) return false; + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + return false; + } + if (address >= 0x8000 and address <= 0xffff) { + if ((value & 0x80) != 0) { + // Reset shift register + self.shift_register = 0x10; + self.control |= 0x0c; // PRG mode 3 + return true; + } + + const is_full = (self.shift_register & 1) != 0; + self.shift_register = (self.shift_register >> 1) | (@as(u5, @truncate(value & 1)) << 4); + + if (is_full) { + const reg_val = self.shift_register; + self.shift_register = 0x10; + + const reg_select = (address >> 13) & 0x03; + switch (reg_select) { + 0 => self.control = reg_val, + 1 => self.chr_bank_0 = reg_val, + 2 => self.chr_bank_1 = reg_val, + 3 => self.prg_bank = reg_val, + else => unreachable, + } + } + return true; + } + return false; +} + +pub fn ppuRead(self: *const Mmc1, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + return 0; + } + if (chr_rom.len == 0) return 0; + + const chr_4k_mode = (self.control & 0x10) != 0; + const total_4k_banks = @max(1, chr_rom.len / 0x1000); + var offset: usize = 0; + + if (chr_4k_mode) { + if (address < 0x1000) { + const bank = @as(usize, self.chr_bank_0) % total_4k_banks; + offset = (bank * 0x1000) + address; + } else { + const bank = @as(usize, self.chr_bank_1) % total_4k_banks; + offset = (bank * 0x1000) + (address - 0x1000); + } + } else { + const bank = @as(usize, self.chr_bank_0 & 0x1e) % total_4k_banks; + offset = (bank * 0x1000) + address; + } + + if (offset < chr_rom.len) return chr_rom[offset]; + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Mmc1, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Mmc1) Mirroring { + return switch (self.control & 0x03) { + 0 => .single_screen_lower, + 1 => .single_screen_upper, + 2 => .vertical, + 3 => .horizontal, + else => unreachable, + }; +} + +pub fn irqLine(_: *const Mmc1) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Mmc1, _: u16) void {} + +test "mmc1 serial shift register write" { + var mmc1 = Mmc1.init(); + var ram: [0x2000]u8 = undefined; + + // Write 5 bits (e.g. 0b00011 -> horizontal mirroring) into $8000 (control register) + _ = mmc1.cpuWrite(0x8000, 1, &ram); // bit 0 = 1 + _ = mmc1.cpuWrite(0x8000, 1, &ram); // bit 1 = 1 + _ = mmc1.cpuWrite(0x8000, 0, &ram); // bit 2 = 0 + _ = mmc1.cpuWrite(0x8000, 0, &ram); // bit 3 = 0 + _ = mmc1.cpuWrite(0x8000, 0, &ram); // bit 4 = 0 -> loaded! + + try std.testing.expectEqual(@as(u5, 0x03), mmc1.control); + try std.testing.expectEqual(Mirroring.horizontal, mmc1.mirroring()); +} diff --git a/src/system/nes/mapper/mmc2.zig b/src/system/nes/mapper/mmc2.zig new file mode 100644 index 0000000..91fa64a --- /dev/null +++ b/src/system/nes/mapper/mmc2.zig @@ -0,0 +1,151 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 9 (MMC2) - Mike Tyson's Punch-Out!! +// 8KB switchable PRG at $8000, fixed 24KB at $A000-$FFFF. +// Dual 4KB CHR latches toggled by PPU fetches at $xFD8-$xFDF and $xFE8-$xFEF. +pub const Mmc2 = @This(); + +prg_bank: u8 = 0, +chr_bank_0_fd: u8 = 0, +chr_bank_0_fe: u8 = 0, +chr_bank_1_fd: u8 = 0, +chr_bank_1_fe: u8 = 0, +latch_0: u1 = 0, +latch_1: u1 = 0, +mirroring_mode: Mirroring = .vertical, + +pub fn init() Mmc2 { + return .{}; +} + +pub fn cpuRead(self: *const Mmc2, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_8k_banks = @max(1, prg_rom.len / 0x2000); + var bank: usize = 0; + + switch (address) { + 0x8000...0x9fff => bank = @as(usize, self.prg_bank) % total_8k_banks, + 0xa000...0xbfff => bank = if (total_8k_banks >= 3) total_8k_banks - 3 else 0, + 0xc000...0xdfff => bank = if (total_8k_banks >= 2) total_8k_banks - 2 else 0, + 0xe000...0xffff => bank = total_8k_banks - 1, + else => return 0, + } + + const offset = (bank * 0x2000) + (address & 0x1fff); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Mmc2, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + return false; + } + switch (address) { + 0xa000...0xafff => { + self.prg_bank = value & 0x0f; + return true; + }, + 0xb000...0xbfff => { + self.chr_bank_0_fd = value & 0x1f; + return true; + }, + 0xc000...0xcfff => { + self.chr_bank_0_fe = value & 0x1f; + return true; + }, + 0xd000...0xdfff => { + self.chr_bank_1_fd = value & 0x1f; + return true; + }, + 0xe000...0xefff => { + self.chr_bank_1_fe = value & 0x1f; + return true; + }, + 0xf000...0xffff => { + self.mirroring_mode = if ((value & 1) == 0) .vertical else .horizontal; + return true; + }, + else => return false, + } +} + +pub fn ppuRead(self: *const Mmc2, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + return 0; + } + if (chr_rom.len == 0) return 0; + + const total_4k_banks = @max(1, chr_rom.len / 0x1000); + const bank = if (address < 0x1000) + (if (self.latch_0 == 0) self.chr_bank_0_fd else self.chr_bank_0_fe) + else + (if (self.latch_1 == 0) self.chr_bank_1_fd else self.chr_bank_1_fe); + + const offset = ((@as(usize, bank) % total_4k_banks) * 0x1000) + (address & 0x0fff); + if (offset < chr_rom.len) return chr_rom[offset]; + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Mmc2, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn notifyPpuAddress(self: *Mmc2, address: u16) void { + self.checkLatch(address); +} + +inline fn checkLatch(self: *Mmc2, address: u16) void { + switch (address) { + 0x0fd8...0x0fdf => self.latch_0 = 0, + 0x0fe8...0x0fef => self.latch_0 = 1, + 0x1fd8...0x1fdf => self.latch_1 = 0, + 0x1fe8...0x1fef => self.latch_1 = 1, + else => {}, + } +} + +pub fn mirroring(self: *const Mmc2) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Mmc2) bool { + return false; +} + +test "mmc2 latch toggling" { + var m = Mmc2.init(); + var ram: [0x2000]u8 = undefined; + + _ = m.cpuWrite(0xb000, 1, &ram); // CHR 0 FD = 1 + _ = m.cpuWrite(0xc000, 2, &ram); // CHR 0 FE = 2 + + m.notifyPpuAddress(0x0fd8); + try std.testing.expectEqual(@as(u1, 0), m.latch_0); + + m.notifyPpuAddress(0x0fe8); + try std.testing.expectEqual(@as(u1, 1), m.latch_0); +} diff --git a/src/system/nes/mapper/mmc3.zig b/src/system/nes/mapper/mmc3.zig new file mode 100644 index 0000000..f0632b0 --- /dev/null +++ b/src/system/nes/mapper/mmc3.zig @@ -0,0 +1,222 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Mmc3 = @This(); + +bank_select: u8 = 0, +registers: [8]u8 = [_]u8{0} ** 8, +mirroring_mode: Mirroring = .vertical, +prg_ram_protect: u8 = 0x80, + +irq_latch: u8 = 0, +irq_counter: u8 = 0, +irq_reload: bool = false, +irq_enabled: bool = false, +irq_asserted: bool = false, + +last_a12: bool = false, +a12_low_cycles: u32 = 0, + +pub fn init(initial_mirroring: Mirroring) Mmc3 { + return .{ + .mirroring_mode = initial_mirroring, + }; +} + +pub fn cpuRead(self: *const Mmc3, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + if ((self.prg_ram_protect & 0x80) == 0) return 0; + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_8k_banks = @max(1, prg_rom.len / 0x2000); + const prg_mode = (self.bank_select & 0x40) != 0; + var bank: usize = 0; + + switch (address) { + 0x8000...0x9fff => { + bank = if (!prg_mode) self.registers[6] else (total_8k_banks - 2); + }, + 0xa000...0xbfff => { + bank = self.registers[7]; + }, + 0xc000...0xdfff => { + bank = if (!prg_mode) (total_8k_banks - 2) else self.registers[6]; + }, + 0xe000...0xffff => { + bank = total_8k_banks - 1; + }, + else => return 0, + } + + bank %= total_8k_banks; + const offset = (bank * 0x2000) + (address & 0x1fff); + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Mmc3, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + if ((self.prg_ram_protect & 0x80) == 0 or (self.prg_ram_protect & 0x40) != 0) return false; + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + return false; + } + + switch (address) { + 0x8000...0x9fff => { + if ((address & 1) == 0) { + self.bank_select = value; + } else { + const reg = self.bank_select & 0x07; + self.registers[reg] = value; + } + return true; + }, + 0xa000...0xbfff => { + if ((address & 1) == 0) { + self.mirroring_mode = if ((value & 1) == 0) .vertical else .horizontal; + } else { + self.prg_ram_protect = value; + } + return true; + }, + 0xc000...0xdfff => { + if ((address & 1) == 0) { + self.irq_latch = value; + } else { + self.irq_reload = true; + } + return true; + }, + 0xe000...0xffff => { + if ((address & 1) == 0) { + self.irq_enabled = false; + self.irq_asserted = false; + } else { + self.irq_enabled = true; + } + return true; + }, + else => return false, + } +} + +pub fn ppuRead(self: *const Mmc3, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + return 0; + } + if (chr_rom.len == 0) return 0; + + const chr_inversion = (self.bank_select & 0x80) != 0; + const total_1k_banks = @max(1, chr_rom.len / 0x0400); + var bank: usize = 0; + + if (!chr_inversion) { + switch (address) { + 0x0000...0x07ff => bank = (self.registers[0] & 0xfe) +% ((address >> 10) & 1), + 0x0800...0x0fff => bank = (self.registers[1] & 0xfe) +% ((address >> 10) & 1), + 0x1000...0x13ff => bank = self.registers[2], + 0x1400...0x17ff => bank = self.registers[3], + 0x1800...0x1bff => bank = self.registers[4], + 0x1c00...0x1fff => bank = self.registers[5], + else => unreachable, + } + } else { + switch (address) { + 0x0000...0x03ff => bank = self.registers[2], + 0x0400...0x07ff => bank = self.registers[3], + 0x0800...0x0bff => bank = self.registers[4], + 0x0c00...0x0fff => bank = self.registers[5], + 0x1000...0x17ff => bank = (self.registers[0] & 0xfe) +% ((address >> 10) & 1), + 0x1800...0x1fff => bank = (self.registers[1] & 0xfe) +% ((address >> 10) & 1), + else => unreachable, + } + } + + bank %= total_1k_banks; + const offset = (bank * 0x0400) + (address & 0x03ff); + if (offset < chr_rom.len) return chr_rom[offset]; + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Mmc3, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn notifyPpuAddress(self: *Mmc3, address: u16) void { + const a12 = (address & 0x1000) != 0; + if (a12) { + if (!self.last_a12 and self.a12_low_cycles >= 3) { + // Rising edge on A12 + if (self.irq_counter == 0 or self.irq_reload) { + self.irq_counter = self.irq_latch; + self.irq_reload = false; + } else { + self.irq_counter -= 1; + } + + if (self.irq_counter == 0 and self.irq_enabled) { + self.irq_asserted = true; + } + } + self.a12_low_cycles = 0; + } else { + self.a12_low_cycles +|= 1; + } + self.last_a12 = a12; +} + +pub fn mirroring(self: *const Mmc3) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(self: *const Mmc3) bool { + return self.irq_asserted; +} + +test "mmc3 scanline counter rising a12" { + var mmc3 = Mmc3.init(.vertical); + var ram: [0x2000]u8 = undefined; + + // Set IRQ latch to 2 and enable IRQ + _ = mmc3.cpuWrite(0xc000, 2, &ram); // latch = 2 + _ = mmc3.cpuWrite(0xc001, 0, &ram); // reload + _ = mmc3.cpuWrite(0xe001, 0, &ram); // enable + + try std.testing.expect(!mmc3.irqLine()); + + // Cycle A12: low for 4 cycles, then high + for (0..4) |_| mmc3.notifyPpuAddress(0x0000); + mmc3.notifyPpuAddress(0x1000); // 1st rising edge: reloaded to 2 + try std.testing.expectEqual(@as(u8, 2), mmc3.irq_counter); + try std.testing.expect(!mmc3.irqLine()); + + for (0..4) |_| mmc3.notifyPpuAddress(0x0000); + mmc3.notifyPpuAddress(0x1000); // 2nd rising edge: 2 -> 1 + try std.testing.expectEqual(@as(u8, 1), mmc3.irq_counter); + try std.testing.expect(!mmc3.irqLine()); + + for (0..4) |_| mmc3.notifyPpuAddress(0x0000); + mmc3.notifyPpuAddress(0x1000); // 3rd rising edge: 1 -> 0, IRQ asserted! + try std.testing.expectEqual(@as(u8, 0), mmc3.irq_counter); + try std.testing.expect(mmc3.irqLine()); +} diff --git a/src/system/nes/mapper/mmc4.zig b/src/system/nes/mapper/mmc4.zig new file mode 100644 index 0000000..1c64a42 --- /dev/null +++ b/src/system/nes/mapper/mmc4.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 10 (MMC4) - Fire Emblem & Fire Emblem Gaiden +// 16KB switchable PRG at $8000, fixed last 16KB at $C000-$FFFF. +// Dual 4KB CHR latches toggled by PPU fetches at $xFD8-$xFDF and $xFE8-$xFEF. +pub const Mmc4 = @This(); + +prg_bank: u8 = 0, +chr_bank_0_fd: u8 = 0, +chr_bank_0_fe: u8 = 0, +chr_bank_1_fd: u8 = 0, +chr_bank_1_fe: u8 = 0, +latch_0: u1 = 0, +latch_1: u1 = 0, +mirroring_mode: Mirroring = .vertical, + +pub fn init() Mmc4 { + return .{}; +} + +pub fn cpuRead(self: *const Mmc4, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_16k_banks = @max(1, prg_rom.len / 0x4000); + var bank: usize = 0; + + if (address < 0xc000) { + bank = @as(usize, self.prg_bank) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = total_16k_banks - 1; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Mmc4, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + return false; + } + switch (address) { + 0xa000...0xafff => { + self.prg_bank = value & 0x0f; + return true; + }, + 0xb000...0xbfff => { + self.chr_bank_0_fd = value & 0x1f; + return true; + }, + 0xc000...0xcfff => { + self.chr_bank_0_fe = value & 0x1f; + return true; + }, + 0xd000...0xdfff => { + self.chr_bank_1_fd = value & 0x1f; + return true; + }, + 0xe000...0xefff => { + self.chr_bank_1_fe = value & 0x1f; + return true; + }, + 0xf000...0xffff => { + self.mirroring_mode = if ((value & 1) == 0) .vertical else .horizontal; + return true; + }, + else => return false, + } +} + +pub fn ppuRead(self: *const Mmc4, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + return 0; + } + if (chr_rom.len == 0) return 0; + + const total_4k_banks = @max(1, chr_rom.len / 0x1000); + const bank = if (address < 0x1000) + (if (self.latch_0 == 0) self.chr_bank_0_fd else self.chr_bank_0_fe) + else + (if (self.latch_1 == 0) self.chr_bank_1_fd else self.chr_bank_1_fe); + + const offset = ((@as(usize, bank) % total_4k_banks) * 0x1000) + (address & 0x0fff); + if (offset < chr_rom.len) return chr_rom[offset]; + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Mmc4, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn notifyPpuAddress(self: *Mmc4, address: u16) void { + self.checkLatch(address); +} + +inline fn checkLatch(self: *Mmc4, address: u16) void { + switch (address) { + 0x0fd8...0x0fdf => self.latch_0 = 0, + 0x0fe8...0x0fef => self.latch_0 = 1, + 0x1fd8...0x1fdf => self.latch_1 = 0, + 0x1fe8...0x1fef => self.latch_1 = 1, + else => {}, + } +} + +pub fn mirroring(self: *const Mmc4) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Mmc4) bool { + return false; +} + +test "mmc4 latch toggling" { + var m = Mmc4.init(); + var ram: [0x2000]u8 = undefined; + + _ = m.cpuWrite(0xa000, 3, &ram); // PRG bank 3 + try std.testing.expectEqual(@as(u8, 3), m.prg_bank); + + m.notifyPpuAddress(0x1fe8); + try std.testing.expectEqual(@as(u1, 1), m.latch_1); +} diff --git a/src/system/nes/mapper/nrom.zig b/src/system/nes/mapper/nrom.zig new file mode 100644 index 0000000..0b691f0 --- /dev/null +++ b/src/system/nes/mapper/nrom.zig @@ -0,0 +1,83 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Nrom = @This(); + +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) Nrom { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(_: *const Nrom, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const mask: usize = if (prg_rom.len <= 0x4000) 0x3fff else 0x7fff; + const offset = (address - 0x8000) & mask; + if (offset < prg_rom.len) return prg_rom[offset]; + return 0; + } + return null; +} + +pub fn cpuWrite(_: *Nrom, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + return false; +} + +pub fn ppuRead(_: *const Nrom, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else { + if (chr_rom.len > 0) return chr_rom[address & (chr_rom.len - 1)]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Nrom, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Nrom) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Nrom) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Nrom, _: u16) void {} + +test "nrom prg mirroring" { + var nrom = Nrom.init(.horizontal); + var prg_16k: [0x4000]u8 = undefined; + @memset(&prg_16k, 0x42); + prg_16k[0] = 0x11; + + var ram: [0x2000]u8 = undefined; + @memset(&ram, 0); + + // 16K PRG is mirrored at $8000 and $C000 + try std.testing.expectEqual(@as(?u8, 0x11), nrom.cpuRead(0x8000, &prg_16k, &ram)); + try std.testing.expectEqual(@as(?u8, 0x11), nrom.cpuRead(0xc000, &prg_16k, &ram)); +} diff --git a/src/system/nes/mapper/root.zig b/src/system/nes/mapper/root.zig new file mode 100644 index 0000000..719ded6 --- /dev/null +++ b/src/system/nes/mapper/root.zig @@ -0,0 +1,97 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Nrom = @import("nrom.zig"); +pub const Mmc1 = @import("mmc1.zig"); +pub const Uxrom = @import("uxrom.zig"); +pub const Cnrom = @import("cnrom.zig"); +pub const Mmc3 = @import("mmc3.zig"); +pub const Axrom = @import("axrom.zig"); +pub const ColorDreams = @import("color_dreams.zig"); +pub const Unrom512 = @import("unrom512.zig"); +pub const Bnrom = @import("bnrom.zig"); +pub const Gxrom = @import("gxrom.zig"); +pub const Camerica = @import("camerica.zig"); +pub const Mmc2 = @import("mmc2.zig"); +pub const Mmc4 = @import("mmc4.zig"); +pub const Action53 = @import("action53.zig"); +pub const Mapper180 = @import("mapper180.zig"); + +pub const Mapper = union(enum) { + nrom: Nrom, + mmc1: Mmc1, + uxrom: Uxrom, + cnrom: Cnrom, + mmc3: Mmc3, + axrom: Axrom, + color_dreams: ColorDreams, + unrom512: Unrom512, + bnrom: Bnrom, + gxrom: Gxrom, + camerica: Camerica, + mmc2: Mmc2, + mmc4: Mmc4, + action53: Action53, + mapper180: Mapper180, + + pub fn cpuRead(self: *const Mapper, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + return switch (self.*) { + inline else => |*m| m.cpuRead(address, prg_rom, prg_ram), + }; + } + + pub fn cpuWrite(self: *Mapper, address: u16, value: u8, prg_ram: []u8) bool { + return switch (self.*) { + inline else => |*m| m.cpuWrite(address, value, prg_ram), + }; + } + + pub fn ppuRead(self: *const Mapper, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + return switch (self.*) { + inline else => |*m| m.ppuRead(address, chr_rom, chr_ram, chr_is_ram), + }; + } + + pub fn ppuWrite(self: *Mapper, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + return switch (self.*) { + inline else => |*m| m.ppuWrite(address, value, chr_ram, chr_is_ram), + }; + } + + pub fn mirroring(self: *const Mapper) Mirroring { + return switch (self.*) { + inline else => |*m| m.mirroring(), + }; + } + + pub fn irqLine(self: *const Mapper) bool { + return switch (self.*) { + inline else => |*m| m.irqLine(), + }; + } + + pub fn notifyPpuAddress(self: *Mapper, address: u16) void { + switch (self.*) { + inline else => |*m| m.notifyPpuAddress(address), + } + } +}; + +test { + _ = Nrom; + _ = Mmc1; + _ = Uxrom; + _ = Cnrom; + _ = Mmc3; + _ = Axrom; + _ = ColorDreams; + _ = Unrom512; + _ = Bnrom; + _ = Gxrom; + _ = Camerica; + _ = Mmc2; + _ = Mmc4; + _ = Action53; + _ = Mapper180; +} diff --git a/src/system/nes/mapper/unrom512.zig b/src/system/nes/mapper/unrom512.zig new file mode 100644 index 0000000..3706b7c --- /dev/null +++ b/src/system/nes/mapper/unrom512.zig @@ -0,0 +1,112 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +// ponytail: Mapper 30 (UNROM-512) - 16KB PRG bank (bits 0-4), 8KB CHR-RAM bank (bits 5-6), 1-screen mirroring (bit 7) +pub const Unrom512 = @This(); + +prg_bank: u8 = 0, +chr_bank: u8 = 0, +mirroring_mode: Mirroring, +supports_mirroring_control: bool, + +pub fn init(initial_mirroring: Mirroring) Unrom512 { + return .{ + .mirroring_mode = initial_mirroring, + .supports_mirroring_control = (initial_mirroring == .single_screen_lower or initial_mirroring == .single_screen_upper), + }; +} + +pub fn cpuRead(self: *const Unrom512, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_16k_banks = @max(1, prg_rom.len / 0x4000); + var bank: usize = 0; + if (address < 0xc000) { + bank = @as(usize, self.prg_bank) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = total_16k_banks - 1; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Unrom512, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x8000 and address <= 0xffff) { + self.prg_bank = value & 0x1f; + self.chr_bank = (value >> 5) & 0x03; + if (self.supports_mirroring_control) { + self.mirroring_mode = if ((value & 0x80) != 0) .single_screen_upper else .single_screen_lower; + } + return true; + } + return false; +} + +pub fn ppuRead(self: *const Unrom512, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + const total_8k_banks = @max(1, chr_ram.len / 0x2000); + const bank = @as(usize, self.chr_bank) % total_8k_banks; + const offset = (bank * 0x2000) + address; + if (offset < chr_ram.len) return chr_ram[offset]; + } else if (chr_rom.len > 0) { + const total_8k_banks = @max(1, chr_rom.len / 0x2000); + const bank = @as(usize, self.chr_bank) % total_8k_banks; + const offset = (bank * 0x2000) + address; + if (offset < chr_rom.len) return chr_rom[offset]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(self: *Unrom512, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + const total_8k_banks = @max(1, chr_ram.len / 0x2000); + const bank = @as(usize, self.chr_bank) % total_8k_banks; + const offset = (bank * 0x2000) + address; + if (offset < chr_ram.len) { + chr_ram[offset] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Unrom512) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Unrom512) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Unrom512, _: u16) void {} + +test "unrom512 bank switching" { + var un = Unrom512.init(.single_screen_lower); + var ram: [0x2000]u8 = undefined; + + // Write: PRG bank 5, CHR bank 2, Upper screen mirroring + _ = un.cpuWrite(0x8000, 0x80 | (2 << 5) | 5, &ram); + try std.testing.expectEqual(@as(u8, 5), un.prg_bank); + try std.testing.expectEqual(@as(u8, 2), un.chr_bank); + try std.testing.expectEqual(Mirroring.single_screen_upper, un.mirroring()); +} diff --git a/src/system/nes/mapper/uxrom.zig b/src/system/nes/mapper/uxrom.zig new file mode 100644 index 0000000..0781c80 --- /dev/null +++ b/src/system/nes/mapper/uxrom.zig @@ -0,0 +1,83 @@ +const std = @import("std"); +const common = @import("../common.zig"); +const Mirroring = common.Mirroring; + +pub const Uxrom = @This(); + +prg_bank: u8 = 0, +mirroring_mode: Mirroring, + +pub fn init(initial_mirroring: Mirroring) Uxrom { + return .{ .mirroring_mode = initial_mirroring }; +} + +pub fn cpuRead(self: *const Uxrom, address: u16, prg_rom: []const u8, prg_ram: []const u8) ?u8 { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) return prg_ram[offset]; + return 0; + } + if (address >= 0x8000 and address <= 0xffff) { + const total_16k_banks = @max(1, prg_rom.len / 0x4000); + var bank: usize = 0; + if (address < 0xc000) { + bank = @as(usize, self.prg_bank) % total_16k_banks; + const offset = (bank * 0x4000) + (address - 0x8000); + if (offset < prg_rom.len) return prg_rom[offset]; + } else { + bank = total_16k_banks - 1; + const offset = (bank * 0x4000) + (address - 0xc000); + if (offset < prg_rom.len) return prg_rom[offset]; + } + return 0; + } + return null; +} + +pub fn cpuWrite(self: *Uxrom, address: u16, value: u8, prg_ram: []u8) bool { + if (address >= 0x6000 and address <= 0x7fff) { + const offset = address - 0x6000; + if (offset < prg_ram.len) { + prg_ram[offset] = value; + return true; + } + } + if (address >= 0x8000 and address <= 0xffff) { + // ponytail: mask up to 32 banks (512KB) for UOROM / UNROM + self.prg_bank = value & 0x1f; + return true; + } + return false; +} + +pub fn ppuRead(_: *const Uxrom, address: u16, chr_rom: []const u8, chr_ram: []const u8, chr_is_ram: bool) ?u8 { + if (address < 0x2000) { + if (chr_is_ram) { + if (address < chr_ram.len) return chr_ram[address]; + } else { + if (address < chr_rom.len) return chr_rom[address]; + } + return 0; + } + return null; +} + +pub fn ppuWrite(_: *Uxrom, address: u16, value: u8, chr_ram: []u8, chr_is_ram: bool) bool { + if (address < 0x2000 and chr_is_ram) { + if (address < chr_ram.len) { + chr_ram[address] = value; + return true; + } + } + return false; +} + +pub fn mirroring(self: *const Uxrom) Mirroring { + return self.mirroring_mode; +} + +pub fn irqLine(_: *const Uxrom) bool { + return false; +} + +pub fn notifyPpuAddress(_: *Uxrom, _: u16) void {} diff --git a/src/system/nes/ppu/control.zig b/src/system/nes/ppu/control.zig new file mode 100644 index 0000000..8b4aa31 --- /dev/null +++ b/src/system/nes/ppu/control.zig @@ -0,0 +1,95 @@ +// PPUCTRL +// +// VPHB SINN + +pub const Control = @This(); + +raw: u8 = 0, + +pub fn baseNametable(self: Control) u2 { + return @truncate(self.raw & 0x03); +} + +pub fn baseNametableAddress(self: Control) u16 { + return 0x2000 + + (@as(u16, self.baseNametable()) * 0x400); +} + +pub fn vramIncrement(self: Control) u8 { + return if ((self.raw & 0x04) != 0) + 32 + else + 1; +} + +pub fn sprite8x8PatternAddress(self: Control) u16 { + return if ((self.raw & 0x08) != 0) + 0x1000 + else + 0x0000; +} + +/// Computes pattern table address for a sprite tile (supports both 8x8 and 8x16 modes). +pub fn spritePatternAddressForTile(self: Control, tile: u8, sprite_y_offset: u4) u16 { + if (self.spriteHeight() == 8) { + return self.sprite8x8PatternAddress() | (@as(u16, tile) << 4) | (sprite_y_offset & 7); + } else { + const bank: u16 = @as(u16, tile & 1) << 12; + var tile_index: u16 = tile & 0xfe; + if (sprite_y_offset >= 8) { + tile_index += 1; + } + return bank | (tile_index << 4) | (sprite_y_offset & 7); + } +} + +pub fn backgroundPatternAddress(self: Control) u16 { + return if ((self.raw & 0x10) != 0) + 0x1000 + else + 0x0000; +} + +pub fn spriteHeight(self: Control) u8 { + return if ((self.raw & 0x20) != 0) + 16 + else + 8; +} + +pub fn masterSlave(self: Control) bool { + return (self.raw & 0x40) != 0; +} + +pub fn nmiEnabled(self: Control) bool { + return (self.raw & 0x80) != 0; +} + +const std = @import("std"); + +test "8x8 sprite pattern address" { + const c0: Control = .{ .raw = 0x00 }; // 8x8, pattern table 0 + try std.testing.expectEqual(@as(u16, 0x0000), c0.sprite8x8PatternAddress()); + try std.testing.expectEqual(@as(u16, 0x0143), c0.spritePatternAddressForTile(0x14, 3)); + + const c1: Control = .{ .raw = 0x08 }; // 8x8, pattern table 1 ($1000) + try std.testing.expectEqual(@as(u16, 0x1000), c1.sprite8x8PatternAddress()); + try std.testing.expectEqual(@as(u16, 0x1143), c1.spritePatternAddressForTile(0x14, 3)); +} + +test "8x16 sprite pattern address ignores bit 3 and uses tile bit 0" { + const c: Control = .{ .raw = 0x28 }; // 8x16, bit 3 set (ignored!) + try std.testing.expectEqual(@as(u8, 16), c.spriteHeight()); + + // Tile 0x20: bit 0 is 0 -> bank 0 ($0000). Top half (offset 0..7). + try std.testing.expectEqual(@as(u16, 0x0203), c.spritePatternAddressForTile(0x20, 3)); + + // Tile 0x20: Bottom half (offset 8..15) -> tile index becomes 0x21! + try std.testing.expectEqual(@as(u16, 0x0212), c.spritePatternAddressForTile(0x20, 10)); + + // Tile 0x25: bit 0 is 1 -> bank 1 ($1000). Tile index & 0xFE = 0x24. Top half. + try std.testing.expectEqual(@as(u16, 0x1245), c.spritePatternAddressForTile(0x25, 5)); + + // Tile 0x25: Bottom half -> tile index & 0xFE + 1 = 0x25. + try std.testing.expectEqual(@as(u16, 0x1254), c.spritePatternAddressForTile(0x25, 12)); +} diff --git a/src/system/nes/ppu/mask.zig b/src/system/nes/ppu/mask.zig new file mode 100644 index 0000000..c1b7a0b --- /dev/null +++ b/src/system/nes/ppu/mask.zig @@ -0,0 +1,76 @@ +// PPUMASK +// +// BGRs bMmG +pub const Mask = @This(); + +raw: u8 = 0, + +pub fn grayscale(self: Mask) bool { + return (self.raw & 0x01) != 0; +} + +pub fn showBackgroundLeft(self: Mask) bool { + return (self.raw & 0x02) != 0; +} + +pub fn showSpritesLeft(self: Mask) bool { + return (self.raw & 0x04) != 0; +} + +pub fn showBackground(self: Mask) bool { + return (self.raw & 0x08) != 0; +} + +pub fn showSprites(self: Mask) bool { + return (self.raw & 0x10) != 0; +} + +pub fn renderingEnabled(self: Mask) bool { + return self.showBackground() or self.showSprites(); +} + +pub fn emphasizeRed(self: Mask) bool { + return (self.raw & 0x20) != 0; +} + +pub fn emphasizeGreen(self: Mask) bool { + return (self.raw & 0x40) != 0; +} + +pub fn emphasizeBlue(self: Mask) bool { + return (self.raw & 0x80) != 0; +} + +pub fn isEmphasizeRed(self: Mask, is_pal: bool) bool { + return if (is_pal) (self.raw & 0x40) != 0 else (self.raw & 0x20) != 0; +} + +pub fn isEmphasizeGreen(self: Mask, is_pal: bool) bool { + return if (is_pal) (self.raw & 0x20) != 0 else (self.raw & 0x40) != 0; +} + +pub fn isEmphasizeBlue(self: Mask, _: bool) bool { + return (self.raw & 0x80) != 0; +} + +pub fn emphasis(self: Mask) u3 { + return @truncate(self.raw >> 5); +} + +const std = @import("std"); + +test "mask region-aware color emphasis" { + // Bit 5 set, Bit 6 clear + const m1: Mask = .{ .raw = 0x20 }; + try std.testing.expect(m1.isEmphasizeRed(false)); // NTSC: bit 5 is Red + try std.testing.expect(!m1.isEmphasizeGreen(false)); + try std.testing.expect(!m1.isEmphasizeRed(true)); // PAL: bit 5 is Green! + try std.testing.expect(m1.isEmphasizeGreen(true)); + + // Bit 6 set, Bit 5 clear + const m2: Mask = .{ .raw = 0x40 }; + try std.testing.expect(!m2.isEmphasizeRed(false)); + try std.testing.expect(m2.isEmphasizeGreen(false)); // NTSC: bit 6 is Green + try std.testing.expect(m2.isEmphasizeRed(true)); // PAL: bit 6 is Red! + try std.testing.expect(!m2.isEmphasizeGreen(true)); +} diff --git a/src/system/nes/ppu/palette.zig b/src/system/nes/ppu/palette.zig new file mode 100644 index 0000000..39288b0 --- /dev/null +++ b/src/system/nes/ppu/palette.zig @@ -0,0 +1,73 @@ +const std = @import("std"); + +pub const Palette = @This(); + +ram: [32]u8 = [_]u8{0} ** 32, + +pub fn init() Palette { + return .{}; +} + +pub fn reset(self: *Palette) void { + @memset(&self.ram, 0); +} + +/// Mirrors addresses $3F00..$3FFF down to the 32 physical palette entries, +/// applying special mirrors for $10, $14, $18, $1C to $00, $04, $08, $0C. +pub inline fn mirrorAddress(address: u16) u5 { + var index: u5 = @truncate(address & 0x1f); + if ((index & 0x13) == 0x10) { + index &= 0x0f; + } + return index; +} + +pub fn read(self: *const Palette, address: u16) u8 { + return self.ram[mirrorAddress(address)]; +} + +pub fn write(self: *Palette, address: u16, value: u8) void { + self.ram[mirrorAddress(address)] = value & 0x3f; +} + +/// Canonical 64-color 2C02 NTSC palette (RGBA8888). +pub const default_palette = [64]u32{ + 0x666666FF, 0x002A88FF, 0x1412A7FF, 0x3B00A4FF, 0x5C007EFF, 0x6E0040FF, 0x6C0600FF, 0x561D00FF, + 0x333500FF, 0x0B4800FF, 0x005200FF, 0x004F08FF, 0x00404DFF, 0x000000FF, 0x000000FF, 0x000000FF, + 0xADADADFF, 0x155FD9FF, 0x4240FFFF, 0x7527FEFF, 0xA01ACCFF, 0xB71E7BFF, 0xB53120FF, 0x994E00FF, + 0x6B6D00FF, 0x388700FF, 0x0C9300FF, 0x008F32FF, 0x007C8DFF, 0x000000FF, 0x000000FF, 0x000000FF, + 0xFFFFFFFF, 0x64B0FFFF, 0x9290FFFF, 0xC676FFFF, 0xF36AFFFF, 0xFE6ECCFF, 0xFE8170FF, 0xEA9E22FF, + 0xBCBE00FF, 0x88D800FF, 0x5CE430FF, 0x45E082FF, 0x48CDDEFF, 0x4F4F4FFF, 0x000000FF, 0x000000FF, + 0xFFFFFFFF, 0xC0E0FFFF, 0xD3D2FFFF, 0xE8C1FFFF, 0xFBC0FFFF, 0xFEC2EFFF, 0xFECAC4FF, 0xF6D5A1FF, + 0xE3DF9EFF, 0xCEEB9EFF, 0xBCF3B9FF, 0xB2F1D4FF, 0xB4EBF8FF, 0xB8B8B8FF, 0x000000FF, 0x000000FF, +}; + +test "palette mirroring and special backdrop mirrors" { + var pal = Palette.init(); + + // Write backdrop color at $3F00 + pal.write(0x3F00, 0x0F); + try std.testing.expectEqual(@as(u8, 0x0F), pal.read(0x3F00)); + + // Reading $3F10 should mirror $3F00! + try std.testing.expectEqual(@as(u8, 0x0F), pal.read(0x3F10)); + + // Writing to $3F10 mirrors back to $3F00 + pal.write(0x3F10, 0x30); + try std.testing.expectEqual(@as(u8, 0x30), pal.read(0x3F00)); + try std.testing.expectEqual(@as(u8, 0x30), pal.read(0x3F10)); + + // Test mirrors $3F14 -> $3F04, $3F18 -> $3F08, $3F1C -> $3F0C + pal.write(0x3F04, 0x15); + try std.testing.expectEqual(@as(u8, 0x15), pal.read(0x3F14)); + + pal.write(0x3F08, 0x27); + try std.testing.expectEqual(@as(u8, 0x27), pal.read(0x3F18)); + + pal.write(0x3F0C, 0x39); + try std.testing.expectEqual(@as(u8, 0x39), pal.read(0x3F1C)); + + // Values are clamped to 6 bits + pal.write(0x3F01, 0xFF); + try std.testing.expectEqual(@as(u8, 0x3F), pal.read(0x3F01)); +} diff --git a/src/system/nes/ppu/registers.zig b/src/system/nes/ppu/registers.zig new file mode 100644 index 0000000..0b3a767 --- /dev/null +++ b/src/system/nes/ppu/registers.zig @@ -0,0 +1,340 @@ +// PPU Registers ($2000-$2007) +const std = @import("std"); +const Control = @import("control.zig"); +const Mask = @import("mask.zig"); +const common = @import("../common.zig"); + +pub const Registers = @This(); + +control: Control = .{}, +mask: Mask = .{}, +pending_mask: Mask = .{}, +mask_delay: u3 = 0, + +status: u8 = 0, +oam_addr: u8 = 0, +oam: [256]u8 = [_]u8{0} ** 256, + +v: u15 = 0, +t: u15 = 0, +x: u3 = 0, +w: bool = false, + +read_buffer: u8 = 0, +io_bus: u8 = 0, + +pending_v: u15 = 0, +v_commit_delay: u3 = 0, + +suppress_writes_cycles: u32 = 0, + +pub fn init() Registers { + return .{}; +} + +pub fn power(self: *Registers) void { + self.powerRegion(.ntsc); +} + +pub fn powerRegion(self: *Registers, _: common.Region) void { + self.control.raw = 0; + self.mask.raw = 0; + self.pending_mask.raw = 0; + self.mask_delay = 0; + self.status = 0; + self.oam_addr = 0; + self.v = 0; + self.t = 0; + self.x = 0; + self.w = false; + self.read_buffer = 0; + self.io_bus = 0; + self.pending_v = 0; + self.v_commit_delay = 0; + self.suppress_writes_cycles = 29658; +} + +pub fn reset(self: *Registers) void { + self.resetRegion(.ntsc); +} + +pub fn resetRegion(self: *Registers, _: common.Region) void { + self.control.raw = 0; + self.mask.raw = 0; + self.pending_mask.raw = 0; + self.mask_delay = 0; + self.w = false; + self.read_buffer = 0; +} + +pub fn tickCpuCycle(self: *Registers) void { + if (self.suppress_writes_cycles > 0) { + self.suppress_writes_cycles -= 1; + } +} + +pub fn clockDot(self: *Registers) void { + if (self.mask_delay > 0) { + self.mask_delay -= 1; + if (self.mask_delay == 0) { + self.mask = self.pending_mask; + } + } + if (self.v_commit_delay > 0) { + self.v_commit_delay -= 1; + if (self.v_commit_delay == 0) { + self.v = self.pending_v; + } + } +} + +pub fn vblank(self: *const Registers) bool { + return (self.status & 0x80) != 0; +} + +pub fn setVblank(self: *Registers, val: bool) void { + if (val) { + self.status |= 0x80; + } else { + self.status &= ~@as(u8, 0x80); + } +} + +pub fn clearRenderingFlags(self: *Registers) void { + self.status &= ~@as(u8, 0xe0); +} + +pub fn setSpriteZeroHit(self: *Registers, val: bool) void { + if (val) self.status |= 0x40 else self.status &= ~@as(u8, 0x40); +} + +pub fn setSpriteOverflow(self: *Registers, val: bool) void { + if (val) self.status |= 0x20 else self.status &= ~@as(u8, 0x20); +} + +pub fn spriteHit(self: *const Registers) bool { + return (self.status & 0x40) != 0; +} + +pub fn nmiLine(self: *const Registers) bool { + return self.control.nmiEnabled() and self.vblank(); +} + +pub fn takeNmi(self: *Registers) bool { + if (self.nmiLine()) { + return true; + } + return false; +} + +pub fn currentVramAddress(self: *const Registers) u16 { + return self.v; +} + +pub fn copyVertical(self: *Registers) void { + self.v = (self.v & ~@as(u15, 0x7be0)) | (self.t & 0x7be0); +} + +pub fn copyHorizontal(self: *Registers) void { + self.v = (self.v & ~@as(u15, 0x041f)) | (self.t & 0x041f); +} + +pub fn incrementCoarseX(self: *Registers) void { + if ((self.v & 0x001f) == 31) { + self.v &= ~@as(u15, 0x001f); + self.v ^= 0x0400; + } else { + self.v += 1; + } +} + +pub fn incrementY(self: *Registers) void { + if ((self.v & 0x7000) != 0x7000) { + self.v += 0x1000; + } else { + self.v &= ~@as(u15, 0x7000); + var y = (self.v & 0x03e0) >> 5; + if (y == 29) { + y = 0; + self.v ^= 0x0800; + } else if (y == 31) { + y = 0; + } else { + y += 1; + } + self.v = (self.v & ~@as(u15, 0x03e0)) | (y << 5); + } +} + +pub fn cpuRead(self: *Registers, bus: anytype, address: u16, rendering: bool) ?u8 { + _ = rendering; + const reg = address & 7; + return switch (reg) { + 2 => blk: { + const res = (self.status & 0xe0) | (self.io_bus & 0x1f); + self.status &= ~@as(u8, 0x80); + self.w = false; + self.io_bus = res; + break :blk res; + }, + 4 => blk: { + const val = self.oam[self.oam_addr]; + self.io_bus = val; + break :blk val; + }, + 7 => blk: { + const addr = self.v & 0x3fff; + var val: u8 = undefined; + if (addr >= 0x3f00) { + val = (self.io_bus & 0xc0) | (bus.read(addr) & 0x3f); + self.read_buffer = bus.read(addr - 0x1000); + } else { + val = self.read_buffer; + self.read_buffer = bus.read(addr); + } + self.v = (self.v +% self.control.vramIncrement()) & 0x3fff; + self.io_bus = val; + break :blk val; + }, + else => self.io_bus, + }; +} + +pub fn cpuWrite(self: *Registers, bus: anytype, address: u16, value: u8, rendering: bool) bool { + _ = rendering; + self.io_bus = value; + const reg = address & 7; + + const suppress = self.suppress_writes_cycles > 0; + if (suppress and (reg == 0 or reg == 1 or reg == 5 or reg == 6)) { + return true; + } + + switch (reg) { + 0 => { + const old_nmi = self.nmiLine(); + self.control.raw = value; + self.t = (self.t & 0x73ff) | (@as(u15, value & 3) << 10); + if (!old_nmi and self.nmiLine()) { + // Trigger immediate NMI + } + }, + 1 => { + self.pending_mask.raw = value; + self.mask_delay = 4; + }, + 3 => self.oam_addr = value, + 4 => { + var val = value; + if ((self.oam_addr & 3) == 2) { + val &= 0xe3; // mask unused bits + } + self.oam[self.oam_addr] = val; + self.oam_addr +%= 1; + }, + 5 => { + if (!self.w) { + self.t = (self.t & 0x7fe0) | (value >> 3); + self.x = @truncate(value & 7); + self.w = true; + } else { + self.t = (self.t & 0x0c1f) | (@as(u15, value & 7) << 12) | (@as(u15, value & 0xf8) << 2); + self.w = false; + } + }, + 6 => { + if (!self.w) { + self.t = (self.t & 0x00ff) | (@as(u15, value & 0x3f) << 8); + self.w = true; + } else { + self.t = (self.t & 0x7f00) | value; + self.pending_v = self.t; + self.v_commit_delay = 2; + self.w = false; + } + }, + 7 => { + const addr = self.v & 0x3fff; + bus.write(addr, value); + self.v = (self.v +% self.control.vramIncrement()) & 0x3fff; + }, + else => {}, + } + return true; +} + +// ponytail: consolidated PPU registers test suite +pub const TestBus = struct { + memory: [0x10000]u8 = [_]u8{0} ** 0x10000, + pub fn read(self: *TestBus, address: u16) u8 { return self.memory[address]; } + pub fn write(self: *TestBus, address: u16, value: u8) void { self.memory[address] = value; } +}; + +test "ppu registers $2000-$2007 control, scroll, address and buffered reads" { + var regs = Registers.init(); + var bus = TestBus{}; + + // $2000 & NMI + _ = regs.cpuWrite(&bus, 0x2000, 0b0000_0011, false); + try std.testing.expectEqual(@as(u15, 0x0c00), regs.t & 0x0c00); + regs.setVblank(true); + _ = regs.cpuWrite(&bus, 0x2000, 0x80, false); + try std.testing.expect(regs.takeNmi()); + + // $2002 status & latch reset + regs.status = 0xe0; + regs.io_bus = 0x1b; + regs.w = true; + try std.testing.expectEqual(@as(u8, 0xfb), regs.cpuRead(&bus, 0x2002, false).?); + try std.testing.expect(!regs.vblank() and !regs.w); + + // $2005 coarse & fine scroll + regs = Registers.init(); + _ = regs.cpuWrite(&bus, 0x2005, 0b10101_011, false); + try std.testing.expectEqual(@as(u15, 0b10101), regs.t & 0x001f); + try std.testing.expectEqual(@as(u3, 3), regs.x); + + // $2006 address latching & delayed commit + regs = Registers.init(); + _ = regs.cpuWrite(&bus, 0x2006, 0x23, false); + _ = regs.cpuWrite(&bus, 0x2006, 0x45, false); + try std.testing.expectEqual(@as(u15, 0x2345), regs.t); + regs.clockDot(); + regs.clockDot(); + try std.testing.expectEqual(@as(u15, 0x2345), regs.v); + + // $2007 buffered VRAM & immediate palette reads + regs = Registers.init(); + bus.memory[0x2000] = 0xaa; + regs.v = 0x2000; + regs.read_buffer = 0x55; + try std.testing.expectEqual(@as(u8, 0x55), regs.cpuRead(&bus, 0x2007, false).?); + try std.testing.expectEqual(@as(u8, 0xaa), regs.read_buffer); +} + +test "ppu oam, mask rendering delay, and power-on write suppression" { + var regs = Registers.init(); + var bus = TestBus{}; + + // OAM address auto-increment + _ = regs.cpuWrite(&bus, 0x2003, 0x10, false); + _ = regs.cpuWrite(&bus, 0x2004, 0xab, false); + try std.testing.expectEqual(@as(u8, 0xab), regs.oam[0x10]); + try std.testing.expectEqual(@as(u8, 0x11), regs.oam_addr); + + // PPUMASK 3-4 dot delay + regs = Registers.init(); + _ = regs.cpuWrite(&bus, 0x2001, 0x18, false); + try std.testing.expect(!regs.mask.renderingEnabled()); + for (0..4) |_| regs.clockDot(); + try std.testing.expect(regs.mask.renderingEnabled()); + + // Power-on write suppression + regs = Registers.init(); + regs.power(); + _ = regs.cpuWrite(&bus, 0x2000, 0x80, false); + try std.testing.expectEqual(@as(u8, 0), regs.control.raw); + for (0..29658) |_| regs.tickCpuCycle(); + _ = regs.cpuWrite(&bus, 0x2000, 0x80, false); + try std.testing.expectEqual(@as(u8, 0x80), regs.control.raw); +} diff --git a/src/system/nes/ppu/root.zig b/src/system/nes/ppu/root.zig new file mode 100644 index 0000000..8b1aaa2 --- /dev/null +++ b/src/system/nes/ppu/root.zig @@ -0,0 +1,542 @@ +const std = @import("std"); + +pub const Control = @import("control.zig"); +pub const Mask = @import("mask.zig"); +pub const Registers = @import("registers.zig"); +pub const Palette = @import("palette.zig").Palette; +pub const default_palette = @import("palette.zig").default_palette; + +pub const common = @import("../common.zig"); +pub const Region = common.Region; + +pub const SpriteUnit = struct { + pattern_low: u8 = 0, + pattern_high: u8 = 0, + x_counter: u8 = 0, + attribute: u8 = 0, + is_sprite_zero: bool = false, +}; + +pub const Ppu = @This(); + +region: Region = .ntsc, + +registers: Registers = .{}, +palette: Palette = .{}, + +// Frame timing counters +dot: u16 = 0, +scanline: u16 = 0, +odd_frame: bool = false, +frame_count: u64 = 0, +frame_complete: bool = false, + +// Framebuffer: 256 x 240 containing 6-bit NES palette indices +framebuffer: [common.screen_width * common.screen_height]u8 = [_]u8{0} ** (common.screen_width * common.screen_height), + +// Background fetch pipeline latches +next_tile_id: u8 = 0, +next_tile_attribute: u8 = 0, +next_pattern_low: u8 = 0, +next_pattern_high: u8 = 0, + +// Background 16-bit shift registers +pattern_shift_low: u16 = 0, +pattern_shift_high: u16 = 0, +attribute_shift_low: u16 = 0, +attribute_shift_high: u16 = 0, + +// Sprite evaluation & fetching +secondary_oam: [32]u8 = [_]u8{0xff} ** 32, +sprite_count: u4 = 0, +sprite_zero_in_secondary: bool = false, + +sprite_units: [8]SpriteUnit = [_]SpriteUnit{.{}} ** 8, +active_sprite_units: [8]SpriteUnit = [_]SpriteUnit{.{}} ** 8, +active_sprite_count: u4 = 0, +sprite_zero_active: bool = false, + +pub fn init(region: Region) Ppu { + return .{ + .region = region, + .registers = Registers.init(), + .palette = Palette.init(), + }; +} + +pub fn power(self: *Ppu) void { + self.registers.powerRegion(self.region); + self.palette.reset(); + self.dot = 0; + self.scanline = 0; + self.odd_frame = false; + self.frame_count = 0; + self.frame_complete = false; + @memset(&self.framebuffer, 0); + self.resetPipeline(); +} + +pub fn reset(self: *Ppu) void { + self.registers.resetRegion(self.region); + self.dot = 0; + self.scanline = 0; + self.frame_complete = false; + self.resetPipeline(); +} + +fn resetPipeline(self: *Ppu) void { + self.next_tile_id = 0; + self.next_tile_attribute = 0; + self.next_pattern_low = 0; + self.next_pattern_high = 0; + self.pattern_shift_low = 0; + self.pattern_shift_high = 0; + self.attribute_shift_low = 0; + self.attribute_shift_high = 0; + @memset(&self.secondary_oam, 0xff); + self.sprite_count = 0; + self.sprite_zero_in_secondary = false; + self.active_sprite_count = 0; + self.sprite_zero_active = false; +} + +pub inline fn preRenderScanline(self: *const Ppu) u16 { + return self.region.preRenderScanline(); +} + +pub inline fn totalScanlines(self: *const Ppu) u16 { + return self.region.totalScanlines(); +} + +pub inline fn isRendering(self: *const Ppu) bool { + const visible_or_prerender = (self.scanline < 240 or self.scanline == self.preRenderScanline()); + return visible_or_prerender and self.registers.mask.renderingEnabled(); +} + +pub fn nmiLine(self: *const Ppu) bool { + return self.registers.nmiLine(); +} + +// CPU register interface +pub fn cpuRead(self: *Ppu, bus: anytype, address: u16) ?u8 { + // VBlank / NMI race condition on reading $2002 around scanline 241 dot 1: + // Reading on scanline 241 dot 0 clears VBlank before it can set NMI. + if (address == 0x2002 and self.scanline == 241 and self.dot == 0) { + // Suppress VBlank flag set on dot 1 + var val = self.registers.cpuRead(bus, address, self.isRendering()); + if (val) |*v| { + v.* &= 0x7f; + } + return val; + } + return self.registers.cpuRead(bus, address, self.isRendering()); +} + +pub fn cpuWrite(self: *Ppu, bus: anytype, address: u16, value: u8) bool { + return self.registers.cpuWrite(bus, address, value, self.isRendering()); +} + +// Main 341-dot PPU Clock +pub fn clock(self: *Ppu, bus: anytype) void { + // Advance delayed register commits (~1-2 dots for $2006, ~3-4 dots for $2001) + self.registers.clockDot(); + + const prerender = self.preRenderScanline(); + const visible = self.scanline < 240; + const rendering = self.registers.mask.renderingEnabled(); + + // 1. Scanline-specific events + if (self.scanline == 241 and self.dot == 1) { + // VBlank start + self.registers.setVblank(true); + } else if (self.scanline == prerender and self.dot == 1) { + // Pre-render clear flags + self.registers.clearRenderingFlags(); + } + + // Pre-render vertical scroll copy (dots 280..304) + if (self.scanline == prerender and self.dot >= 280 and self.dot <= 304 and rendering) { + self.registers.copyVertical(); + } + + // 2. Background and Sprite Rendering operations + if (visible or self.scanline == prerender) { + self.stepRendering(bus); + } + + // 3. Pixel output to framebuffer (visible scanlines dots 1..256) + if (visible and self.dot >= 1 and self.dot <= 256) { + self.renderPixel(); + } + + // 4. Dot and Scanline Advance + self.dot += 1; + + // Odd-frame skip on NTSC pre-render scanline (dot 339 -> 0) + if (self.region == .ntsc and self.odd_frame and rendering and self.scanline == prerender and self.dot == 340) { + self.dot = 0; + self.scanline = 0; + self.odd_frame = !self.odd_frame; + self.frame_complete = true; + self.frame_count += 1; + return; + } + + if (self.dot >= 341) { + self.dot = 0; + self.scanline += 1; + + if (self.scanline >= self.totalScanlines()) { + self.scanline = 0; + self.odd_frame = !self.odd_frame; + self.frame_complete = true; + self.frame_count += 1; + } + } +} + +fn stepRendering(self: *Ppu, bus: anytype) void { + const rendering = self.registers.mask.renderingEnabled(); + + // Background fetching during dots 1..256 and 321..336 + if ((self.dot >= 1 and self.dot <= 256) or (self.dot >= 321 and self.dot <= 336)) { + // Shift background registers on every dot + if (rendering) { + self.pattern_shift_low <<= 1; + self.pattern_shift_high <<= 1; + self.attribute_shift_low <<= 1; + self.attribute_shift_high <<= 1; + } + + const step = (self.dot - 1) & 7; + switch (step) { + 0 => { + // Fetch Nametable byte + const nt_addr: u16 = 0x2000 | (self.registers.v & 0x0fff); + self.next_tile_id = bus.read(nt_addr); + }, + 2 => { + // Fetch Attribute byte + const v = self.registers.v; + const attr_addr: u16 = 0x23c0 | + (v & 0x0c00) | + ((v >> 4) & 0x38) | + ((v >> 2) & 0x07); + const attr_byte = bus.read(attr_addr); + // Determine 2-bit attribute quadrant based on coarse X and Y bit 1 + const shift: u3 = @truncate(((v >> 4) & 0x04) | (v & 0x02)); + self.next_tile_attribute = (attr_byte >> shift) & 0x03; + }, + 4 => { + // Fetch Pattern Table Low byte + const bg_base = self.registers.control.backgroundPatternAddress(); + const fine_y: u16 = (self.registers.v >> 12) & 0x07; + const pat_addr: u16 = bg_base | (@as(u16, self.next_tile_id) << 4) | fine_y; + self.next_pattern_low = bus.read(pat_addr); + }, + 6 => { + // Fetch Pattern Table High byte + const bg_base = self.registers.control.backgroundPatternAddress(); + const fine_y: u16 = (self.registers.v >> 12) & 0x07; + const pat_addr: u16 = bg_base | (@as(u16, self.next_tile_id) << 4) | fine_y | 8; + self.next_pattern_high = bus.read(pat_addr); + }, + 7 => { + // Load shift registers and increment coarse X + if (rendering) { + self.loadShiftRegisters(); + self.registers.incrementCoarseX(); + } + }, + else => {}, + } + } + + // Dot 256: Increment Y + if (self.dot == 256 and rendering) { + self.registers.incrementY(); + } + + // Dot 257: Copy horizontal scroll + if (self.dot == 257 and rendering) { + self.registers.copyHorizontal(); + } + + // Dots 337..340: Two dummy nametable fetches (vital for mapper IRQ / A12 observation) + if (self.dot == 337 or self.dot == 339) { + const nt_addr: u16 = 0x2000 | (self.registers.v & 0x0fff); + _ = bus.read(nt_addr); + } + + // Sprite evaluation and fetching (visible scanlines only) + if (self.scanline < 240) { + self.stepSpriteLogic(bus); + } +} + +fn loadShiftRegisters(self: *Ppu) void { + self.pattern_shift_low = (self.pattern_shift_low & 0xff00) | self.next_pattern_low; + self.pattern_shift_high = (self.pattern_shift_high & 0xff00) | self.next_pattern_high; + + const attr_lo: u8 = if ((self.next_tile_attribute & 1) != 0) 0xff else 0x00; + const attr_hi: u8 = if ((self.next_tile_attribute & 2) != 0) 0xff else 0x00; + self.attribute_shift_low = (self.attribute_shift_low & 0xff00) | attr_lo; + self.attribute_shift_high = (self.attribute_shift_high & 0xff00) | attr_hi; +} + +fn stepSpriteLogic(self: *Ppu, bus: anytype) void { + const rendering = self.registers.mask.renderingEnabled(); + + // Dots 1..64: Clear secondary OAM + if (self.dot == 1) { + @memset(&self.secondary_oam, 0xff); + self.sprite_count = 0; + self.sprite_zero_in_secondary = false; + } + + // Dots 65..256: Sprite Evaluation for next scanline + if (self.dot == 65 and rendering) { + const sprite_height = self.registers.control.spriteHeight(); + const target_scanline = self.scanline; + + var i: usize = 0; + while (i < 64) : (i += 1) { + const y = self.registers.oam[i * 4]; + if (target_scanline >= y and target_scanline < y + sprite_height) { + if (self.sprite_count < 8) { + const dst = @as(usize, self.sprite_count) * 4; + @memcpy(self.secondary_oam[dst .. dst + 4], self.registers.oam[i * 4 .. i * 4 + 4]); + if (i == 0) { + self.sprite_zero_in_secondary = true; + } + self.sprite_count += 1; + } else { + // ponytail: 8-sprite limit reached; set overflow flag + self.registers.setSpriteOverflow(true); + break; + } + } + } + } + + // Dots 257..320: OAMADDR is forced to 0 + if (self.dot >= 257 and self.dot <= 320 and rendering) { + self.registers.oam_addr = 0; + } + + // Dots 257..320: Sprite pattern fetching for the 8 evaluated sprites + if (self.dot == 257) { + const sprite_height = self.registers.control.spriteHeight(); + const target_scanline = self.scanline; + + for (0..8) |s| { + if (s < self.sprite_count) { + const y = self.secondary_oam[s * 4]; + const tile = self.secondary_oam[s * 4 + 1]; + const attr = self.secondary_oam[s * 4 + 2]; + const x = self.secondary_oam[s * 4 + 3]; + + var row: u4 = @truncate(target_scanline - y); + if ((attr & 0x80) != 0) { + // Vertical flip + row = @truncate(sprite_height - 1 - row); + } + + const addr_lo = self.registers.control.spritePatternAddressForTile(tile, row); + const addr_hi = addr_lo + 8; + + var lo = bus.read(addr_lo); + var hi = bus.read(addr_hi); + + if ((attr & 0x40) != 0) { + // Horizontal flip + lo = @bitReverse(lo); + hi = @bitReverse(hi); + } + + self.sprite_units[s] = .{ + .pattern_low = lo, + .pattern_high = hi, + .x_counter = x, + .attribute = attr, + .is_sprite_zero = (s == 0 and self.sprite_zero_in_secondary), + }; + } else { + // Dummy sprite fetches + _ = bus.read(self.registers.control.sprite8x8PatternAddress()); + _ = bus.read(self.registers.control.sprite8x8PatternAddress() + 8); + self.sprite_units[s] = .{}; + } + } + } + + // At dot 0 of the next scanline, commit fetched sprite units + if (self.dot == 340) { + self.active_sprite_units = self.sprite_units; + self.active_sprite_count = self.sprite_count; + self.sprite_zero_active = self.sprite_zero_in_secondary; + } +} + +fn renderPixel(self: *Ppu) void { + const pixel_x = self.dot - 1; + const pixel_y = self.scanline; + + var bg_color: u2 = 0; + var bg_palette: u2 = 0; + + if (self.registers.mask.showBackground()) { + const fine_x = self.registers.x; + const bit_mux = @as(u16, 0x8000) >> fine_x; + + const p0: u2 = if ((self.pattern_shift_low & bit_mux) != 0) 1 else 0; + const p1: u2 = if ((self.pattern_shift_high & bit_mux) != 0) 2 else 0; + bg_color = p0 | p1; + + const a0: u2 = if ((self.attribute_shift_low & bit_mux) != 0) 1 else 0; + const a1: u2 = if ((self.attribute_shift_high & bit_mux) != 0) 2 else 0; + bg_palette = a0 | a1; + + // Left 8-pixel clipping + if (pixel_x < 8 and !self.registers.mask.showBackgroundLeft()) { + bg_color = 0; + } + } + + var sprite_color: u2 = 0; + var sprite_palette: u2 = 0; + var sprite_priority: bool = false; + var is_sprite_zero: bool = false; + + if (self.registers.mask.showSprites()) { + for (&self.active_sprite_units) |*unit| { + if (unit.x_counter == 0) { + const sp0: u2 = if ((unit.pattern_low & 0x80) != 0) 1 else 0; + const sp1: u2 = if ((unit.pattern_high & 0x80) != 0) 2 else 0; + const col = sp0 | sp1; + + if (col != 0 and sprite_color == 0) { + sprite_color = col; + sprite_palette = @truncate(unit.attribute & 0x03); + sprite_priority = (unit.attribute & 0x20) != 0; + is_sprite_zero = unit.is_sprite_zero; + } + } + } + + // Left 8-pixel clipping + if (pixel_x < 8 and !self.registers.mask.showSpritesLeft()) { + sprite_color = 0; + } + } + + // Shift active sprite pattern registers and decrement non-zero X counters + for (&self.active_sprite_units) |*unit| { + if (unit.x_counter > 0) { + unit.x_counter -= 1; + } else { + unit.pattern_low <<= 1; + unit.pattern_high <<= 1; + } + } + + // Sprite 0 Hit detection + if (is_sprite_zero and self.sprite_zero_active and bg_color != 0 and sprite_color != 0) { + // Sprite 0 hit cannot occur at X=255 + if (pixel_x < 255) { + self.registers.setSpriteZeroHit(true); + } + } + + // Pixel composition & palette lookup + var palette_addr: u16 = 0x3f00; + if (bg_color == 0 and sprite_color == 0) { + palette_addr = 0x3f00; + } else if (bg_color != 0 and sprite_color == 0) { + palette_addr = 0x3f00 | (@as(u16, bg_palette) << 2) | bg_color; + } else if (bg_color == 0 and sprite_color != 0) { + palette_addr = 0x3f10 | (@as(u16, sprite_palette) << 2) | sprite_color; + } else { + // Both opaque: check priority + if (sprite_priority) { + // Behind background + palette_addr = 0x3f00 | (@as(u16, bg_palette) << 2) | bg_color; + } else { + // In front of background + palette_addr = 0x3f10 | (@as(u16, sprite_palette) << 2) | sprite_color; + } + } + + const color_index = self.palette.read(palette_addr); + self.framebuffer[pixel_y * 256 + pixel_x] = color_index; +} + +/// Helper to convert the 8-bit palette-indexed framebuffer to standard 32-bit RGBA8888 pixels. +pub fn renderToRgba(self: *const Ppu, dest: []u32) void { + std.debug.assert(dest.len >= common.screen_width * common.screen_height); + for (self.framebuffer, 0..) |color_idx, i| { + dest[i] = default_palette[color_idx & 0x3f]; + } +} + +const MockBus = struct { + read_count: usize = 0, + last_address: u16 = 0, + + pub fn read(self: *MockBus, address: u16) u8 { + self.read_count += 1; + self.last_address = address; + if (address < 0x2000) return 0x55; // Pattern data + if (address < 0x3f00) return 0x01; // Nametable / attribute + return 0; + } + + pub fn write(_: *MockBus, _: u16, _: u8) void {} +}; + +// ponytail: consolidated PPU timing and rendering unit test suite +test "ppu scanline progression, vblank flags, odd-frame skip and sprite 0 hit" { + var ppu = Ppu.init(.ntsc); + var bus = MockBus{}; + + // Scanline & dot clock progression + try std.testing.expectEqual(@as(u16, 0), ppu.scanline); + ppu.clock(&bus); + try std.testing.expectEqual(@as(u16, 1), ppu.dot); + + // Run until vblank set on scanline 241 + while (!(ppu.scanline == 241 and ppu.dot == 1)) ppu.clock(&bus); + ppu.clock(&bus); + try std.testing.expect(ppu.registers.vblank()); + + // Odd-frame skipped dot on NTSC rendering + ppu = Ppu.init(.ntsc); + _ = ppu.cpuWrite(&bus, 0x2001, 0x08); + for (0..4) |_| ppu.clock(&bus); + while (!ppu.frame_complete) ppu.clock(&bus); + try std.testing.expect(ppu.odd_frame); + + // Sprite 0 Hit detection + ppu = Ppu.init(.ntsc); + _ = ppu.cpuWrite(&bus, 0x2001, 0x18); + for (0..4) |_| ppu.clock(&bus); + ppu.registers.oam[0] = 10; + ppu.registers.oam[1] = 0; + ppu.registers.oam[2] = 0; + ppu.registers.oam[3] = 10; + ppu.active_sprite_units[0] = .{ + .pattern_low = 0xff, + .pattern_high = 0x00, + .x_counter = 0, + .attribute = 0, + .is_sprite_zero = true, + }; + ppu.sprite_zero_active = true; + ppu.pattern_shift_low = 0xffff; + ppu.pattern_shift_high = 0x0000; + ppu.scanline = 10; + ppu.dot = 11; + + ppu.renderPixel(); + try std.testing.expect(ppu.registers.spriteHit()); +} diff --git a/src/system/nes/root.zig b/src/system/nes/root.zig index e69de29..95a4a80 100644 --- a/src/system/nes/root.zig +++ b/src/system/nes/root.zig @@ -0,0 +1,252 @@ +const std = @import("std"); +const contract = @import("contract"); +const video = contract.video; +const audio = contract.audio; +const input = contract.input; +const storage = contract.storage; +const state = contract.state; +const _cpu = @import("cpu"); + +pub const common = @import("common.zig"); +pub const Region = common.Region; +pub const Mirroring = common.Mirroring; +pub const Button = common.Button; +pub const screen_width = common.screen_width; +pub const screen_height = common.screen_height; + +pub const ppu = @import("ppu/root.zig"); +pub const apu = @import("apu/root.zig"); +pub const bus_mod = @import("bus.zig"); +pub const Bus = bus_mod.Bus; +pub const cartridge_mod = @import("cartridge.zig"); +pub const Cartridge = cartridge_mod.Cartridge; +pub const controller_mod = @import("controller.zig"); +pub const Controller = controller_mod.Controller; +pub const mapper_mod = @import("mapper/root.zig"); + +const Cpu = _cpu.m6502.Cpu(Bus, .ricoh2a03, .cycle); + +pub const Nes = @This(); + +pub const default_palette_565: [64]u16 = blk: { + var table: [64]u16 = undefined; + for (ppu.default_palette, 0..) |rgba, i| { + const r = @as(u16, @truncate((rgba >> 24) & 0xFF)); + const g = @as(u16, @truncate((rgba >> 16) & 0xFF)); + const b = @as(u16, @truncate((rgba >> 8) & 0xFF)); + const r5 = r >> 3; + const g6 = g >> 2; + const b5 = b >> 3; + table[i] = (r5 << 11) | (g6 << 5) | b5; + } + break :blk table; +}; + +pub const spec: contract.SystemSpec = .{ + .name = "Nintendo Entertainment System", + .video_outputs = &.{ + .{ + .max_width = common.screen_width, + .max_height = common.screen_height, + .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, + }, + }, + .save_state = .{ + .max_size = 64 * 1024, + }, +}; + +bus: Bus = undefined, +cpu: Cpu = undefined, +rgb565_framebuffer: [common.screen_width * common.screen_height]u16 = [_]u16{0} ** (common.screen_width * common.screen_height), + +pub fn init(self: *Nes, rom_bytes: []const u8) !void { + const cart = try Cartridge.init(rom_bytes); + self.bus = Bus.init(.ntsc, cart); + self.cpu = Cpu.init(&self.bus); + self.cpu.reset(); +} + +pub fn reset(self: *Nes) void { + self.cpu.bus = &self.bus; + self.bus.reset(); + self.cpu.reset(); +} + +pub fn runFrame(self: *Nes) void { + self.bus.ppu.frame_complete = false; + self.bus.apu.clearSamples(); + + while (!self.bus.ppu.frame_complete) { + if (self.bus.dma_active) { + self.bus.stepDma(); + } else { + _ = self.cpu.step(); + } + } + + // Convert PPU framebuffer to RGB565 for frontend driver + for (self.bus.ppu.framebuffer, 0..) |color_idx, i| { + self.rgb565_framebuffer[i] = default_palette_565[color_idx & 0x3f]; + } +} + +pub fn videoFrame(self: *const Nes, _: usize) video.VideoFrame { + return .{ + .data = std.mem.sliceAsBytes(&self.rgb565_framebuffer), + .width = common.screen_width, + .height = common.screen_height, + .pitch = common.screen_width * 2, + .format = .rgb565, + .frame_number = self.bus.ppu.frame_count, + }; +} + +pub fn audioBuffer(self: *const Nes, _: usize) audio.Buffer { + const samples = self.bus.apu.getSamples(); + return .{ + .data = std.mem.sliceAsBytes(samples), + .frames = samples.len, + .sample_rate = 44_100, + .channels = 1, + .format = .i16, + }; +} + +pub fn setInput(self: *Nes, device_index: usize, device_input: input.DeviceInput) void { + if (device_index < 2) { + self.bus.controllers[device_index].setButtons(@truncate(device_input.buttons)); + } +} + +pub fn storageView(self: *const Nes, index: usize) storage.View { + if (index == 0 and self.bus.cartridge != null) { + return .{ + .data = &self.bus.cartridge.?.prg_ram, + .generation = 0, + }; + } + return .{ .data = &.{}, .generation = 0 }; +} + +pub fn loadStorage(self: *Nes, index: usize, data: []const u8) storage.LoadError!void { + if (index != 0 or self.bus.cartridge == null) 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); +} + +pub fn saveState(self: *const Nes, dest: []u8) state.Error!usize { + const required_size: usize = 8 + 7 + 0x800 + 0x800 + 256 + 32 + 0x2000; + if (dest.len < required_size) return error.BufferTooSmall; + + var offset: usize = 0; + @memcpy(dest[offset .. offset + 8], "NESSTATE"); + offset += 8; + + const regs = self.cpu.registers; + dest[offset] = regs.a; + dest[offset + 1] = regs.x; + dest[offset + 2] = regs.y; + dest[offset + 3] = regs.sp; + dest[offset + 4] = @bitCast(regs.status); + dest[offset + 5] = @truncate(regs.pc); + dest[offset + 6] = @truncate(regs.pc >> 8); + offset += 7; + + @memcpy(dest[offset .. offset + 0x800], &self.bus.ram); + offset += 0x800; + + @memcpy(dest[offset .. offset + 0x800], &self.bus.ciram); + offset += 0x800; + + @memcpy(dest[offset .. offset + 256], &self.bus.ppu.registers.oam); + offset += 256; + + @memcpy(dest[offset .. offset + 32], &self.bus.ppu.palette.ram); + offset += 32; + + if (self.bus.cartridge) |*c| { + @memcpy(dest[offset .. offset + 0x2000], &c.prg_ram); + offset += 0x2000; + } + + return offset; +} + +pub fn loadState(self: *Nes, src: []const u8) state.Error!void { + if (src.len < 8 or !std.mem.eql(u8, src[0..8], "NESSTATE")) { + return error.InvalidState; + } + var offset: usize = 8; + + self.cpu.registers.a = src[offset]; + self.cpu.registers.x = src[offset + 1]; + self.cpu.registers.y = src[offset + 2]; + self.cpu.registers.sp = src[offset + 3]; + self.cpu.registers.status = @bitCast(src[offset + 4]); + self.cpu.registers.pc = @as(u16, src[offset + 5]) | (@as(u16, src[offset + 6]) << 8); + offset += 7; + + @memcpy(&self.bus.ram, src[offset .. offset + 0x800]); + offset += 0x800; + + @memcpy(&self.bus.ciram, src[offset .. offset + 0x800]); + offset += 0x800; + + @memcpy(&self.bus.ppu.registers.oam, src[offset .. offset + 256]); + offset += 256; + + @memcpy(&self.bus.ppu.palette.ram, src[offset .. offset + 32]); + offset += 32; + + if (self.bus.cartridge) |*c| { + if (src.len >= offset + 0x2000) { + @memcpy(&c.prg_ram, src[offset .. offset + 0x2000]); + offset += 0x2000; + } + } +} + +test { + contract.system.validate(Nes); + _ = common; + _ = ppu; + _ = apu; + _ = bus_mod; + _ = cartridge_mod; + _ = controller_mod; + _ = mapper_mod; + _ = conformance; +} + +pub const conformance = @import("conformance/root.zig"); +