#![allow(
clippy::collapsible_if,
clippy::items_after_statements,
clippy::option_if_let_else,
clippy::doc_markdown,
clippy::missing_errors_doc,
clippy::map_unwrap_or
)]
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use rayon::prelude::*;
use super::parser::merge_databases;
use super::types::{
MaterialDef, MergedDatabase, TextureParam, TextureRef, VirtualTextureRef, VisualAsset,
};
use crate::error::{Error, Result};
use crate::formats::common::extract_value;
use crate::formats::lsf::{LsfDocument, parse_lsf_bytes};
use crate::pak::PakOperations;
pub const BG3_DATA_PATH_WINDOWS: &str =
r"C:\Program Files (x86)\Steam\steamapps\common\Baldurs Gate 3\Data";
pub struct GameDataResolver {
game_data_path: PathBuf,
database: OnceLock<MergedDatabase>,
}
impl GameDataResolver {
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().to_path_buf();
if !path.exists() {
return Err(Error::InvalidPath(format!(
"Game data path does not exist: {}",
path.display()
)));
}
let shared_pak = path.join("Shared.pak");
if !shared_pak.exists() {
return Err(Error::InvalidPath(format!(
"Shared.pak not found in: {}",
path.display()
)));
}
Ok(Self {
game_data_path: path,
database: OnceLock::new(),
})
}
pub fn auto_detect() -> Result<Self> {
if let Some(path) = super::bg3_data_path() {
if path.exists() {
return Self::new(path);
}
}
let windows_path = PathBuf::from(BG3_DATA_PATH_WINDOWS);
if windows_path.exists() {
return Self::new(windows_path);
}
Err(Error::InvalidPath(
"Could not find BG3 install path. Use --bg3-path to specify the path.".to_string(),
))
}
#[must_use]
pub fn game_data_path(&self) -> &Path {
&self.game_data_path
}
#[must_use]
pub fn database(&self) -> &MergedDatabase {
self.database.get_or_init(|| self.build_database())
}
fn build_database(&self) -> MergedDatabase {
let shared_pak = self.game_data_path.join("Shared.pak");
let gustavx_pak = self.game_data_path.join("GustavX.pak");
tracing::info!(
"Building asset database from: {}",
self.game_data_path.display()
);
let mut db = MergedDatabase::new(self.game_data_path.to_string_lossy());
if let Err(e) = self.parse_pak_filtered(&shared_pak, &mut db) {
tracing::error!("Failed to parse Shared.pak: {}", e);
return db;
}
if gustavx_pak.exists() {
if let Err(e) = self.parse_pak_filtered(&gustavx_pak, &mut db) {
tracing::warn!("Failed to parse GustavX.pak: {}", e);
}
}
db.resolve_references();
let stats = db.stats();
tracing::info!(
"Database built: {} visuals, {} materials, {} textures",
stats.visual_count,
stats.material_count,
stats.texture_count
);
db
}
fn parse_pak_filtered(&self, pak_path: &Path, db: &mut MergedDatabase) -> Result<()> {
let all_files = PakOperations::list(pak_path)?;
let relevant_paths: Vec<String> = all_files
.into_iter()
.filter(|p| p.ends_with("_merged.lsf") && is_relevant_asset_path(p))
.collect();
if relevant_paths.is_empty() {
return Ok(());
}
let pak_name = pak_path.file_name().unwrap_or_default().to_string_lossy();
tracing::info!(
"Parsing {} relevant _merged.lsf files from {}",
relevant_paths.len(),
pak_name
);
let file_data = PakOperations::read_files_bytes(pak_path, &relevant_paths)?;
let parsed_dbs: Vec<(String, Result<MergedDatabase>)> = file_data
.par_iter()
.map(|(path, data)| {
let result = parse_lsf_to_database(data, path);
(path.clone(), result)
})
.collect();
for (path, result) in parsed_dbs {
match result {
Ok(file_db) => {
merge_databases(db, file_db);
}
Err(e) => {
tracing::debug!("Failed to parse {}: {}", path, e);
}
}
}
Ok(())
}
pub fn parse_pak_with_progress<F>(
&self,
pak_path: &Path,
db: &mut MergedDatabase,
progress: F,
) -> Result<()>
where
F: Fn(usize, usize, &str) + Send + Sync,
{
let all_files = PakOperations::list(pak_path)?;
let relevant_paths: Vec<String> = all_files
.into_iter()
.filter(|p| p.ends_with("_merged.lsf") && is_relevant_asset_path(p))
.collect();
if relevant_paths.is_empty() {
return Ok(());
}
let total = relevant_paths.len();
let pak_name = pak_path.file_name().unwrap_or_default().to_string_lossy();
tracing::info!(
"Parsing {} relevant _merged.lsf files from {}",
total,
pak_name
);
progress(0, total, "Reading files from PAK...");
let file_data = PakOperations::read_files_bytes(pak_path, &relevant_paths)?;
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = AtomicUsize::new(0);
let parsed_dbs: Vec<(String, Result<MergedDatabase>)> = file_data
.par_iter()
.map(|(path, data)| {
let current = counter.fetch_add(1, Ordering::SeqCst);
let filename = Path::new(path)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
progress(current + 1, total, &filename);
let result = parse_lsf_to_database(data, path);
(path.clone(), result)
})
.collect();
for (path, result) in parsed_dbs {
match result {
Ok(file_db) => {
merge_databases(db, file_db);
}
Err(e) => {
tracing::debug!("Failed to parse {}: {}", path, e);
}
}
}
Ok(())
}
#[must_use]
pub fn get_by_visual_name(&self, name: &str) -> Option<&VisualAsset> {
self.database().get_by_visual_name(name)
}
#[must_use]
pub fn get_visuals_for_gr2(&self, gr2_name: &str) -> Vec<&VisualAsset> {
self.database().get_visuals_for_gr2(gr2_name)
}
#[must_use]
pub fn is_available() -> bool {
Self::auto_detect().is_ok()
}
}
fn parse_lsf_to_database(data: &[u8], source_path: &str) -> Result<MergedDatabase> {
let doc = parse_lsf_bytes(data)
.map_err(|e| Error::ConversionError(format!("LSF parse error for {source_path}: {e}")))?;
let mut db = MergedDatabase::new(source_path);
for root_idx in doc.root_nodes() {
let Some(region_name) = doc.node_name(root_idx) else {
continue;
};
match region_name {
"VisualBank" => parse_visual_bank_lsf(&doc, root_idx, &mut db),
"MaterialBank" => parse_material_bank_lsf(&doc, root_idx, &mut db),
"TextureBank" => parse_texture_bank_lsf(&doc, root_idx, &mut db),
"VirtualTextureBank" => parse_virtual_texture_bank_lsf(&doc, root_idx, &mut db),
_ => {}
}
}
Ok(db)
}
fn get_attr_string(doc: &LsfDocument, node_idx: usize, attr_name: &str) -> Option<String> {
for (_, name, type_id, offset, length) in doc.attributes_of(node_idx) {
if name == attr_name {
return extract_value(&doc.values, offset, length, type_id).ok();
}
}
None
}
fn get_attr_u32(doc: &LsfDocument, node_idx: usize, attr_name: &str) -> Option<u32> {
get_attr_string(doc, node_idx, attr_name).and_then(|s| s.parse().ok())
}
fn parse_visual_bank_lsf(doc: &LsfDocument, bank_idx: usize, db: &mut MergedDatabase) {
for resource_idx in doc.find_children_by_name(bank_idx, "Resource") {
let Some(id) = get_attr_string(doc, resource_idx, "ID") else {
continue;
};
let gr2_path = get_attr_string(doc, resource_idx, "SourceFile").unwrap_or_default();
if gr2_path.is_empty() {
continue;
}
let name = get_attr_string(doc, resource_idx, "Name").unwrap_or_default();
let mut material_ids = Vec::new();
for obj_idx in doc.find_children_by_name(resource_idx, "Objects") {
if let Some(mat_id) = get_attr_string(doc, obj_idx, "MaterialID") {
if !mat_id.is_empty() && !material_ids.contains(&mat_id) {
material_ids.push(mat_id);
}
}
}
let visual = VisualAsset {
id: id.clone(),
name: name.clone(),
gr2_path: gr2_path.clone(),
source_pak: String::new(),
material_ids,
textures: Vec::new(),
virtual_textures: Vec::new(),
};
db.visuals_by_name.insert(name, id.clone());
let gr2_filename = Path::new(&gr2_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if !gr2_filename.is_empty() {
db.visuals_by_gr2
.entry(gr2_filename)
.or_default()
.push(id.clone());
}
db.visuals_by_id.insert(id, visual);
}
}
fn parse_material_bank_lsf(doc: &LsfDocument, bank_idx: usize, db: &mut MergedDatabase) {
for resource_idx in doc.find_children_by_name(bank_idx, "Resource") {
let Some(id) = get_attr_string(doc, resource_idx, "ID") else {
continue;
};
let name = get_attr_string(doc, resource_idx, "Name").unwrap_or_default();
let source_file = get_attr_string(doc, resource_idx, "SourceFile").unwrap_or_default();
let mut texture_ids = Vec::new();
for tex_idx in doc.find_children_by_name(resource_idx, "Texture2DParameters") {
let param_name = get_attr_string(doc, tex_idx, "ParameterName").unwrap_or_default();
if let Some(tex_id) = get_attr_string(doc, tex_idx, "ID") {
if !tex_id.is_empty() {
texture_ids.push(TextureParam {
name: param_name,
texture_id: tex_id,
});
}
}
}
let mut virtual_texture_ids = Vec::new();
for vt_idx in doc.find_children_by_name(resource_idx, "VirtualTextureParameters") {
if let Some(vt_id) = get_attr_string(doc, vt_idx, "ID") {
if !vt_id.is_empty() && !virtual_texture_ids.contains(&vt_id) {
virtual_texture_ids.push(vt_id);
}
}
}
db.materials.insert(
id.clone(),
MaterialDef {
id,
name,
source_file,
source_pak: String::new(),
texture_ids,
virtual_texture_ids,
},
);
}
}
fn parse_texture_bank_lsf(doc: &LsfDocument, bank_idx: usize, db: &mut MergedDatabase) {
for resource_idx in doc.find_children_by_name(bank_idx, "Resource") {
let Some(id) = get_attr_string(doc, resource_idx, "ID") else {
continue;
};
let name = get_attr_string(doc, resource_idx, "Name").unwrap_or_default();
let dds_path = get_attr_string(doc, resource_idx, "SourceFile").unwrap_or_default();
let width = get_attr_u32(doc, resource_idx, "Width").unwrap_or(0);
let height = get_attr_u32(doc, resource_idx, "Height").unwrap_or(0);
db.textures.insert(
id.clone(),
TextureRef {
id,
name,
dds_path,
source_pak: String::new(),
width,
height,
parameter_name: None,
},
);
}
}
fn parse_virtual_texture_bank_lsf(doc: &LsfDocument, bank_idx: usize, db: &mut MergedDatabase) {
for resource_idx in doc.find_children_by_name(bank_idx, "Resource") {
let Some(id) = get_attr_string(doc, resource_idx, "ID") else {
continue;
};
let name = get_attr_string(doc, resource_idx, "Name").unwrap_or_default();
let gtex_hash = get_attr_string(doc, resource_idx, "GTexFileName").unwrap_or_default();
db.virtual_textures.insert(
id.clone(),
VirtualTextureRef {
id,
name,
gtex_hash,
},
);
}
}
fn is_relevant_asset_path(path: &str) -> bool {
if !path.starts_with("Public/") {
return false;
}
if !path.contains("/Content/Assets/") {
return false;
}
if let Some(pak_start) = path.find("[PAK]_") {
let pak_part = &path[pak_start..];
if pak_part.contains("Armor") || pak_part.contains("Clothing") || pak_part.contains("Body")
{
return true;
}
}
path.contains("/Loot/") || path.contains("/Equipment/")
}