gameboy-rust/crates/gba-core/src/video/sprites/pipeline.rs
0xrsydn fd73788735 feat(core): add whole-machine save states with F5/F9 window slots
Machine::save_state and load_state encode the CPU, every RAM, and every device
through Snapshot impls into a versioned, checksummed format. Rejected loads leave
the machine unchanged. The ROM window saves to and loads from one state file
with F5 and F9.
2026-10-14 14:28:41 +07:00

250 lines
8.7 KiB
Rust

//! Row-at-once approximation of the one-line-ahead OBJ pipeline.
//! OAM and VRAM sample at cycle40 of the preceding line. This does not model
//! individual fetches, fetch budgets, or OAM/VRAM contention.
use super::PreparedObjects;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use crate::{display::CYCLES_PER_LINE, video::VideoError};
pub(crate) const PREPARE_CYCLE: u32 = 40;
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SpritePipeline {
rows: [Option<(usize, Result<PreparedObjects, VideoError>)>; 2],
pub started: bool,
}
impl SpritePipeline {
pub fn store(&mut self, row: usize, objects: Result<PreparedObjects, VideoError>) {
self.rows[row % 2] = Some((row, objects));
}
pub fn row(&self, row: usize) -> &Result<PreparedObjects, VideoError> {
let (prepared_row, objects) = self.rows[row % 2]
.as_ref()
.expect("sprite row prepared before capture");
assert_eq!(*prepared_row, row);
objects
}
}
/// Only rows0..158 and227 prepare the following visible row.
pub(crate) fn target(scanline: u16) -> Option<usize> {
match scanline {
0..=158 => Some(usize::from(scanline) + 1),
227 => Some(0),
_ => None,
}
}
pub(crate) fn next_event(scanline: u16, cycle: u16) -> u32 {
let (line, cycle) = (u32::from(scanline), u32::from(cycle));
if target(scanline).is_some() && cycle < PREPARE_CYCLE {
return PREPARE_CYCLE - cycle;
}
let lines = if (158..227).contains(&line) {
227 - line
} else {
1
};
lines * CYCLES_PER_LINE + PREPARE_CYCLE - cycle
}
/// Latest two events suffice when capture is disabled: fixed memory/registers
/// cannot change inside a clock batch, and only two prepared rows are retained.
/// Offsets are relative to the supplied starting phase, in chronological order.
pub(crate) fn latest_events(phase: u32, cycles: u32) -> [Option<(u32, usize)>; 2] {
let start = u64::from(phase);
let end = start + u64::from(cycles);
let latest = |end: u64| {
let mut line = end.checked_sub(u64::from(PREPARE_CYCLE))? / u64::from(CYCLES_PER_LINE);
let row = line % 228;
if (159..227).contains(&row) {
line -= row - 158;
}
let time = line * u64::from(CYCLES_PER_LINE) + u64::from(PREPARE_CYCLE);
(time > start).then_some((time, ((line + 1) % 228) as usize))
};
let Some((last, row)) = latest(end) else {
return [None; 2];
};
let previous = latest(last - 1).map(|(time, row)| ((time - start) as u32, row));
[previous, Some(((last - start) as u32, row))]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::display::CYCLES_PER_FRAME;
#[test]
fn event_selection_matches_independent_line_iteration() {
let mut seed = 0x6328_93abu32;
for case in 0..1000 {
let mut next = || {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
seed
};
let phase = next() % CYCLES_PER_FRAME;
let cycles = if case == 0 {
u32::MAX
} else {
next() % (CYCLES_PER_FRAME * 5)
};
let end = u64::from(phase) + u64::from(cycles);
let mut expected = [None; 2];
for line in u64::from(phase / CYCLES_PER_LINE)..=end / u64::from(CYCLES_PER_LINE) {
let time = line * u64::from(CYCLES_PER_LINE) + 40;
if time > u64::from(phase) && time <= end && (line % 228 < 159 || line % 228 == 227)
{
expected[0] = expected[1];
expected[1] = Some((
(time - u64::from(phase)) as u32,
((line + 1) % 228) as usize,
));
}
}
assert_eq!(latest_events(phase, cycles), expected);
}
}
#[test]
fn next_event_handles_visible_end_hidden_lines_and_exact_boundary() {
for line in 0..228 {
for cycle in [0, 39, 40, 1006, 1231] {
let phase = u32::from(line) * CYCLES_PER_LINE + u32::from(cycle);
let distance = next_event(line, cycle);
assert!(distance > 0);
assert_eq!(latest_events(phase, distance - 1), [None; 2]);
assert!(latest_events(phase, distance)[1].is_some());
}
}
}
}
impl Snapshot for SpritePipeline {
fn save(&self, writer: &mut Writer) {
let Self { rows, started } = self;
writer.tag(b"SPRP");
writer.bool(*started);
for row in rows {
writer.option(row.as_ref(), |(index, result), writer| {
if *index >= crate::video::HEIGHT {
writer.fail(SnapshotError::Unsupported("invalid sprite row index"));
}
writer.u64(*index as u64);
match result {
Ok(objects) => {
writer.u8(0);
objects.save(writer);
}
Err(_) => {
writer.u8(1);
writer.fail(SnapshotError::Unsupported("deferred video diagnostic"));
}
}
});
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { rows, started } = self;
reader.tag(b"SPRP")?;
*started = reader.bool()?;
for (slot, destination) in rows.iter_mut().enumerate() {
*destination = reader.option(|reader| {
let index = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("sprite row index"))?;
if index >= crate::video::HEIGHT || index % 2 != slot {
return Err(SnapshotError::Corrupt("sprite row index"));
}
let result = match reader.u8()? {
0 => {
let mut objects = PreparedObjects::default();
objects.load(reader)?;
Ok(objects)
}
1 => return Err(SnapshotError::Corrupt("deferred video diagnostic")),
_ => return Err(SnapshotError::Corrupt("sprite row result")),
};
Ok((index, result))
})?;
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
use crate::video::sprites::{ObjectPixel, Sample};
fn encoded(value: &SpritePipeline) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn prepared(index: usize) -> PreparedObjects {
PreparedObjects {
samples: vec![
Sample {
pixel: Some(ObjectPixel {
palette_index: (index % 256) as u16,
semi_transparent: true,
}),
priority: (index % 4) as u8,
mosaic: true,
};
crate::video::WIDTH
],
window: vec![index % 2 == 0; crate::video::WIDTH],
}
}
#[test]
fn sprite_pipeline_round_trips_rows_and_checks_prefixes() {
let source = SpritePipeline {
rows: [Some((4, Ok(prepared(13)))), Some((5, Ok(prepared(17))))],
started: true,
};
let bytes = encoded(&source);
let mut restored = SpritePipeline::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(
SpritePipeline::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err(),
"prefix {end}"
);
}
let mut invalid = bytes.clone();
invalid[4] = 2;
assert_eq!(
SpritePipeline::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
invalid.copy_from_slice(&bytes);
invalid[6] = crate::video::HEIGHT as u8;
invalid[7..14].fill(0);
assert_eq!(
SpritePipeline::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("sprite row index"))
);
}
#[test]
fn deferred_sprite_diagnostic_cannot_be_saved() {
let mut source = SpritePipeline::default();
source.rows[0] = Some((0, Err(VideoError::UnsupportedMode(7))));
let mut writer = Writer::new();
source.save(&mut writer);
assert_eq!(
writer.finish(),
Err(SnapshotError::Unsupported("deferred video diagnostic"))
);
}
}