1pub mod saves;
5pub mod scanner;
6
7use std::path::{Path, PathBuf};
8
9use modde_core::collision::CollisionSeverity;
10use modde_core::installer::InstallMethod;
11use modde_core::paths;
12use smallvec::SmallVec;
13
14use crate::optiscaler::OptiScalerProfile;
15use crate::policies::{CollisionPolicy, ContentPolicy, DllOverridePolicy, StagingDllSearch};
16use crate::traits::{ContentCategory, DeployTarget, DeployTargetKind, GamePlugin, ModSafety};
17
18pub struct Ue4Game {
27 game_id: &'static str,
28 display_name: &'static str,
29 steam_app_id: &'static str,
31 project_name: &'static str,
35 nexus_domain: Option<&'static str>,
37 save_profiles: bool,
40}
41
42impl Ue4Game {
43 #[must_use]
46 pub const fn new(
47 game_id: &'static str,
48 display_name: &'static str,
49 steam_app_id: &'static str,
50 project_name: &'static str,
51 nexus_domain: Option<&'static str>,
52 ) -> Self {
53 Self {
54 game_id,
55 display_name,
56 steam_app_id,
57 project_name,
58 nexus_domain,
59 save_profiles: false,
60 }
61 }
62
63 #[must_use]
68 pub const fn with_save_profiles(mut self, enabled: bool) -> Self {
69 self.save_profiles = enabled;
70 self
71 }
72
73 #[must_use]
75 pub fn paks_root(&self, install: &Path) -> PathBuf {
76 install.join(self.project_name).join("Content").join("Paks")
77 }
78
79 #[must_use]
81 pub fn project_name(&self) -> &'static str {
82 self.project_name
83 }
84}
85
86const UE4_PROXY_DLLS: &[&str] = &[
90 "dwmapi", "xinput1_3", "d3d11", "dxgi", "version", "winmm", "dinput8", ];
98
99const UE4_SAVE_BREAKING_EXT: &[&str] = &["pak", "ucas", "utoc", "dll", "lua"];
101
102const UE4_COSMETIC_EXT: &[&str] = &["png", "jpg", "dds", "tga", "ini"];
104
105const UE4_CONTENT_CATEGORIES: &[(&str, ContentCategory)] = &[
106 ("pak", ContentCategory::Archive),
107 ("ucas", ContentCategory::Archive),
108 ("utoc", ContentCategory::Archive),
109 ("dll", ContentCategory::Binary),
110 ("so", ContentCategory::Binary),
111 ("lua", ContentCategory::Script),
112 ("dds", ContentCategory::Texture),
113 ("png", ContentCategory::Texture),
114 ("tga", ContentCategory::Texture),
115 ("jpg", ContentCategory::Texture),
116 ("ini", ContentCategory::Config),
117 ("json", ContentCategory::Config),
118 ("yaml", ContentCategory::Config),
119 ("xml", ContentCategory::Config),
120 ("toml", ContentCategory::Config),
121];
122
123const UE4_CONTENT_POLICY: ContentPolicy = ContentPolicy {
124 save_breaking_ext: UE4_SAVE_BREAKING_EXT,
125 cosmetic_ext: UE4_COSMETIC_EXT,
126 save_breaking_dirs: &[],
127 categories: UE4_CONTENT_CATEGORIES,
128};
129
130const UE4_DLL_POLICY: DllOverridePolicy = DllOverridePolicy {
131 proxy_dlls: UE4_PROXY_DLLS,
132 staging_search: StagingDllSearch::DirectChildDirs,
133};
134
135pub(crate) const UE4_COLLISION_SEVERITIES: &[(&str, CollisionSeverity)] = &[
136 ("pak", CollisionSeverity::Dangerous),
137 ("ucas", CollisionSeverity::Dangerous),
138 ("utoc", CollisionSeverity::Dangerous),
139 ("dll", CollisionSeverity::Dangerous),
140 ("lua", CollisionSeverity::Dangerous),
141 ("ini", CollisionSeverity::Config),
142 ("cfg", CollisionSeverity::Config),
143 ("json", CollisionSeverity::Config),
144 ("toml", CollisionSeverity::Config),
145 ("xml", CollisionSeverity::Config),
146 ("yaml", CollisionSeverity::Config),
147 ("dds", CollisionSeverity::Cosmetic),
148 ("png", CollisionSeverity::Cosmetic),
149 ("jpg", CollisionSeverity::Cosmetic),
150 ("tga", CollisionSeverity::Cosmetic),
151];
152
153pub(crate) const UE4_COLLISION_POLICY: CollisionPolicy = CollisionPolicy {
154 archive_extensions: &["pak", "ucas", "utoc"],
155 severities: UE4_COLLISION_SEVERITIES,
156};
157
158pub const STELLAR_BLADE: Ue4Game = Ue4Game::new(
159 "stellar-blade",
160 "Stellar Blade",
161 "3489700",
162 "SB",
163 Some("stellarblade"),
164)
165.with_save_profiles(true);
166
167pub const SUBNAUTICA2: Ue4Game = Ue4Game::new(
168 "subnautica2",
169 "Subnautica 2",
170 "1962700",
171 "Subnautica2",
172 Some("subnautica2"),
173)
174.with_save_profiles(true);
175
176pub(crate) const STELLAR_BLADE_OPTISCALER_PROFILES: &[OptiScalerProfile] = &[OptiScalerProfile {
177 id: "community-dxgi",
178 name: "Community tested dxgi.dll",
179 source_url: "https://github.com/optiscaler/OptiScaler/wiki/Stellar-Blade",
180 tested_optiscaler_version: "0.9",
181 source_mode: Some("github_release"),
182 goverlay_channel: None,
183 proxy_dll: "dxgi.dll",
184 release_tag: Some("official:v0.9.1"),
185 release_asset: None,
186 wine_dll_overrides: &[],
187 copy_companion_files: true,
188 enable_optipatcher: true,
189 fsr4_variant: Some("latest_fp8"),
190 emulate_fp8: true,
191 spoof_dlss: false,
192 ini_overrides: &[],
193 notes: "Use OptiPatcher to unlock DLSS and DLSS-FG inputs without spoofing. The community compatibility notes report that the game may crash on first boot but work afterwards, and that setting the in-game sharpness slider to 0 can fix DLSSG HUD interpolation.",
194}];
195
196impl GamePlugin for Ue4Game {
197 fn game_id(&self) -> &str {
198 self.game_id
199 }
200
201 fn display_name(&self) -> &str {
202 self.display_name
203 }
204
205 fn mod_directory(&self, install: &Path) -> PathBuf {
210 self.paks_root(install).join("~mods")
211 }
212
213 fn save_directory(&self) -> Option<PathBuf> {
214 let compat = paths::steam_common()
215 .parent()?
216 .join("compatdata")
217 .join(self.steam_app_id)
218 .join("pfx/drive_c/users/steamuser/AppData/Local")
219 .join(self.project_name)
220 .join("Saved/SaveGames");
221 if compat.exists() {
222 return Some(compat);
223 }
224 None
225 }
226
227 fn supports_save_profiles(&self) -> bool {
228 self.save_profiles
229 }
230
231 fn deploy_targets(&self) -> &'static [DeployTarget] {
232 &[DeployTarget {
233 id: "ue4-saved-config",
234 label: "UE4 Saved/Config",
235 kind: DeployTargetKind::UserConfig,
236 }]
237 }
238
239 fn resolve_deploy_target(&self, id: &str, _install: &Path) -> Option<PathBuf> {
247 if id != "ue4-saved-config" {
248 return None;
249 }
250 let prefix = paths::steam_common()
251 .parent()?
252 .join("compatdata")
253 .join(self.steam_app_id)
254 .join("pfx");
255 if !prefix.exists() {
256 return None;
257 }
258 Some(
259 prefix
260 .join("drive_c/users/steamuser/AppData/Local")
261 .join(self.project_name)
262 .join("Saved/Config/Windows"),
263 )
264 }
265
266 fn executable_dir(&self, install: &Path) -> PathBuf {
267 install
268 .join(self.project_name)
269 .join("Binaries")
270 .join("Win64")
271 }
272
273 fn wine_dll_overrides(&self, game_dir: &Path) -> SmallVec<[String; 4]> {
274 UE4_DLL_POLICY.from_executable_dir(&self.executable_dir(game_dir))
275 }
276
277 fn wine_dll_overrides_from_staging(&self, staging: &Path) -> SmallVec<[String; 4]> {
278 UE4_DLL_POLICY.from_staging(staging)
279 }
280
281 fn classify_mod(&self, mod_dir: &Path) -> ModSafety {
282 UE4_CONTENT_POLICY.classify_mod(mod_dir)
283 }
284
285 fn classify_extension(&self, ext: &str) -> ContentCategory {
286 UE4_CONTENT_POLICY.classify_extension(ext)
287 }
288
289 fn archive_extensions(&self) -> &[&str] {
290 &["pak", "ucas", "utoc"]
291 }
292
293 fn steam_app_id_u32(&self) -> Option<u32> {
294 self.steam_app_id.parse().ok()
295 }
296
297 fn nexus_game_domain(&self) -> Option<&str> {
298 self.nexus_domain
299 }
300
301 fn analyze_mod_archive(&self, extracted_dir: &Path) -> Option<InstallMethod> {
302 has_root_file_with_ext(extracted_dir, &["pak", "ucas", "utoc"])
303 .then_some(InstallMethod::SingleFileSet)
304 }
305}
306
307fn has_root_file_with_ext(dir: &Path, extensions: &[&str]) -> bool {
308 std::fs::read_dir(dir).is_ok_and(|entries| {
309 entries.flatten().any(|entry| {
310 let path = entry.path();
311 path.is_file()
312 && path
313 .extension()
314 .and_then(|ext| ext.to_str())
315 .is_some_and(|ext| {
316 extensions
317 .iter()
318 .any(|candidate| ext.eq_ignore_ascii_case(candidate))
319 })
320 })
321 })
322}