Files
nesemu/crates/nesemu-desktop/src/main.rs
se.cherkasov bdf23de8db
Some checks failed
CI / rust (push) Has been cancelled
Initial commit: NES emulator with GTK4 desktop frontend
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.
2026-03-13 11:48:45 +03:00

506 lines
16 KiB
Rust

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: &gtk::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
}
}