diff --git a/Cargo.lock b/Cargo.lock index 6d35ac4..c24fdf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -439,6 +439,9 @@ name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] [[package]] name = "block" @@ -820,6 +823,9 @@ name = "cursor-icon" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +dependencies = [ + "serde", +] [[package]] name = "d3d12" @@ -838,6 +844,27 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -884,6 +911,9 @@ name = "dpi" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f25c0e292a7ca6d6498557ff1df68f32c99850012b6ea401cf8daf771f22ff53" +dependencies = [ + "serde", +] [[package]] name = "ecolor" @@ -1266,6 +1296,7 @@ dependencies = [ "fnv", "gilrs-core", "log", + "serde", "uuid", "vec_map", ] @@ -1284,6 +1315,7 @@ dependencies = [ "libudev-sys", "log", "nix", + "serde", "uuid", "vec_map", "wasm-bindgen", @@ -1651,6 +1683,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + [[package]] name = "jni" version = "0.21.1" @@ -1719,6 +1757,7 @@ dependencies = [ "cc", "clap", "cpal", + "directories", "egui", "egui-toast", "egui-wgpu", @@ -1734,6 +1773,8 @@ dependencies = [ "rfd", "rtrb", "rubato", + "serde", + "serde_json", "thread-priority", "wgpu", "windows 0.58.0", @@ -2309,6 +2350,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e296cf87e61c9cfc1a61c3c63a0f7f286ed4554e0e22be84e8a38e1d264a2a29" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.48" @@ -2602,6 +2649,17 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.11.1" @@ -2717,6 +2775,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "same-file" version = "1.0.6" @@ -2753,24 +2817,36 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.215" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", "syn 2.0.90", ] +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -3960,6 +4036,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix", "sctk-adwaita", + "serde", "smithay-client-toolkit", "smol_str", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 26c6cd6..9741f2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,16 +5,17 @@ edition = "2021" [dependencies] anyhow = "1" -bitflags = "2" +bitflags = { version = "2", features = ["serde"] } bytemuck = { version = "1", features = ["derive"] } clap = { version = "4", features = ["derive"] } cpal = { git = "https://github.com/sidit77/cpal.git", rev = "66ed6be" } +directories = "5" egui = "0.29" egui_extras = "0.29" egui-toast = "0.15" egui-winit = "0.29" egui-wgpu = { version = "0.29", features = ["winit"] } -gilrs = "0.11" +gilrs = { version = "0.11", features = ["serde-serialize"] } image = { version = "0.25", default-features = false, features = ["png"] } itertools = "0.13" num-derive = "0.4" @@ -24,9 +25,11 @@ pollster = "0.4" rfd = "0.15" rtrb = "0.3" rubato = "0.16" +serde = { version = "1", features = ["derive"] } +serde_json = "1" thread-priority = "1" wgpu = "22.1" -winit = "0.30" +winit = { version = "0.30", features = ["serde"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_System_Threading"] } diff --git a/src/app.rs b/src/app.rs index 8f04ce1..9e112d3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,6 +17,7 @@ use crate::{ controller::ControllerManager, emulator::{EmulatorClient, EmulatorCommand, SimId}, input::MappingProvider, + persistence::Persistence, window::{AppWindow, GameWindow, InputWindow}, }; @@ -44,7 +45,8 @@ pub struct Application { impl Application { pub fn new(client: EmulatorClient, proxy: EventLoopProxy) -> Self { let icon = load_icon().ok().map(Arc::new); - let mappings = MappingProvider::new(); + let persistence = Persistence::new(); + let mappings = MappingProvider::new(persistence); let controllers = ControllerManager::new(client.clone(), &mappings); { let mappings = mappings.clone(); diff --git a/src/emulator/shrooms_vb_core.rs b/src/emulator/shrooms_vb_core.rs index 6a3394b..2c9510a 100644 --- a/src/emulator/shrooms_vb_core.rs +++ b/src/emulator/shrooms_vb_core.rs @@ -3,6 +3,7 @@ use std::{ffi::c_void, ptr, slice}; use anyhow::{anyhow, Result}; use bitflags::bitflags; use num_derive::{FromPrimitive, ToPrimitive}; +use serde::{Deserialize, Serialize}; #[repr(C)] struct VB { @@ -33,7 +34,7 @@ enum VBOption { bitflags! { #[repr(transparent)] - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct VBKey: u16 { const PWR = 0x0001; const SGN = 0x0002; diff --git a/src/input.rs b/src/input.rs index 057d616..252645b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,16 +1,40 @@ use std::{ collections::{hash_map::Entry, HashMap}, + fmt::Display, + str::FromStr, sync::{Arc, RwLock}, }; +use anyhow::anyhow; use gilrs::{ev::Code, Axis, Button, Gamepad, GamepadId}; +use serde::{Deserialize, Serialize}; use winit::keyboard::{KeyCode, PhysicalKey}; -use crate::emulator::{SimId, VBKey}; +use crate::{ + emulator::{SimId, VBKey}, + persistence::Persistence, +}; -#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, PartialEq, Eq, Hash)] struct DeviceId(u16, u16); +impl FromStr for DeviceId { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut ids = s.split("-"); + let vendor_id: u16 = ids.next().ok_or(anyhow!("missing vendor id"))?.parse()?; + let product_id: u16 = ids.next().ok_or(anyhow!("missing product id"))?.parse()?; + Ok(Self(vendor_id, product_id)) + } +} + +impl Display for DeviceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}-{}", self.0, self.1)) + } +} + #[derive(Clone)] pub struct GamepadInfo { pub id: GamepadId, @@ -26,6 +50,7 @@ pub trait Mappings { fn use_default_mappings(&mut self); } +#[derive(Serialize, Deserialize)] pub struct GamepadMapping { buttons: HashMap, axes: HashMap, @@ -89,6 +114,30 @@ impl GamepadMapping { .or_insert((VBKey::empty(), VBKey::empty())); entry.1 = entry.1.union(key); } + + fn save_mappings(&self) -> PersistedGamepadMapping { + fn flatten(values: &HashMap) -> Vec<(Code, V)> { + values.iter().map(|(k, v)| (*k, *v)).collect() + } + PersistedGamepadMapping { + buttons: flatten(&self.buttons), + axes: flatten(&self.axes), + default_buttons: flatten(&self.default_buttons), + default_axes: flatten(&self.default_axes), + } + } + + fn from_mappings(mappings: &PersistedGamepadMapping) -> Self { + fn unflatten(values: &[(Code, V)]) -> HashMap { + values.iter().map(|(k, v)| (*k, *v)).collect() + } + Self { + buttons: unflatten(&mappings.buttons), + axes: unflatten(&mappings.axes), + default_buttons: unflatten(&mappings.default_buttons), + default_axes: unflatten(&mappings.default_axes), + } + } } impl Mappings for GamepadMapping { @@ -158,6 +207,16 @@ impl InputMapping { let entry = self.keys.entry(keyboard_key).or_insert(VBKey::empty()); *entry = entry.union(key); } + + fn save_mappings(&self) -> PersistedKeyboardMapping { + PersistedKeyboardMapping { + keys: self.keys.iter().map(|(k, v)| (*k, *v)).collect(), + } + } + + fn restore_mappings(&mut self, persisted: &PersistedKeyboardMapping) { + self.keys = persisted.keys.iter().map(|(k, v)| (*k, *v)).collect(); + } } impl Mappings for InputMapping { @@ -210,25 +269,42 @@ impl Mappings for InputMapping { #[derive(Clone)] pub struct MappingProvider { + persistence: Persistence, device_mappings: Arc>>>>, sim_mappings: HashMap>>, gamepad_info: Arc>>, } impl MappingProvider { - pub fn new() -> Self { - let mut mappings = HashMap::new(); + pub fn new(persistence: Persistence) -> Self { + let mut sim_mappings = HashMap::new(); + let mut device_mappings = HashMap::new(); let mut p1_mappings = InputMapping::default(); - p1_mappings.use_default_mappings(); - let p2_mappings = InputMapping::default(); + let mut p2_mappings = InputMapping::default(); - mappings.insert(SimId::Player1, Arc::new(RwLock::new(p1_mappings))); - mappings.insert(SimId::Player2, Arc::new(RwLock::new(p2_mappings))); + if let Ok(persisted) = persistence.load_config::("mappings") { + p1_mappings.restore_mappings(&persisted.p1_keyboard); + p2_mappings.restore_mappings(&persisted.p2_keyboard); + + for (device_id, mappings) in persisted.gamepads { + let Ok(device_id) = device_id.parse::() else { + continue; + }; + let gamepad = GamepadMapping::from_mappings(&mappings); + device_mappings.insert(device_id, Arc::new(RwLock::new(gamepad))); + } + } else { + p1_mappings.use_default_mappings(); + } + + sim_mappings.insert(SimId::Player1, Arc::new(RwLock::new(p1_mappings))); + sim_mappings.insert(SimId::Player2, Arc::new(RwLock::new(p2_mappings))); Self { - device_mappings: Arc::new(RwLock::new(HashMap::new())), + persistence, + device_mappings: Arc::new(RwLock::new(device_mappings)), gamepad_info: Arc::new(RwLock::new(HashMap::new())), - sim_mappings: mappings, + sim_mappings, } } @@ -238,7 +314,7 @@ impl MappingProvider { pub fn for_gamepad(&self, gamepad_id: GamepadId) -> Option>> { let lock = self.gamepad_info.read().unwrap(); - let device_id = lock.get(&gamepad_id)?.device_id; + let device_id = lock.get(&gamepad_id)?.device_id.clone(); drop(lock); let lock = self.device_mappings.read().unwrap(); lock.get(&device_id).cloned() @@ -250,7 +326,7 @@ impl MappingProvider { gamepad.product_id().unwrap_or_default(), ); let mut lock = self.device_mappings.write().unwrap(); - let mappings = match lock.entry(device_id) { + let mappings = match lock.entry(device_id.clone()) { Entry::Vacant(entry) => { let mappings = GamepadMapping::for_gamepad(gamepad); entry.insert(Arc::new(RwLock::new(mappings))) @@ -303,7 +379,7 @@ impl MappingProvider { return; }; info.bound_to = Some(sim_id); - let device_id = info.device_id; + let device_id = info.device_id.clone(); drop(lock); let Some(device_mappings) = self .device_mappings @@ -341,4 +417,40 @@ impl MappingProvider { .cloned() .collect() } + + pub fn save(&self) { + let p1_keyboard = self.for_sim(SimId::Player1).read().unwrap().save_mappings(); + let p2_keyboard = self.for_sim(SimId::Player2).read().unwrap().save_mappings(); + let mut gamepads = HashMap::new(); + for (device_id, gamepad) in self.device_mappings.read().unwrap().iter() { + let mapping = gamepad.read().unwrap().save_mappings(); + gamepads.insert(device_id.to_string(), mapping); + } + let persisted = PersistedInputMappings { + p1_keyboard, + p2_keyboard, + gamepads, + }; + let _ = self.persistence.save_config("mappings", &persisted); + } +} + +#[derive(Serialize, Deserialize)] +struct PersistedInputMappings { + p1_keyboard: PersistedKeyboardMapping, + p2_keyboard: PersistedKeyboardMapping, + gamepads: HashMap, +} + +#[derive(Serialize, Deserialize)] +struct PersistedKeyboardMapping { + keys: Vec<(PhysicalKey, VBKey)>, +} + +#[derive(Serialize, Deserialize)] +struct PersistedGamepadMapping { + buttons: Vec<(Code, VBKey)>, + axes: Vec<(Code, (VBKey, VBKey))>, + default_buttons: Vec<(Code, VBKey)>, + default_axes: Vec<(Code, (VBKey, VBKey))>, } diff --git a/src/main.rs b/src/main.rs index f747fca..d700cd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod controller; mod emulator; mod graphics; mod input; +mod persistence; mod window; #[derive(Parser)] diff --git a/src/persistence.rs b/src/persistence.rs new file mode 100644 index 0000000..f5ebec6 --- /dev/null +++ b/src/persistence.rs @@ -0,0 +1,46 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{bail, Result}; +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/input.rs b/src/window/input.rs index c93c6e4..8fc20c2 100644 --- a/src/window/input.rs +++ b/src/window/input.rs @@ -53,10 +53,12 @@ impl InputWindow { ui.horizontal(|ui| { if ui.button("Use defaults").clicked() { mappings.write().unwrap().use_default_mappings(); + self.mappings.save(); self.now_binding = None; } if ui.button("Clear all").clicked() { mappings.write().unwrap().clear_all_mappings(); + self.mappings.save(); self.now_binding = None; } }); @@ -99,6 +101,8 @@ impl InputWindow { { let mut mapping = mappings.write().unwrap(); mapping.clear_mappings(*key); + drop(mapping); + self.mappings.save(); self.now_binding = None; } }); @@ -210,6 +214,8 @@ impl AppWindow for InputWindow { }; let mut mappings = self.mappings.for_sim(sim_id).write().unwrap(); mappings.add_keyboard_mapping(vb, event.physical_key); + drop(mappings); + self.mappings.save(); } fn handle_gamepad_event(&mut self, event: &gilrs::Event) {