Skip to main content

roxlap_formats/
kfa.rs

1//! `.kfa` kv6 hinge / animation transform data.
2//!
3//! Reference: voxlaptest's `getkfa` (``) and the
4//! `hingetype` / `seqtyp` / `kfatype` declarations in
5//! `..59`. File layout (all multi-byte fields are little-
6//! endian; structs are tightly packed because `voxlap5.h` opens with
7//! `#pragma pack(push, 1)` before declaring them):
8//!
9//! ```text
10//! offset  size                            description
11//! 0x00    u32                             magic = 0x6b6c774b ("Kwlk")
12//! 0x04    u32                             name_len
13//! 0x08    name_len bytes                  associated kv6 filename (no NUL)
14//! ...     u32                             numhin
15//! ...     numhin × 64 bytes               hinges
16//! ...     u32                             numfrm
17//! ...     numfrm × numhin × i16           frmval (per-frame, per-hinge values)
18//! ...     u32                             seqnum
19//! ...     seqnum × 8 bytes                seq (tim:i32, frm:i32)
20//! ```
21//!
22//! `hingetype` (64 bytes packed):
23//!
24//! ```text
25//!     i32    parent       index of parent hinge (-1 = none)
26//!     point3 p[2]         "velcro" anchor points (24 bytes, 2 × 3 × f32)
27//!     point3 v[2]         rotation axes (24 bytes)
28//!     i16    vmin
29//!     i16    vmax
30//!     u8     htype
31//!     u8[7]  filler
32//! ```
33//!
34//! No real `.kfa` fixture lives in voxlaptest yet (the oracle doesn't
35//! render animated sprites), so this module's tests build a synthetic
36//! `Kfa`, serialise, parse, and assert struct-equal + byte-equal
37//! round-trip. Swap in a real fixture once R6 / sprite animation
38//! coverage needs one.
39
40use core::fmt;
41
42use crate::bytes::{Cursor, OutOfBounds};
43use crate::xform::BoneXform;
44
45const MAGIC: u32 = 0x6b6c_774b; // "Kwlk" little-endian
46const HINGE_SIZE: usize = 64;
47const SEQ_SIZE: usize = 8;
48
49/// 3D point (`point3d` in voxlaptest), 12 bytes packed.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct Point3 {
52    pub x: f32,
53    pub y: f32,
54    pub z: f32,
55}
56
57/// One hinge / joint definition (`hingetype` in voxlaptest).
58#[derive(Debug, Clone, Copy)]
59pub struct Hinge {
60    /// Index of the parent hinge in the same `Kfa`, or `-1` for none.
61    pub parent: i32,
62    /// Anchor ("velcro") points — `p[0]` on this object, `p[1]` on the
63    /// parent.
64    pub p: [Point3; 2],
65    /// Rotation axes — same convention as `p`.
66    pub v: [Point3; 2],
67    pub vmin: i16,
68    pub vmax: i16,
69    pub htype: u8,
70    /// Trailing 7 bytes of padding inside the on-disk struct. Stored
71    /// verbatim so byte-equal round-trip survives — files in the wild
72    /// may carry non-zero bytes here.
73    pub filler: [u8; 7],
74}
75
76/// One animation sequence entry (`seqtyp` in voxlaptest).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct Seq {
79    pub tim: i32,
80    pub frm: i32,
81}
82
83/// Parsed `.kfa` file. Round-trips byte-equally via [`parse`] +
84/// [`serialize`].
85#[derive(Debug, Clone)]
86pub struct Kfa {
87    /// Associated `.kv6` filename (raw bytes, no NUL terminator). Voxlap
88    /// uses this to locate the rigged kv6 model.
89    pub kv6_name: Vec<u8>,
90    pub hinges: Vec<Hinge>,
91    /// `frmval[frame_idx][hinge_idx]` — outer length is `numfrm`,
92    /// inner length must equal `hinges.len()` for every frame.
93    pub frmval: Vec<Vec<i16>>,
94    pub seq: Vec<Seq>,
95}
96
97/// Errors returned by [`parse`].
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ParseError {
100    /// First 4 bytes are not the `0x6b6c774b` magic.
101    BadMagic { got: u32 },
102    /// A read of `need` bytes at offset `at` would run past EOF.
103    Truncated { at: usize, need: usize },
104    /// QE.6b — a hinge whose `parent` is neither `-1` (root) nor a
105    /// valid hinge index. Pre-QE.6 this panicked out-of-bounds later,
106    /// in `sort_hinges`.
107    BadHingeParent { hinge: usize, parent: i32 },
108    /// QE.6b — the hinge parent links contain a cycle. Pre-QE.6 this
109    /// hung the loader forever (the topological solve in
110    /// `sort_hinges` never converged).
111    HingeCycle { hinge: usize },
112}
113
114impl fmt::Display for ParseError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match *self {
117            Self::BadMagic { got } => {
118                write!(f, "kfa bad magic: got {got:#010x}, expected 0x6b6c774b")
119            }
120            Self::Truncated { at, need } => {
121                write!(f, "kfa truncated: need {need} bytes at offset {at}")
122            }
123            Self::BadHingeParent { hinge, parent } => {
124                write!(f, "kfa hinge {hinge}: parent {parent} out of range")
125            }
126            Self::HingeCycle { hinge } => {
127                write!(f, "kfa hinge {hinge}: parent links form a cycle")
128            }
129        }
130    }
131}
132
133impl std::error::Error for ParseError {}
134
135impl From<OutOfBounds> for ParseError {
136    fn from(e: OutOfBounds) -> Self {
137        Self::Truncated {
138            at: e.at,
139            need: e.need,
140        }
141    }
142}
143
144/// Parse a `.kfa` file's bytes into a [`Kfa`].
145///
146/// # Errors
147///
148/// Returns [`ParseError`] if the magic mismatches or a sequential read
149/// for any header / hinge / frmval / seq region runs past EOF.
150pub fn parse(bytes: &[u8]) -> Result<Kfa, ParseError> {
151    let mut cur = Cursor::new(bytes);
152    let magic = cur.read_u32()?;
153    if magic != MAGIC {
154        return Err(ParseError::BadMagic { got: magic });
155    }
156
157    let name_len = cur.read_u32()? as usize;
158    let kv6_name = cur.read_bytes(name_len)?.to_vec();
159
160    // QE.6b — capacities clamped by the remaining bytes (hinge record
161    // = 64 B, frmval row = 2 B/hinge, seq entry = 8 B) so a crafted
162    // count can't allocation-bomb before the reads fail as Truncated.
163    let numhin = cur.read_u32()? as usize;
164    let mut hinges = Vec::with_capacity(cur.clamped_capacity(numhin, 64));
165    for _ in 0..numhin {
166        hinges.push(read_hinge(&mut cur)?);
167    }
168    validate_hinge_topology(&hinges)?;
169
170    let numfrm = cur.read_u32()? as usize;
171    let mut frmval = Vec::with_capacity(cur.clamped_capacity(numfrm, numhin.saturating_mul(2)));
172    for _ in 0..numfrm {
173        let mut row = Vec::with_capacity(cur.clamped_capacity(numhin, 2));
174        for _ in 0..numhin {
175            row.push(cur.read_i16()?);
176        }
177        frmval.push(row);
178    }
179
180    let seqnum = cur.read_u32()? as usize;
181    let mut seq = Vec::with_capacity(cur.clamped_capacity(seqnum, 8));
182    for _ in 0..seqnum {
183        let tim = cur.read_i32()?;
184        let frm = cur.read_i32()?;
185        seq.push(Seq { tim, frm });
186    }
187
188    Ok(Kfa {
189        kv6_name,
190        hinges,
191        frmval,
192        seq,
193    })
194}
195
196/// Serialise a [`Kfa`] back to bytes. Round-trips byte-equally with
197/// the input that produced this `Kfa` via [`parse`].
198///
199/// # Panics
200///
201/// Panics if `kv6_name.len()`, `hinges.len()`, `frmval.len()`, or
202/// `seq.len()` does not fit in a `u32` (the on-disk format stores
203/// these as `u32`), or if `frmval` is not rectangular (every inner
204/// row's length must equal `hinges.len()`). `Kfa` values produced by
205/// [`parse`] always satisfy these invariants.
206#[must_use]
207pub fn serialize(kfa: &Kfa) -> Vec<u8> {
208    let numhin = kfa.hinges.len();
209    for (i, row) in kfa.frmval.iter().enumerate() {
210        assert!(
211            row.len() == numhin,
212            "kfa frmval[{}].len() = {}, expected numhin = {}",
213            i,
214            row.len(),
215            numhin,
216        );
217    }
218    let name_len = u32::try_from(kfa.kv6_name.len()).expect("kv6_name length must fit in u32");
219    let numhin_u32 = u32::try_from(numhin).expect("numhin must fit in u32");
220    let numfrm_u32 = u32::try_from(kfa.frmval.len()).expect("numfrm must fit in u32");
221    let seqnum_u32 = u32::try_from(kfa.seq.len()).expect("seqnum must fit in u32");
222
223    let total = 4
224        + 4
225        + kfa.kv6_name.len()
226        + 4
227        + numhin * HINGE_SIZE
228        + 4
229        + (kfa.frmval.len() * numhin) * 2
230        + 4
231        + kfa.seq.len() * SEQ_SIZE;
232    let mut out = Vec::with_capacity(total);
233
234    out.extend_from_slice(&MAGIC.to_le_bytes());
235    out.extend_from_slice(&name_len.to_le_bytes());
236    out.extend_from_slice(&kfa.kv6_name);
237
238    out.extend_from_slice(&numhin_u32.to_le_bytes());
239    for h in &kfa.hinges {
240        write_hinge(&mut out, h);
241    }
242
243    out.extend_from_slice(&numfrm_u32.to_le_bytes());
244    for row in &kfa.frmval {
245        for v in row {
246            out.extend_from_slice(&v.to_le_bytes());
247        }
248    }
249
250    out.extend_from_slice(&seqnum_u32.to_le_bytes());
251    for s in &kfa.seq {
252        out.extend_from_slice(&s.tim.to_le_bytes());
253        out.extend_from_slice(&s.frm.to_le_bytes());
254    }
255
256    out
257}
258
259// --- internal helpers ---------------------------------------------------
260
261fn read_point3(cur: &mut Cursor<'_>) -> Result<Point3, OutOfBounds> {
262    let x = cur.read_f32()?;
263    let y = cur.read_f32()?;
264    let z = cur.read_f32()?;
265    Ok(Point3 { x, y, z })
266}
267
268fn write_point3(out: &mut Vec<u8>, p: Point3) {
269    out.extend_from_slice(&p.x.to_le_bytes());
270    out.extend_from_slice(&p.y.to_le_bytes());
271    out.extend_from_slice(&p.z.to_le_bytes());
272}
273
274/// QE.6b — validate every hinge's `parent` is `-1` or an in-range
275/// index, and that the parent links are acyclic. A malformed skeleton
276/// previously panicked out-of-bounds or **hung forever** in
277/// `sort_hinges`'s topological solve — a denial-of-service on load
278/// for hostile/corrupted `.kfa`/`.rkc` files.
279fn validate_hinge_topology(hinges: &[Hinge]) -> Result<(), ParseError> {
280    let n = hinges.len();
281    for (i, h) in hinges.iter().enumerate() {
282        if h.parent != -1 && usize::try_from(h.parent).map_or(true, |p| p >= n) {
283            return Err(ParseError::BadHingeParent {
284                hinge: i,
285                parent: h.parent,
286            });
287        }
288    }
289    // Walk each parent chain; more than `n` hops means a cycle.
290    for start in 0..n {
291        let mut cur = hinges[start].parent;
292        let mut hops = 0usize;
293        while cur != -1 {
294            hops += 1;
295            if hops > n {
296                return Err(ParseError::HingeCycle { hinge: start });
297            }
298            // In-range per the loop above.
299            cur = hinges[usize::try_from(cur).expect("validated non-negative")].parent;
300        }
301    }
302    Ok(())
303}
304
305fn read_hinge(cur: &mut Cursor<'_>) -> Result<Hinge, OutOfBounds> {
306    let parent = cur.read_i32()?;
307    let p0 = read_point3(cur)?;
308    let p1 = read_point3(cur)?;
309    let v0 = read_point3(cur)?;
310    let v1 = read_point3(cur)?;
311    let vmin = cur.read_i16()?;
312    let vmax = cur.read_i16()?;
313    let htype = cur.read_u8()?;
314    let filler_buf = cur.read_bytes(7)?;
315    let mut filler = [0u8; 7];
316    filler.copy_from_slice(filler_buf);
317    Ok(Hinge {
318        parent,
319        p: [p0, p1],
320        v: [v0, v1],
321        vmin,
322        vmax,
323        htype,
324        filler,
325    })
326}
327
328fn write_hinge(out: &mut Vec<u8>, h: &Hinge) {
329    out.extend_from_slice(&h.parent.to_le_bytes());
330    write_point3(out, h.p[0]);
331    write_point3(out, h.p[1]);
332    write_point3(out, h.v[0]);
333    write_point3(out, h.v[1]);
334    out.extend_from_slice(&h.vmin.to_le_bytes());
335    out.extend_from_slice(&h.vmax.to_le_bytes());
336    out.push(h.htype);
337    out.extend_from_slice(&h.filler);
338}
339
340// --- KFA sprite (host-facing scene type) --------------------------------
341
342/// One animated KFA sprite — bones + hinges + per-bone live
343/// animation values.
344///
345/// The host owns one of these per animated model, updates `kfaval[]`
346/// over time, and passes it to roxlap-core's `draw_kfa_sprite` each
347/// frame. Construction is data-only (this crate); rendering is in
348/// `roxlap-core`.
349#[derive(Clone)]
350pub struct KfaSprite {
351    /// One [`crate::sprite::Sprite`] per bone. Limb `i`'s
352    /// `(s, h, f, p)` is computed per frame by the renderer from
353    /// the parent's transform + hinge math; the `kv6` field holds
354    /// the bone's kv6 mesh and never changes.
355    pub limbs: Vec<crate::sprite::Sprite>,
356    /// Bone hierarchy. Mirror of voxlap's `kfatype.hinge[]`.
357    pub hinges: Vec<Hinge>,
358    /// Topological sort of bone indices — populated once at
359    /// construction, used by the renderer's per-frame loop.
360    pub hinge_sort: Vec<usize>,
361    /// Per-bone resolved local transform for the current frame (translation,
362    /// quaternion rotation, scale). Generalises voxlap's `vx5.kfaval[]` (which
363    /// was a single Q15 hinge angle) to full TRS. Updated per frame by
364    /// [`Self::animsprite`], or poked directly by the host.
365    pub kfaval: Vec<BoneXform>,
366    /// World-space anchor of the root limb's `hinge.p[0]`. The
367    /// root limb is positioned so `hinge.p[0]` lands at this
368    /// point given the world basis below.
369    pub p: [f32; 3],
370    /// World-space basis for the root limb. Mirror of
371    /// `vx5sprite.{s, h, f}` for the root.
372    pub s: [f32; 3],
373    pub h: [f32; 3],
374    pub f: [f32; 3],
375    /// Animation keyframe table — `frmval[frame][hinge]` local transforms.
376    /// Empty until [`Self::set_animation`]; an empty table makes
377    /// [`Self::animsprite`] a no-op so hosts that poke [`kfaval`](Self::kfaval)
378    /// directly keep working.
379    pub frmval: Vec<Vec<BoneXform>>,
380    /// Animation sequence — ordered `(tim, frm)` keyframes. Mirror of
381    /// `kfatype.seq`. `tim` is an absolute timestamp (ms); `frm` is a
382    /// frame index into [`frmval`](Self::frmval), or `!target`
383    /// (bitwise-NOT, hence negative) for a jump/loop to seq entry
384    /// `target`.
385    pub seq: Vec<Seq>,
386    /// Current animation time (ms) — voxlap's `vx5sprite.kfatim`.
387    /// Advanced by [`Self::animsprite`].
388    pub kfatim: i32,
389    /// Previous animation time (ms) — voxlap's `vx5sprite.okfatim`,
390    /// used to cross-fade when the active sequence entry is itself a
391    /// blend marker (`seq[z].frm < 0`). Host sets it when switching
392    /// animations; [`Self::animsprite`] never writes it.
393    pub okfatim: i32,
394}
395
396impl KfaSprite {
397    /// Build a KFA sprite from a list of `(Sprite, Hinge)` bones.
398    /// `limbs.len()` must equal `hinges.len()`. The first bone with
399    /// `parent < 0` is the root.
400    ///
401    /// `kfaval` is initialised to all zeros; the host should set
402    /// per-bone angles before / between render calls.
403    ///
404    /// # Panics
405    ///
406    /// Panics if `limbs.len() != hinges.len()`.
407    #[must_use]
408    pub fn new(limbs: Vec<crate::sprite::Sprite>, hinges: Vec<Hinge>, root_pos: [f32; 3]) -> Self {
409        assert_eq!(
410            limbs.len(),
411            hinges.len(),
412            "limbs ({}) and hinges ({}) length mismatch",
413            limbs.len(),
414            hinges.len()
415        );
416        let n = hinges.len();
417        let hinge_sort = sort_hinges(&hinges);
418        Self {
419            limbs,
420            hinges,
421            hinge_sort,
422            kfaval: vec![BoneXform::IDENTITY; n],
423            p: root_pos,
424            s: [1.0, 0.0, 0.0],
425            h: [0.0, 1.0, 0.0],
426            f: [0.0, 0.0, 1.0],
427            frmval: Vec::new(),
428            seq: Vec::new(),
429            kfatim: 0,
430            okfatim: 0,
431        }
432    }
433
434    /// Attach an animation curve — the `frmval` + `seq` tables parsed
435    /// from a [`Kfa`]. After this, [`Self::animsprite`] drives
436    /// [`kfaval`](Self::kfaval) from playback time instead of the host
437    /// poking individual bones.
438    pub fn set_animation(&mut self, frmval: Vec<Vec<BoneXform>>, seq: Vec<Seq>) {
439        self.frmval = frmval;
440        self.seq = seq;
441    }
442
443    /// Advance the animation by `ti` milliseconds and recompute every
444    /// child bone's [`kfaval`](Self::kfaval) — a faithful port of
445    /// voxlap's `animsprite` (``).
446    ///
447    /// Walks the sequence forward from the current
448    /// [`kfatim`](Self::kfatim) (honouring `!target` jump/loop
449    /// entries), then piecewise-linearly interpolates the two bracketing
450    /// keyframes per hinge. Interpolation is angle-wrap-aware: a free
451    /// hinge (`vmin == vmax`) takes the shortest path, a limited hinge
452    /// winds in its allowed direction. When the active entry is itself a
453    /// blend marker (`seq[z].frm < 0`), the pose cross-fades from the
454    /// [`okfatim`](Self::okfatim)-derived frame.
455    ///
456    /// No-op when no animation curve is attached (see
457    /// [`Self::set_animation`]).
458    #[allow(
459        clippy::cast_possible_truncation,
460        clippy::cast_possible_wrap,
461        clippy::cast_sign_loss,
462        clippy::similar_names
463    )]
464    pub fn animsprite(&mut self, mut ti: i32) {
465        if self.seq.is_empty() || self.frmval.is_empty() {
466            return;
467        }
468        let numhin = self.hinges.len();
469        let seqnum = self.seq.len();
470
471        // Phase 1 — advance kfatim by `ti` ms through the sequence,
472        // following `!target` jump entries.
473        let mut z = kfatime2seq(&self.seq, self.kfatim) as i32;
474        while ti > 0 {
475            z += 1;
476            if z as usize >= seqnum {
477                break;
478            }
479            let dt = self.seq[z as usize].tim - self.kfatim;
480            if dt <= 0 {
481                break;
482            }
483            if dt > ti {
484                self.kfatim += ti;
485                break;
486            }
487            ti -= dt;
488            let jump = !self.seq[z as usize].frm; // ~frm
489            if jump >= 0 {
490                if z == jump {
491                    break;
492                }
493                z = jump;
494            }
495            self.kfatim = self.seq[z as usize].tim;
496        }
497
498        // Phase 2 — resolve the bracketing frames + 16.16 blend ratios
499        // for the current segment.
500        let z_seq = kfatime2seq(&self.seq, self.kfatim);
501        let zz_idx = z_seq + 1;
502        let (trat, zz_frm) = if zz_idx < seqnum && self.seq[zz_idx].frm != !(zz_idx as i32) {
503            let span = self.seq[zz_idx].tim - self.seq[z_seq].tim;
504            let trat = if span != 0 {
505                shldiv16(self.kfatim - self.seq[z_seq].tim, span)
506            } else {
507                0
508            };
509            let i = self.seq[zz_idx].frm;
510            let zz_frm = if i < 0 {
511                self.seq[(!i) as usize].frm
512            } else {
513                i
514            };
515            (trat, zz_frm)
516        } else {
517            (0, 0)
518        };
519
520        let z_frm = self.seq[z_seq].frm;
521        // trat2 < 0 signals "no okfatim cross-fade" (the common path).
522        let mut trat2 = -1i32;
523        let mut z0_frm = 0i32;
524        let mut zz0_frm = 0i32;
525        if z_frm < 0 {
526            let z0_seq = kfatime2seq(&self.seq, self.okfatim);
527            let zz0_idx = z0_seq + 1;
528            if zz0_idx < seqnum && self.seq[zz0_idx].frm != !(zz0_idx as i32) {
529                let span = self.seq[zz0_idx].tim - self.seq[z0_seq].tim;
530                trat2 = if span != 0 {
531                    shldiv16(self.okfatim - self.seq[z0_seq].tim, span)
532                } else {
533                    0
534                };
535                let i = self.seq[zz0_idx].frm;
536                zz0_frm = if i < 0 {
537                    self.seq[(!i) as usize].frm
538                } else {
539                    i
540                };
541            } else {
542                trat2 = 0;
543            }
544            z0_frm = self.seq[z0_seq].frm;
545            if z0_frm < 0 {
546                z0_frm = zz0_frm;
547                trat2 = 0;
548            }
549        }
550
551        // Phase 3 — per-hinge interpolation into kfaval
552        //. Root bones (parent < 0) keep their
553        // value untouched, exactly as voxlap's `continue`.
554        // `trat` / `trat2` are 16.16 fixed-point blend ratios; `/ 65536` gives
555        // the `[0, 1]` factor for the TRS blend.
556        for i in (0..numhin).rev() {
557            if self.hinges[i].parent < 0 {
558                continue;
559            }
560            let mut x = if trat2 < 0 {
561                self.frmval[z_frm as usize][i]
562            } else {
563                let base = self.frmval[z0_frm as usize][i];
564                if trat2 > 0 {
565                    base.blend(self.frmval[zz0_frm as usize][i], trat2 as f32 / 65536.0)
566                } else {
567                    base
568                }
569            };
570            if trat > 0 {
571                x = x.blend(self.frmval[zz_frm as usize][i], trat as f32 / 65536.0);
572            }
573            self.kfaval[i] = x;
574        }
575    }
576}
577
578/// 16.16 fixed-point signed shift-divide — voxlap's `shldiv16`
579/// (``): `((i64)a << 16) / b`, truncating toward zero
580/// (matching x86 `idiv`).
581#[inline]
582#[allow(clippy::cast_possible_truncation)]
583fn shldiv16(a: i32, b: i32) -> i32 {
584    ((i64::from(a) << 16) / i64::from(b)) as i32
585}
586
587/// Binary-search the seq entry whose `tim` brackets `tim` from below —
588/// voxlap's `kfatime2seq` (`voxlap5.c`). Returns the index `a` such
589/// that `seq[a].tim <= tim < seq[a+1].tim` (clamped to the ends).
590/// Caller guarantees `seq` is non-empty.
591#[allow(
592    clippy::cast_possible_truncation,
593    clippy::cast_possible_wrap,
594    clippy::cast_sign_loss
595)]
596fn kfatime2seq(seq: &[Seq], tim: i32) -> usize {
597    let mut a: isize = 0;
598    let mut b: isize = seq.len() as isize - 1;
599    while b - a >= 2 {
600        let i = (a + b) >> 1;
601        if tim >= seq[i as usize].tim {
602            a = i;
603        } else {
604            b = i;
605        }
606    }
607    a as usize
608}
609
610/// Build the hinge-sort order — voxlap's `kfasorthinge`
611/// (``). The result is an array of hinge
612/// indices ordered such that **walking from index `n-1` down to
613/// 0** visits parents before children — a valid topological order
614/// for the chain of `setlimb` calls in voxlap's `kfadraw`.
615///
616/// Voxlap mutates the hinges in place during sort and restores
617/// them; this port produces the same `hsort` array without
618/// touching the input.
619#[must_use]
620#[allow(clippy::cast_sign_loss)] // parent >= 0 checked immediately above
621pub fn sort_hinges(hinges: &[Hinge]) -> Vec<usize> {
622    let n = hinges.len();
623    let mut hsort = vec![0usize; n];
624    // First pass: roots at the end, non-roots at the start.
625    let mut head = 0usize;
626    let mut tail = n;
627    for i in (0..n).rev() {
628        if hinges[i].parent < 0 {
629            tail -= 1;
630            hsort[tail] = i;
631        } else {
632            hsort[head] = i;
633            head += 1;
634        }
635    }
636
637    // `solved[h]` = true once hinge h's parent has been settled
638    // into the "tail" half. Voxlap encodes this in-place by
639    // flipping the parent field to -2-parent; we use a side
640    // bitmap to leave the input immutable.
641    let mut solved = vec![false; n];
642    for i in (tail..n).rev() {
643        solved[hsort[i]] = true;
644    }
645
646    // Iterative pass: pick non-root entries in head whose parent
647    // is already solved; move them to the tail.
648    let mut idx = head; // idx walks the head [0..head) backward
649    while tail > 0 {
650        if idx == 0 {
651            idx = head;
652        }
653        idx -= 1;
654        let j = hsort[idx];
655        let parent = hinges[j].parent;
656        if parent < 0 {
657            // Already in the tail (shouldn't happen since the
658            // first pass sorted these out).
659            continue;
660        }
661        if solved[parent as usize] {
662            solved[j] = true;
663            tail -= 1;
664            hsort[idx] = hsort[tail];
665            hsort[tail] = j;
666            head -= 1;
667        }
668        if head == 0 {
669            break;
670        }
671    }
672    hsort
673}
674
675// --- tests --------------------------------------------------------------
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    fn synthetic_kfa() -> Kfa {
682        Kfa {
683            kv6_name: b"anasaur.kv6".to_vec(),
684            hinges: vec![
685                Hinge {
686                    parent: -1,
687                    p: [
688                        Point3 {
689                            x: 0.0,
690                            y: 0.0,
691                            z: 0.0,
692                        },
693                        Point3 {
694                            x: 1.0,
695                            y: 0.0,
696                            z: 0.0,
697                        },
698                    ],
699                    v: [
700                        Point3 {
701                            x: 0.0,
702                            y: 1.0,
703                            z: 0.0,
704                        },
705                        Point3 {
706                            x: 0.0,
707                            y: 0.0,
708                            z: 1.0,
709                        },
710                    ],
711                    vmin: -180,
712                    vmax: 180,
713                    htype: 0,
714                    filler: [0; 7],
715                },
716                Hinge {
717                    parent: 0,
718                    p: [
719                        Point3 {
720                            x: 0.5,
721                            y: 0.0,
722                            z: 0.0,
723                        },
724                        Point3 {
725                            x: 0.5,
726                            y: 1.0,
727                            z: 0.0,
728                        },
729                    ],
730                    v: [
731                        Point3 {
732                            x: 1.0,
733                            y: 0.0,
734                            z: 0.0,
735                        },
736                        Point3 {
737                            x: 0.0,
738                            y: 1.0,
739                            z: 0.0,
740                        },
741                    ],
742                    vmin: -90,
743                    vmax: 90,
744                    htype: 1,
745                    // Non-zero filler tests round-trip preservation.
746                    filler: [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba],
747                },
748            ],
749            frmval: vec![vec![0, 0], vec![45, -30], vec![90, -60], vec![135, -90]],
750            seq: vec![
751                Seq { tim: 0, frm: 0 },
752                Seq { tim: 100, frm: 1 },
753                Seq { tim: 200, frm: 2 },
754                Seq { tim: 300, frm: 3 },
755            ],
756        }
757    }
758
759    #[test]
760    fn synthetic_roundtrips_byte_equal() {
761        let kfa = synthetic_kfa();
762        let bytes = serialize(&kfa);
763        let parsed = parse(&bytes).expect("parse synthetic");
764        let bytes2 = serialize(&parsed);
765        assert_eq!(bytes, bytes2, "byte-level round-trip failed");
766        // Spot-check the structural round-trip too.
767        assert_eq!(parsed.kv6_name, kfa.kv6_name);
768        assert_eq!(parsed.hinges.len(), kfa.hinges.len());
769        assert_eq!(parsed.frmval, kfa.frmval);
770        assert_eq!(parsed.seq, kfa.seq);
771    }
772
773    #[test]
774    fn hinge_size_matches_voxlap_packed_layout() {
775        // 4 (parent) + 24 (p[2]) + 24 (v[2]) + 2 (vmin) + 2 (vmax)
776        //   + 1 (htype) + 7 (filler) = 64.
777        assert_eq!(HINGE_SIZE, 64);
778        // And we serialise exactly that many bytes per hinge.
779        let kfa = synthetic_kfa();
780        let bytes = serialize(&kfa);
781        // 4 magic + 4 name_len + 11 name + 4 numhin = 23 bytes header.
782        let header = 4 + 4 + kfa.kv6_name.len() + 4;
783        let after_hinges = header + kfa.hinges.len() * HINGE_SIZE;
784        // Re-parse and verify the second hinge's filler matches what we set.
785        let parsed = parse(&bytes).expect("parse synthetic");
786        assert_eq!(
787            parsed.hinges[1].filler,
788            [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba]
789        );
790        // Sanity: total size must include numfrm field after hinges.
791        assert!(bytes.len() > after_hinges + 4);
792    }
793
794    #[test]
795    fn parse_bad_magic_fails() {
796        let mut bytes = serialize(&synthetic_kfa());
797        bytes[0] ^= 0xff;
798        let r = parse(&bytes);
799        assert!(matches!(r, Err(ParseError::BadMagic { .. })));
800    }
801
802    #[test]
803    fn parse_truncated_in_hinge_table_fails() {
804        let bytes = serialize(&synthetic_kfa());
805        // Truncate inside the first hinge.
806        let truncated = &bytes[..30];
807        let r = parse(truncated);
808        assert!(matches!(r, Err(ParseError::Truncated { .. })));
809    }
810
811    /// `sort_hinges` puts roots at high indices and children at low.
812    /// 3-bone chain: root → child1 → child2.
813    #[test]
814    #[allow(clippy::cast_sign_loss)] // p >= 0 checked at the assert site
815    fn sort_hinges_three_bone_chain() {
816        let axis = |x: f32, y: f32, z: f32| Point3 { x, y, z };
817        let h = |parent: i32| Hinge {
818            parent,
819            p: [axis(0.0, 0.0, 0.0); 2],
820            v: [axis(1.0, 0.0, 0.0); 2],
821            vmin: 0,
822            vmax: 0,
823            htype: 0,
824            filler: [0; 7],
825        };
826        // hinge[0] = root, hinge[1] child of 0, hinge[2] child of 1.
827        let hinges = vec![h(-1), h(0), h(1)];
828        let sort = sort_hinges(&hinges);
829        // Walking sort[i] for i=n-1..=0 must visit each bone's parent
830        // before the bone itself.
831        let mut seen = [false; 3];
832        for k in (0..3).rev() {
833            let j = sort[k];
834            seen[j] = true;
835            let p = hinges[j].parent;
836            if p >= 0 {
837                assert!(
838                    seen[p as usize],
839                    "bone {j}'s parent {p} not yet visited at descent step k={k}"
840                );
841            }
842        }
843    }
844
845    // --- animsprite playback ------------------------------------------
846
847    /// Minimal two-bone sprite (root + one child hinge) for driving
848    /// [`KfaSprite::animsprite`]. `limbs` is empty — `animsprite` reads
849    /// only the hinges + curve, never the limb geometry — so we build
850    /// the struct directly to avoid needing a kv6.
851    fn anim_sprite(
852        child_vmin: i16,
853        child_vmax: i16,
854        frmval: Vec<Vec<i16>>,
855        seq: Vec<Seq>,
856    ) -> KfaSprite {
857        let zero = Point3 {
858            x: 0.0,
859            y: 0.0,
860            z: 0.0,
861        };
862        let axis = Point3 {
863            x: 1.0,
864            y: 0.0,
865            z: 0.0,
866        };
867        let hinges = vec![
868            Hinge {
869                parent: -1,
870                p: [zero, zero],
871                v: [axis, axis],
872                vmin: 0,
873                vmax: 0,
874                htype: 0,
875                filler: [0; 7],
876            },
877            Hinge {
878                parent: 0,
879                p: [zero, zero],
880                v: [axis, axis],
881                vmin: child_vmin,
882                vmax: child_vmax,
883                htype: 0,
884                filler: [0; 7],
885            },
886        ];
887        // Tests author keyframes as Q15 angles; migrate them to rotation-only
888        // BoneXforms about each bone's hinge axis (the runtime model is TRS).
889        let frmval: Vec<Vec<BoneXform>> = frmval
890            .into_iter()
891            .map(|row| {
892                row.into_iter()
893                    .enumerate()
894                    .map(|(b, a)| {
895                        let v = hinges[b].v[0];
896                        BoneXform::from_hinge_angle([v.x, v.y, v.z], a)
897                    })
898                    .collect()
899            })
900            .collect();
901        KfaSprite {
902            limbs: Vec::new(),
903            hinge_sort: sort_hinges(&hinges),
904            kfaval: vec![BoneXform::IDENTITY; hinges.len()],
905            hinges,
906            p: [0.0; 3],
907            s: [1.0, 0.0, 0.0],
908            h: [0.0, 1.0, 0.0],
909            f: [0.0, 0.0, 1.0],
910            frmval,
911            seq,
912            kfatim: 0,
913            okfatim: 0,
914        }
915    }
916
917    /// Recover bone `i`'s Q15 hinge angle about the test axis (`+x`) from its
918    /// resolved `kfaval` — the inverse of how the helper builds keyframes.
919    fn angle_of(kfa: &KfaSprite, i: usize) -> i16 {
920        kfa.kfaval[i].hinge_angle([1.0, 0.0, 0.0])
921    }
922
923    /// Half-way through a single 0→16384 segment a free hinge sits at
924    /// exactly 8192, and the root bone is left untouched.
925    #[test]
926    fn animsprite_lerps_free_hinge_midpoint() {
927        // Free hinge: vmin == vmax.
928        let mut kfa = anim_sprite(
929            0,
930            0,
931            vec![vec![0, 0], vec![0, 16384]],
932            vec![Seq { tim: 0, frm: 0 }, Seq { tim: 1000, frm: 1 }],
933        );
934        kfa.animsprite(500);
935        assert_eq!(kfa.kfatim, 500, "time cursor advanced by ti");
936        assert_eq!(angle_of(&kfa, 0), 0, "root bone untouched");
937        // nlerp at t=0.5 of two same-axis rotations is exact, so the midpoint
938        // is still 8192 (45°).
939        assert!(
940            (i32::from(angle_of(&kfa, 1)) - 8192).abs() <= 2,
941            "child at midpoint"
942        );
943    }
944
945    /// A free hinge interpolating 30000 → -30000 takes the *short* way
946    /// (through ±32768), not the long way through 0 — so the midpoint
947    /// lands at the wrap boundary, not near 0.
948    #[test]
949    fn animsprite_free_hinge_takes_shortest_wrap() {
950        let mut kfa = anim_sprite(
951            0,
952            0,
953            vec![vec![0, 30000], vec![0, -30000]],
954            vec![Seq { tim: 0, frm: 0 }, Seq { tim: 1000, frm: 1 }],
955        );
956        kfa.animsprite(500);
957        // nlerp takes the short arc (the quaternions are flipped to the same
958        // hemisphere), so the midpoint lands at the ±180° wrap, not near 0.
959        assert!(
960            i32::from(angle_of(&kfa, 1)).abs() >= 32000,
961            "midpoint at the wrap"
962        );
963    }
964
965    /// `seq[].frm < 0` is a `!target` jump: advancing time past the
966    /// jump entry loops back to `target` and keeps consuming `ti`.
967    #[test]
968    fn animsprite_follows_loop_jump_entry() {
969        let mut kfa = anim_sprite(
970            0,
971            0,
972            vec![vec![0, 0], vec![0, 16384]],
973            vec![
974                Seq { tim: 0, frm: 0 },
975                Seq { tim: 1000, frm: 1 },
976                // Jump back to seq entry 0 (== !0 == -1).
977                Seq { tim: 2000, frm: !0 },
978            ],
979        );
980        // 2500 ms: 0→1000 (seg 0), 1000→2000 hits the jump → loop to 0,
981        // then 500 ms more into the first segment again.
982        kfa.animsprite(2500);
983        assert_eq!(kfa.kfatim, 500, "looped back and advanced 500 ms");
984    }
985
986    /// With no curve attached, animsprite leaves kfaval alone so hosts
987    /// that drive kfaval[] directly are unaffected.
988    #[test]
989    fn animsprite_no_curve_is_noop() {
990        let mut kfa = anim_sprite(0, 0, Vec::new(), Vec::new());
991        kfa.kfaval[1] = BoneXform::from_hinge_angle([1.0, 0.0, 0.0], 1234);
992        kfa.animsprite(500);
993        assert_eq!(angle_of(&kfa, 1), 1234);
994        assert_eq!(kfa.kfatim, 0);
995    }
996
997    #[test]
998    fn kfatime2seq_brackets_from_below() {
999        let seq = vec![
1000            Seq { tim: 0, frm: 0 },
1001            Seq { tim: 100, frm: 1 },
1002            Seq { tim: 200, frm: 2 },
1003            Seq { tim: 300, frm: 3 },
1004        ];
1005        assert_eq!(kfatime2seq(&seq, 0), 0);
1006        assert_eq!(kfatime2seq(&seq, 99), 0);
1007        assert_eq!(kfatime2seq(&seq, 100), 1);
1008        assert_eq!(kfatime2seq(&seq, 250), 2);
1009        // Never returns the final index: the last entry is always the
1010        // *upper* bracket, so beyond it we stay on the last segment.
1011        assert_eq!(kfatime2seq(&seq, 9999), 2, "last segment's lower bracket");
1012    }
1013
1014    // ---- QE.6b adversarial skeletons: error, never panic/hang ----
1015
1016    fn hinge_with_parent(parent: i32) -> Hinge {
1017        let p = Point3 {
1018            x: 0.0,
1019            y: 0.0,
1020            z: 0.0,
1021        };
1022        Hinge {
1023            parent,
1024            p: [p, p],
1025            v: [p, p],
1026            vmin: 0,
1027            vmax: 0,
1028            htype: 0,
1029            filler: [0; 7],
1030        }
1031    }
1032
1033    fn kfa_with_hinges(hinges: Vec<Hinge>) -> Vec<u8> {
1034        serialize(&Kfa {
1035            kv6_name: b"x.kv6".to_vec(),
1036            hinges,
1037            frmval: Vec::new(),
1038            seq: Vec::new(),
1039        })
1040    }
1041
1042    #[test]
1043    fn parse_rejects_out_of_range_hinge_parent() {
1044        // Pre-QE.6b: an OOB panic later, in sort_hinges.
1045        let bytes = kfa_with_hinges(vec![hinge_with_parent(5)]);
1046        assert!(matches!(
1047            parse(&bytes),
1048            Err(ParseError::BadHingeParent {
1049                hinge: 0,
1050                parent: 5
1051            })
1052        ));
1053        let bytes = kfa_with_hinges(vec![hinge_with_parent(-2)]);
1054        assert!(matches!(
1055            parse(&bytes),
1056            Err(ParseError::BadHingeParent { .. })
1057        ));
1058    }
1059
1060    #[test]
1061    fn parse_rejects_cyclic_hinge_parents() {
1062        // Pre-QE.6b: the loader hung forever (sort_hinges never
1063        // converged) - a denial-of-service on load.
1064        let bytes = kfa_with_hinges(vec![hinge_with_parent(1), hinge_with_parent(0)]);
1065        assert!(matches!(parse(&bytes), Err(ParseError::HingeCycle { .. })));
1066        // Self-parent is the 1-cycle.
1067        let bytes = kfa_with_hinges(vec![hinge_with_parent(0)]);
1068        assert!(matches!(parse(&bytes), Err(ParseError::HingeCycle { .. })));
1069    }
1070
1071    #[test]
1072    fn parse_survives_absurd_counts_without_alloc_bomb() {
1073        // A tiny buffer claiming u32::MAX hinges must fail as
1074        // Truncated - not attempt a ~275 GiB Vec::with_capacity.
1075        let mut bytes = Vec::new();
1076        bytes.extend_from_slice(&MAGIC.to_le_bytes());
1077        bytes.extend_from_slice(&0u32.to_le_bytes()); // name_len
1078        bytes.extend_from_slice(&u32::MAX.to_le_bytes()); // numhin
1079        assert!(matches!(parse(&bytes), Err(ParseError::Truncated { .. })));
1080    }
1081}