- the transcription had dropped parent= on 72 declarations the reference carries (37 to UIParent, 28 to the action bar's art frame, 7 more), so the HUD was a field of top-level frames and UIParent:Hide() reached almost nothing — which is why a cinematic had to hide the UI at the draw level and then special-case the pointer; both workarounds are deleted and the HUD hides through the reference's own route; restoring only the UIParent half would have been the dangerous part (draw order is one flat sort, so a partial restore lifts frames over the neighbours they carry), and manifest order turns out to be load-bearing — a parent name that doesn't exist yet is silently ignored, so UIParent.xml moves to the reference's own slot with a test that fails if anything declares a child first; the chat still drew over a fly-by after all that, because the reference parents the chat windows on their TEMPLATE, invisible to a name-for-name diff when both sides rename — two declarations fix it, and the sweep that replaces the method loads the whole shipped manifest, fires CINEMATIC_START and asserts the survivors are exactly three: 91 frames visible before, 3 after, and the count comes back on stop - every cinematic boundary cuts through the reference's 0.25 s fade to black, and the next step hangs on the fade's COMPLETION, not a delay — the shot boundary is exactly where the reference does a blocking terrain load, so a delay-driven cut shows through a hitch and a completion-driven one cannot; and a fly-by now ends into black instead of a ~0.3 s loading cover: the reference raises no cover on that path, it holds black while the ground under the player loads back, so ours holds black while the stream catches up — budgeted, never an unbounded wait - the death arc keeps its promises across a relog: the body's own state machine now dies with the session — re-entering the world as a ghost used to match no arm of the world-edge machine, so the corpse query never went out and "Resurrect Now" stayed down until the next death; and a corpse wears its owner's guild crest — the corpse descriptor carries the guild field and resolves through the same crest cache a living tabard does, where ours dressed the body crestless - the char-select ghost gets its body kit: the screen resolves the state kit of the very "Ghost" aura a released ghost rides in the world — tint (140,185,253), alpha 0.5, and the Ghost_state wisp model at the body's attach point, pinned by a golden that decodes the row out of the shipped DBC; the airborne model is corrected before its fix lands — the address six comments called the gravity integrator is really the fall-velocity query, and a knockback is unsteerable because its launch PLANTS the forward bit, not because the arc is airborne (our guard was a velocity proxy, so a near-zero-speed knockback steered from frame 2); and the hover inspect card now decodes a picked part's mesh tag — alpha, rig slot, and both readings of the lighting payload side by side — turning a rebuild-and-two-probes hunt into a hover Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1438 lines
65 KiB
Rust
1438 lines
65 KiB
Rust
//! The **tuple-driven character preview** builder (decisions 0423 + 0465, widened by 1060) — the
|
||
//! entities-side half of every booth that shows a character nobody is standing in. It lives under
|
||
//! `attach` so it can reuse the character body pipeline directly
|
||
//! (`char_skin::build_char_skin_materials`, the geoset filter, the [`super::super::EntityPart`]
|
||
//! source, the item-display models) without widening any of it.
|
||
//!
|
||
//! Two callers, **one assembly** ([`assemble`]). Each reduces its own look to the same
|
||
//! [`PreviewSpec`] — body displayId + the five appearance dials + an enum-shaped
|
||
//! `[CharEnumItem; 19]` — and gets back the flat part/rider/effect lists its booth bakes:
|
||
//!
|
||
//! - **The glue screens** ([`build_glue_preview`]): a **Select** look carries its roster record
|
||
//! verbatim; a **Create** look resolves the (race, class, sex) starting outfit (CharStartOutfit,
|
||
//! decision 0527) into that array — so the create preview is geared exactly like a select body
|
||
//! (the ref's create screen dresses the model, not the underwear-only body decision 0423 assumed).
|
||
//! - **The dressing room** ([`build_dressup_preview`], decision 1060): the *live player's* own body
|
||
//! and appearance, wearing their own visible items with the tried-on ones substituted in. That
|
||
//! substitution is the whole feature, and it is why the dressing room cannot mirror a world
|
||
//! entity's spawned children the way the paper doll does (`portrait::sync_body_booth`) — nobody
|
||
//! in the world is wearing the item being previewed.
|
||
//!
|
||
//! The assembly is the world's own way — displayId → the shared [`Creatures`] display cache →
|
||
//! geoset filter → the composited body over a hand-built [`CharLook`] — plus the armor-region
|
||
//! composite + real equipment geosets, the cloak, and the attach riders: helm (per-race/sex model,
|
||
//! hide-helm honored), shoulders, and the weapons **held in the hands** (the byte-verified select
|
||
//! build — wow-re `glue-select-model.md`, folded back over 0465's sheathed INTERIM). The portrait
|
||
//! module's `sync_glue_booth` / `sync_dressup_booth` then re-light and bake them (they own the
|
||
//! *booth*; this owns the *assembly*). A glue body's displayId is added to
|
||
//! `update_display_models`'s want-list (in [`super::super`]) so its model builds with no wire
|
||
//! entity — the dressing room's is the player's own, already built; item models register through
|
||
//! the same [`ItemDisplays`] want the world path uses.
|
||
|
||
use benilla_formats::CharSkinSlot;
|
||
use benilla_protocol::{
|
||
CharEnumItem, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM,
|
||
};
|
||
use bevy::prelude::*;
|
||
|
||
use crate::portrait::{
|
||
BoothTwins, DressUpBake, DressUpLook, DressUpPreview, GhostKit, GlueLook, GluePetBake,
|
||
GluePreview, GluePreviewBake, PetLook, PreviewBillboard, PreviewEffects, PreviewPart,
|
||
PreviewRider,
|
||
};
|
||
use benilla_assets::materials::WowModelMaterial;
|
||
use benilla_assets::WorldAssets;
|
||
|
||
use super::super::equipment::{attach_id, ensure_item_model, placement, ItemModelKind};
|
||
use super::super::item_glow::{self, ItemGlows};
|
||
use super::super::{
|
||
CharCreate, Characters, Creatures, EntityPart, ItemDisplays, SkinComposites, SkinSections,
|
||
};
|
||
use super::char_skin::{equip_geosets, BodySkin, CharLook};
|
||
|
||
/// The enum equipment slots that paint the armor-region composite, in the bodyslot order
|
||
/// (shirt · chest · belt · pants · boots · wrist · gloves · tabard → bodyslot 2–9):
|
||
/// equipment-slot indices 3, 4, 5, 6, 7, 8, 9, 18.
|
||
const ENUM_BODYSLOTS: [usize; 8] = [3, 4, 5, 6, 7, 8, 9, 18];
|
||
/// The enum's helm / shoulder / cloak slots.
|
||
const ENUM_HELM: usize = 0;
|
||
const ENUM_SHOULDER: usize = 2;
|
||
const ENUM_CLOAK: usize = 14;
|
||
/// Main hand · off hand · ranged, in [`placement`]'s held-triple slot order (0/1/2).
|
||
const ENUM_HELD: [usize; 3] = [15, 16, 17];
|
||
|
||
/// Map an item's `InventoryType` to its equipment-slot index in the enum-shaped `[_; 19]` array
|
||
/// (`EQUIPMENT_SLOT_*`), so a CharStartOutfit item (decision 0527) lands where the Select pipeline
|
||
/// reads it — the render slots the compositor paints (helm 0 · shoulder 2 · shirt 3 · chest 4 ·
|
||
/// waist 5 · legs 6 · feet 7 · wrist 8 · hands 9 · back 14 · tabard 18, wow-re `glue-select-model.md`)
|
||
/// plus the held triple (main 15 · off 16 · ranged 17). `None` for a non-worn / non-rendered type
|
||
/// (bags, ammo, quiver, relic, and NON_EQUIP consumables — the outfit's food + hearthstone).
|
||
pub(crate) fn equip_slot(inv_type: u8) -> Option<usize> {
|
||
Some(match inv_type {
|
||
1 => 0, // HEAD
|
||
2 => 1, // NECK (not rendered; its slot for completeness)
|
||
3 => 2, // SHOULDERS
|
||
4 => 3, // BODY (shirt)
|
||
5 | 20 => 4, // CHEST / ROBE
|
||
6 => 5, // WAIST
|
||
7 => 6, // LEGS
|
||
8 => 7, // FEET
|
||
9 => 8, // WRISTS
|
||
10 => 9, // HANDS
|
||
16 => 14, // BACK (cloak)
|
||
13 | 17 | 21 => 15, // WEAPON / TWOHAND / WEAPONMAINHAND → main hand
|
||
14 | 22 | 23 => 16, // SHIELD / WEAPONOFFHAND / HOLDABLE → off hand
|
||
15 | 25 | 26 => 17, // RANGED / THROWN / RANGEDRIGHT (wand, gun) → ranged
|
||
19 => 18, // TABARD
|
||
_ => return None, // 0 NON_EQUIP · 11 FINGER · 12 TRINKET · 18 BAG · 24 AMMO · 28 RELIC · …
|
||
})
|
||
}
|
||
|
||
/// One preview's complete input tuple — the body model, its five appearance dials, and the worn
|
||
/// equipment in the `SMSG_CHAR_ENUM` slot shape. Both callers reduce their own look to this, so
|
||
/// [`assemble`] has exactly one shape to build from and the two previews can never dress by
|
||
/// different laws.
|
||
pub(in crate::entities) struct PreviewSpec {
|
||
/// The **body** display id, already resolved by the caller: the race/sex body for a glue look
|
||
/// (`CharCreateCatalog::body_display`), the live player's own display for the dressing room.
|
||
/// Its model must be in the display cache (or its want-list) or the assembly waits for it.
|
||
pub(in crate::entities) display_id: u32,
|
||
pub(in crate::entities) race: u8,
|
||
pub(in crate::entities) sex: u8,
|
||
pub(in crate::entities) skin: u8,
|
||
pub(in crate::entities) face: u8,
|
||
pub(in crate::entities) hair_style: u8,
|
||
pub(in crate::entities) hair_color: u8,
|
||
pub(in crate::entities) facial_hair: u8,
|
||
/// Worn **ItemDisplayInfo** ids by equipment slot (`EQUIPMENT_SLOT_*`) — no template hop; the
|
||
/// caller has already resolved entries to displays.
|
||
pub(in crate::entities) equipment: [CharEnumItem; 19],
|
||
/// The wearer's guild tabard (decision 1704), or `None` for no crest.
|
||
pub(in crate::entities) emblem: Option<benilla_formats::GuildEmblem>,
|
||
/// `CHARACTER_FLAG_*` bits; only hide-helm / hide-cloak are read here.
|
||
pub(in crate::entities) flags: u32,
|
||
/// **Whose held law this look follows** (decision 1076) — the one place the two previews
|
||
/// genuinely differ, and a *verified* difference rather than a convenience.
|
||
///
|
||
/// `false` — the **character-select mannequin** (`0x472950`), whose equipment loop skips
|
||
/// `EQUIPMENT_SLOT_RANGED` outright (`0x472bfe cmp esi,0x11; je`) and forces SheatheType 0.
|
||
///
|
||
/// `true` — the **dressing room** (`DressUpModel::TryOn 0x504d90`), which does not skip it: its
|
||
/// remap table (`0x504b7c` → `0x504b44`) sends `INVTYPE_RANGED` down the same held arm as a
|
||
/// weapon, and `sheathFlag = 0` is pushed literally at `0x504adc`, so a tried-on ranged weapon
|
||
/// is installed **at a hand and never at a sheath point, whatever its SheatheType** — by the
|
||
/// world's own split (bow → HandLeft, gun/crossbow/wand/thrown → HandRight). Which is exactly
|
||
/// why this rides [`placement`]'s existing, already-verified ranged arm rather than growing a
|
||
/// second copy of that mapping.
|
||
pub(in crate::entities) ranged_in_hand: bool,
|
||
}
|
||
|
||
/// What one [`assemble`] produced — the content half of a preview bake, in the booth's own shape.
|
||
pub(in crate::entities) struct Assembled {
|
||
pub(in crate::entities) parts: Vec<PreviewPart>,
|
||
pub(in crate::entities) riders: Vec<PreviewRider>,
|
||
pub(in crate::entities) effects: Vec<PreviewEffects>,
|
||
pub(in crate::entities) billboards: Vec<PreviewBillboard>,
|
||
pub(in crate::entities) grip: [bool; 2],
|
||
}
|
||
|
||
/// The resource borrows [`assemble`] needs — the display caches it reads and registers wants with,
|
||
/// the character/skin data the composite is built from, and the asset stores it writes into. A
|
||
/// plain borrow struct rather than a `SystemParam`: each driver already holds these as its own
|
||
/// `Res`/`ResMut` params and simply lends them for the call.
|
||
pub(in crate::entities) struct PreviewCtx<'a, 'w> {
|
||
pub(in crate::entities) creatures: &'a Creatures,
|
||
pub(in crate::entities) characters: Option<&'a Characters>,
|
||
pub(in crate::entities) displays: Option<&'a mut ItemDisplays>,
|
||
/// The item/enchant glow chain (decision 0805): both previews resolve it themselves — the
|
||
/// world's `resolve_equipment` never runs pre-world, and never runs for a look nobody wears.
|
||
pub(in crate::entities) glows: Option<&'a mut ItemGlows>,
|
||
pub(in crate::entities) sections: Option<&'a SkinSections>,
|
||
pub(in crate::entities) world_assets: Option<&'a WorldAssets>,
|
||
pub(in crate::entities) images: &'a mut Assets<Image>,
|
||
pub(in crate::entities) skin_composites: &'a mut SkinComposites,
|
||
pub(in crate::entities) asset_server: &'a AssetServer,
|
||
pub(in crate::entities) mats: &'a mut benilla_world::model_render::M2BatchMaterials<'w>,
|
||
}
|
||
|
||
/// Assemble the preview part (+ rider) lists when the glue-screen look changes. Runs in the
|
||
/// entity-visuals chain (after `update_display_models` has built the body model from the
|
||
/// want-list), so a fresh look's models are ready within a frame or two; until then it leaves the
|
||
/// bake untouched and retries. A bare yaw change never reaches here (the booth handles it).
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(in crate::entities) fn build_glue_preview(
|
||
preview: Res<GluePreview>,
|
||
mut bake: ResMut<GluePreviewBake>,
|
||
creatures: Option<Res<Creatures>>,
|
||
characters: Option<Res<Characters>>,
|
||
char_create: Option<Res<CharCreate>>,
|
||
mut displays: Option<ResMut<ItemDisplays>>,
|
||
// The item/enchant glow chain (decision 0805): the glue screens resolve it themselves — the
|
||
// world's `resolve_equipment` never runs pre-world.
|
||
mut glows: Option<ResMut<ItemGlows>>,
|
||
sections: Option<Res<SkinSections>>,
|
||
world_assets: Option<Res<WorldAssets>>,
|
||
mut images: ResMut<Assets<Image>>,
|
||
mut skin_composites: ResMut<SkinComposites>,
|
||
asset_server: Res<AssetServer>,
|
||
mut mats: benilla_world::model_render::M2BatchMaterials,
|
||
// The ghost kit (wow-re `glue-select-model.md` §A2): the `SpellVisualKit` catalog the row is
|
||
// read from, and the path-keyed spell-effect model cache its attached models load through —
|
||
// the same one the world's kit plays use, so `Spells\Ghost_state.mdx` is loaded once per
|
||
// session whichever screen wants it first.
|
||
visuals: Option<Res<crate::creature_anim::SpellVisuals>>,
|
||
mut fx: Option<ResMut<super::super::spell_fx::SpellFx>>,
|
||
// Change tracking: the look we last emitted a bake for, and whether that bake succeeded (so a
|
||
// look whose models weren't ready yet keeps retrying until they build).
|
||
mut state: Local<PreviewState>,
|
||
) {
|
||
// A new look resets the retry latch.
|
||
if state.last_look != preview.look {
|
||
state.last_look = preview.look;
|
||
state.built = false;
|
||
}
|
||
if state.built {
|
||
return;
|
||
}
|
||
|
||
// No look → clear the booth once.
|
||
let Some(look) = preview.look else {
|
||
if bake.look.is_some() {
|
||
*bake = GluePreviewBake {
|
||
revision: bake.revision + 1,
|
||
..default()
|
||
};
|
||
}
|
||
state.built = true;
|
||
return;
|
||
};
|
||
|
||
// displayId → the shared display cache. The want-list in `update_display_models` has already
|
||
// asked for this display; the assembly waits (retry, `built` stays false) until its model +
|
||
// parts are built.
|
||
let (Some(creatures), Some(char_create)) = (creatures.as_deref(), char_create.as_deref())
|
||
else {
|
||
return;
|
||
};
|
||
let (race, sex) = look.body();
|
||
let Some(display_id) = char_create.0.body_display(race, sex) else {
|
||
state.built = true; // a non-playable race can't resolve — nothing to show, don't spin
|
||
return;
|
||
};
|
||
|
||
// The body appearance is identical in shape for both looks (only its source struct differs).
|
||
let (skin, face, hair_style, hair_color, facial_hair) = match look {
|
||
GlueLook::Create(l) => (l.skin, l.face, l.hair_style, l.hair_color, l.facial_hair),
|
||
GlueLook::Select(l) => (l.skin, l.face, l.hair_style, l.hair_color, l.facial_hair),
|
||
};
|
||
|
||
// The equipment, unified as an enum-shaped `[CharEnumItem; 19]` + display flags: a Select look
|
||
// carries its roster record verbatim; a Create look resolves the (race, class, sex) starting
|
||
// outfit (CharStartOutfit, decision 0527) into the same slot array — each worn item at the
|
||
// equipment slot its InventoryType maps to. Display ids are ItemDisplayInfo ids in both cases
|
||
// (no template hop). A level-1 outfit carries no helm/cloak and there is no hide flag, so the
|
||
// create preview's flags are 0 — the create body is dressed exactly like a geared select body.
|
||
let (equipment, flags): ([CharEnumItem; 19], u32) = match look {
|
||
GlueLook::Create(l) => {
|
||
let mut equipment = [CharEnumItem::default(); 19];
|
||
for item in char_create.0.start_outfit(l.race, l.class, l.sex) {
|
||
if let Some(slot) = equip_slot(item.inv_type) {
|
||
equipment[slot] = CharEnumItem {
|
||
display_id: item.display_id,
|
||
inventory_type: item.inv_type,
|
||
};
|
||
}
|
||
}
|
||
(equipment, 0)
|
||
}
|
||
GlueLook::Select(l) => (l.equipment, l.flags),
|
||
};
|
||
|
||
let spec = PreviewSpec {
|
||
display_id,
|
||
race,
|
||
sex,
|
||
skin,
|
||
face,
|
||
hair_style,
|
||
hair_color,
|
||
facial_hair,
|
||
equipment,
|
||
// No crest at character select (decision 1704). `SMSG_CHAR_ENUM` carries a `guildId` but
|
||
// not the five emblem indices, and there is no world session to `CMSG_GUILD_QUERY` with —
|
||
// so a Guild Tabard on the mannequin wears its own `Tabard_A_05Default` art, which is what
|
||
// the reference's own empty guild cache leaves it with too.
|
||
emblem: None,
|
||
flags,
|
||
// The mannequin's own law: the select build skips EQUIPMENT_SLOT_RANGED (0x472bfe).
|
||
ranged_in_hand: false,
|
||
};
|
||
let Some(a) = assemble(
|
||
&spec,
|
||
&mut PreviewCtx {
|
||
creatures,
|
||
characters: characters.as_deref(),
|
||
displays: displays.as_deref_mut(),
|
||
glows: glows.as_deref_mut(),
|
||
sections: sections.as_deref(),
|
||
world_assets: world_assets.as_deref(),
|
||
images: &mut images,
|
||
skin_composites: &mut skin_composites,
|
||
asset_server: &asset_server,
|
||
mats: &mut mats,
|
||
},
|
||
) else {
|
||
return; // a model (body, item, or glow) is still loading — retry next frame
|
||
};
|
||
|
||
// The ghost kit — a **Select** look only, and only when the roster record carries the bit
|
||
// (`CHARSELECT+0xfc & 0x2000`). A create look never can: the character does not exist yet, and
|
||
// the create screen builds its flags as `0` above. The kit's attached models join the ordinary
|
||
// lists; what the body renders with rides the bake.
|
||
let mut a = a;
|
||
let ghost = match look {
|
||
GlueLook::Select(l) if l.flags & CHARACTER_FLAG_GHOST != 0 => {
|
||
let mut pending = false;
|
||
let arm = (|| {
|
||
let body = creatures.models.get(&display_id)?;
|
||
glue_ghost_arm(
|
||
body,
|
||
&visuals.as_deref()?.0,
|
||
fx.as_deref_mut()?,
|
||
&asset_server,
|
||
&mut pending,
|
||
)
|
||
})();
|
||
if pending {
|
||
return; // an effect model is still loading — retry, don't bake a half ghost
|
||
}
|
||
arm.map(|arm| {
|
||
a.riders.extend(arm.riders);
|
||
a.billboards.extend(arm.billboards);
|
||
a.effects.extend(arm.effects);
|
||
arm.kit
|
||
})
|
||
}
|
||
_ => None,
|
||
};
|
||
|
||
debug!(
|
||
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, \
|
||
{} camera-facing, {} effect model(s) / {} emitter(s) ({})",
|
||
a.parts.len(),
|
||
a.riders.len(),
|
||
a.billboards.len(),
|
||
a.effects.len(),
|
||
a.effects
|
||
.iter()
|
||
.map(|e: &PreviewEffects| e.emitters.len())
|
||
.sum::<usize>(),
|
||
match look {
|
||
GlueLook::Create(_) => "create",
|
||
GlueLook::Select(_) => "select",
|
||
},
|
||
);
|
||
*bake = GluePreviewBake {
|
||
look: Some(look),
|
||
display_id,
|
||
parts: a.parts,
|
||
riders: a.riders,
|
||
effects: a.effects,
|
||
billboards: a.billboards,
|
||
grip: a.grip,
|
||
ghost,
|
||
revision: bake.revision + 1,
|
||
};
|
||
state.built = true;
|
||
}
|
||
|
||
/// Assemble the **dressing room**'s look (decision 1060) — the same law as the glue driver above,
|
||
/// over a look whose equipment [`crate::ui_dressup`] composed from the player's own visible items
|
||
/// plus the tried-on substitutions. The body display is the player's own, so it is already built
|
||
/// (they are standing in the world); an ITEM model still has to load, which is what the retry latch
|
||
/// is for.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(in crate::entities) fn build_dressup_preview(
|
||
preview: Res<DressUpPreview>,
|
||
mut bake: ResMut<DressUpBake>,
|
||
creatures: Option<Res<Creatures>>,
|
||
characters: Option<Res<Characters>>,
|
||
mut displays: Option<ResMut<ItemDisplays>>,
|
||
mut glows: Option<ResMut<ItemGlows>>,
|
||
sections: Option<Res<SkinSections>>,
|
||
world_assets: Option<Res<WorldAssets>>,
|
||
mut images: ResMut<Assets<Image>>,
|
||
mut skin_composites: ResMut<SkinComposites>,
|
||
asset_server: Res<AssetServer>,
|
||
mut mats: benilla_world::model_render::M2BatchMaterials,
|
||
mut state: Local<DressUpState>,
|
||
) {
|
||
// A new look resets the retry latch (the glue driver's own shape).
|
||
if state.last_look != preview.look {
|
||
state.last_look = preview.look;
|
||
state.built = false;
|
||
}
|
||
if state.built {
|
||
return;
|
||
}
|
||
|
||
// No look → clear the booth once (the window closed, or nothing to show).
|
||
let Some(look) = preview.look else {
|
||
if bake.look.is_some() {
|
||
*bake = DressUpBake {
|
||
revision: bake.revision + 1,
|
||
..default()
|
||
};
|
||
}
|
||
state.built = true;
|
||
return;
|
||
};
|
||
|
||
let Some(creatures) = creatures.as_deref() else {
|
||
return;
|
||
};
|
||
let spec = PreviewSpec {
|
||
display_id: look.display_id,
|
||
race: look.race,
|
||
sex: look.sex,
|
||
skin: look.skin,
|
||
face: look.face,
|
||
hair_style: look.hair_style,
|
||
hair_color: look.hair_color,
|
||
facial_hair: look.facial_hair,
|
||
equipment: look.equipment,
|
||
emblem: look.emblem,
|
||
// **No hide flags here, because the hiding already happened upstream** (decision 1472).
|
||
// The dressing room DOES honour show-helm/show-cloak — byte-verified: `SetUnit 0x476cb0`
|
||
// clones the live player's per-bodyslot display pointers, so a suppressed piece is not in
|
||
// what is cloned — but a TRY-ON must preview the helm regardless, and only
|
||
// [`crate::ui_dressup`]'s look builder knows which slots are worn and which are tried on.
|
||
// So it applies the preference there, per slot, and hands us an already-correct array;
|
||
// re-applying a flag mask over it here would hide the very item being previewed.
|
||
flags: 0,
|
||
// The widget's law, not the mannequin's (decision 1076). Unconditional, because the ranged
|
||
// slot of a dressing-room look is only ever filled by a TRY-ON: `crate::ui_dressup` leaves
|
||
// a worn ranged weapon out, since the reference's Dress()/SetUnit clones the live world
|
||
// model and a ranged weapon shows there only while ranged-drawn.
|
||
ranged_in_hand: true,
|
||
};
|
||
let Some(a) = assemble(
|
||
&spec,
|
||
&mut PreviewCtx {
|
||
creatures,
|
||
characters: characters.as_deref(),
|
||
displays: displays.as_deref_mut(),
|
||
glows: glows.as_deref_mut(),
|
||
sections: sections.as_deref(),
|
||
world_assets: world_assets.as_deref(),
|
||
images: &mut images,
|
||
skin_composites: &mut skin_composites,
|
||
asset_server: &asset_server,
|
||
mats: &mut mats,
|
||
},
|
||
) else {
|
||
return; // an item model is still loading — retry next frame
|
||
};
|
||
|
||
debug!(
|
||
"dressup preview: race {} sex {} display {} → {} parts, {} riders, {} camera-facing, \
|
||
{} effect model(s)",
|
||
look.race,
|
||
look.sex,
|
||
look.display_id,
|
||
a.parts.len(),
|
||
a.riders.len(),
|
||
a.billboards.len(),
|
||
a.effects.len(),
|
||
);
|
||
*bake = DressUpBake {
|
||
look: Some(look),
|
||
display_id: look.display_id,
|
||
parts: a.parts,
|
||
riders: a.riders,
|
||
effects: a.effects,
|
||
billboards: a.billboards,
|
||
grip: a.grip,
|
||
revision: bake.revision + 1,
|
||
};
|
||
state.built = true;
|
||
}
|
||
|
||
/// The one assembly both previews run — [`PreviewSpec`] in, the booth's flat lists out.
|
||
///
|
||
/// `None` means **not ready**: the body model, an item model, or a glow model is still loading.
|
||
/// A caller must treat that as "retry next frame" and leave its bake untouched, never as an empty
|
||
/// look — a geared character popping in piecewise would flicker on every change.
|
||
/// The `SpellVisualKit` row the character-select ghost is, **hard-coded exactly as the reference
|
||
/// hard-codes it**: `0x47280f` loads `idmap[989]` behind the guard `0x4727f6 cmp ds:0xc0d750,0x3dd`
|
||
/// (wow-re `glue-select-model.md` §A2). The id is the constant; every value behind it is DBC data,
|
||
/// read below. 989 is `SpellVisual` 886's state kit — the visual of spell 8326 "Ghost", which is
|
||
/// also what a released ghost in the WORLD rides, so the two paths share one row by construction
|
||
/// (wow-re `ghost-death-visuals.md` §5a).
|
||
const GLUE_GHOST_KIT: u32 = 989;
|
||
|
||
/// What the ghost kit contributes to a select bake: the body's own render properties, plus the
|
||
/// kit's attached effect models already reduced to the booth's ordinary seat shapes.
|
||
struct GhostArm {
|
||
kit: GhostKit,
|
||
riders: Vec<PreviewRider>,
|
||
billboards: Vec<PreviewBillboard>,
|
||
effects: Vec<PreviewEffects>,
|
||
}
|
||
|
||
/// Resolve [`GLUE_GHOST_KIT`] against the live `SpellVisualKit` catalog and the body's attachment
|
||
/// table. `None` means **not ready** — either a DBC is absent (nothing to show, the caller stops
|
||
/// retrying) or one of the kit's effect models is still loading (the caller retries), which the
|
||
/// `pending` out-flag distinguishes.
|
||
///
|
||
/// The tint and alpha go through [`crate::aura_visual::node_for`] — the one dispatch point, the ECS
|
||
/// twin of the client's `0x60dbfc` jump table — rather than a second decode here: the select screen
|
||
/// and the world aura reach the same row, and a proc type newly given a mechanism must light up on
|
||
/// both at once. What the select path does NOT share is the world's 1000 ms `StartAlphaFade` ramp:
|
||
/// `0x4727f0` writes the instance fields straight through `0x710cb0`/`0x710cf0`, so this is instant.
|
||
fn glue_ghost_arm(
|
||
body: &super::super::DisplayModel,
|
||
visuals: &benilla_formats::SpellVisualCatalog,
|
||
fx: &mut super::super::spell_fx::SpellFx,
|
||
asset_server: &AssetServer,
|
||
pending: &mut bool,
|
||
) -> Option<GhostArm> {
|
||
let kit_row = visuals.kit(GLUE_GHOST_KIT)?;
|
||
let (mut tint, mut alpha) = (None, None);
|
||
for node in kit_row
|
||
.char_procs()
|
||
.filter_map(crate::aura_visual::node_for)
|
||
{
|
||
match node {
|
||
crate::aura_visual::AuraNode::Tint(rgb) => tint = Some(rgb),
|
||
crate::aura_visual::AuraNode::Alpha(a) => alpha = Some(a),
|
||
// Every other proc type the row could carry belongs to a mechanism the select path has
|
||
// no equivalent for (the anim-rate freeze writes bone clocks on a world unit). 989
|
||
// carries only these two; a patched row that carries more is ignored, not guessed at.
|
||
_ => {}
|
||
}
|
||
}
|
||
let kit = GhostKit {
|
||
tint: tint?,
|
||
alpha: alpha.unwrap_or(1.0),
|
||
};
|
||
|
||
// The kit's attached effect models, at the body's own attachment points. `Spells\Ghost_state.mdx`
|
||
// (slot tag 0x13, Base) is the only populated slot on the shipped row and is emitter-only — but
|
||
// the row is data, so geometry and camera-facing cards are carried too, split exactly the way the
|
||
// item-glow arm below splits an item's own model.
|
||
let (mut riders, mut billboards, mut effects) = (Vec::new(), Vec::new(), Vec::new());
|
||
for (tag, effect_id) in kit_row.effects() {
|
||
// The world-plant sentinel names no attachment point; the select build hangs models on the
|
||
// character alone (`0x472780` → `0x712f70`), so there is nowhere for it to go.
|
||
if tag == benilla_formats::WORLD_EFFECT_TAG {
|
||
continue;
|
||
}
|
||
let Some(path) = visuals.effect_path(effect_id) else {
|
||
continue;
|
||
};
|
||
let path = path.to_string();
|
||
// `0x472780`'s `test esi,esi; jl 0x4727dd` rejects a `-1` attach id, and a body with no such
|
||
// point hangs nothing — the world path's rule, and the reference's.
|
||
let Some(point) = body.attachments.iter().find(|a| a.id == tag) else {
|
||
continue;
|
||
};
|
||
let dm =
|
||
fx.models
|
||
.entry(path.clone())
|
||
.or_insert_with(|| super::super::display::DisplayModel {
|
||
handle: super::super::display::ModelHandle::M2(
|
||
asset_server.load(benilla_assets::m2_url(&path)),
|
||
),
|
||
..super::super::display::empty_shell()
|
||
});
|
||
let Some(parts) = dm.parts.as_deref() else {
|
||
*pending = true; // still loading — the caller retries rather than baking half a ghost
|
||
continue;
|
||
};
|
||
for part in parts {
|
||
match &part.billboard {
|
||
Some(info) => billboards.push(PreviewBillboard {
|
||
mesh: part.mesh.clone(),
|
||
material: part.material.clone(),
|
||
bone: point.bone,
|
||
offset: point.offset + info.pivot,
|
||
kind: info.kind,
|
||
twins: BoothTwins {
|
||
blend: part.fade_blend.clone(),
|
||
zfill: part.zfill.clone(),
|
||
},
|
||
}),
|
||
None => riders.push(PreviewRider {
|
||
mesh: part.mesh.clone(),
|
||
material: part.material.clone(),
|
||
bone: point.bone,
|
||
offset: point.offset,
|
||
twins: BoothTwins {
|
||
blend: part.fade_blend.clone(),
|
||
zfill: part.zfill.clone(),
|
||
},
|
||
}),
|
||
}
|
||
}
|
||
if !dm.emitters.is_empty() {
|
||
effects.push(PreviewEffects {
|
||
bone: point.bone,
|
||
offset: point.offset,
|
||
emitters: dm.emitters.clone(),
|
||
});
|
||
}
|
||
}
|
||
Some(GhostArm {
|
||
kit,
|
||
riders,
|
||
billboards,
|
||
effects,
|
||
})
|
||
}
|
||
|
||
fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assembled> {
|
||
let PreviewSpec {
|
||
display_id,
|
||
race,
|
||
sex,
|
||
equipment,
|
||
flags,
|
||
..
|
||
} = *spec;
|
||
let dm = ctx.creatures.models.get(&display_id)?; // model not built yet — retry next frame
|
||
let parts = dm.parts.as_deref()?; // asset still loading
|
||
|
||
let char_look = CharLook {
|
||
race,
|
||
sex,
|
||
skin: spec.skin,
|
||
hair_style: spec.hair_style,
|
||
hair_color: spec.hair_color,
|
||
facial_hair: spec.facial_hair,
|
||
body: BodySkin::Composite { face: spec.face },
|
||
};
|
||
let bodyslots = ENUM_BODYSLOTS.map(|slot| equipment[slot].display_id);
|
||
let cloak = if flags & CHARACTER_FLAG_HIDE_CLOAK != 0 {
|
||
0
|
||
} else {
|
||
equipment[ENUM_CLOAK].display_id
|
||
};
|
||
let helm = if flags & CHARACTER_FLAG_HIDE_HELM != 0 {
|
||
0
|
||
} else {
|
||
equipment[ENUM_HELM].display_id
|
||
};
|
||
let mut held = held_wants(&equipment, helm, race, sex, spec.ranged_in_hand);
|
||
// Per-hand grip `[right, left]`: a hand whose attach point holds a weapon curls into `HandsClosed`
|
||
// (wow-re `hand-grip-mechanism.md` — the ref's paperdoll rule `0x5059a0`: attach-point occupancy, per
|
||
// hand). Resolved here because the flat rider list drops each held item's attach id. A shield sits on
|
||
// the forearm (Shield attach, id 0), never a hand point, so its hand stays open.
|
||
let grip = [
|
||
held.iter().any(|w| w.attach == attach_id::HAND_RIGHT),
|
||
held.iter().any(|w| w.attach == attach_id::HAND_LEFT),
|
||
];
|
||
|
||
// The rider models: register each want with [`ItemDisplays`] (built by
|
||
// `update_display_models` once the M2 loads) and gate the bake on ALL of them being ready —
|
||
// a geared character popping in piecewise would flicker on every selection change.
|
||
if let Some(d) = ctx.displays.as_deref_mut() {
|
||
for w in &held {
|
||
ensure_item_model(d, w.display, w.kind, ctx.asset_server);
|
||
}
|
||
// The glow ids (decision 0805), and the models they imply: **held weapons/shields only**
|
||
// — the reference passes a display's visual from the hand attach and a literal `0` from
|
||
// the helm/shoulder ones (`crate::entities::item_glow`). The world lane resolves this in
|
||
// `resolve_equipment`, which never runs pre-world.
|
||
if let Some(g) = ctx.glows.as_deref_mut() {
|
||
for w in held
|
||
.iter_mut()
|
||
.filter(|w| matches!(w.kind, ItemModelKind::Weapon | ItemModelKind::Shield))
|
||
{
|
||
w.visual = d.catalog.get(w.display).map_or(0, |c| c.item_visual);
|
||
item_glow::ensure_glow_models(g, w.visual, ctx.asset_server);
|
||
}
|
||
}
|
||
if !held.iter().all(|w| {
|
||
d.models
|
||
.get(&(w.display, w.kind))
|
||
.and_then(|m| m.parts.as_ref())
|
||
.is_some()
|
||
}) {
|
||
return None; // an item model is still loading — retry next frame
|
||
}
|
||
// Same gate for the glow models: they are tiny beside the weapon that carries them, and a
|
||
// weapon that pops in ahead of its glow would flicker on every selection change.
|
||
if let Some(g) = ctx.glows.as_deref() {
|
||
let pending = held
|
||
.iter()
|
||
.filter_map(|w| g.effects(w.visual))
|
||
.flat_map(|paths| paths.iter().flatten())
|
||
.any(|p| g.models.get(p).is_none_or(|m| m.parts.is_none()));
|
||
if pending {
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The dressed geoset set: the worn armor's geoset groups + the cloak + the helm's hide-masks
|
||
// (RF-0083), exactly the world path's selection (the shared helper).
|
||
let eg = equip_geosets(ctx.displays.as_deref(), &bodyslots, cloak, helm);
|
||
let visible = ctx.characters.map(|c| {
|
||
c.0.visible_geosets(race, sex, char_look.hair_style, char_look.facial_hair, &eg)
|
||
});
|
||
let char_mats = super::char_skin::build_char_skin_materials(
|
||
&char_look,
|
||
bodyslots,
|
||
cloak,
|
||
spec.emblem,
|
||
ctx.displays.as_deref(),
|
||
ctx.sections,
|
||
ctx.world_assets,
|
||
parts,
|
||
ctx.images,
|
||
&mut ctx.skin_composites.0,
|
||
ctx.asset_server,
|
||
ctx.mats,
|
||
);
|
||
|
||
// Whether this look shows the part's geoset (billboard or body alike).
|
||
let shows = |p: &EntityPart| {
|
||
visible
|
||
.as_ref()
|
||
.is_none_or(|vis| vis.contains(&p.geoset_id))
|
||
};
|
||
let preview_parts: Vec<PreviewPart> = parts
|
||
.iter()
|
||
.filter(|p| p.billboard.is_none() && shows(p))
|
||
.map(|p| PreviewPart {
|
||
static_mesh: p.mesh.clone(),
|
||
skinned_mesh: p.skinned_mesh.clone(),
|
||
material: steady_material(p, &char_mats).unwrap_or_else(|| p.material.clone()),
|
||
twins: part_twins(p, &char_mats),
|
||
// `None` — decision 0807's named gap, deliberately still open: a character batch with
|
||
// an authored dimming constant previews at 1.0. The select body's look is signed off
|
||
// as it stands, so closing it is its own change, not a rider on the pet's.
|
||
alpha_anim: None,
|
||
})
|
||
.collect();
|
||
|
||
// The eye-glow (and any character billboard) — the undead/night-elf glowing eyes. The world
|
||
// splits these into camera-facing cards; the booth is a separate camera, so it can't ride that
|
||
// system — carry them through to the booth spawn, which seats each on its billboard bone's joint
|
||
// and re-faces it ([`crate::portrait::booth::face_booth_billboards`]). Geoset-gated like the body
|
||
// parts: the undead glow rides geoset 302 (the glowing "Features" variants), faithful to the DBC.
|
||
// The batch is fullbright (M2 unlit), so it keeps its built material — no char-skin swap. Offset
|
||
// `ZERO`: the body IS rigged here, so its billboard bone's joint already bakes the pivot.
|
||
let mut preview_billboards: Vec<PreviewBillboard> = parts
|
||
.iter()
|
||
.filter(|p| shows(p))
|
||
.filter_map(|p| {
|
||
let info = p.billboard.as_ref()?;
|
||
Some(PreviewBillboard {
|
||
mesh: p.mesh.clone(),
|
||
material: p.material.clone(),
|
||
bone: info.bone,
|
||
offset: Vec3::ZERO,
|
||
kind: info.kind,
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
// The riders: each want's item-model parts seated at the body's attach point (bone + offset),
|
||
// the world path's `PortraitRider` shape — plus, for a held weapon, the glows its display's
|
||
// `ItemVisuals` id hangs on the weapon's OWN attachment points (decision 0805). The reference
|
||
// reaches those through the same primitive here as in the world (`0x472c91` → `0x47a0c0`
|
||
// hands `ItemDisplayInfo+0x58` to `0x4798c0`), so a permanently-glowing weapon glows on the
|
||
// select screen too; the enum carries no enchant ids (vmangos `BuildEnumData` sends displayId
|
||
// + inventoryType only), so the fork's enchant half simply has no input here.
|
||
//
|
||
// An item model is not just meshes, and for a while this loop treated it as if it were — it kept
|
||
// the batches with no billboard flag and dropped everything else on the floor, so at character
|
||
// select a worn item's own **effects** (its emitters, its camera-facing batches) simply did not
|
||
// exist. That is `#bugs` B118 as nazriel filed it: the R14 PVP shoulders' sparkle is the item
|
||
// model's own emitters (decision 0813), and no part of it reached the booth. So each want
|
||
// contributes up to four things at one seat: plain meshes ([`PreviewRider`]), camera-facing
|
||
// batches ([`PreviewBillboard`]), its emitters ([`PreviewEffects`]), and — held weapons only —
|
||
// the same three off each glow model its `ItemVisuals` names.
|
||
let attach_point = |id: u16| dm.attachments.iter().find(|a| a.id == id);
|
||
let mut riders = Vec::new();
|
||
let mut preview_effects = Vec::new();
|
||
if let Some(d) = ctx.displays.as_deref() {
|
||
for w in &held {
|
||
let Some(point) = attach_point(w.attach) else {
|
||
continue; // the body has no such attach point — hold nothing (world-path rule)
|
||
};
|
||
let Some(item) = d.models.get(&(w.display, w.kind)) else {
|
||
continue; // gated above; belt-and-braces
|
||
};
|
||
let Some(item_parts) = item.parts.as_deref() else {
|
||
continue;
|
||
};
|
||
for p in item_parts.iter() {
|
||
// A camera-facing batch of the item — a wand's gem, a glowing rune (270 of the 2681
|
||
// `Item\` models author one). It can't be drawn as a plain rider mesh: the world
|
||
// splits it into a card, so the booth carries it as one too, seated at the attach
|
||
// point **plus its own model-local pivot** (an item model gets no rig here, so
|
||
// nothing else bakes that pivot).
|
||
match &p.billboard {
|
||
Some(info) => preview_billboards.push(PreviewBillboard {
|
||
mesh: p.mesh.clone(),
|
||
material: p.material.clone(),
|
||
bone: point.bone,
|
||
offset: point.offset + info.pivot,
|
||
kind: info.kind,
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
}),
|
||
None => riders.push(PreviewRider {
|
||
mesh: p.mesh.clone(),
|
||
material: p.material.clone(),
|
||
bone: point.bone,
|
||
offset: point.offset,
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
}),
|
||
}
|
||
}
|
||
// The item model's OWN particle emitters — the R14 PVP pauldron's `SPARKLE` twinkle
|
||
// (`#bugs` B118, decision 0813), the held torch's flame. 95 `Item\` models hang one on a
|
||
// billboard bone alone; the booth spawns them off a host at the attach point, each
|
||
// billboard-chain emitter through a booth-camera frame.
|
||
if !item.emitters.is_empty() {
|
||
preview_effects.push(PreviewEffects {
|
||
bone: point.bone,
|
||
offset: point.offset,
|
||
emitters: item.emitters.clone(),
|
||
});
|
||
}
|
||
let Some(paths) = ctx.glows.as_deref().and_then(|g| g.effects(w.visual)) else {
|
||
continue;
|
||
};
|
||
for (slot, path) in paths
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, p)| p.as_ref().map(|p| (i, p)))
|
||
{
|
||
// The seat: the body's attach point plus where this slot sits on the ITEM's own
|
||
// model. A slot the item model doesn't author hangs nothing — the world lane's
|
||
// rule, and the reference's.
|
||
let at = crate::portrait::attachment_point(
|
||
&item.skeleton,
|
||
&item.attachments,
|
||
slot as u16,
|
||
);
|
||
let (Some(at), Some(glow)) =
|
||
(at, ctx.glows.as_deref().and_then(|g| g.models.get(path)))
|
||
else {
|
||
continue;
|
||
};
|
||
let offset = point.offset + at;
|
||
for p in glow.parts.iter().flatten() {
|
||
// Same split as the item's own batches above. This is not a rare arm:
|
||
// `Spells\Enchantments\Sparkle_A.m2` is *one* additive camera-facing quad and
|
||
// nothing else — no emitters at all — and `ItemVisuals` 28 (10 item displays)
|
||
// hangs it on slot 4, so dropping billboard batches here glowed those items
|
||
// nothing whatsoever at select.
|
||
match &p.billboard {
|
||
Some(info) => preview_billboards.push(PreviewBillboard {
|
||
mesh: p.mesh.clone(),
|
||
material: p.material.clone(),
|
||
bone: point.bone,
|
||
offset: offset + info.pivot,
|
||
kind: info.kind,
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
}),
|
||
None => riders.push(PreviewRider {
|
||
mesh: p.mesh.clone(),
|
||
material: p.material.clone(),
|
||
bone: point.bone,
|
||
offset,
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
}),
|
||
}
|
||
}
|
||
if !glow.emitters.is_empty() {
|
||
preview_effects.push(PreviewEffects {
|
||
bone: point.bone,
|
||
offset,
|
||
emitters: glow.emitters.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Some(Assembled {
|
||
parts: preview_parts,
|
||
riders,
|
||
effects: preview_effects,
|
||
billboards: preview_billboards,
|
||
grip,
|
||
})
|
||
}
|
||
|
||
/// One wanted rider model: which display + model kind, the body attach point it seats on, and —
|
||
/// filled in by the caller once the display catalog is in hand — the `ItemVisuals` id its glow
|
||
/// comes from (`0` for everything that never glows: helm, shoulders, and any weapon whose display
|
||
/// authors none). Decision 0805.
|
||
struct HeldWant {
|
||
display: u32,
|
||
kind: ItemModelKind,
|
||
attach: u16,
|
||
visual: i32,
|
||
}
|
||
|
||
/// The select character's attach-model wants from its enum record: helm + the shoulder pair, and
|
||
/// the weapons **held in the hands** — the byte-verified select build (wow-re
|
||
/// `glue-select-model.md` TU-A, folding back decision 0465's sheathed INTERIM): the builder
|
||
/// forces SheatheType 0 (`0x472c8c`), so the sheath resolver never yields a stow point and the
|
||
/// hand code runs — mainhand to HandRight (attach 1), an off-hand weapon to HandLeft (2), a
|
||
/// shield to the Shield point (0) — and the RANGED slot is skipped outright (`0x472bfe`).
|
||
/// [`placement`]'s melee-drawn arm (`unit_sheath = 1`) is exactly that law, ranged-skip included
|
||
/// (its ranged arm only yields while ranged-drawn). A held off-hand frill (INVTYPE_HOLDABLE)
|
||
/// rides the same hand law — the verdict enumerates weapons/shields only, and the world's held
|
||
/// placement is the natural reading for the rest.
|
||
///
|
||
/// **`ranged_in_hand` is the dressing room's departure from that** (decision 1076): the dress-up
|
||
/// widget does *not* skip the ranged slot, and installs it at a hand. That is the same mapping
|
||
/// [`placement`]'s **ranged-drawn** arm already carries, so the flag simply chooses which sheath
|
||
/// state the ranged slot is asked with — the melee pair is always asked drawn.
|
||
fn held_wants(
|
||
equipment: &[CharEnumItem; 19],
|
||
helm: u32,
|
||
race: u8,
|
||
sex: u8,
|
||
ranged_in_hand: bool,
|
||
) -> Vec<HeldWant> {
|
||
let mut wants = Vec::new();
|
||
if helm != 0 {
|
||
wants.push(HeldWant {
|
||
display: helm,
|
||
kind: ItemModelKind::Helm { race, sex },
|
||
attach: attach_id::HELM,
|
||
visual: 0, // a helm never glows: its attach site pushes a literal 0 (0805)
|
||
});
|
||
}
|
||
let shoulder = equipment[ENUM_SHOULDER].display_id;
|
||
if shoulder != 0 {
|
||
for (kind, attach) in [
|
||
(ItemModelKind::ShoulderLeft, attach_id::SHOULDER_LEFT),
|
||
(ItemModelKind::ShoulderRight, attach_id::SHOULDER_RIGHT),
|
||
] {
|
||
wants.push(HeldWant {
|
||
display: shoulder,
|
||
kind,
|
||
attach,
|
||
visual: 0, // nor do shoulders — same rule
|
||
});
|
||
}
|
||
}
|
||
for (held_slot, enum_slot) in ENUM_HELD.into_iter().enumerate() {
|
||
let item = equipment[enum_slot];
|
||
if item.display_id == 0 {
|
||
continue;
|
||
}
|
||
// Per-slot, because the two arms want opposite sheath states: the melee pair yields while
|
||
// melee-drawn (1), the ranged slot only while ranged-drawn (2). The mannequin asks every
|
||
// slot drawn-melee, which is its own ranged skip; the dressing room asks the ranged slot
|
||
// drawn-ranged, which is the widget's hand install (decision 1076).
|
||
let unit_sheath = if held_slot == 2 && ranged_in_hand {
|
||
2
|
||
} else {
|
||
1
|
||
};
|
||
let Some(attach) = placement(held_slot, item.inventory_type as u32, 0, unit_sheath) else {
|
||
continue;
|
||
};
|
||
let kind = if item.inventory_type == 14 {
|
||
ItemModelKind::Shield
|
||
} else {
|
||
ItemModelKind::Weapon
|
||
};
|
||
wants.push(HeldWant {
|
||
display: item.display_id,
|
||
kind,
|
||
attach,
|
||
visual: 0, // filled by the caller, which holds the display catalog
|
||
});
|
||
}
|
||
wants
|
||
}
|
||
|
||
/// A batch's translucency twins ([`BoothTwins`]) as the preview carries them — the `AlphaMode::Blend`
|
||
/// twin and the depth-prime twin, both already built beside the steady material by
|
||
/// `M2BatchMaterials` (an `EntityPart` carries its own pair; a character composite's are the quint's
|
||
/// third and sixth members). `char_mats` supplies them for a **character** batch, whose steady
|
||
/// material is the composited atlas rather than the part's own built one; every other part answers
|
||
/// from itself.
|
||
///
|
||
/// Only a bake whose instance alpha is below 1 ever draws through them (the ghost select), but they
|
||
/// are collected unconditionally: they cost two handle clones on a bake that happens once per
|
||
/// selection, and a look that resolves them *conditionally* would have to re-assemble on the ghost
|
||
/// edge, which is exactly the sort of second code path the one-alpha law exists to prevent.
|
||
fn part_twins(part: &EntityPart, char_mats: &super::char_skin::CharSkinMaterials) -> BoothTwins {
|
||
// The same slot walk [`steady_material`] does, kept beside it so the pair can never disagree
|
||
// about which quint a batch reads.
|
||
let quint = (|| match part.char_slot {
|
||
Some(CharSkinSlot::Body) => {
|
||
let (single, two) = char_mats.0.as_ref()?;
|
||
Some(if part.two_sided { two } else { single })
|
||
}
|
||
Some(CharSkinSlot::Hair) => char_mats.1.as_ref(),
|
||
Some(CharSkinSlot::Object) => char_mats.2.as_ref(),
|
||
Some(CharSkinSlot::SkinExtra) => {
|
||
let (single, two) = &char_mats.3;
|
||
(if part.two_sided { two } else { single }).as_ref()
|
||
}
|
||
None => None,
|
||
})();
|
||
match quint {
|
||
// The quint's order is the engine's variant set: (steady, interior, fade_blend,
|
||
// interior_bake, interior_bake_blend, zfill) — `char_skin::build_char_skin_materials`.
|
||
Some(q) => BoothTwins {
|
||
blend: Some(q.2.clone()),
|
||
zfill: q.5.clone(),
|
||
},
|
||
// A non-character batch keeps its own built material, so it keeps its own twins.
|
||
None => BoothTwins {
|
||
blend: part.fade_blend.clone(),
|
||
zfill: part.zfill.clone(),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// The steady, world-lit material a character part wears — the body atlas (at the part's sidedness),
|
||
/// the hair texture, the cape, or the extra-skin fur — mirroring the attach swap ([`super`]) but
|
||
/// keeping only the steady variant (the booth wants no fade / interior twin). `None` for a
|
||
/// non-character part (it keeps its built material).
|
||
fn steady_material(
|
||
part: &EntityPart,
|
||
char_mats: &super::char_skin::CharSkinMaterials,
|
||
) -> Option<Handle<WowModelMaterial>> {
|
||
let quint = match part.char_slot {
|
||
Some(CharSkinSlot::Body) => {
|
||
let (single, two) = char_mats.0.as_ref()?;
|
||
if part.two_sided {
|
||
two
|
||
} else {
|
||
single
|
||
}
|
||
}
|
||
Some(CharSkinSlot::Hair) => char_mats.1.as_ref()?,
|
||
Some(CharSkinSlot::Object) => char_mats.2.as_ref()?,
|
||
Some(CharSkinSlot::SkinExtra) => {
|
||
let (single, two) = &char_mats.3;
|
||
(if part.two_sided { two } else { single }).as_ref()?
|
||
}
|
||
None => return None,
|
||
};
|
||
Some(quint.0.clone())
|
||
}
|
||
|
||
/// Assemble the select screen's **pet** — the hunter/warlock companion the enum's pet triple names
|
||
/// (`SMSG_CHAR_ENUM`'s `petDisplayId`, the reference's secondary model `record+0x114`).
|
||
///
|
||
/// Its own system beside [`build_glue_preview`], not a limb of it, for the reason
|
||
/// [`GluePetBake`] is its own resource: a pet is an ordinary creature display with no compositor,
|
||
/// no equipment and no geoset selection between the display cache and the booth, and its model
|
||
/// lands on its own schedule. Folding it into the character's assembly would gate the *character*
|
||
/// on the pet's asset — a body that waits for a wolf to load is a worse screen than a body that is
|
||
/// joined by one a frame later.
|
||
///
|
||
/// Runs after `update_display_models`, whose want-list asked for this display; retries (leaving
|
||
/// `built` false) until its parts build.
|
||
pub(in crate::entities) fn build_glue_pet(
|
||
preview: Res<GluePreview>,
|
||
mut bake: ResMut<GluePetBake>,
|
||
creatures: Option<Res<Creatures>>,
|
||
// The `CreatureFamily` size ramp (decision 1538) — the pet's scale is its family's, read at its
|
||
// level. Absent (the DBC failed to load) ⇒ every pet falls back to its display's own scale
|
||
// product, which is the reference's own family-miss fallthrough.
|
||
families: Option<Res<crate::ui_pet_stats::PetFamilyTables>>,
|
||
mut state: Local<PetState>,
|
||
) {
|
||
let want = preview.look.and_then(|l| l.pet());
|
||
if state.last != want {
|
||
state.last = want;
|
||
state.built = false;
|
||
}
|
||
if state.built {
|
||
return;
|
||
}
|
||
// No pet (every class but a living hunter's or warlock's — the server suppresses the triple
|
||
// for the rest) → clear the bake once and stop.
|
||
let Some(want) = want else {
|
||
if bake.display_id != 0 {
|
||
*bake = GluePetBake {
|
||
revision: bake.revision + 1,
|
||
..default()
|
||
};
|
||
}
|
||
state.built = true;
|
||
return;
|
||
};
|
||
let Some(creatures) = creatures.as_deref() else {
|
||
return;
|
||
};
|
||
let Some(pet) = assemble_pet(creatures, families.as_deref(), want) else {
|
||
return; // the model is still loading — retry next frame
|
||
};
|
||
debug!(
|
||
"glue pet: display {} (family {} level {}) → {} parts, {} camera-facing, scale {:.3}",
|
||
want.display_id,
|
||
want.family,
|
||
want.level,
|
||
pet.parts.len(),
|
||
pet.billboards.len(),
|
||
pet.scale,
|
||
);
|
||
*bake = GluePetBake {
|
||
revision: bake.revision + 1,
|
||
..pet
|
||
};
|
||
state.built = true;
|
||
}
|
||
|
||
/// The pet's parts, straight off the shared display cache: every batch of the creature model, split
|
||
/// into plain meshes and the camera-facing ones (a voidwalker's eyes are the latter). `None` while
|
||
/// the display's model is still building.
|
||
///
|
||
/// No geoset filter: only the character compositor selects among a model's geosets, and a pet is
|
||
/// not a character model — the world's creature path draws every batch of a beast the same way.
|
||
fn assemble_pet(
|
||
creatures: &Creatures,
|
||
families: Option<&crate::ui_pet_stats::PetFamilyTables>,
|
||
want: PetLook,
|
||
) -> Option<GluePetBake> {
|
||
let display_id = want.display_id;
|
||
let model = creatures.models.get(&display_id)?;
|
||
let parts = model.parts.as_deref()?;
|
||
let (billboard_parts, mesh_parts): (Vec<&EntityPart>, Vec<&EntityPart>) =
|
||
parts.iter().partition(|p| p.billboard.is_some());
|
||
Some(GluePetBake {
|
||
display_id,
|
||
parts: mesh_parts
|
||
.iter()
|
||
.map(|p| PreviewPart {
|
||
static_mesh: p.mesh.clone(),
|
||
skinned_mesh: p.skinned_mesh.clone(),
|
||
material: p.material.clone(),
|
||
alpha_anim: p.alpha_anim.clone(),
|
||
// The pet is its own CM2 instance, chained to the SCENE (attachment 1), never to
|
||
// the character (wow-re `glue-select-model.md` §B1) — so it composes onto nothing
|
||
// and is never ghosted with its owner. The twins ride anyway, for the same reason
|
||
// the body's do: they are a property of the batch.
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
})
|
||
.collect(),
|
||
billboards: billboard_parts
|
||
.iter()
|
||
.filter_map(|p| {
|
||
let info = p.billboard.as_ref()?;
|
||
Some(PreviewBillboard {
|
||
mesh: p.mesh.clone(),
|
||
material: p.material.clone(),
|
||
bone: info.bone,
|
||
// `ZERO`: the pet is rigged, so its billboard bone's booth joint already bakes
|
||
// the pivot (the 0130 rig identity) — the same seat the body's eye-glow takes.
|
||
offset: Vec3::ZERO,
|
||
kind: info.kind,
|
||
twins: BoothTwins {
|
||
blend: p.fade_blend.clone(),
|
||
zfill: p.zfill.clone(),
|
||
},
|
||
})
|
||
})
|
||
.collect(),
|
||
// The model's own emitters, carried whole: an imp without its flames is the same kind of
|
||
// wrong as an imp without its wings. `parts` and `emitters` are captured together when the
|
||
// asset lands, so the `parts` gate above already answers for both.
|
||
emitters: model.emitters.clone(),
|
||
// The size: the family's level ramp, and the display's scale product only where the family
|
||
// table can't answer — which is the reference's own order (it computes the product, then
|
||
// overwrites it with the ramp, `0x472e87`). A display neither can size renders at 1.0.
|
||
scale: families
|
||
.and_then(|f| f.families.pet_scale(want.family, want.level))
|
||
.or_else(|| creatures.model_scale(display_id))
|
||
.unwrap_or(1.0),
|
||
revision: 0, // the caller stamps it
|
||
})
|
||
}
|
||
|
||
/// [`build_glue_pet`]'s per-run memory — the pet display it last emitted a bake for, and whether
|
||
/// that bake succeeded (a display whose model wasn't ready keeps retrying).
|
||
#[derive(Default)]
|
||
pub(in crate::entities) struct PetState {
|
||
last: Option<PetLook>,
|
||
built: bool,
|
||
}
|
||
|
||
/// [`build_glue_preview`]'s per-run memory.
|
||
#[derive(Default)]
|
||
pub(in crate::entities) struct PreviewState {
|
||
last_look: Option<GlueLook>,
|
||
built: bool,
|
||
}
|
||
|
||
/// [`build_dressup_preview`]'s per-run memory — the same latch over the dressing room's look.
|
||
#[derive(Default)]
|
||
pub(in crate::entities) struct DressUpState {
|
||
last_look: Option<DressUpLook>,
|
||
built: bool,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// GOLDEN, against the **real shipped 5875 `SpellVisualKit.dbc`** — the one row the reference
|
||
/// hard-codes for the character-select ghost, decoded through the same dispatch point the world
|
||
/// aura uses.
|
||
///
|
||
/// Every number here is byte-VERIFIED in wow-re at the record itself
|
||
/// (`ghost-death-visuals.md` §5a: row 989's `+0x3c` reads
|
||
/// `01 00 00 00 0e 00 00 00 ff ff ff ff ff ff ff ff fd b9 0c 4b 00 00 00 3f`), and the
|
||
/// note records a §5 derivation that got `0x4B0CB53D → (140,181,61)` and was refuted at those
|
||
/// bytes. That is exactly the failure this test exists to catch: a tint transcribed one nibble
|
||
/// wrong is still a plausible pale blue, and nothing but the shipped row can tell them apart.
|
||
///
|
||
/// It also pins the *shape* the select path depends on — one populated effect slot, at
|
||
/// attachment `0x13` (Base) — because "nine ghost sub-models" was this note's own earlier
|
||
/// reading (benilla decision 0469 §6 carried it), corrected 2026-08-21 when `0x472780`'s
|
||
/// `test esi,esi; jl` was seen rejecting the eight `-1`s.
|
||
#[test]
|
||
fn the_shipped_ghost_kit_decodes_to_the_verified_row() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = benilla_formats::open_chain(&data).expect("open the install's MPQ chain");
|
||
let catalog = benilla_formats::load_spell_visual_catalog(&mut chain)
|
||
.expect("SpellVisual/SpellVisualKit");
|
||
let kit = catalog
|
||
.kit(GLUE_GHOST_KIT)
|
||
.expect("the reference hard-codes kit 989; the shipped table has it");
|
||
|
||
let nodes: Vec<_> = kit
|
||
.char_procs()
|
||
.filter_map(crate::aura_visual::node_for)
|
||
.collect();
|
||
assert!(
|
||
nodes.contains(&crate::aura_visual::AuraNode::Tint([140, 185, 253])),
|
||
"kit 989's CharProc 1 is 9222653.0 → 0x8CB9FD → RGB (140,185,253); got {nodes:?}"
|
||
);
|
||
assert!(
|
||
nodes
|
||
.iter()
|
||
.any(|n| matches!(n, crate::aura_visual::AuraNode::Alpha(a) if *a == 0.5)),
|
||
"kit 989's CharProc 14 is 0.5; got {nodes:?}"
|
||
);
|
||
|
||
// Exactly ONE populated slot, and it is the Base attach point — not nine.
|
||
let effects: Vec<_> = kit.effects().collect();
|
||
assert_eq!(effects.len(), 1, "kit 989 fills one slot, got {effects:?}");
|
||
let (tag, effect_id) = effects[0];
|
||
assert_eq!(tag, 0x13, "the Base attachment (KIT_SLOT_TAGS[2])");
|
||
let path = catalog
|
||
.effect_path(effect_id)
|
||
.expect("SpellVisualEffectName row for the ghost state model")
|
||
.to_ascii_lowercase();
|
||
assert!(
|
||
path.replace('\\', "/").ends_with("spells/ghost_state.mdx"),
|
||
"the ghost state model, got {path:?}"
|
||
);
|
||
}
|
||
|
||
/// **The bug the imp filed** (decision 1539): a pet is a *creature* display, and creature models
|
||
/// routinely author their own particle emitters — the Imp's three flame jets, the Voidwalker's
|
||
/// four smoke plumes. `assemble_pet` read `parts` and nothing else, so they never reached the
|
||
/// booth and the select screen stood a grey imp beside the warlock.
|
||
///
|
||
/// Asserted as "everything the display cache holds arrives", not "three emitters arrive": the
|
||
/// failure mode was a *dropped field*, and a count pinned to one model would pass just as
|
||
/// silently the next time one is added. The billboard-chain emitter is in the fixture because
|
||
/// it is the branch that survives a booth's `without_camera_billboards` only through the frame
|
||
/// [`crate::portrait::spawn_booth_own_emitters`] spawns for it.
|
||
#[test]
|
||
fn a_pet_carries_every_emitter_its_display_authored() {
|
||
let emitter = |bone: u16, billboard: Option<u16>| benilla_assets::ModelEmitter {
|
||
def: benilla_formats::ParticleEmitterDef {
|
||
bone,
|
||
..benilla_world::testing::plain_particle_def()
|
||
},
|
||
texture: None,
|
||
bone_pivot: [0.0; 3],
|
||
billboard: billboard.map(|b| benilla_assets::EmitterBillboard {
|
||
kind: benilla_formats::BillboardKind::Spherical,
|
||
pivot: [0.0, 0.0, 1.0],
|
||
bone: b,
|
||
}),
|
||
recursion: None,
|
||
geometry: None,
|
||
owner_reach: 0.0,
|
||
water_bound: (Vec3::ZERO, 0.0),
|
||
idle_seq: usize::from(bone),
|
||
};
|
||
let mut dm = crate::entities::display::empty_shell();
|
||
// Built, and it drew nothing of its own — the emitters are the whole model here. An empty
|
||
// `parts` list is still `Some`, which is what "the asset landed" means to the gate.
|
||
dm.parts = Some(Vec::new());
|
||
dm.emitters = vec![emitter(17, None), emitter(45, None), emitter(51, Some(42))];
|
||
let mut creatures = Creatures {
|
||
catalog: Default::default(),
|
||
models: std::collections::HashMap::new(),
|
||
};
|
||
creatures.models.insert(4449, dm);
|
||
|
||
let bake = assemble_pet(
|
||
&creatures,
|
||
None,
|
||
PetLook {
|
||
display_id: 4449,
|
||
level: 60,
|
||
family: 0,
|
||
},
|
||
)
|
||
.expect("a display whose parts have landed assembles");
|
||
assert_eq!(
|
||
bake.emitters.len(),
|
||
3,
|
||
"every emitter the display holds must reach the bake, not a subset"
|
||
);
|
||
assert_eq!(
|
||
bake.emitters.iter().map(|e| e.def.bone).collect::<Vec<_>>(),
|
||
[17, 45, 51],
|
||
"and in file order, on their own bones"
|
||
);
|
||
assert_eq!(
|
||
bake.emitters[2].billboard.map(|b| b.bone),
|
||
Some(42),
|
||
"the billboard host bone rides along — a booth needs it to seat the frame"
|
||
);
|
||
|
||
// A display still loading (`parts: None`) yields nothing at all rather than a pet with
|
||
// emitters and no body: the retry latch is what stands it up a frame later.
|
||
let mut loading = crate::entities::display::empty_shell();
|
||
loading.emitters = vec![emitter(17, None)];
|
||
creatures.models.insert(4450, loading);
|
||
assert!(
|
||
assemble_pet(
|
||
&creatures,
|
||
None,
|
||
PetLook {
|
||
display_id: 4450,
|
||
level: 60,
|
||
family: 0
|
||
}
|
||
)
|
||
.is_none(),
|
||
"emitters alone are not a pet"
|
||
);
|
||
}
|
||
|
||
/// The InventoryType → equipment-slot map lands each worn kind where the Select pipeline reads
|
||
/// it, and drops the non-rendered kinds. Pinned against the real Human Warrior recruit set
|
||
/// (byte-verified in decision 0527): shirt→body(3), pants→legs(6), boots→feet(7), the mainhand
|
||
/// sword→15, the shield→offhand(16); the outfit's food/hearthstone (inv 0) drop out.
|
||
#[test]
|
||
fn equip_slot_maps_the_recruit_set_and_drops_non_worn() {
|
||
assert_eq!(equip_slot(4), Some(ENUM_BODYSLOTS[0])); // shirt (BODY) → slot 3
|
||
assert_eq!(equip_slot(7), Some(ENUM_BODYSLOTS[3])); // pants (LEGS) → slot 6
|
||
assert_eq!(equip_slot(8), Some(ENUM_BODYSLOTS[4])); // boots (FEET) → slot 7
|
||
assert_eq!(equip_slot(21), Some(ENUM_HELD[0])); // WEAPONMAINHAND → main hand (15)
|
||
assert_eq!(equip_slot(14), Some(ENUM_HELD[1])); // SHIELD → off hand (16)
|
||
assert_eq!(equip_slot(20), Some(ENUM_BODYSLOTS[1])); // ROBE shares the chest slot (4)
|
||
assert_eq!(equip_slot(1), Some(ENUM_HELM)); // HEAD → helm slot
|
||
assert_eq!(equip_slot(3), Some(ENUM_SHOULDER)); // SHOULDERS → shoulder slot
|
||
assert_eq!(equip_slot(16), Some(ENUM_CLOAK)); // BACK → cloak slot
|
||
assert_eq!(equip_slot(19), Some(ENUM_BODYSLOTS[7])); // TABARD → slot 18
|
||
assert_eq!(equip_slot(15), Some(ENUM_HELD[2])); // RANGED → ranged slot (17)
|
||
// Non-worn / non-rendered kinds drop out entirely.
|
||
for inv in [0u8, 11, 12, 18, 24, 27, 28] {
|
||
assert_eq!(equip_slot(inv), None, "inv {inv} should not map to a slot");
|
||
}
|
||
// Every mapped slot is a valid index into the 19-slot enum array.
|
||
for inv in 0u8..=30 {
|
||
if let Some(slot) = equip_slot(inv) {
|
||
assert!(slot < 19, "inv {inv} → slot {slot} out of range");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **The two held laws, side by side** (decision 1076). The character-select mannequin skips
|
||
/// `EQUIPMENT_SLOT_RANGED` outright (`0x472bfe`); the dressing-room widget installs it at a
|
||
/// hand, by the world's own split — bow (INVTYPE_RANGED 15) to HandLeft, gun/crossbow/wand
|
||
/// (26) and thrown (25) to HandRight.
|
||
///
|
||
/// Asserted per inventory type rather than once, because a one-sided implementation — placing
|
||
/// every ranged type at the same hand — is the plausible wrong answer and it still shows *a*
|
||
/// bow, which is all the report asked for.
|
||
#[test]
|
||
fn the_dressing_room_hands_a_ranged_weapon_where_the_mannequin_drops_it() {
|
||
let ranged = |inv: u8| {
|
||
let mut e = [CharEnumItem::default(); 19];
|
||
e[ENUM_HELD[2]] = CharEnumItem {
|
||
display_id: 8500,
|
||
inventory_type: inv,
|
||
};
|
||
e
|
||
};
|
||
for (inv, hand, what) in [
|
||
(15u8, attach_id::HAND_LEFT, "bow"),
|
||
(26, attach_id::HAND_RIGHT, "gun/crossbow/wand"),
|
||
(25, attach_id::HAND_RIGHT, "thrown"),
|
||
] {
|
||
let e = ranged(inv);
|
||
assert!(
|
||
held_wants(&e, 0, 1, 0, false).is_empty(),
|
||
"the select mannequin skips the ranged slot ({what})"
|
||
);
|
||
let wants = held_wants(&e, 0, 1, 0, true);
|
||
assert_eq!(wants.len(), 1, "the dressing room holds the {what}");
|
||
assert_eq!(wants[0].attach, hand, "{what} rides the right hand point");
|
||
assert_eq!(wants[0].display, 8500);
|
||
}
|
||
}
|
||
|
||
/// …and the flag touches ONLY the ranged slot: the melee pair is asked drawn either way, so a
|
||
/// dressing-room look still hands a sword to HandRight and a shield to the Shield point.
|
||
#[test]
|
||
fn the_ranged_flag_leaves_the_melee_pair_alone() {
|
||
let mut e = [CharEnumItem::default(); 19];
|
||
e[ENUM_HELD[0]] = CharEnumItem {
|
||
display_id: 5500,
|
||
inventory_type: 21, // WEAPONMAINHAND
|
||
};
|
||
e[ENUM_HELD[1]] = CharEnumItem {
|
||
display_id: 5600,
|
||
inventory_type: 14, // SHIELD
|
||
};
|
||
for ranged_in_hand in [false, true] {
|
||
let wants = held_wants(&e, 0, 1, 0, ranged_in_hand);
|
||
let at = |display: u32| {
|
||
wants
|
||
.iter()
|
||
.find(|w| w.display == display)
|
||
.map(|w| w.attach)
|
||
};
|
||
assert_eq!(at(5500), Some(attach_id::HAND_RIGHT));
|
||
assert_eq!(at(5600), Some(attach_id::SHIELD));
|
||
}
|
||
}
|
||
}
|