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