feat(video): render tiled affine and bitmap backgrounds

Add Mode 0-5 composition, palettes, tile maps, affine sampling, and framebuffer diagnostics.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit fad034ae74
5 changed files with 1953 additions and 0 deletions

381
src/video.rs Normal file
View file

@ -0,0 +1,381 @@
//! Platform-independent Mode 0–5 rendering for snapshots and row capture.
//! Includes window masks, mosaic, and color effects. Horizontal window history
//! has four-cycle timing. Sprites prepare one row ahead; backgrounds, palettes,
//! and color effects still sample once per displayed row.
mod affine;
pub(crate) mod capture;
pub(crate) mod effects;
pub(crate) mod mosaic;
pub(crate) mod sprites;
pub(crate) mod windows;
use std::{error::Error, fmt};
pub const WIDTH: usize = 240;
pub const HEIGHT: usize = 160;
/// Convert GBA RGB555 (red in bits 0..4) into packed 0x00RRGGBB.
/// Bit 15 is unused. Replication expands five-bit channels to eight bits.
pub fn rgb555_to_rgb888(color: u16) -> u32 {
let expand = |channel: u16| u32::from((channel << 3) | (channel >> 2));
let red = expand(color & 31);
let green = expand((color >> 5) & 31);
let blue = expand((color >> 10) & 31);
(red << 16) | (green << 8) | blue
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VideoError {
UnsupportedMode(u8),
UnsupportedLayers(u16),
UnsupportedTileAddress(usize),
UnsupportedMapAddress(usize),
UnsupportedObject { index: usize, reason: &'static str },
}
impl fmt::Display for VideoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedMode(mode) => write!(
f,
"unsupported display mode {mode}; only Modes 0 through 5 are rendered"
),
Self::UnsupportedTileAddress(address) => write!(
f,
"text background tile address {address:#x} exceeds the supported 64 KiB BG area"
),
Self::UnsupportedMapAddress(address) => write!(
f,
"background map address {address:#x} exceeds the supported 64 KiB BG area"
),
Self::UnsupportedObject { index, reason } => write!(f, "OBJ{index}: {reason}"),
Self::UnsupportedLayers(control) => {
write!(f, "unsupported display layers in DISPCNT {control:#06x}")
}
}
}
}
impl Error for VideoError {}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Background {
pub control: u16,
pub x: u16,
pub y: u16,
pub affine: affine::AffineBackground,
}
pub(crate) struct RenderSettings<'a> {
pub control: u16,
pub prepared_objects: Option<&'a Result<sprites::PreparedObjects, VideoError>>,
pub internal_affine: bool,
pub vertical_windows: Option<[bool; 2]>,
pub horizontal_windows: Option<&'a windows::horizontal::HorizontalWindows>,
pub green_swap: bool,
pub mosaic: u16,
pub vertical_mosaic: Option<mosaic::VerticalMosaic>,
pub backgrounds: &'a [Background; 4],
pub effects: &'a effects::Effects,
}
#[derive(Clone, Copy)]
struct Pixel {
color: u16,
layer: u8, // BG0..3, OBJ=4, backdrop=5.
priority: u8,
semi_transparent: bool,
}
impl Pixel {
fn order(self) -> (u8, u8) {
// OBJ wins a tie against BG; lower BG number wins other ties.
(
self.priority,
if self.layer == 4 { 0 } else { self.layer + 1 },
)
}
}
/// Snapshot the current registers and memory. Errors leave the output unchanged.
/// Memory owns fixed-size 96 KiB VRAM, 1 KiB palette, and 1 KiB OAM buffers.
pub(crate) fn render(
settings: RenderSettings<'_>,
vram: &[u8],
palette: &[u8],
oam: &[u8],
frame: &mut Framebuffer,
) -> Result<(), VideoError> {
frame.pixels = render_rows(settings, vram, palette, oam, 0..HEIGHT)?;
Ok(())
}
/// Render the requested rows from current registers and optional scanline state.
/// Row capture uses prepared sprites, affine origins, window history, and mosaic counters.
/// Debug snapshots use programmed origins, geometric window bounds, and screen-grid mosaic.
pub(crate) fn render_rows(
settings: RenderSettings<'_>,
vram: &[u8],
palette: &[u8],
oam: &[u8],
rows: std::ops::Range<usize>,
) -> Result<Vec<u32>, VideoError> {
debug_assert!(rows.start <= rows.end && rows.end <= HEIGHT);
let length = rows.len() * WIDTH;
let RenderSettings {
control,
prepared_objects,
internal_affine,
vertical_windows,
horizontal_windows,
green_swap,
mosaic,
vertical_mosaic,
backgrounds,
effects,
} = settings;
if control & 0x80 != 0 {
return Ok(vec![0xffffff; length]);
}
let backdrop = Pixel {
color: halfword(palette, 0),
layer: 5,
priority: 4,
semi_transparent: false,
};
let mut output = vec![0; length];
let mode = control & 7;
let unsupported = match mode {
0 => 0,
1 => 0x800, // BG0/1 text, BG2 affine.
2 => 0x300, // BG2/3 affine.
3..=5 => 0xb00, // BG2 bitmap.
_ => return Err(VideoError::UnsupportedMode(mode as u8)),
};
if control & unsupported != 0 {
return Err(VideoError::UnsupportedLayers(control));
}
let mut layers: Vec<usize> = (0..4).filter(|bg| control & (0x100 << bg) != 0).collect();
// Lower priority number wins. BG0 wins ties, then BG1, BG2, BG3.
layers.sort_by_key(|&bg| (backgrounds[bg].control & 3, bg));
let objects = if control & 0x1000 == 0 {
sprites::Objects {
pixels: vec![None; length],
window: vec![false; length],
}
} else if let Some(prepared) = prepared_objects {
debug_assert_eq!(rows.len(), 1);
prepared
.as_ref()
.map_err(Clone::clone)?
.compose(mosaic, palette)
} else {
sprites::prepare(control, mosaic, None, oam, vram, rows.clone())?.compose(mosaic, palette)
};
let mosaic_width = usize::from(mosaic & 15) + 1;
let mosaic_height = usize::from((mosaic >> 4) & 15) + 1;
for y in rows.clone() {
for x in 0..WIDTH {
let offset = (y - rows.start) * WIDTH + x;
let mask = effects.mask(
control,
x,
y,
objects.window[offset],
vertical_windows,
horizontal_windows,
);
let mut top = backdrop;
let mut below = None;
let mut insert = |pixel: Pixel| {
if pixel.order() < top.order() {
below = Some(top);
top = pixel;
} else if below.is_none_or(|second: Pixel| pixel.order() < second.order()) {
below = Some(pixel);
}
};
for &bg in &layers {
// Validate enabled background fetches even when a window hides them.
// Mosaic samples before scroll or affine mapping. Captured vertical
// phase comes from a counter; horizontal blocks remain screen-aligned.
// Window selection still uses the actual output coordinate.
let (sample_x, sample_y) = if backgrounds[bg].control & 0x40 != 0 {
let counter =
vertical_mosaic.map_or(y % mosaic_height, |v| usize::from(v.background));
(x - x % mosaic_width, y - counter)
} else {
(x, y)
};
let color = if mode == 0 || (mode == 1 && bg < 2) {
text_pixel(backgrounds[bg], vram, palette, sample_x, sample_y)?
} else {
affine::pixel(
backgrounds[bg],
vram,
palette,
sample_x,
sample_y,
control,
internal_affine,
)?
};
if let Some(color) = color.filter(|_| mask & (1 << bg) != 0) {
insert(Pixel {
color,
layer: bg as u8,
priority: (backgrounds[bg].control & 3) as u8,
semi_transparent: false,
});
}
}
if let Some(object) = objects.pixels[offset].filter(|_| mask & 0x10 != 0) {
insert(object);
}
output[offset] = rgb555_to_rgb888(effects.apply(top, below, mask & 0x20 != 0));
}
}
if green_swap {
for pair in output.chunks_exact_mut(2) {
let green_difference = (pair[0] ^ pair[1]) & 0x0000_ff00;
pair[0] ^= green_difference;
pair[1] ^= green_difference;
}
}
Ok(output)
}
fn halfword(bytes: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
}
fn text_pixel(
bg: Background,
vram: &[u8],
palette: &[u8],
x: usize,
y: usize,
) -> Result<Option<u16>, VideoError> {
let control = usize::from(bg.control);
let width = if control & 0x4000 != 0 { 512 } else { 256 };
let height = if control & 0x8000 != 0 { 512 } else { 256 };
// Text backgrounds always wrap; the affine overflow bit has no effect.
let x = (x + usize::from(bg.x)) % width;
let y = (y + usize::from(bg.y)) % height;
let block = x / 256 + (y / 256) * (width / 256);
let map = ((control >> 8) & 31) * 0x800 + block * 0x800 + ((y / 8 % 32) * 32 + x / 8 % 32) * 2;
if map >= 0x10000 {
return Err(VideoError::UnsupportedMapAddress(map));
}
let entry = usize::from(halfword(vram, map));
let tx = (x & 7) ^ if entry & 0x400 != 0 { 7 } else { 0 };
let ty = (y & 7) ^ if entry & 0x800 != 0 { 7 } else { 0 };
let eight_bit = control & 0x80 != 0;
let pixel = ty * 8 + tx;
let address = ((control >> 2) & 3) * 0x4000
+ (entry & 0x3ff) * if eight_bit { 64 } else { 32 }
+ if eight_bit { pixel } else { pixel / 2 };
// The real PPU has special behavior outside BG VRAM. Do not read OBJ tiles
// as ordinary BG data or silently wrap this unsupported case.
if address >= 0x10000 {
return Err(VideoError::UnsupportedTileAddress(address));
}
let index = if eight_bit {
usize::from(vram[address])
} else {
usize::from((vram[address] >> ((pixel & 1) * 4)) & 15)
};
if index == 0 {
return Ok(None);
}
let bank = if eight_bit { 0 } else { (entry >> 12) * 16 };
Ok(Some(halfword(palette, (bank + index) * 2)))
}
pub struct Framebuffer {
pixels: Vec<u32>,
}
impl Default for Framebuffer {
fn default() -> Self {
Self {
pixels: vec![0; WIDTH * HEIGHT],
}
}
}
impl Framebuffer {
/// Packed 0x00RRGGBB pixels in row-major order.
pub fn pixels(&self) -> &[u32] {
&self.pixels
}
pub fn clear(&mut self, color: u16) {
self.pixels.fill(rgb555_to_rgb888(color));
}
/// Write a GBA RGB555 color. Out-of-bounds coordinates return false.
pub fn set_pixel(&mut self, x: usize, y: usize, color: u16) -> bool {
if x >= WIDTH || y >= HEIGHT {
return false;
}
self.pixels[y * WIDTH + x] = rgb555_to_rgb888(color);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_gba_channel_order_and_ignores_unused_bit() {
for (color, expected) in [
(0, 0),
(0x001f, 0x00ff_0000),
(0x03e0, 0x0000_ff00),
(0x7c00, 0x0000_00ff),
(0x7fff, 0x00ff_ffff),
(0xffff, 0x00ff_ffff),
(0x8000, 0),
(0x4210, 0x0084_8484),
] {
assert_eq!(rgb555_to_rgb888(color), expected);
}
}
#[test]
fn starts_with_a_black_gba_sized_buffer() {
let frame = Framebuffer::default();
assert_eq!(frame.pixels().len(), 240 * 160);
assert!(frame.pixels().iter().all(|pixel| *pixel == 0));
}
#[test]
fn writes_row_major_pixels_including_last_pixel() {
let mut frame = Framebuffer::default();
assert!(frame.set_pixel(1, 2, 0x001f));
assert!(frame.set_pixel(WIDTH - 1, HEIGHT - 1, 0x03e0));
assert_eq!(frame.pixels()[2 * WIDTH + 1], 0x00ff_0000);
assert_eq!(frame.pixels()[WIDTH * HEIGHT - 1], 0x0000_ff00);
assert_eq!(frame.pixels()[0], 0);
}
#[test]
fn clips_invalid_coordinates_without_changing_pixels() {
let mut frame = Framebuffer::default();
for (x, y) in [(WIDTH, 0), (0, HEIGHT), (usize::MAX, usize::MAX)] {
assert!(!frame.set_pixel(x, y, 0x7fff));
}
assert!(frame.pixels().iter().all(|pixel| *pixel == 0));
}
#[test]
fn clear_replaces_every_pixel() {
let mut frame = Framebuffer::default();
frame.set_pixel(0, 0, 0x001f);
frame.clear(0x7c00);
assert!(frame.pixels().iter().all(|pixel| *pixel == 0x0000_00ff));
}
}

266
src/video/affine.rs Normal file
View file

@ -0,0 +1,266 @@
//! Programmed affine BG registers and sampling for snapshots and row capture.
//! Internal origins advance at line boundaries. Pixel-fetch and per-access timing
//! remain outside this row-level model.
use super::{halfword, Background, VideoError, HEIGHT, WIDTH};
#[cfg(test)]
use crate::display::{CYCLES_PER_FRAME, CYCLES_PER_LINE};
const REFERENCE_MASK: u32 = 0x0fff_ffff;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AffineBackground {
matrix: [u16; 4],
reference: [u32; 2],
internal: [u32; 2],
// An HBlank write sets the next row's origin and overrides that row's increment.
reload_next: [bool; 2],
}
impl AffineBackground {
/// Merge into write-only latches, never into their zero readback placeholders.
pub(crate) fn write8(&mut self, offset: usize, value: u8, after_row: bool) {
if offset < 8 {
let word = &mut self.matrix[offset / 2];
let shift = (offset & 1) * 8;
*word = (*word & !(0xff << shift)) | (u16::from(value) << shift);
} else {
let axis = (offset - 8) / 4;
let word = &mut self.reference[axis];
let shift = (offset & 3) * 8;
*word = ((*word & !(0xff << shift)) | (u32::from(value) << shift)) & REFERENCE_MASK;
self.internal[axis] = *word;
self.reload_next[axis] = after_row;
}
}
/// Advance across line ends with fixed registers. Reload at the next visible
/// frame's line-zero boundary, so arbitrarily large batches remain O(1).
pub(crate) fn advance(
&mut self,
phase: u32,
cycles: u32,
active: bool,
mosaic_height: u32,
counter: u8,
) {
let progress = super::mosaic::progress(phase, cycles, counter, mosaic_height);
if progress.frame_reloaded {
self.internal = self.reference;
self.reload_next = [false; 2];
}
if !progress.crossed_line {
return;
}
// A mosaic-enabled affine origin advances only when the shared BG
// counter becomes zero. Height1/counter0 models non-mosaic backgrounds.
for (axis, coefficient) in [self.matrix[1], self.matrix[3]].into_iter().enumerate() {
if active {
let count =
progress.resets - u32::from(self.reload_next[axis] && progress.first_reset);
let delta = i32::from(coefficient as i16) * (count * mosaic_height) as i32;
self.internal[axis] =
self.internal[axis].wrapping_add(delta as u32) & REFERENCE_MASK;
}
self.reload_next[axis] = false;
}
}
fn source(self, x: usize, y: usize, internal: bool) -> (i32, i32) {
let [a, b, c, d] = self.matrix.map(|v| i32::from(v as i16));
let reference = if internal {
self.internal
} else {
self.reference
};
let [rx, ry] = reference.map(|v| ((v << 4) as i32) >> 4);
// Internal origins already include vertical progress.
let y = if internal { 0 } else { y };
// Products and sums fit i32 for a 240x160 output. Model the 28-bit
// fixed-point range, then floor with an arithmetic shift by eight.
let coordinate = |v: i32| ((v as u32) << 4) as i32 >> 12;
(
coordinate(rx + a * x as i32 + b * y as i32),
coordinate(ry + c * x as i32 + d * y as i32),
)
}
}
pub(super) fn pixel(
bg: Background,
vram: &[u8],
palette: &[u8],
x: usize,
y: usize,
display_control: u16,
internal: bool,
) -> Result<Option<u16>, VideoError> {
let (mut tx, mut ty) = bg.affine.source(x, y, internal);
let mode = display_control & 7;
if (3..=5).contains(&mode) {
let (width, height) = if mode == 5 {
(160, 128)
} else {
(WIDTH, HEIGHT)
};
// Bitmap overflow never wraps, even when BGCNT bit 13 is set.
if tx < 0 || ty < 0 || tx >= width as i32 || ty >= height as i32 {
return Ok(None);
}
// Mode 3 ignores the page bit. Modes 4/5 share the same 40 KiB page stride.
let page = if mode != 3 && display_control & 0x10 != 0 {
0xa000
} else {
0
};
let offset = ty as usize * width + tx as usize;
return Ok(if mode == 4 {
let index = usize::from(vram[page + offset]);
if index == 0 {
None
} else {
Some(halfword(palette, index * 2))
}
} else {
// Modes 3/5 store opaque RGB555, including black. Bit 15 is unused.
Some(halfword(vram, page + offset * 2))
});
}
let control = usize::from(bg.control);
let size = 128_i32 << (control >> 14);
if control & 0x2000 != 0 {
tx = tx.rem_euclid(size);
ty = ty.rem_euclid(size);
} else if tx < 0 || ty < 0 || tx >= size || ty >= size {
return Ok(None);
}
let (tx, ty) = (tx as usize, ty as usize);
let map = ((control >> 8) & 31) * 0x800 + (ty / 8) * (size as usize / 8) + tx / 8;
if map >= 0x10000 {
return Err(VideoError::UnsupportedMapAddress(map));
}
// Affine maps have byte-sized indices and no flip/palette-bank attributes.
// Every tile is 8bpp, regardless of BGCNT bit 7.
let address =
((control >> 2) & 3) * 0x4000 + usize::from(vram[map]) * 64 + (ty % 8) * 8 + tx % 8;
let index = usize::from(vram[address]); // Maximum address is 0xffff.
Ok(if index == 0 {
None
} else {
Some(halfword(palette, index * 2))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batched_tracking_matches_an_independent_line_boundary_model() {
let mut seed = 0x92ad_1037u32;
for _ in 0..500 {
let mut next = || {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
seed
};
let mut bg = AffineBackground {
matrix: [next() as u16, next() as u16, next() as u16, next() as u16],
reference: [next() & REFERENCE_MASK, next() & REFERENCE_MASK],
internal: [next() & REFERENCE_MASK, next() & REFERENCE_MASK],
reload_next: [next() & 1 != 0, next() & 2 != 0],
};
let phase = next() % CYCLES_PER_FRAME;
let cycles = next() % (CYCLES_PER_FRAME * 7);
let height = next() % 16 + 1;
let counter = (next() & 15) as u8;
let mut expected_counter = counter;
let active = next() & 8 != 0;
let mut expected = bg.internal.map(i64::from);
let mut pending = bg.reload_next;
let first = phase / CYCLES_PER_LINE + 1;
let last = (phase + cycles) / CYCLES_PER_LINE;
for boundary in first..=last {
let row = boundary % 228;
if row == 0 {
expected = bg.reference.map(i64::from);
expected_counter = 0;
} else if row <= 160 {
let next = u32::from(expected_counter) + 1;
expected_counter = if row == 160 || next == height {
0
} else {
(next & 15) as u8
};
for axis in 0..2 {
if active && expected_counter == 0 && !pending[axis] {
let coefficient = i64::from(bg.matrix[axis * 2 + 1] as i16);
expected[axis] = (expected[axis] + coefficient * i64::from(height))
.rem_euclid(1 << 28);
}
}
}
pending = [false; 2];
}
bg.advance(phase, cycles, active, height, counter);
assert_eq!(
bg.internal.map(i64::from),
expected,
"phase={phase} cycles={cycles} height={height} active={active}"
);
assert_eq!(bg.reload_next, pending);
}
}
#[test]
fn signed_accumulators_wrap_and_extreme_batches_reload_without_iteration() {
let mut bg = AffineBackground {
matrix: [256, 1, 0, (-1i16) as u16],
reference: [REFERENCE_MASK, 0],
internal: [REFERENCE_MASK, 0],
..Default::default()
};
bg.advance(0, CYCLES_PER_LINE, true, 1, 0);
assert_eq!(bg.internal, [0, REFERENCE_MASK]);
assert_eq!(bg.source(0, 0, true), (0, -1));
assert_eq!(bg.source(0, 0, false), (-1, 0));
let phase = 17 * CYCLES_PER_LINE + 413;
bg.advance(phase, u32::MAX, true, 1, 0);
let lines = ((u64::from(phase) + u64::from(u32::MAX)) % u64::from(CYCLES_PER_FRAME)
/ u64::from(CYCLES_PER_LINE))
.min(160);
assert_eq!(
bg.internal[0],
((i64::from(REFERENCE_MASK) + lines as i64) % (1 << 28)) as u32
);
assert_eq!(bg.internal[1], (-(lines as i64)).rem_euclid(1 << 28) as u32);
}
#[test]
fn reference_byte_writes_merge_programmed_values_not_accumulated_values() {
let mut bg = AffineBackground {
matrix: [256, 256, 0, 256],
reference: [0x1234500, 0x2000],
internal: [0x1234500, 0x2000],
..Default::default()
};
bg.advance(0, 10 * CYCLES_PER_LINE, true, 1, 0);
let y = bg.internal[1];
bg.write8(8, 0x7f, false);
assert_eq!(bg.reference[0], 0x123457f);
assert_eq!(bg.internal[0], 0x123457f);
assert_eq!(bg.internal[1], y);
bg.write8(11, 0xff, true);
assert_eq!(bg.reference[0], 0x0f23457f);
bg.advance(
10 * CYCLES_PER_LINE + 1006,
CYCLES_PER_LINE - 1006,
true,
1,
0,
);
assert_eq!(bg.internal[0], 0x0f23457f); // HBlank reload overrides this transition.
assert_eq!(bg.internal[1], y + 256); // Other axis still increments.
bg.write8(2, 0, false); // PB byte write must not reload X.
assert_eq!(bg.internal[0], 0x0f23457f);
}
}

379
tests/affine_backgrounds.rs Normal file
View file

@ -0,0 +1,379 @@
use gba_rust::{
io::*,
memory::{Memory, MemoryError, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM},
video::{rgb555_to_rgb888, Framebuffer, VideoError, 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 diagnostics_leave_output_unchanged_and_disabled_layers_skip_validation() {
let mut m = setup(2, 3, false);
let mut f = Framebuffer::default();
f.clear(0x001f);
for control in [0xc01, 0xd02, 0xe02] {
m.write16(DISPCNT, control).unwrap();
assert_eq!(
m.render_frame(&mut f),
Err(VideoError::UnsupportedLayers(control))
);
}
m.write16(DISPCNT, 0x402).unwrap();
m.write16(BG2CNT, 0xdf00).unwrap(); // Map31, 1024 pixels.
m.write32(BG2Y, 128 * 256).unwrap(); // Flat row starts outside first64K.
assert_eq!(
m.render_frame(&mut f),
Err(VideoError::UnsupportedMapAddress(0x10000))
);
assert!(f.pixels().iter().all(|&p| p == 0xff0000));
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 cpu_demo_pans_rotates_zooms_clips_cancels_opposites_and_resets() {
use gba_rust::{
affine_demo::{AffineDemo, AFFINE_STATE},
input::{Button, Buttons},
};
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());
}

446
tests/bitmap_modes.rs Normal file
View file

@ -0,0 +1,446 @@
use gba_rust::{
io::{BG2CNT, BG2PA, BG2X, BG2Y, DISPCNT, GREENSWAP},
memory::{Memory, OAM_START as OAM, PALETTE_START as PAL, VRAM_START as VRAM},
video::{rgb555_to_rgb888, Framebuffer, VideoError, WIDTH},
};
fn setup(mode: u16) -> Memory {
let mut m = Memory::new(vec![]).unwrap();
m.write16(DISPCNT, 0x400 | mode).unwrap();
transform(&mut m, [256, 0, 0, 256], [0, 0]);
m.write16(PAL, 0x4000).unwrap();
for i in 1..256 {
m.write16(PAL + i * 2, (i as u16 * 123) & 0x7fff).unwrap();
}
m
}
fn transform(m: &mut Memory, values: [i16; 4], origin: [i32; 2]) {
for (i, v) in values.into_iter().enumerate() {
m.write16(BG2PA + i as u32 * 2, v as u16).unwrap();
}
m.write32(BG2X, origin[0] as u32).unwrap();
m.write32(BG2Y, origin[1] 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 sprite(m: &mut Memory, mode: u16, priority: u16, affine: bool) {
for i in 0..128 {
m.write16(OAM + i * 8, 0x200).unwrap();
}
m.write16(OAM, if affine { 0x100 } else { 0 }).unwrap();
m.write16(OAM + 4, 512 | (priority << 10)).unwrap();
m.write16(OAM + 6, 256).unwrap();
m.write16(OAM + 30, 256).unwrap();
m.write16(VRAM + 0x14000, 0x1111).unwrap();
m.write16(PAL + 0x202, 0x03e0).unwrap();
m.write16(DISPCNT, 0x1440 | mode).unwrap();
}
#[test]
fn mode4_reads_adjacent_bytes_all_indices_and_both_page_boundaries() {
let mut m = setup(4);
for page in [0, 0xa000] {
m.write16(VRAM + page, 0xff01).unwrap();
m.write16(VRAM + page + 238, 0x0203).unwrap();
m.write16(VRAM + page + 240, 0x0405).unwrap();
m.write16(VRAM + page + 0x95fe, 0x0607).unwrap();
m.write16(DISPCNT, 0x404 | if page == 0 { 0 } else { 0x10 })
.unwrap();
for (x, y, index) in [
(0, 0, 1),
(1, 0, 255),
(239, 0, 2),
(0, 1, 5),
(239, 159, 6),
] {
assert_eq!(pixel(&m, x, y), rgb555_to_rgb888((index * 123) & 0x7fff));
}
}
for i in (0..256).step_by(2) {
m.write16(VRAM + i, (i as u16) | ((i as u16 + 1) << 8))
.unwrap();
}
m.write16(DISPCNT, 0x404).unwrap();
let f = frame(&m);
for i in 0..256 {
assert_eq!(
f.pixels()[i],
rgb555_to_rgb888(if i == 0 {
0x4000
} else {
(i as u16 * 123) & 0x7fff
})
);
}
}
#[test]
fn mode4_page_stride_is_40k_and_inactive_page_writes_do_not_change_output() {
let mut m = setup(4);
m.write16(VRAM, 0x0101).unwrap();
m.write16(VRAM + 0x9600, 0x0303).unwrap(); // Padding is not page1.
let before = frame(&m);
m.write16(VRAM + 0xa000, 0x0202).unwrap();
assert_eq!(frame(&m).pixels(), before.pixels());
m.write8(DISPCNT, 0x14).unwrap(); // Byte write selects page1 without losing BG2 enable.
assert_eq!(pixel(&m, 0, 0), rgb555_to_rgb888(246));
m.write16(PAL + 4, 0x001f).unwrap();
assert_eq!(pixel(&m, 0, 0), 0xff0000); // Palette is live, not cached per page.
m.write8(DISPCNT, 4).unwrap();
assert_eq!(frame(&m).pixels(), before.pixels());
}
#[test]
fn mode5_uses_160_by_128_rgb555_pages_and_leaves_remainder_transparent() {
let mut m = setup(5);
for (page, color) in [(0, 0x801f), (0xa000, 0x83e0)] {
m.write16(VRAM + page, color).unwrap();
m.write16(VRAM + page + 318, 0x7c00).unwrap();
m.write16(VRAM + page + 320, 0x7fff).unwrap();
m.write16(VRAM + page + 0x9ffe, color).unwrap();
}
for (control, color) in [(0x405, 0xff0000), (0x415, 0x00ff00)] {
m.write16(DISPCNT, control).unwrap();
let f = frame(&m);
assert_eq!(f.pixels()[0], color);
assert_eq!(f.pixels()[159], 0x0000ff);
assert_eq!(f.pixels()[WIDTH], 0xffffff);
assert_eq!(f.pixels()[127 * WIDTH + 159], color);
for y in 0..160 {
for x in 0..240 {
if x >= 160 || y >= 128 {
assert_eq!(f.pixels()[y * WIDTH + x], rgb555_to_rgb888(0x4000));
}
}
}
}
}
#[test]
fn mode4_zero_index_is_transparent_but_nonzero_black_is_opaque() {
for affine in [false, true] {
let mut m = setup(4);
sprite(&mut m, 4, 3, affine);
m.write16(VRAM, 0x0100).unwrap();
m.write16(PAL + 2, 0).unwrap();
assert_eq!(pixel(&m, 0, 0), 0x00ff00); // Index0 reveals lower-priority sprite.
assert_eq!(pixel(&m, 1, 0), 0); // Black index1 still covers it.
m.write16(BG2CNT, 3).unwrap();
assert_eq!(pixel(&m, 1, 0), 0x00ff00); // OBJ wins equal priority.
}
}
#[test]
fn mode5_black_is_opaque_and_ignores_palette_while_outside_pixels_reveal_objects() {
let mut m = setup(5);
sprite(&mut m, 5, 3, false);
m.write16(VRAM, 0).unwrap();
m.write16(PAL, 0x7fff).unwrap();
assert_eq!(pixel(&m, 0, 0), 0);
m.write32(BG2X, u32::MAX).unwrap(); // Source X=-1/256 => first output column outside.
assert_eq!(pixel(&m, 0, 0), 0x00ff00);
assert_eq!(pixel(&m, 1, 0), 0);
}
#[test]
fn bitmap_sprites_use_upper_obj_area_independent_of_page() {
for mode in [4, 5] {
let mut m = setup(mode);
sprite(&mut m, mode, 0, false);
m.write16(VRAM + 0x10000, 0x1111).unwrap();
m.write16(OAM + 4, 0).unwrap();
assert_ne!(pixel(&m, 0, 0), 0x00ff00); // Lower512 OBJ tiles unavailable.
m.write16(OAM + 4, 512).unwrap();
for page in [0, 0x10] {
m.write16(DISPCNT, 0x1440 | mode | page).unwrap();
assert_eq!(pixel(&m, 0, 0), 0x00ff00);
}
assert_eq!(m.read16(VRAM + 0x14000).unwrap(), 0x1111);
}
}
#[test]
fn bitmap_byte_writes_duplicate_pixels_but_never_modify_obj_tiles() {
for mode in [4, 5] {
let mut m = setup(mode);
for address in [VRAM, VRAM + 0xa001, VRAM + 0x13fff] {
m.write8(address, 0x5a).unwrap();
assert_eq!(m.read16(address & !1).unwrap(), 0x5a5a);
}
m.write16(VRAM + 0x14000, 0x1234).unwrap();
m.write8(VRAM + 0x1c000, 0xff).unwrap(); // OBJ mirror.
assert_eq!(m.read16(VRAM + 0x14000).unwrap(), 0x1234);
}
}
#[test]
fn affine_bitmap_sampling_matches_wide_integer_reference_on_both_pages() {
for mode in [4, 5] {
let mut m = setup(mode);
let (w, h) = if mode == 4 { (240, 160) } else { (160, 128) };
for page in 0..2_u32 {
if mode == 4 {
for offset in (0..w * h).step_by(2) {
let lo = ((offset * 13 + page * 73) & 255) as u16;
let hi = (((offset + 1) * 13 + page * 73) & 255) as u16;
m.write16(VRAM + page * 0xa000 + offset, lo | (hi << 8))
.unwrap();
}
} else {
for offset in 0..w * h {
m.write16(
VRAM + page * 0xa000 + offset * 2,
((offset * 37 + page * 127) & 0xffff) as u16,
)
.unwrap();
}
}
}
for page in 0..2_u32 {
m.write16(DISPCNT, 0x400 | mode | (page as u16 * 16))
.unwrap();
m.write16(BG2CNT, 0xff8f).unwrap(); // Ignored map/size/overflow/color fields; mosaic clear.
for (coeff, origin) in [
([256, 0, 0, 256], [0, 0]),
([181, 181, -181, 181], [-173, 20001]),
([128, 0, 0, 128], [127, -129]),
(
[-256, 0, 0, -256],
[(w as i32 - 1) * 256, (h as i32 - 1) * 256],
),
([256, 73, -129, 512], [-512, 127]),
([0; 4], [256, 256]),
] {
transform(&mut m, coeff, origin);
let f = frame(&m);
for y in 0..160 {
for x in 0..240 {
let tx = (i64::from(origin[0])
+ i64::from(coeff[0]) * x as i64
+ i64::from(coeff[1]) * y as i64)
.div_euclid(256);
let ty = (i64::from(origin[1])
+ i64::from(coeff[2]) * x as i64
+ i64::from(coeff[3]) * y as i64)
.div_euclid(256);
let color = if tx < 0 || ty < 0 || tx >= i64::from(w) || ty >= i64::from(h)
{
0x4000
} else {
let offset = ty as u32 * w + tx as u32;
if mode == 4 {
let i = ((offset * 13 + page * 73) & 255) as u16;
if i == 0 {
0x4000
} else {
(i * 123) & 0x7fff
}
} else {
((offset * 37 + page * 127) & 0x7fff) as u16
}
};
assert_eq!(
f.pixels()[y * WIDTH + x],
rgb555_to_rgb888(color),
"mode {mode}, page {page}, pixel {x},{y}"
);
}
}
}
}
}
}
#[test]
fn mode5_can_scale_to_full_lcd_without_implicit_stretching() {
let mut m = setup(5);
for offset in 0..160 * 128 {
m.write16(VRAM + offset * 2, 0x001f).unwrap();
}
assert_eq!(pixel(&m, 239, 159), rgb555_to_rgb888(0x4000));
transform(&mut m, [170, 0, 0, 204], [0, 0]); // Approximate 160/240 and 128/160 sampling steps.
assert!(frame(&m).pixels().iter().all(|&p| p == 0xff0000));
}
#[test]
fn bitmap_green_swap_disabled_bg_and_forced_blank_remain_consistent() {
for mode in [4, 5] {
let mut m = setup(mode);
m.write16(PAL + 2, 0x001f).unwrap();
m.write16(PAL + 4, 0x03e0).unwrap();
if mode == 4 {
m.write16(VRAM, 0x0201).unwrap();
} else {
m.write32(VRAM, 0x03e0_001f).unwrap();
}
m.write16(GREENSWAP, 1).unwrap();
assert_eq!(&frame(&m).pixels()[..2], &[0xffff00, 0]);
m.write16(DISPCNT, mode | 0x10).unwrap();
assert!(frame(&m)
.pixels()
.iter()
.all(|&p| p == rgb555_to_rgb888(0x4000)));
m.write16(DISPCNT, 0xffff).unwrap();
assert!(frame(&m).pixels().iter().all(|&p| p == 0xffffff));
}
}
fn assert_demo_frame(
f: &Framebuffer,
mode: gba_rust::bitmap_demo::BitmapMode,
pan: (i32, i32),
rotated: bool,
zoomed: bool,
page: usize,
) {
use gba_rust::bitmap_demo::BitmapMode;
let (w, h) = if mode == BitmapMode::Mode4 {
(240, 160)
} else {
(160, 128)
};
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 = (f64::from(w / 2 + pan.0) + a * dx + b * dy).floor() as i32;
let ty = (f64::from(h / 2 + pan.1) - b * dx + a * dy).floor() as i32;
let color = if tx < 0 || ty < 0 || tx >= w || ty >= h {
0x4000
} else {
let (tx, ty) = (tx as usize, ty as usize);
if mode == BitmapMode::Mode4 {
let i = (tx / 8 + 3 * (ty / 8) + page * 64) % 256;
if i == 0 {
0x4000
} else {
((i % 32) | ((i / 8 % 32) << 5) | ((i * 3 % 32) << 10)) as u16
}
} else {
(((tx / 5 + page * 11) % 32)
| (((ty / 4 + page * 7) % 32) << 5)
| (((tx / 8 + ty / 8 + page * 5) % 32) << 10)) as u16
}
};
assert_eq!(
f.pixels()[y * WIDTH + x],
rgb555_to_rgb888(color),
"{mode:?} page{page}, pixel{x},{y}, pan={pan:?}"
);
}
}
}
#[test]
fn cpu_demos_copy_both_pages_and_flip_during_vblank_every_32_updates() {
use gba_rust::{
bitmap_demo::{BitmapDemo, BitmapMode, BITMAP_STATE},
input::Buttons,
};
for mode in [BitmapMode::Mode4, BitmapMode::Mode5] {
let mut demo = BitmapDemo::new(mode).unwrap();
let mut f = Framebuffer::default();
assert_eq!(demo.machine().memory().read16(DISPCNT).unwrap(), 0);
assert_eq!(demo.machine().memory().read16(VRAM + 0xa000).unwrap(), 0);
for updates in 1..=65 {
let before = demo.machine().memory().display_position();
demo.frame(Buttons::default(), &mut f).unwrap();
let page = (updates / 32) % 2;
let m = demo.machine().memory();
assert_eq!(
m.read16(DISPCNT).unwrap(),
0x400 | mode as u16 | (page as u16 * 16)
);
assert_eq!(m.read32(BITMAP_STATE).unwrap(), updates as u32);
assert_eq!(m.display_position().scanline, 160);
if updates > 1 {
assert_eq!(m.display_position().vblanks, before.vblanks + 1);
}
assert_demo_frame(&f, mode, (0, 0), false, false, page);
}
}
}
#[test]
fn cpu_bitmap_controls_pan_rotate_zoom_force_page_and_reset() {
use gba_rust::{
bitmap_demo::{BitmapDemo, BitmapMode, BITMAP_STATE},
input::{Button, Buttons},
};
for mode in [BitmapMode::Mode4, BitmapMode::Mode5] {
let mut demo = BitmapDemo::new(mode).unwrap();
let mut f = Framebuffer::default();
demo.frame(
Buttons::default()
.with(Button::Left, true)
.with(Button::Up, true),
&mut f,
)
.unwrap();
demo.frame(Buttons::from_bits(0xf0), &mut f).unwrap();
for bits in 0..8 {
let rotated = bits & 1 != 0;
let zoomed = bits & 2 != 0;
let page = usize::from(bits & 4 != 0);
demo.frame(
Buttons::default()
.with(Button::L, rotated)
.with(Button::R, zoomed)
.with(Button::A, page != 0),
&mut f,
)
.unwrap();
assert_demo_frame(&f, mode, (-2, -2), rotated, zoomed, page);
assert_eq!(
demo.machine().memory().read32(BITMAP_STATE + 4).unwrap() as i32,
-2
);
assert_eq!(
demo.machine().memory().read32(BITMAP_STATE + 8).unwrap() as i32,
-2
);
}
demo.frame(
Buttons::default()
.with(Button::Start, true)
.with(Button::Right, true),
&mut f,
)
.unwrap();
assert_demo_frame(&f, mode, (0, 0), false, false, 0);
assert_eq!(demo.machine().memory().read32(BITMAP_STATE + 4).unwrap(), 0);
assert_eq!(demo.machine().memory().read32(BITMAP_STATE + 8).unwrap(), 0);
}
}
#[test]
fn unsupported_bitmap_layers_leave_frame_unchanged() {
for mode in [4, 5] {
let mut m = setup(mode);
let mut f = Framebuffer::default();
f.clear(0x001f);
for extra in [0x100, 0x200, 0x800] {
let control = 0x410 | mode | extra;
m.write16(DISPCNT, control).unwrap();
assert_eq!(
m.render_frame(&mut f),
Err(VideoError::UnsupportedLayers(control))
);
}
assert!(f.pixels().iter().all(|&p| p == 0xff0000));
assert_eq!(m.cycles(), 0);
}
}

481
tests/tiles.rs Normal file
View file

@ -0,0 +1,481 @@
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);
}