1#![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
24use std::collections::BTreeSet;
26
27use banished_formats::pkg::{normalise, Package};
28
29pub mod preview;
30pub mod text;
31
32pub trait Settings {
36 fn get_bool(&self, section: &str, name: &str, value: &mut bool);
38 fn get_int(&self, section: &str, name: &str, value: &mut i32);
40 fn set_bool(&mut self, section: &str, name: &str, value: bool);
42 fn set_int(&mut self, section: &str, name: &str, value: i32);
44}
45
46#[derive(Clone)]
48pub struct Mod {
49 pub file: String,
51 pub name: String,
53 pub author: String,
55 pub description: String,
57 pub game_version: u32,
60 pub user_version: u32,
62 pub crc: u32,
64 pub icon: (u32, u32, Vec<u8>),
66 pub files: BTreeSet<String>,
69 pub names: Vec<String>,
71 pub enabled: bool,
74 pub index: i32,
76 pub conflicts: Vec<String>,
79 pub bytes: std::rc::Rc<Vec<u8>>,
81}
82
83impl Mod {
84 pub fn path(&self) -> String {
87 format!("WinData\\{}.pkm", self.file)
88 }
89}
90
91#[derive(Default, Clone)]
93pub struct Modification {
94 pub mods: Vec<Mod>,
96 pub dirty: bool,
98}
99
100impl Modification {
101 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 fn order(&mut self) {
150 self.mods.sort_by_key(|m| m.index);
151 self.renumber();
152 self.find_conflicts();
153 }
154
155 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 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 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 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 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 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 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 pub fn load_order(&self) -> Vec<&Mod> {
249 self.mods.iter().rev().filter(|m| m.enabled).collect()
250 }
251
252 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 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 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#[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 #[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}