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