use alloc::string::ToString;
use alloc::sync::Arc;
use alloc::vec::Vec;
use super::*;
use crate::core::clock::{ClockForest, Rational};
use crate::core::device::Deferred;
use crate::core::space::{RamStore, Region as MmioRegion, RequesterId, UnassignedPolicy};
use crate::core::state::{MachineShape, Migrations, StateReader, StateWriter};
use crate::core::wire::{Wire, WireId, WireIdAllocator, WireSink};
fn new_ppu() -> (NesPpu, Arc<RamStore>, Arc<RamStore>) {
new_ppu_in(Region::Ntsc)
}
fn new_ppu_in(region: Region) -> (NesPpu, Arc<RamStore>, Arc<RamStore>) {
let mut props = Props::new();
props.insert("region", region.name());
props.insert("warmup", false);
let ppu = NesPpu::new(&props).expect("properties are valid");
let chr = Arc::new(RamStore::new(0x2000));
let nt = Arc::new(RamStore::new(0x1000));
let space = AddressSpace::new("ppu", 14).with_unassigned(UnassignedPolicy::ZEROS);
space
.topology()
.map(Arc::new(MmioRegion::ram("chr", Arc::clone(&chr))), 0x0000)
.expect("chr fits");
space
.topology()
.map(
Arc::new(
MmioRegion::mirror(
"nametables",
Arc::new(MmioRegion::ram("nt", Arc::clone(&nt))),
0x1000,
)
.expect("mirror fits"),
),
0x2000,
)
.expect("nametables fit");
ppu.attach_bus(Arc::new(space));
(ppu, chr, nt)
}
fn seek(ppu: &NesPpu, scanline: u16, dot: u16) {
ppu.with_engine(|e| {
e.scanline = scanline;
e.dot = dot;
});
}
fn draw_two_frames(ppu: &NesPpu) {
ppu.advance_by(DOTS_PER_FRAME * 2 - u64::from(DOTS_PER_SCANLINE) * 22);
assert_eq!(ppu.position(), (SCREEN_HEIGHT as u16, 0));
}
fn enable_rendering(ppu: &NesPpu) {
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
}
#[test]
fn three_dots_per_cpu_cycle_exactly() {
let mut forest = ClockForest::new();
let master = forest
.add_oscillator("master", Rational::new(236_250_000, 11).unwrap())
.unwrap();
let cpu = forest.add_domain("cpu", master, 1, 12).unwrap();
let dots = add_clock_domain(&mut forest, master, Region::Ntsc).unwrap();
assert_eq!(forest.convert_ticks(cpu, dots, 1).unwrap(), 3);
assert_eq!(forest.convert_ticks(cpu, dots, 29_780).unwrap(), 89_340);
}
#[test]
fn an_even_frame_is_341_by_262_dots() {
let (ppu, _, _) = new_ppu();
ppu.advance_by(DOTS_PER_FRAME);
assert_eq!(ppu.frame(), 1);
assert_eq!(ppu.position(), (0, 0));
assert_eq!(DOTS_PER_FRAME, 89_342);
}
#[test]
fn an_odd_frame_skips_a_dot_when_rendering() {
let (ppu, _, _) = new_ppu();
enable_rendering(&ppu);
ppu.advance_by(DOTS_PER_FRAME); assert_eq!(ppu.frame(), 1);
assert_eq!(ppu.position(), (0, 0));
ppu.advance_by(DOTS_PER_FRAME - 1); assert_eq!(ppu.frame(), 2);
assert_eq!(ppu.position(), (0, 0));
}
#[test]
fn the_odd_frame_skip_needs_rendering_enabled() {
let (ppu, _, _) = new_ppu();
ppu.advance_by(DOTS_PER_FRAME);
ppu.advance_by(DOTS_PER_FRAME);
assert_eq!(ppu.frame(), 2);
assert_eq!(ppu.position(), (0, 0));
}
#[derive(Debug, Default)]
struct Recorder {
levels: crate::core::sync::Mutex<Vec<bool>>,
}
impl WireSink for Recorder {
fn set_level(&self, _src: WireId, _line: u32, level: Level) {
self.levels.lock().push(level.is_high());
}
}
fn with_nmi(ppu: &NesPpu) -> Arc<Recorder> {
let ids = WireIdAllocator::new();
let id = ids.alloc();
let sink = Arc::new(Recorder::default());
let wire = Wire::builder()
.source(id)
.sink(Arc::clone(&sink) as Arc<dyn WireSink>, 0)
.build_shared();
ppu.attach_nmi(WireSource::new(wire, id));
sink
}
#[test]
fn the_vblank_flag_is_set_at_dot_1_of_scanline_241() {
let (ppu, _, _) = new_ppu();
seek(&ppu, VBLANK_SCANLINE, 0);
ppu.advance_by(1); assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0);
ppu.advance_by(1); assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, STATUS_VBLANK);
ppu.advance_by(3);
assert_eq!(ppu.read_register(PPUSTATUS) & STATUS_VBLANK, STATUS_VBLANK);
assert_eq!(
ppu.with_engine(|e| e.status) & STATUS_VBLANK,
0,
"cleared by the read"
);
}
#[test]
fn the_vblank_flag_is_cleared_at_dot_1_of_the_pre_render_line() {
let (ppu, _, _) = new_ppu();
seek(&ppu, VBLANK_SCANLINE, 1);
ppu.advance_by(1);
ppu.with_engine(|e| e.status |= STATUS_SPRITE0 | STATUS_OVERFLOW);
seek(&ppu, PRE_RENDER_SCANLINE, 0);
ppu.advance_by(1);
assert_ne!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0);
ppu.advance_by(1); assert_eq!(
ppu.with_engine(|e| e.status) & (STATUS_VBLANK | STATUS_SPRITE0 | STATUS_OVERFLOW),
0
);
}
#[test]
fn reading_2002_one_dot_before_the_set_suppresses_the_flag_entirely() {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
ppu.write_register(PPUCTRL, CTRL_NMI);
seek(&ppu, VBLANK_SCANLINE, 1);
assert_eq!(ppu.read_register(PPUSTATUS) & STATUS_VBLANK, 0);
ppu.advance_by(3);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0);
assert!(
!nmi.levels.lock().iter().any(|high| *high),
"no NMI may be requested this frame"
);
}
#[test]
fn reading_2002_on_the_set_dot_returns_the_flag_and_suppresses_the_nmi() {
for dot in [2u16, 3] {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
ppu.write_register(PPUCTRL, CTRL_NMI);
seek(&ppu, VBLANK_SCANLINE, 1);
ppu.advance_by(u64::from(dot - 1)); assert_eq!(ppu.position(), (VBLANK_SCANLINE, dot));
let status = ppu.read_register(PPUSTATUS);
assert_eq!(status & STATUS_VBLANK, STATUS_VBLANK, "dot {dot}");
assert!(ppu.with_engine(|e| e.suppress_nmi), "dot {dot}");
ppu.advance_by(u64::from(DOTS_PER_SCANLINE) * 19);
assert!(
!nmi.levels.lock().iter().any(|high| *high),
"dot {dot}: the NMI must stay suppressed for the frame"
);
}
}
#[test]
fn a_read_four_dots_after_the_set_is_too_late_to_suppress() {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
ppu.write_register(PPUCTRL, CTRL_NMI);
seek(&ppu, VBLANK_SCANLINE, 1);
ppu.advance_by(3);
assert_eq!(ppu.position(), (VBLANK_SCANLINE, 4));
assert_eq!(nmi.levels.lock().as_slice(), &[true]);
ppu.read_register(PPUSTATUS);
assert_eq!(nmi.levels.lock().as_slice(), &[true, false]);
}
#[test]
fn enabling_nmi_mid_vblank_requests_one_immediately() {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
seek(&ppu, VBLANK_SCANLINE, 1);
ppu.advance_by(10);
assert!(nmi.levels.lock().is_empty(), "NMI output is still off");
ppu.write_register(PPUCTRL, CTRL_NMI);
assert_eq!(nmi.levels.lock().as_slice(), &[true]);
ppu.write_register(PPUCTRL, 0);
ppu.write_register(PPUCTRL, CTRL_NMI);
assert_eq!(nmi.levels.lock().as_slice(), &[true, false, true]);
}
#[test]
fn the_nmi_falls_when_vblank_ends() {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
ppu.write_register(PPUCTRL, CTRL_NMI);
seek(&ppu, VBLANK_SCANLINE, 1);
ppu.advance_by(3);
assert_eq!(nmi.levels.lock().as_slice(), &[true]);
seek(&ppu, PRE_RENDER_SCANLINE, 0);
ppu.advance_by(2);
assert_eq!(nmi.levels.lock().as_slice(), &[true, false]);
}
#[test]
fn the_2007_read_buffer_delays_by_one_access() {
let (ppu, _, nt) = new_ppu();
nt.write_u8(0x000, 0x11).unwrap();
nt.write_u8(0x001, 0x22).unwrap();
ppu.write_register(PPUADDR, 0x20);
ppu.write_register(PPUADDR, 0x00);
let dummy = ppu.read_register(PPUDATA);
assert_eq!(dummy, 0x00, "the first read returns the stale buffer");
assert_eq!(ppu.read_register(PPUDATA), 0x11);
assert_eq!(ppu.read_register(PPUDATA), 0x22);
}
#[test]
fn a_palette_read_is_not_buffered_but_still_fills_the_buffer() {
let (ppu, _, nt) = new_ppu();
nt.write_u8(0xf01, 0x5a).unwrap();
ppu.poke_palette(0x3f01, 0x21);
ppu.write_register(PPUADDR, 0x3f);
ppu.write_register(PPUADDR, 0x01);
assert_eq!(ppu.read_register(PPUDATA) & 0x3f, 0x21);
ppu.write_register(PPUADDR, 0x20);
ppu.write_register(PPUADDR, 0x00);
assert_eq!(ppu.read_register(PPUDATA), 0x5a);
}
#[test]
fn the_registers_mirror_every_eight_bytes_to_3fff() {
let (ppu, _, _) = new_ppu();
let port = ppu.port();
let mut byte = [0u8; 1];
ppu.with_engine(|e| e.status |= STATUS_VBLANK);
port.read(0x3ffa - REGISTER_BASE, &mut byte, MemAttrs::DEFAULT)
.unwrap();
assert_eq!(byte[0] & STATUS_VBLANK, STATUS_VBLANK);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0);
}
#[test]
fn the_write_toggle_is_shared_and_2002_clears_it() {
let (ppu, _, _) = new_ppu();
ppu.write_register(PPUADDR, 0x21); assert!(ppu.with_engine(|e| e.w));
ppu.read_register(PPUSTATUS);
assert!(!ppu.with_engine(|e| e.w));
ppu.write_register(PPUADDR, 0x2c);
ppu.write_register(PPUADDR, 0x00);
assert_eq!(ppu.with_engine(|e| e.v), 0x2c00);
}
#[test]
fn scroll_writes_land_in_t_and_x_exactly() {
let (ppu, _, _) = new_ppu();
ppu.write_register(PPUSCROLL, 0b1010_1011); assert_eq!(ppu.with_engine(|e| e.t) & 0x001f, 0b10101);
assert_eq!(ppu.with_engine(|e| e.x), 0b011);
ppu.write_register(PPUSCROLL, 0b0110_1101); let t = ppu.with_engine(|e| e.t);
assert_eq!((t >> 5) & 0x1f, 0b01101);
assert_eq!((t >> 12) & 0x07, 0b101);
}
#[test]
fn the_2006_high_write_clears_bit_14() {
let (ppu, _, _) = new_ppu();
ppu.write_register(PPUSCROLL, 0);
ppu.write_register(PPUSCROLL, 0xff); ppu.write_register(PPUADDR, 0xff); assert_eq!(ppu.with_engine(|e| e.t) & 0x7f00, 0x3f00);
}
#[test]
fn open_bus_fills_the_unused_bits_of_2002_and_decays() {
let mut props = Props::new();
props.insert("warmup", false);
props.insert("open-bus-decay-dots", 100u64);
let ppu = NesPpu::new(&props).unwrap();
ppu.attach_bus(Arc::new(AddressSpace::new("ppu", 14)));
ppu.write_register(PPUMASK, 0x1f);
assert_eq!(ppu.read_register(PPUSTATUS) & 0x1f, 0x1f);
assert_eq!(ppu.read_register(PPUCTRL), 0x1f);
ppu.advance_by(200);
assert_eq!(
ppu.read_register(PPUSTATUS) & 0x1f,
0x00,
"the charge decayed"
);
}
#[test]
fn a_debug_read_of_2002_has_no_side_effects() {
let (ppu, _, _) = new_ppu();
ppu.with_engine(|e| e.status |= STATUS_VBLANK);
ppu.write_register(PPUADDR, 0x21); let port = ppu.port();
let mut byte = [0u8; 1];
port.read(2, &mut byte, MemAttrs::DEBUG).unwrap();
assert_eq!(byte[0] & STATUS_VBLANK, STATUS_VBLANK);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, STATUS_VBLANK);
assert!(ppu.with_engine(|e| e.w), "the toggle must not have moved");
}
#[test]
fn a_debug_read_of_2007_does_not_advance_the_address() {
let (ppu, _, nt) = new_ppu();
nt.write_u8(0, 0x77).unwrap();
ppu.write_register(PPUADDR, 0x20);
ppu.write_register(PPUADDR, 0x00);
let port = ppu.port();
let mut byte = [0u8; 1];
port.read(7, &mut byte, MemAttrs::DEBUG).unwrap();
assert_eq!(ppu.with_engine(|e| e.v), 0x2000);
assert_eq!(ppu.read_register(PPUDATA), 0x00);
}
#[test]
fn the_warmup_lockout_ignores_early_writes() {
let ppu = NesPpu::new(&Props::new()).unwrap();
ppu.attach_bus(Arc::new(AddressSpace::new("ppu", 14)));
ppu.write_register(PPUCTRL, CTRL_NMI);
assert_eq!(ppu.with_engine(|e| e.ctrl), 0, "too early");
ppu.with_engine(|e| e.dots = WARMUP_DOTS);
ppu.write_register(PPUCTRL, CTRL_NMI);
assert_eq!(ppu.with_engine(|e| e.ctrl), CTRL_NMI);
ppu.with_engine(|e| e.dots = 0);
ppu.write_register(OAMADDR, 0x40);
assert_eq!(ppu.oam_addr(), 0x40);
}
#[test]
fn the_sprite_palette_zeros_alias_the_background_ones() {
let (ppu, _, _) = new_ppu();
for (sprite, background) in [
(0x3f10, 0x3f00),
(0x3f14, 0x3f04),
(0x3f18, 0x3f08),
(0x3f1c, 0x3f0c),
] {
ppu.poke_palette(sprite, 0x2a);
assert_eq!(ppu.peek_palette(background), 0x2a, "{sprite:#x}");
ppu.poke_palette(background, 0x15);
assert_eq!(ppu.peek_palette(sprite), 0x15, "{sprite:#x}");
}
ppu.poke_palette(0x3f01, 0x01);
ppu.poke_palette(0x3f11, 0x02);
assert_eq!(ppu.peek_palette(0x3f01), 0x01);
}
#[test]
fn palette_ram_repeats_through_3fff() {
let (ppu, _, _) = new_ppu();
ppu.poke_palette(0x3f03, 0x30);
assert_eq!(ppu.peek_palette(0x3fe3), 0x30);
assert_eq!(ppu.peek_palette(0x3f23), 0x30);
}
#[test]
fn palette_entries_are_six_bits() {
let (ppu, _, _) = new_ppu();
ppu.poke_palette(0x3f00, 0xff);
assert_eq!(ppu.peek_palette(0x3f00), 0x3f);
}
#[test]
fn a_palette_read_reports_the_top_two_bits_as_open_bus() {
let (ppu, _, _) = new_ppu();
ppu.poke_palette(0x3f00, 0x0f);
ppu.write_register(PPUMASK, 0xc0); ppu.write_register(PPUADDR, 0x3f);
ppu.write_register(PPUADDR, 0x00);
ppu.write_register(PPUSTATUS, 0xc0); assert_eq!(ppu.read_register(PPUDATA), 0xc0 | 0x0f);
}
fn plain_background(chr: &RamStore, nt: &RamStore, tile: u8) {
for row in 0..8u64 {
chr.write_u8(u64::from(tile) * 16 + row, 0xff).unwrap(); chr.write_u8(u64::from(tile) * 16 + 8 + row, 0x00).unwrap(); }
for index in 0..0x3c0u64 {
nt.write_u8(index, tile).unwrap();
}
}
#[test]
fn the_background_pipeline_draws_a_uniform_screen() {
let (ppu, chr, nt) = new_ppu();
plain_background(&chr, &nt, 1);
ppu.poke_palette(0x3f00, 0x0f); ppu.poke_palette(0x3f01, 0x21); ppu.write_register(PPUMASK, MASK_BG | MASK_BG_LEFT);
draw_two_frames(&ppu);
for x in [0usize, 1, 7, 8, 128, 255] {
assert_eq!(ppu.pixel(x, 100).unwrap().index(), 0x21, "x = {x}");
}
}
#[test]
fn the_left_column_mask_hides_the_first_eight_pixels() {
let (ppu, chr, nt) = new_ppu();
plain_background(&chr, &nt, 1);
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f01, 0x21);
ppu.write_register(PPUMASK, MASK_BG); draw_two_frames(&ppu);
assert_eq!(ppu.pixel(0, 100).unwrap().index(), 0x0f);
assert_eq!(ppu.pixel(7, 100).unwrap().index(), 0x0f);
assert_eq!(ppu.pixel(8, 100).unwrap().index(), 0x21);
}
#[test]
fn fine_x_shifts_the_background_left() {
let (ppu, chr, nt) = new_ppu();
for row in 0..8u64 {
chr.write_u8(16 + row, 0x80).unwrap();
chr.write_u8(16 + 8 + row, 0x00).unwrap();
}
for index in 0..0x3c0u64 {
nt.write_u8(index, 1).unwrap();
}
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f01, 0x21);
ppu.write_register(PPUMASK, MASK_BG | MASK_BG_LEFT);
ppu.write_register(PPUSCROLL, 3); ppu.write_register(PPUSCROLL, 0);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(5, 100).unwrap().index(), 0x21);
assert_eq!(ppu.pixel(4, 100).unwrap().index(), 0x0f);
}
#[test]
fn greyscale_masks_the_palette_index() {
let (ppu, chr, nt) = new_ppu();
plain_background(&chr, &nt, 1);
ppu.poke_palette(0x3f01, 0x21);
ppu.write_register(PPUMASK, MASK_BG | MASK_BG_LEFT | MASK_GREYSCALE);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(100, 100).unwrap().index(), 0x20);
}
#[test]
fn emphasis_travels_with_the_pixel() {
let (ppu, _, _) = new_ppu();
ppu.poke_palette(0x3f00, 0x0f);
ppu.write_register(PPUMASK, MASK_EMPHASIS_R | MASK_EMPHASIS_B);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(10, 10).unwrap().emphasis(), 0b101);
}
#[test]
fn rendering_disabled_with_v_in_the_palette_shows_that_entry() {
let (ppu, _, _) = new_ppu();
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f05, 0x16);
ppu.write_register(PPUADDR, 0x3f);
ppu.write_register(PPUADDR, 0x05);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(10, 10).unwrap().index(), 0x16);
}
#[test]
fn coarse_x_wraps_into_the_next_nametable() {
let (ppu, _, _) = new_ppu();
ppu.with_engine(|e| e.v = 0x001f);
enable_rendering(&ppu);
seek(&ppu, 0, 8);
ppu.advance_by(1);
assert_eq!(ppu.with_engine(|e| e.v), 0x0400);
}
#[test]
fn y_increment_wraps_at_row_29_and_at_row_31() {
let (ppu, _, _) = new_ppu();
enable_rendering(&ppu);
ppu.with_engine(|e| e.v = 0x7000 | (29 << 5));
seek(&ppu, 0, 256);
ppu.advance_by(1);
assert_eq!(ppu.with_engine(|e| e.v) & 0x0be0, 0x0800, "toggles bit 11");
ppu.with_engine(|e| e.v = 0x7000 | (31 << 5));
seek(&ppu, 0, 256);
ppu.advance_by(1);
assert_eq!(ppu.with_engine(|e| e.v) & 0x0be0, 0x0000, "no toggle");
}
#[test]
fn dot_257_copies_the_horizontal_bits_and_280_to_304_the_vertical_ones() {
let (ppu, _, _) = new_ppu();
enable_rendering(&ppu);
ppu.with_engine(|e| {
e.v = 0;
e.t = 0x7fff;
});
seek(&ppu, 0, 257);
ppu.advance_by(1);
assert_eq!(ppu.with_engine(|e| e.v), 0x041f);
ppu.with_engine(|e| e.v = 0);
seek(&ppu, PRE_RENDER_SCANLINE, 280);
ppu.advance_by(1);
assert_eq!(ppu.with_engine(|e| e.v), 0x7be0);
}
#[test]
fn writing_2007_while_rendering_moves_the_scroll_counters() {
let (ppu, _, _) = new_ppu();
enable_rendering(&ppu);
seek(&ppu, 0, 100);
ppu.with_engine(|e| e.v = 0x0000);
ppu.write_register(PPUDATA, 0x00);
assert_eq!(ppu.with_engine(|e| e.v), 0x1001);
}
fn opaque_sprite(chr: &RamStore, tile: u8) {
for row in 0..8u64 {
chr.write_u8(u64::from(tile) * 16 + row, 0xff).unwrap();
chr.write_u8(u64::from(tile) * 16 + 8 + row, 0x00).unwrap();
}
}
#[test]
fn a_sprite_is_drawn_one_line_below_its_y_byte() {
let (ppu, chr, _) = new_ppu();
opaque_sprite(&chr, 1);
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f11, 0x27);
ppu.poke_oam(0, 50); ppu.poke_oam(1, 1); ppu.poke_oam(2, 0); ppu.poke_oam(3, 40); ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(40, 50).unwrap().index(), 0x0f, "not on line 50");
assert_eq!(ppu.pixel(40, 51).unwrap().index(), 0x27);
assert_eq!(ppu.pixel(47, 58).unwrap().index(), 0x27);
assert_eq!(ppu.pixel(48, 58).unwrap().index(), 0x0f);
}
#[test]
fn sprites_never_appear_on_scanline_zero() {
let (ppu, chr, _) = new_ppu();
opaque_sprite(&chr, 1);
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f11, 0x27);
ppu.poke_oam(0, 0xff); ppu.poke_oam(1, 1);
ppu.poke_oam(3, 0);
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(0, 0).unwrap().index(), 0x0f);
}
#[test]
fn an_8x16_sprite_takes_its_bank_from_the_tile_byte() {
let (ppu, chr, _) = new_ppu();
for row in 0..8u64 {
chr.write_u8(0x1000 + 0x02 * 16 + row, 0xff).unwrap();
chr.write_u8(0x1000 + 0x03 * 16 + row, 0x00).unwrap();
chr.write_u8(0x1000 + 0x03 * 16 + 8 + row, 0xff).unwrap();
}
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f11, 0x27); ppu.poke_palette(0x3f12, 0x28); ppu.poke_oam(0, 50);
ppu.poke_oam(1, 0x03); ppu.poke_oam(2, 0);
ppu.poke_oam(3, 40);
ppu.write_register(PPUCTRL, CTRL_SPRITE_16);
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(40, 51).unwrap().index(), 0x27, "top half");
assert_eq!(ppu.pixel(40, 59).unwrap().index(), 0x28, "bottom half");
assert_eq!(ppu.pixel(40, 67).unwrap().index(), 0x0f, "16 rows only");
}
#[test]
fn sprite_flipping_mirrors_the_pattern() {
let (ppu, chr, _) = new_ppu();
chr.write_u8(16, 0xff).unwrap();
for row in 1..8u64 {
chr.write_u8(16 + row, 0x80).unwrap();
}
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f11, 0x27);
ppu.poke_oam(0, 50);
ppu.poke_oam(1, 1);
ppu.poke_oam(2, SPRITE_FLIP_X | SPRITE_FLIP_Y);
ppu.poke_oam(3, 40);
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(47, 58).unwrap().index(), 0x27);
assert_eq!(ppu.pixel(40, 58).unwrap().index(), 0x27);
assert_eq!(ppu.pixel(40, 51).unwrap().index(), 0x0f);
}
#[test]
fn only_eight_sprites_are_drawn_and_the_ninth_sets_overflow() {
let (ppu, chr, _) = new_ppu();
opaque_sprite(&chr, 1);
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f11, 0x27);
for index in 0..9u8 {
ppu.poke_oam(index * 4, 50);
ppu.poke_oam(index * 4 + 1, 1);
ppu.poke_oam(index * 4 + 2, 0);
ppu.poke_oam(index * 4 + 3, index * 8);
}
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_eq!(
ppu.pixel(56, 51).unwrap().index(),
0x27,
"the eighth is drawn"
);
assert_eq!(ppu.pixel(64, 51).unwrap().index(), 0x0f, "the ninth is not");
assert_ne!(ppu.with_engine(|e| e.status) & STATUS_OVERFLOW, 0);
}
#[test]
fn the_overflow_bug_reads_a_tile_byte_as_a_y_coordinate() {
let (ppu, chr, _) = new_ppu();
opaque_sprite(&chr, 1);
for index in 0..8u8 {
ppu.poke_oam(index * 4, 50);
ppu.poke_oam(index * 4 + 1, 1);
ppu.poke_oam(index * 4 + 2, 0);
ppu.poke_oam(index * 4 + 3, index * 8);
}
for index in 8..64u8 {
ppu.poke_oam(index * 4, 0xf0);
ppu.poke_oam(index * 4 + 1, 0xf0);
ppu.poke_oam(index * 4 + 2, 0xf0);
ppu.poke_oam(index * 4 + 3, 0xf0);
}
ppu.poke_oam(9 * 4 + 1, 50);
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_ne!(
ppu.with_engine(|e| e.status) & STATUS_OVERFLOW,
0,
"a tile byte in range must trip the buggy overflow search"
);
}
#[test]
fn the_overflow_flag_stays_clear_with_eight_sprites_and_nothing_in_range() {
let (ppu, chr, _) = new_ppu();
opaque_sprite(&chr, 1);
for index in 0..8u8 {
ppu.poke_oam(index * 4, 50);
ppu.poke_oam(index * 4 + 1, 1);
ppu.poke_oam(index * 4 + 2, 0);
ppu.poke_oam(index * 4 + 3, index * 8);
}
for index in 8..64u8 {
for byte in 0..4u8 {
ppu.poke_oam(index * 4 + byte, 0xf0);
}
}
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
draw_two_frames(&ppu);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_OVERFLOW, 0);
}
#[test]
fn sprite_priority_is_by_oam_index_then_by_the_priority_bit() {
let (ppu, chr, nt) = new_ppu();
opaque_sprite(&chr, 1);
opaque_sprite(&chr, 2);
plain_background(&chr, &nt, 3);
for row in 0..8u64 {
chr.write_u8(3 * 16 + row, 0x00).unwrap();
chr.write_u8(3 * 16 + 8 + row, 0x00).unwrap();
}
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f11, 0x27); ppu.poke_palette(0x3f15, 0x28); ppu.poke_oam(0, 50);
ppu.poke_oam(1, 1);
ppu.poke_oam(2, 0);
ppu.poke_oam(3, 40);
ppu.poke_oam(4, 50);
ppu.poke_oam(5, 2);
ppu.poke_oam(6, 1); ppu.poke_oam(7, 40);
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(40, 51).unwrap().index(), 0x27, "lower index wins");
}
#[test]
fn a_behind_sprite_loses_to_opaque_background() {
let (ppu, chr, nt) = new_ppu();
opaque_sprite(&chr, 1);
plain_background(&chr, &nt, 2);
opaque_sprite(&chr, 2);
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f01, 0x11); ppu.poke_palette(0x3f11, 0x27);
ppu.poke_oam(0, 50);
ppu.poke_oam(1, 1);
ppu.poke_oam(2, SPRITE_BEHIND);
ppu.poke_oam(3, 40);
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
draw_two_frames(&ppu);
assert_eq!(ppu.pixel(40, 51).unwrap().index(), 0x11);
}
fn sprite_zero_scene(ppu: &NesPpu, chr: &RamStore, nt: &RamStore, x: u8, y: u8) {
opaque_sprite(chr, 1);
plain_background(chr, nt, 1);
ppu.poke_palette(0x3f00, 0x0f);
ppu.poke_palette(0x3f01, 0x11);
ppu.poke_palette(0x3f11, 0x27);
ppu.poke_oam(0, y);
ppu.poke_oam(1, 1);
ppu.poke_oam(2, 0);
ppu.poke_oam(3, x);
}
#[test]
fn sprite_zero_hits_where_both_layers_are_opaque() {
let (ppu, chr, nt) = new_ppu();
sprite_zero_scene(&ppu, &chr, &nt, 40, 50);
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
draw_two_frames(&ppu);
assert_ne!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
}
#[test]
fn sprite_zero_never_hits_at_x_255() {
let (ppu, chr, nt) = new_ppu();
sprite_zero_scene(&ppu, &chr, &nt, 255, 50);
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
draw_two_frames(&ppu);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
}
#[test]
fn sprite_zero_never_hits_in_a_clipped_left_column() {
let (ppu, chr, nt) = new_ppu();
sprite_zero_scene(&ppu, &chr, &nt, 0, 50);
ppu.write_register(PPUMASK, MASK_BG | MASK_SPRITE | MASK_BG_LEFT);
draw_two_frames(&ppu);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
}
#[test]
fn sprite_zero_never_hits_with_a_layer_disabled() {
let (ppu, chr, nt) = new_ppu();
sprite_zero_scene(&ppu, &chr, &nt, 40, 50);
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT); draw_two_frames(&ppu);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
}
#[test]
fn sprite_zero_never_hits_on_a_transparent_background_pixel() {
let (ppu, chr, nt) = new_ppu();
sprite_zero_scene(&ppu, &chr, &nt, 40, 50);
for row in 0..8u64 {
chr.write_u8(16 + row, 0x00).unwrap();
}
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
draw_two_frames(&ppu);
assert_eq!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
}
#[test]
fn the_sprite_zero_flag_lands_one_dot_after_the_pixel() {
let (ppu, chr, nt) = new_ppu();
sprite_zero_scene(&ppu, &chr, &nt, 0, 50);
ppu.write_register(
PPUMASK,
MASK_BG | MASK_SPRITE | MASK_BG_LEFT | MASK_SPRITE_LEFT,
);
ppu.advance_by(DOTS_PER_FRAME); while ppu.position() != (51, 0) {
ppu.advance_by(1);
}
ppu.advance_by(1); assert_eq!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
ppu.advance_by(1); assert_eq!(
ppu.with_engine(|e| e.status) & STATUS_SPRITE0,
0,
"not yet visible"
);
ppu.advance_by(1); assert_ne!(ppu.with_engine(|e| e.status) & STATUS_SPRITE0, 0);
}
#[test]
fn the_unimplemented_attribute_bits_read_back_as_zero() {
let (ppu, _, _) = new_ppu();
ppu.write_register(OAMADDR, 2);
ppu.write_register(OAMDATA, 0xff);
ppu.write_register(OAMADDR, 2);
assert_eq!(ppu.read_register(OAMDATA), 0xe3);
}
#[test]
fn writing_2004_while_rendering_bumps_oamaddr_without_storing() {
let (ppu, _, _) = new_ppu();
enable_rendering(&ppu);
seek(&ppu, 10, 100);
ppu.write_register(OAMADDR, 0x10);
ppu.write_register(OAMDATA, 0x5a);
assert_eq!(ppu.peek_oam(0x10), 0x00, "OAM is busy being evaluated");
assert_eq!(ppu.oam_addr(), 0x14, "only the high six bits moved");
}
#[test]
fn reading_2004_during_the_secondary_oam_clear_returns_ff() {
let (ppu, _, _) = new_ppu();
enable_rendering(&ppu);
ppu.poke_oam(0, 0x12);
seek(&ppu, 10, 30);
assert_eq!(ppu.read_register(OAMDATA), 0xff);
seek(&ppu, 10, 100);
assert_eq!(ppu.read_register(OAMDATA), 0x12);
}
#[test]
fn oam_dma_bytes_go_through_the_2004_path() {
let (ppu, _, _) = new_ppu();
ppu.write_register(OAMADDR, 0);
for index in 0..4u8 {
ppu.oam_dma_write(0x40 + index);
}
assert_eq!(ppu.peek_oam(0), 0x40);
assert_eq!(ppu.peek_oam(2), 0x42 & SPRITE_ATTR_IMPLEMENTED);
assert_eq!(ppu.oam_addr(), 4);
}
#[test]
fn a_high_oamaddr_corrupts_the_first_eight_oam_bytes_at_rendering_start() {
let (ppu, _, _) = new_ppu();
for index in 0..8u8 {
ppu.poke_oam(index, 0x00);
ppu.poke_oam(0x20 + index, 0xa0 + index);
}
enable_rendering(&ppu);
ppu.write_register(OAMADDR, 0x20);
seek(&ppu, PRE_RENDER_SCANLINE, 0);
ppu.advance_by(1);
for index in 0..8u8 {
let expected = if index & 3 == 2 {
(0xa0 + index) & SPRITE_ATTR_IMPLEMENTED
} else {
0xa0 + index
};
assert_eq!(ppu.peek_oam(index), expected, "byte {index}");
}
}
#[test]
fn a_misaligned_oamaddr_reinterprets_oam_bytes() {
let (ppu, chr, _) = new_ppu();
opaque_sprite(&chr, 1);
ppu.poke_oam(0, 50);
ppu.poke_oam(1, 1);
ppu.poke_oam(2, 0);
ppu.poke_oam(3, 40);
ppu.poke_oam(4, 50);
ppu.poke_oam(5, 1);
ppu.poke_oam(6, 0);
ppu.poke_oam(7, 60);
ppu.write_register(PPUMASK, MASK_SPRITE | MASK_SPRITE_LEFT);
ppu.advance_by(DOTS_PER_FRAME);
while ppu.position() != (40, 330) {
ppu.advance_by(1);
}
ppu.write_register(OAMADDR, 3);
ppu.advance_by(u64::from(DOTS_PER_SCANLINE));
assert_eq!(ppu.position(), (41, 330));
ppu.with_engine(|e| {
assert_eq!(e.eval_base, 3);
assert_eq!(&e.secondary_oam[..4], &[40, 50, 1, 0]);
assert_eq!(e.eval_found, 1);
});
}
#[test]
fn realize_no_longer_demands_a_bus_of_its_own() {
let ppu = NesPpu::new(&Props::new()).unwrap();
let mut deferred = Deferred::new();
let mut ctx = RealizeCtx::new("ppu", RequesterId(1), &mut deferred);
ppu.realize(&mut ctx)
.expect("realize is about the wire, not the bus");
}
#[test]
fn realize_announces_the_nmi_line() {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
let mut deferred = Deferred::new();
let mut ctx = RealizeCtx::new("ppu", RequesterId(1), &mut deferred);
ppu.realize(&mut ctx).unwrap();
assert_eq!(nmi.levels.lock().as_slice(), &[] as &[bool]);
assert!(!ppu.with_engine(|e| e.nmi_active()));
}
#[test]
fn a_cold_reset_returns_every_register_to_its_documented_value() {
let (ppu, _, _) = new_ppu();
ppu.write_register(PPUCTRL, 0xff);
ppu.write_register(PPUMASK, 0xff);
ppu.advance_by(1000);
ppu.reset(ResetKind::Cold);
ppu.with_engine(|e| {
assert_eq!(e.ctrl, 0);
assert_eq!(e.mask, 0);
assert_eq!(e.status, 0);
assert_eq!(e.v, 0);
assert_eq!(e.t, 0);
assert_eq!(e.dots, 0);
});
}
fn state_hash(ppu: &NesPpu) -> u64 {
let mut w = StateWriter::new(MachineShape::new());
{
let mut chunk = w
.chunk("/ppu", NES_PPU_CLASS.name, NES_PPU_CLASS.version)
.unwrap();
ppu.save(&mut chunk).unwrap();
}
let bytes = w.to_vec().unwrap();
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[test]
fn a_mid_frame_snapshot_round_trips_including_the_shift_registers() {
let (ppu, chr, nt) = new_ppu();
plain_background(&chr, &nt, 1);
ppu.poke_palette(0x3f01, 0x21);
enable_rendering(&ppu);
ppu.advance_by(DOTS_PER_FRAME + 100 * u64::from(DOTS_PER_SCANLINE) + 123);
assert_ne!(ppu.with_engine(|e| e.bg_shift_lo), 0, "a tile is in flight");
let mut w = StateWriter::new(MachineShape::new());
{
let mut chunk = w
.chunk("/ppu", NES_PPU_CLASS.name, NES_PPU_CLASS.version)
.unwrap();
ppu.save(&mut chunk).unwrap();
}
let bytes = w.to_vec().unwrap();
let before = state_hash(&ppu);
let (restored, chr2, nt2) = new_ppu();
plain_background(&chr2, &nt2, 1);
let reader = StateReader::new(&bytes).unwrap();
let chunk = reader
.load(
"/ppu",
NES_PPU_CLASS.name,
NES_PPU_CLASS.version,
&Migrations::new(),
)
.unwrap();
restored.load(&mut chunk.reader()).unwrap();
assert_eq!(state_hash(&restored), before);
ppu.advance_by(5000);
restored.advance_by(5000);
assert_eq!(state_hash(&restored), state_hash(&ppu));
}
#[test]
fn a_short_chunk_is_rejected() {
let (ppu, _, _) = new_ppu();
let mut reader = ChunkReader::new(&[0u8; 4]);
assert!(ppu.load(&mut reader).is_err());
}
#[test]
fn the_framebuffer_is_256_by_240_indexed_pixels() {
let (ppu, _, _) = new_ppu();
ppu.with_framebuffer(|fb| assert_eq!(fb.len(), FRAMEBUFFER_LEN));
assert!(ppu.pixel(255, 239).is_some());
assert!(ppu.pixel(256, 0).is_none());
assert!(ppu.pixel(0, 240).is_none());
}
#[test]
fn a_pixel_packs_an_index_and_emphasis() {
let pixel = Pixel::new(0x3f, 0b101);
assert_eq!(pixel.index(), 0x3f);
assert_eq!(pixel.emphasis(), 0b101);
assert_eq!(Pixel::new(0xff, 0xff), Pixel::new(0x3f, 0b111));
}
#[test]
fn the_register_block_takes_byte_accesses_only() {
let (ppu, _, _) = new_ppu();
let port = ppu.port();
let mut wide = [0u8; 2];
assert!(port.read(0, &mut wide, MemAttrs::DEFAULT).is_err());
assert!(port.write(0, &[0, 0], MemAttrs::DEFAULT).is_err());
assert_eq!(port.constraints().max, Width::U8);
}
#[test]
fn a_debug_write_is_refused_rather_than_guessed_at() {
let (ppu, _, _) = new_ppu();
let port = ppu.port();
assert!(port.write(0, &[0xff], MemAttrs::DEBUG).is_err());
assert_eq!(ppu.with_engine(|e| e.ctrl), 0);
}
const REGIONS: [Region; 3] = [Region::Ntsc, Region::Pal, Region::Dendy];
#[test]
fn every_regions_geometry_adds_up_to_its_frame() {
for region in REGIONS {
let g = region.geometry();
assert_eq!(
g.visible_scanlines + g.post_render_lines + g.vblank_lines + 1,
g.scanlines_per_frame,
"{region}"
);
assert_eq!(g.vblank_scanline, g.visible_scanlines + g.post_render_lines);
assert_eq!(g.pre_render_scanline, g.scanlines_per_frame - 1);
assert_eq!(
g.dots_per_frame,
u64::from(DOTS_PER_SCANLINE) * u64::from(g.scanlines_per_frame)
);
assert!(g.picture_height <= g.visible_scanlines);
}
}
#[test]
fn the_frame_is_89342_106392_and_106392_dots() {
assert_eq!(Region::Ntsc.geometry().dots_per_frame, 89_342);
assert_eq!(Region::Pal.geometry().dots_per_frame, 106_392);
assert_eq!(Region::Dendy.geometry().dots_per_frame, 106_392);
for region in REGIONS {
let (ppu, _, _) = new_ppu_in(region);
ppu.advance_by(region.geometry().dots_per_frame);
assert_eq!(ppu.frame(), 1, "{region}");
assert_eq!(ppu.position(), (0, 0), "{region}");
}
}
#[test]
fn the_odd_frame_skip_is_ntsc_only() {
for region in REGIONS {
let g = region.geometry();
let (ppu, _, _) = new_ppu_in(region);
enable_rendering(&ppu);
ppu.advance_by(g.dots_per_frame); assert_eq!(ppu.frame(), 1, "{region}");
assert_eq!(ppu.position(), (0, 0), "{region}");
let odd = g.dots_per_frame - u64::from(g.odd_frame_skip);
ppu.advance_by(odd);
assert_eq!(ppu.frame(), 2, "{region}");
assert_eq!(ppu.position(), (0, 0), "{region}");
assert_eq!(g.odd_frame_skip, region == Region::Ntsc, "{region}");
}
}
#[test]
fn an_odd_pal_frame_is_not_short_even_with_rendering_on() {
let g = Region::Pal.geometry();
let (ppu, _, _) = new_ppu_in(Region::Pal);
enable_rendering(&ppu);
ppu.advance_by(g.dots_per_frame); ppu.advance_by(g.dots_per_frame - 2); assert_eq!(ppu.position(), (g.pre_render_scanline, 339));
ppu.advance_by(1);
assert_eq!(ppu.position(), (g.pre_render_scanline, 340));
assert_eq!(ppu.frame(), 1, "still inside the odd frame");
ppu.advance_by(1);
assert_eq!(ppu.frame(), 2);
}
#[test]
fn vblank_is_set_and_cleared_on_each_regions_own_scanlines() {
assert_eq!(Region::Ntsc.geometry().vblank_scanline, 241);
assert_eq!(Region::Pal.geometry().vblank_scanline, 241);
assert_eq!(Region::Dendy.geometry().vblank_scanline, 291);
assert_eq!(Region::Ntsc.geometry().pre_render_scanline, 261);
assert_eq!(Region::Pal.geometry().pre_render_scanline, 311);
assert_eq!(Region::Dendy.geometry().pre_render_scanline, 311);
for region in REGIONS {
let g = region.geometry();
let (ppu, _, _) = new_ppu_in(region);
seek(&ppu, g.vblank_scanline, 0);
ppu.advance_by(1); assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0, "{region}");
ppu.advance_by(1); assert_eq!(
ppu.with_engine(|e| e.status) & STATUS_VBLANK,
STATUS_VBLANK,
"{region}"
);
seek(&ppu, g.pre_render_scanline, 0);
ppu.advance_by(1); assert_ne!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0, "{region}");
ppu.advance_by(1); assert_eq!(ppu.with_engine(|e| e.status) & STATUS_VBLANK, 0, "{region}");
}
}
#[test]
fn the_vblank_window_is_the_length_the_chart_gives() {
assert_eq!(Region::Ntsc.geometry().vblank_lines, 20);
assert_eq!(Region::Pal.geometry().vblank_lines, 70);
assert_eq!(Region::Dendy.geometry().vblank_lines, 20);
for region in REGIONS {
let g = region.geometry();
assert_eq!(
g.pre_render_scanline - g.vblank_scanline,
g.vblank_lines,
"{region}"
);
}
}
#[test]
fn the_dividers_give_3_16_over_5_and_3_dots_per_cpu_cycle() {
for region in REGIONS {
let (num, den) = region.master_clock();
let mut forest = ClockForest::new();
let master = forest
.add_oscillator("master", Rational::new(num, den).unwrap())
.unwrap();
let cpu = forest
.add_domain("cpu", master, 1, region.cpu_divider())
.unwrap();
let dots = add_clock_domain(&mut forest, master, region).unwrap();
assert_eq!(
forest.convert_ticks(cpu, dots, 5).unwrap(),
5 * region.cpu_divider() / region.dot_divider(),
"{region}"
);
}
assert_eq!(Region::Pal.cpu_divider(), 16);
assert_eq!(Region::Pal.dot_divider(), 5);
assert_eq!(Region::Dendy.cpu_divider(), 15);
assert_eq!(Region::Dendy.dot_divider(), 5);
let mut forest = ClockForest::new();
let master = forest
.add_oscillator("master", Rational::new(236_250_000, 11).unwrap())
.unwrap();
let cpu = forest.add_domain("cpu", master, 1, 12).unwrap();
let dots = add_clock_domain(&mut forest, master, Region::Ntsc).unwrap();
assert_eq!(forest.convert_ticks(cpu, dots, 1).unwrap(), 3);
}
#[test]
fn a_frame_is_the_number_of_cpu_cycles_the_chart_gives() {
for (region, cycles_x2) in [
(Region::Ntsc, 59_561u64), (Region::Pal, 66_495), (Region::Dendy, 70_928), ] {
let g = region.geometry();
let dots_x2 = 2 * g.dots_per_frame - u64::from(g.odd_frame_skip);
assert_eq!(
dots_x2 * region.dot_divider(),
cycles_x2 * region.cpu_divider(),
"{region}"
);
}
}
#[test]
fn the_nmi_announce_delay_is_one_cpu_cycle_rounded_up() {
assert_eq!(Region::Ntsc.geometry().nmi_announce_dots, 3);
assert_eq!(Region::Pal.geometry().nmi_announce_dots, 4);
assert_eq!(Region::Dendy.geometry().nmi_announce_dots, 3);
for region in REGIONS {
let g = region.geometry();
assert!(
g.nmi_announce_dots * region.dot_divider() >= region.cpu_divider(),
"{region} must not announce before a CPU cycle has passed"
);
}
}
#[test]
fn the_nmi_reaches_the_wire_after_that_delay_in_every_region() {
for region in REGIONS {
let g = region.geometry();
let (ppu, _, _) = new_ppu_in(region);
let log = with_nmi(&ppu);
ppu.write_register(PPUCTRL, CTRL_NMI);
seek(&ppu, g.vblank_scanline, 1);
ppu.advance_by(g.nmi_announce_dots - 1);
assert!(
!log.levels.lock().iter().any(|high| *high),
"{region}: /NMI must not be announced inside one CPU cycle"
);
ppu.advance_by(1);
assert!(
log.levels.lock().iter().any(|high| *high),
"{region}: /NMI must be announced once the CPU cycle has passed"
);
}
}
#[test]
fn the_write_lockout_is_29658_cpu_cycles_in_every_region() {
for region in REGIONS {
let dots = region.geometry().warmup_dots;
assert_eq!(
dots,
RESET_LOCKOUT_CPU_CYCLES * region.cpu_divider() / region.dot_divider(),
"{region}"
);
}
assert_eq!(Region::Ntsc.geometry().warmup_dots, 88_974);
assert_eq!(Region::Pal.geometry().warmup_dots, 94_905);
assert_eq!(Region::Dendy.geometry().warmup_dots, 88_974);
}
#[test]
fn pal_and_dendy_black_out_the_top_scanline_of_the_picture() {
assert_eq!(Region::Ntsc.geometry().picture_height, 240);
assert_eq!(Region::Pal.geometry().picture_height, 239);
assert_eq!(Region::Dendy.geometry().picture_height, 239);
for region in REGIONS {
let (ppu, _, _) = new_ppu_in(region);
ppu.poke_palette(0x3f00, 0x21);
ppu.advance_by(u64::from(DOTS_PER_SCANLINE) * 2);
let top = ppu.pixel(0, 0).unwrap().index();
let next = ppu.pixel(0, 1).unwrap().index();
assert_eq!(next, 0x21, "{region}: line 1 is picture in every region");
if region.geometry().top_border_lines() == 0 {
assert_eq!(top, 0x21, "{region}");
} else {
assert_eq!(top, BORDER_BLACK, "{region}");
}
}
}
#[test]
fn a_region_is_a_property_and_a_bad_one_names_the_alternatives() {
for region in REGIONS {
let ppu = NesPpu::new(&Props::new().with("region", region.name())).unwrap();
assert_eq!(ppu.tv_region(), region);
assert_eq!(ppu.geometry(), region.geometry());
}
assert_eq!(
NesPpu::new(&Props::new()).unwrap().tv_region(),
Region::Ntsc
);
let err = NesPpu::new(&Props::new().with("region", "secam"))
.unwrap_err()
.to_string();
assert!(err.contains("ntsc"), "{err}");
assert!(err.contains("pal"), "{err}");
assert!(err.contains("dendy"), "{err}");
assert_eq!(Region::from_name("dendy"), Some(Region::Dendy));
assert_eq!(Region::from_name("secam"), None);
for region in REGIONS {
assert_eq!(Region::from_name(region.name()), Some(region));
assert!(Region::NAMES.contains(®ion.name()));
assert!(!region.part_number().is_empty());
}
}
#[test]
fn the_master_clock_is_an_exact_rational_in_every_region() {
assert_eq!(Region::Ntsc.master_clock(), (236_250_000, 11));
assert_eq!(Region::Pal.master_clock(), (53_203_425, 2));
assert_eq!(Region::Dendy.master_clock(), Region::Pal.master_clock());
for region in REGIONS {
let (num, den) = region.master_clock();
assert_ne!(num % den, 0, "{region} is not a whole number of hertz");
assert!(Rational::new(num, den).is_ok());
}
}
#[test]
fn the_region_is_configuration_and_survives_a_snapshot_untouched() {
let (pal, _, _) = new_ppu_in(Region::Pal);
pal.advance_by(1234);
let mut w = StateWriter::new(MachineShape::new());
{
let mut chunk = w
.chunk("/ppu", NES_PPU_CLASS.name, NES_PPU_CLASS.version)
.unwrap();
pal.save(&mut chunk).unwrap();
}
let bytes = w.to_vec().unwrap();
let (restored, _, _) = new_ppu_in(Region::Pal);
let reader = StateReader::new(&bytes).unwrap();
let chunk = reader
.load(
"/ppu",
NES_PPU_CLASS.name,
NES_PPU_CLASS.version,
&Migrations::new(),
)
.unwrap();
restored.load(&mut chunk.reader()).unwrap();
assert_eq!(restored.tv_region(), Region::Pal);
assert_eq!(restored.dots(), 1234);
let (ntsc, _, _) = new_ppu_in(Region::Ntsc);
ntsc.load(&mut chunk.reader()).unwrap();
assert_eq!(ntsc.tv_region(), Region::Ntsc);
}
#[test]
fn the_register_block_is_the_region_a_map_statement_names() {
let (ppu, _, _) = new_ppu();
let unnamed = Device::region(&ppu, "").expect("the whole aperture");
let named = Device::region(&ppu, REGISTER_REGION).expect("`regs`");
assert_eq!(unnamed.len(), REGISTER_WINDOW_LEN);
assert!(Arc::ptr_eq(&unnamed, &named));
assert!(Device::region(&ppu, "vram").is_none());
let space = AddressSpace::new("cpu", 16);
space.topology().map(named, REGISTER_BASE).unwrap();
ppu.with_engine(|e| e.status |= STATUS_VBLANK);
let status = space
.read(REGISTER_BASE + 2, Width::U8, MemAttrs::DEFAULT)
.unwrap();
assert_ne!(status as u8 & STATUS_VBLANK, 0);
ppu.with_engine(|e| e.status |= STATUS_VBLANK);
let mirrored = space
.read(REGISTER_BASE + 0x1ffa, Width::U8, MemAttrs::DEFAULT)
.unwrap();
assert_ne!(mirrored as u8 & STATUS_VBLANK, 0);
}
#[test]
fn the_nmi_pin_connects_announces_and_refuses_anything_else() {
let (ppu, _, _) = new_ppu();
ppu.with_engine(|e| {
e.status |= STATUS_VBLANK;
e.ctrl |= CTRL_NMI;
e.dots = 100;
});
let ids = WireIdAllocator::new();
let id = ids.alloc();
let sink = Arc::new(Recorder::default());
let wire = Wire::builder()
.source(id)
.sink(Arc::clone(&sink) as Arc<dyn WireSink>, 0)
.build_shared();
ppu.connect(NMI_PIN, WireSource::new(wire, id)).unwrap();
assert!(
sink.levels.lock().is_empty(),
"connecting is not an outward action; announcing is"
);
ppu.announce(NMI_PIN);
assert_eq!(sink.levels.lock().last().copied(), Some(true));
let other = Wire::builder().source(id).build_shared();
let err = ppu
.connect("irq", WireSource::new(other, id))
.unwrap_err()
.to_string();
assert!(err.contains("irq"), "{err}");
ppu.announce("irq");
}
#[test]
fn the_class_constructs_through_the_registry_with_a_region() {
let mut registry = crate::core::Registry::new();
register(&mut registry).unwrap();
let device = registry
.create("nes.ppu", &Props::new().with("region", "dendy"))
.unwrap();
assert_eq!(device.class().name, "nes.ppu");
assert!(
NES_PPU_CLASS.properties.iter().any(|p| p.name == "region"),
"`rsemu describe nes.ppu` must list it"
);
assert!(Device::region(device.as_ref(), "").is_some());
}
#[test]
fn the_next_event_is_always_far_enough_ahead_to_be_reachable() {
for region in [Region::Ntsc, Region::Pal, Region::Dendy] {
let (ppu, _, _) = new_ppu_in(region);
let lead = ppu.geometry().nmi_announce_dots + 1;
let ceiling = 2 * u64::from(DOTS_PER_SCANLINE) + lead;
for _ in 0..(ppu.geometry().dots_per_frame + 500) {
let next = ppu.next_event_dot();
let ahead = next - ppu.dots();
assert!(
ahead >= lead,
"{region}: next event {next} is only {ahead} dots past {}",
ppu.dots()
);
assert!(
ahead <= ceiling,
"{region}: next event {next} is {ahead} dots away; a mid-quantum \
$2002 read would be that stale"
);
ppu.advance_by(1);
}
}
}
#[test]
fn stopping_at_every_next_event_still_reaches_the_nmi_on_its_own_dot() {
let (ppu, _, _) = new_ppu();
let nmi = with_nmi(&ppu);
ppu.write_register(regs::PPUCTRL, CTRL_NMI);
let geom = ppu.geometry();
let target = geom.dots_per_frame;
let mut first_high = None;
while ppu.dots() < target {
let next = ppu.next_event_dot().min(target);
ppu.advance_to(next);
if first_high.is_none() && nmi.levels.lock().iter().any(|l| *l) {
first_high = Some(ppu.dots());
}
}
let at = first_high.expect("the NMI never asserted in a frame");
let flag_dot = u64::from(geom.vblank_scanline) * u64::from(DOTS_PER_SCANLINE) + 1;
assert!(
(flag_dot + geom.nmi_announce_dots..=flag_dot + geom.nmi_announce_dots + 8).contains(&at),
"the request reached the wire at dot {at}, not just after {flag_dot}"
);
}
#[test]
fn the_lock_free_position_tracks_the_engine() {
let (ppu, _, _) = new_ppu();
let device: &dyn Device = &ppu;
assert!(device.is_lazy());
assert_eq!(device.current_tick(), 0);
ppu.advance_by(1000);
assert_eq!(device.current_tick(), ppu.dots());
assert_eq!(device.next_event_tick(), Some(ppu.next_event_dot()));
ppu.write_register(regs::PPUCTRL, CTRL_NMI);
assert_eq!(device.current_tick(), ppu.dots());
Device::reset(&ppu, ResetKind::Cold);
assert_eq!(device.current_tick(), 0);
assert_eq!(device.current_tick(), ppu.dots());
}
#[test]
fn a_snapshot_restores_the_lock_free_position_too() {
let (ppu, _, _) = new_ppu();
ppu.advance_by(12_345);
let mut w = StateWriter::new(MachineShape::new());
{
let mut chunk = w
.chunk("ppu", NES_PPU_CLASS.name, NES_PPU_CLASS.version)
.expect("a chunk");
Device::save(&ppu, &mut chunk).expect("saves");
}
let bytes = w.to_vec().expect("serializes");
let (restored, _, _) = new_ppu();
let reader = StateReader::new(&bytes).expect("well formed");
let chunk = reader
.load(
"ppu",
NES_PPU_CLASS.name,
NES_PPU_CLASS.version,
&Migrations::new(),
)
.expect("the chunk is there");
Device::load(&restored, &mut chunk.reader()).expect("loads");
let device: &dyn Device = &restored;
assert_eq!(device.current_tick(), 12_345);
assert_eq!(device.next_event_tick(), Some(restored.next_event_dot()));
}