Skip to main content

modde_games/bg3/
mod.rs

1//! The Baldur's Gate 3 game plugin: Larian `.pak` mod layout, `modsettings.lsx`
2//! load-order management, and Proton-prefix path resolution.
3
4pub mod saves;
5pub mod scanner;
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use modde_core::installer::InstallMethod;
11
12use crate::policies::{BareLayoutPolicy, ContentPolicy};
13use crate::traits::{ContentCategory, GamePlugin, ModSafety};
14
15/// [`GamePlugin`] for Baldur's Gate 3 (Larian's Divinity engine).
16pub struct LarianBg3Game;
17
18pub static BALDURS_GATE3: LarianBg3Game = LarianBg3Game;
19
20const STEAM_APP_ID: &str = "1086940";
21
22const BG3_SAVE_BREAKING_EXT: &[&str] = &["pak", "dll", "json", "lsx"];
23const BG3_COSMETIC_EXT: &[&str] = &["png", "jpg", "dds", "tga"];
24const BG3_CONTENT_CATEGORIES: &[(&str, ContentCategory)] = &[
25    ("pak", ContentCategory::Archive),
26    ("dll", ContentCategory::Binary),
27    ("json", ContentCategory::Config),
28    ("lsx", ContentCategory::Config),
29    ("dds", ContentCategory::Texture),
30    ("png", ContentCategory::Texture),
31    ("tga", ContentCategory::Texture),
32    ("jpg", ContentCategory::Texture),
33];
34
35const BG3_CONTENT_POLICY: ContentPolicy = ContentPolicy {
36    save_breaking_ext: BG3_SAVE_BREAKING_EXT,
37    cosmetic_ext: BG3_COSMETIC_EXT,
38    save_breaking_dirs: &["script extender", "scriptextender"],
39    categories: BG3_CONTENT_CATEGORIES,
40};
41
42const BG3_BARE_LAYOUT_POLICY: BareLayoutPolicy = BareLayoutPolicy {
43    root_dirs: &["mods", "playerprofiles"],
44    root_file_exts: &["pak", "lsx"],
45    case_insensitive_dirs: true,
46};
47
48/// Resolve BG3's `AppData/Local` data root, preferring the Steam Proton prefix
49/// derived from `install` and falling back to an install-relative path.
50#[must_use]
51pub fn data_root_from_install(install: &Path) -> PathBuf {
52    let proton = install
53        .ancestors()
54        .find(|path| path.file_name().and_then(|name| name.to_str()) == Some("common"))
55        .and_then(|common| common.parent())
56        .map(|steamapps| {
57            steamapps
58                .join("compatdata")
59                .join(STEAM_APP_ID)
60                .join("pfx/drive_c/users/steamuser/AppData/Local/Larian Studios/Baldur's Gate 3")
61        });
62
63    proton.unwrap_or_else(|| install.join("Larian Studios/Baldur's Gate 3"))
64}
65
66/// The BG3 `Mods` directory derived from [`data_root_from_install`].
67#[must_use]
68pub fn mods_dir_from_install(install: &Path) -> PathBuf {
69    data_root_from_install(install).join("Mods")
70}
71
72/// Path to the `modsettings.lsx` load-order file derived from `install`.
73#[must_use]
74pub fn modsettings_path_from_install(install: &Path) -> PathBuf {
75    data_root_from_install(install).join("PlayerProfiles/Public/modsettings.lsx")
76}
77
78/// BG3 savegame directory inside the default Steam Proton prefix, if it exists.
79#[must_use]
80pub fn save_dir_from_steam_default() -> Option<PathBuf> {
81    Some(
82        modde_core::paths::steam_common()
83            .parent()?
84            .join("compatdata")
85            .join(STEAM_APP_ID)
86            .join(
87                "pfx/drive_c/users/steamuser/AppData/Local/Larian Studios/Baldur's Gate 3/PlayerProfiles/Public/Savegames",
88            ),
89    )
90}
91
92/// Read the ordered list of enabled module folders from a `modsettings.lsx` file.
93pub fn read_modsettings(path: &Path) -> Result<Vec<String>> {
94    let content = std::fs::read_to_string(path)
95        .with_context(|| format!("failed to read {}", path.display()))?;
96    Ok(content
97        .lines()
98        .filter_map(|line| line.split_once("value=\""))
99        .filter_map(|(_, rest)| rest.split_once('"'))
100        .map(|(value, _)| value.to_string())
101        .filter(|value| !value.is_empty())
102        .collect())
103}
104
105/// Write a `modsettings.lsx` file enabling the given module folders in order.
106pub fn write_modsettings(path: &Path, mods: &[String]) -> Result<()> {
107    if let Some(parent) = path.parent() {
108        std::fs::create_dir_all(parent)
109            .with_context(|| format!("failed to create {}", parent.display()))?;
110    }
111    let module_nodes = mods
112        .iter()
113        .map(|name| format!("          <node id=\"ModuleShortDesc\"><attribute id=\"Folder\" type=\"LSString\" value=\"{name}\" /></node>"))
114        .collect::<Vec<_>>()
115        .join("\n");
116    std::fs::write(
117        path,
118        format!(
119            r#"<?xml version="1.0" encoding="utf-8"?>
120<save>
121  <region id="ModuleSettings">
122    <node id="root">
123      <children>
124{module_nodes}
125      </children>
126    </node>
127  </region>
128</save>
129"#
130        ),
131    )
132    .with_context(|| format!("failed to write {}", path.display()))?;
133    Ok(())
134}
135
136impl GamePlugin for LarianBg3Game {
137    fn game_id(&self) -> &'static str {
138        "baldurs-gate3"
139    }
140
141    fn display_name(&self) -> &'static str {
142        "Baldur's Gate 3"
143    }
144
145    fn mod_directory(&self, install: &Path) -> PathBuf {
146        mods_dir_from_install(install)
147    }
148
149    fn mod_root(&self, install: &Path) -> Result<PathBuf> {
150        Ok(mods_dir_from_install(install))
151    }
152
153    fn save_directory(&self) -> Option<PathBuf> {
154        save_dir_from_steam_default()
155    }
156
157    fn supports_save_profiles(&self) -> bool {
158        true
159    }
160
161    fn classify_mod(&self, mod_dir: &Path) -> ModSafety {
162        BG3_CONTENT_POLICY.classify_mod(mod_dir)
163    }
164
165    fn classify_extension(&self, ext: &str) -> ContentCategory {
166        BG3_CONTENT_POLICY.classify_extension(ext)
167    }
168
169    fn archive_extensions(&self) -> &[&str] {
170        &["pak"]
171    }
172
173    fn executable_dir(&self, install: &Path) -> PathBuf {
174        install.join("bin")
175    }
176
177    fn steam_app_id_u32(&self) -> Option<u32> {
178        Some(1086940)
179    }
180
181    fn nexus_game_domain(&self) -> Option<&str> {
182        Some("baldursgate3")
183    }
184
185    fn post_deploy(&self, install: &Path) -> Result<()> {
186        let mods_dir = mods_dir_from_install(install);
187        let mut mods = Vec::new();
188        if mods_dir.is_dir() {
189            for entry in std::fs::read_dir(&mods_dir)
190                .with_context(|| format!("failed to read directory: {}", mods_dir.display()))?
191                .flatten()
192            {
193                let path = entry.path();
194                if path
195                    .extension()
196                    .and_then(|ext| ext.to_str())
197                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pak"))
198                    && let Some(stem) = path.file_stem().and_then(|stem| stem.to_str())
199                {
200                    mods.push(stem.to_string());
201                }
202            }
203        }
204        mods.sort();
205        if !mods.is_empty() {
206            write_modsettings(&modsettings_path_from_install(install), &mods)?;
207        }
208        Ok(())
209    }
210
211    fn analyze_mod_archive(&self, extracted_dir: &Path) -> Option<InstallMethod> {
212        if has_root_file_with_ext(extracted_dir, &["pak"]) {
213            return Some(InstallMethod::SingleFileSet);
214        }
215        extracted_dir
216            .join("Mods")
217            .is_dir()
218            .then(|| InstallMethod::StripContentRoot {
219                root: "Mods".to_string(),
220            })
221    }
222
223    fn recognizes_bare_layout(&self, extracted_dir: &Path) -> bool {
224        BG3_BARE_LAYOUT_POLICY.recognizes(extracted_dir)
225    }
226}
227
228fn has_root_file_with_ext(dir: &Path, extensions: &[&str]) -> bool {
229    std::fs::read_dir(dir).is_ok_and(|entries| {
230        entries.flatten().any(|entry| {
231            let path = entry.path();
232            path.is_file()
233                && path
234                    .extension()
235                    .and_then(|ext| ext.to_str())
236                    .is_some_and(|ext| {
237                        extensions
238                            .iter()
239                            .any(|candidate| ext.eq_ignore_ascii_case(candidate))
240                    })
241        })
242    })
243}