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