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:
0xrsydn 2026-10-05 19:12:55 +07:00
commit 109c1ffe81
19 changed files with 919 additions and 57 deletions

View file

@ -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::{
error::Error,
@ -15,42 +17,71 @@ use gba_core::{
};
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)]
pub struct Options {
path: PathBuf,
steps: u64,
mode: Mode,
}
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)?;
let mut frames = None;
let mut window = false;
let mut args = args.iter();
while let Some(arg) = args.next() {
if arg == "--rom" && path.is_none() {
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()) {
return 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());
}
steps = Some(count);
if arg == "--steps" {
steps = Some(count);
} else {
frames = Some(count);
}
} else {
return Err(invalid());
}
}
if !pairs.remainder().is_empty() {
return Err(invalid());
}
let mode = match (window, steps, frames) {
(false, Some(steps), None) => Mode::Terminal { steps },
(true, None, frames) => Mode::Window { frames },
_ => return Err(invalid()),
};
Ok(Options {
path: path.ok_or_else(invalid)?,
steps: steps.ok_or_else(invalid)?,
mode,
})
}
@ -112,6 +143,19 @@ struct Stats {
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)]
enum Reason {
StepLimit,
@ -137,14 +181,10 @@ fn run_bounded(machine: &mut Machine, limit: u64) -> Report {
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,
Ok(kind) => stats.record(kind),
Err(error) => break Reason::Diagnostic(error),
}
stats.steps += 1;
};
Report { stats, reason }
}
@ -156,7 +196,10 @@ fn write_report(writer: &mut impl Write, machine: &Machine, report: &Report) ->
Reason::Diagnostic(_) => "emulation diagnostic",
};
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!(
writer,
"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,
"Original BIOS replacement; no commercial-game compatibility claim."
)?;
writeln!(
writer,
"Machine-step limit: {} (includes boot)",
options.steps
)?;
let steps = match options.mode {
Mode::Terminal { steps } => steps,
Mode::Window { frames } => return crate::desktop::run_rom(bytes, frames, writer),
};
writeln!(writer, "Machine-step limit: {steps} (includes boot)")?;
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)?;
writer.flush()?;
if let Reason::Diagnostic(error) = report.reason {