loadorder/
game_settings.rs

1/*
2 * This file is part of libloadorder
3 *
4 * Copyright (C) 2017 Oliver Hamlet
5 *
6 * libloadorder is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * libloadorder is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with libloadorder. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20use std::cmp::Ordering;
21use std::fs::{read_dir, DirEntry, File};
22use std::io::{BufRead, BufReader};
23use std::iter::once;
24use std::path::Path;
25use std::path::PathBuf;
26
27use crate::enums::{Error, GameId, LoadOrderMethod};
28use crate::ini::{test_files, use_my_games_directory};
29use crate::is_enderal;
30use crate::load_order::{
31    AsteriskBasedLoadOrder, OpenMWLoadOrder, TextfileBasedLoadOrder, TimestampBasedLoadOrder,
32    WritableLoadOrder,
33};
34use crate::openmw_config;
35use crate::plugin::{has_plugin_extension, Plugin};
36
37#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
38pub struct GameSettings {
39    id: GameId,
40    game_path: PathBuf,
41    plugins_directory: PathBuf,
42    plugins_file_path: PathBuf,
43    my_games_path: PathBuf,
44    load_order_path: Option<PathBuf>,
45    implicitly_active_plugins: Vec<String>,
46    early_loading_plugins: Vec<String>,
47    additional_plugins_directories: Vec<PathBuf>,
48}
49
50const SKYRIM_HARDCODED_PLUGINS: &[&str] = &["Skyrim.esm"];
51
52const SKYRIM_SE_HARDCODED_PLUGINS: &[&str] = &[
53    "Skyrim.esm",
54    "Update.esm",
55    "Dawnguard.esm",
56    "HearthFires.esm",
57    "Dragonborn.esm",
58];
59
60const SKYRIM_VR_HARDCODED_PLUGINS: &[&str] = &[
61    "Skyrim.esm",
62    "Update.esm",
63    "Dawnguard.esm",
64    "HearthFires.esm",
65    "Dragonborn.esm",
66    "SkyrimVR.esm",
67];
68
69const FALLOUT4_HARDCODED_PLUGINS: &[&str] = &[
70    "Fallout4.esm",
71    "DLCRobot.esm",
72    "DLCworkshop01.esm",
73    "DLCCoast.esm",
74    "DLCworkshop02.esm",
75    "DLCworkshop03.esm",
76    "DLCNukaWorld.esm",
77    "DLCUltraHighResolution.esm",
78];
79
80const FALLOUT4VR_HARDCODED_PLUGINS: &[&str] = &["Fallout4.esm", "Fallout4_VR.esm"];
81
82pub(crate) const STARFIELD_HARDCODED_PLUGINS: &[&str] = &[
83    "Starfield.esm",
84    "Constellation.esm",
85    "OldMars.esm",
86    "ShatteredSpace.esm",
87    "SFBGS003.esm",
88    "SFBGS004.esm",
89    "SFBGS006.esm",
90    "SFBGS007.esm",
91    "SFBGS008.esm",
92];
93
94const OPENMW_HARDCODED_PLUGINS: &[&str] = &["builtin.omwscripts"];
95
96// It's safe to use relative paths like this because the Microsoft Store
97// version of Fallout 4 won't launch if a DLC is installed and its install
98// path changed (e.g. by renaming a directory), so the DLC plugins must be
99// in their default locations.
100const MS_FO4_FAR_HARBOR_PATH: &str = "../../Fallout 4- Far Harbor (PC)/Content/Data";
101const MS_FO4_NUKA_WORLD_PATH: &str = "../../Fallout 4- Nuka-World (PC)/Content/Data";
102const MS_FO4_AUTOMATRON_PATH: &str = "../../Fallout 4- Automatron (PC)/Content/Data";
103const MS_FO4_TEXTURE_PACK_PATH: &str = "../../Fallout 4- High Resolution Texture Pack/Content/Data";
104const MS_FO4_WASTELAND_PATH: &str = "../../Fallout 4- Wasteland Workshop (PC)/Content/Data";
105const MS_FO4_CONTRAPTIONS_PATH: &str = "../../Fallout 4- Contraptions Workshop (PC)/Content/Data";
106const MS_FO4_VAULT_TEC_PATH: &str = "../../Fallout 4- Vault-Tec Workshop (PC)/Content/Data";
107
108const PLUGINS_TXT: &str = "Plugins.txt";
109
110const OBLIVION_REMASTERED_RELATIVE_DATA_PATH: &str = "OblivionRemastered/Content/Dev/ObvData/Data";
111
112impl GameSettings {
113    pub fn new(game_id: GameId, game_path: &Path) -> Result<GameSettings, Error> {
114        let local_path = local_path(game_id, game_path)?.unwrap_or_default();
115        GameSettings::with_local_path(game_id, game_path, &local_path)
116    }
117
118    pub fn with_local_path(
119        game_id: GameId,
120        game_path: &Path,
121        local_path: &Path,
122    ) -> Result<GameSettings, Error> {
123        let my_games_path = my_games_path(game_id, game_path, local_path)?.unwrap_or_default();
124
125        GameSettings::with_local_and_my_games_paths(game_id, game_path, local_path, my_games_path)
126    }
127
128    pub(crate) fn with_local_and_my_games_paths(
129        game_id: GameId,
130        game_path: &Path,
131        local_path: &Path,
132        my_games_path: PathBuf,
133    ) -> Result<GameSettings, Error> {
134        let plugins_file_path = plugins_file_path(game_id, game_path, local_path)?;
135        let load_order_path = load_order_path(game_id, local_path, &plugins_file_path);
136        let plugins_directory = plugins_directory(game_id, game_path, local_path)?;
137        let additional_plugins_directories =
138            additional_plugins_directories(game_id, game_path, &my_games_path)?;
139
140        let (early_loading_plugins, implicitly_active_plugins) =
141            GameSettings::load_implicitly_active_plugins(
142                game_id,
143                game_path,
144                &my_games_path,
145                &plugins_directory,
146                &additional_plugins_directories,
147            )?;
148
149        Ok(GameSettings {
150            id: game_id,
151            game_path: game_path.to_path_buf(),
152            plugins_directory,
153            plugins_file_path,
154            load_order_path,
155            my_games_path,
156            implicitly_active_plugins,
157            early_loading_plugins,
158            additional_plugins_directories,
159        })
160    }
161
162    pub fn id(&self) -> GameId {
163        self.id
164    }
165
166    pub fn load_order_method(&self) -> LoadOrderMethod {
167        match self.id {
168            GameId::OpenMW => LoadOrderMethod::OpenMW,
169            GameId::Morrowind | GameId::Oblivion | GameId::Fallout3 | GameId::FalloutNV => {
170                LoadOrderMethod::Timestamp
171            }
172            GameId::Skyrim | GameId::OblivionRemastered => LoadOrderMethod::Textfile,
173            GameId::SkyrimSE
174            | GameId::SkyrimVR
175            | GameId::Fallout4
176            | GameId::Fallout4VR
177            | GameId::Starfield => LoadOrderMethod::Asterisk,
178        }
179    }
180
181    pub fn into_load_order(self) -> Box<dyn WritableLoadOrder + Send + Sync + 'static> {
182        match self.load_order_method() {
183            LoadOrderMethod::Asterisk => Box::new(AsteriskBasedLoadOrder::new(self)),
184            LoadOrderMethod::Textfile => Box::new(TextfileBasedLoadOrder::new(self)),
185            LoadOrderMethod::Timestamp => Box::new(TimestampBasedLoadOrder::new(self)),
186            LoadOrderMethod::OpenMW => Box::new(OpenMWLoadOrder::new(self)),
187        }
188    }
189
190    #[deprecated = "The master file is not necessarily of any significance: you should probably use early_loading_plugins() instead."]
191    pub fn master_file(&self) -> &'static str {
192        match self.id {
193            GameId::Morrowind | GameId::OpenMW => "Morrowind.esm",
194            GameId::Oblivion | GameId::OblivionRemastered => "Oblivion.esm",
195            GameId::Skyrim | GameId::SkyrimSE | GameId::SkyrimVR => "Skyrim.esm",
196            GameId::Fallout3 => "Fallout3.esm",
197            GameId::FalloutNV => "FalloutNV.esm",
198            GameId::Fallout4 | GameId::Fallout4VR => "Fallout4.esm",
199            GameId::Starfield => "Starfield.esm",
200        }
201    }
202
203    pub fn implicitly_active_plugins(&self) -> &[String] {
204        &self.implicitly_active_plugins
205    }
206
207    pub fn is_implicitly_active(&self, plugin: &str) -> bool {
208        use unicase::eq;
209        self.implicitly_active_plugins()
210            .iter()
211            .any(|p| eq(p.as_str(), plugin))
212    }
213
214    pub fn early_loading_plugins(&self) -> &[String] {
215        &self.early_loading_plugins
216    }
217
218    pub fn loads_early(&self, plugin: &str) -> bool {
219        use unicase::eq;
220        self.early_loading_plugins()
221            .iter()
222            .any(|p| eq(p.as_str(), plugin))
223    }
224
225    pub fn plugins_directory(&self) -> PathBuf {
226        self.plugins_directory.clone()
227    }
228
229    pub fn active_plugins_file(&self) -> &PathBuf {
230        &self.plugins_file_path
231    }
232
233    pub fn load_order_file(&self) -> Option<&PathBuf> {
234        self.load_order_path.as_ref()
235    }
236
237    pub fn additional_plugins_directories(&self) -> &[PathBuf] {
238        &self.additional_plugins_directories
239    }
240
241    pub fn set_additional_plugins_directories(&mut self, paths: Vec<PathBuf>) {
242        self.additional_plugins_directories = paths;
243    }
244
245    /// Find installed plugins and return them in their "inactive load order",
246    /// which is generally the order in which the game launcher would display
247    /// them if they were all inactive, ignoring rules like master files
248    /// loading before others and about early-loading plugins.
249    pub(crate) fn find_plugins(&self) -> Vec<PathBuf> {
250        let main_dir_iter = once(&self.plugins_directory);
251        let other_directories_iter = self.additional_plugins_directories.iter();
252
253        // For most games, plugins in the additional directories override any of
254        // the same names that appear in the main plugins directory, so check
255        // for the additional paths first. For OpenMW the main directory is
256        // listed first.
257        if self.id == GameId::OpenMW {
258            find_plugins_in_directories(main_dir_iter.chain(other_directories_iter), self.id)
259        } else {
260            find_plugins_in_directories(other_directories_iter.chain(main_dir_iter), self.id)
261        }
262    }
263
264    pub(crate) fn game_path(&self) -> &Path {
265        &self.game_path
266    }
267
268    pub fn plugin_path(&self, plugin_name: &str) -> PathBuf {
269        plugin_path(
270            self.id,
271            plugin_name,
272            &self.plugins_directory,
273            &self.additional_plugins_directories,
274        )
275    }
276
277    pub fn refresh_implicitly_active_plugins(&mut self) -> Result<(), Error> {
278        let (early_loading_plugins, implicitly_active_plugins) =
279            GameSettings::load_implicitly_active_plugins(
280                self.id,
281                &self.game_path,
282                &self.my_games_path,
283                &self.plugins_directory,
284                &self.additional_plugins_directories,
285            )?;
286
287        self.early_loading_plugins = early_loading_plugins;
288        self.implicitly_active_plugins = implicitly_active_plugins;
289
290        Ok(())
291    }
292
293    fn load_implicitly_active_plugins(
294        game_id: GameId,
295        game_path: &Path,
296        my_games_path: &Path,
297        plugins_directory: &Path,
298        additional_plugins_directories: &[PathBuf],
299    ) -> Result<(Vec<String>, Vec<String>), Error> {
300        let mut test_files = test_files(game_id, game_path, my_games_path)?;
301
302        if matches!(
303            game_id,
304            GameId::Fallout4 | GameId::Fallout4VR | GameId::Starfield
305        ) {
306            // Fallout 4 and Starfield ignore plugins.txt and Fallout4.ccc if there are valid
307            // plugins listed as test files, so filter out invalid values.
308            test_files.retain(|f| {
309                let path = plugin_path(
310                    game_id,
311                    f,
312                    plugins_directory,
313                    additional_plugins_directories,
314                );
315                Plugin::with_path(&path, game_id, false).is_ok()
316            });
317        }
318
319        let early_loading_plugins =
320            early_loading_plugins(game_id, game_path, my_games_path, !test_files.is_empty())?;
321
322        let implicitly_active_plugins =
323            implicitly_active_plugins(game_id, game_path, &early_loading_plugins, &test_files)?;
324
325        Ok((early_loading_plugins, implicitly_active_plugins))
326    }
327}
328
329#[cfg(windows)]
330fn local_path(game_id: GameId, game_path: &Path) -> Result<Option<PathBuf>, Error> {
331    if game_id == GameId::OpenMW {
332        return openmw_config::user_config_dir(game_path).map(Some);
333    }
334
335    let Some(local_app_data_path) = dirs::data_local_dir() else {
336        return Err(Error::NoLocalAppData);
337    };
338
339    match appdata_folder_name(game_id, game_path) {
340        Some(x) => Ok(Some(local_app_data_path.join(x))),
341        None => Ok(None),
342    }
343}
344
345#[cfg(not(windows))]
346fn local_path(game_id: GameId, game_path: &Path) -> Result<Option<PathBuf>, Error> {
347    if game_id == GameId::OpenMW {
348        openmw_config::user_config_dir(game_path).map(Some)
349    } else if appdata_folder_name(game_id, game_path).is_none() {
350        // There is no local path, the value doesn't matter.
351        Ok(None)
352    } else {
353        // A local app data path is needed, but there's no way to get it.
354        Err(Error::NoLocalAppData)
355    }
356}
357
358// The local path can vary depending on where the game was bought from.
359fn appdata_folder_name(game_id: GameId, game_path: &Path) -> Option<&'static str> {
360    match game_id {
361        GameId::Morrowind | GameId::OpenMW | GameId::OblivionRemastered => None,
362        GameId::Oblivion => Some("Oblivion"),
363        GameId::Skyrim => Some(skyrim_appdata_folder_name(game_path)),
364        GameId::SkyrimSE => Some(skyrim_se_appdata_folder_name(game_path)),
365        GameId::SkyrimVR => Some("Skyrim VR"),
366        GameId::Fallout3 => Some("Fallout3"),
367        GameId::FalloutNV => Some(falloutnv_appdata_folder_name(game_path)),
368        GameId::Fallout4 => Some(fallout4_appdata_folder_name(game_path)),
369        GameId::Fallout4VR => Some("Fallout4VR"),
370        GameId::Starfield => Some("Starfield"),
371    }
372}
373
374fn skyrim_appdata_folder_name(game_path: &Path) -> &'static str {
375    if is_enderal(game_path) {
376        // It's not actually Skyrim, it's Enderal.
377        "enderal"
378    } else {
379        "Skyrim"
380    }
381}
382
383fn skyrim_se_appdata_folder_name(game_path: &Path) -> &'static str {
384    let is_gog_install = game_path.join("Galaxy64.dll").exists();
385
386    if is_enderal(game_path) {
387        if is_gog_install {
388            "Enderal Special Edition GOG"
389        } else {
390            "Enderal Special Edition"
391        }
392    } else if is_gog_install {
393        // Galaxy64.dll is only installed by GOG's installer.
394        "Skyrim Special Edition GOG"
395    } else if game_path.join("EOSSDK-Win64-Shipping.dll").exists() {
396        // EOSSDK-Win64-Shipping.dll is only installed by Epic.
397        "Skyrim Special Edition EPIC"
398    } else if is_microsoft_store_install(GameId::SkyrimSE, game_path) {
399        "Skyrim Special Edition MS"
400    } else {
401        // If neither file is present it's probably the Steam distribution.
402        "Skyrim Special Edition"
403    }
404}
405
406fn falloutnv_appdata_folder_name(game_path: &Path) -> &'static str {
407    if game_path.join("EOSSDK-Win32-Shipping.dll").exists() {
408        // EOSSDK-Win32-Shipping.dll is only installed by Epic.
409        "FalloutNV_Epic"
410    } else {
411        "FalloutNV"
412    }
413}
414
415fn fallout4_appdata_folder_name(game_path: &Path) -> &'static str {
416    if is_microsoft_store_install(GameId::Fallout4, game_path) {
417        "Fallout4 MS"
418    } else if game_path.join("EOSSDK-Win64-Shipping.dll").exists() {
419        // EOSSDK-Win64-Shipping.dll is only installed by Epic.
420        "Fallout4 EPIC"
421    } else {
422        "Fallout4"
423    }
424}
425
426fn my_games_path(
427    game_id: GameId,
428    game_path: &Path,
429    local_path: &Path,
430) -> Result<Option<PathBuf>, Error> {
431    if game_id == GameId::OpenMW {
432        // Use the local path as the my games path, so that both refer to the
433        // user config path for OpenMW.
434        return Ok(Some(local_path.to_path_buf()));
435    }
436
437    my_games_folder_name(game_id, game_path)
438        .map(|folder| {
439            documents_path(local_path)
440                .map(|d| d.join("My Games").join(folder))
441                .ok_or_else(|| Error::NoDocumentsPath)
442        })
443        .transpose()
444}
445
446fn my_games_folder_name(game_id: GameId, game_path: &Path) -> Option<&'static str> {
447    match game_id {
448        GameId::OpenMW => Some("OpenMW"),
449        GameId::OblivionRemastered => Some("Oblivion Remastered"),
450        GameId::Skyrim => Some(skyrim_my_games_folder_name(game_path)),
451        // For all other games the name is the same as the AppData\Local folder name.
452        _ => appdata_folder_name(game_id, game_path),
453    }
454}
455
456fn skyrim_my_games_folder_name(game_path: &Path) -> &'static str {
457    if is_enderal(game_path) {
458        "Enderal"
459    } else {
460        "Skyrim"
461    }
462}
463
464fn is_microsoft_store_install(game_id: GameId, game_path: &Path) -> bool {
465    const APPX_MANIFEST: &str = "appxmanifest.xml";
466
467    match game_id {
468        GameId::Morrowind | GameId::Oblivion | GameId::Fallout3 | GameId::FalloutNV => game_path
469            .parent()
470            .is_some_and(|parent| parent.join(APPX_MANIFEST).exists()),
471        GameId::SkyrimSE | GameId::Fallout4 | GameId::Starfield | GameId::OblivionRemastered => {
472            game_path.join(APPX_MANIFEST).exists()
473        }
474        _ => false,
475    }
476}
477
478#[cfg(windows)]
479fn documents_path(_local_path: &Path) -> Option<PathBuf> {
480    dirs::document_dir()
481}
482
483#[cfg(not(windows))]
484fn documents_path(local_path: &Path) -> Option<PathBuf> {
485    // Get the documents path relative to the game's local path, which should end in
486    // AppData/Local/<Game>.
487    local_path
488        .parent()
489        .and_then(Path::parent)
490        .and_then(Path::parent)
491        .map(|p| p.join("Documents"))
492        .or_else(|| {
493            // Fall back to creating a path that navigates up parent directories. This may give a
494            // different result if local_path involves symlinks, and requires local_path to exist.
495            Some(local_path.join("../../../Documents"))
496        })
497}
498
499fn plugins_directory(
500    game_id: GameId,
501    game_path: &Path,
502    local_path: &Path,
503) -> Result<PathBuf, Error> {
504    match game_id {
505        GameId::OpenMW => openmw_config::resources_vfs_path(game_path, local_path),
506        GameId::Morrowind => Ok(game_path.join("Data Files")),
507        GameId::OblivionRemastered => Ok(game_path.join(OBLIVION_REMASTERED_RELATIVE_DATA_PATH)),
508        _ => Ok(game_path.join("Data")),
509    }
510}
511
512fn additional_plugins_directories(
513    game_id: GameId,
514    game_path: &Path,
515    my_games_path: &Path,
516) -> Result<Vec<PathBuf>, Error> {
517    if game_id == GameId::Fallout4 && is_microsoft_store_install(game_id, game_path) {
518        Ok(vec![
519            game_path.join(MS_FO4_AUTOMATRON_PATH),
520            game_path.join(MS_FO4_NUKA_WORLD_PATH),
521            game_path.join(MS_FO4_WASTELAND_PATH),
522            game_path.join(MS_FO4_TEXTURE_PACK_PATH),
523            game_path.join(MS_FO4_VAULT_TEC_PATH),
524            game_path.join(MS_FO4_FAR_HARBOR_PATH),
525            game_path.join(MS_FO4_CONTRAPTIONS_PATH),
526        ])
527    } else if game_id == GameId::Starfield {
528        Ok(vec![my_games_path.join("Data")])
529    } else if game_id == GameId::OpenMW {
530        openmw_config::additional_data_paths(game_path, my_games_path)
531    } else {
532        Ok(Vec::new())
533    }
534}
535
536fn load_order_path(
537    game_id: GameId,
538    local_path: &Path,
539    plugins_file_path: &Path,
540) -> Option<PathBuf> {
541    const LOADORDER_TXT: &str = "loadorder.txt";
542    match game_id {
543        GameId::Skyrim => Some(local_path.join(LOADORDER_TXT)),
544        GameId::OblivionRemastered => plugins_file_path.parent().map(|p| p.join(LOADORDER_TXT)),
545        _ => None,
546    }
547}
548
549fn plugins_file_path(
550    game_id: GameId,
551    game_path: &Path,
552    local_path: &Path,
553) -> Result<PathBuf, Error> {
554    match game_id {
555        GameId::OpenMW => Ok(local_path.join("openmw.cfg")),
556        GameId::Morrowind => Ok(game_path.join("Morrowind.ini")),
557        GameId::Oblivion => oblivion_plugins_file_path(game_path, local_path),
558        GameId::OblivionRemastered => Ok(game_path
559            .join(OBLIVION_REMASTERED_RELATIVE_DATA_PATH)
560            .join(PLUGINS_TXT)),
561        // Although the launchers for Fallout 3, Fallout NV and Skyrim all create plugins.txt, the
562        // games themselves read Plugins.txt.
563        _ => Ok(local_path.join(PLUGINS_TXT)),
564    }
565}
566
567fn oblivion_plugins_file_path(game_path: &Path, local_path: &Path) -> Result<PathBuf, Error> {
568    let ini_path = game_path.join("Oblivion.ini");
569
570    let parent_path = if use_my_games_directory(&ini_path)? {
571        local_path
572    } else {
573        game_path
574    };
575
576    // Although Oblivion's launcher creates plugins.txt, the game itself reads Plugins.txt.
577    Ok(parent_path.join(PLUGINS_TXT))
578}
579
580fn ccc_file_paths(game_id: GameId, game_path: &Path, my_games_path: &Path) -> Vec<PathBuf> {
581    match game_id {
582        GameId::Fallout4 => vec![game_path.join("Fallout4.ccc")],
583        GameId::SkyrimSE => vec![game_path.join("Skyrim.ccc")],
584        // If the My Games CCC file is present, it overrides the other, even if empty.
585        GameId::Starfield => vec![
586            my_games_path.join("Starfield.ccc"),
587            game_path.join("Starfield.ccc"),
588        ],
589        _ => vec![],
590    }
591}
592
593fn hardcoded_plugins(game_id: GameId) -> &'static [&'static str] {
594    match game_id {
595        GameId::Skyrim => SKYRIM_HARDCODED_PLUGINS,
596        GameId::SkyrimSE => SKYRIM_SE_HARDCODED_PLUGINS,
597        GameId::SkyrimVR => SKYRIM_VR_HARDCODED_PLUGINS,
598        GameId::Fallout4 => FALLOUT4_HARDCODED_PLUGINS,
599        GameId::Fallout4VR => FALLOUT4VR_HARDCODED_PLUGINS,
600        GameId::Starfield => STARFIELD_HARDCODED_PLUGINS,
601        GameId::OpenMW => OPENMW_HARDCODED_PLUGINS,
602        _ => &[],
603    }
604}
605
606fn find_nam_plugins(plugins_path: &Path) -> Result<Vec<String>, Error> {
607    // Scan the path for .nam files. Each .nam file can activate a .esm or .esp
608    // plugin with the same basename, so return those filenames.
609    let mut plugin_names = Vec::new();
610
611    if !plugins_path.exists() {
612        return Ok(plugin_names);
613    }
614
615    let dir_iter = plugins_path
616        .read_dir()
617        .map_err(|e| Error::IoError(plugins_path.to_path_buf(), e))?
618        .filter_map(Result::ok)
619        .filter(|e| e.file_type().map(|f| f.is_file()).unwrap_or(false))
620        .filter(|e| {
621            e.path()
622                .extension()
623                .unwrap_or_default()
624                .eq_ignore_ascii_case("nam")
625        });
626
627    for entry in dir_iter {
628        let file_name = entry.file_name();
629
630        let plugin = Path::new(&file_name).with_extension("esp");
631        if let Some(esp) = plugin.to_str() {
632            plugin_names.push(esp.to_owned());
633        }
634
635        let master = Path::new(&file_name).with_extension("esm");
636        if let Some(esm) = master.to_str() {
637            plugin_names.push(esm.to_owned());
638        }
639    }
640
641    Ok(plugin_names)
642}
643
644fn early_loading_plugins(
645    game_id: GameId,
646    game_path: &Path,
647    my_games_path: &Path,
648    has_test_files: bool,
649) -> Result<Vec<String>, Error> {
650    let mut plugin_names: Vec<String> = hardcoded_plugins(game_id)
651        .iter()
652        .map(|s| (*s).to_owned())
653        .collect();
654
655    if matches!(game_id, GameId::Fallout4 | GameId::Starfield) && has_test_files {
656        // If test files are configured for Fallout 4, CCC plugins are not loaded.
657        // No need to check for Fallout 4 VR, as it has no CCC plugins file.
658        return Ok(plugin_names);
659    }
660
661    for file_path in ccc_file_paths(game_id, game_path, my_games_path) {
662        if file_path.exists() {
663            let reader =
664                BufReader::new(File::open(&file_path).map_err(|e| Error::IoError(file_path, e))?);
665
666            let lines = reader
667                .lines()
668                .filter_map(|line| line.ok().filter(|l| !l.is_empty()));
669
670            plugin_names.extend(lines);
671            break;
672        }
673    }
674
675    if game_id == GameId::OpenMW {
676        plugin_names.extend(openmw_config::non_user_active_plugin_names(game_path)?);
677    }
678
679    deduplicate(&mut plugin_names);
680
681    Ok(plugin_names)
682}
683
684fn implicitly_active_plugins(
685    game_id: GameId,
686    game_path: &Path,
687    early_loading_plugins: &[String],
688    test_files: &[String],
689) -> Result<Vec<String>, Error> {
690    let mut plugin_names = Vec::new();
691
692    plugin_names.extend_from_slice(early_loading_plugins);
693    plugin_names.extend_from_slice(test_files);
694
695    if game_id == GameId::FalloutNV {
696        // If there is a .nam file with the same basename as a plugin then the plugin is activated
697        // and listed as a DLC in the game's title screen menu. This only works in the game's
698        // Data path, so ignore additional plugin directories.
699        let nam_plugins = find_nam_plugins(&game_path.join("Data"))?;
700
701        plugin_names.extend(nam_plugins);
702    } else if game_id == GameId::Skyrim {
703        // Update.esm is always active, but loads after all other masters if it is not made to load
704        // earlier (e.g. by listing in plugins.txt or by being a master of another master).
705        plugin_names.push("Update.esm".to_owned());
706    } else if game_id == GameId::Starfield {
707        // BlueprintShips-Starfield.esm is always active but loads after all other plugins if not
708        // made to load earlier.
709        plugin_names.push("BlueprintShips-Starfield.esm".to_owned());
710    }
711
712    deduplicate(&mut plugin_names);
713
714    Ok(plugin_names)
715}
716
717/// Remove duplicates, keeping only the first instance of each plugin.
718fn deduplicate(plugin_names: &mut Vec<String>) {
719    let mut set = std::collections::HashSet::new();
720    plugin_names.retain(|e| set.insert(unicase::UniCase::new(e.clone())));
721}
722
723fn find_map_path(directory: &Path, plugin_name: &str, game_id: GameId) -> Option<PathBuf> {
724    if game_id.allow_plugin_ghosting() {
725        // Plugins may be ghosted, so take that into account when checking.
726        use crate::ghostable_path::GhostablePath;
727
728        directory.join(plugin_name).resolve_path().ok()
729    } else {
730        let path = directory.join(plugin_name);
731        path.exists().then_some(path)
732    }
733}
734
735fn pick_plugin_path<'a>(
736    game_id: GameId,
737    plugin_name: &str,
738    plugins_directory: &Path,
739    mut dir_iter: impl Iterator<Item = &'a PathBuf>,
740) -> PathBuf {
741    dir_iter
742        .find_map(|d| find_map_path(d, plugin_name, game_id))
743        .unwrap_or_else(|| plugins_directory.join(plugin_name))
744}
745
746fn plugin_path(
747    game_id: GameId,
748    plugin_name: &str,
749    plugins_directory: &Path,
750    additional_plugins_directories: &[PathBuf],
751) -> PathBuf {
752    // There may be multiple directories that the plugin could be installed in, so check each in
753    // turn.
754
755    // Starfield (at least as of 1.12.32.0) only loads plugins from its additional directory if
756    // they're also present in plugins_directory, so there's no point checking the additional
757    // directory if a plugin isn't present in plugins_directory.
758    if game_id == GameId::Starfield {
759        // Plugins may be ghosted, so take that into account when checking.
760        use crate::ghostable_path::GhostablePath;
761
762        let path = plugins_directory.join(plugin_name);
763        if path.resolve_path().is_err() {
764            return path;
765        }
766    }
767
768    // In OpenMW, if there are multiple directories containing the same filename, the last directory
769    // listed "wins".
770    match game_id {
771        GameId::OpenMW => pick_plugin_path(
772            game_id,
773            plugin_name,
774            plugins_directory,
775            additional_plugins_directories.iter().rev(),
776        ),
777        _ => pick_plugin_path(
778            game_id,
779            plugin_name,
780            plugins_directory,
781            additional_plugins_directories.iter(),
782        ),
783    }
784}
785
786fn sort_plugins_dir_entries(a: &DirEntry, b: &DirEntry) -> Ordering {
787    // Sort by file modification timestamps, in ascending order. If two
788    // timestamps are equal, sort by filenames in descending order.
789    let m_a = a.metadata().and_then(|m| m.modified()).ok();
790    let m_b = b.metadata().and_then(|m| m.modified()).ok();
791
792    match m_a.cmp(&m_b) {
793        Ordering::Equal => a.file_name().cmp(&b.file_name()).reverse(),
794        x => x,
795    }
796}
797
798fn sort_plugins_dir_entries_starfield(a: &DirEntry, b: &DirEntry) -> Ordering {
799    // Sort by file modification timestamps, in ascending order. If two
800    // timestamps are equal, sort by filenames in ascending order.
801    let m_a = a.metadata().and_then(|m| m.modified()).ok();
802    let m_b = b.metadata().and_then(|m| m.modified()).ok();
803
804    match m_a.cmp(&m_b) {
805        Ordering::Equal => a.file_name().cmp(&b.file_name()),
806        x => x,
807    }
808}
809
810fn sort_plugins_dir_entries_openmw(a: &DirEntry, b: &DirEntry) -> Ordering {
811    // Preserve the directory ordering, but sort case-sensitive
812    // lexicographically within directories.
813    if a.path().parent() == b.path().parent() {
814        a.file_name().cmp(&b.file_name())
815    } else {
816        Ordering::Equal
817    }
818}
819
820fn find_plugins_in_directories<'a>(
821    directories_iter: impl Iterator<Item = &'a PathBuf>,
822    game_id: GameId,
823) -> Vec<PathBuf> {
824    let mut dir_entries: Vec<_> = directories_iter
825        .flat_map(read_dir)
826        .flatten()
827        .filter_map(Result::ok)
828        .filter(|e| e.file_type().map(|f| f.is_file()).unwrap_or(false))
829        .filter(|e| {
830            e.file_name()
831                .to_str()
832                .is_some_and(|f| has_plugin_extension(f, game_id))
833        })
834        .collect();
835
836    let compare = match game_id {
837        GameId::OpenMW => sort_plugins_dir_entries_openmw,
838        GameId::Starfield => sort_plugins_dir_entries_starfield,
839        _ => sort_plugins_dir_entries,
840    };
841
842    dir_entries.sort_by(compare);
843
844    dir_entries.into_iter().map(|e| e.path()).collect()
845}
846
847#[cfg(test)]
848mod tests {
849    #[cfg(windows)]
850    use std::env;
851    use std::{fs::create_dir_all, io::Write};
852    use tempfile::tempdir;
853
854    use crate::tests::{copy_to_dir, set_file_timestamps, NON_ASCII};
855
856    use super::*;
857
858    fn game_with_generic_paths(game_id: GameId) -> GameSettings {
859        GameSettings::with_local_and_my_games_paths(
860            game_id,
861            &PathBuf::from("game"),
862            &PathBuf::from("local"),
863            PathBuf::from("my games"),
864        )
865        .unwrap()
866    }
867
868    fn game_with_game_path(game_id: GameId, game_path: &Path) -> GameSettings {
869        GameSettings::with_local_and_my_games_paths(
870            game_id,
871            game_path,
872            &PathBuf::default(),
873            PathBuf::default(),
874        )
875        .unwrap()
876    }
877
878    fn game_with_ccc_plugins(
879        game_id: GameId,
880        game_path: &Path,
881        plugin_names: &[&str],
882    ) -> GameSettings {
883        let ccc_path = &ccc_file_paths(game_id, game_path, &PathBuf::new())[0];
884        create_ccc_file(ccc_path, plugin_names);
885
886        game_with_game_path(game_id, game_path)
887    }
888
889    fn create_ccc_file(path: &Path, plugin_names: &[&str]) {
890        create_dir_all(path.parent().unwrap()).unwrap();
891
892        let mut file = File::create(path).unwrap();
893
894        for plugin_name in plugin_names {
895            writeln!(file, "{plugin_name}").unwrap();
896        }
897    }
898
899    #[test]
900    #[cfg(windows)]
901    fn new_should_determine_correct_local_path_on_windows() {
902        let settings = GameSettings::new(GameId::Skyrim, Path::new("game")).unwrap();
903        let local_app_data = env::var("LOCALAPPDATA").unwrap();
904        let local_app_data_path = Path::new(&local_app_data);
905
906        assert_eq!(
907            local_app_data_path.join("Skyrim").join("Plugins.txt"),
908            *settings.active_plugins_file()
909        );
910        assert_eq!(
911            &local_app_data_path.join("Skyrim").join("loadorder.txt"),
912            *settings.load_order_file().as_ref().unwrap()
913        );
914    }
915
916    #[test]
917    #[cfg(windows)]
918    fn new_should_determine_correct_local_path_for_openmw() {
919        let tmp_dir = tempdir().unwrap();
920        let global_cfg_path = tmp_dir.path().join("openmw.cfg");
921
922        std::fs::write(&global_cfg_path, "config=local").unwrap();
923
924        let settings = GameSettings::new(GameId::OpenMW, tmp_dir.path()).unwrap();
925
926        assert_eq!(
927            &tmp_dir.path().join("local/openmw.cfg"),
928            settings.active_plugins_file()
929        );
930        assert_eq!(tmp_dir.path().join("local"), settings.my_games_path);
931    }
932
933    #[test]
934    fn new_should_use_an_empty_local_path_for_morrowind() {
935        let settings = GameSettings::new(GameId::Morrowind, Path::new("game")).unwrap();
936
937        assert_eq!(PathBuf::new(), settings.my_games_path);
938    }
939
940    #[test]
941    #[cfg(not(windows))]
942    fn new_should_determine_correct_local_path_for_openmw_on_linux() {
943        let config_path = Path::new("/etc/openmw");
944
945        let settings = GameSettings::new(GameId::OpenMW, Path::new("game")).unwrap();
946
947        assert_eq!(
948            &config_path.join("openmw.cfg"),
949            settings.active_plugins_file()
950        );
951        assert_eq!(config_path, settings.my_games_path);
952    }
953
954    #[test]
955    fn id_should_be_the_id_the_struct_was_created_with() {
956        let settings = game_with_generic_paths(GameId::Morrowind);
957        assert_eq!(GameId::Morrowind, settings.id());
958    }
959
960    #[test]
961    fn load_order_method_should_be_timestamp_for_tes3_tes4_fo3_and_fonv() {
962        let mut settings = game_with_generic_paths(GameId::Morrowind);
963        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
964
965        settings = game_with_generic_paths(GameId::Oblivion);
966        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
967
968        settings = game_with_generic_paths(GameId::Fallout3);
969        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
970
971        settings = game_with_generic_paths(GameId::FalloutNV);
972        assert_eq!(LoadOrderMethod::Timestamp, settings.load_order_method());
973    }
974
975    #[test]
976    fn load_order_method_should_be_textfile_for_tes5() {
977        let settings = game_with_generic_paths(GameId::Skyrim);
978        assert_eq!(LoadOrderMethod::Textfile, settings.load_order_method());
979    }
980
981    #[test]
982    fn load_order_method_should_be_asterisk_for_tes5se_tes5vr_fo4_fo4vr_and_starfield() {
983        let mut settings = game_with_generic_paths(GameId::SkyrimSE);
984        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
985
986        settings = game_with_generic_paths(GameId::SkyrimVR);
987        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
988
989        settings = game_with_generic_paths(GameId::Fallout4);
990        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
991
992        settings = game_with_generic_paths(GameId::Fallout4VR);
993        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
994
995        settings = game_with_generic_paths(GameId::Starfield);
996        assert_eq!(LoadOrderMethod::Asterisk, settings.load_order_method());
997    }
998
999    #[test]
1000    fn load_order_method_should_be_openmw_for_openmw() {
1001        let settings = game_with_generic_paths(GameId::OpenMW);
1002
1003        assert_eq!(LoadOrderMethod::OpenMW, settings.load_order_method());
1004    }
1005
1006    #[test]
1007    #[expect(deprecated)]
1008    fn master_file_should_be_mapped_from_game_id() {
1009        let mut settings = game_with_generic_paths(GameId::OpenMW);
1010        assert_eq!("Morrowind.esm", settings.master_file());
1011
1012        settings = game_with_generic_paths(GameId::Morrowind);
1013        assert_eq!("Morrowind.esm", settings.master_file());
1014
1015        settings = game_with_generic_paths(GameId::Oblivion);
1016        assert_eq!("Oblivion.esm", settings.master_file());
1017
1018        settings = game_with_generic_paths(GameId::Skyrim);
1019        assert_eq!("Skyrim.esm", settings.master_file());
1020
1021        settings = game_with_generic_paths(GameId::SkyrimSE);
1022        assert_eq!("Skyrim.esm", settings.master_file());
1023
1024        settings = game_with_generic_paths(GameId::SkyrimVR);
1025        assert_eq!("Skyrim.esm", settings.master_file());
1026
1027        settings = game_with_generic_paths(GameId::Fallout3);
1028        assert_eq!("Fallout3.esm", settings.master_file());
1029
1030        settings = game_with_generic_paths(GameId::FalloutNV);
1031        assert_eq!("FalloutNV.esm", settings.master_file());
1032
1033        settings = game_with_generic_paths(GameId::Fallout4);
1034        assert_eq!("Fallout4.esm", settings.master_file());
1035
1036        settings = game_with_generic_paths(GameId::Fallout4VR);
1037        assert_eq!("Fallout4.esm", settings.master_file());
1038
1039        settings = game_with_generic_paths(GameId::Starfield);
1040        assert_eq!("Starfield.esm", settings.master_file());
1041    }
1042
1043    #[test]
1044    fn appdata_folder_name_should_be_mapped_from_game_id() {
1045        // The game path is unused for most game IDs.
1046        let game_path = Path::new("");
1047
1048        assert!(appdata_folder_name(GameId::OpenMW, game_path).is_none());
1049
1050        assert!(appdata_folder_name(GameId::Morrowind, game_path).is_none());
1051
1052        let mut folder = appdata_folder_name(GameId::Oblivion, game_path).unwrap();
1053        assert_eq!("Oblivion", folder);
1054
1055        folder = appdata_folder_name(GameId::Skyrim, game_path).unwrap();
1056        assert_eq!("Skyrim", folder);
1057
1058        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1059        assert_eq!("Skyrim Special Edition", folder);
1060
1061        folder = appdata_folder_name(GameId::SkyrimVR, game_path).unwrap();
1062        assert_eq!("Skyrim VR", folder);
1063
1064        folder = appdata_folder_name(GameId::Fallout3, game_path).unwrap();
1065        assert_eq!("Fallout3", folder);
1066
1067        folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1068        assert_eq!("FalloutNV", folder);
1069
1070        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1071        assert_eq!("Fallout4", folder);
1072
1073        folder = appdata_folder_name(GameId::Fallout4VR, game_path).unwrap();
1074        assert_eq!("Fallout4VR", folder);
1075
1076        folder = appdata_folder_name(GameId::Starfield, game_path).unwrap();
1077        assert_eq!("Starfield", folder);
1078    }
1079
1080    #[test]
1081    fn appdata_folder_name_for_skyrim_se_should_have_gog_suffix_if_galaxy_dll_is_in_game_path() {
1082        let tmp_dir = tempdir().unwrap();
1083        let game_path = tmp_dir.path();
1084
1085        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1086        assert_eq!("Skyrim Special Edition", folder);
1087
1088        let dll_path = game_path.join("Galaxy64.dll");
1089        File::create(&dll_path).unwrap();
1090
1091        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1092        assert_eq!("Skyrim Special Edition GOG", folder);
1093    }
1094
1095    #[test]
1096    fn appdata_folder_name_for_skyrim_se_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1097        let tmp_dir = tempdir().unwrap();
1098        let game_path = tmp_dir.path();
1099
1100        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1101        assert_eq!("Skyrim Special Edition", folder);
1102
1103        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1104        File::create(&dll_path).unwrap();
1105
1106        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1107        assert_eq!("Skyrim Special Edition EPIC", folder);
1108    }
1109
1110    #[test]
1111    fn appdata_folder_name_for_skyrim_se_should_have_ms_suffix_if_appxmanifest_xml_is_in_game_path()
1112    {
1113        let tmp_dir = tempdir().unwrap();
1114        let game_path = tmp_dir.path();
1115
1116        let mut folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1117        assert_eq!("Skyrim Special Edition", folder);
1118
1119        let dll_path = game_path.join("appxmanifest.xml");
1120        File::create(&dll_path).unwrap();
1121
1122        folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1123        assert_eq!("Skyrim Special Edition MS", folder);
1124    }
1125
1126    #[test]
1127    fn appdata_folder_name_for_skyrim_se_prefers_gog_suffix_over_epic_suffix() {
1128        let tmp_dir = tempdir().unwrap();
1129        let game_path = tmp_dir.path();
1130
1131        let dll_path = game_path.join("Galaxy64.dll");
1132        File::create(&dll_path).unwrap();
1133
1134        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1135        File::create(&dll_path).unwrap();
1136
1137        let folder = appdata_folder_name(GameId::SkyrimSE, game_path).unwrap();
1138        assert_eq!("Skyrim Special Edition GOG", folder);
1139    }
1140
1141    #[test]
1142    fn appdata_folder_name_for_fallout_nv_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1143        let tmp_dir = tempdir().unwrap();
1144        let game_path = tmp_dir.path();
1145
1146        let mut folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1147        assert_eq!("FalloutNV", folder);
1148
1149        let dll_path = game_path.join("EOSSDK-Win32-Shipping.dll");
1150        File::create(&dll_path).unwrap();
1151
1152        folder = appdata_folder_name(GameId::FalloutNV, game_path).unwrap();
1153        assert_eq!("FalloutNV_Epic", folder);
1154    }
1155
1156    #[test]
1157    fn appdata_folder_name_for_fallout4_should_have_ms_suffix_if_appxmanifest_xml_is_in_game_path()
1158    {
1159        let tmp_dir = tempdir().unwrap();
1160        let game_path = tmp_dir.path();
1161
1162        let mut folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1163        assert_eq!("Fallout4", folder);
1164
1165        let dll_path = game_path.join("appxmanifest.xml");
1166        File::create(&dll_path).unwrap();
1167
1168        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1169        assert_eq!("Fallout4 MS", folder);
1170    }
1171
1172    #[test]
1173    fn appdata_folder_name_for_fallout4_should_have_epic_suffix_if_eossdk_dll_is_in_game_path() {
1174        let tmp_dir = tempdir().unwrap();
1175        let game_path = tmp_dir.path();
1176
1177        let mut folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1178        assert_eq!("Fallout4", folder);
1179
1180        let dll_path = game_path.join("EOSSDK-Win64-Shipping.dll");
1181        File::create(&dll_path).unwrap();
1182
1183        folder = appdata_folder_name(GameId::Fallout4, game_path).unwrap();
1184        assert_eq!("Fallout4 EPIC", folder);
1185    }
1186
1187    #[test]
1188    #[cfg(windows)]
1189    fn my_games_path_should_be_in_documents_path_on_windows() {
1190        let empty_path = Path::new("");
1191        let parent_path = dirs::document_dir().unwrap().join("My Games");
1192
1193        let path = my_games_path(GameId::Morrowind, empty_path, empty_path).unwrap();
1194        assert!(path.is_none());
1195
1196        let path = my_games_path(GameId::Oblivion, empty_path, empty_path)
1197            .unwrap()
1198            .unwrap();
1199        assert_eq!(parent_path.join("Oblivion"), path);
1200
1201        let path = my_games_path(GameId::Skyrim, empty_path, empty_path)
1202            .unwrap()
1203            .unwrap();
1204        assert_eq!(parent_path.join("Skyrim"), path);
1205
1206        let path = my_games_path(GameId::SkyrimSE, empty_path, empty_path)
1207            .unwrap()
1208            .unwrap();
1209        assert_eq!(parent_path.join("Skyrim Special Edition"), path);
1210
1211        let path = my_games_path(GameId::SkyrimVR, empty_path, empty_path)
1212            .unwrap()
1213            .unwrap();
1214        assert_eq!(parent_path.join("Skyrim VR"), path);
1215
1216        let path = my_games_path(GameId::Fallout3, empty_path, empty_path)
1217            .unwrap()
1218            .unwrap();
1219        assert_eq!(parent_path.join("Fallout3"), path);
1220
1221        let path = my_games_path(GameId::FalloutNV, empty_path, empty_path)
1222            .unwrap()
1223            .unwrap();
1224        assert_eq!(parent_path.join("FalloutNV"), path);
1225
1226        let path = my_games_path(GameId::Fallout4, empty_path, empty_path)
1227            .unwrap()
1228            .unwrap();
1229        assert_eq!(parent_path.join("Fallout4"), path);
1230
1231        let path = my_games_path(GameId::Fallout4VR, empty_path, empty_path)
1232            .unwrap()
1233            .unwrap();
1234        assert_eq!(parent_path.join("Fallout4VR"), path);
1235
1236        let path = my_games_path(GameId::Starfield, empty_path, empty_path)
1237            .unwrap()
1238            .unwrap();
1239        assert_eq!(parent_path.join("Starfield"), path);
1240    }
1241
1242    #[test]
1243    #[cfg(not(windows))]
1244    fn my_games_path_should_be_relative_to_local_path_on_linux() {
1245        let empty_path = Path::new("");
1246        let local_path = Path::new("wineprefix/drive_c/Users/user/AppData/Local/Game");
1247        let parent_path = Path::new("wineprefix/drive_c/Users/user/Documents/My Games");
1248
1249        let path = my_games_path(GameId::Morrowind, empty_path, local_path).unwrap();
1250        assert!(path.is_none());
1251
1252        let path = my_games_path(GameId::Oblivion, empty_path, local_path)
1253            .unwrap()
1254            .unwrap();
1255        assert_eq!(parent_path.join("Oblivion"), path);
1256
1257        let path = my_games_path(GameId::Skyrim, empty_path, local_path)
1258            .unwrap()
1259            .unwrap();
1260        assert_eq!(parent_path.join("Skyrim"), path);
1261
1262        let path = my_games_path(GameId::SkyrimSE, empty_path, local_path)
1263            .unwrap()
1264            .unwrap();
1265        assert_eq!(parent_path.join("Skyrim Special Edition"), path);
1266
1267        let path = my_games_path(GameId::SkyrimVR, empty_path, local_path)
1268            .unwrap()
1269            .unwrap();
1270        assert_eq!(parent_path.join("Skyrim VR"), path);
1271
1272        let path = my_games_path(GameId::Fallout3, empty_path, local_path)
1273            .unwrap()
1274            .unwrap();
1275        assert_eq!(parent_path.join("Fallout3"), path);
1276
1277        let path = my_games_path(GameId::FalloutNV, empty_path, local_path)
1278            .unwrap()
1279            .unwrap();
1280        assert_eq!(parent_path.join("FalloutNV"), path);
1281
1282        let path = my_games_path(GameId::Fallout4, empty_path, local_path)
1283            .unwrap()
1284            .unwrap();
1285        assert_eq!(parent_path.join("Fallout4"), path);
1286
1287        let path = my_games_path(GameId::Fallout4VR, empty_path, local_path)
1288            .unwrap()
1289            .unwrap();
1290        assert_eq!(parent_path.join("Fallout4VR"), path);
1291
1292        let path = my_games_path(GameId::Starfield, empty_path, local_path)
1293            .unwrap()
1294            .unwrap();
1295        assert_eq!(parent_path.join("Starfield"), path);
1296    }
1297
1298    #[test]
1299    #[cfg(windows)]
1300    fn my_games_path_should_be_local_path_for_openmw() {
1301        let local_path = Path::new("path/to/local");
1302
1303        let path = my_games_path(GameId::OpenMW, Path::new(""), local_path)
1304            .unwrap()
1305            .unwrap();
1306        assert_eq!(local_path, path);
1307    }
1308
1309    #[test]
1310    fn plugins_directory_should_be_mapped_from_game_id() {
1311        let data_path = Path::new("Data");
1312        let empty_path = Path::new("");
1313        let closure = |game_id| plugins_directory(game_id, empty_path, empty_path).unwrap();
1314
1315        assert_eq!(Path::new("resources/vfs"), closure(GameId::OpenMW));
1316        assert_eq!(Path::new("Data Files"), closure(GameId::Morrowind));
1317        assert_eq!(data_path, closure(GameId::Oblivion));
1318        assert_eq!(data_path, closure(GameId::Skyrim));
1319        assert_eq!(data_path, closure(GameId::SkyrimSE));
1320        assert_eq!(data_path, closure(GameId::SkyrimVR));
1321        assert_eq!(data_path, closure(GameId::Fallout3));
1322        assert_eq!(data_path, closure(GameId::FalloutNV));
1323        assert_eq!(data_path, closure(GameId::Fallout4));
1324        assert_eq!(data_path, closure(GameId::Fallout4VR));
1325        assert_eq!(data_path, closure(GameId::Starfield));
1326    }
1327
1328    #[test]
1329    fn active_plugins_file_should_be_mapped_from_game_id() {
1330        let mut settings = game_with_generic_paths(GameId::OpenMW);
1331        assert_eq!(
1332            Path::new("local/openmw.cfg"),
1333            settings.active_plugins_file()
1334        );
1335
1336        settings = game_with_generic_paths(GameId::Morrowind);
1337        assert_eq!(
1338            Path::new("game/Morrowind.ini"),
1339            settings.active_plugins_file()
1340        );
1341
1342        settings = game_with_generic_paths(GameId::Oblivion);
1343        assert_eq!(
1344            Path::new("local/Plugins.txt"),
1345            settings.active_plugins_file()
1346        );
1347
1348        settings = game_with_generic_paths(GameId::Skyrim);
1349        assert_eq!(
1350            Path::new("local/Plugins.txt"),
1351            settings.active_plugins_file()
1352        );
1353
1354        settings = game_with_generic_paths(GameId::SkyrimSE);
1355        assert_eq!(
1356            Path::new("local/Plugins.txt"),
1357            settings.active_plugins_file()
1358        );
1359
1360        settings = game_with_generic_paths(GameId::SkyrimVR);
1361        assert_eq!(
1362            Path::new("local/Plugins.txt"),
1363            settings.active_plugins_file()
1364        );
1365
1366        settings = game_with_generic_paths(GameId::Fallout3);
1367        assert_eq!(
1368            Path::new("local/Plugins.txt"),
1369            settings.active_plugins_file()
1370        );
1371
1372        settings = game_with_generic_paths(GameId::FalloutNV);
1373        assert_eq!(
1374            Path::new("local/Plugins.txt"),
1375            settings.active_plugins_file()
1376        );
1377
1378        settings = game_with_generic_paths(GameId::Fallout4);
1379        assert_eq!(
1380            Path::new("local/Plugins.txt"),
1381            settings.active_plugins_file()
1382        );
1383
1384        settings = game_with_generic_paths(GameId::Fallout4VR);
1385        assert_eq!(
1386            Path::new("local/Plugins.txt"),
1387            settings.active_plugins_file()
1388        );
1389
1390        settings = game_with_generic_paths(GameId::Starfield);
1391        assert_eq!(
1392            Path::new("local/Plugins.txt"),
1393            settings.active_plugins_file()
1394        );
1395    }
1396
1397    #[test]
1398    fn active_plugins_file_should_be_in_game_path_for_oblivion_if_ini_setting_is_not_1() {
1399        let tmp_dir = tempdir().unwrap();
1400        let game_path = tmp_dir.path();
1401        let ini_path = game_path.join("Oblivion.ini");
1402
1403        std::fs::write(ini_path, "[General]\nbUseMyGamesDirectory=0\n").unwrap();
1404
1405        let settings = game_with_game_path(GameId::Oblivion, game_path);
1406        assert_eq!(
1407            game_path.join("Plugins.txt"),
1408            *settings.active_plugins_file()
1409        );
1410    }
1411
1412    #[test]
1413    fn early_loading_plugins_should_be_mapped_from_game_id() {
1414        let mut settings = game_with_generic_paths(GameId::Skyrim);
1415        let mut plugins = vec!["Skyrim.esm"];
1416        assert_eq!(plugins, settings.early_loading_plugins());
1417
1418        settings = game_with_generic_paths(GameId::SkyrimSE);
1419        plugins = vec![
1420            "Skyrim.esm",
1421            "Update.esm",
1422            "Dawnguard.esm",
1423            "HearthFires.esm",
1424            "Dragonborn.esm",
1425        ];
1426        assert_eq!(plugins, settings.early_loading_plugins());
1427
1428        settings = game_with_generic_paths(GameId::SkyrimVR);
1429        plugins = vec![
1430            "Skyrim.esm",
1431            "Update.esm",
1432            "Dawnguard.esm",
1433            "HearthFires.esm",
1434            "Dragonborn.esm",
1435            "SkyrimVR.esm",
1436        ];
1437        assert_eq!(plugins, settings.early_loading_plugins());
1438
1439        settings = game_with_generic_paths(GameId::Fallout4);
1440        plugins = vec![
1441            "Fallout4.esm",
1442            "DLCRobot.esm",
1443            "DLCworkshop01.esm",
1444            "DLCCoast.esm",
1445            "DLCworkshop02.esm",
1446            "DLCworkshop03.esm",
1447            "DLCNukaWorld.esm",
1448            "DLCUltraHighResolution.esm",
1449        ];
1450        assert_eq!(plugins, settings.early_loading_plugins());
1451
1452        settings = game_with_generic_paths(GameId::OpenMW);
1453        plugins = vec!["builtin.omwscripts"];
1454        assert_eq!(plugins, settings.early_loading_plugins());
1455
1456        settings = game_with_generic_paths(GameId::Morrowind);
1457        assert!(settings.early_loading_plugins().is_empty());
1458
1459        settings = game_with_generic_paths(GameId::Oblivion);
1460        assert!(settings.early_loading_plugins().is_empty());
1461
1462        settings = game_with_generic_paths(GameId::Fallout3);
1463        assert!(settings.early_loading_plugins().is_empty());
1464
1465        settings = game_with_generic_paths(GameId::FalloutNV);
1466        assert!(settings.early_loading_plugins().is_empty());
1467
1468        settings = game_with_generic_paths(GameId::Fallout4VR);
1469        plugins = vec!["Fallout4.esm", "Fallout4_VR.esm"];
1470        assert_eq!(plugins, settings.early_loading_plugins());
1471
1472        settings = game_with_generic_paths(GameId::Starfield);
1473        plugins = vec![
1474            "Starfield.esm",
1475            "Constellation.esm",
1476            "OldMars.esm",
1477            "ShatteredSpace.esm",
1478            "SFBGS003.esm",
1479            "SFBGS004.esm",
1480            "SFBGS006.esm",
1481            "SFBGS007.esm",
1482            "SFBGS008.esm",
1483        ];
1484        assert_eq!(plugins, settings.early_loading_plugins());
1485    }
1486
1487    #[test]
1488    fn early_loading_plugins_should_include_plugins_loaded_from_ccc_file() {
1489        let tmp_dir = tempdir().unwrap();
1490        let game_path = tmp_dir.path();
1491
1492        let mut plugins = vec![
1493            "Skyrim.esm",
1494            "Update.esm",
1495            "Dawnguard.esm",
1496            "HearthFires.esm",
1497            "Dragonborn.esm",
1498            "ccBGSSSE002-ExoticArrows.esl",
1499            "ccBGSSSE003-Zombies.esl",
1500            "ccBGSSSE004-RuinsEdge.esl",
1501            "ccBGSSSE006-StendarsHammer.esl",
1502            "ccBGSSSE007-Chrysamere.esl",
1503            "ccBGSSSE010-PetDwarvenArmoredMudcrab.esl",
1504            "ccBGSSSE014-SpellPack01.esl",
1505            "ccBGSSSE019-StaffofSheogorath.esl",
1506            "ccMTYSSE001-KnightsoftheNine.esl",
1507            "ccQDRSSE001-SurvivalMode.esl",
1508        ];
1509        let mut settings = game_with_ccc_plugins(GameId::SkyrimSE, game_path, &plugins[5..]);
1510        assert_eq!(plugins, settings.early_loading_plugins());
1511
1512        plugins = vec![
1513            "Fallout4.esm",
1514            "DLCRobot.esm",
1515            "DLCworkshop01.esm",
1516            "DLCCoast.esm",
1517            "DLCworkshop02.esm",
1518            "DLCworkshop03.esm",
1519            "DLCNukaWorld.esm",
1520            "DLCUltraHighResolution.esm",
1521            "ccBGSFO4001-PipBoy(Black).esl",
1522            "ccBGSFO4002-PipBoy(Blue).esl",
1523            "ccBGSFO4003-PipBoy(Camo01).esl",
1524            "ccBGSFO4004-PipBoy(Camo02).esl",
1525            "ccBGSFO4006-PipBoy(Chrome).esl",
1526            "ccBGSFO4012-PipBoy(Red).esl",
1527            "ccBGSFO4014-PipBoy(White).esl",
1528            "ccBGSFO4016-Prey.esl",
1529            "ccBGSFO4017-Mauler.esl",
1530            "ccBGSFO4018-GaussRiflePrototype.esl",
1531            "ccBGSFO4019-ChineseStealthArmor.esl",
1532            "ccBGSFO4020-PowerArmorSkin(Black).esl",
1533            "ccBGSFO4022-PowerArmorSkin(Camo01).esl",
1534            "ccBGSFO4023-PowerArmorSkin(Camo02).esl",
1535            "ccBGSFO4025-PowerArmorSkin(Chrome).esl",
1536            "ccBGSFO4038-HorseArmor.esl",
1537            "ccBGSFO4039-TunnelSnakes.esl",
1538            "ccBGSFO4041-DoomMarineArmor.esl",
1539            "ccBGSFO4042-BFG.esl",
1540            "ccBGSFO4043-DoomChainsaw.esl",
1541            "ccBGSFO4044-HellfirePowerArmor.esl",
1542            "ccFSVFO4001-ModularMilitaryBackpack.esl",
1543            "ccFSVFO4002-MidCenturyModern.esl",
1544            "ccFRSFO4001-HandmadeShotgun.esl",
1545            "ccEEJFO4001-DecorationPack.esl",
1546        ];
1547        settings = game_with_ccc_plugins(GameId::Fallout4, game_path, &plugins[8..]);
1548        assert_eq!(plugins, settings.early_loading_plugins());
1549    }
1550
1551    #[test]
1552    fn early_loading_plugins_should_use_the_starfield_ccc_file_in_game_path() {
1553        let tmp_dir = tempdir().unwrap();
1554        let game_path = tmp_dir.path().join("game");
1555        let my_games_path = tmp_dir.path().join("my games");
1556
1557        create_ccc_file(&game_path.join("Starfield.ccc"), &["test.esm"]);
1558
1559        let settings = GameSettings::with_local_and_my_games_paths(
1560            GameId::Starfield,
1561            &game_path,
1562            &PathBuf::default(),
1563            my_games_path,
1564        )
1565        .unwrap();
1566
1567        let expected = &[
1568            "Starfield.esm",
1569            "Constellation.esm",
1570            "OldMars.esm",
1571            "ShatteredSpace.esm",
1572            "SFBGS003.esm",
1573            "SFBGS004.esm",
1574            "SFBGS006.esm",
1575            "SFBGS007.esm",
1576            "SFBGS008.esm",
1577            "test.esm",
1578        ];
1579        assert_eq!(expected, settings.early_loading_plugins());
1580    }
1581
1582    #[test]
1583    fn early_loading_plugins_should_use_the_starfield_ccc_file_in_my_games_path() {
1584        let tmp_dir = tempdir().unwrap();
1585        let game_path = tmp_dir.path().join("game");
1586        let my_games_path = tmp_dir.path().join("my games");
1587
1588        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test.esm"]);
1589
1590        let settings = GameSettings::with_local_and_my_games_paths(
1591            GameId::Starfield,
1592            &game_path,
1593            &PathBuf::default(),
1594            my_games_path,
1595        )
1596        .unwrap();
1597
1598        let expected = &[
1599            "Starfield.esm",
1600            "Constellation.esm",
1601            "OldMars.esm",
1602            "ShatteredSpace.esm",
1603            "SFBGS003.esm",
1604            "SFBGS004.esm",
1605            "SFBGS006.esm",
1606            "SFBGS007.esm",
1607            "SFBGS008.esm",
1608            "test.esm",
1609        ];
1610        assert_eq!(expected, settings.early_loading_plugins());
1611    }
1612
1613    #[test]
1614    fn early_loading_plugins_should_use_the_first_ccc_file_that_exists() {
1615        let tmp_dir = tempdir().unwrap();
1616        let game_path = tmp_dir.path().join("game");
1617        let my_games_path = tmp_dir.path().join("my games");
1618
1619        create_ccc_file(&game_path.join("Starfield.ccc"), &["test1.esm"]);
1620        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test2.esm"]);
1621
1622        let settings = GameSettings::with_local_and_my_games_paths(
1623            GameId::Starfield,
1624            &game_path,
1625            &PathBuf::default(),
1626            my_games_path,
1627        )
1628        .unwrap();
1629
1630        let expected = &[
1631            "Starfield.esm",
1632            "Constellation.esm",
1633            "OldMars.esm",
1634            "ShatteredSpace.esm",
1635            "SFBGS003.esm",
1636            "SFBGS004.esm",
1637            "SFBGS006.esm",
1638            "SFBGS007.esm",
1639            "SFBGS008.esm",
1640            "test2.esm",
1641        ];
1642        assert_eq!(expected, settings.early_loading_plugins());
1643    }
1644
1645    #[test]
1646    fn early_loading_plugins_should_not_include_cc_plugins_for_fallout4_if_test_files_are_configured(
1647    ) {
1648        let tmp_dir = tempdir().unwrap();
1649        let game_path = tmp_dir.path();
1650
1651        create_ccc_file(
1652            &game_path.join("Fallout4.ccc"),
1653            &["ccBGSFO4001-PipBoy(Black).esl"],
1654        );
1655
1656        let ini_path = game_path.join("Fallout4.ini");
1657        std::fs::write(&ini_path, "[General]\nsTestFile1=Blank.esp\n").unwrap();
1658
1659        copy_to_dir(
1660            "Blank.esp",
1661            &game_path.join("Data"),
1662            "Blank.esp",
1663            GameId::Fallout4,
1664        );
1665
1666        let settings = GameSettings::with_local_and_my_games_paths(
1667            GameId::Fallout4,
1668            game_path,
1669            &PathBuf::default(),
1670            game_path.to_path_buf(),
1671        )
1672        .unwrap();
1673
1674        assert_eq!(FALLOUT4_HARDCODED_PLUGINS, settings.early_loading_plugins());
1675    }
1676
1677    #[test]
1678    fn early_loading_plugins_should_not_include_cc_plugins_for_starfield_if_test_files_are_configured(
1679    ) {
1680        let tmp_dir = tempdir().unwrap();
1681        let game_path = tmp_dir.path();
1682        let my_games_path = tmp_dir.path().join("my games");
1683
1684        create_ccc_file(&my_games_path.join("Starfield.ccc"), &["test.esp"]);
1685
1686        let ini_path = game_path.join("Starfield.ini");
1687        std::fs::write(&ini_path, "[General]\nsTestFile1=Blank.esp\n").unwrap();
1688
1689        copy_to_dir(
1690            "Blank.esp",
1691            &game_path.join("Data"),
1692            "Blank.esp",
1693            GameId::Starfield,
1694        );
1695
1696        let settings = GameSettings::with_local_and_my_games_paths(
1697            GameId::Starfield,
1698            game_path,
1699            &PathBuf::default(),
1700            my_games_path,
1701        )
1702        .unwrap();
1703
1704        assert!(!settings.loads_early("test.esp"));
1705    }
1706
1707    #[test]
1708    fn early_loading_plugins_should_include_plugins_from_global_config_for_openmw() {
1709        let tmp_dir = tempdir().unwrap();
1710        let global_cfg_path = tmp_dir.path().join("openmw.cfg");
1711
1712        std::fs::write(
1713            &global_cfg_path,
1714            "config=local\ncontent=test.esm\ncontent=test.esp",
1715        )
1716        .unwrap();
1717
1718        let settings = game_with_game_path(GameId::OpenMW, tmp_dir.path());
1719
1720        let expected = &["builtin.omwscripts", "test.esm", "test.esp"];
1721
1722        assert_eq!(expected, settings.early_loading_plugins());
1723    }
1724
1725    #[test]
1726    fn early_loading_plugins_should_ignore_later_duplicate_entries() {
1727        let tmp_dir = tempdir().unwrap();
1728        let game_path = tmp_dir.path();
1729        let my_games_path = tmp_dir.path().join("my games");
1730
1731        create_ccc_file(
1732            &my_games_path.join("Starfield.ccc"),
1733            &["Starfield.esm", "test.esm"],
1734        );
1735
1736        let settings = GameSettings::with_local_and_my_games_paths(
1737            GameId::Starfield,
1738            game_path,
1739            &PathBuf::default(),
1740            my_games_path,
1741        )
1742        .unwrap();
1743
1744        let expected = &[
1745            "Starfield.esm",
1746            "Constellation.esm",
1747            "OldMars.esm",
1748            "ShatteredSpace.esm",
1749            "SFBGS003.esm",
1750            "SFBGS004.esm",
1751            "SFBGS006.esm",
1752            "SFBGS007.esm",
1753            "SFBGS008.esm",
1754            "test.esm",
1755        ];
1756        assert_eq!(expected, settings.early_loading_plugins());
1757    }
1758
1759    #[test]
1760    fn implicitly_active_plugins_should_include_early_loading_plugins() {
1761        let tmp_dir = tempdir().unwrap();
1762        let game_path = tmp_dir.path();
1763
1764        let settings = game_with_game_path(GameId::SkyrimSE, game_path);
1765
1766        assert_eq!(
1767            settings.early_loading_plugins(),
1768            settings.implicitly_active_plugins()
1769        );
1770    }
1771
1772    #[test]
1773    fn implicitly_active_plugins_should_include_test_files() {
1774        let tmp_dir = tempdir().unwrap();
1775        let game_path = tmp_dir.path();
1776
1777        let ini_path = game_path.join("Skyrim.ini");
1778        std::fs::write(&ini_path, "[General]\nsTestFile1=plugin.esp\n").unwrap();
1779
1780        let settings = GameSettings::with_local_and_my_games_paths(
1781            GameId::SkyrimSE,
1782            game_path,
1783            &PathBuf::default(),
1784            game_path.to_path_buf(),
1785        )
1786        .unwrap();
1787
1788        let mut expected_plugins = settings.early_loading_plugins().to_vec();
1789        expected_plugins.push("plugin.esp".to_owned());
1790
1791        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
1792    }
1793
1794    #[test]
1795    fn implicitly_active_plugins_should_only_include_valid_test_files_for_fallout4() {
1796        let tmp_dir = tempdir().unwrap();
1797        let game_path = tmp_dir.path();
1798
1799        let ini_path = game_path.join("Fallout4.ini");
1800        std::fs::write(
1801            &ini_path,
1802            "[General]\nsTestFile1=plugin.esp\nsTestFile2=Blank.esp",
1803        )
1804        .unwrap();
1805
1806        copy_to_dir(
1807            "Blank.esp",
1808            &game_path.join("Data"),
1809            "Blank.esp",
1810            GameId::Fallout4,
1811        );
1812
1813        let settings = GameSettings::with_local_and_my_games_paths(
1814            GameId::Fallout4,
1815            game_path,
1816            &PathBuf::default(),
1817            game_path.to_path_buf(),
1818        )
1819        .unwrap();
1820
1821        let mut expected_plugins = settings.early_loading_plugins().to_vec();
1822        expected_plugins.push("Blank.esp".to_owned());
1823
1824        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
1825    }
1826
1827    #[test]
1828    fn implicitly_active_plugins_should_only_include_valid_test_files_for_fallout4vr() {
1829        let tmp_dir = tempdir().unwrap();
1830        let game_path = tmp_dir.path();
1831
1832        let ini_path = game_path.join("Fallout4VR.ini");
1833        std::fs::write(
1834            &ini_path,
1835            "[General]\nsTestFile1=plugin.esp\nsTestFile2=Blank.esp",
1836        )
1837        .unwrap();
1838
1839        copy_to_dir(
1840            "Blank.esp",
1841            &game_path.join("Data"),
1842            "Blank.esp",
1843            GameId::Fallout4VR,
1844        );
1845
1846        let settings = GameSettings::with_local_and_my_games_paths(
1847            GameId::Fallout4VR,
1848            game_path,
1849            &PathBuf::default(),
1850            game_path.to_path_buf(),
1851        )
1852        .unwrap();
1853
1854        let mut expected_plugins = settings.early_loading_plugins().to_vec();
1855        expected_plugins.push("Blank.esp".to_owned());
1856
1857        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
1858    }
1859
1860    #[test]
1861    fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_fallout_nv() {
1862        let tmp_dir = tempdir().unwrap();
1863        let game_path = tmp_dir.path();
1864        let data_path = game_path.join("Data");
1865
1866        create_dir_all(&data_path).unwrap();
1867        File::create(data_path.join("plugin1.nam")).unwrap();
1868        File::create(data_path.join("plugin2.NAM")).unwrap();
1869
1870        let settings = game_with_game_path(GameId::FalloutNV, game_path);
1871        let expected_plugins = vec!["plugin1.esm", "plugin1.esp", "plugin2.esm", "plugin2.esp"];
1872        let mut plugins = settings.implicitly_active_plugins().to_vec();
1873        plugins.sort();
1874
1875        assert_eq!(expected_plugins, plugins);
1876    }
1877
1878    #[test]
1879    fn implicitly_active_plugins_should_include_update_esm_for_skyrim() {
1880        let settings = game_with_generic_paths(GameId::Skyrim);
1881        let plugins = settings.implicitly_active_plugins();
1882
1883        assert!(plugins.contains(&"Update.esm".to_owned()));
1884    }
1885
1886    #[test]
1887    fn implicitly_active_plugins_should_include_blueprintships_starfield_esm_for_starfield() {
1888        let settings = game_with_generic_paths(GameId::Starfield);
1889        let plugins = settings.implicitly_active_plugins();
1890
1891        assert!(plugins.contains(&"BlueprintShips-Starfield.esm".to_owned()));
1892    }
1893
1894    #[test]
1895    fn implicitly_active_plugins_should_include_plugins_with_nam_files_for_games_other_than_fallout_nv(
1896    ) {
1897        let tmp_dir = tempdir().unwrap();
1898        let game_path = tmp_dir.path();
1899        let data_path = game_path.join("Data");
1900
1901        create_dir_all(&data_path).unwrap();
1902        File::create(data_path.join("plugin.nam")).unwrap();
1903
1904        let settings = game_with_game_path(GameId::Fallout3, game_path);
1905        assert!(settings.implicitly_active_plugins().is_empty());
1906    }
1907
1908    #[test]
1909    fn implicitly_active_plugins_should_not_include_case_insensitive_duplicates() {
1910        let tmp_dir = tempdir().unwrap();
1911        let game_path = tmp_dir.path();
1912
1913        let ini_path = game_path.join("Fallout4.ini");
1914        std::fs::write(&ini_path, "[General]\nsTestFile1=fallout4.esm\n").unwrap();
1915
1916        let settings = GameSettings::with_local_and_my_games_paths(
1917            GameId::Fallout4,
1918            game_path,
1919            &PathBuf::default(),
1920            game_path.to_path_buf(),
1921        )
1922        .unwrap();
1923
1924        assert_eq!(
1925            settings.early_loading_plugins(),
1926            settings.implicitly_active_plugins()
1927        );
1928    }
1929
1930    #[test]
1931    fn is_implicitly_active_should_return_true_iff_the_plugin_is_implicitly_active() {
1932        let settings = game_with_generic_paths(GameId::Skyrim);
1933        assert!(settings.is_implicitly_active("Update.esm"));
1934        assert!(!settings.is_implicitly_active("Test.esm"));
1935    }
1936
1937    #[test]
1938    fn is_implicitly_active_should_match_case_insensitively() {
1939        let settings = game_with_generic_paths(GameId::Skyrim);
1940        assert!(settings.is_implicitly_active("update.esm"));
1941    }
1942
1943    #[test]
1944    fn loads_early_should_return_true_iff_the_plugin_loads_early() {
1945        let settings = game_with_generic_paths(GameId::SkyrimSE);
1946        assert!(settings.loads_early("Dawnguard.esm"));
1947        assert!(!settings.loads_early("Test.esm"));
1948    }
1949
1950    #[test]
1951    fn loads_early_should_match_case_insensitively() {
1952        let settings = game_with_generic_paths(GameId::SkyrimSE);
1953        assert!(settings.loads_early("dawnguard.esm"));
1954    }
1955
1956    #[test]
1957    fn plugins_folder_should_be_a_child_of_the_game_path() {
1958        let settings = game_with_generic_paths(GameId::Skyrim);
1959        assert_eq!(Path::new("game/Data"), settings.plugins_directory());
1960    }
1961
1962    #[test]
1963    fn load_order_file_should_be_in_local_path_for_skyrim_and_none_for_other_games() {
1964        let mut settings = game_with_generic_paths(GameId::Skyrim);
1965        assert_eq!(
1966            Path::new("local/loadorder.txt"),
1967            settings.load_order_file().unwrap()
1968        );
1969
1970        settings = game_with_generic_paths(GameId::SkyrimSE);
1971        assert!(settings.load_order_file().is_none());
1972
1973        settings = game_with_generic_paths(GameId::OpenMW);
1974        assert!(settings.load_order_file().is_none());
1975
1976        settings = game_with_generic_paths(GameId::Morrowind);
1977        assert!(settings.load_order_file().is_none());
1978
1979        settings = game_with_generic_paths(GameId::Oblivion);
1980        assert!(settings.load_order_file().is_none());
1981
1982        settings = game_with_generic_paths(GameId::Fallout3);
1983        assert!(settings.load_order_file().is_none());
1984
1985        settings = game_with_generic_paths(GameId::FalloutNV);
1986        assert!(settings.load_order_file().is_none());
1987
1988        settings = game_with_generic_paths(GameId::Fallout4);
1989        assert!(settings.load_order_file().is_none());
1990    }
1991
1992    #[test]
1993    fn additional_plugins_directories_should_be_empty_if_game_is_not_fallout4_or_starfield() {
1994        let tmp_dir = tempdir().unwrap();
1995        let game_path = tmp_dir.path();
1996
1997        File::create(game_path.join("appxmanifest.xml")).unwrap();
1998
1999        let game_ids = [
2000            GameId::Morrowind,
2001            GameId::Oblivion,
2002            GameId::Skyrim,
2003            GameId::SkyrimSE,
2004            GameId::SkyrimVR,
2005            GameId::Fallout3,
2006            GameId::FalloutNV,
2007        ];
2008
2009        for game_id in game_ids {
2010            let settings = game_with_game_path(game_id, game_path);
2011
2012            assert!(settings.additional_plugins_directories().is_empty());
2013        }
2014    }
2015
2016    #[test]
2017    fn additional_plugins_directories_should_be_empty_if_fallout4_is_not_from_the_microsoft_store()
2018    {
2019        let settings = game_with_generic_paths(GameId::Fallout4);
2020
2021        assert!(settings.additional_plugins_directories().is_empty());
2022    }
2023
2024    #[test]
2025    fn additional_plugins_directories_should_not_be_empty_if_game_is_fallout4_from_the_microsoft_store(
2026    ) {
2027        let tmp_dir = tempdir().unwrap();
2028        let game_path = tmp_dir.path();
2029
2030        File::create(game_path.join("appxmanifest.xml")).unwrap();
2031
2032        let settings = game_with_game_path(GameId::Fallout4, game_path);
2033
2034        assert_eq!(
2035            vec![
2036                game_path.join(MS_FO4_AUTOMATRON_PATH),
2037                game_path.join(MS_FO4_NUKA_WORLD_PATH),
2038                game_path.join(MS_FO4_WASTELAND_PATH),
2039                game_path.join(MS_FO4_TEXTURE_PACK_PATH),
2040                game_path.join(MS_FO4_VAULT_TEC_PATH),
2041                game_path.join(MS_FO4_FAR_HARBOR_PATH),
2042                game_path.join(MS_FO4_CONTRAPTIONS_PATH),
2043            ],
2044            settings.additional_plugins_directories()
2045        );
2046    }
2047
2048    #[test]
2049    fn additional_plugins_directories_should_not_be_empty_if_game_is_starfield() {
2050        let settings = game_with_generic_paths(GameId::Starfield);
2051
2052        assert_eq!(
2053            vec![Path::new("my games").join("Data")],
2054            settings.additional_plugins_directories()
2055        );
2056    }
2057
2058    #[test]
2059    fn additional_plugins_directories_should_read_from_openmw_cfgs() {
2060        let tmp_dir = tempdir().unwrap();
2061        let game_path = tmp_dir.path().join("game");
2062        let my_games_path = tmp_dir.path().join("my games");
2063        let global_cfg_path = game_path.join("openmw.cfg");
2064        let cfg_path = my_games_path.join("openmw.cfg");
2065
2066        create_dir_all(global_cfg_path.parent().unwrap()).unwrap();
2067        std::fs::write(&global_cfg_path, "config=\"../my games\"\ndata=\"foo/bar\"").unwrap();
2068
2069        create_dir_all(cfg_path.parent().unwrap()).unwrap();
2070        std::fs::write(
2071            &cfg_path,
2072            "data=\"Path\\&&&\"&a&&&&\\Data Files\"\ndata=games/path",
2073        )
2074        .unwrap();
2075
2076        let settings =
2077            GameSettings::with_local_path(GameId::OpenMW, &game_path, &my_games_path).unwrap();
2078
2079        let expected: Vec<PathBuf> = vec![
2080            game_path.join("foo/bar"),
2081            my_games_path.join("Path\\&\"a&&\\Data Files"),
2082            my_games_path.join("games/path"),
2083        ];
2084        assert_eq!(expected, settings.additional_plugins_directories());
2085    }
2086
2087    #[test]
2088    fn plugin_path_should_append_plugin_name_to_additional_plugin_directory_if_that_path_exists() {
2089        let tmp_dir = tempdir().unwrap();
2090        let other_dir = tmp_dir.path().join("other");
2091
2092        let plugin_name = "external.esp";
2093        let expected_plugin_path = other_dir.join(plugin_name);
2094
2095        let mut settings = game_with_generic_paths(GameId::Fallout4);
2096        settings.additional_plugins_directories = vec![other_dir.clone()];
2097
2098        copy_to_dir("Blank.esp", &other_dir, plugin_name, GameId::Fallout4);
2099
2100        let plugin_path = settings.plugin_path(plugin_name);
2101
2102        assert_eq!(expected_plugin_path, plugin_path);
2103    }
2104
2105    #[test]
2106    fn plugin_path_should_append_plugin_name_to_additional_plugin_directory_if_the_ghosted_path_exists(
2107    ) {
2108        let tmp_dir = tempdir().unwrap();
2109        let other_dir = tmp_dir.path().join("other");
2110
2111        let plugin_name = "external.esp";
2112        let ghosted_plugin_name = "external.esp.ghost";
2113        let expected_plugin_path = other_dir.join(ghosted_plugin_name);
2114
2115        let mut settings = game_with_generic_paths(GameId::Fallout4);
2116        settings.additional_plugins_directories = vec![other_dir.clone()];
2117
2118        copy_to_dir(
2119            "Blank.esp",
2120            &other_dir,
2121            ghosted_plugin_name,
2122            GameId::Fallout4,
2123        );
2124
2125        let plugin_path = settings.plugin_path(plugin_name);
2126
2127        assert_eq!(expected_plugin_path, plugin_path);
2128    }
2129
2130    #[test]
2131    fn plugin_path_should_not_resolve_ghosted_paths_for_openmw() {
2132        let tmp_dir = tempdir().unwrap();
2133        let game_path = tmp_dir.path().join("game");
2134        let other_dir = tmp_dir.path().join("other");
2135
2136        let plugin_name = "external.esp";
2137
2138        let mut settings = game_with_game_path(GameId::OpenMW, &game_path);
2139        settings.additional_plugins_directories = vec![other_dir.clone()];
2140
2141        copy_to_dir(
2142            "Blank.esp",
2143            &other_dir,
2144            "external.esp.ghost",
2145            GameId::OpenMW,
2146        );
2147
2148        let plugin_path = settings.plugin_path(plugin_name);
2149
2150        assert_eq!(
2151            game_path.join("resources/vfs").join(plugin_name),
2152            plugin_path
2153        );
2154    }
2155
2156    #[test]
2157    fn plugin_path_should_return_the_last_directory_that_contains_a_file_for_openmw() {
2158        let tmp_dir = tempdir().unwrap();
2159        let other_dir_1 = tmp_dir.path().join("other1");
2160        let other_dir_2 = tmp_dir.path().join("other2");
2161
2162        let plugin_name = "Blank.esp";
2163
2164        let mut settings = game_with_game_path(GameId::OpenMW, tmp_dir.path());
2165        settings.additional_plugins_directories = vec![other_dir_1.clone(), other_dir_2.clone()];
2166
2167        copy_to_dir("Blank.esp", &other_dir_1, plugin_name, GameId::OpenMW);
2168        copy_to_dir("Blank.esp", &other_dir_2, plugin_name, GameId::OpenMW);
2169
2170        let plugin_path = settings.plugin_path(plugin_name);
2171
2172        assert_eq!(other_dir_2.join(plugin_name), plugin_path);
2173    }
2174
2175    #[test]
2176    fn plugin_path_should_return_plugins_dir_subpath_if_name_does_not_match_any_external_plugin() {
2177        let settings = game_with_generic_paths(GameId::Fallout4);
2178
2179        let plugin_name = "DLCCoast.esm";
2180        assert_eq!(
2181            settings.plugins_directory().join(plugin_name),
2182            settings.plugin_path(plugin_name)
2183        );
2184    }
2185
2186    #[test]
2187    fn plugin_path_should_only_resolve_additional_starfield_plugin_paths_if_they_exist_or_are_ghosted_in_the_plugins_directory(
2188    ) {
2189        let tmp_dir = tempdir().unwrap();
2190        let game_path = tmp_dir.path().join("game");
2191        let data_path = game_path.join("Data");
2192        let other_dir = tmp_dir.path().join("other");
2193
2194        let plugin_name_1 = "external1.esp";
2195        let plugin_name_2 = "external2.esp";
2196        let plugin_name_3 = "external3.esp";
2197        let ghosted_plugin_name_3 = "external3.esp.ghost";
2198
2199        let mut settings = game_with_game_path(GameId::Starfield, &game_path);
2200        settings.additional_plugins_directories = vec![other_dir.clone()];
2201
2202        copy_to_dir("Blank.esp", &other_dir, plugin_name_1, GameId::Starfield);
2203        copy_to_dir("Blank.esp", &other_dir, plugin_name_2, GameId::Starfield);
2204        copy_to_dir("Blank.esp", &data_path, plugin_name_2, GameId::Starfield);
2205        copy_to_dir("Blank.esp", &other_dir, plugin_name_3, GameId::Starfield);
2206        copy_to_dir(
2207            "Blank.esp",
2208            &data_path,
2209            ghosted_plugin_name_3,
2210            GameId::Starfield,
2211        );
2212
2213        let plugin_1_path = settings.plugin_path(plugin_name_1);
2214        let plugin_2_path = settings.plugin_path(plugin_name_2);
2215        let plugin_3_path = settings.plugin_path(plugin_name_3);
2216
2217        assert_eq!(data_path.join(plugin_name_1), plugin_1_path);
2218        assert_eq!(other_dir.join(plugin_name_2), plugin_2_path);
2219        assert_eq!(other_dir.join(plugin_name_3), plugin_3_path);
2220    }
2221
2222    #[test]
2223    fn refresh_implicitly_active_plugins_should_update_early_loading_and_implicitly_active_plugins()
2224    {
2225        let tmp_dir = tempdir().unwrap();
2226        let game_path = tmp_dir.path();
2227
2228        let mut settings = GameSettings::with_local_and_my_games_paths(
2229            GameId::SkyrimSE,
2230            game_path,
2231            &PathBuf::default(),
2232            game_path.to_path_buf(),
2233        )
2234        .unwrap();
2235
2236        let hardcoded_plugins = vec![
2237            "Skyrim.esm",
2238            "Update.esm",
2239            "Dawnguard.esm",
2240            "HearthFires.esm",
2241            "Dragonborn.esm",
2242        ];
2243        assert_eq!(hardcoded_plugins, settings.early_loading_plugins());
2244        assert_eq!(hardcoded_plugins, settings.implicitly_active_plugins());
2245
2246        std::fs::write(game_path.join("Skyrim.ccc"), "ccBGSSSE002-ExoticArrows.esl").unwrap();
2247        std::fs::write(
2248            game_path.join("Skyrim.ini"),
2249            "[General]\nsTestFile1=plugin.esp\n",
2250        )
2251        .unwrap();
2252
2253        settings.refresh_implicitly_active_plugins().unwrap();
2254
2255        let mut expected_plugins = hardcoded_plugins;
2256        expected_plugins.push("ccBGSSSE002-ExoticArrows.esl");
2257        assert_eq!(expected_plugins, settings.early_loading_plugins());
2258
2259        expected_plugins.push("plugin.esp");
2260        assert_eq!(expected_plugins, settings.implicitly_active_plugins());
2261    }
2262
2263    #[test]
2264    fn find_plugins_in_directories_should_sort_files_by_modification_timestamp() {
2265        let tmp_dir = tempdir().unwrap();
2266        let game_path = tmp_dir.path();
2267
2268        let plugin_names = [
2269            "Blank.esp",
2270            "Blank - Different.esp",
2271            "Blank - Master Dependent.esp",
2272            NON_ASCII,
2273        ];
2274
2275        copy_to_dir("Blank.esp", game_path, NON_ASCII, GameId::Oblivion);
2276
2277        for (i, plugin_name) in plugin_names.iter().enumerate() {
2278            let path = game_path.join(plugin_name);
2279            if !path.exists() {
2280                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Oblivion);
2281            }
2282            set_file_timestamps(&path, i.try_into().unwrap());
2283        }
2284
2285        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Oblivion);
2286
2287        let expected: Vec<_> = plugin_names.iter().map(|n| game_path.join(n)).collect();
2288
2289        assert_eq!(expected, result);
2290    }
2291
2292    #[test]
2293    fn find_plugins_in_directories_should_sort_files_by_descending_filename_if_timestamps_are_equal(
2294    ) {
2295        let tmp_dir = tempdir().unwrap();
2296        let game_path = tmp_dir.path();
2297
2298        let non_ascii = NON_ASCII;
2299        let plugin_names = [
2300            "Blank.esm",
2301            "Blank.esp",
2302            "Blank - Different.esp",
2303            "Blank - Master Dependent.esp",
2304            non_ascii,
2305        ];
2306
2307        copy_to_dir("Blank.esp", game_path, non_ascii, GameId::Oblivion);
2308
2309        for (i, plugin_name) in plugin_names.iter().enumerate() {
2310            let path = game_path.join(plugin_name);
2311            if !path.exists() {
2312                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Oblivion);
2313            }
2314            set_file_timestamps(&path, i.try_into().unwrap());
2315        }
2316
2317        let timestamp = 3;
2318        set_file_timestamps(&game_path.join("Blank - Different.esp"), timestamp);
2319        set_file_timestamps(&game_path.join("Blank - Master Dependent.esp"), timestamp);
2320
2321        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Oblivion);
2322
2323        let plugin_paths = vec![
2324            game_path.join("Blank.esm"),
2325            game_path.join("Blank.esp"),
2326            game_path.join("Blank - Master Dependent.esp"),
2327            game_path.join("Blank - Different.esp"),
2328            game_path.join(non_ascii),
2329        ];
2330
2331        assert_eq!(plugin_paths, result);
2332    }
2333
2334    #[test]
2335    fn find_plugins_in_directories_should_sort_files_by_ascending_filename_if_timestamps_are_equal_and_game_is_starfield(
2336    ) {
2337        let tmp_dir = tempdir().unwrap();
2338        let game_path = tmp_dir.path();
2339
2340        let plugin_names = [
2341            "Blank.full.esm",
2342            "Blank.small.esm",
2343            "Blank.medium.esm",
2344            "Blank.esp",
2345            "Blank - Override.esp",
2346        ];
2347
2348        let timestamp = 1_321_009_991;
2349
2350        for plugin_name in &plugin_names {
2351            let path = game_path.join(plugin_name);
2352            if !path.exists() {
2353                copy_to_dir(plugin_name, game_path, plugin_name, GameId::Starfield);
2354            }
2355            set_file_timestamps(&path, timestamp);
2356        }
2357
2358        let result = find_plugins_in_directories(once(&game_path.to_path_buf()), GameId::Starfield);
2359
2360        let plugin_paths = vec![
2361            game_path.join("Blank - Override.esp"),
2362            game_path.join("Blank.esp"),
2363            game_path.join("Blank.full.esm"),
2364            game_path.join("Blank.medium.esm"),
2365            game_path.join("Blank.small.esm"),
2366        ];
2367
2368        assert_eq!(plugin_paths, result);
2369    }
2370}