use nesemu::prelude::*; use nesemu::{ AudioOutput, HostConfig, InputProvider, JOYPAD_BUTTONS_COUNT, NullAudio, NullInput, NullVideo, RuntimeError, VideoOutput, }; #[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 { 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 = 0xa075_8dd6_adea_e775_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 ); }