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.
506 lines
16 KiB
Rust
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: >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
|
|
}
|
|
}
|