Initial commit: NES emulator with GTK4 desktop frontend
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:
2026-03-13 11:48:45 +03:00
commit bdf23de8db
143 changed files with 18501 additions and 0 deletions

118
src/runtime/adapters.rs Normal file
View File

@@ -0,0 +1,118 @@
#[cfg(feature = "adapter-api")]
use nesemu_adapter_api::{
AudioSink, BUTTONS_COUNT, ButtonState, InputSource, TimeSource, VideoSink,
};
#[cfg(feature = "adapter-api")]
use crate::runtime::{FrameClock, JoypadButtons};
#[cfg(feature = "adapter-api")]
fn to_joypad_buttons(buttons: ButtonState) -> JoypadButtons {
let mut out = [false; crate::runtime::JOYPAD_BUTTONS_COUNT];
out.copy_from_slice(&buttons[..BUTTONS_COUNT]);
out
}
#[cfg(feature = "adapter-api")]
pub struct InputAdapter<T> {
inner: T,
}
#[cfg(feature = "adapter-api")]
impl<T> InputAdapter<T> {
pub const fn new(inner: T) -> Self {
Self { inner }
}
pub fn into_inner(self) -> T {
self.inner
}
}
#[cfg(feature = "adapter-api")]
impl<T> crate::runtime::InputProvider for InputAdapter<T>
where
T: InputSource,
{
fn poll_buttons(&mut self) -> JoypadButtons {
to_joypad_buttons(self.inner.poll_buttons())
}
}
#[cfg(feature = "adapter-api")]
pub struct VideoAdapter<T> {
inner: T,
}
#[cfg(feature = "adapter-api")]
impl<T> VideoAdapter<T> {
pub const fn new(inner: T) -> Self {
Self { inner }
}
pub fn into_inner(self) -> T {
self.inner
}
}
#[cfg(feature = "adapter-api")]
impl<T> crate::runtime::VideoOutput for VideoAdapter<T>
where
T: VideoSink,
{
fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) {
self.inner.present_rgba(frame, width as u32, height as u32);
}
}
#[cfg(feature = "adapter-api")]
pub struct AudioAdapter<T> {
inner: T,
}
#[cfg(feature = "adapter-api")]
impl<T> AudioAdapter<T> {
pub const fn new(inner: T) -> Self {
Self { inner }
}
pub fn into_inner(self) -> T {
self.inner
}
}
#[cfg(feature = "adapter-api")]
impl<T> crate::runtime::AudioOutput for AudioAdapter<T>
where
T: AudioSink,
{
fn push_samples(&mut self, samples: &[f32]) {
self.inner.push_samples(samples);
}
}
#[cfg(feature = "adapter-api")]
pub struct ClockAdapter<T> {
inner: T,
}
#[cfg(feature = "adapter-api")]
impl<T> ClockAdapter<T> {
pub const fn new(inner: T) -> Self {
Self { inner }
}
pub fn into_inner(self) -> T {
self.inner
}
}
#[cfg(feature = "adapter-api")]
impl<T> FrameClock for ClockAdapter<T>
where
T: TimeSource,
{
fn wait_next_frame(&mut self) {
self.inner.wait_next_frame();
}
}

39
src/runtime/audio.rs Normal file
View File

@@ -0,0 +1,39 @@
use crate::runtime::VideoMode;
#[derive(Debug)]
pub struct AudioMixer {
sample_rate: u32,
samples_per_cpu_cycle: f64,
sample_accumulator: f64,
}
impl AudioMixer {
pub fn new(sample_rate: u32, mode: VideoMode) -> Self {
let cpu_hz = mode.cpu_hz();
Self {
sample_rate,
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
sample_accumulator: 0.0,
}
}
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
pub fn reset(&mut self) {
self.sample_accumulator = 0.0;
}
pub fn push_cycles(&mut self, cpu_cycles: u8, apu_regs: &[u8; 0x20], out: &mut Vec<f32>) {
self.sample_accumulator += self.samples_per_cpu_cycle * f64::from(cpu_cycles);
let samples = self.sample_accumulator.floor() as usize;
self.sample_accumulator -= samples as f64;
// Current core does not expose a final mixed PCM stream yet.
// Use DMC output level as a stable interim signal in [-1.0, 1.0].
let dmc = apu_regs[0x11] & 0x7F;
let sample = (f32::from(dmc) / 63.5) - 1.0;
out.extend(std::iter::repeat_n(sample, samples));
}
}

6
src/runtime/constants.rs Normal file
View File

@@ -0,0 +1,6 @@
pub const FRAME_WIDTH: usize = 256;
pub const FRAME_HEIGHT: usize = 240;
pub const FRAME_RGBA_BYTES: usize = FRAME_WIDTH * FRAME_HEIGHT * 4;
pub const SAVE_STATE_VERSION: u32 = 1;
pub(crate) const SAVE_STATE_MAGIC: &[u8; 8] = b"NESRT001";

150
src/runtime/core.rs Normal file
View File

@@ -0,0 +1,150 @@
use crate::runtime::state::{load_runtime_state, save_runtime_state};
use crate::runtime::{
AudioMixer, FRAME_RGBA_BYTES, FramePacer, JoypadButtons, RuntimeError, VideoMode,
};
use crate::{Cpu6502, InesRom, NativeBus, create_mapper, parse_rom};
pub struct NesRuntime {
cpu: Cpu6502,
bus: NativeBus,
video_mode: VideoMode,
frame_number: u64,
buttons: JoypadButtons,
}
impl NesRuntime {
pub fn from_rom_bytes(bytes: &[u8]) -> Result<Self, RuntimeError> {
let rom = parse_rom(bytes).map_err(RuntimeError::RomParse)?;
Self::from_rom(rom)
}
pub fn from_rom(rom: InesRom) -> Result<Self, RuntimeError> {
let video_mode = VideoMode::from_ines_timing_mode(rom.header.cpu_ppu_timing_mode);
let mapper = create_mapper(rom).map_err(RuntimeError::MapperInit)?;
let mut bus = NativeBus::new(mapper);
let mut cpu = Cpu6502::default();
cpu.reset(&mut bus);
Ok(Self {
cpu,
bus,
video_mode,
frame_number: 0,
buttons: [false; crate::runtime::JOYPAD_BUTTONS_COUNT],
})
}
pub fn reset(&mut self) {
self.cpu.reset(&mut self.bus);
self.frame_number = 0;
}
pub fn cpu(&self) -> &Cpu6502 {
&self.cpu
}
pub fn cpu_mut(&mut self) -> &mut Cpu6502 {
&mut self.cpu
}
pub fn bus(&self) -> &NativeBus {
&self.bus
}
pub fn bus_mut(&mut self) -> &mut NativeBus {
&mut self.bus
}
pub fn frame_number(&self) -> u64 {
self.frame_number
}
pub fn video_mode(&self) -> VideoMode {
self.video_mode
}
pub fn default_frame_pacer(&self) -> FramePacer {
FramePacer::new(self.video_mode)
}
pub fn default_audio_mixer(&self, sample_rate: u32) -> AudioMixer {
AudioMixer::new(sample_rate, self.video_mode)
}
pub fn buttons(&self) -> JoypadButtons {
self.buttons
}
pub fn set_buttons(&mut self, buttons: JoypadButtons) {
self.buttons = buttons;
self.bus.set_joypad_buttons(buttons);
}
pub fn step_instruction(&mut self) -> Result<u8, 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)
}
pub fn run_until_frame_complete(&mut self) -> Result<(), RuntimeError> {
self.bus.begin_frame();
while !self.bus.take_frame_complete() {
self.step_instruction()?;
}
self.frame_number = self.frame_number.saturating_add(1);
Ok(())
}
pub fn run_frame_paced(&mut self, pacer: &mut FramePacer) -> Result<(), RuntimeError> {
self.run_until_frame_complete()?;
pacer.wait_next_frame();
Ok(())
}
pub fn run_until_frame_complete_with_audio(
&mut self,
mixer: &mut AudioMixer,
out_samples: &mut Vec<f32>,
) -> Result<(), RuntimeError> {
self.bus.begin_frame();
while !self.bus.take_frame_complete() {
let cycles = self.step_instruction()?;
mixer.push_cycles(cycles, self.bus.apu_registers(), out_samples);
}
self.frame_number = self.frame_number.saturating_add(1);
Ok(())
}
pub fn render_frame_rgba(&self, out_rgba: &mut [u8]) -> Result<(), RuntimeError> {
if out_rgba.len() < FRAME_RGBA_BYTES {
return Err(RuntimeError::BufferTooSmall {
expected: FRAME_RGBA_BYTES,
got: out_rgba.len(),
});
}
self.bus
.render_frame(out_rgba, self.frame_number as u32, self.buttons);
Ok(())
}
pub fn frame_rgba(&self) -> Vec<u8> {
let mut out = vec![0; FRAME_RGBA_BYTES];
self.bus
.render_frame(&mut out, self.frame_number as u32, self.buttons);
out
}
pub fn save_state(&self) -> Vec<u8> {
save_runtime_state(self.frame_number, self.buttons, &self.cpu, &self.bus)
}
pub fn load_state(&mut self, data: &[u8]) -> Result<(), RuntimeError> {
load_runtime_state(
data,
&mut self.frame_number,
&mut self.buttons,
&mut self.cpu,
&mut self.bus,
)
}
}

28
src/runtime/error.rs Normal file
View File

@@ -0,0 +1,28 @@
use crate::CpuError;
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum RuntimeError {
RomParse(String),
MapperInit(String),
Cpu(CpuError),
BufferTooSmall { expected: usize, got: usize },
InvalidState(String),
}
impl fmt::Display for RuntimeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RomParse(e) => write!(f, "ROM parse error: {e}"),
Self::MapperInit(e) => write!(f, "mapper init error: {e}"),
Self::Cpu(e) => write!(f, "CPU error: {e:?}"),
Self::BufferTooSmall { expected, got } => {
write!(f, "buffer too small: expected {expected} bytes, got {got}")
}
Self::InvalidState(e) => write!(f, "invalid runtime state: {e}"),
}
}
}
impl std::error::Error for RuntimeError {}

51
src/runtime/host/clock.rs Normal file
View File

@@ -0,0 +1,51 @@
use crate::runtime::{FramePacer, NesRuntime};
pub trait FrameClock {
fn wait_next_frame(&mut self);
}
impl<T> FrameClock for Box<T>
where
T: FrameClock + ?Sized,
{
fn wait_next_frame(&mut self) {
(**self).wait_next_frame();
}
}
pub struct PacingClock {
pacer: FramePacer,
}
impl PacingClock {
pub fn from_runtime(runtime: &NesRuntime) -> Self {
Self {
pacer: runtime.default_frame_pacer(),
}
}
pub fn from_pacer(pacer: FramePacer) -> Self {
Self { pacer }
}
pub fn pacer(&self) -> &FramePacer {
&self.pacer
}
pub fn pacer_mut(&mut self) -> &mut FramePacer {
&mut self.pacer
}
}
impl FrameClock for PacingClock {
fn wait_next_frame(&mut self) {
self.pacer.wait_next_frame();
}
}
#[derive(Default)]
pub struct NoopClock;
impl FrameClock for NoopClock {
fn wait_next_frame(&mut self) {}
}

View File

@@ -0,0 +1,24 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HostConfig {
pub sample_rate: u32,
pub pacing: bool,
}
impl HostConfig {
pub const fn new(sample_rate: u32, pacing: bool) -> Self {
Self {
sample_rate,
pacing,
}
}
}
impl Default for HostConfig {
fn default() -> Self {
Self {
sample_rate: 48_000,
pacing: true,
}
}
}

View File

@@ -0,0 +1,63 @@
use crate::runtime::{
AudioMixer, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, NesRuntime, RuntimeError,
};
use super::io::{AudioOutput, InputProvider, VideoOutput};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct FrameExecution {
pub frame_number: u64,
pub audio_samples: usize,
}
pub struct FrameExecutor {
mixer: AudioMixer,
frame_buffer: Vec<u8>,
audio_buffer: Vec<f32>,
}
impl FrameExecutor {
pub fn from_runtime(runtime: &NesRuntime, sample_rate: u32) -> Self {
Self {
mixer: runtime.default_audio_mixer(sample_rate),
frame_buffer: vec![0; FRAME_RGBA_BYTES],
audio_buffer: Vec::new(),
}
}
pub fn mixer(&self) -> &AudioMixer {
&self.mixer
}
pub fn mixer_mut(&mut self) -> &mut AudioMixer {
&mut self.mixer
}
pub fn execute_frame<I, V, A>(
&mut self,
runtime: &mut NesRuntime,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<FrameExecution, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
runtime.set_buttons(input.poll_buttons());
self.audio_buffer.clear();
runtime.run_until_frame_complete_with_audio(&mut self.mixer, &mut self.audio_buffer)?;
runtime.render_frame_rgba(&mut self.frame_buffer)?;
audio.push_samples(&self.audio_buffer);
video.present_rgba(&self.frame_buffer, FRAME_WIDTH, FRAME_HEIGHT);
Ok(FrameExecution {
frame_number: runtime.frame_number(),
audio_samples: self.audio_buffer.len(),
})
}
}

36
src/runtime/host/io.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::runtime::{JOYPAD_BUTTONS_COUNT, JoypadButtons};
pub trait InputProvider {
fn poll_buttons(&mut self) -> JoypadButtons;
}
pub trait VideoOutput {
fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize);
}
pub trait AudioOutput {
fn push_samples(&mut self, samples: &[f32]);
}
#[derive(Default)]
pub struct NullInput;
impl InputProvider for NullInput {
fn poll_buttons(&mut self) -> JoypadButtons {
[false; JOYPAD_BUTTONS_COUNT]
}
}
#[derive(Default)]
pub struct NullVideo;
impl VideoOutput for NullVideo {
fn present_rgba(&mut self, _frame: &[u8], _width: usize, _height: usize) {}
}
#[derive(Default)]
pub struct NullAudio;
impl AudioOutput for NullAudio {
fn push_samples(&mut self, _samples: &[f32]) {}
}

View File

@@ -0,0 +1,154 @@
use crate::runtime::{NesRuntime, RuntimeError};
use super::clock::{FrameClock, NoopClock, PacingClock};
use super::config::HostConfig;
use super::executor::{FrameExecution, FrameExecutor};
use super::io::{AudioOutput, InputProvider, VideoOutput};
pub struct RuntimeHostLoop<C = PacingClock> {
runtime: NesRuntime,
executor: FrameExecutor,
clock: C,
}
impl RuntimeHostLoop<PacingClock> {
pub fn new(runtime: NesRuntime, sample_rate: u32) -> Self {
let executor = FrameExecutor::from_runtime(&runtime, sample_rate);
let clock = PacingClock::from_runtime(&runtime);
Self {
runtime,
executor,
clock,
}
}
pub fn with_config(
runtime: NesRuntime,
config: HostConfig,
) -> RuntimeHostLoop<Box<dyn FrameClock>> {
let executor = FrameExecutor::from_runtime(&runtime, config.sample_rate);
let clock: Box<dyn FrameClock> = if config.pacing {
Box::new(PacingClock::from_runtime(&runtime))
} else {
Box::new(NoopClock)
};
RuntimeHostLoop {
runtime,
executor,
clock,
}
}
}
impl<C> RuntimeHostLoop<C>
where
C: FrameClock,
{
pub fn with_clock(runtime: NesRuntime, sample_rate: u32, clock: C) -> Self {
let executor = FrameExecutor::from_runtime(&runtime, sample_rate);
Self {
runtime,
executor,
clock,
}
}
pub fn runtime(&self) -> &NesRuntime {
&self.runtime
}
pub fn runtime_mut(&mut self) -> &mut NesRuntime {
&mut self.runtime
}
pub fn into_runtime(self) -> NesRuntime {
self.runtime
}
pub fn executor(&self) -> &FrameExecutor {
&self.executor
}
pub fn executor_mut(&mut self) -> &mut FrameExecutor {
&mut self.executor
}
pub fn clock(&self) -> &C {
&self.clock
}
pub fn clock_mut(&mut self) -> &mut C {
&mut self.clock
}
pub fn run_frame<I, V, A>(
&mut self,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<FrameExecution, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
let stats = self.run_frame_unpaced(input, video, audio)?;
self.clock.wait_next_frame();
Ok(stats)
}
pub fn run_frame_unpaced<I, V, A>(
&mut self,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<FrameExecution, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
self.executor
.execute_frame(&mut self.runtime, input, video, audio)
}
pub fn run_frames<I, V, A>(
&mut self,
frames: usize,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<usize, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
let mut total_samples = 0usize;
for _ in 0..frames {
total_samples =
total_samples.saturating_add(self.run_frame(input, video, audio)?.audio_samples);
}
Ok(total_samples)
}
pub fn run_frames_unpaced<I, V, A>(
&mut self,
frames: usize,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<usize, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
let mut total_samples = 0usize;
for _ in 0..frames {
total_samples = total_samples
.saturating_add(self.run_frame_unpaced(input, video, audio)?.audio_samples);
}
Ok(total_samples)
}
}

13
src/runtime/host/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
mod clock;
mod config;
mod executor;
mod io;
mod loop_runner;
mod session;
pub use clock::{FrameClock, NoopClock, PacingClock};
pub use config::HostConfig;
pub use executor::{FrameExecution, FrameExecutor};
pub use io::{AudioOutput, InputProvider, NullAudio, NullInput, NullVideo, VideoOutput};
pub use loop_runner::RuntimeHostLoop;
pub use session::{ClientRuntime, EmulationState};

110
src/runtime/host/session.rs Normal file
View File

@@ -0,0 +1,110 @@
use crate::runtime::RuntimeError;
use super::clock::{FrameClock, PacingClock};
use super::config::HostConfig;
use super::executor::FrameExecution;
use super::io::{AudioOutput, InputProvider, VideoOutput};
use super::loop_runner::RuntimeHostLoop;
use crate::runtime::NesRuntime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EmulationState {
Running,
Paused,
}
pub struct ClientRuntime<C = PacingClock> {
host: RuntimeHostLoop<C>,
state: EmulationState,
}
impl ClientRuntime<PacingClock> {
pub fn new(runtime: NesRuntime, sample_rate: u32) -> Self {
Self {
host: RuntimeHostLoop::new(runtime, sample_rate),
state: EmulationState::Running,
}
}
pub fn with_config(
runtime: NesRuntime,
config: HostConfig,
) -> ClientRuntime<Box<dyn FrameClock>> {
ClientRuntime {
host: RuntimeHostLoop::with_config(runtime, config),
state: EmulationState::Running,
}
}
}
impl<C> ClientRuntime<C>
where
C: FrameClock,
{
pub fn with_host_loop(host: RuntimeHostLoop<C>) -> Self {
Self {
host,
state: EmulationState::Running,
}
}
pub fn state(&self) -> EmulationState {
self.state
}
pub fn pause(&mut self) {
self.state = EmulationState::Paused;
}
pub fn resume(&mut self) {
self.state = EmulationState::Running;
}
pub fn set_state(&mut self, state: EmulationState) {
self.state = state;
}
pub fn host(&self) -> &RuntimeHostLoop<C> {
&self.host
}
pub fn host_mut(&mut self) -> &mut RuntimeHostLoop<C> {
&mut self.host
}
pub fn into_host_loop(self) -> RuntimeHostLoop<C> {
self.host
}
pub fn tick<I, V, A>(
&mut self,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<Option<FrameExecution>, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
if self.state == EmulationState::Paused {
return Ok(None);
}
self.host.run_frame(input, video, audio).map(Some)
}
pub fn step_frame<I, V, A>(
&mut self,
input: &mut I,
video: &mut V,
audio: &mut A,
) -> Result<FrameExecution, RuntimeError>
where
I: InputProvider,
V: VideoOutput,
A: AudioOutput,
{
self.host.run_frame_unpaced(input, video, audio)
}
}

43
src/runtime/mod.rs Normal file
View File

@@ -0,0 +1,43 @@
#[cfg(feature = "adapter-api")]
mod adapters;
mod audio;
mod constants;
mod core;
mod error;
mod host;
mod state;
mod timing;
mod types;
#[cfg(feature = "adapter-api")]
pub use adapters::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter};
pub use audio::AudioMixer;
pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERSION};
pub use core::NesRuntime;
pub use error::RuntimeError;
pub use host::{
AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, FrameExecutor,
HostConfig, InputProvider, NoopClock, NullAudio, NullInput, NullVideo, PacingClock,
RuntimeHostLoop, VideoOutput,
};
pub use timing::{FramePacer, VideoMode};
pub use types::{
JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons, button_pressed,
set_button_pressed,
};
pub mod prelude {
#[cfg(feature = "adapter-api")]
pub use crate::runtime::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter};
pub use crate::runtime::{
AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, FrameExecutor,
HostConfig, InputProvider, JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton,
JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo, PacingClock,
RuntimeError, RuntimeHostLoop, VideoOutput, button_pressed, set_button_pressed,
};
}
pub(crate) use constants::SAVE_STATE_MAGIC;
#[cfg(test)]
mod tests;

151
src/runtime/state.rs Normal file
View File

@@ -0,0 +1,151 @@
use crate::runtime::{JoypadButtons, RuntimeError, SAVE_STATE_MAGIC, SAVE_STATE_VERSION};
use crate::{Cpu6502, NativeBus};
const CPU_STATE_BYTES: usize = 12;
pub(crate) fn save_runtime_state(
frame_number: u64,
buttons: JoypadButtons,
cpu: &Cpu6502,
bus: &NativeBus,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(SAVE_STATE_MAGIC);
out.extend_from_slice(&SAVE_STATE_VERSION.to_le_bytes());
out.extend_from_slice(&frame_number.to_le_bytes());
out.push(buttons_to_bits(buttons));
out.extend_from_slice(&cpu_to_bytes(cpu));
let mut bus_state = Vec::new();
bus.save_state(&mut bus_state);
out.extend_from_slice(&(bus_state.len() as u32).to_le_bytes());
out.extend_from_slice(&bus_state);
out
}
pub(crate) fn load_runtime_state(
data: &[u8],
frame_number: &mut u64,
buttons: &mut JoypadButtons,
cpu: &mut Cpu6502,
bus: &mut NativeBus,
) -> Result<(), RuntimeError> {
let mut cursor = 0usize;
let magic = take_exact(data, &mut cursor, SAVE_STATE_MAGIC.len())?;
if magic != SAVE_STATE_MAGIC {
return Err(RuntimeError::InvalidState(
"unexpected save-state magic".to_string(),
));
}
let version = take_u32(data, &mut cursor)?;
if version != SAVE_STATE_VERSION {
return Err(RuntimeError::InvalidState(format!(
"unsupported save-state version: {version}"
)));
}
*frame_number = take_u64(data, &mut cursor)?;
*buttons = bits_to_buttons(take_u8(data, &mut cursor)?);
*cpu = cpu_from_bytes(take_exact(data, &mut cursor, CPU_STATE_BYTES)?)?;
let bus_len = take_u32(data, &mut cursor)? as usize;
let bus_state = take_exact(data, &mut cursor, bus_len)?;
bus.load_state(bus_state)
.map_err(RuntimeError::InvalidState)?;
bus.set_joypad_buttons(*buttons);
if cursor != data.len() {
return Err(RuntimeError::InvalidState(
"trailing bytes in runtime save-state payload".to_string(),
));
}
Ok(())
}
fn buttons_to_bits(buttons: JoypadButtons) -> u8 {
buttons.iter().enumerate().fold(
0u8,
|acc, (idx, pressed)| {
if *pressed { acc | (1 << idx) } else { acc }
},
)
}
fn bits_to_buttons(bits: u8) -> JoypadButtons {
let mut out = [false; crate::runtime::JOYPAD_BUTTONS_COUNT];
for (idx, item) in out.iter_mut().enumerate() {
*item = (bits & (1 << idx)) != 0;
}
out
}
fn cpu_to_bytes(cpu: &Cpu6502) -> [u8; CPU_STATE_BYTES] {
[
cpu.a,
cpu.x,
cpu.y,
cpu.sp,
cpu.pc as u8,
(cpu.pc >> 8) as u8,
cpu.p,
u8::from(cpu.halted),
u8::from(cpu.irq_delay),
u8::from(cpu.pending_nmi),
u8::from(cpu.pending_irq),
0,
]
}
fn cpu_from_bytes(bytes: &[u8]) -> Result<Cpu6502, RuntimeError> {
if bytes.len() != CPU_STATE_BYTES {
return Err(RuntimeError::InvalidState(format!(
"invalid cpu state size: {}",
bytes.len()
)));
}
Ok(Cpu6502 {
a: bytes[0],
x: bytes[1],
y: bytes[2],
sp: bytes[3],
pc: u16::from_le_bytes([bytes[4], bytes[5]]),
p: bytes[6],
halted: bytes[7] != 0,
irq_delay: bytes[8] != 0,
pending_nmi: bytes[9] != 0,
pending_irq: bytes[10] != 0,
})
}
fn take_exact<'a>(
data: &'a [u8],
cursor: &mut usize,
len: usize,
) -> Result<&'a [u8], RuntimeError> {
let end = cursor
.checked_add(len)
.ok_or_else(|| RuntimeError::InvalidState("payload overflow".to_string()))?;
if end > data.len() {
return Err(RuntimeError::InvalidState("payload too short".to_string()));
}
let out = &data[*cursor..end];
*cursor = end;
Ok(out)
}
fn take_u8(data: &[u8], cursor: &mut usize) -> Result<u8, RuntimeError> {
Ok(take_exact(data, cursor, 1)?[0])
}
fn take_u32(data: &[u8], cursor: &mut usize) -> Result<u32, RuntimeError> {
let bytes = take_exact(data, cursor, 4)?;
Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
fn take_u64(data: &[u8], cursor: &mut usize) -> Result<u64, RuntimeError> {
let bytes = take_exact(data, cursor, 8)?;
Ok(u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]))
}

262
src/runtime/tests.rs Normal file
View File

@@ -0,0 +1,262 @@
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,
};
use std::cell::Cell;
use std::rc::Rc;
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; // 16 KiB PRG
rom[5] = 1; // 8 KiB CHR
let prg_offset = 16;
let reset_vec = prg_offset + 0x3FFC;
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
}
#[test]
fn runtime_runs_frame_and_renders() {
let rom = nrom_test_rom();
let mut rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
rt.run_until_frame_complete().expect("frame should run");
let frame = rt.frame_rgba();
assert_eq!(frame.len(), FRAME_RGBA_BYTES);
assert_eq!(rt.frame_number(), 1);
}
#[test]
fn runtime_state_roundtrip() {
let rom = nrom_test_rom();
let mut rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
rt.set_buttons([true, false, true, false, true, false, true, false]);
rt.step_instruction().expect("step");
let state = rt.save_state();
rt.run_until_frame_complete().expect("run");
rt.load_state(&state).expect("load state");
assert_eq!(
rt.buttons(),
[true, false, true, false, true, false, true, false]
);
}
#[test]
fn timing_mode_defaults_to_ntsc_for_ines1() {
let rom = nrom_test_rom();
let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
assert_eq!(rt.video_mode(), VideoMode::Ntsc);
}
#[test]
fn joypad_button_helpers_match_public_order() {
let mut buttons = [false; JOYPAD_BUTTONS_COUNT];
for &button in &JOYPAD_BUTTON_ORDER {
assert!(!button_pressed(&buttons, button));
set_button_pressed(&mut buttons, button, true);
assert!(button_pressed(&buttons, button));
}
assert!(button_pressed(&buttons, JoypadButton::A));
assert!(button_pressed(&buttons, JoypadButton::Select));
}
#[test]
fn audio_mixer_generates_samples() {
let rom = nrom_test_rom();
let mut rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mut mixer = rt.default_audio_mixer(48_000);
let mut out = Vec::new();
rt.run_until_frame_complete_with_audio(&mut mixer, &mut out)
.expect("run frame with audio");
assert!(!out.is_empty());
assert_eq!(mixer.sample_rate(), 48_000);
}
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 MockVideo {
frames: usize,
last_len: usize,
}
impl VideoOutput for MockVideo {
fn present_rgba(&mut self, frame: &[u8], _width: usize, _height: usize) {
self.frames += 1;
self.last_len = frame.len();
}
}
#[derive(Default)]
struct MockAudio {
total_samples: usize,
}
impl AudioOutput for MockAudio {
fn push_samples(&mut self, samples: &[f32]) {
self.total_samples += samples.len();
}
}
#[derive(Clone)]
struct CountingClock {
waits: Rc<Cell<usize>>,
}
impl CountingClock {
fn new() -> Self {
Self {
waits: Rc::new(Cell::new(0)),
}
}
fn waits(&self) -> usize {
self.waits.get()
}
}
impl crate::runtime::FrameClock for CountingClock {
fn wait_next_frame(&mut self) {
self.waits.set(self.waits.get().saturating_add(1));
}
}
#[test]
fn host_loop_runs_single_frame() {
let rom = nrom_test_rom();
let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let mut host = RuntimeHostLoop::with_clock(rt, 48_000, NoopClock);
let mut input = FixedInput;
let mut video = MockVideo::default();
let mut audio = MockAudio::default();
let stats = host
.run_frame(&mut input, &mut video, &mut audio)
.expect("host frame should run");
assert_eq!(stats.frame_number, 1);
assert!(stats.audio_samples > 0);
assert_eq!(host.runtime().frame_number(), 1);
assert_eq!(video.frames, 1);
assert_eq!(video.last_len, FRAME_RGBA_BYTES);
assert!(audio.total_samples > 0);
}
#[test]
fn host_loop_runs_multiple_frames() {
let rom = nrom_test_rom();
let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let config = HostConfig::new(48_000, false);
let mut host = RuntimeHostLoop::with_config(rt, config);
let mut input = FixedInput;
let mut video = MockVideo::default();
let mut audio = MockAudio::default();
let total_samples = host
.run_frames(3, &mut input, &mut video, &mut audio)
.expect("host frames should run");
assert_eq!(host.runtime().frame_number(), 3);
assert_eq!(video.frames, 3);
assert!(audio.total_samples > 0);
assert_eq!(total_samples, audio.total_samples);
}
#[test]
fn client_runtime_respects_pause_and_step() {
let rom = nrom_test_rom();
let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let host = RuntimeHostLoop::with_clock(rt, 48_000, NoopClock);
let mut client = ClientRuntime::with_host_loop(host);
let mut input = FixedInput;
let mut video = MockVideo::default();
let mut audio = MockAudio::default();
client.pause();
assert_eq!(client.state(), EmulationState::Paused);
let skipped = client
.tick(&mut input, &mut video, &mut audio)
.expect("paused tick should succeed");
assert!(skipped.is_none());
assert_eq!(client.host().runtime().frame_number(), 0);
let step_stats = client
.step_frame(&mut input, &mut video, &mut audio)
.expect("manual step should run");
assert_eq!(step_stats.frame_number, 1);
assert_eq!(client.host().runtime().frame_number(), 1);
client.resume();
let tick_stats = client
.tick(&mut input, &mut video, &mut audio)
.expect("running tick should succeed");
assert_eq!(tick_stats.expect("must run").frame_number, 2);
}
#[test]
fn run_frame_unpaced_does_not_call_clock() {
let rom = nrom_test_rom();
let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let clock = CountingClock::new();
let clock_probe = clock.clone();
let mut host = RuntimeHostLoop::with_clock(rt, 48_000, clock);
let mut input = FixedInput;
let mut video = MockVideo::default();
let mut audio = MockAudio::default();
host.run_frame_unpaced(&mut input, &mut video, &mut audio)
.expect("frame should run");
assert_eq!(clock_probe.waits(), 0);
host.run_frame(&mut input, &mut video, &mut audio)
.expect("paced frame should run");
assert_eq!(clock_probe.waits(), 1);
}
#[test]
fn client_step_frame_is_unpaced() {
let rom = nrom_test_rom();
let rt = NesRuntime::from_rom_bytes(&rom).expect("runtime init");
let clock = CountingClock::new();
let clock_probe = clock.clone();
let host = RuntimeHostLoop::with_clock(rt, 48_000, clock);
let mut client = ClientRuntime::with_host_loop(host);
let mut input = FixedInput;
let mut video = MockVideo::default();
let mut audio = MockAudio::default();
client.pause();
client
.step_frame(&mut input, &mut video, &mut audio)
.expect("manual step should run");
assert_eq!(clock_probe.waits(), 0);
client.resume();
client
.tick(&mut input, &mut video, &mut audio)
.expect("running tick should run");
assert_eq!(clock_probe.waits(), 1);
}

83
src/runtime/timing.rs Normal file
View File

@@ -0,0 +1,83 @@
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum VideoMode {
Ntsc,
Pal,
Dendy,
}
impl VideoMode {
pub fn from_ines_timing_mode(mode: u8) -> Self {
match mode {
1 => Self::Pal,
3 => Self::Dendy,
_ => Self::Ntsc,
}
}
pub fn cpu_hz(self) -> f64 {
match self {
Self::Ntsc => 1_789_773.0,
Self::Pal => 1_662_607.0,
Self::Dendy => 1_773_448.0,
}
}
pub fn frame_hz(self) -> f64 {
match self {
Self::Ntsc => 60.098_8,
Self::Pal => 50.007_0,
Self::Dendy => 50.0,
}
}
pub fn frame_duration(self) -> Duration {
Duration::from_secs_f64(1.0 / self.frame_hz())
}
}
#[derive(Debug)]
pub struct FramePacer {
frame_duration: Duration,
next_deadline: Option<Instant>,
}
impl FramePacer {
pub fn new(mode: VideoMode) -> Self {
Self::with_frame_duration(mode.frame_duration())
}
pub fn with_frame_duration(frame_duration: Duration) -> Self {
Self {
frame_duration,
next_deadline: None,
}
}
pub fn reset(&mut self) {
self.next_deadline = None;
}
pub fn wait_next_frame(&mut self) {
let now = Instant::now();
match self.next_deadline {
None => {
self.next_deadline = Some(now + self.frame_duration);
}
Some(deadline) => {
if now < deadline {
std::thread::sleep(deadline - now);
self.next_deadline = Some(deadline + self.frame_duration);
} else {
let frame_ns = self.frame_duration.as_nanos();
let late_ns = now.duration_since(deadline).as_nanos();
let missed = (late_ns / frame_ns) + 1;
self.next_deadline =
Some(deadline + self.frame_duration.mul_f64(missed as f64 + 1.0));
}
}
}
}
}

49
src/runtime/types.rs Normal file
View File

@@ -0,0 +1,49 @@
pub const JOYPAD_BUTTONS_COUNT: usize = 8;
pub type JoypadButtons = [bool; JOYPAD_BUTTONS_COUNT];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoypadButton {
Up,
Down,
Left,
Right,
A,
B,
Start,
Select,
}
impl JoypadButton {
pub const fn index(self) -> usize {
match self {
Self::Up => 0,
Self::Down => 1,
Self::Left => 2,
Self::Right => 3,
Self::A => 4,
Self::B => 5,
Self::Start => 6,
Self::Select => 7,
}
}
}
pub const JOYPAD_BUTTON_ORDER: [JoypadButton; JOYPAD_BUTTONS_COUNT] = [
JoypadButton::Up,
JoypadButton::Down,
JoypadButton::Left,
JoypadButton::Right,
JoypadButton::A,
JoypadButton::B,
JoypadButton::Start,
JoypadButton::Select,
];
pub fn set_button_pressed(buttons: &mut JoypadButtons, button: JoypadButton, pressed: bool) {
buttons[button.index()] = pressed;
}
pub fn button_pressed(buttons: &JoypadButtons, button: JoypadButton) -> bool {
buttons[button.index()]
}