Skip to main content

wow_wdl/
parser.rs

1//! Parser implementation for WDL files
2//!
3//! This module provides the main functionality for reading and writing WDL files.
4//! The [`WdlParser`] struct is the primary entry point for working with WDL files.
5
6use memchr::memchr;
7use std::collections::HashMap;
8use std::io::{self, Cursor, Read, Seek, SeekFrom, Write};
9
10use crate::error::{Result, WdlError};
11use crate::types::*;
12use crate::version::WdlVersion;
13
14/// Parser for WDL (World Distance Lookup) files
15///
16/// The `WdlParser` provides methods to read and write WDL files, with support for
17/// different game versions. It automatically handles the different chunk formats
18/// and structures used across World of Warcraft expansions.
19///
20/// # Examples
21///
22/// ```rust,no_run
23/// use std::fs::File;
24/// use std::io::{BufReader, BufWriter};
25/// use wow_wdl::parser::WdlParser;
26/// use wow_wdl::version::WdlVersion;
27///
28/// // Parse a WDL file
29/// let file = File::open("input.wdl").unwrap();
30/// let mut reader = BufReader::new(file);
31/// let parser = WdlParser::new();
32/// let wdl_file = parser.parse(&mut reader).unwrap();
33///
34/// // Write a WDL file
35/// let output = File::create("output.wdl").unwrap();
36/// let mut writer = BufWriter::new(output);
37/// let wotlk_parser = WdlParser::with_version(WdlVersion::Wotlk);
38/// wotlk_parser.write(&mut writer, &wdl_file).unwrap();
39/// ```
40#[derive(Debug, Default)]
41pub struct WdlParser {
42    /// The version to use for parsing
43    version: WdlVersion,
44}
45
46impl WdlParser {
47    /// Creates a new WDL parser with the latest version
48    ///
49    /// This creates a parser configured to work with the most recent
50    /// WDL format version supported by the library.
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use wow_wdl::parser::WdlParser;
56    ///
57    /// let parser = WdlParser::new();
58    /// ```
59    pub fn new() -> Self {
60        Self {
61            version: WdlVersion::Latest,
62        }
63    }
64
65    /// Creates a WDL parser with the specified version
66    ///
67    /// This allows parsing and writing WDL files using a specific game version's
68    /// format. This is useful when you know exactly which version you're working with.
69    ///
70    /// # Examples
71    ///
72    /// ```
73    /// use wow_wdl::parser::WdlParser;
74    /// use wow_wdl::version::WdlVersion;
75    ///
76    /// let parser = WdlParser::with_version(WdlVersion::Wotlk);
77    /// ```
78    pub fn with_version(version: WdlVersion) -> Self {
79        Self { version }
80    }
81
82    /// Sets the version for the parser
83    ///
84    /// This changes the version used by the parser for future operations.
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use wow_wdl::parser::WdlParser;
90    /// use wow_wdl::version::WdlVersion;
91    ///
92    /// let mut parser = WdlParser::new();
93    /// parser.set_version(WdlVersion::Legion);
94    /// ```
95    pub fn set_version(&mut self, version: WdlVersion) {
96        self.version = version;
97    }
98
99    /// Gets the current version of the parser
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use wow_wdl::parser::WdlParser;
105    /// use wow_wdl::version::WdlVersion;
106    ///
107    /// let parser = WdlParser::with_version(WdlVersion::Wotlk);
108    /// assert_eq!(parser.version(), WdlVersion::Wotlk);
109    /// ```
110    pub fn version(&self) -> WdlVersion {
111        self.version
112    }
113
114    /// Parses a WDL file from a reader
115    ///
116    /// This method reads a WDL file from the provided reader and returns a
117    /// parsed `WdlFile` structure. It automatically detects the file version
118    /// and parses all relevant chunks.
119    ///
120    /// # Arguments
121    ///
122    /// * `reader` - Any type that implements `Read + Seek`
123    ///
124    /// # Returns
125    ///
126    /// A `Result` containing either the parsed `WdlFile` or an error.
127    ///
128    /// # Examples
129    ///
130    /// ```rust,no_run
131    /// use std::fs::File;
132    /// use std::io::BufReader;
133    /// use wow_wdl::parser::WdlParser;
134    ///
135    /// let file = File::open("input.wdl").unwrap();
136    /// let mut reader = BufReader::new(file);
137    /// let parser = WdlParser::new();
138    /// let wdl_file = parser.parse(&mut reader).unwrap();
139    /// ```
140    pub fn parse<R: Read + Seek>(&self, reader: &mut R) -> Result<WdlFile> {
141        let mut file = WdlFile::new();
142        file.version = self.version;
143
144        let mut mver_found = false;
145        let mut mwmo_index = None;
146        let mut mwid_index = None;
147        let mut modf_index = None;
148        let mut maof_index = None;
149        let mut mldd_index = None;
150        let mut mldx_index = None;
151        let mut mlmd_index = None;
152        let mut mlmx_index = None;
153
154        // First, we read all chunks to get an overview of the file
155        let mut chunk_index = 0;
156        loop {
157            let chunk = match Chunk::read(reader) {
158                Ok(chunk) => chunk,
159                Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
160                Err(e) => return Err(WdlError::Io(e)),
161            };
162
163            // Check for specific chunks
164            match chunk.magic {
165                MVER_MAGIC => {
166                    mver_found = true;
167                    // Parse version number
168                    let mut cursor = Cursor::new(&chunk.data);
169                    let mut buf = [0u8; 4];
170                    cursor.read_exact(&mut buf).map_err(WdlError::Io)?;
171                    file.version_number = u32::from_le_bytes(buf);
172                }
173                MWMO_MAGIC => mwmo_index = Some(chunk_index),
174                MWID_MAGIC => mwid_index = Some(chunk_index),
175                MODF_MAGIC => modf_index = Some(chunk_index),
176                MAOF_MAGIC => maof_index = Some(chunk_index),
177                MLDD_MAGIC => mldd_index = Some(chunk_index),
178                MLDX_MAGIC => mldx_index = Some(chunk_index),
179                MLMD_MAGIC => mlmd_index = Some(chunk_index),
180                MLMX_MAGIC => mlmx_index = Some(chunk_index),
181                _ => {}
182            }
183
184            file.chunks.push(chunk);
185            chunk_index += 1;
186        }
187
188        // Check if we found the MVER chunk
189        if !mver_found {
190            return Err(WdlError::InvalidMagic {
191                expected: String::from_utf8_lossy(&MVER_MAGIC).to_string(),
192                found: "Not found".to_string(),
193            });
194        }
195
196        // Detect version based on chunks present if not explicitly set by parser
197        if self.version == WdlVersion::Latest {
198            // If we have ML* chunks, it's Legion or later
199            if mldd_index.is_some()
200                || mldx_index.is_some()
201                || mlmd_index.is_some()
202                || mlmx_index.is_some()
203            {
204                file.version = WdlVersion::Legion;
205            }
206            // If we have WMO chunks, it's pre-Legion
207            else if mwmo_index.is_some() || mwid_index.is_some() || modf_index.is_some() {
208                // Check for MAHO to distinguish WotLK+ from Vanilla
209                if file.chunks.iter().any(|c| c.magic == MAHO_MAGIC) {
210                    file.version = WdlVersion::Wotlk;
211                } else {
212                    file.version = WdlVersion::Vanilla;
213                }
214            }
215            // Otherwise keep the parser's version
216        }
217
218        // Parse MWMO chunk (WMO filenames)
219        if let Some(index) = mwmo_index {
220            let chunk = &file.chunks[index];
221            file.wmo_filenames = self.parse_zero_terminated_strings(&chunk.data)?;
222        }
223
224        // Parse MWID chunk (WMO indices)
225        if let Some(index) = mwid_index {
226            let chunk = &file.chunks[index];
227            let mut cursor = Cursor::new(&chunk.data);
228
229            while cursor.position() < chunk.data.len() as u64 {
230                let mut buf = [0u8; 4];
231                match cursor.read_exact(&mut buf) {
232                    Ok(_) => file.wmo_indices.push(u32::from_le_bytes(buf)),
233                    Err(_) => break,
234                }
235            }
236        }
237
238        // Parse MODF chunk (WMO placements)
239        if let Some(index) = modf_index {
240            let chunk = &file.chunks[index];
241            let mut cursor = Cursor::new(&chunk.data);
242
243            while cursor.position() < chunk.data.len() as u64 {
244                match ModelPlacement::read(&mut cursor) {
245                    Ok(placement) => file.wmo_placements.push(placement),
246                    Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
247                    Err(e) => return Err(WdlError::Io(e)),
248                }
249            }
250        }
251
252        // Parse MAOF chunk (Map tile offsets)
253        if let Some(index) = maof_index {
254            let chunk = &file.chunks[index];
255            let mut cursor = Cursor::new(&chunk.data);
256
257            for i in 0..64 * 64 {
258                let mut buf = [0u8; 4];
259                cursor.read_exact(&mut buf).map_err(WdlError::Io)?;
260                file.map_tile_offsets[i] = u32::from_le_bytes(buf);
261            }
262
263            // Now parse the MARE and MAHO chunks using the offsets
264            self.parse_map_tiles(reader, &mut file)?;
265        }
266
267        // Parse Legion+ chunks
268        if file.version.has_ml_chunks() {
269            // Parse MLDD chunk (M2 placements)
270            if let Some(index) = mldd_index {
271                let chunk = &file.chunks[index];
272                let mut cursor = Cursor::new(&chunk.data);
273
274                while cursor.position() < chunk.data.len() as u64 {
275                    match M2Placement::read(&mut cursor) {
276                        Ok(placement) => file.m2_placements.push(placement),
277                        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
278                        Err(e) => return Err(WdlError::Io(e)),
279                    }
280                }
281            }
282
283            // Parse MLDX chunk (M2 visibility info)
284            if let Some(index) = mldx_index {
285                let chunk = &file.chunks[index];
286                let mut cursor = Cursor::new(&chunk.data);
287
288                while cursor.position() < chunk.data.len() as u64 {
289                    match M2VisibilityInfo::read(&mut cursor) {
290                        Ok(info) => file.m2_visibility.push(info),
291                        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
292                        Err(e) => return Err(WdlError::Io(e)),
293                    }
294                }
295            }
296
297            // Parse MLMD chunk (WMO Legion placements)
298            if let Some(index) = mlmd_index {
299                let chunk = &file.chunks[index];
300                let mut cursor = Cursor::new(&chunk.data);
301
302                while cursor.position() < chunk.data.len() as u64 {
303                    match M2Placement::read(&mut cursor) {
304                        Ok(placement) => file.wmo_legion_placements.push(placement),
305                        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
306                        Err(e) => return Err(WdlError::Io(e)),
307                    }
308                }
309            }
310
311            // Parse MLMX chunk (WMO Legion visibility info)
312            if let Some(index) = mlmx_index {
313                let chunk = &file.chunks[index];
314                let mut cursor = Cursor::new(&chunk.data);
315
316                while cursor.position() < chunk.data.len() as u64 {
317                    match M2VisibilityInfo::read(&mut cursor) {
318                        Ok(info) => file.wmo_legion_visibility.push(info),
319                        Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
320                        Err(e) => return Err(WdlError::Io(e)),
321                    }
322                }
323            }
324        }
325
326        // Validate the file
327        file.validate()?;
328
329        Ok(file)
330    }
331
332    /// Writes a WDL file to a writer
333    ///
334    /// This method writes a `WdlFile` structure to the provided writer using
335    /// the format specified by the parser's version.
336    ///
337    /// # Arguments
338    ///
339    /// * `writer` - Any type that implements `Write + Seek`
340    /// * `file` - The `WdlFile` to write
341    ///
342    /// # Returns
343    ///
344    /// A `Result` indicating success or an error.
345    ///
346    /// # Examples
347    ///
348    /// ```rust,no_run
349    /// use std::fs::File;
350    /// use std::io::BufWriter;
351    /// use wow_wdl::parser::WdlParser;
352    /// use wow_wdl::types::WdlFile;
353    /// use wow_wdl::version::WdlVersion;
354    ///
355    /// // Create a new WDL file
356    /// let file = WdlFile::with_version(WdlVersion::Wotlk);
357    ///
358    /// // Write the file
359    /// let output = File::create("output.wdl").unwrap();
360    /// let mut writer = BufWriter::new(output);
361    /// let parser = WdlParser::with_version(WdlVersion::Wotlk);
362    /// parser.write(&mut writer, &file).unwrap();
363    /// ```
364    pub fn write<W: Write + Seek>(&self, writer: &mut W, file: &WdlFile) -> Result<()> {
365        // We'll build the file in memory first to calculate offsets
366        let mut chunks = Vec::new();
367
368        // Write MVER chunk
369        let mut mver_data = Vec::new();
370        mver_data
371            .write_all(&file.version.version_number().to_le_bytes())
372            .map_err(WdlError::Io)?;
373        chunks.push(Chunk::new(MVER_MAGIC, mver_data));
374
375        // Write WMO chunks if supported
376        if file.version.has_wmo_chunks() && !file.wmo_filenames.is_empty() {
377            // Write MWMO chunk (WMO filenames)
378            let mut mwmo_data = Vec::new();
379            for name in &file.wmo_filenames {
380                mwmo_data.extend_from_slice(name.as_bytes());
381                mwmo_data.push(0); // Null terminator
382            }
383            chunks.push(Chunk::new(MWMO_MAGIC, mwmo_data));
384
385            // Write MWID chunk (WMO indices)
386            let mut mwid_data = Vec::new();
387            for &idx in &file.wmo_indices {
388                mwid_data
389                    .write_all(&idx.to_le_bytes())
390                    .map_err(WdlError::Io)?;
391            }
392            chunks.push(Chunk::new(MWID_MAGIC, mwid_data));
393
394            // Write MODF chunk (WMO placements)
395            let mut modf_data = Vec::new();
396            for placement in &file.wmo_placements {
397                placement.write(&mut modf_data).map_err(WdlError::Io)?;
398            }
399            chunks.push(Chunk::new(MODF_MAGIC, modf_data));
400        }
401
402        // Write Legion+ chunks if supported
403        if file.version.has_ml_chunks() {
404            // Write MLDD chunk (M2 placements)
405            if !file.m2_placements.is_empty() {
406                let mut mldd_data = Vec::new();
407                for placement in &file.m2_placements {
408                    placement.write(&mut mldd_data).map_err(WdlError::Io)?;
409                }
410                chunks.push(Chunk::new(MLDD_MAGIC, mldd_data));
411            }
412
413            // Write MLDX chunk (M2 visibility info)
414            if !file.m2_visibility.is_empty() {
415                let mut mldx_data = Vec::new();
416                for info in &file.m2_visibility {
417                    info.write(&mut mldx_data).map_err(WdlError::Io)?;
418                }
419                chunks.push(Chunk::new(MLDX_MAGIC, mldx_data));
420            }
421
422            // Write MLMD chunk (WMO Legion placements)
423            if !file.wmo_legion_placements.is_empty() {
424                let mut mlmd_data = Vec::new();
425                for placement in &file.wmo_legion_placements {
426                    placement.write(&mut mlmd_data).map_err(WdlError::Io)?;
427                }
428                chunks.push(Chunk::new(MLMD_MAGIC, mlmd_data));
429            }
430
431            // Write MLMX chunk (WMO Legion visibility info)
432            if !file.wmo_legion_visibility.is_empty() {
433                let mut mlmx_data = Vec::new();
434                for info in &file.wmo_legion_visibility {
435                    info.write(&mut mlmx_data).map_err(WdlError::Io)?;
436                }
437                chunks.push(Chunk::new(MLMX_MAGIC, mlmx_data));
438            }
439        }
440
441        // Now we need to determine the positions of the MARE and MAHO chunks
442        // So we can write the correct offsets in the MAOF chunk
443        let mut map_tile_offsets = [0u32; 64 * 64];
444        let mut mare_chunks = HashMap::new();
445        let mut maho_chunks = HashMap::new();
446
447        // Calculate the base offset for the MARE and MAHO chunks
448        // which is right after the MAOF chunk
449        let mut current_offset = 0;
450
451        // Account for all chunks written so far, plus the MAOF chunk
452        for chunk in &chunks {
453            current_offset += 8 + chunk.size; // 8 bytes for magic and size
454        }
455
456        // Add the MAOF chunk size
457        current_offset += 8 + (64 * 64 * 4); // 8 bytes for magic and size, 4 bytes per offset
458
459        // Now calculate offsets for each map tile
460        for y in 0..64 {
461            for x in 0..64 {
462                let index = y * 64 + x;
463                let key = (x as u32, y as u32);
464
465                // Skip empty tiles
466                if !file.heightmap_tiles.contains_key(&key) {
467                    map_tile_offsets[index] = 0;
468                    continue;
469                }
470
471                // Set the offset for this tile
472                map_tile_offsets[index] = current_offset;
473
474                // Create MARE chunk
475                let heightmap = file.heightmap_tiles.get(&key).unwrap();
476                let mut mare_data = Vec::new();
477                heightmap.write(&mut mare_data).map_err(WdlError::Io)?;
478                mare_chunks.insert(key, Chunk::new(MARE_MAGIC, mare_data));
479
480                // Update offset for next chunk
481                current_offset += 8 + (HeightMapTile::TOTAL_COUNT * 2) as u32; // 8 bytes for magic and size, 2 bytes per height value
482
483                // Create MAHO chunk if needed
484                if file.version.has_maho_chunk()
485                    && let Some(holes) = file.holes_data.get(&key)
486                {
487                    let mut maho_data = Vec::new();
488                    holes.write(&mut maho_data).map_err(WdlError::Io)?;
489                    maho_chunks.insert(key, Chunk::new(MAHO_MAGIC, maho_data));
490
491                    // Update offset for next chunk
492                    current_offset += 8 + (HolesData::MASK_COUNT * 2) as u32;
493                    // 8 bytes for magic and size, 2 bytes per mask
494                }
495            }
496        }
497
498        // Write MAOF chunk (Map tile offsets)
499        let mut maof_data = Vec::new();
500        for &offset in &map_tile_offsets {
501            maof_data
502                .write_all(&offset.to_le_bytes())
503                .map_err(WdlError::Io)?;
504        }
505        chunks.push(Chunk::new(MAOF_MAGIC, maof_data));
506
507        // Write all the chunks that we've prepared
508        for chunk in &chunks {
509            chunk.write(writer).map_err(WdlError::Io)?;
510        }
511
512        // Write the MARE and MAHO chunks for each map tile
513        for y in 0..64 {
514            for x in 0..64 {
515                let key = (x as u32, y as u32);
516
517                // Skip empty tiles
518                if !mare_chunks.contains_key(&key) {
519                    continue;
520                }
521
522                // Write MARE chunk
523                mare_chunks
524                    .get(&key)
525                    .unwrap()
526                    .write(writer)
527                    .map_err(WdlError::Io)?;
528
529                // Write MAHO chunk if present
530                if let Some(maho_chunk) = maho_chunks.get(&key) {
531                    maho_chunk.write(writer).map_err(WdlError::Io)?;
532                }
533            }
534        }
535
536        Ok(())
537    }
538
539    /// Parses zero-terminated strings from a buffer
540    ///
541    /// Internal helper method to parse null-terminated strings from a byte buffer.
542    ///
543    /// # Arguments
544    ///
545    /// * `data` - The byte buffer containing the strings
546    ///
547    /// # Returns
548    ///
549    /// A `Result` containing a vector of strings or an error.
550    fn parse_zero_terminated_strings(&self, data: &[u8]) -> Result<Vec<String>> {
551        let mut strings = Vec::new();
552        let mut start = 0;
553
554        while start < data.len() {
555            match memchr(0, &data[start..]) {
556                Some(end) => {
557                    match String::from_utf8(data[start..start + end].to_vec()) {
558                        Ok(s) => strings.push(s),
559                        Err(_) => {
560                            return Err(WdlError::ParseError(
561                                "Invalid UTF-8 in string".to_string(),
562                            ));
563                        }
564                    }
565                    start += end + 1; // Skip the null terminator
566                }
567                None => break,
568            }
569        }
570
571        Ok(strings)
572    }
573
574    /// Parses map tiles (MARE and MAHO chunks) from the reader
575    ///
576    /// Internal helper method to parse map tile data referenced by offsets in the MAOF chunk.
577    ///
578    /// # Arguments
579    ///
580    /// * `reader` - The reader containing the file data
581    /// * `file` - The WdlFile being populated
582    ///
583    /// # Returns
584    ///
585    /// A `Result` indicating success or an error.
586    fn parse_map_tiles<R: Read + Seek>(&self, reader: &mut R, file: &mut WdlFile) -> Result<()> {
587        for y in 0..64 {
588            for x in 0..64 {
589                let index = y * 64 + x;
590                let offset = file.map_tile_offsets[index];
591
592                if offset == 0 {
593                    continue; // No data for this tile
594                }
595
596                // Seek to the offset
597                reader
598                    .seek(SeekFrom::Start(offset as u64))
599                    .map_err(WdlError::Io)?;
600
601                // Read the MARE chunk
602                let chunk = Chunk::read(reader).map_err(WdlError::Io)?;
603
604                if chunk.magic != MARE_MAGIC {
605                    return Err(WdlError::UnexpectedChunk(
606                        String::from_utf8_lossy(&chunk.magic).to_string(),
607                    ));
608                }
609
610                // Parse the heightmap
611                let mut cursor = Cursor::new(&chunk.data);
612                let heightmap = HeightMapTile::read(&mut cursor).map_err(WdlError::Io)?;
613
614                file.heightmap_tiles.insert((x as u32, y as u32), heightmap);
615
616                // Check for MAHO chunk
617                if self.version.has_maho_chunk() {
618                    match Chunk::read(reader) {
619                        Ok(chunk) => {
620                            if chunk.magic == MAHO_MAGIC {
621                                let mut cursor = Cursor::new(&chunk.data);
622                                let holes = HolesData::read(&mut cursor).map_err(WdlError::Io)?;
623
624                                file.holes_data.insert((x as u32, y as u32), holes);
625                            } else {
626                                // Seek back, this wasn't a MAHO chunk
627                                reader
628                                    .seek(SeekFrom::Current(-(8 + chunk.size as i64)))
629                                    .map_err(WdlError::Io)?;
630                            }
631                        }
632                        Err(_) => {
633                            // No MAHO chunk, that's fine
634                        }
635                    }
636                }
637            }
638        }
639
640        Ok(())
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use std::io::Cursor;
648
649    #[test]
650    fn test_parse_zero_terminated_strings() {
651        let parser = WdlParser::new();
652
653        let data = b"test1\0test2\0test3\0";
654        let strings = parser.parse_zero_terminated_strings(data).unwrap();
655
656        assert_eq!(strings.len(), 3);
657        assert_eq!(strings[0], "test1");
658        assert_eq!(strings[1], "test2");
659        assert_eq!(strings[2], "test3");
660    }
661
662    #[test]
663    fn test_empty_write_read() {
664        let parser = WdlParser::new();
665        let file = WdlFile::new();
666
667        let mut buffer = Vec::new();
668        let mut cursor = Cursor::new(&mut buffer);
669        parser.write(&mut cursor, &file).unwrap();
670
671        let mut cursor = Cursor::new(buffer);
672        let parsed_file = parser.parse(&mut cursor).unwrap();
673
674        assert_eq!(parsed_file.version, file.version);
675        assert_eq!(parsed_file.version_number, file.version_number);
676    }
677}