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