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