Initial commit: NES emulator with GTK4 desktop frontend
Some checks failed
CI / rust (push) Has been cancelled
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.
This commit is contained in:
227
tests/public_api.rs
Normal file
227
tests/public_api.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user