Compare commits

...

3 Commits

Author SHA1 Message Date
badbe0979f feat(desktop): add input configuration dialog with key remapping
Some checks failed
CI / rust (push) Has been cancelled
Add modal controls dialog (header bar button) with per-player keyboard
remapping, key capture mode, duplicate protection, reset to defaults,
and a gamepad tab stub for future Xbox/DualSense support.
2026-03-18 15:40:52 +03:00
ad6970d4b5 feat(desktop): GPU rendering, modern GTK4 UI, hotkeys and player 2
Some checks failed
CI / rust (push) Has been cancelled
- Replace Cairo DrawingArea with custom NesScreen widget using
  GskTextureScaleNode for GPU-accelerated nearest-neighbor rendering
- Migrate from FileChooserNative to FileDialog (GTK 4.10+)
- Add AlertDialog for error display, structured logging via env_logger
- Add FPS counter (F3), NTSC/PAL toggle (F7), fullscreen (F11),
  Esc to quit, save/load state (Ctrl+S/L), volume slider
- Add player 2 keyboard input support
- Fix window proportions by compensating for header bar height
2026-03-18 15:12:06 +03:00
d113228f1b refactor(runtime): inline FrameExecutor, add joypad2 and video mode setter
Remove FrameExecutor indirection by inlining its logic into
RuntimeHostLoop. Add set_video_mode() for NTSC/PAL switching,
set_joypad2_buttons() for player 2 input, and fix mapper scanline
IRQ test timing.
2026-03-18 15:11:56 +03:00
16 changed files with 1058 additions and 252 deletions

148
Cargo.lock generated
View File

@@ -33,6 +33,56 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -159,6 +209,12 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -230,6 +286,29 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -604,6 +683,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -613,6 +698,30 @@ dependencies = [
"either", "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]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -759,9 +868,10 @@ dependencies = [
name = "nesemu-desktop" name = "nesemu-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cairo-rs",
"cpal", "cpal",
"env_logger",
"gtk4", "gtk4",
"log",
"nesemu", "nesemu",
] ]
@@ -846,6 +956,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.19.8" version = "0.19.8"
@@ -882,6 +998,21 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@@ -1156,6 +1287,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -1312,6 +1449,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.42.2" version = "0.42.2"

View File

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

View File

@@ -13,18 +13,22 @@ use crate::SAMPLE_RATE;
pub(crate) struct DesktopApp { pub(crate) struct DesktopApp {
session: Option<ClientRuntime<Box<dyn FrameClock>>>, session: Option<ClientRuntime<Box<dyn FrameClock>>>,
input: InputState, input_p1: InputState,
input_p2: InputState,
audio: CpalAudioSink, audio: CpalAudioSink,
video: BufferedVideo, video: BufferedVideo,
save_slot: Option<Vec<u8>>,
} }
impl DesktopApp { impl DesktopApp {
pub(crate) fn new(volume: Arc<AtomicU32>) -> Self { pub(crate) fn new(volume: Arc<AtomicU32>) -> Self {
Self { Self {
session: None, session: None,
input: InputState::default(), input_p1: InputState::default(),
input_p2: InputState::default(),
audio: CpalAudioSink::new(volume), audio: CpalAudioSink::new(volume),
video: BufferedVideo::new(), video: BufferedVideo::new(),
save_slot: None,
} }
} }
@@ -74,14 +78,20 @@ impl DesktopApp {
return; return;
}; };
match session.tick(&mut self.input, &mut self.video, &mut self.audio) { // Set player 2 buttons before the frame tick.
Ok(_) => {} use nesemu::InputProvider;
Err(err) => { let p2_buttons = self.input_p2.poll_buttons();
eprintln!("Frame execution error: {err}"); 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(); session.pause();
} }
} }
}
pub(crate) fn frame_rgba(&self) -> &[u8] { pub(crate) fn frame_rgba(&self) -> &[u8] {
self.video.frame_rgba() self.video.frame_rgba()
@@ -94,7 +104,49 @@ impl DesktopApp {
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration()) .unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
} }
pub(crate) fn input_mut(&mut self) -> &mut InputState { pub(crate) fn input_p1_mut(&mut self) -> &mut InputState {
&mut self.input &mut self.input_p1
}
pub(crate) fn input_p2_mut(&mut self) -> &mut InputState {
&mut self.input_p2
}
pub(crate) fn save_state(&mut self) {
if let Some(session) = self.session.as_ref() {
self.save_slot = Some(session.host().runtime().save_state());
} }
} }
pub(crate) fn load_state(&mut self) {
let Some(data) = self.save_slot.as_ref() else {
return;
};
if let Some(session) = self.session.as_mut() {
if let Err(err) = session.host_mut().runtime_mut().load_state(data) {
log::error!("Failed to load state: {err}");
}
self.audio.clear();
}
}
pub(crate) fn video_mode(&self) -> VideoMode {
self.session
.as_ref()
.map(|s| s.host().runtime().video_mode())
.unwrap_or(VideoMode::Ntsc)
}
pub(crate) fn cycle_video_mode(&mut self) -> Option<VideoMode> {
let session = self.session.as_mut()?;
let current = session.host().runtime().video_mode();
let next = match current {
VideoMode::Ntsc => VideoMode::Pal,
_ => VideoMode::Ntsc,
};
session.host_mut().runtime_mut().set_video_mode(next);
log::info!("Video mode: {next:?}");
Some(next)
}
}

View File

@@ -1,4 +1,3 @@
use gtk4::gdk;
use nesemu::{InputProvider, JoypadButton, JoypadButtons, set_button_pressed}; use nesemu::{InputProvider, JoypadButton, JoypadButtons, set_button_pressed};
#[derive(Default)] #[derive(Default)]
@@ -7,18 +6,7 @@ pub(crate) struct InputState {
} }
impl InputState { impl InputState {
pub(crate) fn set_key_state(&mut self, key: gdk::Key, pressed: bool) { pub(crate) fn set_button(&mut self, button: JoypadButton, 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); set_button_pressed(&mut self.buttons, button, pressed);
} }
} }

View File

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

View File

@@ -1,10 +1,11 @@
mod app; mod app;
mod audio; mod audio;
mod input; mod input;
mod input_config;
mod scheduling; mod scheduling;
mod video; mod video;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@@ -17,10 +18,12 @@ use gtk::glib;
use gtk::prelude::*; use gtk::prelude::*;
use gtk4 as gtk; use gtk4 as gtk;
use nesemu::prelude::EmulationState; use nesemu::prelude::EmulationState;
use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH}; use nesemu::{FRAME_HEIGHT, FRAME_WIDTH};
use app::DesktopApp; use app::DesktopApp;
use input_config::InputConfig;
use scheduling::DesktopFrameScheduler; use scheduling::DesktopFrameScheduler;
use video::NesScreen;
const APP_ID: &str = "org.nesemu.desktop"; const APP_ID: &str = "org.nesemu.desktop";
const TITLE: &str = "NES Emulator"; const TITLE: &str = "NES Emulator";
@@ -28,11 +31,7 @@ const SCALE: i32 = 3;
const SAMPLE_RATE: u32 = 48_000; const SAMPLE_RATE: u32 = 48_000;
fn main() { fn main() {
if std::env::var_os("GSK_RENDERER").is_none() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
unsafe {
std::env::set_var("GSK_RENDERER", "cairo");
}
}
let app = gtk::Application::builder().application_id(APP_ID).build(); let app = gtk::Application::builder().application_id(APP_ID).build();
@@ -53,7 +52,7 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
.application(app) .application(app)
.title(TITLE) .title(TITLE)
.default_width((FRAME_WIDTH as i32) * SCALE) .default_width((FRAME_WIDTH as i32) * SCALE)
.default_height((FRAME_HEIGHT as i32) * SCALE) .default_height((FRAME_HEIGHT as i32) * SCALE + 45)
.build(); .build();
// --- Header bar --- // --- Header bar ---
@@ -106,47 +105,58 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
volume_box.append(&volume_scale); volume_box.append(&volume_scale);
header.pack_end(&volume_box); 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)); window.set_titlebar(Some(&header));
// --- Drawing area --- // --- NES screen widget (GPU-accelerated, nearest-neighbor scaling) ---
let drawing_area = gtk::DrawingArea::new(); let screen = NesScreen::new();
drawing_area.set_hexpand(true); screen.set_size_request(FRAME_WIDTH as i32, FRAME_HEIGHT as i32);
drawing_area.set_vexpand(true); screen.set_hexpand(true);
screen.set_vexpand(true);
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
overlay.set_child(Some(&drawing_area)); overlay.set_child(Some(&screen));
let drop_label = gtk::Label::builder() 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) .justify(gtk::Justification::Center)
.css_classes(["dim-label"]) .css_classes(["dim-label"])
.build(); .build();
drop_label.set_halign(gtk::Align::Center); drop_label.set_halign(gtk::Align::Center);
drop_label.set_valign(gtk::Align::Center); drop_label.set_valign(gtk::Align::Center);
overlay.add_overlay(&drop_label); 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)); window.set_child(Some(&overlay));
// --- State --- // --- State ---
let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume)))); 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())); let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new()));
let input_config = Rc::new(RefCell::new(InputConfig::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();
video::draw_frame(&frame, cr, width, height);
});
}
// --- Helper to sync UI with emulation state --- // --- Helper to sync UI with emulation state ---
let current_rom_name: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let sync_ui = { let sync_ui = {
let pause_button = pause_button.clone(); let pause_button = pause_button.clone();
let reset_button = reset_button.clone(); let reset_button = reset_button.clone();
let drop_label = drop_label.clone(); let drop_label = drop_label.clone();
let window = window.clone(); let window = window.clone();
let current_rom_name = Rc::clone(&current_rom_name);
move |app_state: &DesktopApp, rom_name: Option<&str>| { move |app_state: &DesktopApp, rom_name: Option<&str>| {
let loaded = app_state.is_loaded(); let loaded = app_state.is_loaded();
pause_button.set_sensitive(loaded); pause_button.set_sensitive(loaded);
@@ -162,7 +172,11 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
} }
if let Some(name) = rom_name { 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:?}]")));
} }
} }
}; };
@@ -172,13 +186,18 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
{ {
let mut app_state = desktop.borrow_mut(); let mut app_state = desktop.borrow_mut();
if let Some(path) = initial_rom { if let Some(path) = initial_rom {
if let Err(err) = app_state.load_rom_from_path(&path) { match app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display()); Ok(()) => {
sync_ui(&app_state, None);
} else {
let name = rom_filename(&path); let name = rom_filename(&path);
sync_ui(&app_state, Some(&name)); 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 { } else {
sync_ui(&app_state, None); sync_ui(&app_state, None);
} }
@@ -191,42 +210,48 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
let window = window.clone(); let window = window.clone();
Rc::new(move || { 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(); let nes_filter = gtk::FileFilter::new();
nes_filter.set_name(Some("NES ROMs")); nes_filter.set_name(Some("NES ROMs"));
nes_filter.add_pattern("*.nes"); nes_filter.add_pattern("*.nes");
chooser.add_filter(&nes_filter);
let all_filter = gtk::FileFilter::new(); let all_filter = gtk::FileFilter::new();
all_filter.set_name(Some("All files")); all_filter.set_name(Some("All files"));
all_filter.add_pattern("*"); 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 desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler); let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
chooser.connect_response(move |dialog, response| { let parent = window.clone();
if response == gtk::ResponseType::Accept let error_window = window.clone();
&& let Some(path) = dialog.file().and_then(|f| f.path()) 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(); let mut app_state = desktop.borrow_mut();
if let Err(err) = app_state.load_rom_from_path(&path) { match app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display()); Ok(()) => {
} else {
scheduler.borrow_mut().reset_timing(); scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path); let name = rom_filename(&path);
sync_ui(&app_state, Some(&name)); sync_ui(&app_state, Some(&name));
} }
Err(err) => {
drop(app_state);
show_error(&error_window, &format!("Failed to load ROM: {err}"));
}
} }
}); });
chooser.show();
}) })
}; };
@@ -262,6 +287,14 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
}); });
} }
{
let input_config = Rc::clone(&input_config);
let window = window.clone();
controls_button.connect_clicked(move |_| {
input_config::show_input_config_dialog(&window, Rc::clone(&input_config));
});
}
// --- Keyboard shortcuts via actions --- // --- Keyboard shortcuts via actions ---
let action_open = gio::SimpleAction::new("open", None); let action_open = gio::SimpleAction::new("open", None);
{ {
@@ -307,20 +340,105 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
window.add_action(&action_reset); window.add_action(&action_reset);
app.set_accels_for_action("win.reset", &["<Ctrl>r"]); 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 --- // --- Keyboard controller for joypad input ---
{ {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let input_config = Rc::clone(&input_config);
let key_controller = gtk::EventControllerKey::new(); let key_controller = gtk::EventControllerKey::new();
let desktop_for_press = Rc::clone(&desktop); let desktop_for_press = Rc::clone(&desktop);
let config_for_press = Rc::clone(&input_config);
key_controller.connect_key_pressed(move |_, key, _, _| { key_controller.connect_key_pressed(move |_, key, _, _| {
let config = config_for_press.borrow();
let mut app_state = desktop_for_press.borrow_mut(); 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 gtk::glib::Propagation::Proceed
}); });
key_controller.connect_key_released(move |_, key, _, _| { 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); window.add_controller(key_controller);
@@ -331,32 +449,43 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
let desktop = Rc::clone(&desktop); let desktop = Rc::clone(&desktop);
let scheduler = Rc::clone(&scheduler); let scheduler = Rc::clone(&scheduler);
let sync_ui = Rc::clone(&sync_ui); let sync_ui = Rc::clone(&sync_ui);
let window = window.clone();
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY); let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
drop_target.connect_drop(move |_, value, _, _| { drop_target.connect_drop(move |_, value, _, _| {
if let Ok(file) = value.get::<gio::File>() if let Ok(file) = value.get::<gio::File>()
&& let Some(path) = file.path() && let Some(path) = file.path()
{ {
let mut app_state = desktop.borrow_mut(); let mut app_state = desktop.borrow_mut();
if let Err(err) = app_state.load_rom_from_path(&path) { match app_state.load_rom_from_path(&path) {
eprintln!("Failed to load ROM '{}': {err}", path.display()); Ok(()) => {
return false;
}
scheduler.borrow_mut().reset_timing(); scheduler.borrow_mut().reset_timing();
let name = rom_filename(&path); let name = rom_filename(&path);
sync_ui(&app_state, Some(&name)); sync_ui(&app_state, Some(&name));
return true; 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 false
}); });
drawing_area.add_controller(drop_target); screen.add_controller(drop_target);
} }
// --- FPS counter state ---
let fps_state = Rc::new(FpsCounter::new());
// --- Game loop --- // --- Game loop ---
{ {
schedule_game_loop( schedule_game_loop(
Rc::clone(&desktop), Rc::clone(&desktop),
drawing_area.clone(), screen.clone(),
Rc::clone(&frame_for_draw), fps_label.clone(),
Rc::clone(&fps_state),
Rc::clone(&scheduler), Rc::clone(&scheduler),
); );
} }
@@ -364,16 +493,42 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
window.present(); window.present();
} }
struct FpsCounter {
frame_count: Cell<u32>,
last_update: Cell<Instant>,
}
impl FpsCounter {
fn new() -> Self {
Self {
frame_count: Cell::new(0),
last_update: Cell::new(Instant::now()),
}
}
fn tick(&self, label: &gtk::Label) {
self.frame_count.set(self.frame_count.get() + 1);
let now = Instant::now();
let elapsed = now.duration_since(self.last_update.get());
if elapsed.as_secs_f64() >= 1.0 {
let fps = self.frame_count.get() as f64 / elapsed.as_secs_f64();
label.set_label(&format!("{fps:.1} FPS"));
self.frame_count.set(0);
self.last_update.set(now);
}
}
}
fn schedule_game_loop( fn schedule_game_loop(
desktop: Rc<RefCell<DesktopApp>>, desktop: Rc<RefCell<DesktopApp>>,
drawing_area: gtk::DrawingArea, screen: NesScreen,
frame_for_draw: Rc<RefCell<Vec<u8>>>, fps_label: gtk::Label,
fps_state: Rc<FpsCounter>,
scheduler: Rc<RefCell<DesktopFrameScheduler>>, scheduler: Rc<RefCell<DesktopFrameScheduler>>,
) { ) {
let interval = desktop.borrow().frame_interval();
let delay = scheduler let delay = scheduler
.borrow_mut() .borrow_mut()
.delay_until_next_frame(Instant::now(), interval); .delay_until_next_frame(Instant::now());
glib::timeout_add_local_once(delay, move || { glib::timeout_add_local_once(delay, move || {
{ {
@@ -383,17 +538,27 @@ fn schedule_game_loop(
scheduler.borrow_mut().mark_frame_complete(now, interval); scheduler.borrow_mut().mark_frame_complete(now, interval);
app_state.tick(); app_state.tick();
screen.set_frame(app_state.frame_rgba());
frame_for_draw if fps_label.is_visible() {
.borrow_mut() fps_state.tick(&fps_label);
.copy_from_slice(app_state.frame_rgba()); }
drawing_area.queue_draw();
} }
schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler); schedule_game_loop(desktop, screen, fps_label, fps_state, scheduler);
}); });
} }
fn show_error(window: &gtk::ApplicationWindow, message: &str) {
log::error!("{message}");
let dialog = gtk::AlertDialog::builder()
.modal(true)
.message("Error")
.detail(message)
.build();
dialog.show(Some(window));
}
fn rom_filename(path: &Path) -> String { fn rom_filename(path: &Path) -> String {
path.file_name() path.file_name()
.map(|n| n.to_string_lossy().into_owned()) .map(|n| n.to_string_lossy().into_owned())

View File

@@ -15,11 +15,7 @@ impl DesktopFrameScheduler {
self.next_deadline = None; self.next_deadline = None;
} }
pub(crate) fn delay_until_next_frame( pub(crate) fn delay_until_next_frame(&mut self, now: Instant) -> Duration {
&mut self,
now: Instant,
_interval: Duration,
) -> Duration {
match self.next_deadline { match self.next_deadline {
None => { None => {
self.next_deadline = Some(now); self.next_deadline = Some(now);
@@ -50,16 +46,16 @@ mod tests {
let interval = Duration::from_micros(16_639); let interval = Duration::from_micros(16_639);
assert_eq!( assert_eq!(
scheduler.delay_until_next_frame(start, interval), scheduler.delay_until_next_frame(start),
Duration::ZERO Duration::ZERO
); );
scheduler.mark_frame_complete(start, interval); scheduler.mark_frame_complete(start, interval);
assert!( assert!(
scheduler.delay_until_next_frame(start + Duration::from_millis(1), interval) scheduler.delay_until_next_frame(start + Duration::from_millis(1))
> Duration::ZERO > Duration::ZERO
); );
assert_eq!( assert_eq!(
scheduler.delay_until_next_frame(start + interval, interval), scheduler.delay_until_next_frame(start + interval),
Duration::ZERO Duration::ZERO
); );
} }
@@ -71,15 +67,15 @@ mod tests {
let interval = Duration::from_micros(16_639); let interval = Duration::from_micros(16_639);
assert_eq!( assert_eq!(
scheduler.delay_until_next_frame(start, interval), scheduler.delay_until_next_frame(start),
Duration::ZERO Duration::ZERO
); );
scheduler.mark_frame_complete(start, interval); scheduler.mark_frame_complete(start, interval);
assert!(scheduler.delay_until_next_frame(start, interval) > Duration::ZERO); assert!(scheduler.delay_until_next_frame(start) > Duration::ZERO);
scheduler.reset_timing(); scheduler.reset_timing();
assert_eq!( assert_eq!(
scheduler.delay_until_next_frame(start, interval), scheduler.delay_until_next_frame(start),
Duration::ZERO Duration::ZERO
); );
} }
@@ -91,13 +87,13 @@ mod tests {
let interval = Duration::from_micros(16_639); let interval = Duration::from_micros(16_639);
assert_eq!( assert_eq!(
scheduler.delay_until_next_frame(start, interval), scheduler.delay_until_next_frame(start),
Duration::ZERO Duration::ZERO
); );
scheduler.mark_frame_complete(start, interval); scheduler.mark_frame_complete(start, interval);
assert_eq!( assert_eq!(
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2), interval), scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2)),
Duration::ZERO Duration::ZERO
); );
} }

View File

@@ -1,3 +1,11 @@
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}; use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, VideoOutput};
pub(crate) struct BufferedVideo { pub(crate) struct BufferedVideo {
@@ -25,48 +33,82 @@ impl VideoOutput for BufferedVideo {
} }
} }
pub(crate) fn draw_frame(frame: &[u8], cr: &cairo::Context, width: i32, height: i32) { // ---------------------------------------------------------------------------
let stride = cairo::Format::ARgb32 // NesScreen — a custom GTK widget that renders a NES frame buffer on the GPU
.stride_for_width(FRAME_WIDTH as u32) // with nearest-neighbor (pixel-perfect) scaling via GskTextureScaleNode.
.unwrap(); // ---------------------------------------------------------------------------
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
for y in 0..FRAME_HEIGHT { mod imp {
for x in 0..FRAME_WIDTH { use super::*;
let src = (y * FRAME_WIDTH + x) * 4;
let dst = y * stride as usize + x * 4; #[derive(Default)]
let r = frame[src]; pub struct NesScreen {
let g = frame[src + 1]; pub(super) texture: RefCell<Option<gdk::Texture>>,
let b = frame[src + 2]; }
let a = frame[src + 3];
argb[dst] = b; #[glib::object_subclass]
argb[dst + 1] = g; impl ObjectSubclass for NesScreen {
argb[dst + 2] = r; const NAME: &'static str = "NesScreen";
argb[dst + 3] = a; type Type = super::NesScreen;
type ParentType = gtk::Widget;
}
impl ObjectImpl for NesScreen {}
impl WidgetImpl for NesScreen {
fn snapshot(&self, snapshot: &gtk::Snapshot) {
let Some(texture) = self.texture.borrow().clone() else {
return;
};
let widget = self.obj();
let w = widget.width() as f32;
let h = widget.height() as f32;
if w <= 0.0 || h <= 0.0 {
return;
}
// Compute scale that fits the frame inside the widget, preserving
// aspect ratio.
let scale_x = w / FRAME_WIDTH as f32;
let scale_y = h / FRAME_HEIGHT as f32;
let scale = scale_x.min(scale_y);
let scaled_w = FRAME_WIDTH as f32 * scale;
let scaled_h = FRAME_HEIGHT as f32 * scale;
let offset_x = (w - scaled_w) / 2.0;
let offset_y = (h - scaled_h) / 2.0;
let bounds = gtk::graphene::Rect::new(offset_x, offset_y, scaled_w, scaled_h);
snapshot.append_scaled_texture(&texture, gsk::ScalingFilter::Nearest, &bounds);
} }
} }
let surface = cairo::ImageSurface::create_for_data( }
argb,
cairo::Format::ARgb32, 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_WIDTH as i32,
FRAME_HEIGHT as i32, FRAME_HEIGHT as i32,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
stride, stride,
) )
.expect("Failed to create Cairo surface"); .upcast();
*self.imp().texture.borrow_mut() = Some(texture);
cr.set_source_rgb(0.0, 0.0, 0.0); self.queue_draw();
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;
cr.translate(offset_x, offset_y);
cr.scale(scale, scale);
let _ = cr.set_source_surface(&surface, 0.0, 0.0);
cr.source().set_filter(cairo::Filter::Nearest);
let _ = cr.paint();
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -2,6 +2,20 @@ use super::NativeBus;
impl NativeBus { impl NativeBus {
pub fn set_joypad_buttons(&mut self, buttons: [bool; 8]) { 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; let mut state = 0u8;
if buttons[4] { if buttons[4] {
state |= 1 << 0; // A state |= 1 << 0; // A
@@ -27,12 +41,7 @@ impl NativeBus {
if buttons[3] { if buttons[3] {
state |= 1 << 7; // Right state |= 1 << 7; // Right
} }
self.joypad_state = state; state
self.joypad2_state = 0;
if self.joypad_strobe {
self.joypad_shift = self.joypad_state;
self.joypad2_shift = self.joypad2_state;
}
} }
pub(super) fn joypad_read(&mut self) -> u8 { pub(super) fn joypad_read(&mut self) -> u8 {

View File

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

View File

@@ -62,6 +62,10 @@ impl NesRuntime {
self.video_mode self.video_mode
} }
pub fn set_video_mode(&mut self, mode: VideoMode) {
self.video_mode = mode;
}
pub fn default_frame_pacer(&self) -> FramePacer { pub fn default_frame_pacer(&self) -> FramePacer {
FramePacer::new(self.video_mode) FramePacer::new(self.video_mode)
} }
@@ -95,12 +99,6 @@ impl NesRuntime {
Ok(()) 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( pub fn run_until_frame_complete_with_audio(
&mut self, &mut self,
mixer: &mut AudioMixer, mixer: &mut AudioMixer,

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
mod clock; mod clock;
mod config; mod config;
mod executor;
mod io; mod io;
mod loop_runner; mod loop_runner;
mod session; mod session;
pub use clock::{FrameClock, NoopClock, PacingClock}; pub use clock::{FrameClock, NoopClock, PacingClock};
pub use config::HostConfig; pub use config::HostConfig;
pub use executor::{FrameExecution, FrameExecutor};
pub use io::{AudioOutput, InputProvider, NullAudio, NullInput, NullVideo, VideoOutput}; 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}; pub use session::{ClientRuntime, EmulationState};

View File

@@ -2,9 +2,8 @@ use crate::runtime::RuntimeError;
use super::clock::{FrameClock, PacingClock}; use super::clock::{FrameClock, PacingClock};
use super::config::HostConfig; use super::config::HostConfig;
use super::executor::FrameExecution;
use super::io::{AudioOutput, InputProvider, VideoOutput}; use super::io::{AudioOutput, InputProvider, VideoOutput};
use super::loop_runner::RuntimeHostLoop; use super::loop_runner::{FrameExecution, RuntimeHostLoop};
use crate::runtime::NesRuntime; use crate::runtime::NesRuntime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -18,9 +18,9 @@ pub use constants::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, SAVE_STATE_VERS
pub use core::NesRuntime; pub use core::NesRuntime;
pub use error::RuntimeError; pub use error::RuntimeError;
pub use host::{ pub use host::{
AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, FrameExecutor, AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, HostConfig,
HostConfig, InputProvider, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, InputProvider, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, RuntimeHostLoop,
RuntimeHostLoop, VideoOutput, VideoOutput,
}; };
pub use timing::{FramePacer, VideoMode}; pub use timing::{FramePacer, VideoMode};
pub use types::{ pub use types::{
@@ -32,10 +32,10 @@ pub mod prelude {
#[cfg(feature = "adapter-api")] #[cfg(feature = "adapter-api")]
pub use crate::runtime::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter}; pub use crate::runtime::{AudioAdapter, ClockAdapter, InputAdapter, VideoAdapter};
pub use crate::runtime::{ pub use crate::runtime::{
AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, FrameExecutor, AudioOutput, ClientRuntime, EmulationState, FrameClock, FrameExecution, HostConfig,
HostConfig, InputProvider, JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, InputProvider, JOYPAD_BUTTON_ORDER, JOYPAD_BUTTONS_COUNT, JoypadButton, JoypadButtons,
JoypadButtons, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, NesRuntime, NoopClock, NullAudio, NullInput, NullVideo, PacingClock, RuntimeError,
RuntimeError, RuntimeHostLoop, VideoOutput, button_pressed, set_button_pressed, RuntimeHostLoop, VideoOutput, button_pressed, set_button_pressed,
}; };
} }