Skip to main content

modde_games/
registry.rs

1//! Central registry of every supported game: the [`GameRegistration`] records
2//! that bind a `game_id` to its plugin, scanner, save tracker, and launcher IDs,
3//! plus the lookup helpers ([`resolve_game`], [`all_games`]) used across the crate.
4
5use std::sync::{OnceLock, RwLock};
6
7use crate::generic::loader::load_user_games;
8use crate::optiscaler::OptiScalerProfile;
9use crate::policies::{CollisionPolicy, PolicyCollisionClassifier};
10use crate::traits::{GamePlugin, ModScanner, SaveTracker};
11
12/// Factory producing a boxed collision classifier for a registered game.
13pub type CollisionClassifierFactory = fn() -> Box<dyn modde_core::collision::CollisionClassifier>;
14
15/// The modding engine a game is built on, used to share engine-wide behaviour.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum EngineFamily {
18    Bethesda,
19    Bannerlord,
20    CyberpunkRedEngine,
21    Gamebryo,
22    Generic,
23    Larian,
24    Smapi,
25    Unreal4,
26    Witcher,
27}
28
29/// Per-launcher identifiers used to locate a game install across Steam and Heroic.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct LauncherIds {
32    pub steam_app_id: Option<&'static str>,
33    pub steam_dir: Option<&'static str>,
34    pub heroic_gog_app_id: Option<&'static str>,
35    pub heroic_epic_app_id: Option<&'static str>,
36}
37
38/// A single supported game's metadata and capability wiring: it ties a
39/// `game_id` to its [`GamePlugin`], optional [`ModScanner`]/[`SaveTracker`],
40/// launcher IDs, and Nexus/Wabbajack identifiers.
41#[derive(Clone, Copy)]
42pub struct GameRegistration {
43    pub game_id: &'static str,
44    pub display_name: &'static str,
45    pub engine: EngineFamily,
46    pub launcher: LauncherIds,
47    pub wabbajack_names: &'static [&'static str],
48    pub nexus_domain: Option<&'static str>,
49    pub nexus_game_id: Option<u32>,
50    pub supports_save_profiles: bool,
51    pub plugin: &'static dyn GamePlugin,
52    pub scanner: Option<&'static dyn ModScanner>,
53    pub save_tracker: Option<&'static dyn SaveTracker>,
54    pub collision_classifier: Option<CollisionClassifierFactory>,
55    pub optiscaler_profiles: &'static [OptiScalerProfile],
56}
57
58impl GameRegistration {
59    /// Yield this game's Wabbajack names lowercased and stripped to ASCII
60    /// alphanumerics, for robust matching against manifest game strings.
61    pub fn normalized_wabbajack_names(self) -> impl Iterator<Item = String> {
62        self.wabbajack_names.iter().map(|name| {
63            name.chars()
64                .filter(char::is_ascii_alphanumeric)
65                .flat_map(char::to_lowercase)
66                .collect()
67        })
68    }
69}
70
71fn bethesda_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier> {
72    Box::new(crate::bethesda::collision::BethesdaCollisionClassifier)
73}
74
75fn cyberpunk_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier> {
76    Box::new(crate::cyberpunk::collision::CyberpunkCollisionClassifier)
77}
78
79fn ue4_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier> {
80    Box::new(crate::policies::PolicyCollisionClassifier {
81        policy: crate::ue4::UE4_COLLISION_POLICY,
82    })
83}
84
85fn policy_collision_classifier(
86    archive_extensions: &'static [&'static str],
87) -> Box<dyn modde_core::collision::CollisionClassifier> {
88    Box::new(PolicyCollisionClassifier {
89        policy: CollisionPolicy {
90            archive_extensions,
91            severities: DEFAULT_SEVERITIES,
92        },
93    })
94}
95
96const DEFAULT_ARCHIVE_EXTENSIONS: &[&str] = &[];
97const BSA_ARCHIVE_EXTENSIONS: &[&str] = &["bsa"];
98const PAK_ARCHIVE_EXTENSIONS: &[&str] = &["pak", "ucas", "utoc"];
99const WITCHER_ARCHIVE_EXTENSIONS: &[&str] = &["bundle", "cache"];
100
101const DEFAULT_SEVERITIES: &[(&str, modde_core::collision::CollisionSeverity)] = &[
102    ("dds", modde_core::collision::CollisionSeverity::Cosmetic),
103    ("png", modde_core::collision::CollisionSeverity::Cosmetic),
104    ("jpg", modde_core::collision::CollisionSeverity::Cosmetic),
105    ("tga", modde_core::collision::CollisionSeverity::Cosmetic),
106    ("nif", modde_core::collision::CollisionSeverity::Cosmetic),
107    ("ini", modde_core::collision::CollisionSeverity::Config),
108    ("json", modde_core::collision::CollisionSeverity::Config),
109    ("xml", modde_core::collision::CollisionSeverity::Config),
110    ("esp", modde_core::collision::CollisionSeverity::Dangerous),
111    ("esm", modde_core::collision::CollisionSeverity::Dangerous),
112    ("dll", modde_core::collision::CollisionSeverity::Dangerous),
113    ("lua", modde_core::collision::CollisionSeverity::Dangerous),
114    ("ws", modde_core::collision::CollisionSeverity::Dangerous),
115];
116
117pub(crate) fn generic_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier>
118{
119    policy_collision_classifier(DEFAULT_ARCHIVE_EXTENSIONS)
120}
121
122fn gamebryo_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier> {
123    policy_collision_classifier(BSA_ARCHIVE_EXTENSIONS)
124}
125
126fn pak_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier> {
127    policy_collision_classifier(PAK_ARCHIVE_EXTENSIONS)
128}
129
130fn witcher_collision_classifier() -> Box<dyn modde_core::collision::CollisionClassifier> {
131    policy_collision_classifier(WITCHER_ARCHIVE_EXTENSIONS)
132}
133
134pub const SUPPORTED_GAME_IDS: &[&str] = &[
135    "skyrim-se",
136    "skyrim-ae",
137    "fallout4",
138    "fallout76",
139    "starfield",
140    "cyberpunk2077",
141    "stellar-blade",
142    "baldurs-gate3",
143    "stardew-valley",
144    "fallout-new-vegas",
145    "oblivion",
146    "oblivion-remastered",
147    "bannerlord",
148    "witcher3",
149    "subnautica2",
150];
151
152pub static GAME_REGISTRY: &[GameRegistration] = &[
153    GameRegistration {
154        game_id: "skyrim-se",
155        display_name: "The Elder Scrolls V: Skyrim Special Edition",
156        engine: EngineFamily::Bethesda,
157        launcher: LauncherIds {
158            steam_app_id: Some("489830"),
159            steam_dir: Some("Skyrim Special Edition"),
160            heroic_gog_app_id: None,
161            heroic_epic_app_id: None,
162        },
163        wabbajack_names: &["SkyrimSpecialEdition", "SkyrimSE"],
164        nexus_domain: Some("skyrimspecialedition"),
165        nexus_game_id: Some(1704),
166        supports_save_profiles: true,
167        plugin: &crate::bethesda::SKYRIM_SE,
168        scanner: Some(&crate::bethesda::scanner::SKYRIM_SCANNER),
169        save_tracker: Some(&crate::bethesda::saves::SKYRIM_SAVE_TRACKER),
170        collision_classifier: Some(bethesda_collision_classifier),
171        optiscaler_profiles: &[],
172    },
173    GameRegistration {
174        game_id: "skyrim-ae",
175        display_name: "The Elder Scrolls V: Skyrim Anniversary Edition",
176        engine: EngineFamily::Bethesda,
177        launcher: LauncherIds {
178            // AE shares the SE app and install dir. Launcher detection reports
179            // skyrim-se by default; users can still select skyrim-ae explicitly.
180            steam_app_id: None,
181            steam_dir: None,
182            heroic_gog_app_id: None,
183            heroic_epic_app_id: None,
184        },
185        wabbajack_names: &["SkyrimAnniversaryEdition", "SkyrimAE"],
186        nexus_domain: Some("skyrimspecialedition"),
187        nexus_game_id: Some(1704),
188        supports_save_profiles: true,
189        plugin: &crate::bethesda::SKYRIM_AE,
190        scanner: Some(&crate::bethesda::scanner::SKYRIM_SCANNER),
191        save_tracker: Some(&crate::bethesda::saves::SKYRIM_SAVE_TRACKER),
192        collision_classifier: Some(bethesda_collision_classifier),
193        optiscaler_profiles: &[],
194    },
195    GameRegistration {
196        game_id: "fallout4",
197        display_name: "Fallout 4",
198        engine: EngineFamily::Bethesda,
199        launcher: LauncherIds {
200            steam_app_id: Some("377160"),
201            steam_dir: Some("Fallout 4"),
202            heroic_gog_app_id: Some("1998527297"),
203            heroic_epic_app_id: None,
204        },
205        wabbajack_names: &["Fallout4"],
206        nexus_domain: Some("fallout4"),
207        nexus_game_id: Some(1151),
208        supports_save_profiles: true,
209        plugin: &crate::bethesda::FALLOUT4,
210        scanner: Some(&crate::bethesda::scanner::FALLOUT4_SCANNER),
211        save_tracker: Some(&crate::bethesda::saves::FALLOUT4_SAVE_TRACKER),
212        collision_classifier: Some(bethesda_collision_classifier),
213        optiscaler_profiles: &[],
214    },
215    GameRegistration {
216        game_id: "fallout76",
217        display_name: "Fallout 76",
218        engine: EngineFamily::Bethesda,
219        launcher: LauncherIds {
220            steam_app_id: Some("1151340"),
221            steam_dir: Some("Fallout76"),
222            heroic_gog_app_id: None,
223            heroic_epic_app_id: None,
224        },
225        wabbajack_names: &["Fallout76"],
226        nexus_domain: Some("fallout76"),
227        nexus_game_id: Some(2590),
228        supports_save_profiles: true,
229        plugin: &crate::bethesda::FALLOUT76,
230        scanner: Some(&crate::bethesda::scanner::FALLOUT76_SCANNER),
231        save_tracker: Some(&crate::bethesda::saves::FALLOUT76_SAVE_TRACKER),
232        collision_classifier: Some(bethesda_collision_classifier),
233        optiscaler_profiles: &[],
234    },
235    GameRegistration {
236        game_id: "starfield",
237        display_name: "Starfield",
238        engine: EngineFamily::Bethesda,
239        launcher: LauncherIds {
240            steam_app_id: Some("1716740"),
241            steam_dir: Some("Starfield"),
242            heroic_gog_app_id: None,
243            heroic_epic_app_id: None,
244        },
245        wabbajack_names: &["Starfield"],
246        nexus_domain: Some("starfield"),
247        nexus_game_id: Some(4187),
248        supports_save_profiles: true,
249        plugin: &crate::bethesda::STARFIELD,
250        scanner: Some(&crate::bethesda::scanner::STARFIELD_SCANNER),
251        save_tracker: Some(&crate::bethesda::saves::STARFIELD_SAVE_TRACKER),
252        collision_classifier: Some(bethesda_collision_classifier),
253        optiscaler_profiles: &[],
254    },
255    GameRegistration {
256        game_id: "cyberpunk2077",
257        display_name: "Cyberpunk 2077",
258        engine: EngineFamily::CyberpunkRedEngine,
259        launcher: LauncherIds {
260            steam_app_id: Some("1091500"),
261            steam_dir: Some("Cyberpunk 2077"),
262            heroic_gog_app_id: Some("1423049311"),
263            heroic_epic_app_id: Some("Ginger"),
264        },
265        wabbajack_names: &["Cyberpunk2077"],
266        nexus_domain: Some("cyberpunk2077"),
267        nexus_game_id: Some(3333),
268        supports_save_profiles: true,
269        plugin: &crate::cyberpunk::CYBERPUNK2077,
270        scanner: Some(&crate::cyberpunk::scanner::CYBERPUNK_SCANNER),
271        save_tracker: Some(&crate::cyberpunk::saves::CYBERPUNK_SAVE_TRACKER),
272        collision_classifier: Some(cyberpunk_collision_classifier),
273        optiscaler_profiles: &[],
274    },
275    GameRegistration {
276        game_id: "stellar-blade",
277        display_name: "Stellar Blade",
278        engine: EngineFamily::Unreal4,
279        launcher: LauncherIds {
280            steam_app_id: Some("3489700"),
281            steam_dir: Some("Stellar Blade"),
282            heroic_gog_app_id: None,
283            heroic_epic_app_id: None,
284        },
285        wabbajack_names: &["StellarBlade"],
286        nexus_domain: Some("stellarblade"),
287        // nexus_game_id intentionally None: the numeric Nexus game id is
288        // not exposed publicly. The domain alone is enough for URL-based
289        // installs and update-tracking; only the UI's "Browse Nexus"
290        // picker requires the numeric id, so Stellar Blade will be
291        // hidden from that picker until we can confirm the value.
292        nexus_game_id: None,
293        supports_save_profiles: true,
294        plugin: &crate::ue4::STELLAR_BLADE,
295        scanner: Some(&crate::ue4::scanner::STELLAR_BLADE_SCANNER),
296        save_tracker: Some(&crate::ue4::saves::STELLAR_BLADE_SAVE_TRACKER),
297        collision_classifier: Some(ue4_collision_classifier),
298        optiscaler_profiles: crate::ue4::STELLAR_BLADE_OPTISCALER_PROFILES,
299    },
300    GameRegistration {
301        game_id: "baldurs-gate3",
302        display_name: "Baldur's Gate 3",
303        engine: EngineFamily::Larian,
304        launcher: LauncherIds {
305            steam_app_id: Some("1086940"),
306            steam_dir: Some("Baldurs Gate 3"),
307            heroic_gog_app_id: None,
308            heroic_epic_app_id: None,
309        },
310        wabbajack_names: &[],
311        nexus_domain: Some("baldursgate3"),
312        nexus_game_id: None,
313        supports_save_profiles: true,
314        plugin: &crate::bg3::BALDURS_GATE3,
315        scanner: Some(&crate::bg3::scanner::BG3_SCANNER),
316        save_tracker: Some(&crate::bg3::saves::BG3_SAVE_TRACKER),
317        collision_classifier: Some(pak_collision_classifier),
318        optiscaler_profiles: &[],
319    },
320    GameRegistration {
321        game_id: "stardew-valley",
322        display_name: "Stardew Valley",
323        engine: EngineFamily::Smapi,
324        launcher: LauncherIds {
325            steam_app_id: Some("413150"),
326            steam_dir: Some("Stardew Valley"),
327            heroic_gog_app_id: None,
328            heroic_epic_app_id: None,
329        },
330        wabbajack_names: &[],
331        nexus_domain: Some("stardewvalley"),
332        nexus_game_id: None,
333        supports_save_profiles: true,
334        plugin: &crate::stardew::STARDEW_VALLEY,
335        scanner: Some(&crate::stardew::scanner::STARDEW_SCANNER),
336        save_tracker: Some(&crate::stardew::saves::STARDEW_SAVE_TRACKER),
337        collision_classifier: Some(generic_collision_classifier),
338        optiscaler_profiles: &[],
339    },
340    GameRegistration {
341        game_id: "fallout-new-vegas",
342        display_name: "Fallout: New Vegas",
343        engine: EngineFamily::Gamebryo,
344        launcher: LauncherIds {
345            steam_app_id: Some("22380"),
346            steam_dir: Some("Fallout New Vegas"),
347            heroic_gog_app_id: None,
348            heroic_epic_app_id: None,
349        },
350        wabbajack_names: &["FalloutNewVegas", "FalloutNV"],
351        nexus_domain: Some("newvegas"),
352        nexus_game_id: None,
353        supports_save_profiles: true,
354        plugin: &crate::gamebryo::FALLOUT_NEW_VEGAS,
355        scanner: Some(&crate::gamebryo::scanner::FALLOUT_NEW_VEGAS_SCANNER),
356        save_tracker: Some(&crate::gamebryo::saves::GAMEBRYO_SAVE_TRACKER),
357        collision_classifier: Some(gamebryo_collision_classifier),
358        optiscaler_profiles: &[],
359    },
360    GameRegistration {
361        game_id: "oblivion",
362        display_name: "The Elder Scrolls IV: Oblivion",
363        engine: EngineFamily::Gamebryo,
364        launcher: LauncherIds {
365            steam_app_id: Some("22330"),
366            steam_dir: Some("Oblivion"),
367            heroic_gog_app_id: None,
368            heroic_epic_app_id: None,
369        },
370        wabbajack_names: &["Oblivion"],
371        nexus_domain: Some("oblivion"),
372        nexus_game_id: None,
373        supports_save_profiles: true,
374        plugin: &crate::gamebryo::OBLIVION,
375        scanner: Some(&crate::gamebryo::scanner::OBLIVION_SCANNER),
376        save_tracker: Some(&crate::gamebryo::saves::GAMEBRYO_SAVE_TRACKER),
377        collision_classifier: Some(gamebryo_collision_classifier),
378        optiscaler_profiles: &[],
379    },
380    GameRegistration {
381        game_id: "oblivion-remastered",
382        display_name: "The Elder Scrolls IV: Oblivion Remastered",
383        engine: EngineFamily::Unreal4,
384        launcher: LauncherIds {
385            steam_app_id: Some("2623190"),
386            steam_dir: Some("Oblivion Remastered"),
387            heroic_gog_app_id: None,
388            heroic_epic_app_id: None,
389        },
390        wabbajack_names: &["OblivionRemastered"],
391        nexus_domain: Some("oblivionremastered"),
392        nexus_game_id: None,
393        supports_save_profiles: true,
394        plugin: &crate::oblivion_remastered::OBLIVION_REMASTERED,
395        scanner: Some(&crate::oblivion_remastered::scanner::OBLIVION_REMASTERED_SCANNER),
396        save_tracker: Some(&crate::oblivion_remastered::saves::OBLIVION_REMASTERED_SAVE_TRACKER),
397        collision_classifier: Some(pak_collision_classifier),
398        optiscaler_profiles: &[],
399    },
400    GameRegistration {
401        game_id: "bannerlord",
402        display_name: "Mount & Blade II: Bannerlord",
403        engine: EngineFamily::Bannerlord,
404        launcher: LauncherIds {
405            steam_app_id: Some("261550"),
406            steam_dir: Some("Mount & Blade II Bannerlord"),
407            heroic_gog_app_id: None,
408            heroic_epic_app_id: None,
409        },
410        wabbajack_names: &[],
411        nexus_domain: Some("mountandblade2bannerlord"),
412        nexus_game_id: None,
413        supports_save_profiles: true,
414        plugin: &crate::bannerlord::BANNERLORD,
415        scanner: Some(&crate::bannerlord::scanner::BANNERLORD_SCANNER),
416        save_tracker: Some(&crate::bannerlord::saves::BANNERLORD_SAVE_TRACKER),
417        collision_classifier: Some(generic_collision_classifier),
418        optiscaler_profiles: &[],
419    },
420    GameRegistration {
421        game_id: "witcher3",
422        display_name: "The Witcher 3: Wild Hunt",
423        engine: EngineFamily::Witcher,
424        launcher: LauncherIds {
425            steam_app_id: Some("292030"),
426            steam_dir: Some("The Witcher 3"),
427            heroic_gog_app_id: None,
428            heroic_epic_app_id: None,
429        },
430        wabbajack_names: &[],
431        nexus_domain: Some("witcher3"),
432        nexus_game_id: None,
433        supports_save_profiles: true,
434        plugin: &crate::witcher3::WITCHER3,
435        scanner: Some(&crate::witcher3::scanner::WITCHER3_SCANNER),
436        save_tracker: Some(&crate::witcher3::saves::WITCHER3_SAVE_TRACKER),
437        collision_classifier: Some(witcher_collision_classifier),
438        optiscaler_profiles: &[],
439    },
440    GameRegistration {
441        game_id: "subnautica2",
442        display_name: "Subnautica 2",
443        engine: EngineFamily::Unreal4,
444        launcher: LauncherIds {
445            steam_app_id: Some("1962700"),
446            steam_dir: Some("Subnautica2"),
447            heroic_gog_app_id: None,
448            heroic_epic_app_id: None,
449        },
450        wabbajack_names: &[],
451        nexus_domain: Some("subnautica2"),
452        nexus_game_id: None,
453        supports_save_profiles: true,
454        plugin: &crate::ue4::SUBNAUTICA2,
455        scanner: Some(&crate::ue4::scanner::SUBNAUTICA2_SCANNER),
456        save_tracker: Some(&crate::ue4::saves::SUBNAUTICA2_SAVE_TRACKER),
457        collision_classifier: Some(ue4_collision_classifier),
458        optiscaler_profiles: &[],
459    },
460];
461
462static REGISTRY: OnceLock<RwLock<&'static [GameRegistration]>> = OnceLock::new();
463
464fn build_registry_snapshot() -> &'static [GameRegistration] {
465    Box::leak(
466        GAME_REGISTRY
467            .iter()
468            .copied()
469            .chain(load_user_games())
470            .collect::<Vec<_>>()
471            .into_boxed_slice(),
472    )
473}
474
475/// Return the current registry snapshot of all registered games.
476#[must_use]
477pub fn all_games() -> &'static [GameRegistration] {
478    *REGISTRY
479        .get_or_init(|| RwLock::new(build_registry_snapshot()))
480        .read()
481        .unwrap_or_else(std::sync::PoisonError::into_inner)
482}
483
484/// Rebuild the registry snapshot, picking up newly added user-defined games.
485pub fn reload_registry() {
486    let registry = REGISTRY.get_or_init(|| RwLock::new(build_registry_snapshot()));
487    *registry
488        .write()
489        .unwrap_or_else(std::sync::PoisonError::into_inner) = build_registry_snapshot();
490}
491
492/// List the `game_id` of every registered game.
493#[must_use]
494pub fn supported_game_ids() -> Vec<&'static str> {
495    all_games().iter().map(|game| game.game_id).collect()
496}
497
498/// Look up a game registration by its `game_id`.
499#[must_use]
500pub fn resolve_game(game_id: &str) -> Option<&'static GameRegistration> {
501    all_games().iter().find(|game| game.game_id == game_id)
502}
503
504/// Resolve a Nexus Mods domain (e.g. `"stellarblade"`, `"skyrimspecialedition"`)
505/// to its registered game. Used by the install pipeline so a Nexus URL
506/// like `nexusmods.com/<domain>/mods/<id>` can pick up game-specific
507/// install hints even when the domain string differs from the modde
508/// `game_id`.
509#[must_use]
510pub fn resolve_game_by_nexus_domain(domain: &str) -> Option<&'static GameRegistration> {
511    all_games()
512        .iter()
513        .find(|game| game.nexus_domain == Some(domain))
514}
515
516/// Iterate over games that have at least one known launcher (Steam or Heroic) ID.
517pub fn launcher_games() -> impl Iterator<Item = &'static GameRegistration> {
518    all_games().iter().filter(|game| {
519        game.launcher.steam_app_id.is_some()
520            || game.launcher.heroic_gog_app_id.is_some()
521            || game.launcher.heroic_epic_app_id.is_some()
522    })
523}