From 260e7ff9db2402e1ae5416ccec70d28393b17ef0 Mon Sep 17 00:00:00 2001 From: "se.cherkasov" Date: Fri, 13 Mar 2026 18:58:07 +0300 Subject: [PATCH] fix: stabilize desktop audio playback --- .gitignore | 3 +- Cargo.toml | 9 + crates/nesemu-desktop/src/main.rs | 306 ++++++++++++++++++++++++++---- src/native_core/bus.rs | 8 + src/native_core/bus/tests/apu.rs | 20 ++ src/native_core/bus/timing.rs | 1 + src/runtime/audio.rs | 60 +++++- src/runtime/core.rs | 12 +- src/runtime/tests.rs | 41 +++- tests/public_api.rs | 6 +- 10 files changed, 399 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index 4f3d578..06ca02f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/.worktrees/ +/.worktrees +/docs/superpowers diff --git a/Cargo.toml b/Cargo.toml index 3a137e2..cf619c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,12 @@ match_same_arms = "allow" module_name_repetitions = "allow" too_many_lines = "allow" needless_pass_by_value = "allow" + +[profile.dev] +opt-level = 1 + +[profile.dev.package.nesemu] +opt-level = 3 + +[profile.dev.package.nesemu-desktop] +opt-level = 2 diff --git a/crates/nesemu-desktop/src/main.rs b/crates/nesemu-desktop/src/main.rs index 3fb802e..cc21044 100644 --- a/crates/nesemu-desktop/src/main.rs +++ b/crates/nesemu-desktop/src/main.rs @@ -3,23 +3,25 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; -use gtk::gio; use gtk::gdk; +use gtk::gio; use gtk::glib; use gtk::prelude::*; use gtk4 as gtk; use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop}; use nesemu::{ - FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton, - JoypadButtons, NesRuntime, RingBuffer, set_button_pressed, + set_button_pressed, FrameClock, InputProvider, JoypadButton, JoypadButtons, NesRuntime, + RingBuffer, VideoMode, VideoOutput, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, }; const APP_ID: &str = "org.nesemu.desktop"; const TITLE: &str = "NES Emulator"; const SCALE: i32 = 3; const SAMPLE_RATE: u32 = 48_000; +const AUDIO_RING_CAPACITY: usize = 1536; +const AUDIO_CALLBACK_FRAMES: u32 = 256; fn main() { if std::env::var_os("GSK_RENDERER").is_none() { @@ -28,9 +30,7 @@ fn main() { } } - let app = gtk::Application::builder() - .application_id(APP_ID) - .build(); + let app = gtk::Application::builder().application_id(APP_ID).build(); let initial_rom: Rc>> = Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from))); @@ -125,16 +125,17 @@ fn build_ui(app: >k::Application, initial_rom: Option) { // --- State --- let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume)))); - let frame_for_draw: Rc>> = - Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES])); + let frame_for_draw: Rc>> = Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES])); + let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new())); // --- Draw function (pixel-perfect nearest-neighbor) --- { let frame_for_draw = Rc::clone(&frame_for_draw); drawing_area.set_draw_func(move |_da, cr, width, height| { let frame = frame_for_draw.borrow(); - let stride = - cairo::Format::ARgb32.stride_for_width(FRAME_WIDTH as u32).unwrap(); + let stride = cairo::Format::ARgb32 + .stride_for_width(FRAME_WIDTH as u32) + .unwrap(); let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT]; for y in 0..FRAME_HEIGHT { for x in 0..FRAME_WIDTH { @@ -223,6 +224,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { // --- Open ROM handler --- let do_open_rom = { let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); let window = window.clone(); Rc::new(move || { @@ -244,6 +246,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { chooser.add_filter(&all_filter); let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); chooser.connect_response(move |dialog, response| { if response == gtk::ResponseType::Accept { @@ -252,6 +255,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { if let Err(err) = app_state.load_rom_from_path(&path) { eprintln!("Failed to load ROM '{}': {err}", path.display()); } else { + scheduler.borrow_mut().reset_timing(); let name = rom_filename(&path); sync_ui(&app_state, Some(&name)); } @@ -273,20 +277,24 @@ fn build_ui(app: >k::Application, initial_rom: Option) { { let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); pause_button.connect_clicked(move |_| { let mut app_state = desktop.borrow_mut(); app_state.toggle_pause(); + scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); }); } { let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); reset_button.connect_clicked(move |_| { let mut app_state = desktop.borrow_mut(); app_state.reset(); + scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); }); } @@ -305,11 +313,13 @@ fn build_ui(app: >k::Application, initial_rom: Option) { let action_pause = gio::SimpleAction::new("toggle-pause", None); { let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); action_pause.connect_activate(move |_, _| { let mut app_state = desktop.borrow_mut(); if app_state.is_loaded() { app_state.toggle_pause(); + scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); } }); @@ -320,11 +330,13 @@ fn build_ui(app: >k::Application, initial_rom: Option) { let action_reset = gio::SimpleAction::new("reset", None); { let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); action_reset.connect_activate(move |_, _| { let mut app_state = desktop.borrow_mut(); if app_state.is_loaded() { app_state.reset(); + scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); } }); @@ -354,6 +366,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { // --- Drag-and-drop --- { let desktop = Rc::clone(&desktop); + let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY); drop_target.connect_drop(move |_, value, _, _| { @@ -364,6 +377,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { eprintln!("Failed to load ROM '{}': {err}", path.display()); return false; } + scheduler.borrow_mut().reset_timing(); let name = rom_filename(&path); sync_ui(&app_state, Some(&name)); return true; @@ -376,23 +390,45 @@ fn build_ui(app: >k::Application, initial_rom: Option) { // --- Game loop --- { - let desktop = Rc::clone(&desktop); - let drawing_area = drawing_area.clone(); - let frame_for_draw = Rc::clone(&frame_for_draw); - glib::timeout_add_local(Duration::from_millis(16), move || { + schedule_game_loop( + Rc::clone(&desktop), + drawing_area.clone(), + Rc::clone(&frame_for_draw), + Rc::clone(&scheduler), + ); + } + + window.present(); +} + +fn schedule_game_loop( + desktop: Rc>, + drawing_area: gtk::DrawingArea, + frame_for_draw: Rc>>, + scheduler: Rc>, +) { + let interval = desktop.borrow().frame_interval(); + let delay = scheduler + .borrow_mut() + .delay_until_next_frame(Instant::now(), interval); + + glib::timeout_add_local_once(delay, move || { + { let mut app_state = desktop.borrow_mut(); + let now = Instant::now(); + let interval = app_state.frame_interval(); + + scheduler.borrow_mut().mark_frame_complete(now, interval); app_state.tick(); frame_for_draw .borrow_mut() .copy_from_slice(app_state.frame_rgba()); drawing_area.queue_draw(); + } - glib::ControlFlow::Continue - }); - } - - window.present(); + schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler); + }); } fn rom_filename(path: &Path) -> String { @@ -445,7 +481,7 @@ struct CpalAudioSink { impl CpalAudioSink { fn new(volume: Arc) -> Self { - let ring = Arc::new(RingBuffer::new(4096)); + let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY)); let ring_for_cb = Arc::clone(&ring); let vol_for_cb = Arc::clone(&volume); let stream = Self::try_build_stream(ring_for_cb, vol_for_cb); @@ -456,10 +492,7 @@ impl CpalAudioSink { } } - fn try_build_stream( - ring: Arc, - volume: Arc, - ) -> Option { + fn try_build_stream(ring: Arc, volume: Arc) -> Option { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; let host = cpal::default_host(); @@ -471,11 +504,7 @@ impl CpalAudioSink { } }; - let config = cpal::StreamConfig { - channels: 1, - sample_rate: cpal::SampleRate(SAMPLE_RATE), - buffer_size: cpal::BufferSize::Default, - }; + let config = cpal_stream_config(); let stream = match device.build_output_stream( &config, @@ -523,6 +552,85 @@ impl nesemu::AudioOutput for CpalAudioSink { } } +#[cfg(test)] +fn audio_ring_latency_ms(capacity: usize, sample_rate: u32) -> f64 { + ((capacity.saturating_sub(1)) as f64 / sample_rate as f64) * 1000.0 +} + +#[cfg(test)] +fn required_audio_ring_capacity(sample_rate: u32, mode: VideoMode) -> usize { + let samples_per_frame = (sample_rate as f64 / mode.frame_hz()).ceil() as usize; + samples_per_frame + AUDIO_CALLBACK_FRAMES as usize + 1 +} + +fn cpal_stream_config() -> cpal::StreamConfig { + cpal::StreamConfig { + channels: 1, + sample_rate: cpal::SampleRate(SAMPLE_RATE), + buffer_size: cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES), + } +} + +struct DesktopFrameScheduler { + next_deadline: Option, +} + +impl DesktopFrameScheduler { + fn new() -> Self { + Self { + next_deadline: None, + } + } + + fn reset_timing(&mut self) { + self.next_deadline = None; + } + + fn delay_until_next_frame(&mut self, now: Instant, _interval: Duration) -> Duration { + match self.next_deadline { + None => { + self.next_deadline = Some(now); + Duration::ZERO + } + Some(deadline) if now < deadline => deadline - now, + Some(_) => Duration::ZERO, + } + } + + fn mark_frame_complete(&mut self, now: Instant, interval: Duration) { + let mut next_deadline = self.next_deadline.unwrap_or(now) + interval; + while next_deadline <= now { + next_deadline += interval; + } + self.next_deadline = Some(next_deadline); + } +} + +struct BufferedVideo { + frame_rgba: Vec, +} + +impl BufferedVideo { + fn new() -> Self { + Self { + frame_rgba: vec![0; FRAME_RGBA_BYTES], + } + } + + fn frame_rgba(&self) -> &[u8] { + &self.frame_rgba + } +} + +impl VideoOutput for BufferedVideo { + fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) { + if width != FRAME_WIDTH || height != FRAME_HEIGHT || frame.len() != FRAME_RGBA_BYTES { + return; + } + self.frame_rgba.copy_from_slice(frame); + } +} + // --------------------------------------------------------------------------- // Application state // --------------------------------------------------------------------------- @@ -531,7 +639,7 @@ struct DesktopApp { host: Option>>, input: InputState, audio: CpalAudioSink, - frame_rgba: Vec, + video: BufferedVideo, state: EmulationState, } @@ -541,7 +649,7 @@ impl DesktopApp { host: None, input: InputState::default(), audio: CpalAudioSink::new(volume), - frame_rgba: vec![0; FRAME_RGBA_BYTES], + video: BufferedVideo::new(), state: EmulationState::Paused, } } @@ -589,23 +697,137 @@ impl DesktopApp { return; }; - let mut null_video = nesemu::NullVideo; - if let Err(err) = host.run_frame_unpaced(&mut self.input, &mut null_video, &mut self.audio) - { - eprintln!("Frame execution error: {err}"); - self.state = EmulationState::Paused; - return; + match host.run_frame_unpaced(&mut self.input, &mut self.video, &mut self.audio) { + Ok(_) => {} + Err(err) => { + eprintln!("Frame execution error: {err}"); + self.state = EmulationState::Paused; + return; + } } - - self.frame_rgba - .copy_from_slice(&host.runtime().frame_rgba()); } fn frame_rgba(&self) -> &[u8] { - &self.frame_rgba + self.video.frame_rgba() + } + + fn frame_interval(&self) -> Duration { + self.host + .as_ref() + .map(|host| host.runtime().video_mode().frame_duration()) + .unwrap_or_else(|| VideoMode::Ntsc.frame_duration()) } fn input_mut(&mut self) -> &mut InputState { &mut self.input } } + +#[cfg(test)] +mod tests { + use super::*; + use nesemu::{VideoOutput, FRAME_HEIGHT, FRAME_WIDTH}; + use std::time::Instant; + + #[test] + fn frame_scheduler_waits_until_frame_deadline() { + let mut scheduler = DesktopFrameScheduler::new(); + let start = Instant::now(); + let interval = Duration::from_micros(16_639); + + assert_eq!( + scheduler.delay_until_next_frame(start, interval), + Duration::ZERO + ); + scheduler.mark_frame_complete(start, interval); + assert!( + scheduler.delay_until_next_frame(start + Duration::from_millis(1), interval) + > Duration::ZERO + ); + assert_eq!( + scheduler.delay_until_next_frame(start + interval, interval), + Duration::ZERO + ); + } + + #[test] + fn buffered_video_captures_presented_frame() { + let mut video = BufferedVideo::new(); + let mut frame = vec![0u8; FRAME_RGBA_BYTES]; + frame[0] = 0x12; + frame[1] = 0x34; + frame[2] = 0x56; + frame[3] = 0x78; + + video.present_rgba(&frame, FRAME_WIDTH, FRAME_HEIGHT); + + assert_eq!(video.frame_rgba(), frame.as_slice()); + } + + #[test] + fn frame_scheduler_reset_restarts_from_immediate_tick() { + let mut scheduler = DesktopFrameScheduler::new(); + let start = Instant::now(); + let interval = Duration::from_micros(16_639); + + assert_eq!( + scheduler.delay_until_next_frame(start, interval), + Duration::ZERO + ); + scheduler.mark_frame_complete(start, interval); + assert!(scheduler.delay_until_next_frame(start, interval) > Duration::ZERO); + + scheduler.reset_timing(); + assert_eq!( + scheduler.delay_until_next_frame(start, interval), + Duration::ZERO + ); + } + + #[test] + fn frame_scheduler_reports_zero_delay_when_late() { + let mut scheduler = DesktopFrameScheduler::new(); + let start = Instant::now(); + let interval = Duration::from_micros(16_639); + + assert_eq!( + scheduler.delay_until_next_frame(start, interval), + Duration::ZERO + ); + scheduler.mark_frame_complete(start, interval); + + assert_eq!( + scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2), interval), + Duration::ZERO + ); + } + + #[test] + fn desktop_audio_ring_budget_stays_below_25ms() { + let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE); + let max_budget_ms = 40.0; + assert!( + latency_ms <= max_budget_ms, + "desktop audio ring latency budget too high: {latency_ms:.2}ms" + ); + } + + #[test] + fn desktop_audio_uses_fixed_low_latency_callback_size() { + let config = cpal_stream_config(); + assert_eq!( + config.buffer_size, + cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES) + ); + } + + #[test] + fn desktop_audio_ring_has_frame_burst_headroom() { + let required = required_audio_ring_capacity(SAMPLE_RATE, VideoMode::Ntsc); + assert!( + AUDIO_RING_CAPACITY >= required, + "audio ring too small for frame burst: capacity={}, required={required}", + AUDIO_RING_CAPACITY, + ); + } +} diff --git a/src/native_core/bus.rs b/src/native_core/bus.rs index d620ec5..ab36f46 100644 --- a/src/native_core/bus.rs +++ b/src/native_core/bus.rs @@ -23,6 +23,7 @@ pub struct NativeBus { odd_frame: bool, in_vblank: bool, frame_complete: bool, + cpu_cycles_since_poll: u32, mmc3_a12_prev_high: bool, mmc3_a12_low_dots: u16, mmc3_last_irq_scanline: u32, @@ -47,6 +48,7 @@ impl NativeBus { odd_frame: false, in_vblank: false, frame_complete: false, + cpu_cycles_since_poll: 0, mmc3_a12_prev_high: false, mmc3_a12_low_dots: 8, mmc3_last_irq_scanline: u32::MAX, @@ -84,6 +86,12 @@ impl NativeBus { pub fn clock_cpu(&mut self, cycles: u8) { self.clock_cpu_cycles(cycles as u32); } + + pub fn take_cpu_cycles_since_poll(&mut self) -> u32 { + let cycles = self.cpu_cycles_since_poll; + self.cpu_cycles_since_poll = 0; + cycles + } } // CpuBus trait implementation (memory map + side effects). diff --git a/src/native_core/bus/tests/apu.rs b/src/native_core/bus/tests/apu.rs index e635af2..9f99f25 100644 --- a/src/native_core/bus/tests/apu.rs +++ b/src/native_core/bus/tests/apu.rs @@ -312,3 +312,23 @@ fn dmc_playback_updates_output_level_from_sample_bits() { assert!(bus.apu.dmc_output_level < initial); } + +#[test] +fn pulse_channel_outputs_become_audible_after_setup() { + let mut bus = NativeBus::new(Box::new(StubMapper)); + bus.write(0x4015, 0x01); // enable pulse1 + bus.write(0x4000, 0b0101_1111); // 25% duty, constant volume=15 + bus.write(0x4002, 0x08); // low timer period, not sweep-muted + bus.write(0x4003, 0x00); // reload length + reset duty sequencer + + let mut saw_non_zero = false; + for _ in 0..64u32 { + bus.clock_cpu(1); + if bus.apu_channel_outputs().pulse1 > 0 { + saw_non_zero = true; + break; + } + } + + assert!(saw_non_zero, "pulse1 never produced audible output"); +} diff --git a/src/native_core/bus/timing.rs b/src/native_core/bus/timing.rs index 94d4aa7..d0a2974 100644 --- a/src/native_core/bus/timing.rs +++ b/src/native_core/bus/timing.rs @@ -7,6 +7,7 @@ use crate::native_core::mapper::Mapper; impl NativeBus { fn clock_one_cpu_cycle(&mut self) { + self.cpu_cycles_since_poll = self.cpu_cycles_since_poll.saturating_add(1); for _ in 0..3 { self.clock_ppu_dot(); } diff --git a/src/runtime/audio.rs b/src/runtime/audio.rs index e9953a4..349ec80 100644 --- a/src/runtime/audio.rs +++ b/src/runtime/audio.rs @@ -6,6 +6,7 @@ pub struct AudioMixer { sample_rate: u32, samples_per_cpu_cycle: f64, sample_accumulator: f64, + last_output_sample: f32, } impl AudioMixer { @@ -15,6 +16,7 @@ impl AudioMixer { sample_rate, samples_per_cpu_cycle: sample_rate as f64 / cpu_hz, sample_accumulator: 0.0, + last_output_sample: 0.0, } } @@ -24,10 +26,11 @@ impl AudioMixer { pub fn reset(&mut self) { self.sample_accumulator = 0.0; + self.last_output_sample = 0.0; } - pub fn push_cycles(&mut self, cpu_cycles: u8, channels: ChannelOutputs, out: &mut Vec) { - self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles); + pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec) { + self.sample_accumulator += self.samples_per_cpu_cycle * cpu_cycles as f64; let samples = self.sample_accumulator.floor() as usize; self.sample_accumulator -= samples as f64; @@ -35,10 +38,23 @@ impl AudioMixer { let tnd_out = 0.00851 * f32::from(channels.triangle) + 0.00494 * f32::from(channels.noise) + 0.00335 * f32::from(channels.dmc); - let mixed = pulse_out + tnd_out; - let sample = mixed * 2.0 - 1.0; + let sample = pulse_out + tnd_out; - out.extend(std::iter::repeat_n(sample, samples)); + if samples == 0 { + return; + } + + let start = self.last_output_sample; + if samples == 1 { + out.push(sample); + } else { + let denom = samples as f32; + for idx in 0..samples { + let t = (idx + 1) as f32 / denom; + out.push(start + (sample - start) * t); + } + } + self.last_output_sample = sample; } } @@ -47,14 +63,14 @@ mod tests { use super::*; #[test] - fn mixer_silent_channels_produce_negative_one() { + fn mixer_silent_channels_produce_zero() { let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc); let channels = ChannelOutputs::default(); let mut out = Vec::new(); mixer.push_cycles(50, channels, &mut out); assert!(!out.is_empty()); for &s in &out { - assert!((s - (-1.0)).abs() < 1e-6, "expected -1.0, got {s}"); + assert!(s.abs() < 1e-6, "expected 0.0, got {s}"); } } @@ -75,4 +91,34 @@ mod tests { assert!(s > 0.0, "expected positive sample, got {s}"); } } + + #[test] + fn mixer_smooths_transition_between_batches() { + let mut mixer = AudioMixer::new(44_100, VideoMode::Ntsc); + let mut out = Vec::new(); + + mixer.push_cycles(200, ChannelOutputs::default(), &mut out); + let before = out.len(); + + mixer.push_cycles( + 200, + ChannelOutputs { + pulse1: 15, + pulse2: 15, + triangle: 15, + noise: 15, + dmc: 127, + }, + &mut out, + ); + + let transition = &out[before..]; + assert!(transition.len() > 1); + assert!(transition[0] < *transition.last().expect("transition sample")); + assert!( + transition[0] > 0.0, + "expected smoothed ramp start, got {}", + transition[0] + ); + } } diff --git a/src/runtime/core.rs b/src/runtime/core.rs index 468e3c8..90cbbb4 100644 --- a/src/runtime/core.rs +++ b/src/runtime/core.rs @@ -1,8 +1,8 @@ use crate::runtime::state::{load_runtime_state, save_runtime_state}; use crate::runtime::{ - AudioMixer, FRAME_RGBA_BYTES, FramePacer, JoypadButtons, RuntimeError, VideoMode, + AudioMixer, FramePacer, JoypadButtons, RuntimeError, VideoMode, FRAME_RGBA_BYTES, }; -use crate::{Cpu6502, InesRom, NativeBus, create_mapper, parse_rom}; +use crate::{create_mapper, parse_rom, Cpu6502, InesRom, NativeBus}; pub struct NesRuntime { cpu: Cpu6502, @@ -79,11 +79,11 @@ impl NesRuntime { self.bus.set_joypad_buttons(buttons); } - pub fn step_instruction(&mut self) -> Result { + pub fn step_instruction(&mut self) -> Result { self.bus.set_joypad_buttons(self.buttons); - let cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?; - self.bus.clock_cpu(cycles); - Ok(cycles) + let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?; + self.bus.clock_cpu(cpu_cycles); + Ok(self.bus.take_cpu_cycles_since_poll()) } pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> { diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 050274b..7b7a8e0 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -1,12 +1,17 @@ use crate::runtime::{ - AudioOutput, ClientRuntime, EmulationState, FRAME_RGBA_BYTES, HostConfig, InputProvider, - JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, NesRuntime, NoopClock, - RuntimeHostLoop, VideoMode, VideoOutput, button_pressed, set_button_pressed, + button_pressed, set_button_pressed, AudioOutput, ClientRuntime, EmulationState, HostConfig, + InputProvider, JoypadButton, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo, + RuntimeHostLoop, VideoMode, VideoOutput, FRAME_RGBA_BYTES, JOYPAD_BUTTONS_COUNT, + JOYPAD_BUTTON_ORDER, }; use std::cell::Cell; use std::rc::Rc; fn nrom_test_rom() -> Vec { + nrom_test_rom_with_program(&[0xEA, 0x4C, 0x00, 0x80]) +} + +fn nrom_test_rom_with_program(program: &[u8]) -> Vec { let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024]; rom[0..4].copy_from_slice(b"NES\x1A"); rom[4] = 1; // 16 KiB PRG @@ -17,11 +22,7 @@ fn nrom_test_rom() -> Vec { rom[reset_vec] = 0x00; rom[reset_vec + 1] = 0x80; - // 0x8000: NOP; JMP $8000 - rom[prg_offset] = 0xEA; - rom[prg_offset + 1] = 0x4C; - rom[prg_offset + 2] = 0x00; - rom[prg_offset + 3] = 0x80; + rom[prg_offset..prg_offset + program.len()].copy_from_slice(program); rom } @@ -83,6 +84,30 @@ fn audio_mixer_generates_samples() { assert_eq!(mixer.sample_rate(), 48_000); } +#[test] +fn audio_mixer_accounts_for_oam_dma_stall_cycles() { + let rom = nrom_test_rom_with_program(&[ + 0xA9, 0x00, // LDA #$00 + 0x8D, 0x14, 0x40, // STA $4014 + 0x4C, 0x00, 0x80, // JMP $8000 + ]); + let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init"); + let mode = runtime.video_mode(); + let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false)); + + let total_samples = host + .run_frames_unpaced(120, &mut NullInput, &mut NullVideo, &mut NullAudio) + .expect("run frames"); + + let expected = ((host.runtime().frame_number() as f64) * 48_000.0 / mode.frame_hz()).round(); + let drift_pct = ((total_samples as f64 - expected).abs() / expected) * 100.0; + + assert!( + drift_pct <= 2.5, + "audio drift too high with OAM DMA: {drift_pct:.3}% (samples={total_samples}, expected={expected:.0})" + ); +} + struct FixedInput; impl InputProvider for FixedInput { diff --git a/tests/public_api.rs b/tests/public_api.rs index b59b2db..f3c6f52 100644 --- a/tests/public_api.rs +++ b/tests/public_api.rs @@ -1,7 +1,7 @@ use nesemu::prelude::*; use nesemu::{ - AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NullAudio, NullInput, NullVideo, - RuntimeError, VideoOutput, + AudioOutput, HostConfig, InputProvider, NullAudio, NullInput, NullVideo, RuntimeError, + VideoOutput, JOYPAD_BUTTONS_COUNT, }; #[derive(Clone, Copy)] @@ -212,7 +212,7 @@ fn public_api_regression_hashes_for_reference_rom() { .expect("run frames"); let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64; - let expected_audio_hash = 0xa075_8dd6_adea_e775_u64; + let expected_audio_hash = 0x19f5_be12_66f3_37c5_u64; assert_eq!( video.last_hash, expected_frame_hash,