Files
nesemu/tests/public_api.rs
se.cherkasov d2be893cfe
Some checks failed
CI / rust (push) Has been cancelled
fix: stabilize desktop audio playback
2026-03-13 19:20:33 +03:00

228 lines
6.3 KiB
Rust

use nesemu::prelude::*;
use nesemu::{
AudioOutput, HostConfig, InputProvider, NullAudio, NullInput, NullVideo, RuntimeError,
VideoOutput, JOYPAD_BUTTONS_COUNT,
};
#[derive(Clone, Copy)]
struct Fnv1a64 {
state: u64,
}
impl Fnv1a64 {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
const fn new() -> Self {
Self {
state: Self::OFFSET_BASIS,
}
}
fn update(&mut self, bytes: &[u8]) {
for &b in bytes {
self.state ^= u64::from(b);
self.state = self.state.wrapping_mul(Self::PRIME);
}
}
const fn finish(self) -> u64 {
self.state
}
}
fn nrom_test_rom() -> Vec<u8> {
let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024];
rom[0..4].copy_from_slice(b"NES\x1A");
rom[4] = 1;
rom[5] = 1;
let prg_offset = 16;
let reset_vec = prg_offset + 0x3FFC;
rom[reset_vec] = 0x00;
rom[reset_vec + 1] = 0x80;
rom[prg_offset] = 0xEA;
rom[prg_offset + 1] = 0x4C;
rom[prg_offset + 2] = 0x00;
rom[prg_offset + 3] = 0x80;
rom
}
struct FixedInput;
impl InputProvider for FixedInput {
fn poll_buttons(&mut self) -> [bool; JOYPAD_BUTTONS_COUNT] {
[true, false, false, false, false, false, false, false]
}
}
#[derive(Default)]
struct CountingVideo {
frames: usize,
last_hash: u64,
}
impl VideoOutput for CountingVideo {
fn present_rgba(&mut self, frame: &[u8], _width: usize, _height: usize) {
self.frames = self.frames.saturating_add(1);
let mut hasher = Fnv1a64::new();
hasher.update(frame);
self.last_hash = hasher.finish();
}
}
struct CountingAudio {
samples: usize,
hash: u64,
}
impl Default for CountingAudio {
fn default() -> Self {
Self {
samples: 0,
hash: Fnv1a64::new().finish(),
}
}
}
impl AudioOutput for CountingAudio {
fn push_samples(&mut self, samples: &[f32]) {
self.samples = self.samples.saturating_add(samples.len());
let mut hasher = Fnv1a64 { state: self.hash };
for sample in samples {
hasher.update(&sample.to_le_bytes());
}
self.hash = hasher.finish();
}
}
#[test]
fn public_api_load_run_input_frame_flow() {
let rom = nrom_test_rom();
let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false));
let mut input = FixedInput;
let mut video = CountingVideo::default();
let mut audio = CountingAudio::default();
let first = host
.run_frame_unpaced(&mut input, &mut video, &mut audio)
.expect("frame 1");
let second = host
.run_frame_unpaced(&mut input, &mut video, &mut audio)
.expect("frame 2");
assert_eq!(first.frame_number, 1);
assert_eq!(second.frame_number, 2);
assert_eq!(video.frames, 2);
assert!(audio.samples > 0);
}
#[test]
fn public_api_invalid_state_is_reported() {
let rom = nrom_test_rom();
let mut runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
runtime.run_until_frame_complete().expect("frame");
let mut state = runtime.save_state();
state[0] ^= 0xFF;
let err = runtime
.load_state(&state)
.expect_err("must reject bad state");
assert!(matches!(err, RuntimeError::InvalidState(_)));
}
#[test]
fn public_api_headless_client_loop_smoke_1000_frames() {
let rom = nrom_test_rom();
let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false));
let total_samples = host
.run_frames_unpaced(1_000, &mut NullInput, &mut NullVideo, &mut NullAudio)
.expect("run frames");
assert_eq!(host.runtime().frame_number(), 1_000);
assert!(total_samples > 0);
}
#[test]
fn public_api_client_pause_resume_contract() {
let rom = nrom_test_rom();
let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mut client = ClientRuntime::with_config(runtime, HostConfig::new(48_000, false));
let mut input = FixedInput;
let mut video = CountingVideo::default();
let mut audio = CountingAudio::default();
client.pause();
let skipped = client
.tick(&mut input, &mut video, &mut audio)
.expect("tick");
assert!(skipped.is_none());
let stepped = client
.step_frame(&mut input, &mut video, &mut audio)
.expect("step");
assert_eq!(stepped.frame_number, 1);
client.resume();
let ticked = client
.tick(&mut input, &mut video, &mut audio)
.expect("tick")
.expect("running tick");
assert_eq!(ticked.frame_number, 2);
}
#[test]
fn public_api_audio_timing_within_expected_drift() {
let rom = nrom_test_rom();
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(1_200, &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: {drift_pct:.3}% (samples={total_samples}, expected={expected:.0})"
);
}
#[test]
fn public_api_regression_hashes_for_reference_rom() {
let rom = nrom_test_rom();
let runtime = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mut host = RuntimeHostLoop::with_config(runtime, HostConfig::new(48_000, false));
let mut input = FixedInput;
let mut video = CountingVideo::default();
let mut audio = CountingAudio::default();
host.run_frames_unpaced(120, &mut input, &mut video, &mut audio)
.expect("run frames");
let expected_frame_hash = 0x42d1_20e3_54e0_a325_u64;
let expected_audio_hash = 0x19f5_be12_66f3_37c5_u64;
assert_eq!(
video.last_hash, expected_frame_hash,
"update expected frame hash to 0x{:016x}",
video.last_hash
);
assert_eq!(
audio.hash, expected_audio_hash,
"update expected audio hash to 0x{:016x}",
audio.hash
);
}