Files
benilla-octo/crates/benilla-formats/tests/m2_entity_mat_anim.rs
T
Sam SungandClaude Opus 5.5 8da634afa9 Comments in benilla-formats: one line of fact each, no history, no record numbers (#130)
Part of #41: the comments of `benilla-formats` (the library, its extract tools, examples and tests) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 19,654 comment lines become 7,264.

Fixes #105: the WMO collision comments now name walking's box-query leaf `0x6bca50` and the camera and line-of-sight segment-query leaf `0x6bc700`, in `models/collision.rs`, `examples/wmo_bsp.rs` and `tests/chain.rs`.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - A placed doodad's material alpha reads file slot 0 on its spawn clock, while the reference reads bone 0's armed sequence, re-rolled every play window. The old text said doodads are armed once at load.
  - The fog end is stored ×36 and scaled once at load (`0x6d6090`). It is not raw.
  - The MCSH bit picks a map doodad's sun scale, 1.0 lit or 0.5 shadowed. 2.5 belongs to an entity's light node.
  - `SpellRec` is the row as stored, column × 4, not a translated copy.
  - A global sequence's clock is anchored at the instance's attach time (`0x714352`).
  - Particle twinkle is a gated size multiplier (`0x7b2adc`).
  - The blob shadow sizes from the Stand box.
  - Data claims are checked against the install: the weapon-trail kits' parameters and alphas, the beam rows' noise, and the maps' `field_16`.
  - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Four second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference, the code and the shipped data.

String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:38:53 +07:00

188 lines
7.8 KiB
Rust

//! The material animation shapes the unit, GameObject and held-item lane serves, each on the asset
//! that needs it.
use benilla_formats::{open_chain, parse_m2_render_submeshes};
/// Every wind elemental is this model, and 26 of its 32 batches slide a full sheet on a global
/// sequence. The reference clocks a global sequence on one per-scene ms cursor, so a shared
/// material is exact.
#[test]
fn a_creature_scrolls_on_a_global_sequence() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file("Creature\\AirElemental\\AirElemental.m2")
.expect("AirElemental is in the chain");
let subs = parse_m2_render_submeshes(&bytes, "Creature\\AirElemental", &[]).expect("parse");
let live: Vec<_> = subs
.iter()
.filter_map(|s| s.uv_anim.as_ref())
.filter(|a| a.period > 0.0)
.collect();
assert_eq!(live.len(), 26, "the banded shell's animating batches");
assert!(
live.iter().all(|a| a.gseq),
"every one rides a GLOBAL sequence — no host, no play head, a shared clock is faithful"
);
for a in &live {
let (lo, hi) = a
.keys
.iter()
.fold((f32::MAX, f32::MIN), |(lo, hi), (_, v)| {
(lo.min(v[0]), hi.max(v[0]))
});
// To the keys' quantization: one batch ends at 1.0017.
assert!(
(hi - lo - 1.0).abs() < 5e-3,
"a whole sheet of U per loop: [{lo}, {hi}]"
);
}
// Nothing is per-sequence, so the shared material serves.
assert!(subs.iter().all(|s| s.uv_seq.is_none()));
}
/// The corpus's two batches that rotate and never translate, both on GameObjects: `uv_anim` alone
/// misses them.
#[test]
fn a_gameobject_batch_rotates_with_no_translation_at_all() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
for (path, batches) in [
("World\\Goober\\G_ScryingBowl.m2", 1usize),
("World\\Goober\\G_ScourgeRuneCircleCrystal.m2", 1),
] {
let bytes = chain.read_file(path).expect("in the chain");
let dir = path.rsplit_once('\\').expect("a directory").0;
let subs = parse_m2_render_submeshes(&bytes, dir, &[]).expect("parse");
let turning: Vec<_> = subs.iter().filter(|s| s.uv_rot_seq.is_some()).collect();
assert_eq!(turning.len(), batches, "{path}: the rotating batches");
for s in &turning {
assert!(
s.uv_anim.is_none() && s.uv_seq.is_none(),
"{path}: no translation channel at all — the obvious test answers None"
);
assert!(s.uv_scale_seq.is_none(), "{path}: and no scaling either");
let rot = s.uv_rot_seq.as_ref().expect("checked");
let l = rot.seq(None).expect("slot 0 animates");
assert!(l.period > 0.0 && l.keys.len() > 1, "{path}: a live turn");
}
}
}
/// `BloodOfHeroes` authors its five bubble sheets in two slots of one animation id, slot 0 still
/// and slot 1 sweeping, so each instance needs its own material reading the slot it plays.
#[test]
fn a_gameobjects_slots_bake_different_loops() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let path = "World\\Lordaeron\\Plagueland\\PassiveDoodads\\BloodOfHeroes\\BloodOfHeroes.m2";
let bytes = chain.read_file(path).expect("in the chain");
let dir = path.rsplit_once('\\').expect("a directory").0;
let subs = parse_m2_render_submeshes(&bytes, dir, &[]).expect("parse");
let split: Vec<_> = subs.iter().filter(|s| s.uv_seq.is_some()).collect();
assert_eq!(split.len(), 5, "the bubble sheets");
for s in &split {
assert!(
s.uv_anim.is_none(),
"no single loop serves both slots — which is why the shared lane registers nothing \
here and the bubbles never moved"
);
let seqs = s.uv_seq.as_ref().expect("checked");
let slots = seqs.slots();
assert_eq!(slots.len(), 2, "two file sequence slots");
assert!(slots[0].is_none(), "slot 0 holds the sheet still");
let live = slots[1].as_ref().expect("slot 1 sweeps it");
let hi = live.keys.iter().fold(f32::MIN, |hi, (_, v)| hi.max(v[1]));
assert!(
(live.period - 3.334).abs() < 1e-2 && (hi - 0.605).abs() < 1e-2,
"slot 1 sweeps v to {hi} over {}s",
live.period
);
}
assert_eq!(
subs.iter().filter(|s| s.uv_seq.is_none()).count(),
1,
"the pool draws with no transform, and always did"
);
}
/// Slot 1 plays as often as the variation walk lands on it. A GameObject owning none of the
/// door-family ids re-arms Stand every window with a fresh roll (`0x5f3a52`, `0x5f4167`), so the
/// two frequencies are the duty cycle: `roll < 16384` is still, `16384..=32766` bubbles, and the
/// leftover 32767 exhausts the chain back to the head.
#[test]
fn the_bubbling_take_is_half_the_pools_duty_cycle() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file("World\\Lordaeron\\Plagueland\\PassiveDoodads\\BloodOfHeroes\\BloodOfHeroes.m2")
.expect("in the chain");
let seqs = benilla_formats::parse_m2_animations(&bytes);
assert_eq!(
seqs.len(),
2,
"one two-take variation chain and nothing else"
);
assert!(
seqs.iter().all(|s| s.anim_id == 0),
"both takes are Stand — which is what the collapse-to-0 leg arms"
);
let freqs: Vec<u16> = seqs.iter().map(|s| s.frequency).collect();
assert_eq!(
freqs,
vec![16_384, 16_383],
"the still sheet and the bubbling one, at even odds"
);
// The walk: `roll < freq`, strict and unsigned, node by node, else `roll -= freq`.
let bubbling = (0u32..32_768)
.filter(|&roll| {
let mut roll = roll;
freqs.iter().position(|&f| {
let win = roll < u32::from(f);
roll = roll.saturating_sub(u32::from(f));
win
}) == Some(1)
})
.count();
assert_eq!(
bubbling, 16_383,
"the bubbling take wins 16383 of 32768 draws — 49.997 %, re-rolled every 3.3 s window"
);
}
/// Four of the corpus's five animated entity tints key nothing in slot 0, so `rgb_anim` is `None`:
/// the hunter's freezing trap pulses its glow card only on the `Custom0` clip the server plays.
#[test]
fn a_gameobjects_tint_is_keyed_in_a_later_slot_alone() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file("World\\Goober\\G_FreezingTrap.m2")
.expect("in the chain");
let subs = parse_m2_render_submeshes(&bytes, "World\\Goober", &[]).expect("parse");
let tinted: Vec<_> = subs.iter().filter(|s| s.rgb_seq.is_some()).collect();
assert!(!tinted.is_empty(), "the glow card's tint is per-sequence");
for s in &tinted {
assert!(
s.rgb_anim.is_none(),
"slot 0 bakes nothing — the shared lane's seed is white, for ever"
);
let seqs = s.rgb_seq.as_ref().expect("checked");
let slots = seqs.slots();
assert!(slots[0].is_none(), "…which is exactly what slot 0 holds");
let live = slots
.iter()
.enumerate()
.find_map(|(i, l)| l.as_ref().map(|l| (i, l)));
let (slot, l) = live.expect("some later slot keys the pulse");
assert!(
slot > 0 && l.period > 0.0 && l.keys.len() > 1,
"slot {slot}"
);
}
}