diff --git a/src/video.rs b/src/video.rs new file mode 100644 index 0000000..665b55f --- /dev/null +++ b/src/video.rs @@ -0,0 +1,381 @@ +//! Platform-independent Mode 0–5 rendering for snapshots and row capture. +//! Includes window masks, mosaic, and color effects. Horizontal window history +//! has four-cycle timing. Sprites prepare one row ahead; backgrounds, palettes, +//! and color effects still sample once per displayed row. + +mod affine; +pub(crate) mod capture; +pub(crate) mod effects; +pub(crate) mod mosaic; +pub(crate) mod sprites; +pub(crate) mod windows; + +use std::{error::Error, fmt}; + +pub const WIDTH: usize = 240; +pub const HEIGHT: usize = 160; + +/// Convert GBA RGB555 (red in bits 0..4) into packed 0x00RRGGBB. +/// Bit 15 is unused. Replication expands five-bit channels to eight bits. +pub fn rgb555_to_rgb888(color: u16) -> u32 { + let expand = |channel: u16| u32::from((channel << 3) | (channel >> 2)); + let red = expand(color & 31); + let green = expand((color >> 5) & 31); + let blue = expand((color >> 10) & 31); + (red << 16) | (green << 8) | blue +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VideoError { + UnsupportedMode(u8), + UnsupportedLayers(u16), + UnsupportedTileAddress(usize), + UnsupportedMapAddress(usize), + UnsupportedObject { index: usize, reason: &'static str }, +} + +impl fmt::Display for VideoError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedMode(mode) => write!( + f, + "unsupported display mode {mode}; only Modes 0 through 5 are rendered" + ), + Self::UnsupportedTileAddress(address) => write!( + f, + "text background tile address {address:#x} exceeds the supported 64 KiB BG area" + ), + Self::UnsupportedMapAddress(address) => write!( + f, + "background map address {address:#x} exceeds the supported 64 KiB BG area" + ), + Self::UnsupportedObject { index, reason } => write!(f, "OBJ{index}: {reason}"), + Self::UnsupportedLayers(control) => { + write!(f, "unsupported display layers in DISPCNT {control:#06x}") + } + } + } +} + +impl Error for VideoError {} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Background { + pub control: u16, + pub x: u16, + pub y: u16, + pub affine: affine::AffineBackground, +} + +pub(crate) struct RenderSettings<'a> { + pub control: u16, + pub prepared_objects: Option<&'a Result>, + pub internal_affine: bool, + pub vertical_windows: Option<[bool; 2]>, + pub horizontal_windows: Option<&'a windows::horizontal::HorizontalWindows>, + pub green_swap: bool, + pub mosaic: u16, + pub vertical_mosaic: Option, + pub backgrounds: &'a [Background; 4], + pub effects: &'a effects::Effects, +} + +#[derive(Clone, Copy)] +struct Pixel { + color: u16, + layer: u8, // BG0..3, OBJ=4, backdrop=5. + priority: u8, + semi_transparent: bool, +} + +impl Pixel { + fn order(self) -> (u8, u8) { + // OBJ wins a tie against BG; lower BG number wins other ties. + ( + self.priority, + if self.layer == 4 { 0 } else { self.layer + 1 }, + ) + } +} + +/// Snapshot the current registers and memory. Errors leave the output unchanged. +/// Memory owns fixed-size 96 KiB VRAM, 1 KiB palette, and 1 KiB OAM buffers. +pub(crate) fn render( + settings: RenderSettings<'_>, + vram: &[u8], + palette: &[u8], + oam: &[u8], + frame: &mut Framebuffer, +) -> Result<(), VideoError> { + frame.pixels = render_rows(settings, vram, palette, oam, 0..HEIGHT)?; + Ok(()) +} + +/// Render the requested rows from current registers and optional scanline state. +/// Row capture uses prepared sprites, affine origins, window history, and mosaic counters. +/// Debug snapshots use programmed origins, geometric window bounds, and screen-grid mosaic. +pub(crate) fn render_rows( + settings: RenderSettings<'_>, + vram: &[u8], + palette: &[u8], + oam: &[u8], + rows: std::ops::Range, +) -> Result, VideoError> { + debug_assert!(rows.start <= rows.end && rows.end <= HEIGHT); + let length = rows.len() * WIDTH; + let RenderSettings { + control, + prepared_objects, + internal_affine, + vertical_windows, + horizontal_windows, + green_swap, + mosaic, + vertical_mosaic, + backgrounds, + effects, + } = settings; + if control & 0x80 != 0 { + return Ok(vec![0xffffff; length]); + } + let backdrop = Pixel { + color: halfword(palette, 0), + layer: 5, + priority: 4, + semi_transparent: false, + }; + let mut output = vec![0; length]; + let mode = control & 7; + let unsupported = match mode { + 0 => 0, + 1 => 0x800, // BG0/1 text, BG2 affine. + 2 => 0x300, // BG2/3 affine. + 3..=5 => 0xb00, // BG2 bitmap. + _ => return Err(VideoError::UnsupportedMode(mode as u8)), + }; + if control & unsupported != 0 { + return Err(VideoError::UnsupportedLayers(control)); + } + let mut layers: Vec = (0..4).filter(|bg| control & (0x100 << bg) != 0).collect(); + // Lower priority number wins. BG0 wins ties, then BG1, BG2, BG3. + layers.sort_by_key(|&bg| (backgrounds[bg].control & 3, bg)); + let objects = if control & 0x1000 == 0 { + sprites::Objects { + pixels: vec![None; length], + window: vec![false; length], + } + } else if let Some(prepared) = prepared_objects { + debug_assert_eq!(rows.len(), 1); + prepared + .as_ref() + .map_err(Clone::clone)? + .compose(mosaic, palette) + } else { + sprites::prepare(control, mosaic, None, oam, vram, rows.clone())?.compose(mosaic, palette) + }; + let mosaic_width = usize::from(mosaic & 15) + 1; + let mosaic_height = usize::from((mosaic >> 4) & 15) + 1; + for y in rows.clone() { + for x in 0..WIDTH { + let offset = (y - rows.start) * WIDTH + x; + let mask = effects.mask( + control, + x, + y, + objects.window[offset], + vertical_windows, + horizontal_windows, + ); + let mut top = backdrop; + let mut below = None; + let mut insert = |pixel: Pixel| { + if pixel.order() < top.order() { + below = Some(top); + top = pixel; + } else if below.is_none_or(|second: Pixel| pixel.order() < second.order()) { + below = Some(pixel); + } + }; + for &bg in &layers { + // Validate enabled background fetches even when a window hides them. + // Mosaic samples before scroll or affine mapping. Captured vertical + // phase comes from a counter; horizontal blocks remain screen-aligned. + // Window selection still uses the actual output coordinate. + let (sample_x, sample_y) = if backgrounds[bg].control & 0x40 != 0 { + let counter = + vertical_mosaic.map_or(y % mosaic_height, |v| usize::from(v.background)); + (x - x % mosaic_width, y - counter) + } else { + (x, y) + }; + let color = if mode == 0 || (mode == 1 && bg < 2) { + text_pixel(backgrounds[bg], vram, palette, sample_x, sample_y)? + } else { + affine::pixel( + backgrounds[bg], + vram, + palette, + sample_x, + sample_y, + control, + internal_affine, + )? + }; + if let Some(color) = color.filter(|_| mask & (1 << bg) != 0) { + insert(Pixel { + color, + layer: bg as u8, + priority: (backgrounds[bg].control & 3) as u8, + semi_transparent: false, + }); + } + } + if let Some(object) = objects.pixels[offset].filter(|_| mask & 0x10 != 0) { + insert(object); + } + output[offset] = rgb555_to_rgb888(effects.apply(top, below, mask & 0x20 != 0)); + } + } + if green_swap { + for pair in output.chunks_exact_mut(2) { + let green_difference = (pair[0] ^ pair[1]) & 0x0000_ff00; + pair[0] ^= green_difference; + pair[1] ^= green_difference; + } + } + Ok(output) +} + +fn halfword(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn text_pixel( + bg: Background, + vram: &[u8], + palette: &[u8], + x: usize, + y: usize, +) -> Result, VideoError> { + let control = usize::from(bg.control); + let width = if control & 0x4000 != 0 { 512 } else { 256 }; + let height = if control & 0x8000 != 0 { 512 } else { 256 }; + // Text backgrounds always wrap; the affine overflow bit has no effect. + let x = (x + usize::from(bg.x)) % width; + let y = (y + usize::from(bg.y)) % height; + let block = x / 256 + (y / 256) * (width / 256); + let map = ((control >> 8) & 31) * 0x800 + block * 0x800 + ((y / 8 % 32) * 32 + x / 8 % 32) * 2; + if map >= 0x10000 { + return Err(VideoError::UnsupportedMapAddress(map)); + } + let entry = usize::from(halfword(vram, map)); + let tx = (x & 7) ^ if entry & 0x400 != 0 { 7 } else { 0 }; + let ty = (y & 7) ^ if entry & 0x800 != 0 { 7 } else { 0 }; + let eight_bit = control & 0x80 != 0; + let pixel = ty * 8 + tx; + let address = ((control >> 2) & 3) * 0x4000 + + (entry & 0x3ff) * if eight_bit { 64 } else { 32 } + + if eight_bit { pixel } else { pixel / 2 }; + // The real PPU has special behavior outside BG VRAM. Do not read OBJ tiles + // as ordinary BG data or silently wrap this unsupported case. + if address >= 0x10000 { + return Err(VideoError::UnsupportedTileAddress(address)); + } + let index = if eight_bit { + usize::from(vram[address]) + } else { + usize::from((vram[address] >> ((pixel & 1) * 4)) & 15) + }; + if index == 0 { + return Ok(None); + } + let bank = if eight_bit { 0 } else { (entry >> 12) * 16 }; + Ok(Some(halfword(palette, (bank + index) * 2))) +} + +pub struct Framebuffer { + pixels: Vec, +} + +impl Default for Framebuffer { + fn default() -> Self { + Self { + pixels: vec![0; WIDTH * HEIGHT], + } + } +} + +impl Framebuffer { + /// Packed 0x00RRGGBB pixels in row-major order. + pub fn pixels(&self) -> &[u32] { + &self.pixels + } + + pub fn clear(&mut self, color: u16) { + self.pixels.fill(rgb555_to_rgb888(color)); + } + + /// Write a GBA RGB555 color. Out-of-bounds coordinates return false. + pub fn set_pixel(&mut self, x: usize, y: usize, color: u16) -> bool { + if x >= WIDTH || y >= HEIGHT { + return false; + } + self.pixels[y * WIDTH + x] = rgb555_to_rgb888(color); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_gba_channel_order_and_ignores_unused_bit() { + for (color, expected) in [ + (0, 0), + (0x001f, 0x00ff_0000), + (0x03e0, 0x0000_ff00), + (0x7c00, 0x0000_00ff), + (0x7fff, 0x00ff_ffff), + (0xffff, 0x00ff_ffff), + (0x8000, 0), + (0x4210, 0x0084_8484), + ] { + assert_eq!(rgb555_to_rgb888(color), expected); + } + } + + #[test] + fn starts_with_a_black_gba_sized_buffer() { + let frame = Framebuffer::default(); + assert_eq!(frame.pixels().len(), 240 * 160); + assert!(frame.pixels().iter().all(|pixel| *pixel == 0)); + } + + #[test] + fn writes_row_major_pixels_including_last_pixel() { + let mut frame = Framebuffer::default(); + assert!(frame.set_pixel(1, 2, 0x001f)); + assert!(frame.set_pixel(WIDTH - 1, HEIGHT - 1, 0x03e0)); + assert_eq!(frame.pixels()[2 * WIDTH + 1], 0x00ff_0000); + assert_eq!(frame.pixels()[WIDTH * HEIGHT - 1], 0x0000_ff00); + assert_eq!(frame.pixels()[0], 0); + } + + #[test] + fn clips_invalid_coordinates_without_changing_pixels() { + let mut frame = Framebuffer::default(); + for (x, y) in [(WIDTH, 0), (0, HEIGHT), (usize::MAX, usize::MAX)] { + assert!(!frame.set_pixel(x, y, 0x7fff)); + } + assert!(frame.pixels().iter().all(|pixel| *pixel == 0)); + } + + #[test] + fn clear_replaces_every_pixel() { + let mut frame = Framebuffer::default(); + frame.set_pixel(0, 0, 0x001f); + frame.clear(0x7c00); + assert!(frame.pixels().iter().all(|pixel| *pixel == 0x0000_00ff)); + } +} diff --git a/src/video/affine.rs b/src/video/affine.rs new file mode 100644 index 0000000..7421b9a --- /dev/null +++ b/src/video/affine.rs @@ -0,0 +1,266 @@ +//! Programmed affine BG registers and sampling for snapshots and row capture. +//! Internal origins advance at line boundaries. Pixel-fetch and per-access timing +//! remain outside this row-level model. + +use super::{halfword, Background, VideoError, HEIGHT, WIDTH}; +#[cfg(test)] +use crate::display::{CYCLES_PER_FRAME, CYCLES_PER_LINE}; + +const REFERENCE_MASK: u32 = 0x0fff_ffff; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AffineBackground { + matrix: [u16; 4], + reference: [u32; 2], + internal: [u32; 2], + // An HBlank write sets the next row's origin and overrides that row's increment. + reload_next: [bool; 2], +} + +impl AffineBackground { + /// Merge into write-only latches, never into their zero readback placeholders. + pub(crate) fn write8(&mut self, offset: usize, value: u8, after_row: bool) { + if offset < 8 { + let word = &mut self.matrix[offset / 2]; + let shift = (offset & 1) * 8; + *word = (*word & !(0xff << shift)) | (u16::from(value) << shift); + } else { + let axis = (offset - 8) / 4; + let word = &mut self.reference[axis]; + let shift = (offset & 3) * 8; + *word = ((*word & !(0xff << shift)) | (u32::from(value) << shift)) & REFERENCE_MASK; + self.internal[axis] = *word; + self.reload_next[axis] = after_row; + } + } + + /// Advance across line ends with fixed registers. Reload at the next visible + /// frame's line-zero boundary, so arbitrarily large batches remain O(1). + pub(crate) fn advance( + &mut self, + phase: u32, + cycles: u32, + active: bool, + mosaic_height: u32, + counter: u8, + ) { + let progress = super::mosaic::progress(phase, cycles, counter, mosaic_height); + if progress.frame_reloaded { + self.internal = self.reference; + self.reload_next = [false; 2]; + } + if !progress.crossed_line { + return; + } + // A mosaic-enabled affine origin advances only when the shared BG + // counter becomes zero. Height1/counter0 models non-mosaic backgrounds. + for (axis, coefficient) in [self.matrix[1], self.matrix[3]].into_iter().enumerate() { + if active { + let count = + progress.resets - u32::from(self.reload_next[axis] && progress.first_reset); + let delta = i32::from(coefficient as i16) * (count * mosaic_height) as i32; + self.internal[axis] = + self.internal[axis].wrapping_add(delta as u32) & REFERENCE_MASK; + } + self.reload_next[axis] = false; + } + } + + fn source(self, x: usize, y: usize, internal: bool) -> (i32, i32) { + let [a, b, c, d] = self.matrix.map(|v| i32::from(v as i16)); + let reference = if internal { + self.internal + } else { + self.reference + }; + let [rx, ry] = reference.map(|v| ((v << 4) as i32) >> 4); + // Internal origins already include vertical progress. + let y = if internal { 0 } else { y }; + // Products and sums fit i32 for a 240x160 output. Model the 28-bit + // fixed-point range, then floor with an arithmetic shift by eight. + let coordinate = |v: i32| ((v as u32) << 4) as i32 >> 12; + ( + coordinate(rx + a * x as i32 + b * y as i32), + coordinate(ry + c * x as i32 + d * y as i32), + ) + } +} + +pub(super) fn pixel( + bg: Background, + vram: &[u8], + palette: &[u8], + x: usize, + y: usize, + display_control: u16, + internal: bool, +) -> Result, VideoError> { + let (mut tx, mut ty) = bg.affine.source(x, y, internal); + let mode = display_control & 7; + if (3..=5).contains(&mode) { + let (width, height) = if mode == 5 { + (160, 128) + } else { + (WIDTH, HEIGHT) + }; + // Bitmap overflow never wraps, even when BGCNT bit 13 is set. + if tx < 0 || ty < 0 || tx >= width as i32 || ty >= height as i32 { + return Ok(None); + } + // Mode 3 ignores the page bit. Modes 4/5 share the same 40 KiB page stride. + let page = if mode != 3 && display_control & 0x10 != 0 { + 0xa000 + } else { + 0 + }; + let offset = ty as usize * width + tx as usize; + return Ok(if mode == 4 { + let index = usize::from(vram[page + offset]); + if index == 0 { + None + } else { + Some(halfword(palette, index * 2)) + } + } else { + // Modes 3/5 store opaque RGB555, including black. Bit 15 is unused. + Some(halfword(vram, page + offset * 2)) + }); + } + let control = usize::from(bg.control); + let size = 128_i32 << (control >> 14); + if control & 0x2000 != 0 { + tx = tx.rem_euclid(size); + ty = ty.rem_euclid(size); + } else if tx < 0 || ty < 0 || tx >= size || ty >= size { + return Ok(None); + } + let (tx, ty) = (tx as usize, ty as usize); + let map = ((control >> 8) & 31) * 0x800 + (ty / 8) * (size as usize / 8) + tx / 8; + if map >= 0x10000 { + return Err(VideoError::UnsupportedMapAddress(map)); + } + // Affine maps have byte-sized indices and no flip/palette-bank attributes. + // Every tile is 8bpp, regardless of BGCNT bit 7. + let address = + ((control >> 2) & 3) * 0x4000 + usize::from(vram[map]) * 64 + (ty % 8) * 8 + tx % 8; + let index = usize::from(vram[address]); // Maximum address is 0xffff. + Ok(if index == 0 { + None + } else { + Some(halfword(palette, index * 2)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batched_tracking_matches_an_independent_line_boundary_model() { + let mut seed = 0x92ad_1037u32; + for _ in 0..500 { + let mut next = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + seed + }; + let mut bg = AffineBackground { + matrix: [next() as u16, next() as u16, next() as u16, next() as u16], + reference: [next() & REFERENCE_MASK, next() & REFERENCE_MASK], + internal: [next() & REFERENCE_MASK, next() & REFERENCE_MASK], + reload_next: [next() & 1 != 0, next() & 2 != 0], + }; + let phase = next() % CYCLES_PER_FRAME; + let cycles = next() % (CYCLES_PER_FRAME * 7); + let height = next() % 16 + 1; + let counter = (next() & 15) as u8; + let mut expected_counter = counter; + let active = next() & 8 != 0; + let mut expected = bg.internal.map(i64::from); + let mut pending = bg.reload_next; + let first = phase / CYCLES_PER_LINE + 1; + let last = (phase + cycles) / CYCLES_PER_LINE; + for boundary in first..=last { + let row = boundary % 228; + if row == 0 { + expected = bg.reference.map(i64::from); + expected_counter = 0; + } else if row <= 160 { + let next = u32::from(expected_counter) + 1; + expected_counter = if row == 160 || next == height { + 0 + } else { + (next & 15) as u8 + }; + for axis in 0..2 { + if active && expected_counter == 0 && !pending[axis] { + let coefficient = i64::from(bg.matrix[axis * 2 + 1] as i16); + expected[axis] = (expected[axis] + coefficient * i64::from(height)) + .rem_euclid(1 << 28); + } + } + } + pending = [false; 2]; + } + bg.advance(phase, cycles, active, height, counter); + assert_eq!( + bg.internal.map(i64::from), + expected, + "phase={phase} cycles={cycles} height={height} active={active}" + ); + assert_eq!(bg.reload_next, pending); + } + } + + #[test] + fn signed_accumulators_wrap_and_extreme_batches_reload_without_iteration() { + let mut bg = AffineBackground { + matrix: [256, 1, 0, (-1i16) as u16], + reference: [REFERENCE_MASK, 0], + internal: [REFERENCE_MASK, 0], + ..Default::default() + }; + bg.advance(0, CYCLES_PER_LINE, true, 1, 0); + assert_eq!(bg.internal, [0, REFERENCE_MASK]); + assert_eq!(bg.source(0, 0, true), (0, -1)); + assert_eq!(bg.source(0, 0, false), (-1, 0)); + let phase = 17 * CYCLES_PER_LINE + 413; + bg.advance(phase, u32::MAX, true, 1, 0); + let lines = ((u64::from(phase) + u64::from(u32::MAX)) % u64::from(CYCLES_PER_FRAME) + / u64::from(CYCLES_PER_LINE)) + .min(160); + assert_eq!( + bg.internal[0], + ((i64::from(REFERENCE_MASK) + lines as i64) % (1 << 28)) as u32 + ); + assert_eq!(bg.internal[1], (-(lines as i64)).rem_euclid(1 << 28) as u32); + } + + #[test] + fn reference_byte_writes_merge_programmed_values_not_accumulated_values() { + let mut bg = AffineBackground { + matrix: [256, 256, 0, 256], + reference: [0x1234500, 0x2000], + internal: [0x1234500, 0x2000], + ..Default::default() + }; + bg.advance(0, 10 * CYCLES_PER_LINE, true, 1, 0); + let y = bg.internal[1]; + bg.write8(8, 0x7f, false); + assert_eq!(bg.reference[0], 0x123457f); + assert_eq!(bg.internal[0], 0x123457f); + assert_eq!(bg.internal[1], y); + bg.write8(11, 0xff, true); + assert_eq!(bg.reference[0], 0x0f23457f); + bg.advance( + 10 * CYCLES_PER_LINE + 1006, + CYCLES_PER_LINE - 1006, + true, + 1, + 0, + ); + assert_eq!(bg.internal[0], 0x0f23457f); // HBlank reload overrides this transition. + assert_eq!(bg.internal[1], y + 256); // Other axis still increments. + bg.write8(2, 0, false); // PB byte write must not reload X. + assert_eq!(bg.internal[0], 0x0f23457f); + } +} diff --git a/tests/affine_backgrounds.rs b/tests/affine_backgrounds.rs new file mode 100644 index 0000000..fd11dd9 --- /dev/null +++ b/tests/affine_backgrounds.rs @@ -0,0 +1,379 @@ +use gba_rust::{ + io::*, + memory::{Memory, MemoryError, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM}, + video::{rgb555_to_rgb888, Framebuffer, VideoError, WIDTH}, +}; + +fn setup(bg: u32, size: u16, wrap: bool) -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(DISPCNT, 2 | (0x100 << bg)).unwrap(); + m.write16( + BG0CNT + bg * 2, + 0x1000 | (size << 14) | if wrap { 0x2000 } else { 0 }, + ) + .unwrap(); + transform(&mut m, bg, [256, 0, 0, 256], [0, 0]); + m.write16(PAL, 0x4000).unwrap(); + for i in 1..=255 { + m.write16(PAL + i * 2, i as u16 * 31).unwrap(); + } + // All 256 byte-sized tile numbers, with an asymmetric indexed pattern. + for tile in 0..256 { + for y in 0..8 { + for x in (0..8).step_by(2) { + let lo = index(tile, x, y); + let hi = index(tile, x + 1, y); + m.write16( + VRAM + tile * 64 + y * 8 + x, + u16::from(lo) | (u16::from(hi) << 8), + ) + .unwrap(); + } + } + } + let tiles = 16_u32 << size; + for y in 0..tiles { + for x in (0..tiles).step_by(2) { + m.write16( + VRAM + 0x8000 + y * tiles + x, + u16::from(tile_number(x, y)) | (u16::from(tile_number(x + 1, y)) << 8), + ) + .unwrap(); + } + } + m +} + +fn tile_number(x: u32, y: u32) -> u8 { + (x.wrapping_mul(17) + y.wrapping_mul(31)) as u8 +} +fn index(tile: u32, x: u32, y: u32) -> u8 { + (tile + x + 3 * y) as u8 +} +fn transform(m: &mut Memory, bg: u32, coeff: [i16; 4], origin: [i32; 2]) { + let base = BG2PA + (bg - 2) * 16; + for (i, v) in coeff.into_iter().enumerate() { + m.write16(base + i as u32 * 2, v as u16).unwrap(); + } + for (i, v) in origin.into_iter().enumerate() { + m.write32(base + 8 + i as u32 * 4, v as u32).unwrap(); + } +} +fn frame(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + m.render_frame(&mut f).unwrap(); + f +} +fn pixel(m: &Memory, x: usize, y: usize) -> u32 { + frame(m).pixels()[y * WIDTH + x] +} + +fn reference(m: &Memory, size: u16, wrap: bool, coeff: [i16; 4], origin: [i32; 2]) { + let f = frame(m); + let size = 128_i64 << size; + for y in 0..160 { + for x in 0..240 { + // Wide-integer reference with explicit 28-bit wrapping and floor division. + let coord = |r: i32, a: i16, b: i16| { + let v = i64::from(r) + i64::from(a) * x as i64 + i64::from(b) * y as i64; + let signed = (v + (1 << 27)).rem_euclid(1 << 28) - (1 << 27); + signed.div_euclid(256) + }; + let mut tx = coord(origin[0], coeff[0], coeff[1]); + let mut ty = coord(origin[1], coeff[2], coeff[3]); + let mut color = 0x4000; + if wrap || (tx >= 0 && ty >= 0 && tx < size && ty < size) { + tx = tx.rem_euclid(size); + ty = ty.rem_euclid(size); + let t = tile_number(tx as u32 / 8, ty as u32 / 8); + let i = index(u32::from(t), tx as u32 % 8, ty as u32 % 8); + if i != 0 { + color = u16::from(i) * 31; + } + } + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb555_to_rgb888(color), + "pixel {x},{y}, size {size}, origin={origin:?}, matrix={coeff:?}" + ); + } + } +} + +#[test] +fn affine_matrices_sizes_wrapping_and_signed_origins_match_wide_reference() { + for bg in [2, 3] { + for size in 0..4 { + for wrap in [false, true] { + let mut m = setup(bg, size, wrap); + for (coeff, origin) in [ + ([256, 0, 0, 256], [0, 0]), + ([0, 256, -256, 0], [-1, 128 * 256]), + ([181, 181, -181, 181], [-173, 17551]), + ([128, 0, 0, 512], [127, -129]), + ([256, 127, 1, -256], [0x07ff_ffff, 0x0800_0000]), + ([32767, -32768, -32768, 32767], [-123456, 123456]), + ([0; 4], [23 * 256, 17 * 256]), + ] { + transform(&mut m, bg, coeff, origin); + reference(&m, size, wrap, coeff, origin); + } + } + } + } +} + +#[test] +fn write_only_registers_merge_bytes_halfwords_and_words_and_mask_reference_top_bits() { + let mut m = setup(2, 1, true); + m.write32(BG2PA, 0xff80_0180).unwrap(); // A=1.5, B=-0.5 + m.write8(BG2PA, 0x40).unwrap(); // A=1.25, retains high byte + m.write32(BG2PC, 0x0100_0000).unwrap(); + m.write32(BG2X, 0xffff_ffff).unwrap(); + m.write8(BG2X, 0x80).unwrap(); // -0.5 + m.write16(BG2Y, 0x0101).unwrap(); + m.write16(BG2Y + 2, 0xf000).unwrap(); // Unused high nibble must be zero internally. + reference(&m, 1, true, [320, -128, 0, 256], [-128, 257]); + for address in (BG2PA..=BG3Y).step_by(4) { + assert_eq!(m.read32(address).unwrap(), 0); + } + assert_eq!( + m.read16(0x0400_0058), + Err(MemoryError::Unmapped(0x0400_0058)) + ); + assert_eq!(m.cycles(), 0); +} + +#[test] +fn affine_bg_ignores_text_scroll_and_color_depth_bit() { + let mut m = setup(2, 1, true); + let before = frame(&m); + m.write32(BG2HOFS, u32::MAX).unwrap(); + m.write16(BG2CNT, 0x7080).unwrap(); // Same map/size/wrap, color bit set. + assert_eq!(frame(&m).pixels(), before.pixels()); +} + +#[test] +fn affine_byte_maps_are_flat_rows_use_tile255_and_have_no_flip_or_bank_bits() { + let mut m = setup(2, 2, false); + m.write16(VRAM + 0x8000 + 64, 0x00ff).unwrap(); // (tileX=0,tileY=1), not next text screen block. + assert_eq!(pixel(&m, 0, 8), rgb555_to_rgb888(255 * 31)); + assert_eq!(pixel(&m, 1, 8), rgb555_to_rgb888(0x4000)); // Tile255 texel1 wraps to palette index0. + assert_eq!(pixel(&m, 8, 8), rgb555_to_rgb888(0x4000)); // Adjacent map byte selects tile0. +} + +#[test] +fn affine_character_base_and_last_tile_reach_the_end_of_bg_vram() { + let mut m = setup(2, 0, false); + m.write16(BG2CNT, 0x100c).unwrap(); // Character base3, map16. + m.write16(VRAM + 0x8000, 0x00ff).unwrap(); + m.write16(VRAM + 0xfffe, 0x0201).unwrap(); + transform(&mut m, 2, [256, 0, 0, 256], [6 * 256, 7 * 256]); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31)); + assert_eq!(pixel(&m, 1, 0), rgb555_to_rgb888(62)); +} + +#[test] +fn mode1_composes_two_text_layers_and_one_affine_layer_with_priority_ties() { + let mut m = setup(2, 1, true); + m.write16(DISPCNT, 0x701).unwrap(); + m.write16(BG0CNT, 0x1804).unwrap(); // Text tile base1, map24, priority0. + m.write16(BG1CNT, 0x1904).unwrap(); + m.write16(VRAM + 0x4000, 0x2222).unwrap(); + m.write16(VRAM + 0xc800, 1).unwrap(); + m.write16(VRAM + 0x4020, 0x3333).unwrap(); + m.write16(VRAM, 0x0101).unwrap(); // BG2 affine nonzero index1. + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(2 * 31)); // BG0 wins tie. + m.write16(BG0CNT, 0x1807).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(3 * 31)); // BG1 wins tie over BG2. + m.write16(BG1CNT, 0x1907).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31)); + m.write16(VRAM, 0).unwrap(); // Affine palette index0 reveals text BG0. + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(2 * 31)); +} + +#[test] +fn mode2_bg3_has_independent_matrix_reference_and_priority() { + let mut m = setup(2, 1, true); + m.write16(DISPCNT, 0xc02).unwrap(); + m.write16(BG3CNT, 0x7000).unwrap(); + transform(&mut m, 2, [0; 4], [256, 0]); + transform(&mut m, 3, [0; 4], [512, 0]); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31)); // BG2 wins tie. + m.write16(BG2CNT, 0x7001).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(62)); + m.write16(PAL + 4, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0); // Nonzero index with black color is opaque. +} + +#[test] +fn mode3_uses_affine_matrix_ignores_overflow_and_keeps_black_opaque() { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(DISPCNT, 0x403).unwrap(); + m.write16(PAL, 0x03e0).unwrap(); + m.write16(VRAM, 0).unwrap(); + m.write16(VRAM + 2, 0x001f).unwrap(); + m.write16(VRAM + (159 * 240 + 239) * 2, 0x7fff).unwrap(); + transform(&mut m, 2, [256, 0, 0, 256], [-256, 0]); + m.write16(BG2CNT, 0x2000).unwrap(); // Ignored bitmap overflow flag. + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + assert_eq!(pixel(&m, 1, 0), 0); + assert_eq!(pixel(&m, 2, 0), 0xff0000); + transform(&mut m, 2, [-256, 0, 0, -256], [239 * 256, 159 * 256]); + assert_eq!(pixel(&m, 0, 0), 0xffffff); + assert_eq!(pixel(&m, 239, 159), 0); + // Hardware reset is a zero matrix, not an implicit identity. + transform(&mut m, 2, [0; 4], [256, 0]); + assert!(frame(&m).pixels().iter().all(|&p| p == 0xff0000)); +} + +#[test] +fn transformed_backgrounds_composite_with_regular_and_affine_sprites() { + for mode in [1, 2] { + let mut m = setup(2, 1, true); + m.write16(DISPCNT, mode | 0x1440).unwrap(); + for i in 0..128 { + m.write16(OAM + i * 8, 0x200).unwrap(); + } + m.write16(VRAM, 0x0101).unwrap(); + m.write16(VRAM + 0x10000, 0x2222).unwrap(); + m.write16(PAL + 0x204, 0x03e0).unwrap(); + m.write16(OAM, 0).unwrap(); + m.write16(OAM + 4, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); // OBJ wins equal BG priority. + m.write16(OAM + 4, 0x400).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31)); + m.write16(OAM, 0x100).unwrap(); + m.write16(OAM + 6, 256).unwrap(); + m.write16(OAM + 30, 256).unwrap(); + m.write16(OAM + 4, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + } +} + +#[test] +fn diagnostics_leave_output_unchanged_and_disabled_layers_skip_validation() { + let mut m = setup(2, 3, false); + let mut f = Framebuffer::default(); + f.clear(0x001f); + for control in [0xc01, 0xd02, 0xe02] { + m.write16(DISPCNT, control).unwrap(); + assert_eq!( + m.render_frame(&mut f), + Err(VideoError::UnsupportedLayers(control)) + ); + } + m.write16(DISPCNT, 0x402).unwrap(); + m.write16(BG2CNT, 0xdf00).unwrap(); // Map31, 1024 pixels. + m.write32(BG2Y, 128 * 256).unwrap(); // Flat row starts outside first64K. + assert_eq!( + m.render_frame(&mut f), + Err(VideoError::UnsupportedMapAddress(0x10000)) + ); + assert!(f.pixels().iter().all(|&p| p == 0xff0000)); + m.write16(DISPCNT, 2).unwrap(); + m.render_frame(&mut f).unwrap(); + assert!(f.pixels().iter().all(|&p| p == rgb555_to_rgb888(0x4000))); + m.write16(DISPCNT, 0xffff).unwrap(); + m.render_frame(&mut f).unwrap(); + assert!(f.pixels().iter().all(|&p| p == 0xffffff)); +} + +#[test] +fn cpu_demo_pans_rotates_zooms_clips_cancels_opposites_and_resets() { + use gba_rust::{ + affine_demo::{AffineDemo, AFFINE_STATE}, + input::{Button, Buttons}, + }; + let mut demo = AffineDemo::new().unwrap(); + let mut f = Framebuffer::default(); + assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0); + assert_eq!(demo.machine().memory().read16(VRAM).unwrap(), 0); + // Signed negative panning and opposite-key cancellation. + demo.frame( + Buttons::default() + .with(Button::Left, true) + .with(Button::Up, true), + &mut f, + ) + .unwrap(); + demo.frame(Buttons::from_bits(0xf0), &mut f).unwrap(); + assert_eq!( + demo.machine().memory().read32(AFFINE_STATE + 4).unwrap() as i32, + -2 + ); + assert_eq!( + demo.machine().memory().read32(AFFINE_STATE + 8).unwrap() as i32, + -2 + ); + for bits in 0..8 { + let rotated = bits & 1 != 0; + let zoomed = bits & 2 != 0; + let clipped = bits & 4 != 0; + let before = demo.machine().memory().display_position(); + demo.frame( + Buttons::default() + .with(Button::L, rotated) + .with(Button::R, zoomed) + .with(Button::A, clipped), + &mut f, + ) + .unwrap(); + let a = f64::from(match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }) / 256.0; + let b = if rotated { a } else { 0.0 }; + for y in 0..160 { + for x in 0..240 { + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + let tx = (126.0 + a * dx + b * dy).floor() as i32; + let ty = (126.0 - b * dx + a * dy).floor() as i32; + let color = if clipped && (!(0..256).contains(&tx) || !(0..256).contains(&ty)) { + 0x4000 + } else { + let tx = tx.rem_euclid(256) as usize; + let ty = ty.rem_euclid(256) as usize; + [ + 0x0260, 0x03a0, 0x7d20, 0x7e80, 0x001f, 0x421f, 0x03ff, 0x7fff, + ][((tx / 32 + 2 * (ty / 32)) % 4) * 2 + (tx / 2 + ty / 2) % 2] + }; + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb555_to_rgb888(color), + "input {bits}, pixel {x},{y}" + ); + } + } + let m = demo.machine().memory(); + assert_eq!(m.read32(AFFINE_STATE).unwrap(), bits + 3); + assert_eq!(m.read16(DISPCNT).unwrap(), 0x402); + assert_eq!(m.display_position().vblanks, before.vblanks + 1); + assert_eq!(m.display_position().scanline, 160); + } + demo.frame( + Buttons::default() + .with(Button::Start, true) + .with(Button::Right, true), + &mut f, + ) + .unwrap(); + assert_eq!(demo.machine().memory().read32(AFFINE_STATE + 4).unwrap(), 0); + assert_eq!(demo.machine().memory().read32(AFFINE_STATE + 8).unwrap(), 0); +} + +#[test] +fn snapshot_uses_programmed_origin_without_advancing_or_accumulating_scanline_state() { + let mut m = setup(2, 1, true); + transform(&mut m, 2, [181, 181, -181, 181], [12345, -567]); + let first = frame(&m); + m.advance_cycles(1232 * 57); + let cycles = m.cycles(); + assert_eq!(frame(&m).pixels(), first.pixels()); + assert_eq!(m.cycles(), cycles); + m.write32(BG2X, 0).unwrap(); + assert_ne!(frame(&m).pixels(), first.pixels()); +} diff --git a/tests/bitmap_modes.rs b/tests/bitmap_modes.rs new file mode 100644 index 0000000..338adff --- /dev/null +++ b/tests/bitmap_modes.rs @@ -0,0 +1,446 @@ +use gba_rust::{ + io::{BG2CNT, BG2PA, BG2X, BG2Y, DISPCNT, GREENSWAP}, + memory::{Memory, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM}, + video::{rgb555_to_rgb888, Framebuffer, VideoError, WIDTH}, +}; + +fn setup(mode: u16) -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(DISPCNT, 0x400 | mode).unwrap(); + transform(&mut m, [256, 0, 0, 256], [0, 0]); + m.write16(PAL, 0x4000).unwrap(); + for i in 1..256 { + m.write16(PAL + i * 2, (i as u16 * 123) & 0x7fff).unwrap(); + } + m +} +fn transform(m: &mut Memory, values: [i16; 4], origin: [i32; 2]) { + for (i, v) in values.into_iter().enumerate() { + m.write16(BG2PA + i as u32 * 2, v as u16).unwrap(); + } + m.write32(BG2X, origin[0] as u32).unwrap(); + m.write32(BG2Y, origin[1] as u32).unwrap(); +} +fn frame(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + m.render_frame(&mut f).unwrap(); + f +} +fn pixel(m: &Memory, x: usize, y: usize) -> u32 { + frame(m).pixels()[y * WIDTH + x] +} +fn sprite(m: &mut Memory, mode: u16, priority: u16, affine: bool) { + for i in 0..128 { + m.write16(OAM + i * 8, 0x200).unwrap(); + } + m.write16(OAM, if affine { 0x100 } else { 0 }).unwrap(); + m.write16(OAM + 4, 512 | (priority << 10)).unwrap(); + m.write16(OAM + 6, 256).unwrap(); + m.write16(OAM + 30, 256).unwrap(); + m.write16(VRAM + 0x14000, 0x1111).unwrap(); + m.write16(PAL + 0x202, 0x03e0).unwrap(); + m.write16(DISPCNT, 0x1440 | mode).unwrap(); +} + +#[test] +fn mode4_reads_adjacent_bytes_all_indices_and_both_page_boundaries() { + let mut m = setup(4); + for page in [0, 0xa000] { + m.write16(VRAM + page, 0xff01).unwrap(); + m.write16(VRAM + page + 238, 0x0203).unwrap(); + m.write16(VRAM + page + 240, 0x0405).unwrap(); + m.write16(VRAM + page + 0x95fe, 0x0607).unwrap(); + m.write16(DISPCNT, 0x404 | if page == 0 { 0 } else { 0x10 }) + .unwrap(); + for (x, y, index) in [ + (0, 0, 1), + (1, 0, 255), + (239, 0, 2), + (0, 1, 5), + (239, 159, 6), + ] { + assert_eq!(pixel(&m, x, y), rgb555_to_rgb888((index * 123) & 0x7fff)); + } + } + for i in (0..256).step_by(2) { + m.write16(VRAM + i, (i as u16) | ((i as u16 + 1) << 8)) + .unwrap(); + } + m.write16(DISPCNT, 0x404).unwrap(); + let f = frame(&m); + for i in 0..256 { + assert_eq!( + f.pixels()[i], + rgb555_to_rgb888(if i == 0 { + 0x4000 + } else { + (i as u16 * 123) & 0x7fff + }) + ); + } +} + +#[test] +fn mode4_page_stride_is_40k_and_inactive_page_writes_do_not_change_output() { + let mut m = setup(4); + m.write16(VRAM, 0x0101).unwrap(); + m.write16(VRAM + 0x9600, 0x0303).unwrap(); // Padding is not page1. + let before = frame(&m); + m.write16(VRAM + 0xa000, 0x0202).unwrap(); + assert_eq!(frame(&m).pixels(), before.pixels()); + m.write8(DISPCNT, 0x14).unwrap(); // Byte write selects page1 without losing BG2 enable. + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(246)); + m.write16(PAL + 4, 0x001f).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); // Palette is live, not cached per page. + m.write8(DISPCNT, 4).unwrap(); + assert_eq!(frame(&m).pixels(), before.pixels()); +} + +#[test] +fn mode5_uses_160_by_128_rgb555_pages_and_leaves_remainder_transparent() { + let mut m = setup(5); + for (page, color) in [(0, 0x801f), (0xa000, 0x83e0)] { + m.write16(VRAM + page, color).unwrap(); + m.write16(VRAM + page + 318, 0x7c00).unwrap(); + m.write16(VRAM + page + 320, 0x7fff).unwrap(); + m.write16(VRAM + page + 0x9ffe, color).unwrap(); + } + for (control, color) in [(0x405, 0xff0000), (0x415, 0x00ff00)] { + m.write16(DISPCNT, control).unwrap(); + let f = frame(&m); + assert_eq!(f.pixels()[0], color); + assert_eq!(f.pixels()[159], 0x0000ff); + assert_eq!(f.pixels()[WIDTH], 0xffffff); + assert_eq!(f.pixels()[127 * WIDTH + 159], color); + for y in 0..160 { + for x in 0..240 { + if x >= 160 || y >= 128 { + assert_eq!(f.pixels()[y * WIDTH + x], rgb555_to_rgb888(0x4000)); + } + } + } + } +} + +#[test] +fn mode4_zero_index_is_transparent_but_nonzero_black_is_opaque() { + for affine in [false, true] { + let mut m = setup(4); + sprite(&mut m, 4, 3, affine); + m.write16(VRAM, 0x0100).unwrap(); + m.write16(PAL + 2, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); // Index0 reveals lower-priority sprite. + assert_eq!(pixel(&m, 1, 0), 0); // Black index1 still covers it. + m.write16(BG2CNT, 3).unwrap(); + assert_eq!(pixel(&m, 1, 0), 0x00ff00); // OBJ wins equal priority. + } +} + +#[test] +fn mode5_black_is_opaque_and_ignores_palette_while_outside_pixels_reveal_objects() { + let mut m = setup(5); + sprite(&mut m, 5, 3, false); + m.write16(VRAM, 0).unwrap(); + m.write16(PAL, 0x7fff).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0); + m.write32(BG2X, u32::MAX).unwrap(); // Source X=-1/256 => first output column outside. + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + assert_eq!(pixel(&m, 1, 0), 0); +} + +#[test] +fn bitmap_sprites_use_upper_obj_area_independent_of_page() { + for mode in [4, 5] { + let mut m = setup(mode); + sprite(&mut m, mode, 0, false); + m.write16(VRAM + 0x10000, 0x1111).unwrap(); + m.write16(OAM + 4, 0).unwrap(); + assert_ne!(pixel(&m, 0, 0), 0x00ff00); // Lower512 OBJ tiles unavailable. + m.write16(OAM + 4, 512).unwrap(); + for page in [0, 0x10] { + m.write16(DISPCNT, 0x1440 | mode | page).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + } + assert_eq!(m.read16(VRAM + 0x14000).unwrap(), 0x1111); + } +} + +#[test] +fn bitmap_byte_writes_duplicate_pixels_but_never_modify_obj_tiles() { + for mode in [4, 5] { + let mut m = setup(mode); + for address in [VRAM, VRAM + 0xa001, VRAM + 0x13fff] { + m.write8(address, 0x5a).unwrap(); + assert_eq!(m.read16(address & !1).unwrap(), 0x5a5a); + } + m.write16(VRAM + 0x14000, 0x1234).unwrap(); + m.write8(VRAM + 0x1c000, 0xff).unwrap(); // OBJ mirror. + assert_eq!(m.read16(VRAM + 0x14000).unwrap(), 0x1234); + } +} + +#[test] +fn affine_bitmap_sampling_matches_wide_integer_reference_on_both_pages() { + for mode in [4, 5] { + let mut m = setup(mode); + let (w, h) = if mode == 4 { (240, 160) } else { (160, 128) }; + for page in 0..2_u32 { + if mode == 4 { + for offset in (0..w * h).step_by(2) { + let lo = ((offset * 13 + page * 73) & 255) as u16; + let hi = (((offset + 1) * 13 + page * 73) & 255) as u16; + m.write16(VRAM + page * 0xa000 + offset, lo | (hi << 8)) + .unwrap(); + } + } else { + for offset in 0..w * h { + m.write16( + VRAM + page * 0xa000 + offset * 2, + ((offset * 37 + page * 127) & 0xffff) as u16, + ) + .unwrap(); + } + } + } + for page in 0..2_u32 { + m.write16(DISPCNT, 0x400 | mode | (page as u16 * 16)) + .unwrap(); + m.write16(BG2CNT, 0xff8f).unwrap(); // Ignored map/size/overflow/color fields; mosaic clear. + for (coeff, origin) in [ + ([256, 0, 0, 256], [0, 0]), + ([181, 181, -181, 181], [-173, 20001]), + ([128, 0, 0, 128], [127, -129]), + ( + [-256, 0, 0, -256], + [(w as i32 - 1) * 256, (h as i32 - 1) * 256], + ), + ([256, 73, -129, 512], [-512, 127]), + ([0; 4], [256, 256]), + ] { + transform(&mut m, coeff, origin); + let f = frame(&m); + for y in 0..160 { + for x in 0..240 { + let tx = (i64::from(origin[0]) + + i64::from(coeff[0]) * x as i64 + + i64::from(coeff[1]) * y as i64) + .div_euclid(256); + let ty = (i64::from(origin[1]) + + i64::from(coeff[2]) * x as i64 + + i64::from(coeff[3]) * y as i64) + .div_euclid(256); + let color = if tx < 0 || ty < 0 || tx >= i64::from(w) || ty >= i64::from(h) + { + 0x4000 + } else { + let offset = ty as u32 * w + tx as u32; + if mode == 4 { + let i = ((offset * 13 + page * 73) & 255) as u16; + if i == 0 { + 0x4000 + } else { + (i * 123) & 0x7fff + } + } else { + ((offset * 37 + page * 127) & 0x7fff) as u16 + } + }; + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb555_to_rgb888(color), + "mode {mode}, page {page}, pixel {x},{y}" + ); + } + } + } + } + } +} + +#[test] +fn mode5_can_scale_to_full_lcd_without_implicit_stretching() { + let mut m = setup(5); + for offset in 0..160 * 128 { + m.write16(VRAM + offset * 2, 0x001f).unwrap(); + } + assert_eq!(pixel(&m, 239, 159), rgb555_to_rgb888(0x4000)); + transform(&mut m, [170, 0, 0, 204], [0, 0]); // Approximate 160/240 and 128/160 sampling steps. + assert!(frame(&m).pixels().iter().all(|&p| p == 0xff0000)); +} + +#[test] +fn bitmap_green_swap_disabled_bg_and_forced_blank_remain_consistent() { + for mode in [4, 5] { + let mut m = setup(mode); + m.write16(PAL + 2, 0x001f).unwrap(); + m.write16(PAL + 4, 0x03e0).unwrap(); + if mode == 4 { + m.write16(VRAM, 0x0201).unwrap(); + } else { + m.write32(VRAM, 0x03e0_001f).unwrap(); + } + m.write16(GREENSWAP, 1).unwrap(); + assert_eq!(&frame(&m).pixels()[..2], &[0xffff00, 0]); + m.write16(DISPCNT, mode | 0x10).unwrap(); + assert!(frame(&m) + .pixels() + .iter() + .all(|&p| p == rgb555_to_rgb888(0x4000))); + m.write16(DISPCNT, 0xffff).unwrap(); + assert!(frame(&m).pixels().iter().all(|&p| p == 0xffffff)); + } +} + +fn assert_demo_frame( + f: &Framebuffer, + mode: gba_rust::bitmap_demo::BitmapMode, + pan: (i32, i32), + rotated: bool, + zoomed: bool, + page: usize, +) { + use gba_rust::bitmap_demo::BitmapMode; + let (w, h) = if mode == BitmapMode::Mode4 { + (240, 160) + } else { + (160, 128) + }; + let a = f64::from(match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }) / 256.0; + let b = if rotated { a } else { 0.0 }; + for y in 0..160 { + for x in 0..240 { + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + let tx = (f64::from(w / 2 + pan.0) + a * dx + b * dy).floor() as i32; + let ty = (f64::from(h / 2 + pan.1) - b * dx + a * dy).floor() as i32; + let color = if tx < 0 || ty < 0 || tx >= w || ty >= h { + 0x4000 + } else { + let (tx, ty) = (tx as usize, ty as usize); + if mode == BitmapMode::Mode4 { + let i = (tx / 8 + 3 * (ty / 8) + page * 64) % 256; + if i == 0 { + 0x4000 + } else { + ((i % 32) | ((i / 8 % 32) << 5) | ((i * 3 % 32) << 10)) as u16 + } + } else { + (((tx / 5 + page * 11) % 32) + | (((ty / 4 + page * 7) % 32) << 5) + | (((tx / 8 + ty / 8 + page * 5) % 32) << 10)) as u16 + } + }; + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb555_to_rgb888(color), + "{mode:?} page{page}, pixel{x},{y}, pan={pan:?}" + ); + } + } +} + +#[test] +fn cpu_demos_copy_both_pages_and_flip_during_vblank_every_32_updates() { + use gba_rust::{ + bitmap_demo::{BitmapDemo, BitmapMode, BITMAP_STATE}, + input::Buttons, + }; + for mode in [BitmapMode::Mode4, BitmapMode::Mode5] { + let mut demo = BitmapDemo::new(mode).unwrap(); + let mut f = Framebuffer::default(); + assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0); + assert_eq!(demo.machine().memory().read16(VRAM + 0xa000).unwrap(), 0); + for updates in 1..=65 { + let before = demo.machine().memory().display_position(); + demo.frame(Buttons::default(), &mut f).unwrap(); + let page = (updates / 32) % 2; + let m = demo.machine().memory(); + assert_eq!( + m.read16(DISPCNT).unwrap(), + 0x400 | mode as u16 | (page as u16 * 16) + ); + assert_eq!(m.read32(BITMAP_STATE).unwrap(), updates as u32); + assert_eq!(m.display_position().scanline, 160); + if updates > 1 { + assert_eq!(m.display_position().vblanks, before.vblanks + 1); + } + assert_demo_frame(&f, mode, (0, 0), false, false, page); + } + } +} + +#[test] +fn cpu_bitmap_controls_pan_rotate_zoom_force_page_and_reset() { + use gba_rust::{ + bitmap_demo::{BitmapDemo, BitmapMode, BITMAP_STATE}, + input::{Button, Buttons}, + }; + for mode in [BitmapMode::Mode4, BitmapMode::Mode5] { + let mut demo = BitmapDemo::new(mode).unwrap(); + let mut f = Framebuffer::default(); + demo.frame( + Buttons::default() + .with(Button::Left, true) + .with(Button::Up, true), + &mut f, + ) + .unwrap(); + demo.frame(Buttons::from_bits(0xf0), &mut f).unwrap(); + for bits in 0..8 { + let rotated = bits & 1 != 0; + let zoomed = bits & 2 != 0; + let page = usize::from(bits & 4 != 0); + demo.frame( + Buttons::default() + .with(Button::L, rotated) + .with(Button::R, zoomed) + .with(Button::A, page != 0), + &mut f, + ) + .unwrap(); + assert_demo_frame(&f, mode, (-2, -2), rotated, zoomed, page); + assert_eq!( + demo.machine().memory().read32(BITMAP_STATE + 4).unwrap() as i32, + -2 + ); + assert_eq!( + demo.machine().memory().read32(BITMAP_STATE + 8).unwrap() as i32, + -2 + ); + } + demo.frame( + Buttons::default() + .with(Button::Start, true) + .with(Button::Right, true), + &mut f, + ) + .unwrap(); + assert_demo_frame(&f, mode, (0, 0), false, false, 0); + assert_eq!(demo.machine().memory().read32(BITMAP_STATE + 4).unwrap(), 0); + assert_eq!(demo.machine().memory().read32(BITMAP_STATE + 8).unwrap(), 0); + } +} + +#[test] +fn unsupported_bitmap_layers_leave_frame_unchanged() { + for mode in [4, 5] { + let mut m = setup(mode); + let mut f = Framebuffer::default(); + f.clear(0x001f); + for extra in [0x100, 0x200, 0x800] { + let control = 0x410 | mode | extra; + m.write16(DISPCNT, control).unwrap(); + assert_eq!( + m.render_frame(&mut f), + Err(VideoError::UnsupportedLayers(control)) + ); + } + assert!(f.pixels().iter().all(|&p| p == 0xff0000)); + assert_eq!(m.cycles(), 0); + } +} diff --git a/tests/tiles.rs b/tests/tiles.rs new file mode 100644 index 0000000..45e4b2c --- /dev/null +++ b/tests/tiles.rs @@ -0,0 +1,481 @@ +use gba_rust::{ + io::*, + memory::{Memory, MemoryError, PALETTE_START as PAL, VRAM_START as VRAM}, + video::{rgb555_to_rgb888, Framebuffer, VideoError, WIDTH}, +}; + +fn setup() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(DISPCNT, 0x100).unwrap(); + m.write16(BG0CNT, 0x1000).unwrap(); // Map at 0x8000, tiles at zero. + for (i, color) in [0x4210, 0x001f, 0x03e0, 0x7c00, 0x7fff] + .into_iter() + .enumerate() + { + m.write16(PAL + i as u32 * 2, color).unwrap(); + } + m +} + +fn pixel(m: &Memory, x: usize, y: usize) -> u32 { + let mut frame = Framebuffer::default(); + m.render_frame(&mut frame).unwrap(); + frame.pixels()[y * WIDTH + x] +} + +fn tile(m: &mut Memory, address: u32, value: u16, bytes: u32) { + for i in (0..bytes).step_by(2) { + m.write16(VRAM + address + i, value).unwrap(); + } +} + +#[test] +fn controls_mask_unused_bits_and_merge_byte_and_word_writes() { + let mut m = setup(); + m.write32(BG0CNT, u32::MAX).unwrap(); + m.write32(BG2CNT, u32::MAX).unwrap(); + assert_eq!(m.read32(BG0CNT).unwrap(), 0xdfcf_dfcf); + assert_eq!(m.read32(BG2CNT).unwrap(), 0xffcf_ffcf); + m.write8(BG0CNT, 0x81).unwrap(); + m.write8(BG0CNT + 1, 0x10).unwrap(); + assert_eq!(m.read16(BG0CNT).unwrap(), 0x1081); + for reg in (BG0HOFS..=BG3VOFS).step_by(2) { + m.write16(reg, 0xffff).unwrap(); + assert_eq!(m.read16(reg).unwrap(), 0); // Write-only placeholder, not open bus. + } + assert_eq!( + m.read16(0x0400_0058), + Err(MemoryError::Unmapped(0x0400_0058)) + ); +} + +#[test] +fn four_bit_nibble_order_palette_banks_and_transparent_zero() { + let mut m = setup(); + m.write16(VRAM, 0x0321).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + assert_eq!(pixel(&m, 1, 0), 0x00ff00); + assert_eq!(pixel(&m, 2, 0), 0x0000ff); + assert_eq!(pixel(&m, 3, 0), rgb555_to_rgb888(0x4210)); + m.write16(VRAM + 0x8000, 0xf000).unwrap(); + m.write16(PAL + 241 * 2, 0x7fff).unwrap(); + m.write16(PAL + 240 * 2, 0x001f).unwrap(); // Bank zero is still transparent. + assert_eq!(pixel(&m, 0, 0), 0xffffff); + assert_eq!(pixel(&m, 3, 0), rgb555_to_rgb888(0x4210)); +} + +#[test] +fn eight_bit_tiles_use_full_index_and_ignore_palette_bank() { + let mut m = setup(); + m.write16(BG0CNT, 0x1080).unwrap(); + m.write16(VRAM + 0x8000, 0xf001).unwrap(); + m.write16(VRAM + 64, 0x00ff).unwrap(); + m.write16(PAL + 510, 0x001f).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + assert_eq!(pixel(&m, 1, 0), rgb555_to_rgb888(0x4210)); +} + +#[test] +fn horizontal_and_vertical_flips_apply_in_both_color_depths() { + for eight in [false, true] { + let mut m = setup(); + m.write16(BG0CNT, 0x1000 | if eight { 0x80 } else { 0 }) + .unwrap(); + let (top, bottom) = if eight { + (0x0201, 0x0403) + } else { + (0x21, 0x43) + }; + m.write16(VRAM, top).unwrap(); + m.write16(VRAM + if eight { 56 } else { 28 }, bottom) + .unwrap(); + for (flip, x, y, expected) in [ + (0, 0, 0, 0xff0000), + (0x400, 7, 0, 0xff0000), + (0x800, 0, 0, 0x0000ff), + (0xc00, 6, 0, 0xffffff), + ] { + m.write16(VRAM + 0x8000, flip).unwrap(); + assert_eq!(pixel(&m, x, y), expected); + } + } +} + +#[test] +fn all_map_sizes_use_screen_blocks_not_flat_rows_and_wrap() { + for size in 0..4_u16 { + let mut m = setup(); + for i in 1..=4 { + tile(&mut m, i * 32, 0x1111 * i as u16, 32); + } + for block in 0..4 { + tile(&mut m, 0x8000 + block * 0x800, block as u16 + 1, 0x800); + } + m.write16(BG0CNT, 0x1000 | (size << 14)).unwrap(); + let width = if size & 1 != 0 { 512 } else { 256 }; + let height = if size & 2 != 0 { 512 } else { 256 }; + for y in [0, 255, 256, 511] { + for x in [0, 255, 256, 511] { + m.write16(BG0HOFS, x).unwrap(); + m.write16(BG0VOFS, y).unwrap(); + let block = (x % width) / 256 + (y % height) / 256 * (width / 256); + let colors = [0x001f, 0x03e0, 0x7c00, 0x7fff]; + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(colors[block as usize])); + } + } + } +} + +#[test] +fn scroll_byte_writes_merge_latches_mask_nine_bits_and_move_subtile_pixels() { + let mut m = setup(); + m.write16(BG0CNT, 0x1080).unwrap(); + m.write16(VRAM + 2, 0x0201).unwrap(); + m.write16(BG0HOFS, 0xffff).unwrap(); + m.write8(BG0HOFS, 2).unwrap(); // latch becomes 0x102; 256-wide map wraps to 2. + assert_eq!(pixel(&m, 0, 0), 0xff0000); + m.write8(BG0HOFS + 1, 0xfe).unwrap(); // Only bit zero survives. + assert_eq!(pixel(&m, 1, 0), 0x00ff00); + m.write16(VRAM + 8 + 2, 0x0403).unwrap(); + m.write32(BG0HOFS, 0xfe01_fe02).unwrap(); // X=2, Y=1 + assert_eq!(pixel(&m, 0, 0), 0x0000ff); + assert_eq!(m.cycles(), 0); +} + +#[test] +fn each_background_uses_its_own_scroll_and_control() { + for bg in 0..4 { + let mut m = setup(); + m.write16(DISPCNT, 0x100 << bg).unwrap(); + m.write16(BG0CNT + bg * 2, 0x1080).unwrap(); + m.write16(VRAM + 10, 0x0201).unwrap(); + m.write16(BG0HOFS + bg * 4, 2).unwrap(); + m.write16(BG0VOFS + bg * 4, 1).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + } +} + +#[test] +fn priorities_ties_transparency_and_opaque_black_composite_all_four_layers() { + let mut m = setup(); + m.write16(DISPCNT, 0xf00).unwrap(); + for bg in 0..4 { + m.write16(BG0CNT + bg * 2, ((16 + bg) << 8) as u16).unwrap(); + tile(&mut m, 0x8000 + bg * 0x800, bg as u16 + 1, 0x800); + tile(&mut m, (bg + 1) * 32, 0x1111 * (bg as u16 + 1), 32); + } + for bg in 0..4 { + let expected = [0xff0000, 0x00ff00, 0x0000ff, 0xffffff][bg as usize]; + assert_eq!(pixel(&m, 0, 0), expected); + // Lower the winning layer's priority so the next wins the tie. + m.write16(BG0CNT + bg * 2, ((16 + bg) << 8) as u16 | 3) + .unwrap(); + } + assert_eq!(pixel(&m, 0, 0), 0xff0000); // All priority 3, BG0 wins. + m.write16(PAL + 2, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0); // Nonzero index with black palette color is opaque. + tile(&mut m, 32, 0, 32); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + m.write16(DISPCNT, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(0x4210)); +} + +#[test] +fn character_base_and_high_tile_numbers_cross_character_blocks() { + for eight in [false, true] { + let mut m = setup(); + m.write16(BG0CNT, 0x1004 | if eight { 0x80 } else { 0 }) + .unwrap(); + m.write16(VRAM + 0x8000, 1023).unwrap(); + let address = 0x4000 + 1023 * if eight { 64 } else { 32 }; + if eight { + assert!(matches!( + m.render_frame(&mut Framebuffer::default()), + Err(VideoError::UnsupportedTileAddress(_)) + )); + } else { + tile(&mut m, address, 0x1111, 32); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + } + } +} + +#[test] +fn screen_map_outside_supported_bg_area_reports_error_without_wrapping() { + let mut m = setup(); + m.write16(BG0CNT, 0x5f04).unwrap(); // Map block31, wide; tile base1. + m.write16(BG0HOFS, 256).unwrap(); // Second block lies outside the BG area. + let mut frame = Framebuffer::default(); + frame.clear(0x001f); + assert_eq!( + m.render_frame(&mut frame), + Err(VideoError::UnsupportedMapAddress(0x10000)) + ); + assert!(frame.pixels().iter().all(|&p| p == 0xff0000)); +} + +#[test] +fn unsupported_features_leave_output_unchanged_and_forced_blank_bypasses_them() { + let mut m = setup(); + let mut frame = Framebuffer::default(); + frame.clear(0x001f); + m.write16(DISPCNT, 0x100).unwrap(); + m.write16(BG0CNT, 0x100c).unwrap(); + m.write16(VRAM + 0x8002, 1023).unwrap(); // Error after some valid pixels. + assert!(matches!( + m.render_frame(&mut frame), + Err(VideoError::UnsupportedTileAddress(_)) + )); + assert!(frame.pixels().iter().all(|&p| p == 0xff0000)); + m.write16(DISPCNT, 0xf180).unwrap(); + m.render_frame(&mut frame).unwrap(); + assert!(frame.pixels().iter().all(|&p| p == 0xffffff)); +} + +fn assert_scene( + frame: &Framebuffer, + sx: usize, + sy: usize, + flipped: bool, + behind: bool, + rotated: bool, + zoomed: bool, +) { + for y in 0..160 { + for x in 0..240 { + let fx = (x + sx / 2) % 256; + let fy = (y + sy / 2) % 256; + let bx = (x + sx) % 512; + let by = (y + sy) % 512; + let cross = fx / 8 % 4 == 1 && fy / 8 % 4 == 1 && (fx % 8 == 3 || fy % 8 == 3); + let mut color = if cross { + 0x03ff + } else { + [0x0260, 0x03a0, 0x7d20, 0x7e80] + [((bx / 32 + by / 32) % 2) * 2 + (bx / 2 + by / 2) % 2] + }; + let affine = rotated || zoomed; + let source = if affine && (104..136).contains(&x) && (64..96).contains(&y) { + let a = f64::from(if rotated { + if zoomed { + 90 + } else { + 181 + } + } else { + 128 + }); + let b = if rotated { a } else { 0.0 }; + let dx = x as f64 - 120.0; + let dy = y as f64 - 80.0; + Some(( + ((a * dx + b * dy) / 256.0 + 8.0).floor() as i32, + ((-b * dx + a * dy) / 256.0 + 8.0).floor() as i32, + )) + } else if !affine && (112..128).contains(&x) && (72..88).contains(&y) { + Some(( + if flipped { + 127 - x as i32 + } else { + x as i32 - 112 + }, + y as i32 - 72, + )) + } else { + None + }; + if let Some((tx, ty)) = source.filter(|&(tx, ty)| { + (2..14).contains(&tx) && (1..15).contains(&ty) && !(cross && behind) + }) { + color = if tx == 10 && ty == 7 { + 0 + } else if tx == 12 && (10..13).contains(&ty) { + 0x03ff + } else if (5..10).contains(&ty) { + 0x7fff + } else { + 0x001f + }; + } + assert_eq!( + frame.pixels()[y * WIDTH + x], + rgb555_to_rgb888(color), + "pixel {x},{y} scroll {sx},{sy}" + ); + } + } +} + +#[test] +fn cpu_tile_demo_copies_assets_scrolls_wraps_cancels_opposites_and_resets() { + use gba_rust::{ + input::{Button, Buttons}, + tile_demo::{TileDemo, TILE_STATE}, + }; + let mut demo = TileDemo::new().unwrap(); + let mut frame = Framebuffer::default(); + assert_eq!(demo.machine().memory().read16(VRAM).unwrap(), 0); + assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0); + let mut updates = 0; + for (buttons, x, y) in [ + (Buttons::default(), 0, 0), + ( + Buttons::default() + .with(Button::Left, true) + .with(Button::Up, true), + 510, + 510, + ), + (Buttons::from_bits(0xf0), 510, 510), + ( + Buttons::default() + .with(Button::Right, true) + .with(Button::Down, true), + 0, + 0, + ), + ( + Buttons::default() + .with(Button::Right, true) + .with(Button::Down, true), + 2, + 2, + ), + ( + Buttons::default() + .with(Button::Start, true) + .with(Button::Right, true), + 0, + 0, + ), + ] { + let before = demo.machine().memory().display_position(); + demo.frame(buttons, &mut frame).unwrap(); + updates += 1; + assert_scene(&frame, x, y, false, false, false, false); + let m = demo.machine().memory(); + assert_eq!(m.read32(TILE_STATE).unwrap(), updates); + assert_eq!(m.read32(TILE_STATE + 4).unwrap(), x as u32); + assert_eq!(m.read32(TILE_STATE + 8).unwrap(), y as u32); + assert_eq!(m.read16(DISPCNT).unwrap(), 0x1340); + assert_eq!(m.display_position().scanline, 160); + if updates > 1 { + assert_eq!(m.display_position().vblanks, before.vblanks + 1); + } + } +} + +#[test] +fn cpu_sprite_controls_flip_and_change_priority_through_oam() { + use gba_rust::{ + input::{Button, Buttons}, + memory::OAM_START, + tile_demo::TileDemo, + }; + let mut demo = TileDemo::new().unwrap(); + let mut frame = Framebuffer::default(); + for _ in 0..16 { + demo.frame(Buttons::default().with(Button::Right, true), &mut frame) + .unwrap(); + } + for (flipped, behind) in [ + (false, false), + (true, false), + (true, true), + (false, true), + (false, false), + ] { + demo.frame( + Buttons::default() + .with(Button::A, flipped) + .with(Button::B, behind), + &mut frame, + ) + .unwrap(); + assert_scene(&frame, 32, 0, flipped, behind, false, false); + let m = demo.machine().memory(); + assert_eq!(m.read16(OAM_START).unwrap(), 72); + assert_eq!( + m.read16(OAM_START + 2).unwrap(), + 0x4070 | if flipped { 0x1000 } else { 0 } + ); + assert_eq!( + m.read16(OAM_START + 4).unwrap(), + if behind { 0x400 } else { 0 } + ); + for i in 1..128 { + assert_eq!(m.read16(OAM_START + i * 8).unwrap(), 0x200); + } + } +} + +#[test] +fn cpu_affine_controls_write_matrices_preserve_center_and_return_to_regular_mode() { + use gba_rust::{ + input::{Button, Buttons}, + memory::OAM_START, + tile_demo::TileDemo, + }; + let mut demo = TileDemo::new().unwrap(); + let mut frame = Framebuffer::default(); + // Put foreground crosses over the sprite so X also tests affine composition. + for _ in 0..16 { + demo.frame(Buttons::default().with(Button::Right, true), &mut frame) + .unwrap(); + } + for bits in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0] { + let flipped = bits & 1 != 0; + let behind = bits & 2 != 0; + let rotated = bits & 4 != 0; + let zoomed = bits & 8 != 0; + let buttons = Buttons::default() + .with(Button::A, flipped) + .with(Button::B, behind) + .with(Button::L, rotated) + .with(Button::R, zoomed); + let before = demo.machine().memory().display_position(); + demo.frame(buttons, &mut frame).unwrap(); + assert_scene(&frame, 32, 0, flipped, behind, rotated, zoomed); + let m = demo.machine().memory(); + let affine = rotated || zoomed; + assert_eq!( + m.read16(OAM_START).unwrap(), + if affine { 0x340 } else { 72 } + ); + assert_eq!( + m.read16(OAM_START + 2).unwrap(), + if affine { + 0x4068 + } else { + 0x4070 | if flipped { 0x1000 } else { 0 } + } + ); + let a: i16 = match (rotated, zoomed) { + (false, false) => 256, + (false, true) => 128, + (true, false) => 181, + (true, true) => 90, + }; + let b = if rotated { a } else { 0 }; + for (i, value) in [a, b, -b, a].into_iter().enumerate() { + assert_eq!( + m.read16(OAM_START + 6 + i as u32 * 8).unwrap(), + value as u16 + ); + } + for i in 1..128 { + assert_eq!(m.read16(OAM_START + i * 8).unwrap(), 0x200); + } + assert_eq!(m.display_position().vblanks, before.vblanks + 1); + assert_eq!(m.display_position().scanline, 160); + } +} + +#[test] +fn green_swap_runs_after_tile_compositing() { + let mut m = setup(); + m.write16(VRAM, 0x21).unwrap(); + m.write16(GREENSWAP, 1).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xffff00); + assert_eq!(pixel(&m, 1, 0), 0); +}