modde_games/witcher3/
mod.rs1pub 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
15pub 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#[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#[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}