wow_wdt/
lib.rs

1//! World of Warcraft WDT (World Data Table) parser library
2//!
3//! This library provides functionality to parse, validate, create, and convert
4//! WDT files used in World of Warcraft for map data organization.
5//!
6//! # Features
7//!
8//! - Parse WDT files from any WoW version (Classic through modern)
9//! - Validate WDT structure with version-aware rules
10//! - Create new WDT files programmatically
11//! - Convert WDT files between different WoW versions
12//! - Support for all chunk types (MVER, MPHD, MAIN, MAID, MWMO, MODF)
13//! - Coordinate system conversion utilities
14//!
15//! # Version Support
16//!
17//! Tested against 100+ real WDT files from:
18//! - 1.12.1 (Classic)
19//! - 2.4.3 (The Burning Crusade)
20//! - 3.3.5a (Wrath of the Lich King)
21//! - 4.3.4 (Cataclysm) - Breaking change: terrain maps lose MWMO chunks
22//! - 5.4.8 (Mists of Pandaria)
23//! - 8.x+ (Battle for Azeroth) - FileDataID support
24//!
25//! # Example
26//!
27//! ```no_run
28//! use std::fs::File;
29//! use std::io::BufReader;
30//! use wow_wdt::{WdtReader, version::WowVersion};
31//!
32//! let file = File::open("path/to/map.wdt").unwrap();
33//! let mut reader = WdtReader::new(BufReader::new(file), WowVersion::WotLK);
34//! let wdt = reader.read().unwrap();
35//!
36//! println!("Map has {} tiles", wdt.count_existing_tiles());
37//! ```
38
39pub mod chunks;
40pub mod conversion;
41pub mod error;
42pub mod version;
43
44use crate::chunks::{Chunk, MaidChunk, MainChunk, ModfChunk, MphdChunk, MverChunk, MwmoChunk};
45use crate::error::{Error, Result};
46use crate::version::{VersionConfig, WowVersion};
47use std::io::{Read, Seek, SeekFrom, Write};
48
49/// A complete WDT file representation
50#[derive(Debug, Clone, PartialEq)]
51pub struct WdtFile {
52    /// Version chunk (always required)
53    pub mver: MverChunk,
54
55    /// Map header chunk (always required)
56    pub mphd: MphdChunk,
57
58    /// Map area information (always required)
59    pub main: MainChunk,
60
61    /// FileDataIDs for map files (BfA+ only)
62    pub maid: Option<MaidChunk>,
63
64    /// Global WMO filename (WMO-only maps, or pre-4.x terrain maps)
65    pub mwmo: Option<MwmoChunk>,
66
67    /// Global WMO placement (WMO-only maps)
68    pub modf: Option<ModfChunk>,
69
70    /// Version configuration for validation
71    pub version_config: VersionConfig,
72}
73
74impl WdtFile {
75    /// Create a new empty WDT file
76    pub fn new(version: WowVersion) -> Self {
77        Self {
78            mver: MverChunk::new(),
79            mphd: MphdChunk::new(),
80            main: MainChunk::new(),
81            maid: None,
82            mwmo: None,
83            modf: None,
84            version_config: VersionConfig::new(version),
85        }
86    }
87
88    /// Check if this is a WMO-only map
89    pub fn is_wmo_only(&self) -> bool {
90        self.mphd.is_wmo_only()
91    }
92
93    /// Count tiles with ADT data
94    pub fn count_existing_tiles(&self) -> usize {
95        if let Some(ref maid) = self.maid {
96            maid.count_existing_tiles()
97        } else {
98            self.main.count_existing_tiles()
99        }
100    }
101
102    /// Get tile information at coordinates
103    pub fn get_tile(&self, x: usize, y: usize) -> Option<TileInfo> {
104        let main_entry = self.main.get(x, y)?;
105
106        let has_adt = if let Some(ref maid) = self.maid {
107            maid.has_tile(x, y)
108        } else {
109            main_entry.has_adt()
110        };
111
112        Some(TileInfo {
113            x,
114            y,
115            has_adt,
116            area_id: main_entry.area_id,
117            flags: main_entry.flags,
118        })
119    }
120
121    /// Get the detected WoW version
122    pub fn version(&self) -> WowVersion {
123        self.version_config.version
124    }
125
126    /// Validate the WDT file structure
127    pub fn validate(&self) -> Vec<String> {
128        let mut warnings = Vec::new();
129
130        // Version validation
131        if self.mver.version != chunks::WDT_VERSION {
132            warnings.push(format!(
133                "Invalid WDT version: expected {}, found {}",
134                chunks::WDT_VERSION,
135                self.mver.version
136            ));
137        }
138
139        // Flag validation
140        warnings.extend(
141            self.version_config
142                .validate_mphd_flags(self.mphd.flags.bits()),
143        );
144
145        // Structure validation
146        if self.is_wmo_only() {
147            if self.mwmo.is_none() {
148                warnings.push("WMO-only map missing MWMO chunk".to_string());
149            }
150            if self.modf.is_none() {
151                warnings.push("WMO-only map missing MODF chunk".to_string());
152            }
153        } else {
154            // Terrain map validations
155            if self.modf.is_some() {
156                warnings.push("Terrain map should not have MODF chunk".to_string());
157            }
158
159            // Check MWMO presence based on version
160            let should_have_mwmo = self.version_config.should_have_chunk("MWMO", false);
161            let has_mwmo = self.mwmo.is_some();
162
163            if should_have_mwmo && !has_mwmo {
164                warnings
165                    .push("Terrain map missing expected MWMO chunk for this version".to_string());
166            } else if !should_have_mwmo && has_mwmo {
167                warnings.push("Terrain map has unexpected MWMO chunk for this version".to_string());
168            }
169        }
170
171        // MAID validation
172        if self.mphd.has_maid() && self.maid.is_none() {
173            warnings
174                .push("MPHD indicates MAID chunk should be present but it's missing".to_string());
175        } else if !self.mphd.has_maid() && self.maid.is_some() {
176            warnings.push("MAID chunk present but not indicated in MPHD flags".to_string());
177        }
178
179        warnings
180    }
181}
182
183/// Information about a specific tile
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub struct TileInfo {
186    pub x: usize,
187    pub y: usize,
188    pub has_adt: bool,
189    pub area_id: u32,
190    pub flags: u32,
191}
192
193/// WDT file reader
194pub struct WdtReader<R: Read + Seek> {
195    reader: R,
196    version: WowVersion,
197}
198
199impl<R: Read + Seek> WdtReader<R> {
200    /// Create a new WDT reader
201    pub fn new(reader: R, version: WowVersion) -> Self {
202        Self { reader, version }
203    }
204
205    /// Read a complete WDT file
206    pub fn read(&mut self) -> Result<WdtFile> {
207        let mut wdt = WdtFile::new(self.version);
208
209        // Track which chunks we've seen
210        let mut has_mver = false;
211        let mut has_mphd = false;
212        let mut has_main = false;
213
214        // Read chunks until EOF
215        loop {
216            match self.read_chunk_header() {
217                Ok((magic, size)) => {
218                    match &magic {
219                        b"REVM" => {
220                            wdt.mver = MverChunk::read(&mut self.reader, size)?;
221                            has_mver = true;
222                        }
223                        b"DHPM" => {
224                            wdt.mphd = MphdChunk::read(&mut self.reader, size)?;
225                            has_mphd = true;
226                        }
227                        b"NIAM" => {
228                            wdt.main = MainChunk::read(&mut self.reader, size)?;
229                            has_main = true;
230                        }
231                        b"DIAM" => {
232                            wdt.maid = Some(MaidChunk::read(&mut self.reader, size)?);
233                        }
234                        b"OMWM" => {
235                            wdt.mwmo = Some(MwmoChunk::read(&mut self.reader, size)?);
236                        }
237                        b"FDOM" => {
238                            wdt.modf = Some(ModfChunk::read(&mut self.reader, size)?);
239                        }
240                        _ => {
241                            // Skip unknown chunks
242                            self.reader.seek(SeekFrom::Current(size as i64))?;
243                        }
244                    }
245                }
246                Err(e) => {
247                    // Check if we hit EOF by matching on the Io variant
248                    if let Error::Io(ref io_err) = e
249                        && io_err.kind() == std::io::ErrorKind::UnexpectedEof
250                    {
251                        break;
252                    }
253                    return Err(e);
254                }
255            }
256        }
257
258        // Verify required chunks
259        if !has_mver {
260            return Err(Error::MissingChunk("MVER".to_string()));
261        }
262        if !has_mphd {
263            return Err(Error::MissingChunk("MPHD".to_string()));
264        }
265        if !has_main {
266            return Err(Error::MissingChunk("MAIN".to_string()));
267        }
268
269        // Detect actual version based on chunk presence and content
270        let detected_version = self.detect_version(&wdt);
271
272        // Update the WDT's version configuration with detected version
273        wdt.version_config = VersionConfig::new(detected_version);
274
275        Ok(wdt)
276    }
277
278    /// Detect WoW version based on chunk presence and content
279    fn detect_version(&self, wdt: &WdtFile) -> WowVersion {
280        // Version detection based on chunk presence and content:
281        // 1. MAID chunk (BfA+ 8.x+)
282        // 2. MWMO presence rules (Cataclysm+ breaking change)
283        // 3. Flag usage patterns
284
285        // Check for MAID chunk (Battle for Azeroth+)
286        if wdt.maid.is_some() {
287            return WowVersion::BfA;
288        }
289
290        // Check MWMO presence rules
291        let has_mwmo = wdt.mwmo.is_some();
292        let is_wmo_only = wdt.is_wmo_only();
293
294        // In Cataclysm+, terrain maps don't have MWMO chunks
295        // Pre-Cataclysm, all maps have MWMO chunks (even if empty)
296        if !is_wmo_only && !has_mwmo {
297            // Terrain map without MWMO = Cataclysm+
298            // Without other indicators, assume Cataclysm
299            return WowVersion::Cataclysm;
300        } else if !is_wmo_only && has_mwmo {
301            // Terrain map with MWMO = pre-Cataclysm
302            // Check flags to differentiate further
303            let flags = wdt.mphd.flags.bits();
304
305            // Advanced flags suggest later pre-Cata versions
306            if (flags & 0x0002) != 0 || (flags & 0x0004) != 0 || (flags & 0x0008) != 0 {
307                // These flags were more commonly used from WotLK
308                return WowVersion::WotLK;
309            } else if (flags & 0x0001) != 0 || flags > 1 {
310                // Basic flags suggest TBC+
311                return WowVersion::TBC;
312            } else {
313                // Minimal flags suggest Vanilla
314                return WowVersion::Classic;
315            }
316        }
317
318        // WMO-only maps should have both MWMO and MODF
319        if is_wmo_only && wdt.modf.is_some() {
320            // Both chunks present, check flag sophistication
321            let flags = wdt.mphd.flags.bits();
322            if flags > 0x000F {
323                return WowVersion::WotLK;
324            } else if flags > 1 {
325                return WowVersion::TBC;
326            } else {
327                return WowVersion::Classic;
328            }
329        }
330
331        // Default fallback based on initial version hint
332        self.version
333    }
334
335    /// Read a chunk header (magic + size)
336    fn read_chunk_header(&mut self) -> Result<([u8; 4], usize)> {
337        let mut magic = [0u8; 4];
338        self.reader.read_exact(&mut magic)?;
339
340        let mut buf = [0u8; 4];
341        self.reader.read_exact(&mut buf)?;
342        let size = u32::from_le_bytes(buf) as usize;
343
344        Ok((magic, size))
345    }
346}
347
348/// WDT file writer
349pub struct WdtWriter<W: Write> {
350    writer: W,
351}
352
353impl<W: Write> WdtWriter<W> {
354    /// Create a new WDT writer
355    pub fn new(writer: W) -> Self {
356        Self { writer }
357    }
358
359    /// Write a complete WDT file
360    pub fn write(&mut self, wdt: &WdtFile) -> Result<()> {
361        // Write required chunks in order
362        wdt.mver.write_chunk(&mut self.writer)?;
363        wdt.mphd.write_chunk(&mut self.writer)?;
364        wdt.main.write_chunk(&mut self.writer)?;
365
366        // Write optional chunks
367        if let Some(ref maid) = wdt.maid {
368            maid.write_chunk(&mut self.writer)?;
369        }
370
371        // Write MWMO only if appropriate for the version and map type
372        if let Some(ref mwmo) = wdt.mwmo {
373            let should_write = wdt
374                .version_config
375                .should_have_chunk("MWMO", wdt.is_wmo_only());
376            if should_write {
377                mwmo.write_chunk(&mut self.writer)?;
378            }
379        }
380
381        if let Some(ref modf) = wdt.modf {
382            modf.write_chunk(&mut self.writer)?;
383        }
384
385        Ok(())
386    }
387}
388
389/// Convert ADT tile coordinates to world coordinates
390pub fn tile_to_world(tile_x: u32, tile_y: u32) -> (f32, f32) {
391    const MAP_SIZE: f32 = 533.333_3;
392    const MAP_OFFSET: f32 = 32.0 * MAP_SIZE;
393
394    let world_x = MAP_OFFSET - (tile_y as f32 * MAP_SIZE);
395    let world_y = MAP_OFFSET - (tile_x as f32 * MAP_SIZE);
396
397    (world_x, world_y)
398}
399
400/// Convert world coordinates to ADT tile coordinates
401pub fn world_to_tile(world_x: f32, world_y: f32) -> (u32, u32) {
402    const MAP_SIZE: f32 = 533.333_3;
403    const MAP_OFFSET: f32 = 32.0 * MAP_SIZE;
404
405    let tile_x = ((MAP_OFFSET - world_y) / MAP_SIZE) as u32;
406    let tile_y = ((MAP_OFFSET - world_x) / MAP_SIZE) as u32;
407
408    (tile_x.min(63), tile_y.min(63))
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::io::Cursor;
415
416    #[test]
417    fn test_empty_wdt() {
418        let wdt = WdtFile::new(WowVersion::Classic);
419        assert_eq!(wdt.count_existing_tiles(), 0);
420        assert!(!wdt.is_wmo_only());
421    }
422
423    #[test]
424    fn test_wdt_read_write() {
425        let mut wdt = WdtFile::new(WowVersion::BfA);
426
427        // Set up some test data
428        wdt.mphd.flags |= chunks::MphdFlags::ADT_HAS_HEIGHT_TEXTURING;
429        wdt.main.get_mut(10, 20).unwrap().set_has_adt(true);
430        wdt.main.get_mut(10, 20).unwrap().area_id = 1234;
431
432        // Write to buffer
433        let mut buffer = Vec::new();
434        let mut writer = WdtWriter::new(&mut buffer);
435        writer.write(&wdt).unwrap();
436
437        // Read back
438        let mut reader = WdtReader::new(Cursor::new(buffer), WowVersion::BfA);
439        let read_wdt = reader.read().unwrap();
440
441        // Verify
442        assert_eq!(read_wdt.mphd.flags, wdt.mphd.flags);
443        assert_eq!(read_wdt.main.get(10, 20).unwrap().area_id, 1234);
444        assert!(read_wdt.main.get(10, 20).unwrap().has_adt());
445    }
446
447    #[test]
448    fn test_coordinate_conversion() {
449        // Test center of map
450        let (wx, wy) = tile_to_world(32, 32);
451        assert!((wx - 0.0).abs() < 0.1);
452        assert!((wy - 0.0).abs() < 0.1);
453
454        // Test reverse conversion
455        let (tx, ty) = world_to_tile(wx, wy);
456        assert_eq!(tx, 32);
457        assert_eq!(ty, 32);
458    }
459}