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    }
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}