1pub mod common; 2pub mod gb; 3pub mod gba; 4pub mod genesis; 5pub mod input; 6mod migration; 7pub mod nes; 8pub mod paths; 9pub mod pce; 10pub mod smsgg; 11pub mod snes; 12 13pub use migration::{migrate_config, migrate_config_str}; 14 15use crate::common::{CheatPath, CommonAppConfig}; 16use crate::gb::GameBoyAppConfig; 17use crate::gba::GameBoyAdvanceAppConfig; 18use crate::genesis::{GenesisAppConfig, Sega32XAppConfig, SegaCdAppConfig}; 19use crate::input::InputAppConfig; 20use crate::nes::NesAppConfig; 21use crate::pce::PcEngineAppConfig; 22use crate::smsgg::SmsGgAppConfig; 23use crate::snes::SnesAppConfig; 24use jgenesis_proc_macros::{EnumDisplay, deserialize_default_on_error}; 25use serde::de::DeserializeOwned; 26use serde::{Deserialize, Serialize}; 27use std::path::{Path, PathBuf}; 28use std::{fs, io}; 29use thiserror::Error; 30 31pub const CHEATS_SUBDIR: &str = "cheats"; 32 33pub const DEFAULT_GUI_WIDTH: f32 = 900.0; 34pub const DEFAULT_GUI_HEIGHT: f32 = 675.0; 35 36#[deserialize_default_on_error] 37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 38#[serde(default)] 39pub struct ListFilters { 40 pub master_system: bool, 41 pub game_gear: bool, 42 pub sg_1000: bool, 43 pub genesis: bool, 44 pub sega_cd: bool, 45 pub sega_32x: bool, 46 pub nes: bool, 47 pub snes: bool, 48 pub game_boy: bool, 49 pub game_boy_color: bool, 50 pub game_boy_advance: bool, 51 pub pc_engine: bool, 52} 53 54impl ListFilters { 55 pub const ALL: Self = Self { 56 master_system: true, 57 game_gear: true, 58 sg_1000: true, 59 genesis: true, 60 sega_cd: true, 61 sega_32x: true, 62 nes: true, 63 snes: true, 64 game_boy: true, 65 game_boy_color: true, 66 game_boy_advance: true, 67 pc_engine: true, 68 }; 69 70 pub const NONE: Self = Self { 71 master_system: false, 72 game_gear: false, 73 sg_1000: false, 74 genesis: false, 75 sega_cd: false, 76 sega_32x: false, 77 nes: false, 78 snes: false, 79 game_boy: false, 80 game_boy_color: false, 81 game_boy_advance: false, 82 pc_engine: false, 83 }; 84} 85 86impl Default for ListFilters { 87 fn default() -> Self { 88 Self::ALL 89 } 90} 91 92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 93pub struct RomSearchDirectory { 94 pub path: PathBuf, 95 pub recursive: bool, 96} 97 98impl RomSearchDirectory { 99 #[must_use] 100 pub fn new(path: PathBuf) -> Self { 101 Self { path, recursive: false } 102 } 103} 104 105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 106pub struct RecentOpen { 107 pub console: String, 108 pub path: PathBuf, 109 #[serde(default)] 110 pub secondary_paths: Vec<PathBuf>, 111} 112 113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, EnumDisplay)] 114pub enum EguiTheme { 115 #[default] 116 SystemDefault, 117 Dark, 118 Light, 119} 120 121#[derive(Debug, Error)] 122pub enum SaveCheatsError { 123 #[error("I/O error saving cheats: {0}")] 124 Io(#[from] io::Error), 125 #[error("TOML serialization error: {0}")] 126 Toml(#[from] toml::ser::Error), 127 #[error( 128 "Unable to determine cheats file path; config_path='{config_path}', rom_file_path='{rom_file_path}'" 129 )] 130 UnableToDeterminePath { config_path: String, rom_file_path: String }, 131} 132 133#[derive(Debug, Error)] 134pub enum ConfigOverrideError { 135 #[error("invalid config override value: '{0}'")] 136 InvalidFormat(String), 137 #[error("invalid config override value: '{config_override}', '{path_segment}' is a table")] 138 IntermediateTable { config_override: String, path_segment: String }, 139 #[error("invalid config override value: '{0}', cannot override table or array fields")] 140 EndIsTableOrArray(String), 141 #[error("TOML serialization error: {0}")] 142 TomlSerialize(#[from] toml::ser::Error), 143 #[error("TOML deserialization error: {0}")] 144 TomlDeserialize(#[from] toml::de::Error), 145} 146 147#[deserialize_default_on_error] 148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 149#[serde(default)] 150pub struct AppConfig { 151 pub config_version: Option<String>, 152 pub common: CommonAppConfig, 153 pub smsgg: SmsGgAppConfig, 154 pub genesis: GenesisAppConfig, 155 pub sega_cd: SegaCdAppConfig, 156 pub sega_32x: Sega32XAppConfig, 157 pub nes: NesAppConfig, 158 pub snes: SnesAppConfig, 159 pub game_boy: GameBoyAppConfig, 160 pub game_boy_advance: GameBoyAdvanceAppConfig, 161 pub pc_engine: PcEngineAppConfig, 162 pub input: InputAppConfig, 163 // TODO move GUI-specific config/state somewhere else - separate file? 164 pub list_filters: ListFilters, 165 pub rom_search_dirs: Vec<RomSearchDirectory>, 166 pub recent_open_list: Vec<RecentOpen>, 167 pub egui_theme: EguiTheme, 168 pub gui_window_width: f32, 169 pub gui_window_height: f32, 170} 171 172impl AppConfig { 173 pub fn from_file<P: AsRef<Path>>(path: P) -> Self { 174 let config_str = fs::read_to_string(path).unwrap_or_default(); 175 toml::from_str(&config_str).unwrap_or_else(|err| { 176 log::error!("Error deserializing app config: {err}"); 177 Self::default() 178 }) 179 } 180 181 fn cheats_file_path( 182 &self, 183 config_path: &Path, 184 rom_file_path: &Path, 185 system_extension: &str, 186 ) -> Option<PathBuf> { 187 let cheats_dir = match self.common.cheats_path { 188 CheatPath::SettingsFolder => config_path.parent()?.join(CHEATS_SUBDIR), 189 CheatPath::EmulatorFolder => { 190 jgenesis_common::determine_emulator_dir()?.join(CHEATS_SUBDIR) 191 } 192 CheatPath::Custom => self.common.cheats_custom_path.clone(), 193 }; 194 195 let rom_path_toml = rom_file_path.with_extension("toml"); 196 let rom_file_name_toml = rom_path_toml.file_name()?; 197 198 Some(cheats_dir.join(system_extension).join(rom_file_name_toml)) 199 } 200 201 #[must_use] 202 pub fn try_load_cheats<Cheats>( 203 &self, 204 config_path: &Path, 205 rom_file_path: &Path, 206 system_extension: &str, 207 ) -> Option<Cheats> 208 where 209 Cheats: DeserializeOwned, 210 { 211 let cheats_path = self.cheats_file_path(config_path, rom_file_path, system_extension)?; 212 213 log::debug!("Loading cheats from '{}'", cheats_path.display()); 214 215 let cheats_str = fs::read_to_string(&cheats_path).ok()?; 216 let cheats: Cheats = toml::from_str(&cheats_str).ok()?; 217 218 log::debug!("Successfully loaded cheats"); 219 220 Some(cheats) 221 } 222 223 #[must_use] 224 pub fn try_load_cheats_if_enabled<Cheats>( 225 &self, 226 config_path: &Path, 227 rom_file_path: &Path, 228 system_extension: &str, 229 ) -> Option<Cheats> 230 where 231 Cheats: DeserializeOwned, 232 { 233 self.common 234 .cheats_enabled 235 .then(|| self.try_load_cheats::<Cheats>(config_path, rom_file_path, system_extension)) 236 .flatten() 237 }
Save cheats to a game-specific cheats file.
Errors
Propagates any I/O or serialization errors encountered.
244 pub fn save_cheats<Cheats>( 245 &self, 246 config_path: &Path, 247 rom_file_path: &Path, 248 system_extension: &str, 249 cheats: &Cheats, 250 ) -> Result<(), SaveCheatsError> 251 where 252 Cheats: Serialize, 253 { 254 let unable_determine_path_err = || SaveCheatsError::UnableToDeterminePath { 255 config_path: config_path.display().to_string(), 256 rom_file_path: rom_file_path.display().to_string(), 257 }; 258 259 let Some(cheats_path) = self.cheats_file_path(config_path, rom_file_path, system_extension) 260 else { 261 return Err(unable_determine_path_err()); 262 }; 263 264 log::debug!("Saving cheats to '{}'", cheats_path.display()); 265 266 let Some(cheats_parent) = cheats_path.parent() else { 267 return Err(unable_determine_path_err()); 268 }; 269 270 if !cheats_parent.exists() { 271 fs::create_dir_all(cheats_parent)?; 272 } 273 274 let cheats_str = toml::to_string_pretty(cheats)?; 275 fs::write(&cheats_path, &cheats_str)?; 276 277 log::debug!("Successfully saved cheats"); 278 279 Ok(()) 280 }
Apply config overrides to an [AppConfig] in place. Input strings should be TOML path/value
pairs in the format a.b.c=value (with an arbitrary number of path segments). String values
should not be quoted.
Does not support setting array or table fields, and does not support arrays as part of the path.
Errors
Returns an error if a config override is clearly invalid, e.g. it tries to set a table field to a bool. Will not return an error if an override sets a non-existent field.
self is not modified when this function returns an error.
294 #[allow(clippy::missing_panics_doc)] 295 pub fn apply_overrides( 296 &mut self, 297 overrides: &[impl AsRef<str>], 298 ) -> Result<(), ConfigOverrideError> { 299 if overrides.is_empty() { 300 return Ok(()); 301 } 302 303 let mut document: toml::Table = toml::from_str(&toml::to_string(self)?)?; 304 305 for config_override in overrides { 306 let config_override = config_override.as_ref(); 307 308 let Some((path, value)) = config_override.split_once('=') else { 309 return Err(ConfigOverrideError::InvalidFormat(config_override.into())); 310 }; 311 312 let path_segments: Vec<_> = 313 path.split('.').filter(|segment| !segment.is_empty()).collect(); 314 if path_segments.is_empty() { 315 return Err(ConfigOverrideError::InvalidFormat(config_override.into())); 316 } 317 318 let mut table = &mut document; 319 for &segment in &path_segments[..path_segments.len() - 1] { 320 match table.get(segment) { 321 Some(toml::Value::Table(_)) => {} 322 Some(_) => { 323 // Intermediate path segment points to a non-table value 324 return Err(ConfigOverrideError::IntermediateTable { 325 config_override: config_override.into(), 326 path_segment: segment.into(), 327 }); 328 } 329 None => { 330 table.insert(segment.into(), toml::Value::Table(toml::Table::new())); 331 } 332 } 333 334 table = table.get_mut(segment).unwrap().as_table_mut().unwrap(); 335 } 336 337 let last_segment = *path_segments.last().unwrap(); 338 if table 339 .get(last_segment) 340 .is_some_and(|item| matches!(item, toml::Value::Array(_) | toml::Value::Table(_))) 341 { 342 return Err(ConfigOverrideError::EndIsTableOrArray(config_override.into())); 343 } 344 345 let parsed_value = if let Ok(bool_value) = value.parse::<bool>() { 346 toml::Value::Boolean(bool_value) 347 } else if let Ok(int_value) = value.parse::<i64>() { 348 toml::Value::Integer(int_value) 349 } else if let Ok(float_value) = value.parse::<f64>() { 350 toml::Value::Float(float_value) 351 } else { 352 toml::Value::String(value.into()) 353 }; 354 355 log::info!("Overriding setting '{path}' to {parsed_value:?}"); 356 357 table.insert(last_segment.into(), parsed_value); 358 } 359 360 *self = toml::from_str(&toml::to_string(&document)?)?; 361 362 Ok(()) 363 } 364}
366impl Default for AppConfig { 367 fn default() -> Self { 368 Self { 369 config_version: None, 370 common: CommonAppConfig::default(), 371 smsgg: SmsGgAppConfig::default(), 372 genesis: GenesisAppConfig::default(), 373 sega_cd: SegaCdAppConfig::default(), 374 sega_32x: Sega32XAppConfig::default(), 375 nes: NesAppConfig::default(), 376 snes: SnesAppConfig::default(), 377 game_boy: GameBoyAppConfig::default(), 378 game_boy_advance: GameBoyAdvanceAppConfig::default(), 379 pc_engine: PcEngineAppConfig::default(), 380 input: InputAppConfig::default(), 381 list_filters: ListFilters::default(), 382 rom_search_dirs: vec![], 383 recent_open_list: vec![], 384 egui_theme: EguiTheme::default(), 385 gui_window_width: DEFAULT_GUI_WIDTH, 386 gui_window_height: DEFAULT_GUI_HEIGHT, 387 } 388 } 389} 390 391#[cfg(test)] 392mod tests { 393 use super::*; 394 395 #[test] 396 fn config_default_does_not_panic() { 397 let _ = AppConfig::default(); 398 } 399 400 #[test] 401 fn can_deserialize_empty_string() { 402 let config: AppConfig = 403 toml::from_str("").expect("Failed to deserialize empty string into AppConfig"); 404 assert_eq!(config, AppConfig::default()); 405 } 406 407 #[test] 408 fn config_override_basic() { 409 let mut config = AppConfig::default(); 410 config.genesis.remove_sprite_limits = false; 411 412 assert!(config.apply_overrides(&["genesis.remove_sprite_limits=true"]).is_ok()); 413 assert!(config.genesis.remove_sprite_limits); 414 } 415 416 #[test] 417 fn config_override_deeper_nesting() { 418 let mut config = AppConfig::default(); 419 assert_ne!(config.nes.overscan.left, 128); 420 421 assert!(config.apply_overrides(&["nes.overscan.left=128"]).is_ok()); 422 assert_eq!(config.nes.overscan.left, 128); 423 } 424 425 #[test] 426 fn try_to_set_table_field() { 427 let mut config = AppConfig::default(); 428 assert!(!config.genesis.anamorphic_widescreen); 429 config.genesis.anamorphic_widescreen = true; 430 431 assert!(config.apply_overrides(&["genesis=0"]).is_err()); 432 assert!(config.genesis.anamorphic_widescreen); 433 } 434 435 #[test] 436 fn try_to_set_array_field() { 437 let mut config = AppConfig::default(); 438 config.genesis.ym2612_channels_enabled = [true, false, true, false, true, false]; 439 440 assert!(config.apply_overrides(&["genesis.ym2612_channels_enabled=true"]).is_err()); 441 assert_eq!(config.genesis.ym2612_channels_enabled, [true, false, true, false, true, false]); 442 } 443 444 #[test] 445 fn override_no_equals() { 446 let mut config = AppConfig::default(); 447 assert!(!config.genesis.anamorphic_widescreen); 448 config.genesis.anamorphic_widescreen = true; 449 450 assert!(config.apply_overrides(&["genesis"]).is_err()); 451 assert!(config.genesis.anamorphic_widescreen); 452 } 453 454 #[test] 455 fn override_empty_string() { 456 let mut config = AppConfig::default(); 457 assert!(config.apply_overrides(&[""]).is_err()); 458 assert_eq!(config, AppConfig::default()); 459 } 460 461 #[test] 462 fn override_empty_path() { 463 let mut config = AppConfig::default(); 464 assert!(config.apply_overrides(&["=asdf"]).is_err()); 465 assert_eq!(config, AppConfig::default()); 466 } 467 468 #[test] 469 fn config_not_modified_when_later_errors() { 470 let mut config = AppConfig::default(); 471 config.genesis.remove_sprite_limits = false; 472 473 assert!( 474 config 475 .apply_overrides(&["genesis.remove_sprite_limits=true", "genesis=fdsa"],) 476 .is_err() 477 ); 478 assert!(!config.genesis.remove_sprite_limits); 479 } 480 481 #[test] 482 fn intermediate_path_not_table() { 483 let mut config = AppConfig::default(); 484 config.common.mute_audio = true; 485 486 assert!(config.apply_overrides(&["common.mute_audio.hello=false"]).is_err()); 487 assert!(config.common.mute_audio); 488 } 489}