Files
benilla-octo/crates/benilla-formats/tests/m2_cold_breath.rs
T
SamandClaude Fable 5 6b980b9efb the player build: one binary that carries its content
- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module
- the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder
- the glue screens get a capture shutter, and the engine stops depending on the protocol crate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:02 +07:00

116 lines
4.8 KiB
Rust

//! The cold-breath puff, pinned against the shipped assets (`#bugs` B233, decision 1149).
//!
//! B233 was reported as a **player character in Dun Morogh** with no visible breath, and the fix
//! rests on three asset facts that a mechanism note alone cannot establish — the trap decision
//! 0705 names, where a verified mechanism turns out not to *apply* to the thing in front of you:
//!
//! 1. the trigger exists on the reported model — `HumanMale.m2` authors a **`$BTH` event on
//! animation id 0 (Stand)**, so an idle player fires one every loop with no timer of ours;
//! 2. the attach tag the client passes, **`0x11` = AttachmentID 17**, resolves on that model to a
//! bone at the **mouth**, not the base — the difference between vapour at the face and a cloud
//! at the feet;
//! 3. the asset the DBC names is a **one-shot emitter**, so the client's end-of-clip terminator is
//! the right lifetime for it (`FxStage::OneShot`, as the mount poof uses).
//!
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use benilla_formats::{open_chain, parse_m2_animations, parse_m2_attachments};
const HUMAN_MALE: &str = "Character\\Human\\Male\\HumanMale.m2";
/// `SpellVisualEffectName` row 107 (`"HARDCODED Breath Cold"`) names `Particles\ColdBreath.mdl`;
/// the loader's own extension swap makes that the shipped `.m2`.
const COLD_BREATH: &str = "Particles\\ColdBreath.m2";
/// The `$BTH` family's attach tag — `DAT_0080c968[3]`, a raw M2 `AttachmentID`.
const BREATH_ATTACH: u16 = 0x11;
/// `AnimationData.dbc` id 0 = Stand.
const STAND: u16 = 0;
#[test]
fn the_idle_player_keys_bth_and_attaches_it_at_the_mouth() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file(HUMAN_MALE)
.expect("HumanMale is in the chain");
// (1) The trigger, on the animation an idle player is actually in.
let anims = parse_m2_animations(&bytes);
let stand = anims
.iter()
.find(|a| a.anim_id == STAND)
.expect("HumanMale authors Stand");
let bth: Vec<f32> = stand
.events
.iter()
.filter(|e| e.ident == *b"$BTH")
.map(|e| e.time)
.collect();
assert_eq!(
bth.len(),
1,
"Stand keys exactly one $BTH; got {bth:?} over a {:.3}s clip",
stand.duration
);
// 0.667 s into a 2.667 s loop: the clip is comfortably longer than ColdBreath's own 1.5 s, so
// consecutive puffs never overlap on an idling player and the overlap guard never bites.
assert!(
(bth[0] - 0.667).abs() < 0.01,
"the $BTH key sits at 0.667s, got {:.3}s",
bth[0]
);
assert!(
stand.duration > 1.5,
"Stand ({:.3}s) outlasts the 1.5s puff",
stand.duration
);
// (2) The attach point: tag 0x11 through the model's OWN AttachLookup (the `0x710310`
// indirection — the tag is an id, never an array index), landing at the mouth.
let attachments = parse_m2_attachments(&bytes).expect("parse attachments");
let breath = attachments
.iter()
.find(|a| a.id == BREATH_ATTACH)
.expect("HumanMale authors attachment 0x11");
// The head attach (id 11) is the neighbour that makes "mouth" mean something: the breath point
// sits just below it and forward of it, on the face — not at the unit base (z ≈ 0).
let head = attachments
.iter()
.find(|a| a.id == 11)
.expect("HumanMale authors the head attachment");
assert!(
breath.position[2] > 1.5 && breath.position[2] < head.position[2],
"breath z {:.3} sits on the face, below the head attach z {:.3}",
breath.position[2],
head.position[2]
);
assert!(
breath.position[0] > head.position[0],
"breath x {:.3} is forward of the head attach x {:.3} — out of the mouth",
breath.position[0],
head.position[0]
);
assert_ne!(
breath.bone, 0,
"the breath rides a real bone, not the model root"
);
// (3) The puff itself: a single non-looping sequence, so the end-of-clip terminator is the
// right lifetime. (`Particles\Bubbles.m2`, the underwater sibling, LOOPS — which is why it
// needs the client's replace-on-respawn dedup and this one does not.)
let breath_bytes = chain
.read_file(COLD_BREATH)
.expect("ColdBreath.m2 is in the chain");
let clips = parse_m2_animations(&breath_bytes);
assert_eq!(clips.len(), 1, "ColdBreath authors one sequence");
assert!(
!clips[0].looping,
"ColdBreath's sequence is one-shot (flags bit 0 set) — it ENDS, which is what lets the \
client's end-of-clip terminator own its lifetime"
);
assert!(
(clips[0].duration - 1.5).abs() < 0.01,
"ColdBreath runs 1.5s, got {:.3}s",
clips[0].duration
);
}