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 /// Animation keyframe table — `frmval[frame][hinge]`, Q15 angles.
324 /// Mirror of `kfatype.frmval`. Empty until [`Self::set_animation`];
325 /// an empty table makes [`Self::animsprite`] a no-op so hosts that
326 /// poke [`kfaval`](Self::kfaval) directly keep working.
327 pub frmval: Vec<Vec<i16>>,
328 /// Animation sequence — ordered `(tim, frm)` keyframes. Mirror of
329 /// `kfatype.seq`. `tim` is an absolute timestamp (ms); `frm` is a
330 /// frame index into [`frmval`](Self::frmval), or `!target`
331 /// (bitwise-NOT, hence negative) for a jump/loop to seq entry
332 /// `target`.
333 pub seq: Vec<Seq>,
334 /// Current animation time (ms) — voxlap's `vx5sprite.kfatim`.
335 /// Advanced by [`Self::animsprite`].
336 pub kfatim: i32,
337 /// Previous animation time (ms) — voxlap's `vx5sprite.okfatim`,
338 /// used to cross-fade when the active sequence entry is itself a
339 /// blend marker (`seq[z].frm < 0`). Host sets it when switching
340 /// animations; [`Self::animsprite`] never writes it.
341 pub okfatim: i32,
342}
343
344impl KfaSprite {
345 /// Build a KFA sprite from a list of `(Sprite, Hinge)` bones.
346 /// `limbs.len()` must equal `hinges.len()`. The first bone with
347 /// `parent < 0` is the root.
348 ///
349 /// `kfaval` is initialised to all zeros; the host should set
350 /// per-bone angles before / between render calls.
351 ///
352 /// # Panics
353 ///
354 /// Panics if `limbs.len() != hinges.len()`.
355 #[must_use]
356 pub fn new(limbs: Vec<crate::sprite::Sprite>, hinges: Vec<Hinge>, root_pos: [f32; 3]) -> Self {
357 assert_eq!(
358 limbs.len(),
359 hinges.len(),
360 "limbs ({}) and hinges ({}) length mismatch",
361 limbs.len(),
362 hinges.len()
363 );
364 let n = hinges.len();
365 let hinge_sort = sort_hinges(&hinges);
366 Self {
367 limbs,
368 hinges,
369 hinge_sort,
370 kfaval: vec![0i16; n],
371 p: root_pos,
372 s: [1.0, 0.0, 0.0],
373 h: [0.0, 1.0, 0.0],
374 f: [0.0, 0.0, 1.0],
375 frmval: Vec::new(),
376 seq: Vec::new(),
377 kfatim: 0,
378 okfatim: 0,
379 }
380 }
381
382 /// Attach an animation curve — the `frmval` + `seq` tables parsed
383 /// from a [`Kfa`]. After this, [`Self::animsprite`] drives
384 /// [`kfaval`](Self::kfaval) from playback time instead of the host
385 /// poking individual bones.
386 pub fn set_animation(&mut self, frmval: Vec<Vec<i16>>, seq: Vec<Seq>) {
387 self.frmval = frmval;
388 self.seq = seq;
389 }
390
391 /// Advance the animation by `ti` milliseconds and recompute every
392 /// child bone's [`kfaval`](Self::kfaval) — a faithful port of
393 /// voxlap's `animsprite` (`voxlap5.c:11125`).
394 ///
395 /// Walks the sequence forward from the current
396 /// [`kfatim`](Self::kfatim) (honouring `!target` jump/loop
397 /// entries), then piecewise-linearly interpolates the two bracketing
398 /// keyframes per hinge. Interpolation is angle-wrap-aware: a free
399 /// hinge (`vmin == vmax`) takes the shortest path, a limited hinge
400 /// winds in its allowed direction. When the active entry is itself a
401 /// blend marker (`seq[z].frm < 0`), the pose cross-fades from the
402 /// [`okfatim`](Self::okfatim)-derived frame.
403 ///
404 /// No-op when no animation curve is attached (see
405 /// [`Self::set_animation`]).
406 #[allow(
407 clippy::cast_possible_truncation,
408 clippy::cast_possible_wrap,
409 clippy::cast_sign_loss,
410 clippy::similar_names
411 )]
412 pub fn animsprite(&mut self, mut ti: i32) {
413 if self.seq.is_empty() || self.frmval.is_empty() {
414 return;
415 }
416 let numhin = self.hinges.len();
417 let seqnum = self.seq.len();
418
419 // Phase 1 — advance kfatim by `ti` ms through the sequence,
420 // following `!target` jump entries (voxlap5.c:11133-11143).
421 let mut z = kfatime2seq(&self.seq, self.kfatim) as i32;
422 while ti > 0 {
423 z += 1;
424 if z as usize >= seqnum {
425 break;
426 }
427 let dt = self.seq[z as usize].tim - self.kfatim;
428 if dt <= 0 {
429 break;
430 }
431 if dt > ti {
432 self.kfatim += ti;
433 break;
434 }
435 ti -= dt;
436 let jump = !self.seq[z as usize].frm; // ~frm
437 if jump >= 0 {
438 if z == jump {
439 break;
440 }
441 z = jump;
442 }
443 self.kfatim = self.seq[z as usize].tim;
444 }
445
446 // Phase 2 — resolve the bracketing frames + 16.16 blend ratios
447 // for the current segment (voxlap5.c:11147-11167).
448 let z_seq = kfatime2seq(&self.seq, self.kfatim);
449 let zz_idx = z_seq + 1;
450 let (trat, zz_frm) = if zz_idx < seqnum && self.seq[zz_idx].frm != !(zz_idx as i32) {
451 let span = self.seq[zz_idx].tim - self.seq[z_seq].tim;
452 let trat = if span != 0 {
453 shldiv16(self.kfatim - self.seq[z_seq].tim, span)
454 } else {
455 0
456 };
457 let i = self.seq[zz_idx].frm;
458 let zz_frm = if i < 0 {
459 self.seq[(!i) as usize].frm
460 } else {
461 i
462 };
463 (trat, zz_frm)
464 } else {
465 (0, 0)
466 };
467
468 let z_frm = self.seq[z_seq].frm;
469 // trat2 < 0 signals "no okfatim cross-fade" (the common path).
470 let mut trat2 = -1i32;
471 let mut z0_frm = 0i32;
472 let mut zz0_frm = 0i32;
473 if z_frm < 0 {
474 let z0_seq = kfatime2seq(&self.seq, self.okfatim);
475 let zz0_idx = z0_seq + 1;
476 if zz0_idx < seqnum && self.seq[zz0_idx].frm != !(zz0_idx as i32) {
477 let span = self.seq[zz0_idx].tim - self.seq[z0_seq].tim;
478 trat2 = if span != 0 {
479 shldiv16(self.okfatim - self.seq[z0_seq].tim, span)
480 } else {
481 0
482 };
483 let i = self.seq[zz0_idx].frm;
484 zz0_frm = if i < 0 {
485 self.seq[(!i) as usize].frm
486 } else {
487 i
488 };
489 } else {
490 trat2 = 0;
491 }
492 z0_frm = self.seq[z0_seq].frm;
493 if z0_frm < 0 {
494 z0_frm = zz0_frm;
495 trat2 = 0;
496 }
497 }
498
499 // Phase 3 — per-hinge interpolation into kfaval
500 // (voxlap5.c:11169-11195). Root bones (parent < 0) keep their
501 // value untouched, exactly as voxlap's `continue`.
502 for i in (0..numhin).rev() {
503 if self.hinges[i].parent < 0 {
504 continue;
505 }
506 let vmin = i32::from(self.hinges[i].vmin);
507 let vmax = i32::from(self.hinges[i].vmax);
508
509 let mut frm0: i32 = if trat2 < 0 {
510 i32::from(self.frmval[z_frm as usize][i])
511 } else {
512 let mut base = i32::from(self.frmval[z0_frm as usize][i]);
513 if trat2 > 0 {
514 let target = i32::from(self.frmval[zz0_frm as usize][i]);
515 base += interp_delta(base, target, vmin, vmax, trat2);
516 }
517 base
518 };
519 if trat > 0 {
520 let target = i32::from(self.frmval[zz_frm as usize][i]);
521 frm0 += interp_delta(frm0, target, vmin, vmax, trat);
522 }
523 // `vx5.kfaval[]` is `short`; the assignment truncates to the
524 // low 16 bits, which `as i16` reproduces.
525 self.kfaval[i] = frm0 as i16;
526 }
527 }
528}
529
530/// 16.16 fixed-point signed multiply-shift — voxlap's `mulshr16`
531/// (`voxlap5.c:276`): `((i64)a * d) >> 16`, low 32 bits.
532#[inline]
533#[allow(clippy::cast_possible_truncation)]
534fn mulshr16(a: i32, d: i32) -> i32 {
535 ((i64::from(a) * i64::from(d)) >> 16) as i32
536}
537
538/// 16.16 fixed-point signed shift-divide — voxlap's `shldiv16`
539/// (`voxlap5.c:296`): `((i64)a << 16) / b`, truncating toward zero
540/// (matching x86 `idiv`).
541#[inline]
542#[allow(clippy::cast_possible_truncation)]
543fn shldiv16(a: i32, b: i32) -> i32 {
544 ((i64::from(a) << 16) / i64::from(b)) as i32
545}
546
547/// Binary-search the seq entry whose `tim` brackets `tim` from below —
548/// voxlap's `kfatime2seq` (`voxlap5.c`). Returns the index `a` such
549/// that `seq[a].tim <= tim < seq[a+1].tim` (clamped to the ends).
550/// Caller guarantees `seq` is non-empty.
551#[allow(
552 clippy::cast_possible_truncation,
553 clippy::cast_possible_wrap,
554 clippy::cast_sign_loss
555)]
556fn kfatime2seq(seq: &[Seq], tim: i32) -> usize {
557 let mut a: isize = 0;
558 let mut b: isize = seq.len() as isize - 1;
559 while b - a >= 2 {
560 let i = (a + b) >> 1;
561 if tim >= seq[i as usize].tim {
562 a = i;
563 } else {
564 b = i;
565 }
566 }
567 a as usize
568}
569
570/// One keyframe-pair angular interpolation step — the shared body of
571/// voxlap's two interp blocks in `animsprite`. Returns the delta to add
572/// to `from` to step `trat`/65536 of the way toward `to`, choosing the
573/// winding direction the way voxlap does: shortest path for a free
574/// hinge (`vmin == vmax`), else winding consistent with `vmin`.
575#[inline]
576fn interp_delta(from: i32, to: i32, vmin: i32, vmax: i32, trat: i32) -> i32 {
577 let mut x = (to - from) & 65535;
578 if vmin == vmax {
579 // Sign-extend the 16-bit delta → shortest angular path.
580 x = (x << 16) >> 16;
581 } else if ((to - vmin) & 65535) < ((from - vmin) & 65535) {
582 x -= 65536;
583 }
584 mulshr16(x, trat)
585}
586
587/// Build the hinge-sort order — voxlap's `kfasorthinge`
588/// (`voxlap5.c:9427-9450`). The result is an array of hinge
589/// indices ordered such that **walking from index `n-1` down to
590/// 0** visits parents before children — a valid topological order
591/// for the chain of `setlimb` calls in voxlap's `kfadraw`.
592///
593/// Voxlap mutates the hinges in place during sort and restores
594/// them; this port produces the same `hsort` array without
595/// touching the input.
596#[must_use]
597#[allow(clippy::cast_sign_loss)] // parent >= 0 checked immediately above
598pub fn sort_hinges(hinges: &[Hinge]) -> Vec<usize> {
599 let n = hinges.len();
600 let mut hsort = vec![0usize; n];
601 // First pass: roots at the end, non-roots at the start.
602 let mut head = 0usize;
603 let mut tail = n;
604 for i in (0..n).rev() {
605 if hinges[i].parent < 0 {
606 tail -= 1;
607 hsort[tail] = i;
608 } else {
609 hsort[head] = i;
610 head += 1;
611 }
612 }
613
614 // `solved[h]` = true once hinge h's parent has been settled
615 // into the "tail" half. Voxlap encodes this in-place by
616 // flipping the parent field to -2-parent; we use a side
617 // bitmap to leave the input immutable.
618 let mut solved = vec![false; n];
619 for i in (tail..n).rev() {
620 solved[hsort[i]] = true;
621 }
622
623 // Iterative pass: pick non-root entries in head whose parent
624 // is already solved; move them to the tail.
625 let mut idx = head; // idx walks the head [0..head) backward
626 while tail > 0 {
627 if idx == 0 {
628 idx = head;
629 }
630 idx -= 1;
631 let j = hsort[idx];
632 let parent = hinges[j].parent;
633 if parent < 0 {
634 // Already in the tail (shouldn't happen since the
635 // first pass sorted these out).
636 continue;
637 }
638 if solved[parent as usize] {
639 solved[j] = true;
640 tail -= 1;
641 hsort[idx] = hsort[tail];
642 hsort[tail] = j;
643 head -= 1;
644 }
645 if head == 0 {
646 break;
647 }
648 }
649 hsort
650}
651
652// --- tests --------------------------------------------------------------
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 fn synthetic_kfa() -> Kfa {
659 Kfa {
660 kv6_name: b"anasaur.kv6".to_vec(),
661 hinges: vec![
662 Hinge {
663 parent: -1,
664 p: [
665 Point3 {
666 x: 0.0,
667 y: 0.0,
668 z: 0.0,
669 },
670 Point3 {
671 x: 1.0,
672 y: 0.0,
673 z: 0.0,
674 },
675 ],
676 v: [
677 Point3 {
678 x: 0.0,
679 y: 1.0,
680 z: 0.0,
681 },
682 Point3 {
683 x: 0.0,
684 y: 0.0,
685 z: 1.0,
686 },
687 ],
688 vmin: -180,
689 vmax: 180,
690 htype: 0,
691 filler: [0; 7],
692 },
693 Hinge {
694 parent: 0,
695 p: [
696 Point3 {
697 x: 0.5,
698 y: 0.0,
699 z: 0.0,
700 },
701 Point3 {
702 x: 0.5,
703 y: 1.0,
704 z: 0.0,
705 },
706 ],
707 v: [
708 Point3 {
709 x: 1.0,
710 y: 0.0,
711 z: 0.0,
712 },
713 Point3 {
714 x: 0.0,
715 y: 1.0,
716 z: 0.0,
717 },
718 ],
719 vmin: -90,
720 vmax: 90,
721 htype: 1,
722 // Non-zero filler tests round-trip preservation.
723 filler: [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba],
724 },
725 ],
726 frmval: vec![vec![0, 0], vec![45, -30], vec![90, -60], vec![135, -90]],
727 seq: vec![
728 Seq { tim: 0, frm: 0 },
729 Seq { tim: 100, frm: 1 },
730 Seq { tim: 200, frm: 2 },
731 Seq { tim: 300, frm: 3 },
732 ],
733 }
734 }
735
736 #[test]
737 fn synthetic_roundtrips_byte_equal() {
738 let kfa = synthetic_kfa();
739 let bytes = serialize(&kfa);
740 let parsed = parse(&bytes).expect("parse synthetic");
741 let bytes2 = serialize(&parsed);
742 assert_eq!(bytes, bytes2, "byte-level round-trip failed");
743 // Spot-check the structural round-trip too.
744 assert_eq!(parsed.kv6_name, kfa.kv6_name);
745 assert_eq!(parsed.hinges.len(), kfa.hinges.len());
746 assert_eq!(parsed.frmval, kfa.frmval);
747 assert_eq!(parsed.seq, kfa.seq);
748 }
749
750 #[test]
751 fn hinge_size_matches_voxlap_packed_layout() {
752 // 4 (parent) + 24 (p[2]) + 24 (v[2]) + 2 (vmin) + 2 (vmax)
753 // + 1 (htype) + 7 (filler) = 64.
754 assert_eq!(HINGE_SIZE, 64);
755 // And we serialise exactly that many bytes per hinge.
756 let kfa = synthetic_kfa();
757 let bytes = serialize(&kfa);
758 // 4 magic + 4 name_len + 11 name + 4 numhin = 23 bytes header.
759 let header = 4 + 4 + kfa.kv6_name.len() + 4;
760 let after_hinges = header + kfa.hinges.len() * HINGE_SIZE;
761 // Re-parse and verify the second hinge's filler matches what we set.
762 let parsed = parse(&bytes).expect("parse synthetic");
763 assert_eq!(
764 parsed.hinges[1].filler,
765 [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba]
766 );
767 // Sanity: total size must include numfrm field after hinges.
768 assert!(bytes.len() > after_hinges + 4);
769 }
770
771 #[test]
772 fn parse_bad_magic_fails() {
773 let mut bytes = serialize(&synthetic_kfa());
774 bytes[0] ^= 0xff;
775 let r = parse(&bytes);
776 assert!(matches!(r, Err(ParseError::BadMagic { .. })));
777 }
778
779 #[test]
780 fn parse_truncated_in_hinge_table_fails() {
781 let bytes = serialize(&synthetic_kfa());
782 // Truncate inside the first hinge.
783 let truncated = &bytes[..30];
784 let r = parse(truncated);
785 assert!(matches!(r, Err(ParseError::Truncated { .. })));
786 }
787
788 /// `sort_hinges` puts roots at high indices and children at low.
789 /// 3-bone chain: root → child1 → child2.
790 #[test]
791 #[allow(clippy::cast_sign_loss)] // p >= 0 checked at the assert site
792 fn sort_hinges_three_bone_chain() {
793 let axis = |x: f32, y: f32, z: f32| Point3 { x, y, z };
794 let h = |parent: i32| Hinge {
795 parent,
796 p: [axis(0.0, 0.0, 0.0); 2],
797 v: [axis(1.0, 0.0, 0.0); 2],
798 vmin: 0,
799 vmax: 0,
800 htype: 0,
801 filler: [0; 7],
802 };
803 // hinge[0] = root, hinge[1] child of 0, hinge[2] child of 1.
804 let hinges = vec![h(-1), h(0), h(1)];
805 let sort = sort_hinges(&hinges);
806 // Walking sort[i] for i=n-1..=0 must visit each bone's parent
807 // before the bone itself.
808 let mut seen = [false; 3];
809 for k in (0..3).rev() {
810 let j = sort[k];
811 seen[j] = true;
812 let p = hinges[j].parent;
813 if p >= 0 {
814 assert!(
815 seen[p as usize],
816 "bone {j}'s parent {p} not yet visited at descent step k={k}"
817 );
818 }
819 }
820 }
821
822 // --- animsprite playback ------------------------------------------
823
824 /// Minimal two-bone sprite (root + one child hinge) for driving
825 /// [`KfaSprite::animsprite`]. `limbs` is empty — `animsprite` reads
826 /// only the hinges + curve, never the limb geometry — so we build
827 /// the struct directly to avoid needing a kv6.
828 fn anim_sprite(
829 child_vmin: i16,
830 child_vmax: i16,
831 frmval: Vec<Vec<i16>>,
832 seq: Vec<Seq>,
833 ) -> KfaSprite {
834 let zero = Point3 {
835 x: 0.0,
836 y: 0.0,
837 z: 0.0,
838 };
839 let axis = Point3 {
840 x: 1.0,
841 y: 0.0,
842 z: 0.0,
843 };
844 let hinges = vec![
845 Hinge {
846 parent: -1,
847 p: [zero, zero],
848 v: [axis, axis],
849 vmin: 0,
850 vmax: 0,
851 htype: 0,
852 filler: [0; 7],
853 },
854 Hinge {
855 parent: 0,
856 p: [zero, zero],
857 v: [axis, axis],
858 vmin: child_vmin,
859 vmax: child_vmax,
860 htype: 0,
861 filler: [0; 7],
862 },
863 ];
864 KfaSprite {
865 limbs: Vec::new(),
866 hinge_sort: sort_hinges(&hinges),
867 kfaval: vec![0i16; hinges.len()],
868 hinges,
869 p: [0.0; 3],
870 s: [1.0, 0.0, 0.0],
871 h: [0.0, 1.0, 0.0],
872 f: [0.0, 0.0, 1.0],
873 frmval,
874 seq,
875 kfatim: 0,
876 okfatim: 0,
877 }
878 }
879
880 /// Half-way through a single 0→16384 segment a free hinge sits at
881 /// exactly 8192, and the root bone is left untouched.
882 #[test]
883 fn animsprite_lerps_free_hinge_midpoint() {
884 // Free hinge: vmin == vmax.
885 let mut kfa = anim_sprite(
886 0,
887 0,
888 vec![vec![0, 0], vec![0, 16384]],
889 vec![Seq { tim: 0, frm: 0 }, Seq { tim: 1000, frm: 1 }],
890 );
891 kfa.animsprite(500);
892 assert_eq!(kfa.kfatim, 500, "time cursor advanced by ti");
893 assert_eq!(kfa.kfaval[0], 0, "root bone untouched");
894 assert_eq!(kfa.kfaval[1], 8192, "child at segment midpoint");
895 }
896
897 /// A free hinge interpolating 30000 → -30000 takes the *short* way
898 /// (through ±32768), not the long way through 0 — so the midpoint
899 /// lands at the wrap boundary, not near 0.
900 #[test]
901 fn animsprite_free_hinge_takes_shortest_wrap() {
902 let mut kfa = anim_sprite(
903 0,
904 0,
905 vec![vec![0, 30000], vec![0, -30000]],
906 vec![Seq { tim: 0, frm: 0 }, Seq { tim: 1000, frm: 1 }],
907 );
908 kfa.animsprite(500);
909 // 30000 + (5536 short-path delta)/2 = 32768 ≡ -32768 as i16.
910 assert_eq!(kfa.kfaval[1], -32768);
911 }
912
913 /// `seq[].frm < 0` is a `!target` jump: advancing time past the
914 /// jump entry loops back to `target` and keeps consuming `ti`.
915 #[test]
916 fn animsprite_follows_loop_jump_entry() {
917 let mut kfa = anim_sprite(
918 0,
919 0,
920 vec![vec![0, 0], vec![0, 16384]],
921 vec![
922 Seq { tim: 0, frm: 0 },
923 Seq { tim: 1000, frm: 1 },
924 // Jump back to seq entry 0 (== !0 == -1).
925 Seq { tim: 2000, frm: !0 },
926 ],
927 );
928 // 2500 ms: 0→1000 (seg 0), 1000→2000 hits the jump → loop to 0,
929 // then 500 ms more into the first segment again.
930 kfa.animsprite(2500);
931 assert_eq!(kfa.kfatim, 500, "looped back and advanced 500 ms");
932 }
933
934 /// With no curve attached, animsprite leaves kfaval alone so hosts
935 /// that drive kfaval[] directly are unaffected.
936 #[test]
937 fn animsprite_no_curve_is_noop() {
938 let mut kfa = anim_sprite(0, 0, Vec::new(), Vec::new());
939 kfa.kfaval[1] = 1234;
940 kfa.animsprite(500);
941 assert_eq!(kfa.kfaval[1], 1234);
942 assert_eq!(kfa.kfatim, 0);
943 }
944
945 #[test]
946 fn kfatime2seq_brackets_from_below() {
947 let seq = vec![
948 Seq { tim: 0, frm: 0 },
949 Seq { tim: 100, frm: 1 },
950 Seq { tim: 200, frm: 2 },
951 Seq { tim: 300, frm: 3 },
952 ];
953 assert_eq!(kfatime2seq(&seq, 0), 0);
954 assert_eq!(kfatime2seq(&seq, 99), 0);
955 assert_eq!(kfatime2seq(&seq, 100), 1);
956 assert_eq!(kfatime2seq(&seq, 250), 2);
957 // Never returns the final index: the last entry is always the
958 // *upper* bracket, so beyond it we stay on the last segment.
959 assert_eq!(kfatime2seq(&seq, 9999), 2, "last segment's lower bracket");
960 }
961}