gameboy-rust/crates/desktop/src/cartridge/suite.rs

470 lines
16 KiB
Rust

//! Bounded, headless ROM tests with explicit checkpoints and assertions.
//! Passing means only that the configured checkpoint and checks succeeded.
use std::{
collections::HashSet,
error::Error,
ffi::OsString,
fs::{self, File},
io::{self, Read, Write},
path::{Path, PathBuf},
};
use gba_core::{
bios,
cpu::InstructionSet,
input::Buttons,
machine::{Machine, StepKind},
video::{Framebuffer, HEIGHT, WIDTH},
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use super::{load, Stats, MAX_FRAMES, MAX_STEPS};
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
const MAX_CASES: usize = 256;
const MAX_CHECKS: usize = 256;
const USAGE: &str = "usage: gameboy-rust --test-suite PATH.json (no other options)";
pub fn parse_args(args: &[OsString]) -> io::Result<PathBuf> {
match args {
[flag, path] if flag == "--test-suite" && !path.is_empty() => Ok(path.into()),
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, USAGE)),
}
}
/// JSON integers or unsigned decimal/0x-prefixed strings, never floats or signs.
fn word<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
let value = Value::deserialize(deserializer)?;
let parsed = match value {
Value::Number(number) => number.as_u64().and_then(|value| u32::try_from(value).ok()),
Value::String(text) => {
let (digits, radix) = text
.strip_prefix("0x")
.map_or((text.as_str(), 10), |digits| (digits, 16));
if digits.is_empty()
|| !digits.bytes().all(|byte| {
if radix == 16 {
byte.is_ascii_hexdigit()
} else {
byte.is_ascii_digit()
}
})
{
None
} else {
u32::from_str_radix(digits, radix).ok()
}
}
_ => None,
};
parsed.ok_or_else(|| {
serde::de::Error::custom("expected a u32 integer or unsigned decimal/0x string")
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Suite {
version: u32,
cases: Vec<Case>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Case {
name: String,
rom: PathBuf,
step_limit: u64,
completion: Completion,
#[serde(default)]
inputs: Option<Vec<Input>>,
checks: Vec<Check>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged, deny_unknown_fields)]
enum Completion {
Pc {
#[serde(deserialize_with = "word")]
pc: u32,
instruction_set: State,
},
VBlanks {
vblanks: u64,
},
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Input {
vblank: u64,
#[serde(deserialize_with = "word")]
buttons: u32,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
enum State {
Arm,
Thumb,
}
impl State {
fn instruction_set(self) -> InstructionSet {
match self {
Self::Arm => InstructionSet::Arm,
Self::Thumb => InstructionSet::Thumb,
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum Check {
Pixel {
x: usize,
y: usize,
#[serde(deserialize_with = "word")]
equals: u32,
},
Register {
index: usize,
#[serde(deserialize_with = "word")]
equals: u32,
},
Memory32 {
#[serde(deserialize_with = "word")]
address: u32,
#[serde(deserialize_with = "word")]
equals: u32,
},
Cpsr {
#[serde(deserialize_with = "word")]
equals: u32,
},
}
impl Suite {
fn validate(&self) -> io::Result<()> {
let invalid = |message: &str| io::Error::new(io::ErrorKind::InvalidInput, message);
if !matches!(self.version, 1 | 2) {
return Err(invalid("suite version must be 1 or 2"));
}
if self.cases.is_empty() || self.cases.len() > MAX_CASES {
return Err(invalid("suite must contain 1..=256 cases"));
}
let mut names = HashSet::new();
let mut total = 0u64;
for case in &self.cases {
if case.name.trim().is_empty() || case.name.len() > 128 || !names.insert(&case.name) {
return Err(invalid(
"case names must be nonempty, unique, and at most 128 UTF-8 bytes",
));
}
if case.rom.as_os_str().is_empty() {
return Err(invalid("case ROM path must not be empty"));
}
if !(1..=MAX_STEPS).contains(&case.step_limit) {
return Err(invalid("case step_limit must be 1..=100000000"));
}
total += case.step_limit;
if total > MAX_STEPS {
return Err(invalid("sum of case step limits must not exceed 100000000"));
}
match case.completion {
Completion::Pc {
pc,
instruction_set,
} => {
let alignment = match instruction_set {
State::Arm => 4,
State::Thumb => 2,
};
if pc % alignment != 0 {
return Err(invalid("completion PC must be aligned for its instruction set; do not set the Thumb address bit"));
}
if case.inputs.is_some() {
return Err(invalid("input schedules require VBlank completion"));
}
}
Completion::VBlanks { vblanks } => {
if self.version != 2 || !(1..=MAX_FRAMES).contains(&vblanks) {
return Err(invalid(
"VBlank completion requires version 2 and 1..=100000 vblanks",
));
}
if let Some(inputs) = &case.inputs {
if inputs.len() > 256 {
return Err(invalid(
"input schedule must contain at most 256 snapshots",
));
}
let mut previous = None;
for input in inputs {
if input.buttons > 0x3ff
|| input.vblank >= vblanks
|| previous.is_some_and(|frame| input.vblank <= frame)
{
return Err(invalid("inputs need 10-bit button masks and strictly increasing VBlanks before completion"));
}
previous = Some(input.vblank);
}
}
}
}
if case.checks.is_empty() || case.checks.len() > MAX_CHECKS {
return Err(invalid("each case must contain 1..=256 checks"));
}
for check in &case.checks {
match check {
Check::Pixel { x, y, equals } => {
if self.version != 2
|| !matches!(case.completion, Completion::VBlanks { .. })
|| *x >= WIDTH
|| *y >= HEIGHT
|| *equals > 0xff_ffff
{
return Err(invalid("pixel checks require version 2, VBlank completion, x < 240, y < 160, and RGB888 values"));
}
}
Check::Register { index, .. } if *index > 15 => {
return Err(invalid("register index must be 0..=15"))
}
Check::Memory32 { address, .. } if address % 4 != 0 => {
return Err(invalid("memory32 address must be word-aligned"))
}
_ => {}
}
}
}
Ok(())
}
}
fn read_suite(path: &Path) -> Result<Suite, Box<dyn Error>> {
let validate = |metadata: fs::Metadata| -> io::Result<()> {
if !metadata.is_file() || metadata.len() > MAX_MANIFEST_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"suite must be a regular file no larger than 1 MiB",
));
}
Ok(())
};
validate(fs::metadata(path)?)?;
let file = File::open(path)?;
validate(file.metadata()?)?;
let mut bytes = Vec::new();
file.take(MAX_MANIFEST_BYTES + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_MANIFEST_BYTES {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "suite exceeds 1 MiB").into());
}
let suite: Suite = serde_json::from_slice(&bytes)?;
suite.validate()?;
Ok(suite)
}
#[derive(Debug, PartialEq, Eq)]
enum Outcome {
Checkpoint,
StepLimit,
Stopped,
EmulationError(String),
RenderError(String),
}
fn run_to_checkpoint(machine: &mut Machine, case: &Case) -> (Stats, Outcome) {
let mut stats = Stats::default();
let capture = matches!(case.completion, Completion::VBlanks { .. });
machine.memory_mut().set_scanline_rendering(capture);
let mut frame = Framebuffer::default();
let mut last_vblank = 0;
let mut inputs = case.inputs.iter().flatten().peekable();
loop {
if machine.stopped() {
return (stats, Outcome::Stopped);
}
let vblanks = machine.memory().display_position().vblanks;
if capture && vblanks != last_vblank {
match machine.memory().present_frame(&mut frame) {
Ok(true) => {}
Ok(false) => {
return (
stats,
Outcome::RenderError("no complete captured frame at VBlank".into()),
)
}
Err(error) => return (stats, Outcome::RenderError(error.to_string())),
}
last_vblank = vblanks;
}
// Snapshots apply after this VBlank's capture, before the next machine step.
while inputs.peek().is_some_and(|input| input.vblank <= vblanks) {
let input = inputs.next().unwrap();
machine
.memory_mut()
.set_buttons(Buttons::from_bits(input.buttons as u16));
}
// The final budgeted step can complete a case; reset alone cannot.
let complete = match case.completion {
Completion::Pc {
pc,
instruction_set,
} => {
machine.cpu().pc() == pc
&& machine.cpu().instruction_set() == instruction_set.instruction_set()
}
Completion::VBlanks { vblanks: target } => vblanks >= target,
};
if stats.steps != 0 && complete {
return (stats, Outcome::Checkpoint);
}
if stats.steps == case.step_limit {
return (stats, Outcome::StepLimit);
}
match machine.step() {
Ok(StepKind::StopIdle) => return (stats, Outcome::Stopped),
Ok(kind) => stats.record(kind),
Err(error) => return (stats, Outcome::EmulationError(error.to_string())),
}
}
}
fn check(machine: &Machine, check: &Check) -> Value {
let (expected, actual) = match check {
Check::Pixel { x, y, equals } => {
let mut frame = Framebuffer::default();
let actual = match machine.memory().present_frame(&mut frame) {
Ok(true) => Ok(frame.pixels()[y * WIDTH + x]),
Ok(false) => Err("no complete captured frame".to_owned()),
Err(error) => Err(error.to_string()),
};
(*equals, actual)
}
Check::Register { index, equals } => (*equals, Ok(machine.cpu().registers()[*index])),
Check::Memory32 { address, equals } => (
*equals,
machine
.memory()
.read32(*address)
.map_err(|error| error.to_string()),
),
Check::Cpsr { equals } => (*equals, Ok(machine.cpu().cpsr())),
};
match actual {
Ok(actual) => {
json!({"check": check, "actual": actual, "passed": actual == expected, "error": null})
}
Err(error) => {
json!({"check": check, "actual": null, "passed": false, "error": error.to_string()})
}
}
}
fn state(machine: &Machine, stats: &Stats) -> Value {
json!({
"registers": machine.cpu().registers(), "pc": machine.cpu().pc(), "cpsr": machine.cpu().cpsr(),
"mode": format!("{:?}", machine.cpu().mode()),
"instruction_set": match machine.cpu().instruction_set() { InstructionSet::Arm => "arm", InstructionSet::Thumb => "thumb" },
"halted": machine.halted(), "stopped": machine.stopped(), "nominal_cycles": machine.cycles(),
"steps": stats.steps, "instructions": stats.instructions, "irq_entries": stats.irq_entries,
"dma_units": stats.dma_units, "halt_idle": stats.halt_idle,
})
}
fn run_case(case: &Case, base: &Path) -> Value {
let path = base.join(&case.rom);
// Debug formatting preserves non-UTF-8 host paths without lossy replacement.
let mut report = json!({
"name": case.name, "rom": case.rom, "resolved_path_debug": format!("{path:?}"),
"step_limit": case.step_limit, "completion": case.completion,
"passed": false, "reason": "load_error", "error": null,
"rom_bytes": null, "state": null, "checks": [],
});
let bytes = match load(&path) {
Ok(bytes) => bytes,
Err(error) => {
report["error"] = json!(error.to_string());
return report;
}
};
report["rom_bytes"] = json!(bytes.len());
let mut machine = match bios::boot(bytes) {
Ok(machine) => machine,
Err(error) => {
report["error"] = json!(error.to_string());
return report;
}
};
let (stats, outcome) = run_to_checkpoint(&mut machine, case);
report["state"] = state(&machine, &stats);
if matches!(case.completion, Completion::VBlanks { .. }) {
report["inputs"] = json!(case.inputs.as_deref().unwrap_or_default());
report["state"]["vblanks"] = json!(machine.memory().display_position().vblanks);
report["state"]["captured_vblank"] = json!(machine.memory().captured_vblank());
}
let reason = match outcome {
Outcome::Checkpoint => {
let checks: Vec<_> = case
.checks
.iter()
.map(|assertion| check(&machine, assertion))
.collect();
let passed = checks.iter().all(|check| check["passed"] == true);
report["passed"] = json!(passed);
report["checks"] = json!(checks);
if passed {
"checkpoint"
} else {
"assertion_failed"
}
}
Outcome::StepLimit => "step_limit",
Outcome::Stopped => "stopped",
Outcome::RenderError(error) => {
report["error"] = json!(error);
"render_error"
}
Outcome::EmulationError(error) => {
report["error"] = json!(error);
"emulation_error"
}
};
report["reason"] = json!(reason);
report
}
fn write_json_report(writer: &mut impl Write, report: &Value) -> Result<(), Box<dyn Error>> {
serde_json::to_writer_pretty(&mut *writer, report)?;
writeln!(writer)?;
writer.flush()?;
Ok(())
}
pub fn execute(path: &Path, writer: &mut impl Write) -> Result<(), Box<dyn Error>> {
let suite = read_suite(path)
.map_err(|error| io::Error::other(format!("cannot read test suite {path:?}: {error}")))?;
let base = path.parent().unwrap_or_else(|| Path::new("."));
let cases: Vec<_> = suite
.cases
.iter()
.map(|case| run_case(case, base))
.collect();
let failed = cases.iter().filter(|case| case["passed"] != true).count();
let report = json!({
"format_version": suite.version, "bios": "original", "passed": failed == 0,
"case_count": cases.len(), "failed_count": failed, "cases": cases,
});
write_json_report(writer, &report)?;
if failed != 0 {
return Err(io::Error::other(format!("ROM test suite: {failed} case(s) failed")).into());
}
Ok(())
}
#[cfg(test)]
mod gameplay_tests;
#[cfg(test)]
mod tests;