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 } 238 239 /// Save cheats to a game-specific cheats file. 240 /// 241 /// # Errors 242 /// 243 /// 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 } 281 282 /// Apply config overrides to an [`AppConfig`] in place. Input strings should be TOML path/value 283 /// pairs in the format `a.b.c=value` (with an arbitrary number of path segments). String values 284 /// should not be quoted. 285 /// 286 /// Does not support setting array or table fields, and does not support arrays as part of the path. 287 /// 288 /// # Errors 289 /// 290 /// Returns an error if a config override is clearly invalid, e.g. it tries to set a table field 291 /// to a bool. Will _not_ return an error if an override sets a non-existent field. 292 /// 293 /// `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} 365 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}