diff --git a/AGENTS.md b/AGENTS.md index 57233c7..4260bfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Window smoke tests (`--*-smoke-test`) need a logged-in desktop session. Details | Working on | Read | | --- | --- | | 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) | | 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) | diff --git a/README.md b/README.md index f3bce8a..41ace5c 100644 --- a/README.md +++ b/README.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.** 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. -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 @@ -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. 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. ## 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/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/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/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 | diff --git a/crates/desktop/examples/write_rom_demo.rs b/crates/desktop/examples/write_rom_demo.rs new file mode 100644 index 0000000..f836921 --- /dev/null +++ b/crates/desktop/examples/write_rom_demo.rs @@ -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> { + 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(()) +} diff --git a/crates/desktop/src/cartridge.rs b/crates/desktop/src/cartridge.rs index 54c9350..1abd934 100644 --- a/crates/desktop/src/cartridge.rs +++ b/crates/desktop/src/cartridge.rs @@ -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 }, +} #[derive(Debug, PartialEq, Eq)] pub struct Options { path: PathBuf, - steps: u64, + mode: Mode, } pub fn parse_args(args: &[OsString]) -> io::Result { 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::().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 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 { diff --git a/crates/desktop/src/cartridge/tests.rs b/crates/desktop/src/cartridge/tests.rs index 5c3f0e7..ca7b807 100644 --- a/crates/desktop/src/cartridge/tests.rs +++ b/crates/desktop/src/cartridge/tests.rs @@ -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 [ diff --git a/crates/desktop/src/cartridge/window.rs b/crates/desktop/src/cartridge/window.rs new file mode 100644 index 0000000..dacd6db --- /dev/null +++ b/crates/desktop/src/cartridge/window.rs @@ -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) -> Result { + 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> { + self.update_bounded(buttons, SLICE_STEPS, FRAME_STEPS) + } + + fn update_bounded( + &mut self, + buttons: Buttons, + slice: usize, + frame_limit: usize, + ) -> Result> { + 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; diff --git a/crates/desktop/src/cartridge/window/tests.rs b/crates/desktop/src/cartridge/window/tests.rs new file mode 100644 index 0000000..c5d5b74 --- /dev/null +++ b/crates/desktop/src/cartridge/window/tests.rs @@ -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 { + 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 + ); +} diff --git a/crates/desktop/src/desktop.rs b/crates/desktop/src/desktop.rs index 83cadbd..db16bad 100644 --- a/crates/desktop/src/desktop.rs +++ b/crates/desktop/src/desktop.rs @@ -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(); } diff --git a/crates/desktop/src/desktop/rom.rs b/crates/desktop/src/desktop/rom.rs new file mode 100644 index 0000000..fbbcc41 --- /dev/null +++ b/crates/desktop/src/desktop/rom.rs @@ -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, + frame_limit: Option, + writer: &mut impl Write, +) -> Result<(), Box> { + 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> { + 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(|_| ()) +} diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index a6c1794..bc2e054 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -42,7 +42,11 @@ enum RunMode { } fn parse_args(args: &[OsString]) -> Result { - 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 { [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> { "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> { 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(()) } diff --git a/crates/desktop/tests/rom_cli.rs b/crates/desktop/tests/rom_cli.rs index c503d8d..a20605a 100644 --- a/crates/desktop/tests/rom_cli.rs +++ b/crates/desktop/tests/rom_cli.rs @@ -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::>(), + ) + .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() { diff --git a/crates/gba-demos/src/lib.rs b/crates/gba-demos/src/lib.rs index 8c69ecb..843091c 100644 --- a/crates/gba-demos/src/lib.rs +++ b/crates/gba-demos/src/lib.rs @@ -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 { + [ + 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; diff --git a/docs/architecture.md b/docs/architecture.md index f7ee514..9c42ad8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | | `Cargo.toml` | Workspace members and shared package settings | | `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/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 | @@ -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/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/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/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/memory.rs` | Memory widths, alignment, errors, and mirrors | | `crates/gba-core/tests/exceptions.rs` | Exception demo, BIOS mapping, and reset-vector execution | diff --git a/docs/development.md b/docs/development.md index 0dad210..6906db1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. - 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 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. 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. -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. For zsh, add this line to `~/.zshrc`: diff --git a/docs/hardware/bios.md b/docs/hardware/bios.md index a994eff..31ac888 100644 --- a/docs/hardware/bios.md +++ b/docs/hardware/bios.md @@ -26,7 +26,7 @@ Minimal boot initializes: 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. -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. Supported software interrupt services: diff --git a/docs/hardware/cartridge.md b/docs/hardware/cartridge.md index e81671d..1706578 100644 --- a/docs/hardware/cartridge.md +++ b/docs/hardware/cartridge.md @@ -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. ```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 --window ``` -Both options are required. Their order does not matter. -`COUNT` must contain decimal digits and be between 1 and 100,000,000 inclusive. +Supply `--rom PATH` and exactly one execution mode: `--steps COUNT` or `--window`. +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. 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. 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. 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. -## Execution limits and results +## Terminal execution limits and results The budget counts successful machine steps, including BIOS boot instructions. 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. 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. -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. diff --git a/docs/hardware/display-timing.md b/docs/hardware/display-timing.md index ac65992..ad748f7 100644 --- a/docs/hardware/display-timing.md +++ b/docs/hardware/display-timing.md @@ -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. 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. For each visible line, the renderer composes the row at HBlank entry, cycle 1,006. diff --git a/docs/status.md b/docs/status.md index a690e57..655d53e 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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. - 256 KiB external work RAM and 32 KiB internal work RAM, including mirrors. - 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. - 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. @@ -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. 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. -ROM files can run in bounded terminal mode 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 files can run in bounded terminal mode or a window with the original BIOS replacement. +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. 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. 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. -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. Treat Nintendo DS support as a separate project phase. diff --git a/docs/testing.md b/docs/testing.md index 3160093..6041e18 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,16 @@ file target/release/gameboy-rust ./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. 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. 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. -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. They cover a dense ArcTan unit-interval grid, sampled full-domain and seeded inputs, axes, quadrant boundaries, signed extremes, and scale invariance.