From c256bf8ccd55ad03c854b3d56bfbbd2740ea326f Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Mon, 5 Oct 2026 18:57:16 +0700 Subject: [PATCH] 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. --- AGENTS.md | 1 + README.md | 10 +- crates/desktop/src/cartridge.rs | 212 ++++++++++++++++++ crates/desktop/src/cartridge/tests.rs | 301 ++++++++++++++++++++++++++ crates/desktop/src/main.rs | 28 ++- crates/desktop/tests/rom_cli.rs | 208 ++++++++++++++++++ crates/gba-core/src/memory.rs | 3 +- docs/architecture.md | 5 +- docs/development.md | 1 + docs/hardware/bios.md | 3 +- docs/hardware/cartridge.md | 64 ++++++ docs/status.md | 7 +- docs/testing.md | 7 + 13 files changed, 837 insertions(+), 13 deletions(-) create mode 100644 crates/desktop/src/cartridge.rs create mode 100644 crates/desktop/src/cartridge/tests.rs create mode 100644 crates/desktop/tests/rom_cli.rs create mode 100644 docs/hardware/cartridge.md diff --git a/AGENTS.md b/AGENTS.md index 9f3828f..57233c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Window smoke tests (`--*-smoke-test`) need a logged-in desktop session. Details | Working on | Read | | --- | --- | | ARM/Thumb instructions, modes, exceptions, timing | [docs/hardware/cpu.md](docs/hardware/cpu.md) | +| ROM files and bounded terminal execution | [docs/hardware/cartridge.md](docs/hardware/cartridge.md) | | BIOS services (SWI) | [docs/hardware/bios.md](docs/hardware/bios.md) | | Timers, IE/IF/IME, HALT/STOP | [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) | | DMA | [docs/hardware/dma.md](docs/hardware/dma.md) | diff --git a/README.md b/README.md index ab83846..f3bce8a 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ Game Boy family emulation in Rust, written from scratch. The Game Boy Advance (GBA) is the first and currently only system. -**This cannot run Pokémon Emerald or other games yet.** +**This cannot run Pokémon Emerald or other commercial games yet.** It is an emulator foundation: an ARM7TDMI interpreter, memory bus, timers, DMA, Mode 0–5 video, and an original BIOS replacement. -The included demos run original ARM test programs through the emulated hardware. ROM loading, audio, and saves are not implemented. +The included demos run original ARM test programs through the emulated hardware. +Raw ROM files can run in bounded terminal sessions. ROM windows, audio, and saves are not implemented. ## Layout @@ -26,7 +27,9 @@ direnv exec . cargo test --locked ``` Arrows scroll, Q rotates, W zooms, Z flips, X changes priority, Enter resets, and Escape exits. -Run `direnv exec . cargo run --locked -- --help` to list every demo and smoke test. +Run `direnv exec . cargo run --locked -- --help` to list all modes. +For original ROM files, use `--rom path/to/original.gba --steps 100000`. +See [cartridge loading](docs/hardware/cartridge.md) for limits and report meanings. ## Documentation @@ -38,6 +41,7 @@ Run `direnv exec . cargo run --locked -- --help` to list every demo and smoke te | [docs/testing.md](docs/testing.md) | Validation commands and what the test suites cover | | [docs/architecture.md](docs/architecture.md) | Crate boundaries and a file-by-file map | | [docs/hardware/cpu.md](docs/hardware/cpu.md) | ARM/Thumb execution rules, modes, exceptions, and timing | +| [docs/hardware/cartridge.md](docs/hardware/cartridge.md) | Raw ROM loading, bounded terminal execution, and diagnostics | | [docs/hardware/bios.md](docs/hardware/bios.md) | The original BIOS replacement and each supported service | | [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) | Timers, interrupt registers, HALT/STOP, and power control | | [docs/hardware/dma.md](docs/hardware/dma.md) | DMA channels, triggers, and timing | diff --git a/crates/desktop/src/cartridge.rs b/crates/desktop/src/cartridge.rs new file mode 100644 index 0000000..54c9350 --- /dev/null +++ b/crates/desktop/src/cartridge.rs @@ -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 { + 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::().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> { + 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> { + 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> { + 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; diff --git a/crates/desktop/src/cartridge/tests.rs b/crates/desktop/src/cartridge/tests.rs new file mode 100644 index 0000000..5c3f0e7 --- /dev/null +++ b/crates/desktop/src/cartridge/tests.rs @@ -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 { + 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 { + 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 { + 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 = [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 { + 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 + ); +} diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index 29b9188..a6c1794 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -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 { + 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 { [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> { +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Box> { match parse_args(&std::env::args_os().skip(1).collect::>())? { 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> { 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> { 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()); diff --git a/crates/desktop/tests/rom_cli.rs b/crates/desktop/tests/rom_cli.rs new file mode 100644 index 0000000..c503d8d --- /dev/null +++ b/crates/desktop/tests/rom_cli.rs @@ -0,0 +1,208 @@ +//! Process-level checks use only original instructions and temporary raw files. +use std::{ + fs::{self, File}, + path::{Path, PathBuf}, + process::{Command, Output}, + sync::atomic::{AtomicUsize, Ordering}, +}; + +use gba_core::memory::ROM_CAPACITY; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "gameboy-rust-rom-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn rom(&self, code: &[u32]) -> PathBuf { + let path = self.0.join("original program.gba"); + let bytes: Vec = code.iter().flat_map(|word| word.to_le_bytes()).collect(); + fs::write(&path, bytes).unwrap(); + path + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_gameboy-rust")) +} +fn run(path: &Path, steps: &str) -> Output { + command() + .arg("--rom") + .arg(path) + .args(["--steps", steps]) + .output() + .unwrap() +} +fn stdout(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).unwrap() +} +fn stderr(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).unwrap() +} + +#[test] +fn raw_original_program_runs_with_a_bounded_report_without_changing_the_file() { + let fixture = Fixture::new(); + let path = fixture.rom(&[0xe3a0_002a, 0xeaff_fffe]); + let original = fs::read(&path).unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions).unwrap(); + let output = run(&path, "100"); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + for expected in [ + "(8 bytes)", + "Original BIOS replacement", + "Result: step limit reached", + "Steps: 100", + "PC=0x08000004", + "r0=0x0000002a", + ] { + assert!(text.contains(expected), "{text}"); + } + assert!(output.stderr.is_empty()); + assert_eq!(fs::read(path).unwrap(), original); +} + +#[test] +fn stop_is_a_normal_bounded_outcome() { + let fixture = Fixture::new(); + let path = fixture.rom(&[0xef03_0000, 0xeaff_fffe]); + let output = run(&path, "1000"); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains("Result: STOP; input required")); + assert!(stdout(&output).contains("stopped=true")); + assert!(!stdout(&output).contains("Steps: 1000;")); +} + +#[test] +fn unsupported_instruction_and_fallthrough_fail_with_machine_state() { + let fixture = Fixture::new(); + for code in [[0xee00_0000], [0xe3a0_002a]] { + let path = fixture.rom(&code); + let output = run(&path, "100"); + assert_eq!(output.status.code(), Some(1)); + assert!(stdout(&output).contains("Result: emulation diagnostic")); + assert!(stdout(&output).contains("r15=0x080000")); + assert!(!output.stderr.is_empty()); + } +} + +#[test] +fn missing_short_directory_and_oversized_files_fail_before_execution() { + let fixture = Fixture::new(); + let path = fixture.0.join("input.gba"); + for candidate in [&path, &fixture.0] { + let output = run(candidate, "1"); + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).contains("cannot load ROM")); + assert!(output.stdout.is_empty()); + } + for size in [0, 1, 2, 3, ROM_CAPACITY + 1] { + File::create(&path).unwrap().set_len(size as u64).unwrap(); + let output = run(&path, "1"); + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).contains(if size < 4 { + "at least 4 bytes" + } else { + "32 MiB" + })); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn exact_capacity_is_accepted_and_odd_lengths_are_not_padded() { + let fixture = Fixture::new(); + let path = fixture.rom(&[0xeaff_fffe]); + File::options() + .write(true) + .open(&path) + .unwrap() + .set_len(ROM_CAPACITY as u64) + .unwrap(); + let output = run(&path, "100"); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains("(33554432 bytes)")); + fs::write(&path, [0xfe, 0xff, 0xff, 0xea, 0x42]).unwrap(); + let output = run(&path, "100"); + assert!(output.status.success()); + assert!(stdout(&output).contains("(5 bytes)")); +} + +#[test] +fn invalid_options_fail_before_opening_a_file() { + for count in ["0", "-1", "100000001", "18446744073709551616"] { + let output = run(Path::new("does-not-exist.gba"), count); + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).contains("usage:")); + assert!(!stderr(&output).contains("cannot load")); + } + for extra in ["--cpu-demo", "--help", "--rom", "--steps"] { + let output = command() + .args(["--rom", "missing.gba", "--steps", "1", extra]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).contains("usage:")); + } + let output = command().args(["--steps", "1"]).output().unwrap(); + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn help_describes_terminal_rom_mode_and_existing_cpu_demo_still_runs() { + let output = command().arg("--help").output().unwrap(); + assert!(output.status.success()); + assert!(stdout(&output).contains("--rom PATH --steps COUNT")); + assert!(stdout(&output).contains("no window, input, audio, or saves")); + let output = command().arg("--cpu-demo").output().unwrap(); + assert!(output.status.success()); + assert!(stdout(&output).contains("Exception demo")); +} + +#[cfg(unix)] +#[test] +fn non_utf8_paths_reach_the_loader_without_panicking() { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + let fixture = Fixture::new(); + // Darwin filesystems can reject invalid UTF-8. Do not require creating one. + let path = fixture.0.join(OsStr::from_bytes(b"missing-\xff.gba")); + let output = run(&path, "1"); + assert_eq!(output.status.code(), Some(1)); + assert!(stderr(&output).contains("cannot load ROM")); + assert!(!stderr(&output).contains("usage:")); + assert!(output.stdout.is_empty()); +} + +#[cfg(unix)] +#[test] +fn unicode_paths_and_regular_file_symlinks_work() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let original = fixture.rom(&[0xeaff_fffe]); + for name in ["original-λ.gba", "original-link.gba"] { + let path = fixture.0.join(name); + symlink(&original, &path).unwrap(); + let output = command() + .args(["--steps", "100", "--rom"]) + .arg(path) + .output() + .unwrap(); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout(&output).contains("Result: step limit reached")); + } +} diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index 02b535b..1969359 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -10,7 +10,8 @@ use crate::{ }; pub const ROM_START: u32 = 0x0800_0000; -const ROM_CAPACITY: usize = 32 * 1024 * 1024; +/// Maximum supplied cartridge size, shared by all three Game Pak ROM windows. +pub const ROM_CAPACITY: usize = 32 * 1024 * 1024; pub const BIOS_SIZE: usize = 16 * 1024; pub const PALETTE_START: u32 = 0x0500_0000; pub const VRAM_START: u32 = 0x0600_0000; diff --git a/docs/architecture.md b/docs/architecture.md index f56800f..f7ee514 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,10 @@ The repository is a Cargo workspace with three crates: | `crates/desktop/src/desktop/effects.rs` | Window/effects demo and independent smoke-test pixel checks | | `crates/desktop/src/desktop/mosaic.rs` | Mosaic window and 128-frame independent pixel checks | | `crates/desktop/src/desktop/raster.rs` | Raster window and scanline color-band smoke test | -| `crates/desktop/src/main.rs` | Command-line modes and fixed-length CPU demo | +| `crates/desktop/src/main.rs` | Command-line modes, error exit status, and fixed-length CPU demo | +| `crates/desktop/src/cartridge.rs` | Read-only ROM files, argument checks, bounded machine execution, and terminal reports | +| `crates/desktop/src/cartridge/tests.rs` | ROM options, bounded readers, instruction/DMA/IRQ budgets, HALT/STOP, and diagnostic reports | +| `crates/desktop/tests/rom_cli.rs` | Process-level loading and exit-status checks using temporary original programs | | `crates/gba-core/tests/core.rs` | CPU integration tests and the complete demo | | `crates/gba-core/tests/memory.rs` | Memory widths, alignment, errors, and mirrors | | `crates/gba-core/tests/exceptions.rs` | Exception demo, BIOS mapping, and reset-vector execution | diff --git a/docs/development.md b/docs/development.md index 3733b14..0dad210 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,6 +17,7 @@ Verified locally on macOS 15.7.3: - All ten native window smoke tests pass. The mosaic test submits 128 frames; the other nine submit 60 frames each. - The eight CPU-driven smoke tests check CPU state, every output pixel, and presentation at VBlank entry. - The terminal-only CPU and timer IRQ demos run successfully. +- The bounded ROM runner loads original raw programs and reports state without opening a window. These results cover the CPU core, memory, Mode 0–5 snapshots and row capture, input mapping, and desktop windows. They do not verify a complete GBA display controller or audio. diff --git a/docs/hardware/bios.md b/docs/hardware/bios.md index 9696e26..a994eff 100644 --- a/docs/hardware/bios.md +++ b/docs/hardware/bios.md @@ -26,7 +26,8 @@ Minimal boot initializes: It then enters `0x08000000` in ARM System mode with IRQ/FIQ masks clear. It does not reproduce Nintendo's logo, cartridge-header checks, RAM clearing, boot delays, or full hardware initialization. -The executable still does not load game files. +The executable can load raw ROM files for [bounded terminal diagnostics](cartridge.md) using this boot sequence. +This does not establish game compatibility or complete BIOS behavior. Supported software interrupt services: diff --git a/docs/hardware/cartridge.md b/docs/hardware/cartridge.md new file mode 100644 index 0000000..e81671d --- /dev/null +++ b/docs/hardware/cartridge.md @@ -0,0 +1,64 @@ +# Cartridge files and terminal execution + +The desktop executable can load raw ROM bytes and run a bounded diagnostic session. +This supports original test programs. It does not establish commercial-game compatibility. + +```sh +direnv exec . cargo run --locked --release -- --rom path/to/original.gba --steps 100000 +``` + +Both options are required. Their order does not matter. +`COUNT` must contain decimal digits and be between 1 and 100,000,000 inclusive. +Do not combine ROM mode with demo or help options. Bare file paths are not accepted. +Quote paths that contain spaces. File extensions do not affect loading. + +## Loading and boot + +`crates/desktop/src/cartridge.rs` owns file access, argument checks, execution limits, and reports. +The core remains independent of the filesystem and window library. +The loader opens a regular file read-only and accepts 4 bytes through 32 MiB inclusive. +It checks metadata before and after opening, then independently bounds the read to the limit plus one byte. +This bounds file content reads even if the file grows. It is not a filesystem race protection mechanism. +Directories and other non-regular files are rejected. Symlinks to regular files are accepted. +Paths retain their operating-system representation and appear quoted in diagnostics. +The host filesystem can reject filenames, including invalid UTF-8 on Darwin. + +Bytes are not padded, patched, or written back. +The loader does not validate a cartridge header, logo, or checksum. +It does not decode archives or ELF executables. Supply a raw binary with an ARM instruction at offset zero. +Headerless original programs are accepted. +Reads past supplied cartridge bytes remain explicit memory errors, not open-bus values. +All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules. +Cartridge writes, save devices, and cartridge peripherals remain unsupported. + +The runner calls `bios::boot` with the loaded bytes. +The [original BIOS replacement](bios.md) executes its minimal boot before entering `0x08000000` in ARM System mode. +There is no command-line option for external BIOS files. +Unsupported services remain diagnostics. In particular, RegisterRamReset sound/serial flags, including `r0=0xff`, still fail. + +## Execution limits and results + +The budget counts successful machine steps, including BIOS boot instructions. +Each CPU instruction, IRQ entry, DMA unit, or HALT idle batch consumes one step. +The budget is not an instruction-only, cycle, frame, or wall-clock limit. +HALT continues advancing devices within the budget. +STOP returns immediately because terminal mode has no input source to wake the machine. +STOP on the final allowed step is reported as STOP, not as budget exhaustion. +A failed CPU or DMA step consumes no budget and retains the machine state from earlier successful steps. + +The report includes: + +- Quoted path, byte count, and requested step limit. +- Stop reason and successful-step counters by kind. +- Nominal emulated cycles, PC, CPSR, CPU mode, and ARM/Thumb state. +- HALT/STOP flags and all active registers r0–r15. + +Exit status zero means the step limit was reached or STOP was entered. +It does not mean the program completed or passed a hardware test. +Invalid arguments, loading failures, output failures, and emulation diagnostics produce a nonzero exit status. +For emulation diagnostics, stdout contains the final machine state and stderr contains the core error. + +ROM mode has no window, keyboard input, audio, saves, per-step trace, or real-time pacing. +It does not enable frame capture or validate rendered output. +Use the existing demos for window and input tests. +Only load files that you may lawfully use. Do not commit game ROMs or firmware. diff --git a/docs/status.md b/docs/status.md index 85d8dc3..a690e57 100644 --- a/docs/status.md +++ b/docs/status.md @@ -62,6 +62,7 @@ Update this file when a feature lands or a limit is removed. - `SWP`/`SWPB`, including unaligned word rotation and register aliases. - 256 KiB external work RAM and 32 KiB internal work RAM, including mirrors. - Read-only cartridge bytes in the three GBA cartridge windows. +- Raw ROM-file loading for bounded terminal diagnostics, with original BIOS boot and final CPU/step reports. - Little-endian byte, halfword, and word reads/writes. - ARM7TDMI word-load rotation, aligned stores, and odd-address halfword-load behavior. - Explicit errors for unsupported instructions when their condition passes, and for unsupported memory accesses. @@ -102,7 +103,9 @@ Keypad IRQs use a documented polling model; hardware retrigger edges and asynchr STOP implements keypad wake only. Serial/Game Pak wake sources, oscillator restart delays, and exact wake timing remain unimplemented or unverified. The presentation framebuffer is separate from emulated video RAM. All named graphics demos connect desktop input and rendering to the emulated CPU. The default display test remains host-generated. -The executable does not accept ROM files yet. +ROM files can run in bounded terminal mode with the original BIOS replacement. +This mode has no window, input, audio, saves, or external BIOS option. It does not establish commercial-game compatibility. +See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status. Unmapped reads return errors instead of hardware open-bus values. Reads beyond the supplied cartridge bytes also return errors. @@ -117,7 +120,7 @@ Writes to cartridge addresses return errors instead of modeling cartridge hardwa 3. Validate keypad retrigger behavior with hardware tests and add remaining device registers; extend BIOS reset coverage as sound/serial support becomes available. Validate STOP entry/wake edges and add external wake sources and remaining DMA device modes. 4. Replace nominal sprite work limits with verified individual fetch timing; add background fetch timing and per-pixel composition. -5. Expand graphics, audio, cartridge loading, and saves before testing Emerald compatibility. +5. Connect ROM execution to the window and input; expand audio, cartridge hardware, and saves before testing Emerald compatibility. Keep the emulator core independent of window and audio libraries. Treat Nintendo DS support as a separate project phase. diff --git a/docs/testing.md b/docs/testing.md index bfce7db..3160093 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -37,6 +37,13 @@ The core's integration tests use `gba-demos` as a development dependency. On Apple Silicon, `file` must report a Mach-O `arm64` executable. Do not set a Linux cross-compilation target for this validation. +ROM-runner tests use temporary files containing original instructions, without Nintendo assets or a desktop session. +They check file-size boundaries, bounded reads, short reads, I/O errors, paths, symlinks, and read-only loading. +CLI tests cover option order, missing/conflicting options, invalid step limits, process exit status, and reports. +Execution tests cover original ARM/Thumb code, BIOS boot, DMA/IRQ step accounting, HALT budgets, and prompt STOP results. +Unsupported instructions, truncated code, and DMA errors retain the failure state for inspection. +See [cartridge execution](hardware/cartridge.md) for usage and limitations. + Angle-service tests compare results with wide-integer polynomial and signed-sector references, plus floating-point accuracy checks in supported ranges. They cover a dense ArcTan unit-interval grid, sampled full-domain and seeded inputs, axes, quadrant boundaries, signed extremes, and scale invariance. Other tests verify all caller flag combinations, User/System masks, non-result registers, stack bounds, repeated calls, diagnostics, and DMA/IRQ progress.