wow_m2/
file_resolver.rs

1//! FileDataID resolution system for M2 chunked format
2//!
3//! This module provides traits and implementations for resolving FileDataIDs
4//! to file paths and loading external files referenced by M2 models.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::error::{M2Error, Result};
11
12/// Trait for resolving FileDataIDs to file paths and loading external files
13pub trait FileResolver {
14    /// Resolve a FileDataID to a file path
15    fn resolve_file_data_id(&self, id: u32) -> Result<String>;
16
17    /// Load file data by FileDataID
18    fn load_file_by_id(&self, id: u32) -> Result<Vec<u8>>;
19
20    /// Load a skin file by FileDataID
21    fn load_skin_by_id(&self, id: u32) -> Result<Vec<u8>> {
22        self.load_file_by_id(id)
23    }
24
25    /// Load an animation file by FileDataID
26    fn load_animation_by_id(&self, id: u32) -> Result<Vec<u8>> {
27        self.load_file_by_id(id)
28    }
29
30    /// Load a texture file by FileDataID
31    fn load_texture_by_id(&self, id: u32) -> Result<Vec<u8>> {
32        self.load_file_by_id(id)
33    }
34
35    /// Load a physics file by FileDataID
36    fn load_physics_by_id(&self, id: &u32) -> Result<Vec<u8>> {
37        self.load_file_by_id(*id)
38    }
39
40    /// Load a skeleton file by FileDataID
41    fn load_skeleton_by_id(&self, id: &u32) -> Result<Vec<u8>> {
42        self.load_file_by_id(*id)
43    }
44
45    /// Load a bone file by FileDataID
46    fn load_bone_by_id(&self, id: &u32) -> Result<Vec<u8>> {
47        self.load_file_by_id(*id)
48    }
49}
50
51/// A file resolver that uses a listfile mapping from FileDataID to file path
52/// This is the most common implementation for WoW file resolution
53#[derive(Debug)]
54pub struct ListfileResolver {
55    /// Mapping from FileDataID to file path
56    id_to_path: HashMap<u32, String>,
57    /// Base path for resolving file paths
58    base_path: Option<PathBuf>,
59}
60
61impl ListfileResolver {
62    /// Create a new empty listfile resolver
63    pub fn new() -> Self {
64        Self {
65            id_to_path: HashMap::new(),
66            base_path: None,
67        }
68    }
69
70    /// Create a new listfile resolver with a base path
71    pub fn with_base_path<P: AsRef<Path>>(base_path: P) -> Self {
72        Self {
73            id_to_path: HashMap::new(),
74            base_path: Some(base_path.as_ref().to_path_buf()),
75        }
76    }
77
78    /// Load mappings from a CSV file (FileDataID;filepath format)
79    pub fn load_from_csv<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
80        let contents = fs::read_to_string(path)
81            .map_err(|e| M2Error::ExternalFileError(format!("Failed to read listfile: {}", e)))?;
82
83        for line in contents.lines() {
84            let line = line.trim();
85            if line.is_empty() || line.starts_with('#') {
86                continue;
87            }
88
89            // Parse CSV format: FileDataID;filepath
90            let parts: Vec<&str> = line.split(';').collect();
91            if parts.len() >= 2
92                && let Ok(id) = parts[0].parse::<u32>()
93            {
94                let path = parts[1].to_string();
95                self.id_to_path.insert(id, path);
96            }
97        }
98
99        Ok(())
100    }
101
102    /// Load mappings from a simple text file (one "ID filepath" per line)
103    pub fn load_from_text<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
104        let contents = fs::read_to_string(path)
105            .map_err(|e| M2Error::ExternalFileError(format!("Failed to read listfile: {}", e)))?;
106
107        for line in contents.lines() {
108            let line = line.trim();
109            if line.is_empty() || line.starts_with('#') {
110                continue;
111            }
112
113            // Parse text format: ID filepath
114            let parts: Vec<&str> = line.split_whitespace().collect();
115            if parts.len() >= 2
116                && let Ok(id) = parts[0].parse::<u32>()
117            {
118                let path = parts[1..].join(" "); // Handle paths with spaces
119                self.id_to_path.insert(id, path);
120            }
121        }
122
123        Ok(())
124    }
125
126    /// Add a manual mapping from FileDataID to file path
127    pub fn add_mapping<S: Into<String>>(&mut self, id: u32, path: S) {
128        self.id_to_path.insert(id, path.into());
129    }
130
131    /// Remove a mapping by FileDataID
132    pub fn remove_mapping(&mut self, id: u32) -> Option<String> {
133        self.id_to_path.remove(&id)
134    }
135
136    /// Get the number of mappings
137    pub fn len(&self) -> usize {
138        self.id_to_path.len()
139    }
140
141    /// Check if the resolver is empty
142    pub fn is_empty(&self) -> bool {
143        self.id_to_path.is_empty()
144    }
145
146    /// Set the base path for file resolution
147    pub fn set_base_path<P: AsRef<Path>>(&mut self, base_path: P) {
148        self.base_path = Some(base_path.as_ref().to_path_buf());
149    }
150
151    /// Get the resolved absolute path for a file path
152    fn resolve_path(&self, file_path: &str) -> PathBuf {
153        match &self.base_path {
154            Some(base) => base.join(file_path),
155            None => PathBuf::from(file_path),
156        }
157    }
158}
159
160impl Default for ListfileResolver {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166impl FileResolver for ListfileResolver {
167    fn resolve_file_data_id(&self, id: u32) -> Result<String> {
168        self.id_to_path
169            .get(&id)
170            .cloned()
171            .ok_or(M2Error::UnknownFileDataId(id))
172    }
173
174    fn load_file_by_id(&self, id: u32) -> Result<Vec<u8>> {
175        let file_path = self.resolve_file_data_id(id)?;
176        let absolute_path = self.resolve_path(&file_path);
177
178        fs::read(&absolute_path).map_err(|e| {
179            M2Error::ExternalFileError(format!(
180                "Failed to load file {} (ID {}): {}",
181                absolute_path.display(),
182                id,
183                e
184            ))
185        })
186    }
187}
188
189/// A simple file resolver that only works with file paths (no FileDataID resolution)
190/// This is useful for testing or when working with extracted files
191#[derive(Debug)]
192pub struct PathResolver {
193    base_path: PathBuf,
194}
195
196impl PathResolver {
197    /// Create a new path resolver with a base directory
198    pub fn new<P: AsRef<Path>>(base_path: P) -> Self {
199        Self {
200            base_path: base_path.as_ref().to_path_buf(),
201        }
202    }
203
204    /// Load a file by relative path
205    pub fn load_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<u8>> {
206        let full_path = self.base_path.join(path.as_ref());
207        fs::read(&full_path).map_err(|e| {
208            M2Error::ExternalFileError(format!(
209                "Failed to load file {}: {}",
210                full_path.display(),
211                e
212            ))
213        })
214    }
215}
216
217impl FileResolver for PathResolver {
218    fn resolve_file_data_id(&self, id: u32) -> Result<String> {
219        // This resolver doesn't support FileDataID resolution
220        Err(M2Error::UnknownFileDataId(id))
221    }
222
223    fn load_file_by_id(&self, id: u32) -> Result<Vec<u8>> {
224        // This resolver doesn't support FileDataID-based loading
225        Err(M2Error::UnknownFileDataId(id))
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::io::Write;
233    use tempfile::NamedTempFile;
234
235    #[test]
236    fn test_listfile_resolver_csv_format() {
237        let mut resolver = ListfileResolver::new();
238
239        // Create a temporary CSV listfile
240        let mut listfile = NamedTempFile::new().unwrap();
241        writeln!(listfile, "# Comment line").unwrap();
242        writeln!(listfile, "123456;World\\Maps\\Azeroth\\Azeroth.wdt").unwrap();
243        writeln!(listfile, "789012;Creature\\Human\\Male\\HumanMale.m2").unwrap();
244        writeln!(listfile).unwrap(); // Empty line
245        listfile.flush().unwrap();
246
247        resolver.load_from_csv(listfile.path()).unwrap();
248
249        assert_eq!(resolver.len(), 2);
250        assert_eq!(
251            resolver.resolve_file_data_id(123456).unwrap(),
252            "World\\Maps\\Azeroth\\Azeroth.wdt"
253        );
254        assert_eq!(
255            resolver.resolve_file_data_id(789012).unwrap(),
256            "Creature\\Human\\Male\\HumanMale.m2"
257        );
258
259        // Test unknown ID
260        assert!(resolver.resolve_file_data_id(999999).is_err());
261    }
262
263    #[test]
264    fn test_listfile_resolver_text_format() {
265        let mut resolver = ListfileResolver::new();
266
267        // Create a temporary text listfile
268        let mut listfile = NamedTempFile::new().unwrap();
269        writeln!(listfile, "# Comment line").unwrap();
270        writeln!(listfile, "123456 World/Maps/Azeroth/Azeroth.wdt").unwrap();
271        writeln!(listfile, "789012 Creature/Human/Male/HumanMale.m2").unwrap();
272        writeln!(listfile, "555666 Path with spaces/file.blp").unwrap();
273        listfile.flush().unwrap();
274
275        resolver.load_from_text(listfile.path()).unwrap();
276
277        assert_eq!(resolver.len(), 3);
278        assert_eq!(
279            resolver.resolve_file_data_id(555666).unwrap(),
280            "Path with spaces/file.blp"
281        );
282    }
283
284    #[test]
285    fn test_listfile_resolver_manual_mappings() {
286        let mut resolver = ListfileResolver::new();
287
288        resolver.add_mapping(12345, "test/file.m2");
289        resolver.add_mapping(67890, "another/file.blp");
290
291        assert_eq!(resolver.len(), 2);
292        assert_eq!(
293            resolver.resolve_file_data_id(12345).unwrap(),
294            "test/file.m2"
295        );
296
297        let removed = resolver.remove_mapping(12345);
298        assert_eq!(removed, Some("test/file.m2".to_string()));
299        assert_eq!(resolver.len(), 1);
300    }
301
302    #[test]
303    fn test_path_resolver() {
304        let temp_dir = tempfile::tempdir().unwrap();
305        let resolver = PathResolver::new(temp_dir.path());
306
307        // PathResolver should not support FileDataID resolution
308        assert!(resolver.resolve_file_data_id(123).is_err());
309        assert!(resolver.load_file_by_id(123).is_err());
310
311        // Create a test file
312        let test_file_path = temp_dir.path().join("test.txt");
313        fs::write(&test_file_path, b"test content").unwrap();
314
315        let content = resolver.load_file("test.txt").unwrap();
316        assert_eq!(content, b"test content");
317    }
318}