use crate::error::{Error, Result};
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use walkdir::WalkDir;
const CACHE_VERSION: u32 = 3;
#[derive(Serialize, Deserialize)]
struct GameIndexCache {
version: u32,
game_fingerprint: u64,
wad_index: HashMap<String, Vec<Utf8PathBuf>>,
hash_index: HashMap<u64, Vec<Utf8PathBuf>>,
subchunktoc_blocked: Vec<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameIndex {
pub wad_index: HashMap<String, Vec<Utf8PathBuf>>,
pub hash_index: HashMap<u64, Vec<Utf8PathBuf>>,
pub game_fingerprint: u64,
pub subchunktoc_blocked: HashSet<u64>,
}
impl GameIndex {
pub fn new() -> Self {
Self {
wad_index: HashMap::new(),
hash_index: HashMap::new(),
game_fingerprint: 0,
subchunktoc_blocked: HashSet::new(),
}
}
pub fn build(game_dir: &Utf8Path) -> Result<Self> {
let data_final_dir = game_dir.join("DATA").join("FINAL");
if !data_final_dir.as_std_path().exists() {
return Err(Error::InvalidGameDir(format!(
"DATA/FINAL not found in {}",
game_dir
)));
}
tracing::info!("Building game index from {}", data_final_dir);
let wad_paths = collect_wad_paths_sorted(&data_final_dir)?;
let wad_index = build_wad_filename_index(&wad_paths);
let (hash_index, wad_relative_paths) = build_game_hash_index(game_dir, &wad_paths);
let game_fingerprint = calculate_game_fingerprint(&wad_paths);
let subchunktoc_blocked = build_subchunktoc_blocked(&wad_relative_paths);
tracing::info!(
"Game index built: {} WAD filenames, {} unique hashes, {} SubChunkTOC blocked, fingerprint: {:016x}",
wad_index.len(),
hash_index.len(),
subchunktoc_blocked.len(),
game_fingerprint
);
Ok(Self {
wad_index,
hash_index,
game_fingerprint,
subchunktoc_blocked,
})
}
pub fn load_or_build(game_dir: &Utf8Path, cache_path: &Utf8Path) -> Result<Self> {
match Self::load_cache(cache_path) {
Ok(Some(cached)) => {
let data_final_dir = game_dir.join("DATA").join("FINAL");
let current_fp = calculate_game_fingerprint_from_dir(&data_final_dir)?;
if cached.game_fingerprint == current_fp {
tracing::info!(
"Game index loaded from cache (fingerprint {:016x} matched)",
current_fp
);
return Ok(cached);
}
tracing::info!(
"Game index cache stale (fingerprint {:016x} != {:016x}), rebuilding",
cached.game_fingerprint,
current_fp
);
}
Ok(None) => {
tracing::debug!("No game index cache found at {}", cache_path);
}
Err(e) => {
tracing::warn!("Failed to load game index cache: {}", e);
}
}
let index = Self::build(game_dir)?;
if let Err(e) = index.save(cache_path) {
tracing::warn!("Failed to save game index cache: {}", e);
}
Ok(index)
}
pub fn save(&self, cache_path: &Utf8Path) -> Result<()> {
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent.as_std_path())?;
}
let cache = self.to_cache();
let bytes = rmp_serde::to_vec_named(&cache)
.map_err(|e| Error::Other(format!("Failed to serialize game index cache: {}", e)))?;
std::fs::write(cache_path.as_std_path(), bytes)?;
tracing::debug!("Game index cache saved to {}", cache_path);
Ok(())
}
pub fn find_wad(&self, filename: &str) -> Result<&Utf8PathBuf> {
let key = filename.to_ascii_lowercase();
let candidates = self
.wad_index
.get(&key)
.ok_or_else(|| Error::WadNotFound(Utf8PathBuf::from(filename)))?;
if candidates.len() == 1 {
Ok(&candidates[0])
} else {
Err(Error::AmbiguousWad {
name: filename.to_string(),
count: candidates.len(),
})
}
}
pub fn find_wads_with_hash(&self, path_hash: u64) -> Option<&[Utf8PathBuf]> {
self.hash_index.get(&path_hash).map(|v| v.as_slice())
}
pub fn game_fingerprint(&self) -> u64 {
self.game_fingerprint
}
pub fn subchunktoc_blocked(&self) -> &HashSet<u64> {
&self.subchunktoc_blocked
}
pub fn find_best_matching_wad(&self, chunk_hashes: &[u64]) -> Option<Utf8PathBuf> {
let mut wad_overlap_counts: HashMap<&Utf8Path, usize> = HashMap::new();
for &hash in chunk_hashes {
if let Some(wad_paths) = self.hash_index.get(&hash) {
for wad_path in wad_paths {
*wad_overlap_counts.entry(wad_path.as_path()).or_insert(0) += 1;
}
}
}
wad_overlap_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(path, count)| {
tracing::info!(
"Overlap detection: best match WAD={} with {} overlapping chunks out of {}",
path,
count,
chunk_hashes.len()
);
path.to_path_buf()
})
}
pub fn compute_content_hashes_batch(
&self,
game_dir: &Utf8Path,
path_hashes: &HashSet<u64>,
) -> HashMap<u64, u64> {
use ltk_wad::Wad;
use xxhash_rust::xxh3::xxh3_64;
let mut wad_to_hashes: HashMap<&Utf8PathBuf, Vec<u64>> = HashMap::new();
for &ph in path_hashes {
if let Some(wad_paths) = self.hash_index.get(&ph) {
if let Some(first_wad) = wad_paths.first() {
wad_to_hashes.entry(first_wad).or_default().push(ph);
}
}
}
let mut result: HashMap<u64, u64> = HashMap::with_capacity(path_hashes.len());
for (wad_rel_path, hashes) in &wad_to_hashes {
let abs_path = game_dir.join(wad_rel_path);
let needed: HashSet<u64> = hashes.iter().copied().collect();
let file = match std::fs::File::open(abs_path.as_std_path()) {
Ok(f) => f,
Err(e) => {
tracing::warn!("Failed to open WAD '{}': {}", abs_path, e);
continue;
}
};
let mut wad = match Wad::mount(file) {
Ok(w) => w,
Err(e) => {
tracing::warn!("Failed to mount WAD '{}': {}", abs_path, e);
continue;
}
};
let chunks: Vec<_> = wad
.chunks()
.iter()
.filter(|c| needed.contains(&c.path_hash))
.cloned()
.collect();
for chunk in &chunks {
match wad.load_chunk_decompressed(chunk) {
Ok(data) => {
result.insert(chunk.path_hash, xxh3_64(&data));
}
Err(e) => {
tracing::trace!(
"Failed to decompress chunk {:016x} in '{}': {}",
chunk.path_hash,
abs_path,
e
);
}
}
}
}
tracing::info!(
"Computed {} content hashes on-demand (from {} WADs)",
result.len(),
wad_to_hashes.len()
);
result
}
fn load_cache(cache_path: &Utf8Path) -> Result<Option<Self>> {
if !cache_path.as_std_path().exists() {
return Ok(None);
}
let bytes = std::fs::read(cache_path.as_std_path())?;
let cache: GameIndexCache = match rmp_serde::from_slice(&bytes) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to deserialize game index cache: {}", e);
return Ok(None);
}
};
if cache.version != CACHE_VERSION {
tracing::info!(
"Game index cache version mismatch ({} != {}), ignoring",
cache.version,
CACHE_VERSION
);
return Ok(None);
}
Ok(Some(Self::from_cache(cache)))
}
fn from_cache(cache: GameIndexCache) -> Self {
Self {
wad_index: cache.wad_index,
hash_index: cache.hash_index,
game_fingerprint: cache.game_fingerprint,
subchunktoc_blocked: cache.subchunktoc_blocked.into_iter().collect(),
}
}
fn to_cache(&self) -> GameIndexCache {
GameIndexCache {
version: CACHE_VERSION,
game_fingerprint: self.game_fingerprint,
wad_index: self.wad_index.clone(),
hash_index: self.hash_index.clone(),
subchunktoc_blocked: self.subchunktoc_blocked.iter().copied().collect(),
}
}
}
impl Default for GameIndex {
fn default() -> Self {
Self::new()
}
}
fn collect_wad_paths_sorted(root: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
let mut paths: Vec<Utf8PathBuf> = WalkDir::new(root.as_std_path())
.into_iter()
.filter_map(|entry| {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::warn!("Skipping unreadable entry: {}", e);
return None;
}
};
if entry.file_type().is_dir() {
return None;
}
let path = match Utf8PathBuf::from_path_buf(entry.into_path()) {
Ok(p) => p,
Err(p) => {
tracing::warn!("Skipping non-UTF-8 path: {}", p.display());
return None;
}
};
let name = path.file_name()?;
if !name.to_ascii_lowercase().ends_with(".wad.client") {
return None;
}
Some(path)
})
.collect();
paths.sort();
Ok(paths)
}
fn build_wad_filename_index(wad_paths: &[Utf8PathBuf]) -> HashMap<String, Vec<Utf8PathBuf>> {
let mut index: HashMap<String, Vec<Utf8PathBuf>> = HashMap::new();
for path in wad_paths {
let name = path.file_name().unwrap();
index
.entry(name.to_ascii_lowercase())
.or_default()
.push(path.clone());
}
index
}
type HashIndexResult = (HashMap<u64, Vec<Utf8PathBuf>>, Vec<Utf8PathBuf>);
struct WadMountResult {
relative_path: Utf8PathBuf,
chunk_hashes: Vec<u64>,
}
fn mount_and_extract_hashes(
abs_path: &Utf8Path,
relative_path: Utf8PathBuf,
) -> Option<WadMountResult> {
use ltk_wad::Wad;
let file = match std::fs::File::open(abs_path.as_std_path()) {
Ok(f) => f,
Err(e) => {
tracing::warn!("Failed to open WAD '{}': {}", abs_path, e);
return None;
}
};
let wad = match Wad::mount(file) {
Ok(w) => w,
Err(e) => {
tracing::warn!("Failed to mount WAD '{}': {}", abs_path, e);
return None;
}
};
let chunk_hashes: Vec<u64> = wad.chunks().iter().map(|c| c.path_hash).collect();
Some(WadMountResult {
relative_path,
chunk_hashes,
})
}
fn build_game_hash_index(game_dir: &Utf8Path, wad_paths: &[Utf8PathBuf]) -> HashIndexResult {
let wad_abs_rel: Vec<(&Utf8PathBuf, Utf8PathBuf)> = wad_paths
.iter()
.filter_map(|abs_path| {
let rel = abs_path.strip_prefix(game_dir).ok()?.to_path_buf();
Some((abs_path, rel))
})
.collect();
use rayon::prelude::*;
let mount_results: Vec<WadMountResult> = wad_abs_rel
.into_par_iter()
.filter_map(|(abs, rel)| mount_and_extract_hashes(abs, rel))
.collect();
let mut hash_to_wads: HashMap<u64, Vec<Utf8PathBuf>> = HashMap::new();
let mut wad_relative_paths: Vec<Utf8PathBuf> = Vec::with_capacity(mount_results.len());
let mut chunk_count = 0usize;
for result in mount_results {
wad_relative_paths.push(result.relative_path.clone());
for hash in &result.chunk_hashes {
hash_to_wads
.entry(*hash)
.or_default()
.push(result.relative_path.clone());
chunk_count += 1;
}
}
tracing::info!(
"Game hash index built: {} WADs, {} total chunk entries, {} unique hashes",
wad_relative_paths.len(),
chunk_count,
hash_to_wads.len()
);
(hash_to_wads, wad_relative_paths)
}
fn build_subchunktoc_blocked(wad_relative_paths: &[Utf8PathBuf]) -> HashSet<u64> {
use xxhash_rust::xxh64::xxh64;
let mut blocked = HashSet::new();
for rel_path in wad_relative_paths {
let path_str = rel_path.as_str();
let toc_path = if let Some(stripped) = path_str.strip_suffix(".client") {
format!("{}.SubChunkTOC", stripped)
} else {
continue;
};
let normalized = toc_path.replace('\\', "/").to_lowercase();
let hash = xxh64(normalized.as_bytes(), 0);
blocked.insert(hash);
tracing::trace!("SubChunkTOC blocked: {} -> {:016x}", normalized, hash);
}
blocked
}
fn calculate_game_fingerprint(wad_paths: &[Utf8PathBuf]) -> u64 {
use xxhash_rust::xxh3::xxh3_64;
let mut hasher_input = Vec::new();
for path in wad_paths {
hasher_input.extend_from_slice(path.as_str().as_bytes());
if let Ok(metadata) = std::fs::metadata(path.as_std_path()) {
hasher_input.extend_from_slice(&metadata.len().to_le_bytes());
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
hasher_input.extend_from_slice(&duration.as_secs().to_le_bytes());
}
}
}
}
xxh3_64(&hasher_input)
}
fn calculate_game_fingerprint_from_dir(data_final_dir: &Utf8Path) -> Result<u64> {
let wad_paths = collect_wad_paths_sorted(data_final_dir)?;
Ok(calculate_game_fingerprint(&wad_paths))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wad_index_creation() {
}
#[test]
fn test_subchunktoc_blocked() {
let paths = vec![
Utf8PathBuf::from("DATA/FINAL/Champions/Aatrox.wad.client"),
Utf8PathBuf::from("DATA/FINAL/Maps/Map11.wad.client"),
];
let blocked = build_subchunktoc_blocked(&paths);
assert_eq!(blocked.len(), 2);
use xxhash_rust::xxh64::xxh64;
let expected_hash = xxh64(b"data/final/champions/aatrox.wad.subchunktoc", 0);
assert!(
blocked.contains(&expected_hash),
"Expected hash {:016x} for aatrox SubChunkTOC",
expected_hash
);
}
#[test]
fn test_subchunktoc_blocked_backslash_normalization() {
let paths = vec![Utf8PathBuf::from(
"DATA\\FINAL\\Champions\\Aatrox.wad.client",
)];
let blocked = build_subchunktoc_blocked(&paths);
use xxhash_rust::xxh64::xxh64;
let expected_hash = xxh64(b"data/final/champions/aatrox.wad.subchunktoc", 0);
assert!(
blocked.contains(&expected_hash),
"Backslash paths should normalize to same hash"
);
}
#[test]
fn test_cache_roundtrip() {
let mut wad_index = HashMap::new();
wad_index.insert(
"aatrox.wad.client".to_string(),
vec![Utf8PathBuf::from(
"/game/DATA/FINAL/Champions/Aatrox.wad.client",
)],
);
let mut hash_index = HashMap::new();
hash_index.insert(
0xDEADBEEF_u64,
vec![Utf8PathBuf::from("DATA/FINAL/Champions/Aatrox.wad.client")],
);
let mut subchunktoc_blocked = HashSet::new();
subchunktoc_blocked.insert(0xCAFEBABE_u64);
let index = GameIndex {
wad_index,
hash_index,
game_fingerprint: 0x123456,
subchunktoc_blocked,
};
let cache = index.to_cache();
assert_eq!(cache.version, CACHE_VERSION);
assert_eq!(cache.game_fingerprint, 0x123456);
let restored = GameIndex::from_cache(cache);
assert_eq!(restored.game_fingerprint, 0x123456);
assert_eq!(
restored.find_wads_with_hash(0xDEADBEEF).map(|v| v.len()),
Some(1)
);
assert!(restored.subchunktoc_blocked.contains(&0xCAFEBABE));
assert!(restored.find_wad("aatrox.wad.client").is_ok());
}
#[test]
fn test_find_best_matching_wad_returns_highest_overlap() {
let mut hash_index = HashMap::new();
for h in [1u64, 2, 3] {
hash_index
.entry(h)
.or_insert_with(Vec::new)
.push(Utf8PathBuf::from("DATA/FINAL/Champions/WadA.wad.client"));
}
for h in [2u64, 3, 4, 5] {
hash_index
.entry(h)
.or_insert_with(Vec::new)
.push(Utf8PathBuf::from("DATA/FINAL/Champions/WadB.wad.client"));
}
let index = GameIndex {
wad_index: HashMap::new(),
hash_index,
game_fingerprint: 0,
subchunktoc_blocked: HashSet::new(),
};
let result = index.find_best_matching_wad(&[2, 3, 4, 5]);
assert_eq!(
result.as_deref(),
Some(Utf8Path::new("DATA/FINAL/Champions/WadB.wad.client"))
);
let result = index.find_best_matching_wad(&[1, 2, 3]);
assert_eq!(
result.as_deref(),
Some(Utf8Path::new("DATA/FINAL/Champions/WadA.wad.client"))
);
}
#[test]
fn test_find_best_matching_wad_returns_none_for_no_matches() {
let index = GameIndex {
wad_index: HashMap::new(),
hash_index: HashMap::new(),
game_fingerprint: 0,
subchunktoc_blocked: HashSet::new(),
};
assert!(index.find_best_matching_wad(&[1, 2, 3]).is_none());
}
#[test]
fn test_find_best_matching_wad_empty_input() {
let mut hash_index = HashMap::new();
hash_index.insert(
1u64,
vec![Utf8PathBuf::from("DATA/FINAL/Champions/Aatrox.wad.client")],
);
let index = GameIndex {
wad_index: HashMap::new(),
hash_index,
game_fingerprint: 0,
subchunktoc_blocked: HashSet::new(),
};
assert!(index.find_best_matching_wad(&[]).is_none());
}
#[test]
fn test_cache_save_and_load() {
let mut wad_index = HashMap::new();
wad_index.insert(
"test.wad.client".to_string(),
vec![Utf8PathBuf::from("/game/DATA/FINAL/test.wad.client")],
);
let index = GameIndex {
wad_index,
hash_index: HashMap::new(),
game_fingerprint: 0xABCDEF,
subchunktoc_blocked: HashSet::new(),
};
let temp = tempfile::NamedTempFile::new().unwrap();
let cache_path = Utf8Path::from_path(temp.path()).unwrap();
index.save(cache_path).unwrap();
let loaded = GameIndex::load_cache(cache_path).unwrap().unwrap();
assert_eq!(loaded.game_fingerprint, 0xABCDEF);
assert!(loaded.find_wad("test.wad.client").is_ok());
}
}