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:
118
src/runtime/adapters.rs
Normal file
118
src/runtime/adapters.rs
Normal 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
39
src/runtime/audio.rs
Normal 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
6
src/runtime/constants.rs
Normal 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
150
src/runtime/core.rs
Normal 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
28
src/runtime/error.rs
Normal 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
51
src/runtime/host/clock.rs
Normal 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) {}
|
||||
}
|
||||
24
src/runtime/host/config.rs
Normal file
24
src/runtime/host/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/runtime/host/executor.rs
Normal file
63
src/runtime/host/executor.rs
Normal 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
36
src/runtime/host/io.rs
Normal 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]) {}
|
||||
}
|
||||
154
src/runtime/host/loop_runner.rs
Normal file
154
src/runtime/host/loop_runner.rs
Normal 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
13
src/runtime/host/mod.rs
Normal 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
110
src/runtime/host/session.rs
Normal 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
43
src/runtime/mod.rs
Normal 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
151
src/runtime/state.rs
Normal 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
262
src/runtime/tests.rs
Normal 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
83
src/runtime/timing.rs
Normal 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
49
src/runtime/types.rs
Normal 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()]
|
||||
}
|
||||
Reference in New Issue
Block a user