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
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::{
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
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 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();
|
||||
}
|
||||
|
|
|
|||
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> {
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue