gameboy-rust/crates/gba-core/src/video.rs
0xrsydn f8a0676748 refactor: split into gameboy-rust workspace with core, demos, and desktop crates
Move the emulator into crates/gba-core, the original demo programs into
crates/gba-demos, and the minifb frontend into crates/desktop. The binary
is now gameboy-rust. Imports change from gba_rust to gba_core/gba_demos;
no behavior changes.

gba-core has no dependencies. Its integration tests use gba-demos as a
development dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:09:46 +07:00

381 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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