From e2c25cbae55672b5dd1e1f3ccb016fc0a5e00d21 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sun, 6 Sep 2026 21:30:46 -0400 Subject: [PATCH] Choose save data location --- src/app.rs | 2 +- src/config.rs | 26 +++++++++- src/emulator.rs | 116 +++++++++++++++++++++++++++---------------- src/emulator/cart.rs | 46 ++++++++++++++--- src/filesystem.rs | 52 +++++++++++++++++++ src/input.rs | 2 +- src/main.rs | 26 ++++------ src/persistence.rs | 46 ----------------- src/window/game.rs | 35 ++++++++++++- 9 files changed, 234 insertions(+), 117 deletions(-) create mode 100644 src/filesystem.rs delete mode 100644 src/persistence.rs diff --git a/src/app.rs b/src/app.rs index 8cb234a..a83f84e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,10 +26,10 @@ use crate::{ config::{AppConfig, CliArgs}, controller::ControllerManager, emulator::{EmulatorClient, EmulatorCommand, SimId}, + filesystem::Persistence, images::ImageTextureLoader, input::{MappingProvider, ShortcutProvider}, memory::MemoryClient, - persistence::Persistence, window::{AppWindow, ChildWindow, GameScreen, GameWindow, InitArgs}, }; diff --git a/src/config.rs b/src/config.rs index 883b9da..0706c3b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,10 @@ use anyhow::Result; +use bytemuck::NoUninit; use clap::{Parser, ValueEnum}; use egui::{Color32, Pos2, Vec2}; use serde::{Deserialize, Serialize}; -use crate::{emulator::SimId, persistence::Persistence, window::DisplayMode}; +use crate::{emulator::SimId, filesystem::Persistence, window::DisplayMode}; use std::path::PathBuf; #[derive(Parser)] @@ -53,6 +54,9 @@ pub struct CliArgs { /// Set a preference for whether to use a low-power or high-performance GPU adapter #[arg(long)] pub wgpu_power_preference: Option, + /// Choose whether save files are kept in the rom or the data directory + #[arg(long)] + pub save_data_location: Option, } #[derive(ValueEnum, Clone, Copy)] @@ -90,12 +94,27 @@ const fn default_power_preference() -> wgpu::PowerPreference { wgpu::PowerPreference::LowPower } +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ValueEnum, Debug, NoUninit)] +#[repr(usize)] +pub enum SaveDataLocation { + /// The same directory as the ROM + RomDirectory, + /// This application's data directory + DataDirectory, +} + +const fn default_save_data_location() -> SaveDataLocation { + SaveDataLocation::RomDirectory +} + #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct AppConfig { #[serde(default = "default_power_preference")] pub power_preference: wgpu::PowerPreference, #[serde(default)] pub force_fallback: bool, + #[serde(default = "default_save_data_location")] + pub save_data_location: SaveDataLocation, } impl AppConfig { @@ -106,6 +125,7 @@ impl AppConfig { Self { power_preference: default_power_preference(), force_fallback: false, + save_data_location: default_save_data_location(), } } @@ -123,6 +143,10 @@ impl AppConfig { self.force_fallback = force_fallback; updated = true; } + if let Some(save_data_location) = args.save_data_location { + self.save_data_location = save_data_location; + updated = true; + } updated } } diff --git a/src/emulator.rs b/src/emulator.rs index 9d44041..7293e54 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -18,6 +18,7 @@ use tracing::{error, warn}; use crate::{ audio::Audio, + config::SaveDataLocation, graphics::TextureSink, memory::{MemoryRange, MemoryRegion}, }; @@ -74,12 +75,13 @@ pub struct EmulatorBuilder { state: Arc>, audio_on: Arc<[AtomicBool; 2]>, linked: Arc, + save_data_location: Arc>, start_paused: bool, watch_rom: Arc<[AtomicBool; 2]>, } impl EmulatorBuilder { - pub fn new() -> (Self, EmulatorClient) { + pub fn new(save_data_location: SaveDataLocation) -> (Self, EmulatorClient) { let (queue, commands) = mpsc::channel(); let builder = Self { rom: None, @@ -91,6 +93,7 @@ impl EmulatorBuilder { state: Arc::new(Atomic::new(EmulatorState::Paused)), audio_on: Arc::new([AtomicBool::new(true), AtomicBool::new(true)]), linked: Arc::new(AtomicBool::new(false)), + save_data_location: Arc::new(Atomic::new(save_data_location)), start_paused: false, watch_rom: Arc::new([AtomicBool::new(false), AtomicBool::new(false)]), }; @@ -98,8 +101,9 @@ impl EmulatorBuilder { queue, sim_state: builder.sim_state.clone(), state: builder.state.clone(), - watch_rom: builder.watch_rom.clone(), linked: builder.linked.clone(), + save_data_location: builder.save_data_location.clone(), + watch_rom: builder.watch_rom.clone(), }; (builder, client) } @@ -131,18 +135,42 @@ impl EmulatorBuilder { } 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 { + let Self { + rom, + commands, + sim_state, + state, + audio_on, + watch_rom, + linked, + save_data_location, + start_paused, + } = self; + let mut emulator = Emulator { + sims: vec![], + carts: [None, None], + audio: Audio::init(), + commands, + sim_state, + state, + audio_on, + watch_rom, + linked, + save_data_location, + profilers: [None, None], + renderers: HashMap::new(), + messages: HashMap::new(), + debuggers: HashMap::new(), + stdouts: HashMap::new(), + watched_regions: HashMap::new(), + eye_contents: [vec![0u8; 384 * 224 * 2], vec![0u8; 384 * 224 * 2]], + audio_samples: Vec::with_capacity(EXPECTED_FRAME_SIZE), + buffer: vec![], + }; + if let Some(path) = rom { emulator.load_cart(SimId::Player1, &path)?; } - if self.start_paused { + if start_paused { emulator.pause_sims()?; } Ok(emulator) @@ -159,6 +187,7 @@ pub struct Emulator { audio_on: Arc<[AtomicBool; 2]>, watch_rom: Arc<[AtomicBool; 2]>, linked: Arc, + save_data_location: Arc>, profilers: [Option; 2], renderers: HashMap, messages: HashMap>, @@ -171,36 +200,6 @@ pub struct Emulator { } impl Emulator { - fn new( - commands: mpsc::Receiver, - sim_state: Arc<[Atomic; 2]>, - state: Arc>, - audio_on: Arc<[AtomicBool; 2]>, - watch_rom: Arc<[AtomicBool; 2]>, - linked: Arc, - ) -> Self { - Self { - sims: vec![], - carts: [None, None], - audio: Audio::init(), - commands, - sim_state, - state, - audio_on, - watch_rom, - linked, - profilers: [None, None], - renderers: HashMap::new(), - messages: HashMap::new(), - debuggers: HashMap::new(), - stdouts: HashMap::new(), - watched_regions: HashMap::new(), - eye_contents: [vec![0u8; 384 * 224 * 2], vec![0u8; 384 * 224 * 2]], - audio_samples: Vec::with_capacity(EXPECTED_FRAME_SIZE), - buffer: vec![], - } - } - pub fn reload_cart(&mut self, sim_id: SimId) -> Result<()> { let Some(cart) = &self.carts[sim_id.to_index()] else { return Ok(()); @@ -211,7 +210,12 @@ impl Emulator { pub fn load_cart(&mut self, sim_id: SimId, path: &Path) -> Result<()> { let watch = self.watch_rom[sim_id.to_index()].load(Ordering::Acquire); - let cart = Cart::load(path, sim_id, watch)?; + let cart = Cart::load( + path, + sim_id, + self.save_data_location.load(Ordering::Acquire), + watch, + )?; self.try_reset_sim(sim_id, Some(cart))?; Ok(()) } @@ -224,7 +228,12 @@ impl Emulator { }; 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, watch)?), + Some(rom_path) => Some(Cart::load( + &rom_path, + SimId::Player2, + self.save_data_location.load(Ordering::Acquire), + watch, + )?), None => None, }; self.try_reset_sim(SimId::Player2, cart)?; @@ -687,6 +696,20 @@ impl Emulator { cart.stop_watching(); } } + EmulatorCommand::SetSaveDataLocation(save_data_location) => { + self.save_data_location + .store(save_data_location, Ordering::Release); + for sim_id in SimId::values() { + if let Some(cart) = self.carts[sim_id.to_index()].as_mut() + && let Err(error) = cart.change_save_data_location(save_data_location) + { + self.report_error( + sim_id, + format!("Error changing save data location: {error}"), + ); + } + } + } EmulatorCommand::StartSecondSim(path) => { if let Err(error) = self.start_second_sim(path) { self.report_error( @@ -854,6 +877,7 @@ pub enum EmulatorCommand { LoadGame(SimId, PathBuf), ReloadRom(SimId), WatchRom(SimId, bool), + SetSaveDataLocation(SaveDataLocation), StartSecondSim(Option), StopSecondSim, Pause, @@ -943,6 +967,7 @@ pub struct EmulatorClient { sim_state: Arc<[Atomic; 2]>, state: Arc>, linked: Arc, + save_data_location: Arc>, watch_rom: Arc<[AtomicBool; 2]>, } @@ -959,6 +984,9 @@ impl EmulatorClient { pub fn are_sims_linked(&self) -> bool { self.linked.load(Ordering::Acquire) } + pub fn save_data_location(&self) -> SaveDataLocation { + self.save_data_location.load(Ordering::Acquire) + } pub fn is_rom_watched(&self, sim_id: SimId) -> bool { self.watch_rom[sim_id.to_index()].load(Ordering::Acquire) } diff --git a/src/emulator/cart.rs b/src/emulator/cart.rs index 6ad8df2..906c618 100644 --- a/src/emulator/cart.rs +++ b/src/emulator/cart.rs @@ -8,10 +8,15 @@ use std::{ sync::{Arc, atomic::AtomicBool}, }; -use crate::emulator::{SimId, game_info::GameInfo, shrooms_vb_util::rom_from_isx}; +use crate::{ + config::SaveDataLocation, + emulator::{SimId, game_info::GameInfo, shrooms_vb_util::rom_from_isx}, + filesystem, +}; pub struct Cart { pub file_path: PathBuf, + sim_id: SimId, pub rom: Vec, sram_file: File, pub sram: Vec, @@ -21,7 +26,12 @@ pub struct Cart { } impl Cart { - pub fn load(file_path: &Path, sim_id: SimId, watch: bool) -> Result { + pub fn load( + file_path: &Path, + sim_id: SimId, + save_data_location: SaveDataLocation, + watch: bool, + ) -> Result { let (rom, info) = Self::read_rom(file_path)?; let mut sram_file = File::options() @@ -29,7 +39,7 @@ impl Cart { .write(true) .create(true) .truncate(false) - .open(sram_path(file_path, sim_id))?; + .open(sram_path(file_path, sim_id, save_data_location))?; let sram = if sram_file.metadata()?.len() == 0 { // new SRAM file, randomize the contents @@ -54,6 +64,7 @@ impl Cart { Ok(Cart { file_path: file_path.to_path_buf(), + sim_id, rom, sram_file, sram, @@ -73,6 +84,19 @@ impl Cart { Ok(()) } + pub fn change_save_data_location( + &mut self, + save_data_location: SaveDataLocation, + ) -> Result<()> { + self.sram_file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(sram_path(&self.file_path, self.sim_id, save_data_location))?; + self.save_sram() + } + pub fn save_sram(&mut self) -> Result<()> { self.sram_file.seek(SeekFrom::Start(0))?; self.sram_file.write_all(&self.sram)?; @@ -177,9 +201,17 @@ fn parse_elf_program PathBuf { - match sim_id { - SimId::Player1 => file_path.with_extension("p1.sram"), - SimId::Player2 => file_path.with_extension("p2.sram"), +fn sram_path(file_path: &Path, sim_id: SimId, save_data_location: SaveDataLocation) -> PathBuf { + let extension = match sim_id { + SimId::Player1 => "p1.sram", + SimId::Player2 => "p2.sram", + }; + if let SaveDataLocation::DataDirectory = save_data_location + && let Some(data_dir) = filesystem::init_data_dir() + && let Some(rom_name) = file_path.file_name() + { + data_dir.join(rom_name).with_extension(extension) + } else { + file_path.with_extension(extension) } } diff --git a/src/filesystem.rs b/src/filesystem.rs new file mode 100644 index 0000000..fbf093d --- /dev/null +++ b/src/filesystem.rs @@ -0,0 +1,52 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Result, bail}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +#[derive(Clone)] +pub struct Persistence { + config_dir: Option, +} + +impl Persistence { + pub fn new() -> Self { + Self { + config_dir: init_config_dir(), + } + } + + pub fn save_config(&self, file: &str, data: &T) -> Result<()> { + if let Some(config_dir) = self.config_dir.as_ref() { + let bytes = serde_json::to_vec_pretty(data)?; + let filename = config_dir.join(file).with_extension("json"); + fs::write(&filename, bytes)?; + } + Ok(()) + } + + pub fn load_config Deserialize<'a>>(&self, file: &str) -> Result { + let Some(config_dir) = self.config_dir.as_ref() else { + bail!("config directory not found"); + }; + let filename = config_dir.join(file).with_extension("json"); + let bytes = fs::read(filename)?; + Ok(serde_json::from_slice(&bytes)?) + } +} + +fn init_config_dir() -> Option { + let config_dir = project_dirs()?.config_dir().to_path_buf(); + fs::create_dir_all(&config_dir).ok()?; + Some(config_dir) +} + +pub fn init_data_dir() -> Option { + let data_dir = project_dirs()?.data_dir().to_path_buf(); + fs::create_dir_all(&data_dir).ok()?; + Some(data_dir) +} + +fn project_dirs() -> Option { + ProjectDirs::from("com", "virtual-boy", "Lemur") +} diff --git a/src/input.rs b/src/input.rs index 20fcf2c..05e14bd 100644 --- a/src/input.rs +++ b/src/input.rs @@ -14,7 +14,7 @@ use winit::keyboard::{KeyCode, PhysicalKey}; use crate::{ emulator::{SimId, VBKey}, - persistence::Persistence, + filesystem::Persistence, }; #[derive(Clone, PartialEq, Eq, Hash)] diff --git a/src/main.rs b/src/main.rs index 97c6f80..0b568cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use winit::event_loop::{ControlFlow, EventLoop}; use crate::{ config::{AppConfig, CliArgs, SimConfig}, emulator::SimId, - persistence::Persistence, + filesystem::Persistence, }; mod app; @@ -24,12 +24,12 @@ mod config; mod controller; mod emulator; mod filepicker; +mod filesystem; mod gdbserver; mod graphics; mod images; mod input; mod memory; -mod persistence; mod profiler; mod window; @@ -60,14 +60,9 @@ fn set_panic_handler() { eprint!("{message}"); - let Some(project_dirs) = directories::ProjectDirs::from("com", "virtual-boy", "Lemur") - else { + let Some(data_dir) = filesystem::init_data_dir() else { return; }; - let data_dir = project_dirs.data_dir(); - if std::fs::create_dir_all(data_dir).is_err() { - return; - } let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() @@ -98,7 +93,14 @@ fn main() -> Result<()> { let persistence = Persistence::new(); - let (mut builder, client) = EmulatorBuilder::new(); + let mut config = AppConfig::load(&persistence); + if config.update(&args) { + let _ = config.save(&persistence); + } + let p1 = SimConfig::load(&persistence, SimId::Player1); + let p2 = SimConfig::load(&persistence, SimId::Player2); + + let (mut builder, client) = EmulatorBuilder::new(config.save_data_location); if let Some(path) = &args.rom { builder = builder.with_rom(path); } @@ -111,12 +113,6 @@ fn main() -> Result<()> { if args.profile { builder = builder.start_paused(true) } - let mut config = AppConfig::load(&persistence); - if config.update(&args) { - let _ = config.save(&persistence); - } - let p1 = SimConfig::load(&persistence, SimId::Player1); - let p2 = SimConfig::load(&persistence, SimId::Player2); let watch = args.watch; diff --git a/src/persistence.rs b/src/persistence.rs deleted file mode 100644 index ffd7853..0000000 --- a/src/persistence.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::{fs, path::PathBuf}; - -use anyhow::{Result, bail}; -use directories::ProjectDirs; -use serde::{Deserialize, Serialize}; - -#[derive(Clone)] -pub struct Persistence { - dirs: Option, -} - -impl Persistence { - pub fn new() -> Self { - Self { dirs: init_dirs() } - } - - pub fn save_config(&self, file: &str, data: &T) -> Result<()> { - if let Some(dirs) = self.dirs.as_ref() { - let bytes = serde_json::to_vec_pretty(data)?; - let filename = dirs.config_dir.join(file).with_extension("json"); - fs::write(&filename, bytes)?; - } - Ok(()) - } - - pub fn load_config Deserialize<'a>>(&self, file: &str) -> Result { - let Some(dirs) = self.dirs.as_ref() else { - bail!("config directory not found"); - }; - let filename = dirs.config_dir.join(file).with_extension("json"); - let bytes = fs::read(filename)?; - Ok(serde_json::from_slice(&bytes)?) - } -} - -#[derive(Clone)] -struct Dirs { - config_dir: PathBuf, -} - -fn init_dirs() -> Option { - let dirs = ProjectDirs::from("com", "virtual-boy", "Lemur")?; - let config_dir = dirs.config_dir().to_path_buf(); - fs::create_dir_all(&config_dir).ok()?; - Some(Dirs { config_dir }) -} diff --git a/src/window/game.rs b/src/window/game.rs index afbbd03..adba4d4 100644 --- a/src/window/game.rs +++ b/src/window/game.rs @@ -7,13 +7,13 @@ use std::{ use crate::{ app::UserEvent, - config::{COLOR_PRESETS, SimConfig}, + config::{AppConfig, COLOR_PRESETS, SaveDataLocation, SimConfig}, emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState}, filepicker::FilePicker, + filesystem::Persistence, images::ImageTextureLoader, input::{Command, MappingProvider, ShortcutProvider}, memory::MemoryClient, - persistence::Persistence, window::{ AboutWindow, BgMapWindow, CharacterDataWindow, FrameBufferWindow, GdbServerWindow, HotkeysWindow, InitArgs, InputWindow, ObjectWindow, ProfileWindow, RegisterWindow, @@ -604,6 +604,37 @@ impl GameWindow { if ui.button("Hotkeys").clicked() { self.open(ChildWindow::Hotkeys); } + ui.menu_button("Save Data Location", |ui| { + let save_data_location = self.client.save_data_location(); + let update_save_data_location = |new_location: SaveDataLocation| { + if new_location == save_data_location { + return; + }; + let mut app_config = AppConfig::load(&self.persistence); + app_config.save_data_location = new_location; + let _ = app_config.save(&self.persistence); + self.client + .send_command(EmulatorCommand::SetSaveDataLocation(new_location)); + }; + if ui + .selectable_button( + save_data_location == SaveDataLocation::RomDirectory, + "Same Directory as ROM", + ) + .clicked() + { + update_save_data_location(SaveDataLocation::RomDirectory); + } + if ui + .selectable_button( + save_data_location == SaveDataLocation::DataDirectory, + "App Data Directory", + ) + .clicked() + { + update_save_data_location(SaveDataLocation::DataDirectory); + } + }); } fn show_color_picker(&mut self, ui: &mut Ui) {