From 9d14dd7cf4022d2db11209eab4d2d328024c1f1e Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 5 Oct 2026 12:55:35 +0700 Subject: [PATCH] feat(video): track windows and compose color effects Add persistent window comparators, region masks, alpha blending, and brightness effects. --- src/video/effects.rs | 95 ++++++++++++ src/video/windows.rs | 133 ++++++++++++++++ src/video/windows/horizontal.rs | 168 ++++++++++++++++++++ tests/effects.rs | 61 ++++++++ tests/effects/colors.rs | 172 +++++++++++++++++++++ tests/effects/demo.rs | 166 ++++++++++++++++++++ tests/effects/windows.rs | 176 +++++++++++++++++++++ tests/window_tracking.rs | 228 ++++++++++++++++++++++++++++ tests/window_tracking/horizontal.rs | 209 +++++++++++++++++++++++++ 9 files changed, 1408 insertions(+) create mode 100644 src/video/effects.rs create mode 100644 src/video/windows.rs create mode 100644 src/video/windows/horizontal.rs create mode 100644 tests/effects.rs create mode 100644 tests/effects/colors.rs create mode 100644 tests/effects/demo.rs create mode 100644 tests/effects/windows.rs create mode 100644 tests/window_tracking.rs create mode 100644 tests/window_tracking/horizontal.rs diff --git a/src/video/effects.rs b/src/video/effects.rs new file mode 100644 index 0000000..8731040 --- /dev/null +++ b/src/video/effects.rs @@ -0,0 +1,95 @@ +//! Window masks and five-bit color arithmetic for snapshots and row capture. +//! Snapshots use half-open, wrapping bounds. Row capture supplies vertical flags +//! and horizontal comparator history. Layer/effect selection remains row-sampled. + +use super::Pixel; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Effects { + pub horizontal: [u16; 2], + pub vertical: [u16; 2], + pub inside: u16, + pub outside: u16, + pub control: u16, + pub alpha: u16, + pub brightness: u16, +} + +impl Effects { + pub(super) fn mask( + &self, + display: u16, + x: usize, + y: usize, + object: bool, + vertical: Option<[bool; 2]>, + horizontal: Option<&super::windows::horizontal::HorizontalWindows>, + ) -> u16 { + if display & 0xe000 == 0 { + return 0x3f; + } + for window in 0..2 { + if display & (0x2000 << window) != 0 + && horizontal.map_or_else( + || contains(self.horizontal[window], x), + |state| state.pixels[window][x], + ) + && vertical + .map_or_else(|| contains(self.vertical[window], y), |flags| flags[window]) + { + return (self.inside >> (window * 8)) & 0x3f; + } + } + if display & 0x9000 == 0x9000 && object { + (self.outside >> 8) & 0x3f + } else { + self.outside & 0x3f + } + } + + pub(super) fn apply(&self, top: Pixel, below: Option, enabled: bool) -> u16 { + if !enabled { + return top.color; + } + let mode = (self.control >> 6) & 3; + let first = self.control & (1 << top.layer) != 0; + if top.semi_transparent || (mode == 1 && first) { + if let Some(second) = below.filter(|p| self.control & (0x100 << p.layer) != 0) { + let a = (self.alpha & 31).min(16); + let b = ((self.alpha >> 8) & 31).min(16); + return channels(top.color, second.color, |x, y| { + ((x * a + y * b) >> 4).min(31) + }); + } + } + // Semi-transparent OBJ falls back to a selected brightness effect only + // when no eligible second target is directly below it. + if first && mode >= 2 { + let coefficient = (self.brightness & 31).min(16); + return channels(top.color, 0, |x, _| { + if mode == 2 { + x + (((31 - x) * coefficient) >> 4) + } else { + x - ((x * coefficient) >> 4) + } + }); + } + top.color + } +} + +fn contains(bounds: u16, coordinate: usize) -> bool { + let start = usize::from(bounds >> 8); + let end = usize::from(bounds & 255); + if start > end { + coordinate >= start || coordinate < end + } else { + (start..end).contains(&coordinate) + } +} + +fn channels(first: u16, second: u16, operation: impl Fn(u16, u16) -> u16) -> u16 { + [0, 5, 10].into_iter().fold(0, |color, shift| { + color | (operation((first >> shift) & 31, (second >> shift) & 31) << shift) + }) +} diff --git a/src/video/windows.rs b/src/video/windows.rs new file mode 100644 index 0000000..1e4b193 --- /dev/null +++ b/src/video/windows.rs @@ -0,0 +1,133 @@ +//! Vertical WIN0/WIN1 comparators. State survives frame wrap and register writes. +//! Horizontal comparators retain per-column history separately from vertical state. + +pub(crate) mod horizontal; + +use crate::display::{CYCLES_PER_LINE, LINES_PER_FRAME}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WindowEdges { + pub active: [bool; 2], + started: bool, +} + +impl WindowEdges { + /// Evaluate each crossed line start, including hidden lines. With fixed bounds, + /// only the latest matching edge matters, so large clock batches remain O(1). + pub fn advance(&mut self, phase: u32, cycles: u32, bounds: [u16; 2]) { + if cycles == 0 { + return; + } + if !self.started { + // Allow host setup before the first clock advance at reset's line zero. + // Later writes at an already-entered line never replay its comparator. + let row = phase / CYCLES_PER_LINE; + for (window, bounds) in bounds.into_iter().enumerate() { + if row == u32::from(bounds >> 8) { + self.active[window] = true; + } + if row == u32::from(bounds & 255) { + self.active[window] = false; + } + } + self.started = true; + } + let first = u64::from(phase / CYCLES_PER_LINE) + 1; + let last = (u64::from(phase) + u64::from(cycles)) / u64::from(CYCLES_PER_LINE); + let period = u64::from(LINES_PER_FRAME); + let latest = |row: u16| { + if u32::from(row) >= LINES_PER_FRAME { + return None; // VCOUNT never reaches values 228..255. + } + let distance = (last + period - u64::from(row)) % period; + last.checked_sub(distance).filter(|&edge| edge >= first) + }; + for (window, bounds) in bounds.into_iter().enumerate() { + let top = latest(bounds >> 8); + let bottom = latest(bounds & 255); + match (top, bottom) { + (Some(top), Some(bottom)) => self.active[window] = top > bottom, + (Some(_), None) => self.active[window] = true, + (None, Some(_)) => self.active[window] = false, + (None, None) => {} // No edge: retain state, even across frames. + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::CYCLES_PER_FRAME; + + fn step(active: &mut bool, bounds: u16, row: u32) { + if row == u32::from(bounds >> 8) { + *active = true; + } + if row == u32::from(bounds & 255) { + *active = false; + } + } + + #[test] + fn all_bounds_match_independent_edge_iteration_across_two_frames() { + for bounds in 0..=u16::MAX { + let mut edges = WindowEdges::default(); + let mut expected = false; + for row in 0..=LINES_PER_FRAME * 2 { + step(&mut expected, bounds, row % LINES_PER_FRAME); + } + edges.advance(0, 2 * CYCLES_PER_FRAME, [bounds; 2]); + assert_eq!(edges.active, [expected; 2], "bounds={bounds:#06x}"); + } + } + + #[test] + fn random_batches_and_extreme_batch_match_independent_edge_iteration() { + let mut seed = 0x60c3_273bu32; + for case in 0..500 { + let mut next = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + seed + }; + let bounds = [next() as u16, next() as u16]; + let phase = next() % CYCLES_PER_FRAME; + let cycles = if case == 0 { + u32::MAX + } else { + next() % (7 * CYCLES_PER_FRAME) + }; + let mut edges = WindowEdges { + active: [next() & 4 != 0, next() & 8 != 0], + started: true, + }; + let mut expected = edges.active; + for boundary in u64::from(phase / CYCLES_PER_LINE) + 1 + ..=(u64::from(phase) + u64::from(cycles)) / u64::from(CYCLES_PER_LINE) + { + for window in 0..2 { + step( + &mut expected[window], + bounds[window], + (boundary % u64::from(LINES_PER_FRAME)) as u32, + ); + } + } + edges.advance(phase, cycles, bounds); + assert_eq!(edges.active, expected, "phase={phase} cycles={cycles}"); + } + } + + #[test] + fn zero_cycles_and_writes_do_not_replay_an_entered_line() { + let mut edges = WindowEdges::default(); + edges.advance(0, 0, [0x0010, 0x0020]); + assert!(!edges.started); + edges.advance(0, 1, [0x0010, 0x0020]); + assert_eq!(edges.active, [true; 2]); + edges.advance(1, CYCLES_PER_LINE - 1, [0x0110, 0x0101]); + assert_eq!(edges.active, [true, false]); // Bottom wins when edges coincide. + edges.advance(CYCLES_PER_LINE, 1, [0x0101, 0x0110]); + assert_eq!(edges.active, [true, false]); // The row1 comparator already ran. + } +} diff --git a/src/video/windows/horizontal.rs b/src/video/windows/horizontal.rs new file mode 100644 index 0000000..5558194 --- /dev/null +++ b/src/video/windows/horizontal.rs @@ -0,0 +1,168 @@ +//! Horizontal comparator history only, not pixel-fetch or color-register timing. +//! Sample X=0..255 at line cycles 0,4,..1020; right-edge matches clear after left. + +use crate::{display::CYCLES_PER_LINE, video::WIDTH}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HorizontalWindows { + active: [bool; 2], + pub pixels: [[bool; WIDTH]; 2], +} + +impl Default for HorizontalWindows { + fn default() -> Self { + Self { + active: [false; 2], + pixels: [[false; WIDTH]; 2], + } + } +} + +impl HorizontalWindows { + /// Consume the half-open clock interval [phase, phase+cycles). A write at + /// cycle 4*x therefore precedes X's comparison; a write at 4*x+1 is too late. + /// Process at most three line segments, even across arbitrarily many frames. + pub fn advance(&mut self, phase: u32, cycles: u32, bounds: [u16; 2]) { + if cycles == 0 { + return; + } + let start = phase % CYCLES_PER_LINE; + let end = u64::from(start) + u64::from(cycles); + let last_line = (end - 1) / u64::from(CYCLES_PER_LINE); + if last_line == 0 { + self.segment(start, end as u32, bounds); + return; + } + self.segment(start, CYCLES_PER_LINE, bounds); + if last_line > 1 { + if last_line > 2 { + // Two or more full intervening lines: both edges have fired, + // so the last full line starts in the stable wrapping state. + self.active = bounds.map(|b| (b >> 8) > (b & 255)); + } + // Retain unvisited pixels from the previous line, just as repeated + // single-cycle advancement does. Never clear history on line entry. + self.segment(0, CYCLES_PER_LINE, bounds); + } + let tail = ((end - 1) % u64::from(CYCLES_PER_LINE) + 1) as u32; + self.segment(0, tail, bounds); + } + + fn segment(&mut self, start: u32, end: u32, bounds: [u16; 2]) { + let first = start.div_ceil(4).min(256) as usize; + let last = end.div_ceil(4).min(256) as usize; + if first == last { + return; + } + for (window, bounds) in bounds.into_iter().enumerate() { + let left = usize::from(bounds >> 8); + let right = usize::from(bounds & 255); + let edges = if left <= right { + [(left, true), (right, false)] + } else { + [(right, false), (left, true)] + }; + let mut from = first; + for (edge, state) in edges { + if (first..last).contains(&edge) { + self.pixels[window][from.min(WIDTH)..edge.min(WIDTH)].fill(self.active[window]); + self.active[window] = state; + from = edge; + } + } + self.pixels[window][from.min(WIDTH)..last.min(WIDTH)].fill(self.active[window]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reference(state: &mut HorizontalWindows, start: u32, cycles: u32, bounds: [u16; 2]) { + // Independent dot iteration, including offscreen columns and hidden lines. + let end = u64::from(start) + u64::from(cycles); + let mut tick = u64::from(start.div_ceil(4)) * 4; + while tick < end { + let x = (tick % u64::from(CYCLES_PER_LINE)) / 4; + if x < 256 { + for (window, bounds) in bounds.into_iter().enumerate() { + if x == u64::from(bounds >> 8) { + state.active[window] = true; + } + if x == u64::from(bounds & 255) { + state.active[window] = false; + } + if x < WIDTH as u64 { + state.pixels[window][x as usize] = state.active[window]; + } + } + } + tick += 4; + } + } + + #[test] + fn all_bounds_match_dot_iteration_for_startup_and_wrapped_next_line() { + for bounds in 0..=u16::MAX { + let mut actual = HorizontalWindows::default(); + let mut expected = actual.clone(); + for phase in [0, CYCLES_PER_LINE] { + actual.advance(phase, CYCLES_PER_LINE, [bounds; 2]); + reference(&mut expected, phase, CYCLES_PER_LINE, [bounds; 2]); + assert_eq!(actual, expected, "bounds={bounds:#x} phase={phase}"); + } + } + } + + #[test] + fn arbitrary_partial_lines_and_bound_writes_match_dot_iteration() { + let mut seed = 0x371b_c321u32; + let mut next = || { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + seed + }; + let mut actual = HorizontalWindows::default(); + let mut expected = actual.clone(); + let mut phase = 0; + for _ in 0..2000 { + let bounds = [next() as u16, next() as u16]; + let cycles = next() % (9 * CYCLES_PER_LINE); + actual.advance(phase, cycles, bounds); + reference(&mut expected, phase, cycles, bounds); + assert_eq!( + actual, expected, + "phase={phase} cycles={cycles} bounds={bounds:?}" + ); + phase = (phase + cycles) % (228 * CYCLES_PER_LINE); + } + } + + #[test] + fn largest_batch_has_same_state_as_short_batch_with_same_final_phase() { + for start in [0, 1, 79, 955, 1006, 1021, 1231] { + let mut actual = HorizontalWindows::default(); + let mut expected = actual.clone(); + let cycles = u32::MAX; + // After several complete fixed-bound lines all earlier history is replaced. + let short = 4 * CYCLES_PER_LINE + cycles % CYCLES_PER_LINE; + actual.advance(start, cycles, [0xf00a, 0x1428]); + reference(&mut expected, start, short, [0xf00a, 0x1428]); + assert_eq!(actual, expected); + } + } + + #[test] + fn zero_cycles_do_not_sample_or_replay_edges() { + let mut state = HorizontalWindows::default(); + state.advance(0, 0, [0x0010; 2]); + assert_eq!(state, HorizontalWindows::default()); + state.advance(0, 1, [0x0010; 2]); + assert!(state.active[0] && state.pixels[0][0]); + let before = state.clone(); + state.advance(1, 0, [0; 2]); + assert_eq!(state, before); + state.advance(1, 3, [0; 2]); + assert_eq!(state, before); // X0 already passed, X1 has not started. + } +} diff --git a/tests/effects.rs b/tests/effects.rs new file mode 100644 index 0000000..b146b43 --- /dev/null +++ b/tests/effects.rs @@ -0,0 +1,61 @@ +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 = "effects/colors.rs"] +mod colors; +#[path = "effects/demo.rs"] +mod demo; +#[path = "effects/windows.rs"] +mod windows; + +fn setup() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.write16(DISPCNT, 0x700).unwrap(); + m.write16(PAL, 0x4210).unwrap(); + for (bg, color) in [0x001f, 0x03e0, 0x7c00].into_iter().enumerate() { + let bg = bg as u32; + m.write16(PAL + (bg + 1) * 2, color).unwrap(); + m.write16(BG0CNT + bg * 2, (((24 + bg) << 8) | (bg << 2) | bg) as u16) + .unwrap(); + for offset in (0..32).step_by(2) { + m.write16(VRAM + bg * 0x4000 + offset, (bg as u16 + 1) * 0x1111) + .unwrap(); + } + } + for obj in 0..128 { + m.write16(OAM + obj * 8, 0x200).unwrap(); + } + for offset in (0..32).step_by(2) { + m.write16(VRAM + 0x10000 + offset, 0x1111).unwrap(); + } + m.write16(PAL + 0x202, 0x7fff).unwrap(); + m +} + +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] +} + +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 window(m: &mut Memory, h: u16, v: u16, inside: u16, outside: u16) { + m.write16(DISPCNT, m.read16(DISPCNT).unwrap() | 0x2000) + .unwrap(); + m.write16(WIN0H, h).unwrap(); + m.write16(WIN0V, v).unwrap(); + m.write16(WININ, inside).unwrap(); + m.write16(WINOUT, outside).unwrap(); +} diff --git a/tests/effects/colors.rs b/tests/effects/colors.rs new file mode 100644 index 0000000..1f410d9 --- /dev/null +++ b/tests/effects/colors.rs @@ -0,0 +1,172 @@ +use super::*; + +#[test] +fn alpha_uses_five_bit_channels_clamps_coefficients_and_saturates_sums() { + let mut m = setup(); + m.write16(BLDCNT, 0x0241).unwrap(); // BG0 with BG1. + for (coefficients, expected) in [ + (0, 0), + (0x0808, 0x01ef), + (0x0010, 31), + (0x1000, 0x3e0), + (0x1f1f, 0x3ff), + ] { + m.write16(BLDALPHA, coefficients).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(expected)); + } + m.write16(PAL + 2, 0x7fff).unwrap(); + m.write16(PAL + 4, 0x7fff).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); + m.write16(PAL + 2, 1).unwrap(); + m.write16(PAL + 4, 1).unwrap(); + m.write16(BLDALPHA, 0x0808).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(1)); // Sum fractional terms before rounding. +} + +#[test] +fn alpha_requires_top_first_target_and_immediate_second_target() { + let mut m = setup(); + m.write16(BLDALPHA, 0x0808).unwrap(); + for control in [0x0441, 0x0242, 0x0142, 0x2041, 0x0240] { + m.write16(BLDCNT, control).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(31), "control={control:x}"); + } + m.write16(BLDCNT, 0x0441).unwrap(); + m.write16(VRAM + 0x4000, 0).unwrap(); // Transparent BG1 exposes BG2. + assert_eq!(pixel(&m, 0, 0), rgb(0x3c0f)); + assert_eq!(pixel(&m, 4, 0), rgb(31)); +} + +#[test] +fn windows_remove_layers_before_selecting_blend_targets() { + let mut m = setup(); + m.write16(BLDALPHA, 0x0808).unwrap(); + m.write16(BLDCNT, 0x0441).unwrap(); + window(&mut m, 0x0008, 0x0008, 0x25, 0x3f); // Hide BG1 only inside. + assert_eq!(pixel(&m, 0, 0), rgb(0x3c0f)); + assert_eq!(pixel(&m, 8, 0), rgb(31)); +} + +#[test] +fn brightness_uses_selected_top_layer_and_clamped_write_only_coefficient() { + let mut m = setup(); + m.write16(PAL + 2, 0x4210).unwrap(); + for (control, coefficient, expected) in [ + (0x81, 0, 0x4210), + (0x81, 8, 0x5ef7), + (0x81, 31, 0x7fff), + (0xc1, 8, 0x2108), + (0xc1, 16, 0), + (0xc1, 31, 0), + (0x82, 16, 0x4210), + (0x01, 16, 0x4210), + ] { + m.write16(BLDCNT, control).unwrap(); + m.write32(BLDY, coefficient).unwrap(); + assert_eq!(m.read16(BLDY).unwrap(), 0); + assert_eq!(pixel(&m, 0, 0), rgb(expected)); + } + m.write16(BLDCNT, 0x81).unwrap(); + m.write8(BLDY, 8).unwrap(); + m.write8(BLDY + 1, 0xff).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x5ef7)); +} + +#[test] +fn backdrop_is_a_target_but_cannot_blend_with_itself() { + let mut m = setup(); + m.write16(DISPCNT, 0).unwrap(); + m.write16(BLDCNT, 0x2060).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x4210)); + m.write16(BLDCNT, 0xa0).unwrap(); + m.write16(BLDY, 16).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); + m.write16(DISPCNT, 0x100).unwrap(); + m.write16(BLDCNT, 0x2041).unwrap(); + m.write16(BLDALPHA, 0x1000).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x4210)); // Backdrop is not pre-brightened. +} + +#[test] +fn object_can_be_first_or_second_target_but_never_blends_with_another_object() { + let mut m = setup(); + m.write16(DISPCNT, 0x1100).unwrap(); + object(&mut m, 0, 0, 0, 0); + object(&mut m, 1, 0, 0, 0xc00); // Lower priority; never a second OBJ target. + m.write16(BLDALPHA, 0x0808).unwrap(); + m.write16(BLDCNT, 0x0150).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); // White OBJ + red BG0. + m.write16(BLDCNT, 0x1050).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); // No OBJ-to-OBJ blend. + object(&mut m, 0, 0, 0, 0x400); // OBJ0 below BG0; later OBJ still excluded. + m.write16(BLDCNT, 0x1041).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); +} + +#[test] +fn semi_transparent_obj_overrides_mode_and_first_target_selection() { + let mut m = setup(); + m.write16(DISPCNT, 0x1100).unwrap(); + object(&mut m, 0, 0x400, 0, 0); + m.write16(BLDALPHA, 0x0808).unwrap(); + m.write16(BLDY, 16).unwrap(); + for mode in 0..4 { + m.write16(BLDCNT, 0x100 | (mode << 6)).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); + } + window(&mut m, 0x0004, 0x0008, 0x11, 0x31); + assert_eq!(pixel(&m, 0, 0), rgb(0x7fff)); // Window can disable forced alpha. + assert_eq!(pixel(&m, 4, 0), rgb(0x3dff)); +} + +#[test] +fn semi_transparent_obj_falls_back_to_selected_brightness_without_second_target() { + let mut m = setup(); + m.write16(DISPCNT, 0x1100).unwrap(); + object(&mut m, 0, 0x400, 0, 0); + m.write16(BLDY, 16).unwrap(); + for (control, expected) in [(0xc0, 0x7fff), (0xd0, 0), (0x10, 0x7fff), (0x1d0, 0)] { + m.write16(BLDCNT, control).unwrap(); + // Last case has a second target; zero alpha coefficients produce black. + assert_eq!(pixel(&m, 0, 0), rgb(expected)); + } + m.write16(BLDALPHA, 0x0808).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x3dff)); // Alpha takes precedence over brightness. +} + +#[test] +fn color_effects_work_in_affine_and_all_bitmap_modes() { + for mode in 1..=5 { + let mut m = setup(); + m.write16(DISPCNT, 0x400 | mode).unwrap(); + m.write16(BG2CNT, 0).unwrap(); + m.write16(BG2PA, 256).unwrap(); + m.write16(BG2PD, 256).unwrap(); + if mode <= 2 { + m.write16(BG2CNT, 0x1000).unwrap(); // Byte map at 0x8000, tile0 at zero. + m.write16(VRAM + 0x8000, 0).unwrap(); + m.write16(VRAM, 0x0101).unwrap(); + } else { + m.write16(VRAM, if mode == 4 { 0x0101 } else { 31 }) + .unwrap(); + } + m.write16(BLDCNT, 0x2044).unwrap(); + m.write16(BLDALPHA, 0x1000).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x4210), "mode={mode}"); + window(&mut m, 0x0001, 0x0001, 4, 0x24); + assert_eq!(pixel(&m, 0, 0), rgb(31)); + } +} + +#[test] +fn green_swap_follows_effects_and_forced_blank_bypasses_every_effect() { + let mut m = setup(); + m.write16(PAL + 2, 0x3e0).unwrap(); + m.write16(BLDCNT, 0xc1).unwrap(); + m.write16(BLDY, 16).unwrap(); + window(&mut m, 0x0001, 0x0001, 1, 0x21); + m.write16(GREENSWAP, 1).unwrap(); + assert_eq!(&render(&m).pixels()[..2], &[0, 0xff00]); + m.write16(DISPCNT, 0xffff).unwrap(); + assert!(render(&m).pixels().iter().all(|&p| p == 0xffffff)); +} diff --git a/tests/effects/demo.rs b/tests/effects/demo.rs new file mode 100644 index 0000000..d758778 --- /dev/null +++ b/tests/effects/demo.rs @@ -0,0 +1,166 @@ +use super::*; +use gba_rust::{ + effects_demo::{EffectsDemo, EFFECTS_STATE}, + input::{Button, Buttons}, + tile_demo::TileDemo, +}; + +#[test] +fn cpu_demo_moves_wraps_cancels_resets_and_updates_effect_registers_during_vblank() { + let mut demo = EffectsDemo::new().unwrap(); + let mut frame = Framebuffer::default(); + assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0); + let mut expected_x = 0u32; + let mut expected_y = 0u32; + let sequence = [ + Buttons::default(), + Buttons::default() + .with(Button::Left, true) + .with(Button::Up, true), + Buttons::default() + .with(Button::Right, true) + .with(Button::Down, true), + Buttons::default() + .with(Button::Right, true) + .with(Button::Left, true), + Buttons::default().with(Button::A, true), + Buttons::default().with(Button::B, true), + Buttons::default() + .with(Button::A, true) + .with(Button::B, true), + Buttons::default() + .with(Button::Start, true) + .with(Button::Right, true), + Buttons::default(), + ]; + for (i, buttons) in sequence.into_iter().enumerate() { + let before = demo.machine().cycles(); + demo.frame(buttons, &mut frame).unwrap(); + let memory = demo.machine().memory(); + // Explicit expected movements keep this independent of the ARM program. + if i == 1 { + expected_x = 510; + expected_y = 510; + } + if i == 2 || i == 7 { + expected_x = 0; + expected_y = 0; + } + assert_eq!(memory.read32(EFFECTS_STATE).unwrap(), i as u32 + 1); + assert_eq!(memory.read32(EFFECTS_STATE + 4).unwrap(), expected_x); + assert_eq!(memory.read32(EFFECTS_STATE + 8).unwrap(), expected_y); + assert_eq!(memory.read16(DISPCNT).unwrap(), 0x3340); + assert_eq!(memory.read16(WININ).unwrap(), 0x1f); + assert_eq!(memory.read16(WINOUT).unwrap(), 0x3f); + assert_eq!(memory.read16(BLDALPHA).unwrap(), 0x808); + assert_eq!( + memory.read16(BLDCNT).unwrap(), + match i { + 4 | 6 => 0x251, + 5 => 0xff, + _ => 0xbf, + } + ); + assert_eq!(memory.display_position().scanline, 160); + assert!(demo.machine().cycles() > before); + // The center is in WIN0 and therefore unchanged by any effect. + assert_eq!(frame.pixels()[80 * WIDTH + 120], rgb(0x7fff)); + } +} + +#[test] +fn cpu_brightness_demo_matches_tile_scene_inside_and_outside_moving_window() { + let mut demo = EffectsDemo::new().unwrap(); + let mut base = TileDemo::new().unwrap(); + let mut actual = Framebuffer::default(); + let mut original = Framebuffer::default(); + // Include wrapped scroll, affine objects, and brightness changes. + for (index, buttons) in [ + Buttons::default() + .with(Button::Left, true) + .with(Button::Up, true), + Buttons::default() + .with(Button::B, true) + .with(Button::L, true), + Buttons::default() + .with(Button::B, true) + .with(Button::R, true), + Buttons::default().with(Button::Start, true), + ] + .into_iter() + .enumerate() + { + demo.frame(buttons, &mut actual).unwrap(); + base.frame(buttons, &mut original).unwrap(); + let (left, top) = if index < 3 { (62, 38) } else { (64, 40) }; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let old = original.pixels()[y * WIDTH + x]; + // Recover the exact five-bit channels from RGB888. + let channels = [(old >> 19) & 31, (old >> 11) & 31, (old >> 3) & 31]; + let mut expected = 0u16; + for (channel, shift) in channels.into_iter().zip([0, 5, 10]) { + let changed = if (left..left + 112).contains(&x) && (top..top + 80).contains(&y) + { + channel + } else if index == 1 || index == 2 { + channel - channel / 2 + } else { + channel + (31 - channel) / 2 + }; + expected |= (changed as u16) << shift; + } + assert_eq!( + actual.pixels()[y * WIDTH + x], + rgb(expected), + "frame={index} pixel={x},{y}" + ); + } + } + } +} + +#[test] +fn cpu_window_demo_retains_active_region_when_bottom_moves_beyond_vcount_range() { + let mut demo = EffectsDemo::new().unwrap(); + let mut frame = Framebuffer::default(); + for index in 0..54 { + demo.frame(Buttons::default().with(Button::Down, true), &mut frame) + .unwrap(); + // x=80 avoids foreground crosses and sprites. It stays inside WIN0's X bounds. + let sy = 2 * (index + 1); + let raw: u16 = + [0x260, 0x3a0, 0x7d20, 0x7e80][((80 / 32 + sy / 32) % 2) * 2 + (80 / 2 + sy / 2) % 2]; + let expected = if index == 53 { + // New bottom=228 cannot match any line. Last frame's active flag survives. + raw + } else { + [0, 5, 10].into_iter().fold(0, |value, shift| { + let channel = (raw >> shift) & 31; + value | ((channel + (31 - channel) / 2) << shift) + }) + }; + assert_eq!(frame.pixels()[80], rgb(expected), "frame={index}"); + } + let mut snapshot = Framebuffer::default(); + demo.machine().memory().render_frame(&mut snapshot).unwrap(); + assert_ne!(snapshot.pixels()[80], frame.pixels()[80]); +} + +#[test] +fn dma_word_write_updates_selection_and_coefficients_together() { + use gba_rust::{ + cpu::Cpu, + dma::DMA_BASE, + machine::{Machine, StepKind}, + }; + let mut m = setup(); + m.write32(0x0200_0000, 0x0808_0241).unwrap(); + m.write32(DMA_BASE + 36, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 40, BLDCNT).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 }); + assert_eq!(machine.memory().read32(BLDCNT).unwrap(), 0x0808_0241); + assert_eq!(pixel(machine.memory(), 0, 0), rgb(0x1ef)); +} diff --git a/tests/effects/windows.rs b/tests/effects/windows.rs new file mode 100644 index 0000000..6ec21b8 --- /dev/null +++ b/tests/effects/windows.rs @@ -0,0 +1,176 @@ +use super::*; + +#[test] +fn register_masks_byte_merges_and_write_only_reads() { + let mut m = setup(); + for (address, mask) in [ + (WININ, 0x3f3f), + (WINOUT, 0x3f3f), + (BLDCNT, 0x3fff), + (BLDALPHA, 0x1f1f), + ] { + assert_eq!(m.read16(address).unwrap(), 0); + m.write16(address, 0xffff).unwrap(); + assert_eq!(m.read16(address).unwrap(), mask); + m.write8(address, 0).unwrap(); + assert_eq!(m.read16(address).unwrap(), mask & 0xff00); + m.write8(address + 1, 0).unwrap(); + assert_eq!(m.read16(address).unwrap(), 0); + } + for address in [WIN0H, WIN1H, WIN0V, WIN1V, BLDY] { + m.write16(address, 0xffff).unwrap(); + assert_eq!(m.read16(address).unwrap(), 0); + } + m.write32(BLDY, u32::MAX).unwrap(); + assert_eq!(m.read32(BLDY).unwrap(), 0); + assert_eq!( + m.read8(0x0400_0058), + Err(MemoryError::Unmapped(0x0400_0058)) + ); + assert_eq!( + m.read8(0x0401_0048), + Err(MemoryError::Unmapped(0x0401_0048)) + ); + assert_eq!( + m.write16(WININ + 1, 1), + Err(MemoryError::Unaligned(WININ + 1)) + ); +} + +#[test] +fn no_enabled_windows_ignore_zero_masks_but_apply_effects() { + let mut m = setup(); + m.write16(BLDCNT, 0xc1).unwrap(); + m.write16(BLDY, 16).unwrap(); + assert_eq!(pixel(&m, 0, 0), 0); + m.write16(DISPCNT, 0x2700).unwrap(); // Empty WIN0; outside mask is zero. + assert_eq!(pixel(&m, 0, 0), rgb(0x4210)); +} + +#[test] +fn bounds_are_half_open_and_byte_writes_preserve_other_bound() { + let mut m = setup(); + window(&mut m, 0, 0, 1, 2); + m.write8(WIN0H, 30).unwrap(); + m.write8(WIN0H + 1, 10).unwrap(); + m.write8(WIN0V, 40).unwrap(); + m.write8(WIN0V + 1, 20).unwrap(); + let f = render(&m); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let inside = (10..30).contains(&x) && (20..40).contains(&y); + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb(if inside { 31 } else { 0x3e0 }) + ); + } + } +} + +#[test] +fn inverted_bounds_wrap_equal_bounds_are_empty_and_offscreen_bounds_clip() { + let mut m = setup(); + for (h, v) in [ + (0xdc14, 0x960a), + (0xfafa, 0x00ff), + (0xf005, 0xa005), + (0x00ff, 0x00ff), + ] { + window(&mut m, h, v, 1, 2); + let f = render(&m); + for y in 0..HEIGHT { + for x in 0..WIDTH { + // Independent modular-distance definition of a half-open interval. + let inside = (x as u8).wrapping_sub((h >> 8) as u8) + < (h as u8).wrapping_sub((h >> 8) as u8) + && (y as u8).wrapping_sub((v >> 8) as u8) + < (v as u8).wrapping_sub((v >> 8) as u8); + assert_eq!( + f.pixels()[y * WIDTH + x], + rgb(if inside { 31 } else { 0x3e0 }) + ); + } + } + } +} + +#[test] +fn win0_overrides_win1_without_combining_masks() { + let mut m = setup(); + window(&mut m, 0x0a1e, 0x0a1e, 0x0200, 4); // WIN0 hides all; WIN1 selects BG1. + m.write16(WIN1H, 0x1428).unwrap(); + m.write16(WIN1V, 0x1428).unwrap(); + m.write16(DISPCNT, 0x6700).unwrap(); + assert_eq!(pixel(&m, 25, 25), rgb(0x4210)); + assert_eq!(pixel(&m, 35, 35), rgb(0x03e0)); + assert_eq!(pixel(&m, 45, 45), rgb(0x7c00)); + m.write16(DISPCNT, 0x6500).unwrap(); // Window cannot enable a globally disabled BG. + assert_eq!(pixel(&m, 35, 35), rgb(0x4210)); +} + +#[test] +fn window_effect_bit_is_independent_of_layer_bits_and_backdrop_is_always_visible() { + let mut m = setup(); + window(&mut m, 0x0008, 0x0008, 1, 0x21); + m.write16(BLDCNT, 0xc1).unwrap(); + m.write16(BLDY, 16).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(31)); + assert_eq!(pixel(&m, 8, 0), 0); + m.write16(WINOUT, 0x20).unwrap(); + m.write16(BLDCNT, 0xa0).unwrap(); // Brighten backdrop. + assert_eq!(pixel(&m, 8, 0), rgb(0x7fff)); +} + +#[test] +fn object_window_uses_nonzero_texels_not_color_priority_or_visible_obj_mask() { + let mut m = setup(); + m.write16(DISPCNT, 0x9700).unwrap(); + m.write16(WINOUT, 0x0102).unwrap(); // OBJ region BG0 only, outside BG1 only. + object(&mut m, 0, 0x800, 0, 0xfc00); // Palette15, priority3 are irrelevant. + m.write16(VRAM + 0x10000, 0x1010).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(0x3e0)); + assert_eq!(pixel(&m, 1, 0), rgb(31)); + assert_eq!(pixel(&m, 8, 0), rgb(0x3e0)); + m.write16(DISPCNT, 0x8700).unwrap(); // OBJ master enable is also required. + assert_eq!(pixel(&m, 1, 0), rgb(0x3e0)); + m.write16(DISPCNT, 0x1700).unwrap(); // Window OBJ never becomes a visible sprite. + assert_eq!(pixel(&m, 1, 0), rgb(31)); +} + +#[test] +fn visible_objects_do_not_occlude_object_window_and_rectangles_take_precedence() { + let mut m = setup(); + m.write16(DISPCNT, 0x9700).unwrap(); + m.write16(WINOUT, 0x0102).unwrap(); + object(&mut m, 0, 0, 0, 0); // Occupies the same pixels as the later window OBJ. + object(&mut m, 127, 0x800, 0, 0); + assert_eq!(pixel(&m, 0, 0), rgb(31)); // OBJ mask hides color OBJ, not window coverage. + window(&mut m, 0x0002, 0x0008, 4, 0x0102); + assert_eq!(pixel(&m, 0, 0), rgb(0x7c00)); + assert_eq!(pixel(&m, 2, 0), rgb(31)); + m.write16(WIN1H, 0x0004).unwrap(); + m.write16(WIN1V, 0x0008).unwrap(); + m.write16(WININ, 0x0204).unwrap(); + m.write16(DISPCNT, 0xf700).unwrap(); + assert_eq!(pixel(&m, 2, 0), rgb(0x3e0)); + assert_eq!(pixel(&m, 4, 0), rgb(31)); +} + +#[test] +fn object_window_supports_regular_flips_and_affine_sampling() { + let mut m = setup(); + m.write16(DISPCNT, 0x9700).unwrap(); + m.write16(WINOUT, 0x0102).unwrap(); + for offset in (0..32).step_by(2) { + m.write16(VRAM + 0x10000 + offset, 0).unwrap(); + } + m.write16(VRAM + 0x10000, 1).unwrap(); + object(&mut m, 0, 0x800, 0x1000, 0); + assert_eq!(pixel(&m, 7, 0), rgb(31)); + assert_eq!(pixel(&m, 0, 0), rgb(0x3e0)); + object(&mut m, 0, 0x900, 0, 0); + m.write16(OAM + 6, 256).unwrap(); + m.write16(OAM + 30, 256).unwrap(); + assert_eq!(pixel(&m, 0, 0), rgb(31)); + assert_eq!(pixel(&m, 1, 0), rgb(0x3e0)); +} diff --git a/tests/window_tracking.rs b/tests/window_tracking.rs new file mode 100644 index 0000000..0e13e23 --- /dev/null +++ b/tests/window_tracking.rs @@ -0,0 +1,228 @@ +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, PALETTE_START as PAL, ROM_START}, + video::{rgb555_to_rgb888 as rgb, Framebuffer, HEIGHT, WIDTH}, +}; + +#[path = "window_tracking/horizontal.rs"] +mod horizontal; + +fn setup(bounds: u16) -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_scanline_rendering(true); + m.write16(DISPCNT, 0x2000).unwrap(); + m.write16(PAL, 31).unwrap(); + m.write16(WIN0H, 240).unwrap(); + m.write16(WIN1H, 240).unwrap(); + m.write16(WIN0V, bounds).unwrap(); + m.write16(WININ, 0x2020).unwrap(); + m.write16(WINOUT, 0).unwrap(); + m.write16(BLDCNT, 0xe0).unwrap(); // Darken backdrop only when effects are enabled. + m.write16(BLDY, 16).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 position = m.display_position(); + let phase = u32::from(position.scanline) * LINE + u32::from(position.line_cycle); + m.advance_cycles(VB - phase); + present(m) +} + +fn check_rows(f: &Framebuffer, inside: impl Fn(usize) -> bool) { + for y in 0..HEIGHT { + let expected = if inside(y) { 0 } else { rgb(31) }; + assert!( + f.pixels()[y * WIDTH..(y + 1) * WIDTH] + .iter() + .all(|&p| p == expected), + "row={y}" + ); + } +} + +#[test] +fn ordinary_static_windows_match_debug_snapshots() { + for bounds in [0x00a0, 0x0130, 0x0405, 0x9fa0, 0x0000, 0x0505, 0x00ff] { + let mut m = setup(bounds); + let mut expected = Framebuffer::default(); + m.render_frame(&mut expected).unwrap(); + assert_eq!( + finish(&mut m).pixels(), + expected.pixels(), + "bounds={bounds:#x}" + ); + } +} + +#[test] +fn moving_top_after_entry_does_not_close_an_active_window() { + let mut m = setup(0x0210); + m.advance_cycles(4 * LINE + HB); + m.write8(WIN0V + 1, 12).unwrap(); + let f = finish(&mut m); + check_rows(&f, |y| (2..16).contains(&y)); + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + check_rows(&snapshot, |y| (12..16).contains(&y)); + assert_eq!(present(&m).pixels(), f.pixels()); // Snapshot rendering has no latch side effects. +} + +#[test] +fn moving_top_behind_vcount_does_not_open_a_window() { + let mut m = setup(0x0a14); + m.advance_cycles(5 * LINE + HB); + m.write8(WIN0V + 1, 2).unwrap(); + check_rows(&finish(&mut m), |_| false); + m.advance_cycles(FRAME - VB); + check_rows(&finish(&mut m), |y| (2..20).contains(&y)); +} + +#[test] +fn missing_bottom_keeps_window_open_across_frame_wrap() { + let mut m = setup(0x0214); + m.advance_cycles(5 * LINE + HB); + m.write8(WIN0V, 4).unwrap(); + check_rows(&finish(&mut m), |y| y >= 2); + m.advance_cycles(FRAME - VB); + check_rows(&finish(&mut m), |y| y < 4); // No unconditional line-zero reset. +} + +#[test] +fn writes_before_line_start_take_effect_but_writes_after_it_do_not_replay_edges() { + for before in [false, true] { + let mut m = setup(0x1420); + m.advance_cycles(5 * LINE - u32::from(before)); + m.write16(WIN0V, 0x0508).unwrap(); + m.advance_cycles(0); // Does not trigger a comparator. + check_rows(&finish(&mut m), |y| before && (5..8).contains(&y)); + } +} + +#[test] +fn hblank_writes_cannot_replay_the_current_rows_comparator() { + let mut m = setup(0x0208); + m.advance_cycles(3 * LINE + HB); + m.write16(WIN0V, 0x0203).unwrap(); // Bottom already missed this row. + check_rows(&finish(&mut m), |y| y >= 2); +} + +#[test] +fn hidden_line_edges_run_and_unreachable_edges_do_not_fire() { + for top in [160, 200, 227, 228, 255] { + let mut m = setup((top << 8) | 8); + check_rows(&finish(&mut m), |_| false); // Reset starts outside; top not reached yet. + m.advance_cycles(FRAME - VB); + check_rows(&finish(&mut m), |y| top < 228 && y < 8); + } +} + +#[test] +fn equal_bounds_clear_only_at_the_matching_edge_and_bottom_wins() { + let mut m = setup(0x0214); + m.advance_cycles(4 * LINE); + m.write16(WIN0V, 0x0808).unwrap(); + check_rows(&finish(&mut m), |y| (2..8).contains(&y)); + + let mut m = setup(0x0214); + m.advance_cycles(4 * LINE); + m.write16(WIN0V, 0xffff).unwrap(); // Neither edge is reachable by VCOUNT. + check_rows(&finish(&mut m), |y| y >= 2); + m.advance_cycles(FRAME - VB); + check_rows(&finish(&mut m), |_| true); +} + +#[test] +fn window_disable_and_forced_blank_do_not_pause_comparators() { + for control in [0x4000, 0x2080] { + let mut m = setup(0x0210); + m.write16(DISPCNT, control).unwrap(); + m.advance_cycles(5 * LINE); + m.write16(DISPCNT, 0x2000).unwrap(); + let f = finish(&mut m); + for y in 0..HEIGHT { + let expected = if y < 5 && control & 0x80 != 0 { + 0xffffff + } else if (5..16).contains(&y) { + 0 + } else { + rgb(31) + }; + assert_eq!( + f.pixels()[y * WIDTH], + expected, + "control={control:#x} row={y}" + ); + } + } +} + +#[test] +fn win0_and_win1_keep_independent_state_and_win0_keeps_precedence() { + let mut m = setup(0x0206); + m.write32(WIN0V, 0x0408_0206).unwrap(); // Independent byte merging for both windows. + m.write16(DISPCNT, 0x6000).unwrap(); + m.write16(WININ, 0x2000).unwrap(); // WIN0 allows red; WIN1 darkens to black. + m.advance_cycles(3 * LINE + HB); + m.write8(WIN0V + 1, 5).unwrap(); // Does not close WIN0 before row5. + m.advance_cycles(LINE); + m.write8(WIN1V + 1, 7).unwrap(); // Does not close WIN1 before row7. + check_rows(&finish(&mut m), |y| (6..8).contains(&y)); + assert_eq!(m.read32(WIN0V).unwrap(), 0); // Registers remain write-only. +} + +#[test] +fn hblank_bounds_and_mask_writes_apply_to_subsequent_rows() { + let mut m = setup(0x020a); + m.advance_cycles(4 * LINE + HB); + m.write16(WIN0H, 0x0a14).unwrap(); + m.advance_cycles(LINE); + m.write16(WININ, 0).unwrap(); + let f = finish(&mut m); + for y in 0..HEIGHT { + for x in 0..WIDTH { + let dark = (2..5).contains(&y) || (y == 5 && (10..20).contains(&x)); + assert_eq!(f.pixels()[y * WIDTH + x], if dark { 0 } else { rgb(31) }); + } + } +} + +#[test] +fn hblank_dma_programs_future_edges_without_recomputing_interval_membership() { + let mut m = setup(0x1020); + for y in 0..160 { + m.write16(0x0200_0000 + y * 2, if y == 0 { 0x0104 } else { 0xc804 }) + .unwrap(); + } + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, WIN0V).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(); + check_rows(&present(machine.memory()), |y| (1..4).contains(&y)); + assert!(machine.halted()); + assert_eq!(machine.memory().read16(DMA_BASE + 10).unwrap(), 0xa240); +} + +#[test] +fn capture_disabled_still_tracks_hidden_edges_and_enabling_does_not_reset_state() { + let mut m = setup(0xc808); + m.set_scanline_rendering(false); + m.advance_cycles(FRAME); // Hidden row200 opens the window, retained at row0. + m.set_scanline_rendering(true); + m.set_scanline_rendering(true); + check_rows(&finish(&mut m), |y| y < 8); +} diff --git a/tests/window_tracking/horizontal.rs b/tests/window_tracking/horizontal.rs new file mode 100644 index 0000000..3413e05 --- /dev/null +++ b/tests/window_tracking/horizontal.rs @@ -0,0 +1,209 @@ +use super::*; + +fn scene(horizontal: u16) -> Memory { + let mut m = setup(160); + m.write16(WIN0H, horizontal).unwrap(); + m +} + +fn check_line(f: &Framebuffer, y: usize, inside: impl Fn(usize) -> bool) { + for x in 0..WIDTH { + assert_eq!( + f.pixels()[y * WIDTH + x], + if inside(x) { 0 } else { rgb(31) }, + "pixel={x},{y}" + ); + } +} + +#[test] +fn moving_left_after_entry_preserves_earlier_pixels_and_active_state() { + let mut m = scene(0x0a50); + m.advance_cycles(4 * 20 + 1); + m.write8(WIN0H + 1, 40).unwrap(); + let f = finish(&mut m); + check_line(&f, 0, |x| (10..80).contains(&x)); + check_line(&f, 1, |x| (40..80).contains(&x)); + let mut snapshot = Framebuffer::default(); + m.render_frame(&mut snapshot).unwrap(); + check_line(&snapshot, 0, |x| (40..80).contains(&x)); + assert_eq!(present(&m).pixels(), f.pixels()); +} + +#[test] +fn moving_left_behind_current_column_does_not_open_an_inactive_window() { + let mut m = scene(0x6478); + m.advance_cycles(4 * 20); + m.write8(WIN0H + 1, 10).unwrap(); + let f = finish(&mut m); + check_line(&f, 0, |_| false); + check_line(&f, 1, |x| (10..120).contains(&x)); +} + +#[test] +fn missing_right_keeps_window_open_into_the_next_line() { + let mut m = scene(0x0a50); + m.advance_cycles(4 * 30); + m.write8(WIN0H, 5).unwrap(); + let f = finish(&mut m); + check_line(&f, 0, |x| x >= 10); + check_line(&f, 1, |x| !(5..10).contains(&x)); +} + +#[test] +fn exact_four_cycle_sampling_boundaries_and_zero_cycle_advances_are_observable() { + for elapsed in [79, 80, 81, 83] { + let mut m = scene(0x6450); + m.advance_cycles(elapsed); + m.write16(WIN0H, 0x1450).unwrap(); + m.advance_cycles(0); + let f = finish(&mut m); + check_line(&f, 0, |x| elapsed <= 80 && (20..80).contains(&x)); + check_line(&f, 1, |x| (20..80).contains(&x)); + } +} + +#[test] +fn equal_bounds_clear_at_the_edge_not_at_register_write() { + for equal in [20, 40] { + let mut m = scene(0x0ac8); + m.advance_cycles(4 * 30); + m.write16(WIN0H, equal * 0x101).unwrap(); + let f = finish(&mut m); + check_line(&f, 0, |x| x >= 10 && (equal == 20 || x < 40)); + check_line(&f, 1, |x| equal == 20 && x < 20); + check_line(&f, 2, |_| false); + } +} + +#[test] +fn offscreen_columns_including_late_hblank_set_next_lines_wrapping_state() { + for left in [240, 251, 252, 255] { + let mut m = scene((left << 8) | 10); + let f = finish(&mut m); + check_line(&f, 0, |_| false); // No previous line at startup. + check_line(&f, 1, |x| x < 10); + check_line(&f, 159, |x| x < 10); + } +} + +#[test] +fn hblank_writes_can_cancel_a_late_offscreen_edge_without_changing_captured_row() { + let mut m = scene(0xff0a); + m.advance_cycles(HB); + m.write8(WIN0H + 1, 20).unwrap(); // X255 not reached; X20 already passed. + let f = finish(&mut m); + check_line(&f, 0, |_| false); + check_line(&f, 1, |x| x >= 20); + check_line(&f, 2, |x| !(10..20).contains(&x)); +} + +#[test] +fn independent_window_histories_preserve_win0_precedence() { + let mut m = scene(0x0a50); + m.write16(WIN1H, 0x1464).unwrap(); + m.write16(WIN1V, 160).unwrap(); + m.write16(DISPCNT, 0x6000).unwrap(); + m.write16(WININ, 0x2000).unwrap(); // WIN0 red, WIN1 black. + m.advance_cycles(4 * 30); + m.write32(WIN0H, 0x1464_3250).unwrap(); // Move WIN0's left edge only. + let f = finish(&mut m); + check_line(&f, 0, |x| (80..100).contains(&x)); + check_line(&f, 1, |x| (20..50).contains(&x) || (80..100).contains(&x)); + assert_eq!(m.read32(WIN0H).unwrap(), 0); +} + +#[test] +fn disabled_windows_and_forced_blank_do_not_pause_horizontal_history() { + for control in [0x4000, 0x2080] { + let mut m = scene(0x0a50); + m.write16(DISPCNT, control).unwrap(); + m.advance_cycles(4 * 30); + m.write16(DISPCNT, 0x2000).unwrap(); + let f = finish(&mut m); + // Enable/blank bits still sample at HBlank, unlike WIN0H comparator history. + check_line(&f, 0, |x| (10..80).contains(&x)); + } +} + +#[test] +fn hblank_dma_can_set_an_offscreen_edge_before_the_next_line() { + let mut m = scene(0); + for row in 0..160 { + m.write16(0x0200_0000 + row * 2, 0xff0a).unwrap(); + } + m.write32(DMA_BASE, 0x0200_0000).unwrap(); + m.write32(DMA_BASE + 4, WIN0H).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()); + check_line(&f, 0, |_| false); + for y in 1..HEIGHT { + check_line(&f, y, |x| x < 10); + } + assert!(machine.halted()); +} + +#[test] +fn cpu_store_crossing_a_comparator_uses_committed_bounds_not_final_instruction_time() { + for after in [false, true] { + let mut m = scene(0x6450); + let code = [ + 0xe3a0_0301, // MOV r0,#0x04000000 + 0xe3a0_1b05, // MOV r1,#0x1400 + 0xe381_1050, // ORR r1,r1,#0x50 + 0xe1c0_14b0, // STRH r1,[r0,#0x40] (WIN0H) + ]; + for (i, value) in code.into_iter().enumerate() { + m.write32(0x0300_0000 + i as u32 * 4, value).unwrap(); + } + let mut machine = Machine::new(Cpu::new(0x0300_0000), m); + for _ in 0..3 { + machine.step().unwrap(); + } + let cycles = machine.cycles() as u32; + machine + .memory_mut() + .advance_cycles(if after { 81 } else { 79 } - cycles); + machine.step().unwrap(); + assert!(machine.cycles() > 80); + let f = finish(machine.memory_mut()); + check_line(&f, 0, |x| !after && (20..80).contains(&x)); + } +} + +#[test] +fn pixel_colors_still_sample_at_hblank_while_window_edges_keep_history() { + let mut m = scene(0x0a50); + m.advance_cycles(4 * 30); + m.write8(WIN0H + 1, 40).unwrap(); + m.write16(PAL, 0x7c00).unwrap(); + let f = finish(&mut m); + for x in 0..WIDTH { + assert_eq!( + f.pixels()[x], + if (10..80).contains(&x) { + 0 + } else { + rgb(0x7c00) + } + ); + } +} + +#[test] +fn capture_enable_preserves_partial_line_history_and_hidden_lines_update_flags() { + let mut m = scene(0x0a50); + m.set_scanline_rendering(false); + m.advance_cycles(4 * 30); + m.write8(WIN0H + 1, 40).unwrap(); + m.set_scanline_rendering(true); // Before first HBlank, this frame is still complete. + check_line(&finish(&mut m), 0, |x| (10..80).contains(&x)); + m.set_scanline_rendering(false); + m.write16(WIN0H, 0xff0a).unwrap(); + m.advance_cycles(FRAME - VB); + m.set_scanline_rendering(true); + check_line(&finish(&mut m), 0, |x| x < 10); +}