feat(video): bound per-row sprite preparation work

Apply nominal 1210/954-cycle allowances, affine setup costs, OAM-order allocation, and partial sprite prefixes.
This commit is contained in:
0xrsydn 2026-10-05 12:55:35 +07:00
commit bfab05b34d
3 changed files with 452 additions and 7 deletions

View file

@ -1,6 +1,7 @@
//! Regular and affine OBJ sampling.
//! Regular and affine OBJ sampling with a nominal per-row work allowance.
//! Individual fetch timing and OAM contention are not modeled.
mod budget;
pub(crate) mod pipeline;
use super::{halfword, Pixel, VideoError, WIDTH};
@ -53,6 +54,7 @@ pub(crate) fn prepare(
return Ok(output);
}
let samples = &mut output.samples;
let mut budgets = vec![budget::Budget::new(control); rows.len()];
let mosaic_height = usize::from(mosaic >> 12) + 1;
let one_dimensional = control & 0x40 != 0;
for index in 0..128 {
@ -60,7 +62,10 @@ pub(crate) fn prepare(
let b = usize::from(halfword(oam, index * 8 + 2));
let c = usize::from(halfword(oam, index * 8 + 4));
if a & 0x300 == 0x200 {
continue; // Disabled regular OBJ.
for budget in &mut budgets {
budget.skip();
}
continue; // Disabled regular OBJ still consumes nominal OAM inspection.
}
let reason = if a & 0xc00 == 0xc00 {
Some("prohibited OBJ mode")
@ -89,17 +94,27 @@ pub(crate) fn prepare(
};
let eight_bit = a & 0x2000 != 0;
let mut tile = c & 0x3ff;
if control & 7 >= 3 && tile < 512 {
continue; // Bitmap modes reserve the first half of OBJ tile memory.
}
// Restricted bitmap tiles produce no pixels, but active sprite work
// still uses the allowance. They are not disabled OAM entries.
let tile_visible = !(control & 7 >= 3 && tile < 512);
if eight_bit && !one_dimensional {
tile &= !1; // 2D 8bpp tiles are aligned to pairs of 32-byte slots.
}
let slot_width = if eight_bit { 2 } else { 1 };
let priority = ((c >> 10) & 3) as u8;
for y in rows.clone() {
let budget = &mut budgets[y - rows.start];
let sy = (y + 256 - (a & 255)) & 255;
if sy >= canvas_height {
let left = b & 511;
let intersects_screen = left < WIDTH || left + canvas_width > 512;
if sy >= canvas_height || !intersects_screen {
budget.skip();
continue;
}
// Charge before texel lookup or priority/window masking. Transparent,
// hidden, and out-of-texture samples still require preparation work.
let columns = budget.draw(canvas_width, affine);
if !tile_visible {
continue;
}
// Preparation latches the ahead-of-display OBJ counter; snapshots
@ -111,7 +126,7 @@ pub(crate) fn prepare(
} else {
sy
};
for sx in 0..canvas_width {
for sx in 0..columns {
let x = ((b & 511) + sx) & 511;
if x >= WIDTH {
continue;

124
src/video/sprites/budget.rs Normal file
View file

@ -0,0 +1,124 @@
//! Nominal per-row OBJ work allowance, not a timed OAM/VRAM fetch scheduler.
//! GBATEK gives 1210/954 cycles and costs of width or 10+2*width.
//! Inactive entries cost two inspection cycles, following emulator references.
//! Active costs include that inspection; clipped active canvases cost full width.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Budget {
remaining: usize,
}
impl Budget {
pub fn new(control: u16) -> Self {
Self {
remaining: if control & 0x20 != 0 { 954 } else { 1210 },
}
}
pub fn skip(&mut self) {
self.remaining = self.remaining.saturating_sub(2);
}
/// Return the prepared prefix of the drawing canvas. Unfinished work consumes
/// the remaining allowance; it cannot be reused by a later, cheaper object.
pub fn draw(&mut self, width: usize, affine: bool) -> usize {
if self.remaining < 2 {
self.remaining = 0;
return 0;
}
let (setup, per_pixel) = if affine { (10, 2) } else { (0, 1) };
let pixels = (self.remaining.saturating_sub(setup) / per_pixel).min(width);
self.remaining = self.remaining.saturating_sub(setup + per_pixel * width);
pixels
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_allowance_and_width_matches_independent_cycle_consumption() {
for initial in 0usize..=1210 {
for width in [8, 16, 32, 64, 128] {
for affine in [false, true] {
let mut expected = initial;
let mut pixels = 0;
if expected < 2 {
expected = 0;
} else {
if affine {
for _ in 0..10 {
expected = expected.saturating_sub(1);
}
}
for _ in 0..width {
let needed = if affine { 2 } else { 1 };
if expected < needed {
expected = 0;
break;
}
expected -= needed;
pixels += 1;
}
}
let mut budget = Budget { remaining: initial };
assert_eq!(
budget.draw(width, affine),
pixels,
"initial={initial} width={width} affine={affine}"
);
assert_eq!(budget.remaining, expected);
}
}
}
}
#[test]
fn inactive_scan_and_active_costs_do_not_double_charge_inspection() {
let mut normal = Budget::new(0);
assert_eq!(normal.draw(64, false), 64);
assert_eq!(normal.remaining, 1210 - 64);
normal.skip();
assert_eq!(normal.remaining, 1210 - 66);
assert_eq!(normal.draw(128, true), 128);
assert_eq!(normal.remaining, 1210 - 66 - 266);
let mut short = Budget::new(0x20);
for _ in 0..119 {
assert_eq!(short.draw(8, false), 8);
}
assert_eq!(short.draw(8, false), 2);
assert_eq!(short.draw(8, false), 0);
}
#[test]
fn random_mixed_work_matches_an_independent_remaining_time_model() {
let mut seed = 0x8321_602bu32;
for control in [0, 0x20] {
for _ in 0..1000 {
let mut budget = Budget::new(control);
let mut expected: i32 = if control == 0 { 1210 } else { 954 };
for _ in 0..128 {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
let width = 8 << ((seed >> 4) % 5);
let affine = seed & 8 != 0;
if seed & 3 == 0 {
budget.skip();
expected = (expected - 2).max(0);
} else {
let columns = if expected < 2 {
0
} else if affine {
((expected - 10).max(0) / 2).min(width)
} else {
expected.min(width)
};
assert_eq!(budget.draw(width as usize, affine), columns as usize);
expected = (expected - if affine { 10 + 2 * width } else { width }).max(0);
}
assert_eq!(budget.remaining, expected as usize);
}
}
}
}
}