Initial commit: NES emulator with GTK4 desktop frontend
Some checks failed
CI / rust (push) Has been cancelled

Full NES emulation: CPU, PPU, APU, 47 mappers, iNES/NES 2.0 parsing.
GTK4 desktop client with HeaderBar, pixel-perfect Cairo rendering,
drag-and-drop ROM loading, and keyboard shortcuts.
187 tests covering core emulation, mappers, and runtime.
This commit is contained in:
2026-03-13 11:48:45 +03:00
commit bdf23de8db
143 changed files with 18501 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
use super::*;
impl CpuBus for NativeBus {
fn read(&mut self, addr: u16) -> u8 {
let value = match addr {
0x0000..=0x1FFF => self.cpu_ram[(addr as usize) & 0x07FF],
0x2000..=0x3FFF => {
let reg = (addr as u8) & 7;
let scanline = self.ppu_dot / PPU_DOTS_PER_SCANLINE;
let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE;
let value = if reg == 4 {
let rendering_read_phase = self.ppu.rendering_enabled()
&& (scanline < 240 || scanline == PPU_PRERENDER_SCANLINE)
&& ((1..=256).contains(&dot) || (321..=340).contains(&dot));
self.ppu.cpu_read_oamdata(rendering_read_phase)
} else {
let mapper: &(dyn Mapper + Send) = &*self.mapper;
self.ppu.cpu_read(reg, mapper)
};
if reg == 2 {
if scanline == PPU_VBLANK_START_SCANLINE && dot == 1 {
self.suppress_vblank_this_frame = true;
}
// Reading PPUSTATUS clears VBlank; do not keep a stale NMI latched.
self.nmi_pending = false;
}
value
}
0x4000..=0x4013 => self.cpu_open_bus,
0x4015 => self.apu.read(addr),
0x4016 => self.joypad_read(),
0x4017 => self.joypad2_read(),
0x6000..=0x7FFF => self.mapper.cpu_read_low(addr).unwrap_or(self.cpu_open_bus),
0x8000..=0xFFFF => self.mapper.cpu_read(addr),
_ => self.cpu_open_bus,
};
self.cpu_open_bus = value;
value
}
fn write(&mut self, addr: u16, value: u8) {
self.cpu_open_bus = value;
match addr {
0x0000..=0x1FFF => self.cpu_ram[(addr as usize) & 0x07FF] = value,
0x2000..=0x3FFF => {
let reg = (addr as u8) & 7;
let nmi_was_enabled = self.ppu.nmi_enabled();
{
let (ppu, mapper) = (&mut self.ppu, &mut self.mapper);
ppu.cpu_write(reg, value, &mut **mapper);
}
if reg == 0
&& !nmi_was_enabled
&& self.ppu.nmi_enabled()
&& self.ppu.vblank_flag_set()
{
self.nmi_pending = true;
}
if reg == 0 || reg == 5 {
self.note_scroll_write_now();
}
}
0x4000..=0x4013 => self.apu.write(addr, value),
0x4015 => self.apu.write(addr, value),
0x4017 => self.apu.write(addr, value),
0x4014 => {
self.apu.write(0x4014, value);
let base = (value as u16) << 8;
let mut dma = [0u8; 256];
for i in 0..=u8::MAX {
dma[i as usize] = self.dma_read(base.wrapping_add(i as u16));
}
for byte in dma {
self.ppu.dma_write_oam(byte);
}
// OAM DMA stalls CPU for 513/514 cycles while PPU and mapper continue.
let cpu_phase = (self.ppu_dot / 3) & 1;
self.clock_cpu_cycles(513 + cpu_phase);
}
0x4016 => self.joypad_write(value),
0x6000..=0x7FFF => {
self.mapper.cpu_write_low(addr, value);
}
0x8000..=0xFFFF => self.mapper.cpu_write(addr, value),
_ => {}
}
}
fn poll_nmi(&mut self) -> bool {
let out = self.nmi_pending;
self.nmi_pending = false;
out
}
fn poll_irq(&mut self) -> bool {
if self.apu.poll_irq() {
true
} else {
self.mapper.poll_irq()
}
}
}

View File

@@ -0,0 +1,74 @@
use super::NativeBus;
impl NativeBus {
pub fn set_joypad_buttons(&mut self, buttons: [bool; 8]) {
let mut state = 0u8;
if buttons[4] {
state |= 1 << 0; // A
}
if buttons[5] {
state |= 1 << 1; // B
}
if buttons[7] {
state |= 1 << 2; // Select
}
if buttons[6] {
state |= 1 << 3; // Start
}
if buttons[0] {
state |= 1 << 4; // Up
}
if buttons[1] {
state |= 1 << 5; // Down
}
if buttons[2] {
state |= 1 << 6; // Left
}
if buttons[3] {
state |= 1 << 7; // Right
}
self.joypad_state = state;
self.joypad2_state = 0;
if self.joypad_strobe {
self.joypad_shift = self.joypad_state;
self.joypad2_shift = self.joypad2_state;
}
}
pub(super) fn joypad_read(&mut self) -> u8 {
let bit = if self.joypad_strobe {
self.joypad_state & 1
} else {
let bit = self.joypad_shift & 1;
self.joypad_shift = (self.joypad_shift >> 1) | 0x80;
bit
};
self.format_controller_read(bit)
}
pub(super) fn joypad2_read(&mut self) -> u8 {
let bit = if self.joypad_strobe {
self.joypad2_state & 1
} else {
let bit = self.joypad2_shift & 1;
self.joypad2_shift = (self.joypad2_shift >> 1) | 0x80;
bit
};
self.format_controller_read(bit)
}
pub(super) fn joypad_write(&mut self, value: u8) {
let strobe = (value & 1) != 0;
self.joypad_strobe = strobe;
if strobe {
self.joypad_shift = self.joypad_state;
self.joypad2_shift = self.joypad2_state;
}
}
fn format_controller_read(&self, bit: u8) -> u8 {
// Controller reads expose serial data in bit0, keep bit6 high, and
// preserve open-bus upper bits.
(self.cpu_open_bus & 0xE0) | 0x40 | (bit & 1)
}
}

View File

@@ -0,0 +1,158 @@
use super::*;
use crate::native_core::apu::ApuStateTail;
use crate::native_core::state_io as sio;
const BUS_STATE_CTX: &str = "bus state";
impl NativeBus {
pub fn save_state(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.cpu_ram);
self.ppu.save_state(out);
out.extend_from_slice(self.apu.registers());
out.push(self.cpu_open_bus);
out.push(self.joypad_state);
out.push(self.joypad_shift);
out.push(self.joypad2_state);
out.push(self.joypad2_shift);
out.push(u8::from(self.joypad_strobe));
out.push(u8::from(self.nmi_pending));
out.extend_from_slice(&self.ppu_dot.to_le_bytes());
out.push(u8::from(self.odd_frame));
out.push(u8::from(self.in_vblank));
out.push(u8::from(self.mmc3_a12_prev_high));
out.extend_from_slice(&self.mmc3_a12_low_dots.to_le_bytes());
out.extend_from_slice(&self.mmc3_last_irq_scanline.to_le_bytes());
self.apu.save_state_tail(out);
let mut mapper_state = Vec::new();
self.mapper.save_state(&mut mapper_state);
out.extend_from_slice(&(mapper_state.len() as u32).to_le_bytes());
out.extend_from_slice(&mapper_state);
}
pub fn load_state(&mut self, data: &[u8]) -> Result<(), String> {
let mut cursor = 0usize;
self.cpu_ram.copy_from_slice(sio::take_exact(
data,
&mut cursor,
CPU_RAM_SIZE,
BUS_STATE_CTX,
)?);
let ppu_consumed = self.ppu.load_state(&data[cursor..])?;
cursor = cursor.saturating_add(ppu_consumed);
let mut apu_regs = [0u8; 0x20];
apu_regs.copy_from_slice(sio::take_exact(data, &mut cursor, 0x20, BUS_STATE_CTX)?);
self.apu.set_registers(apu_regs);
self.frame_complete = false;
self.cpu_open_bus = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
self.joypad_state = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
self.joypad_shift = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
self.joypad2_state = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
self.joypad2_shift = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
self.joypad_strobe = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
self.nmi_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
self.suppress_vblank_this_frame = false;
self.ppu_dot = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)?;
self.ppu_dot %= PPU_DOTS_PER_FRAME;
self.odd_frame = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
self.in_vblank = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
self.mmc3_a12_prev_high = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
self.mmc3_a12_low_dots = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
self.mmc3_last_irq_scanline = u32::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
let frame_cycle = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)?;
let frame_mode_5step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let frame_irq_inhibit = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let frame_irq_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let channel_enable_mask = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let mut length_counters = [0u8; 4];
length_counters.copy_from_slice(sio::take_exact(data, &mut cursor, 4, BUS_STATE_CTX)?);
let dmc_bytes_remaining = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
let dmc_irq_enabled = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let dmc_irq_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let dmc_cycle_counter = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
let dmc_current_addr = u16::from_le_bytes([
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?,
]);
let dmc_sample_buffer = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let dmc_sample_buffer_valid = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let dmc_shift_reg = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let dmc_bits_remaining = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let dmc_silence = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let dmc_output_level = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let dmc_dma_request = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let mut envelope_divider = [0u8; 3];
envelope_divider.copy_from_slice(sio::take_exact(data, &mut cursor, 3, BUS_STATE_CTX)?);
let mut envelope_decay = [0u8; 3];
envelope_decay.copy_from_slice(sio::take_exact(data, &mut cursor, 3, BUS_STATE_CTX)?);
let envelope_start_flags = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let triangle_linear_counter = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let triangle_linear_reload_flag = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let mut sweep_divider = [0u8; 2];
sweep_divider.copy_from_slice(sio::take_exact(data, &mut cursor, 2, BUS_STATE_CTX)?);
let sweep_reload_flags = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let cpu_cycle_parity = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let frame_reset_pending = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let frame_reset_delay = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)?;
let pending_frame_mode_5step = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
let pending_frame_irq_inhibit = sio::take_u8(data, &mut cursor, BUS_STATE_CTX)? != 0;
self.apu.load_state_tail(ApuStateTail {
frame_cycle,
frame_mode_5step,
frame_irq_inhibit,
frame_irq_pending,
channel_enable_mask,
length_counters,
dmc_bytes_remaining,
dmc_irq_enabled,
dmc_irq_pending,
dmc_cycle_counter,
dmc_current_addr,
dmc_sample_buffer,
dmc_sample_buffer_valid,
dmc_shift_reg,
dmc_bits_remaining,
dmc_silence,
dmc_output_level,
dmc_dma_request,
envelope_divider,
envelope_decay,
envelope_start_flags,
triangle_linear_counter,
triangle_linear_reload_flag,
sweep_divider,
sweep_reload_flags,
cpu_cycle_parity,
frame_reset_pending,
frame_reset_delay,
pending_frame_mode_5step,
pending_frame_irq_inhibit,
});
let mapper_len = sio::take_u32(data, &mut cursor, BUS_STATE_CTX)? as usize;
let mapper_state = sio::take_exact(data, &mut cursor, mapper_len, BUS_STATE_CTX)?;
self.ppu.set_vblank(self.in_vblank);
self.mapper.load_state(mapper_state)?;
if cursor != data.len() {
return Err("bus state: trailing bytes in payload".to_string());
}
Ok(())
}
}

View File

@@ -0,0 +1,159 @@
use super::{
CpuBus, NativeBus, PPU_DOTS_PER_SCANLINE, PPU_PRERENDER_SCANLINE, PPU_VBLANK_START_SCANLINE,
};
use crate::native_core::{ines::Mirroring, mapper::Mapper};
struct StubMapper;
impl Mapper for StubMapper {
fn cpu_read(&self, _addr: u16) -> u8 {
0
}
fn cpu_write(&mut self, _addr: u16, _value: u8) {}
fn ppu_read(&self, _addr: u16) -> u8 {
0
}
fn ppu_write(&mut self, _addr: u16, _value: u8) {}
fn mirroring(&self) -> Mirroring {
Mirroring::Horizontal
}
fn save_state(&self, _out: &mut Vec<u8>) {}
fn load_state(&mut self, _data: &[u8]) -> Result<(), String> {
Ok(())
}
}
struct ScanlineIrqMapper {
irq_pending: bool,
}
impl Mapper for ScanlineIrqMapper {
fn cpu_read(&self, _addr: u16) -> u8 {
0
}
fn cpu_write(&mut self, _addr: u16, _value: u8) {}
fn ppu_read(&self, _addr: u16) -> u8 {
0
}
fn ppu_write(&mut self, _addr: u16, _value: u8) {}
fn mirroring(&self) -> Mirroring {
Mirroring::Horizontal
}
fn clock_scanline(&mut self) {
self.irq_pending = true;
}
fn poll_irq(&mut self) -> bool {
let out = self.irq_pending;
self.irq_pending = false;
out
}
fn save_state(&self, _out: &mut Vec<u8>) {}
fn load_state(&mut self, _data: &[u8]) -> Result<(), String> {
Ok(())
}
}
struct A12GatedMapper {
irq_pending: bool,
}
impl Mapper for A12GatedMapper {
fn cpu_read(&self, _addr: u16) -> u8 {
0
}
fn cpu_write(&mut self, _addr: u16, _value: u8) {}
fn ppu_read(&self, _addr: u16) -> u8 {
0
}
fn ppu_write(&mut self, _addr: u16, _value: u8) {}
fn mirroring(&self) -> Mirroring {
Mirroring::Horizontal
}
fn clock_scanline(&mut self) {
self.irq_pending = true;
}
fn needs_ppu_a12_clock(&self) -> bool {
true
}
fn poll_irq(&mut self) -> bool {
let out = self.irq_pending;
self.irq_pending = false;
out
}
fn save_state(&self, _out: &mut Vec<u8>) {}
fn load_state(&mut self, _data: &[u8]) -> Result<(), String> {
Ok(())
}
}
struct A12CountMapper {
clocks: u8,
}
impl Mapper for A12CountMapper {
fn cpu_read(&self, _addr: u16) -> u8 {
0
}
fn cpu_write(&mut self, _addr: u16, _value: u8) {}
fn ppu_read(&self, _addr: u16) -> u8 {
0
}
fn ppu_write(&mut self, _addr: u16, _value: u8) {}
fn mirroring(&self) -> Mirroring {
Mirroring::Horizontal
}
fn clock_scanline(&mut self) {
self.clocks = self.clocks.saturating_add(1);
}
fn needs_ppu_a12_clock(&self) -> bool {
true
}
fn poll_irq(&mut self) -> bool {
if self.clocks == 0 {
false
} else {
self.clocks -= 1;
true
}
}
fn save_state(&self, _out: &mut Vec<u8>) {}
fn load_state(&mut self, _data: &[u8]) -> Result<(), String> {
Ok(())
}
}
mod apu;
mod mapper_timing;
mod ppu_open_bus;

View File

@@ -0,0 +1,314 @@
use super::*;
#[test]
fn apu_frame_irq_asserts_in_4_step_mode() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
bus.clock_cpu(1);
}
assert!(bus.poll_irq(), "APU frame IRQ should assert in 4-step mode");
}
#[test]
fn reading_4015_clears_apu_frame_irq_flag() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
bus.clock_cpu(1);
}
let status = bus.read(0x4015);
assert_ne!(status & 0x40, 0, "frame IRQ bit should be set in status");
assert!(!bus.poll_irq(), "reading 4015 should clear frame IRQ");
}
#[test]
fn apu_frame_irq_inhibit_bit_disables_irq_and_clears_pending() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
bus.clock_cpu(1);
}
assert!(bus.poll_irq());
bus.write(0x4017, 0x40); // 4-step, IRQ inhibit
assert!(
!bus.poll_irq(),
"inhibit write should clear pending frame IRQ"
);
}
#[test]
fn writing_4015_does_not_acknowledge_apu_frame_irq() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
bus.clock_cpu(1);
}
assert!(bus.poll_irq(), "frame IRQ must be pending");
// Recreate pending frame IRQ and ensure $4015 write does not clear it.
for _ in 0..14_918u32 {
bus.clock_cpu(1);
}
bus.write(0x4015, 0x00);
assert!(bus.poll_irq(), "writing $4015 must not clear frame IRQ");
// Reading $4015 still acknowledges frame IRQ as expected.
let _ = bus.read(0x4015);
assert!(!bus.poll_irq(), "reading $4015 should clear frame IRQ");
}
#[test]
fn apu_5step_mode_does_not_generate_frame_irq() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x80); // 5-step mode
for _ in 0..20_000u32 {
bus.clock_cpu(1);
}
assert!(!bus.poll_irq(), "5-step mode must not assert frame IRQ");
}
#[test]
fn apu_write_only_register_reads_return_cpu_open_bus() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4000, 0x12);
bus.write(0x0000, 0xAB);
assert_eq!(bus.read(0x0000), 0xAB);
assert_eq!(bus.read(0x4000), 0xAB);
assert_eq!(bus.read(0x400E), 0xAB);
}
#[test]
fn writing_4017_in_5step_mode_clocks_half_frame_after_delay() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4015, 0x01); // enable pulse1
bus.write(0x4000, 0x00); // length halt disabled
bus.write(0x4003, 0x18); // length index 3 => 2
assert_eq!(bus.apu.length_counters[0], 2);
bus.write(0x4017, 0x80); // switch to 5-step mode
assert_eq!(bus.apu.length_counters[0], 2);
for _ in 0..2u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.length_counters[0], 2);
bus.clock_cpu(1); // reset delay complete (3 CPU cycles on even phase)
assert_eq!(bus.apu.length_counters[0], 1);
}
#[test]
fn state_roundtrip_preserves_apu_frame_counter_fields() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.apu.frame_cycle = 777;
bus.apu.frame_mode_5step = true;
bus.apu.frame_irq_inhibit = true;
bus.apu.frame_irq_pending = true;
bus.apu.channel_enable_mask = 0x1F;
bus.apu.length_counters = [1, 2, 3, 4];
bus.apu.dmc_bytes_remaining = 99;
bus.apu.dmc_irq_enabled = true;
bus.apu.dmc_irq_pending = true;
bus.apu.dmc_cycle_counter = 1234;
bus.apu.envelope_divider = [9, 8, 7];
bus.apu.envelope_decay = [6, 5, 4];
bus.apu.envelope_start_flags = 0x05;
bus.apu.triangle_linear_counter = 3;
bus.apu.triangle_linear_reload_flag = true;
bus.apu.sweep_divider = [11, 12];
bus.apu.sweep_reload_flags = 0x03;
bus.apu.cpu_cycle_parity = true;
bus.apu.frame_reset_pending = true;
bus.apu.frame_reset_delay = 2;
bus.apu.pending_frame_mode_5step = true;
bus.apu.pending_frame_irq_inhibit = false;
let mut raw = Vec::new();
bus.save_state(&mut raw);
let mut restored = NativeBus::new(Box::new(StubMapper));
restored.load_state(&raw).expect("state should load");
assert_eq!(restored.apu.frame_cycle, 777);
assert!(restored.apu.frame_mode_5step);
assert!(restored.apu.frame_irq_inhibit);
assert!(restored.apu.frame_irq_pending);
assert_eq!(restored.apu.channel_enable_mask, 0x1F);
assert_eq!(restored.apu.length_counters, [1, 2, 3, 4]);
assert_eq!(restored.apu.dmc_bytes_remaining, 99);
assert!(restored.apu.dmc_irq_enabled);
assert!(restored.apu.dmc_irq_pending);
assert_eq!(restored.apu.dmc_cycle_counter, 1234);
assert_eq!(restored.apu.envelope_divider, [9, 8, 7]);
assert_eq!(restored.apu.envelope_decay, [6, 5, 4]);
assert_eq!(restored.apu.envelope_start_flags, 0x05);
assert_eq!(restored.apu.triangle_linear_counter, 3);
assert!(restored.apu.triangle_linear_reload_flag);
assert_eq!(restored.apu.sweep_divider, [11, 12]);
assert_eq!(restored.apu.sweep_reload_flags, 0x03);
assert!(restored.apu.cpu_cycle_parity);
assert!(restored.apu.frame_reset_pending);
assert_eq!(restored.apu.frame_reset_delay, 2);
assert!(restored.apu.pending_frame_mode_5step);
assert!(!restored.apu.pending_frame_irq_inhibit);
}
#[test]
fn apu_status_reflects_length_counters_and_disable_clears_them() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4015, 0x0F); // enable pulse1/pulse2/triangle/noise
bus.write(0x4003, 0xF8); // load pulse1 length index 31
bus.write(0x4007, 0xF8); // load pulse2
bus.write(0x400B, 0xF8); // load triangle
bus.write(0x400F, 0xF8); // load noise
let status = bus.read(0x4015);
assert_eq!(status & 0x0F, 0x0F);
bus.write(0x4015, 0x00);
let status2 = bus.read(0x4015);
assert_eq!(status2 & 0x0F, 0x00);
}
#[test]
fn apu_length_counter_decrements_on_half_frame_when_not_halted() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4015, 0x01); // enable pulse1
bus.write(0x4000, 0x00); // halt=0
bus.write(0x4003, 0x18); // length index 3 => value 2
assert_eq!(bus.apu.length_counters[0], 2);
for _ in 0..7_457u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.length_counters[0], 1);
for _ in 0..7_458u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.length_counters[0], 0);
}
#[test]
fn dmc_irq_raises_and_is_reported_in_4015_status() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4010, 0x8F); // IRQ enable, no loop, fastest rate
bus.write(0x4013, 0x00); // sample length = 1 byte
bus.write(0x4015, 0x10); // enable DMC
for _ in 0..54u32 {
bus.clock_cpu(1);
}
assert!(bus.poll_irq());
let status = bus.read(0x4015);
assert_ne!(status & 0x80, 0, "DMC IRQ should be visible in status");
assert!(bus.poll_irq(), "status read must not clear DMC IRQ");
bus.write(0x4015, 0x10);
assert!(!bus.poll_irq(), "writing 4015 acknowledges DMC IRQ");
}
#[test]
fn quarter_frame_clocks_triangle_linear_counter() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4008, 0x05); // control=0, reload value=5
bus.write(0x400B, 0x00); // set reload flag
for _ in 0..3_729u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.triangle_linear_counter, 5);
assert!(!bus.apu.triangle_linear_reload_flag);
for _ in 0..3_728u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.triangle_linear_counter, 4);
}
#[test]
fn quarter_frame_envelope_start_reloads_decay() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4015, 0x01); // enable pulse1
bus.write(0x4000, 0x03); // envelope period=3
bus.write(0x4003, 0x00); // start envelope
assert_ne!(bus.apu.envelope_start_flags & 0x01, 0);
for _ in 0..3_729u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.envelope_decay[0], 15);
assert_eq!(bus.apu.envelope_divider[0], 3);
assert_eq!(bus.apu.envelope_start_flags & 0x01, 0);
}
#[test]
fn sweep_half_frame_updates_pulse_timer_period() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4002, 0x00); // timer low
bus.write(0x4003, 0x02); // timer high => period 0x200
bus.write(0x4001, 0x82); // enable, period=1, negate=0, shift=2
for _ in 0..7_457u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.read(0x4002), 0x80);
assert_eq!(bus.apu.read(0x4003) & 0x07, 0x02);
}
#[test]
fn sweep_negative_pulse1_uses_ones_complement() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4002, 0x00); // period 0x200
bus.write(0x4003, 0x02);
bus.write(0x4001, 0x8A); // enable, period=1, negate=1, shift=2
for _ in 0..7_457u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.read(0x4002), 0x7F);
assert_eq!(bus.apu.read(0x4003) & 0x07, 0x01);
}
#[test]
fn dmc_dma_fetches_sample_bytes_and_steals_cpu_cycles() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4010, 0x0F); // no IRQ, no loop, fastest period
bus.write(0x4012, 0x00); // sample start $C000
bus.write(0x4013, 0x00); // sample length = 1 byte
bus.write(0x4015, 0x10); // enable DMC (issues initial DMA request)
assert_eq!(bus.ppu_dot, 0);
bus.clock_cpu(1);
// 1 CPU cycle + 4-cycle DMA steal = 5 total CPU cycles => 15 PPU dots.
assert_eq!(bus.ppu_dot, 15);
assert_eq!(bus.apu.dmc_bytes_remaining, 0);
assert_eq!(bus.apu.dmc_current_addr, 0xC001);
assert!(bus.apu.dmc_sample_buffer_valid);
}
#[test]
fn dmc_playback_updates_output_level_from_sample_bits() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4011, 0x20); // initial DMC DAC level
bus.write(0x4010, 0x0F); // fastest DMC rate
bus.write(0x4012, 0x00); // sample start $C000
bus.write(0x4013, 0x00); // 1-byte sample
bus.write(0x4015, 0x10); // enable DMC
// Service initial DMA request.
bus.clock_cpu(1);
let initial = bus.apu.dmc_output_level;
// Stub mapper returns 0x00 sample byte, so each played bit drives output down by 2.
for _ in 0..600u32 {
bus.clock_cpu(1);
}
assert!(bus.apu.dmc_output_level < initial);
}

View File

@@ -0,0 +1,72 @@
use super::*;
#[test]
fn prerender_scanline_still_clocks_mapper_scanline_irq() {
let mut bus = NativeBus::new(Box::new(ScanlineIrqMapper { irq_pending: false }));
bus.write(0x2001, 0x18); // enable rendering
bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE + 259;
bus.clock_ppu_dot(); // now at dot 260
assert!(bus.poll_irq());
}
#[test]
fn mmc3_class_scanline_clock_is_suppressed_when_a12_has_no_activity() {
let mut bus = NativeBus::new(Box::new(A12GatedMapper { irq_pending: false }));
bus.write(0x2001, 0x18); // BG+sprites on
bus.write(0x2000, 0x00); // BG table $0000, sprite table $0000, 8x8 sprites
bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE;
for _ in 0..120 {
bus.clock_ppu_dot();
}
assert!(!bus.poll_irq());
}
#[test]
fn mmc3_class_scanline_clock_runs_when_pattern_table_uses_a12() {
let mut bus = NativeBus::new(Box::new(A12GatedMapper { irq_pending: false }));
bus.write(0x2001, 0x18); // BG+sprites on
bus.write(0x2000, 0x10); // BG pattern table $1000
bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE;
for _ in 0..120 {
bus.clock_ppu_dot();
}
assert!(bus.poll_irq());
}
#[test]
fn mmc3_class_a12_filter_clocks_once_per_scanline() {
let mut bus = NativeBus::new(Box::new(A12CountMapper { clocks: 0 }));
bus.write(0x2001, 0x08); // BG on
bus.write(0x2000, 0x10); // BG pattern table $1000
bus.ppu_dot = 30 * PPU_DOTS_PER_SCANLINE;
for _ in 0..PPU_DOTS_PER_SCANLINE {
bus.clock_ppu_dot();
}
let mut count = 0u8;
while bus.poll_irq() {
count = count.saturating_add(1);
}
assert_eq!(count, 1);
}
#[test]
fn state_roundtrip_preserves_mmc3_a12_timing_fields() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.mmc3_a12_prev_high = true;
bus.mmc3_a12_low_dots = 3;
bus.mmc3_last_irq_scanline = 123;
let mut raw = Vec::new();
bus.save_state(&mut raw);
let mut restored = NativeBus::new(Box::new(StubMapper));
restored.load_state(&raw).expect("state should load");
assert!(restored.mmc3_a12_prev_high);
assert_eq!(restored.mmc3_a12_low_dots, 3);
assert_eq!(restored.mmc3_last_irq_scanline, 123);
}

View File

@@ -0,0 +1,191 @@
use super::*;
#[test]
fn reading_ppustatus_clears_latched_nmi_request() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2000, 0x80); // enable NMI on VBlank
for _ in 0..100_000usize {
bus.clock_cpu(1);
if bus.nmi_pending {
break;
}
}
assert!(bus.nmi_pending, "vblank NMI should have latched");
let _ = bus.read(0x2002);
assert!(!bus.nmi_pending, "status read should clear pending NMI");
assert!(
!bus.poll_nmi(),
"CPU should not observe stale NMI after status read"
);
}
#[test]
fn sprite_overflow_flag_is_set_during_rendering_and_cleared_prerender() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2001, 0x18); // enable BG + sprites rendering
// Initialize OAM offscreen, then place 9 sprites on scanline 20.
bus.write(0x2003, 0x00);
for _ in 0..256usize {
bus.write(0x2004, 0xFF);
}
bus.write(0x2003, 0x00);
for _ in 0..9usize {
bus.write(0x2004, 19); // Y (sprite appears at Y+1 = 20)
bus.write(0x2004, 0); // tile
bus.write(0x2004, 0); // attr
bus.write(0x2004, 0); // X
}
bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 256;
bus.clock_ppu_dot(); // dot 257 -> overflow evaluation
assert!(bus.ppu.sprite_overflow_set());
bus.in_vblank = true;
bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE;
bus.clock_ppu_dot(); // prerender dot 1 -> clear status flags
assert!(!bus.ppu.sprite_overflow_set());
}
#[test]
fn sprite_overflow_latches_until_prerender_clear() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2001, 0x18); // rendering enabled
// Populate first 9 sprites on scanline 20, others offscreen.
bus.write(0x2003, 0x00);
for _ in 0..256usize {
bus.write(0x2004, 0xFF);
}
bus.write(0x2003, 0x00);
for _ in 0..9usize {
bus.write(0x2004, 19); // Y => visible on scanline 20
bus.write(0x2004, 0);
bus.write(0x2004, 0);
bus.write(0x2004, 0);
}
bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 256;
bus.clock_ppu_dot(); // scanline 20 dot 257
assert!(bus.ppu.sprite_overflow_set());
// Move to a scanline with no overflow and ensure bit stays latched.
bus.ppu_dot = 100 * PPU_DOTS_PER_SCANLINE + 256;
bus.clock_ppu_dot();
assert!(bus.ppu.sprite_overflow_set());
}
#[test]
fn sprite_overflow_not_evaluated_when_sprites_disabled() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2001, 0x08); // BG enabled, sprites disabled
bus.write(0x2003, 0x00);
for _ in 0..256usize {
bus.write(0x2004, 0xFF);
}
bus.write(0x2003, 0x00);
for _ in 0..9usize {
bus.write(0x2004, 19);
bus.write(0x2004, 0);
bus.write(0x2004, 0);
bus.write(0x2004, 0);
}
bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 256;
bus.clock_ppu_dot();
assert!(!bus.ppu.sprite_overflow_set());
}
#[test]
fn odd_frame_skips_one_ppu_dot_when_rendering_enabled() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2001, 0x18); // rendering enabled
let mut dots = 0u32;
while !bus.frame_complete {
bus.clock_ppu_dot();
dots += 1;
}
assert_eq!(dots, 341 * 262); // even frame
bus.frame_complete = false;
let mut odd_frame_dots = 0u32;
while !bus.frame_complete {
bus.clock_ppu_dot();
odd_frame_dots += 1;
}
assert_eq!(odd_frame_dots, 341 * 262 - 1); // odd frame skip
}
#[test]
fn unmapped_cpu_reads_return_open_bus_value() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x0000, 0xAB); // write puts value on CPU bus
assert_eq!(bus.read(0x4018), 0xAB); // unmapped APU/test range
bus.write(0x0001, 0xCD);
assert_eq!(bus.read(0x4FFF), 0xCD); // unmapped expansion range
}
#[test]
fn joypad_read_preserves_open_bus_upper_bits_and_sets_bit6() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.set_joypad_buttons([false, false, false, false, true, false, false, false]); // A pressed
bus.write(0x4016, 0xA1); // strobe on + seed open bus high bits
let v = bus.read(0x4016);
assert_eq!(v & 0x01, 1);
assert_eq!(v & 0x40, 0x40);
assert_eq!(v & 0xE0, 0xE0);
}
#[test]
fn ppustatus_read_at_vblank_edge_suppresses_vblank_for_that_frame() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2000, 0x80); // enable NMI
// Position exactly at vblank set point (scanline 241 dot 1).
bus.ppu_dot = PPU_VBLANK_START_SCANLINE * PPU_DOTS_PER_SCANLINE + 1;
let _ = bus.read(0x2002); // status read at dot 1 suppresses vblank for this frame
bus.clock_ppu_dot(); // dot 2 on scanline 241
assert!(!bus.in_vblank);
assert!(!bus.nmi_pending);
let status = bus.read(0x2002);
assert_eq!(status & 0x80, 0);
}
#[test]
fn prerender_clears_status_flags_even_if_not_in_vblank() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.in_vblank = false;
bus.ppu.set_vblank(true);
bus.ppu.set_sprite0_hit(true);
bus.ppu.set_sprite_overflow(true);
bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE;
bus.clock_ppu_dot(); // prerender dot 1
let status = bus.read(0x2002);
assert_eq!(status & 0xE0, 0);
}
#[test]
fn oamdata_read_returns_ff_during_active_rendering() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x2001, 0x18); // rendering enabled
bus.write(0x2003, 0x10);
bus.write(0x2004, 0x22);
bus.write(0x2003, 0x10);
bus.ppu_dot = 20 * PPU_DOTS_PER_SCANLINE + 100;
let v = bus.read(0x2004);
assert_eq!(v, 0xFF);
bus.ppu_dot = PPU_VBLANK_START_SCANLINE * PPU_DOTS_PER_SCANLINE + 10;
let v2 = bus.read(0x2004);
assert_eq!(v2, 0x22);
}

View File

@@ -0,0 +1,116 @@
use super::{
NativeBus, PPU_DOTS_PER_FRAME, PPU_DOTS_PER_SCANLINE, PPU_PRERENDER_SCANLINE,
PPU_VBLANK_START_SCANLINE,
};
use crate::native_core::cpu::CpuBus;
use crate::native_core::mapper::Mapper;
impl NativeBus {
fn clock_one_cpu_cycle(&mut self) {
for _ in 0..3 {
self.clock_ppu_dot();
}
self.mapper.clock_cpu(1);
self.apu.clock_cpu_cycle();
}
fn service_dmc_dma(&mut self, addr: u16) {
let byte = self.dma_read(addr);
self.apu.provide_dmc_dma_byte(byte);
// DMC DMA steals CPU bus cycles while APU/PPU/mapper keep ticking.
for _ in 0..4 {
self.clock_one_cpu_cycle();
}
}
pub(super) fn dma_read(&mut self, addr: u16) -> u8 {
<Self as CpuBus>::read(self, addr)
}
pub(super) fn clock_ppu_dot(&mut self) {
self.ppu_dot += 1;
if self.ppu_dot >= PPU_DOTS_PER_FRAME {
self.ppu_dot = 0;
self.frame_complete = true;
self.odd_frame = !self.odd_frame;
}
let scanline = self.ppu_dot / PPU_DOTS_PER_SCANLINE;
let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE;
let rendering_enabled = self.ppu.rendering_enabled();
{
let mapper: &(dyn Mapper + Send) = &*self.mapper;
self.ppu.render_dot(mapper, scanline, dot);
}
if rendering_enabled && (scanline < 240 || scanline == PPU_PRERENDER_SCANLINE) {
if self.mapper.needs_ppu_a12_clock() {
let a12_high = self.ppu.mmc3_a12_high_at(scanline, dot);
if a12_high {
if !self.mmc3_a12_prev_high
&& self.mmc3_a12_low_dots >= 8
&& self.mmc3_last_irq_scanline != scanline
{
self.mapper.clock_scanline();
self.mmc3_last_irq_scanline = scanline;
}
self.mmc3_a12_prev_high = true;
self.mmc3_a12_low_dots = 0;
} else {
self.mmc3_a12_prev_high = false;
self.mmc3_a12_low_dots = self.mmc3_a12_low_dots.saturating_add(1);
}
} else if dot == 260 {
self.mapper.clock_scanline();
}
} else {
self.mmc3_a12_prev_high = false;
self.mmc3_a12_low_dots = self.mmc3_a12_low_dots.saturating_add(1);
}
if rendering_enabled && scanline == PPU_PRERENDER_SCANLINE && dot == 339 && self.odd_frame {
// NTSC odd frame timing: skip pre-render dot 340 when rendering is enabled.
self.ppu_dot = self.ppu_dot.saturating_add(1);
}
if !self.in_vblank && scanline == PPU_VBLANK_START_SCANLINE && dot == 1 {
if self.suppress_vblank_this_frame {
self.suppress_vblank_this_frame = false;
self.in_vblank = false;
self.ppu.set_vblank(false);
} else {
self.in_vblank = true;
self.ppu.set_vblank(true);
if self.ppu.nmi_enabled() {
self.nmi_pending = true;
}
}
} else if scanline == PPU_PRERENDER_SCANLINE && dot == 1 {
self.in_vblank = false;
self.ppu.set_vblank(false);
self.ppu.set_sprite0_hit(false);
self.ppu.set_sprite_overflow(false);
self.suppress_vblank_this_frame = false;
}
}
pub(super) fn clock_cpu_cycles(&mut self, cycles: u32) {
if cycles == 0 {
return;
}
let mut remaining = cycles;
while remaining != 0 {
self.clock_one_cpu_cycle();
while let Some(addr) = self.apu.take_dmc_dma_request() {
self.service_dmc_dma(addr);
}
remaining -= 1;
}
}
pub(super) fn note_scroll_write_now(&mut self) {
let scanline = (self.ppu_dot / PPU_DOTS_PER_SCANLINE) as usize;
let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE;
self.ppu.note_scroll_register_write(scanline, dot);
}
}