feat(video): capture scanlines and prepare sprites one row ahead

Add cycle40 preparation, deferred diagnostics, HBlank capture, VBlank presentation, and affine history tests.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit 52c77193cb
9 changed files with 1376 additions and 0 deletions

76
src/video/capture.rs Normal file
View file

@ -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<u32>,
completed: Vec<u32>,
rows: usize,
drawing_error: Option<VideoError>,
completed_error: Option<VideoError>,
vblank: Option<u64>,
}
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<Vec<u32>, 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<u64> {
self.vblank
}
/// Errors leave the caller's last displayed image unchanged.
pub(crate) fn present(&self, output: &mut Framebuffer) -> Result<bool, VideoError> {
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)
}
}

View file

@ -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] = [

View file

@ -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<PreparedObjects, VideoError>)>; 2],
pub started: bool,
}
impl SpritePipeline {
pub fn store(&mut self, row: usize, objects: Result<PreparedObjects, VideoError>) {
self.rows[row % 2] = Some((row, objects));
}
pub fn row(&self, row: usize) -> &Result<PreparedObjects, VideoError> {
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<usize> {
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());
}
}
}
}

330
tests/affine_tracking.rs Normal file
View file

@ -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
);
}

38
tests/scanlines.rs Normal file
View file

@ -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<u8> {
code.iter().flat_map(|v| v.to_le_bytes()).collect()
}

169
tests/scanlines/capture.rs Normal file
View file

@ -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());
}

196
tests/scanlines/machine.rs Normal file
View file

@ -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))
);
}
}

View file

@ -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]);
}

288
tests/sprite_pipeline.rs Normal file
View file

@ -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));
}