diff --git a/Cargo.lock b/Cargo.lock index 384f4a1..2999beb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/nesemu-desktop/Cargo.toml b/crates/nesemu-desktop/Cargo.toml index 5214ffe..7855a3e 100644 --- a/crates/nesemu-desktop/Cargo.toml +++ b/crates/nesemu-desktop/Cargo.toml @@ -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" diff --git a/crates/nesemu-desktop/src/app.rs b/crates/nesemu-desktop/src/app.rs index 73674f4..2c50ddb 100644 --- a/crates/nesemu-desktop/src/app.rs +++ b/crates/nesemu-desktop/src/app.rs @@ -13,18 +13,22 @@ use crate::SAMPLE_RATE; pub(crate) struct DesktopApp { session: Option>>, - input: InputState, + input_p1: InputState, + input_p2: InputState, audio: CpalAudioSink, video: BufferedVideo, + save_slot: Option>, } impl DesktopApp { pub(crate) fn new(volume: Arc) -> Self { Self { session: None, - input: InputState::default(), + input_p1: InputState::default(), + input_p2: InputState::default(), audio: CpalAudioSink::new(volume), video: BufferedVideo::new(), + save_slot: None, } } @@ -74,12 +78,18 @@ impl DesktopApp { return; }; - match session.tick(&mut self.input, &mut self.video, &mut self.audio) { - Ok(_) => {} - Err(err) => { - eprintln!("Frame execution error: {err}"); - session.pause(); - } + // 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(); } } @@ -94,7 +104,49 @@ impl DesktopApp { .unwrap_or_else(|| VideoMode::Ntsc.frame_duration()) } - pub(crate) fn input_mut(&mut self) -> &mut InputState { - &mut self.input + 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 { + 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) + } + } diff --git a/crates/nesemu-desktop/src/input.rs b/crates/nesemu-desktop/src/input.rs index c319c08..bf1625f 100644 --- a/crates/nesemu-desktop/src/input.rs +++ b/crates/nesemu-desktop/src/input.rs @@ -7,18 +7,7 @@ pub(crate) struct InputState { } impl InputState { - pub(crate) 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, - }; + pub(crate) fn set_button(&mut self, button: JoypadButton, pressed: bool) { set_button_pressed(&mut self.buttons, button, pressed); } } @@ -28,3 +17,31 @@ impl InputProvider for InputState { self.buttons } } + +pub(crate) fn key_to_p1_button(key: gdk::Key) -> Option { + match key { + gdk::Key::Up => Some(JoypadButton::Up), + gdk::Key::Down => Some(JoypadButton::Down), + gdk::Key::Left => Some(JoypadButton::Left), + gdk::Key::Right => Some(JoypadButton::Right), + gdk::Key::x | gdk::Key::X => Some(JoypadButton::A), + gdk::Key::z | gdk::Key::Z => Some(JoypadButton::B), + gdk::Key::Return => Some(JoypadButton::Start), + gdk::Key::Shift_L | gdk::Key::Shift_R => Some(JoypadButton::Select), + _ => None, + } +} + +pub(crate) fn key_to_p2_button(key: gdk::Key) -> Option { + match key { + gdk::Key::w | gdk::Key::W => Some(JoypadButton::Up), + gdk::Key::s | gdk::Key::S => Some(JoypadButton::Down), + gdk::Key::a | gdk::Key::A => Some(JoypadButton::Left), + gdk::Key::d | gdk::Key::D => Some(JoypadButton::Right), + gdk::Key::k | gdk::Key::K => Some(JoypadButton::A), + gdk::Key::j | gdk::Key::J => Some(JoypadButton::B), + gdk::Key::i | gdk::Key::I => Some(JoypadButton::Start), + gdk::Key::u | gdk::Key::U => Some(JoypadButton::Select), + _ => None, + } +} diff --git a/crates/nesemu-desktop/src/main.rs b/crates/nesemu-desktop/src/main.rs index 83390b8..2a8da8b 100644 --- a/crates/nesemu-desktop/src/main.rs +++ b/crates/nesemu-desktop/src/main.rs @@ -4,7 +4,7 @@ mod input; mod scheduling; mod video; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; @@ -17,10 +17,11 @@ use gtk::glib; use gtk::prelude::*; use gtk4 as gtk; use nesemu::prelude::EmulationState; -use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH}; +use nesemu::{FRAME_HEIGHT, FRAME_WIDTH}; use app::DesktopApp; use scheduling::DesktopFrameScheduler; +use video::NesScreen; const APP_ID: &str = "org.nesemu.desktop"; const TITLE: &str = "NES Emulator"; @@ -28,11 +29,7 @@ const SCALE: i32 = 3; const SAMPLE_RATE: u32 = 48_000; 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(); @@ -53,7 +50,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { .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 --- @@ -108,45 +105,48 @@ fn build_ui(app: >k::Application, initial_rom: Option) { 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>> = 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(); - video::draw_frame(&frame, cr, width, height); - }); - } - // --- Helper to sync UI with emulation state --- + let current_rom_name: Rc>> = 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); @@ -162,7 +162,11 @@ fn build_ui(app: >k::Application, initial_rom: Option) { } 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,12 +176,17 @@ fn build_ui(app: >k::Application, initial_rom: Option) { { let mut app_state = desktop.borrow_mut(); if let Some(path) = initial_rom { - if let Err(err) = app_state.load_rom_from_path(&path) { - eprintln!("Failed to load ROM '{}': {err}", path.display()); - sync_ui(&app_state, None); - } else { - let name = rom_filename(&path); - sync_ui(&app_state, Some(&name)); + match app_state.load_rom_from_path(&path) { + Ok(()) => { + let name = rom_filename(&path); + sync_ui(&app_state, Some(&name)); + } + Err(err) => { + log::error!("Failed to load ROM '{}': {err}", path.display()); + sync_ui(&app_state, None); + drop(app_state); + show_error(&window, &format!("Failed to load ROM: {err}")); + } } } else { sync_ui(&app_state, None); @@ -191,42 +200,48 @@ fn build_ui(app: >k::Application, initial_rom: Option) { 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::(); + 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 - && let Some(path) = dialog.file().and_then(|f| f.path()) - { - let mut app_state = desktop.borrow_mut(); - if let Err(err) = app_state.load_rom_from_path(&path) { - eprintln!("Failed to load ROM '{}': {err}", path.display()); - } else { + let parent = window.clone(); + let error_window = window.clone(); + dialog.open(Some(&parent), gio::Cancellable::NONE, move |result| { + let file = match result { + Ok(file) => file, + Err(_) => return, // user cancelled + }; + let Some(path) = file.path() else { return }; + let mut app_state = desktop.borrow_mut(); + match app_state.load_rom_from_path(&path) { + Ok(()) => { scheduler.borrow_mut().reset_timing(); let name = rom_filename(&path); sync_ui(&app_state, Some(&name)); } + Err(err) => { + drop(app_state); + show_error(&error_window, &format!("Failed to load ROM: {err}")); + } } }); - - chooser.show(); }) }; @@ -307,6 +322,76 @@ fn build_ui(app: >k::Application, initial_rom: Option) { window.add_action(&action_reset); app.set_accels_for_action("win.reset", &["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", &["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", &["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); @@ -315,12 +400,23 @@ fn build_ui(app: >k::Application, initial_rom: Option) { let desktop_for_press = Rc::clone(&desktop); key_controller.connect_key_pressed(move |_, key, _, _| { let mut app_state = desktop_for_press.borrow_mut(); - app_state.input_mut().set_key_state(key, true); + if let Some(btn) = input::key_to_p1_button(key) { + app_state.input_p1_mut().set_button(btn, true); + } + if let Some(btn) = input::key_to_p2_button(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 mut app_state = desktop.borrow_mut(); + if let Some(btn) = input::key_to_p1_button(key) { + app_state.input_p1_mut().set_button(btn, false); + } + if let Some(btn) = input::key_to_p2_button(key) { + app_state.input_p2_mut().set_button(btn, false); + } }); window.add_controller(key_controller); @@ -331,32 +427,43 @@ fn build_ui(app: >k::Application, initial_rom: Option) { 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::() && 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; + } } - scheduler.borrow_mut().reset_timing(); - let name = rom_filename(&path); - sync_ui(&app_state, Some(&name)); - return true; } false }); - drawing_area.add_controller(drop_target); + screen.add_controller(drop_target); } + // --- FPS counter state --- + let fps_state = Rc::new(FpsCounter::new()); + // --- Game loop --- { schedule_game_loop( Rc::clone(&desktop), - drawing_area.clone(), - Rc::clone(&frame_for_draw), + screen.clone(), + fps_label.clone(), + Rc::clone(&fps_state), Rc::clone(&scheduler), ); } @@ -364,16 +471,42 @@ fn build_ui(app: >k::Application, initial_rom: Option) { window.present(); } +struct FpsCounter { + frame_count: Cell, + last_update: Cell, +} + +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>, - drawing_area: gtk::DrawingArea, - frame_for_draw: Rc>>, + screen: NesScreen, + fps_label: gtk::Label, + fps_state: Rc, scheduler: Rc>, ) { - 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 || { { @@ -383,17 +516,27 @@ 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()) diff --git a/crates/nesemu-desktop/src/scheduling.rs b/crates/nesemu-desktop/src/scheduling.rs index 3651812..d70ab31 100644 --- a/crates/nesemu-desktop/src/scheduling.rs +++ b/crates/nesemu-desktop/src/scheduling.rs @@ -15,11 +15,7 @@ impl DesktopFrameScheduler { self.next_deadline = None; } - pub(crate) fn delay_until_next_frame( - &mut self, - now: Instant, - _interval: Duration, - ) -> Duration { + pub(crate) fn delay_until_next_frame(&mut self, now: Instant) -> Duration { match self.next_deadline { None => { self.next_deadline = Some(now); @@ -50,16 +46,16 @@ mod tests { let interval = Duration::from_micros(16_639); assert_eq!( - scheduler.delay_until_next_frame(start, interval), + 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), interval) + scheduler.delay_until_next_frame(start + Duration::from_millis(1)) > Duration::ZERO ); assert_eq!( - scheduler.delay_until_next_frame(start + interval, interval), + scheduler.delay_until_next_frame(start + interval), Duration::ZERO ); } @@ -71,15 +67,15 @@ mod tests { let interval = Duration::from_micros(16_639); assert_eq!( - scheduler.delay_until_next_frame(start, interval), + scheduler.delay_until_next_frame(start), Duration::ZERO ); 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(); assert_eq!( - scheduler.delay_until_next_frame(start, interval), + scheduler.delay_until_next_frame(start), Duration::ZERO ); } @@ -91,13 +87,13 @@ mod tests { let interval = Duration::from_micros(16_639); assert_eq!( - scheduler.delay_until_next_frame(start, interval), + 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), interval), + scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2)), Duration::ZERO ); } diff --git a/crates/nesemu-desktop/src/video.rs b/crates/nesemu-desktop/src/video.rs index fcd5ba2..e15fce0 100644 --- a/crates/nesemu-desktop/src/video.rs +++ b/crates/nesemu-desktop/src/video.rs @@ -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}; 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 - .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; +// --------------------------------------------------------------------------- +// 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>, + } + + #[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); } } - 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"); +} - cr.set_source_rgb(0.0, 0.0, 0.0); - let _ = cr.paint(); +glib::wrapper! { + pub struct NesScreen(ObjectSubclass) + @extends gtk::Widget; +} - 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; +impl NesScreen { + pub(crate) fn new() -> Self { + glib::Object::builder().build() + } - 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(); + 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)]