Skip to main content

banished_mods/
lib.rs

1//! The mod list Un-Banished shares between the game and the mod manager: the `.pkm` packages in the
2//! pack directory, which are enabled and in what order, and their conflicts. It is the port of
3//! `IO::Modification` (Runtime), moved out of the game so that both read and write the same
4//! settings in the same way.
5//!
6//! - **The list** (`RefreshList`): every `*.pkm` the pack directory holds
7//!   (`FileSystem::FindFiles`), read with `PackageFile::ReadPackfile`: its name, author,
8//!   description, icon, preview and files. A mod is known by its file's name without the extension;
9//!   the settings keep whether it is enabled (`ModEnabled_<file>`) and its place in the list
10//!   (`ModIndex_<file>`). Each mod lists the others that hold one of its files (its conflicts).
11//! - **Saving** (`SaveList`) writes both settings for every mod.
12//!
13//! Inferred: the list is ordered by the saved index, a mod without one after the others in the
14//! directory's order.
15//!
16//! [`text`] is a settings store for the settings file, for tools that run without the game.
17//!
18//! Original: Runtime `0x1800b0d40`, Runtime `0x1800b2e50`.
19#![doc(
20    html_logo_url = "https://tanepiper.github.io/unbanished/assets/un-banished-icon.svg",
21    html_favicon_url = "https://tanepiper.github.io/unbanished/favicon.svg"
22)]
23
24// The conflicts list is at Mod +0x70.
25use std::collections::BTreeSet;
26
27use banished_formats::pkg::{normalise, Package};
28
29pub mod preview;
30pub mod text;
31
32/// Where the mod list reads and writes its settings: the game's `Persistence`, or a
33/// [`text::TextSettings`]. Names are made from a section and a name (`ModEnabled_` and the
34/// file), as `IO::Persistence` does.
35pub trait Settings {
36    /// Unchanged when absent; `"true"` is true, anything else false.
37    fn get_bool(&self, section: &str, name: &str, value: &mut bool);
38    /// Unchanged when absent or not a number.
39    fn get_int(&self, section: &str, name: &str, value: &mut i32);
40    /// Store a bool as `true` or `false`.
41    fn set_bool(&mut self, section: &str, name: &str, value: bool);
42    /// Store a number.
43    fn set_int(&mut self, section: &str, name: &str, value: i32);
44}
45
46/// One mod (`Modification::Mod`, 0x80 bytes).
47#[derive(Clone)]
48pub struct Mod {
49    /// The package's file name without `.pkm` (`GetPathFilename`).
50    pub file: String,
51    /// The name the mod's author gave it.
52    pub name: String,
53    /// The mod's author.
54    pub author: String,
55    /// The mod's description.
56    pub description: String,
57    // +0x18; +0x1c
58    /// The game version it was made with and the author's version.
59    pub game_version: u32,
60    /// The author's own version number.
61    pub user_version: u32,
62    /// The package's checksum, which saves record.
63    pub crc: u32,
64    /// The 48×48 icon (`0xAARRGGBB`).
65    pub icon: (u32, u32, Vec<u8>),
66    // +0x60
67    /// Its files, normalised, and as the package names them, in that order.
68    pub files: BTreeSet<String>,
69    /// Its files as the package names them, sorted, for the details.
70    pub names: Vec<String>,
71    // +0x50; +0x20
72    /// Whether it is enabled.
73    pub enabled: bool,
74    /// Its place in the list.
75    pub index: i32,
76    // +0x70
77    /// The other mods sharing a file, by file name.
78    pub conflicts: Vec<String>,
79    /// The package's bytes.
80    pub bytes: std::rc::Rc<Vec<u8>>,
81}
82
83impl Mod {
84    /// `+0x58`: where it is, under the data directory (`WinData\<file>.pkm`). Deviation: the
85    /// original shows the full path.
86    pub fn path(&self) -> String {
87        format!("WinData\\{}.pkm", self.file)
88    }
89}
90
91/// The mod list: every package found, in list order.
92#[derive(Default, Clone)]
93pub struct Modification {
94    /// The mods, the first winning over the others when they share a file.
95    pub mods: Vec<Mod>,
96    /// `+0`: the list changed (Enable, Swap) since the packages were set up.
97    pub dirty: bool,
98}
99
100impl Modification {
101    /// `RefreshList` over the packages found: `(file name without .pkm, bytes)`.
102    pub fn refresh(found: Vec<(String, Vec<u8>)>, settings: &impl Settings) -> Modification {
103        let mut mods = Vec::new();
104        for (order, (file, bytes)) in found.into_iter().enumerate() {
105            let package = match Package::parse(bytes.clone()) {
106                Ok(p) => p,
107                Err(e) => {
108                    log::warn!("{file}.pkm: {e}");
109                    continue;
110                }
111            };
112            let h = &package.header;
113            if h.skip != 0 {
114                continue;
115            }
116            let mut enabled = false;
117            settings.get_bool("ModEnabled_", &file, &mut enabled);
118            let mut index = i32::MAX / 2 + order as i32;
119            settings.get_int("ModIndex_", &file, &mut index);
120            mods.push(Mod {
121                name: h.name.clone(),
122                author: h.author.clone(),
123                description: h.description.clone(),
124                game_version: h.game_version,
125                user_version: h.user_version,
126                crc: h.crc,
127                icon: h.icon.clone(),
128                files: package.entries.iter().map(|e| normalise(&e.name)).collect(),
129                names: {
130                    let mut v: Vec<String> =
131                        package.entries.iter().map(|e| e.name.clone()).collect();
132                    v.sort_by_key(|n| normalise(n));
133                    v.dedup_by_key(|n| normalise(n));
134                    v
135                },
136                enabled,
137                index,
138                conflicts: Vec::new(),
139                bytes: std::rc::Rc::new(bytes),
140                file,
141            });
142        }
143        let mut list = Modification { mods, dirty: false };
144        list.order();
145        list
146    }
147
148    /// By the saved index, renumbered, with each mod's conflicts.
149    fn order(&mut self) {
150        self.mods.sort_by_key(|m| m.index);
151        self.renumber();
152        self.find_conflicts();
153    }
154
155    /// Mods added while the game runs (dropped in the browser, or installed from Nexus Mods):
156    /// the list is read again with them, replacing any of the same name. They take effect
157    /// after a reload; the list is marked changed when an enabled mod was replaced or the
158    /// enabled set changed.
159    pub fn add(&mut self, found: Vec<(String, Vec<u8>)>, settings: &impl Settings) {
160        if found.is_empty() {
161            return;
162        }
163        let names: Vec<String> = found.iter().map(|(n, _)| n.clone()).collect();
164        let mut all: Vec<(String, Vec<u8>)> = self
165            .mods
166            .iter()
167            .filter(|m| !names.contains(&m.file))
168            .map(|m| (m.file.clone(), m.bytes.as_ref().clone()))
169            .collect();
170        all.extend(found);
171        let before = self.enabled_files();
172        let dirty = self.dirty
173            || self
174                .mods
175                .iter()
176                .any(|m| m.enabled && names.contains(&m.file));
177        *self = Modification::refresh(all, settings);
178        self.dirty = dirty || self.enabled_files() != before;
179    }
180
181    /// `RefreshList` over the packages already read: the settings' order and enabled mods
182    /// again (the Mods dialog's Cancel). The list stays dirty.
183    pub fn revert(&mut self, settings: &impl Settings) {
184        for (order, m) in self.mods.iter_mut().enumerate() {
185            m.enabled = false;
186            settings.get_bool("ModEnabled_", &m.file, &mut m.enabled);
187            m.index = i32::MAX / 2 + order as i32;
188            settings.get_int("ModIndex_", &m.file, &mut m.index);
189        }
190        self.order();
191    }
192
193    fn renumber(&mut self) {
194        for (i, m) in self.mods.iter_mut().enumerate() {
195            m.index = i as i32;
196        }
197    }
198
199    /// Each mod's conflicts: the other mods with a file in common.
200    fn find_conflicts(&mut self) {
201        let n = self.mods.len();
202        for i in 0..n {
203            let conflicts = (0..n)
204                .filter(|&j| j != i && !self.mods[i].files.is_disjoint(&self.mods[j].files))
205                .map(|j| self.mods[j].file.clone())
206                .collect();
207            self.mods[i].conflicts = conflicts;
208        }
209    }
210
211    /// `SaveList`.
212    pub fn save_list(&self, settings: &mut impl Settings) {
213        for m in &self.mods {
214            settings.set_bool("ModEnabled_", &m.file, m.enabled);
215            settings.set_int("ModIndex_", &m.file, m.index);
216        }
217    }
218
219    /// `Enable`.
220    pub fn enable(&mut self, i: usize, on: bool) {
221        self.dirty = true;
222        if let Some(m) = self.mods.get_mut(i) {
223            m.enabled = on;
224        }
225    }
226
227    /// `Swap`: two places in the list.
228    pub fn swap(&mut self, a: usize, b: usize) {
229        if a != b && a < self.mods.len() && b < self.mods.len() {
230            self.dirty = true;
231            self.mods.swap(a, b);
232            self.renumber();
233        }
234    }
235
236    /// Whether mod `i` is enabled with another enabled mod sharing a file.
237    pub fn in_conflict(&self, i: usize) -> bool {
238        let Some(m) = self.mods.get(i).filter(|m| m.enabled) else {
239            return false;
240        };
241        m.conflicts
242            .iter()
243            .any(|f| self.mods.iter().any(|o| o.enabled && &o.file == f))
244    }
245
246    /// The enabled mods in the order `SetupFileSystem` adds them: from the last in the list to
247    /// the first.
248    pub fn load_order(&self) -> Vec<&Mod> {
249        self.mods.iter().rev().filter(|m| m.enabled).collect()
250    }
251
252    // +0x2c; +0x18; +0x1c
253    /// `SaveLoad::SaveMetaData`: each enabled mod as the save records it, its name, its file under
254    /// the data directory (`WinData\<file>.pkm`, inferred from `GetPackDirectory`) and its crc,
255    /// game version and author's version.
256    pub fn saved(&self) -> Vec<banished_formats::save::SavedMod> {
257        self.mods
258            .iter()
259            .filter(|m| m.enabled)
260            .map(|m| banished_formats::save::SavedMod {
261                name: m.name.clone(),
262                file: m.path(),
263                values: [m.crc, m.game_version, m.user_version],
264            })
265            .collect()
266    }
267
268    /// 0 when the saved mod is installed as it was, 1 when it is not installed (by its file,
269    /// without case), 4 when its crc differs, 3 when the author's version differs and 2 when it was
270    /// made before game version 1.0.4.
271    ///
272    /// Original: `SaveLoad::CheckMod`, Runtime `0x1800c3900`.
273    pub fn check(&self, saved: &banished_formats::save::SavedMod) -> u32 {
274        let stem = saved
275            .file
276            .rsplit(['\\', '/'])
277            .next()
278            .unwrap_or("")
279            .trim_end_matches(".pkm")
280            .trim_end_matches(".PKM");
281        let Some(m) = self.mods.iter().find(|m| m.file.eq_ignore_ascii_case(stem)) else {
282            return 1;
283        };
284        let [crc, game_version, user_version] = saved.values;
285        if m.crc != crc {
286            4
287        } else if m.user_version != user_version {
288            3
289        } else if game_version < 0x0100_0400 {
290            2
291        } else {
292            0
293        }
294    }
295
296    /// The enabled mods' file names, in list order (their resource lists load in this order).
297    pub fn enabled_files(&self) -> Vec<String> {
298        self.mods
299            .iter()
300            .filter(|m| m.enabled)
301            .map(|m| m.file.clone())
302            .collect()
303    }
304}
305
306/// `FileSystem::FindFiles(GetPackDirectory(), "*.pkm")`: the packages in the folder.
307#[cfg(not(target_arch = "wasm32"))]
308pub fn find(dir: &std::path::Path) -> Vec<(String, Vec<u8>)> {
309    let Ok(read) = std::fs::read_dir(dir) else {
310        return Vec::new();
311    };
312    let mut paths: Vec<std::path::PathBuf> = read
313        .flatten()
314        .map(|e| e.path())
315        .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("pkm")))
316        .collect();
317    paths.sort();
318    paths
319        .into_iter()
320        .filter_map(|p| {
321            let stem = p.file_stem()?.to_str()?.to_string();
322            match std::fs::read(&p) {
323                Ok(b) => Some((stem, b)),
324                Err(e) => {
325                    log::warn!("{}: {e}", p.display());
326                    None
327                }
328            }
329        })
330        .collect()
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn pkm(name: &str) -> Option<(String, Vec<u8>)> {
338        let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
339        let b = std::fs::read(root.join("game-data/mods/downloads").join(name)).ok()?;
340        Some((name.trim_end_matches(".pkm").to_string(), b))
341    }
342
343    /// The settings decide what is enabled and the order; the list saves back the same way.
344    #[test]
345    fn the_list_follows_the_settings() {
346        let (Some(a), Some(b)) = (pkm("apiary.pkm"), pkm("StorageCart.pkm")) else {
347            return;
348        };
349        let mut s = text::TextSettings::default();
350        s.set_bool("ModEnabled_", "apiary", true);
351        s.set_int("ModIndex_", "apiary", 1);
352        s.set_int("ModIndex_", "StorageCart", 0);
353        let mut list = Modification::refresh(vec![a, b], &s);
354        assert_eq!(list.mods[0].file, "StorageCart");
355        assert_eq!(list.mods[1].name, "Sample - Apiary");
356        assert!(list.mods[1].enabled && !list.mods[0].enabled);
357        assert_eq!(list.enabled_files(), vec!["apiary".to_string()]);
358        list.swap(0, 1);
359        let mut saved = text::TextSettings::default();
360        list.save_list(&mut saved);
361        let mut i = -1;
362        saved.get_int("ModIndex_", "apiary", &mut i);
363        assert_eq!(i, 0);
364    }
365}