lib.rsannotatedlib.rssource489 lines · 15.1 KB · raw
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}