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

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