wow-adt 0.7.0

Parser for World of Warcraft ADT terrain files with heightmap and texture layer support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
//! Complete MCNK chunk with two-level parsing.
//!
//! MCNK chunks use a two-level discovery approach:
//! 1. Parse the 128-byte header to get subchunk offsets
//! 2. Selectively parse subchunks based on header flags and offsets
//!
//! This allows efficient partial parsing - only loading subchunks that are needed.
//!
//! # Cross-Reference Validation
//!
//! This implementation has been cross-referenced with:
//! - **noggit-red**: MapChunk.cpp/MapHeaders.h (WotLK 3.3.5a) - ✅ Validated
//!   - MCNK header: All 128 bytes match specification (MapHeaders.h:127-156)
//!   - MCVT parsing: 145 f32 heights confirmed (MapChunk.cpp:166-190)
//!   - MCNR parsing: 145 × 3-byte normals confirmed (MapChunk.cpp:202-215)
//!   - Offset handling: Relative to MCNK chunk start (MapChunk.cpp:167)
//!   - Reference: github.com/Marlamin/noggit-red
//! - **wowdev.wiki**: ADT/v18 specification
//!   - All field offsets, sizes, and interpretations validated
//!   - Reference: <https://wowdev.wiki/ADT/v18#MCNK_header>
//!
//! ## Known Deviations
//! - Field naming: warcraft-rs uses descriptive names vs noggit-red abbreviations
//! - Semantic interpretation: `pred_tex`/`no_effect_doodad` vs `doodadMapping`/`doodadStencil`
//!   (warcraft-rs interpretation matches wowdev.wiki specification)
//!
//! ## Validation Date
//! - 2025-10-30: Cross-reference completed, HIGH confidence
//!
//! See: `/specs/001-adt-binrw-refactor/CROSS_REFERENCE_MCNK.md` for full analysis

use crate::chunk_header::ChunkHeader;
use crate::chunk_id::ChunkId;
use binrw::{BinRead, BinResult};
use std::io::{Read, Seek, SeekFrom};

use super::header::McnkHeader;
use super::mcal::McalChunk;
use super::mcbb::McbbChunk;
use super::mccv::MccvChunk;
use super::mcdd::McddChunk;
use super::mclq::MclqChunk;
use super::mclv::MclvChunk;
use super::mcly::MclyChunk;
use super::mcmt::McmtChunk;
use super::mcnr::McnrChunk;
use super::mcrd::McrdChunk;
use super::mcrf::McrfChunk;
use super::mcrw::McrwChunk;
use super::mcse::McseChunk;
use super::mcsh::McshChunk;
use super::mcvt::McvtChunk;

/// Complete MCNK terrain chunk with optional subchunks (Vanilla+)
///
/// Represents a 16×16 yard terrain tile within an ADT file. MCNK uses a two-level
/// parsing strategy:
///
/// **Level 1:** Parse 128-byte header containing subchunk offsets
/// **Level 2:** Selectively parse subchunks based on header flags
///
/// This approach allows efficient partial parsing - only load subchunks you need.
///
/// # Example
///
/// ```rust,no_run
/// use std::fs::File;
/// use std::io::BufReader;
/// use wow_adt::chunks::mcnk::McnkChunk;
/// use binrw::BinRead;
///
/// # fn example() -> binrw::BinResult<()> {
/// let file = File::open("terrain.adt")?;
/// let mut reader = BufReader::new(file);
///
/// // Parse MCNK chunk (automatically parses header + subchunks)
/// // Note: offset should be the position where MCNK chunk starts
/// let mcnk = McnkChunk::parse_with_offset(&mut reader, 0)?;
///
/// // Access terrain data
/// if let Some(heights) = &mcnk.heights {
///     println!("Min height: {:?}", heights.min_height());
/// }
///
/// if let Some(layers) = &mcnk.layers {
///     println!("Texture layers: {}", layers.layer_count());
/// }
/// # Ok(())
/// # }
/// ```
///
/// ## Version Support
///
/// - **Vanilla (1.12.1)**: ✅ Introduced
/// - **TBC (2.4.3)**: ✅ Present
/// - **WotLK (3.3.5a)**: ✅ Present
/// - **Cataclysm (4.3.4)**: ✅ Present
/// - **MoP (5.4.8)**: ✅ Present
///
/// Reference: <https://wowdev.wiki/ADT/v18#MCNK_chunk>
#[derive(Debug, Clone)]
pub struct McnkChunk {
    /// MCNK header (128 bytes)
    pub header: McnkHeader,

    /// MCVT: Vertex heights (145 f32 values)
    pub heights: Option<McvtChunk>,

    /// MCNR: Vertex normals (145 entries + padding)
    pub normals: Option<McnrChunk>,

    /// MCLY: Texture layers (up to 4 entries)
    pub layers: Option<MclyChunk>,

    /// MCMT: Terrain material IDs (split files, Cataclysm+)
    pub materials: Option<McmtChunk>,

    /// MCRF: Object references (doodad + WMO indices, pre-Cataclysm)
    pub refs: Option<McrfChunk>,

    /// MCRD: Doodad references (split files, Cataclysm+)
    pub doodad_refs: Option<McrdChunk>,

    /// MCRW: WMO references (split files, Cataclysm+)
    pub wmo_refs: Option<McrwChunk>,

    /// MCAL: Alpha maps for texture blending
    pub alpha: Option<McalChunk>,

    /// MCSH: Shadow map (512 bytes)
    pub shadow: Option<McshChunk>,

    /// MCCV: Vertex colors (145 BGRA entries, WotLK+)
    pub vertex_colors: Option<MccvChunk>,

    /// MCLV: Vertex lighting (145 ARGB entries, Cataclysm+)
    pub vertex_lighting: Option<MclvChunk>,

    /// MCSE: Sound emitters
    pub sound_emitters: Option<McseChunk>,

    /// MCLQ: Legacy liquid (pre-WotLK, deprecated)
    pub liquid: Option<MclqChunk>,

    /// MCDD: Doodad disable bitmap (WoD+)
    pub doodad_disable: Option<McddChunk>,

    /// MCBB: Blend batches (MoP+)
    pub blend_batches: Option<McbbChunk>,
}

impl McnkChunk {
    /// Parse MCNK chunk with selective subchunk loading.
    ///
    /// Reads the header first, then parses subchunks based on offsets and flags.
    /// Offsets in the header are relative to the MCNK chunk start (including
    /// the 8-byte chunk header).
    ///
    /// # Arguments
    ///
    /// * `reader` - Reader positioned at MCNK chunk data (after chunk header)
    /// * `mcnk_start_offset` - File offset where MCNK chunk header begins
    ///
    /// # Returns
    ///
    /// Parsed MCNK chunk with populated subchunks
    pub fn parse_with_offset<R: Read + Seek>(
        reader: &mut R,
        mcnk_start_offset: u64,
    ) -> BinResult<Self> {
        // Default chunk size for backwards compatibility (256KB should be plenty)
        Self::parse_with_offset_and_size(reader, mcnk_start_offset, 0x40000)
    }

    /// Parse MCNK chunk with known chunk size.
    ///
    /// For MoP 5.3+ with high_res_holes flag, the MCVT/MCNR offsets are not stored
    /// in the header. This variant accepts the chunk size to enable scanning for
    /// subchunks in that case.
    ///
    /// # Arguments
    ///
    /// * `reader` - Reader positioned at MCNK chunk data (after chunk header)
    /// * `mcnk_start_offset` - File offset where MCNK chunk header begins
    /// * `mcnk_size` - Total size of MCNK chunk data (excluding 8-byte header)
    ///
    /// # Returns
    ///
    /// Parsed MCNK chunk with populated subchunks
    pub fn parse_with_offset_and_size<R: Read + Seek>(
        reader: &mut R,
        mcnk_start_offset: u64,
        mcnk_size: u32,
    ) -> BinResult<Self> {
        // Read 128-byte header
        let header = McnkHeader::read_le(reader)?;

        // Parse subchunks based on header offsets
        // For Cata+ split files or MoP with high_res_holes, offsets may be 0 - fall back to scanning
        let heights = if header.has_height() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_height(), "MCVT")?;
            if !data.is_empty() {
                Some(McvtChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            // Offset is 0 - try scanning for MCVT within chunk bounds
            // This handles MoP 5.3+ high_res_holes and Cata+ split files
            let data = scan_for_subchunk(reader, mcnk_start_offset, mcnk_size, ChunkId::MCVT)?;
            if !data.is_empty() {
                Some(McvtChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        };

        let normals = if header.has_normal() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_normal(), "MCNR")?;
            if !data.is_empty() {
                Some(McnrChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            // Offset is 0 - try scanning for MCNR within chunk bounds
            // This handles MoP 5.3+ high_res_holes and Cata+ split files
            let data = scan_for_subchunk(reader, mcnk_start_offset, mcnk_size, ChunkId::MCNR)?;
            if !data.is_empty() {
                Some(McnrChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        };

        let layers = if header.has_layer() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_layer, "MCLY")?;
            if !data.is_empty() {
                Some(MclyChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        // MCMT has no dedicated offset in MCNK header
        // Found via chunk discovery in _tex.adt files (Cataclysm+)
        // TODO: Add split file support with chunk discovery
        let materials = None;

        let refs = if header.has_refs() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_refs, "MCRF")?;
            if !data.is_empty() {
                Some(McrfChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        // MCRD shares ofs_refs with MCRF (Cataclysm+ split files)
        // TODO: Add version/file-type detection to distinguish MCRF vs MCRD
        let doodad_refs = if header.has_refs() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_refs, "MCRF")?;
            if !data.is_empty() {
                Some(McrdChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        // MCRW shares ofs_refs with MCRF (Cataclysm+ split files)
        // TODO: Add version/file-type detection to distinguish MCRF vs MCRD/MCRW
        let wmo_refs = if header.has_refs() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_refs, "MCRF")?;
            if !data.is_empty() {
                Some(McrwChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        // MCAL and MCSH use size from MCNK header, not from subchunk header
        // Some early Vanilla files have corrupted size values in subchunk headers
        let alpha = if header.has_alpha() {
            let data = read_subchunk_with_size(
                reader,
                mcnk_start_offset,
                header.ofs_alpha,
                header.size_alpha,
                "MCAL",
            )?;
            if !data.is_empty() {
                Some(McalChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        let shadow = if header.has_shadow() {
            let data = read_subchunk_with_size(
                reader,
                mcnk_start_offset,
                header.ofs_shadow,
                header.size_shadow,
                "MCSH",
            )?;
            if !data.is_empty() {
                Some(McshChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        let vertex_colors = if header.has_vertex_colors() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_mccv, "MCCV")?;
            if !data.is_empty() {
                Some(MccvChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        let vertex_lighting = if header.has_baked_lighting() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_mclv, "MCLV")?;
            if !data.is_empty() {
                Some(MclvChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        let sound_emitters = if header.has_sound_emitters() {
            let data = read_subchunk(reader, mcnk_start_offset, header.ofs_snd_emitters, "MCSE")?;
            if !data.is_empty() {
                Some(McseChunk::read_le(&mut std::io::Cursor::new(data))?)
            } else {
                None
            }
        } else {
            None
        };

        // MCLQ is a special case: the size is stored in MCNK header (size_liquid),
        // NOT in the MCLQ chunk header (which always has size=0).
        let liquid = if header.has_legacy_liquid() {
            // Seek to MCLQ chunk
            let mclq_pos = mcnk_start_offset + u64::from(header.ofs_liquid);
            reader
                .seek(SeekFrom::Start(mclq_pos))
                .map_err(|e| binrw::Error::Custom {
                    pos: mclq_pos,
                    err: Box::new(format!(
                        "Failed to seek to MCLQ at offset {}: {}",
                        header.ofs_liquid, e
                    )),
                })?;

            // Read the 8-byte chunk header (magic + size, where size is always 0)
            let _chunk_header = ChunkHeader::read_le(reader)?;

            // Read the actual data using size_liquid from MCNK header
            let mut data = vec![0u8; header.size_liquid as usize];
            reader.read_exact(&mut data)?;

            if !data.is_empty() {
                // Pass MCNK flags to MCLQ parser for liquid type detection
                // Note: Small MCLQ chunks (8 bytes) are often corrupted/empty placeholders
                // We catch parsing errors and treat them as "no liquid"
                MclqChunk::read_le_args(&mut std::io::Cursor::new(data), header.flags.value).ok() // Silently skip corrupted/placeholder chunks
            } else {
                None
            }
        } else {
            None
        };

        // MCDD has no dedicated offset in MCNK header
        // Found via chunk discovery in root ADT files (WoD+)
        // TODO: Add chunk discovery support for MCDD
        let doodad_disable = None;

        Ok(Self {
            header,
            heights,
            normals,
            layers,
            materials,
            refs,
            doodad_refs,
            wmo_refs,
            alpha,
            shadow,
            vertex_colors,
            vertex_lighting,
            sound_emitters,
            liquid,
            doodad_disable,
            blend_batches: None, // TODO: Parse MCBB from chunk discovery
        })
    }

    /// Check if chunk has valid height data.
    pub fn has_heights(&self) -> bool {
        self.heights.is_some()
    }

    /// Check if chunk has valid normal data.
    pub fn has_normals(&self) -> bool {
        self.normals.is_some()
    }

    /// Check if chunk has texture layers.
    pub fn has_layers(&self) -> bool {
        self.layers.is_some()
    }

    /// Check if chunk has object references.
    pub fn has_refs(&self) -> bool {
        self.refs.is_some()
    }

    /// Check if chunk has alpha maps.
    pub fn has_alpha(&self) -> bool {
        self.alpha.is_some()
    }

    /// Check if chunk has shadow map.
    pub fn has_shadow(&self) -> bool {
        self.shadow.is_some()
    }

    /// Check if chunk has vertex colors.
    pub fn has_vertex_colors(&self) -> bool {
        self.vertex_colors.is_some()
    }

    /// Check if chunk has sound emitters.
    pub fn has_sound_emitters(&self) -> bool {
        self.sound_emitters.is_some()
    }

    /// Check if chunk has legacy liquid data.
    pub fn has_liquid(&self) -> bool {
        self.liquid.is_some()
    }

    /// Validate that header flags match subchunk presence.
    pub fn validate_consistency(&self) -> bool {
        // Check that header flags match actual subchunk presence
        (self.header.has_height() == self.has_heights())
            && (self.header.has_normal() == self.has_normals())
            && (self.header.has_layer() == self.has_layers())
            && (self.header.has_shadow() == self.has_shadow())
            && (self.header.has_vertex_colors() == self.has_vertex_colors())
            && (self.header.has_legacy_liquid() == self.has_liquid())
    }
}

/// Read a subchunk from within an MCNK chunk.
///
/// Seeks to the specified offset (relative to MCNK chunk start), reads the
/// subchunk header, and returns the subchunk data.
fn read_subchunk<R: Read + Seek>(
    reader: &mut R,
    mcnk_start_offset: u64,
    offset: u32,
    name: &str,
) -> BinResult<Vec<u8>> {
    if offset == 0 {
        return Ok(Vec::new());
    }

    // Seek to subchunk (offset is relative to MCNK chunk start)
    let subchunk_pos = mcnk_start_offset + u64::from(offset);
    reader
        .seek(SeekFrom::Start(subchunk_pos))
        .map_err(|e| binrw::Error::Custom {
            pos: subchunk_pos,
            err: Box::new(format!(
                "Failed to seek to {} subchunk at offset {}: {}",
                name, offset, e
            )),
        })?;

    // Read subchunk header
    let subchunk_header = ChunkHeader::read_le(reader).map_err(|e| binrw::Error::Custom {
        pos: subchunk_pos,
        err: Box::new(format!(
            "Failed to read {} subchunk header at file offset {} (MCNK+{}): {:?}",
            name, subchunk_pos, offset, e
        )),
    })?;

    // Read subchunk data
    let mut data = vec![0u8; subchunk_header.size as usize];
    reader.read_exact(&mut data)?;

    Ok(data)
}

/// Read a subchunk with a known expected size.
///
/// Similar to `read_subchunk`, but uses the provided expected_size instead of reading
/// the size from the subchunk header. This is useful for chunks like MCAL and MCSH
/// where the MCNK header provides the correct size, but the subchunk header may
/// contain garbage values (especially in early Vanilla files).
///
/// The function still validates that the subchunk magic matches the expected name.
fn read_subchunk_with_size<R: Read + Seek>(
    reader: &mut R,
    mcnk_start_offset: u64,
    offset: u32,
    expected_size: u32,
    name: &str,
) -> BinResult<Vec<u8>> {
    if offset == 0 || expected_size == 0 {
        return Ok(Vec::new());
    }

    // Seek to subchunk (offset is relative to MCNK chunk start)
    let subchunk_pos = mcnk_start_offset + u64::from(offset);
    reader
        .seek(SeekFrom::Start(subchunk_pos))
        .map_err(|e| binrw::Error::Custom {
            pos: subchunk_pos,
            err: Box::new(format!(
                "Failed to seek to {} subchunk at offset {}: {}",
                name, offset, e
            )),
        })?;

    // Read subchunk header (magic + size) but only validate magic
    let subchunk_header = ChunkHeader::read_le(reader).map_err(|e| binrw::Error::Custom {
        pos: subchunk_pos,
        err: Box::new(format!(
            "Failed to read {} subchunk header at file offset {} (MCNK+{}): {:?}",
            name, subchunk_pos, offset, e
        )),
    })?;

    // Validate magic matches expected chunk name
    // ChunkId::as_str() returns the normalized name (e.g., "MCAL"), not the reversed form
    if subchunk_header.id.as_str() != name {
        log::warn!(
            "{} subchunk at 0x{:x} has unexpected magic '{}' (expected '{}')",
            name,
            subchunk_pos,
            subchunk_header.id.as_str(),
            name
        );
    }

    // Use provided size (from MCNK header) instead of subchunk header size
    // Log a warning if they differ significantly
    if subchunk_header.size != expected_size && subchunk_header.size < 0x100000 {
        log::debug!(
            "{} subchunk size mismatch: header says 0x{:x}, MCNK header says 0x{:x}",
            name,
            subchunk_header.size,
            expected_size
        );
    }

    // Read subchunk data using the expected size
    let mut data = vec![0u8; expected_size as usize];
    reader.read_exact(&mut data)?;

    Ok(data)
}

/// Scan for a subchunk by chunk ID (used for MoP 5.3+ when offsets aren't in header).
///
/// When `high_res_holes` flag is set, the MCNK header's `multipurpose_field` contains
/// holes data instead of MCVT/MCNR offsets. This function scans the chunk data
/// sequentially to find the subchunks by their magic bytes.
fn scan_for_subchunk<R: Read + Seek>(
    reader: &mut R,
    mcnk_start_offset: u64,
    mcnk_size: u32,
    target_id: ChunkId,
) -> BinResult<Vec<u8>> {
    // Scan from after the 128-byte header to end of chunk
    let scan_start = mcnk_start_offset + 8 + 128; // 8-byte chunk header + 128-byte MCNK header
    let scan_end = mcnk_start_offset + 8 + u64::from(mcnk_size);

    let mut pos = scan_start;
    while pos + 8 <= scan_end {
        reader
            .seek(SeekFrom::Start(pos))
            .map_err(|e| binrw::Error::Custom {
                pos,
                err: Box::new(format!("Failed to seek during subchunk scan: {}", e)),
            })?;

        // Read potential chunk header
        let subchunk_header = match ChunkHeader::read_le(reader) {
            Ok(h) => h,
            Err(_) => {
                pos += 1;
                continue;
            }
        };

        if subchunk_header.id == target_id {
            // Found it! Read the data
            let mut data = vec![0u8; subchunk_header.size as usize];
            reader.read_exact(&mut data)?;
            return Ok(data);
        }

        // Move to next potential chunk (header + data)
        pos += 8 + u64::from(subchunk_header.size);
    }

    Ok(Vec::new()) // Not found
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    /// Create minimal MCNK chunk data for testing.
    fn create_test_mcnk_data() -> Vec<u8> {
        let mut data = Vec::new();

        // MCNK chunk header (8 bytes)
        data.extend_from_slice(b"MCNK"); // Magic
        data.extend_from_slice(&500u32.to_le_bytes()); // Size (will be updated)

        // MCNK header (128 bytes) - starts at offset 8
        let header_start = data.len();

        // flags (0x41 = has_mcsh | has_mccv)
        data.extend_from_slice(&0x0041u32.to_le_bytes());

        // index_x, index_y
        data.extend_from_slice(&5u32.to_le_bytes());
        data.extend_from_slice(&7u32.to_le_bytes());

        // n_layers
        data.extend_from_slice(&2u32.to_le_bytes());

        // n_doodad_refs
        data.extend_from_slice(&0u32.to_le_bytes());

        // multipurpose_field: ofs_height (136) + ofs_normal (0)
        // (8 bytes total: first 4 = ofs_height, last 4 = ofs_normal)
        // Points to MCVT at offset 136 (8-byte chunk header + 128-byte MCNK header)
        data.extend_from_slice(&136u32.to_le_bytes()); // ofs_height
        data.extend_from_slice(&0u32.to_le_bytes()); // ofs_normal

        // ofs_layer (0 = not present)
        data.extend_from_slice(&0u32.to_le_bytes());

        // ofs_refs (0 = not present)
        data.extend_from_slice(&0u32.to_le_bytes());

        // ofs_alpha (0 = not present)
        data.extend_from_slice(&0u32.to_le_bytes());

        // size_alpha
        data.extend_from_slice(&0u32.to_le_bytes());

        // ofs_shadow, size_shadow (0 = not present)
        data.extend_from_slice(&0u32.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes());

        // area_id
        data.extend_from_slice(&1234u32.to_le_bytes());

        // n_map_obj_refs
        data.extend_from_slice(&0u32.to_le_bytes());

        // holes_low_res, unknown_but_used
        data.extend_from_slice(&0u16.to_le_bytes());
        data.extend_from_slice(&1u16.to_le_bytes());

        // pred_tex (8 bytes), no_effect_doodad (8 bytes)
        data.extend_from_slice(&[0u8; 16]);

        // ofs_snd_emitters, n_snd_emitters
        data.extend_from_slice(&0u32.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes());

        // ofs_liquid, size_liquid
        data.extend_from_slice(&0u32.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes());

        // position (12 bytes)
        data.extend_from_slice(&0.0f32.to_le_bytes());
        data.extend_from_slice(&0.0f32.to_le_bytes());
        data.extend_from_slice(&0.0f32.to_le_bytes());

        // ofs_mccv, ofs_mclv, unused
        data.extend_from_slice(&0u32.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes());

        // _padding (8 bytes) - added to reach 128-byte header size
        data.extend_from_slice(&[0u8; 8]);

        assert_eq!(
            data.len() - header_start,
            128,
            "Header must be exactly 128 bytes"
        );

        // Add MCVT subchunk at offset 136 (8-byte chunk header + 128-byte MCNK header)
        assert_eq!(data.len(), 136, "Data should be at offset 136 before MCVT");

        // MCVT chunk header
        data.extend_from_slice(b"MCVT"); // Magic (reversed)
        data.extend_from_slice(&(145 * 4u32).to_le_bytes()); // Size: 145 floats

        // MCVT data (145 f32 values)
        for i in 0..145 {
            data.extend_from_slice(&(i as f32).to_le_bytes());
        }

        data
    }

    #[test]
    fn test_mcnk_chunk_parse() {
        let data = create_test_mcnk_data();
        let mut cursor = Cursor::new(data);

        // Skip MCNK chunk header (8 bytes)
        cursor.set_position(8);

        let mcnk = McnkChunk::parse_with_offset(&mut cursor, 0).unwrap();

        assert_eq!(mcnk.header.index_x, 5);
        assert_eq!(mcnk.header.index_y, 7);
        assert_eq!(mcnk.header.n_layers, 2);
        assert_eq!(mcnk.header.area_id, 1234);

        // MCVT should be present
        assert!(mcnk.has_heights());
        assert_eq!(mcnk.heights.as_ref().unwrap().heights.len(), 145);

        // Other subchunks should be absent
        assert!(!mcnk.has_normals());
        assert!(!mcnk.has_layers());
        assert!(!mcnk.has_refs());
        assert!(!mcnk.has_alpha());
        assert!(!mcnk.has_shadow());
        assert!(!mcnk.has_vertex_colors());
        assert!(!mcnk.has_sound_emitters());
        assert!(!mcnk.has_liquid());
    }

    #[test]
    fn test_mcnk_chunk_validate_consistency() {
        let data = create_test_mcnk_data();
        let mut cursor = Cursor::new(data);
        cursor.set_position(8);

        let mcnk = McnkChunk::parse_with_offset(&mut cursor, 0).unwrap();

        // Note: validate_consistency checks header.has_* methods, which check offsets
        // Our test data has ofs_height set, so header.has_height() should be true
        // and mcnk.has_heights() should also be true
        assert!(mcnk.header.has_height());
        assert!(mcnk.has_heights());
    }

    #[test]
    fn test_mcnk_chunk_presence_checks() {
        let data = create_test_mcnk_data();
        let mut cursor = Cursor::new(data);
        cursor.set_position(8);

        let mcnk = McnkChunk::parse_with_offset(&mut cursor, 0).unwrap();

        assert!(mcnk.has_heights());
        assert!(!mcnk.has_normals());
        assert!(!mcnk.has_layers());
    }

    #[test]
    fn test_mcnk_chunk_height_access() {
        let data = create_test_mcnk_data();
        let mut cursor = Cursor::new(data);
        cursor.set_position(8);

        let mcnk = McnkChunk::parse_with_offset(&mut cursor, 0).unwrap();

        if let Some(heights) = &mcnk.heights {
            // Test data has sequential values 0.0, 1.0, 2.0, ...
            assert_eq!(heights.heights[0], 0.0);
            assert_eq!(heights.heights[1], 1.0);
            assert_eq!(heights.heights[144], 144.0);
        } else {
            panic!("Heights should be present");
        }
    }
}