feat(desktop): run ROM files in a window with keypad input
Add --rom PATH --window with optional bounded --frames execution. Capture from BIOS boot, pump native events between bounded machine slices, retain frames during STOP, and sample keypad input for wake-up. Share final machine reports with terminal mode. Add an original file-exportable input ROM, controller and CLI regressions, and opt-in native window tests. Document controls, frame limits, STOP behavior, and compatibility limits. Verified on Darwin arm64: debug/release workspace and core tests, Clippy, rustdoc, native ROM diagnostics, all ten demo smoke tests, and a 60-frame release ROM run.
This commit is contained in:
parent
c256bf8ccd
commit
109c1ffe81
19 changed files with 919 additions and 57 deletions
|
|
@ -34,7 +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) |
|
| ROM files, window execution, and terminal diagnostics | [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) |
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ The Game Boy Advance (GBA) is the first and currently only system.
|
||||||
**This cannot run Pokémon Emerald or other commercial 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.
|
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.
|
Raw ROM files can run in terminal sessions or a window with keyboard input. Audio and saves are not implemented.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ 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 all modes.
|
Run `direnv exec . cargo run --locked -- --help` to list all modes.
|
||||||
For original ROM files, use `--rom path/to/original.gba --steps 100000`.
|
For original ROM files, use `--rom path/to/original.gba --window`, or `--steps 100000` instead of `--window` for terminal diagnostics.
|
||||||
See [cartridge loading](docs/hardware/cartridge.md) for limits and report meanings.
|
See [cartridge loading](docs/hardware/cartridge.md) for limits and report meanings.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
@ -41,7 +41,7 @@ See [cartridge loading](docs/hardware/cartridge.md) for limits and report meanin
|
||||||
| [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/cartridge.md](docs/hardware/cartridge.md) | ROM loading, window/input controls, original ROM generator, 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 |
|
||||||
|
|
|
||||||
31
crates/desktop/examples/write_rom_demo.rs
Normal file
31
crates/desktop/examples/write_rom_demo.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
//! Export original input-test instructions, never game or firmware content.
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::{self, Write},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut args = std::env::args_os().skip(1);
|
||||||
|
let path = args.next().filter(|path| !path.is_empty()).ok_or_else(|| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"usage: write_rom_demo OUTPUT_PATH",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if args.next().is_some() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"usage: write_rom_demo OUTPUT_PATH",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&path)?;
|
||||||
|
file.write_all(&gba_demos::input_rom())?;
|
||||||
|
file.flush()?;
|
||||||
|
println!("Wrote original input-test ROM to {path:?}. Blue: idle; Z: red; Right: green.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
//! Host-only raw ROM loading and bounded terminal diagnostics.
|
//! Host-only raw ROM loading, execution modes, and diagnostics.
|
||||||
|
|
||||||
|
pub mod window;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
|
|
@ -15,42 +17,71 @@ use gba_core::{
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_STEPS: u64 = 100_000_000;
|
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";
|
const MAX_FRAMES: u64 = 100_000;
|
||||||
|
const USAGE: &str = "usage: gameboy-rust --rom PATH (--steps COUNT | --window [--frames COUNT]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Mode {
|
||||||
|
Terminal { steps: u64 },
|
||||||
|
Window { frames: Option<u64> },
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct Options {
|
pub struct Options {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
steps: u64,
|
mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
let invalid = || io::Error::new(io::ErrorKind::InvalidInput, USAGE);
|
let invalid = || io::Error::new(io::ErrorKind::InvalidInput, USAGE);
|
||||||
let mut path = None;
|
let mut path = None;
|
||||||
let mut steps = None;
|
let mut steps = None;
|
||||||
let mut pairs = args.chunks_exact(2);
|
let mut frames = None;
|
||||||
for pair in &mut pairs {
|
let mut window = false;
|
||||||
if pair[0] == "--rom" && path.is_none() && !pair[1].is_empty() {
|
let mut args = args.iter();
|
||||||
path = Some(PathBuf::from(&pair[1]));
|
while let Some(arg) = args.next() {
|
||||||
} else if pair[0] == "--steps" && steps.is_none() {
|
if arg == "--rom" && path.is_none() {
|
||||||
let value = pair[1].to_str().ok_or_else(invalid)?;
|
let value = args
|
||||||
|
.next()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(invalid)?;
|
||||||
|
path = Some(PathBuf::from(value));
|
||||||
|
} else if arg == "--window" && !window {
|
||||||
|
window = true;
|
||||||
|
} else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) {
|
||||||
|
let value = args
|
||||||
|
.next()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.ok_or_else(invalid)?;
|
||||||
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
let count = value.parse::<u64>().map_err(|_| invalid())?;
|
let count = value.parse::<u64>().map_err(|_| invalid())?;
|
||||||
if !(1..=MAX_STEPS).contains(&count) {
|
let max = if arg == "--steps" {
|
||||||
|
MAX_STEPS
|
||||||
|
} else {
|
||||||
|
MAX_FRAMES
|
||||||
|
};
|
||||||
|
if !(1..=max).contains(&count) {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
|
if arg == "--steps" {
|
||||||
steps = Some(count);
|
steps = Some(count);
|
||||||
|
} else {
|
||||||
|
frames = Some(count);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !pairs.remainder().is_empty() {
|
let mode = match (window, steps, frames) {
|
||||||
return Err(invalid());
|
(false, Some(steps), None) => Mode::Terminal { steps },
|
||||||
}
|
(true, None, frames) => Mode::Window { frames },
|
||||||
|
_ => return Err(invalid()),
|
||||||
|
};
|
||||||
Ok(Options {
|
Ok(Options {
|
||||||
path: path.ok_or_else(invalid)?,
|
path: path.ok_or_else(invalid)?,
|
||||||
steps: steps.ok_or_else(invalid)?,
|
mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +143,19 @@ struct Stats {
|
||||||
halt_idle: u64,
|
halt_idle: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Stats {
|
||||||
|
fn record(&mut self, kind: StepKind) {
|
||||||
|
match kind {
|
||||||
|
StepKind::Instruction => self.instructions += 1,
|
||||||
|
StepKind::IrqEntry => self.irq_entries += 1,
|
||||||
|
StepKind::Dma { .. } => self.dma_units += 1,
|
||||||
|
StepKind::HaltIdle => self.halt_idle += 1,
|
||||||
|
StepKind::StopIdle => return,
|
||||||
|
}
|
||||||
|
self.steps += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Reason {
|
enum Reason {
|
||||||
StepLimit,
|
StepLimit,
|
||||||
|
|
@ -137,14 +181,10 @@ fn run_bounded(machine: &mut Machine, limit: u64) -> Report {
|
||||||
break Reason::StepLimit;
|
break Reason::StepLimit;
|
||||||
}
|
}
|
||||||
match machine.step() {
|
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,
|
Ok(StepKind::StopIdle) => break Reason::Stopped,
|
||||||
|
Ok(kind) => stats.record(kind),
|
||||||
Err(error) => break Reason::Diagnostic(error),
|
Err(error) => break Reason::Diagnostic(error),
|
||||||
}
|
}
|
||||||
stats.steps += 1;
|
|
||||||
};
|
};
|
||||||
Report { stats, reason }
|
Report { stats, reason }
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +196,10 @@ fn write_report(writer: &mut impl Write, machine: &Machine, report: &Report) ->
|
||||||
Reason::Diagnostic(_) => "emulation diagnostic",
|
Reason::Diagnostic(_) => "emulation diagnostic",
|
||||||
};
|
};
|
||||||
writeln!(writer, "Result: {reason}")?;
|
writeln!(writer, "Result: {reason}")?;
|
||||||
let stats = &report.stats;
|
write_state(writer, machine, &report.stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_state(writer: &mut impl Write, machine: &Machine, stats: &Stats) -> io::Result<()> {
|
||||||
writeln!(
|
writeln!(
|
||||||
writer,
|
writer,
|
||||||
"Steps: {}; instructions: {}; IRQ entries: {}; DMA units: {}; HALT idle: {}",
|
"Steps: {}; instructions: {}; IRQ entries: {}; DMA units: {}; HALT idle: {}",
|
||||||
|
|
@ -193,13 +236,13 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
||||||
writer,
|
writer,
|
||||||
"Original BIOS replacement; no commercial-game compatibility claim."
|
"Original BIOS replacement; no commercial-game compatibility claim."
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
let steps = match options.mode {
|
||||||
writer,
|
Mode::Terminal { steps } => steps,
|
||||||
"Machine-step limit: {} (includes boot)",
|
Mode::Window { frames } => return crate::desktop::run_rom(bytes, frames, writer),
|
||||||
options.steps
|
};
|
||||||
)?;
|
writeln!(writer, "Machine-step limit: {steps} (includes boot)")?;
|
||||||
let mut machine = bios::boot(bytes)?;
|
let mut machine = bios::boot(bytes)?;
|
||||||
let report = run_bounded(&mut machine, options.steps);
|
let report = run_bounded(&mut machine, steps);
|
||||||
write_report(writer, &machine, &report)?;
|
write_report(writer, &machine, &report)?;
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
if let Reason::Diagnostic(error) = report.reason {
|
if let Reason::Diagnostic(error) = report.reason {
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,81 @@ fn options_require_both_flags_in_either_order() {
|
||||||
parse_args(&arguments).unwrap(),
|
parse_args(&arguments).unwrap(),
|
||||||
Options {
|
Options {
|
||||||
path: "original program.gba".into(),
|
path: "original program.gba".into(),
|
||||||
steps: count
|
mode: Mode::Terminal { steps: count }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_options_accept_any_order_and_optional_frame_limits() {
|
||||||
|
for arguments in [
|
||||||
|
args(&["--rom", "original.gba", "--window"]),
|
||||||
|
args(&["--window", "--rom", "original.gba"]),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&arguments).unwrap().mode,
|
||||||
|
Mode::Window { frames: None }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for count in [1, MAX_FRAMES] {
|
||||||
|
for arguments in [
|
||||||
|
args(&[
|
||||||
|
"--rom",
|
||||||
|
"original.gba",
|
||||||
|
"--window",
|
||||||
|
"--frames",
|
||||||
|
&count.to_string(),
|
||||||
|
]),
|
||||||
|
args(&[
|
||||||
|
"--frames",
|
||||||
|
&count.to_string(),
|
||||||
|
"--window",
|
||||||
|
"--rom",
|
||||||
|
"original.gba",
|
||||||
|
]),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&arguments).unwrap().mode,
|
||||||
|
Mode::Window {
|
||||||
|
frames: Some(count)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_options_reject_conflicting_modes_and_invalid_frame_limits() {
|
||||||
|
for tail in [
|
||||||
|
vec![],
|
||||||
|
vec!["--window", "--window"],
|
||||||
|
vec!["--frames", "1"],
|
||||||
|
vec!["--window", "--steps", "1"],
|
||||||
|
vec!["--steps", "1", "--frames", "1"],
|
||||||
|
vec!["--window", "--frames"],
|
||||||
|
vec!["--window", "--help"],
|
||||||
|
vec!["--window", "--cpu-demo"],
|
||||||
|
vec!["--window", "--frames", "1", "--frames", "2"],
|
||||||
|
] {
|
||||||
|
let mut arguments = args(&["--rom", "original.gba"]);
|
||||||
|
arguments.extend(args(&tail));
|
||||||
|
assert!(parse_args(&arguments).is_err(), "{arguments:?}");
|
||||||
|
}
|
||||||
|
for count in ["", "0", "-1", "+1", " 1", "100001", "18446744073709551616"] {
|
||||||
|
assert!(parse_args(&args(&[
|
||||||
|
"--rom",
|
||||||
|
"original.gba",
|
||||||
|
"--window",
|
||||||
|
"--frames",
|
||||||
|
count
|
||||||
|
]))
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
assert!(parse_args(&args(&["--window"])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn options_reject_missing_duplicate_conflicting_and_invalid_values() {
|
fn options_reject_missing_duplicate_conflicting_and_invalid_values() {
|
||||||
for arguments in [
|
for arguments in [
|
||||||
|
|
|
||||||
113
crates/desktop/src/cartridge/window.rs
Normal file
113
crates/desktop/src/cartridge/window.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
//! ROM window execution without window-library or host-clock dependencies.
|
||||||
|
//! No demo-specific display register, RAM mailbox, or startup condition is used.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
io::{self, Write},
|
||||||
|
};
|
||||||
|
|
||||||
|
use gba_core::{bios, input::Buttons, machine::Machine, memory::MemoryError, video::Framebuffer};
|
||||||
|
|
||||||
|
use super::{write_state, Stats};
|
||||||
|
|
||||||
|
/// Yield to the host after this many steps, even before a frame completes.
|
||||||
|
const SLICE_STEPS: usize = 4096;
|
||||||
|
/// Guard progress between VBlank events; retained across slices and STOP waits.
|
||||||
|
// A frame takes 280,896 cycles. Leave room for one-cycle instructions in RAM.
|
||||||
|
const FRAME_STEPS: usize = 400_000;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Update {
|
||||||
|
Running,
|
||||||
|
Frame,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Session {
|
||||||
|
machine: Machine,
|
||||||
|
frame: Framebuffer,
|
||||||
|
stats: Stats,
|
||||||
|
frames: u64,
|
||||||
|
frame_steps: usize,
|
||||||
|
vblank: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(bytes: Vec<u8>) -> Result<Self, MemoryError> {
|
||||||
|
let mut machine = bios::boot(bytes)?;
|
||||||
|
machine.memory_mut().set_scanline_rendering(true);
|
||||||
|
Ok(Self {
|
||||||
|
machine,
|
||||||
|
frame: Framebuffer::default(),
|
||||||
|
stats: Stats::default(),
|
||||||
|
frames: 0,
|
||||||
|
frame_steps: 0,
|
||||||
|
vblank: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame(&self) -> &Framebuffer {
|
||||||
|
&self.frame
|
||||||
|
}
|
||||||
|
pub fn frames(&self) -> u64 {
|
||||||
|
self.frames
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample input, then advance at most one bounded slice or one VBlank event.
|
||||||
|
/// STOP preserves clocks and the last image while allowing later input to wake.
|
||||||
|
/// Diagnostics preserve the last presented image and earlier successful steps.
|
||||||
|
pub fn update(&mut self, buttons: Buttons) -> Result<Update, Box<dyn Error>> {
|
||||||
|
self.update_bounded(buttons, SLICE_STEPS, FRAME_STEPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_bounded(
|
||||||
|
&mut self,
|
||||||
|
buttons: Buttons,
|
||||||
|
slice: usize,
|
||||||
|
frame_limit: usize,
|
||||||
|
) -> Result<Update, Box<dyn Error>> {
|
||||||
|
self.machine.memory_mut().set_buttons(buttons);
|
||||||
|
for _ in 0..slice {
|
||||||
|
if self.machine.stopped() {
|
||||||
|
return Ok(Update::Stopped);
|
||||||
|
}
|
||||||
|
if self.frame_steps >= frame_limit {
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"ROM exceeded {frame_limit} machine steps without VBlank"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let kind = self.machine.step()?;
|
||||||
|
self.stats.record(kind);
|
||||||
|
self.frame_steps += 1;
|
||||||
|
let vblank = self.machine.memory().display_position().vblanks;
|
||||||
|
if vblank != self.vblank {
|
||||||
|
if !self.machine.memory().present_frame(&mut self.frame)? {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"ROM reached VBlank without a complete captured frame",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
self.vblank = vblank;
|
||||||
|
self.frame_steps = 0;
|
||||||
|
self.frames += 1;
|
||||||
|
return Ok(Update::Frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.machine.stopped() {
|
||||||
|
Ok(Update::Stopped)
|
||||||
|
} else {
|
||||||
|
Ok(Update::Running)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn report(&self, writer: &mut impl Write, reason: &str) -> io::Result<()> {
|
||||||
|
writeln!(writer, "Result: {reason}")?;
|
||||||
|
writeln!(writer, "Captured ROM frames: {}", self.frames)?;
|
||||||
|
write_state(writer, &self.machine, &self.stats)?;
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
302
crates/desktop/src/cartridge/window/tests.rs
Normal file
302
crates/desktop/src/cartridge/window/tests.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
use super::*;
|
||||||
|
use gba_core::{
|
||||||
|
dma::DMA_BASE,
|
||||||
|
input::Button,
|
||||||
|
io::{DISPCNT, HALTCNT, IE, IME, KEYCNT, KEYINPUT, TIMER_BASE},
|
||||||
|
memory::ROM_START,
|
||||||
|
video::rgb555_to_rgb888,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn original() -> Session {
|
||||||
|
Session::new(gba_demos::input_rom()).unwrap()
|
||||||
|
}
|
||||||
|
fn code(words: &[u32]) -> Session {
|
||||||
|
Session::new(words.iter().flat_map(|word| word.to_le_bytes()).collect()).unwrap()
|
||||||
|
}
|
||||||
|
fn next_frame(session: &mut Session, buttons: Buttons) {
|
||||||
|
for _ in 0..100 {
|
||||||
|
match session.update(buttons).unwrap() {
|
||||||
|
Update::Frame => return,
|
||||||
|
Update::Stopped => panic!("unexpected STOP"),
|
||||||
|
Update::Running => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no frame within bounded test updates");
|
||||||
|
}
|
||||||
|
fn assert_color(session: &Session, color: u16) {
|
||||||
|
assert!(session
|
||||||
|
.frame()
|
||||||
|
.pixels()
|
||||||
|
.iter()
|
||||||
|
.all(|&pixel| pixel == rgb555_to_rgb888(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captures_first_frame_from_reset_without_demo_startup_registers() {
|
||||||
|
let mut session = original();
|
||||||
|
assert_eq!(session.machine.cycles(), 0);
|
||||||
|
assert_color(&session, 0);
|
||||||
|
assert_eq!(session.update(Buttons::default()).unwrap(), Update::Running);
|
||||||
|
assert_eq!(session.stats.steps, SLICE_STEPS as u64);
|
||||||
|
assert_eq!(session.frames(), 0);
|
||||||
|
assert_color(&session, 0);
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
assert_eq!(session.frames(), 1);
|
||||||
|
assert_eq!(session.machine.memory().display_position().vblanks, 1);
|
||||||
|
assert_eq!(session.machine.memory().captured_vblank(), Some(1));
|
||||||
|
assert_eq!(session.machine.memory().read16(DISPCNT).unwrap(), 0);
|
||||||
|
assert_color(&session, 0x7c00);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vblank_on_the_final_budgeted_step_succeeds_and_resets_the_budget() {
|
||||||
|
let mut reference = original();
|
||||||
|
next_frame(&mut reference, Buttons::default());
|
||||||
|
let limit = reference.stats.steps as usize;
|
||||||
|
let mut session = original();
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.update_bounded(Buttons::default(), limit, limit)
|
||||||
|
.unwrap(),
|
||||||
|
Update::Frame
|
||||||
|
);
|
||||||
|
assert_eq!(session.stats.steps, limit as u64);
|
||||||
|
assert_eq!(session.frame_steps, 0);
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
assert_eq!(session.frames(), 2);
|
||||||
|
assert_eq!(session.frame_steps, 0);
|
||||||
|
assert_color(&session, 0x7c00);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_changes_cpu_palette_writes_and_complete_frame_pixels() {
|
||||||
|
let mut session = original();
|
||||||
|
for (buttons, color) in [
|
||||||
|
(Buttons::default(), 0x7c00),
|
||||||
|
(Buttons::default().with(Button::A, true), 0x001f),
|
||||||
|
(
|
||||||
|
Buttons::default()
|
||||||
|
.with(Button::Right, true)
|
||||||
|
.with(Button::A, true),
|
||||||
|
0x03e0,
|
||||||
|
),
|
||||||
|
(Buttons::default(), 0x7c00),
|
||||||
|
] {
|
||||||
|
next_frame(&mut session, buttons);
|
||||||
|
assert_color(&session, color);
|
||||||
|
}
|
||||||
|
assert_eq!(session.frames(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focused_keyboard_reaches_rom_and_focus_loss_releases_input() {
|
||||||
|
use crate::desktop::read_buttons;
|
||||||
|
use minifb::Key;
|
||||||
|
let focused = !cfg!(target_os = "macos");
|
||||||
|
let mut session = original();
|
||||||
|
next_frame(&mut session, read_buttons(focused, |key| key == Key::Right));
|
||||||
|
assert_color(&session, 0x03e0);
|
||||||
|
next_frame(
|
||||||
|
&mut session,
|
||||||
|
read_buttons(!focused, |_| panic!("must not poll keys")),
|
||||||
|
);
|
||||||
|
assert_color(&session, 0x7c00);
|
||||||
|
assert_eq!(session.machine.memory().read16(KEYINPUT).unwrap(), 0x03ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_retains_image_and_clocks_then_keypad_input_resumes_frames() {
|
||||||
|
let mut session = original();
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
let bus = session.machine.memory_mut();
|
||||||
|
bus.write16(KEYCNT, 0x4001).unwrap(); // A OR, keypad enabled
|
||||||
|
bus.write16(IE, 1 << 12).unwrap();
|
||||||
|
bus.write8(HALTCNT, 0x80).unwrap();
|
||||||
|
let cycles = session.machine.cycles();
|
||||||
|
let steps = session.stats.steps;
|
||||||
|
for _ in 0..10 {
|
||||||
|
assert_eq!(session.update(Buttons::default()).unwrap(), Update::Stopped);
|
||||||
|
assert_eq!(session.machine.cycles(), cycles);
|
||||||
|
assert_eq!(session.stats.steps, steps);
|
||||||
|
assert_eq!(session.frames(), 1);
|
||||||
|
assert_color(&session, 0x7c00);
|
||||||
|
}
|
||||||
|
// A non-selected key must not wake STOP.
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.update(Buttons::default().with(Button::B, true))
|
||||||
|
.unwrap(),
|
||||||
|
Update::Stopped
|
||||||
|
);
|
||||||
|
next_frame(&mut session, Buttons::default().with(Button::A, true));
|
||||||
|
assert!(!session.machine.stopped());
|
||||||
|
assert!(session.machine.cycles() > cycles);
|
||||||
|
assert_color(&session, 0x001f);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bios_stop_during_boot_keeps_the_initial_black_image() {
|
||||||
|
let mut session = code(&[0xef03_0000, 0xeaff_fffe]);
|
||||||
|
assert_eq!(session.update(Buttons::default()).unwrap(), Update::Stopped);
|
||||||
|
assert_eq!(session.frames(), 0);
|
||||||
|
assert_color(&session, 0);
|
||||||
|
assert!(session.stats.steps < SLICE_STEPS as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_on_the_last_slice_step_is_not_delayed_until_next_poll() {
|
||||||
|
let mut session = code(&[0xef03_0000, 0xeaff_fffe]);
|
||||||
|
for _ in 0..100 {
|
||||||
|
let update = session
|
||||||
|
.update_bounded(Buttons::default(), 1, FRAME_STEPS)
|
||||||
|
.unwrap();
|
||||||
|
if session.machine.stopped() {
|
||||||
|
assert_eq!(update, Update::Stopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert_eq!(update, Update::Running);
|
||||||
|
}
|
||||||
|
panic!("BIOS Stop did not complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn halt_advances_display_and_presents_frames_without_host_wake() {
|
||||||
|
let mut session = code(&[0xef02_0000, 0xeaff_fffe]);
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
assert!(session.machine.halted());
|
||||||
|
assert!(session.stats.halt_idle > 0);
|
||||||
|
assert_eq!(
|
||||||
|
session.stats.steps,
|
||||||
|
session.stats.instructions + session.stats.halt_idle
|
||||||
|
);
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
assert_eq!(session.frames(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_budget_is_retained_across_slices_and_stop_waits() {
|
||||||
|
let mut session = original();
|
||||||
|
assert_eq!(
|
||||||
|
session.update_bounded(Buttons::default(), 1, 2).unwrap(),
|
||||||
|
Update::Running
|
||||||
|
);
|
||||||
|
session
|
||||||
|
.machine
|
||||||
|
.memory_mut()
|
||||||
|
.write16(KEYCNT, 0x4001)
|
||||||
|
.unwrap();
|
||||||
|
session.machine.memory_mut().write16(IE, 1 << 12).unwrap();
|
||||||
|
session.machine.memory_mut().write8(HALTCNT, 0x80).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
session.update_bounded(Buttons::default(), 1, 2).unwrap(),
|
||||||
|
Update::Stopped
|
||||||
|
);
|
||||||
|
assert_eq!(session.frame_steps, 1);
|
||||||
|
let pressed = Buttons::default().with(Button::A, true);
|
||||||
|
assert_eq!(
|
||||||
|
session.update_bounded(pressed, 1, 2).unwrap(),
|
||||||
|
Update::Running
|
||||||
|
);
|
||||||
|
let cycles = session.machine.cycles();
|
||||||
|
assert!(session
|
||||||
|
.update_bounded(pressed, 1, 2)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("2 machine steps without VBlank"));
|
||||||
|
assert_eq!(session.stats.steps, 2);
|
||||||
|
assert_eq!(session.machine.cycles(), cycles);
|
||||||
|
assert_color(&session, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cpu_diagnostics_preserve_the_failed_state_and_last_image() {
|
||||||
|
let mut session = code(&[0xee00_0000]);
|
||||||
|
assert!(session.update(Buttons::default()).is_err());
|
||||||
|
assert_eq!(session.machine.cpu().pc(), ROM_START);
|
||||||
|
let cpu = session.machine.cpu().clone();
|
||||||
|
let cycles = session.machine.cycles();
|
||||||
|
let steps = session.stats.steps;
|
||||||
|
assert!(session.update(Buttons::default()).is_err());
|
||||||
|
assert_eq!(session.machine.cpu(), &cpu);
|
||||||
|
assert_eq!(session.machine.cycles(), cycles);
|
||||||
|
assert_eq!(session.stats.steps, steps);
|
||||||
|
assert_color(&session, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_diagnostics_retain_the_last_complete_image() {
|
||||||
|
let mut session = original();
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
session.machine.memory_mut().write16(DISPCNT, 6).unwrap();
|
||||||
|
let error = (0..100)
|
||||||
|
.find_map(|_| match session.update(Buttons::default()) {
|
||||||
|
Ok(Update::Running) => None,
|
||||||
|
Ok(other) => panic!("unexpected {other:?}"),
|
||||||
|
Err(error) => Some(error),
|
||||||
|
})
|
||||||
|
.expect("video error within bounded updates");
|
||||||
|
assert!(error.to_string().contains("6"));
|
||||||
|
assert_eq!(session.frames(), 1);
|
||||||
|
assert_color(&session, 0x7c00);
|
||||||
|
assert_eq!(session.machine.memory().display_position().vblanks, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dma_and_irq_consume_slice_budget_and_dma_failure_preserves_progress() {
|
||||||
|
let mut session = original();
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
let bus = session.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 steps = session.stats.steps;
|
||||||
|
let pc = session.machine.cpu().pc();
|
||||||
|
session
|
||||||
|
.update_bounded(Buttons::default(), 2, FRAME_STEPS)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.stats.dma_units, 2);
|
||||||
|
assert_eq!(session.stats.steps, steps + 2);
|
||||||
|
assert_eq!(session.machine.cpu().pc(), pc);
|
||||||
|
session
|
||||||
|
.update_bounded(Buttons::default(), 1, FRAME_STEPS)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.stats.irq_entries, 1);
|
||||||
|
let bus = session.machine.memory_mut();
|
||||||
|
bus.write32(DMA_BASE + 4, ROM_START).unwrap();
|
||||||
|
bus.write32(DMA_BASE + 8, 0x8000_0001).unwrap();
|
||||||
|
let cycles = session.machine.cycles();
|
||||||
|
assert!(session.update(Buttons::default()).is_err());
|
||||||
|
assert_eq!(session.stats.steps, steps + 3);
|
||||||
|
assert_eq!(session.machine.cycles(), cycles);
|
||||||
|
assert_color(&session, 0x7c00);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_contains_capture_count_and_propagates_output_errors() {
|
||||||
|
let mut session = original();
|
||||||
|
next_frame(&mut session, Buttons::default());
|
||||||
|
let mut output = Vec::new();
|
||||||
|
session.report(&mut output, "test result").unwrap();
|
||||||
|
let text = String::from_utf8(output).unwrap();
|
||||||
|
assert!(text.contains("Captured ROM frames: 1"));
|
||||||
|
assert!(text.contains("Result: test result"));
|
||||||
|
assert!(text.contains("CPSR="));
|
||||||
|
assert!(text.contains("r15="));
|
||||||
|
struct Closed;
|
||||||
|
impl Write for Closed {
|
||||||
|
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
|
||||||
|
Err(io::ErrorKind::BrokenPipe.into())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
session.report(&mut Closed, "test").unwrap_err().kind(),
|
||||||
|
io::ErrorKind::BrokenPipe
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,14 @@ mod bitmap;
|
||||||
mod effects;
|
mod effects;
|
||||||
mod mosaic;
|
mod mosaic;
|
||||||
mod raster;
|
mod raster;
|
||||||
|
mod rom;
|
||||||
pub use affine::run as run_affine;
|
pub use affine::run as run_affine;
|
||||||
pub use affine_raster::run as run_affine_raster;
|
pub use affine_raster::run as run_affine_raster;
|
||||||
pub use bitmap::run as run_bitmap;
|
pub use bitmap::run as run_bitmap;
|
||||||
pub use effects::run as run_effects;
|
pub use effects::run as run_effects;
|
||||||
pub use mosaic::run as run_mosaic;
|
pub use mosaic::run as run_mosaic;
|
||||||
pub use raster::run as run_raster;
|
pub use raster::run as run_raster;
|
||||||
|
pub use rom::run as run_rom;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
|
|
@ -54,7 +56,7 @@ fn is_focused(reported_active: bool) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_buttons(reported_active: bool, key_down: impl Fn(Key) -> bool) -> Buttons {
|
pub(super) fn read_buttons(reported_active: bool, key_down: impl Fn(Key) -> bool) -> Buttons {
|
||||||
if !is_focused(reported_active) {
|
if !is_focused(reported_active) {
|
||||||
return Buttons::default();
|
return Buttons::default();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
crates/desktop/src/desktop/rom.rs
Normal file
85
crates/desktop/src/desktop/rom.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
//! Main-thread ROM presentation and physical keyboard input.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::cartridge::window::{Session, Update};
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
frame_limit: Option<u64>,
|
||||||
|
writer: &mut impl Write,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut session = Session::new(bytes)?;
|
||||||
|
writeln!(
|
||||||
|
writer,
|
||||||
|
"ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits."
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
writer,
|
||||||
|
"Input releases on focus loss. No audio or saves. Startup frames are not suppressed."
|
||||||
|
)?;
|
||||||
|
if let Some(limit) = frame_limit {
|
||||||
|
writeln!(
|
||||||
|
writer,
|
||||||
|
"Window frame limit: {limit}; STOP or early closure is an error."
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writer.flush()?;
|
||||||
|
let result = (|| -> Result<&str, Box<dyn Error>> {
|
||||||
|
let title = "GBA Rust | ROM | Esc: exit | No audio or saves";
|
||||||
|
let mut window = create_window(title)?;
|
||||||
|
window.set_target_fps(0);
|
||||||
|
let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ));
|
||||||
|
let mut frame_started = Instant::now();
|
||||||
|
let mut was_stopped = false;
|
||||||
|
// Show a black buffer and pump initial events before boot starts.
|
||||||
|
window.update_with_buffer(session.frame().pixels(), WIDTH, HEIGHT)?;
|
||||||
|
while window.is_open() && !window.is_key_down(Key::Escape) {
|
||||||
|
let buttons = read_buttons(window.is_active(), |key| window.is_key_down(key));
|
||||||
|
let update = session.update(buttons)?;
|
||||||
|
let stopped = update == Update::Stopped;
|
||||||
|
if stopped != was_stopped {
|
||||||
|
window.set_title(if stopped {
|
||||||
|
"GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit"
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
});
|
||||||
|
was_stopped = stopped;
|
||||||
|
}
|
||||||
|
match update {
|
||||||
|
Update::Running => {
|
||||||
|
// Always service native events between bounded CPU slices.
|
||||||
|
window.update();
|
||||||
|
}
|
||||||
|
Update::Frame => {
|
||||||
|
window.update_with_buffer(session.frame().pixels(), WIDTH, HEIGHT)?;
|
||||||
|
if frame_limit.is_some_and(|limit| session.frames() >= limit) {
|
||||||
|
return Ok("window frame limit reached");
|
||||||
|
}
|
||||||
|
// Slow hosts do not skip emulated frames or accumulate catch-up work.
|
||||||
|
thread::sleep(period.saturating_sub(frame_started.elapsed()));
|
||||||
|
frame_started = Instant::now();
|
||||||
|
}
|
||||||
|
Update::Stopped => {
|
||||||
|
if frame_limit.is_some() {
|
||||||
|
return Err(io::Error::other("ROM entered STOP before the window frame limit; use --window without --frames to wait for input").into());
|
||||||
|
}
|
||||||
|
// Retain the last image. Host polling and sleep do not advance GBA clocks.
|
||||||
|
window.update();
|
||||||
|
thread::sleep(period);
|
||||||
|
frame_started = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if frame_limit.is_some() {
|
||||||
|
return Err(io::Error::other("ROM window closed before the frame limit").into());
|
||||||
|
}
|
||||||
|
Ok("ROM window closed")
|
||||||
|
})();
|
||||||
|
session.report(
|
||||||
|
writer,
|
||||||
|
result.as_ref().copied().unwrap_or("ROM window diagnostic"),
|
||||||
|
)?;
|
||||||
|
result.map(|_| ())
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,11 @@ 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") {
|
if args.iter().any(|arg| {
|
||||||
|
["--rom", "--steps", "--window", "--frames"]
|
||||||
|
.iter()
|
||||||
|
.any(|flag| arg == flag)
|
||||||
|
}) {
|
||||||
return cartridge::parse_args(args).map(RunMode::Rom);
|
return cartridge::parse_args(args).map(RunMode::Rom);
|
||||||
}
|
}
|
||||||
match args {
|
match args {
|
||||||
|
|
@ -71,7 +75,7 @@ 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]; or gameboy-rust --rom PATH --steps COUNT",
|
"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 | --window [--frames COUNT])",
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +118,8 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||||
"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\
|
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
|
||||||
|
--rom PATH --window Open a raw ROM with keyboard input\n\
|
||||||
|
--frames COUNT Exit ROM window after 1..=100000 captured frames\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\
|
||||||
|
|
@ -146,7 +152,9 @@ fn run() -> Result<(), Box<dyn Error>> {
|
||||||
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. ROM mode uses our limited BIOS replacement.\n\
|
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."
|
Terminal ROM mode has no window or input. ROM modes have no audio or saves.\n\
|
||||||
|
ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits.\n\
|
||||||
|
Commercial games are not supported."
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -168,12 +168,114 @@ fn help_describes_terminal_rom_mode_and_existing_cpu_demo_still_runs() {
|
||||||
let output = command().arg("--help").output().unwrap();
|
let output = command().arg("--help").output().unwrap();
|
||||||
assert!(output.status.success());
|
assert!(output.status.success());
|
||||||
assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
|
assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
|
||||||
assert!(stdout(&output).contains("no window, input, audio, or saves"));
|
assert!(stdout(&output).contains("--rom PATH --window"));
|
||||||
|
assert!(stdout(&output).contains("ROM modes have no audio or saves"));
|
||||||
let output = command().arg("--cpu-demo").output().unwrap();
|
let output = command().arg("--cpu-demo").output().unwrap();
|
||||||
assert!(output.status.success());
|
assert!(output.status.success());
|
||||||
assert!(stdout(&output).contains("Exception demo"));
|
assert!(stdout(&output).contains("Exception demo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_options_and_bad_files_fail_without_opening_a_window() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let missing = fixture.0.join("missing.gba");
|
||||||
|
for extra in [
|
||||||
|
vec!["--steps", "1"],
|
||||||
|
vec!["--frames", "0"],
|
||||||
|
vec!["--frames", "100001"],
|
||||||
|
vec!["--window"],
|
||||||
|
vec!["--help"],
|
||||||
|
] {
|
||||||
|
let output = command()
|
||||||
|
.arg("--rom")
|
||||||
|
.arg(&missing)
|
||||||
|
.arg("--window")
|
||||||
|
.args(extra)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
assert!(stderr(&output).contains("usage:"));
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
}
|
||||||
|
let output = command()
|
||||||
|
.arg("--rom")
|
||||||
|
.arg(&missing)
|
||||||
|
.arg("--window")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
assert!(stderr(&output).contains("cannot load ROM"));
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires a logged-in native desktop session"]
|
||||||
|
fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() {
|
||||||
|
use std::{
|
||||||
|
process::Stdio,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
fn window(path: &Path) -> Output {
|
||||||
|
let mut child = command()
|
||||||
|
.arg("--rom")
|
||||||
|
.arg(path)
|
||||||
|
.args(["--window", "--frames", "3"])
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let start = Instant::now();
|
||||||
|
while child.try_wait().unwrap().is_none() {
|
||||||
|
if start.elapsed() > Duration::from_secs(20) {
|
||||||
|
child.kill().unwrap();
|
||||||
|
let output = child.wait_with_output().unwrap();
|
||||||
|
panic!(
|
||||||
|
"ROM window did not exit: {} {}",
|
||||||
|
stdout(&output),
|
||||||
|
stderr(&output)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let path = fixture.0.join("original-input.gba");
|
||||||
|
fs::write(&path, gba_demos::input_rom()).unwrap();
|
||||||
|
let output = window(&path);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{} {}",
|
||||||
|
stdout(&output),
|
||||||
|
stderr(&output)
|
||||||
|
);
|
||||||
|
assert!(stdout(&output).contains("Captured ROM frames: 3"));
|
||||||
|
assert!(stdout(&output).contains("Result: window frame limit reached"));
|
||||||
|
for (code, error) in [
|
||||||
|
(vec![0xef03_0000, 0xeaff_fffe], "entered STOP"),
|
||||||
|
(vec![0xee00_0000], "instruction"),
|
||||||
|
(
|
||||||
|
vec![0xe3a0_0006, 0xe3a0_1301, 0xe1c1_00b0, 0xeaff_fffe],
|
||||||
|
"6",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
code.iter()
|
||||||
|
.flat_map(|word: &u32| word.to_le_bytes())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let output = window(&path);
|
||||||
|
assert_eq!(output.status.code(), Some(1), "{}", stdout(&output));
|
||||||
|
assert!(stdout(&output).contains("Result: ROM window diagnostic"));
|
||||||
|
assert!(stdout(&output).contains("Captured ROM frames: 0"));
|
||||||
|
assert!(stdout(&output).contains("PC="));
|
||||||
|
assert!(stderr(&output).contains(error), "{}", stderr(&output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn non_utf8_paths_reach_the_loader_without_panicking() {
|
fn non_utf8_paths_reach_the_loader_without_panicking() {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,30 @@ pub mod raster_demo;
|
||||||
pub mod tile_demo;
|
pub mod tile_demo;
|
||||||
pub mod timer_demo;
|
pub mod timer_demo;
|
||||||
|
|
||||||
|
/// Headerless original ROM for file-loading and window-input tests.
|
||||||
|
/// The CPU writes palette entry zero: blue at rest, red for A, green for Right.
|
||||||
|
/// Right takes priority. No layer setup or demo-specific host initialization is needed.
|
||||||
|
pub fn input_rom() -> Vec<u8> {
|
||||||
|
[
|
||||||
|
0xe3a0_1301_u32, // MOV r1, #0x04000000
|
||||||
|
0xe281_1c01, // ADD r1, r1, #0x100
|
||||||
|
0xe281_1030, // ADD r1, r1, #0x30 (KEYINPUT)
|
||||||
|
0xe3a0_2405, // MOV r2, #0x05000000 (palette)
|
||||||
|
0xe1d1_00b0, // loop: LDRH r0, [r1]
|
||||||
|
0xe1e0_0000, // MVN r0, r0 (pressed bits)
|
||||||
|
0xe3a0_3b1f, // MOV r3, #0x7c00 (blue)
|
||||||
|
0xe310_0001, // TST r0, #1 (A)
|
||||||
|
0x13a0_301f, // MOVNE r3, #0x001f (red)
|
||||||
|
0xe310_0010, // TST r0, #0x10 (Right)
|
||||||
|
0x13a0_3e3e, // MOVNE r3, #0x03e0 (green)
|
||||||
|
0xe1c2_30b0, // STRH r3, [r2]
|
||||||
|
0xeaff_fff6, // B loop
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(u32::to_le_bytes)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Steps needed to reach and execute the demo's final self-branch.
|
/// Steps needed to reach and execute the demo's final self-branch.
|
||||||
pub const DEMO_STEPS: usize = 40;
|
pub const DEMO_STEPS: usize = 40;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ The repository is a Cargo workspace with three crates:
|
||||||
| `crates/gba-demos/src/timer_demo.rs` | Original timer-configuration program and IRQ handler |
|
| `crates/gba-demos/src/timer_demo.rs` | Original timer-configuration program and IRQ handler |
|
||||||
| `Cargo.toml` | Workspace members and shared package settings |
|
| `Cargo.toml` | Workspace members and shared package settings |
|
||||||
| `crates/gba-core/src/lib.rs` | Core modules |
|
| `crates/gba-core/src/lib.rs` | Core modules |
|
||||||
| `crates/gba-demos/src/lib.rs` | Demo modules and original instruction/exception demo bytes |
|
| `crates/gba-demos/src/lib.rs` | Demo modules, original instruction/exception bytes, and raw input-test ROM |
|
||||||
| `crates/gba-core/src/bios.rs` | Original ARM BIOS image builder, minimal boot, IRQ dispatch, waits, and memory services |
|
| `crates/gba-core/src/bios.rs` | Original ARM BIOS image builder, minimal boot, IRQ dispatch, waits, and memory services |
|
||||||
| `crates/gba-core/src/bios/reset.rs` | Emitted ARM SoftReset and selective RegisterRamReset; CPU banks, RAM clearing, and supported device-register resets |
|
| `crates/gba-core/src/bios/reset.rs` | Emitted ARM SoftReset and selective RegisterRamReset; CPU banks, RAM clearing, and supported device-register resets |
|
||||||
| `crates/gba-core/src/bios/arithmetic.rs` | Emitted ARM division and integer-square-root routines |
|
| `crates/gba-core/src/bios/arithmetic.rs` | Emitted ARM division and integer-square-root routines |
|
||||||
|
|
@ -79,9 +79,13 @@ The repository is a Cargo workspace with three crates:
|
||||||
| `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, error exit status, 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.rs` | Read-only ROM files, mode/limit checks, bounded terminal execution, and shared state reports |
|
||||||
|
| `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits |
|
||||||
|
| `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics |
|
||||||
|
| `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, presentation pacing, and frame-limited exit |
|
||||||
|
| `crates/desktop/examples/write_rom_demo.rs` | Non-overwriting export of the original input-test ROM |
|
||||||
| `crates/desktop/src/cartridge/tests.rs` | ROM options, bounded readers, instruction/DMA/IRQ budgets, HALT/STOP, and diagnostic 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/desktop/tests/rom_cli.rs` | File loading and exit-status checks; opt-in native ROM-window integration test |
|
||||||
| `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,12 +17,13 @@ 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.
|
- The bounded terminal ROM runner loads original raw programs and reports state without opening a window.
|
||||||
|
- The native ROM-window test presents a file-backed original program and reports STOP, CPU, and video failures.
|
||||||
|
|
||||||
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.
|
||||||
Arrow-key movement in the original host display test was confirmed manually on this Mac.
|
Arrow-key movement in the original host display test was confirmed manually on this Mac.
|
||||||
The new CPU graphics demo has automated input-mapping tests; its physical keyboard behavior still needs manual confirmation.
|
CPU demos and ROM windows have automated input-mapping tests; their physical keyboard behavior still needs manual confirmation.
|
||||||
|
|
||||||
Enable the direnv hook in your shell if it is not already configured.
|
Enable the direnv hook in your shell if it is not already configured.
|
||||||
For zsh, add this line to `~/.zshrc`:
|
For zsh, add this line to `~/.zshrc`:
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ 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 can load raw ROM files for [bounded terminal diagnostics](cartridge.md) using this boot sequence.
|
The executable can load raw ROM files for [terminal or window execution](cartridge.md) using this boot sequence.
|
||||||
This does not establish game compatibility or complete BIOS behavior.
|
This does not establish game compatibility or complete BIOS behavior.
|
||||||
|
|
||||||
Supported software interrupt services:
|
Supported software interrupt services:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
# Cartridge files and terminal execution
|
# Cartridge files and execution
|
||||||
|
|
||||||
The desktop executable can load raw ROM bytes and run a bounded diagnostic session.
|
The desktop executable can load raw ROM bytes for terminal diagnostics or window execution.
|
||||||
This supports original test programs. It does not establish commercial-game compatibility.
|
This supports original test programs. It does not establish commercial-game compatibility.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec . cargo run --locked --release -- --rom path/to/original.gba --steps 100000
|
direnv exec . cargo run --locked --release -- --rom path/to/original.gba --steps 100000
|
||||||
|
direnv exec . cargo run --locked --release -- --rom path/to/original.gba --window
|
||||||
```
|
```
|
||||||
|
|
||||||
Both options are required. Their order does not matter.
|
Supply `--rom PATH` and exactly one execution mode: `--steps COUNT` or `--window`.
|
||||||
`COUNT` must contain decimal digits and be between 1 and 100,000,000 inclusive.
|
Option order does not matter. Terminal step counts accept decimal digits from 1 through 100,000,000.
|
||||||
|
Window mode optionally accepts `--frames COUNT`, from 1 through 100,000, for bounded presentation tests.
|
||||||
|
Reject zero, signed numbers, duplicate options, and combinations of terminal and window limits.
|
||||||
Do not combine ROM mode with demo or help options. Bare file paths are not accepted.
|
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.
|
Quote paths that contain spaces. File extensions do not affect loading.
|
||||||
|
|
||||||
|
|
@ -31,12 +34,12 @@ Reads past supplied cartridge bytes remain explicit memory errors, not open-bus
|
||||||
All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules.
|
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.
|
Cartridge writes, save devices, and cartridge peripherals remain unsupported.
|
||||||
|
|
||||||
The runner calls `bios::boot` with the loaded bytes.
|
Both modes call `bios::boot` with the loaded bytes.
|
||||||
The [original BIOS replacement](bios.md) executes its minimal boot before entering `0x08000000` in ARM System mode.
|
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.
|
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.
|
Unsupported services remain diagnostics. In particular, RegisterRamReset sound/serial flags, including `r0=0xff`, still fail.
|
||||||
|
|
||||||
## Execution limits and results
|
## Terminal execution limits and results
|
||||||
|
|
||||||
The budget counts successful machine steps, including BIOS boot instructions.
|
The budget counts successful machine steps, including BIOS boot instructions.
|
||||||
Each CPU instruction, IRQ entry, DMA unit, or HALT idle batch consumes one step.
|
Each CPU instruction, IRQ entry, DMA unit, or HALT idle batch consumes one step.
|
||||||
|
|
@ -58,7 +61,67 @@ 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.
|
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.
|
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.
|
Terminal mode has no window, keyboard input, or real-time pacing.
|
||||||
It does not enable frame capture or validate rendered output.
|
It does not enable frame capture or validate rendered output.
|
||||||
Use the existing demos for window and input tests.
|
|
||||||
|
## Window execution
|
||||||
|
|
||||||
|
`--window` opens a native 240×160 window at 4× scale on the main thread.
|
||||||
|
The window presents [scanline-captured frames](display-timing.md#scanline-frame-capture) at VBlank entry.
|
||||||
|
Capture starts before BIOS boot. It has no demo-specific readiness checks and does not suppress startup images.
|
||||||
|
A black buffer appears before the first captured frame.
|
||||||
|
The host does not set display registers, initialize game RAM, or draw replacement game graphics.
|
||||||
|
|
||||||
|
Controls use the same focus correction and key mapping as the existing demos:
|
||||||
|
|
||||||
|
| Host key | GBA button |
|
||||||
|
| --- | --- |
|
||||||
|
| Arrows | D-pad |
|
||||||
|
| Z / X | A / B |
|
||||||
|
| Q / W | L / R |
|
||||||
|
| Enter | Start |
|
||||||
|
| Backspace | Select |
|
||||||
|
|
||||||
|
Escape or the window close control ends execution. Enter does not reset the emulator.
|
||||||
|
Focus loss releases all buttons but does not pause execution.
|
||||||
|
The host samples buttons before each execution slice, including while stopped.
|
||||||
|
|
||||||
|
Each slice executes at most 4,096 machine steps or stops at the next VBlank event.
|
||||||
|
The host processes window events between slices, not only when a frame completes.
|
||||||
|
A separate 400,000-step limit guards progress between VBlank events and persists across slices and STOP waits.
|
||||||
|
This limit permits a full frame of one-cycle instructions. It is not a wall-clock deadline.
|
||||||
|
HALT continues device clocks and frame publication.
|
||||||
|
Normal presentation targets approximately 59.73 frames per second, without frame skipping or accumulated catch-up work.
|
||||||
|
Slow hosts can run slower than real time. Host sleeping never advances emulated clocks.
|
||||||
|
|
||||||
|
In interactive mode, STOP retains the last image and changes the window title.
|
||||||
|
The host continues polling input and window-close events while GBA clocks remain frozen.
|
||||||
|
Only keypad conditions configured by the ROM through KEYCNT and IE can wake STOP.
|
||||||
|
A key press cannot wake a program that has not enabled a matching wake source.
|
||||||
|
|
||||||
|
`--frames COUNT` exits successfully after that many captured frames are submitted to the window.
|
||||||
|
It is a presentation limit, not proof that the ROM completed or passed a test.
|
||||||
|
STOP before that limit is an error instead of an indefinite wait for physical input.
|
||||||
|
Early window closure is also an error in this bounded mode; closure is normal in interactive mode.
|
||||||
|
|
||||||
|
CPU, DMA, video, and window errors end the session with a nonzero exit status.
|
||||||
|
The terminal report includes captured-frame count and the final machine state; stderr contains the error.
|
||||||
|
Rendering errors do not overwrite the last complete framebuffer.
|
||||||
|
Reports count captured frames, not idle redraws or window-event updates.
|
||||||
|
|
||||||
|
## Try an original ROM
|
||||||
|
|
||||||
|
Generate an original headerless input-test file, then open that file through the ROM loader:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec . cargo run --locked --example write_rom_demo -- /tmp/gba-input-demo.gba
|
||||||
|
direnv exec . cargo run --locked --release -- --rom /tmp/gba-input-demo.gba --window
|
||||||
|
```
|
||||||
|
|
||||||
|
The generator refuses to overwrite an existing file. Choose a new path if needed.
|
||||||
|
The CPU writes the palette: blue at rest, red while Z is held, and green while Right is held.
|
||||||
|
Right takes priority over Z. Other buttons have no visible effect in this small program.
|
||||||
|
No game or firmware content is included.
|
||||||
|
|
||||||
|
Both ROM modes lack audio, saves, and per-step tracing. Commercial-game compatibility remains unverified and unsupported.
|
||||||
Only load files that you may lawfully use. Do not commit game ROMs or firmware.
|
Only load files that you may lawfully use. Do not commit game ROMs or firmware.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ When row capture is enabled, the clock advances within those steps also render v
|
||||||
|
|
||||||
`Memory::set_scanline_rendering(true)` enables row capture without changing clocks or hardware registers.
|
`Memory::set_scanline_rendering(true)` enables row capture without changing clocks or hardware registers.
|
||||||
Capture is disabled by default for CPU-only use. All CPU-driven demo frame runners enable it automatically.
|
Capture is disabled by default for CPU-only use. All CPU-driven demo frame runners enable it automatically.
|
||||||
|
The ROM window runner enables capture before BIOS boot and presents the first complete frame without demo-specific startup checks.
|
||||||
Repeatedly enabling capture preserves progress. Disabling it drops captured buffers and availability state.
|
Repeatedly enabling capture preserves progress. Disabling it drops captured buffers and availability state.
|
||||||
|
|
||||||
For each visible line, the renderer composes the row at HBlank entry, cycle 1,006.
|
For each visible line, the renderer composes the row at HBlank entry, cycle 1,006.
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,9 @@ 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.
|
- Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports.
|
||||||
|
- ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP.
|
||||||
|
- Optional ROM-window frame limits and an original input-test ROM generator.
|
||||||
- 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.
|
||||||
|
|
@ -103,8 +105,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.
|
||||||
ROM files can run in bounded terminal mode with the original BIOS replacement.
|
ROM files can run in bounded terminal mode or a window with the original BIOS replacement.
|
||||||
This mode has no window, input, audio, saves, or external BIOS option. It does not establish commercial-game compatibility.
|
ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices.
|
||||||
|
Audio, saves, and external BIOS loading remain unavailable. ROM loading does not establish commercial-game compatibility.
|
||||||
See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status.
|
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.
|
||||||
|
|
@ -120,7 +123,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. Connect ROM execution to the window and input; expand audio, cartridge hardware, and saves before testing Emerald compatibility.
|
5. Validate more original/public test ROMs through the window; add 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.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,16 @@ file target/release/gameboy-rust
|
||||||
./target/release/gameboy-rust --timer-demo
|
./target/release/gameboy-rust --timer-demo
|
||||||
```
|
```
|
||||||
|
|
||||||
All ten window smoke tests need an active desktop session. Ordinary tests do not open windows.
|
All ten demo window smoke tests need an active desktop session. Ordinary tests do not open windows.
|
||||||
|
Run the additional file-backed ROM-window test explicitly in that desktop session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec . cargo test --locked -p gameboy-rust-desktop --test rom_cli native_rom_window -- --ignored --test-threads=1
|
||||||
|
direnv exec . cargo test --locked --release -p gameboy-rust-desktop --test rom_cli native_rom_window -- --ignored --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
This opt-in test opens native windows in child processes and checks bounded presentation and STOP/CPU/video diagnostics.
|
||||||
|
Each process has a host-side timeout. It does not require or validate physical keyboard presses.
|
||||||
CPU-driven smoke tests supply scripted buttons; they do not validate physical keyboard events.
|
CPU-driven smoke tests supply scripted buttons; they do not validate physical keyboard events.
|
||||||
To test the core and demo programs without building the window dependency:
|
To test the core and demo programs without building the window dependency:
|
||||||
|
|
||||||
|
|
@ -42,7 +51,10 @@ They check file-size boundaries, bounded reads, short reads, I/O errors, paths,
|
||||||
CLI tests cover option order, missing/conflicting options, invalid step limits, process exit status, and reports.
|
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.
|
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.
|
Unsupported instructions, truncated code, and DMA errors retain the failure state for inspection.
|
||||||
See [cartridge execution](hardware/cartridge.md) for usage and limitations.
|
ROM-window session tests compare every pixel from an original CPU-written palette with expected colors.
|
||||||
|
They check keyboard-to-ROM input, focus loss, repeated STOP polling, keypad wake, HALT progress, and bounded slices.
|
||||||
|
Further checks cover frame-budget retention/reset, DMA/IRQ step accounting, video errors, and final reports.
|
||||||
|
See [cartridge execution](hardware/cartridge.md) for usage, limitations, and a generated original ROM for manual input testing.
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue