Skip to main content

modde_games/witcher3/
mod.rs

1//! The Witcher 3 game plugin: `REDkit` mod layout, script-conflict detection,
2//! and save/scanner wiring.
3
4pub mod saves;
5pub mod scanner;
6
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use modde_core::installer::InstallMethod;
11
12use crate::policies::{BareLayoutPolicy, ContentPolicy, DllOverridePolicy, StagingDllSearch};
13use crate::traits::{ContentCategory, GamePlugin, ModSafety};
14
15/// [`GamePlugin`] implementation for The Witcher 3: Wild Hunt.
16pub struct Witcher3Game;
17
18pub static WITCHER3: Witcher3Game = Witcher3Game;
19
20const WITCHER_SAVE_BREAKING_EXT: &[&str] = &["ws", "xml", "bundle", "cache", "csv", "dll"];
21const WITCHER_COSMETIC_EXT: &[&str] = &["dds", "png", "jpg", "xbm"];
22const WITCHER_CONTENT_CATEGORIES: &[(&str, ContentCategory)] = &[
23    ("ws", ContentCategory::Script),
24    ("xml", ContentCategory::Config),
25    ("csv", ContentCategory::Config),
26    ("bundle", ContentCategory::Archive),
27    ("cache", ContentCategory::Archive),
28    ("dds", ContentCategory::Texture),
29    ("png", ContentCategory::Texture),
30    ("jpg", ContentCategory::Texture),
31    ("xbm", ContentCategory::Texture),
32    ("dll", ContentCategory::Binary),
33];
34
35const WITCHER_CONTENT_POLICY: ContentPolicy = ContentPolicy {
36    save_breaking_ext: WITCHER_SAVE_BREAKING_EXT,
37    cosmetic_ext: WITCHER_COSMETIC_EXT,
38    save_breaking_dirs: &["content/scripts"],
39    categories: WITCHER_CONTENT_CATEGORIES,
40};
41
42const WITCHER_BARE_LAYOUT_POLICY: BareLayoutPolicy = BareLayoutPolicy {
43    root_dirs: &["mods", "dlc", "bin", "content"],
44    root_file_exts: &["ws", "bundle", "xml"],
45    case_insensitive_dirs: true,
46};
47
48const WITCHER_PROXY_DLLS: &[&str] = &["dxgi", "d3d11", "version", "winmm"];
49const WITCHER_DLL_POLICY: DllOverridePolicy = DllOverridePolicy {
50    proxy_dlls: WITCHER_PROXY_DLLS,
51    staging_search: StagingDllSearch::DirectChildDirs,
52};
53
54/// Returns `true` if a mod directory contains any `.ws` script files, which
55/// can conflict with other mods' scripts.
56#[must_use]
57pub fn has_script_conflict(mod_dir: &Path) -> bool {
58    let mut stack = vec![mod_dir.to_path_buf()];
59    while let Some(dir) = stack.pop() {
60        let Ok(entries) = std::fs::read_dir(&dir) else {
61            continue;
62        };
63        for entry in entries.flatten() {
64            let path = entry.path();
65            if path.is_dir() {
66                stack.push(path);
67                continue;
68            }
69            if path
70                .extension()
71                .and_then(|ext| ext.to_str())
72                .is_some_and(|ext| ext.eq_ignore_ascii_case("ws"))
73            {
74                return true;
75            }
76        }
77    }
78    false
79}
80
81/// List relative `.ws` script paths provided by more than one installed mod
82/// under `mods_root` (i.e. the scripts that actually collide).
83#[must_use]
84pub fn script_conflict_paths(mods_root: &Path) -> Vec<String> {
85    let mut providers: std::collections::BTreeMap<String, usize> =
86        std::collections::BTreeMap::new();
87    let Ok(mods) = std::fs::read_dir(mods_root) else {
88        return Vec::new();
89    };
90    for entry in mods.flatten() {
91        let mod_dir = entry.path();
92        if !mod_dir.is_dir() {
93            continue;
94        }
95        for rel in script_paths(&mod_dir) {
96            *providers.entry(rel).or_default() += 1;
97        }
98    }
99    providers
100        .into_iter()
101        .filter_map(|(path, count)| (count > 1).then_some(path))
102        .collect()
103}
104
105fn script_paths(mod_dir: &Path) -> Vec<String> {
106    let mut out = Vec::new();
107    let mut stack = vec![mod_dir.to_path_buf()];
108    while let Some(dir) = stack.pop() {
109        let Ok(entries) = std::fs::read_dir(&dir) else {
110            continue;
111        };
112        for entry in entries.flatten() {
113            let path = entry.path();
114            if path.is_dir() {
115                stack.push(path);
116                continue;
117            }
118            if path
119                .extension()
120                .and_then(|ext| ext.to_str())
121                .is_some_and(|ext| ext.eq_ignore_ascii_case("ws"))
122                && let Ok(rel) = path.strip_prefix(mod_dir)
123            {
124                out.push(rel.to_string_lossy().replace('\\', "/").to_lowercase());
125            }
126        }
127    }
128    out
129}
130
131impl GamePlugin for Witcher3Game {
132    fn game_id(&self) -> &'static str {
133        "witcher3"
134    }
135
136    fn display_name(&self) -> &'static str {
137        "The Witcher 3: Wild Hunt"
138    }
139
140    fn mod_directory(&self, install: &Path) -> PathBuf {
141        install.join("mods")
142    }
143
144    fn save_directory(&self) -> Option<PathBuf> {
145        Some(modde_core::paths::home_dir().join("Documents/The Witcher 3/gamesaves"))
146    }
147
148    fn supports_save_profiles(&self) -> bool {
149        true
150    }
151
152    fn classify_mod(&self, mod_dir: &Path) -> ModSafety {
153        WITCHER_CONTENT_POLICY.classify_mod(mod_dir)
154    }
155
156    fn classify_extension(&self, ext: &str) -> ContentCategory {
157        WITCHER_CONTENT_POLICY.classify_extension(ext)
158    }
159
160    fn executable_dir(&self, install: &Path) -> PathBuf {
161        install.join("bin/x64")
162    }
163
164    fn wine_dll_overrides(&self, game_dir: &Path) -> smallvec::SmallVec<[String; 4]> {
165        WITCHER_DLL_POLICY.from_executable_dir(&self.executable_dir(game_dir))
166    }
167
168    fn wine_dll_overrides_from_staging(&self, staging: &Path) -> smallvec::SmallVec<[String; 4]> {
169        WITCHER_DLL_POLICY.from_staging(staging)
170    }
171
172    fn deploy_to_install(&self, staging: &Path, install: &Path) -> Result<()> {
173        if ["mods", "dlc", "bin", "content"]
174            .iter()
175            .any(|root| staging.join(root).exists())
176        {
177            modde_core::fs::deploy_symlinks(staging, install)
178        } else {
179            let target = self.mod_root(install)?;
180            self.deploy(staging, &target)
181        }
182    }
183
184    fn archive_extensions(&self) -> &[&str] {
185        &["bundle", "cache"]
186    }
187
188    fn steam_app_id_u32(&self) -> Option<u32> {
189        Some(292030)
190    }
191
192    fn nexus_game_domain(&self) -> Option<&str> {
193        Some("witcher3")
194    }
195
196    fn analyze_mod_archive(&self, extracted_dir: &Path) -> Option<InstallMethod> {
197        let roots = present_roots(extracted_dir, &["mods", "dlc", "bin", "content"]);
198        if !roots.is_empty() {
199            return Some(InstallMethod::MultiRootOverlay { roots });
200        }
201
202        std::fs::read_dir(extracted_dir)
203            .ok()?
204            .flatten()
205            .find_map(|entry| {
206                let path = entry.path();
207                let name = entry.file_name().to_string_lossy().to_string();
208                (path.is_dir() && name.to_lowercase().starts_with("mod")).then_some({
209                    InstallMethod::DirectoryMod {
210                        directory_name: Some(name),
211                    }
212                })
213            })
214    }
215
216    fn recognizes_bare_layout(&self, extracted_dir: &Path) -> bool {
217        WITCHER_BARE_LAYOUT_POLICY.recognizes(extracted_dir)
218            || extracted_dir
219                .file_name()
220                .and_then(|name| name.to_str())
221                .is_some_and(|name| name.to_lowercase().starts_with("mod"))
222    }
223}
224
225fn present_roots(dir: &Path, roots: &[&str]) -> Vec<String> {
226    roots
227        .iter()
228        .filter(|root| dir.join(root).exists())
229        .map(|root| (*root).to_string())
230        .collect()
231}