- 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>
3539 lines
178 KiB
Rust
3539 lines
178 KiB
Rust
//! Unit-frame **portraits** — the modern 2D take on the player/target face windows.
|
||
//!
|
||
//! The real 1.12 client renders a unit's model **once** into a tiny (64²) off-screen texture and
|
||
//! freezes it (re-baked only on model change), then stamps a round alpha stencil into it; the low
|
||
//! resolution is a 2004 shortcut, not a look (wow-re `system/ui/scratch/portrait-render.md`, §5-verified).
|
||
//! benilla keeps the *idea* — a flat 2D face in the ring — but bakes it properly: a **high-resolution**
|
||
//! off-screen render of the unit's real model, so the still is crisp. No live 3D widget sits in the UI;
|
||
//! what lands in the circle is a plain rendered image (director's call).
|
||
//!
|
||
//! ## The "photo booth"
|
||
//!
|
||
//! Each portrait slot (`"player"`/`"target"`) gets its own render layer + camera rendering into a
|
||
//! [`PortraitImages`] entry. A third slot — `"paperdoll"` (decision 0208 §5) — reuses the exact same
|
||
//! bake pipeline for the character window's **full-body** model pane: it mirrors the *player's*
|
||
//! dressed look like the `"player"` slot, but frames it through the model's own `<PlayerModel>`
|
||
//! camera ([`framing::body_frame`] — raw `cameras[1]`, not the authored bust camera the round
|
||
//! portraits use; decision 1089), bakes at 512², and spins the
|
||
//! model to a live yaw ([`PaperDollBooth`], the ref's `Model:SetRotation`). The UI samples it
|
||
//! *square*, not through the circular mask.
|
||
//!
|
||
//! The parts baked are the unit's **live dressed look** — the attach path's
|
||
//! spawned children (geosets already appearance-filtered; materials already carrying the composited
|
||
//! body skin / hair / NPC atlas), each stamped with a [`PortraitPart`] naming its static bind-pose
|
||
//! mesh twin + steady exterior material. Mirroring the children (not the shared display cache) means
|
||
//! the portrait can never drift from what's standing in the world, and gear/appearance rebuilds
|
||
//! re-bake automatically (the parts key changes). While a live unit's model is still loading, the
|
||
//! slot shows the ref's own 2D stand-in (`TemporaryPortrait-{Sex}-{Race}` / `-Monster`, RE C5) via
|
||
//! [`PortraitSource::File`].
|
||
//!
|
||
//! ## Framing: the model's own authored camera
|
||
//!
|
||
//! The framing is the model's **authored portrait camera** — the MD20 camera `cameraLookup[0]`
|
||
//! selects (VERIFIED, wow-re `system/ui/scratch/portrait-render.md` §4 + corrected verdict
|
||
//! `aa186e79`): the real bake builds `lookAt(eye, target, up-from-roll)` + the gxumath
|
||
//! *diagonal-FOV* perspective at the portrait path's aspect, which is **1.0 on every screen** —
|
||
//! net vertical half-angle `fov/(2√2)`, isotropic (`framing::WowPortraitProjection`, decision
|
||
//! 1543) — and **no** engine-side yaw or normalization on top. Every artist calibrated camera 0 to their
|
||
//! own model — that is the whole mechanism behind the ref's uniformly tight, consistently-angled
|
||
//! face crops across humans, wolves, and rabbits. It supersedes the first RE verdict's C4 ("framing
|
||
//! is not model data"), corrected on the wow-re record. A camera-less model (a few creatures,
|
||
//! props) falls back to [`frame`]'s heuristic head-anchor framing.
|
||
//!
|
||
//! ## Pose: a fresh instance at Stand
|
||
//!
|
||
//! The bake is **posed like the ref's** (wow-re §4 D2): a fresh throwaway instance — the booth's
|
||
//! own joint hierarchy + the parts' skinned twins — armed to the model's Stand (anim id 0 through
|
||
//! its own baked resolution, the ref's loader-idle seed) and frozen, never the unit's live world
|
||
//! pose. Bone riders (helm/shoulder armor, held items) ride their bone's joint, so they sit in
|
||
//! the Stand pose exactly like the world instance ([`PortraitRider`]; the ref resets the attach
|
||
//! *sockets* the same way, RE C3). See [`spawn_booth_model`].
|
||
//!
|
||
//! ## Deviations from the ref (deliberate) and what's still coarse
|
||
//!
|
||
//! High-res (256² vs 64²), a fixed neutral **studio light** on the round portraits (vs the ref's
|
||
//! ambient state — the *body* panes instead carry the reference's own `<PlayerModel>` light, see
|
||
//! [`model_pane_light`]), continuous
|
||
//! booth render vs dirty-byte bake, and the frozen Stand *phase* is t=0 (the ref's sampling clock
|
||
//! is the verdict's one unsettled INFERRED point — t≈0 vs live phase; both are Stand, and t=0
|
||
//! reproduces the ref wolf's open mouth). The creature *loading* stand-in is `-Monster` (our
|
||
//! pick — the ref's `-Pet` belongs to its pet-frame delegate).
|
||
//! `WOW_PORTRAIT_TEST=<Model\Path.mdx>` (+ `WOW_PORTRAIT_TEST_SKIN=<blp>`) bakes that model into
|
||
//! every slot (both portraits + the paper doll's body framing) to eyeball the pipeline without a server.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use bevy::asset::RenderAssetUsages;
|
||
use bevy::camera::visibility::RenderLayers;
|
||
use bevy::camera::{PerspectiveProjection, Projection, RenderTarget};
|
||
use bevy::core_pipeline::tonemapping::Tonemapping;
|
||
use bevy::ecs::system::SystemParam;
|
||
use bevy::prelude::*;
|
||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
|
||
use bevy::render::view::Msaa;
|
||
|
||
use crate::entities::Creatures;
|
||
use crate::net::{NetEntity, SelfPlayer};
|
||
use crate::target::Selection;
|
||
use benilla_assets::materials::WowModelMaterial;
|
||
|
||
mod framing;
|
||
pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
|
||
use framing::{body_frame, frame, PORTRAIT_FOV};
|
||
mod booth;
|
||
/// The translucency twins ride the *preview* shapes as well as the booth ones — one type for both
|
||
/// halves of the same batch, so the entities-side assembly and the bake cannot disagree about what
|
||
/// a batch can feather through.
|
||
pub(crate) use booth::BoothTwins;
|
||
use booth::{
|
||
clear_booth_rig, spawn_booth_effects, spawn_booth_model, spawn_booth_own_emitters,
|
||
BoothBillboardSpec, BoothEffects, BoothInstance, BoothMotion, BoothPart, BoothRider,
|
||
};
|
||
mod dressup;
|
||
pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview};
|
||
mod glue_booth;
|
||
pub(crate) use glue_booth::{
|
||
CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook,
|
||
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
|
||
};
|
||
mod light;
|
||
use light::{material_variant, model_pane_light, studio_light, BoothLight};
|
||
mod test_bake;
|
||
|
||
/// The portrait slots we bake, each with its own render layer/camera: the player + target unit
|
||
/// frames, `"pet"` (decision 0990's frame), and `"npc"` — the NPC an interaction window (gossip /
|
||
/// quest / merchant / trainer / taxi) is bound to ([`crate::ui_session::InteractNpc`]), so those
|
||
/// windows show the creature's face instead of the `?` placeholder.
|
||
/// `"targettarget"` (decision 1576) is the ninth and the odd one: it is the only slot whose unit
|
||
/// resolution is gated on the UI actually drawing it — see [`sync_portraits`]'s arm for why.
|
||
const SLOTS: [&str; 9] = [
|
||
"player",
|
||
"target",
|
||
"targettarget",
|
||
"pet",
|
||
"npc",
|
||
"party1",
|
||
"party2",
|
||
"party3",
|
||
"party4",
|
||
];
|
||
/// The character-window **paper-doll** slot (decision 0208 §5): a full-body bake of the dressed
|
||
/// player, sampled *square* (not circular) by the character frame's model pane. Its own booth —
|
||
/// separate resolution ([`PAPERDOLL_SIZE`]), body framing ([`framing::body_frame`]), and a live yaw
|
||
/// ([`PaperDollBooth`]) — so the two portrait slots stay pixel-identical.
|
||
const PAPERDOLL_SLOT: &str = "paperdoll";
|
||
/// The **inspect** window's model pane (decision 0631 §4) — the same full-body bake as
|
||
/// [`PAPERDOLL_SLOT`], pointed at *another* player. It is the composition of what the existing
|
||
/// slots already do separately: body framing like the paper doll, an arbitrary unit like
|
||
/// `"target"`. Both run through [`sync_body_booth`]; only the unit and the yaw differ.
|
||
const INSPECT_SLOT: &str = "inspect";
|
||
/// The **pet paper doll**'s model pane (decision 1057) — the character window's tab 2. Composed
|
||
/// exactly like [`INSPECT_SLOT`] (body framing + an arbitrary unit + a live yaw), pointed at the
|
||
/// pet instead of at another player; it too runs through [`sync_body_booth`]. It gets its own booth
|
||
/// rather than sharing the `"pet"` portrait slot because that one is a 256² *bust* for the pet unit
|
||
/// frame — different subject framing, different resolution, and a yaw the portrait must never have.
|
||
const PETDOLL_SLOT: &str = "petdoll";
|
||
/// The **stable window**'s model pane (wow-re `ui/scratch/stable-master-window.md` §7.1) — the
|
||
/// fourth body pane, and the only one whose subject may have no world object at all.
|
||
/// `SetPetStablePaperdoll` forks on the selection: `-1` (the summoned pet) resolves the live pet by
|
||
/// GUID and takes its model, anything else maps the petNumber back to a slot and goes through the
|
||
/// **creature cache**, i.e. a bare `CreatureDisplayInfo` id for a pet that is asleep in a stable and
|
||
/// has no unit anywhere. Both forks land in the same booth here: the live one points
|
||
/// [`StableBooth::unit`] at the pet entity ([`PETDOLL_SLOT`]'s case exactly), the cached one points
|
||
/// [`StableBooth::display_id`] at the display and the mirror comes off a [`PortraitStandIn`].
|
||
const STABLE_SLOT: &str = "stable";
|
||
/// World is layer 0, the UI quad pass layer 1; portraits sit on their own high layers so nothing in the
|
||
/// world leaks into a booth and vice-versa (one layer per slot: base, base+1, …).
|
||
const PORTRAIT_LAYER_BASE: usize = 2;
|
||
/// The paper-doll booth's render layer — the next layer past the portrait slots.
|
||
const PAPERDOLL_LAYER: usize = PORTRAIT_LAYER_BASE + SLOTS.len();
|
||
/// The inspect booth's render layer — the next one past the paper doll's.
|
||
const INSPECT_LAYER: usize = PAPERDOLL_LAYER + 1;
|
||
/// The pet-doll booth's render layer — the next one past inspect's (the one ladder below).
|
||
const PETDOLL_LAYER: usize = INSPECT_LAYER + 1;
|
||
/// The stable pane's render layer — the next one past the pet doll's, by the ladder rule below.
|
||
const STABLE_LAYER: usize = PETDOLL_LAYER + 1;
|
||
/// The glue booth's render layer — the next one past inspect's. **Every booth layer is computed
|
||
/// HERE**, in one ladder, because they were not: the glue booth (`930b327c`) and the inspect booth
|
||
/// (`ead3b0c9`) each defined "the next layer past the paper doll's" in a different file and landed
|
||
/// on the same number. Two booths sharing a layer is not a cosmetic clash — `particles::sim`
|
||
/// resolves an emitter's booth camera by *finding the first camera whose layers intersect*, so the
|
||
/// glue scene's 28 emitters addressed the INSPECT camera, which is off at the glue screens, and the
|
||
/// login screen's braziers simulated forever without ever being drawn (decision 0775).
|
||
pub(super) const GLUE_LAYER: usize = STABLE_LAYER + 1;
|
||
/// The **dressing room**'s render layer (decision 1060) — the next one past the glue booth's, by
|
||
/// the same ladder rule.
|
||
pub(super) const DRESSUP_LAYER: usize = GLUE_LAYER + 1;
|
||
/// The pipe_warm **twin booth**'s render layer — the next one past the dressing room's (same ladder
|
||
/// rule as [`GLUE_LAYER`]). This camera exists only while the warm pass runs
|
||
/// ([`spawn_warm_booth`]); nothing but menagerie rigs ever rides its layer.
|
||
pub(crate) const WARM_BOOTH_LAYER: usize = DRESSUP_LAYER + 1;
|
||
/// The **minimap interior composite**'s render layer (decision 1466) — the next one past the warm
|
||
/// booth's. Not a portrait booth, but it is an offscreen camera with its own layer, and 0775's rule
|
||
/// is that EVERY such layer is computed in this one ladder: the two booths that each worked out
|
||
/// "the next layer past the paper doll's" in their own file landed on the same number, and the
|
||
/// clash was silent in both rendering and the emitter→camera match.
|
||
pub(crate) const MINIMAP_COMPOSITE_LAYER: usize = WARM_BOOTH_LAYER + 1;
|
||
|
||
// The ladder must stay collision-free: a booth camera's layer is its identity for both rendering
|
||
// and the emitter→camera match, and the failure above was silent in both.
|
||
const _: () = assert!(
|
||
PAPERDOLL_LAYER != INSPECT_LAYER
|
||
&& INSPECT_LAYER != PETDOLL_LAYER
|
||
&& PETDOLL_LAYER != STABLE_LAYER
|
||
&& STABLE_LAYER != GLUE_LAYER
|
||
&& PAPERDOLL_LAYER != STABLE_LAYER
|
||
&& INSPECT_LAYER != STABLE_LAYER
|
||
&& PETDOLL_LAYER != GLUE_LAYER
|
||
&& PAPERDOLL_LAYER != PETDOLL_LAYER
|
||
&& PAPERDOLL_LAYER != GLUE_LAYER
|
||
&& INSPECT_LAYER != GLUE_LAYER
|
||
&& DRESSUP_LAYER > GLUE_LAYER
|
||
&& WARM_BOOTH_LAYER > DRESSUP_LAYER
|
||
&& MINIMAP_COMPOSITE_LAYER > WARM_BOOTH_LAYER,
|
||
"booth render layers must be distinct — see GLUE_LAYER"
|
||
);
|
||
const _: () = assert!(
|
||
PAPERDOLL_LAYER > PORTRAIT_LAYER_BASE + SLOTS.len() - 1,
|
||
"booth layers must not overlap the per-slot portrait layers"
|
||
);
|
||
/// The baked image is high-res (vs the ref's 64²) — the crisp modern look. Square; the UI quad
|
||
/// shader cuts the inscribed circle at draw time (`ui_quad.wgsl`'s `circular`, the ref's stencil).
|
||
const PORTRAIT_SIZE: u32 = 256;
|
||
/// The paper-doll bake resolution. The pane draws ~233×224 points; at 2× hidpi that is ≈466×448, so
|
||
/// 512² covers it crisply with a little to spare (and, being sampled square, wants more than the
|
||
/// portraits' 256² for the taller full-body subject).
|
||
const PAPERDOLL_SIZE: u32 = 512;
|
||
/// Stamped on every spawned unit-model part child by the attach path ([`crate::entities`]): the
|
||
/// part's two mesh twins — the booth poses the **skinned** twin at Stand on its own throwaway
|
||
/// skeleton (the ref bake, wow-re §4 D2), the **static** bind-pose twin serving the boneless
|
||
/// fallback — and its **steady exterior material** (the child may currently wear the appear-fade
|
||
/// blend or interior variant; a portrait always wants the steady look). The booth mirrors a unit's
|
||
/// `PortraitPart` children — the exact dressed look standing in the world.
|
||
#[derive(Component)]
|
||
pub(crate) struct PortraitPart {
|
||
pub(crate) static_mesh: Handle<Mesh>,
|
||
/// `None` for a WMO-display part (never skins) — the booth then draws the static twin.
|
||
pub(crate) skinned_mesh: Option<Handle<Mesh>>,
|
||
pub(crate) material: Handle<WowModelMaterial>,
|
||
}
|
||
|
||
/// Stamped on every spawned **bone-rider** mesh child (helm / shoulder / held item —
|
||
/// [`crate::entities::equipment`]): the mesh + steady material like [`PortraitPart`], plus where it
|
||
/// sits — the body bone it rides and the attach-point offset under that bone. The posed booth
|
||
/// seats the rider under its throwaway skeleton's joint (so it rides the Stand pose exactly like
|
||
/// the world instance rides its gait); the ref resets the attach *sockets* the same way (RE C3).
|
||
///
|
||
/// It also rides a **mirror-only carrier** — a marker child that draws nothing — where the world
|
||
/// geometry it names is not a unit descendant we can stamp: an item glow's own render batches, which
|
||
/// the shared effect lane spawns under its own rig ([`crate::entities::item_glow`], decision 0822).
|
||
/// A booth bakes those at the bind pose, like any other rider.
|
||
#[derive(Component)]
|
||
pub(crate) struct PortraitRider {
|
||
pub(crate) static_mesh: Handle<Mesh>,
|
||
pub(crate) material: Handle<WowModelMaterial>,
|
||
/// The body-skeleton bone the rider's joint entity belongs to.
|
||
pub(crate) bone: u16,
|
||
/// The attach point's Bevy-space offset under that bone ([`crate::entities::BoneAttach`]).
|
||
pub(crate) offset: Vec3,
|
||
/// The **M2 attachment id** this rider's model hangs from on the body — what
|
||
/// [`attach_reset`] filters on. `None` where the geometry is the host model's own and sits
|
||
/// in no attachment node at all (see [`PortraitBillboard::attach`]).
|
||
pub(crate) attach: Option<u16>,
|
||
}
|
||
|
||
/// Stamped on a lightweight **anchor child** the attach path plants under a unit for each
|
||
/// camera-facing batch it dresses in. The visible world card is a *root-spawned* entity
|
||
/// ([`benilla_world::billboard::BillboardCard`]), never a unit descendant, so it can't be mirrored; this
|
||
/// marker rides the unit's tree purely so the portrait / paper-doll booths (which mirror the unit's
|
||
/// dressed descendants) can rebuild the batch as a booth card ([`BoothBillboardSpec`] +
|
||
/// [`booth::face_booth_billboards`]) — the same reconstruction the char-create glue path does from
|
||
/// its own parts ([`PreviewBillboard`]). Carries the centred quad, its material, where it sits, and
|
||
/// the billboard flag.
|
||
///
|
||
/// Three sources plant one, exactly as on the glue path (decision 0822):
|
||
///
|
||
/// - The character's own **eye-glow** (undead/night-elf, geoset 302 / geoset 0 — a fullbright quad
|
||
/// on the eye bone), planted by [`crate::entities::attach`].
|
||
/// - An **equipped item's** camera-facing batch — a wand's gem, the held torch's `GLOWWHITE32` halo
|
||
/// (270 of the 2681 `Item\` models author one), planted by
|
||
/// [`crate::entities::equipment`]'s attach.
|
||
/// - An **item glow's** batch (decision 0805) — `Spells\Enchantments\Sparkle_A.m2` is one additive
|
||
/// quad and nothing else — planted by [`crate::entities::item_glow`].
|
||
#[derive(Component)]
|
||
pub(crate) struct PortraitBillboard {
|
||
pub(crate) mesh: Handle<Mesh>,
|
||
pub(crate) material: Handle<WowModelMaterial>,
|
||
/// The BODY bone whose booth joint the card seats on — the eye bone for the character's own
|
||
/// glow, the item's attach-point bone for anything an item wears.
|
||
pub(crate) bone: u16,
|
||
pub(crate) seat: PortraitSeat,
|
||
pub(crate) kind: benilla_formats::BillboardKind,
|
||
/// The **M2 attachment id** the batch's model hangs from on the body, for [`attach_reset`].
|
||
/// `None` for a batch of the host model ITSELF (the character's own eye-glow): the reference's
|
||
/// reset walks the duplicate's *attachment list*, and a geoset batch of the body is not in it.
|
||
pub(crate) attach: Option<u16>,
|
||
}
|
||
|
||
/// Whose model a mirrored camera-facing batch belongs to — which decides both where its pivot comes
|
||
/// from and whether a *mounted* unit's booth keeps it.
|
||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||
pub(crate) enum PortraitSeat {
|
||
/// A batch of the **rigged host model** itself (the eye-glow). Its bone's joint frame already
|
||
/// bakes the bone pivot (the 0130 rig identity), so it needs no offset — and it belongs to that
|
||
/// model's body, so a mount's own glow card prunes with the mount's meshes (a portrait shows the
|
||
/// rider alone, never the horse — decision 0441).
|
||
Body,
|
||
/// A batch of a **rig-less rider** — an equipped item's, an item glow's — at its seat in the
|
||
/// bone's joint frame (Bevy axes): the attach point **plus the batch's own model-local pivot**,
|
||
/// because with no rig nothing else will bake that pivot. Collected like a [`PortraitRider`]
|
||
/// whatever the mount state: while mounted, the character's own joints re-root INSIDE the mount
|
||
/// subtree, and the rider's gear must survive that.
|
||
Rider(Vec3),
|
||
}
|
||
|
||
impl PortraitSeat {
|
||
/// The offset a booth seats this batch at under its bone's joint.
|
||
pub(crate) fn offset(self) -> Vec3 {
|
||
match self {
|
||
PortraitSeat::Body => Vec3::ZERO,
|
||
PortraitSeat::Rider(at) => at,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Stamped on an **effect-bearing model** riding a unit — the equipped item whose own emitters are
|
||
/// its whole look (the R14 PVP pauldron's `SPARKLE` twinkle, the held torch's flame — decision 0813,
|
||
/// `#bugs` B118) and the `ItemVisuals` glow a held weapon hangs on its own attachment points
|
||
/// (decision 0805). The world emitters are *free* entities the owner contract walks
|
||
/// ([`benilla_world::particles::spawn_emitter`]), never unit descendants, so — like [`PortraitBillboard`] —
|
||
/// this marker is how a booth learns they exist at all: the mirror carries the emitter records plus
|
||
/// the composed seat, and the booth spawns its own copies against its own camera
|
||
/// ([`booth::spawn_booth_effects`]). The glue path's [`PreviewEffects`] is the same carry assembled
|
||
/// from data instead of mirrored from entities.
|
||
///
|
||
/// Which booths act on it is a *fidelity* split, not a cost one — see
|
||
/// [`booth::spawn_booth_effects`]: the body panes are live `<PlayerModel>` widgets in the reference,
|
||
/// the round portraits a one-shot cached bake.
|
||
#[derive(Component)]
|
||
pub(crate) struct PortraitEffects {
|
||
/// The BODY bone whose booth joint the effect model's host seats on.
|
||
pub(crate) bone: u16,
|
||
/// The **M2 attachment id** the effect-bearing model hangs from on the body, for
|
||
/// [`attach_reset`]. Every publisher of this marker is an item's model, so it is always
|
||
/// `Some`; the option shape is the other two markers'.
|
||
pub(crate) attach: Option<u16>,
|
||
/// The host's offset in that bone's joint frame (Bevy axes) — the item's attach point, plus the
|
||
/// glow slot's offset on the item's own model when this is a glow.
|
||
pub(crate) offset: Vec3,
|
||
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
|
||
}
|
||
|
||
/// Stamped on a **stand-in subject** — a bodiless entity that exists only to be mirrored, whose
|
||
/// [`PortraitPart`] / [`PortraitBillboard`] children are assembled straight from the display cache
|
||
/// ([`crate::entities::Creatures::display_mirror`]) instead of from a unit's attach path.
|
||
///
|
||
/// It is what lets a body booth be pointed at a creature that has **no world object at all** — the
|
||
/// stable window's stabled pet, which is a row in the server's character-pet cache and a
|
||
/// `CreatureDisplayInfo` id, nothing more. The alternative would have been a second bake path
|
||
/// beside [`sync_body_booth`]; a subject entity means the *existing* one runs verbatim, so the
|
||
/// summoned-pet case and the stabled case cannot drift apart in framing, lighting, yaw or settle.
|
||
///
|
||
/// It carries the display id because that is the one thing [`sync_body_booth`] otherwise reads off
|
||
/// the unit's [`crate::entities::NetEntity`] — and a stand-in has none, by construction. Nothing
|
||
/// else about it is a unit: no guid, no transform of consequence, no visibility, no descriptors.
|
||
#[derive(Component)]
|
||
pub(crate) struct PortraitStandIn(pub(crate) u32);
|
||
|
||
/// What a portrait slot currently shows — the booth's live bake, or the ref's 2D stand-in file
|
||
/// while a unit's model is still streaming in (RE C5).
|
||
#[derive(Clone, PartialEq)]
|
||
pub(crate) enum PortraitSource {
|
||
/// The slot's off-screen render target (the model bake).
|
||
Live(Handle<Image>),
|
||
/// A flat portrait BLP (`Interface\CharacterFrame\TemporaryPortrait-…`), resolved by the UI
|
||
/// extract through the standard sprite path.
|
||
File(String),
|
||
}
|
||
|
||
/// **The GUID-keyed bake cache** — `0xc0ce7c` (decision 1640; wow-re
|
||
/// `ui/scratch/party-oor-stats-and-portrait-law.md` §4/§5, report B334).
|
||
///
|
||
/// `SetPortraitTexture` on a player GUID whose object the client does not hold does **not** go
|
||
/// straight to the 2D stand-in: it probes this cache by guid and, on a hit with a live handle,
|
||
/// **binds the cached bake** (`0x525c36 call 0x770300`) — the face the member had when you last
|
||
/// saw them. Only a miss loads `TemporaryPortrait-{Male|Female}-{Race}`. So a party member who
|
||
/// walks out of visibility range keeps their portrait, and a member you have never seen falls to
|
||
/// the stand-in: exactly the two halves of the reported behaviour.
|
||
///
|
||
/// **How a bake gets in here.** A booth renders into its own target image and then sleeps, so that
|
||
/// image already *is* a still of the last bake — the retention is a **handover**: at the moment a
|
||
/// party booth loses its member, its target moves in here under that member's guid and the booth
|
||
/// is given a fresh one ([`sync_portraits`]'s empty arm). The UI keeps sampling the same handle it
|
||
/// was sampling a frame earlier, so the frozen face costs no flicker and no copy — one 256²
|
||
/// texture per retained member, allocated only at a handover.
|
||
///
|
||
/// **Two stated deviations from `0xc0ce7c`** (which caches every portrait draw for the whole
|
||
/// session and is emptied only by `ClientDestroyGame 0x401ee0`):
|
||
/// - only the four **party** slots hand over. They are where the observable lives: a target out of
|
||
/// the object manager has already been deselected, and the pet slot takes the reference's own
|
||
/// creature leg. A general cache would pay a texture for every unit ever portraited.
|
||
/// - it is **capped** at [`Self::CAP`] entries, oldest-first, instead of growing all session. The
|
||
/// cap is memory, not policy — and it is not cleared on disconnect either, because a guid's face
|
||
/// does not go stale with the socket; the reference's clear is its graphics graph coming down,
|
||
/// not the cache expiring.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct PortraitBakes {
|
||
faces: HashMap<u64, Handle<Image>>,
|
||
/// Insertion order, oldest first — the eviction queue for [`Self::CAP`].
|
||
order: Vec<u64>,
|
||
}
|
||
|
||
impl PortraitBakes {
|
||
/// How many members' faces are kept. Four is a full party; the slack covers a roster that
|
||
/// churns (somebody leaves out of range and is re-invited) without unbounding the memory.
|
||
const CAP: usize = 8;
|
||
|
||
/// The bake standing for `guid` — the cache probe `0x525ba0` makes before it reaches for the
|
||
/// stand-in.
|
||
fn get(&self, guid: u64) -> Option<Handle<Image>> {
|
||
self.faces.get(&guid).cloned()
|
||
}
|
||
|
||
/// Take a booth's target as `guid`'s face. A re-bake replaces the entry (the reference
|
||
/// re-stores the handle at `+0x24` too), and only a genuinely new guid can evict.
|
||
fn store(&mut self, guid: u64, face: Handle<Image>) {
|
||
if self.faces.insert(guid, face).is_none() {
|
||
self.order.push(guid);
|
||
if self.order.len() > Self::CAP {
|
||
let oldest = self.order.remove(0);
|
||
self.faces.remove(&oldest);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The bridge between the booth and the UI: unit token (`"player"`/`"target"`) → what its portrait
|
||
/// region shows. The UI extract pass ([`crate::ui_script`]) reads it for a `SetPortraitTexture`-bound
|
||
/// region; the booth writes it (Live ↔ File transitions included).
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct PortraitImages(pub(crate) HashMap<String, PortraitSource>);
|
||
|
||
/// The paper-doll model pane's live input (decision 0208 §5): the bake **yaw** in radians — the
|
||
/// ref's `Model:SetRotation` convention (rotate-left *decrements*; the pane's default is `0.61`, a
|
||
/// three-quarter view). The character window's feed writes `yaw` each frame (from the rotate
|
||
/// buttons / drag); the [`PAPERDOLL_SLOT`] booth spins the model root to match and re-bakes only
|
||
/// when it (or the dressed look) changes — never every frame. The bake lands in [`PortraitImages`]
|
||
/// under the `"paperdoll"` key, sampled square by the model pane's region.
|
||
#[derive(Resource)]
|
||
pub(crate) struct PaperDollBooth {
|
||
pub(crate) yaw: f32,
|
||
}
|
||
|
||
impl Default for PaperDollBooth {
|
||
fn default() -> Self {
|
||
Self { yaw: 0.61 }
|
||
}
|
||
}
|
||
|
||
/// The inspect window's model pane input (decision 0631 §4) — the [`PaperDollBooth`] twin, plus
|
||
/// the one thing the paper doll never needs: **which unit**. `unit` is the entity
|
||
/// [`crate::ui_inspect`] resolved the inspected token to this frame, `None` when nothing is being
|
||
/// inspected (or the target isn't streamed), which empties the booth.
|
||
#[derive(Resource)]
|
||
pub(crate) struct InspectBooth {
|
||
pub(crate) yaw: f32,
|
||
pub(crate) unit: Option<Entity>,
|
||
}
|
||
|
||
impl Default for InspectBooth {
|
||
fn default() -> Self {
|
||
Self {
|
||
yaw: 0.61,
|
||
unit: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The pet paper doll's model pane input (decision 1057) — the [`InspectBooth`] shape exactly:
|
||
/// a yaw the pane's rotate buttons write, and the unit [`crate::ui_pet_doll`] resolved the pet
|
||
/// token to this frame (`None` with no pet out, or while its object hasn't streamed — which empties
|
||
/// the booth, the same "no pet" the rest of the page shows).
|
||
#[derive(Resource)]
|
||
pub(crate) struct PetDollBooth {
|
||
pub(crate) yaw: f32,
|
||
pub(crate) unit: Option<Entity>,
|
||
}
|
||
|
||
impl Default for PetDollBooth {
|
||
fn default() -> Self {
|
||
Self {
|
||
yaw: 0.61,
|
||
unit: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The stable window's model pane input (wow-re `stable-master-window.md` §7.1) — the
|
||
/// [`PetDollBooth`] shape plus the one thing no other body pane needs: a **subject with no world
|
||
/// object**.
|
||
///
|
||
/// `GetSelectedStablePet()` answers `0` for the summoned pet, `1..=2` for a bought stable slot and
|
||
/// `-1` for nothing, and the reference's `SetPetStablePaperdoll` forks on exactly that: a live pet
|
||
/// is resolved by GUID and its model taken, while a stabled — or merely dismissed — pet is a row in
|
||
/// the server's character-pet cache with nothing but a creature entry, resolved through the
|
||
/// creature cache. So this booth carries both, and [`sync_stable_booth`] prefers the live one:
|
||
///
|
||
/// - `unit` — the summoned pet's entity, when the selection is slot 0 **and** the pet is actually
|
||
/// out and streamed. Then the pane is [`PETDOLL_SLOT`]'s case exactly, mirrored from the world
|
||
/// body, and [`sync_body_booth`] handles it unchanged.
|
||
/// - `display_id` — the selected pet's `CreatureDisplayInfo`, from the creature query
|
||
/// ([`crate::names::CreatureRecord::display_id`]). Used whenever `unit` is `None`: every stabled
|
||
/// pet, and slot 0 while the pet is dismissed or out of range to summon (the server still sends
|
||
/// its row, and the reference still draws it — `0xb72060`, the summoned record's creature entry,
|
||
/// is the fall-through of the live branch's own GUID miss).
|
||
///
|
||
/// Both `None` = nothing selected, or the query has not answered yet, which empties the booth.
|
||
///
|
||
/// ## No pet-family size ramp here, and that is checked rather than assumed
|
||
///
|
||
/// Decision 1538 sizes the *select screen's* pet by `CreatureFamily`'s level ramp. **This pane
|
||
/// applies no scale at all**, because the reference applies none: `SetPetStablePaperdoll`
|
||
/// (`0x4cb870`) writes no scale on either arm; `0x505cb0` only stores the pointer into
|
||
/// `[widget+0x3f0]` and clears `[+0x3e0]`/`[+0x3e4]`; and the record→model build it defers to
|
||
/// (`0x505a70`) walks `CreatureDisplayInfo` → `CreatureModelData` → the model path and calls
|
||
/// `0x4797b0`, which wow-re's `charactermodel` ledger records as the creature **texture-variation**
|
||
/// applier — there is no `fmul` of a scale anywhere on the path. The pane is unnormalized by
|
||
/// construction anyway ([`framing::body_frame`]: a `<PlayerModel>` renders through the model's own
|
||
/// authored camera or a fixed rig, never a bounds fit), so "small model draws small" is already the
|
||
/// law here.
|
||
///
|
||
/// The second reason is internal, and it is the stronger one: the live arm goes through
|
||
/// [`sync_body_booth`] like every other body pane, which applies only [`framing::pane_root_scale`].
|
||
/// Ramping the display arm alone would make **the same pet change size** the moment it was
|
||
/// dismissed — one pane telling two stories.
|
||
#[derive(Resource)]
|
||
pub(crate) struct StableBooth {
|
||
pub(crate) yaw: f32,
|
||
pub(crate) unit: Option<Entity>,
|
||
pub(crate) display_id: Option<u32>,
|
||
}
|
||
|
||
impl Default for StableBooth {
|
||
fn default() -> Self {
|
||
Self {
|
||
// The reference's own `<PlayerModel>` default facing (UIParent.lua:1422), seeded again
|
||
// by `BenillaPaperDollModel_OnLoad` the moment the window loads.
|
||
yaw: 0.61,
|
||
unit: None,
|
||
display_id: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The key identifying what a booth currently has baked. Any change in the unit's dressed look
|
||
/// (gear swap, appearance refresh, different unit) changes it → re-bake.
|
||
#[derive(PartialEq)]
|
||
struct LookKey {
|
||
/// The (mesh, material) identity of every mirrored draw — [`PortraitPart`],
|
||
/// [`PortraitRider`], [`PortraitBillboard`].
|
||
draws: Vec<(AssetId<Mesh>, AssetId<WowModelMaterial>)>,
|
||
/// One entry per mirrored [`PortraitEffects`]: its seat (bone + offset bits) and how many
|
||
/// emitters it carries. Effects carry no asset handle to key on, and they can arrive **later**
|
||
/// than the meshes they ride with — an item glow resolves asynchronously
|
||
/// ([`crate::entities::item_glow`]: a template round trip, then the effect model's own load) —
|
||
/// so a key blind to them would leave the glow out of the bake until some unrelated edge forced
|
||
/// a re-bake.
|
||
effects: Vec<(u16, [u32; 3], usize)>,
|
||
}
|
||
|
||
impl LookKey {
|
||
/// The key for one mirrored dressed look, in the order [`DressedLook::collect`] returns it.
|
||
fn build(
|
||
parts: &[&PortraitPart],
|
||
riders: &[&PortraitRider],
|
||
billboards: &[&PortraitBillboard],
|
||
effects: &[&PortraitEffects],
|
||
) -> Self {
|
||
LookKey {
|
||
draws: parts
|
||
.iter()
|
||
.map(|p| (p.static_mesh.id(), p.material.id()))
|
||
.chain(riders.iter().map(|r| (r.static_mesh.id(), r.material.id())))
|
||
.chain(billboards.iter().map(|b| (b.mesh.id(), b.material.id())))
|
||
.collect(),
|
||
effects: effects
|
||
.iter()
|
||
.map(|e| {
|
||
(
|
||
e.bone,
|
||
e.offset.to_array().map(f32::to_bits),
|
||
e.emitters.len(),
|
||
)
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Per-slot booth: its render layer, the model-root entity (children = the baked model's meshes),
|
||
/// its render-target image (so the bridge can flip back to `Live` after a `File` stand-in), and the
|
||
/// parts key currently baked (`None` = empty booth).
|
||
struct Booth {
|
||
layer: RenderLayers,
|
||
root: Entity,
|
||
target: Handle<Image>,
|
||
baked: Option<LookKey>,
|
||
/// **Whose face is standing in this booth** — the guid the current [`Self::baked`] belongs to,
|
||
/// for the handover into [`PortraitBakes`] when the booth loses them (decision 1640). Set by
|
||
/// the party slots alone; `None` on every other booth, which never hands over.
|
||
baked_guid: Option<u64>,
|
||
/// **Body panes only** — what [`sync_body_booth`] last snapshotted, in place of `baked`.
|
||
/// A body pane is a `<PlayerModel>` widget and re-takes its model on the reference's own
|
||
/// four triggers, never on the world moving something ([`SnapKey`]).
|
||
snap: Option<SnapKey>,
|
||
/// The pane was on screen last frame ([`BoothPanes`]) — the edge detector for the widget's
|
||
/// C++ show override (`0x505d00`), which re-duplicates on every hidden→visible transition.
|
||
shown: bool,
|
||
/// How many times that edge has fired. Lives in [`SnapKey::show`].
|
||
show_rev: u32,
|
||
/// Demand-render window (decision 0540): frames [`gate_booth_cameras`] still keeps this
|
||
/// booth's camera active. Armed to [`BOOTH_SETTLE_FRAMES`] by every content edge (bake,
|
||
/// empty, framing/yaw write); 0 with `pending` drained = the camera sleeps and the target
|
||
/// keeps the last render — a still costs nothing per frame.
|
||
wake: u32,
|
||
/// The bake standing in this booth is a **live widget**, not a still: its Stand loops and its
|
||
/// item emitters run ([`booth::spawn_booth_effects`]), so it must re-render every frame — but
|
||
/// only while something is drawing it, which [`BoothPanes`] answers (decision 1069). The
|
||
/// generalization of the glue booth's own always-on rule (`live_scene` below, decision 0540);
|
||
/// only the body panes set it (decision 0822 §4 — the round portraits are a one-shot bake).
|
||
live: bool,
|
||
/// Textures the last bake referenced that were not yet resident: the camera stays awake
|
||
/// until each lands (an `mpq://` image arriving after the bake would otherwise be frozen
|
||
/// OUT of the still forever), then renders one final resident frame.
|
||
pending: Vec<Handle<Image>>,
|
||
/// When the current `pending` hold began (wall secs) — `None` while it is empty. The hold
|
||
/// was designed for a texture in flight, not one that will NEVER land (a failed load leaves
|
||
/// no `Assets<Image>` entry): unbounded, that pinned the camera rendering forever behind a
|
||
/// closed window. [`gate_booth_cameras`] releases the hold at [`PENDING_LANDING_SECS`]
|
||
/// with a warn — the still keeps whatever did land.
|
||
pending_since: Option<f64>,
|
||
/// **This bake has not yet been drawn with every pipeline compiled** — the [`Booth::pending`]
|
||
/// law, one level further down the stack (report B331).
|
||
///
|
||
/// `pending` asks whether a texture is resident in `Assets<Image>`; that is a *main-world*
|
||
/// question, and it is not the whole of "did this draw appear". Off macOS Bevy builds each
|
||
/// pipeline variant on the async pool, and a batch whose variant is still building is
|
||
/// **skipped** — silently, with no error and no missing asset ([`PipeWatch::compiling`] has
|
||
/// the byte references). A live view redraws it a few frames later and nobody ever sees it.
|
||
/// A one-shot portrait bake does not: [`BOOTH_SETTLE_FRAMES`] elapse, the camera sleeps, and
|
||
/// the still keeps the hole for the rest of the session — which is exactly the report
|
||
/// ("*sometimes hair missing, sometimes face, totally random*"): hair is the alpha-key
|
||
/// pipeline, the body the opaque one, the eye-glow card the additive one, and which of the
|
||
/// three had landed by the fourth frame is a race. It is invisible on macOS, where the same
|
||
/// compile `block_on`s the render thread instead: the whole class exists on the reporters'
|
||
/// machines and on no screen we can look at. Decision 1621.
|
||
///
|
||
/// Set by [`wake_booth`]; [`gate_booth_cameras`] holds the camera awake while the cache is
|
||
/// draining and then spends one final rendered frame, exactly as it does for `pending`.
|
||
pipes_settling: bool,
|
||
/// When the current [`Booth::pipes_settling`] hold began (wall secs) — `None` while it is
|
||
/// clear. Bounded like [`Booth::pending_since`] and for the same reason: a session that keeps
|
||
/// meeting new variants (walking a city) keeps the cache non-empty, and an unbounded hold
|
||
/// would pin a 256² camera rendering behind it.
|
||
pipes_since: Option<f64>,
|
||
/// The **destination pane's** aspect this booth's camera is currently framed for
|
||
/// ([`framing::WowPortraitProjection::aspect`], decision 1069) — 1.0 until the UI has drawn the
|
||
/// pane once, then sticky: a hidden window must not re-frame the bake back to square.
|
||
aspect: f32,
|
||
/// A rig stands in this booth ([`booth::BoothRig::rigged`] at the last bake) — the park
|
||
/// gate's "is there anything to park". Cleared with the emptied booth.
|
||
rigged: bool,
|
||
/// The rotate arrows' turn animation ([`Turn`], decision 1559) — body panes only.
|
||
turn: Turn,
|
||
/// The booth scene is parked: its camera is asleep, so [`gate_booth_cameras`] put
|
||
/// [`benilla_world::rig_anim::AnimParked`] on the root — the 0712 evaluator, the pose
|
||
/// composes, the palette writes and the global-sequence writes all skip (the pose HOLDS —
|
||
/// the buffer is state), and the booth-lane emitters freeze under the draw-set law
|
||
/// (`particles::sim` — the reference only ticks an emitter its frame draws). A camera wake
|
||
/// drops the marker before the animation lane runs, so the wake window's first render
|
||
/// already animates. Costs nothing while a window is open; retires the whole idle lane the
|
||
/// rest of the session.
|
||
parked: bool,
|
||
}
|
||
|
||
/// A body pane's **turn animation** state — the half of the reference's `SetRotation` that is not
|
||
/// the facing write (decision 1559, director report B313).
|
||
///
|
||
/// `PlayerModel:SetRotation(angle)` (`0x505bb0`, wow-re `modelframe-camera-law.md` §6) does two
|
||
/// things: it picks a turn-in-place shuffle by direction, queues it and arms a 100 ms expiry, and
|
||
/// *then* writes the facing field `SetFacing` writes. Ours only ever did the second — the doll
|
||
/// spun on the spot while the reference's steps its feet round, which is what a held arrow looks
|
||
/// like there.
|
||
///
|
||
/// The producer/consumer split is one frame's width and deliberate: [`sync_body_booth`] holds the
|
||
/// yaw and sets [`Self::spun`]; [`booth::drive_booth_turn`], which holds the `AnimationPlayer`,
|
||
/// spends it. The syncs run before it in the same chain.
|
||
#[derive(Default)]
|
||
struct Turn {
|
||
/// The facing this booth's root is posed at — the reference's `[+0x39c]`, and the value its
|
||
/// direction test compares the incoming angle against. `None` until the first pose, which is
|
||
/// why a fresh bake does not step (the reference re-snapshots on `RefreshUnit` without
|
||
/// calling `SetRotation`).
|
||
faced: Option<f32>,
|
||
/// A rotation happened this frame, and the `AnimationData` id it arms
|
||
/// ([`booth::turn_shuffle`] picked it — a shuffle, or `Stand` for the equal/NaN case). Set by
|
||
/// the sync, taken by the driver, at most one frame old.
|
||
spun: Option<u16>,
|
||
/// The shuffle currently stepping — its `AnimationData` id and the wall-clock second it
|
||
/// expires at. `None` = the doll is back on Stand.
|
||
shuffle: Option<(u16, f64)>,
|
||
/// The graph node the turn's **primary** is running — the shuffle while one steps, the Stand
|
||
/// variation it settled into otherwise. Kept because every arm rolls a **frequency-weighted
|
||
/// random variation** (`0x7121a0`'s `-1`), so the node playing is not recoverable from the id
|
||
/// alone. `None` before the first arm, where the bake's own Stand is the pose in flight.
|
||
playing: Option<bevy::animation::graph::AnimationNodeIndex>,
|
||
/// The cross-fade out of the pose the last arm replaced ([`Fade`]) — `None` between turns.
|
||
fade: Option<Fade>,
|
||
}
|
||
|
||
impl Turn {
|
||
/// **The bake replaced this booth's `AnimationPlayer`** — drop every node reference the turn was
|
||
/// holding, because they name `ActiveAnimation`s in a player that no longer exists (the bake
|
||
/// `insert`s a fresh one on the same root). Without this a re-bake mid-turn — an equipment
|
||
/// change while the arrow is held, the pane's aspect settling — leaves
|
||
/// [`booth::drive_booth_turn`] naming a node the new player never played, and the next weight
|
||
/// write **starts** it: a phantom shuffle at full weight over the doll's idle.
|
||
///
|
||
/// [`Self::faced`] survives, and must: the yaw is the bake's own (`sync_body_booth` re-poses
|
||
/// the root to it), so a re-bake is not a rotation and must not step the feet. That is the
|
||
/// reference's `RefreshUnit`, which re-snapshots the unit without ever calling `SetRotation`.
|
||
fn rebaked(&mut self) {
|
||
*self = Turn {
|
||
faced: self.faced,
|
||
..Default::default()
|
||
};
|
||
}
|
||
}
|
||
|
||
/// The **cross-fade out of the pose an arm replaced** — the reference's per-bone SECONDARY blend
|
||
/// slot, seeded by every arm the turn makes (decision 1565, director report B321).
|
||
///
|
||
/// `0x7121a0`'s sixth argument is `1` at both of the turn's call sites (`0x505c23` the rotation,
|
||
/// `0x505c98` the 100 ms expiry — wow-re `modelframe-camera-law.md` §13.4). A non-zero there is
|
||
/// `0x7125d6`: `rep movsd` copies the live primary track into the secondary sub-record `[blk+0xc4]`
|
||
/// — the outgoing clip, **still on its own clock**, not a frozen pose — then writes the window end
|
||
/// `[blk+0x100]`, the rate `[blk+0x104] = 1/blendTime` and the amplitude `[blk+0x108] = 1.0f`. The
|
||
/// kernel at `0x714880` decays λ = smoothstep across it and blends `out = primary + (secondary −
|
||
/// primary)·λ` ([`crate::creature_anim::select::blend_lambda`], the same curve the world lane's
|
||
/// key-bone slot already runs — decision 0878).
|
||
///
|
||
/// **One slot, not a list**, exactly as the client keeps one: an arm inside a running window
|
||
/// overwrites `[blk+0xc4]`, and the pose it was fading out is dropped there and then.
|
||
#[derive(Clone, Copy)]
|
||
struct Fade {
|
||
/// The outgoing graph node. Its clock is left running — the `rep movsd` copies the live
|
||
/// track's window base and rate, so the client fades out of a clip that is still stepping.
|
||
node: bevy::animation::graph::AnimationNodeIndex,
|
||
/// The wall-clock second λ reaches 0 at (`[blk+0x100]`).
|
||
until: f64,
|
||
/// The window's full width — the **incoming** clip's own `M2Sequence.blendTime` (`0x7125f2`),
|
||
/// which is why a release settles so much more slowly than a turn starts: HumanMale authors
|
||
/// 0.25 s on ShuffleLeft/Right and **0.5 s** on Stand (`benilla-extract m2seq`).
|
||
span: f32,
|
||
}
|
||
|
||
/// How many frames a content edge keeps a booth camera rendering ([`Booth::wake`]): covers the
|
||
/// command-applied spawn, the billboard re-face's one-frame lag, and GPU upload of fresh meshes.
|
||
const BOOTH_SETTLE_FRAMES: u32 = 4;
|
||
|
||
/// This rig stands on a booth stage, not in the world: its `AnimParked` marker is owned by
|
||
/// [`gate_booth_cameras`] alone, and the world-view parker (`creature_anim::lod`) filters it out
|
||
/// (decision 1447). One marker, one writer — the same split the doodad draw gate holds for
|
||
/// placed doodads (decision 1365). The hazard is structural: a booth stage sits outside every
|
||
/// world frustum by construction, so an unfiltered view parker freezes every pane moments after
|
||
/// its bake — while this gate, tracking only its own park edges, cannot heal the foreign marker.
|
||
/// [`booth::BoothRig::finish`] inserts it beside the `RigPose`; [`booth::clear_booth_rig`]
|
||
/// strips it with the rest. Policy, not machinery — which is why it lives here and not in
|
||
/// `benilla_world::rig_anim` beside the marker it fences (the 1160 line).
|
||
#[derive(bevy::prelude::Component)]
|
||
pub(crate) struct StageRig;
|
||
|
||
/// How long a [`Booth::pending`] texture hold may keep the camera awake (wall secs) before it is
|
||
/// declared never-landing and released ([`Booth::pending_since`]). Generous against a real load —
|
||
/// an MPQ image lands in well under a second — because a premature release only costs a stale
|
||
/// still, while the old unbounded hold cost a forever-rendering camera.
|
||
const PENDING_LANDING_SECS: f64 = 10.0;
|
||
|
||
/// How long a [`Booth::pipes_settling`] hold may keep the camera awake (wall secs) before it is
|
||
/// spent anyway ([`Booth::pipes_since`]). The pipeline cache always drains *eventually* — every
|
||
/// build settles `Ok` or `Err` — but it is a **process-global** counter, so a session that keeps
|
||
/// meeting new variants (walking into a new city, the first cast of every spell) can hold it
|
||
/// non-empty for a long stretch. Generous, because the cost of waiting is one 256² camera and the
|
||
/// cost of releasing early is the wrong face on the screen for the rest of the session.
|
||
const PIPELINE_SETTLING_SECS: f64 = 15.0;
|
||
|
||
/// `WOW_BOOTH_LOG=1` — is the booth instrument armed? (Read once.)
|
||
fn booth_log() -> bool {
|
||
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*LOG.get_or_init(|| std::env::var_os("WOW_BOOTH_LOG").is_some())
|
||
}
|
||
|
||
/// `WOW_BOOTH_LOG=1` — one line per BAKE DECISION, which is the half the gate timeline cannot
|
||
/// show: the gate says a camera *drew*, never *what it drew*. B106's first-login shot is a
|
||
/// portrait wearing no armour beside a fully-equipped character, so the question is exactly "how
|
||
/// many parts/riders did the bake see, and did it ever re-bake when the rest arrived".
|
||
/// `verb`: `bake` committed · `wait` abandoned (a source material was not resident, 0744) ·
|
||
/// `stand-in` the 2D fallback while no parts are attached · `empty` the booth was cleared.
|
||
fn log_bake(
|
||
token: &str,
|
||
verb: &str,
|
||
parts: &[&PortraitPart],
|
||
riders: &[&PortraitRider],
|
||
billboards: &[&PortraitBillboard],
|
||
effects: &[&PortraitEffects],
|
||
) {
|
||
if booth_log() {
|
||
// The **attach ids** the bake is dressed at, sorted and de-duplicated. Counts alone could
|
||
// not answer the question `#bugs` B324 asked — *what is hanging on this doll?* — and the
|
||
// answer is one line: a hunter mid-shot read `at=[2,35]` (bow at HandLeft, arrow at
|
||
// HandArrow) where the reference can only ever show `[2]`. It is also the retest readout
|
||
// for the reference's attach reset ([`attach_reset`]): no id in the cut family may appear.
|
||
let mut at: Vec<u16> = riders
|
||
.iter()
|
||
.filter_map(|r| r.attach)
|
||
.chain(billboards.iter().filter_map(|b| b.attach))
|
||
.chain(effects.iter().filter_map(|f| f.attach))
|
||
.collect();
|
||
at.sort_unstable();
|
||
at.dedup();
|
||
eprintln!(
|
||
"[booth] {token} {verb} parts={} riders={} billboards={} fx={}/{} at={at:?} grip={:?}",
|
||
parts.len(),
|
||
riders.len(),
|
||
billboards.len(),
|
||
effects.len(),
|
||
effects.iter().map(|e| e.emitters.len()).sum::<usize>(),
|
||
hand_grip(riders, billboards, effects),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The framing anchors for a bake — **`None` means "not ready, come back next frame", never
|
||
/// "this display has no anchors".**
|
||
///
|
||
/// `Creatures::display_anchors` returns `None` in exactly two states, both transient: the display
|
||
/// is not in the cache yet, or its `parts` are *"not yet built"* (its own comment). Once built it
|
||
/// always answers `Some`; a display that genuinely has no authored portrait camera answers `Some`
|
||
/// with `camera: None` inside, which the framing heuristics are built to handle.
|
||
///
|
||
/// Both bake sites used to paper over that with `unwrap_or(PortraitAnchors { .., pivot_height: 0.0,
|
||
/// .. })`. That is not a neutral default: the retired body fit floored the head signal at `0.1` for
|
||
/// "a hypothetical bounds-less display", so zero anchors aimed the camera at a 0.1-unit-tall
|
||
/// subject — the paper doll "zoomed into the max" and the wrong-size portrait of `#bugs` B106. (1089
|
||
/// retired that fit, and zero anchors are no longer a *zoom* — but they are still the wrong camera:
|
||
/// an unbuilt display has no `cameras[1]` to read, so the pane would latch the fixed fallback rig
|
||
/// aimed at a zero bbox centre.) And it latches: the camera is aimed once per bake, and the parts
|
||
/// key cannot change afterwards
|
||
/// (handle ids are stable from `load()`, so mesh/model arrival is invisible to it), so the frame
|
||
/// stays wrong until some unrelated content edge forces a re-bake.
|
||
///
|
||
/// A display id of `None` is a different case — there is nothing to wait for — and keeps the
|
||
/// bounds-less anchors it always had.
|
||
fn booth_anchors(
|
||
creatures: Option<&Creatures>,
|
||
display_id: Option<u32>,
|
||
) -> Option<PortraitAnchors> {
|
||
let Some(display_id) = display_id else {
|
||
return Some(PortraitAnchors {
|
||
camera: None,
|
||
pane_camera: None,
|
||
bbox_center: Vec3::ZERO,
|
||
head: None,
|
||
pivot_height: 0.0,
|
||
ground_radius: 0.0,
|
||
});
|
||
};
|
||
creatures?.display_anchors(display_id)
|
||
}
|
||
|
||
/// `WOW_BOOTH_LOG=1` — the resolved framing for a bake: which anchors it had and where the camera
|
||
/// ended up. An opaque near-black pane is what a camera parked *inside* the model renders, so
|
||
/// "black" and "zoomed to max" may be one symptom; this is the line that tells them apart.
|
||
fn log_frame(token: &str, a: &PortraitAnchors, cam: &Transform) {
|
||
if booth_log() {
|
||
eprintln!(
|
||
"[booth] {token} frame eye=({:.3},{:.3},{:.3}) authored_cam={} pivot={:.3} head_y={:.3} gr={:.3}",
|
||
cam.translation.x,
|
||
cam.translation.y,
|
||
cam.translation.z,
|
||
a.camera.is_some(),
|
||
a.pivot_height,
|
||
a.head.map_or(f32::NAN, |h| h.y),
|
||
a.ground_radius,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `WOW_BOOTH_LOG=1` — where each booth's **posed skeleton** actually ended up, logged whenever it
|
||
/// moves (quantised to a centimetre, so a still bake prints once).
|
||
///
|
||
/// [`log_frame`] says where the *camera* went; this says where the *model* went, and the pair is the
|
||
/// whole diagnosis of a bake that came back empty. Decision 1577's carrion bird posed its bones up
|
||
/// to y = 4.70 with its authored camera sitting at y = 3.03 — a camera aimed correctly at geometry
|
||
/// Bevy was culling against a **bind-pose** bound that stopped at 1.19. Either half alone reads as
|
||
/// "looks fine"; only the two together say the camera and the model were in the same place and the
|
||
/// pixels still were not.
|
||
fn log_booth_pose(
|
||
booths: Res<Booths>,
|
||
rigs: Query<&benilla_world::rig_anim::RigPose>,
|
||
// Last-logged extent per booth, in centimetres — `[min xyz, max xyz]`.
|
||
mut last: Local<HashMap<String, [i32; 6]>>,
|
||
) {
|
||
if !booth_log() {
|
||
return;
|
||
}
|
||
for (token, booth) in &booths.0 {
|
||
let Ok(pose) = rigs.get(booth.root) else {
|
||
last.remove(token);
|
||
continue;
|
||
};
|
||
let (mut mn, mut mx) = (Vec3::MAX, Vec3::MIN);
|
||
for m in &pose.model {
|
||
let t = Vec3::from(m.translation);
|
||
mn = mn.min(t);
|
||
mx = mx.max(t);
|
||
}
|
||
let cm = |v: Vec3| [v.x, v.y, v.z].map(|c| (c * 100.0).round() as i32);
|
||
let ([a, b, c], [d, e, f]) = (cm(mn), cm(mx));
|
||
let key = [a, b, c, d, e, f];
|
||
if last.insert(token.clone(), key) == Some(key) {
|
||
continue;
|
||
}
|
||
eprintln!(
|
||
"[booth] {token} posed-bones n={} min=({:.3},{:.3},{:.3}) max=({:.3},{:.3},{:.3})",
|
||
pose.model.len(),
|
||
mn.x,
|
||
mn.y,
|
||
mn.z,
|
||
mx.x,
|
||
mx.y,
|
||
mx.z,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Arm `booth` after a content edge: render the settle window, plus every frame until each twin
|
||
/// material's texture is resident. `twins` = the material handles the bake just installed.
|
||
fn wake_booth<'a>(
|
||
booth: &mut Booth,
|
||
mats: &Assets<WowModelMaterial>,
|
||
twins: impl Iterator<Item = &'a Handle<WowModelMaterial>>,
|
||
) {
|
||
booth.wake = BOOTH_SETTLE_FRAMES;
|
||
booth.pending = twins
|
||
.filter_map(|h| mats.get(h))
|
||
.filter_map(|m| m.base.base_color_texture.clone())
|
||
.collect();
|
||
// A fresh hold gets a fresh clock ([`Booth::pending_since`]) — the gate stamps it.
|
||
booth.pending_since = None;
|
||
// …and a fresh bake owes a pipeline settle ([`Booth::pipes_settling`]): the variants this
|
||
// bake's materials specialize to are only queued when the render world first sees them, so
|
||
// the gate cannot judge this until the settle window has otherwise drained.
|
||
booth.pipes_settling = true;
|
||
booth.pipes_since = None;
|
||
}
|
||
|
||
#[derive(Resource, Default)]
|
||
struct Booths(HashMap<String, Booth>);
|
||
|
||
/// Where each booth's bake is actually being **sampled on screen this frame**: slot token → the
|
||
/// destination region's aspect (width ÷ height). Published by the UI extract for every portrait
|
||
/// binding it emits — the *square* `BenillaSetBoothTexture` pane (decision 0208 §5) and, since
|
||
/// 1576, the round `SetPortraitTexture` unit portraits beside it. The two consumers below are
|
||
/// body-booth-only and unreachable for a round slot (`sync_body_booth` is never called with one,
|
||
/// and `live_pane` needs a `live` booth, which only a body pane ever is), so the round rows are
|
||
/// inert here and exist for a third reader: [`sync_portraits`]'s `"targettarget"` gate, which asks
|
||
/// this resource whether the frame it feeds is on screen at all.
|
||
///
|
||
/// Two things need it, and neither can be a constant (decision 1069):
|
||
///
|
||
/// - **Shape.** A booth renders into a square target that the UI stretches to fill the pane's rect
|
||
/// (`extract`'s `UvRect::FULL`), so the projection has to run at the *pane's* aspect for the
|
||
/// stretch to cancel. Rendering at 1.0 into the dressing room's 316×351 pane made every
|
||
/// character 11% too tall (director report, 2026-08-06).
|
||
/// - **Liveness.** A body pane's bake *animates* ([`BoothMotion::Loop`] — the reference's
|
||
/// `<PlayerModel>` widgets render live, decision 0822 §4), so its camera renders every frame it
|
||
/// is on screen — and, now that this resource can say so, **none** when it is not. That last
|
||
/// half is also a strict win for the pre-1069 emitter case, which used to render forever behind
|
||
/// a closed window.
|
||
///
|
||
/// One frame stale by construction (the extract runs after the booth syncs), which is why the
|
||
/// aspect is latched into [`Booth::aspect`] rather than read fresh: a window that closes must not
|
||
/// re-frame the bake it left standing.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct BoothPanes(pub(crate) HashMap<String, f32>);
|
||
|
||
/// Both directions of the booth↔UI bridge in one system param: the bake a bound region **samples**
|
||
/// ([`PortraitImages`]) and the pane geometry the extract **publishes** back ([`BoothPanes`]).
|
||
///
|
||
/// They travel together because they are the same seam, and because `drive_script` had already
|
||
/// reached Bevy's 16-parameter ceiling — two more `Res`es there is one too many.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct BoothBridge<'w> {
|
||
pub(crate) images: Res<'w, PortraitImages>,
|
||
pub(crate) panes: ResMut<'w, BoothPanes>,
|
||
}
|
||
|
||
/// The group-facing inputs [`sync_portraits`] needs, in one param: who is in the party
|
||
/// ([`crate::ui_party::GroupState`]), the guid→entity index that says which of them are actually
|
||
/// STREAMED, the skin-palette table (decision 0720), the pet bar (0990), and the name cache — the
|
||
/// only place a member we hold no object for has a race and sex at all (report B315). The index
|
||
/// serves the party slots and the pet alike.
|
||
///
|
||
/// One struct rather than five loose params because `sync_portraits` sits at Bevy's 16-parameter
|
||
/// ceiling; it was a bare tuple until the name cache made that tuple's type unreadable. It has
|
||
/// since become that system's overflow bag outright — [`BoothPanes`] is not group-facing at all,
|
||
/// it rides here because there is no room left in the signature.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct PartyBooths<'w, 's> {
|
||
roster: Res<'w, crate::ui_party::GroupState>,
|
||
index: Res<'w, crate::net::GuidIndex>,
|
||
palettes: ResMut<'w, benilla_world::rig_palette::RigPalettes>,
|
||
pet_bar: Res<'w, crate::ui_pet::PetBar>,
|
||
names: Res<'w, crate::names::NameCache>,
|
||
/// What the UI drew last frame ([`BoothPanes`]) — read by the `"targettarget"` slot alone.
|
||
panes: Res<'w, BoothPanes>,
|
||
/// The guid-keyed bake cache + what the handover needs to run (decision 1640): a fresh render
|
||
/// target for the booth that just gave its own away, and that booth camera's
|
||
/// [`RenderTarget`] to re-point at it.
|
||
///
|
||
/// A query of its own rather than a wider `cams` tuple in [`sync_portraits`]: `aim`'s query
|
||
/// shape is shared by five booth systems across four files, and this is one system's concern.
|
||
/// It takes `RenderTarget` mutably where `cams` takes `Transform`/`Projection`, so the two
|
||
/// never overlap.
|
||
bakes: ResMut<'w, PortraitBakes>,
|
||
images: ResMut<'w, Assets<Image>>,
|
||
targets: Query<'w, 's, (&'static BoothCam, &'static mut RenderTarget)>,
|
||
}
|
||
|
||
/// Tags a booth camera with its slot token, so the model-sync pass can re-frame it per model.
|
||
/// (`BoothCam`, not `PortraitCamera` — that name is the authored M2 rig, `benilla_assets::PortraitCamera`.)
|
||
/// (`pub(crate)` for the particle census, which reports draws per booth token — decision 0775.)
|
||
#[derive(Component)]
|
||
pub(crate) struct BoothCam(pub(crate) String);
|
||
|
||
/// **The display aspect the client's `screencoord` scale is computed from** — its `gxResolution`
|
||
/// CVar's `width/height`, gated by the `widescreen` CVar (registered with default `"1"`, so on by
|
||
/// default; with it off the client uses `4/3` on any monitor). Its one consumer is
|
||
/// [`framing::pane_model_scale`], the model-root renormalize factor a `<PlayerModel>` pane applies
|
||
/// to a model with no camera of its own.
|
||
///
|
||
/// **A stated deviation:** benilla has neither CVar yet, so this reads the primary window's own
|
||
/// aspect. That is the same number whenever the client runs at its display's native resolution, and
|
||
/// ours always does — we expose no resolution mode list to disagree with. When the graphics CVars
|
||
/// land, this resource is the one place to repoint.
|
||
///
|
||
/// Defaults to `4/3` — the client's own `.data` default pair, and the aspect at which the factor is
|
||
/// exactly `1.0`, so a booth that bakes before the window is measured is unscaled rather than wrong.
|
||
#[derive(Resource)]
|
||
pub(crate) struct GxAspect(pub(crate) f32);
|
||
|
||
impl Default for GxAspect {
|
||
fn default() -> Self {
|
||
Self(4.0 / 3.0)
|
||
}
|
||
}
|
||
|
||
/// Track the primary window's aspect into [`GxAspect`] (see its deviation note). Cheap enough to run
|
||
/// every frame; the booths latch it, so a resize re-scales the standing bake exactly once.
|
||
fn feed_gx_aspect(
|
||
mut gx: ResMut<GxAspect>,
|
||
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||
) {
|
||
let Ok(w) = window.single() else {
|
||
return;
|
||
};
|
||
let (px_w, px_h) = (w.resolution.width(), w.resolution.height());
|
||
if px_h > 0.0 {
|
||
let a = px_w / px_h;
|
||
if (a - gx.0).abs() > 1e-4 {
|
||
gx.0 = a;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The body panes' render rate (decision 1444): while a `<PlayerModel>`-family pane (paper
|
||
/// doll, dressing room, inspect, pet) is on screen, its booth camera renders every OTHER frame
|
||
/// instead of every frame. The pane's per-frame bill is the **second render pass itself** —
|
||
/// 1441's trace put ~0.9 ms/frame in graph re-run + drawable pressure, and 1443 measured the
|
||
/// open-pane delta at +2.7 with the pass running every frame — so a 512²-class doll at 30 fps
|
||
/// is the cheapest half of that back. The pose keeps evaluating at full rate (the park is not
|
||
/// touched); only the camera skips, so the held frame is always one the pose just produced.
|
||
///
|
||
/// `boothHalfRate` is **benilla's own CVar** (the reference has no second view to rate-limit —
|
||
/// its doll draws in the main pass, 1069's known rent). **Default ON (half-rate) — restored by
|
||
/// 1607.** 1444 shipped it on; 1559 turned it off on the director's look-call (a full-rate doll
|
||
/// reads as smoother); the 08-25 weak-GPU perf reports (B329) then measured what that costs — a
|
||
/// body-pane booth's off-screen pass every frame, ~1.6 ms at 1600×900 and **7.6 ms at 4K**
|
||
/// (`sess/perfregress` A/B), paid on exactly the weak GPUs that reported. The director retested
|
||
/// the 30 fps doll and it reads fine, so the cheaper default is back. Full-rate is one
|
||
/// `/script SetCVar("boothHalfRate", 0)` away for anyone who wants the smoother cadence.
|
||
///
|
||
/// Half-rate no longer slows the item effects (why 1559's revert was safe to make, and this one).
|
||
/// Booth-lane emitters used to freeze on the camera's own `is_active` bit and so ticked once per
|
||
/// *drawn* frame — the draw-set law read onto a skip the reference never makes. They key on
|
||
/// [`benilla_world::particles::ViewThrottled`] now: a paced camera's scene is still live, so the
|
||
/// sim runs at full rate and only the render is halved (1559). So B312 does not return: only the
|
||
/// doll's animation *cadence* in the pane drops to 30 fps, never its item/weapon effects.
|
||
///
|
||
/// The glue screens (`live_scene`) are exempt: a fullscreen create/select scene at 30 fps is
|
||
/// not a pane crop, and those screens have no world behind them to pay for.
|
||
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub(crate) struct PaneRate {
|
||
/// The registered CVar is welded to this in `cvars::tests`, so the shipped default cannot
|
||
/// drift from the registered one.
|
||
pub(crate) half: bool,
|
||
}
|
||
|
||
impl Default for PaneRate {
|
||
/// Half-rate (1607) — see the type's doc. `Default` is hand-written rather than derived so the
|
||
/// shipped default is not silently `false`; the welded `cvars::tests` assert pins it to the
|
||
/// registered CVar either way.
|
||
fn default() -> Self {
|
||
Self { half: true }
|
||
}
|
||
}
|
||
|
||
/// The two **framing inputs** a body booth reads, in one param: the pane geometry the UI extract
|
||
/// publishes ([`BoothPanes`]) and the display aspect ([`GxAspect`]).
|
||
///
|
||
/// Bundled rather than passed side by side because these systems sit on Bevy's 16-parameter ceiling
|
||
/// — the same reason [`BoothBridge`] exists on the other side of the seam.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub(crate) struct BoothFraming<'w> {
|
||
pub(crate) panes: Res<'w, BoothPanes>,
|
||
pub(crate) gx: Res<'w, GxAspect>,
|
||
}
|
||
|
||
/// Owns the portrait bake pipeline: the [`PortraitImages`] bridge + the per-slot off-screen booths.
|
||
pub(crate) struct PortraitPlugin;
|
||
|
||
impl Plugin for PortraitPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<PortraitImages>()
|
||
.init_resource::<PortraitBakes>()
|
||
.init_resource::<PaneRate>()
|
||
.init_resource::<PaperDollBooth>()
|
||
.init_resource::<InspectBooth>()
|
||
.init_resource::<PetDollBooth>()
|
||
.init_resource::<StableBooth>()
|
||
.init_resource::<StableStandIn>()
|
||
.init_resource::<glue_booth::GluePreview>()
|
||
.init_resource::<glue_booth::GluePreviewBake>()
|
||
.init_resource::<glue_booth::GluePetBake>()
|
||
.init_resource::<dressup::DressUpPreview>()
|
||
.init_resource::<dressup::DressUpBake>()
|
||
.init_resource::<Booths>()
|
||
.init_resource::<BoothPanes>()
|
||
.init_resource::<GxAspect>()
|
||
.init_resource::<BoothLight>()
|
||
.add_systems(Startup, setup_booths)
|
||
// The variant-cache reaper: booth twins die with their world source material.
|
||
.add_systems(Update, (light::reap_dead_variants, feed_gx_aspect))
|
||
// The `"npc"` token's entity is resolved by `ui_session`'s own plugin (it is shared with
|
||
// the interaction face-me, decision 1467) — the booths read whatever it last published.
|
||
// Here the test bake owns the booths when its env is set (the live syncs yield to it),
|
||
// and the paper-doll sync runs last (it shares the camera/booth/image resources, so the
|
||
// chain keeps the access ordered).
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
// First: the model-changed producers this frame, so every pane below reads
|
||
// one revision. Its writes are `Commands`, so a bump reaches the panes on the
|
||
// NEXT frame — which is the reference's own latency, an event queued into
|
||
// `0x524cd0`'s per-frame drain and consumed by Lua a round-trip later.
|
||
bump_model_revision,
|
||
test_bake::sync_test_portraits,
|
||
sync_portraits,
|
||
sync_paperdoll,
|
||
sync_inspect_booth,
|
||
sync_petdoll_booth,
|
||
// Before the pane that reads it: the stand-in must exist (and have had a frame
|
||
// to flush its children) before the booth can mirror it.
|
||
sync_stable_standin,
|
||
sync_stable_booth,
|
||
glue_booth::sync_glue_booth,
|
||
glue_booth::sync_glue_scene,
|
||
// After the scene's framing: the viewport/clear its law decided (1619).
|
||
glue_booth::pillarbox_glue_scene,
|
||
// After the scene: the pet's seat and its light are the scene's to publish.
|
||
glue_booth::sync_glue_pet,
|
||
dressup::sync_dressup_booth,
|
||
// After the syncs, which are the only writers of the yaw it spends (1559).
|
||
booth::drive_booth_turn,
|
||
// Last: it reads the wake/pending state every sync above may have armed.
|
||
gate_booth_cameras,
|
||
)
|
||
.chain(),
|
||
)
|
||
// Re-face each booth's eye-glow cards to its own camera (reads last-propagate joint
|
||
// globals; unordered w.r.t. the syncs — a fresh card just faces forward one frame).
|
||
.add_systems(Update, booth::face_booth_billboards)
|
||
// The booth twin of the world visibility authority's render-alpha write (decision 0807):
|
||
// push each booth part's sampled `MatAnim` onto its tag. Ordered after the shared
|
||
// sampler, the only producer of `MatAnim::current`, so it moves THIS frame's value.
|
||
.add_systems(
|
||
Update,
|
||
booth::push_booth_mat_alpha.after(benilla_world::doodad_anim::sample_mat_anim),
|
||
)
|
||
// The phase-3 preview instrument (`WOW_CREATE_TEST`, decision 0423): inert without the env.
|
||
.add_systems(Update, glue_booth::drive_create_test)
|
||
// `WOW_BOOTH_DUMP=<token>:<path>:<secs>` — photograph a booth's render target to disk
|
||
// (the headless eye on "what is the paperdoll actually showing right now"; the
|
||
// first-login black-pane hunt). Inert without the env.
|
||
.add_systems(Update, test_bake::dump_booth_target)
|
||
// `WOW_BOOTH_LOG=1` — the model half of the framing instrument (see `log_frame`).
|
||
// Inert without the env.
|
||
.add_systems(Update, log_booth_pose);
|
||
}
|
||
}
|
||
|
||
/// A fresh transparent render-target image of `size²`, usable as a camera target and sampled by the
|
||
/// UI. Portrait slots pass [`PORTRAIT_SIZE`]; the paper doll passes [`PAPERDOLL_SIZE`].
|
||
fn new_target_image(size: u32) -> Image {
|
||
let mut image = Image::new_fill(
|
||
Extent3d {
|
||
width: size,
|
||
height: size,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
TextureDimension::D2,
|
||
&[0; 8],
|
||
// **Un-encoded on purpose, and at float precision — the two are separate choices.**
|
||
//
|
||
// Un-encoded (no `…Srgb` label) is measured: with an Srgb label the composited portrait read
|
||
// one net sRGB-decode too dark vs the world render of the same model, because the UI arc
|
||
// composites in GAMMA bytes and does its one decode at the end (`ui_gamma`, decisions
|
||
// 0254/0541) — a booth target that pre-encodes lands a second encode in that chain. So the
|
||
// target holds the booth's own un-encoded values and the UI shader encodes them.
|
||
//
|
||
// FLOAT, not `Rgba8Unorm`, is B126 (decision 0804). Quantizing *un-encoded* values to 8 bits
|
||
// is a precision collapse exactly where the eye is most sensitive: the only display levels
|
||
// reachable below display byte 100 are `srgb(k/255)` = 0, 13, 22, 28, 34, 38, … — ~25 steps
|
||
// where the 8-bit gamma backbuffer this feeds has 100. That is the reported "colors are 16
|
||
// bit instead of 32" banding on the glue screens' trees and skybox, measured on the
|
||
// histogram of a char-select capture (15 of 16 predicted ladder values hit). The pipeline's
|
||
// *semantics* are unchanged by this — same values, same single encode downstream, just not
|
||
// rounded to a 256-step linear grid on the way through.
|
||
TextureFormat::Rgba16Float,
|
||
RenderAssetUsages::default(),
|
||
);
|
||
image.texture_descriptor.usage =
|
||
TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
|
||
image
|
||
}
|
||
|
||
/// The booth **view shape** — the components that set a booth camera's *pipeline key space*:
|
||
/// HDR float intermediate + `Tonemapping::None` (the world camera's exact shape — the measured
|
||
/// whys sit on the portrait-slot spawn below) at `Msaa::Off`. Defined ONCE and spawned by every
|
||
/// booth camera — the portrait slots, the two body panes, the glue booth, and pipe_warm's twin
|
||
/// booth — because the warm pass compiles the samples=1 twin of every model pipeline against
|
||
/// exactly this shape behind the loading cover (decisions 0938/0958): a booth camera whose shape
|
||
/// drifts from the warm booth's is a live pipeline stall on its first bake.
|
||
fn booth_view_shape() -> impl Bundle {
|
||
(
|
||
Camera3d::default(),
|
||
bevy::render::view::Hdr,
|
||
Tonemapping::None,
|
||
Msaa::Off,
|
||
)
|
||
}
|
||
|
||
/// The pipe_warm **twin booth** (decision 0958). The real booths spawn with a
|
||
/// `PerspectiveProjection` placeholder and render the menagerie's twins under bevy_pbr's
|
||
/// PERSPECTIVE view key — but nearly every real bake installs
|
||
/// `Projection::custom(WowPortraitProjection)` ([`frame`]'s authored path; [`body_frame`]
|
||
/// always), which is the NONSTANDARD view key: a distinct pipeline per variant, so the whole
|
||
/// samples=1 twin space compiled AGAIN, live, on the first target/paperdoll bake after entry.
|
||
/// This camera is that missing key space: the booth view shape exactly, with the custom
|
||
/// projection class real bakes use. pipe_warm spawns it beside the menagerie and despawns it at
|
||
/// drain. A REAL booth's projection is deliberately never stamped for warming — a bake completing
|
||
/// during the warm window would keep a misframed image. (The placeholder-Perspective class stays
|
||
/// warmed through a real booth: [`frame`]'s camera-less fallback still bakes with it.)
|
||
pub(crate) fn spawn_warm_booth(
|
||
commands: &mut Commands,
|
||
images: &mut Assets<Image>,
|
||
) -> (Entity, RenderLayers) {
|
||
let layer = RenderLayers::layer(WARM_BOOTH_LAYER);
|
||
let image = images.add(new_target_image(PORTRAIT_SIZE));
|
||
let cam = commands
|
||
.spawn((
|
||
booth_view_shape(),
|
||
Camera {
|
||
order: -100 + WARM_BOOTH_LAYER as isize,
|
||
clear_color: ClearColorConfig::Custom(Color::srgb(0.055, 0.045, 0.04)),
|
||
..default()
|
||
},
|
||
RenderTarget::Image(image.into()),
|
||
benilla_world::ffx_glow::FfxGlow::BOOTH,
|
||
Projection::custom(framing::WowPortraitProjection {
|
||
fov: framing::PANE_FIXED_FOV,
|
||
near: 0.02,
|
||
far: 100.0,
|
||
// The warm pass compiles PIPELINES, which the aspect does not key.
|
||
aspect: 1.0,
|
||
}),
|
||
layer.clone(),
|
||
))
|
||
.id();
|
||
(cam, layer)
|
||
}
|
||
|
||
/// Startup: stand up one booth per slot — its image (registered in [`PortraitImages`]), a model-root
|
||
/// entity, and a camera rendering only that slot's layer into the image (transparent, no bloom/MSAA,
|
||
/// rendered before the world camera via a negative order).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn setup_booths(
|
||
mut commands: Commands,
|
||
mut images: ResMut<Assets<Image>>,
|
||
mut portraits: ResMut<PortraitImages>,
|
||
mut booths: ResMut<Booths>,
|
||
mut booth_light: ResMut<BoothLight>,
|
||
mut mirrors: ResMut<benilla_world::rig_palette::RigPaletteMirrors>,
|
||
device: Res<bevy::render::renderer::RenderDevice>,
|
||
queue: Res<bevy::render::renderer::RenderQueue>,
|
||
) {
|
||
// The studio-light buffer: same LAYOUT as the shared world light, written once, never touched
|
||
// again (fixed values — no per-frame upload, no render-world system). Sized to the FULL blob
|
||
// (`light_blob_bytes` — header rows + the 0278 point-light table): the model shader declares the
|
||
// whole struct and wgpu validates the bound size against it at every draw. Only the studio header
|
||
// rows are written; wgpu zero-initializes the rest, so `point_count = 0` and no scene point light
|
||
// ever touches a portrait (the studio look is deliberately static).
|
||
let light_buffer = |label: &'static str, blob: benilla_world::lighting::LightBlob| {
|
||
let buffer = blob.create(&device, label);
|
||
blob.write(&queue, &buffer);
|
||
buffer
|
||
};
|
||
booth_light.studio.buffer = Some(light_buffer("wow_portrait_light", studio_light()));
|
||
// The body panes' own light — the reference `<PlayerModel>` widget's (see the fn's doc).
|
||
booth_light.pane.buffer = Some(light_buffer("wow_model_pane_light", model_pane_light()));
|
||
// Booth rigs skin from the palette regions of THESE buffers (decision 0720): register both
|
||
// as mirrors so the palette upload keeps their regions live.
|
||
for (key, buf) in [
|
||
("portrait", &booth_light.studio.buffer),
|
||
("pane", &booth_light.pane.buffer),
|
||
] {
|
||
if let Some(b) = buf {
|
||
mirrors.0.insert(key, b.clone());
|
||
}
|
||
}
|
||
|
||
for (i, token) in SLOTS.iter().enumerate() {
|
||
let image = images.add(new_target_image(PORTRAIT_SIZE));
|
||
portraits
|
||
.0
|
||
.insert((*token).to_string(), PortraitSource::Live(image.clone()));
|
||
let layer = RenderLayers::layer(PORTRAIT_LAYER_BASE + i);
|
||
let root = commands
|
||
.spawn((Transform::IDENTITY, Visibility::Visible, layer.clone()))
|
||
.id();
|
||
commands.spawn((
|
||
// The shared booth view shape — HDR intermediate, no tonemap, no MSAA (its doc has
|
||
// the whys); per-slot here: the order, the backdrop, the glow node, the projection.
|
||
booth_view_shape(),
|
||
Camera {
|
||
// Render the booths first (negative order), so the freshly baked image is ready when
|
||
// the world + UI cameras run. One order per slot to keep them distinct.
|
||
order: -100 + i as isize,
|
||
// The ref bake's opaque near-black backdrop (the world must never show through the
|
||
// circle); the round cut happens at draw time (ui_quad.wgsl's circular mask).
|
||
clear_color: ClearColorConfig::Custom(Color::srgb(0.055, 0.045, 0.04)),
|
||
..default()
|
||
},
|
||
// The render target is its own component in Bevy 0.18 (Camera `#[require]`s it).
|
||
RenderTarget::Image(image.clone().into()),
|
||
// The gamma lane (0161): booth materials emit gamma bytes like the world's, so the
|
||
// booth needs the same final node — the FFXGlow combine owns the frame's ONE decode.
|
||
// This also keeps the bake at exact world parity (same glow, same transform chain);
|
||
// without it the portrait reads one encode too bright.
|
||
benilla_world::ffx_glow::FfxGlow::BOOTH,
|
||
Projection::from(PerspectiveProjection {
|
||
fov: PORTRAIT_FOV,
|
||
near: 0.02,
|
||
far: 100.0,
|
||
..default()
|
||
}),
|
||
layer.clone(),
|
||
BoothCam((*token).to_string()),
|
||
));
|
||
booths.0.insert(
|
||
(*token).to_string(),
|
||
Booth {
|
||
layer,
|
||
root,
|
||
target: image,
|
||
baked: None,
|
||
baked_guid: None,
|
||
snap: None,
|
||
shown: false,
|
||
show_rev: 0,
|
||
wake: 0,
|
||
live: false,
|
||
pending: Vec::new(),
|
||
pending_since: None,
|
||
pipes_settling: false,
|
||
pipes_since: None,
|
||
aspect: 1.0,
|
||
rigged: false,
|
||
parked: false,
|
||
turn: Turn::default(),
|
||
},
|
||
);
|
||
}
|
||
|
||
// The four **body** booths — the character window's paper doll (decision 0208 §5), the
|
||
// inspect window's pane (decision 0631 §4), the pet paper doll's (decision 1057) and the
|
||
// stable window's (wow-re `stable-master-window.md` §7.1). Same off-screen pipeline as the
|
||
// portrait slots (transparent target, HDR + the FFXGlow node, negative order so the bake is
|
||
// ready before the world/UI cameras), but their own 512² targets, their own layers, and a
|
||
// body-framing projection (aimed per-bake by `sync_body_booth`). Kept a separate spawn from
|
||
// the portrait loop above so these cameras stay byte-for-byte what the director approved.
|
||
for (i, (slot, layer_index)) in [
|
||
(PAPERDOLL_SLOT, PAPERDOLL_LAYER),
|
||
(INSPECT_SLOT, INSPECT_LAYER),
|
||
(PETDOLL_SLOT, PETDOLL_LAYER),
|
||
(STABLE_SLOT, STABLE_LAYER),
|
||
]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
let image = images.add(new_target_image(PAPERDOLL_SIZE));
|
||
portraits
|
||
.0
|
||
.insert(slot.to_string(), PortraitSource::Live(image.clone()));
|
||
let layer = RenderLayers::layer(layer_index);
|
||
let root = commands
|
||
.spawn((Transform::IDENTITY, Visibility::Visible, layer.clone()))
|
||
.id();
|
||
commands.spawn((
|
||
booth_view_shape(),
|
||
Camera {
|
||
order: -100 + (SLOTS.len() + i) as isize,
|
||
// **TRANSPARENT** — a `<PlayerModel>` widget has no backdrop of its own (decision
|
||
// 1083). The reference's three model frames declare nothing but the widget; what
|
||
// fills the pane behind the figure is the PAGE's own art, the same
|
||
// `UI-Character-CharacterTab-*` / `UI-Character-General-*` plates our transcription
|
||
// already draws, and the model composites over them. Baking an opaque backdrop into
|
||
// the target painted those plates out — the director's report is exactly that: "our
|
||
// paperdoll is pure black bg instead of having what the ref has, slightly off black
|
||
// and textured" (2026-08-07). The glue booth has composited this way since 0807;
|
||
// the combine passes scene alpha through untouched.
|
||
clear_color: ClearColorConfig::Custom(Color::NONE),
|
||
..default()
|
||
},
|
||
RenderTarget::Image(image.clone().into()),
|
||
// Decode, but NO glow: these two stand in for 1.12 `<PlayerModel>` widgets, which the
|
||
// reference paints in the UI strata — after the WorldFrame's own FFX apply — so they
|
||
// never carry the scene glow (decision 0638; [`benilla_world::ffx_glow::FfxGlow::UI_PANE`]).
|
||
benilla_world::ffx_glow::FfxGlow::UI_PANE,
|
||
// Placeholder — `sync_body_booth` overwrites transform + projection from the unit's
|
||
// bounds on the first bake. A plain perspective is harmless while the model is loading.
|
||
Projection::from(PerspectiveProjection {
|
||
fov: PORTRAIT_FOV,
|
||
near: 0.02,
|
||
far: 100.0,
|
||
..default()
|
||
}),
|
||
layer.clone(),
|
||
BoothCam(slot.to_string()),
|
||
));
|
||
booths.0.insert(
|
||
slot.to_string(),
|
||
Booth {
|
||
layer,
|
||
root,
|
||
target: image,
|
||
baked: None,
|
||
baked_guid: None,
|
||
snap: None,
|
||
shown: false,
|
||
show_rev: 0,
|
||
wake: 0,
|
||
live: false,
|
||
pending: Vec::new(),
|
||
pending_since: None,
|
||
pipes_settling: false,
|
||
pipes_since: None,
|
||
aspect: 1.0,
|
||
rigged: false,
|
||
parked: false,
|
||
turn: Turn::default(),
|
||
},
|
||
);
|
||
}
|
||
|
||
// The glue booth (decisions 0423 + 0465): its own slot/layer/target, framed per-bake.
|
||
glue_booth::spawn_glue_booth(&mut commands, &mut images, &mut portraits, &mut booths);
|
||
// The dressing room (decision 1060): a third body pane, tuple-driven like the glue booth but
|
||
// lit and framed like the paper doll.
|
||
dressup::spawn_dressup_booth(&mut commands, &mut images, &mut portraits, &mut booths);
|
||
}
|
||
|
||
/// `true` while the `WOW_PORTRAIT_TEST` debug bake owns the booths (checked once — env vars don't
|
||
/// change mid-run).
|
||
fn test_mode(cached: &mut Option<bool>) -> bool {
|
||
*cached.get_or_insert_with(|| std::env::var("WOW_PORTRAIT_TEST").is_ok_and(|s| !s.is_empty()))
|
||
}
|
||
|
||
/// benilla's **`UNIT_MODEL_CHANGED`**, for the two producers that are not a change of dress
|
||
/// ([`crate::entities::DressKey`] is the rest). One counter per unit; a body pane re-takes its
|
||
/// snapshot when it moves ([`SnapKey`]).
|
||
///
|
||
/// - **The manual sheath ceremony's own keyframe.** `SetSheatheState(…, bInstant = 0)` — which
|
||
/// only `ToggleSheath` passes, on all three legs — runs the ceremony, and the clip's `$SHL`/
|
||
/// `$SHR` event marks the unit **unconditionally** (`0x611b60` → `0x5ffb10`, the mark at
|
||
/// `0x5ffbbe`, a function with one `ret`). So pressing Z moves an open character sheet's doll,
|
||
/// one Lua round-trip later. Every *snap* sheath change — the combat auto-draw, the stand-state
|
||
/// stow, the descriptor apply — takes `bInstant != 0` and reaches the queue only through the
|
||
/// enchant-gated `0x5eed50`, which is why drawing a bow on a mob does **not** move the doll.
|
||
/// That split is `#bugs` B324.
|
||
/// - **An item's glow instances landing** — ours, not the reference's. Its widget duplicates a
|
||
/// model the world had already finished building; our `ItemVisuals` models stream in, and a key
|
||
/// blind to their arrival would leave a permanently-glowing weapon glowing nothing in the
|
||
/// character window (the case [`LookKey`]'s effects term was added for).
|
||
#[derive(Component, Default, Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub(crate) struct ModelRevision(pub(crate) u32);
|
||
|
||
/// Bump [`ModelRevision`] for every unit one of its producers fired on this frame.
|
||
fn bump_model_revision(
|
||
mut commands: Commands,
|
||
mut swaps: MessageReader<crate::creature_anim::SheathSwapMessage>,
|
||
glows: Query<&benilla_world::model_fade::ParentModel, Added<crate::entities::ItemGlowAttached>>,
|
||
revs: Query<&ModelRevision>,
|
||
) {
|
||
let mut bumped = <bevy::platform::collections::HashSet<Entity>>::default();
|
||
let units = swaps
|
||
.read()
|
||
.map(|m| m.entity)
|
||
// A glow's root is chained to the WEARER (`spawn_slot` stamps `ParentModel(wearer)`),
|
||
// which is the unit whose model just gained geometry.
|
||
.chain(glows.iter().map(|p| p.0));
|
||
for unit in units {
|
||
if !bumped.insert(unit) {
|
||
continue;
|
||
}
|
||
let next = revs.get(unit).map_or(0, |r| r.0).wrapping_add(1);
|
||
commands.entity(unit).try_insert(ModelRevision(next));
|
||
}
|
||
}
|
||
|
||
/// The attach ids the **sheath lane owns** — the hand points a weapon is drawn into, the forearm a
|
||
/// shield takes, and the six sheath points plus the shield's back slot it is stowed at. A draw or
|
||
/// a stow moves an item among these, and for two kinds of item makes it vanish outright: a
|
||
/// **ranged** weapon renders nothing while stowed (`0x611770` detaches it and never re-attaches —
|
||
/// `ranged-sheath-display.md`), and so does a melee weapon whose `SheatheType` is 0. The worn
|
||
/// quiver rides `0x1a` on the same gate.
|
||
///
|
||
/// [`SnapKey`] is blind to every one of them, which is the whole mechanism of `#bugs` B324: a
|
||
/// widget's duplicate must not notice that the world drew a weapon.
|
||
fn sheath_lane(attach: Option<u16>) -> bool {
|
||
matches!(attach, Some(0..=2 | 26..=28 | 30..=33))
|
||
}
|
||
|
||
/// **What a body pane re-takes its snapshot on** — the reference's re-`SetUnit` set, as content.
|
||
///
|
||
/// A `<PlayerModel>` does not mirror the unit: it **duplicates** the unit's `CM2Model` once and
|
||
/// renders the copy (`0x5059a0` → `0x707400`), and the copy is dead to everything the world does
|
||
/// afterwards — `[dup+0x34]` holds the source only long enough to build, and is Released and
|
||
/// nulled inside `0x707400` itself (wow-re `ui/scratch/paperdoll-liveness-law.md`, §5 verified).
|
||
/// Ours mirrored the live tree every frame, so a bow drawn in combat walked straight onto the
|
||
/// character sheet.
|
||
///
|
||
/// The copy is re-taken on exactly four things, and each is a field here:
|
||
///
|
||
/// 1. **The pane becoming visible** ([`Self::show`]). Not a Lua event at all — `0x505d00` is the
|
||
/// widget's effective-visible SHOW override (primary vtable index 34) and re-duplicates on
|
||
/// every hidden→visible edge; index 33 `0x505ce0` destroys the model on hide. This is why
|
||
/// `PaperDollFrame_OnShow` calls no `SetUnit` and the doll still tracks your gear.
|
||
/// 2. **A change of dress** ([`Self::dress`], [`Self::stable`]) — `UNIT_MODEL_CHANGED`, whose one
|
||
/// fire site image-wide is `0x524df1`. Equipping or unequipping a visible item marks the unit
|
||
/// unconditionally (`0x5e2810` → `0x5dee30`); so do a displayId change, a helm/cloak toggle and
|
||
/// an enchant. **Nocked ammo does not** (`0x60ba30` reaches no queue site), and neither does
|
||
/// entering combat.
|
||
/// 3. **An explicit model event** ([`Self::rev`]) — the manual sheath ceremony's own `$SHL`/`$SHR`
|
||
/// keyframe, and our late-arriving item glows. See [`ModelRevision`].
|
||
/// 4. **A resize** ([`Self::aspect_bits`]) — `DISPLAY_SIZE_CHANGED` → `RefreshUnit()`, the one
|
||
/// re-take the FrameXML does register, and the reason the widget re-snapshots its camera (1069).
|
||
#[derive(PartialEq, Eq)]
|
||
struct SnapKey {
|
||
/// A different body is a different `SetUnit`.
|
||
unit: Entity,
|
||
/// The mirrored geometry **no sheath change can move**: the unit's own body parts — which
|
||
/// carry the armour composite, and the reference shares that texture object by pointer, so a
|
||
/// re-blit is meant to reach an open doll — plus every rider and card outside
|
||
/// [`sheath_lane`]: the helm, the pauldrons, their glows and their emitters.
|
||
stable: Vec<(AssetId<Mesh>, AssetId<WowModelMaterial>)>,
|
||
/// The same cut over the effect seats [`LookKey`] keys on, for the same reason.
|
||
stable_fx: Vec<(u16, [u32; 3], usize)>,
|
||
/// The three weapon slots, resolved **above** the placement gate — the only lane whose sheath
|
||
/// state can decide whether an item exists at all, and so the one that cannot be read off the
|
||
/// mirrored tree ([`crate::entities::DressKey`]).
|
||
dress: Option<crate::entities::DressKey>,
|
||
rev: u32,
|
||
show: u32,
|
||
aspect_bits: u32,
|
||
}
|
||
|
||
impl SnapKey {
|
||
/// Build the key for `unit`'s pane this frame. `dress`/`rev` are the unit's own components
|
||
/// (absent until its equipment first resolves, which is simply another value); `show` and
|
||
/// `aspect` are the booth's.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build(
|
||
unit: Entity,
|
||
parts: &[&PortraitPart],
|
||
riders: &[&PortraitRider],
|
||
billboards: &[&PortraitBillboard],
|
||
effects: &[&PortraitEffects],
|
||
dress: Option<crate::entities::DressKey>,
|
||
rev: u32,
|
||
show: u32,
|
||
aspect: f32,
|
||
) -> Self {
|
||
// Sorted, so the key cannot move for a reason as incidental as traversal order: adding or
|
||
// removing a sheath-lane child re-orders the siblings popped around it.
|
||
let mut stable: Vec<(AssetId<Mesh>, AssetId<WowModelMaterial>)> = parts
|
||
.iter()
|
||
.map(|p| (p.static_mesh.id(), p.material.id()))
|
||
.chain(
|
||
riders
|
||
.iter()
|
||
.filter(|r| !sheath_lane(r.attach))
|
||
.map(|r| (r.static_mesh.id(), r.material.id())),
|
||
)
|
||
.chain(
|
||
billboards
|
||
.iter()
|
||
.filter(|b| !sheath_lane(b.attach))
|
||
.map(|b| (b.mesh.id(), b.material.id())),
|
||
)
|
||
.collect();
|
||
stable.sort_unstable();
|
||
let mut stable_fx: Vec<(u16, [u32; 3], usize)> = effects
|
||
.iter()
|
||
.filter(|f| !sheath_lane(f.attach))
|
||
.map(|e| {
|
||
(
|
||
e.bone,
|
||
e.offset.to_array().map(f32::to_bits),
|
||
e.emitters.len(),
|
||
)
|
||
})
|
||
.collect();
|
||
stable_fx.sort_unstable();
|
||
SnapKey {
|
||
unit,
|
||
stable,
|
||
stable_fx,
|
||
dress,
|
||
rev,
|
||
show,
|
||
aspect_bits: aspect.to_bits(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The reference's **attach reset** — does `0x47a230` detach a sub-model hanging at this M2
|
||
/// attachment id from the duplicate a model widget renders?
|
||
///
|
||
/// Every widget that shows a live unit builds its model by *duplicating* the unit's own
|
||
/// `CM2Model`, attachment tree and all (`0x707400`/`0x70ea00` deep-copy the children at the same
|
||
/// attach ids), and then runs this partial reset on the copy — the `<PlayerModel>` paper doll via
|
||
/// `0x5059a0 → 0x47a230`, the round unit portrait via `0x525261` (wow-re
|
||
/// `ui/scratch/dressup-model-equipment.md` §1, `ui/scratch/portrait-render.md` §4, both VERIFIED).
|
||
///
|
||
/// It detaches fifteen ids — `0xf` (twice), `0x10`, `0x11`, `0x12`, `0x13`–`0x19`, `0x1d`, `0x22`,
|
||
/// `0x23` — and pointedly **keeps** the three hand points `0`/`1`/`2`, the sheath family
|
||
/// `0x1a`/`0x1b`/`0x1c`/`0x1e`–`0x21`, and the worn slots (helm `0xb`, shoulders `5`/`6`,
|
||
/// cape `0xc`). Read as a family, the cut is *everything transient*: chest blood front/back,
|
||
/// breath, the two nameplates, Base, Head, the two spell hands, Special1–3, Chest — and
|
||
/// **HandArrow (`0x23`)**, the nocked arrow. Equipment survives; effects do not.
|
||
///
|
||
/// `0x23` is the one that shows up in a bug report (`#bugs` B324): our booths mirrored the
|
||
/// nocked arrow onto the character-window doll, where the reference can never draw one.
|
||
fn attach_reset(attach: Option<u16>) -> bool {
|
||
matches!(attach, Some(0xf..=0x19 | 0x1d | 0x22 | 0x23))
|
||
}
|
||
|
||
/// The reference's **hand grip** for a widget's duplicate, from attachment occupancy alone
|
||
/// (`0x5059a0` at `505a34`/`505a4d`: `GetAttachment(model, 2)` non-null → `CloseHand(1)`,
|
||
/// `GetAttachment(model, 1)` non-null → `CloseHand(0)` — wow-re
|
||
/// `animation/scratch/hand-grip-mechanism.md` §4c, which names this the cleanest expression of the
|
||
/// rule and the one benilla should implement).
|
||
///
|
||
/// Returns [`booth::spawn_booth_model`]'s `[right, left]`. **Occupancy, not sheath state and not
|
||
/// combat**: a hand holding anything closes; the shield's forearm point (`0`) closes nothing. The
|
||
/// probe runs over the mirrored set *after* [`attach_reset`], exactly as the reference probes the
|
||
/// duplicate after `0x47a230`.
|
||
fn hand_grip(
|
||
riders: &[&PortraitRider],
|
||
billboards: &[&PortraitBillboard],
|
||
effects: &[&PortraitEffects],
|
||
) -> [bool; 2] {
|
||
// A wand's whole model can be a single camera-facing batch and an emitter set, so the probe
|
||
// has to see every mirror lane, not just the meshes.
|
||
let occupied = |id: u16| {
|
||
riders.iter().any(|r| r.attach == Some(id))
|
||
|| billboards.iter().any(|b| b.attach == Some(id))
|
||
|| effects.iter().any(|f| f.attach == Some(id))
|
||
};
|
||
[
|
||
occupied(crate::entities::attach_id::HAND_RIGHT),
|
||
occupied(crate::entities::attach_id::HAND_LEFT),
|
||
]
|
||
}
|
||
|
||
/// The three queries that read a unit's **dressed look** — the attach-spawned [`PortraitPart`] /
|
||
/// [`PortraitRider`] descendants a booth mirrors. Bundled as one `SystemParam` so `sync_portraits`
|
||
/// and `sync_paperdoll` stay under Bevy's 16-parameter system ceiling, and share the one
|
||
/// descendants walk ([`Self::collect`]) instead of open-coding it twice.
|
||
#[derive(SystemParam)]
|
||
struct DressedLook<'w, 's> {
|
||
children: Query<'w, 's, &'static Children>,
|
||
parts: Query<'w, 's, &'static PortraitPart>,
|
||
riders: Query<'w, 's, &'static PortraitRider>,
|
||
billboards: Query<'w, 's, &'static PortraitBillboard>,
|
||
effects: Query<'w, 's, &'static PortraitEffects>,
|
||
mounts: Query<'w, 's, (), With<crate::entities::mount::MountBody>>,
|
||
/// The two re-snapshot inputs a body pane cannot read off the mirrored tree ([`SnapKey`]).
|
||
/// They ride here because this is already the "what does this unit look like" param, and
|
||
/// because all three body-pane systems were at Bevy's 16-parameter ceiling.
|
||
dress: Query<'w, 's, &'static crate::entities::DressKey>,
|
||
revs: Query<'w, 's, &'static ModelRevision>,
|
||
/// A [`PortraitStandIn`] subject's display id — the one thing a bodiless subject carries that
|
||
/// a world unit reads off its [`crate::entities::NetEntity`] instead. It rides this param for
|
||
/// the same reason `dress`/`revs` do: all four body-pane systems sit at Bevy's 16-parameter
|
||
/// ceiling, and this is already the "what does this subject look like" bundle.
|
||
stand_in: Query<'w, 's, &'static PortraitStandIn>,
|
||
}
|
||
|
||
impl DressedLook<'_, '_> {
|
||
/// Walk `unit`'s descendants once, collecting its part + rider children. All empty while the
|
||
/// unit's model is still loading / cube-fallback (no attach path has spawned the parts yet).
|
||
///
|
||
/// A mounted unit's MOUNT child (decision 0441) is a second creature under the unit — a
|
||
/// portrait/paper-doll shows the character alone, never the horse (the ref's `Model:SetUnit`
|
||
/// binds the player model, not the mount). Its **parts** (the mount's body meshes) are
|
||
/// skipped by pruning on the [`mount::MountBody`] marker; **riders** stay collected from the
|
||
/// whole tree, because the rider's own helm/shoulder/held riders hang under the rider's
|
||
/// joints, which re-root under the mount's seat anchor INSIDE the mount subtree while
|
||
/// mounted — and a mount model never carries equipment riders of its own. An item's
|
||
/// camera-facing batches and effects hang off those same joints, so they follow the rider rule,
|
||
/// not the part rule; only the CHARACTER's own billboard (the eye-glow) prunes with the body.
|
||
fn collect(
|
||
&self,
|
||
unit: Entity,
|
||
) -> (
|
||
Vec<&PortraitPart>,
|
||
Vec<&PortraitRider>,
|
||
Vec<&PortraitBillboard>,
|
||
Vec<&PortraitEffects>,
|
||
) {
|
||
let mut parts = Vec::new();
|
||
let mut riders = Vec::new();
|
||
let mut billboards = Vec::new();
|
||
let mut effects = Vec::new();
|
||
let mut stack: Vec<(Entity, bool)> = vec![(unit, false)];
|
||
while let Some((e, mut in_mount)) = stack.pop() {
|
||
in_mount |= self.mounts.contains(e);
|
||
if !in_mount {
|
||
if let Ok(p) = self.parts.get(e) {
|
||
parts.push(p);
|
||
}
|
||
}
|
||
if let Ok(r) = self.riders.get(e) {
|
||
if !attach_reset(r.attach) {
|
||
riders.push(r);
|
||
}
|
||
}
|
||
// A camera-facing batch prunes by whose model it is ([`PortraitSeat`]): a mount's own
|
||
// glow card goes with the mount's meshes, an item's card rides an attach joint that
|
||
// re-roots INSIDE the mount subtree while mounted and must survive it like a rider.
|
||
if let Ok(b) = self.billboards.get(e) {
|
||
if (!in_mount || b.seat != PortraitSeat::Body) && !attach_reset(b.attach) {
|
||
billboards.push(b);
|
||
}
|
||
}
|
||
// Never pruned: every publisher of this marker is an ITEM's model (its own emitters, or
|
||
// its `ItemVisuals` glow's), seated on an attach joint — the rider rule. Nothing
|
||
// publishes a mount's own emitters, so there is no mount case to exclude.
|
||
if let Ok(fx) = self.effects.get(e) {
|
||
if !attach_reset(fx.attach) {
|
||
effects.push(fx);
|
||
}
|
||
}
|
||
if let Ok(c) = self.children.get(e) {
|
||
stack.extend(c.iter().map(|child| (child, in_mount)));
|
||
}
|
||
}
|
||
(parts, riders, billboards, effects)
|
||
}
|
||
|
||
/// A stand-in subject's display id — `None` for a real world unit, which carries its own on
|
||
/// its [`crate::entities::NetEntity`].
|
||
fn stand_in_display(&self, unit: Entity) -> Option<u32> {
|
||
self.stand_in.get(unit).ok().map(|s| s.0)
|
||
}
|
||
|
||
/// `unit`'s dress and model revision — `None`/`0` before its equipment has first resolved,
|
||
/// which is simply another key value and re-snapshots when it lands.
|
||
fn snapshot_inputs(&self, unit: Entity) -> (Option<crate::entities::DressKey>, u32) {
|
||
(
|
||
self.dress.get(unit).ok().copied(),
|
||
self.revs.get(unit).map_or(0, |r| r.0),
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Each frame: for every slot, mirror the unit's **live dressed look** — its attach-spawned
|
||
/// [`PortraitPart`] children — into the booth whenever that look changes (new unit, gear swap,
|
||
/// appearance refresh), re-framing the camera from the display's anchors. A live unit whose model
|
||
/// hasn't attached yet shows the ref's 2D `TemporaryPortrait` stand-in instead (RE C5).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_portraits(
|
||
mut commands: Commands,
|
||
mut booths: ResMut<Booths>,
|
||
mut portraits: ResMut<PortraitImages>,
|
||
mut booth_light: ResMut<BoothLight>,
|
||
creatures: Option<Res<Creatures>>,
|
||
selection: Res<Selection>,
|
||
self_q: Query<Entity, With<SelfPlayer>>,
|
||
ent_q: Query<&NetEntity>,
|
||
stores_q: Query<&crate::net::ObjectStore>,
|
||
look: DressedLook,
|
||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||
mut env_cache: Local<Option<bool>>,
|
||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||
interact_npc: Res<crate::ui_session::InteractNpc>,
|
||
mut party: PartyBooths,
|
||
) {
|
||
if test_mode(&mut env_cache) {
|
||
return; // the test bake owns the booths
|
||
}
|
||
for token in SLOTS {
|
||
// A token can name a unit the object manager does not hold: a party member outside the
|
||
// local area, a pet whose object hasn't streamed yet. The reference does NOT leave that
|
||
// circle empty — RE C5 (`portrait-render.md`, CONFIRMED taxonomy) is explicit that a unit
|
||
// not held as a live UNIT object draws the 2D stand-in, and only "model/subsystem not
|
||
// ready" draws the blank. `unseen` carries that art for exactly those tokens; a token that
|
||
// names nobody at all leaves it `None` and the booth empties as before (report B315).
|
||
let mut unseen: Option<String> = None;
|
||
// The guid this slot names, when the slot is one that retains a bake (decision 1640).
|
||
let mut occupant: Option<u64> = None;
|
||
let unit: Option<Entity> = match token {
|
||
"player" => self_q.single().ok(),
|
||
"target" => selection.target,
|
||
// The target's own target (decision 1576) — one hop off `UNIT_FIELD_TARGET`, the same
|
||
// read the `"targettarget"` unit snapshot makes.
|
||
//
|
||
// **Gated on the UI actually drawing this slot**, which no other slot here is, and
|
||
// which this one needs. The other eight are gated by their own resolution: no pet, no
|
||
// party member, no interact NPC means no unit and no work. This one resolves for as
|
||
// long as anything you have selected is fighting anything — while the frame that
|
||
// draws it ships HIDDEN (1.12's `SHOW_TARGET_OF_TARGET` default is `"0"`), so
|
||
// ungated it would pay a descendants walk every frame and a re-bake on every mob's
|
||
// target switch for a circle nobody is looking at. One frame stale by construction
|
||
// (the extract runs after this), which costs the first bake a frame and nothing else.
|
||
"targettarget" => party
|
||
.panes
|
||
.0
|
||
.contains_key("targettarget")
|
||
.then(|| {
|
||
selection
|
||
.target
|
||
.and_then(|e| stores_q.get(e).ok())
|
||
.and_then(|s| s.0.unit_target())
|
||
.filter(|g| *g != 0)
|
||
.and_then(|g| party.index.0.get(&g).copied())
|
||
})
|
||
.flatten(),
|
||
// The pet, off the bar's cached guid — the same word its unit token and `UNIT_PET`
|
||
// read (`crate::ui_pet::feed_pet_unit`).
|
||
"pet" => {
|
||
let guid = party.pet_bar.spells.pet_guid;
|
||
let entity = (guid != 0)
|
||
.then(|| party.index.0.get(&guid))
|
||
.flatten()
|
||
.copied();
|
||
if entity.is_none() && guid != 0 {
|
||
unseen = Some(creature_temporary_portrait());
|
||
}
|
||
entity
|
||
}
|
||
// The NPC an interaction window is bound to (gossip / quest / merchant / trainer),
|
||
// resolved to its live entity by `feed_interact_npc` — the same bake path as "target".
|
||
"npc" => interact_npc.0,
|
||
// A party member's slot bakes only while the member is streamed (in range). Out of
|
||
// range there is no model to pose — and no descriptor either, so the stand-in's
|
||
// race/sex come from the name cache's `SMSG_NAME_QUERY_RESPONSE` triple, warmed for
|
||
// every roster entry by `net::apply::group::list`.
|
||
tok => {
|
||
let member = tok
|
||
.strip_prefix("party")
|
||
.and_then(|n| n.parse::<usize>().ok())
|
||
.and_then(|n| party.roster.party_slots().nth(n - 1));
|
||
// Whose face this slot is showing — the key the bake handover files it under,
|
||
// and the key the absent-unit arm probes the cache with (decision 1640).
|
||
occupant = member.map(|m| m.guid);
|
||
let entity = member.and_then(|m| party.index.0.get(&m.guid)).copied();
|
||
if entity.is_none() {
|
||
unseen = member.map(|m| {
|
||
let traits = party.names.player_traits(m.guid);
|
||
player_temporary_portrait(
|
||
traits.map(|(race, _, _)| race),
|
||
traits.map(|(_, _, sex)| sex),
|
||
)
|
||
});
|
||
}
|
||
entity
|
||
}
|
||
};
|
||
let Some(booth) = booths.0.get_mut(token) else {
|
||
continue;
|
||
};
|
||
let Some(unit) = unit else {
|
||
// ── The bake handover (decision 1640, report B334) ─────────────────────────────
|
||
//
|
||
// The booth is about to be emptied, and its target is a *still of the face that was
|
||
// standing in it* — so before the clear renders over it, that image becomes this
|
||
// member's entry in the guid cache and the booth is handed a fresh one. The UI is
|
||
// left sampling the very handle it sampled last frame, which now holds the frozen
|
||
// bake: the reference's "bind the cached bake" (`0x525c36`), reached without a copy
|
||
// and without a flicker.
|
||
//
|
||
// Keyed on whose bake is STANDING, not on who the slot names now: a slot whose
|
||
// occupant changed still owes the departed member their face. Only a **settled**
|
||
// bake is worth keeping — an unfinished one (camera still awake, a texture still in
|
||
// flight, pipelines still compiling) would freeze a half-rendered face forever, and
|
||
// the stand-in is the honest answer for it.
|
||
if let Some(guid) = booth.baked_guid.filter(|_| {
|
||
booth.baked.is_some()
|
||
&& booth.wake == 0
|
||
&& booth.pending.is_empty()
|
||
&& !booth.pipes_settling
|
||
}) {
|
||
let fresh = party.images.add(new_target_image(PORTRAIT_SIZE));
|
||
for (cam, mut target) in &mut party.targets {
|
||
if cam.0 == *token {
|
||
*target = RenderTarget::Image(fresh.clone().into());
|
||
}
|
||
}
|
||
party
|
||
.bakes
|
||
.store(guid, std::mem::replace(&mut booth.target, fresh));
|
||
}
|
||
booth.baked_guid = None;
|
||
// No unit: empty the booth (the frame itself is hidden on UnitExists false; the dark
|
||
// disc behind it never shows).
|
||
if booth.baked.is_some() {
|
||
commands.entity(booth.root).despawn_related::<Children>();
|
||
booth.baked = None;
|
||
// Render the emptied stage (decision 0540): the target must hold the cleared
|
||
// backdrop, not the departed unit's face, before the camera sleeps.
|
||
booth.wake = BOOTH_SETTLE_FRAMES;
|
||
booth.pending.clear();
|
||
// The despawn reaped meshes and anchors; the rig state on the ROOT (pose buffer,
|
||
// player, palette slot) needs its own strip or it evaluates behind the emptied
|
||
// stage for as long as the booth stands empty ([`booth::clear_booth_rig`]).
|
||
clear_booth_rig(&mut commands, booth.root);
|
||
booth.rigged = false;
|
||
booth.parked = false;
|
||
}
|
||
// A unit we hold no object for takes the **cached bake if we have ever baked them**
|
||
// and the ref's 2D stand-in otherwise (`0x525ba0`: hit → bind, miss → the
|
||
// TemporaryPortrait file); only a token that names nobody falls to the emptied
|
||
// render target.
|
||
let src = match unseen {
|
||
Some(file) => occupant
|
||
.and_then(|g| party.bakes.get(g))
|
||
.map_or(PortraitSource::File(file), PortraitSource::Live),
|
||
None => PortraitSource::Live(booth.target.clone()),
|
||
};
|
||
if portraits.0.get(token) != Some(&src) {
|
||
portraits.0.insert(token.to_string(), src);
|
||
}
|
||
continue;
|
||
};
|
||
// The unit's dressed look: its attach-spawned part children (geosets filtered, composited
|
||
// materials) + its bone riders (helm/shoulders/held — grandchildren under joint entities)
|
||
// + the camera-facing batches and effect models it wears, one descendants walk. Parts empty
|
||
// while the model is still loading / cube-fallback.
|
||
let (parts, riders, billboards, effects) = look.collect(unit);
|
||
if parts.is_empty() {
|
||
// Model not attached yet → the ref's own 2D stand-in (RE C5): sex/race for a player
|
||
// body, the Monster art for a creature (our pick — the ref's `-Pet` file belongs to
|
||
// its pet delegate). Keeps the booth's last bake around; only the bridge flips.
|
||
let file = temporary_portrait(ent_q.get(unit).ok(), stores_q.get(unit).ok());
|
||
let src = PortraitSource::File(file);
|
||
if portraits.0.get(token) != Some(&src) {
|
||
portraits.0.insert(token.to_string(), src);
|
||
}
|
||
continue;
|
||
}
|
||
let key = LookKey::build(&parts, &riders, &billboards, &effects);
|
||
// **A changed occupant is a re-bake even at an identical look** (decision 1640). The key
|
||
// is built from mesh/material handles, so two party members in the same race, sex and
|
||
// gear share it — and without this term the booth would keep standing A's bake while
|
||
// `baked_guid` was quietly re-filed to B, so the handover would put that face in the
|
||
// cache under the wrong guid and B would never get one of their own. The bake itself is
|
||
// per-unit; the key alone cannot say so.
|
||
if booth.baked.as_ref() != Some(&key) || booth.baked_guid != occupant {
|
||
let display_id = ent_q.get(unit).ok().and_then(|n| n.display_id);
|
||
// The look changed — re-bake: studio-lit twins of the exact dressed materials, posed
|
||
// at Stand on the booth's own throwaway skeleton (the ref bake — riders ride their
|
||
// bone's joint, exactly like the world instance).
|
||
// Resolve the framing anchors FIRST — before anything is despawned or spawned. A
|
||
// `None` here means the display is still loading (see `booth_anchors`), and a bake
|
||
// committed now would aim the camera at fabricated zero bounds and never re-aim.
|
||
let Some(anchors) = booth_anchors(creatures.as_deref(), display_id) else {
|
||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||
log_bake(
|
||
token,
|
||
"wait-anchors",
|
||
&parts,
|
||
&riders,
|
||
&billboards,
|
||
&effects,
|
||
);
|
||
continue;
|
||
};
|
||
let rig = creatures
|
||
.as_deref()
|
||
.zip(display_id)
|
||
.and_then(|(c, d)| c.display_rig(d));
|
||
let booth_parts: Vec<BoothPart> = parts
|
||
.iter()
|
||
.map(|p| BoothPart {
|
||
skinned: p.skinned_mesh.clone(),
|
||
static_mesh: p.static_mesh.clone(),
|
||
material: booth_light.studio.variant(&p.material, &mut wow_mats),
|
||
// `None` — the same known gap as the glue preview's (decision 0807): a
|
||
// mirrored `PortraitPart` doesn't carry the batch's alpha loops.
|
||
alpha_anim: None,
|
||
twins: BoothTwins::default(),
|
||
})
|
||
.collect();
|
||
let booth_riders: Vec<BoothRider> = riders
|
||
.iter()
|
||
.map(|r| BoothRider {
|
||
mesh: r.static_mesh.clone(),
|
||
material: booth_light.studio.variant(&r.material, &mut wow_mats),
|
||
bone: r.bone,
|
||
offset: r.offset,
|
||
twins: BoothTwins::default(),
|
||
})
|
||
.collect();
|
||
// The camera-facing batches — the eye-glow on its eye bone, an item's gem/halo at its
|
||
// attach point — seated on their bone's booth joint and camera-faced by the booth (relit
|
||
// onto the studio buffer like everything else; harmless where the batch is fullbright).
|
||
let booth_billboards: Vec<BoothBillboardSpec> = billboards
|
||
.iter()
|
||
.map(|b| BoothBillboardSpec {
|
||
mesh: b.mesh.clone(),
|
||
material: booth_light.studio.variant(&b.material, &mut wow_mats),
|
||
bone: b.bone,
|
||
offset: b.seat.offset(),
|
||
kind: b.kind,
|
||
twins: BoothTwins::default(),
|
||
})
|
||
.collect();
|
||
// A source material was not resident, so at least one "twin" above is the WORLD
|
||
// material — bound to the world light buffer, which this booth's whole point is to
|
||
// avoid ("would render a night portrait pitch black", `light.rs`). Abandon the bake:
|
||
// `booth.baked` stays untouched, so the parts-key compare re-fires next frame and we
|
||
// retry once the material lands. The previous bake stays on screen meanwhile.
|
||
if booth_light.studio.take_unready() {
|
||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||
continue;
|
||
}
|
||
commands.entity(booth.root).despawn_related::<Children>();
|
||
let booth_rig = spawn_booth_model(
|
||
&mut commands,
|
||
&mut party.palettes,
|
||
booth.root,
|
||
booth.layer.clone(),
|
||
&booth_parts,
|
||
&booth_riders,
|
||
rig.as_ref().and_then(|r| {
|
||
r.inverse_bindposes
|
||
.as_ref()
|
||
.map(|ibp| (r.skeleton, ibp, r.animations))
|
||
}),
|
||
anim_data.as_deref().map(|a| &a.0),
|
||
BoothMotion::Frozen,
|
||
// Open hands, and NOT because "a still portrait sheaths its weapons" — it does
|
||
// not; it mirrors whatever the body holds, same as a pane. The reference's
|
||
// portrait bake (`0x524f60`) duplicates and runs the attach reset `0x47a230`, and
|
||
// that is *all*: unlike `0x5059a0` it never probes the hand points or calls
|
||
// `0x479660`, and the duplicate starts from a fresh `0x70dd70` init that carries
|
||
// no armed anim blocks across. So the reference's own portrait holds a weapon in
|
||
// an open hand — rarely in frame on a bust, and its rule all the same.
|
||
[false, false],
|
||
&booth_billboards,
|
||
BoothInstance::default(),
|
||
);
|
||
// Even a Frozen still re-evaluates its paused pose every frame — the park matters
|
||
// MOST here, since a portrait's camera sleeps for the whole session outside its
|
||
// bake window.
|
||
booth.rigged = booth_rig.rigged();
|
||
booth_rig.finish(&mut commands);
|
||
booth.parked = false;
|
||
// **No emitters here, and that is the reference's own answer** (decision 0822): the
|
||
// round portrait is a ONE-SHOT bake — a fresh M2 scene + instance, one `0x707680` draw,
|
||
// the texture cached by GUID/displayId and returned with *no re-render* on a hit, nothing
|
||
// persisting between bakes (wow-re `portrait-render.md` §2, byte-verified). A particle
|
||
// emitter contributes nothing to a single frame of a freshly-born pool, so the ref's
|
||
// portrait shows none — and a booth that spawned them would either freeze a cloud
|
||
// mid-birth into the still or have to render forever for a 256² face. The batches above
|
||
// *are* geometry, so they draw in that one frame and belong here. The body panes are a
|
||
// different widget with a different law — see [`sync_body_booth`].
|
||
//
|
||
// Frame through the display's authored portrait camera (heuristic anchors for the
|
||
// camera-less few), resolved above before anything was torn down.
|
||
log_frame(token, &anchors, &frame(&anchors).0);
|
||
aim(&mut cams, token, &frame(&anchors));
|
||
log_bake(token, "bake", &parts, &riders, &billboards, &effects);
|
||
wake_booth(
|
||
booth,
|
||
&wow_mats,
|
||
booth_parts
|
||
.iter()
|
||
.map(|p| &p.material)
|
||
.chain(booth_riders.iter().map(|r| &r.material))
|
||
.chain(booth_billboards.iter().map(|b| &b.material)),
|
||
);
|
||
booth.baked = Some(key);
|
||
booth.baked_guid = occupant;
|
||
}
|
||
let live = PortraitSource::Live(booth.target.clone());
|
||
if portraits.0.get(token) != Some(&live) {
|
||
portraits.0.insert(token.to_string(), live);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Each frame: mirror the **player's** dressed look into the paper-doll booth — the SAME
|
||
/// [`PortraitPart`]/[`PortraitRider`] children the `"player"` portrait slot mirrors, so a gear /
|
||
/// appearance change flips the parts key and re-bakes the full-body pane exactly as it re-bakes the
|
||
/// face. Differences from [`sync_portraits`]: only the self player feeds it, the framing is
|
||
/// full-body ([`body_frame`] from the model's bounds, never the authored bust camera — decision
|
||
/// 0208 §5), and the model root spins to the pane's [`PaperDollBooth::yaw`] (the ref's
|
||
/// `Model:SetRotation`).
|
||
///
|
||
/// **What re-bakes.** A [`SnapKey`] change respawns the posed instance and re-aims the (yaw-
|
||
/// independent) camera; a bare yaw change only re-rotates the root — neither happens on an unchanged
|
||
/// frame. The bake stands ready whether or not the window is open, but the 512² *pass* only runs
|
||
/// while the pane is being drawn ([`BoothPanes`], decision 1069).
|
||
///
|
||
/// The key is **not** the mirrored geometry. A `<PlayerModel>` duplicates the unit's model once and
|
||
/// renders a copy the world can no longer reach, and it re-takes that copy on four things — the
|
||
/// pane showing, a change of dress, an explicit model event, a resize. Mirroring live put a bow
|
||
/// drawn in combat straight onto the character sheet (`#bugs` B324); [`SnapKey`] carries the whole
|
||
/// law and its byte provenance.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_paperdoll(
|
||
mut commands: Commands,
|
||
mut booths: ResMut<Booths>,
|
||
mut portraits: ResMut<PortraitImages>,
|
||
mut booth_light: ResMut<BoothLight>,
|
||
creatures: Option<Res<Creatures>>,
|
||
self_q: Query<Entity, With<SelfPlayer>>,
|
||
ent_q: Query<&NetEntity>,
|
||
look: DressedLook,
|
||
paperdoll: Res<PaperDollBooth>,
|
||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||
mut env_cache: Local<Option<bool>>,
|
||
mut last_pose: Local<Option<(f32, f32)>>,
|
||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
framing_in: BoothFraming,
|
||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||
) {
|
||
if test_mode(&mut env_cache) {
|
||
return; // the test bake owns the booths (it drives the paper doll too, see `bake_test`)
|
||
}
|
||
sync_body_booth(
|
||
&mut palettes,
|
||
PAPERDOLL_SLOT,
|
||
self_q.single().ok(),
|
||
paperdoll.yaw,
|
||
&mut last_pose,
|
||
framing_in.gx.0,
|
||
&mut commands,
|
||
&mut booths,
|
||
&mut portraits,
|
||
&mut booth_light,
|
||
creatures.as_deref(),
|
||
&ent_q,
|
||
&look,
|
||
&mut wow_mats,
|
||
&mut cams,
|
||
anim_data.as_deref(),
|
||
&framing_in.panes,
|
||
);
|
||
}
|
||
|
||
/// The inspect window's model pane (decision 0631 §4) — the paper doll's exact twin, pointed at
|
||
/// whichever unit [`crate::ui_inspect`] resolved this frame instead of at the self player.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_inspect_booth(
|
||
mut commands: Commands,
|
||
mut booths: ResMut<Booths>,
|
||
mut portraits: ResMut<PortraitImages>,
|
||
mut booth_light: ResMut<BoothLight>,
|
||
creatures: Option<Res<Creatures>>,
|
||
ent_q: Query<&NetEntity>,
|
||
look: DressedLook,
|
||
inspect: Res<InspectBooth>,
|
||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||
mut env_cache: Local<Option<bool>>,
|
||
mut last_pose: Local<Option<(f32, f32)>>,
|
||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
framing_in: BoothFraming,
|
||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||
) {
|
||
if test_mode(&mut env_cache) {
|
||
return;
|
||
}
|
||
sync_body_booth(
|
||
&mut palettes,
|
||
INSPECT_SLOT,
|
||
inspect.unit,
|
||
inspect.yaw,
|
||
&mut last_pose,
|
||
framing_in.gx.0,
|
||
&mut commands,
|
||
&mut booths,
|
||
&mut portraits,
|
||
&mut booth_light,
|
||
creatures.as_deref(),
|
||
&ent_q,
|
||
&look,
|
||
&mut wow_mats,
|
||
&mut cams,
|
||
anim_data.as_deref(),
|
||
&framing_in.panes,
|
||
);
|
||
}
|
||
|
||
/// The pet paper doll's model pane (decision 1057) — the inspect pane's exact twin, pointed at the
|
||
/// pet [`crate::ui_pet_doll`] resolved this frame.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_petdoll_booth(
|
||
mut commands: Commands,
|
||
mut booths: ResMut<Booths>,
|
||
mut portraits: ResMut<PortraitImages>,
|
||
mut booth_light: ResMut<BoothLight>,
|
||
creatures: Option<Res<Creatures>>,
|
||
ent_q: Query<&NetEntity>,
|
||
look: DressedLook,
|
||
petdoll: Res<PetDollBooth>,
|
||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||
mut env_cache: Local<Option<bool>>,
|
||
mut last_pose: Local<Option<(f32, f32)>>,
|
||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
framing_in: BoothFraming,
|
||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||
) {
|
||
if test_mode(&mut env_cache) {
|
||
return;
|
||
}
|
||
sync_body_booth(
|
||
&mut palettes,
|
||
PETDOLL_SLOT,
|
||
petdoll.unit,
|
||
petdoll.yaw,
|
||
&mut last_pose,
|
||
framing_in.gx.0,
|
||
&mut commands,
|
||
&mut booths,
|
||
&mut portraits,
|
||
&mut booth_light,
|
||
creatures.as_deref(),
|
||
&ent_q,
|
||
&look,
|
||
&mut wow_mats,
|
||
&mut cams,
|
||
anim_data.as_deref(),
|
||
&framing_in.panes,
|
||
);
|
||
}
|
||
|
||
/// The stable pane's **stand-in subject** — the bodiless [`PortraitStandIn`] entity that stands
|
||
/// for a pet with no world object (module note on [`StableBooth`]).
|
||
///
|
||
/// Keyed on the **display**, not on the selection: two stabled pets that share a display id are the
|
||
/// same model standing at the same facing, so moving the selection between them is not a re-bake —
|
||
/// and the reference is no different, since its cached branch resolves a creature record and the
|
||
/// two rows resolve to the same one. A *changed* display respawns the entity rather than refilling
|
||
/// it, so the bake's own `SnapKey` (keyed on the subject entity) cannot carry across.
|
||
#[derive(Resource, Default)]
|
||
struct StableStandIn {
|
||
/// The stand-in, while one exists.
|
||
entity: Option<Entity>,
|
||
/// The display it was spawned for.
|
||
display: Option<u32>,
|
||
/// Its mirror children are up. `false` while the display's model is still building, which
|
||
/// simply retries next frame — the glue pet's assembly discipline.
|
||
built: bool,
|
||
}
|
||
|
||
/// Keep [`StableStandIn`] pointing at the selected stabled pet: spawn one when the pane wants a
|
||
/// display it has no world body for, fill its mirror children the moment that display's model
|
||
/// finishes building, and despawn it the instant the want changes (a different pet, a live pet to
|
||
/// mirror instead, or nothing selected).
|
||
fn sync_stable_standin(
|
||
mut commands: Commands,
|
||
stable: Res<StableBooth>,
|
||
creatures: Option<Res<Creatures>>,
|
||
mut state: ResMut<StableStandIn>,
|
||
) {
|
||
// **The live pet wins.** With a world body to mirror there is nothing for a stand-in to do, and
|
||
// the reference agrees: `SetPetStablePaperdoll` reaches the creature cache only after the
|
||
// live-pet-by-GUID resolve has failed (§7.1's fall-through at `0x4cb9bc`).
|
||
let want = stable.unit.is_none().then_some(stable.display_id).flatten();
|
||
if state.display != want {
|
||
if let Some(old) = state.entity.take() {
|
||
commands.entity(old).despawn();
|
||
}
|
||
state.display = want;
|
||
state.built = false;
|
||
state.entity = want.map(|display| {
|
||
commands
|
||
.spawn((
|
||
PortraitStandIn(display),
|
||
// Hidden, at the origin: nothing under a stand-in is ever drawn in the world —
|
||
// the booth re-spawns its own copies of these meshes on its own layer, which is
|
||
// what it does for a real unit too. The pair is here so the transform and
|
||
// visibility propagation walk a well-formed tree rather than a rootless one.
|
||
Transform::default(),
|
||
Visibility::Hidden,
|
||
))
|
||
.id()
|
||
});
|
||
}
|
||
if state.built {
|
||
return;
|
||
}
|
||
let (Some(root), Some(disp), Some(creatures)) = (state.entity, want, creatures.as_deref())
|
||
else {
|
||
return;
|
||
};
|
||
// `None` while the display's model is still loading (`update_display_models` asked for it on
|
||
// this same want) — leave `built` false and retry next frame.
|
||
let Some((parts, cards)) = creatures.display_mirror(disp) else {
|
||
return;
|
||
};
|
||
let (n_parts, n_cards) = (parts.len(), cards.len());
|
||
commands.entity(root).with_children(|kids| {
|
||
for part in parts {
|
||
kids.spawn((Transform::default(), Visibility::Inherited, part));
|
||
}
|
||
for card in cards {
|
||
kids.spawn((Transform::default(), Visibility::Inherited, card));
|
||
}
|
||
});
|
||
state.built = true;
|
||
debug!(
|
||
"stable booth: stand-in for display {disp} — {n_parts} part(s), {n_cards} camera-facing"
|
||
);
|
||
}
|
||
|
||
/// The stable window's model pane — the pet `GetSelectedStablePet()` names.
|
||
///
|
||
/// The **whole fork is upstream of here**: [`crate::ui_stable`] resolves the selection into either
|
||
/// a live pet entity or a bare display id, and [`sync_stable_standin`] turns the latter into a
|
||
/// subject entity. So this is [`sync_petdoll_booth`] with one `or` — which is the point of the
|
||
/// stand-in: the summoned pet and the stabled pet reach the same bake through the same code, and so
|
||
/// cannot drift apart in framing, lighting, animation or settle.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_stable_booth(
|
||
mut commands: Commands,
|
||
mut booths: ResMut<Booths>,
|
||
mut portraits: ResMut<PortraitImages>,
|
||
mut booth_light: ResMut<BoothLight>,
|
||
creatures: Option<Res<Creatures>>,
|
||
ent_q: Query<&NetEntity>,
|
||
look: DressedLook,
|
||
stable: Res<StableBooth>,
|
||
stand_in: Res<StableStandIn>,
|
||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
|
||
mut env_cache: Local<Option<bool>>,
|
||
mut last_pose: Local<Option<(f32, f32)>>,
|
||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
framing_in: BoothFraming,
|
||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||
) {
|
||
if test_mode(&mut env_cache) {
|
||
return;
|
||
}
|
||
sync_body_booth(
|
||
&mut palettes,
|
||
STABLE_SLOT,
|
||
stable.unit.or(stand_in.entity),
|
||
stable.yaw,
|
||
&mut last_pose,
|
||
framing_in.gx.0,
|
||
&mut commands,
|
||
&mut booths,
|
||
&mut portraits,
|
||
&mut booth_light,
|
||
creatures.as_deref(),
|
||
&ent_q,
|
||
&look,
|
||
&mut wow_mats,
|
||
&mut cams,
|
||
anim_data.as_deref(),
|
||
&framing_in.panes,
|
||
);
|
||
}
|
||
|
||
/// Bake `unit`'s full-body dressed look into the `slot` booth at `yaw` — the shared body of all
|
||
/// four body booths (decision 0208 §5 for the paper doll, 0631 §4 for inspect, 1057 for the pet
|
||
/// doll, wow-re `stable-master-window.md` §7.1 for the stable pane). `unit` is `None` when there is
|
||
/// nothing to show, which empties the booth.
|
||
///
|
||
/// `unit` is a **subject**, not necessarily a world unit: a [`PortraitStandIn`] mirrors the same
|
||
/// way and carries its own display id, which is how the stable pane draws a pet that has no object
|
||
/// anywhere ([`StableBooth`]).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_body_booth(
|
||
palettes: &mut benilla_world::rig_palette::RigPalettes,
|
||
slot: &str,
|
||
unit: Option<Entity>,
|
||
yaw: f32,
|
||
last_pose: &mut Option<(f32, f32)>,
|
||
// The primary window's aspect ([`GxAspect`]) — only the model-root scale reads it.
|
||
display_aspect: f32,
|
||
commands: &mut Commands,
|
||
booths: &mut Booths,
|
||
portraits: &mut PortraitImages,
|
||
booth_light: &mut BoothLight,
|
||
creatures: Option<&Creatures>,
|
||
ent_q: &Query<&NetEntity>,
|
||
look: &DressedLook,
|
||
wow_mats: &mut Assets<WowModelMaterial>,
|
||
cams: &mut Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
anim_data: Option<&crate::creature_anim::AnimData>,
|
||
panes: &BoothPanes,
|
||
) {
|
||
let Some(booth) = booths.0.get_mut(slot) else {
|
||
return;
|
||
};
|
||
// Latch the pane's aspect while it is on screen (decision 1069). Sticky: a closed window
|
||
// publishes nothing, and re-framing the standing bake back to square on the way out would be a
|
||
// visible pop on the way back in.
|
||
let aspect = panes.0.get(slot).copied().unwrap_or(booth.aspect);
|
||
// **The show edge** — the widget's own re-take (`0x505d00`, the effective-visible SHOW
|
||
// override at primary vtable index 34, which re-duplicates the unit's model on every
|
||
// hidden→visible transition; index 33 destroys it on hide). `BoothPanes` publishes a slot
|
||
// exactly while its pane is being drawn, so its rising edge is that transition.
|
||
//
|
||
// We re-take rather than destroy-and-rebuild: the two are indistinguishable on screen (nothing
|
||
// is drawn in between) and keeping the bake avoids the re-frame pop 1069's aspect latch exists
|
||
// to prevent.
|
||
let on_screen = panes.0.contains_key(slot);
|
||
if on_screen && !booth.shown {
|
||
booth.show_rev = booth.show_rev.wrapping_add(1);
|
||
}
|
||
booth.shown = on_screen;
|
||
// There is no 2D stand-in for a body pane — the bridge always points at the live target (an
|
||
// empty booth just renders the dark backdrop until the unit's model attaches).
|
||
let live = PortraitSource::Live(booth.target.clone());
|
||
if portraits.0.get(slot) != Some(&live) {
|
||
portraits.0.insert(slot.to_string(), live);
|
||
}
|
||
// The unit's dressed look — the same mirrored descendants the portrait slots collect. Empty
|
||
// while there's no unit, or its model hasn't attached yet.
|
||
let (parts, riders, billboards, effects) = match unit {
|
||
Some(unit) => look.collect(unit),
|
||
None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()),
|
||
};
|
||
if parts.is_empty() {
|
||
// No unit / model not attached → empty the booth and forget the applied yaw (so it
|
||
// re-applies on the next bake).
|
||
if booth.snap.is_some() {
|
||
commands.entity(booth.root).despawn_related::<Children>();
|
||
booth.snap = None;
|
||
*last_pose = None;
|
||
// Render the emptied stage before sleeping (decision 0540) — and the emptied stage has
|
||
// no emitters left, so the pane stops being live.
|
||
booth.wake = BOOTH_SETTLE_FRAMES;
|
||
booth.live = false;
|
||
booth.pending.clear();
|
||
// The despawn reaped meshes and anchors; the rig state on the ROOT needs its own
|
||
// strip ([`booth::clear_booth_rig`]).
|
||
clear_booth_rig(commands, booth.root);
|
||
booth.rigged = false;
|
||
booth.parked = false;
|
||
}
|
||
return;
|
||
}
|
||
let unit = unit.expect("unit present — parts came from its descendants");
|
||
let (dress, rev) = look.snapshot_inputs(unit);
|
||
let key = SnapKey::build(
|
||
unit,
|
||
&parts,
|
||
&riders,
|
||
&billboards,
|
||
&effects,
|
||
dress,
|
||
rev,
|
||
booth.show_rev,
|
||
aspect,
|
||
);
|
||
// The display the framing, the rig and the model-root scale all key on. A world unit carries
|
||
// it on its wire record; a bodiless [`PortraitStandIn`] carries it on itself, and has no wire
|
||
// record at all — which is the whole of what makes a stabled pet drawable.
|
||
let display_id = ent_q
|
||
.get(unit)
|
||
.ok()
|
||
.and_then(|n| n.display_id)
|
||
.or_else(|| look.stand_in_display(unit));
|
||
// Anchors first, before any teardown — a still-loading display must not be framed from
|
||
// fabricated zero bounds (see the portrait site, and `booth_anchors`). Resolved out here rather
|
||
// than inside the bake because the model-root scale below needs them on an otherwise-idle frame
|
||
// too (a window resize moves the scale without touching the bake).
|
||
let anchors_now = booth_anchors(creatures, display_id);
|
||
// The model-root scale, taken while the anchors are still in hand (the bake below consumes
|
||
// them). `1.0` until they resolve — an unscaled root is the harmless default, and the 4:3
|
||
// factor is 1.0 anyway.
|
||
let model_scale = anchors_now
|
||
.as_ref()
|
||
.map_or(1.0, |a| framing::pane_root_scale(a, display_aspect));
|
||
// A changed pane aspect re-runs the same path: the camera's projection depends on it, and it
|
||
// only ever moves once — the first frame the window is drawn.
|
||
// **The snapshot compare** — the whole of `#bugs` B324. This used to be `LookKey`, the
|
||
// mirrored geometry, which moved the instant the world drew a weapon. It is now the
|
||
// reference's own re-`SetUnit` set, and a draw is not in it.
|
||
let parts_changed = booth.snap.as_ref() != Some(&key);
|
||
if parts_changed {
|
||
booth.aspect = aspect;
|
||
let Some(anchors) = anchors_now else {
|
||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||
log_bake(slot, "wait-anchors", &parts, &riders, &billboards, &effects);
|
||
return;
|
||
};
|
||
let rig = creatures
|
||
.zip(display_id)
|
||
.and_then(|(c, d)| c.display_rig(d));
|
||
let booth_parts: Vec<BoothPart> = parts
|
||
.iter()
|
||
.map(|p| BoothPart {
|
||
skinned: p.skinned_mesh.clone(),
|
||
static_mesh: p.static_mesh.clone(),
|
||
material: booth_light.pane.variant(&p.material, wow_mats),
|
||
// `None` — the same known gap as the glue preview's (decision 0807).
|
||
alpha_anim: None,
|
||
twins: BoothTwins::default(),
|
||
})
|
||
.collect();
|
||
let booth_riders: Vec<BoothRider> = riders
|
||
.iter()
|
||
.map(|r| BoothRider {
|
||
mesh: r.static_mesh.clone(),
|
||
material: booth_light.pane.variant(&r.material, wow_mats),
|
||
bone: r.bone,
|
||
offset: r.offset,
|
||
twins: BoothTwins::default(),
|
||
})
|
||
.collect();
|
||
// The camera-facing batches — the eye-glow on its eye bone, a wand's gem or the held torch's
|
||
// halo at its attach point — seated on their bone's booth joint and camera-faced by the booth
|
||
// (relit onto the pane buffer like everything else; harmless where the batch is fullbright).
|
||
let booth_billboards: Vec<BoothBillboardSpec> = billboards
|
||
.iter()
|
||
.map(|b| BoothBillboardSpec {
|
||
mesh: b.mesh.clone(),
|
||
material: booth_light.pane.variant(&b.material, wow_mats),
|
||
bone: b.bone,
|
||
offset: b.seat.offset(),
|
||
kind: b.kind,
|
||
twins: BoothTwins::default(),
|
||
})
|
||
.collect();
|
||
// The worn items' effects (decision 0822) — an equipped item's own emitters (0813, `#bugs`
|
||
// B118) and a held weapon's `ItemVisuals` glow (0805). Collected BEFORE the teardown for the
|
||
// same reason as everything else here; spawned after the model, which is what hands us the
|
||
// joints they seat on.
|
||
let booth_effects: Vec<BoothEffects> = effects
|
||
.iter()
|
||
.map(|fx| BoothEffects {
|
||
bone: fx.bone,
|
||
offset: fx.offset,
|
||
emitters: fx.emitters.clone(),
|
||
})
|
||
.collect();
|
||
// Same law as the portrait bake: never latch a world-lane material into the pane. Leave
|
||
// `booth.snap` alone and retry next frame (see the portrait site for the full note).
|
||
if booth_light.pane.take_unready() {
|
||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||
return;
|
||
}
|
||
commands.entity(booth.root).despawn_related::<Children>();
|
||
let mut booth_rig = spawn_booth_model(
|
||
commands,
|
||
palettes,
|
||
booth.root,
|
||
booth.layer.clone(),
|
||
&booth_parts,
|
||
&booth_riders,
|
||
rig.as_ref().and_then(|r| {
|
||
r.inverse_bindposes
|
||
.as_ref()
|
||
.map(|ibp| (r.skeleton, ibp, r.animations))
|
||
}),
|
||
anim_data.map(|a| &a.0),
|
||
// The pane ANIMATES: Stand loops and the global-sequence bones run, which is what the
|
||
// reference's `<PlayerModel>` widget does (decision 0822 §4 read it as live-rendering
|
||
// and left the pose as a look call; the director made that call — decision 1069).
|
||
BoothMotion::Loop,
|
||
// The grip is the reference's own probe of the duplicate's hand attachment nodes
|
||
// ([`hand_grip`]) — NOT a constant. The `[false, false]` this replaced rested on
|
||
// "the pane sheaths its weapons", which was never true of this lane: the pane mirrors
|
||
// whatever the body is holding, so a drawn weapon arrived in an open hand.
|
||
hand_grip(&riders, &billboards, &effects),
|
||
&booth_billboards,
|
||
BoothInstance::default(),
|
||
);
|
||
// The item effects go up on the posed skeleton's anchors — the body pane is the lane that
|
||
// gets them (the reference's `<PlayerModel>` widget renders live; the round portraits are
|
||
// a one-shot cached bake — [`spawn_booth_effects`]).
|
||
spawn_booth_effects(
|
||
commands,
|
||
&mut booth_rig,
|
||
&booth.layer,
|
||
booth_light.pane.buffer.as_ref(),
|
||
&booth_effects,
|
||
);
|
||
// The turn's node bookkeeping named the player this bake just replaced.
|
||
booth.turn.rebaked();
|
||
// So the whole bake is live, emitters or not — `wake` can't gate a looping animation.
|
||
// `gate_booth_cameras` renders it every frame its pane is on screen, and none when it isn't.
|
||
booth.live = true;
|
||
// A fresh bake is animated by construction; the park state is the new rig's.
|
||
booth.rigged = booth_rig.rigged();
|
||
booth_rig.finish(commands);
|
||
booth.parked = false;
|
||
// Body framing from the display's bounds — the full standing figure, feet-to-crown, at the
|
||
// destination pane's aspect. Resolved before the teardown above; see the portrait site for
|
||
// why it cannot be faked.
|
||
log_frame(slot, &anchors, &body_frame(&anchors, aspect).0);
|
||
aim(cams, slot, &body_frame(&anchors, aspect));
|
||
log_bake(slot, "bake", &parts, &riders, &billboards, &effects);
|
||
wake_booth(
|
||
booth,
|
||
wow_mats,
|
||
booth_parts
|
||
.iter()
|
||
.map(|p| &p.material)
|
||
.chain(booth_riders.iter().map(|r| &r.material))
|
||
.chain(booth_billboards.iter().map(|b| &b.material)),
|
||
);
|
||
booth.snap = Some(key);
|
||
}
|
||
// The model root: **yaw → rotation, plus the pane's model scale** — the widget's own
|
||
// `T(pos)·R(facing)·S(s)` with `pos` at the origin (the ref's `Model:SetRotation` writes the
|
||
// facing, and a spin about WoW +Z-up conjugates to a spin about Bevy +Y-up).
|
||
//
|
||
// `s` is [`framing::pane_root_scale`]: `1.0` for a model with its own camera and the
|
||
// display-aspect renormalize factor for one without. It rides the same latch as the yaw because
|
||
// it moves for the same reason — a window resize — and the reference re-snapshots on exactly
|
||
// that (`DISPLAY_SIZE_CHANGED → RefreshUnit()`, which both panes register). Cheap enough to
|
||
// re-derive here rather than key a whole re-bake on: the CAMERA does not depend on the display
|
||
// aspect, only the root does.
|
||
//
|
||
// The **turn animation** rides the facing write, exactly as it does in the reference
|
||
// (`0x505bb0` picks the shuffle, arms its expiry, and only then stores the angle — 1559).
|
||
// Keyed on the yaw alone, not on this block's condition: a re-bake and a window resize both
|
||
// land here without the model having turned, and neither steps its feet in the reference.
|
||
// The comparison runs on the **Lua-facing scalar**, which is the very value the reference
|
||
// passes to `SetRotation`, so its own `current > angle` branch transfers with no sign work.
|
||
if booth.turn.faced != Some(yaw) {
|
||
if let Some(prev) = booth.turn.faced {
|
||
booth.turn.spun = Some(booth::turn_shuffle(prev, yaw));
|
||
}
|
||
booth.turn.faced = Some(yaw);
|
||
}
|
||
if parts_changed || *last_pose != Some((yaw, model_scale)) {
|
||
commands.entity(booth.root).insert(
|
||
Transform::from_rotation(Quat::from_rotation_y(yaw))
|
||
.with_scale(Vec3::splat(model_scale)),
|
||
);
|
||
*last_pose = Some((yaw, model_scale));
|
||
// A spin is a content edge too (decision 0540): render the new pose, then sleep.
|
||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||
}
|
||
}
|
||
|
||
/// What one frame owes a booth's **pipeline settle** ([`Booth::pipes_settling`]) — the decision
|
||
/// alone, so the law is testable without standing a render world up around it.
|
||
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
|
||
enum PipeSettle {
|
||
/// The render world is still building variants, so it is still dropping draws: keep the
|
||
/// camera awake rather than freeze a still with a hole in it.
|
||
Hold,
|
||
/// Drained. Spend the settle on one final rendered frame — the same "one more frame" a
|
||
/// landed [`Booth::pending`] texture buys, and for the same reason.
|
||
Spend,
|
||
/// Nothing owed *yet*: the cache reads idle, but this bake's own [`BOOTH_SETTLE_FRAMES`]
|
||
/// window has not drained, so the reading cannot be trusted about it.
|
||
Idle,
|
||
/// [`PIPELINE_SETTLING_SECS`] ran out — spend it anyway, loudly.
|
||
Expired,
|
||
}
|
||
|
||
/// The settle's one rule, and the ±1-frame race it exists to survive.
|
||
///
|
||
/// A bake's pipeline variants are queued by the **render** world, the frame after the main world
|
||
/// spawned its meshes, and `PipeWatch`'s counters cross back a frame later still. So an idle
|
||
/// reading taken at the moment of the bake is a reading of the cache *before* this bake existed —
|
||
/// which is why `Spend` waits for `wake_drained`: by the time [`BOOTH_SETTLE_FRAMES`] have gone
|
||
/// by, whatever this bake queued is counted.
|
||
fn pipe_settle(compiling: bool, wake_drained: bool, held_for: f64) -> PipeSettle {
|
||
if held_for > PIPELINE_SETTLING_SECS {
|
||
PipeSettle::Expired
|
||
} else if compiling {
|
||
PipeSettle::Hold
|
||
} else if wake_drained {
|
||
PipeSettle::Spend
|
||
} else {
|
||
PipeSettle::Idle
|
||
}
|
||
}
|
||
|
||
/// The demand-render gate (decision 0540): each booth camera is active only while its booth has
|
||
/// something new to show — [`Booth::wake`] frames after a content edge, or a bake texture still
|
||
/// in flight ([`Booth::pending`]) — except the booths whose content is **live**, which render
|
||
/// continuously: the glue booth, whose whole scene is animated (looping sequences, global-sequence
|
||
/// bones, particle emitters) while a glue screen shows, and a **body pane**, whose bake is a live
|
||
/// `<PlayerModel>` widget ([`Booth::live`], decisions 0822 §4 + 1069). A sleeping camera
|
||
/// skips its whole pass (clear + model + FFXGlow chain); its target keeps the last render — exactly
|
||
/// right for a still (the 0105 bake, frozen at Stand).
|
||
///
|
||
/// A live pane renders **only while it is on screen** ([`BoothPanes`], decision 1069): a character
|
||
/// window nobody opened costs nothing, which is the follow-up [`sync_paperdoll`] named and 0822's
|
||
/// unconditional `live` never had.
|
||
/// With `WOW_PORTRAIT_TEST` set the gate stands down (the eyeball harness wants live cameras).
|
||
/// The pipeline warm pass is demand too (decision 0938): its menagerie duplicates rigs onto a
|
||
/// booth layer so the booths' `Msaa::Off` pipeline twins compile behind the entry cover — which
|
||
/// only works if the booth cameras render during the warm window.
|
||
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource/query
|
||
fn gate_booth_cameras(
|
||
mut commands: Commands,
|
||
mut booths: ResMut<Booths>,
|
||
preview: Res<GluePreview>,
|
||
panes: Res<BoothPanes>,
|
||
images: Res<Assets<Image>>,
|
||
warm: Res<crate::pipe_warm::WarmPass>,
|
||
// The pipeline settle's input ([`Booth::pipes_settling`]) — is the render world still
|
||
// building variants, i.e. is it still dropping draws on the floor.
|
||
pipes: Res<crate::pipe_warm::PipeWatch>,
|
||
time: Res<Time<bevy::time::Real>>,
|
||
mut cams: Query<(
|
||
Entity,
|
||
&BoothCam,
|
||
&mut Camera,
|
||
Has<benilla_world::particles::ViewThrottled>,
|
||
)>,
|
||
rate: Res<PaneRate>,
|
||
frames: Res<bevy::diagnostic::FrameCount>,
|
||
// `WOW_BOOTH_LOG` only: the marker's REAL state beside this gate's `booth.parked`
|
||
// bookkeeping. The two can desync exactly one way — a foreign writer — and that desync is
|
||
// invisible in every capture (a woken rig snaps to the absolute clock), so it must be
|
||
// loggable (decision 1447: the world parker froze every pane and no dump could show it).
|
||
markers: Query<(), With<benilla_world::rig_anim::AnimParked>>,
|
||
mut env_cache: Local<Option<bool>>,
|
||
) {
|
||
let test = test_mode(&mut env_cache);
|
||
// `satisfied()` is false exactly while the covered warm window runs (the loading screen
|
||
// holds on it), so this term costs nothing outside that window.
|
||
let warming = !warm.satisfied();
|
||
for (cam_entity, BoothCam(token), mut cam, was_paced) in &mut cams {
|
||
let Some(booth) = booths.0.get_mut(token.as_str()) else {
|
||
continue;
|
||
};
|
||
// A pending texture landing this frame still needs one rendered frame to reach the still.
|
||
let had_pending = !booth.pending.is_empty();
|
||
booth.pending.retain(|h| !images.contains(h));
|
||
if had_pending && booth.pending.is_empty() {
|
||
booth.wake = booth.wake.max(1);
|
||
}
|
||
// Bound the hold (the `pending_since` field's doc): a texture that will never land must
|
||
// not keep this camera rendering for the rest of the session.
|
||
if booth.pending.is_empty() {
|
||
booth.pending_since = None;
|
||
} else {
|
||
let now = time.elapsed_secs_f64();
|
||
let since = *booth.pending_since.get_or_insert(now);
|
||
if now - since > PENDING_LANDING_SECS {
|
||
warn!(
|
||
"booth {}: {} texture(s) never landed after {PENDING_LANDING_SECS:.0}s — \
|
||
releasing the wake hold with the still as-is",
|
||
token.as_str(),
|
||
booth.pending.len(),
|
||
);
|
||
booth.pending.clear();
|
||
booth.pending_since = None;
|
||
booth.wake = booth.wake.max(1);
|
||
}
|
||
}
|
||
// The **pipeline settle** ([`Booth::pipes_settling`]), the pending hold's twin one level
|
||
// down: a batch whose pipeline variant is still being built draws NOTHING and says
|
||
// nothing about it, so a still committed inside a compile burst keeps the hole. Judged
|
||
// only once the settle window has otherwise drained — the variants this bake needs are
|
||
// queued by the RENDER world, and these counters cross back ±1 frame, so an idle reading
|
||
// taken the frame of the bake is a reading of the frame before it.
|
||
let settling = if booth.pipes_settling {
|
||
let now = time.elapsed_secs_f64();
|
||
let since = *booth.pipes_since.get_or_insert(now);
|
||
match pipe_settle(pipes.compiling(), booth.wake == 0, now - since) {
|
||
PipeSettle::Hold => true,
|
||
PipeSettle::Idle => false,
|
||
spent => {
|
||
if spent == PipeSettle::Expired {
|
||
warn!(
|
||
"booth {}: pipelines still compiling after \
|
||
{PIPELINE_SETTLING_SECS:.0}s — spending the settle with the still \
|
||
as-is",
|
||
token.as_str(),
|
||
);
|
||
}
|
||
booth.pipes_settling = false;
|
||
booth.wake = booth.wake.max(1);
|
||
false
|
||
}
|
||
}
|
||
} else {
|
||
booth.pipes_since = None;
|
||
false
|
||
};
|
||
let live_scene = token.as_str() == GLUE_SLOT && preview.scene.is_some();
|
||
// A live bake renders every frame — but only while the UI is actually drawing its pane.
|
||
// (The glue screens sample their booth outside the FrameXML extract, so they publish no
|
||
// pane and stay on `live_scene`.)
|
||
let live_pane = booth.live && panes.0.contains_key(token.as_str());
|
||
let active = test
|
||
|| warming
|
||
|| live_scene
|
||
|| live_pane
|
||
|| booth.wake > 0
|
||
|| !booth.pending.is_empty()
|
||
|| settling;
|
||
// Half-rate (decision 1444, [`PaneRate`]): when the live pane is the ONLY thing keeping
|
||
// this camera rendering — no wake window settling a fresh bake, no pending texture hold,
|
||
// no fullscreen glue scene — skip every other frame. `active` stays the LOGICAL state:
|
||
// the park bookkeeping below keys off it (the pose keeps evaluating; only the render
|
||
// skips), and the wake counter keeps draining per real frame.
|
||
//
|
||
// `paced` is the REGIME — is this camera being rate-limited at all — and `throttled` is
|
||
// this frame's parity within it. The regime is what the rest of the engine needs to know:
|
||
// a camera whose render we skipped is not a camera whose scene stopped, and the emitter
|
||
// lane keys on exactly that distinction ([`benilla_world::particles::ViewThrottled`],
|
||
// decision 1559 — B312's half-speed item effects were it reading our skip as a cull).
|
||
// Marking the regime rather than the parity also keeps the archetype still: the marker
|
||
// moves when a pane opens or closes, not twice every frame.
|
||
let paced = rate.half
|
||
&& live_pane
|
||
&& !(test || warming || live_scene)
|
||
&& booth.wake == 0
|
||
&& booth.pending.is_empty()
|
||
&& !settling;
|
||
let throttled = paced && frames.0 % 2 == 1;
|
||
let render = active && !throttled;
|
||
if was_paced != paced {
|
||
if paced {
|
||
commands
|
||
.entity(cam_entity)
|
||
.insert(benilla_world::particles::ViewThrottled);
|
||
} else {
|
||
commands
|
||
.entity(cam_entity)
|
||
.remove::<benilla_world::particles::ViewThrottled>();
|
||
}
|
||
}
|
||
// `WOW_BOOTH_LOG=1`: the gate's timeline — every activity flip and every armed frame,
|
||
// wall-stamped (the first-login black-pane hunt).
|
||
static LOG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
if *LOG.get_or_init(|| std::env::var_os("WOW_BOOTH_LOG").is_some())
|
||
&& (cam.is_active != render || active)
|
||
{
|
||
eprintln!(
|
||
"[booth] t={:7.2} {} active={} render={} wake={} pending={} settling={} \
|
||
marker={}",
|
||
time.elapsed_secs(),
|
||
token.as_str(),
|
||
active,
|
||
render,
|
||
booth.wake,
|
||
booth.pending.len(),
|
||
settling,
|
||
markers.contains(booth.root),
|
||
);
|
||
}
|
||
if cam.is_active != render {
|
||
cam.is_active = render;
|
||
}
|
||
// Park/unpark the standing scene with its camera (director report 2026-08-19: the doll
|
||
// bake animated at full cost from the LOGIN bake to quit, window opened or not). The
|
||
// park is one `AnimParked` marker on the root (decision 1443): the 0712 evaluator, the
|
||
// pose composes, the palette writes and the global-sequence writes all check it — the
|
||
// pose HOLDS, the buffer is state. Booth-lane emitters freeze on the camera bit itself
|
||
// (`particles::sim`). This system runs before the PostUpdate animation lane, so an
|
||
// unpark's marker drop is seen the same frame — the wake window's first render already
|
||
// animates (0739's wake law, same as every world rig).
|
||
if booth.parked == active && booth.rigged {
|
||
if active {
|
||
commands
|
||
.entity(booth.root)
|
||
.remove::<benilla_world::rig_anim::AnimParked>();
|
||
} else {
|
||
commands
|
||
.entity(booth.root)
|
||
.insert(benilla_world::rig_anim::AnimParked);
|
||
}
|
||
booth.parked = !active;
|
||
}
|
||
if active {
|
||
booth.wake = booth.wake.saturating_sub(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The stand-in art's folder + stem, shared by the three arms below.
|
||
const TEMPORARY_PORTRAIT: &str = "Interface\\CharacterFrame\\TemporaryPortrait";
|
||
|
||
/// The ref's 2D portrait stand-in for a not-yet-renderable unit (RE C5):
|
||
/// `TemporaryPortrait-{Male|Female}-{Race}` for a player body, `-Monster` otherwise.
|
||
fn temporary_portrait(net: Option<&NetEntity>, store: Option<&crate::net::ObjectStore>) -> String {
|
||
use benilla_protocol::EntityKind;
|
||
if net.map(|n| n.kind) == Some(EntityKind::Player) {
|
||
return match store {
|
||
Some(s) => player_temporary_portrait(s.0.unit_race(), s.0.unit_gender()),
|
||
None => format!("{TEMPORARY_PORTRAIT}.blp"),
|
||
};
|
||
}
|
||
creature_temporary_portrait()
|
||
}
|
||
|
||
/// The player half of [`temporary_portrait`], taking the two bytes rather than a descriptor — a
|
||
/// party member outside the local area HAS no descriptor, and reaches the same art through the
|
||
/// name cache's `SMSG_NAME_QUERY_RESPONSE` triple instead (report B315).
|
||
///
|
||
/// An unknown race falls to the plain `TemporaryPortrait.blp`, which is the ref's own behaviour
|
||
/// when its `%s-%s` format has no race string to substitute; an unknown sex reads Male, the
|
||
/// zero-byte default.
|
||
fn player_temporary_portrait(race: Option<u8>, sex: Option<u8>) -> String {
|
||
let sex = match sex {
|
||
Some(1) => "Female",
|
||
_ => "Male",
|
||
};
|
||
let race = match race {
|
||
Some(1) => "Human",
|
||
Some(2) => "Orc",
|
||
Some(3) => "Dwarf",
|
||
Some(4) => "NightElf",
|
||
Some(5) => "Scourge",
|
||
Some(6) => "Tauren",
|
||
Some(7) => "Gnome",
|
||
Some(8) => "Troll",
|
||
_ => return format!("{TEMPORARY_PORTRAIT}.blp"),
|
||
};
|
||
format!("{TEMPORARY_PORTRAIT}-{sex}-{race}.blp")
|
||
}
|
||
|
||
/// The creature stand-in — the Monster art rather than the ref's `-Pet` file (our pick; see
|
||
/// [`sync_portraits`]'s not-attached-yet arm).
|
||
fn creature_temporary_portrait() -> String {
|
||
format!("{TEMPORARY_PORTRAIT}-Monster.blp")
|
||
}
|
||
|
||
/// Set the named slot's camera to the rig `frame` built — transform AND projection (the authored
|
||
/// camera brings its own fov/near/far, so the projection is per-bake, not booth-fixed).
|
||
fn aim(
|
||
cams: &mut Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||
token: &str,
|
||
rig: &(Transform, Projection),
|
||
) {
|
||
for (cam, mut t, mut p) in cams.iter_mut() {
|
||
if cam.0 == token {
|
||
*t = rig.0;
|
||
*p = rig.1.clone();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::entities::ItemModelKind;
|
||
|
||
/// **The cache keeps faces, not slots** (decision 1640, report B334): a re-bake of somebody
|
||
/// already in it replaces their entry rather than queueing a second one, and only genuinely
|
||
/// new members can push the oldest out. Get this wrong — count a replace as an insert — and a
|
||
/// party member whose gear changes a few times evicts the other three.
|
||
#[test]
|
||
fn the_bake_cache_replaces_in_place_and_evicts_oldest_first() {
|
||
let mut bakes = PortraitBakes::default();
|
||
let mut images = Assets::<Image>::default();
|
||
// Distinct real handles — a 1² target is the cheapest thing `Assets` will mint.
|
||
let face = |images: &mut Assets<Image>| images.add(new_target_image(1));
|
||
|
||
let faces: Vec<Handle<Image>> =
|
||
(0..PortraitBakes::CAP).map(|_| face(&mut images)).collect();
|
||
for (i, f) in faces.iter().enumerate() {
|
||
bakes.store(i as u64 + 1, f.clone());
|
||
}
|
||
// A re-bake of the oldest entry: replaced, and it does NOT move up the eviction queue —
|
||
// the reference re-stores the handle at `+0x24` and touches no ordering either.
|
||
let rebaked = face(&mut images);
|
||
bakes.store(1, rebaked.clone());
|
||
assert_eq!(bakes.get(1), Some(rebaked));
|
||
assert_eq!(
|
||
bakes.order.len(),
|
||
PortraitBakes::CAP,
|
||
"a replace is not an insert"
|
||
);
|
||
|
||
// One new member past the cap evicts exactly the oldest.
|
||
let newcomer = face(&mut images);
|
||
bakes.store(99, newcomer.clone());
|
||
assert_eq!(bakes.get(1), None, "the oldest face went");
|
||
assert_eq!(bakes.get(2), Some(faces[1].clone()), "and only the oldest");
|
||
assert_eq!(bakes.get(99), Some(newcomer));
|
||
assert_eq!(bakes.faces.len(), PortraitBakes::CAP);
|
||
}
|
||
|
||
fn body_part() -> PortraitPart {
|
||
PortraitPart {
|
||
static_mesh: Handle::default(),
|
||
skinned_mesh: None,
|
||
material: Handle::default(),
|
||
}
|
||
}
|
||
|
||
/// A camera-facing batch at attach `attach` — [`attach_id::SHOULDER_RIGHT`] by default, an id
|
||
/// the reference's [`attach_reset`] keeps.
|
||
fn card(seat: PortraitSeat) -> PortraitBillboard {
|
||
card_at(seat, Some(SHOULDER))
|
||
}
|
||
|
||
fn card_at(seat: PortraitSeat, attach: Option<u16>) -> PortraitBillboard {
|
||
PortraitBillboard {
|
||
mesh: Handle::default(),
|
||
material: Handle::default(),
|
||
bone: 4,
|
||
seat,
|
||
kind: benilla_formats::BillboardKind::Spherical,
|
||
attach,
|
||
}
|
||
}
|
||
|
||
/// A worn attach id the reset KEEPS — the right pauldron. Every fixture that isn't testing the
|
||
/// reset itself hangs from it, so the reset never silently eats a fixture.
|
||
const SHOULDER: u16 = 5;
|
||
|
||
fn rider() -> PortraitRider {
|
||
rider_at(SHOULDER)
|
||
}
|
||
|
||
fn rider_at(attach: u16) -> PortraitRider {
|
||
PortraitRider {
|
||
static_mesh: Handle::default(),
|
||
material: Handle::default(),
|
||
bone: 4,
|
||
offset: Vec3::new(0.21, 1.42, 0.06),
|
||
attach: Some(attach),
|
||
}
|
||
}
|
||
|
||
fn effects(bone: u16, offset: Vec3, count: usize) -> PortraitEffects {
|
||
effects_at(bone, offset, count, SHOULDER)
|
||
}
|
||
|
||
fn effects_at(bone: u16, offset: Vec3, count: usize, attach: u16) -> PortraitEffects {
|
||
PortraitEffects {
|
||
bone,
|
||
offset,
|
||
attach: Some(attach),
|
||
emitters: (0..count)
|
||
.map(|_| benilla_assets::ModelEmitter {
|
||
def: benilla_world::testing::plain_particle_def(),
|
||
texture: None,
|
||
bone_pivot: [0.0; 3],
|
||
billboard: None,
|
||
recursion: None,
|
||
geometry: None,
|
||
owner_reach: 0.0,
|
||
water_bound: (Vec3::ZERO, 0.0),
|
||
idle_seq: 0,
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// What [`DressedLook::collect`] found, flattened to what the assertions care about.
|
||
#[derive(Resource, Default)]
|
||
struct Collected {
|
||
parts: usize,
|
||
riders: usize,
|
||
billboards: Vec<PortraitSeat>,
|
||
effects: Vec<(u16, usize)>,
|
||
/// What the body panes would arm — [`hand_grip`] over the same collected set.
|
||
grip: [bool; 2],
|
||
}
|
||
|
||
/// Run the walk over `unit` in `app` and hand back what it collected.
|
||
fn walk(app: &mut App, unit: Entity) -> Collected {
|
||
app.init_resource::<Collected>();
|
||
app.insert_resource(Unit(unit));
|
||
app.add_systems(
|
||
Update,
|
||
|unit: Res<Unit>, look: DressedLook, mut out: ResMut<Collected>| {
|
||
let (parts, riders, billboards, effects) = look.collect(unit.0);
|
||
*out = Collected {
|
||
parts: parts.len(),
|
||
riders: riders.len(),
|
||
billboards: billboards.iter().map(|b| b.seat).collect(),
|
||
effects: effects.iter().map(|e| (e.bone, e.emitters.len())).collect(),
|
||
grip: hand_grip(&riders, &billboards, &effects),
|
||
};
|
||
},
|
||
);
|
||
app.update();
|
||
std::mem::take(app.world_mut().resource_mut::<Collected>().as_mut())
|
||
}
|
||
|
||
#[derive(Resource)]
|
||
struct Unit(Entity);
|
||
|
||
/// **A mounted unit's booth keeps the rider's gear and drops the horse's.** The mount prune is
|
||
/// what makes a portrait show the character alone (decision 0441) — but while mounted the
|
||
/// character's own attach joints re-root INSIDE the mount subtree, so pruning everything under it
|
||
/// would take the rider's equipment with the horse. Riders were already exempt; an equipped item's
|
||
/// camera-facing batch and its effects have to be too (decision 0822), and the discriminator is
|
||
/// [`PortraitSeat`] — whose model the batch belongs to — not where in the tree it happens to sit.
|
||
#[test]
|
||
fn a_mounts_own_glow_card_prunes_but_the_riders_gear_does_not() {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
let unit = app.world_mut().spawn(body_part()).id();
|
||
// The character's own eye-glow, outside the mount subtree.
|
||
app.world_mut()
|
||
.spawn((card(PortraitSeat::Body), ChildOf(unit)));
|
||
let mount = app
|
||
.world_mut()
|
||
.spawn((
|
||
body_part(),
|
||
crate::entities::mount::MountBody { host: unit },
|
||
ChildOf(unit),
|
||
))
|
||
.id();
|
||
// The mount's OWN glow card (its lantern) — a batch of the mount's rigged body.
|
||
app.world_mut()
|
||
.spawn((card(PortraitSeat::Body), ChildOf(mount)));
|
||
// …and the seated rider's gear, re-rooted under the mount: a shoulder rider, its
|
||
// camera-facing batch and its emitters.
|
||
let seat = app.world_mut().spawn(ChildOf(mount)).id();
|
||
app.world_mut().spawn((rider(), ChildOf(seat)));
|
||
app.world_mut().spawn((
|
||
card(PortraitSeat::Rider(Vec3::new(0.15, 1.58, 0.05))),
|
||
ChildOf(seat),
|
||
));
|
||
app.world_mut()
|
||
.spawn((effects(4, Vec3::new(0.21, 1.42, 0.06), 2), ChildOf(seat)));
|
||
|
||
let got = walk(&mut app, unit);
|
||
assert_eq!(got.parts, 1, "the character's body, never the mount's");
|
||
assert_eq!(
|
||
got.riders, 1,
|
||
"the rider's shoulder survives the mount subtree"
|
||
);
|
||
// Two `Body` cards exist in this tree — the character's eye-glow and the mount's lantern —
|
||
// and exactly one may survive; the item's `Rider` card must, wherever it sits.
|
||
let (body, rider): (Vec<PortraitSeat>, Vec<PortraitSeat>) = got
|
||
.billboards
|
||
.iter()
|
||
.partition(|s| **s == PortraitSeat::Body);
|
||
assert_eq!(
|
||
body.len(),
|
||
1,
|
||
"the character's eye-glow, not the mount's lantern"
|
||
);
|
||
assert_eq!(
|
||
rider,
|
||
vec![PortraitSeat::Rider(Vec3::new(0.15, 1.58, 0.05))],
|
||
"the item's card survives the mount subtree, like the rider it belongs to",
|
||
);
|
||
assert_eq!(
|
||
got.effects,
|
||
vec![(4, 2)],
|
||
"an item's emitters are never pruned"
|
||
);
|
||
}
|
||
|
||
/// A distinct mesh/material pair, so a fixture's identity is its own.
|
||
fn mesh(n: u64) -> Handle<Mesh> {
|
||
Handle::Uuid(
|
||
bevy::asset::uuid::Uuid::from_u128(0xb324_0000 | u128::from(n)),
|
||
std::marker::PhantomData,
|
||
)
|
||
}
|
||
fn mat(n: u64) -> Handle<WowModelMaterial> {
|
||
Handle::Uuid(
|
||
bevy::asset::uuid::Uuid::from_u128(0xb324_1000 | u128::from(n)),
|
||
std::marker::PhantomData,
|
||
)
|
||
}
|
||
|
||
/// A rider at `attach` with its own geometry.
|
||
fn geom_rider(attach: u16, n: u64) -> PortraitRider {
|
||
PortraitRider {
|
||
static_mesh: mesh(n),
|
||
material: mat(n),
|
||
bone: 4,
|
||
offset: Vec3::ZERO,
|
||
attach: Some(attach),
|
||
}
|
||
}
|
||
|
||
fn dress_of(held: [Option<(u32, ItemModelKind, i32)>; 3]) -> crate::entities::DressKey {
|
||
crate::entities::DressKey {
|
||
display_id: Some(57),
|
||
held,
|
||
held_ready: true,
|
||
}
|
||
}
|
||
|
||
/// The three-slot dress of a hunter carrying a bow — unchanged by drawing it, because
|
||
/// [`crate::entities::DressKey`] is read above the placement gate.
|
||
fn hunter() -> crate::entities::DressKey {
|
||
dress_of([None, None, Some((3026, ItemModelKind::Weapon, 0))])
|
||
}
|
||
|
||
fn key(
|
||
riders: &[PortraitRider],
|
||
dress: crate::entities::DressKey,
|
||
rev: u32,
|
||
show: u32,
|
||
) -> SnapKey {
|
||
let body = [body_part()];
|
||
let body: Vec<&PortraitPart> = body.iter().collect();
|
||
let riders: Vec<&PortraitRider> = riders.iter().collect();
|
||
SnapKey::build(
|
||
Entity::PLACEHOLDER,
|
||
&body,
|
||
&riders,
|
||
&[],
|
||
&[],
|
||
Some(dress),
|
||
rev,
|
||
show,
|
||
1.0,
|
||
)
|
||
}
|
||
|
||
/// **`#bugs` B324, the half that removes the bow.** In combat the auto-draw is a *snap*
|
||
/// (`SetSheatheState(…, bInstant != 0)`), which reaches `UNIT_MODEL_CHANGED`'s one fire site
|
||
/// only through the enchant-gated `0x5eed50` — so the reference's doll never hears about it.
|
||
/// Ours re-baked on the mirrored geometry, and a drawn bow IS mirrored geometry.
|
||
///
|
||
/// The bow appears from nothing (a stowed ranged weapon renders nothing at all), so this is
|
||
/// not a matter of ignoring a moved rider: the key has to be blind to the hand points, and to
|
||
/// know the bow exists from somewhere else. That somewhere is the dress.
|
||
#[test]
|
||
fn drawing_a_bow_in_combat_does_not_re_snapshot_the_pane() {
|
||
use crate::entities::attach_id::HAND_LEFT;
|
||
let bow = [geom_rider(HAND_LEFT, 1)];
|
||
let stowed = key(&[], hunter(), 0, 1);
|
||
let drawn = key(&bow, hunter(), 0, 1);
|
||
assert!(
|
||
stowed == drawn,
|
||
"the world drew the bow; the widget's duplicate must not notice"
|
||
);
|
||
|
||
// **The control, and the defect as a number.** The pane used to key on [`LookKey`] — the
|
||
// mirrored geometry — and that is exactly what a draw moves. Without this the test above
|
||
// asserts only that the new key ignores an input, never that the old one did not.
|
||
let body = [body_part()];
|
||
let body: Vec<&PortraitPart> = body.iter().collect();
|
||
let bow: Vec<&PortraitRider> = bow.iter().collect();
|
||
assert!(
|
||
LookKey::build(&body, &[], &[], &[]) != LookKey::build(&body, &bow, &[], &[]),
|
||
"the pre-1616 key moved on the draw — which is the bug"
|
||
);
|
||
}
|
||
|
||
/// The melee twin: a stow does not delete the weapon, it re-parents it (0826) — same handles,
|
||
/// a hand point for a hip point. Both ends are in the sheath lane, so neither is in the key.
|
||
#[test]
|
||
fn stowing_a_sword_does_not_re_snapshot_the_pane() {
|
||
use crate::entities::attach_id::{HAND_RIGHT, HIP_MAIN};
|
||
let sword = dress_of([Some((1234, ItemModelKind::Weapon, 0)), None, None]);
|
||
let drawn = key(&[geom_rider(HAND_RIGHT, 7)], sword, 0, 1);
|
||
let hipped = key(&[geom_rider(HIP_MAIN, 7)], sword, 0, 1);
|
||
assert!(drawn == hipped);
|
||
}
|
||
|
||
/// …but the **manual** toggle does. `ToggleSheath` passes `bInstant = 0` on all three legs,
|
||
/// which runs the ceremony, whose `$SHL`/`$SHR` keyframe marks the unit unconditionally
|
||
/// (`0x5ffb10`, the mark at `0x5ffbbe`). benilla fires `SheathSwapMessage` at exactly that
|
||
/// keyframe, and [`bump_model_revision`] turns it into a re-take. The director confirmed the
|
||
/// observable: with the sheet open, Z moves the doll.
|
||
#[test]
|
||
fn the_manual_sheath_ceremony_re_snapshots_the_pane() {
|
||
use crate::entities::attach_id::HAND_LEFT;
|
||
let before = key(&[], hunter(), 0, 1);
|
||
let after = key(&[geom_rider(HAND_LEFT, 1)], hunter(), 1, 1);
|
||
assert!(
|
||
before != after,
|
||
"the ceremony's own keyframe is a model event"
|
||
);
|
||
}
|
||
|
||
/// Opening the window re-takes the model — the C++ show override `0x505d00`, not a Lua event
|
||
/// (`PaperDollFrame_OnShow` calls no `SetUnit` at all). So a weapon drawn while the sheet was
|
||
/// shut IS on the doll the next time you open it.
|
||
#[test]
|
||
fn showing_the_pane_re_snapshots_it() {
|
||
use crate::entities::attach_id::HAND_LEFT;
|
||
let open_stowed = key(&[], hunter(), 0, 1);
|
||
let reopened_drawn = key(&[geom_rider(HAND_LEFT, 1)], hunter(), 0, 2);
|
||
assert!(open_stowed != reopened_drawn);
|
||
}
|
||
|
||
/// Equipping is `UNIT_MODEL_CHANGED`'s unconditional producer (`0x5e2810` → `0x5dee30` →
|
||
/// `0x5df119`), and it must reach the pane through the dress even when the new weapon is
|
||
/// **stowed** — where it adds no mirrored geometry whatsoever.
|
||
#[test]
|
||
fn equipping_a_stowed_weapon_still_re_snapshots_the_pane() {
|
||
let empty = key(&[], dress_of([None, None, None]), 0, 1);
|
||
let armed = key(&[], hunter(), 0, 1);
|
||
assert!(
|
||
empty != armed,
|
||
"the dress moved even though nothing is drawn"
|
||
);
|
||
}
|
||
|
||
/// And the gear that cannot move with a sheath is keyed straight off the mirrored tree, so a
|
||
/// helm arriving — or the armour composite re-blitting, which the reference shares by pointer
|
||
/// and means to show on an open doll — re-takes without any event plumbing.
|
||
#[test]
|
||
fn a_helm_arriving_re_snapshots_the_pane() {
|
||
use crate::entities::attach_id::HELM;
|
||
let bare = key(&[], hunter(), 0, 1);
|
||
let helmed = key(&[geom_rider(HELM, 9)], hunter(), 0, 1);
|
||
assert!(bare != helmed);
|
||
}
|
||
|
||
/// **`#bugs` B324 — the nocked arrow reached the character-window doll.** A hunter shooting in
|
||
/// combat has a bow at HandLeft(2) and an arrow at HandArrow(0x23); our booths mirrored both,
|
||
/// and the reference's doll can never draw the arrow: every model widget duplicates the unit's
|
||
/// attachment tree and then detaches `0x23` with fourteen other transient ids
|
||
/// (`0x47a230`, [`attach_reset`]). The bow is NOT ours to hide here — `0x47a230` keeps the hand
|
||
/// points — which is why this asserts both halves.
|
||
#[test]
|
||
fn the_nocked_arrow_never_reaches_a_booth_but_the_bow_in_hand_does() {
|
||
use crate::entities::attach_id::{HAND_ARROW, HAND_LEFT};
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
let unit = app.world_mut().spawn(body_part()).id();
|
||
// The drawn bow: mesh parts at HandLeft.
|
||
app.world_mut().spawn((rider_at(HAND_LEFT), ChildOf(unit)));
|
||
// The nocked arrow, at the one body-bone attach the whole ammo mechanism uses.
|
||
app.world_mut().spawn((rider_at(HAND_ARROW), ChildOf(unit)));
|
||
|
||
let got = walk(&mut app, unit);
|
||
assert_eq!(
|
||
got.riders, 1,
|
||
"the arrow is detached from the duplicate; the bow is not"
|
||
);
|
||
assert_eq!(
|
||
got.grip,
|
||
[false, true],
|
||
"a bow at HandLeft closes the LEFT hand (0x5059a0's occupancy probe), \
|
||
and nothing closes the right"
|
||
);
|
||
}
|
||
|
||
/// **The reset's membership, against the byte list.** `0x47a230` detaches fifteen ids —
|
||
/// `0xf` twice, `0x10`, `0x11`, `0x12`, `0x13`–`0x19`, `0x1d`, `0x22`, `0x23` — and keeps
|
||
/// everything else. Spelled out rather than re-derived, because getting the boundary wrong
|
||
/// silently deletes worn gear from every pane (`0xb` helm, `0xc` cape, `5`/`6` shoulders) or
|
||
/// silently keeps an effect.
|
||
#[test]
|
||
fn the_attach_reset_cuts_the_transient_family_and_keeps_equipment() {
|
||
for kept in [
|
||
0, 1, 2, // the three hand points — the whole reason the widget shows weapons
|
||
3, 4, 5, 6, 7, 8, 9, 10, // elbows, shoulders, knees, hips
|
||
0xb, 0xc, 0xd, 0xe, // helm, cape, the two shoulder flaps
|
||
0x1a, 0x1b, 0x1c, // sheath main/off, sheath shield (also the worn quiver, 0x1a)
|
||
0x1e, 0x1f, 0x20, 0x21, // the large-weapon and hip-weapon sheath pairs
|
||
] {
|
||
assert!(!attach_reset(Some(kept)), "0x47a230 keeps attach {kept:#x}");
|
||
}
|
||
for cut in [
|
||
0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1d, 0x22, 0x23,
|
||
] {
|
||
assert!(attach_reset(Some(cut)), "0x47a230 detaches attach {cut:#x}");
|
||
}
|
||
assert!(
|
||
!attach_reset(None),
|
||
"the host model's OWN batches sit in no attachment node — the reset walks the \
|
||
attachment list and cannot reach them"
|
||
);
|
||
}
|
||
|
||
/// **The grip is occupancy, not sheath state — and a shield never closes a hand.** The
|
||
/// forearm point (`0`) is the shield's, and `0x479700`/`0x5059a0` fork on ids `1`/`2` alone
|
||
/// (wow-re `hand-grip-mechanism.md` §4a/§4b: "a shield in the offhand NEVER closes the hand,
|
||
/// in any stance").
|
||
#[test]
|
||
fn a_shield_on_the_forearm_closes_no_hand() {
|
||
use crate::entities::attach_id::{HAND_RIGHT, SHIELD};
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
let unit = app.world_mut().spawn(body_part()).id();
|
||
app.world_mut().spawn((rider_at(SHIELD), ChildOf(unit)));
|
||
app.world_mut().spawn((rider_at(HAND_RIGHT), ChildOf(unit)));
|
||
|
||
assert_eq!(
|
||
walk(&mut app, unit).grip,
|
||
[true, false],
|
||
"the sword's hand closes, the shield's does not"
|
||
);
|
||
}
|
||
|
||
/// **A wand is a camera-facing batch and an emitter set, with no mesh rider at all** — so a
|
||
/// grip probe that only walked the mesh lane would leave the hand open around it. The
|
||
/// reference probes the attachment NODE, which every lane of ours hangs from.
|
||
#[test]
|
||
fn a_meshless_held_item_still_closes_its_hand() {
|
||
use crate::entities::attach_id::{HAND_LEFT, HAND_RIGHT};
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins);
|
||
let unit = app.world_mut().spawn(body_part()).id();
|
||
app.world_mut().spawn((
|
||
card_at(PortraitSeat::Rider(Vec3::ZERO), Some(HAND_RIGHT)),
|
||
ChildOf(unit),
|
||
));
|
||
app.world_mut()
|
||
.spawn((effects_at(4, Vec3::ZERO, 1, HAND_LEFT), ChildOf(unit)));
|
||
|
||
assert_eq!(walk(&mut app, unit).grip, [true, true]);
|
||
}
|
||
|
||
/// **A key blind to effects would never re-bake for a glow.** An item glow resolves
|
||
/// asynchronously (`entities::item_glow`: the item's template, then the effect model's own load),
|
||
/// so its mirror lands *after* the meshes it rides with — a later frame, with every mesh handle
|
||
/// identical. If the bake key ignored effects, the compare would say "unchanged" and the glow
|
||
/// would stay out of the pane until some unrelated content edge forced a re-bake.
|
||
#[test]
|
||
fn a_glow_arriving_after_its_item_still_changes_the_bake_key() {
|
||
let parts = [body_part()];
|
||
let riders = [rider()];
|
||
let parts: Vec<&PortraitPart> = parts.iter().collect();
|
||
let riders: Vec<&PortraitRider> = riders.iter().collect();
|
||
|
||
let before = LookKey::build(&parts, &riders, &[], &[]);
|
||
let glow = effects(4, Vec3::new(0.21, 1.42, 0.06), 1);
|
||
let after = LookKey::build(&parts, &riders, &[], &[&glow]);
|
||
assert!(before != after, "the glow's arrival is a content edge");
|
||
|
||
// …and a *different* seat for the same emitter count is a different bake too: two glow slots
|
||
// on one weapon differ by nothing else.
|
||
let moved = effects(4, Vec3::new(0.21, 1.55, 0.06), 1);
|
||
assert!(
|
||
LookKey::build(&parts, &riders, &[], &[&moved]) != after,
|
||
"the seat is part of the key",
|
||
);
|
||
// The same look twice is the same key — the compare must not re-bake every frame.
|
||
assert!(LookKey::build(&parts, &riders, &[], &[&glow]) == after);
|
||
}
|
||
|
||
/// RE C5's player arm (`portrait-render.md`, CONFIRMED taxonomy): a unit the object manager
|
||
/// does not hold draws `TemporaryPortrait-{Sex}-{Race}`. The out-of-area party member reaches
|
||
/// it through the name cache's triple rather than a descriptor — same art, other source
|
||
/// (report B315).
|
||
#[test]
|
||
fn the_stand_in_names_the_sex_and_race_and_falls_back_without_them() {
|
||
assert_eq!(
|
||
player_temporary_portrait(Some(4), Some(1)),
|
||
"Interface\\CharacterFrame\\TemporaryPortrait-Female-NightElf.blp",
|
||
);
|
||
// Sex 0 is Male, and so is anything else the byte could hold.
|
||
assert_eq!(
|
||
player_temporary_portrait(Some(6), Some(0)),
|
||
"Interface\\CharacterFrame\\TemporaryPortrait-Male-Tauren.blp",
|
||
);
|
||
// No name answer yet (or a race id the client has no string for): the plain file, which is
|
||
// what the ref's `%s-%s` format degenerates to — NOT an empty circle, which is the bug.
|
||
assert_eq!(
|
||
player_temporary_portrait(None, None),
|
||
"Interface\\CharacterFrame\\TemporaryPortrait.blp",
|
||
);
|
||
assert_eq!(
|
||
player_temporary_portrait(Some(9), Some(1)),
|
||
"Interface\\CharacterFrame\\TemporaryPortrait.blp",
|
||
);
|
||
}
|
||
|
||
/// **A still is never committed while the render world is still building pipelines** —
|
||
/// report B331, the player's own portrait baking with the face (or the hair, or the shoulder)
|
||
/// simply absent, "totally random", on the reporter's Windows machine.
|
||
///
|
||
/// The mechanism is not a missing asset and never shows up as one: off macOS Bevy builds each
|
||
/// pipeline variant on the async pool, and `SetItemPipeline` answers a not-yet-built variant
|
||
/// with `Skip` — the batch draws nothing, silently. Every live view redraws it a few frames
|
||
/// later; a one-shot bake sleeps after [`BOOTH_SETTLE_FRAMES`] and keeps the hole for the
|
||
/// session. Which batches lose the race is which variants were cold, which is why one bake
|
||
/// loses the hair (the alpha-key pipeline) and the next the face (the opaque one).
|
||
#[test]
|
||
fn a_bake_holds_its_camera_awake_while_pipelines_are_still_building() {
|
||
assert_eq!(pipe_settle(true, true, 0.0), PipeSettle::Hold);
|
||
assert_eq!(pipe_settle(true, false, 0.0), PipeSettle::Hold);
|
||
}
|
||
|
||
/// **An idle cache does not spend the settle until the bake's own window has drained.** The
|
||
/// counters cross worlds a frame behind, and the variants a bake needs are only queued once
|
||
/// the render world has seen its meshes — so "the cache is idle" read on the frame of the
|
||
/// bake is a fact about the frame *before* it. Spending there would restore exactly the bug.
|
||
#[test]
|
||
fn an_idle_reading_before_the_settle_window_drains_decides_nothing() {
|
||
assert_eq!(pipe_settle(false, false, 0.0), PipeSettle::Idle);
|
||
assert_eq!(pipe_settle(false, true, 0.0), PipeSettle::Spend);
|
||
}
|
||
|
||
/// The bound (the [`Booth::pipes_since`] doc): the cache is process-global, so a session that
|
||
/// keeps meeting new variants can hold it non-empty indefinitely. The hold is released loudly
|
||
/// rather than pinning a camera rendering for the rest of the session — the same shape, and
|
||
/// the same reasoning, as [`PENDING_LANDING_SECS`].
|
||
#[test]
|
||
fn the_settle_is_bounded_even_while_the_cache_keeps_filling() {
|
||
assert_eq!(
|
||
pipe_settle(true, false, PIPELINE_SETTLING_SECS + 0.1),
|
||
PipeSettle::Expired,
|
||
);
|
||
// …and the bound outranks the hold, or a busy cache would never reach it.
|
||
assert_eq!(
|
||
pipe_settle(true, true, PIPELINE_SETTLING_SECS + 0.1),
|
||
PipeSettle::Expired,
|
||
);
|
||
}
|
||
}
|