use quick_xml::de::from_str as xml_from_str;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::formats::lsf::parse_lsf_bytes;
use crate::pak::PakOperations;
use super::loaders::{
find_mod_name_from_mods_dir, load_virtual_textures_json, load_vtex_config_xml,
parse_vtex_config_from_lsf,
};
use super::types::{DiscoveredVirtualTexture, DiscoverySource, VTexConfigXml, VirtualTexturesJson};
pub fn discover_mod_virtual_textures(mod_root: &Path) -> Result<Vec<DiscoveredVirtualTexture>> {
let mut discovered = Vec::new();
let mut seen_hashes = HashSet::new();
let mod_name = find_mod_name_from_mods_dir(mod_root);
if let Some(ref mod_name) = mod_name {
if let Some(xml) = load_vtex_config_xml(mod_root, mod_name) {
let tileset_name = xml.name.clone();
if let Some(ref paths) = xml.paths {
if let Some(ref vt_path) = paths.virtual_textures {
let vt_path_normalized = vt_path.replace('\\', "/");
let gts_filename = format!("{tileset_name}.gts");
let gts_path = mod_root.join(&vt_path_normalized).join(>s_filename);
if let Some(ref textures) = xml.textures {
for texture in &textures.textures {
seen_hashes.insert(texture.name.clone());
discovered.push(DiscoveredVirtualTexture {
mod_name: mod_name.clone(),
mod_root: mod_root.to_path_buf(),
tileset_name: Some(tileset_name.clone()),
gtex_hash: texture.name.clone(),
gts_path: gts_path.clone(),
source: DiscoverySource::VTexConfigXml,
});
}
}
}
}
}
if let Some(json) = load_virtual_textures_json(mod_root, mod_name) {
for mapping in json.mappings {
if !seen_hashes.contains(&mapping.gtex_name) {
seen_hashes.insert(mapping.gtex_name.clone());
let gts_path_normalized = mapping.gts_path.replace('\\', "/");
let gts_path = mod_root.join(>s_path_normalized);
discovered.push(DiscoveredVirtualTexture {
mod_name: mod_name.clone(),
mod_root: mod_root.to_path_buf(),
tileset_name: None, gtex_hash: mapping.gtex_name,
gts_path,
source: DiscoverySource::VirtualTexturesJson,
});
}
}
}
}
let has_public = mod_root.join("Public").is_dir();
let has_generated = mod_root.join("Generated").is_dir();
let has_mods = mod_root.join("Mods").is_dir();
if has_public || has_generated || has_mods {
let seen_gts_paths: HashSet<_> = discovered.iter().map(|d| d.gts_path.clone()).collect();
let gts_discovered = discover_gts_files(mod_root, &seen_hashes, &seen_gts_paths);
discovered.extend(gts_discovered);
}
Ok(discovered)
}
fn discover_gts_files(
mod_root: &Path,
seen_hashes: &HashSet<String>,
seen_gts_paths: &HashSet<PathBuf>,
) -> Vec<DiscoveredVirtualTexture> {
let mut discovered = Vec::new();
let gts_files = find_gts_files_recursive(mod_root);
for gts_path in gts_files {
if seen_gts_paths.contains(>s_path) {
continue;
}
let gts_stem = gts_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let is_duplicate = seen_gts_paths
.iter()
.any(|p| p.file_stem().and_then(|s| s.to_str()) == Some(gts_stem));
if is_duplicate {
continue;
}
let mod_name = extract_mod_name_from_path(>s_path, mod_root);
let gts_dir = gts_path.parent().unwrap_or(mod_root);
if let Ok(entries) = std::fs::read_dir(gts_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("gtp"))
{
let gtp_name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let gtex_hash =
if let Some(hash) = extract_gtex_hash_from_gtp_name(gtp_name, gts_stem) {
hash
} else {
gts_stem.to_string()
};
if seen_hashes.contains(>ex_hash) {
continue;
}
let tileset_name = find_stacked_texture_xml(mod_root, >ex_hash)
.or_else(|| Some(gts_stem.to_string()));
discovered.push(DiscoveredVirtualTexture {
mod_name: mod_name.clone(),
mod_root: mod_root.to_path_buf(),
tileset_name,
gtex_hash,
gts_path: gts_path.clone(),
source: DiscoverySource::GtsFileScan,
});
}
}
}
if discovered.is_empty() && !seen_hashes.contains(gts_stem) {
let mod_name = extract_mod_name_from_path(>s_path, mod_root);
discovered.push(DiscoveredVirtualTexture {
mod_name,
mod_root: mod_root.to_path_buf(),
tileset_name: Some(gts_stem.to_string()),
gtex_hash: gts_stem.to_string(),
gts_path: gts_path.clone(),
source: DiscoverySource::GtsFileScan,
});
}
}
discovered
}
fn find_gts_files_recursive(dir: &Path) -> Vec<PathBuf> {
let mut gts_files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
gts_files.extend(find_gts_files_recursive(&path));
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("gts"))
{
gts_files.push(path);
}
}
}
gts_files
}
fn extract_gtex_hash_from_gtp_name(gtp_name: &str, gts_stem: &str) -> Option<String> {
gtp_name
.strip_prefix(gts_stem)
.and_then(|suffix| suffix.strip_prefix('_'))
.filter(|hash| !hash.is_empty() && hash.chars().all(|c| c.is_ascii_hexdigit()))
.map(ToString::to_string)
}
fn extract_mod_name_from_path(file_path: &Path, mod_root: &Path) -> String {
if let Ok(relative) = file_path.strip_prefix(mod_root) {
let parts: Vec<_> = relative.iter().collect();
for (i, part) in parts.iter().enumerate() {
if part.to_string_lossy().eq_ignore_ascii_case("Public") {
if let Some(mod_name) = parts.get(i + 1) {
return mod_name.to_string_lossy().to_string();
}
}
}
}
mod_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown")
.to_string()
}
fn find_stacked_texture_xml(mod_root: &Path, gtex_hash: &str) -> Option<String> {
let xml_name = format!("{gtex_hash}.xml");
for entry in walkdir_simple(mod_root, 5) {
if entry
.file_name()
.is_some_and(|n| n.to_string_lossy() == xml_name)
{
if let Ok(content) = std::fs::read_to_string(&entry) {
if content.contains("<StackedTexture>") {
return Some(gtex_hash.to_string());
}
}
}
}
None
}
fn walkdir_simple(dir: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut results = Vec::new();
walkdir_simple_inner(dir, max_depth, 0, &mut results);
results
}
fn walkdir_simple_inner(
dir: &Path,
max_depth: usize,
current_depth: usize,
results: &mut Vec<PathBuf>,
) {
if current_depth > max_depth {
return;
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walkdir_simple_inner(&path, max_depth, current_depth + 1, results);
} else {
results.push(path);
}
}
}
}
pub fn discover_pak_virtual_textures(pak_path: &Path) -> Result<Vec<DiscoveredVirtualTexture>> {
let mut discovered = Vec::new();
let mut seen_hashes = HashSet::new();
let files = PakOperations::list(pak_path)?;
let vtex_configs: Vec<_> = files
.iter()
.filter(|f| f.ends_with("VTexConfig.xml"))
.collect();
let vtex_jsons: Vec<_> = files
.iter()
.filter(|f| f.ends_with("VirtualTextures.json"))
.collect();
for config_path in &vtex_configs {
let mod_name = extract_mod_name_from_pak_path(config_path);
if let Ok(content) = PakOperations::read_file_bytes(pak_path, config_path) {
let parsed = if content.len() >= 4 && &content[0..4] == b"LSOF" {
parse_lsf_bytes(&content)
.ok()
.and_then(|doc| parse_vtex_config_from_lsf(&doc))
.map(|lsf| {
(
lsf.tileset_name,
lsf.virtual_textures_path,
lsf.texture_names,
)
})
} else {
String::from_utf8(content)
.ok()
.and_then(|s| xml_from_str::<VTexConfigXml>(&s).ok())
.map(|xml| {
let tileset_name = xml.name;
let vt_path = xml.paths.and_then(|p| p.virtual_textures);
let texture_names = xml
.textures
.map(|t| t.textures.into_iter().map(|tex| tex.name).collect())
.unwrap_or_default();
(tileset_name, vt_path, texture_names)
})
};
if let Some((tileset_name, Some(vt_path), texture_names)) = parsed {
let vt_path_normalized = vt_path.replace('\\', "/");
let gts_filename = format!("{tileset_name}.gts");
let gts_path = PathBuf::from(&vt_path_normalized).join(>s_filename);
for texture_name in texture_names {
seen_hashes.insert(texture_name.clone());
discovered.push(DiscoveredVirtualTexture {
mod_name: mod_name.clone(),
mod_root: pak_path.to_path_buf(), tileset_name: Some(tileset_name.clone()),
gtex_hash: texture_name,
gts_path: gts_path.clone(),
source: DiscoverySource::VTexConfigXml,
});
}
}
}
}
for json_path in &vtex_jsons {
let mod_name = extract_mod_name_from_pak_path(json_path);
if let Ok(content) = PakOperations::read_file_bytes(pak_path, json_path) {
if let Ok(content_str) = String::from_utf8(content) {
if let Ok(json) = serde_json::from_str::<VirtualTexturesJson>(&content_str) {
for mapping in json.mappings {
if !seen_hashes.contains(&mapping.gtex_name) {
let gts_path_normalized = mapping.gts_path.replace('\\', "/");
let gts_path = PathBuf::from(>s_path_normalized);
discovered.push(DiscoveredVirtualTexture {
mod_name: mod_name.clone(),
mod_root: pak_path.to_path_buf(),
tileset_name: None,
gtex_hash: mapping.gtex_name,
gts_path,
source: DiscoverySource::VirtualTexturesJson,
});
}
}
}
}
}
}
Ok(discovered)
}
fn extract_mod_name_from_pak_path(path: &str) -> String {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 && parts[0] == "Mods" {
parts[1].to_string()
} else {
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
}
}
pub fn discover_virtual_textures(
search_paths: &[PathBuf],
) -> Result<Vec<DiscoveredVirtualTexture>> {
let mut all_discovered = Vec::new();
for search_path in search_paths {
if !search_path.exists() {
continue;
}
let is_pak = search_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pak"));
if is_pak {
if let Ok(discovered) = discover_pak_virtual_textures(search_path) {
all_discovered.extend(discovered);
}
continue;
}
if search_path.join("Mods").is_dir() {
if let Ok(discovered) = discover_mod_virtual_textures(search_path) {
all_discovered.extend(discovered);
}
continue;
}
if search_path.is_dir() {
if let Ok(discovered) = discover_mod_virtual_textures(search_path) {
if !discovered.is_empty() {
all_discovered.extend(discovered);
continue;
}
}
}
if let Ok(entries) = std::fs::read_dir(search_path) {
for entry in entries.flatten() {
let path = entry.path();
let is_pak = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pak"));
if is_pak {
if let Ok(discovered) = discover_pak_virtual_textures(&path) {
all_discovered.extend(discovered);
}
} else if path.is_dir() {
if let Ok(discovered) = discover_mod_virtual_textures(&path) {
all_discovered.extend(discovered);
}
}
}
}
}
Ok(all_discovered)
}