From 2034f7934bd5974cbaca020684efd0b5a7e4f02f Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sun, 23 Nov 2025 15:45:57 -0500 Subject: [PATCH] Support manual/automatic ROM reloading --- Cargo.lock | 55 +++++++++++++++++++++ Cargo.toml | 1 + src/config.rs | 5 +- src/emulator.rs | 113 +++++++++++++++++++++++++++++++++++++------ src/emulator/cart.rs | 60 ++++++++++++++++++++++- src/input.rs | 9 +++- src/main.rs | 11 +++-- src/window/game.rs | 16 ++++++ 8 files changed, 248 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf19f2a..5703008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1449,6 +1449,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -2157,6 +2166,26 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2189,6 +2218,7 @@ dependencies = [ "image", "itertools 0.14.0", "normpath", + "notify", "num-derive", "num-traits", "object 0.37.3", @@ -2482,6 +2512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -2624,6 +2655,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.9.3", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + [[package]] name = "nu-ansi-term" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 5ceb489..1efad83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ hex = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } itertools = "0.14" normpath = "1" +notify = "8" num-derive = "0.4" num-traits = "0.2" object = "0.37" diff --git a/src/config.rs b/src/config.rs index 0918110..8ca3273 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,7 +26,7 @@ pub struct CliArgs { #[arg(short, long)] pub object_data: bool, /// Open worlds window - #[arg(short, long)] + #[arg(long)] pub worlds: bool, /// Open frame buffers window #[arg(short, long)] @@ -37,6 +37,9 @@ pub struct CliArgs { /// Open terminal #[arg(short, long)] pub terminal: bool, + /// Watch ROM files for changes, automatically reload + #[arg(short, long)] + pub watch: bool, } pub const COLOR_PRESETS: [[Color32; 2]; 3] = [ diff --git a/src/emulator.rs b/src/emulator.rs index 4dc4a88..976dde1 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -5,7 +5,7 @@ use std::{ sync::{ Arc, Weak, atomic::{AtomicBool, Ordering}, - mpsc::{self, RecvError, TryRecvError}, + mpsc::{self, RecvTimeoutError, TryRecvError}, }, time::Duration, }; @@ -75,6 +75,7 @@ pub struct EmulatorBuilder { audio_on: Arc<[AtomicBool; 2]>, linked: Arc, start_paused: bool, + watch_rom: Arc<[AtomicBool; 2]>, } impl EmulatorBuilder { @@ -91,11 +92,13 @@ impl EmulatorBuilder { audio_on: Arc::new([AtomicBool::new(true), AtomicBool::new(true)]), linked: Arc::new(AtomicBool::new(false)), start_paused: false, + watch_rom: Arc::new([AtomicBool::new(false), AtomicBool::new(false)]), }; let client = EmulatorClient { queue, sim_state: builder.sim_state.clone(), state: builder.state.clone(), + watch_rom: builder.watch_rom.clone(), linked: builder.linked.clone(), }; (builder, client) @@ -121,12 +124,19 @@ impl EmulatorBuilder { self } + pub fn with_watch_rom(self, p1: bool, p2: bool) -> Self { + self.watch_rom[0].store(p1, Ordering::Relaxed); + self.watch_rom[1].store(p2, Ordering::Relaxed); + self + } + pub fn build(self) -> Result { let mut emulator = Emulator::new( self.commands, self.sim_state, self.state, self.audio_on, + self.watch_rom, self.linked, )?; if let Some(path) = self.rom { @@ -147,6 +157,7 @@ pub struct Emulator { sim_state: Arc<[Atomic; 2]>, state: Arc>, audio_on: Arc<[AtomicBool; 2]>, + watch_rom: Arc<[AtomicBool; 2]>, linked: Arc, profilers: [Option; 2], renderers: HashMap, @@ -165,6 +176,7 @@ impl Emulator { sim_state: Arc<[Atomic; 2]>, state: Arc>, audio_on: Arc<[AtomicBool; 2]>, + watch_rom: Arc<[AtomicBool; 2]>, linked: Arc, ) -> Result { Ok(Self { @@ -175,6 +187,7 @@ impl Emulator { sim_state, state, audio_on, + watch_rom, linked, profilers: [None, None], renderers: HashMap::new(), @@ -188,9 +201,18 @@ impl Emulator { }) } + pub fn reload_cart(&mut self, sim_id: SimId) -> Result<()> { + let Some(cart) = &self.carts[sim_id.to_index()] else { + return Ok(()); + }; + let path = cart.file_path.clone(); + self.load_cart(sim_id, &path) + } + pub fn load_cart(&mut self, sim_id: SimId, path: &Path) -> Result<()> { - let cart = Cart::load(path, sim_id)?; - self.reset_sim(sim_id, Some(cart))?; + let watch = self.watch_rom[sim_id.to_index()].load(Ordering::Acquire); + let cart = Cart::load(path, sim_id, watch)?; + self.try_reset_sim(sim_id, Some(cart))?; Ok(()) } @@ -200,16 +222,26 @@ impl Emulator { } else { self.carts[0].as_ref().map(|c| c.file_path.clone()) }; + let watch = self.watch_rom[SimId::Player2.to_index()].load(Ordering::Acquire); let cart = match file_path { - Some(rom_path) => Some(Cart::load(&rom_path, SimId::Player2)?), + Some(rom_path) => Some(Cart::load(&rom_path, SimId::Player2, watch)?), None => None, }; - self.reset_sim(SimId::Player2, cart)?; + self.try_reset_sim(SimId::Player2, cart)?; self.link_sims(); Ok(()) } - fn reset_sim(&mut self, sim_id: SimId, new_cart: Option) -> Result<()> { + fn reset_sim(&mut self, sim_id: SimId, new_cart: Option) -> bool { + if let Err(error) = self.try_reset_sim(sim_id, new_cart) { + self.report_error(sim_id, format!("Error resetting sim: {error}")); + false + } else { + true + } + } + + fn try_reset_sim(&mut self, sim_id: SimId, new_cart: Option) -> Result<()> { self.save_sram(sim_id)?; let index = sim_id.to_index(); @@ -230,6 +262,10 @@ impl Emulator { sim.load_cart(cart.rom.clone(), cart.sram.clone())?; self.carts[index] = Some(cart); self.sim_state[index].store(SimState::Ready, Ordering::Release); + } else if let Some(cart) = self.carts[index].as_mut() + && cart.is_watching() + { + cart.restart_watching(); } let mut profiling = false; if let Some(profiler) = self.profilers[sim_id.to_index()].as_ref() @@ -346,7 +382,7 @@ impl Emulator { fn start_profiling(&mut self, sim_id: SimId, sender: ProfileSender) -> Result<()> { self.profilers[sim_id.to_index()] = Some(sender); - self.reset_sim(sim_id, None) + self.try_reset_sim(sim_id, None) } fn start_debugging(&mut self, sim_id: SimId, sender: DebugSender) { @@ -422,11 +458,30 @@ impl Emulator { let idle = self.tick(); if idle { // The game is paused, and we have output all the video/audio we have. - // Block the thread until a new command comes in. - match self.commands.recv() { - Ok(command) => self.handle_command(command), - Err(RecvError) => { - return; + // Block the thread until a new command comes in, or a ROM changes. + loop { + match self.commands.recv_timeout(Duration::from_millis(250)) { + Ok(command) => { + self.handle_command(command); + break; + } + Err(RecvTimeoutError::Timeout) => { + let mut changed = false; + for sim_id in SimId::values() { + let Some(cart) = self.carts[sim_id.to_index()].as_ref() else { + continue; + }; + if cart.changed() { + changed |= self.reset_sim(sim_id, None); + } + } + if changed { + break; + } + } + Err(RecvTimeoutError::Disconnected) => { + return; + } } } } @@ -441,6 +496,14 @@ impl Emulator { } } } + for sim_id in SimId::values() { + let Some(cart) = self.carts[sim_id.to_index()].as_ref() else { + continue; + }; + if cart.changed() { + self.reset_sim(sim_id, None); + } + } self.watched_regions.retain(|range, region| { let Some(region) = region.upgrade() else { return false; @@ -607,6 +670,22 @@ impl Emulator { self.report_error(sim_id, format!("Error loading rom: {error}")); } } + EmulatorCommand::ReloadRom(sim_id) => { + if let Err(error) = self.reload_cart(sim_id) { + self.report_error(sim_id, format!("Error loading rom: {error}")); + } + } + EmulatorCommand::WatchRom(sim_id, watch) => { + self.watch_rom[sim_id.to_index()].store(watch, Ordering::Release); + let Some(cart) = self.carts[sim_id.to_index()].as_mut() else { + return; + }; + if watch { + cart.restart_watching(); + } else { + cart.stop_watching(); + } + } EmulatorCommand::StartSecondSim(path) => { if let Err(error) = self.start_second_sim(path) { self.report_error( @@ -730,9 +809,7 @@ impl Emulator { self.unlink_sims(); } EmulatorCommand::Reset(sim_id) => { - if let Err(error) = self.reset_sim(sim_id, None) { - self.report_error(sim_id, format!("Error resetting sim: {error}")); - } + self.reset_sim(sim_id, None); } EmulatorCommand::SetKeys(sim_id, keys) => { if let Some(sim) = self.sims.get_mut(sim_id.to_index()) { @@ -774,6 +851,8 @@ impl Emulator { pub enum EmulatorCommand { ConnectToSim(SimId, TextureSink, mpsc::Sender), LoadGame(SimId, PathBuf), + ReloadRom(SimId), + WatchRom(SimId, bool), StartSecondSim(Option), StopSecondSim, Pause, @@ -863,6 +942,7 @@ pub struct EmulatorClient { sim_state: Arc<[Atomic; 2]>, state: Arc>, linked: Arc, + watch_rom: Arc<[AtomicBool; 2]>, } impl EmulatorClient { @@ -875,6 +955,9 @@ impl EmulatorClient { pub fn are_sims_linked(&self) -> bool { self.linked.load(Ordering::Acquire) } + pub fn is_rom_watched(&self, sim_id: SimId) -> bool { + self.watch_rom[sim_id.to_index()].load(Ordering::Acquire) + } pub fn send_command(&self, command: EmulatorCommand) -> bool { match self.queue.send(command) { Ok(()) => true, diff --git a/src/emulator/cart.rs b/src/emulator/cart.rs index 4bb6a39..4a6d136 100644 --- a/src/emulator/cart.rs +++ b/src/emulator/cart.rs @@ -1,10 +1,11 @@ use anyhow::Result; +use notify::Watcher; use rand::Rng; use std::{ fs::{self, File}, io::{Read, Seek as _, SeekFrom, Write as _}, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, atomic::AtomicBool}, }; use crate::emulator::{SimId, game_info::GameInfo, shrooms_vb_util::rom_from_isx}; @@ -15,10 +16,12 @@ pub struct Cart { sram_file: File, pub sram: Vec, pub info: Arc, + watcher: Option, + changed: Arc, } impl Cart { - pub fn load(file_path: &Path, sim_id: SimId) -> Result { + pub fn load(file_path: &Path, sim_id: SimId, watch: bool) -> Result { let rom = fs::read(file_path)?; let (rom, info) = try_parse_isx(file_path, &rom) .or_else(|| try_parse_elf(file_path, &rom)) @@ -45,12 +48,21 @@ impl Cart { sram }; + let changed = Arc::new(AtomicBool::new(false)); + let watcher = if watch { + build_watcher(file_path, changed.clone()) + } else { + None + }; + Ok(Cart { file_path: file_path.to_path_buf(), rom, sram_file, sram, info: Arc::new(info), + watcher, + changed, }) } @@ -59,6 +71,50 @@ impl Cart { self.sram_file.write_all(&self.sram)?; Ok(()) } + + pub fn changed(&self) -> bool { + self.changed.load(std::sync::atomic::Ordering::Relaxed) + } + + pub fn is_watching(&self) -> bool { + self.watcher.is_some() + } + + pub fn restart_watching(&mut self) { + self.changed = Arc::new(AtomicBool::new(false)); + if let Some(mut watcher) = self.watcher.take() { + let _ = watcher.unwatch(&self.file_path); + }; + self.watcher = build_watcher(&self.file_path, self.changed.clone()); + } + + pub fn stop_watching(&mut self) { + self.changed = Arc::new(AtomicBool::new(false)); + self.watcher = None; + } +} + +fn build_watcher(file_path: &Path, changed: Arc) -> Option { + let file_path = file_path.to_path_buf(); + let mut watcher = + notify::recommended_watcher(move |event: Result| { + let Ok(e) = event else { + return; + }; + let modified = !matches!( + e.kind, + notify::EventKind::Access(_) + | notify::EventKind::Modify(notify::event::ModifyKind::Metadata(_)) + ); + if modified { + changed.store(true, std::sync::atomic::Ordering::Relaxed); + } + }) + .ok()?; + watcher + .watch(&file_path, notify::RecursiveMode::NonRecursive) + .ok()?; + Some(watcher) } fn try_parse_isx(file_path: &Path, data: &[u8]) -> Option<(Vec, GameInfo)> { diff --git a/src/input.rs b/src/input.rs index 2712d71..20ed118 100644 --- a/src/input.rs +++ b/src/input.rs @@ -460,6 +460,7 @@ struct PersistedGamepadMapping { #[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum Command { OpenRom, + ReloadRom, Quit, FrameAdvance, FastForward(u32), @@ -470,9 +471,10 @@ pub enum Command { } impl Command { - pub fn all() -> [Self; 7] { + pub fn all() -> [Self; 8] { [ Self::OpenRom, + Self::ReloadRom, Self::Quit, Self::PauseResume, Self::Reset, @@ -485,6 +487,7 @@ impl Command { pub fn name(self) -> &'static str { match self { Self::OpenRom => "Open ROM", + Self::ReloadRom => "Reload ROM", Self::Quit => "Exit", Self::PauseResume => "Pause/Resume", Self::Reset => "Reset", @@ -516,6 +519,10 @@ impl Default for Shortcuts { Command::OpenRom, KeyboardShortcut::new(Modifiers::COMMAND, Key::O), ); + shortcuts.set( + Command::ReloadRom, + KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::F5), + ); shortcuts.set( Command::Quit, KeyboardShortcut::new(Modifiers::COMMAND, Key::Q), diff --git a/src/main.rs b/src/main.rs index 4c0d93f..200328e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,9 +110,14 @@ fn main() -> Result<()> { if args.profile { builder = builder.start_paused(true) } - let p1_audio_on = SimConfig::load(&persistence, SimId::Player1).audio_enabled; - let p2_audio_on = SimConfig::load(&persistence, SimId::Player2).audio_enabled; - builder = builder.with_audio_on(p1_audio_on, p2_audio_on); + let p1 = SimConfig::load(&persistence, SimId::Player1); + let p2 = SimConfig::load(&persistence, SimId::Player2); + + let watch = args.watch; + + builder = builder + .with_audio_on(p1.audio_enabled, p2.audio_enabled) + .with_watch_rom(watch, watch); ThreadBuilder::default() .name("Emulator".to_owned()) diff --git a/src/window/game.rs b/src/window/game.rs index b4892e4..e85342a 100644 --- a/src/window/game.rs +++ b/src/window/game.rs @@ -85,6 +85,10 @@ impl GameWindow { .send_command(EmulatorCommand::LoadGame(self.sim_id, path)); } } + Command::ReloadRom => { + self.client + .send_command(EmulatorCommand::ReloadRom(self.sim_id)); + } Command::Quit => { let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id)); } @@ -137,6 +141,18 @@ impl GameWindow { .send_command(EmulatorCommand::LoadGame(self.sim_id, path)); } } + if ui + .add(self.button_for(ui.ctx(), "Reload ROM", Command::ReloadRom)) + .clicked() + { + self.client + .send_command(EmulatorCommand::ReloadRom(self.sim_id)); + } + let watch_rom = self.client.is_rom_watched(self.sim_id); + if ui.selectable_button(watch_rom, "Watch ROM").clicked() { + self.client + .send_command(EmulatorCommand::WatchRom(self.sim_id, !watch_rom)); + } if ui .add(self.button_for(ui.ctx(), "Quit", Command::Quit)) .clicked()