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}