feat(cartridge): add bounded terminal ROM loading

Load read-only raw ROM files with bounded size checks and original BIOS boot. Require a machine-step budget and report CPU state, STOP, and explicit diagnostics without opening a window. Keep file access in the desktop crate.

Cover argument parsing, Darwin paths, file boundaries, ARM/Thumb execution, DMA/IRQ budgets, HALT/STOP, and error reports. Document limits and usage. Verified on native Darwin arm64: debug/release workspace tests, core-only tests, Clippy, rustdoc, all ten window smoke tests, and a release raw-ROM run.
This commit is contained in:
0xrsydn 2026-10-05 18:57:16 +07:00
commit c256bf8ccd
13 changed files with 837 additions and 13 deletions

View file

@ -0,0 +1,212 @@
//! Host-only raw ROM loading and bounded terminal diagnostics.
use std::{
error::Error,
ffi::OsString,
fs::{self, File},
io::{self, Read, Write},
path::{Path, PathBuf},
};
use gba_core::{
bios,
machine::{Machine, MachineError, StepKind},
memory::ROM_CAPACITY,
};
const MAX_STEPS: u64 = 100_000_000;
const USAGE: &str = "usage: gameboy-rust --rom PATH --steps COUNT (1..=100000000); do not combine with demo or help options";
#[derive(Debug, PartialEq, Eq)]
pub struct Options {
path: PathBuf,
steps: u64,
}
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
let invalid = || io::Error::new(io::ErrorKind::InvalidInput, USAGE);
let mut path = None;
let mut steps = None;
let mut pairs = args.chunks_exact(2);
for pair in &mut pairs {
if pair[0] == "--rom" && path.is_none() && !pair[1].is_empty() {
path = Some(PathBuf::from(&pair[1]));
} else if pair[0] == "--steps" && steps.is_none() {
let value = pair[1].to_str().ok_or_else(invalid)?;
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid());
}
let count = value.parse::<u64>().map_err(|_| invalid())?;
if !(1..=MAX_STEPS).contains(&count) {
return Err(invalid());
}
steps = Some(count);
} else {
return Err(invalid());
}
}
if !pairs.remainder().is_empty() {
return Err(invalid());
}
Ok(Options {
path: path.ok_or_else(invalid)?,
steps: steps.ok_or_else(invalid)?,
})
}
fn validate_file(metadata: &fs::Metadata) -> io::Result<()> {
if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"ROM must be a regular file",
));
}
if metadata.len() > ROM_CAPACITY as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ROM exceeds the 32 MiB cartridge limit",
));
}
Ok(())
}
fn read_bounded(reader: impl Read, capacity: usize) -> io::Result<Vec<u8>> {
let mut bytes = Vec::new();
reader.take(capacity as u64 + 1).read_to_end(&mut bytes)?;
if bytes.len() > capacity {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ROM exceeds the cartridge byte limit",
));
}
if bytes.len() < 4 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ROM needs at least 4 bytes for the initial ARM instruction",
));
}
Ok(bytes)
}
fn load(path: &Path) -> io::Result<Vec<u8>> {
let result = (|| {
// Check before opening to reject ordinary directories and named pipes.
// This is not a guarantee against concurrent filesystem replacement.
validate_file(&fs::metadata(path)?)?;
let file = File::open(path)?;
validate_file(&file.metadata()?)?;
// Bound the read independently of metadata in case the file grows.
read_bounded(file, ROM_CAPACITY)
})();
result.map_err(|error: io::Error| {
io::Error::new(error.kind(), format!("cannot load ROM {path:?}: {error}"))
})
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Stats {
steps: u64,
instructions: u64,
irq_entries: u64,
dma_units: u64,
halt_idle: u64,
}
#[derive(Debug, PartialEq, Eq)]
enum Reason {
StepLimit,
Stopped,
Diagnostic(MachineError),
}
#[derive(Debug)]
struct Report {
stats: Stats,
reason: Reason,
}
fn run_bounded(machine: &mut Machine, limit: u64) -> Report {
let mut stats = Stats::default();
let reason = loop {
// STOP has no host input source here. Do not spin with a frozen clock.
// Check before the budget so STOP on the last step is reported correctly.
if machine.stopped() {
break Reason::Stopped;
}
if stats.steps == limit {
break Reason::StepLimit;
}
match machine.step() {
Ok(StepKind::Instruction) => stats.instructions += 1,
Ok(StepKind::IrqEntry) => stats.irq_entries += 1,
Ok(StepKind::Dma { .. }) => stats.dma_units += 1,
Ok(StepKind::HaltIdle) => stats.halt_idle += 1,
Ok(StepKind::StopIdle) => break Reason::Stopped,
Err(error) => break Reason::Diagnostic(error),
}
stats.steps += 1;
};
Report { stats, reason }
}
fn write_report(writer: &mut impl Write, machine: &Machine, report: &Report) -> io::Result<()> {
let reason = match report.reason {
Reason::StepLimit => "step limit reached",
Reason::Stopped => "STOP; input required (no input source in terminal mode)",
Reason::Diagnostic(_) => "emulation diagnostic",
};
writeln!(writer, "Result: {reason}")?;
let stats = &report.stats;
writeln!(
writer,
"Steps: {}; instructions: {}; IRQ entries: {}; DMA units: {}; HALT idle: {}",
stats.steps, stats.instructions, stats.irq_entries, stats.dma_units, stats.halt_idle,
)?;
let cpu = machine.cpu();
writeln!(writer, "Nominal cycles: {}", machine.cycles())?;
writeln!(
writer,
"PC={:#010x} CPSR={:#010x} {:?}/{:?} halted={} stopped={}",
cpu.pc(),
cpu.cpsr(),
cpu.mode(),
cpu.instruction_set(),
machine.halted(),
machine.stopped(),
)?;
for (row, registers) in cpu.registers().chunks(4).enumerate() {
for (column, register) in registers.iter().enumerate() {
if column != 0 {
write!(writer, " ")?;
}
write!(writer, "r{}={register:#010x}", row * 4 + column)?;
}
writeln!(writer)?;
}
Ok(())
}
pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn Error>> {
let bytes = load(&options.path)?;
writeln!(writer, "ROM: {:?} ({} bytes)", options.path, bytes.len())?;
writeln!(
writer,
"Original BIOS replacement; no commercial-game compatibility claim."
)?;
writeln!(
writer,
"Machine-step limit: {} (includes boot)",
options.steps
)?;
let mut machine = bios::boot(bytes)?;
let report = run_bounded(&mut machine, options.steps);
write_report(writer, &machine, &report)?;
writer.flush()?;
if let Reason::Diagnostic(error) = report.reason {
return Err(Box::new(error));
}
Ok(())
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,301 @@
use super::*;
use gba_core::{
cpu::Cpu,
dma::DMA_BASE,
io::{HALTCNT, IE, IME, TIMER_BASE},
memory::{Memory, ROM_START},
};
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
fn boot(code: &[u32]) -> Machine {
bios::boot(code.iter().flat_map(|word| word.to_le_bytes()).collect()).unwrap()
}
#[test]
fn options_require_both_flags_in_either_order() {
for count in [1, MAX_STEPS] {
let count_text = count.to_string();
for arguments in [
args(&["--rom", "original program.gba", "--steps", &count_text]),
args(&["--steps", &count_text, "--rom", "original program.gba"]),
] {
assert_eq!(
parse_args(&arguments).unwrap(),
Options {
path: "original program.gba".into(),
steps: count
}
);
}
}
}
#[test]
fn options_reject_missing_duplicate_conflicting_and_invalid_values() {
for arguments in [
vec![],
vec!["--rom"],
vec!["--steps"],
vec!["--rom", "test.gba"],
vec!["--steps", "1"],
vec!["--rom", "", "--steps", "1"],
vec!["--rom", "test.gba", "--steps"],
vec!["--rom", "test.gba", "--steps", "1", "--help"],
vec!["--rom", "test.gba", "--steps", "1", "--cpu-demo", "1"],
vec!["--rom", "test.gba", "--rom", "test2.gba", "--steps", "1"],
vec!["--rom", "test.gba", "--steps", "1", "--steps", "2"],
vec!["--unknown", "test.gba", "--steps", "1"],
] {
assert!(parse_args(&args(&arguments)).is_err(), "{arguments:?}");
}
for count in [
"",
"0",
"-1",
"+1",
" 1",
"1 ",
"1.0",
"0x10",
"1",
"100000001",
"18446744073709551616",
] {
assert!(
parse_args(&args(&["--rom", "test.gba", "--steps", count])).is_err(),
"{count:?}"
);
}
}
#[cfg(unix)]
#[test]
fn options_preserve_non_utf8_paths_but_reject_non_utf8_counts() {
use std::os::unix::ffi::OsStringExt;
let raw = OsString::from_vec(b"original-\xff.gba".to_vec());
let mut arguments = args(&["--rom", "unused", "--steps", "1"]);
arguments[1] = raw.clone();
assert_eq!(
parse_args(&arguments).unwrap().path,
PathBuf::from(raw.clone())
);
arguments[3] = raw;
assert!(parse_args(&arguments).is_err());
}
#[test]
fn bounded_reader_accepts_exact_capacity_and_does_not_pad_odd_lengths() {
for length in 4..=8 {
let bytes = vec![0x42; length];
assert_eq!(read_bounded(bytes.as_slice(), 8).unwrap(), bytes);
}
for length in [0, 1, 2, 3, 9] {
assert_eq!(
read_bounded(vec![0; length].as_slice(), 8)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
}
}
#[test]
fn bounded_reader_consumes_at_most_capacity_plus_one() {
let bytes = [0; 20];
let mut reader = io::Cursor::new(bytes);
assert!(read_bounded(&mut reader, 8).is_err());
assert_eq!(reader.position(), 9);
}
#[test]
fn reader_handles_short_reads_and_propagates_errors() {
struct ByteReader {
remaining: usize,
}
impl Read for ByteReader {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
if self.remaining == 0 || output.is_empty() {
return Ok(0);
}
self.remaining -= 1;
output[0] = 0x42;
Ok(1)
}
}
assert_eq!(
read_bounded(ByteReader { remaining: 5 }, 8).unwrap(),
[0x42; 5]
);
struct FailingReader;
impl Read for FailingReader {
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other("read failed"))
}
}
assert_eq!(
read_bounded(FailingReader, 8).unwrap_err().to_string(),
"read failed"
);
}
#[test]
fn bounded_runner_includes_boot_and_retains_original_program_results() {
let mut machine = boot(&[0xe3a0_002a, 0xeaff_fffe]); // MOV r0,#42; B .
let first = run_bounded(&mut machine, 1);
assert_eq!(first.reason, Reason::StepLimit);
assert_eq!(first.stats.instructions, 1);
assert!(machine.cpu().pc() < ROM_START);
let report = run_bounded(&mut machine, 100);
assert_eq!(report.reason, Reason::StepLimit);
assert_eq!(report.stats.steps, 100);
assert_eq!(report.stats.instructions, 100);
assert_eq!(machine.cpu().registers()[0], 42);
assert_eq!(machine.cpu().pc(), ROM_START + 4);
}
#[test]
fn original_program_can_switch_to_thumb() {
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]); // MOV r0,#42; B . in Thumb.
let mut machine = bios::boot(bytes).unwrap();
let report = run_bounded(&mut machine, 100);
assert_eq!(report.reason, Reason::StepLimit);
assert_eq!(
machine.cpu().instruction_set(),
gba_core::cpu::InstructionSet::Thumb
);
assert_eq!(machine.cpu().registers()[0], 42);
assert_eq!(machine.cpu().pc(), ROM_START + 14);
}
#[test]
fn diagnostics_keep_the_failed_pc_registers_and_clock() {
for code in [vec![0xee00_0000], vec![0xe3a0_002a]] {
let mut machine = boot(&code);
let report = run_bounded(&mut machine, 100);
assert!(matches!(report.reason, Reason::Diagnostic(_)));
assert!(report.stats.steps < 100);
assert_eq!(
machine.cpu().pc(),
ROM_START + if code[0] == 0xee00_0000 { 0 } else { 4 }
);
let cpu = machine.cpu().clone();
let cycles = machine.cycles();
let retry = run_bounded(&mut machine, 100);
assert_eq!(retry.stats.steps, 0);
assert_eq!(retry.reason, report.reason);
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), cycles);
}
}
#[test]
fn stop_finishes_promptly_even_at_the_exact_budget_boundary() {
let code = [0xef03_0000, 0xeaff_fffe]; // SWI Stop; B .
let mut machine = boot(&code);
let report = run_bounded(&mut machine, 1000);
assert_eq!(report.reason, Reason::Stopped);
assert!(report.stats.steps > 0 && report.stats.steps < 1000);
let cycles = machine.cycles();
let retry = run_bounded(&mut machine, 1000);
assert_eq!(retry.reason, Reason::Stopped);
assert_eq!(retry.stats.steps, 0);
assert_eq!(machine.cycles(), cycles);
let mut exact = boot(&code);
assert_eq!(
run_bounded(&mut exact, report.stats.steps).reason,
Reason::Stopped
);
}
#[test]
fn halt_idles_consume_the_budget() {
let mut machine = boot(&[0xef02_0000, 0xeaff_fffe]);
let report = run_bounded(&mut machine, 1000);
assert_eq!(report.reason, Reason::StepLimit);
assert_eq!(report.stats.steps, 1000);
assert!(report.stats.halt_idle > 0);
assert_eq!(report.stats.instructions + report.stats.halt_idle, 1000);
assert!(machine.halted());
assert!(!machine.stopped());
}
#[test]
fn dma_units_and_irq_entries_each_consume_one_step() {
let mut machine = boot(&[0xeaff_fffe]);
run_bounded(&mut machine, 100);
let pc = machine.cpu().pc();
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 report = run_bounded(&mut machine, 2);
assert_eq!(report.stats.dma_units, 2);
assert_eq!(report.stats.instructions, 0);
assert_eq!(machine.cpu().pc(), pc);
let report = run_bounded(&mut machine, 1);
assert_eq!(report.stats.irq_entries, 1);
assert_eq!(report.stats.steps, 1);
assert_eq!(machine.cpu().pc(), 0x18);
}
#[test]
fn dma_errors_return_a_diagnostic_without_consuming_a_step() {
let mut machine = boot(&[0xeaff_fffe]);
let bus = machine.memory_mut();
bus.write32(DMA_BASE, 0x0200_0000).unwrap();
bus.write32(DMA_BASE + 4, ROM_START).unwrap();
bus.write32(DMA_BASE + 8, 0x8000_0001).unwrap();
let report = run_bounded(&mut machine, 100);
assert!(matches!(
report.reason,
Reason::Diagnostic(MachineError::Dma(_))
));
assert_eq!(report.stats.steps, 0);
assert_eq!(machine.cycles(), 0);
}
#[test]
fn report_shows_state_and_propagates_write_errors() {
let mut machine = Machine::new(Cpu::new(ROM_START), Memory::new(vec![]).unwrap());
machine.memory_mut().write8(HALTCNT, 0x80).unwrap();
let report = run_bounded(&mut machine, 100);
let mut output = Vec::new();
write_report(&mut output, &machine, &report).unwrap();
let output = String::from_utf8(output).unwrap();
for expected in [
"Result: STOP",
"Steps: 0",
"Nominal cycles: 0",
"PC=0x08000000",
"halted=false stopped=true",
"r15=0x08000000",
] {
assert!(output.contains(expected), "{output}");
}
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
assert_eq!(
write_report(&mut FailingWriter, &machine, &report)
.unwrap_err()
.kind(),
io::ErrorKind::BrokenPipe
);
}

View file

@ -1,6 +1,7 @@
mod cartridge;
mod desktop;
use std::{error::Error, ffi::OsString, io};
use std::{error::Error, ffi::OsString, io, process::ExitCode};
use gba_core::{
cpu::Cpu,
@ -17,6 +18,7 @@ use gba_demos::{
#[derive(Debug, PartialEq, Eq)]
enum RunMode {
Window,
Rom(cartridge::Options),
SmokeTest,
CpuDemo,
TimerDemo,
@ -40,6 +42,9 @@ enum RunMode {
}
fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
if args.iter().any(|arg| arg == "--rom" || arg == "--steps") {
return cartridge::parse_args(args).map(RunMode::Rom);
}
match args {
[] => Ok(RunMode::Window),
[arg] if arg == "--smoke-test" => Ok(RunMode::SmokeTest),
@ -66,14 +71,25 @@ 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]; ROM loading is not supported yet",
"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",
)),
}
}
fn main() -> Result<(), Box<dyn Error>> {
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
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::SmokeTest => desktop::run(Some(60)),
RunMode::CpuDemo => run_cpu_demo(),
RunMode::TimerDemo => run_timer_demo(),
@ -97,6 +113,7 @@ fn main() -> Result<(), Box<dyn Error>> {
println!(
"GBA Rust — emulator foundation, not game-compatible yet\n\
No arguments Open the native 240x160 display test at 4x scale\n\
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\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\
@ -128,7 +145,8 @@ fn main() -> Result<(), Box<dyn Error>> {
Affine raster: Arrows pan; Q rotates; W zooms; Z bypasses distortion; Enter resets; Escape exits.\n\
Affine backgrounds: Arrows pan; Q rotates; W zooms; Z disables wrapping; Enter resets; Escape exits.\n\
Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\
All demos use original test content. Game ROM loading is not supported."
All demos use original test content. ROM mode uses our limited BIOS replacement.\n\
ROM mode has no window, input, audio, or saves. Commercial games are not supported."
);
Ok(())
}
@ -244,7 +262,7 @@ mod tests {
}
#[test]
fn command_line_rejects_rom_paths_and_conflicting_options() {
fn command_line_rejects_bare_paths_and_conflicting_options() {
assert!(parse_args(&["emerald.gba".into()]).is_err());
assert!(parse_args(&["--unknown".into()]).is_err());
assert!(parse_args(&["--cpu-demo".into(), "--smoke-test".into()]).is_err());