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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue