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}