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