Some checks failed
CI / rust (push) Has been cancelled
Full NES emulation: CPU, PPU, APU, 47 mappers, iNES/NES 2.0 parsing. GTK4 desktop client with HeaderBar, pixel-perfect Cairo rendering, drag-and-drop ROM loading, and keyboard shortcuts. 187 tests covering core emulation, mappers, and runtime.
228 lines
6.3 KiB
Rust
228 lines
6.3 KiB
Rust
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<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 = 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
|
|
);
|
|
}
|