Skip to main content

modde_games/bethesda/
mod.rs

1//! Data-driven support for Bethesda Creation Engine games (Skyrim, Fallout,
2//! Starfield), grouping per-game submodules (archives, INI handling, FOMOD,
3//! plugin scanning, saves) and defining [`BethesdaGame`], a single
4//! [`GamePlugin`] implementation parameterized by static per-title metadata.
5
6pub mod archive_index;
7pub mod archives;
8pub mod collision;
9pub mod diagnostics;
10pub mod fomod;
11pub mod ini;
12pub mod ini_profiles;
13pub mod ini_tweaks;
14pub mod loot;
15pub mod plugin_header;
16pub mod plugins_txt;
17pub mod saves;
18pub mod scanner;
19
20use std::path::{Path, PathBuf};
21
22use modde_core::paths;
23
24use crate::policies::{BareLayoutPolicy, ContentPolicy};
25use crate::traits::{ContentCategory, GamePlugin, ModSafety};
26
27/// Data-driven Bethesda game plugin.
28///
29/// All Bethesda games share the same deploy strategy (symlink into `Data/`)
30/// and differ only in metadata. This replaces four near-identical unit structs.
31pub struct BethesdaGame {
32    game_id: &'static str,
33    display_name: &'static str,
34    /// Steam App ID (for Proton save path detection).
35    steam_app_id: &'static str,
36    /// "My Games" subdirectory name where saves are stored.
37    my_games_dir: &'static str,
38    /// INI file names managed per-profile.
39    ini_files: &'static [&'static str],
40    /// Archive file extensions this game uses.
41    archive_ext: &'static [&'static str],
42    /// Nexus Mods game domain name.
43    nexus_domain: &'static str,
44    /// Game folder name in Proton's AppData/Local for plugins.txt.
45    plugins_txt_folder_name: &'static str,
46    /// Whether this specific Bethesda game participates in modde's
47    /// per-profile save layer. Not every Bethesda plugin has shipped save
48    /// traversal support yet.
49    save_profiles: bool,
50}
51
52impl BethesdaGame {
53    #[must_use]
54    pub const fn new(
55        game_id: &'static str,
56        display_name: &'static str,
57        steam_app_id: &'static str,
58        my_games_dir: &'static str,
59        ini_files: &'static [&'static str],
60        archive_ext: &'static [&'static str],
61        nexus_domain: &'static str,
62        plugins_txt_folder_name: &'static str,
63    ) -> Self {
64        Self {
65            game_id,
66            display_name,
67            steam_app_id,
68            my_games_dir,
69            ini_files,
70            archive_ext,
71            nexus_domain,
72            plugins_txt_folder_name,
73            save_profiles: false,
74        }
75    }
76
77    /// Opt this Bethesda game into modde's per-profile save layer.
78    #[must_use]
79    pub const fn with_save_profiles(mut self, enabled: bool) -> Self {
80        self.save_profiles = enabled;
81        self
82    }
83}
84
85/// File extensions that indicate a Bethesda mod alters game logic.
86const BETHESDA_SAVE_BREAKING_EXT: &[&str] = &["esp", "esm", "esl", "pex", "dll", "psc"];
87
88/// Extensions that are purely cosmetic in Bethesda games.
89const BETHESDA_COSMETIC_EXT: &[&str] = &[
90    "nif", "bsa", "ba2", "dds", "png", "tga", "jpg", "hkx", "fuz", "wav", "xwm", "swf", "ini",
91    "json",
92];
93
94const BETHESDA_CONTENT_CATEGORIES: &[(&str, ContentCategory)] = &[
95    ("esp", ContentCategory::Plugin),
96    ("esm", ContentCategory::Plugin),
97    ("esl", ContentCategory::Plugin),
98    ("dds", ContentCategory::Texture),
99    ("png", ContentCategory::Texture),
100    ("tga", ContentCategory::Texture),
101    ("jpg", ContentCategory::Texture),
102    ("nif", ContentCategory::Mesh),
103    ("wav", ContentCategory::Sound),
104    ("xwm", ContentCategory::Sound),
105    ("fuz", ContentCategory::Sound),
106    ("mp3", ContentCategory::Sound),
107    ("ogg", ContentCategory::Sound),
108    ("pex", ContentCategory::Script),
109    ("psc", ContentCategory::Script),
110    ("swf", ContentCategory::Interface),
111    ("bsa", ContentCategory::Archive),
112    ("ba2", ContentCategory::Archive),
113    ("ini", ContentCategory::Config),
114    ("json", ContentCategory::Config),
115    ("yaml", ContentCategory::Config),
116    ("xml", ContentCategory::Config),
117    ("toml", ContentCategory::Config),
118    ("dll", ContentCategory::Binary),
119    ("so", ContentCategory::Binary),
120];
121
122const BETHESDA_CONTENT_POLICY: ContentPolicy = ContentPolicy {
123    save_breaking_ext: BETHESDA_SAVE_BREAKING_EXT,
124    cosmetic_ext: BETHESDA_COSMETIC_EXT,
125    save_breaking_dirs: &[],
126    categories: BETHESDA_CONTENT_CATEGORIES,
127};
128
129const BETHESDA_BARE_LAYOUT_POLICY: BareLayoutPolicy = BareLayoutPolicy {
130    root_dirs: &[
131        "data",
132        "meshes",
133        "textures",
134        "scripts",
135        "interface",
136        "sound",
137        "music",
138        "materials",
139        "seq",
140        "shadersfx",
141        "strings",
142    ],
143    root_file_exts: &["esp", "esm", "esl", "bsa", "ba2"],
144    case_insensitive_dirs: true,
145};
146
147pub const SKYRIM_SE: BethesdaGame = BethesdaGame::new(
148    "skyrim-se",
149    "The Elder Scrolls V: Skyrim Special Edition",
150    "489830",
151    "Skyrim Special Edition",
152    &["Skyrim.ini", "SkyrimPrefs.ini", "SkyrimCustom.ini"],
153    &["bsa", "ba2"],
154    "skyrimspecialedition",
155    "Skyrim Special Edition",
156)
157.with_save_profiles(true);
158
159pub const SKYRIM_AE: BethesdaGame = BethesdaGame::new(
160    "skyrim-ae",
161    "The Elder Scrolls V: Skyrim Anniversary Edition",
162    "489830",
163    "Skyrim Special Edition",
164    &["Skyrim.ini", "SkyrimPrefs.ini", "SkyrimCustom.ini"],
165    &["bsa", "ba2"],
166    "skyrimspecialedition",
167    "Skyrim Special Edition",
168)
169.with_save_profiles(true);
170
171pub const FALLOUT4: BethesdaGame = BethesdaGame::new(
172    "fallout4",
173    "Fallout 4",
174    "377160",
175    "Fallout4",
176    &["Fallout4.ini", "Fallout4Prefs.ini", "Fallout4Custom.ini"],
177    &["ba2"],
178    "fallout4",
179    "Fallout4",
180)
181.with_save_profiles(true);
182
183pub const FALLOUT76: BethesdaGame = BethesdaGame::new(
184    "fallout76",
185    "Fallout 76",
186    "1151340",
187    "Fallout 76",
188    &["Fallout76.ini", "Fallout76Prefs.ini", "Fallout76Custom.ini"],
189    &["ba2"],
190    "fallout76",
191    "Fallout76",
192)
193.with_save_profiles(true);
194
195pub const STARFIELD: BethesdaGame = BethesdaGame::new(
196    "starfield",
197    "Starfield",
198    "1716740",
199    "Starfield",
200    &["StarfieldPrefs.ini", "StarfieldCustom.ini"],
201    &["ba2"],
202    "starfield",
203    "Starfield",
204)
205.with_save_profiles(true);
206
207impl GamePlugin for BethesdaGame {
208    fn game_id(&self) -> &str {
209        self.game_id
210    }
211
212    fn display_name(&self) -> &str {
213        self.display_name
214    }
215
216    fn mod_directory(&self, install: &Path) -> PathBuf {
217        install.join("Data")
218    }
219
220    fn save_directory(&self) -> Option<PathBuf> {
221        // Proton prefix: compatdata/<APP_ID>/pfx/drive_c/Users/steamuser/Documents/My Games/<DIR>/Saves
222        let compat = paths::steam_common()
223            .parent()? // steamapps/
224            .join("compatdata")
225            .join(self.steam_app_id)
226            .join("pfx/drive_c/Users/steamuser/Documents/My Games")
227            .join(self.my_games_dir)
228            .join("Saves");
229        if compat.exists() {
230            return Some(compat);
231        }
232        None
233    }
234
235    fn supports_save_profiles(&self) -> bool {
236        self.save_profiles
237    }
238
239    fn classify_mod(&self, mod_dir: &Path) -> ModSafety {
240        BETHESDA_CONTENT_POLICY.classify_mod(mod_dir)
241    }
242
243    fn classify_extension(&self, ext: &str) -> ContentCategory {
244        BETHESDA_CONTENT_POLICY.classify_extension(ext)
245    }
246
247    fn ini_file_names(&self) -> &[&str] {
248        self.ini_files
249    }
250
251    fn archive_extensions(&self) -> &[&str] {
252        self.archive_ext
253    }
254
255    fn has_plugin_system(&self) -> bool {
256        true
257    }
258
259    fn steam_app_id_u32(&self) -> Option<u32> {
260        self.steam_app_id.parse().ok()
261    }
262
263    fn plugins_txt_folder(&self) -> Option<&str> {
264        Some(self.plugins_txt_folder_name)
265    }
266
267    fn nexus_game_domain(&self) -> Option<&str> {
268        Some(self.nexus_domain)
269    }
270
271    fn nexus_game_id_u32(&self) -> Option<u32> {
272        // Nexus Mods v2 GraphQL numeric game IDs.
273        // Source: https://api.nexusmods.com/v1/games.json
274        match self.game_id {
275            "skyrim-se" => Some(1704),
276            "skyrim-ae" => Some(1704), // Skyrim SE/AE share the same Nexus domain
277            "fallout4" => Some(1151),
278            "fallout76" => Some(2590),
279            "starfield" => Some(4187),
280            _ => None,
281        }
282    }
283
284    fn recognizes_bare_layout(&self, extracted_dir: &Path) -> bool {
285        BETHESDA_BARE_LAYOUT_POLICY.recognizes(extracted_dir)
286    }
287}