Files
benilla-octo/crates/benilla-formats/tests/m2_idle_anim.rs
T
SamandClaude Fable 5 2ad0043523 benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:27:01 +07:00

95 lines
3.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Animation-keyframe regression test against real vanilla creatures (decision 0019).
//!
//! Guards the bug that froze most creatures in Milestone B: a per-sequence keyframe window pulled in a
//! keyframe from a *later* sequence (14–64 s away), which inflated the Bevy clip's duration and made
//! the whole animation crawl. We now select keys by absolute timestamp within each sequence's band, so
//! every keyframe of every sequence must land inside `[0, duration]`. Skips when the client isn't present.
use std::path::PathBuf;
use benilla_formats::{open_chain, parse_m2_animations, ModelAnimation};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// A spread of vanilla creatures: simple (rabbit/chicken) through rigged humanoid-ish (kobold/murloc),
/// covering the ones that animated *and* the ones that froze before the fix.
const CREATURES: &[&str] = &[
"Creature\\Rabbit\\Rabbit.m2",
"Creature\\Chicken\\Chicken.m2",
"Creature\\Deer\\Deer.m2",
"Creature\\Kobold\\Kobold.m2",
"Creature\\Wolf\\Wolf.m2",
"Creature\\Murloc\\Murloc.m2",
"Creature\\Bear\\Bear.m2",
"Creature\\Cat\\Cat.m2",
];
#[test]
fn creature_animation_keyframes_stay_within_their_sequence() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let mut checked = 0;
for path in CREATURES {
let Ok(bytes) = chain.read_file(path) else {
continue; // a model not in this install — skip
};
let anims = parse_m2_animations(&bytes);
assert!(
!anims.is_empty(),
"{path}: a creature should have sequences"
);
// Every creature must carry a Stand (AnimationData id 0) somewhere — the records are NOT
// ordered by id (e.g. Rabbit's record 0 is Walk), so the idle is found by id, not position.
let ids: Vec<u16> = anims.iter().map(|a| a.anim_id).collect();
assert!(
ids.contains(&0),
"{path}: no Stand (id 0) among sequence ids {ids:?}"
);
for anim in &anims {
assert!(
anim.duration > 0.0,
"{path}: anim {} has no duration",
anim.anim_id
);
// No keyframe may sit past its sequence: a key beyond `duration` is the cross-sequence leak
// that froze creatures (it stretched a clip to many seconds). Small epsilon for rounding.
let max_t = max_key_time(anim);
assert!(
max_t <= anim.duration + 1e-3,
"{path}: anim {} key at {max_t:.3}s exceeds duration {:.3}s — a cross-sequence leak",
anim.anim_id,
anim.duration
);
}
checked += 1;
}
assert!(
checked > 0,
"no test creatures present — expected at least the rabbit"
);
}
/// The latest keyframe time across every bone/channel of the animation (0.0 if it has no keys).
fn max_key_time(anim: &ModelAnimation) -> f32 {
let mut max = 0.0_f32;
for b in &anim.bones {
for (t, _) in &b.translation {
max = max.max(*t);
}
for (t, _) in &b.rotation {
max = max.max(*t);
}
for (t, _) in &b.scale {
max = max.max(*t);
}
}
max
}