Compare commits

..

10 Commits

Author SHA1 Message Date
badbe0979f feat(desktop): add input configuration dialog with key remapping
Some checks failed
CI / rust (push) Has been cancelled
Add modal controls dialog (header bar button) with per-player keyboard
remapping, key capture mode, duplicate protection, reset to defaults,
and a gamepad tab stub for future Xbox/DualSense support.
2026-03-18 15:40:52 +03:00
ad6970d4b5 feat(desktop): GPU rendering, modern GTK4 UI, hotkeys and player 2
Some checks failed
CI / rust (push) Has been cancelled
- Replace Cairo DrawingArea with custom NesScreen widget using
  GskTextureScaleNode for GPU-accelerated nearest-neighbor rendering
- Migrate from FileChooserNative to FileDialog (GTK 4.10+)
- Add AlertDialog for error display, structured logging via env_logger
- Add FPS counter (F3), NTSC/PAL toggle (F7), fullscreen (F11),
  Esc to quit, save/load state (Ctrl+S/L), volume slider
- Add player 2 keyboard input support
- Fix window proportions by compensating for header bar height
2026-03-18 15:12:06 +03:00
d113228f1b refactor(runtime): inline FrameExecutor, add joypad2 and video mode setter
Remove FrameExecutor indirection by inlining its logic into
RuntimeHostLoop. Add set_video_mode() for NTSC/PAL switching,
set_joypad2_buttons() for player 2 input, and fix mapper scanline
IRQ test timing.
2026-03-18 15:11:56 +03:00
38a62b6f93 refactor(desktop): decompose monolithic main.rs into layered modules
Some checks failed
CI / rust (push) Has been cancelled
Split DesktopApp into input, audio, video, scheduling, and app modules.
Migrate DesktopApp from manual pause/resume logic to library ClientRuntime.
2026-03-18 12:52:08 +03:00
2878187180 chore: fix clippy warnings and update docs to match public API
Some checks failed
CI / rust (push) Has been cancelled
Fix 9 clippy warnings across mmc5 mapper and desktop frontend.
Sync api_contract.md and architecture.md with actual public surface.
2026-03-16 15:30:16 +03:00
188444f987 feat(mmc5): implement MMC5 mapper with accurate scanline IRQ and CHR banking
Some checks failed
CI / rust (push) Has been cancelled
- Add ExRAM (modes 0-1) and fill-mode nametable routing via
  read_nametable_byte / write_nametable_byte mapper hooks
- Separate sprite and BG CHR bank sets ($5120-$5127 vs $5128-$512B);
  BG banks are only active in 8x16 sprite mode
- Use mapper.ppu_read_sprite() for sprite tile loads so they always
  use the sprite bank set regardless of PPU fetch phase
- Replace CPU-cycle IRQ stub with scanline-based counter matching
  Mesen2 hardware behaviour: fire when counter == irq_scanline at
  dot 2 (start of scanline), irq_scanline=0 never fires
- Add Mapper::notify_frame_start() called unconditionally at the PPU
  frame boundary; MMC5 uses it to hard-reset the scanline counter even
  when rendering is disabled (e.g. during room transitions), preventing
  stale counter values from shifting the CHR split by 8+ scanlines
- Fix CHR bank calculation for modes 0-2: use << 3/2/1 shifts instead
  of & !7/3/1 masking to correctly convert bank numbers to 1KB indices
- Correct $5204 read: bit 7 = IRQ pending (cleared on read), bit 6 =
  in-frame flag; IRQ line stays asserted until $5204 is read
- Dispatch $4020-$5FFF CPU reads/writes to mapper cpu_read_low /
  cpu_write_low so MMC5 internal registers are accessible
2026-03-15 17:10:50 +03:00
d9666c23b4 feat: Hermite resampling, sprite shift registers, controller open bus
Some checks failed
CI / rust (push) Has been cancelled
#3 audio.rs: replace linear interpolation with Catmull-Rom Hermite cubic.
  Stores prev_sample as p0 control point; m1=(p2-p0)/2, m2=(p2-p1)/2
  tangents give continuous first derivative across batch boundaries.

#4 ppu: add per-slot sprite shift registers (spr_shift_lo/hi, spr_x_counter,
  spr_attr_latch). load_sprite_shifters fetches pattern bytes with h-flip at
  dot 1 of each visible scanline. sprite_pixel_from_shifters replaces the
  per-pixel OAM scan; sprite-0 hit detection integrated into the shifter path.

#5 joypad.rs: format_controller_read now preserves bits 1-5,7 as open bus
  (!0x41 mask) instead of zeroing bits 1-4, matching NES hardware behaviour.
2026-03-15 11:30:14 +03:00
c77be7c84b feat(audio): non-linear APU mixing and mapper expansion audio (VRC6, FME-7, Namco163) 2026-03-15 11:17:37 +03:00
d94fbb894b fix(audio): fix DMC loop byte skip, add DC blocker, lazy cpal stream
Some checks failed
CI / rust (push) Has been cancelled
Three audio bugs fixed:

1. DMC loop mode skipped the last byte of each sample iteration.
   provide_dmc_dma_byte() was immediately setting dmc_dma_request on
   loop restart while the sample buffer was still full, causing the
   while-loop in clock_cpu_cycles to service a second DMA immediately
   and overwrite the valid buffer. Per NES hardware spec, the reader
   only fills an empty buffer — the request is now left to clock_dmc
   when the output unit actually empties the buffer into the shift
   register. Fixes intermittent clicking/crackling in games that use
   looped DMC samples (BGM, SFX).

2. Missing DC blocker (high-pass filter) in AudioMixer. The NES APU
   has a capacitor-coupled output stage that blocks DC bias. Without
   it, abrupt channel state changes (length counter expiry, sweep
   mute, triangle period < 2) produce DC steps that manifest as
   audible clicks. Added a one-pole IIR high-pass filter at ~5 Hz
   applied after the existing low-pass filter.

3. cpal stream was opened at application startup with
   BufferSize::Fixed(256), forcing PipeWire/PulseAudio to run the
   entire audio graph at a 5.3 ms quantum. This disrupted other audio
   applications (browsers, media players) even when no ROM was loaded.
   Fixed by: (a) creating the stream lazily on the first push_samples
   call so no device is touched until a ROM is running, and (b)
   switching to BufferSize::Default so the audio server chooses the
   quantum instead of the emulator imposing one. Ring buffer capacity
   increased from 1536 to 4096 samples to absorb larger server quanta.
2026-03-15 10:44:43 +03:00
d8f41bc2c9 fix(apu): correct frame counter timing, add LP filter, mute aliased triangle
- Fix frame counter running at 2× speed: clock_frame_counter now skips
  odd CPU cycles (APU cycle = CPU/2), so envelope, sweep, and length
  counters tick at the correct rate. Fixes sweep-driven whistle in Megaman II.

- Switch audio sampling to per-CPU-cycle granularity in
  run_until_frame_complete_with_audio to eliminate square-wave harmonic
  aliasing caused by sampling only once per instruction.

- Add IIR one-pole low-pass filter (~14 kHz) to AudioMixer to smooth
  abrupt level transitions (crackling) introduced by correct envelope timing.

- Mute triangle channel when timer_period < 2 (≥27 kHz), which aliases
  into the audible range at 48 kHz. Real NES RC circuit removes these
  ultrasonics; emulator must suppress them explicitly.

- Update all APU bus tests to use correct (doubled) CPU cycle counts.
2026-03-15 10:44:43 +03:00
44 changed files with 2390 additions and 737 deletions

148
Cargo.lock generated
View File

@@ -33,6 +33,56 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -159,6 +209,12 @@ dependencies = [
"libloading",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -230,6 +286,29 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "env_filter"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -604,6 +683,12 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@@ -613,6 +698,30 @@ dependencies = [
"either",
]
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -759,9 +868,10 @@ dependencies = [
name = "nesemu-desktop"
version = "0.1.0"
dependencies = [
"cairo-rs",
"cpal",
"env_logger",
"gtk4",
"log",
"nesemu",
]
@@ -846,6 +956,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pango"
version = "0.19.8"
@@ -882,6 +998,21 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -1156,6 +1287,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -1312,6 +1449,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"

View File

@@ -5,6 +5,8 @@ edition = "2024"
[dependencies]
nesemu = { path = "../.." }
gtk4 = "0.8"
cairo-rs = "0.19"
gtk4 = { version = "0.8", features = ["v4_10"] }
cpal = "0.15"
log = "0.4"
env_logger = "0.11"

View File

@@ -0,0 +1,152 @@
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
use std::time::Duration;
use nesemu::prelude::{ClientRuntime, EmulationState, HostConfig};
use nesemu::{FrameClock, NesRuntime, VideoMode};
use crate::audio::CpalAudioSink;
use crate::input::InputState;
use crate::video::BufferedVideo;
use crate::SAMPLE_RATE;
pub(crate) struct DesktopApp {
session: Option<ClientRuntime<Box<dyn FrameClock>>>,
input_p1: InputState,
input_p2: InputState,
audio: CpalAudioSink,
video: BufferedVideo,
save_slot: Option<Vec<u8>>,
}
impl DesktopApp {
pub(crate) fn new(volume: Arc<AtomicU32>) -> Self {
Self {
session: None,
input_p1: InputState::default(),
input_p2: InputState::default(),
audio: CpalAudioSink::new(volume),
video: BufferedVideo::new(),
save_slot: None,
}
}
pub(crate) fn load_rom_from_path(
&mut self,
path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let data = std::fs::read(path)?;
let runtime = NesRuntime::from_rom_bytes(&data)?;
let config = HostConfig::new(SAMPLE_RATE, false);
let session = ClientRuntime::with_config(runtime, config);
self.session = Some(session);
self.audio.clear();
Ok(())
}
pub(crate) fn reset(&mut self) {
if let Some(session) = self.session.as_mut() {
session.host_mut().runtime_mut().reset();
self.audio.clear();
session.resume();
}
}
pub(crate) fn is_loaded(&self) -> bool {
self.session.is_some()
}
pub(crate) fn state(&self) -> EmulationState {
self.session
.as_ref()
.map(|s| s.state())
.unwrap_or(EmulationState::Paused)
}
pub(crate) fn toggle_pause(&mut self) {
if let Some(session) = self.session.as_mut() {
match session.state() {
EmulationState::Running => session.pause(),
_ => session.resume(),
}
}
}
pub(crate) fn tick(&mut self) {
let Some(session) = self.session.as_mut() else {
return;
};
// Set player 2 buttons before the frame tick.
use nesemu::InputProvider;
let p2_buttons = self.input_p2.poll_buttons();
session
.host_mut()
.runtime_mut()
.bus_mut()
.set_joypad2_buttons(p2_buttons);
if let Err(err) = session.tick(&mut self.input_p1, &mut self.video, &mut self.audio) {
log::error!("Frame execution error: {err}");
session.pause();
}
}
pub(crate) fn frame_rgba(&self) -> &[u8] {
self.video.frame_rgba()
}
pub(crate) fn frame_interval(&self) -> Duration {
self.session
.as_ref()
.map(|s| s.host().runtime().video_mode().frame_duration())
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
}
pub(crate) fn input_p1_mut(&mut self) -> &mut InputState {
&mut self.input_p1
}
pub(crate) fn input_p2_mut(&mut self) -> &mut InputState {
&mut self.input_p2
}
pub(crate) fn save_state(&mut self) {
if let Some(session) = self.session.as_ref() {
self.save_slot = Some(session.host().runtime().save_state());
}
}
pub(crate) fn load_state(&mut self) {
let Some(data) = self.save_slot.as_ref() else {
return;
};
if let Some(session) = self.session.as_mut() {
if let Err(err) = session.host_mut().runtime_mut().load_state(data) {
log::error!("Failed to load state: {err}");
}
self.audio.clear();
}
}
pub(crate) fn video_mode(&self) -> VideoMode {
self.session
.as_ref()
.map(|s| s.host().runtime().video_mode())
.unwrap_or(VideoMode::Ntsc)
}
pub(crate) fn cycle_video_mode(&mut self) -> Option<VideoMode> {
let session = self.session.as_mut()?;
let current = session.host().runtime().video_mode();
let next = match current {
VideoMode::Ntsc => VideoMode::Pal,
_ => VideoMode::Ntsc,
};
session.host_mut().runtime_mut().set_video_mode(next);
log::info!("Video mode: {next:?}");
Some(next)
}
}

View File

@@ -0,0 +1,146 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use nesemu::RingBuffer;
use crate::SAMPLE_RATE;
pub(crate) const AUDIO_RING_CAPACITY: usize = 4096;
pub(crate) struct CpalAudioSink {
_stream: Option<cpal::Stream>,
ring: Arc<RingBuffer>,
_volume: Arc<AtomicU32>,
}
impl CpalAudioSink {
pub(crate) fn new(volume: Arc<AtomicU32>) -> Self {
let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY));
Self {
_stream: None,
ring,
_volume: volume,
}
}
fn ensure_stream(&mut self) {
if self._stream.is_none() {
let ring_for_cb = Arc::clone(&self.ring);
let vol_for_cb = Arc::clone(&self._volume);
self._stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
}
}
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host();
let device = match host.default_output_device() {
Some(d) => d,
None => {
eprintln!("No audio output device found — running without sound");
return None;
}
};
let config = cpal_stream_config();
let stream = match device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let read = ring.pop(data);
for sample in &mut data[read..] {
*sample = 0.0;
}
let vol = f32::from_bits(volume.load(AtomicOrdering::Relaxed));
for sample in &mut data[..read] {
*sample *= vol;
}
},
move |err| {
eprintln!("Audio stream error: {err}");
},
None,
) {
Ok(s) => s,
Err(err) => {
eprintln!("Failed to build audio stream: {err} — running without sound");
return None;
}
};
if let Err(err) = stream.play() {
eprintln!("Failed to start audio stream: {err} — running without sound");
return None;
}
Some(stream)
}
pub(crate) fn clear(&self) {
self.ring.clear();
}
}
impl nesemu::AudioOutput for CpalAudioSink {
fn push_samples(&mut self, samples: &[f32]) {
self.ensure_stream();
self.ring.push(samples);
}
}
fn cpal_stream_config() -> cpal::StreamConfig {
cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Default,
}
}
#[cfg(test)]
use nesemu::VideoMode;
#[cfg(test)]
fn audio_ring_latency_ms(capacity: usize, sample_rate: u32) -> f64 {
((capacity.saturating_sub(1)) as f64 / sample_rate as f64) * 1000.0
}
#[cfg(test)]
const AUDIO_CALLBACK_FRAMES: u32 = 256;
#[cfg(test)]
fn required_audio_ring_capacity(sample_rate: u32, mode: VideoMode) -> usize {
let samples_per_frame = (sample_rate as f64 / mode.frame_hz()).ceil() as usize;
samples_per_frame + AUDIO_CALLBACK_FRAMES as usize + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_audio_ring_budget_stays_below_100ms() {
let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE);
let max_budget_ms = 100.0;
assert!(
latency_ms <= max_budget_ms,
"desktop audio ring latency budget too high: {latency_ms:.2}ms"
);
}
#[test]
fn desktop_audio_uses_default_buffer_size() {
let config = cpal_stream_config();
assert_eq!(config.buffer_size, cpal::BufferSize::Default);
}
#[test]
fn desktop_audio_ring_has_frame_burst_headroom() {
let required = required_audio_ring_capacity(SAMPLE_RATE, VideoMode::Ntsc);
assert!(
AUDIO_RING_CAPACITY >= required,
"audio ring too small for frame burst: capacity={}, required={required}",
AUDIO_RING_CAPACITY,
);
}
}

View File

@@ -0,0 +1,18 @@
use nesemu::{InputProvider, JoypadButton, JoypadButtons, set_button_pressed};
#[derive(Default)]
pub(crate) struct InputState {
buttons: JoypadButtons,
}
impl InputState {
pub(crate) fn set_button(&mut self, button: JoypadButton, pressed: bool) {
set_button_pressed(&mut self.buttons, button, pressed);
}
}
impl InputProvider for InputState {
fn poll_buttons(&mut self) -> JoypadButtons {
self.buttons
}
}

View File

@@ -0,0 +1,446 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use gtk::gdk;
use gtk::glib::translate::IntoGlib;
use gtk::prelude::*;
use gtk4 as gtk;
use nesemu::{JOYPAD_BUTTONS_COUNT, JOYPAD_BUTTON_ORDER, JoypadButton};
// ---------------------------------------------------------------------------
// KeyBindings — maps each JoypadButton to a gdk::Key for one player
// ---------------------------------------------------------------------------
#[derive(Clone)]
pub(crate) struct KeyBindings {
keys: [gdk::Key; JOYPAD_BUTTONS_COUNT],
reverse: HashMap<gdk::Key, JoypadButton>,
}
impl KeyBindings {
fn new(keys: [gdk::Key; JOYPAD_BUTTONS_COUNT]) -> Self {
let reverse = Self::build_reverse(&keys);
Self { keys, reverse }
}
pub(crate) fn default_p1() -> Self {
Self::new([
gdk::Key::Up,
gdk::Key::Down,
gdk::Key::Left,
gdk::Key::Right,
gdk::Key::x,
gdk::Key::z,
gdk::Key::Return,
gdk::Key::Shift_L,
])
}
pub(crate) fn default_p2() -> Self {
Self::new([
gdk::Key::w,
gdk::Key::s,
gdk::Key::a,
gdk::Key::d,
gdk::Key::k,
gdk::Key::j,
gdk::Key::i,
gdk::Key::u,
])
}
pub(crate) fn lookup(&self, key: gdk::Key) -> Option<JoypadButton> {
let normalized = normalize_key(key);
self.reverse.get(&normalized).copied()
}
pub(crate) fn key_for(&self, button: JoypadButton) -> gdk::Key {
self.keys[button.index()]
}
pub(crate) fn set_key(&mut self, button: JoypadButton, key: gdk::Key) {
let normalized = normalize_key(key);
// Clear duplicate: if another button has this key, unbind it
if let Some(&old_button) = self.reverse.get(&normalized) {
if old_button != button {
self.keys[old_button.index()] = gdk::Key::VoidSymbol;
}
}
// Remove old reverse entry for the button being rebound
let old_key = self.keys[button.index()];
self.reverse.remove(&old_key);
// Set new binding
self.keys[button.index()] = normalized;
self.rebuild_reverse();
}
fn rebuild_reverse(&mut self) {
self.reverse = Self::build_reverse(&self.keys);
}
fn build_reverse(keys: &[gdk::Key; JOYPAD_BUTTONS_COUNT]) -> HashMap<gdk::Key, JoypadButton> {
let mut map = HashMap::with_capacity(JOYPAD_BUTTONS_COUNT);
for &button in &JOYPAD_BUTTON_ORDER {
let key = keys[button.index()];
if key != gdk::Key::VoidSymbol {
map.insert(key, button);
}
}
map
}
}
// ---------------------------------------------------------------------------
// InputConfig — shared state for both players
// ---------------------------------------------------------------------------
pub(crate) struct InputConfig {
pub(crate) p1: KeyBindings,
pub(crate) p2: KeyBindings,
}
impl InputConfig {
pub(crate) fn new() -> Self {
Self {
p1: KeyBindings::default_p1(),
p2: KeyBindings::default_p2(),
}
}
pub(crate) fn lookup_p1(&self, key: gdk::Key) -> Option<JoypadButton> {
self.p1.lookup(key)
}
pub(crate) fn lookup_p2(&self, key: gdk::Key) -> Option<JoypadButton> {
self.p2.lookup(key)
}
}
// ---------------------------------------------------------------------------
// Key normalization & display
// ---------------------------------------------------------------------------
fn normalize_key(key: gdk::Key) -> gdk::Key {
let lower = key.to_lower();
if lower != gdk::Key::VoidSymbol {
lower
} else {
key
}
}
fn display_key_name(key: gdk::Key) -> String {
if key == gdk::Key::VoidSymbol {
return "".to_string();
}
match key {
gdk::Key::Return => "Enter".to_string(),
gdk::Key::Shift_L => "LShift".to_string(),
gdk::Key::Shift_R => "RShift".to_string(),
gdk::Key::Control_L => "LCtrl".to_string(),
gdk::Key::Control_R => "RCtrl".to_string(),
gdk::Key::Alt_L => "LAlt".to_string(),
gdk::Key::Alt_R => "RAlt".to_string(),
gdk::Key::space => "Space".to_string(),
gdk::Key::BackSpace => "Backspace".to_string(),
gdk::Key::Tab => "Tab".to_string(),
gdk::Key::Escape => "Escape".to_string(),
gdk::Key::Up => "".to_string(),
gdk::Key::Down => "".to_string(),
gdk::Key::Left => "".to_string(),
gdk::Key::Right => "".to_string(),
other => {
if let Some(name) = other.name() {
let s = name.to_string();
if s.len() == 1 {
s.to_uppercase()
} else {
s
}
} else {
format!("0x{:04x}", other.into_glib())
}
}
}
}
fn button_display_name(button: JoypadButton) -> &'static str {
match button {
JoypadButton::Up => "Up",
JoypadButton::Down => "Down",
JoypadButton::Left => "Left",
JoypadButton::Right => "Right",
JoypadButton::A => "A",
JoypadButton::B => "B",
JoypadButton::Start => "Start",
JoypadButton::Select => "Select",
}
}
// ---------------------------------------------------------------------------
// Dialog
// ---------------------------------------------------------------------------
pub(crate) fn show_input_config_dialog(
parent: &gtk::ApplicationWindow,
config: Rc<RefCell<InputConfig>>,
) {
let dialog = gtk::Window::builder()
.title("Controls")
.modal(true)
.transient_for(parent)
.resizable(false)
.default_width(340)
.build();
let root_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
root_box.set_margin_top(12);
root_box.set_margin_bottom(12);
root_box.set_margin_start(12);
root_box.set_margin_end(12);
// --- Top-level notebook: Keyboard / Gamepad ---
let top_notebook = gtk::Notebook::new();
// --- Keyboard tab ---
let keyboard_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
let player_notebook = gtk::Notebook::new();
// State for key capture mode
let capturing: Rc<RefCell<Option<(usize, JoypadButton)>>> = Rc::new(RefCell::new(None));
// Store all key buttons for updating labels
let key_buttons: Rc<RefCell<Vec<Vec<gtk::Button>>>> =
Rc::new(RefCell::new(vec![Vec::new(), Vec::new()]));
for player_idx in 0..2 {
let page_box = gtk::Box::new(gtk::Orientation::Vertical, 4);
page_box.set_margin_top(8);
page_box.set_margin_bottom(8);
page_box.set_margin_start(8);
page_box.set_margin_end(8);
for &button in &JOYPAD_BUTTON_ORDER {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
row.set_hexpand(true);
let label = gtk::Label::new(Some(button_display_name(button)));
label.set_halign(gtk::Align::Start);
label.set_hexpand(true);
label.set_width_chars(8);
let bindings = if player_idx == 0 {
&config.borrow().p1
} else {
&config.borrow().p2
};
let key_name = display_key_name(bindings.key_for(button));
let key_button = gtk::Button::with_label(&key_name);
key_button.set_width_request(100);
key_button.set_halign(gtk::Align::End);
// On click: enter capture mode
{
let capturing = Rc::clone(&capturing);
let key_buttons = Rc::clone(&key_buttons);
key_button.connect_clicked(move |btn| {
// Reset any previous capturing button label
if let Some((prev_player, prev_btn)) = *capturing.borrow() {
let buttons = key_buttons.borrow();
let prev_widget = &buttons[prev_player][prev_btn.index()];
// Restore its label — we'll just mark it needs refresh
prev_widget.set_label("");
}
*capturing.borrow_mut() = Some((player_idx, button));
btn.set_label("Press a key...");
});
}
row.append(&label);
row.append(&key_button);
page_box.append(&row);
key_buttons.borrow_mut()[player_idx].push(key_button);
}
let tab_label = gtk::Label::new(Some(if player_idx == 0 {
"Player 1"
} else {
"Player 2"
}));
player_notebook.append_page(&page_box, Some(&tab_label));
}
keyboard_box.append(&player_notebook);
// --- Reset to Defaults button ---
let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
button_box.set_halign(gtk::Align::Center);
button_box.set_margin_top(8);
let reset_button = gtk::Button::with_label("Reset to Defaults");
{
let config = Rc::clone(&config);
let player_notebook = player_notebook.clone();
let key_buttons = Rc::clone(&key_buttons);
reset_button.connect_clicked(move |_| {
let current_page = player_notebook.current_page().unwrap_or(0) as usize;
let mut cfg = config.borrow_mut();
if current_page == 0 {
cfg.p1 = KeyBindings::default_p1();
} else {
cfg.p2 = KeyBindings::default_p2();
}
// Refresh button labels for the reset player
let bindings = if current_page == 0 { &cfg.p1 } else { &cfg.p2 };
let buttons = key_buttons.borrow();
for &btn in &JOYPAD_BUTTON_ORDER {
let label = display_key_name(bindings.key_for(btn));
buttons[current_page][btn.index()].set_label(&label);
}
});
}
let close_button = gtk::Button::with_label("Close");
{
let dialog = dialog.clone();
close_button.connect_clicked(move |_| {
dialog.close();
});
}
button_box.append(&reset_button);
button_box.append(&close_button);
keyboard_box.append(&button_box);
let keyboard_tab_label = gtk::Label::new(Some("Keyboard"));
top_notebook.append_page(&keyboard_box, Some(&keyboard_tab_label));
// --- Gamepad tab (stub) ---
let gamepad_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
gamepad_box.set_valign(gtk::Align::Center);
gamepad_box.set_halign(gtk::Align::Center);
gamepad_box.set_vexpand(true);
gamepad_box.set_margin_top(32);
gamepad_box.set_margin_bottom(32);
let gamepad_label = gtk::Label::new(Some(
"Gamepad support coming soon\nXbox / DualSense",
));
gamepad_label.set_justify(gtk::Justification::Center);
gamepad_label.add_css_class("dim-label");
gamepad_box.append(&gamepad_label);
let gamepad_tab_label = gtk::Label::new(Some("Gamepad"));
top_notebook.append_page(&gamepad_box, Some(&gamepad_tab_label));
root_box.append(&top_notebook);
dialog.set_child(Some(&root_box));
// --- Key capture via EventControllerKey on the dialog window ---
{
let config = Rc::clone(&config);
let capturing = Rc::clone(&capturing);
let key_buttons = Rc::clone(&key_buttons);
let key_controller = gtk::EventControllerKey::new();
key_controller.connect_key_pressed(move |_, keyval, _, _| {
let Some((player_idx, button)) = *capturing.borrow() else {
return gtk::glib::Propagation::Proceed;
};
// Escape cancels capture
if keyval == gdk::Key::Escape {
// Restore the label to current binding
let cfg = config.borrow();
let bindings = if player_idx == 0 { &cfg.p1 } else { &cfg.p2 };
let label = display_key_name(bindings.key_for(button));
key_buttons.borrow()[player_idx][button.index()].set_label(&label);
*capturing.borrow_mut() = None;
return gtk::glib::Propagation::Stop;
}
// Assign the key
{
let mut cfg = config.borrow_mut();
let bindings = if player_idx == 0 {
&mut cfg.p1
} else {
&mut cfg.p2
};
bindings.set_key(button, keyval);
}
// Refresh all button labels for this player (duplicate might have been cleared)
{
let cfg = config.borrow();
let bindings = if player_idx == 0 { &cfg.p1 } else { &cfg.p2 };
let buttons = key_buttons.borrow();
for &btn in &JOYPAD_BUTTON_ORDER {
let label = display_key_name(bindings.key_for(btn));
buttons[player_idx][btn.index()].set_label(&label);
}
}
*capturing.borrow_mut() = None;
gtk::glib::Propagation::Stop
});
dialog.add_controller(key_controller);
}
dialog.present();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_bindings_lookup() {
let config = InputConfig::new();
assert_eq!(config.lookup_p1(gdk::Key::x), Some(JoypadButton::A));
assert_eq!(config.lookup_p1(gdk::Key::z), Some(JoypadButton::B));
assert_eq!(config.lookup_p1(gdk::Key::Up), Some(JoypadButton::Up));
assert_eq!(config.lookup_p2(gdk::Key::w), Some(JoypadButton::Up));
assert_eq!(config.lookup_p2(gdk::Key::k), Some(JoypadButton::A));
}
#[test]
fn case_insensitive_lookup() {
let config = InputConfig::new();
// X (uppercase) should also match since we normalize to lowercase
assert_eq!(config.lookup_p1(gdk::Key::X), Some(JoypadButton::A));
assert_eq!(config.lookup_p2(gdk::Key::W), Some(JoypadButton::Up));
}
#[test]
fn set_key_clears_duplicate() {
let mut bindings = KeyBindings::default_p1();
// Bind Up to 'x' — should clear A's binding to 'x'
bindings.set_key(JoypadButton::Up, gdk::Key::x);
assert_eq!(bindings.key_for(JoypadButton::Up), gdk::Key::x);
assert_eq!(bindings.key_for(JoypadButton::A), gdk::Key::VoidSymbol);
assert_eq!(bindings.lookup(gdk::Key::x), Some(JoypadButton::Up));
}
#[test]
fn display_key_names() {
assert_eq!(display_key_name(gdk::Key::Return), "Enter");
assert_eq!(display_key_name(gdk::Key::Up), "");
assert_eq!(display_key_name(gdk::Key::Shift_L), "LShift");
assert_eq!(display_key_name(gdk::Key::VoidSymbol), "");
}
#[test]
fn reset_to_defaults() {
let mut config = InputConfig::new();
config.p1.set_key(JoypadButton::A, gdk::Key::q);
assert_eq!(config.lookup_p1(gdk::Key::q), Some(JoypadButton::A));
config.p1 = KeyBindings::default_p1();
assert_eq!(config.lookup_p1(gdk::Key::q), None);
assert_eq!(config.lookup_p1(gdk::Key::x), Some(JoypadButton::A));
}
}

View File

@@ -1,34 +1,37 @@
use std::cell::RefCell;
mod app;
mod audio;
mod input;
mod input_config;
mod scheduling;
mod video;
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering};
use std::time::Instant;
use gtk::gdk;
use gtk::gio;
use gtk::glib;
use gtk::prelude::*;
use gtk4 as gtk;
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
use nesemu::{
set_button_pressed, FrameClock, InputProvider, JoypadButton, JoypadButtons, NesRuntime,
RingBuffer, VideoMode, VideoOutput, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH,
};
use nesemu::prelude::EmulationState;
use nesemu::{FRAME_HEIGHT, FRAME_WIDTH};
use app::DesktopApp;
use input_config::InputConfig;
use scheduling::DesktopFrameScheduler;
use video::NesScreen;
const APP_ID: &str = "org.nesemu.desktop";
const TITLE: &str = "NES Emulator";
const SCALE: i32 = 3;
const SAMPLE_RATE: u32 = 48_000;
const AUDIO_RING_CAPACITY: usize = 1536;
const AUDIO_CALLBACK_FRAMES: u32 = 256;
fn main() {
if std::env::var_os("GSK_RENDERER").is_none() {
unsafe {
std::env::set_var("GSK_RENDERER", "cairo");
}
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let app = gtk::Application::builder().application_id(APP_ID).build();
@@ -49,7 +52,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
.application(app)
.title(TITLE)
.default_width((FRAME_WIDTH as i32) * SCALE)
.default_height((FRAME_HEIGHT as i32) * SCALE)
.default_height((FRAME_HEIGHT as i32) * SCALE + 45)
.build();
// --- Header bar ---
@@ -102,88 +105,58 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
volume_box.append(&volume_scale);
header.pack_end(&volume_box);
let controls_button = gtk::Button::builder()
.icon_name("preferences-system-symbolic")
.tooltip_text("Controls")
.focusable(false)
.build();
header.pack_end(&controls_button);
window.set_titlebar(Some(&header));
// --- Drawing area ---
let drawing_area = gtk::DrawingArea::new();
drawing_area.set_hexpand(true);
drawing_area.set_vexpand(true);
// --- NES screen widget (GPU-accelerated, nearest-neighbor scaling) ---
let screen = NesScreen::new();
screen.set_size_request(FRAME_WIDTH as i32, FRAME_HEIGHT as i32);
screen.set_hexpand(true);
screen.set_vexpand(true);
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&drawing_area));
overlay.set_child(Some(&screen));
let drop_label = gtk::Label::builder()
.label("Drop a .nes ROM here\nor press Ctrl+O to open")
.label("Drop a .nes ROM here\nor press Ctrl+O to open\n\nF3 — FPS counter\nF7 — NTSC / PAL\nF11 — Fullscreen\nEsc — Quit")
.justify(gtk::Justification::Center)
.css_classes(["dim-label"])
.build();
drop_label.set_halign(gtk::Align::Center);
drop_label.set_valign(gtk::Align::Center);
overlay.add_overlay(&drop_label);
overlay.set_measure_overlay(&drop_label, false);
let fps_label = gtk::Label::new(None);
fps_label.set_halign(gtk::Align::End);
fps_label.set_valign(gtk::Align::Start);
fps_label.set_margin_top(4);
fps_label.set_margin_end(4);
fps_label.add_css_class("monospace");
fps_label.set_visible(false);
overlay.add_overlay(&fps_label);
window.set_child(Some(&overlay));
// --- State ---
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume))));
let frame_for_draw: Rc<RefCell<Vec<u8>>> = Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new()));
// --- Draw function (pixel-perfect nearest-neighbor) ---
{
let frame_for_draw = Rc::clone(&frame_for_draw);
drawing_area.set_draw_func(move |_da, cr, width, height| {
let frame = frame_for_draw.borrow();
let stride = cairo::Format::ARgb32
.stride_for_width(FRAME_WIDTH as u32)
.unwrap();
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
for y in 0..FRAME_HEIGHT {
for x in 0..FRAME_WIDTH {
let src = (y * FRAME_WIDTH + x) * 4;
let dst = y * stride as usize + x * 4;
let r = frame[src];
let g = frame[src + 1];
let b = frame[src + 2];
let a = frame[src + 3];
argb[dst] = b;
argb[dst + 1] = g;
argb[dst + 2] = r;
argb[dst + 3] = a;
}
}
let surface = cairo::ImageSurface::create_for_data(
argb,
cairo::Format::ARgb32,
FRAME_WIDTH as i32,
FRAME_HEIGHT as i32,
stride,
)
.expect("Failed to create Cairo surface");
// Fill background black
let _ = cr.set_source_rgb(0.0, 0.0, 0.0);
let _ = cr.paint();
let sx = width as f64 / FRAME_WIDTH as f64;
let sy = height as f64 / FRAME_HEIGHT as f64;
let scale = sx.min(sy);
let offset_x = (width as f64 - FRAME_WIDTH as f64 * scale) / 2.0;
let offset_y = (height as f64 - FRAME_HEIGHT as f64 * scale) / 2.0;
let _ = cr.translate(offset_x, offset_y);
let _ = cr.scale(scale, scale);
let _ = cr.set_source_surface(&surface, 0.0, 0.0);
cr.source().set_filter(cairo::Filter::Nearest);
let _ = cr.paint();
});
}
let input_config = Rc::new(RefCell::new(InputConfig::new()));
// --- Helper to sync UI with emulation state ---
let current_rom_name: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let sync_ui = {
let pause_button = pause_button.clone();
let reset_button = reset_button.clone();
let drop_label = drop_label.clone();
let window = window.clone();
let current_rom_name = Rc::clone(&current_rom_name);
move |app_state: &DesktopApp, rom_name: Option<&str>| {
let loaded = app_state.is_loaded();
pause_button.set_sensitive(loaded);
@@ -199,7 +172,11 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
}
if let Some(name) = rom_name {
window.set_title(Some(&format!("{TITLE}{name}")));
*current_rom_name.borrow_mut() = Some(name.to_string());
}
if let Some(name) = current_rom_name.borrow().as_deref() {
let mode = app_state.video_mode();
window.set_title(Some(&format!("{TITLE}{name} [{mode:?}]")));
}
}
};
@@ -209,12 +186,17 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
{
let mut app_state = desktop.borrow_mut();
if let Some(path) = initial_rom {
if let Err(err) = app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display());
sync_ui(&app_state, None);
} else {
let name = rom_filename(&path);
sync_ui(&app_state, Some(&name));
match app_state.load_rom_from_path(&path) {
Ok(()) => {
let name = rom_filename(&path);
sync_ui(&app_state, Some(&name));
}
Err(err) => {
log::error!("Failed to load ROM '{}': {err}", path.display());
sync_ui(&app_state, None);
drop(app_state);
show_error(&window, &format!("Failed to load ROM: {err}"));
}
}
} else {
sync_ui(&app_state, None);
@@ -228,42 +210,48 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
let sync_ui = Rc::clone(&sync_ui);
let window = window.clone();
Rc::new(move || {
let chooser = gtk::FileChooserNative::new(
Some("Open NES ROM"),
Some(&window),
gtk::FileChooserAction::Open,
Some("Open"),
Some("Cancel"),
);
let nes_filter = gtk::FileFilter::new();
nes_filter.set_name(Some("NES ROMs"));
nes_filter.add_pattern("*.nes");
chooser.add_filter(&nes_filter);
let all_filter = gtk::FileFilter::new();
all_filter.set_name(Some("All files"));
all_filter.add_pattern("*");
chooser.add_filter(&all_filter);
let filters = gio::ListStore::new::<gtk::FileFilter>();
filters.append(&nes_filter);
filters.append(&all_filter);
let dialog = gtk::FileDialog::builder()
.title("Open NES ROM")
.modal(true)
.filters(&filters)
.default_filter(&nes_filter)
.build();
let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui);
chooser.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
if let Some(path) = dialog.file().and_then(|f| f.path()) {
let mut app_state = desktop.borrow_mut();
if let Err(err) = app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display());
} else {
scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path);
sync_ui(&app_state, Some(&name));
}
let parent = window.clone();
let error_window = window.clone();
dialog.open(Some(&parent), gio::Cancellable::NONE, move |result| {
let file = match result {
Ok(file) => file,
Err(_) => return, // user cancelled
};
let Some(path) = file.path() else { return };
let mut app_state = desktop.borrow_mut();
match app_state.load_rom_from_path(&path) {
Ok(()) => {
scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path);
sync_ui(&app_state, Some(&name));
}
Err(err) => {
drop(app_state);
show_error(&error_window, &format!("Failed to load ROM: {err}"));
}
}
});
chooser.show();
})
};
@@ -299,6 +287,14 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
});
}
{
let input_config = Rc::clone(&input_config);
let window = window.clone();
controls_button.connect_clicked(move |_| {
input_config::show_input_config_dialog(&window, Rc::clone(&input_config));
});
}
// --- Keyboard shortcuts via actions ---
let action_open = gio::SimpleAction::new("open", None);
{
@@ -344,20 +340,105 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
window.add_action(&action_reset);
app.set_accels_for_action("win.reset", &["<Ctrl>r"]);
let action_save = gio::SimpleAction::new("save-state", None);
{
let desktop = Rc::clone(&desktop);
action_save.connect_activate(move |_, _| {
desktop.borrow_mut().save_state();
});
}
window.add_action(&action_save);
app.set_accels_for_action("win.save-state", &["<Ctrl>s"]);
let action_load = gio::SimpleAction::new("load-state", None);
{
let desktop = Rc::clone(&desktop);
action_load.connect_activate(move |_, _| {
desktop.borrow_mut().load_state();
});
}
window.add_action(&action_load);
app.set_accels_for_action("win.load-state", &["<Ctrl>l"]);
let action_fullscreen = gio::SimpleAction::new("toggle-fullscreen", None);
{
let window = window.clone();
action_fullscreen.connect_activate(move |_, _| {
if window.is_fullscreen() {
window.unfullscreen();
} else {
window.fullscreen();
}
});
}
window.add_action(&action_fullscreen);
app.set_accels_for_action("win.toggle-fullscreen", &["F11"]);
let action_fps = gio::SimpleAction::new("toggle-fps", None);
{
let fps_label = fps_label.clone();
action_fps.connect_activate(move |_, _| {
fps_label.set_visible(!fps_label.is_visible());
});
}
window.add_action(&action_fps);
app.set_accels_for_action("win.toggle-fps", &["F3"]);
let action_quit = gio::SimpleAction::new("quit", None);
{
let window = window.clone();
action_quit.connect_activate(move |_, _| {
window.close();
});
}
window.add_action(&action_quit);
app.set_accels_for_action("win.quit", &["Escape"]);
let action_video_mode = gio::SimpleAction::new("cycle-video-mode", None);
{
let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui);
action_video_mode.connect_activate(move |_, _| {
let mut app_state = desktop.borrow_mut();
if app_state.cycle_video_mode().is_some() {
scheduler.borrow_mut().reset_timing();
sync_ui(&app_state, None);
}
});
}
window.add_action(&action_video_mode);
app.set_accels_for_action("win.cycle-video-mode", &["F7"]);
// --- Keyboard controller for joypad input ---
{
let desktop = Rc::clone(&desktop);
let input_config = Rc::clone(&input_config);
let key_controller = gtk::EventControllerKey::new();
let desktop_for_press = Rc::clone(&desktop);
let config_for_press = Rc::clone(&input_config);
key_controller.connect_key_pressed(move |_, key, _, _| {
let config = config_for_press.borrow();
let mut app_state = desktop_for_press.borrow_mut();
app_state.input_mut().set_key_state(key, true);
if let Some(btn) = config.lookup_p1(key) {
app_state.input_p1_mut().set_button(btn, true);
}
if let Some(btn) = config.lookup_p2(key) {
app_state.input_p2_mut().set_button(btn, true);
}
gtk::glib::Propagation::Proceed
});
key_controller.connect_key_released(move |_, key, _, _| {
desktop.borrow_mut().input_mut().set_key_state(key, false);
let config = input_config.borrow();
let mut app_state = desktop.borrow_mut();
if let Some(btn) = config.lookup_p1(key) {
app_state.input_p1_mut().set_button(btn, false);
}
if let Some(btn) = config.lookup_p2(key) {
app_state.input_p2_mut().set_button(btn, false);
}
});
window.add_controller(key_controller);
@@ -368,32 +449,43 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui);
let window = window.clone();
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
drop_target.connect_drop(move |_, value, _, _| {
if let Ok(file) = value.get::<gio::File>() {
if let Some(path) = file.path() {
let mut app_state = desktop.borrow_mut();
if let Err(err) = app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display());
if let Ok(file) = value.get::<gio::File>()
&& let Some(path) = file.path()
{
let mut app_state = desktop.borrow_mut();
match app_state.load_rom_from_path(&path) {
Ok(()) => {
scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path);
sync_ui(&app_state, Some(&name));
return true;
}
Err(err) => {
log::error!("Failed to load ROM '{}': {err}", path.display());
drop(app_state);
show_error(&window, &format!("Failed to load ROM: {err}"));
return false;
}
scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path);
sync_ui(&app_state, Some(&name));
return true;
}
}
false
});
drawing_area.add_controller(drop_target);
screen.add_controller(drop_target);
}
// --- FPS counter state ---
let fps_state = Rc::new(FpsCounter::new());
// --- Game loop ---
{
schedule_game_loop(
Rc::clone(&desktop),
drawing_area.clone(),
Rc::clone(&frame_for_draw),
screen.clone(),
fps_label.clone(),
Rc::clone(&fps_state),
Rc::clone(&scheduler),
);
}
@@ -401,16 +493,42 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
window.present();
}
struct FpsCounter {
frame_count: Cell<u32>,
last_update: Cell<Instant>,
}
impl FpsCounter {
fn new() -> Self {
Self {
frame_count: Cell::new(0),
last_update: Cell::new(Instant::now()),
}
}
fn tick(&self, label: &gtk::Label) {
self.frame_count.set(self.frame_count.get() + 1);
let now = Instant::now();
let elapsed = now.duration_since(self.last_update.get());
if elapsed.as_secs_f64() >= 1.0 {
let fps = self.frame_count.get() as f64 / elapsed.as_secs_f64();
label.set_label(&format!("{fps:.1} FPS"));
self.frame_count.set(0);
self.last_update.set(now);
}
}
}
fn schedule_game_loop(
desktop: Rc<RefCell<DesktopApp>>,
drawing_area: gtk::DrawingArea,
frame_for_draw: Rc<RefCell<Vec<u8>>>,
screen: NesScreen,
fps_label: gtk::Label,
fps_state: Rc<FpsCounter>,
scheduler: Rc<RefCell<DesktopFrameScheduler>>,
) {
let interval = desktop.borrow().frame_interval();
let delay = scheduler
.borrow_mut()
.delay_until_next_frame(Instant::now(), interval);
.delay_until_next_frame(Instant::now());
glib::timeout_add_local_once(delay, move || {
{
@@ -420,414 +538,29 @@ fn schedule_game_loop(
scheduler.borrow_mut().mark_frame_complete(now, interval);
app_state.tick();
screen.set_frame(app_state.frame_rgba());
frame_for_draw
.borrow_mut()
.copy_from_slice(app_state.frame_rgba());
drawing_area.queue_draw();
if fps_label.is_visible() {
fps_state.tick(&fps_label);
}
}
schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler);
schedule_game_loop(desktop, screen, fps_label, fps_state, scheduler);
});
}
fn show_error(window: &gtk::ApplicationWindow, message: &str) {
log::error!("{message}");
let dialog = gtk::AlertDialog::builder()
.modal(true)
.message("Error")
.detail(message)
.build();
dialog.show(Some(window));
}
fn rom_filename(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Unknown".into())
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
#[derive(Default)]
struct InputState {
buttons: JoypadButtons,
}
impl InputState {
fn set_key_state(&mut self, key: gdk::Key, pressed: bool) {
let button = match key {
gdk::Key::Up => JoypadButton::Up,
gdk::Key::Down => JoypadButton::Down,
gdk::Key::Left => JoypadButton::Left,
gdk::Key::Right => JoypadButton::Right,
gdk::Key::x | gdk::Key::X => JoypadButton::A,
gdk::Key::z | gdk::Key::Z => JoypadButton::B,
gdk::Key::Return => JoypadButton::Start,
gdk::Key::Shift_L | gdk::Key::Shift_R => JoypadButton::Select,
_ => return,
};
set_button_pressed(&mut self.buttons, button, pressed);
}
}
impl InputProvider for InputState {
fn poll_buttons(&mut self) -> JoypadButtons {
self.buttons
}
}
// ---------------------------------------------------------------------------
// Audio (cpal backend)
// ---------------------------------------------------------------------------
struct CpalAudioSink {
_stream: Option<cpal::Stream>,
ring: Arc<RingBuffer>,
_volume: Arc<AtomicU32>,
}
impl CpalAudioSink {
fn new(volume: Arc<AtomicU32>) -> Self {
let ring = Arc::new(RingBuffer::new(AUDIO_RING_CAPACITY));
let ring_for_cb = Arc::clone(&ring);
let vol_for_cb = Arc::clone(&volume);
let stream = Self::try_build_stream(ring_for_cb, vol_for_cb);
Self {
_stream: stream,
ring,
_volume: volume,
}
}
fn try_build_stream(ring: Arc<RingBuffer>, volume: Arc<AtomicU32>) -> Option<cpal::Stream> {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host();
let device = match host.default_output_device() {
Some(d) => d,
None => {
eprintln!("No audio output device found — running without sound");
return None;
}
};
let config = cpal_stream_config();
let stream = match device.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let read = ring.pop(data);
for sample in &mut data[read..] {
*sample = 0.0;
}
let vol = f32::from_bits(volume.load(AtomicOrdering::Relaxed));
for sample in &mut data[..read] {
*sample *= vol;
}
},
move |err| {
eprintln!("Audio stream error: {err}");
},
None,
) {
Ok(s) => s,
Err(err) => {
eprintln!("Failed to build audio stream: {err} — running without sound");
return None;
}
};
if let Err(err) = stream.play() {
eprintln!("Failed to start audio stream: {err} — running without sound");
return None;
}
Some(stream)
}
/// Reset the ring buffer. Note: the cpal callback may still be calling
/// `pop()` concurrently; in practice this is benign — at worst a few stale
/// samples are played during the ROM load / reset transition.
fn clear(&self) {
self.ring.clear();
}
}
impl nesemu::AudioOutput for CpalAudioSink {
fn push_samples(&mut self, samples: &[f32]) {
self.ring.push(samples);
}
}
#[cfg(test)]
fn audio_ring_latency_ms(capacity: usize, sample_rate: u32) -> f64 {
((capacity.saturating_sub(1)) as f64 / sample_rate as f64) * 1000.0
}
#[cfg(test)]
fn required_audio_ring_capacity(sample_rate: u32, mode: VideoMode) -> usize {
let samples_per_frame = (sample_rate as f64 / mode.frame_hz()).ceil() as usize;
samples_per_frame + AUDIO_CALLBACK_FRAMES as usize + 1
}
fn cpal_stream_config() -> cpal::StreamConfig {
cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES),
}
}
struct DesktopFrameScheduler {
next_deadline: Option<Instant>,
}
impl DesktopFrameScheduler {
fn new() -> Self {
Self {
next_deadline: None,
}
}
fn reset_timing(&mut self) {
self.next_deadline = None;
}
fn delay_until_next_frame(&mut self, now: Instant, _interval: Duration) -> Duration {
match self.next_deadline {
None => {
self.next_deadline = Some(now);
Duration::ZERO
}
Some(deadline) if now < deadline => deadline - now,
Some(_) => Duration::ZERO,
}
}
fn mark_frame_complete(&mut self, now: Instant, interval: Duration) {
let mut next_deadline = self.next_deadline.unwrap_or(now) + interval;
while next_deadline <= now {
next_deadline += interval;
}
self.next_deadline = Some(next_deadline);
}
}
struct BufferedVideo {
frame_rgba: Vec<u8>,
}
impl BufferedVideo {
fn new() -> Self {
Self {
frame_rgba: vec![0; FRAME_RGBA_BYTES],
}
}
fn frame_rgba(&self) -> &[u8] {
&self.frame_rgba
}
}
impl VideoOutput for BufferedVideo {
fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) {
if width != FRAME_WIDTH || height != FRAME_HEIGHT || frame.len() != FRAME_RGBA_BYTES {
return;
}
self.frame_rgba.copy_from_slice(frame);
}
}
// ---------------------------------------------------------------------------
// Application state
// ---------------------------------------------------------------------------
struct DesktopApp {
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
input: InputState,
audio: CpalAudioSink,
video: BufferedVideo,
state: EmulationState,
}
impl DesktopApp {
fn new(volume: Arc<AtomicU32>) -> Self {
Self {
host: None,
input: InputState::default(),
audio: CpalAudioSink::new(volume),
video: BufferedVideo::new(),
state: EmulationState::Paused,
}
}
fn load_rom_from_path(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let data = std::fs::read(path)?;
let runtime = NesRuntime::from_rom_bytes(&data)?;
let config = HostConfig::new(SAMPLE_RATE, false);
self.host = Some(RuntimeHostLoop::with_config(runtime, config));
self.audio.clear();
self.state = EmulationState::Running;
Ok(())
}
fn reset(&mut self) {
if let Some(host) = self.host.as_mut() {
host.runtime_mut().reset();
self.audio.clear();
self.state = EmulationState::Running;
}
}
fn is_loaded(&self) -> bool {
self.host.is_some()
}
fn state(&self) -> EmulationState {
self.state
}
fn toggle_pause(&mut self) {
self.state = match self.state {
EmulationState::Running => EmulationState::Paused,
EmulationState::Paused => EmulationState::Running,
_ => EmulationState::Paused,
};
}
fn tick(&mut self) {
if self.state != EmulationState::Running {
return;
}
let Some(host) = self.host.as_mut() else {
return;
};
match host.run_frame_unpaced(&mut self.input, &mut self.video, &mut self.audio) {
Ok(_) => {}
Err(err) => {
eprintln!("Frame execution error: {err}");
self.state = EmulationState::Paused;
return;
}
}
}
fn frame_rgba(&self) -> &[u8] {
self.video.frame_rgba()
}
fn frame_interval(&self) -> Duration {
self.host
.as_ref()
.map(|host| host.runtime().video_mode().frame_duration())
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
}
fn input_mut(&mut self) -> &mut InputState {
&mut self.input
}
}
#[cfg(test)]
mod tests {
use super::*;
use nesemu::{VideoOutput, FRAME_HEIGHT, FRAME_WIDTH};
use std::time::Instant;
#[test]
fn frame_scheduler_waits_until_frame_deadline() {
let mut scheduler = DesktopFrameScheduler::new();
let start = Instant::now();
let interval = Duration::from_micros(16_639);
assert_eq!(
scheduler.delay_until_next_frame(start, interval),
Duration::ZERO
);
scheduler.mark_frame_complete(start, interval);
assert!(
scheduler.delay_until_next_frame(start + Duration::from_millis(1), interval)
> Duration::ZERO
);
assert_eq!(
scheduler.delay_until_next_frame(start + interval, interval),
Duration::ZERO
);
}
#[test]
fn buffered_video_captures_presented_frame() {
let mut video = BufferedVideo::new();
let mut frame = vec![0u8; FRAME_RGBA_BYTES];
frame[0] = 0x12;
frame[1] = 0x34;
frame[2] = 0x56;
frame[3] = 0x78;
video.present_rgba(&frame, FRAME_WIDTH, FRAME_HEIGHT);
assert_eq!(video.frame_rgba(), frame.as_slice());
}
#[test]
fn frame_scheduler_reset_restarts_from_immediate_tick() {
let mut scheduler = DesktopFrameScheduler::new();
let start = Instant::now();
let interval = Duration::from_micros(16_639);
assert_eq!(
scheduler.delay_until_next_frame(start, interval),
Duration::ZERO
);
scheduler.mark_frame_complete(start, interval);
assert!(scheduler.delay_until_next_frame(start, interval) > Duration::ZERO);
scheduler.reset_timing();
assert_eq!(
scheduler.delay_until_next_frame(start, interval),
Duration::ZERO
);
}
#[test]
fn frame_scheduler_reports_zero_delay_when_late() {
let mut scheduler = DesktopFrameScheduler::new();
let start = Instant::now();
let interval = Duration::from_micros(16_639);
assert_eq!(
scheduler.delay_until_next_frame(start, interval),
Duration::ZERO
);
scheduler.mark_frame_complete(start, interval);
assert_eq!(
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2), interval),
Duration::ZERO
);
}
#[test]
fn desktop_audio_ring_budget_stays_below_25ms() {
let latency_ms = audio_ring_latency_ms(AUDIO_RING_CAPACITY, SAMPLE_RATE);
let max_budget_ms = 40.0;
assert!(
latency_ms <= max_budget_ms,
"desktop audio ring latency budget too high: {latency_ms:.2}ms"
);
}
#[test]
fn desktop_audio_uses_fixed_low_latency_callback_size() {
let config = cpal_stream_config();
assert_eq!(
config.buffer_size,
cpal::BufferSize::Fixed(AUDIO_CALLBACK_FRAMES)
);
}
#[test]
fn desktop_audio_ring_has_frame_burst_headroom() {
let required = required_audio_ring_capacity(SAMPLE_RATE, VideoMode::Ntsc);
assert!(
AUDIO_RING_CAPACITY >= required,
"audio ring too small for frame burst: capacity={}, required={required}",
AUDIO_RING_CAPACITY,
);
}
}

View File

@@ -0,0 +1,100 @@
use std::time::{Duration, Instant};
pub(crate) struct DesktopFrameScheduler {
next_deadline: Option<Instant>,
}
impl DesktopFrameScheduler {
pub(crate) fn new() -> Self {
Self {
next_deadline: None,
}
}
pub(crate) fn reset_timing(&mut self) {
self.next_deadline = None;
}
pub(crate) fn delay_until_next_frame(&mut self, now: Instant) -> Duration {
match self.next_deadline {
None => {
self.next_deadline = Some(now);
Duration::ZERO
}
Some(deadline) if now < deadline => deadline - now,
Some(_) => Duration::ZERO,
}
}
pub(crate) fn mark_frame_complete(&mut self, now: Instant, interval: Duration) {
let mut next_deadline = self.next_deadline.unwrap_or(now) + interval;
while next_deadline <= now {
next_deadline += interval;
}
self.next_deadline = Some(next_deadline);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_scheduler_waits_until_frame_deadline() {
let mut scheduler = DesktopFrameScheduler::new();
let start = Instant::now();
let interval = Duration::from_micros(16_639);
assert_eq!(
scheduler.delay_until_next_frame(start),
Duration::ZERO
);
scheduler.mark_frame_complete(start, interval);
assert!(
scheduler.delay_until_next_frame(start + Duration::from_millis(1))
> Duration::ZERO
);
assert_eq!(
scheduler.delay_until_next_frame(start + interval),
Duration::ZERO
);
}
#[test]
fn frame_scheduler_reset_restarts_from_immediate_tick() {
let mut scheduler = DesktopFrameScheduler::new();
let start = Instant::now();
let interval = Duration::from_micros(16_639);
assert_eq!(
scheduler.delay_until_next_frame(start),
Duration::ZERO
);
scheduler.mark_frame_complete(start, interval);
assert!(scheduler.delay_until_next_frame(start) > Duration::ZERO);
scheduler.reset_timing();
assert_eq!(
scheduler.delay_until_next_frame(start),
Duration::ZERO
);
}
#[test]
fn frame_scheduler_reports_zero_delay_when_late() {
let mut scheduler = DesktopFrameScheduler::new();
let start = Instant::now();
let interval = Duration::from_micros(16_639);
assert_eq!(
scheduler.delay_until_next_frame(start),
Duration::ZERO
);
scheduler.mark_frame_complete(start, interval);
assert_eq!(
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2)),
Duration::ZERO
);
}
}

View File

@@ -0,0 +1,131 @@
use std::cell::RefCell;
use gtk::gdk;
use gtk::glib;
use gtk::gsk;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk4 as gtk;
use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, VideoOutput};
pub(crate) struct BufferedVideo {
frame_rgba: Vec<u8>,
}
impl BufferedVideo {
pub(crate) fn new() -> Self {
Self {
frame_rgba: vec![0; FRAME_RGBA_BYTES],
}
}
pub(crate) fn frame_rgba(&self) -> &[u8] {
&self.frame_rgba
}
}
impl VideoOutput for BufferedVideo {
fn present_rgba(&mut self, frame: &[u8], width: usize, height: usize) {
if width != FRAME_WIDTH || height != FRAME_HEIGHT || frame.len() != FRAME_RGBA_BYTES {
return;
}
self.frame_rgba.copy_from_slice(frame);
}
}
// ---------------------------------------------------------------------------
// NesScreen — a custom GTK widget that renders a NES frame buffer on the GPU
// with nearest-neighbor (pixel-perfect) scaling via GskTextureScaleNode.
// ---------------------------------------------------------------------------
mod imp {
use super::*;
#[derive(Default)]
pub struct NesScreen {
pub(super) texture: RefCell<Option<gdk::Texture>>,
}
#[glib::object_subclass]
impl ObjectSubclass for NesScreen {
const NAME: &'static str = "NesScreen";
type Type = super::NesScreen;
type ParentType = gtk::Widget;
}
impl ObjectImpl for NesScreen {}
impl WidgetImpl for NesScreen {
fn snapshot(&self, snapshot: &gtk::Snapshot) {
let Some(texture) = self.texture.borrow().clone() else {
return;
};
let widget = self.obj();
let w = widget.width() as f32;
let h = widget.height() as f32;
if w <= 0.0 || h <= 0.0 {
return;
}
// Compute scale that fits the frame inside the widget, preserving
// aspect ratio.
let scale_x = w / FRAME_WIDTH as f32;
let scale_y = h / FRAME_HEIGHT as f32;
let scale = scale_x.min(scale_y);
let scaled_w = FRAME_WIDTH as f32 * scale;
let scaled_h = FRAME_HEIGHT as f32 * scale;
let offset_x = (w - scaled_w) / 2.0;
let offset_y = (h - scaled_h) / 2.0;
let bounds = gtk::graphene::Rect::new(offset_x, offset_y, scaled_w, scaled_h);
snapshot.append_scaled_texture(&texture, gsk::ScalingFilter::Nearest, &bounds);
}
}
}
glib::wrapper! {
pub struct NesScreen(ObjectSubclass<imp::NesScreen>)
@extends gtk::Widget;
}
impl NesScreen {
pub(crate) fn new() -> Self {
glib::Object::builder().build()
}
pub(crate) fn set_frame(&self, frame: &[u8]) {
let bytes = glib::Bytes::from(frame);
let stride = FRAME_WIDTH * 4;
let texture: gdk::Texture = gdk::MemoryTexture::new(
FRAME_WIDTH as i32,
FRAME_HEIGHT as i32,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
stride,
)
.upcast();
*self.imp().texture.borrow_mut() = Some(texture);
self.queue_draw();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buffered_video_captures_presented_frame() {
let mut video = BufferedVideo::new();
let mut frame = vec![0u8; FRAME_RGBA_BYTES];
frame[0] = 0x12;
frame[1] = 0x34;
frame[2] = 0x56;
frame[3] = 0x78;
video.present_rgba(&frame, FRAME_WIDTH, FRAME_HEIGHT);
assert_eq!(video.frame_rgba(), frame.as_slice());
}
}

View File

@@ -35,8 +35,13 @@ The main public API is organized around these groups:
- `CpuBus`
- `CpuError`
- `NativeBus`
- `Ppu`
- `Apu`
- `ApuStateTail`
- `ChannelOutputs`
- High-level runtime:
- `NesRuntime`
- `RuntimeError`
- Host execution and lifecycle:
- `RuntimeHostLoop`
- `ClientRuntime`
@@ -46,12 +51,23 @@ The main public API is organized around these groups:
- `InputProvider`
- `VideoOutput`
- `AudioOutput`
- Null/stub implementations:
- `NullInput`
- `NullVideo`
- `NullAudio`
- Audio helpers:
- `AudioMixer`
- `RingBuffer`
- Timing and pacing:
- `FrameClock`
- `FramePacer`
- `PacingClock`
- `NoopClock`
- `VideoMode`
- Video constants:
- `FRAME_WIDTH`
- `FRAME_HEIGHT`
- `FRAME_RGBA_BYTES`
- Input helpers:
- `JoypadButton`
- `JoypadButtons`
@@ -59,6 +75,8 @@ The main public API is organized around these groups:
- `JOYPAD_BUTTONS_COUNT`
- `set_button_pressed`
- `button_pressed`
- State versioning:
- `SAVE_STATE_VERSION`
## Supported Client Flow

View File

@@ -41,6 +41,10 @@ The workspace is split into four layers:
- `src/runtime/audio.rs`: interim PCM synthesis from core state
- `src/runtime/timing.rs`: frame pacing types and video timing
- `src/runtime/types.rs`: public joypad-related types and helpers
- `src/runtime/constants.rs`: frame dimensions and video constants
- `src/runtime/error.rs`: `RuntimeError` type definitions
- `src/runtime/ring_buffer.rs`: lock-free ring buffer for audio sample transport
- `src/runtime/adapters.rs`: adapter bridge types (behind `adapter-api` feature)
- `src/runtime/host/io.rs`: host IO traits and null implementations
- `src/runtime/host/executor.rs`: per-frame execution unit
- `src/runtime/host/clock.rs`: clock abstraction and pacing implementations

View File

@@ -200,9 +200,15 @@ impl Apu {
}
if self.dmc_bytes_remaining == 0 {
if (self.io[0x10] & 0x40) != 0 {
// Loop mode: reset address and byte counter.
// Do NOT request another DMA here — the sample buffer is full
// right now. clock_dmc will request the next fetch when the
// output unit empties the buffer into the shift register, which
// is the correct NES hardware behaviour (reader only fills an
// empty buffer). Requesting early would overwrite the valid
// buffer and skip the last byte of each loop iteration.
self.dmc_bytes_remaining = self.dmc_sample_length_bytes();
self.dmc_current_addr = self.dmc_sample_start_addr();
self.dmc_dma_request = true;
} else if self.dmc_irq_enabled {
self.dmc_irq_pending = true;
}
@@ -331,9 +337,13 @@ impl Apu {
};
let triangle = {
// Timer period < 2 produces ultrasonic output (~28-56 kHz) that aliases
// to audible frequencies when sampled at 48 kHz. Real hardware filters
// this via the RC output stage; mute here to match that behaviour.
let active = (self.channel_enable_mask & 0x04) != 0
&& self.length_counters[2] > 0
&& self.triangle_linear_counter > 0;
&& self.triangle_linear_counter > 0
&& self.triangle_timer_period() >= 2;
if active {
TRIANGLE_SEQUENCE[self.triangle_step as usize & 0x1F]
} else {
@@ -355,6 +365,6 @@ impl Apu {
let dmc = self.dmc_output_level;
ChannelOutputs { pulse1, pulse2, triangle, noise, dmc }
ChannelOutputs { pulse1, pulse2, triangle, noise, dmc, expansion: 0.0 }
}
}

View File

@@ -14,6 +14,9 @@ impl Apu {
status
}
pub(crate) fn clock_frame_counter(&mut self) {
if self.cpu_cycle_parity {
return;
}
let seq_len = if self.frame_mode_5step {
APU_FRAME_SEQ_5_STEP_CYCLES
} else {

View File

@@ -5,6 +5,11 @@ pub struct ChannelOutputs {
pub triangle: u8,
pub noise: u8,
pub dmc: u8,
/// Pre-mixed expansion audio from the cartridge mapper (VRC6, FME-7,
/// Namco163, etc.). Normalized to roughly the same amplitude range as
/// the internal NES APU output. Added linearly to the final sample
/// after the non-linear NES APU mixing stage.
pub expansion: f32,
}
pub(super) const APU_FRAME_SEQ_4_STEP_CYCLES: u32 = 14_915;

View File

@@ -61,7 +61,9 @@ impl NativeBus {
}
pub fn apu_channel_outputs(&self) -> crate::native_core::apu::ChannelOutputs {
self.apu.channel_outputs()
let mut outputs = self.apu.channel_outputs();
outputs.expansion = self.mapper.expansion_audio_sample();
outputs
}
pub fn render_frame(&self, out_rgba: &mut [u8], frame_number: u32, buttons: [bool; 8]) {

View File

@@ -29,6 +29,7 @@ impl CpuBus for NativeBus {
0x4015 => self.apu.read(addr),
0x4016 => self.joypad_read(),
0x4017 => self.joypad2_read(),
0x4020..=0x5FFF => self.mapper.cpu_read_low(addr).unwrap_or(self.cpu_open_bus),
0x6000..=0x7FFF => self.mapper.cpu_read_low(addr).unwrap_or(self.cpu_open_bus),
0x8000..=0xFFFF => self.mapper.cpu_read(addr),
_ => self.cpu_open_bus,
@@ -48,6 +49,9 @@ impl CpuBus for NativeBus {
let (ppu, mapper) = (&mut self.ppu, &mut self.mapper);
ppu.cpu_write(reg, value, &mut **mapper);
}
if reg == 0 {
self.mapper.notify_ppu_ctrl_write(value);
}
if reg == 0
&& !nmi_was_enabled
&& self.ppu.nmi_enabled()
@@ -77,6 +81,9 @@ impl CpuBus for NativeBus {
self.clock_cpu_cycles(513 + cpu_phase);
}
0x4016 => self.joypad_write(value),
0x4020..=0x5FFF => {
self.mapper.cpu_write_low(addr, value);
}
0x6000..=0x7FFF => {
self.mapper.cpu_write_low(addr, value);
}

View File

@@ -2,6 +2,20 @@ use super::NativeBus;
impl NativeBus {
pub fn set_joypad_buttons(&mut self, buttons: [bool; 8]) {
self.joypad_state = Self::encode_buttons(buttons);
if self.joypad_strobe {
self.joypad_shift = self.joypad_state;
}
}
pub fn set_joypad2_buttons(&mut self, buttons: [bool; 8]) {
self.joypad2_state = Self::encode_buttons(buttons);
if self.joypad_strobe {
self.joypad2_shift = self.joypad2_state;
}
}
fn encode_buttons(buttons: [bool; 8]) -> u8 {
let mut state = 0u8;
if buttons[4] {
state |= 1 << 0; // A
@@ -27,12 +41,7 @@ impl NativeBus {
if buttons[3] {
state |= 1 << 7; // Right
}
self.joypad_state = state;
self.joypad2_state = 0;
if self.joypad_strobe {
self.joypad_shift = self.joypad_state;
self.joypad2_shift = self.joypad2_state;
}
state
}
pub(super) fn joypad_read(&mut self) -> u8 {
@@ -67,8 +76,11 @@ impl NativeBus {
}
fn format_controller_read(&self, bit: u8) -> u8 {
// Controller reads expose serial data in bit0, keep bit6 high, and
// preserve open-bus upper bits.
(self.cpu_open_bus & 0xE0) | 0x40 | (bit & 1)
// The NES controller port drives only bit 0 (serial data); bit 6 is
// held high by a pull-up on the expansion connector. All other bits
// (1-5, 7) float and retain whatever is currently on the CPU data bus
// (open bus). !0x41 clears bits 6 and 0 so we can OR in their
// canonical values without corrupting any open-bus bits.
(self.cpu_open_bus & !0x41u8) | 0x40 | (bit & 1)
}
}

View File

@@ -5,7 +5,7 @@ fn apu_frame_irq_asserts_in_4_step_mode() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
for _ in 0..29_832u32 {
bus.clock_cpu(1);
}
@@ -17,7 +17,7 @@ fn reading_4015_clears_apu_frame_irq_flag() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
for _ in 0..29_832u32 {
bus.clock_cpu(1);
}
@@ -30,7 +30,7 @@ fn reading_4015_clears_apu_frame_irq_flag() {
fn apu_frame_irq_inhibit_bit_disables_irq_and_clears_pending() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
for _ in 0..29_832u32 {
bus.clock_cpu(1);
}
assert!(bus.poll_irq());
@@ -46,13 +46,13 @@ fn apu_frame_irq_inhibit_bit_disables_irq_and_clears_pending() {
fn writing_4015_does_not_acknowledge_apu_frame_irq() {
let mut bus = NativeBus::new(Box::new(StubMapper));
bus.write(0x4017, 0x00); // 4-step, IRQ enabled
for _ in 0..14_918u32 {
for _ in 0..29_832u32 {
bus.clock_cpu(1);
}
assert!(bus.poll_irq(), "frame IRQ must be pending");
// Recreate pending frame IRQ and ensure $4015 write does not clear it.
for _ in 0..14_918u32 {
for _ in 0..29_832u32 {
bus.clock_cpu(1);
}
bus.write(0x4015, 0x00);
@@ -183,11 +183,11 @@ fn apu_length_counter_decrements_on_half_frame_when_not_halted() {
bus.write(0x4003, 0x18); // length index 3 => value 2
assert_eq!(bus.apu.length_counters[0], 2);
for _ in 0..7_457u32 {
for _ in 0..14_913u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.length_counters[0], 1);
for _ in 0..7_458u32 {
for _ in 0..14_916u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.length_counters[0], 0);
@@ -218,13 +218,13 @@ fn quarter_frame_clocks_triangle_linear_counter() {
bus.write(0x4008, 0x05); // control=0, reload value=5
bus.write(0x400B, 0x00); // set reload flag
for _ in 0..3_729u32 {
for _ in 0..7_457u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.triangle_linear_counter, 5);
assert!(!bus.apu.triangle_linear_reload_flag);
for _ in 0..3_728u32 {
for _ in 0..7_456u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.triangle_linear_counter, 4);
@@ -238,7 +238,7 @@ fn quarter_frame_envelope_start_reloads_decay() {
bus.write(0x4003, 0x00); // start envelope
assert_ne!(bus.apu.envelope_start_flags & 0x01, 0);
for _ in 0..3_729u32 {
for _ in 0..7_457u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.envelope_decay[0], 15);
@@ -253,7 +253,7 @@ fn sweep_half_frame_updates_pulse_timer_period() {
bus.write(0x4003, 0x02); // timer high => period 0x200
bus.write(0x4001, 0x82); // enable, period=1, negate=0, shift=2
for _ in 0..7_457u32 {
for _ in 0..14_913u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.read(0x4002), 0x80);
@@ -267,7 +267,7 @@ fn sweep_negative_pulse1_uses_ones_complement() {
bus.write(0x4003, 0x02);
bus.write(0x4001, 0x8A); // enable, period=1, negate=1, shift=2
for _ in 0..7_457u32 {
for _ in 0..14_913u32 {
bus.clock_cpu(1);
}
assert_eq!(bus.apu.read(0x4002), 0x7F);

View File

@@ -5,8 +5,8 @@ fn prerender_scanline_still_clocks_mapper_scanline_irq() {
let mut bus = NativeBus::new(Box::new(ScanlineIrqMapper { irq_pending: false }));
bus.write(0x2001, 0x18); // enable rendering
bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE + 259;
bus.clock_ppu_dot(); // now at dot 260
bus.ppu_dot = PPU_PRERENDER_SCANLINE * PPU_DOTS_PER_SCANLINE + 1;
bus.clock_ppu_dot(); // now at dot 2, triggers clock_scanline
assert!(bus.poll_irq());
}

View File

@@ -34,12 +34,25 @@ impl NativeBus {
self.ppu_dot = 0;
self.frame_complete = true;
self.odd_frame = !self.odd_frame;
// Unconditional frame-boundary notification: mappers that maintain
// per-frame state (e.g. MMC5 scanline IRQ counter) must reset here
// regardless of whether rendering is currently enabled.
self.mapper.notify_frame_start();
}
let scanline = self.ppu_dot / PPU_DOTS_PER_SCANLINE;
let dot = self.ppu_dot % PPU_DOTS_PER_SCANLINE;
let rendering_enabled = self.ppu.rendering_enabled();
// Notify the mapper when PPU transitions between BG and sprite fetch phases.
if rendering_enabled && scanline < 240 {
if dot == 1 || dot == 321 {
self.mapper.notify_ppu_fetch_phase(false);
} else if dot == 257 {
self.mapper.notify_ppu_fetch_phase(true);
}
}
{
let mapper: &(dyn Mapper + Send) = &*self.mapper;
self.ppu.render_dot(mapper, scanline, dot);
@@ -62,7 +75,7 @@ impl NativeBus {
self.mmc3_a12_prev_high = false;
self.mmc3_a12_low_dots = self.mmc3_a12_low_dots.saturating_add(1);
}
} else if dot == 260 {
} else if dot == 2 {
self.mapper.clock_scanline();
}
} else {

View File

@@ -6,19 +6,52 @@ const MAPPER_STATE_SECTION_VERSION: u8 = 1;
pub trait Mapper {
fn cpu_read(&self, addr: u16) -> u8;
fn cpu_write(&mut self, addr: u16, value: u8);
fn cpu_read_low(&self, _addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, _addr: u16) -> Option<u8> {
None
}
fn cpu_write_low(&mut self, _addr: u16, _value: u8) -> bool {
false
}
fn ppu_read(&self, addr: u16) -> u8;
/// Read a CHR byte for sprite tile loading. Overridden by mappers (e.g.
/// MMC5) that use separate sprite and background CHR bank sets — sprite
/// loads must always use the sprite bank set regardless of the PPU's
/// current rendering phase. Default: delegates to `ppu_read`.
fn ppu_read_sprite(&self, addr: u16) -> u8 {
self.ppu_read(addr)
}
fn ppu_write(&mut self, addr: u16, value: u8);
fn mirroring(&self) -> Mirroring;
fn map_nametable_addr(&self, _addr: u16) -> Option<usize> {
None
}
/// Override a nametable read without going through PPU CIRAM. Return
/// `Some(byte)` when the mapper provides the data directly (e.g. MMC5
/// ExRAM or fill-mode nametables); `None` to fall back to the standard
/// CIRAM index returned by `map_nametable_addr` / `mirroring()`.
fn read_nametable_byte(&self, _addr: u16) -> Option<u8> {
None
}
/// Override a nametable write without going through PPU CIRAM. Return
/// `true` when the mapper has consumed the write; `false` to fall back to
/// the standard CIRAM write.
fn write_nametable_byte(&mut self, _addr: u16, _value: u8) -> bool {
false
}
/// Called when PPUCTRL ($2000) is written, so mappers can track bit 5 (8x16 sprite mode).
fn notify_ppu_ctrl_write(&mut self, _value: u8) {}
/// Notify the mapper about the current PPU fetch phase so it can select
/// the correct CHR bank set. Called by the bus at the phase transition
/// dots of visible scanlines (1 = BG, 257 = sprite, 321 = BG prefetch).
/// Default: no-op.
fn notify_ppu_fetch_phase(&mut self, _sprite_phase: bool) {}
fn clock_cpu(&mut self, _cycles: u8) {}
/// Called unconditionally when the PPU dot counter wraps to 0 (frame
/// boundary), regardless of whether rendering is enabled. Mappers that
/// maintain per-frame state (e.g. MMC5 scanline IRQ counter) use this to
/// perform a hard reset that is independent of the rendering-enabled gate
/// applied to `clock_scanline`.
fn notify_frame_start(&mut self) {}
fn clock_scanline(&mut self) {}
fn needs_ppu_a12_clock(&self) -> bool {
false
@@ -26,6 +59,14 @@ pub trait Mapper {
fn poll_irq(&mut self) -> bool {
false
}
/// Returns the current pre-mixed expansion audio sample for mappers that
/// include an on-cartridge sound chip (VRC6, FME-7/Sunsoft 5B, Namco163,
/// etc.). The value is already normalized so that its amplitude is
/// comparable to the internal NES APU output range. Default: 0.0
/// (no expansion audio).
fn expansion_audio_sample(&self) -> f32 {
0.0
}
fn save_state(&self, out: &mut Vec<u8>);
fn load_state(&mut self, data: &[u8]) -> Result<(), String>;
}

View File

@@ -16,4 +16,13 @@ pub(crate) struct Fme7 {
pub(super) irq_enabled: bool,
pub(super) irq_counter_enabled: bool,
pub(super) irq_pending: bool,
// Sunsoft 5B (YM2149 / AY-3-8910 compatible) expansion audio.
// Registers R0-R13 hold period, mixer, volume, and envelope config.
// Commands 0xC0-0xCF select audio register (low nibble).
pub(super) ay_regs: [u8; 16],
// Per-channel 12-bit period counter and current square-wave state.
pub(super) ay_timer: [u16; 3],
pub(super) ay_state: [bool; 3],
// Prescaler: the AY chip runs at CPU clock / 16.
pub(super) ay_prescaler: u8,
}

View File

@@ -193,7 +193,7 @@ impl Mapper for InesMapper105 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) && !self.wram_disabled {
let idx = (addr as usize) & 0x1FFF;
return self.prg_ram.get(idx).copied();

View File

@@ -21,7 +21,7 @@ impl Mapper for InesMapper118 {
self.mmc3.cpu_write(addr, value);
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
self.mmc3.cpu_read_low(addr)
}

View File

@@ -147,7 +147,7 @@ impl Mapper for InesMapper155 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) {
return self.prg_ram.get((addr as usize) & 0x1FFF).copied();
}

View File

@@ -25,7 +25,7 @@ impl Mapper for InesMapper253 {
self.base.cpu_write(addr, value);
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
self.base.cpu_read_low(addr)
}
@@ -103,6 +103,10 @@ impl Fme7 {
irq_enabled: false,
irq_counter_enabled: false,
irq_pending: false,
ay_regs: [0; 16],
ay_timer: [1; 3],
ay_state: [false; 3],
ay_prescaler: 0,
}
}
@@ -137,15 +141,21 @@ impl Mapper for Fme7 {
fn cpu_write(&mut self, addr: u16, value: u8) {
if (0x8000..=0x9FFF).contains(&addr) {
self.command = value & 0x0F;
self.command = value;
return;
}
if !(0xA000..=0xBFFF).contains(&addr) {
return;
}
match self.command {
0x0..=0x7 => self.chr_banks[self.command as usize] = value,
// Commands 0xC0-0xCF: Sunsoft 5B (AY-3-8910) audio registers.
if self.command >= 0xC0 {
self.ay_regs[(self.command & 0x0F) as usize] = value;
return;
}
match self.command & 0x0F {
0x0..=0x7 => self.chr_banks[(self.command & 0x0F) as usize] = value,
0x8 => {
self.low_bank = value & 0x3F;
self.low_is_ram = (value & 0x40) != 0;
@@ -181,7 +191,7 @@ impl Mapper for Fme7 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if !(0x6000..=0x7FFF).contains(&addr) {
return None;
}
@@ -238,19 +248,57 @@ impl Mapper for Fme7 {
}
fn clock_cpu(&mut self, cycles: u8) {
if !self.irq_counter_enabled {
return;
}
for _ in 0..cycles {
if self.irq_counter == 0 {
self.irq_counter = 0xFFFF;
if self.irq_enabled {
self.irq_pending = true;
if self.irq_counter_enabled {
for _ in 0..cycles {
if self.irq_counter == 0 {
self.irq_counter = 0xFFFF;
if self.irq_enabled {
self.irq_pending = true;
}
} else {
self.irq_counter = self.irq_counter.wrapping_sub(1);
}
} else {
self.irq_counter = self.irq_counter.wrapping_sub(1);
}
}
// Sunsoft 5B AY-3-8910 timer: chip runs at CPU clock / 16.
// Each time the prescaler wraps, tick all three tone channels.
for _ in 0..cycles {
self.ay_prescaler = self.ay_prescaler.wrapping_add(1);
if self.ay_prescaler < 16 {
continue;
}
self.ay_prescaler = 0;
for ch in 0..3usize {
let period = {
let lo = self.ay_regs[ch * 2] as u16;
let hi = (self.ay_regs[ch * 2 + 1] & 0x0F) as u16;
let p = (hi << 8) | lo;
if p == 0 { 1 } else { p }
};
if self.ay_timer[ch] == 0 {
self.ay_timer[ch] = period;
self.ay_state[ch] = !self.ay_state[ch];
} else {
self.ay_timer[ch] -= 1;
}
}
}
}
fn expansion_audio_sample(&self) -> f32 {
// Mixer register R7: bits 2:0 are tone-disable flags (0 = enabled).
let mixer = self.ay_regs[7];
let mut sample = 0.0f32;
for ch in 0..3usize {
let tone_enabled = (mixer >> ch) & 1 == 0;
if tone_enabled && self.ay_state[ch] {
let volume = (self.ay_regs[8 + ch] & 0x0F) as f32;
// Scale similarly to a NES pulse channel.
sample += volume * 0.00752;
}
}
sample
}
fn poll_irq(&mut self) -> bool {
@@ -271,12 +319,14 @@ impl Mapper for Fme7 {
out.push(u8::from(self.irq_counter_enabled));
out.push(u8::from(self.irq_pending));
out.push(encode_mirroring(self.mirroring));
out.extend_from_slice(&self.ay_regs);
write_state_bytes(out, &self.low_ram);
write_chr_state(out, &self.chr_data);
}
fn load_state(&mut self, data: &[u8]) -> Result<(), String> {
if data.len() < 21 {
// 21 original + 16 ay_regs bytes
if data.len() < 21 + 16 {
return Err("mapper state is truncated".to_string());
}
let mut cursor = 0usize;
@@ -302,6 +352,8 @@ impl Mapper for Fme7 {
cursor += 1;
self.mirroring = decode_mirroring(data[cursor]);
cursor += 1;
self.ay_regs.copy_from_slice(&data[cursor..cursor + 16]);
cursor += 16;
let low_ram_payload = read_state_bytes(data, &mut cursor)?;
if low_ram_payload.len() != self.low_ram.len() {
return Err("mapper state does not match loaded ROM".to_string());

View File

@@ -144,7 +144,7 @@ impl Mapper for Mmc3 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) {
if self.prg_ram_enabled {
Some(self.prg_ram[(addr as usize) - 0x6000])

View File

@@ -21,6 +21,14 @@ pub(crate) struct Mmc5 {
irq_enable: bool,
irq_pending: bool,
irq_cycles: u32,
irq_scanline_counter: u8,
ex_ram: [u8; 0x400],
ex_ram_mode: u8,
fill_tile: u8,
fill_attr: u8,
bg_chr_lo_banks: [u16; 4],
sprite_fetch_phase: bool,
sprite_8x16: bool,
}
impl Mmc5 {
@@ -53,6 +61,14 @@ impl Mmc5 {
irq_enable: false,
irq_pending: false,
irq_cycles: 0,
irq_scanline_counter: 0,
ex_ram: [0; 0x400],
ex_ram_mode: 0,
fill_tile: 0,
fill_attr: 0,
bg_chr_lo_banks: [0, 1, 2, 3],
sprite_fetch_phase: false,
sprite_8x16: false,
}
}
@@ -92,24 +108,27 @@ impl Mmc5 {
let mut banks = [0usize; 8];
match self.chr_mode & 0x03 {
0 => {
let base = (self.chr_banks_1k[7] as usize) & !7;
// 8KB mode: register holds 8KB bank number; convert to 1KB page index.
let base = (self.chr_banks_1k[7] as usize) << 3;
for (i, bank) in banks.iter_mut().enumerate() {
*bank = base + i;
}
}
1 => {
let b0 = (self.chr_banks_1k[3] as usize) & !3;
let b1 = (self.chr_banks_1k[7] as usize) & !3;
// 4KB mode: registers hold 4KB bank numbers; convert to 1KB page index.
let b0 = (self.chr_banks_1k[3] as usize) << 2;
let b1 = (self.chr_banks_1k[7] as usize) << 2;
for i in 0..4usize {
banks[i] = b0 + i;
banks[i + 4] = b1 + i;
}
}
2 => {
let b0 = (self.chr_banks_1k[1] as usize) & !1;
let b1 = (self.chr_banks_1k[3] as usize) & !1;
let b2 = (self.chr_banks_1k[5] as usize) & !1;
let b3 = (self.chr_banks_1k[7] as usize) & !1;
// 2KB mode: registers hold 2KB bank numbers; convert to 1KB page index.
let b0 = (self.chr_banks_1k[1] as usize) << 1;
let b1 = (self.chr_banks_1k[3] as usize) << 1;
let b2 = (self.chr_banks_1k[5] as usize) << 1;
let b3 = (self.chr_banks_1k[7] as usize) << 1;
banks[0] = b0;
banks[1] = b0 + 1;
banks[2] = b1;
@@ -128,10 +147,51 @@ impl Mmc5 {
banks[page]
}
fn bg_chr_lo_bank_for_page(&self, page: usize) -> usize {
// page is 0-7 (full CHR window). In 8x16 mode, BG banks cover all 8 pages.
// Modes 1-3: upper 4KB ($1000-$1FFF, pages 4-7) mirrors lower 4KB — use page & 3.
// Mode 0 (8KB): sequential, no mirroring.
match self.chr_mode & 0x03 {
0 => {
// 8KB: $512B holds 8KB bank number; convert to 1KB page index.
let base = (self.bg_chr_lo_banks[3] as usize) << 3;
base + page
}
1 => {
// 4KB: $512B holds 4KB bank number; upper 4KB mirrors lower 4KB.
let base = (self.bg_chr_lo_banks[3] as usize) << 2;
base + (page & 3)
}
2 => {
// 2KB: $5129 → pages 0-1 and 4-5, $512B → pages 2-3 and 6-7.
let p = page & 3;
if p < 2 {
let base = (self.bg_chr_lo_banks[1] as usize) << 1;
base + (p & 1)
} else {
let base = (self.bg_chr_lo_banks[3] as usize) << 1;
base + (p & 1)
}
}
_ => {
// 1KB: pages 4-7 mirror pages 0-3 via bg_chr_lo_banks[page & 3].
self.bg_chr_lo_banks[page & 3] as usize
}
}
}
fn ram_writable(&self) -> bool {
self.ram_protect_1 == 0x02 && self.ram_protect_2 == 0x01
}
fn nt_slot_type(&self, addr: u16) -> (u8, usize) {
let rel = addr.wrapping_sub(0x2000) & 0x0FFF;
let slot = (rel / 0x400) as usize;
let offset = (rel & 0x3FF) as usize;
let nt_type = (self.nt_mapping >> (slot * 2)) & 0x03;
(nt_type, offset)
}
fn decode_mirroring(&self) -> Mirroring {
let nt0 = self.nt_mapping & 0x03;
let nt1 = (self.nt_mapping >> 2) & 0x03;
@@ -170,20 +230,25 @@ impl Mapper for Mmc5 {
fn cpu_write(&mut self, _addr: u16, _value: u8) {}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
match addr {
0x5104 => Some(self.ex_ram_mode & 0x03),
0x5204 => {
let mut status = 0u8;
if self.irq_pending {
status |= 0x80;
self.irq_pending = false; // reading $5204 acknowledges the IRQ
}
if self.irq_enable {
// Bit 6 = in-frame flag: 1 while PPU renders visible scanlines (0-239).
// irq_scanline_counter is 0 during vblank and 1-240 during rendering.
if self.irq_scanline_counter != 0 {
status |= 0x40;
}
Some(status)
}
0x5205 => Some(((self.multiplier_a as u16 * self.multiplier_b as u16) & 0x00FF) as u8),
0x5206 => Some(((self.multiplier_a as u16 * self.multiplier_b as u16) >> 8) as u8),
0x5C00..=0x5FFF => Some(self.ex_ram[(addr - 0x5C00) as usize]),
0x6000..=0x7FFF => {
let bank = (self.prg_ram_bank & 0x07) as usize;
let idx = bank * 0x2000 + ((addr as usize) & 0x1FFF);
@@ -199,7 +264,14 @@ impl Mapper for Mmc5 {
0x5101 => self.chr_mode = value & 0x03,
0x5102 => self.ram_protect_1 = value & 0x03,
0x5103 => self.ram_protect_2 = value & 0x03,
0x5104 => self.ex_ram_mode = value & 0x03,
0x5105 => self.nt_mapping = value,
0x5106 => self.fill_tile = value,
0x5107 => {
// bits [1:0] = palette; replicated into all 4 quadrants of the attribute byte
let p = value & 0x03;
self.fill_attr = p | (p << 2) | (p << 4) | (p << 6);
}
0x5113 => self.prg_ram_bank = value & 0x07,
0x5114..=0x5117 => {
let reg = (addr - 0x5114) as usize;
@@ -213,9 +285,7 @@ impl Mapper for Mmc5 {
0x5128..=0x512B => {
let reg = (addr - 0x5128) as usize;
let bank = (((self.chr_upper_bits & 0x03) as u16) << 8) | value as u16;
let base = reg * 2;
self.chr_banks_1k[base] = bank & !1;
self.chr_banks_1k[base + 1] = (bank & !1).wrapping_add(1);
self.bg_chr_lo_banks[reg] = bank;
}
0x5130 => self.chr_upper_bits = value & 0x03,
0x5203 => self.irq_scanline = value,
@@ -227,6 +297,12 @@ impl Mapper for Mmc5 {
}
0x5205 => self.multiplier_a = value,
0x5206 => self.multiplier_b = value,
0x5C00..=0x5FFF => {
// ExRAM CPU write: allowed in modes 0, 1, and 2
if self.ex_ram_mode < 3 {
self.ex_ram[(addr - 0x5C00) as usize] = value;
}
}
0x6000..=0x7FFF => {
if self.ram_writable() {
let bank = (self.prg_ram_bank & 0x07) as usize;
@@ -246,6 +322,26 @@ impl Mapper for Mmc5 {
return 0;
}
let page = (addr / 0x0400) as usize;
// BG/sprite split only applies in 8x16 sprite mode.
// In 8x8 mode, all CHR uses $5120-$5127.
// In 8x16 BG mode, all 8 pages use bg_chr_lo_banks (pages 4-7 mirror pages 0-3
// in modes 1-3; mode 0 uses a full sequential 8KB block).
let raw_bank = if self.sprite_8x16 && !self.sprite_fetch_phase {
self.bg_chr_lo_bank_for_page(page)
} else {
self.chr_bank_1k_for_page(page)
};
let bank = safe_mod(raw_bank, self.chr_bank_count_1k());
read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF)
}
fn ppu_read_sprite(&self, addr: u16) -> u8 {
if addr > 0x1FFF {
return 0;
}
// Sprite tile loads always use the sprite CHR bank set ($5120-$5127),
// regardless of the current sprite_fetch_phase flag.
let page = (addr / 0x0400) as usize;
let bank = safe_mod(self.chr_bank_1k_for_page(page), self.chr_bank_count_1k());
read_bank(&self.chr_data, 0x0400, bank, (addr as usize) & 0x03FF)
}
@@ -266,22 +362,85 @@ impl Mapper for Mmc5 {
self.decode_mirroring()
}
fn clock_cpu(&mut self, cycles: u8) {
if !self.irq_enable {
return;
}
self.irq_cycles = self.irq_cycles.saturating_add(cycles as u32);
let threshold = (self.irq_scanline as u32 + 1).saturating_mul(113);
if threshold != 0 && self.irq_cycles >= threshold {
self.irq_cycles %= threshold;
self.irq_pending = true;
fn map_nametable_addr(&self, addr: u16) -> Option<usize> {
let (nt_type, offset) = self.nt_slot_type(addr);
match nt_type {
0 => Some(offset), // CIRAM bank 0
1 => Some(0x400 + offset), // CIRAM bank 1
_ => None, // ExRAM / fill — handled by read/write_nametable_byte
}
}
fn read_nametable_byte(&self, addr: u16) -> Option<u8> {
let (nt_type, offset) = self.nt_slot_type(addr);
match nt_type {
2 => {
// ExRAM as nametable (modes 0 and 1 only)
if self.ex_ram_mode < 2 {
Some(self.ex_ram[offset & 0x3FF])
} else {
Some(0)
}
}
3 => {
// Fill mode: attribute table area starts at offset 0x3C0
if offset >= 0x3C0 {
Some(self.fill_attr)
} else {
Some(self.fill_tile)
}
}
_ => None, // CIRAM: handled by map_nametable_addr
}
}
fn write_nametable_byte(&mut self, addr: u16, value: u8) -> bool {
let (nt_type, offset) = self.nt_slot_type(addr);
match nt_type {
2 => {
if self.ex_ram_mode < 2 {
self.ex_ram[offset & 0x3FF] = value;
}
true // Intercept regardless so CIRAM is not written
}
3 => true, // Fill mode: writes are discarded
_ => false, // CIRAM: let PPU handle it
}
}
fn notify_ppu_fetch_phase(&mut self, sprite_phase: bool) {
self.sprite_fetch_phase = sprite_phase;
}
fn notify_ppu_ctrl_write(&mut self, value: u8) {
self.sprite_8x16 = (value & 0x20) != 0;
}
fn notify_frame_start(&mut self) {
// Hard reset at frame boundary, called unconditionally regardless of
// rendering state. This is the authoritative counter reset — it fires
// even when the PPU is disabled (e.g. during room transitions), which
// prevents stale counter values from causing IRQs on wrong scanlines.
self.irq_scanline_counter = 0;
}
fn clock_scanline(&mut self) {
// Called at dot 2 of each visible and prerender scanline.
// Counter is already 0 at the start of a new frame (reset by
// notify_frame_start), so after 240 visible + 1 prerender calls
// it reaches 241 before the next notify_frame_start resets it.
//
// irq_scanline=0 never fires (special "disabled" sentinel).
if self.irq_enable && self.irq_scanline != 0 && self.irq_scanline_counter == self.irq_scanline {
self.irq_pending = true;
}
self.irq_scanline_counter = self.irq_scanline_counter.saturating_add(1);
}
fn poll_irq(&mut self) -> bool {
let out = self.irq_pending;
self.irq_pending = false;
out
// MMC5 IRQ line stays asserted until explicitly acknowledged by reading $5204.
// Do NOT clear irq_pending here; clearing happens in cpu_read_low($5204).
self.irq_pending
}
fn save_state(&self, out: &mut Vec<u8>) {
@@ -302,12 +461,19 @@ impl Mapper for Mmc5 {
for bank in self.chr_banks_1k {
out.extend_from_slice(&bank.to_le_bytes());
}
out.push(self.ex_ram_mode);
out.push(self.fill_tile);
out.push(self.fill_attr);
out.extend_from_slice(&self.ex_ram);
out.extend_from_slice(&self.bg_chr_lo_banks.iter().flat_map(|b| b.to_le_bytes()).collect::<Vec<_>>());
out.push(self.irq_scanline_counter);
out.push(u8::from(self.sprite_8x16));
write_state_bytes(out, &self.prg_ram);
write_chr_state(out, &self.chr_data);
}
fn load_state(&mut self, data: &[u8]) -> Result<(), String> {
if data.len() < 23 + 16 {
if data.len() < 39 {
return Err("mapper state is truncated".to_string());
}
let mut cursor = 0usize;
@@ -348,6 +514,30 @@ impl Mapper for Mmc5 {
self.chr_banks_1k[i] = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
}
if cursor + 3 + 0x400 <= data.len() {
self.ex_ram_mode = data[cursor];
cursor += 1;
self.fill_tile = data[cursor];
cursor += 1;
self.fill_attr = data[cursor];
cursor += 1;
self.ex_ram.copy_from_slice(&data[cursor..cursor + 0x400]);
cursor += 0x400;
}
if cursor + 8 <= data.len() {
for i in 0..4usize {
self.bg_chr_lo_banks[i] = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
}
}
if cursor < data.len() {
self.irq_scanline_counter = data[cursor];
cursor += 1;
}
if cursor < data.len() {
self.sprite_8x16 = data[cursor] != 0;
cursor += 1;
}
let prg_ram_payload = read_state_bytes(data, &mut cursor)?;
if prg_ram_payload.len() != self.prg_ram.len() {
return Err("mapper state does not match loaded ROM".to_string());

View File

@@ -12,6 +12,12 @@ pub(crate) struct Namco163_19 {
irq_counter: u16,
irq_enabled: bool,
irq_pending: bool,
// Namco163 wavetable audio. Each active channel has a 24-bit phase
// accumulator. Channels 7..7-N+1 are active (N from audio_ram[0x7F]).
// Phase increments by the 18-bit frequency value every 15 CPU cycles
// per active channel (chip cycles sequentially through all channels).
namco_phase: [u32; 8],
namco_cycle: u16,
}
impl Namco163_19 {
@@ -28,6 +34,8 @@ impl Namco163_19 {
irq_counter: 0,
irq_enabled: false,
irq_pending: false,
namco_phase: [0; 8],
namco_cycle: 0,
}
}
@@ -82,7 +90,7 @@ impl Mapper for Namco163_19 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
match addr {
0x4800..=0x487F => Some(self.audio_ram[(addr as usize) & 0x7F]),
0x5000 => Some((self.irq_counter & 0x00FF) as u8),
@@ -144,14 +152,64 @@ impl Mapper for Namco163_19 {
}
fn clock_cpu(&mut self, cycles: u8) {
if !self.irq_enabled {
return;
if self.irq_enabled {
let sum = self.irq_counter as u32 + cycles as u32;
if sum > 0x7FFF {
self.irq_pending = true;
}
self.irq_counter = (sum as u16) & 0x7FFF;
}
let sum = self.irq_counter as u32 + cycles as u32;
if sum > 0x7FFF {
self.irq_pending = true;
// Namco163 audio: the chip cycles through all active channels, clocking
// one channel every 15 CPU cycles. When all channels have been clocked
// once, each channel's phase has advanced by its 18-bit frequency value.
let num_active = ((self.audio_ram[0x7F] >> 4) & 0x07) as u16 + 1;
let period = 15 * num_active;
for _ in 0..cycles {
self.namco_cycle += 1;
if self.namco_cycle >= period {
self.namco_cycle = 0;
for j in 0..num_active as usize {
// Channel j registers start at audio_ram[0x40 + j*8].
let base = 0x40 + j * 8;
let freq = (self.audio_ram[base] as u32)
| ((self.audio_ram[base + 2] as u32) << 8)
| (((self.audio_ram[base + 4] & 0x03) as u32) << 16);
self.namco_phase[j] =
(self.namco_phase[j] + freq) & 0x00FF_FFFF;
}
}
}
self.irq_counter = (sum as u16) & 0x7FFF;
}
fn expansion_audio_sample(&self) -> f32 {
let num_active = ((self.audio_ram[0x7F] >> 4) & 0x07) as usize + 1;
let mut output = 0.0f32;
for j in 0..num_active {
let base = 0x40 + j * 8;
// Wave length is stored in the upper 6 bits of the byte at base+4,
// encoded as (256 - wave_nibbles): value 0 → 256 nibbles.
let len_raw = (self.audio_ram[base + 4] >> 2) as u16;
let wave_len = if len_raw == 0 { 256u16 } else { 256 - len_raw * 4 };
let wave_len = wave_len.max(1);
let wave_addr = self.audio_ram[base + 6] as u16;
let volume = (self.audio_ram[base + 7] & 0x0F) as f32;
// Current position in the waveform (nibble index).
let nibble_pos = ((self.namco_phase[j] >> 16) as u16 % wave_len + wave_addr)
& 0xFF;
let byte = self.audio_ram[(nibble_pos / 2) as usize];
let nibble = if nibble_pos & 1 == 0 {
byte & 0x0F
} else {
(byte >> 4) & 0x0F
} as f32;
// Centre at 8 (DC = 0), scale by volume, normalize.
output += (nibble - 8.0) * volume / (15.0 * num_active as f32);
}
// Scale to NES amplitude range.
output * 0.02
}
fn poll_irq(&mut self) -> bool {

View File

@@ -33,7 +33,7 @@ impl Mapper for Nrom {
fn cpu_write(&mut self, _addr: u16, _value: u8) {}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
let _ = addr;
None
}

View File

@@ -163,7 +163,7 @@ impl Mapper for Tqrom119 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) {
if self.prg_ram_enabled {
Some(self.prg_ram[(addr as usize) - 0x6000])

View File

@@ -159,7 +159,7 @@ impl Vrc2_23 {
}
impl Mapper for Vrc2_23 {
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) && !self.prg_ram.is_empty() {
Some(self.prg_ram[(addr as usize - 0x6000) % self.prg_ram.len()])
} else {

View File

@@ -18,6 +18,27 @@ pub(crate) struct Vrc6_24 {
irq_mode_cpu: bool,
irq_pending: bool,
irq_prescaler: i16,
// VRC6 expansion audio — 2 pulse channels + 1 sawtooth channel.
// Pulse channel n: 12-bit period timer, 4-bit volume, 3-bit duty (0-7),
// mode flag (ignore duty → always output), gate (enabled) flag.
// Timer decrements each CPU cycle; at 0 reload and advance duty_step (0-15).
// Output: if mode OR duty_step <= duty → volume, else 0.
vrc6_pulse_period: [u16; 2],
vrc6_pulse_counter: [u16; 2],
vrc6_pulse_duty_step: [u8; 2],
vrc6_pulse_duty: [u8; 2],
vrc6_pulse_volume: [u8; 2],
vrc6_pulse_mode: [bool; 2],
vrc6_pulse_enabled: [bool; 2],
// Sawtooth channel: 12-bit period timer, 6-bit accumulator rate.
// Step counter 0-6; on steps 1/3/5 accumulator += rate; on step 6 reset.
// Output: accumulator >> 3.
vrc6_saw_period: u16,
vrc6_saw_counter: u16,
vrc6_saw_step: u8,
vrc6_saw_accumulator: u8,
vrc6_saw_rate: u8,
vrc6_saw_enabled: bool,
}
impl Vrc6_24 {
@@ -44,6 +65,19 @@ impl Vrc6_24 {
irq_mode_cpu: false,
irq_pending: false,
irq_prescaler: 341,
vrc6_pulse_period: [0; 2],
vrc6_pulse_counter: [0; 2],
vrc6_pulse_duty_step: [0; 2],
vrc6_pulse_duty: [0; 2],
vrc6_pulse_volume: [0; 2],
vrc6_pulse_mode: [false; 2],
vrc6_pulse_enabled: [false; 2],
vrc6_saw_period: 0,
vrc6_saw_counter: 0,
vrc6_saw_step: 0,
vrc6_saw_accumulator: 0,
vrc6_saw_rate: 0,
vrc6_saw_enabled: false,
}
}
@@ -118,7 +152,47 @@ impl Mapper for Vrc6_24 {
}
match self.decode_register(addr) {
0x8000..=0x8003 => self.prg_bank_16k = value & 0x0F,
// VRC6 pulse 1 registers ($9000-$9002)
0x9000 => {
self.vrc6_pulse_mode[0] = (value & 0x80) != 0;
self.vrc6_pulse_duty[0] = (value >> 4) & 0x07;
self.vrc6_pulse_volume[0] = value & 0x0F;
}
0x9001 => {
self.vrc6_pulse_period[0] =
(self.vrc6_pulse_period[0] & 0x0F00) | value as u16;
}
0x9002 => {
self.vrc6_pulse_enabled[0] = (value & 0x80) != 0;
self.vrc6_pulse_period[0] =
(self.vrc6_pulse_period[0] & 0x00FF) | (((value & 0x0F) as u16) << 8);
}
0x9003 => self.control = value,
// VRC6 pulse 2 registers ($A000-$A002)
0xA000 => {
self.vrc6_pulse_mode[1] = (value & 0x80) != 0;
self.vrc6_pulse_duty[1] = (value >> 4) & 0x07;
self.vrc6_pulse_volume[1] = value & 0x0F;
}
0xA001 => {
self.vrc6_pulse_period[1] =
(self.vrc6_pulse_period[1] & 0x0F00) | value as u16;
}
0xA002 => {
self.vrc6_pulse_enabled[1] = (value & 0x80) != 0;
self.vrc6_pulse_period[1] =
(self.vrc6_pulse_period[1] & 0x00FF) | (((value & 0x0F) as u16) << 8);
}
// VRC6 sawtooth registers ($B000-$B002)
0xB000 => self.vrc6_saw_rate = value & 0x3F,
0xB001 => {
self.vrc6_saw_period = (self.vrc6_saw_period & 0x0F00) | value as u16;
}
0xB002 => {
self.vrc6_saw_enabled = (value & 0x80) != 0;
self.vrc6_saw_period =
(self.vrc6_saw_period & 0x00FF) | (((value & 0x0F) as u16) << 8);
}
0xC000..=0xC003 => self.prg_bank_8k = value & 0x1F,
0xD000 => self.chr_banks_1k[0] = value,
0xD001 => self.chr_banks_1k[1] = value,
@@ -135,7 +209,7 @@ impl Mapper for Vrc6_24 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) && (self.control & 0x80) != 0 {
Some(self.prg_ram[(addr as usize) - 0x6000])
} else {
@@ -193,6 +267,65 @@ impl Mapper for Vrc6_24 {
fn clock_cpu(&mut self, cycles: u8) {
vrc_irq_clock(cycles, self.irq_state());
for _ in 0..cycles {
// Pulse channels
for i in 0..2usize {
if !self.vrc6_pulse_enabled[i] {
continue;
}
if self.vrc6_pulse_counter[i] == 0 {
self.vrc6_pulse_counter[i] = self.vrc6_pulse_period[i].max(1);
self.vrc6_pulse_duty_step[i] = (self.vrc6_pulse_duty_step[i] + 1) & 0x0F;
} else {
self.vrc6_pulse_counter[i] -= 1;
}
}
// Sawtooth channel
if self.vrc6_saw_enabled {
if self.vrc6_saw_counter == 0 {
self.vrc6_saw_counter = self.vrc6_saw_period.max(1);
self.vrc6_saw_step += 1;
match self.vrc6_saw_step {
1 | 3 | 5 => {
self.vrc6_saw_accumulator =
self.vrc6_saw_accumulator.wrapping_add(self.vrc6_saw_rate);
}
6 => {
self.vrc6_saw_accumulator = 0;
self.vrc6_saw_step = 0;
}
_ => {}
}
} else {
self.vrc6_saw_counter -= 1;
}
}
}
}
fn expansion_audio_sample(&self) -> f32 {
// Pulse 1 & 2: 4-bit output (0-15), scaled like NES pulse channels.
let mut sample = 0.0f32;
for i in 0..2usize {
if self.vrc6_pulse_enabled[i] {
let raw = if self.vrc6_pulse_mode[i]
|| self.vrc6_pulse_duty_step[i] <= self.vrc6_pulse_duty[i]
{
self.vrc6_pulse_volume[i] as f32
} else {
0.0
};
// Scale to match NES pulse level (0.00752 * 15 ≈ 0.113 max per channel).
sample += raw * 0.00752;
}
}
// Sawtooth: accumulator >> 3 gives a 0-23 range; scale comparably.
if self.vrc6_saw_enabled {
let raw = (self.vrc6_saw_accumulator >> 3) as f32;
sample += raw * 0.00752;
}
sample
}
fn poll_irq(&mut self) -> bool {
@@ -214,12 +347,30 @@ impl Mapper for Vrc6_24 {
out.push(u8::from(self.irq_mode_cpu));
out.push(u8::from(self.irq_pending));
out.extend_from_slice(&self.irq_prescaler.to_le_bytes());
// VRC6 expansion audio state (24 bytes)
for i in 0..2 {
out.extend_from_slice(&self.vrc6_pulse_period[i].to_le_bytes());
out.extend_from_slice(&self.vrc6_pulse_counter[i].to_le_bytes());
out.push(self.vrc6_pulse_duty_step[i]);
out.push(self.vrc6_pulse_duty[i]);
out.push(self.vrc6_pulse_volume[i]);
out.push(
u8::from(self.vrc6_pulse_mode[i]) | (u8::from(self.vrc6_pulse_enabled[i]) << 1),
);
}
out.extend_from_slice(&self.vrc6_saw_period.to_le_bytes());
out.extend_from_slice(&self.vrc6_saw_counter.to_le_bytes());
out.push(self.vrc6_saw_step);
out.push(self.vrc6_saw_accumulator);
out.push(self.vrc6_saw_rate);
out.push(u8::from(self.vrc6_saw_enabled));
write_state_bytes(out, &self.prg_ram);
write_chr_state(out, &self.chr_data);
}
fn load_state(&mut self, data: &[u8]) -> Result<(), String> {
if data.len() < 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 2 {
// 20 fixed + 24 VRC6 audio bytes
if data.len() < 1 + 1 + 1 + 8 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 2 + 24 {
return Err("mapper state is truncated".to_string());
}
let mut cursor = 0usize;
@@ -247,6 +398,37 @@ impl Mapper for Vrc6_24 {
cursor += 1;
self.irq_prescaler = i16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
// VRC6 expansion audio state
for i in 0..2 {
self.vrc6_pulse_period[i] =
u16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
self.vrc6_pulse_counter[i] =
u16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
self.vrc6_pulse_duty_step[i] = data[cursor];
cursor += 1;
self.vrc6_pulse_duty[i] = data[cursor];
cursor += 1;
self.vrc6_pulse_volume[i] = data[cursor];
cursor += 1;
let flags = data[cursor];
cursor += 1;
self.vrc6_pulse_mode[i] = (flags & 0x01) != 0;
self.vrc6_pulse_enabled[i] = (flags & 0x02) != 0;
}
self.vrc6_saw_period = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
self.vrc6_saw_counter = u16::from_le_bytes([data[cursor], data[cursor + 1]]);
cursor += 2;
self.vrc6_saw_step = data[cursor];
cursor += 1;
self.vrc6_saw_accumulator = data[cursor];
cursor += 1;
self.vrc6_saw_rate = data[cursor];
cursor += 1;
self.vrc6_saw_enabled = data[cursor] != 0;
cursor += 1;
let prg_ram = read_state_bytes(data, &mut cursor)?;
if prg_ram.len() != self.prg_ram.len() {

View File

@@ -228,7 +228,7 @@ impl Mapper for Vrc7_85 {
}
}
fn cpu_read_low(&self, addr: u16) -> Option<u8> {
fn cpu_read_low(&mut self, addr: u16) -> Option<u8> {
if (0x6000..=0x7FFF).contains(&addr) && self.prg_ram_enabled {
Some(self.prg_ram[(addr as usize) - 0x6000])
} else {

View File

@@ -44,6 +44,10 @@ impl Ppu {
sprite_count: 0,
next_sprite_indices: [0; 8],
next_sprite_count: 0,
spr_shift_lo: [0; 8],
spr_shift_hi: [0; 8],
spr_x_counter: [0; 8],
spr_attr_latch: [0; 8],
}
}
@@ -90,17 +94,33 @@ impl Ppu {
(self.read_palette(0), false)
};
if !self.sprite0_hit_set() && self.sprite0_hit_at(mapper, y, dot) && bg_opaque {
self.set_sprite0_hit(true);
}
// Advance sprite shift registers for every visible pixel
// (unconditional so x-counters stay in sync even when the sprite
// layer is clipped on the left).
let spr_result = if self.sprites_enabled() {
self.sprite_pixel_from_shifters()
} else {
None
};
let mut final_color = bg_color_index & 0x3F;
let sprite_layer_enabled = show_spr && (x >= 8 || show_spr_left);
if sprite_layer_enabled
&& let Some((spr_color_index, behind_bg)) = self.sprite_pixel(mapper, x, y)
&& !(behind_bg && bg_opaque)
{
final_color = spr_color_index & 0x3F;
if sprite_layer_enabled {
if let Some((spr_color_index, behind_bg, is_sprite0)) = spr_result {
// Sprite-0 hit: set when a non-transparent sprite-0 pixel
// overlaps a non-transparent background pixel. Suppressed
// in the left 8 pixels when either clip bit is clear.
if is_sprite0
&& bg_opaque
&& !self.sprite0_hit_set()
&& (x >= 8 || (show_bg_left && show_spr_left))
{
self.set_sprite0_hit(true);
}
if !(behind_bg && bg_opaque) {
final_color = spr_color_index & 0x3F;
}
}
}
let (r, g, b) = apply_color_emphasis(nes_rgb(final_color), self.mask);
@@ -160,11 +180,13 @@ impl Ppu {
}
if rendering_active {
// Transfer pre-evaluated sprite list at the start of each visible scanline,
// so dots 1-256 render with the correct sprites for *this* scanline.
// Transfer pre-evaluated sprite list at the start of each visible
// scanline, then immediately load the per-slot shift registers so
// that dots 1-256 render with the correct sprites for *this* line.
if scanline < 240 && dot == 1 && self.sprites_enabled() {
self.sprite_count = self.next_sprite_count;
self.sprite_indices = self.next_sprite_indices;
self.load_sprite_shifters(mapper, scanline);
}
if dot == 256 {
@@ -284,6 +306,93 @@ impl Ppu {
(count, indices, overflow)
}
/// Fetch pattern bytes for the sprites evaluated for `scanline` and load
/// them into the per-slot shift registers. Called once at dot 1 of each
/// visible scanline so that `sprite_pixel_from_shifters` can provide
/// cycle-accurate, shift-register-based sprite rendering for dots 1-256.
pub(super) fn load_sprite_shifters(&mut self, mapper: &dyn Mapper, scanline: u32) {
let sprite_height = if (self.ctrl & 0x20) != 0 { 16i16 } else { 8i16 };
for slot in 0..8usize {
// Default: inactive slot — shifters transparent, counter parked.
self.spr_shift_lo[slot] = 0;
self.spr_shift_hi[slot] = 0;
self.spr_x_counter[slot] = 0xFF;
self.spr_attr_latch[slot] = 0;
if slot >= self.sprite_count as usize {
continue;
}
let i = self.sprite_indices[slot] as usize;
let oam_idx = i * 4;
let attr = self.oam[oam_idx + 2];
let tile = self.oam[oam_idx + 1];
let sprite_y = self.oam[oam_idx] as i16 + 1;
let mut row = scanline as i16 - sprite_y;
if row < 0 || row >= sprite_height {
continue;
}
if (attr & 0x80) != 0 {
row = sprite_height - 1 - row; // vertical flip
}
let (lo_addr, hi_addr) = if sprite_height == 16 {
let table = ((tile & 1) as u16) << 12;
let tile_num = (tile & 0xFE).wrapping_add((row / 8) as u8) as u16;
let row_in_tile = (row & 7) as u16;
let lo = table + tile_num * 16 + row_in_tile;
(lo, lo + 8)
} else {
let table = if (self.ctrl & 0x08) != 0 { 0x1000u16 } else { 0u16 };
let lo = table + (tile as u16) * 16 + row as u16;
(lo, lo + 8)
};
let mut lo = mapper.ppu_read_sprite(lo_addr);
let mut hi = mapper.ppu_read_sprite(hi_addr);
if (attr & 0x40) != 0 {
// Horizontal flip: reverse bit order so MSB is always the
// leftmost pixel when we shift out from bit 7.
lo = lo.reverse_bits();
hi = hi.reverse_bits();
}
self.spr_shift_lo[slot] = lo;
self.spr_shift_hi[slot] = hi;
self.spr_x_counter[slot] = self.oam[oam_idx + 3];
self.spr_attr_latch[slot] = attr;
}
}
/// Advance all active sprite shift registers by one pixel and return the
/// colour and priority of the first non-transparent sprite pixel found.
/// The third element of the tuple is `true` when the winning sprite is
/// OAM sprite 0 (used for sprite-0 hit detection).
///
/// Every active slot is always updated regardless of which slot wins, so
/// this must be called exactly once per visible pixel dot (1-256).
pub(super) fn sprite_pixel_from_shifters(&mut self) -> Option<(u8, bool, bool)> {
let mut result: Option<(u8, bool, bool)> = None;
for slot in 0..self.sprite_count as usize {
if self.spr_x_counter[slot] > 0 {
self.spr_x_counter[slot] -= 1;
continue;
}
// Extract the MSB from each pattern plane then advance the shifter.
let lo_bit = (self.spr_shift_lo[slot] >> 7) & 1;
let hi_bit = (self.spr_shift_hi[slot] >> 7) & 1;
self.spr_shift_lo[slot] <<= 1;
self.spr_shift_hi[slot] <<= 1;
if result.is_none() {
let pix = lo_bit | (hi_bit << 1);
if pix != 0 {
let attr = self.spr_attr_latch[slot];
let pal_idx = (((attr & 0x03) as u16) << 2) | pix as u16;
let color = self.read_palette(0x10 | pal_idx);
let behind_bg = (attr & 0x20) != 0;
let is_sprite0 = self.sprite_indices[slot] == 0;
result = Some((color, behind_bg, is_sprite0));
}
}
}
result
}
pub fn note_scroll_register_write_legacy(&mut self, scanline: usize, dot: u32) {
let mut target_scanline = scanline;
let mut x_start = 0u8;

View File

@@ -16,10 +16,12 @@ impl Ppu {
match addr {
0x0000..=0x1FFF => mapper.ppu_write(addr, value),
0x2000..=0x3EFF => {
let idx = mapper
.map_nametable_addr(addr)
.unwrap_or_else(|| self.nt_index(addr, mapper.mirroring()));
self.vram[idx] = value;
if !mapper.write_nametable_byte(addr, value) {
let idx = mapper
.map_nametable_addr(addr)
.unwrap_or_else(|| self.nt_index(addr, mapper.mirroring()));
self.vram[idx] = value;
}
}
0x3F00..=0x3FFF => {
let idx = palette_index(addr);
@@ -30,6 +32,9 @@ impl Ppu {
}
pub(super) fn read_nt(&self, addr: u16, mapper: &dyn Mapper) -> u8 {
if let Some(val) = mapper.read_nametable_byte(addr) {
return val;
}
let idx = mapper
.map_nametable_addr(addr)
.unwrap_or_else(|| self.nt_index(addr, mapper.mirroring()));

View File

@@ -42,6 +42,15 @@ pub struct Ppu {
pub(super) sprite_count: u8,
pub(super) next_sprite_indices: [u8; 8],
pub(super) next_sprite_count: u8,
// Per-slot sprite shift registers loaded at dot 1 of each visible scanline.
// spr_shift_lo/hi hold the 8-bit pattern row (h-flip already applied).
// spr_x_counter counts down the remaining pixels before a slot becomes
// active; when it reaches 0 the slot starts shifting out pixel bits.
// spr_attr_latch stores the OAM attribute byte (priority, palette, flips).
pub(super) spr_shift_lo: [u8; 8],
pub(super) spr_shift_hi: [u8; 8],
pub(super) spr_x_counter: [u8; 8],
pub(super) spr_attr_latch: [u8; 8],
}
impl Default for Ppu {

View File

@@ -7,16 +7,43 @@ pub struct AudioMixer {
samples_per_cpu_cycle: f64,
sample_accumulator: f64,
last_output_sample: f32,
// Previous output sample (two batches ago) used as the p0 control point
// for Catmull-Rom Hermite interpolation. Storing p0 allows the tangent at
// the start of each interpolation interval to be computed as
// m1 = (p2 - p0) / 2
// which produces a smooth, continuous first derivative across batch
// boundaries rather than the kink introduced by linear ramps.
prev_sample: f32,
// One-pole IIR low-pass filter state (approximates NES ~14 kHz RC filter).
// Coefficient: a = exp(-2π * fc / fs). At fc=14000, fs=48000: a ≈ 0.160
lp_coeff: f32,
lp_state: f32,
// One-pole IIR high-pass filter (DC blocker). Removes the DC bias that
// accumulates when APU channels switch state, preventing audible clicks and
// pops. Approximates the NES capacitor-coupled output stage (~5 Hz cutoff).
// Formula: y[n] = hp_coeff * y[n-1] + x[n] - x[n-1]
// Coefficient: a = exp(-2π * fc / fs). At fc=5, fs=48000: a ≈ 0.99935.
hp_coeff: f32,
hp_prev_x: f32,
hp_prev_y: f32,
}
impl AudioMixer {
pub fn new(sample_rate: u32, mode: VideoMode) -> Self {
let cpu_hz = mode.cpu_hz();
let lp_coeff = (-2.0 * std::f64::consts::PI * 14_000.0 / sample_rate as f64).exp() as f32;
let hp_coeff = (-2.0 * std::f64::consts::PI * 5.0 / sample_rate as f64).exp() as f32;
Self {
sample_rate,
samples_per_cpu_cycle: sample_rate as f64 / cpu_hz,
sample_accumulator: 0.0,
last_output_sample: 0.0,
prev_sample: 0.0,
lp_coeff,
lp_state: 0.0,
hp_coeff,
hp_prev_x: 0.0,
hp_prev_y: 0.0,
}
}
@@ -27,6 +54,10 @@ impl AudioMixer {
pub fn reset(&mut self) {
self.sample_accumulator = 0.0;
self.last_output_sample = 0.0;
self.prev_sample = 0.0;
self.lp_state = 0.0;
self.hp_prev_x = 0.0;
self.hp_prev_y = 0.0;
}
pub fn push_cycles(&mut self, cpu_cycles: u32, channels: ChannelOutputs, out: &mut Vec<f32>) {
@@ -34,26 +65,75 @@ impl AudioMixer {
let samples = self.sample_accumulator.floor() as usize;
self.sample_accumulator -= samples as f64;
let pulse_out = 0.00752 * (f32::from(channels.pulse1) + f32::from(channels.pulse2));
let tnd_out = 0.00851 * f32::from(channels.triangle)
+ 0.00494 * f32::from(channels.noise)
+ 0.00335 * f32::from(channels.dmc);
let sample = pulse_out + tnd_out;
// NES non-linear APU mixing (Blargg's reference formulas).
// Pulse channels use a shared lookup:
// pulse_out = 95.88 / (8128 / (p1 + p2) + 100)
// TND channels use a separate lookup:
// tnd_out = 159.79 / (1 / (tri/8227 + noise/12241 + dmc/22638) + 100)
// Both formulas produce 0.0 when all contributing channels are silent.
let p_sum = f32::from(channels.pulse1) + f32::from(channels.pulse2);
let pulse_out = if p_sum == 0.0 {
0.0
} else {
95.88 / (8128.0 / p_sum + 100.0)
};
let tnd_sum = f32::from(channels.triangle) / 8227.0
+ f32::from(channels.noise) / 12241.0
+ f32::from(channels.dmc) / 22638.0;
let tnd_out = if tnd_sum == 0.0 {
0.0
} else {
159.79 / (1.0 / tnd_sum + 100.0)
};
let sample = pulse_out + tnd_out + channels.expansion;
if samples == 0 {
return;
}
let start = self.last_output_sample;
if samples == 1 {
out.push(sample);
} else {
let denom = samples as f32;
for idx in 0..samples {
let t = (idx + 1) as f32 / denom;
out.push(start + (sample - start) * t);
}
// Catmull-Rom Hermite interpolation between the previous batch sample
// (p1 = last_output_sample) and the current batch sample (p2 = sample).
//
// The tangent at p1 uses the two-point central difference:
// m1 = (p2 - p0) / 2, where p0 = prev_sample (two batches ago).
// The tangent at p2 uses the forward difference (p3 approximated as p2,
// i.e. the signal stays flat beyond the current batch):
// m2 = (p2 - p1) / 2.
//
// Hermite basis:
// h00(t) = 2t³ - 3t² + 1
// h10(t) = t³ - 2t² + t
// h01(t) = -2t³ + 3t²
// h11(t) = t³ - t²
// f(t) = h00·p1 + h10·m1 + h01·p2 + h11·m2
//
// For t = 1 this collapses to p2, so the last output of each batch
// always lands exactly on the current APU sample value.
let p0 = self.prev_sample;
let p1 = self.last_output_sample;
let p2 = sample;
let m1 = (p2 - p0) * 0.5;
let m2 = (p2 - p1) * 0.5;
let denom = samples as f32;
let a = self.lp_coeff;
let b = 1.0 - a;
for idx in 0..samples {
let t = (idx + 1) as f32 / denom;
let t2 = t * t;
let t3 = t2 * t;
let interp = (2.0 * t3 - 3.0 * t2 + 1.0) * p1
+ (t3 - 2.0 * t2 + t) * m1
+ (-2.0 * t3 + 3.0 * t2) * p2
+ (t3 - t2) * m2;
let lp = a * self.lp_state + b * interp;
self.lp_state = lp;
let hp = self.hp_coeff * self.hp_prev_y + lp - self.hp_prev_x;
self.hp_prev_x = lp;
self.hp_prev_y = hp;
out.push(hp);
}
self.prev_sample = p1;
self.last_output_sample = sample;
}
}
@@ -83,6 +163,7 @@ mod tests {
triangle: 15,
noise: 15,
dmc: 127,
expansion: 0.0,
};
let mut out = Vec::new();
mixer.push_cycles(50, channels, &mut out);
@@ -108,6 +189,7 @@ mod tests {
triangle: 15,
noise: 15,
dmc: 127,
expansion: 0.0,
},
&mut out,
);

View File

@@ -62,6 +62,10 @@ impl NesRuntime {
self.video_mode
}
pub fn set_video_mode(&mut self, mode: VideoMode) {
self.video_mode = mode;
}
pub fn default_frame_pacer(&self) -> FramePacer {
FramePacer::new(self.video_mode)
}
@@ -95,12 +99,6 @@ impl NesRuntime {
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,
@@ -108,8 +106,16 @@ impl NesRuntime {
) -> Result<(), RuntimeError> {
self.bus.begin_frame();
while !self.bus.take_frame_complete() {
let cycles = self.step_instruction()?;
mixer.push_cycles(cycles, self.bus.apu_channel_outputs(), out_samples);
self.bus.set_joypad_buttons(self.buttons);
let cpu_cycles = self.cpu.step(&mut self.bus).map_err(RuntimeError::Cpu)?;
// Sample APU output once per CPU cycle for better audio resolution.
// OAM DMA cycles (triggered inside cpu.step) are captured in the
// first take_cpu_cycles_since_poll call of this instruction.
for _ in 0..cpu_cycles {
self.bus.clock_cpu(1);
let actual = self.bus.take_cpu_cycles_since_poll();
mixer.push_cycles(actual, self.bus.apu_channel_outputs(), out_samples);
}
}
self.frame_number = self.frame_number.saturating_add(1);
Ok(())

View File

@@ -1,63 +0,0 @@
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(),
})
}
}

View File

@@ -1,23 +1,35 @@
use crate::runtime::{NesRuntime, RuntimeError};
use crate::runtime::{
AudioMixer, FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, NesRuntime, RuntimeError,
};
use super::clock::{FrameClock, NoopClock, PacingClock};
use super::config::HostConfig;
use super::executor::{FrameExecution, FrameExecutor};
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 RuntimeHostLoop<C = PacingClock> {
runtime: NesRuntime,
executor: FrameExecutor,
mixer: AudioMixer,
frame_buffer: Vec<u8>,
audio_buffer: Vec<f32>,
clock: C,
}
impl RuntimeHostLoop<PacingClock> {
pub fn new(runtime: NesRuntime, sample_rate: u32) -> Self {
let executor = FrameExecutor::from_runtime(&runtime, sample_rate);
let mixer = runtime.default_audio_mixer(sample_rate);
let clock = PacingClock::from_runtime(&runtime);
Self {
runtime,
executor,
mixer,
frame_buffer: vec![0; FRAME_RGBA_BYTES],
audio_buffer: Vec::new(),
clock,
}
}
@@ -26,7 +38,7 @@ impl RuntimeHostLoop<PacingClock> {
runtime: NesRuntime,
config: HostConfig,
) -> RuntimeHostLoop<Box<dyn FrameClock>> {
let executor = FrameExecutor::from_runtime(&runtime, config.sample_rate);
let mixer = runtime.default_audio_mixer(config.sample_rate);
let clock: Box<dyn FrameClock> = if config.pacing {
Box::new(PacingClock::from_runtime(&runtime))
} else {
@@ -34,7 +46,9 @@ impl RuntimeHostLoop<PacingClock> {
};
RuntimeHostLoop {
runtime,
executor,
mixer,
frame_buffer: vec![0; FRAME_RGBA_BYTES],
audio_buffer: Vec::new(),
clock,
}
}
@@ -45,10 +59,12 @@ where
C: FrameClock,
{
pub fn with_clock(runtime: NesRuntime, sample_rate: u32, clock: C) -> Self {
let executor = FrameExecutor::from_runtime(&runtime, sample_rate);
let mixer = runtime.default_audio_mixer(sample_rate);
Self {
runtime,
executor,
mixer,
frame_buffer: vec![0; FRAME_RGBA_BYTES],
audio_buffer: Vec::new(),
clock,
}
}
@@ -65,12 +81,12 @@ where
self.runtime
}
pub fn executor(&self) -> &FrameExecutor {
&self.executor
pub fn mixer(&self) -> &AudioMixer {
&self.mixer
}
pub fn executor_mut(&mut self) -> &mut FrameExecutor {
&mut self.executor
pub fn mixer_mut(&mut self) -> &mut AudioMixer {
&mut self.mixer
}
pub fn clock(&self) -> &C {
@@ -108,8 +124,20 @@ where
V: VideoOutput,
A: AudioOutput,
{
self.executor
.execute_frame(&mut self.runtime, input, video, audio)
self.runtime.set_buttons(input.poll_buttons());
self.audio_buffer.clear();
self.runtime
.run_until_frame_complete_with_audio(&mut self.mixer, &mut self.audio_buffer)?;
self.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: self.runtime.frame_number(),
audio_samples: self.audio_buffer.len(),
})
}
pub fn run_frames<I, V, A>(

View File

@@ -1,13 +1,11 @@
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 loop_runner::{FrameExecution, RuntimeHostLoop};
pub use session::{ClientRuntime, EmulationState};

View File

@@ -2,9 +2,8 @@ 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 super::loop_runner::{FrameExecution, RuntimeHostLoop};
use crate::runtime::NesRuntime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -18,9 +18,9 @@ pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERS
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,
AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, HostConfig,
InputProvider, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, RuntimeHostLoop,
VideoOutput,
};
pub use timing::{FramePacer, VideoMode};
pub use types::{
@@ -32,10 +32,10 @@ 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,
AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, HostConfig,
InputProvider, JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons,
NesRuntime, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, RuntimeError,
RuntimeHostLoop, VideoOutput, button_pressed, set_button_pressed,
};
}