- 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>
116 lines
4.8 KiB
Rust
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
|
|
);
|
|
}
|