maclarian 0.1.3

Larian file format library for Baldur's Gate 3 - PAK, LSF, LSX, GR2, DDS, and more
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Type definitions for merged LSX asset database

#![allow(clippy::doc_markdown)]

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

// ============================================================================
// Progress Types
// ============================================================================

/// Progress callback type for merged database operations
pub type MergedProgressCallback<'a> = &'a (dyn Fn(&MergedProgress) + Sync + Send);

/// Progress information during merged database operations
#[derive(Debug, Clone)]
pub struct MergedProgress {
    /// Current operation phase
    pub phase: MergedPhase,
    /// Current item number (1-indexed)
    pub current: usize,
    /// Total number of items
    pub total: usize,
    /// Current file being processed (if applicable)
    pub current_file: Option<String>,
}

impl MergedProgress {
    /// Create a new progress update
    #[must_use]
    pub fn new(phase: MergedPhase, current: usize, total: usize) -> Self {
        Self {
            phase,
            current,
            total,
            current_file: None,
        }
    }

    /// Create a progress update with a file/item name
    #[must_use]
    pub fn with_file(
        phase: MergedPhase,
        current: usize,
        total: usize,
        file: impl Into<String>,
    ) -> Self {
        Self {
            phase,
            current,
            total,
            current_file: Some(file.into()),
        }
    }

    /// Get the progress percentage (0.0 - 1.0)
    #[must_use]
    pub fn percentage(&self) -> f32 {
        if self.total == 0 {
            1.0
        } else {
            self.current as f32 / self.total as f32
        }
    }
}

/// Phase of merged database operation
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergedPhase {
    /// Scanning for _merged.lsf files
    ScanningFiles,
    /// Extracting files from PAK
    ExtractingFiles,
    /// Converting LSF to LSX and parsing
    ParsingLsf,
    /// Merging multiple databases
    MergingData,
    /// Resolving texture/material references
    ResolvingReferences,
    /// Operation complete
    Complete,
}

impl MergedPhase {
    /// Get a human-readable description of this phase
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::ScanningFiles => "Scanning files",
            Self::ExtractingFiles => "Extracting files",
            Self::ParsingLsf => "Parsing LSF files",
            Self::MergingData => "Merging data",
            Self::ResolvingReferences => "Resolving references",
            Self::Complete => "Complete",
        }
    }
}

// ============================================================================
// Asset Types
// ============================================================================

/// A visual asset (mesh) with its associated textures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualAsset {
    /// The visual resource ID (GUID)
    pub id: String,
    /// Human-readable name (e.g., "`HUM_M_ARM_Robe_C_Bracers_0`")
    pub name: String,
    /// Path to the GR2 source file (e.g., "Generated/Public/Shared/Assets/...")
    pub gr2_path: String,
    /// Pak file where the GR2 mesh is located (e.g., "Models.pak")
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub source_pak: String,
    /// Material IDs referenced by this visual
    pub material_ids: Vec<String>,
    /// Resolved DDS texture paths (from `TextureBank`)
    pub textures: Vec<TextureRef>,
    /// Resolved virtual texture references (from `VirtualTextureBank`)
    pub virtual_textures: Vec<VirtualTextureRef>,
}

/// Reference to a DDS texture in `TextureBank`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextureRef {
    /// Texture resource ID (GUID)
    pub id: String,
    /// Human-readable name
    pub name: String,
    /// Path to the DDS source file (e.g., "Generated/Public/Shared/Assets/...")
    pub dds_path: String,
    /// Pak file where this texture is located (e.g., "Textures.pak")
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub source_pak: String,
    /// Texture width in pixels.
    pub width: u32,
    /// Texture height in pixels.
    pub height: u32,
    /// Parameter name in material (e.g., "`MSKColor`", "`NormalMap`")
    pub parameter_name: Option<String>,
}

/// Reference to a virtual/streaming texture in `VirtualTextureBank`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VirtualTextureRef {
    /// Virtual texture resource ID (GUID)
    pub id: String,
    /// Human-readable name
    pub name: String,
    /// `GTex` filename hash (32-char hex)
    pub gtex_hash: String,
}

/// Pak file paths for resolving assets
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct PakPaths {
    /// Pak containing GR2 mesh files (typically "Models.pak")
    pub models: String,
    /// Pak containing DDS texture files (typically "Textures.pak")
    pub textures: String,
    /// Pak containing GTP virtual texture files (typically "VirtualTextures.pak")
    pub virtual_textures: String,
    /// Pattern for deriving GTP path from `gtex_hash`
    /// e.g., "Generated/Public/VirtualTextures/Albedo_Normal_Physical_{first}_{hash}.gtp"
    pub gtp_path_pattern: String,
}

impl PakPaths {
    /// Default BG3 pak paths
    #[must_use]
    pub fn bg3_default() -> Self {
        Self {
            models: "Models.pak".to_string(),
            textures: "Textures.pak".to_string(),
            virtual_textures: "VirtualTextures.pak".to_string(),
            gtp_path_pattern:
                "Generated/Public/VirtualTextures/Albedo_Normal_Physical_{first}_{hash}.gtp"
                    .to_string(),
        }
    }

    /// Derive the GTP path from a gtex hash
    #[must_use]
    pub fn gtp_path_from_hash(&self, gtex_hash: &str) -> String {
        if gtex_hash.is_empty() {
            return String::new();
        }
        let first = &gtex_hash[0..1];
        self.gtp_path_pattern
            .replace("{first}", first)
            .replace("{hash}", gtex_hash)
    }
}

/// A material definition from `MaterialBank`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct MaterialDef {
    /// Material resource ID (GUID)
    pub id: String,
    /// Human-readable name
    pub name: String,
    /// Path to base material template file (e.g., "Public/Shared/Assets/Materials/...")
    pub source_file: String,
    /// Pak file where this material definition was found (e.g., "Shared.pak")
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub source_pak: String,
    /// Texture parameter IDs (GUID references to `TextureBank`)
    pub texture_ids: Vec<TextureParam>,
    /// Virtual texture parameter IDs (GUID references to `VirtualTextureBank`)
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub virtual_texture_ids: Vec<String>,
}

/// A texture parameter within a material
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextureParam {
    /// Parameter name (e.g., "`MSKColor`", "`NormalMap`")
    pub name: String,
    /// Texture resource ID (GUID)
    pub texture_id: String,
}

/// The complete asset database built from a _merged.lsx file
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MergedDatabase {
    /// Source file this database was built from
    pub source_path: String,
    /// Pak file paths for resolving assets
    pub(crate) pak_paths: PakPaths,
    /// Visual assets indexed by their ID (GUID)
    pub visuals_by_id: HashMap<String, VisualAsset>,
    /// Visual ID indexed by visual name (e.g., "`HUM_M_ARM_Robe_C_Bracers_1`" -> ID)
    pub visuals_by_name: HashMap<String, String>,
    /// Visual IDs indexed by GR2 filename (one GR2 can have multiple visuals)
    pub visuals_by_gr2: HashMap<String, Vec<String>>,
    /// Materials indexed by their ID
    pub(crate) materials: HashMap<String, MaterialDef>,
    /// Textures indexed by their ID
    pub textures: HashMap<String, TextureRef>,
    /// Virtual textures indexed by their ID
    pub virtual_textures: HashMap<String, VirtualTextureRef>,
}

impl MergedDatabase {
    /// Creates a new empty merged database with the given source path.
    pub fn new(source_path: impl Into<String>) -> Self {
        Self {
            source_path: source_path.into(),
            pak_paths: PakPaths::bg3_default(),
            ..Default::default()
        }
    }

    /// Get a visual asset by its exact name (e.g., "`HUM_M_ARM_Robe_C_Bracers_1`")
    #[must_use]
    pub fn get_by_visual_name(&self, visual_name: &str) -> Option<&VisualAsset> {
        let id = self.visuals_by_name.get(visual_name)?;
        self.visuals_by_id.get(id)
    }

    /// Get all visuals that use a specific GR2 file
    #[must_use]
    pub fn get_visuals_for_gr2(&self, gr2_name: &str) -> Vec<&VisualAsset> {
        // Extract just the filename
        let filename = std::path::Path::new(gr2_name)
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or(gr2_name);

        // Try exact match first
        let ids = self.visuals_by_gr2.get(filename).or_else(|| {
            // Try case-insensitive match (database uses uppercase .GR2)
            let upper = filename.to_uppercase();
            // Handle case where filename is lowercase but DB key is uppercase
            if upper != filename {
                return self.visuals_by_gr2.get(&upper);
            }

            // Try with uppercase extension specifically
            if let Some(stem) = filename.strip_suffix(".gr2") {
                let with_upper_ext = format!("{stem}.GR2");
                return self.visuals_by_gr2.get(&with_upper_ext);
            }

            None
        });

        ids.map(|ids| {
            ids.iter()
                .filter_map(|id| self.visuals_by_id.get(id))
                .collect()
        })
        .unwrap_or_default()
    }

    /// Get all visual names in the database
    pub fn visual_names(&self) -> impl Iterator<Item = &str> {
        self.visuals_by_name.keys().map(std::string::String::as_str)
    }

    /// Get all GR2 files in the database
    pub fn gr2_files(&self) -> impl Iterator<Item = &str> {
        self.visuals_by_gr2.keys().map(std::string::String::as_str)
    }

    /// Get count statistics
    #[must_use]
    pub fn stats(&self) -> DatabaseStats {
        DatabaseStats {
            visual_count: self.visuals_by_id.len(),
            material_count: self.materials.len(),
            texture_count: self.textures.len(),
            virtual_texture_count: self.virtual_textures.len(),
        }
    }

    /// Import materials and textures from another database
    ///
    /// This is useful when one database (like Loot) references materials
    /// that are defined in another database (like `Humans_Male_Armor`).
    /// Only imports materials/textures that don't already exist.
    pub fn import_materials_from(&mut self, other: &MergedDatabase) {
        // Import materials that don't exist locally
        for (id, material) in &other.materials {
            if !self.materials.contains_key(id) {
                self.materials.insert(id.clone(), material.clone());
            }
        }

        // Import textures that don't exist locally
        for (id, texture) in &other.textures {
            if !self.textures.contains_key(id) {
                self.textures.insert(id.clone(), texture.clone());
            }
        }

        // Import virtual textures that don't exist locally
        for (id, vt) in &other.virtual_textures {
            if !self.virtual_textures.contains_key(id) {
                self.virtual_textures.insert(id.clone(), vt.clone());
            }
        }
    }

    /// Re-resolve texture references for all visuals using current materials/textures
    ///
    /// Call this after importing materials from external databases to populate
    /// the textures and `virtual_textures` fields on each visual.
    pub fn resolve_references(&mut self) {
        let materials = self.materials.clone();
        let textures = self.textures.clone();
        let virtual_textures = self.virtual_textures.clone();

        for visual in self.visuals_by_id.values_mut() {
            let mut resolved_textures = Vec::new();
            let mut resolved_vts = Vec::new();

            for mat_id in &visual.material_ids {
                if let Some(material) = materials.get(mat_id) {
                    // Resolve texture references
                    for tex_param in &material.texture_ids {
                        if let Some(texture) = textures.get(&tex_param.texture_id) {
                            let mut tex_ref = texture.clone();
                            tex_ref.parameter_name = Some(tex_param.name.clone());
                            if !resolved_textures
                                .iter()
                                .any(|t: &TextureRef| t.id == tex_ref.id)
                            {
                                resolved_textures.push(tex_ref);
                            }
                        }
                    }
                }
            }

            // Resolve virtual textures through material's virtual_texture_ids
            for mat_id in &visual.material_ids {
                if let Some(material) = materials.get(mat_id) {
                    for vt_id in &material.virtual_texture_ids {
                        if let Some(vt) = virtual_textures.get(vt_id)
                            && !resolved_vts
                                .iter()
                                .any(|v: &VirtualTextureRef| v.id == vt.id)
                        {
                            resolved_vts.push(vt.clone());
                        }
                    }
                }
            }

            visual.textures = resolved_textures;
            visual.virtual_textures = resolved_vts;
        }
    }
}

/// Statistics about a merged database
#[derive(Debug, Clone)]
pub struct DatabaseStats {
    /// Number of visual assets.
    pub visual_count: usize,
    /// Number of materials.
    pub material_count: usize,
    /// Number of textures.
    pub texture_count: usize,
    /// Number of virtual textures.
    pub virtual_texture_count: usize,
}

/// A matched .gtp file from VirtualTextures.pak
#[derive(Debug, Clone)]
pub struct GtpMatch {
    /// The `GTex` hash that was searched for
    pub gtex_hash: String,
    /// Path to the .gtp file inside the pak
    pub gtp_path: String,
    /// Path to the VirtualTextures.pak file
    pub pak_path: PathBuf,
}