Skip to main content

modde_games/generic/
loader.rs

1//! Loads user-defined games from TOML specs in modde's data directory,
2//! validating and deduplicating each [`GameSpec`] before turning it into a
3//! [`GenericGame`]-backed [`GameRegistration`] for the game registry.
4
5use std::collections::HashSet;
6use std::fs;
7use std::path::PathBuf;
8
9use tracing::warn;
10
11use crate::generic::GenericGame;
12use crate::registry::{EngineFamily, GameRegistration, LauncherIds, SUPPORTED_GAME_IDS};
13use crate::traits::GamePlugin;
14
15use super::leak::str as leak_str;
16use super::spec::GameSpec;
17
18#[must_use]
19pub fn load_user_games() -> Vec<GameRegistration> {
20    let games_dir = modde_core::paths::modde_data_dir().join("games");
21    let Ok(entries) = fs::read_dir(&games_dir) else {
22        return Vec::new();
23    };
24
25    let mut paths: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
26    paths.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
27
28    let mut seen_ids: HashSet<String> = SUPPORTED_GAME_IDS
29        .iter()
30        .map(|id| (*id).to_string())
31        .collect();
32    let mut registrations = Vec::new();
33
34    for path in paths {
35        if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
36            continue;
37        }
38
39        if path
40            .file_name()
41            .and_then(|name| name.to_str())
42            .is_some_and(|name| name.ends_with(".optiscaler.toml"))
43        {
44            continue;
45        }
46
47        let content = match fs::read_to_string(&path) {
48            Ok(content) => content,
49            Err(error) => {
50                warn!(path = %path.display(), error = %error, "skipping user game spec");
51                continue;
52            }
53        };
54
55        let spec = match toml::from_str::<GameSpec>(&content) {
56            Ok(spec) => spec,
57            Err(error) => {
58                warn!(path = %path.display(), error = %error, "skipping user game spec");
59                continue;
60            }
61        };
62
63        if let Err(error) = spec.validate() {
64            warn!(path = %path.display(), error = %error, "skipping user game spec");
65            continue;
66        }
67
68        if !seen_ids.insert(spec.id.clone()) {
69            warn!(
70                path = %path.display(),
71                game_id = %spec.id,
72                "skipping user game spec with duplicate id"
73            );
74            continue;
75        }
76
77        let game_id = leak_str(spec.id.clone());
78        let display_name = leak_str(spec.display_name.clone());
79        let nexus_domain = spec.nexus_domain.clone().map(leak_str);
80        let steam_app_id = spec.steam_app_id.clone().map(leak_str);
81        let steam_dir = spec.install_dir_name.clone().map(leak_str);
82
83        let plugin: &'static dyn GamePlugin = Box::leak(Box::new(GenericGame::from_spec(spec)));
84
85        registrations.push(GameRegistration {
86            game_id,
87            display_name,
88            engine: EngineFamily::Generic,
89            launcher: LauncherIds {
90                steam_app_id,
91                steam_dir,
92                heroic_gog_app_id: None,
93                heroic_epic_app_id: None,
94            },
95            wabbajack_names: &[],
96            nexus_domain,
97            nexus_game_id: None,
98            supports_save_profiles: false,
99            plugin,
100            scanner: None,
101            save_tracker: None,
102            collision_classifier: Some(crate::registry::generic_collision_classifier),
103            optiscaler_profiles: &[],
104        });
105    }
106
107    registrations
108}
109
110pub fn reload_user_games() {
111    crate::registry::reload_registry();
112}