Skip to main content

launcher/pages/mods/
tasks.rs

1use std::path::{Path, PathBuf};
2
3use supreme_fs::GameStorage;
4use supreme_hook_config::{
5    DEFAULT_MODS_DIR_NAME, HookConfigError, MOD_FILE_NAME, ModConfig, ModsPreset, load_mods_state,
6    save_mods_state,
7};
8
9use super::types::LoadedMod;
10
11pub(super) fn mods_dir(storage: &GameStorage) -> PathBuf {
12    storage.game_path().join(DEFAULT_MODS_DIR_NAME)
13}
14
15/// Scans `.supreme/mods/`, loads every mod's `mod.json`, and pairs each one
16/// with its current values from the active configuration in
17/// `mods.json`.
18///
19/// Ordering: [`ModsPreset::order`] (of the active configuration) is the
20/// single, stable source of list position, toggling a mod's enabled state
21/// never changes it. A mod folder found on disk but not yet in `order`
22/// (new install, or a mod a just-applied preset didn't mention, see
23/// [`supreme_hook_config::ModsState::apply_preset`]) is appended at the
24/// end, alphabetically among any other new-this-scan folders; a name in
25/// `order` no longer found on disk is dropped. The reconciled order is
26/// written straight back so `order` never drifts from what's actually on
27/// disk between scans. For the active configuration, display order and
28/// DLL merge priority are the same list, `order` filtered to what's
29/// enabled (see [`ModsPreset::priority_order`]), so there's nothing else
30/// to reconcile. A mod that fails to load or validate still gets a row,
31/// see [`LoadedMod::load_error`], rather than disappearing from the list.
32pub fn discover_mods(storage: &GameStorage) -> Vec<LoadedMod> {
33    let dir = mods_dir(storage);
34    let mut state = load_mods_state(&dir).unwrap_or_default();
35
36    let mut found_names: Vec<String> = std::fs::read_dir(&dir)
37        .ok()
38        .into_iter()
39        .flatten()
40        .flatten()
41        .filter(|entry| entry.path().is_dir())
42        .filter_map(|entry| entry.file_name().into_string().ok())
43        .collect();
44    found_names.sort();
45
46    state.sync_active_order(found_names);
47    let enabled_set: std::collections::HashSet<&str> =
48        state.active.enabled.iter().map(String::as_str).collect();
49
50    let mods: Vec<LoadedMod> = state
51        .active
52        .order
53        .iter()
54        .map(|name| {
55            let enabled = enabled_set.contains(name.as_str());
56            load_one_mod(&dir, name, enabled, &state.active)
57        })
58        .collect();
59
60    // Persist the reconciled order/enabled-set so a subsequent scan (or
61    // the DLL's own load) sees exactly what was just shown, even if this
62    // was the first time a new mod folder was ever seen.
63    if let Err(e) = save_mods_state(&dir, &state) {
64        eprintln!("Failed to save reconciled mods state: {e}");
65    }
66
67    mods
68}
69
70fn load_one_mod(mods_dir: &Path, dir_name: &str, enabled: bool, active: &ModsPreset) -> LoadedMod {
71    let path = mods_dir.join(dir_name).join(MOD_FILE_NAME);
72    let configuration = active
73        .configuration
74        .get(dir_name)
75        .cloned()
76        .unwrap_or_default();
77    match ModConfig::load_from_path_with_warnings(&path) {
78        Ok((config, warnings)) => {
79            let load_error = config.validate().err().map(|errors| errors.join("; "));
80            LoadedMod {
81                dir_name: dir_name.to_string(),
82                config,
83                configuration,
84                enabled,
85                load_error,
86                warnings,
87            }
88        }
89        Err(e) => LoadedMod {
90            dir_name: dir_name.to_string(),
91            // A placeholder so the row still has a name to display,
92            // `dir_name` is the best guess available since the real
93            // `mod.json` couldn't be read.
94            config: ModConfig {
95                name: dir_name.to_string(),
96                ..ModConfig::default()
97            },
98            configuration,
99            enabled,
100            load_error: Some(format!("Couldn't load mod.json: {e}")),
101            warnings: Vec::new(),
102        },
103    }
104}
105
106/// Persists the current display order and enabled set of `mods` to
107/// `mods.json`: `active.order` is set directly from `mods`' current
108/// list order, this is what makes reordering in the UI stick, and, since
109/// display order *is* DLL merge priority for the active configuration,
110/// what makes a drag-reorder change priority too. `active.enabled` is set
111/// to the checked subset (membership only; its effective order always
112/// comes from `active.order` via [`ModsPreset::priority_order`], never
113/// stored separately). A single call covers both a checkbox toggle and a
114/// drag-reorder, since `mods` already reflects whichever just happened.
115/// Called after every checkbox toggle or reorder.
116///
117/// # Errors
118///
119/// Returns an error if the mods directory can't be created or the file
120/// can't be written.
121pub fn save_enabled_order(
122    storage: &GameStorage,
123    mods: &[LoadedMod],
124) -> Result<(), HookConfigError> {
125    let dir = mods_dir(storage);
126    let mut state = load_mods_state(&dir).unwrap_or_default();
127
128    state.active.order = mods.iter().map(|m| m.dir_name.clone()).collect();
129    let enabled: Vec<&str> = mods
130        .iter()
131        .filter(|m| m.enabled)
132        .map(|m| m.dir_name.as_str())
133        .collect();
134    state.active.set_enabled(enabled);
135
136    save_mods_state(&dir, &state)
137}
138
139/// Persists every currently-loaded mod's in-memory `configuration`
140/// (overrides only, never a mod's `mod.json` defaults) into the active
141/// configuration in `mods.json`. Merges with whatever's already
142/// there rather than replacing it wholesale, so a mod not currently in
143/// `mods` (temporarily removed, or failed to load this session) keeps its
144/// saved overrides rather than losing them the next time anything else
145/// gets persisted. Never touches any mod's own `mod.json`, so an edit
146/// here can't clobber a mod author's rules/parameters/defaults, and
147/// replacing a mod's whole directory (e.g. an update) can't take a
148/// person's overrides for it along with it.
149///
150/// # Errors
151///
152/// Returns an error if the mods directory can't be created or the file
153/// can't be written.
154pub fn persist_mod_configurations(
155    storage: &GameStorage,
156    mods: &[LoadedMod],
157) -> Result<(), HookConfigError> {
158    let dir = mods_dir(storage);
159    let mut state = load_mods_state(&dir).unwrap_or_default();
160
161    for m in mods {
162        state
163            .active
164            .configuration
165            .insert(m.dir_name.clone(), m.configuration.clone());
166    }
167
168    save_mods_state(&dir, &state)
169}