feat(video): track windows and compose color effects

Add persistent window comparators, region masks, alpha blending, and brightness effects.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit 9d14dd7cf4
9 changed files with 1408 additions and 0 deletions

61
tests/effects.rs Normal file
View file

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

172
tests/effects/colors.rs Normal file
View file

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

166
tests/effects/demo.rs Normal file
View file

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

176
tests/effects/windows.rs Normal file
View file

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

228
tests/window_tracking.rs Normal file
View file

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

View file

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