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    match data {
614        MdlNodeData::Skin(skin) => {
615            if parse_skin_field(toks, ln, p, skin)? {
616                return Ok(());
617            }
618        }
619        MdlNodeData::Dangly(dangly) => {
620            if parse_dangly_field(toks, ln, p, dangly)? {
621                return Ok(());
622            }
623        }
624        MdlNodeData::Aabb(aabb) => {
625            if parse_aabb_field(toks, ln, p, aabb)? {
626                return Ok(());
627            }
628        }
629        MdlNodeData::Light(light) => {
630            if parse_light_field(toks, ln, p, light)? {
631                return Ok(());
632            }
633        }
634        MdlNodeData::Emitter(emitter) => {
635            if parse_emitter_field(toks, ln, p, emitter)? {
636                return Ok(());
637            }
638        }
639        MdlNodeData::Reference(reference) => {
640            if parse_reference_field(toks, ln, reference)? {
641                return Ok(());
642            }
643        }
644        MdlNodeData::AnimMesh(animmesh) => {
645            if parse_animmesh_field(toks, ln, p, animmesh)? {
646                return Ok(());
647            }
648        }
649        _ => {}
650    }
651
652    // Unknown fields silently skipped (matching engine behavior).
653    Ok(())
654}
655
656// ---------------------------------------------------------------------------
657// Mesh field parsing
658// ---------------------------------------------------------------------------
659
660fn parse_mesh_field(
661    toks: &[&str],
662    ln: usize,
663    p: &mut AsciiParser,
664    mesh: &mut MdlMesh,
665) -> Result<bool, MdlAsciiError> {
666    let kw = toks[0];
667
668    if eq_ci(kw, "diffuse") && toks.len() >= 4 {
669        mesh.diffuse_color = [
670            parse_f32(toks[1], ln)?,
671            parse_f32(toks[2], ln)?,
672            parse_f32(toks[3], ln)?,
673        ];
674    } else if eq_ci(kw, "ambient") && toks.len() >= 4 {
675        mesh.ambient_color = [
676            parse_f32(toks[1], ln)?,
677            parse_f32(toks[2], ln)?,
678            parse_f32(toks[3], ln)?,
679        ];
680    } else if eq_ci(kw, "transparencyhint") && toks.len() >= 2 {
681        mesh.transparency_hint = parse_i32(toks[1], ln)?;
682    } else if eq_ci(kw, "animateuv") && toks.len() >= 2 {
683        mesh.animate_uv = parse_i32(toks[1], ln)?;
684    } else if eq_ci(kw, "uvdirectionx") && toks.len() >= 2 {
685        mesh.uv_direction_x = parse_f32(toks[1], ln)?;
686    } else if eq_ci(kw, "uvdirectiony") && toks.len() >= 2 {
687        mesh.uv_direction_y = parse_f32(toks[1], ln)?;
688    } else if eq_ci(kw, "uvjitter") && toks.len() >= 2 {
689        mesh.uv_jitter = parse_f32(toks[1], ln)?;
690    } else if eq_ci(kw, "uvjitterspeed") && toks.len() >= 2 {
691        mesh.uv_jitter_speed = parse_f32(toks[1], ln)?;
692    } else if eq_ci(kw, "lightmapped") && toks.len() >= 2 {
693        mesh.light_mapped = parse_i32(toks[1], ln)? != 0;
694    } else if eq_ci(kw, "rotatetexture") && toks.len() >= 2 {
695        mesh.rotate_texture = parse_i32(toks[1], ln)? != 0;
696    } else if eq_ci(kw, "m_bIsBackgroundGeometry") && toks.len() >= 2 {
697        mesh.is_background_geometry = parse_i32(toks[1], ln)? != 0;
698    } else if eq_ci(kw, "shadow") && toks.len() >= 2 {
699        mesh.shadow = parse_i32(toks[1], ln)? != 0;
700    } else if eq_ci(kw, "beaming") && toks.len() >= 2 {
701        mesh.beaming = parse_i32(toks[1], ln)? != 0;
702    } else if eq_ci(kw, "render") && toks.len() >= 2 {
703        mesh.render = parse_i32(toks[1], ln)? != 0;
704    } else if (eq_ci(kw, "bitmap") || eq_ci(kw, "texture0")) && toks.len() >= 2 {
705        let val = toks[1];
706        mesh.texture_0 = if eq_ci(val, "NULL") {
707            String::new()
708        } else {
709            val.to_string()
710        };
711    } else if (eq_ci(kw, "bitmap2") || eq_ci(kw, "texture1")) && toks.len() >= 2 {
712        let val = toks[1];
713        mesh.texture_1 = if eq_ci(val, "NULL") {
714            String::new()
715        } else {
716            val.to_string()
717        };
718    } else if eq_ci(kw, "inv_count") && toks.len() >= 2 {
719        mesh.inverted_counter = parse_u32(toks[1], ln)?;
720    } else if eq_ci(kw, "verts") && toks.len() >= 2 {
721        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
722        mesh.positions = parse_vec3_block(p, count)?;
723    } else if eq_ci(kw, "faces") && toks.len() >= 2 {
724        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
725        mesh.faces = parse_face_block(p, count)?;
726    } else if (eq_ci(kw, "tverts") || eq_ci(kw, "tverts0")) && toks.len() >= 2 {
727        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
728        mesh.uv1 = parse_uv_block(p, count)?;
729    } else if eq_ci(kw, "tverts1") && toks.len() >= 2 {
730        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
731        mesh.uv2 = parse_uv_block(p, count)?;
732    } else if eq_ci(kw, "tverts2") && toks.len() >= 2 {
733        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
734        mesh.uv3 = parse_uv_block(p, count)?;
735    } else if eq_ci(kw, "tverts3") && toks.len() >= 2 {
736        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
737        mesh.uv4 = parse_uv_block(p, count)?;
738    } else if eq_ci(kw, "colors") && toks.len() >= 2 {
739        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
740        mesh.vertex_colors = parse_color_block(p, count)?;
741    } else if eq_ci(kw, "tangentspace")
742        || eq_ci(kw, "dirt_enabled")
743        || eq_ci(kw, "dirt_texture")
744        || eq_ci(kw, "dirt_worldspace")
745        || eq_ci(kw, "hologram_donotdraw")
746    {
747        // Informational fields -- parsed and ignored.
748    } else {
749        return Ok(false);
750    }
751
752    Ok(true)
753}
754
755fn parse_vec3_block(p: &mut AsciiParser, count: usize) -> Result<Vec<[f32; 3]>, MdlAsciiError> {
756    let mut result = Vec::with_capacity(count);
757    for _ in 0..count {
758        let (ln, line) = p
759            .next_line()
760            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in vec3 block".into()))?;
761        let toks = tokens(&line);
762        if toks.len() < 3 {
763            return Err(p.parse_err(ln, "expected 3 floats"));
764        }
765        result.push([
766            parse_f32(toks[0], ln)?,
767            parse_f32(toks[1], ln)?,
768            parse_f32(toks[2], ln)?,
769        ]);
770    }
771    Ok(result)
772}
773
774fn parse_face_block(p: &mut AsciiParser, count: usize) -> Result<Vec<MdlFace>, MdlAsciiError> {
775    let mut faces = Vec::with_capacity(count);
776    for _ in 0..count {
777        let (ln, line) = p
778            .next_line()
779            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in face block".into()))?;
780        let toks = tokens(&line);
781        if toks.len() < 8 {
782            return Err(p.parse_err(ln, "expected 8 values in face line"));
783        }
784        let v0 = parse_u16(toks[0], ln)?;
785        let v1 = parse_u16(toks[1], ln)?;
786        let v2 = parse_u16(toks[2], ln)?;
787        // toks[3] = smoothgroup (ignored)
788        // toks[4..7] = tv indices (ignored)
789        let surface_id = parse_u32(toks[7], ln)?;
790
791        faces.push(MdlFace {
792            plane_normal: [0.0; 3], // computed later
793            plane_distance: 0.0,    // computed later
794            surface_id,
795            adjacent: [0xFFFF; 3], // computed later
796            vertex_indices: [v0, v1, v2],
797        });
798    }
799    Ok(faces)
800}
801
802fn parse_uv_block(p: &mut AsciiParser, count: usize) -> Result<Vec<[f32; 2]>, MdlAsciiError> {
803    let mut uvs = Vec::with_capacity(count);
804    for _ in 0..count {
805        let (ln, line) = p
806            .next_line()
807            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in UV block".into()))?;
808        let toks = tokens(&line);
809        if toks.len() < 2 {
810            return Err(p.parse_err(ln, "expected at least 2 floats in UV line"));
811        }
812        // Accept 2 or 3 values (3rd is legacy trailing zero).
813        uvs.push([parse_f32(toks[0], ln)?, parse_f32(toks[1], ln)?]);
814    }
815    Ok(uvs)
816}
817
818fn parse_color_block(p: &mut AsciiParser, count: usize) -> Result<Vec<[u8; 4]>, MdlAsciiError> {
819    let mut colors = Vec::with_capacity(count);
820    for _ in 0..count {
821        let (ln, line) = p
822            .next_line()
823            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in color block".into()))?;
824        let toks = tokens(&line);
825        if toks.len() < 3 {
826            return Err(p.parse_err(ln, "expected 3 floats in color line"));
827        }
828        // Clamped to [0, 255] before cast -- truncation is impossible.
829        #[allow(
830            clippy::cast_possible_truncation,
831            clippy::cast_sign_loss,
832            clippy::as_conversions
833        )]
834        let r = (parse_f32(toks[0], ln)? * 255.0).round().clamp(0.0, 255.0) as u8;
835        #[allow(
836            clippy::cast_possible_truncation,
837            clippy::cast_sign_loss,
838            clippy::as_conversions
839        )]
840        let g = (parse_f32(toks[1], ln)? * 255.0).round().clamp(0.0, 255.0) as u8;
841        #[allow(
842            clippy::cast_possible_truncation,
843            clippy::cast_sign_loss,
844            clippy::as_conversions
845        )]
846        let b = (parse_f32(toks[2], ln)? * 255.0).round().clamp(0.0, 255.0) as u8;
847        colors.push([r, g, b, 255]);
848    }
849    Ok(colors)
850}
851
852// ---------------------------------------------------------------------------
853// Skin field parsing
854// ---------------------------------------------------------------------------
855
856fn parse_skin_field(
857    toks: &[&str],
858    ln: usize,
859    p: &mut AsciiParser,
860    skin: &mut MdlSkin,
861) -> Result<bool, MdlAsciiError> {
862    let kw = toks[0];
863    if (eq_ci(kw, "weights") || eq_ci(kw, "skinweights")) && toks.len() >= 2 {
864        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
865        parse_skin_weights(p, count, skin)?;
866        Ok(true)
867    } else {
868        Ok(false)
869    }
870}
871
872/// Parsed bone weight entry for a single vertex.
873struct VertexWeights {
874    /// (bone_name, weight) pairs, up to 4.
875    pairs: Vec<(String, f32)>,
876}
877
878fn parse_skin_weights(
879    p: &mut AsciiParser,
880    count: usize,
881    skin: &mut MdlSkin,
882) -> Result<(), MdlAsciiError> {
883    let mut vertex_weights: Vec<VertexWeights> = Vec::with_capacity(count);
884
885    for _ in 0..count {
886        let (ln, line) = p
887            .next_line()
888            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in weights block".into()))?;
889        let toks = tokens(&line);
890        let mut pairs = Vec::new();
891        let mut i = 0;
892        while i + 1 < toks.len() && pairs.len() < 4 {
893            let bone_name = toks[i].to_string();
894            let weight = parse_f32(toks[i + 1], ln)?;
895            if weight > 0.0 {
896                pairs.push((bone_name, weight));
897            }
898            i += 2;
899        }
900        vertex_weights.push(VertexWeights { pairs });
901    }
902
903    // Collect unique bone names and assign MDX bone indices.
904    let mut bone_name_to_idx: HashMap<String, usize> = HashMap::new();
905    for vw in &vertex_weights {
906        for (name, _) in &vw.pairs {
907            let next_idx = bone_name_to_idx.len();
908            bone_name_to_idx.entry(name.clone()).or_insert(next_idx);
909        }
910    }
911
912    // Populate typed bone weight/index arrays. The binary writer will compute
913    // canonical MDX byte offsets at serialization time - the offset fields here
914    // are placeholders that get backpatched during MDX layout computation.
915    skin.mdx_bone_weights_offset = 0;
916    skin.mdx_bone_indices_offset = 0;
917
918    let mut bone_weights_vec = Vec::with_capacity(count);
919    let mut bone_indices_vec = Vec::with_capacity(count);
920    for vw in &vertex_weights {
921        let mut weights = [0.0f32; 4];
922        let mut indices = [0.0f32; 4];
923        for (j, (name, weight)) in vw.pairs.iter().enumerate().take(4) {
924            let idx = *bone_name_to_idx.get(name).unwrap_or(&0);
925            weights[j] = *weight;
926            // MDX stores bone indices as f32 (engine convention). KotOR bone
927            // counts are always < 256, so usize->f32 is lossless in practice.
928            #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
929            let idx_f32 = idx as f32;
930            indices[j] = idx_f32;
931        }
932        bone_weights_vec.push(weights);
933        bone_indices_vec.push(indices);
934    }
935    skin.bone_weights = bone_weights_vec;
936    skin.bone_indices = bone_indices_vec;
937
938    // Bonemap requires the full geometry tree (name -> DFS index) to build.
939    // Left empty here; populated during tree assembly post-processing.
940    skin.bonemap = Vec::new();
941
942    Ok(())
943}
944
945// ---------------------------------------------------------------------------
946// Dangly field parsing
947// ---------------------------------------------------------------------------
948
949fn parse_dangly_field(
950    toks: &[&str],
951    ln: usize,
952    p: &mut AsciiParser,
953    dangly: &mut MdlDangly,
954) -> Result<bool, MdlAsciiError> {
955    let kw = toks[0];
956
957    if eq_ci(kw, "displacement") && toks.len() >= 2 {
958        dangly.displacement = parse_f32(toks[1], ln)?;
959    } else if eq_ci(kw, "tightness") && toks.len() >= 2 {
960        dangly.tightness = parse_f32(toks[1], ln)?;
961    } else if eq_ci(kw, "period") && toks.len() >= 2 {
962        dangly.period = parse_f32(toks[1], ln)?;
963    } else if eq_ci(kw, "constraints") && toks.len() >= 2 {
964        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
965        dangly.constraints = parse_float_block(p, count)?;
966    } else {
967        return Ok(false);
968    }
969    Ok(true)
970}
971
972fn parse_float_block(p: &mut AsciiParser, count: usize) -> Result<Vec<f32>, MdlAsciiError> {
973    let mut result = Vec::with_capacity(count);
974    for _ in 0..count {
975        let (ln, line) = p
976            .next_line()
977            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in float block".into()))?;
978        let toks = tokens(&line);
979        if toks.is_empty() {
980            return Err(p.parse_err(ln, "expected float value"));
981        }
982        result.push(parse_f32(toks[0], ln)?);
983    }
984    Ok(result)
985}
986
987// ---------------------------------------------------------------------------
988// AABB field parsing
989// ---------------------------------------------------------------------------
990
991fn parse_aabb_field(
992    toks: &[&str],
993    _ln: usize,
994    p: &mut AsciiParser,
995    aabb: &mut MdlAabb,
996) -> Result<bool, MdlAsciiError> {
997    if !eq_ci(toks[0], "aabb") {
998        return Ok(false);
999    }
1000
1001    // Read leaf entries until a line doesn't have 7 numeric tokens.
1002    let mut leaves: Vec<AabbLeaf> = Vec::new();
1003    while let Some((ln, line)) = p.peek_line() {
1004        let toks = tokens(line);
1005        if toks.len() < 7 {
1006            break;
1007        }
1008        // Try to parse all 7 as numbers.
1009        let vals: Result<Vec<f32>, _> = toks[..7].iter().map(|t| parse_f32(t, ln)).collect();
1010        match vals {
1011            Ok(v) => {
1012                p.next_line(); // consume
1013                leaves.push(AabbLeaf {
1014                    box_min: [v[0], v[1], v[2]],
1015                    box_max: [v[3], v[4], v[5]],
1016                    // AABB face indices are small non-negative integers stored as f32
1017                    // in ASCII MDL. Truncation to i32 is intentional and lossless.
1018                    #[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
1019                    face_index: v[6] as i32,
1020                });
1021            }
1022            Err(_) => break,
1023        }
1024    }
1025
1026    if !leaves.is_empty() {
1027        aabb.aabb_tree = Some(Box::new(build_aabb_tree(&leaves)));
1028    }
1029
1030    Ok(true)
1031}
1032
1033struct AabbLeaf {
1034    box_min: [f32; 3],
1035    box_max: [f32; 3],
1036    face_index: i32,
1037}
1038
1039/// Builds a BVH tree from AABB leaf entries using median-split.
1040fn build_aabb_tree(leaves: &[AabbLeaf]) -> AabbNode {
1041    if leaves.len() == 1 {
1042        return AabbNode {
1043            box_min: leaves[0].box_min,
1044            box_max: leaves[0].box_max,
1045            face_index: leaves[0].face_index,
1046            split_direction_flags: 0,
1047            left: None,
1048            right: None,
1049        };
1050    }
1051
1052    // Compute combined bbox.
1053    let mut combined_min = [f32::MAX; 3];
1054    let mut combined_max = [f32::MIN; 3];
1055    for leaf in leaves {
1056        for i in 0..3 {
1057            combined_min[i] = combined_min[i].min(leaf.box_min[i]);
1058            combined_max[i] = combined_max[i].max(leaf.box_max[i]);
1059        }
1060    }
1061
1062    // Find longest axis of combined bbox.
1063    let extents = [
1064        combined_max[0] - combined_min[0],
1065        combined_max[1] - combined_min[1],
1066        combined_max[2] - combined_min[2],
1067    ];
1068    let axis = if extents[0] >= extents[1] && extents[0] >= extents[2] {
1069        0
1070    } else if extents[1] >= extents[2] {
1071        1
1072    } else {
1073        2
1074    };
1075
1076    // Sort by centroid along the chosen axis.
1077    let mut sorted: Vec<usize> = (0..leaves.len()).collect();
1078    sorted.sort_by(|&a, &b| {
1079        let ca = (leaves[a].box_min[axis] + leaves[a].box_max[axis]) * 0.5;
1080        let cb = (leaves[b].box_min[axis] + leaves[b].box_max[axis]) * 0.5;
1081        ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
1082    });
1083
1084    // Split at median.
1085    let mid = sorted.len() / 2;
1086    let left_leaves: Vec<AabbLeaf> = sorted[..mid]
1087        .iter()
1088        .map(|&i| AabbLeaf {
1089            box_min: leaves[i].box_min,
1090            box_max: leaves[i].box_max,
1091            face_index: leaves[i].face_index,
1092        })
1093        .collect();
1094    let right_leaves: Vec<AabbLeaf> = sorted[mid..]
1095        .iter()
1096        .map(|&i| AabbLeaf {
1097            box_min: leaves[i].box_min,
1098            box_max: leaves[i].box_max,
1099            face_index: leaves[i].face_index,
1100        })
1101        .collect();
1102
1103    let left = build_aabb_tree(&left_leaves);
1104    let right = build_aabb_tree(&right_leaves);
1105
1106    // Split direction flags: 1=+X, 2=+Y, 4=+Z.
1107    let split_flags = 1u32 << axis;
1108
1109    AabbNode {
1110        box_min: combined_min,
1111        box_max: combined_max,
1112        face_index: -1,
1113        split_direction_flags: split_flags,
1114        left: Some(Box::new(left)),
1115        right: Some(Box::new(right)),
1116    }
1117}
1118
1119// ---------------------------------------------------------------------------
1120// Light field parsing
1121// ---------------------------------------------------------------------------
1122
1123fn parse_light_field(
1124    toks: &[&str],
1125    ln: usize,
1126    p: &mut AsciiParser,
1127    light: &mut MdlLight,
1128) -> Result<bool, MdlAsciiError> {
1129    let kw = toks[0];
1130
1131    if eq_ci(kw, "lightpriority") && toks.len() >= 2 {
1132        light.priority = parse_i32(toks[1], ln)?;
1133    } else if eq_ci(kw, "ambientonly") && toks.len() >= 2 {
1134        light.ambientonly = parse_i32(toks[1], ln)?;
1135    } else if eq_ci(kw, "ndynamictype") && toks.len() >= 2 {
1136        light.num_dynamic_types = parse_i32(toks[1], ln)?;
1137    } else if eq_ci(kw, "affectdynamic") && toks.len() >= 2 {
1138        light.affectdynamic = parse_i32(toks[1], ln)?;
1139    } else if eq_ci(kw, "shadow") && toks.len() >= 2 {
1140        light.shadow = parse_i32(toks[1], ln)?;
1141    } else if eq_ci(kw, "generateflare") && toks.len() >= 2 {
1142        light.generateflare = parse_i32(toks[1], ln)?;
1143    } else if eq_ci(kw, "fadingLight") && toks.len() >= 2 {
1144        light.fading_light = parse_i32(toks[1], ln)?;
1145    } else if eq_ci(kw, "flareradius") && toks.len() >= 2 {
1146        light.flare_radius = parse_f32(toks[1], ln)?;
1147    } else if eq_ci(kw, "lensflares") {
1148        // Count-only header, no data lines.
1149    } else if eq_ci(kw, "texturenames") && toks.len() >= 2 {
1150        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1151        light.flare_texture_names = parse_string_block(p, count)?;
1152    } else if eq_ci(kw, "flarepositions") && toks.len() >= 2 {
1153        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1154        light.flare_positions = parse_float_block(p, count)?;
1155    } else if eq_ci(kw, "flaresizes") && toks.len() >= 2 {
1156        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1157        light.flare_sizes = parse_float_block(p, count)?;
1158    } else if eq_ci(kw, "flarecolorshifts") && toks.len() >= 2 {
1159        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1160        light.flare_color_shifts = parse_vec3_block(p, count)?;
1161    } else {
1162        return Ok(false);
1163    }
1164    Ok(true)
1165}
1166
1167fn parse_string_block(p: &mut AsciiParser, count: usize) -> Result<Vec<String>, MdlAsciiError> {
1168    let mut result = Vec::with_capacity(count);
1169    for _ in 0..count {
1170        let (_ln, line) = p
1171            .next_line()
1172            .ok_or_else(|| MdlAsciiError::InvalidData("unexpected EOF in string block".into()))?;
1173        result.push(line.trim().to_string());
1174    }
1175    Ok(result)
1176}
1177
1178// ---------------------------------------------------------------------------
1179// Emitter field parsing
1180// ---------------------------------------------------------------------------
1181
1182fn parse_emitter_field(
1183    toks: &[&str],
1184    ln: usize,
1185    _p: &mut AsciiParser,
1186    em: &mut MdlEmitter,
1187) -> Result<bool, MdlAsciiError> {
1188    let kw = toks[0];
1189
1190    if eq_ci(kw, "deadspace") && toks.len() >= 2 {
1191        em.deadspace = parse_f32(toks[1], ln)?;
1192    } else if eq_ci(kw, "blastRadius") && toks.len() >= 2 {
1193        em.blast_radius = parse_f32(toks[1], ln)?;
1194    } else if eq_ci(kw, "blastLength") && toks.len() >= 2 {
1195        em.blast_length = parse_f32(toks[1], ln)?;
1196    } else if eq_ci(kw, "numBranches") && toks.len() >= 2 {
1197        em.num_branches = parse_i32(toks[1], ln)?;
1198    } else if eq_ci(kw, "controlptsmoothing") && toks.len() >= 2 {
1199        em.control_pt_smoothing = parse_i32(toks[1], ln)?;
1200    } else if eq_ci(kw, "xgrid") && toks.len() >= 2 {
1201        em.x_grid = parse_i32(toks[1], ln)?;
1202    } else if eq_ci(kw, "ygrid") && toks.len() >= 2 {
1203        em.y_grid = parse_i32(toks[1], ln)?;
1204    } else if eq_ci(kw, "spawntype") && toks.len() >= 2 {
1205        em.spawn_type = parse_i32(toks[1], ln)?;
1206    } else if eq_ci(kw, "update") && toks.len() >= 2 {
1207        em.update = toks[1].to_string();
1208    } else if eq_ci(kw, "render") && toks.len() >= 2 {
1209        em.render = toks[1].to_string();
1210    } else if eq_ci(kw, "blend") && toks.len() >= 2 {
1211        em.blend = toks[1].to_string();
1212    } else if eq_ci(kw, "texture") && toks.len() >= 2 {
1213        em.texture = toks[1].to_string();
1214    } else if eq_ci(kw, "chunkName") && toks.len() >= 2 {
1215        em.chunk_name = toks[1].to_string();
1216    } else if eq_ci(kw, "twosidedtex") && toks.len() >= 2 {
1217        em.two_sided_tex = parse_i32(toks[1], ln)?;
1218    } else if eq_ci(kw, "loop") && toks.len() >= 2 {
1219        em.loop_emitter = parse_i32(toks[1], ln)?;
1220    } else if eq_ci(kw, "renderorder") && toks.len() >= 2 {
1221        em.render_order = parse_u16(toks[1], ln)?;
1222    } else if eq_ci(kw, "m_bFrameBlending") && toks.len() >= 2 {
1223        em.frame_blending = parse_i32(toks[1], ln)? != 0;
1224    } else if eq_ci(kw, "m_sDepthTextureName") && toks.len() >= 2 {
1225        em.depth_texture_name = toks[1].to_string();
1226    } else if eq_ci(kw, "p2p")
1227        || eq_ci(kw, "p2p_sel")
1228        || eq_ci(kw, "affectedByWind")
1229        || eq_ci(kw, "m_isTinted")
1230        || eq_ci(kw, "bounce")
1231        || eq_ci(kw, "random")
1232        || eq_ci(kw, "inherit")
1233        || eq_ci(kw, "inheritvel")
1234        || eq_ci(kw, "inherit_local")
1235        || eq_ci(kw, "splat")
1236        || eq_ci(kw, "inherit_part")
1237        || eq_ci(kw, "depth_texture")
1238    {
1239        // Controller-driven flags -- not stored in the binary struct.
1240        // Silently ignored (matching engine behavior).
1241    } else {
1242        return Ok(false);
1243    }
1244    Ok(true)
1245}
1246
1247// ---------------------------------------------------------------------------
1248// Reference field parsing
1249// ---------------------------------------------------------------------------
1250
1251fn parse_reference_field(
1252    toks: &[&str],
1253    ln: usize,
1254    reference: &mut MdlReference,
1255) -> Result<bool, MdlAsciiError> {
1256    let kw = toks[0];
1257
1258    if eq_ci(kw, "refModel") && toks.len() >= 2 {
1259        reference.ref_model = toks[1].to_string();
1260    } else if eq_ci(kw, "reattachable") && toks.len() >= 2 {
1261        reference.reattachable = parse_i32(toks[1], ln)?;
1262    } else {
1263        return Ok(false);
1264    }
1265    Ok(true)
1266}
1267
1268// ---------------------------------------------------------------------------
1269// AnimMesh field parsing
1270// ---------------------------------------------------------------------------
1271
1272fn parse_animmesh_field(
1273    toks: &[&str],
1274    ln: usize,
1275    p: &mut AsciiParser,
1276    am: &mut MdlAnimMesh,
1277) -> Result<bool, MdlAsciiError> {
1278    let kw = toks[0];
1279
1280    if eq_ci(kw, "sampleperiod") && toks.len() >= 2 {
1281        am.sample_period = parse_f32(toks[1], ln)?;
1282    } else if eq_ci(kw, "animverts") && toks.len() >= 2 {
1283        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1284        am.anim_verts = parse_vec3_block(p, count)?;
1285    } else if eq_ci(kw, "animtverts") && toks.len() >= 2 {
1286        let count = usize::try_from(parse_u32(toks[1], ln)?).expect("count fits in usize");
1287        am.anim_t_verts = parse_vec3_block(p, count)?;
1288    } else {
1289        return Ok(false);
1290    }
1291    Ok(true)
1292}
1293
1294// ---------------------------------------------------------------------------
1295// Animation parsing
1296// ---------------------------------------------------------------------------
1297
1298fn parse_animation(
1299    p: &mut AsciiParser,
1300    anim_name: &str,
1301    _model_name: &str,
1302    _start_line: usize,
1303) -> Result<MdlAnimation, MdlAsciiError> {
1304    let mut length: f32 = 0.0;
1305    let mut transition_time: f32 = 0.0;
1306    let mut anim_root = String::new();
1307    let mut events: Vec<MdlAnimEvent> = Vec::new();
1308    let mut flat_nodes: Vec<FlatAnimNode> = Vec::new();
1309
1310    while let Some((ln, line)) = p.next_line() {
1311        let toks = tokens(&line);
1312        if toks.is_empty() {
1313            continue;
1314        }
1315        let kw = toks[0];
1316
1317        if eq_ci(kw, "length") && toks.len() >= 2 {
1318            length = parse_f32(toks[1], ln)?;
1319        } else if eq_ci(kw, "transtime") && toks.len() >= 2 {
1320            transition_time = parse_f32(toks[1], ln)?;
1321        } else if eq_ci(kw, "animroot") && toks.len() >= 2 {
1322            anim_root = toks[1].to_string();
1323        } else if eq_ci(kw, "event") && toks.len() >= 3 {
1324            let time = parse_f32(toks[1], ln)?;
1325            let name = toks[2].to_string();
1326            events.push(MdlAnimEvent { time, name });
1327        } else if eq_ci(kw, "node") && toks.len() >= 3 {
1328            let flat = parse_anim_node(p, toks[2], ln)?;
1329            flat_nodes.push(flat);
1330        } else if eq_ci(kw, "doneanim") {
1331            break;
1332        }
1333    }
1334
1335    let root_node = assemble_anim_node_tree(flat_nodes)?;
1336
1337    Ok(MdlAnimation {
1338        name: anim_name.to_string(),
1339        length,
1340        transition_time,
1341        anim_root,
1342        events,
1343        root_node,
1344        fn_ptr1: 0,
1345        fn_ptr2: 0,
1346    })
1347}
1348
1349fn parse_anim_node(
1350    p: &mut AsciiParser,
1351    name: &str,
1352    _node_line: usize,
1353) -> Result<FlatAnimNode, MdlAsciiError> {
1354    let mut parent_name = "NULL".into();
1355    let mut controllers = Vec::new();
1356
1357    while let Some((ln, line)) = p.next_line() {
1358        let toks = tokens(&line);
1359        if toks.is_empty() {
1360            continue;
1361        }
1362        let kw = toks[0];
1363
1364        if eq_ci(kw, "endnode") {
1365            break;
1366        } else if eq_ci(kw, "parent") && toks.len() >= 2 {
1367            parent_name = toks[1].to_string();
1368        } else {
1369            // Try all contexts for animation nodes.
1370            try_parse_controller_line(p, &toks, ln, NodeTypeContext::Base, &mut controllers)?;
1371        }
1372    }
1373
1374    Ok(FlatAnimNode {
1375        name: name.to_string(),
1376        parent_name,
1377        controllers,
1378    })
1379}
1380
1381// ---------------------------------------------------------------------------
1382// Node tree assembly
1383// ---------------------------------------------------------------------------
1384
1385fn assemble_node_tree(flat_nodes: Vec<FlatNode>) -> Result<MdlNode, MdlAsciiError> {
1386    if flat_nodes.is_empty() {
1387        return Err(MdlAsciiError::InvalidData("no geometry nodes found".into()));
1388    }
1389
1390    // Find root (parent == "NULL").
1391    let root_idx = flat_nodes
1392        .iter()
1393        .position(|n| eq_ci(&n.parent_name, "NULL"))
1394        .ok_or_else(|| MdlAsciiError::InvalidData("no root node (parent NULL) found".into()))?;
1395
1396    // Nodes are in DFS preorder. Use a stack to track the current ancestor
1397    // chain, correctly handling duplicate names (e.g., parent and child both
1398    // named "lhand_g"). Each stack entry is (flat_index, name).
1399    let mut children_of_idx: HashMap<usize, Vec<usize>> = HashMap::new();
1400    let mut stack: Vec<(usize, String)> = Vec::new();
1401
1402    for (i, node) in flat_nodes.iter().enumerate() {
1403        if i == root_idx {
1404            stack.push((i, node.name.clone()));
1405            continue;
1406        }
1407
1408        // Pop the stack back to the parent: find the topmost stack entry
1409        // whose name matches this node's parent_name.
1410        let parent_pos = stack.iter().rposition(|(_, n)| eq_ci(n, &node.parent_name));
1411        if let Some(pos) = parent_pos {
1412            // Pop everything above the parent (sibling subtrees that ended).
1413            stack.truncate(pos + 1);
1414            let parent_idx = stack[pos].0;
1415            children_of_idx.entry(parent_idx).or_default().push(i);
1416        }
1417        // Push this node onto the stack.
1418        stack.push((i, node.name.clone()));
1419    }
1420
1421    fn build_node(
1422        idx: usize,
1423        flat: &[FlatNode],
1424        children_of_idx: &HashMap<usize, Vec<usize>>,
1425    ) -> MdlNode {
1426        let f = &flat[idx];
1427        let children: Vec<MdlNode> = children_of_idx
1428            .get(&idx)
1429            .cloned()
1430            .unwrap_or_default()
1431            .iter()
1432            .map(|&ci| build_node(ci, flat, children_of_idx))
1433            .collect();
1434
1435        MdlNode {
1436            name: f.name.clone(),
1437            parent_index: None, // Not used for ASCII-sourced.
1438            children,
1439            position: f.position,
1440            rotation: f.rotation,
1441            node_data: f.node_data.clone(),
1442            controllers: f.controllers.clone(),
1443            orphan_controller_data: Vec::new(),
1444            header_padding_02: [0, 0],
1445            header_padding_06: [0, 0],
1446        }
1447    }
1448
1449    Ok(build_node(root_idx, &flat_nodes, &children_of_idx))
1450}
1451
1452fn assemble_anim_node_tree(flat_nodes: Vec<FlatAnimNode>) -> Result<MdlAnimNode, MdlAsciiError> {
1453    if flat_nodes.is_empty() {
1454        // Empty animation -- create a dummy root.
1455        return Ok(MdlAnimNode {
1456            name: String::new(),
1457            node_number: 0,
1458            controllers: Vec::new(),
1459            orphan_controller_data: Vec::new(),
1460            children: Vec::new(),
1461        });
1462    }
1463
1464    let root_idx = flat_nodes
1465        .iter()
1466        .position(|n| eq_ci(&n.parent_name, "NULL"))
1467        .unwrap_or(0);
1468
1469    // Same DFS-order stack-based parent resolution as assemble_node_tree.
1470    let mut children_of_idx: HashMap<usize, Vec<usize>> = HashMap::new();
1471    let mut stack: Vec<(usize, String)> = Vec::new();
1472    for (i, node) in flat_nodes.iter().enumerate() {
1473        if i == root_idx {
1474            stack.push((i, node.name.clone()));
1475            continue;
1476        }
1477        let parent_pos = stack.iter().rposition(|(_, n)| eq_ci(n, &node.parent_name));
1478        if let Some(pos) = parent_pos {
1479            stack.truncate(pos + 1);
1480            let parent_idx = stack[pos].0;
1481            children_of_idx.entry(parent_idx).or_default().push(i);
1482        }
1483        stack.push((i, node.name.clone()));
1484    }
1485
1486    fn build_anim(
1487        idx: usize,
1488        flat: &[FlatAnimNode],
1489        children_of_idx: &HashMap<usize, Vec<usize>>,
1490    ) -> MdlAnimNode {
1491        let f = &flat[idx];
1492        let children: Vec<MdlAnimNode> = children_of_idx
1493            .get(&idx)
1494            .cloned()
1495            .unwrap_or_default()
1496            .iter()
1497            .map(|&ci| build_anim(ci, flat, children_of_idx))
1498            .collect();
1499
1500        MdlAnimNode {
1501            name: f.name.clone(),
1502            node_number: 0, // Set during post-processing.
1503            controllers: f.controllers.clone(),
1504            orphan_controller_data: Vec::new(),
1505            children,
1506        }
1507    }
1508
1509    Ok(build_anim(root_idx, &flat_nodes, &children_of_idx))
1510}
1511
1512// ---------------------------------------------------------------------------
1513// Post-processing helpers
1514// ---------------------------------------------------------------------------
1515
1516fn build_name_index_map(node: &MdlNode) -> HashMap<String, u16> {
1517    let mut map = HashMap::new();
1518    let mut idx = 0u16;
1519    build_name_idx_recursive(node, &mut map, &mut idx);
1520    map
1521}
1522
1523fn build_name_idx_recursive(node: &MdlNode, map: &mut HashMap<String, u16>, idx: &mut u16) {
1524    map.insert(node.name.clone(), *idx);
1525    *idx += 1;
1526    for child in &node.children {
1527        build_name_idx_recursive(child, map, idx);
1528    }
1529}
1530
1531fn subtract_geo_positions_from_anim(
1532    node: &mut MdlAnimNode,
1533    geo_positions: &HashMap<&str, [f32; 3]>,
1534) {
1535    if let Some(geo_pos) = geo_positions.get(node.name.as_str()) {
1536        for ctrl in &mut node.controllers {
1537            if ctrl.controller_type == MdlControllerType::POSITION {
1538                for key in &mut ctrl.keys {
1539                    if key.values.len() >= 3 {
1540                        key.values[0] -= geo_pos[0];
1541                        key.values[1] -= geo_pos[1];
1542                        key.values[2] -= geo_pos[2];
1543                    }
1544                }
1545            }
1546        }
1547    }
1548    for child in &mut node.children {
1549        subtract_geo_positions_from_anim(child, geo_positions);
1550    }
1551}
1552
1553fn assign_anim_node_numbers(node: &mut MdlAnimNode, name_to_index: &HashMap<String, u16>) {
1554    node.node_number = name_to_index.get(&node.name).copied().unwrap_or(0);
1555    for child in &mut node.children {
1556        assign_anim_node_numbers(child, name_to_index);
1557    }
1558}
1559
1560// ---------------------------------------------------------------------------
1561// Utility: case-insensitive suffix stripping
1562// ---------------------------------------------------------------------------
1563
1564fn strip_suffix_ci<'a>(s: &'a str, suffix: &str) -> Option<&'a str> {
1565    let s_lower = s.to_ascii_lowercase();
1566    let suffix_lower = suffix.to_ascii_lowercase();
1567    if s_lower.ends_with(&suffix_lower) {
1568        Some(&s[..s.len() - suffix.len()])
1569    } else {
1570        None
1571    }
1572}
1573
1574// ---------------------------------------------------------------------------
1575// Tests
1576// ---------------------------------------------------------------------------
1577
1578#[cfg(test)]
1579mod tests {
1580    use super::*;
1581    use crate::mdl::ascii_writer::write_mdl_ascii_to_string;
1582
1583    #[test]
1584    fn minimal_model_parse() {
1585        let input = "\
1586newmodel test
1587setsupermodel test NULL
1588classification other
1589classification_unk1 0
1590ignorefog 0
1591setanimationscale 1.0
1592compress_quaternions 0
1593headlink 0
1594beginmodelgeom test
1595  bmin -1.0 -1.0 -1.0
1596  bmax 1.0 1.0 1.0
1597  radius 1.73
1598  node dummy test
1599    parent NULL
1600  endnode
1601endmodelgeom test
1602donemodel test
1603";
1604        let mdl = read_mdl_ascii_from_str(input).unwrap();
1605        assert_eq!(mdl.root_node.name, "test");
1606        assert_eq!(mdl.supermodel_name, "NULL");
1607        assert_eq!(mdl.classification, 0);
1608        assert_eq!(mdl.affected_by_fog, 1);
1609        assert_eq!(mdl.node_count, 1);
1610    }
1611
1612    #[test]
1613    fn mesh_node_parse() {
1614        let input = "\
1615newmodel m
1616setsupermodel m NULL
1617classification other
1618beginmodelgeom m
1619  node trimesh mesh1
1620    parent NULL
1621    diffuse 0.8 0.8 0.8
1622    ambient 0.2 0.2 0.2
1623    bitmap texture_a
1624    render 1
1625    verts 3
1626      0.0 0.0 0.0
1627      1.0 0.0 0.0
1628      0.0 1.0 0.0
1629    faces 1
1630      0 1 2  1  0 1 2  0
1631    tverts 3
1632      0.0 0.0
1633      1.0 0.0
1634      0.0 1.0
1635  endnode
1636endmodelgeom m
1637donemodel m
1638";
1639        let mdl = read_mdl_ascii_from_str(input).unwrap();
1640        let mesh = mdl.root_node.node_data.mesh().unwrap();
1641        assert_eq!(mesh.positions.len(), 3);
1642        assert_eq!(mesh.faces.len(), 1);
1643        assert_eq!(mesh.uv1.len(), 3);
1644        assert_eq!(mesh.texture_0, "texture_a");
1645        assert_eq!(mesh.faces[0].vertex_indices, [0, 1, 2]);
1646        assert_eq!(mesh.vertex_count, 3);
1647    }
1648
1649    #[test]
1650    fn controller_keyed_parse() {
1651        let input = "\
1652newmodel m
1653setsupermodel m NULL
1654classification other
1655beginmodelgeom m
1656  node dummy root
1657    parent NULL
1658    positionkey
1659      0.0 1.0 2.0 3.0
1660      0.5 4.0 5.0 6.0
1661    endlist
1662  endnode
1663endmodelgeom m
1664donemodel m
1665";
1666        let mdl = read_mdl_ascii_from_str(input).unwrap();
1667        assert_eq!(mdl.root_node.controllers.len(), 1);
1668        let ctrl = &mdl.root_node.controllers[0];
1669        assert_eq!(ctrl.controller_type, MdlControllerType::POSITION);
1670        assert_eq!(ctrl.keys.len(), 2);
1671        assert_eq!(ctrl.keys[0].time, 0.0);
1672        assert_eq!(ctrl.keys[0].values, vec![1.0, 2.0, 3.0]);
1673        assert_eq!(ctrl.keys[1].time, 0.5);
1674        assert_eq!(ctrl.keys[1].values, vec![4.0, 5.0, 6.0]);
1675    }
1676
1677    #[test]
1678    fn orientation_conversion() {
1679        let input = "\
1680newmodel m
1681setsupermodel m NULL
1682classification other
1683beginmodelgeom m
1684  node dummy root
1685    parent NULL
1686    orientation 0.0 0.0 1.0 1.5707963
1687  endnode
1688endmodelgeom m
1689donemodel m
1690";
1691        let mdl = read_mdl_ascii_from_str(input).unwrap();
1692        // Should be a ~90 degree rotation around Z.
1693        let q = mdl.root_node.rotation;
1694        // q = [w, x, y, z] -- w should be ~cos(pi/4) = ~0.707
1695        let expected_half = std::f32::consts::FRAC_1_SQRT_2;
1696        assert!((q[0] - expected_half).abs() < 0.01, "w = {}", q[0]);
1697        assert!(q[1].abs() < 0.01, "x = {}", q[1]);
1698        assert!(q[2].abs() < 0.01, "y = {}", q[2]);
1699        assert!((q[3] - expected_half).abs() < 0.01, "z = {}", q[3]);
1700    }
1701
1702    #[test]
1703    fn animation_parse() {
1704        let input = "\
1705newmodel m
1706setsupermodel m NULL
1707classification other
1708beginmodelgeom m
1709  node dummy root
1710    parent NULL
1711    position 10.0 20.0 30.0
1712  endnode
1713endmodelgeom m
1714newanim walk m
1715  length 1.0
1716  transtime 0.25
1717  animroot root
1718  event 0.5 footstep
1719  node dummy root
1720    parent NULL
1721    positionkey
1722      0.0 10.0 20.0 30.0
1723      1.0 11.0 21.0 31.0
1724    endlist
1725  endnode
1726doneanim walk m
1727donemodel m
1728";
1729        let mdl = read_mdl_ascii_from_str(input).unwrap();
1730        assert_eq!(mdl.animations.len(), 1);
1731        let anim = &mdl.animations[0];
1732        assert_eq!(anim.name, "walk");
1733        assert_eq!(anim.length, 1.0);
1734        assert_eq!(anim.anim_root, "root");
1735        assert_eq!(anim.events.len(), 1);
1736        assert_eq!(anim.events[0].name, "footstep");
1737
1738        // Position values should be deltas (absolute - geometry rest pose).
1739        // ASCII: [10, 20, 30] and [11, 21, 31], geo_pos = [10, 20, 30]
1740        // -> binary deltas: [0, 0, 0] and [1, 1, 1]
1741        let ctrl = &anim.root_node.controllers[0];
1742        assert_eq!(ctrl.controller_type, MdlControllerType::POSITION);
1743        assert!((ctrl.keys[0].values[0]).abs() < 0.001);
1744        assert!((ctrl.keys[0].values[1]).abs() < 0.001);
1745        assert!((ctrl.keys[0].values[2]).abs() < 0.001);
1746        assert!((ctrl.keys[1].values[0] - 1.0).abs() < 0.001);
1747        assert!((ctrl.keys[1].values[1] - 1.0).abs() < 0.001);
1748        assert!((ctrl.keys[1].values[2] - 1.0).abs() < 0.001);
1749    }
1750
1751    #[test]
1752    fn aabb_tree_reconstruction() {
1753        // 4 leaf entries should produce a balanced tree.
1754        let input = "\
1755newmodel m
1756setsupermodel m NULL
1757classification other
1758beginmodelgeom m
1759  node aabb walkmesh
1760    parent NULL
1761    verts 4
1762      0.0 0.0 0.0
1763      1.0 0.0 0.0
1764      1.0 1.0 0.0
1765      0.0 1.0 0.0
1766    faces 2
1767      0 1 2  1  0 1 2  0
1768      0 2 3  1  0 2 3  0
1769    aabb
1770      0.0 0.0 0.0 1.0 0.5 0.0 0
1771      0.0 0.5 0.0 1.0 1.0 0.0 1
1772  endnode
1773endmodelgeom m
1774donemodel m
1775";
1776        let mdl = read_mdl_ascii_from_str(input).unwrap();
1777        if let MdlNodeData::Aabb(ref aabb) = mdl.root_node.node_data {
1778            assert!(aabb.aabb_tree.is_some());
1779            let tree = aabb.aabb_tree.as_ref().unwrap();
1780            // Root should be internal (face_index == -1).
1781            assert_eq!(tree.face_index, -1);
1782            assert!(tree.left.is_some());
1783            assert!(tree.right.is_some());
1784        } else {
1785            panic!("expected AABB node");
1786        }
1787    }
1788
1789    // ---------------------------------------------------------------
1790    // ASCII self round-trip: write -> read -> write, compare strings
1791    // ---------------------------------------------------------------
1792
1793    fn ascii_self_roundtrip(input: &str) {
1794        let mdl = read_mdl_ascii_from_str(input).unwrap();
1795        let ascii1 = write_mdl_ascii_to_string(&mdl).unwrap();
1796        let mdl2 = read_mdl_ascii_from_str(&ascii1).unwrap();
1797        let ascii2 = write_mdl_ascii_to_string(&mdl2).unwrap();
1798        if ascii1 != ascii2 {
1799            // Find first differing line for diagnostics.
1800            for (i, (a, b)) in ascii1.lines().zip(ascii2.lines()).enumerate() {
1801                if a != b {
1802                    panic!(
1803                        "ASCII self round-trip mismatch at line {}:\n  pass 1: {}\n  pass 2: {}",
1804                        i + 1,
1805                        a,
1806                        b
1807                    );
1808                }
1809            }
1810            let c1 = ascii1.lines().count();
1811            let c2 = ascii2.lines().count();
1812            if c1 != c2 {
1813                panic!("ASCII self round-trip: line count differs ({c1} vs {c2})");
1814            }
1815        }
1816    }
1817
1818    #[test]
1819    fn self_roundtrip_minimal() {
1820        let input = "\
1821newmodel test
1822setsupermodel test NULL
1823classification other
1824classification_unk1 0
1825ignorefog 0
1826setanimationscale 1.0
1827compress_quaternions 0
1828headlink 0
1829beginmodelgeom test
1830  bmin -1.0 -1.0 -1.0
1831  bmax 1.0 1.0 1.0
1832  radius 1.73
1833  node dummy test
1834    parent NULL
1835  endnode
1836endmodelgeom test
1837donemodel test
1838";
1839        ascii_self_roundtrip(input);
1840    }
1841
1842    #[test]
1843    fn self_roundtrip_mesh_with_controllers() {
1844        let input = "\
1845newmodel m
1846setsupermodel m NULL
1847classification other
1848classification_unk1 0
1849ignorefog 0
1850setanimationscale 1.0
1851compress_quaternions 0
1852headlink 0
1853beginmodelgeom m
1854  bmin -1.0 -1.0 -1.0
1855  bmax 1.0 1.0 1.0
1856  radius 1.73
1857  node trimesh mesh1
1858    parent NULL
1859    diffuse 0.8 0.8 0.8
1860    ambient 0.2 0.2 0.2
1861    bitmap texture_a
1862    render 1
1863    shadow 0
1864    verts 3
1865      0.0 0.0 0.0
1866      1.0 0.0 0.0
1867      0.0 1.0 0.0
1868    faces 1
1869      0 1 2  1  0 1 2  0
1870    tverts 3
1871      0.0 0.0
1872      1.0 0.0
1873      0.0 1.0
1874  endnode
1875endmodelgeom m
1876donemodel m
1877";
1878        ascii_self_roundtrip(input);
1879    }
1880
1881    #[test]
1882    fn self_roundtrip_animation() {
1883        let input = "\
1884newmodel m
1885setsupermodel m NULL
1886classification character
1887classification_unk1 0
1888ignorefog 0
1889setanimationscale 1.0
1890compress_quaternions 0
1891headlink 0
1892beginmodelgeom m
1893  bmin -1.0 -1.0 -1.0
1894  bmax 1.0 1.0 1.0
1895  radius 1.73
1896  node dummy root
1897    parent NULL
1898    position 10.0 20.0 30.0
1899  endnode
1900endmodelgeom m
1901newanim walk m
1902  length 1.0
1903  transtime 0.25
1904  animroot root
1905  event 0.5 footstep
1906  node dummy root
1907    parent NULL
1908    positionkey
1909      0.0 10.0 20.0 30.0
1910      1.0 11.0 21.0 31.0
1911    endlist
1912  endnode
1913doneanim walk m
1914donemodel m
1915";
1916        ascii_self_roundtrip(input);
1917    }
1918
1919    // ---------------------------------------------------------------
1920    // Binary -> ASCII -> read back round-trip (vanilla models)
1921    // ---------------------------------------------------------------
1922
1923    /// Compare two node trees structurally (names, types, children, positions,
1924    /// faces, controllers). Ignores binary-only metadata.
1925    fn assert_nodes_equivalent(a: &super::super::MdlNode, b: &super::super::MdlNode, path: &str) {
1926        assert_eq!(a.name, b.name, "{path}: name mismatch");
1927        assert_eq!(
1928            std::mem::discriminant(&a.node_data),
1929            std::mem::discriminant(&b.node_data),
1930            "{path}: node type mismatch"
1931        );
1932        // Positions (allow small float rounding).
1933        for i in 0..3 {
1934            assert!(
1935                (a.position[i] - b.position[i]).abs() < 1e-4,
1936                "{path}: position[{i}] {:.6} vs {:.6}",
1937                a.position[i],
1938                b.position[i]
1939            );
1940        }
1941        // Rotation (wider tolerance: axis-angle round-trip loses precision
1942        // for near-identity orientations).
1943        for i in 0..4 {
1944            assert!(
1945                (a.rotation[i] - b.rotation[i]).abs() < 2e-3,
1946                "{path}: rotation[{i}] {:.6} vs {:.6}",
1947                a.rotation[i],
1948                b.rotation[i]
1949            );
1950        }
1951        // Controller comparison: the ASCII writer skips identity position/
1952        // orientation, so the roundtripped model may have fewer controllers.
1953        // Check that every controller in b exists in a with the same key count.
1954        for cb in &b.controllers {
1955            if let Some(ca) = a
1956                .controllers
1957                .iter()
1958                .find(|c| c.controller_type == cb.controller_type)
1959            {
1960                assert_eq!(
1961                    ca.keys.len(),
1962                    cb.keys.len(),
1963                    "{path}: controller {:?} key count ({} vs {})",
1964                    cb.controller_type,
1965                    ca.keys.len(),
1966                    cb.keys.len()
1967                );
1968            } else {
1969                panic!(
1970                    "{path}: roundtripped has controller {:?} not in original",
1971                    cb.controller_type
1972                );
1973            }
1974        }
1975        // Mesh fields.
1976        if let (Some(ma), Some(mb)) = (a.node_data.mesh(), b.node_data.mesh()) {
1977            assert_eq!(
1978                ma.positions.len(),
1979                mb.positions.len(),
1980                "{path}: vertex count"
1981            );
1982            assert_eq!(ma.faces.len(), mb.faces.len(), "{path}: face count");
1983            assert_eq!(ma.uv1.len(), mb.uv1.len(), "{path}: uv1 count");
1984        }
1985        // Children.
1986        assert_eq!(
1987            a.children.len(),
1988            b.children.len(),
1989            "{path}: child count ({} vs {})",
1990            a.children.len(),
1991            b.children.len()
1992        );
1993        for (ca, cb) in a.children.iter().zip(b.children.iter()) {
1994            assert_nodes_equivalent(ca, cb, &format!("{path}/{}", ca.name));
1995        }
1996    }
1997
1998    fn assert_anims_equivalent(a: &[super::super::MdlAnimation], b: &[super::super::MdlAnimation]) {
1999        assert_eq!(a.len(), b.len(), "animation count");
2000        for (i, (aa, ab)) in a.iter().zip(b.iter()).enumerate() {
2001            assert_eq!(aa.name, ab.name, "anim[{i}] name");
2002            assert!((aa.length - ab.length).abs() < 1e-4, "anim[{i}] length");
2003            assert_eq!(aa.anim_root, ab.anim_root, "anim[{i}] anim_root");
2004            assert_eq!(aa.events.len(), ab.events.len(), "anim[{i}] event count");
2005        }
2006    }
2007
2008    /// Returns the K1 Override directory from `KOTOR_GAME_DIR` env var,
2009    /// or None if not set (tests should skip).
2010    fn k1_override_dir() -> Option<String> {
2011        std::env::var("KOTOR_GAME_DIR")
2012            .ok()
2013            .map(|d| format!("{d}/Override"))
2014    }
2015
2016    /// Full binary -> ASCII -> read back round-trip for a vanilla model.
2017    fn binary_ascii_roundtrip(mdl_path: &str, mdx_path: Option<&str>) {
2018        let mdl_data = match std::fs::read(mdl_path) {
2019            Ok(d) => d,
2020            Err(_) => return,
2021        };
2022        let mdx_data = mdx_path.and_then(|p| std::fs::read(p).ok());
2023        let original =
2024            super::super::reader::read_mdl_from_bytes(&mdl_data, mdx_data.as_deref()).unwrap();
2025
2026        let ascii = write_mdl_ascii_to_string(&original).unwrap();
2027        let roundtripped = read_mdl_ascii_from_str(&ascii).unwrap();
2028
2029        // Header fields.
2030        assert_eq!(
2031            original.root_node.name, roundtripped.root_node.name,
2032            "model_name"
2033        );
2034        assert_eq!(
2035            original.supermodel_name, roundtripped.supermodel_name,
2036            "supermodel_name"
2037        );
2038        assert_eq!(
2039            original.classification, roundtripped.classification,
2040            "classification"
2041        );
2042        assert_eq!(
2043            original.affected_by_fog, roundtripped.affected_by_fog,
2044            "affected_by_fog"
2045        );
2046        assert!(
2047            (original.animation_scale - roundtripped.animation_scale).abs() < 1e-4,
2048            "animation_scale"
2049        );
2050        // node_count is derived differently (binary header value vs computed
2051        // from tree), so just sanity check it's reasonable.
2052        assert!(roundtripped.node_count > 0, "node_count should be > 0");
2053
2054        // Node tree.
2055        assert_nodes_equivalent(
2056            &original.root_node,
2057            &roundtripped.root_node,
2058            &original.root_node.name,
2059        );
2060
2061        // Animations.
2062        assert_anims_equivalent(&original.animations, &roundtripped.animations);
2063    }
2064
2065    #[test]
2066    fn roundtrip_vanilla_item() {
2067        // Item model with duplicate node names (root and child share same name).
2068        let base = match k1_override_dir() {
2069            Some(d) => d,
2070            None => return,
2071        };
2072        binary_ascii_roundtrip(
2073            &format!("{base}/i_adrnaline_001.mdl"),
2074            Some(&format!("{base}/i_adrnaline_001.mdx")),
2075        );
2076    }
2077
2078    #[test]
2079    fn roundtrip_vanilla_placeable() {
2080        // 3dgui.mdl -- simple placeable, no MDX needed.
2081        let base = match k1_override_dir() {
2082            Some(d) => d,
2083            None => return,
2084        };
2085        binary_ascii_roundtrip(&format!("{base}/3dgui.mdl"), None);
2086    }
2087
2088    #[test]
2089    fn roundtrip_vanilla_character() {
2090        // Character model with skins and animations.
2091        let base = match k1_override_dir() {
2092            Some(d) => d,
2093            None => return,
2094        };
2095        binary_ascii_roundtrip(
2096            &format!("{base}/p_bastilabb.mdl"),
2097            Some(&format!("{base}/p_bastilabb.mdx")),
2098        );
2099    }
2100
2101    #[test]
2102    fn roundtrip_vanilla_supermodel() {
2103        // Supermodel with many animations.
2104        let base = match k1_override_dir() {
2105            Some(d) => d,
2106            None => return,
2107        };
2108        binary_ascii_roundtrip(
2109            &format!("{base}/s_female03.mdl"),
2110            Some(&format!("{base}/s_female03.mdx")),
2111        );
2112    }
2113
2114    #[test]
2115    fn roundtrip_vanilla_effect() {
2116        // FX model with emitters/lights.
2117        let base = match k1_override_dir() {
2118            Some(d) => d,
2119            None => return,
2120        };
2121        binary_ascii_roundtrip(&format!("{base}/fx_carbref.mdl"), None);
2122    }
2123}