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:
parent
c836944c2e
commit
c256bf8ccd
13 changed files with 837 additions and 13 deletions
212
crates/desktop/src/cartridge.rs
Normal file
212
crates/desktop/src/cartridge.rs
Normal 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;
|
||||
301
crates/desktop/src/cartridge/tests.rs
Normal file
301
crates/desktop/src/cartridge/tests.rs
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
208
crates/desktop/tests/rom_cli.rs
Normal file
208
crates/desktop/tests/rom_cli.rs
Normal file
|
|
@ -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<u8> = 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue