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
|
|
@ -34,6 +34,7 @@ Window smoke tests (`--*-smoke-test`) need a logged-in desktop session. Details
|
||||||
| Working on | Read |
|
| Working on | Read |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ARM/Thumb instructions, modes, exceptions, timing | [docs/hardware/cpu.md](docs/hardware/cpu.md) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| DMA | [docs/hardware/dma.md](docs/hardware/dma.md) |
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -3,9 +3,10 @@
|
||||||
Game Boy family emulation in Rust, written from scratch.
|
Game Boy family emulation in Rust, written from scratch.
|
||||||
The Game Boy Advance (GBA) is the first and currently only system.
|
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.
|
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
|
## 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.
|
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
|
## 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/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/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/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/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/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 |
|
| [docs/hardware/dma.md](docs/hardware/dma.md) | DMA channels, triggers, and timing |
|
||||||
|
|
|
||||||
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;
|
mod desktop;
|
||||||
|
|
||||||
use std::{error::Error, ffi::OsString, io};
|
use std::{error::Error, ffi::OsString, io, process::ExitCode};
|
||||||
|
|
||||||
use gba_core::{
|
use gba_core::{
|
||||||
cpu::Cpu,
|
cpu::Cpu,
|
||||||
|
|
@ -17,6 +18,7 @@ use gba_demos::{
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum RunMode {
|
enum RunMode {
|
||||||
Window,
|
Window,
|
||||||
|
Rom(cartridge::Options),
|
||||||
SmokeTest,
|
SmokeTest,
|
||||||
CpuDemo,
|
CpuDemo,
|
||||||
TimerDemo,
|
TimerDemo,
|
||||||
|
|
@ -40,6 +42,9 @@ enum RunMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
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 {
|
match args {
|
||||||
[] => Ok(RunMode::Window),
|
[] => Ok(RunMode::Window),
|
||||||
[arg] if arg == "--smoke-test" => Ok(RunMode::SmokeTest),
|
[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),
|
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
|
||||||
_ => Err(io::Error::new(
|
_ => Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
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<_>>())? {
|
match parse_args(&std::env::args_os().skip(1).collect::<Vec<_>>())? {
|
||||||
RunMode::Window => desktop::run(None),
|
RunMode::Window => desktop::run(None),
|
||||||
|
RunMode::Rom(options) => cartridge::execute(options, &mut io::stdout().lock()),
|
||||||
RunMode::SmokeTest => desktop::run(Some(60)),
|
RunMode::SmokeTest => desktop::run(Some(60)),
|
||||||
RunMode::CpuDemo => run_cpu_demo(),
|
RunMode::CpuDemo => run_cpu_demo(),
|
||||||
RunMode::TimerDemo => run_timer_demo(),
|
RunMode::TimerDemo => run_timer_demo(),
|
||||||
|
|
@ -97,6 +113,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
println!(
|
println!(
|
||||||
"GBA Rust — emulator foundation, not game-compatible yet\n\
|
"GBA Rust — emulator foundation, not game-compatible yet\n\
|
||||||
No arguments Open the native 240x160 display test at 4x scale\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\
|
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
|
||||||
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\
|
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\
|
||||||
--graphics-demo Open the CPU-driven Mode 3 demo\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 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\
|
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\
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +262,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(&["emerald.gba".into()]).is_err());
|
||||||
assert!(parse_args(&["--unknown".into()]).is_err());
|
assert!(parse_args(&["--unknown".into()]).is_err());
|
||||||
assert!(parse_args(&["--cpu-demo".into(), "--smoke-test".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;
|
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 BIOS_SIZE: usize = 16 * 1024;
|
||||||
pub const PALETTE_START: u32 = 0x0500_0000;
|
pub const PALETTE_START: u32 = 0x0500_0000;
|
||||||
pub const VRAM_START: u32 = 0x0600_0000;
|
pub const VRAM_START: u32 = 0x0600_0000;
|
||||||
|
|
|
||||||
|
|
@ -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/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/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/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/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/memory.rs` | Memory widths, alignment, errors, and mirrors |
|
||||||
| `crates/gba-core/tests/exceptions.rs` | Exception demo, BIOS mapping, and reset-vector execution |
|
| `crates/gba-core/tests/exceptions.rs` | Exception demo, BIOS mapping, and reset-vector execution |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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 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 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.
|
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.
|
They do not verify a complete GBA display controller or audio.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ Minimal boot initializes:
|
||||||
|
|
||||||
It then enters `0x08000000` in ARM System mode with IRQ/FIQ masks clear.
|
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.
|
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:
|
Supported software interrupt services:
|
||||||
|
|
||||||
|
|
|
||||||
64
docs/hardware/cartridge.md
Normal file
64
docs/hardware/cartridge.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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.
|
- `SWP`/`SWPB`, including unaligned word rotation and register aliases.
|
||||||
- 256 KiB external work RAM and 32 KiB internal work RAM, including mirrors.
|
- 256 KiB external work RAM and 32 KiB internal work RAM, including mirrors.
|
||||||
- Read-only cartridge bytes in the three GBA cartridge windows.
|
- 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.
|
- Little-endian byte, halfword, and word reads/writes.
|
||||||
- ARM7TDMI word-load rotation, aligned stores, and odd-address halfword-load behavior.
|
- 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.
|
- 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.
|
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.
|
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.
|
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.
|
Unmapped reads return errors instead of hardware open-bus values.
|
||||||
Reads beyond the supplied cartridge bytes also return errors.
|
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.
|
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.
|
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.
|
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.
|
Keep the emulator core independent of window and audio libraries.
|
||||||
Treat Nintendo DS support as a separate project phase.
|
Treat Nintendo DS support as a separate project phase.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
On Apple Silicon, `file` must report a Mach-O `arm64` executable.
|
||||||
Do not set a Linux cross-compilation target for this validation.
|
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.
|
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.
|
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.
|
Other tests verify all caller flag combinations, User/System masks, non-result registers, stack bounds, repeated calls, diagnostics, and DMA/IRQ progress.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue