gameboy-rust/tests/tiles.rs
0xrsydn fad034ae74 feat(video): render tiled affine and bitmap backgrounds
Add Mode 0-5 composition, palettes, tile maps, affine sampling, and framebuffer diagnostics.
2026-10-05 12:58:38 +07:00

481 lines
16 KiB
Rust

use gba_rust::{
io::*,
memory::{Memory, MemoryError, PALETTE_START as PAL, VRAM_START as VRAM},
video::{rgb555_to_rgb888, Framebuffer, VideoError, WIDTH},
};
fn setup() -> Memory {
let mut m = Memory::new(vec![]).unwrap();
m.write16(DISPCNT, 0x100).unwrap();
m.write16(BG0CNT, 0x1000).unwrap(); // Map at 0x8000, tiles at zero.
for (i, color) in [0x4210, 0x001f, 0x03e0, 0x7c00, 0x7fff]
.into_iter()
.enumerate()
{
m.write16(PAL + i as u32 * 2, color).unwrap();
}
m
}
fn pixel(m: &Memory, x: usize, y: usize) -> u32 {
let mut frame = Framebuffer::default();
m.render_frame(&mut frame).unwrap();
frame.pixels()[y * WIDTH + x]
}
fn tile(m: &mut Memory, address: u32, value: u16, bytes: u32) {
for i in (0..bytes).step_by(2) {
m.write16(VRAM + address + i, value).unwrap();
}
}
#[test]
fn controls_mask_unused_bits_and_merge_byte_and_word_writes() {
let mut m = setup();
m.write32(BG0CNT, u32::MAX).unwrap();
m.write32(BG2CNT, u32::MAX).unwrap();
assert_eq!(m.read32(BG0CNT).unwrap(), 0xdfcf_dfcf);
assert_eq!(m.read32(BG2CNT).unwrap(), 0xffcf_ffcf);
m.write8(BG0CNT, 0x81).unwrap();
m.write8(BG0CNT + 1, 0x10).unwrap();
assert_eq!(m.read16(BG0CNT).unwrap(), 0x1081);
for reg in (BG0HOFS..=BG3VOFS).step_by(2) {
m.write16(reg, 0xffff).unwrap();
assert_eq!(m.read16(reg).unwrap(), 0); // Write-only placeholder, not open bus.
}
assert_eq!(
m.read16(0x0400_0058),
Err(MemoryError::Unmapped(0x0400_0058))
);
}
#[test]
fn four_bit_nibble_order_palette_banks_and_transparent_zero() {
let mut m = setup();
m.write16(VRAM, 0x0321).unwrap();
assert_eq!(pixel(&m, 0, 0), 0xff0000);
assert_eq!(pixel(&m, 1, 0), 0x00ff00);
assert_eq!(pixel(&m, 2, 0), 0x0000ff);
assert_eq!(pixel(&m, 3, 0), rgb555_to_rgb888(0x4210));
m.write16(VRAM + 0x8000, 0xf000).unwrap();
m.write16(PAL + 241 * 2, 0x7fff).unwrap();
m.write16(PAL + 240 * 2, 0x001f).unwrap(); // Bank zero is still transparent.
assert_eq!(pixel(&m, 0, 0), 0xffffff);
assert_eq!(pixel(&m, 3, 0), rgb555_to_rgb888(0x4210));
}
#[test]
fn eight_bit_tiles_use_full_index_and_ignore_palette_bank() {
let mut m = setup();
m.write16(BG0CNT, 0x1080).unwrap();
m.write16(VRAM + 0x8000, 0xf001).unwrap();
m.write16(VRAM + 64, 0x00ff).unwrap();
m.write16(PAL + 510, 0x001f).unwrap();
assert_eq!(pixel(&m, 0, 0), 0xff0000);
assert_eq!(pixel(&m, 1, 0), rgb555_to_rgb888(0x4210));
}
#[test]
fn horizontal_and_vertical_flips_apply_in_both_color_depths() {
for eight in [false, true] {
let mut m = setup();
m.write16(BG0CNT, 0x1000 | if eight { 0x80 } else { 0 })
.unwrap();
let (top, bottom) = if eight {
(0x0201, 0x0403)
} else {
(0x21, 0x43)
};
m.write16(VRAM, top).unwrap();
m.write16(VRAM + if eight { 56 } else { 28 }, bottom)
.unwrap();
for (flip, x, y, expected) in [
(0, 0, 0, 0xff0000),
(0x400, 7, 0, 0xff0000),
(0x800, 0, 0, 0x0000ff),
(0xc00, 6, 0, 0xffffff),
] {
m.write16(VRAM + 0x8000, flip).unwrap();
assert_eq!(pixel(&m, x, y), expected);
}
}
}
#[test]
fn all_map_sizes_use_screen_blocks_not_flat_rows_and_wrap() {
for size in 0..4_u16 {
let mut m = setup();
for i in 1..=4 {
tile(&mut m, i * 32, 0x1111 * i as u16, 32);
}
for block in 0..4 {
tile(&mut m, 0x8000 + block * 0x800, block as u16 + 1, 0x800);
}
m.write16(BG0CNT, 0x1000 | (size << 14)).unwrap();
let width = if size & 1 != 0 { 512 } else { 256 };
let height = if size & 2 != 0 { 512 } else { 256 };
for y in [0, 255, 256, 511] {
for x in [0, 255, 256, 511] {
m.write16(BG0HOFS, x).unwrap();
m.write16(BG0VOFS, y).unwrap();
let block = (x % width) / 256 + (y % height) / 256 * (width / 256);
let colors = [0x001f, 0x03e0, 0x7c00, 0x7fff];
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(colors[block as usize]));
}
}
}
}
#[test]
fn scroll_byte_writes_merge_latches_mask_nine_bits_and_move_subtile_pixels() {
let mut m = setup();
m.write16(BG0CNT, 0x1080).unwrap();
m.write16(VRAM + 2, 0x0201).unwrap();
m.write16(BG0HOFS, 0xffff).unwrap();
m.write8(BG0HOFS, 2).unwrap(); // latch becomes 0x102; 256-wide map wraps to 2.
assert_eq!(pixel(&m, 0, 0), 0xff0000);
m.write8(BG0HOFS + 1, 0xfe).unwrap(); // Only bit zero survives.
assert_eq!(pixel(&m, 1, 0), 0x00ff00);
m.write16(VRAM + 8 + 2, 0x0403).unwrap();
m.write32(BG0HOFS, 0xfe01_fe02).unwrap(); // X=2, Y=1
assert_eq!(pixel(&m, 0, 0), 0x0000ff);
assert_eq!(m.cycles(), 0);
}
#[test]
fn each_background_uses_its_own_scroll_and_control() {
for bg in 0..4 {
let mut m = setup();
m.write16(DISPCNT, 0x100 << bg).unwrap();
m.write16(BG0CNT + bg * 2, 0x1080).unwrap();
m.write16(VRAM + 10, 0x0201).unwrap();
m.write16(BG0HOFS + bg * 4, 2).unwrap();
m.write16(BG0VOFS + bg * 4, 1).unwrap();
assert_eq!(pixel(&m, 0, 0), 0xff0000);
}
}
#[test]
fn priorities_ties_transparency_and_opaque_black_composite_all_four_layers() {
let mut m = setup();
m.write16(DISPCNT, 0xf00).unwrap();
for bg in 0..4 {
m.write16(BG0CNT + bg * 2, ((16 + bg) << 8) as u16).unwrap();
tile(&mut m, 0x8000 + bg * 0x800, bg as u16 + 1, 0x800);
tile(&mut m, (bg + 1) * 32, 0x1111 * (bg as u16 + 1), 32);
}
for bg in 0..4 {
let expected = [0xff0000, 0x00ff00, 0x0000ff, 0xffffff][bg as usize];
assert_eq!(pixel(&m, 0, 0), expected);
// Lower the winning layer's priority so the next wins the tie.
m.write16(BG0CNT + bg * 2, ((16 + bg) << 8) as u16 | 3)
.unwrap();
}
assert_eq!(pixel(&m, 0, 0), 0xff0000); // All priority 3, BG0 wins.
m.write16(PAL + 2, 0).unwrap();
assert_eq!(pixel(&m, 0, 0), 0); // Nonzero index with black palette color is opaque.
tile(&mut m, 32, 0, 32);
assert_eq!(pixel(&m, 0, 0), 0x00ff00);
m.write16(DISPCNT, 0).unwrap();
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(0x4210));
}
#[test]
fn character_base_and_high_tile_numbers_cross_character_blocks() {
for eight in [false, true] {
let mut m = setup();
m.write16(BG0CNT, 0x1004 | if eight { 0x80 } else { 0 })
.unwrap();
m.write16(VRAM + 0x8000, 1023).unwrap();
let address = 0x4000 + 1023 * if eight { 64 } else { 32 };
if eight {
assert!(matches!(
m.render_frame(&mut Framebuffer::default()),
Err(VideoError::UnsupportedTileAddress(_))
));
} else {
tile(&mut m, address, 0x1111, 32);
assert_eq!(pixel(&m, 0, 0), 0xff0000);
}
}
}
#[test]
fn screen_map_outside_supported_bg_area_reports_error_without_wrapping() {
let mut m = setup();
m.write16(BG0CNT, 0x5f04).unwrap(); // Map block31, wide; tile base1.
m.write16(BG0HOFS, 256).unwrap(); // Second block lies outside the BG area.
let mut frame = Framebuffer::default();
frame.clear(0x001f);
assert_eq!(
m.render_frame(&mut frame),
Err(VideoError::UnsupportedMapAddress(0x10000))
);
assert!(frame.pixels().iter().all(|&p| p == 0xff0000));
}
#[test]
fn unsupported_features_leave_output_unchanged_and_forced_blank_bypasses_them() {
let mut m = setup();
let mut frame = Framebuffer::default();
frame.clear(0x001f);
m.write16(DISPCNT, 0x100).unwrap();
m.write16(BG0CNT, 0x100c).unwrap();
m.write16(VRAM + 0x8002, 1023).unwrap(); // Error after some valid pixels.
assert!(matches!(
m.render_frame(&mut frame),
Err(VideoError::UnsupportedTileAddress(_))
));
assert!(frame.pixels().iter().all(|&p| p == 0xff0000));
m.write16(DISPCNT, 0xf180).unwrap();
m.render_frame(&mut frame).unwrap();
assert!(frame.pixels().iter().all(|&p| p == 0xffffff));
}
fn assert_scene(
frame: &Framebuffer,
sx: usize,
sy: usize,
flipped: bool,
behind: bool,
rotated: bool,
zoomed: bool,
) {
for y in 0..160 {
for x in 0..240 {
let fx = (x + sx / 2) % 256;
let fy = (y + sy / 2) % 256;
let bx = (x + sx) % 512;
let by = (y + sy) % 512;
let cross = fx / 8 % 4 == 1 && fy / 8 % 4 == 1 && (fx % 8 == 3 || fy % 8 == 3);
let mut color = if cross {
0x03ff
} else {
[0x0260, 0x03a0, 0x7d20, 0x7e80]
[((bx / 32 + by / 32) % 2) * 2 + (bx / 2 + by / 2) % 2]
};
let affine = rotated || zoomed;
let source = if affine && (104..136).contains(&x) && (64..96).contains(&y) {
let a = f64::from(if rotated {
if zoomed {
90
} else {
181
}
} else {
128
});
let b = if rotated { a } else { 0.0 };
let dx = x as f64 - 120.0;
let dy = y as f64 - 80.0;
Some((
((a * dx + b * dy) / 256.0 + 8.0).floor() as i32,
((-b * dx + a * dy) / 256.0 + 8.0).floor() as i32,
))
} else if !affine && (112..128).contains(&x) && (72..88).contains(&y) {
Some((
if flipped {
127 - x as i32
} else {
x as i32 - 112
},
y as i32 - 72,
))
} else {
None
};
if let Some((tx, ty)) = source.filter(|&(tx, ty)| {
(2..14).contains(&tx) && (1..15).contains(&ty) && !(cross && behind)
}) {
color = if tx == 10 && ty == 7 {
0
} else if tx == 12 && (10..13).contains(&ty) {
0x03ff
} else if (5..10).contains(&ty) {
0x7fff
} else {
0x001f
};
}
assert_eq!(
frame.pixels()[y * WIDTH + x],
rgb555_to_rgb888(color),
"pixel {x},{y} scroll {sx},{sy}"
);
}
}
}
#[test]
fn cpu_tile_demo_copies_assets_scrolls_wraps_cancels_opposites_and_resets() {
use gba_rust::{
input::{Button, Buttons},
tile_demo::{TileDemo, TILE_STATE},
};
let mut demo = TileDemo::new().unwrap();
let mut frame = Framebuffer::default();
assert_eq!(demo.machine().memory().read16(VRAM).unwrap(), 0);
assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0);
let mut updates = 0;
for (buttons, x, y) in [
(Buttons::default(), 0, 0),
(
Buttons::default()
.with(Button::Left, true)
.with(Button::Up, true),
510,
510,
),
(Buttons::from_bits(0xf0), 510, 510),
(
Buttons::default()
.with(Button::Right, true)
.with(Button::Down, true),
0,
0,
),
(
Buttons::default()
.with(Button::Right, true)
.with(Button::Down, true),
2,
2,
),
(
Buttons::default()
.with(Button::Start, true)
.with(Button::Right, true),
0,
0,
),
] {
let before = demo.machine().memory().display_position();
demo.frame(buttons, &mut frame).unwrap();
updates += 1;
assert_scene(&frame, x, y, false, false, false, false);
let m = demo.machine().memory();
assert_eq!(m.read32(TILE_STATE).unwrap(), updates);
assert_eq!(m.read32(TILE_STATE + 4).unwrap(), x as u32);
assert_eq!(m.read32(TILE_STATE + 8).unwrap(), y as u32);
assert_eq!(m.read16(DISPCNT).unwrap(), 0x1340);
assert_eq!(m.display_position().scanline, 160);
if updates > 1 {
assert_eq!(m.display_position().vblanks, before.vblanks + 1);
}
}
}
#[test]
fn cpu_sprite_controls_flip_and_change_priority_through_oam() {
use gba_rust::{
input::{Button, Buttons},
memory::OAM_START,
tile_demo::TileDemo,
};
let mut demo = TileDemo::new().unwrap();
let mut frame = Framebuffer::default();
for _ in 0..16 {
demo.frame(Buttons::default().with(Button::Right, true), &mut frame)
.unwrap();
}
for (flipped, behind) in [
(false, false),
(true, false),
(true, true),
(false, true),
(false, false),
] {
demo.frame(
Buttons::default()
.with(Button::A, flipped)
.with(Button::B, behind),
&mut frame,
)
.unwrap();
assert_scene(&frame, 32, 0, flipped, behind, false, false);
let m = demo.machine().memory();
assert_eq!(m.read16(OAM_START).unwrap(), 72);
assert_eq!(
m.read16(OAM_START + 2).unwrap(),
0x4070 | if flipped { 0x1000 } else { 0 }
);
assert_eq!(
m.read16(OAM_START + 4).unwrap(),
if behind { 0x400 } else { 0 }
);
for i in 1..128 {
assert_eq!(m.read16(OAM_START + i * 8).unwrap(), 0x200);
}
}
}
#[test]
fn cpu_affine_controls_write_matrices_preserve_center_and_return_to_regular_mode() {
use gba_rust::{
input::{Button, Buttons},
memory::OAM_START,
tile_demo::TileDemo,
};
let mut demo = TileDemo::new().unwrap();
let mut frame = Framebuffer::default();
// Put foreground crosses over the sprite so X also tests affine composition.
for _ in 0..16 {
demo.frame(Buttons::default().with(Button::Right, true), &mut frame)
.unwrap();
}
for bits in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0] {
let flipped = bits & 1 != 0;
let behind = bits & 2 != 0;
let rotated = bits & 4 != 0;
let zoomed = bits & 8 != 0;
let buttons = Buttons::default()
.with(Button::A, flipped)
.with(Button::B, behind)
.with(Button::L, rotated)
.with(Button::R, zoomed);
let before = demo.machine().memory().display_position();
demo.frame(buttons, &mut frame).unwrap();
assert_scene(&frame, 32, 0, flipped, behind, rotated, zoomed);
let m = demo.machine().memory();
let affine = rotated || zoomed;
assert_eq!(
m.read16(OAM_START).unwrap(),
if affine { 0x340 } else { 72 }
);
assert_eq!(
m.read16(OAM_START + 2).unwrap(),
if affine {
0x4068
} else {
0x4070 | if flipped { 0x1000 } else { 0 }
}
);
let a: i16 = match (rotated, zoomed) {
(false, false) => 256,
(false, true) => 128,
(true, false) => 181,
(true, true) => 90,
};
let b = if rotated { a } else { 0 };
for (i, value) in [a, b, -b, a].into_iter().enumerate() {
assert_eq!(
m.read16(OAM_START + 6 + i as u32 * 8).unwrap(),
value as u16
);
}
for i in 1..128 {
assert_eq!(m.read16(OAM_START + i * 8).unwrap(), 0x200);
}
assert_eq!(m.display_position().vblanks, before.vblanks + 1);
assert_eq!(m.display_position().scanline, 160);
}
}
#[test]
fn green_swap_runs_after_tile_compositing() {
let mut m = setup();
m.write16(VRAM, 0x21).unwrap();
m.write16(GREENSWAP, 1).unwrap();
assert_eq!(pixel(&m, 0, 0), 0xffff00);
assert_eq!(pixel(&m, 1, 0), 0);
}