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}