test(nes): consolidate and simplify inline subsystem unit tests
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user