Files
benilla-octo/crates/benilla-assets/src/m2.rs
T
pkuzicandClaude Fable 5.1 082f3ebe50 MONKEY: lighting round — fire/lamp lights, light lanes, room gating, static torch cache
Point lights
- Synthesised lights for fire and lamp models that author none (benilla-formats
  fire_light.rs: flame route from the particle emitter's over-life colour, name and
  emissive-glass routes for lampposts/lanterns/candelabra; NoOmni braziers included —
  they are Orgrimmar's street lighting). Spawned on every lane: ADT doodads, WMO props
  in interior and exterior groups (2.5 yd dedupe against authored MOLT fixtures),
  GameObjects and creatures (carried). Cvar fireLightGain (synthetic only).
- Light LANE decided by a down-ray at the light's position: exterior consumers
  (terrain, models, WMO exterior batches — new per-vertex point term) read exterior
  lights; the interior room lane reads interior fixtures. Fixes interior fixtures
  leaking onto lawns and street torches lighting nothing.
- Interior attenuation: the authored MOLT reach replaces the flat 48 yd, with a soft
  long-tailed profile (constant 1.75 yd core so energy no longer scales with reach),
  cvar interiorAttenScale; a 1.6x default.
- ROOM GATE: a WMO surface takes an interior fixture only if the fixture claims its
  group — claims = containment ∪ MOLR ∪ portal-adjacent rooms (2 hops), shared rule in
  benilla-formats room_claim.rs, packed per light into a 32 KB storage buffer; portal
  claims are weighted (1 across the door frame, fading into the next room) so doorways
  carry no seam; in-building exterior-class groups take claimed fixtures. Carried
  lights (GameObjects, pets) claim rooms from their world position once settled.
  Cvar interiorRoomGate.

Torch shadows
- Casters ranked by contribution at the player with hysteresis and fade weights
  (no pop-in); eligibility 4R+3 and the shadowDistance setting; caster geometry
  gathered around the fixtures, not the camera.
- Static cube maps cached per fixture (rendered once), entity casters rendered per
  frame only for the nearest interiorShadowDynamic fixtures via a depth copy; up to
  16 resident maps (interiorShadowCasters), interiorShadowSoft PCF radius.
- Carried lights: rest-pose flame position (no hand-bone jitter), lane from the
  bearer's cross-fade weight, caster only while settled — the imp no longer strobes.

Entities
- Portal lane cross-fade (tag bits 15..18) and MATTE_INDOOR bit so a unit keeps its
  room lighting across a Bake/Matte flicker; hover highlight folded into the room lane;
  the blob shadow returns whenever the realtime sun lane is inactive (night, beyond
  shadow distance) with a crossfade at dusk/dawn.

Robustness
- A placement or WMO prop whose model fails to load is retired instead of pinning
  the loading screen forever (Stormwind's alliancebrasscannon_flat.m2).
- benilla-extract: wmolights (MOLT + claims + group classes), wmolamps (corpus audit),
  m2firescan (synthesis routes).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 16:08:36 +01:00

890 lines
52 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.
//! M2 → model asset loader.
//!
//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each
//! carrying the batch's **decoded geometry** plus its texture as a `Handle<Image>` **dependency**
//! (through the `mpq://` source). **The loader ships no meshes** (decision 0834, the model-lane
//! twin of 0832's terrain rule): a labeled mesh sub-asset lands the whole model's render form in
//! ONE frame — the city first-contact spike — so the app builds each batch's `Mesh` paced at the
//! spawn side (`benilla`'s `model_forms`, via [`submesh_to_static_mesh`](crate::submesh_to_static_mesh)
//! / [`submesh_to_skinned_mesh`](crate::submesh_to_skinned_mesh)).
//!
//! Deliberately app-independent: ships **geometry + texture handles + metadata only**, not materials
//! (the WoW lighting material is an app concern built at spawn) — so this asset is shared verbatim by
//! doodads, creatures, GameObjects, and the star model. Creature **skin variations** (`Monster1/2/3`)
//! are blank here (no `skins` passed) and resolved at the spawn site from `CreatureDisplayInfo`; this
//! loads the model's own embedded (hardcoded) textures.
//!
//! References use `.mdx`/`.mdl`, but the physical archive file is `.m2`; callers map the extension when
//! forming the `mpq://…m2` load path (the loader registers for `m2`).
use benilla_formats::{
hand_grip_finger_poses, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
parse_m2_collision_hull, parse_m2_global_sequence_bones, parse_m2_lights,
parse_m2_particle_emitters, parse_m2_playable_animation_lookup, parse_m2_portrait_camera,
parse_m2_render_submeshes, parse_m2_skeleton, CollisionMesh, M2Bounds, M2Light,
ParticleEmitterDef,
};
use bevy::animation::graph::AnimationGraph;
use bevy::asset::io::Reader;
use bevy::asset::{Asset, AssetLoader, LoadContext};
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
use bevy::prelude::*;
use bevy::reflect::TypePath;
use crate::bone_target_id;
use crate::coords::wow_to_bevy;
use crate::model::{
arm_subtree_roots, billboard_info, build_animation_clip, build_attachments, build_global_bones,
build_grip_clip, build_skeleton, finger_subtree_roots, in_subtree, skeleton_pivots,
upper_subtree_root, AnimClip, ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh,
};
/// A loaded M2 model: its render batches + authored bounds (the distance-fade size source) + the
/// coarse collision hull (the app bakes a collider from it per placement) + its particle emitters.
#[derive(Asset, TypePath, Clone)]
pub struct M2Model {
pub submeshes: Vec<ModelSubmesh>,
/// Authored bounding sphere/box (model-local yards) — the reference's distance-fade size. `None`
/// if the header bounds couldn't be read.
pub bounds: Option<M2Bounds>,
/// The doodad's collision hull in raw WoW model space (trunk/solid; ≪ render mesh). `None` when the
/// model carries no hull (`nBoundingTriangles == 0` — many small props are collide-iff-hull).
pub collision: Option<CollisionMesh>,
/// Particle emitters (flames, glows, smoke). Each carries its parsed def (positions in raw WoW
/// model space) + its resolved texture handle. Empty for most models; one or two for campfires.
pub emitters: Vec<ModelEmitter>,
/// Ribbon emitters (weapon trails, wisp streamers, missile trails — wow-re
/// `ribbon-emitter-spec.md`). Same shape as the particle emitters: parsed def + resolved trail
/// texture + the host bone's pivot for joint riding. Empty for nearly everything (176 models
/// corpus-wide author one).
pub ribbons: Vec<ModelRibbon>,
/// M2 light blocks (raw WoW model space + the host bone's rest pivot). The spawn site turns each
/// casting `type==1` (point) light into a Bevy `PointLight` that lays the faithful dynamic
/// hot-spot on every lit surface (decisions 0016/0273). Empty for nearly all models; one on fire
/// props (campfire/firepit/cooker/forge) — and one on the held torch every torch-bearing NPC
/// carries.
pub lights: Vec<ModelLight>,
/// The model's rest skeleton in Bevy space (decision 0019) — the joint tree the skinned creature
/// path spawns one entity per bone from. Empty for a boneless model.
pub skeleton: ModelSkeleton,
/// The matching inverse bind poses (`translate(−pivot)` per bone), shared across every instance of
/// this model; each skinned submesh references it from its `SkinnedMesh`. A labeled sub-asset.
pub inverse_bindposes: Handle<SkinnedMeshInverseBindposes>,
/// The model's animations, ready to play (decision 0019) — one shared `AnimationGraph` with every
/// sequence's clip. `None` for a model with no animated sequences (static props, boneless models).
/// Built once here; each creature instance drives it with its own `AnimationPlayer`.
pub animations: Option<ModelAnimations>,
/// The **file-order-first sequence's authored duration** (seconds, one full pass) — carried even
/// when no clip builds ([`ModelAnimations`] drops sequences that move no bone, and a model whose
/// sequences all sit still has no `animations` at all). The real client's effect-completion
/// clock runs one pass of the armed sequence regardless of bone keys or the LOOP flag
/// (byte-verified, wow-re `ceffect-selfterm.md`: the fire is the CM2 advance `0x7194f8`, a pure
/// end-boundary compare; loop is tested only *after* it) — the spell-fx self-termination reads
/// this. Named approximation: the client arms `animationLookup[0]`'s sequence, not strictly the
/// file-order-first one; they coincide on every effect model probed (single-sequence models).
/// `None` for a model with no sequences at all.
pub first_seq_span: Option<f32>,
/// The model's attachment points (decision 0072 — held items): weapon/shield hand slots, sheath
/// points, etc. Each carries the bone it rides + its bind-pose-relative offset (see
/// [`ModelAttachment`]). Empty for a model with no attachment table (most doodads/WMO props).
pub attachments: Vec<ModelAttachment>,
/// The animation-event table's positional markers (`$CSL`/`$CSR`/`$CST` casting hands, `$BWR`
/// ranged release, …), baked like [`Self::attachments`] and queried by 4CC first-match — the
/// client's `0x7130e0`/`0x7131b0` surface. Empty for a model with no events.
pub markers: Vec<crate::ModelMarker>,
/// The model's authored **portrait camera** (`benilla_formats::M2PortraitCamera` baked to Bevy
/// space) — the exact rig the real client renders the unit-frame portrait through (VERIFIED,
/// wow-re portrait-render §4: `cameraLookup[0]`, `lookAt` + authored perspective, no engine-side
/// framing on top). `None` for a model with no camera table (props, a few creatures).
pub portrait_camera: Option<PortraitCamera>,
/// The camera **table's first record** — the glue screens' `Model:SetCamera(0)` rig (the
/// create/select background scenes; their camera *lookup* is the 0xffff none sentinel, so
/// `portrait_camera` sees nothing there). Same Bevy-space conversion as `portrait_camera`.
pub camera0: Option<PortraitCamera>,
/// The camera table's **second** record — what a 1.12 `<PlayerModel>` UI widget (the paper doll,
/// the inspect pane, the pet page) renders through (VERIFIED, wow-re
/// `ui/scratch/modelframe-camera-law.md`: `0x505b30` → the chooser `0x505890` selects **raw
/// index 1**, the `type == 1` "characterinfo" camera, and freezes it at `0x7acf10`).
///
/// **Raw, not through `cameraLookup`** — that array is the portrait bake's path and is not
/// consulted here; the index is a literal 1 whatever the record's `type` says. `None` for a
/// model with fewer than two cameras, where the client synthesizes a *fixed* camera instead
/// (transcribed at the framing site). Same Bevy-space conversion as `portrait_camera`.
pub pane_camera: Option<PortraitCamera>,
/// A bow's `$WTT`/`$WTB` bowstring anchors (wow-re `nocked-ammo-cancel.md` §G2), baked to Bevy
/// space: `[top, bottom]` as `(bone index, model-local position)` — the two limb-tip points the
/// engine-drawn string spans. `None` for every non-bow model.
pub string_anchors: Option<[(u16, Vec3); 2]>,
/// The fishing pole's `$CCH` line anchor (wow-re `fishing-line.md` §2), baked to Bevy space in
/// the mesh frame — the rod-tip point the engine-drawn fishing line starts from. `None` for
/// every model that doesn't author it (exactly one weapon model in the chain does).
pub cch_marker: Option<Vec3>,
/// MD20 header `GlobalModelFlags` (`+0x10`). Bits `&3` are the **terrain-conform gate**
/// (wow-re `terrain-tilt.md`, §5 byte-verified): `1` = pitch to the ground slope (every
/// mount + most quadrupeds), `3` = pitch **and** roll (kodo/crab/spider), `0`/`2` = level.
/// The entity layer reads it to conform a standing model to the terrain under it.
pub global_flags: u32,
}
/// An M2's authored portrait-camera rig in Bevy space (see [`M2Model::portrait_camera`]): eye/target
/// in model-local yards at scale 1 (the client bakes portraits with root scale reset to 1), the
/// projection scalars straight from the record.
#[derive(Clone, Copy)]
pub struct PortraitCamera {
pub eye: Vec3,
pub target: Vec3,
/// Roll about the eye→target axis (radians) — rotates the up vector; `0.0` on every portrait
/// camera audited (kept for fidelity, not observed nonzero).
pub roll: f32,
/// The record's FOV (radians), verbatim — a **diagonal** angle in the client's convention;
/// the consumer builds the client's diagonal-FOV projection from it at whichever aspect its
/// path uses (`benilla`'s `WowPortraitProjection`) — `1.0` for the round portrait bake, so
/// `fovy = fov/√2` (decision 1543), the pane's own rect for a `<PlayerModel>` (1089).
pub fov: f32,
pub near: f32,
pub far: f32,
}
/// The **billboard frame** an emitter's bone chain reaches — the host bone's arm, its pivot in raw
/// WoW model space, and **which bone it is**. See [`ModelEmitter::billboard`] for when it is `Some`
/// and why the emitter's live origin is then `pivot + camBasis·(def.position − pivot)`.
///
/// The bone index is what lets a **rigged** consumer be exactly right rather than approximately:
/// seating the replacement frame on that bone's own joint puts it at the composed pivot with the
/// chain above it already folded in, where placing it at `pivot` under the model root silently
/// assumes every ancestor is at rest.
#[derive(Clone, Copy, Debug)]
pub struct EmitterBillboard {
pub kind: benilla_formats::BillboardKind,
/// The host bone's pivot, **raw WoW model space**.
pub pivot: [f32; 3],
/// The host bone's index — its seat in the skeleton, for a consumer that has a rig.
pub bone: u16,
}
/// One particle emitter of an [`M2Model`]: the parsed [`ParticleEmitterDef`] plus its resolved
/// particle texture as a `Handle<Image>` dependency (loaded through the `mpq://` source, like a
/// submesh albedo). The app's particle system simulates + renders it at the spawn site.
#[derive(Clone)]
pub struct ModelEmitter {
pub def: ParticleEmitterDef,
pub texture: Option<Handle<Image>>,
/// The host bone's (`def.bone`) pivot, **raw WoW model space** (0130 phase 4). `def.position` is
/// model-space; `position − bone_pivot` is the same point in the bone's own frame — the offset
/// an emitter riding a live joint composes through the joint's transform. Rest pose is identity
/// about the pivot, so the static path (`placement · position`) is the exact special case.
/// `[0; 3]` when the bone index is out of range (or the model is boneless).
pub bone_pivot: [f32; 3],
/// The **billboard frame** this emitter rides, when its bone chain reaches a billboard bone
/// (`benilla_formats::Skeleton::billboard_host`): that bone's arm and its pivot, **raw WoW
/// model space**. The emitter's live origin is then camera-dependent —
/// `pivot + camBasis·(def.position − pivot)` — because the reference folds the record position
/// through the *replaced* palette matrix (wow-re `part-anchoring-live-bone.md` §1 row 3,
/// `billboard-bone-law.md`'s "children multiply onto this").
///
/// A rigged host in the WORLD needs nothing from this: its joint palette already carries the
/// replacement (`billboard::billboard_joint_palette`) and the emitter rides the joint. Two
/// lanes still do: the spawn paths with **no rig** — an equipped item's model
/// (helm/shoulders/held), where the whole affected population's chains are otherwise rest-pose
/// (corpus-verified: 95 item models / 197 emitters ride a billboard chain, **zero** with an
/// animating one — `benilla-extract bbscan`) — and every **booth** bake, rigged or not, whose
/// pose deliberately drops the camera arms (`RigPose::without_camera_billboards`: the world
/// pass would face them at the *world* camera, which is not the one drawing a booth). `None`
/// for the ordinary case.
pub billboard: Option<EmitterBillboard>,
/// The **recursion model** (`def.recursion_model`) as a loaded dependency: its own emitters
/// become this emitter's CHILD emitters (wow-re `part-child-recursion.md`), wired by the
/// app's particle system once the asset resolves. `None` when unauthored.
pub recursion: Option<Handle<M2Model>>,
/// The **geometry model** (`def.geometry_model`) as a loaded dependency: this emitter's
/// particles render as 3-D instances of it instead of billboard quads (wow-re
/// `part-model-particles.md`). `None` when unauthored.
pub geometry: Option<Handle<M2Model>>,
/// How far the OWNER model's own transparent-pass batches sort from its origin, model-local
/// yards — the bound the draw-order rung is sized from (`particles::owner_last_bias`,
/// decisions 0719/0721). Computed by [`benilla_formats::m2_owner_reach`], which is where the
/// reasoning lives.
pub owner_reach: f32,
/// The OWNER model's authored bound sphere — the water-plane classification input (wow-re
/// `water-frame-straddle.md` §6, byte-VERIFIED): the reference dots `world_matrix ×
/// (bbox_min+bbox_max)/2` against the plane once per MODEL and every emitter reads that one
/// verdict, with the slack `above ⇔ d ≥ −r`, `r = |matrix row 0| × sphere radius`. Centre in
/// **Bevy model space** (the same frame the instance transform maps), radius model-local
/// yards. `(ZERO, 0)` when the header carries no bounds — the sign test at the anchor.
pub water_bound: (Vec3, f32),
/// The OWNER model's **loader-idle file sequence slot**
/// ([`crate::ModelAnimations::idle_seq`]) — the sequence every M2 instance is playing when no
/// rig has armed anything, and therefore the slot this emitter's per-sequence rate/gate/param
/// bakes sample against by default (decision 0936). Baked onto the emitter, not asked of the
/// spawn site, because the lanes that need it most are exactly the ones with no rig to ask:
/// a content-gated GameObject (`spawn_emitter`'s `EmitClock::Host` whose player armed nothing)
/// and an unrigged placed doodad (`EmitClock::Pinned`). `0` for a model with no sequences —
/// the same degrade as before, now stated once instead of falling out of an `unwrap_or`.
pub idle_seq: usize,
}
/// Whether looping `anim` would look like anything other than the **static mesh** — the doodad
/// content gate (decision 0130, premise corrected by 0637).
///
/// The original gate asked "does some track have >1 key", reasoning that a constant pose renders
/// identically to the un-rigged mesh. That premise is false: the static mesh is the **bind** pose,
/// and a constant clip only equals it when the constant *is* rest. `DuelingFlag.m2` is the
/// counter-example that cost us the bug — its Stand band brackets to a constant bone-0 translation
/// of `−9.124` (the flag planted in the ground) while its bind pose is 9 yards up in the air, so a
/// "constant ⇒ skip the rig" gate renders it floating.
///
/// So: a track that MOVES qualifies, and so does a constant sitting away from rest — translation
/// ≠ 0 (the M2 translation track is a delta on the pivot offset), rotation ≠ identity, scale ≠ 1.
/// Genuinely rest-posed idles — the ~90% of placed doodads decision 0130 measured — still take the
/// static path, so that optimization survives intact.
///
/// The predicate itself is `ModelAnimation::is_rest_pose`, beside the parse, so
/// `benilla-extract idleslotscan`'s corpus census of this gate's reach can't drift from the gate
/// (decision 0936). What it decides is **whether to build a rig**, and nothing else — the sequence
/// the instance is playing is [`ModelAnimations::idle_seq`], which has an answer either way.
fn idle_pose_differs(anim: &benilla_formats::ModelAnimation) -> bool {
!anim.is_rest_pose()
}
/// A model reference inside an M2 record (`.mdx`/`.mdl`, mixed case, backslashes) → its
/// `mpq://…m2` load URL — the same extension remap the client's loader does (the physical
/// archive file is `.m2`).
fn m2_dep_url(raw: &str) -> String {
let p = raw.to_ascii_lowercase().replace('\\', "/");
let stem = p
.strip_suffix(".mdx")
.or_else(|| p.strip_suffix(".mdl"))
.or_else(|| p.strip_suffix(".m2"))
.unwrap_or(&p);
format!("mpq://{stem}.m2")
}
/// One ribbon emitter of an [`M2Model`]: the parsed [`benilla_formats::RibbonEmitterDef`] plus its
/// resolved trail texture and — like [`ModelEmitter::bone_pivot`] — the host bone's rest pivot, so
/// the spawn site can ride the bone's joint with `position − pivot` in the joint frame (the
/// reference transforms the bone-local position by the live bone matrix each frame, `0x718960`).
#[derive(Clone)]
pub struct ModelRibbon {
pub def: benilla_formats::RibbonEmitterDef,
pub texture: Option<Handle<Image>>,
pub bone_pivot: [f32; 3],
/// The owner model's reach — same field, same bound, same reason as
/// [`ModelEmitter::owner_reach`]. A trail is one of its model's emitters and takes the same
/// owner-last draw-order rung: measured on the wisp (14 blend batches, 3 streamers), its
/// trails otherwise interleave with its own body, 6 batches deep, and which batches are over
/// which streamer *changes frame to frame* as they whip (decision 0721).
pub owner_reach: f32,
/// The owner model's bound sphere for the water-plane side — same field, same law as
/// [`ModelEmitter::water_bound`]: the ribbon leg reads the MODEL's side-A boolean verbatim
/// (wow-re `water-frame-straddle.md` §6 — `cmp [ebp-0x24],0` at `0x7081f1`), slack included.
pub water_bound: (Vec3, f32),
}
/// One M2 light block of an [`M2Model`]: the raw [`M2Light`] record plus — like
/// [`ModelEmitter::bone_pivot`] — its host bone's rest pivot, so a light on an ANIMATING model can
/// ride that bone's joint with `position − pivot` in the joint frame. The reference does exactly
/// this every frame (`0x718960` transforms the def position by the live bone matrix before
/// registering the light), which is how a torch's glow tracks the hand that swings it.
#[derive(Clone, Copy)]
pub struct ModelLight {
pub def: M2Light,
pub bone_pivot: [f32; 3],
/// MONKEY (fire GO lights): `true` when this light was **synthesised** from the model's flame
/// particle emitter rather than read off an authored light block
/// ([`benilla_formats::fire_light`]). Only ~17 of the ~430 fire-ish props in the chain author
/// one, so without this every wall torch, magic brazier, forge and candle in the game lights
/// nothing.
///
/// The flag exists so a lane can REFUSE it, and one does: the WMO MODD prop lane. A building's
/// own MOLT fixtures already sit at its wall torches' flames (the artists place a companion
/// MOLT per fixture doodad — NSabbey ships one per candelabra), so a synthetic light on the
/// prop would double-light exactly the interiors that are already correct. Every other lane —
/// ADT map doodads, GameObjects/creatures, transport props — takes it like an authored one.
pub synthetic: bool,
}
/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`].
#[derive(Default, TypePath)]
pub struct M2ModelLoader;
impl AssetLoader for M2ModelLoader {
type Asset = M2Model;
type Settings = ();
type Error = std::io::Error;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &(),
ctx: &mut LoadContext<'_>,
) -> Result<M2Model, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}"));
// `dir` only feeds creature skin-variation resolution, which is inert here (we pass no skins —
// skins are applied at the spawn site from CreatureDisplayInfo), so an empty dir is correct.
let subs = parse_m2_render_submeshes(&bytes, "", &[]).map_err(to_io)?;
let bounds = parse_m2_bounds(&bytes).ok();
// The owner-last draw-order bound, once for the whole model: every effect this model
// authors (quad emitters, ribbon trails, model-particle instances) takes the rung it sizes,
// because the reference draws them all in the one post-batch bracket.
let owner_reach = benilla_formats::m2_owner_reach(&subs);
// The water-plane classification sphere, once for the whole model — the header AABB's
// midpoint (Bevy model frame) + the header sphere radius. See [`ModelEmitter::water_bound`].
let water_bound = bounds.as_ref().map_or((Vec3::ZERO, 0.0), |b| {
(
wow_to_bevy([
(b.bbox_min[0] + b.bbox_max[0]) * 0.5,
(b.bbox_min[1] + b.bbox_max[1]) * 0.5,
(b.bbox_min[2] + b.bbox_max[2]) * 0.5,
]),
b.sphere_radius,
)
});
// The collision hull (collide-iff-hull): keep only a non-empty mesh so the spawn site can skip
// hull-less props cheaply.
let collision = parse_m2_collision_hull(&bytes)
.ok()
.filter(|c| !c.is_empty());
let mut submeshes = Vec::with_capacity(subs.len());
for sub in subs {
// No meshes built here (decision 0834): the geometry ships on the submesh and the app
// builds the render form paced — the static mesh on demand, the skinned twin only for
// the lanes that rig (which also retires the old always-built, mostly-unused twin).
//
// Embedded texture → an mpq:// dependency (WorldArt, the loader default). Lowercase the
// path: Bevy's loader lookup is case-sensitive, and these tables carry uppercase `.BLP`
// (the `BlpImageLoader` registers `blp`), so an uppercase extension silently falls back to
// type-based resolution — which is ambiguous with Bevy's built-in image loader and spams a
// "Multiple AssetLoaders found" warning per texture. Lowercasing also dedupes case variants.
// The URL carries the batch's authored sampler address mode, because two modes of one
// `.blp` are two different `Image` uploads (decision 0763 — `crate::texture_url`).
let texture = sub
.texture
.as_deref()
.map(|t| ctx.load::<Image>(crate::texture_url(t, (sub.wrap_x, sub.wrap_y))));
submeshes.push(ModelSubmesh {
texture,
skin_slot: sub.skin_slot,
geoset_id: sub.geoset_id,
char_slot: sub.char_slot,
blend: sub.blend,
two_sided: sub.two_sided,
interior: false, // M2 has no interior/exterior group concept
emissive: sub.emissive, // M2 UNLIT (0x01) glass/glow → fullbright
sidn: None, // WMO-only (MOMT SIDN night glow)
window: false, // WMO-only (MOMT WINDOW interior light)
additive: sub.additive, // M2 blend 3/4 → additive (glow cards)
env_map: sub.env_map, // texture_unit_lookup > 2 → the runtime generates this stage's UVs
no_depth_write: sub.no_depth_write, // M2 render flag 0x10
no_depth_test: sub.no_depth_test, // M2 render flag 0x08
fog_policy: sub.fog_policy, // M2 render flag 0x02 / the per-blend fog table
billboard: billboard_info(&sub), // glow cards / chains face the camera
alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new),
uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new),
uv_seq: sub.uv_seq.clone().map(std::sync::Arc::new),
rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new),
rgb_seq: sub.rgb_seq.clone().map(std::sync::Arc::new),
wmo_batch: None, // M2 batches have no MOBA section
ground_quad: sub.ground_quad(), // flat ground-ring quads → the fx decal lane
geometry: std::sync::Arc::new(sub),
});
}
// M2 light blocks (raw — wow-m2's light parse reads the wrong vanilla record shape). The spawn
// site filters to casting point lights ([`M2Light::casts`]). Empty for nearly all models.
// The bone-pivot bake is below, once the skeleton is parsed.
let light_defs = parse_m2_lights(&bytes);
// The rest skeleton (decision 0019): bake the raw bone tree to Bevy-space joints + the matching
// inverse bind poses. A boneless model yields an empty skeleton + an empty bindpose set (the
// skinned mesh then carries no joint attributes, so it never takes the SKINNED path).
let skeleton_raw = parse_m2_skeleton(&bytes).unwrap_or_default();
// Particle emitters: resolve each emitter's texture to an `mpq://` image dependency (same
// lowercase normalisation as submesh albedos), and bake its host bone's pivot so the spawn
// site can ride a live joint (see [`ModelEmitter::bone_pivot`]). Absent on most models.
let mut emitters: Vec<ModelEmitter> = parse_m2_particle_emitters(&bytes)
.unwrap_or_default()
.into_iter()
.map(|def| {
let texture = def.texture.as_deref().map(|t| {
ctx.load::<Image>(format!(
"mpq://{}",
t.replace('\\', "/").to_ascii_lowercase()
))
});
let bone_pivot = skeleton_raw
.bones
.get(def.bone as usize)
.map_or([0.0; 3], |b| b.pivot);
// The recursion/geometry models load as ordinary mpq:// M2 dependencies —
// children wiring and model-particle instancing happen app-side on resolve.
let recursion = def
.recursion_model
.as_deref()
.map(|p| ctx.load::<M2Model>(m2_dep_url(p)));
let geometry = def
.geometry_model
.as_deref()
.map(|p| ctx.load::<M2Model>(m2_dep_url(p)));
// The billboard frame the emitter's bone chain inherits, if any (see the field).
let billboard = skeleton_raw.billboard_host(def.bone).and_then(|h| {
let b = skeleton_raw.bones.get(h)?;
Some(EmitterBillboard {
kind: b.billboard?,
pivot: b.pivot,
bone: u16::try_from(h).ok()?,
})
});
ModelEmitter {
def,
texture,
bone_pivot,
billboard,
recursion,
geometry,
owner_reach,
water_bound,
idle_seq: 0, // stamped below, once the sequences are built
}
})
.collect();
// Ribbon emitters (trails): same texture resolution, bone-pivot bake and owner-reach bake
// as the particles — a trail is one of the model's emitters and draws under the same law.
let ribbons: Vec<ModelRibbon> = benilla_formats::parse_m2_ribbon_emitters(&bytes)
.unwrap_or_default()
.into_iter()
.map(|def| {
let texture = def.texture.as_deref().map(|t| {
ctx.load::<Image>(format!(
"mpq://{}",
t.replace('\\', "/").to_ascii_lowercase()
))
});
let bone_pivot = skeleton_raw
.bones
.get(def.bone as usize)
.map_or([0.0; 3], |b| b.pivot);
ModelRibbon {
def,
texture,
bone_pivot,
owner_reach,
water_bound,
}
})
.collect();
// M2 lights: the same host-bone pivot bake, so a light on an animating model (the torch in
// an NPC's hand) can ride its bone's joint instead of freezing at the rest-pose spot.
let mut lights: Vec<ModelLight> = light_defs
.into_iter()
.map(|def| ModelLight {
bone_pivot: skeleton_raw
.bones
.get(def.bone as usize)
.map_or([0.0; 3], |b| b.pivot),
def,
synthetic: false,
})
.collect();
// MONKEY (fire GO lights) / MONKEY (lamp lights): a prop that authors NO casting light gets
// ONE derived from what it DOES author — its flame emitter, or (lamps, lanterns, sconces,
// chandeliers, which author no particles whatsoever) its unlit glass geoset. Both rules,
// their calibration and the `benilla-extract m2firescan` audit live in
// [`benilla_formats::fire_light`]. This is the single site the synthesis happens, so every
// lane that already consumes `M2Model::lights` inherits it for free and nothing downstream
// has to know an emitter or a render batch was ever read.
//
// Gated on `casts()` and not on emptiness: an authored block ALWAYS wins, including the
// directional-only and visibility-off shapes — a model whose one light block is authored
// dark was authored dark on purpose.
if !lights.iter().any(|l| l.def.casts()) {
let path = ctx.path().path().to_string_lossy().to_string();
// The FLAME route first, then the LAMP route — never both. The flame wins because it
// carries a REAL colour (read off the artist's own over-life ramp) where the lamp route
// can only pick a plausible one from a name; a model with both a flame and lamp glass
// (`OrcBrazierStreetLamp`) should take the measured hue, not the guessed one. Each
// yields the same four things: model-space position, host bone, colour, intensity.
let synth = benilla_formats::synthesize_fire_light(
&path,
emitters.iter().map(|e| &e.def),
)
.map(|fire| {
let src = &emitters[fire.emitter].def;
(src.position, src.bone, fire.color, fire.intensity)
})
.or_else(|| {
// MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
// at all — its glow is an UNLIT (render-flag 0x01) glass geoset. The rule reads the
// already-built render batches, so nothing extra is parsed; the position it returns
// is that geoset's CENTROID (the lamp head, 4-5 yd up a lamppost), never the model
// origin at the base of the pole. `bounds` is the fallback for a name-route hit
// whose glass we can't find — see `fire_light::lamp_position`.
let batches: Vec<benilla_formats::EmissiveBatch<'_>> = submeshes
.iter()
.map(|s| benilla_formats::EmissiveBatch::from(&*s.geometry))
.collect();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
benilla_formats::synthesize_lamp_light(&path, &batches, bbox)
.map(|l| (l.position, l.bone, l.color, l.intensity))
});
if let Some((position, src_bone, color, intensity)) = synth {
// The light sits at the FLAME (or the lamp glass), not the model origin: a brazier's
// origin is under its bowl, and a light there back-lights the bowl into every
// surface it should be lighting (and self-shadows through the prop's own caster
// mesh, which the torch lane includes). Same bone convention as an authored light —
// a bone the skeleton doesn't carry reads as `-1` (model origin), which is what a
// boneless prop is.
let bone = i16::try_from(src_bone)
.ok()
.filter(|_| (src_bone as usize) < skeleton_raw.bones.len())
.unwrap_or(-1);
lights.push(ModelLight {
def: M2Light {
light_type: 1, // point — the hot-spot caster; the whole reason we're here
bone,
position,
// Diffuse only, like every world light the reference commits (its ambient
// and specular are zero on world props — decision 0273).
ambient_color: [0.0; 3],
ambient_intensity: 0.0,
diffuse_color: color,
diffuse_intensity: intensity,
// The GL curve is fixed (`1/(0.7d+0.03d²)`) and ignores these; they are a
// cull hint no consumer reads, so a synthesised light authors none.
attenuation_start: 0.0,
attenuation_end: 0.0,
bone_z: [0.0, 0.0, 1.0], // point lights have no direction basis
visibility_off: false,
},
bone_pivot: skeleton_raw
.bones
.get(src_bone as usize)
.map_or([0.0; 3], |b| b.pivot),
synthetic: true,
});
}
}
let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw);
let inverse_bindposes = ctx.add_labeled_asset(
"inverse_bindposes".to_string(),
SkinnedMeshInverseBindposes::from(inverse_bindposes),
);
// The attachment-point table (decision 0072 — held items): the same bind-pose pivots
// `build_skeleton` used above, so a held item's offset is exactly consistent with the joint
// it's spawned under. Empty for a boneless/attachment-less model.
let attachments = build_attachments(
&parse_m2_attachments(&bytes).unwrap_or_default(),
&skeleton_pivots(&skeleton_raw),
);
// The event-marker table, same bake (the missile-launch $CSL/$CSR/$CST/$BWR points).
let markers = crate::model::build_markers(
&benilla_formats::parse_m2_event_markers(&bytes).unwrap_or_default(),
&skeleton_pivots(&skeleton_raw),
);
// The animations (decision 0019): build each sequence's clip into one shared `AnimationGraph`
// (the bench scrubs them; gameplay plays Stand). All as labeled sub-assets. `None` unless at
// least one sequence produced an animated clip.
let sequences = parse_m2_animations(&bytes);
let first_seq_span = sequences.first().map(|a| a.duration).filter(|d| *d > 0.0);
let animations = {
let mut graph = AnimationGraph::new();
let root = graph.root;
// The direct pose evaluator's bake (decision 0712) — filled beside every graph
// mutation below so the table mirrors the graph by construction: `bone_masks` beside
// each `add_target_to_mask_group`, `set_node` beside each `add_clip*`.
let mut pose = crate::model::PoseSource {
bone_masks: vec![0u64; skeleton_raw.bones.len()],
..Default::default()
};
// Per-arm mask groups for the client's per-slot one-shots (the draw/stow plays each
// hand's ceremony on its own arm subtree, over the gait). Each arm's subtree root is the
// **shoulder-keybone ancestor of the hand attachment's bone** (attachment id 1 = right
// hand, 2 = left; keybones 2/3 = the shoulders — data-driven, no left/right guessing).
// Every joint *outside* an arm joins that arm's mask group, so a node masked with group
// 0 (or 1) animates only the right (or left) arm.
let arm_roots = arm_subtree_roots(&skeleton_raw, &attachments);
if let Some((right_root, left_root)) = arm_roots {
for i in 0..skeleton_raw.bones.len() {
let target = bone_target_id(i as u16);
if !in_subtree(&skeleton_raw, i, right_root) {
graph.add_target_to_mask_group(target, 0);
pose.bone_masks[i] |= 1 << 0;
}
if !in_subtree(&skeleton_raw, i, left_root) {
graph.add_target_to_mask_group(target, 1);
pose.bone_masks[i] |= 1 << 1;
}
}
}
// The upper-body one-shot mask group (decision 0087): group 2 collects every joint
// *outside* the SpineLow subtree (the legs + pelvis), so a clip masked with it animates
// only the upper body — the general form of the per-arm ceremony above, one subtree wider.
let upper_root = upper_subtree_root(&skeleton_raw);
if let Some(upper_root) = upper_root {
for i in 0..skeleton_raw.bones.len() {
if !in_subtree(&skeleton_raw, i, upper_root) {
graph.add_target_to_mask_group(bone_target_id(i as u16), 2);
pose.bone_masks[i] |= 1 << 2;
}
}
}
// Per-hand **finger** mask groups (3 = right hand, 4 = left) for the weapon grip: a clip
// masked with group 3 (or 4) animates only that hand's finger key-bone subtrees, so the
// `HandsClosed` pose curls the fingers while the arm keeps its gait (wow-re
// `hand-grip-mechanism.md`). Empty per hand for a model with no finger key-bones (beasts).
let finger_roots = finger_subtree_roots(&skeleton_raw);
for i in 0..skeleton_raw.bones.len() {
let target = bone_target_id(i as u16);
if !finger_roots[0].is_empty()
&& !finger_roots[0]
.iter()
.any(|&r| in_subtree(&skeleton_raw, i, r))
{
graph.add_target_to_mask_group(target, 3);
pose.bone_masks[i] |= 1 << 3;
}
if !finger_roots[1].is_empty()
&& !finger_roots[1]
.iter()
.any(|&r| in_subtree(&skeleton_raw, i, r))
{
graph.add_target_to_mask_group(target, 4);
pose.bone_masks[i] |= 1 << 4;
}
}
// The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2
// header alongside the sequences above, so a model lacking a requested clip can resolve
// to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the
// creature driver silently doing nothing. Read BEFORE the clip walk because the
// loader-idle seed below resolves through it.
let playable_animation_lookup =
parse_m2_playable_animation_lookup(&bytes).unwrap_or_default();
// The model's own "do I author id X" table (the reference's `0x711960`) — the
// GameObject arm's missing-sequence remap branches on it (`ModelAnimations::owns`).
let animation_lookup =
benilla_formats::parse_m2_animation_lookup(&bytes).unwrap_or_default();
let mut clips = Vec::new();
// The loader-idle seed (decision 0637, wow-re `gameobject-anim-arm.md` §1 — the
// corrected `0x71019b` read): the loader arms **animation id 0 ("Stand")** resolved
// through the model's own `playableAnimationLookup`, NOT the file-order-first
// sequence. For the overwhelming majority of models those coincide, which is why the
// old file-order read survived; they diverge exactly on models whose first sequence is
// a Spawn — `DuelingFlag.m2` is Spawn(145)/Stand(0)/Despawn(157), and looping its
// Spawn band left the flag hanging 9 yards in the air on a 3.3 s cycle.
let idle_id = playable_animation_lookup
.first()
.map_or(0, |p: &benilla_formats::PlayableAnim| p.resolved_id);
let mut first_seq = None;
for (i, anim) in sequences.iter().enumerate() {
// EVERY sequence becomes a clip — the clip carries the instance's sequence CLOCK,
// not just a bone pose (decision 0941, [`build_animation_clip`]). `poses_bones`
// separates the two meanings: the clock is free, the rig is not.
{
let (clip, pose_clip, poses_bones) = build_animation_clip(anim, &skeleton);
// `first_seq` stays the RENDERING question — "is this seed worth a skin +
// player" (0130's content gate, and decision 0936's split). The identity
// question, "which sequence does the loader arm", is
// [`ModelAnimations::idle_seq`]/`idle_clip`, which asks the same selection
// without the gate.
if first_seq.is_none() && anim.anim_id == idle_id && idle_pose_differs(anim) {
first_seq = Some(clips.len());
}
let pose_idx = pose.clips.len() as u32;
pose.clips.push(pose_clip);
let clip = ctx.add_labeled_asset(format!("clip{i}"), clip);
let node = graph.add_clip(clip.clone(), 1.0, root);
pose.set_node(node, pose_idx, 0);
// The sheath family gets per-arm masked variants ([`AnimClip::arm_nodes`]).
let arm_nodes =
(matches!(anim.anim_id, 89 | 90) && arm_roots.is_some()).then(|| {
let nodes = (
graph.add_clip_with_mask(clip.clone(), 1 << 0, 1.0, root),
graph.add_clip_with_mask(clip.clone(), 1 << 1, 1.0, root),
);
pose.set_node(nodes.0, pose_idx, 1 << 0);
pose.set_node(nodes.1, pose_idx, 1 << 1);
nodes
});
// The upper-body masked variant ([`AnimClip::upper_node`]): the masked destination
// for a one-shot routed over a live base (swing/emote — the route is chosen per play
// in `creature_anim`). Built for every clip when the model has a split key-bone, so
// any one-shot id can take the masked route; `None` on the −1-sentinel models.
let upper_node = upper_root.map(|_| {
let n = graph.add_clip_with_mask(clip.clone(), 1 << 2, 1.0, root);
pose.set_node(n, pose_idx, 1 << 2);
n
});
clips.push(AnimClip {
anim_id: anim.anim_id,
seq_index: anim.seq_index,
node,
looping: anim.looping,
duration: anim.duration,
move_speed: anim.move_speed,
blend_time: anim.blend_time,
// Into the mesh vertices' frame, so the pick's world math is one transform.
bounds_center: wow_to_bevy(anim.bounds_center),
bounds_radius: anim.bounds_radius,
// The axis mapping flips signs, so min/max are re-derived componentwise.
bounds_min: wow_to_bevy(anim.bounds_min).min(wow_to_bevy(anim.bounds_max)),
bounds_max: wow_to_bevy(anim.bounds_min).max(wow_to_bevy(anim.bounds_max)),
events: anim.events.clone().into(),
arm_nodes,
upper_node,
frequency: anim.frequency,
replay: (anim.min_replay, anim.max_replay),
poses_bones,
});
}
}
// The per-hand weapon-grip nodes ([`ModelAnimations::hand_close`]): a purpose-built grip clip
// — each finger key-bone posed at its **clamped** `HandsClosed` value — masked to one hand's
// finger subtrees (group 3 = right, 4 = left). Built separately from the sequence clips above
// because the general in-band read drops the weapon-hand fingers (keyed off the HandsClosed
// frame); [`hand_grip_finger_poses`] clamps them, scoped to the grip (wow-re
// `hand-grip-mechanism.md`). `None` per hand for a model with no finger key-bones / no grip pose.
let mut hand_close: [Option<bevy::animation::graph::AnimationNodeIndex>; 2] =
[None, None];
// Every bone in a finger subtree — the key-bone AND its child segments (the fingertip joints),
// so the whole finger curls, not just its base (HandsClosed keys the tips too, ~51° vs ~30°).
let finger_bones: Vec<u16> = (0..skeleton_raw.bones.len())
.filter(|&i| {
finger_roots
.iter()
.flatten()
.any(|&r| in_subtree(&skeleton_raw, i, r))
})
.map(|i| i as u16)
.collect();
let grip_poses = hand_grip_finger_poses(&bytes, &finger_bones);
if !grip_poses.is_empty() {
let (grip_clip, grip_pose) = build_grip_clip(&grip_poses);
let grip_idx = pose.clips.len() as u32;
pose.clips.push(grip_pose);
let grip = ctx.add_labeled_asset("grip_clip".to_string(), grip_clip);
if !finger_roots[0].is_empty() {
let n = graph.add_clip_with_mask(grip.clone(), 1 << 3, 1.0, root);
pose.set_node(n, grip_idx, 1 << 3);
hand_close[0] = Some(n);
}
if !finger_roots[1].is_empty() {
let n = graph.add_clip_with_mask(grip.clone(), 1 << 4, 1.0, root);
pose.set_node(n, grip_idx, 1 << 4);
hand_close[1] = Some(n);
}
}
// Global-sequence channels alone are enough to carry `ModelAnimations`: a model whose
// sequences produced no clips can still pulse/flicker on its free-running loops (the
// doodad `GlobalSeqOnly` tier, decision 0130) — dropping them because `clips` came up
// empty would silently freeze it.
let global_bones =
build_global_bones(&parse_m2_global_sequence_bones(&bytes), &skeleton);
// The gate is "does ANYTHING in this model vary with the sequence clock" — and its
// consumer list was incomplete (decision 0941). A bone-posing clip and a global
// sequence were counted; the **per-sequence samplers** were not: the emitters'
// rate/enable/params tracks, a ribbon's, and the material alpha/colour/UV loops all
// resolve through the instance's playing sequence. 807 corpus models animate ONLY
// through those, and denying them `ModelAnimations` denied them the clock itself —
// no player, no slot, no time — so every one of those tracks read file slot 0 at
// t=0 for ever. Models with none of the three keep the static path: the optimization
// decision 0130 measured (~90% of placed doodads) is untouched, and 6342 boneless
// models with nothing to sample stay out of the animation lane entirely.
let samples_sequence = !emitters.is_empty()
|| !ribbons.is_empty()
|| submeshes
.iter()
.any(|s| s.alpha_anim.is_some() || s.uv_anim.is_some() || s.rgb_anim.is_some());
(clips.iter().any(|c| c.poses_bones)
|| !global_bones.is_empty()
|| (samples_sequence && !clips.is_empty()))
.then(|| {
let graph = ctx.add_labeled_asset("anim_graph".to_string(), graph);
ModelAnimations {
graph,
clips,
playable_animation_lookup,
animation_lookup,
hand_close,
global_bones,
first_seq,
pose: std::sync::Arc::new(pose),
}
})
};
// Stamp every emitter with the model's loader-idle slot (decision 0936) — the sequence a
// rig-less instance is playing, and so the slot its rate/gate/param bakes sample by
// default. Done here rather than in the map above because the selection needs the built
// clips (their `seq_index` + the `playableAnimationLookup` resolve), and only `None` when
// the model builds no clip at all — nothing to name, so the historical slot 0 stands.
let idle_seq = animations.as_ref().and_then(ModelAnimations::idle_seq);
for e in &mut emitters {
e.idle_seq = idle_seq.unwrap_or(0);
}
// The authored portrait camera (raw WoW model space → Bevy, same pure rotation the meshes
// bake through, so eye/target land in exactly the submeshes' frame) — and the camera
// table's first record, the glue screens' `SetCamera(0)` rig.
let to_bevy = |c: benilla_formats::M2PortraitCamera| PortraitCamera {
eye: wow_to_bevy(c.position),
target: wow_to_bevy(c.target),
roll: c.roll,
fov: c.fov,
near: c.near_clip,
far: c.far_clip,
};
let portrait_camera = parse_m2_portrait_camera(&bytes).map(to_bevy);
let camera0 = benilla_formats::parse_m2_camera(&bytes, 0).map(to_bevy);
let pane_camera = benilla_formats::parse_m2_camera(&bytes, 1).map(to_bevy);
// The bowstring anchors (bows only): raw WoW positions → Bevy, the meshes' frame.
let string_anchors = benilla_formats::parse_m2_string_anchors(&bytes).map(|a| {
[
(a.top.0, wow_to_bevy(a.top.1)),
(a.bottom.0, wow_to_bevy(a.bottom.1)),
]
});
// The fishing line's near anchor (mesh frame, like the bowstring anchors).
let cch_marker = benilla_formats::parse_m2_cch_marker(&bytes).map(|(_, p)| wow_to_bevy(p));
// The header GlobalModelFlags word sits at a fixed offset (MD20 magic @0, +0x10) — a
// bare scalar the sectioned parsers never touch.
let global_flags = bytes
.get(0x10..0x14)
.map_or(0, |b| u32::from_le_bytes(b.try_into().unwrap()));
Ok(M2Model {
submeshes,
bounds,
collision,
emitters,
ribbons,
lights,
skeleton,
inverse_bindposes,
animations,
first_seq_span,
attachments,
markers,
portrait_camera,
camera0,
pane_camera,
string_anchors,
cch_marker,
global_flags,
})
}
fn extensions(&self) -> &[&str] {
&["m2"]
}
}