Skip to main content

rakata_formats/mdl/
ascii_reader.rs

1//! ASCII MDL reader.
2//!
3//! Parses human-readable ASCII MDL text into an [`Mdl`] model struct,
4//! producing the same representation as the binary reader. The parser is
5//! line-oriented and case-insensitive, matching the engine's `FuncInterp` /
6//! `InternalParseField` dispatch pipeline.
7//!
8//! Derived fields (bounding box, bounding sphere, face planes, adjacency,
9//! surface area) are computed automatically via
10//! [`MdlMesh::recompute_derived_fields`] after parsing.
11
12use std::collections::HashMap;
13use std::io::BufRead;
14
15use super::ascii_names::{
16    classification_from_ascii, controller_from_ascii_name, is_non_controller_keyword,
17    node_data_from_ascii_name, node_type_context, NodeTypeContext,
18};
19use super::ascii_writer::MdlAsciiError;
20use super::controllers::{MdlController, MdlControllerType, MdlKey, CTRL_FLAG_BEZIER};
21use super::orientation::axis_angle_to_quat;
22use super::types::{
23    AabbNode, MdlAabb, MdlAnimMesh, MdlDangly, MdlEmitter, MdlFace, MdlLight, MdlMesh, MdlNodeData,
24    MdlReference, MdlSkin,
25};
26use super::{
27    collect_geo_positions, count_anim_nodes, count_nodes, Mdl, MdlAnimEvent, MdlAnimNode,
28    MdlAnimation, MdlNode,
29};
30
31// ---------------------------------------------------------------------------
32// Public API
33// ---------------------------------------------------------------------------
34
35/// Reads an ASCII MDL model from a buffered reader.
36#[cfg_attr(
37    feature = "tracing",
38    tracing::instrument(level = "debug", skip(reader))
39)]
40pub fn read_mdl_ascii<R: BufRead>(reader: R) -> Result<Mdl, MdlAsciiError> {
41    let mut raw_lines = Vec::new();
42    for (i, line) in reader.lines().enumerate() {
43        let line = line.map_err(MdlAsciiError::Io)?;
44        let trimmed = line.trim();
45        if !trimmed.is_empty() && !trimmed.starts_with('#') {
46            raw_lines.push((i + 1, trimmed.to_string()));
47        }
48    }
49    let mut parser = AsciiParser {
50        lines: raw_lines,
51        pos: 0,
52    };
53    parse_model(&mut parser)
54}
55
56/// Reads an ASCII MDL model from a string.
57#[cfg_attr(
58    feature = "tracing",
59    tracing::instrument(level = "debug", skip(s), fields(bytes_len = s.len()))
60)]
61pub fn read_mdl_ascii_from_str(s: &str) -> Result<Mdl, MdlAsciiError> {
62    read_mdl_ascii(std::io::Cursor::new(s))
63}
64
65// ---------------------------------------------------------------------------
66// Line-oriented tokenizer
67// ---------------------------------------------------------------------------
68
69struct AsciiParser {
70    lines: Vec<(usize, String)>,
71    pos: usize,
72}
73
74impl AsciiParser {
75    /// Advances and returns the (line_number, content) pair.
76    ///
77    /// Takes ownership of the line string via `mem::take`, avoiding a clone.
78    /// The parser only moves forward so consumed lines are never revisited.
79    fn next_line(&mut self) -> Option<(usize, String)> {
80        if self.pos < self.lines.len() {
81            let (ln, ref mut s) = self.lines[self.pos];
82            self.pos += 1;
83            Some((ln, std::mem::take(s)))
84        } else {
85            None
86        }
87    }
88
89    fn peek_line(&self) -> Option<(usize, &str)> {
90        if self.pos < self.lines.len() {
91            let (ln, ref s) = self.lines[self.pos];
92            Some((ln, s.as_str()))
93        } else {
94            None
95        }
96    }
97
98    fn parse_err(&self, line: usize, msg: impl Into<String>) -> MdlAsciiError {
99        MdlAsciiError::Parse {
100            line,
101            message: msg.into(),
102        }
103    }
104}
105
106fn tokens(line: &str) -> Vec<&str> {
107    line.split_whitespace().collect()
108}
109
110fn parse_f32(s: &str, line: usize) -> Result<f32, MdlAsciiError> {
111    s.parse::<f32>().map_err(|_| MdlAsciiError::Parse {
112        line,
113        message: format!("invalid float: {s}"),
114    })
115}
116
117fn parse_u32(s: &str, line: usize) -> Result<u32, MdlAsciiError> {
118    s.parse::<u32>().map_err(|_| MdlAsciiError::Parse {
119        line,
120        message: format!("invalid u32: {s}"),
121    })
122}
123
124fn parse_i32(s: &str, line: usize) -> Result<i32, MdlAsciiError> {
125    s.parse::<i32>().map_err(|_| MdlAsciiError::Parse {
126        line,
127        message: format!("invalid i32: {s}"),
128    })
129}
130
131fn parse_u16(s: &str, line: usize) -> Result<u16, MdlAsciiError> {
132    s.parse::<u16>().map_err(|_| MdlAsciiError::Parse {
133        line,
134        message: format!("invalid u16: {s}"),
135    })
136}
137
138fn parse_u8(s: &str, line: usize) -> Result<u8, MdlAsciiError> {
139    s.parse::<u8>().map_err(|_| MdlAsciiError::Parse {
140        line,
141        message: format!("invalid u8: {s}"),
142    })
143}
144
145fn eq_ci(a: &str, b: &str) -> bool {
146    a.eq_ignore_ascii_case(b)
147}
148
149// ---------------------------------------------------------------------------
150// Intermediate flat node for tree assembly
151// ---------------------------------------------------------------------------
152
153struct FlatNode {
154    name: String,
155    parent_name: String, // "NULL" for root
156    position: [f32; 3],
157    rotation: [f32; 4], // quaternion [w, x, y, z]
158    node_data: MdlNodeData,
159    controllers: Vec<MdlController>,
160}
161
162struct FlatAnimNode {
163    name: String,
164    parent_name: String,
165    controllers: Vec<MdlController>,
166}
167
168// ---------------------------------------------------------------------------
169// Top-level model parser
170// ---------------------------------------------------------------------------
171
172fn parse_model(p: &mut AsciiParser) -> Result<Mdl, MdlAsciiError> {
173    let mut _model_name = String::new();
174    let mut supermodel_name = String::new();
175    let mut classification: u8 = 0;
176    let mut subclassification: u8 = 0;
177    let mut affected_by_fog: u8 = 1;
178    let mut animation_scale: f32 = 1.0;
179    let mut headlink = false;
180    let mut bounding_box = [0.0f32; 6];
181    let mut radius: f32 = 0.0;
182    let mut geo_nodes: Vec<FlatNode> = Vec::new();
183    let mut animations: Vec<MdlAnimation> = Vec::new();
184
185    while let Some((ln, line)) = p.next_line() {
186        let toks = tokens(&line);
187        if toks.is_empty() {
188            continue;
189        }
190        let kw = toks[0];
191
192        if eq_ci(kw, "newmodel") {
193            if toks.len() >= 2 {
194                _model_name = toks[1].to_string();
195            }
196        } else if eq_ci(kw, "setsupermodel") {
197            if toks.len() >= 3 {
198                supermodel_name = toks[2].to_string();
199            }
200        } else if eq_ci(kw, "classification") && toks.len() >= 2 {
201            classification = classification_from_ascii(toks[1]).unwrap_or(0);
202        } else if eq_ci(kw, "classification_unk1") && toks.len() >= 2 {
203            subclassification = parse_u8(toks[1], ln)?;
204        } else if eq_ci(kw, "ignorefog") && toks.len() >= 2 {
205            let v = parse_i32(toks[1], ln)?;
206            affected_by_fog = if v != 0 { 0 } else { 1 };
207        } else if eq_ci(kw, "setanimationscale") && toks.len() >= 2 {
208            animation_scale = parse_f32(toks[1], ln)?;
209        } else if eq_ci(kw, "compress_quaternions") {
210            // Informational only -- not stored.
211        } else if eq_ci(kw, "headlink") && toks.len() >= 2 {
212            headlink = parse_i32(toks[1], ln)? != 0;
213        } else if eq_ci(kw, "beginmodelgeom") {
214            // Parse geometry block.
215            parse_geometry_block(p, &mut bounding_box, &mut radius, &mut geo_nodes)?;
216        } else if eq_ci(kw, "newanim") && toks.len() >= 3 {
217            let anim = parse_animation(p, toks[1], toks[2], ln)?;
218            animations.push(anim);
219        } else if eq_ci(kw, "donemodel") {
220            break;
221        }
222        // Skip unknown top-level keywords (filedependancy, etc.)
223    }
224
225    // Assemble geometry node tree.
226    let root_node = assemble_node_tree(geo_nodes)?;
227    let mut node_count = count_nodes(&root_node);
228
229    // Build geometry position map for animation position delta conversion.
230    let geo_positions = collect_geo_positions(&root_node);
231
232    // Build name->DFS-index map for animation node_number resolution.
233    let name_to_index = build_name_index_map(&root_node);
234
235    // Post-process animations: subtract geometry rest positions from
236    // animation position keyframes (ASCII stores absolute, binary stores
237    // deltas).
238    for anim in &mut animations {
239        subtract_geo_positions_from_anim(&mut anim.root_node, &geo_positions);
240        assign_anim_node_numbers(&mut anim.root_node, &name_to_index);
241    }
242
243    // Include animation nodes in total (binary header stores total across
244    // geometry + all animation trees).
245    for anim in &animations {
246        node_count += count_anim_nodes(&anim.root_node);
247    }
248
249    // Determine anim_root_node from headlink flag.
250    let anim_root_node = if headlink {
251        animations
252            .first()
253            .map(|a| a.anim_root.clone())
254            .filter(|s| !s.is_empty())
255    } else {
256        None
257    };
258
259    Ok(Mdl {
260        root_node,
261        geometry_fn_ptr1: 0,
262        geometry_fn_ptr2: 0,
263        model_type: 2,
264        classification,
265        subclassification,
266        affected_by_fog,
267        supermodel_name,
268        node_count,
269        bounding_box,
270        radius,
271        animation_scale,
272        animations,
273        anim_root_node,
274    })
275}
276
277// ---------------------------------------------------------------------------
278// Geometry block parser
279// ---------------------------------------------------------------------------
280
281fn parse_geometry_block(
282    p: &mut AsciiParser,
283    bbox: &mut [f32; 6],
284    radius: &mut f32,
285    nodes: &mut Vec<FlatNode>,
286) -> Result<(), MdlAsciiError> {
287    while let Some((ln, line)) = p.next_line() {
288        let toks = tokens(&line);
289        if toks.is_empty() {
290            continue;
291        }
292        let kw = toks[0];
293
294        if eq_ci(kw, "bmin") && toks.len() >= 4 {
295            bbox[0] = parse_f32(toks[1], ln)?;
296            bbox[1] = parse_f32(toks[2], ln)?;
297            bbox[2] = parse_f32(toks[3], ln)?;
298        } else if eq_ci(kw, "bmax") && toks.len() >= 4 {
299            bbox[3] = parse_f32(toks[1], ln)?;
300            bbox[4] = parse_f32(toks[2], ln)?;
301            bbox[5] = parse_f32(toks[3], ln)?;
302        } else if eq_ci(kw, "radius") && toks.len() >= 2 {
303            *radius = parse_f32(toks[1], ln)?;
304        } else if eq_ci(kw, "node") && toks.len() >= 3 {
305            let flat = parse_geometry_node(p, toks[1], toks[2], ln)?;
306            nodes.push(flat);
307        } else if eq_ci(kw, "endmodelgeom") {
308            break;
309        }
310    }
311    Ok(())
312}
313
314// ---------------------------------------------------------------------------
315// Geometry node parser
316// ---------------------------------------------------------------------------
317
318fn parse_geometry_node(
319    p: &mut AsciiParser,
320    type_str: &str,
321    name: &str,
322    _node_line: usize,
323) -> Result<FlatNode, MdlAsciiError> {
324    let mut flat = FlatNode {
325        name: name.to_string(),
326        parent_name: "NULL".into(),
327        position: [0.0; 3],
328        rotation: [1.0, 0.0, 0.0, 0.0],
329        node_data: node_data_from_type_str(type_str),
330        controllers: Vec::new(),
331    };
332
333    let ctx = node_type_context(&flat.node_data);
334
335    while let Some((ln, line)) = p.next_line() {
336        let toks = tokens(&line);
337        if toks.is_empty() {
338            continue;
339        }
340        let kw = toks[0];
341
342        if eq_ci(kw, "endnode") {
343            break;
344        } else if eq_ci(kw, "parent") && toks.len() >= 2 {
345            flat.parent_name = toks[1].to_string();
346        } else if eq_ci(kw, "position") && toks.len() >= 4 {
347            // Could be header position or single-key controller.
348            // If exactly 4 tokens (position x y z), treat as header position
349            // AND as a single-key controller (matching engine behavior).
350            flat.position = [
351                parse_f32(toks[1], ln)?,
352                parse_f32(toks[2], ln)?,
353                parse_f32(toks[3], ln)?,
354            ];
355        } else if eq_ci(kw, "orientation") && toks.len() >= 5 {
356            let aa = [
357                parse_f32(toks[1], ln)?,
358                parse_f32(toks[2], ln)?,
359                parse_f32(toks[3], ln)?,
360                parse_f32(toks[4], ln)?,
361            ];
362            flat.rotation = axis_angle_to_quat(aa);
363        } else if try_parse_controller_line(p, &toks, ln, ctx, &mut flat.controllers)? {
364            // Handled by controller parser.
365        } else {
366            // Try type-specific fields.
367            parse_node_field(&toks, ln, p, &mut flat.node_data)?;
368        }
369    }
370
371    // Post-process mesh derived fields.
372    if let Some(mesh) = flat.node_data.mesh_mut() {
373        mesh.vertex_count = u16::try_from(mesh.positions.len())
374            .map_err(|_| MdlAsciiError::InvalidData("vertex count exceeds u16".into()))?;
375        mesh.indices_per_face = 3;
376        // Count UV channels present.
377        let mut tc: u16 = 0;
378        if !mesh.uv1.is_empty() {
379            tc += 1;
380        }
381        if !mesh.uv2.is_empty() {
382            tc += 1;
383        }
384        if !mesh.uv3.is_empty() {
385            tc += 1;
386        }
387        if !mesh.uv4.is_empty() {
388            tc += 1;
389        }
390        mesh.texture_channel_count = tc;
391        mesh.recompute_derived_fields();
392    }
393
394    Ok(flat)
395}
396
397fn node_data_from_type_str(s: &str) -> MdlNodeData {
398    node_data_from_ascii_name(s)
399}
400
401// ---------------------------------------------------------------------------
402// Controller parsing
403// ---------------------------------------------------------------------------
404
405/// Attempts to parse a controller from the current line tokens.
406/// Returns true if the line was consumed as a controller.
407fn try_parse_controller_line(
408    p: &mut AsciiParser,
409    toks: &[&str],
410    ln: usize,
411    ctx: NodeTypeContext,
412    controllers: &mut Vec<MdlController>,
413) -> Result<bool, MdlAsciiError> {
414    if toks.is_empty() {
415        return Ok(false);
416    }
417
418    let kw = toks[0];
419
420    // Check for keyed block: "positionkey", "orientationbezierkey", etc.
421    let (base_name, is_bezier) = if let Some(base) = strip_suffix_ci(kw, "bezierkey") {
422        (base, true)
423    } else if let Some(base) = strip_suffix_ci(kw, "key") {
424        (base, false)
425    } else {
426        // Check for single-value inline controller.
427        return try_parse_inline_controller(toks, ln, ctx, controllers);
428    };
429
430    // Resolve controller type from name.
431    let ctrl_type = resolve_controller_type(base_name, ctx)?;
432
433    // Parse keyed block until "endlist".
434    let is_orientation = ctrl_type == MdlControllerType::ORIENTATION;
435    let mut keys = Vec::new();
436
437    while let Some((kln, kline)) = p.next_line() {
438        let ktoks = tokens(&kline);
439        if ktoks.is_empty() {
440            continue;
441        }
442        if eq_ci(ktoks[0], "endlist") {
443            break;
444        }
445        if ktoks.len() < 2 {
446            continue;
447        }
448
449        let time = parse_f32(ktoks[0], kln)?;
450        let mut values: Vec<f32> = Vec::new();
451        for t in &ktoks[1..] {
452            values.push(parse_f32(t, kln)?);
453        }
454
455        // Orientation: convert axis-angle -> quaternion [x,y,z,w] storage order.
456        if is_orientation && values.len() >= 4 {
457            let aa = [values[0], values[1], values[2], values[3]];
458            let q = axis_angle_to_quat(aa); // [w, x, y, z]
459                                            // Store as [x, y, z, w] (binary storage order).
460            values[0] = q[1];
461            values[1] = q[2];
462            values[2] = q[3];
463            values[3] = q[0];
464        }
465
466        keys.push(MdlKey { time, values });
467    }
468
469    let col_count = if let Some(first_key) = keys.first() {
470        u8::try_from(first_key.values.len()).map_err(|_| MdlAsciiError::Parse {
471            line: ln,
472            message: "column count exceeds u8".into(),
473        })?
474    } else {
475        0
476    };
477    let raw_column_count = if is_bezier {
478        col_count | CTRL_FLAG_BEZIER
479    } else {
480        col_count
481    };
482
483    controllers.push(MdlController {
484        controller_type: ctrl_type,
485        raw_column_count,
486        key_unknown_04: [0; 2],
487        key_unknown_0d: [0; 3],
488        keys,
489    });
490
491    Ok(true)
492}
493
494/// Attempts to parse a single-value inline controller (e.g., "scale 1.0").
495fn try_parse_inline_controller(
496    toks: &[&str],
497    ln: usize,
498    ctx: NodeTypeContext,
499    controllers: &mut Vec<MdlController>,
500) -> Result<bool, MdlAsciiError> {
501    if toks.len() < 2 {
502        return Ok(false);
503    }
504
505    let name = toks[0];
506
507    // Skip known non-controller keywords to avoid misinterpreting them.
508    // The caller handles these as type-specific fields.
509    if is_non_controller_keyword(name) {
510        return Ok(false);
511    }
512
513    // Try to resolve as a controller name.
514    let ctrl_type = match resolve_controller_type_optional(name, ctx) {
515        Some(ct) => ct,
516        None => return Ok(false),
517    };
518
519    // Parse values.
520    let is_orientation = ctrl_type == MdlControllerType::ORIENTATION;
521    let mut values: Vec<f32> = Vec::new();
522    for t in &toks[1..] {
523        match t.parse::<f32>() {
524            Ok(v) => values.push(v),
525            Err(_) => return Ok(false), // Not a controller line.
526        }
527    }
528
529    if values.is_empty() {
530        return Ok(false);
531    }
532
533    // Orientation: convert axis-angle -> quaternion [x,y,z,w].
534    if is_orientation && values.len() >= 4 {
535        let aa = [values[0], values[1], values[2], values[3]];
536        let q = axis_angle_to_quat(aa);
537        values[0] = q[1];
538        values[1] = q[2];
539        values[2] = q[3];
540        values[3] = q[0];
541    }
542
543    let raw_column_count = u8::try_from(values.len()).map_err(|_| MdlAsciiError::Parse {
544        line: ln,
545        message: "column count exceeds u8".into(),
546    })?;
547
548    controllers.push(MdlController {
549        controller_type: ctrl_type,
550        raw_column_count,
551        key_unknown_04: [0; 2],
552        key_unknown_0d: [0; 3],
553        keys: vec![MdlKey { time: 0.0, values }],
554    });
555
556    Ok(true)
557}
558
559/// Resolves a controller name to a type code, trying all contexts for
560/// animation nodes and falling back to `controller_N` pattern.
561fn resolve_controller_type(
562    name: &str,
563    ctx: NodeTypeContext,
564) -> Result<MdlControllerType, MdlAsciiError> {
565    resolve_controller_type_optional(name, ctx)
566        .ok_or_else(|| MdlAsciiError::InvalidData(format!("unknown controller: {name}")))
567}
568
569fn resolve_controller_type_optional(name: &str, ctx: NodeTypeContext) -> Option<MdlControllerType> {
570    // Try the node's own context first.
571    if let Some(ct) = controller_from_ascii_name(name, ctx) {
572        return Some(ct);
573    }
574    // Try all other contexts.
575    for alt_ctx in &[
576        NodeTypeContext::Base,
577        NodeTypeContext::Mesh,
578        NodeTypeContext::Light,
579        NodeTypeContext::Emitter,
580    ] {
581        if let Some(ct) = controller_from_ascii_name(name, *alt_ctx) {
582            return Some(ct);
583        }
584    }
585    // Try controller_N pattern.
586    let lower = name.to_ascii_lowercase();
587    if let Some(num_str) = lower.strip_prefix("controller_") {
588        if let Ok(code) = num_str.parse::<u32>() {
589            return Some(MdlControllerType::from_raw(code));
590        }
591    }
592    None
593}
594
595// ---------------------------------------------------------------------------
596// Type-specific field parsing
597// ---------------------------------------------------------------------------
598
599fn parse_node_field(
600    toks: &[&str],
601    ln: usize,
602    p: &mut AsciiParser,
603    data: &mut MdlNodeData,
604) -> Result<(), MdlAsciiError> {
605    // Mesh fields (shared by all mesh subtypes).
606    if let Some(mesh) = data.mesh_mut() {
607        if parse_mesh_field(toks, ln, p, mesh)? {
608            return Ok(());
609        }
610    }
611
612    // Type-specific fields.
613    let handled = match data {
614        MdlNodeData::Skin(skin) => parse_skin_field(toks, ln, p, skin)?,
615        MdlNodeData::Dangly(dangly) => parse_dangly_field(toks, ln, p, dangly)?,
616        MdlNodeData::Aabb(aabb) => parse_aabb_field(toks, ln, p, aabb)?,
617        MdlNodeData::Light(light) => parse_light_field(toks, ln, p, light)?,
618        MdlNodeData::Emitter(emitter) => parse_emitter_field(toks, ln, p, emitter)?,
619        MdlNodeData::Reference(reference) => parse_reference_field(toks, ln, reference)?,
620        MdlNodeData::AnimMesh(animmesh) => parse_animmesh_field(toks, ln, p, animmesh)?,
621        _ => false,
622    };
623
624    if handled {
625        return Ok(());
626    }
627
628    // Unknown fields silently skipped (matching engine behavior).
629    Ok(())
630}
631
632// ---------------------------------------------------------------------------
633// Mesh field parsing
634// ---------------------------------------------------------------------------
635
636fn parse_mesh_field(
637    toks: &[&str],
638    ln: usize,
639    p: &mut AsciiParser,
640    mesh: &mut MdlMesh,
641) -> Result<bool, MdlAsciiError> {
642    let kw = toks[0];
643
644    if eq_ci(kw, "diffuse") && toks.len() >= 4 {
645        mesh.diffuse_color = [
646            parse_f32(toks[1], ln)?,
647            parse_f32(toks[2], ln)?,
648            parse_f32(toks[3], ln)?,
649        ];
650    } else if eq_ci(kw, "ambient") && toks.len() >= 4 {
651        mesh.ambient_color = [
652            parse_f32(toks[1], ln)?,
653            parse_f32(toks[2], ln)?,
654            parse_f32(toks[3], ln)?,
655        ];
656    } else if eq_ci(kw, "transparencyhint") && toks.len() >= 2 {
657        mesh.transparency_hint = parse_i32(toks[1], ln)?;
658    } else if eq_ci(kw, "animateuv") && toks.len() >= 2 {
659        mesh.animate_uv = parse_i32(toks[1], ln)?;
660    } else if eq_ci(kw, "uvdirectionx") && toks.len() >= 2 {
661        mesh.uv_direction_x = parse_f32(toks[1], ln)?;
662    } else if eq_ci(kw, "uvdirectiony") && toks.len() >= 2 {
663        mesh.uv_direction_y = parse_f32(toks[1], ln)?;
664    } else if eq_ci(kw, "uvjitter") && toks.len() >= 2 {
665        mesh.uv_jitter = parse_f32(toks[1], ln)?;
666    } else if eq_ci(kw, "uvjitterspeed") && toks.len() >= 2 {
667        mesh.uv_jitter_speed = parse_f32(toks[1], ln)?;
668    } else if eq_ci(kw, "lightmapped") && toks.len() >= 2 {
669        mesh.light_mapped = parse_i32(toks[1], ln)? != 0;
670    } else if eq_ci(kw, "rotatetexture") && toks.len() >= 2 {
671        mesh.rotate_texture = parse_i32(toks[1], ln)? != 0;
672    } else if eq_ci(kw, "m_bIsBackgroundGeometry") && toks.len() >= 2 {
673        mesh.is_background_geometry = parse_i32(toks[1], ln)? != 0;
674    } else if eq_ci(kw, "shadow") && toks.len() >= 2 {
675        mesh.shadow = parse_i32(toks[1], ln)? != 0;
676    } else if eq_ci(kw, "beaming") && toks.len() >= 2 {
677        mesh.beaming = parse_i32(toks[1], ln)? != 0;
678    } else if eq_ci(kw, "render") && toks.len() >= 2 {
679        mesh.render = parse_i32(toks[1], ln)? != 0;
680    } else if (eq_ci(kw, "bitmap") || eq_ci(kw, "texture0")) && toks.len() >= 2 {
681        let val = toks[1];
682        mesh.texture_0 = if eq_ci(val, "NULL") {
683            String::new()
684        } else {
685            val.to_string()
686        };
687    } else if (eq_ci(kw, "bitmap2") || eq_ci(kw, "texture1")) && toks.len() >= 2 {
688        let val = toks[1];
689        mesh.texture_1 = if eq_ci(val, "NULL") {
690            String::new()
691        } else {
692            val.to_string()
693        };
694    } else if eq_ci(kw, "inv_count") && toks.len() >= 2 {
695        mesh.inverted_counter = parse_u32(toks[1], ln)?;
696    } else if eq_ci(kw, "verts") && toks.len() >= 2 {
697        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
698        mesh.positions = parse_vec3_block(p, count)?;
699    } else if eq_ci(kw, "faces") && toks.len() >= 2 {
700        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
701        mesh.faces = parse_face_block(p, count)?;
702    } else if (eq_ci(kw, "tverts") || eq_ci(kw, "tverts0")) && toks.len() >= 2 {
703        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
704        mesh.uv1 = parse_uv_block(p, count)?;
705    } else if eq_ci(kw, "tverts1") && toks.len() >= 2 {
706        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
707        mesh.uv2 = parse_uv_block(p, count)?;
708    } else if eq_ci(kw, "tverts2") && toks.len() >= 2 {
709        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
710        mesh.uv3 = parse_uv_block(p, count)?;
711    } else if eq_ci(kw, "tverts3") && toks.len() >= 2 {
712        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
713        mesh.uv4 = parse_uv_block(p, count)?;
714    } else if eq_ci(kw, "colors") && toks.len() >= 2 {
715        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
716        mesh.vertex_colors = parse_color_block(p, count)?;
717    } else if eq_ci(kw, "tangentspace")
718        || eq_ci(kw, "dirt_enabled")
719        || eq_ci(kw, "dirt_texture")
720        || eq_ci(kw, "dirt_worldspace")
721        || eq_ci(kw, "hologram_donotdraw")
722    {
723        // Informational fields -- parsed and ignored.
724    } else {
725        return Ok(false);
726    }
727
728    Ok(true)
729}
730
731fn parse_vec3_block(p: &mut AsciiParser, count: usize) -> Result<Vec<[f32; 3]>, MdlAsciiError> {
732    let mut result = Vec::with_capacity(count);
733    for _ in 0..count {
734        let (ln, line) = p
735            .next_line()
736            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in vec3 block".into()))?;
737        let toks = tokens(&line);
738        if toks.len() < 3 {
739            return Err(p.parse_err(ln, "expected 3 floats"));
740        }
741        result.push([
742            parse_f32(toks[0], ln)?,
743            parse_f32(toks[1], ln)?,
744            parse_f32(toks[2], ln)?,
745        ]);
746    }
747    Ok(result)
748}
749
750fn parse_face_block(p: &mut AsciiParser, count: usize) -> Result<Vec<MdlFace>, MdlAsciiError> {
751    let mut faces = Vec::with_capacity(count);
752    for _ in 0..count {
753        let (ln, line) = p
754            .next_line()
755            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in face block".into()))?;
756        let toks = tokens(&line);
757        if toks.len() < 8 {
758            return Err(p.parse_err(ln, "expected 8 values in face line"));
759        }
760        let v0 = parse_u16(toks[0], ln)?;
761        let v1 = parse_u16(toks[1], ln)?;
762        let v2 = parse_u16(toks[2], ln)?;
763        // toks[3] = smoothgroup (ignored)
764        // toks[4..7] = tv indices (ignored)
765        let surface_id = parse_u32(toks[7], ln)?;
766
767        faces.push(MdlFace {
768            plane_normal: [0.0; 3], // computed later
769            plane_distance: 0.0,    // computed later
770            surface_id,
771            adjacent: [0xFFFF; 3], // computed later
772            vertex_indices: [v0, v1, v2],
773        });
774    }
775    Ok(faces)
776}
777
778fn parse_uv_block(p: &mut AsciiParser, count: usize) -> Result<Vec<[f32; 2]>, MdlAsciiError> {
779    let mut uvs = Vec::with_capacity(count);
780    for _ in 0..count {
781        let (ln, line) = p
782            .next_line()
783            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in UV block".into()))?;
784        let toks = tokens(&line);
785        if toks.len() < 2 {
786            return Err(p.parse_err(ln, "expected at least 2 floats in UV line"));
787        }
788        // Accept 2 or 3 values (3rd is legacy trailing zero).
789        uvs.push([parse_f32(toks[0], ln)?, parse_f32(toks[1], ln)?]);
790    }
791    Ok(uvs)
792}
793
794fn parse_color_block(p: &mut AsciiParser, count: usize) -> Result<Vec<[u8; 4]>, MdlAsciiError> {
795    let mut colors = Vec::with_capacity(count);
796    for _ in 0..count {
797        let (ln, line) = p
798            .next_line()
799            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in color block".into()))?;
800        let toks = tokens(&line);
801        if toks.len() < 3 {
802            return Err(p.parse_err(ln, "expected 3 floats in color line"));
803        }
804        // Clamped to [0, 255] before cast -- truncation is impossible.
805        #[allow(
806            clippy::cast_possible_truncation,
807            clippy::cast_sign_loss,
808            clippy::as_conversions
809        )]
810        let r = (parse_f32(toks[0], ln)? * 255.0).round().clamp(0.0, 255.0) as u8;
811        #[allow(
812            clippy::cast_possible_truncation,
813            clippy::cast_sign_loss,
814            clippy::as_conversions
815        )]
816        let g = (parse_f32(toks[1], ln)? * 255.0).round().clamp(0.0, 255.0) as u8;
817        #[allow(
818            clippy::cast_possible_truncation,
819            clippy::cast_sign_loss,
820            clippy::as_conversions
821        )]
822        let b = (parse_f32(toks[2], ln)? * 255.0).round().clamp(0.0, 255.0) as u8;
823        colors.push([r, g, b, 255]);
824    }
825    Ok(colors)
826}
827
828// ---------------------------------------------------------------------------
829// Skin field parsing
830// ---------------------------------------------------------------------------
831
832fn parse_skin_field(
833    toks: &[&str],
834    ln: usize,
835    p: &mut AsciiParser,
836    skin: &mut MdlSkin,
837) -> Result<bool, MdlAsciiError> {
838    let kw = toks[0];
839    if (eq_ci(kw, "weights") || eq_ci(kw, "skinweights")) && toks.len() >= 2 {
840        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
841        parse_skin_weights(p, count, skin)?;
842        Ok(true)
843    } else {
844        Ok(false)
845    }
846}
847
848/// Parsed bone weight entry for a single vertex.
849struct VertexWeights {
850    /// (bone_name, weight) pairs, up to 4.
851    pairs: Vec<(String, f32)>,
852}
853
854fn parse_skin_weights(
855    p: &mut AsciiParser,
856    count: usize,
857    skin: &mut MdlSkin,
858) -> Result<(), MdlAsciiError> {
859    let mut vertex_weights: Vec<VertexWeights> = Vec::with_capacity(count);
860
861    for _ in 0..count {
862        let (ln, line) = p
863            .next_line()
864            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in weights block".into()))?;
865        let toks = tokens(&line);
866        let mut pairs = Vec::new();
867        let mut i = 0;
868        while i + 1 < toks.len() && pairs.len() < 4 {
869            let bone_name = toks[i].to_string();
870            let weight = parse_f32(toks[i + 1], ln)?;
871            if weight > 0.0 {
872                pairs.push((bone_name, weight));
873            }
874            i += 2;
875        }
876        vertex_weights.push(VertexWeights { pairs });
877    }
878
879    // Collect unique bone names and assign MDX bone indices.
880    let mut bone_name_to_idx: HashMap<String, usize> = HashMap::new();
881    for vw in &vertex_weights {
882        for (name, _) in &vw.pairs {
883            let next_idx = bone_name_to_idx.len();
884            bone_name_to_idx.entry(name.clone()).or_insert(next_idx);
885        }
886    }
887
888    // Populate typed bone weight/index arrays. The binary writer will compute
889    // canonical MDX byte offsets at serialization time - the offset fields here
890    // are placeholders that get backpatched during MDX layout computation.
891    skin.mdx_bone_weights_offset = 0;
892    skin.mdx_bone_indices_offset = 0;
893
894    let mut bone_weights_vec = Vec::with_capacity(count);
895    let mut bone_indices_vec = Vec::with_capacity(count);
896    for vw in &vertex_weights {
897        let mut weights = [0.0f32; 4];
898        let mut indices = [0.0f32; 4];
899        for (j, (name, weight)) in vw.pairs.iter().enumerate().take(4) {
900            let idx = *bone_name_to_idx.get(name).unwrap_or(&0);
901            weights[j] = *weight;
902            // MDX stores bone indices as f32 (engine convention). KotOR bone
903            // counts are always < 256, so usize->f32 is lossless in practice.
904            #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
905            let idx_f32 = idx as f32;
906            indices[j] = idx_f32;
907        }
908        bone_weights_vec.push(weights);
909        bone_indices_vec.push(indices);
910    }
911    skin.bone_weights = bone_weights_vec;
912    skin.bone_indices = bone_indices_vec;
913
914    // Bonemap requires the full geometry tree (name -> DFS index) to build.
915    // Left empty here; populated during tree assembly post-processing.
916    skin.bonemap = Vec::new();
917
918    Ok(())
919}
920
921// ---------------------------------------------------------------------------
922// Dangly field parsing
923// ---------------------------------------------------------------------------
924
925fn parse_dangly_field(
926    toks: &[&str],
927    ln: usize,
928    p: &mut AsciiParser,
929    dangly: &mut MdlDangly,
930) -> Result<bool, MdlAsciiError> {
931    let kw = toks[0];
932
933    if eq_ci(kw, "displacement") && toks.len() >= 2 {
934        dangly.displacement = parse_f32(toks[1], ln)?;
935    } else if eq_ci(kw, "tightness") && toks.len() >= 2 {
936        dangly.tightness = parse_f32(toks[1], ln)?;
937    } else if eq_ci(kw, "period") && toks.len() >= 2 {
938        dangly.period = parse_f32(toks[1], ln)?;
939    } else if eq_ci(kw, "constraints") && toks.len() >= 2 {
940        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
941        dangly.constraints = parse_float_block(p, count)?;
942    } else {
943        return Ok(false);
944    }
945    Ok(true)
946}
947
948fn parse_float_block(p: &mut AsciiParser, count: usize) -> Result<Vec<f32>, MdlAsciiError> {
949    let mut result = Vec::with_capacity(count);
950    for _ in 0..count {
951        let (ln, line) = p
952            .next_line()
953            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in float block".into()))?;
954        let toks = tokens(&line);
955        if toks.is_empty() {
956            return Err(p.parse_err(ln, "expected float value"));
957        }
958        result.push(parse_f32(toks[0], ln)?);
959    }
960    Ok(result)
961}
962
963// ---------------------------------------------------------------------------
964// AABB field parsing
965// ---------------------------------------------------------------------------
966
967fn parse_aabb_field(
968    toks: &[&str],
969    _ln: usize,
970    p: &mut AsciiParser,
971    aabb: &mut MdlAabb,
972) -> Result<bool, MdlAsciiError> {
973    if !eq_ci(toks[0], "aabb") {
974        return Ok(false);
975    }
976
977    // Read leaf entries until a line doesn't have 7 numeric tokens.
978    let mut leaves: Vec<AabbLeaf> = Vec::new();
979    while let Some((ln, line)) = p.peek_line() {
980        let toks = tokens(line);
981        if toks.len() < 7 {
982            break;
983        }
984        // Try to parse all 7 as numbers.
985        let vals: Result<Vec<f32>, _> = toks[..7].iter().map(|t| parse_f32(t, ln)).collect();
986        match vals {
987            Ok(v) => {
988                p.next_line(); // consume
989                leaves.push(AabbLeaf {
990                    box_min: [v[0], v[1], v[2]],
991                    box_max: [v[3], v[4], v[5]],
992                    // AABB face indices are small non-negative integers stored as f32
993                    // in ASCII MDL. Truncation to i32 is intentional and lossless.
994                    #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
995                    face_index: v[6] as i32,
996                });
997            }
998            Err(_) => break,
999        }
1000    }
1001
1002    if !leaves.is_empty() {
1003        aabb.aabb_tree = Some(Box::new(build_aabb_tree(&leaves)));
1004    }
1005
1006    Ok(true)
1007}
1008
1009struct AabbLeaf {
1010    box_min: [f32; 3],
1011    box_max: [f32; 3],
1012    face_index: i32,
1013}
1014
1015/// Builds a BVH tree from AABB leaf entries using median-split.
1016fn build_aabb_tree(leaves: &[AabbLeaf]) -> AabbNode {
1017    if leaves.len() == 1 {
1018        return AabbNode {
1019            box_min: leaves[0].box_min,
1020            box_max: leaves[0].box_max,
1021            face_index: leaves[0].face_index,
1022            split_direction_flags: 0,
1023            left: None,
1024            right: None,
1025        };
1026    }
1027
1028    // Compute combined bbox.
1029    let mut combined_min = [f32::MAX; 3];
1030    let mut combined_max = [f32::MIN; 3];
1031    for leaf in leaves {
1032        for i in 0..3 {
1033            combined_min[i] = combined_min[i].min(leaf.box_min[i]);
1034            combined_max[i] = combined_max[i].max(leaf.box_max[i]);
1035        }
1036    }
1037
1038    // Find longest axis of combined bbox.
1039    let extents = [
1040        combined_max[0] - combined_min[0],
1041        combined_max[1] - combined_min[1],
1042        combined_max[2] - combined_min[2],
1043    ];
1044    let axis = if extents[0] >= extents[1] && extents[0] >= extents[2] {
1045        0
1046    } else if extents[1] >= extents[2] {
1047        1
1048    } else {
1049        2
1050    };
1051
1052    // Sort by centroid along the chosen axis.
1053    let mut sorted: Vec<usize> = (0..leaves.len()).collect();
1054    sorted.sort_by(|&a, &b| {
1055        let ca = (leaves[a].box_min[axis] + leaves[a].box_max[axis]) * 0.5;
1056        let cb = (leaves[b].box_min[axis] + leaves[b].box_max[axis]) * 0.5;
1057        ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
1058    });
1059
1060    // Split at median.
1061    let mid = sorted.len() / 2;
1062    let left_leaves: Vec<AabbLeaf> = sorted[..mid]
1063        .iter()
1064        .map(|&i| AabbLeaf {
1065            box_min: leaves[i].box_min,
1066            box_max: leaves[i].box_max,
1067            face_index: leaves[i].face_index,
1068        })
1069        .collect();
1070    let right_leaves: Vec<AabbLeaf> = sorted[mid..]
1071        .iter()
1072        .map(|&i| AabbLeaf {
1073            box_min: leaves[i].box_min,
1074            box_max: leaves[i].box_max,
1075            face_index: leaves[i].face_index,
1076        })
1077        .collect();
1078
1079    let left = build_aabb_tree(&left_leaves);
1080    let right = build_aabb_tree(&right_leaves);
1081
1082    // Split direction flags: 1=+X, 2=+Y, 4=+Z.
1083    let split_flags = 1u32 << axis;
1084
1085    AabbNode {
1086        box_min: combined_min,
1087        box_max: combined_max,
1088        face_index: -1,
1089        split_direction_flags: split_flags,
1090        left: Some(Box::new(left)),
1091        right: Some(Box::new(right)),
1092    }
1093}
1094
1095// ---------------------------------------------------------------------------
1096// Light field parsing
1097// ---------------------------------------------------------------------------
1098
1099fn parse_light_field(
1100    toks: &[&str],
1101    ln: usize,
1102    p: &mut AsciiParser,
1103    light: &mut MdlLight,
1104) -> Result<bool, MdlAsciiError> {
1105    let kw = toks[0];
1106
1107    if eq_ci(kw, "lightpriority") && toks.len() >= 2 {
1108        light.priority = parse_i32(toks[1], ln)?;
1109    } else if eq_ci(kw, "ambientonly") && toks.len() >= 2 {
1110        light.ambientonly = parse_i32(toks[1], ln)?;
1111    } else if eq_ci(kw, "ndynamictype") && toks.len() >= 2 {
1112        light.num_dynamic_types = parse_i32(toks[1], ln)?;
1113    } else if eq_ci(kw, "affectdynamic") && toks.len() >= 2 {
1114        light.affectdynamic = parse_i32(toks[1], ln)?;
1115    } else if eq_ci(kw, "shadow") && toks.len() >= 2 {
1116        light.shadow = parse_i32(toks[1], ln)?;
1117    } else if eq_ci(kw, "generateflare") && toks.len() >= 2 {
1118        light.generateflare = parse_i32(toks[1], ln)?;
1119    } else if eq_ci(kw, "fadingLight") && toks.len() >= 2 {
1120        light.fading_light = parse_i32(toks[1], ln)?;
1121    } else if eq_ci(kw, "flareradius") && toks.len() >= 2 {
1122        light.flare_radius = parse_f32(toks[1], ln)?;
1123    } else if eq_ci(kw, "lensflares") {
1124        // Count-only header, no data lines.
1125    } else if eq_ci(kw, "texturenames") && toks.len() >= 2 {
1126        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1127        light.flare_texture_names = parse_string_block(p, count)?;
1128    } else if eq_ci(kw, "flarepositions") && toks.len() >= 2 {
1129        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1130        light.flare_positions = parse_float_block(p, count)?;
1131    } else if eq_ci(kw, "flaresizes") && toks.len() >= 2 {
1132        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1133        light.flare_sizes = parse_float_block(p, count)?;
1134    } else if eq_ci(kw, "flarecolorshifts") && toks.len() >= 2 {
1135        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1136        light.flare_color_shifts = parse_vec3_block(p, count)?;
1137    } else {
1138        return Ok(false);
1139    }
1140    Ok(true)
1141}
1142
1143fn parse_string_block(p: &mut AsciiParser, count: usize) -> Result<Vec<String>, MdlAsciiError> {
1144    let mut result = Vec::with_capacity(count);
1145    for _ in 0..count {
1146        let (_ln, line) = p
1147            .next_line()
1148            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in string block".into()))?;
1149        result.push(line.trim().to_string());
1150    }
1151    Ok(result)
1152}
1153
1154// ---------------------------------------------------------------------------
1155// Emitter field parsing
1156// ---------------------------------------------------------------------------
1157
1158fn parse_emitter_field(
1159    toks: &[&str],
1160    ln: usize,
1161    _p: &mut AsciiParser,
1162    em: &mut MdlEmitter,
1163) -> Result<bool, MdlAsciiError> {
1164    let kw = toks[0];
1165
1166    if eq_ci(kw, "deadspace") && toks.len() >= 2 {
1167        em.deadspace = parse_f32(toks[1], ln)?;
1168    } else if eq_ci(kw, "blastRadius") && toks.len() >= 2 {
1169        em.blast_radius = parse_f32(toks[1], ln)?;
1170    } else if eq_ci(kw, "blastLength") && toks.len() >= 2 {
1171        em.blast_length = parse_f32(toks[1], ln)?;
1172    } else if eq_ci(kw, "numBranches") && toks.len() >= 2 {
1173        em.num_branches = parse_i32(toks[1], ln)?;
1174    } else if eq_ci(kw, "controlptsmoothing") && toks.len() >= 2 {
1175        em.control_pt_smoothing = parse_i32(toks[1], ln)?;
1176    } else if eq_ci(kw, "xgrid") && toks.len() >= 2 {
1177        em.x_grid = parse_i32(toks[1], ln)?;
1178    } else if eq_ci(kw, "ygrid") && toks.len() >= 2 {
1179        em.y_grid = parse_i32(toks[1], ln)?;
1180    } else if eq_ci(kw, "spawntype") && toks.len() >= 2 {
1181        em.spawn_type = parse_i32(toks[1], ln)?;
1182    } else if eq_ci(kw, "update") && toks.len() >= 2 {
1183        em.update = toks[1].to_string();
1184    } else if eq_ci(kw, "render") && toks.len() >= 2 {
1185        em.render = toks[1].to_string();
1186    } else if eq_ci(kw, "blend") && toks.len() >= 2 {
1187        em.blend = toks[1].to_string();
1188    } else if eq_ci(kw, "texture") && toks.len() >= 2 {
1189        em.texture = toks[1].to_string();
1190    } else if eq_ci(kw, "chunkName") && toks.len() >= 2 {
1191        em.chunk_name = toks[1].to_string();
1192    } else if eq_ci(kw, "twosidedtex") && toks.len() >= 2 {
1193        em.two_sided_tex = parse_i32(toks[1], ln)?;
1194    } else if eq_ci(kw, "loop") && toks.len() >= 2 {
1195        em.loop_emitter = parse_i32(toks[1], ln)?;
1196    } else if eq_ci(kw, "renderorder") && toks.len() >= 2 {
1197        em.render_order = parse_u16(toks[1], ln)?;
1198    } else if eq_ci(kw, "m_bFrameBlending") && toks.len() >= 2 {
1199        em.frame_blending = parse_i32(toks[1], ln)? != 0;
1200    } else if eq_ci(kw, "m_sDepthTextureName") && toks.len() >= 2 {
1201        em.depth_texture_name = toks[1].to_string();
1202    } else if eq_ci(kw, "p2p")
1203        || eq_ci(kw, "p2p_sel")
1204        || eq_ci(kw, "affectedByWind")
1205        || eq_ci(kw, "m_isTinted")
1206        || eq_ci(kw, "bounce")
1207        || eq_ci(kw, "random")
1208        || eq_ci(kw, "inherit")
1209        || eq_ci(kw, "inheritvel")
1210        || eq_ci(kw, "inherit_local")
1211        || eq_ci(kw, "splat")
1212        || eq_ci(kw, "inherit_part")
1213        || eq_ci(kw, "depth_texture")
1214    {
1215        // Controller-driven flags -- not stored in the binary struct.
1216        // Silently ignored (matching engine behavior).
1217    } else {
1218        return Ok(false);
1219    }
1220    Ok(true)
1221}
1222
1223// ---------------------------------------------------------------------------
1224// Reference field parsing
1225// ---------------------------------------------------------------------------
1226
1227fn parse_reference_field(
1228    toks: &[&str],
1229    ln: usize,
1230    reference: &mut MdlReference,
1231) -> Result<bool, MdlAsciiError> {
1232    let kw = toks[0];
1233
1234    if eq_ci(kw, "refModel") && toks.len() >= 2 {
1235        reference.ref_model = toks[1].to_string();
1236    } else if eq_ci(kw, "reattachable") && toks.len() >= 2 {
1237        reference.reattachable = parse_i32(toks[1], ln)?;
1238    } else {
1239        return Ok(false);
1240    }
1241    Ok(true)
1242}
1243
1244// ---------------------------------------------------------------------------
1245// AnimMesh field parsing
1246// ---------------------------------------------------------------------------
1247
1248fn parse_animmesh_field(
1249    toks: &[&str],
1250    ln: usize,
1251    p: &mut AsciiParser,
1252    am: &mut MdlAnimMesh,
1253) -> Result<bool, MdlAsciiError> {
1254    let kw = toks[0];
1255
1256    if eq_ci(kw, "sampleperiod") && toks.len() >= 2 {
1257        am.sample_period = parse_f32(toks[1], ln)?;
1258    } else if eq_ci(kw, "animverts") && toks.len() >= 2 {
1259        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1260        am.anim_verts = parse_vec3_block(p, count)?;
1261    } else if eq_ci(kw, "animtverts") && toks.len() >= 2 {
1262        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1263        am.anim_t_verts = parse_vec3_block(p, count)?;
1264    } else {
1265        return Ok(false);
1266    }
1267    Ok(true)
1268}
1269
1270// ---------------------------------------------------------------------------
1271// Animation parsing
1272// ---------------------------------------------------------------------------
1273
1274fn parse_animation(
1275    p: &mut AsciiParser,
1276    anim_name: &str,
1277    _model_name: &str,
1278    _start_line: usize,
1279) -> Result<MdlAnimation, MdlAsciiError> {
1280    let mut length: f32 = 0.0;
1281    let mut transition_time: f32 = 0.0;
1282    let mut anim_root = String::new();
1283    let mut events: Vec<MdlAnimEvent> = Vec::new();
1284    let mut flat_nodes: Vec<FlatAnimNode> = Vec::new();
1285
1286    while let Some((ln, line)) = p.next_line() {
1287        let toks = tokens(&line);
1288        if toks.is_empty() {
1289            continue;
1290        }
1291        let kw = toks[0];
1292
1293        if eq_ci(kw, "length") && toks.len() >= 2 {
1294            length = parse_f32(toks[1], ln)?;
1295        } else if eq_ci(kw, "transtime") && toks.len() >= 2 {
1296            transition_time = parse_f32(toks[1], ln)?;
1297        } else if eq_ci(kw, "animroot") && toks.len() >= 2 {
1298            anim_root = toks[1].to_string();
1299        } else if eq_ci(kw, "event") && toks.len() >= 3 {
1300            let time = parse_f32(toks[1], ln)?;
1301            let name = toks[2].to_string();
1302            events.push(MdlAnimEvent { time, name });
1303        } else if eq_ci(kw, "node") && toks.len() >= 3 {
1304            let flat = parse_anim_node(p, toks[2], ln)?;
1305            flat_nodes.push(flat);
1306        } else if eq_ci(kw, "doneanim") {
1307            break;
1308        }
1309    }
1310
1311    let root_node = assemble_anim_node_tree(flat_nodes)?;
1312
1313    Ok(MdlAnimation {
1314        name: anim_name.to_string(),
1315        length,
1316        transition_time,
1317        anim_root,
1318        events,
1319        root_node,
1320        fn_ptr1: 0,
1321        fn_ptr2: 0,
1322    })
1323}
1324
1325fn parse_anim_node(
1326    p: &mut AsciiParser,
1327    name: &str,
1328    _node_line: usize,
1329) -> Result<FlatAnimNode, MdlAsciiError> {
1330    let mut parent_name = "NULL".into();
1331    let mut controllers = Vec::new();
1332
1333    while let Some((ln, line)) = p.next_line() {
1334        let toks = tokens(&line);
1335        if toks.is_empty() {
1336            continue;
1337        }
1338        let kw = toks[0];
1339
1340        if eq_ci(kw, "endnode") {
1341            break;
1342        } else if eq_ci(kw, "parent") && toks.len() >= 2 {
1343            parent_name = toks[1].to_string();
1344        } else {
1345            // Try all contexts for animation nodes.
1346            try_parse_controller_line(p, &toks, ln, NodeTypeContext::Base, &mut controllers)?;
1347        }
1348    }
1349
1350    Ok(FlatAnimNode {
1351        name: name.to_string(),
1352        parent_name,
1353        controllers,
1354    })
1355}
1356
1357// ---------------------------------------------------------------------------
1358// Node tree assembly
1359// ---------------------------------------------------------------------------
1360
1361fn assemble_node_tree(flat_nodes: Vec<FlatNode>) -> Result<MdlNode, MdlAsciiError> {
1362    if flat_nodes.is_empty() {
1363        return Err(MdlAsciiError::InvalidData("no geometry nodes found".into()));
1364    }
1365
1366    // Find root (parent == "NULL").
1367    let root_idx = flat_nodes
1368        .iter()
1369        .position(|n| eq_ci(&n.parent_name, "NULL"))
1370        .ok_or_else(|| MdlAsciiError::InvalidData("no root node (parent NULL) found".into()))?;
1371
1372    // Nodes are in DFS preorder. Use a stack to track the current ancestor
1373    // chain, correctly handling duplicate names (e.g., parent and child both
1374    // named "lhand_g"). Each stack entry is (flat_index, name).
1375    let mut children_of_idx: HashMap<usize, Vec<usize>> = HashMap::new();
1376    let mut stack: Vec<(usize, String)> = Vec::new();
1377
1378    for (i, node) in flat_nodes.iter().enumerate() {
1379        if i == root_idx {
1380            stack.push((i, node.name.clone()));
1381            continue;
1382        }
1383
1384        // Pop the stack back to the parent: find the topmost stack entry
1385        // whose name matches this node's parent_name.
1386        let parent_pos = stack.iter().rposition(|(_, n)| eq_ci(n, &node.parent_name));
1387        if let Some(pos) = parent_pos {
1388            // Pop everything above the parent (sibling subtrees that ended).
1389            stack.truncate(pos + 1);
1390            let parent_idx = stack[pos].0;
1391            children_of_idx.entry(parent_idx).or_default().push(i);
1392        }
1393        // Push this node onto the stack.
1394        stack.push((i, node.name.clone()));
1395    }
1396
1397    fn build_node(
1398        idx: usize,
1399        flat: &[FlatNode],
1400        children_of_idx: &HashMap<usize, Vec<usize>>,
1401    ) -> MdlNode {
1402        let f = &flat[idx];
1403        let children: Vec<MdlNode> = children_of_idx
1404            .get(&idx)
1405            .cloned()
1406            .unwrap_or_default()
1407            .iter()
1408            .map(|&ci| build_node(ci, flat, children_of_idx))
1409            .collect();
1410
1411        MdlNode {
1412            name: f.name.clone(),
1413            parent_index: None, // Not used for ASCII-sourced.
1414            children,
1415            position: f.position,
1416            rotation: f.rotation,
1417            node_data: f.node_data.clone(),
1418            controllers: f.controllers.clone(),
1419            orphan_controller_data: Vec::new(),
1420            header_padding_02: [0, 0],
1421            header_padding_06: [0, 0],
1422        }
1423    }
1424
1425    Ok(build_node(root_idx, &flat_nodes, &children_of_idx))
1426}
1427
1428fn assemble_anim_node_tree(flat_nodes: Vec<FlatAnimNode>) -> Result<MdlAnimNode, MdlAsciiError> {
1429    if flat_nodes.is_empty() {
1430        // Empty animation -- create a dummy root.
1431        return Ok(MdlAnimNode {
1432            name: String::new(),
1433            node_number: 0,
1434            controllers: Vec::new(),
1435            orphan_controller_data: Vec::new(),
1436            children: Vec::new(),
1437        });
1438    }
1439
1440    let root_idx = flat_nodes
1441        .iter()
1442        .position(|n| eq_ci(&n.parent_name, "NULL"))
1443        .unwrap_or(0);
1444
1445    // Same DFS-order stack-based parent resolution as assemble_node_tree.
1446    let mut children_of_idx: HashMap<usize, Vec<usize>> = HashMap::new();
1447    let mut stack: Vec<(usize, String)> = Vec::new();
1448    for (i, node) in flat_nodes.iter().enumerate() {
1449        if i == root_idx {
1450            stack.push((i, node.name.clone()));
1451            continue;
1452        }
1453        let parent_pos = stack.iter().rposition(|(_, n)| eq_ci(n, &node.parent_name));
1454        if let Some(pos) = parent_pos {
1455            stack.truncate(pos + 1);
1456            let parent_idx = stack[pos].0;
1457            children_of_idx.entry(parent_idx).or_default().push(i);
1458        }
1459        stack.push((i, node.name.clone()));
1460    }
1461
1462    fn build_anim(
1463        idx: usize,
1464        flat: &[FlatAnimNode],
1465        children_of_idx: &HashMap<usize, Vec<usize>>,
1466    ) -> MdlAnimNode {
1467        let f = &flat[idx];
1468        let children: Vec<MdlAnimNode> = children_of_idx
1469            .get(&idx)
1470            .cloned()
1471            .unwrap_or_default()
1472            .iter()
1473            .map(|&ci| build_anim(ci, flat, children_of_idx))
1474            .collect();
1475
1476        MdlAnimNode {
1477            name: f.name.clone(),
1478            node_number: 0, // Set during post-processing.
1479            controllers: f.controllers.clone(),
1480            orphan_controller_data: Vec::new(),
1481            children,
1482        }
1483    }
1484
1485    Ok(build_anim(root_idx, &flat_nodes, &children_of_idx))
1486}
1487
1488// ---------------------------------------------------------------------------
1489// Post-processing helpers
1490// ---------------------------------------------------------------------------
1491
1492fn build_name_index_map(node: &MdlNode) -> HashMap<String, u16> {
1493    let mut map = HashMap::new();
1494    let mut idx = 0u16;
1495    build_name_idx_recursive(node, &mut map, &mut idx);
1496    map
1497}
1498
1499fn build_name_idx_recursive(node: &MdlNode, map: &mut HashMap<String, u16>, idx: &mut u16) {
1500    map.insert(node.name.clone(), *idx);
1501    *idx += 1;
1502    for child in &node.children {
1503        build_name_idx_recursive(child, map, idx);
1504    }
1505}
1506
1507fn subtract_geo_positions_from_anim(
1508    node: &mut MdlAnimNode,
1509    geo_positions: &HashMap<&str, [f32; 3]>,
1510) {
1511    if let Some(geo_pos) = geo_positions.get(node.name.as_str()) {
1512        for ctrl in &mut node.controllers {
1513            if ctrl.controller_type == MdlControllerType::POSITION {
1514                for key in &mut ctrl.keys {
1515                    if key.values.len() >= 3 {
1516                        key.values[0] -= geo_pos[0];
1517                        key.values[1] -= geo_pos[1];
1518                        key.values[2] -= geo_pos[2];
1519                    }
1520                }
1521            }
1522        }
1523    }
1524    for child in &mut node.children {
1525        subtract_geo_positions_from_anim(child, geo_positions);
1526    }
1527}
1528
1529fn assign_anim_node_numbers(node: &mut MdlAnimNode, name_to_index: &HashMap<String, u16>) {
1530    node.node_number = name_to_index.get(&node.name).copied().unwrap_or(0);
1531    for child in &mut node.children {
1532        assign_anim_node_numbers(child, name_to_index);
1533    }
1534}
1535
1536// ---------------------------------------------------------------------------
1537// Utility: case-insensitive suffix stripping
1538// ---------------------------------------------------------------------------
1539
1540fn strip_suffix_ci<'a>(s: &'a str, suffix: &str) -> Option<&'a str> {
1541    let s_lower = s.to_ascii_lowercase();
1542    let suffix_lower = suffix.to_ascii_lowercase();
1543    if s_lower.ends_with(&suffix_lower) {
1544        Some(&s[..s.len() - suffix.len()])
1545    } else {
1546        None
1547    }
1548}
1549
1550// ---------------------------------------------------------------------------
1551// Tests
1552// ---------------------------------------------------------------------------
1553
1554#[cfg(test)]
1555mod tests {
1556    use super::*;
1557    use crate::mdl::ascii_writer::write_mdl_ascii_to_string;
1558
1559    #[test]
1560    fn minimal_model_parse() {
1561        let input = "\
1562newmodel test
1563setsupermodel test NULL
1564classification other
1565classification_unk1 0
1566ignorefog 0
1567setanimationscale 1.0
1568compress_quaternions 0
1569headlink 0
1570beginmodelgeom test
1571  bmin -1.0 -1.0 -1.0
1572  bmax 1.0 1.0 1.0
1573  radius 1.73
1574  node dummy test
1575    parent NULL
1576  endnode
1577endmodelgeom test
1578donemodel test
1579";
1580        let mdl = read_mdl_ascii_from_str(input).unwrap();
1581        assert_eq!(mdl.root_node.name, "test");
1582        assert_eq!(mdl.supermodel_name, "NULL");
1583        assert_eq!(mdl.classification, 0);
1584        assert_eq!(mdl.affected_by_fog, 1);
1585        assert_eq!(mdl.node_count, 1);
1586    }
1587
1588    #[test]
1589    fn mesh_node_parse() {
1590        let input = "\
1591newmodel m
1592setsupermodel m NULL
1593classification other
1594beginmodelgeom m
1595  node trimesh mesh1
1596    parent NULL
1597    diffuse 0.8 0.8 0.8
1598    ambient 0.2 0.2 0.2
1599    bitmap texture_a
1600    render 1
1601    verts 3
1602      0.0 0.0 0.0
1603      1.0 0.0 0.0
1604      0.0 1.0 0.0
1605    faces 1
1606      0 1 2  1  0 1 2  0
1607    tverts 3
1608      0.0 0.0
1609      1.0 0.0
1610      0.0 1.0
1611  endnode
1612endmodelgeom m
1613donemodel m
1614";
1615        let mdl = read_mdl_ascii_from_str(input).unwrap();
1616        let mesh = mdl.root_node.node_data.mesh().unwrap();
1617        assert_eq!(mesh.positions.len(), 3);
1618        assert_eq!(mesh.faces.len(), 1);
1619        assert_eq!(mesh.uv1.len(), 3);
1620        assert_eq!(mesh.texture_0, "texture_a");
1621        assert_eq!(mesh.faces[0].vertex_indices, [0, 1, 2]);
1622        assert_eq!(mesh.vertex_count, 3);
1623    }
1624
1625    #[test]
1626    fn controller_keyed_parse() {
1627        let input = "\
1628newmodel m
1629setsupermodel m NULL
1630classification other
1631beginmodelgeom m
1632  node dummy root
1633    parent NULL
1634    positionkey
1635      0.0 1.0 2.0 3.0
1636      0.5 4.0 5.0 6.0
1637    endlist
1638  endnode
1639endmodelgeom m
1640donemodel m
1641";
1642        let mdl = read_mdl_ascii_from_str(input).unwrap();
1643        assert_eq!(mdl.root_node.controllers.len(), 1);
1644        let ctrl = &mdl.root_node.controllers[0];
1645        assert_eq!(ctrl.controller_type, MdlControllerType::POSITION);
1646        assert_eq!(ctrl.keys.len(), 2);
1647        assert_eq!(ctrl.keys[0].time, 0.0);
1648        assert_eq!(ctrl.keys[0].values, vec![1.0, 2.0, 3.0]);
1649        assert_eq!(ctrl.keys[1].time, 0.5);
1650        assert_eq!(ctrl.keys[1].values, vec![4.0, 5.0, 6.0]);
1651    }
1652
1653    #[test]
1654    fn orientation_conversion() {
1655        let input = "\
1656newmodel m
1657setsupermodel m NULL
1658classification other
1659beginmodelgeom m
1660  node dummy root
1661    parent NULL
1662    orientation 0.0 0.0 1.0 1.5707963
1663  endnode
1664endmodelgeom m
1665donemodel m
1666";
1667        let mdl = read_mdl_ascii_from_str(input).unwrap();
1668        // Should be a ~90 degree rotation around Z.
1669        let q = mdl.root_node.rotation;
1670        // q = [w, x, y, z] -- w should be ~cos(pi/4) = ~0.707
1671        let expected_half = std::f32::consts::FRAC_1_SQRT_2;
1672        assert!((q[0] - expected_half).abs() < 0.01, "w = {}", q[0]);
1673        assert!(q[1].abs() < 0.01, "x = {}", q[1]);
1674        assert!(q[2].abs() < 0.01, "y = {}", q[2]);
1675        assert!((q[3] - expected_half).abs() < 0.01, "z = {}", q[3]);
1676    }
1677
1678    #[test]
1679    fn animation_parse() {
1680        let input = "\
1681newmodel m
1682setsupermodel m NULL
1683classification other
1684beginmodelgeom m
1685  node dummy root
1686    parent NULL
1687    position 10.0 20.0 30.0
1688  endnode
1689endmodelgeom m
1690newanim walk m
1691  length 1.0
1692  transtime 0.25
1693  animroot root
1694  event 0.5 footstep
1695  node dummy root
1696    parent NULL
1697    positionkey
1698      0.0 10.0 20.0 30.0
1699      1.0 11.0 21.0 31.0
1700    endlist
1701  endnode
1702doneanim walk m
1703donemodel m
1704";
1705        let mdl = read_mdl_ascii_from_str(input).unwrap();
1706        assert_eq!(mdl.animations.len(), 1);
1707        let anim = &mdl.animations[0];
1708        assert_eq!(anim.name, "walk");
1709        assert_eq!(anim.length, 1.0);
1710        assert_eq!(anim.anim_root, "root");
1711        assert_eq!(anim.events.len(), 1);
1712        assert_eq!(anim.events[0].name, "footstep");
1713
1714        // Position values should be deltas (absolute - geometry rest pose).
1715        // ASCII: [10, 20, 30] and [11, 21, 31], geo_pos = [10, 20, 30]
1716        // -> binary deltas: [0, 0, 0] and [1, 1, 1]
1717        let ctrl = &anim.root_node.controllers[0];
1718        assert_eq!(ctrl.controller_type, MdlControllerType::POSITION);
1719        assert!((ctrl.keys[0].values[0]).abs() < 0.001);
1720        assert!((ctrl.keys[0].values[1]).abs() < 0.001);
1721        assert!((ctrl.keys[0].values[2]).abs() < 0.001);
1722        assert!((ctrl.keys[1].values[0] - 1.0).abs() < 0.001);
1723        assert!((ctrl.keys[1].values[1] - 1.0).abs() < 0.001);
1724        assert!((ctrl.keys[1].values[2] - 1.0).abs() < 0.001);
1725    }
1726
1727    #[test]
1728    fn aabb_tree_reconstruction() {
1729        // 4 leaf entries should produce a balanced tree.
1730        let input = "\
1731newmodel m
1732setsupermodel m NULL
1733classification other
1734beginmodelgeom m
1735  node aabb walkmesh
1736    parent NULL
1737    verts 4
1738      0.0 0.0 0.0
1739      1.0 0.0 0.0
1740      1.0 1.0 0.0
1741      0.0 1.0 0.0
1742    faces 2
1743      0 1 2  1  0 1 2  0
1744      0 2 3  1  0 2 3  0
1745    aabb
1746      0.0 0.0 0.0 1.0 0.5 0.0 0
1747      0.0 0.5 0.0 1.0 1.0 0.0 1
1748  endnode
1749endmodelgeom m
1750donemodel m
1751";
1752        let mdl = read_mdl_ascii_from_str(input).unwrap();
1753        if let MdlNodeData::Aabb(ref aabb) = mdl.root_node.node_data {
1754            assert!(aabb.aabb_tree.is_some());
1755            let tree = aabb.aabb_tree.as_ref().unwrap();
1756            // Root should be internal (face_index == -1).
1757            assert_eq!(tree.face_index, -1);
1758            assert!(tree.left.is_some());
1759            assert!(tree.right.is_some());
1760        } else {
1761            panic!("expected AABB node");
1762        }
1763    }
1764
1765    // ---------------------------------------------------------------
1766    // ASCII self round-trip: write -> read -> write, compare strings
1767    // ---------------------------------------------------------------
1768
1769    fn ascii_self_roundtrip(input: &str) {
1770        let mdl = read_mdl_ascii_from_str(input).unwrap();
1771        let ascii1 = write_mdl_ascii_to_string(&mdl).unwrap();
1772        let mdl2 = read_mdl_ascii_from_str(&ascii1).unwrap();
1773        let ascii2 = write_mdl_ascii_to_string(&mdl2).unwrap();
1774        if ascii1 != ascii2 {
1775            // Find first differing line for diagnostics.
1776            for (i, (a, b)) in ascii1.lines().zip(ascii2.lines()).enumerate() {
1777                if a != b {
1778                    panic!(
1779                        "ASCII self round-trip mismatch at line {}:\n  pass 1: {}\n  pass 2: {}",
1780                        i + 1,
1781                        a,
1782                        b
1783                    );
1784                }
1785            }
1786            let c1 = ascii1.lines().count();
1787            let c2 = ascii2.lines().count();
1788            if c1 != c2 {
1789                panic!("ASCII self round-trip: line count differs ({c1} vs {c2})");
1790            }
1791        }
1792    }
1793
1794    #[test]
1795    fn self_roundtrip_minimal() {
1796        let input = "\
1797newmodel test
1798setsupermodel test NULL
1799classification other
1800classification_unk1 0
1801ignorefog 0
1802setanimationscale 1.0
1803compress_quaternions 0
1804headlink 0
1805beginmodelgeom test
1806  bmin -1.0 -1.0 -1.0
1807  bmax 1.0 1.0 1.0
1808  radius 1.73
1809  node dummy test
1810    parent NULL
1811  endnode
1812endmodelgeom test
1813donemodel test
1814";
1815        ascii_self_roundtrip(input);
1816    }
1817
1818    #[test]
1819    fn self_roundtrip_mesh_with_controllers() {
1820        let input = "\
1821newmodel m
1822setsupermodel m NULL
1823classification other
1824classification_unk1 0
1825ignorefog 0
1826setanimationscale 1.0
1827compress_quaternions 0
1828headlink 0
1829beginmodelgeom m
1830  bmin -1.0 -1.0 -1.0
1831  bmax 1.0 1.0 1.0
1832  radius 1.73
1833  node trimesh mesh1
1834    parent NULL
1835    diffuse 0.8 0.8 0.8
1836    ambient 0.2 0.2 0.2
1837    bitmap texture_a
1838    render 1
1839    shadow 0
1840    verts 3
1841      0.0 0.0 0.0
1842      1.0 0.0 0.0
1843      0.0 1.0 0.0
1844    faces 1
1845      0 1 2  1  0 1 2  0
1846    tverts 3
1847      0.0 0.0
1848      1.0 0.0
1849      0.0 1.0
1850  endnode
1851endmodelgeom m
1852donemodel m
1853";
1854        ascii_self_roundtrip(input);
1855    }
1856
1857    #[test]
1858    fn self_roundtrip_animation() {
1859        let input = "\
1860newmodel m
1861setsupermodel m NULL
1862classification character
1863classification_unk1 0
1864ignorefog 0
1865setanimationscale 1.0
1866compress_quaternions 0
1867headlink 0
1868beginmodelgeom m
1869  bmin -1.0 -1.0 -1.0
1870  bmax 1.0 1.0 1.0
1871  radius 1.73
1872  node dummy root
1873    parent NULL
1874    position 10.0 20.0 30.0
1875  endnode
1876endmodelgeom m
1877newanim walk m
1878  length 1.0
1879  transtime 0.25
1880  animroot root
1881  event 0.5 footstep
1882  node dummy root
1883    parent NULL
1884    positionkey
1885      0.0 10.0 20.0 30.0
1886      1.0 11.0 21.0 31.0
1887    endlist
1888  endnode
1889doneanim walk m
1890donemodel m
1891";
1892        ascii_self_roundtrip(input);
1893    }
1894
1895    // ---------------------------------------------------------------
1896    // Binary -> ASCII -> read back round-trip (vanilla models)
1897    // ---------------------------------------------------------------
1898
1899    /// Compare two node trees structurally (names, types, children, positions,
1900    /// faces, controllers). Ignores binary-only metadata.
1901    fn assert_nodes_equivalent(a: &super::super::MdlNode, b: &super::super::MdlNode, path: &str) {
1902        assert_eq!(a.name, b.name, "{path}: name mismatch");
1903        assert_eq!(
1904            std::mem::discriminant(&a.node_data),
1905            std::mem::discriminant(&b.node_data),
1906            "{path}: node type mismatch"
1907        );
1908        // Positions (allow small float rounding).
1909        for i in 0..3 {
1910            assert!(
1911                (a.position[i] - b.position[i]).abs() < 1e-4,
1912                "{path}: position[{i}] {:.6} vs {:.6}",
1913                a.position[i],
1914                b.position[i]
1915            );
1916        }
1917        // Rotation (wider tolerance: axis-angle round-trip loses precision
1918        // for near-identity orientations).
1919        for i in 0..4 {
1920            assert!(
1921                (a.rotation[i] - b.rotation[i]).abs() < 2e-3,
1922                "{path}: rotation[{i}] {:.6} vs {:.6}",
1923                a.rotation[i],
1924                b.rotation[i]
1925            );
1926        }
1927        // Controller comparison: the ASCII writer skips identity position/
1928        // orientation, so the roundtripped model may have fewer controllers.
1929        // Check that every controller in b exists in a with the same key count.
1930        for cb in &b.controllers {
1931            if let Some(ca) = a
1932                .controllers
1933                .iter()
1934                .find(|c| c.controller_type == cb.controller_type)
1935            {
1936                assert_eq!(
1937                    ca.keys.len(),
1938                    cb.keys.len(),
1939                    "{path}: controller {:?} key count ({} vs {})",
1940                    cb.controller_type,
1941                    ca.keys.len(),
1942                    cb.keys.len()
1943                );
1944            } else {
1945                panic!(
1946                    "{path}: roundtripped has controller {:?} not in original",
1947                    cb.controller_type
1948                );
1949            }
1950        }
1951        // Mesh fields.
1952        if let (Some(ma), Some(mb)) = (a.node_data.mesh(), b.node_data.mesh()) {
1953            assert_eq!(
1954                ma.positions.len(),
1955                mb.positions.len(),
1956                "{path}: vertex count"
1957            );
1958            assert_eq!(ma.faces.len(), mb.faces.len(), "{path}: face count");
1959            assert_eq!(ma.uv1.len(), mb.uv1.len(), "{path}: uv1 count");
1960        }
1961        // Children.
1962        assert_eq!(
1963            a.children.len(),
1964            b.children.len(),
1965            "{path}: child count ({} vs {})",
1966            a.children.len(),
1967            b.children.len()
1968        );
1969        for (ca, cb) in a.children.iter().zip(b.children.iter()) {
1970            assert_nodes_equivalent(ca, cb, &format!("{path}/{}", ca.name));
1971        }
1972    }
1973
1974    fn assert_anims_equivalent(a: &[super::super::MdlAnimation], b: &[super::super::MdlAnimation]) {
1975        assert_eq!(a.len(), b.len(), "animation count");
1976        for (i, (aa, ab)) in a.iter().zip(b.iter()).enumerate() {
1977            assert_eq!(aa.name, ab.name, "anim[{i}] name");
1978            assert!((aa.length - ab.length).abs() < 1e-4, "anim[{i}] length");
1979            assert_eq!(aa.anim_root, ab.anim_root, "anim[{i}] anim_root");
1980            assert_eq!(aa.events.len(), ab.events.len(), "anim[{i}] event count");
1981        }
1982    }
1983
1984    /// Returns the K1 Override directory from `KOTOR_GAME_DIR` env var,
1985    /// or None if not set (tests should skip).
1986    fn k1_override_dir() -> Option<String> {
1987        std::env::var("KOTOR_GAME_DIR")
1988            .ok()
1989            .map(|d| format!("{d}/Override"))
1990    }
1991
1992    /// Full binary -> ASCII -> read back round-trip for a vanilla model.
1993    fn binary_ascii_roundtrip(mdl_path: &str, mdx_path: Option<&str>) {
1994        let mdl_data = match std::fs::read(mdl_path) {
1995            Ok(d) => d,
1996            Err(_) => return,
1997        };
1998        let mdx_data = mdx_path.and_then(|p| std::fs::read(p).ok());
1999        let original =
2000            super::super::reader::read_mdl_from_bytes(&mdl_data, mdx_data.as_deref()).unwrap();
2001
2002        let ascii = write_mdl_ascii_to_string(&original).unwrap();
2003        let roundtripped = read_mdl_ascii_from_str(&ascii).unwrap();
2004
2005        // Header fields.
2006        assert_eq!(
2007            original.root_node.name, roundtripped.root_node.name,
2008            "model_name"
2009        );
2010        assert_eq!(
2011            original.supermodel_name, roundtripped.supermodel_name,
2012            "supermodel_name"
2013        );
2014        assert_eq!(
2015            original.classification, roundtripped.classification,
2016            "classification"
2017        );
2018        assert_eq!(
2019            original.affected_by_fog, roundtripped.affected_by_fog,
2020            "affected_by_fog"
2021        );
2022        assert!(
2023            (original.animation_scale - roundtripped.animation_scale).abs() < 1e-4,
2024            "animation_scale"
2025        );
2026        // node_count is derived differently (binary header value vs computed
2027        // from tree), so just sanity check it's reasonable.
2028        assert!(roundtripped.node_count > 0, "node_count should be > 0");
2029
2030        // Node tree.
2031        assert_nodes_equivalent(
2032            &original.root_node,
2033            &roundtripped.root_node,
2034            &original.root_node.name,
2035        );
2036
2037        // Animations.
2038        assert_anims_equivalent(&original.animations, &roundtripped.animations);
2039    }
2040
2041    #[test]
2042    fn roundtrip_vanilla_item() {
2043        // Item model with duplicate node names (root and child share same name).
2044        let base = match k1_override_dir() {
2045            Some(d) => d,
2046            None => return,
2047        };
2048        binary_ascii_roundtrip(
2049            &format!("{base}/i_adrnaline_001.mdl"),
2050            Some(&format!("{base}/i_adrnaline_001.mdx")),
2051        );
2052    }
2053
2054    #[test]
2055    fn roundtrip_vanilla_placeable() {
2056        // 3dgui.mdl -- simple placeable, no MDX needed.
2057        let base = match k1_override_dir() {
2058            Some(d) => d,
2059            None => return,
2060        };
2061        binary_ascii_roundtrip(&format!("{base}/3dgui.mdl"), None);
2062    }
2063
2064    #[test]
2065    fn roundtrip_vanilla_character() {
2066        // Character model with skins and animations.
2067        let base = match k1_override_dir() {
2068            Some(d) => d,
2069            None => return,
2070        };
2071        binary_ascii_roundtrip(
2072            &format!("{base}/p_bastilabb.mdl"),
2073            Some(&format!("{base}/p_bastilabb.mdx")),
2074        );
2075    }
2076
2077    #[test]
2078    fn roundtrip_vanilla_supermodel() {
2079        // Supermodel with many animations.
2080        let base = match k1_override_dir() {
2081            Some(d) => d,
2082            None => return,
2083        };
2084        binary_ascii_roundtrip(
2085            &format!("{base}/s_female03.mdl"),
2086            Some(&format!("{base}/s_female03.mdx")),
2087        );
2088    }
2089
2090    #[test]
2091    fn roundtrip_vanilla_effect() {
2092        // FX model with emitters/lights.
2093        let base = match k1_override_dir() {
2094            Some(d) => d,
2095            None => return,
2096        };
2097        binary_ascii_roundtrip(&format!("{base}/fx_carbref.mdl"), None);
2098    }
2099}