diff --git a/crates/desktop/src/cartridge.rs b/crates/desktop/src/cartridge.rs index f5e6e01..c912e3e 100644 --- a/crates/desktop/src/cartridge.rs +++ b/crates/desktop/src/cartridge.rs @@ -22,7 +22,7 @@ use gba_core::{ const MAX_STEPS: u64 = 100_000_000; const MAX_FRAMES: u64 = 100_000; -const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; +const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; #[derive(Debug, PartialEq, Eq)] enum Mode { @@ -37,6 +37,7 @@ pub struct Options { hardware: CartridgeHardware, save_device: SaveDevice, audio: bool, + speed: u32, save_file: Option, } @@ -47,6 +48,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { let mut frames = None; let mut window = false; let mut audio = false; + let mut speed = None; let mut hardware = CartridgeHardware::None; let mut save_device = SaveDevice::None; let mut save_file = None; @@ -70,6 +72,19 @@ pub fn parse_args(args: &[OsString]) -> io::Result { save_file = Some(PathBuf::from( args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?, )); + } else if arg == "--speed" && speed.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 multiplier = value.parse::().map_err(|_| invalid())?; + if !(1..=16).contains(&multiplier) { + return Err(invalid()); + } + speed = Some(multiplier); } else if arg == "--audio" && !audio { audio = true; } else if arg == "--window" && !window { @@ -103,7 +118,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { if save_file.is_some() && save_device == SaveDevice::None { return Err(invalid()); } - if audio && !window { + if (audio || speed.is_some()) && !window { return Err(invalid()); } let mode = match (window, steps, frames) { @@ -118,6 +133,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { save_device, save_file, audio, + speed: speed.unwrap_or(1), }) } @@ -320,7 +336,10 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box bool { + self.audio && self.speed == 1 + } + + fn frame_period(&self) -> Duration { + Duration::from_secs_f64( + f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ) / f64::from(self.speed), + ) + } +} + fn next_frame_start(previous: Instant, now: Instant, period: Duration, audio: bool) -> Instant { let target = previous + period; if audio && now.saturating_duration_since(target) <= period { @@ -19,10 +37,11 @@ pub fn run( frame_limit: Option, hardware: gba_core::cartridge::CartridgeHardware, save_device: gba_core::cartridge::SaveDevice, - audio_enabled: bool, + playback: Playback, save_file: Option<&mut crate::cartridge::save_file::SaveFile>, writer: &mut impl Write, ) -> Result<(), Box> { + let audio_enabled = playback.audio_enabled(); let mut session = Session::new(bytes)?; session.set_cartridge_hardware(hardware); session.memory_mut().set_save_device(save_device); @@ -44,6 +63,11 @@ pub fn run( writer, "Input releases and audio mutes on focus loss. Save files require --save-file and clean exit. Startup frames are not suppressed." )?; + writeln!( + writer, + "Playback speed: {}x target; actual speed depends on host performance.", + playback.speed + )?; if let Some(limit) = frame_limit { writeln!( writer, @@ -54,20 +78,31 @@ pub fn run( let mut audio = None; let mut samples = [gba_core::audio::StereoLevel::default(); 1024]; let result = (|| -> Result<&str, Box> { - let title = "GBA Rust | ROM | Esc: exit"; - let mut window = create_window(title)?; + let title = format!("GBA Rust | ROM | {}x target | Esc: exit", playback.speed); + let mut window = create_window(&title)?; window.set_target_fps(0); if audio_enabled { let output = crate::audio::AudioOutput::new()?; writeln!(writer, "Audio enabled: {}", output.description())?; audio = Some(output); session.memory_mut().set_audio_capture(true); + } else if playback.speed > 1 { + writeln!( + writer, + "Audio muted for accelerated playback (--audio ignored above 1x)." + )?; } else { writeln!(writer, "Audio muted; use --audio for macOS output.")?; } writer.flush()?; let mut was_audio_active = false; - let period = Duration::from_secs_f64(f64::from(CYCLES_PER_FRAME) / f64::from(CPU_HZ)); + let period = playback.frame_period(); + // STOP has no emulation work to accelerate. Keep host input polling at normal speed. + let stopped_period = Playback { + audio: false, + speed: 1, + } + .frame_period(); let mut frame_started = Instant::now(); let mut was_stopped = false; // Show a black buffer and pump initial events before boot starts. @@ -104,7 +139,7 @@ pub fn run( window.set_title(if stopped { "GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit" } else { - title + &title }); was_stopped = stopped; } @@ -131,7 +166,7 @@ pub fn run( } // Retain the last image. Host polling and sleep do not advance GBA clocks. window.update(); - thread::sleep(period); + thread::sleep(stopped_period); frame_started = Instant::now(); } } @@ -159,6 +194,29 @@ pub fn run( #[cfg(test)] mod pacing_tests { use super::*; + #[test] + fn speed_scales_only_host_period_and_disables_audio_above_one() { + let normal = Playback { + audio: true, + speed: 1, + }; + assert!(normal.audio_enabled()); + assert!(!Playback { + audio: false, + speed: 1 + } + .audio_enabled()); + for speed in 1..=16 { + for audio in [false, true] { + let playback = Playback { audio, speed }; + assert_eq!(playback.audio_enabled(), audio && speed == 1); + let total = playback.frame_period().as_secs_f64() * f64::from(speed); + assert!((total - normal.frame_period().as_secs_f64()).abs() < 0.00000002); + assert!(!playback.frame_period().is_zero()); + } + } + } + #[test] fn audio_deadlines_absorb_sleep_overshoot_but_bound_catch_up() { let start = Instant::now(); diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index e82a5e2..eefc940 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -55,6 +55,7 @@ fn parse_args(args: &[OsString]) -> Result { "--frames", "--rtc", "--audio", + "--speed", "--save-type", "--save-file", ] @@ -89,7 +90,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 [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio]); or gameboy-rust --test-suite PATH.json", + "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 [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER]); or gameboy-rust --test-suite PATH.json", )), } } @@ -135,7 +136,8 @@ fn run() -> Result<(), Box> { --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\ - --audio Enable macOS output for ROM windows (default: muted)\n\ + --speed MULTIPLIER ROM-window speed: integer 1..=16 (default: 1); mutes audio above 1x\n\ + --audio Enable macOS output at 1x (default: muted)\n\ --rtc Select cartridge RTC (UTC startup, host elapsed time)\n\ --save-type flash64|flash128 Select nominal Macronix Flash emulation\n\ --save-file PATH Load raw Flash image; persist only on clean exit\n\ diff --git a/crates/desktop/tests/rom_cli.rs b/crates/desktop/tests/rom_cli.rs index 9e93426..d542d44 100644 --- a/crates/desktop/tests/rom_cli.rs +++ b/crates/desktop/tests/rom_cli.rs @@ -98,6 +98,78 @@ fn wave_setup() -> Vec { code } +#[test] +fn speed_options_fail_before_file_access_when_invalid() { + for tail in [ + vec!["--speed"], + vec!["--speed", "0"], + vec!["--speed", "17"], + vec!["--speed", "NaN"], + vec!["--speed", "2.5"], + vec!["--speed", "2", "--speed", "2"], + vec!["--steps", "1", "--speed", "1"], + ] { + let output = command() + .args(["--rom", "missing.gba", "--window"]) + .args(tail) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(stderr(&output).contains("usage:")); + assert!(!stderr(&output).contains("cannot load ROM")); + } + let output = command() + .args(["--rom", "missing.gba", "--steps", "1", "--speed", "2"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(stderr(&output).contains("usage:")); + for tail in [vec!["--speed", "2"], vec!["--speed", "2", "--audio"]] { + let output = command() + .args(["--rom", "missing.gba", "--window"]) + .args(tail) + .output() + .unwrap(); + assert!(stderr(&output).contains("cannot load ROM")); + } + let output = command().arg("--help").output().unwrap(); + assert!(stdout(&output).contains("--speed MULTIPLIER")); +} + +#[test] +#[ignore = "requires a logged-in desktop session; accelerated audio stays muted"] +fn native_rom_window_speed_preserves_state_and_mutes_requested_audio() { + let fixture = Fixture::new(); + let path = fixture.rom(&wave_setup()); + let mut baseline = None; + for speed in ["1", "2", "16"] { + let mut cmd = command(); + cmd.arg("--rom") + .arg(&path) + .args(["--window", "--frames", "60", "--speed", speed]); + if speed != "1" { + cmd.arg("--audio"); + } + let started = std::time::Instant::now(); + let output = cmd.output().unwrap(); + eprintln!("speed {speed}x: {:?}", started.elapsed()); + assert!(output.status.success(), "{}", stderr(&output)); + let report = stdout(&output); + assert!(report.contains(&format!("Playback speed: {speed}x target"))); + assert!(report.contains("Captured ROM frames: 60")); + assert!(!report.contains("Audio enabled:")); + if speed != "1" { + assert!(report.contains("Audio muted for accelerated playback")); + } + let state = report.split("Steps: ").nth(1).unwrap().to_owned(); + if let Some(expected) = &baseline { + assert_eq!(&state, expected); + } else { + baseline = Some(state); + } + } +} + #[test] fn audio_options_fail_before_file_access_unless_a_window_is_selected() { for tail in [ diff --git a/crates/desktop/tests/save_cli.rs b/crates/desktop/tests/save_cli.rs index 4328eea..d8231d2 100644 --- a/crates/desktop/tests/save_cli.rs +++ b/crates/desktop/tests/save_cli.rs @@ -189,7 +189,7 @@ fn native_rom_window_save_reloads_in_another_process() { let f = Fixture::new(); let write = f.rom("writer.gba", &writer(0xa5, false)); let output = command(&write, &f.save(), "flash128") - .args(["--window", "--frames", "3"]) + .args(["--window", "--frames", "3", "--speed", "2", "--audio"]) .output() .unwrap(); assert!(output.status.success(), "{}", text(&output)); diff --git a/docs/architecture.md b/docs/architecture.md index 97ab327..a3c5fb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,7 +176,7 @@ The repository is a Cargo workspace with three crates: | `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests | | `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, optional audio draining/muting, bounded pacing, and frame-limited exit | +| `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, optional audio draining/muting, speed-scaled host 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` | File loading and exit-status checks; opt-in native ROM-window integration test | diff --git a/docs/hardware/audio.md b/docs/hardware/audio.md index 92a07b8..110a83c 100644 --- a/docs/hardware/audio.md +++ b/docs/hardware/audio.md @@ -264,8 +264,10 @@ Original tests cover exact sample boundaries, timer consumption, pulse phases, b ## Darwin output adapter -Use `--audio` with `--rom ... --window` to enable CPAL 0.16/CoreAudio output on macOS. +Use `--audio` with `--rom ... --window` at 1× speed to enable CPAL 0.16/CoreAudio output on macOS. Windows remain muted by default. Terminal runs, ROM suites, and demos do not open an audio device. +ROM-window `--speed` values above 1 override `--audio`: no device is opened and sample capture stays disabled. +Sound hardware still executes at the same emulated cycle rates. Accelerated audio conversion is not implemented. The flag is invalid in terminal mode and cannot be repeated. Other platforms currently report an explicit unsupported-platform error. The adapter opens the default output device at its default configuration. diff --git a/docs/hardware/cartridge.md b/docs/hardware/cartridge.md index 431877f..a04a048 100644 --- a/docs/hardware/cartridge.md +++ b/docs/hardware/cartridge.md @@ -108,6 +108,27 @@ 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. +### Playback speed + +Use `--speed 2` with `--window` to target twice normal speed. +The option accepts integer multipliers from 1 through 16; the default is 1. +Missing values, duplicate options, fractions, and values outside this range are rejected before ROM loading. +The option is not available for terminal runs, test suites, or demos. + +```sh +direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --save-file roms/pokemon-emerald.sav --window --speed 2 +``` + +Speed changes only the host frame period. CPU/device cycle ratios, captured frames, execution budgets, and save operations remain unchanged. +Every emulated frame is still presented. Actual speed depends on CPU, rendering, window-system, and display costs. +Input remains sampled between bounded execution slices. Interactive STOP retains normal-speed host polling instead of busy waiting. +RTC still follows host elapsed time; the speed multiplier does not accelerate the cartridge clock. + +Above 1×, audio output and capture are disabled, even when `--audio` is present. +No host audio device is opened. Sound hardware still executes normally, including diagnostics for unsupported behavior. +At 1×, `--audio` retains its existing meaning. The window title and terminal report show the requested speed. +Finish the in-game save before exiting normally; speed does not change clean-exit persistence rules. + 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. @@ -138,7 +159,8 @@ Right takes priority over Z. Other buttons have no visible effect in this small No game or firmware content is included. ROM windows can enable Darwin output with `--audio`; they are muted by default. -The option requires a usable default audio device. Device/configuration errors fail explicitly; omit the flag to run muted. +At 1×, the option requires a usable default audio device. Device/configuration errors fail explicitly; omit the flag to run muted. +Above 1×, `--speed` overrides `--audio` and keeps output muted. Focus loss and STOP clear queued audio. See [audio output and its limits](audio.md#darwin-output-adapter). Terminal mode has no audio. Both modes support explicit save files but lack per-step tracing. Commercial-game compatibility remains unverified and unsupported. diff --git a/docs/status.md b/docs/status.md index b660cd1..e314b54 100644 --- a/docs/status.md +++ b/docs/status.md @@ -95,6 +95,7 @@ Update this file when a feature lands or a limit is removed. - Two-bank wave playback, active bank changes, and unsupported serial operations remain diagnostic with whole-access and block-store rollback. - ROM windows with scanline capture, keyboard input, focus-loss release, bounded execution slices, and interactive keypad wake from STOP. - Opt-in macOS CoreAudio output, with tested original tones and wave-ROM playback; focus loss and STOP mute queued audio. +- ROM-window `--speed 1..16` targets accelerated playback through host pacing only; audio is muted above 1×. - Optional ROM-window frame limits and an original input-test ROM generator. - Headless JSON ROM suites with PC or VBlank completion, register/CPSR/memory/pixel assertions, scheduled buttons, bounded budgets, and structured failure reports. - Hash-pinned preparation of public `jsmolka/gba-tests` ARM, Thumb, memory, and BIOS ROMs, with verified result-register checkpoints. diff --git a/docs/testing.md b/docs/testing.md index 052cc7a..b0bfced 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,6 +99,18 @@ Original terminal/native programs activate wave playback without external assets Fixed-rate capture tests verify sample boundaries, timer/PSG ordering, rollback, queue limits, drop counts, and HALT/STOP. Host conversion tests require no device and cover rates, stereo/mono mapping, prefill, overflow, underflow, DC removal, and callback splitting. CLI tests keep output muted by default and reject `--audio` outside ROM-window mode. +Speed tests validate integer multipliers 1–16, default 1, invalid/duplicate options, and window-only use. +Pacing tests check scaled host periods and forced muting above 1×, even with `--audio`. +A native test compares identical final CPU/cycle reports after fixed frame counts at 1×, 2×, and 16×. +It records elapsed host time without imposing a load-sensitive timing assertion. +The original native save/restart test also runs its saving window at 2× with `--audio` requested, but muted. + +```sh +direnv exec . cargo test --locked --release -p gameboy-rust-desktop --test rom_cli native_rom_window_speed -- --ignored --nocapture --test-threads=1 +``` + +A Darwin arm64 release run took approximately 1.27 seconds at 1× and 0.63 seconds at 2× for the same original program. +Process/window startup is included. This verifies acceleration in that probe, not a speed guarantee for other games. The following ignored tests require a native audio device and play original test tones: