Skip to main content

roxlap_formats/
character.rs

1//! `.rkc` rigged-character container — the on-disk form of a complete
2//! animated voxel character (meshes + skeleton + clips).
3//!
4//! Where [`kfa`](crate::kfa) stores only a skeleton, one clip, and a
5//! *single* kv6 filename, this container stores a whole character: N
6//! KV6 meshes, the hinge skeleton, any number of named animation clips,
7//! and (v3) embedded animated voxel clips. Each bone carries a **list of
8//! attachments** — static meshes and/or animated clips, each with its own
9//! local offset — so e.g. a flame can hang off a hand. It is the build
10//! target of the **demiurg** editor and
11//! the load source for **monada** at runtime; both sides go through
12//! [`parse`](crate::character::parse) / [`serialize`](crate::character::serialize),
13//! and [`Character::to_kfa_sprite`](crate::character::Character::to_kfa_sprite) turns a
14//! parsed character into a renderable [`KfaSprite`](crate::kfa::KfaSprite).
15//!
16//! # Layout
17//!
18//! Little-endian throughout (matching [`kv6`](crate::kv6) /
19//! [`kfa`](crate::kfa)). A fixed header, then a flat list of **chunks**;
20//! a reader indexes by `tag` and skips unknown tags via `len`. This chunk
21//! list is the whole forward-compatibility story — any future content
22//! type is a new tag (top-level chunk), a new clip `kind`, or a new
23//! `mesh_kind`.
24//!
25//! ```text
26//! File:
27//!     magic   [u8;4]   = b"RKCH"
28//!     version u16      = 3
29//!     chunks  [Chunk]  until EOF
30//!
31//! Chunk:
32//!     tag     [u8;4]
33//!     len     u32              # payload byte length
34//!     payload [u8; len]
35//! ```
36//!
37//! The canonical writer emits `META`, `MSHS`, `BONS`, `CLPS`, `VCLP` in
38//! that order, followed by any preserved unknown chunks; a reader must
39//! accept any order and ignore unknown tags. See the module-level handoff doc
40//! (`docs/handoff-character-container.md`) for the full byte spec and the
41//! forward-compat rationale.
42
43use core::fmt;
44
45use crate::bytes::{Cursor, OutOfBounds};
46use crate::kfa::{Hinge, Kfa, KfaSprite, Point3, Seq};
47use crate::kv6::{self, Kv6};
48use crate::sprite::Sprite;
49use crate::voxel_clip::{self, VoxelClip};
50use crate::xform::{BoneXform, Quat};
51
52const MAGIC: [u8; 4] = *b"RKCH";
53// v3 (VCL.5): a bone carries a *list* of attachments (each a static KV6
54// mesh or an animated voxel clip, with a local offset + playback), and the
55// character gains a `VCLP` chunk of embedded clips. v2 (single mesh per
56// bone) and v1 (i16 frmval) files are rejected — regenerate them.
57const VERSION: u16 = 3;
58
59const TAG_META: [u8; 4] = *b"META";
60const TAG_MSHS: [u8; 4] = *b"MSHS";
61const TAG_BONS: [u8; 4] = *b"BONS";
62const TAG_CLPS: [u8; 4] = *b"CLPS";
63/// Embedded animated voxel clips (VCL.5), referenced by [`MeshRef::Clip`].
64const TAG_VCLP: [u8; 4] = *b"VCLP";
65
66const HINGE_SIZE: usize = 64;
67
68/// `mesh_kind` discriminant for a static KV6 mesh (index into `MSHS`).
69const MESH_KIND_STATIC: u16 = 0;
70/// `mesh_kind` discriminant for an animated voxel clip (index into
71/// [`Character::voxel_clips`]) — VCL.5.
72const MESH_KIND_CLIP: u16 = 1;
73
74/// `kind` discriminant for a skeletal animation clip. Future clip types
75/// (e.g. voxel-video) get their own value; an old reader keeps them as
76/// [`ClipData::Unknown`].
77const CLIP_KIND_SKELETAL: u16 = 0;
78
79/// A parsed rigged-character container.
80///
81/// Index conventions that the byte layout depends on:
82/// - `meshes` is indexed by `mesh_id` ([`MeshRef::Static`]).
83/// - `bones` index *is* the canonical bone index — the column index of
84///   every clip's `frmval` and of [`KfaSprite::kfaval`], and the target
85///   of every [`Hinge::parent`].
86#[derive(Debug, Clone)]
87pub struct Character {
88    /// Human-facing name (`META`). May be empty.
89    pub name: String,
90    /// World placement passed to [`KfaSprite::new`] (`META`).
91    pub root: [f32; 3],
92    /// Bone meshes (`MSHS`), indexed by `mesh_id`.
93    pub meshes: Vec<Kv6>,
94    /// The skeleton (`BONS`); index = canonical bone index.
95    pub bones: Vec<Bone>,
96    /// Named animation clips (`CLPS`). May be empty (a posable rig with
97    /// no baked animation).
98    pub clips: Vec<Clip>,
99    /// Embedded animated voxel clips (`VCLP`, VCL.5), indexed by
100    /// [`MeshRef::Clip`]. May be empty.
101    pub voxel_clips: Vec<VoxelClip>,
102    /// Unknown top-level chunks preserved verbatim so re-saving with an
103    /// older build doesn't strip newer data. Re-emitted after the known
104    /// chunks in encounter order. Empty for canonically-written files.
105    pub extra_chunks: Vec<([u8; 4], Vec<u8>)>,
106}
107
108/// One bone: a name, a list of attachments, and its hinge (VCL.5). A bone
109/// may carry several attachments (static meshes and/or animated clips),
110/// each positioned by its own `local_offset` relative to the bone — so a
111/// flame can hang off a hand alongside the hand mesh. v2 had exactly one
112/// mesh per bone; that is now a single [`MeshRef::Static`] attachment at
113/// the identity offset.
114#[derive(Debug, Clone)]
115pub struct Bone {
116    /// Human-facing bone name (e.g. `"hand_l"`). May be empty; not
117    /// required to be unique.
118    pub name: String,
119    /// What this bone draws (see [`Attachment`]). May be empty (a pure
120    /// transform bone).
121    pub attachments: Vec<Attachment>,
122    /// Packed hinge, reused from [`kfa`](crate::kfa). `hinge.parent`
123    /// references bone indices in [`Character::bones`]; `-1` = root.
124    pub hinge: Hinge,
125}
126
127/// One thing a bone draws: a mesh/clip reference, a local offset placing
128/// it on the bone, and (for clips) playback parameters (VCL.5).
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct Attachment {
131    /// The drawn source — a static KV6 mesh or an animated voxel clip.
132    pub target: MeshRef,
133    /// Transform applied **on top of** the bone's solved world transform,
134    /// positioning/orienting/scaling this attachment relative to the bone.
135    /// Identity = sit at the bone origin.
136    pub local_offset: BoneXform,
137    /// Playback parameters; ignored for a [`MeshRef::Static`] target.
138    pub playback: ClipPlayback,
139}
140
141impl Attachment {
142    /// A static mesh attachment at the identity offset — the v2-equivalent
143    /// "one mesh per bone".
144    #[must_use]
145    pub fn static_mesh(mesh_id: usize) -> Self {
146        Self {
147            target: MeshRef::Static(mesh_id),
148            local_offset: BoneXform::IDENTITY,
149            playback: ClipPlayback::default(),
150        }
151    }
152
153    /// An animated-clip attachment at the identity offset, default playback.
154    #[must_use]
155    pub fn clip(clip_id: usize) -> Self {
156        Self {
157            target: MeshRef::Clip(clip_id),
158            local_offset: BoneXform::IDENTITY,
159            playback: ClipPlayback::default(),
160        }
161    }
162}
163
164/// Playback parameters for a [`MeshRef::Clip`] attachment. The clip's own
165/// [`LoopMode`](crate::voxel_clip::LoopMode) governs looping; these only
166/// control rate + phase, so the same clip can run fast on one attachment
167/// and slow/offset on another.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct ClipPlayback {
170    /// Playback speed as Q8 fixed point (`256` = 1.0×). Drives how fast the
171    /// clip clock advances.
172    pub speed_q8: i32,
173    /// Initial clock offset (ms) so several instances of one clip don't
174    /// play in lockstep.
175    pub start_phase_ms: u32,
176}
177
178impl Default for ClipPlayback {
179    fn default() -> Self {
180        Self {
181            speed_q8: 256,
182            start_phase_ms: 0,
183        }
184    }
185}
186
187/// Typed reference to what a bone attachment draws. The on-disk
188/// `mesh_kind` discriminant selects the variant.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum MeshRef {
191    /// `mesh_kind 0` — index into [`Character::meshes`].
192    Static(usize),
193    /// `mesh_kind 1` — index into [`Character::voxel_clips`] (VCL.5).
194    Clip(usize),
195}
196
197/// One named animation clip.
198#[derive(Debug, Clone)]
199pub struct Clip {
200    /// Clip name the host plays it by (e.g. `"walk"`, `"idle"`). May
201    /// be empty; not required to be unique.
202    pub name: String,
203    /// The keyframe payload (or an unknown-kind blob kept for
204    /// round-trip) — see [`ClipData`].
205    pub data: ClipData,
206}
207
208/// A clip's payload, discriminated by the on-disk `kind`.
209#[derive(Debug, Clone, PartialEq)]
210pub enum ClipData {
211    /// `kind 0` — the `frmval` + `seq` pair. `frmval[frame][bone]` is the
212    /// bone's local [`BoneXform`] (translation, quaternion rotation, scale);
213    /// the inner length equals [`Character::bones`]`.len()`. `seq` matches
214    /// [`Kfa::seq`](crate::kfa::Kfa::seq).
215    Skeletal {
216        /// Keyframe table: `frmval[frame][bone]` local TRS; every inner
217        /// row has exactly `Character::bones.len()` entries.
218        frmval: Vec<Vec<BoneXform>>,
219        /// Playback sequence — `(tim, frm)` entries, same semantics as
220        /// [`Kfa::seq`](crate::kfa::Kfa::seq).
221        seq: Vec<Seq>,
222    },
223    /// A clip `kind` this build doesn't model — preserved verbatim so it
224    /// survives a load/save cycle.
225    Unknown {
226        /// The on-disk `kind` discriminant that wasn't recognised.
227        kind: u16,
228        /// The clip's raw payload bytes, kept for byte-exact re-save.
229        bytes: Vec<u8>,
230    },
231}
232
233/// Errors returned by [`parse`].
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum ParseError {
236    /// First 4 bytes are not the `b"RKCH"` magic.
237    BadMagic {
238        /// The 4 bytes actually found.
239        got: [u8; 4],
240    },
241    /// `version` field is not one this build understands.
242    UnsupportedVersion(u16),
243    /// A sequential read ran past EOF.
244    Truncated {
245        /// Byte offset of the failed read.
246        at: usize,
247        /// Number of bytes the read required.
248        need: usize,
249    },
250    /// A bone references a `mesh_kind` this build can't render. Hard
251    /// error — you can't render what you can't decode.
252    UnsupportedMeshKind(u16),
253    /// A `Skeletal` clip's `numhin` does not equal `bones.len()`.
254    ClipBoneCountMismatch,
255    /// A required chunk (`META` / `MSHS` / `BONS`) was absent.
256    MissingChunk([u8; 4]),
257    /// An embedded `MSHS` mesh blob failed to parse as a KV6.
258    BadMesh(kv6::ParseError),
259    /// An embedded `VCLP` clip blob failed to parse as a voxel clip (VCL.5).
260    BadClip(voxel_clip::ParseError),
261    /// QE.6b - an attachment references a mesh / voxel-clip index past
262    /// the corresponding table. Pre-QE.6 this surfaced as a documented
263    /// (but wrong: "parse keeps indices in range") panic later, in
264    /// `to_kfa_sprite` / the attachment runtime.
265    BadAttachmentIndex {
266        /// The owning bone.
267        bone: usize,
268        /// The out-of-range index.
269        index: usize,
270        /// The referenced table's length (meshes or voxel clips).
271        table_len: usize,
272    },
273    /// QE.6b - a bone hinge whose `parent` is neither `-1` nor a valid
274    /// bone index, or whose parent links form a cycle (previously an
275    /// OOB panic / infinite loop in the skeleton solve).
276    BadSkeleton {
277        /// The offending bone.
278        bone: usize,
279    },
280}
281
282impl fmt::Display for ParseError {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::BadMagic { got } => {
286                write!(
287                    f,
288                    "character bad magic: got {got:02x?}, expected {MAGIC:02x?}"
289                )
290            }
291            Self::UnsupportedVersion(v) => {
292                write!(
293                    f,
294                    "character unsupported version {v} (this build reads {VERSION})"
295                )
296            }
297            Self::Truncated { at, need } => {
298                write!(f, "character truncated: need {need} bytes at offset {at}")
299            }
300            Self::UnsupportedMeshKind(k) => {
301                write!(f, "character bone references unsupported mesh_kind {k}")
302            }
303            Self::ClipBoneCountMismatch => {
304                write!(f, "character skeletal clip numhin != bones.len()")
305            }
306            Self::MissingChunk(tag) => {
307                write!(
308                    f,
309                    "character missing required chunk {:?}",
310                    String::from_utf8_lossy(tag)
311                )
312            }
313            Self::BadMesh(e) => write!(f, "character embedded kv6 mesh: {e}"),
314            Self::BadClip(e) => write!(f, "character embedded voxel clip: {e:?}"),
315            Self::BadAttachmentIndex {
316                bone,
317                index,
318                table_len,
319            } => write!(
320                f,
321                "character bone {bone}: attachment index {index} past table of {table_len}"
322            ),
323            Self::BadSkeleton { bone } => {
324                write!(f, "character bone {bone}: parent out of range or cyclic")
325            }
326        }
327    }
328}
329
330impl std::error::Error for ParseError {}
331
332impl From<OutOfBounds> for ParseError {
333    fn from(e: OutOfBounds) -> Self {
334        Self::Truncated {
335            at: e.at,
336            need: e.need,
337        }
338    }
339}
340
341/// Parse an `.rkc` file's bytes into a [`Character`].
342///
343/// Accepts chunks in any order and skips unknown top-level tags (kept in
344/// [`Character::extra_chunks`]). `META` / `MSHS` / `BONS` are required.
345///
346/// # Errors
347///
348/// Returns [`ParseError`] on a bad magic / unsupported version, a read
349/// past EOF, an unsupported `mesh_kind`, a skeletal clip whose bone count
350/// disagrees with `BONS`, a missing required chunk, or a malformed
351/// embedded KV6 mesh. Note: `Hinge::htype` is *not* validated here — the
352/// renderer only implements `htype == 0` (others are treated as no
353/// rotation), so non-zero values load but won't animate.
354pub fn parse(bytes: &[u8]) -> Result<Character, ParseError> {
355    let mut cur = Cursor::new(bytes);
356    let magic = cur.read_bytes(4)?;
357    if magic != MAGIC {
358        return Err(ParseError::BadMagic {
359            got: [magic[0], magic[1], magic[2], magic[3]],
360        });
361    }
362    let version = cur.read_u16()?;
363    if version != VERSION {
364        return Err(ParseError::UnsupportedVersion(version));
365    }
366
367    // Pass 1: split the chunk list. Keep the raw payload of each known
368    // chunk and stash unknown chunks for verbatim re-emit. Last
369    // occurrence of a known tag wins (the canonical writer emits each
370    // once).
371    let mut meta: Option<&[u8]> = None;
372    let mut mshs: Option<&[u8]> = None;
373    let mut bons: Option<&[u8]> = None;
374    let mut clps: Option<&[u8]> = None;
375    let mut vclp: Option<&[u8]> = None;
376    let mut extra_chunks = Vec::new();
377    while cur.remaining() > 0 {
378        let tag_buf = cur.read_bytes(4)?;
379        let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
380        let len = cur.read_u32()? as usize;
381        let payload = cur.read_bytes(len)?;
382        match tag {
383            TAG_META => meta = Some(payload),
384            TAG_MSHS => mshs = Some(payload),
385            TAG_BONS => bons = Some(payload),
386            TAG_CLPS => clps = Some(payload),
387            TAG_VCLP => vclp = Some(payload),
388            _ => extra_chunks.push((tag, payload.to_vec())),
389        }
390    }
391
392    let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
393    let mshs = mshs.ok_or(ParseError::MissingChunk(TAG_MSHS))?;
394    let bons = bons.ok_or(ParseError::MissingChunk(TAG_BONS))?;
395
396    let (name, root) = parse_meta(meta)?;
397    let meshes = parse_mshs(mshs)?;
398    let bones = parse_bons(bons)?;
399    let clips = match clps {
400        Some(p) => parse_clps(p, bones.len())?,
401        None => Vec::new(),
402    };
403    let voxel_clips = match vclp {
404        Some(p) => parse_vclp(p)?,
405        None => Vec::new(),
406    };
407
408    // QE.6b - cross-reference validation, so the doc contract
409    // "`Character` values from `parse` keep indices in range" is
410    // actually true (pre-QE.6 it was claimed but never checked:
411    // `to_kfa_sprite` / the attachment runtime panicked instead).
412    for (bi, bone) in bones.iter().enumerate() {
413        for att in &bone.attachments {
414            let (index, table_len) = match att.target {
415                MeshRef::Static(i) => (i, meshes.len()),
416                MeshRef::Clip(i) => (i, voxel_clips.len()),
417            };
418            if index >= table_len {
419                return Err(ParseError::BadAttachmentIndex {
420                    bone: bi,
421                    index,
422                    table_len,
423                });
424            }
425        }
426        // Parent range; cycles checked below over the whole set.
427        if bone.hinge.parent != -1
428            && usize::try_from(bone.hinge.parent).map_or(true, |p| p >= bones.len())
429        {
430            return Err(ParseError::BadSkeleton { bone: bi });
431        }
432    }
433    for start in 0..bones.len() {
434        let mut cur_parent = bones[start].hinge.parent;
435        let mut hops = 0usize;
436        while cur_parent != -1 {
437            hops += 1;
438            if hops > bones.len() {
439                return Err(ParseError::BadSkeleton { bone: start });
440            }
441            cur_parent = bones[usize::try_from(cur_parent).expect("validated non-negative")]
442                .hinge
443                .parent;
444        }
445    }
446
447    Ok(Character {
448        name,
449        root,
450        meshes,
451        bones,
452        clips,
453        voxel_clips,
454        extra_chunks,
455    })
456}
457
458/// Serialise a [`Character`] back to bytes. Round-trips byte-equally
459/// with the canonical output that produced this `Character` via
460/// [`parse`].
461///
462/// # Panics
463///
464/// Panics if any count (mesh / bone / clip / frame / seq) or byte length
465/// does not fit in a `u32`, or if a `Skeletal` clip's `frmval` is not
466/// rectangular with row length `bones.len()`. `Character` values produced
467/// by [`parse`] always satisfy these.
468#[must_use]
469pub fn serialize(c: &Character) -> Vec<u8> {
470    let mut out = Vec::new();
471    out.extend_from_slice(&MAGIC);
472    out.extend_from_slice(&VERSION.to_le_bytes());
473
474    write_chunk(&mut out, TAG_META, |b| write_meta(b, c));
475    write_chunk(&mut out, TAG_MSHS, |b| write_mshs(b, c));
476    write_chunk(&mut out, TAG_BONS, |b| write_bons(b, c));
477    write_chunk(&mut out, TAG_CLPS, |b| write_clps(b, c));
478    write_chunk(&mut out, TAG_VCLP, |b| write_vclp(b, c));
479
480    for (tag, payload) in &c.extra_chunks {
481        write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
482    }
483
484    out
485}
486
487impl Character {
488    /// Build a renderable [`KfaSprite`]. `clip` selects a `Skeletal`
489    /// clip to bake in via [`KfaSprite::set_animation`]; `None` leaves
490    /// the sprite in its rest pose for the host to drive `kfaval`
491    /// directly. A non-`Skeletal` (`Unknown`) clip selection is ignored
492    /// (no animation attached).
493    ///
494    /// Meshes are cloned per call. Editors that re-pose every frame
495    /// should build the sprite once and reuse it.
496    ///
497    /// # Panics
498    ///
499    /// Panics if `clip` is out of range, or if a bone's `MeshRef::Static`
500    /// index is out of range for [`Character::meshes`]. `Character`
501    /// values from [`parse`] keep mesh indices in range.
502    #[must_use]
503    pub fn to_kfa_sprite(&self, clip: Option<usize>) -> KfaSprite {
504        // The hinge solver needs exactly one limb per bone, so this legacy
505        // path draws each bone's **first `Static` attachment** (the
506        // v2-equivalent single mesh), at the bone's transform. Extra
507        // attachments, per-attachment `local_offset`s, and `Clip`
508        // attachments are handled by the richer attachment runtime (VCL.6),
509        // not by `KfaSprite`. A bone with no static mesh gets an empty limb
510        // (draws nothing) to keep the 1:1 bone↔limb mapping.
511        let limbs = self
512            .bones
513            .iter()
514            .map(|b| {
515                let kv6 = b
516                    .attachments
517                    .iter()
518                    .find_map(|a| match a.target {
519                        MeshRef::Static(i) => Some(self.meshes[i].clone()),
520                        MeshRef::Clip(_) => None,
521                    })
522                    .unwrap_or_else(|| Kv6::from_fn(1, 1, 1, |_, _, _| None));
523                Sprite::axis_aligned(kv6, self.root)
524            })
525            .collect();
526        let hinges = self.bones.iter().map(|b| b.hinge).collect();
527        let mut k = KfaSprite::new(limbs, hinges, self.root);
528        if let Some(ci) = clip {
529            if let ClipData::Skeletal { frmval, seq } = &self.clips[ci].data {
530                // Clip frames are already per-bone TRS — hand them straight to
531                // the poser.
532                k.set_animation(frmval.clone(), seq.clone());
533            }
534        }
535        k
536    }
537
538    /// Export a **lossy** voxlap-toolchain [`Kfa`] (`.kfa`): the skeleton
539    /// plus one clip, referencing a single kv6 by filename.
540    ///
541    /// voxlap's `.kfa` is fundamentally narrower than this container — it
542    /// stores the hinge skeleton and one animation, but points at just
543    /// *one* kv6 file (voxlap rigs a single mesh with a per-voxel limb
544    /// index, which roxlap deliberately doesn't model). So this export
545    /// drops, by design:
546    /// - every embedded [`Character::meshes`] mesh — only `kv6_name` (the
547    ///   filename voxlap should load) is written. Export the bone meshes
548    ///   separately via [`kv6::serialize`] if a tool needs them.
549    /// - every clip except `clip`.
550    ///
551    /// `clip` selects the [`ClipData::Skeletal`] clip whose `frmval` /
552    /// `seq` to bake in. `None`, an out-of-range index, or a
553    /// non-`Skeletal` clip yields an empty animation table (a posable rig
554    /// with no baked motion). Serialise the result with
555    /// [`kfa::serialize`](crate::kfa::serialize).
556    #[must_use]
557    pub fn to_kfa(&self, clip: Option<usize>, kv6_name: impl Into<Vec<u8>>) -> Kfa {
558        let hinges = self.bones.iter().map(|b| b.hinge).collect();
559        // The `.kfa` file stores one Q15 angle per bone; collapse each TRS to
560        // its rotation about the bone's hinge axis (translation / scale /
561        // off-axis rotation are dropped — this export is documented as lossy).
562        let (frmval, seq) = match clip.and_then(|ci| self.clips.get(ci)) {
563            Some(Clip {
564                data: ClipData::Skeletal { frmval, seq },
565                ..
566            }) => {
567                let angles = frmval
568                    .iter()
569                    .map(|row| {
570                        row.iter()
571                            .enumerate()
572                            .map(|(bone, x)| {
573                                let v = self.bones[bone].hinge.v[0];
574                                x.hinge_angle([v.x, v.y, v.z])
575                            })
576                            .collect()
577                    })
578                    .collect();
579                (angles, seq.clone())
580            }
581            _ => (Vec::new(), Vec::new()),
582        };
583        Kfa {
584            kv6_name: kv6_name.into(),
585            hinges,
586            frmval,
587            seq,
588        }
589    }
590}
591
592// --- chunk payload readers ----------------------------------------------
593
594fn parse_meta(payload: &[u8]) -> Result<(String, [f32; 3]), ParseError> {
595    let mut cur = Cursor::new(payload);
596    let name_len = cur.read_u16()? as usize;
597    let name = String::from_utf8_lossy(cur.read_bytes(name_len)?).into_owned();
598    let root = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
599    Ok((name, root))
600}
601
602fn parse_mshs(payload: &[u8]) -> Result<Vec<Kv6>, ParseError> {
603    let mut cur = Cursor::new(payload);
604    let count = cur.read_u32()? as usize;
605    // QE.6b - capacity clamped by remaining bytes (>= 4 B length per
606    // mesh blob); a lying count fails as Truncated, not an alloc bomb.
607    let mut meshes = Vec::with_capacity(cur.clamped_capacity(count, 4));
608    for _ in 0..count {
609        let blob_len = cur.read_u32()? as usize;
610        let blob = cur.read_bytes(blob_len)?;
611        meshes.push(kv6::parse(blob).map_err(ParseError::BadMesh)?);
612    }
613    Ok(meshes)
614}
615
616fn parse_bons(payload: &[u8]) -> Result<Vec<Bone>, ParseError> {
617    let mut cur = Cursor::new(payload);
618    let count = cur.read_u32()? as usize;
619    // QE.6b - >= 70 B per bone (name len + attach count + 64 B hinge).
620    let mut bones = Vec::with_capacity(cur.clamped_capacity(count, 70));
621    for _ in 0..count {
622        let name_len = cur.read_u16()? as usize;
623        let name = String::from_utf8_lossy(cur.read_bytes(name_len)?).into_owned();
624        let attach_count = cur.read_u32()? as usize;
625        // QE.6b - an attachment record is 54 bytes.
626        let mut attachments = Vec::with_capacity(cur.clamped_capacity(attach_count, 54));
627        for _ in 0..attach_count {
628            attachments.push(read_attachment(&mut cur)?);
629        }
630        let hinge = read_hinge(&mut cur)?;
631        bones.push(Bone {
632            name,
633            attachments,
634            hinge,
635        });
636    }
637    Ok(bones)
638}
639
640/// One attachment: `mesh_kind u16`, `index u32`, `local_offset` (40-byte
641/// `BoneXform`), then playback (`speed_q8 i32`, `start_phase_ms u32`).
642fn read_attachment(cur: &mut Cursor<'_>) -> Result<Attachment, ParseError> {
643    let mesh_kind = cur.read_u16()?;
644    let index = cur.read_u32()? as usize;
645    let target = match mesh_kind {
646        MESH_KIND_STATIC => MeshRef::Static(index),
647        MESH_KIND_CLIP => MeshRef::Clip(index),
648        other => return Err(ParseError::UnsupportedMeshKind(other)),
649    };
650    let local_offset = read_bonexform(cur)?;
651    let speed_q8 = cur.read_i32()?;
652    let start_phase_ms = cur.read_u32()?;
653    Ok(Attachment {
654        target,
655        local_offset,
656        playback: ClipPlayback {
657            speed_q8,
658            start_phase_ms,
659        },
660    })
661}
662
663fn parse_vclp(payload: &[u8]) -> Result<Vec<VoxelClip>, ParseError> {
664    let mut cur = Cursor::new(payload);
665    let count = cur.read_u32()? as usize;
666    // QE.6b - >= 4 B length prefix per clip blob.
667    let mut clips = Vec::with_capacity(cur.clamped_capacity(count, 4));
668    for _ in 0..count {
669        let blob_len = cur.read_u32()? as usize;
670        let blob = cur.read_bytes(blob_len)?;
671        clips.push(VoxelClip::parse(blob).map_err(ParseError::BadClip)?);
672    }
673    Ok(clips)
674}
675
676fn parse_clps(payload: &[u8], numbone: usize) -> Result<Vec<Clip>, ParseError> {
677    let mut cur = Cursor::new(payload);
678    let count = cur.read_u32()? as usize;
679    // QE.6b - >= 8 B per named clip record.
680    let mut clips = Vec::with_capacity(cur.clamped_capacity(count, 8));
681    for _ in 0..count {
682        let name_len = cur.read_u16()? as usize;
683        let name = String::from_utf8_lossy(cur.read_bytes(name_len)?).into_owned();
684        let kind = cur.read_u16()?;
685        let payload_len = cur.read_u32()? as usize;
686        let body = cur.read_bytes(payload_len)?;
687        let data = if kind == CLIP_KIND_SKELETAL {
688            parse_skeletal(body, numbone)?
689        } else {
690            ClipData::Unknown {
691                kind,
692                bytes: body.to_vec(),
693            }
694        };
695        clips.push(Clip { name, data });
696    }
697    Ok(clips)
698}
699
700fn parse_skeletal(body: &[u8], numbone: usize) -> Result<ClipData, ParseError> {
701    let mut cur = Cursor::new(body);
702    let numfrm = cur.read_u32()? as usize;
703    let numhin = cur.read_u32()? as usize;
704    if numhin != numbone {
705        return Err(ParseError::ClipBoneCountMismatch);
706    }
707    // QE.6b - a BoneXform row is 40 B per bone.
708    let mut frmval = Vec::with_capacity(cur.clamped_capacity(numfrm, numhin.saturating_mul(40)));
709    for _ in 0..numfrm {
710        let mut row = Vec::with_capacity(cur.clamped_capacity(numhin, 40));
711        for _ in 0..numhin {
712            row.push(read_bonexform(&mut cur)?);
713        }
714        frmval.push(row);
715    }
716    let seqcount = cur.read_u32()? as usize;
717    let mut seq = Vec::with_capacity(cur.clamped_capacity(seqcount, 8));
718    for _ in 0..seqcount {
719        let tim = cur.read_i32()?;
720        let frm = cur.read_i32()?;
721        seq.push(Seq { tim, frm });
722    }
723    Ok(ClipData::Skeletal { frmval, seq })
724}
725
726fn read_hinge(cur: &mut Cursor<'_>) -> Result<Hinge, OutOfBounds> {
727    let parent = cur.read_i32()?;
728    let p0 = read_point3(cur)?;
729    let p1 = read_point3(cur)?;
730    let v0 = read_point3(cur)?;
731    let v1 = read_point3(cur)?;
732    let vmin = cur.read_i16()?;
733    let vmax = cur.read_i16()?;
734    let htype = cur.read_u8()?;
735    let filler_buf = cur.read_bytes(7)?;
736    let mut filler = [0u8; 7];
737    filler.copy_from_slice(filler_buf);
738    Ok(Hinge {
739        parent,
740        p: [p0, p1],
741        v: [v0, v1],
742        vmin,
743        vmax,
744        htype,
745        filler,
746    })
747}
748
749fn read_point3(cur: &mut Cursor<'_>) -> Result<Point3, OutOfBounds> {
750    Ok(Point3 {
751        x: cur.read_f32()?,
752        y: cur.read_f32()?,
753        z: cur.read_f32()?,
754    })
755}
756
757/// One per-bone keyframe transform: translation (3), rotation quaternion
758/// (x, y, z, w), scale (3) — ten little-endian `f32`s, 40 bytes.
759fn read_bonexform(cur: &mut Cursor<'_>) -> Result<BoneXform, OutOfBounds> {
760    let t = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
761    let r = Quat {
762        x: cur.read_f32()?,
763        y: cur.read_f32()?,
764        z: cur.read_f32()?,
765        w: cur.read_f32()?,
766    };
767    let s = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
768    Ok(BoneXform { t, r, s })
769}
770
771fn write_bonexform(out: &mut Vec<u8>, x: &BoneXform) {
772    for v in [
773        x.t[0], x.t[1], x.t[2], x.r.x, x.r.y, x.r.z, x.r.w, x.s[0], x.s[1], x.s[2],
774    ] {
775        out.extend_from_slice(&v.to_le_bytes());
776    }
777}
778
779// --- chunk payload writers ----------------------------------------------
780
781/// Emit `tag` + a `u32` length + the payload produced by `body`.
782fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
783    out.extend_from_slice(&tag);
784    let len_pos = out.len();
785    out.extend_from_slice(&0u32.to_le_bytes()); // placeholder
786    let start = out.len();
787    body(out);
788    let len = u32::try_from(out.len() - start).expect("chunk payload length must fit in u32");
789    out[len_pos..len_pos + 4].copy_from_slice(&len.to_le_bytes());
790}
791
792fn write_meta(out: &mut Vec<u8>, c: &Character) {
793    let name = c.name.as_bytes();
794    let name_len = u16::try_from(name.len()).expect("character name length must fit in u16");
795    out.extend_from_slice(&name_len.to_le_bytes());
796    out.extend_from_slice(name);
797    for v in c.root {
798        out.extend_from_slice(&v.to_le_bytes());
799    }
800}
801
802fn write_mshs(out: &mut Vec<u8>, c: &Character) {
803    let count = u32::try_from(c.meshes.len()).expect("mesh count must fit in u32");
804    out.extend_from_slice(&count.to_le_bytes());
805    for mesh in &c.meshes {
806        let blob = kv6::serialize(mesh);
807        let blob_len = u32::try_from(blob.len()).expect("kv6 blob length must fit in u32");
808        out.extend_from_slice(&blob_len.to_le_bytes());
809        out.extend_from_slice(&blob);
810    }
811}
812
813fn write_bons(out: &mut Vec<u8>, c: &Character) {
814    let count = u32::try_from(c.bones.len()).expect("bone count must fit in u32");
815    out.extend_from_slice(&count.to_le_bytes());
816    for bone in &c.bones {
817        let name = bone.name.as_bytes();
818        let name_len = u16::try_from(name.len()).expect("bone name length must fit in u16");
819        out.extend_from_slice(&name_len.to_le_bytes());
820        out.extend_from_slice(name);
821        let attach_count =
822            u32::try_from(bone.attachments.len()).expect("attachment count must fit in u32");
823        out.extend_from_slice(&attach_count.to_le_bytes());
824        for a in &bone.attachments {
825            write_attachment(out, a);
826        }
827        write_hinge(out, &bone.hinge);
828    }
829}
830
831fn write_attachment(out: &mut Vec<u8>, a: &Attachment) {
832    let (kind, index) = match a.target {
833        MeshRef::Static(i) => (MESH_KIND_STATIC, i),
834        MeshRef::Clip(i) => (MESH_KIND_CLIP, i),
835    };
836    out.extend_from_slice(&kind.to_le_bytes());
837    let idx = u32::try_from(index).expect("attachment index must fit in u32");
838    out.extend_from_slice(&idx.to_le_bytes());
839    write_bonexform(out, &a.local_offset);
840    out.extend_from_slice(&a.playback.speed_q8.to_le_bytes());
841    out.extend_from_slice(&a.playback.start_phase_ms.to_le_bytes());
842}
843
844fn write_vclp(out: &mut Vec<u8>, c: &Character) {
845    let count = u32::try_from(c.voxel_clips.len()).expect("voxel clip count must fit in u32");
846    out.extend_from_slice(&count.to_le_bytes());
847    for clip in &c.voxel_clips {
848        let blob = clip.serialize();
849        let blob_len = u32::try_from(blob.len()).expect("voxel clip blob length must fit in u32");
850        out.extend_from_slice(&blob_len.to_le_bytes());
851        out.extend_from_slice(&blob);
852    }
853}
854
855fn write_clps(out: &mut Vec<u8>, c: &Character) {
856    let count = u32::try_from(c.clips.len()).expect("clip count must fit in u32");
857    out.extend_from_slice(&count.to_le_bytes());
858    for clip in &c.clips {
859        let name = clip.name.as_bytes();
860        let name_len = u16::try_from(name.len()).expect("clip name length must fit in u16");
861        out.extend_from_slice(&name_len.to_le_bytes());
862        out.extend_from_slice(name);
863        match &clip.data {
864            ClipData::Skeletal { frmval, seq } => {
865                out.extend_from_slice(&CLIP_KIND_SKELETAL.to_le_bytes());
866                write_chunk_body(out, |b| write_skeletal(b, frmval, seq));
867            }
868            ClipData::Unknown { kind, bytes } => {
869                out.extend_from_slice(&kind.to_le_bytes());
870                write_chunk_body(out, |b| b.extend_from_slice(bytes));
871            }
872        }
873    }
874}
875
876/// Emit a `u32` length + the payload produced by `body` (the clip
877/// `payload_len` + `payload` pair). Shares the back-patch trick with
878/// [`write_chunk`] but without a leading tag.
879fn write_chunk_body(out: &mut Vec<u8>, body: impl FnOnce(&mut Vec<u8>)) {
880    let len_pos = out.len();
881    out.extend_from_slice(&0u32.to_le_bytes());
882    let start = out.len();
883    body(out);
884    let len = u32::try_from(out.len() - start).expect("clip payload length must fit in u32");
885    out[len_pos..len_pos + 4].copy_from_slice(&len.to_le_bytes());
886}
887
888fn write_skeletal(out: &mut Vec<u8>, frmval: &[Vec<BoneXform>], seq: &[Seq]) {
889    let numhin = frmval.first().map_or(0, Vec::len);
890    for (i, row) in frmval.iter().enumerate() {
891        assert!(
892            row.len() == numhin,
893            "skeletal frmval[{i}].len() = {} != numhin {numhin}",
894            row.len(),
895        );
896    }
897    let numfrm = u32::try_from(frmval.len()).expect("numfrm must fit in u32");
898    let numhin_u32 = u32::try_from(numhin).expect("numhin must fit in u32");
899    out.extend_from_slice(&numfrm.to_le_bytes());
900    out.extend_from_slice(&numhin_u32.to_le_bytes());
901    for row in frmval {
902        for v in row {
903            write_bonexform(out, v);
904        }
905    }
906    let seqcount = u32::try_from(seq.len()).expect("seqcount must fit in u32");
907    out.extend_from_slice(&seqcount.to_le_bytes());
908    for s in seq {
909        out.extend_from_slice(&s.tim.to_le_bytes());
910        out.extend_from_slice(&s.frm.to_le_bytes());
911    }
912}
913
914fn write_hinge(out: &mut Vec<u8>, h: &Hinge) {
915    out.extend_from_slice(&h.parent.to_le_bytes());
916    for p in h.p {
917        write_point3(out, p);
918    }
919    for v in h.v {
920        write_point3(out, v);
921    }
922    out.extend_from_slice(&h.vmin.to_le_bytes());
923    out.extend_from_slice(&h.vmax.to_le_bytes());
924    out.push(h.htype);
925    out.extend_from_slice(&h.filler);
926}
927
928fn write_point3(out: &mut Vec<u8>, p: Point3) {
929    out.extend_from_slice(&p.x.to_le_bytes());
930    out.extend_from_slice(&p.y.to_le_bytes());
931    out.extend_from_slice(&p.z.to_le_bytes());
932}
933
934// Keep the on-disk hinge width pinned to the shared 64-byte layout.
935const _: () = assert!(HINGE_SIZE == 64);
936
937// --- tests --------------------------------------------------------------
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    fn unit_kv6(fill: u32) -> Kv6 {
944        // A 1×1×1 model with a single voxel — enough to round-trip.
945        Kv6 {
946            xsiz: 1,
947            ysiz: 1,
948            zsiz: 1,
949            xpiv: 0.5,
950            ypiv: 0.5,
951            zpiv: 0.5,
952            voxels: vec![kv6::Voxel {
953                col: fill,
954                z: 0,
955                vis: 0x3f,
956                dir: 0,
957            }],
958            xlen: vec![1],
959            ylen: vec![vec![1]],
960            palette: None,
961        }
962    }
963
964    fn hinge(parent: i32) -> Hinge {
965        let zero = Point3 {
966            x: 0.0,
967            y: 0.0,
968            z: 0.0,
969        };
970        let axis = Point3 {
971            x: 0.0,
972            y: 0.0,
973            z: 1.0,
974        };
975        Hinge {
976            parent,
977            p: [zero, zero],
978            v: [axis, axis],
979            vmin: 0,
980            vmax: 0,
981            htype: 0,
982            filler: [0; 7],
983        }
984    }
985
986    fn synthetic_character() -> Character {
987        Character {
988            name: "anasaur".to_string(),
989            root: [70.0, -75.0, 50.0],
990            meshes: vec![unit_kv6(0x00ff_8040), unit_kv6(0x0010_2030)],
991            bones: vec![
992                Bone {
993                    name: "body".to_string(),
994                    attachments: vec![Attachment::static_mesh(0)],
995                    hinge: hinge(-1),
996                },
997                Bone {
998                    name: "arm".to_string(),
999                    attachments: vec![Attachment::static_mesh(1)],
1000                    hinge: hinge(0),
1001                },
1002            ],
1003            clips: vec![Clip {
1004                name: "wave".to_string(),
1005                // Bones rotate about +z; build rotation-only TRS frames from
1006                // the wave's Q15 angles.
1007                data: ClipData::Skeletal {
1008                    frmval: [[0i16, 0], [0, 16000], [0, 0], [0, -16000]]
1009                        .iter()
1010                        .map(|[r, a]| {
1011                            let z = [0.0, 0.0, 1.0];
1012                            vec![
1013                                BoneXform::from_hinge_angle(z, *r),
1014                                BoneXform::from_hinge_angle(z, *a),
1015                            ]
1016                        })
1017                        .collect(),
1018                    seq: vec![
1019                        Seq { tim: 0, frm: 0 },
1020                        Seq { tim: 500, frm: 1 },
1021                        Seq { tim: 1000, frm: 2 },
1022                        Seq { tim: 1500, frm: 3 },
1023                        Seq { tim: 2000, frm: !0 },
1024                    ],
1025                },
1026            }],
1027            voxel_clips: Vec::new(),
1028            extra_chunks: Vec::new(),
1029        }
1030    }
1031
1032    #[test]
1033    fn roundtrips_byte_equal() {
1034        let c = synthetic_character();
1035        let bytes = serialize(&c);
1036        let parsed = parse(&bytes).expect("parse synthetic");
1037        let bytes2 = serialize(&parsed);
1038        assert_eq!(bytes, bytes2, "byte-level round-trip failed");
1039        assert_eq!(parsed.name, c.name);
1040        assert_eq!(parsed.root, c.root);
1041        assert_eq!(parsed.meshes.len(), c.meshes.len());
1042        assert_eq!(parsed.bones.len(), c.bones.len());
1043        assert_eq!(parsed.bones[1].name, "arm");
1044        assert_eq!(parsed.bones[1].attachments[0].target, MeshRef::Static(1));
1045        assert_eq!(parsed.bones[1].hinge.parent, 0);
1046        assert_eq!(parsed.clips.len(), 1);
1047        assert_eq!(parsed.clips[0].data, c.clips[0].data);
1048        assert!(parsed.voxel_clips.is_empty());
1049    }
1050
1051    #[test]
1052    fn roundtrips_with_clips_and_multi_attachment() {
1053        use crate::voxel_clip::{LoopMode, VoxelClip, VoxelFrame};
1054        // A tiny 1-frame clip (1×1×4, one voxel at z=0).
1055        let frame = VoxelFrame {
1056            occupancy: vec![1u32],
1057            colors: vec![0x8011_2233],
1058            color_offsets: vec![0, 1],
1059        };
1060        let clip = VoxelClip::from_frames(
1061            [1, 1, 4],
1062            [0.5, 0.5, 2.0],
1063            1.0,
1064            LoopMode::Loop,
1065            &[frame],
1066            &[],
1067            33,
1068            0,
1069        );
1070
1071        let mut c = synthetic_character();
1072        c.voxel_clips = vec![clip];
1073        // Give the body bone a SECOND attachment: the clip, at a non-identity
1074        // offset + non-default playback — so a flame hangs off it.
1075        c.bones[0].attachments.push(Attachment {
1076            target: MeshRef::Clip(0),
1077            local_offset: BoneXform {
1078                t: [1.0, 2.0, 3.0],
1079                r: Quat::IDENTITY,
1080                s: [1.0, 1.0, 1.0],
1081            },
1082            playback: ClipPlayback {
1083                speed_q8: 512,
1084                start_phase_ms: 100,
1085            },
1086        });
1087
1088        let bytes = serialize(&c);
1089        let parsed = parse(&bytes).expect("parse v3 with clips");
1090        assert_eq!(serialize(&parsed), bytes, "byte round-trip");
1091
1092        assert_eq!(parsed.voxel_clips.len(), 1);
1093        assert_eq!(parsed.voxel_clips[0], c.voxel_clips[0]);
1094
1095        // body bone: static mesh 0 + clip 0 (multi-attachment).
1096        let body = &parsed.bones[0].attachments;
1097        assert_eq!(body.len(), 2);
1098        assert_eq!(body[0].target, MeshRef::Static(0));
1099        assert_eq!(body[1].target, MeshRef::Clip(0));
1100        assert_eq!(body[1].local_offset.t, [1.0, 2.0, 3.0]);
1101        assert_eq!(
1102            body[1].playback,
1103            ClipPlayback {
1104                speed_q8: 512,
1105                start_phase_ms: 100,
1106            }
1107        );
1108
1109        // to_kfa_sprite keeps one limb per bone (first static attachment;
1110        // the clip is the attachment runtime's job).
1111        let k = parsed.to_kfa_sprite(None);
1112        assert_eq!(k.limbs.len(), parsed.bones.len());
1113    }
1114
1115    #[test]
1116    fn to_kfa_sprite_builds_renderable() {
1117        let c = synthetic_character();
1118        let mut k = c.to_kfa_sprite(Some(0));
1119        assert_eq!(k.limbs.len(), 2);
1120        assert_eq!(k.hinges.len(), 2);
1121        assert_eq!(k.p, c.root);
1122        // Baked clip advances the child bone away from rest.
1123        k.animsprite(500);
1124        assert_ne!(
1125            k.kfaval[1],
1126            crate::xform::BoneXform::IDENTITY,
1127            "baked clip should move the arm bone"
1128        );
1129
1130        // Rest pose: no curve attached → animsprite is a no-op.
1131        let mut rest = c.to_kfa_sprite(None);
1132        rest.animsprite(500);
1133        assert_eq!(rest.kfaval[1], crate::xform::BoneXform::IDENTITY);
1134    }
1135
1136    #[test]
1137    fn to_kfa_export_is_lossy_but_valid() {
1138        let c = synthetic_character();
1139        let kfa = c.to_kfa(Some(0), "coco.kv6");
1140        // Skeleton + the selected clip survive; the filename is set.
1141        assert_eq!(kfa.kv6_name, b"coco.kv6");
1142        assert_eq!(kfa.hinges.len(), 2);
1143        assert_eq!(kfa.hinges[1].parent, 0);
1144        if let ClipData::Skeletal { frmval, seq } = &c.clips[0].data {
1145            // The .kfa export collapses each TRS to its hinge angle about +z;
1146            // assert it recovers the angles the frames were built from.
1147            let z = [0.0, 0.0, 1.0];
1148            let expected: Vec<Vec<i16>> = frmval
1149                .iter()
1150                .map(|row| row.iter().map(|x| x.hinge_angle(z)).collect())
1151                .collect();
1152            assert_eq!(kfa.frmval, expected);
1153            assert_eq!(&kfa.seq, seq);
1154        } else {
1155            panic!("clip 0 should be skeletal");
1156        }
1157        // The embedded meshes are dropped (lossy) — only the name remains.
1158        // The result is a well-formed .kfa: serialise + re-parse round-trips.
1159        let bytes = crate::kfa::serialize(&kfa);
1160        let reparsed = crate::kfa::parse(&bytes).expect("export round-trips through kfa");
1161        assert_eq!(reparsed.kv6_name, kfa.kv6_name);
1162        assert_eq!(reparsed.frmval, kfa.frmval);
1163        assert_eq!(reparsed.seq, kfa.seq);
1164    }
1165
1166    #[test]
1167    fn to_kfa_without_clip_is_posable() {
1168        let c = synthetic_character();
1169        // No clip / out-of-range / Unknown → empty animation table.
1170        let kfa = c.to_kfa(None, b"x.kv6".to_vec());
1171        assert_eq!(kfa.hinges.len(), 2);
1172        assert!(kfa.frmval.is_empty());
1173        assert!(kfa.seq.is_empty());
1174        // Still a valid .kfa.
1175        let bytes = crate::kfa::serialize(&kfa);
1176        assert!(crate::kfa::parse(&bytes).is_ok());
1177    }
1178
1179    #[test]
1180    fn clips_may_be_empty() {
1181        let mut c = synthetic_character();
1182        c.clips.clear();
1183        let bytes = serialize(&c);
1184        let parsed = parse(&bytes).expect("parse");
1185        assert!(parsed.clips.is_empty());
1186        // Posable rig still builds a sprite.
1187        let k = parsed.to_kfa_sprite(None);
1188        assert_eq!(k.limbs.len(), 2);
1189    }
1190
1191    #[test]
1192    fn unknown_top_level_chunk_is_skipped_and_preserved() {
1193        let mut bytes = serialize(&synthetic_character());
1194        // Append a ZZZZ chunk with a 3-byte payload.
1195        bytes.extend_from_slice(b"ZZZZ");
1196        bytes.extend_from_slice(&3u32.to_le_bytes());
1197        bytes.extend_from_slice(&[1, 2, 3]);
1198        let parsed = parse(&bytes).expect("parse with unknown chunk");
1199        assert_eq!(parsed.bones.len(), 2, "known chunks still parse");
1200        assert_eq!(parsed.extra_chunks, vec![(*b"ZZZZ", vec![1u8, 2, 3])]);
1201        // Re-emit preserves it.
1202        let bytes2 = serialize(&parsed);
1203        assert_eq!(
1204            bytes2, bytes,
1205            "unknown chunk preserved byte-equal on re-save"
1206        );
1207    }
1208
1209    #[test]
1210    fn unknown_clip_kind_preserved() {
1211        let mut c = synthetic_character();
1212        c.clips.push(Clip {
1213            name: "mystery".to_string(),
1214            data: ClipData::Unknown {
1215                kind: 7,
1216                bytes: vec![9, 8, 7, 6],
1217            },
1218        });
1219        let bytes = serialize(&c);
1220        let parsed = parse(&bytes).expect("parse");
1221        // The skeletal clip still loads and plays.
1222        assert!(matches!(parsed.clips[0].data, ClipData::Skeletal { .. }));
1223        assert_eq!(
1224            parsed.clips[1].data,
1225            ClipData::Unknown {
1226                kind: 7,
1227                bytes: vec![9, 8, 7, 6]
1228            }
1229        );
1230        // And re-serialises byte-equal.
1231        assert_eq!(serialize(&parsed), bytes);
1232    }
1233
1234    #[test]
1235    fn bad_magic_errors() {
1236        let mut bytes = serialize(&synthetic_character());
1237        bytes[0] ^= 0xff;
1238        assert!(matches!(parse(&bytes), Err(ParseError::BadMagic { .. })));
1239    }
1240
1241    #[test]
1242    fn version_mismatch_errors() {
1243        let mut bytes = serialize(&synthetic_character());
1244        // version is the 2 bytes right after the 4-byte magic.
1245        bytes[4] = 0xff;
1246        bytes[5] = 0xff;
1247        assert!(matches!(
1248            parse(&bytes),
1249            Err(ParseError::UnsupportedVersion(0xffff))
1250        ));
1251    }
1252
1253    #[test]
1254    fn truncated_errors() {
1255        let bytes = serialize(&synthetic_character());
1256        assert!(matches!(
1257            parse(&bytes[..bytes.len() - 4]),
1258            Err(ParseError::Truncated { .. })
1259        ));
1260    }
1261
1262    #[test]
1263    fn missing_required_chunk_errors() {
1264        // A minimal valid header with no chunks at all.
1265        let mut bytes = Vec::new();
1266        bytes.extend_from_slice(&MAGIC);
1267        bytes.extend_from_slice(&VERSION.to_le_bytes());
1268        assert!(matches!(
1269            parse(&bytes),
1270            Err(ParseError::MissingChunk(TAG_META))
1271        ));
1272    }
1273
1274    #[test]
1275    fn unsupported_mesh_kind_errors() {
1276        let mut bytes = serialize(&synthetic_character());
1277        // Flip the first attachment's mesh_kind to an undefined value (2;
1278        // 0 = Static, 1 = Clip are both valid). The BONS payload begins:
1279        //   tag(4) len(4) count(4) name_len(2) name("body"=4) attach_count(4)
1280        //   kind(2)...
1281        let pos = find_tag(&bytes, *b"BONS");
1282        let kind_off = pos + 8 + 4 + 2 + 4 + 4;
1283        bytes[kind_off] = 2;
1284        bytes[kind_off + 1] = 0;
1285        assert!(matches!(
1286            parse(&bytes),
1287            Err(ParseError::UnsupportedMeshKind(2))
1288        ));
1289    }
1290
1291    #[test]
1292    fn clip_bone_count_mismatch_errors() {
1293        // Build a character whose skeletal clip has the wrong bone count
1294        // by hand-serialising bones=1 but clip numhin=2.
1295        let mut c = synthetic_character();
1296        c.bones.pop(); // now 1 bone
1297        c.bones[0].attachments = vec![Attachment::static_mesh(0)];
1298        // frmval rows still have width 2 → numhin 2 != bones 1.
1299        let bytes = serialize(&c);
1300        assert!(matches!(
1301            parse(&bytes),
1302            Err(ParseError::ClipBoneCountMismatch)
1303        ));
1304    }
1305
1306    #[test]
1307    fn bad_embedded_mesh_errors() {
1308        let mut bytes = serialize(&synthetic_character());
1309        // Corrupt the first embedded kv6's magic. MSHS payload begins:
1310        //   tag(4) len(4) count(4) blob_len(4) <kv6 magic 4 bytes>
1311        let pos = find_tag(&bytes, *b"MSHS");
1312        let kv6_magic_off = pos + 8 + 4 + 4;
1313        bytes[kv6_magic_off] ^= 0xff;
1314        assert!(matches!(parse(&bytes), Err(ParseError::BadMesh(_))));
1315    }
1316
1317    /// Find the byte offset of a top-level chunk `tag` in a serialised
1318    /// character (walks the chunk list from the 6-byte header).
1319    fn find_tag(bytes: &[u8], tag: [u8; 4]) -> usize {
1320        let mut pos = 6; // magic(4) + version(2)
1321        while pos + 8 <= bytes.len() {
1322            let here = &bytes[pos..pos + 4];
1323            let len = u32::from_le_bytes([
1324                bytes[pos + 4],
1325                bytes[pos + 5],
1326                bytes[pos + 6],
1327                bytes[pos + 7],
1328            ]) as usize;
1329            if here == tag {
1330                return pos;
1331            }
1332            pos += 8 + len;
1333        }
1334        panic!("tag {tag:?} not found");
1335    }
1336
1337    // ---- QE.6b adversarial characters: error, never panic/hang ----
1338
1339    #[test]
1340    fn parse_rejects_out_of_range_attachment_index() {
1341        // Pre-QE.6b: parse accepted this and to_kfa_sprite / the
1342        // attachment runtime panicked out-of-bounds later.
1343        let mut c = synthetic_character();
1344        c.bones[1].attachments = vec![Attachment::static_mesh(99)];
1345        let bytes = serialize(&c);
1346        assert!(matches!(
1347            parse(&bytes),
1348            Err(ParseError::BadAttachmentIndex {
1349                bone: 1,
1350                index: 99,
1351                table_len: 2,
1352            })
1353        ));
1354    }
1355
1356    #[test]
1357    fn parse_rejects_bad_or_cyclic_bone_skeleton() {
1358        // Out-of-range parent (pre-QE.6b: OOB panic in the solve).
1359        let mut c = synthetic_character();
1360        c.bones[1].hinge.parent = 42;
1361        assert!(matches!(
1362            parse(&serialize(&c)),
1363            Err(ParseError::BadSkeleton { bone: 1 })
1364        ));
1365        // Cyclic parents (pre-QE.6b: the loader hung forever).
1366        let mut c = synthetic_character();
1367        c.bones[0].hinge.parent = 1;
1368        assert!(matches!(
1369            parse(&serialize(&c)),
1370            Err(ParseError::BadSkeleton { .. })
1371        ));
1372    }
1373}