rakata_formats/mdl/
reader.rs

1//! MDL binary reader.
2//!
3//! The reader uses a two-pass architecture:
4//!
5//! **Pass 1 (node tree):** Recursive DFS traversal of the node hierarchy. Each
6//! node's headers and type-specific extra data are parsed, and mesh nodes
7//! collect an `MdxMeshInfo` descriptor with stride, vertex count, and
8//! per-attribute byte offsets. Vertex attribute arrays are left empty at this
9//! stage - only structural metadata is captured.
10//!
11//! **Pass 2 (MDX vertex population):** After the full tree is built, vertex
12//! data is populated from the MDX buffer (or from MDL content-blob fallback
13//! positions when no MDX is present). Meshes are visited in non-skin-first
14//! order - all non-skin meshes in DFS order, then all skin meshes in DFS
15//! order - matching the BioWare engine's canonical MDX layout. A cumulative
16//! cursor advances through the MDX buffer, reading interleaved vertex
17//! attributes and skipping terminator/alignment padding between meshes.
18//!
19//! This two-pass design decouples the node parse order (DFS) from the MDX
20//! data order (non-skin-first), enabling correct vertex assignment for vanilla
21//! game files where these orders differ.
22
23use std::io::{Cursor, Read, Seek, SeekFrom};
24
25use crate::binary::{
26    check_slice_in_bounds, checked_to_usize, read_f32, read_i32, read_u16, read_u32, read_u8,
27};
28
29use super::controllers::{MdlController, MdlControllerType, MdlKey};
30use super::types::{
31    AabbNode, MdlAabb, MdlAnimMesh, MdlCamera, MdlDangly, MdlLight, MdlMesh, MdlNodeData,
32    MdlReference, MdlSaber, MdlSkin,
33};
34use super::{
35    aabb_offsets, anim_header_offsets, anim_mesh_offsets, dangly_offsets, header_offsets,
36    light_offsets, mesh_offsets, node_flags, node_offsets, saber_offsets, skin_offsets, Mdl,
37    MdlAnimEvent, MdlAnimNode, MdlAnimation, MdlError, MdlNode, AABB_EXTRA_SIZE,
38    ANIMATION_EVENT_SIZE, ANIMATION_HEADER_SIZE, ANIM_MESH_EXTRA_SIZE, DANGLY_EXTRA_SIZE,
39    EMITTER_EXTRA_SIZE, LIGHT_EXTRA_SIZE, MAX_FACE_SIZE, MDL_WRAPPER_SIZE, MESH_EXTRA_SIZE,
40    NODE_HEADER_SIZE, NUM_SABER_VERTS, REFERENCE_EXTRA_SIZE, SABER_EXTRA_SIZE, SKIN_EXTRA_SIZE,
41};
42
43/// Reads an MDL file from the given reader.
44///
45/// This buffers the entire stream to memory.
46#[cfg_attr(
47    feature = "tracing",
48    tracing::instrument(level = "debug", skip(reader, mdx_bytes))
49)]
50pub fn read_mdl<R: Read>(reader: &mut R, mdx_bytes: Option<&[u8]>) -> Result<Mdl, MdlError> {
51    let mut buffer = Vec::new();
52    reader.read_to_end(&mut buffer)?;
53    crate::trace_debug!(bytes_len = buffer.len(), "read mdl bytes from reader");
54    read_mdl_from_bytes(&buffer, mdx_bytes)
55}
56
57/// Reads an MDL file from the given byte slice.
58///
59/// Accepts an optional `mdx_bytes` slice for external geometry data.
60/// This currently supports reading the node hierarchy, names, transforms,
61/// and basic mesh headers (vertex counts).
62#[cfg_attr(
63    feature = "tracing",
64    tracing::instrument(level = "debug", skip(bytes, mdx_bytes), fields(bytes_len = bytes.len()))
65)]
66pub fn read_mdl_from_bytes(bytes: &[u8], mdx_bytes: Option<&[u8]>) -> Result<Mdl, MdlError> {
67    read_mdl_from_cursor(&mut Cursor::new(bytes), mdx_bytes)
68}
69
70// ---------------------------------------------------------------------------
71// Two-pass MDX reading infrastructure
72// ---------------------------------------------------------------------------
73
74/// Metadata collected during Pass 1 (DFS tree parse) for deferred MDX reading.
75///
76/// During Pass 1, the tree is built with empty vertex arrays on every mesh.
77/// Each mesh's MDX-relevant metadata is stored here so that Pass 2 can read
78/// vertex data in the correct ordering (non-skin-first, matching the writer).
79struct MdxMeshInfo {
80    /// Per-vertex byte stride in the MDX data.
81    stride: u32,
82    /// Number of vertices declared in the mesh header.
83    vertex_count: usize,
84    /// Per-mesh offset into the MDX file (mesh header +0x144).
85    /// Used by the engine and community tools to seek to the correct
86    /// position in the MDX buffer for each mesh's interleaved vertex data.
87    mdx_data_offset: usize,
88    /// Content-relative pointer to position-only vertex data (mesh header
89    /// +0x148). Points to vertex_count * 12 bytes (3x f32) in the MDL
90    /// content blob. Used as fallback when no MDX file is available.
91    vert_array_offset: usize,
92    /// Per-attribute byte offsets within the interleaved stride (-1 = absent).
93    pos_off: i32,
94    norm_off: i32,
95    color_off: i32,
96    uv1_off: i32,
97    uv2_off: i32,
98    uv3_off: i32,
99    uv4_off: i32,
100    tangent_off: i32,
101    /// Bone weight/index byte offsets (skin meshes only, -1 when absent).
102    bone_weights_off: i32,
103    bone_indices_off: i32,
104}
105
106/// Vertex data read from MDX for a single mesh, ready to be applied to the tree.
107#[derive(Default)]
108struct MdxVertexData {
109    positions: Vec<[f32; 3]>,
110    normals: Vec<[f32; 3]>,
111    vertex_colors: Vec<[u8; 4]>,
112    uv1: Vec<[f32; 2]>,
113    uv2: Vec<[f32; 2]>,
114    uv3: Vec<[f32; 2]>,
115    uv4: Vec<[f32; 2]>,
116    tangent_space: Vec<[[f32; 3]; 3]>,
117    bone_weights: Vec<[f32; 4]>,
118    bone_indices: Vec<[f32; 4]>,
119    /// If the MDX data was truncated, the clamped vertex count.
120    clamped_vertex_count: Option<u16>,
121}
122
123// ---------------------------------------------------------------------------
124// Shared reader helpers
125// ---------------------------------------------------------------------------
126
127// ---------------------------------------------------------------------------
128// Top-level reader
129// ---------------------------------------------------------------------------
130
131fn read_mdl_from_cursor<R: Read + Seek>(
132    reader: &mut R,
133    mdx_bytes: Option<&[u8]>,
134) -> Result<Mdl, MdlError> {
135    // 1. Skip 12-byte preamble/wrapper.
136    let len = reader.seek(SeekFrom::End(0))?;
137    if len < MDL_WRAPPER_SIZE {
138        return Err(crate::binary::BinaryLayoutError::UnexpectedEof("preamble").into());
139    }
140    reader.seek(SeekFrom::Start(MDL_WRAPPER_SIZE))?;
141
142    // Read remaining content (treating byte 12 as offset 0)
143    let mut content = Vec::new();
144    reader.read_to_end(&mut content)?;
145    let bytes = &content;
146
147    // 2. Parse Header (196 bytes: 80-byte geometry header + 116-byte model header)
148    if bytes.len() < 196 {
149        return Err(crate::binary::BinaryLayoutError::UnexpectedEof("header").into());
150    }
151
152    // --- Geometry header fields ---
153    let geometry_fn_ptr1 = read_u32(bytes, header_offsets::FN_PTR1)?;
154    let geometry_fn_ptr2 = read_u32(bytes, header_offsets::FN_PTR2)?;
155    let root_node_offset = checked_to_usize(
156        read_u32(bytes, header_offsets::ROOT_NODE_PTR)?,
157        "root_offset",
158    )?;
159    let node_count = read_u32(bytes, header_offsets::NODE_COUNT)?;
160    let model_type = read_u8(bytes, header_offsets::MODEL_TYPE)?;
161
162    // --- Model header fields ---
163    let classification = read_u8(bytes, header_offsets::CLASSIFICATION)?;
164    let subclassification = read_u8(bytes, header_offsets::SUBCLASSIFICATION)?;
165    let affected_by_fog = read_u8(bytes, header_offsets::AFFECTED_BY_FOG)?;
166
167    // Bounding box: 6 floats (min_xyz, max_xyz)
168    let mut bounding_box = [0.0f32; 6];
169    for (i, val) in bounding_box.iter_mut().enumerate() {
170        *val = read_f32(bytes, header_offsets::BOUNDING_BOX_MIN + i * 4)?;
171    }
172    let radius = read_f32(bytes, header_offsets::RADIUS)?;
173    let animation_scale = read_f32(bytes, header_offsets::ANIMATION_SCALE)?;
174
175    // Supermodel name: null-terminated string at +0x88, up to 32 bytes.
176    let supermodel_name = read_fixed_string(
177        bytes,
178        header_offsets::SUPERMODEL_NAME,
179        header_offsets::SUPERMODEL_NAME_SIZE,
180    );
181
182    let name_offsets_ptr = checked_to_usize(
183        read_u32(bytes, header_offsets::NAME_OFFSETS_PTR)?,
184        "name_offsets",
185    )?;
186    let name_count = read_u32(bytes, header_offsets::NAME_COUNT)?;
187
188    // 3. Name Resolution
189    let mut names = Vec::new();
190    if name_offsets_ptr > 0 && name_offsets_ptr < bytes.len() {
191        let name_count = checked_to_usize(name_count, "name_count")?;
192        for i in 0..name_count {
193            let ptr_offset = name_offsets_ptr + (i * 4);
194            if ptr_offset + 4 > bytes.len() {
195                crate::trace_warn!(
196                    index = i,
197                    ptr_offset,
198                    bytes_len = bytes.len(),
199                    "name offset array truncated"
200                );
201                break;
202            }
203
204            let name_offset = checked_to_usize(read_u32(bytes, ptr_offset)?, "name_ptr")?;
205
206            if name_offset < bytes.len() {
207                names.push(crate::binary::read_c_string(bytes, name_offset));
208            } else {
209                crate::trace_warn!(
210                    index = i,
211                    name_offset,
212                    bytes_len = bytes.len(),
213                    "name string offset out of bounds, using synthetic name"
214                );
215                names.push(format!("Node_{}", i));
216            }
217        }
218    }
219
220    // 4. Pass 1: Recursive tree traversal (no MDX reading).
221    // Builds the complete node tree with empty vertex arrays on meshes.
222    // MDX metadata is collected into `mdx_infos` for Pass 2.
223    let mut mdx_infos: Vec<MdxMeshInfo> = Vec::new();
224    let mut dfs_mesh_counter: usize = 0;
225    let mut root = read_node(
226        bytes,
227        root_node_offset,
228        &names,
229        None,
230        None,
231        &mut mdx_infos,
232        &mut dfs_mesh_counter,
233    )?;
234
235    // 5. Pass 2: Populate vertex data from MDX or MDL content.
236    //
237    // MDX vertex data is laid out in non-skin-first order (BioWare vanilla
238    // convention): all non-skin meshes first in DFS order, then all skin
239    // meshes in DFS order. The writer uses this same ordering.
240    //
241    // TODO: MDX ordering auto-detection for modded files.
242    //
243    // Currently assumes non-skin-first ordering (matches BioWare vanilla).
244    // Files produced by mdlops (DFS order) or PyKotor (node-ID order) with
245    // mixed skin/non-skin mesh nodes will get vertex data misassigned.
246    //
247    // Future: detect ordering via sentinel float scanning - check for 10M/1M
248    // sentinel values at expected positions for each candidate ordering, pick
249    // the one that matches. See vanilla-inspector's `diagnose_mdx_strategies()`
250    // for the 10-strategy approach.
251    if let Some(mdx) = mdx_bytes {
252        populate_mdx_data(&mut root, mdx, &mdx_infos)?;
253        // Fallback: mesh nodes that the MDX pass skipped (stride=0, e.g.,
254        // saber nodes) still need their embedded content positions read from
255        // vert_array_offset (+0x148). Without this, the writer would emit
256        // vert_array_offset=0 and the engine loses the position fallback.
257        populate_content_positions_fallback(&mut root, bytes, &mdx_infos)?;
258    } else {
259        populate_content_positions(&mut root, bytes, &mdx_infos)?;
260    }
261
262    // 6. Parse Animations
263    let anim_arr_ptr = read_u32(bytes, header_offsets::ANIMATION_ARR_PTR)?;
264    let anim_arr_count = read_u32(bytes, header_offsets::ANIMATION_ARR_COUNT)?;
265    let animations = read_animations(bytes, anim_arr_ptr, anim_arr_count, &names)?;
266
267    // 7. Resolve off_anim_root (+0xA8).
268    // Head models point this to `neck_g` instead of the geometry root.
269    let off_anim_root_raw = read_u32(bytes, header_offsets::OFF_ANIM_ROOT)?;
270    let anim_root_node = if off_anim_root_raw
271        != u32::try_from(root_node_offset)
272            .map_err(|_| MdlError::ValueOverflow("root_node_offset"))?
273        && off_anim_root_raw > 0
274    {
275        let ar_offset = checked_to_usize(off_anim_root_raw, "off_anim_root")?;
276        if ar_offset + 6 <= bytes.len() {
277            let name_idx = usize::from(read_u16(bytes, ar_offset + node_offsets::NODE_ID)?);
278            if name_idx < names.len() {
279                Some(names[name_idx].clone())
280            } else {
281                None
282            }
283        } else {
284            None
285        }
286    } else {
287        None
288    };
289
290    Ok(Mdl {
291        root_node: root,
292        geometry_fn_ptr1,
293        geometry_fn_ptr2,
294        model_type,
295        classification,
296        subclassification,
297        affected_by_fog,
298        supermodel_name,
299        node_count,
300        bounding_box,
301        radius,
302        animation_scale,
303        animations,
304        anim_root_node,
305    })
306}
307
308// ---------------------------------------------------------------------------
309// Shared controller reader
310// ---------------------------------------------------------------------------
311
312/// Reads controller key headers and their associated keyframe data.
313///
314/// Used by both geometry nodes and animation nodes - the binary format
315/// is identical. Each controller key header is 16 bytes.
316/// Result of reading controller arrays from a node header.
317///
318/// Contains both parsed controllers (when key_count > 0) and any orphan data
319/// floats (when key_count == 0 but data_count > 0).
320struct ControllerReadResult {
321    controllers: Vec<MdlController>,
322    orphan_data: Vec<f32>,
323}
324
325fn read_controllers(
326    bytes: &[u8],
327    key_ptr: usize,
328    key_count: usize,
329    data_ptr: usize,
330    data_count: usize,
331) -> Result<ControllerReadResult, MdlError> {
332    // Read controller data floats first.
333    let mut controller_data = Vec::with_capacity(data_count);
334    if data_count > 0 && data_ptr > 0 {
335        check_slice_in_bounds(bytes, data_ptr, data_count * 4, "controller_data")?;
336        for i in 0..data_count {
337            let val = read_f32(bytes, data_ptr + (i * 4))?;
338            controller_data.push(val);
339        }
340    }
341
342    let mut controllers = Vec::with_capacity(key_count);
343    if key_count > 0 && key_ptr > 0 {
344        for i in 0..key_count {
345            let key_offset = key_ptr + (i * 16);
346            if key_offset + 16 > bytes.len() {
347                crate::trace_warn!(
348                    index = i,
349                    key_offset,
350                    bytes_len = bytes.len(),
351                    "controller key header truncated"
352                );
353                break;
354            }
355
356            let type_code = read_u32(bytes, key_offset)?;
357            let controller_type = MdlControllerType::from(type_code);
358
359            // Preserve unknown bytes for roundtrip fidelity (reserved field rule).
360            let mut key_unknown_04 = [0u8; 2];
361            key_unknown_04.copy_from_slice(&bytes[key_offset + 4..key_offset + 6]);
362
363            let row_count = usize::from(read_u16(bytes, key_offset + 6)?);
364            let time_index = usize::from(read_u16(bytes, key_offset + 8)?);
365            let data_index = usize::from(read_u16(bytes, key_offset + 10)?);
366            let raw_column_count = read_u8(bytes, key_offset + 12)?;
367
368            let mut key_unknown_0d = [0u8; 3];
369            key_unknown_0d.copy_from_slice(&bytes[key_offset + 13..key_offset + 16]);
370
371            // Decode column_count to determine actual values per keyframe row.
372            //
373            // The raw byte encodes:
374            // - Lower 4 bits: base column count
375            // - Bit 4 (0x10): Bezier flag (3x control point triplets)
376            // - Special case: ORIENTATION with raw == 2 means integral
377            //     compressed quaternion (1 u32 per row, stored as f32 bits)
378            //
379            // See kotorblender reader.py load_controllers() for reference.
380            let actual_columns =
381                if controller_type == MdlControllerType::ORIENTATION && raw_column_count == 2 {
382                    // Integral compressed quaternion: 1 packed u32 per row.
383                    // We read it as f32 (bit pattern preserved for roundtrip).
384                    1
385                } else {
386                    let base = usize::from(raw_column_count & 0x0F);
387                    let has_bezier = (raw_column_count & super::controllers::CTRL_FLAG_BEZIER) != 0;
388                    if has_bezier {
389                        base * 3
390                    } else {
391                        base
392                    }
393                };
394
395            // Reconstruct keyframe rows.
396            let mut keys = Vec::with_capacity(row_count);
397
398            if time_index + row_count <= controller_data.len() {
399                for r in 0..row_count {
400                    let time = controller_data[time_index + r];
401                    let mut values = Vec::with_capacity(actual_columns);
402
403                    let data_start = data_index + (r * actual_columns);
404                    if data_start + actual_columns <= controller_data.len() {
405                        for c in 0..actual_columns {
406                            values.push(controller_data[data_start + c]);
407                        }
408                    } else {
409                        crate::trace_warn!(
410                            controller = ?controller_type,
411                            row = r,
412                            data_start,
413                            actual_columns,
414                            raw_column_count,
415                            data_len = controller_data.len(),
416                            "controller keyframe data out of bounds"
417                        );
418                    }
419                    keys.push(MdlKey { time, values });
420                }
421            } else {
422                crate::trace_warn!(
423                    controller = ?controller_type,
424                    time_index,
425                    row_count,
426                    data_len = controller_data.len(),
427                    "controller time indices out of bounds"
428                );
429            }
430
431            controllers.push(MdlController {
432                controller_type,
433                keys,
434                raw_column_count,
435                key_unknown_04,
436                key_unknown_0d,
437            });
438        }
439    }
440
441    // When key_count == 0 but data was read, preserve as orphan data.
442    let orphan_data = if controllers.is_empty() && !controller_data.is_empty() {
443        controller_data
444    } else {
445        Vec::new()
446    };
447
448    Ok(ControllerReadResult {
449        controllers,
450        orphan_data,
451    })
452}
453
454// ---------------------------------------------------------------------------
455// Animation reader
456// ---------------------------------------------------------------------------
457
458/// Reads all animations from the animation offset array.
459fn read_animations(
460    bytes: &[u8],
461    anim_arr_ptr: u32,
462    anim_arr_count: u32,
463    names: &[String],
464) -> Result<Vec<MdlAnimation>, MdlError> {
465    if anim_arr_count == 0 {
466        return Ok(Vec::new());
467    }
468
469    let arr_offset = checked_to_usize(anim_arr_ptr, "anim_arr_ptr")?;
470    let anim_count = checked_to_usize(anim_arr_count, "anim_count")?;
471    if arr_offset + (anim_count * 4) > bytes.len() {
472        return Err(MdlError::InvalidData(format!(
473            "animation offset array at {arr_offset} with {anim_arr_count} entries exceeds content"
474        )));
475    }
476
477    // Read the array of content-relative offsets to animation headers.
478    let mut anim_offsets = Vec::with_capacity(anim_count);
479    for i in 0..anim_count {
480        let off = checked_to_usize(read_u32(bytes, arr_offset + i * 4)?, "anim_header_offset")?;
481        anim_offsets.push(off);
482    }
483
484    let mut animations = Vec::with_capacity(anim_offsets.len());
485    for &offset in &anim_offsets {
486        animations.push(read_animation(bytes, offset, names)?);
487    }
488    Ok(animations)
489}
490
491/// Reads a single animation from its header offset.
492fn read_animation(bytes: &[u8], offset: usize, names: &[String]) -> Result<MdlAnimation, MdlError> {
493    check_slice_in_bounds(bytes, offset, ANIMATION_HEADER_SIZE, "animation_header")?;
494
495    let fn_ptr1 = read_u32(bytes, offset + anim_header_offsets::FN_PTR1)?;
496    let fn_ptr2 = read_u32(bytes, offset + anim_header_offsets::FN_PTR2)?;
497
498    // Animation name (32-byte null-terminated).
499    let name = read_fixed_string(
500        bytes,
501        offset + anim_header_offsets::NAME,
502        anim_header_offsets::NAME_SIZE,
503    );
504
505    let root_node_ptr = checked_to_usize(
506        read_u32(bytes, offset + anim_header_offsets::ROOT_NODE_PTR)?,
507        "anim_root_node_ptr",
508    )?;
509
510    // length and transition_time
511    let length = read_f32(bytes, offset + anim_header_offsets::LENGTH)?;
512    let transition_time = read_f32(bytes, offset + anim_header_offsets::TRANSITION)?;
513
514    // Animation root name (32-byte null-terminated).
515    let anim_root = read_fixed_string(
516        bytes,
517        offset + anim_header_offsets::ANIM_ROOT,
518        anim_header_offsets::ANIM_ROOT_SIZE,
519    );
520
521    // Event array
522    let event_arr_ptr = read_u32(bytes, offset + anim_header_offsets::EVENT_ARR_PTR)?;
523    let event_arr_count = read_u32(bytes, offset + anim_header_offsets::EVENT_ARR_COUNT)?;
524
525    let events = read_anim_events(bytes, event_arr_ptr, event_arr_count)?;
526
527    // Parse animation node tree recursively.
528    let root_node = read_anim_node(bytes, root_node_ptr, names)?;
529
530    Ok(MdlAnimation {
531        name,
532        length,
533        transition_time,
534        anim_root,
535        events,
536        root_node,
537        fn_ptr1,
538        fn_ptr2,
539    })
540}
541
542/// Reads animation events from the event array.
543fn read_anim_events(
544    bytes: &[u8],
545    event_arr_ptr: u32,
546    event_arr_count: u32,
547) -> Result<Vec<MdlAnimEvent>, MdlError> {
548    if event_arr_count == 0 {
549        return Ok(Vec::new());
550    }
551
552    let arr_offset = checked_to_usize(event_arr_ptr, "event_arr_ptr")?;
553    let event_count = checked_to_usize(event_arr_count, "event_count")?;
554    let total_size = event_count * ANIMATION_EVENT_SIZE;
555    if arr_offset + total_size > bytes.len() {
556        return Err(MdlError::InvalidData(format!(
557            "event array at {arr_offset} with {event_arr_count} events exceeds content"
558        )));
559    }
560
561    let mut events = Vec::with_capacity(event_count);
562    for i in 0..event_count {
563        let event_offset = arr_offset + i * ANIMATION_EVENT_SIZE;
564        let time = read_f32(bytes, event_offset)?;
565        let name = read_fixed_string(bytes, event_offset + 4, 32);
566        events.push(MdlAnimEvent { time, name });
567    }
568    Ok(events)
569}
570
571/// Reads a single animation node and its children recursively.
572///
573/// Animation nodes use the same 80-byte base header layout as geometry nodes
574/// but carry no type-specific extra data. The `node_number` field maps back
575/// to the corresponding geometry node.
576fn read_anim_node(bytes: &[u8], offset: usize, names: &[String]) -> Result<MdlAnimNode, MdlError> {
577    check_slice_in_bounds(bytes, offset, NODE_HEADER_SIZE, "anim_node_header")?;
578
579    // +0x02: node_number - maps this animation node to its geometry counterpart.
580    let node_number = read_u16(bytes, offset + 0x02)?;
581
582    // +0x04: name_index - index into the model's name table.
583    let name_index = usize::from(read_u16(bytes, offset + node_offsets::NODE_ID)?);
584    let name = if name_index < names.len() {
585        names[name_index].clone()
586    } else {
587        format!("AnimNode_{}", name_index)
588    };
589
590    // Children array.
591    let child_offset_ptr = checked_to_usize(
592        read_u32(bytes, offset + node_offsets::CHILD_ARRAY_PTR)?,
593        "anim_child_offset_ptr",
594    )?;
595    let child_count = checked_to_usize(
596        read_u32(bytes, offset + node_offsets::CHILD_COUNT)?,
597        "anim_child_count",
598    )?;
599
600    // Controller arrays.
601    let ctrl_key_ptr = checked_to_usize(
602        read_u32(bytes, offset + node_offsets::CONTROLLER_KEY_PTR)?,
603        "anim_ctrl_key_ptr",
604    )?;
605    let ctrl_key_count = checked_to_usize(
606        read_u32(bytes, offset + node_offsets::CONTROLLER_KEY_COUNT)?,
607        "anim_ctrl_key_count",
608    )?;
609    let ctrl_data_ptr = checked_to_usize(
610        read_u32(bytes, offset + node_offsets::CONTROLLER_DATA_PTR)?,
611        "anim_ctrl_data_ptr",
612    )?;
613    let ctrl_data_count = checked_to_usize(
614        read_u32(bytes, offset + node_offsets::CONTROLLER_DATA_COUNT)?,
615        "anim_ctrl_data_count",
616    )?;
617
618    // Parse controllers (reusing same infrastructure as geometry nodes).
619    let ctrl_result = read_controllers(
620        bytes,
621        ctrl_key_ptr,
622        ctrl_key_count,
623        ctrl_data_ptr,
624        ctrl_data_count,
625    )?;
626
627    // Parse children recursively.
628    let mut children = Vec::with_capacity(child_count);
629    if child_count > 0 && child_offset_ptr > 0 && child_offset_ptr + child_count * 4 <= bytes.len()
630    {
631        for i in 0..child_count {
632            let child_ptr =
633                checked_to_usize(read_u32(bytes, child_offset_ptr + i * 4)?, "anim_child_ptr")?;
634            children.push(read_anim_node(bytes, child_ptr, names)?);
635        }
636    }
637
638    Ok(MdlAnimNode {
639        name,
640        node_number,
641        controllers: ctrl_result.controllers,
642        orphan_controller_data: ctrl_result.orphan_data,
643        children,
644    })
645}
646
647// ---------------------------------------------------------------------------
648// Geometry node reader
649// ---------------------------------------------------------------------------
650
651fn read_node(
652    bytes: &[u8],
653    offset: usize,
654    names: &[String],
655    parent_node_id: Option<u16>,
656    node_end_hint: Option<usize>,
657    mdx_infos: &mut Vec<MdxMeshInfo>,
658    dfs_mesh_counter: &mut usize,
659) -> Result<MdlNode, MdlError> {
660    // Basic Node Header check (0x44 = 68 bytes)
661    check_slice_in_bounds(bytes, offset, NODE_HEADER_SIZE, "node_header")?;
662
663    let flags = u32::from(read_u16(bytes, offset + node_offsets::FLAGS)?);
664
665    // Preserve unknown bytes for roundtrip fidelity (reserved field rule).
666    let mut header_padding_02 = [0u8; 2];
667    header_padding_02.copy_from_slice(&bytes[offset + 0x02..offset + 0x04]);
668
669    let node_id = usize::from(read_u16(bytes, offset + node_offsets::NODE_ID)?);
670
671    let mut header_padding_06 = [0u8; 2];
672    header_padding_06.copy_from_slice(&bytes[offset + 0x06..offset + 0x08]);
673
674    let px = read_f32(bytes, offset + node_offsets::POS_X)?;
675    let py = read_f32(bytes, offset + node_offsets::POS_X + 4)?;
676    let pz = read_f32(bytes, offset + node_offsets::POS_X + 8)?;
677
678    // Orientation quaternion (w, x, y, z) - Ghidra-verified field order
679    let rw = read_f32(bytes, offset + node_offsets::ORIENTATION_W)?;
680    let rx = read_f32(bytes, offset + node_offsets::ORIENTATION_W + 4)?;
681    let ry = read_f32(bytes, offset + node_offsets::ORIENTATION_W + 8)?;
682    let rz = read_f32(bytes, offset + node_offsets::ORIENTATION_W + 12)?;
683
684    let child_offset_ptr = checked_to_usize(
685        read_u32(bytes, offset + node_offsets::CHILD_ARRAY_PTR)?,
686        "child_offset",
687    )?;
688    let child_count = checked_to_usize(
689        read_u32(bytes, offset + node_offsets::CHILD_COUNT)?,
690        "child_count",
691    )?;
692
693    let controller_key_ptr = checked_to_usize(
694        read_u32(bytes, offset + node_offsets::CONTROLLER_KEY_PTR)?,
695        "controller_key_ptr",
696    )?;
697    let controller_key_count = checked_to_usize(
698        read_u32(bytes, offset + node_offsets::CONTROLLER_KEY_COUNT)?,
699        "controller_key_count",
700    )?;
701
702    let controller_data_ptr = checked_to_usize(
703        read_u32(bytes, offset + node_offsets::CONTROLLER_DATA_PTR)?,
704        "controller_data_ptr",
705    )?;
706    let controller_data_count = checked_to_usize(
707        read_u32(bytes, offset + node_offsets::CONTROLLER_DATA_COUNT)?,
708        "controller_data_count",
709    )?;
710
711    let name = if node_id < names.len() {
712        names[node_id].clone()
713    } else {
714        format!("Node_{}", node_id)
715    };
716
717    // Read controllers using shared helper.
718    let ctrl_result = read_controllers(
719        bytes,
720        controller_key_ptr,
721        controller_key_count,
722        controller_data_ptr,
723        controller_data_count,
724    )?;
725
726    // Sequential Header Parsing
727    // Standard packing order: Light -> Emitter -> Camera -> Reference -> Mesh -> Skin
728    // WARNING: This assumes fixed sizes which is brittle. Real parser needs exact struct sizes.
729    let mut current_offset = offset + NODE_HEADER_SIZE; // End of Node Header
730
731    if (flags & node_flags::LIGHT) != 0 {
732        current_offset += LIGHT_EXTRA_SIZE;
733    }
734    if (flags & node_flags::EMITTER) != 0 {
735        current_offset += EMITTER_EXTRA_SIZE;
736    }
737    // Camera: 0 extra bytes beyond the base node header (Ghidra-verified,
738    // see mdl_mdx.md: Non-Mesh Node Type Structs).
739    // No offset adjustment needed.
740    if (flags & node_flags::REFERENCE) != 0 {
741        current_offset += REFERENCE_EXTRA_SIZE;
742    }
743
744    // Determine node_data variant from flags.
745    // Priority: check mesh subtypes first (most specific), then non-mesh types,
746    // then plain mesh, then base.
747    let node_data = if (flags & node_flags::MESH) != 0 {
748        *dfs_mesh_counter += 1;
749
750        let mut node_data_end_hint = node_end_hint.unwrap_or(bytes.len());
751        for ptr in [child_offset_ptr, controller_key_ptr, controller_data_ptr] {
752            if ptr > 0 {
753                node_data_end_hint = node_data_end_hint.min(ptr);
754            }
755        }
756
757        let (mesh, info) = read_mesh_header(bytes, current_offset)?;
758        mdx_infos.push(info);
759
760        if (flags & node_flags::SABER) != 0 {
761            let saber = read_saber_extra(bytes, current_offset + MESH_EXTRA_SIZE, mesh)?;
762            MdlNodeData::Saber(saber)
763        } else if (flags & node_flags::AABB) != 0 {
764            let aabb = read_aabb_extra(
765                bytes,
766                current_offset + MESH_EXTRA_SIZE,
767                node_data_end_hint,
768                mesh,
769            )?;
770            MdlNodeData::Aabb(aabb)
771        } else if (flags & node_flags::DANGLY) != 0 {
772            let dangly = read_dangly_extra(bytes, current_offset + MESH_EXTRA_SIZE, mesh)?;
773            MdlNodeData::Dangly(dangly)
774        } else if (flags & node_flags::ANIM) != 0 {
775            let anim = read_anim_mesh_extra(bytes, current_offset + MESH_EXTRA_SIZE, mesh)?;
776            MdlNodeData::AnimMesh(anim)
777        } else if (flags & node_flags::SKIN) != 0 {
778            let skin = read_skin_extra(bytes, current_offset + MESH_EXTRA_SIZE, mesh)?;
779            // Propagate bone weight/index offsets into the MdxMeshInfo so the
780            // MDX vertex reader can extract typed bone data.
781            if let Some(last_info) = mdx_infos.last_mut() {
782                last_info.bone_weights_off = skin.mdx_bone_weights_offset;
783                last_info.bone_indices_off = skin.mdx_bone_indices_offset;
784            }
785            MdlNodeData::Skin(skin)
786        } else {
787            MdlNodeData::Mesh(mesh)
788        }
789    } else if (flags & node_flags::LIGHT) != 0 {
790        MdlNodeData::Light(read_light_header(bytes, offset + NODE_HEADER_SIZE)?)
791    } else if (flags & node_flags::EMITTER) != 0 {
792        MdlNodeData::Emitter(read_emitter_header(bytes, offset + NODE_HEADER_SIZE)?)
793    } else if (flags & node_flags::CAMERA) != 0 {
794        MdlNodeData::Camera(MdlCamera::new())
795    } else if (flags & node_flags::REFERENCE) != 0 {
796        MdlNodeData::Reference(read_reference_header(bytes, offset + NODE_HEADER_SIZE)?)
797    } else {
798        MdlNodeData::Base
799    };
800
801    let mut children = Vec::with_capacity(child_count);
802    if child_count > 0 && child_offset_ptr > 0 {
803        let mut child_ptrs = Vec::with_capacity(child_count);
804        for i in 0..child_count {
805            let ptr_loc = child_offset_ptr + (i * 4);
806            let child_ptr = checked_to_usize(read_u32(bytes, ptr_loc)?, "child_ptr")?;
807            child_ptrs.push(child_ptr);
808        }
809
810        for (i, child_ptr) in child_ptrs.iter().copied().enumerate() {
811            let mut child_end_hint: Option<usize> = None;
812            let mut consider_end = |candidate: Option<usize>| {
813                if let Some(end) = candidate.filter(|end| *end > child_ptr) {
814                    child_end_hint = Some(child_end_hint.map_or(end, |curr| curr.min(end)));
815                }
816            };
817            consider_end(child_ptrs.get(i + 1).copied());
818            consider_end((controller_key_ptr > 0).then_some(controller_key_ptr));
819            consider_end((controller_data_ptr > 0).then_some(controller_data_ptr));
820            consider_end(node_end_hint);
821
822            let child_node = read_node(
823                bytes,
824                child_ptr,
825                names,
826                u16::try_from(node_id).ok(),
827                child_end_hint,
828                mdx_infos,
829                dfs_mesh_counter,
830            )?;
831            children.push(child_node);
832        }
833    }
834
835    Ok(MdlNode {
836        name,
837        parent_index: parent_node_id,
838        children,
839        position: [px, py, pz],
840        rotation: [rw, rx, ry, rz],
841        node_data,
842        controllers: ctrl_result.controllers,
843        orphan_controller_data: ctrl_result.orphan_data,
844        header_padding_02,
845        header_padding_06,
846    })
847}
848
849// ---------------------------------------------------------------------------
850// Mesh header (Pass 1 - no MDX reading)
851// ---------------------------------------------------------------------------
852
853fn read_mesh_header(bytes: &[u8], offset: usize) -> Result<(MdlMesh, MdxMeshInfo), MdlError> {
854    use super::types::MdlFace;
855
856    // Full mesh extra header is 332 bytes (MdlNodeTriMesh 0x50..0x19C).
857    check_slice_in_bounds(bytes, offset, MESH_EXTRA_SIZE, "mesh_header")?;
858
859    let face_offset = checked_to_usize(
860        read_u32(bytes, offset + mesh_offsets::FACE_ARRAY_OFFSET)?,
861        "face_offset",
862    )?;
863    let face_count = checked_to_usize(
864        read_u32(bytes, offset + mesh_offsets::FACE_COUNT)?,
865        "face_count",
866    )?;
867
868    // +0x98: vertex_indices - dead field, skipped.
869    // +0xA4: left_over_faces - always empty, skipped.
870
871    let vertex_indices_count_ptr = checked_to_usize(
872        read_u32(bytes, offset + mesh_offsets::VERTEX_INDICES_COUNT_ARRAY_PTR)?,
873        "vertex_indices_count_ptr",
874    )?;
875    let vertex_indices_count_count = read_u32(
876        bytes,
877        offset + mesh_offsets::VERTEX_INDICES_COUNT_ARRAY_COUNT,
878    )?;
879    let _vertex_indices_count_alloc = read_u32(
880        bytes,
881        offset + mesh_offsets::VERTEX_INDICES_COUNT_ARRAY_ALLOC,
882    )?;
883
884    // +0xBC: mdx_offsets - data derived from faces, only count/ptr used for reading.
885    let _mdx_offsets_count = read_u32(bytes, offset + mesh_offsets::MDX_OFFSETS_ARRAY_COUNT)?;
886    let _mdx_offsets_alloc = read_u32(bytes, offset + mesh_offsets::MDX_OFFSETS_ARRAY_ALLOC)?;
887
888    let index_buffer_pools_ptr = checked_to_usize(
889        read_u32(bytes, offset + mesh_offsets::INDEX_BUFFER_POOLS_ARRAY_PTR)?,
890        "index_buffer_pools_ptr",
891    )?;
892    let index_buffer_pools_count =
893        read_u32(bytes, offset + mesh_offsets::INDEX_BUFFER_POOLS_ARRAY_COUNT)?;
894    let _index_buffer_pools_alloc =
895        read_u32(bytes, offset + mesh_offsets::INDEX_BUFFER_POOLS_ARRAY_ALLOC)?;
896
897    let shared_index_offset = read_i32(bytes, offset + mesh_offsets::SHARED_INDEX_OFFSET)?;
898    let shared_index_pool = read_i32(bytes, offset + mesh_offsets::SHARED_INDEX_POOL)?;
899    let shared_index_size = read_i32(bytes, offset + mesh_offsets::SHARED_INDEX_SIZE)?;
900    let indices_per_face = read_u32(bytes, offset + mesh_offsets::INDICES_PER_FACE)?;
901
902    // Vertex info from Ghidra-verified offsets.
903    let vertex_count = usize::from(read_u16(bytes, offset + mesh_offsets::VERTEX_COUNT)?);
904    let mdx_data_offset = checked_to_usize(
905        read_u32(bytes, offset + mesh_offsets::MDX_DATA_OFFSET)?,
906        "mdx_data_offset",
907    )?;
908    let vert_array_offset = checked_to_usize(
909        read_u32(bytes, offset + mesh_offsets::VERT_ARRAY_OFFSET)?,
910        "vert_array_offset",
911    )?;
912    let vertex_stride = read_u32(bytes, offset + mesh_offsets::VERTEX_STRUCT_SIZE)?;
913
914    // Render/shadow flags from Ghidra-verified offsets.
915    let render = read_u8(bytes, offset + mesh_offsets::RENDER)? != 0;
916    let shadow = read_u8(bytes, offset + mesh_offsets::SHADOW)? != 0;
917
918    // Toolset function pointer stubs (extra +0x00, +0x04).
919    let fn_ptr_gen_vertices = read_u32(bytes, offset + mesh_offsets::FN_PTR_GEN_VERTICES)?;
920    let fn_ptr_remove_temp_array =
921        read_u32(bytes, offset + mesh_offsets::FN_PTR_REMOVE_TEMP_ARRAY)?;
922
923    // Bounding box and sphere.
924    let bounding_min = [
925        read_f32(bytes, offset + mesh_offsets::BOUNDING_MIN)?,
926        read_f32(bytes, offset + mesh_offsets::BOUNDING_MIN + 4)?,
927        read_f32(bytes, offset + mesh_offsets::BOUNDING_MIN + 8)?,
928    ];
929    let bounding_max = [
930        read_f32(bytes, offset + mesh_offsets::BOUNDING_MAX)?,
931        read_f32(bytes, offset + mesh_offsets::BOUNDING_MAX + 4)?,
932        read_f32(bytes, offset + mesh_offsets::BOUNDING_MAX + 8)?,
933    ];
934    let bsphere_radius = read_f32(bytes, offset + mesh_offsets::BSPHERE_RADIUS)?;
935    let bsphere_center = [
936        read_f32(bytes, offset + mesh_offsets::BSPHERE_CENTER)?,
937        read_f32(bytes, offset + mesh_offsets::BSPHERE_CENTER + 4)?,
938        read_f32(bytes, offset + mesh_offsets::BSPHERE_CENTER + 8)?,
939    ];
940
941    // Colors.
942    let diffuse_color = [
943        read_f32(bytes, offset + mesh_offsets::DIFFUSE_COLOR)?,
944        read_f32(bytes, offset + mesh_offsets::DIFFUSE_COLOR + 4)?,
945        read_f32(bytes, offset + mesh_offsets::DIFFUSE_COLOR + 8)?,
946    ];
947    let ambient_color = [
948        read_f32(bytes, offset + mesh_offsets::AMBIENT_COLOR)?,
949        read_f32(bytes, offset + mesh_offsets::AMBIENT_COLOR + 4)?,
950        read_f32(bytes, offset + mesh_offsets::AMBIENT_COLOR + 8)?,
951    ];
952    let transparency_hint = read_i32(bytes, offset + mesh_offsets::TRANSPARENCY_HINT)?;
953
954    // Texture names (null-terminated char[32]).
955    let texture_0 = read_fixed_string(
956        bytes,
957        offset + mesh_offsets::TEXTURE_0,
958        mesh_offsets::TEXTURE_NAME_SIZE,
959    );
960    let texture_1 = read_fixed_string(
961        bytes,
962        offset + mesh_offsets::TEXTURE_1,
963        mesh_offsets::TEXTURE_NAME_SIZE,
964    );
965
966    // UV animation.
967    let animate_uv = read_i32(bytes, offset + mesh_offsets::ANIMATE_UV)?;
968    let uv_direction_x = read_f32(bytes, offset + mesh_offsets::UV_DIRECTION_X)?;
969    let uv_direction_y = read_f32(bytes, offset + mesh_offsets::UV_DIRECTION_Y)?;
970    let uv_jitter = read_f32(bytes, offset + mesh_offsets::UV_JITTER)?;
971    let uv_jitter_speed = read_f32(bytes, offset + mesh_offsets::UV_JITTER_SPEED)?;
972
973    // Remaining scalar/boolean fields.
974    let texture_channel_count = read_u16(bytes, offset + mesh_offsets::TEXTURE_CHANNEL_COUNT)?;
975    let light_mapped = read_u8(bytes, offset + mesh_offsets::LIGHT_MAPPED)? != 0;
976    let rotate_texture = read_u8(bytes, offset + mesh_offsets::ROTATE_TEXTURE)? != 0;
977    let is_background_geometry =
978        read_u8(bytes, offset + mesh_offsets::IS_BACKGROUND_GEOMETRY)? != 0;
979    let beaming = read_u8(bytes, offset + mesh_offsets::BEAMING)? != 0;
980    let total_surface_area = read_f32(bytes, offset + mesh_offsets::TOTAL_SURFACE_AREA)?;
981
982    // Read Faces - MaxFace is 32 bytes per entry.
983    let mut faces = Vec::with_capacity(face_count);
984    if face_count > 0 && face_offset > 0 {
985        if face_offset + (face_count * MAX_FACE_SIZE) <= bytes.len() {
986            for i in 0..face_count {
987                let curr = face_offset + (i * MAX_FACE_SIZE);
988                faces.push(MdlFace {
989                    plane_normal: [
990                        read_f32(bytes, curr)?,
991                        read_f32(bytes, curr + 4)?,
992                        read_f32(bytes, curr + 8)?,
993                    ],
994                    plane_distance: read_f32(bytes, curr + 12)?,
995                    surface_id: read_u32(bytes, curr + 16)?,
996                    adjacent: [
997                        read_u16(bytes, curr + 20)?,
998                        read_u16(bytes, curr + 22)?,
999                        read_u16(bytes, curr + 24)?,
1000                    ],
1001                    vertex_indices: [
1002                        read_u16(bytes, curr + 26)?,
1003                        read_u16(bytes, curr + 28)?,
1004                        read_u16(bytes, curr + 30)?,
1005                    ],
1006                });
1007            }
1008        } else {
1009            crate::trace_warn!(
1010                face_offset,
1011                face_count,
1012                bytes_len = bytes.len(),
1013                "face array extends beyond buffer, skipping faces"
1014            );
1015        }
1016    }
1017
1018    // --- TriMesh internal CExoArrayList typed extraction ---
1019    // These 5 fields are now stored as typed values rather than raw blobs.
1020    // See `docs/notes/mesh_derived_fields.md` for full documentation.
1021
1022    // +0x98: Dead field (always zeros in KotOR). Skipped - writer emits zeros.
1023
1024    // +0xC8: Inverted counter (single u32 data value).
1025    let inverted_counter = if index_buffer_pools_count > 0 && index_buffer_pools_ptr > 0 {
1026        if index_buffer_pools_ptr + 4 <= bytes.len() {
1027            read_u32(bytes, index_buffer_pools_ptr)?
1028        } else {
1029            0
1030        }
1031    } else {
1032        0
1033    };
1034
1035    // +0xB0: Detect embedded-position variant (count==1 with positions at ptr+4).
1036    let has_embedded_positions = vertex_indices_count_count == 1
1037        && vertex_indices_count_ptr > 0
1038        && vert_array_offset == vertex_indices_count_ptr.saturating_add(4);
1039
1040    // Read per-attribute byte offsets from mesh header (+0x104..+0x120).
1041    // Each is an i32; value -1 (0xFFFFFFFF) means not present.
1042    let pos_off = read_i32(bytes, offset + mesh_offsets::MDX_POSITION_OFFSET)?;
1043    let norm_off = read_i32(bytes, offset + mesh_offsets::MDX_NORMAL_OFFSET)?;
1044    let color_off = read_i32(bytes, offset + mesh_offsets::MDX_COLOR_OFFSET)?;
1045    let uv1_off = read_i32(bytes, offset + mesh_offsets::MDX_UV1_OFFSET)?;
1046    let uv2_off = read_i32(bytes, offset + mesh_offsets::MDX_UV2_OFFSET)?;
1047    let uv3_off = read_i32(bytes, offset + mesh_offsets::MDX_UV3_OFFSET)?;
1048    let uv4_off = read_i32(bytes, offset + mesh_offsets::MDX_UV4_OFFSET)?;
1049    let tangent_off = read_i32(bytes, offset + mesh_offsets::MDX_TANGENT_SPACE_OFFSET)?;
1050
1051    let info = MdxMeshInfo {
1052        stride: vertex_stride,
1053        vertex_count,
1054        mdx_data_offset,
1055        vert_array_offset,
1056        pos_off,
1057        norm_off,
1058        color_off,
1059        uv1_off,
1060        uv2_off,
1061        uv3_off,
1062        uv4_off,
1063        tangent_off,
1064        bone_weights_off: -1,
1065        bone_indices_off: -1,
1066    };
1067
1068    let mesh = MdlMesh {
1069        fn_ptr_gen_vertices,
1070        fn_ptr_remove_temp_array,
1071        bounding_min,
1072        bounding_max,
1073        bsphere_radius,
1074        bsphere_center,
1075        diffuse_color,
1076        ambient_color,
1077        transparency_hint,
1078        texture_0,
1079        texture_1,
1080        animate_uv,
1081        uv_direction_x,
1082        uv_direction_y,
1083        uv_jitter,
1084        uv_jitter_speed,
1085        texture_channel_count,
1086        light_mapped,
1087        rotate_texture,
1088        is_background_geometry,
1089        beaming,
1090        total_surface_area,
1091        positions: Vec::new(),
1092        normals: Vec::new(),
1093        vertex_colors: Vec::new(),
1094        uv1: Vec::new(),
1095        uv2: Vec::new(),
1096        uv3: Vec::new(),
1097        uv4: Vec::new(),
1098        tangent_space: Vec::new(),
1099        faces,
1100        inverted_counter,
1101        has_embedded_positions,
1102        shared_index_offset,
1103        shared_index_pool,
1104        shared_index_size,
1105        indices_per_face,
1106        vertex_count: u16::try_from(vertex_count)
1107            .map_err(|_| MdlError::ValueOverflow("vertex_count"))?,
1108        render,
1109        shadow,
1110    };
1111
1112    Ok((mesh, info))
1113}
1114
1115// ---------------------------------------------------------------------------
1116// Pass 2: MDX vertex data population (per-mesh seeking via mdx_data_offset)
1117// ---------------------------------------------------------------------------
1118
1119/// Reads MDX vertex data using each mesh's `mdx_data_offset` (+0x144) to seek
1120/// directly to the correct position in the MDX buffer.
1121///
1122/// This matches how the engine and community tools (kotorblender, mdledit)
1123/// consume MDX data -- each mesh's header stores the byte offset where its
1124/// interleaved vertex data begins in the MDX file, so the reader does not
1125/// need to assume any particular mesh ordering within the MDX buffer.
1126fn populate_mdx_data(
1127    root: &mut MdlNode,
1128    mdx: &[u8],
1129    infos: &[MdxMeshInfo],
1130) -> Result<(), MdlError> {
1131    if infos.is_empty() {
1132        return Ok(());
1133    }
1134
1135    // Read vertex data for each mesh by seeking to its mdx_data_offset.
1136    let mut results: Vec<MdxVertexData> =
1137        (0..infos.len()).map(|_| MdxVertexData::default()).collect();
1138
1139    for (idx, info) in infos.iter().enumerate() {
1140        let data = read_mdx_vertices(mdx, info)?;
1141        results[idx] = data;
1142    }
1143
1144    // Apply vertex data to the tree in DFS order.
1145    let mut counter = 0;
1146    apply_vertex_data(root, &mut results, &mut counter);
1147
1148    Ok(())
1149}
1150
1151/// Reads all vertex attributes for a single mesh from the MDX buffer,
1152/// seeking directly to the mesh's `mdx_data_offset` position.
1153fn read_mdx_vertices(mdx: &[u8], info: &MdxMeshInfo) -> Result<MdxVertexData, MdlError> {
1154    let mut data = MdxVertexData::default();
1155
1156    if info.vertex_count == 0 || info.stride == 0 {
1157        return Ok(data);
1158    }
1159
1160    let stride = checked_to_usize(info.stride, "vertex_stride")?;
1161    let mdx_base = info.mdx_data_offset;
1162
1163    for i in 0..info.vertex_count {
1164        let base = mdx_base + (i * stride);
1165        if base + stride > mdx.len() {
1166            crate::trace_warn!(
1167                vertex_index = i,
1168                base,
1169                stride,
1170                mdx_len = mdx.len(),
1171                mdx_cursor = mdx_base,
1172                "MDX vertex data truncated"
1173            );
1174            break;
1175        }
1176
1177        // Position (3×f32 = 12 bytes)
1178        if let Ok(off) = usize::try_from(info.pos_off) {
1179            let o = base + off;
1180            data.positions.push([
1181                read_f32(mdx, o)?,
1182                read_f32(mdx, o + 4)?,
1183                read_f32(mdx, o + 8)?,
1184            ]);
1185        }
1186
1187        // Normal (3×f32 = 12 bytes)
1188        if let Ok(off) = usize::try_from(info.norm_off) {
1189            let o = base + off;
1190            data.normals.push([
1191                read_f32(mdx, o)?,
1192                read_f32(mdx, o + 4)?,
1193                read_f32(mdx, o + 8)?,
1194            ]);
1195        }
1196
1197        // Vertex color (4×u8 = 4 bytes)
1198        if let Ok(off) = usize::try_from(info.color_off) {
1199            let o = base + off;
1200            data.vertex_colors.push([
1201                read_u8(mdx, o)?,
1202                read_u8(mdx, o + 1)?,
1203                read_u8(mdx, o + 2)?,
1204                read_u8(mdx, o + 3)?,
1205            ]);
1206        }
1207
1208        // UV1 (2×f32 = 8 bytes)
1209        if let Ok(off) = usize::try_from(info.uv1_off) {
1210            let o = base + off;
1211            data.uv1.push([read_f32(mdx, o)?, read_f32(mdx, o + 4)?]);
1212        }
1213
1214        // UV2 (2×f32 = 8 bytes)
1215        if let Ok(off) = usize::try_from(info.uv2_off) {
1216            let o = base + off;
1217            data.uv2.push([read_f32(mdx, o)?, read_f32(mdx, o + 4)?]);
1218        }
1219
1220        // UV3 (2×f32 = 8 bytes)
1221        if let Ok(off) = usize::try_from(info.uv3_off) {
1222            let o = base + off;
1223            data.uv3.push([read_f32(mdx, o)?, read_f32(mdx, o + 4)?]);
1224        }
1225
1226        // UV4 (2×f32 = 8 bytes)
1227        if let Ok(off) = usize::try_from(info.uv4_off) {
1228            let o = base + off;
1229            data.uv4.push([read_f32(mdx, o)?, read_f32(mdx, o + 4)?]);
1230        }
1231
1232        // Tangent space (3×3×f32 = 36 bytes)
1233        if let Ok(off) = usize::try_from(info.tangent_off) {
1234            let o = base + off;
1235            data.tangent_space.push([
1236                [
1237                    read_f32(mdx, o)?,
1238                    read_f32(mdx, o + 4)?,
1239                    read_f32(mdx, o + 8)?,
1240                ],
1241                [
1242                    read_f32(mdx, o + 12)?,
1243                    read_f32(mdx, o + 16)?,
1244                    read_f32(mdx, o + 20)?,
1245                ],
1246                [
1247                    read_f32(mdx, o + 24)?,
1248                    read_f32(mdx, o + 28)?,
1249                    read_f32(mdx, o + 32)?,
1250                ],
1251            ]);
1252        }
1253
1254        // Bone weights (4×f32 = 16 bytes)
1255        if let Ok(off) = usize::try_from(info.bone_weights_off) {
1256            let o = base + off;
1257            data.bone_weights.push([
1258                read_f32(mdx, o)?,
1259                read_f32(mdx, o + 4)?,
1260                read_f32(mdx, o + 8)?,
1261                read_f32(mdx, o + 12)?,
1262            ]);
1263        }
1264
1265        // Bone indices (4×f32 = 16 bytes)
1266        if let Ok(off) = usize::try_from(info.bone_indices_off) {
1267            let o = base + off;
1268            data.bone_indices.push([
1269                read_f32(mdx, o)?,
1270                read_f32(mdx, o + 4)?,
1271                read_f32(mdx, o + 8)?,
1272                read_f32(mdx, o + 12)?,
1273            ]);
1274        }
1275    }
1276
1277    // Clamp vertex_count to actual vertex data length. Vanilla item models
1278    // may declare more vertices than the MDX buffer contains (shared buffer
1279    // truncation). The typed vertex_count must match the actual data arrays
1280    // for roundtrip fidelity - the writer uses vertex_count to size the MDX
1281    // output region, and over-declaring causes cross-contamination between
1282    // meshes on re-read.
1283    let actual_vertex_count = data
1284        .positions
1285        .len()
1286        .max(data.normals.len())
1287        .max(data.vertex_colors.len())
1288        .max(data.uv1.len())
1289        .max(data.uv2.len())
1290        .max(data.uv3.len())
1291        .max(data.uv4.len())
1292        .max(data.tangent_space.len());
1293
1294    if actual_vertex_count > 0 && actual_vertex_count < info.vertex_count {
1295        data.clamped_vertex_count = Some(
1296            u16::try_from(actual_vertex_count)
1297                .map_err(|_| MdlError::ValueOverflow("actual_vertex_count"))?,
1298        );
1299    }
1300
1301    Ok(data)
1302}
1303
1304/// Reads position-only vertex data from the MDL content blob (no-MDX fallback).
1305///
1306/// Each mesh's `vert_array_offset` (+0x148) is a content-relative offset into
1307/// the MDL blob pointing to position-only data (12 bytes per vertex = 3x f32).
1308/// This path is used when no MDX file is available. Ordering doesn't matter
1309/// here since each mesh reads from its own independent content offset.
1310fn populate_content_positions(
1311    root: &mut MdlNode,
1312    bytes: &[u8],
1313    infos: &[MdxMeshInfo],
1314) -> Result<(), MdlError> {
1315    if infos.is_empty() {
1316        return Ok(());
1317    }
1318
1319    // Read position-only data for each mesh from its stored MDL content offset.
1320    let mut results: Vec<MdxVertexData> =
1321        (0..infos.len()).map(|_| MdxVertexData::default()).collect();
1322
1323    for (idx, info) in infos.iter().enumerate() {
1324        if info.vertex_count == 0 || info.vert_array_offset == 0 {
1325            continue;
1326        }
1327
1328        let pos_end = info.vert_array_offset + (info.vertex_count * 12);
1329        if pos_end <= bytes.len() {
1330            let mut positions = Vec::with_capacity(info.vertex_count);
1331            for i in 0..info.vertex_count {
1332                let o = info.vert_array_offset + (i * 12);
1333                positions.push([
1334                    read_f32(bytes, o)?,
1335                    read_f32(bytes, o + 4)?,
1336                    read_f32(bytes, o + 8)?,
1337                ]);
1338            }
1339            results[idx].positions = positions;
1340        } else {
1341            crate::trace_warn!(
1342                vert_array_offset = info.vert_array_offset,
1343                vertex_count = info.vertex_count,
1344                pos_end,
1345                bytes_len = bytes.len(),
1346                "MDL content position data extends beyond buffer"
1347            );
1348        }
1349    }
1350
1351    // Apply to tree in DFS order.
1352    let mut counter = 0;
1353    apply_vertex_data(root, &mut results, &mut counter);
1354
1355    Ok(())
1356}
1357
1358/// Reads content positions for mesh nodes that the MDX pass skipped.
1359///
1360/// After `populate_mdx_data`, mesh nodes with stride=0 (e.g., saber nodes)
1361/// have empty position arrays because the MDX path returns no data for them.
1362/// However, these nodes may still have valid embedded positions at their
1363/// `vert_array_offset` (+0x148). This function fills in positions only for
1364/// nodes where the MDX pass left positions empty.
1365fn populate_content_positions_fallback(
1366    root: &mut MdlNode,
1367    bytes: &[u8],
1368    infos: &[MdxMeshInfo],
1369) -> Result<(), MdlError> {
1370    if infos.is_empty() {
1371        return Ok(());
1372    }
1373
1374    let mut counter = 0;
1375    fill_missing_positions(root, bytes, infos, &mut counter)?;
1376    Ok(())
1377}
1378
1379/// Recursive helper: walks DFS and reads content positions for mesh nodes
1380/// that still have empty positions but a valid vert_array_offset.
1381fn fill_missing_positions(
1382    node: &mut MdlNode,
1383    bytes: &[u8],
1384    infos: &[MdxMeshInfo],
1385    counter: &mut usize,
1386) -> Result<(), MdlError> {
1387    if node.node_data.mesh().is_some() {
1388        let idx = *counter;
1389        *counter += 1;
1390
1391        if let (Some(info), Some(mesh)) = (infos.get(idx), node.node_data.mesh_mut()) {
1392            if mesh.positions.is_empty() && info.vertex_count > 0 && info.vert_array_offset > 0 {
1393                let pos_end = info.vert_array_offset + (info.vertex_count * 12);
1394                if pos_end <= bytes.len() {
1395                    let mut positions = Vec::with_capacity(info.vertex_count);
1396                    for i in 0..info.vertex_count {
1397                        let o = info.vert_array_offset + (i * 12);
1398                        positions.push([
1399                            read_f32(bytes, o)?,
1400                            read_f32(bytes, o + 4)?,
1401                            read_f32(bytes, o + 8)?,
1402                        ]);
1403                    }
1404                    mesh.positions = positions;
1405                } else {
1406                    crate::trace_warn!(
1407                        vert_array_offset = info.vert_array_offset,
1408                        vertex_count = info.vertex_count,
1409                        pos_end,
1410                        bytes_len = bytes.len(),
1411                        "fallback content position data extends beyond buffer"
1412                    );
1413                }
1414            }
1415        }
1416    }
1417
1418    for child in &mut node.children {
1419        fill_missing_positions(child, bytes, infos, counter)?;
1420    }
1421
1422    Ok(())
1423}
1424
1425/// Walks the node tree in DFS order, applying vertex data from `results`
1426/// to each mesh node. The results are indexed by DFS mesh order (matching
1427/// the order meshes were encountered during Pass 1).
1428fn apply_vertex_data(node: &mut MdlNode, results: &mut Vec<MdxVertexData>, counter: &mut usize) {
1429    if node.node_data.mesh().is_some() {
1430        let idx = *counter;
1431        *counter += 1;
1432
1433        if idx < results.len() {
1434            let mut data = std::mem::take(&mut results[idx]);
1435            let bone_weights = std::mem::take(&mut data.bone_weights);
1436            let bone_indices = std::mem::take(&mut data.bone_indices);
1437
1438            if let Some(mesh) = node.node_data.mesh_mut() {
1439                mesh.positions = data.positions;
1440                mesh.normals = data.normals;
1441                mesh.vertex_colors = data.vertex_colors;
1442                mesh.uv1 = data.uv1;
1443                mesh.uv2 = data.uv2;
1444                mesh.uv3 = data.uv3;
1445                mesh.uv4 = data.uv4;
1446                mesh.tangent_space = data.tangent_space;
1447                if let Some(clamped) = data.clamped_vertex_count {
1448                    mesh.vertex_count = clamped;
1449                }
1450            }
1451
1452            // Move bone weight/index data to the skin wrapper (only skins
1453            // carry these fields).
1454            if let MdlNodeData::Skin(skin) = &mut node.node_data {
1455                skin.bone_weights = bone_weights;
1456                skin.bone_indices = bone_indices;
1457            }
1458        }
1459    }
1460
1461    for child in &mut node.children {
1462        apply_vertex_data(child, results, counter);
1463    }
1464}
1465
1466// ---------------------------------------------------------------------------
1467// Non-mesh node type readers (unchanged)
1468// ---------------------------------------------------------------------------
1469
1470/// Reads a fixed-size null-terminated string from the byte buffer.
1471fn read_fixed_string(bytes: &[u8], offset: usize, max_len: usize) -> String {
1472    crate::binary::read_fixed_c_string(bytes, offset, max_len)
1473}
1474
1475/// Reads a variable-length null-terminated string from the byte buffer.
1476fn read_cstring(bytes: &[u8], offset: usize) -> String {
1477    crate::binary::read_c_string(bytes, offset)
1478}
1479
1480/// Reads a CExoArrayList (ptr/count pair) with bounds checking and OOB warning.
1481///
1482/// Extracts the content-relative pointer and element count from the given field
1483/// offsets, performs a bounds check, and delegates to `reader(bytes, ptr, count)`.
1484/// Returns an empty vec if count is zero, ptr is null, or data is out of bounds.
1485fn read_cexo_array<T>(
1486    bytes: &[u8],
1487    offset: usize,
1488    ptr_field: usize,
1489    count_field: usize,
1490    element_size: usize,
1491    label: &'static str,
1492    reader: impl FnOnce(&[u8], usize, usize) -> Result<Vec<T>, MdlError>,
1493) -> Result<Vec<T>, MdlError> {
1494    let ptr = checked_to_usize(read_u32(bytes, offset + ptr_field)?, label)?;
1495    let count = checked_to_usize(read_u32(bytes, offset + count_field)?, label)?;
1496
1497    if count == 0 || ptr == 0 {
1498        return Ok(Vec::new());
1499    }
1500
1501    let byte_size = count * element_size;
1502    if ptr + byte_size > bytes.len() {
1503        crate::trace_warn!(
1504            ptr,
1505            count,
1506            bytes_len = bytes.len(),
1507            label,
1508            "CExoArrayList extends beyond buffer"
1509        );
1510        return Ok(Vec::new());
1511    }
1512
1513    reader(bytes, ptr, count)
1514}
1515
1516/// Reads `count` sequential `f32` values starting at `ptr`.
1517fn read_f32_array(bytes: &[u8], ptr: usize, count: usize) -> Result<Vec<f32>, MdlError> {
1518    let mut result = Vec::with_capacity(count);
1519    for i in 0..count {
1520        result.push(read_f32(bytes, ptr + i * 4)?);
1521    }
1522    Ok(result)
1523}
1524
1525/// Reads `count` sequential `[f32; 3]` vectors (12 bytes each) starting at `ptr`.
1526fn read_vec3_array(bytes: &[u8], ptr: usize, count: usize) -> Result<Vec<[f32; 3]>, MdlError> {
1527    let mut result = Vec::with_capacity(count);
1528    for i in 0..count {
1529        let base = ptr + i * 12;
1530        result.push([
1531            read_f32(bytes, base)?,
1532            read_f32(bytes, base + 4)?,
1533            read_f32(bytes, base + 8)?,
1534        ]);
1535    }
1536    Ok(result)
1537}
1538
1539/// Reads `count` sequential `[f32; 4]` quaternions (16 bytes each) starting at `ptr`.
1540fn read_quat_array(bytes: &[u8], ptr: usize, count: usize) -> Result<Vec<[f32; 4]>, MdlError> {
1541    let mut result = Vec::with_capacity(count);
1542    for i in 0..count {
1543        let base = ptr + i * 16;
1544        result.push([
1545            read_f32(bytes, base)?,
1546            read_f32(bytes, base + 4)?,
1547            read_f32(bytes, base + 8)?,
1548            read_f32(bytes, base + 12)?,
1549        ]);
1550    }
1551    Ok(result)
1552}
1553
1554/// Reads a Reference node header (36 extra bytes after the base node header).
1555///
1556/// Layout: `ref_model` (char[32]) + `reattachable` (i32).
1557/// See `docs/notes/mdl_mdx.md` §Non-Mesh Node Type Structs.
1558fn read_reference_header(bytes: &[u8], offset: usize) -> Result<MdlReference, MdlError> {
1559    check_slice_in_bounds(bytes, offset, REFERENCE_EXTRA_SIZE, "reference_header")?;
1560
1561    let ref_model = read_fixed_string(bytes, offset, 32);
1562    let reattachable = read_i32(bytes, offset + 0x20)?;
1563
1564    Ok(MdlReference {
1565        ref_model,
1566        reattachable,
1567    })
1568}
1569
1570/// Reads a Light node header (92 extra bytes after the base node header).
1571///
1572/// Scalar fields are parsed into typed fields. Array headers (5 × 12 bytes)
1573/// are preserved as raw bytes since they contain file-relative pointers.
1574/// See `docs/notes/mdl_mdx.md` §Non-Mesh Node Type Structs.
1575/// Reads Light extension fields (92 extra bytes after the base node header).
1576///
1577/// Parses scalar fields and follows CExoArrayList pointers for flare data:
1578/// sizes, positions, color shifts, and texture names.
1579///
1580/// See `docs/notes/mdl_mdx.md` §Non-Mesh Node Type Structs.
1581fn read_light_header(bytes: &[u8], offset: usize) -> Result<MdlLight, MdlError> {
1582    check_slice_in_bounds(bytes, offset, LIGHT_EXTRA_SIZE, "light_header")?;
1583
1584    let flare_radius = read_f32(bytes, offset + light_offsets::FLARE_RADIUS)?;
1585
1586    // Texture SafePointers (runtime-only, 3×u32 at +0x04).
1587    let sp_base = offset + light_offsets::TEXTURE_SAFE_PTRS_PTR;
1588    let texture_safe_ptrs = [
1589        read_u32(bytes, sp_base)?,
1590        read_u32(bytes, sp_base + 4)?,
1591        read_u32(bytes, sp_base + 8)?,
1592    ];
1593
1594    // Flare sizes: CExoArrayList<float> at +0x10
1595    let flare_sizes = read_cexo_array(
1596        bytes,
1597        offset,
1598        light_offsets::FLARE_SIZES_PTR,
1599        light_offsets::FLARE_SIZES_COUNT,
1600        4,
1601        "flare_sizes",
1602        read_f32_array,
1603    )?;
1604
1605    // Flare positions: CExoArrayList<float> at +0x1C
1606    let flare_positions = read_cexo_array(
1607        bytes,
1608        offset,
1609        light_offsets::FLARE_POSITIONS_PTR,
1610        light_offsets::FLARE_POSITIONS_COUNT,
1611        4,
1612        "flare_positions",
1613        read_f32_array,
1614    )?;
1615
1616    // Flare color shifts: CExoArrayList<Vector> at +0x28
1617    let flare_color_shifts = read_cexo_array(
1618        bytes,
1619        offset,
1620        light_offsets::FLARE_COLOR_SHIFTS_PTR,
1621        light_offsets::FLARE_COLOR_SHIFTS_COUNT,
1622        12,
1623        "flare_color_shifts",
1624        read_vec3_array,
1625    )?;
1626
1627    // Flare texture names: CExoArrayList<char*> at +0x34
1628    // Each entry is a u32 content-relative offset to a null-terminated string.
1629    let flare_texture_names = read_cexo_array(
1630        bytes,
1631        offset,
1632        light_offsets::FLARE_TEX_NAMES_PTR,
1633        light_offsets::FLARE_TEX_NAMES_COUNT,
1634        4,
1635        "flare_tex_names",
1636        |b, ptr, count| {
1637            let mut names = Vec::with_capacity(count);
1638            for i in 0..count {
1639                let str_offset =
1640                    checked_to_usize(read_u32(b, ptr + i * 4)?, "flare_tex_name_str_ptr")?;
1641                if str_offset > 0 && str_offset < b.len() {
1642                    names.push(read_cstring(b, str_offset));
1643                } else {
1644                    names.push(String::new());
1645                }
1646            }
1647            Ok(names)
1648        },
1649    )?;
1650
1651    let priority = read_i32(bytes, offset + light_offsets::PRIORITY)?;
1652    let num_dynamic_types = read_i32(bytes, offset + light_offsets::NUM_DYNAMIC_TYPES)?;
1653    let affectdynamic = read_i32(bytes, offset + light_offsets::AFFECTDYNAMIC)?;
1654    let shadow = read_i32(bytes, offset + light_offsets::SHADOW)?;
1655    let ambientonly = read_i32(bytes, offset + light_offsets::AMBIENTONLY)?;
1656    let generateflare = read_i32(bytes, offset + light_offsets::GENERATEFLARE)?;
1657    let fading_light = read_i32(bytes, offset + light_offsets::FADING_LIGHT)?;
1658
1659    Ok(MdlLight {
1660        flare_radius,
1661        texture_safe_ptrs,
1662        flare_sizes,
1663        flare_positions,
1664        flare_color_shifts,
1665        flare_texture_names,
1666        priority,
1667        num_dynamic_types,
1668        affectdynamic,
1669        shadow,
1670        ambientonly,
1671        generateflare,
1672        fading_light,
1673    })
1674}
1675
1676/// Reads an Emitter node header (224 extra bytes after the base node header).
1677///
1678/// All fields are inline fixed-size data - no pointer relocation needed.
1679/// See `docs/notes/mdl_mdx.md` §Non-Mesh Node Type Structs.
1680fn read_emitter_header(bytes: &[u8], offset: usize) -> Result<super::types::MdlEmitter, MdlError> {
1681    check_slice_in_bounds(bytes, offset, EMITTER_EXTRA_SIZE, "emitter_header")?;
1682
1683    let deadspace = read_f32(bytes, offset)?;
1684    let blast_radius = read_f32(bytes, offset + 0x04)?;
1685    let blast_length = read_f32(bytes, offset + 0x08)?;
1686    let num_branches = read_i32(bytes, offset + 0x0C)?;
1687    let control_pt_smoothing = read_i32(bytes, offset + 0x10)?;
1688    let x_grid = read_i32(bytes, offset + 0x14)?;
1689    let y_grid = read_i32(bytes, offset + 0x18)?;
1690    let spawn_type = read_i32(bytes, offset + 0x1C)?;
1691
1692    let update = read_fixed_string(bytes, offset + 0x20, 32);
1693    let render = read_fixed_string(bytes, offset + 0x40, 32);
1694    let blend = read_fixed_string(bytes, offset + 0x60, 32);
1695    let texture = read_fixed_string(bytes, offset + 0x80, 32);
1696    let chunk_name = read_fixed_string(bytes, offset + 0xA0, 16);
1697
1698    let two_sided_tex = read_i32(bytes, offset + 0xB0)?;
1699    let loop_emitter = read_i32(bytes, offset + 0xB4)?;
1700    let render_order = read_u16(bytes, offset + 0xB8)?;
1701    let frame_blending = read_u8(bytes, offset + 0xBA)? != 0;
1702    let depth_texture_name = read_fixed_string(bytes, offset + 0xBB, 16);
1703
1704    // +0xCB..+0xE0: 21 bytes reserved/padding - read verbatim
1705    let mut reserved = [0u8; 21];
1706    reserved.copy_from_slice(&bytes[offset + 0xCB..offset + 0xE0]);
1707
1708    Ok(super::types::MdlEmitter {
1709        deadspace,
1710        blast_radius,
1711        blast_length,
1712        num_branches,
1713        control_pt_smoothing,
1714        x_grid,
1715        y_grid,
1716        spawn_type,
1717        update,
1718        render,
1719        blend,
1720        texture,
1721        chunk_name,
1722        two_sided_tex,
1723        loop_emitter,
1724        render_order,
1725        frame_blending,
1726        depth_texture_name,
1727        reserved,
1728    })
1729}
1730
1731/// Reads DanglyMesh extension fields (28 extra bytes after the TriMesh header).
1732///
1733/// Parses the constraint array (per-vertex floats), three inline physics
1734/// parameters, and the per-vertex dangly position array.
1735///
1736/// The data pointer at +0x18 references `vertex_count` vec3 positions in the
1737/// MDL content blob. At runtime, `PartDanglyMesh` (`0x00447980`) copies these
1738/// into a GL vertex pool for the dangly physics simulation.
1739///
1740/// See `docs/notes/mdl_mdx.md` §Mesh Subtype Structs.
1741fn read_dangly_extra(bytes: &[u8], offset: usize, mesh: MdlMesh) -> Result<MdlDangly, MdlError> {
1742    check_slice_in_bounds(bytes, offset, DANGLY_EXTRA_SIZE, "dangly_extra")?;
1743
1744    // CExoArrayList<float> at +0x00: per-vertex constraint weights.
1745    let constraints = read_cexo_array(
1746        bytes,
1747        offset,
1748        dangly_offsets::CONSTRAINTS_PTR,
1749        dangly_offsets::CONSTRAINTS_COUNT,
1750        4,
1751        "dangly_constraints",
1752        read_f32_array,
1753    )?;
1754
1755    let displacement = read_f32(bytes, offset + dangly_offsets::DISPLACEMENT)?;
1756    let tightness = read_f32(bytes, offset + dangly_offsets::TIGHTNESS)?;
1757    let period = read_f32(bytes, offset + dangly_offsets::PERIOD)?;
1758
1759    // Conditional data pointer at +0x18: vertex_count × vec3 positions.
1760    // Relocated against MDL content base when vertex_count > 0 (ResetDangly).
1761    let dangly_verts_ptr = checked_to_usize(
1762        read_u32(bytes, offset + dangly_offsets::DATA_PTR)?,
1763        "dangly_verts_ptr",
1764    )?;
1765
1766    // Read per-vertex dangly positions (vertex_count × vec3, 12 bytes each).
1767    let vert_count = usize::from(mesh.vertex_count);
1768    let mut dangly_vertices = Vec::with_capacity(vert_count);
1769    if vert_count > 0 && dangly_verts_ptr > 0 {
1770        let required = vert_count * 12;
1771        if dangly_verts_ptr + required <= bytes.len() {
1772            dangly_vertices = read_vec3_array(bytes, dangly_verts_ptr, vert_count)?;
1773        } else {
1774            crate::trace_warn!(
1775                dangly_verts_ptr,
1776                vert_count,
1777                bytes_len = bytes.len(),
1778                "dangly vertices array extends beyond buffer"
1779            );
1780        }
1781    }
1782
1783    Ok(MdlDangly {
1784        mesh,
1785        constraints,
1786        displacement,
1787        tightness,
1788        period,
1789        dangly_vertices,
1790    })
1791}
1792
1793/// Reads Skin extension fields (100 extra bytes after the TriMesh header).
1794///
1795/// Parses the weights CExoArrayList, MDX bone weight/index offsets, bonemap,
1796/// three CExoArrayList arrays (qbone, tbone, bone_constant_indices), and the
1797/// fixed-size bone_node_numbers array.
1798///
1799/// All fields are fully typed - no raw blob is preserved.
1800///
1801/// See `docs/notes/mdl_mdx.md` §Mesh Subtype Structs.
1802fn read_skin_extra(bytes: &[u8], offset: usize, mesh: MdlMesh) -> Result<MdlSkin, MdlError> {
1803    check_slice_in_bounds(bytes, offset, SKIN_EXTRA_SIZE, "skin_extra")?;
1804
1805    // Weights CExoArrayList header at +0x00: ptr, count, alloc (12 bytes).
1806    // Always zeros in vanilla binary files - engine uses MDX bone data instead.
1807    // SkinVertexWeight (52 bytes/element) is only populated by the ASCII parser.
1808    // Weights CExoArrayList at +0x00: always zeros in binary - writer emits zeros.
1809
1810    // MDX per-vertex bone weight/index offsets at +0x0C/+0x10.
1811    let mdx_bone_weights_offset = read_i32(bytes, offset + skin_offsets::MDX_BONE_WEIGHTS_OFFSET)?;
1812    let mdx_bone_indices_offset = read_i32(bytes, offset + skin_offsets::MDX_BONE_INDICES_OFFSET)?;
1813
1814    // Bonemap pointer + count at +0x14/+0x18.
1815    let bonemap_ptr = checked_to_usize(
1816        read_u32(bytes, offset + skin_offsets::BONEMAP_PTR)?,
1817        "skin_bonemap_ptr",
1818    )?;
1819    let bonemap_count = read_u32(bytes, offset + skin_offsets::BONEMAP_COUNT)?;
1820
1821    let mut bonemap = Vec::new();
1822    if bonemap_count > 0 && bonemap_ptr > 0 {
1823        let entry_count = checked_to_usize(bonemap_count, "bonemap_count")?;
1824        let byte_len = entry_count.checked_mul(4).unwrap_or(0);
1825        if byte_len > 0 && bonemap_ptr + byte_len <= bytes.len() {
1826            bonemap.reserve(entry_count);
1827            for i in 0..entry_count {
1828                bonemap.push(read_u32(bytes, bonemap_ptr + i * 4)?);
1829            }
1830        } else {
1831            crate::trace_warn!(
1832                bonemap_ptr,
1833                bonemap_count,
1834                bytes_len = bytes.len(),
1835                "skin bonemap extends beyond buffer"
1836            );
1837        }
1838    }
1839
1840    // CExoArrayList<Quaternion> at +0x1C: inverse bind rotations
1841    let qbone_ref_inv = read_cexo_array(
1842        bytes,
1843        offset,
1844        skin_offsets::QBONE_REF_INV_PTR,
1845        skin_offsets::QBONE_REF_INV_COUNT,
1846        16,
1847        "skin_qbone_ref_inv",
1848        read_quat_array,
1849    )?;
1850
1851    // CExoArrayList<Vector> at +0x28: inverse bind translations
1852    let tbone_ref_inv = read_cexo_array(
1853        bytes,
1854        offset,
1855        skin_offsets::TBONE_REF_INV_PTR,
1856        skin_offsets::TBONE_REF_INV_COUNT,
1857        12,
1858        "skin_tbone_ref_inv",
1859        read_vec3_array,
1860    )?;
1861
1862    // CExoArrayList<int> at +0x34: bone constant indices
1863    let bone_constant_indices = read_cexo_array(
1864        bytes,
1865        offset,
1866        skin_offsets::BONE_CONSTANT_INDICES_PTR,
1867        skin_offsets::BONE_CONSTANT_INDICES_COUNT,
1868        4,
1869        "skin_bone_constant_indices",
1870        |b, ptr, count| {
1871            let mut indices = Vec::with_capacity(count);
1872            for i in 0..count {
1873                indices.push(read_i32(b, ptr + (i * 4))?);
1874            }
1875            Ok(indices)
1876        },
1877    )?;
1878
1879    // bone_node_numbers: 16 × u16 at +0x40.
1880    let mut bone_node_numbers = [0u16; 16];
1881    let bnn_offset = offset + skin_offsets::BONE_NODE_NUMBERS;
1882    for (i, slot) in bone_node_numbers.iter_mut().enumerate() {
1883        *slot = read_u16(bytes, bnn_offset + i * 2)?;
1884    }
1885
1886    // +0x60..+0x63: Padding (leaked runtime pointers in ~74 vanilla models).
1887    // Writer emits zeros - this is garbage data the engine doesn't read.
1888
1889    Ok(MdlSkin {
1890        mesh,
1891        mdx_bone_weights_offset,
1892        mdx_bone_indices_offset,
1893        bone_weights: Vec::new(),
1894        bone_indices: Vec::new(),
1895        bonemap,
1896        qbone_ref_inv,
1897        tbone_ref_inv,
1898        bone_constant_indices,
1899        bone_node_numbers,
1900    })
1901}
1902
1903/// Reads AnimMesh extension fields (56 extra bytes after the TriMesh header).
1904///
1905/// Parses one inline scalar (`sample_period`), two CExoArrayList arrays,
1906/// and six runtime-only fields:
1907/// - `anim_verts`: animated vertex positions (Vector = 3 × f32 each)
1908/// - `anim_t_verts`: animated texture coordinates (Vector = 3 × f32 each)
1909/// - Runtime fields at +0x1C..+0x37: always zero in authored files, preserved
1910///   for roundtrip fidelity.
1911///
1912/// See `docs/notes/mdl_mdx.md` §Mesh Subtype Structs.
1913fn read_anim_mesh_extra(
1914    bytes: &[u8],
1915    offset: usize,
1916    mesh: MdlMesh,
1917) -> Result<MdlAnimMesh, MdlError> {
1918    check_slice_in_bounds(bytes, offset, ANIM_MESH_EXTRA_SIZE, "anim_mesh_extra")?;
1919
1920    let sample_period = read_f32(bytes, offset + anim_mesh_offsets::SAMPLE_PERIOD)?;
1921
1922    // CExoArrayList<Vector> at +0x04: animated vertex positions
1923    let anim_verts = read_cexo_array(
1924        bytes,
1925        offset,
1926        anim_mesh_offsets::ANIM_VERTS_PTR,
1927        anim_mesh_offsets::ANIM_VERTS_COUNT,
1928        12,
1929        "anim_verts",
1930        read_vec3_array,
1931    )?;
1932
1933    // CExoArrayList<Vector> at +0x10: animated texture coordinates
1934    let anim_t_verts = read_cexo_array(
1935        bytes,
1936        offset,
1937        anim_mesh_offsets::ANIM_T_VERTS_PTR,
1938        anim_mesh_offsets::ANIM_T_VERTS_COUNT,
1939        12,
1940        "anim_t_verts",
1941        read_vec3_array,
1942    )?;
1943
1944    // Runtime-only fields at +0x1C..+0x37 (no ASCII parser names).
1945    // Always zero in authored files; preserved for roundtrip fidelity.
1946    let data_ptr_1 = read_u32(bytes, offset + anim_mesh_offsets::DATA_PTR_1)?;
1947    let data_count_1 = read_u32(bytes, offset + anim_mesh_offsets::DATA_COUNT_1)?;
1948    let padding_24 = read_u32(bytes, offset + anim_mesh_offsets::PADDING_24)?;
1949    let anim_vertices_ptr = read_u32(bytes, offset + anim_mesh_offsets::ANIM_VERTICES_PTR)?;
1950    let anim_tex_vertices_ptr = read_u32(bytes, offset + anim_mesh_offsets::ANIM_TEX_VERTICES_PTR)?;
1951    let anim_vertices_count_val = read_u32(bytes, offset + anim_mesh_offsets::ANIM_VERTICES_COUNT)?;
1952    let anim_tex_vertices_count =
1953        read_u32(bytes, offset + anim_mesh_offsets::ANIM_TEX_VERTICES_COUNT)?;
1954
1955    Ok(MdlAnimMesh {
1956        mesh,
1957        sample_period,
1958        anim_verts,
1959        anim_t_verts,
1960        data_ptr_1,
1961        data_count_1,
1962        padding_24,
1963        anim_vertices_ptr,
1964        anim_tex_vertices_ptr,
1965        anim_vertices_count: anim_vertices_count_val,
1966        anim_tex_vertices_count,
1967    })
1968}
1969
1970/// Reads AABB extension fields (4 extra bytes after the TriMesh header).
1971///
1972/// The 4-byte extra header contains a single pointer to the AABB binary
1973/// search tree root. The tree is parsed recursively by following child
1974/// pointers - each node is 40 bytes containing bounding box, child
1975/// pointers, face index, and split direction flags.
1976///
1977/// See `docs/notes/mdl_mdx.md` §AABB Tree Node Layout.
1978fn read_aabb_extra(
1979    bytes: &[u8],
1980    offset: usize,
1981    _node_data_end_hint: usize,
1982    mesh: MdlMesh,
1983) -> Result<MdlAabb, MdlError> {
1984    check_slice_in_bounds(bytes, offset, AABB_EXTRA_SIZE, "aabb_extra")?;
1985
1986    let aabb_tree_ptr = checked_to_usize(
1987        read_u32(bytes, offset + aabb_offsets::TREE_PTR)?,
1988        "aabb_tree_ptr",
1989    )?;
1990
1991    let aabb_tree = if aabb_tree_ptr > 0 {
1992        Some(Box::new(read_aabb_tree(bytes, aabb_tree_ptr)?))
1993    } else {
1994        None
1995    };
1996
1997    Ok(MdlAabb { mesh, aabb_tree })
1998}
1999
2000/// Recursively reads an AABB binary search tree node and its children.
2001///
2002/// Each node is 40 bytes on disk. Child pointers are content-blob-relative
2003/// offsets (0 = no child / leaf). The tree is followed depth-first.
2004///
2005/// See `docs/notes/mdl_mdx.md` §AABB Tree Node Layout.
2006fn read_aabb_tree(bytes: &[u8], ptr: usize) -> Result<AabbNode, MdlError> {
2007    const AABB_NODE_SIZE: usize = 40;
2008
2009    check_slice_in_bounds(bytes, ptr, AABB_NODE_SIZE, "aabb_node")?;
2010
2011    let box_min = [
2012        read_f32(bytes, ptr)?,
2013        read_f32(bytes, ptr + 4)?,
2014        read_f32(bytes, ptr + 8)?,
2015    ];
2016    let box_max = [
2017        read_f32(bytes, ptr + 12)?,
2018        read_f32(bytes, ptr + 16)?,
2019        read_f32(bytes, ptr + 20)?,
2020    ];
2021    // Note: right_child at +0x18, left_child at +0x1C (Ghidra struct order).
2022    let right_ptr = checked_to_usize(read_u32(bytes, ptr + 24)?, "aabb_right_child")?;
2023    let left_ptr = checked_to_usize(read_u32(bytes, ptr + 28)?, "aabb_left_child")?;
2024    let face_index = read_i32(bytes, ptr + 32)?;
2025    let split_direction_flags = read_u32(bytes, ptr + 36)?;
2026
2027    let left = if left_ptr > 0 {
2028        Some(Box::new(read_aabb_tree(bytes, left_ptr)?))
2029    } else {
2030        None
2031    };
2032    let right = if right_ptr > 0 {
2033        Some(Box::new(read_aabb_tree(bytes, right_ptr)?))
2034    } else {
2035        None
2036    };
2037
2038    Ok(AabbNode {
2039        box_min,
2040        box_max,
2041        face_index,
2042        split_direction_flags,
2043        left,
2044        right,
2045    })
2046}
2047
2048/// Reads Saber extension fields (20 extra bytes after the TriMesh header).
2049///
2050/// The 20-byte extra header contains 3 relocated data pointers and 2 runtime
2051/// GL pool IDs. All fields are preserved as raw bytes - the data pointers
2052/// contain file-relative offsets and the GL pool IDs are runtime-only values.
2053///
2054/// See `docs/notes/mdl_mdx.md` §Mesh Subtype Structs.
2055/// Reads Saber extension fields (20 extra bytes after the TriMesh header).
2056///
2057/// Structurally parses the three saber vertex arrays (positions, UVs, normals)
2058/// at known fixed sizes (176 vertices each), plus two runtime GL pool IDs.
2059///
2060/// Semantic names from kotorblender: `off_saber_verts`, `off_saber_uv`,
2061/// `off_saber_normals`.
2062///
2063/// See `docs/notes/mdl_mdx.md` §Mesh Subtype Structs.
2064fn read_saber_extra(bytes: &[u8], offset: usize, mesh: MdlMesh) -> Result<MdlSaber, MdlError> {
2065    check_slice_in_bounds(bytes, offset, SABER_EXTRA_SIZE, "saber_extra")?;
2066
2067    let verts_ptr = checked_to_usize(
2068        read_u32(bytes, offset + saber_offsets::VERTS_PTR)?,
2069        "saber_verts_ptr",
2070    )?;
2071    let uvs_ptr = checked_to_usize(
2072        read_u32(bytes, offset + saber_offsets::UVS_PTR)?,
2073        "saber_uvs_ptr",
2074    )?;
2075    let normals_ptr = checked_to_usize(
2076        read_u32(bytes, offset + saber_offsets::NORMALS_PTR)?,
2077        "saber_normals_ptr",
2078    )?;
2079    let gl_pool_vert = read_u32(bytes, offset + saber_offsets::GL_POOL_VERT)?;
2080    let gl_pool_index = read_u32(bytes, offset + saber_offsets::GL_POOL_INDEX)?;
2081
2082    // Positions: NUM_SABER_VERTS × vec3 (12 bytes each)
2083    let mut saber_verts = Vec::with_capacity(NUM_SABER_VERTS);
2084    if verts_ptr > 0 {
2085        let byte_size = NUM_SABER_VERTS * 12;
2086        if verts_ptr + byte_size <= bytes.len() {
2087            saber_verts = read_vec3_array(bytes, verts_ptr, NUM_SABER_VERTS)?;
2088        } else {
2089            crate::trace_warn!(
2090                verts_ptr,
2091                byte_size,
2092                bytes_len = bytes.len(),
2093                "saber_verts array extends beyond buffer"
2094            );
2095        }
2096    }
2097
2098    // UVs: NUM_SABER_VERTS × vec2 (8 bytes each)
2099    let mut saber_uvs = Vec::with_capacity(NUM_SABER_VERTS);
2100    if uvs_ptr > 0 {
2101        let byte_size = NUM_SABER_VERTS * 8;
2102        if uvs_ptr + byte_size <= bytes.len() {
2103            for i in 0..NUM_SABER_VERTS {
2104                let base = uvs_ptr + i * 8;
2105                saber_uvs.push([read_f32(bytes, base)?, read_f32(bytes, base + 4)?]);
2106            }
2107        } else {
2108            crate::trace_warn!(
2109                uvs_ptr,
2110                byte_size,
2111                bytes_len = bytes.len(),
2112                "saber_uvs array extends beyond buffer"
2113            );
2114        }
2115    }
2116
2117    // Normals: NUM_SABER_VERTS × vec3 (12 bytes each)
2118    let mut saber_normals = Vec::with_capacity(NUM_SABER_VERTS);
2119    if normals_ptr > 0 {
2120        let byte_size = NUM_SABER_VERTS * 12;
2121        if normals_ptr + byte_size <= bytes.len() {
2122            saber_normals = read_vec3_array(bytes, normals_ptr, NUM_SABER_VERTS)?;
2123        } else {
2124            crate::trace_warn!(
2125                normals_ptr,
2126                byte_size,
2127                bytes_len = bytes.len(),
2128                "saber_normals array extends beyond buffer"
2129            );
2130        }
2131    }
2132
2133    Ok(MdlSaber {
2134        mesh,
2135        saber_verts,
2136        saber_uvs,
2137        saber_normals,
2138        gl_pool_vert,
2139        gl_pool_index,
2140    })
2141}