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};
43
44const MAGIC: u32 = 0x6b6c_774b; // "Kwlk" little-endian
45const HINGE_SIZE: usize = 64;
46const SEQ_SIZE: usize = 8;
47
48/// 3D point (`point3d` in voxlaptest), 12 bytes packed.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Point3 {
51    pub x: f32,
52    pub y: f32,
53    pub z: f32,
54}
55
56/// One hinge / joint definition (`hingetype` in voxlaptest).
57#[derive(Debug, Clone, Copy)]
58pub struct Hinge {
59    /// Index of the parent hinge in the same `Kfa`, or `-1` for none.
60    pub parent: i32,
61    /// Anchor ("velcro") points — `p[0]` on this object, `p[1]` on the
62    /// parent.
63    pub p: [Point3; 2],
64    /// Rotation axes — same convention as `p`.
65    pub v: [Point3; 2],
66    pub vmin: i16,
67    pub vmax: i16,
68    pub htype: u8,
69    /// Trailing 7 bytes of padding inside the on-disk struct. Stored
70    /// verbatim so byte-equal round-trip survives — files in the wild
71    /// may carry non-zero bytes here.
72    pub filler: [u8; 7],
73}
74
75/// One animation sequence entry (`seqtyp` in voxlaptest).
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Seq {
78    pub tim: i32,
79    pub frm: i32,
80}
81
82/// Parsed `.kfa` file. Round-trips byte-equally via [`parse`] +
83/// [`serialize`].
84#[derive(Debug, Clone)]
85pub struct Kfa {
86    /// Associated `.kv6` filename (raw bytes, no NUL terminator). Voxlap
87    /// uses this to locate the rigged kv6 model.
88    pub kv6_name: Vec<u8>,
89    pub hinges: Vec<Hinge>,
90    /// `frmval[frame_idx][hinge_idx]` — outer length is `numfrm`,
91    /// inner length must equal `hinges.len()` for every frame.
92    pub frmval: Vec<Vec<i16>>,
93    pub seq: Vec<Seq>,
94}
95
96/// Errors returned by [`parse`].
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ParseError {
99    /// First 4 bytes are not the `0x6b6c774b` magic.
100    BadMagic { got: u32 },
101    /// A read of `need` bytes at offset `at` would run past EOF.
102    Truncated { at: usize, need: usize },
103}
104
105impl fmt::Display for ParseError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match *self {
108            Self::BadMagic { got } => {
109                write!(f, "kfa bad magic: got {got:#010x}, expected 0x6b6c774b")
110            }
111            Self::Truncated { at, need } => {
112                write!(f, "kfa truncated: need {need} bytes at offset {at}")
113            }
114        }
115    }
116}
117
118impl std::error::Error for ParseError {}
119
120impl From<OutOfBounds> for ParseError {
121    fn from(e: OutOfBounds) -> Self {
122        Self::Truncated {
123            at: e.at,
124            need: e.need,
125        }
126    }
127}
128
129/// Parse a `.kfa` file's bytes into a [`Kfa`].
130///
131/// # Errors
132///
133/// Returns [`ParseError`] if the magic mismatches or a sequential read
134/// for any header / hinge / frmval / seq region runs past EOF.
135pub fn parse(bytes: &[u8]) -> Result<Kfa, ParseError> {
136    let mut cur = Cursor::new(bytes);
137    let magic = cur.read_u32()?;
138    if magic != MAGIC {
139        return Err(ParseError::BadMagic { got: magic });
140    }
141
142    let name_len = cur.read_u32()? as usize;
143    let kv6_name = cur.read_bytes(name_len)?.to_vec();
144
145    let numhin = cur.read_u32()? as usize;
146    let mut hinges = Vec::with_capacity(numhin);
147    for _ in 0..numhin {
148        hinges.push(read_hinge(&mut cur)?);
149    }
150
151    let numfrm = cur.read_u32()? as usize;
152    let mut frmval = Vec::with_capacity(numfrm);
153    for _ in 0..numfrm {
154        let mut row = Vec::with_capacity(numhin);
155        for _ in 0..numhin {
156            row.push(cur.read_i16()?);
157        }
158        frmval.push(row);
159    }
160
161    let seqnum = cur.read_u32()? as usize;
162    let mut seq = Vec::with_capacity(seqnum);
163    for _ in 0..seqnum {
164        let tim = cur.read_i32()?;
165        let frm = cur.read_i32()?;
166        seq.push(Seq { tim, frm });
167    }
168
169    Ok(Kfa {
170        kv6_name,
171        hinges,
172        frmval,
173        seq,
174    })
175}
176
177/// Serialise a [`Kfa`] back to bytes. Round-trips byte-equally with
178/// the input that produced this `Kfa` via [`parse`].
179///
180/// # Panics
181///
182/// Panics if `kv6_name.len()`, `hinges.len()`, `frmval.len()`, or
183/// `seq.len()` does not fit in a `u32` (the on-disk format stores
184/// these as `u32`), or if `frmval` is not rectangular (every inner
185/// row's length must equal `hinges.len()`). `Kfa` values produced by
186/// [`parse`] always satisfy these invariants.
187#[must_use]
188pub fn serialize(kfa: &Kfa) -> Vec<u8> {
189    let numhin = kfa.hinges.len();
190    for (i, row) in kfa.frmval.iter().enumerate() {
191        assert!(
192            row.len() == numhin,
193            "kfa frmval[{}].len() = {}, expected numhin = {}",
194            i,
195            row.len(),
196            numhin,
197        );
198    }
199    let name_len = u32::try_from(kfa.kv6_name.len()).expect("kv6_name length must fit in u32");
200    let numhin_u32 = u32::try_from(numhin).expect("numhin must fit in u32");
201    let numfrm_u32 = u32::try_from(kfa.frmval.len()).expect("numfrm must fit in u32");
202    let seqnum_u32 = u32::try_from(kfa.seq.len()).expect("seqnum must fit in u32");
203
204    let total = 4
205        + 4
206        + kfa.kv6_name.len()
207        + 4
208        + numhin * HINGE_SIZE
209        + 4
210        + (kfa.frmval.len() * numhin) * 2
211        + 4
212        + kfa.seq.len() * SEQ_SIZE;
213    let mut out = Vec::with_capacity(total);
214
215    out.extend_from_slice(&MAGIC.to_le_bytes());
216    out.extend_from_slice(&name_len.to_le_bytes());
217    out.extend_from_slice(&kfa.kv6_name);
218
219    out.extend_from_slice(&numhin_u32.to_le_bytes());
220    for h in &kfa.hinges {
221        write_hinge(&mut out, h);
222    }
223
224    out.extend_from_slice(&numfrm_u32.to_le_bytes());
225    for row in &kfa.frmval {
226        for v in row {
227            out.extend_from_slice(&v.to_le_bytes());
228        }
229    }
230
231    out.extend_from_slice(&seqnum_u32.to_le_bytes());
232    for s in &kfa.seq {
233        out.extend_from_slice(&s.tim.to_le_bytes());
234        out.extend_from_slice(&s.frm.to_le_bytes());
235    }
236
237    out
238}
239
240// --- internal helpers ---------------------------------------------------
241
242fn read_point3(cur: &mut Cursor<'_>) -> Result<Point3, OutOfBounds> {
243    let x = cur.read_f32()?;
244    let y = cur.read_f32()?;
245    let z = cur.read_f32()?;
246    Ok(Point3 { x, y, z })
247}
248
249fn write_point3(out: &mut Vec<u8>, p: Point3) {
250    out.extend_from_slice(&p.x.to_le_bytes());
251    out.extend_from_slice(&p.y.to_le_bytes());
252    out.extend_from_slice(&p.z.to_le_bytes());
253}
254
255fn read_hinge(cur: &mut Cursor<'_>) -> Result<Hinge, OutOfBounds> {
256    let parent = cur.read_i32()?;
257    let p0 = read_point3(cur)?;
258    let p1 = read_point3(cur)?;
259    let v0 = read_point3(cur)?;
260    let v1 = read_point3(cur)?;
261    let vmin = cur.read_i16()?;
262    let vmax = cur.read_i16()?;
263    let htype = cur.read_u8()?;
264    let filler_buf = cur.read_bytes(7)?;
265    let mut filler = [0u8; 7];
266    filler.copy_from_slice(filler_buf);
267    Ok(Hinge {
268        parent,
269        p: [p0, p1],
270        v: [v0, v1],
271        vmin,
272        vmax,
273        htype,
274        filler,
275    })
276}
277
278fn write_hinge(out: &mut Vec<u8>, h: &Hinge) {
279    out.extend_from_slice(&h.parent.to_le_bytes());
280    write_point3(out, h.p[0]);
281    write_point3(out, h.p[1]);
282    write_point3(out, h.v[0]);
283    write_point3(out, h.v[1]);
284    out.extend_from_slice(&h.vmin.to_le_bytes());
285    out.extend_from_slice(&h.vmax.to_le_bytes());
286    out.push(h.htype);
287    out.extend_from_slice(&h.filler);
288}
289
290// --- KFA sprite (host-facing scene type) --------------------------------
291
292/// One animated KFA sprite — bones + hinges + per-bone live
293/// animation values.
294///
295/// The host owns one of these per animated model, updates `kfaval[]`
296/// over time, and passes it to roxlap-core's `draw_kfa_sprite` each
297/// frame. Construction is data-only (this crate); rendering is in
298/// `roxlap-core`.
299#[derive(Clone)]
300pub struct KfaSprite {
301    /// One [`crate::sprite::Sprite`] per bone. Limb `i`'s
302    /// `(s, h, f, p)` is computed per frame by the renderer from
303    /// the parent's transform + hinge math; the `kv6` field holds
304    /// the bone's kv6 mesh and never changes.
305    pub limbs: Vec<crate::sprite::Sprite>,
306    /// Bone hierarchy. Mirror of voxlap's `kfatype.hinge[]`.
307    pub hinges: Vec<Hinge>,
308    /// Topological sort of bone indices — populated once at
309    /// construction, used by the renderer's per-frame loop.
310    pub hinge_sort: Vec<usize>,
311    /// Per-bone animation value. Voxlap's `vx5.kfaval[]`. Q15
312    /// angle (full circle = 65536). Host updates per frame.
313    pub kfaval: Vec<i16>,
314    /// World-space anchor of the root limb's `hinge.p[0]`. The
315    /// root limb is positioned so `hinge.p[0]` lands at this
316    /// point given the world basis below.
317    pub p: [f32; 3],
318    /// World-space basis for the root limb. Mirror of
319    /// `vx5sprite.{s, h, f}` for the root.
320    pub s: [f32; 3],
321    pub h: [f32; 3],
322    pub f: [f32; 3],
323}
324
325impl KfaSprite {
326    /// Build a KFA sprite from a list of `(Sprite, Hinge)` bones.
327    /// `limbs.len()` must equal `hinges.len()`. The first bone with
328    /// `parent < 0` is the root.
329    ///
330    /// `kfaval` is initialised to all zeros; the host should set
331    /// per-bone angles before / between render calls.
332    ///
333    /// # Panics
334    ///
335    /// Panics if `limbs.len() != hinges.len()`.
336    #[must_use]
337    pub fn new(limbs: Vec<crate::sprite::Sprite>, hinges: Vec<Hinge>, root_pos: [f32; 3]) -> Self {
338        assert_eq!(
339            limbs.len(),
340            hinges.len(),
341            "limbs ({}) and hinges ({}) length mismatch",
342            limbs.len(),
343            hinges.len()
344        );
345        let n = hinges.len();
346        let hinge_sort = sort_hinges(&hinges);
347        Self {
348            limbs,
349            hinges,
350            hinge_sort,
351            kfaval: vec![0i16; n],
352            p: root_pos,
353            s: [1.0, 0.0, 0.0],
354            h: [0.0, 1.0, 0.0],
355            f: [0.0, 0.0, 1.0],
356        }
357    }
358}
359
360/// Build the hinge-sort order — voxlap's `kfasorthinge`
361/// (`voxlap5.c:9427-9450`). The result is an array of hinge
362/// indices ordered such that **walking from index `n-1` down to
363/// 0** visits parents before children — a valid topological order
364/// for the chain of `setlimb` calls in voxlap's `kfadraw`.
365///
366/// Voxlap mutates the hinges in place during sort and restores
367/// them; this port produces the same `hsort` array without
368/// touching the input.
369#[must_use]
370#[allow(clippy::cast_sign_loss)] // parent >= 0 checked immediately above
371pub fn sort_hinges(hinges: &[Hinge]) -> Vec<usize> {
372    let n = hinges.len();
373    let mut hsort = vec![0usize; n];
374    // First pass: roots at the end, non-roots at the start.
375    let mut head = 0usize;
376    let mut tail = n;
377    for i in (0..n).rev() {
378        if hinges[i].parent < 0 {
379            tail -= 1;
380            hsort[tail] = i;
381        } else {
382            hsort[head] = i;
383            head += 1;
384        }
385    }
386
387    // `solved[h]` = true once hinge h's parent has been settled
388    // into the "tail" half. Voxlap encodes this in-place by
389    // flipping the parent field to -2-parent; we use a side
390    // bitmap to leave the input immutable.
391    let mut solved = vec![false; n];
392    for i in (tail..n).rev() {
393        solved[hsort[i]] = true;
394    }
395
396    // Iterative pass: pick non-root entries in head whose parent
397    // is already solved; move them to the tail.
398    let mut idx = head; // idx walks the head [0..head) backward
399    while tail > 0 {
400        if idx == 0 {
401            idx = head;
402        }
403        idx -= 1;
404        let j = hsort[idx];
405        let parent = hinges[j].parent;
406        if parent < 0 {
407            // Already in the tail (shouldn't happen since the
408            // first pass sorted these out).
409            continue;
410        }
411        if solved[parent as usize] {
412            solved[j] = true;
413            tail -= 1;
414            hsort[idx] = hsort[tail];
415            hsort[tail] = j;
416            head -= 1;
417        }
418        if head == 0 {
419            break;
420        }
421    }
422    hsort
423}
424
425// --- tests --------------------------------------------------------------
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn synthetic_kfa() -> Kfa {
432        Kfa {
433            kv6_name: b"anasaur.kv6".to_vec(),
434            hinges: vec![
435                Hinge {
436                    parent: -1,
437                    p: [
438                        Point3 {
439                            x: 0.0,
440                            y: 0.0,
441                            z: 0.0,
442                        },
443                        Point3 {
444                            x: 1.0,
445                            y: 0.0,
446                            z: 0.0,
447                        },
448                    ],
449                    v: [
450                        Point3 {
451                            x: 0.0,
452                            y: 1.0,
453                            z: 0.0,
454                        },
455                        Point3 {
456                            x: 0.0,
457                            y: 0.0,
458                            z: 1.0,
459                        },
460                    ],
461                    vmin: -180,
462                    vmax: 180,
463                    htype: 0,
464                    filler: [0; 7],
465                },
466                Hinge {
467                    parent: 0,
468                    p: [
469                        Point3 {
470                            x: 0.5,
471                            y: 0.0,
472                            z: 0.0,
473                        },
474                        Point3 {
475                            x: 0.5,
476                            y: 1.0,
477                            z: 0.0,
478                        },
479                    ],
480                    v: [
481                        Point3 {
482                            x: 1.0,
483                            y: 0.0,
484                            z: 0.0,
485                        },
486                        Point3 {
487                            x: 0.0,
488                            y: 1.0,
489                            z: 0.0,
490                        },
491                    ],
492                    vmin: -90,
493                    vmax: 90,
494                    htype: 1,
495                    // Non-zero filler tests round-trip preservation.
496                    filler: [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba],
497                },
498            ],
499            frmval: vec![vec![0, 0], vec![45, -30], vec![90, -60], vec![135, -90]],
500            seq: vec![
501                Seq { tim: 0, frm: 0 },
502                Seq { tim: 100, frm: 1 },
503                Seq { tim: 200, frm: 2 },
504                Seq { tim: 300, frm: 3 },
505            ],
506        }
507    }
508
509    #[test]
510    fn synthetic_roundtrips_byte_equal() {
511        let kfa = synthetic_kfa();
512        let bytes = serialize(&kfa);
513        let parsed = parse(&bytes).expect("parse synthetic");
514        let bytes2 = serialize(&parsed);
515        assert_eq!(bytes, bytes2, "byte-level round-trip failed");
516        // Spot-check the structural round-trip too.
517        assert_eq!(parsed.kv6_name, kfa.kv6_name);
518        assert_eq!(parsed.hinges.len(), kfa.hinges.len());
519        assert_eq!(parsed.frmval, kfa.frmval);
520        assert_eq!(parsed.seq, kfa.seq);
521    }
522
523    #[test]
524    fn hinge_size_matches_voxlap_packed_layout() {
525        // 4 (parent) + 24 (p[2]) + 24 (v[2]) + 2 (vmin) + 2 (vmax)
526        //   + 1 (htype) + 7 (filler) = 64.
527        assert_eq!(HINGE_SIZE, 64);
528        // And we serialise exactly that many bytes per hinge.
529        let kfa = synthetic_kfa();
530        let bytes = serialize(&kfa);
531        // 4 magic + 4 name_len + 11 name + 4 numhin = 23 bytes header.
532        let header = 4 + 4 + kfa.kv6_name.len() + 4;
533        let after_hinges = header + kfa.hinges.len() * HINGE_SIZE;
534        // Re-parse and verify the second hinge's filler matches what we set.
535        let parsed = parse(&bytes).expect("parse synthetic");
536        assert_eq!(
537            parsed.hinges[1].filler,
538            [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba]
539        );
540        // Sanity: total size must include numfrm field after hinges.
541        assert!(bytes.len() > after_hinges + 4);
542    }
543
544    #[test]
545    fn parse_bad_magic_fails() {
546        let mut bytes = serialize(&synthetic_kfa());
547        bytes[0] ^= 0xff;
548        let r = parse(&bytes);
549        assert!(matches!(r, Err(ParseError::BadMagic { .. })));
550    }
551
552    #[test]
553    fn parse_truncated_in_hinge_table_fails() {
554        let bytes = serialize(&synthetic_kfa());
555        // Truncate inside the first hinge.
556        let truncated = &bytes[..30];
557        let r = parse(truncated);
558        assert!(matches!(r, Err(ParseError::Truncated { .. })));
559    }
560
561    /// `sort_hinges` puts roots at high indices and children at low.
562    /// 3-bone chain: root → child1 → child2.
563    #[test]
564    #[allow(clippy::cast_sign_loss)] // p >= 0 checked at the assert site
565    fn sort_hinges_three_bone_chain() {
566        let axis = |x: f32, y: f32, z: f32| Point3 { x, y, z };
567        let h = |parent: i32| Hinge {
568            parent,
569            p: [axis(0.0, 0.0, 0.0); 2],
570            v: [axis(1.0, 0.0, 0.0); 2],
571            vmin: 0,
572            vmax: 0,
573            htype: 0,
574            filler: [0; 7],
575        };
576        // hinge[0] = root, hinge[1] child of 0, hinge[2] child of 1.
577        let hinges = vec![h(-1), h(0), h(1)];
578        let sort = sort_hinges(&hinges);
579        // Walking sort[i] for i=n-1..=0 must visit each bone's parent
580        // before the bone itself.
581        let mut seen = [false; 3];
582        for k in (0..3).rev() {
583            let j = sort[k];
584            seen[j] = true;
585            let p = hinges[j].parent;
586            if p >= 0 {
587                assert!(
588                    seen[p as usize],
589                    "bone {j}'s parent {p} not yet visited at descent step k={k}"
590                );
591            }
592        }
593    }
594}