Compare commits
8 Commits
d94fbb894b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| badbe0979f | |||
| ad6970d4b5 | |||
| d113228f1b | |||
| 38a62b6f93 | |||
| 2878187180 | |||
| 188444f987 | |||
| d9666c23b4 | |||
| c77be7c84b |
148
Cargo.lock
generated
148
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
152
crates/nesemu-desktop/src/app.rs
Normal file
152
crates/nesemu-desktop/src/app.rs
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
146
crates/nesemu-desktop/src/audio.rs
Normal file
146
crates/nesemu-desktop/src/audio.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
18
crates/nesemu-desktop/src/input.rs
Normal file
18
crates/nesemu-desktop/src/input.rs
Normal 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
|
||||
}
|
||||
}
|
||||
446
crates/nesemu-desktop/src/input_config.rs
Normal file
446
crates/nesemu-desktop/src/input_config.rs
Normal 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: >k::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));
|
||||
}
|
||||
}
|
||||
@@ -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 = 4096;
|
||||
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: >k::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: >k::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(¤t_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: >k::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,13 +186,18 @@ fn build_ui(app: >k::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 {
|
||||
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: >k::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 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();
|
||||
if let Err(err) = app_state.load_rom_from_path(&path) {
|
||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||
} else {
|
||||
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: >k::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: >k::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: >k::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() {
|
||||
if let Ok(file) = value.get::<gio::File>()
|
||||
&& 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());
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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: >k::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: >k::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,427 +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: >k::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));
|
||||
// Do NOT open the audio device here. Creating a cpal stream at startup
|
||||
// forces the system audio server (PipeWire/PulseAudio) to allocate
|
||||
// resources and may disrupt other running audio applications even when
|
||||
// the emulator is idle. The stream is opened lazily on the first
|
||||
// push_samples call, i.e. only when a ROM is actually playing.
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.ensure_stream();
|
||||
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),
|
||||
// Use the audio server's default buffer size to avoid forcing the entire
|
||||
// PipeWire/PulseAudio graph into low-latency mode, which would disturb
|
||||
// other audio applications (browsers, media players, etc.).
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
}
|
||||
}
|
||||
|
||||
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_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();
|
||||
// Default lets the audio server (PipeWire/PulseAudio) choose the
|
||||
// buffer size, preventing interference with other audio applications.
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
100
crates/nesemu-desktop/src/scheduling.rs
Normal file
100
crates/nesemu-desktop/src/scheduling.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
131
crates/nesemu-desktop/src/video.rs
Normal file
131
crates/nesemu-desktop/src/video.rs
Normal 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: >k::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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -365,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,9 +248,7 @@ impl Mapper for Fme7 {
|
||||
}
|
||||
|
||||
fn clock_cpu(&mut self, cycles: u8) {
|
||||
if !self.irq_counter_enabled {
|
||||
return;
|
||||
}
|
||||
if self.irq_counter_enabled {
|
||||
for _ in 0..cycles {
|
||||
if self.irq_counter == 0 {
|
||||
self.irq_counter = 0xFFFF;
|
||||
@@ -253,6 +261,46 @@ impl Mapper for Fme7 {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let out = self.irq_pending;
|
||||
self.irq_pending = false;
|
||||
@@ -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());
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,9 +152,7 @@ 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;
|
||||
@@ -154,6 +160,58 @@ impl Mapper for Namco163_19 {
|
||||
self.irq_counter = (sum as u16) & 0x7FFF;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let out = self.irq_pending;
|
||||
self.irq_pending = false;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,18 +94,34 @@ 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)
|
||||
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);
|
||||
let i = (y * 256 + x) * 4;
|
||||
@@ -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;
|
||||
|
||||
@@ -16,11 +16,13 @@ impl Ppu {
|
||||
match addr {
|
||||
0x0000..=0x1FFF => mapper.ppu_write(addr, value),
|
||||
0x2000..=0x3EFF => {
|
||||
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);
|
||||
self.palette_ram[idx] = value & 0x3F;
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,13 @@ 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,
|
||||
@@ -31,6 +38,7 @@ impl AudioMixer {
|
||||
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,
|
||||
@@ -46,6 +54,7 @@ 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;
|
||||
@@ -56,31 +65,67 @@ 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;
|
||||
// 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;
|
||||
if samples == 1 {
|
||||
let lp = a * self.lp_state + b * sample;
|
||||
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);
|
||||
} else {
|
||||
let denom = samples as f32;
|
||||
for idx in 0..samples {
|
||||
let t = (idx + 1) as f32 / denom;
|
||||
let interp = start + (sample - start) * t;
|
||||
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;
|
||||
@@ -88,7 +133,7 @@ impl AudioMixer {
|
||||
self.hp_prev_y = hp;
|
||||
out.push(hp);
|
||||
}
|
||||
}
|
||||
self.prev_sample = p1;
|
||||
self.last_output_sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -118,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);
|
||||
@@ -143,6 +189,7 @@ mod tests {
|
||||
triangle: 15,
|
||||
noise: 15,
|
||||
dmc: 127,
|
||||
expansion: 0.0,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user