From 1cba1510d0b3b778dddff764d594280439efdcbf Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 5 Oct 2026 12:55:35 +0700 Subject: [PATCH] feat(video): render regular and affine sprites Add OAM sampling, affine canvases, tile mapping, priority metadata, OBJ windows, and mosaic composition. --- src/video/sprites.rs | 213 +++++++++++++++++++++++ tests/sprites.rs | 370 ++++++++++++++++++++++++++++++++++++++++ tests/sprites/affine.rs | 285 +++++++++++++++++++++++++++++++ 3 files changed, 868 insertions(+) create mode 100644 src/video/sprites.rs create mode 100644 tests/sprites.rs create mode 100644 tests/sprites/affine.rs diff --git a/src/video/sprites.rs b/src/video/sprites.rs new file mode 100644 index 0000000..647522c --- /dev/null +++ b/src/video/sprites.rs @@ -0,0 +1,213 @@ +//! Regular and affine OBJ sampling. +//! Individual fetch timing and OAM contention are not modeled. + +use super::{halfword, Pixel, VideoError, WIDTH}; + +const SIZES: [[(usize, usize); 4]; 3] = [ + [(8, 8), (16, 16), (32, 32), (64, 64)], + [(16, 8), (32, 8), (32, 16), (64, 32)], + [(8, 16), (8, 32), (16, 32), (32, 64)], +]; + +pub(super) struct Objects { + pub pixels: Vec>, + pub window: Vec, +} + +pub(crate) struct PreparedObjects { + samples: Vec, + window: Vec, +} + +#[derive(Clone, Copy)] +struct ObjectPixel { + palette_index: u16, + semi_transparent: bool, +} + +// Transparent texels can change priority and mosaic without replacing color. +#[derive(Clone, Copy, Default)] +struct Sample { + pixel: Option, + priority: u8, + mosaic: bool, +} + +pub(crate) fn prepare( + control: u16, + mosaic: u16, + vertical_counter: Option, + oam: &[u8], + vram: &[u8], + rows: std::ops::Range, +) -> Result { + // Lower priority numbers win; equal priorities retain the earlier OAM entry. + // Color, priority, and mosaic metadata are tracked separately. + let mut output = PreparedObjects { + samples: vec![Sample::default(); WIDTH * rows.len()], + window: vec![false; WIDTH * rows.len()], + }; + if control & 0x1000 == 0 { + return Ok(output); + } + let samples = &mut output.samples; + let mosaic_height = usize::from(mosaic >> 12) + 1; + let one_dimensional = control & 0x40 != 0; + for index in 0..128 { + let a = usize::from(halfword(oam, index * 8)); + let b = usize::from(halfword(oam, index * 8 + 2)); + let c = usize::from(halfword(oam, index * 8 + 4)); + if a & 0x300 == 0x200 { + continue; // Disabled regular OBJ. + } + let reason = if a & 0xc00 == 0xc00 { + Some("prohibited OBJ mode") + } else if a >> 14 == 3 { + Some("prohibited OBJ shape") + } else { + None + }; + if let Some(reason) = reason { + return Err(VideoError::UnsupportedObject { index, reason }); + } + let window = a & 0xc00 == 0x800; + // Window coverage is prepared independently of its later display mask. + // OBJ-window coverage does not use either mosaic dimension. + let mosaic_enabled = a & 0x1000 != 0 && !window; + let (width, height) = SIZES[a >> 14][b >> 14]; + let affine = a & 0x100 != 0; + let doubled = affine && a & 0x200 != 0; + let canvas_width = width << usize::from(doubled); + let canvas_height = height << usize::from(doubled); + let matrix = if affine { + let base = ((b >> 9) & 31) * 32 + 6; + [0, 8, 16, 24].map(|offset| i32::from(halfword(oam, base + offset) as i16)) + } else { + [0; 4] + }; + let eight_bit = a & 0x2000 != 0; + let mut tile = c & 0x3ff; + if control & 7 >= 3 && tile < 512 { + continue; // Bitmap modes reserve the first half of OBJ tile memory. + } + if eight_bit && !one_dimensional { + tile &= !1; // 2D 8bpp tiles are aligned to pairs of 32-byte slots. + } + let slot_width = if eight_bit { 2 } else { 1 }; + let priority = ((c >> 10) & 3) as u8; + for y in rows.clone() { + let sy = (y + 256 - (a & 255)) & 255; + if sy >= canvas_height { + continue; + } + // Preparation latches the ahead-of-display OBJ counter; snapshots + // use the screen grid. + // Clamp partial first blocks to the object's first row, without + // extending its canvas. OBJ-window sampling bypasses this counter. + let sample_y = if mosaic_enabled { + sy.saturating_sub(vertical_counter.map_or(y % mosaic_height, usize::from)) + } else { + sy + }; + for sx in 0..canvas_width { + let x = ((b & 511) + sx) & 511; + if x >= WIDTH { + continue; + } + let offset = (y - rows.start) * WIDTH + x; + let (tx, ty) = if affine { + // Signed 8.8 inverse mapping from canvas center to texture center. + // Keep fractional products until the final arithmetic shift. Negative + // coordinates round down, not toward zero. Outside texels are transparent. + let dx = sx as i32 - canvas_width as i32 / 2; + let dy = sample_y as i32 - canvas_height as i32 / 2; + let tx = (matrix[0] * dx + matrix[1] * dy + ((width as i32) << 7)) >> 8; + let ty = (matrix[2] * dx + matrix[3] * dy + ((height as i32) << 7)) >> 8; + if tx < 0 || ty < 0 || tx >= width as i32 || ty >= height as i32 { + continue; + } + (tx as usize, ty as usize) + } else { + ( + if b & 0x1000 != 0 { width - 1 - sx } else { sx }, + if b & 0x2000 != 0 { + height - 1 - sample_y + } else { + sample_y + }, + ) + }; + let slot = if one_dimensional { + tile + (ty / 8 * (width / 8) + tx / 8) * slot_width + } else { + // A 2D row is 32 slots wide. Horizontal overflow wraps within + // that row; vertical overflow wraps within 32 KiB OBJ VRAM. + (tile & !31) + (ty / 8) * 32 + ((tile + tx / 8 * slot_width) & 31) + }; + let pixel = (ty % 8) * 8 + tx % 8; + let address = + 0x10000 + ((slot * 32 + if eight_bit { pixel } else { pixel / 2 }) & 0x7fff); + let color_index = if eight_bit { + usize::from(vram[address]) + } else { + usize::from(vram[address] >> ((pixel & 1) * 4) & 15) + }; + if window && color_index != 0 { + output.window[offset] = true; + } else { + let sample = &mut samples[offset]; + if sample.pixel.is_none() || priority < sample.priority { + if color_index != 0 { + let bank = if eight_bit { 0 } else { (c >> 12) * 16 }; + sample.pixel = Some(ObjectPixel { + palette_index: (bank + color_index) as u16, + semi_transparent: a & 0xc00 == 0x400, + }); + } + sample.priority = priority; + sample.mosaic = mosaic_enabled; + } + } + } + } + } + Ok(output) +} + +impl PreparedObjects { + /// Resolve buffered indices through the current palette. Horizontal mosaic + /// belongs to composition, not the previous line's OAM/VRAM preparation. + pub(super) fn compose(&self, mosaic: u16, palette: &[u8]) -> Objects { + let mut output = Objects { + pixels: vec![None; self.samples.len()], + window: self.window.clone(), + }; + let mosaic_width = usize::from((mosaic >> 8) & 15) + 1; + // Horizontal OBJ mosaic latches the selected object sample, not each + // object's texture coordinate. Reset at each scanline and at transitions + // to/from non-mosaic samples or a numerically lower priority. + for (source, destination) in self + .samples + .chunks_exact(WIDTH) + .zip(output.pixels.chunks_exact_mut(WIDTH)) + { + let mut latched = Sample::default(); + for (x, (¤t, pixel)) in source.iter().zip(destination).enumerate() { + if x % mosaic_width == 0 + || !current.mosaic + || !latched.mosaic + || current.priority < latched.priority + { + latched = current; + } + *pixel = latched.pixel.map(|p| Pixel { + color: halfword(palette, 0x200 + usize::from(p.palette_index) * 2), + layer: 4, + priority: latched.priority, + semi_transparent: p.semi_transparent, + }); + } + } + output + } +} diff --git a/tests/sprites.rs b/tests/sprites.rs new file mode 100644 index 0000000..923c6dc --- /dev/null +++ b/tests/sprites.rs @@ -0,0 +1,370 @@ +use gba_rust::{ + io::{BG0CNT, BG2CNT, DISPCNT, GREENSWAP}, + memory::{Memory, MemoryError, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM}, + video::{rgb555_to_rgb888, Framebuffer, VideoError, WIDTH}, +}; + +#[path = "sprites/affine.rs"] +mod affine; + +fn setup() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(DISPCNT, 0x1040).unwrap(); // OBJ, 1D, Mode 0 + for i in 0..128 { + m.write16(OAM + i * 8, 0x200).unwrap(); + } + for (i, color) in [0x4210, 0x001f, 0x03e0, 0x7c00, 0x7fff] + .into_iter() + .enumerate() + { + m.write16(PAL + 0x200 + i as u32 * 2, color).unwrap(); + } + m.write16(PAL, 0x4210).unwrap(); + m +} + +fn obj(m: &mut Memory, index: u32, a: u16, b: u16, c: u16) { + m.write16(OAM + index * 8, a).unwrap(); + m.write16(OAM + index * 8 + 2, b).unwrap(); + m.write16(OAM + index * 8 + 4, c).unwrap(); +} + +fn fill(m: &mut Memory, slot: u32, bytes: u32, value: u16) { + for offset in (0..bytes).step_by(2) { + m.write16(VRAM + 0x10000 + ((slot * 32 + offset) & 0x7fff), value) + .unwrap(); + } +} + +fn render(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + m.render_frame(&mut f).unwrap(); + f +} + +fn pixel(m: &Memory, x: usize, y: usize) -> u32 { + render(m).pixels()[y * WIDTH + x] +} + +#[test] +fn oam_mirrors_reads_all_bytes_and_ignores_only_byte_writes() { + let mut m = setup(); + m.write32(OAM, 0xabcd_1234).unwrap(); + assert_eq!(m.read32(OAM + 0x400).unwrap(), 0xabcd_1234); + assert_eq!(m.read8(OAM + 1).unwrap(), 0x12); + m.write8(OAM, 0xff).unwrap(); + m.write8(OAM + 3, 0xff).unwrap(); + assert_eq!(m.read32(OAM).unwrap(), 0xabcd_1234); + m.write32(0x07ff_fffc, 0x7654_3210).unwrap(); + assert_eq!(m.read32(OAM + 0x3fc).unwrap(), 0x7654_3210); + m.write16(OAM + 6, 0xface).unwrap(); // Affine parameter slots are ordinary memory. + assert_eq!(m.read16(OAM + 0x406).unwrap(), 0xface); + assert_eq!(m.write16(OAM + 1, 0), Err(MemoryError::Unaligned(OAM + 1))); + assert_eq!(m.write32(OAM + 2, 0), Err(MemoryError::Unaligned(OAM + 2))); + assert_eq!(m.read32(OAM).unwrap(), 0xabcd_1234); +} + +#[test] +fn four_bit_sprite_nibbles_banks_zero_transparency_and_opaque_black() { + let mut m = setup(); + obj(&mut m, 0, 0, 0, 0xf000); + fill(&mut m, 0, 2, 0x0321); + m.write16(PAL + 0x200 + 241 * 2, 0x001f).unwrap(); + m.write16(PAL + 0x200 + 242 * 2, 0x03e0).unwrap(); + m.write16(PAL + 0x200 + 243 * 2, 0).unwrap(); + m.write16(PAL + 0x200 + 240 * 2, 0x7fff).unwrap(); + assert_eq!( + &render(&m).pixels()[..4], + &[0xff0000, 0x00ff00, 0, rgb555_to_rgb888(0x4210)] + ); +} + +#[test] +fn eight_bit_sprite_ignores_bank_and_uses_obj_palette_not_bg_palette() { + let mut m = setup(); + obj(&mut m, 0, 0x2000, 0, 0xf000); + fill(&mut m, 0, 2, 0x00ff); + m.write16(PAL + 0x3fe, 0x03e0).unwrap(); + m.write16(PAL + 0x1fe, 0x001f).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + assert_eq!(pixel(&m, 1, 0), rgb555_to_rgb888(0x4210)); +} + +#[test] +fn every_shape_and_size_covers_exact_rectangle() { + let sizes = [ + [(8, 8), (16, 16), (32, 32), (64, 64)], + [(16, 8), (32, 8), (32, 16), (64, 32)], + [(8, 16), (8, 32), (16, 32), (32, 64)], + ]; + for (shape, dimensions) in sizes.iter().enumerate() { + for (size, &(w, h)) in dimensions.iter().enumerate() { + let mut m = setup(); + fill(&mut m, 0, 0x8000, 0x1111); + obj( + &mut m, + 0, + ((shape as u16) << 14) | 5, + ((size as u16) << 14) | 7, + 0, + ); + let f = render(&m); + for y in 0..160 { + for x in 0..240 { + let expected = if (7..7 + w).contains(&x) && (5..5 + h).contains(&y) { + 0xff0000 + } else { + rgb555_to_rgb888(0x4210) + }; + assert_eq!( + f.pixels()[y * WIDTH + x], + expected, + "shape {shape}, size {size}, pixel {x},{y}" + ); + } + } + } + } +} + +#[test] +fn tile_row_strides_differ_between_1d_and_2d_in_both_depths() { + for eight in [false, true] { + for one in [false, true] { + let mut m = setup(); + m.write16(DISPCNT, if one { 0x1040 } else { 0x1000 }) + .unwrap(); + obj(&mut m, 0, if eight { 0x2000 } else { 0 }, 0x4000, 0); + let slots = if eight { 2 } else { 1 }; + for (slot, index) in [ + (0, 1), + (slots, 2), + (if one { 2 * slots } else { 32 }, 3), + (if one { 3 * slots } else { 32 + slots }, 4), + ] { + fill( + &mut m, + slot, + slots * 32, + if eight { index * 0x101 } else { index * 0x1111 }, + ); + } + let f = render(&m); + for (x, y, color) in [ + (0, 0, 0xff0000), + (8, 0, 0x00ff00), + (0, 8, 0x0000ff), + (8, 8, 0xffffff), + ] { + assert_eq!(f.pixels()[y * WIDTH + x], color, "8bit={eight} 1D={one}"); + } + } + } +} + +#[test] +fn eight_bit_odd_base_is_preserved_in_1d_but_aligned_in_2d() { + let mut m = setup(); + fill(&mut m, 0, 32, 0x0101); + fill(&mut m, 1, 32, 0x0202); + obj(&mut m, 0, 0x2000, 0, 1); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + m.write16(DISPCNT, 0x1000).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); +} + +#[test] +fn mapping_wraps_horizontal_2d_rows_and_the_32k_obj_area() { + let mut m = setup(); + obj(&mut m, 0, 0x4000, 0, 1023); // 16x8 + fill(&mut m, 1023, 32, 0x1111); + fill(&mut m, 0, 32, 0x2222); + fill(&mut m, 992, 32, 0x3333); + assert_eq!(pixel(&m, 8, 0), 0x00ff00); // 1D wraps entire area. + m.write16(DISPCNT, 0x1000).unwrap(); + assert_eq!(pixel(&m, 8, 0), 0x0000ff); // 2D wraps within last row. + obj(&mut m, 0, 0, 0x4000, 992); // 16x16 + assert_eq!(pixel(&m, 0, 8), 0x00ff00); // Next row wraps to first row. +} + +#[test] +fn flips_reverse_whole_multi_tile_sprite_not_each_tile() { + for eight in [false, true] { + let mut m = setup(); + let slots = if eight { 2 } else { 1 }; + for i in 0..4 { + fill( + &mut m, + i * slots, + 32 * slots, + if eight { + (i as u16 + 1) * 0x101 + } else { + (i as u16 + 1) * 0x1111 + }, + ); + } + for (flips, color) in [ + (0, 0xff0000), + (0x1000, 0x00ff00), + (0x2000, 0x0000ff), + (0x3000, 0xffffff), + ] { + obj(&mut m, 0, if eight { 0x2000 } else { 0 }, 0x4000 | flips, 0); + assert_eq!(pixel(&m, 0, 0), color); + } + } +} + +#[test] +fn screen_coordinates_wrap_and_clip_at_each_edge() { + let mut m = setup(); + fill(&mut m, 0, 32, 0x1111); + for (ox, oy) in [(508, 252), (238, 158), (250, 170)] { + obj(&mut m, 0, oy, ox, 0); + let f = render(&m); + for y in 0..160 { + for x in 0..240 { + let inside = + ((x + 512 - ox as usize) & 511) < 8 && ((y + 256 - oy as usize) & 255) < 8; + assert_eq!( + f.pixels()[y * WIDTH + x], + if inside { + 0xff0000 + } else { + rgb555_to_rgb888(0x4210) + } + ); + } + } + } +} + +#[test] +fn object_priority_wins_then_oam_index_and_transparent_pixels_expose_later_objs() { + let mut m = setup(); + fill(&mut m, 0, 32, 0x1111); + fill(&mut m, 1, 32, 0x2222); + obj(&mut m, 0, 0, 0, 0xc00); // First OBJ has lowest BG priority. + obj(&mut m, 127, 0, 0, 1); // Later OBJ has highest BG priority. + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + obj(&mut m, 127, 0, 0, 0xc01); // Equal priority: first OAM entry wins. + assert_eq!(pixel(&m, 0, 0), 0xff0000); + // BG0 priority2 covers both priority3 objects. + m.write16(DISPCNT, 0x1140).unwrap(); + m.write16(BG0CNT, 0x1002).unwrap(); + m.write16(VRAM, 0x3333).unwrap(); + m.write16(PAL + 6, 0x7c00).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x0000ff); + obj(&mut m, 0, 0, 0, 0); + obj(&mut m, 127, 0, 0, 1); + fill(&mut m, 0, 2, 0x0010); // Zero only at first pixel. + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + assert_eq!(pixel(&m, 1, 0), 0xff0000); +} + +#[test] +fn sprite_beats_bg_on_equal_priority_but_not_higher_priority_bg() { + let mut m = setup(); + obj(&mut m, 0, 0, 0, 0x400); + fill(&mut m, 0, 32, 0x1111); + m.write16(DISPCNT, 0x1140).unwrap(); + m.write16(BG0CNT, 0x1001).unwrap(); + m.write16(VRAM, 0x2222).unwrap(); + m.write16(PAL + 4, 0x03e0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + m.write16(BG0CNT, 0x1000).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + m.write16(VRAM, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); // Transparent BG reveals sprite. +} + +#[test] +fn mode3_uses_bg2_priority_and_only_upper_obj_tile_numbers() { + let mut m = setup(); + m.write16(DISPCNT, 0x1443).unwrap(); + m.write16(VRAM, 0x03e0).unwrap(); + fill(&mut m, 0, 32, 0x1111); + fill(&mut m, 512, 32, 0x1111); + obj(&mut m, 0, 0, 0, 0); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + obj(&mut m, 0, 0, 0, 512); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + obj(&mut m, 0, 0, 0, 512 | 0x400); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + m.write16(BG2CNT, 1).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); + m.write16(DISPCNT, 0x1043).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0xff0000); // BG2 disabled. +} + +#[test] +fn disabled_objects_ignore_unsupported_bits_and_global_disable_skips_all_objects() { + let mut m = setup(); + obj(&mut m, 0, 0xfe00, 0xffff, 0xffff); // Disabled, despite mode/mosaic/shape bits. + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(0x4210)); + obj(&mut m, 0, 0xc00, 0, 0); // Prohibited OBJ mode. + m.write16(DISPCNT, 0).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(0x4210)); +} + +#[test] +fn unsupported_objects_leave_output_unchanged_and_forced_blank_bypasses_errors() { + let mut m = setup(); + fill(&mut m, 0, 32, 0x2222); + obj(&mut m, 0, 0, 0, 0); + let mut frame = Framebuffer::default(); + frame.clear(0x001f); + for attr in [0xc00, 0xc000, 0xd00, 0x1c00, 0xc300] { + obj(&mut m, 127, attr, 0, 0); + assert!(matches!( + m.render_frame(&mut frame), + Err(VideoError::UnsupportedObject { index: 127, .. }) + )); + assert!(frame.pixels().iter().all(|&p| p == 0xff0000)); + } + m.write16(DISPCNT, 0x1080).unwrap(); + m.render_frame(&mut frame).unwrap(); + assert!(frame.pixels().iter().all(|&p| p == 0xffffff)); +} + +#[test] +fn green_swap_applies_after_sprite_composition() { + let mut m = setup(); + obj(&mut m, 0, 0, 0, 0); + fill(&mut m, 0, 2, 0x21); + m.write16(GREENSWAP, 1).unwrap(); + assert_eq!(&render(&m).pixels()[..2], &[0xffff00, 0]); +} + +#[test] +fn oam_bus_uses_one_cycle_for_every_width() { + use gba_rust::timing::{bus_cycles, AccessKind, AccessWidth}; + for kind in [AccessKind::Sequential, AccessKind::NonSequential] { + for width in [AccessWidth::Byte, AccessWidth::Halfword, AccessWidth::Word] { + assert_eq!(bus_cycles(0, OAM, width, kind), 1); + } + } +} + +#[test] +fn dma_copies_words_to_oam_and_reads_them_back() { + use gba_rust::{ + cpu::Cpu, + dma::DMA_BASE, + machine::{Machine, StepKind}, + }; + let mut machine = Machine::new(Cpu::new(0x0300_0000), setup()); + machine + .memory_mut() + .write32(0x0200_0000, 0x1234_abcd) + .unwrap(); + for (source, dest) in [(0x0200_0000, OAM + 0x400), (OAM, 0x0200_0004)] { + let m = machine.memory_mut(); + m.write32(DMA_BASE + 36, source).unwrap(); + m.write32(DMA_BASE + 40, dest).unwrap(); + m.write32(DMA_BASE + 44, 0x8400_0001).unwrap(); + assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 }); + } + assert_eq!(machine.memory().read32(0x0200_0004).unwrap(), 0x1234_abcd); +} diff --git a/tests/sprites/affine.rs b/tests/sprites/affine.rs new file mode 100644 index 0000000..6a99db1 --- /dev/null +++ b/tests/sprites/affine.rs @@ -0,0 +1,285 @@ +use super::*; + +fn matrix(m: &mut Memory, group: u32, values: [i16; 4]) { + for (i, value) in values.into_iter().enumerate() { + m.write16(OAM + group * 32 + 6 + i as u32 * 8, value as u16) + .unwrap(); + } +} + +fn texel(x: usize, y: usize) -> u16 { + if (x + y) % 7 == 0 { + 0 + } else { + ((x + 3 * y) % 15 + 1) as u16 + } +} + +fn pattern(m: &mut Memory, w: usize, h: usize, eight: bool, one: bool) { + let mut bytes = [0_u8; 0x8000]; + for y in 0..h { + for x in 0..w { + let slot = (y / 8) + * if one { + w / 8 * if eight { 2 } else { 1 } + } else { + 32 + } + + (x / 8) * if eight { 2 } else { 1 }; + let p = (y % 8) * 8 + x % 8; + let address = slot * 32 + if eight { p } else { p / 2 }; + bytes[address] |= (texel(x, y) as u8) << if eight { 0 } else { (p % 2) * 4 }; + } + } + for (i, pair) in bytes.chunks_exact(2).enumerate() { + m.write16( + VRAM + 0x10000 + i as u32 * 2, + u16::from_le_bytes([pair[0], pair[1]]), + ) + .unwrap(); + } + for i in 1..16 { + m.write16(PAL + 0x200 + i * 2, i as u16 * 0x421).unwrap(); + } +} + +// Independent floating-point reference. Every coefficient is an exact multiple +// of 1/256, so these small products are exact in f64. Floor defines negative rounding. +fn check_reference( + m: &Memory, + dimensions: (usize, usize), + origin: (usize, usize), + doubled: bool, + values: [i16; 4], +) { + let (w, h) = dimensions; + let (cw, ch) = if doubled { (w * 2, h * 2) } else { (w, h) }; + let f = render(m); + for y in 0..160 { + for x in 0..240 { + let lx = (x + 512 - origin.0) % 512; + let ly = (y + 256 - origin.1) % 256; + let mut color = rgb555_to_rgb888(0x4210); + if lx < cw && ly < ch { + let dx = lx as f64 - cw as f64 / 2.0; + let dy = ly as f64 - ch as f64 / 2.0; + let tx = (f64::from(values[0]) / 256.0 * dx + + f64::from(values[1]) / 256.0 * dy + + w as f64 / 2.0) + .floor(); + let ty = (f64::from(values[2]) / 256.0 * dx + + f64::from(values[3]) / 256.0 * dy + + h as f64 / 2.0) + .floor(); + if tx >= 0.0 && ty >= 0.0 && tx < w as f64 && ty < h as f64 { + let index = texel(tx as usize, ty as usize); + if index != 0 { + color = rgb555_to_rgb888(index * 0x421); + } + } + } + assert_eq!( + f.pixels()[y * WIDTH + x], + color, + "pixel {x},{y}, size {w}x{h}, double={doubled}, matrix={values:?}" + ); + } + } +} + +#[test] +fn identity_matches_regular_sprites_for_all_shapes_sizes_depths_and_layouts() { + let sizes = [ + [(8, 8), (16, 16), (32, 32), (64, 64)], + [(16, 8), (32, 8), (32, 16), (64, 32)], + [(8, 16), (8, 32), (16, 32), (32, 64)], + ]; + for (shape, row) in sizes.iter().enumerate() { + for (size, &(w, h)) in row.iter().enumerate() { + for eight in [false, true] { + for one in [false, true] { + let mut m = setup(); + m.write16(DISPCNT, if one { 0x1040 } else { 0x1000 }) + .unwrap(); + pattern(&mut m, w, h, eight, one); + let a = (shape as u16) << 14 | if eight { 0x2000 } else { 0 } | 9; + let b = (size as u16) << 14 | 11; + obj(&mut m, 0, a, b, 0); + let regular = render(&m); + matrix(&mut m, 31, [256, 0, 0, 256]); + obj(&mut m, 0, a | 0x100, b | (31 << 9), 0); // High group bits are not flip flags. + assert_eq!(render(&m).pixels(), regular.pixels()); + } + } + } + } +} + +#[test] +fn rotations_shears_reflections_and_fractional_scales_match_reference() { + let transforms = [ + [0, 256, -256, 0], + [0, -256, 256, 0], + [-256, 0, 0, -256], + [181, 181, -181, 181], + [256, 128, 0, 256], + [-256, 0, 0, 256], + [128, 0, 0, 128], + [512, 0, 0, 512], + [512, 0, 0, 128], + [255, 1, -1, 257], + ]; + for eight in [false, true] { + for one in [false, true] { + for doubled in [false, true] { + let mut m = setup(); + m.write16(DISPCNT, if one { 0x1040 } else { 0x1000 }) + .unwrap(); + pattern(&mut m, 16, 8, eight, one); + obj( + &mut m, + 0, + 0x4100 | if doubled { 0x200 } else { 0 } | if eight { 0x2000 } else { 0 } | 17, + 23, + 0, + ); + for values in transforms { + matrix(&mut m, 0, values); + check_reference(&m, (16, 8), (23, 17), doubled, values); + } + } + } + } +} + +#[test] +fn double_size_changes_canvas_and_center_without_changing_texture_size() { + let mut m = setup(); + pattern(&mut m, 8, 8, false, true); + for doubled in [false, true] { + obj( + &mut m, + 0, + 0x100 | if doubled { 0x200 } else { 0 } | 20, + 30, + 0, + ); + for values in [[256, 0, 0, 256], [128, 0, 0, 128]] { + matrix(&mut m, 0, values); + check_reference(&m, (8, 8), (30, 20), doubled, values); + } + } + // Identity double-size: texture starts four pixels inside the enlarged box. + matrix(&mut m, 0, [256, 0, 0, 256]); + assert_eq!(pixel(&m, 30, 20), rgb555_to_rgb888(0x4210)); + assert_eq!(pixel(&m, 35, 24), rgb555_to_rgb888(texel(1, 0) * 0x421)); +} + +#[test] +fn zero_singular_and_extreme_signed_matrices_do_not_overflow_or_invert() { + let mut m = setup(); + pattern(&mut m, 64, 64, true, true); + obj(&mut m, 0, 0x2300, 0xc000, 0); + for values in [ + [0; 4], + [256, 0, 0, 0], + [32767, -32768, -32768, 32767], + [-1, 1, 1, -1], + [1, 0, 0, 1], + ] { + matrix(&mut m, 0, values); + check_reference(&m, (64, 64), (0, 0), true, values); + } +} + +#[test] +fn all_32_matrix_groups_are_addressed_independently_and_can_be_shared() { + let mut m = setup(); + pattern(&mut m, 8, 8, false, true); + for group in 0..32 { + // Distinct coefficients detect both incorrect matrix stride and component stride. + let values = [ + 256 + group as i16, + group as i16 * 3, + -(group as i16) * 2, + 128, + ]; + matrix(&mut m, group, values); + } + for group in 0..32 { + let values = [ + 256 + group as i16, + group as i16 * 3, + -(group as i16) * 2, + 128, + ]; + obj(&mut m, 0, 0x100, (group as u16) << 9, 0); + check_reference(&m, (8, 8), (0, 0), false, values); + } + matrix(&mut m, 31, [256, 0, 0, 256]); + obj(&mut m, 0, 0x100, 31 << 9, 0); + obj(&mut m, 127, 0x100, (31 << 9) | 16, 0); + let f = render(&m); + for y in 0..8 { + for x in 0..8 { + assert_eq!(f.pixels()[y * WIDTH + x], f.pixels()[y * WIDTH + x + 16]); + } + } + // Changes to one shared matrix are visible to both sprites on the next snapshot. + matrix(&mut m, 31, [0; 4]); + let f = render(&m); + for y in 0..8 { + for x in 0..8 { + assert_eq!(f.pixels()[y * WIDTH + x], f.pixels()[y * WIDTH + x + 16]); + } + } +} + +#[test] +fn affine_canvas_wraps_coordinates_and_clips_without_restarting_transform() { + let mut m = setup(); + pattern(&mut m, 32, 64, false, true); + let values = [181, 181, -181, 181]; + matrix(&mut m, 0, values); + for (x, y) in [(500, 240), (220, 145), (255, 170)] { + obj(&mut m, 0, 0x8300 | y as u16, 0xc000 | x as u16, 0); + check_reference(&m, (32, 64), (x, y), true, values); + } +} + +#[test] +fn affine_transparent_edges_reveal_later_objects_and_obey_bg_priority() { + let mut m = setup(); + fill(&mut m, 0, 32, 0x1111); + fill(&mut m, 1, 32, 0x2222); + matrix(&mut m, 0, [512, 0, 0, 512]); + obj(&mut m, 0, 0x100, 0, 0x400); + obj(&mut m, 1, 0, 0, 0x401); // Equal priority retains the first opaque OBJ. + assert_eq!(pixel(&m, 0, 0), 0x00ff00); // Outside transformed texture, later OBJ visible. + assert_eq!(pixel(&m, 4, 4), 0xff0000); + m.write16(DISPCNT, 0x1140).unwrap(); + m.write16(BG0CNT, 0x1000).unwrap(); + for address in (0..32).step_by(2) { + m.write16(VRAM + address, 0x3333).unwrap(); + } + m.write16(PAL + 6, 0x7c00).unwrap(); + assert_eq!(pixel(&m, 4, 4), 0x0000ff); // BG priority0 covers selected OBJ priority1. + assert_eq!(pixel(&m, 0, 0), 0x0000ff); + m.write16(BG0CNT, 0x1001).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); // OBJ ties BG and wins at transparent edge. +} + +#[test] +fn mode3_affine_sprites_keep_bitmap_tile_restrictions() { + let mut m = setup(); + m.write16(DISPCNT, 0x1043).unwrap(); + matrix(&mut m, 0, [128, 0, 0, 128]); + fill(&mut m, 0, 32, 0x1111); + fill(&mut m, 512, 32, 0x2222); + obj(&mut m, 0, 0x300, 0, 0); + assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(0x4210)); + obj(&mut m, 0, 0x300, 0, 512); + assert_eq!(pixel(&m, 0, 0), 0x00ff00); + assert_eq!(pixel(&m, 15, 15), 0x00ff00); + assert_eq!(pixel(&m, 16, 16), rgb555_to_rgb888(0x4210)); +}