Skip to main content

wow_adt/
split_set.rs

1//! Split ADT file set discovery and management.
2//!
3//! Starting with Cataclysm (4.3.4), ADT files were split into multiple specialized files
4//! that must be loaded together as a logical unit. This module provides types and utilities
5//! for discovering, managing, and validating complete sets of split ADT files.
6//!
7//! ## File Naming Convention
8//!
9//! ```text
10//! <InternalMapName>_<BlockX>_<BlockY>.adt        # Root file
11//! <InternalMapName>_<BlockX>_<BlockY>_tex0.adt   # Texture file (primary)
12//! <InternalMapName>_<BlockX>_<BlockY>_tex1.adt   # Texture file (deprecated BfA+)
13//! <InternalMapName>_<BlockX>_<BlockY>_obj0.adt   # Object file (primary)
14//! <InternalMapName>_<BlockX>_<BlockY>_obj1.adt   # Object file (secondary)
15//! <InternalMapName>_<BlockX>_<BlockY>_lod.adt    # LOD file (Legion+)
16//! ```
17//!
18//! ## Examples
19//!
20//! ```
21//! use std::path::Path;
22//! use wow_adt::split_set::SplitFileSet;
23//!
24//! # fn example() -> std::io::Result<()> {
25//! // Discover split files from root path
26//! let root_path = Path::new("World/Maps/Azeroth/Azeroth_30_30.adt");
27//! let file_set = SplitFileSet::discover(root_path);
28//!
29//! // Check which files exist
30//! let presence = file_set.verify_existence();
31//! if presence.has_root && presence.has_tex0 && presence.has_obj0 {
32//!     println!("Complete Cataclysm ADT set found!");
33//! }
34//!
35//! // Get all present file paths
36//! for path in file_set.present_files() {
37//!     println!("Found: {}", path.display());
38//! }
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! ## References
44//!
45//! - [wowdev.wiki ADT/v18](https://wowdev.wiki/ADT/v18) - Split file specification
46//! - `SPLIT_FILE_ARCHITECTURE.md` - Comprehensive architecture documentation
47
48use std::path::{Path, PathBuf};
49
50/// Complete set of split ADT files for a single map tile.
51///
52/// Represents all the files that make up a Cataclysm+ ADT tile. Not all files
53/// may be present - use [`verify_existence`](SplitFileSet::verify_existence) to check which files actually exist.
54///
55/// ## File Responsibilities
56///
57/// - **root**: Terrain geometry (heightmaps, normals, water)
58/// - **tex0**: Primary texture data (texture list, layers, alpha maps)
59/// - **tex1**: Additional texture data (deprecated in BfA+)
60/// - **obj0**: Primary object placements (M2 models, WMO buildings)
61/// - **obj1**: Additional object placements
62/// - **lod**: Low-detail geometry for distant rendering (Legion+)
63///
64/// ## Discovery
65///
66/// Use [`SplitFileSet::discover`] to automatically derive all split file paths from a root ADT path:
67///
68/// ```
69/// use std::path::{Path, PathBuf};
70/// use wow_adt::split_set::SplitFileSet;
71///
72/// let root = Path::new("Azeroth_30_30.adt");
73/// let set = SplitFileSet::discover(root);
74///
75/// assert_eq!(
76///     set.tex0,
77///     Some(PathBuf::from("Azeroth_30_30_tex0.adt"))
78/// );
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct SplitFileSet {
82    /// Root ADT file path (terrain geometry).
83    ///
84    /// This is the base file - always present for all WoW versions.
85    pub root: PathBuf,
86
87    /// Primary texture file path (_tex0.adt).
88    ///
89    /// Contains MTEX, MCLY, MCAL chunks. Expected for Cataclysm+ split files.
90    pub tex0: Option<PathBuf>,
91
92    /// Secondary texture file path (_tex1.adt).
93    ///
94    /// Contains additional texture data. Deprecated in Battle for Azeroth (8.x+).
95    /// May be `None` even for Cataclysm-Legion if not used.
96    pub tex1: Option<PathBuf>,
97
98    /// Primary object file path (_obj0.adt).
99    ///
100    /// Contains MMDX, MWMO, MDDF, MODF chunks. Expected for Cataclysm+ split files.
101    pub obj0: Option<PathBuf>,
102
103    /// Secondary object file path (_obj1.adt).
104    ///
105    /// Contains additional object placements. May be `None` if tile has few objects.
106    pub obj1: Option<PathBuf>,
107
108    /// LOD file path (_lod.adt).
109    ///
110    /// Contains low-detail geometry for distant rendering. Legion (7.x+) only.
111    /// May be `None` for Cataclysm-WoD tiles.
112    pub lod: Option<PathBuf>,
113}
114
115impl SplitFileSet {
116    /// Discover split file paths from a root ADT path.
117    ///
118    /// Given a root ADT file path (ending in `.adt`), this method derives the expected
119    /// paths for all associated split files by replacing the extension with the appropriate
120    /// suffix.
121    ///
122    /// # Arguments
123    ///
124    /// * `root_path` - Path to the root ADT file (e.g., "Azeroth_30_30.adt")
125    ///
126    /// # Returns
127    ///
128    /// A `SplitFileSet` with all potential file paths. Use [`verify_existence`](SplitFileSet::verify_existence)
129    /// to check which files actually exist.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use std::path::{Path, PathBuf};
135    /// use wow_adt::split_set::SplitFileSet;
136    ///
137    /// let root = Path::new("World/Maps/Azeroth/Azeroth_30_30.adt");
138    /// let set = SplitFileSet::discover(root);
139    ///
140    /// assert_eq!(set.root, PathBuf::from("World/Maps/Azeroth/Azeroth_30_30.adt"));
141    /// assert_eq!(
142    ///     set.tex0,
143    ///     Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_tex0.adt"))
144    /// );
145    /// assert_eq!(
146    ///     set.obj0,
147    ///     Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_obj0.adt"))
148    /// );
149    /// ```
150    ///
151    /// # Panics
152    ///
153    /// Panics if the root path has no parent directory or no file stem.
154    pub fn discover(root_path: impl AsRef<Path>) -> Self {
155        let root = root_path.as_ref().to_path_buf();
156        let parent = root.parent().expect("Root path must have parent directory");
157        let stem = root
158            .file_stem()
159            .and_then(|s| s.to_str())
160            .expect("Root path must have file stem");
161
162        Self {
163            root: root.clone(),
164            tex0: Some(parent.join(format!("{}_tex0.adt", stem))),
165            tex1: Some(parent.join(format!("{}_tex1.adt", stem))),
166            obj0: Some(parent.join(format!("{}_obj0.adt", stem))),
167            obj1: Some(parent.join(format!("{}_obj1.adt", stem))),
168            lod: Some(parent.join(format!("{}_lod.adt", stem))),
169        }
170    }
171
172    /// Check which split files actually exist on the filesystem.
173    ///
174    /// This method tests each file path in the set to determine which files are present.
175    /// Useful for determining if a complete set exists before attempting to load.
176    ///
177    /// # Returns
178    ///
179    /// A [`SplitFilePresence`] struct indicating which files exist.
180    ///
181    /// # Examples
182    ///
183    /// ```no_run
184    /// use std::path::Path;
185    /// use wow_adt::split_set::SplitFileSet;
186    ///
187    /// # fn example() -> std::io::Result<()> {
188    /// let set = SplitFileSet::discover("Azeroth_30_30.adt");
189    /// let presence = set.verify_existence();
190    ///
191    /// if presence.has_root && presence.has_tex0 && presence.has_obj0 {
192    ///     println!("Complete Cataclysm ADT set found!");
193    /// } else if presence.has_root && !presence.has_tex0 {
194    ///     println!("Pre-Cataclysm monolithic ADT file");
195    /// } else {
196    ///     println!("Incomplete split file set");
197    /// }
198    /// # Ok(())
199    /// # }
200    /// ```
201    pub fn verify_existence(&self) -> SplitFilePresence {
202        SplitFilePresence {
203            has_root: self.root.exists(),
204            has_tex0: self.tex0.as_ref().is_some_and(|p| p.exists()),
205            has_tex1: self.tex1.as_ref().is_some_and(|p| p.exists()),
206            has_obj0: self.obj0.as_ref().is_some_and(|p| p.exists()),
207            has_obj1: self.obj1.as_ref().is_some_and(|p| p.exists()),
208            has_lod: self.lod.as_ref().is_some_and(|p| p.exists()),
209        }
210    }
211
212    /// Get a vector of all file paths that are present (non-None).
213    ///
214    /// This returns paths regardless of whether they exist on disk. Use
215    /// [`verify_existence`](SplitFileSet::verify_existence) to filter to only existing files.
216    ///
217    /// # Returns
218    ///
219    /// Vector of references to all non-None file paths in the set.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use std::path::Path;
225    /// use wow_adt::split_set::SplitFileSet;
226    ///
227    /// let set = SplitFileSet::discover("Azeroth_30_30.adt");
228    /// let paths = set.present_files();
229    ///
230    /// // All paths are present (including root)
231    /// assert_eq!(paths.len(), 6); // root + tex0 + tex1 + obj0 + obj1 + lod
232    /// ```
233    pub fn present_files(&self) -> Vec<&PathBuf> {
234        let mut files = vec![&self.root];
235
236        if let Some(ref tex0) = self.tex0 {
237            files.push(tex0);
238        }
239        if let Some(ref tex1) = self.tex1 {
240            files.push(tex1);
241        }
242        if let Some(ref obj0) = self.obj0 {
243            files.push(obj0);
244        }
245        if let Some(ref obj1) = self.obj1 {
246            files.push(obj1);
247        }
248        if let Some(ref lod) = self.lod {
249            files.push(lod);
250        }
251
252        files
253    }
254
255    /// Check if this represents a complete Cataclysm+ split file set.
256    ///
257    /// A complete set requires root, tex0, and obj0 files to exist.
258    /// tex1, obj1, and lod are optional.
259    ///
260    /// # Returns
261    ///
262    /// `true` if root, tex0, and obj0 files exist.
263    ///
264    /// # Examples
265    ///
266    /// ```no_run
267    /// use std::path::Path;
268    /// use wow_adt::split_set::SplitFileSet;
269    ///
270    /// # fn example() -> std::io::Result<()> {
271    /// let set = SplitFileSet::discover("Azeroth_30_30.adt");
272    ///
273    /// if set.is_complete_cataclysm_set() {
274    ///     println!("Complete Cataclysm+ ADT tile");
275    /// } else {
276    ///     println!("Incomplete or pre-Cataclysm tile");
277    /// }
278    /// # Ok(())
279    /// # }
280    /// ```
281    pub fn is_complete_cataclysm_set(&self) -> bool {
282        let presence = self.verify_existence();
283        presence.has_root && presence.has_tex0 && presence.has_obj0
284    }
285}
286
287/// Bitmask indicating which split files are present on the filesystem.
288///
289/// Returned by [`SplitFileSet::verify_existence`] to indicate which files
290/// actually exist for a given ADT tile.
291///
292/// ## Detection Patterns
293///
294/// - **Pre-Cataclysm**: `has_root` only
295/// - **Cataclysm**: `has_root`, `has_tex0`, `has_obj0` (minimum)
296/// - **Legion+**: Above plus potentially `has_lod`
297/// - **BfA+**: `tex1` typically absent (deprecated)
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
299pub struct SplitFilePresence {
300    /// Root file exists (`.adt`).
301    pub has_root: bool,
302
303    /// Primary texture file exists (`_tex0.adt`).
304    pub has_tex0: bool,
305
306    /// Secondary texture file exists (`_tex1.adt`).
307    pub has_tex1: bool,
308
309    /// Primary object file exists (`_obj0.adt`).
310    pub has_obj0: bool,
311
312    /// Secondary object file exists (`_obj1.adt`).
313    pub has_obj1: bool,
314
315    /// LOD file exists (`_lod.adt`).
316    pub has_lod: bool,
317}
318
319impl SplitFilePresence {
320    /// Check if this represents a complete Cataclysm+ split file set.
321    ///
322    /// Requires root, tex0, and obj0 to be present.
323    ///
324    /// # Returns
325    ///
326    /// `true` if minimum required files exist for Cataclysm+ split architecture.
327    #[inline]
328    pub const fn is_complete_cataclysm_set(&self) -> bool {
329        self.has_root && self.has_tex0 && self.has_obj0
330    }
331
332    /// Check if this represents a pre-Cataclysm monolithic file.
333    ///
334    /// Only root file exists, no split files.
335    ///
336    /// # Returns
337    ///
338    /// `true` if only root exists (monolithic ADT).
339    #[inline]
340    pub const fn is_monolithic(&self) -> bool {
341        self.has_root
342            && !self.has_tex0
343            && !self.has_tex1
344            && !self.has_obj0
345            && !self.has_obj1
346            && !self.has_lod
347    }
348
349    /// Check if any split files are present.
350    ///
351    /// # Returns
352    ///
353    /// `true` if at least one split file (tex/obj/lod) exists.
354    #[inline]
355    pub const fn has_split_files(&self) -> bool {
356        self.has_tex0 || self.has_tex1 || self.has_obj0 || self.has_obj1 || self.has_lod
357    }
358
359    /// Count how many files are present.
360    ///
361    /// # Returns
362    ///
363    /// Number of files that exist (0-6).
364    pub const fn count(&self) -> usize {
365        let mut count = 0;
366        if self.has_root {
367            count += 1;
368        }
369        if self.has_tex0 {
370            count += 1;
371        }
372        if self.has_tex1 {
373            count += 1;
374        }
375        if self.has_obj0 {
376            count += 1;
377        }
378        if self.has_obj1 {
379            count += 1;
380        }
381        if self.has_lod {
382            count += 1;
383        }
384        count
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::path::PathBuf;
392
393    #[test]
394    fn test_discover_split_files() {
395        let root = PathBuf::from("World/Maps/Azeroth/Azeroth_30_30.adt");
396        let set = SplitFileSet::discover(&root);
397
398        assert_eq!(
399            set.root,
400            PathBuf::from("World/Maps/Azeroth/Azeroth_30_30.adt")
401        );
402        assert_eq!(
403            set.tex0,
404            Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_tex0.adt"))
405        );
406        assert_eq!(
407            set.tex1,
408            Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_tex1.adt"))
409        );
410        assert_eq!(
411            set.obj0,
412            Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_obj0.adt"))
413        );
414        assert_eq!(
415            set.obj1,
416            Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_obj1.adt"))
417        );
418        assert_eq!(
419            set.lod,
420            Some(PathBuf::from("World/Maps/Azeroth/Azeroth_30_30_lod.adt"))
421        );
422    }
423
424    #[test]
425    fn test_discover_without_directory() {
426        let root = PathBuf::from("Azeroth_30_30.adt");
427        let set = SplitFileSet::discover(&root);
428
429        assert_eq!(set.root, PathBuf::from("Azeroth_30_30.adt"));
430        assert_eq!(set.tex0, Some(PathBuf::from("Azeroth_30_30_tex0.adt")));
431        assert_eq!(set.obj0, Some(PathBuf::from("Azeroth_30_30_obj0.adt")));
432    }
433
434    #[test]
435    fn test_present_files() {
436        let set = SplitFileSet::discover("Azeroth_30_30.adt");
437        let files = set.present_files();
438
439        // All files should be present (non-None)
440        assert_eq!(files.len(), 6); // root + 5 split files
441    }
442
443    #[test]
444    fn test_split_file_presence_is_complete() {
445        let complete = SplitFilePresence {
446            has_root: true,
447            has_tex0: true,
448            has_tex1: false,
449            has_obj0: true,
450            has_obj1: false,
451            has_lod: false,
452        };
453        assert!(complete.is_complete_cataclysm_set());
454
455        let incomplete = SplitFilePresence {
456            has_root: true,
457            has_tex0: false, // Missing required file
458            has_tex1: false,
459            has_obj0: true,
460            has_obj1: false,
461            has_lod: false,
462        };
463        assert!(!incomplete.is_complete_cataclysm_set());
464    }
465
466    #[test]
467    fn test_split_file_presence_is_monolithic() {
468        let monolithic = SplitFilePresence {
469            has_root: true,
470            has_tex0: false,
471            has_tex1: false,
472            has_obj0: false,
473            has_obj1: false,
474            has_lod: false,
475        };
476        assert!(monolithic.is_monolithic());
477
478        let split = SplitFilePresence {
479            has_root: true,
480            has_tex0: true,
481            has_tex1: false,
482            has_obj0: true,
483            has_obj1: false,
484            has_lod: false,
485        };
486        assert!(!split.is_monolithic());
487    }
488
489    #[test]
490    fn test_split_file_presence_count() {
491        let presence = SplitFilePresence {
492            has_root: true,
493            has_tex0: true,
494            has_tex1: false,
495            has_obj0: true,
496            has_obj1: false,
497            has_lod: false,
498        };
499        assert_eq!(presence.count(), 3);
500
501        let all = SplitFilePresence {
502            has_root: true,
503            has_tex0: true,
504            has_tex1: true,
505            has_obj0: true,
506            has_obj1: true,
507            has_lod: true,
508        };
509        assert_eq!(all.count(), 6);
510    }
511}