Skip to main content

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