Files
benilla-octo/crates/benilla-assets/src/model.rs
T
SamandClaude Opus 5.5 0963bbd8f2 the spell owns its state, and a malformed file is refused, not a crash
- the spell owns its lifecycle state: the in-flight cast, the local cancel, the cooldowns and the spell-modifier tables live in one module (2328)
- a malformed MPQ, DBC, M2 or WDT file is refused instead of crashing the client, nine read sites bounded
- a test that needs the install or the addon corpus fails instead of skipping when the data is there; the Bagnon render suite runs again (2329)
- the dev crash injector is armed on every platform, and no instrument writes a path relative to the working directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:08:33 +07:00

1169 lines
57 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.
//! Shared building blocks for the M2 and WMO model loaders: the per-batch submesh type and the
//! WoW→Bevy mesh bake. Both formats decode to a list of these (geometry + texture + blend/sidedness),
//! with materials deferred to the spawn site (see the loader module docs) so the assets stay
//! app-independent.
use benilla_formats::{
BillboardKind, BoneScaleAnim, CharSkinSlot, M2Attachment, ModelAnimation, ModelBlend,
RenderSubmesh, Skeleton,
};
use bevy::animation::animatable::Animatable;
use bevy::animation::animation_curves::{AnimatableCurve, AnimatableKeyframeCurve};
use bevy::animation::{animated_field, AnimationClip, AnimationTargetId};
use bevy::asset::RenderAssetUsages;
use bevy::math::Mat3;
use bevy::mesh::{Indices, PrimitiveTopology, VertexAttributeValues};
use bevy::prelude::*;
use crate::coords::wow_to_bevy;
mod anims;
mod pose;
pub use anims::{AnimClip, ClipEvent, ModelAnimations};
pub use pose::{PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack};
/// A billboarded submesh's render data (Bevy space): the bone pivot the card rotates about (model-local
/// — compose with the instance transform) and the billboard kind. The mesh is built **centred at
/// `pivot`** so the spawn site can rotate it about the pivot to face the camera each frame.
#[derive(Clone)]
pub struct BillboardInfo {
pub pivot: Vec3,
/// The billboard bone's index — the joint the card rides on an animated host (swinging lamp,
/// mount lights). On a rest-pose host the pivot alone places the card.
pub bone: u16,
pub kind: BillboardKind,
/// The billboard bone's global-sequence scale animation (the looping glow-card pulse), if any —
/// sampled per-frame at the spawn site and folded into the card scale. `None` for a static card.
pub scale_anim: Option<BoneScaleAnim>,
/// The bone's per-sequence translation loops `(anim id, loop)`, keys baked to **Bevy axes** —
/// a model-local offset the card adds at its pivot when the spawn site arms one. Anim 0 is the
/// load arm's default (the questgiver `?` marker's low bob); anim 190 the marker's raised bob,
/// armed while the unit shows an overhead name (only the marker arms these today; see
/// `benilla_formats::Billboard::seq_translations`).
pub seq_translations: Vec<(u16, BoneScaleAnim)>,
}
/// One render batch of a loaded model (an M2 batch or a WMO group batch): the decoded geometry,
/// the batch's albedo texture (WorldArt), and its blend/sidedness. Carries data + metadata only —
/// **no `Mesh` assets** (decision 0834, the model-lane twin of 0832's terrain rule): a labeled
/// mesh sub-asset lands the instant the decode completes and the render world ingests the whole
/// model in ONE frame, which is the city first-contact spike. The app builds each batch's render
/// form paced (`benilla`'s `model_forms`) via [`submesh_to_static_mesh`] /
/// [`submesh_to_skinned_mesh`]; the material stays a spawn-site concern as before.
#[derive(Clone)]
pub struct ModelSubmesh {
/// The batch's decoded geometry (model space, WoW axes) — everything the mesh builders below
/// need, shared across every instance of the model. `Arc` keeps the submesh clone cheap and is
/// the model's single resident CPU copy of the vertex data (the static render form is
/// GPU-only; see [`submesh_to_static_mesh`]).
pub geometry: std::sync::Arc<RenderSubmesh>,
/// Embedded albedo texture, if any. `None` for creature skin slots (filled at spawn from the
/// display's skin variation — see [`Self::skin_slot`]).
pub texture: Option<Handle<Image>>,
/// The creature skin variation this batch draws from (`Some(0/1/2)` ⇒ `Monster1/2/3`) when its
/// [`Self::texture`] is `None` — so the entity spawn site can fill it from `CreatureDisplayInfo`.
/// `None` for batches with their own embedded texture (all doodads/WMOs; most M2 batches).
pub skin_slot: Option<u8>,
/// The batch's `skinSectionId` (geoset / mesh-part ID, see
/// [`benilla_formats::RenderSubmesh::geoset_id`]) — `group*100 + variant`. The character compositor
/// selects which geosets a player shows by this; `0` for a single-geoset model (creatures/doodads).
pub geoset_id: u16,
/// The character runtime texture slot this batch carries (see
/// [`benilla_formats::RenderSubmesh::char_slot`]) — the spawn site fills its texture per-player from
/// the appearance (decisions 0041 / 0044 / 0045). `None` for everything with its own texture.
pub char_slot: Option<CharSkinSlot>,
pub blend: ModelBlend,
pub two_sided: bool,
/// This batch is a WMO **interior** group (see [`benilla_formats::RenderSubmesh::interior`]) — lit
/// by its baked MOCV with the directional sun off. `false` for M2 doodads and exterior WMO groups.
pub interior: bool,
/// This batch is unlit (see [`benilla_formats::RenderSubmesh::emissive`]) — rendered fullbright:
/// M2 `UNLIT (0x01)` glass/glow, or WMO `UNLIT` on an exterior-group batch. `false` otherwise.
pub emissive: bool,
/// This batch's texture is the **icon slot** (M2 texture type 14, see
/// [`benilla_formats::RenderSubmesh::icon_slot`]): the spawn site fills it from
/// `Model:ReplaceIconTexture`'s path. `false` for every other batch and all of WMO.
pub icon_slot: bool,
/// The WMO MOMT **SIDN** night-glow colour (see [`benilla_formats::RenderSubmesh::sidn`]) — the
/// authored emissive RGB the shader ramps by the night fraction on lit lanes. `None` for M2.
pub sidn: Option<[u8; 3]>,
/// WMO MOMT **WINDOW** (see [`benilla_formats::RenderSubmesh::window`]) — an interior-group
/// batch takes the brighter Direct/Ambient-midpoint light. `false` for M2.
pub window: bool,
/// This batch blends **additively** (M2 glow cards / coronae — see
/// [`benilla_formats::RenderSubmesh::additive`]): the spawn site builds it with `AlphaMode::Add` so
/// its warm colour is added on top of the scene, not mixed with the background. `false` otherwise.
pub additive: bool,
/// This batch's texture coordinates are **generated, not authored** (see
/// [`benilla_formats::RenderSubmesh::env_map`]): a sphere-map environment coordinate off the
/// view-space reflection vector. The spawn site marks the material so `wow_model.wgsl` derives
/// the UV instead of reading the (meaningless) vertex one. `false` for WMO batches and for the
/// M2 batches that name a real UV channel.
pub env_map: bool,
/// M2 render flag **0x10 — disable depth write** (see [`benilla_formats::RenderSubmesh::no_depth_write`]):
/// `specialize` keeps depth-write ON for transparent batches *unless* this is set, matching the real
/// client (so a model's transparent cards occlude each other instead of bleeding through). `false` for WMO.
pub no_depth_write: bool,
/// M2 render flag **0x08 — disable depth test** (drawn over everything). `false` for WMO.
pub no_depth_test: bool,
/// The batch's fog COLOUR policy (see [`benilla_formats::RenderSubmesh::fog_policy`]) — `Scene`
/// for WMO and every non-M2 batch.
pub fog_policy: benilla_formats::FogPolicy,
/// Set when this batch rides an M2 billboard bone (glow cards, chains): the spawn site faces it to
/// the camera each frame. The [`Self::mesh`] is built centred at the pivot so it rotates in place.
pub billboard: Option<BillboardInfo>,
/// The batch's **animated material alpha** (decision 0130 phase 2, wow-re `m2-alpha-combine-cull`):
/// time-varying colour-alpha/transparency-weight loops (or a dimming constant) the doodad spawn
/// site samples per instance into the render-alpha channel. `None` for the overwhelming majority
/// (both factors static-1 or statically culled). `Arc` keeps the submesh clone cheap.
pub alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
/// The batch's **UV-animation** loop (decision 0130 phase 3, wow-re `m2-texanim-uv`): the
/// texture transform's translation track — sampled per frame into the batch material's UV
/// offset (flowing waterfalls, scrolling energy). `None` for the ~98.6% of models with no
/// texture transform and all WMO batches. The `Arc` doubles as the material-dedup identity:
/// every instance of a loaded model shares this allocation.
pub uv_anim: Option<std::sync::Arc<benilla_formats::UvAnim>>,
/// The batch's UV loop **per file sequence slot**, `Some` only where the slots disagree — the
/// batches for which [`Self::uv_anim`]'s single loop is structurally unable to be right,
/// because which loop applies depends on the sequence the *instance* is playing (decision
/// 1408). Like [`Self::uv_anim`], the `Arc` doubles as a material-dedup identity.
pub uv_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// The batch's texture-transform **rotation** loop per file sequence slot (decision 2019)
/// — the UI model tiles sample it off the pane's play head into the material's affine row;
/// no world lane reads it (no placed doodad authors one). `None` for the rest.
pub uv_rot_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
/// The scaling twin of [`Self::uv_rot_seq`].
pub uv_scale_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell
/// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped
/// at parse (`benilla-formats`): the render side seeds the material's tint at the first key —
/// pixel-identical to the old static bake — and the material-animating lanes tick it per
/// frame. `None` for constant/keyless tints (the vertex bake).
pub rgb_anim: Option<std::sync::Arc<benilla_formats::RgbAnim>>,
/// The tint twin of [`Self::uv_seq`], on the same rule.
pub rgb_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>>,
/// The batch's MOBA section for WMO group batches ([`benilla_formats::WmoBatchClass`] — an
/// interior group's per-batch lighting law: INT = unlit `tex × MOCV`, TRANS = the MOCV-alpha
/// lit↔bake lerp, EXT = the exterior day/night law). `None` for every M2 batch.
pub wmo_batch: Option<benilla_formats::WmoBatchClass>,
/// The batch's flat **ground-plane quad** shape ([`benilla_formats::GroundQuad`], detected at
/// load — corners stay in WoW model space; consumers map through `coords::wow_to_bevy`). The
/// ground-fx decal lane (`benilla::ground_fx`) re-renders such parts of a base-anchored spell
/// effect as projected surface decals. `None` for ordinary geometry and all WMO batches.
pub ground_quad: Option<benilla_formats::GroundQuad>,
}
/// Turn a [`RenderSubmesh`] (model space) into a Bevy [`Mesh`] baked to Bevy space — decision 0002,
/// applied here at the render boundary. Prefers the authored normals (soft, outward — how WoW lights
/// foliage); recomputes flat ones only when absent. WMO per-vertex MOCV colour is folded in when
/// present (M2 has none). The WoW→Bevy map is a pure rotation, so it applies to normals too.
///
/// `usages` is the caller's lane split (decision 0834): the **static** form is `RENDER_WORLD`-only
/// (nothing reads it main-side; the render world takes the buffers at extract, no resident CPU
/// copy), while the **skinned** twin keeps the default `MAIN_WORLD | RENDER_WORLD` because the
/// mouseover picker rays its vertices on the main world (`target::hover::ray_posed_mesh`).
fn build_submesh_mesh(sub: &RenderSubmesh, usages: RenderAssetUsages) -> Mesh {
// A billboard batch is built centred at its bone pivot, so the spawn site can rotate it about that
// point to face the camera (the entity transform then re-places it at the pivot in the world).
let center = sub
.billboard
.as_ref()
.map_or(Vec3::ZERO, |b| wow_to_bevy(b.pivot));
let positions: Vec<[f32; 3]> = sub
.positions
.iter()
.map(|p| (wow_to_bevy(*p) - center).to_array())
.collect();
let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, usages);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone());
mesh.insert_indices(Indices::U32(sub.indices.clone()));
if sub.vertex_colors.len() == sub.positions.len() {
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone());
}
if sub.normals.len() == sub.positions.len() {
// A billboard CARD is lit on the face it PRESENTS (decision 0788): a card authored
// back-to-front against the law's `+X`-at-the-viewer gets its plane's normals turned round
// here, so its shading stops swinging with the camera (the shape, the reference's own
// behaviour and the 279-batch population are on
// [`RenderSubmesh::billboard_card_faces_away`]).
//
// **Normals only — never the winding.** The 177 away-facing SINGLE-sided cards are
// authored placeholder geometry the reference backface-culls from every angle
// (`bbfacescan`), and our `cull_mode` reproduces that; re-winding them would reveal it.
let flip = sub.billboard_card_faces_away();
let normals: Vec<[f32; 3]> = sub
.normals
.iter()
.map(|n| {
let b = wow_to_bevy(*n);
if flip { -b } else { b }.to_array()
})
.collect();
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
} else {
mesh.compute_normals();
}
mesh
}
/// The skinned twin's per-vertex joint indices (`Uint16x4` — M2 bone indices, used directly as
/// palette indices). **Deliberately NOT `Mesh::ATTRIBUTE_JOINT_INDEX`** (decision 0720): Bevy's
/// `SKINNED` pipeline path triggers on the standard attributes being in the mesh LAYOUT — while
/// its draw-time skin bind group resolves per ENTITY registration — so a mesh carrying them can
/// only ever render through Bevy's skin lane. Our own attribute ids keep Bevy's `is_skinned()`
/// false everywhere; `WowModelExt::specialize` sees them in the layout and compiles the
/// `WOW_RIG_SKIN` path instead, which skins from the owned palette region of the shared light
/// buffer (`rig_palette`). Shader locations 10/11 (Bevy's builtin set ends at 7).
pub const ATTRIBUTE_WOW_JOINT_INDEX: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_JointIndex",
988_540_917,
bevy::render::render_resource::VertexFormat::Uint16x4,
);
/// The skinned twin's per-vertex joint weights (`Float32x4`, normalised) — see
/// [`ATTRIBUTE_WOW_JOINT_INDEX`].
pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_JointWeight",
988_540_918,
bevy::render::render_resource::VertexFormat::Float32x4,
);
/// A merged fader blob's per-vertex fade sphere (`center.xyz` world-space, `w` = fade radius —
/// decision 1418): every vertex of one baked placement carries the placement's sphere, and
/// `wow_model.wgsl` computes the faithful `doodad_fade_alpha` from it per vertex (the erode
/// lane), replacing the per-entity MeshTag/twin/Hidden fade channels a merged draw cannot
/// express. Own attribute id for the same reason as the joints above; shader location 12.
pub const ATTRIBUTE_WOW_FADE_SPHERE: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_FadeSphere",
988_540_919,
bevy::render::render_resource::VertexFormat::Float32x4,
);
/// A merged interior-prop blob's per-vertex SH-probe slot (decision 1418 lane 3): the slot
/// index every vertex of one baked placement carries, replacing the per-entity `MeshTag`
/// payload (`wow_model.wgsl`'s `WOW_MERGED_SLOT` reads it instead of the tag bits). Shader
/// location 13.
pub const ATTRIBUTE_WOW_MERGED_SLOT: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_MergedSlot",
988_540_920,
bevy::render::render_resource::VertexFormat::Uint32,
);
/// The app-facing **static** mesh build (decision 0834): geometry only, `RENDER_WORLD`-only
/// usages — the render world takes the vertex buffers at extract and the main world keeps no
/// copy. Consumers must pair it with an explicit `Aabb` computed at build time (the exterior
/// cull fails OPEN on a missing bound, and `RENDER_WORLD` races Bevy's `calculate_bounds` —
/// the same rule 0832 set for terrain cells).
pub fn submesh_to_static_mesh(sub: &RenderSubmesh) -> Mesh {
build_submesh_mesh(sub, RenderAssetUsages::RENDER_WORLD)
}
/// Build the **skinned** twin of [`submesh_to_static_mesh`]: the same baked geometry plus the
/// per-vertex [`ATTRIBUTE_WOW_JOINT_INDEX`] + [`ATTRIBUTE_WOW_JOINT_WEIGHT`]. These two
/// attributes are the entire trigger for the owned-palette skinning path (decisions 0019/0720).
/// When the submesh carries no skin (WMO / a boneless batch) this is identical to the static
/// mesh — harmless, but the rigged paths are the only consumers regardless. Keeps the default
/// `MAIN_WORLD | RENDER_WORLD` usages: the mouseover picker skins these vertices on the CPU
/// (`target::hover`), so the main-world copy is read, not waste.
pub fn submesh_to_skinned_mesh(sub: &RenderSubmesh) -> Mesh {
let mut mesh = build_submesh_mesh(sub, RenderAssetUsages::default());
if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() {
mesh.insert_attribute(
ATTRIBUTE_WOW_JOINT_INDEX,
VertexAttributeValues::Uint16x4(sub.joints.clone()),
);
mesh.insert_attribute(
ATTRIBUTE_WOW_JOINT_WEIGHT,
VertexAttributeValues::Float32x4(sub.weights.clone()),
);
}
mesh
}
/// One joint of a model's rest skeleton, baked to Bevy space (decision 0019): its parent joint index
/// (`-1` = root → parented to the entity) and its **local** rest translation. The skinned creature
/// path spawns one entity per joint carrying this translation; animation later drives each joint's TRS.
#[derive(Clone, Copy)]
pub struct ModelJoint {
pub parent: i16,
pub local_translation: Vec3,
/// The bone's billboard arm, if authored (M2 bone flags `0x08/0x10/0x20/0x40`). The byte law
/// replaces the billboarded bone's rotation IN THE PALETTE, so children — and any geometry
/// skinned to them — inherit the camera facing (the frost-armor sheets skin to the scale-in
/// CHILD of a lock-Z bone). Rigged hosts feed this to the billboard joint pass; the per-batch
/// card split only covers geometry skinned to the billboard bone itself.
///
/// Authored on every bone that carries the flag, welded seam or not: `m2_animate` reads no
/// vertex, weight or triangle data at all, so there is no topology gate on the arm (decision
/// 0945 superseding 0935). A welded seam stays welded because the replacement is built about
/// the bone's own pivot, which is what makes the blended seam ring move by millimetres.
pub billboard: Option<benilla_formats::BillboardKind>,
/// Bone flags `0x1/0x2/0x4` (see [`benilla_formats::SkeletonBone::parent_arm`]): how the
/// bone's effective PARENT matrix is rebuilt from the model's own root frame before it
/// composes. Applied by the same palette passes as `billboard`, and *before* them.
pub parent_arm: Option<benilla_formats::ParentArm>,
}
/// A model's rest skeleton in Bevy space (decision 0019). Built once per M2 asset; the creature path
/// spawns a joint-entity hierarchy per instance from `joints` and shares the matching inverse bind
/// poses ([`build_skeleton`]'s second return) across instances.
#[derive(Clone, Default)]
pub struct ModelSkeleton {
pub joints: Vec<ModelJoint>,
/// The bone whose `KeyBoneID == 4` (SpineLow) — the client's display-facing counter-twist spine
/// channel (`0x711f10(4, …)` in the `0x607ed0` body-facing tail): while a unit's rendered root
/// yaw is offset from its aim (a strafe), this subtree counter-rotates back toward the aim.
/// `None` when the model lacks the key-bone (beasts, props).
pub spine_bone: Option<u16>,
/// The bone whose `KeyBoneID == 6` (Head) — the twist's head channel (`0x711f10(6, …)`), taking
/// the gap the spine channel leaves so the head lands back on the aim. `None` when absent.
pub head_bone: Option<u16>,
}
/// Bake a raw [`Skeleton`] into the Bevy-space [`ModelSkeleton`] + its inverse-bind-pose matrices.
///
/// Verified rig math (wow-5875-re: vanilla M2 has no inverse-bind array, rest pose is identity TRS,
/// the **pivot encodes bind position**) composed with Bevy's joint formula
/// (`joint_matrix = joint_global · inverse_bindpose`, which *becomes* `world_from_local`):
/// - **inverse bind pose** `i = translate(−pivot_i)` (model→bone-local at bind),
/// - **joint rest-local translation** = `pivot_i − pivot_parent` (pure translations, so they telescope
/// to `pivot_i` up the chain). At rest every joint matrix collapses to the entity transform, so the
/// creature renders exactly where the static mesh did, undeformed — even with a scaled entity
/// transform (the two pivot translations cancel before scale acts).
///
/// Pivots map WoW→Bevy via [`wow_to_bevy`] (a pure rotation), so the whole skeleton lives in mesh space.
pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec<Mat4>) {
let pivots = skeleton_pivots(skel);
let joints = skel
.bones
.iter()
.enumerate()
.map(|(i, b)| {
let parent_pivot = usize::try_from(b.parent)
.ok()
.and_then(|p| pivots.get(p).copied())
.unwrap_or(Vec3::ZERO);
ModelJoint {
parent: b.parent,
local_translation: pivots[i] - parent_pivot,
billboard: b.billboard,
parent_arm: b.parent_arm,
}
})
.collect();
let inverse_bindposes = pivots.iter().map(|p| Mat4::from_translation(-*p)).collect();
// A bone carries KeyBoneID `k` iff `keyBoneLookup[k]` points at it (same rule as
// [`upper_subtree_root`]); 4 = SpineLow, 6 = Head — the display-facing twist channels.
let key_bone = |k: i16| {
skel.bones
.iter()
.position(|b| b.key_bone == k)
.map(|i| i as u16)
};
(
ModelSkeleton {
joints,
spine_bone: key_bone(4),
head_bone: key_bone(6),
},
inverse_bindposes,
)
}
/// Each bone's Bevy-space bind-pose pivot (`wow_to_bevy(bone.pivot)`), in file order — the one
/// source [`build_skeleton`] (inverse bind poses) and [`build_attachments`] (attach-point offsets)
/// both derive from, so the two never compute a divergent pivot.
pub(crate) fn skeleton_pivots(skel: &Skeleton) -> Vec<Vec3> {
skel.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect()
}
/// One M2 attachment point (decision 0072 — held items), baked to a **Bevy-space bone-local
/// offset**: a child spawned under the bone's joint entity at `Transform::from_translation(offset)`
/// sits exactly at the attach point at bind pose and rides the bone's animation thereafter.
/// `offset` is `≈ Vec3::ZERO` on character models (the attach bones are leaves sitting exactly at
/// the attach point — VERIFIED, direct dump of `HumanMale.m2`, `decisions/0072`).
#[derive(Clone, Copy)]
pub struct ModelAttachment {
pub id: u16,
pub bone: u16,
pub offset: Vec3,
}
/// The right/left **arm subtree roots** for the per-arm animation masks: each hand attachment's
/// (id 1 = right, id 2 = left) bone walked up to its shoulder-keybone ancestor (keybones 2/3 —
/// either; the walk just finds "the shoulder above this hand", so no left/right convention is
/// assumed). `None` when the model lacks either hand attachment or a shoulder ancestor (beasts,
/// props — they never play per-slot one-shots).
pub(crate) fn arm_subtree_roots(
skel: &Skeleton,
attachments: &[ModelAttachment],
) -> Option<(usize, usize)> {
let shoulder_of = |hand_attach_id: u16| -> Option<usize> {
let mut bone = attachments
.iter()
.find(|a| a.id == hand_attach_id)
.map(|a| a.bone as usize)?;
loop {
let b = skel.bones.get(bone)?;
if matches!(b.key_bone, 2 | 3) {
return Some(bone);
}
bone = usize::try_from(b.parent).ok()?;
}
};
Some((shoulder_of(1)?, shoulder_of(2)?))
}
/// The model's **upper-body split key-bone** subtree root — the client's `CGUnit+0xd5c`, selected by
/// the capability probe `0x60ce70` (`keyBoneLookup[4]` preferred, `[6]` fallback, else the −1
/// sentinel = no split). On HumanMale `keyBoneLookup[4]` is bone 20 (SpineLow: chest/shoulders/arms/
/// hands/head — a subtree that provably **excludes** the legs, which hang off the pelvis sibling
/// bone 21), so a clip masked to this subtree moves the upper body only (wow-re
/// `anim-composition-model.md` §1, `923ac7bc`). A bone carries KeyBoneID `k` iff `keyBoneLookup[k]`
/// points at it, so the SpineLow bone is the one whose `key_bone == 4` (else `== 6`). `None` when the
/// model has neither (the −1 sentinel) — the one-shot route then falls back to full-body.
pub(crate) fn upper_subtree_root(skel: &Skeleton) -> Option<usize> {
let bone_with = |k: i16| skel.bones.iter().position(|b| b.key_bone == k);
bone_with(4).or_else(|| bone_with(6))
}
/// The model's **finger key-bone subtree roots** per hand `(right, left)` — the client's `CloseHand`
/// targets. WoW rigs each hand's fingers under **key-bones 8–12 (mainhand/right)** and **13–17 (offhand/
/// left)**; the grip pose (`HandsClosed`, AnimationData 15) is armed on exactly these when a weapon is
/// held in that hand (wow-re `hand-grip-mechanism.md`: `0x479660`/`0x60b590`). Only the *rigged* ones
/// exist (HumanMale carries 11/12 and 16/17; the rest are the −1 sentinel), so each entry is the set of
/// bones actually carrying a finger key-bone in that hand's range — **empty** for a model with no finger
/// key-bones (beasts/props), which never grip.
pub(crate) fn finger_subtree_roots(skel: &Skeleton) -> [Vec<usize>; 2] {
let roots = |lo: i16, hi: i16| -> Vec<usize> {
skel.bones
.iter()
.enumerate()
.filter(|(_, b)| (lo..=hi).contains(&b.key_bone))
.map(|(i, _)| i)
.collect()
};
[roots(8, 12), roots(13, 17)]
}
/// Whether bone `i` is `root` or one of its descendants (a parent-chain walk).
pub(crate) fn in_subtree(skel: &Skeleton, i: usize, root: usize) -> bool {
let mut cur = i;
loop {
if cur == root {
return true;
}
match skel
.bones
.get(cur)
.and_then(|b| usize::try_from(b.parent).ok())
{
Some(p) => cur = p,
None => return false,
}
}
}
/// Bake the raw [`M2Attachment`] table into [`ModelAttachment`]s: `offset = wow_to_bevy(position) −
/// pivot_bevy(bone)`, the bone-local offset from the joint's bind position. Records whose `bone`
/// indexes past `pivots` are dropped (defence in depth — `benilla-m2` already range-checks against
/// its own bone array; this guards against the skeleton/attachment parses ever disagreeing).
pub(crate) fn build_attachments(
attachments: &[M2Attachment],
pivots: &[Vec3],
) -> Vec<ModelAttachment> {
attachments
.iter()
.filter_map(|a| {
let pivot = pivots.get(a.bone as usize)?;
Some(ModelAttachment {
id: a.id,
bone: a.bone,
offset: wow_to_bevy(a.position) - *pivot,
})
})
.collect()
}
/// One M2 animation-event **positional marker**, baked exactly like [`ModelAttachment`] (same
/// bone-local Bevy-space offset convention — a consumer transforms it by the bone joint's live
/// global). The client's by-4CC position queries (`0x7130e0`/`0x7131b0`) read this table first
/// match: the missile launch points `$CSL`/`$CSR`/`$CST` (casting hand) and `$BWR` (ranged
/// release) are the consumers here.
#[derive(Clone, Copy)]
pub struct ModelMarker {
/// The identifier 4CC, stored forward (`*b"$CSL"`).
pub ident: [u8; 4],
pub bone: u16,
pub offset: Vec3,
}
/// Bake the raw [`benilla_formats::EventMarker`] table into [`ModelMarker`]s — the same
/// `offset = wow_to_bevy(position) − pivot_bevy(bone)` bake and the same defence-in-depth bone
/// range check as [`build_attachments`]. File order is preserved (queries take the first ident
/// match, the client's scan order).
pub(crate) fn build_markers(
markers: &[benilla_formats::EventMarker],
pivots: &[Vec3],
) -> Vec<ModelMarker> {
markers
.iter()
.filter_map(|m| {
let pivot = pivots.get(m.bone as usize)?;
Some(ModelMarker {
ident: m.ident,
bone: m.bone,
offset: wow_to_bevy(m.position) - *pivot,
})
})
.collect()
}
/// A stable [`AnimationTargetId`] for bone index `bone` (decision 0019). The idle clip's curves and the
/// joint entities' target ids both derive from the bone index (via a synthetic name), so they match
/// without a real `Name` hierarchy. Scoped to one creature's joints + its shared clip.
pub fn bone_target_id(bone: u16) -> AnimationTargetId {
AnimationTargetId::from_name(&Name::new(format!("benilla_bone_{bone}")))
}
/// The WoW→Bevy basis change as a quaternion. DR 0002's map is a proper rotation (det +1); a bone
/// rotation track is a WoW-space quat, and a rotation transforms under a basis change by **conjugation**
/// `r·q·r⁻¹`, so this `r` is what [`build_idle_clip`] conjugates each rotation keyframe by. Derived from
/// `wow_to_bevy` itself (its images of the basis vectors are `R`'s columns) so it can't drift from it.
fn wow_to_bevy_quat() -> Quat {
Quat::from_mat3(&Mat3::from_cols(
wow_to_bevy([1.0, 0.0, 0.0]),
wow_to_bevy([0.0, 1.0, 0.0]),
wow_to_bevy([0.0, 0.0, 1.0]),
))
}
/// Build a keyframe curve, handling Bevy's ≥2-sample requirement: a single key (a constant channel)
/// becomes a flat curve spanning `[0, duration]`. `None` for no keys (the channel stays at rest).
fn keyframe_curve<T: Animatable + Clone>(
duration: f32,
keys: Vec<(f32, T)>,
) -> Option<AnimatableKeyframeCurve<T>> {
match keys.len() {
0 => None,
1 => AnimatableKeyframeCurve::new([
(0.0, keys[0].1.clone()),
(duration.max(1e-3), keys[0].1.clone()),
])
.ok(),
_ => AnimatableKeyframeCurve::new(keys).ok(),
}
}
/// Build one sequence's [`AnimationClip`] — **and its [`PoseClip`] twin** (decision 0712, one walk
/// so the two cannot drift) — from parsed raw-WoW keyframes (decision 0019), transforming each
/// channel into the joints' Bevy-space `Transform`:
/// - **translation** = the bone's rest local (`pivot − pivot_parent`) **plus** `wow_to_bevy(track)` —
/// the M2 translation track is a delta on the pivot offset;
/// - **rotation** = the WoW quat conjugated into Bevy space (`r·q·r⁻¹`);
/// - **scale** = the WoW scale with axes permuted to Bevy's (magnitudes; near-always uniform anyway).
///
/// Always returns a clip, and a flag for whether any channel produced a **curve**. A sequence
/// whose bones all hold bind pose still gets one — an empty clip carrying the sequence's own
/// duration — because the clip is not only a bone pose: it is this model's carrier for the
/// instance's **sequence clock** (which slot is playing, and how far into it), which the emitter
/// rate/params tracks, the material-alpha loops and the GameObject state arm all resolve through.
/// Dropping the track-less ones left 807 corpus models — the ones whose animation is authored
/// entirely in the *emitter* tracks — with no clock at all, frozen on slot 0 at t=0 for ever
/// (decision 0941). The flag is what the doodad content gate reads instead (`poses_bones`): a clip
/// with no curves can only ever render the bind-pose mesh, so it must not spawn a rig.
pub(crate) fn build_animation_clip(
anim: &ModelAnimation,
skeleton: &ModelSkeleton,
) -> (AnimationClip, PoseClip, bool) {
let r = wow_to_bevy_quat();
let mut clip = AnimationClip::default();
let mut pose = PoseClip::default();
let mut any = false;
for bk in &anim.bones {
let target = bone_target_id(bk.bone);
let rest = skeleton
.joints
.get(bk.bone as usize)
.map_or(Vec3::ZERO, |j| j.local_translation);
let trans: Vec<_> = bk
.translation
.iter()
.map(|(t, v)| (*t, rest + wow_to_bevy(*v)))
.collect();
let pose_trans = PoseTrack::new(&trans);
if let Some(c) = keyframe_curve(anim.duration, trans) {
clip.add_curve_to_target(
target,
AnimatableCurve::new(animated_field!(Transform::translation), c),
);
any = true;
}
let rot: Vec<_> = bk
.rotation
.iter()
.map(|(t, q)| {
(
*t,
r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse(),
)
})
.collect();
let pose_rot = PoseTrack::new(&rot);
if let Some(c) = keyframe_curve(anim.duration, rot) {
clip.add_curve_to_target(
target,
AnimatableCurve::new(animated_field!(Transform::rotation), c),
);
any = true;
}
let scale: Vec<_> = bk
.scale
.iter()
.map(|(t, s)| (*t, Vec3::new(s[1], s[2], s[0])))
.collect();
let pose_scale = PoseTrack::new(&scale);
if let Some(c) = keyframe_curve(anim.duration, scale) {
clip.add_curve_to_target(
target,
AnimatableCurve::new(animated_field!(Transform::scale), c),
);
any = true;
}
pose.push(PoseBone {
bone: bk.bone,
translation: pose_trans,
rotation: pose_rot,
scale: pose_scale,
});
}
if !any {
// Bevy derives a clip's duration from its curves; with none, the sequence's own band
// length is the clock's period — without it the player would wrap on a 0-second loop
// (a modulo by zero) and the clock would never leave t=0, which is the whole defect.
clip.set_duration(anim.duration.max(1e-3));
}
(clip, pose, any)
}
/// Build the weapon-grip **overlay clip** from clamped finger poses ([`benilla_formats::hand_grip_finger_poses`]):
/// a single constant rotation key per finger key-bone, conjugated into Bevy space with the same `r·q·r⁻¹`
/// as [`build_animation_clip`]. Played masked to one hand's finger subtrees, it holds those fingers curled
/// over the gait (rotation only — the fingers don't translate). Empty `poses` → an empty clip (the caller
/// builds no grip node).
pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> (AnimationClip, PoseClip) {
let r = wow_to_bevy_quat();
let mut clip = AnimationClip::default();
let mut pose = PoseClip::default();
for &(bone, q) in poses {
let rot = r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse();
if let Some(c) = keyframe_curve(0.033, vec![(0.0, rot)]) {
clip.add_curve_to_target(
bone_target_id(bone),
AnimatableCurve::new(animated_field!(Transform::rotation), c),
);
pose.push(PoseBone {
bone,
translation: PoseTrack::default(),
rotation: PoseTrack::new(&[(0.0, rot)]),
scale: PoseTrack::default(),
});
}
}
(clip, pose)
}
/// The [`BillboardInfo`] for a submesh that rides a billboard bone (Bevy space): the pivot to rotate
/// about. `None` for ordinary geometry. Pairs with [`build_submesh_mesh`], which centres the mesh.
///
/// It used to also derive the card's resting plane normal from its first triangle. Nothing has read
/// that since the camera-basis law replaced the facet-normal card (0788's family), so it is deleted
/// rather than left as a field that reads as load-bearing.
pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option<BillboardInfo> {
let bb = sub.billboard.as_ref()?;
Some(BillboardInfo {
pivot: wow_to_bevy(bb.pivot),
bone: bb.bone,
kind: bb.kind,
scale_anim: bb.scale_anim.clone(),
// Translation values are model-space offsets — bake each key to Bevy axes here (scale keys
// above stay raw: per-axis factors, not vectors).
seq_translations: bb
.seq_translations
.iter()
.map(|(id, a)| {
let mut a = a.clone();
for (_, v) in &mut a.keys {
*v = wow_to_bevy(*v).to_array();
}
(*id, a)
})
.collect(),
})
}
/// A global-sequence bone channel baked to Bevy space: the free-clock loop the runtime samples at
/// `(model_time mod period)` and writes onto the joint. `period` and key times are **seconds**; values
/// are already in the joints' Bevy `Transform` frame. See [`GlobalBone`].
#[derive(Clone)]
pub struct GlobalSeqChannel<T> {
pub period: f32,
pub keys: Vec<(f32, T)>,
}
impl<T: Copy> GlobalSeqChannel<T> {
/// The two keys bracketing `t` (wrapped into `[0, period]`) and the fraction between them. Clamps to
/// the end keys (WoW authors the first/last key at the loop endpoints, so there is no wrap gap).
fn bracket(&self, t: f32) -> (T, T, f32) {
let period = self.period.max(1e-3);
let t = t.rem_euclid(period);
let keys = &self.keys;
if t <= keys[0].0 {
return (keys[0].1, keys[0].1, 0.0);
}
for w in keys.windows(2) {
if t <= w[1].0 {
let span = (w[1].0 - w[0].0).max(1e-6);
return (w[0].1, w[1].1, (t - w[0].0) / span);
}
}
let last = keys[keys.len() - 1].1;
(last, last, 0.0)
}
}
impl GlobalSeqChannel<Vec3> {
pub fn sample(&self, t: f32) -> Vec3 {
let (a, b, f) = self.bracket(t);
a.lerp(b, f)
}
}
impl GlobalSeqChannel<Quat> {
pub fn sample(&self, t: f32) -> Quat {
let (a, b, f) = self.bracket(t);
a.slerp(b, f)
}
}
/// A bone's global-sequence channels baked to Bevy space (see [`GlobalSeqChannel`]) — any of
/// translation / rotation / scale, each on its own free clock. The runtime writes only the driven
/// components onto the bone's joint entity, leaving the rest to the playing animation.
#[derive(Clone)]
pub struct GlobalBone {
pub bone: u16,
pub translation: Option<GlobalSeqChannel<Vec3>>,
pub rotation: Option<GlobalSeqChannel<Quat>>,
pub scale: Option<GlobalSeqChannel<Vec3>>,
}
/// Bake the raw [`benilla_formats::GlobalSeqBone`] channels into Bevy-space [`GlobalBone`]s, applying
/// the same per-channel transforms as [`build_animation_clip`]: translation = the bone's rest-local
/// (`pivot − pivot_parent`) plus `wow_to_bevy(track)`; rotation conjugated `r·q·r⁻¹`; scale axis-permuted
/// to Bevy's. Times/periods convert ms → seconds.
pub(crate) fn build_global_bones(
gseq: &[benilla_formats::GlobalSeqBone],
skeleton: &ModelSkeleton,
) -> Vec<GlobalBone> {
let r = wow_to_bevy_quat();
gseq.iter()
.map(|g| {
let rest = skeleton
.joints
.get(g.bone as usize)
.map_or(Vec3::ZERO, |j| j.local_translation);
let ms = |t: u32| t as f32 / 1000.0;
GlobalBone {
bone: g.bone,
translation: g.translation.as_ref().map(|c| GlobalSeqChannel {
period: ms(c.period_ms),
keys: c
.keys
.iter()
.map(|(t, v)| (ms(*t), rest + wow_to_bevy(*v)))
.collect(),
}),
rotation: g.rotation.as_ref().map(|c| GlobalSeqChannel {
period: ms(c.period_ms),
keys: c
.keys
.iter()
.map(|(t, q)| {
(
ms(*t),
r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse(),
)
})
.collect(),
}),
scale: g.scale.as_ref().map(|c| GlobalSeqChannel {
period: ms(c.period_ms),
keys: c
.keys
.iter()
.map(|(t, s)| (ms(*t), Vec3::new(s[1], s[2], s[0])))
.collect(),
}),
}
})
.collect()
}
/// Build ONE mesh from many placed static submeshes — the transforms baked into the vertices
/// (the production blob build, 1418; the retired `WOW_MEGA_STATIC` bracket's unfaded twin died
/// with it): same attribute recipe as [`submesh_to_static_mesh`], concatenated, with each part's
/// placement transform applied to positions and its rotation to normals (placements scale
/// uniformly, so directions survive), plus the per-vertex [`ATTRIBUTE_WOW_FADE_SPHERE`] (one
/// sphere per PART, replicated onto each of its vertices). `slots` (index-parallel with `parts`
/// when `Some`) additionally bakes each part's SH-probe slot as [`ATTRIBUTE_WOW_MERGED_SLOT`] —
/// the interior-prop lane. Parts lacking vertex colours pad white when any part has them — one
/// attribute set must cover the whole concatenation.
///
/// **The mesh is recentred on its union-AABB centre** and that world centre is returned last:
/// the caller places the entity AT the centre (`Transform::from_translation`), and the returned
/// min/max are mesh-LOCAL for its `Aabb`. A world-baked mesh on `Transform::IDENTITY` sorts the
/// entity at the WORLD ORIGIN in Bevy's transparent phase — ~9 400 yd out, drawn first among
/// ALL transparent content with the twin's depth-write ON, so a feathering blob's translucent
/// pixels depth-killed every transparent entity drawn after it (a per-entity fader behind a
/// 20 %-alpha merged fence vanished outright, popping in only when the sightline cleared the
/// segment — decision 1422). Centre-sort restores the same location-sort semantics every
/// per-entity draw has (whose sort key is its placement origin). The fade spheres stay
/// WORLD-space — the shader compares them against the camera, never against mesh-local
/// positions — so the recentring does not touch them.
pub fn merged_static_mesh_faded(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
spheres: &[Vec4],
slots: Option<&[u32]>,
) -> (Mesh, Vec3, Vec3, Vec3) {
merged_static_mesh_impl(parts, Some((spheres, slots)))
}
fn merged_static_mesh_impl(
parts: &[(std::sync::Arc<RenderSubmesh>, Transform)],
extras: Option<(&[Vec4], Option<&[u32]>)>,
) -> (Mesh, Vec3, Vec3, Vec3) {
let any_colors = parts
.iter()
.any(|(s, _)| s.vertex_colors.len() == s.positions.len());
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut colors: Vec<[f32; 4]> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut fade: Vec<[f32; 4]> = Vec::new();
let mut slot_verts: Vec<u32> = Vec::new();
let (mut mn, mut mx) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
for (i, (sub, transform)) in parts.iter().enumerate() {
let base = u32::try_from(positions.len()).expect("merged mesh under u32 vertices");
for p in &sub.positions {
let w = transform.transform_point(wow_to_bevy(*p));
mn = mn.min(w);
mx = mx.max(w);
positions.push(w.to_array());
}
if sub.normals.len() == sub.positions.len() {
let flip = sub.billboard_card_faces_away();
for n in &sub.normals {
let b = transform.rotation * wow_to_bevy(*n);
normals.push(if flip { -b } else { b }.normalize_or_zero().to_array());
}
} else {
// The lone compute_normals fallback path never merges (its models are rare and the
// bracket tolerates flat shading there): pad up, face normals are close enough for a
// measurement build.
normals.extend(std::iter::repeat_n([0.0, 1.0, 0.0], sub.positions.len()));
}
uvs.extend(sub.uvs.iter().copied());
if any_colors {
if sub.vertex_colors.len() == sub.positions.len() {
colors.extend(sub.vertex_colors.iter().copied());
} else {
colors.extend(std::iter::repeat_n(
[1.0, 1.0, 1.0, 1.0],
sub.positions.len(),
));
}
}
if let Some((spheres, slots)) = extras {
fade.extend(std::iter::repeat_n(
spheres[i].to_array(),
sub.positions.len(),
));
if let Some(slots) = slots {
slot_verts.extend(std::iter::repeat_n(slots[i], sub.positions.len()));
}
}
indices.extend(sub.indices.iter().map(|i| base + i));
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::RENDER_WORLD,
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
if any_colors {
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
}
if let Some((_, slots)) = extras {
mesh.insert_attribute(ATTRIBUTE_WOW_FADE_SPHERE, fade);
if slots.is_some() {
mesh.insert_attribute(
ATTRIBUTE_WOW_MERGED_SLOT,
bevy::mesh::VertexAttributeValues::Uint32(slot_verts),
);
}
}
mesh.insert_indices(Indices::U32(indices));
// Recentre on the union-AABB centre (see `merged_static_mesh`'s doc): positions become
// mesh-local, min/max follow, the centre goes back to the caller for the entity Transform.
// Guard the empty accumulation (no parts) — a MAX/MIN sentinel centre would explode.
// (`mn > mx` iff the vertex loop never ran; `positions` has been moved into the mesh.)
let center = if mn.x > mx.x {
Vec3::ZERO
} else {
(mn + mx) * 0.5
};
if center != Vec3::ZERO {
let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) =
mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION)
else {
unreachable!("positions were just inserted as Float32x3");
};
for p in pos.iter_mut() {
p[0] -= center.x;
p[1] -= center.y;
p[2] -= center.z;
}
}
(mesh, mn - center, mx - center, center)
}
#[cfg(test)]
mod tests {
use super::*;
/// One sequence record, `bones` supplied by the caller — everything else at a neutral value.
fn sequence(duration: f32, bones: Vec<benilla_formats::BoneKeys>) -> ModelAnimation {
ModelAnimation {
anim_id: 0,
seq_index: 0,
start_ms: 0,
end_ms: (duration * 1000.0) as u32,
duration,
looping: true,
move_speed: 0.0,
blend_time: 0.0,
bounds_center: [0.0; 3],
bounds_radius: 0.0,
bounds_min: [0.0; 3],
bounds_max: [0.0; 3],
frequency: 0,
min_replay: 0,
max_replay: 0,
bones,
events: Vec::new(),
}
}
/// A sequence whose bones hold bind pose still becomes a clip — an EMPTY one carrying the
/// sequence's own duration — because the clip is this model's carrier for the instance's
/// **sequence clock** (decision 0941). Dropping it left 807 corpus models whose animation is
/// authored purely in their emitter tracks with no clock at all: no player, so no slot and no
/// time, so every per-sequence track read file slot 0 at t = 0 for ever. The duration is the
/// load-bearing half — Bevy derives a clip's period from its curves, and a 0-second period
/// would wrap the player's `seek_time` by a modulo-zero and never leave t = 0.
#[test]
fn a_boneless_sequence_still_yields_a_clock_clip() {
let skeleton = ModelSkeleton {
joints: Vec::new(),
spine_bone: None,
head_bone: None,
};
let (clip, _pose, poses_bones) =
build_animation_clip(&sequence(1.333, Vec::new()), &skeleton);
assert!(!poses_bones, "no bone track ⇒ nothing to skin to");
assert!(
(clip.duration() - 1.333).abs() < 1e-4,
"the clock's period is the sequence's own band, not 0: {}",
clip.duration()
);
// The flag is what the doodad content gate reads: a clock-only clip must never spawn a rig.
let moving = benilla_formats::BoneKeys {
bone: 0,
translation: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [0.0, 0.0, 1.0])],
rotation: Vec::new(),
scale: Vec::new(),
};
let (_, _, poses_bones) = build_animation_clip(&sequence(1.0, vec![moving]), &skeleton);
assert!(poses_bones, "a keyed track ⇒ a real pose");
}
/// A [`GlobalSeqChannel`] samples with linear interpolation, clamps at the endpoint keys, and wraps
/// on its period — the eye-blink shape: `0` (open) held, a fast ramp to `1` (shut), back to `0`, and
/// the whole thing repeating every `period`. Models the real eyelid keys (ms → seconds).
#[test]
fn global_seq_channel_samples_and_wraps() {
let ch = GlobalSeqChannel {
period: 6.633,
keys: vec![
(0.0, Vec3::ZERO),
(0.033, Vec3::ONE),
(0.100, Vec3::ONE),
(0.133, Vec3::ZERO),
],
};
assert!(
ch.sample(0.0).abs_diff_eq(Vec3::ZERO, 1e-4),
"loop start = open"
);
assert!(
ch.sample(0.06).abs_diff_eq(Vec3::ONE, 1e-4),
"shut during the blink"
);
assert!(
ch.sample(3.0).abs_diff_eq(Vec3::ZERO, 1e-4),
"held open for the rest of the loop"
);
assert!(
ch.sample(0.0165).abs_diff_eq(Vec3::splat(0.5), 1e-2),
"linear ramp"
);
assert!(
ch.sample(6.633 + 0.06).abs_diff_eq(Vec3::ONE, 1e-4),
"wraps modulo period"
);
}
/// The WoW→Bevy rotation quat must reproduce `wow_to_bevy` on vectors — it's the same rotation, so
/// `r·v` equals the coordinate map. (Guards against `wow_to_bevy_quat` drifting from `wow_to_bevy`.)
#[test]
fn rotation_quat_matches_the_coordinate_map() {
let r = wow_to_bevy_quat();
for axis in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] {
let by_quat = r * Vec3::from_array(axis);
let by_map = wow_to_bevy(axis);
assert!(
by_quat.abs_diff_eq(by_map, 1e-5),
"r·{axis:?} = {by_quat:?} should equal wow_to_bevy = {by_map:?}"
);
}
}
/// A bone rotation about WoW **up** (+Z) must conjugate to a rotation about Bevy **up** (+Y) by the
/// same angle — the load-bearing check for the `r·q·r⁻¹` conjugation + the `[x,y,z,w]` quat order.
/// (A wrong conjugation/sign/order is exactly the "plausible but twisted creature" failure.)
#[test]
fn wow_up_yaw_conjugates_to_bevy_up_yaw() {
let r = wow_to_bevy_quat();
let theta = std::f32::consts::FRAC_PI_3; // 60°, an asymmetric angle
let (s, c) = (theta / 2.0).sin_cos();
// A WoW quat for a yaw about +Z: (x,y,z,w) = (0,0,sin,cos).
let q_wow = Quat::from_xyzw(0.0, 0.0, s, c);
let q_bevy = r * q_wow * r.inverse();
let expected = Quat::from_axis_angle(Vec3::Y, theta);
// Quats equal up to sign: |dot| ≈ 1.
assert!(
q_bevy.dot(expected).abs() > 0.9999,
"WoW +Z yaw should map to Bevy +Y yaw: got {q_bevy:?}, want {expected:?}"
);
}
/// Conjugating the identity rotation is the identity (a sanity floor for the conjugation).
#[test]
fn identity_rotation_conjugates_to_identity() {
let r = wow_to_bevy_quat();
let id = r * Quat::IDENTITY * r.inverse();
assert!(id.dot(Quat::IDENTITY).abs() > 0.9999, "got {id:?}");
}
/// **A welded billboard bone still reaches the palette with its arm** (decision 0945,
/// superseding 0935's gate) — on the real shipped asset, the director's own pauldron.
///
/// `LShoulder_Plate_PVPAlliance_A_01.m2` authors 3 bones: a plain root and two spherical
/// billboard spikes whose 8-vertex 50/50 seam rings stitch them to the body. 0935 dropped
/// their arm on exactly that welding. `m2_animate` reads no vertex, weight or triangle data at
/// all — over its whole extent there is a single `movzx byte ptr`, the arm-selector map — so
/// no such gate exists in the reference (wow-re `billboard-bone-law.md` §9.4 SQ1). The seam
/// survives because the replacement is built about the bone's own pivot, not because the arm
/// is skipped.
///
/// The predicate itself is still live and still correct where it belongs: the per-batch **card
/// split** refuses to lift a welded bone's geometry into its own draw (0839). This test pins
/// that the two lanes now disagree *on purpose* — welded for the card split, faced by the
/// palette. Skips when the client isn't installed (the repo ships no assets).
#[test]
fn a_welded_billboard_bone_still_reaches_the_palette_with_its_arm() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let path = "Item\\ObjectComponents\\Shoulder\\LShoulder_Plate_PVPAlliance_A_01.m2";
let bytes = chain.read_file(path).expect("read the pauldron");
let raw = benilla_formats::parse_m2_skeleton(&bytes).expect("parse its skeleton");
// The asset really does author two spherical arms — otherwise this test would pass on a
// build that had simply stopped parsing bone flags.
assert_eq!(
raw.bones.iter().filter(|b| b.billboard.is_some()).count(),
2,
"the M2 authors two billboard bones"
);
assert_eq!(
benilla_formats::non_separable_billboard_bones(&bytes),
vec![1, 2],
"both are welded to the body — the card split still refuses to lift them"
);
let (built, _) = build_skeleton(&raw);
assert_eq!(
built
.joints
.iter()
.filter(|j| j.billboard == Some(benilla_formats::BillboardKind::Spherical))
.count(),
2,
"…and the palette faces them anyway: welding is not a gate on the arm"
);
}
/// **The mount seat's parent arm survives the bake** — the load-bearing case, on the asset
/// that decides it.
///
/// `RidingHorse.m2` attachment 0 (the rider seat) rides bone 30, `flags = 0x6` = ignore parent
/// rotation + scale. That is why a galloping horse never rocks its rider: the seat's basis is
/// replaced by the mount's own root basis before the rider's frame is built from it, so the
/// spine's ~21° stride swing is discarded at the saddle while the seat still *translates* with
/// it (wow-re §9.1/§9.2, byte-verified). Every vanilla player mount is `0x4`, `0x6`, or has a
/// seat whose ancestors don't rotate.
#[test]
fn the_riding_horse_seat_bone_carries_the_root_basis_arm() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("Creature\\RidingHorse\\RidingHorse.m2")
.expect("read the horse");
let raw = benilla_formats::parse_m2_skeleton(&bytes).expect("parse its skeleton");
let seat = benilla_formats::parse_m2_attachments(&bytes)
.expect("parse attachments")
.into_iter()
.find(|a| a.id == 0)
.expect("the horse authors a rider seat");
assert_eq!(seat.bone, 30, "the seat rides bone 30");
let (built, _) = build_skeleton(&raw);
assert_eq!(
built.joints[usize::from(seat.bone)].parent_arm,
Some(benilla_formats::ParentArm {
ignore_translate: false,
basis: benilla_formats::ParentBasis::RootBasis,
}),
"the seat takes the mount's own root basis — the gallop reaches it as translation only"
);
// The spine bones it hangs off carry no arm: they are what supplies the discarded swing.
assert!(
built.joints[23].parent_arm.is_none() && built.joints[25].parent_arm.is_none(),
"the swinging spine bones are ordinary"
);
}
}