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