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:
parent
bb6784615a
commit
fad034ae74
5 changed files with 1953 additions and 0 deletions
381
src/video.rs
Normal file
381
src/video.rs
Normal 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
266
src/video/affine.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue