feat(desktop): add opt-in Darwin audio output

This commit is contained in:
0xrsydn 2026-10-07 07:16:42 +07:00
commit 2ea7164d9a
21 changed files with 1311 additions and 30 deletions

View file

@ -15,3 +15,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Pinned because src/desktop.rs corrects a macOS focus bug in this version.
minifb = { version = "=0.28.0", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "macos")'.dependencies]
cpal = { version = "=0.16.0", default-features = false }

View file

@ -0,0 +1,30 @@
//! Opt-in desktop audio. The emulator core never owns a host device.
#[cfg(target_os = "macos")]
mod device;
#[cfg(any(target_os = "macos", test))]
mod playback;
#[cfg(target_os = "macos")]
pub use device::AudioOutput;
#[cfg(not(target_os = "macos"))]
pub struct AudioOutput;
#[cfg(not(target_os = "macos"))]
impl AudioOutput {
pub fn new() -> std::io::Result<Self> {
Err(std::io::Error::other(
"--audio currently requires macOS; omit --audio to run muted",
))
}
pub fn submit(&self, _: &[gba_core::audio::StereoLevel]) -> std::io::Result<()> {
unreachable!()
}
pub fn clear(&self) -> std::io::Result<()> {
unreachable!()
}
pub fn description(&self) -> String {
unreachable!()
}
pub fn report(&self, _: &mut impl std::io::Write) -> std::io::Result<()> {
unreachable!()
}
}

View file

@ -0,0 +1,188 @@
//! macOS CoreAudio through CPAL. No blocking or allocation in the data callback.
use super::playback::Playback;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use gba_core::audio::StereoLevel;
use std::{
io::{self, Write},
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
};
struct Shared {
playback: Mutex<Playback>,
failed: AtomicBool,
callbacks: AtomicU64,
nonzero: AtomicU64,
contention: AtomicU64,
}
pub struct AudioOutput {
_stream: cpal::Stream,
shared: Arc<Shared>,
rate: u32,
channels: u16,
}
impl AudioOutput {
pub fn new() -> io::Result<Self> {
let device = cpal::default_host()
.default_output_device()
.ok_or_else(|| {
io::Error::other("no default audio output device; omit --audio to run muted")
})?;
let config = device.default_output_config().map_err(io::Error::other)?;
let rate = config.sample_rate().0;
let channels = config.channels();
if !(8000..=192000).contains(&rate) || !(1..=32).contains(&channels) {
return Err(io::Error::other(
"unsupported audio rate/channel count; omit --audio to run muted",
));
}
let shared = Arc::new(Shared {
playback: Mutex::new(Playback::new(rate, usize::from(channels))),
failed: AtomicBool::new(false),
callbacks: AtomicU64::new(0),
nonzero: AtomicU64::new(0),
contention: AtomicU64::new(0),
});
let stream = match config.sample_format() {
cpal::SampleFormat::F32 => build::<f32>(&device, &config.into(), shared.clone()),
cpal::SampleFormat::I16 => build::<i16>(&device, &config.into(), shared.clone()),
cpal::SampleFormat::U16 => build::<u16>(&device, &config.into(), shared.clone()),
format => {
return Err(io::Error::other(format!(
"unsupported output sample format {format}; omit --audio to run muted"
)))
}
}?;
stream.play().map_err(io::Error::other)?;
Ok(Self {
_stream: stream,
shared,
rate,
channels,
})
}
fn check(&self) -> io::Result<()> {
if self.shared.failed.load(Ordering::Relaxed) {
Err(io::Error::other(
"audio output stream failed; restart without --audio to run muted",
))
} else {
Ok(())
}
}
pub fn submit(&self, samples: &[StereoLevel]) -> io::Result<()> {
self.check()?;
self.shared
.playback
.lock()
.map_err(|_| io::Error::other("audio queue poisoned"))?
.submit(samples);
Ok(())
}
pub fn clear(&self) -> io::Result<()> {
self.check()?;
self.shared
.playback
.lock()
.map_err(|_| io::Error::other("audio queue poisoned"))?
.clear();
Ok(())
}
pub fn description(&self) -> String {
format!(
"CoreAudio: {} Hz, {} channels; linear conversion, 25% gain, DC removal",
self.rate, self.channels
)
}
pub fn report(&self, writer: &mut impl Write) -> io::Result<()> {
let p = self
.shared
.playback
.lock()
.map_err(|_| io::Error::other("audio queue poisoned"))?;
writeln!(writer,"Audio callbacks: {}; nonzero output frames: {}; underruns: {}; dropped input frames: {}; callback lock misses: {}", self.shared.callbacks.load(Ordering::Relaxed),self.shared.nonzero.load(Ordering::Relaxed),p.underruns,p.dropped,self.shared.contention.load(Ordering::Relaxed))?;
writeln!(
writer,
"Audio submitted frames: {}; nonzero input frames: {}",
p.submitted, p.nonzero_input
)?;
self.check()
}
}
fn build<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
shared: Arc<Shared>,
) -> io::Result<cpal::Stream>
where
T: cpal::SizedSample + cpal::FromSample<f32>,
{
let errors = shared.clone();
device
.build_output_stream(
config,
move |data: &mut [T], _| {
data.fill(T::from_sample(0.));
shared.callbacks.fetch_add(1, Ordering::Relaxed);
match shared.playback.try_lock() {
Ok(mut p) => {
let nonzero = p.render(data, T::from_sample);
shared.nonzero.fetch_add(nonzero as u64, Ordering::Relaxed);
}
Err(std::sync::TryLockError::WouldBlock) => {
shared.contention.fetch_add(1, Ordering::Relaxed);
}
Err(std::sync::TryLockError::Poisoned(_)) => {
shared.failed.store(true, Ordering::Relaxed);
}
}
},
move |_| {
errors.failed.store(true, Ordering::Relaxed);
},
None,
)
.map_err(io::Error::other)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires a native default audio device; plays a quiet original test tone"]
fn native_audio_output_consumes_original_samples() {
let output = AudioOutput::new().unwrap();
// Exercise the actual core capture path without depending on window focus.
let mut memory = gba_core::memory::Memory::new(vec![]).unwrap();
for (address, value) in [
(0x04000084, 0x80),
(0x04000088, 0x200),
(0x04000082, 2),
(0x04000080, 0x1177),
(0x04000062, 0x3080),
(0x04000064, 0x86d6),
] {
memory.write16(address, value).unwrap();
}
memory.set_audio_capture(true);
memory.advance_cycles(4096 * 512);
let mut samples = vec![StereoLevel::default(); 4096];
assert_eq!(memory.drain_audio_samples(&mut samples), 4096);
assert_eq!(memory.audio_dropped_samples(), 0);
assert!(samples.iter().any(|s| s.left > 0));
assert!(samples.iter().any(|s| s.left < 0));
output.submit(&samples).unwrap();
let start = std::time::Instant::now();
while output.shared.nonzero.load(Ordering::Relaxed) == 0
&& start.elapsed() < std::time::Duration::from_secs(3)
{
std::thread::sleep(std::time::Duration::from_millis(10));
output.check().unwrap();
}
output.report(&mut std::io::stdout()).unwrap();
assert!(output.shared.nonzero.load(Ordering::Relaxed) > 0);
output.clear().unwrap();
}
}

View file

@ -0,0 +1,193 @@
//! Bounded host buffering and linear rate conversion. No device access here.
use gba_core::audio::{StereoLevel, AUDIO_SAMPLE_RATE};
use std::collections::VecDeque;
pub(super) const CAPACITY: usize = 8192;
const PREFILL: usize = 1024;
pub(super) struct Playback {
queue: VecDeque<StereoLevel>,
rate: u32,
channels: usize,
phase: u32,
pair: Option<[StereoLevel; 2]>,
previous: [f32; 2],
filtered: [f32; 2],
decay: f32,
pub(super) submitted: u64,
pub(super) nonzero_input: u64,
pub(super) dropped: u64,
pub(super) underruns: u64,
}
impl Playback {
pub(super) fn new(rate: u32, channels: usize) -> Self {
assert!((8000..=192000).contains(&rate) && (1..=32).contains(&channels));
Self {
queue: VecDeque::with_capacity(CAPACITY),
rate,
channels,
phase: 0,
pair: None,
previous: [0.; 2],
filtered: [0.; 2],
decay: (-std::f32::consts::TAU * 20. / rate as f32).exp(),
submitted: 0,
nonzero_input: 0,
dropped: 0,
underruns: 0,
}
}
pub(super) fn submit(&mut self, samples: &[StereoLevel]) {
self.submitted = self.submitted.saturating_add(samples.len() as u64);
self.nonzero_input = self.nonzero_input.saturating_add(
samples
.iter()
.filter(|s| s.left != 0 || s.right != 0)
.count() as u64,
);
for &sample in samples {
if self.queue.len() == CAPACITY {
self.dropped = self.dropped.saturating_add(1);
} else {
self.queue.push_back(sample);
}
}
}
pub(super) fn clear(&mut self) {
self.queue.clear();
self.reset();
}
fn reset(&mut self) {
self.pair = None;
self.phase = 0;
self.previous = [0.; 2];
self.filtered = [0.; 2];
}
fn next(&mut self) -> [f32; 2] {
if self.pair.is_none() {
if self.queue.len() < PREFILL {
return [0.; 2];
}
self.pair = Some([
self.queue.pop_front().unwrap(),
self.queue.pop_front().unwrap(),
]);
}
let [a, b] = self.pair.unwrap();
let fraction = self.phase as f32 / self.rate as f32;
let mut output = [0.; 2];
for (side, (a, b)) in [(a.left, b.left), (a.right, b.right)]
.into_iter()
.enumerate()
{
// Conservative 25% host gain and 20 Hz DC removal; not a hardware analog model.
let input = (f32::from(a) + (f32::from(b) - f32::from(a)) * fraction) / 512. * 0.25;
self.filtered[side] = input - self.previous[side] + self.decay * self.filtered[side];
self.previous[side] = input;
output[side] = self.filtered[side].clamp(-1., 1.);
}
self.phase += AUDIO_SAMPLE_RATE;
while self.phase >= self.rate {
self.phase -= self.rate;
if let Some(next) = self.queue.pop_front() {
let old = self.pair.unwrap()[1];
self.pair = Some([old, next]);
} else {
self.underruns = self.underruns.saturating_add(1);
self.reset();
break;
}
}
output
}
pub(super) fn render<T: Copy>(
&mut self,
output: &mut [T],
convert: impl Fn(f32) -> T,
) -> usize {
output.fill(convert(0.));
let mut nonzero = 0;
for frame in output.chunks_exact_mut(self.channels) {
let [left, right] = self.next();
if self.channels == 1 {
frame[0] = convert((left + right) * 0.5);
} else {
frame[0] = convert(left);
frame[1] = convert(right);
}
nonzero += usize::from(left != 0. || right != 0.);
}
nonzero
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(left: i16, right: i16) -> StereoLevel {
StereoLevel { left, right }
}
#[test]
fn prefill_silence_stereo_mapping_extra_channels_and_clear() {
let mut p = Playback::new(48000, 4);
let mut out = [9.; 16];
p.render(&mut out, |x| x);
assert_eq!(out, [0.; 16]);
p.submit(&vec![sample(256, -256); PREFILL]);
p.render(&mut out, |x| x);
assert_eq!(&out[..4], &[0.125, -0.125, 0., 0.]);
assert!(out[4] > 0. && out[4] < 0.125);
p.clear();
p.render(&mut out, |x| x);
assert_eq!(out, [0.; 16]);
}
#[test]
fn split_callbacks_keep_phase_and_rate_conversion_consumption() {
for rate in [8000, 32768, 44100, 48000, 192000] {
let mut a = Playback::new(rate, 2);
let mut b = Playback::new(rate, 2);
let data: Vec<_> = (0..4096)
.map(|i| sample((i % 1024) as i16 - 512, 0))
.collect();
a.submit(&data);
b.submit(&data);
let mut one = vec![0.; 2000];
let mut split = one.clone();
a.render(&mut one, |x| x);
for part in split.chunks_mut(14) {
b.render(part, |x| x);
}
assert_eq!(one, split);
assert_eq!(a.phase, b.phase);
assert_eq!(a.queue, b.queue);
if rate >= 32768 {
assert_eq!(
a.queue.len(),
4096 - 2 - (1000 * AUDIO_SAMPLE_RATE / rate) as usize
);
}
}
}
#[test]
fn overflow_underflow_and_recovery_are_bounded() {
let mut p = Playback::new(32768, 1);
p.submit(&vec![sample(100, -100); CAPACITY + 7]);
assert_eq!(p.dropped, 7);
let mut out = vec![1.; CAPACITY + 100];
p.render(&mut out, |x| x);
assert!(out.iter().all(|x| *x == 0.));
assert_eq!(p.underruns, 1);
p.submit(&vec![sample(256, 256); PREFILL]);
p.render(&mut out[..1], |x| x);
assert_eq!(out[0], 0.125);
}
#[test]
fn dc_offset_decays_without_out_of_range_or_nonfinite_samples() {
let mut p = Playback::new(32768, 2);
p.submit(&vec![sample(-512, 511); CAPACITY]);
let mut out = vec![0.; 12000];
p.render(&mut out, |x| x);
assert!(out.iter().all(|x| x.is_finite() && x.abs() <= 0.25));
assert!(out[11999].abs() < 0.000001);
}
}

View file

@ -21,7 +21,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] (--steps COUNT | --window [--frames COUNT]); 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] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
#[derive(Debug, PartialEq, Eq)]
enum Mode {
@ -35,6 +35,7 @@ pub struct Options {
mode: Mode,
hardware: CartridgeHardware,
save_device: SaveDevice,
audio: bool,
}
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
@ -43,6 +44,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
let mut steps = None;
let mut frames = None;
let mut window = false;
let mut audio = false;
let mut hardware = CartridgeHardware::None;
let mut save_device = SaveDevice::None;
let mut args = args.iter();
@ -61,6 +63,8 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
Some("flash128") => SaveDevice::Flash128,
_ => return Err(invalid()),
};
} else if arg == "--audio" && !audio {
audio = true;
} else if arg == "--window" && !window {
window = true;
} else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) {
@ -89,6 +93,9 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
return Err(invalid());
}
}
if audio && !window {
return Err(invalid());
}
let mode = match (window, steps, frames) {
(false, Some(steps), None) => Mode::Terminal { steps },
(true, None, frames) => Mode::Window { frames },
@ -99,6 +106,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
mode,
hardware,
save_device,
audio,
})
}
@ -284,6 +292,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
frames,
options.hardware,
options.save_device,
options.audio,
writer,
)
}

View file

@ -28,13 +28,36 @@ fn options_require_both_flags_in_either_order() {
path: "original program.gba".into(),
mode: Mode::Terminal { steps: count },
hardware: CartridgeHardware::None,
save_device: SaveDevice::None
save_device: SaveDevice::None,
audio: false,
}
);
}
}
}
#[test]
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
assert!(
parse_args(&args(&["--audio", "--rom", "test.gba", "--window"]))
.unwrap()
.audio
);
assert!(
!parse_args(&args(&["--rom", "test.gba", "--window"]))
.unwrap()
.audio
);
for tail in [
vec!["--audio", "--steps", "10"],
vec!["--audio", "--window", "--audio"],
] {
let mut arguments = args(&["--rom", "test.gba"]);
arguments.extend(args(&tail));
assert!(parse_args(&arguments).is_err());
}
}
#[test]
fn window_options_accept_any_order_and_optional_frame_limits() {
for arguments in [

View file

@ -1,15 +1,25 @@
//! Main-thread ROM presentation and physical keyboard input.
//! Main-thread ROM presentation, physical keyboard input, and optional host audio.
use std::io::Write;
use super::*;
use crate::cartridge::window::{Session, Update};
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 {
target
} else {
now
}
}
pub fn run(
bytes: Vec<u8>,
frame_limit: Option<u64>,
hardware: gba_core::cartridge::CartridgeHardware,
save_device: gba_core::cartridge::SaveDevice,
audio_enabled: bool,
writer: &mut impl Write,
) -> Result<(), Box<dyn Error>> {
let mut session = Session::new(bytes)?;
@ -28,7 +38,7 @@ pub fn run(
)?;
writeln!(
writer,
"Input releases on focus loss. No audio or saves. Startup frames are not suppressed."
"Input releases and audio mutes on focus loss. No saves. Startup frames are not suppressed."
)?;
if let Some(limit) = frame_limit {
writeln!(
@ -37,10 +47,22 @@ pub fn run(
)?;
}
writer.flush()?;
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 | No audio or saves";
let title = "GBA Rust | ROM | Esc: exit | No saves";
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 {
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 mut frame_started = Instant::now();
let mut was_stopped = false;
@ -50,9 +72,30 @@ pub fn run(
if let Some(clock) = rtc_clock.as_mut() {
clock.sync(session.memory_mut())?;
}
let buttons = read_buttons(window.is_active(), |key| window.is_key_down(key));
let reported_active = window.is_active();
let focused = is_focused(reported_active);
let buttons = read_buttons(reported_active, |key| window.is_key_down(key));
let update = session.update(buttons)?;
let stopped = update == Update::Stopped;
if let Some(output) = audio.as_ref() {
let active = focused && !stopped;
if !active && was_audio_active {
output.clear()?;
}
was_audio_active = active;
loop {
let count = session.memory_mut().drain_audio_samples(&mut samples);
output.submit(if active { &samples[..count] } else { &[] })?;
if count < samples.len() {
break;
}
}
if session.memory_mut().audio_dropped_samples() != 0 {
return Err(
io::Error::other("core audio capture overflow; reduce host load").into(),
);
}
}
if stopped != was_stopped {
window.set_title(if stopped {
"GBA Rust | ROM STOP: waiting for enabled keypad input | Esc: exit"
@ -71,9 +114,12 @@ pub fn run(
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();
// With audio, retain an absolute frame deadline so sleep overshoot
// does not steadily starve the output device. Catch up at most one
// frame; a slow host never accumulates unbounded work or skips emulation.
frame_started =
next_frame_start(frame_started, Instant::now(), period, audio_enabled);
}
Update::Stopped => {
if frame_limit.is_some() {
@ -95,5 +141,26 @@ pub fn run(
writer,
result.as_ref().copied().unwrap_or("ROM window diagnostic"),
)?;
if let Some(output) = audio.as_ref() {
output.report(writer)?;
}
result.map(|_| ())
}
#[cfg(test)]
mod pacing_tests {
use super::*;
#[test]
fn audio_deadlines_absorb_sleep_overshoot_but_bound_catch_up() {
let start = Instant::now();
let period = Duration::from_millis(16);
assert_eq!(
next_frame_start(start, start + Duration::from_millis(17), period, true),
start + period
);
let late = start + Duration::from_millis(50);
assert_eq!(next_frame_start(start, late, period, true), late);
let now = start + Duration::from_millis(17);
assert_eq!(next_frame_start(start, now, period, false), now);
}
}

View file

@ -1,3 +1,4 @@
mod audio;
mod cartridge;
mod desktop;
@ -53,6 +54,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
"--window",
"--frames",
"--rtc",
"--audio",
"--save-type",
]
.iter()
@ -86,7 +88,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] (--steps COUNT | --window [--frames COUNT]); 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] (--steps COUNT | --window [--frames COUNT] [--audio]); or gameboy-rust --test-suite PATH.json",
)),
}
}
@ -132,6 +134,7 @@ 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\
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--save-type flash64|flash128 Select Flash identification/read subset (no saving yet)\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
@ -167,7 +170,7 @@ fn run() -> Result<(), Box<dyn Error>> {
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\
Terminal ROM mode has no window or input. ROM modes have no audio or saves.\n\
Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. No 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."
);

View file

@ -82,10 +82,13 @@ fn wave_setup() -> Vec<u32> {
let mut code = Vec::new();
for (address, value) in [
(0x04000084, 0x80),
(0x04000088, 0x200),
(0x04000080, 0x4477),
(0x04000082, 2),
(0x04000090, 0xf012), // CPU bank 1 before selecting playback bank 1.
(0x04000070, 0xc0),
(0x04000072, 0x2000),
(0x04000074, 0x87ff),
(0x04000074, 0x87e0),
] {
code.extend([0xe59f0000, 0xea000000, address]);
code.extend([0xe59f1000, 0xea000000, value]);
@ -95,6 +98,29 @@ fn wave_setup() -> Vec<u32> {
code
}
#[test]
fn audio_options_fail_before_file_access_unless_a_window_is_selected() {
for tail in [
vec!["--audio", "--steps", "1"],
vec!["--audio", "--window", "--audio"],
] {
let output = command()
.args(["--rom", "missing-original.gba"])
.args(tail)
.output()
.unwrap();
assert!(!output.status.success());
assert!(stderr(&output).contains("usage:"));
assert!(!stderr(&output).contains("cannot load ROM"));
}
let output = command()
.args(["--audio", "--window", "--rom", "missing-original.gba"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(stderr(&output).contains("cannot load ROM"));
}
#[test]
fn original_wave_program_can_activate_channel3_without_host_audio() {
let fixture = Fixture::new();
@ -401,7 +427,7 @@ fn help_describes_terminal_rom_mode_and_existing_cpu_demo_still_runs() {
assert!(output.status.success());
assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
assert!(stdout(&output).contains("--rom PATH --window"));
assert!(stdout(&output).contains("ROM modes have no audio or saves"));
assert!(stdout(&output).contains("ROM windows offer --audio on macOS. No saves."));
let output = command().arg("--cpu-demo").output().unwrap();
assert!(output.status.success());
assert!(stdout(&output).contains("Exception demo"));
@ -440,6 +466,56 @@ fn window_options_and_bad_files_fail_without_opening_a_window() {
assert!(output.stdout.is_empty());
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "requires a focused native window and audio device; plays an original wave tone"]
fn native_rom_audio_window_routes_core_samples_to_output() {
let fixture = Fixture::new();
let path = fixture.rom(&wave_setup());
let mut child = command()
.arg("--rom")
.arg(&path)
.args(["--window", "--audio", "--frames", "120"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let start = std::time::Instant::now();
while child.try_wait().unwrap().is_none() {
if start.elapsed() > std::time::Duration::from_secs(30) {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
panic!(
"audio window timed out: {} {}",
stdout(&output),
stderr(&output)
);
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{} {}",
stdout(&output),
stderr(&output)
);
let text = stdout(&output);
assert!(text.contains("Audio enabled: CoreAudio:"), "{text}");
assert!(text.contains("Captured ROM frames: 120"), "{text}");
let nonzero: u64 = text
.split("nonzero output frames: ")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap()
.parse()
.unwrap();
assert!(nonzero > 0, "{text}");
assert!(text.contains("dropped input frames: 0"), "{text}");
}
#[test]
#[ignore = "requires a logged-in native desktop session"]
fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() {