From bfab05b34d4ee6a0e236cc1f7844e8bcf3da1878 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 5 Oct 2026 12:55:35 +0700 Subject: [PATCH] feat(video): bound per-row sprite preparation work Apply nominal 1210/954-cycle allowances, affine setup costs, OAM-order allocation, and partial sprite prefixes. --- src/video/sprites.rs | 29 +++- src/video/sprites/budget.rs | 124 +++++++++++++++ tests/sprite_budget.rs | 306 ++++++++++++++++++++++++++++++++++++ 3 files changed, 452 insertions(+), 7 deletions(-) create mode 100644 src/video/sprites/budget.rs create mode 100644 tests/sprite_budget.rs diff --git a/src/video/sprites.rs b/src/video/sprites.rs index 20cd3b6..eb3697e 100644 --- a/src/video/sprites.rs +++ b/src/video/sprites.rs @@ -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; diff --git a/src/video/sprites/budget.rs b/src/video/sprites/budget.rs new file mode 100644 index 0000000..c318964 --- /dev/null +++ b/src/video/sprites/budget.rs @@ -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); + } + } + } + } +} diff --git a/tests/sprite_budget.rs b/tests/sprite_budget.rs new file mode 100644 index 0000000..3d23679 --- /dev/null +++ b/tests/sprite_budget.rs @@ -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(); + } +}