feat(desktop): add muted accelerated ROM playback

This commit is contained in:
0xrsydn 2026-10-07 08:51:05 +07:00
commit 423d0ba120
12 changed files with 270 additions and 16 deletions

View file

@ -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<PathBuf>,
}
@ -47,6 +48,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
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<Options> {
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::<u32>().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<Options> {
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<Options> {
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<dyn
frames,
options.hardware,
options.save_device,
options.audio,
crate::desktop::RomPlayback {
audio: options.audio,
speed: options.speed,
},
save_file.as_mut(),
writer,
)

View file

@ -31,6 +31,7 @@ fn options_require_both_flags_in_either_order() {
save_device: SaveDevice::None,
save_file: None,
audio: false,
speed: 1,
}
);
}
@ -70,6 +71,71 @@ fn save_file_requires_explicit_device_and_one_nonempty_path() {
);
}
#[test]
fn speed_defaults_to_one_and_accepts_bounded_integers_in_any_order() {
assert_eq!(
parse_args(&args(&["--rom", "test.gba", "--window"]))
.unwrap()
.speed,
1
);
for multiplier in 1..=16 {
let value = multiplier.to_string();
for arguments in [
args(&["--rom", "test.gba", "--window", "--speed", &value]),
args(&[
"--speed", &value, "--audio", "--frames", "3", "--rom", "test.gba", "--window",
]),
] {
assert_eq!(parse_args(&arguments).unwrap().speed, multiplier);
}
}
}
#[test]
fn speed_rejects_invalid_values_duplicate_flags_and_nonwindow_modes() {
for value in [
"",
"0",
"17",
"-1",
"+2",
"2.0",
"1.5",
"NaN",
"inf",
" 2",
"2 ",
"2",
"0x2",
"4294967296",
] {
assert!(
parse_args(&args(&["--rom", "test.gba", "--window", "--speed", value])).is_err(),
"{value:?}"
);
}
for arguments in [
vec!["--rom", "test.gba", "--window", "--speed"],
vec![
"--rom", "test.gba", "--window", "--speed", "2", "--speed", "2",
],
vec!["--rom", "test.gba", "--steps", "100", "--speed", "1"],
vec!["--rom", "test.gba", "--speed", "2"],
vec!["--speed", "2", "--cpu-demo"],
vec!["--speed", "2", "--help"],
] {
assert!(parse_args(&args(&arguments)).is_err(), "{arguments:?}");
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let mut arguments = args(&["--rom", "test.gba", "--window", "--speed"]);
arguments.push(OsString::from_vec(vec![255]));
assert!(parse_args(&arguments).is_err());
}
}
#[test]
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
assert!(

View file

@ -13,7 +13,7 @@ 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;
pub use rom::{run as run_rom, Playback as RomPlayback};
use std::{
error::Error,

View file

@ -5,6 +5,24 @@ use std::io::Write;
use super::*;
use crate::cartridge::window::{Session, Update};
/// Host-only playback policy. The CLI validates integer speeds from 1 through 16.
pub struct Playback {
pub audio: bool,
pub speed: u32,
}
impl Playback {
fn audio_enabled(&self) -> 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<u64>,
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<dyn Error>> {
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<dyn Error>> {
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();

View file

@ -55,6 +55,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
"--frames",
"--rtc",
"--audio",
"--speed",
"--save-type",
"--save-file",
]
@ -89,7 +90,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 [--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<dyn Error>> {
--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\

View file

@ -98,6 +98,78 @@ fn wave_setup() -> Vec<u32> {
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 [

View file

@ -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));

View file

@ -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 |

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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: