1pub 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
15pub 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#[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#[must_use]
68pub fn mods_dir_from_install(install: &Path) -> PathBuf {
69 data_root_from_install(install).join("Mods")
70}
71
72#[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#[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
92pub 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
105pub 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}