Skip to main content

roxlap_formats/
kfa.rs

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