test(nes): consolidate and simplify inline subsystem unit tests

This commit is contained in:
2026-08-27 07:16:26 +02:00
parent f028213c11
commit 57f349fa17
35 changed files with 6856 additions and 0 deletions
+877
View File
@@ -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);
}
+132
View File
@@ -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
}
+237
View File
@@ -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);
}
+78
View File
@@ -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);
}
+175
View File
@@ -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);
}
+301
View File
@@ -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());
}
+493
View File
@@ -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());
}
+171
View File
@@ -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);
}
+672
View File
@@ -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(),
);
}
+195
View File
@@ -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));
}
+137
View File
@@ -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());
}
+84
View File
@@ -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());
}
+60
View File
@@ -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());
}
+157
View File
@@ -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());
}
+76
View File
@@ -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 {}
+110
View File
@@ -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);
}
+104
View File
@@ -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());
}
+70
View File
@@ -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 {}
+91
View File
@@ -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);
}
+91
View File
@@ -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);
}
+94
View File
@@ -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);
}
+178
View File
@@ -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());
}
+151
View File
@@ -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);
}
+222
View File
@@ -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());
}
+147
View File
@@ -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);
}
+83
View File
@@ -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));
}
+97
View File
@@ -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;
}
+112
View File
@@ -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());
}
+83
View File
@@ -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 {}
+95
View File
@@ -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));
}
+76
View File
@@ -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));
}
+73
View File
@@ -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));
}
+340
View File
@@ -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);
}
+542
View File
@@ -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());
}
+252
View File
@@ -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");