- units could be hovered through the loading screen — the sword cursor gave it away: the world pick chain has no state gate, one of fifty-one input sites, so a gate per consumer is a rule that regresses; the cut is at the source instead — one `PreUpdate` system empties the button planes, drains the raw queues, zeroes the accumulators and blanks the window's cursor position while the cover is up (the alt-tabbed-away state every consumer already handles); a press under the cover never happened, a held button is released once, a gesture in flight is cancelled, and a wall test counts the channels; the reference does the same — its raise registers a veto stub (`0x406a50`) on five event-bus categories — and one gap closed with it: the reference parks the plain arrow and drops any item overlay before the screen goes up, where benilla kept a held item's icon over the loading art (1986, 1990) - the micro-button row is the reference's own `MainMenuBarMicroButtons.xml`, our transcription deleted — no engine verb was missing, and the whole engine-side cost is one alias, `SoundOptionsFrame = OptionsFrame`, because the stock file reads the reference's three options windows and ours is the one window for all three; what the stock file brings back: `UpdateTalentButton`'s own −2 re-seat (the five buttons after the talent button sit 1 px further right — the reference's own pixel), the raw binding token in the hover label (`(C)`, `(SHIFT-P)`), the sixty-second talent-button pulse on a ding while the sheet is closed (0304's scope-out retired), the keyring arm, and "Abilities" on the spellbook hover for a character with no spells; `BenillaActionBarArt_SeatAbove` goes with the file — a child is born one level above its parent (`SetParent` `0x76ab65`), the seat it set by hand (1987) - hovering the player arrow on the stock world map showed an empty tooltip: narrower than 4:3 the map is scaled under 1, and our `MouseIsOver` had dropped the reference's division by `GetEffectiveScale` as "always 1" — the frame getters answer in the frame's own units, `GetCursorPosition` in the screen's; the division is back, and the region getters divide by their owner's scale too; and the ready check times out: the stock `UIParent.xml` ticks `CheckReadyCheckTime` every frame, the one unbound name in that file's way, so the mechanism behind it is built — a 30 s deadline and a "has not answered" flag per roster entry, the leader getting no popup while members print "%s has initiated a ready check" and get it, and on expiry `RAID_MEMBERS_AFK` with the unanswered names or `READY_CHECK_NO_AFK`, the last answer closing the check at once (1985, 1989) - a review of the floor grind closed eight stale-input and bypassed-side-effect siblings the gates had left open: a never-placed card places on a still frame, the dormant-emitter skip sits below the emitter's lifetime blocks, PVS changes (not only spawns) un-still the gates, the appear ramp's retirement and a far-side unmark come due, a host born under a parked camera is judged, the projection counts as a still input, a fresh palette range is zeroed, the stable feed runs once more after a close, the unit publisher's viewer query is filtered, solo UI materials are reused by key, and the draw-list cache compares exactly; and three doc links the chat window's stock move (1948) left dangling point at what replaced them — the colour table and the tell ring are the stock Lua's now — which is what had the doc-links gate (1925) red on main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1731 lines
86 KiB
Rust
1731 lines
86 KiB
Rust
//! Streamed world entities: give each network entity ([`crate::net::NetEntity`]) a visual. NPCs **and
|
||
//! other players** render as their real M2 (resolved from the display id via `CreatureCatalog` — a
|
||
//! player's body resolves through the same creature chain, decision 0041), GameObjects as their model
|
||
//! (`GameObjectCatalog`), and everything else as a colored cube.
|
||
//!
|
||
//! The entity itself — its identity, pose, and movement — is owned by the net bridge: `apply_net_updates`
|
||
//! spawns one real ECS entity per server guid (with a [`Transform`] driven by `sample_splines`), and this
|
||
//! module simply attaches a visual to it as soon as the model asset has loaded. There is no per-frame
|
||
//! snapshot and no entity side-map: a unit *is* one entity.
|
||
//!
|
||
//! Models load through the standard `AssetServer` as `Handle<M2Model>`/`Handle<WmoModel>` (the same
|
||
//! `mpq://` pipeline the terrain streamer uses) — deduped + async, no main-thread parse. Per display id
|
||
//! we keep a [`DisplayModel`]: its handle + (for creatures) its skin variations, and the spawn parts
|
||
//! built once the asset loads (creature `Monster1/2/3` skin slots filled here from the display's
|
||
//! variations — see [`ModelSubmesh::skin_slot`]).
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use benilla_assets::{M2Model, WmoModel};
|
||
use benilla_formats::{
|
||
load_creature_catalog, load_gameobject_catalog, load_item_display_catalog, CharCreateCatalog,
|
||
CharSections, CharacterGeosets, CreatureCatalog, GameObjectCatalog,
|
||
};
|
||
use benilla_protocol::EntityKind;
|
||
use bevy::prelude::*;
|
||
|
||
use crate::net::NetEntity;
|
||
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
|
||
use benilla_world::model_fade::apply_render_fade;
|
||
use benilla_world::schedule::WorldStage;
|
||
|
||
/// Resolving a display id to its [`DisplayModel`] and building its spawn parts once the model asset
|
||
/// loads (materials, skeleton, collider, camera/selection metrics) — the front half of this subsystem,
|
||
/// kept in its own file as it carries the bulk of the per-display cache/build logic.
|
||
pub(crate) mod display;
|
||
use display::{
|
||
build_parts, empty_display, empty_shell, new_creature_display, new_gameobject_display,
|
||
DisplayModel, EntityPart, ModelHandle,
|
||
};
|
||
|
||
/// Attaching a visual to each net entity (the back half of this subsystem) — kept in its own file as it
|
||
/// carries the bulk of the per-entity spawn logic (skeleton/animation, character geoset + skin, fade).
|
||
mod attach;
|
||
use attach::{attach_entity_visuals, build_dressup_preview, build_glue_pet, build_glue_preview};
|
||
|
||
/// The dynamic point lights an entity's own model carries into the world — the held torch above all:
|
||
/// decision 0016's law applied to the *entity* half of the scene, not just the placed half.
|
||
mod carried_light;
|
||
use carried_light::spawn_carried_lights;
|
||
|
||
/// Equipment visuals (decisions 0072/0074): held items (weapon/shield/ranged) plus worn-armor and
|
||
/// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as
|
||
/// children of the body's attach-point joints.
|
||
mod equipment;
|
||
use equipment::{attach_held_items, resolve_corpse_equipment, resolve_equipment};
|
||
|
||
/// Item / enchant glow effects (decision 0805): the `Spells\Enchantments\*.mdx` models an item's
|
||
/// `ItemVisuals` id hangs on the item's OWN attachment points — the permanent weapon glows and
|
||
/// the shaman/oil enchant visuals.
|
||
mod item_glow;
|
||
pub(crate) use item_glow::ItemGlowAttached;
|
||
use item_glow::{attach_item_glows, ItemGlows};
|
||
|
||
/// Mounts (decision 0441): the `UNIT_FIELD_MOUNTDISPLAYID` → second-creature-visual projection —
|
||
/// the mount child + seat components and the transition's **re-seat** (B199: the reference
|
||
/// re-parents the rider's model onto the mount, it never rebuilds it).
|
||
pub(crate) mod mount;
|
||
use mount::reseat_mounts;
|
||
|
||
/// Live descriptor appearance (decision 0695): a `Values` delta moving the display id swaps the
|
||
/// model in place (druid forms, GM morphs — ledger B69/F04) and one moving `SCALE_X` eases the
|
||
/// render scale (the reference's 2 s cosine smoothstep); both restamp the collision height.
|
||
/// The corpse OBJECT (decision 1706): a `TYPEID_CORPSE` create rendered as the dead body — the
|
||
/// character-model chain dressed from the corpse's own `CORPSE_FIELD_*` snapshot, or the
|
||
/// `<Race><Sex>DeathSkeleton` prop once the server has converted it to bones.
|
||
pub(crate) mod corpse;
|
||
|
||
mod live_display;
|
||
pub(crate) use live_display::DisplaySwapped;
|
||
use live_display::{refresh_live_display, tick_scale_ease};
|
||
|
||
/// Terrain conform (decisions 0482/0486): every flagged model (`GlobalModelFlags & 3 ∈ {1,3}` —
|
||
/// mounts and wild quadrupeds alike) tilts to the ground under its unit, through the conform
|
||
/// node its root bones parent under.
|
||
mod conform;
|
||
|
||
/// The per-unit collision height (decision 0645): the display id's `CreatureModelData` column,
|
||
/// scaled — the `h` the swim/wade/splash/foam depth lines are each a verified fraction of.
|
||
mod collision_height;
|
||
use collision_height::stamp_collision_heights;
|
||
pub(crate) use collision_height::CollisionHeight;
|
||
|
||
/// Spell-visual effect models (decision 0099 phase 3): a casting unit's attach-point `.mdx` glows,
|
||
/// spawned under the same attach-point joints as held items, lifetime per the kit stage.
|
||
mod missile;
|
||
pub(crate) use missile::MissileSound;
|
||
use missile::{attach_missile_models, move_missiles, spawn_missiles};
|
||
|
||
/// WMO-display GameObject doodad props (the ship's sails / the zeppelin's rotor): the WMO's MODD
|
||
/// M2s spawned as children of the streamed gameobject, so they ride a moving transport.
|
||
mod wmo_props;
|
||
use wmo_props::{resolve_wmo_gameobject_props, spawn_wmo_gameobject_props};
|
||
pub(crate) mod spell_fx;
|
||
use spell_fx::{attach_spell_fx, resolve_spell_fx};
|
||
|
||
/// Dest-anchored spell effects (decision 0797): a DynamicObject's persistent area visuals
|
||
/// (Blizzard's storm, Flamestrike's burn) + the GO dest one-shot burst. (Distinct from
|
||
/// `crate::ground_fx`, the flat-quad decal renderer this lane's models feed into.)
|
||
pub(crate) mod dest_fx;
|
||
use dest_fx::{
|
||
arm_ground_effects, attach_ground_fx_models, spawn_ground_bursts, tick_shard_emitters,
|
||
};
|
||
|
||
/// Spell **chain beams** (decision 0955): the polyline of hops a kit's chain `CharProc` draws —
|
||
/// Chain Lightning's arcs, Drain Life's rope of soul, C'Thun's eye beam. Its geometry rides the
|
||
/// shared effect-quad stream beside the ribbon trails.
|
||
mod chain_beam;
|
||
pub(crate) use chain_beam::ChainHops;
|
||
use chain_beam::{simulate_chain_beams, spawn_chain_beams};
|
||
// The container feed reads the icon column off the same catalog resource (one DBC parse).
|
||
// The one InventoryType → equipment-slot table (`attach::preview`): the dressing-room feed places a
|
||
// tried-on item by the very same map the preview it feeds dresses by (decision 1060).
|
||
pub(crate) use attach::{equip_slot, BodyPartsDesc};
|
||
pub(crate) use equipment::ItemDisplays;
|
||
pub(crate) use equipment::{BoneAttach, Equipment};
|
||
// The instruments' read of what a body is actually WEARING vs what it resolved (`WOW_DRESS_CENSUS`).
|
||
pub(crate) use equipment::{attach_id, DressKey, HeldAttached, ATTACH_SLOT_NAMES};
|
||
// [`DressKey`] carries one of these per weapon slot, but nothing outside this module has to NAME
|
||
// the type to hold or compare a key — only the tests that build one do.
|
||
#[cfg(test)]
|
||
pub(crate) use equipment::ItemModelKind;
|
||
|
||
/// The overhead attachment slot (`PlayerName`, id 18). The anchor of the overhead name, the
|
||
/// floating combat text, and the questgiver marker alike.
|
||
pub(crate) const ATTACH_OVERHEAD: u16 = 18;
|
||
|
||
/// Its mounted twin (`PlayerNameMounted`, id 29) — preferred while a mount model is attached
|
||
/// (VERIFIED wow-re `questgiver-marker.md`/`nameplate-vkey.md`; decision 0441 P2), authored on
|
||
/// the RIDER's own model (character models seat it higher so overhead content clears the
|
||
/// mount's bulk). A rider model without it falls back to 18 like the client.
|
||
pub(crate) const ATTACH_OVERHEAD_MOUNTED: u16 = 29;
|
||
|
||
/// The overhead attachment slot to use for a unit **right now** — the reference's own pick inside
|
||
/// the marker attach `0x6074c0` (VERIFIED, wow-re object-layer `questgiver-marker.md` Q2), always
|
||
/// queried on the unit's *body* model: slot **29** when a mount MODEL exists (`unit+0xdc != 0` —
|
||
/// our [`mount::MountChild`]) *and* the body authors 29, else slot **18**; `None` when the body
|
||
/// authors neither, which is the reference's "marker created but never parented" (invisible) and
|
||
/// this file's bbox fallback for the overhead readers.
|
||
///
|
||
/// **The pick is LIVE, not a bake.** `0x6074c0`'s five call sites include `0x5ffae7` inside
|
||
/// `0x5ffa50` — the `UNIT_FIELD_MOUNTDISPLAYID` field-watch handler (`0x604330 mov edx,0x1fc; mov
|
||
/// ecx,3`), the very handler [`mount::reseat_mounts`] ports — so the reference re-runs the whole
|
||
/// attach, slot choice included, on every mount and dismount. A per-frame *reader*
|
||
/// ([`overhead_anchor`]) gets that for free; a consumer that PARENTS something at the slot
|
||
/// ([`crate::quest_markers`]) has to notice the move and re-parent itself (decision 1871).
|
||
pub(crate) fn overhead_slot(attach: &BoneAttach, mounted: bool) -> Option<u16> {
|
||
if mounted && attach.points.contains_key(&ATTACH_OVERHEAD_MOUNTED) {
|
||
Some(ATTACH_OVERHEAD_MOUNTED)
|
||
} else if attach.points.contains_key(&ATTACH_OVERHEAD) {
|
||
Some(ATTACH_OVERHEAD)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The `0x608640` fallback multiplier (`0x80c5d0` = 1.25): a unit whose model has no PlayerName
|
||
/// attachment anchors overhead content at `feet + scale × bbox_z × 1.25`.
|
||
const OVERHEAD_FALLBACK_FACTOR: f32 = 1.25;
|
||
|
||
/// The unit's **Stand-animation box height** (model-local, pre-scale) — the chat bubble's anchor,
|
||
/// and *only* the chat bubble's (1406).
|
||
///
|
||
/// The reference's two overhead heights are two different mechanisms, and benilla had recorded them
|
||
/// as one. The overhead NAME (and the floating combat text, and the V-plate) takes `0x608640`: the
|
||
/// live posed PlayerName attachment, which tracks the pose. The chat bubble takes `0x711a20`, which
|
||
/// wow-re's cross-check followed into the model layer and found reading the **MD20 header image** —
|
||
/// file bytes, no bone matrix in the call tree — returning the Stand sequence CAaBox's Z extent,
|
||
/// and the client caches it in the bubble at `+0x354` on a parity guard so it is queried **once per
|
||
/// chat line**. The recorded claim that the two calls were equivalent ("both are the head-region
|
||
/// attachment height, model-scaled", INFERRED) is refuted: they differ precisely on
|
||
/// animated-vs-static.
|
||
///
|
||
/// So this is a constant per display, stamped at attach and never re-read — which is also why the
|
||
/// bubble's height cannot acquire a pose-clock bug of the kind 1398 had to remove from the anchor.
|
||
#[derive(Component)]
|
||
pub(crate) struct StandBoxHeight(pub(crate) f32);
|
||
|
||
/// The unit's model bbox z-extent (model-local, pre-scale) — stamped by the attach path for
|
||
/// [`overhead_anchor`]'s fallback. `0.0` until the model loads (the fallback then anchors at
|
||
/// feet — the same degenerate the client hits with no model).
|
||
#[derive(Component)]
|
||
pub(crate) struct OverheadFallback(pub(crate) f32);
|
||
|
||
/// The unit's OVERHEAD anchor, world space (`0x608640`, byte-read): the **posed** PlayerName
|
||
/// attachment (slot 29 while a mount model is attached, else 18 — head height, tracking model
|
||
/// stature and the live pose intrinsically), else `feet + scale × bbox_z × 1.25`. Consumed
|
||
/// per-frame by the nameplate and snapshotted at spawn by the floating combat text. Generic over
|
||
/// the joint-globals query filter so a caller that also mutates `GlobalTransform` elsewhere (the
|
||
/// nameplate placer) can pass a disjoint query.
|
||
///
|
||
/// A pure position read: it computes through `RigPose::posed_point` — the composed pose × the
|
||
/// rig root's frame — and never touches an anchor entity, so the overhead bone spawns nothing
|
||
/// (decision 1355).
|
||
///
|
||
/// **The rig root's frame is taken from `tf`, not from its propagated `GlobalTransform`, whenever
|
||
/// the rig root IS the unit** — which is the normal case (a mounted rider's root is the seat
|
||
/// anchor, a conform-tilted model's is its conform node; those two still read the propagated
|
||
/// frame and keep the lag below). Bevy propagates `GlobalTransform` in `PostUpdate`, so an
|
||
/// `Update` reader like the V-plate or the chat bubble gets **last frame's** world frame while the
|
||
/// unit's own `Transform` beside it — and the camera it projects through, and the model being
|
||
/// drawn — are this frame's. Running, that seam is one frame of travel: measured on a Westfall run
|
||
/// (1398), "head above feet" — a body constant — wobbled up to **11.7 px** per frame and 15.1 px
|
||
/// across the leg, and collapsed to 0.02 px the moment the anchor was paired with the position it
|
||
/// was actually computed from. That is the chat bubble sliding against the head the director
|
||
/// reported as jitter, and 1341 cleared the same term for the plate by measuring a unit that was
|
||
/// STANDING STILL, where it is identically zero.
|
||
///
|
||
/// Reading `tf` is exact here because a unit is a world-root entity — nothing in the world lane
|
||
/// parents a `NetEntity` (the `ChildOf` sites are the portrait booth, the pipe-warm menagerie and
|
||
/// the UI glue), so its global IS its local. A `PostUpdate` caller ([`crate::nameplates`], which
|
||
/// moved there for this very lag) is unaffected: after propagation the two are the same value.
|
||
/// The client's **per-attachment z-bias** fallback table, `[0x862708]` — 37 floats, attachment
|
||
/// ids `0..=0x24`, read as `[0x862708 + 4·id]` under a `0 ≤ id < 0x25` guard (wow-re
|
||
/// `object-layer/scratch/anim-event-position-law.md` §4, §5-cross-checked). It is consulted only
|
||
/// when the model does **not** carry the attachment the caller asked for: the sound then plays at
|
||
/// the unit's own position raised by this much, which is how a headless or attachment-less model
|
||
/// still puts a mouth sound somewhere plausible instead of at its feet.
|
||
const ATTACH_Z_BIAS: [f32; 37] = [
|
||
1.0, 1.0, 1.0, 1.5, 1.5, 1.8, 1.8, 0.5, 0.5, 1.0, 1.0, 2.0, 1.5, 1.0, 1.0, 1.0, 1.0, 2.0, 2.5,
|
||
0.0, 2.0, 1.5, 1.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 3.0, 1.5, 1.5, 1.0, 1.0, 1.5, 1.0, 1.0,
|
||
];
|
||
|
||
/// **`0x623b90(attachId)` — where a unit-attached sound is born**, as a [`SystemParam`] because
|
||
/// two different routers need the same three pure reads.
|
||
///
|
||
/// [`SystemParam`]: bevy::ecs::system::SystemParam
|
||
///
|
||
/// `AttachPoints::point` returns The attachment's live world
|
||
/// point when the model carries it (`0x712cb0` finds the record, `0x712d50` transforms it), else
|
||
/// the unit's own `GetPosition` raised by [`ATTACH_Z_BIAS`]`[attachId]` (`0x623be2 fadd
|
||
/// [4·ebx + 0x862708]`).
|
||
///
|
||
/// Two anim-event arms reach it and they are the reason this exists as a named function rather
|
||
/// than inline at either: the emote voice `$CSD` asks for **17** (`0x623c3a push 0x11`), and a
|
||
/// **whiffed** melee swing's `$CSS` asks for **1** (`0x624bdd`). Both are emphatically *not* the
|
||
/// fired event's own point, which is what makes them the exceptions in
|
||
/// `anim-event-position-law.md` §3's table — a player model's six head-mounted `$CSD` records do
|
||
/// not decide where that sound plays.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct AttachPoints<'w, 's> {
|
||
anchors: Query<'w, 's, &'static BoneAttach>,
|
||
poses: Query<'w, 's, &'static benilla_world::rig_anim::RigPose>,
|
||
globals: Query<'w, 's, &'static GlobalTransform>,
|
||
}
|
||
|
||
impl AttachPoints<'_, '_> {
|
||
/// The world point, with `fallback` standing in for the reference's `GetPosition` (the caller
|
||
/// already holds the unit's transform, and passing it keeps this a pure read).
|
||
pub(crate) fn point(&self, entity: Entity, attach: u16, fallback: Vec3) -> Vec3 {
|
||
self.anchors
|
||
.get(entity)
|
||
.ok()
|
||
.and_then(|a| {
|
||
let &(bone, offset) = a.points.get(&attach)?;
|
||
let pose = self.poses.get(entity).ok()?;
|
||
pose.posed_point(self.globals.get(pose.joints_root).ok()?, bone, offset)
|
||
})
|
||
.unwrap_or_else(|| {
|
||
fallback + Vec3::Y * ATTACH_Z_BIAS.get(attach as usize).copied().unwrap_or(0.0)
|
||
})
|
||
}
|
||
}
|
||
|
||
pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
|
||
entity: Entity,
|
||
tf: &Transform,
|
||
anchors: &Query<&BoneAttach>,
|
||
poses: &Query<&benilla_world::rig_anim::RigPose>,
|
||
fallbacks: &Query<&OverheadFallback>,
|
||
globals: &Query<&GlobalTransform, F>,
|
||
mounts: &Query<(), With<mount::MountChild>>,
|
||
) -> Vec3 {
|
||
anchors
|
||
.get(entity)
|
||
.ok()
|
||
.and_then(|a| {
|
||
let &(bone, offset) = a.points.get(&overhead_slot(a, mounts.contains(entity))?)?;
|
||
let pose = poses.get(entity).ok()?;
|
||
let own;
|
||
let root = if pose.joints_root == entity {
|
||
own = GlobalTransform::from(*tf);
|
||
&own
|
||
} else {
|
||
globals.get(pose.joints_root).ok()?
|
||
};
|
||
pose.posed_point(root, bone, offset)
|
||
})
|
||
.unwrap_or_else(|| {
|
||
let bbox_z = fallbacks.get(entity).map_or(0.0, |f| f.0);
|
||
tf.translation + Vec3::Y * (tf.scale.y * bbox_z * OVERHEAD_FALLBACK_FACTOR)
|
||
})
|
||
}
|
||
|
||
/// The child mesh spawned by the fallback-cube arm of [`attach::attach_entity_visuals`] — "this
|
||
/// entity's display named no model we could load".
|
||
///
|
||
/// A marker, so the condition is **countable** rather than eyeballed: `WOW_UNIT_VISUALS`
|
||
/// ([`crate::capture::probes::UnitVisualsPlugin`]) reports how many streamed entities are standing
|
||
/// as cubes, and on which displays. Before decision 1403 a cube also stood for every invisible
|
||
/// trigger creature, which is what B13 saw as a black slab — an unlit `StandardMaterial` catches no
|
||
/// light in our scene, so the "red" NPC box renders pure black. The census is how that stays
|
||
/// visible if the gate ever regresses.
|
||
#[derive(Component)]
|
||
pub(crate) struct FallbackCube;
|
||
|
||
/// Shared fallback cube mesh + per-kind materials, used when an entity has no usable model. (No
|
||
/// GameObject color: GameObjects render their model or nothing — a model-less GameObject is an
|
||
/// effect/trigger that's invisible in the real client.)
|
||
#[derive(Resource)]
|
||
pub(crate) struct CubeAssets {
|
||
mesh: Handle<Mesh>,
|
||
/// Slimmer, shorter block for a player whose body model isn't available (smaller than the NPC box).
|
||
player_mesh: Handle<Mesh>,
|
||
player_mat: Handle<StandardMaterial>,
|
||
npc_mat: Handle<StandardMaterial>,
|
||
}
|
||
|
||
impl CubeAssets {
|
||
/// The pipeline-warm rig parts ([`crate::pipe_warm`], decision 0938): the production cube
|
||
/// mesh + both materials, so the fallback-cube pipeline compiles behind the cover instead of
|
||
/// on the first model-less spawn in view.
|
||
pub(crate) fn warm_parts(&self) -> (Handle<Mesh>, [Handle<StandardMaterial>; 2]) {
|
||
(
|
||
self.mesh.clone(),
|
||
[self.player_mat.clone(), self.npc_mat.clone()],
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Creature rendering: the display→model catalog + a per-display [`DisplayModel`] cache. Optional — if
|
||
/// the DBCs fail to load, NPCs stay cubes.
|
||
#[derive(Resource)]
|
||
pub(crate) struct Creatures {
|
||
catalog: CreatureCatalog,
|
||
models: HashMap<u32, DisplayModel>,
|
||
}
|
||
|
||
impl Creatures {
|
||
/// A display's **foley material** (`Material.dbc` id) — the creature half of the footfall
|
||
/// rustle; see [`benilla_formats::CreatureCatalog::foley_material`] and
|
||
/// [`crate::sound::footsteps`]. `None` for an unknown display.
|
||
pub(crate) fn foley_material(&self, display_id: u32) -> Option<u32> {
|
||
self.catalog.foley_material(display_id)
|
||
}
|
||
|
||
/// A display's collision height in **raw model units** — see [`CollisionHeight`] for the world
|
||
/// value and everything that reads it. `None` for an unknown display.
|
||
pub(crate) fn collision_height(&self, display_id: u32) -> Option<f32> {
|
||
self.catalog.collision_height(display_id)
|
||
}
|
||
|
||
/// A display's footprint-decal parameters (yards, pre-scale) — `None` = this model leaves no
|
||
/// prints. See [`benilla_formats::CreatureCatalog::footprint`].
|
||
pub(crate) fn footprint(&self, display_id: u32) -> Option<benilla_formats::FootprintParams> {
|
||
self.catalog.footprint(display_id)
|
||
}
|
||
|
||
/// Does this display's model breathe — may it wear the `$BTH` puffs (cold vapour, bubbles)?
|
||
/// `CreatureModelData.Flags & 0x2` suppresses the family: skeletons, ghosts, elementals,
|
||
/// golems, slimes, totems. See [`benilla_formats::CreatureCatalog::breathes`].
|
||
pub(crate) fn breathes(&self, display_id: u32) -> bool {
|
||
self.catalog.breathes(display_id)
|
||
}
|
||
|
||
/// A display's **base render alpha** (`CreatureDisplayInfo.CreatureModelAlpha / 255`) — the
|
||
/// `baseAlpha` factor of the per-unit alpha product the aura CharProc nodes multiply into
|
||
/// (`crate::aura_visual`). `None` for an unknown display.
|
||
pub(crate) fn display_base_alpha(&self, display_id: u32) -> Option<f32> {
|
||
self.catalog.display_base_alpha(display_id)
|
||
}
|
||
|
||
/// A display's **footstep camera-shake preset** (`CameraModelData.FootstepShakeSize` → a
|
||
/// `CameraShakes.dbc` row id) — see [`crate::camera_shake`]. `None` for the 405 of 430 shipped
|
||
/// models that shake nothing.
|
||
pub(crate) fn footstep_shake(&self, display_id: u32) -> Option<u32> {
|
||
self.catalog.footstep_shake(display_id)
|
||
}
|
||
|
||
/// A display's **death-thud camera-shake preset** — same table, fired on `$DTH`.
|
||
pub(crate) fn death_thud_shake(&self, display_id: u32) -> Option<u32> {
|
||
self.catalog.death_thud_shake(display_id)
|
||
}
|
||
|
||
/// A display's **audible size class** (`0 Small`..`4 Colossal`) — the axis the body-fall
|
||
/// sample is picked on (`crate::sound::death_thud`). The display's own column overrides the
|
||
/// model's; see [`benilla_formats::CreatureCatalog::size_class`].
|
||
pub(crate) fn size_class(&self, display_id: u32) -> Option<u32> {
|
||
self.catalog.size_class(display_id)
|
||
}
|
||
|
||
/// A display's **spawned-creature render scale** — see
|
||
/// [`benilla_formats::CreatureCatalog::model_scale`] for why almost nothing reads it and why
|
||
/// the glue screens' pet must. `None` for an unknown display.
|
||
pub(crate) fn model_scale(&self, display_id: u32) -> Option<f32> {
|
||
self.catalog.model_scale(display_id)
|
||
}
|
||
|
||
/// A display's two **blood-row candidates** — `CreatureDisplayInfo.BloodLevel` and
|
||
/// `CreatureModelData.BloodID`, tiers 1 and 2 of the reference's UnitBloodLevels resolve.
|
||
/// `None` = unknown display. The tiers themselves need the table, so they live in
|
||
/// [`benilla_formats::BloodCatalog::level_key`] — which is where tier 3 is, and 1850's whole
|
||
/// point is that tier 3 is not optional.
|
||
pub(crate) fn blood_candidates(&self, display_id: u32) -> Option<(i32, i32)> {
|
||
self.catalog
|
||
.model(display_id)
|
||
.map(|m| (m.blood_display, m.blood_model))
|
||
}
|
||
|
||
/// A built display's booth framing — the model's own **authored cameras**, which is what both
|
||
/// booth families frame through: `camera` for the round portrait (`cameraLookup[0]`, wow-re
|
||
/// portrait-render §4) and `pane_camera` for a `<PlayerModel>` body pane (raw index 1, wow-re
|
||
/// `modelframe-camera-law.md`). Each carries the fallback data its own path needs when the model
|
||
/// has no such camera: the heuristic anchors (head bone / neck height / footprint) for the
|
||
/// portrait, the bbox centre for the pane's fixed camera. All model-local pre-scale. `None`
|
||
/// while the display's model is still loading (the booth's part source — the attach-spawned
|
||
/// children — won't exist yet either).
|
||
pub(crate) fn display_anchors(
|
||
&self,
|
||
display_id: u32,
|
||
) -> Option<crate::portrait::PortraitAnchors> {
|
||
let dm = self.models.get(&display_id)?;
|
||
dm.parts.as_ref()?; // not yet built
|
||
Some(crate::portrait::PortraitAnchors {
|
||
camera: dm.portrait_camera,
|
||
pane_camera: dm.pane_camera,
|
||
bbox_center: dm.bake_center_local,
|
||
head: crate::portrait::head_anchor(&dm.skeleton, &dm.attachments),
|
||
pivot_height: dm.pivot_height_local,
|
||
ground_radius: dm.ground_radius_local,
|
||
})
|
||
}
|
||
|
||
/// A built display's **mirror lists** — the same `(PortraitPart, PortraitBillboard)` pair a
|
||
/// dressed unit's attach-spawned descendants carry, assembled straight from the display cache
|
||
/// for a creature that has **no world entity at all**: the stable window's stabled pet
|
||
/// ([`crate::portrait::PortraitStandIn`]).
|
||
///
|
||
/// The split is [`crate::entities::attach::preview`]'s `assemble_pet` law verbatim — every
|
||
/// batch of the model, camera-facing ones separated out — because it is the same question:
|
||
/// only the character compositor selects among geosets, and a creature is not a character
|
||
/// model, so the world's creature path draws every batch of a beast the same way. The
|
||
/// billboards seat at [`crate::portrait::PortraitSeat::Body`] (the model's own rigged batch:
|
||
/// its bone's booth joint already bakes the pivot, the 0130 rig identity) with `attach: None`,
|
||
/// which is exactly what a host model's own batch is.
|
||
///
|
||
/// `None` while the display's model is still loading — the same gate [`Self::display_rig`] and
|
||
/// [`Self::display_anchors`] apply, so all three become available together.
|
||
pub(crate) fn display_mirror(
|
||
&self,
|
||
display_id: u32,
|
||
) -> Option<(
|
||
Vec<crate::portrait::PortraitPart>,
|
||
Vec<crate::portrait::PortraitBillboard>,
|
||
)> {
|
||
let parts = self.models.get(&display_id)?.parts.as_deref()?;
|
||
let mut meshes = Vec::new();
|
||
let mut cards = Vec::new();
|
||
for part in parts {
|
||
match &part.billboard {
|
||
Some(info) => cards.push(crate::portrait::PortraitBillboard {
|
||
mesh: part.mesh.clone(),
|
||
material: part.material.clone(),
|
||
bone: info.bone,
|
||
seat: crate::portrait::PortraitSeat::Body,
|
||
kind: info.kind,
|
||
attach: None,
|
||
}),
|
||
None => meshes.push(crate::portrait::PortraitPart {
|
||
static_mesh: part.mesh.clone(),
|
||
skinned_mesh: part.skinned_mesh.clone(),
|
||
material: part.material.clone(),
|
||
}),
|
||
}
|
||
}
|
||
Some((meshes, cards))
|
||
}
|
||
|
||
/// A built display's **booth rig** — what the portrait booth needs to pose a fresh instance at
|
||
/// Stand like the ref bake (wow-re portrait-render §4 D2: a throwaway instance armed to
|
||
/// Stand/seq-0, not the unit's live world pose): the rest skeleton, the shared inverse bind
|
||
/// poses, and the animation surface. `None` while the model is still loading; a boneless /
|
||
/// WMO-display model yields an empty skeleton (the booth then bakes the static bind pose).
|
||
pub(crate) fn display_rig(&self, display_id: u32) -> Option<DisplayRig<'_>> {
|
||
let dm = self.models.get(&display_id)?;
|
||
dm.parts.as_ref()?; // not yet built
|
||
Some(DisplayRig {
|
||
skeleton: &dm.skeleton,
|
||
inverse_bindposes: dm.inverse_bindposes.clone(),
|
||
animations: dm.animations.as_ref(),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// The skeleton/animation surface the portrait booth poses a bake with ([`Creatures::display_rig`]).
|
||
pub(crate) struct DisplayRig<'a> {
|
||
pub(crate) skeleton: &'a benilla_assets::ModelSkeleton,
|
||
/// The shared inverse bind poses — `Some` for every M2 display (built at load), `None` only for
|
||
/// WMO / model-less displays (whose skeletons are empty anyway).
|
||
pub(crate) inverse_bindposes: Option<Handle<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
|
||
pub(crate) animations: Option<&'a benilla_assets::ModelAnimations>,
|
||
}
|
||
|
||
/// GameObject rendering: the GameObjectDisplayInfo catalog + a per-display [`DisplayModel`] cache. Same
|
||
/// fallback rules as [`Creatures`]; a model-less GameObject renders nothing (effect/trigger).
|
||
#[derive(Resource)]
|
||
struct GameObjects {
|
||
catalog: GameObjectCatalog,
|
||
models: HashMap<u32, DisplayModel>,
|
||
}
|
||
|
||
/// Character geoset selection (decision 0041, Milestone B): the customization → visible-geoset tables.
|
||
/// Optional — if the DBCs fail to load, players simply render every geoset (the un-filtered body), the
|
||
/// same as before this feature.
|
||
#[derive(Resource)]
|
||
struct Characters(CharacterGeosets);
|
||
|
||
/// Character skin textures (decision 0041, Milestone B): the CharSections base-skin lookup. Optional —
|
||
/// if it fails to load, a player's body-skin batches stay untextured (the muted fallback), as before.
|
||
#[derive(Resource)]
|
||
struct SkinSections(CharSections);
|
||
|
||
/// Character-creation source data (decision 0423): per-race body displayIds, race/class combos, and
|
||
/// the appearance-dial ranges. Read by the glue-preview builder ([`attach`]) — displayId +
|
||
/// look — and by the char-create screen. Optional — absent ⇒ the create screen has no data (it
|
||
/// degrades to disabled), but the rest of the game is unaffected.
|
||
#[derive(Resource)]
|
||
pub(crate) struct CharCreate(pub(crate) CharCreateCatalog);
|
||
|
||
/// Per-appearance cache of composited body-skin atlases (decision 0044): each distinct character look
|
||
/// composites + uploads its 256² skin once, and every player sharing that look reuses the handle. A
|
||
/// composite is a fresh `Image` asset per build (unlike an `asset_server.load` path, which dedups by
|
||
/// path), so without this cache every player would re-composite and break material dedup downstream.
|
||
#[derive(Resource, Default)]
|
||
struct SkinComposites(benilla_assets::SpatialCache<SkinKey, Handle<Image>>);
|
||
|
||
/// The appearance fields that determine a composited body skin (decision 0044): race/sex pick the
|
||
/// CharSections rows; skin/face/facialHair/hairStyle/hairColor pick the base + overlay variations;
|
||
/// `equip` (decision 0074) the worn armor display ids whose region textures paint over it, by
|
||
/// bodyslot−2 — so each distinct dressed look composites + uploads once.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub(super) struct SkinKey {
|
||
pub(super) race: u8,
|
||
pub(super) sex: u8,
|
||
pub(super) skin: u8,
|
||
pub(super) face: u8,
|
||
pub(super) facial_hair: u8,
|
||
pub(super) hair_style: u8,
|
||
pub(super) hair_color: u8,
|
||
pub(super) equip: [u32; 8],
|
||
/// The wearer's guild tabard (decision 1704) — part of the key, not of `equip`, because two
|
||
/// guilds' members wear the *same* tabard display and must not share one atlas. Without it the
|
||
/// first guild to composite would lend its crest to every other guild on screen.
|
||
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
|
||
/// The tabard designer's preview flag (1977): the emblem paints over an empty tabard slot.
|
||
pub(super) tabard_preview: bool,
|
||
}
|
||
|
||
/// Marks a net entity whose visual (model children or fallback cube) has been attached, so the attach
|
||
/// system processes each entity exactly once. `pub(crate)`: the `waterfx` capture rig pre-marks its
|
||
/// dummy unit so it never receives the fallback cube (which would occlude the foam under test).
|
||
#[derive(Component)]
|
||
pub(crate) struct VisualAttached;
|
||
|
||
/// The per-frame entity-visuals pipeline (resolve → build → attach → held → refresh) as one set, so
|
||
/// upstream writers can order themselves before the whole chain — the animation driver's
|
||
/// [`crate::creature_anim::VisualSheath`] must land before [`resolve_equipment`] reads it, or a
|
||
/// sheath transition double-swaps the weapon placement for a frame (the "flash").
|
||
#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||
pub(crate) struct EntityVisualsSet;
|
||
|
||
/// [`update_display_models`]' ordering handle: anything that must **create** a display-cache entry
|
||
/// in time for this frame's build orders before it.
|
||
///
|
||
/// It exists for one caller, the `fxview` fixture's driver — which registers itself against this
|
||
/// from `capture` rather than being listed in the chain above, because a fixture's driver belongs
|
||
/// with its fixture and gameplay may not name the harness (decisions 1173/1174). Same shape as the
|
||
/// `waterfx` fixture's registration against the engine's `WaterFoamSet`.
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub(crate) struct DisplayBuildSet;
|
||
|
||
/// Drop every display/material dedup on a cross-map transition (`world_map::MapChange` — see its
|
||
/// doc for why a clear is always safe mid-session). These caches are get-or-insert at every use
|
||
/// site, so a cleared entry rebuilds on the next spawn that wants it; without this, every display
|
||
/// id, material key, and composited skin ever seen stayed resident for the life of the process.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn evict_display_caches(
|
||
mut changes: MessageReader<benilla_world::world_map::MapChange>,
|
||
mut composites: ResMut<SkinComposites>,
|
||
mut fx: ResMut<spell_fx::SpellFx>,
|
||
creatures: Option<ResMut<Creatures>>,
|
||
gos: Option<ResMut<GameObjects>>,
|
||
items: Option<ResMut<equipment::ItemDisplays>>,
|
||
glows: Option<ResMut<ItemGlows>>,
|
||
// The bone-pile bodies (decision 1706) — 16 at most, but they hold M2 handles like any other
|
||
// display cache and must die with the map for the same reason (the teleport asset leak).
|
||
mut bones: ResMut<corpse::BonesModels>,
|
||
) {
|
||
if changes.is_empty() {
|
||
return;
|
||
}
|
||
changes.clear();
|
||
info!(
|
||
"display caches evicted: {} creature / {} go / {} item / {} fx / {} glow models, {} skins",
|
||
creatures.as_ref().map_or(0, |c| c.models.len()),
|
||
gos.as_ref().map_or(0, |g| g.models.len()),
|
||
items.as_ref().map_or(0, |i| i.models.len()),
|
||
fx.models.len(),
|
||
glows.as_ref().map_or(0, |g| g.models.len()),
|
||
composites.0.len(),
|
||
);
|
||
composites.0.clear();
|
||
fx.models.clear();
|
||
if let Some(mut c) = creatures {
|
||
c.models.clear();
|
||
}
|
||
if let Some(mut g) = gos {
|
||
g.models.clear();
|
||
}
|
||
if let Some(mut i) = items {
|
||
i.models.clear();
|
||
}
|
||
if let Some(mut g) = glows {
|
||
g.models.clear();
|
||
}
|
||
bones.0.clear();
|
||
}
|
||
|
||
/// Expire the streamed-entity dedups by **distance** (decision 0793) — the within-map half of
|
||
/// [`evict_display_caches`]. The composited skins are the notable half: each distinct dressed look is
|
||
/// its own 256² `Image` (decision 0044), so a session that meets a lot of players accumulates
|
||
/// uploads no map change ever reaches. The display-id → model caches (`Creatures`/`GameObjects`/
|
||
/// `ItemDisplays`/`SpellFx`) are deliberately *not* swept: they key on ids, not places, and hold M2
|
||
/// assets whose count is bounded by the catalogs rather than by where you have been.
|
||
fn scope_entity_art(
|
||
mut scope: benilla_world::art_scope::ArtScope,
|
||
mut composites: ResMut<SkinComposites>,
|
||
) {
|
||
scope.apply(&mut composites.0, benilla_world::art_scope::ArtSlot::Skins);
|
||
}
|
||
|
||
/// **The armed idle's authored CAaBox for a built body**, model space — recorded by
|
||
/// [`attach_entity_visuals`] where the display model is read, and restated onto
|
||
/// [`WorldUnit::bound`](benilla_world::world_unit::WorldUnit::bound) by [`publish_world_units`].
|
||
///
|
||
/// Split from the field it feeds for the same reason `CollisionHeight` is: the attach knows the
|
||
/// number, the reconciler owns the component, and one writer per component is decision 0025. Absent
|
||
/// until a body's model resolves — and on a body that never gets one, absent for good.
|
||
#[derive(Component, Clone, Copy)]
|
||
pub(crate) struct ModelBound(pub(crate) bevy::camera::primitives::Aabb);
|
||
|
||
/// Everything [`publish_world_units`] reads to restate one body: its wire record, the two
|
||
/// game-side components whose numbers it folds in, whether its `Visibility` is the transport
|
||
/// tick's, and what it currently says.
|
||
type WireBody = (
|
||
Entity,
|
||
&'static NetEntity,
|
||
Option<&'static collision_height::CollisionHeight>,
|
||
Option<&'static ModelBound>,
|
||
Has<crate::transport::TransportAnchor>,
|
||
Option<&'static benilla_world::world_unit::WorldUnit>,
|
||
);
|
||
|
||
/// One viewer-reconcile row: the entity, whether it is the embodied self, whether it is marked.
|
||
type ViewerRow = (
|
||
Entity,
|
||
Has<crate::net::Embodied>,
|
||
Has<benilla_world::world_unit::ViewerUnit>,
|
||
);
|
||
|
||
/// Only entities that can be a viewer at all — one of the two markers present.
|
||
type ViewerCandidate = Or<(
|
||
With<crate::net::Embodied>,
|
||
With<benilla_world::world_unit::ViewerUnit>,
|
||
)>;
|
||
|
||
/// The edges on which a body's [`WorldUnit`](benilla_world::world_unit::WorldUnit) restatement can
|
||
/// move — [`publish_world_units`]' query filter (the anchor's removal is read separately).
|
||
type WireBodyMoved = Or<(
|
||
Changed<NetEntity>,
|
||
Changed<collision_height::CollisionHeight>,
|
||
Changed<ModelBound>,
|
||
Added<crate::transport::TransportAnchor>,
|
||
)>;
|
||
|
||
/// **Restate every wire body as a [`WorldUnit`](benilla_world::world_unit::WorldUnit)** — the game's half
|
||
/// of the unit inversion (see that module).
|
||
///
|
||
/// One reconciler rather than a line at each of the dozen sites that spawn a `NetEntity`: a
|
||
/// spawn path that forgets the marker is a body the world cannot see — no ground shade, no room
|
||
/// claim, no foam — and that is exactly the kind of omission nobody notices until a screenshot.
|
||
/// Runs between the wire drain and the rest of the frame, so a unit that arrived this frame is
|
||
/// visible to the world this frame.
|
||
fn publish_world_units(
|
||
mut commands: Commands,
|
||
// Only bodies whose inputs moved this frame: the restatement is a pure function of these
|
||
// four, and walking every resident body to compare equal answers was ~1.3 k struct builds
|
||
// a frame at the Stormwind pin (decision 1979's floor). A body leaving its transport loses
|
||
// the anchor without a `Changed` — that edge is read off `unanchored`.
|
||
bodies: Query<WireBody, WireBodyMoved>,
|
||
all_bodies: Query<WireBody>,
|
||
mut unanchored: RemovedComponents<crate::transport::TransportAnchor>,
|
||
// Only entities that can be a viewer at all: the reconcile below needs one of the two
|
||
// present, and an unfiltered query walked every entity in the world each frame.
|
||
viewers: Query<ViewerRow, ViewerCandidate>,
|
||
) {
|
||
let freed: Vec<Entity> = unanchored.read().collect();
|
||
let due = bodies
|
||
.iter()
|
||
.chain(freed.iter().filter_map(|&e| all_bodies.get(e).ok()));
|
||
for (entity, net, height, bound, anchored, current) in due {
|
||
let want = benilla_world::world_unit::WorldUnit {
|
||
// The wire kind is answered HERE and never handed over (1177): the engine asks "does
|
||
// this body displace water", and translating its own vocabulary into that answer is
|
||
// the game's job. A live body wades; a GameObject, a dynamic-object spell anchor or
|
||
// anything else standing in a lake makes no ripple.
|
||
wades: matches!(net.kind, EntityKind::Unit | EntityKind::Player),
|
||
scale: net.scale,
|
||
// `unwrap_or_default`, NOT zero: `CollisionHeight`'s Default is the client's own
|
||
// ctor value, and its doc says why — at 0.0 "every depth line collapses and the unit
|
||
// swims on dry land". A body whose display has not resolved yet must read as a
|
||
// default-sized body, which is what the foam site did before this component existed.
|
||
height: height.copied().unwrap_or_default().0,
|
||
// The box the exterior cull may elect this body by (decision 1270). **A transport
|
||
// answers `None` on purpose**: `transport::tick_transports` writes that root's
|
||
// `Visibility` every frame off its own timetable, and a second writer there is the
|
||
// fight decision 0025 exists to prevent — so the world is told not to decide, rather
|
||
// than told a box and left to race.
|
||
//
|
||
// Every other body is elected from its FIRST frame, with a degenerate box at its own
|
||
// origin until its model resolves. Waiting for the extent looks conservative and is
|
||
// not: the bound reaches this reconciler only after `attach_entity_visuals` has already
|
||
// spawned the visual, so "no box yet ⇒ admit" drew every streaming mob for one whole
|
||
// frame through a sealed ceiling — and a cavern runs slowly enough that one frame is
|
||
// most of a second. The origin is the server's own position, exact from the start.
|
||
bound: (!anchored).then(|| {
|
||
bound.map_or_else(
|
||
|| bevy::camera::primitives::Aabb::from_min_max(Vec3::ZERO, Vec3::ZERO),
|
||
|b| b.0,
|
||
)
|
||
}),
|
||
};
|
||
// Only write on a real change: the component is change-detected downstream, and a
|
||
// per-frame rewrite would mark every body dirty for every reader every frame.
|
||
let same = current.is_some_and(|c| {
|
||
c.wades == want.wades
|
||
&& c.scale == want.scale
|
||
&& c.height == want.height
|
||
&& c.bound == want.bound
|
||
});
|
||
if !same {
|
||
commands.entity(entity).insert(want);
|
||
}
|
||
}
|
||
// The viewer marker is reconciled independently of `NetEntity`: the self entity exists before
|
||
// its wire record does, and a `/logout` takes the record away first.
|
||
for (entity, is_self, marked) in &viewers {
|
||
match (is_self, marked) {
|
||
(true, false) => {
|
||
commands
|
||
.entity(entity)
|
||
.insert(benilla_world::world_unit::ViewerUnit);
|
||
}
|
||
(false, true) => {
|
||
commands
|
||
.entity(entity)
|
||
.remove::<benilla_world::world_unit::ViewerUnit>();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The streamed-entity subsystem: builds the shared cube assets + display catalogs at startup, then
|
||
/// each frame resolves/builds display models and attaches a visual to every net entity.
|
||
pub(crate) struct EntitiesPlugin;
|
||
|
||
impl Plugin for EntitiesPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.add_systems(
|
||
Update,
|
||
publish_world_units
|
||
.after(benilla_world::schedule::WorldStage::Net)
|
||
.before(benilla_world::schedule::WorldStage::Input),
|
||
)
|
||
.init_resource::<SkinComposites>()
|
||
.init_resource::<attach::MergedFormsCache>()
|
||
// The 16 bone-pile body models (decision 1706), keyed (race, sex) rather than by a
|
||
// display id — a skeleton has no CreatureDisplayInfo row.
|
||
.init_resource::<corpse::BonesModels>()
|
||
.init_resource::<spell_fx::SpellFx>()
|
||
.init_resource::<spell_fx::FxTintAnims>()
|
||
// The per-caster pending-projectile queues (the client's `unit+0xac` lists).
|
||
.init_resource::<missile::PendingMissiles>()
|
||
// The projectile flight-loop edges (`crate::sound::missile` consumes them).
|
||
.add_message::<MissileSound>()
|
||
// The cast router's dest one-shot orders (`dest_fx`, decision 0797).
|
||
.add_message::<dest_fx::GroundBurst>()
|
||
// A live display-id swap's rebuild edge — consumed by the morph-latch replay
|
||
// (`crate::creature_anim`), the reference's `0x60abe0` impact-kit replay tail.
|
||
.add_message::<live_display::DisplaySwapped>()
|
||
.add_systems(Startup, setup_entities.after(AssetSet::Open))
|
||
// The map-scope teardown (`world_map::MapChange`): drop every display/material dedup
|
||
// so a map's assets actually die with it — the #bugs teleport leak.
|
||
.add_systems(Update, (evict_display_caches, scope_entity_art))
|
||
// Every streamed unit's collision height, the frame after `apply_net_updates` spawns it
|
||
// (that stage's Commands are what create the entity, so this cannot be earlier). Its
|
||
// consumers all read `Option<&CollisionHeight>` against the ctor default, so a unit's
|
||
// first frame simply uses that — see `CollisionHeight`.
|
||
.add_systems(Update, stamp_collision_heights.after(WorldStage::Net))
|
||
// Resolve/build display models before attaching, so a model is ready the frame attach wants it.
|
||
// **After `WorldStage::Net`**: `apply_net_updates` spawns the entities (via Commands), so
|
||
// these must run *after* that stage — with the sync point it forces — or they'd attach an
|
||
// entity the same frame its display id is still unresolved (`dm == None`) and lock in a cube.
|
||
// The net stage was previously unordered (`schedule.rs` chained only Input→Stream→Present), so
|
||
// this held only by luck of Bevy's auto-sort; pinning it makes display resolution deterministic.
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
// Equipment resolution first (decisions 0072/0074): it *creates* item
|
||
// DisplayModel entries, which update_display_models then builds the same frame.
|
||
// The corpse's dress rides beside it (decision 1706, one nested unordered
|
||
// element — the outer tuple is at `chain()`'s 20-element ceiling): a disjoint
|
||
// entity set, the same item-model cache, the same must-be-before-the-build.
|
||
(resolve_equipment, resolve_corpse_equipment),
|
||
// Spell-effect resolution likewise creates its path-keyed entries (0099 P3),
|
||
// as does the missile launcher (P4) — both feed the same SpellFx model cache.
|
||
resolve_spell_fx,
|
||
move_missiles,
|
||
spawn_missiles,
|
||
// The dest-anchored lane (0797): the dynobj arm + the GO burst + the shard
|
||
// tick all create cache entries too — before the same-frame build below.
|
||
// One nested (unordered) element: three independent producers, and the
|
||
// outer tuple is at `chain()`'s 20-element ceiling.
|
||
(arm_ground_effects, spawn_ground_bursts, tick_shard_emitters),
|
||
update_display_models.in_set(DisplayBuildSet),
|
||
// The char-create preview (decision 0423): assemble the selected look's parts from
|
||
// the freshly-built display model, for the create booth to bake. After
|
||
// `update_display_models` (its want-list built the body) — server-less, at char select.
|
||
build_glue_preview,
|
||
// …and the select screen's PET beside it: an ordinary creature display, assembled
|
||
// off the same freshly-built cache, on its own latch so a slow pet model never
|
||
// holds the character back.
|
||
build_glue_pet,
|
||
// The dressing room's preview (decision 1060): the same tuple-driven assembly,
|
||
// for the item nobody in the world is wearing. Beside the glue one — same
|
||
// dependency (the display cache built by `update_display_models` above), same
|
||
// retry-until-ready latch.
|
||
build_dressup_preview,
|
||
attach_entity_visuals,
|
||
// WMO-gameobject doodad props (the ship's sails): resolve the MODD list the
|
||
// frame after the WMO visual attaches, then spawn each prop as its M2 lands
|
||
// (parented under the entity — they sail with the boat).
|
||
resolve_wmo_gameobject_props,
|
||
spawn_wmo_gameobject_props,
|
||
attach_held_items,
|
||
// One nested (unordered) element — the two passes that hang effect models on
|
||
// a unit: its spell-kit instances, and the glows its held items' `ItemVisuals`
|
||
// ids name (0805 — after `attach_held_items`, which makes the item roots).
|
||
// Independent of each other, both before the tint tick below; the outer tuple
|
||
// is at `chain()`'s 20-element ceiling.
|
||
(attach_item_glows, attach_spell_fx),
|
||
// One nested (unordered) element — two independent free-model attach
|
||
// passes, plus the world-plant tender (0850: sweeps orphaned plants,
|
||
// re-plants root-aura ones on owner displacement); the outer tuple is at
|
||
// `chain()`'s 20-element ceiling.
|
||
(
|
||
attach_missile_models,
|
||
attach_ground_fx_models,
|
||
spell_fx::tend_world_plants,
|
||
),
|
||
// Tick the live per-instance tint clones AFTER the attach passes registered
|
||
// them, so a clone's first drawn frame is already on its own clock.
|
||
spell_fx::tick_fx_tint,
|
||
// Fire each live instance's crossed event keyframes (decision 0304) — after
|
||
// attach so a just-spawned instance's head window [0, cur] fires this frame.
|
||
// Beside it (one nested, unordered element — the outer tuple is at `chain()`'s
|
||
// 20-element ceiling): the effect-model completion callbacks, which advance an
|
||
// instance birth → Hold and arm the reap's Decay. Independent of the event
|
||
// scan; both only read a player Bevy already advanced in `PreUpdate`, and both
|
||
// want to run after the attach passes so a fresh instance is covered at once.
|
||
(spell_fx::fire_fx_anim_events, spell_fx::advance_fx_anim),
|
||
// One nested (unordered) element — the outer tuple is at `chain()`'s
|
||
// 20-element ceiling. Two independent post-attach touch-ups on a standing
|
||
// visual: a gear change re-dresses it in place — a re-composited atlas on the
|
||
// same parts, the equipment geosets re-selected, every attachment left alone
|
||
// (decision 0835, the reference's own shape) — and a freshly attached corpse
|
||
// arms its settled Dead/Drowned pose (decision 1706).
|
||
(attach::redress_player_looks, corpse::pose_corpses),
|
||
// A mount transition does the same, and for the same reason (B199): the
|
||
// field diff **re-seats** the standing rig — onto the mount's attachment-0
|
||
// joint, or back onto its own frame — where it used to tear the whole rider
|
||
// down and let attach rebuild it. The reference re-parents the body model
|
||
// (`0x712f70`/`0x713020`); it never re-creates it.
|
||
reseat_mounts,
|
||
// A live display-id / scale change (decision 0695): the display swap is the
|
||
// same teardown-and-rebuild; the scale change arms the reference's 2 s ease.
|
||
// The rig heal (decision 0863) rides the same nest (Bevy's 20-tuple ceiling):
|
||
// a unit denied a palette rig at attach rebuilds — the same teardown — once
|
||
// the table has headroom again; never a permanent statue.
|
||
(
|
||
refresh_live_display,
|
||
tick_scale_ease,
|
||
live_display::heal_rig_starved,
|
||
)
|
||
.chain(),
|
||
)
|
||
.chain()
|
||
.in_set(EntityVisualsSet)
|
||
.after(WorldStage::Net),
|
||
)
|
||
// The chain-beam spawner (0955): in the visuals set, so the cast router — which is
|
||
// `.before(EntityVisualsSet)` — has already emitted this frame's beam plays, and the
|
||
// net stage's Commands (the hop arrays) have already been applied.
|
||
.add_systems(
|
||
Update,
|
||
spawn_chain_beams
|
||
.in_set(EntityVisualsSet)
|
||
.after(WorldStage::Net),
|
||
)
|
||
// …and its per-frame geometry, beside the ribbon trails and for the same reason: a
|
||
// beam's endpoints are attachment joints, so it must sample the pose the billboard
|
||
// palette and the rig finalizer just wrote.
|
||
//
|
||
// `.after(begin_effect_frame)` is the load-bearing one, and its omission is what made
|
||
// the whole beam invisible on first ship (B161): the clear carries an extra
|
||
// `.after(face_billboards)` the beam sim does not, so without this edge the sim
|
||
// becomes runnable a step EARLIER and its vertices are wiped before extract — every
|
||
// frame, silently, with the arithmetic perfect. Every writer into the shared stream
|
||
// declares this; the tripwire in `commit` now refuses a write that precedes the clear.
|
||
.add_systems(
|
||
PostUpdate,
|
||
simulate_chain_beams
|
||
.in_set(benilla_world::billboard::BillboardPlace)
|
||
.after(benilla_world::billboard::billboard_joint_palette)
|
||
.after(benilla_world::rig_anim::finalize_rig_worlds)
|
||
.after(benilla_world::particles::buffer::begin_effect_frame),
|
||
)
|
||
// Terrain conform (decisions 0482/0486, the byte law of wow-re `terrain-tilt.md`):
|
||
// reads each flagged unit's Update-final transform, writes its conform node's
|
||
// local rotation — before propagation so the composite's globals carry this
|
||
// frame's stance.
|
||
.add_systems(
|
||
PostUpdate,
|
||
conform::conform_units.before(bevy::transform::TransformSystems::Propagate),
|
||
)
|
||
// The unit lane's material-alpha compose: after every steady-state owner of the
|
||
// render-alpha field (the interior classifier, the visibility authority's own
|
||
// fade/effect writes), before the self-avatar feather that is allowed to override it.
|
||
.add_systems(
|
||
Update,
|
||
apply_unit_mat_alpha
|
||
.after(benilla_world::interior::classify_entity_interior)
|
||
.after(benilla_world::model_render::ModelVisSet)
|
||
.after(apply_render_fade)
|
||
.before(crate::player::apply_self_model_fade),
|
||
)
|
||
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
|
||
// The drain installs/removes this frame's nodes; the author then owns the render alpha
|
||
// of every part under a translucent unit — after the three steady-state alpha authors
|
||
// above so its override lands, before the self feather which folds the factor in itself.
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
// The display's own base alpha first (the reference's DISPLAYID-watcher leg
|
||
// of the same recompute, `base-render-alpha.md` §5 — no aura needed), so a
|
||
// same-frame aura edge retargets from the already-updated base.
|
||
crate::aura_visual::refresh_base_alpha,
|
||
crate::aura_visual::drain_aura_procs,
|
||
// The tint publish only needs the drain ahead of it (it writes a resource, not
|
||
// the alpha channel), so it rides the same chain rather than earning its own.
|
||
(
|
||
crate::aura_visual::apply_aura_alpha,
|
||
crate::aura_visual::apply_aura_tint,
|
||
),
|
||
)
|
||
.chain()
|
||
.after(apply_unit_mat_alpha)
|
||
.before(crate::player::apply_self_model_fade),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Startup: build the shared cube mesh/materials (always), and load the creature/GameObject display
|
||
/// catalogs off the shared chain (when present). On a catalog failure the resource is simply absent and
|
||
/// those entities stay cubes (attach treats both as optional).
|
||
fn setup_entities(
|
||
mut commands: Commands,
|
||
mut meshes: ResMut<Assets<Mesh>>,
|
||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||
world_assets: Option<ResMut<WorldAssets>>,
|
||
) {
|
||
// A person-sized box (2 wide, 4 tall, 2 deep); origin is centered so we lift it by half-height.
|
||
let mesh = meshes.add(Cuboid::new(2.0, 4.0, 2.0));
|
||
// A player without a body model uses a slimmer, shorter block.
|
||
let player_mesh = meshes.add(Cuboid::new(0.8, 2.0, 0.8));
|
||
let mut mat = |r, g, b| {
|
||
materials.add(StandardMaterial {
|
||
base_color: Color::linear_rgb(r, g, b), // GAMMA LANE (0161): raw bytes
|
||
perceptual_roughness: 0.7,
|
||
..default()
|
||
})
|
||
};
|
||
commands.insert_resource(CubeAssets {
|
||
mesh,
|
||
player_mesh,
|
||
player_mat: mat(0.1, 0.85, 0.9), // cyan: players
|
||
npc_mat: mat(0.9, 0.25, 0.2), // red: NPCs
|
||
});
|
||
|
||
let Some(world_assets) = world_assets else {
|
||
return;
|
||
};
|
||
let mut chain = world_assets.chain.lock_recover();
|
||
match load_creature_catalog(&mut chain) {
|
||
Ok(catalog) => {
|
||
info!(
|
||
"creature catalog: {} display entries, {} character-model NPC appearances",
|
||
catalog.len(),
|
||
catalog.extra_len()
|
||
);
|
||
commands.insert_resource(Creatures {
|
||
catalog,
|
||
models: HashMap::new(),
|
||
});
|
||
}
|
||
Err(e) => warn!("creature catalog unavailable, NPCs stay cubes: {e:#}"),
|
||
}
|
||
match load_gameobject_catalog(&mut chain) {
|
||
Ok(catalog) => {
|
||
info!("gameobject catalog: {} display entries", catalog.len());
|
||
commands.insert_resource(GameObjects {
|
||
catalog,
|
||
models: HashMap::new(),
|
||
});
|
||
}
|
||
Err(e) => warn!("gameobject catalog unavailable, GameObjects stay cubes: {e:#}"),
|
||
}
|
||
match benilla_formats::load_lock_catalog(&mut chain) {
|
||
Ok(catalog) => {
|
||
info!("lock catalog: {} locks", catalog.len());
|
||
commands.insert_resource(crate::go_templates::Locks(catalog));
|
||
}
|
||
// No lock data ⇒ every GameObject reads as lockless (right-click sends USE) — degraded, not broken.
|
||
Err(e) => warn!("lock catalog unavailable, GameObjects treated as lockless: {e:#}"),
|
||
}
|
||
match benilla_formats::load_lock_type_catalog(&mut chain) {
|
||
Ok(catalog) => {
|
||
info!(
|
||
"lock-type catalog: {} cursor-bearing lock kinds",
|
||
catalog.len()
|
||
);
|
||
commands.insert_resource(crate::go_templates::LockTypes(catalog));
|
||
}
|
||
// No LockType data ⇒ lock-bearing GOs fall back to the Interact gear cursor — degraded, not broken.
|
||
Err(e) => warn!("lock-type catalog unavailable, GO cursors fall back to Interact: {e:#}"),
|
||
}
|
||
match (
|
||
benilla_formats::load_pet_personalities(&mut chain),
|
||
benilla_formats::load_pet_loyalty_names(&mut chain),
|
||
) {
|
||
(Ok(personalities), Ok(loyalty)) => {
|
||
info!(
|
||
"pet stat tables: {} personalities, {} loyalty levels",
|
||
personalities.len(),
|
||
loyalty.len()
|
||
);
|
||
commands.insert_resource(crate::ui_pet_stats::PetStatTables {
|
||
personalities,
|
||
loyalty,
|
||
});
|
||
}
|
||
// No pet tables ⇒ happiness answers its own gate-failure nil (so the icon hides) and the
|
||
// loyalty line is blank; the rest of the paper doll — XP, training points — reads straight
|
||
// off the descriptor and is unaffected. Degraded, not wrong.
|
||
(p, l) => warn!(
|
||
"pet stat tables unavailable, happiness/loyalty stay blank: {:#}",
|
||
p.err().or(l.err()).expect("at least one of the two failed")
|
||
),
|
||
}
|
||
// The family pair (decision 1062) — its own resource, so it degrades independently of the
|
||
// happiness tables above rather than taking them down with it.
|
||
match (
|
||
benilla_formats::load_creature_families(&mut chain),
|
||
benilla_formats::load_pet_food_names(&mut chain),
|
||
) {
|
||
(Ok(families), Ok(foods)) => {
|
||
info!(
|
||
"pet family tables: {} families, {} food types",
|
||
families.len(),
|
||
foods.len()
|
||
);
|
||
commands.insert_resource(crate::ui_pet_stats::PetFamilyTables { families, foods });
|
||
}
|
||
// No family tables ⇒ `UnitCreatureFamily("pet")` answers nil, which the reference's own
|
||
// guard turns into a BLANK level line on the pet page (ref `PetPaperDollFrame.lua:68-70`),
|
||
// and the diet tooltip shows nothing at all. That is exactly the state 1057 shipped in —
|
||
// degraded to the faithful fallback, not wrong.
|
||
(f, p) => warn!(
|
||
"pet family tables unavailable, the pet's level line stays blank: {:#}",
|
||
f.err().or(p.err()).expect("at least one of the two failed")
|
||
),
|
||
}
|
||
match CharacterGeosets::load(&mut chain) {
|
||
Ok(geosets) => commands.insert_resource(Characters(geosets)),
|
||
Err(e) => warn!("character geosets unavailable, players show every geoset: {e:#}"),
|
||
}
|
||
match CharSections::load(&mut chain) {
|
||
Ok(sections) => commands.insert_resource(SkinSections(sections)),
|
||
Err(e) => warn!("char sections unavailable, player bodies stay untextured: {e:#}"),
|
||
}
|
||
match CharCreateCatalog::load(&mut chain) {
|
||
Ok(catalog) => commands.insert_resource(CharCreate(catalog)),
|
||
Err(e) => warn!("char-create catalog unavailable, the create screen is disabled: {e:#}"),
|
||
}
|
||
match load_item_display_catalog(&mut chain) {
|
||
Ok(catalog) => {
|
||
info!("item display catalog: {} entries", catalog.len());
|
||
commands.insert_resource(ItemDisplays {
|
||
catalog,
|
||
models: HashMap::new(),
|
||
});
|
||
}
|
||
Err(e) => warn!("item displays unavailable, units hold nothing: {e:#}"),
|
||
}
|
||
// The glow chain's ItemVisuals join (decision 0805). Absent, weapons draw unadorned.
|
||
match benilla_formats::load_item_visual_catalog(&mut chain) {
|
||
Ok(visuals) => {
|
||
info!("item visual catalog: {} glow rows", visuals.len());
|
||
commands.insert_resource(ItemGlows::new(visuals));
|
||
}
|
||
Err(e) => warn!("item visuals unavailable, weapons never glow: {e:#}"),
|
||
}
|
||
// `SpellItemEnchantment`'s two consumer columns — the enchant half of that glow chain, and
|
||
// the tooltip's enchant line (decision 0915). One load, one resource, both lanes.
|
||
match benilla_formats::load_enchant_catalog(&mut chain) {
|
||
Ok(enchants) => {
|
||
info!(
|
||
"enchant catalog: {} named, {} carrying a glow",
|
||
enchants.name_count(),
|
||
enchants.visual_count()
|
||
);
|
||
commands.insert_resource(crate::items::Enchants(enchants));
|
||
}
|
||
Err(e) => warn!("enchants unavailable: no enchant glow, no enchant line: {e:#}"),
|
||
}
|
||
// `ItemRandomProperties` — the random-suffix roll's name and its enchant slots 2..6 (decision
|
||
// 1547). Absent, a rolled item reads by its base name and shows no suffix lines.
|
||
match benilla_formats::load_random_property_catalog(&mut chain) {
|
||
Ok(props) => {
|
||
info!("random-property catalog: {} suffix rows", props.len());
|
||
commands.insert_resource(crate::items::RandomProperties(props));
|
||
}
|
||
Err(e) => {
|
||
warn!("random properties unavailable: no item name suffix, no suffix line: {e:#}")
|
||
}
|
||
}
|
||
match benilla_formats::load_durability_tables(&mut chain) {
|
||
Ok(tables) => commands.insert_resource(crate::ui_merchant::RepairTables(tables)),
|
||
Err(e) => warn!("durability tables unavailable, repair costs show 0: {e:#}"),
|
||
}
|
||
match benilla_formats::load_bank_bag_slot_prices(&mut chain) {
|
||
Ok(prices) => commands.insert_resource(crate::ui_bank::BankPrices(prices)),
|
||
Err(e) => warn!("bank bag slot prices unavailable, the purchase row shows 0: {e:#}"),
|
||
}
|
||
match benilla_formats::load_stable_slot_prices(&mut chain) {
|
||
Ok(prices) => commands.insert_resource(crate::ui_stable::StableSlotPrices(prices)),
|
||
Err(e) => warn!("stable slot prices unavailable, the purchase row shows 0: {e:#}"),
|
||
}
|
||
match benilla_formats::load_stationery_catalog(&mut chain) {
|
||
Ok(catalog) => commands.insert_resource(crate::ui_mail::Stationery(catalog)),
|
||
Err(e) => warn!("stationery catalog unavailable, mail uses the default backdrop: {e:#}"),
|
||
}
|
||
match benilla_formats::load_page_text_material_catalog(&mut chain) {
|
||
Ok(catalog) => commands.insert_resource(crate::ui_item_text::PageMaterials(catalog)),
|
||
Err(e) => warn!("page text materials unavailable, books read on parchment: {e:#}"),
|
||
}
|
||
}
|
||
|
||
/// For every display id active among the net entities: ensure its [`DisplayModel`] exists (resolve the
|
||
/// catalog + request the model handle), and once the handle has loaded, build its spawn parts (the
|
||
/// per-submesh material, with creature skin slots filled from the display's variations).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn update_display_models(
|
||
// `ObjectStore` rides along for the corpses: which cache holds a corpse's model is a
|
||
// descriptor question (`CORPSE_FLAG_BONES`), not a display-id one — decision 1706.
|
||
entities: Query<(&NetEntity, Option<&crate::net::ObjectStore>)>,
|
||
mut creatures: Option<ResMut<Creatures>>,
|
||
mut gameobjects: Option<ResMut<GameObjects>>,
|
||
mut held: Option<ResMut<ItemDisplays>>,
|
||
mut spell_fx: Option<ResMut<spell_fx::SpellFx>>,
|
||
mut glows: Option<ResMut<ItemGlows>>,
|
||
model_assets: (Res<Assets<M2Model>>, Res<Assets<WmoModel>>),
|
||
mut forms: ResMut<benilla_world::model_forms::ModelForms>,
|
||
asset_server: Res<AssetServer>,
|
||
mut mats: benilla_world::model_render::M2BatchMaterials,
|
||
// The bone-pile display cache + the ChrRaces fileStrings its paths are built from (1706).
|
||
mut bones: ResMut<corpse::BonesModels>,
|
||
// The glue-preview want (decisions 0423 + 0465): the glue screens' look's body displayId, so
|
||
// its model builds with no wire entity (the screens run pre-world, where no NetEntity carries it).
|
||
glue_preview: Option<Res<crate::portrait::GluePreview>>,
|
||
char_create: Option<Res<CharCreate>>,
|
||
// The stable pane's want (wow-re `stable-master-window.md` §7.1): a stabled pet is a row in the
|
||
// server's character-pet cache and a `CreatureDisplayInfo` id — there is no wire entity to
|
||
// carry it, so the booth's own selection is the want.
|
||
stable_booth: Option<Res<crate::portrait::StableBooth>>,
|
||
) {
|
||
if !mats.ready() {
|
||
return; // no lighting yet → no materials to build
|
||
}
|
||
let (m2s, wmos) = (&model_assets.0, &model_assets.1);
|
||
|
||
// The (kind, display) pairs live in the world this frame — cheap to collect.
|
||
let mut actives: Vec<(EntityKind, u32)> = entities
|
||
.iter()
|
||
// A **bone pile** is skipped here (decision 1706): it carries a perfectly valid display id
|
||
// — the server's corpse→bones conversion copies the field verbatim — and the client
|
||
// deliberately ignores it, so requesting the body it names would load and build a
|
||
// character model nothing will ever render. Its own model is collected below.
|
||
.filter(|(e, store)| {
|
||
!matches!(
|
||
corpse::corpse_model(e, *store),
|
||
Some(corpse::CorpseModel::Bones(..))
|
||
)
|
||
})
|
||
.filter_map(|(e, _)| e.display_id.map(|d| (e.kind, d)))
|
||
.collect();
|
||
// The bone piles want the OTHER cache (decision 1706): their model is keyed (race, sex), so
|
||
// they are collected separately and never enter the display-id want list above — a bone pile's
|
||
// display id is a live field the client deliberately ignores, and building the body it names
|
||
// would load a model nothing renders. Their FLESH siblings need nothing here: a corpse's
|
||
// display id IS a `CreatureDisplayInfo` id, so the `Corpse` arm below resolves it exactly like
|
||
// a unit's.
|
||
let bones_wanted: Vec<(u8, u8)> = entities
|
||
.iter()
|
||
.filter_map(|(e, store)| match corpse::corpse_model(e, store) {
|
||
Some(corpse::CorpseModel::Bones(race, sex)) => Some((race, sex)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// The glue-preview body (decisions 0423 + 0465): a want beside the NetEntity scan, so the
|
||
// character on the glue stage has a model even though nothing streamed it in.
|
||
if let (Some(preview), Some(cc)) = (glue_preview.as_deref(), char_create.as_deref()) {
|
||
if let Some(look) = preview.look {
|
||
let (race, sex) = look.body();
|
||
if let Some(disp) = cc.0.body_display(race, sex) {
|
||
actives.push((EntityKind::Player, disp));
|
||
}
|
||
}
|
||
}
|
||
// The select screen's **pet** — the same kind of wantless want, one display further. It is an
|
||
// ordinary creature display (`Unit`, not `Player`): the enum's pet triple carries a
|
||
// `CreatureDisplayInfo` id, so it resolves down the plain creature chain, skins and all.
|
||
if let Some(preview) = glue_preview.as_deref() {
|
||
if let Some(pet) = preview.look.and_then(|l| l.pet()) {
|
||
actives.push((EntityKind::Unit, pet.display_id));
|
||
}
|
||
}
|
||
|
||
// The stable window's selected pet — the same wantless want, and the same `Unit` kind: the
|
||
// creature query answers with a `CreatureDisplayInfo` id, so it resolves down the plain
|
||
// creature chain, skins and all. Asked for whenever the pane names one, live pet or not; a
|
||
// summoned pet's display is already in the scan above, and a repeat is free (the cache is
|
||
// keyed by display).
|
||
if let Some(display) = stable_booth.as_deref().and_then(|b| b.display_id) {
|
||
actives.push((EntityKind::Unit, display));
|
||
}
|
||
|
||
for (kind, disp) in actives {
|
||
match kind {
|
||
// Players resolve their body model through the very same creature chain (decision 0041):
|
||
// displayId → CreatureDisplayInfo → CreatureModelData → a `Character\…\…\….mdx` body.
|
||
// …and so does a **corpse** that is not a bone pile (decision 1706): its
|
||
// `CORPSE_FIELD_DISPLAY_ID` is the dead player's own body display, which is the
|
||
// reference's own lookup at `0x5d6759`.
|
||
EntityKind::Unit | EntityKind::Player | EntityKind::Corpse => {
|
||
let Some(cr) = creatures.as_deref_mut() else {
|
||
continue;
|
||
};
|
||
if !cr.models.contains_key(&disp) {
|
||
let dm = new_creature_display(&cr.catalog, disp, &asset_server);
|
||
cr.models.insert(disp, dm);
|
||
}
|
||
if let Some(dm) = cr.models.get_mut(&disp) {
|
||
if dm.parts.is_none() {
|
||
build_parts(
|
||
dm,
|
||
m2s,
|
||
wmos,
|
||
&mut forms,
|
||
&asset_server,
|
||
&mut mats,
|
||
false, // gameobject: creatures — no hull collider, no bake variant
|
||
);
|
||
}
|
||
}
|
||
}
|
||
EntityKind::GameObject => {
|
||
let Some(go) = gameobjects.as_deref_mut() else {
|
||
continue;
|
||
};
|
||
if !go.models.contains_key(&disp) {
|
||
let dm = new_gameobject_display(&go.catalog, disp, &asset_server);
|
||
go.models.insert(disp, dm);
|
||
}
|
||
if let Some(dm) = go.models.get_mut(&disp) {
|
||
if dm.parts.is_none() {
|
||
build_parts(
|
||
dm,
|
||
m2s,
|
||
wmos,
|
||
&mut forms,
|
||
&asset_server,
|
||
&mut mats,
|
||
true, // gameobject: hull collider + the interior BAKE material variant
|
||
);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// The bone piles (decision 1706): request each wanted `(race, sex)` skeleton once, then build
|
||
// its parts as the M2 lands — the same two-step as every other display, on its own tiny cache.
|
||
// Character-creation data is the source of the race fileString the path is built from, so a
|
||
// session that could not load it simply shows no skeletons rather than guessing at names.
|
||
if let Some(cc) = char_create.as_deref() {
|
||
for key in bones_wanted {
|
||
corpse::ensure_bones_display(&mut bones, &cc.0, key, &asset_server);
|
||
if let Some(dm) = bones.0.get_mut(&key) {
|
||
if dm.parts.is_none() {
|
||
build_parts(
|
||
dm,
|
||
m2s,
|
||
wmos,
|
||
&mut forms,
|
||
&asset_server,
|
||
&mut mats,
|
||
false, // gameobject: a prop body — unit lighting, no hull collider
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Held-item displays (decision 0072): entries are created by `resolve_equipment`; build each
|
||
// one's parts once its M2 loads. Items are static meshes — no collider, unit lighting.
|
||
if let Some(held) = held.as_deref_mut() {
|
||
for dm in held.models.values_mut() {
|
||
if dm.parts.is_none() {
|
||
build_parts(
|
||
dm,
|
||
m2s,
|
||
wmos,
|
||
&mut forms,
|
||
&asset_server,
|
||
&mut mats,
|
||
false, // gameobject: held items — unit lighting, no collider
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Spell-effect displays (decision 0099 phase 3): entries are created by
|
||
// `spell_fx::resolve_spell_fx`; the same build, keyed by model path instead of display id.
|
||
if let Some(fx) = spell_fx.as_deref_mut() {
|
||
for dm in fx.models.values_mut() {
|
||
if dm.parts.is_none() {
|
||
build_parts(
|
||
dm,
|
||
m2s,
|
||
wmos,
|
||
&mut forms,
|
||
&asset_server,
|
||
&mut mats,
|
||
false, // gameobject: effects — unit lighting, no collider
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Item/enchant glow models (decision 0805): path-keyed like the effect cache above, entries
|
||
// created by `resolve_equipment`'s glow resolve.
|
||
if let Some(glows) = glows.as_deref_mut() {
|
||
for dm in glows.models.values_mut() {
|
||
if dm.parts.is_none() {
|
||
build_parts(
|
||
dm,
|
||
m2s,
|
||
wmos,
|
||
&mut forms,
|
||
&asset_server,
|
||
&mut mats,
|
||
false, // gameobject: effects — unit lighting, no collider
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Compose a unit part's **animated material alpha** into the render-alpha `MeshTag` field — the
|
||
/// unit-lane half of the verified per-batch combine `A = instanceAlpha × colourAlpha × weight`
|
||
/// (wow-re `m2-alpha-combine-cull.md`). The `A ≤ 0` *cull* is already the single `Visibility`
|
||
/// authority's (`debug_panel::apply_model_visibility` ANDs `mat_factor > 0`); this is the partial
|
||
/// factor, the dimming half, which only a `MeshTag` write can express.
|
||
///
|
||
/// A fourth alpha writer is exactly what decision 0066's protocol forbids, so this is not one: it
|
||
/// writes **only** the alpha field, through [`benilla_world::mesh_tag::with_alpha`] (the probe slot,
|
||
/// interior-fog bit, shade byte and highlight bit all ride through), it writes the sampled factor
|
||
/// **verbatim** rather than multiplying into what it finds — so re-running it is idempotent, unlike
|
||
/// a compounding read-modify-write — and it is ordered after the steady-state owner (the interior
|
||
/// classifier) so it re-asserts over that owner's whole-payload reclaim, exactly as
|
||
/// `entity_shade::update_ground_shade` does for the shade byte.
|
||
///
|
||
/// Two owners deliberately keep the channel instead: a live **appear/despawn fade**
|
||
/// (`RenderFade`/`PendingAppearFade` — excluded here, and `apply_render_fade` multiplies the factor
|
||
/// into its own ramp), and the self-avatar zoom feather, which runs after this and wins on the self
|
||
/// body while feathering (its own documented override).
|
||
#[allow(clippy::type_complexity)]
|
||
fn apply_unit_mat_alpha(
|
||
mut parts: Query<
|
||
(
|
||
&benilla_world::doodad_anim::MatAnim,
|
||
&mut bevy::mesh::MeshTag,
|
||
),
|
||
(
|
||
Without<benilla_world::model_fade::RenderFade>,
|
||
Without<benilla_world::model_fade::PendingAppearFade>,
|
||
),
|
||
>,
|
||
mut logged: Local<bool>,
|
||
) {
|
||
let mut culled = 0usize;
|
||
for (anim, mut tag) in &mut parts {
|
||
if !anim.composes_unit_tag() {
|
||
continue;
|
||
}
|
||
if anim.current <= 0.0 {
|
||
culled += 1;
|
||
}
|
||
let bits = benilla_world::mesh_tag::with_alpha(tag.0, anim.current);
|
||
if tag.0 != bits {
|
||
tag.0 = bits;
|
||
}
|
||
}
|
||
// One breadcrumb per session, the first frame a unit batch actually resolves to the reference's
|
||
// `A <= 0` cull — the machine-readable "this lane is live" signal for a bug whose symptom is
|
||
// geometry that should not be on screen. The live count is in the debug panel's material meter.
|
||
if !*logged && culled > 0 {
|
||
*logged = true;
|
||
info!("unit material alpha: {culled} batch(es) culled at A <= 0 (the first frame any did)");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod world_unit_tests {
|
||
use super::*;
|
||
use bevy::ecs::system::RunSystemOnce;
|
||
|
||
fn net(kind: EntityKind, scale: f32) -> NetEntity {
|
||
NetEntity {
|
||
kind,
|
||
display_id: None,
|
||
scale,
|
||
}
|
||
}
|
||
|
||
/// The reconciler is the *only* thing that makes a wire body visible to the world — no
|
||
/// `WorldUnit` means no ground shade, no WMO room claim, no foam ring, and nothing louder than
|
||
/// a slightly emptier screenshot to say so. So: a body gets one, the viewer gets its marker,
|
||
/// and both track the facts they restate.
|
||
#[test]
|
||
fn every_wire_body_becomes_a_world_unit_and_the_viewer_is_marked() {
|
||
let mut app = App::new();
|
||
let plain = app.world_mut().spawn(net(EntityKind::Unit, 1.25)).id();
|
||
let chest = app.world_mut().spawn(net(EntityKind::GameObject, 1.0)).id();
|
||
let me = app
|
||
.world_mut()
|
||
.spawn((
|
||
net(EntityKind::Player, 1.0),
|
||
crate::net::Embodied,
|
||
collision_height::CollisionHeight(2.5),
|
||
))
|
||
.id();
|
||
|
||
app.world_mut()
|
||
.run_system_once(publish_world_units)
|
||
.expect("reconciler runs");
|
||
|
||
let w = app.world();
|
||
let unit = w
|
||
.get::<benilla_world::world_unit::WorldUnit>(plain)
|
||
.expect("a wire body is a world unit");
|
||
assert!(unit.wades, "a creature displaces water");
|
||
assert_eq!(unit.scale, 1.25);
|
||
assert_eq!(
|
||
unit.height,
|
||
collision_height::CollisionHeight::default().0,
|
||
"an unresolved height reads as the CLIENT's ctor default, never 0.0 — at zero every \
|
||
depth line collapses and the body swims on dry land (see the type's own doc)"
|
||
);
|
||
|
||
// The wire→engine translation 1177 moved here: `benilla-world` no longer knows what a
|
||
// `TYPEID` is, so this is the only place the creature-vs-GameObject question is answered.
|
||
assert!(
|
||
!w.get::<benilla_world::world_unit::WorldUnit>(chest)
|
||
.expect("a GameObject is still a body the world can shade and room-claim")
|
||
.wades,
|
||
"…but a chest standing in a lake makes no ripple"
|
||
);
|
||
|
||
let mine = w
|
||
.get::<benilla_world::world_unit::WorldUnit>(me)
|
||
.expect("so is the avatar's");
|
||
assert!(mine.wades, "and so does a player");
|
||
assert_eq!(mine.height, 2.5, "the collision cylinder travels with it");
|
||
assert!(
|
||
w.get::<benilla_world::world_unit::ViewerUnit>(me).is_some(),
|
||
"and the eye's own body is marked, which is what first-person feathering filters on"
|
||
);
|
||
assert!(
|
||
w.get::<benilla_world::world_unit::ViewerUnit>(plain)
|
||
.is_none(),
|
||
"…and nothing else is"
|
||
);
|
||
}
|
||
|
||
/// The viewer marker is reconciled, not stamped once: a `/logout` takes `Embodied` away and
|
||
/// the marker has to go with it, or the next character is feathered as someone else's avatar.
|
||
#[test]
|
||
fn the_viewer_marker_follows_self_player_off_as_well_as_on() {
|
||
let mut app = App::new();
|
||
let me = app
|
||
.world_mut()
|
||
.spawn((net(EntityKind::Player, 1.0), crate::net::Embodied))
|
||
.id();
|
||
app.world_mut()
|
||
.run_system_once(publish_world_units)
|
||
.expect("reconciler runs");
|
||
assert!(app
|
||
.world()
|
||
.get::<benilla_world::world_unit::ViewerUnit>(me)
|
||
.is_some());
|
||
|
||
app.world_mut()
|
||
.entity_mut(me)
|
||
.remove::<crate::net::Embodied>();
|
||
app.world_mut()
|
||
.run_system_once(publish_world_units)
|
||
.expect("reconciler runs");
|
||
assert!(
|
||
app.world()
|
||
.get::<benilla_world::world_unit::ViewerUnit>(me)
|
||
.is_none(),
|
||
"the marker is reconciled off, not left behind"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod display_mirror_tests {
|
||
use super::*;
|
||
use crate::portrait::PortraitSeat;
|
||
|
||
/// One synthetic batch — plain, or a camera-facing one on `bone`.
|
||
fn part(billboard: Option<u16>) -> crate::entities::display::EntityPart {
|
||
crate::entities::display::EntityPart {
|
||
mesh: Handle::default(),
|
||
geometry: std::sync::Arc::new(benilla_formats::RenderSubmesh::default()),
|
||
aabb: None,
|
||
skinned_mesh: Some(Handle::default()),
|
||
welded_billboard: false,
|
||
material: Handle::default(),
|
||
material_interior: None,
|
||
material_interior_bake: None,
|
||
material_interior_bake_blend: None,
|
||
fade_blend: None,
|
||
zfill: None,
|
||
blend: benilla_formats::ModelBlend::Opaque,
|
||
additive: false,
|
||
two_sided: false,
|
||
geoset_id: 0,
|
||
char_slot: None,
|
||
billboard: billboard.map(|bone| benilla_assets::BillboardInfo {
|
||
pivot: Vec3::ZERO,
|
||
bone,
|
||
kind: benilla_formats::BillboardKind::Spherical,
|
||
scale_anim: None,
|
||
seq_translations: Vec::new(),
|
||
}),
|
||
alpha_anim: None,
|
||
rgb_anim: None,
|
||
ground_quad: None,
|
||
}
|
||
}
|
||
|
||
fn creatures(display_id: u32, dm: crate::entities::display::DisplayModel) -> Creatures {
|
||
let mut c = Creatures {
|
||
catalog: Default::default(),
|
||
models: HashMap::new(),
|
||
};
|
||
c.models.insert(display_id, dm);
|
||
c
|
||
}
|
||
|
||
/// **Every batch the display authored reaches the mirror, split by lane.** This is the same
|
||
/// failure mode decision 1539's imp filed against the select pet: a display-only assembly that
|
||
/// reads one field and drops another renders a creature that is *nearly* right, silently. The
|
||
/// assertion is "everything the cache holds arrives, in the right lane", not a count pinned to
|
||
/// one model.
|
||
#[test]
|
||
fn a_display_mirror_carries_every_batch_and_splits_the_camera_facing_ones() {
|
||
let mut dm = crate::entities::display::empty_shell();
|
||
dm.parts = Some(vec![part(None), part(Some(17)), part(None)]);
|
||
let c = creatures(4449, dm);
|
||
|
||
let (parts, cards) = c.display_mirror(4449).expect("the model is built");
|
||
assert_eq!(parts.len(), 2, "the two plain batches are ordinary parts");
|
||
assert_eq!(
|
||
cards.len(),
|
||
1,
|
||
"the camera-facing batch is a card, not a part"
|
||
);
|
||
assert_eq!(cards[0].bone, 17, "the card keeps its own billboard bone");
|
||
assert_eq!(
|
||
cards[0].seat,
|
||
PortraitSeat::Body,
|
||
"a creature's own batch is the host model's — its bone's booth joint already bakes the \
|
||
pivot, so it takes no rider offset"
|
||
);
|
||
assert!(
|
||
cards[0].attach.is_none(),
|
||
"a batch of the host model itself sits in no M2 attachment node"
|
||
);
|
||
assert!(
|
||
parts.iter().all(|p| p.skinned_mesh.is_some()),
|
||
"the skinned twin travels — the booth poses it at Stand on its own skeleton"
|
||
);
|
||
}
|
||
|
||
/// The gate that keeps a half-loaded display out of the booth: `display_mirror` answers `None`
|
||
/// until the parts are built, the same instant [`Creatures::display_rig`] and
|
||
/// [`Creatures::display_anchors`] start answering — so the stand-in never spawns a subject the
|
||
/// booth could frame from fabricated bounds.
|
||
#[test]
|
||
fn a_display_still_loading_mirrors_nothing() {
|
||
let c = creatures(4449, crate::entities::display::empty_shell());
|
||
assert!(c.display_mirror(4449).is_none(), "parts not built yet");
|
||
assert!(c.display_mirror(1).is_none(), "unknown display");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod overhead_slot_tests {
|
||
use super::*;
|
||
|
||
/// A body model authoring exactly `slots` (the bone/offset payload is immaterial here).
|
||
fn body(slots: &[u16]) -> BoneAttach {
|
||
BoneAttach {
|
||
points: slots.iter().map(|&s| (s, (0u16, Vec3::ZERO))).collect(),
|
||
markers: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
/// `0x6074c0`'s pick and only it: 29 is *preferred*, never required, and never taken by an
|
||
/// unmounted unit. So a character body — which authors both (wow-re's table: HumanMale 18 at
|
||
/// z 2.2123, 29 at z 1.4029) — moves 18 ↔ 29 across a mount, a creature that authors only 18
|
||
/// (AncientOfLore, Kobold) stays at 18 whether or not it is mounted, and a body authoring
|
||
/// neither anchors nothing: the reference creates the marker and never parents it.
|
||
#[test]
|
||
fn twenty_nine_needs_both_a_mount_model_and_the_authored_point() {
|
||
let both = body(&[ATTACH_OVERHEAD, ATTACH_OVERHEAD_MOUNTED]);
|
||
assert_eq!(overhead_slot(&both, false), Some(ATTACH_OVERHEAD));
|
||
assert_eq!(overhead_slot(&both, true), Some(ATTACH_OVERHEAD_MOUNTED));
|
||
|
||
let plain = body(&[ATTACH_OVERHEAD]);
|
||
assert_eq!(overhead_slot(&plain, false), Some(ATTACH_OVERHEAD));
|
||
assert_eq!(
|
||
overhead_slot(&plain, true),
|
||
Some(ATTACH_OVERHEAD),
|
||
"no 29 on the body ⇒ the mounted leg falls back to 18, like the client"
|
||
);
|
||
|
||
let mounted_only = body(&[ATTACH_OVERHEAD_MOUNTED]);
|
||
assert_eq!(
|
||
overhead_slot(&mounted_only, false),
|
||
None,
|
||
"29 is never the unmounted fallback — the fallback is 18 or nothing"
|
||
);
|
||
assert_eq!(
|
||
overhead_slot(&mounted_only, true),
|
||
Some(ATTACH_OVERHEAD_MOUNTED)
|
||
);
|
||
|
||
assert_eq!(overhead_slot(&body(&[]), true), None, "never parented");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod attach_bias_tests {
|
||
use super::*;
|
||
|
||
/// The z-bias fallback table `[0x862708]`, verbatim (wow-re
|
||
/// `object-layer/scratch/anim-event-position-law.md` §4). It is 37 f32 for attachment ids
|
||
/// `0..=0x24`, and the two ids that actually reach it through an animation event are the
|
||
/// emote voice's **17** and a whiffed swing's **1** — so those two are what a drift here
|
||
/// would move, on exactly the models that lack the attachment.
|
||
#[test]
|
||
fn the_z_bias_table_is_the_reference_row_for_ever_id() {
|
||
assert_eq!(ATTACH_Z_BIAS.len(), 0x25, "ids 0..=0x24");
|
||
assert_eq!(ATTACH_Z_BIAS[17], 2.0, "$CSD's fallback lift");
|
||
assert_eq!(ATTACH_Z_BIAS[1], 1.0, "a whiffed $CSS's");
|
||
// The two that are not 1.0-or-1.5, which is what makes a transcription slip visible.
|
||
assert_eq!(ATTACH_Z_BIAS[19], 0.0);
|
||
assert_eq!(ATTACH_Z_BIAS[29], 3.0);
|
||
assert_eq!(ATTACH_Z_BIAS[18], 2.5);
|
||
// Out of range reads as no lift, matching the reference's `0 ≤ id < 0x25` guard.
|
||
assert_eq!(ATTACH_Z_BIAS.get(0x25), None);
|
||
}
|
||
}
|