feat(desktop): add muted accelerated ROM playback
This commit is contained in:
parent
86f29018fd
commit
423d0ba120
12 changed files with 270 additions and 16 deletions
|
|
@ -22,7 +22,7 @@ use gba_core::{
|
||||||
|
|
||||||
const MAX_STEPS: u64 = 100_000_000;
|
const MAX_STEPS: u64 = 100_000_000;
|
||||||
const MAX_FRAMES: u64 = 100_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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
|
|
@ -37,6 +37,7 @@ pub struct Options {
|
||||||
hardware: CartridgeHardware,
|
hardware: CartridgeHardware,
|
||||||
save_device: SaveDevice,
|
save_device: SaveDevice,
|
||||||
audio: bool,
|
audio: bool,
|
||||||
|
speed: u32,
|
||||||
save_file: Option<PathBuf>,
|
save_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,6 +48,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
let mut frames = None;
|
let mut frames = None;
|
||||||
let mut window = false;
|
let mut window = false;
|
||||||
let mut audio = false;
|
let mut audio = false;
|
||||||
|
let mut speed = None;
|
||||||
let mut hardware = CartridgeHardware::None;
|
let mut hardware = CartridgeHardware::None;
|
||||||
let mut save_device = SaveDevice::None;
|
let mut save_device = SaveDevice::None;
|
||||||
let mut save_file = None;
|
let mut save_file = None;
|
||||||
|
|
@ -70,6 +72,19 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
save_file = Some(PathBuf::from(
|
save_file = Some(PathBuf::from(
|
||||||
args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?,
|
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 {
|
} else if arg == "--audio" && !audio {
|
||||||
audio = true;
|
audio = true;
|
||||||
} else if arg == "--window" && !window {
|
} 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 {
|
if save_file.is_some() && save_device == SaveDevice::None {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
if audio && !window {
|
if (audio || speed.is_some()) && !window {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
let mode = match (window, steps, frames) {
|
let mode = match (window, steps, frames) {
|
||||||
|
|
@ -118,6 +133,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
save_device,
|
save_device,
|
||||||
save_file,
|
save_file,
|
||||||
audio,
|
audio,
|
||||||
|
speed: speed.unwrap_or(1),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,7 +336,10 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
||||||
frames,
|
frames,
|
||||||
options.hardware,
|
options.hardware,
|
||||||
options.save_device,
|
options.save_device,
|
||||||
options.audio,
|
crate::desktop::RomPlayback {
|
||||||
|
audio: options.audio,
|
||||||
|
speed: options.speed,
|
||||||
|
},
|
||||||
save_file.as_mut(),
|
save_file.as_mut(),
|
||||||
writer,
|
writer,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ fn options_require_both_flags_in_either_order() {
|
||||||
save_device: SaveDevice::None,
|
save_device: SaveDevice::None,
|
||||||
save_file: None,
|
save_file: None,
|
||||||
audio: false,
|
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]
|
#[test]
|
||||||
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ pub use bitmap::run as run_bitmap;
|
||||||
pub use effects::run as run_effects;
|
pub use effects::run as run_effects;
|
||||||
pub use mosaic::run as run_mosaic;
|
pub use mosaic::run as run_mosaic;
|
||||||
pub use raster::run as run_raster;
|
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::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,24 @@ use std::io::Write;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cartridge::window::{Session, Update};
|
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 {
|
fn next_frame_start(previous: Instant, now: Instant, period: Duration, audio: bool) -> Instant {
|
||||||
let target = previous + period;
|
let target = previous + period;
|
||||||
if audio && now.saturating_duration_since(target) <= period {
|
if audio && now.saturating_duration_since(target) <= period {
|
||||||
|
|
@ -19,10 +37,11 @@ pub fn run(
|
||||||
frame_limit: Option<u64>,
|
frame_limit: Option<u64>,
|
||||||
hardware: gba_core::cartridge::CartridgeHardware,
|
hardware: gba_core::cartridge::CartridgeHardware,
|
||||||
save_device: gba_core::cartridge::SaveDevice,
|
save_device: gba_core::cartridge::SaveDevice,
|
||||||
audio_enabled: bool,
|
playback: Playback,
|
||||||
save_file: Option<&mut crate::cartridge::save_file::SaveFile>,
|
save_file: Option<&mut crate::cartridge::save_file::SaveFile>,
|
||||||
writer: &mut impl Write,
|
writer: &mut impl Write,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let audio_enabled = playback.audio_enabled();
|
||||||
let mut session = Session::new(bytes)?;
|
let mut session = Session::new(bytes)?;
|
||||||
session.set_cartridge_hardware(hardware);
|
session.set_cartridge_hardware(hardware);
|
||||||
session.memory_mut().set_save_device(save_device);
|
session.memory_mut().set_save_device(save_device);
|
||||||
|
|
@ -44,6 +63,11 @@ pub fn run(
|
||||||
writer,
|
writer,
|
||||||
"Input releases and audio mutes on focus loss. Save files require --save-file and clean exit. Startup frames are not suppressed."
|
"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 {
|
if let Some(limit) = frame_limit {
|
||||||
writeln!(
|
writeln!(
|
||||||
writer,
|
writer,
|
||||||
|
|
@ -54,20 +78,31 @@ pub fn run(
|
||||||
let mut audio = None;
|
let mut audio = None;
|
||||||
let mut samples = [gba_core::audio::StereoLevel::default(); 1024];
|
let mut samples = [gba_core::audio::StereoLevel::default(); 1024];
|
||||||
let result = (|| -> Result<&str, Box<dyn Error>> {
|
let result = (|| -> Result<&str, Box<dyn Error>> {
|
||||||
let title = "GBA Rust | ROM | Esc: exit";
|
let title = format!("GBA Rust | ROM | {}x target | Esc: exit", playback.speed);
|
||||||
let mut window = create_window(title)?;
|
let mut window = create_window(&title)?;
|
||||||
window.set_target_fps(0);
|
window.set_target_fps(0);
|
||||||
if audio_enabled {
|
if audio_enabled {
|
||||||
let output = crate::audio::AudioOutput::new()?;
|
let output = crate::audio::AudioOutput::new()?;
|
||||||
writeln!(writer, "Audio enabled: {}", output.description())?;
|
writeln!(writer, "Audio enabled: {}", output.description())?;
|
||||||
audio = Some(output);
|
audio = Some(output);
|
||||||
session.memory_mut().set_audio_capture(true);
|
session.memory_mut().set_audio_capture(true);
|
||||||
|
} else if playback.speed > 1 {
|
||||||
|
writeln!(
|
||||||
|
writer,
|
||||||
|
"Audio muted for accelerated playback (--audio ignored above 1x)."
|
||||||
|
)?;
|
||||||
} else {
|
} else {
|
||||||
writeln!(writer, "Audio muted; use --audio for macOS output.")?;
|
writeln!(writer, "Audio muted; use --audio for macOS output.")?;
|
||||||
}
|
}
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
let mut was_audio_active = false;
|
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 frame_started = Instant::now();
|
||||||
let mut was_stopped = false;
|
let mut was_stopped = false;
|
||||||
// Show a black buffer and pump initial events before boot starts.
|
// Show a black buffer and pump initial events before boot starts.
|
||||||
|
|
@ -104,7 +139,7 @@ pub fn run(
|
||||||
window.set_title(if stopped {
|
window.set_title(if stopped {
|
||||||
"GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit"
|
"GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit"
|
||||||
} else {
|
} else {
|
||||||
title
|
&title
|
||||||
});
|
});
|
||||||
was_stopped = stopped;
|
was_stopped = stopped;
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +166,7 @@ pub fn run(
|
||||||
}
|
}
|
||||||
// Retain the last image. Host polling and sleep do not advance GBA clocks.
|
// Retain the last image. Host polling and sleep do not advance GBA clocks.
|
||||||
window.update();
|
window.update();
|
||||||
thread::sleep(period);
|
thread::sleep(stopped_period);
|
||||||
frame_started = Instant::now();
|
frame_started = Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +194,29 @@ pub fn run(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod pacing_tests {
|
mod pacing_tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn audio_deadlines_absorb_sleep_overshoot_but_bound_catch_up() {
|
fn audio_deadlines_absorb_sleep_overshoot_but_bound_catch_up() {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
||||||
"--frames",
|
"--frames",
|
||||||
"--rtc",
|
"--rtc",
|
||||||
"--audio",
|
"--audio",
|
||||||
|
"--speed",
|
||||||
"--save-type",
|
"--save-type",
|
||||||
"--save-file",
|
"--save-file",
|
||||||
]
|
]
|
||||||
|
|
@ -89,7 +90,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
||||||
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
|
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
|
||||||
_ => Err(io::Error::new(
|
_ => Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
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 --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
|
||||||
--rom PATH --window Open a raw ROM with keyboard input\n\
|
--rom PATH --window Open a raw ROM with keyboard input\n\
|
||||||
--frames COUNT Exit ROM window after 1..=100000 captured frames\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\
|
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
|
||||||
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
|
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
|
||||||
--save-file PATH Load raw Flash image; persist only on clean exit\n\
|
--save-file PATH Load raw Flash image; persist only on clean exit\n\
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,78 @@ fn wave_setup() -> Vec<u32> {
|
||||||
code
|
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]
|
#[test]
|
||||||
fn audio_options_fail_before_file_access_unless_a_window_is_selected() {
|
fn audio_options_fail_before_file_access_unless_a_window_is_selected() {
|
||||||
for tail in [
|
for tail in [
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ fn native_rom_window_save_reloads_in_another_process() {
|
||||||
let f = Fixture::new();
|
let f = Fixture::new();
|
||||||
let write = f.rom("writer.gba", &writer(0xa5, false));
|
let write = f.rom("writer.gba", &writer(0xa5, false));
|
||||||
let output = command(&write, &f.save(), "flash128")
|
let output = command(&write, &f.save(), "flash128")
|
||||||
.args(["--window", "--frames", "3"])
|
.args(["--window", "--frames", "3", "--speed", "2", "--audio"])
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(output.status.success(), "{}", text(&output));
|
assert!(output.status.success(), "{}", text(&output));
|
||||||
|
|
|
||||||
|
|
@ -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/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.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/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/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/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 |
|
| `crates/desktop/tests/rom_cli.rs` | File loading and exit-status checks; opt-in native ROM-window integration test |
|
||||||
|
|
|
||||||
|
|
@ -264,8 +264,10 @@ Original tests cover exact sample boundaries, timer consumption, pulse phases, b
|
||||||
|
|
||||||
## Darwin output adapter
|
## 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.
|
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 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.
|
The adapter opens the default output device at its default configuration.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
No game or firmware content is included.
|
||||||
|
|
||||||
ROM windows can enable Darwin output with `--audio`; they are muted by default.
|
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).
|
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.
|
Terminal mode has no audio. Both modes support explicit save files but lack per-step tracing.
|
||||||
Commercial-game compatibility remains unverified and unsupported.
|
Commercial-game compatibility remains unverified and unsupported.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Hash-pinned preparation of public `jsmolka/gba-tests` ARM, Thumb, memory, and BIOS ROMs, with verified result-register checkpoints.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
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:
|
The following ignored tests require a native audio device and play original test tones:
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue