Initial commit: NES emulator with GTK4 desktop frontend
Some checks failed
CI / rust (push) Has been cancelled
Some checks failed
CI / rust (push) Has been cancelled
Full NES emulation: CPU, PPU, APU, 47 mappers, iNES/NES 2.0 parsing. GTK4 desktop client with HeaderBar, pixel-perfect Cairo rendering, drag-and-drop ROM loading, and keyboard shortcuts. 187 tests covering core emulation, mappers, and runtime.
This commit is contained in:
9
crates/nesemu-desktop/Cargo.toml
Normal file
9
crates/nesemu-desktop/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "nesemu-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
nesemu = { path = "../.." }
|
||||
gtk4 = "0.8"
|
||||
cairo-rs = "0.19"
|
||||
505
crates/nesemu-desktop/src/main.rs
Normal file
505
crates/nesemu-desktop/src/main.rs
Normal file
@@ -0,0 +1,505 @@
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk::gio;
|
||||
use gtk::gdk;
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use gtk4 as gtk;
|
||||
use nesemu::prelude::{EmulationState, HostConfig, RuntimeHostLoop};
|
||||
use nesemu::{
|
||||
FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, FrameClock, InputProvider, JoypadButton,
|
||||
JoypadButtons, NesRuntime, set_button_pressed,
|
||||
};
|
||||
|
||||
const APP_ID: &str = "org.nesemu.desktop";
|
||||
const TITLE: &str = "NES Emulator";
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
let app = gtk::Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.build();
|
||||
|
||||
let initial_rom: Rc<RefCell<Option<PathBuf>>> =
|
||||
Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from)));
|
||||
|
||||
let initial_rom_for_activate = Rc::clone(&initial_rom);
|
||||
app.connect_activate(move |app| {
|
||||
let rom = initial_rom_for_activate.borrow_mut().take();
|
||||
build_ui(app, rom);
|
||||
});
|
||||
|
||||
app.run_with_args::<&str>(&[]);
|
||||
}
|
||||
|
||||
fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title(TITLE)
|
||||
.default_width((FRAME_WIDTH as i32) * SCALE)
|
||||
.default_height((FRAME_HEIGHT as i32) * SCALE)
|
||||
.build();
|
||||
|
||||
// --- Header bar ---
|
||||
let header = gtk::HeaderBar::new();
|
||||
|
||||
let open_button = gtk::Button::builder()
|
||||
.icon_name("document-open-symbolic")
|
||||
.tooltip_text("Open ROM (Ctrl+O)")
|
||||
.focusable(false)
|
||||
.build();
|
||||
|
||||
let pause_button = gtk::Button::builder()
|
||||
.icon_name("media-playback-pause-symbolic")
|
||||
.tooltip_text("Pause / Resume (P)")
|
||||
.focusable(false)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
|
||||
let reset_button = gtk::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Reset (Ctrl+R)")
|
||||
.focusable(false)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
|
||||
header.pack_start(&open_button);
|
||||
header.pack_start(&pause_button);
|
||||
header.pack_start(&reset_button);
|
||||
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
// --- Drawing area ---
|
||||
let drawing_area = gtk::DrawingArea::new();
|
||||
drawing_area.set_hexpand(true);
|
||||
drawing_area.set_vexpand(true);
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.set_child(Some(&drawing_area));
|
||||
|
||||
let drop_label = gtk::Label::builder()
|
||||
.label("Drop a .nes ROM here\nor press Ctrl+O to open")
|
||||
.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);
|
||||
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
// --- State ---
|
||||
let desktop = Rc::new(RefCell::new(DesktopApp::new()));
|
||||
let frame_for_draw: Rc<RefCell<Vec<u8>>> =
|
||||
Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES]));
|
||||
|
||||
// --- 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();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Helper to sync UI with emulation state ---
|
||||
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();
|
||||
move |app_state: &DesktopApp, rom_name: Option<&str>| {
|
||||
let loaded = app_state.is_loaded();
|
||||
pause_button.set_sensitive(loaded);
|
||||
reset_button.set_sensitive(loaded);
|
||||
drop_label.set_visible(!loaded);
|
||||
|
||||
if app_state.state() == EmulationState::Running {
|
||||
pause_button.set_icon_name("media-playback-pause-symbolic");
|
||||
pause_button.set_tooltip_text(Some("Pause (P)"));
|
||||
} else {
|
||||
pause_button.set_icon_name("media-playback-start-symbolic");
|
||||
pause_button.set_tooltip_text(Some("Resume (P)"));
|
||||
}
|
||||
|
||||
if let Some(name) = rom_name {
|
||||
window.set_title(Some(&format!("{TITLE} — {name}")));
|
||||
}
|
||||
}
|
||||
};
|
||||
let sync_ui = Rc::new(sync_ui);
|
||||
|
||||
// --- Load initial ROM ---
|
||||
{
|
||||
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));
|
||||
}
|
||||
} else {
|
||||
sync_ui(&app_state, None);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Open ROM handler ---
|
||||
let do_open_rom = {
|
||||
let desktop = Rc::clone(&desktop);
|
||||
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 desktop = Rc::clone(&desktop);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
chooser.connect_response(move |dialog, response| {
|
||||
if response == gtk::ResponseType::Accept {
|
||||
if let Some(path) = dialog.file().and_then(|f| f.path()) {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if let Err(err) = app_state.load_rom_from_path(&path) {
|
||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||
} else {
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chooser.show();
|
||||
})
|
||||
};
|
||||
|
||||
// --- Button handlers ---
|
||||
{
|
||||
let do_open_rom = Rc::clone(&do_open_rom);
|
||||
open_button.connect_clicked(move |_| {
|
||||
do_open_rom();
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
pause_button.connect_clicked(move |_| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
app_state.toggle_pause();
|
||||
sync_ui(&app_state, None);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
reset_button.connect_clicked(move |_| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
app_state.reset();
|
||||
sync_ui(&app_state, None);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts via actions ---
|
||||
let action_open = gio::SimpleAction::new("open", None);
|
||||
{
|
||||
let do_open_rom = Rc::clone(&do_open_rom);
|
||||
action_open.connect_activate(move |_, _| {
|
||||
do_open_rom();
|
||||
});
|
||||
}
|
||||
window.add_action(&action_open);
|
||||
app.set_accels_for_action("win.open", &["<Ctrl>o"]);
|
||||
|
||||
let action_pause = gio::SimpleAction::new("toggle-pause", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
action_pause.connect_activate(move |_, _| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if app_state.is_loaded() {
|
||||
app_state.toggle_pause();
|
||||
sync_ui(&app_state, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.add_action(&action_pause);
|
||||
app.set_accels_for_action("win.toggle-pause", &["p"]);
|
||||
|
||||
let action_reset = gio::SimpleAction::new("reset", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
action_reset.connect_activate(move |_, _| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if app_state.is_loaded() {
|
||||
app_state.reset();
|
||||
sync_ui(&app_state, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.add_action(&action_reset);
|
||||
app.set_accels_for_action("win.reset", &["<Ctrl>r"]);
|
||||
|
||||
// --- Keyboard controller for joypad input ---
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let key_controller = gtk::EventControllerKey::new();
|
||||
|
||||
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);
|
||||
gtk::glib::Propagation::Proceed
|
||||
});
|
||||
|
||||
key_controller.connect_key_released(move |_, key, _, _| {
|
||||
desktop.borrow_mut().input_mut().set_key_state(key, false);
|
||||
});
|
||||
|
||||
window.add_controller(key_controller);
|
||||
}
|
||||
|
||||
// --- Drag-and-drop ---
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY);
|
||||
drop_target.connect_drop(move |_, value, _, _| {
|
||||
if let Ok(file) = value.get::<gio::File>() {
|
||||
if let Some(path) = file.path() {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if let Err(err) = app_state.load_rom_from_path(&path) {
|
||||
eprintln!("Failed to load ROM '{}': {err}", path.display());
|
||||
return false;
|
||||
}
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
drawing_area.add_controller(drop_target);
|
||||
}
|
||||
|
||||
// --- Game loop ---
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let drawing_area = drawing_area.clone();
|
||||
let frame_for_draw = Rc::clone(&frame_for_draw);
|
||||
glib::timeout_add_local(Duration::from_millis(16), move || {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
app_state.tick();
|
||||
|
||||
frame_for_draw
|
||||
.borrow_mut()
|
||||
.copy_from_slice(app_state.frame_rgba());
|
||||
drawing_area.queue_draw();
|
||||
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
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 (stub)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct AudioSink;
|
||||
|
||||
impl nesemu::AudioOutput for AudioSink {
|
||||
fn push_samples(&mut self, _samples: &[f32]) {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct DesktopApp {
|
||||
host: Option<RuntimeHostLoop<Box<dyn FrameClock>>>,
|
||||
input: InputState,
|
||||
audio: AudioSink,
|
||||
frame_rgba: Vec<u8>,
|
||||
state: EmulationState,
|
||||
}
|
||||
|
||||
impl DesktopApp {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
input: InputState::default(),
|
||||
audio: AudioSink,
|
||||
frame_rgba: vec![0; FRAME_RGBA_BYTES],
|
||||
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.state = EmulationState::Running;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
if let Some(host) = self.host.as_mut() {
|
||||
host.runtime_mut().reset();
|
||||
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;
|
||||
};
|
||||
|
||||
let mut null_video = nesemu::NullVideo;
|
||||
if let Err(err) = host.run_frame_unpaced(&mut self.input, &mut null_video, &mut self.audio)
|
||||
{
|
||||
eprintln!("Frame execution error: {err}");
|
||||
self.state = EmulationState::Paused;
|
||||
return;
|
||||
}
|
||||
|
||||
self.frame_rgba
|
||||
.copy_from_slice(&host.runtime().frame_rgba());
|
||||
}
|
||||
|
||||
fn frame_rgba(&self) -> &[u8] {
|
||||
&self.frame_rgba
|
||||
}
|
||||
|
||||
fn input_mut(&mut self) -> &mut InputState {
|
||||
&mut self.input
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user