This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/target
|
||||
/.worktrees/
|
||||
/.worktrees
|
||||
/docs/superpowers
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<RefCell<Option<PathBuf>>> =
|
||||
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<PathBuf>) {
|
||||
|
||||
// --- State ---
|
||||
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
|
||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> =
|
||||
Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> = 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<PathBuf>) {
|
||||
// --- 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<PathBuf>) {
|
||||
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<PathBuf>) {
|
||||
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<PathBuf>) {
|
||||
|
||||
{
|
||||
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<PathBuf>) {
|
||||
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<PathBuf>) {
|
||||
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<PathBuf>) {
|
||||
// --- 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<PathBuf>) {
|
||||
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<PathBuf>) {
|
||||
|
||||
// --- 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<RefCell<DesktopApp>>,
|
||||
drawing_area: gtk::DrawingArea,
|
||||
frame_for_draw: Rc<RefCell<Vec<u8>>>,
|
||||
scheduler: Rc<RefCell<DesktopFrameScheduler>>,
|
||||
) {
|
||||
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<AtomicU32>) -> 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<RingBuffer>,
|
||||
volume: Arc<AtomicU32>,
|
||||
) -> Option<cpal::Stream> {
|
||||
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
|
||||
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<Instant>,
|
||||
}
|
||||
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
||||
input: InputState,
|
||||
audio: CpalAudioSink,
|
||||
frame_rgba: Vec<u8>,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<f32>) {
|
||||
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<f32>) {
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u8, RuntimeError> {
|
||||
pub fn step_instruction(&mut self) -> Result<u32, RuntimeError> {
|
||||
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> {
|
||||
|
||||
@@ -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<u8> {
|
||||
nrom_test_rom_with_program(&[0xEA, 0x4C, 0x00, 0x80])
|
||||
}
|
||||
|
||||
fn nrom_test_rom_with_program(program: &[u8]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user