feat(testing): add bounded headless ROM suites and JSON reports

Read strict versioned suite manifests with independent ROM cases, explicit ARM/Thumb checkpoints, register/CPSR/memory assertions, and aggregate step limits. Report every case deterministically and fail on timeouts, STOP, diagnostics, missing files, or mismatches. Keep host JSON dependencies outside the core.

Add an original ARM/Thumb/BIOS suite generator, unit and process-level regressions, and schema/usage documentation. Verified on Darwin arm64 with debug/release workspace and core suites, Clippy, rustdoc, native ROM-window tests, all ten demo smoke tests, repeatable release reports, and a deliberate assertion failure. Independent public hardware-test ROM validation remains next.
This commit is contained in:
0xrsydn 2026-10-05 19:30:37 +07:00
commit da700671ad
16 changed files with 1162 additions and 3 deletions

View file

@ -1,5 +1,6 @@
//! Host-only raw ROM loading, execution modes, and diagnostics.
pub mod suite;
pub mod window;
use std::{

View file

@ -0,0 +1,344 @@
//! 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,
machine::{Machine, StepKind},
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use super::{load, Stats, 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,
checks: Vec<Check>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Completion {
#[serde(deserialize_with = "word")]
pc: u32,
instruction_set: State,
}
#[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 {
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 self.version != 1 {
return Err(invalid("suite version must be 1"));
}
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"));
}
let alignment = match case.completion.instruction_set {
State::Arm => 4,
State::Thumb => 2,
};
if case.completion.pc % alignment != 0 {
return Err(invalid("completion PC must be aligned for its instruction set; do not set the Thumb address bit"));
}
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::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),
}
fn run_to_checkpoint(machine: &mut Machine, case: &Case) -> (Stats, Outcome) {
let mut stats = Stats::default();
loop {
if machine.stopped() {
return (stats, Outcome::Stopped);
}
// Check after successful progress, before trying to execute the checkpoint.
// The final budgeted step can reach it. No initial/reset-state pass is allowed.
if stats.steps != 0
&& machine.cpu().pc() == case.completion.pc
&& machine.cpu().instruction_set() == case.completion.instruction_set.instruction_set()
{
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::Register { index, equals } => (*equals, Ok(machine.cpu().registers()[*index])),
Check::Memory32 { address, equals } => (*equals, machine.memory().read32(*address)),
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);
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::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": 1, "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 tests;

View file

@ -0,0 +1,346 @@
use super::*;
use gba_core::{
dma::DMA_BASE,
io::{IE, IME, TIMER_BASE},
memory::ROM_START,
};
fn case_value() -> Value {
json!({"name": "original", "rom": "original.gba", "step_limit": 100,
"completion": {"pc": "0x08000004", "instruction_set": "arm"},
"checks": [{"kind": "register", "index": 0, "equals": "0x2a"}]})
}
fn case() -> Case {
serde_json::from_value(case_value()).unwrap()
}
fn suite(value: Value) -> Result<Suite, Box<dyn Error>> {
let suite: Suite = serde_json::from_value(value)?;
suite.validate()?;
Ok(suite)
}
fn boot(words: &[u32]) -> Machine {
bios::boot(words.iter().flat_map(|word| word.to_le_bytes()).collect()).unwrap()
}
#[test]
fn suite_requires_one_path_and_no_other_options() {
for args in [
vec![],
vec!["--test-suite"],
vec!["--test-suite", ""],
vec!["--test-suite", "a.json", "--help"],
vec!["--rom", "a.json"],
] {
assert!(parse_args(&args.into_iter().map(OsString::from).collect::<Vec<_>>()).is_err());
}
assert_eq!(
parse_args(&["--test-suite".into(), "space λ.json".into()]).unwrap(),
PathBuf::from("space λ.json")
);
}
#[test]
fn word_values_accept_u32_numbers_decimal_strings_and_hex() {
for value in [
json!(42),
json!("42"),
json!("0x2a"),
json!("0x2A"),
json!(4294967295u32),
json!("0xffffffff"),
] {
let mut input = case_value();
input["checks"][0]["equals"] = value;
assert!(serde_json::from_value::<Case>(input).is_ok());
}
for value in [
json!(-1),
json!(42.0),
json!(4294967296u64),
json!(null),
json!(true),
json!(""),
json!("-1"),
json!("+1"),
json!(" 1"),
json!("1 "),
json!("0x"),
json!("0x100000000"),
json!("0xgg"),
json!("1.0"),
] {
let mut input = case_value();
input["checks"][0]["equals"] = value.clone();
assert!(serde_json::from_value::<Case>(input).is_err(), "{value}");
}
}
#[test]
fn unknown_fields_and_unknown_check_kinds_are_rejected() {
for pointer in ["", "/cases/0", "/cases/0/completion", "/cases/0/checks/0"] {
let mut value = json!({"version": 1, "cases": [case_value()]});
value.pointer_mut(pointer).unwrap()["typo"] = json!(1);
assert!(suite(value).is_err());
}
let mut value = case_value();
value["checks"][0]["kind"] = json!("memory8");
assert!(suite(json!({"version": 1, "cases": [value]})).is_err());
assert!(serde_json::from_str::<Suite>(r#"{"version":1,"version":1,"cases":[]}"#).is_err());
}
#[test]
fn manifest_rejects_empty_duplicate_excessive_or_invalid_cases() {
assert!(suite(json!({"version": 2, "cases": [case_value()]})).is_err());
assert!(suite(json!({"version": 1, "cases": []})).is_err());
assert!(suite(json!({"version": 1, "cases": [case_value(), case_value()]})).is_err());
for (field, invalid) in [
("name", json!(" ")),
("name", json!("x".repeat(129))),
("rom", json!("")),
("step_limit", json!(0)),
("step_limit", json!(100000001u64)),
("checks", json!([])),
] {
let mut value = case_value();
value[field] = invalid;
assert!(suite(json!({"version": 1, "cases": [value]})).is_err());
}
let cases: Vec<_> = (0..257)
.map(|index| {
let mut value = case_value();
value["name"] = json!(format!("test-{index}"));
value
})
.collect();
assert!(suite(json!({"version": 1, "cases": cases})).is_err());
let mut first = case_value();
first["step_limit"] = json!(MAX_STEPS);
assert!(suite(json!({"version": 1, "cases": [first.clone()]})).is_ok());
let mut second = case_value();
second["name"] = json!("second");
assert!(suite(json!({"version": 1, "cases": [first, second]})).is_err());
}
#[test]
fn manifest_checks_alignment_register_bounds_and_check_count() {
for (pc, state, valid) in [
("0x08000002", "arm", false),
("0x08000002", "thumb", true),
("0x08000003", "thumb", false),
("0x08000004", "invalid", false),
] {
let mut value = case_value();
value["completion"] = json!({"pc": pc, "instruction_set": state});
assert_eq!(
suite(json!({"version": 1, "cases": [value]})).is_ok(),
valid
);
}
for check in [
json!({"kind": "register", "index":16,"equals":0}),
json!({"kind":"memory32","address":"0x02000001","equals":0}),
] {
let mut value = case_value();
value["checks"] = json!([check]);
assert!(suite(json!({"version": 1, "cases": [value]})).is_err());
}
let mut value = case_value();
value["checks"] = json!(vec![value["checks"][0].clone(); MAX_CHECKS + 1]);
assert!(suite(json!({"version": 1, "cases": [value]})).is_err());
}
#[test]
fn json_report_propagates_write_and_flush_failures() {
struct Fails {
on_flush: bool,
}
impl Write for Fails {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if self.on_flush {
Ok(bytes.len())
} else {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "write failure"))
}
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("flush failure"))
}
}
let report = json!({"passed":true});
for on_flush in [false, true] {
let error = write_json_report(&mut Fails { on_flush }, &report).unwrap_err();
assert!(error.to_string().contains(if on_flush {
"flush failure"
} else {
"write failure"
}));
}
let mut output = Vec::new();
write_json_report(&mut output, &report).unwrap();
assert_eq!(serde_json::from_slice::<Value>(&output).unwrap(), report);
assert_eq!(output.last(), Some(&b'\n'));
}
#[test]
fn reset_pc_is_not_an_immediate_pass_without_execution() {
let mut config = case();
config.completion.pc = 0;
config.step_limit = 1;
let (stats, outcome) = run_to_checkpoint(&mut boot(&[0xeaff_fffe]), &config);
assert_eq!(stats.steps, 1);
assert_eq!(outcome, Outcome::StepLimit);
}
#[test]
fn checkpoint_is_checked_before_execution_and_on_the_last_budgeted_step() {
let code = [0xe3a0_002a, 0xee00_0000]; // MOV r0,#42; unsupported (never fetched)
let mut machine = boot(&code);
let (stats, outcome) = run_to_checkpoint(&mut machine, &case());
assert_eq!(outcome, Outcome::Checkpoint);
assert!(stats.steps > 1); // BIOS boot is included.
assert_eq!(check(&machine, &case().checks[0])["passed"], true);
let mut exact = case();
exact.step_limit = stats.steps;
assert_eq!(
run_to_checkpoint(&mut boot(&code), &exact).1,
Outcome::Checkpoint
);
exact.step_limit -= 1;
assert_eq!(
run_to_checkpoint(&mut boot(&code), &exact).1,
Outcome::StepLimit
);
}
#[test]
fn matching_assertions_do_not_pass_without_the_checkpoint() {
let mut config = case();
config.completion.pc = ROM_START + 8;
let mut machine = boot(&[0xe3a0_002a, 0xeaff_fffe]);
let (stats, outcome) = run_to_checkpoint(&mut machine, &config);
assert_eq!(outcome, Outcome::StepLimit);
assert_eq!(stats.steps, config.step_limit);
assert_eq!(check(&machine, &config.checks[0])["passed"], true);
config.completion.pc = ROM_START + 4;
config.completion.instruction_set = State::Thumb;
assert_eq!(
run_to_checkpoint(&mut boot(&[0xe3a0_002a, 0xeaff_fffe]), &config).1,
Outcome::StepLimit
);
}
#[test]
fn thumb_checkpoint_uses_aligned_architectural_pc() {
let mut bytes: Vec<u8> = [0xe59f_1000u32, 0xe12f_ff11, ROM_START + 13]
.iter()
.flat_map(|word| word.to_le_bytes())
.collect();
bytes.extend([0x2a, 0x20, 0xfe, 0xe7]);
let mut machine = bios::boot(bytes).unwrap();
let mut config = case();
config.completion.pc = ROM_START + 14;
config.completion.instruction_set = State::Thumb;
assert_eq!(
run_to_checkpoint(&mut machine, &config).1,
Outcome::Checkpoint
);
assert_eq!(check(&machine, &config.checks[0])["passed"], true);
}
#[test]
fn memory_and_cpsr_checks_report_actual_values_and_read_errors() {
let mut config = case();
config.completion.pc = ROM_START + 12;
let mut machine = boot(&[0xe3a0_002a, 0xe3a0_1402, 0xe581_0000, 0xeaff_fffe]);
assert_eq!(
run_to_checkpoint(&mut machine, &config).1,
Outcome::Checkpoint
);
for assertion in [
Check::Memory32 {
address: 0x0200_0000,
equals: 42,
},
Check::Cpsr { equals: 0x1f },
] {
let result = check(&machine, &assertion);
assert_eq!(result["passed"], true);
assert!(result["error"].is_null());
}
let wrong = check(
&machine,
&Check::Register {
index: 0,
equals: 43,
},
);
assert_eq!(wrong["passed"], false);
assert_eq!(wrong["actual"], 42);
let unmapped = check(
&machine,
&Check::Memory32 {
address: 0x0100_0000,
equals: 0,
},
);
assert_eq!(unmapped["passed"], false);
assert!(unmapped["actual"].is_null());
assert!(unmapped["error"].is_string());
}
#[test]
fn stop_is_failure_while_halt_consumes_a_bounded_budget() {
let mut config = case();
config.completion.pc = ROM_START + 8;
let (stats, outcome) = run_to_checkpoint(&mut boot(&[0xef03_0000, 0xeaff_fffe]), &config);
assert_eq!(outcome, Outcome::Stopped);
assert!(stats.steps < config.step_limit);
let (stats, outcome) = run_to_checkpoint(&mut boot(&[0xef02_0000, 0xeaff_fffe]), &config);
assert_eq!(outcome, Outcome::StepLimit);
assert_eq!(stats.steps, config.step_limit);
assert!(stats.halt_idle > 0);
}
#[test]
fn failing_instructions_do_not_advance_the_reported_state() {
let mut machine = boot(&[0xee00_0000]);
let (stats, outcome) = run_to_checkpoint(&mut machine, &case());
assert!(matches!(outcome, Outcome::EmulationError(_)));
assert!(stats.steps < case().step_limit);
assert_eq!(machine.cpu().pc(), ROM_START);
let cpu = machine.cpu().clone();
let cycles = machine.cycles();
let (retry, outcome) = run_to_checkpoint(&mut machine, &case());
assert_eq!(retry.steps, 0);
assert!(matches!(outcome, Outcome::EmulationError(_)));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), cycles);
}
#[test]
fn dma_and_irq_each_consume_a_budget_step_and_dma_errors_fail() {
let mut machine = boot(&[0xe3a0_002a, 0xeaff_fffe]);
run_to_checkpoint(&mut machine, &case());
let mut config = case();
config.completion.pc = ROM_START + 8;
config.step_limit = 3;
let bus = machine.memory_mut();
bus.write32(DMA_BASE, 0x0200_0000).unwrap();
bus.write32(DMA_BASE + 4, 0x0300_0000).unwrap();
bus.write32(DMA_BASE + 8, 0x8000_0002).unwrap();
bus.write16(IE, 8).unwrap();
bus.write16(IME, 1).unwrap();
bus.write32(TIMER_BASE, 0x00c0_ffff).unwrap();
bus.advance_cycles(1);
let (stats, outcome) = run_to_checkpoint(&mut machine, &config);
assert_eq!(outcome, Outcome::StepLimit);
assert_eq!(stats.dma_units, 2);
assert_eq!(stats.irq_entries, 1);
assert_eq!(stats.instructions, 0);
let bus = machine.memory_mut();
bus.write32(DMA_BASE + 4, ROM_START).unwrap();
bus.write32(DMA_BASE + 8, 0x8000_0001).unwrap();
let (stats, outcome) = run_to_checkpoint(&mut machine, &config);
assert_eq!(stats.steps, 0);
assert!(matches!(outcome, Outcome::EmulationError(_)));
}

View file

@ -1,7 +1,7 @@
mod cartridge;
mod desktop;
use std::{error::Error, ffi::OsString, io, process::ExitCode};
use std::{error::Error, ffi::OsString, io, path::PathBuf, process::ExitCode};
use gba_core::{
cpu::Cpu,
@ -19,6 +19,7 @@ use gba_demos::{
enum RunMode {
Window,
Rom(cartridge::Options),
TestSuite(PathBuf),
SmokeTest,
CpuDemo,
TimerDemo,
@ -42,6 +43,9 @@ enum RunMode {
}
fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
if args.first().is_some_and(|arg| arg == "--test-suite") {
return cartridge::suite::parse_args(args).map(RunMode::TestSuite);
}
if args.iter().any(|arg| {
["--rom", "--steps", "--window", "--frames"]
.iter()
@ -75,7 +79,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH (--steps COUNT | --window [--frames COUNT])",
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH (--steps COUNT | --window [--frames COUNT]); or gameboy-rust --test-suite PATH.json",
)),
}
}
@ -94,6 +98,7 @@ fn run() -> Result<(), Box<dyn Error>> {
match parse_args(&std::env::args_os().skip(1).collect::<Vec<_>>())? {
RunMode::Window => desktop::run(None),
RunMode::Rom(options) => cartridge::execute(options, &mut io::stdout().lock()),
RunMode::TestSuite(path) => cartridge::suite::execute(&path, &mut io::stdout().lock()),
RunMode::SmokeTest => desktop::run(Some(60)),
RunMode::CpuDemo => run_cpu_demo(),
RunMode::TimerDemo => run_timer_demo(),
@ -120,6 +125,7 @@ fn run() -> Result<(), Box<dyn Error>> {
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
--rom PATH --window Open a raw ROM with keyboard input\n\
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\
--graphics-demo Open the CPU-driven Mode 3 demo\n\