Skip to main content

modde_games/ue4/
mod.rs

1//! Data-driven support for Unreal Engine 4 games: a shared [`Ue4Game`] plugin
2//! (pak `~mods` layout, proxy-DLL overrides) parameterised per title.
3
4pub 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
18/// Data-driven UE4 game plugin.
19///
20/// UE4 titles share a near-identical layout: a project folder under the install
21/// root (`SB/`, `Palworld/`, etc.) that contains `Content/Paks/` for pak files
22/// and `Binaries/Win64/` for the shipping executable and proxy DLLs. Mods drop
23/// into `Content/Paks/~mods/` (the tilde forces mount order after base paks).
24///
25/// A new UE4 game needs only a new `const` instance; the trait impl is shared.
26pub struct Ue4Game {
27    game_id: &'static str,
28    display_name: &'static str,
29    /// Steam App ID as a string (parsed on demand for `steam_app_id_u32`).
30    steam_app_id: &'static str,
31    /// UE4 project short name — the folder under the install root that
32    /// contains `Content/Paks/` and `Binaries/Win64/`. For Stellar Blade this
33    /// is `"SB"`.
34    project_name: &'static str,
35    /// Nexus Mods game domain name, if the game is tracked there.
36    nexus_domain: Option<&'static str>,
37    /// Whether this specific UE4 game participates in modde's per-profile
38    /// save layer. This is intentionally per-game; UE4 save semantics vary.
39    save_profiles: bool,
40}
41
42impl Ue4Game {
43    /// Construct a UE4 game definition; save-profile support defaults off
44    /// (enable it via [`Ue4Game::with_save_profiles`]).
45    #[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    /// Opt this UE4 game into modde's per-profile save layer.
64    ///
65    /// This is a const builder so game definitions can stay data-only while
66    /// avoiding engine-wide assumptions about save-file layout.
67    #[must_use]
68    pub const fn with_save_profiles(mut self, enabled: bool) -> Self {
69        self.save_profiles = enabled;
70        self
71    }
72
73    /// `<install>/<ProjectName>/Content/Paks`
74    #[must_use]
75    pub fn paks_root(&self, install: &Path) -> PathBuf {
76        install.join(self.project_name).join("Content").join("Paks")
77    }
78
79    /// The UE4 project short name (the folder under the install root).
80    #[must_use]
81    pub fn project_name(&self) -> &'static str {
82        self.project_name
83    }
84}
85
86/// UE4SS / proxy DLLs commonly used by UE4 mod loaders. Detected in
87/// `<install>/<ProjectName>/Binaries/Win64/` and surfaced as `WINEDLLOVERRIDES`
88/// so Wine loads the native (mod) DLL instead of its built-in stub.
89const UE4_PROXY_DLLS: &[&str] = &[
90    "dwmapi",    // UE4SS default proxy
91    "xinput1_3", // UE4SS alternate + some trainers
92    "d3d11",     // ReShade / ENB-style
93    "dxgi",      // ReShade / DLSS swappers
94    "version",   // Generic ASI loader
95    "winmm",     // Generic ASI loader
96    "dinput8",   // Generic hook
97];
98
99/// File extensions that indicate a UE4 mod alters game logic.
100const UE4_SAVE_BREAKING_EXT: &[&str] = &["pak", "ucas", "utoc", "dll", "lua"];
101
102/// File extensions that are purely cosmetic in UE4 games.
103const 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    /// Deploy target: `<install>/<ProjectName>/Content/Paks/~mods`.
206    ///
207    /// The tilde prefix forces UE4's pak mounter to load these after the
208    /// shipping paks so mods can override base content.
209    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    /// Resolve the per-user config dir under the Proton prefix:
240    /// `<steam_root>/compatdata/<APP_ID>/pfx/drive_c/users/steamuser/`
241    /// `AppData/Local/<Project>/Saved/Config/Windows/`.
242    ///
243    /// Returns `None` if the prefix doesn't exist yet — the caller is
244    /// expected to surface that to the user (typically: launch the
245    /// game once so Proton creates the prefix).
246    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}