diff --git a/src/video/mosaic.rs b/src/video/mosaic.rs new file mode 100644 index 0000000..27a4be4 --- /dev/null +++ b/src/video/mosaic.rs @@ -0,0 +1,190 @@ +//! Vertical mosaic phase for row capture. Size writes preserve four-bit counters. +//! Background phase follows the displayed row. OBJ phase runs one row ahead +//! for sprite preparation. Per-access size latching is not modeled. + +use crate::display::{CYCLES_PER_FRAME, CYCLES_PER_LINE, VISIBLE_LINES}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct VerticalMosaic { + pub background: u8, + pub object: u8, + started: bool, +} + +impl VerticalMosaic { + pub fn advance(&mut self, phase: u32, cycles: u32, sizes: u16) { + if cycles == 0 { + return; + } + let object_height = u32::from(sizes >> 12) + 1; + if !self.started { + // Reset has no preceding row227. Row0 uses phase0; row1 uses the next phase. + self.object = u8::from(object_height != 1); + self.started = true; + } + self.background = progress( + phase, + cycles, + self.background, + u32::from((sizes >> 4) & 15) + 1, + ) + .counter; + self.object = progress( + (phase + CYCLES_PER_LINE) % CYCLES_PER_FRAME, + cycles, + self.object, + object_height, + ) + .counter; + } +} + +pub(super) struct Progress { + pub counter: u8, + /// Number of zero transitions since the last frame reload, if any. + pub resets: u32, + pub first_reset: bool, + pub frame_reloaded: bool, + pub crossed_line: bool, +} + +/// Summarize fixed-size line transitions without iterating over lines or frames. +/// A frame wrap discards earlier history because affine origins reload at row0. +pub(super) fn progress(phase: u32, cycles: u32, counter: u8, height: u32) -> Progress { + debug_assert!(counter < 16 && (1..=16).contains(&height)); + let end = u64::from(phase) + u64::from(cycles); + let wrapped = end >= u64::from(CYCLES_PER_FRAME); + let first = if wrapped { 0 } else { phase / CYCLES_PER_LINE }; + let last = (end % u64::from(CYCLES_PER_FRAME)) as u32 / CYCLES_PER_LINE; + let initial = if wrapped { 0 } else { u32::from(counter) }; + let first_reset = first < VISIBLE_LINES + && (first == VISIBLE_LINES - 1 || initial + 1 == height || initial == 15); + // Normal increments enter rows1..159. Entry to row160 unconditionally resets. + let lines = last.min(VISIBLE_LINES - 1) - first.min(VISIBLE_LINES - 1); + let until_zero = if initial < height { + height - initial + } else { + 16 - initial + }; + let (mut current, mut resets) = if lines < until_zero { + (initial + lines, 0) + } else { + let remaining = lines - until_zero; + (remaining % height, 1 + remaining / height) + }; + if first < VISIBLE_LINES && last >= VISIBLE_LINES { + current = 0; + resets += 1; + } + Progress { + counter: current as u8, + resets, + first_reset, + frame_reloaded: wrapped, + crossed_line: wrapped || first != last, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_counter_height_and_visible_line_matches_iterated_transitions() { + for counter in 0..16 { + for height in 1..=16 { + for first in 0..160 { + let mut expected = counter; + let mut resets = 0; + for last in first + 1..=160 { + let next = expected + 1; + expected = if last == 160 || next == height { + 0 + } else { + next & 15 + }; + resets += u32::from(expected == 0); + let p = progress( + first * CYCLES_PER_LINE, + (last - first) * CYCLES_PER_LINE, + counter as u8, + height, + ); + assert_eq!( + u32::from(p.counter), + expected, + "counter={counter} height={height} first={first} last={last}" + ); + assert_eq!(p.resets, resets); + assert_eq!( + p.first_reset, + first == 159 || counter + 1 == height || counter == 15 + ); + } + } + } + } + } + + #[test] + fn random_clock_batches_and_maximum_batch_match_line_iteration() { + let mut seed = 0x3712_bc09u32; + for case in 0..500 { + 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() % (7 * CYCLES_PER_FRAME) + }; + let height = next() % 16 + 1; + let counter = if phase / CYCLES_PER_LINE >= 160 { + 0 + } else { + (next() & 15) as u8 + }; + let mut expected = counter; + let mut resets = 0; + for boundary in u64::from(phase / CYCLES_PER_LINE) + 1 + ..=(u64::from(phase) + u64::from(cycles)) / u64::from(CYCLES_PER_LINE) + { + let row = boundary % 228; + if row == 0 { + expected = 0; + resets = 0; + } else if row <= 160 { + let value = u32::from(expected) + 1; + expected = if row == 160 || value == height { + 0 + } else { + (value & 15) as u8 + }; + resets += u32::from(expected == 0); + } + } + let p = progress(phase, cycles, counter, height); + assert_eq!(p.counter, expected, "phase={phase} cycles={cycles}"); + assert_eq!(p.resets, resets); + } + } + + #[test] + fn size_shrink_preserves_counter_until_four_bit_wrap_and_zero_cycles_do_nothing() { + let mut mosaic = VerticalMosaic::default(); + mosaic.advance(0, 6 * CYCLES_PER_LINE, 0x3070); // BG8, OBJ4. + assert_eq!((mosaic.background, mosaic.object), (6, 3)); // OBJ prepares row7. + mosaic.advance(6 * CYCLES_PER_LINE, 0, 0); + assert_eq!((mosaic.background, mosaic.object), (6, 3)); + mosaic.advance(6 * CYCLES_PER_LINE, 9 * CYCLES_PER_LINE, 0x1010); // BG2, OBJ2. + assert_eq!((mosaic.background, mosaic.object), (15, 12)); + mosaic.advance(15 * CYCLES_PER_LINE, CYCLES_PER_LINE, 0x1010); + assert_eq!((mosaic.background, mosaic.object), (0, 13)); + mosaic.advance(16 * CYCLES_PER_LINE, 144 * CYCLES_PER_LINE, 0xffff); + assert_eq!((mosaic.background, mosaic.object), (0, 0)); + mosaic.advance(160 * CYCLES_PER_LINE, 68 * CYCLES_PER_LINE, 0xffff); + assert_eq!((mosaic.background, mosaic.object), (0, 1)); // Row1 phase, row0 already prepared. + } +} diff --git a/tests/mosaic.rs b/tests/mosaic.rs new file mode 100644 index 0000000..f336fe2 --- /dev/null +++ b/tests/mosaic.rs @@ -0,0 +1,47 @@ +use gba_rust::{ + io::*, + memory::{Memory, MemoryError, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM}, + video::{rgb555_to_rgb888 as rgb, Framebuffer, HEIGHT, WIDTH}, +}; + +#[path = "mosaic/backgrounds.rs"] +mod backgrounds; +#[path = "mosaic/demo.rs"] +mod demo; +#[path = "mosaic/objects.rs"] +mod objects; + +fn memory() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + for index in 0..128 { + m.write16(OAM + index * 8, 0x200).unwrap(); + } + for index in 0..256 { + let color = (index & 31) | (((index >> 3) & 31) << 5) | (((index * 7) & 31) << 10); + m.write16(PAL + index * 2, color as u16).unwrap(); + m.write16(PAL + 0x200 + index * 2, color as u16).unwrap(); + } + m +} + +fn render(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + m.render_frame(&mut f).unwrap(); + f +} + +fn object(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, address: u32, bytes: u32, value: u16) { + for offset in (0..bytes).step_by(2) { + m.write16(address + offset, value).unwrap(); + } +} + +fn at(frame: &Framebuffer, x: usize, y: usize) -> u32 { + frame.pixels()[y * WIDTH + x] +} diff --git a/tests/mosaic/backgrounds.rs b/tests/mosaic/backgrounds.rs new file mode 100644 index 0000000..4e67fac --- /dev/null +++ b/tests/mosaic/backgrounds.rs @@ -0,0 +1,212 @@ +use super::*; + +fn bitmap(mode: u16, page: u16) -> Memory { + let mut m = memory(); + m.write16(DISPCNT, 0x400 | mode | (page * 16)).unwrap(); + m.write16(BG2PA, 256).unwrap(); + m.write16(BG2PD, 256).unwrap(); + let (w, h) = if mode == 5 { (160, 128) } else { (240, 160) }; + for y in 0..h { + for x in 0..w { + let offset = (y * w + x) as u32; + let index = (x * 3 + y * 7) as u16 & 255; + if mode == 4 { + if x % 2 == 0 { + let next = ((x + 1) * 3 + y * 7) as u16 & 255; + m.write16(VRAM + page as u32 * 0xa000 + offset, index | (next << 8)) + .unwrap(); + } + } else { + let color = + (x as u16 & 31) | ((y as u16 & 31) << 5) | (((x + y) as u16 & 31) << 10); + m.write16(VRAM + page as u32 * 0xa000 + offset * 2, color) + .unwrap(); + } + } + } + m +} + +#[test] +fn write_only_register_merges_bytes_and_ignores_upper_word_half() { + let mut m = bitmap(3, 0); + m.write16(BG2CNT, 0x40).unwrap(); + m.write32(MOSAIC, 0xffff_0034).unwrap(); // BG 5x4, OBJ1x1. + assert_eq!(m.read32(MOSAIC).unwrap(), 0); + m.write8(MOSAIC + 1, 0xff).unwrap(); // Must retain BG dimensions. + let f = render(&m); + assert_eq!(at(&f, 4, 3), at(&f, 0, 0)); + assert_ne!(at(&f, 5, 4), at(&f, 0, 0)); + m.write8(MOSAIC, 0).unwrap(); + assert_ne!(at(&render(&m), 1, 0), at(&f, 0, 0)); + assert_eq!(m.read16(MOSAIC + 2).unwrap(), 0); + assert_eq!( + m.read8(MOSAIC + 0x10000), + Err(MemoryError::Unmapped(MOSAIC + 0x10000)) + ); + assert_eq!( + m.write16(MOSAIC + 1, 0), + Err(MemoryError::Unaligned(MOSAIC + 1)) + ); + assert_eq!(m.cycles(), 0); +} + +#[test] +fn every_bg_size_and_bitmap_page_repeats_screen_grid_samples() { + for mode in [3, 4, 5] { + for page in 0..=u16::from(mode != 3) { + let mut m = bitmap(mode, page); + let base = render(&m); + m.write16(BG2CNT, 0x40).unwrap(); + // Exercise every field value, including asymmetric blocks and partial LCD blocks. + for w in 1..=16 { + let h = 17 - w; + m.write16(MOSAIC, ((h - 1) * 16 + w - 1) as u16).unwrap(); + let f = render(&m); + for y in 0..HEIGHT { + for x in 0..WIDTH { + assert_eq!( + at(&f, x, y), + at(&base, x / w * w, y / h * h), + "mode={mode} page={page} size={w}x{h} at={x},{y}" + ); + } + } + } + } + } +} + +#[test] +fn mosaic_flag_is_independent_of_dimensions_and_one_by_one_is_identity() { + let mut m = bitmap(3, 0); + let base = render(&m); + m.write16(MOSAIC, 0xffff).unwrap(); + assert_eq!(render(&m).pixels(), base.pixels()); // Flag is off. + m.write16(BG2CNT, 0x40).unwrap(); + m.write16(MOSAIC, 0xff00).unwrap(); // OBJ fields do not affect BG. + assert_eq!(render(&m).pixels(), base.pixels()); + m.write16(MOSAIC, 0x000f).unwrap(); + let f = render(&m); + assert_eq!(at(&f, 15, 1), at(&base, 0, 1)); + assert_ne!(at(&f, 15, 1), at(&base, 0, 0)); +} + +#[test] +fn text_mosaic_samples_before_scroll_and_tile_flips_in_both_depths() { + for eight_bit in [false, true] { + let mut m = memory(); + m.write16(DISPCNT, 0x100).unwrap(); + let control = 0x1000 | if eight_bit { 0x80 } else { 0 }; + m.write16(BG0CNT, control).unwrap(); + m.write16(BG0HOFS, 7).unwrap(); + m.write16(BG0VOFS, 5).unwrap(); + fill(&mut m, VRAM + 0x8000, 0x800, 0x0c00); // Both tile flips. + for y in 0..8 { + for x in (0..8).step_by(if eight_bit { 2 } else { 4 }) { + let mut value = 0; + for i in 0..if eight_bit { 2 } else { 4 } { + let index = ((x + i + y * 3) % 15) as u16; + value |= index << (i * if eight_bit { 8 } else { 4 }); + } + m.write16( + VRAM + (y * if eight_bit { 8 } else { 4 } + x / if eight_bit { 1 } else { 2 }) + as u32, + value, + ) + .unwrap(); + } + } + let base = render(&m); + m.write16(BG0CNT, control | 0x40).unwrap(); + m.write16(MOSAIC, 0x54).unwrap(); // 5x6 crosses tile boundaries. + let f = render(&m); + for y in 0..HEIGHT { + for x in 0..WIDTH { + assert_eq!(at(&f, x, y), at(&base, x / 5 * 5, y / 6 * 6)); + } + } + } +} + +#[test] +fn affine_mosaic_samples_screen_positions_before_signed_transform() { + for mode in [1, 2, 3, 4, 5] { + let mut m = if mode >= 3 { bitmap(mode, 0) } else { memory() }; + m.write16(DISPCNT, 0x400 | mode).unwrap(); + let control = if mode <= 2 { 0x3000 } else { 0 }; + m.write16(BG2CNT, control).unwrap(); + if mode <= 2 { + for offset in (0..64).step_by(2) { + m.write16(VRAM + offset, (offset as u16 * 0x101).wrapping_add(0x100)) + .unwrap(); + } + } + for (reg, value) in [ + (BG2PA, 181), + (BG2PB, 91), + (BG2PC, (-181i16) as u16), + (BG2PD, 256), + ] { + m.write16(reg, value).unwrap(); + } + m.write32(BG2X, (-769i32) as u32).unwrap(); + m.write32(BG2Y, 31 * 256 + 127).unwrap(); + let base = render(&m); + m.write16(BG2CNT, control | 0x40).unwrap(); + m.write16(MOSAIC, 0x76).unwrap(); + let f = render(&m); + for y in 0..HEIGHT { + for x in 0..WIDTH { + assert_eq!( + at(&f, x, y), + at(&base, x / 7 * 7, y / 8 * 8), + "mode={mode} pixel={x},{y}" + ); + } + } + } +} + +#[test] +fn independent_background_flags_preserve_transparency_and_priority() { + let mut m = memory(); + m.write16(DISPCNT, 0x300).unwrap(); + m.write16(BG0CNT, 0x1040).unwrap(); + m.write16(BG1CNT, 0x1105).unwrap(); + m.write16(PAL + 2, 31).unwrap(); + m.write16(PAL + 4, 0x3e0).unwrap(); + fill(&mut m, VRAM, 32, 0x1010); + fill(&mut m, VRAM + 0x4000, 32, 0x2222); + m.write16(MOSAIC, 2).unwrap(); + let f = render(&m); + for x in 0..18 { + assert_eq!(at(&f, x, 0), rgb(if x / 3 % 2 == 0 { 0x3e0 } else { 31 })); + } + m.write16(BG0CNT, 0x1000).unwrap(); + m.write16(BG1CNT, 0x1145).unwrap(); + let f = render(&m); + for x in 0..18 { + assert_eq!(at(&f, x, 0), rgb(if x % 2 == 0 { 0x3e0 } else { 31 })); + } +} + +#[test] +fn windows_and_color_effects_use_output_coordinate_not_mosaic_sample() { + let mut m = bitmap(3, 0); + m.write16(BG2CNT, 0x40).unwrap(); + m.write16(MOSAIC, 0x77).unwrap(); + m.write16(DISPCNT, 0x2403).unwrap(); + m.write16(WIN0H, 0x0306).unwrap(); + m.write16(WIN0V, 0x0008).unwrap(); + m.write16(WININ, 4).unwrap(); + m.write16(WINOUT, 0x24).unwrap(); + m.write16(BLDCNT, 0x84).unwrap(); + m.write16(BLDY, 16).unwrap(); + let f = render(&m); + for x in 0..8 { + assert_eq!(at(&f, x, 0), if (3..6).contains(&x) { 0 } else { 0xffffff }); + } + m.write16(DISPCNT, 0xffff).unwrap(); + assert!(render(&m).pixels().iter().all(|&p| p == 0xffffff)); +} diff --git a/tests/mosaic/demo.rs b/tests/mosaic/demo.rs new file mode 100644 index 0000000..8a9c4ba --- /dev/null +++ b/tests/mosaic/demo.rs @@ -0,0 +1,100 @@ +use super::*; +use gba_rust::{ + input::{Button, Buttons}, + mosaic_demo::{MosaicDemo, MOSAIC_STATE}, + tile_demo::TileDemo, +}; + +#[test] +fn cpu_demo_cycles_all_sizes_and_wraps_without_host_video_writes() { + let mut demo = MosaicDemo::new().unwrap(); + let mut frame = Framebuffer::default(); + assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0); + assert_eq!(demo.machine().memory().read16(VRAM).unwrap(), 0); + for update in 1..=129 { + demo.frame(Buttons::default(), &mut frame).unwrap(); + let m = demo.machine().memory(); + assert_eq!(m.read32(MOSAIC_STATE).unwrap(), update); + assert_eq!(m.read16(DISPCNT).unwrap(), 0x1340); + assert_eq!(m.read16(BG0CNT).unwrap(), 0x1844); + assert_eq!(m.read16(BG1CNT).unwrap(), 0xd0c1); + assert_eq!(m.read16(OAM).unwrap(), 0x1048); + assert_eq!(m.read32(MOSAIC).unwrap(), 0); + assert_eq!(m.display_position().scanline, 160); + let size = (1 + (update / 8) % 16) as usize; + // Check terrain and foreground away from the sprite for every size. + for y in 0..48 { + for x in 0..WIDTH { + let sx = x / size * size; + let sy = y / size * size; + let cross = sx / 8 % 4 == 1 && sy / 8 % 4 == 1 && (sx % 8 == 3 || sy % 8 == 3); + let color = if cross { + 0x3ff + } else { + [0x260, 0x3a0, 0x7d20, 0x7e80] + [((sx / 32 + sy / 32) % 2) * 2 + (sx / 2 + sy / 2) % 2] + }; + assert_eq!(at(&frame, x, y), rgb(color), "update={update} at={x},{y}"); + } + } + } +} + +#[test] +fn both_bypasses_restore_original_scene_with_scroll_rotation_zoom_and_reset() { + let mut demo = MosaicDemo::new().unwrap(); + let mut base = TileDemo::new().unwrap(); + let mut f = Framebuffer::default(); + let mut reference = Framebuffer::default(); + for i in 0..32 { + let buttons = Buttons::default() + .with(Button::Left, i < 16) + .with(Button::Down, true) + .with(Button::Start, i == 31) + .with(Button::L, (8..24).contains(&i)) + .with(Button::R, (16..28).contains(&i)); + base.frame(buttons, &mut reference).unwrap(); + demo.frame(buttons.with(Button::A, true).with(Button::B, true), &mut f) + .unwrap(); + assert_eq!(f.pixels(), reference.pixels()); + let m = demo.machine().memory(); + assert_ne!(m.read16(OAM).unwrap() & 0x1000, 0); + assert_eq!(m.read16(OAM + 2).unwrap() & 0x1000, 0); // Z no longer flips. + assert_eq!(m.read16(OAM + 4).unwrap() & 0xc00, 0); // X no longer lowers priority. + assert_eq!( + m.read32(MOSAIC_STATE + 4).unwrap(), + base.machine().memory().read32(MOSAIC_STATE + 4).unwrap() + ); + } + assert_eq!(demo.machine().memory().read32(MOSAIC_STATE + 4).unwrap(), 0); + assert_eq!(demo.machine().memory().read32(MOSAIC_STATE + 8).unwrap(), 0); +} + +#[test] +fn dma_word_write_sets_bg_and_obj_fields_while_unused_halfword_ignores_writes() { + use gba_rust::{ + cpu::Cpu, + dma::DMA_BASE, + machine::{Machine, StepKind}, + }; + let mut m = memory(); + m.write16(DISPCNT, 0x1140).unwrap(); + m.write16(BG0CNT, 0x1040).unwrap(); + fill(&mut m, VRAM, 32, 0x1212); + m.write16(PAL + 4, 31).unwrap(); + m.write16(PAL + 2, 0x3e0).unwrap(); + object(&mut m, 0, 0x1000, 0, 0); + fill(&mut m, VRAM + 0x10000, 32, 0x0101); + m.write16(PAL + 0x202, 0x7c00).unwrap(); + m.write32(0x0200_0000, 0xffff_0302).unwrap(); // BG3x1, OBJ4x1. + m.write32(DMA_BASE + 36, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 40, MOSAIC).unwrap(); + m.write32(DMA_BASE + 44, 0x8400_0001).unwrap(); + let mut machine = Machine::new(Cpu::new(0x0300_0000), m); + assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 }); + let f = render(machine.memory()); + assert_eq!(at(&f, 3, 0), rgb(0x7c00)); + assert_eq!(at(&f, 8, 0), rgb(31)); // BG samples x6, not x8's independent phase. + assert_eq!(at(&f, 9, 0), rgb(0x3e0)); + assert_eq!(machine.memory().read32(MOSAIC).unwrap(), 0); +} diff --git a/tests/mosaic/objects.rs b/tests/mosaic/objects.rs new file mode 100644 index 0000000..4d51621 --- /dev/null +++ b/tests/mosaic/objects.rs @@ -0,0 +1,215 @@ +use super::*; + +fn scene(a: u16, b: u16) -> Memory { + let mut m = memory(); + m.write16(DISPCNT, 0x1040).unwrap(); + object(&mut m, 0, a | 7, b | 5 | 0x4000, 0); // 16x16 at (5,7). + for y in 0..16 { + for x in (0..16).step_by(2) { + let tile = y / 8 * 2 + x / 8; + let address = VRAM + 0x10000 + tile * 64 + y % 8 * 8 + x % 8; + let first = 1 + (x + y * 5) % 254; + let second = 1 + (x + 1 + y * 5) % 254; + m.write16(address, (first | second << 8) as u16).unwrap(); + } + } + m +} + +#[test] +fn regular_obj_uses_screen_grid_clamps_partial_first_blocks_and_preserves_flips() { + for flips in [0, 0x1000, 0x2000, 0x3000] { + let mut m = scene(0x2000, flips); + let base = render(&m); + m.write16(OAM, 0x3007).unwrap(); // 8bpp plus mosaic, y7. + for width in 1..=16 { + let height = 17 - width; + m.write16(MOSAIC, (((height - 1) << 12) | ((width - 1) << 8)) as u16) + .unwrap(); + let f = render(&m); + for y in 0..32 { + for x in 0..32 { + let expected = if (5..21).contains(&x) && (7..23).contains(&y) { + at( + &base, + (x / width * width).max(5), + (y / height * height).max(7), + ) + } else { + 0 + }; + assert_eq!( + at(&f, x, y), + expected, + "flip={flips:x} size={width}x{height} at={x},{y}" + ); + } + } + } + } +} + +#[test] +fn affine_and_double_size_mosaic_repeat_transformed_samples() { + for doubled in [false, true] { + let mut m = scene(0x2100 | if doubled { 0x200 } else { 0 }, 0); + let matrix: [i16; 4] = if doubled { + [64, 32, -32, 64] + } else { + [128, 64, -64, 128] + }; + for (i, value) in matrix.into_iter().enumerate() { + m.write16(OAM + 6 + i as u32 * 8, value as u16).unwrap(); + } + let base = render(&m); + m.write16(OAM, 0x3107 | if doubled { 0x200 } else { 0 }) + .unwrap(); + m.write16(MOSAIC, 0x4500).unwrap(); // OBJ6x5; BG1x1. + let f = render(&m); + let size = if doubled { 32 } else { 16 }; + for y in 0..48 { + for x in 0..48 { + let expected = if (5..5 + size).contains(&x) && (7..7 + size).contains(&y) { + at(&base, (x / 6 * 6).max(5), (y / 5 * 5).max(7)) + } else { + 0 + }; + assert_eq!(at(&f, x, y), expected, "double={doubled} at={x},{y}"); + } + } + } +} + +#[test] +fn obj_flag_and_high_byte_are_independent_and_vertical_sampling_wraps_at_screen_top() { + let mut m = scene(0x2000, 0); + let base = render(&m); + m.write16(MOSAIC, 0xffff).unwrap(); + assert_eq!(render(&m).pixels(), base.pixels()); // Flag disabled. + m.write16(OAM, 0x3007).unwrap(); + m.write8(MOSAIC + 1, 0).unwrap(); // BG15 bits do not affect OBJ. + assert_eq!(render(&m).pixels(), base.pixels()); + object(&mut m, 0, 0x20fc, 0x41fc, 0); // 16x16, wraps from (-4,-4). + let base = render(&m); + m.write16(OAM, 0x30fc).unwrap(); + m.write16(MOSAIC, 0x3400).unwrap(); + let f = render(&m); + for y in 0..12 { + for x in 0..12 { + assert_eq!(at(&f, x, y), at(&base, x / 5 * 5, y / 4 * 4)); + } + } + assert_eq!(at(&f, 12, 0), 0); + assert_eq!(at(&f, 0, 12), 0); +} + +#[test] +fn transparent_samples_repeat_but_non_mosaic_samples_restart_the_latch() { + let mut m = memory(); + m.write16(DISPCNT, 0x1040).unwrap(); + m.write16(MOSAIC, 0x0300).unwrap(); // OBJ4x1. + m.write16(PAL + 0x202, 31).unwrap(); + fill(&mut m, VRAM + 0x10000, 32, 0x1010); // First sample transparent, second red. + object(&mut m, 0, 0x1000, 0, 0); + assert!(render(&m).pixels().iter().all(|&p| p == 0)); + m.write16(VRAM + 0x10000, 0x0101).unwrap(); + let f = render(&m); + for x in 0..4 { + assert_eq!(at(&f, x, 0), rgb(31)); + } + // A lower-priority non-mosaic sprite fills transparent texels before mosaic. + // Its non-mosaic metadata makes those pixels latch immediately. + fill(&mut m, VRAM + 0x10020, 32, 0x2222); + m.write16(PAL + 0x204, 0x3e0).unwrap(); + object(&mut m, 1, 0, 0, 0x401); + let f = render(&m); + assert_eq!(at(&f, 0, 0), rgb(31)); + assert_eq!(at(&f, 1, 0), rgb(0x3e0)); +} + +#[test] +fn horizontal_latch_restarts_for_non_mosaic_and_higher_priority_pixels() { + let mut m = memory(); + m.write16(DISPCNT, 0x1040).unwrap(); + m.write16(MOSAIC, 0x0f00).unwrap(); + for (tile, color) in [(0, 31), (1, 0x3e0), (2, 0x7c00)] { + fill( + &mut m, + VRAM + 0x10000 + tile * 32, + 32, + (tile as u16 + 1) * 0x1111, + ); + m.write16(PAL + 0x202 + tile * 2, color).unwrap(); + } + object(&mut m, 0, 0x1000, 1, 0x800); + object(&mut m, 1, 0x1000, 3, 0x401); + object(&mut m, 2, 0, 5, 2); + let f = render(&m); + assert_eq!(at(&f, 0, 0), 0); + assert_eq!(at(&f, 1, 0), rgb(31)); + assert_eq!(at(&f, 3, 0), rgb(0x3e0)); + assert_eq!(at(&f, 5, 0), rgb(0x7c00)); + assert_eq!(at(&f, 13, 0), 0); // Leaving the canvas resets the latch. +} + +#[test] +fn transparent_higher_priority_obj_changes_metadata_without_replacing_color() { + let mut m = memory(); + m.write16(DISPCNT, 0x1140).unwrap(); + m.write16(BG0CNT, 0x1001).unwrap(); // BG priority1. + fill(&mut m, VRAM, 32, 0x3333); + m.write16(PAL + 6, 0x7c00).unwrap(); + fill(&mut m, VRAM + 0x10000, 32, 0x1111); + m.write16(PAL + 0x202, 31).unwrap(); + object(&mut m, 0, 0x400, 0, 0x800); // Semi-transparent red, priority2. + object(&mut m, 1, 0x1000, 0, 0x401); // Transparent tile1, priority1, mosaic. + fill(&mut m, VRAM + 0x10040, 32, 0x2222); + object(&mut m, 2, 0, 0, 0x402); // Same priority as metadata; cannot replace red. + m.write16(MOSAIC, 0x0300).unwrap(); + m.write16(BLDCNT, 0x100).unwrap(); + m.write16(BLDALPHA, 0x0808).unwrap(); + assert_eq!(at(&render(&m), 0, 0), rgb(0x3c0f)); // Alpha flag retained, priority now ties BG. +} + +#[test] +fn object_window_ignores_mosaic_and_window_edges_do_not_quantize() { + let mut m = memory(); + m.write16(DISPCNT, 0x9100).unwrap(); + m.write16(BG0CNT, 0x1000).unwrap(); + fill(&mut m, VRAM, 32, 0x1111); + m.write16(PAL + 2, 31).unwrap(); + fill(&mut m, VRAM + 0x10000, 32, 0x1010); + object(&mut m, 0, 0x1800, 0, 0); + m.write16(MOSAIC, 0xffff).unwrap(); + m.write16(WINOUT, 0x0100).unwrap(); + let f = render(&m); + for x in 0..8 { + assert_eq!(at(&f, x, 0), if x % 2 == 0 { 0 } else { rgb(31) }); + } + m.write16(VRAM + 0x10004, 0x0101).unwrap(); // Next source row remains distinct. + assert_eq!(at(&render(&m), 0, 1), rgb(31)); +} + +#[test] +fn mosaic_latch_precedes_window_masking_and_semi_transparent_blending() { + let mut m = memory(); + m.write16(DISPCNT, 0x3100).unwrap(); + m.write16(BG0CNT, 0x1000).unwrap(); + fill(&mut m, VRAM, 32, 0x2222); + m.write16(PAL + 4, 0x7c00).unwrap(); + m.write16(PAL + 0x202, 31).unwrap(); + fill(&mut m, VRAM + 0x10000, 32, 0x0101); + object(&mut m, 0, 0x1400, 0, 0); + m.write16(MOSAIC, 0x0300).unwrap(); + m.write16(WIN0H, 0x0001).unwrap(); + m.write16(WIN0V, 0x0008).unwrap(); + m.write16(WININ, 1).unwrap(); // First pixel masks OBJ but must not reset its latch. + m.write16(WINOUT, 0x31).unwrap(); + m.write16(BLDCNT, 0x100).unwrap(); + m.write16(BLDALPHA, 0x0808).unwrap(); + let f = render(&m); + assert_eq!(at(&f, 0, 0), rgb(0x7c00)); + for x in 1..4 { + assert_eq!(at(&f, x, 0), rgb(0x3c0f)); + } +} diff --git a/tests/mosaic_tracking.rs b/tests/mosaic_tracking.rs new file mode 100644 index 0000000..cbad859 --- /dev/null +++ b/tests/mosaic_tracking.rs @@ -0,0 +1,351 @@ +use gba_rust::{ + cpu::Cpu, + display::{ + CYCLES_PER_FRAME as FRAME, CYCLES_PER_LINE as LINE, HBLANK_START as HB, 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, HEIGHT, WIDTH}, +}; + +fn color(row: usize) -> u16 { + ((row % 64 + 1) * 0x101) as u16 +} + +fn scene() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m.write16(DISPCNT, 0x1140).unwrap(); + m.write16(BG0CNT, 0x10c0).unwrap(); // 8bpp, mosaic, map block16. + for index in 0..64 { + m.write16(PAL + (index + 1) * 2, color(index as usize)) + .unwrap(); + m.write16(PAL + 0x200 + (index + 1) * 2, color(index as usize)) + .unwrap(); + } + // Eight text tiles, each with a distinct color per row. The map repeats vertically. + for y in 0..64 { + for x in (0..8).step_by(2) { + m.write16(VRAM + y * 8 + x, (y as u16 + 1) * 0x101).unwrap(); + } + } + for y in 0..32 { + for x in 0..32 { + m.write16(VRAM + 0x8000 + (y * 32 + x) * 2, (y % 8) as u16) + .unwrap(); + } + } + for index in 0..128 { + m.write16(OAM + index * 8, 0x200).unwrap(); + } + m.write16(OAM, 0x3000).unwrap(); // Mosaic 8bpp sprite at (32,0), 16x16. + m.write16(OAM + 2, 0x4020).unwrap(); + for y in 0..16 { + for x in (0..16).step_by(2) { + let tile = y / 8 * 2 + x / 8; + m.write16( + VRAM + 0x10000 + tile * 64 + y % 8 * 8 + x % 8, + (y as u16 + 1) * 0x101, + ) + .unwrap(); + } + } + m +} + +fn bitmap() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m.write16(DISPCNT, 0x403).unwrap(); + m.write16(BG2CNT, 0x40).unwrap(); + m.write16(BG2PA, 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(y as usize)) + .unwrap(); + } + } + m +} + +fn present(m: &Memory) -> Framebuffer { + let mut f = Framebuffer::default(); + assert!(m.present_frame(&mut f).unwrap()); + f +} + +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 at(f: &Framebuffer, x: usize, y: usize) -> u32 { + f.pixels()[y * WIDTH + x] +} + +#[test] +fn all_static_heights_match_snapshots_with_independent_background_and_sprite_sizes() { + for height in 1..=16 { + let mut m = scene(); + m.write16(MOSAIC, ((height - 1) << 4) | ((16 - height) << 12)) + .unwrap(); + let mut expected = Framebuffer::default(); + m.render_frame(&mut expected).unwrap(); + assert_eq!( + finish(&mut m).pixels(), + expected.pixels(), + "height={height}" + ); + } +} + +#[test] +fn growing_a_block_preserves_counter_instead_of_realigning_to_screen_y() { + let mut m = scene(); + m.write16(MOSAIC, 0x30).unwrap(); // BG4, OBJ1. + m.advance_cycles(5 * LINE + HB); + m.write8(MOSAIC, 0x70).unwrap(); // BG8: phase1 remains phase1. + let f = finish(&mut m); + for y in 0..24 { + let source = if y < 4 { + 0 + } else if y < 12 { + 4 + } else if y < 20 { + 12 + } else { + 20 + }; + assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}"); + if y < 16 { + assert_eq!(at(&f, 32, y), rgb(color(y))); + } + } + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + assert_eq!(at(&snapshot, 0, 8), rgb(color(8))); + assert_eq!(at(&f, 0, 8), rgb(color(4))); + assert_eq!(present(&m).pixels(), f.pixels()); +} + +#[test] +fn shrinking_below_current_phase_waits_for_four_bit_wrap() { + let mut m = scene(); + m.write16(MOSAIC, 0x70).unwrap(); + m.advance_cycles(6 * LINE); + m.write8(MOSAIC, 0x10).unwrap(); // Phase6 > new height2; no immediate reset. + m.advance_cycles(0); + let f = finish(&mut m); + for y in 0..24 { + let source = if y < 16 { 0 } else { y / 2 * 2 }; + assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}"); + } +} + +#[test] +fn affine_mosaic_advances_by_current_height_when_counter_wraps_not_by_elapsed_rows() { + let mut m = bitmap(); + m.write16(MOSAIC, 0x70).unwrap(); + m.advance_cycles(6 * LINE + HB); + m.write8(MOSAIC, 0x10).unwrap(); + let f = finish(&mut m); + for y in 0..24 { + let source = if y < 16 { 0 } else { 2 + (y - 16) / 2 * 2 }; + assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}"); + } +} + +#[test] +fn affine_reference_writes_and_coefficients_do_not_reset_shared_mosaic_phase() { + let mut m = bitmap(); + m.write16(MOSAIC, 0x70).unwrap(); + m.advance_cycles(5 * LINE); + m.write32(BG2Y, 20 * 256).unwrap(); + m.write16(BG2PD, 2 * 256).unwrap(); + m.write8(MOSAIC, 0x10).unwrap(); + let f = finish(&mut m); + for y in 0..20 { + let source = if y < 5 { + 0 + } else if y < 16 { + 20 + } else { + 24 + (y - 16) / 2 * 4 + }; + assert_eq!(at(&f, 0, y), rgb(color(source)), "row={y}"); + } +} + +#[test] +fn mosaic_flags_and_display_enable_do_not_gate_counters() { + for disabled in [false, true] { + let mut m = scene(); + m.write16(MOSAIC, 0x7070).unwrap(); + m.write16(BG0CNT, 0x1080).unwrap(); + m.write16(OAM, 0x2000).unwrap(); + if disabled { + m.write16(DISPCNT, 0).unwrap(); + } + m.advance_cycles(6 * LINE); + m.write16(MOSAIC, 0x1010).unwrap(); + m.write16(BG0CNT, 0x10c0).unwrap(); + m.write16(OAM, 0x3000).unwrap(); + m.write16(DISPCNT, 0x1140).unwrap(); + let f = finish(&mut m); + for y in 6..16 { + assert_eq!(at(&f, 0, y), rgb(color(0))); + // Row6 was prepared on row5, before the attribute/enable write. + let source = if y == 6 && !disabled { 6 } else { 0 }; + assert_eq!(at(&f, 32, y), rgb(color(source))); + } + } +} + +#[test] +fn forced_blank_does_not_pause_counters() { + let mut m = scene(); + m.write16(MOSAIC, 0x7070).unwrap(); + m.write16(DISPCNT, 0x11c0).unwrap(); + m.advance_cycles(6 * LINE); + m.write16(MOSAIC, 0x1010).unwrap(); + m.write16(DISPCNT, 0x1140).unwrap(); + let f = finish(&mut m); + assert!(f.pixels()[..6 * WIDTH].iter().all(|&p| p == 0xffffff)); + for y in 6..16 { + assert_eq!(at(&f, 0, y), rgb(color(0))); + } +} + +#[test] +fn sprite_counter_is_independent_and_clamps_partial_first_blocks_before_transform() { + for affine in [false, true] { + let mut m = scene(); + m.write16(OAM, if affine { 0x3103 } else { 0x3003 }) + .unwrap(); // y3. + if affine { + m.write16(OAM + 6, 256).unwrap(); + m.write16(OAM + 30, 256).unwrap(); + } + m.write16(MOSAIC, 0x7030).unwrap(); // BG4, OBJ8. + m.advance_cycles(6 * LINE); + m.write8(MOSAIC + 1, 0x10).unwrap(); // OBJ2 only. + let f = finish(&mut m); + for y in 0..24 { + assert_eq!(at(&f, 0, y), rgb(color(y / 4 * 4))); + let source = if !(3..19).contains(&y) { + y / 4 * 4 + } else if y < 16 { + 0 + } else if y < 18 { + 13 + } else { + 15 + }; + assert_eq!(at(&f, 32, y), rgb(color(source)), "affine={affine} row={y}"); + } + } +} + +#[test] +fn object_window_ignores_live_mosaic_phase() { + let mut m = scene(); + m.write16(DISPCNT, 0x9140).unwrap(); + m.write16(WINOUT, 0x0100).unwrap(); // Show BG only inside OBJ window. + m.write16(BG0CNT, 0x1080).unwrap(); + m.write16(OAM, 0x3800).unwrap(); // Mosaic-enabled window sprite. + for y in (1..16).step_by(2) { + for x in (0..16).step_by(2) { + let tile = y / 8 * 2 + x / 8; + m.write16(VRAM + 0x10000 + tile * 64 + y % 8 * 8 + x % 8, 0) + .unwrap(); + } + } + m.write16(MOSAIC, 0x7000).unwrap(); + m.advance_cycles(6 * LINE); + m.write16(MOSAIC, 0x1000).unwrap(); + let f = finish(&mut m); + for y in 0..24 { + assert_eq!( + at(&f, 32, y), + if y < 16 && y % 2 == 0 { + rgb(color(y)) + } else { + 0 + } + ); + } +} + +#[test] +fn vertical_hold_uses_live_palette_but_sprite_vram_changes_wait_for_preparation() { + let mut m = scene(); + m.write16(MOSAIC, 0x7070).unwrap(); + m.advance_cycles(3 * LINE + HB); + m.write16(PAL + 2, 31).unwrap(); + m.write16(VRAM + 0x10000, 0x0202).unwrap(); + let f = finish(&mut m); + assert_eq!(at(&f, 0, 3), rgb(color(0))); + assert_eq!(at(&f, 0, 4), rgb(31)); + assert_eq!(at(&f, 32, 3), rgb(color(0))); + assert_eq!(at(&f, 32, 4), rgb(color(0))); // Prepared before row3 HBlank. + assert_eq!(at(&f, 32, 5), rgb(color(1))); +} + +#[test] +fn hblank_dma_size_writes_preserve_phase_and_affect_next_line_transition() { + let mut m = scene(); + m.write16(MOSAIC, 0x7070).unwrap(); + for y in 0..160 { + m.write16(0x0200_0000 + y * 2, if y < 6 { 0x7070 } else { 0x3010 }) + .unwrap(); + } + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, MOSAIC).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()); + // Independent recurrence, driven by the table value written after each row. + let (mut bg, mut obj) = (0usize, 0usize); + for y in 0..HEIGHT { + assert_eq!(at(&f, 0, y), rgb(color(y - bg)), "row={y}"); + if y < 16 { + assert_eq!(at(&f, 32, y), rgb(color(y - obj))); + } + let (bg_height, obj_height) = if y < 6 { (8, 8) } else { (2, 4) }; + bg = if bg + 1 == bg_height { + 0 + } else { + (bg + 1) % 16 + }; + obj = if obj + 1 == obj_height { + 0 + } else { + (obj + 1) % 16 + }; + } + assert!(machine.halted()); +} + +#[test] +fn vblank_resets_counters_and_capture_toggles_do_not_reset_them() { + let mut m = scene(); + m.set_scanline_rendering(false); + m.write16(MOSAIC, 0x7070).unwrap(); + m.advance_cycles(6 * LINE); + m.write16(MOSAIC, 0x1010).unwrap(); + m.advance_cycles(VB - 6 * LINE); + m.write16(MOSAIC, 0x5050).unwrap(); // New size during VBlank. + m.advance_cycles(FRAME - VB); + m.set_scanline_rendering(true); + let f = finish(&mut m); + for y in 0..16 { + assert_eq!(at(&f, 0, y), rgb(color(y / 6 * 6))); + assert_eq!(at(&f, 32, y), rgb(color(y / 6 * 6))); + } +}