gameboy-rust/crates/gba-core/tests/affine_backgrounds.rs
0xrsydn 0ef2719c6b fix(video): ignore enable bits for backgrounds a mode lacks
DISPCNT enable bits for BG3 in Mode 1, BG0/BG1 in Mode 2, and every background
but BG2 in Modes 3-5 failed the whole frame with UnsupportedLayers, and the
window turned that into a stopped session. Hardware has no such backgrounds and
ignores the bits, so the renderer now masks them. The error variant is removed.

Prohibited OBJ mode 3 and shape 3 stay diagnostics: their hardware output is
not established here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhfHfHEuzv2AUTd6erW1gU
2026-10-14 14:28:41 +07:00

380 lines
14 KiB
Rust

use gba_core::{
io::*,
memory::{Memory, MemoryError, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM},
video::{rgb555_to_rgb888, Framebuffer, WIDTH},
};
fn setup(bg: u32, size: u16, wrap: bool) -> Memory {
let mut m = Memory::new(vec![]).unwrap();
m.write16(DISPCNT, 2 | (0x100 << bg)).unwrap();
m.write16(
BG0CNT + bg * 2,
0x1000 | (size << 14) | if wrap { 0x2000 } else { 0 },
)
.unwrap();
transform(&mut m, bg, [256, 0, 0, 256], [0, 0]);
m.write16(PAL, 0x4000).unwrap();
for i in 1..=255 {
m.write16(PAL + i * 2, i as u16 * 31).unwrap();
}
// All 256 byte-sized tile numbers, with an asymmetric indexed pattern.
for tile in 0..256 {
for y in 0..8 {
for x in (0..8).step_by(2) {
let lo = index(tile, x, y);
let hi = index(tile, x + 1, y);
m.write16(
VRAM + tile * 64 + y * 8 + x,
u16::from(lo) | (u16::from(hi) << 8),
)
.unwrap();
}
}
}
let tiles = 16_u32 << size;
for y in 0..tiles {
for x in (0..tiles).step_by(2) {
m.write16(
VRAM + 0x8000 + y * tiles + x,
u16::from(tile_number(x, y)) | (u16::from(tile_number(x + 1, y)) << 8),
)
.unwrap();
}
}
m
}
fn tile_number(x: u32, y: u32) -> u8 {
(x.wrapping_mul(17) + y.wrapping_mul(31)) as u8
}
fn index(tile: u32, x: u32, y: u32) -> u8 {
(tile + x + 3 * y) as u8
}
fn transform(m: &mut Memory, bg: u32, coeff: [i16; 4], origin: [i32; 2]) {
let base = BG2PA + (bg - 2) * 16;
for (i, v) in coeff.into_iter().enumerate() {
m.write16(base + i as u32 * 2, v as u16).unwrap();
}
for (i, v) in origin.into_iter().enumerate() {
m.write32(base + 8 + i as u32 * 4, v as u32).unwrap();
}
}
fn frame(m: &Memory) -> Framebuffer {
let mut f = Framebuffer::default();
m.render_frame(&mut f).unwrap();
f
}
fn pixel(m: &Memory, x: usize, y: usize) -> u32 {
frame(m).pixels()[y * WIDTH + x]
}
fn reference(m: &Memory, size: u16, wrap: bool, coeff: [i16; 4], origin: [i32; 2]) {
let f = frame(m);
let size = 128_i64 << size;
for y in 0..160 {
for x in 0..240 {
// Wide-integer reference with explicit 28-bit wrapping and floor division.
let coord = |r: i32, a: i16, b: i16| {
let v = i64::from(r) + i64::from(a) * x as i64 + i64::from(b) * y as i64;
let signed = (v + (1 << 27)).rem_euclid(1 << 28) - (1 << 27);
signed.div_euclid(256)
};
let mut tx = coord(origin[0], coeff[0], coeff[1]);
let mut ty = coord(origin[1], coeff[2], coeff[3]);
let mut color = 0x4000;
if wrap || (tx >= 0 && ty >= 0 && tx < size && ty < size) {
tx = tx.rem_euclid(size);
ty = ty.rem_euclid(size);
let t = tile_number(tx as u32 / 8, ty as u32 / 8);
let i = index(u32::from(t), tx as u32 % 8, ty as u32 % 8);
if i != 0 {
color = u16::from(i) * 31;
}
}
assert_eq!(
f.pixels()[y * WIDTH + x],
rgb555_to_rgb888(color),
"pixel {x},{y}, size {size}, origin={origin:?}, matrix={coeff:?}"
);
}
}
}
#[test]
fn affine_matrices_sizes_wrapping_and_signed_origins_match_wide_reference() {
for bg in [2, 3] {
for size in 0..4 {
for wrap in [false, true] {
let mut m = setup(bg, size, wrap);
for (coeff, origin) in [
([256, 0, 0, 256], [0, 0]),
([0, 256, -256, 0], [-1, 128 * 256]),
([181, 181, -181, 181], [-173, 17551]),
([128, 0, 0, 512], [127, -129]),
([256, 127, 1, -256], [0x07ff_ffff, 0x0800_0000]),
([32767, -32768, -32768, 32767], [-123456, 123456]),
([0; 4], [23 * 256, 17 * 256]),
] {
transform(&mut m, bg, coeff, origin);
reference(&m, size, wrap, coeff, origin);
}
}
}
}
}
#[test]
fn write_only_registers_merge_bytes_halfwords_and_words_and_mask_reference_top_bits() {
let mut m = setup(2, 1, true);
m.write32(BG2PA, 0xff80_0180).unwrap(); // A=1.5, B=-0.5
m.write8(BG2PA, 0x40).unwrap(); // A=1.25, retains high byte
m.write32(BG2PC, 0x0100_0000).unwrap();
m.write32(BG2X, 0xffff_ffff).unwrap();
m.write8(BG2X, 0x80).unwrap(); // -0.5
m.write16(BG2Y, 0x0101).unwrap();
m.write16(BG2Y + 2, 0xf000).unwrap(); // Unused high nibble must be zero internally.
reference(&m, 1, true, [320, -128, 0, 256], [-128, 257]);
for address in (BG2PA..=BG3Y).step_by(4) {
assert_eq!(m.read32(address).unwrap(), 0);
}
assert_eq!(
m.read16(0x0400_0058),
Err(MemoryError::Unmapped(0x0400_0058))
);
assert_eq!(m.cycles(), 0);
}
#[test]
fn affine_bg_ignores_text_scroll_and_color_depth_bit() {
let mut m = setup(2, 1, true);
let before = frame(&m);
m.write32(BG2HOFS, u32::MAX).unwrap();
m.write16(BG2CNT, 0x7080).unwrap(); // Same map/size/wrap, color bit set.
assert_eq!(frame(&m).pixels(), before.pixels());
}
#[test]
fn affine_byte_maps_are_flat_rows_use_tile255_and_have_no_flip_or_bank_bits() {
let mut m = setup(2, 2, false);
m.write16(VRAM + 0x8000 + 64, 0x00ff).unwrap(); // (tileX=0,tileY=1), not next text screen block.
assert_eq!(pixel(&m, 0, 8), rgb555_to_rgb888(255 * 31));
assert_eq!(pixel(&m, 1, 8), rgb555_to_rgb888(0x4000)); // Tile255 texel1 wraps to palette index0.
assert_eq!(pixel(&m, 8, 8), rgb555_to_rgb888(0x4000)); // Adjacent map byte selects tile0.
}
#[test]
fn affine_character_base_and_last_tile_reach_the_end_of_bg_vram() {
let mut m = setup(2, 0, false);
m.write16(BG2CNT, 0x100c).unwrap(); // Character base3, map16.
m.write16(VRAM + 0x8000, 0x00ff).unwrap();
m.write16(VRAM + 0xfffe, 0x0201).unwrap();
transform(&mut m, 2, [256, 0, 0, 256], [6 * 256, 7 * 256]);
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31));
assert_eq!(pixel(&m, 1, 0), rgb555_to_rgb888(62));
}
#[test]
fn mode1_composes_two_text_layers_and_one_affine_layer_with_priority_ties() {
let mut m = setup(2, 1, true);
m.write16(DISPCNT, 0x701).unwrap();
m.write16(BG0CNT, 0x1804).unwrap(); // Text tile base1, map24, priority0.
m.write16(BG1CNT, 0x1904).unwrap();
m.write16(VRAM + 0x4000, 0x2222).unwrap();
m.write16(VRAM + 0xc800, 1).unwrap();
m.write16(VRAM + 0x4020, 0x3333).unwrap();
m.write16(VRAM, 0x0101).unwrap(); // BG2 affine nonzero index1.
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(2 * 31)); // BG0 wins tie.
m.write16(BG0CNT, 0x1807).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(3 * 31)); // BG1 wins tie over BG2.
m.write16(BG1CNT, 0x1907).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31));
m.write16(VRAM, 0).unwrap(); // Affine palette index0 reveals text BG0.
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(2 * 31));
}
#[test]
fn mode2_bg3_has_independent_matrix_reference_and_priority() {
let mut m = setup(2, 1, true);
m.write16(DISPCNT, 0xc02).unwrap();
m.write16(BG3CNT, 0x7000).unwrap();
transform(&mut m, 2, [0; 4], [256, 0]);
transform(&mut m, 3, [0; 4], [512, 0]);
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31)); // BG2 wins tie.
m.write16(BG2CNT, 0x7001).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(62));
m.write16(PAL + 4, 0).unwrap();
assert_eq!(pixel(&m, 0, 0), 0); // Nonzero index with black color is opaque.
}
#[test]
fn mode3_uses_affine_matrix_ignores_overflow_and_keeps_black_opaque() {
let mut m = Memory::new(vec![]).unwrap();
m.write16(DISPCNT, 0x403).unwrap();
m.write16(PAL, 0x03e0).unwrap();
m.write16(VRAM, 0).unwrap();
m.write16(VRAM + 2, 0x001f).unwrap();
m.write16(VRAM + (159 * 240 + 239) * 2, 0x7fff).unwrap();
transform(&mut m, 2, [256, 0, 0, 256], [-256, 0]);
m.write16(BG2CNT, 0x2000).unwrap(); // Ignored bitmap overflow flag.
assert_eq!(pixel(&m, 0, 0), 0x00ff00);
assert_eq!(pixel(&m, 1, 0), 0);
assert_eq!(pixel(&m, 2, 0), 0xff0000);
transform(&mut m, 2, [-256, 0, 0, -256], [239 * 256, 159 * 256]);
assert_eq!(pixel(&m, 0, 0), 0xffffff);
assert_eq!(pixel(&m, 239, 159), 0);
// Hardware reset is a zero matrix, not an implicit identity.
transform(&mut m, 2, [0; 4], [256, 0]);
assert!(frame(&m).pixels().iter().all(|&p| p == 0xff0000));
}
#[test]
fn transformed_backgrounds_composite_with_regular_and_affine_sprites() {
for mode in [1, 2] {
let mut m = setup(2, 1, true);
m.write16(DISPCNT, mode | 0x1440).unwrap();
for i in 0..128 {
m.write16(OAM + i * 8, 0x200).unwrap();
}
m.write16(VRAM, 0x0101).unwrap();
m.write16(VRAM + 0x10000, 0x2222).unwrap();
m.write16(PAL + 0x204, 0x03e0).unwrap();
m.write16(OAM, 0).unwrap();
m.write16(OAM + 4, 0).unwrap();
assert_eq!(pixel(&m, 0, 0), 0x00ff00); // OBJ wins equal BG priority.
m.write16(OAM + 4, 0x400).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(31));
m.write16(OAM, 0x100).unwrap();
m.write16(OAM + 6, 256).unwrap();
m.write16(OAM + 30, 256).unwrap();
m.write16(OAM + 4, 0).unwrap();
assert_eq!(pixel(&m, 0, 0), 0x00ff00);
}
}
#[test]
fn absent_layer_bits_are_ignored_and_disabled_layers_skip_validation() {
let mut m = setup(2, 3, false);
let mut f = Framebuffer::default();
// Mode 1 has no BG3 and mode 2 has no BG0 or BG1; their enable bits change nothing.
for (control, present) in [(0xc01, 0x401), (0xd02, 0xc02), (0xe02, 0xc02)] {
m.write16(DISPCNT, present).unwrap();
let expected = frame(&m);
m.write16(DISPCNT, control).unwrap();
m.render_frame(&mut f).unwrap();
assert_eq!(f.pixels(), expected.pixels());
}
m.write16(DISPCNT, 2).unwrap();
m.render_frame(&mut f).unwrap();
assert!(f.pixels().iter().all(|&p| p == rgb555_to_rgb888(0x4000)));
m.write16(DISPCNT, 0xffff).unwrap();
m.render_frame(&mut f).unwrap();
assert!(f.pixels().iter().all(|&p| p == 0xffffff));
}
#[test]
fn affine_map_at_map_base_31_reads_high_vram_through_the_bus_mirror() {
let mut m = setup(2, 3, false);
m.write16(BG2CNT, 0xdf00).unwrap(); // Map31, 1024 pixels.
m.write32(BG2Y, 128 * 256).unwrap(); // Map row16 starts at offset0x10000.
m.write16(VRAM + 0x18000, 1).unwrap(); // Halfword bus mirror of the selected map byte.
let mut f = Framebuffer::default();
m.render_frame(&mut f).unwrap();
assert_eq!(f.pixels()[0], rgb555_to_rgb888(31));
}
#[test]
fn cpu_demo_pans_rotates_zooms_clips_cancels_opposites_and_resets() {
use gba_core::input::{Button, Buttons};
use gba_demos::affine_demo::{AffineDemo, AFFINE_STATE};
let mut demo = AffineDemo::new().unwrap();
let mut f = Framebuffer::default();
assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0);
assert_eq!(demo.machine().memory().read16(VRAM).unwrap(), 0);
// Signed negative panning and opposite-key cancellation.
demo.frame(
Buttons::default()
.with(Button::Left, true)
.with(Button::Up, true),
&mut f,
)
.unwrap();
demo.frame(Buttons::from_bits(0xf0), &mut f).unwrap();
assert_eq!(
demo.machine().memory().read32(AFFINE_STATE + 4).unwrap() as i32,
-2
);
assert_eq!(
demo.machine().memory().read32(AFFINE_STATE + 8).unwrap() as i32,
-2
);
for bits in 0..8 {
let rotated = bits & 1 != 0;
let zoomed = bits & 2 != 0;
let clipped = bits & 4 != 0;
let before = demo.machine().memory().display_position();
demo.frame(
Buttons::default()
.with(Button::L, rotated)
.with(Button::R, zoomed)
.with(Button::A, clipped),
&mut f,
)
.unwrap();
let a = f64::from(match (rotated, zoomed) {
(false, false) => 256,
(false, true) => 128,
(true, false) => 181,
(true, true) => 90,
}) / 256.0;
let b = if rotated { a } else { 0.0 };
for y in 0..160 {
for x in 0..240 {
let dx = x as f64 - 120.0;
let dy = y as f64 - 80.0;
let tx = (126.0 + a * dx + b * dy).floor() as i32;
let ty = (126.0 - b * dx + a * dy).floor() as i32;
let color = if clipped && (!(0..256).contains(&tx) || !(0..256).contains(&ty)) {
0x4000
} else {
let tx = tx.rem_euclid(256) as usize;
let ty = ty.rem_euclid(256) as usize;
[
0x0260, 0x03a0, 0x7d20, 0x7e80, 0x001f, 0x421f, 0x03ff, 0x7fff,
][((tx / 32 + 2 * (ty / 32)) % 4) * 2 + (tx / 2 + ty / 2) % 2]
};
assert_eq!(
f.pixels()[y * WIDTH + x],
rgb555_to_rgb888(color),
"input {bits}, pixel {x},{y}"
);
}
}
let m = demo.machine().memory();
assert_eq!(m.read32(AFFINE_STATE).unwrap(), bits + 3);
assert_eq!(m.read16(DISPCNT).unwrap(), 0x402);
assert_eq!(m.display_position().vblanks, before.vblanks + 1);
assert_eq!(m.display_position().scanline, 160);
}
demo.frame(
Buttons::default()
.with(Button::Start, true)
.with(Button::Right, true),
&mut f,
)
.unwrap();
assert_eq!(demo.machine().memory().read32(AFFINE_STATE + 4).unwrap(), 0);
assert_eq!(demo.machine().memory().read32(AFFINE_STATE + 8).unwrap(), 0);
}
#[test]
fn snapshot_uses_programmed_origin_without_advancing_or_accumulating_scanline_state() {
let mut m = setup(2, 1, true);
transform(&mut m, 2, [181, 181, -181, 181], [12345, -567]);
let first = frame(&m);
m.advance_cycles(1232 * 57);
let cycles = m.cycles();
assert_eq!(frame(&m).pixels(), first.pixels());
assert_eq!(m.cycles(), cycles);
m.write32(BG2X, 0).unwrap();
assert_ne!(frame(&m).pixels(), first.pixels());
}