diff --git a/src/video/capture.rs b/src/video/capture.rs new file mode 100644 index 0000000..bde3ad5 --- /dev/null +++ b/src/video/capture.rs @@ -0,0 +1,76 @@ +//! Optional scanline frame capture. Rows sample at HBlank entry; publication +//! happens at VBlank entry. This is not a pixel fetch pipeline. + +use super::{Framebuffer, VideoError, HEIGHT, WIDTH}; + +pub(crate) struct Capture { + drawing: Vec, + completed: Vec, + rows: usize, + drawing_error: Option, + completed_error: Option, + vblank: Option, +} + +impl Default for Capture { + fn default() -> Self { + Self { + drawing: vec![0; WIDTH * HEIGHT], + completed: vec![0; WIDTH * HEIGHT], + rows: 0, + drawing_error: None, + completed_error: None, + vblank: None, + } + } +} + +impl Capture { + /// Starting capture in the middle of a frame must not publish missing rows. + pub(crate) fn wants_row(&self, row: usize) -> bool { + row == 0 || (self.rows != 0 && row == self.rows) + } + + pub(crate) fn record(&mut self, row: usize, pixels: Result, VideoError>) { + if row == 0 { + self.rows = 0; + self.drawing_error = None; + } + debug_assert_eq!(self.rows, row); + match pixels { + Ok(pixels) => self.drawing[row * WIDTH..(row + 1) * WIDTH].copy_from_slice(&pixels), + Err(error) => { + self.drawing_error.get_or_insert(error); + } + } + self.rows += 1; + } + + pub(crate) fn publish(&mut self, vblank: u64) { + if self.rows != HEIGHT { + return; + } + self.completed_error = self.drawing_error.take(); + if self.completed_error.is_none() { + std::mem::swap(&mut self.drawing, &mut self.completed); + } + self.vblank = Some(vblank); + self.rows = 0; + } + + pub(crate) fn vblank(&self) -> Option { + self.vblank + } + + /// Errors leave the caller's last displayed image unchanged. + pub(crate) fn present(&self, output: &mut Framebuffer) -> Result { + if self.vblank.is_none() { + return Ok(false); + } + if let Some(error) = &self.completed_error { + return Err(error.clone()); + } + output.pixels.copy_from_slice(&self.completed); + Ok(true) + } +} diff --git a/src/video/sprites.rs b/src/video/sprites.rs index 647522c..20cd3b6 100644 --- a/src/video/sprites.rs +++ b/src/video/sprites.rs @@ -1,6 +1,8 @@ //! Regular and affine OBJ sampling. //! Individual fetch timing and OAM contention are not modeled. +pub(crate) mod pipeline; + use super::{halfword, Pixel, VideoError, WIDTH}; const SIZES: [[(usize, usize); 4]; 3] = [ diff --git a/src/video/sprites/pipeline.rs b/src/video/sprites/pipeline.rs new file mode 100644 index 0000000..35b9efd --- /dev/null +++ b/src/video/sprites/pipeline.rs @@ -0,0 +1,122 @@ +//! Row-at-once approximation of the one-line-ahead OBJ pipeline. +//! OAM and VRAM sample at cycle40 of the preceding line. This does not model +//! individual fetches, fetch budgets, or OAM/VRAM contention. + +use super::PreparedObjects; +use crate::{display::CYCLES_PER_LINE, video::VideoError}; + +pub(crate) const PREPARE_CYCLE: u32 = 40; + +#[derive(Default)] +pub(crate) struct SpritePipeline { + rows: [Option<(usize, Result)>; 2], + pub started: bool, +} + +impl SpritePipeline { + pub fn store(&mut self, row: usize, objects: Result) { + self.rows[row % 2] = Some((row, objects)); + } + + pub fn row(&self, row: usize) -> &Result { + let (prepared_row, objects) = self.rows[row % 2] + .as_ref() + .expect("sprite row prepared before capture"); + assert_eq!(*prepared_row, row); + objects + } +} + +/// Only rows0..158 and227 prepare the following visible row. +pub(crate) fn target(scanline: u16) -> Option { + match scanline { + 0..=158 => Some(usize::from(scanline) + 1), + 227 => Some(0), + _ => None, + } +} + +pub(crate) fn next_event(scanline: u16, cycle: u16) -> u32 { + let (line, cycle) = (u32::from(scanline), u32::from(cycle)); + if target(scanline).is_some() && cycle < PREPARE_CYCLE { + return PREPARE_CYCLE - cycle; + } + let lines = if (158..227).contains(&line) { + 227 - line + } else { + 1 + }; + lines * CYCLES_PER_LINE + PREPARE_CYCLE - cycle +} + +/// Latest two events suffice when capture is disabled: fixed memory/registers +/// cannot change inside a clock batch, and only two prepared rows are retained. +/// Offsets are relative to the supplied starting phase, in chronological order. +pub(crate) fn latest_events(phase: u32, cycles: u32) -> [Option<(u32, usize)>; 2] { + let start = u64::from(phase); + let end = start + u64::from(cycles); + let latest = |end: u64| { + let mut line = end.checked_sub(u64::from(PREPARE_CYCLE))? / u64::from(CYCLES_PER_LINE); + let row = line % 228; + if (159..227).contains(&row) { + line -= row - 158; + } + let time = line * u64::from(CYCLES_PER_LINE) + u64::from(PREPARE_CYCLE); + (time > start).then_some((time, ((line + 1) % 228) as usize)) + }; + let Some((last, row)) = latest(end) else { + return [None; 2]; + }; + let previous = latest(last - 1).map(|(time, row)| ((time - start) as u32, row)); + [previous, Some(((last - start) as u32, row))] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::CYCLES_PER_FRAME; + + #[test] + fn event_selection_matches_independent_line_iteration() { + let mut seed = 0x6328_93abu32; + for case in 0..1000 { + let mut next = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + seed + }; + let phase = next() % CYCLES_PER_FRAME; + let cycles = if case == 0 { + u32::MAX + } else { + next() % (CYCLES_PER_FRAME * 5) + }; + let end = u64::from(phase) + u64::from(cycles); + let mut expected = [None; 2]; + for line in u64::from(phase / CYCLES_PER_LINE)..=end / u64::from(CYCLES_PER_LINE) { + let time = line * u64::from(CYCLES_PER_LINE) + 40; + if time > u64::from(phase) && time <= end && (line % 228 < 159 || line % 228 == 227) + { + expected[0] = expected[1]; + expected[1] = Some(( + (time - u64::from(phase)) as u32, + ((line + 1) % 228) as usize, + )); + } + } + assert_eq!(latest_events(phase, cycles), expected); + } + } + + #[test] + fn next_event_handles_visible_end_hidden_lines_and_exact_boundary() { + for line in 0..228 { + for cycle in [0, 39, 40, 1006, 1231] { + let phase = u32::from(line) * CYCLES_PER_LINE + u32::from(cycle); + let distance = next_event(line, cycle); + assert!(distance > 0); + assert_eq!(latest_events(phase, distance - 1), [None; 2]); + assert!(latest_events(phase, distance)[1].is_some()); + } + } + } +} diff --git a/tests/affine_tracking.rs b/tests/affine_tracking.rs new file mode 100644 index 0000000..7712b52 --- /dev/null +++ b/tests/affine_tracking.rs @@ -0,0 +1,330 @@ +use gba_rust::{ + affine_raster_demo::{AffineRasterDemo, AFFINE_RASTER_STATE, AFFINE_RASTER_TABLE}, + cpu::Cpu, + display::{CYCLES_PER_FRAME, CYCLES_PER_LINE as LINE, HBLANK_START as HB, VBLANK_START as VB}, + dma::DMA_BASE, + input::{Button, Buttons}, + io::*, + machine::Machine, + memory::{Memory, PALETTE_START as PAL, ROM_START, VRAM_START as VRAM}, + video::{rgb555_to_rgb888 as rgb, Framebuffer, HEIGHT, WIDTH}, +}; + +fn color(x: u32, y: u32) -> u16 { + ((x & 31) | ((y & 31) << 5) | (((x + y) & 31) << 10)) as u16 +} + +fn bitmap() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m.write16(DISPCNT, 0x403).unwrap(); + m.write16(BG2PA, 256).unwrap(); + m.write16(BG2PB, 256).unwrap(); + m.write16(BG2PD, 256).unwrap(); + for y in 0..160 { + for x in 0..240 { + m.write16(VRAM + (y * 240 + x) * 2, color(x, y)).unwrap(); + } + } + m +} + +fn finish(m: &mut Memory) -> Framebuffer { + let p = m.display_position(); + let phase = u32::from(p.scanline) * LINE + u32::from(p.line_cycle); + m.advance_cycles(VB - phase); + let mut f = Framebuffer::default(); + assert!(m.present_frame(&mut f).unwrap()); + f +} + +fn row(f: &Framebuffer, y: usize, sx: u32, sy: u32) { + assert_eq!(f.pixels()[y * WIDTH], rgb(color(sx, sy)), "row={y}"); +} + +#[test] +fn coefficient_changes_accumulate_instead_of_multiplying_absolute_screen_y() { + let mut m = bitmap(); + m.advance_cycles(2 * LINE + HB); + m.write16(BG2PB, 3 * 256).unwrap(); + m.write16(BG2PD, 2 * 256).unwrap(); + let f = finish(&mut m); + for (y, x, sy) in [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 5, 4), (4, 8, 6)] { + row(&f, y, x, sy); + } + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + row(&snapshot, 3, 9, 6); // Debug snapshots still use programmed origins. +} + +#[test] +fn visible_reference_writes_replace_current_origin_and_only_the_written_axis() { + let mut m = bitmap(); + m.advance_cycles(3 * LINE); + m.write32(BG2X, 10 * 256).unwrap(); + m.advance_cycles(HB); + m.write32(BG2Y, 20 * 256).unwrap(); // HBlank: override next Y increment. + let f = finish(&mut m); + row(&f, 2, 2, 2); + row(&f, 3, 10, 3); + row(&f, 4, 11, 20); + row(&f, 5, 12, 21); +} + +#[test] +fn partial_reference_writes_merge_programmed_not_accumulated_values() { + let mut m = bitmap(); + m.write32(BG2X, 0x200).unwrap(); + m.advance_cycles(5 * LINE); + m.write8(BG2X, 0x80).unwrap(); + let f = finish(&mut m); + row(&f, 4, 6, 4); + row(&f, 5, 2, 5); + row(&f, 6, 3, 6); + assert_eq!(m.read32(BG2X).unwrap(), 0); // Still write-only. +} + +#[test] +fn line_zero_reloads_programmed_origins_after_vblank_writes() { + let mut m = bitmap(); + finish(&mut m); + m.write32(BG2X, 7 * 256).unwrap(); + m.write32(BG2Y, 9 * 256).unwrap(); + m.advance_cycles(CYCLES_PER_FRAME - VB); + let f = finish(&mut m); + row(&f, 0, 7, 9); + row(&f, 1, 8, 10); + m.advance_cycles(CYCLES_PER_FRAME - VB); + let f = finish(&mut m); + row(&f, 0, 7, 9); // Not the last frame's accumulated position. +} + +#[test] +fn disabled_and_non_affine_modes_pause_tracking_but_forced_blank_does_not() { + for disabled in [3, 0x400, 0x483] { + let mut m = bitmap(); + m.advance_cycles(2 * LINE); + m.write16(DISPCNT, disabled).unwrap(); + m.advance_cycles(3 * LINE); + m.write16(DISPCNT, 0x403).unwrap(); + let f = finish(&mut m); + let origin = if disabled == 0x483 { 5 } else { 2 }; + row(&f, 5, origin, origin); + if disabled == 0x483 { + assert_eq!(f.pixels()[2 * WIDTH], 0xffffff); + } + } +} + +#[test] +fn window_masks_do_not_pause_internal_tracking() { + let mut m = bitmap(); + m.write16(DISPCNT, 0x2403).unwrap(); + m.write16(WIN0H, 240).unwrap(); + m.write16(WIN0V, 160).unwrap(); + m.write16(WININ, 0).unwrap(); + m.advance_cycles(5 * LINE); + m.write16(WININ, 4).unwrap(); + let f = finish(&mut m); + assert_eq!(f.pixels()[4 * WIDTH], 0); + row(&f, 5, 5, 5); +} + +#[test] +fn mosaic_holds_origin_then_uses_current_coefficient_for_block_increment() { + let mut m = bitmap(); + m.write16(BG2CNT, 0x40).unwrap(); + m.write16(MOSAIC, 0x30).unwrap(); // Four-row vertical blocks. + m.advance_cycles(2 * LINE + HB); + m.write16(BG2PB, 2 * 256).unwrap(); + let f = finish(&mut m); + for y in 0..4 { + row(&f, y, 0, 0); + } + for y in 4..8 { + row(&f, y, 8, 4); + } + row(&f, 8, 16, 8); +} + +#[test] +fn bg2_and_bg3_track_independently_in_supported_tiled_modes() { + for mode in [1, 2] { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m.write16(DISPCNT, 0x400 | mode).unwrap(); + m.write16(BG2CNT, 0x1000).unwrap(); + m.write16(BG3CNT, 0x1000).unwrap(); + for reg in [BG2PA, BG3PA] { + m.write16(reg, 256).unwrap(); + } + m.write16(BG2PB, 256).unwrap(); + m.write16(BG3PB, 2 * 256).unwrap(); + for index in 1..=8 { + m.write16(PAL + index * 2, color(index, 0)).unwrap(); + } + for y in 0..8 { + for x in (0..8).step_by(2) { + m.write16(VRAM + y * 8 + x, (x + 1) as u16 | (((x + 2) as u16) << 8)) + .unwrap(); + } + } + m.advance_cycles(3 * LINE); + m.write16(DISPCNT, if mode == 2 { 0x802 } else { 0x401 }) + .unwrap(); + let f = finish(&mut m); + row(&f, 2, 3, 0); + if mode == 2 { + row(&f, 3, 1, 0); // Disabled BG3 did not accumulate. + row(&f, 4, 3, 0); + row(&f, 5, 5, 0); + } else { + row(&f, 4, 5, 0); // BG2 remains affine in mode1. + } + } +} + +#[test] +fn capture_enable_does_not_reset_affine_hardware_state() { + let mut m = bitmap(); + m.set_scanline_rendering(false); + m.advance_cycles(CYCLES_PER_FRAME); // Batched clock path. + m.set_scanline_rendering(true); + m.advance_cycles(4 * LINE); + m.write32(BG2X, 9 * 256).unwrap(); + let captured = finish(&mut m); + row(&captured, 4, 9, 4); + m.set_scanline_rendering(false); + m.advance_cycles(CYCLES_PER_FRAME - VB); + m.set_scanline_rendering(true); + let captured = finish(&mut m); + row(&captured, 0, 9, 0); +} + +#[test] +fn hblank_dma_reference_writes_override_next_increment() { + let mut m = bitmap(); + for y in 0..160 { + m.write32(0x0200_0000 + y * 4, (y % 16 + 10) * 256).unwrap(); + } + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, BG2X).unwrap(); + m.write32(DMA_BASE + 8, 0xa640_0001).unwrap(); // Word, fixed destination, repeat HBlank. + m.write8(HALTCNT, 0).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + machine.run_until_vblank(1000).unwrap(); + let mut f = Framebuffer::default(); + assert!(machine.memory().present_frame(&mut f).unwrap()); + row(&f, 0, 0, 0); + for y in 1..HEIGHT { + row(&f, y, (y as u32 - 1) % 16 + 10, y as u32); + } +} + +#[test] +fn cpu_raster_demo_generates_dma_table_and_all_transform_combinations() { + let mut demo = AffineRasterDemo::new().unwrap(); + let mut f = Framebuffer::default(); + for index in 0..8 { + let rotated = index & 1 != 0; + let zoomed = index & 2 != 0; + let bypassed = index & 4 != 0; + let buttons = Buttons::default() + .with(Button::L, rotated) + .with(Button::R, zoomed) + .with(Button::A, bypassed) + .with(Button::Left, true) + .with(Button::Up, true); + demo.frame(buttons, &mut f).unwrap(); + let m = demo.machine().memory(); + let pan = -2 * (index + 1); + assert_eq!(m.read32(AFFINE_RASTER_STATE).unwrap(), index as u32 + 1); + assert_eq!(m.read32(AFFINE_RASTER_STATE + 4).unwrap() as i32, pan); + assert_eq!(m.read32(AFFINE_RASTER_STATE + 8).unwrap() as i32, pan); + assert_eq!(m.read16(DMA_BASE + 10).unwrap(), 0xa240); + assert_eq!(m.captured_vblank(), Some(m.display_position().vblanks)); + let a = match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }; + let b = if rotated { a } else { 0 }; + for y in 0..160 { + let offset = if bypassed { + 0 + } else if y & 16 == 0 { + 512 + } else { + -512 + }; + assert_eq!( + m.read16(AFFINE_RASTER_TABLE + y * 2).unwrap() as i16, + (b + offset) as i16 + ); + } + for y in 0..HEIGHT { + for x in 0..WIDTH { + let dx = x as i32 - 120; + let dy = y as i32 - 80; + let phase = y as i32 % 32; + let extra = if bypassed { + 0 + } else { + 512 * phase.min(32 - phase) + }; + let tx = ((128 + pan) * 256 + a * dx + b * dy + extra) + .div_euclid(256) + .rem_euclid(256) as usize; + let ty = ((128 + pan) * 256 - b * dx + a * dy) + .div_euclid(256) + .rem_euclid(256) as usize; + let tile = (tx / 32 + 2 * (ty / 32)) % 4; + let shade = (tx / 2 + ty / 2) % 2; + let expected = [ + 0x0260, 0x03a0, 0x7d20, 0x7e80, 0x001f, 0x421f, 0x03ff, 0x7fff, + ][tile * 2 + shade]; + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb(expected), + "case={index} x={x} y={y}" + ); + } + } + } + demo.frame( + Buttons::default() + .with(Button::Left, true) + .with(Button::Right, true), + &mut f, + ) + .unwrap(); + assert_eq!( + demo.machine() + .memory() + .read32(AFFINE_RASTER_STATE + 4) + .unwrap() as i32, + -16 + ); + demo.frame( + Buttons::default() + .with(Button::Start, true) + .with(Button::Right, true), + &mut f, + ) + .unwrap(); + assert_eq!( + demo.machine() + .memory() + .read32(AFFINE_RASTER_STATE + 4) + .unwrap(), + 0 + ); + assert_eq!( + demo.machine() + .memory() + .read32(AFFINE_RASTER_STATE + 8) + .unwrap(), + 0 + ); +} diff --git a/tests/scanlines.rs b/tests/scanlines.rs new file mode 100644 index 0000000..d761b95 --- /dev/null +++ b/tests/scanlines.rs @@ -0,0 +1,38 @@ +use gba_rust::{ + cpu::Cpu, + display::{CYCLES_PER_FRAME, CYCLES_PER_LINE, HBLANK_START, VBLANK_START}, + dma::DMA_BASE, + input::{Button, Buttons}, + io::*, + machine::{Machine, StepKind}, + memory::{Memory, OAM_START as OAM, PALETTE_START as PAL, ROM_START, VRAM_START as VRAM}, + raster_demo::{RasterDemo, RASTER_STATE, RASTER_TABLE}, + video::{rgb555_to_rgb888 as rgb, Framebuffer, VideoError, HEIGHT, WIDTH}, +}; + +#[path = "scanlines/capture.rs"] +mod capture; +#[path = "scanlines/machine.rs"] +mod machine; +#[path = "scanlines/rendering.rs"] +mod rendering; + +fn memory() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m +} + +fn present(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + assert!(m.present_frame(&mut f).unwrap()); + f +} + +fn at(f: &Framebuffer, x: usize, y: usize) -> u32 { + f.pixels()[y * WIDTH + x] +} + +fn word_rom(code: &[u32]) -> Vec { + code.iter().flat_map(|v| v.to_le_bytes()).collect() +} diff --git a/tests/scanlines/capture.rs b/tests/scanlines/capture.rs new file mode 100644 index 0000000..51182d6 --- /dev/null +++ b/tests/scanlines/capture.rs @@ -0,0 +1,169 @@ +use super::*; + +#[test] +fn capture_is_optional_and_snapshot_api_remains_independent() { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(PAL, 31).unwrap(); + m.advance_cycles(CYCLES_PER_FRAME * 2); + let mut f = Framebuffer::default(); + f.clear(0x3e0); + assert!(!m.present_frame(&mut f).unwrap()); + assert!(f.pixels().iter().all(|&p| p == rgb(0x3e0))); + assert_eq!(m.captured_vblank(), None); + m.render_frame(&mut f).unwrap(); + assert!(f.pixels().iter().all(|&p| p == rgb(31))); + let cycles = m.cycles(); + m.set_scanline_rendering(true); + assert_eq!(m.cycles(), cycles); + m.advance_cycles(VBLANK_START); + assert_eq!(m.captured_vblank(), Some(3)); + assert_eq!(present(&m).pixels(), f.pixels()); +} + +#[test] +fn hblank_captures_old_row_and_later_palette_writes_do_not_recolor_it() { + let mut m = memory(); + m.write16(PAL, 31).unwrap(); + m.advance_cycles(HBLANK_START); + m.write16(PAL, 0x3e0).unwrap(); + m.advance_cycles(VBLANK_START - HBLANK_START); + let f = present(&m); + for y in 0..HEIGHT { + assert!(f.pixels()[y * WIDTH..(y + 1) * WIDTH] + .iter() + .all(|&p| p == rgb(if y == 0 { 31 } else { 0x3e0 }))); + } + m.write16(PAL, 0x7c00).unwrap(); + assert_eq!(present(&m).pixels(), f.pixels()); + let cycles = m.cycles(); + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + assert!(snapshot.pixels().iter().all(|&p| p == rgb(0x7c00))); + assert_eq!(m.cycles(), cycles); +} + +#[test] +fn last_row_capture_does_not_publish_before_vblank_entry() { + let mut m = memory(); + m.write16(PAL, 31).unwrap(); + let mut f = Framebuffer::default(); + f.clear(0x3e0); + m.advance_cycles(159 * CYCLES_PER_LINE + HBLANK_START); + assert!(!m.present_frame(&mut f).unwrap()); + m.advance_cycles(CYCLES_PER_LINE - HBLANK_START - 1); + assert!(!m.present_frame(&mut f).unwrap()); + assert!(f.pixels().iter().all(|&p| p == rgb(0x3e0))); + m.advance_cycles(1); + assert!(m.present_frame(&mut f).unwrap()); + assert_eq!(m.captured_vblank(), Some(1)); + assert!(f.pixels().iter().all(|&p| p == rgb(31))); +} + +#[test] +fn enabling_mid_frame_discards_partial_frame_and_repeated_enable_preserves_progress() { + for start in [HBLANK_START, 40 * CYCLES_PER_LINE, VBLANK_START - 1] { + let mut m = Memory::new(vec![]).unwrap(); + m.advance_cycles(start); + m.set_scanline_rendering(true); + m.write16(PAL, 31).unwrap(); + m.advance_cycles(VBLANK_START - start); + assert_eq!(m.captured_vblank(), None); + m.advance_cycles(CYCLES_PER_FRAME - VBLANK_START + HBLANK_START); + m.set_scanline_rendering(true); // Must not reset row0. + m.advance_cycles(VBLANK_START - HBLANK_START); + assert_eq!(m.captured_vblank(), Some(2)); + assert!(present(&m).pixels().iter().all(|&p| p == rgb(31))); + m.set_scanline_rendering(false); + assert_eq!(m.captured_vblank(), None); + assert!(!m.present_frame(&mut Framebuffer::default()).unwrap()); + } +} + +#[test] +fn enable_before_first_hblank_and_during_vblank_can_capture_next_complete_image() { + for start in [0, HBLANK_START - 1, VBLANK_START, CYCLES_PER_FRAME - 1] { + let mut m = Memory::new(vec![]).unwrap(); + m.advance_cycles(start); + m.set_scanline_rendering(true); + let target = if start < HBLANK_START { + VBLANK_START + } else { + CYCLES_PER_FRAME + VBLANK_START + }; + m.advance_cycles(target - start); + assert_eq!( + m.captured_vblank(), + Some(if start < HBLANK_START { 1 } else { 2 }) + ); + assert!(m.present_frame(&mut Framebuffer::default()).unwrap()); + } +} + +#[test] +fn large_batches_publish_latest_frame_and_zero_cycles_have_no_effect() { + let mut m = memory(); + m.write16(PAL, 0x4210).unwrap(); + m.advance_cycles(CYCLES_PER_FRAME * 3 + VBLANK_START); + assert_eq!(m.captured_vblank(), Some(4)); + let before = m.display_position(); + let f = present(&m); + m.advance_cycles(0); + m.write16(PAL, 0).unwrap(); + assert_eq!(m.display_position(), before); + assert_eq!(present(&m).pixels(), f.pixels()); + assert!(f.pixels().iter().all(|&p| p == rgb(0x4210))); +} + +#[test] +fn first_render_error_is_reported_at_publication_without_partial_output_then_recovers() { + let mut m = memory(); + m.write16(PAL, 31).unwrap(); + m.advance_cycles(VBLANK_START); + let mut f = present(&m); + m.advance_cycles(CYCLES_PER_FRAME - VBLANK_START); + m.write16(DISPCNT, 6).unwrap(); + m.advance_cycles(HBLANK_START); + m.write16(DISPCNT, 7).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); + m.write16(DISPCNT, 0).unwrap(); + assert!(m.present_frame(&mut f).unwrap()); // Previous frame remains available while drawing. + m.advance_cycles(VBLANK_START - HBLANK_START - CYCLES_PER_LINE); + assert_eq!(m.captured_vblank(), Some(2)); + assert_eq!(m.present_frame(&mut f), Err(VideoError::UnsupportedMode(6))); + assert!(f.pixels().iter().all(|&p| p == rgb(31))); + m.write16(PAL, 0x3e0).unwrap(); + m.advance_cycles(CYCLES_PER_FRAME); + assert_eq!(m.captured_vblank(), Some(3)); + assert!(m.present_frame(&mut f).unwrap()); + assert!(f.pixels().iter().all(|&p| p == rgb(0x3e0))); +} + +#[test] +fn split_clock_advance_matches_single_cycle_reference_and_disabled_capture_devices() { + let mut bulk = memory(); + let mut single = memory(); + let mut disabled = Memory::new(vec![]).unwrap(); + for m in [&mut bulk, &mut single, &mut disabled] { + m.write16(DISPSTAT, 0x5038).unwrap(); + m.write32(TIMER_BASE, 0x00c0_fff1).unwrap(); + m.write16(IE, 15).unwrap(); + m.write8(HALTCNT, 0).unwrap(); + m.write16(PAL, 0x4210).unwrap(); + } + let cycles = CYCLES_PER_FRAME + VBLANK_START + 73; + bulk.advance_cycles(cycles); + disabled.advance_cycles(cycles); + for _ in 0..cycles { + single.advance_cycles(1); + } + for m in [&single, &disabled] { + assert_eq!(bulk.display_position(), m.display_position()); + assert_eq!(bulk.cycles(), m.cycles()); + assert_eq!(bulk.halted(), m.halted()); + for register in [DISPSTAT, IF, TIMER_BASE] { + assert_eq!(bulk.read16(register), m.read16(register)); + } + } + assert_eq!(present(&bulk).pixels(), present(&single).pixels()); + assert_eq!(bulk.captured_vblank(), single.captured_vblank()); +} diff --git a/tests/scanlines/machine.rs b/tests/scanlines/machine.rs new file mode 100644 index 0000000..06a1e6b --- /dev/null +++ b/tests/scanlines/machine.rs @@ -0,0 +1,196 @@ +use super::*; + +#[test] +fn hblank_dma_updates_next_row_while_cpu_stays_halted() { + let mut m = memory(); + m.write16(PAL, 31).unwrap(); + for i in 0..160 { + m.write16(0x0200_0000 + i * 2, ((i + 1) as u16 & 31) << 5) + .unwrap(); + } + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, PAL).unwrap(); + m.write32(DMA_BASE + 8, 0xa240_0001).unwrap(); + m.write8(HALTCNT, 0).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + let cpu = machine.cpu().clone(); + assert_eq!(machine.step().unwrap(), StepKind::HaltIdle); + assert_eq!( + machine.memory().display_position().line_cycle, + HBLANK_START as u16 + ); + assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 0 }); + machine.run_until_vblank(1000).unwrap(); + assert_eq!(machine.cpu(), &cpu); + assert!(machine.halted()); + let f = present(machine.memory()); + for y in 0..HEIGHT { + let color = if y == 0 { 31 } else { (y as u16 & 31) << 5 }; + assert!(f.pixels()[y * WIDTH..(y + 1) * WIDTH] + .iter() + .all(|&p| p == rgb(color))); + } + assert_eq!(machine.memory().read16(PAL).unwrap(), 0); // Row159 HBlank DMA ran after its capture. +} + +#[test] +fn vblank_dma_cannot_change_the_frame_just_published() { + let mut m = memory(); + m.write16(PAL, 31).unwrap(); + m.write16(0x0200_0000, 0x3e0).unwrap(); + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, PAL).unwrap(); + m.write32(DMA_BASE + 8, 0x9000_0001).unwrap(); + m.advance_cycles(VBLANK_START); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 0 }); + assert!(present(machine.memory()) + .pixels() + .iter() + .all(|&p| p == rgb(31))); + machine.memory_mut().advance_cycles(CYCLES_PER_FRAME); + assert!(present(machine.memory()) + .pixels() + .iter() + .all(|&p| p == rgb(0x3e0))); +} + +#[test] +fn cpu_polling_vcount_can_split_the_frame_without_host_video_writes() { + let rom = word_rom(&[ + 0xe3a0_0301, // MOV r0,#0x04000000 + 0xe1d0_10b6, // LDRH r1,[r0,#6] (VCOUNT) + 0xe351_0050, // CMP r1,#80 + 0x1aff_fffc, // BNE poll + 0xe3a0_2405, // MOV r2,#0x05000000 + 0xe3a0_3c7c, // MOV r3,#0x7c00 + 0xe1c2_30b0, // STRH r3,[r2] + 0xeaff_fffe, + ]); + let mut m = Memory::new(rom).unwrap(); + m.set_scanline_rendering(true); + m.write16(PAL, 31).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + machine.run_until_vblank(100_000).unwrap(); + let f = present(machine.memory()); + for y in 0..HEIGHT { + let color = if y < 80 { 31 } else { 0x7c00 }; + assert!(f.pixels()[y * WIDTH..(y + 1) * WIDTH] + .iter() + .all(|&p| p == rgb(color))); + } +} + +#[test] +fn instruction_crossing_hblank_uses_committed_write_for_entire_row() { + let rom = word_rom(&[ + 0xe3a0_0405, // MOV r0,#0x05000000 + 0xe3a0_1e3e, // MOV r1,#0x3e0 + 0xe1c0_10b0, // STRH r1,[r0] + ]); + let mut m = Memory::new(rom).unwrap(); + m.set_scanline_rendering(true); + m.write16(PAL, 31).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + machine.step().unwrap(); + machine.step().unwrap(); + let elapsed = machine.cycles() as u32; + machine + .memory_mut() + .advance_cycles(HBLANK_START - 1 - elapsed); + machine.step().unwrap(); + assert!(machine.cycles() > u64::from(HBLANK_START)); + let remaining = VBLANK_START - machine.cycles() as u32; + machine.memory_mut().advance_cycles(remaining); + // This pins the nominal whole-instruction policy, not pixel-accurate timing. + assert_eq!(at(&present(machine.memory()), 0, 0), rgb(0x3e0)); +} + +#[test] +fn failed_cpu_step_does_not_advance_or_discard_capture_progress() { + let mut m = Memory::new(word_rom(&[0xf000_0000])).unwrap(); + m.set_scanline_rendering(true); + m.write16(PAL, 31).unwrap(); + m.advance_cycles(HBLANK_START); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + let before = machine.memory().display_position(); + let cycles = machine.cycles(); + assert!(machine.step().is_err()); + assert_eq!(machine.cycles(), cycles); + assert_eq!(machine.memory().display_position(), before); + machine.memory_mut().write16(PAL, 0x3e0).unwrap(); + machine + .memory_mut() + .advance_cycles(VBLANK_START - HBLANK_START); + let f = present(machine.memory()); + assert_eq!(at(&f, 0, 0), rgb(31)); + assert_eq!(at(&f, 0, 1), rgb(0x3e0)); +} + +#[test] +fn cpu_raster_demo_copies_table_and_rearms_hblank_dma_across_frames() { + let mut demo = RasterDemo::new().unwrap(); + let mut f = Framebuffer::default(); + assert_eq!(demo.machine().memory().read16(RASTER_TABLE).unwrap(), 0); + for update in 1..=40 { + demo.frame(Buttons::default().with(Button::Right, true), &mut f) + .unwrap(); + let phase = update % 32; + let m = demo.machine().memory(); + assert_eq!(m.read32(RASTER_STATE).unwrap(), update); + assert_eq!(m.read32(RASTER_STATE + 4).unwrap(), phase); + assert_eq!(m.read16(DMA_BASE + 10).unwrap(), 0xa240); + assert_eq!(m.captured_vblank(), Some(m.display_position().vblanks)); + assert_eq!(m.display_position().scanline, 160); + for y in 0..HEIGHT { + let n = (y as u16 + phase as u16) % 32; + let color = n + (31 - n) * 32 + (n / 2) * 1024; + assert!( + f.pixels()[y * WIDTH..(y + 1) * WIDTH] + .iter() + .all(|&p| p == rgb(color)), + "update={update} row={y}" + ); + } + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + assert_ne!(snapshot.pixels(), f.pixels()); // Same memory, but no row history in a snapshot. + } + assert_eq!( + demo.machine().memory().read16(RASTER_TABLE).unwrap(), + 31 * 32 + ); +} + +#[test] +fn raster_controls_wrap_cancel_opposites_and_reset() { + let mut demo = RasterDemo::new().unwrap(); + let mut f = Framebuffer::default(); + for (buttons, phase) in [ + (Buttons::default().with(Button::Left, true), 31), + ( + Buttons::default() + .with(Button::Left, true) + .with(Button::Right, true), + 31, + ), + (Buttons::default().with(Button::Right, true), 0), + (Buttons::default().with(Button::Right, true), 1), + ( + Buttons::default() + .with(Button::Start, true) + .with(Button::Right, true), + 0, + ), + ] { + demo.frame(buttons, &mut f).unwrap(); + assert_eq!( + demo.machine().memory().read32(RASTER_STATE + 4).unwrap(), + phase + ); + assert_eq!( + at(&f, 0, 0), + rgb(phase as u16 | ((31 - phase as u16) << 5) | ((phase as u16 / 2) << 10)) + ); + } +} diff --git a/tests/scanlines/rendering.rs b/tests/scanlines/rendering.rs new file mode 100644 index 0000000..f595c61 --- /dev/null +++ b/tests/scanlines/rendering.rs @@ -0,0 +1,155 @@ +use super::*; + +#[test] +fn static_capture_matches_snapshots_in_every_mode_with_objects_mosaic_windows_and_effects() { + for mode in 0..=5 { + let mut m = memory(); + for i in 0..512 { + m.write16(PAL + i * 2, (i as u16).wrapping_mul(0x421)) + .unwrap(); + } + for i in 0..128 { + m.write16(OAM + i * 8, 0x200).unwrap(); + } + for offset in (0..0x14000).step_by(2) { + let value = if mode < 3 { + if offset < 0x4000 { + 0x1111 + } else { + 0 + } + } else { + (offset as u16).wrapping_mul(17) + }; + m.write16(VRAM + offset, value).unwrap(); + } + m.write16(DISPCNT, 0x3440 | mode).unwrap(); + m.write16(BG2CNT, 0x1041).unwrap(); + m.write16(BG2PA, 181).unwrap(); + m.write16(BG2PB, 91).unwrap(); + m.write16(BG2PC, (-91i16) as u16).unwrap(); + m.write16(BG2PD, 256).unwrap(); + m.write32(BG2X, 513).unwrap(); + m.write32(BG2Y, 7 * 256).unwrap(); + m.write16(OAM, 0x1003).unwrap(); + m.write16(OAM + 2, 5).unwrap(); + m.write16(OAM + 4, if mode >= 3 { 512 } else { 0 }).unwrap(); + let tiles = if mode >= 3 { 0x14000 } else { 0x10000 }; + for offset in (0..32).step_by(2) { + m.write16(VRAM + tiles + offset, 0x1111).unwrap(); + } + m.write16(MOSAIC, 0x3243).unwrap(); + m.write16(WIN0H, 0x0a3c).unwrap(); + m.write16(WIN0V, 0x0432).unwrap(); + m.write16(WININ, 0x1f).unwrap(); + m.write16(WINOUT, 0x3f).unwrap(); + m.write16(BLDCNT, 0xbf).unwrap(); + m.write16(BLDY, 5).unwrap(); + m.write16(GREENSWAP, 1).unwrap(); + let mut expected = Framebuffer::default(); + m.render_frame(&mut expected).unwrap(); + m.advance_cycles(VBLANK_START); + assert_eq!(present(&m).pixels(), expected.pixels(), "mode={mode}"); + } +} + +#[test] +fn page_palette_vram_and_oam_writes_only_change_rows_not_yet_captured() { + let mut m = memory(); + m.write16(DISPCNT, 0x404).unwrap(); + m.write16(BG2PA, 256).unwrap(); + m.write16(BG2PD, 256).unwrap(); + for i in 0..128 { + m.write16(OAM + i * 8, 0x200).unwrap(); + } + for offset in (0..38400).step_by(2) { + m.write16(VRAM + offset, 0x0101).unwrap(); + m.write16(VRAM + 0xa000 + offset, 0x0202).unwrap(); + } + for (index, color) in [(1, 31), (2, 0x3e0), (3, 0x7fff)] { + m.write16(PAL + index * 2, color).unwrap(); + } + m.advance_cycles(HBLANK_START); // Row0 page0 red. + m.write16(DISPCNT, 0x414).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); // Row1 page1 green. + m.write16(PAL + 4, 0x7c00).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); // Row2 same index, now blue. + m.write16(VRAM + 0xa000 + 3 * 240, 0x0303).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); // Row3 first two pixels now white. + m.write16(DISPCNT, 0x1454).unwrap(); + m.write16(OAM, 0).unwrap(); + m.write16(OAM + 4, 512).unwrap(); + for offset in (0..32).step_by(2) { + m.write16(VRAM + 0x14000 + offset, 0x1111).unwrap(); + } + m.write16(PAL + 0x202, 0).unwrap(); // Opaque black OBJ. + m.advance_cycles(VBLANK_START - (3 * CYCLES_PER_LINE + HBLANK_START)); + let f = present(&m); + // Row4's sprites were prepared before the row3 HBlank writes; row5 sees them. + for (y, color) in [ + (0, 31), + (1, 0x3e0), + (2, 0x7c00), + (3, 0x7fff), + (4, 0x7c00), + (5, 0), + ] { + assert_eq!(at(&f, 0, y), rgb(color)); + } + assert_eq!(at(&f, 8, 4), rgb(0x7c00)); + assert_eq!(at(&f, 0, 8), rgb(0x7c00)); +} + +#[test] +fn scroll_mosaic_window_effect_and_forced_blank_changes_apply_per_row() { + let mut m = memory(); + m.write16(DISPCNT, 0x100).unwrap(); + m.write16(BG0CNT, 0x1000).unwrap(); + m.write16(PAL, 0x4210).unwrap(); + m.write16(PAL + 2, 31).unwrap(); + m.write16(PAL + 4, 0x3e0).unwrap(); + for offset in (0..32).step_by(2) { + m.write16(VRAM + offset, 0x2121).unwrap(); + } + m.advance_cycles(HBLANK_START); + m.write16(BG0HOFS, 1).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); + m.write16(BG0CNT, 0x1040).unwrap(); + m.write16(MOSAIC, 1).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); + m.write16(DISPCNT, 0x2100).unwrap(); + m.write16(WIN0H, 1).unwrap(); + // Set the next row's top edge; a missed row-zero edge cannot open the window. + m.write16(WIN0V, 0x03a0).unwrap(); + m.write16(WININ, 0).unwrap(); + m.write16(WINOUT, 0x21).unwrap(); + m.write16(BLDCNT, 0xc1).unwrap(); + m.write16(BLDY, 8).unwrap(); + m.advance_cycles(CYCLES_PER_LINE); + m.write16(DISPCNT, 0x80).unwrap(); + m.advance_cycles(VBLANK_START - (3 * CYCLES_PER_LINE + HBLANK_START)); + let f = present(&m); + assert_eq!(&f.pixels()[..2], &[rgb(31), rgb(0x3e0)]); + assert_eq!(&f.pixels()[WIDTH..WIDTH + 2], &[rgb(0x3e0), rgb(31)]); + assert_eq!(&f.pixels()[2 * WIDTH..2 * WIDTH + 2], &[rgb(0x3e0); 2]); + assert_eq!(at(&f, 0, 3), rgb(0x4210)); + assert_eq!(at(&f, 1, 3), rgb(0x200)); + assert!(f.pixels()[4 * WIDTH..].iter().all(|&p| p == 0xffffff)); +} + +#[test] +fn green_swap_setting_is_captured_per_row_after_color_composition() { + let mut m = memory(); + m.write16(DISPCNT, 0x403).unwrap(); + m.write16(BG2PA, 256).unwrap(); + m.write16(BG2PD, 256).unwrap(); + for row in 0..2 { + m.write32(VRAM + row * 480, 0x03e0_001f).unwrap(); + } + m.advance_cycles(HBLANK_START); + m.write16(GREENSWAP, 1).unwrap(); + m.advance_cycles(VBLANK_START - HBLANK_START); + let f = present(&m); + assert_eq!(&f.pixels()[..2], &[0xff0000, 0xff00]); + assert_eq!(&f.pixels()[WIDTH..WIDTH + 2], &[0xffff00, 0]); +} diff --git a/tests/sprite_pipeline.rs b/tests/sprite_pipeline.rs new file mode 100644 index 0000000..56a39f2 --- /dev/null +++ b/tests/sprite_pipeline.rs @@ -0,0 +1,288 @@ +use gba_rust::{ + cpu::Cpu, + display::{CYCLES_PER_FRAME as FRAME, CYCLES_PER_LINE as LINE, VBLANK_START as VB}, + dma::DMA_BASE, + io::*, + machine::Machine, + memory::{Memory, OAM_START as OAM, PALETTE_START as PAL, ROM_START, VRAM_START as VRAM}, + video::{rgb555_to_rgb888 as rgb, Framebuffer, VideoError, WIDTH}, +}; + +fn scene() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m.write16(DISPCNT, 0x1040).unwrap(); + m.write16(PAL, 0x7c00).unwrap(); + m.write16(PAL + 0x202, 31).unwrap(); + m.write16(PAL + 0x204, 0x3e0).unwrap(); + for i in 0..128 { + m.write16(OAM + i * 8, 0x200).unwrap(); + } + m.write16(OAM, 0).unwrap(); + for offset in (0..32).step_by(2) { + m.write16(VRAM + 0x10000 + offset, 0x1111).unwrap(); + } + m +} + +fn finish(m: &mut Memory) -> Framebuffer { + let p = m.display_position(); + m.advance_cycles(VB - u32::from(p.scanline) * LINE - u32::from(p.line_cycle)); + present(m) +} + +fn present(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + assert!(m.present_frame(&mut f).unwrap()); + f +} + +fn at(f: &Framebuffer, x: usize, y: usize) -> u32 { + f.pixels()[y * WIDTH + x] +} + +#[test] +fn oam_position_writes_obey_preceding_lines_cycle40_boundary() { + for elapsed in [39, 40, 41] { + let mut m = scene(); + m.advance_cycles(elapsed); + m.write16(OAM + 2, 16).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 0), rgb(31)); // Reset row0 was already prepared. + let x = if elapsed < 40 { 16 } else { 0 }; + assert_eq!(at(&f, x, 1), rgb(31)); + assert_eq!(at(&f, 16 - x, 1), rgb(0x7c00)); + assert_eq!(at(&f, 16, 2), rgb(31)); + } +} + +#[test] +fn buffered_indices_keep_vram_history_but_resolve_current_palette() { + let mut m = scene(); + m.advance_cycles(40); + for offset in (0..32).step_by(2) { + m.write16(VRAM + 0x10000 + offset, 0x2222).unwrap(); + } + m.write16(PAL + 0x202, 0x7fff).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 0), rgb(0x7fff)); + assert_eq!(at(&f, 0, 1), rgb(0x7fff)); + assert_eq!(at(&f, 0, 2), rgb(0x3e0)); + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + assert_eq!(at(&snapshot, 0, 1), rgb(0x3e0)); + assert_eq!(present(&m).pixels(), f.pixels()); +} + +#[test] +fn affine_matrix_and_tile_samples_are_frozen_at_preparation() { + let mut m = scene(); + m.write16(OAM, 0x2100).unwrap(); // Affine, 8bpp. + m.write16(OAM + 6, 256).unwrap(); + m.write16(OAM + 30, 256).unwrap(); + for y in 0..8 { + for x in (0..8).step_by(2) { + m.write16( + VRAM + 0x10000 + y * 8 + x, + if x < 4 { 0x0101 } else { 0x0202 }, + ) + .unwrap(); + } + } + m.advance_cycles(40); + m.write16(OAM + 6, 0).unwrap(); // PA0 samples the texture center for every X. + let f = finish(&mut m); + assert_eq!(at(&f, 0, 1), rgb(31)); + assert_eq!(at(&f, 0, 2), rgb(0x3e0)); +} + +#[test] +fn semitransparency_is_buffered_but_blend_coefficients_remain_live() { + let mut m = scene(); + m.write16(OAM, 0x400).unwrap(); + m.advance_cycles(40); + m.write16(OAM, 0).unwrap(); + m.write16(BLDCNT, 0x2000).unwrap(); // Backdrop as second target. + m.write16(BLDALPHA, 0x0808).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 1), rgb(0x3c0f)); + assert_eq!(at(&f, 0, 2), rgb(31)); +} + +#[test] +fn obj_window_coverage_is_buffered_even_before_its_display_mask_is_enabled() { + let mut m = scene(); + m.write16(OAM, 0x800).unwrap(); + m.advance_cycles(40); + m.write16(OAM + 2, 16).unwrap(); + m.write16(DISPCNT, 0x9040).unwrap(); + m.write16(WINOUT, 0x2000).unwrap(); + m.write16(BLDCNT, 0xa0).unwrap(); + m.write16(BLDY, 16).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 1), rgb(0x7fff)); + assert_eq!(at(&f, 16, 1), rgb(0x7c00)); + assert_eq!(at(&f, 0, 2), rgb(0x7c00)); + assert_eq!(at(&f, 16, 2), rgb(0x7fff)); +} + +#[test] +fn horizontal_mosaic_uses_current_size_and_prepared_sprite_metadata() { + let mut m = scene(); + m.write16(OAM, 0x1000).unwrap(); + for offset in (0..32).step_by(2) { + m.write16(VRAM + 0x10000 + offset, 0x2121).unwrap(); + } + m.advance_cycles(40); + m.write16(OAM, 0).unwrap(); + m.write16(MOSAIC, 0x0300).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 1, 1), rgb(31)); + assert_eq!(at(&f, 1, 2), rgb(0x3e0)); +} + +#[test] +fn global_enable_gates_preparation_and_current_composition() { + let mut m = scene(); + m.write16(DISPCNT, 0x40).unwrap(); + m.advance_cycles(40); + m.write16(DISPCNT, 0x1040).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 0), rgb(0x7c00)); + assert_eq!(at(&f, 0, 1), rgb(0x7c00)); + assert_eq!(at(&f, 0, 2), rgb(31)); + + let mut m = scene(); + m.advance_cycles(40); + m.write16(DISPCNT, 0x40).unwrap(); + assert_eq!(at(&finish(&mut m), 0, 1), rgb(0x7c00)); +} + +#[test] +fn forced_blank_does_not_stop_preparation_in_this_row_model() { + let mut m = scene(); + m.write16(DISPCNT, 0x10c0).unwrap(); + m.advance_cycles(40); + m.write16(OAM + 2, 16).unwrap(); + m.write16(DISPCNT, 0x1040).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 1), rgb(31)); + assert_eq!(at(&f, 16, 2), rgb(31)); +} + +#[test] +fn hblank_dma_oam_changes_wait_until_a_later_preparation_event() { + let mut m = scene(); + for row in 0..160 { + m.write16(0x0200_0000 + row * 2, ((row + 1) % 20 * 8) as u16) + .unwrap(); + } + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, OAM + 2).unwrap(); + m.write32(DMA_BASE + 8, 0xa240_0001).unwrap(); + m.write8(HALTCNT, 0).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), m); + machine.run_until_vblank(1000).unwrap(); + let f = present(machine.memory()); + for y in 0usize..8 { + let left = y.saturating_sub(1) * 8; + for x in 0..WIDTH { + assert_eq!( + at(&f, x, y), + rgb(if (left..left + 8).contains(&x) { + 31 + } else { + 0x7c00 + }), + "pixel={x},{y}" + ); + } + } + assert!(machine.halted()); +} + +#[test] +fn final_hidden_line_prepares_row_zero_before_frame_wrap() { + for cycle in [39, 40] { + let mut m = scene(); + finish(&mut m); + m.advance_cycles(227 * LINE + cycle - VB); + m.write16(OAM + 2, 16).unwrap(); + m.advance_cycles(FRAME - 227 * LINE - cycle); + let f = finish(&mut m); + assert_eq!(at(&f, if cycle < 40 { 16 } else { 0 }, 0), rgb(31)); + assert_eq!(at(&f, 16, 1), rgb(31)); + } +} + +#[test] +fn capture_toggles_preserve_prepared_rows_after_large_capture_disabled_batches() { + for frames in [0, 3] { + let mut m = scene(); + m.set_scanline_rendering(false); + m.advance_cycles(frames * FRAME + 41); + m.write16(OAM + 2, 16).unwrap(); + m.set_scanline_rendering(true); + m.set_scanline_rendering(false); + m.set_scanline_rendering(true); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 0), rgb(31)); + assert_eq!(at(&f, 0, 1), rgb(31)); + assert_eq!(at(&f, 16, 2), rgb(31)); + } +} + +#[test] +fn prepared_errors_are_deferred_until_visible_composition_and_recover_next_frame() { + let mut m = scene(); + m.advance_cycles(1); // Bootstrap row0 remains valid. + m.write16(OAM, 0xc00).unwrap(); + m.advance_cycles(39); // Prepare invalid row1; clock advancement itself succeeds. + m.write16(OAM, 0).unwrap(); + m.advance_cycles(VB - 40); + let mut f = Framebuffer::default(); + f.clear(0x3e0); + assert!(matches!( + m.present_frame(&mut f), + Err(VideoError::UnsupportedObject { index: 0, .. }) + )); + assert!(f.pixels().iter().all(|&p| p == rgb(0x3e0))); + m.advance_cycles(FRAME); + assert!(m.present_frame(&mut f).unwrap()); + assert_eq!(at(&f, 0, 1), rgb(31)); +} + +#[test] +fn hidden_preparation_errors_do_not_bypass_global_disable_or_forced_blank() { + for control in [0x40, 0x10c0] { + let mut m = scene(); + m.write16(OAM, 0xc00).unwrap(); + m.advance_cycles(40); + m.write16(DISPCNT, control).unwrap(); + let f = finish(&mut m); + assert_eq!( + at(&f, 0, 1), + if control & 0x80 != 0 { + 0xffffff + } else { + rgb(0x7c00) + } + ); + } +} + +#[test] +fn zero_cycle_advances_and_failed_cpu_steps_do_not_prepare_rows() { + let mut m = scene(); + m.advance_cycles(0); + m.write16(OAM + 2, 16).unwrap(); + m.advance_cycles(39); + m.write32(0x0300_0000, 0xf000_0000).unwrap(); + let mut machine = Machine::new(Cpu::new(0x0300_0000), m); + assert!(machine.step().is_err()); + assert_eq!(machine.cycles(), 39); + machine.memory_mut().write16(OAM + 2, 24).unwrap(); + let f = finish(machine.memory_mut()); + assert_eq!(at(&f, 16, 0), rgb(31)); + assert_eq!(at(&f, 24, 1), rgb(31)); +}