stealth dims its sparkles, and gear changes stop rebuilding you

- a stealthed unit's particles and ribbons now dim with the body, and a depth-prime pass stops overlapping translucent parts from darkening each other
- changing gear re-dresses the character in place instead of tearing the whole model down and rebuilding it
- terrain tiles furnish cell-by-cell and model render forms build paced across frames, so first contact with fresh ground no longer hitches
- Ghost Wolf renders properly ghostly, and a live toggle swaps its action-bar icon to the active form while it holds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-31 17:01:07 +07:00
co-authored by Claude Fable 5
parent 76cc038998
commit 167f80d92d
54 changed files with 3413 additions and 831 deletions
+117 -58
View File
@@ -1,12 +1,22 @@
//! ADT → terrain-tile asset loader.
//!
//! Decodes one vanilla ADT tile into an [`AdtTile`]: **one splat-blended `Mesh` per MCNK chunk**
//! (Bevy space, absolute world coords) plus its three `texture_2d_array`s — ground layers, per-chunk
//! alpha (blend) maps, per-chunk MCSH shadow maps — and the raw doodad/WMO placement lists (for the
//! spawn system to load as `M2Model`/`WmoModel`). Each chunk mesh carries its array indices on every
//! one of its vertices — `COLOR` (4 layer indices) and `UV1` (alpha index `.x`, shadow index `.y`,
//! `-1` = none) — which is why all 256 chunks of a tile still share **one** material; the app's
//! terrain material reads them.
//! Decodes one vanilla ADT tile into an [`AdtTile`]: the decoded per-MCNK chunks + a
//! [`ChunkShading`] per chunk (the splat-array indices resolved while packing), plus the tile's
//! three `texture_2d_array`s — ground layers, per-chunk alpha (blend) maps, per-chunk MCSH shadow
//! maps — and the raw doodad/WMO placement lists (for the spawn system to load as
//! `M2Model`/`WmoModel`).
//!
//! **The loader ships no meshes.** A tile's ~256 chunk meshes used to be labeled sub-assets built
//! here — which made a tile's landing atomic: the whole set hit the render world's
//! extract/prepare in ONE frame (~80–105 ms of process CPU per fresh row, measured — the B181
//! first-contact spike), and no app-side budget downstream of the load could spread work the
//! loader had already packaged. The app builds each cell's mesh itself via [`chunk_to_mesh`], a
//! few cells per frame (`terrain_stream::furnish`), so the render-world cost of a landing is
//! paced at the only place pacing is possible: mesh-asset creation.
//!
//! Each chunk mesh carries its array indices on every one of its vertices — `COLOR` (4 layer
//! indices) and `UV1` (alpha index `.x`, shadow index `.y`, `-1` = none) — which is why all 256
//! chunks of a tile still share **one** material; the app's terrain material reads them.
//!
//! Layer textures are read via [`LoadContext::read_asset_bytes`] (raw bytes, dependency-tracked) and
//! packed by [`crate::terrain`] — the BLP's authored mips verbatim (the C2 fidelity invariant).
@@ -31,20 +41,16 @@ use crate::terrain::{
LayerTexture, LAYER_TEX_SIZE,
};
/// A loaded ADT terrain tile: one splat mesh per MCNK chunk + its three texture arrays, plus the raw
/// doodad/WMO placements (which the spawn system resolves to `M2Model`/`WmoModel` handles).
/// A loaded ADT terrain tile: the decoded chunks + per-chunk shading indices + the tile's three
/// texture arrays, plus the raw doodad/WMO placements (which the spawn system resolves to
/// `M2Model`/`WmoModel` handles). Cell meshes are built by the app, per chunk, via
/// [`chunk_to_mesh`] — see the module doc for why the loader ships none.
#[derive(Asset, TypePath)]
pub struct AdtTile {
/// One terrain mesh per drawn MCNK chunk (Bevy space, absolute world coords) — the tile's 256
/// cells, less any that a hole mask emptied. Layer indices ride vertex `COLOR`; the alpha and
/// shadow array indices ride `UV1.x` / `UV1.y`, uniform across a chunk's own vertices.
///
/// **Per chunk, not per tile, because the cull's unit is the chunk** (decision 0780): the
/// exterior-scene cull tests one AABB per drawn object, and the reference's object there is the
/// 33.333 yd cell (`0x683bf0`), never the 533 yd tile — a slab the camera stands on intersects
/// every portal window, so it could never be hidden from inside a building. Nothing is duplicated
/// by the split: the merged form never shared a vertex across a chunk boundary either.
pub chunk_meshes: Vec<Handle<Mesh>>,
/// The splat-array indices for each entry of `chunks` (index-parallel; a hole-emptied chunk
/// carries the default, which [`chunk_to_mesh`] never reads because it returns `None` first).
/// Resolved here because only the loader knows the arrays' layout — it packs them.
pub shading: Vec<ChunkShading>,
/// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains).
pub layer_array: Handle<Image>,
/// `texture_2d_array` of per-chunk alpha (blend-weight) maps.
@@ -55,15 +61,91 @@ pub struct AdtTile {
pub doodads: Vec<Doodad>,
/// WMO building placements (raw WoW coords).
pub wmos: Vec<WmoInstance>,
/// The tile's decoded per-MCNK chunks — the source the `chunk_meshes` + arrays were built from,
/// kept resident so the app can derive what those forms drop: the terrain collider, the MCLQ
/// liquid surfaces (each
/// The tile's decoded per-MCNK chunks — the source the arrays were built from and the cell
/// meshes are built from ([`chunk_to_mesh`]), kept resident so the app can derive everything
/// else it owes the tile: the terrain collider, the MCLQ liquid surfaces (each
/// `ChunkMesh.liquid`) and the ground-clutter scatter (per-chunk layers/normals/MCSH + the
/// app-side ground-effect catalog). Bounded by the loaded-tile count; the heaviest field on the
/// asset, carried because clutter/liquid are app concerns (the loader stays catalog-independent).
pub chunks: Vec<ChunkMesh>,
}
/// One drawn chunk's resolved splat indices — everything [`chunk_to_mesh`] needs beyond the
/// decoded [`ChunkMesh`] itself. Uniform across the chunk's own vertices by construction.
#[derive(Clone, Copy)]
pub struct ChunkShading {
/// The chunk's up-to-4 layer-array indices (vertex `COLOR`; unused slots repeat slot 0, whose
/// alpha weight is 0, so they never show).
pub layers: [u32; 4],
/// The chunk's alpha-array index (vertex `UV1.x`; 0 — a weight-0 slot — when it has no map).
pub alpha: u32,
/// The chunk's shadow-array index (vertex `UV1.y`; `-1.0` = no MCSH shadow map).
pub shadow: f32,
}
impl Default for ChunkShading {
fn default() -> Self {
Self {
layers: [0; 4],
alpha: 0,
shadow: -1.0,
}
}
}
/// Build one drawn MCNK chunk's render mesh (Bevy space, absolute world coords) — called by the
/// app a few cells per frame, never by the loader (see the module doc: a loader-built set lands
/// atomically, and the landing IS the B181 first-contact spike).
///
/// **Per chunk, not per tile, because the cull's unit is the chunk** (decision 0780): the
/// exterior-scene cull tests one AABB per drawn object, and the reference's object there is the
/// 33.333 yd cell (`0x683bf0`), never the 533 yd tile — a slab the camera stands on intersects
/// every portal window, so it could never be hidden from inside a building. Nothing is duplicated
/// by the split: the merged form never shared a vertex across a chunk boundary either.
///
/// `RenderAssetUsages::RENDER_WORLD`: the render world takes the buffers on extract — no clone on
/// landing, no resident main-world copy per cell. Nothing reads the *mesh* main-side: the
/// collider, liquid, and clutter all derive from the decoded [`AdtTile::chunks`]. The one thing
/// the main world does need from it — the cell's `Aabb`, which the exterior cull FAILS OPEN
/// without — the caller takes via `Mesh::compute_aabb` before handing the mesh over.
///
/// Returns `None` for a chunk the hole mask emptied (it draws nothing: no mesh, no entity).
pub fn chunk_to_mesh(chunk: &ChunkMesh, shading: &ChunkShading) -> Option<Mesh> {
if chunk.indices.len() < 3 {
return None;
}
let positions: Vec<[f32; 3]> = chunk
.positions
.iter()
.map(|p| wow_to_bevy(*p).to_array())
.collect();
// Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat.
let normals: Vec<[f32; 3]> = if chunk.normals.len() == chunk.positions.len() {
chunk
.normals
.iter()
.map(|n| wow_to_bevy(*n).to_array())
.collect()
} else {
computed_normals(&positions, &chunk.indices)
};
let li = shading.layers;
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
let uv1 = [shading.alpha as f32, shading.shadow];
let n = positions.len();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::RENDER_WORLD,
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, chunk.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![uv1; n]);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![col; n]);
mesh.insert_indices(Indices::U32(chunk.indices.clone()));
Some(mesh)
}
/// Bevy [`AssetLoader`] decoding a vanilla `*.adt` tile → [`AdtTile`].
#[derive(Default, TypePath)]
pub struct AdtLoader;
@@ -96,32 +178,19 @@ impl AssetLoader for AdtLoader {
let mut shadow_buf: Vec<u8> = Vec::new();
let mut shadow_count = 0u32;
// One mesh per drawn chunk. COLOR carries the chunk's 4 layer indices, UV1 its alpha/shadow —
// uniform over the chunk's own vertices, which is what keeps ONE material per tile.
let mut chunk_meshes: Vec<Handle<Mesh>> = Vec::with_capacity(tile.chunks.len());
// One shading entry per chunk (holes get the default). COLOR carries the chunk's 4 layer
// indices, UV1 its alpha/shadow — uniform over the chunk's own vertices, which is what
// keeps ONE material per tile. The MESH built from these lands later, app-side and paced
// (`chunk_to_mesh`); only the array-index resolution belongs here, with the arrays.
let mut shading: Vec<ChunkShading> = Vec::with_capacity(tile.chunks.len());
for (i, chunk) in tile.chunks.iter().enumerate() {
for chunk in tile.chunks.iter() {
// A chunk the hole mask emptied draws nothing: no mesh, no entity, and no alpha/shadow
// array slot either (the indices below are running counters, so skipping here is free).
if chunk.indices.len() < 3 {
shading.push(ChunkShading::default());
continue;
}
let positions: Vec<[f32; 3]> = chunk
.positions
.iter()
.map(|p| wow_to_bevy(*p).to_array())
.collect();
// Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat.
let normals: Vec<[f32; 3]> = if chunk.normals.len() == chunk.positions.len() {
chunk
.normals
.iter()
.map(|n| wow_to_bevy(*n).to_array())
.collect()
} else {
computed_normals(&positions, &chunk.indices)
};
// Resolve up to 4 layer textures to array indices (appending new ones). Missing slots
// reuse layer 0 (its alpha weight is 0, so it never shows).
let mut li = [0u32; 4];
@@ -158,21 +227,11 @@ impl AssetLoader for AdtLoader {
}
None => -1.0,
};
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
let uv1 = [ai as f32, si];
let n = positions.len();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, chunk.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![uv1; n]);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![col; n]);
mesh.insert_indices(Indices::U32(chunk.indices.clone()));
chunk_meshes.push(ctx.add_labeled_asset(format!("chunk{i}"), mesh));
shading.push(ChunkShading {
layers: li,
alpha: ai,
shadow: si,
});
}
// A `texture_2d_array` needs ≥1 layer even when a tile carries no alpha / shadow maps.
@@ -190,7 +249,7 @@ impl AssetLoader for AdtLoader {
let shadow_array = shadow_array_image(SHADOW_MAP_SIZE, shadow_count, shadow_buf);
Ok(AdtTile {
chunk_meshes,
shading,
layer_array: ctx.add_labeled_asset("layer_array".to_string(), layer_array),
alpha_array: ctx.add_labeled_asset("alpha_array".to_string(), alpha_array),
shadow_array: ctx.add_labeled_asset("shadow_array".to_string(), shadow_array),
+61 -34
View File
@@ -26,14 +26,15 @@ pub mod minimap_grid;
mod model;
pub use model::{
bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations,
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
bone_target_id, submesh_to_skinned_mesh, submesh_to_static_mesh, AnimClip, BillboardInfo,
GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, ModelJoint, ModelMarker,
ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack,
ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
};
mod adt;
mod terrain;
mod wdt;
pub use adt::{AdtLoader, AdtTile};
pub use adt::{chunk_to_mesh, AdtLoader, AdtTile, ChunkShading};
pub use wdt::{WdtIndex, WdtIndexLoader};
mod blp;
pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
@@ -186,6 +187,7 @@ pub fn register_asset_loaders(app: &mut App) {
#[cfg(test)]
mod tests {
use super::*;
use bevy::camera::primitives::MeshAabb;
use bevy::tasks::block_on;
/// crates/benilla-assets -> repo root -> WoW/Data (gitignored; test skips when absent).
@@ -428,25 +430,35 @@ mod tests {
for _ in 0..600 {
app.update();
if let Some(m) = app.world().resource::<Assets<M2Model>>().get(&handle) {
let meshes: Vec<Handle<Mesh>> =
m.submeshes.iter().map(|s| s.mesh.clone()).collect();
let geometries: Vec<_> = m.submeshes.iter().map(|s| s.geometry.clone()).collect();
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
info = Some((meshes, textured, m.bounds.is_some()));
info = Some((geometries, textured, m.bounds.is_some()));
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let (meshes, textured, has_bounds) =
let (geometries, textured, has_bounds) =
info.expect("the campfire M2 should load via mpq:// + M2ModelLoader within 600 ticks");
assert!(!meshes.is_empty(), "campfire should have render batches");
assert!(
!geometries.is_empty(),
"campfire should have render batches"
);
assert!(has_bounds, "M2 carries authored bounds");
assert!(textured > 0, "campfire batches reference embedded textures");
// Each submesh mesh sub-asset is present and non-empty (baked to Bevy space).
let mesh_assets = app.world().resource::<Assets<Mesh>>();
for h in &meshes {
let mesh = mesh_assets.get(h).expect("submesh mesh sub-asset present");
// The loader ships geometry, no meshes (decision 0834) — the app builds the render form.
// Exercise both builders per batch: non-empty, and the static form must yield the Aabb
// the spawn side inserts explicitly (RENDER_WORLD meshes race `calculate_bounds`).
for g in &geometries {
let mesh = submesh_to_static_mesh(g);
assert!(mesh.count_vertices() > 0, "submesh has vertices");
assert!(mesh.compute_aabb().is_some(), "static form yields an Aabb");
let skinned = submesh_to_skinned_mesh(g);
assert_eq!(
skinned.count_vertices(),
mesh.count_vertices(),
"the skinned twin bakes the same geometry"
);
}
}
@@ -475,26 +487,26 @@ mod tests {
for _ in 0..600 {
app.update();
if let Some(m) = app.world().resource::<Assets<WmoModel>>().get(&handle) {
let meshes: Vec<Handle<Mesh>> =
m.submeshes.iter().map(|s| s.mesh.clone()).collect();
let geometries: Vec<_> = m.submeshes.iter().map(|s| s.geometry.clone()).collect();
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
info = Some((meshes, textured));
info = Some((geometries, textured));
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let (meshes, textured) =
let (geometries, textured) =
info.expect("the Goldshire Inn WMO should load via mpq:// + WmoModelLoader");
assert!(
!meshes.is_empty(),
!geometries.is_empty(),
"the inn has render batches across its groups"
);
assert!(textured > 0, "WMO batches reference textures");
let mesh_assets = app.world().resource::<Assets<Mesh>>();
for h in &meshes {
let mesh = mesh_assets.get(h).expect("group submesh sub-asset present");
// Geometry, no meshes (decision 0834): the static build is the WMO's one render form.
for g in &geometries {
let mesh = submesh_to_static_mesh(g);
assert!(mesh.count_vertices() > 0, "group submesh has vertices");
assert!(mesh.compute_aabb().is_some(), "static form yields an Aabb");
}
}
@@ -532,7 +544,12 @@ mod tests {
app.update();
if let Some(t) = app.world().resource::<Assets<AdtTile>>().get(&handle) {
info = Some((
t.chunk_meshes.clone(),
t.chunks
.iter()
.zip(&t.shading)
.map(|(c, s)| (c.clone(), *s))
.collect::<Vec<_>>(),
t.shading.len(),
t.layer_array.clone(),
t.alpha_array.clone(),
t.shadow_array.clone(),
@@ -542,24 +559,34 @@ mod tests {
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let (chunk_hs, layer_h, alpha_h, shadow_h, n_doodads) =
let (cells, n_shading, layer_h, alpha_h, shadow_h, n_doodads) =
info.expect("the ADT tile should load via mpq:// + AdtLoader");
// One drawn mesh per MCNK cell, never one merged slab (decision 0780) — the exterior-scene
// cull's unit is the chunk, and a tile that loads as a single object cannot be culled from
// inside a building. Every cell must be non-empty and a 145-vertex 9×9+8×8 grid.
let meshes = app.world().resource::<Assets<Mesh>>();
// inside a building. The loader ships the shading (index-parallel with the chunks); the
// mesh itself is the app's paced `chunk_to_mesh` build — exercised per cell here. Every
// drawn cell must be a 145-vertex 9×9+8×8 grid.
assert_eq!(n_shading, cells.len(), "one ChunkShading per decoded chunk");
let drawn: Vec<Mesh> = cells
.iter()
.filter_map(|(c, s)| chunk_to_mesh(c, s))
.collect();
assert!(
(1..=256).contains(&chunk_hs.len()),
(1..=256).contains(&drawn.len()),
"a tile draws as its MCNK cells, got {} meshes",
chunk_hs.len()
drawn.len()
);
for h in &chunk_hs {
let vc = meshes
.get(h)
.expect("chunk mesh sub-asset present")
.count_vertices();
assert_eq!(vc, 145, "an MCNK cell is 9×9 + 8×8 vertices");
for mesh in &drawn {
assert_eq!(
mesh.count_vertices(),
145,
"an MCNK cell is 9×9 + 8×8 vertices"
);
assert!(
mesh.compute_aabb().is_some(),
"a cell mesh must yield the Aabb the exterior cull fails open without"
);
}
let images = app.world().resource::<Assets<Image>>();
@@ -577,7 +604,7 @@ mod tests {
// Placement lists are carried as data (count is tile-dependent — some tiles are bare).
eprintln!(
"ADT tile loaded: {} MCNK cells, {n_doodads} doodad placements",
chunk_hs.len()
drawn.len()
);
}
}
+17 -20
View File
@@ -1,10 +1,14 @@
//! M2 → model asset loader.
//!
//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each carrying
//! a [`Mesh`](bevy::mesh::Mesh) **labeled sub-asset** (baked to Bevy space via [`crate::coords`]) plus
//! the batch's texture as a `Handle<Image>` **dependency** (through the `mpq://` source).
//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each
//! carrying the batch's **decoded geometry** plus its texture as a `Handle<Image>` **dependency**
//! (through the `mpq://` source). **The loader ships no meshes** (decision 0834, the model-lane
//! twin of 0832's terrain rule): a labeled mesh sub-asset lands the whole model's render form in
//! ONE frame — the city first-contact spike — so the app builds each batch's `Mesh` paced at the
//! spawn side (`benilla`'s `model_forms`, via [`submesh_to_static_mesh`](crate::submesh_to_static_mesh)
//! / [`submesh_to_skinned_mesh`](crate::submesh_to_skinned_mesh)).
//!
//! Deliberately app-independent: builds **meshes + texture handles + metadata only**, not materials
//! Deliberately app-independent: ships **geometry + texture handles + metadata only**, not materials
//! (the WoW lighting material is an app concern built at spawn) — so this asset is shared verbatim by
//! doodads, creatures, GameObjects, and the star model. Creature **skin variations** (`Monster1/2/3`)
//! are blank here (no `skins` passed) and resolved at the spawn site from `CreatureDisplayInfo`; this
@@ -31,9 +35,8 @@ use crate::bone_target_id;
use crate::coords::wow_to_bevy;
use crate::model::{
arm_subtree_roots, billboard_info, build_animation_clip, build_attachments, build_global_bones,
build_grip_clip, build_skeleton, build_skinned_submesh_mesh, build_submesh_mesh,
finger_subtree_roots, in_subtree, skeleton_pivots, upper_subtree_root, AnimClip,
ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh,
build_grip_clip, build_skeleton, finger_subtree_roots, in_subtree, skeleton_pivots,
upper_subtree_root, AnimClip, ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh,
};
/// A loaded M2 model: its render batches + authored bounds (the distance-fade size source) + the
@@ -276,16 +279,11 @@ impl AssetLoader for M2ModelLoader {
.filter(|c| !c.is_empty());
let mut submeshes = Vec::with_capacity(subs.len());
for (i, sub) in subs.iter().enumerate() {
let mesh = ctx.add_labeled_asset(format!("submesh{i}"), build_submesh_mesh(sub));
// The skinned twin (decision 0019): same geometry + JOINT_INDEX/JOINT_WEIGHT. Built for
// every M2 (the loader can't know the model's role); only the creature path uses it, so a
// doodad/GameObject model carries an unused skinned mesh — a bounded interim cost (the first
// deferred optimisation is building this lazily, or unifying all M2s onto the skinned path).
let skinned_mesh = ctx.add_labeled_asset(
format!("submesh{i}_skinned"),
build_skinned_submesh_mesh(sub),
);
for sub in subs {
// No meshes built here (decision 0834): the geometry ships on the submesh and the app
// builds the render form paced — the static mesh on demand, the skinned twin only for
// the lanes that rig (which also retires the old always-built, mostly-unused twin).
//
// Embedded texture → an mpq:// dependency (WorldArt, the loader default). Lowercase the
// path: Bevy's loader lookup is case-sensitive, and these tables carry uppercase `.BLP`
// (the `BlpImageLoader` registers `blp`), so an uppercase extension silently falls back to
@@ -298,8 +296,6 @@ impl AssetLoader for M2ModelLoader {
.as_deref()
.map(|t| ctx.load::<Image>(crate::texture_url(t, (sub.wrap_x, sub.wrap_y))));
submeshes.push(ModelSubmesh {
mesh,
skinned_mesh,
texture,
skin_slot: sub.skin_slot,
geoset_id: sub.geoset_id,
@@ -314,12 +310,13 @@ impl AssetLoader for M2ModelLoader {
no_depth_write: sub.no_depth_write, // M2 render flag 0x10
no_depth_test: sub.no_depth_test, // M2 render flag 0x08
fog_policy: sub.fog_policy, // M2 render flag 0x02 / the per-blend fog table
billboard: billboard_info(sub), // glow cards / chains face the camera
billboard: billboard_info(&sub), // glow cards / chains face the camera
alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new),
uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new),
rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new),
wmo_batch: None, // M2 batches have no MOBA section
ground_quad: sub.ground_quad(), // flat ground-ring quads → the fx decal lane
geometry: std::sync::Arc::new(sub),
});
}
// M2 light blocks (raw — wow-m2's light parse reads the wrong vanilla record shape). The spawn
+34 -21
View File
@@ -47,20 +47,20 @@ pub struct BillboardInfo {
pub seq_translations: Vec<(u16, BoneScaleAnim)>,
}
/// One render batch of a loaded model (an M2 batch or a WMO group batch): geometry as a Bevy `Mesh`
/// sub-asset, the batch's albedo texture (WorldArt), and its blend/sidedness. Carries handles +
/// metadata only — the material is built at spawn (an app concern).
/// One render batch of a loaded model (an M2 batch or a WMO group batch): the decoded geometry,
/// the batch's albedo texture (WorldArt), and its blend/sidedness. Carries data + metadata only —
/// **no `Mesh` assets** (decision 0834, the model-lane twin of 0832's terrain rule): a labeled
/// mesh sub-asset lands the instant the decode completes and the render world ingests the whole
/// model in ONE frame, which is the city first-contact spike. The app builds each batch's render
/// form paced (`benilla`'s `model_forms`) via [`submesh_to_static_mesh`] /
/// [`submesh_to_skinned_mesh`]; the material stays a spawn-site concern as before.
#[derive(Clone)]
pub struct ModelSubmesh {
/// Geometry, baked to Bevy space (a labeled sub-asset of the model). The **static** mesh — no
/// joint attributes — used by doodads/GameObjects (which never skin).
pub mesh: Handle<Mesh>,
/// The **skinned** twin of [`Self::mesh`] (decision 0019): the same geometry plus the per-vertex
/// `JOINT_INDEX`/`JOINT_WEIGHT` attributes, so Bevy's mesh pipeline takes the `SKINNED` path. Used
/// only by the skinned creature path (which also attaches a `SkinnedMesh` + joint hierarchy); a
/// static instance must use [`Self::mesh`] instead, since a skinned-layout mesh with no
/// `SkinnedMesh` component indexes the joint buffer out of bounds (garbage, not bind pose).
pub skinned_mesh: Handle<Mesh>,
/// The batch's decoded geometry (model space, WoW axes) — everything the mesh builders below
/// need, shared across every instance of the model. `Arc` keeps the submesh clone cheap and is
/// the model's single resident CPU copy of the vertex data (the static render form is
/// GPU-only; see [`submesh_to_static_mesh`]).
pub geometry: std::sync::Arc<RenderSubmesh>,
/// Embedded albedo texture, if any. `None` for creature skin slots (filled at spawn from the
/// display's skin variation — see [`Self::skin_slot`]).
pub texture: Option<Handle<Image>>,
@@ -138,7 +138,12 @@ pub struct ModelSubmesh {
/// applied here at the render boundary. Prefers the authored normals (soft, outward — how WoW lights
/// foliage); recomputes flat ones only when absent. WMO per-vertex MOCV colour is folded in when
/// present (M2 has none). The WoW→Bevy map is a pure rotation, so it applies to normals too.
pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
///
/// `usages` is the caller's lane split (decision 0834): the **static** form is `RENDER_WORLD`-only
/// (nothing reads it main-side; the render world takes the buffers at extract, no resident CPU
/// copy), while the **skinned** twin keeps the default `MAIN_WORLD | RENDER_WORLD` because the
/// mouseover picker rays its vertices on the main world (`target::hover::ray_posed_mesh`).
fn build_submesh_mesh(sub: &RenderSubmesh, usages: RenderAssetUsages) -> Mesh {
// A billboard batch is built centred at its bone pivot, so the spawn site can rotate it about that
// point to face the camera (the entity transform then re-places it at the pivot in the world).
let center = sub
@@ -150,10 +155,7 @@ pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
.iter()
.map(|p| (wow_to_bevy(*p) - center).to_array())
.collect();
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, usages);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone());
mesh.insert_indices(Indices::U32(sub.indices.clone()));
@@ -209,13 +211,24 @@ pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute =
bevy::render::render_resource::VertexFormat::Float32x4,
);
/// Build the **skinned** twin of [`build_submesh_mesh`]: the same baked geometry plus the
/// The app-facing **static** mesh build (decision 0834): geometry only, `RENDER_WORLD`-only
/// usages — the render world takes the vertex buffers at extract and the main world keeps no
/// copy. Consumers must pair it with an explicit `Aabb` computed at build time (the exterior
/// cull fails OPEN on a missing bound, and `RENDER_WORLD` races Bevy's `calculate_bounds` —
/// the same rule 0832 set for terrain cells).
pub fn submesh_to_static_mesh(sub: &RenderSubmesh) -> Mesh {
build_submesh_mesh(sub, RenderAssetUsages::RENDER_WORLD)
}
/// Build the **skinned** twin of [`submesh_to_static_mesh`]: the same baked geometry plus the
/// per-vertex [`ATTRIBUTE_WOW_JOINT_INDEX`] + [`ATTRIBUTE_WOW_JOINT_WEIGHT`]. These two
/// attributes are the entire trigger for the owned-palette skinning path (decisions 0019/0720).
/// When the submesh carries no skin (WMO / a boneless batch) this is identical to the static
/// mesh — harmless, but the creature path is the only consumer regardless.
pub(crate) fn build_skinned_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
let mut mesh = build_submesh_mesh(sub);
/// mesh — harmless, but the rigged paths are the only consumers regardless. Keeps the default
/// `MAIN_WORLD | RENDER_WORLD` usages: the mouseover picker skins these vertices on the CPU
/// (`target::hover`), so the main-world copy is read, not waste.
pub fn submesh_to_skinned_mesh(sub: &RenderSubmesh) -> Mesh {
let mut mesh = build_submesh_mesh(sub, RenderAssetUsages::default());
if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() {
mesh.insert_attribute(
ATTRIBUTE_WOW_JOINT_INDEX,
+7 -1
View File
@@ -154,6 +154,12 @@ pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec<u8>) ->
/// Build a `2d_array` `Image` from concatenated per-layer bytes, with a `D2Array` view so the
/// `dimension = "2d_array"` bindings resolve.
///
/// `RenderAssetUsages::RENDER_WORLD`: the render world takes the bytes on extract — a tile's
/// three arrays are megabytes each, and nothing reads them main-side (they flow into the terrain
/// material as handles; the minimap, collider, clutter all derive from other sources). The
/// default usage cloned every array on landing AND kept a main-world copy resident per tile —
/// pure cost on both the B181 landing frame and the resident-set memory.
fn array_image(size: u32, layer_count: u32, data: Vec<u8>, format: TextureFormat) -> Image {
let mut image = Image::new(
Extent3d {
@@ -164,7 +170,7 @@ fn array_image(size: u32, layer_count: u32, data: Vec<u8>, format: TextureFormat
TextureDimension::D2,
data,
format,
RenderAssetUsages::default(),
RenderAssetUsages::RENDER_WORLD,
);
image.texture_view_descriptor = Some(TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
+10 -8
View File
@@ -6,8 +6,9 @@
//! [`ModelSubmesh`] per group render batch with its texture resolved against the root's material
//! tables.
//!
//! Same app-independent shape as the M2 loader (meshes + texture handles + metadata; materials at
//! spawn). WMOs don't size-fade, so there are no bounds.
//! Same app-independent shape as the M2 loader (geometry + texture handles + metadata; meshes are
//! built paced app-side — decision 0834 — and materials at spawn). WMOs don't size-fade, so there
//! are no bounds.
use crate::column_grid::ColumnGrid;
use std::sync::Arc;
@@ -24,7 +25,7 @@ use bevy::asset::{Asset, AssetLoader, LoadContext};
use bevy::prelude::*;
use bevy::reflect::TypePath;
use crate::model::{build_submesh_mesh, ModelSubmesh};
use crate::model::ModelSubmesh;
/// Per-WMO-group navigation data for the portal visibility cull (`crate::wmo_portal`): the group's
/// MOGP flags (the EXTERIOR `0x8` bit the flood defers on), its bounding box (WMO model space, WoW
@@ -596,9 +597,11 @@ impl AssetLoader for WmoModelLoader {
if let Some(slot) = group_liquids.get_mut(gi as usize) {
*slot = wmo_group_liquid_mesh(&gbytes);
}
for (j, sub) in subs.iter().enumerate() {
let mesh =
ctx.add_labeled_asset(format!("group{gi}_sub{j}"), build_submesh_mesh(sub));
for sub in subs {
// No meshes built here (decision 0834): a city root's ~thousands of group batches
// as labeled sub-assets landed in ONE frame — the geometry ships on the submesh
// and the app builds each batch's mesh paced (`benilla`'s `model_forms`).
//
// Lowercase the path: Bevy's loader lookup is case-sensitive and these tables carry
// uppercase `.BLP`, so an uppercase extension falls back to type-based resolution —
// ambiguous with Bevy's built-in image loader, which spams a "Multiple AssetLoaders
@@ -610,8 +613,6 @@ impl AssetLoader for WmoModelLoader {
))
});
submeshes.push(ModelSubmesh {
skinned_mesh: mesh.clone(), // WMO never skins; alias the static mesh (unused)
mesh,
texture,
skin_slot: sub.skin_slot, // always None for WMO groups (no creature skins)
geoset_id: 0, // WMO has no M2 geoset concept
@@ -632,6 +633,7 @@ impl AssetLoader for WmoModelLoader {
rgb_anim: None, // …nor M2Color tints
wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law
ground_quad: None, // the fx decal lane is M2-only
geometry: std::sync::Arc::new(sub),
});
submesh_group.push(gi as u16);
}
+4
View File
@@ -121,6 +121,10 @@ struct ModelRow {
}
/// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`].
///
/// `Default` is the **empty** catalog — the same thing "the DBC failed to load" already means to
/// every consumer (every lookup misses and the caller takes its documented fallback).
#[derive(Default)]
pub struct CreatureCatalog {
/// CreatureDisplayInfo: displayId → row.
display: HashMap<u32, DisplayRow>,
@@ -569,6 +569,15 @@ fn real_spell_catalog_reads_usable_walk_columns() {
// Stance carries 0, which is what keeps a stance un-cancelable on the plain paths.
assert_ne!(ghost_wolf.active_icon_id, 0);
assert_eq!(cat.get(2457).unwrap().active_icon_id, 0, "Battle Stance");
// The form's AttackIconID column (field 13, `0x4e6870`'s `+0x34` read — wow-re
// `action-spell-icon-apis.md` §3.3), resolved through SpellIcon.dbc at load: Cat Form
// carries its own attack face, Ghost Wolf's column is 0 → the weapon fall-through.
assert_eq!(
forms.get(&1).unwrap().attack_icon.as_deref(),
Some("Interface\\Icons\\Ability_Druid_CatFormAttack")
);
assert_eq!(wolf_row.attack_icon, None);
}
/// The tooltip-arc columns (decision 0274 P2) on the real build-5875 `Spell.dbc`, pinned
@@ -33,6 +33,13 @@ pub struct ShapeshiftForm {
/// predicates. `<= 0` reads Humanoid (the resolver's fallback; vmangos's own row comment).
/// Consumed by the tracking dots (decision 0564).
pub creature_type: i32,
/// **AttackIconID** (field 13, the byte-carved `+0x34` read — wow-re
/// `action-spell-icon-apis.md` §3.3): the form's own attack icon, resolved through
/// `SpellIcon.dbc` at load. The Attack action's icon resolver (`0x4e6870`) serves the
/// CURRENT form's icon before the main-hand weapon's, on both the action bar and the
/// spellbook. `None` = column 0, no form icon — fall through to the weapon (5875 data:
/// warrior stances and bear forms carry one; Ghost Wolf and Moonkin are 0).
pub attack_icon: Option<String>,
}
impl ShapeshiftForm {
@@ -74,6 +81,10 @@ pub fn load_shapeshift_forms(chain: &mut Chain) -> Result<HashMap<u32, Shapeshif
}
}
let set = parse(&bytes, schema, "SpellShapeshiftForm.dbc")?;
// The AttackIconID column resolves through SpellIcon.dbc like a spell's own icon does
// (`0x4e6870`'s `0x4e68af`–`0x4e68da` id → path hop). 1033 tiny records — loading the map
// here keeps every call site's signature (six of them) untouched.
let icons = crate::dbc::load_spell_icon_map(chain)?;
let mut map = HashMap::new();
for r in set.records() {
if let Some(id) = u32_at(r, 0) {
@@ -84,6 +95,9 @@ pub fn load_shapeshift_forms(chain: &mut Chain) -> Result<HashMap<u32, Shapeshif
name: str_at(&set, r, 2).unwrap_or_default(),
flags: u32_at(r, 11).unwrap_or(0),
creature_type: i32_at(r, 12).unwrap_or(0),
attack_icon: u32_at(r, 13)
.filter(|&i| i != 0)
.and_then(|i| icons.get(&i).cloned()),
},
);
}
@@ -524,6 +524,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
if (m.model_flags.y > 0.5 && base_color.a < VANILLA_ALPHA_KEY) {
discard;
}
// DEPTH-PRIME TWIN (the zfill pipeline variant — terrain.rs `specialize`, the reference's
// M2UseZFill clone command, wow-re m2-blend-promotion-zfill.md §4): colour writes are masked
// off at the pipeline, so on that variant only the discards above (farclip wall, the
// model_flags.y cutout) shape what depth gets written; the lighting/fog below is computed and
// thrown away. (A shader-def early return here would be the natural spelling, but naga's MSL
// backend miscompiles the dead tail — "redefinition of '_tmp'" — so the twin pays the colour
// math instead. Episodes are transient; the cost is bounded.)
// Blend source alpha = texel alpha × fade (translucent fade of the fixed cutout shape). For steady
// cutout/opaque draws blend is off so this is ignored; for the fade twin it drives the feather.
let faded_alpha = base_color.a * obj_fade;
+118 -36
View File
@@ -18,19 +18,25 @@
//! act on the body:
//!
//! - **proc 14 = translucency** (`0x60d972`): the param becomes a node keyed by spell id
//! (`node+0x18`, value at `node+0x78`) on the unit's list `unit+0xb50`. The unit's effective alpha
//! is then recomputed by `0x60d180` as **`baseAlpha × Π(node alphas)`** — `baseAlpha` from the
//! CGUnit vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`; a flat `1.0` for
//! players) — and handed to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block
//! (`+0xec..0x100`) and the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
//! ([`crate::model_fade::fade_alpha`]). Per frame `model+0x180 = baseRenderAlpha × fade`
//! (`node+0x18`, value at `node+0x78`), linked at the HEAD of the unit's list `unit+0xb50`. The
//! unit's effective alpha is then recomputed by `0x60d180` as **`baseAlpha × the head node's
//! factor`** — at most one node term, never a product over the chain (wow-re
//! `base-render-alpha.md` §4, correcting this note's earlier `Π` gloss) — `baseAlpha` from the
//! vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`, the SAME slot in the unit
//! and player vtables: "players 1.0" was the data talking, not a type fork — §6) — and handed
//! to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block (`+0xec..0x100`) and
//! the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
//! ([`crate::model_fade::fade_alpha`]). Per frame `model+0x180 = master × fade`
//! (`0x614baa → 0x710cb0`), which multiplies into the per-batch alpha (`0x707680`).
//! The recompute has a second, aura-free caller: the DISPLAYID watcher's refresh (`0x60abe0 →
//! 0x60ad9f`), which is how a display whose row says `CreatureModelAlpha < 255` — Ghost Wolf's
//! 4613 = 102 — renders translucent by itself; [`refresh_base_alpha`] is that leg.
//! - **proc 1 = tint** (`0x60d840`): `round(param)` is a packed `0x00RRGGBB` OR'd with `0xff000000`
//! into a tint node (list `unit+0xce0`); per frame the head node goes `×1/255` into
//! `model+0x184/188/18c`.
//!
//! Removal is the mirror image: the aura leaves the slot → `0x5ff290` drops that spell's nodes and
//! recomputes, so the alpha **ramps back** to the new product over the same 1000 ms.
//! recomputes, so the alpha **ramps back** to the new target over the same 1000 ms.
//!
//! The vis-flag is a decoy and must not be used here: `UNIT_FIELD_BYTES_1` byte 3's CREEP (stealth)
//! and GHOST bits drive **no** body render at all — an exhaustive census of every `+0x213` reference
@@ -51,7 +57,7 @@
//!
//! ## What is built here, and what is not
//!
//! **Proc 14 ships whole** — nodes, the `baseAlpha × Π` product, the 1000 ms cubic ramp both ways,
//! **Proc 14 ships whole** — nodes, the `baseAlpha × head node` target, the 1000 ms cubic ramp both ways,
//! and the render composition below.
//!
//! **Proc 1 ships too** (decision 0812): its nodes live here ([`AuraNodes::tint`], head-node-wins as
@@ -104,7 +110,7 @@ const ALPHA_EPS: f32 = 1.0 / 128.0;
/// reference keys `node+0x18`. Held in [`AuraNodes`]; a `Reap` for that spell id removes it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum AuraNode {
/// Proc 14: this aura's alpha factor (`node+0x78`), one term of the unit's alpha product.
/// Proc 14: this aura's alpha factor (`node+0x78`) — the target's node term while it is the head.
Alpha(f32),
/// Proc 1: this aura's body tint, unpacked from the param's `0x00RRGGBB`. Modelled, not yet
/// rendered (module docs).
@@ -112,7 +118,7 @@ pub(crate) enum AuraNode {
}
/// The CharProc node lists of one unit — the ECS twin of the reference's per-unit `unit+0xb50`
/// (alpha) and `unit+0xce0` (tint) lists, plus the ramp `0x614f80` drives from their product.
/// (alpha) and `unit+0xce0` (tint) lists, plus the ramp `0x614f80` drives from their heads.
///
/// Lives on the **net entity root**, like [`crate::entity_shade::GroundShade`]: the reference has one
/// of these per CGUnit and every attached model (held item, helm, shoulder) renders off the owner's,
@@ -120,13 +126,17 @@ pub(crate) enum AuraNode {
/// direct children.
#[derive(Component, Debug, Default)]
pub(crate) struct AuraNodes {
/// `(spell id, factor)` — proc-14 nodes. The product of these times [`Self::base`] is the target.
/// `(spell id, factor)` — proc-14 nodes, newest at the front (the reference links a fresh node
/// at the list head). The HEAD times [`Self::base`] is the target — never a product.
alpha: Vec<(u32, f32)>,
/// `(spell id, rgb)` — proc-1 nodes, insertion-ordered so the front is the reference's head node
/// (the one its per-frame apply reads). No renderer yet; see the module docs.
/// `(spell id, rgb)` — proc-1 nodes, newest at the front like the alpha list; the head is what
/// the per-frame apply reads.
tint: Vec<(u32, [u8; 3])>,
/// `baseAlpha`: `CreatureDisplayInfo.CreatureModelAlpha / 255` for a creature, `1.0` for a player
/// (`0x60d2d0`). Seeded when the first node arrives — by then the display id has landed.
/// `baseAlpha`: the display row's `CreatureModelAlpha / 255` — for EVERY unit, players
/// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255
/// (`base-render-alpha.md` §6, correcting the "players 1.0" gloss). Owned by
/// [`refresh_base_alpha`], which re-resolves it on every display-id change (a shapeshift
/// swaps it live) and ramps to the new value.
base: f32,
/// Where the ramp is now — what the parts actually render at.
current: f32,
@@ -155,15 +165,16 @@ impl AuraNodes {
}
}
/// The reference's `0x60d180`: `baseAlpha × Π(node alphas)`, clamped to a sane `[0, 1]`.
/// The reference's `0x60d180`: `baseAlpha × the HEAD alpha node's factor` — at most ONE node
/// term, skipped entirely when the list is empty (`0x60d195 je`), never a product over the
/// chain (wow-re `base-render-alpha.md` §4, correcting `ghost-death-visuals.md`'s `Π` gloss).
/// Nodes are linked at the head as they install, so the newest one is the term that counts.
fn target(&self) -> f32 {
self.alpha
.iter()
.fold(self.base, |acc, (_, f)| acc * f)
.clamp(0.0, 1.0)
let head = self.alpha.first().map_or(1.0, |(_, f)| *f);
(self.base * head).clamp(0.0, 1.0)
}
/// Point the ramp at the current product, from wherever it is now. A no-op when the target
/// Point the ramp at the current target, from wherever it is now. A no-op when the target
/// hasn't moved, so an aura refresh (or a node whose factor equals the live one) doesn't restart
/// the ease.
fn retarget(&mut self, now: f32) {
@@ -295,11 +306,12 @@ pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
/// Apply this frame's [`AuraProc`] edges to the units' node lists, then re-aim every live ramp.
///
/// `base_alpha` is resolved once per unit, when its first node arrives: a player is a flat `1.0`
/// (the reference's getter has no display lookup for them), a creature is its display's
/// `CreatureModelAlpha`. A unit whose display is unknown falls back to `1.0` — opaque, i.e. the
/// aura's factor alone, which is the safe direction (the alternative would hide a unit whose row
/// simply failed to load).
/// `base_alpha` here only SEEDS a node set created by an aura edge — its steady-state owner is
/// [`refresh_base_alpha`], which re-resolves it on every display change. The getter is the same
/// for players and creatures (the display row's `CreatureModelAlpha`; `base-render-alpha.md` §6).
/// A unit whose display is unknown falls back to `1.0` — opaque, i.e. the aura's factor alone,
/// which is the safe direction (the alternative would hide a unit whose row simply failed to
/// load).
pub(crate) fn drain_aura_procs(
mut edges: MessageReader<AuraProc>,
time: Res<Time>,
@@ -330,7 +342,7 @@ pub(crate) fn drain_aura_procs(
// that streams in already carrying two aura procs (or gets two in one packet
// burst) emits two Begins this frame and the second would `get_mut` the
// still-absent component, build a *fresh* set, and insert over the first —
// silently keeping only the last aura's factor. Staging makes the product of a
// silently keeping only the last aura's factor. Staging makes the outcome of a
// same-frame burst come out right, which is the common case for a unit observed
// mid-buff, not a corner.
let n = fresh.entry(*entity).or_insert_with(|| {
@@ -358,7 +370,8 @@ pub(crate) fn drain_aura_procs(
}
}
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp at the new product.
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp at the new target
/// (the next head node down, or the bare base).
fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
n.alpha.retain(|(s, _)| *s != spell_id);
n.tint.retain(|(s, _)| *s != spell_id);
@@ -367,20 +380,23 @@ fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
/// Install one spell's nodes, replacing any it already had (an aura re-applied by a second caster
/// holds a second slot but installs one node set — the reference's own per-spell-id keying).
/// New nodes link **at the head** (`base-render-alpha.md` §4: the newest node is the one the
/// recompute reads), which is why both lists insert at the front here and both readers take
/// `.first()`.
fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
n.alpha.retain(|(s, _)| *s != spell_id);
n.tint.retain(|(s, _)| *s != spell_id);
for node in procs {
match *node {
AuraNode::Alpha(f) => n.alpha.push((spell_id, f)),
AuraNode::Tint(rgb) => n.tint.push((spell_id, rgb)),
AuraNode::Alpha(f) => n.alpha.insert(0, (spell_id, f)),
AuraNode::Tint(rgb) => n.tint.insert(0, (spell_id, rgb)),
}
}
n.retarget(now);
}
/// The layer's instrument (`WOW_MOVE_TRACE=<path>`, tag `aur`) — one line per node edge, carrying
/// the spell, the nodes it installed, and the product the ramp is now aiming at.
/// the spell, the nodes it installed, and the target the ramp is now aiming at.
///
/// This is how "stealth shows nothing" is *reproduced and then closed* without eyeballing a capture
/// (`method.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
@@ -407,7 +423,12 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
);
}
/// `baseAlpha` for a unit: `1.0` for a player, else its display's `CreatureModelAlpha / 255`.
/// `baseAlpha` for a unit — the getter `0x60d2d0`: its CURRENT display row's
/// `CreatureModelAlpha / 255`, for **every** unit kind. There is no player override — the same
/// vtable slot in both classes, byte-identical (`base-render-alpha.md` §6); an unshifted
/// character reads 1.0 only because its row (49, 50, …) says 255, and a shaman wearing Ghost
/// Wolf's display 4613 reads 102/255 = 0.4 through exactly this path. No row → 1.0 (the
/// `0x60d2e4` NULL fallback).
fn base_alpha(
entity: Entity,
units: &Query<&crate::net::NetEntity>,
@@ -416,12 +437,73 @@ fn base_alpha(
let Ok(net) = units.get(entity) else {
return 1.0;
};
if net.kind != EntityKind::Player {
if let (Some(c), Some(display)) = (creatures, net.display_id) {
return c.display_base_alpha(display).unwrap_or(1.0);
display_base_alpha(net, creatures)
}
/// The display-row half of [`base_alpha`], for callers already holding the `NetEntity`.
fn display_base_alpha(
net: &crate::net::NetEntity,
creatures: Option<&crate::entities::Creatures>,
) -> f32 {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
return 1.0;
}
match (creatures, net.display_id) {
(Some(c), Some(display)) => c.display_base_alpha(display).unwrap_or(1.0),
_ => 1.0,
}
}
/// Re-resolve every unit's base alpha when its display changes — the reference's DISPLAYID
/// watcher path (`base-render-alpha.md` §5: `0x604990 → 0x60abe0 → 0x60afb0` caches the row →
/// `0x60ad9f → 0x60d180 → StartAlphaFade(target, 1000 ms)`), self-gated on a real record change
/// (`0x60ae10`). This is what makes a unit with `CreatureModelAlpha < 255` translucent with **no
/// aura in play** — the shifted shaman's ghost look — and what ramps it back to opaque when the
/// display swaps home. The 1000 ms cubic fade is part of the mechanism: a display swap always
/// eases to the new alpha, never snaps.
///
/// `Changed<NetEntity>` is our `0x60ae10`: [`crate::entities`]' live-display refresh writes
/// `net.display_id` only on an actual swap (and insertion covers the create path). The base
/// compare keeps a scale-only mutation from restarting anything.
pub(crate) fn refresh_base_alpha(
time: Res<Time>,
creatures: Option<Res<crate::entities::Creatures>>,
mut commands: Commands,
mut units: Query<
(Entity, &crate::net::NetEntity, Option<&mut AuraNodes>),
Changed<crate::net::NetEntity>,
>,
) {
let now = time.elapsed_secs();
for (entity, net, nodes) in &mut units {
let base = display_base_alpha(net, creatures.as_deref());
match nodes {
Some(mut n) => {
if (n.base - base).abs() > f32::EPSILON {
debug!(
"aura_visual: e={entity} display {:?} base alpha {:.2} -> {base:.2} (1 s ramp)",
net.display_id, n.base
);
n.base = base;
n.retarget(now);
}
}
// A unit that never carried a node set needs one exactly when its display is
// authored translucent — created settled at opaque so the retarget rides the same
// 1000 ms ramp down a fresh aura node would.
None if base < 1.0 => {
debug!(
"aura_visual: e={entity} display {:?} authors base alpha {base:.2} (1 s ramp)",
net.display_id
);
let mut n = AuraNodes::new(1.0);
n.base = base;
n.retarget(now);
commands.entity(entity).try_insert(n);
}
None => {}
}
}
1.0
}
/// The fadeable-part view [`apply_aura_alpha`] authors through: the part's material pair and tag,
+58 -17
View File
@@ -79,13 +79,15 @@ fn the_dispatch_names_only_the_verified_procs() {
}
}
// ── The product and the ramp ─────────────────────────────────────────────────────────────────────
// ── The target and the ramp ──────────────────────────────────────────────────────────────────────
/// `0x60d180`: the target is `baseAlpha × Π(node alphas)` — every live aura multiplies, and the
/// display's own authored opacity is the first term. Stacking stealth (0.3) with invisibility (0.5)
/// on a half-opaque display (0.5) is 0.075, not "whichever landed last".
/// `0x60d180`: the target is `baseAlpha × the HEAD node's factor` — at most one node term, the
/// newest (nodes link at the list head as they install), never a product over the chain (wow-re
/// `base-render-alpha.md` §4, correcting the earlier `Π` reading). Stacking stealth (0.3) then the
/// ghost aura (0.5) reads 0.5 — whichever landed last — and dropping the head hands the term to
/// the next node down.
#[test]
fn the_target_is_the_product_of_base_and_every_node() {
fn the_target_is_base_times_the_newest_node() {
let mut n = AuraNodes::new(1.0);
assert_eq!(n.target(), 1.0, "no nodes → opaque");
@@ -93,19 +95,51 @@ fn the_target_is_the_product_of_base_and_every_node() {
assert!((n.target() - 0.3).abs() < 1e-6);
install(&mut n, GHOST, &[AuraNode::Alpha(0.5)], 0.0);
assert!((n.target() - 0.15).abs() < 1e-6, "two nodes multiply");
assert!(
(n.target() - 0.5).abs() < 1e-6,
"the newest node is the term"
);
// A display authored translucent (CreatureModelAlpha 128/255) is the product's first term.
let mut half = AuraNodes::new(128.0 / 255.0);
// Reaping the head hands the term back to the older node.
n.alpha.retain(|(s, _)| *s != GHOST);
n.retarget(0.0);
assert!((n.target() - 0.3).abs() < 1e-6);
// A display authored translucent (CreatureModelAlpha 128/255) multiplies the head term —
// and stands alone when no node is live (the display-set leg, no aura anywhere).
let mut half = AuraNodes::new(1.0);
half.base = 128.0 / 255.0;
install(&mut half, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
assert!((half.target() - (128.0 / 255.0) * 0.3).abs() < 1e-6);
// Reaping one node leaves the other's factor, not opaque.
half.alpha.retain(|(s, _)| *s != STEALTH);
half.alpha.clear();
half.retarget(0.0);
assert!((half.target() - 128.0 / 255.0).abs() < 1e-6);
}
/// The display-swap leg ([`refresh_base_alpha`]'s retarget): a base change rides the same 1000 ms
/// cubic ramp an aura node does — Ghost Wolf's 102/255 eases in from opaque, and swapping home
/// eases back — never snaps (`base-render-alpha.md` §5: the fade is part of the mechanism).
#[test]
fn a_base_change_rides_the_same_ramp() {
let wolf = 102.0 / 255.0;
let mut n = AuraNodes::new(1.0);
n.base = wolf;
n.retarget(0.0);
assert!((n.tick(0.0) - 1.0).abs() < 1e-6, "starts where it was");
assert!(
(n.tick(AURA_ALPHA_FADE_SECS) - wolf).abs() < 1e-6,
"arrives"
);
assert!(n.translucent());
// The swap home: base back to 1.0, eased up from the live value.
n.base = 1.0;
n.retarget(AURA_ALPHA_FADE_SECS);
assert!((n.from - wolf).abs() < 1e-6);
assert!((n.tick(2.0 * AURA_ALPHA_FADE_SECS) - 1.0).abs() < 1e-6);
assert!(!n.translucent(), "settled opaque again");
}
/// A re-applied aura installs ONE node set, not a second copy — the reference keys its nodes by spell
/// id (`node+0x18`) and its own dedup walk returns early on a spell that already has one. Without
/// this a refresh would square the factor every application.
@@ -171,14 +205,17 @@ fn a_retarget_to_the_same_value_does_not_restart_the_ease() {
assert!((n.tick(10.0) - 0.3).abs() < 1e-6, "and stays arrived");
}
/// Proc 1's nodes are held head-node-first (`unit+0xce0`), so the tint the reference's per-frame
/// apply would read is deterministic — the layer is complete even though no renderer consumes it yet.
/// Proc 1's nodes link at the head like the alpha list's (`unit+0xce0`), so the tint the per-frame
/// apply reads is the NEWEST aura's — deterministic under stacking.
#[test]
fn the_tint_head_node_is_the_first_installed() {
fn the_tint_head_node_is_the_newest_installed() {
let mut n = AuraNodes::new(1.0);
assert_eq!(n.head_tint(), None);
install(&mut n, GHOST, &[AuraNode::Tint([0x8C, 0xB9, 0xFD])], 0.0);
install(&mut n, 12881, &[AuraNode::Tint([0x00, 0xFF, 0x00])], 0.0);
assert_eq!(n.head_tint(), Some([0x00, 0xFF, 0x00]));
// The newest dropping hands the head back.
n.tint.retain(|(s, _)| *s != 12881);
assert_eq!(n.head_tint(), Some([0x8C, 0xB9, 0xFD]));
assert_eq!(n.target(), 1.0, "a tint node is not an alpha term");
}
@@ -319,8 +356,8 @@ fn a_kit_with_both_procs_installs_both_nodes() {
assert_eq!(n.head_tint(), Some([0x8C, 0xB9, 0xFD]));
}
/// Two auras on one unit stack multiplicatively through the live edges, and reaping just one leaves
/// the other's translucency behind rather than snapping the body opaque.
/// Two auras on one unit: the newest node holds the head (the reference's link-at-head list), and
/// reaping the other leaves it untouched — the body never snaps opaque while any alpha aura lives.
#[test]
fn two_auras_stack_and_unstack_through_the_slots() {
let mut app = app_with_chains();
@@ -328,7 +365,10 @@ fn two_auras_stack_and_unstack_through_the_slots() {
let unit = app.world_mut().spawn(crate::net::ObjectStore(both)).id();
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert!((n.target() - 0.15).abs() < 1e-6, "0.3 × 0.5");
assert!(
(n.target() - 0.5).abs() < 1e-6,
"the ghost node installed last → it is the head term"
);
// Stealth alone drops (slot 0 cleared, slot 1 still occupied).
app.world_mut()
@@ -415,6 +455,7 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
cutout: cutout.clone(),
blend: blend.clone(),
bake_blend: None,
zfill: None,
};
// root → body part, and root → joint → held weapon (the depth the walk must reach).
let mut nodes = AuraNodes::new(1.0);
+37 -7
View File
@@ -973,16 +973,29 @@ fn kind_index(kind: crate::debug_panel::ModelKind) -> usize {
/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at
/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already
/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live.
///
/// **`WOW_PARTICLE_CENSUS=+<secs>` fires that long after the world is first SHOWN** (the loading
/// screen dropping) instead of after app start. Everything that rides the appear ramp — decision
/// 0827/0833's `alpha` column above all — lives in a 2-second window whose start moves by *seconds*
/// between a warm and a cold load, and a wall-clock timer either lands in it or does not: three
/// runs in a row missed it while the question was "does a weapon glow ramp with its wearer?". A
/// probe should not be a dice roll, and the ramp's own trigger is the thing to time from.
pub(crate) struct ParticleCensusPlugin;
impl Plugin for ParticleCensusPlugin {
fn build(&self, app: &mut App) {
let at = std::env::var("WOW_PARTICLE_CENSUS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
app.insert_resource(ParticleCensus { at, fired: false })
.add_systems(Update, fire_particle_census);
let spec = std::env::var("WOW_PARTICLE_CENSUS").unwrap_or_default();
let after_shown = spec.starts_with('+');
let at = spec
.trim_start_matches('+')
.parse()
.unwrap_or(if after_shown { 1.0 } else { 10.0 });
app.insert_resource(ParticleCensus {
at,
after_shown,
fired: false,
})
.add_systems(Update, fire_particle_census);
}
}
@@ -990,11 +1003,15 @@ impl Plugin for ParticleCensusPlugin {
#[derive(Resource)]
struct ParticleCensus {
at: f32,
/// `at` is measured from the world being shown, not from app start — and is rewritten into an
/// absolute time the first frame the loading screen stops covering.
after_shown: bool,
fired: bool,
}
fn fire_particle_census(
mut probe: ResMut<ParticleCensus>,
screen: Res<crate::loading_screen::LoadingScreen>,
time: ProbeClock,
view: Res<crate::view::ViewDistance>,
cam: Query<&GlobalTransform, With<crate::player::WorldCamera>>,
@@ -1004,6 +1021,15 @@ fn fire_particle_census(
Option<&bevy::camera::visibility::RenderLayers>,
)>,
) {
// Latch the shown-relative deadline the first frame the screen drops (and only then: while it
// still covers there is no ramp to be relative to).
if probe.after_shown {
if screen.covering() {
return;
}
probe.at += time.elapsed_secs();
probe.after_shown = false;
}
if probe.fired || time.elapsed_secs() < probe.at {
return;
}
@@ -1101,13 +1127,17 @@ fn fire_particle_census(
})
.unwrap_or_default();
println!(
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={}{dist}{plane}",
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={} alpha={:.2}{dist}{plane}",
d.blend,
d.flags,
rate_keys.join(","),
d.lifespan,
d.texture.as_deref().unwrap_or("-"),
e.live(),
// The frame's composed MODEL alpha (decision 0827/0833) — the number that answers
// "this cloud is drawing, why can't I see it / why is it full strength?". An effect on
// a unit that has not appeared yet reads ~0; one with no model above it reads 1.
e.render_alpha(),
);
total += e.live();
n += 1;
+12
View File
@@ -137,6 +137,18 @@ pub(crate) struct AnimHostSpawn {
pub anim_id: Option<u16>,
}
/// Whether [`spawn_anim_host`] would rig this model — the model-forms requester's twin of the
/// spawn gate (decision 0834), so the skinned twins are built exactly for the placed models that
/// will draw them. Mirrors both of the host's gates: the tier, and the capture freeze (captures
/// keep every doodad static, so they need no twins either).
pub(crate) fn wants_rig(m: &M2Model) -> bool {
!crate::capture::scenario_active()
&& !matches!(
classify(&m.skeleton, m.animations.as_ref()),
DoodadAnimTier::Static
)
}
/// Spawn the animation host for one placed M2, if [`classify`] says it animates: an anim-root entity
/// at the placement transform, the joint hierarchy as its children (so despawning the root cascades),
/// and — per tier — an `AnimationPlayer` looping the **loader-idle seed's** clip (the client's
+19 -5
View File
@@ -57,7 +57,7 @@ use carried_light::spawn_carried_lights;
/// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as
/// children of the body's attach-point joints.
mod equipment;
use equipment::{attach_held_items, refresh_player_looks, resolve_equipment};
use equipment::{attach_held_items, resolve_equipment};
/// Item / enchant glow effects (decision 0805): the `Spells\Enchantments\*.mdx` models an item's
/// `ItemVisuals` id hangs on the item's OWN attachment points — the permanent weapon glows and
@@ -455,9 +455,10 @@ impl Plugin for EntitiesPlugin {
// Fire each live instance's crossed event keyframes (decision 0304) — after
// attach so a just-spawned instance's head window [0, cur] fires this frame.
spell_fx::fire_fx_anim_events,
// A gear change tears the visual down; attach rebuilds it next frame with the
// new composite (fade-skipped — a shirt swap isn't a spawn).
refresh_player_looks,
// A gear change re-dresses the standing visual in place — a re-composited atlas
// on the same parts, the equipment geosets re-selected, every attachment left
// alone (decision 0835, the reference's own shape).
attach::redress_player_looks,
// A mount transition does the same (decision 0441): the field diff tears the
// visual down, attach rebuilds it mounted (or dismounted) next frame(s).
refresh_mounts,
@@ -505,6 +506,10 @@ impl Plugin for EntitiesPlugin {
.add_systems(
Update,
(
// The display's own base alpha first (the reference's DISPLAYID-watcher leg
// of the same recompute, `base-render-alpha.md` §5 — no aura needed), so a
// same-frame aura edge retargets from the already-updated base.
crate::aura_visual::refresh_base_alpha,
crate::aura_visual::drain_aura_procs,
// The tint publish only needs the drain ahead of it (it writes a resource, not
// the alpha channel), so it rides the same chain rather than earning its own.
@@ -542,7 +547,10 @@ impl Plugin for EntitiesPlugin {
.add_systems(
PostUpdate,
crate::model_fade::publish_model_alpha.before(crate::billboard::BillboardPlace),
);
)
// The depth-prime twins (decision 0831 — the reference's `M2UseZFill`): PostUpdate too,
// after the same Update-side tag writers, so a twin arms on its episode's first frame.
.add_systems(PostUpdate, crate::zfill::sync_zfill_twins);
}
}
@@ -688,6 +696,7 @@ fn update_display_models(
mut spell_fx: Option<ResMut<spell_fx::SpellFx>>,
mut glows: Option<ResMut<ItemGlows>>,
model_assets: (Res<Assets<M2Model>>, Res<Assets<WmoModel>>),
mut forms: ResMut<crate::model_forms::ModelForms>,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
@@ -738,6 +747,7 @@ fn update_display_models(
dm,
m2s,
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
@@ -761,6 +771,7 @@ fn update_display_models(
dm,
m2s,
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
@@ -783,6 +794,7 @@ fn update_display_models(
dm,
m2s,
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
@@ -802,6 +814,7 @@ fn update_display_models(
dm,
m2s,
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
@@ -821,6 +834,7 @@ fn update_display_models(
dm,
m2s,
wmos,
&mut forms,
&asset_server,
&mut materials,
cache,
@@ -199,14 +199,15 @@ pub(in crate::entities) fn equip_geosets(
}
/// One per-appearance material set for a character runtime texture slot: (steady, interior-matte,
/// appear-fade-blend, interior-bake, interior-bake-blend). `model_material` dedups by
/// texture+blend, so all players of one look share them.
/// appear-fade-blend, interior-bake, interior-bake-blend, depth-prime twin). `model_material` /
/// `zfill_material` dedup by texture+blend, so all players of one look share them.
pub(super) type MatQuint = (
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
);
/// The full character material set [`build_char_skin_materials`] returns: `(body, hair, object,
@@ -233,6 +234,17 @@ fn build_char_materials(
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
) -> MatQuint {
// The depth-prime twin (decision 0831): body/hair batches are opaque/alpha-cut and always
// z-writing, so every character part twins; `cutout` mirrors the colour twin's 224/255
// discard exactly as the model-built parts' twins do. Built before `mk` captures the caches.
let zfill = crate::model_render::zfill_material(
cache,
materials,
Some(tex.clone()),
two_sided,
blend != ModelBlend::Blend,
light,
);
let mut mk = |blend, shade: ShadeSel, fade, probe| {
model_material(
cache,
@@ -273,6 +285,7 @@ fn build_char_materials(
mk(ModelBlend::Blend, ShadeSel::Lit, true, false),
mk(blend, ShadeSel::Matte, false, true),
mk(ModelBlend::Blend, ShadeSel::Matte, true, true),
zfill,
)
}
+550
View File
@@ -0,0 +1,550 @@
//! **Dressing** a unit's model parts — the per-batch spawn [`super::attach_entity_visuals`] runs
//! once per visual, factored out so the **re-dress** ([`super::redress`]) can run the identical law
//! over the parts a unit is already standing in.
//!
//! The reference never rebuilds a character to change what it is wearing: the composite atlas is
//! re-blitted into the component's own 256² target and the geosets are re-selected on the SAME
//! `CM2Model` (its per-instance visibility array `+0x98`, whose only writer is the character
//! compositor — wow-re `charactermodel.md` "Assembly orchestration" + `models.md`
//! §"geoset-visibility-default"). Its attachments — held items, the helm, the shoulders and
//! everything hanging off them — are never touched by that path at all. This module is the half of
//! that law that concerns one part: which materials it draws through, and how it is born.
use benilla_formats::CharSkinSlot;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::billboard::BillboardCard;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interact::WorldObject;
use crate::interior::part_interior_lit;
use crate::model_fade::{
fade_alpha, FadeMaterials, JoinedFade, PendingAppearFade, RenderFade, APPEAR_FADE_SECS,
};
use crate::terrain::WowModelMaterial;
use super::super::EntityPart;
use super::char_skin::CharSkinMaterials;
/// The display-model batch a spawned part child was built from — its index into
/// [`DisplayModel::parts`](super::super::DisplayModel), plus the world-root billboard card that
/// belongs to it when the batch is a camera-facing one (decision 0153: the card is a ROOT entity,
/// so it does not cascade with its anchor's despawn and must be named here).
///
/// This is the **re-dress edge** ([`super::redress`]): a gear change re-selects the geoset set and
/// re-composites the atlas, and each standing part is found again through this index — its geoset
/// id for "does it still show?", its char slot for "which atlas does it draw?". The index is stable
/// for as long as the visual lives, because the only thing that can rebuild `parts` is the display
/// itself changing, which is a teardown (`live_display::refresh_live_display`).
#[derive(Component, Clone, Copy)]
pub(in crate::entities) struct DressedPart {
pub(in crate::entities) index: u32,
pub(in crate::entities) card: Option<Entity>,
}
/// The material variants one part draws through, after the per-appearance character swap: the
/// steady exterior material, its interior-matte / interior-bake law twins, and the `Blend` twins the
/// appear/despawn ramps feather on.
///
/// One function answers it for both the spawn and the re-dress, which is the point: "which material
/// does this batch wear?" has exactly one implementation, so a gear change can never dress a body
/// differently from the way it was first built.
pub(super) struct PartMaterials<'a> {
pub(super) steady: &'a Handle<WowModelMaterial>,
pub(super) interior: Option<&'a Handle<WowModelMaterial>>,
pub(super) fade_blend: Option<&'a Handle<WowModelMaterial>>,
pub(super) bake: Option<&'a Handle<WowModelMaterial>>,
pub(super) bake_blend: Option<&'a Handle<WowModelMaterial>>,
/// The depth-prime twin (decision 0831) — carried per appearance like the rest, since its
/// cutout discard samples the same swapped texture.
pub(super) zfill: Option<&'a Handle<WowModelMaterial>>,
}
/// Select `part`'s materials: on a character-slot batch the per-appearance variants over the
/// composited body atlas / hair / cape / extra-skin textures (decisions 0041 / 0044 / 0045) — the
/// body and the extra skin at the batch's own sidedness, since a robe skirt is authored two-sided
/// and the closed body is not — and on every other batch the ones the shared model was built with.
pub(super) fn part_materials<'a>(
part: &'a EntityPart,
char_mats: &'a CharSkinMaterials,
) -> PartMaterials<'a> {
let slot_mats = match part.char_slot {
Some(CharSkinSlot::Body) => {
char_mats
.0
.as_ref()
.map(|(single, two)| if part.two_sided { two } else { single })
}
Some(CharSkinSlot::Hair) => char_mats.1.as_ref(),
Some(CharSkinSlot::Object) => char_mats.2.as_ref(),
Some(CharSkinSlot::SkinExtra) => {
let (single, two) = &char_mats.3;
if part.two_sided { two } else { single }.as_ref()
}
None => None,
};
match slot_mats {
Some((ext, int, fade, bake, bake_blend, zfill)) => PartMaterials {
steady: ext,
interior: Some(int),
fade_blend: Some(fade),
bake: Some(bake),
bake_blend: Some(bake_blend),
zfill: Some(zfill),
},
None => PartMaterials {
steady: &part.material,
interior: part.material_interior.as_ref(),
fade_blend: part.fade_blend.as_ref(),
bake: part.material_interior_bake.as_ref(),
bake_blend: part.material_interior_bake_blend.as_ref(),
zfill: part.zfill.as_ref(),
},
}
}
/// Everything one part's spawn reads from its UNIT — gathered once per visual so the per-part body
/// isn't a twenty-argument call, and so the spawn path and the re-dress path provably feed it the
/// same things.
pub(super) struct PartDress<'a> {
/// The unit whose model this is: the parent, the interior classifier's anchor, the animation
/// clock every `MatAnim` follows.
pub(super) unit: Entity,
pub(super) kind: ModelKind,
pub(super) char_mats: &'a CharSkinMaterials,
/// Identity for the mouseover inspector, cloned onto every part and card.
pub(super) object: &'a WorldObject,
/// The unit's rig-palette slot in `MeshTag` bits — a per-INSTANCE identity every part carries,
/// skinned or not, so a tinted unit tints whole (decision 0812).
pub(super) rig_tag: u32,
/// `0` = the palette table was full: parts fall back to the static bind-pose mesh.
pub(super) inst_slot: u16,
/// Whether the unit built a rig at all — the gate on a part drawing its skinned twin.
pub(super) rigged: bool,
/// Bone index → anchor entity, for a billboard batch's card to ride its live joint.
pub(super) anchors: &'a std::collections::HashMap<u16, Entity>,
/// The model's interior fold reference (model-local) — one verdict per unit, so a body can
/// never split across the two light laws.
pub(super) bake_center: Vec3,
/// The armed idle's authored CAaBox — the mouseover picker's volume for a skinned part.
pub(super) idle_aabb: Option<Aabb>,
/// `Time::elapsed_secs`, for a part joining a ramp already in progress.
pub(super) now: f32,
/// What this part should do about the unit's appear-fade. A fresh visual passes
/// `Pending { since: now }` (decision 0032); a part spawned by a **re-dress** JOINS whatever the
/// unit's own clock says ([`crate::model_fade::join_unit_appear_fade`]), exactly as a
/// late-resolving held item does — so a gear change during the login cascade neither pops the
/// new geoset opaque over a still-feathering body nor restarts a second ramp beside it.
pub(super) fade: JoinedFade,
}
/// One part's resolved appear-fade for this spawn: [`JoinedFade`] folded with whether the part is
/// fade-capable at all (a WMO-display batch has no blend twin).
#[derive(Clone, Copy)]
enum PartFade {
Steady,
Pending(f32),
Live(f32),
}
/// Spawn one part of `dress.unit`'s model as a child of it, dressed by [`part_materials`]. Returns
/// whether it armed an appear-fade — the attach path mirrors that onto the unit root so a held item
/// resolving later can join the same ramp.
///
/// A **billboard** batch (a glow card / chain) can't spawn as an ordinary child: its mesh is centred
/// at the bone pivot and its transform belongs to the billboard system, so as a plain child it would
/// render at the model origin (the "glow on the ground" family, decision 0153). It spawns as a
/// lightweight mirror anchor under the unit plus a world-root card following the anchor's live joint.
pub(super) fn spawn_part(
commands: &mut Commands,
part: &EntityPart,
index: usize,
dress: &PartDress,
) -> bool {
if let Some(info) = &part.billboard {
spawn_billboard_part(commands, part, index, info, dress);
return false;
}
let mats = part_materials(part, dress.char_mats);
// Per-part join decision: the unit's clock combined with whether this part is fade-capable.
let effective = match (dress.fade, mats.fade_blend) {
(_, None) => PartFade::Steady,
(JoinedFade::Steady, _) => PartFade::Steady,
(JoinedFade::Pending { since }, _) => PartFade::Pending(since),
(JoinedFade::Live { started }, _) => PartFade::Live(started),
};
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the blend twin
// with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before `apply_render_fade` ramps
// `α = t³`. A joiner computes the ramp's *current* alpha rather than 0, so it doesn't flash
// invisible for a frame either.
let (init_mat, tag_alpha) = match effective {
PartFade::Steady => (mats.steady.clone(), 1.0),
PartFade::Pending(_) => (mats.fade_blend.cloned().unwrap_or_default(), 0.0),
PartFade::Live(started) => (
mats.fade_blend.cloned().unwrap_or_default(),
fade_alpha(0.0, 1.0, (dress.now - started) / APPEAR_FADE_SECS),
),
};
// A skinned creature part draws its skinned-mesh twin (the WOW joint attributes → the
// owned-palette `WOW_RIG_SKIN` shader path, decision 0720); everything else — and the
// palette-full fallback (slot 0) — the static mesh. Keyed on the part having a twin, which the
// slot alone no longer implies.
let skinned = dress.inst_slot != 0 && part.skinned_mesh.is_some();
let mesh = match &part.skinned_mesh {
Some(sm) if skinned => sm.clone(),
_ => part.mesh.clone(),
};
let mut child = commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(init_mat),
Transform::default(),
ChildOf(dress.unit),
ModelPart {
kind: dress.kind,
blend: part.blend,
},
// The portrait booth mirrors this part ([`crate::portrait`]): both mesh twins — the booth
// poses the skinned twin at Stand on its own throwaway skeleton (the ref bake, wow-re §4
// D2), falling back to the static bind-pose twin for a boneless model — + the steady
// exterior material (not the appear-fade/interior variant the child may wear now).
crate::portrait::PortraitPart {
static_mesh: part.mesh.clone(),
skinned_mesh: part.skinned_mesh.clone(),
material: mats.steady.clone(),
},
dress.object.clone(),
DressedPart {
index: index as u32,
card: None,
},
));
// Every part gets a tag: the instance slot plus the live alpha field. Unconditional because the
// slot is a per-instance identity every part needs (the tint) rather than a skinning detail —
// and because the two conditional writers that used to seed it (the interior classifier, the
// animated-alpha compose) each covered only their own subset, leaving a part in neither with no
// tag at all.
child.insert(MeshTag(
dress.rig_tag | crate::mesh_tag::alpha_bits(tag_alpha),
));
// `RigPart` stays gated on actually being skinned by that rig — it is the CPU-side link for the
// mouseover picker's skinned ray test (decision 0720), which has nothing to say about a static
// part.
if skinned {
child.insert(crate::rig_palette::RigPart(dress.unit));
}
if dress.rigged && part.skinned_mesh.is_some() {
// A streamed entity's M2 is **never view-culled** — the reference registers entity render
// records with effectively-infinite bounds and the one frustum cull in its machinery is
// map-doodad-only (wow-re `unit-anim-visibility-gate.md` §2/§4). Bevy's bind-pose `Aabb` is
// the wrong stand-in (the duel flag plants itself 9 yd below it) and is stomped anyway by
// `calculate_bounds` on any `Mesh3d` change. So the renderer gets the faithful
// `NoFrustumCulling`, and the `Aabb` beside it serves ONE master: the mouseover picker
// (`target/hover.rs`) — the armed idle's authored CAaBox when it has one, else the bind
// box, read from the part's build-time bound (decision 0834: the static mesh is
// `RENDER_WORLD`-only, so its main-world data — which the old `compute_aabb` fallback
// read — is gone after extract). Both `calculate_bounds` queries skip `NoFrustumCulling`
// entities, so this box survives.
let picker_aabb = dress.idle_aabb.or(part.aabb);
if let Some(aabb) = picker_aabb {
child.insert((aabb, NoFrustumCulling));
}
} else if let Some(aabb) = part.aabb {
// A STATIC part (a boneless GameObject model, a WMO-display batch) keeps Bevy's ordinary
// frustum cull — but its bind-pose box must be inserted here now (decision 0834):
// `calculate_bounds` used to derive it from the mesh's main-world data, which the
// `RENDER_WORLD`-only static form no longer has after extract.
child.insert(aabb);
}
// M2 parts can light off a WMO room they stand in: a `MeshTag` + the classifier pick the law by
// location (0354). Anchored at the unit root so every part shares the root's verdict, and the
// indoor LAW is one for every entity M2 — the footprint-MOCV bake (wow-re
// `unit-m2-shader-light.md`), with the matte ×1.0 as the bake's miss fallback.
if let Some(lit) = part_interior_lit(
mats.steady,
mats.interior,
mats.bake,
dress.bake_center,
dress.unit,
) {
child.insert(lit);
}
// The batch's **animated material alpha** (the verified combine's runtime half, wow-re
// `m2-alpha-combine-cull.md`): a creature's colour-alpha/transparency tracks are authored PER
// SEQUENCE, so which of its batches draw is a function of what it is playing. Sampling follows
// the unit's own `AnimationPlayer`, so the alpha stays in phase with the pose.
if let Some(anim) = &part.alpha_anim {
child.insert(crate::doodad_anim::MatAnim::following(
anim.clone(),
dress.unit,
));
}
// `FadeMaterials` is persistent bookkeeping — the self-avatar zoom feather and decision 0032's
// despawn fade-out both re-arm from it — not a record of whether *this* spawn happened to join
// an in-flight ramp. Attach it whenever the part is fade-capable at all, steady or not; the item
// lane (`equipment::spawn`) has always done it this way, and gating it on the arm is what left a
// player who had ever swapped a shirt unable to fade out on stream-out.
if let Some(blend) = mats.fade_blend {
child.insert(FadeMaterials {
cutout: mats.steady.clone(),
blend: blend.clone(),
bake_blend: mats.bake_blend.cloned(),
zfill: mats.zfill.cloned(),
});
}
match effective {
PartFade::Steady => false,
PartFade::Pending(since) => {
// `arm_appear_fade` starts the ramp once the world is on-screen (not behind the loading
// screen), so it plays where the player can see it.
child.insert(PendingAppearFade { since });
true
}
PartFade::Live(started) => {
child.insert(RenderFade {
started,
duration: APPEAR_FADE_SECS,
from: 0.0,
to: 1.0,
});
true
}
}
}
/// The camera-facing half of [`spawn_part`]: a mirror anchor under the unit (so the portrait /
/// paper-doll booths can rebuild the batch — the visible card is a world ROOT and can never be
/// mirrored) plus the card itself, following the batch's live joint when the unit is rigged and the
/// anchor otherwise.
fn spawn_billboard_part(
commands: &mut Commands,
part: &EntityPart,
index: usize,
info: &benilla_assets::BillboardInfo,
dress: &PartDress,
) {
let anchor = commands
.spawn((
Transform::default(),
Visibility::default(),
ChildOf(dress.unit),
crate::portrait::PortraitBillboard {
mesh: part.mesh.clone(),
material: part.material.clone(),
bone: info.bone,
// A rigged host's billboard bone already bakes the pivot into its booth joint (the
// 0130 rig identity) — and the batch belongs to the host's body, so a mount's own
// glow card prunes with the mount (`DressedLook::collect`).
seat: crate::portrait::PortraitSeat::Body,
kind: info.kind,
},
))
.id();
let (owner, at_joint) = match dress.anchors.get(&info.bone).copied() {
Some(j) => (j, true),
None => (anchor, false),
};
let card_follow = if at_joint {
BillboardCard::following_joint(info, owner)
} else {
BillboardCard::following(info, owner)
};
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
Transform::default(),
ModelPart {
kind: dress.kind,
blend: part.blend,
},
dress.object.clone(),
card_follow,
));
// A card takes its MODEL's indoor law through the same constructor as the sibling meshes it was
// split out of (decision 0778), and carries the unit's INSTANCE slot like every sibling part
// even though it is never skinned by it — it is a batch of the unit's own model, so a tinted
// unit tints its eye-glow and torch cards too (decision 0812). No char-slot variants are
// consulted: a body/hair/cape/skin-extra batch is never a billboard batch.
card.insert(MeshTag(dress.rig_tag | crate::mesh_tag::alpha_bits(1.0)));
// The card's build-time bound (decision 0834) — `calculate_bounds` can no longer derive one
// from the `RENDER_WORLD`-only static form's data.
if let Some(aabb) = part.aabb {
card.insert(aabb);
}
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
dress.bake_center,
dress.unit,
) {
card.insert(lit);
}
// A card shares its batch's per-sequence alpha loops (the billboard split copies them onto every
// group), sampled off the same unit clock as the mesh parts.
if let Some(anim) = &part.alpha_anim {
card.insert(crate::doodad_anim::MatAnim::following(
anim.clone(),
dress.unit,
));
}
let card = card.id();
// The card follows the JOINT when the unit is rigged, so it does not cascade with the anchor's
// despawn — the re-dress has to name it to reap it (see [`DressedPart::card`]).
commands.entity(anchor).insert(DressedPart {
index: index as u32,
card: Some(card),
});
}
#[cfg(test)]
mod tests {
use super::*;
/// One synthetic batch at `char_slot`/`two_sided`, carrying its own (distinguishable) built
/// material set so the fallback arm is visible in the assertions.
fn part(char_slot: Option<CharSkinSlot>, two_sided: bool) -> EntityPart {
EntityPart {
mesh: Handle::default(),
aabb: None,
skinned_mesh: None,
material: mat(0),
material_interior: None,
material_interior_bake: None,
material_interior_bake_blend: None,
fade_blend: None,
zfill: None,
blend: benilla_formats::ModelBlend::Opaque,
additive: false,
two_sided,
geoset_id: 0,
char_slot,
billboard: None,
alpha_anim: None,
rgb_anim: None,
ground_quad: None,
}
}
/// A distinguishable material handle — a stable-id (`Uuid`) handle, which needs no `Assets`
/// store behind it and compares by identity like any other.
fn mat(n: u128) -> Handle<WowModelMaterial> {
Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(n + 1),
std::marker::PhantomData,
)
}
/// A set of six distinguishable handles, so which variant landed where is checkable.
fn quint(seed: u128) -> super::super::char_skin::MatQuint {
(
mat(seed * 8),
mat(seed * 8 + 1),
mat(seed * 8 + 2),
mat(seed * 8 + 3),
mat(seed * 8 + 4),
mat(seed * 8 + 5),
)
}
/// The character swap picks a slot's quint — and the BODY (and extra skin) pick by the batch's
/// own sidedness, because a robe skirt (geoset 1302) is authored two-sided while the closed body
/// is not; flattening them to one variant culled the robe's inner faces (see-through from below).
#[test]
fn a_character_batch_takes_its_slots_variants_at_its_own_sidedness() {
let single = quint(1);
let two = quint(2);
let hair = quint(3);
let mats: CharSkinMaterials = (
Some((single.clone(), two.clone())),
Some(hair.clone()),
None,
(None, None),
);
let body_single = part(Some(CharSkinSlot::Body), false);
assert_eq!(*part_materials(&body_single, &mats).steady, single.0);
let body_two = part(Some(CharSkinSlot::Body), true);
assert_eq!(*part_materials(&body_two, &mats).steady, two.0);
let hair_part = part(Some(CharSkinSlot::Hair), false);
let m = part_materials(&hair_part, &mats);
assert_eq!(*m.steady, hair.0);
assert_eq!(m.interior, Some(&hair.1));
assert_eq!(m.fade_blend, Some(&hair.2));
assert_eq!(m.bake, Some(&hair.3));
assert_eq!(m.bake_blend, Some(&hair.4));
}
/// **`FadeMaterials` is a material RECORD, not a record of having armed a fade.** It used to be
/// inserted only alongside the appear-fade arm, so a part spawned by anything that isn't a spawn
/// — which, before decision 0835, meant a body rebuilt on *every gear change* — carried none at
/// all. Two things read it and both silently stopped working for a player who had swapped so
/// much as a shirt: the stream-out fade (`arm_despawn_descendants` gates on it) and, worse, the
/// self-avatar first-person feather, whose query takes `&FadeMaterials` outright — so your own
/// body stayed solid in your face. The item lane always did it this way; the body lane does now.
#[test]
fn a_steady_spawn_still_records_its_fade_materials() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Mesh>();
let unit = app.world_mut().spawn(Transform::default()).id();
let mut fadeable = part(None, false);
fadeable.fade_blend = Some(mat(99));
let anchors = std::collections::HashMap::new();
let object = WorldObject {
kind: ModelKind::Creature,
label: String::new(),
id: 0,
detail: String::new(),
};
let empty: CharSkinMaterials = (None, None, None, (None, None));
let dress = PartDress {
unit,
kind: ModelKind::Creature,
char_mats: &empty,
object: &object,
rig_tag: 0,
inst_slot: 0,
rigged: false,
anchors: &anchors,
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
fade: JoinedFade::Steady,
};
let mut queue = bevy::ecs::world::CommandQueue::default();
let armed = {
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &fadeable, 0, &dress)
};
queue.apply(app.world_mut());
assert!(!armed, "steady: no ramp armed");
let mut q = app
.world_mut()
.query::<(&FadeMaterials, Has<PendingAppearFade>)>();
let found: Vec<_> = q
.iter(app.world())
.map(|(fm, p)| (fm.blend.clone(), p))
.collect();
assert_eq!(found.len(), 1, "the record is kept even with no ramp");
assert_eq!(found[0].0, mat(99), "…and names the part's own blend twin");
assert!(!found[0].1, "…without arming an appear fade");
}
/// A batch with no character slot — and a slot whose row the appearance didn't resolve (a bald
/// style has no hair texture, only tauren author an extra skin) — keeps the shared model's own
/// built materials. That is the arm a creature, a GameObject and an unresolvable look all take.
#[test]
fn an_unswapped_batch_keeps_the_models_own_materials() {
let empty: CharSkinMaterials = (None, None, None, (None, None));
let plain = part(None, false);
assert_eq!(*part_materials(&plain, &empty).steady, plain.material);
// A slot the look didn't fill falls to the same place, not to another slot's quint.
let mats: CharSkinMaterials = (Some((quint(1), quint(2))), None, None, (None, None));
let bald = part(Some(CharSkinSlot::Hair), false);
assert_eq!(*part_materials(&bald, &mats).steady, bald.material);
}
}
+48 -287
View File
@@ -9,23 +9,17 @@
use avian3d::prelude::RigidBody;
use benilla_assets::ModelSkeleton;
use benilla_formats::CharSkinSlot;
use benilla_protocol::EntityKind;
use bevy::camera::primitives::MeshAabb;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::billboard::BillboardCard;
use bevy::animation::transition::AnimationTransitions;
use crate::creature_anim::AnimDriver;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::debug_panel::ModelKind;
use crate::interact::WorldObject;
use crate::interior::part_interior_lit;
use crate::lighting::SharedLightBuffer;
use crate::model_fade::{FadeMaterials, PendingAppearFade};
use crate::model_fade::JoinedFade;
use crate::net::{NetEntity, ObjectStore};
use crate::particles;
use crate::player::CameraPivot;
@@ -39,8 +33,12 @@ use super::{
mod char_skin;
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
mod dress;
use dress::{spawn_part, PartDress};
mod glue_preview;
pub(super) use glue_preview::build_glue_preview;
mod redress;
pub(super) use redress::redress_player_looks;
/// Set up the skinned + animated instance shared by creatures/players (decision 0019) and animated
/// GameObjects (decision 0242): the per-instance joints, the billboard glow rig, and — when the model
@@ -277,7 +275,6 @@ pub(super) fn attach_entity_visuals(
ResMut<Assets<WowModelMaterial>>,
ResMut<EntityMaterials>,
),
meshes: Res<Assets<Mesh>>,
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
time: Res<Time>,
@@ -670,9 +667,6 @@ pub(super) fn attach_entity_visuals(
// instance's identity rather than a skinning detail (decision 0812).
let inst_slot = skin.as_ref().map_or(0, |rb| rb.slot);
let rig_tag = crate::mesh_tag::rig_bits(inst_slot);
// Billboard batches (the brazier/lantern glow card) collected for the world-root card
// spawn below — inside the loop we only plant their anchor child.
let mut billboard_parts = Vec::new();
// The armed idle's **authored** CAaBox (decision 0637) — the mouseover picker's
// volume for a skinned part, NOT a culling volume (skinned entity parts are never
// frustum-culled; see the `NoFrustumCulling` note at the insert below). The bind-pose
@@ -688,284 +682,51 @@ pub(super) fn attach_entity_visuals(
bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max)
})
});
commands.entity(entity).with_children(|parent| {
for part in parts {
// Skip a geoset this character doesn't show (an unselected hair/facial/body variant).
if visible_geosets
.as_ref()
.is_some_and(|vis| !vis.contains(&part.geoset_id))
{
continue;
}
// A billboard batch (glow card / chain) can't spawn as an ordinary child: its
// mesh is centred at the bone pivot and its transform belongs to the billboard
// system — as a plain child it renders at the model ORIGIN (the brazier glow on
// the ground, decision 0153). A skinned host's card rides its billboard bone's
// live JOINT (the mount's lights follow the gait — the joint frame bakes the
// pivot, 0130 rig identity); a rest-pose host (GameObject, boneless) gets an
// empty ANCHOR child at the root (lifecycle matches the sibling meshes exactly).
// The world-root card FOLLOWING it spawns below.
if let Some(info) = &part.billboard {
let joint = skin
.as_ref()
.and_then(|rb| rb.anchors.get(&info.bone).copied());
// A mirror carrier under the unit for the eye-glow: the portrait / paper-doll
// booths mirror the unit's dressed DESCENDANTS, but the visible world card
// spawned below is a ROOT entity — never a descendant — so it can't be
// mirrored. This lightweight anchor rides the unit's tree tagged with the
// glow's quad/material/bone so those booths rebuild it as a booth card
// (`crate::portrait::PortraitBillboard`). It doubles as the boneless host's
// card follow-anchor; a skinned host follows its live joint, leaving the
// anchor mirror-only.
let anchor = parent
.spawn((
Transform::default(),
Visibility::default(),
crate::portrait::PortraitBillboard {
mesh: part.mesh.clone(),
material: part.material.clone(),
bone: info.bone,
// This host IS rigged, so its billboard bone's booth joint
// already bakes the pivot (the 0130 rig identity) — and the
// batch belongs to the host's body, so a mount's own glow card
// prunes with the mount (`DressedLook::collect`).
seat: crate::portrait::PortraitSeat::Body,
kind: info.kind,
},
))
.id();
let (owner, at_joint) = match joint {
Some(j) => (j, true),
None => (anchor, false),
};
billboard_parts.push((info.clone(), part, owner, at_joint));
continue;
}
// On a player's character-slot part, swap in the per-appearance material variants
// (steady / interior-matte / fade / interior-bake): the body atlas for a body
// batch — at the batch's own sidedness (the robe skirt is two-sided; the closed
// body isn't) — the hair texture for a hair batch, the worn cape for an object
// (type 2) batch, the extra-skin fur (per flavor: opaque core / alpha-cut fringe)
// for a type-8 batch; every other part keeps its built ones.
let slot_mats =
match part.char_slot {
Some(CharSkinSlot::Body) => char_mats
.0
.as_ref()
.map(|(single, two)| if part.two_sided { two } else { single }),
Some(CharSkinSlot::Hair) => char_mats.1.as_ref(),
Some(CharSkinSlot::Object) => char_mats.2.as_ref(),
Some(CharSkinSlot::SkinExtra) => {
let (single, two) = &char_mats.3;
if part.two_sided { two } else { single }.as_ref()
}
None => None,
};
let (mat, mat_interior, fade_blend, mat_bake, mat_bake_blend) = match slot_mats
{
Some((ext, int, fade, bake, bake_blend)) => {
(ext, Some(int), Some(fade), Some(bake), Some(bake_blend))
}
None => (
&part.material,
part.material_interior.as_ref(),
part.fade_blend.as_ref(),
part.material_interior_bake.as_ref(),
part.material_interior_bake_blend.as_ref(),
),
};
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the
// blend twin with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before
// `apply_render_fade` ramps `α = t³`. WMO-display parts (no `fade_blend`) spawn steady.
let (init_mat, fading) = match fade_blend {
Some(b) => (b.clone(), true),
None => (mat.clone(), false),
};
// The tag's slot field identifies the **instance**, not this part's skinning: it
// is the UNIT's slot on every part, even a boneless geoset. The vertex stage only
// reads it under `WOW_RIG_SKIN`, which `WowModelExt::specialize` keys on the
// mesh's own joint attributes — so a static mesh carrying a slot is not skinned
// by it — while the fragment stage reads it for the per-instance body tint
// (decision 0812). Giving it to every part is what makes a tinted unit tint
// *whole*, which is the reference's own rule: an attached/chained model inherits
// the parent CM2's computed colours (`0x714000` recursion). Hoisted above the
// loop — it is a property of the unit, not of the part.
//
// A skinned creature part draws its skinned-mesh twin (the WOW joint
// attributes → the owned-palette WOW_RIG_SKIN shader path, decision 0720);
// everything else — and the palette-full fallback (slot 0) — the static mesh.
// Keyed on the part having a twin, which the slot alone no longer implies.
let skinned = inst_slot != 0 && part.skinned_mesh.is_some();
let mesh = match &part.skinned_mesh {
Some(sm) if skinned => sm.clone(),
_ => part.mesh.clone(),
};
let mut child = parent.spawn((
Mesh3d(mesh),
MeshMaterial3d(init_mat),
Transform::default(),
ModelPart {
kind,
blend: part.blend,
},
// The portrait booth mirrors this part ([`crate::portrait`]): both mesh
// twins — the booth poses the skinned twin at Stand on its own throwaway
// skeleton (the ref bake, wow-re §4 D2), falling back to the static
// bind-pose twin for a boneless model — + the steady exterior material
// (not the appear-fade/interior variant the child may wear now).
crate::portrait::PortraitPart {
static_mesh: part.mesh.clone(),
skinned_mesh: part.skinned_mesh.clone(),
material: mat.clone(),
},
object.clone(),
));
// Every part gets a tag: the instance slot above plus the live alpha field. It is
// unconditional because the slot is now a per-instance identity every part needs
// (the tint) rather than a skinning detail only skinned parts have — and because
// the two conditional writers that used to seed it (the interior classifier, the
// animated-alpha compose) each covered only their own subset, so a part in
// neither carried no tag at all. The alpha honours `fading` like those two did:
// the old unconditional `1.0` here was overwritten by them on the parts they
// covered, and left a one-frame opaque flash on the parts they didn't.
child.insert(MeshTag(
rig_tag | crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 }),
));
// `RigPart` stays gated on actually being skinned by that rig — it is the
// CPU-side link for the mouseover picker's skinned ray test (decision 0720),
// which has nothing to say about a static part.
if skinned {
child.insert(crate::rig_palette::RigPart(entity));
}
if let (Some(_), Some(_)) = (&skin, &part.skinned_mesh) {
// A streamed entity's M2 is **never view-culled** — the reference registers
// entity render records with effectively-infinite bounds (≈1e7) and the one
// frustum cull in its machinery is map-doodad-only (wow-re
// `unit-anim-visibility-gate.md` §2/§4: "doodads get the faithful
// frustum/occlusion/distance cull; units do not"). Bevy's bind-pose `Aabb`
// is the wrong stand-in — an armed idle can leave the bind box entirely
// (the duel flag plants itself 9 yd below it, so it culled away at every
// ground-level camera) — and a better box inserted here is stomped anyway:
// `calculate_bounds`' update query rewrites the `Aabb` of any entity whose
// `Mesh3d` changed (spawn tick, async mesh load) back to the bind pose.
// So the renderer gets the faithful `NoFrustumCulling`, and the `Aabb` we
// insert beside it serves ONE master: the mouseover picker
// (`target/hover.rs`) — the armed idle's authored CAaBox when it has one
// (it tracks the drawn pose), else the bind box. Both `calculate_bounds`
// queries skip `NoFrustumCulling` entities, so this box survives.
let picker_aabb = idle_aabb
.or_else(|| meshes.get(&part.mesh).and_then(MeshAabb::compute_aabb));
if let Some(aabb) = picker_aabb {
child.insert((aabb, NoFrustumCulling));
}
}
// M2 parts can light off a WMO room they stand in: a `MeshTag` + the classifier
// pick the law by location (0354: the day/night state rides the intensity byte
// on the SAME exterior material; only the footprint bake swaps to the probe
// variant). While the appear-fade is live the tag carries its alpha (≈0 here)
// and the classifier yields ([`RenderFade`]); once the fade latches the
// classifier reclaims the tag and the steady material. `mat_interior` (the
// interior-capable build) stays the gate for which parts classify at all.
// Anchored at the unit root: every part shares the root's verdict, so a body
// never splits across the interior/exterior light laws. The indoor LAW is one
// for every entity M2 — unit, player, GameObject alike take the footprint-MOCV
// bake (the reference registers each with the same entity-node fill, wow-re
// `unit-m2-shader-light.md`, superseding 0315's class split); the matte ×1.0
// stays as the bake's miss fallback.
if let Some(lit) =
part_interior_lit(mat, mat_interior, mat_bake, bake_center, entity)
{
// The tag itself is already seeded above, with this same value — the
// classifier composes into it rather than owning it.
child.insert(lit);
}
// The batch's **animated material alpha** (the verified combine's runtime half,
// wow-re `m2-alpha-combine-cull.md`): a creature's colour-alpha/transparency
// tracks are authored PER SEQUENCE, so which of its batches draw is a function
// of what it is playing — a voidwalker's two upper armour pieces are weight 0
// in Stand/Walk/Run and 1 only in Death. Sampling follows this unit's own
// `AnimationPlayer` (the root, `entity`), so the alpha stays in phase with the
// pose. The `A <= 0` cull lands through the `Visibility` authority; the partial
// factor through `entities::apply_unit_mat_alpha`. Nothing is inserted for the
// overwhelming majority of batches, which author no tracks at all.
if let Some(anim) = &part.alpha_anim {
// The compose needs a tag to write into; every part has one now (above).
child.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
}
// Queue the appear-fade on M2 parts — `arm_appear_fade` starts the ramp once the world
// is on-screen (not behind the loading screen), so it plays where the player sees it.
// `FadeMaterials` persists the material pair so the despawn fade-out can re-arm later.
// Skipped on a gear-change rebuild (`Reattached`) — a shirt swap isn't a spawn.
if let (Some(blend), false) = (fade_blend, reattached) {
unit_will_fade = true;
child.insert((
PendingAppearFade { since: now },
FadeMaterials {
cutout: mat.clone(),
blend: blend.clone(),
bake_blend: mat_bake_blend.cloned(),
},
));
}
}
});
// The billboard cards (decision 0153): world-root entities following their anchor —
// the facing system re-seats each from the anchor's live global transform every frame
// (camera-facing around the authored bone pivot, exactly like the doodad path) and
// despawns it with the anchor.
for (info, part, owner, at_joint) in billboard_parts {
let card_follow = if at_joint {
BillboardCard::following_joint(&info, owner)
// Everything one part's spawn reads from this unit, gathered once (`attach::dress`) —
// the same context the gear-change **re-dress** feeds `spawn_part`, so a body built at
// stream-in and a geoset that appears when a belt is swapped are dressed by one law.
let no_anchors = std::collections::HashMap::new();
let dress = PartDress {
unit: entity,
kind,
char_mats: &char_mats,
object: &object,
rig_tag,
inst_slot,
rigged: skin.is_some(),
anchors: skin.as_ref().map_or(&no_anchors, |rb| &rb.anchors),
bake_center,
idle_aabb,
now,
// A fresh visual arms the appear-fade on every fade-capable part (decision 0032).
// A rebuild that is NOT a spawn — a mount transition, a display swap — spawns
// steady: `Reattached` says the unit was already standing there.
fade: if reattached {
JoinedFade::Steady
} else {
BillboardCard::following(&info, owner)
};
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
Transform::default(),
ModelPart {
kind,
blend: part.blend,
},
object.clone(),
card_follow,
));
// A card takes its MODEL's indoor law, through the same constructor as the sibling
// meshes it was split out of (decision 0778). No char-slot variants are consulted:
// a body/hair/cape/skin-extra batch is never a billboard batch, so the slot lookup
// living past the `continue` above costs a card nothing.
//
// A card carries the unit's INSTANCE slot like every sibling part, even though it is
// never skinned by it (it draws the static mesh whatever its host — that is what the
// vertex stage's attribute gate is for): it is a batch of the unit's own model, so a
// tinted unit tints its eye-glow and torch cards too (decision 0812). Seeded
// unconditionally for the same reason as the mesh parts — the two writers below cover
// only their own subsets.
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)));
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
bake_center,
entity,
) {
card.insert(lit);
}
// A card shares its batch's per-sequence alpha loops (the billboard split copies
// them onto every group), sampled off the same unit clock as the mesh parts — a
// creature's eye glow is authored to vanish in the sequences that hide its eyes.
// The classifier composes into the same tag rather than stomping it
// (`write_part_law` rewrites only the interior bits), so the tag is seeded here
// just for the batches that classified out above.
if let Some(anim) = &part.alpha_anim {
card.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
JoinedFade::Pending { since: now }
},
};
for (i, part) in parts.iter().enumerate() {
// Skip a geoset this character doesn't show (an unselected hair/facial/body
// variant, or a body region its worn gear replaces). The equipment half of that
// selection is re-run in place on a gear change (`attach::redress`); this is the
// same predicate, evaluated once at build.
if visible_geosets
.as_ref()
.is_some_and(|vis| !vis.contains(&part.geoset_id))
{
continue;
}
unit_will_fade |= spawn_part(&mut commands, part, i, &dress);
}
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
// / helm / shoulder attaching later reads this to join the same ramp
// (`entities::equipment::attach_held_items`). A gear-change rebuild (`Reattached`) abandons
// any in-flight fade outright — matching the body parts it just rebuilt steady, "a shirt
// swap isn't a spawn" — so anything spawned for that same rebuild also spawns steady.
// (`entities::equipment::attach_held_items`). A `Reattached` rebuild — a mount
// transition, a display swap — abandons any in-flight fade outright, matching the body
// parts it just rebuilt steady, so anything spawned for that same rebuild is steady too.
// (A GEAR change no longer reaches here at all: it re-dresses in place, and the parts it
// spawns join this clock instead of clearing it — `attach::redress`.)
if unit_will_fade {
commands
.entity(entity)
@@ -988,8 +749,8 @@ pub(super) fn attach_entity_visuals(
} else {
Transform::default()
};
// The equipment this visual was dressed with (decision 0074): `refresh_player_looks`
// diffs it against the live resolution and rebuilds the visual on a gear change.
// The equipment this visual was dressed with (decision 0074): `redress_player_looks`
// diffs it against the live resolution and re-dresses the standing visual on a change.
if let (EntityKind::Player, Some(e)) = (net.kind, equipment) {
commands
.entity(entity)
@@ -0,0 +1,564 @@
//! **Re-dressing** a character in place when its worn equipment changes — the gear-change half of
//! [`super`] (decision 0074's teardown, replaced).
//!
//! A gear change used to tear the whole visual down (`despawn_related::<Children>()` + strip the rig,
//! the animation player, the bone-attach table and the held-item bookkeeping) and let
//! [`super::attach_entity_visuals`] rebuild it the next frame. That is not what the reference does,
//! and the difference is visible: it destroys and re-creates *every* attached model too — both
//! weapons, their enchant glows, the helm, the shoulders, every persistent aura visual — so swapping
//! a belt blinked things that had nothing to do with the belt (and, before decision 0833 chained an
//! effect's lifetime to its model, smeared their particle clouds across the ground behind a running
//! character).
//!
//! The reference re-dresses on the SAME `CM2Model`. Its character compositor re-blits the dirty
//! region groups into the component's own 256² target and re-runs the geoset selection `0x477520`,
//! whose only reach into the model is the per-instance visibility array `+0x98` — a range filter
//! writing ordinals, nothing more (wow-re `charactermodel.md` "Assembly orchestration";
//! `models.md` §"geoset-visibility-default", where the writer census proves the character compositor
//! is the *only* thing in the binary that can hide a submesh). Attachments are installed through a
//! different mechanism entirely (`0x712f70 CM2Model::attachChild`) and the compositor never touches
//! them.
//!
//! So this system is that law, and only that law:
//!
//! 1. re-composite the body atlas + re-resolve the cape texture for the new equipment, and re-point
//! every standing part's material variants at them ([`super::dress::part_materials`]);
//! 2. re-run the geoset selection — a batch the new gear hides is despawned, one it reveals is
//! spawned by the same [`super::dress::spawn_part`] the first build used;
//! 3. touch nothing else. The rig, the pose buffer, the bone anchors, the held items and everything
//! hanging off them are the same objects they were a frame ago.
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::interior::InteriorLit;
use crate::lighting::SharedLightBuffer;
use crate::model_fade::{join_unit_appear_fade, FadeMaterials, PendingAppearFade, RenderFade};
use crate::net::{NetEntity, ObjectStore};
use crate::portrait::PortraitPart;
use crate::terrain::WowModelMaterial;
use super::super::equipment::AppliedEquipment;
use super::super::{
Characters, Creatures, EntityMaterials, EntityPart, Equipment, ItemDisplays, SkinComposites,
SkinSections, VisualAttached,
};
use super::char_skin::{
build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
CharSkinMaterials,
};
use super::dress::{part_materials, spawn_part, DressedPart, PartDress};
/// The per-part write surface of a re-dress: the displayed material and the three records that
/// decide what a part draws when something *else* owns that channel — the interior classifier's law
/// variants, the fade ramps' twin pair, and the portrait booths' steady mirror. All optional
/// because a [`DressedPart`] is also carried by a billboard batch's **anchor**, which draws nothing
/// (its card is a world root) and holds none of them.
type PartWrites<'a> = (
Option<Mut<'a, MeshMaterial3d<WowModelMaterial>>>,
Option<Mut<'a, InteriorLit>>,
Option<Mut<'a, FadeMaterials>>,
Option<Mut<'a, PortraitPart>>,
);
/// Re-dress every player whose worn equipment changed, in place (module docs). Players only: a
/// creature's look never changes this way, and a character-model NPC wears its display's columns —
/// a *display* change is a different model and stays a teardown
/// ([`super::super::live_display::refresh_live_display`]).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(in crate::entities) fn redress_player_looks(
mut commands: Commands,
mut players: Query<
(
Entity,
&NetEntity,
&Equipment,
&mut AppliedEquipment,
&Children,
Option<&crate::rig_palette::RigSkin>,
Option<&super::super::BoneAttach>,
Option<&crate::interior::BodyBakeCenter>,
Option<&crate::model_fade::UnitAppearFade>,
),
With<VisualAttached>,
>,
// The parts already standing under those players — found again by the batch index each carries.
mut standing: Query<(
&DressedPart,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
Option<&mut InteriorLit>,
Option<&mut FadeMaterials>,
Option<&mut PortraitPart>,
Has<RenderFade>,
Has<PendingAppearFade>,
)>,
stores: Query<&ObjectStore>,
creatures: Option<Res<Creatures>>,
displays: Option<Res<ItemDisplays>>,
characters: Option<Res<Characters>>,
// The character-skin build chain, nested to stay inside Bevy's system-param tuple limit — the
// same set [`super::attach_entity_visuals`] composites with, because it is the same composite.
skin_build: (
Option<Res<SkinSections>>,
Option<Res<WorldAssets>>,
Option<Res<SharedLightBuffer>>,
ResMut<Assets<Image>>,
ResMut<SkinComposites>,
Res<AssetServer>,
ResMut<Assets<WowModelMaterial>>,
ResMut<EntityMaterials>,
),
time: Res<Time>,
) {
let (
sections,
world_assets,
shared_light,
mut images,
mut skin_composites,
asset_server,
mut materials,
mut entity_mats,
) = skin_build;
let now = time.elapsed_secs();
for (entity, net, live, mut applied, children, rig, bones, bake_center, unit_fade) in
&mut players
{
// `settled` (decision 0074): every non-empty visible-item entry has resolved through the
// template cache. Re-dressing on a half-resolved set would composite a half-dressed atlas
// and then composite again a frame later.
if net.kind != EntityKind::Player || !live.settled || *live == applied.0 {
continue;
}
// Stamp first, unconditionally: a player whose display resolved to no model at all (the
// cube fallback) has nothing to re-dress, and must not re-enter this arm every frame.
applied.0 = *live;
let Some(dm) = net
.display_id
.and_then(|disp| creatures.as_deref()?.models.get(&disp))
else {
continue;
};
let Some(parts) = dm.parts.as_deref() else {
continue;
};
// The dressing inputs, re-resolved exactly as the first build resolved them.
let worn = resolve_worn_equip(net, Some(live), Some(dm));
let look = resolve_char_look(net, Some(dm), entity, &stores);
let eg = equip_geosets(displays.as_deref(), &worn.bodyslots, worn.cloak, worn.helm);
let visible: Option<Vec<u16>> = look.as_ref().and_then(|l| {
let cg = characters.as_deref()?;
Some(cg.0.visible_geosets(l.race, l.sex, l.hair_style, l.facial_hair, &eg))
});
// The re-composite. Cached per (appearance, worn set) in `SkinComposites`, so a swap back
// to a set already worn this session costs a lookup.
let char_mats: CharSkinMaterials = match look.as_ref() {
Some(l) => build_char_skin_materials(
l,
worn.bodyslots,
worn.cloak,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
shared_light.as_deref(),
parts,
&mut images,
&mut skin_composites.0,
&asset_server,
&mut materials,
&mut entity_mats.0,
),
None => (None, None, None, (None, None)),
};
// No look (a druid form, a GM morph — a Player-kind entity on a beast display) means no
// geoset filter, exactly as at build: every batch the model authors draws.
let shows = |geoset: u16| visible.as_ref().is_none_or(|v| v.contains(&geoset));
// Pass 1 — the parts already standing: hide-by-despawn, or re-point.
let mut present = vec![false; parts.len()];
let (mut hidden, mut repointed) = (0usize, 0usize);
for child in children.iter() {
let Ok((dp, mat, lit, fade_mats, portrait, ramping, pending)) = standing.get_mut(child)
else {
continue;
};
let dp = *dp;
let Some(part) = parts.get(dp.index as usize) else {
continue; // a stale index (the model rebuilt under us) — leave it to the teardown
};
if !shows(part.geoset_id) {
// A billboard batch's card is a world ROOT following this anchor's joint, so it does
// not cascade — reap it by name (decision 0153's lifecycle, [`DressedPart::card`]).
if let Some(card) = dp.card {
if let Ok(mut ec) = commands.get_entity(card) {
ec.despawn();
}
}
commands.entity(child).despawn();
hidden += 1;
continue;
}
present[dp.index as usize] = true;
// A card's material is the model's own batch material — never a character slot — so a
// billboard part has nothing to re-point.
if part.billboard.is_none() {
repoint_part(
part,
&char_mats,
(mat, lit, fade_mats, portrait),
ramping || pending,
);
repointed += 1;
}
}
// Pass 2 — the batches the new gear reveals. They join the unit's own appear-fade clock if
// one is still in flight (the login gear cascade lands mid-ramp), exactly as a
// late-resolving held item does — never a second ramp of their own, never a pop.
let no_anchors = std::collections::HashMap::new();
let dress = PartDress {
unit: entity,
kind: crate::debug_panel::ModelKind::Creature,
char_mats: &char_mats,
object: &crate::interact::WorldObject {
kind: crate::debug_panel::ModelKind::Creature,
label: super::display_label(&dm.handle),
id: net.display_id.unwrap_or(0),
detail: format!("emitters: {}", dm.emitters.len()),
},
rig_tag: crate::mesh_tag::rig_bits(rig.map_or(0, |r| r.slot)),
inst_slot: rig.map_or(0, |r| r.slot),
rigged: bones.is_some(),
anchors: bones.map_or(&no_anchors, |b| &b.anchors),
bake_center: bake_center.map_or(dm.bake_center_local, |c| c.0),
idle_aabb: idle_aabb(dm),
now,
fade: join_unit_appear_fade(unit_fade.copied()),
};
let mut shown = 0usize;
for (i, part) in parts.iter().enumerate() {
if present[i] || !shows(part.geoset_id) {
continue;
}
spawn_part(&mut commands, part, i, &dress);
shown += 1;
}
// One line per re-dress — a handful per session, and the only readout of a mechanism whose
// whole point is that nothing else moves. `atlas` is the composited body material's id: it
// must CHANGE when the worn set changes a body region, which is what says the re-composite
// reached the parts rather than merely being built. The counts say which batches the new
// gear hid and revealed. Nothing about the unit's attachments appears here because this
// system cannot touch them.
info!(
"redress: {entity} — {hidden} batch(es) hidden, {shown} shown, {repointed} re-pointed, \
atlas {:?}",
char_mats.0.as_ref().map(|(single, _)| single.0.id()),
);
}
}
/// Re-point one standing part at the freshly-built material set its unit now wears.
///
/// The three records are updated whatever the part is doing, because each is read by a *different*
/// owner at a different moment: the interior classifier resolves the law's steady material, the fade
/// ramps resolve their twin every frame from [`FadeMaterials`] + the law, and the portrait booths
/// mirror the steady one whenever a booth rebuilds. The **displayed** material is only written when
/// no ramp owns that channel — a part mid-appear-fade wears its blend twin, and
/// [`crate::model_fade::apply_render_fade`] re-resolves it from the two records above on its next
/// tick, so writing the steady handle here would flash it opaque for a frame.
fn repoint_part(
part: &EntityPart,
char_mats: &CharSkinMaterials,
(mat, lit, fade_mats, portrait): PartWrites,
fading: bool,
) {
// A batch with no character texture slot draws its shared model's own built materials, which no
// amount of gear can change — the body atlas, the hair, the cape and the extra skin are the
// whole of what a re-dress can touch.
if part.char_slot.is_none() {
return;
}
let m = part_materials(part, char_mats);
if let Some(mut fm) = fade_mats {
fm.cutout = m.steady.clone();
if let Some(blend) = m.fade_blend {
fm.blend = blend.clone();
}
fm.bake_blend = m.bake_blend.cloned();
}
if let Some(mut p) = portrait {
p.material = m.steady.clone();
}
let want = match lit {
Some(mut lit) => lit.repoint(m.steady, m.bake).clone(),
None => m.steady.clone(),
};
if let Some(mut mat) = mat {
if !fading && mat.0 != want {
mat.0 = want;
}
}
}
/// The armed idle's authored CAaBox — the mouseover picker's volume for a skinned part (decision
/// 0637). Same read as the first build's; see the note there for why the bind box is not a fair
/// stand-in.
fn idle_aabb(dm: &super::super::DisplayModel) -> Option<bevy::camera::primitives::Aabb> {
let anims = dm.animations.as_ref()?;
let clip = anims.first_seq.and_then(|i| anims.clips.get(i))?;
(clip.bounds_max.cmpgt(clip.bounds_min).all())
.then(|| bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use benilla_formats::{CharacterGeosets, ItemDisplay, ItemDisplayCatalog, NpcAppearance};
use super::super::super::display::{empty_display, EntityPart};
use super::super::super::{BoneAttach, Creatures};
use super::*;
/// One synthetic body batch at `geoset`.
fn part(geoset: u16) -> EntityPart {
EntityPart {
mesh: Handle::default(),
aabb: None,
skinned_mesh: None,
material: Handle::default(),
material_interior: None,
material_interior_bake: None,
material_interior_bake_blend: None,
fade_blend: None,
zfill: None,
blend: benilla_formats::ModelBlend::Opaque,
additive: false,
two_sided: false,
geoset_id: geoset,
char_slot: None,
billboard: None,
alpha_anim: None,
rgb_anim: None,
ground_quad: None,
}
}
/// A player standing fully dressed: the display model behind it, the resource set the re-dress
/// reads, and — the point of the whole change — a bone anchor with a held item hanging off it,
/// exactly where a weapon and its enchant glow live.
struct Standing {
app: App,
player: Entity,
joint: Entity,
held: Entity,
}
/// Build one. `geosets` are the model's batches in order; `showing` the batch indices already
/// spawned as children (what the first build selected); `characters` the geoset tables (absent ⇒
/// no filter at all, the druid-form / beast-display arm).
fn stand(geosets: &[u16], showing: &[usize], characters: Option<CharacterGeosets>) -> Standing {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Mesh>()
.init_asset::<Image>()
.init_asset::<WowModelMaterial>()
.init_resource::<SkinComposites>()
.init_resource::<EntityMaterials>();
let mut dm = empty_display();
dm.parts = Some(geosets.iter().map(|g| part(*g)).collect());
// The look comes off the DISPLAY here (a character-model NPC row), which is the same
// `CharLook` the wire path builds — and needs no `ObjectStore` to stand one up.
dm.npc_appearance = Some(NpcAppearance {
race: 1,
sex: 0,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
equipment: [0; 10],
bake_name: None,
});
app.insert_resource(Creatures {
catalog: Default::default(),
models: HashMap::from([(42u32, dm)]),
});
// Display 7 = a pair of gloves whose `geosetGroup[0]` is 1 (→ geoset 402, the glove group's
// own range disabled).
app.insert_resource(ItemDisplays::icons_for_tests(
ItemDisplayCatalog::from_displays(HashMap::from([(
7u32,
ItemDisplay {
geoset_groups: [1, 0, 0],
..Default::default()
},
)])),
));
if let Some(cg) = characters {
app.insert_resource(Characters(cg));
}
let player = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Player,
display_id: Some(42),
scale: 1.0,
},
Equipment {
settled: true,
..Default::default()
},
AppliedEquipment(Equipment {
settled: true,
..Default::default()
}),
VisualAttached,
Transform::default(),
Visibility::default(),
))
.id();
// The rig's bone anchor and the item hanging off it — the weapon root a gear change used to
// take with it.
let joint = app
.world_mut()
.spawn((Transform::default(), Visibility::default(), ChildOf(player)))
.id();
let held = app
.world_mut()
.spawn((Transform::default(), Visibility::default(), ChildOf(joint)))
.id();
app.world_mut().entity_mut(player).insert(BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::new(),
markers: HashMap::new(),
});
for &i in showing {
app.world_mut().spawn((
Transform::default(),
Visibility::default(),
ChildOf(player),
MeshMaterial3d(Handle::<WowModelMaterial>::default()),
DressedPart {
index: i as u32,
card: None,
},
));
}
app.add_systems(Update, redress_player_looks);
app.update();
Standing {
app,
player,
joint,
held,
}
}
impl Standing {
/// Swap the player's gear and run one re-dress pass.
fn wear(&mut self, gear: Equipment) {
*self
.app
.world_mut()
.get_mut::<Equipment>(self.player)
.unwrap() = gear;
self.app.update();
}
/// The batch indices currently standing under the player.
fn showing(&mut self) -> Vec<u32> {
let mut out: Vec<u32> = self
.app
.world_mut()
.query::<&DressedPart>()
.iter(self.app.world())
.map(|d| d.index)
.collect();
out.sort_unstable();
out
}
}
/// **The director's report.** A gear change used to `despawn_related::<Children>()` the whole
/// visual, taking both weapons, their enchant glows, the helm, the shoulders and every aura
/// visual with it — everything hanging off a bone was destroyed and re-created because a belt
/// changed. Nothing on the unit but its own body batches may be touched.
#[test]
fn a_gear_change_leaves_the_rig_and_every_attachment_standing() {
let mut s = stand(&[0, 401], &[0, 1], None);
s.wear(Equipment {
cloak: 9,
settled: true,
..Default::default()
});
let w = s.app.world();
assert!(w.get_entity(s.joint).is_ok(), "the bone anchor survives");
assert!(w.get_entity(s.held).is_ok(), "the held item survives");
assert!(
w.get::<BoneAttach>(s.player).is_some(),
"the attach table survives",
);
assert_eq!(
s.showing(),
vec![0, 1],
"the body batches are the same ones"
);
}
/// …and the diff is stamped, so the re-dress fires **once** per change rather than every frame
/// (the composite behind it is a synchronous BLP read on a miss).
#[test]
fn a_gear_change_restamps_what_the_visual_is_dressed_with() {
let mut s = stand(&[0], &[0], None);
let gear = Equipment {
cloak: 9,
settled: true,
..Default::default()
};
s.wear(gear);
assert_eq!(
s.app.world().get::<AppliedEquipment>(s.player).unwrap().0,
gear,
);
}
/// The **geoset half**, against the shipped customization tables: putting gloves on replaces the
/// glove group — the naked 401 batch stops drawing and the item's 402 batch starts — and that
/// now happens by despawning/spawning exactly those two batches instead of rebuilding the unit.
/// (The reference flips two entries of the model's own visibility array; the visible result is
/// the same set, which is what this pins.)
#[test]
fn worn_gloves_replace_the_glove_geoset_in_place() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cg = CharacterGeosets::load(&mut chain).expect("customization tables");
let mut s = stand(&[0, 401, 402], &[0, 1], Some(cg));
assert_eq!(s.showing(), vec![0, 1], "bare-handed to start");
let mut gear = Equipment {
settled: true,
..Default::default()
};
gear.bodyslots[6] = 7; // the gloves slot
s.wear(gear);
assert_eq!(
s.showing(),
vec![0, 2],
"the glove batch replaced the bare-hand one",
);
assert!(
s.app.world().get_entity(s.held).is_ok(),
"and the held item never moved",
);
}
}
+3 -1
View File
@@ -291,7 +291,9 @@ pub(super) fn attach_ground_fx_models(
dm,
now,
true, // a dest-anchored area model's flat quads ARE ground decals
None, // a free world model — any trail stays world-frozen
// A free world model standing at the point, chained to nothing: its trail stays
// world-frozen and its pool finishes in place when the effect ends.
super::spell_fx::EffectHost::default(),
&mut wow_materials,
&mut tint_reg,
&ibps,
+74 -6
View File
@@ -28,9 +28,15 @@ pub(super) enum ModelHandle {
}
/// One spawn part of a display model: a submesh's mesh + its built `WowModelMaterial` + blend (for the
/// `ModelPart` toggle). Built once the model asset loads.
/// `ModelPart` toggle). Built once the model asset loads **and** the model-forms furnisher has
/// built its render forms (decision 0834 — the handles here come from the app-side cache, not the
/// loader).
pub(super) struct EntityPart {
pub(super) mesh: Handle<Mesh>,
/// The static form's build-time `Aabb` (decision 0834): the static mesh is `RENDER_WORLD`-only,
/// so a consumer that used to compute a bound from its main-world data (the attach path's
/// picker-volume fallback) reads this instead. `None` for degenerate geometry.
pub(super) aabb: Option<bevy::camera::primitives::Aabb>,
/// The skinned twin of [`Self::mesh`] (decision 0019), present for every M2 part. An animated
/// instance (a creature, or a GameObject that runs the state machine / loops a loader-idle seq)
/// renders this, skinned through its palette slot's rows (decision 0720); a truly static
@@ -56,6 +62,14 @@ pub(super) struct EntityPart {
/// the entity feathers in on this while `α < 1`, then swaps back to `material`. `Some` for M2 parts
/// (creatures + M2 GameObjects, the CGObjects that appear-fade); `None` for WMO-display parts.
pub(super) fade_blend: Option<Handle<WowModelMaterial>>,
/// The **depth-prime twin** material ([`crate::model_render::zfill_material`] — the reference's
/// `M2UseZFill` clone, wow-re `m2-blend-promotion-zfill.md` §4). While the part draws
/// translucent, `model_fade::sync_zfill_twins` spawns a child mesh on this: colour-masked,
/// blend-off, z-writing, sorted before the colour parts — one blended layer everywhere, no
/// self-overlap darkening on a stealthed or fading body. `None` when the batch's material
/// disables z-write/z-test (the reference's own `(flags & 0x10) == 0` twin gate) or cannot
/// fade at all (Mod/Mod2x), and for WMO-display parts.
pub(super) zfill: Option<Handle<WowModelMaterial>>,
pub(super) blend: ModelBlend,
/// Whether this part blends **additively** (M2 blend mode 3 `NoAlphaAdd` / 4 `Add`).
/// [`ModelBlend`] deliberately folds "alpha-blended / additive" into its single `Blend`
@@ -300,6 +314,10 @@ pub(super) fn build_parts(
dm: &mut DisplayModel,
m2s: &Assets<M2Model>,
wmos: &Assets<WmoModel>,
// The app-built render forms (decision 0834): parts wait for the furnisher exactly as they
// wait for the asset. Entity displays request at priority 0 — a mob walking into view never
// queues behind a city crossing's scenery (whose placements request at 16+).
forms: &mut crate::model_forms::ModelForms,
asset_server: &AssetServer,
materials: &mut Assets<WowModelMaterial>,
cache: &mut MaterialCache,
@@ -330,6 +348,14 @@ pub(super) fn build_parts(
let Some(model) = m2s.get(h) else {
return; // still loading
};
// Every M2 entity lane can rig (creatures, animated GameObjects, held items with
// billboard chains), so both forms are requested; the static form still serves the
// truly static instances and the billboard cards.
let key = crate::model_forms::ModelKey::from(h);
let want = crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED;
if !forms.require(key, want, 0) {
return; // forms still building — same retry-next-frame as the asset itself
}
emitters = model.emitters.clone();
ribbons = model.ribbons.clone();
lights = model.lights.clone();
@@ -381,10 +407,13 @@ pub(super) fn build_parts(
if gameobject {
collider = model.collision.as_ref().and_then(model_local_collider);
}
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
model
.submeshes
.iter()
.map(|sub| {
.enumerate()
.map(|(pi, sub)| {
let texture = resolve_skin(
sub,
&dm.dir,
@@ -425,6 +454,26 @@ pub(super) fn build_parts(
// batch (Mod/Mod2x) gets NO twin: its blend equation reads no alpha, so it cannot
// fade — the reference's instanceAlpha ramp leaves the sheen at full strength while
// the base fades under it (decision 0528); `None` spawns the part Steady.
// The depth-prime twin (wow-re `m2-blend-promotion-zfill.md` §4): every batch
// that can fade AND writes depth gets one. `cutout` mirrors what the part's
// colour pass discards while translucent — the fade twin's hard 224/255 for
// Opaque/AlphaKey sources, nothing for authored-Blend sources (their colour
// pass is the plain blend material) — so depth and colour coverage agree.
let zfill = if sub.no_depth_write || sub.no_depth_test {
None
} else {
match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
b => Some(crate::model_render::zfill_material(
cache,
materials,
texture.clone(),
sub.two_sided,
b != ModelBlend::Blend,
light,
)),
}
};
let fade_blend = match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
ModelBlend::Blend => Some(exterior.clone()),
@@ -544,13 +593,20 @@ pub(super) fn build_parts(
light,
);
EntityPart {
mesh: sub.mesh.clone(),
skinned_mesh: Some(sub.skinned_mesh.clone()),
// Index-parallel with the submeshes by the forms contract; a miss is a
// broken contract — a default (dead) handle draws nothing rather than panic.
mesh: stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
skinned_mesh: skin_forms.get(pi).cloned(),
material: exterior,
material_interior: Some(interior),
material_interior_bake: interior_bake,
material_interior_bake_blend: interior_bake_blend,
fade_blend,
zfill,
blend: sub.blend,
additive: sub.additive,
two_sided: sub.two_sided,
@@ -568,14 +624,25 @@ pub(super) fn build_parts(
let Some(model) = wmos.get(h) else {
return;
};
// WMO-display GameObjects never skin — static forms only.
let key = crate::model_forms::ModelKey::from(h);
if !forms.require(key, crate::model_forms::WANT_STATIC, 0) {
return;
}
if gameobject {
collider = model.collision.as_ref().and_then(model_local_collider);
}
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
model
.submeshes
.iter()
.map(|sub| EntityPart {
mesh: sub.mesh.clone(),
.enumerate()
.map(|(pi, sub)| EntityPart {
mesh: stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
geoset_id: sub.geoset_id, // 0 for WMO — no geoset selection
char_slot: None, // WMO is never a character body
skinned_mesh: None, // WMO-display GameObjects don't skin (no skeleton)
@@ -606,6 +673,7 @@ pub(super) fn build_parts(
material_interior_bake: None, // …and never the M2 footprint lane
material_interior_bake_blend: None,
fade_blend: None, // WMO-display GameObjects don't appear-fade (rare; M2 only)
zfill: None, // …so they never need the depth-prime twin either
blend: sub.blend,
additive: false, // WMO MOMT carries no additive mode (`RenderSubmesh::additive`)
two_sided: sub.two_sided,
+10 -49
View File
@@ -30,12 +30,10 @@
use std::collections::HashMap;
use benilla_formats::ItemDisplayCatalog;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use super::{DisplayModel, ModelHandle};
use crate::model_render::m2_url;
use crate::net::NetEntity;
mod resolve;
pub(in crate::entities) use resolve::placement;
@@ -118,7 +116,7 @@ impl ItemDisplays {
/// every non-empty visible-item entry has resolved through the template cache (hit or recorded
/// miss) — the attach path waits for it so a player composites dressed, not naked-then-flicker.
/// Players only; a character-model NPC's armor ships pre-baked (decision 0060).
#[derive(Component, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Component, Clone, Copy, PartialEq, Eq, Default, Debug)]
pub(crate) struct Equipment {
pub(crate) bodyslots: [u32; 8],
/// The back slot's display id (0 = no cloak) — a geoset + runtime cape texture, no body region.
@@ -129,57 +127,20 @@ pub(crate) struct Equipment {
pub(crate) settled: bool,
}
/// The [`Equipment`] a player's attached visual was built with (its composite key's equip half).
/// [`refresh_player_looks`] diffs it against the live resolution and re-attaches on change.
/// The [`Equipment`] a player's attached visual was **dressed** with (its composite key's equip
/// half). [`super::attach::redress_player_looks`] diffs it against the live resolution and re-dresses
/// the standing visual in place on a change — the reference's own shape, and the reason a weapon
/// glow no longer blinks when a belt is swapped (decision 0835, superseding 0074's teardown).
#[derive(Component)]
pub(super) struct AppliedEquipment(pub(super) Equipment);
pub(in crate::entities) struct AppliedEquipment(pub(in crate::entities) Equipment);
/// Marks a player whose visual was torn down by [`refresh_player_looks`] (a gear change): the
/// re-attach skips the appear-fade — changing a shirt isn't a spawn.
/// Marks a unit whose visual was torn down and is being rebuilt for something **other** than a
/// spawn: the re-attach skips the appear-fade. A mount transition ([`super::mount`]) and a live
/// display swap ([`super::live_display`]) are the two remaining tear-downs; a gear change is not one
/// of them any more (decision 0835).
#[derive(Component)]
pub(super) struct Reattached;
/// Rebuild a player's visual when their worn equipment changes (decision 0074): the composite atlas
/// (and, later, the equipment geosets) are baked into the attached children, so a gear change tears
/// the visual down — every child (parts, joints, held-item roots) plus the per-instance visual
/// components — and lets [`super::attach_entity_visuals`] re-run next frame with the new
/// [`Equipment`], fade-skipped via [`Reattached`]. Players only: a creature's look never changes
/// this way (weapon swaps ride [`HeldItems`]' own diff, no teardown).
pub(super) fn refresh_player_looks(
mut commands: Commands,
players: Query<
(Entity, &NetEntity, &Equipment, &AppliedEquipment),
With<super::VisualAttached>,
>,
) {
for (entity, net, live, applied) in &players {
if net.kind != EntityKind::Player || !live.settled || *live == applied.0 {
continue;
}
commands
.entity(entity)
.despawn_related::<Children>()
.remove::<(
super::VisualAttached,
AppliedEquipment,
AnimationPlayer,
bevy::animation::transition::AnimationTransitions,
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
(
crate::creature_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
BoneAttach,
HeldAttached,
)>()
.insert(Reattached);
}
}
/// Player equipment slots feeding the armor composite → their bodyslot−2 index (decision 0074):
/// shirt(3) chest(4) waist(5) legs(6) feet(7) wrists(8) hands(9) tabard(18). The visible-item block
/// is indexed by equipment slot, so no invType mapping is needed on this path.
+66 -8
View File
@@ -256,7 +256,15 @@ fn spawn_slot(
let &(bone, offset) = bones.points.get(&hs.attach)?;
let joint = bones.anchor(bone)?;
let root = commands
.spawn((Transform::from_translation(offset), Visibility::default()))
.spawn((
Transform::from_translation(offset),
Visibility::default(),
// This item model is CHAINED to the body wearing it (`0x712f70` attach → the
// `[model+0x1cc]` parent link): the wearer's computed render alpha multiplies
// everything this root carries, and everything chained below it in turn — its glow
// instances included (decision 0833).
crate::model_fade::ParentModel(entity),
))
.id();
commands.entity(joint).add_child(root);
// The item/enchant glow (decision 0805): its instances hang off the ITEM's own
@@ -360,6 +368,11 @@ fn spawn_slot(
// the ground-shade ramp that darkens its wielder; both now follow the body, which
// is the same light-collector aliasing the comment above describes.
child.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
// The item part's build-time bound (decision 0834): `calculate_bounds` can no longer
// derive one from the `RENDER_WORLD`-only static form's data.
if let Some(aabb) = part.aabb {
child.insert(aabb);
}
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
@@ -377,6 +390,7 @@ fn spawn_slot(
cutout: part.material.clone(),
blend: blend.clone(),
bake_blend: part.material_interior_bake_blend.clone(),
zfill: part.zfill.clone(),
});
}
match effective {
@@ -427,6 +441,10 @@ fn spawn_slot(
},
BillboardCard::following(&info, root),
));
// The card's build-time bound (decision 0834) — same rule as its mesh siblings above.
if let Some(aabb) = part.aabb {
card.insert(aabb);
}
// Same interior-light membership the item's mesh parts get above, through the same
// constructor and anchored at the same WEARER (decision 0778) — so a held torch's
// glow card can never split from the arm holding it. Spawned at a steady alpha: a
@@ -521,10 +539,11 @@ fn spawn_slot(
// the air behind the character (decision 0826). A sheath swap no longer comes
// through here at all: the root is MOVED, and this pool rides it.
on_owner_loss: crate::particles::OwnerLoss::Free,
// An ATTACHED model inherits its parent's computed alpha (`0x714000`), so the
// sparkle on a pauldron fades in with the body wearing it and vanishes with the
// avatar in first person — decision 0827. The WEARER, never the item root.
alpha: Some(entity),
// This emitter's own model instance is the item root; `ParentModel` above chains
// it to the wearer, and the chain is what an ATTACHED model's composed alpha is
// (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it
// and vanishes with the avatar in first person (0827/0833).
alpha: Some(root),
},
// A held item spawns no rig; its emitters run the item model's own slot-0
// loop on the spawn clock (the torch burns always — the doodad law).
@@ -551,9 +570,9 @@ fn spawn_slot(
false,
ctx.scale,
Some(0),
// An attached model inherits the WEARER's render alpha, so an enchant streamer is
// gone with the avatar in first person and absent until the body is shown (0827).
Some(entity),
// Its own model instance — chained to the wearer above — so an enchant streamer is
// gone with the avatar in first person and absent until the body is shown (0827/0833).
Some(root),
);
}
debug!(
@@ -579,12 +598,14 @@ mod tests {
fn part(interior: bool) -> EntityPart {
EntityPart {
mesh: Handle::default(),
aabb: None,
skinned_mesh: None,
material: Handle::default(),
material_interior: interior.then(Handle::default),
material_interior_bake: None,
material_interior_bake_blend: None,
fade_blend: None,
zfill: None,
blend: benilla_formats::ModelBlend::Opaque,
additive: false,
two_sided: false,
@@ -957,4 +978,41 @@ mod tests {
);
assert_eq!(after[4], Some(shoulder_root), "the shoulders are untouched");
}
/// The chain's first link, on the real spawn path (decision 0833): an item model is CHAINED to
/// the body wearing it, and the emitters it spawns point at **their own** root rather than at
/// the wearer. Both halves matter — the item's own sparkle would fade correctly either way,
/// but a glow instance hung on this root two links down can only reach the wearer through it,
/// and that is the link the enchant-glow lane never had.
///
/// It survives the sheath MOVE for the same reason the pool does: the root is re-parented, not
/// rebuilt.
#[test]
fn an_item_model_is_chained_to_its_wearer() {
use crate::model_fade::ParentModel;
let (mut app, wearer) = dress_a_wearer();
let shoulder = roots_of(&app, wearer)[4].expect("shoulder");
assert_eq!(
app.world()
.entity(shoulder)
.get::<ParentModel>()
.map(|p| p.0),
Some(wearer),
"the item chains to the body wearing it"
);
let mut items = app.world_mut().entity_mut(wearer);
let mut items = items.get_mut::<HeldItems>().unwrap();
items.slots[0].as_mut().unwrap().attach = SHEATH_BACK;
app.update();
assert_eq!(
app.world()
.entity(shoulder)
.get::<ParentModel>()
.map(|p| p.0),
Some(wearer),
"…and a sheath swap elsewhere leaves that link alone"
);
}
}
+10 -3
View File
@@ -55,7 +55,7 @@ use crate::model_render::m2_url;
use crate::terrain::WowModelMaterial;
use super::equipment::{ItemDisplays, ItemModelKind};
use super::spell_fx::{attach_effect_visuals, FxTintAnims};
use super::spell_fx::{attach_effect_visuals, EffectHost, FxTintAnims};
use super::{DisplayModel, ModelHandle};
/// The glow chain's data + the effect-model cache: the two joined DBC catalogs, plus a
@@ -286,8 +286,15 @@ pub(super) fn attach_item_glows(
instance,
dm,
now,
false, // a weapon glow is never ground-anchored
Some(instance), // an attached model in the client's sense — it rides the item
false, // a weapon glow is never ground-anchored
// An attached model in the client's sense — it rides the ITEM (`0x712f70`), so it
// is chained to that item root and through it to the wearer: the glow fades in
// with the body instead of blazing over a character that has not appeared yet,
// and it dies with the weapon instead of hanging in the air behind it (0833).
EffectHost {
attached: true,
parent: Some(root),
},
&mut wow_materials,
&mut tint_reg,
&ibps,
+2 -2
View File
@@ -13,8 +13,8 @@
//! handler instead **eases the render scale over 2 s with a cosine smoothstep** (byte-verified,
//! `0x614bbf`). All wow-re: `questgiver-marker.md` §W6, `w2d2.md` §2.x, `object-layer.md`.
//!
//! Our shape is the house diff-and-rebuild ([`super::mount::refresh_mounts`] /
//! [`super::equipment::refresh_player_looks`]): the visual was BUILT with [`AppliedDisplay`], the
//! Our shape is the house diff-and-rebuild ([`super::mount::refresh_mounts`]): the visual was
//! BUILT with [`AppliedDisplay`], the
//! live truth is the descriptor store, and a difference tears the visual down for
//! `attach_entity_visuals` to rebuild — fade-skipped (a shapeshift isn't a spawn), waiting out the
//! new model's async load rather than flashing a cube. The collision height restamps **in the same
+4 -2
View File
@@ -61,7 +61,7 @@ use crate::creature_anim::{
use crate::model_render::m2_url;
use super::equipment::ItemDisplays;
use super::spell_fx::{attach_effect_visuals, SpellFx};
use super::spell_fx::{attach_effect_visuals, EffectHost, SpellFx};
use super::{BoneAttach, DisplayModel, ModelHandle};
/// The anim-event idents that release queued missiles — the dispatcher's drain arms (`0x5ffbd0`:
@@ -561,7 +561,9 @@ pub(super) fn attach_missile_models(
dm,
time.elapsed_secs(),
false, // a missile flies — its flat quads are geometry, never ground decals
None, // a missile is a FREE world model — its trail stays world-frozen
// A missile is a FREE world model: its trail stays world-frozen, and its pool
// finishes in place when it impacts (0202's drain).
EffectHost::default(),
&mut wow_materials,
&mut tint_reg,
&ibps,
+3 -2
View File
@@ -33,8 +33,9 @@ pub(super) struct AppliedMount(pub(super) u32);
/// Rebuild a unit's visual when its mount field changes (decision 0441): 0→id (mount up), id→0
/// (dismount — the reference tears the secondary model down on the spot, byte-verified
/// `0x607ce0`: detach body, `SetMountModel(0)`, body to Stand, no transition anim), id→id′ (a
/// re-mount). The transition is the gear-swap teardown (`refresh_player_looks`' exact shape):
/// children — mount child, joints, parts, held roots — despawn, the visual components strip, and
/// re-mount). The transition is a full teardown — a mount is a second SKELETON the rider's own
/// rig re-roots under, so there is nothing to re-dress in place the way a gear change is
/// (`attach::redress`): children — mount child, joints, parts, held roots — despawn, the visual components strip, and
/// `attach_entity_visuals` rebuilds next frame(s) in the right configuration, fade-skipped via
/// `Reattached` (mounting up isn't a spawn).
pub(super) fn refresh_mounts(
+68 -13
View File
@@ -288,7 +288,12 @@ pub(crate) fn drive_fx_view(
&fx.models[&req.model_path],
time.elapsed_secs(),
true, // the fixture plants at a world point — ground quads decal onto the terrain
Some(root), // the fixture previews kit effects, which are attached models
// The fixture previews kit effects, which are attached models — but it hangs on
// nothing (there is no host model in a preview), so its pool keeps the drain.
EffectHost {
attached: true,
parent: None,
},
&mut wow_materials,
&mut tint_reg,
&ibps,
@@ -300,6 +305,24 @@ pub(crate) fn drive_fx_view(
}
}
/// Where an effect-model instance sits in the client's **model graph** — the two facts every
/// caller of [`attach_effect_visuals`] states, together, because they are one fact seen from two
/// sides and a lane that answered only the first is what left a weapon's enchant glow with no
/// alpha source and no owner (decision 0833).
#[derive(Clone, Copy, Default)]
pub(super) struct EffectHost {
/// Is this an **attached** model in the client's sense (`[model+0x17c] ≠ 0`)? It sets the
/// emitters' attach frame `A`: a kit effect on a unit and the `fxview` fixture are attached
/// (the cloud fans with the host's motion), a missile is not — its trail stays world-frozen
/// (wow-re `part-kit-effect-attach-orient.md`).
pub attached: bool,
/// The model instance this one is **chained to** ([`crate::model_fade::ParentModel`]): the
/// unit a kit effect is hung on, the item root a weapon glow rides. `None` for a model that
/// belongs to no other — a missile, the fixture preview — which is also what keeps the 0202
/// drain for the impacting trail.
pub parent: Option<Entity>,
}
/// Attach one effect model's full visual set to `root` — THE one body for every effect-model
/// consumer (spell-kit instances, missiles, the `fxview` capture fixture): the authored rig
/// ([`arm_effect_rig`]), the part meshes (skinned twins on a rigged model, `NoFrustumCulling` —
@@ -311,11 +334,10 @@ pub(crate) fn drive_fx_view(
/// instance (`ground_anchor`: the kit slot resolved to the base point `0x13` or the unit root),
/// each flat ground-plane quad part as a projected surface decal ([`crate::ground_fx`]) riding
/// its joint, so Battle Shout's crescents drape sloped terrain instead of being buried by it.
/// `attach` is the emitters' attach frame — `Some(root)` for an instance that is an ATTACHED
/// model in the client's sense (a kit effect on a unit, the fxview fixture), `None` for a
/// free world model (a missile: its trail stays world-frozen, wow-re
/// `part-kit-effect-attach-orient.md`). Returns `false` while the model's parts are still
/// building — call again next frame.
/// `host` says where the instance sits in the model graph ([`EffectHost`]) — which decides its
/// emitters' attach frame, whose render alpha they inherit, and what becomes of them when the
/// instance goes. Returns `false` while the model's parts are still building — call again next
/// frame.
#[allow(clippy::too_many_arguments)]
pub(super) fn attach_effect_visuals(
commands: &mut Commands,
@@ -323,7 +345,7 @@ pub(super) fn attach_effect_visuals(
dm: &DisplayModel,
now: f32,
ground_anchor: bool,
attach: Option<Entity>,
host: EffectHost,
wow_materials: &mut Assets<WowModelMaterial>,
tint_reg: &mut FxTintAnims,
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
@@ -333,6 +355,24 @@ pub(super) fn attach_effect_visuals(
let Some(parts) = dm.parts.as_ref() else {
return false; // still loading — attach on a later pass
};
let attach = host.attached.then_some(root);
// Chain this instance onto the model it hangs from, so its effects compose through the
// parent's computed alpha the way `0x714000` does — a weapon's glow through the item, the
// item through its wearer (decision 0833).
if let Some(parent) = host.parent {
commands
.entity(root)
.insert(crate::model_fade::ParentModel(parent));
}
// …and, from the same fact, what losing the owner means: an instance hung on another model is
// part of that model's tree, so its pool is freed with it (the reference's dtor) instead of
// being left to finish in world space — the ghost clouds a gear change used to strand at the
// body. A free-standing instance (a missile) keeps the 0202 drain.
let on_owner_loss = if host.parent.is_some() {
crate::particles::OwnerLoss::Free
} else {
crate::particles::OwnerLoss::Drain
};
let is_ground_decal = |part: &EntityPart| ground_anchor && part.ground_quad.is_some();
// Per-part materials for THIS instance (a tint clone where the RGB animates), resolved
// before the child-spawn closure so the material assets aren't borrowed inside it.
@@ -439,6 +479,11 @@ pub(super) fn attach_effect_visuals(
},
card,
));
// The card's build-time bound (decision 0834): `calculate_bounds` can no longer derive
// one from the `RENDER_WORLD`-only static form's data.
if let Some(aabb) = part.aabb {
spawned.insert(aabb);
}
// A card shares its batch's material-alpha loops (the billboard split copies them).
if let Some(anim) = &part.alpha_anim {
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq);
@@ -509,9 +554,12 @@ pub(super) fn attach_effect_visuals(
// The cloud anchors at the instance root — the emitter's bone only composes births
// (the food sparkle's bone orbits; the risen stars must not swirl with it).
anchor: Some(root),
// An effect instance that ends mid-flight leaves its live particles to finish
// (the impacting missile's trail) — the DRAIN default.
..default()
// Free with the model when this instance belongs to one, drain when it stands
// alone (the impacting missile's trail — 0202's case). See `on_owner_loss` above.
on_owner_loss,
// This instance IS the model these particles belong to; its chain (set above)
// carries the host's fade down to them — decision 0833.
alpha: Some(root),
},
// The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot
// (a missile's InFlight is not file-order-first), on the instance's own spawn clock.
@@ -538,8 +586,9 @@ pub(super) fn attach_effect_visuals(
// families land on the same draw-order rung, which is the property that matters.
1.0,
played_anim,
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
None,
// This instance's own model alpha, chained to its host (0827/0833): a standalone
// instance has none above it and draws exactly as before.
Some(root),
);
}
true
@@ -800,7 +849,13 @@ pub(super) fn attach_spell_fx(
dm,
now,
ground_anchor,
Some(root), // a kit instance on a unit is an attached model
// A kit instance on a unit is an attached model, chained to that unit: it fades
// with the body it is cast on, and it is freed with it (0833) — where before, a
// gear change that tore the unit's visual down left its cloud in the air.
EffectHost {
attached: true,
parent: Some(unit),
},
&mut wow_materials,
&mut tint_reg,
&ibps,
+17
View File
@@ -184,6 +184,7 @@ pub(super) fn resolve_wmo_gameobject_props(
pub(super) fn spawn_wmo_gameobject_props(
mut commands: Commands,
m2s: Res<Assets<M2Model>>,
mut forms: ResMut<crate::model_forms::ModelForms>,
shared_light: Option<Res<SharedLightBuffer>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut entity_mats: ResMut<EntityMaterials>,
@@ -211,6 +212,18 @@ pub(super) fn spawn_wmo_gameobject_props(
let Some(m) = m2s.get(&prop.handle) else {
return true; // still loading — retry next frame
};
// The prop's app-built render forms (decision 0834): a transport's cabin props are
// the entity lane — priority 0, same as any creature walking into view.
let key = crate::model_forms::ModelKey::from(&prop.handle);
let kinds = crate::model_forms::WANT_STATIC
| if crate::doodad_anim::wants_rig(m) {
crate::model_forms::WANT_SKINNED
} else {
0
};
if !forms.require(key, kinds, 0) {
return true; // forms still building — retry next frame
}
// A boneless prop's glow card follows an ANCHOR child at the prop's placement (the
// card itself stays a world root the billboard pass writes absolutely). Only spawned
// when the model has billboard batches — one lantern per ship, not 134 empty entities.
@@ -257,6 +270,10 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut palettes,
light,
&m.submeshes,
crate::model_forms::FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
skin: forms.skinned_meshes(key),
},
prop.local, // doodad-LOCAL — the parent composes the world pose
false,
// Deck props: plain matte, like a terrain exterior prop on lit ground (a boat is
+23
View File
@@ -278,6 +278,28 @@ impl InteriorLit {
}
}
/// Re-point a standing part's law variants at a freshly-built material set, and answer what it
/// should now draw — the character **re-dress** (`entities::attach::redress`): a gear change
/// re-composites the body atlas and re-resolves the cape texture, so every variant's handle
/// changes while the part, the room and therefore the *law* stay exactly as they were.
///
/// The applied law is deliberately KEPT, which is what makes this a re-point rather than a
/// re-classification: nothing moved, so re-running the down-ray would answer the same thing at
/// the cost of a frame on the wrong material. A part's `kind` cannot change under it either —
/// bake-capability is a property of the batch's build, and a re-dress never changes which batch
/// a part is.
pub(crate) fn repoint(
&mut self,
exterior: &Handle<WowModelMaterial>,
bake: Option<&Handle<WowModelMaterial>>,
) -> &Handle<WowModelMaterial> {
self.exterior = exterior.clone();
if let (InteriorKind::Bake { material, .. }, Some(b)) = (&mut self.kind, bake) {
*material = b.clone();
}
self.steady_material()
}
pub(crate) fn new(kind: InteriorKind, exterior: Handle<WowModelMaterial>) -> Self {
Self {
kind,
@@ -1188,6 +1210,7 @@ mod tests {
cutout: Handle::default(),
blend: exterior_blend.clone(),
bake_blend: Some(bake_blend.clone()),
zfill: None,
},
crate::model_fade::RenderFade {
started: 0.0,
+2
View File
@@ -69,6 +69,7 @@ mod map_proj;
mod mesh_tag;
mod minimap;
mod model_fade;
mod model_forms;
mod model_render;
mod modkeys;
mod nameplates;
@@ -145,6 +146,7 @@ mod wmo_portal;
mod wmo_sky;
mod world_map;
mod world_state;
mod zfill;
use assets::AssetPlugin;
use avian3d::prelude::*;
+12
View File
@@ -224,6 +224,18 @@ pub(crate) fn shade_of(tag: u32) -> u8 {
((tag & SHADE_MASK) >> SHADE_SHIFT) as u8
}
/// Whether this tag's instance draws **translucent** (`0 < α < 1`) — the depth-prime twin's
/// activation rule ([`crate::zfill`], decision 0831), decoding exactly as the shader does: the
/// flag bits split off first, a whole payload of `0` is the *untagged ⇒ opaque* sentinel, and a
/// full alpha field (63) is opaque. The field is never legitimately `0` ([`alpha_bits`] floors at
/// `1`), so there is no "invisible" branch to exclude — bits `1..=62` are the whole translucent
/// band, matching the reference's twin gate (any batch whose evaluated `A < 0.99999` routes to the
/// transparent lists and clones a twin; only `A ≤ 0` culls the batch outright).
pub(crate) fn translucent(tag: u32) -> bool {
let payload = tag & !(HIGHLIGHT_BIT | INTERIOR_FOG_BIT);
payload != 0 && matches!(payload & ALPHA_MASK, 1..=62)
}
/// Human-readable decode of a `MeshTag`, for the probes (`WOW_PICK`'s per-frame shading dump).
///
/// It lives **here** because this module owns the bit conventions: a probe that re-derived the masks
+158 -10
View File
@@ -157,16 +157,74 @@ pub fn fade_alpha(from: f32, to: f32, t: f32) -> f32 {
/// An **attached** model inherits its parent's, which is why an item's effects read their WEARER's
/// (the `0x714000` recursion: a child model with `[model+0x1cc] ≠ 0` composes onto the parent's
/// computed colours and alpha — wow-re `selection-circle.md` §scope, `0x714260`'s `[ebp+0x14]`).
/// That inheritance is [`ParentModel`] + [`ModelAlphas`], never a spawn site pointing at the top
/// of the chain by hand (decision 0833).
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub struct ModelAlpha(pub f32);
/// The model instance this one is **chained to** — the reference's `[model+0x1cc]` parent link,
/// set on a model that was attached to another model (`0x712f70 CM2Model::attachChild`). An item
/// root's parent is its wearer; a weapon's enchant-glow instance's parent is that item root; a
/// spell kit hung on a unit's is the unit.
///
/// It exists because composition is **recursive** in the reference (`0x714000` walks the chain and
/// composes each child onto its parent's *computed* colour and alpha) and every spawn site knows
/// exactly one thing for certain: who it just attached itself to. Asking a site for the far end of
/// the chain instead is what left a weapon's glow — two links down — with no alpha source at all,
/// blazing at full strength over a character that had not faded in yet (decision 0833).
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub struct ParentModel(pub Entity);
/// How far [`ModelAlphas`] follows [`ParentModel`] before giving up. The real chains are 1–3 links
/// (unit → item → glow); the bound is a cycle backstop, not a policy.
const MAX_MODEL_CHAIN: usize = 8;
/// The composed render alpha of a model instance — [`ModelAlpha`] multiplied along the
/// [`ParentModel`] chain, i.e. the reference's `0x714000` recursion as a lookup. This is the number
/// an effect multiplies into its vertex alpha; **an effect passes its OWN instance root** and the
/// chain supplies everything above it.
///
/// Missing components read as opaque throughout: an entity with no `ModelAlpha` and no parent is
/// `1.0`, which is why the steady-state world pays nothing for this.
#[derive(bevy::ecs::system::SystemParam)]
pub struct ModelAlphas<'w, 's> {
chain: Query<'w, 's, (Option<&'static ModelAlpha>, Option<&'static ParentModel>)>,
}
impl ModelAlphas<'_, '_> {
/// `instance`'s composed alpha. A despawned link ends the walk (its effects are being freed
/// this frame anyway — [`crate::particles::OwnerLoss::Free`]).
pub fn get(&self, instance: Entity) -> f32 {
let mut alpha = 1.0;
let mut at = instance;
for _ in 0..MAX_MODEL_CHAIN {
let Ok((own, parent)) = self.chain.get(at) else {
break;
};
if let Some(a) = own {
alpha *= a.0;
}
match parent {
Some(p) => at = p.0,
None => break,
}
}
alpha
}
}
/// The render alpha of one streamed model instance this frame — pure, so the composition is pinned
/// by tests without an ECS (the `model_fade` pattern):
///
/// - the **appear** ramp: none ⇒ opaque; pending ⇒ 0 (the entity is not being shown yet, so its
/// effects must not be either — this is the login symptom); live ⇒ the cubic ramp,
/// - × the **despawn** ramp (our stream-out look, 0032/0067) once armed,
/// - × the **self-avatar** zoom feather, which applies to the player's own body alone.
/// - × the **self-avatar** zoom feather, which applies to the player's own body alone,
/// - × the **aura** alpha (`crate::aura_visual` — stealth's 0.3, invisibility's 0.5, …): the ramped
/// CharProc-14 product. In the reference this is not a separate channel at all — the aura
/// recompute drives the SAME `StartAlphaFade` slot the appear-fade uses (`0x60d180` →
/// `0x614f80` → `obj+0xf4` → `+0x180` → `+0x19c` → `emitter+0x1a8`), which is exactly why a
/// stealthed unit's weapon-glow particles and ribbons dim with it.
///
/// The product is the reference's own shape: it multiplies the transition alpha into the same
/// `+0x180` slot rather than keeping a second channel.
@@ -175,6 +233,7 @@ pub fn model_render_alpha(
appear: Option<UnitAppearFade>,
despawn_started: Option<f32>,
self_fade: f32,
aura: f32,
) -> f32 {
let appear = match appear {
None => 1.0,
@@ -186,7 +245,7 @@ pub fn model_render_alpha(
let despawn = despawn_started.map_or(1.0, |started| {
fade_alpha(1.0, 0.0, (now - started) / APPEAR_FADE_SECS)
});
(appear * despawn * self_fade).clamp(0.0, 1.0)
(appear * despawn * self_fade * aura).clamp(0.0, 1.0)
}
/// Publish [`ModelAlpha`] on every streamed object each frame. Runs in `PostUpdate`, after every
@@ -208,13 +267,14 @@ pub(crate) fn publish_model_alpha(
Option<&UnitAppearFade>,
Option<&DespawnFade>,
Has<crate::net::SelfPlayer>,
Option<&crate::aura_visual::AuraNodes>,
Option<&mut ModelAlpha>,
),
With<crate::net::NetEntity>,
>,
) {
let now = time.elapsed_secs();
for (entity, appear, despawn, is_self, current) in &mut units {
for (entity, appear, despawn, is_self, aura, current) in &mut units {
let alpha = model_render_alpha(
now,
appear.copied(),
@@ -225,6 +285,11 @@ pub(crate) fn publish_model_alpha(
} else {
1.0
},
// The aura ramp ticked this frame in Update (`apply_aura_alpha` — this system runs
// PostUpdate), so `current` is fresh. `self_fade()` above is the bare zoom feather
// (the mesh-side writer folds the aura factor separately), so this is not a double
// application on the self body.
crate::aura_visual::root_alpha(aura),
);
match current {
Some(mut c) => {
@@ -484,6 +549,13 @@ pub struct FadeMaterials {
/// this so its light stays the room's probe through the feather instead of jumping to the
/// exterior twin's lit-outdoor intensity (0355). `None` for parts without a bake variant.
pub bake_blend: Option<Handle<WowModelMaterial>>,
/// The depth-prime twin material ([`crate::model_render::zfill_material`] — the reference's
/// `M2UseZFill` clone, wow-re `m2-blend-promotion-zfill.md` §4, decision 0831). While this
/// part's instance alpha sits in `(0, 1)`, [`sync_zfill_twins`] keeps a colour-masked,
/// z-writing child mesh alive on it, drawn before the model's colour parts — one blended layer
/// everywhere, no self-overlap darkening. `None` for a batch whose material disables
/// z-write/z-test (the reference's own twin gate).
pub zfill: Option<Handle<WowModelMaterial>>,
}
impl FadeMaterials {
@@ -598,6 +670,57 @@ fn arm_despawn_descendants(
mod tests {
use super::*;
/// The `0x714000` recursion as a lookup (decision 0833). A weapon's enchant glow is **two**
/// links from the body wearing it — glow → item → wearer — and the director's report is what
/// happens when a lane has no expression for that at all: the glow's particles blazed at full
/// strength over a character that was still at alpha 0, because the site that spawned them
/// could name only its immediate host and was asked for the far end of the chain.
///
/// The `dimmed` case pins the composition as the reference's **product** (own × parent's), not
/// a pick of one — that is what makes a fading item on a fading wearer behave.
#[test]
fn an_attached_models_alpha_composes_through_the_whole_chain() {
use bevy::ecs::system::SystemState;
let mut world = World::new();
let wearer = world.spawn(ModelAlpha(0.25)).id();
let item = world.spawn(ParentModel(wearer)).id();
let glow = world.spawn(ParentModel(item)).id();
let dimmed = world.spawn((ModelAlpha(0.5), ParentModel(wearer))).id();
let loose = world.spawn_empty().id();
let gone = world.spawn(ParentModel(wearer)).id();
world.despawn(gone);
let mut state: SystemState<ModelAlphas> = SystemState::new(&mut world);
let alphas = state.get(&world);
assert_eq!(alphas.get(wearer), 0.25, "the unit's own");
assert_eq!(alphas.get(item), 0.25, "an item takes its wearer's");
assert_eq!(alphas.get(glow), 0.25, "…and its glow takes the item's");
assert_eq!(alphas.get(dimmed), 0.125, "own × parent's — a product");
assert_eq!(alphas.get(loose), 1.0, "chained to nothing ⇒ opaque");
assert_eq!(
alphas.get(gone),
1.0,
"a despawned instance never gates a draw"
);
}
/// The cycle backstop: [`MAX_MODEL_CHAIN`] bounds the walk, so a link that somehow closes on
/// itself costs a few lookups instead of hanging the frame. Nothing builds one today — that is
/// exactly why the guard is worth a test rather than a comment.
#[test]
fn a_looping_chain_terminates() {
use bevy::ecs::system::SystemState;
let mut world = World::new();
let a = world.spawn_empty().id();
let b = world.spawn(ParentModel(a)).id();
world.entity_mut(a).insert(ParentModel(b));
let mut state: SystemState<ModelAlphas> = SystemState::new(&mut world);
assert_eq!(state.get(&world).get(a), 1.0);
}
/// The 0200 login crash, reproduced at the seam: [`arm_appear_fade`]'s query sees the entity
/// alive, a wire despawn applies first, and only then do the system's own commands land — the
/// `try_*` contract makes that a no-op instead of a panic. `SystemState::apply` after the
@@ -650,6 +773,7 @@ mod tests {
cutout: cutout.clone(),
blend: blend.clone(),
bake_blend: Some(bake_blend.clone()),
zfill: None,
};
// Unclassified (no `InteriorLit` at all — a WMO-display part): the exterior pair.
@@ -675,6 +799,7 @@ mod tests {
// dropping the feather.
let no_twin = FadeMaterials {
bake_blend: None,
zfill: None,
..fm.clone()
};
assert_eq!(*no_twin.material_for(Some(&lit), true), blend);
@@ -838,13 +963,21 @@ mod tests {
10.0,
Some(UnitAppearFade::Pending { since: 9.0 }),
None,
1.0,
1.0
),
0.0,
"pending: the unit is not on screen, and neither are its effects"
);
let live =
|now| model_render_alpha(now, Some(UnitAppearFade::Live { started: 10.0 }), None, 1.0);
let live = |now| {
model_render_alpha(
now,
Some(UnitAppearFade::Live { started: 10.0 }),
None,
1.0,
1.0,
)
};
assert_eq!(live(10.0), 0.0, "the ramp starts at nothing");
assert!(
(live(11.0) - fade_alpha(0.0, 1.0, 0.5)).abs() < 1e-6,
@@ -852,7 +985,7 @@ mod tests {
);
assert_eq!(live(12.0), 1.0, "…and latches opaque at the duration");
assert_eq!(
model_render_alpha(10.0, None, None, 1.0),
model_render_alpha(10.0, None, None, 1.0, 1.0),
1.0,
"no fade: opaque"
);
@@ -864,21 +997,36 @@ mod tests {
#[test]
fn the_render_alpha_multiplies_its_writers() {
// Zooming to first person: the body is opaque-by-appear but the feather is 0.
assert_eq!(model_render_alpha(20.0, None, None, 0.0), 0.0);
assert!((model_render_alpha(20.0, None, None, 0.5) - 0.5).abs() < 1e-6);
assert_eq!(model_render_alpha(20.0, None, None, 0.0, 1.0), 0.0);
assert!((model_render_alpha(20.0, None, None, 0.5, 1.0) - 0.5).abs() < 1e-6);
// Streaming out: the despawn ramp eases the same channel to nothing.
assert_eq!(model_render_alpha(10.0, None, Some(10.0), 1.0), 1.0);
assert_eq!(model_render_alpha(12.0, None, Some(10.0), 1.0), 0.0);
assert_eq!(model_render_alpha(10.0, None, Some(10.0), 1.0, 1.0), 1.0);
assert_eq!(model_render_alpha(12.0, None, Some(10.0), 1.0, 1.0), 0.0);
// Both at once stay a product, and the result can never leave [0, 1].
let both = model_render_alpha(
11.0,
Some(UnitAppearFade::Live { started: 10.0 }),
Some(10.0),
0.5,
1.0,
);
assert!((0.0..=1.0).contains(&both) && both > 0.0);
}
/// **The stealth-particle symptom, at its cause**: the aura CharProc alpha (stealth 0.3,
/// invisibility 0.5) is a factor of the SAME render-alpha slot in the reference (`0x60d180`'s
/// recompute drives the same `StartAlphaFade` the appear-fade uses), which is what carries a
/// stealthed rogue's weapon-enchant glow and kit particles down with the body. Before this
/// factor existed, `ModelAlpha` stayed 1.0 under stealth and every emitter burned at full
/// brightness beside a 30 %-alpha body.
#[test]
fn the_aura_alpha_is_a_factor_of_the_effect_render_alpha() {
assert!((model_render_alpha(20.0, None, None, 1.0, 0.3) - 0.3).abs() < 1e-6);
// …and it composes with the other writers as a product, never a replacement.
assert!((model_render_alpha(20.0, None, None, 0.5, 0.3) - 0.15).abs() < 1e-6);
assert_eq!(model_render_alpha(20.0, None, Some(20.0), 1.0, 0.3), 0.3);
}
#[test]
fn unit_fade_retires_after_the_appear_duration() {
// The join marker itself doesn't retire (that's `retire_unit_appear_fade`, an ECS system), but
+295
View File
@@ -0,0 +1,295 @@
//! Paced model render-form building — the model-lane twin of the tile furnisher (decision 0834,
//! closing 0832's named residual).
//!
//! The M2/WMO loaders ship **geometry, no meshes** (`ModelSubmesh::geometry`): a loader-built
//! labeled sub-asset lands the instant its decode completes, and the render world ingests the
//! whole model — a city root's thousands of group batches, a fresh row's hundreds of doodads —
//! in ONE frame. Measured at the Stormwind line (pre-0834): 2000–3200 mesh assets per crossing,
//! 44–119 ms wall on the worst frame, on every crossing including re-entries. No budget
//! downstream of a load can spread work the loader has already packaged, so the build lives
//! here: consumers `require()` the forms they need, and [`furnish_model_forms`] builds a bounded
//! amount per frame while live — nearest requester first — uncapped behind the loading cover.
//!
//! One [`Entry`] per loaded model asset holds the built handles; every instance of the model
//! shares them (the build runs once per asset, not per placement). The **static** form is
//! `RENDER_WORLD`-only with its `Aabb` computed at build time (the exterior cull fails open on a
//! missing bound — 0832's rule); the **skinned** twin keeps main-world data because the mouseover
//! picker CPU-skins its vertices (`target::hover`), and it is built only for lanes that rig —
//! which retires the loader's old always-built, mostly-unused twin (the 0019 deferral).
use std::collections::HashMap;
use std::time::Instant;
use benilla_assets::{
submesh_to_skinned_mesh, submesh_to_static_mesh, M2Model, ModelSubmesh, WmoModel,
};
use bevy::asset::AssetId;
use bevy::camera::primitives::{Aabb, MeshAabb};
use bevy::prelude::*;
use crate::player::Player;
/// Request bit: the static (unskinned) render form.
pub(crate) const WANT_STATIC: u8 = 1;
/// Request bit: the skinned twin (rigged lanes only).
pub(crate) const WANT_SKINNED: u8 = 2;
/// Per-frame build budget while live, in submeshes. The companion vertex budget below is the
/// real governor for big WMO group batches; this cap bounds the per-mesh fixed cost (asset add +
/// extract + prepare) the Stormwind leg measured dominating at ~3000 small doodad meshes per
/// crossing. At 96/frame a whole city crossing's ~3000 meshes land in ~0.5 s of frames — inside
/// the two-tile fog margin the 5×5 window gives a first contact.
const MESH_BUDGET: usize = 96;
/// Per-frame build budget while live, in vertices — the governor for the handful of huge group
/// batches (tens of thousands of vertices each). A single submesh may exceed it alone; it then
/// lands alone in its frame (a submesh is the atomic unit — at least one always builds, so
/// progress is guaranteed).
const VERT_BUDGET: usize = 16_384;
/// A loaded model asset the forms cache keys by — the two model kinds share one cache because
/// every consumer lane (placements, entities, markers, booths) handles both.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum ModelKey {
M2(AssetId<M2Model>),
Wmo(AssetId<WmoModel>),
}
impl From<&Handle<M2Model>> for ModelKey {
fn from(h: &Handle<M2Model>) -> Self {
Self::M2(h.id())
}
}
impl From<&Handle<WmoModel>> for ModelKey {
fn from(h: &Handle<WmoModel>) -> Self {
Self::Wmo(h.id())
}
}
/// One model's built render forms + its build cursors (`stat.len()` / `skin.len()` — resumed
/// across frames, like a tile's cell cursor).
#[derive(Default)]
struct Entry {
/// The static form per submesh, with the `Aabb` computed at build (`None` = degenerate
/// geometry): consumers insert it explicitly — `RENDER_WORLD` meshes race Bevy's
/// `calculate_bounds`, and the exterior cull fails open on a missing bound.
stat: Vec<(Handle<Mesh>, Option<Aabb>)>,
/// The skinned twin per submesh (rigged lanes only; main-world data kept for the picker).
skin: Vec<Handle<Mesh>>,
/// Kinds ever requested (`WANT_*` bits). Sticky: demand for a form doesn't vanish because
/// one requester despawned mid-build — the entry frees with the asset.
want: u8,
/// Kinds fully built.
done: u8,
/// This frame's most urgent requester (lower = sooner), reset after each furnish pass so a
/// vanished requester stops hoisting its model.
priority: i32,
}
/// The per-asset render-form cache. Entries are created by [`Self::require`], built by
/// [`furnish_model_forms`], and dropped when the model asset itself leaves the store (the mesh
/// handles drop with them; instances that already spawned keep theirs via `Mesh3d`).
#[derive(Resource, Default)]
pub(crate) struct ModelForms {
entries: HashMap<ModelKey, Entry>,
}
/// A borrowed view of one model's built forms for the assembler, index-parallel with the model's
/// submeshes: the static handle + build-time `Aabb` per batch, plus the skinned twins when the
/// lane rigs.
#[derive(Clone, Copy)]
pub(crate) struct FormSlices<'a> {
pub(crate) stat: &'a [(Handle<Mesh>, Option<Aabb>)],
pub(crate) skin: Option<&'a [Handle<Mesh>]>,
}
impl ModelForms {
/// Record that a consumer needs `kinds` of this model's forms, at `priority` (lower =
/// sooner; pass the requester's tile/chunk distance). Returns `true` when every requested
/// kind is already built — the consumer's gate, polled the same way it polls the model
/// asset itself.
pub(crate) fn require(&mut self, key: ModelKey, kinds: u8, priority: i32) -> bool {
let e = self.entries.entry(key).or_default();
e.want |= kinds;
e.priority = e.priority.min(priority);
e.done & kinds == kinds
}
/// The built static forms (handle + build-time `Aabb` per submesh), or `None` until
/// [`Self::require`]`(…, WANT_STATIC, …)` has returned `true`.
pub(crate) fn static_meshes(&self, key: ModelKey) -> Option<&[(Handle<Mesh>, Option<Aabb>)]> {
let e = self.entries.get(&key)?;
(e.done & WANT_STATIC != 0).then_some(e.stat.as_slice())
}
/// The built skinned twins, or `None` until built.
pub(crate) fn skinned_meshes(&self, key: ModelKey) -> Option<&[Handle<Mesh>]> {
let e = self.entries.get(&key)?;
(e.done & WANT_SKINNED != 0).then_some(e.skin.as_slice())
}
/// Build every requested-and-missing form of one model NOW, uncapped — the booth/glue/marker
/// lanes: one small model at a time, usually behind a screen whose job is to absorb exactly
/// this. The streaming lanes must go through [`Self::require`] + the paced furnisher instead.
pub(crate) fn ensure_now(
&mut self,
key: ModelKey,
kinds: u8,
submeshes: &[ModelSubmesh],
meshes: &mut Assets<Mesh>,
) {
let e = self.entries.entry(key).or_default();
e.want |= kinds;
build_entry(e, kinds, submeshes, meshes, usize::MAX, &mut 0);
}
/// Drop one model's entry (its asset left the store).
fn forget(&mut self, key: ModelKey) {
self.entries.remove(&key);
}
/// Drop everything — leaving the world (`release_world`), where the per-asset `Unused`
/// events can no longer reach the world-live-gated furnisher.
pub(crate) fn clear(&mut self) {
self.entries.clear();
}
}
/// Advance one entry's requested builds, bounded by `budget` submeshes and [`VERT_BUDGET`]
/// vertices (via `verts`, shared across entries within a frame). Returns how many submeshes were
/// built. Marks a kind done when its cursor has walked every submesh.
fn build_entry(
e: &mut Entry,
kinds: u8,
submeshes: &[ModelSubmesh],
meshes: &mut Assets<Mesh>,
budget: usize,
verts: &mut usize,
) -> usize {
let mut built = 0usize;
if kinds & WANT_STATIC != 0 && e.done & WANT_STATIC == 0 {
while e.stat.len() < submeshes.len() {
if built >= budget || *verts >= VERT_BUDGET {
return built;
}
let geo = &submeshes[e.stat.len()].geometry;
let mesh = submesh_to_static_mesh(geo);
let aabb = mesh.compute_aabb();
*verts += geo.positions.len();
e.stat.push((meshes.add(mesh), aabb));
built += 1;
}
e.done |= WANT_STATIC;
}
if kinds & WANT_SKINNED != 0 && e.done & WANT_SKINNED == 0 {
while e.skin.len() < submeshes.len() {
if built >= budget || *verts >= VERT_BUDGET {
return built;
}
let geo = &submeshes[e.skin.len()].geometry;
*verts += geo.positions.len();
e.skin.push(meshes.add(submesh_to_skinned_mesh(geo)));
built += 1;
}
e.done |= WANT_SKINNED;
}
built
}
/// Build requested model forms, [`MESH_BUDGET`]/[`VERT_BUDGET`] per frame while live — most
/// urgent requester first — uncapped behind the loading cover (entry/teleport/world-stale), which
/// exists to absorb exactly that burst. At least one submesh always builds, so progress is
/// guaranteed. Frees an entry when its model asset leaves the store.
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(crate) fn furnish_model_forms(
mut forms: ResMut<ModelForms>,
m2s: Res<Assets<M2Model>>,
wmos: Res<Assets<WmoModel>>,
mut meshes: ResMut<Assets<Mesh>>,
mut activity: ResMut<crate::perf::StreamActivity>,
player: Option<Res<Player>>,
mut m2_events: MessageReader<AssetEvent<M2Model>>,
mut wmo_events: MessageReader<AssetEvent<WmoModel>>,
) {
let t0 = Instant::now();
// The free path: a model asset leaving the store (or replaced by a reload) drops its forms —
// the mesh handles' last cache-side strong refs go with it, so the GPU copies free once the
// last spawned instance is gone too. `Unused` fires for untracked (`RENDER_WORLD`-extracted)
// and tracked assets alike, exactly once (0832's counter rule).
for ev in m2_events.read() {
if let AssetEvent::Unused { id }
| AssetEvent::Removed { id }
| AssetEvent::Modified { id } = ev
{
forms.forget(ModelKey::M2(*id));
}
}
for ev in wmo_events.read() {
if let AssetEvent::Unused { id }
| AssetEvent::Removed { id }
| AssetEvent::Modified { id } = ev
{
forms.forget(ModelKey::Wmo(*id));
}
}
let live = player
.as_deref()
.is_some_and(|p| p.active && !p.settling && !p.world_stale);
let cap = if live { MESH_BUDGET } else { usize::MAX };
// The frame's work list: entries with a requested kind still unbuilt, most urgent first.
let mut pending: Vec<(i32, ModelKey)> = forms
.entries
.iter()
.filter(|(_, e)| e.done & e.want != e.want)
.map(|(&k, e)| (e.priority, k))
.collect();
if pending.is_empty() {
return;
}
pending.sort_unstable_by_key(|(p, _)| *p);
let mut built = 0usize;
let mut verts = 0usize;
for (_, key) in pending {
if built >= cap || verts >= VERT_BUDGET {
break;
}
// The asset can lag its request (still decoding) or predecease it (dropped mid-build);
// both just skip — the entry builds when the asset lands, or frees on its event above.
let submeshes: &[ModelSubmesh] = match key {
ModelKey::M2(id) => match m2s.get(id) {
Some(m) => &m.submeshes,
None => continue,
},
ModelKey::Wmo(id) => match wmos.get(id) {
Some(m) => &m.submeshes,
None => continue,
},
};
let Some(e) = forms.entries.get_mut(&key) else {
continue;
};
let want = e.want;
built += build_entry(e, want, submeshes, &mut meshes, cap - built, &mut verts);
e.priority = i32::MAX; // re-asserted by next frame's require() calls
}
activity.model_meshes_built += built as u32;
activity.mfurnish_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
#[cfg(test)]
mod tests {
/// The cap's arithmetic, kept honest in one place (the furnish.rs test's model twin): the
/// Stormwind crossing's measured ~3200 mesh burst must land in well under a second of frames
/// at 60 Hz — inside the fog margin a 5×5-window first contact gives.
#[test]
fn a_city_crossing_furnishes_in_under_a_second() {
let crossing_meshes: usize = 3200;
let frames = crossing_meshes.div_ceil(super::MESH_BUDGET);
assert!(
frames <= 40,
"a city crossing's models must land within ~2/3 s at 60 Hz, got {frames} frames"
);
}
}
+103 -2
View File
@@ -62,6 +62,11 @@ pub(crate) struct MatKey {
sidn: Option<[u8; 3]>,
/// The WMO MOMT WINDOW flag — an interior-group batch on the brighter midpoint light.
window: bool,
/// This material is a **depth-prime twin** ([`zfill_material`] — the reference's `M2UseZFill`
/// clone command, wow-re `m2-blend-promotion-zfill.md` §4): colour writes masked off, blend off,
/// z-write on, drawn before its model's colour batches. Its own key axis so a twin can never
/// dedupe onto a colour material.
zfill: bool,
}
/// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity
@@ -119,8 +124,10 @@ impl ShadeSel {
pub(crate) type MaterialCache = crate::art_scope::SpatialCache<MatKey, Handle<WowModelMaterial>>;
/// Build (or fetch the deduped) [`WowModelMaterial`] for a model batch: a `StandardMaterial` base
/// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant` is
/// the `AlphaMode::Blend` twin used by the doodad distance-fade feather pass (entities pass `false`).
/// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant`
/// marks the `AlphaMode::Blend` twin every feather pass rides — the doodad distance fade AND the
/// entity appear/despawn/aura fades (`entities::display` builds its twins with it too); steady
/// materials pass `false`. `specialize` keys depth-write-ON on this bit for the twin.
#[allow(clippy::too_many_arguments)]
pub(crate) fn model_material(
cache: &mut MaterialCache,
@@ -168,6 +175,7 @@ pub(crate) fn model_material(
wmo_class,
sidn,
window,
zfill: false,
};
if let Some(h) = cache.fetch(&key) {
return h;
@@ -330,6 +338,99 @@ pub(crate) fn model_material(
handle
}
/// `clutter_fade.z` marker bit 9: this material is a depth-prime twin ([`zfill_material`]).
pub(crate) const ZFILL_MARKER: u16 = 1 << 9;
/// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is
/// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all**
/// of a fading model's twins draw before **any** of its colour parts — the reference achieves the
/// same by tying every command of one instance to a single sort key (`cmd+0x14 = model+0x84`) and
/// putting twins first inside the tie (`m2-blend-promotion-zfill.md` §4/§6). 8 yd covers every
/// humanoid and mount; the residue (a model taller than 8 yd may keep a little self-overlap
/// darkening at its extremities, and transparent scene content sorting within 8 yd behind a fading
/// body draws after the prime and is depth-clipped where the body covers it) is recorded in
/// decision 0831. The same field doubles as the rasterizer depth bias in relative ULPs — at −8 that
/// is ~2⁻²⁰ relative, and its direction (twin marginally farther) only makes the colour pass's
/// GreaterEqual test safer against cross-pipeline ULP noise.
pub(crate) const ZFILL_SORT_BIAS: f32 = -8.0;
/// Build (or fetch) the **depth-prime twin** material for one fadeable entity batch — the
/// reference's `M2UseZFill` clone (wow-re `m2-blend-promotion-zfill.md` §4, VERIFIED): while a
/// model draws translucent (`0 < A < 1`), a colour-masked, blend-off, z-writing copy of each of its
/// depth-writing batches draws first, so every colour fragment behind the model's own nearest
/// surface fails the depth test — one blended layer everywhere, no self-overlap darkening.
///
/// `cutout` mirrors what the part's COLOUR pass discards, and must keep mirroring it: a twin that
/// writes depth where the colour pass discards leaves an invisible depth wall inside the cutout
/// holes. Opaque/AlphaKey sources ride the fade twin's hard 224/255 cutout (`model_flags.y`);
/// authored-Blend sources discard nothing (their colour pass is the plain blend material).
///
/// Everything that shapes only the *colour* (lighting lane, shade, fog, tint) is canonicalized so
/// twins dedupe maximally — the `WOW_ZFILL` fragment returns before any of it.
pub(crate) fn zfill_material(
cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
texture: Option<Handle<Image>>,
two_sided: bool,
cutout: bool,
light: &Buffer,
) -> Handle<WowModelMaterial> {
let key = MatKey {
texture: texture.as_ref().map(Handle::id),
blend: if cutout {
ModelBlend::AlphaTest
} else {
ModelBlend::Blend
},
two_sided,
is_wmo: false,
is_interior: false,
is_emissive: false,
is_additive: false,
fade_variant: cutout,
no_depth_write: false,
no_depth_test: false,
fog_policy: benilla_formats::FogPolicy::Scene,
shade: ShadeSel::Lit,
wmo_batch_order: 0,
uv_anim: None,
rgb_anim: None,
wmo_class: None,
sidn: None,
window: false,
zfill: true,
};
if let Some(h) = cache.fetch(&key) {
return h;
}
let cull_mode = if two_sided { None } else { Some(Face::Back) };
let base = StandardMaterial {
base_color_texture: texture,
// Blend keeps the twin in the transparent pass, where the sort bias can place it before
// the colour parts; `specialize` masks the colour writes off and turns blend off.
alpha_mode: AlphaMode::Blend,
depth_bias: ZFILL_SORT_BIAS,
double_sided: two_sided,
cull_mode,
..default()
};
let handle = materials.add(ExtendedMaterial {
base,
extension: WowModelExt {
clutter_fade: Vec4::new(0.0, 0.0, f32::from(ZFILL_MARKER), 0.0),
// `model_flags.y` (the fade-twin bit) drives the shader's hard 224/255 cutout — set
// exactly when the colour pass discards, so depth and colour coverage agree.
model_flags: Vec4::new(0.0, if cutout { 1.0 } else { 0.0 }, 0.0, 0.0),
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
sidn: Vec4::ZERO,
light_buf: light.clone(),
},
});
cache.insert(key, handle.clone());
handle
}
/// Replace the fog-policy bits (4-6) inside a packed `clutter_fade.z` marker word, preserving
/// every other pipeline marker — bits 0-3 AND the Mod/Mod2x multiply markers in bits 7-8
/// (decision 0528). The mask lives here, beside the packer above: the portrait booth's rig twin
+22 -2
View File
@@ -41,9 +41,12 @@ pub(super) struct ModelInstance {
/// Grow + position each model-particle emitter's instance pool from its freshly-simulated
/// pool. Runs after [`super::sim::simulate_particles`] in the same set, so instances land on
/// this frame's positions (the anchored-exactness rule).
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn update_model_particles(
mut commands: Commands,
models: Res<Assets<M2Model>>,
mut forms: ResMut<crate::model_forms::ModelForms>,
mut mesh_assets: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
light: Res<crate::lighting::SharedLightBuffer>,
mut emitters: Query<&mut ParticleEmitter>,
@@ -64,14 +67,26 @@ pub(super) fn update_model_particles(
let Some(model) = models.get(&geometry) else {
continue; // still loading — particles simulate meanwhile, nothing draws yet
};
// The geometry model's render forms, built NOW rather than paced (decision 0834): a
// spell's visual must not lag its cast, and a particle-geometry model is a handful of
// tiny batches — the booth-lane exception, not the streaming rule.
let key = crate::model_forms::ModelKey::from(&geometry);
forms.ensure_now(
key,
crate::model_forms::WANT_STATIC,
&model.submeshes,
&mut mesh_assets,
);
// Grow the pool to the live count (bounded).
let rung = emitter.owner_rung();
let want = emitter.particles.len().min(MAX_INSTANCES);
while emitter.model_instances.len() < want {
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let meshes = model
.submeshes
.iter()
.map(|sub| {
.enumerate()
.map(|(pi, sub)| {
// A fresh (never-deduped) material per instance — its `tint` is mutated
// every frame by the over-life ramp, so instances must not share.
let mut throwaway = MaterialCache::default();
@@ -113,7 +128,12 @@ pub(super) fn update_model_particles(
}
let entity = commands
.spawn((
Mesh3d(sub.mesh.clone()),
Mesh3d(
stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
),
MeshMaterial3d(material.clone()),
Transform::IDENTITY,
Visibility::Hidden,
+9 -9
View File
@@ -202,8 +202,9 @@ pub(super) fn simulate_particles(
// The ground-snap probe (file 0x2000 births): terrain + WMO/doodad geometry, the walking
// collision audience.
spatial: SpatialQuery,
// The per-model render alpha an entity-owned cloud is multiplied by (decision 0827).
model_alphas: Query<&crate::model_fade::ModelAlpha>,
// The per-model render alpha an entity-owned cloud is multiplied by (decision 0827), composed
// along the attached-model chain (0833).
model_alphas: crate::model_fade::ModelAlphas,
// `Without<WorldCamera>`: the `&mut GlobalTransform` must be provably disjoint from the
// camera read above too (an emitter never rides the camera entity).
mut emitters: Query<
@@ -484,13 +485,12 @@ pub(super) fn simulate_particles(
}
// The MODEL's render alpha for this frame (decision 0827 — the reference's per-frame
// `emitter+0x1a8 = Model+0x19c` copy at `0x718960` @`0x719073`). Two disjoint sources, the
// same slot the reference writes both through: an entity-owned cloud takes its model
// instance's [`crate::model_fade::ModelAlpha`] (an item's is its WEARER's — an attached
// model inherits the parent's), and a placed doodad's takes its own distance fade, whose
// cutoff the draw-set gate above already applies as a hard stop.
*alpha = alpha_src
.and_then(|e| model_alphas.get(e).ok())
.map_or(1.0, |a| a.0)
// same slot the reference writes both through: an entity-owned cloud takes its OWN model
// instance's composed alpha ([`crate::model_fade::ModelAlphas`] walks the attached-model
// chain, so a weapon glow's reaches the item's and the item's the wearer's — 0833), and a
// placed doodad's takes its own distance fade, whose cutoff the draw-set gate above
// already applies as a hard stop.
*alpha = alpha_src.map_or(1.0, |e| model_alphas.get(e))
* fade.map_or(1.0, |f| f.distance_alpha(cam_pos));
let attach_inv = attach_rot.inverse();
// The cloud anchor (see the field doc): the model's live translation, or the last-known
+48 -27
View File
@@ -118,14 +118,21 @@ pub(crate) struct StreamActivity {
pub(crate) placement_entities_dropped: u32,
/// New ADT asset requests fired.
pub(crate) tiles_requested: u32,
/// Loaded tiles spawned (terrain mesh + placements registered + liquid + clutter).
/// Loaded tiles spawned (root + collider kicked off + placements registered + liquid + clutter).
pub(crate) tiles_spawned: u32,
/// MCNK cell meshes built + spawned by the paced furnisher (decision 0832).
pub(crate) cells_spawned: u32,
/// Model submesh render forms built by the paced model furnisher (decision 0834).
pub(crate) model_meshes_built: u32,
/// Placements (or WMO props) whose model landed and spawned.
pub(crate) placements_spawned: u32,
/// Off-thread colliders attached.
pub(crate) colliders_attached: u32,
/// Self-time of `stream_terrain` / `spawn_loaded_placements` / `finish_colliders` (ms).
/// Self-time of `stream_terrain` / `furnish_tile_cells` / `furnish_model_forms` /
/// `spawn_loaded_placements` / `finish_colliders` (ms).
pub(crate) stream_ms: f32,
pub(crate) furnish_ms: f32,
pub(crate) mfurnish_ms: f32,
pub(crate) spawn_ms: f32,
pub(crate) collider_ms: f32,
}
@@ -138,6 +145,8 @@ impl StreamActivity {
+ self.placements_dropped
+ self.tiles_requested
+ self.tiles_spawned
+ self.cells_spawned
+ self.model_meshes_built
+ self.placements_spawned
+ self.colliders_attached
> 0
@@ -169,10 +178,11 @@ struct StreamTrace {
prev_cpu_secs: Option<f64>,
}
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,spawn_ms,collider_ms,\
tiles_dropped,pl_dropped,pl_ents_dropped,tiles_req,\
tiles_spawned,pl_spawned,attached,adt_freed,meshes_freed,\
images_freed\n";
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnish_ms,mfurnish_ms,\
spawn_ms,collider_ms,tiles_dropped,pl_dropped,pl_ents_dropped,\
tiles_req,tiles_spawned,cells_spawned,mmeshes_built,pl_spawned,\
attached,adt_freed,meshes_freed,images_freed,adt_added,\
meshes_added,images_added\n";
#[allow(clippy::too_many_arguments)]
fn trace_stream(
@@ -196,31 +206,38 @@ fn trace_stream(
_ => String::new(),
};
trace.prev_cpu_secs = cpu_now;
let freed = |n: usize| n as u32;
let removed_adt = freed(
adt_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
.count(),
);
let removed_mesh = freed(
mesh_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
.count(),
);
let removed_img = freed(
image_events
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
.count(),
);
// One pass per reader: `Added` beside `Unused` — B181's recurring spike was the free wave's
// family, but the first-contact head frame is the ADD wave (a landed tile's cell meshes +
// texture arrays hitting the render world's prepare at once), invisible until counted.
//
// The freed columns count `Unused` (last strong handle dropped), not `Removed`: a
// `RENDER_WORLD`-only asset (chunk-cell meshes, the tile arrays — decision 0832) leaves the
// main store at *extract* via the untracked path, so `Removed` never fires for it; `Unused`
// is the release signal both usage kinds emit exactly once, and it is what actually frees
// the GPU copy.
fn count_events<A: bevy::asset::Asset>(
events: &mut MessageReader<bevy::asset::AssetEvent<A>>,
) -> (u32, u32) {
let (mut added, mut unused) = (0u32, 0u32);
for e in events.read() {
match e {
bevy::asset::AssetEvent::Added { .. } => added += 1,
bevy::asset::AssetEvent::Unused { .. } => unused += 1,
_ => {}
}
}
(added, unused)
}
let (added_adt, removed_adt) = count_events(&mut adt_events);
let (added_mesh, removed_mesh) = count_events(&mut mesh_events);
let (added_img, removed_img) = count_events(&mut image_events);
let delta_ms = time.delta_secs() * 1000.0;
if a.any_event() || removed_adt > 0 {
if a.any_event() || removed_adt > 0 || added_adt > 0 {
trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
}
if !(a.any_event()
|| removed_adt > 0
|| added_adt > 0
|| delta_ms > TRACE_FRAME_MS
|| trace.frame <= trace.log_until)
{
@@ -230,11 +247,13 @@ fn trace_stream(
let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
}
let line = format!(
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img}\n",
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img}\n",
trace.frame,
time.elapsed_secs(),
entities.iter().len(),
a.stream_ms,
a.furnish_ms,
a.mfurnish_ms,
a.spawn_ms,
a.collider_ms,
a.tiles_dropped,
@@ -242,6 +261,8 @@ fn trace_stream(
a.placement_entities_dropped,
a.tiles_requested,
a.tiles_spawned,
a.cells_spawned,
a.model_meshes_built,
a.placements_spawned,
a.colliders_attached,
);
+27 -5
View File
@@ -469,13 +469,16 @@ pub(super) fn sync_glue_scene(
device: Res<bevy::render::renderer::RenderDevice>,
queue: Res<bevy::render::renderer::RenderQueue>,
// The scene's particle-emitter spawn wiring (decision 0539 §5) + the skin-palette table and
// its mirror registry (decision 0720) — one tuple param (the 16-SystemParam ceiling).
// its mirror registry (decision 0720) + the model-forms cache and mesh store (decision 0834)
// — one tuple param (the 16-SystemParam ceiling).
particle_assets: (
ResMut<crate::rig_palette::RigPalettes>,
ResMut<crate::rig_palette::RigPaletteMirrors>,
ResMut<crate::model_forms::ModelForms>,
ResMut<Assets<Mesh>>,
),
) {
let (mut palettes, mut mirrors) = particle_assets;
let (mut palettes, mut mirrors, mut forms, mut mesh_assets) = particle_assets;
let Some(booth) = booths.0.get(GLUE_SLOT) else {
return;
};
@@ -597,10 +600,26 @@ pub(super) fn sync_glue_scene(
rig.probe[1].w,
rig.probe[2].w,
);
// The scene model's render forms, built NOW (decision 0834): one backdrop model, behind
// the glue screen — the booth-lane exception to the paced rule.
let scene_key = scene
.handle
.as_ref()
.map(crate::model_forms::ModelKey::from)
.expect("scene.handle is Some — `model` above came from it");
forms.ensure_now(
scene_key,
crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED,
&model.submeshes,
&mut mesh_assets,
);
let stat_forms = forms.static_meshes(scene_key).unwrap_or(&[]);
let skin_forms = forms.skinned_meshes(scene_key).unwrap_or(&[]);
let scene_parts: Vec<BoothPart> = model
.submeshes
.iter()
.map(|s| {
.enumerate()
.map(|(pi, s)| {
let material = crate::model_render::model_material(
&mut cache,
&mut materials,
@@ -625,8 +644,11 @@ pub(super) fn sync_glue_scene(
&light,
);
BoothPart {
skinned: Some(s.skinned_mesh.clone()),
static_mesh: s.mesh.clone(),
skinned: skin_forms.get(pi).cloned(),
static_mesh: stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
material,
// The scene's authored per-batch material alpha (decision 0807). UI_Tauren
// alone: a 0.55 `LENSALPHA` corner vignette, a 0.99 `GROUNDSHADOW` decal, 0.15
+23 -3
View File
@@ -30,6 +30,8 @@ pub(super) fn sync_test_portraits(
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut forms: ResMut<crate::model_forms::ModelForms>,
mut mesh_assets: ResMut<Assets<Mesh>>,
) {
if !test_mode(&mut env_cache) || *test_done {
return;
@@ -42,6 +44,8 @@ pub(super) fn sync_test_portraits(
&path,
&asset_server,
&m2s,
&mut forms,
&mut mesh_assets,
&mut wow_mats,
&booth_light,
&mut test_cache,
@@ -65,6 +69,8 @@ fn bake_test(
path: &str,
asset_server: &AssetServer,
m2s: &Assets<M2Model>,
forms: &mut crate::model_forms::ModelForms,
mesh_assets: &mut Assets<Mesh>,
wow_mats: &mut Assets<WowModelMaterial>,
booth_light: &BoothLight,
cache: &mut crate::model_render::MaterialCache,
@@ -99,13 +105,24 @@ fn bake_test(
p.replace('\\', "/").to_ascii_lowercase()
))
});
// The test model's render forms, built NOW (decision 0834) — a dev harness bake, one model.
let key = crate::model_forms::ModelKey::from(&handle);
forms.ensure_now(
key,
crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED,
&model.submeshes,
mesh_assets,
);
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
let parts_against = |light: &bevy::render::render_resource::Buffer,
cache: &mut crate::model_render::MaterialCache,
wow_mats: &mut Assets<WowModelMaterial>| {
model
.submeshes
.iter()
.map(|s| {
.enumerate()
.map(|(pi, s)| {
let mat = crate::model_render::model_material(
cache,
wow_mats,
@@ -130,8 +147,11 @@ fn bake_test(
light,
);
BoothPart {
skinned: Some(s.skinned_mesh.clone()),
static_mesh: s.mesh.clone(),
skinned: skin_forms.get(pi).cloned(),
static_mesh: stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
material: mat,
// The harness parses submeshes straight off the model, so the authored alpha is
// right here — an eyeball bake should show the batch dimming the artist wrote.
+26 -4
View File
@@ -254,6 +254,8 @@ fn build_markers(
mut commands: Commands,
mut roots: Query<(Entity, &mut QuestMarkerRoot)>,
m2s: Res<Assets<M2Model>>,
mut forms: ResMut<crate::model_forms::ModelForms>,
mut mesh_assets: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
light: Res<SharedLightBuffer>,
@@ -270,6 +272,16 @@ fn build_markers(
let Some(model) = m2s.get(&marker.handle) else {
continue; // marker M2 still loading
};
// The marker's render forms, built NOW (decision 0834): two tiny models per map, on the
// booth-lane exception — a marker popping a frame late over a questgiver would be a
// regression nothing here needs.
let key = crate::model_forms::ModelKey::from(&marker.handle);
forms.ensure_now(
key,
crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED,
&model.submeshes,
&mut mesh_assets,
);
// The unit's joint set + attachment table — absent while its own model still loads (or
// forever on a boneless/model-less unit, which then simply never shows a marker).
let Some((unit, anchor)) = index
@@ -329,7 +341,9 @@ fn build_markers(
// The client arms the marker's animation at status receive — the cards' bob loop starts
// its cursor here (the same clock `face_billboards` samples).
let arm_ms = time.elapsed().as_millis() as u32;
for sub in &model.submeshes {
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
for (pi, sub) in model.submeshes.iter().enumerate() {
let material = model_material(
&mut cache,
&mut materials,
@@ -362,7 +376,12 @@ fn build_markers(
// world placement each frame; the armed loop plays the bob at the pivot.
let child = commands
.spawn((
Mesh3d(sub.mesh.clone()),
Mesh3d(
stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
),
MeshMaterial3d(material),
MeshTag(alpha_bits(1.0)),
Transform::IDENTITY,
@@ -380,9 +399,12 @@ fn build_markers(
// including the palette-full fallback (slot 0).
let use_rig = host.as_ref().is_some_and(|h| h.slot != 0);
let mesh = if use_rig {
sub.skinned_mesh.clone()
skin_forms.get(pi).cloned().unwrap_or_default()
} else {
sub.mesh.clone()
stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default()
};
let rig_tag = host
.as_ref()
+4 -6
View File
@@ -189,8 +189,9 @@ pub(crate) fn simulate_ribbons(
transforms: Query<&GlobalTransform, Without<RibbonTrail>>,
images: Res<Assets<Image>>,
mut quads: ResMut<EffectQuads>,
// The owning model's render alpha — the trail's draw gate (decision 0827).
model_alphas: Query<&crate::model_fade::ModelAlpha>,
// The owning model's render alpha — the trail's draw gate (decision 0827), composed along the
// attached-model chain (0833).
model_alphas: crate::model_fade::ModelAlphas,
// Trails belong to the world lane (no booth ribbons; a booth-parked owner's strip is eaten
// by the shader's farclip wall, exactly as on the material path).
world_cam: Query<Entity, With<WorldCamera>>,
@@ -289,10 +290,7 @@ pub(crate) fn simulate_ribbons(
// model's render alpha and drops the draw below a threshold (decision 0827). This is what
// takes your own weapon's enchant trail out of your face in first person, and keeps a
// not-yet-shown unit's trail off the screen while its body is still at alpha 0.
if alpha_src
.and_then(|e| model_alphas.get(e).ok())
.is_some_and(|a| a.0 <= 1e-3)
{
if alpha_src.is_some_and(|e| model_alphas.get(e) <= 1e-3) {
continue;
}
let n = edges.len() + usize::from(head.is_some());
+27 -2
View File
@@ -15,8 +15,8 @@ use bevy::pbr::{
};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, CompareFunction,
RenderPipelineDescriptor, SpecializedMeshPipelineError,
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, ColorWrites,
CompareFunction, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
@@ -65,6 +65,10 @@ pub struct WowModelKey {
/// orders draws for batching, not authorship, so coplanar ties were unstable (the run-to-run
/// "pale film" on buildings); the bias reproduces the authored layering deterministically.
wmo_batch_order: u16,
/// Depth-prime twin (`clutter_fade.z` bit 9 — `model_render::zfill_material`, the reference's
/// `M2UseZFill` clone command, wow-re `m2-blend-promotion-zfill.md` §4): `specialize` masks the
/// colour writes off, turns blend off, and forces depth-write ON.
zfill: bool,
}
impl From<&WowModelExt> for WowModelKey {
@@ -79,6 +83,7 @@ impl From<&WowModelExt> for WowModelKey {
modulate: markers & 0x80 != 0,
modulate2x: markers & 0x100 != 0,
wmo_batch_order: e.sun_scale.y as u16,
zfill: markers & 0x200 != 0,
}
}
}
@@ -279,6 +284,26 @@ impl MaterialExtension for WowModelExt {
});
}
}
if key.bind_group_data.zfill {
// The depth-prime twin (`model_render::zfill_material` — the reference's `M2UseZFill`
// clone, wow-re `m2-blend-promotion-zfill.md` §4): a translucent model's z-writing
// batches draw once colour-masked-off, blend-off, z-write ON, sorted before its colour
// batches (the material's negative sort bias) — so each colour fragment passes
// GreaterEqual only at the model's own nearest surface, and interior/overlapped layers
// fail. One blended layer everywhere: no self-overlap darkening on a stealthed body.
// The fragment still runs its discards (farclip, the cutout when `model_flags.y`);
// its colour output is computed and masked (an early return would be the natural
// spelling, but naga's MSL backend miscompiles the dead tail — see the shader note).
if let Some(frag) = descriptor.fragment.as_mut() {
if let Some(target) = frag.targets.get_mut(0).and_then(|t| t.as_mut()) {
target.blend = None;
target.write_mask = ColorWrites::empty();
}
}
if let Some(ds) = descriptor.depth_stencil.as_mut() {
ds.depth_write_enabled = true;
}
}
let key = &key.bind_group_data;
if key.modulate || key.modulate2x {
// The MULTIPLY blends (decision 0528, byte-verified wow-re `m2-depth-blend-state`):
+60 -106
View File
@@ -15,7 +15,6 @@
//! [`crate::clutter::ClutterPlugin`].
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
@@ -40,11 +39,18 @@ use crate::world_map::{CurrentMap, MapCatalogRes};
use crate::SPAWN_XY;
mod collider;
mod furnish;
mod queries;
mod spawn;
use collider::{finish_colliders, terrain_collider_data};
use furnish::furnish_tile_cells;
use spawn::prop_light::WmoDoodadInst;
use spawn::spawn_loaded_placements;
// The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two
// outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their
// `terrain_stream::X` paths, same as the `queries` items below.
pub(crate) use spawn::prop_light::{fold_interior_probe, PropLobeLight};
// The shared placed-model assembler + the off-thread collider build — also the WMO-gameobject
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
@@ -75,6 +81,9 @@ pub(crate) struct TerrainStreamer {
/// The map directory the loaded tiles are for (e.g. `"Azeroth"`); a change means a cross-map
/// teleport, so every tile is dropped and re-streamed for the new map.
map_dir: Option<String>,
/// The focus tile [`stream_terrain`] last streamed around — the furnisher's nearest-first
/// ordering key, published here so it never recomputes the focus ladder a second time.
focus: (i32, i32),
/// The current map's WDT tile index (decision 0476), requested with the map: ADT requests wait
/// for it and consult its `MAIN` grid — open ocean authors no tiles to ask for.
wdt: Option<Handle<WdtIndex>>,
@@ -94,10 +103,10 @@ pub(crate) struct TerrainStreamer {
const GLOBAL_WMO_UID: u32 = u32::MAX;
impl TerrainStreamer {
/// Residency counts for the debug panel's World readout: `(spawned, requested)` — tiles whose
/// terrain entity exists vs. every tile in the stream window (spawned + still loading).
/// Residency counts for the debug panel's World readout: `(spawned, requested)` — tiles fully
/// up (root + cells furnished) vs. every tile in the stream window (up + loading/furnishing).
pub(crate) fn residency(&self) -> (usize, usize) {
let spawned = self.tiles.values().filter(|t| t.entity.is_some()).count();
let spawned = self.tiles.values().filter(|t| t.furnished).count();
(spawned, self.tiles.len())
}
}
@@ -105,9 +114,19 @@ impl TerrainStreamer {
/// One loaded tile: the resident asset handle, the terrain entity, and the placement ids it references.
struct TileState {
handle: Handle<AdtTile>,
/// `None` until the `AdtTile` finishes loading and the terrain mesh entity is spawned (which is also
/// `None` until the `AdtTile` finishes loading and the terrain root entity is spawned (which is also
/// when this tile's placements are registered).
entity: Option<Entity>,
/// The tile's terrain material, made at root spawn and shared by every cell the furnisher hangs
/// off the root (one material per tile — the loader's arrays make that possible).
material: Option<Handle<TerrainMaterial>>,
/// The furnishing cursor: the next `AdtTile::chunks` index whose cell [`furnish::furnish_tile_cells`]
/// should build. Cells land a few per frame while live (decision 0832) — the mesh-asset creation
/// IS the pacing point; everything downstream (extract, prepare, upload) follows its rate.
next_cell: usize,
/// `true` once every cell is up (or cells are ablated off): the "this tile is really resident"
/// bit the loading-screen accounting reads, so a reveal never shows a root with bare ground.
furnished: bool,
/// uniqueIds of the doodad/WMO placements this tile references — registered once the tile's `AdtTile`
/// loads, refcount-released when the tile unloads.
placements: Vec<u32>,
@@ -155,81 +174,6 @@ struct Placement {
refs: u32,
}
/// One WMO doodad prop instance: its M2 handle and the **world** transform (the WMO instance
/// transform composed with the doodad's WMO-local transform), spawned once the M2 asset loads.
struct WmoDoodadInst {
handle: Handle<M2Model>,
transform: Transform,
/// Every WMO group whose MODR names this prop — the portal-cull key, so a prop is hidden with
/// the rooms it furnishes and drawn while any one of them is visible. Empty for a MODD no group
/// references (the reference never instantiates one at all; we still show it, uncullable,
/// rather than change what draws today).
groups: Arc<[u16]>,
/// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this
/// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR
/// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold
/// reference point needs the M2 bounds).
light: PropLight,
spawned: bool,
}
/// A WMO prop's placement-resolved lighting: the asset-level [`DoodadBase`] with the owning group's
/// MOLR lights already transformed to WORLD (Bevy) space — so the spawn-time SH fold needs only the
/// loaded M2's bounds (its reference point) and nothing from the WMO asset.
enum PropLight {
Exterior,
Interior {
/// `cap96(MODD.colour)` — the ambient word (0–1 RGB).
ambient: [f32; 3],
/// `floor112(MODD.colour)` — the diffuse word, committed on the fixed interior axis.
diffuse: [f32; 3],
/// The owning group's MOLR omni lights: world (Bevy) position, colour × intensity, and the
/// disk `attenStart`/`attenEnd` window (the fold's range gate).
lights: Vec<PropLobeLight>,
},
}
/// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour
/// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the
/// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict).
pub(crate) struct PropLobeLight {
pub(crate) pos: Vec3,
pub(crate) color_i: [f32; 3],
pub(crate) atten_start: f32,
pub(crate) atten_end: f32,
}
/// Fold one interior committed light into its 7-row SH probe: the ambient word + the diffuse word
/// as a directional on the FIXED interior axis + each MOLR lobe windowed by its disk
/// attenStart/attenEnd from `ref_point` (the byte-verified `0x69e1c0` falloff: d ≤ start → 1;
/// d ≥ end → excluded; else linear). One definition for both interior lanes — the MODD prop
/// (spawn-time, MODD-colour words) and the GameObject footprint (classify-time, MOCV-derived
/// words); the SH closed form itself is [`prop_probe_coeffs`].
pub(crate) fn fold_interior_probe(
ambient: [f32; 3],
diffuse: [f32; 3],
ref_point: Vec3,
lights: &[PropLobeLight],
) -> [bevy::math::Vec4; 7] {
// Toward-light, Bevy space: wow (0.30822, 0.30822, 0.9) → (−y, z, −x).
let mut lobes: Vec<(Vec3, [f32; 3])> = vec![(Vec3::new(-0.30822, 0.9, -0.30822), diffuse)];
for l in lights {
let dv = l.pos - ref_point;
let dist = dv.length();
let gain = if dist <= l.atten_start {
1.0
} else if dist >= l.atten_end || l.atten_end <= l.atten_start {
0.0
} else {
1.0 - (dist - l.atten_start) / (l.atten_end - l.atten_start)
};
if gain > 0.0 {
lobes.push((dv / dist.max(1e-4), l.color_i.map(|c| c * gain)));
}
}
crate::lighting::prop_probe_coeffs(ambient, &lobes)
}
/// **Where the world streams from**, in WoW coordinates — one rule, shared by the detailed
/// streamer and the distant WDL ring (which used to carry its own copy under a comment promising
/// they agreed).
@@ -297,6 +241,7 @@ impl Plugin for TerrainPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TerrainStreamer>()
.init_resource::<Placements>()
.init_resource::<crate::model_forms::ModelForms>()
.init_resource::<CurrentArea>()
// **The streaming chain lives in `WorldStage::Stream`** — the load-bearing half of the
// frame ordering contract (schedule.rs): the teleport snap (Input) → the streamer
@@ -312,6 +257,7 @@ impl Plugin for TerrainPlugin {
(
finish_colliders,
stream_terrain,
furnish_tile_cells,
spawn_loaded_placements,
sync_interior_volumes,
)
@@ -323,6 +269,17 @@ impl Plugin for TerrainPlugin {
// would leave three systems walking empty queries to prove it every frame.
.run_if(crate::schedule::world_is_live),
)
// The model-forms furnisher (decision 0834) runs UNGATED, unlike the chain above: the
// glue screens' character preview requests forms pre-world (`update_display_models`
// serves the glue stage too), and a world-live gate would starve it forever. Ordered
// before the placement spawner so a form completed this frame can land this frame;
// everything it does is demand-driven, so with no requests it is a no-op.
.add_systems(
Update,
crate::model_forms::furnish_model_forms
.in_set(WorldStage::Stream)
.before(spawn_loaded_placements),
)
// Leaving the world releases it. Without this, a `/logout` back to character select
// would leave the whole streamed world resident and unowned — the gate above stops it
// being *maintained*, which is precisely what makes an abandoned world immortal.
@@ -472,6 +429,7 @@ fn stream_terrain(
let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0);
let (cx, cy) = world_to_tile(center[0], center[1]);
let (cx, cy) = (cx as i32, cy as i32);
state.focus = (cx, cy);
// Desired set: the (2r+1)² square around the focus, clamped to the 64×64 tile grid — and
// filtered to tiles the WDT says exist (open ocean authors none). The filter also fixes the
@@ -571,6 +529,9 @@ fn stream_terrain(
handle: asset_server
.load(format!("mpq://World/Maps/{dir}/{dir}_{tx}_{ty}.adt")),
entity: None,
material: None,
next_cell: 0,
furnished: false,
placements: Vec::new(),
liquid: Vec::new(),
clutter: Vec::new(),
@@ -612,27 +573,13 @@ fn stream_terrain(
// exterior-scene cull needs (decision 0780; a 533 yd slab the camera stands on intersects
// every portal window, so it could never be hidden), and it also gives Bevy's own frustum cull
// something smaller than half a kilometre to reject. Despawning the root takes the cells.
//
// The cells themselves do NOT spawn here (decision 0832): a whole row of tiles finishes
// decoding in one frame, and spawning ~1300 loader-built cell meshes at once handed the
// render world their entire extract/prepare/upload as one 80–105 ms (process-CPU) frame —
// the B181 first-contact spike. `furnish_tile_cells` (chained right after this system)
// builds them from `AdtTile::chunks` + `shading` a few per frame while live.
let mut tile_ent = commands.spawn((Transform::IDENTITY, Visibility::default()));
// Ablation switch (`WOW_NO_TILE_CELLS=1`): spawn the tile — asset residency, collider,
// placements, liquid, clutter — without its per-chunk cell entities, isolating the
// render-entity lane from the asset lane when measuring a streaming cost. The B181 carve
// ran on it (a crossing's spike survived zero cells, exonerating the entity lane); the
// `WOW_NO_LIQUID`/`WOW_NO_PARTICLES` family's pattern.
if std::env::var("WOW_NO_TILE_CELLS").is_err() {
tile_ent.with_children(|cells| {
for mesh in &adt.chunk_meshes {
cells.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::IDENTITY, // chunk meshes are already in absolute world coords
// ADT terrain is exterior scene: from inside a WMO a cell draws only through a
// portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see
// `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
}
});
}
if let Some((verts, tris)) = collider_data {
// `GroundDecalSurface`: terrain receives the selection ring (see `crate::collision`).
// `PickOccluder`: terrain clamps the mouse pick (the reference's world trace).
@@ -643,6 +590,7 @@ fn stream_terrain(
));
}
tile.entity = Some(tile_ent.id());
tile.material = Some(material);
activity.tiles_spawned += 1;
// Register this tile's doodad/WMO placements (deduped + refcounted by uniqueId across tiles).
@@ -702,13 +650,14 @@ fn stream_terrain(
// focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting
// for ground that isn't there.
if let Some(p) = load_progress.as_mut() {
let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.entity.is_some());
// "Spawned" means FURNISHED (root + every cell): the furnisher is uncapped while the
// cover is up, so this costs entry nothing — but it keeps a reveal from ever showing a
// root whose ground is still landing (`furnished` lags root spawn by one frame here,
// since the furnisher runs after this system in the same chain).
let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.furnished);
p.total = desired.len();
p.ready = desired.iter().filter(|c| spawned(c)).count();
p.focus_resident = state
.tiles
.get(&(cx, cy))
.is_none_or(|t| t.entity.is_some());
p.focus_resident = state.tiles.get(&(cx, cy)).is_none_or(|t| t.furnished);
// The focus neighbourhood's placements join the accounting (bar + scene term). A placement
// is *up* once its own model spawned AND its WMO props (each an M2 arriving on its own
// schedule) have — the furniture the reveal would otherwise pop in. Placements of tiles not
@@ -722,7 +671,7 @@ fn stream_terrain(
continue; // off-grid or WDT says no tile — nothing to wait for
}
match state.tiles.get(&c) {
Some(t) if t.entity.is_some() => {
Some(t) if t.furnished => {
for uid in &t.placements {
if let Some(pl) = placements.by_id.get(uid) {
let up = pl.spawned && pl.doodads.iter().all(|d| d.spawned);
@@ -896,6 +845,7 @@ fn release_world(
mut commands: Commands,
mut state: ResMut<TerrainStreamer>,
mut placements: ResMut<Placements>,
mut forms: ResMut<crate::model_forms::ModelForms>,
mut progress: Option<ResMut<WorldLoadProgress>>,
mut activity: ResMut<crate::perf::StreamActivity>,
) {
@@ -906,6 +856,10 @@ fn release_world(
"terrain: leaving the world — releasing {} tiles",
state.tiles.len()
);
// The model-form cache frees per asset on `Unused` events; leaving the world drops the whole
// cache in one deterministic sweep instead — nothing world-scoped may survive a logout, and
// an entry whose asset another holder keeps resident would otherwise pin its meshes forever.
forms.clear();
drop_streamed_world(&mut commands, &mut state, &mut placements, &mut activity);
state.map_dir = None;
state.wdt = None;
@@ -0,0 +1,138 @@
//! Paced per-cell tile furnishing — the fix for B181's *first-contact* spike (decision 0832).
//!
//! A landed tile's ~256 MCNK cell meshes used to arrive as loader-built sub-assets: the whole
//! set (and, decodes bunching, usually the whole 5-tile row's ~1300) hit the render world's
//! extract/prepare/upload in ONE frame — 80–105 ms of process CPU at the Emerald Dream pin,
//! 120–240 ms at Undercity, on every first crossing of a tile line. No budget downstream of the
//! loader could spread work the loader had already packaged, so the build moved here: each cell's
//! mesh is made from the decoded [`AdtTile::chunks`] + [`AdtTile::shading`]
//! ([`benilla_assets::chunk_to_mesh`]), a bounded number per frame while live, nearest tile
//! first. Mesh-asset creation is the pacing point; everything downstream follows its rate.
//!
//! While the body is **not** live (entry, teleport, world-stale) the cap is off: the loading
//! screen exists to absorb exactly that burst, and the residency the settle release reads counts
//! *furnished* tiles, so the cover never lifts onto bare ground.
use std::time::Instant;
use benilla_assets::{chunk_to_mesh, AdtTile};
use bevy::camera::primitives::MeshAabb;
use bevy::prelude::*;
use crate::player::Player;
use super::TerrainStreamer;
/// Cells built per frame while live. Measured on the B181 carve: a landed cell mesh costs ~70 µs
/// end-to-end (asset add + extract + prepare + upload, dev profile, Metal) — 64 ≈ 4.5 ms of
/// render-side work per frame, and a whole fresh row (5 × 256 cells, two tiles out, in fog)
/// furnishes in ~20 frames. The placement cap (`SPAWN_COUNT_CAP`) is this same idea one lane over.
const CELL_SPAWN_CAP: usize = 64;
/// Build + spawn the cell entities of spawned-but-unfurnished tiles, [`CELL_SPAWN_CAP`] per frame
/// while live, nearest tile to the stream focus first. Chained directly after `stream_terrain`
/// (roots spawned there furnish from the same frame on) and before `spawn_loaded_placements`.
pub(super) fn furnish_tile_cells(
mut commands: Commands,
mut state: ResMut<TerrainStreamer>,
tiles: Res<Assets<AdtTile>>,
mut meshes: ResMut<Assets<Mesh>>,
mut activity: ResMut<crate::perf::StreamActivity>,
player: Option<Res<Player>>,
) {
let t0 = Instant::now();
let live = player
.as_deref()
.is_some_and(|p| p.active && !p.settling && !p.world_stale);
let cap = if live { CELL_SPAWN_CAP } else { usize::MAX };
// Ablation switch (`WOW_NO_TILE_CELLS=1`): tiles stream — asset residency, collider,
// placements, liquid, clutter — without their per-chunk cell entities, isolating the
// render-entity lane from the asset lane when measuring a streaming cost. The B181 carve ran
// on it (a crossing's spike survived zero cells, exonerating the entity lane); the
// `WOW_NO_LIQUID`/`WOW_NO_PARTICLES` family's pattern. Tiles still MARK furnished, so the
// loading-screen residency (which now waits on furnishing) converges.
let ablated = std::env::var("WOW_NO_TILE_CELLS").is_ok();
let focus = state.focus;
let mut pending: Vec<(i32, (i32, i32))> = state
.tiles
.iter()
.filter(|(_, t)| t.entity.is_some() && !t.furnished)
.map(|(&(tx, ty), _)| ((tx - focus.0).abs().max((ty - focus.1).abs()), (tx, ty)))
.collect();
if pending.is_empty() {
return;
}
pending.sort_unstable();
let mut built = 0usize;
'tiles: for (_, key) in pending {
let Some(tile) = state.tiles.get_mut(&key) else {
continue;
};
let (Some(root), Some(material)) = (tile.entity, tile.material.clone()) else {
continue; // unreachable given the filter above, but never worth a panic
};
// The handle keeps the asset resident for the tile's whole life, so a spawned tile's
// `AdtTile` is always here; a missing one (asset store torn down mid-shutdown) just
// leaves the tile unfurnished.
let Some(adt) = tiles.get(&tile.handle) else {
continue;
};
while tile.next_cell < adt.chunks.len() {
if built >= cap {
break 'tiles;
}
let i = tile.next_cell;
tile.next_cell += 1;
if ablated {
continue;
}
// A hole-emptied chunk yields no mesh (and costs nothing — it doesn't count).
let Some(mesh) = chunk_to_mesh(&adt.chunks[i], &adt.shading[i]) else {
continue;
};
// The cell's Aabb, computed HERE and inserted explicitly: the mesh is
// `RENDER_WORLD`-only, so Bevy's own `calculate_bounds` may find its data already
// extracted — and the exterior cull FAILS OPEN on a missing Aabb (an unbounded cell
// would draw forever, silently undoing 0780's cull unit).
let aabb = mesh.compute_aabb();
let handle = meshes.add(mesh);
commands.entity(root).with_children(|cells| {
let mut cell = cells.spawn((
Mesh3d(handle),
MeshMaterial3d(material.clone()),
Transform::IDENTITY, // cell meshes are in absolute world coords
// ADT terrain is exterior scene: from inside a WMO a cell draws only through
// a portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` —
// see `crate::exterior_cull`).
crate::exterior_cull::ExteriorScene,
));
if let Some(aabb) = aabb {
cell.insert(aabb);
}
});
built += 1;
}
tile.furnished = true;
}
activity.cells_spawned += built as u32;
activity.furnish_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
/// The one thing the furnisher promises the rest of the streamer: a tile is `furnished` only
/// when its cursor walked every chunk (or cells are ablated off) — never merely "root exists".
#[cfg(test)]
mod tests {
/// The cap's arithmetic, kept honest in one place: a full fresh row must furnish in well
/// under a second of frames at 60 Hz.
#[test]
fn a_fresh_row_furnishes_in_under_half_a_second() {
let row_cells: usize = 5 * 256;
let frames = row_cells.div_ceil(super::CELL_SPAWN_CAP);
assert!(
frames <= 30,
"a 5-tile row must land within ~half a second at 60 Hz, got {frames} frames"
);
}
}
@@ -55,6 +55,11 @@ pub(crate) fn spawn_model_entities(
palettes: &mut crate::rig_palette::RigPalettes,
light: &Buffer,
submeshes: &[ModelSubmesh],
// The model's app-built render forms (decision 0834), index-parallel with `submeshes`: the
// static handle + its build-time `Aabb` per batch, and the skinned twins when this model's
// lane rigs (`None` otherwise). Callers gate on `ModelForms::require`, so by the time a
// placement spawns these are complete.
forms: crate::model_forms::FormSlices<'_>,
transform: Transform,
is_wmo: bool,
// The static terrain-shade selector for every batch (the MCSH sample at this placement's base →
@@ -127,6 +132,11 @@ pub(crate) fn spawn_model_entities(
}
let mut skinned_meshes: Vec<Entity> = Vec::new();
for (wmo_batch_idx, sub) in submeshes.iter().enumerate() {
// The batch's app-built render form (decision 0834). Callers gate spawning on the forms
// being complete, so a miss here is a broken contract — skip the batch rather than panic.
let Some((stat_mesh, stat_aabb)) = forms.stat.get(wmo_batch_idx) else {
continue;
};
// WMO batches carry their authored order (index + 1; 0 = non-WMO) into the material so the
// pipeline can bias coplanar layers to resolve in MOBA file order — the byte-verified client
// behaviour (wow-5875-re models/scratch/wmo-batch-blend-depth-state.md). Every WMO batch gets
@@ -265,50 +275,60 @@ pub(crate) fn spawn_model_entities(
None => BillboardCard::new(info, transform),
},
};
(
commands
.spawn((
Mesh3d(sub.mesh.clone()),
MeshMaterial3d(cutout.clone()),
Transform::from_translation(transform.transform_point(info.pivot)),
ModelPart {
kind,
blend: sub.blend,
},
mesh_tag,
card,
))
.id(),
Vec3::ZERO,
)
let mut card_entity = commands.spawn((
Mesh3d(stat_mesh.clone()),
MeshMaterial3d(cutout.clone()),
Transform::from_translation(transform.transform_point(info.pivot)),
ModelPart {
kind,
blend: sub.blend,
},
mesh_tag,
card,
));
// The build-time Aabb, inserted explicitly: the static form is `RENDER_WORLD`-only,
// so Bevy's `calculate_bounds` can race extraction — and the exterior cull fails
// OPEN on a missing bound (0832's rule, extended to the model lane).
if let Some(aabb) = stat_aabb {
card_entity.insert(*aabb);
}
(card_entity.id(), Vec3::ZERO)
} else {
// An animated doodad's ordinary submesh draws the skinned twin bound to the placement's
// palette rig (decision 0720 — the rig slot in the tag; the entity keeps the placement
// transform — culling/fade read it; the skinned draw itself is palette-driven).
// Everything else — and the palette-full fallback (slot 0) — draws the static mesh.
let use_rig = skin.is_some() && rig_slot != 0;
let mesh = if use_rig {
sub.skinned_mesh.clone()
} else {
sub.mesh.clone()
};
// The twin comes from the app-built forms (0834); a lane that didn't request it
// (or a contract break) falls back to the static form, un-rigged, rather than warn
// the picker with a joint-less "skinned" mesh.
let skinned_mesh = (skin.is_some() && rig_slot != 0)
.then(|| forms.skin.and_then(|s| s.get(wmo_batch_idx)).cloned())
.flatten();
let use_rig = skinned_mesh.is_some();
let part_tag = if use_rig {
MeshTag(crate::mesh_tag::rig_bits(rig_slot) | mesh_tag.0)
} else {
mesh_tag
};
let entity = commands
.spawn((
Mesh3d(mesh),
MeshMaterial3d(cutout.clone()),
transform,
ModelPart {
kind,
blend: sub.blend,
},
part_tag,
))
.id();
let mut part_entity = commands.spawn((
Mesh3d(skinned_mesh.unwrap_or_else(|| stat_mesh.clone())),
MeshMaterial3d(cutout.clone()),
transform,
ModelPart {
kind,
blend: sub.blend,
},
part_tag,
));
// Static parts carry the build-time Aabb (the RENDER_WORLD rule above); a rigged
// part's skinned mesh keeps main-world data, so `calculate_bounds` covers it as
// before.
if !use_rig {
if let Some(aabb) = stat_aabb {
part_entity.insert(*aabb);
}
}
let entity = part_entity.id();
if skin.is_some() {
if let Some(root) = rig_root {
commands
@@ -154,6 +154,11 @@ pub(super) fn spawn_emitters_for(
owner,
attach: None, // placed doodads/props are never attached models
anchor: None, // anchor at the placement: an animated bone never drags the cloud
// An animated doodad's rig goes when its placement unloads, and that IS this
// model being destroyed — free the pool with it, the same rule the WMO-prop lane
// beside this one already states (0826, missed here: a tile crossing left the
// brazier's flame draining at the old spot).
on_owner_loss: particles::OwnerLoss::Free,
..default()
},
// A placed doodad's arm is NOT one-time: it re-rolls its variation every play-window
+74 -11
View File
@@ -5,6 +5,7 @@
mod assemble;
mod fx;
pub(crate) mod prop_light;
pub(crate) use assemble::spawn_model_entities;
pub(crate) use fx::point_light;
@@ -15,24 +16,26 @@ use std::time::Instant;
use benilla_assets::coords::{wmo_doodad_local, wow_to_bevy};
use benilla_assets::{AdtTile, DoodadBase, M2Model, WmoModel};
use benilla_formats::M2Bounds;
use benilla_formats::{world_to_tile, M2Bounds};
use bevy::prelude::*;
use crate::collision::{camera_layers, walk_layers, GroundDecalSurface, PickOccluder};
use crate::debug_panel::ModelKind;
use crate::doodad_anim::wants_rig;
use crate::interact::WorldObject;
use crate::lighting::SharedLightBuffer;
use crate::lighting::{PropProbeSlot, PropProbes};
use crate::liquid::{spawn_wmo_liquids, LiquidAssets};
use crate::model_forms::{FormSlices, ModelForms, ModelKey, WANT_SKINNED, WANT_STATIC};
use crate::model_render::{m2_url, ShadeSel};
use crate::terrain::WowModelMaterial;
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
use super::collider::{build_collider_task, placement_collider_data, PendingCollider};
use super::{
doodad_ground_shade, fold_interior_probe, ModelHandle, Placements, PropLight, PropLobeLight,
ShadeResolve, TerrainStreamer, WmoDoodadInst, SPAWN_BUDGET,
doodad_ground_shade, ModelHandle, Placements, ShadeResolve, TerrainStreamer, SPAWN_BUDGET,
};
use prop_light::{fold_interior_probe, PropLight, PropLobeLight, WmoDoodadInst};
/// Placements (models or WMO props) spawned per frame while live — the count half of the landing
/// budget; see the cap's comment in [`spawn_loaded_placements`]. ~150 × the measured ~50 µs
@@ -43,6 +46,17 @@ const SPAWN_COUNT_CAP: usize = 150;
/// Spawn the submesh entities for any placement whose model asset has finished loading. Each submesh
/// gets the production [`WowModelMaterial`] (cutout + blend twin) and the same `ModelPart`/`DoodadFade`/
/// `MeshTag` components the old spawn used — so the existing visibility/fade/lighting systems take over.
/// The nested resource tuple of [`spawn_loaded_placements`] (the 16-SystemParam ceiling): the
/// prop-probe table, the skin-palette table (0720), the stream-trace counters, the live/settling
/// state the landing cap reads, and the model-forms cache (0834).
type SpawnTables<'w> = (
ResMut<'w, PropProbes>,
ResMut<'w, crate::rig_palette::RigPalettes>,
ResMut<'w, crate::perf::StreamActivity>,
Option<Res<'w, crate::player::Player>>,
ResMut<'w, ModelForms>,
);
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_loaded_placements(
mut commands: Commands,
@@ -65,15 +79,10 @@ pub(super) fn spawn_loaded_placements(
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
// Nested to stay inside Bevy's 16-element system-param tuple limit: the prop-probe table +
// the skin-palette table (decision 0720) + the stream-trace counters + the live/settling
// state the landing cap reads.
tables: (
ResMut<PropProbes>,
ResMut<crate::rig_palette::RigPalettes>,
ResMut<crate::perf::StreamActivity>,
Option<Res<crate::player::Player>>,
),
// state the landing cap reads + the model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut palettes, mut activity, player) = tables;
let (mut probes, mut palettes, mut activity, player, mut forms) = tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -116,6 +125,18 @@ pub(super) fn spawn_loaded_placements(
let Some(m) = m2s.get(h) else {
continue; // model still loading (or missing) — try next frame
};
// The model's app-built render forms (decision 0834): request static — plus
// the skinned twins iff the anim host will rig this model — and wait for the
// paced furnisher, exactly as the placement waits for the asset itself.
let key = ModelKey::from(h);
let kinds = WANT_STATIC | if wants_rig(m) { WANT_SKINNED } else { 0 };
if !forms.require(
key,
kinds,
placement_priority(&streamer, p.transform.translation),
) {
continue;
}
// Resolve the MCSH ground-shade the reference way: a GLOBAL world→tile→chunk lookup at
// the doodad's origin, independent of which tile registered it or in what order. An ADT
// map doodad on lit ground takes the boosted ADT sun level (`ShadeSel::Lit` — the
@@ -136,6 +157,10 @@ pub(super) fn spawn_loaded_placements(
&mut palettes,
light,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
skin: forms.skinned_meshes(key),
},
p.transform,
false,
shade,
@@ -217,6 +242,14 @@ pub(super) fn spawn_loaded_placements(
let Some(m) = wmos.get(h) else {
continue;
};
// The building's app-built render forms (0834): static only — WMO group
// geometry never skins. A city root's thousands of batches are exactly the
// burst the paced furnisher exists to spread.
let key = ModelKey::from(h);
let prio = placement_priority(&streamer, p.transform.translation);
if !forms.require(key, WANT_STATIC, prio) {
continue;
}
// WMOs carry no authored bounds → never size-fade (∞ radius), rely on the far-clip.
// (`m2: None` ⇒ no anim host, so the joint half of the return is always empty.)
let (mut ents, _) = spawn_model_entities(
@@ -226,6 +259,10 @@ pub(super) fn spawn_loaded_placements(
&mut palettes,
light,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
skin: None,
},
p.transform,
true,
ShadeSel::Matte, // WMO lights on the FFP N·L path — the selector is unread
@@ -425,6 +462,16 @@ pub(super) fn spawn_loaded_placements(
let Some(m) = m2s.get(&d.handle) else {
continue; // this prop's M2 still loading
};
// The prop's app-built render forms (0834) — same gate as its owning placement's.
let key = ModelKey::from(&d.handle);
let kinds = WANT_STATIC | if wants_rig(m) { WANT_SKINNED } else { 0 };
if !forms.require(
key,
kinds,
placement_priority(&streamer, d.transform.translation),
) {
continue;
}
// The verified MODD lighting: an EXTERIOR-group WMO prop samples the terrain MCSH at
// its footprint like the reference's per-frame refresh `0x698c50` — plain matte (sun
// ×1.0) on lit ground, the shaded level on MCSH-shadowed ground, and NEVER the ADT 2.5
@@ -476,6 +523,10 @@ pub(super) fn spawn_loaded_placements(
&mut palettes,
light,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
skin: forms.skinned_meshes(key),
},
d.transform,
false,
shade,
@@ -642,6 +693,18 @@ fn resolve_wmo_doodads(
/// A model handle's source path as a readable label for the object inspector (the asset path without
/// the `mpq://` source prefix). Empty if the handle carries no path.
/// A placement's model-forms build priority (decision 0834): its Chebyshev tile distance to the
/// stream focus, scaled to leave the band below for the entity lane — a mob walking into view
/// never queues behind a city's scenery. `translation` is Bevy space (the placement transform);
/// `world_to_tile` wants WoW ground coords, the inverse of decision 0002's rotation.
fn placement_priority(streamer: &TerrainStreamer, translation: Vec3) -> i32 {
let (tx, ty) = world_to_tile(-translation.z, -translation.x);
let d = (tx as i32 - streamer.focus.0)
.abs()
.max((ty as i32 - streamer.focus.1).abs());
16 + d * 16
}
fn handle_label<A: Asset>(handle: &Handle<A>) -> String {
handle
.path()
@@ -0,0 +1,88 @@
//! The WMO prop-light machinery — a placed prop's resolved lighting and the interior SH fold.
//!
//! Split out of `terrain_stream.rs` (0830 named the carve; 0832 executed it): this is spawn-side
//! state and math — consumed by the placement assembler, the WMO-gameobject prop lane
//! (`crate::entities`' `wmo_props`) and the GameObject footprint classifier (`crate::interior`) —
//! and none of it touches the streamer's residency logic. Paths outside the streamer are stable:
//! `terrain_stream` re-exports [`fold_interior_probe`] and [`PropLobeLight`].
use std::sync::Arc;
use benilla_assets::M2Model;
use bevy::prelude::*;
/// One WMO doodad prop instance: its M2 handle and the **world** transform (the WMO instance
/// transform composed with the doodad's WMO-local transform), spawned once the M2 asset loads.
pub(crate) struct WmoDoodadInst {
pub(crate) handle: Handle<M2Model>,
pub(crate) transform: Transform,
/// Every WMO group whose MODR names this prop — the portal-cull key, so a prop is hidden with
/// the rooms it furnishes and drawn while any one of them is visible. Empty for a MODD no group
/// references (the reference never instantiates one at all; we still show it, uncullable,
/// rather than change what draws today).
pub(crate) groups: Arc<[u16]>,
/// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this
/// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR
/// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold
/// reference point needs the M2 bounds).
pub(crate) light: PropLight,
pub(crate) spawned: bool,
}
/// A WMO prop's placement-resolved lighting: the asset-level [`DoodadBase`](benilla_assets::DoodadBase)
/// with the owning group's MOLR lights already transformed to WORLD (Bevy) space — so the
/// spawn-time SH fold needs only the loaded M2's bounds (its reference point) and nothing from the
/// WMO asset.
pub(crate) enum PropLight {
Exterior,
Interior {
/// `cap96(MODD.colour)` — the ambient word (0–1 RGB).
ambient: [f32; 3],
/// `floor112(MODD.colour)` — the diffuse word, committed on the fixed interior axis.
diffuse: [f32; 3],
/// The owning group's MOLR omni lights: world (Bevy) position, colour × intensity, and the
/// disk `attenStart`/`attenEnd` window (the fold's range gate).
lights: Vec<PropLobeLight>,
},
}
/// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour
/// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the
/// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict).
pub(crate) struct PropLobeLight {
pub(crate) pos: Vec3,
pub(crate) color_i: [f32; 3],
pub(crate) atten_start: f32,
pub(crate) atten_end: f32,
}
/// Fold one interior committed light into its 7-row SH probe: the ambient word + the diffuse word
/// as a directional on the FIXED interior axis + each MOLR lobe windowed by its disk
/// attenStart/attenEnd from `ref_point` (the byte-verified `0x69e1c0` falloff: d ≤ start → 1;
/// d ≥ end → excluded; else linear). One definition for both interior lanes — the MODD prop
/// (spawn-time, MODD-colour words) and the GameObject footprint (classify-time, MOCV-derived
/// words); the SH closed form itself is [`prop_probe_coeffs`](crate::lighting::prop_probe_coeffs).
pub(crate) fn fold_interior_probe(
ambient: [f32; 3],
diffuse: [f32; 3],
ref_point: Vec3,
lights: &[PropLobeLight],
) -> [bevy::math::Vec4; 7] {
// Toward-light, Bevy space: wow (0.30822, 0.30822, 0.9) → (−y, z, −x).
let mut lobes: Vec<(Vec3, [f32; 3])> = vec![(Vec3::new(-0.30822, 0.9, -0.30822), diffuse)];
for l in lights {
let dv = l.pos - ref_point;
let dist = dv.length();
let gain = if dist <= l.atten_start {
1.0
} else if dist >= l.atten_end || l.atten_end <= l.atten_start {
0.0
} else {
1.0 - (dist - l.atten_start) / (l.atten_end - l.atten_start)
};
if gain > 0.0 {
lobes.push((dv / dist.max(1e-4), l.color_i.map(|c| c * gain)));
}
}
crate::lighting::prop_probe_coeffs(ambient, &lobes)
}
+67 -19
View File
@@ -242,14 +242,18 @@ pub(super) fn feed_actions(
for (slot, button) in &actions.buttons {
let (texture, count) = match button.kind {
ACTION_KIND_SPELL => {
// The melee auto-attack shows the equipped weapon icon, not spell 6603's `Temp`
// placeholder (decision 0230); every other spell uses its own icon.
let d = spells.as_ref().and_then(|s| s.catalog.get(button.action));
let icon = d
.and_then(|d| {
auto_attack_icon(d, store, &mut items, icons.as_deref(), &commands)
})
.or_else(|| d.and_then(|d| d.icon.clone()));
let icon = spells.as_ref().and_then(|sp| {
let d = sp.catalog.get(button.action)?;
spell_action_icon(
button.action,
d,
sp,
store,
&mut items,
icons.as_deref(),
&commands,
)
});
(icon, 0)
}
ACTION_KIND_ITEM => {
@@ -328,29 +332,39 @@ pub(super) fn feed_actions(
}
changed
}
// A weapon-substituting icon tracks the equipped weapon — main-hand for Attack
// (decision 0230), the ranged slot for Auto Shot / wand Shoot (decision 0231's
// ranged case) — which a swap changes without touching the action table, so
// refresh it too. A normal spell's icon is stable, so it's skipped.
// Two icon families track live character state, not the action table, so they
// refresh every frame: a weapon-substituting icon follows the equipped weapon
// AND the current form (Attack's `0x4e6870` — decisions 0230/0231 + the form
// face), and a toggle spell with a nonzero ActiveIconID swaps faces with its own
// aura (`0x4e6a50`'s `0x4e6bbd` predicate — a shift in/out never touches
// SMSG_ACTION_BUTTONS). A plain spell's icon is stable, so it's skipped.
ACTION_KIND_SPELL => {
let d = spells.as_ref().and_then(|s| s.catalog.get(slot.action));
match d.filter(|d| substitutes_weapon_icon(d)) {
Some(d) => {
let fresh = auto_attack_icon(
let d = spells
.as_ref()
.and_then(|s| s.catalog.get(slot.action))
.filter(|d| substitutes_weapon_icon(d) || d.active_icon_id != 0);
match (d, spells.as_ref()) {
(Some(d), Some(sp)) => {
let fresh = spell_action_icon(
slot.action,
d,
sp,
Some(store),
&mut items,
icons.as_deref(),
&commands,
)
.or_else(|| d.icon.clone());
);
let changed = fresh != slot.texture;
if changed {
debug!(
"ui_action: live icon swap slot {action} ({}) -> {fresh:?}",
slot.action
);
slot.texture = fresh;
}
changed
}
None => false,
_ => false,
}
}
_ => false,
@@ -365,3 +379,37 @@ pub(super) fn feed_actions(
}
}
}
/// The whole SPELL-slot icon rule — the reference's `GetActionTexture` resolver `0x4e6a50`
/// (wow-re `action-spell-icon-apis.md` §3, §5-verified), arms in its execution order:
///
/// 1. The **pre-emptive arms** ([`auto_attack_icon`]): Attack serves the current form's face /
/// the main-hand weapon (`0x4e6870`), an auto-repeat shot the ranged weapon (`0x4e6990`) —
/// before the spell's own icon fields are ever read.
/// 2. The **active-toggle swap** (`0x4e6bbd → 0x4e6bc6`): `ActiveIconID` while the button's OWN
/// spell id sits live-and-cancelable in the player's aura slots — the literal `0x4e55f0`
/// predicate `UseAction`'s cancel fork rides ([`super::toggle::active_action_toggle`]; the
/// same function in the binary, by call-target address). Ghost Wolf's swirl while shifted.
/// 3. The spell's own `SpellIconID` face.
///
/// The spellbook's `GetSpellTexture` (`0x4b3f50`) deliberately runs ONLY arms 1 and 3 — it never
/// serves `ActiveIconID` (proof by exhaustion in the note). `ui_spellbook` keeps that asymmetry;
/// do not "fix" it to match the bar.
#[allow(clippy::too_many_arguments)] // the resolver's full input set, twice-called above
fn spell_action_icon(
spell_id: u32,
d: &benilla_formats::SpellDisplay,
spells: &super::Spells,
store: Option<&crate::net::ObjectStore>,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
auto_attack_icon(d, store, &spells.forms, items, icons, commands)
.or_else(|| {
store
.filter(|s| super::toggle::active_action_toggle(spell_id, d, s))
.and_then(|_| d.active_icon.clone())
})
.or_else(|| d.icon.clone())
}
+11 -2
View File
@@ -194,6 +194,9 @@ pub(super) fn feed_action_state(
let (pending, queued_melee, channel) = &cast_state;
let me = self_q.iter().next();
let engaged = me.is_some_and(|(_, _, e, _)| e);
let form_byte = me
.map(|(s, _, _, _)| s.0.unit_shapeshift_form())
.unwrap_or(0);
let casting_spell = me.and_then(|(_, _, _, c)| c.map(|c| c.spell_id));
let current_cast = pending.current(now).or(casting_spell);
let self_reach = me.map_or(1.5, |(s, _, _, _)| s.0.unit_combat_reach());
@@ -225,14 +228,20 @@ pub(super) fn feed_action_state(
// spell while it is our in-flight cast OR our queued on-next-swing strike OR our
// running channel (the ref reads one inflight id `0xceca88` — which a queued
// Heroic Strike *occupies* until the swing fires it — plus the channel id
// `0xceac58`; our model splits the queue into its own slot, same observable).
// The form/stance legs stay unmodeled.
// `0xceac58`; our model splits the queue into its own slot, same observable) —
// OR the shapeshift arm (`IsCurrentAction`'s predicate `0x4e53a0` @ `0x4e5556`,
// wow-re `action-spell-icon-apis.md` §5): a MOD_SHAPESHIFT spell whose form ==
// the player's form byte reads checked. Deliberately NOT the icon's aura-scan
// predicate — the two are different functions in the binary and the asymmetry
// is load-bearing (a form granted by a different spell lights the check without
// swapping the icon).
st.current = if st.is_attack {
engaged
} else {
current_cast == Some(button.action)
|| queued_melee.current() == Some(button.action)
|| channel.current(now) == Some(button.action)
|| (form_byte != 0 && d.shapeshift_form == Some(u32::from(form_byte)))
};
st.auto_repeat = auto_repeat.0 == Some(button.action);
// The full usable walk (`0x6e3d60` §2a — [`super::usable`]): reagents, forms,
+18 -5
View File
@@ -45,16 +45,28 @@ fn main_hand_weapon_icon(
}
/// The character's melee auto-attack icon (decision 0231; the client's melee helper `0x4e6870`):
/// the equipped main-hand weapon's icon, or [`SPELL_RESET_ICON`] when unarmed. Character-level —
/// independent of WHICH auto-attack spell (they all show this), so the spellbook can pre-resolve it
/// once for its whole page. (The verdict's ranged auto-repeat / shapeshift-form / disarmed cases
/// are deferred — see decision 0231; this covers the common melee, armed-or-unarmed case.)
/// the **current shapeshift form's own attack face** when its `SpellShapeshiftForm` row carries
/// one (the `+0x34` AttackIconID read, `0x4e68af`–`0x4e68da` — a cat's paw, a bear's swipe; wow-re
/// `action-spell-icon-apis.md` §3.3, closing decision 0231's deferred form case), else the
/// equipped main-hand weapon's icon, else [`SPELL_RESET_ICON`]. Character-level — independent of
/// WHICH auto-attack spell (they all show this), so the spellbook can pre-resolve it once for its
/// whole page.
pub(crate) fn melee_auto_attack_icon(
store: &ObjectStore,
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> String {
let form = store.0.unit_shapeshift_form();
if form != 0 {
if let Some(icon) = forms
.get(&u32::from(form))
.and_then(|f| f.attack_icon.clone())
{
return icon;
}
}
main_hand_weapon_icon(store, items, icons, commands)
.unwrap_or_else(|| SPELL_RESET_ICON.to_string())
}
@@ -98,13 +110,14 @@ pub(super) fn substitutes_weapon_icon(spell: &SpellDisplay) -> bool {
pub(super) fn auto_attack_icon(
spell: &SpellDisplay,
store: Option<&ObjectStore>,
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
let store = store?;
if spell.is_melee_auto_attack() {
return Some(melee_auto_attack_icon(store, items, icons, commands));
return Some(melee_auto_attack_icon(store, forms, items, icons, commands));
}
if spell.ranged_icon_substitution() {
return ranged_weapon_icon(store, items, icons, commands);
+2 -2
View File
@@ -140,8 +140,8 @@ fn feed_spellbook(
// The melee auto-attack's icon is the equipped main-hand weapon (or Spell-Reset when unarmed),
// not spell 6603's `Temp` placeholder (decision 0230) — resolved here where the self player +
// item stores are in hand, once for the whole page (it's the same for any auto-attack spell).
let attack_icon =
store.map(|s| melee_auto_attack_icon(s, &mut items, icons.as_deref(), &commands));
let attack_icon = store
.map(|s| melee_auto_attack_icon(s, &spells.forms, &mut items, icons.as_deref(), &commands));
// The ranged auto-repeat shots (Auto Shot, wand Shoot) borrow the equipped ranged weapon's
// icon the same way (decision 0231's ranged case; `None` — unarmed/thrown — keeps the
// spell's own icon, never Spell-Reset). Character-level like the melee icon: one resolve
+159
View File
@@ -0,0 +1,159 @@
//! **The depth-prime twin** — the reference's `M2UseZFill` ("z-fill transparent objects", default
//! ON) reproduced for fading/stealthed entities (decision 0831; wow-re
//! `m2-blend-promotion-zfill.md` §4, VERIFIED at the bytes).
//!
//! The mechanism it reproduces: when a model instance draws translucent (`0 < A < 1` — stealth's
//! CharProc 0.3, the appear/despawn ramps, the self-avatar zoom feather), the reference's collector
//! CLONES each of its z-writing mesh commands into a twin that draws **first** (comparator key
//! `cmd+0x08` desc inside the instance's sort-key tie), **colour-masked off** (`glColorMask 0`),
//! **blend off**, **z-write ON**. The whole model's nearest-surface depth is therefore in the
//! buffer before any of its colour batches draw, each colour fragment passes the depth test only at
//! that nearest surface, and every interior/overlapped layer fails — **one blended layer
//! everywhere**. Without it, a stealthed body's overlapping parts compound `0.3` over `0.3` into
//! the darker patches of ledger B182.
//!
//! The benilla shape: [`sync_zfill_twins`] watches every fadeable part
//! ([`FadeMaterials::zfill`] carries the twin material, built next to the part's blend twin) and
//! keeps a **twin child mesh** alive on it exactly while the part's `MeshTag` alpha is translucent
//! ([`crate::mesh_tag::translucent`]). The twin shares the part's mesh, transform and tag (same
//! skinned pose, same rig slot), and its material's `specialize` masks the colour writes off and
//! forces z-write on; its ordering comes from the material's negative `Transparent3d` sort bias
//! ([`crate::model_render::ZFILL_SORT_BIAS`]).
//!
//! Two deliberate departures from the reference, both recorded in 0831:
//! - **Scope**: the reference twins every translucent mesh command always (authored-Blend glass
//! included); we twin only while an instance alpha episode is live, so every steady-state look is
//! untouched.
//! - **Ordering**: the reference ties one instance's commands to a single sort key and puts twins
//! first inside the tie; Bevy sorts per-entity, so the twins ride a fixed −8 yd bias instead —
//! transparent scene content sorting within that window behind a fading body draws after the
//! prime and is depth-clipped where the body covers it, for the seconds the episode lasts.
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::model_fade::FadeMaterials;
/// On a fadeable part: its live depth-prime twin entity. Present exactly while the part draws
/// translucent — [`sync_zfill_twins`] is the only writer.
#[derive(Component)]
pub(crate) struct ZfillTwin(Entity);
/// On a twin: the part it primes for. The mesh/tag re-sync key, and the marker that keeps the twin
/// out of every part query (writers key on [`FadeMaterials`], which a twin never carries; this side
/// of the disjointness is the `Without` in the queries below).
#[derive(Component)]
pub(crate) struct ZfillTwinOf(Entity);
/// Keep every fadeable part's depth-prime twin in sync with its episode: spawn the twin child when
/// the part's tag alpha enters the translucent band, despawn it when the tag settles (opaque or the
/// part itself despawns — a child dies with its parent), and mirror the part's mesh + tag onto a
/// live twin (a gear-change redress swaps the part's mesh in place, 0835; the rig slot rides the
/// tag, so the twin skins into the same pose as the surface it primes).
///
/// Runs in `PostUpdate`, after every Update-side tag writer (the aura author, the appear/despawn
/// ramp, the self feather) — the twin's first frame is the episode's first frame.
/// One fadeable part as [`sync_zfill_twins`] sees it: its twin material source, live tag, mesh,
/// and (maybe) the twin it already has.
type ZfillParts<'w, 's> = Query<
'w,
's,
(
Entity,
&'static FadeMaterials,
&'static MeshTag,
&'static Mesh3d,
Option<&'static ZfillTwin>,
),
Without<ZfillTwinOf>,
>;
pub(crate) fn sync_zfill_twins(
mut commands: Commands,
parts: ZfillParts,
mut twins: Query<(&ZfillTwinOf, &mut MeshTag, &mut Mesh3d), Without<FadeMaterials>>,
) {
for (part, fm, tag, mesh, twin) in &parts {
let Some(mat) = fm.zfill.as_ref() else {
continue; // no-z-write / Mod / Mod2x batch — the reference's own twin gate
};
let active = crate::mesh_tag::translucent(tag.0);
match twin {
None if active => {
let t = commands
.spawn((
Mesh3d(mesh.0.clone()),
MeshMaterial3d(mat.clone()),
MeshTag(tag.0),
Transform::IDENTITY,
ZfillTwinOf(part),
ChildOf(part),
))
.id();
commands.entity(part).insert(ZfillTwin(t));
trace("arm", part, tag.0);
}
Some(t) if !active => {
commands.entity(t.0).despawn();
commands.entity(part).remove::<ZfillTwin>();
trace("release", part, tag.0);
}
_ => {}
}
}
for (of, mut tag, mut mesh) in &mut twins {
// The part's tag moves every fade frame (its alpha bits); mirroring it keeps the rig slot
// — the half the twin's vertex stage actually reads — correct across a redress or a slot
// reassignment without a dedicated edge.
let Ok((_, _, ptag, pmesh, _)) = parts.get(of.0) else {
continue; // part going away this frame — the child despawns with it
};
if tag.0 != ptag.0 {
tag.0 = ptag.0;
}
if mesh.0 != pmesh.0 {
mesh.0 = pmesh.0.clone();
}
}
}
/// The layer's instrument (`WOW_MOVE_TRACE=<path>`, tag `zfl`): one line per twin edge, so "did
/// the depth prime actually arm on this body" is answered from the trace beside the `aur` lines —
/// a stealth press prints `aur arm … target 0.30` and then one `zfl arm` per fadeable part.
fn trace(what: &str, part: Entity, tag: u32) {
if !crate::dbg_trace::enabled() {
return;
}
crate::dbg_trace::line("zfl", &format!("{what} part={part} tag={tag:#010x}"));
}
#[cfg(test)]
mod tests {
/// The activation band, pinned against the tag encoding it reads: the untagged sentinel and a
/// full alpha field are opaque (no twin), every ramp value in between is translucent (twin),
/// and the flag bits never leak into the decision.
#[test]
fn the_twin_arms_exactly_on_the_translucent_band() {
use crate::mesh_tag;
assert!(!mesh_tag::translucent(0), "untagged ⇒ opaque, no twin");
assert!(
!mesh_tag::translucent(mesh_tag::with_alpha(0, 1.0)),
"opaque ramp end: released"
);
for alpha in [0.02, 0.3, 0.5, 0.9] {
assert!(
mesh_tag::translucent(mesh_tag::with_alpha(0, alpha)),
"mid-ramp {alpha} must arm the twin"
);
}
// alpha_bits floors a non-positive alpha at field value 1 — still armed, matching the
// reference (only A ≤ 0 culls the batch, and that never reaches a tag).
assert!(mesh_tag::translucent(mesh_tag::with_alpha(0, 0.0)));
// The standalone flag bits are not payload: a highlighted-but-untagged instance is opaque.
assert!(!mesh_tag::translucent(mesh_tag::HIGHLIGHT_BIT));
// …and a highlighted mid-ramp instance still arms.
assert!(mesh_tag::translucent(
mesh_tag::with_alpha(0, 0.3) | mesh_tag::HIGHLIGHT_BIT
));
}
}