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}