Skip to main content

modde_games/
lib.rs

1//! Per-game support for modde: the game registry plus each game's plugin,
2//! scanner, save tracker, and tool integrations, exposed through `game_id`-keyed
3//! resolver functions.
4
5use anyhow::{Context, Result};
6use smallvec::SmallVec;
7
8pub mod bannerlord;
9pub mod bethesda;
10pub mod bg3;
11pub mod cyberpunk;
12pub mod detection;
13pub mod gamebryo;
14pub mod generic;
15pub mod launcher;
16pub mod oblivion_remastered;
17pub mod optiscaler;
18pub mod policies;
19pub mod registry;
20pub mod save_patterns;
21pub mod scanner_patterns;
22pub mod stardew;
23pub mod tools;
24pub mod traits;
25pub mod ue4;
26pub mod witcher3;
27
28pub use detection::{DetectedGame, LauncherSource, find_detected_game, scan_installed_games};
29pub use generic::loader::{load_user_games, reload_user_games};
30pub use generic::manage::{
31    AddUserGameResult, DetectCandidateDir, add_user_game, detect_candidates, read_user_game_spec,
32    remove_user_game,
33};
34pub use optiscaler::{
35    OptiScalerIniOverride, OptiScalerProfile, default_optiscaler_profile,
36    resolve_optiscaler_profiles,
37};
38pub use registry::{
39    EngineFamily, GameRegistration, LauncherIds, all_games, resolve_game, supported_game_ids,
40};
41pub use traits::{
42    DeployTarget, DeployTargetKind, DiscoveredFile, DiscoveredMod, GamePlugin, ModClassifyConfig,
43    ModSafety, ModScanner, ModSource, SaveTracker, ScanContext, classify_mod_by_content, slug,
44    walk_files_relative,
45};
46
47/// Build an [`modde_core::installer::InstallProbe`] that delegates to a
48/// game plugin's [`GamePlugin::analyze_mod_archive`] and
49/// [`GamePlugin::recognizes_bare_layout`] hooks.
50///
51/// Only `&'static dyn GamePlugin` is accepted because all registered
52/// plugins are `static` (see [`resolve_game_plugin`]), and the probe's
53/// closures need `'static` captures to cross async task boundaries.
54///
55/// ```ignore
56/// let plugin = resolve_game_plugin("skyrim-se").unwrap();
57/// let probe = game_probe(plugin);
58/// let plan = modde_core::installer::analyze(&extracted, &probe, hash)?;
59/// ```
60pub fn game_probe(plugin: &'static dyn GamePlugin) -> modde_core::installer::InstallProbe {
61    let mut probe = modde_core::installer::InstallProbe::new(
62        move |dir: &std::path::Path| plugin.analyze_mod_archive(dir),
63        move |dir: &std::path::Path| plugin.recognizes_bare_layout(dir),
64    );
65    // Surface the first `UserConfig` deploy target the plugin
66    // advertises so analyze can route config-only archives.
67    if let Some(target) = plugin
68        .deploy_targets()
69        .iter()
70        .find(|t| t.kind == crate::traits::DeployTargetKind::UserConfig)
71    {
72        probe = probe.with_user_config_target(target.id);
73    }
74    probe
75}
76
77/// All recognized game IDs, in registry order.
78pub const SUPPORTED_GAME_IDS: &[&str] = registry::SUPPORTED_GAME_IDS;
79
80/// (`game_id`, `display_name`) for every supported game, derived from the plugin registry.
81#[must_use]
82pub fn supported_games() -> SmallVec<[(&'static str, &'static str); 8]> {
83    registry::all_games()
84        .iter()
85        .map(|game| (game.game_id, game.display_name))
86        .collect()
87}
88
89/// Map a Wabbajack manifest `game` field (e.g. `"Cyberpunk2077"`, `"SkyrimSpecialEdition"`)
90/// to the internal `game_id` (e.g. `"cyberpunk2077"`, `"skyrim-se"`).
91///
92/// Returns `None` if the name is not recognized.
93#[must_use]
94pub fn normalize_wabbajack_game(wj_game: &str) -> Option<&'static str> {
95    let key: String = wj_game
96        .chars()
97        .filter(char::is_ascii_alphanumeric)
98        .flat_map(char::to_lowercase)
99        .collect();
100
101    registry::all_games()
102        .iter()
103        .find(|game| game.normalized_wabbajack_names().any(|name| name == key))
104        .map(|game| game.game_id)
105}
106
107/// Resolve a `game_id` string to the corresponding `GamePlugin` implementation.
108#[must_use]
109pub fn resolve_game_plugin(game_id: &str) -> Option<&'static dyn GamePlugin> {
110    registry::resolve_game(game_id).map(|game| game.plugin)
111}
112
113/// Like [`resolve_game_plugin`] but keyed on the Nexus Mods domain.
114///
115/// The Nexus URL format embeds the domain (e.g. `stellarblade`,
116/// `skyrimspecialedition`), which in many cases doesn't match modde's
117/// `game_id` (`stellar-blade`, `skyrim-se`). The install pipeline uses
118/// this to recover the right plugin when a user pastes a Nexus URL.
119#[must_use]
120pub fn resolve_game_plugin_by_nexus_domain(domain: &str) -> Option<&'static dyn GamePlugin> {
121    registry::resolve_game_by_nexus_domain(domain).map(|game| game.plugin)
122}
123
124/// Resolve a `game_id` to its `ModScanner` implementation, if one exists.
125#[must_use]
126pub fn resolve_mod_scanner(game_id: &str) -> Option<&'static dyn ModScanner> {
127    registry::resolve_game(game_id).and_then(|game| game.scanner)
128}
129
130/// Resolve a `game_id` to its `CollisionClassifier` implementation, if one exists.
131#[must_use]
132pub fn resolve_collision_classifier(
133    game_id: &str,
134) -> Option<Box<dyn modde_core::collision::CollisionClassifier>> {
135    registry::resolve_game(game_id).and_then(|game| game.collision_classifier.map(|build| build()))
136}
137
138/// Resolve a `game_id` to its `SaveTracker` implementation, if one exists.
139#[must_use]
140pub fn resolve_save_tracker(game_id: &str) -> Option<&'static dyn SaveTracker> {
141    registry::resolve_game(game_id).and_then(|game| game.save_tracker)
142}
143
144/// Return whether a game participates in modde's per-profile save layer.
145#[must_use]
146pub fn supports_save_profiles(game_id: &str) -> bool {
147    registry::resolve_game(game_id).is_some_and(|game| game.supports_save_profiles)
148}
149
150/// Read the native plugin order for a game from `plugins.txt`, when the game uses one.
151pub fn read_native_plugin_order(game_id: &str) -> Result<Vec<modde_core::PluginEntry>> {
152    let plugin = resolve_game_plugin(game_id)
153        .ok_or_else(|| anyhow::anyhow!("unsupported game '{game_id}'"))?;
154    let app_id = plugin
155        .steam_app_id_u32()
156        .ok_or_else(|| anyhow::anyhow!("game '{game_id}' does not expose plugins.txt"))?;
157    let folder = plugin
158        .plugins_txt_folder()
159        .ok_or_else(|| anyhow::anyhow!("game '{game_id}' does not expose plugins.txt"))?;
160
161    let entries = bethesda::plugins_txt::read_plugins_txt(app_id, folder)
162        .with_context(|| format!("failed to read plugins.txt for '{game_id}'"))?;
163
164    Ok(entries
165        .into_iter()
166        .enumerate()
167        .map(|(sort_index, entry)| modde_core::PluginEntry {
168            plugin_name: entry.name,
169            sort_index: sort_index as i64,
170            enabled: entry.enabled,
171        })
172        .collect())
173}
174
175/// Persist plugin order back to the game's native `plugins.txt`, when supported.
176pub fn write_native_plugin_order(game_id: &str, plugins: &[modde_core::PluginEntry]) -> Result<()> {
177    let plugin = resolve_game_plugin(game_id)
178        .ok_or_else(|| anyhow::anyhow!("unsupported game '{game_id}'"))?;
179    let app_id = plugin
180        .steam_app_id_u32()
181        .ok_or_else(|| anyhow::anyhow!("game '{game_id}' does not expose plugins.txt"))?;
182    let folder = plugin
183        .plugins_txt_folder()
184        .ok_or_else(|| anyhow::anyhow!("game '{game_id}' does not expose plugins.txt"))?;
185
186    let entries: Vec<bethesda::plugins_txt::PluginEntry> = plugins
187        .iter()
188        .map(|plugin| bethesda::plugins_txt::PluginEntry {
189            name: plugin.plugin_name.clone(),
190            enabled: plugin.enabled,
191        })
192        .collect();
193
194    bethesda::plugins_txt::write_plugins_txt(app_id, folder, &entries)
195        .with_context(|| format!("failed to write plugins.txt for '{game_id}'"))
196}