rakata_formats/mdl/
ascii_writer.rs

1//! ASCII MDL writer.
2//!
3//! Serializes an [`Mdl`] model into the human-readable ASCII MDL format
4//! used by the KotOR engine's text-mode model parser. The output is
5//! compatible with mdledit, kotorblender, and the engine's native
6//! `ParseNode` / `InternalParseField` dispatch pipeline.
7//!
8//! The writer emits a deterministic representation: geometry header,
9//! recursive DFS node tree, then animations. Controller values are
10//! converted from binary quaternion to ASCII axis-angle for orientation
11//! data, and unknown controller codes get a `controller_<N>` fallback name.
12
13use std::collections::HashMap;
14use std::io::Write;
15
16use super::ascii_names::{
17    classification_to_ascii, controller_ascii_name, node_type_ascii_name, node_type_context,
18};
19use super::controllers::{MdlController, MdlControllerType, MdlKey, CTRL_FLAG_BEZIER};
20use super::orientation::quat_to_axis_angle;
21use super::types::{AabbNode, MdlNodeData};
22use super::{collect_geo_positions, Mdl, MdlAnimNode, MdlAnimation, MdlNode};
23
24/// Errors specific to ASCII MDL serialization and parsing.
25#[derive(Debug)]
26pub enum MdlAsciiError {
27    /// I/O error.
28    Io(std::io::Error),
29    /// Invalid data preventing serialization.
30    InvalidData(String),
31    /// Parse error at a specific line.
32    Parse {
33        /// 1-based line number in the source text.
34        line: usize,
35        /// Description of the parse failure.
36        message: String,
37    },
38}
39
40impl std::fmt::Display for MdlAsciiError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Io(e) => write!(f, "I/O error: {e}"),
44            Self::InvalidData(msg) => write!(f, "invalid data: {msg}"),
45            Self::Parse { line, message } => write!(f, "line {line}: {message}"),
46        }
47    }
48}
49
50impl std::error::Error for MdlAsciiError {
51    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
52        match self {
53            Self::Io(e) => Some(e),
54            Self::InvalidData(_) | Self::Parse { .. } => None,
55        }
56    }
57}
58
59impl From<std::io::Error> for MdlAsciiError {
60    fn from(e: std::io::Error) -> Self {
61        Self::Io(e)
62    }
63}
64
65/// Writes an ASCII MDL representation to a writer.
66///
67/// The output follows the engine's native ASCII MDL grammar and is compatible
68/// with mdledit, kotorblender, and the engine's own text-mode parser.
69#[cfg_attr(
70    feature = "tracing",
71    tracing::instrument(level = "debug", skip(w, mdl))
72)]
73pub fn write_mdl_ascii<W: Write>(w: &mut W, mdl: &Mdl) -> Result<(), MdlAsciiError> {
74    let name = &mdl.root_node.name;
75    let super_name = if mdl.supermodel_name.is_empty() {
76        "NULL"
77    } else {
78        &mdl.supermodel_name
79    };
80
81    // Model header
82    writeln!(w, "newmodel {name}")?;
83    writeln!(w, "setsupermodel {name} {super_name}")?;
84    writeln!(
85        w,
86        "classification {}",
87        classification_to_ascii(mdl.classification)
88    )?;
89    writeln!(w, "classification_unk1 {}", mdl.subclassification)?;
90    let ignore_fog = if mdl.affected_by_fog != 0 { 0 } else { 1 };
91    writeln!(w, "ignorefog {ignore_fog}")?;
92    writeln!(w, "setanimationscale {}", format_float(mdl.animation_scale))?;
93
94    // compress_quaternions: derived from whether any orientation controller
95    // uses compressed quaternion encoding (raw_column_count == 2).
96    let has_compressed_quats = has_compressed_quaternions(mdl);
97    writeln!(
98        w,
99        "compress_quaternions {}",
100        i32::from(has_compressed_quats)
101    )?;
102
103    // headlink: derived from whether the model has a separate animation root
104    // (typically neck_g for head models).
105    let is_headlinked = mdl.anim_root_node.is_some();
106    writeln!(w, "headlink {}", i32::from(is_headlinked))?;
107
108    // Geometry block
109    writeln!(w, "beginmodelgeom {name}")?;
110    writeln!(
111        w,
112        "  bmin {} {} {}",
113        format_float(mdl.bounding_box[0]),
114        format_float(mdl.bounding_box[1]),
115        format_float(mdl.bounding_box[2])
116    )?;
117    writeln!(
118        w,
119        "  bmax {} {} {}",
120        format_float(mdl.bounding_box[3]),
121        format_float(mdl.bounding_box[4]),
122        format_float(mdl.bounding_box[5])
123    )?;
124    writeln!(w, "  radius {}", format_float(mdl.radius))?;
125
126    // Recursive DFS node tree
127    write_node(w, &mdl.root_node, None, &mdl.root_node)?;
128
129    writeln!(w, "endmodelgeom {name}")?;
130
131    // Build geometry node position map for animation position offsetting.
132    // Binary animation position controllers store deltas from the geometry
133    // rest pose; ASCII format uses absolute positions. mdledit adds the
134    // geometry node's position to each animation keyframe value.
135    let geo_positions = collect_geo_positions(&mdl.root_node);
136
137    // Animations
138    for anim in &mdl.animations {
139        write_animation(w, anim, name, &geo_positions)?;
140    }
141
142    writeln!(w, "donemodel {name}")?;
143    Ok(())
144}
145
146/// Serializes an MDL to an ASCII string.
147#[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(mdl)))]
148pub fn write_mdl_ascii_to_string(mdl: &Mdl) -> Result<String, MdlAsciiError> {
149    let mut buf = Vec::new();
150    write_mdl_ascii(&mut buf, mdl)?;
151    String::from_utf8(buf)
152        .map_err(|e| MdlAsciiError::InvalidData(format!("output is not valid UTF-8: {e}")))
153}
154
155// ---------------------------------------------------------------------------
156// Node writing
157// ---------------------------------------------------------------------------
158
159/// Writes a geometry node and its children recursively.
160fn write_node<W: Write>(
161    w: &mut W,
162    node: &MdlNode,
163    parent_name: Option<&str>,
164    model_root: &MdlNode,
165) -> Result<(), MdlAsciiError> {
166    let type_str = node_type_ascii_name(&node.node_data);
167    let parent_str = parent_name.unwrap_or("NULL");
168
169    writeln!(w, "node {type_str} {}", node.name)?;
170    writeln!(w, "  parent {parent_str}")?;
171
172    // Check if position/orientation controllers exist -- if so, they'll be
173    // written as controller values below and we skip the header defaults.
174    let has_pos_ctrl = node
175        .controllers
176        .iter()
177        .any(|c| c.controller_type == MdlControllerType::POSITION);
178    let has_ori_ctrl = node
179        .controllers
180        .iter()
181        .any(|c| c.controller_type == MdlControllerType::ORIENTATION);
182
183    // Position (from header, unless overridden by controller).
184    // Skip identity values for the root node (matching mdledit behavior).
185    if !has_pos_ctrl {
186        let [px, py, pz] = node.position;
187        if px != 0.0 || py != 0.0 || pz != 0.0 {
188            writeln!(
189                w,
190                "  position {} {} {}",
191                format_float(px),
192                format_float(py),
193                format_float(pz)
194            )?;
195        }
196    }
197
198    // Orientation (from header, unless overridden by controller).
199    // Skip identity values (matching mdledit behavior).
200    if !has_ori_ctrl {
201        let aa = quat_to_axis_angle(node.rotation);
202        if aa[3] != 0.0 {
203            writeln!(
204                w,
205                "  orientation {} {} {} {}",
206                format_float(aa[0]),
207                format_float(aa[1]),
208                format_float(aa[2]),
209                format_float(aa[3])
210            )?;
211        }
212    }
213
214    // Controllers (written before type-specific fields, matching mdledit order).
215    // Single-key inline format appears as e.g. "position x y z", multi-key
216    // uses "positionkey" block format.
217    let ctx = node_type_context(&node.node_data);
218    for ctrl in &node.controllers {
219        write_controller(w, ctrl, ctx, "  ")?;
220    }
221
222    // Type-specific fields
223    write_node_data(w, &node.node_data, model_root)?;
224
225    writeln!(w, "endnode")?;
226
227    // Children follow after endnode (DFS preorder)
228    for child in &node.children {
229        write_node(w, child, Some(&node.name), model_root)?;
230    }
231
232    Ok(())
233}
234
235/// Writes type-specific node fields.
236fn write_node_data<W: Write>(
237    w: &mut W,
238    data: &MdlNodeData,
239    model_root: &MdlNode,
240) -> Result<(), MdlAsciiError> {
241    match data {
242        MdlNodeData::Base | MdlNodeData::Camera(_) => {}
243        MdlNodeData::Mesh(mesh) => write_mesh_fields(w, mesh)?,
244        MdlNodeData::Skin(skin) => {
245            write_mesh_fields(w, &skin.mesh)?;
246            write_skin_fields(w, skin, model_root)?;
247        }
248        MdlNodeData::AnimMesh(am) => {
249            write_mesh_fields(w, &am.mesh)?;
250            write_animmesh_fields(w, am)?;
251        }
252        MdlNodeData::Dangly(dangly) => {
253            write_mesh_fields(w, &dangly.mesh)?;
254            write_dangly_fields(w, dangly)?;
255        }
256        MdlNodeData::Aabb(aabb) => {
257            write_mesh_fields(w, &aabb.mesh)?;
258            write_aabb_fields(w, aabb)?;
259        }
260        MdlNodeData::Saber(saber) => {
261            write_mesh_fields(w, &saber.mesh)?;
262        }
263        MdlNodeData::Light(light) => write_light_fields(w, light)?,
264        MdlNodeData::Emitter(emitter) => write_emitter_fields(w, emitter)?,
265        MdlNodeData::Reference(reference) => write_reference_fields(w, reference)?,
266    }
267    Ok(())
268}
269
270// ---------------------------------------------------------------------------
271// Mesh fields
272// ---------------------------------------------------------------------------
273
274/// Writes mesh-specific fields (trimesh and all mesh subtypes).
275///
276/// Field ordering matches mdledit's asciiwrite.cpp for interoperability.
277fn write_mesh_fields<W: Write>(
278    w: &mut W,
279    mesh: &super::types::MdlMesh,
280) -> Result<(), MdlAsciiError> {
281    // Colors
282    writeln!(
283        w,
284        "  diffuse {} {} {}",
285        format_float(mesh.diffuse_color[0]),
286        format_float(mesh.diffuse_color[1]),
287        format_float(mesh.diffuse_color[2])
288    )?;
289    writeln!(
290        w,
291        "  ambient {} {} {}",
292        format_float(mesh.ambient_color[0]),
293        format_float(mesh.ambient_color[1]),
294        format_float(mesh.ambient_color[2])
295    )?;
296    writeln!(w, "  transparencyhint {}", mesh.transparency_hint)?;
297
298    // UV animation (always written, even when zero)
299    writeln!(w, "  animateuv {}", mesh.animate_uv)?;
300    writeln!(w, "  uvdirectionx {}", format_float(mesh.uv_direction_x))?;
301    writeln!(w, "  uvdirectiony {}", format_float(mesh.uv_direction_y))?;
302    writeln!(w, "  uvjitter {}", format_float(mesh.uv_jitter))?;
303    writeln!(w, "  uvjitterspeed {}", format_float(mesh.uv_jitter_speed))?;
304
305    // Boolean flags
306    writeln!(w, "  lightmapped {}", i32::from(mesh.light_mapped))?;
307    writeln!(w, "  rotatetexture {}", i32::from(mesh.rotate_texture))?;
308    writeln!(
309        w,
310        "  m_bIsBackgroundGeometry {}",
311        i32::from(mesh.is_background_geometry)
312    )?;
313    writeln!(w, "  shadow {}", i32::from(mesh.shadow))?;
314    writeln!(w, "  beaming {}", i32::from(mesh.beaming))?;
315    writeln!(w, "  render {}", i32::from(mesh.render))?;
316
317    // K1 defaults (not stored in binary, written for mdledit interop)
318    writeln!(w, "  dirt_enabled 0")?;
319    writeln!(w, "  dirt_texture 1")?;
320    writeln!(w, "  dirt_worldspace 1")?;
321    writeln!(w, "  hologram_donotdraw 0")?;
322
323    // Tangent space flag (derived from vertex data)
324    let has_tangent = !mesh.tangent_space.is_empty();
325    writeln!(w, "  tangentspace {}", i32::from(has_tangent))?;
326
327    // Inverted counter (mesh sequence value)
328    writeln!(w, "  inv_count {}", mesh.inverted_counter)?;
329
330    // Textures
331    if mesh.texture_0.is_empty() {
332        writeln!(w, "  bitmap NULL")?;
333    } else {
334        writeln!(w, "  bitmap {}", mesh.texture_0)?;
335    }
336    if !mesh.texture_1.is_empty() {
337        writeln!(w, "  bitmap2 {}", mesh.texture_1)?;
338    }
339
340    // Vertex positions
341    let vert_count = mesh.positions.len();
342    if vert_count > 0 {
343        writeln!(w, "  verts {vert_count}")?;
344        for pos in &mesh.positions {
345            writeln!(
346                w,
347                "    {} {} {}",
348                format_float(pos[0]),
349                format_float(pos[1]),
350                format_float(pos[2])
351            )?;
352        }
353    }
354
355    // Faces (written before tverts, matching mdledit order)
356    if !mesh.faces.is_empty() {
357        let has_uvs = !mesh.uv1.is_empty();
358        writeln!(w, "  faces {}", mesh.faces.len())?;
359        for face in &mesh.faces {
360            let [v0, v1, v2] = face.vertex_indices;
361            // Face format: v0 v1 v2 smoothgroup tv0 tv1 tv2 material
362            // Binary doesn't store smoothgroups; use 1 as default.
363            // TV indices mirror vertex indices when UVs present, else 0 0 0.
364            let (tv0, tv1, tv2) = if has_uvs { (v0, v1, v2) } else { (0, 0, 0) };
365            writeln!(
366                w,
367                "    {v0} {v1} {v2}  1  {tv0} {tv1} {tv2}  {}",
368                face.surface_id
369            )?;
370        }
371    }
372
373    // UV channels (written after faces, matching mdledit order)
374    write_tverts(w, &mesh.uv1, "tverts")?;
375    write_tverts(w, &mesh.uv2, "tverts1")?;
376    write_tverts(w, &mesh.uv3, "tverts2")?;
377    write_tverts(w, &mesh.uv4, "tverts3")?;
378
379    // Vertex colors
380    if !mesh.vertex_colors.is_empty() {
381        writeln!(w, "  colors {}", mesh.vertex_colors.len())?;
382        for c in &mesh.vertex_colors {
383            writeln!(
384                w,
385                "    {} {} {}",
386                format_float(f32::from(c[0]) / 255.0),
387                format_float(f32::from(c[1]) / 255.0),
388                format_float(f32::from(c[2]) / 255.0)
389            )?;
390        }
391    }
392
393    Ok(())
394}
395
396/// Writes a texture vertex array (2 values per line: u, v).
397fn write_tverts<W: Write>(w: &mut W, uvs: &[[f32; 2]], keyword: &str) -> Result<(), MdlAsciiError> {
398    if !uvs.is_empty() {
399        writeln!(w, "  {keyword} {}", uvs.len())?;
400        for uv in uvs {
401            writeln!(w, "    {} {}", format_float(uv[0]), format_float(uv[1]))?;
402        }
403    }
404    Ok(())
405}
406
407// ---------------------------------------------------------------------------
408// Skin fields
409// ---------------------------------------------------------------------------
410
411/// Writes skin-specific fields (bone weights).
412fn write_skin_fields<W: Write>(
413    w: &mut W,
414    skin: &super::types::MdlSkin,
415    model_root: &MdlNode,
416) -> Result<(), MdlAsciiError> {
417    let vert_count = skin.mesh.positions.len();
418    if vert_count == 0 || skin.bone_weights.is_empty() || skin.bone_indices.is_empty() {
419        return Ok(());
420    }
421
422    // Build MDX bone index -> node name mapping from the bonemap.
423    //
424    // The bonemap has one entry per node (in tree order). Each entry is a float
425    // (stored as u32 bits) giving the MDX bone index that maps to that tree
426    // position. -1.0 marks unused slots. So bonemap[tree_pos] = mdx_bone_idx.
427    // We need the reverse: mdx_bone_idx -> node_name[tree_pos].
428    let node_names = collect_node_names(model_root);
429    let mut bone_index_to_name: Vec<Option<String>> = Vec::new();
430    for (tree_pos, &raw) in skin.bonemap.iter().enumerate() {
431        let mdx_bone_idx_f = f32::from_bits(raw);
432        if mdx_bone_idx_f >= 0.0 {
433            // MDX bone indices are small non-negative integers stored as f32
434            // by engine convention. No safe f32->usize path exists in std.
435            #[allow(
436                clippy::cast_possible_truncation,
437                clippy::cast_sign_loss,
438                clippy::as_conversions
439            )]
440            let mdx_bone_idx = mdx_bone_idx_f as usize;
441            if mdx_bone_idx >= bone_index_to_name.len() {
442                bone_index_to_name.resize(mdx_bone_idx + 1, None);
443            }
444            bone_index_to_name[mdx_bone_idx] = node_names.get(tree_pos).cloned();
445        }
446    }
447
448    writeln!(w, "  weights {vert_count}")?;
449    for (weights, indices) in skin.bone_weights.iter().zip(&skin.bone_indices) {
450        let mut parts = Vec::new();
451        for j in 0..4 {
452            let weight = weights[j];
453            let bone_idx_f = indices[j];
454
455            if weight > 0.0 && bone_idx_f >= 0.0 {
456                // MDX bone indices are small non-negative f32 by engine convention.
457                // No safe f32->usize path exists in std.
458                #[allow(
459                    clippy::cast_possible_truncation,
460                    clippy::cast_sign_loss,
461                    clippy::as_conversions
462                )]
463                let bone_idx = bone_idx_f as usize;
464                let bone_name = bone_index_to_name
465                    .get(bone_idx)
466                    .and_then(|opt| opt.as_ref())
467                    .cloned()
468                    .unwrap_or_else(|| format!("bone_{bone_idx}"));
469                parts.push(format!("{bone_name} {}", format_float(weight)));
470            }
471        }
472
473        if parts.is_empty() {
474            writeln!(w, "    ")?;
475        } else {
476            writeln!(w, "    {}", parts.join(" "))?;
477        }
478    }
479
480    Ok(())
481}
482
483/// Returns true if any orientation controller in the model uses compressed
484/// quaternion encoding (raw_column_count == 2).
485fn has_compressed_quaternions(mdl: &Mdl) -> bool {
486    // Check geometry node controllers.
487    if node_has_compressed_quats(&mdl.root_node) {
488        return true;
489    }
490    // Check animation node controllers.
491    for anim in &mdl.animations {
492        if anim_node_has_compressed_quats(&anim.root_node) {
493            return true;
494        }
495    }
496    false
497}
498
499fn node_has_compressed_quats(node: &MdlNode) -> bool {
500    for ctrl in &node.controllers {
501        if ctrl.controller_type == MdlControllerType::ORIENTATION && ctrl.raw_column_count == 2 {
502            return true;
503        }
504    }
505    node.children.iter().any(node_has_compressed_quats)
506}
507
508fn anim_node_has_compressed_quats(node: &MdlAnimNode) -> bool {
509    for ctrl in &node.controllers {
510        if ctrl.controller_type == MdlControllerType::ORIENTATION && ctrl.raw_column_count == 2 {
511            return true;
512        }
513    }
514    node.children.iter().any(anim_node_has_compressed_quats)
515}
516
517/// Collects all node names in DFS order from a geometry node tree.
518fn collect_node_names(node: &MdlNode) -> Vec<String> {
519    let mut names = Vec::new();
520    collect_names_recursive(node, &mut names);
521    names
522}
523
524fn collect_names_recursive(node: &MdlNode, names: &mut Vec<String>) {
525    names.push(node.name.clone());
526    for child in &node.children {
527        collect_names_recursive(child, names);
528    }
529}
530
531// ---------------------------------------------------------------------------
532// AnimMesh fields
533// ---------------------------------------------------------------------------
534
535/// Writes animmesh-specific fields.
536fn write_animmesh_fields<W: Write>(
537    w: &mut W,
538    am: &super::types::MdlAnimMesh,
539) -> Result<(), MdlAsciiError> {
540    writeln!(w, "  sampleperiod {}", format_float(am.sample_period))?;
541
542    if !am.anim_verts.is_empty() {
543        writeln!(w, "  animverts {}", am.anim_verts.len())?;
544        for v in &am.anim_verts {
545            writeln!(
546                w,
547                "    {} {} {}",
548                format_float(v[0]),
549                format_float(v[1]),
550                format_float(v[2])
551            )?;
552        }
553    }
554
555    if !am.anim_t_verts.is_empty() {
556        writeln!(w, "  animtverts {}", am.anim_t_verts.len())?;
557        for v in &am.anim_t_verts {
558            writeln!(
559                w,
560                "    {} {} {}",
561                format_float(v[0]),
562                format_float(v[1]),
563                format_float(v[2])
564            )?;
565        }
566    }
567
568    Ok(())
569}
570
571// ---------------------------------------------------------------------------
572// Dangly fields
573// ---------------------------------------------------------------------------
574
575/// Writes dangly-specific fields.
576fn write_dangly_fields<W: Write>(
577    w: &mut W,
578    dangly: &super::types::MdlDangly,
579) -> Result<(), MdlAsciiError> {
580    writeln!(w, "  displacement {}", format_float(dangly.displacement))?;
581    writeln!(w, "  tightness {}", format_float(dangly.tightness))?;
582    writeln!(w, "  period {}", format_float(dangly.period))?;
583
584    if !dangly.constraints.is_empty() {
585        writeln!(w, "  constraints {}", dangly.constraints.len())?;
586        for c in &dangly.constraints {
587            writeln!(w, "    {}", format_float(*c))?;
588        }
589    }
590
591    Ok(())
592}
593
594// ---------------------------------------------------------------------------
595// AABB fields
596// ---------------------------------------------------------------------------
597
598/// Writes AABB walkmesh-specific fields.
599fn write_aabb_fields<W: Write>(
600    w: &mut W,
601    aabb: &super::types::MdlAabb,
602) -> Result<(), MdlAsciiError> {
603    if let Some(tree) = &aabb.aabb_tree {
604        // Collect leaf entries in DFS preorder
605        let mut leaves = Vec::new();
606        collect_aabb_leaves(tree, &mut leaves);
607
608        if !leaves.is_empty() {
609            writeln!(w, "  aabb")?;
610            for leaf in &leaves {
611                writeln!(
612                    w,
613                    "    {} {} {} {} {} {} {}",
614                    format_float(leaf.box_min[0]),
615                    format_float(leaf.box_min[1]),
616                    format_float(leaf.box_min[2]),
617                    format_float(leaf.box_max[0]),
618                    format_float(leaf.box_max[1]),
619                    format_float(leaf.box_max[2]),
620                    leaf.face_index
621                )?;
622            }
623        }
624    }
625    Ok(())
626}
627
628/// Collects AABB leaf entries in DFS preorder.
629fn collect_aabb_leaves<'a>(node: &'a AabbNode, leaves: &mut Vec<&'a AabbNode>) {
630    if node.face_index >= 0 {
631        // Leaf node
632        leaves.push(node);
633    }
634    if let Some(left) = &node.left {
635        collect_aabb_leaves(left, leaves);
636    }
637    if let Some(right) = &node.right {
638        collect_aabb_leaves(right, leaves);
639    }
640}
641
642// ---------------------------------------------------------------------------
643// Light fields
644// ---------------------------------------------------------------------------
645
646/// Writes light-specific fields.
647fn write_light_fields<W: Write>(
648    w: &mut W,
649    light: &super::types::MdlLight,
650) -> Result<(), MdlAsciiError> {
651    writeln!(w, "  lightpriority {}", light.priority)?;
652    writeln!(w, "  ambientonly {}", light.ambientonly)?;
653    writeln!(w, "  ndynamictype {}", light.num_dynamic_types)?;
654    writeln!(w, "  affectdynamic {}", light.affectdynamic)?;
655    writeln!(w, "  shadow {}", light.shadow)?;
656    writeln!(w, "  generateflare {}", light.generateflare)?;
657    writeln!(w, "  fadingLight {}", light.fading_light)?;
658    writeln!(w, "  flareradius {}", format_float(light.flare_radius))?;
659
660    // Flare data
661    let flare_count = light.flare_sizes.len();
662    if flare_count > 0 {
663        writeln!(w, "  lensflares {flare_count}")?;
664    }
665
666    if !light.flare_texture_names.is_empty() {
667        writeln!(w, "  texturenames {}", light.flare_texture_names.len())?;
668        for name in &light.flare_texture_names {
669            writeln!(w, "    {name}")?;
670        }
671    }
672
673    if !light.flare_positions.is_empty() {
674        writeln!(w, "  flarepositions {}", light.flare_positions.len())?;
675        for p in &light.flare_positions {
676            writeln!(w, "    {}", format_float(*p))?;
677        }
678    }
679
680    if !light.flare_sizes.is_empty() {
681        writeln!(w, "  flaresizes {}", light.flare_sizes.len())?;
682        for s in &light.flare_sizes {
683            writeln!(w, "    {}", format_float(*s))?;
684        }
685    }
686
687    if !light.flare_color_shifts.is_empty() {
688        writeln!(w, "  flarecolorshifts {}", light.flare_color_shifts.len())?;
689        for c in &light.flare_color_shifts {
690            writeln!(
691                w,
692                "    {} {} {}",
693                format_float(c[0]),
694                format_float(c[1]),
695                format_float(c[2])
696            )?;
697        }
698    }
699
700    Ok(())
701}
702
703// ---------------------------------------------------------------------------
704// Emitter fields
705// ---------------------------------------------------------------------------
706
707/// Writes emitter-specific fields.
708fn write_emitter_fields<W: Write>(
709    w: &mut W,
710    e: &super::types::MdlEmitter,
711) -> Result<(), MdlAsciiError> {
712    writeln!(w, "  deadspace {}", format_float(e.deadspace))?;
713    writeln!(w, "  blastRadius {}", format_float(e.blast_radius))?;
714    writeln!(w, "  blastLength {}", format_float(e.blast_length))?;
715    writeln!(w, "  numBranches {}", e.num_branches)?;
716    writeln!(w, "  controlptsmoothing {}", e.control_pt_smoothing)?;
717    writeln!(w, "  xgrid {}", e.x_grid)?;
718    writeln!(w, "  ygrid {}", e.y_grid)?;
719    writeln!(w, "  spawntype {}", e.spawn_type)?;
720
721    if !e.update.is_empty() {
722        writeln!(w, "  update {}", e.update)?;
723    }
724    if !e.render.is_empty() {
725        writeln!(w, "  render {}", e.render)?;
726    }
727    if !e.blend.is_empty() {
728        writeln!(w, "  blend {}", e.blend)?;
729    }
730    if !e.texture.is_empty() {
731        writeln!(w, "  texture {}", e.texture)?;
732    }
733    if !e.chunk_name.is_empty() {
734        writeln!(w, "  chunkName {}", e.chunk_name)?;
735    }
736
737    writeln!(w, "  twosidedtex {}", e.two_sided_tex)?;
738    writeln!(w, "  loop {}", e.loop_emitter)?;
739    writeln!(w, "  renderorder {}", e.render_order)?;
740    writeln!(w, "  m_bFrameBlending {}", i32::from(e.frame_blending))?;
741
742    if !e.depth_texture_name.is_empty() {
743        writeln!(w, "  m_sDepthTextureName {}", e.depth_texture_name)?;
744    }
745
746    Ok(())
747}
748
749// ---------------------------------------------------------------------------
750// Reference fields
751// ---------------------------------------------------------------------------
752
753/// Writes reference-specific fields.
754fn write_reference_fields<W: Write>(
755    w: &mut W,
756    r: &super::types::MdlReference,
757) -> Result<(), MdlAsciiError> {
758    writeln!(w, "  refModel {}", r.ref_model)?;
759    writeln!(w, "  reattachable {}", r.reattachable)?;
760    Ok(())
761}
762
763// ---------------------------------------------------------------------------
764// Controller writing
765// ---------------------------------------------------------------------------
766
767/// Writes a controller as either inline (single key) or keyed block.
768fn write_controller<W: Write>(
769    w: &mut W,
770    ctrl: &MdlController,
771    ctx: super::ascii_names::NodeTypeContext,
772    indent: &str,
773) -> Result<(), MdlAsciiError> {
774    let name = controller_name(ctrl.controller_type, ctx);
775    let is_bezier = (ctrl.raw_column_count & CTRL_FLAG_BEZIER) != 0;
776    let is_orientation = ctrl.controller_type == MdlControllerType::ORIENTATION;
777    let is_compressed = is_orientation && ctrl.raw_column_count == 2;
778
779    if ctrl.keys.len() == 1 && ctrl.keys[0].time == 0.0 {
780        // Single-key inline format
781        write!(w, "{indent}{name} ")?;
782        write_key_values(w, &ctrl.keys[0], is_orientation, is_compressed)?;
783        writeln!(w)?;
784    } else {
785        // Multi-key block format
786        let suffix = if is_bezier { "bezierkey" } else { "key" };
787        writeln!(w, "{indent}{name}{suffix}")?;
788        for key in &ctrl.keys {
789            write!(w, "{indent}  {} ", format_float(key.time))?;
790            write_key_values(w, key, is_orientation, is_compressed)?;
791            writeln!(w)?;
792        }
793        writeln!(w, "{indent}endlist")?;
794    }
795
796    Ok(())
797}
798
799/// Writes the value portion of a keyframe.
800fn write_key_values<W: Write>(
801    w: &mut W,
802    key: &MdlKey,
803    is_orientation: bool,
804    is_compressed: bool,
805) -> Result<(), MdlAsciiError> {
806    if is_orientation && !is_compressed && key.values.len() >= 4 {
807        // Controller data stores quaternion as [x,y,z,w] (binary layout),
808        // but quat_to_axis_angle expects [w,x,y,z]. Reorder.
809        let q = [key.values[3], key.values[0], key.values[1], key.values[2]];
810        let aa = quat_to_axis_angle(q);
811        write!(
812            w,
813            "{} {} {} {}",
814            format_float(aa[0]),
815            format_float(aa[1]),
816            format_float(aa[2]),
817            format_float(aa[3])
818        )?;
819    } else {
820        // Generic float output
821        let formatted: Vec<String> = key.values.iter().map(|v| format_float(*v)).collect();
822        write!(w, "{}", formatted.join(" "))?;
823    }
824    Ok(())
825}
826
827/// Returns the ASCII name for a controller type, with fallback.
828fn controller_name(code: MdlControllerType, ctx: super::ascii_names::NodeTypeContext) -> String {
829    controller_ascii_name(code, ctx)
830        .map(String::from)
831        .unwrap_or_else(|| format!("controller_{}", code.raw()))
832}
833
834// ---------------------------------------------------------------------------
835// Animation writing
836// ---------------------------------------------------------------------------
837
838/// Writes an animation block.
839fn write_animation<W: Write>(
840    w: &mut W,
841    anim: &MdlAnimation,
842    model_name: &str,
843    geo_positions: &HashMap<&str, [f32; 3]>,
844) -> Result<(), MdlAsciiError> {
845    writeln!(w, "newanim {} {model_name}", anim.name)?;
846    writeln!(w, "  length {}", format_float(anim.length))?;
847    writeln!(w, "  transtime {}", format_float(anim.transition_time))?;
848    writeln!(w, "  animroot {}", anim.anim_root)?;
849
850    // Events
851    for event in &anim.events {
852        writeln!(w, "  event {} {}", format_float(event.time), event.name)?;
853    }
854
855    // Animation node tree
856    write_anim_node(w, &anim.root_node, None, geo_positions)?;
857
858    writeln!(w, "doneanim {} {model_name}", anim.name)?;
859    Ok(())
860}
861
862/// Writes an animation node and its children recursively.
863fn write_anim_node<W: Write>(
864    w: &mut W,
865    node: &MdlAnimNode,
866    parent_name: Option<&str>,
867    geo_positions: &HashMap<&str, [f32; 3]>,
868) -> Result<(), MdlAsciiError> {
869    let parent_str = parent_name.unwrap_or("NULL");
870    writeln!(w, "    node dummy {}", node.name)?;
871    writeln!(w, "      parent {parent_str}")?;
872
873    // Look up the corresponding geometry node's rest position for this
874    // animation node. Position controller values in binary are deltas
875    // from this rest pose.
876    let geo_pos = geo_positions
877        .get(node.name.as_str())
878        .copied()
879        .unwrap_or([0.0, 0.0, 0.0]);
880
881    // Animation node controllers are always keyed (multi-key block)
882    // Use Base context since anim nodes are type-agnostic.
883    // However, we need to check all contexts for name resolution since
884    // animation nodes can carry light/emitter/mesh controllers.
885    for ctrl in &node.controllers {
886        write_anim_controller(w, ctrl, geo_pos)?;
887    }
888
889    writeln!(w, "    endnode")?;
890
891    for child in &node.children {
892        write_anim_node(w, child, Some(&node.name), geo_positions)?;
893    }
894
895    Ok(())
896}
897
898/// Writes an animation controller (always keyed format).
899///
900/// Position controllers have `geo_pos` added to each keyframe value,
901/// converting binary deltas to the absolute positions used in ASCII.
902fn write_anim_controller<W: Write>(
903    w: &mut W,
904    ctrl: &MdlController,
905    geo_pos: [f32; 3],
906) -> Result<(), MdlAsciiError> {
907    // Try all contexts for name resolution (anim nodes can carry any controller type)
908    let name = controller_ascii_name(
909        ctrl.controller_type,
910        super::ascii_names::NodeTypeContext::Base,
911    )
912    .or_else(|| {
913        controller_ascii_name(
914            ctrl.controller_type,
915            super::ascii_names::NodeTypeContext::Mesh,
916        )
917    })
918    .or_else(|| {
919        controller_ascii_name(
920            ctrl.controller_type,
921            super::ascii_names::NodeTypeContext::Light,
922        )
923    })
924    .or_else(|| {
925        controller_ascii_name(
926            ctrl.controller_type,
927            super::ascii_names::NodeTypeContext::Emitter,
928        )
929    })
930    .map(String::from)
931    .unwrap_or_else(|| format!("controller_{}", ctrl.controller_type.raw()));
932
933    let is_bezier = (ctrl.raw_column_count & CTRL_FLAG_BEZIER) != 0;
934    let is_orientation = ctrl.controller_type == MdlControllerType::ORIENTATION;
935    let is_position = ctrl.controller_type == MdlControllerType::POSITION;
936    let is_compressed = is_orientation && ctrl.raw_column_count == 2;
937    let suffix = if is_bezier { "bezierkey" } else { "key" };
938
939    writeln!(w, "      {name}{suffix}")?;
940    for key in &ctrl.keys {
941        write!(w, "        {} ", format_float(key.time))?;
942        if is_position && key.values.len() >= 3 {
943            // Add geometry rest position to convert delta -> absolute.
944            // For bezier keys, only offset the first 3 values (the position);
945            // control points (values 3..8) are written as-is.
946            let mut offset_key = key.clone();
947            offset_key.values[0] += geo_pos[0];
948            offset_key.values[1] += geo_pos[1];
949            offset_key.values[2] += geo_pos[2];
950            write_key_values(w, &offset_key, false, false)?;
951        } else {
952            write_key_values(w, key, is_orientation, is_compressed)?;
953        }
954        writeln!(w)?;
955    }
956    writeln!(w, "      endlist")?;
957
958    Ok(())
959}
960
961// ---------------------------------------------------------------------------
962// Float formatting
963// ---------------------------------------------------------------------------
964
965/// Formats an f32 for ASCII MDL output, matching mdledit conventions.
966///
967/// - Integers always get a `.0` suffix (e.g., `1.0`, `0.0`, `-5.0`)
968/// - Small values (|v| < 0.0001) use scientific notation (e.g., `7.84e-06`)
969/// - Trailing zeros are trimmed but at least one decimal digit is kept
970/// - Negative zero is normalized to `0.0`
971fn format_float(v: f32) -> String {
972    if v.is_nan() {
973        return "0.0".into();
974    }
975    if v.is_infinite() {
976        return if v > 0.0 {
977            "3.4028235e+38".into()
978        } else {
979            "-3.4028235e+38".into()
980        };
981    }
982    if v == 0.0 {
983        // Avoid "-0.0"
984        return "0.0".into();
985    }
986
987    let abs = v.abs();
988
989    // Small values get scientific notation (matches C %g behavior: exp < -4).
990    if abs < 1e-4 && abs > 0.0 {
991        // Use Rust's built-in {:e} and reformat. Manual log10/powi fails for
992        // subnormals (10^-39 underflows to 0, producing inf mantissa).
993        let raw = format!("{v:e}");
994        if let Some(e_pos) = raw.find('e') {
995            let mantissa = &raw[..e_pos];
996            let exp: i32 = raw[e_pos + 1..]
997                .parse()
998                .expect("Rust {:e} formatting always produces a valid exponent");
999            let trimmed_m = trim_trailing_zeros_keep_one(mantissa);
1000            return format!("{trimmed_m}e{exp:+03}");
1001        }
1002        return raw;
1003    }
1004
1005    let s = v.to_string();
1006
1007    // Rust's Display may output scientific notation for edge cases;
1008    // normalize to our format.
1009    if let Some(e_pos) = s.find('e') {
1010        let mantissa = &s[..e_pos];
1011        let exp_str = &s[e_pos + 1..];
1012        let exp: i32 = exp_str
1013            .parse()
1014            .expect("Rust Display formatting always produces a valid exponent");
1015        let trimmed_m = trim_trailing_zeros_keep_one(mantissa);
1016        return format!("{trimmed_m}e{exp:+03}");
1017    }
1018
1019    trim_trailing_zeros_keep_one(&s)
1020}
1021
1022/// Trims trailing zeros from a decimal string, keeping at least one digit
1023/// after the decimal point. If no decimal point, appends `.0`.
1024fn trim_trailing_zeros_keep_one(s: &str) -> String {
1025    if !s.contains('.') {
1026        // Integer -- add .0
1027        return format!("{s}.0");
1028    }
1029    let trimmed = s.trim_end_matches('0');
1030    if trimmed.ends_with('.') {
1031        // e.g., "5." -> "5.0"
1032        format!("{trimmed}0")
1033    } else {
1034        trimmed.into()
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    #[test]
1043    fn format_float_integers() {
1044        assert_eq!(format_float(0.0), "0.0");
1045        assert_eq!(format_float(1.0), "1.0");
1046        assert_eq!(format_float(-1.0), "-1.0");
1047        assert_eq!(format_float(42.0), "42.0");
1048    }
1049
1050    #[test]
1051    fn format_float_negative_zero() {
1052        assert_eq!(format_float(-0.0), "0.0");
1053    }
1054
1055    #[test]
1056    fn format_float_decimals() {
1057        assert_eq!(format_float(0.5), "0.5");
1058        assert_eq!(format_float(1.5), "1.5");
1059        assert_eq!(format_float(-2.75), "-2.75");
1060    }
1061
1062    #[test]
1063    fn format_float_no_trailing_zeros() {
1064        // 0.25 should not produce "0.250000..."
1065        let s = format_float(0.25);
1066        // Should be "0.25" -- no unnecessary trailing zeros
1067        assert_eq!(s, "0.25");
1068    }
1069
1070    #[test]
1071    fn format_float_scientific_notation() {
1072        // Small values should use scientific notation
1073        let v: f32 = 7.84e-06;
1074        let s = format_float(v);
1075        assert!(s.contains("e-"), "expected scientific notation: {s}");
1076    }
1077
1078    #[test]
1079    fn minimal_model_roundtrip() {
1080        // Build a minimal model with just a root dummy node.
1081        let mdl = Mdl {
1082            root_node: MdlNode {
1083                name: "test_model".into(),
1084                parent_index: None,
1085                position: [0.0, 0.0, 0.0],
1086                rotation: [1.0, 0.0, 0.0, 0.0],
1087                node_data: MdlNodeData::Base,
1088                controllers: Vec::new(),
1089                children: Vec::new(),
1090                orphan_controller_data: Vec::new(),
1091                header_padding_02: [0, 0],
1092                header_padding_06: [0, 0],
1093            },
1094            geometry_fn_ptr1: 0,
1095            geometry_fn_ptr2: 0,
1096            model_type: 0,
1097            classification: 0,
1098            subclassification: 0,
1099            affected_by_fog: 1,
1100            supermodel_name: String::new(),
1101            node_count: 1,
1102            bounding_box: [0.0; 6],
1103            radius: 0.0,
1104            animation_scale: 1.0,
1105            animations: Vec::new(),
1106            anim_root_node: None,
1107        };
1108
1109        let ascii = write_mdl_ascii_to_string(&mdl).unwrap();
1110        assert!(ascii.contains("newmodel test_model"));
1111        assert!(ascii.contains("setsupermodel test_model NULL"));
1112        assert!(ascii.contains("classification other"));
1113        assert!(ascii.contains("node dummy test_model"));
1114        assert!(ascii.contains("parent NULL"));
1115        // Identity orientation and zero position are omitted (matching mdledit).
1116        assert!(!ascii.contains("orientation 0.0 0.0 0.0 0.0"));
1117        assert!(!ascii.contains("position 0.0 0.0 0.0"));
1118        assert!(ascii.contains("endnode"));
1119        assert!(ascii.contains("endmodelgeom test_model"));
1120        assert!(ascii.contains("donemodel test_model"));
1121    }
1122
1123    /// Returns the K1 Override directory from `KOTOR_GAME_DIR` env var,
1124    /// or None if not set (tests should skip).
1125    fn k1_override_dir() -> Option<String> {
1126        std::env::var("KOTOR_GAME_DIR")
1127            .ok()
1128            .map(|d| format!("{d}/Override"))
1129    }
1130
1131    #[test]
1132    fn smoke_test_vanilla_model() {
1133        // Try to read a vanilla binary MDL and write it as ASCII.
1134        // Skip if KOTOR_GAME_DIR isn't set.
1135        let base = match k1_override_dir() {
1136            Some(d) => d,
1137            None => return,
1138        };
1139        let path = format!("{base}/3dgui.mdl");
1140        let data = match std::fs::read(&path) {
1141            Ok(d) => d,
1142            Err(_) => return,
1143        };
1144        let mdl = super::super::reader::read_mdl_from_bytes(&data, None).unwrap();
1145        let ascii = write_mdl_ascii_to_string(&mdl).unwrap();
1146
1147        // Basic structure checks
1148        assert!(ascii.starts_with("newmodel "));
1149        assert!(ascii.contains("beginmodelgeom"));
1150        assert!(ascii.contains("endmodelgeom"));
1151        assert!(ascii.contains("donemodel"));
1152
1153        // Should have nodes
1154        let node_count =
1155            ascii.matches("\nnode ").count() + if ascii.starts_with("node ") { 1 } else { 0 };
1156        assert!(node_count > 0, "expected at least one node");
1157
1158        eprintln!(
1159            "3dgui.mdl: {} bytes ASCII, {} nodes",
1160            ascii.len(),
1161            node_count
1162        );
1163    }
1164
1165    #[test]
1166    fn smoke_test_character_model() {
1167        // Character model with skins, animations.
1168        let base = match k1_override_dir() {
1169            Some(d) => d,
1170            None => return,
1171        };
1172        let mdl_path = format!("{base}/p_bastilabb.mdl");
1173        let mdx_path = format!("{base}/p_bastilabb.mdx");
1174        let mdl_data = match std::fs::read(&mdl_path) {
1175            Ok(d) => d,
1176            Err(_) => return,
1177        };
1178        let mdx_data = std::fs::read(&mdx_path).ok();
1179        let mdl =
1180            super::super::reader::read_mdl_from_bytes(&mdl_data, mdx_data.as_deref()).unwrap();
1181        let ascii = write_mdl_ascii_to_string(&mdl).unwrap();
1182
1183        assert!(ascii.contains("newmodel"));
1184        assert!(ascii.contains("donemodel"));
1185
1186        // Should have skin nodes with weights
1187        let has_skin = ascii.contains("node skin ");
1188        let has_weights = ascii.contains("weights ");
1189        let has_anim = ascii.contains("newanim ");
1190
1191        eprintln!(
1192            "p_bastilabb.mdl: {} bytes ASCII, skin={has_skin}, weights={has_weights}, anims={has_anim}",
1193            ascii.len()
1194        );
1195    }
1196
1197    #[test]
1198    fn smoke_test_animated_model() {
1199        // Supermodel with animations.
1200        let base = match k1_override_dir() {
1201            Some(d) => d,
1202            None => return,
1203        };
1204        let mdl_path = format!("{base}/s_female03.mdl");
1205        let mdx_path = format!("{base}/s_female03.mdx");
1206        let mdl_data = match std::fs::read(&mdl_path) {
1207            Ok(d) => d,
1208            Err(_) => return,
1209        };
1210        let mdx_data = std::fs::read(&mdx_path).ok();
1211        let mdl =
1212            super::super::reader::read_mdl_from_bytes(&mdl_data, mdx_data.as_deref()).unwrap();
1213        let ascii = write_mdl_ascii_to_string(&mdl).unwrap();
1214
1215        assert!(ascii.contains("newmodel"));
1216        assert!(ascii.contains("donemodel"));
1217        assert!(ascii.contains("newanim "));
1218        assert!(ascii.contains("doneanim "));
1219
1220        let anim_count = ascii.matches("newanim ").count();
1221        eprintln!(
1222            "s_female03.mdl: {} bytes ASCII, {} animations",
1223            ascii.len(),
1224            anim_count
1225        );
1226    }
1227
1228    #[test]
1229    fn smoke_test_effect_model() {
1230        // FX model with emitter nodes.
1231        let base = match k1_override_dir() {
1232            Some(d) => d,
1233            None => return,
1234        };
1235        let mdl_path = format!("{base}/fx_carbref.mdl");
1236        let mdl_data = match std::fs::read(&mdl_path) {
1237            Ok(d) => d,
1238            Err(_) => return,
1239        };
1240        let mdl = super::super::reader::read_mdl_from_bytes(&mdl_data, None).unwrap();
1241        let ascii = write_mdl_ascii_to_string(&mdl).unwrap();
1242
1243        assert!(ascii.contains("newmodel"));
1244        assert!(ascii.contains("donemodel"));
1245
1246        let has_emitter = ascii.contains("node emitter ");
1247        let has_light = ascii.contains("node light ");
1248        eprintln!(
1249            "fx_carbref.mdl: {} bytes ASCII, emitter={has_emitter}, light={has_light}",
1250            ascii.len()
1251        );
1252
1253        // Print first emitter node if present
1254        if let Some(pos) = ascii.find("node emitter ") {
1255            let snippet = &ascii[pos..ascii.len().min(pos + 800)];
1256            for line in snippet.lines().take(30) {
1257                eprintln!("  {line}");
1258            }
1259        }
1260    }
1261}