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

306
tests/sprite_budget.rs Normal file
View file

@ -0,0 +1,306 @@
//! Regression tests for the nominal allowance, not hardware fetch cutoffs.
use gba_rust::{
cpu::Cpu,
display::{CYCLES_PER_LINE as LINE, VBLANK_START as VB},
dma::DMA_BASE,
io::*,
machine::Machine,
memory::{Memory, OAM_START as OAM, PALETTE_START as PAL, ROM_START, VRAM_START as VRAM},
video::{rgb555_to_rgb888 as rgb, Framebuffer, VideoError, WIDTH},
};
fn scene(free: bool) -> Memory {
let mut m = Memory::new(vec![]).unwrap();
m.set_scanline_rendering(true);
m.write16(DISPCNT, if free { 0x1060 } else { 0x1040 })
.unwrap();
m.write16(PAL, 0x7c00).unwrap();
m.write16(PAL + 0x202, 31).unwrap();
m.write16(PAL + 0x204, 0x3e0).unwrap();
for i in 0..128 {
obj(&mut m, i, 0x200, 0, 0);
}
// Tile zero is transparent. Separate solid 64x64 textures use indices 1/2.
for offset in (0..2048).step_by(2) {
m.write16(VRAM + 0x11000 + offset, 0x1111).unwrap();
m.write16(VRAM + 0x12000 + offset, 0x2222).unwrap();
m.write16(VRAM + 0x14000 + offset, 0x2222).unwrap();
}
m
}
fn obj(m: &mut Memory, index: u32, a: u16, b: u16, c: u16) {
m.write16(OAM + index * 8, a).unwrap();
m.write16(OAM + index * 8 + 2, b).unwrap();
m.write16(OAM + index * 8 + 4, c).unwrap();
}
fn fillers(m: &mut Memory, count: u32) {
for i in 0..count {
obj(m, i, 0, 0xc000, 0);
}
}
fn snapshot(m: &Memory) -> Framebuffer {
let before = (m.cycles(), m.display_position());
let mut f = Framebuffer::default();
m.render_frame(&mut f).unwrap();
assert_eq!((m.cycles(), m.display_position()), before);
f
}
fn finish(m: &mut Memory) -> Framebuffer {
let p = m.display_position();
m.advance_cycles(VB - u32::from(p.scanline) * LINE - u32::from(p.line_cycle));
let mut f = Framebuffer::default();
assert!(m.present_frame(&mut f).unwrap());
f
}
fn at(f: &Framebuffer, x: usize, y: usize) -> u32 {
f.pixels()[y * WIDTH + x]
}
fn prefix(f: &Framebuffer, y: usize, left: usize, width: usize, visible: usize) {
for x in left..left + width {
assert_eq!(
at(f, x, y),
rgb(if x < left + visible { 0x3e0 } else { 0x7c00 }),
"pixel={x},{y}, prefix={visible}"
);
}
}
#[test]
fn regular_prefix_exhausts_allowance_and_omits_later_entries() {
for free in [false, true] {
let mut m = scene(free);
let count = if free { 14 } else { 18 };
fillers(&mut m, count);
obj(&mut m, count, 0, 0xc000 | 80, 256 | 0xc00);
obj(&mut m, count + 1, 0, 0xc000 | 160, 256);
let f = snapshot(&m);
prefix(&f, 0, 80, 64, 58);
prefix(&f, 0, 160, 64, 0);
assert_eq!(finish(&mut m).pixels(), f.pixels());
}
}
#[test]
fn small_sprites_have_no_fixed_thirty_two_object_limit() {
for (free, count, visible) in [(false, 127, 8), (true, 119, 2), (true, 120, 0)] {
let mut m = scene(free);
for i in 0..count {
obj(&mut m, i, 0, 0, 0);
}
obj(&mut m, count, 0, 80, 256);
prefix(&snapshot(&m), 0, 80, 8, visible);
}
}
#[test]
fn affine_setup_and_double_size_use_drawing_canvas_width() {
for free in [false, true] {
for doubled in [false, true] {
let mut m = scene(free);
let count = if free { 14 } else { 18 };
fillers(&mut m, count); // 58 cycles remain.
// Zero matrices sample the opaque center for the entire canvas.
obj(&mut m, count, 0x100, 80, 256); // 10+2*8 = 26.
obj(
&mut m,
count + 1,
if doubled { 0x300 } else { 0x100 },
if doubled { 112 } else { 0x4000 | 112 },
256,
); // 32 cycles remain: setup plus 11 canvas pixels.
let f = snapshot(&m);
prefix(&f, 0, 80, 8, 8);
prefix(&f, 0, 112, 16, 11);
}
}
}
#[test]
fn unfinished_affine_setup_does_not_refund_work_to_regular_objects() {
for free in [false, true] {
let mut m = scene(free);
let count = if free { 14 } else { 18 };
fillers(&mut m, count);
obj(&mut m, count, 0, 0x8000, 0); // 32 cycles.
obj(&mut m, count + 1, 0, 0x4000, 0); // 16 cycles.
obj(&mut m, count + 2, 0x100, 80, 256); // Only setup fits.
obj(&mut m, count + 3, 0, 96, 256);
let f = snapshot(&m);
prefix(&f, 0, 80, 8, 0);
prefix(&f, 0, 96, 8, 0);
}
}
#[test]
fn disabled_off_row_and_fully_offscreen_entries_cost_inspection_only() {
for free in [false, true] {
for (a, b) in [(0x200, 0), (80, 0xc000), (0, 0xc000 | 240)] {
let mut m = scene(free);
for i in 0..100 {
obj(&mut m, i, a, b, 0);
}
let count = if free { 11 } else { 15 };
for i in 100..100 + count {
obj(&mut m, i, 0, 0xc000, 0);
}
obj(&mut m, 100 + count, 0, 0xc000 | 80, 256);
prefix(&snapshot(&m), 0, 80, 64, 50);
}
}
}
#[test]
fn partially_clipped_objects_charge_full_canvas_in_this_nominal_model() {
let mut m = scene(false);
fillers(&mut m, 18);
obj(&mut m, 18, 0, 0xc000 | 508, 256); // Left four pixels are clipped.
prefix(&snapshot(&m), 0, 0, 60, 54);
let mut m = scene(false);
fillers(&mut m, 17);
obj(&mut m, 17, 0, 0xc000 | 238, 256); // Only two pixels are on screen.
obj(&mut m, 18, 0, 0xc000 | 80, 256);
let f = snapshot(&m);
prefix(&f, 0, 238, 2, 2);
prefix(&f, 0, 80, 64, 58);
}
#[test]
fn transparent_semitransparent_and_disabled_obj_window_pixels_still_cost_work() {
for a in [0, 0x400, 0x800] {
let mut m = scene(false);
for i in 0..18 {
obj(&mut m, i, a, 0xc000, if a == 0x800 { 128 } else { 0 });
}
obj(&mut m, 18, 0, 0xc000 | 80, 256);
prefix(&snapshot(&m), 0, 80, 64, 58);
}
}
#[test]
fn oam_order_not_display_priority_allocates_work_even_for_occluded_objects() {
let mut m = scene(false);
for i in 0..18 {
obj(&mut m, i, 0, 0xc000 | 80, 128 | 0xc00);
}
obj(&mut m, 18, 0, 0xc000 | 80, 256);
let f = snapshot(&m);
for x in 80..144 {
assert_eq!(at(&f, x, 0), rgb(if x < 138 { 0x3e0 } else { 31 }));
}
}
#[test]
fn large_affine_and_out_of_texture_samples_use_full_canvas_cost() {
for doubled in [false, true] {
let mut m = scene(false);
let count = if doubled { 4 } else { 8 };
for i in 0..count {
obj(&mut m, i, if doubled { 0x300 } else { 0x100 }, 0xc000, 0);
}
// Most samples are outside the texture. This does not refund work.
m.write16(OAM + 6, 0x7fff).unwrap();
m.write16(OAM + 30, 0x7fff).unwrap();
obj(&mut m, count, 0, 0xc000 | 16, 256);
obj(&mut m, count + 1, 0, 0xc000 | 80, 256);
obj(&mut m, count + 2, 0, 0xc000 | 144, 256);
let f = snapshot(&m);
prefix(&f, 0, 16, 64, 64);
prefix(&f, 0, 80, 64, if doubled { 64 } else { 42 });
prefix(&f, 0, 144, 64, if doubled { 18 } else { 0 });
}
}
#[test]
fn bitmap_restricted_tiles_do_not_refund_sprite_work() {
let mut m = scene(false);
m.write16(DISPCNT, 0x1043).unwrap();
fillers(&mut m, 18); // Tile zero is unavailable to OBJ in bitmap modes.
obj(&mut m, 18, 0, 0xc000 | 80, 512);
prefix(&snapshot(&m), 0, 80, 64, 58);
}
#[test]
fn each_row_gets_an_independent_allowance() {
let mut m = scene(false);
for i in 0..18 {
obj(&mut m, i, 1, 0xc000, 0);
}
obj(&mut m, 18, 0, 0xc000 | 80, 256);
let f = snapshot(&m);
prefix(&f, 0, 80, 64, 64);
prefix(&f, 1, 80, 64, 58);
assert_eq!(finish(&mut m).pixels(), f.pixels());
}
#[test]
fn hblank_free_is_sampled_at_preparation_not_composition() {
for elapsed in [39, 40, 41] {
let mut m = scene(false);
fillers(&mut m, 18);
obj(&mut m, 18, 0, 0xc000 | 80, 256);
m.advance_cycles(elapsed);
m.write16(DISPCNT, 0x1060).unwrap();
prefix(&snapshot(&m), 0, 80, 64, 0);
let f = finish(&mut m);
prefix(&f, 0, 80, 64, 58);
prefix(&f, 1, 80, 64, if elapsed < 40 { 0 } else { 58 });
prefix(&f, 2, 80, 64, 0);
}
}
#[test]
fn hblank_dma_updates_allowance_for_the_following_preparation() {
let mut m = scene(false);
fillers(&mut m, 18);
obj(&mut m, 18, 0, 0xc000 | 80, 256);
for row in 0..160 {
m.write16(
0x0200_0000 + row * 2,
if row % 2 == 0 { 0x1060 } else { 0x1040 },
)
.unwrap();
}
m.write32(DMA_BASE, 0x0200_0000).unwrap();
m.write32(DMA_BASE + 4, DISPCNT).unwrap();
m.write32(DMA_BASE + 8, 0xa240_0001).unwrap();
m.write8(HALTCNT, 0).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), m);
machine.run_until_vblank(1000).unwrap();
let mut f = Framebuffer::default();
assert!(machine.memory().present_frame(&mut f).unwrap());
for y in 0..64 {
prefix(&f, y, 80, 64, if y < 2 || y % 2 == 1 { 58 } else { 0 });
}
assert!(machine.halted());
}
#[test]
fn exhausted_work_preserves_development_diagnostics_and_output_atomicity() {
let mut m = scene(false);
fillers(&mut m, 19);
obj(&mut m, 127, 0xc00, 0, 0);
let mut f = Framebuffer::default();
f.clear(0x3e0);
assert!(matches!(
m.render_frame(&mut f),
Err(VideoError::UnsupportedObject { index: 127, .. })
));
assert!(f.pixels().iter().all(|&p| p == rgb(0x3e0)));
m.advance_cycles(VB);
assert!(matches!(
m.present_frame(&mut f),
Err(VideoError::UnsupportedObject { index: 127, .. })
));
assert!(f.pixels().iter().all(|&p| p == rgb(0x3e0)));
for control in [0x40, 0x10c0] {
m.write16(DISPCNT, control).unwrap();
m.render_frame(&mut f).unwrap();
}
}