Skip to main content

modde_games/
policies.rs

1//! Reusable, data-driven policies that let games describe their content
2//! classification, archive layout, DLL overrides, mod directory, and collision
3//! rules declaratively instead of hand-writing per-game scanning logic.
4
5use std::path::{Path, PathBuf};
6
7use smallvec::SmallVec;
8
9use modde_core::collision::{CollisionClassifier, CollisionSeverity};
10
11use crate::traits::{ContentCategory, ModSafety};
12
13/// Extension and directory rules used to classify installed mod content.
14#[derive(Debug, Clone, Copy)]
15pub struct ContentPolicy {
16    pub save_breaking_ext: &'static [&'static str],
17    pub cosmetic_ext: &'static [&'static str],
18    pub save_breaking_dirs: &'static [&'static str],
19    pub categories: &'static [(&'static str, ContentCategory)],
20}
21
22impl ContentPolicy {
23    /// Map a file extension (lowercase, no dot) to its [`ContentCategory`].
24    #[must_use]
25    pub fn classify_extension(self, ext: &str) -> ContentCategory {
26        self.categories
27            .iter()
28            .find_map(|(candidate, category)| (*candidate == ext).then_some(*category))
29            .unwrap_or(ContentCategory::Other)
30    }
31
32    /// Walk a mod directory and classify it as save-breaking, save-safe, or unknown.
33    #[must_use]
34    pub fn classify_mod(self, mod_dir: &Path) -> ModSafety {
35        if !mod_dir.exists() {
36            return ModSafety::Unknown;
37        }
38
39        let mut has_any_file = false;
40        let mut has_cosmetic = false;
41        let mut stack = vec![mod_dir.to_path_buf()];
42
43        while let Some(dir) = stack.pop() {
44            let entries = match std::fs::read_dir(&dir) {
45                Ok(entries) => entries,
46                Err(_) => continue,
47            };
48
49            for entry in entries.flatten() {
50                let path = entry.path();
51                if path.is_dir() {
52                    if !self.save_breaking_dirs.is_empty() {
53                        let rel = path.strip_prefix(mod_dir).unwrap_or(&path);
54                        let rel = rel.to_string_lossy().to_lowercase().replace('\\', "/");
55                        if self
56                            .save_breaking_dirs
57                            .iter()
58                            .any(|pattern| rel.contains(pattern))
59                        {
60                            return ModSafety::SaveBreaking;
61                        }
62                    }
63                    stack.push(path);
64                    continue;
65                }
66
67                has_any_file = true;
68                let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
69                    continue;
70                };
71                let ext = ext.to_lowercase();
72                if self.save_breaking_ext.contains(&ext.as_str()) {
73                    return ModSafety::SaveBreaking;
74                }
75                if self.cosmetic_ext.contains(&ext.as_str()) {
76                    has_cosmetic = true;
77                }
78            }
79        }
80
81        if has_cosmetic && has_any_file {
82            ModSafety::SaveSafe
83        } else {
84            ModSafety::Unknown
85        }
86    }
87}
88
89/// Recognizes a "bare" mod layout — files/dirs that belong directly at the mod
90/// root (e.g. a `meshes/`, `textures/` folder or a loose `.esp`) rather than
91/// being wrapped in an extra top-level directory.
92#[derive(Debug, Clone, Copy)]
93pub struct BareLayoutPolicy {
94    pub root_dirs: &'static [&'static str],
95    pub root_file_exts: &'static [&'static str],
96    pub case_insensitive_dirs: bool,
97}
98
99impl BareLayoutPolicy {
100    /// Returns `true` if `extracted_dir` directly contains a recognised root
101    /// directory or file extension (i.e. it is already a bare mod layout).
102    #[must_use]
103    pub fn recognizes(self, extracted_dir: &Path) -> bool {
104        let Ok(entries) = std::fs::read_dir(extracted_dir) else {
105            return false;
106        };
107
108        for entry in entries.flatten() {
109            let path = entry.path();
110            if path.is_dir() {
111                let name = entry.file_name().to_string_lossy().to_string();
112                if self.case_insensitive_dirs {
113                    let name = name.to_lowercase();
114                    if self.root_dirs.iter().any(|candidate| *candidate == name) {
115                        return true;
116                    }
117                } else if self.root_dirs.iter().any(|candidate| *candidate == name) {
118                    return true;
119                }
120            } else if path.is_file()
121                && let Some(ext) = path.extension().and_then(|e| e.to_str())
122            {
123                let ext = ext.to_lowercase();
124                if self.root_file_exts.contains(&ext.as_str()) {
125                    return true;
126                }
127            }
128        }
129
130        false
131    }
132}
133
134/// Strategy for locating proxy DLLs within a staging tree.
135#[derive(Debug, Clone, Copy)]
136pub enum StagingDllSearch {
137    /// Look one level deep, inside each direct child directory.
138    DirectChildDirs,
139    /// Look inside each `mods/<mod>/bin/x64` subtree.
140    NestedModsBinX64,
141}
142
143/// Identifies which proxy/loader DLLs (e.g. `dxgi`, `winmm`) a game install
144/// uses so they can be registered as Wine DLL overrides.
145#[derive(Debug, Clone, Copy)]
146pub struct DllOverridePolicy {
147    pub proxy_dlls: &'static [&'static str],
148    pub staging_search: StagingDllSearch,
149}
150
151impl DllOverridePolicy {
152    /// Detect proxy DLLs present directly in the game executable directory.
153    #[must_use]
154    pub fn from_executable_dir(self, executable_dir: &Path) -> SmallVec<[String; 4]> {
155        let mut out = SmallVec::new();
156        for &name in self.proxy_dlls {
157            if executable_dir.join(format!("{name}.dll")).exists() {
158                out.push(name.to_string());
159            }
160        }
161        out
162    }
163
164    /// Detect proxy DLLs within a staging tree using the configured [`StagingDllSearch`].
165    #[must_use]
166    pub fn from_staging(self, staging: &Path) -> SmallVec<[String; 4]> {
167        match self.staging_search {
168            StagingDllSearch::DirectChildDirs => self.scan_direct_child_dirs(staging),
169            StagingDllSearch::NestedModsBinX64 => self.scan_nested_mods_bin_x64(staging),
170        }
171    }
172
173    fn push_dll_name(self, out: &mut SmallVec<[String; 4]>, file_name: &std::ffi::OsStr) {
174        let name = file_name.to_string_lossy().to_lowercase();
175        if let Some(stem) = name.strip_suffix(".dll")
176            && self.proxy_dlls.contains(&stem)
177            && !out.iter().any(|item| item == stem)
178        {
179            out.push(stem.to_string());
180        }
181    }
182
183    fn scan_direct_child_dirs(self, staging: &Path) -> SmallVec<[String; 4]> {
184        let mut out = SmallVec::new();
185        let Ok(entries) = std::fs::read_dir(staging) else {
186            return out;
187        };
188        for entry in entries.flatten() {
189            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
190                continue;
191            }
192            let Ok(inner) = std::fs::read_dir(entry.path()) else {
193                continue;
194            };
195            for file in inner.flatten() {
196                self.push_dll_name(&mut out, &file.file_name());
197            }
198        }
199        out
200    }
201
202    fn scan_nested_mods_bin_x64(self, staging: &Path) -> SmallVec<[String; 4]> {
203        let mut out = SmallVec::new();
204        let mods_dir = staging.join("mods");
205        if !mods_dir.is_dir() {
206            return out;
207        }
208
209        for entry in std::fs::read_dir(&mods_dir).into_iter().flatten().flatten() {
210            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
211                continue;
212            }
213            let mod_bin_x64 = entry.path().join("bin/x64");
214            if !mod_bin_x64.is_dir() {
215                continue;
216            }
217            for file in std::fs::read_dir(&mod_bin_x64)
218                .into_iter()
219                .flatten()
220                .flatten()
221            {
222                self.push_dll_name(&mut out, &file.file_name());
223            }
224        }
225
226        out
227    }
228}
229
230/// Describes where a game expects deployed mods to live, relative to its install dir.
231#[derive(Debug, Clone, Copy)]
232pub enum ModDirectoryLayout {
233    /// A path relative to the install root.
234    Relative(&'static str),
235    /// The Unreal Engine 4 `<Project>/Content/Paks/~mods` convention.
236    Ue4PaksMods { project_name: &'static str },
237}
238
239impl ModDirectoryLayout {
240    /// Resolve the mod directory against a concrete `install` path.
241    #[must_use]
242    pub fn resolve(self, install: &Path) -> PathBuf {
243        match self {
244            Self::Relative(path) => install.join(path),
245            Self::Ue4PaksMods { project_name } => install
246                .join(project_name)
247                .join("Content")
248                .join("Paks")
249                .join("~mods"),
250        }
251    }
252}
253
254/// Maps file extensions to a [`CollisionSeverity`] so file conflicts between
255/// mods can be ranked (e.g. plugin overwrites are worse than loose-texture overwrites).
256#[derive(Debug, Clone, Copy)]
257pub struct CollisionPolicy {
258    pub archive_extensions: &'static [&'static str],
259    pub severities: &'static [(&'static str, CollisionSeverity)],
260}
261
262impl CollisionPolicy {
263    /// Classify the collision severity of `file_path` from its extension.
264    #[must_use]
265    pub fn classify_severity(self, file_path: &str) -> CollisionSeverity {
266        let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
267        self.severities
268            .iter()
269            .find_map(|(candidate, severity)| (*candidate == ext).then_some(*severity))
270            .unwrap_or(CollisionSeverity::Unknown)
271    }
272}
273
274/// Adapts a [`CollisionPolicy`] into a `modde_core` `CollisionClassifier`,
275/// letting policy-driven games plug into the shared collision engine.
276#[derive(Debug, Clone, Copy)]
277pub struct PolicyCollisionClassifier {
278    pub policy: CollisionPolicy,
279}
280
281impl CollisionClassifier for PolicyCollisionClassifier {
282    fn index_archive(&self, _archive_path: &Path) -> anyhow::Result<Vec<(String, u64)>> {
283        Ok(Vec::new())
284    }
285
286    fn classify_severity(&self, file_path: &str) -> CollisionSeverity {
287        self.policy.classify_severity(file_path)
288    }
289
290    fn archive_extensions(&self) -> &[&str] {
291        self.policy.archive_extensions
292    }
293}