Files
benilla-octo/crates/benilla-app/src/entities/attach/mod.rs
T
SamandClaude Fable 5 cf581aa2ac the auction house opens, the Honor tab lands, and a day of fixes
- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text
- the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields
- a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table
- character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled
- the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party
- README: the auction house and PvP honor move out of "not built yet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:04:27 +07:00

1023 lines
62 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.
//! Attaching a visual to a streamed entity (decision 0006) — the back half of [`super`].
//!
//! [`super`] resolves + builds a [`DisplayModel`](super::DisplayModel) per display id (shared across
//! every entity of that display); this module gives each net entity its visual once that model has
//! loaded: the submesh children + skeleton/animation infra (creatures + player bodies), the per-player
//! character geoset selection + skin material (decision 0041 — the appearance/material resolution
//! lives in [`char_skin`]), particle emitters, GameObject collision, or a colored cube fallback. It
//! reaches the shared types + caches in the parent via `super::`.
use avian3d::prelude::RigidBody;
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use benilla_assets::WorldAssets;
use bevy::animation::transition::AnimationTransitions;
use crate::creature_anim::AnimDriver;
use crate::net::{NetEntity, ObjectStore};
use crate::player::CameraPivot;
use crate::target::SelectionRadius;
use benilla_world::interact::WorldObject;
use benilla_world::model_fade::JoinedFade;
use benilla_world::model_render::ModelKind;
use benilla_world::particles;
use super::{
Characters, Creatures, CubeAssets, DisplayModel, GameObjects, ModelHandle, SkinComposites,
SkinSections, VisualAttached,
};
mod char_skin;
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
mod dress;
use dress::{spawn_part, PartDress};
mod preview;
pub(crate) use preview::equip_slot;
pub(super) use preview::{build_dressup_preview, build_glue_pet, build_glue_preview};
mod redress;
pub(super) use redress::redress_player_looks;
/// Set up the skinned instance shared by creatures/players (decision 0019) and animated
/// GameObjects (decision 0242): the pose buffer, the palette rig slot, and the global-sequence
/// drive.
///
/// The **sequence clock** — the `AnimationPlayer` + graph + [`ModelAnimations`] + the driver that
/// owns the choice — is NOT set up here; it is [`arm_sequence_clock`], called by the spawn site
/// whether or not this rig was built (decision 0941). Everything in this function costs a palette
/// slot and a skinned twin, and is worth spending only on a model whose bones actually move; the
/// clock is worth arming on every instance, because a sequence drives far more than a pose.
///
/// This lane spawns **no joint entities** (decision 0724) — the pose lives in a
/// [`benilla_world::rig_anim::RigPose`] array — and **no anchor entities either** (decision
/// 1355): a bone gets its anchor from `RigPose::anchor_for` the moment something actually
/// consumes it, re-seated from the composed pose each frame it changes. Returns the pose (still
/// in hand — see [`RigBuild`]) + the palette rig slot (decision 0720), or `None` when the model
/// has no inverse bindposes. Slot `0` = the palette table was full: anchors still resolve
/// (emitters/attachments ride them), but parts fall back to the static bind-pose mesh. No
/// animations ⇒ the pose just holds bind pose (Milestone A).
fn setup_skinned_instance(
commands: &mut Commands,
palettes: &mut benilla_world::rig_palette::RigPalettes,
entity: Entity,
joints_root: Entity,
d: &DisplayModel,
) -> Option<RigBuild> {
let ibp = d.inverse_bindposes.as_ref()?;
let nbones = d.skeleton.joints.len();
// `joints_root` — the rig's model-space frame — is normally `entity` itself; a MOUNTED
// rider's frame is the seat anchor instead (decision 0441), a conform-tilted model's its
// conform node, while the `AnimationPlayer`/driver components stay on `entity`. Skinned
// parts render purely from the palette, so their own parentage is free.
// No anchor entities are spawned here (decision 1355). A consumer bone gets its anchor the
// moment something actually consumes it — [`benilla_world::rig_anim::RigPose::anchor_for`],
// called by the emitter/ribbon/light arms, the billboard dress, held items, the mount seat,
// spell kits and quest markers — and the position-only readers (the overhead anchor, missile
// launch points, the bowstring) read `RigPose::posed_point` and never need an entity at all.
// The eager population this replaced was 96 % never-consumed at the LBRS pin (decision 1354).
let pose = benilla_world::rig_anim::RigPose::new(joints_root, &d.skeleton);
// The owned palette rig (decision 0720): the world pass writes this rig's composed frames ×
// these bindposes into the slot; every skinned part below tags the slot so the vertex stage
// finds its palette. The on-replace hook frees the slot with the visual teardown.
let slot = match benilla_world::rig_palette::RigSkin::allocate_bones(
palettes,
nbones as u32,
ibp.clone(),
) {
Some(rig) => {
let slot = rig.slot;
// The marker's invariant is "currently slot-less": a heal-triggered (or any
// other) rebuild that lands a slot clears it, or the healer would loop.
commands
.entity(entity)
.insert(rig)
.remove::<benilla_world::rig_palette::RigStarved>();
slot
}
None => {
// Table full (warned): parts render the static bind-pose mesh — but no longer
// for ever. The marker hands the unit to `heal_rig_starved` (decision 0863),
// which rebuilds the visual once the table has headroom; without it a mob that
// streamed in during a full-table window stayed a statue for its whole life.
commands
.entity(entity)
.insert(benilla_world::rig_palette::RigStarved);
0
}
};
if let Some(anims) = d.animations.as_ref() {
// Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses; a GO's
// free-running flicker): free-clock loops the per-sequence reader drops, driven on their own clock.
// Rig-bound (it writes bone slots), unlike the sequence clock — which is armed by
// [`arm_sequence_clock`] whether or not this instance ended up with a rig.
if let Some(drive) =
benilla_world::rig_anim::GlobalSeqDrive::new_rig(&anims.global_bones, nbones)
{
commands.entity(entity).insert(drive);
}
}
// The pose buffer travels in the build result, NOT onto the entity yet: the arms of
// `attach_entity_visuals` below still resolve anchors into it (`RigPose::anchor_for` needs
// `&mut`), and it lands on the entity in one piece once the build is dressed. The evaluator
// (decision 0712) samples the player state straight into `locals`; with no joint entities and
// no `AnimationTargetId`s, Bevy's `animate_targets` has nothing of ours to touch.
Some(RigBuild { pose, slot })
}
/// Arm this instance's **sequence clock**: the `AnimationPlayer` + graph + [`ModelAnimations`] that
/// say which sequence is playing and how far into it, plus the driver that owns the choice
/// ([`crate::go_anim::GoAnim`] for a state GameObject, `AnimDriver` for a unit).
///
/// Separate from the rig on purpose (decision 0941). The two used to be one call — the clock was
/// armed inside [`setup_skinned_instance`], so an instance that wasn't worth skinning got no clock
/// either. But a skinned pose is only ONE of the things a sequence drives: the particle emitters'
/// rate/enable/params tracks read it (`EmitClock::Host`), so do the material alpha/colour/UV loops
/// and the GameObject state arm. 147 GameObject display models animate **only** through those —
/// no keyed bone anywhere — and for them "no rig" silently meant "no clock", i.e. file slot 0 at
/// t = 0 for ever: the Molten Core rune's flames read its *Closed* band, where every emission rate
/// key is 0, and the flame ring's spawn window never opened past its first frame. The clock is a
/// player and a handle; the rig is a palette slot and a skinned twin. Only the second is worth
/// gating on whether bones actually move.
fn arm_sequence_clock(
commands: &mut Commands,
entity: Entity,
anims: &ModelAnimations,
kind: EntityKind,
go_state_machine: bool,
) {
// **Every** GameObject instance gets the loader-idle seed, state machine or not — the
// reference's `0x70ebd0` tail arms bone 0 the moment the M2 goes LIVE and has exactly two
// callers, so no M2 instance in the client ever exists with nothing armed (wow-re
// `gameobject-anim-arm.md` §1/§2e). For a door/chest the object-layer arm lands *after* it
// and overrides it (§2, "because it lands after the loader seed, it is the effective arm");
// seeding first is what stops the one-frame BIND POSE our state GOs used to render on their
// first displayed frame, before `go_anim` had a chance to run — the "explodes for a split
// second" report. The seed is played THROUGH the transitions object so that first arm
// cleanly fades out of it; playing it bare on the player would leave two clips live at once.
let mut player = AnimationPlayer::default();
let mut transitions = AnimationTransitions::new();
if kind == EntityKind::GameObject {
// `idle_clip`, NOT `first_seq`: the loader arm's identity answer, taken without the
// rendering content gate (decision 0936's split). A GameObject whose sequences pose no
// bone has no `first_seq` at all and used to arm nothing — which is precisely how its
// emitters ended up reading file slot 0 at t = 0 for ever (0941).
if let Some(clip) = anims.idle_clip() {
// Loop iff the sequence says so (`M2Sequence.flags & 1 == 0`) — the kernel's own
// end-of-band law (wow-re `gameobject-anim-arm.md` §2, byte-verified at
// `0x714585`): bit0 clear loops on the modulo wrap, bit0 set plays the window and
// then FREEZES at `end_ms`. An unconditional repeat replayed one-shot idles.
let active = transitions.play(&mut player, clip.node, std::time::Duration::ZERO);
if clip.looping {
active.repeat();
}
}
}
commands.entity(entity).insert((
player,
AnimationGraphHandle(anims.graph.clone()),
anims.clone(),
));
match kind {
EntityKind::GameObject if go_state_machine => {
commands.entity(entity).insert((
// Cross-fades the open/close transition over the clip's blend-in time (0242/0049),
// and carries the seed above as the pose the first arm transitions out of.
transitions,
crate::go_anim::GoAnim::default(),
));
}
// A loader-idle GameObject needs no driver and no transitions — the looping player IS the
// whole animation, and nothing will ever arm over it.
EntityKind::GameObject => {}
_ => {
commands
.entity(entity)
.insert((
// Cross-fades over each clip's blend-in time, so a gait change eases (0049).
AnimationTransitions::new(),
AnimDriver::default(),
))
// A (re)built rig is born live: fresh joints spawn pointing at the root, so a
// stale park marker from the torn-down visual would desync the LOD gate's
// edge-triggered bookkeeping (decision 0448). It re-parks on its own merits.
.remove::<benilla_world::rig_anim::AnimParked>();
}
}
}
/// A collapsed rig's build result (decision 0724): the pose buffer, still in hand so the build's
/// own arms can resolve anchors into it (decision 1355 — anchors spawn on first consumer, via
/// [`benilla_world::rig_anim::RigPose::anchor_for`]), + the palette slot each skinned part tags.
/// The pose lands on the entity at the end of the build.
struct RigBuild {
pose: benilla_world::rig_anim::RigPose,
slot: u16,
}
/// Attach a visual to each net entity that doesn't have one yet: its built model (creature / GameObject
/// / player body) as submesh children, or a colored cube fallback. The entity's pose is owned by the
/// net bridge — or, for our own avatar, the player controller — we only add the geometry (and bake
/// per-display scale onto the root). Our own avatar is the same streamed entity and renders here too.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(super) fn attach_entity_visuals(
mut commands: Commands,
pending: Query<
(
Entity,
&NetEntity,
Option<&super::Equipment>,
Has<super::equipment::Reattached>,
Option<&super::mount::MountChild>,
Option<&super::mount::MountBody>,
Has<crate::transport::TransportAnchor>,
),
Without<VisualAttached>,
>,
// The mount children's build state (decision 0441): a mounted unit's rider waits on its mount
// child's attach — the seat resolves through the mount's attachment-0 point (`BoneAttach`)
// into its `RigPose` below; the `NetEntity` is the staleness check (the field moved while
// the unit was still pending).
mount_children: super::mount::MountChildren,
// Already-built rigs' pose buffers — the mount child's, for its seat anchor to resolve on
// demand (decision 1355). The unit being built here has no `RigPose` component yet (its pose
// is still in `RigBuild`), so the two never alias.
mut built_poses: Query<&mut benilla_world::rig_anim::RigPose>,
// The ItemDisplayInfo catalog (armor region textures, decision 0074). Read-only here; the
// held-item systems in the same chain hold it mutably in their own turns.
displays: Option<Res<super::ItemDisplays>>,
mut transforms: Query<&mut Transform>,
assets: Res<CubeAssets>,
creatures: Option<Res<Creatures>>,
gameobjects: Option<Res<GameObjects>>,
// Character geoset selection (decision 0041, Milestone B): the customization tables + the entity's
// decoded appearance, to pick which body geosets a player shows. Absent ⇒ no filtering (every geoset).
characters: Option<Res<Characters>>,
// A character body's race/sex + customization for the geoset filter + skin materials: for a player,
// its decoded descriptor fields ([`ObjectStore`]); a character-model NPC reads its display instead.
stores: Query<&ObjectStore>,
// Character skin (decisions 0041 / 0044 / 0045): the CharSections lookup + the bits to composite +
// upload the per-appearance body atlas and to load + build the hair material — the shared chain (read
// the BLPs), the `Image` assets + per-appearance composite cache, the asset server (async hair-BLP
// load), the `WowModelMaterial` assets + dedup cache, and the light buffer. Nested into one param to
// stay within Bevy's 16-element system-param tuple limit.
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
benilla_world::model_render::M2BatchMaterials,
),
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
// The collider-set stamp (1384): the GameObject hull insert below is the one collider lane
// outside the streamer's attach queue, so it dates the world itself.
mut collider_epoch: ResMut<benilla_world::collision::ColliderEpoch>,
time: Res<Time>,
) {
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
skin_build;
// Arm each entity's appear-fade at the moment its visual attaches (≈ its first-visible moment).
let now = time.elapsed_secs();
for (entity, net, equipment, reattached, mount_child, mount_body, anchored) in &pending {
// A player attaches only once its worn-equipment resolution settles (decision 0074): the
// template round trips are far faster than the model load, and waiting composites the
// dressed atlas directly instead of flashing naked. (`None` = the resolver hasn't run yet.)
if net.kind == EntityKind::Player && !equipment.is_some_and(|e| e.settled) {
continue;
}
// The entity's built display model (if it has one): None ⇒ no display / not a modeled kind.
let dm = net.display_id.and_then(|disp| match net.kind {
EntityKind::Unit | EntityKind::Player => {
creatures.as_deref().and_then(|c| c.models.get(&disp))
}
EntityKind::GameObject => gameobjects.as_deref().and_then(|g| g.models.get(&disp)),
_ => None,
});
// Worn equipment driving the geoset selection (and, for a player, the region composite): a
// player from its resolved `Equipment`, a character-model NPC from its display's
// CreatureDisplayInfoExtra columns. Zeroed (a beast / GameObject / no data) = the naked body.
let worn = resolve_worn_equip(net, equipment, dm);
let equip = worn.bodyslots;
// A model still loading (`parts == None`) waits — leave it un-attached and retry next frame
// rather than flash a cube we'd swap out. A built-but-empty model draws nothing.
let model = match dm {
Some(d) => match &d.parts {
None => continue,
Some(parts) if !parts.is_empty() => Some(parts.as_slice()),
Some(_) => None,
},
None => None,
};
// **Did the display name a model file at all?** An empty `parts` list means two opposite
// things, and the cube below may only answer one of them (decision 1403): a display that
// resolved to a model which built zero batches has been *answered* — the model draws
// nothing — while a display that resolved to no model at all is a gap of ours.
let named_a_model = dm.is_some_and(DisplayModel::names_a_model);
// Invisible interaction-zone GameObjects (the forge, fishing-bobber zone, aura generators, …)
// carry a *transparent* placeholder M2, and invisible **trigger creatures** an empty or
// constant-zero-alpha one: the real client's mesh gate is **type-independent** — it draws
// any loaded model and the per-batch zero-alpha cull skips the transparent geometry
// (decision 0024, superseding 0023's wrong marker-type gate; verified wow-re go-render-gate).
// Our M2 alpha cull already reduces those models to zero submeshes, so `model` is `None`
// here and they render nothing — neither kind needs a special case, and `named_a_model`
// above is what keeps the unit arm's debug cube off them (1403).
if let Some(parts) = model {
// ── Mounts (decision 0441): a mounted unit is TWO skeletons. The mount is a child
// entity carrying a plain creature `NetEntity` — this very system builds it like any
// beast next frame(s) — and the rider's rig then roots under the mount's attachment-0
// seat joint. Until the mount child has attached, the unit builds nothing (no
// naked-at-the-ground flash; the model loads dominate the wait anyway). A mount child
// itself has no `ObjectStore`, so it can never take this branch.
//
// This is the **first build** of a unit the field already says is mounted (a
// born-mounted NPC, a mounted player streaming in). A field change on a unit that is
// already standing is `mount::reseat_mounts`' job, and it re-seats the standing rig
// rather than coming back through here — the two share the seat leg below.
let mount_display = match net.kind {
EntityKind::Unit | EntityKind::Player => stores
.get(entity)
.map_or(0, |s| s.0.unit_mount_display_id()),
_ => 0,
};
// Where the rider's root bones parent: the unit entity, or the mounted seat anchor.
let mut rider_root = entity;
if mount_display != 0 {
match super::mount::seat_or_spawn_mount(
&mut commands,
&mount_children,
&mut built_poses,
creatures.as_deref(),
entity,
mount_child.map(|&super::mount::MountChild(c)| c),
mount_display,
reattached,
) {
// The mount model is still building (or was just ordered) — wait.
super::mount::Seat::Wait => continue,
super::mount::Seat::Frame(anchor) => rider_root = anchor,
// The reference logs and leaves the body at the unit matrix
// (`0x60ce70`'s present-test miss) — `seat_or_spawn_mount` wrote the warning.
super::mount::Seat::UnitMatrix => {}
}
} else if let Some(&super::mount::MountChild(child)) = mount_child {
// The field zeroed while this unit was still pending — drop the mount it ordered,
// or the build below would stand a riderless horse under a dismounted body.
if let Ok(mut ec) = commands.get_entity(child) {
ec.despawn();
}
commands.entity(entity).remove::<super::mount::MountChild>();
}
// Real model: submesh children inherit the entity's (pose-driven) transform; bake scale onto it.
let kind = match net.kind {
EntityKind::GameObject => ModelKind::GameObject,
_ => ModelKind::Creature,
};
let emitters = dm.map(|d| d.emitters.as_slice()).unwrap_or_default();
let model_lights = dm.map(|d| d.lights.as_slice()).unwrap_or_default();
// A GameObject's interior-fold reference point (model-local; the anchor transform
// applies the entity scale when the classifier folds).
let bake_center = dm.map(|d| d.bake_center_local).unwrap_or(Vec3::ZERO);
// The dynamic ground-shade root (decision 0173): one light-node state per object, like
// the reference's `[obj+0xe0]`; every M2 part below (body, held items) reads it from the
// tree walk. Kind-independent — a unit and a GameObject run the same 2.5/0.5 MCSH chase,
// which is the byte-shared target law (0814 restored this; 0809 had split it by kind on
// the strength of a delivery state we do not model). `insert_if_new` so a gear-change
// re-attach keeps the already-ramped state instead of resetting it (no one-frame
// lighting pop).
commands
.entity(entity)
.insert_if_new(benilla_world::entity_shade::GroundShade::default());
// The root's canonical fold reference: held items share the root's interior verdict
// (one light node per unit — the reference aliases the wearer's collector into each
// equipped item, wow-re `unit-light-combine-storm.md`), and their classifier fold must
// reference the BODY's centre, not the carried position. Plain `insert`: a display-id
// change re-derives it with the new body model.
commands
.entity(entity)
.insert(benilla_world::interior::BodyBakeCenter(bake_center));
// The light node's ATTACH MODE — the reference's `[node+0x90]` bit 13, written once at
// node creation from the descriptor TYPEMASK (`0x613e10`/`0x670db0`) and dispatched at
// `0x6a86d0`: a GameObject attaches by CONTAINMENT (`0x6a8c10`), anchored at the world
// bounding-box centre; a unit/player by DOWN-RAY (`0x6a8a20`), anchored at the position.
// The kind can't change under a live display-id swap, but the remove arm keeps the two
// mutually exclusive by construction rather than by that argument (decision 0776).
match net.kind {
EntityKind::GameObject => {
commands
.entity(entity)
.insert(benilla_world::interior::ContainmentAttach);
}
_ => {
commands
.entity(entity)
.remove::<benilla_world::interior::ContainmentAttach>();
}
}
// Identity for the mouseover inspector (and, later, hover tooltips / targeting).
let object = WorldObject {
kind,
label: dm.map(|d| display_label(&d.handle)).unwrap_or_default(),
id: net.display_id.unwrap_or(0),
detail: format!("emitters: {}", emitters.len()),
};
// Skeletal skin (decision 0019): a creature (Unit/Player) — and now any animated
// GameObject — draws through the skinned-mesh twin and a **per-instance** joint hierarchy
// whose entities are children of this entity (so they inherit its world pose; at bind pose
// every joint matrix collapses to that pose, so the model renders exactly where the static
// mesh did). Truly static props keep the static mesh. `skin` is `Some((joints,
// inverse_bindposes, palette_slot))` when instanced (decision 0720; slot 0 = palette
// full, parts fall back to the static mesh).
// Does this entity run the GameObject open/close state machine (the byte-verified
// TYPE_ID census)? Read once — it picks both the rig flavour below and the clock's
// driver, and those two must never disagree about the same object.
let go_state_machine = net.kind == EntityKind::GameObject
&& stores
.get(entity)
.is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id()));
let mut skin: Option<RigBuild> = match (net.kind, dm) {
// A creature (or player body — decision 0041) with a real skeleton. The `!is_empty`
// guard keeps a degenerate boneless model on the static mesh (its skinned twin would
// carry joint attributes but have no joints to index — out of bounds).
(EntityKind::Unit | EntityKind::Player, Some(d))
if !d.skeleton.joints.is_empty() =>
{
// `rider_root`: the unit itself, or — mounted — the seat anchor under the
// mount's attachment-0 joint (decision 0441). The `AnimationPlayer` stays
// on the unit entity either way (targets bind by entity, not by path).
//
// Terrain conform (decisions 0482/0486): a flagged model's root bones
// parent one level deeper, under a conform node `conform_units` rotates —
// wild quadruped and mount child alike. A mounted RIDER never gets one
// (`rider_root != entity`): the ref's `0x7106c0` dispatch is on the
// mount-PREFERRED model, and the composite tilts through the mount's
// node, seat joint included.
let mut joints_root = rider_root;
if d.terrain_tilt != 0 && rider_root == entity {
let node = commands
.spawn((
super::conform::ConformNode {
// The ground/yaw source: the streamed unit — for a
// mount child, its HOST (the child sits at the unit
// matrix; its own `Transform` is local).
unit: mount_body.map_or(entity, |mb| mb.host),
mode: d.terrain_tilt,
},
Transform::default(),
Visibility::default(),
))
.id();
commands.entity(entity).add_child(node);
joints_root = node;
}
setup_skinned_instance(&mut commands, &mut palettes, entity, joints_root, d)
}
// A GameObject whose model authors a real skeleton + animation draws through the
// skinned twin like a creature. Two flavours share the rig: a door/button/chest
// (`go_animates`) runs the open/close state machine off GAMEOBJECT_STATE (decision
// 0242); ANY other animated GO — a mailbox's wind-swung flags, a banner, a windmill —
// loops its first sequence as the reference's universal loader-idle seed (wow-re
// `doodad-anim-host.md`: a non-transport CGGameObject animates identically to a placed
// doodad). The content gate for the non-state flavour is the doodad classifier's: a GO
// whose first sequence is a constant pose and which has no global sequences
// (`DoodadAnimTier::Static`) has nothing to loop, so it keeps the static mesh.
(EntityKind::GameObject, Some(d))
if !d.skeleton.joints.is_empty() && d.animations.is_some() =>
{
let state_machine = go_state_machine;
let ambient = !matches!(
benilla_world::doodad_anim::classify(&d.skeleton, d.animations.as_ref()),
benilla_world::doodad_anim::DoodadAnimTier::Static
);
// A state GO whose model poses NO bone in any sequence gets no rig: skinning
// it could only reproduce the bind-pose mesh it already renders, at the cost
// of a scarce palette slot (decision 0941 — 147 display models are this
// shape). It still gets the clock below, which is the half it was missing.
let poses = d
.animations
.as_ref()
.is_some_and(|a| a.clips.iter().any(|c| c.poses_bones));
((state_machine && poses) || ambient)
.then(|| {
setup_skinned_instance(&mut commands, &mut palettes, entity, entity, d)
})
.flatten()
}
_ => None,
};
// The instance's SEQUENCE CLOCK — armed for every M2 instance whose model has one,
// rigged or not (decision 0941). The rig above is a palette slot and a skinned twin,
// and is worth spending only when bones actually move; the clock is a player and a
// handle, and everything per-sequence reads it — the emitters' rate/enable/params
// tracks, the material alpha/colour/UV loops, the GameObject state arm. Bundling the
// two is what left the boneless-but-animated content (the Molten Core rune's flames,
// the flame ring's spreading spawn window) reading file slot 0 at t = 0 for ever.
if matches!(
net.kind,
EntityKind::Unit | EntityKind::Player | EntityKind::GameObject
) {
if let Some(anims) = dm.and_then(|d| d.animations.as_ref()) {
arm_sequence_clock(&mut commands, entity, anims, net.kind, go_state_machine);
}
}
// The bone-riding surface (decision 0072): the model's attachment points + event
// markers, so held items (and future bone riders) can hang from the hand/hip/back
// joints. Pure model data — the anchor *entities* live in `RigPose.anchors` and
// spawn on first consumer (decision 1355).
if let (Some(_), Some(d)) = (&skin, dm) {
// The event markers keep the client's first-match scan order: an ident already
// present wins (character models carry six `$CSD` records — the first is the one
// `0x7130e0` would return).
let mut markers = std::collections::HashMap::new();
for m in &d.markers {
markers.entry(m.ident).or_insert((m.bone, m.offset));
}
commands.entity(entity).insert(super::BoneAttach {
points: d
.attachments
.iter()
.map(|a| (a.id, (a.bone, a.offset)))
.collect(),
markers,
});
// The display-facing counter-twist channels (the strafe body pose): the model's
// SpineLow/Head key-bones, straight into the pose buffer. Models without either
// key-bone (beasts, props) get no component — the client's capability gates.
let nb = d.skeleton.joints.len();
let in_range = |b: Option<u16>| b.filter(|&i| (i as usize) < nb);
let (spine, head) = (
in_range(d.skeleton.spine_bone),
in_range(d.skeleton.head_bone),
);
if spine.is_some() || head.is_some() {
commands
.entity(entity)
.insert(crate::creature_anim::BodyTwist::new(spine, head));
}
}
// Character geoset selection (decision 0041, Milestone B): a player body model carries
// *every* hairstyle / facial-hair / body-option geoset; show only the selected ones. The
// model (and its `parts`) is shared across all players of this displayId, so the filter is
// **per-entity** here — from this player's decoded appearance — not baked into the cache.
// `None` (no look, or the tables unavailable) ⇒ render every part, as before.
//
// The character look: a player takes it from the wire, a character-model NPC from its
// display's CreatureDisplayInfoExtra (decision 0041). Both then drive the same geoset filter
// + skin/hair materials below; a beast NPC / GameObject has no look and is unaffected.
let look = resolve_char_look(net, dm, entity, &stores);
// The worn geoset selectors (decision 0074, the B1–B8 branches): a player's from the
// resolved equipment display rows; an NPC / naked default otherwise.
// (The helm's hide-mask row pair, RF-0083: hair/facial/ears tuck under it. For a
// character-model NPC the helm id is its CreatureDisplayInfoExtra head column.)
let equip_geosets = equip_geosets(displays.as_deref(), &equip, worn.cloak, worn.helm);
let visible_geosets: Option<Vec<u16>> = look.as_ref().and_then(|l| {
let cg = characters.as_deref()?;
Some(cg.0.visible_geosets(
l.race,
l.sex,
l.hair_style,
l.facial_hair,
&equip_geosets,
))
});
// Character skin (decisions 0041 / 0044 / 0045): a character body's body-skin batches (M2
// type 1) get the body atlas, and its hair batches (type 6) get the hair-mesh texture — both
// per-appearance over the shared model, so built here (not in the shared model cache);
// `model_material` then dedups by texture so bodies of one look share them. `(None, None)` ⇒
// those parts keep their built (untextured) material (no look, or tables/chain absent).
let char_mats = match look.as_ref() {
Some(l) => build_char_skin_materials(
l,
equip,
worn.cloak,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut mats,
),
None => (None, None, None, (None, None)),
};
// Whether any spawned child armed a `PendingAppearFade` this pass — mirrored onto the unit
// root below as `UnitAppearFade` so a held item / helm / shoulder that resolves and spawns
// *later* (`entities::equipment::attach_held_items`, async — a template round trip, a model
// load) can join the same ramp instead of popping in opaque or racing its own fade from
// zero. Decision 0032 read as a per-unit property, not a per-mesh-at-attach-time stamp.
let mut unit_will_fade = false;
// This unit's **instance slot**, in tag-field form — one value for the whole unit, worn by
// every part and card below. See the per-part note at the spawn for why it is the
// instance's identity rather than a skinning detail (decision 0812).
let inst_slot = skin.as_ref().map_or(0, |rb| rb.slot);
// The armed idle's **authored** CAaBox (decision 0637) — the mouseover picker's
// volume for a skinned part, NOT a culling volume (skinned entity parts are never
// frustum-culled; see the `NoFrustumCulling` note at the insert below). The bind-pose
// box the mesh would otherwise get is only a fair stand-in while the animation keeps
// the model near rest — the duel flag breaks it: `DuelingFlag.m2` is modelled 9 yards
// in the air and its Stand translates the root `−9.124` to plant it, so the bind box
// sits a whole model-height above the drawn geometry. The M2 authors a per-sequence
// CAaBox for exactly this; for the flag's Stand it is ground-to-tip, which is also
// what makes the planted flag hoverable where it is actually seen.
let idle_aabb = dm.and_then(|m| m.animations.as_ref()).and_then(|a| {
let clip = a.first_seq.and_then(|i| a.clips.get(i))?;
(clip.bounds_max.cmpgt(clip.bounds_min).all()).then(|| {
bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max)
})
});
// This body's **election bound** (decision 1270): standing in a sealed WMO room the
// reference never submits an outdoor object at all, so the cull needs one whole-object
// AABB per body, on its root. Recorded here and restated onto `WorldUnit::bound` by
// `publish_world_units` — the one reconciler that answers every "what is this body to
// the world" question, exactly as it already does for the collision height.
//
// **Not `idle_aabb` above, and that distinction is the whole of it.** `first_seq` is
// the *rendering* gate — "is this seed worth a skin + player" (0130's content gate) —
// and it is `None` for every model whose idle does not move its bones off the rest
// pose. The picker can take that: it falls back per part (`idle_aabb.or(part.aabb)`).
// The election cannot, because a body with no bound is a body it never decides — so
// reading the render gate here silently exempted a whole class of creature from the
// cull, and they kept drawing through the ceiling. `idle_clip` is the same selection
// asked as an *identity* (`ModelAnimations`' own doc: "conflating the two is the whole
// defect"), and where even that box is unauthored — a degenerate CAaBox is common in
// 1.12 art — the model's bind-pose extent is what an unarmed body actually draws.
let election_bound = dm
.and_then(|m| m.animations.as_ref())
.and_then(|a| a.idle_clip())
.filter(|c| c.bounds_max.cmpgt(c.bounds_min).all())
.map(|c| bevy::camera::primitives::Aabb::from_min_max(c.bounds_min, c.bounds_max))
.or_else(|| union_aabb(model.unwrap_or_default().iter().filter_map(|p| p.aabb)));
if let Some(aabb) = election_bound {
commands.entity(entity).insert(super::ModelBound(aabb));
}
// Everything one part's spawn reads from this unit, gathered once (`attach::dress`) —
// the same context the gear-change **re-dress** feeds `spawn_part`, so a body built at
// stream-in and a geoset that appears when a belt is swapped are dressed by one law.
//
// Billboard-card bones are the one dress input that needs an anchor ENTITY (the card
// rides its live joint), so they resolve here — through the same first-consumer
// resolver as everything else (decision 1355), over the same geoset predicate as the
// spawn loop below, so a hidden variant's card bone spawns nothing.
let card_anchors: std::collections::HashMap<u16, Entity> = match skin.as_mut() {
Some(rb) => parts
.iter()
.filter(|part| {
!visible_geosets
.as_ref()
.is_some_and(|vis| !vis.contains(&part.geoset_id))
})
.filter_map(|part| part.billboard.as_ref().map(|b| b.bone))
.filter_map(|bone| {
rb.pose
.anchor_for(&mut commands, entity, bone)
.map(|a| (bone, a))
})
.collect(),
None => std::collections::HashMap::new(),
};
let dress = PartDress {
unit: entity,
kind,
char_mats: &char_mats,
object: &object,
inst_slot,
rigged: skin.is_some(),
anchors: card_anchors,
bake_center,
idle_aabb,
now,
// A fresh visual arms the appear-fade on every fade-capable part (decision 0032).
// A rebuild that is NOT a spawn — a mount transition, a display swap — spawns
// steady: `Reattached` says the unit was already standing there.
fade: if reattached {
JoinedFade::Steady
} else {
JoinedFade::Pending { since: now }
},
};
for (i, part) in parts.iter().enumerate() {
// Skip a geoset this character doesn't show (an unselected hair/facial/body
// variant, or a body region its worn gear replaces). The equipment half of that
// selection is re-run in place on a gear change (`attach::redress`); this is the
// same predicate, evaluated once at build.
if visible_geosets
.as_ref()
.is_some_and(|vis| !vis.contains(&part.geoset_id))
{
continue;
}
unit_will_fade |= spawn_part(&mut commands, part, i, &dress);
}
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
// / helm / shoulder attaching later reads this to join the same ramp
// (`entities::equipment::attach_held_items`). A `Reattached` rebuild — a mount
// transition, a display swap — abandons any in-flight fade outright, matching the body
// parts it just rebuilt steady, so anything spawned for that same rebuild is steady too.
// (A GEAR change no longer reaches here at all: it re-dresses in place, and the parts it
// spawns join this clock instead of clearing it — `attach::redress`.)
if unit_will_fade {
commands
.entity(entity)
.insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
} else if reattached {
commands
.entity(entity)
.remove::<benilla_world::model_fade::UnitAppearFade>();
}
// Final size = the server's per-object scale (`OBJECT_FIELD_SCALE_X`) alone. The server
// already folds the unit's DBC scale (`CreatureModelData.modelScale ×
// CreatureDisplayInfo.scale`, or an explicit per-spawn override) into this field, and the
// real client renders units at the field alone (verified: wow-re `world_model_scale`
// `0x613ef0`, vmangos `Unit::GetScaleForDisplayId`). Multiplying our own DBC scale on top
// double-applies it — `native²`, worst for the sub-1.0 starting-zone scales. A GameObject's
// display scale was always 1.0, so this is unchanged for it.
let placement = if let Ok(mut t) = transforms.get_mut(entity) {
t.scale = Vec3::splat(net.scale);
*t
} else {
Transform::default()
};
// The equipment this visual was dressed with (decision 0074): `redress_player_looks`
// diffs it against the live resolution and re-dresses the standing visual on a change.
if let (EntityKind::Player, Some(e)) = (net.kind, equipment) {
commands
.entity(entity)
.insert(super::equipment::AppliedEquipment(*e));
}
// Camera framing-pivot height (model-derived, pre-scale): the self-avatar reads it in the
// camera controller to target ~neck height instead of a fixed offset (harmless on NPCs).
commands.entity(entity).insert(CameraPivot {
height_local: dm.map(|d| d.pivot_height_local).unwrap_or(0.0),
});
// The overhead-anchor fallback input (combat text over a model with no PlayerName
// attachment — `0x608640`'s defensive branch).
commands.entity(entity).insert(super::OverheadFallback(
dm.map(|d| d.bbox_z_local).unwrap_or(0.0),
));
// The chat bubble's own anchor height — the Stand box's Z, a file constant, NOT the
// posed attachment the overhead name above rides (1406).
commands.entity(entity).insert(super::StandBoxHeight(
dm.map(|d| d.stand_box_z_local).unwrap_or(0.0),
));
// Selection-ring radius (model-local sphere radius, pre-scale) — the targeting ring reads it
// × the unit's scale (harmless on non-unit models, which are never ringed).
commands.entity(entity).insert(SelectionRadius(
dm.map(|d| d.ground_radius_local).unwrap_or(0.0),
));
// Particle emitters (flames/glows) — spawned per entity, despawning with it. A skinned
// creature's emitter rides its host bone's joint with the model-space origin rebased
// into the bone frame (`position − pivot`), exactly like a doodad emitter (0130 phase 4,
// same rig identity) — the kobold's candle flame follows the head through the crouch
// instead of floating at the rest-pose height. GameObjects/boneless models keep
// whole-entity follow (no joints; their bones hold rest pose anyway).
{
for em in emitters {
let owner = skin
.as_mut()
.and_then(|rb| rb.pose.anchor_for(&mut commands, entity, em.def.bone))
.map_or((entity, [0.0; 3]), |j| (j, em.bone_pivot));
particles::spawn_emitter(
&mut commands,
em,
placement,
particles::EmitterFrames {
owner: Some(owner),
// A unit's OWN model is not an attached model (`[model+0x17c]` = 0).
attach: None,
// The cloud anchors at the unit; bones compose births only.
anchor: Some(entity),
// The unit's model going away IS this emitter's model going away
// (stream-out, a visual rebuild) — free the pool with it (0826).
on_owner_loss: particles::OwnerLoss::Free,
// The unit IS the model instance here, so its own render alpha
// (appear ramp, stream-out ramp, the self-avatar feather) multiplies
// its clouds — decision 0827.
alpha: Some(entity),
// Scene-graph-carried: this model's world motion arrives on the reference's
// device stack, so its cloud RIDES (0986's baseline).
world_composed: false,
},
// The emitters' rate/enabled read this instance's PLAYING sequence — a
// unit's or GameObject's `AnimationPlayer` on the root. A quest object's
// explosion is authored inside its one-shot clips with an OFF window at
// idle (B27); a creature's death-only smoke is the same shape.
particles::EmitClock::Host(entity),
);
}
}
// The model's own M2 point lights (decision 0016) — a fire elemental's glow, a lit
// GameObject brazier. Same host-bone ride as the emitters, for the same reason: the
// reference re-registers each light at its LIVE bone position every frame. (The far more
// common carried light is the held torch — that one spawns on the item model, in
// `equipment`.) The light bones resolve BEFORE the call: the closure can't borrow
// `commands` while `spawn_carried_lights` holds it.
let light_anchors: std::collections::HashMap<i16, Entity> = model_lights
.iter()
.filter(|l| l.def.casts())
.filter_map(|l| {
let bone = u16::try_from(l.def.bone).ok()?;
let rb = skin.as_mut()?;
rb.pose
.anchor_for(&mut commands, entity, bone)
.map(|a| (l.def.bone, a))
})
.collect();
super::spawn_carried_lights(&mut commands, model_lights, entity, |bone| {
light_anchors.get(&bone).copied()
});
// Ribbon trails (wisp streamers, trailing quest-object crystals) — the same host-bone
// ride as the emitters; the trail self-despawns when its owner joint/entity goes.
{
for rb in dm.map(|d| d.ribbons.as_slice()).unwrap_or_default() {
let (owner, use_pivot) = skin
.as_mut()
.and_then(|build| build.pose.anchor_for(&mut commands, entity, rb.def.bone))
.map_or((entity, false), |j| (j, true));
// The `+0xc0` enable gate reads THIS instance's playing sequence, live —
// a GameObject flips state under its own trails (a trap springs, a door
// opens), so there is no spawn-time answer. Passing `None` here (the old
// "body models don't author a gate" shortcut, true of creatures and false of
// GameObjects) drew every trail a model authored in every state: the Frost
// Trap's twelve trigger-only streamers became a permanent spinning column
// over the placed trap (decision 1011).
benilla_world::ribbons::spawn_ribbon(
&mut commands,
rb,
owner,
use_pivot,
placement.scale.max_element(),
benilla_world::ribbons::RibbonSeq::Host(entity),
// The unit's own render alpha gates its trail (0827).
Some(entity),
// No fade sphere: a streamed unit is not a placed model — its
// population is bounded by server visibility, and its trail by the
// unit's own render alpha one line up.
None,
);
}
}
// The pose buffer lands last (decision 1355): every build arm above has resolved its
// anchors into it, so it arrives on the entity in one piece — the evaluator, the
// compose pass and every later `anchor_for` (a weapon equipped in combat, a spell
// kit) find the same buffer the build populated.
if let Some(rb) = skin {
commands.entity(entity).insert(rb.pose);
}
// Static collision for solid GameObjects (chests, mining veins, doors…): the model-local
// collider baked at build time rides the entity's pose, so player + camera collide with it.
// Hull-less GameObjects (herbs, small props) carry none — collide-iff-hull. GameObjects only;
// creatures use unit-collision, not modeled here. An anchored transport (boat/lift) is
// Kinematic — its body moves every frame, and a Static insert here would silently
// overwrite the arm's label when the asset finished loading after the arm ran.
if matches!(net.kind, EntityKind::GameObject) {
if let Some(col) = dm.and_then(|d| d.collider.clone()) {
let body = if anchored {
RigidBody::Kinematic
} else {
RigidBody::Static
};
commands.entity(entity).insert((body, col));
// This lane bypasses the streamer's attach queue entirely (0761), so it stamps
// the collider set itself — otherwise a creature standing where a GameObject
// hull lands frames later keeps the answer it took before there was one
// (decision 1384).
collider_epoch.bump();
}
}
} else {
// Cube fallback: other players (cyan, slim block) and NPCs (red, person-box) whose display
// named **no model to load** — the debug signal for a gap of ours. The cube origin is
// centered, so a child offset lifts it onto the ground.
//
// A display that DID name a model renders exactly what that model built — including
// nothing (decision 1403, bug B13). The reference's mesh gate is type-independent: it
// draws any loaded model and the per-batch zero-alpha cull skips what is transparent
// (decision 0024, verified wow-re `go-render-gate`), so a model with no surviving batch
// submits no geometry and the unit is invisible. Byte-exact, folded back at 1407: the
// batch loop's TRIP COUNT is the selected ModelView's texUnit count (`0x707a72`), so a
// view with none skips the whole loop — and the unit lane reaches that same loop, on
// the same singleton `CM2Scene`, through the same `0x613e10` as every other typeId.
// That is the whole of how a **trigger creature** hides —
// `Creature\InvisibleStalker\InvisibleStalker.m2` ships with
// `nVertices == 0`, and its `NoName` sibling's one batch carries a constant-zero
// transparency track — no unit flag is consumed anywhere. Cubing them put a black slab
// over 154 vmangos templates on 8 displays (the Scarab Wall's Anachronos trigger,
// Yojamba's Zandalarian Event Generator). It was only ever the *unit* arm that got this
// wrong; the GameObject arm below has drawn nothing since 0024.
let fallback = match net.kind {
_ if named_a_model => None,
EntityKind::Player => {
Some((assets.player_mat.clone(), assets.player_mesh.clone(), 1.0))
}
EntityKind::Unit => Some((assets.npc_mat.clone(), assets.mesh.clone(), 2.0)),
EntityKind::GameObject => {
debug!(
"gameobject (display {:?}) has no usable model — not rendering",
net.display_id
);
None
}
// A DynamicObject is deliberately invisible as an *object* — its look is the
// spell's area effect (the dest-anchored visual lane), never a fallback cube.
EntityKind::DynamicObject | EntityKind::Other => None,
};
if let Some((material, mesh, lift)) = fallback {
// Tag the cube as a pickable unit too (kind `Creature` covers units + player bodies), so
// a model-less NPC / other player can still be inspected and, crucially, targeted.
let object = WorldObject {
kind: ModelKind::Creature,
label: format!("{:?} (no model)", net.kind),
id: net.display_id.unwrap_or(0),
detail: String::new(),
};
commands.entity(entity).with_children(|parent| {
parent.spawn((
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::from_translation(Vec3::Y * lift),
object,
// …and into the unit pick's marker-filtered fallback population like any
// dressed creature part.
benilla_world::interact::CreaturePickPart,
// The cube has no resident `RenderSubmesh` to cast against — its `Aabb` IS
// its shape, which it must SAY (decision 0929): the picker requires pick
// geometry rather than inferring a box from its absence.
benilla_world::interact::PickBox,
// What makes "we are standing a cube here" a number a probe can read
// (`WOW_UNIT_VISUALS`) rather than something only an eye finds — 1403.
super::FallbackCube,
));
});
}
}
// The mount this visual was built with (decision 0441, the `AppliedEquipment` pattern):
// `refresh_mounts` diffs it against the live field and rebuilds on any transition. Written
// on the cube fallback too (same read, no seat), so a model-less unit can never churn.
if matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
let applied = stores
.get(entity)
.map_or(0, |s| s.0.unit_mount_display_id());
commands
.entity(entity)
.insert(super::mount::AppliedMount(applied));
}
commands
.entity(entity)
.insert(VisualAttached)
// The display this visual was built with (decision 0695, the same pattern):
// `refresh_live_display` diffs it against the live descriptor and rebuilds on a
// change (druid form, GM morph). Stamped on the cube fallback too, so a model-less
// unit can never churn.
.insert(super::live_display::AppliedDisplay(net.display_id))
.remove::<super::equipment::Reattached>();
}
}
/// Spawn a rig's joint-entity hierarchy under `root` (decision 0019): one entity per bone
/// carrying its rest-local translation, parented per the skeleton — root bones under `root` so
/// they inherit the entity's world pose, others under their parent joint. Returns the joints in
/// bone order, so a vertex's joint index maps straight in and every submesh's palette rig shares
/// this one set. **The doodad/effect/booth lane only** (decision 0724): a streamed unit's rig is
/// the joint-less [`benilla_world::rig_anim::RigPose`] buffer instead — this hierarchy remains for
/// the Bevy-graph-driven hosts. `holder` is the rig root that carries (or will carry) the
/// [`benilla_world::rig_palette::RigSkin`] — every joint marks it with a `RigJoint`, which is what the
/// palette change-sweep iterates (0720).
/// The enclosing box of a model's parts — its bind-pose extent, in model space.
///
/// The election bound's fallback (decision 1270) for a body whose armed idle authors no CAaBox.
/// Every part of one model shares that space, so their union is the model, and for a body that is
/// not armed at all it is exactly what gets drawn. A bind box can be a poor bound for a body whose
/// idle moves it far (0637's duel flag, modelled 9 yd up and planted by its Stand) — which is why
/// the authored box is preferred whenever there is one, and why this is a fallback rather than the
/// rule. Over-large is the safe direction here: it can only admit, never hide.
fn union_aabb(
boxes: impl Iterator<Item = bevy::camera::primitives::Aabb>,
) -> Option<bevy::camera::primitives::Aabb> {
let mut boxes = boxes;
let first = boxes.next()?;
let (mut min, mut max) = (first.min(), first.max());
for b in boxes {
min = min.min(b.min());
max = max.max(b.max());
}
Some(bevy::camera::primitives::Aabb::from_min_max(
Vec3::from(min),
Vec3::from(max),
))
}
/// A display model's source path as a readable inspector label (the asset path, sans `mpq://` source).
/// Empty for the model-less variant or a path-less handle.
fn display_label(handle: &ModelHandle) -> String {
let path = match handle {
ModelHandle::M2(h) => h.path(),
ModelHandle::Wmo(h) => h.path(),
ModelHandle::None => None,
};
path.map(|p| p.path().to_string_lossy().into_owned())
.unwrap_or_default()
}