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

@ -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(())
}

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 {

View file

@ -26,13 +26,81 @@ fn options_require_both_flags_in_either_order() {
parse_args(&arguments).unwrap(),
Options {
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]
fn options_reject_missing_duplicate_conflicting_and_invalid_values() {
for arguments in [

View 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;

View 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
);
}

View file

@ -6,12 +6,14 @@ mod bitmap;
mod effects;
mod mosaic;
mod raster;
mod rom;
pub use affine::run as run_affine;
pub use affine_raster::run as run_affine_raster;
pub use bitmap::run as run_bitmap;
pub use effects::run as run_effects;
pub use mosaic::run as run_mosaic;
pub use raster::run as run_raster;
pub use rom::run as run_rom;
use std::{
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) {
return Buttons::default();
}

View 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(|_| ())
}

View file

@ -42,7 +42,11 @@ enum RunMode {
}
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);
}
match args {
@ -71,7 +75,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; 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\
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 --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\
--timer-demo Run the timer IRQ demo with nominal cycle costs\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\
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\
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(())
}

View file

@ -168,12 +168,114 @@ 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"));
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();
assert!(output.status.success());
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)]
#[test]
fn non_utf8_paths_reach_the_loader_without_panicking() {

View file

@ -10,6 +10,30 @@ pub mod raster_demo;
pub mod tile_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.
pub const DEMO_STEPS: usize = 40;