stealth you can see, item glows, and smooth character-select skies

- stealth reads as stealth now: the body fades to the reference's 30 % and creeps with the proper stealth walk and stand poses
- the item and enchant glow subsystem exists: permanent weapon glows and enchant visuals show in the world and at character select
- the character-select background's colour banding (the reported "16-bit" look) is fixed at the render target, and the tauren stage's black corners with it
- aura body tints got a real per-instance channel, so a spell can tint the whole unit without touching its neighbours

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-30 19:40:54 +07:00
co-authored by Claude Fable 5
parent 56c62c7591
commit 3fbfebd604
59 changed files with 5396 additions and 1370 deletions
Generated
+1
View File
@@ -584,6 +584,7 @@ dependencies = [
"cosmic-text",
"crossbeam-channel",
"fontdb",
"half",
"image",
"kira",
"libc",
+20
View File
@@ -140,6 +140,20 @@ pub struct ModelEmitter {
/// about the pivot, so the static path (`placement · position`) is the exact special case.
/// `[0; 3]` when the bone index is out of range (or the model is boneless).
pub bone_pivot: [f32; 3],
/// The **billboard frame** this emitter rides, when its bone chain reaches a billboard bone
/// (`benilla_formats::Skeleton::billboard_host`): that bone's arm and its pivot, **raw WoW
/// model space**. The emitter's live origin is then camera-dependent —
/// `pivot + camBasis·(def.position − pivot)` — because the reference folds the record position
/// through the *replaced* palette matrix (wow-re `part-anchoring-live-bone.md` §1 row 3,
/// `billboard-bone-law.md`'s "children multiply onto this").
///
/// A RIGGED host needs nothing from this: its joint palette already carries the replacement
/// (`billboard::billboard_joint_palette`) and the emitter rides the joint. It exists for the
/// spawn paths with no rig — an equipped item's model (helm/shoulders/held), where the whole
/// affected population's chains are otherwise rest-pose (corpus-verified: 95 item models / 197
/// emitters ride a billboard chain, **zero** with an animating one — `benilla-extract bbscan`).
/// `None` for the ordinary case.
pub billboard: Option<(benilla_formats::BillboardKind, [f32; 3])>,
/// The **recursion model** (`def.recursion_model`) as a loaded dependency: its own emitters
/// become this emitter's CHILD emitters (wow-re `part-child-recursion.md`), wired by the
/// app's particle system once the asset resolves. `None` when unauthored.
@@ -345,10 +359,16 @@ impl AssetLoader for M2ModelLoader {
.geometry_model
.as_deref()
.map(|p| ctx.load::<M2Model>(m2_dep_url(p)));
// The billboard frame the emitter's bone chain inherits, if any (see the field).
let billboard = skeleton_raw.billboard_host(def.bone).and_then(|h| {
let b = skeleton_raw.bones.get(h)?;
Some((b.billboard?, b.pivot))
});
ModelEmitter {
def,
texture,
bone_pivot,
billboard,
recursion,
geometry,
owner_reach,
+7 -13
View File
@@ -27,16 +27,14 @@ pub use anims::{AnimClip, ModelAnimations, PlayableAnim, ResolvedAnim};
pub use pose::{PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack};
/// A billboarded submesh's render data (Bevy space): the bone pivot the card rotates about (model-local
/// — compose with the instance transform), the card's resting plane normal, and the billboard kind.
/// The mesh is built **centred at `pivot`** so the spawn site can rotate it about the pivot to face the
/// camera each frame.
/// — compose with the instance transform) and the billboard kind. The mesh is built **centred at
/// `pivot`** so the spawn site can rotate it about the pivot to face the camera each frame.
#[derive(Clone)]
pub struct BillboardInfo {
pub pivot: Vec3,
/// The billboard bone's index — the joint the card rides on an animated host (swinging lamp,
/// mount lights). On a rest-pose host the pivot alone places the card.
pub bone: u16,
pub normal: Vec3,
pub kind: BillboardKind,
/// The billboard bone's global-sequence scale animation (the looping glow-card pulse), if any —
/// sampled per-frame at the spawn site and folded into the card scale. `None` for a static card.
@@ -606,20 +604,16 @@ pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> (AnimationClip, Pose
}
/// The [`BillboardInfo`] for a submesh that rides a billboard bone (Bevy space): the pivot to rotate
/// about and the card's resting plane normal (from its first triangle — robust to absent authored
/// normals). `None` for ordinary geometry. Pairs with [`build_submesh_mesh`], which centres the mesh.
/// about. `None` for ordinary geometry. Pairs with [`build_submesh_mesh`], which centres the mesh.
///
/// It used to also derive the card's resting plane normal from its first triangle. Nothing has read
/// that since the camera-basis law replaced the facet-normal card (0788's family), so it is deleted
/// rather than left as a field that reads as load-bearing.
pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option<BillboardInfo> {
let bb = sub.billboard.as_ref()?;
let p = |i: u32| wow_to_bevy(sub.positions[i as usize]);
let normal = sub
.indices
.chunks_exact(3)
.find_map(|t| (p(t[1]) - p(t[0])).cross(p(t[2]) - p(t[0])).try_normalize())
.unwrap_or(Vec3::Z);
Some(BillboardInfo {
pivot: wow_to_bevy(bb.pivot),
bone: bb.bone,
normal,
kind: bb.kind,
scale_anim: bb.scale_anim.clone(),
// Translation values are model-space offsets — bake each key to Bevy axes here (scale keys
@@ -0,0 +1,42 @@
//! Print every render batch's **lighting-relevant material state** — the UNLIT (0x01) / UNFOGGED
//! (0x02) render flags as our reader resolves them, beside the blend mode and texture.
//!
//! `cargo run -p benilla-formats --example batch_lit -- 'Interface\Glues\Models\UI_Tauren\UI_Tauren.m2'`
//!
//! Built for B121: wow-re's glue backdrop finding is that a glue scene's ground is a multi-layer
//! stack whose overlay layers are authored **UNLIT** (drawn fullbright, `colour = c28[0]·E_SH +
//! c28[1]` with `c28[0] = 0`), which is why UI_Tauren can author no ambient-fill light and still
//! show a lit ground in the reference. The question this answers is the one a grep cannot: whether
//! the flag survives *our* batch reader's render-flag indexing to the `RenderSubmesh` the material
//! builder consumes. A batch printed `LIT` here that the asset authors UNLIT is a reader bug; the
//! asset's own flag table is a separate read (the render-flag array at header 0x84).
fn main() -> anyhow::Result<()> {
let virt = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: batch_lit <m2 path>"))?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[])?;
println!("{virt} — {} batches", subs.len());
let mut unlit = 0;
for (i, s) in subs.iter().enumerate() {
if s.emissive {
unlit += 1;
}
println!(
" batch {i:2}: {:<5} {:<9} blend={:<9} tex={}",
if s.emissive { "UNLIT" } else { "lit" },
if s.fog_policy as u8 == 0 {
"unfogged?"
} else {
""
},
format!("{:?}", s.blend),
s.texture.as_deref().unwrap_or("<none>")
);
}
println!("{unlit} of {} batches are UNLIT (fullbright)", subs.len());
Ok(())
}
@@ -0,0 +1,136 @@
//! `charprocs`: census the `SpellVisualKit` **CharProc** columns (fields 15–34) — the character-half
//! of a kit: what a kit does to the *body* (its alpha, its tint) rather than at an attach point.
//!
//! The scope instrument for the aura-state CharProc system (B114 — "Stealth shows nothing on the
//! character"). It answers, from the shipped table rather than from expectation: which proc **types**
//! exist, how many kits carry each, which **lifecycle stage** reaches them from a live spell, and —
//! for the state stage, whose kits live for an aura's whole life — exactly which spells reach which
//! proc with which parameter. A type that shows up only behind `cast`/`impact` is a discrete-play
//! concern; a type behind `state` is an aura-lifetime one.
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, VisualStages};
/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields.
type StagePick = fn(&VisualStages) -> u32;
/// The five `SpellVisual` lifecycle stages, in field order — the label plus its column selector.
const STAGES: [(&str, StagePick); 5] = [
("precast", |s| s.precast),
("cast", |s| s.cast),
("impact", |s| s.impact),
("state", |s| s.state),
("channel", |s| s.channel),
];
/// A human label for the proc types benilla models by name.
fn type_name(ty: i32) -> &'static str {
match ty {
char_proc_type::TINT => "TINT (body RGB)",
char_proc_type::ALPHA => "ALPHA (body translucency)",
_ => "(unmodelled)",
}
}
/// Census every kit's CharProc slots, then reachability per stage, then the state-stage detail.
pub fn run(chain: &mut Chain) -> Result<()> {
let spells = benilla_formats::load_spell_catalog(chain)?;
let visuals = benilla_formats::load_spell_visual_catalog(chain)?;
// 1. The raw table census: proc type → the kits carrying it.
let mut by_type: BTreeMap<i32, BTreeSet<u32>> = BTreeMap::new();
let mut kits_with_any = 0usize;
for kit_id in visuals.kit_ids() {
let Some(kit) = visuals.kit(kit_id) else {
continue;
};
let mut any = false;
for proc in kit.char_procs() {
by_type.entry(proc.ty).or_default().insert(kit_id);
any = true;
}
kits_with_any += usize::from(any);
}
println!(
"SpellVisualKit: {} rows, {kits_with_any} carry at least one CharProc",
visuals.kit_len(),
);
println!("\nproc type census (all kits):");
for (ty, kits) in &by_type {
println!(
" type {ty:>3} {:>4} kit(s) {}",
kits.len(),
type_name(*ty)
);
}
// 2. Reachability: which stage of which spell's visual actually reaches each proc type. A kit
// nobody's visual chain names is authored-but-dead as far as the client is concerned.
let mut by_stage: BTreeMap<(&str, i32), BTreeSet<u32>> = BTreeMap::new();
// The state stage's detail rows: (spell id, name, kit, proc type, params[0]).
let mut state_rows: Vec<(u32, String, u32, i32, f32)> = Vec::new();
for (spell_id, display) in spells.iter() {
let Some(stages) = visuals.stages(display.visual) else {
continue;
};
for (label, pick) in STAGES {
let kit_id = pick(stages);
let Some(kit) = visuals.kit(kit_id) else {
continue;
};
for proc in kit.char_procs() {
by_stage.entry((label, proc.ty)).or_default().insert(kit_id);
if label == "state" {
state_rows.push((
spell_id,
display.name.clone(),
kit_id,
proc.ty,
proc.params[0],
));
}
}
}
}
println!("\nreached from a live Spell.dbc visual chain, by stage:");
for ((label, ty), kits) in &by_stage {
println!(
" {label:8} type {ty:>3} {:>4} kit(s) {}",
kits.len(),
type_name(*ty)
);
}
// 3. The state stage in full — these are the aura-lifetime procs, one line per (spell, proc).
println!(
"\nSTATE-stage CharProcs — the aura-lifetime set ({} spell/proc pair(s)):",
state_rows.len()
);
state_rows.sort_by_key(|r| (r.3, r.2, r.0));
for (spell_id, name, kit_id, ty, param) in &state_rows {
println!(
" spell {spell_id:>6} {name:<28} kit {kit_id:<5} type {ty:>3} param0 {param:<12} {}",
type_name(*ty)
);
}
Ok(())
}
/// The kits a spell's visual chain reaches, printed as one line per stage — the per-spell view of
/// the census above (used by `spellvis`'s CharProc lines; kept here beside the type names).
pub fn print_kit_procs(visuals: &SpellVisualCatalog, kit_id: u32, indent: &str) {
let Some(kit) = visuals.kit(kit_id) else {
return;
};
for (i, proc) in kit.char_proc_slots.iter().enumerate() {
let Some(proc) = proc else { continue };
println!(
"{indent}charproc[{i}] type {:>3} params {:?} {}",
proc.ty,
proc.params,
type_name(proc.ty)
);
}
}
@@ -127,9 +127,13 @@ pub fn m2attach(chain: &mut Chain, internal_path: &str) -> Result<()> {
.read_file(&name)
.with_context(|| format!("reading '{name}' from chain"))?;
let attachments = benilla_formats::parse_m2_attachments(&data)?;
println!("id bone");
// The position is raw WoW model space (X forward, Y left, Z up), as the M2 stores it — "where
// on the model does this rider actually sit?" needs it as much as the bone does: an item glow
// hangs on ids 0..4, spread along a weapon's length (decision 0805).
println!("id bone position (WoW model space)");
for a in &attachments {
println!("{:>2} {}", a.id, a.bone);
let [x, y, z] = a.position;
println!("{:>2} {:>4} [{x:.3} {y:.3} {z:.3}]", a.id, a.bone);
}
eprintln!("{} attachment points", attachments.len());
Ok(())
@@ -15,6 +15,7 @@ use anyhow::{Context, Result};
use benilla_formats::open_chain;
use clap::{Parser, Subcommand};
mod charprocs;
mod m2dump;
mod scan;
mod spellvis;
@@ -65,6 +66,11 @@ enum Command {
/// The `Spell.dbc` id (e.g. 133 = Fireball).
spell_id: u32,
},
/// Census the `SpellVisualKit` **CharProc** columns (a kit's effect on the BODY — its alpha,
/// its tint): which proc types the shipped table carries, which lifecycle stage reaches each
/// from a live spell, and every state-stage (aura-lifetime) proc in full. The scope instrument
/// for the aura-state CharProc system.
Charprocs,
/// Dump an M2's collision hull as the mover collides with it: vertex/triangle counts, the
/// model-space AABB (WoW axes, Z up), and its extents — the "what does walking into this
/// actually hit" instrument (the step-up climb-vs-slide asset question, decision 0195; a
@@ -128,6 +134,13 @@ enum Command {
/// Internal path to the `.m2` (forward or back slashes accepted).
internal_path: String,
},
/// Sweep every `.m2` and report where **addressing an attachment by id** through the model's
/// AttachLookup (the reference's `0x710310`) disagrees with scanning its record table — the
/// duplicate-id census behind decision 0805 (item glows hang on ids 0..4 of the item model).
Attachscan {
/// Optional path prefix to restrict the sweep (e.g. `Item\ObjectComponents\Weapon`).
prefix: Option<String>,
},
/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters (header
/// `0x134`) — the population instrument for the ribbon subsystem (weapon trails, streamers):
/// which content actually authors one, before we build for it.
@@ -543,6 +556,7 @@ fn main() -> Result<()> {
Command::M2bones { internal_path } => m2dump::m2bones(&mut chain, &internal_path)?,
Command::M2batch { internal_path } => m2dump::m2batch(&mut chain, &internal_path)?,
Command::M2alpha { internal_path } => m2dump::m2alpha(&mut chain, &internal_path)?,
Command::Attachscan { prefix } => scan::attachscan(&mut chain, prefix.as_deref())?,
Command::Ribbonscan => scan::ribbonscan(&mut chain)?,
Command::Cellscan => scan::cellscan(&mut chain)?,
Command::Blendscan { prefix } => scan::blendscan(&mut chain, prefix.as_deref())?,
@@ -619,6 +633,7 @@ fn main() -> Result<()> {
} => scan::doodadscan(&mut chain, &map, center_x, center_y, tile_radius)?,
Command::Shadeat { map, x, y } => scan::shadeat(&mut chain, &map, x, y)?,
Command::Spellvis { spell_id } => spellvis::run(&mut chain, spell_id)?,
Command::Charprocs => charprocs::run(&mut chain)?,
}
Ok(())
@@ -247,6 +247,90 @@ fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option<f32> {
(len > 1e-9).then(|| n[0] / len)
}
/// Sweep every `.m2` and census its **attachment addressing**: how many records it authors, how
/// many attach ids its AttachLookup resolves, and — the point of the report — where the two
/// disagree, i.e. where "scan the table for a record with this id" answers differently from the
/// reference's `lookup[id]` (`0x710310`).
///
/// Built for decision 0805 (item glows hang on the item model's ids 0..4) to answer the question
/// that decides whether the lookup can be adopted globally: which models change hands? One line
/// per divergent model — `+id` = an id only the lookup reaches, `-id` = an id only a table scan
/// reaches (a record the reference cannot address at all), `id:a→b` = same id, different record.
pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
let names: Vec<String> = chain
.list()
.context("listing chain contents")?
.into_iter()
.map(|e| e.name)
.filter(|n| {
let l = n.to_ascii_lowercase();
l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p))
})
.collect();
let (mut scanned, mut with_points, mut divergent) = (0u32, 0u32, 0u32);
let mut by_family: BTreeMap<String, u32> = BTreeMap::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let Ok(format) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else {
continue;
};
let model = format.model();
scanned += 1;
if model.attachments.is_empty() && model.attach_lookup.is_empty() {
continue;
}
with_points += 1;
// What a table scan would answer (first record per id — the pre-0805 rule) against what
// the lookup answers, compared by the record each id lands on.
let mut scan: BTreeMap<u16, usize> = BTreeMap::new();
for (i, a) in model.attachments.iter().enumerate() {
scan.entry(a.id).or_insert(i);
}
let lookup: BTreeMap<u16, usize> = (0..model.attach_lookup.len())
.filter_map(|id| {
let idx = *model.attach_lookup.get(id)?;
(idx != 0xffff && (idx as usize) < model.attachments.len())
.then_some((id as u16, idx as usize))
})
.collect();
let mut diffs: Vec<String> = Vec::new();
for (&id, &idx) in &lookup {
match scan.get(&id) {
Some(&s) if s != idx => diffs.push(format!("{id}:{s}→{idx}")),
Some(_) => {}
None => diffs.push(format!("+{id}")),
}
}
for &id in scan.keys() {
if !lookup.contains_key(&id) {
diffs.push(format!("-{id}"));
}
}
if !diffs.is_empty() {
divergent += 1;
*by_family.entry(model_key(&name)).or_default() += 1;
println!(
"{:<62} recs {:>2} lookup {:>2} {}",
name,
model.attachments.len(),
lookup.len(),
diffs.join(" ")
);
}
}
eprintln!(
"{scanned} models scanned, {with_points} with attachment data, \
{divergent} where a table scan disagrees with the lookup"
);
for (family, n) in by_family {
eprintln!(" {family}: {n}");
}
Ok(())
}
/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters.
pub fn ribbonscan(chain: &mut Chain) -> Result<()> {
let names: Vec<String> = chain
@@ -373,7 +457,11 @@ pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<()
/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the
/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each
/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone
/// descends from one — the joint-palette path, decision 0205).
/// descends from one — the joint-palette path, decision 0205), then the same question for the
/// model's **particle emitters and ribbons** (`fx[…]`) — the population behind decision 0813: an
/// emitter on (or under) a billboard bone has a camera-dependent origin, because the reference
/// folds the record position through the *replaced* palette matrix
/// (wow-re `part-anchoring-live-bone.md` §1 row 3 · `m2emitspine::particle_bone_xform`).
pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
let names: Vec<String> = chain
@@ -396,6 +484,9 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
// Corpus totals: models exercising each arm, split by how the geometry rides it.
let mut direct_models: HashMap<&'static str, u32> = HashMap::new();
let mut inherited_models: HashMap<&'static str, u32> = HashMap::new();
// …and the effect riders (particles/ribbons on a billboard chain).
let mut fx_models = 0u32;
let mut fx_total: HashMap<String, u32> = HashMap::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
@@ -445,12 +536,46 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
v.sort();
v.join(" ")
};
// The EFFECT riders: a particle emitter / ribbon whose bone chain reaches a billboard
// bone has a camera-dependent live frame (its record position rides the replaced palette
// matrix), so a consumer that places it at the rest pose puts it in the wrong place.
// `d` = the effect's own bone is the billboard bone, `i` = it descends from one.
let mut fx: HashMap<String, u32> = HashMap::new();
let mut tally = |tag: &str, bone: u16| {
if let Some((direct, k)) = ancestor_arm(bone as usize) {
let key = format!("{tag}{}{}", if direct { "d" } else { "i" }, arm(k));
*fx.entry(key).or_default() += 1;
}
};
for e in benilla_formats::parse_m2_particle_emitters(&bytes)
.unwrap_or_default()
.iter()
{
tally("p", e.bone);
}
for r in benilla_formats::parse_m2_ribbon_emitters(&bytes)
.unwrap_or_default()
.iter()
{
tally("r", r.bone);
}
let fx_counts = {
let mut v: Vec<String> = fx.iter().map(|(k, n)| format!("{k}:{n}")).collect();
v.sort();
v.join(" ")
};
let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect();
println!(
"{bones:>4} direct[{}] inherited[{}] {name}",
"{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}] {name}",
fmt_counts(&direct),
fmt_counts(&inherited)
);
if !fx.is_empty() {
fx_models += 1;
for (k, n) in &fx {
*fx_total.entry(k.clone()).or_default() += n;
}
}
for k in direct.keys() {
*direct_models
.entry(match *k {
@@ -477,8 +602,13 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
v.sort();
v.join(" ")
};
let fx_tot = {
let mut v: Vec<String> = fx_total.iter().map(|(k, n)| format!("{k}:{n}")).collect();
v.sort();
v.join(" ")
};
eprintln!(
"{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]",
"{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]",
tot(&direct_models),
tot(&inherited_models)
);
@@ -37,6 +37,8 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> {
kit.anim_id.map_or("—".into(), |a| a.to_string()),
kit.sound.map_or("—".into(), |s| s.to_string()),
);
// The kit's CharProcs (fields 15-34): what it does to the BODY.
crate::charprocs::print_kit_procs(&visuals, kit_id, " ");
// The kit's attach-point emitter slots (phase 3): tag + effect model.
for (tag, effect) in kit.effects() {
println!(
+18
View File
@@ -100,6 +100,13 @@ struct DisplayRow {
/// `BloodLevel` (field 10) — a per-display UnitBloodLevels override; 0 = fall through to the
/// model's `BloodID`.
blood_level: u32,
/// `CreatureModelAlpha` (field 5, @+0x14) — the display's **base render opacity**, 0..=255.
/// This is the `baseAlpha` of the reference's per-unit alpha product (`0x60d2d0`, the CGUnit
/// vtbl+0x6c getter: `CreatureDisplayInfo+0x14 × (1/255)`; wow-re `ghost-death-visuals.md`
/// §2.3) — an authored translucency 445 of the 10534 shipped displays carry (wisps, spirits,
/// ghosts; the modal non-opaque value is 128). Players are not on this chain (the getter
/// returns a flat 1.0 for them).
model_alpha: u32,
}
/// One CreatureModelData row (the parts we use).
@@ -133,6 +140,16 @@ impl CreatureCatalog {
self.display.get(&display_id).map(|r| r.scale)
}
/// A display's **base render alpha** in `0.0..=1.0` — `CreatureDisplayInfo.CreatureModelAlpha`
/// / 255 ([`DisplayRow::model_alpha`]). The first factor of the unit alpha product the aura
/// CharProc nodes multiply into (`crate::aura_visual`). `None` for an unknown display; a known
/// display with no authored translucency reads `1.0`.
pub fn display_base_alpha(&self, display_id: u32) -> Option<f32> {
self.display
.get(&display_id)
.map(|r| f32::from(r.model_alpha.min(255) as u8) / 255.0)
}
/// A display's **collision height** in raw model units — `CreatureModelData.collisionHeight`,
/// the per-unit `h` every depth line in the client is a fraction of (swim at `0.75·h`, splash at
/// `0.4·h`, the foam gate at `2·h`; wow-re has each byte-pinned against `CMovement+0xb4`). World
@@ -308,6 +325,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
scale: f32_at(r, 4).unwrap_or(1.0),
textures: [str_at(&rs, r, 6), str_at(&rs, r, 7), str_at(&rs, r, 8)],
blood_level: u32_at(r, 10).unwrap_or(0),
model_alpha: u32_at(r, 5).unwrap_or(255),
},
);
}
+379
View File
@@ -0,0 +1,379 @@
//! `ItemVisuals.dbc` + `ItemVisualEffects.dbc` + `SpellItemEnchantment`'s visual column — the
//! **item / enchant glow chain** (decision 0805): the permanent weapon glows and the shaman/oil
//! enchant visuals, as up to five `Spells\Enchantments\*.mdx` effect models per item.
//!
//! ## The chain
//!
//! Two sources name an **ItemVisuals id**, and both land in the same consumer:
//!
//! - the item's own **intrinsic** visual — `ItemDisplayInfo` column 22
//! ([`crate::ItemDisplay::item_visual`], record `+0x58`), and
//! - the **enchant's** visual — `SpellItemEnchantment` field 22 (record `+0x58`), read off the
//! item's enchant slots.
//!
//! An ItemVisuals row is `{ id, effect[5] }`; each effect is an `ItemVisualEffects` id whose one
//! payload column is the effect model's `.mdx` path. So one id = **up to five glow models**, one
//! per slot, and the slot index *is* the M2 attachment id (0..4) they hang from on the item's own
//! model (`0x479700`'s `0x712f70(glow, item, attachId = loop index)`).
//!
//! ## Layout — VERIFIED against build 5875 (both tables dumped whole)
//!
//! | table | records | fields | record size | columns |
//! |---|---|---|---|---|
//! | `ItemVisuals` | 34 | 6 | 24 | id + 5 `ItemVisualEffects` ids |
//! | `ItemVisualEffects` | 35 | 2 | 8 | id + model `.mdx` path (string) |
//! | `SpellItemEnchantment` | 1460 | 24 | 96 | id · effect[3] · pointsMin[3] · pointsMax[3] · arg[3] · name[8]+mask · **ItemVisual (22)** · flags |
//!
//! The wow-re §5 note `object-layer/scratch/item-visual-enchant.md` byte-pins the same shapes from
//! the loaders' own `cmp fieldCount/recSize` asserts (`0x548760`/`0x548530`/`0x54f6e0`).
//!
//! ## The skip rules are the client's, applied here at load
//!
//! `0x479700` bounds-checks **twice**, signed, and both checks matter on the shipped data:
//!
//! - the ItemVisuals id: `jl` on negative, `jg` on `> maxId`, then a null-row test. **Five of the
//! 365 visual-carrying displays carry `-1`** — they resolve to nothing.
//! - each of the 5 effect ids, the same way — and **ItemVisuals row 28 carries two out-of-range
//! garbage dwords** (`90148992`, `455344256`) in slots 0 and 3, which the reference skips and so
//! do we (id 28 renders only its slot-4 `Sparkle_A`).
//! - an **empty** path string (`cmpb $0,(%eax)`) is skipped before the load. Effect id 61 is
//! `Spells\Enchantments\` — a directory, not empty, so the reference *tries* it and the load
//! fails; no ItemVisuals row references it, so this is theory either way.
//!
//! Every one of those becomes a `None` slot in [`ItemVisualCatalog`], so a consumer sees only real
//! model paths and can never spawn a glow the reference wouldn't.
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
const ITEM_VISUALS: &str = "DBFilesClient\\ItemVisuals.dbc";
const ITEM_VISUAL_EFFECTS: &str = "DBFilesClient\\ItemVisualEffects.dbc";
const SPELL_ITEM_ENCHANTMENT: &str = "DBFilesClient\\SpellItemEnchantment.dbc";
/// The five effect slots an ItemVisuals row carries — and, one-for-one, the M2 attachment ids
/// `0..4` on the item model each one hangs from.
pub const ITEM_VISUAL_SLOTS: usize = 5;
/// `ItemVisuals.dbc` joined with `ItemVisualEffects.dbc`: an ItemVisuals id → its five glow-model
/// paths (raw `.mdx` references, as the DBC stores them — the app's `m2_url` owns the `.m2` swap),
/// `None` where the slot is empty, out of range, or names an empty path.
pub struct ItemVisualCatalog {
visuals: HashMap<u32, [Option<String>; ITEM_VISUAL_SLOTS]>,
}
impl ItemVisualCatalog {
/// The five glow-model slots for an ItemVisuals id, or `None` when the id names no row.
///
/// Takes the id **signed**, because that is how the client reads it: `0x479700` tests `jl`
/// before its `maxId` compare, so `-1` (five shipped `ItemDisplayInfo` rows) and `0` name
/// nothing. `> 0` with no row is equally nothing.
pub fn effects(&self, visual_id: i32) -> Option<&[Option<String>; ITEM_VISUAL_SLOTS]> {
(visual_id > 0).then(|| self.visuals.get(&(visual_id as u32)))?
}
/// Build from an explicit map — tests and synthetic fixtures.
pub fn from_visuals(visuals: HashMap<u32, [Option<String>; ITEM_VISUAL_SLOTS]>) -> Self {
ItemVisualCatalog { visuals }
}
pub fn len(&self) -> usize {
self.visuals.len()
}
pub fn is_empty(&self) -> bool {
self.visuals.is_empty()
}
}
/// `SpellItemEnchantment.dbc`'s **visual column** (field 22): an enchant id → the ItemVisuals id
/// its glow comes from. Only the 102 rows that carry one are kept; everything else about the
/// enchant table (its effects, points, name) belongs to whoever needs it, not here.
pub struct EnchantVisualCatalog {
visuals: HashMap<u32, i32>,
}
impl EnchantVisualCatalog {
/// The ItemVisuals id an enchant glows with, or `None` for an enchant with no visual. The
/// value is signed for the same reason as [`ItemVisualCatalog::effects`] — one shipped row
/// carries `-1`.
pub fn visual(&self, enchant_id: u32) -> Option<i32> {
self.visuals.get(&enchant_id).copied()
}
/// Build from an explicit map — tests and synthetic fixtures.
pub fn from_visuals(visuals: HashMap<u32, i32>) -> Self {
EnchantVisualCatalog { visuals }
}
/// Iterate `(enchant id, ItemVisuals id)` for the rows that carry one (order unspecified) —
/// the cross-table join checks read it.
pub fn iter(&self) -> impl Iterator<Item = (u32, i32)> + '_ {
self.visuals.iter().map(|(k, v)| (*k, *v))
}
pub fn len(&self) -> usize {
self.visuals.len()
}
pub fn is_empty(&self) -> bool {
self.visuals.is_empty()
}
}
/// `ItemVisuals.dbc` — 6 fields / 24-byte records in build 5875.
pub(crate) fn item_visuals_schema() -> Schema {
let mut s = Schema::new("ItemVisuals");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
for i in 0..ITEM_VISUAL_SLOTS {
s.add_field(SchemaField::new(
format!("Effect{i}"),
FieldType::UInt32, // signed in use — see the module doc's skip rules
));
}
s
}
/// `ItemVisualEffects.dbc` — 2 fields / 8-byte records in build 5875.
pub(crate) fn item_visual_effects_schema() -> Schema {
let mut s = Schema::new("ItemVisualEffects");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("Model", FieldType::String));
s
}
/// `SpellItemEnchantment.dbc` — 24 fields / 96-byte records in build 5875. Only field 22
/// (`ItemVisual`) is read; the rest are typed to keep the field-count check exact. The eight
/// `Name_Lang` slots + their mask are the 1.12 localized-string block (only `enUS` is filled).
pub(crate) fn spell_item_enchantment_schema() -> Schema {
let mut s = Schema::new("SpellItemEnchantment");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
for group in ["Effect", "EffectPointsMin", "EffectPointsMax", "EffectArg"] {
for i in 0..3 {
s.add_field(SchemaField::new(format!("{group}{i}"), FieldType::UInt32));
}
}
for i in 0..8 {
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
}
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
s.add_field(SchemaField::new("ItemVisual", FieldType::UInt32));
s.add_field(SchemaField::new("Flags", FieldType::UInt32));
s
}
/// Load `ItemVisuals.dbc` + `ItemVisualEffects.dbc` off the patch chain and join them into an
/// [`ItemVisualCatalog`] — the client's per-slot skip rules (module doc) applied at load, so a
/// resolved slot is always a real model path.
pub fn load_item_visual_catalog(chain: &mut Chain) -> Result<ItemVisualCatalog> {
let bytes = chain
.read_file(ITEM_VISUAL_EFFECTS)
.with_context(|| format!("reading {ITEM_VISUAL_EFFECTS}"))?;
let rs = parse(&bytes, item_visual_effects_schema(), "ItemVisualEffects")?;
// `str_at` already drops the empty string — the reference's own `cmpb $0,(%eax)` skip.
let mut effects: HashMap<u32, String> = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(id), Some(model)) = (u32_at(r, 0), str_at(&rs, r, 1)) else {
continue;
};
effects.insert(id, model);
}
let bytes = chain
.read_file(ITEM_VISUALS)
.with_context(|| format!("reading {ITEM_VISUALS}"))?;
let rs = parse(&bytes, item_visuals_schema(), "ItemVisuals")?;
let mut visuals = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let slots = std::array::from_fn(|i| {
let raw = u32_at(r, 1 + i).unwrap_or(0) as i32;
(raw > 0)
.then(|| effects.get(&(raw as u32)).cloned())
.flatten()
});
visuals.insert(id, slots);
}
Ok(ItemVisualCatalog { visuals })
}
/// Load `SpellItemEnchantment.dbc`'s visual column off the patch chain.
pub fn load_enchant_visual_catalog(chain: &mut Chain) -> Result<EnchantVisualCatalog> {
let bytes = chain
.read_file(SPELL_ITEM_ENCHANTMENT)
.with_context(|| format!("reading {SPELL_ITEM_ENCHANTMENT}"))?;
let rs = parse(
&bytes,
spell_item_enchantment_schema(),
"SpellItemEnchantment",
)?;
let mut visuals = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let visual = u32_at(r, 22).unwrap_or(0) as i32;
if visual != 0 {
visuals.insert(id, visual);
}
}
Ok(EnchantVisualCatalog { visuals })
}
#[cfg(test)]
mod tests {
use super::*;
fn vanilla_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// The two glow tables as they actually ship, including both traps: row **28**'s two
/// out-of-range garbage slots and the reference's per-slot skip.
#[test]
fn real_item_visuals_join_their_effect_models() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_item_visual_catalog(&mut chain).expect("load ItemVisuals");
assert_eq!(cat.len(), 34, "34 ItemVisuals rows in build 5875");
// The common shape: one effect repeated across all five attach slots.
let all_blue = ["Spells\\Enchantments\\BlueGlow_Med.mdx"; ITEM_VISUAL_SLOTS];
let got = cat.effects(2).expect("visual 2");
assert_eq!(
got.each_ref().map(|s| s.as_deref().unwrap_or("")),
all_blue,
"visual 2 glows on every slot"
);
// A single-slot shape — slot 3 only.
let one = cat.effects(1).expect("visual 1");
assert_eq!(
one[3].as_deref(),
Some("Spells\\Enchantments\\SkullBalls.mdx")
);
assert!(
[0, 1, 2, 4].iter().all(|&i| one[i].is_none()),
"visual 1 authors only slot 3"
);
// A mixed row: slot 0 differs from the other four.
let rune = cat.effects(30).expect("visual 30");
assert_eq!(
rune[0].as_deref(),
Some("Spells\\Enchantments\\Rune_Intellect.mdx")
);
assert_eq!(
rune[4].as_deref(),
Some("Spells\\Enchantments\\YellowGlow_Low.mdx")
);
// **The garbage row.** Slots 0 and 3 hold 90148992 / 455344256 — far past the effect
// table's maxId (152) — and the reference's `jg` skips them.
let junk = cat.effects(28).expect("visual 28");
assert_eq!(
junk.each_ref().map(|s| s.is_some()),
[false, false, false, false, true],
"only the in-range slot-4 effect survives row 28"
);
assert_eq!(
junk[4].as_deref(),
Some("Spells\\Enchantments\\Sparkle_A.mdx")
);
// The id gate: 0 and the shipped -1 name nothing.
assert!(cat.effects(0).is_none());
assert!(cat.effects(-1).is_none());
assert!(cat.effects(9999).is_none(), "past maxId");
}
/// The display half of the join, on real data: 365 of the 29 604 displays carry a visual, five
/// of them the unresolvable `-1`, and every other one resolves to at least one glow model.
#[test]
fn real_displays_carrying_a_visual_all_resolve() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let visuals = load_item_visual_catalog(&mut chain).expect("load ItemVisuals");
let displays = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
let (mut carried, mut minus_one, mut resolved, mut models) = (0, 0, 0, 0);
for d in displays.iter() {
if d.item_visual == 0 {
continue;
}
carried += 1;
if d.item_visual == -1 {
minus_one += 1;
}
match visuals.effects(d.item_visual) {
Some(slots) => {
resolved += 1;
models += slots.iter().flatten().count();
}
None => assert_eq!(
d.item_visual, -1,
"the only unresolvable visual ids on the shipped table are -1"
),
}
}
assert_eq!(carried, 365, "displays carrying a nonzero ItemVisuals id");
assert_eq!(minus_one, 5, "…of which five are the skipped -1");
assert_eq!(resolved, 360);
assert_eq!(
models, 1588,
"glow-model instances the shipped displays add up to"
);
}
/// The enchant half: `SpellItemEnchantment` field 22 lands in the ItemVisuals id space on
/// every shipped row but one (`-1`) — the independent corroboration that column 22 (record
/// `+0x58`) is the visual, and the three shaman weapon buffs to name it.
#[test]
fn real_enchant_visuals_resolve() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let visuals = load_item_visual_catalog(&mut chain).expect("load ItemVisuals");
let enchants = load_enchant_visual_catalog(&mut chain).expect("load SpellItemEnchantment");
assert_eq!(enchants.len(), 102, "enchants carrying a visual");
let resolved = enchants
.iter()
.filter(|(_, v)| visuals.effects(*v).is_some())
.count();
assert_eq!(resolved, 101, "…all but the one -1 row resolve to a row");
// Rockbiter 3 (enchant 1) → visual 61 → the slot-3 rock glow.
let rockbiter = visuals
.effects(enchants.visual(1).expect("enchant 1 has a visual"))
.expect("visual 61");
assert_eq!(
rockbiter[3].as_deref(),
Some("Spells\\Enchantments\\Shaman_Rock.mdx")
);
// A sharpening stone (enchant 13) → visual 28 → the garbage row's surviving sparkle.
let sharpened = visuals
.effects(enchants.visual(13).expect("enchant 13 has a visual"))
.expect("visual 28");
assert_eq!(
sharpened[4].as_deref(),
Some("Spells\\Enchantments\\Sparkle_A.mdx")
);
// A plain +stat enchant carries none (241 "Weapon Damage +2", 929 "Stamina +7").
assert_eq!(enchants.visual(241), None);
assert_eq!(enchants.visual(929), None);
}
}
+14 -4
View File
@@ -12,8 +12,10 @@
//! (u32 — the pickup/place sound group; byte-verified `0x458008: mov eax,[edx+0x2c]`, wow-re
//! `system/sound/scratch/item-pickup-place-sound.md`, corroborated 20513/20513 valid ids on the
//! real DBC) · `12`/`13` helm-vis (u32) · `14`-`21` the 8 body-region textures, in **ArmUpper,
//! ArmLower, Hand, TorsoUpper, TorsoLower, LegUpper, LegLower, Foot** order (string) · `22`
//! unpinned int (unread).
//! ArmLower, Hand, TorsoUpper, TorsoLower, LegUpper, LegLower, Foot** order (string) · `22` the
//! **`ItemVisuals.dbc` id** — the item's intrinsic glow (i32; byte-verified `0x47a200: mov
//! edx,[esi+0x58]`, the held-item attach handing it on — decision 0805,
//! [`crate::ItemVisualCatalog`]).
//!
//! **Trap, load-bearing:** `model` and `model_texture` are **independently-resolved basenames** in
//! the same `Item\ObjectComponents\<dir>\` folder — never derive one from the other. Real
@@ -74,6 +76,12 @@ pub struct ItemDisplay {
/// `0` on every non-ranged display; only three distinct nonzero ids exist across the real
/// table (thrown 98 · bow 5 · gun/rifle 224).
pub spell_visual: u32,
/// The `ItemVisuals.dbc` id (col 22) — the display's **intrinsic glow**: the permanent weapon
/// glows, resolved to up to five `Spells\Enchantments\*.mdx` models by
/// [`crate::ItemVisualCatalog`] (decision 0805). **Signed**, because the client reads it that
/// way (`0x4798c0`'s `jle` gate): `0` = none on 29 239 of the 29 604 rows, and **five shipped
/// rows carry `-1`**, which is also none.
pub item_visual: i32,
}
impl ItemDisplay {
@@ -178,7 +186,7 @@ pub(crate) fn item_display_info_schema() -> Schema {
] {
s.add_field(SchemaField::new(name, FieldType::String));
}
s.add_field(SchemaField::new("Unk22", FieldType::UInt32));
s.add_field(SchemaField::new("ItemVisualID", FieldType::UInt32));
s
}
@@ -203,6 +211,7 @@ fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog {
let icon = str_at(&rs, r, 5).map(|i| format!("Interface\\Icons\\{i}"));
let group_sounds = u32_at(r, 11).unwrap_or(0);
let spell_visual = u32_at(r, 10).unwrap_or(0);
let item_visual = u32_at(r, 22).unwrap_or(0) as i32;
displays.insert(
id,
ItemDisplay {
@@ -214,6 +223,7 @@ fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog {
icon,
group_sounds,
spell_visual,
item_visual,
},
);
}
@@ -337,7 +347,7 @@ mod tests {
for _ in 0..8 {
rec.extend(u32le(OFF_EMPTY)); // region textures — this row paints none
}
rec.extend(u32le(0)); // Unk22
rec.extend(u32le(0)); // ItemVisualID
assert_eq!(rec.len(), RECORD_SIZE as usize);
let bytes = build_wdbc(1, FIELD_COUNT, RECORD_SIZE, &rec, &strings.0);
+19 -2
View File
@@ -57,6 +57,11 @@ mod items;
pub use items::{load_item_display_catalog, ItemDisplay, ItemDisplayCatalog};
mod item_sounds;
pub use item_sounds::{load_item_group_sounds, ItemGesture, ItemGroupSoundsCatalog};
mod item_visuals;
pub use item_visuals::{
load_enchant_visual_catalog, load_item_visual_catalog, EnchantVisualCatalog, ItemVisualCatalog,
ITEM_VISUAL_SLOTS,
};
mod ground_effects;
pub use ground_effects::{
load_ground_effect_catalog, scatter_ground_doodads, GroundDoodadPlacement, GroundEffect,
@@ -126,8 +131,8 @@ mod talents;
pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX_TALENT_RANK};
mod spell_visual;
pub use spell_visual::{
load_spell_visual_catalog, SpellVisualCatalog, VisualKit, VisualStages, KIT_SLOT_TAGS,
MISSILE_ATTACH_TABLE,
char_proc_type, load_spell_visual_catalog, CharProc, SpellVisualCatalog, VisualKit,
VisualStages, KIT_CHAR_PROCS, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE,
};
mod emit_timing;
pub use emit_timing::EmitTiming;
@@ -383,6 +388,15 @@ fn schema_for(dbc_name: &str) -> Option<Schema> {
("HelmetGeosetVisData.dbc", characters::helmet_vis_schema),
("CharSections.dbc", characters::char_sections_schema),
("ItemDisplayInfo.dbc", items::item_display_info_schema),
("ItemVisuals.dbc", item_visuals::item_visuals_schema),
(
"ItemVisualEffects.dbc",
item_visuals::item_visual_effects_schema,
),
(
"SpellItemEnchantment.dbc",
item_visuals::spell_item_enchantment_schema,
),
("AreaTrigger.dbc", area_trigger::area_trigger_schema),
] {
if base.eq_ignore_ascii_case(name) {
@@ -631,6 +645,9 @@ mod tests {
"HelmetGeosetVisData",
"CharSections",
"ItemDisplayInfo",
"ItemVisuals",
"ItemVisualEffects",
"SpellItemEnchantment",
"TaxiNodes",
"AreaTable",
"GameObjectDisplayInfo",
+139 -10
View File
@@ -39,6 +39,37 @@ pub struct Skeleton {
pub bones: Vec<SkeletonBone>,
}
impl Skeleton {
/// The **billboard host** of `bone`: the index of the nearest bone at or above it in the parent
/// chain that authors a billboard arm (`bone` itself included), or `None` when the chain reaches
/// the root without one.
///
/// This is the palette-level question, and the reason it has to be asked about ancestors and not
/// just the bone itself: a billboard bone's matrix ROWS are replaced with the camera basis about
/// its own pivot, and *`children multiply onto this`* (wow-re `billboard-bone-law.md`) — so every
/// descendant frame is camera-dependent even though only the ancestor carries the flag. Three
/// consumers ask it: a skinned vertex (which arm its geometry inherits), a **particle emitter**
/// (whose record position rides the replaced matrix — wow-re `part-anchoring-live-bone.md` §1
/// row 3), and a ribbon anchor.
///
/// The nearest host is the whole answer: a nearer replacement discards the rows an outer one
/// wrote, and on a rest-pose chain the pivot-preserving translation rebuild reads the (identity)
/// pre-billboard matrix, so nothing above it survives either.
pub fn billboard_host(&self, bone: u16) -> Option<usize> {
let mut i = usize::from(bone);
// Bounded by the bone count — M2 parents precede children, so this terminates on any
// well-formed file; the bound is the malformed-parent guard.
for _ in 0..=self.bones.len() {
let b = self.bones.get(i)?;
if b.billboard.is_some() {
return Some(i);
}
i = usize::try_from(b.parent).ok()?;
}
None
}
}
/// Parse the M2 bone hierarchy (parent + pivot per bone) into a [`Skeleton`]. Straight off
/// `benilla-m2`'s bone parse (vanilla bone record stride 0x6c: parent i16 @+0x08, pivot C3 @+0x60,
/// VERIFIED wow-5875-re). A separate byte-in entry point alongside [`parse_m2_render_submeshes`], so
@@ -73,19 +104,30 @@ pub struct M2Attachment {
pub position: [f32; 3],
}
/// Parse the M2 attachment-point table (see [`M2Attachment`]). Straight off `benilla-m2`'s already
/// bone-range-checked parse; empty for a model with no attachment points.
/// Parse the M2 attachment-point table **as the reference addresses it**: one entry per attach id
/// the model's AttachLookup resolves (`benilla_m2::M2Model::attachment` — the `0x710310` bounds +
/// `0xffff` sentinel), in id order, so a consumer keyed by id can simply match on
/// [`M2Attachment::id`].
///
/// This is deliberately **not** the raw file table: ~5% of weapon models author several records
/// under one id (both ends of a staff), and the lookup names exactly one of them — the other is
/// unreachable in the reference and must be unreachable here (decision 0805). Records the lookup
/// never names are therefore absent. Empty for a model with no attachment points.
pub fn parse_m2_attachments(bytes: &[u8]) -> Result<Vec<M2Attachment>> {
let format =
parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?;
Ok(format
.model()
.attachments
.iter()
.map(|a| M2Attachment {
id: a.id,
bone: a.bone,
position: a.position,
let model = format.model();
Ok((0..model.attach_lookup.len())
.filter_map(|id| {
let a = model.attachment(id as u16)?;
Some(M2Attachment {
// The LOOKUP's id, not the record's own: the reference resolves `lookup[id]` and
// never re-reads the record's id field, so that is the id this point answers to.
// (They agree on every shipped model checked; this makes it true by construction.)
id: id as u16,
bone: a.bone,
position: a.position,
})
})
.collect())
}
@@ -1055,4 +1097,91 @@ mod tests {
}
}
}
/// **The billboard host of a particle emitter's bone chain** (decision 0813), on the two real
/// assets that opened it. `Field Marshal's Chain Spaulders` (display 32092 — nazriel's B118
/// item) authors a 4-bone chain whose only billboard bone is bone **1** (flags `0x08`,
/// spherical), with the two sparkle emitters hanging off its children 2 and 3. Neither emitter
/// bone carries the flag, so a consumer that asks only about the emitter's OWN bone sees an
/// ordinary rest-pose frame and puts the sparkle ~0.24 yd from where the reference draws it.
///
/// Also pins the authoring pattern the offset is measured against: each emitter's record
/// `position` **is** its own bone's pivot, so `position − host.pivot` is the chain offset the
/// camera basis rotates. And the held torch — the other shipped case — is the same shape one
/// generation shallower (emitter on bone 10, host bone 2, a 0.097 yd offset).
#[test]
fn real_pvp_shoulder_emitters_ride_a_billboard_bone() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file("Item\\ObjectComponents\\Shoulder\\LShoulder_Mail_PVPAlliance_C_01.m2")
.expect("read the R14 mail shoulder");
let skel = parse_m2_skeleton(&bytes).expect("skeleton");
assert_eq!(skel.bones.len(), 4);
assert_eq!(
skel.bones.iter().filter(|b| b.billboard.is_some()).count(),
1,
"one billboard bone in the chain"
);
assert_eq!(
skel.bones[1].billboard,
Some(crate::BillboardKind::Spherical),
"bone 1 authors flag 0x08"
);
let emitters = crate::parse_m2_particle_emitters(&bytes).expect("emitters");
assert_eq!(emitters.len(), 2);
for (i, em) in emitters.iter().enumerate() {
let bone = usize::from(em.bone);
assert_eq!(bone, 2 + i, "the sparkle pair rides bones 2 and 3");
assert!(
skel.bones[bone].billboard.is_none(),
"…neither of which carries the flag itself"
);
assert_eq!(
skel.billboard_host(em.bone),
Some(1),
"so the host is their parent"
);
// The record position IS the emitter bone's own pivot (the exporter's pattern).
for c in 0..3 {
assert!(
(em.position[c] - skel.bones[bone].pivot[c]).abs() < 1e-3,
"emitter {i} position {:?} vs bone pivot {:?}",
em.position,
skel.bones[bone].pivot
);
}
// …and it sits ~0.24 yd from the host pivot — the offset the camera basis rotates,
// i.e. how far a rest-pose placement misses by.
let d: f32 = (0..3)
.map(|c| (em.position[c] - skel.bones[1].pivot[c]).powi(2))
.sum();
assert!(
(0.23..0.26).contains(&d.sqrt()),
"chain offset {} yd",
d.sqrt()
);
}
// The held torch: same mechanism, emitter on bone 10 under billboard bone 2.
let torch = chain
.read_file("Item\\ObjectComponents\\Weapon\\Club_1H_Torch_A_01.m2")
.expect("read the torch");
let tskel = parse_m2_skeleton(&torch).expect("skeleton");
let tem = crate::parse_m2_particle_emitters(&torch).expect("emitters");
assert_eq!(tem.len(), 1);
assert_eq!(tem[0].bone, 10);
assert_eq!(tskel.billboard_host(10), Some(2));
assert_eq!(
tskel.bones[2].billboard,
Some(crate::BillboardKind::Spherical)
);
// A bone with no billboard anywhere above it has no host (bone 0, the model root).
assert_eq!(skel.billboard_host(0), None);
// …and an out-of-range bone index is a `None`, never a panic.
assert_eq!(skel.billboard_host(999), None);
}
}
+127 -38
View File
@@ -32,7 +32,13 @@
//! immediate in the client's slot loop (`0x60edf0`, byte-cited push sites — wow-re
//! `spell-visual-apply.md` §1.3) and a **direct M2 `AttachmentID`**: in kit-field order,
//! [`KIT_SLOT_TAGS`]. Field 12 is the missile effect slot (phase 4), field 14 a visual-group
//! fallback id — neither is read here.
//! fallback id — neither is read here. **Fields 15–34 are the four `CharProc` slots** — the
//! *character* half of a kit (the body's own alpha/tint, as opposed to the attach-point emitters):
//! five parallel 4-element arrays, `CharProcType[4]` @+0x3c then `CharParamZero/One/Two/Three[4]`
//! @+0x4c/+0x5c/+0x6c/+0x7c (wow-re `spellvisual-schema.md`, byte-pinned: all 20 consumed by the
//! dispatcher `0x60d7c0`, which `lea edi,[kit+0x6c]` walks four times reading `[edi-0x30]` as the
//! type key). Read here as [`VisualKit::char_procs`]; the type semantics are
//! [`char_proc_type`]'s.
//! - **SpellVisualEffectName**: 5 fields × 20 B. Field 0 = id; field 1 = a name string — a debug
//! label, and the lookup key for the client's boot-time HARDCODED-effect matcher (`0x61f5b0`
//! over a 14-string table: loot art, footsteps, breath, level-up…; wow-re
@@ -65,7 +71,7 @@ use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::dbc::{f32_at, i32_at, parse, str_at, u32_at};
use crate::Chain;
const SPELL_VISUAL: &str = "DBFilesClient\\SpellVisual.dbc";
@@ -93,6 +99,40 @@ pub const MISSILE_ATTACH_TABLE: [u16; 11] = [
/// `None` alongside plain `0`.
const NONE_SENTINEL: u32 = u32::MAX;
/// The four parallel `CharProc` slots a `SpellVisualKit` row carries (module docs; the client's
/// dispatcher `0x60d7c0` walks exactly four).
pub const KIT_CHAR_PROCS: usize = 4;
/// One of a kit's four `CharProc` slots: a **type key + its four float params**, the character-half
/// of a kit (the body's own render properties, not an attach-point emitter). The client's dispatcher
/// `0x60d7c0` switches on [`Self::ty`] through a byte translation table (`0x60dc20`) into a 9-case
/// jump table (`0x60dbfc`); each case reads whichever params it wants — the alpha and tint procs
/// both read `params[0]` (wow-re `ghost-death-visuals.md` §2.3).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CharProc {
/// `CharProcType[i]` — the dispatch key ([`char_proc_type`] names the ones we model). Never a
/// none-sentinel: an empty slot is `None` at the [`VisualKit::char_procs`] level.
pub ty: i32,
/// `CharParamZero/One/Two/Three[i]`, in that order — float columns (the client loads them with
/// `fld` and rounds where it wants an integer, as the tint proc does with its packed RGB).
pub params: [f32; 4],
}
/// The `CharProc` type keys benilla models, by name. The full key space is the dispatcher's 9 cases
/// (`0x60d7c0`); these are the ones a live 5875 **state** kit uses — every other key is either
/// cast-stage-only or unused by the shipped table (see `charprocs` in `benilla-extract`).
pub mod char_proc_type {
/// **Body TINT** (`0x60d840`): `round(params[0])` is a packed `0x00RRGGBB`, OR'd with
/// `0xff000000` into the tint node (`node+0x7c`), list `unit+0xce0`. Per-frame the head node's
/// RGB goes `×1/255` into `model+0x184/188/18c` (the ghost's pale blue-white `0xFF8CB9FD`).
pub const TINT: i32 = 1;
/// **Body TRANSLUCENCY** (`0x60d972`): `params[0]` is this aura's alpha factor, stored in a
/// spell-id-keyed node (`node+0x78`) on the unit's list `unit+0xb50`. The unit's effective alpha
/// is `baseAlpha × Π(node alphas)` (`0x60d180`), ramped to over 1000 ms by
/// `StartAlphaFade 0x614f80`. Stealth's 0.3 and the ghost aura's 0.5 are both this proc.
pub const ALPHA: i32 = 14;
}
/// One `SpellVisual.dbc` row: the five lifecycle-stage `SpellVisualKit` ids a cast may fire
/// through (`0` = no kit at that stage, this crate's usual absent-FK convention) + the missile
/// block's live columns (model, dest-attach, flight sound — module docs; phase 4).
@@ -144,6 +184,7 @@ pub struct VisualStages {
/// One `SpellVisualKit.dbc` row's body-animation + sound + the nine attach-point emitter slots
/// (the columns consumed through decision 0099 phase 3; see the module doc for what remains).
// No `Eq`: the `CharProc` params are floats (the client's own `fld` columns).
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct VisualKit {
/// The `AnimationData.dbc` id this kit plays on the unit (`None` = no animation for this kit
@@ -155,23 +196,31 @@ pub struct VisualKit {
/// order — slot `i` attaches at [`KIT_SLOT_TAGS`]`[i]`. Both none-sentinels fold to `None`
/// like [`Self::anim_id`]. Iterate populated slots via [`Self::effects`].
pub effect_slots: [Option<u32>; 9],
/// The four CharProc blocks — `CharProcType` (fields 15–18, int) with `CharParamZero`
/// (19–22, f32) and `CharParamOne` (23–26, f32). The dynobj emitter chain scans for the
/// FIRST type **9** slot: param0 encodes the shard-model table index (the exact small-int
/// decode `bits(param0 + 512.0) >> 14 & 0xff`, `0x5d55c0`), param1 is the emit rate the
/// graphics-quality factor multiplies (wow-re `dynobject-visual-machine.md`).
pub char_procs: [KitCharProc; 4],
}
/// One `SpellVisualKit` CharProc block (see [`VisualKit::char_procs`]).
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct KitCharProc {
pub proc_type: u32,
pub param0: f32,
pub param1: f32,
/// The four `CharProc` slots (kit fields 15–34, transposed out of the five parallel arrays).
/// An unfilled slot is `None` — see [`char_proc_slot`] for the sentinel law; iterate the filled
/// ones via [`Self::char_procs`]. **Two consumers, two halves of the same table:** the *body*
/// procs an aura's state kit installs (type 14 translucency / type 1 tint — `benilla`'s
/// `aura_visual`, decision 0806), and the **dynobj emitter chain**, which scans for the FIRST
/// type **9** slot — there `params[0]` encodes the shard-model table index (the exact small-int
/// decode `bits(params[0] + 512.0) >> 14 & 0xff`, `0x5d55c0`) and `params[1]` is the emit rate
/// the graphics-quality factor multiplies (wow-re `dynobject-visual-machine.md`, decision 0797).
pub char_proc_slots: [Option<CharProc>; KIT_CHAR_PROCS],
}
impl VisualKit {
/// The filled `CharProc` slots, in slot order. The client walks all four unconditionally and
/// lets the dispatch table drop the unnamed keys; we drop the empty *slots* here and leave the
/// key switch to the consumer.
pub fn char_procs(&self) -> impl Iterator<Item = CharProc> + '_ {
self.char_proc_slots.iter().flatten().copied()
}
/// The `params[0]` of this kit's first `CharProc` slot of type `ty`, if any — the shape both
/// modelled procs want (a single float: the alpha factor, the packed tint).
pub fn char_proc_param(&self, ty: i32) -> Option<f32> {
self.char_procs().find(|p| p.ty == ty).map(|p| p.params[0])
}
/// The populated emitter slots as `(M2 attachment id, SpellVisualEffectName id)` pairs, in
/// kit-field order — the client fires **all** populated slots at **every** stage (stage sets
/// lifetime policy only, wow-re `spell-visual-apply.md` §1.3).
@@ -243,6 +292,14 @@ impl SpellVisualCatalog {
self.visuals.is_empty()
}
/// Every loaded `SpellVisualKit` id, ascending — for whole-table census instruments
/// (`benilla-extract charprocs`). Runtime consumers resolve a kit by id through [`Self::kit`].
pub fn kit_ids(&self) -> impl Iterator<Item = u32> + '_ {
let mut ids: Vec<u32> = self.kits.keys().copied().collect();
ids.sort_unstable();
ids.into_iter()
}
/// The number of `SpellVisualKit` rows loaded.
pub fn kit_len(&self) -> usize {
self.kits.len()
@@ -299,6 +356,48 @@ fn effect_name_schema() -> Schema {
s
}
/// `SpellVisualKit`'s 35-field schema: fields 0–14 are the ids/FKs (`u32`), **15–18 the signed
/// `CharProcType` keys** and **19–34 the four float param arrays** — the client loads the params
/// with `fld` (wow-re `spellvisual-schema.md`), so they are typed here rather than bit-cast at
/// every read.
fn kit_schema() -> Schema {
let mut s = Schema::new("SpellVisualKit");
for i in 0..SPELL_VISUAL_KIT_FIELDS {
let ty = match i {
CHAR_PROC_TYPE_FIELD..CHAR_PROC_PARAM_FIELD => FieldType::Int32,
f if f >= CHAR_PROC_PARAM_FIELD => FieldType::Float32,
_ => FieldType::UInt32,
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Kit field 15 — `CharProcType[0]`, the first of the four type keys (@+0x3c).
const CHAR_PROC_TYPE_FIELD: usize = 15;
/// Kit field 19 — `CharParamZero[0]`, the first param column (@+0x4c); `CharParamOne/Two/Three[0]`
/// follow every [`KIT_CHAR_PROCS`] fields (@+0x5c/+0x6c/+0x7c).
const CHAR_PROC_PARAM_FIELD: usize = CHAR_PROC_TYPE_FIELD + KIT_CHAR_PROCS;
/// One kit's `CharProc` slot `i`, transposed out of the five parallel arrays.
///
/// **The empty-slot sentinel, read off the real table:** the type column uses **both** `-1` and `0`
/// for "no proc" — the same dual none-encoding this table already shows on its anim/sound FKs
/// (module docs). Every filled slot in 5875 carries a positive key, and the dispatcher's translation
/// table (`0x60dc20`) sends everything it doesn't name to the no-op case, so folding `<= 0` to
/// `None` here is behaviour-identical and keeps consumers from switching on a sentinel.
fn char_proc_slot(r: &benilla_dbc::Record, i: usize) -> Option<CharProc> {
let ty = i32_at(r, CHAR_PROC_TYPE_FIELD + i)?;
if ty <= 0 {
return None;
}
let mut params = [0.0; 4];
for (p, param) in params.iter_mut().enumerate() {
*param = f32_at(r, CHAR_PROC_PARAM_FIELD + p * KIT_CHAR_PROCS + i).unwrap_or(0.0);
}
Some(CharProc { ty, params })
}
/// Fold the DBC's dual none-encoding (module docs) to `Option<u32>`.
fn some_unless_none(v: u32) -> Option<u32> {
(v != 0 && v != NONE_SENTINEL).then_some(v)
@@ -341,11 +440,7 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog
let svk_bytes = chain
.read_file(SPELL_VISUAL_KIT)
.context("reading SpellVisualKit.dbc")?;
let svk_set = parse(
&svk_bytes,
n_u32_schema("SpellVisualKit", SPELL_VISUAL_KIT_FIELDS),
"SpellVisualKit.dbc",
)?;
let svk_set = parse(&svk_bytes, kit_schema(), "SpellVisualKit.dbc")?;
let mut kits = HashMap::with_capacity(svk_set.records().len());
for r in svk_set.records() {
let Some(id) = u32_at(r, 0) else { continue };
@@ -355,21 +450,17 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result<SpellVisualCatalog
for (i, slot) in effect_slots.iter_mut().enumerate() {
*slot = u32_at(r, 3 + i).and_then(some_unless_none);
}
// The four CharProc blocks: type (int column) + two f32 param columns, read as the
// client does — param0/param1 are IEEE floats in the file (the `fadd 512.0` decode and
// the emit-rate multiply are float ops at `0x5d55c0`/`0x6eb930`).
let char_procs = std::array::from_fn(|i| KitCharProc {
proc_type: u32_at(r, 15 + i).unwrap_or(0),
param0: u32_at(r, 19 + i).map(f32::from_bits).unwrap_or(0.0),
param1: u32_at(r, 23 + i).map(f32::from_bits).unwrap_or(0.0),
});
let mut char_proc_slots = [None; KIT_CHAR_PROCS];
for (i, slot) in char_proc_slots.iter_mut().enumerate() {
*slot = char_proc_slot(r, i);
}
kits.insert(
id,
VisualKit {
anim_id,
sound,
effect_slots,
char_procs,
char_proc_slots,
},
);
}
@@ -812,8 +903,8 @@ mod ground_fx_data_tests {
);
let k = visuals.kit(609).unwrap();
assert_eq!(k.sound, Some(7));
let proc = k.char_procs.iter().find(|p| p.proc_type == 9).unwrap();
assert_eq!((proc.param0, proc.param1), (0.0, 5.0));
let proc = k.char_procs().find(|p| p.ty == 9).unwrap();
assert_eq!((proc.params[0], proc.params[1]), (0.0, 5.0));
// Flamestrike 2120 → visual 33: its own model + sound, NO type-9 proc (a burning
// patch has no falling shards) — the emitter half is data-absent, not code-gated.
@@ -831,7 +922,7 @@ mod ground_fx_data_tests {
);
let k = visuals.kit(695).unwrap();
assert_eq!(k.sound, Some(3077));
assert!(k.char_procs.iter().all(|p| p.proc_type != 9));
assert!(k.char_procs().all(|p| p.ty != 9));
// Rain of Fire 5740 → visual 329: shard-table index 1 (its own model again), rate 5/s.
let v = visuals.stages(catalog.get(5740).unwrap().visual).unwrap();
@@ -839,12 +930,10 @@ mod ground_fx_data_tests {
let proc = visuals
.kit(v.area_kit)
.unwrap()
.char_procs
.iter()
.find(|p| p.proc_type == 9)
.copied()
.char_procs()
.find(|p| p.ty == 9)
.unwrap();
assert_eq!((proc.param0, proc.param1), (1.0, 5.0));
assert_eq!((proc.params[0], proc.params[1]), (1.0, 5.0));
// All three pass the GO dest one-shot gate (field 6 == 0 ∧ field 12 ≠ 0) — the burst
// fires for each; Fireball (missile_gate 1, pinned in the loader test above) does not.
@@ -44,3 +44,37 @@ fn humanmale_playable_animation_lookup_matches_the_byte_verified_shape() {
// row 32 substitutes AttackUnarmed(16) for whatever id 32 requests.
assert_eq!(pal[32].resolved_id, 16, "row 32 substitutes AttackUnarmed");
}
/// The **prowl clips** the stealth gait branch asks for (`creature_anim::select`'s `STEALTH_WALK` /
/// `STEALTH_STAND`, RF-0057's `[[110]+0x213]&2` branches): a player model authors both, and a
/// creature model may author NEITHER — in which case the same baked lookup the real client indexes
/// steps 119 down to Walk and 120 to Stand. That asymmetry is why the selector's stealth branch needs
/// no model-capability check of its own, and why a prowling druid cat shows its ordinary walk on the
/// reference client too.
#[test]
fn the_stealth_clips_are_authored_by_players_and_absent_from_the_druid_cat() {
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 chain = open_chain(&data).expect("open chain");
let pal = |path: &str| {
parse_m2_playable_animation_lookup(&chain.read(path).expect("read m2")).expect("parse pal")
};
// Rogue/druid bodies: StealthWalk(119) and StealthStand(120) resolve to themselves.
for model in [
"character\\human\\male\\humanmale.m2",
"character\\nightelf\\female\\nightelffemale.m2",
] {
let p = pal(model);
assert_eq!(p[119].resolved_id, 119, "{model} authors StealthWalk");
assert_eq!(p[120].resolved_id, 120, "{model} authors StealthStand");
}
// Cat form authors neither — the baked walk lands on Walk / Stand.
let cat = pal("creature\\druidcat\\druidcat.m2");
assert_eq!(cat[119].resolved_id, 4, "cat StealthWalk -> Walk");
assert_eq!(cat[120].resolved_id, 0, "cat StealthStand -> Stand");
}
+21 -1
View File
@@ -284,7 +284,10 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
// drops legitimate data.
let att_avail = b.len().saturating_sub(attachments.1 as usize);
let mut attachment_list = Vec::with_capacity(capped(attachments.0 as usize, 48, att_avail));
for i in 0..attachments.0 as usize {
// A dropped record shifts the emitted indices, and the AttachLookup below indexes the FILE's
// records — so the translation is carried here rather than reconstructed later.
let mut emitted_at = vec![0xffffu16; attachments.0 as usize];
for (i, emitted) in emitted_at.iter_mut().enumerate() {
let a = get(attachments.1 as usize + i * 48, 48)?;
let id = a.u32_at(0).ok_or(Error::Truncated)?;
let bone = a.u32_at(4).ok_or(Error::Truncated)?;
@@ -295,6 +298,7 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
if bone as usize >= bone_list.len() {
continue;
}
*emitted = u16::try_from(attachment_list.len()).unwrap_or(0xffff);
attachment_list.push(M2Attachment {
id,
bone,
@@ -302,6 +306,21 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
});
}
// AttachLookup (header `+0x10c`): attachment **id** → the one record it resolves to. This is
// the reference's only id→record map (`0x710310`: `id >= count` ⇒ the `0xffff` sentinel, else
// `lookup[id]`) and it is emphatically NOT a scan of the table — shipped weapons author
// several records under one id (`Stave_2H_Long_D_05`: eight records, ids 0-3 twice, one at
// each end of the staff) and the lookup names exactly one, leaving the other unreachable.
// Stored translated into emitted-list indices, `0xffff` where the id resolves to nothing.
let lk_avail = b.len().saturating_sub(attach_lookup.1 as usize);
let mut attach_lookup_list = Vec::with_capacity(capped(attach_lookup.0 as usize, 2, lk_avail));
for i in 0..attach_lookup.0 as usize {
let raw = get(attach_lookup.1 as usize + i * 2, 2)?
.u16_at(0)
.ok_or(Error::Truncated)?;
attach_lookup_list.push(emitted_at.get(raw as usize).copied().unwrap_or(0xffff));
}
// Event markers: 44 bytes each (identifier 4CC @0, data u32 @4, bone u32 @8, position C3 @12;
// a 20-byte enabled M2TrackBase follows and is skipped). Same bone-range discipline as the
// attachments above; kept in file order (queries take the first ident match).
@@ -482,6 +501,7 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
},
pivot_attach_z,
attachments: attachment_list,
attach_lookup: attach_lookup_list,
event_markers,
animation_lookup,
playable_animation_lookup,
+26 -2
View File
@@ -263,9 +263,15 @@ pub struct M2Model {
pub pivot_attach_z: Option<f32>,
/// The full attachment-point table (see [`M2Attachment`]) — every record whose id/bone fit `u16`
/// and whose bone indexes into [`Self::bones`]. `pivot_attach_z` above is the id-17 special case
/// kept as its own field (the camera pivot's one historical consumer); this is the general table
/// held-item attach points, sheath points, etc. read from.
/// kept as its own field (the camera pivot's one historical consumer). **Records are addressed
/// by ATTACH ID through [`Self::attachment`], never by scanning this table** (see
/// [`Self::attach_lookup`]).
pub attachments: Vec<M2Attachment>,
/// The model's **AttachLookup** (header `+0x10c`), translated into [`Self::attachments`]
/// indices: row `i` is the record attachment **id** `i` resolves to, `0xffff` for none. Read
/// it through [`Self::attachment`], which is the reference's `0x710310` — bounds check and
/// sentinel included.
pub attach_lookup: Vec<u16>,
/// The animation-event table's **positional markers** (see [`M2EventMarker`]) — every record
/// whose bone fits `u16` and indexes into [`Self::bones`], in file order (queries take the
/// first ident match, the client's `0x7130e0` scan order). The cast-release launch points
@@ -302,6 +308,24 @@ impl M2Model {
.get(anim_id as usize)
.is_some_and(|&slot| slot != 0xffff)
}
/// **The attachment record an attach id resolves to** — the reference's `0x710310` +
/// `0x712f70` pair, byte-for-byte: a bounds-checked [`Self::attach_lookup`] read
/// (`id >= count` ⇒ the `0xffff` sentinel) and then the record it names. `None` where the id
/// has no record, which is how the reference behaves too: `0x712f70` stores the sentinel and
/// every consumer of that field (`0x7140aa`, `0x718668`, `0x719266`) tests `cmp 0xffff; je`
/// and skips the child, so an unresolvable id hangs **nothing** rather than falling back to
/// the model origin.
///
/// Never scan [`Self::attachments`] for a matching id instead: on the ~5% of weapon models
/// that author duplicate ids the two disagree, and the lookup is the authority (decision
/// 0805).
pub fn attachment(&self, id: u16) -> Option<&M2Attachment> {
let idx = *self.attach_lookup.get(id as usize)?;
(idx != 0xffff)
.then(|| self.attachments.get(idx as usize))
.flatten()
}
}
/// Holds the parsed model (mirrors the reference's `parse_m2(..).model()` shape).
@@ -74,6 +74,23 @@ impl ObjectFields {
})
.flatten()
}
/// `PLAYER_VISIBLE_ITEM_<slot>_0 + 1 + j` — the **broadcast enchant ids** on the item worn in
/// equipment slot `i`: that 8-field block is the item entry followed by 7 enchant slots, of
/// which 1.12 fills exactly two (vmangos `Player::SetVisibleItemSlot` loops
/// `MAX_INSPECTED_ENCHANTMENT_SLOT = 2` — PERM then TEMP). This is the only enchant feed a
/// client has for **any** unit, itself included: the reference reads a full CGItem's 7-slot
/// array (`item+0xc`, wow-re `item-visual-enchant.md` §3b), which on the wire is these.
/// Consumed by the weapon-glow lane (decision 0805); `None` = empty slot / field not sent.
pub fn player_visible_item_enchant(&self, i: u8, j: u8) -> Option<u32> {
(i < 19 && j < 7)
.then(|| {
self.get_u32(
FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR + 3 + 12 * u16::from(i) + u16::from(j),
)
.filter(|&e| e != 0)
})
.flatten()
}
/// `PLAYER_FIELD_INV_SLOT_HEAD + 2i` — our equipment (0–18) and equipped-bag (19–22) guids.
pub fn player_inv_slot(&self, i: u8) -> Option<u64> {
(i < 23).then(|| self.get_guid(FIELD_PLAYER_INV_SLOT_HEAD + 2 * u16::from(i)))?
+4
View File
@@ -29,6 +29,10 @@ bevy_egui = "0.39"
# Zero-copy POD→bytes for the shared light storage buffer (one per-frame write_buffer). Already in
# the tree transitively; pinned to the same version as the dev-dep below.
bytemuck = { version = "1", features = ["derive"] }
# `f16` decode for the booth-target dump (`WOW_BOOTH_DUMP`): the booth render targets are
# `Rgba16Float` (decision 0804) and the PNG wants display bytes. Already in the tree via
# wgpu/bevy_image; declared so the call site is an honest dependency.
half = "2"
# `getrusage` — whole-process CPU time (all threads), the perf probes' `cpu_ms`/`cpu_pct` metric
# (`perf::process_cpu_secs`). Wall-clock fps is unusable as a regression instrument on a machine
# running parallel sessions' builds; CPU-per-frame is, and it is also the number the Mac report
+1 -1
View File
@@ -59,7 +59,7 @@ struct WowLight {
fog_color: vec4<f32>, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend).
fog_params: vec4<f32>, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall.
_sh: array<vec4<f32>, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain.
sh_c16: vec4<f32>, // row 12: .w = the dynamic point-light gain (decision 0273); xyz the models' c16 band.
sh_c16: vec4<f32>, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired).
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by terrain.
grade: vec4<f32>, // reserved (the 0282 interior A/B retired; 0163). Layout only.
_wmo_fog: array<vec4<f32>, 2>, // rows 18-19: the interior fog triple — unread by terrain.
+108 -84
View File
@@ -7,7 +7,7 @@
// MCSH grey from q12 still rides on `ATTRIBUTE_COLOR → pbr_input.material.base_color` so per-doodad
// shadowing remains.
//
// color = clamp(A + D·SHADE·max(N·L,0)) × tex × tint // M2 doodads: FFP matte, terrain-shaded sun, gamma
// color = clamp(A + D·I·f(N·u)) × tex × tint // M2 doodads: the Model2.bls order-2 lobe (0803)
// color = clamp(ambient + diffuse·max(N·L,0)) × … // clutter / WMO: FFP directional matte (sun-scale 1)
// color = mix(fog_color, color, fog_factor) // Step 5 fog
// out = color // raw gamma — the frame's ONE decode is in FFXGlow (0161)
@@ -17,21 +17,23 @@
// detail clutter, the merged mesh ships `ATTRIBUTE_COLOR = (mcsh_tint, mcsh_tint, mcsh_tint, 1)`,
// which Bevy folds into base_color → the lit factor is multiplied by the MCSH grey per-doodad.
//
// Models (M2 doodads/creatures/player) are lit here by the same fixed-function directional matte as
// terrain and WMO — `clamp(ambient + diffuse·max(N·L,0))` on the model's own vertex normal, to-light =
// `−light_sun.xyz`. The one difference for exterior WORLD doodads: the sun/diffuse term is scaled by the
// per-instance TERRAIN-SHADE sampled at the doodad's base (2.5 on lit ground, 0.5 on MCSH-shadowed
// ground — verified `[def+0xa4]`, ratio 0.2 load-bearing; ambient stays full).
// **Two different lighting laws live in this file, and confusing them has cost us three decisions.**
// Terrain, clutter and WMO genuinely ARE fixed-function (GL_LIGHTING + GL_LIGHT0 + GL_COLOR_MATERIAL,
// byte-verified off WoW.exe 5875 — and all five FFP light-commit call sites are terrain's), so they take
// `clamp(ambient + diffuse·max(N·L,0))`. The exterior M2 lane is NOT fixed-function: the reference loads
// `Shaders\Vertex\Model2.bls` out of misc.MPQ (gated on the VERTEX cvar `M2UseShaders`, which defaults to
// "1") and that program is an order-2 irradiance lobe, running on every exterior M2 it draws — doodad,
// GameObject, creature and player alike. So M2 doodads take that lobe, `E = A + D·I·(4/17)(0.375 + 2μ +
// 1.875μ²)` with μ = N·u toward-light, clamp01 on the SUM (0803, and the lane comment at step 7).
// Per-instance `I` is the terrain-shade family sampled at the doodad's base (2.5 lit / 0.5 MCSH-shadowed,
// `[def+0xa4]`) — see step 7 for the one part of that still open.
//
// **On the exterior M2 lane that matte is OUR choice, not the reference's response** — see 0796 and the
// lane comment at step 7. Terrain and WMO genuinely are fixed-function (GL_LIGHTING + GL_LIGHT0 +
// GL_COLOR_MATERIAL, byte-verified off WoW.exe 5875, and all five FFP light-commit call sites are
// terrain's); M2 is not. An earlier version of this header claimed "there is NO M2 irradiance lobe … no
// such program runs — M2UsePixelShaders defaults off". That is wrong twice over: the gate is the VERTEX
// cvar `M2UseShaders`, which defaults to "1", and the program it loads (`Shaders\Vertex\Model2.bls`, out
// of misc.MPQ) IS an order-2 irradiance lobe, running on every exterior M2 the reference draws. The claim
// is corrected rather than deleted — it stood long enough to seed a decision record, and the next reader
// should know it was retired, not that it was never made.
// Two retracted claims are recorded rather than deleted, because each stood long enough to seed a
// decision record and the next reader should know they were retired, not that they were never made:
// (a) "there is NO M2 irradiance lobe … no such program runs — M2UsePixelShaders defaults off" — wrong
// twice, wrong cvar and the program does run; (b) "on the exterior M2 lane that matte is OUR choice"
// (0796's framing) — true while 0410's cutoff stood, retired by 0803, which put the lane back on the
// reference's own curve.
//
// Specular (row 9 separate-specular, local viewer) is verified by q4/q5/Q13 but kept OUT of this
// step — M2 per-material shininess (q4 §6 INFERRED) is its own A/B and lives in Step 7b. WMO
@@ -90,7 +92,7 @@ struct ModelParams {
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
// `Model2.bls` probe of the day/night scene light at intensity 1 — the reference's own exterior M2
// response, read by the doodad/entity lane under the `WOW_M2_SH=1` A/B (0796 §5) and dark otherwise.
// response, and since 0803 the live law on the doodad/entity lane.
// Shared with terrain.wgsl — a tree and the dirt under it fade into the same haze.
// (light_spec is part of the prefix layout but the model path never reads it.)
struct WowLight {
@@ -106,22 +108,22 @@ struct WowLight {
// linear in the committed colour, so a consumer scales them ALL by the per-instance intensity
// (never I²); the sun's DC redistribution rides `grade.yzw` (× intensity).
//
// **LIVE only under the response A/B (`WOW_M2_SH=1`; 0796 §5).** 0410 took the exterior M2 lane
// off this curve onto the hard-cutoff FFP matte on the director's look call, and the block sat
// packed-but-unread until that call was reopened: 0796 REFUTED the fidelity premise behind
// retiring it (the reference's M2 lane IS this SH shader, not an FFP light slot), so the cutoff
// now stands purely as a look call and the two need to be seen side by side. `sh_c16.w` carries
// the switch (it had been free since the 0273 point gain and the 0750/0751 sun dial retired).
// Whichever side the director calls, the loser goes — the eval below, or this block plus
// `pack_model_core_rows`' fold and the four struct mirrors that carry it for layout.
// **LIVE — the exterior doodad/entity response (0803).** History worth knowing before you touch
// it: 0410 took this lane OFF the curve onto a hard-cutoff FFP matte on the director's look call,
// and the block then sat packed-but-unread for months. 0796 refuted the fidelity premise behind
// that retirement (the reference's M2 lane IS this SH shader, not an FFP light slot — the FFP
// commits in a world frame are terrain's), which reopened the call as a pure look question;
// 0799 restored the eval behind an A/B so it could be seen; 0803 is the director calling it,
// and the cutoff branch came out with the flag.
sh_c10_r: vec4<f32>,
sh_c10_g: vec4<f32>,
sh_c10_b: vec4<f32>,
sh_c13_r: vec4<f32>,
sh_c13_g: vec4<f32>,
sh_c13_b: vec4<f32>,
// xyz = the true c16 quad band (x²−y², per channel), live with the block above; w = the exterior
// M2 response A/B (0796 §5): >0.5 ⇒ the SH lobe, else the shipped hard cutoff.
// xyz = the true c16 quad band (x²−y², per channel), live with the block above. w = FREE — it
// carried the 0273 point gain, then the 0750/0751 sun dial, then 0799's response A/B, and every
// one of those is retired. Nothing writes it and nothing reads it; claim it if you need a lane.
sh_c16: vec4<f32>,
_water: array<vec4<f32>, 4>, // rows 13-16: the liquid swatches — unread by models.
// x = SIDN night fraction (1 overnight, 0 by day — scales m.sidn.rgb).
@@ -150,6 +152,12 @@ struct WowLight {
// `world_from_joint × inverse_bindpose`, the same matrix Bevy's skin lane would feed
// `skin_model` — blended in the vertex stage below (WOW_RIG_SKIN).
rig_table: array<u32, 2048>,
// The per-instance body TINT, on the SAME slot index as `rig_table` (instance_tint.rs, decision
// 0812): the CM2 `model+0x184/188/18c` modulate colour, packed `0xFFRRGGBB` exactly as the
// reference packs its node value (`0x60d840`: `param | 0xff000000`). A word of **0 is identity**
// — so slot 0 (every unskinned instance in the world), a zeroed studio buffer and an untinted
// frame all cost nothing, while a genuine authored BLACK tint still reads as 0xFF000000.
rig_tint: array<u32, 2048>,
palettes: array<vec4<f32>>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -496,6 +504,23 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
let fade_tag = raw_tag & 0x3fffffffu;
let alpha6 = f32(fade_tag & 0x3fu) / 63.0;
let obj_fade = select(alpha6, 1.0, fade_tag == 0u);
// The per-instance body TINT (instance_tint.rs, decision 0812) — the aura state kit's CharProc 1:
// an aura painting the whole model one colour (ghost pale blue, poison green, Frostbolt blue).
// Indexed by the SAME rig slot the vertex stage skins from (bits 19-29), so it needs no tag bits
// of its own; a `0` word is identity, which is every unskinned instance (slot 0) and every
// untinted unit. It is the material's ambient+diffuse colour (gx SetState(1) →
// GL_COLOR_MATERIAL), so it multiplies the light sum INSIDE the clamp below and never the
// emission terms — the same placement the WMO branch already gives MOCV.
let tint_word = wow_light.rig_tint[(fade_tag >> 19u) & 0x7ffu];
let inst_tint = select(
vec3<f32>(
f32((tint_word >> 16u) & 0xffu),
f32((tint_word >> 8u) & 0xffu),
f32(tint_word & 0xffu),
) * (1.0 / 255.0),
vec3<f32>(1.0),
tint_word == 0u,
);
if (m.model_flags.y > 0.5 && base_color.a < VANILLA_ALPHA_KEY) {
discard;
}
@@ -541,33 +566,31 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
let n1 = vec4<f32>(n_lit, 1.0);
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
// Exterior world doodad/entity: a hard-cutoff FFP matte — `clamp01(ambient + D·min(I,1)·
// max(N·L,0))`. **A DELIBERATE DEVIATION, not the reference's response** (0796; read that
// record before touching this lane).
// Exterior world doodad/entity: **the reference's own response** —
// `clamp01(A + C·I·(4/17)(0.375 + 2μ + 1.875μ²))`, μ = N·u toward-light, the order-2
// `Model2.bls` lobe evaluated over the SH rows above (0803; read that record and 0796 before
// touching this lane).
//
// 0410 put the hard cutoff here on the director's look call — a sunlit character's shadow side
// must read the SAME as the same skin standing in shade, and the SH lobe's authored wrap
// (0.059·C on the anti-sun side, dipping −0.037·C mid-back) tinted it warm and lifted it.
// 0753 then read the reference's own apitrace as CORROBORATING that shape (an FFP light slot,
// "no SH constants anywhere in the trace") and set the `min(I,1)` commit. **0796 refutes the
// fidelity half of 0753**: wow-re's §5 cross-check shows the reference's M2 lane — ADT doodad,
// GameObject, creature, player alike — is its own authored `Model2.bls` vertex shader running
// order-2 SH, and the FFP light commits visible in a world frame belong to TERRAIN
// (`0x71c730`'s five `0x68xxxx` call sites) and the WMO path. M2's single FFP call site
// The long way round, because the scar tissue matters: 0410 replaced this curve with a
// hard-cutoff FFP matte on the director's look call — a sunlit character's shadow side must read
// the SAME as the same skin standing in shade, and the lobe's authored wrap (0.059·C on the
// anti-sun side, dipping −0.037·C mid-back) tinted it warm and lifted it. 0753 then read the
// reference's own apitrace as CORROBORATING the cutoff (an FFP light slot, "no SH constants
// anywhere in the trace"). **0796 refuted that**: wow-re's §5 cross-check shows the reference's
// M2 lane — ADT doodad, GameObject, creature, player alike — is its own authored `Model2.bls`
// vertex shader running order-2 SH, and the FFP light commits visible in a world frame belong to
// TERRAIN (`0x71c730`'s five `0x68xxxx` call sites) and the WMO path. M2's single FFP call site
// (`70bdf6`) is reachable only with `M2UseShaders` off. "No SH constants" was a measurement
// artifact — constants upload as dirty-range deltas whose start register varies.
//
// So the reference's response on this lane is `E = A + C·(4/17)(0.375 + 2μ + 1.875μ²)`,
// μ = N·u, `clamp01` on the SUM, with the per-instance gain UNCLAMPED (linear-band gains
// observed live at 0.5/0.85/1.0/1.874/2.5). What survives here is 0410's look call alone.
// The curve is still packed and dormant in rows 6-12 (see WowLight) — one predicate away.
// artifact — constants upload as dirty-range deltas whose start register varies. That left 0410
// standing as a look preference against a verified curve, so 0799 put the two behind an A/B and
// 0803 is the director choosing the curve. The cutoff branch left with the flag.
//
// The CPU light animator targets the per-instance intensity `[node+0xa4]` — 2.5 on lit ground
// / 0.5 on MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, `0x69e280`;
// the force-1.0 leg is `0x69e36b`). Lane history: `Model2.bls` SH lobe (0354/0358) → FFP
// matte, hard cutoff (0410, director's call) → unclamped source (0706) → verified curve
// (0747) → peak-norm (0750) → calibration dial (0751) → the 0753 trace law → 0796, which
// keeps the pixels and demotes the justification to a deviation.
// (0747) → peak-norm (0750) → calibration dial (0751) → the 0753 trace law → 0796 (kept the
// pixels, demoted the justification) → 0799 (the A/B) → 0803, back on the curve for good.
//
// The per-material `sun_scale.x` selector has THREE states (model_render::ShadeSel): ≥0.85 =
// the lit-ground family (ADT doodads and every entity M2 — animator target 2.5, mixed toward
@@ -576,40 +599,34 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
// WMO MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically
// MCSH-shadowed (0.5). The ramp runs in animator units and the COMMIT clamps.
//
// **OPEN (0796): putting entity M2 in the lit-ground family is contradicted by observation.**
// A player + three NPCs across two of the reference's own apitraces all commit their sun at
// gain EXACTLY 1.0 — never 2.5, never 0.5 — while the same frames' ADT doodads show the full
// 0.5/0.85/1.0/1.874/2.5 spread (wow-re `trace-forensics-northshire-d3d` §5). That signature
// is `0x672a20`'s null-node fallback, i.e. a unit may not sit on the terrain-shade chain at
// all. The byte chain benilla built this on (`m2-interior-doodad-base-light` §9) is verified
// up to its FINAL link — "the unit's own node fills the unit's light" — which is the INFERRED
// one the traces contradict; wow-re has it as an open follow-up. It is NOT acted on here
// because `min(I,1)` below already lands a lit-ground unit on 1.0, so the visible error is
// confined to a unit on MCSH-shadowed ground (we dim to 0.5, the reference observes 1.0), and
// the honest fix deletes a byte-verified subsystem (0173/0354/0477/0480) on a mechanism wow-re
// has not closed. Do not "fix" it by unclamping — that lands every character at ×2.5.
// **The per-instance GAIN — half untied (0809).** The reference's own traces show a player +
// three NPCs committing their sun at gain EXACTLY 1.0, never 2.5 or 0.5, while the same frames'
// ADT doodads show the full 0.5/0.85/1.0/1.874/2.5 spread (wow-re
// `trace-forensics-northshire-d3d` §5). wow-re settled the mechanism in
// `unit-mcsh-shadow-target.md`: the 2.5/0.5 node TARGET is byte-shared between a unit and an ADT
// static (single 2.5 site `69e4ad`, shared MCSH sampler `0x69b350`), but DELIVERY splits at
// `0x672a20` — with `[model+0x3c0]==0` the null fallback commits the raw day/night pair with NO
// intensity multiply anywhere: hardwired ×1.0, position-independent, never sampling MCSH. A
// unit's node is born UNREGISTERED (`0x670db0`'s birth-register gate `670fca` is skipped for the
// model-arg-0 spawn), so that is the normal outdoor case. `entity_shade` models exactly that,
// pinning a unit's tag byte to the day/night point CPU-side — units no longer reach the clamp
// below to land on ×1.0, they are already there.
//
// What is left of the knot falls only on statics: `min(I,1)` puts an ADT doodad on lit ground at
// ×1.0 where the reference gives it ×2.5, so **doodads still read dimmer than the reference**.
// Cutting it is safe now that units are off the chain; before 0809 it would have landed every
// character at ×2.5, precisely the amplitude 0410 rejected.
let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop);
let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5);
let shade_t = max(mat_shade, inst_shade);
let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85;
let intensity = min(select(mix(2.5, 0.5, shade_t), 1.0, mid_band), 1.0);
let lit_ffp = wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * (intensity * ndotl);
// THE A/B (`WOW_M2_SH=1` → row 12 `.w`; 0796 §5) — the reference's OWN response on this lane,
// the order-2 `Model2.bls` lobe over the dormant SH rows, restored beside the cutoff so the
// director can judge the two by eye. Same closed form the interior-prop lane below runs and the
// same one `pack_model_core_rows` folds: `E = A + C·(4/17)(0.375 + 2μ + 1.875μ²)`, μ = N·u.
// Every sun band is linear in the committed colour, so ONE `intensity` multiply covers the whole
// lobe (never I²); ambient rides the c10 `.w` lanes and does NOT scale with it, and the sun's DC
// redistribution rides `grade.yzw`.
//
// `intensity` is shared with the FFP branch ON PURPOSE, so the A/B has exactly one variable —
// the curve. It keeps `min(I,1)`, which is refuted as fidelity (0796 §1) but happens to give a
// character in sunlight the ×1.0 the reference is observed to give it — i.e. the amplitude for
// the case 0410 actually judged. The known cost: an ADT doodad on lit ground sits at ×1.0 where
// the reference gives it ×2.5, so DOODADS read dimmer than the reference under either branch.
// Unclamping needs 0796 §4 (units) settled first or every character lands at ×2.5.
// The lobe. Same closed form the interior-prop lane below runs and the same one
// `pack_model_core_rows` folds. Every sun band is linear in the committed colour, so ONE
// `intensity` multiply covers the whole lobe (never I²); ambient rides the c10 `.w` lanes and
// does NOT scale with it, and the sun's DC redistribution rides `grade.yzw`.
let sun_dc = wow_light.grade.yzw * intensity;
let lit_sh = vec3<f32>(
let sun_lobe = vec3<f32>(
wow_light.sh_c10_r.w + sun_dc.x
+ intensity
* (dot(wow_light.sh_c10_r.xyz, n_lit) + dot(wow_light.sh_c13_r, quad)
@@ -625,12 +642,9 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
);
// clamp01 the SUM, never per term — the lobe dips to −0.037·C around μ≈−0.53 (low-order-SH
// ringing, the reference's own authored response), and clamping the sun term alone would floor
// that away and lose the mid-back dip the whole A/B is partly about.
let lit_doodad = clamp(
select(lit_ffp, lit_sh, wow_light.sh_c16.w > 0.5),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
// that away and erase the mid-back dip. That dip is part of the response 0803 chose, not noise
// to tidy up.
let lit_doodad = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
// WMO buildings (model_flags.x) use the FFP directional N·L at sun-scale 1.0 — the reference lights them
// with `ambient + sun·max(N·L,0)` and does NOT apply the exterior doodad terrain-shade (verified: prog
// 198/VS 151). Their per-vertex MOCV shade rides in `base_color` (ATTRIBUTE_COLOR) → folds into `albedo`,
@@ -788,23 +802,33 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
);
}
} else {
// An M2 instance: `inst_tint` is its CM2 modulate colour, multiplying the light terms inside
// the clamp exactly where the WMO branch above multiplies MOCV — both are the material's
// ambient+diffuse under GL_COLOR_MATERIAL — and never the emission terms beside them. It is
// identity for everything untinted, so this is the old product bit-for-bit until an aura
// writes a colour. (Left off the `is_wmo` branch on purpose: a WMO surface is not a CM2
// instance and has no tint slot of its own.)
let primary = clamp(
lit + point_diffuse + sidn_e + vec3<f32>(highlight),
inst_tint * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
lit_rgb = albedo * primary;
}
#else
// (`sidn_e` is zero for every M2 batch; a WMO batch without MOCV lands here too and keeps it.)
// (`sidn_e` is zero for every M2 batch; a WMO batch without MOCV lands here too and keeps it —
// harmlessly, since a WMO instance's tint slot is the identity slot 0.)
let primary = clamp(
lit + point_diffuse + sidn_e + vec3<f32>(highlight),
inst_tint * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
lit_rgb = albedo * primary;
#endif
var rgb = select(lit_rgb, albedo, is_emissive);
// The fullbright/UNLIT path takes the tint too, unlike the highlight: with GL_LIGHTING off the
// same gx state (SetState(1)) is a plain `glColor` modulate on the texture, while GL_EMISSION is
// dead. So a ghost's glow cards tint with its body.
var rgb = select(lit_rgb, albedo * inst_tint, is_emissive);
// Step 5 fog — same gamma-space linear fog as terrain.wgsl. Same DBC values are pushed onto
// both materials by `apply_wow_lighting`, so a tree and the dirt under it land on the same
+610
View File
@@ -0,0 +1,610 @@
//! **The aura-state CharProc layer** — what an aura does to the *body itself*: its translucency and
//! its tint, for exactly as long as the aura lives. This is the half of a stage-2 "state kit" that
//! isn't an attach-point emitter (those are `creature_anim::spell_visual`'s
//! [`arm_aura_state_fx`](crate::creature_anim::arm_aura_state_fx), decision 0393): where that
//! watcher hangs *models* on the unit, this one changes how the unit's own model **renders**.
//!
//! B114 ("Stealth shows nothing on the character") was this whole layer missing. Stealth's state
//! kit carries no effect models and no anim at all — its entire visual is one CharProc — so the
//! effects-only watcher resolved the kit, found an empty slot list, and dropped it on the floor.
//!
//! ## The mechanism (VERIFIED — wow-re `ghost-death-visuals.md` §2, `state-kit-aura-lifecycle.md`)
//!
//! `UNIT_FIELD_AURA` slot change → the aura watcher `0x604d00` → `0x6123f0` → **`0x5ff350`
//! PlayAuraStateVisual**: the slot's spell → `Spell.dbc` → `SpellVisual` → **field 4 = the state
//! kit** → `PlaySpellVisualKit(kit, stage 2)`, whose tail (`0x60f358: push esi(kit);
//! 0x60f35c: call 0x60d7c0`) runs the **CharProc dispatcher** over the kit's four proc slots
//! (`SpellVisualKit` fields 15–34 — `benilla_formats::CharProc`). Two of the dispatcher's nine cases
//! 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`
//! (`0x614baa → 0x710cb0`), which multiplies into the per-batch alpha (`0x707680`).
//! - **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.
//!
//! 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
//! found three sites, all nameplate/marker suppression (wow-re `ghost-death-visuals.md` §1). The two
//! co-travel only because the server sets the flag and the aura from the same spell. Keying the body
//! off the flag would be the wrong mechanism that happens to look right on stealth and then fails on
//! every other member of the family.
//!
//! ## The shipped data (read from the real 5875 `SpellVisualKit.dbc` this session —
//! `benilla-extract charprocs` censuses it)
//!
//! 185 of 1772 kits carry a CharProc, and the **state** stage is far and away their home: 103 state
//! kits, ~1200 (spell, proc) pairs. Proc 14 covers **11 kits / 135 spells** — kit 312 `0.3` is the
//! whole sneak family (Stealth 1784-1787, Prowl, Vanish, Shadowmeld, Hide, Sneak, Disguise), kit
//! 3450 `0.5` is Invisibility/Fade, kit 989 `0.5` the ghost aura, 3129 `0.65` Shadowform, 1286 `0.3`
//! Banish, 3989 `1.0` Possess, 5129 `0.0` a quest fade-out. Proc 1 covers 73 state kits (~800
//! spells): Frostbolt's chill blue, Immolate's ember orange, the poison green.
//!
//! ## What is built here, and what is not
//!
//! **Proc 14 ships whole** — nodes, the `baseAlpha × Π` product, 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
//! the reference's `unit+0xce0` list is) and [`apply_aura_tint`] publishes the head to
//! [`crate::instance_tint`], the per-instance modulate channel keyed on the part's rig slot. Written
//! on change, never eased — the reference's own asymmetry (`unit+0xd04` change-detection straight to
//! `0x710cf0`, with none of the alpha's 1000 ms ramp).
//!
//! 0806 recorded this as blocked on a render-architecture call between per-unit material clones and a
//! tag-indexed palette. That framing was wrong on its own terms: the `MeshTag` rig field already IS a
//! per-instance slot into the shared light buffer, so the tint needed **no** new tag bits (which was
//! the whole stated blocker — the payload has 5 spare bits outdoors and zero indoors) and no material
//! churn. What it did need was reading our own renderer properly.
//!
//! The tint reaches every **skinned** part of a unit, which is the body: skin, face, hair, and every
//! creature's whole model. Separately-attached M2s (held weapons, shoulders) do not carry the rig
//! slot and so stay untinted for now — in the reference they inherit the parent's computed colour
//! (`0x714000` recursion), so this is a real gap; giving them an instance slot means handing them a
//! `MeshTag` they may not have today, which the ground-shade writer keys on ("matched by carrying a
//! `MeshTag`"), so it wants its own pass rather than a rider here.
//!
//! Types 2, 7, 8, 11 and 13 also appear on state kits (Berserk/Bloodlust's 2, the Sap/Feign-Death 7,
//! the Freeze/Ice-Block 11, the "Glowy (Red)" 13) and have **no verified mechanism** — the wow-re
//! dispatcher note names only the cases above. They fall through [`node_for`]'s single `_` arm, which
//! is where each lands as one match arm the day its RE returns.
use benilla_protocol::EntityKind;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::interior::InteriorLit;
use crate::model_fade::{fade_alpha, FadeMaterials, PendingAppearFade, RenderFade};
use crate::terrain::WowModelMaterial;
/// The reference's aura-alpha ramp: `StartAlphaFade(target, **1000 ms**)` (`0x614f80` from
/// `0x60d180`'s recompute), eased `clamp01(t)³` by `0x614a90` — [`fade_alpha`] is that curve.
///
/// It is a **code constant, not a kit column**: proc 14's `params[1..]` are `[2.0, 1000.0, 0.0]` on
/// the sneak kit but `[0.0, 0.0, 0.0]` on the ghost's, and the ghost's ramp is byte-cited at the same
/// 1000 ms — so the sneak kit's literal `1000.0` cannot be the source (a data-sourced duration would
/// snap the ghost instantly). Across the whole shipped table proc 14's `params[2]` is only ever
/// `0.0`, `2.0` or `1000.0`, which is no duration scale in any unit. Read from `params[0]` only.
pub(crate) const AURA_ALPHA_FADE_SECS: f32 = 1.0;
/// Settled-ramp epsilon — under half a step of the tag's 6-bit alpha field (1/64), so a ramp that
/// has visually arrived is treated as arrived.
const ALPHA_EPS: f32 = 1.0 / 128.0;
/// One CharProc node the dispatcher installed on a unit, keyed by the aura's spell id exactly as the
/// 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.
Alpha(f32),
/// Proc 1: this aura's body tint, unpacked from the param's `0x00RRGGBB`. Modelled, not yet
/// rendered (module docs).
Tint([u8; 3]),
}
/// 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.
///
/// 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,
/// which is also why [`apply_aura_alpha`] walks the root's whole descendant tree rather than its
/// 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.
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.
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.
base: f32,
/// Where the ramp is now — what the parts actually render at.
current: f32,
/// The ramp: `from → to` over [`AURA_ALPHA_FADE_SECS`] from `started` (`Time::elapsed_secs`).
from: f32,
to: f32,
started: f32,
/// Whether [`apply_aura_alpha`] still owns this unit's parts. Set while the unit is translucent
/// and for the one frame the ramp latches back at opaque — that frame performs the **release**
/// (writes the settled alpha and hands the material back to the part's light law), the same
/// hand-back protocol the self-avatar feather uses (decision 0213). Without it, a unit whose
/// stealth drops stays latched on the blend twin at its last low alpha until something else
/// happens to rewrite the channel.
authoring: bool,
}
impl AuraNodes {
/// A fresh node set for a unit whose `baseAlpha` is `base`, ramp settled at `base`.
fn new(base: f32) -> Self {
Self {
base,
current: base,
from: base,
to: base,
..Default::default()
}
}
/// The reference's `0x60d180`: `baseAlpha × Π(node alphas)`, clamped to a sane `[0, 1]`.
fn target(&self) -> f32 {
self.alpha
.iter()
.fold(self.base, |acc, (_, f)| acc * f)
.clamp(0.0, 1.0)
}
/// Point the ramp at the current product, 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) {
let target = self.target();
if (target - self.to).abs() <= f32::EPSILON {
return;
}
self.from = self.current;
self.to = target;
self.started = now;
}
/// Advance the ramp to `now` and return the live alpha.
fn tick(&mut self, now: f32) -> f32 {
let t = (now - self.started) / AURA_ALPHA_FADE_SECS;
self.current = fade_alpha(self.from, self.to, t);
self.current
}
/// Whether the unit renders translucent right now (the ramp has arrived at, or is heading
/// anywhere below, opaque).
fn translucent(&self) -> bool {
self.current < 1.0 - ALPHA_EPS
}
/// The head tint node's RGB — the reference's `unit+0xce0` head, which its per-frame apply reads
/// (`0x60cbd9`). `None` when the unit carries no tint aura. Published to the render channel by
/// [`apply_aura_tint`].
pub(crate) fn head_tint(&self) -> Option<[u8; 3]> {
self.tint.first().map(|(_, rgb)| *rgb)
}
}
/// Publish every rig's body tint to the per-instance channel ([`crate::instance_tint`], decision
/// 0812): the head node's RGB packed the reference's way, keyed on the unit's rig slot — the slot
/// each of its skinned parts already carries in its `MeshTag`, and which the fragment stage reads to
/// index the table.
///
/// **On change, not eased** — the reference's own asymmetry between the two node lists: the alpha
/// gets a 1000 ms cubic ramp (`0x614f80`), the tint goes straight in on change-detection
/// (`unit+0xd04` → `0x710cf0`). `InstanceTints::set` is idempotent, so the steady state costs one
/// compare per rig and never touches the upload.
///
/// Iterates every **`RigSkin`**, not every `AuraNodes`, deliberately: reasserting the whole table
/// each frame means no edge can leak a stale colour into a recycled slot — not an `AuraNodes` that
/// went away, not a unit whose last tint aura dropped. (The despawn/rebuild edge is covered
/// structurally too, in `RigSkin`'s free hook.)
pub(crate) fn apply_aura_tint(
rigs: Query<(Option<&AuraNodes>, &crate::rig_palette::RigSkin)>,
mut tints: ResMut<crate::instance_tint::InstanceTints>,
mut probe_logged: Local<bool>,
) {
let probe = tint_probe();
let mut painted = 0usize;
for (nodes, rig) in &rigs {
let word = probe
.or_else(|| nodes.and_then(AuraNodes::head_tint))
.map_or(crate::instance_tint::IDENTITY, crate::instance_tint::pack);
tints.set(rig.slot, word);
painted += 1;
}
if probe.is_some() && !*probe_logged {
*probe_logged = true;
info!("tint probe: painted {painted} live rig slot(s)");
}
}
/// `WOW_TINT_PROBE=RRGGBB` — paint **every live rig slot** that colour, auras ignored.
///
/// The one machine check for the GPU half of the channel: the region upload, the fragment stage's
/// slot lookup, the unpack, and the placement of the multiply. No headless test can reach any of
/// that, and the capture harness cannot stage a real aura to do it with — it sees no networked
/// entities at all (0799), so a tinted *unit* is unstageable there. Pointed at a scene with animated
/// (rigged) doodads it turns each of them the probe colour, which the visual harness then diffs.
///
/// It logs the slot count once, so an unchanged capture reads as "nothing in this scene is rigged"
/// rather than as "the channel is broken" — the two are indistinguishable from the image alone.
fn tint_probe() -> Option<[u8; 3]> {
static PROBE: std::sync::OnceLock<Option<[u8; 3]>> = std::sync::OnceLock::new();
*PROBE.get_or_init(|| {
let raw = std::env::var("WOW_TINT_PROBE").ok()?;
let hex = raw.trim().trim_start_matches('#');
let packed = u32::from_str_radix(hex, 16).ok()?;
Some([
(packed >> 16) as u8,
((packed >> 8) & 0xff) as u8,
(packed & 0xff) as u8,
])
})
}
/// One aura's CharProc edge, written by the aura-slot watcher
/// ([`crate::creature_anim::arm_aura_state_fx`], which owns the slot diff and the state-kit resolve
/// for both halves of a kit) and drained by [`drain_aura_procs`].
#[derive(Message, Clone, Debug)]
pub(crate) enum AuraProc {
/// The aura landed in a slot: install this kit's body procs, keyed by its spell id.
Begin {
entity: Entity,
spell_id: u32,
/// The kit's proc nodes, in slot order (the client walks all four and dispatches each).
nodes: Vec<AuraNode>,
},
/// The aura left the slots: drop every node this spell id installed (`0x5ff290`).
Reap { entity: Entity, spell_id: u32 },
}
/// Turn one `SpellVisualKit` CharProc into the node it installs, or `None` for a proc type we have
/// no verified mechanism for (module docs). **The single dispatch point** — the ECS twin of the
/// client's jump table `0x60dbfc`, and the one place a newly-REd proc type gets an arm.
pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
use benilla_formats::char_proc_type as ty;
match proc.ty {
ty::ALPHA => Some(AuraNode::Alpha(proc.params[0])),
// `round(param)` → `0x00RRGGBB` (`0x60d8cc` then ORs the opaque byte on). The param is a
// float column holding an integral colour: the ghost's 9222653.0 is 0x8CB9FD, pale
// blue-white; the poison kit's 65280.0 is 0x00FF00.
ty::TINT => {
let packed = proc.params[0].round().clamp(0.0, 0xff_ffff as f32) as u32;
Some(AuraNode::Tint([
(packed >> 16) as u8,
(packed >> 8) as u8,
packed as u8,
]))
}
_ => None,
}
}
/// 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).
pub(crate) fn drain_aura_procs(
mut edges: MessageReader<AuraProc>,
time: Res<Time>,
mut commands: Commands,
mut nodes: Query<&mut AuraNodes>,
units: Query<&crate::net::NetEntity>,
creatures: Option<Res<crate::entities::Creatures>>,
) {
let now = time.elapsed_secs();
// Node sets for units that don't carry the component yet — see the `Begin` arm.
let mut fresh: bevy::ecs::entity::EntityHashMap<AuraNodes> = Default::default();
for edge in edges.read() {
match edge {
AuraProc::Begin {
entity,
spell_id,
nodes: procs,
} => {
if procs.is_empty() {
continue;
}
if let Ok(mut n) = nodes.get_mut(*entity) {
install(&mut n, *spell_id, procs, now);
trace_edge("arm", *entity, *spell_id, &n);
} else {
// First edge for this unit: stage the node set locally rather than inserting
// straight away. `Commands` are deferred to the end of the system, so a unit
// 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
// 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(|| {
AuraNodes::new(base_alpha(*entity, &units, creatures.as_deref()))
});
install(n, *spell_id, procs, now);
trace_edge("arm", *entity, *spell_id, n);
}
}
AuraProc::Reap { entity, spell_id } => {
if let Ok(mut n) = nodes.get_mut(*entity) {
reap(&mut n, *spell_id, now);
trace_edge("reap", *entity, *spell_id, &n);
} else if let Some(n) = fresh.get_mut(entity) {
// Armed and reaped inside one frame (a flickering re-apply) — the staged set is
// the live one, so the removal has to land there too.
reap(n, *spell_id, now);
trace_edge("reap", *entity, *spell_id, n);
}
}
}
}
for (entity, n) in fresh {
commands.entity(entity).try_insert(n);
}
}
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp at the new product.
fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
n.alpha.retain(|(s, _)| *s != spell_id);
n.tint.retain(|(s, _)| *s != spell_id);
n.retarget(now);
}
/// 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).
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)),
}
}
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.
///
/// 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
/// the same press prints `aur arm spell=1784 alpha=[0.3] -> base 1.00 target 0.30`, and the drop
/// prints the ramp back to 1.00. It rides the shared move-trace sink so an aura edge interleaves on
/// one timeline with the movement/anim lines — a "why did it not fade when I moved" question needs
/// exactly that.
fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
if !crate::dbg_trace::enabled() {
return;
}
let alphas: Vec<f32> = n.alpha.iter().map(|(_, f)| *f).collect();
let tint = n.head_tint().map_or_else(String::new, |[r, g, b]| {
format!(" tint=#{r:02x}{g:02x}{b:02x}")
});
crate::dbg_trace::line(
"aur",
&format!(
"{what} e={entity} spell={spell_id} alpha={alphas:?}{tint} base {:.2} cur {:.2} target {:.2}",
n.base,
n.current,
n.target()
),
);
}
/// `baseAlpha` for a unit: `1.0` for a player, else its display's `CreatureModelAlpha / 255`.
fn base_alpha(
entity: Entity,
units: &Query<&crate::net::NetEntity>,
creatures: Option<&crate::entities::Creatures>,
) -> f32 {
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);
}
}
1.0
}
/// The fadeable-part view [`apply_aura_alpha`] authors through: the part's material pair and tag,
/// plus every other factor of its alpha (its animated colour alpha, its live appear/despawn ramp)
/// and the light law that decides which material variant it should be on. [`FadeMaterials`] is the
/// "this is a fadeable mesh" marker every writer of this channel keys on.
type AuraParts<'w, 's> = Query<
'w,
's,
(
&'static FadeMaterials,
&'static mut MeshTag,
&'static mut MeshMaterial3d<WowModelMaterial>,
Option<&'static crate::doodad_anim::MatAnim>,
Option<&'static InteriorLit>,
Option<&'static RenderFade>,
Has<PendingAppearFade>,
),
// Disjointness for the card pass (both want `&mut MeshTag`); a card never carries
// `FadeMaterials`, so this filter excludes nothing that would otherwise match — the same
// trick `apply_self_model_fade` uses for the same pair of queries.
Without<crate::billboard::BillboardCard>,
>;
/// Drive every unit's aura-alpha ramp and author its parts' render alpha + material.
///
/// Ordered **after** the three steady-state authors of a unit part's alpha field (the interior
/// classifier, the visibility authority, `entities::apply_unit_mat_alpha`) and **before** the
/// self-avatar zoom feather, so it overrides them for exactly the units that carry a live aura
/// alpha, and the self feather stays the last word on your own body (it folds this factor in
/// itself — [`crate::player::apply_self_model_fade`]). This is the same override-then-release
/// protocol the feather uses (decision 0213); [`AuraNodes::authoring`] is its latch.
///
/// The product it writes is the reference's own: this unit's `ramped aura alpha × the part's
/// animated colour alpha × its live appear/despawn ramp`. Reading the fade back out of
/// [`RenderFade`] rather than the tag keeps every factor a pure function of state, so the write is
/// idempotent (running it twice can't compound) exactly as `apply_unit_mat_alpha`'s is.
///
/// **The material swap is load-bearing, not cosmetic.** A cutout/opaque batch ignores instance
/// alpha, so writing `0.3` into the tag without moving the part onto its **blend twin** renders no
/// translucency at all — the same "blend pass while `0 < fade < 1`" the reference runs for its
/// doodad fade ([`crate::model_fade`]). It goes through [`FadeMaterials::material_for`], the one
/// place the blend-vs-law axes compose, so this writer and the classifier can never disagree.
/// Note the swap keys on the *instance* alpha only: a batch whose own animated colour alpha dips is
/// a per-batch quantity the reference combines separately, and forcing those onto a blend pass would
/// change every existing unit's look.
pub(crate) fn apply_aura_alpha(
time: Res<Time>,
mut roots: Query<(Entity, &mut AuraNodes)>,
children_of: Query<&Children>,
mut parts: AuraParts,
mut cards: Query<(
&crate::billboard::BillboardCard,
&mut MeshTag,
Option<&crate::doodad_anim::MatAnim>,
)>,
mut reauthor: ResMut<crate::interior::InteriorReauthor>,
) {
let now = time.elapsed_secs();
for (root, mut n) in &mut roots {
let alpha = n.tick(now);
let translucent = n.translucent();
if !translucent && !n.authoring {
continue; // settled opaque: not ours, nothing to release
}
// The walked set doubles as "which anchors belong to this unit" for the card pass below —
// built only for a unit we're authoring, so a quiet world never pays for it.
let mut walked = bevy::ecs::entity::EntityHashSet::default();
author_descendants(
root,
alpha,
now,
&children_of,
&mut parts,
&mut reauthor,
&mut walked,
);
author_cards(alpha, &walked, &mut cards);
// The release frame is the one where we authored the settled value and handed the material
// back; after it this unit costs a ramp tick and the branch above.
n.authoring = translucent;
}
}
/// Fold the unit's aura alpha into its **billboard cards** — the batches that can't be tree children.
///
/// An M2's billboard batches are world ROOT entities that merely *follow* an anchor inside the model
/// (decision 0153: their mesh is centred on the bone pivot and their transform belongs to the
/// billboard system), so a descendant walk cannot see them. Skipping them is a burned trap, not a
/// hypothetical: the night-elf eye glow — two additive `…EYEGLOW.BLP` quads at head height — went on
/// burning in mid-air after the body it belongs to had faded out (ledger B71), and a stealthed night
/// elf with full-brightness eyes is the same bug wearing this system's clothes. Cards are picked up
/// by the same rule the self-avatar feather uses: test the card's follow-anchor against the walked
/// set. One multiply covers both halves — the card dims with the body, and for an ADD blend the
/// shader's `out_rgb *= faded_alpha` takes a low alpha toward gone.
fn author_cards(
alpha: f32,
walked: &bevy::ecs::entity::EntityHashSet,
cards: &mut Query<(
&crate::billboard::BillboardCard,
&mut MeshTag,
Option<&crate::doodad_anim::MatAnim>,
)>,
) {
for (card, mut tag, anim) in cards.iter_mut() {
if !card.follows().is_some_and(|a| walked.contains(&a)) {
continue;
}
// Compose from the animation's own factor rather than from the tag we'd read back, so the
// card's per-sequence alpha animation stays alive under the aura — and so the release frame's
// `alpha = 1` write lands exactly on the value the card would have had anyway.
let authored = anim.map_or(1.0, |a| a.current);
let bits = crate::mesh_tag::with_alpha(tag.0, authored * alpha);
if tag.0 != bits {
tag.0 = bits;
}
}
}
/// Depth-first author for [`apply_aura_alpha`]: apply to `entity` if it is a fadeable part, then
/// recurse regardless — a joint or an attach-model root carries no [`FadeMaterials`] itself but the
/// held weapon / helm / shoulder meshes hang beneath it, and the reference's alpha is a per-CGUnit
/// property that its attached models render off (the same reason
/// [`crate::model_fade::apply_despawn_fade`] and the self feather both walk descendants, not
/// children).
fn author_descendants(
entity: Entity,
alpha: f32,
now: f32,
children_of: &Query<&Children>,
parts: &mut AuraParts,
reauthor: &mut crate::interior::InteriorReauthor,
walked: &mut bevy::ecs::entity::EntityHashSet,
) {
walked.insert(entity);
if let Ok((fm, mut tag, mut mat, anim, lit, fade, pending)) = parts.get_mut(entity) {
// A part still WAITING on its appear-fade is deliberately invisible until the ramp arms (its
// tag alpha is seeded near-zero at spawn), so authoring it would flash a streaming-in unit's
// geometry at the aura alpha for the pending window. Every other author of this channel
// filters the same way. Skip the write, still recurse — a joint's children can be past it.
if !pending {
// Every live factor on this part, recomputed from state (never read back from the tag),
// so the write is idempotent: the unit's ramped aura alpha × the batch's animated colour
// alpha × the part's own live appear/despawn ramp.
let ramp = fade.map_or(1.0, |f| {
let t = if f.duration > 0.0 {
(now - f.started) / f.duration
} else {
1.0
};
fade_alpha(f.from, f.to, t)
});
let combined = alpha * anim.map_or(1.0, |a| a.current) * ramp;
let bits = crate::mesh_tag::with_alpha(tag.0, combined);
if tag.0 != bits {
tag.0 = bits;
}
// The INSTANCE alpha decides the pass; a settled-opaque release hands the law's steady
// material back and asks the classifier to re-author, so nothing is left on a stale one.
let want = fm.material_for(lit, alpha < 1.0).clone();
if mat.0 != want {
mat.0 = want;
if alpha >= 1.0 && lit.is_some() {
// 0734's queue: let the classifier re-assert this part's full payload (probe
// slot / fog bit) over whatever this episode wrote.
reauthor.0.push(entity);
}
}
}
}
if let Ok(kids) = children_of.get(entity) {
for kid in kids {
author_descendants(*kid, alpha, now, children_of, parts, reauthor, walked);
}
}
}
/// This unit's live aura alpha, for the writers that compose it into their own product rather than
/// deferring to [`apply_aura_alpha`] (the self-avatar feather, which runs after it and wins on the
/// self body). `1.0` when the unit carries no aura nodes.
pub(crate) fn root_alpha(nodes: Option<&AuraNodes>) -> f32 {
nodes.map_or(1.0, |n| n.current)
}
#[cfg(test)]
mod tests;
+592
View File
@@ -0,0 +1,592 @@
//! Tests for the aura-state CharProc layer. The chains are the **real 5875 rows** (read off
//! `SpellVisualKit.dbc` this session with `benilla-extract charprocs`), so a schema or column-order
//! regression in the data layer shows up here as a wrong number rather than as a silent nothing —
//! which is precisely how B114 hid.
use std::collections::HashMap;
use benilla_formats::{
char_proc_type, CharProc, SpellVisualCatalog, VisualKit, VisualStages, KIT_CHAR_PROCS,
};
use benilla_protocol::messages::ObjectFields;
use bevy::prelude::*;
use super::*;
use crate::creature_anim::SpellVisuals;
// ── The real chains, from the shipped table ───────────────────────────────────────────────────────
/// Stealth rank 1 → `SpellVisual` 184 → state kit 312: proc 7 (unmodelled) + **proc 14 @ 0.3**, no
/// effect models, no anim. The whole of B114 in one row.
const STEALTH: u32 = 1784;
const STEALTH_VISUAL: u32 = 184;
const STEALTH_KIT: u32 = 312;
/// The ghost aura → visual 886 → kit 989: proc 1 tint `9222653.0` (= `0x8CB9FD`) + proc 14 @ 0.5.
const GHOST: u32 = 8326;
const GHOST_VISUAL: u32 = 886;
const GHOST_KIT: u32 = 989;
fn proc(ty: i32, param0: f32) -> CharProc {
CharProc {
ty,
params: [param0, 0.0, 0.0, 0.0],
}
}
/// A kit carrying `procs` in slot order and nothing else — the shape of every proc-only state kit.
fn proc_kit(procs: &[CharProc]) -> VisualKit {
let mut slots = [None; KIT_CHAR_PROCS];
for (slot, p) in slots.iter_mut().zip(procs) {
*slot = Some(*p);
}
VisualKit {
char_proc_slots: slots,
..Default::default()
}
}
// ── The dispatch ─────────────────────────────────────────────────────────────────────────────────
/// [`node_for`] is the twin of the client's proc jump table: 14 → an alpha node carrying `params[0]`,
/// 1 → a tint node with the packed colour unpacked, everything else → no node (and so no arm).
#[test]
fn the_dispatch_names_only_the_verified_procs() {
assert_eq!(
node_for(proc(char_proc_type::ALPHA, 0.3)),
Some(AuraNode::Alpha(0.3)),
"the sneak family's 0.3 rides params[0]"
);
assert_eq!(
node_for(proc(char_proc_type::ALPHA, 0.0)),
Some(AuraNode::Alpha(0.0)),
"kit 5129's 0.0 is a real value, not an absent one"
);
// 9222653 = 0x8CB9FD — the ghost's pale blue-white, byte-cited in wow-re as 0xFF8CB9FD after the
// client ORs the opaque byte on.
assert_eq!(
node_for(proc(char_proc_type::TINT, 9_222_653.0)),
Some(AuraNode::Tint([0x8C, 0xB9, 0xFD])),
);
// 65280 = 0x00FF00 — the poison state kit's pure green.
assert_eq!(
node_for(proc(char_proc_type::TINT, 65_280.0)),
Some(AuraNode::Tint([0x00, 0xFF, 0x00])),
);
// The five other types that ride real state kits have no verified mechanism yet: no node, so
// nothing is invented and the kit doesn't arm on them alone.
for ty in [2, 7, 8, 11, 13] {
assert_eq!(node_for(proc(ty, 1.0)), None, "type {ty} is unmodelled");
}
}
// ── The product 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".
#[test]
fn the_target_is_the_product_of_base_and_every_node() {
let mut n = AuraNodes::new(1.0);
assert_eq!(n.target(), 1.0, "no nodes → opaque");
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
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");
// A display authored translucent (CreatureModelAlpha 128/255) is the product's first term.
let mut half = AuraNodes::new(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.retarget(0.0);
assert!((half.target() - 128.0 / 255.0).abs() < 1e-6);
}
/// 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.
#[test]
fn re_arming_the_same_spell_replaces_its_node() {
let mut n = AuraNodes::new(1.0);
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
assert_eq!(n.alpha.len(), 1);
assert!((n.target() - 0.3).abs() < 1e-6);
}
/// The ramp is `StartAlphaFade(target, 1000 ms)` on the `clamp01(t)³` ease, in **both** directions:
/// arming eases down to the product, reaping eases back up — and a retarget mid-ramp starts from
/// where the ramp actually is, never from the old endpoint (a stealth dropped 300 ms into the fade
/// must not jump to 0.3 first).
#[test]
fn the_ramp_eases_over_one_second_both_ways() {
let mut n = AuraNodes::new(1.0);
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
assert!((n.tick(0.0) - 1.0).abs() < 1e-6, "t=0 is still opaque");
// t³ at the halfway mark = 0.125 of the way down.
let half = n.tick(0.5);
assert!(
(half - (1.0 + (0.3 - 1.0) * 0.125)).abs() < 1e-6,
"cubic ease, not linear (got {half})"
);
assert!(
(n.tick(AURA_ALPHA_FADE_SECS) - 0.3).abs() < 1e-6,
"arrived at 1000 ms"
);
assert!((n.tick(5.0) - 0.3).abs() < 1e-6, "and clamps past the end");
assert!(n.translucent());
// The aura drops: ease back to the new product from where we are.
n.alpha.clear();
n.retarget(AURA_ALPHA_FADE_SECS);
assert!((n.from - 0.3).abs() < 1e-6, "ramps from the live value");
assert!((n.to - 1.0).abs() < 1e-6);
assert!((n.tick(2.0 * AURA_ALPHA_FADE_SECS) - 1.0).abs() < 1e-6);
assert!(!n.translucent(), "settled opaque again");
// A mid-ramp retarget picks up the live value, not the endpoint.
let mut m = AuraNodes::new(1.0);
install(&mut m, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
let mid = m.tick(0.7);
m.alpha.clear();
m.retarget(0.7);
assert!((m.from - mid).abs() < 1e-6);
}
/// An aura refresh whose factor doesn't change must not restart the ease — otherwise a buff ticking
/// every few seconds would keep re-easing a settled body.
#[test]
fn a_retarget_to_the_same_value_does_not_restart_the_ease() {
let mut n = AuraNodes::new(1.0);
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
n.tick(AURA_ALPHA_FADE_SECS);
let started = n.started;
install(&mut n, STEALTH, &[AuraNode::Alpha(0.3)], 10.0);
assert_eq!(n.started, started, "same product → the ramp is untouched");
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.
#[test]
fn the_tint_head_node_is_the_first_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([0x8C, 0xB9, 0xFD]));
assert_eq!(n.target(), 1.0, "a tint node is not an alpha term");
}
// ── The watcher → drain edge, over the real Stealth chain ─────────────────────────────────────────
/// A fixture app carrying the two real chains, the aura watcher and the drain.
fn app_with_chains() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<crate::creature_anim::SpellKitFx>();
app.add_message::<AuraProc>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
(
STEALTH_VISUAL,
VisualStages {
state: STEALTH_KIT,
..Default::default()
},
),
(
GHOST_VISUAL,
VisualStages {
state: GHOST_KIT,
..Default::default()
},
),
]),
HashMap::from([
// Kit 312's real slots: the unmodelled proc 7 FIRST, then proc 14 — so this also pins
// that an unmodelled proc ahead of a modelled one doesn't shadow it.
(
STEALTH_KIT,
proc_kit(&[proc(7, 1.0), proc(char_proc_type::ALPHA, 0.3)]),
),
(
GHOST_KIT,
proc_kit(&[
proc(char_proc_type::TINT, 9_222_653.0),
proc(char_proc_type::ALPHA, 0.5),
]),
),
]),
)));
app.insert_resource(crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([
(
STEALTH,
benilla_formats::SpellDisplay {
visual: STEALTH_VISUAL,
..Default::default()
},
),
(
GHOST,
benilla_formats::SpellDisplay {
visual: GHOST_VISUAL,
..Default::default()
},
),
])),
..crate::ui_action::Spells::empty_for_tests()
});
app.add_systems(
Update,
(
crate::creature_anim::arm_aura_state_fx_for_test,
drain_aura_procs,
)
.chain(),
);
app
}
/// The arming world plus the per-instance tint channel (decision 0812): the table resource, the
/// palette allocator whose slots index it, and the publish system.
fn app_with_tint_channel() -> App {
let mut app = app_with_chains();
app.init_resource::<crate::instance_tint::InstanceTints>();
app.init_resource::<crate::rig_palette::RigPalettes>();
app.add_systems(Update, apply_aura_tint.after(drain_aura_procs));
app
}
/// **B114, end to end at the mechanism.** Stealth's state kit carries no effect models at all, so the
/// effects-only watcher never armed it and the character showed nothing. Now the same slot edge
/// installs a proc-14 node and the unit's target alpha is 0.3 — and dropping the aura ramps it back.
#[test]
fn a_proc_only_state_kit_arms_stealth_translucency() {
let mut app = app_with_chains();
// The aura lands in slot 0 (UNIT_FIELD_AURA[0] = field 47) with an occupied AURAFLAGS nibble.
let stealthed = ObjectFields::from_pairs(&[(47, STEALTH), (95, 0x0E)]);
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(stealthed))
.id();
app.update();
let n = app
.world()
.entity(unit)
.get::<AuraNodes>()
.expect("the sneak kit armed a node set");
assert_eq!(n.alpha.as_slice(), [(STEALTH, 0.3)]);
assert!((n.target() - 0.3).abs() < 1e-6);
assert!(n.head_tint().is_none(), "kit 312 carries no tint");
// The aura drops: the node goes and the target ramps back to opaque.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0,
)])));
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert!(n.alpha.is_empty(), "reaped on the aura-remove edge");
assert!((n.target() - 1.0).abs() < 1e-6);
}
/// The ghost aura's kit carries both procs: the tint node lands beside the alpha one off a single
/// slot edge (the dispatcher walks all four slots), and the alpha is the ghost's 0.5.
#[test]
fn a_kit_with_both_procs_installs_both_nodes() {
let mut app = app_with_chains();
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(47, GHOST),
(95, 0x0E),
])))
.id();
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert_eq!(n.alpha.as_slice(), [(GHOST, 0.5)]);
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.
#[test]
fn two_auras_stack_and_unstack_through_the_slots() {
let mut app = app_with_chains();
let both = ObjectFields::from_pairs(&[(47, STEALTH), (48, GHOST), (95, 0xEE)]);
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");
// Stealth alone drops (slot 0 cleared, slot 1 still occupied).
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(48, GHOST),
(95, 0xE0),
])));
app.update();
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
assert_eq!(n.alpha.as_slice(), [(GHOST, 0.5)]);
assert!((n.target() - 0.5).abs() < 1e-6);
}
/// A state kit whose every proc is unmodelled (and which carries no effect models either) must not
/// arm: no node set, so nothing claims the unit's alpha channel on a mechanism we haven't verified.
#[test]
fn an_unmodelled_proc_only_kit_arms_nothing() {
const SAP: u32 = 6770;
const SAP_VISUAL: u32 = 700;
const SAP_KIT: u32 = 691;
let mut app = app_with_chains();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([(
SAP_VISUAL,
VisualStages {
state: SAP_KIT,
..Default::default()
},
)]),
// Kit 691's real content: proc 7 @ 2.0 and nothing else.
HashMap::from([(SAP_KIT, proc_kit(&[proc(7, 2.0)]))]),
)));
app.insert_resource(crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(HashMap::from([(
SAP,
benilla_formats::SpellDisplay {
visual: SAP_VISUAL,
..Default::default()
},
)])),
..crate::ui_action::Spells::empty_for_tests()
});
let unit = app
.world_mut()
.spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[
(47, SAP),
(95, 0x0E),
])))
.id();
app.update();
assert!(
app.world().entity(unit).get::<AuraNodes>().is_none(),
"an unmodelled proc invents no body change"
);
}
// ── The render authoring ─────────────────────────────────────────────────────────────────────────
/// The author owns a translucent unit's parts **through the whole descendant tree** (a held weapon
/// hangs off a joint several levels down, and the reference's alpha is a per-CGUnit property its
/// attached models render off), writes the product into the tag's alpha field, and moves the part
/// onto its blend twin — without which a cutout batch ignores the alpha entirely and B114's symptom
/// survives the fix. On the frame the ramp latches opaque it releases: settled alpha, steady
/// material, and the part re-queued for the classifier.
#[test]
fn the_author_owns_the_tree_then_releases_at_opaque() {
use crate::model_fade::FadeMaterials;
use bevy::mesh::MeshTag;
let cutout: Handle<crate::terrain::WowModelMaterial> =
bevy::asset::uuid_handle!("c0000000-0000-4000-8000-00000000a114");
let blend: Handle<crate::terrain::WowModelMaterial> =
bevy::asset::uuid_handle!("b1000000-0000-4000-8000-00000000a114");
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::interior::InteriorReauthor>();
app.add_systems(Update, apply_aura_alpha);
let fm = FadeMaterials {
cutout: cutout.clone(),
blend: blend.clone(),
bake_blend: None,
};
// root → body part, and root → joint → held weapon (the depth the walk must reach).
let mut nodes = AuraNodes::new(1.0);
install(&mut nodes, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
let root = app.world_mut().spawn(nodes).id();
let body = app
.world_mut()
.spawn((
fm.clone(),
MeshTag(0),
MeshMaterial3d(cutout.clone()),
ChildOf(root),
))
.id();
let joint = app.world_mut().spawn(ChildOf(root)).id();
let weapon = app
.world_mut()
.spawn((
fm.clone(),
MeshTag(0),
MeshMaterial3d(cutout.clone()),
ChildOf(joint),
))
.id();
// Run the ramp out to its end: `MinimalPlugins`' clock advances a hair per update, so drive the
// component's own clock instead of guessing frame counts.
app.update();
{
let mut root_mut = app.world_mut().entity_mut(root);
let mut n = root_mut.get_mut::<AuraNodes>().unwrap();
n.started = -10.0; // the ramp finished 10 s ago
}
app.update();
for part in [body, weapon] {
let tag = app.world().entity(part).get::<MeshTag>().unwrap().0;
assert!(
(crate::mesh_tag::alpha_of(tag) - 0.3).abs() < 1.0 / 63.0,
"part {part} carries the aura alpha (tag {tag:#x})"
);
assert_eq!(
app.world()
.entity(part)
.get::<MeshMaterial3d<crate::terrain::WowModelMaterial>>()
.unwrap()
.0,
blend,
"part {part} draws on the blend twin while translucent"
);
}
// The aura drops and the ramp completes: the release frame restores opacity and the steady
// material, and stops authoring.
{
let mut root_mut = app.world_mut().entity_mut(root);
let mut n = root_mut.get_mut::<AuraNodes>().unwrap();
n.alpha.clear();
n.retarget(0.0);
n.started = -10.0;
}
app.update();
for part in [body, weapon] {
let tag = app.world().entity(part).get::<MeshTag>().unwrap().0;
assert!((crate::mesh_tag::alpha_of(tag) - 1.0).abs() < 1.0 / 63.0);
assert_eq!(
app.world()
.entity(part)
.get::<MeshMaterial3d<crate::terrain::WowModelMaterial>>()
.unwrap()
.0,
cutout,
"released back to the part's steady law"
);
}
assert!(
!app.world()
.entity(root)
.get::<AuraNodes>()
.unwrap()
.authoring,
"and stops claiming the channel"
);
}
// ── The tint's render channel (decision 0812) ─────────────────────────────────────────────────────
/// **The tint end to end**: the ghost aura's CharProc-1 head node lands in the per-instance table at
/// the unit's own rig slot, packed the way the reference packs it (`param | 0xff000000`) — and the
/// aura dropping puts the slot back to identity, so the body goes back to its own colours.
#[test]
fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() {
let mut app = app_with_tint_channel();
let skin = crate::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
)
.expect("a fresh palette has room");
let slot = skin.slot;
assert!(slot >= 1, "slot 0 is the world's no-rig sentinel");
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])),
skin,
))
.id();
app.update();
let tints = app
.world()
.resource::<crate::instance_tint::InstanceTints>();
assert_eq!(
tints.get(slot),
0xff8c_b9fd,
"the ghost's pale blue-white, alpha byte and all"
);
// The aura leaves its slot: the reference drops the tint node (`0x5ff320`) with no ease, and the
// channel goes back to identity — which is the shader's no-op word, not a colour.
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
95, 0x00,
)])));
app.update();
let tints = app
.world()
.resource::<crate::instance_tint::InstanceTints>();
assert_eq!(tints.get(slot), crate::instance_tint::IDENTITY);
}
/// A tinted unit going away must not leave its colour on the slot for whoever allocates it next —
/// the leak that would paint an unrelated creature ghost-blue. Covered structurally by `RigSkin`'s
/// free hook, so it holds for a despawn AND for a gear/display rebuild that replaces the component.
#[test]
fn the_rig_free_hook_clears_a_dead_units_tint() {
let mut app = app_with_tint_channel();
let skin = crate::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
)
.expect("a fresh palette has room");
let slot = skin.slot;
let unit = app
.world_mut()
.spawn((
crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])),
skin,
))
.id();
app.update();
assert_eq!(
app.world()
.resource::<crate::instance_tint::InstanceTints>()
.get(slot),
0xff8c_b9fd,
);
// Despawn without any aura edge — the unit streamed out mid-buff, which is the common case.
app.world_mut().entity_mut(unit).despawn();
assert_eq!(
app.world()
.resource::<crate::instance_tint::InstanceTints>()
.get(slot),
crate::instance_tint::IDENTITY,
"the slot is clean before anyone can reallocate it",
);
}
+115 -6
View File
@@ -113,6 +113,33 @@ impl BillboardCard {
card
}
/// A card with **no geometry** — a pure billboard *frame*: an entity whose live transform is a
/// billboard bone's replaced palette matrix (pivot in the world, camera basis as the rotation),
/// for the other consumers of that matrix to ride. Today's one caller is the equipped-item
/// emitter lane (`entities::equipment::spawn`): an item model spawns no rig, so nothing else
/// would apply the replacement to a particle emitter hanging under a billboard bone — and the
/// reference folds the emitter's record position through exactly this matrix
/// (wow-re `part-anchoring-live-bone.md` §1 row 3).
///
/// It is the same mechanism as a card and deliberately not a second one (decision 0153's rule):
/// same basis, same pivot law, same follow/despawn contract, one system. A card without a
/// `Mesh3d` simply draws nothing while its transform is maintained.
///
/// `pivot` is model-local **Bevy** axes (the frame `owner`'s children live in).
pub(crate) fn frame_following(kind: BillboardKind, pivot: Vec3, owner: Entity) -> Self {
Self {
world_pivot: Vec3::ZERO, // re-seated from the owner before the first facing write
scale: 1.0,
kind,
scale_anim: None,
phase_ms: 0,
seq_translation: None,
placement_rot: Quat::IDENTITY,
follow: Some(owner),
local_pivot: pivot,
}
}
/// Arm the card's first-sequence translation loop (the questgiver `?` bob) with the client's
/// arm-time cursor: sampling runs on `elapsed − arm_ms` (the loop starts at its first key the
/// moment the marker attaches, like the real arm at status receive). Overrides the position-hash
@@ -134,8 +161,9 @@ impl BillboardCard {
/// The entity this card follows, if any — the anchor/joint that decides both where it sits and
/// which model it BELONGS to. A card is a world root, so a system that walks a model's tree
/// (the self-avatar fade) can only recognise the model's own cards by testing this against the
/// entities it walked.
/// (the self-avatar fade; the light node's shade push in `entity_shade`) can only recognise the
/// model's own cards by testing this against the entities it walked. `None` = a fixed terrain
/// doodad's card, whose shade rides its material selector instead.
pub(crate) fn follows(&self) -> Option<Entity> {
self.follow
}
@@ -396,7 +424,7 @@ pub(crate) struct BillboardPlace;
/// (post-propagation), so it writes `GlobalTransform` directly alongside `Transform` — cards
/// write ABSOLUTE world transforms and live at the root/identity, so the direct write is exact.
#[allow(clippy::type_complexity)] // the owner pose + visibility read, commented inline
fn face_billboards(
pub(crate) fn face_billboards(
mut commands: Commands,
time: Res<Time>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
@@ -540,7 +568,6 @@ mod tests {
let info = BillboardInfo {
bone: 0,
pivot: Vec3::new(0.0, 1.7, 0.0), // the brazier-bowl height, model-local Bevy frame
normal: Vec3::Z,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
@@ -585,6 +612,90 @@ mod tests {
);
}
/// **The billboard FRAME an equipped item's emitter rides** (decision 0813), with the real
/// numbers of nazriel's B118 item (`LShoulder_Mail_PVPAlliance_C_01`: billboard bone 1 pivot
/// `(-0.012, 0.162, -0.060)`, sparkle emitter position `(-0.252, 0.178, -0.046)`, both raw WoW
/// model space — pinned in `benilla_formats`' `real_pvp_shoulder_emitters_ride_a_billboard_bone`).
///
/// The law: the emitter's live origin is `pivot + camBasis·(position − pivot)`, so the sparkle
/// sits a **fixed 0.24 yd along the VIEW axis** from the pauldron's billboard pivot — behind it
/// (the chain offset's WoW +X is toward the viewer and this offset is negative), which is what
/// puts most of the 0.7 yd quad behind the pauldron's own depth. A rest-pose placement instead
/// nails it to a fixed model-space point, so what the pad occludes changes with every camera
/// move — the reported "way too strong and off position". Both halves are asserted: the offset's
/// magnitude/direction at one camera, and that it FOLLOWS the camera to the next.
#[test]
fn an_item_emitters_billboard_frame_puts_it_behind_the_pivot() {
use benilla_assets::coords::wow_to_bevy;
const PIVOT: [f32; 3] = [-0.012, 0.162, -0.060];
const EMITTER: [f32; 3] = [-0.252, 0.178, -0.046];
// What `spawn_emitter`'s pivot rebase stores: the chain offset, raw WoW axes.
let local = wow_to_bevy([
EMITTER[0] - PIVOT[0],
EMITTER[1] - PIVOT[1],
EMITTER[2] - PIVOT[2],
]);
let mut app = App::new();
app.init_resource::<Time>();
app.add_systems(Update, face_billboards);
let cam = app
.world_mut()
.spawn((crate::player::WorldCamera, GlobalTransform::IDENTITY))
.id();
// The item root — the shoulder attach point, wherever the wearer stands.
let root = app
.world_mut()
.spawn(GlobalTransform::from_translation(Vec3::new(3.0, 1.5, 0.0)))
.id();
let frame = app
.world_mut()
.spawn((
BillboardCard::frame_following(BillboardKind::Spherical, wow_to_bevy(PIVOT), root),
Transform::IDENTITY,
))
.id();
// Camera 1: the Bevy default (at the origin, looking down −Z) — so "away from the viewer"
// is −Z.
app.update();
let tf = *app.world().entity(frame).get::<Transform>().unwrap();
let pivot_world = Vec3::new(3.0, 1.5, 0.0) + wow_to_bevy(PIVOT);
assert!(
(tf.translation - pivot_world).length() < 1e-5,
"the frame sits AT the billboard pivot — the rotation swap keeps it fixed"
);
let sparkle = tf.transform_point(local) - pivot_world;
assert!(
(sparkle.z + 0.240).abs() < 2e-3,
"0.24 yd along the view axis, away from the viewer: {sparkle:?}"
);
assert!(
sparkle.truncate().length() < 0.025,
"…and all but ~2 cm of the offset is in that one axis: {sparkle:?}"
);
// Camera 2: a quarter turn — the offset must follow the camera, not the model. This is the
// whole difference from the rest pose, which would return the same vector both times.
app.world_mut()
.entity_mut(cam)
.insert(GlobalTransform::from(Transform::from_rotation(
Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
)));
app.update();
let turned = app
.world()
.entity(frame)
.get::<Transform>()
.unwrap()
.transform_point(local)
- pivot_world;
assert!(
(turned.x + 0.240).abs() < 2e-3 && turned.z.abs() < 0.025,
"the same 0.24 yd, now along the new view axis: {turned:?}"
);
}
/// A card the **exterior-scene cull** owns must not have its `Visibility` written here
/// (decision 0784). One component, one authority (0025): a world-placement card is tagged
/// with the rest of its model, both systems run in the same unordered post-propagation
@@ -613,7 +724,6 @@ mod tests {
let info = BillboardInfo {
bone: 0,
pivot: Vec3::new(0.0, 1.7, 0.0),
normal: Vec3::Z,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
@@ -987,7 +1097,6 @@ mod tests {
let info = BillboardInfo {
bone: 0,
pivot: Vec3::ZERO,
normal: Vec3::Z,
kind: BillboardKind::LockZ,
scale_anim: None,
seq_translations: vec![], // doodad default: `new` never arms one
+18 -2
View File
@@ -652,8 +652,24 @@ fn drive_capture(
game_time: Res<Time>,
mut clock: ResMut<Time<Virtual>>,
) {
let resident =
progress.total > 0 && progress.ready == progress.total && progress.focus_resident;
// Residency, on the STRONGEST term the streamer publishes (0810). `ready == total` counts desired
// tiles spawned, and `focus_resident` only that the tile under the focus exists — neither says the
// tile's *placements* are up. `scene_ready` does exactly that (the focus tile and its 8 neighbours
// spawned with every WMO/doodad/prop they reference — `loading_screen`, 0737), and
// `placements_pending` is its outstanding count. The entity-count quiescence test below cannot
// cover the gap either: a placement that has not spawned yet moves no count, so the window can
// elapse while one is still arriving.
//
// **This is a gate correction, NOT the fix for the capture flake** (`creature-{sun,shade}-rear`
// still flip under `visual.sh selfcheck` at MAE ~4.27 with this in). 0810 has the open defect and,
// more importantly, the protocol: consecutive captures of ONE scenario share a warm asset cache
// and converge on a single state, so they always look deterministic. Test with a full-sweep
// selfcheck, never with repetition — that mistake produced three wrong diagnoses in a row.
let resident = progress.total > 0
&& progress.ready == progress.total
&& progress.focus_resident
&& progress.scene_ready
&& progress.placements_pending == 0;
// Both fixture viewers (fxview / waterfx) share the settle→arm→age→shoot flow; only the
// requested age differs.
let fixture_age = fx_req
+18
View File
@@ -255,6 +255,24 @@ fn apply_roster_policy(
let sel = roster.selected.unwrap_or(0).min(roster.chars.len() - 1);
roster.selected = Some(sel);
}
// `WOW_CHARSELECT_PICK=<name>` — **select** that row and stay on the screen, the
// deliberate opposite of `WOW_CHAR`'s enter-the-world fast path. Without it the screen is
// only ever reachable on its default first row, which makes every "what does this
// character look like at select?" check hostage to the account's create order (it cost
// this session's item-glow verification a run). An unknown name leaves the default.
if let Ok(name) = std::env::var("WOW_CHARSELECT_PICK") {
match roster
.chars
.iter()
.position(|c| c.name.eq_ignore_ascii_case(&name))
{
Some(row) => {
info!("char select: WOW_CHARSELECT_PICK={name} — selecting row {row}");
roster.selected = Some(row);
}
None => warn!("char select: WOW_CHARSELECT_PICK={name} not on this account"),
}
}
if let Some(guid) = roster.pending_pick {
// We already chose this session (in-world reconnect, or a pick raced a dying socket):
// re-answer without showing the screen.
+7
View File
@@ -486,6 +486,10 @@ pub(crate) use env_damage::{EnvDamageTable, HardLanding, HARD_LANDING_DESCENT};
use spell_visual::{
arm_aura_state_fx, arm_level_up_fx, arm_loot_fx, load_spell_visuals, route_cast_visuals,
};
// The aura-slot watcher is the shared trigger for BOTH halves of a state kit, so the CharProc half's
// own tests (`crate::aura_visual`) drive it directly rather than re-deriving the slot diff.
#[cfg(test)]
pub(crate) use spell_visual::arm_aura_state_fx as arm_aura_state_fx_for_test;
pub(crate) use spell_visual::{
held_strike_sound, FxClass, KitPush, MissileSpawn, SpellKitFx, SpellKitSound, SpellVisuals,
};
@@ -699,6 +703,9 @@ impl Plugin for CreatureAnimPlugin {
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<KitPush>()
// The aura CharProc edges the slot watcher emits alongside its effect-model ones —
// drained by `crate::aura_visual`, which owns the body's alpha/tint for an aura's life.
.add_message::<crate::aura_visual::AuraProc>()
.add_message::<HardLanding>()
.add_systems(
Startup,
@@ -307,6 +307,14 @@ pub(super) fn drive_animations(
if mounted {
mv.stand_state = 0;
}
// Stealth — the prowl pose ([`select::STEALTH_WALK`] / [`select::STEALTH_STAND`]): the CREEP
// vis flag off the unit's OWN descriptor, read every frame for self and remote alike, which
// is where the client reads it (`[[unit+0x110]+0x213] & 2` at select time). Never
// controller-fed, unlike the stand state — there is no client-side prediction of stealth, so
// the crouch lands with the server's aura. A mount child carries no store and reads `false`:
// right by construction, since the rider's body holds Mount(91) regardless and stealth and
// mounts are mutually exclusive anyway.
mv.stealthed = store.is_some_and(|s| s.0.unit_is_stealthed());
let moving = mv.flags & move_flags::ANY_MOVE != 0;
// The airborne arc's bookkeeping (wow-re land-anim-height-gate + rf57b §2): on the arc's
// FIRST airborne frame, its launch vertical speed splits a **jump** (upward — the client's
@@ -1077,6 +1077,77 @@ fn the_self_kneel_rides_the_loot_latch_not_the_flag() {
);
}
/// **B114's second half, end to end**: the prowl pose off the descriptor, through the real driver.
/// The CREEP vis flag (`UNIT_FIELD_BYTES_1` byte 3 bit 1 — field 138, `0x0200_0000`) is the whole
/// gate, and it is read from the unit's own descriptor for the SELF unit too (unlike the loot kneel
/// above, which splits self/remote): there is no client-side prediction of stealth, so the crouch
/// arrives with the server's aura. Stand ⇄ StealthStand and Run ⇄ StealthWalk both flip on the bit
/// alone, with no other state changing.
#[test]
fn the_creep_vis_flag_prowls_the_body() {
use benilla_protocol::messages::ObjectFields;
const CREEP: u32 = 0x0200_0000;
let mut app = app();
let model = ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(5, 2, true), // Run
clip(119, 3, true), // StealthWalk
clip(120, 4, true), // StealthStand
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
};
let unit = app
.world_mut()
.spawn((
model,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
crate::net::SelfPlayer,
crate::net::ObjectStore(ObjectFields::from_pairs(&[(138, 0)])),
MovementState::default(),
))
.id();
let gait = |app: &App| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
let set_flag = |app: &mut App, v: u32| {
app.world_mut()
.entity_mut(unit)
.insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
138, v,
)])));
};
app.update();
assert_eq!(gait(&app), Some(0), "unstealthed idle stands");
// The stealth aura landed: the same standing unit drops into the crouch.
set_flag(&mut app, CREEP);
app.update();
assert_eq!(gait(&app), Some(120), "the CREEP bit crouches the idle");
// Moving while stealthed creeps — at a speed that would otherwise be a flat-out Run.
app.world_mut().entity_mut(unit).insert(MovementState {
speed: 7.0,
flags: move_flags::FORWARD,
..Default::default()
});
app.update();
assert_eq!(gait(&app), Some(119), "the prowl outranks the speed tail");
// Stealth broke mid-run: straight back to the ordinary gait, nothing else touched.
set_flag(&mut app, 0);
app.update();
assert_eq!(gait(&app), Some(5), "broken stealth runs again");
}
/// The looping-variation ADVANCE (decision 0516 — wow-re `loop-replay-fidget.md` §7/§7d, the
/// watchdog `0x719370`): a relaxed looping base arm installs a replay window (here `(1,1)` → one
/// pass exactly); each completed window re-arms the id through the weighted, MEMORYLESS variation
+50 -1
View File
@@ -22,6 +22,31 @@ pub(super) const STAND: u16 = 0;
pub(super) const DEATH: u16 = 1;
/// StealthWalk (119) — the **prowl creep**, and with [`STEALTH_STAND`] the entire difference
/// stealth makes to a body's *pose*. Byte-verified (wow-re `rf57-movement-anim-select.md`, the
/// 2026-07-18 §5): both ids are gated by the same descriptor bit — `[[unit+0x110]+0x213] & 2`,
/// `UNIT_FIELD_BYTES_1` byte 3's CREEP flag ([`crate::net::ObjectStore`]'s `unit_is_stealthed`) —
/// 119 from the core cascade at `0x5fd1d3`, tested **after** the backward branch and **before the
/// whole speed tail**, so a prowling unit never plays Run or Sprint however fast it travels.
///
/// No aura, no visual kit, no spell id is involved. Stealth's *translucency* is its aura state
/// kit's CharProc-14 (decision 0806) and its *pose* is this flag: two independent mechanisms off
/// one server byte, which is exactly why the body could go translucent and still stand bolt
/// upright. (The same flag's other three read sites are nameplate/marker suppression — it drives
/// no body render at all; wow-re `ghost-death-visuals.md`.)
///
/// **119 is NOT rate-scaled.** The client's locomotion-rate whitelist `0x5fee80` is exactly
/// `{4,5,11,12,13,37,38,39,42,43,44,45,135,143,187}` (byte-verified, §5-agreed; wow-re
/// `rf57b-rate-jump-standstate.md`) and 119 is absent — the creep cycle plays at 1× whatever the
/// live speed, so [`playback_rate`] leaves it alone.
pub(super) const STEALTH_WALK: u16 = 119;
/// StealthStand (120) — the prowl idle, from the **last** resolver in the chain (the fallback idle
/// `0x5fd830`: swim-idle → `[110]+0x213&2` → jump/float → plain Stand). Being last is its
/// precedence: every other resolver — the sit/sleep/kneel and chair stand-states, the turn-in-place
/// shuffle, the combat Ready idle, loot — outranks it. See [`STEALTH_WALK`] for the shared gate.
pub(super) const STEALTH_STAND: u16 = 120;
/// Mount (91) — the rider's seated pose while a mount model is attached (decision 0441;
/// byte-verified, wow-re `mount-composition.md` B1): held **unconditionally** — moving, turning,
/// airborne. The client arms it outside the base selector (once at attach by `0x607a00`, then
@@ -223,6 +248,12 @@ pub(crate) struct MovementState {
/// Stand-state (`UNIT_FIELD_BYTES_1` byte 0): 0 Stand · 1 Sit · 3 Sleep · 4/5/6 chair low/med/high ·
/// 8 Kneel — drives the idle pose / the sit-sleep-kneel transition while standing.
pub(crate) stand_state: u8,
/// **Stealthed** — `UNIT_FIELD_BYTES_1` byte 3's CREEP bit, the gate on [`STEALTH_WALK`] /
/// [`STEALTH_STAND`]. Stamped by the driver from the unit's OWN descriptor every frame, self
/// and remote alike (the client reads `[[unit+0x110]+0x213]` at select time), so [`unify`]'s
/// legs never touch it: unlike the stand state there is nothing to predict controller-side —
/// the crouch waits on the server's aura, exactly as the reference does.
pub(crate) stealthed: bool,
/// Riding a **flying** server spline (the taxi flight): the client's selector reads the active
/// CMovement's spline flags (`[[unit+0x118]+0xa4]+0x18`) and plays Fly 135 when the wire Flying
/// bit (0x200) is set — RF-0057 `0x5fd19c`. Stamped by [`unify`] from the entity's live
@@ -401,6 +432,16 @@ pub(super) fn gait_candidates(
if f & BACKWARD != 0 {
return &[13, 4, 0];
}
// Stealthed and moving — the prowl (RF-0057 `0x5fd1d3`, sitting between the backward branch and
// the speed tail; [`STEALTH_WALK`]). The gate outranks the ENTIRE speed tail, so a stealthed
// unit creeps at any speed, while a *backpedaling* one plays WalkBackwards — backward is tested
// first. The Walk 4 fallback is `AnimationData.dbc`'s own Fallback for row 119, which is also
// what the baked PlayableAnimationLookup resolves 119 to on a model lacking the clip: HumanMale
// and NightElfFemale author both stealth clips, `druidcat.m2` authors NEITHER (119→4, 120→0), so
// a prowling cat shows its ordinary walk — in the reference too, through the same resolver.
if state.stealthed && f & ANY_MOVE != 0 {
return &[STEALTH_WALK, 4, 0];
}
// Ground gaits by live speed (RF-0057 core): fast-run ≥ 11, run > 2× walk, else walk.
// (Strafe currently routes here to Run/Walk; the dedicated-shuffle question is under §5 verification.)
if f & ANY_MOVE != 0 {
@@ -453,10 +494,13 @@ pub(super) fn gait_candidates(
// State 2 (generic SIT_CHAIR) is verified VESTIGIAL (decision 0280): the client's resolver row
// for 2 writes no override (`0x5fd644` returns a bool; the 0xd0 sentinel stands), so falling to
// plain Stand here is the faithful render, not a gap.
// Standing stealthed: the prowl idle ([`STEALTH_STAND`]) — placed *after* the chair loops, since
// its resolver (`0x5fd830`) is the last in the chain and the stand-state resolver precedes it.
match state.stand_state {
4 => &[102, 0],
5 => &[103, 0],
6 => &[104, 0],
_ if state.stealthed => &[STEALTH_STAND, STAND],
_ => &[STAND],
}
}
@@ -555,8 +599,13 @@ pub(super) fn is_combat_anim(id: u16) -> bool {
/// this predicate never has to account for Specials itself — they simply never reach here. Movement
/// starting again drops straight out of the state-emote idle for the same reason Stand does: the
/// very next frame's `cands` is no longer bare.
///
/// The **prowl idle** counts as bare too: [`STEALTH_STAND`] comes from `0x5fd830`, the *last*
/// resolver in the client's chain, so anything with a resolver of its own outranks it. (OUR
/// ordering, on that structural argument — where the emote-state idle sits in the real chain is
/// unpinned; a stealthed unit holding an emote state is degenerate either way.)
pub(super) fn is_bare_stand(cands: &[u16]) -> bool {
cands == [STAND]
cands == [STAND] || cands == [STEALTH_STAND, STAND]
}
/// The candidate array for a state-emote idle occupying the bare-Stand slot: the resolved anim id
@@ -796,3 +796,108 @@ fn unify_stamps_flying_from_the_live_spline_on_every_leg() {
let v = unify(None, None, Some(&spline(false)), false);
assert!(v.flying && v.flags & move_flags::FORWARD != 0 && v.speed > 0.0);
}
/// The prowl creep outranks the WHOLE speed tail (RF-0057 `0x5fd1d3` precedes `0x5fd202`): a
/// stealthed unit plays 119 at a dead crawl and at sprint speed alike — never Walk, Run or Sprint.
#[test]
fn the_prowl_creeps_at_every_speed() {
for speed in [0.5, 3.0, 7.0, 32.0] {
let plain = moving_forward(speed);
let creeping = MovementState {
stealthed: true,
..plain
};
assert_eq!(
gait_candidates(&creeping, 2.5, None, None)[0],
STEALTH_WALK,
"stealthed at {speed} yd/s must creep",
);
assert_ne!(
gait_candidates(&plain, 2.5, None, None)[0],
STEALTH_WALK,
"unstealthed at {speed} yd/s must fall through to the speed tail",
);
}
// …and it steps down to Walk on a model lacking the clip (AnimationData row 119's own Fallback).
let creeping = MovementState {
stealthed: true,
..moving_forward(7.0)
};
assert_eq!(
gait_candidates(&creeping, 2.5, None, None),
&[STEALTH_WALK, 4, 0],
);
}
/// Swim, the flying spline and backward each precede the stealth branch in the byte cascade
/// (swim → fly → backward → **stealth** → speed tail), so each still wins while stealthed.
#[test]
fn swim_fly_and_backpedal_all_outrank_the_prowl() {
let stealthed = |flags: u32, flying: bool| MovementState {
speed: 5.0,
flags,
stealthed: true,
flying,
..Default::default()
};
let pick = |s: MovementState| gait_candidates(&s, 2.5, None, None)[0];
let swim = move_flags::SWIMMING;
assert_eq!(
pick(stealthed(swim | move_flags::FORWARD, false)),
42,
"a stealthed swimmer strokes",
);
assert_eq!(pick(stealthed(swim, false)), 41, "and treads water at rest");
assert_eq!(
pick(stealthed(move_flags::FORWARD, true)),
135,
"a stealthed flying ride still flies",
);
assert_eq!(
pick(stealthed(move_flags::BACKWARD, false)),
13,
"backward is tested BEFORE stealth",
);
}
/// The prowl idle (`0x5fd830`, last resolver in the chain) sits exactly where plain Stand sat: every
/// resolver ahead of it — the chair stand-states, the turn shuffle, the combat Ready idle — keeps
/// winning, and the state-emote idle may still fill the slot ([`is_bare_stand`]).
#[test]
fn the_prowl_idle_is_the_lowest_priority_stand() {
let idle = MovementState {
stealthed: true,
..Default::default()
};
assert_eq!(
gait_candidates(&idle, 2.5, None, None),
&[STEALTH_STAND, STAND],
);
assert!(
is_bare_stand(gait_candidates(&idle, 2.5, None, None)),
"the state-emote idle still owns this slot",
);
let chair = MovementState {
stand_state: 4,
..idle
};
assert_eq!(gait_candidates(&chair, 2.5, None, None), &[102, 0]);
let turning = MovementState {
flags: move_flags::TURN_LEFT,
..idle
};
assert_eq!(gait_candidates(&turning, 2.5, None, None), &[11, 0]);
assert_eq!(
gait_candidates(&idle, 2.5, Some(26), None),
&[26, 25, 0],
"an engaged Ready idle outranks the crouch",
);
}
/// The creep is NOT in the client's locomotion-rate whitelist (`0x5fee80`), so unlike Walk it plays
/// at 1× however fast the unit is actually travelling.
#[test]
fn the_prowl_creep_is_not_rate_scaled() {
assert_eq!(playback_rate(&clip(STEALTH_WALK, 2.5), 7.0), 1.0);
assert_ne!(playback_rate(&clip(4, 2.5), 7.0), 1.0);
}
@@ -15,6 +15,7 @@ use benilla_formats::{SpellVisualCatalog, VisualKit, VisualStages, MISSILE_ATTAC
use benilla_protocol::EntityKind;
use crate::assets::{LockRecover, WorldAssets};
use crate::aura_visual::AuraProc;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
@@ -888,11 +889,22 @@ pub(super) fn route_cast_visuals(
/// An aura refresh keeps its slot id present (no edge); a re-apply that flickers remove→add
/// across frames reaps then re-begins — the drain's replace-on-persistent-begin keeps even the
/// same-frame corner single-instanced.
pub(super) fn arm_aura_state_fx(
/// A state kit has **two halves** and this watcher owns the slot diff for both: the attach-point
/// effect *models* above, and the kit's `CharProc` columns — what the aura does to the body itself
/// (its translucency, its tint), emitted as [`AuraProc`] edges for [`crate::aura_visual`]. One diff
/// and one kit resolve feed both fan-outs, the same way [`KitOut`] bundles a discrete play's writers,
/// so the two halves of a kit can never disagree about when an aura came or went.
///
/// **The arm predicate is "this kit does *anything*", not "this kit has effect models"** — B114 was
/// exactly that bug: Stealth's kit 312 carries no models, no anim and no attach at all (its whole
/// visual is one proc-14 CharProc), so an effects-only test dropped it and the character showed
/// nothing.
pub(crate) fn arm_aura_state_fx(
units: Query<(Entity, &ObjectStore)>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut fx: MessageWriter<SpellKitFx>,
mut procs: MessageWriter<AuraProc>,
mut armed: Local<EntityHashMap<Vec<u32>>>,
) {
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
@@ -912,6 +924,7 @@ pub(super) fn arm_aura_state_fx(
spell_id,
class: FxClass::AuraState,
});
procs.write(AuraProc::Reap { entity, spell_id });
}
}
let mut next = Vec::with_capacity(prev.len());
@@ -924,16 +937,29 @@ pub(super) fn arm_aura_state_fx(
continue; // no state kit — not this watcher's aura
};
let effects = resolve_kit_effects(&visuals.0, &kit);
if effects.is_empty() {
continue;
let nodes: Vec<crate::aura_visual::AuraNode> = kit
.char_procs()
.filter_map(crate::aura_visual::node_for)
.collect();
if effects.is_empty() && nodes.is_empty() {
continue; // a state kit that does nothing we model — nothing to arm or reap
}
if !effects.is_empty() {
fx.write(SpellKitFx::Begin {
entity,
spell_id,
persistent: true,
class: FxClass::AuraState,
effects,
});
}
if !nodes.is_empty() {
procs.write(AuraProc::Begin {
entity,
spell_id,
nodes,
});
}
fx.write(SpellKitFx::Begin {
entity,
spell_id,
persistent: true,
class: FxClass::AuraState,
effects,
});
next.push(spell_id);
}
*prev = next;
@@ -438,6 +438,9 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>();
// The watcher's other fan-out: the kit's CharProc edges (`crate::aura_visual`). Food's kit 409
// carries none, so nothing is asserted here — the message just has to exist for the writer.
app.add_message::<crate::aura_visual::AuraProc>();
app.init_resource::<FxLog>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables_with_paths(
HashMap::from([(
+1 -1
View File
@@ -563,7 +563,7 @@ pub(crate) fn playing_seq(
/// gate, far-clip) still lands right on re-appear, and the reference animation-evaluates the tracks
/// every frame regardless of the cull (wow-re `m2-alpha-combine-cull`). Runs before the visibility
/// authority so the tag it composes is this frame's value.
fn sample_mat_anim(
pub(crate) fn sample_mat_anim(
time: Res<Time>,
hosts: Query<(&AnimationPlayer, &ModelAnimations)>,
mut q: Query<&mut MatAnim>,
+85 -2
View File
@@ -59,6 +59,12 @@ use carried_light::spawn_carried_lights;
mod equipment;
use equipment::{attach_held_items, refresh_player_looks, resolve_equipment};
/// Item / enchant glow effects (decision 0805): the `Spells\Enchantments\*.mdx` models an item's
/// `ItemVisuals` id hangs on the item's OWN attachment points — the permanent weapon glows and
/// the shaman/oil enchant visuals.
mod item_glow;
use item_glow::{attach_item_glows, ItemGlows};
/// Mounts (decision 0441): the `UNIT_FIELD_MOUNTDISPLAYID` → second-creature-visual projection —
/// the mount child + seat components and the transition's diff-and-rebuild.
pub(crate) mod mount;
@@ -187,6 +193,13 @@ impl Creatures {
self.catalog.collision_height(display_id)
}
/// A display's **base render alpha** (`CreatureDisplayInfo.CreatureModelAlpha / 255`) — the
/// `baseAlpha` factor of the per-unit alpha product the aura CharProc nodes multiply into
/// (`crate::aura_visual`). `None` for an unknown display.
pub(crate) fn display_base_alpha(&self, display_id: u32) -> Option<f32> {
self.catalog.display_base_alpha(display_id)
}
/// A display's resolved blood id (decision 0137 phase 3 — see [`CreatureModel::blood`]):
/// the UnitBloodLevels key the melee spurt chain starts from; `None` = unknown display.
pub(crate) fn blood(&self, display_id: u32) -> Option<i32> {
@@ -308,6 +321,7 @@ pub(crate) struct EntityVisualsSet;
/// doc for why a clear is always safe mid-session). These caches are get-or-insert at every use
/// site, so a cleared entry rebuilds on the next spawn that wants it; without this, every display
/// id, material key, and composited skin ever seen stayed resident for the life of the process.
#[allow(clippy::too_many_arguments)]
fn evict_display_caches(
mut changes: MessageReader<crate::world_map::MapChange>,
mut entity_mats: ResMut<EntityMaterials>,
@@ -316,18 +330,20 @@ fn evict_display_caches(
creatures: Option<ResMut<Creatures>>,
gos: Option<ResMut<GameObjects>>,
items: Option<ResMut<equipment::ItemDisplays>>,
glows: Option<ResMut<ItemGlows>>,
) {
if changes.is_empty() {
return;
}
changes.clear();
info!(
"display caches evicted: {} materials, {} creature / {} go / {} item / {} fx models, {} skins",
"display caches evicted: {} materials, {} creature / {} go / {} item / {} fx / {} glow models, {} skins",
entity_mats.0.len(),
creatures.as_ref().map_or(0, |c| c.models.len()),
gos.as_ref().map_or(0, |g| g.models.len()),
items.as_ref().map_or(0, |i| i.models.len()),
fx.models.len(),
glows.as_ref().map_or(0, |g| g.models.len()),
composites.0.len(),
);
entity_mats.0.clear();
@@ -342,6 +358,9 @@ fn evict_display_caches(
if let Some(mut i) = items {
i.models.clear();
}
if let Some(mut g) = glows {
g.models.clear();
}
}
/// Expire the streamed-entity dedups by **distance** (decision 0793) — the within-map half of
@@ -421,7 +440,12 @@ impl Plugin for EntitiesPlugin {
resolve_wmo_gameobject_props,
spawn_wmo_gameobject_props,
attach_held_items,
attach_spell_fx,
// One nested (unordered) element — the two passes that hang effect models on
// a unit: its spell-kit instances, and the glows its held items' `ItemVisuals`
// ids name (0805 — after `attach_held_items`, which makes the item roots).
// Independent of each other, both before the tint tick below; the outer tuple
// is at `chain()`'s 20-element ceiling.
(attach_item_glows, attach_spell_fx),
// One nested (unordered) element — two independent free-model attach
// passes; the outer tuple is at `chain()`'s 20-element ceiling.
(attach_missile_models, attach_ground_fx_models),
@@ -474,6 +498,25 @@ impl Plugin for EntitiesPlugin {
.after(apply_render_fade)
.before(crate::player::apply_self_model_fade),
)
// The aura CharProc layer (`crate::aura_visual`): the state kit's effect on the BODY.
// The drain installs/removes this frame's nodes; the author then owns the render alpha
// of every part under a translucent unit — after the three steady-state alpha authors
// above so its override lands, before the self feather which folds the factor in itself.
.add_systems(
Update,
(
crate::aura_visual::drain_aura_procs,
// The tint publish only needs the drain ahead of it (it writes a resource, not
// the alpha channel), so it rides the same chain rather than earning its own.
(
crate::aura_visual::apply_aura_alpha,
crate::aura_visual::apply_aura_tint,
),
)
.chain()
.after(apply_unit_mat_alpha)
.before(crate::player::apply_self_model_fade),
)
// Arm a queued appear-fade once the world is on-screen, then drive it each frame; the despawn
// fade-out re-arms the same `RenderFade` channel on stream-out. All disjoint from the interior
// classifier + the doodad fade (they touch different entities / the same channel only while a
@@ -590,6 +633,26 @@ fn setup_entities(
}
Err(e) => warn!("item displays unavailable, units hold nothing: {e:#}"),
}
// The item/enchant glow chain (decision 0805) — both tables or neither: a glow needs the
// ItemVisuals join to name a model and the enchant column to know a temporary one. Absent,
// weapons simply draw unadorned.
match (
benilla_formats::load_item_visual_catalog(&mut chain),
benilla_formats::load_enchant_visual_catalog(&mut chain),
) {
(Ok(visuals), Ok(enchants)) => {
info!(
"item visual catalog: {} glow rows, {} enchants with a glow",
visuals.len(),
enchants.len()
);
commands.insert_resource(ItemGlows::new(visuals, enchants));
}
(v, e) => {
let err = v.err().or(e.err());
warn!("item visuals unavailable, weapons never glow: {err:#?}");
}
}
match benilla_formats::load_durability_tables(&mut chain) {
Ok(tables) => commands.insert_resource(crate::ui_merchant::RepairTables(tables)),
Err(e) => warn!("durability tables unavailable, repair costs show 0: {e:#}"),
@@ -614,6 +677,7 @@ fn update_display_models(
mut gameobjects: Option<ResMut<GameObjects>>,
mut held: Option<ResMut<ItemDisplays>>,
mut spell_fx: Option<ResMut<spell_fx::SpellFx>>,
mut glows: Option<ResMut<ItemGlows>>,
model_assets: (Res<Assets<M2Model>>, Res<Assets<WmoModel>>),
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<WowModelMaterial>>,
@@ -738,6 +802,25 @@ fn update_display_models(
}
}
}
// Item/enchant glow models (decision 0805): path-keyed like the effect cache above, entries
// created by `resolve_equipment`'s glow resolve.
if let Some(glows) = glows.as_deref_mut() {
for dm in glows.models.values_mut() {
if dm.parts.is_none() {
build_parts(
dm,
m2s,
wmos,
&asset_server,
&mut materials,
cache,
light,
false, // gameobject: effects — unit lighting, no collider
);
}
}
}
}
/// Compose a unit part's **animated material alpha** into the render-alpha `MeshTag` field — the
@@ -25,11 +25,13 @@ use bevy::prelude::*;
use crate::assets::WorldAssets;
use crate::lighting::SharedLightBuffer;
use crate::portrait::{
GlueLook, GluePreview, GluePreviewBake, PreviewBillboard, PreviewPart, PreviewRider,
GlueLook, GluePreview, GluePreviewBake, PreviewBillboard, PreviewGlow, PreviewPart,
PreviewRider,
};
use crate::terrain::WowModelMaterial;
use super::super::equipment::{attach_id, ensure_item_model, placement, ItemModelKind};
use super::super::item_glow::{self, ItemGlows};
use super::super::{
CharCreate, Characters, Creatures, EntityMaterials, EntityPart, ItemDisplays, SkinComposites,
SkinSections,
@@ -86,6 +88,9 @@ pub(in crate::entities) fn build_glue_preview(
characters: Option<Res<Characters>>,
char_create: Option<Res<CharCreate>>,
mut displays: Option<ResMut<ItemDisplays>>,
// The item/enchant glow chain (decision 0805): the glue screens resolve it themselves — the
// world's `resolve_equipment` never runs pre-world.
mut glows: Option<ResMut<ItemGlows>>,
sections: Option<Res<SkinSections>>,
world_assets: Option<Res<WorldAssets>>,
shared_light: Option<Res<SharedLightBuffer>>,
@@ -184,7 +189,7 @@ pub(in crate::entities) fn build_glue_preview(
} else {
equipment[ENUM_HELM].display_id
};
let held = held_wants(&equipment, helm, race, sex);
let mut held = held_wants(&equipment, helm, race, sex);
// Per-hand grip `[right, left]`: a hand whose attach point holds a weapon curls into `HandsClosed`
// (wow-re `hand-grip-mechanism.md` — the ref's paperdoll rule `0x5059a0`: attach-point occupancy, per
// hand). Resolved here because the flat rider list drops each held item's attach id. A shield sits on
@@ -201,6 +206,19 @@ pub(in crate::entities) fn build_glue_preview(
for w in &held {
ensure_item_model(d, w.display, w.kind, &asset_server);
}
// The glow ids (decision 0805), and the models they imply: **held weapons/shields only**
// — the reference passes a display's visual from the hand attach and a literal `0` from
// the helm/shoulder ones (`crate::entities::item_glow`). The world lane resolves this in
// `resolve_equipment`, which never runs pre-world.
if let Some(g) = glows.as_deref_mut() {
for w in held
.iter_mut()
.filter(|w| matches!(w.kind, ItemModelKind::Weapon | ItemModelKind::Shield))
{
w.visual = d.catalog.get(w.display).map_or(0, |c| c.item_visual);
item_glow::ensure_glow_models(g, w.visual, &asset_server);
}
}
if !held.iter().all(|w| {
d.models
.get(&(w.display, w.kind))
@@ -209,6 +227,18 @@ pub(in crate::entities) fn build_glue_preview(
}) {
return; // an item model is still loading — retry next frame
}
// Same gate for the glow models: they are tiny beside the weapon that carries them, and a
// weapon that pops in ahead of its glow would flicker on every selection change.
if let Some(g) = glows.as_deref() {
let pending = held
.iter()
.filter_map(|w| g.effects(w.visual))
.flat_map(|paths| paths.iter().flatten())
.any(|p| g.models.get(p).is_none_or(|m| m.parts.is_none()));
if pending {
return;
}
}
}
// The dressed geoset set: the worn armor's geoset groups + the cloak + the helm's hide-masks
@@ -270,21 +300,26 @@ pub(in crate::entities) fn build_glue_preview(
.collect();
// The riders: each want's item-model parts seated at the body's attach point (bone + offset),
// the world path's `PortraitRider` shape.
// the world path's `PortraitRider` shape — plus, for a held weapon, the glows its display's
// `ItemVisuals` id hangs on the weapon's OWN attachment points (decision 0805). The reference
// reaches those through the same primitive here as in the world (`0x472c91` → `0x47a0c0`
// hands `ItemDisplayInfo+0x58` to `0x4798c0`), so a permanently-glowing weapon glows on the
// select screen too; the enum carries no enchant ids (vmangos `BuildEnumData` sends displayId
// + inventoryType only), so the fork's enchant half simply has no input here.
let attach_point = |id: u16| dm.attachments.iter().find(|a| a.id == id);
let mut riders = Vec::new();
let mut preview_glows = Vec::new();
if let Some(d) = displays.as_deref() {
for w in &held {
let Some(point) = attach_point(w.attach) else {
continue; // the body has no such attach point — hold nothing (world-path rule)
};
let Some(item_parts) = d
.models
.get(&(w.display, w.kind))
.and_then(|m| m.parts.as_deref())
else {
let Some(item) = d.models.get(&(w.display, w.kind)) else {
continue; // gated above; belt-and-braces
};
let Some(item_parts) = item.parts.as_deref() else {
continue;
};
for p in item_parts.iter().filter(|p| p.billboard.is_none()) {
riders.push(PreviewRider {
mesh: p.mesh.clone(),
@@ -293,14 +328,59 @@ pub(in crate::entities) fn build_glue_preview(
offset: point.offset,
});
}
let Some(paths) = glows.as_deref().and_then(|g| g.effects(w.visual)) else {
continue;
};
for (slot, path) in paths
.iter()
.enumerate()
.filter_map(|(i, p)| p.as_ref().map(|p| (i, p)))
{
// The seat: the body's attach point plus where this slot sits on the ITEM's own
// model. A slot the item model doesn't author hangs nothing — the world lane's
// rule, and the reference's.
let at = crate::portrait::attachment_point(
&item.skeleton,
&item.attachments,
slot as u16,
);
let (Some(at), Some(glow)) =
(at, glows.as_deref().and_then(|g| g.models.get(path)))
else {
continue;
};
let offset = point.offset + at;
for p in glow
.parts
.iter()
.flatten()
.filter(|p| p.billboard.is_none())
{
riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset,
});
}
if !glow.emitters.is_empty() {
preview_glows.push(PreviewGlow {
bone: point.bone,
offset,
emitters: glow.emitters.clone(),
});
}
}
}
}
debug!(
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, {} glow ({})",
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, \
{} eye-glow, {} item glow ({})",
preview_parts.len(),
riders.len(),
preview_billboards.len(),
preview_glows.len(),
match look {
GlueLook::Create(_) => "create",
GlueLook::Select(_) => "select",
@@ -311,6 +391,7 @@ pub(in crate::entities) fn build_glue_preview(
display_id,
parts: preview_parts,
riders,
glows: preview_glows,
billboards: preview_billboards,
grip,
revision: bake.revision + 1,
@@ -318,11 +399,15 @@ pub(in crate::entities) fn build_glue_preview(
state.built = true;
}
/// One wanted rider model: which display + model kind, and the body attach point it seats on.
/// One wanted rider model: which display + model kind, the body attach point it seats on, and —
/// filled in by the caller once the display catalog is in hand — the `ItemVisuals` id its glow
/// comes from (`0` for everything that never glows: helm, shoulders, and any weapon whose display
/// authors none). Decision 0805.
struct HeldWant {
display: u32,
kind: ItemModelKind,
attach: u16,
visual: i32,
}
/// The select character's attach-model wants from its enum record: helm + the shoulder pair, and
@@ -342,6 +427,7 @@ fn held_wants(equipment: &[CharEnumItem; 19], helm: u32, race: u8, sex: u8) -> V
display: helm,
kind: ItemModelKind::Helm { race, sex },
attach: attach_id::HELM,
visual: 0, // a helm never glows: its attach site pushes a literal 0 (0805)
});
}
let shoulder = equipment[ENUM_SHOULDER].display_id;
@@ -354,6 +440,7 @@ fn held_wants(equipment: &[CharEnumItem; 19], helm: u32, race: u8, sex: u8) -> V
display: shoulder,
kind,
attach,
visual: 0, // nor do shoulders — same rule
});
}
}
@@ -374,6 +461,7 @@ fn held_wants(equipment: &[CharEnumItem; 19], helm: u32, race: u8, sex: u8) -> V
display: item.display_id,
kind,
attach,
visual: 0, // filled by the caller, which holds the display catalog
});
}
wants
+9 -6
View File
@@ -447,14 +447,17 @@ pub(super) fn attach_entity_visuals(
// A GameObject's interior-fold reference point (model-local; the anchor transform
// applies the entity scale when the classifier folds).
let bake_center = dm.map(|d| d.bake_center_local).unwrap_or(Vec3::ZERO);
// The dynamic ground-shade root (decision 0173): a unit/player/GameObject samples the
// terrain MCSH under its feet per frame and ramps its sun term — one state per object,
// like the reference's `[obj+0xe0]` light node; every M2 part below (body, held items)
// reads it from the tree walk. `insert_if_new` so a gear-change re-attach keeps the
// already-ramped state instead of resetting it (no one-frame lighting pop).
// The dynamic ground-shade root (decision 0173): one light-node state per object, like
// the reference's `[obj+0xe0]`; every M2 part below (body, held items) reads it from the
// tree walk. The KIND decides how the node's light is delivered (0809): a GameObject
// samples the terrain MCSH under it and ramps its sun term over 2.5/0.5, while a
// unit/player takes `0x672a20`'s null fallback — a flat day/night ×1.0. `insert_if_new`
// so a gear-change re-attach keeps the already-ramped state instead of resetting it (no
// one-frame lighting pop); the kind it captures cannot change under a live display-id
// swap, the same argument the attach-mode split below rests on.
commands
.entity(entity)
.insert_if_new(crate::entity_shade::GroundShade::default());
.insert_if_new(crate::entity_shade::GroundShade::for_kind(net.kind));
// The root's canonical fold reference: held items share the root's interior verdict
// (one light node per unit — the reference aliases the wearer's collector into each
// equipped item, wow-re `unit-light-combine-storm.md`), and their classifier fold must
+7 -9
View File
@@ -58,8 +58,10 @@ const SHARD_MODELS: [&str; 7] = [
"Spells\\StarShards_Impact_Base.mdx",
];
/// The `CharProcType` the dynobj emitter chain scans for (`0x5d55c0`).
const PROC_TYPE_SHARD_EMITTER: u32 = 9;
/// The `CharProcType` the dynobj emitter chain scans for (`0x5d55c0`). One key out of the same
/// dispatch space the aura-state body procs come from (`crate::aura_visual`, decision 0806) — the
/// kit column is shared, the consumers are not.
const PROC_TYPE_SHARD_EMITTER: i32 = 9;
/// The exact small-int decode the client applies to `CharParamZero` (`bits(p0 + 512.0) >> 14 &
/// 0xff`) — clamped into [`SHARD_MODELS`] because the client itself has **no bounds check**
@@ -182,17 +184,13 @@ pub(super) fn arm_ground_effects(
// kit's own sound column is the looping area sound (a tracked hold loop the sound
// module reaps when the anchor's NetEntity is removed).
if let Some(kit) = visuals.0.kit(stages.area_kit) {
if let Some(proc) = kit
.char_procs
.iter()
.find(|p| p.proc_type == PROC_TYPE_SHARD_EMITTER)
{
let path = SHARD_MODELS[shard_model_index(proc.param0)].to_string();
if let Some(proc) = kit.char_procs().find(|p| p.ty == PROC_TYPE_SHARD_EMITTER) {
let path = SHARD_MODELS[shard_model_index(proc.params[0])].to_string();
ensure_model(&mut fx, &asset_server, &path);
commands.entity(anchor).insert(ShardEmitter {
path,
radius: store.0.dynamicobject_radius().unwrap_or(0.0),
rate: proc.param1,
rate: proc.params[1],
accum: 0.0,
rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(),
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,352 @@
//! Equipment visuals (decisions 0072/0074): held items (weapons, shields, ranged), worn-armor
//! resolution, and helm/shoulder attach models on units' bodies.
//!
//! Two halves, both per-frame systems chained after [`super::attach_entity_visuals`]:
//!
//! - **Resolution** ([`resolve_equipment`]) — what should each unit hold, and where? A **creature**
//! carries its item **display ids directly** in `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY` (+ class/invType and
//! a per-item sheath type in `UNIT_VIRTUAL_ITEM_INFO`) — no lookup. A **player** exposes only item
//! *entries* (`PLAYER_VISIBLE_ITEM_*`); the display id / inventory type / sheath type come from the
//! item template, resolved through the ask-once item layer ([`crate::items::Items`], `CMSG_ITEM_QUERY_SINGLE` on
//! miss — the real client's ItemCache does exactly this; other players' inventory GUIDs are
//! server-private, so the visible-item entry is the *only* path). Drawn-vs-stowed placement follows
//! the unit's sheath state (`UNIT_FIELD_BYTES_2` byte 0) + the item's sheath type.
//! - **Attach** ([`attach_held_items`]) — spawn each resolved item's model as a child of the body's
//! **attach-point joint entity** (via [`BoneAttach`], inserted by the visual attach), so it rides the
//! hand/hip/back bone through every animation — the modern analogue of the client's attach-transform
//! install (`0x47a380`, wow-re charactermodel). The item model itself is a static mesh: its origin
//! *is* the grip, aligned by the attach bone's animated frame.
//!
//! Item models cache per **item display id** in [`ItemDisplays`] (a [`DisplayModel`] like creatures/
//! GameObjects use, resolved from `ItemDisplayInfo.dbc` into `Item\ObjectComponents\{Weapon,Shield}\`),
//! with the display's model texture bound to the model's runtime type-2 batches
//! ([`CharSkinSlot::Object`]).
//!
//! Three files, along the same seam the doc above describes: **`resolve`** (what should each unit
//! hold, and where — the descriptor read + the placement law), **`spawn`** (the sub-model children
//! that ride the bones), and this module, which owns what both share — the attach-id table, the
//! display cache, and the components they diff through.
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;
pub(super) use resolve::resolve_equipment;
mod spawn;
pub(super) use spawn::attach_held_items;
/// The three held-item descriptor slots (vmangos `WeaponAttackType`): 0 mainhand · 1 offhand · 2 ranged.
const HELD_SLOTS: usize = 3;
/// A player's visible-item equipment slots for the held items (vmangos `EQUIPMENT_SLOT_MAINHAND/
/// OFFHAND/RANGED` = 15/16/17 — the `PLAYER_VISIBLE_ITEM_*` blocks are indexed by equipment slot).
const PLAYER_HELD_SLOTS: [u8; HELD_SLOTS] = [15, 16, 17];
/// M2 attachment-point ids (empirically pinned on `HumanMale.m2` — decision 0072): the drawn-hand
/// points, the shield forearm, and the sheathed family the client's `0x47a070` jump table selects
/// from (`K − (mainhand)` over `K ∈ {27, 31, 33}`, const 28 for the shield).
pub(in crate::entities) mod attach_id {
/// Left forearm — a *drawn* shield.
pub(in crate::entities) const SHIELD: u16 = 0;
/// Right/left shoulder (pivots at ∓0.21 Y, shoulder height) — the pauldron pair.
pub(in crate::entities) const SHOULDER_RIGHT: u16 = 5;
pub(in crate::entities) const SHOULDER_LEFT: u16 = 6;
/// Head — the helm.
pub(in crate::entities) const HELM: u16 = 11;
/// Right hand — the drawn mainhand (and a drawn ranged weapon).
pub(in crate::entities) const HAND_RIGHT: u16 = 1;
/// Left hand — a drawn non-shield offhand.
pub(in crate::entities) const HAND_LEFT: u16 = 2;
/// Right/left shoulder-blade — the stowed two-hander family (and a stowed ranged weapon).
pub(in crate::entities) const BACK_RIGHT: u16 = 26;
pub(in crate::entities) const BACK_LEFT: u16 = 27;
/// Centre back — the stowed shield.
pub(in crate::entities) const SHIELD_BACK: u16 = 28;
/// Lower-back pair — the stowed staff family.
pub(in crate::entities) const BACK_LOWER_MAIN: u16 = 30;
pub(in crate::entities) const BACK_LOWER_OFF: u16 = 31;
/// Hip pair — the stowed one-hander family (mainhand on the *left* hip, drawn across the body).
pub(in crate::entities) const HIP_MAIN: u16 = 32;
pub(in crate::entities) const HIP_OFF: u16 = 33;
/// HandArrow (35, bone 126 — flag-0x04 ignore-parent-rotation) — the in-hand nocked arrow's
/// ONE body-bone attach (wow-re `nocked-ammo-cancel.md` §E2: `0x712f70(body, 0x23)` from
/// `0x60ba30`/the `$BWP` BowPull handler; bow/wand only). The old Special2/Special3 (0x18/
/// 0x19) reading is REFUTED — those are the `0x479f40` model-DIRECTORY selectors
/// (`Item\ObjectComponents\Ammo\` vs `…\Weapon\`), never attach ids (§E1).
pub(in crate::entities) const HAND_ARROW: u16 = 0x23;
/// The quiver-on-back attach (wow-re §H2, byte-verified 3×): the worn ammo container's
/// model parents at M2 attachment id 26 — the same point the stowed two-hander family uses.
pub(in crate::entities) const QUIVER: u16 = 26;
}
/// Item-display rendering: the `ItemDisplayInfo.dbc` catalog (held models + armor region textures,
/// decisions 0072/0074) + a per-display [`DisplayModel`] cache for held items. Optional resource —
/// if the DBC fails to load, units hold nothing and armor stays unpainted.
#[derive(Resource)]
pub(crate) struct ItemDisplays {
/// `pub(crate)`: the container feed ([`crate::ui_items`]) reads the icon column off the same
/// parse — one catalog resource serves both the world and the bags.
pub(crate) catalog: ItemDisplayCatalog,
/// Keyed by `(display id, model kind)` — a helm display resolves to a different file per
/// race/sex, a shoulder display to a left/right pair (0074 slice 3c).
pub(super) models: HashMap<(u32, ItemModelKind), DisplayModel>,
}
#[cfg(test)]
impl ItemDisplays {
/// The icon-only test seam: a synthetic catalog with an empty model cache — for the UI feeds
/// that read nothing off this resource but the icon column (the action bar, the bags). They
/// live outside this module, so `models` is not theirs to build.
pub(crate) fn icons_for_tests(catalog: ItemDisplayCatalog) -> Self {
ItemDisplays {
catalog,
models: HashMap::new(),
}
}
}
/// A player's worn-equipment display ids by **bodyslot − 2** (shirt, chest, belt, pants, boots,
/// wrist, gloves, tabard — the armor-composite slots, decision 0074), `0` = empty. `settled` means
/// 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)]
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.
pub(crate) cloak: u32,
/// The head slot's display id (0 = no helm) — an attach sub-model, plus the HelmetGeosetVisData
/// hide-masks that tuck hair/facial/ears under it (wow-re RF-0083).
pub(crate) helm: u32,
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.
#[derive(Component)]
pub(super) struct AppliedEquipment(pub(super) 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.
#[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.
const COMPOSITE_SLOTS: [(u8, usize); 8] = [
(3, 0),
(4, 1),
(5, 2),
(6, 3),
(7, 4),
(8, 5),
(9, 6),
(18, 7),
];
/// The attach sub-model slots a unit shows this frame — the three held items (mainhand/offhand/
/// ranged) plus the helm, the shoulder pair (0074 slice 3c), and the nocked ammo, each an item
/// display + model variant + the body attachment point it hangs from. Recomputed by
/// [`resolve_equipment`] and diffed by [`attach_held_items`], so equipment/sheath changes
/// re-spawn only on an actual change.
#[derive(Component, Default, Clone, PartialEq, Eq)]
pub(super) struct HeldItems {
slots: [Option<HeldSlot>; ATTACH_SLOTS],
}
/// Total attach sub-model slots: the 3 held + helm + shoulder L/R + the nocked ammo + the
/// worn quiver (self-only, ranged-drawn — wow-re `nocked-ammo-cancel.md` §H).
const ATTACH_SLOTS: usize = 8;
#[derive(Clone, Copy, PartialEq, Eq)]
struct HeldSlot {
display: u32,
kind: ItemModelKind,
attach: u16,
/// The `ItemVisuals.dbc` id this item glows with — its display's intrinsic visual, else its
/// first enchant's (decision 0805, [`super::item_glow`]). `0` on the overwhelming majority.
/// Part of the diff key on purpose: applying or losing an enchant re-spawns the item so the
/// glow follows, exactly like a sheath change does.
visual: i32,
}
/// Which of an item display's models a slot shows, and where its file lives (decision 0074 slice 3c
/// — all pinned empirically against the real MPQ listing):
/// `Item\ObjectComponents\{Weapon,Shield}\model[0]` for held items; `Shoulder\model[0]`/`model[1]`
/// for the left/right pauldron (each with its own `model_texture` column); `Head\<stem>_<Ra><S>.m2`
/// for helms — per-race/sex files, prefix by race id (Hu Or Dw Ni Sc Ta Gn Tr), M/F by sex.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(super) enum ItemModelKind {
Weapon,
Shield,
ShoulderLeft,
ShoulderRight,
Helm {
race: u8,
sex: u8,
},
/// A nocked-ammo model ([`crate::creature_anim::NockedAmmo`]) — the missile module's shape
/// rule (its module docs): a display with `model[0]` is a thrown weapon in `Weapon\`; one
/// with only `model[1]` is an arrow/bullet in `Ammo\`.
Ammo,
/// The worn ammo container on the back (wow-re §H2): `Quiver\model[0]` + its own
/// `model_texture[0]` skin, parented at attachment 26 while the ranged weapon is drawn.
Quiver,
}
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the rig's
/// consumer **anchors** by bone (decision 0724 — one anchor entity per bone something reaches,
/// not a full joint hierarchy) + the model's attachment points (id → bone + Bevy-space local
/// offset). Held items spawn under `anchor(bone)` at the offset; every bone rider (mount seat,
/// spell effects, overhead anchors) uses the same two-step lookup.
#[derive(Component)]
pub(crate) struct BoneAttach {
/// Bone index → its anchor entity. Populated for every bone in `points`/`markers` (and the
/// emitter/ribbon/light/billboard hosts) — a `points` hit always resolves.
pub(crate) anchors: HashMap<u16, Entity>,
/// Attachment id → `(bone index, bevy-space offset from the bone's bind pivot)`.
pub(crate) points: HashMap<u16, (u16, Vec3)>,
/// Animation-event marker 4CC → the same `(bone, offset)` shape — first record per ident
/// (the client's `0x7130e0` first-match scan). The missile launch points: `$CSL`/`$CSR`/`$CST`
/// (casting hand left/right/two-hand, `0x60c9b0`'s cascade) and `$BWR` (ranged release).
pub(crate) markers: HashMap<[u8; 4], (u16, Vec3)>,
}
impl BoneAttach {
/// The anchor entity standing in for `bone` — the frame a rider parents under / reads.
pub(crate) fn anchor(&self, bone: u16) -> Option<Entity> {
self.anchors.get(&bone).copied()
}
}
/// The held-item children currently spawned for a unit: the [`HeldItems`] they were built from (the
/// diff key) + the spawned root entities (one per slot, despawned recursively on change).
#[derive(Component, Default)]
pub(super) struct HeldAttached {
applied: HeldItems,
spawned: Vec<Entity>,
}
/// The glow id of a slot the reference never glows: **helm (attach 11), shoulders (5/6) and the
/// quiver (26) push a literal `0`** into the item-attach primitive `0x4798c0` — byte-read at
/// `0x479aa2`/`0x479e5f`/`0x479cf2`/`0x479db4` (decision 0805). Only the weapon/shield hand attach
/// (`0x47a200`) and the ranged/ammo builder pass a display's real visual, so a glowing *shoulder*
/// display — three robes and a chest carry one — is inert in 1.12, and inert here.
const NO_GLOW: i32 = 0;
/// Ensure `display` has a [`DisplayModel`] entry: resolve its ItemDisplayInfo row to the
/// `Item\ObjectComponents\{Weapon,Shield}\` model + its runtime object texture (the display's model
/// texture — an independently-named BLP in the same folder, never derived from the model name).
pub(in crate::entities) fn ensure_item_model(
held: &mut ItemDisplays,
display: u32,
kind: ItemModelKind,
asset_server: &AssetServer,
) {
if held.models.contains_key(&(display, kind)) {
return;
}
// Per-kind: the ObjectComponents directory, which model/texture column, and the helm's
// per-race/sex filename (`<stem>_<Ra><S>.m2` — prefix by race id, empirically pinned, 0074).
// Ammo follows the missile module's shape rule: a `model[0]` row is a thrown weapon
// (`Weapon\`), a `model[1]`-only row an arrow/bullet (`Ammo\`).
let (dir_name, col) = match kind {
ItemModelKind::Weapon => ("Weapon", 0),
ItemModelKind::Shield => ("Shield", 0),
ItemModelKind::ShoulderLeft => ("Shoulder", 0),
ItemModelKind::ShoulderRight => ("Shoulder", 1),
ItemModelKind::Helm { .. } => ("Head", 0),
ItemModelKind::Ammo => {
if held
.catalog
.get(display)
.is_some_and(|d| d.model[0].is_some())
{
("Weapon", 0)
} else {
("Ammo", 1)
}
}
ItemModelKind::Quiver => ("Quiver", 0),
};
let dir = format!("Item\\ObjectComponents\\{dir_name}");
let dm = match held.catalog.get(display) {
Some(d) if d.model[col].is_some() => {
let mut model = d.model[col].clone().unwrap();
if let ItemModelKind::Helm { race, sex } = kind {
// Race id 1–8 → Hu Or Dw Ni Sc Ta Gn Tr; M/F by sex. All 16 variants ship for
// every helm stem (verified against the full MPQ listing).
const RACE_PREFIX: [&str; 8] = ["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr"];
let prefix = RACE_PREFIX[(race.clamp(1, 8) - 1) as usize];
let letter = if sex == 1 { 'F' } else { 'M' };
let stem = model.strip_suffix(".m2").unwrap_or(&model).to_string();
model = format!("{stem}_{prefix}{letter}.m2");
}
let disp_id = display;
debug!(
"item model display {disp_id} → {dir}\\{model} (tex {:?})",
d.model_texture[col]
);
DisplayModel {
handle: ModelHandle::M2(asset_server.load(m2_url(&format!("{dir}\\{model}")))),
// The runtime object skin (bound to the model's type-2 batches) — its own basename in
// the same folder, never derived from the model name (decision 0072's naming trap).
object_texture: d.model_texture[col].clone(),
dir,
..super::empty_shell()
}
}
_ => super::empty_display(),
};
held.models.insert((display, kind), dm);
}
@@ -0,0 +1,496 @@
//! Equipment **resolution** (decisions 0072/0074, split out of `super`'s one file): what each unit
//! should be holding this frame, and where — the descriptor read (a creature's virtual items, a
//! player's visible-item entries through the ask-once item layer), the drawn-vs-stowed placement
//! law, the item/enchant glow id (decision 0805), and the player teardown a gear change forces.
//! The output is [`HeldItems`] + [`Equipment`] + [`Wielded`], which `super::spawn` turns into
//! children.
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::creature_anim::{HandGrip, NockLatch, NockedAmmo, VisualSheath, Wielded};
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
use super::super::item_glow::{self, ItemGlows};
use super::super::Creatures;
use super::{
attach_id, ensure_item_model, Equipment, HeldItems, HeldSlot, ItemDisplays, ItemModelKind,
ATTACH_SLOTS, COMPOSITE_SLOTS, HELD_SLOTS, NO_GLOW, PLAYER_HELD_SLOTS,
};
/// The drawn/stowed attachment point for one held slot, or `None` when the item shows nothing (empty
/// slot, sheath-type-less item while stowed, or an unresolved template).
///
/// Drawn: the unit's sheath state (`UNIT_FIELD_BYTES_2` byte 0: 0 stowed · 1 melee · 2 ranged) draws
/// the matching slots into the hands (shield → forearm). Stowed: the **item's** sheath type picks the
/// body point — 1 two-hander → back · 2 staff → lower back · 3 one-hander → hip · 4 shield → centre
/// back (mainhand takes the `K−1` side of each pair — byte-verified: `0x47a070`'s `dl != 0` is the
/// mainhand bodyslot `0xf`, wow-re `ranged-sheath-display.md`, decision 0370). A sheathed ranged
/// weapon renders **nothing** — the client detaches it rather than re-pointing it to a body bone
/// (byte-verified `0x7130a0`: a pure unlink/release, uniform across bow/gun/crossbow/thrown/wand;
/// wow-re `ranged-sheath-display.md`). Drawn ranged splits by inventory type: a **bow** rides the
/// left hand, gun/crossbow/wand/thrown the right (`0x611e10`'s invType test, same note).
pub(in crate::entities) fn placement(
slot: usize,
inv_type: u32,
item_sheath: u8,
unit_sheath: u8,
) -> Option<u16> {
use attach_id::*;
let shield = inv_type == 14; // INVTYPE_SHIELD
match slot {
// Ranged: in hand while ranged-drawn (bow left, everything else right), invisible otherwise.
2 => (unit_sheath == 2).then_some(if inv_type == 15 {
HAND_LEFT // INVTYPE_RANGED — bows
} else {
HAND_RIGHT
}),
// Melee/shield slots: drawn in melee sheath state, else stowed by the item's sheath type.
0 | 1 if unit_sheath == 1 => Some(match (slot, shield) {
(0, _) => HAND_RIGHT,
(_, true) => SHIELD,
(_, false) => HAND_LEFT,
}),
0 | 1 => match item_sheath {
1 => Some(if slot == 0 { BACK_RIGHT } else { BACK_LEFT }),
2 => Some(if slot == 0 {
BACK_LOWER_MAIN
} else {
BACK_LOWER_OFF
}),
3 => Some(if slot == 0 { HIP_MAIN } else { HIP_OFF }),
4 => Some(SHIELD_BACK),
_ => None,
},
_ => None,
}
}
/// The nocked ammo's attach point (wow-re `nocked-ammo-cancel.md` §E2/E5, byte-verified): the
/// ONE body-bone attach in the whole mechanism is HandArrow (35), fired for a **bow** once its
/// BowPull event latches `[+0xd58]&0x4000` — `nock_latched` is [`NockLatch`], driven by the real
/// `$BWP`/`$BWR` listener (`drive_nock_latch`, decision 0408). Everything else shows NO nocked
/// model: gun/crossbow hit the client's `gunXbow` early return, thrown resolves the `0x19`
/// *directory* (its own weapon-model copy) but fails the `==0x18` attach gate, and a wand's
/// Shoot has no ammo item.
fn ammo_attach(ranged_inv_type: Option<u32>, nock_latched: bool) -> Option<u16> {
const INVTYPE_RANGED_BOW: u32 = 0x0f;
(ranged_inv_type == Some(INVTYPE_RANGED_BOW) && nock_latched).then_some(attach_id::HAND_ARROW)
}
/// The glow id for one held item, and the model requests it implies: the base-or-enchant fork
/// ([`item_glow::effective_visual`]) plus the cache entries for whatever it resolves to. `0` when
/// the glow chain's DBCs are absent — the item simply draws unadorned, as before decision 0805.
fn resolve_glow(
glows: Option<&mut ItemGlows>,
displays: &ItemDisplays,
display: u32,
enchants: impl IntoIterator<Item = u32>,
asset_server: &AssetServer,
) -> i32 {
let Some(glows) = glows else {
return NO_GLOW;
};
let base = displays.catalog.get(display).map_or(0, |d| d.item_visual);
let visual = item_glow::effective_visual(glows, base, enchants);
item_glow::ensure_glow_models(glows, visual, asset_server);
visual
}
/// Resolve every unit's held items from its descriptor. Creatures read display/invType/sheath straight
/// from the virtual-item fields; players go visible-item entry → [`crate::items::Items`] (ask-once query on
/// a miss). Ensures each needed display id has a [`DisplayModel`] entry in [`ItemDisplays`] (built
/// by [`super::update_display_models`] once the asset loads) and writes [`HeldItems`] on change.
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub(in crate::entities) fn resolve_equipment(
mut commands: Commands,
units: Query<(
Entity,
&NetEntity,
&ObjectStore,
Option<&HeldItems>,
Option<&Wielded>,
Option<&Equipment>,
Option<&VisualSheath>,
Option<&crate::creature_anim::AnimDriver>,
Option<&NockedAmmo>,
Has<NockLatch>,
)>,
held: Option<ResMut<ItemDisplays>>,
mut templates: ResMut<Items>,
net: Res<NetCommands>,
asset_server: Res<AssetServer>,
// The creature display cache — a character-model NPC's helm/shoulder ids + race/sex live on its
// display's `NpcAppearance` (CreatureDisplayInfoExtra), read here to resolve its attach models.
creatures: Option<Res<Creatures>>,
// The item/enchant glow chain (decision 0805): resolved here beside the item itself, so a glow
// model is requested the same frame its weapon is and an enchant change rides the item diff.
glows: Option<ResMut<ItemGlows>>,
) {
let Some(mut held) = held else {
return;
};
let mut glows = glows;
for (
entity,
net_entity,
store,
current,
current_wielded,
current_equipment,
visual_sheath,
driver,
nocked,
nock_latched,
) in &units
{
if !matches!(net_entity.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
let s = &store.0;
// Worn armor (players; decision 0074): resolve the composite slots' entries → display ids.
// `settled` only once every non-empty entry has an answer, so the first attach composites the
// dressed atlas directly (the template cache makes later logins instant).
if net_entity.kind == EntityKind::Player {
let mut eq = Equipment {
settled: true,
..default()
};
for (slot, idx) in COMPOSITE_SLOTS {
let Some(entry) = s.player_visible_item_entry(slot).filter(|e| *e != 0) else {
continue;
};
match templates.held(entry, &net) {
Some(t) => eq.bodyslots[idx] = t.display_info_id,
None => eq.settled = false, // asked; answer pending
}
}
// The cloak (equipment slot 14): geoset + cape texture, resolved the same way.
if let Some(entry) = s.player_visible_item_entry(14).filter(|e| *e != 0) {
match templates.held(entry, &net) {
Some(t) => eq.cloak = t.display_info_id,
None => eq.settled = false,
}
}
// The helm (equipment slot 0): attach model + the RF-0083 hide-masks (its geoset effect
// — a hair/facial/ears change — rides the Equipment diff, so donning one re-attaches).
if let Some(entry) = s.player_visible_item_entry(0).filter(|e| *e != 0) {
match templates.held(entry, &net) {
Some(t) => eq.helm = t.display_info_id,
None => eq.settled = false,
}
}
if current_equipment != Some(&eq) {
commands.entity(entity).insert(eq);
}
}
// The *visual* sheath state: held at the pre-transition value while the draw/stow overlay
// plays (the weapon swaps hands at the animation's authored $SHL/$SHR moment, not at the
// byte change — [`VisualSheath`]); else the anim layer's **client-side committed state**
// (the setter/reconcile cache, decision 0080 — the descriptor byte plus the policy's
// forces); else, before the driver first runs, the raw descriptor byte.
let unit_sheath = visual_sheath
.map(|v| v.0)
.or_else(|| driver.and_then(|d| d.sheath_state()))
.or_else(|| s.unit_sheath_state())
.unwrap_or(0);
// A player wearing a NON-character display (druid form, GM morph — decision 0695)
// attaches no equipment sub-models at all: the reference's held/helm/shoulder attach
// lives on the CCharacterComponent (`0x47a0c0`, wow-re charactermodel node), which only
// a character body builds — a bear-form druid shows no weapon by construction, not by a
// hide flag. [`Wielded`] (the anim-class pair) still resolves below: what's IN the hand
// is independent of whether its model is displayed. The creature virtual-item path (a
// naga's trident) is a different, unit-level mechanism and rides the Unit arms untouched.
// An unresolved display cache entry (the one-frame window after a live swap) reads as a
// character body — harmless: this diff re-runs every frame, and an attach needs the
// rebuilt body's `BoneAttach` first anyway.
let char_component = net_entity.kind != EntityKind::Player
|| net_entity
.display_id
.and_then(|d| creatures.as_deref()?.models.get(&d))
.is_none_or(|dm| dm.is_character_body);
let mut slots: [Option<HeldSlot>; ATTACH_SLOTS] = [None; ATTACH_SLOTS];
let mut wielded = Wielded::default();
let mut ranged_inv_type = None;
for slot in 0..HELD_SLOTS {
// (display id, inventory type, item sheath type, item class, item subclass) per slot.
let resolved: Option<(u32, u32, u8, u8, u8)> = match net_entity.kind {
EntityKind::Unit => {
let display = s.unit_virtual_item_display(slot as u8).filter(|d| *d != 0);
display.map(|d| {
let (class, subclass, _, inv) =
s.unit_virtual_item_info(slot as u8).unwrap_or((0, 0, 0, 0));
let sheath = s.unit_virtual_item_sheath(slot as u8).unwrap_or(0);
(d, inv as u32, sheath, class, subclass)
})
}
EntityKind::Player => s
.player_visible_item_entry(PLAYER_HELD_SLOTS[slot])
.filter(|e| *e != 0)
.and_then(|entry| templates.held(entry, &net))
.filter(|t| t.display_info_id != 0)
.map(|t| {
(
t.display_info_id,
t.inventory_type,
t.sheath as u8,
t.class as u8,
t.subclass as u8,
)
}),
_ => None,
};
let Some((display, inv_type, item_sheath, class, subclass)) = resolved else {
continue;
};
// The wielded weapon-class pair (decision 0073's swing/ready selectors) — what's *in*
// the hand, independent of whether its model is displayed (a sheath-less item still
// swings with its own class). The mainhand's sheath type picks the draw/stow one-shot
// (Sheath 89 back / HipSheath 90 hip).
match slot {
0 => {
wielded.main = Some((class, subclass));
wielded.main_sheath = item_sheath;
}
1 => {
wielded.off = Some((class, subclass));
wielded.off_sheath = item_sheath;
}
// The ranged slot: the local auto-repeat idle's Load/Hold selector reads it
// (`select::ranged_load_anim`, 0099 phase 5); the InventoryType picks the
// nocked ammo's attach point below.
2 => {
wielded.ranged = Some((class, subclass));
ranged_inv_type = Some(inv_type);
}
_ => {}
}
if !char_component {
continue; // wielded resolved; the model never attaches on a non-character body
}
let Some(attach) = placement(slot, inv_type, item_sheath, unit_sheath) else {
continue;
};
let kind = if inv_type == 14 {
ItemModelKind::Shield
} else {
ItemModelKind::Weapon
};
ensure_item_model(&mut held, display, kind, &asset_server);
// The glow (decision 0805): the display's intrinsic visual, else this weapon slot's
// first enchant with one. The enchant half is **players only** — the enchant ids ride
// `PLAYER_VISIBLE_ITEM`, and a creature's virtual item carries none, exactly like the
// synthetic item the reference's `GetVirtualItem` hands its resolver.
let enchants = (net_entity.kind == EntityKind::Player).then(|| {
// All 7 CGItem enchant slots, the reference's scan width (`0x62ec70`); 1.12
// broadcasts the first two (PERM, TEMP).
(0..7u8).filter_map(|j| s.player_visible_item_enchant(PLAYER_HELD_SLOTS[slot], j))
});
let visual = resolve_glow(
glows.as_deref_mut(),
&held,
display,
enchants.into_iter().flatten(),
&asset_server,
);
slots[slot] = Some(HeldSlot {
display,
kind,
attach,
visual,
});
}
// The nocked ammo (byte-verified `0x60ba30` + the Q-E round, wow-re
// `nocked-ammo-cancel.md` §E2/E5): the ONE body-bone attach in the whole mechanism is
// HandArrow (35), **bow-only** — gun/crossbow (`gunXbow` early return) and thrown
// (directory selector `0x19`, never an attach id) show NO nocked model. The [`NockedAmmo`]
// display is written per shot from `SMSG_SPELL_START`, any caster; the attach follows the
// client's `$BWP`/`$BWR` keyframes through [`NockLatch`] (`drive_nock_latch`, decision
// 0408 — the arrow appears at the pull and leaves with the release).
if let (true, Some(ammo), Some(attach)) = (
char_component,
nocked,
ammo_attach(ranged_inv_type, nock_latched),
) {
ensure_item_model(
&mut held,
ammo.display_id,
ItemModelKind::Ammo,
&asset_server,
);
// The ammo model's own intrinsic visual — the reference attaches it at the tail of
// the ranged/ammo builder `0x479f40` (`47a051: mov edx,[ebx+0x58]`), the same way the
// hand attach does. No enchant lane here: this is a model, not an equipped item.
let visual = resolve_glow(
glows.as_deref_mut(),
&held,
ammo.display_id,
[],
&asset_server,
);
slots[6] = Some(HeldSlot {
display: ammo.display_id,
kind: ItemModelKind::Ammo,
attach,
visual,
});
}
// The quiver on the back (wow-re `nocked-ammo-cancel.md` §H, byte-verified): while the
// RANGED weapon is drawn — the same `0x611e10` ranged-draw transition, cleared on every
// other ranged state — the client scans the player's OWN inventory for an ItemClass-11
// container (Quiver/Ammo Pouch) and parents its display model at attachment 26 (no
// transform override; no cloak conflict). Self-only by construction, exactly like the
// client: bag slots are never replicated in 1.12, so a remote player's scan finds
// nothing (§H1 — a two-client capture would be the clean confirmation).
if net_entity.kind == EntityKind::Player && char_component && unit_sheath == 2 {
let mut quiver_display = None;
for bag in 19u8..23 {
let entry = s
.player_inv_slot(bag)
.and_then(|g| templates.object(g))
.and_then(|o| o.object_entry());
let Some(t) = entry.and_then(|e| templates.held(e, &net)) else {
continue;
};
if t.class == 11 && t.display_info_id != 0 {
quiver_display = Some(t.display_info_id);
break;
}
}
if let Some(display) = quiver_display {
ensure_item_model(&mut held, display, ItemModelKind::Quiver, &asset_server);
slots[7] = Some(HeldSlot {
display,
kind: ItemModelKind::Quiver,
attach: attach_id::QUIVER,
visual: NO_GLOW,
});
}
}
// Helm + shoulders (0074 slice 3c / the npc-armor arc): attach sub-models like the held items —
// the helm's file is per-race/sex, the shoulders a left/right model pair off one display row.
// A **player** sources them from its visible-item entries (wire → item template → display id)
// with race/sex off its descriptor; a **character-model NPC** sources them from its display's
// CreatureDisplayInfoExtra head/shoulder columns with race/sex from the same row — those are
// direct ItemDisplayInfo display ids, no template round-trip. A beast NPC (no appearance row)
// resolves nothing here, exactly as before.
let head_shoulder: Option<(u32, u32, u8, u8)> = match net_entity.kind {
EntityKind::Player if char_component => {
let race = s.unit_race().unwrap_or(1);
let sex = s.unit_gender().unwrap_or(0).min(1);
let mut resolve = |slot: u8| {
s.player_visible_item_entry(slot)
.filter(|e| *e != 0)
.and_then(|entry| templates.held(entry, &net))
.map(|t| t.display_info_id)
.filter(|d| *d != 0)
.unwrap_or(0)
};
let helm = resolve(0);
let shoulder = resolve(2);
Some((helm, shoulder, race, sex))
}
EntityKind::Unit => net_entity
.display_id
.and_then(|disp| creatures.as_deref()?.models.get(&disp))
.and_then(|dm| dm.npc_appearance.as_ref())
.map(|npc| (npc.equipment[0], npc.equipment[1], npc.race, npc.sex.min(1))),
_ => None,
};
if let Some((helm, shoulder, race, sex)) = head_shoulder {
if helm != 0 {
let kind = ItemModelKind::Helm { race, sex };
ensure_item_model(&mut held, helm, kind, &asset_server);
slots[3] = Some(HeldSlot {
display: helm,
kind,
attach: attach_id::HELM,
visual: NO_GLOW,
});
}
if shoulder != 0 {
for (kind, attach, idx) in [
(ItemModelKind::ShoulderLeft, attach_id::SHOULDER_LEFT, 4),
(ItemModelKind::ShoulderRight, attach_id::SHOULDER_RIGHT, 5),
] {
ensure_item_model(&mut held, shoulder, kind, &asset_server);
slots[idx] = Some(HeldSlot {
display: shoulder,
kind,
attach,
visual: NO_GLOW,
});
}
}
}
let next = HeldItems { slots };
// Per-hand grip: a weapon in a hand's attach point curls that hand's fingers (wow-re
// `hand-grip-mechanism.md`) — mainhand → right (id 1), non-shield offhand → left (id 2); a
// forearm shield (id 0) or an empty hand stays open. Drives [`HandGrip`]'s finger overlay.
let grip = HandGrip {
right: next
.slots
.iter()
.flatten()
.any(|s| s.attach == attach_id::HAND_RIGHT),
left: next
.slots
.iter()
.flatten()
.any(|s| s.attach == attach_id::HAND_LEFT),
};
if current != Some(&next) {
commands.entity(entity).insert((next, grip));
}
if current_wielded != Some(&wielded) {
commands.entity(entity).insert(wielded);
}
}
}
#[cfg(test)]
mod tests {
use super::{ammo_attach, attach_id, placement};
/// The ranged slot's whole placement law: in hand only while ranged-drawn (state 2) — bow
/// (INVTYPE_RANGED 15) to the left hand, gun/crossbow/wand/thrown (RANGEDRIGHT 26 / THROWN 25)
/// to the right — and invisible in every other sheath state, regardless of the item's own
/// sheath type.
#[test]
fn ranged_slot_hidden_unless_ranged_drawn() {
for inv_type in [15, 25, 26] {
for item_sheath in 0..=4u8 {
assert_eq!(placement(2, inv_type, item_sheath, 0), None);
assert_eq!(placement(2, inv_type, item_sheath, 1), None);
let drawn = if inv_type == 15 {
attach_id::HAND_LEFT
} else {
attach_id::HAND_RIGHT
};
assert_eq!(placement(2, inv_type, item_sheath, 2), Some(drawn));
}
}
}
/// Melee slots keep the sheath-type stow table (unchanged by the ranged rule).
#[test]
fn melee_slots_stow_by_item_sheath_type() {
assert_eq!(placement(0, 17, 1, 0), Some(attach_id::BACK_RIGHT));
assert_eq!(placement(0, 21, 3, 0), Some(attach_id::HIP_MAIN));
assert_eq!(placement(1, 14, 4, 0), Some(attach_id::SHIELD_BACK));
assert_eq!(placement(0, 21, 3, 1), Some(attach_id::HAND_RIGHT));
}
/// The nocked-ammo attach law (`0x60ba30`, wow-re `nocked-ammo-cancel.md` §E2, decision
/// 0408): HandArrow (35) is the ONE attach, bow-only, gated on the `$BWP` nock latch.
/// Gun/crossbow/thrown never attach a nocked model.
#[test]
fn ammo_attach_hands_the_volleying_bow_arrow_and_nothing_else() {
assert_eq!(ammo_attach(Some(0x0f), true), Some(attach_id::HAND_ARROW)); // bow, volleying
assert_eq!(ammo_attach(Some(0x0f), false), None); // bow, idle — pre-BowPull, no attach
assert_eq!(ammo_attach(Some(0x19), true), None); // thrown — directory 0x19, never an id
assert_eq!(ammo_attach(Some(0x1a), true), None); // gun/xbow/wand — the gunXbow early return
assert_eq!(ammo_attach(None, true), None); // no ranged record
}
}
@@ -0,0 +1,358 @@
//! Equipment **attach** (decisions 0072/0074, split out of `super`'s one file): the sub-model
//! children a unit's resolved [`HeldItems`] spawns — each item model's parts under its attach
//! point's joint entity, plus everything that rides an item (its billboard cards, emitters,
//! lights, ribbons, and the glow its `ItemVisuals` id names — decision 0805).
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::billboard::BillboardCard;
use crate::debug_panel::{ModelKind, ModelPart};
use crate::interior::part_interior_lit;
use crate::model_fade::{
fade_alpha, join_unit_appear_fade, FadeMaterials, JoinedFade, PendingAppearFade, RenderFade,
UnitAppearFade, APPEAR_FADE_SECS,
};
use super::super::spawn_carried_lights;
use super::{attach_id, BoneAttach, HeldAttached, HeldItems, ItemDisplays, NO_GLOW};
/// A held-item part's resolved appear-fade join state for this spawn, folding [`JoinedFade`] with
/// whether the part itself is fade-capable ([`super::EntityPart::fade_blend`]).
#[derive(Clone, Copy)]
enum PartFade {
Steady,
Pending(f32),
Live(f32),
}
/// Spawn/refresh the held-item children for every unit whose [`HeldItems`] changed (or whose item
/// model finished loading): each slot's model parts spawn under the attach point's joint entity at
/// the attachment offset, so they ride the bone. Slots whose model is still loading are left pending
/// (the `applied` diff key keeps them un-applied) and picked up on a later pass. A part spawning while
/// the unit's own appear-fade is still in flight joins it ([`join_unit_appear_fade`]) instead of
/// popping in opaque (decision 0032 read as a per-unit property).
#[allow(clippy::type_complexity)]
pub(in crate::entities) fn attach_held_items(
mut commands: Commands,
mut units: Query<(
&HeldItems,
&BoneAttach,
Option<&mut HeldAttached>,
Entity,
Option<&UnitAppearFade>,
Option<&crate::interior::BodyBakeCenter>,
// The unit root's own transform — its scale is the wire `NetEntity::scale` the streamer
// writes (`entities::attach`), and the held effects' draw-order rung is measured in the
// world yards that scale produces.
Option<&Transform>,
)>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
) {
let Some(held) = held else {
return;
};
let now = time.elapsed_secs();
for (items, bones, attached, entity, unit_fade, body_center, unit_tf) in &mut units {
// A held item / helm / shoulder resolves and spawns asynchronously (a template round trip, a
// model load) — often *after* the body has already armed its appear-fade (decision 0032 is a
// per-unit property: the reference fades the whole unit, attachments included, as one). Read
// the unit root's fade clock once per unit so every part spawned below joins the same ramp
// instead of popping in opaque.
let joined = join_unit_appear_fade(unit_fade.copied());
// Diff against what's spawned; skip when unchanged. A slot whose model hasn't built parts yet
// is masked out of `next` so it stays "not yet applied" and re-attaches once the parts exist.
let mut next = items.clone();
for slot in next.slots.iter_mut() {
let ready = slot.is_some_and(|hs| {
held.models
.get(&(hs.display, hs.kind))
.and_then(|dm| dm.parts.as_ref())
.is_some()
});
if !ready {
*slot = None;
}
}
if attached.as_ref().is_some_and(|a| a.applied == next) {
continue;
}
// Despawn the previous children, then spawn the new set.
let mut spawned = Vec::new();
if let Some(a) = &attached {
for e in &a.spawned {
commands.entity(*e).despawn();
}
}
for (slot_idx, hs) in next
.slots
.iter()
.enumerate()
.filter_map(|(i, s)| s.as_ref().map(|hs| (i, hs)))
{
let Some(dm) = held.models.get(&(hs.display, hs.kind)) else {
continue;
};
let Some(parts) = dm.parts.as_ref() else {
continue;
};
let Some(&(bone, offset)) = bones.points.get(&hs.attach) else {
// Body model has no such attach point (a non-character skeleton) — hold nothing.
continue;
};
let Some(joint) = bones.anchor(bone) else {
continue;
};
let root = commands
.spawn((Transform::from_translation(offset), Visibility::default()))
.id();
commands.entity(joint).add_child(root);
// The item/enchant glow (decision 0805): its instances hang off the ITEM's own
// attachment points, so they are children of this root — spawned by
// [`super::item_glow::attach_item_glows`] once the glow models build, and reaped with
// the root on any gear/sheath change, which is the whole lifetime rule.
if hs.visual != NO_GLOW {
commands
.entity(root)
.insert(super::super::item_glow::ItemGlow {
display: hs.display,
kind: hs.kind,
visual: hs.visual,
});
}
// The engine-drawn bowstring (0408 §G2) — for the drawn BOW only: the ranged slot's
// left-hand fork (a bow is the one ranged weapon placed in HAND_LEFT; the client
// registers the string callback bow-only, from the ranged-draw path). The `$WTT`/
// `$WTB` anchors alone are NOT the gate — they are generic weapon-TRAIL begin/end
// markers (wow-re w2d2: `WTBT`/`WTTT`, the swing-trail vertex build) that melee
// weapons author too; keying on their presence drew a phantom "bowstring" chord
// across the Whirlwind Axe's blade tips (decision 0531).
if slot_idx == 2 && hs.attach == attach_id::HAND_LEFT {
if let Some([top, bottom]) = dm.string_anchors {
commands.entity(root).insert(crate::bowstring::Bowstring {
owner: entity,
top: top.1,
bottom: bottom.1,
});
}
}
// Billboard batches (the torch's glow card) collected for the world-root card spawn
// below — as plain children they'd render at the item root (the grip), not the
// authored pivot (the torch head). Decision 0153.
let mut billboard_parts = Vec::new();
commands.entity(root).with_children(|parent| {
for part in parts {
if let Some(info) = &part.billboard {
billboard_parts.push((info.clone(), part));
continue;
}
// Per-part join decision: `joined` (the unit's clock) combined with whether this
// part is fade-capable at all (`fade_blend` — WMO-display parts have none, though
// held items are always M2). Steady = spawn opaque now, exactly as before this fix.
let effective = match (joined, part.fade_blend.is_some()) {
(_, false) => PartFade::Steady,
(JoinedFade::Steady, true) => PartFade::Steady,
(JoinedFade::Pending { since }, true) => PartFade::Pending(since),
(JoinedFade::Live { started }, true) => PartFade::Live(started),
};
let (init_mat, tag_alpha) = match effective {
PartFade::Steady => (part.material.clone(), 1.0),
PartFade::Pending(_) => (part.fade_blend.clone().unwrap(), 0.0),
// Compute the *current* alpha (not 0) so a late joiner doesn't flash invisible
// for a frame before `apply_render_fade` catches up — it's already mid-ramp.
PartFade::Live(started) => (
part.fade_blend.clone().unwrap(),
fade_alpha(0.0, 1.0, (now - started) / APPEAR_FADE_SECS),
),
};
let mut child = parent.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(init_mat),
Transform::default(),
ModelPart {
kind: ModelKind::Creature,
blend: part.blend,
},
// The portrait booth mirrors this rider ([`crate::portrait`]): steady
// material (not the fade twin) + where it sits, so the bake can seat it at
// the bone's bind-pose global (the booth spawns no skeleton).
crate::portrait::PortraitRider {
static_mesh: part.mesh.clone(),
material: part.material.clone(),
bone,
offset,
},
));
// Interior-light parity with the body: both material variants + a MeshTag, so the
// classifier relights the weapon inside a WMO room like it does its wielder. While
// joining the unit's appear-fade the tag carries its alpha instead — the classifier
// yields via its own `Without<RenderFade>`/`Without<PendingAppearFade>` filter, same
// as a body part.
// Anchored at the WEARER's root: an equipped item M2 aliases its wearer's
// light collector by pointer (`[item+0x3b8]=[wearer+0x3b8]`, `0x718960` —
// wow-re `unit-light-combine-storm.md`), so it never runs its own
// classify/footprint at the carried position. The animating hand joint
// once anchored these, and a swing alone could trip the resample gate and
// split the shield's light from the body's (director-caught, 2026-07-13).
// The fold reference is the wearer's BODY centre for the same reason.
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
body_center.map_or(dm.bake_center_local, |c| c.0),
entity,
) {
child.insert((MeshTag(crate::mesh_tag::alpha_bits(tag_alpha)), lit));
}
// `FadeMaterials` is persistent bookkeeping (self-avatar zoom fade, decision 0032's
// despawn-fade-out), not tied to whether *this* spawn happens to join an in-flight
// unit fade — attach it whenever the part is fade-capable at all, steady or not.
if let Some(blend) = &part.fade_blend {
child.insert(FadeMaterials {
cutout: part.material.clone(),
blend: blend.clone(),
bake_blend: part.material_interior_bake_blend.clone(),
});
}
match effective {
PartFade::Steady => {}
PartFade::Pending(since) => {
child.insert(PendingAppearFade { since });
}
PartFade::Live(started) => {
child.insert(RenderFade {
started,
duration: APPEAR_FADE_SECS,
from: 0.0,
to: 1.0,
});
}
}
}
});
// The billboard cards (decision 0153): world-root entities FOLLOWING `root` — it sits
// at the attach offset under the hand joint, is fresh per attach, and despawns on a
// gear change, so the card's lifecycle and frame both come for free (same owner
// contract as the item's emitters below).
for (info, part) in billboard_parts {
let mut card = commands.spawn((
Mesh3d(part.mesh.clone()),
MeshMaterial3d(part.material.clone()),
Transform::default(),
ModelPart {
kind: ModelKind::Creature,
blend: part.blend,
},
BillboardCard::following(&info, root),
));
// 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
// card joins no appear-fade today (it carries neither `RenderFade` nor
// `FadeMaterials`), and the classifier preserves the alpha field regardless.
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
body_center.map_or(dm.bake_center_local, |c| c.0),
entity,
) {
card.insert((MeshTag(crate::mesh_tag::alpha_bits(1.0)), lit));
}
}
// The item's own particle emitters — the held torch's flame (0130 phase 4: the same
// owner-follow rider as doodad emitters). `root` sits at the attach offset under the
// hand joint and its frame IS the item's model frame; a held item spawns no skeleton,
// so the rest pose applies and no pivot rebase is needed — the flame burns at its
// authored spot (the torch tip) and follows the hand through the swing. Free entities:
// they self-despawn with `root` via the owner contract (gear change, unit despawn).
// The spawn transform does two jobs, and neither is placing the emitter (the owner
// overwrites the position every frame): its TRANSLATION seeds the flicker RNG —
// root's entity bits de-sync two torch-bearers standing side by side — and its SCALE
// is the wearer's, which is what the effects' draw-order rung is measured in
// (`particles::owner_last_bias`). An item on a twice-size wielder reaches twice as
// far from its own origin, so its rung has to grow with it; reading the scale off a
// transform built as a bare RNG seed silently pinned every held effect at 1×.
let unit_scale = unit_tf.map_or(1.0, |t| t.scale.max_element());
let spawn_tf = Transform::from_translation(Vec3::splat(root.to_bits() as f32))
.with_scale(Vec3::splat(unit_scale));
for em in &dm.emitters {
// …with ONE exception to "the rest pose applies": a **billboard** bone in the
// emitter's chain. Its palette rows are replaced with the camera basis about its
// own pivot every frame and children multiply onto that, so the reference's
// emitter origin is `pivot + camBasis·(position − pivot)` — camera-dependent, and
// up to two chain-offsets away from where the rest pose puts it (decision 0813).
// The rig lane gets this from its joint palette; an item model has no rig, so the
// frame is realized as a mesh-less billboard card the emitter OWNS-follows
// (`BillboardCard::frame_following`). Nothing else in the chain is live: of the
// 95 item models whose emitters ride a billboard bone, none animates its chain.
let owner = match em.billboard {
Some((kind, pivot)) => {
let frame = commands
.spawn(BillboardCard::frame_following(
kind,
benilla_assets::coords::wow_to_bevy(pivot),
root,
))
.id();
let d = (0..3)
.map(|c| (em.def.position[c] - pivot[c]).powi(2))
.sum::<f32>()
.sqrt();
debug!(
"item fx: display {} bone {} rides a {kind:?} billboard frame \
(pivot {pivot:?}, chain offset {d:.3} yd)",
hs.display, em.def.bone
);
(frame, pivot)
}
None => (root, [0.0; 3]),
};
crate::particles::spawn_emitter(
&mut commands,
em,
spawn_tf,
Some(owner),
Some(root), // a held item is an attached model — the flame fans with the swing
Some(root), // the cloud anchors at the MODEL; the bone composes births only
// 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).
crate::particles::EmitClock::Pinned,
);
}
// The item's own M2 point light — **the held torch's glow** (decision 0016's law on the
// entity half of the scene; see `super::carried_light`). `Club_1H_Torch_A_01.m2` — the
// torch every torch-bearing NPC carries — authors exactly one: a warm
// `(0.467, 0.290, 0.133) × 3.0` point light 0.58 yd up the shaft. It rides `root` like
// the emitters and for the same reason (the item poses at rest, so its model space IS
// the bone-local frame), which walks it through the hand's swing; the fence rails and
// grass around the bearer then gather it like any other scene point light.
spawn_carried_lights(&mut commands, &dm.lights, root, |_| None);
// The item's ribbon trails (weapon enchant streaks): ride the item root — a held item
// poses at rest, so the bone-local origin is model-space (no pivot rebase). A held item
// rests in Stand (anim 0): a thrown weapon's trail is keyed dark there, so the flight
// ribbon never shows in the hand — it lights only on the InFlight missile.
for rb in &dm.ribbons {
crate::ribbons::spawn_ribbon(&mut commands, rb, root, false, unit_scale, Some(0));
}
debug!(
"held attach: unit {entity} display {} → attach {} (bone {bone}, {} parts)",
hs.display,
hs.attach,
parts.len()
);
spawned.push(root);
}
let applied = HeldAttached {
applied: next,
spawned,
};
match attached {
Some(mut a) => *a = applied,
None => {
commands.entity(entity).insert(applied);
}
}
}
}
+304
View File
@@ -0,0 +1,304 @@
//! **Item / enchant glow effects** (decision 0805): the permanent weapon glow a display authors,
//! and the enchant glow a weapon's enchant carries — `Spells\Enchantments\*.mdx` effect models
//! hung on the ITEM's own model, not on the wearer's body.
//!
//! ## The chain, and where each half is verified
//!
//! `ItemDisplayInfo` col 22 → `ItemVisuals` → 5 × `ItemVisualEffects` → a glow `.mdx` per slot
//! ([`benilla_formats::ItemVisualCatalog`], whose module doc owns the table shapes and the
//! reference's per-slot skip rules). Two sources feed the id, and the reference resolves exactly
//! one of them per item (wow-re `object-layer/scratch/item-visual-enchant.md`, byte-verified;
//! the call sites below were read at the bytes for decision 0805):
//!
//! - **Intrinsic** — the display's own id, passed by the character's held-item attach:
//! `0x47a200: mov edx,[esi+0x58]` → `0x4798c0(…, itemVisualsId)`, which calls the attach
//! `0x479700` iff the id is **`> 0`** (`jle` skip). The same primitive attaches the helm (id
//! 11), the shoulders (5/6) and the quiver (26) — and **every one of those call sites pushes a
//! literal `0`**, so on a character body only the WEAPON/SHIELD hand attach can glow. The
//! ranged-ammo lane (`0x479f40`) passes `[ebx+0x58]` at its tail, so a thrown/ammo model glows
//! from its own display too.
//! - **Enchant** — `0x5eed50` per weapon slot → `0x62ec70(item)`: if the base display's
//! ItemVisuals row **exists**, it returns `-1` and the enchant glow is suppressed (the base
//! already draws one); otherwise the first of the item's 7 enchant slots whose
//! `SpellItemEnchantment` row carries a nonzero visual wins. [`effective_visual`] is that fork.
//!
//! ## Where the glow hangs
//!
//! `0x479700` walks the 5 effect columns and calls `0x712f70(glow, itemModel, attachId = the
//! column index)` — so **slot i is M2 attachment id i on the item's own model**, and a row that
//! repeats one effect across all five slots (BlueGlow_Med ×5, the common shape) reads as a glow
//! along the whole blade.
//!
//! **How many of the five actually land is the model's business, and it varies**: of the 160
//! distinct weapon models the 360 glowing displays resolve to, 61 author the full ids 0..4 and 44
//! author only 2/3/4 (Ironfoe's hammer is one — three of its five copies draw). An id the model
//! doesn't author hangs nothing at all, byte-verified: `0x712f70` stores `0x710310`'s `0xffff`
//! miss and every consumer of that field (`0x7140aa`, `0x718668`, `0x719266`) tests
//! `cmp 0xffff; je` and skips the child — there is no fallback to the model origin. Which record
//! an id resolves to is the model's **AttachLookup**, never a scan of its table
//! (`benilla_m2::M2Model::attachment`): ~5% of weapon models author two records under one id.
//!
//! An item model poses at rest (no rig is spawned for it — `super::equipment`), so the attach
//! point is its bind-pose position ([`crate::portrait::attachment_point`]) and the glow instance
//! is a plain child of the item root: it rides the hand/hip/back bone with the weapon, and dies
//! with it on a gear or sheath change. Everything past that — the effect model's own rig,
//! emitters, ribbons, material animation — is the shared effect body
//! ([`super::spell_fx::attach_effect_visuals`]); these models are almost all pure particle
//! emitters (BlueGlow_Med: zero render batches, one Plane/Add emitter on `Flare.blp`).
use std::collections::HashMap;
use benilla_formats::{EnchantVisualCatalog, ItemVisualCatalog, ITEM_VISUAL_SLOTS};
use bevy::prelude::*;
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::{DisplayModel, ModelHandle};
/// The glow chain's data + the effect-model cache: the two joined DBC catalogs, plus a
/// path-keyed [`DisplayModel`] per glow model (the [`super::spell_fx::SpellFx`] pattern — entries
/// are created by [`ensure_glow_models`] at equipment-resolve time and built by
/// `super::update_display_models` the same frame). Optional resource: without the DBCs nothing
/// glows, exactly as before this lane existed.
#[derive(Resource)]
pub(crate) struct ItemGlows {
pub(super) visuals: ItemVisualCatalog,
pub(super) enchants: EnchantVisualCatalog,
pub(super) models: HashMap<String, DisplayModel>,
}
impl ItemGlows {
pub(super) fn new(visuals: ItemVisualCatalog, enchants: EnchantVisualCatalog) -> Self {
ItemGlows {
visuals,
enchants,
models: HashMap::new(),
}
}
/// The glow models an ItemVisuals id resolves to, by attach slot — `None` when the id names
/// no row (0, the shipped `-1`s, out of range).
pub(super) fn effects(&self, visual: i32) -> Option<&[Option<String>; ITEM_VISUAL_SLOTS]> {
self.visuals.effects(visual)
}
}
/// The ItemVisuals id an item actually glows with — the reference's base-or-enchant fork
/// (`0x62ec70`): the **base** display's id wins whenever it names a real ItemVisuals row (the
/// client returns `-1` from the enchant resolver precisely so the two can't double up); otherwise
/// the **first** enchant slot whose `SpellItemEnchantment` row carries a nonzero visual. `0` when
/// neither does.
pub(in crate::entities) fn effective_visual(
glows: &ItemGlows,
base: i32,
item_enchants: impl IntoIterator<Item = u32>,
) -> i32 {
if glows.visuals.effects(base).is_some() {
return base;
}
for enchant in item_enchants {
if let Some(visual) = glows.enchants.visual(enchant) {
return visual;
}
}
0
}
/// Create the [`DisplayModel`] cache entries for a visual's glow models, so
/// `super::update_display_models` builds them the same frame the equipment resolve asked for
/// them (the held-item pattern).
pub(in crate::entities) fn ensure_glow_models(
glows: &mut ItemGlows,
visual: i32,
asset_server: &AssetServer,
) {
let Some(paths) = glows.visuals.effects(visual) else {
return;
};
// Collected first: `effects` borrows the catalog, and the insert below borrows the cache.
let wanted: Vec<String> = paths
.iter()
.flatten()
.filter(|p| !glows.models.contains_key(*p))
.cloned()
.collect();
for path in wanted {
let handle = ModelHandle::M2(asset_server.load(m2_url(&path)));
glows.models.insert(
path,
DisplayModel {
handle,
..super::empty_shell()
},
);
}
}
/// A spawned held-item root that should carry a glow: the item's display-model key (for its
/// attachment table) and the resolved ItemVisuals id. Written by `super::equipment`'s attach;
/// consumed once by [`attach_item_glows`], which then marks it [`ItemGlowAttached`].
#[derive(Component)]
pub(in crate::entities) struct ItemGlow {
pub(in crate::entities) display: u32,
pub(in crate::entities) kind: ItemModelKind,
pub(in crate::entities) visual: i32,
}
/// Set once an item root's glow instances are spawned (or resolved to nothing) — the once-only
/// gate. The instances are children of the root, so they need no lifetime bookkeeping of their
/// own: a gear change, a sheath swap, or the unit despawning takes the root and them with it.
#[derive(Component)]
pub(super) struct ItemGlowAttached;
/// Spawn each pending item root's glow instances: one effect-model instance per authored slot, at
/// that slot's attachment point on the item's own model.
///
/// All-or-nothing per item — while any of the item's glow models is still loading the whole set
/// waits, so a two-model visual can't spawn half of itself and then re-enter here. A model that
/// never loads simply never glows (the item is still perfectly drawn); the retry is a handful of
/// roots per frame.
#[allow(clippy::too_many_arguments)]
pub(super) fn attach_item_glows(
mut commands: Commands,
pending: Query<(Entity, &ItemGlow), Without<ItemGlowAttached>>,
glows: Option<Res<ItemGlows>>,
items: Option<Res<ItemDisplays>>,
time: Res<Time>,
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
// One breadcrumb per session the first time a glow actually spawns — the machine-readable
// "this lane is live" signal for a subsystem whose whole symptom is *absence* (the same
// idiom `apply_unit_mat_alpha` uses for its cull).
mut logged: Local<bool>,
) {
let (Some(glows), Some(items)) = (glows, items) else {
return;
};
let now = time.elapsed_secs();
for (root, glow) in &pending {
let Some(paths) = glows.effects(glow.visual) else {
commands.entity(root).insert(ItemGlowAttached); // nothing to hang
continue;
};
let Some(item) = items.models.get(&(glow.display, glow.kind)) else {
continue; // the item's own display entry is gone (a cache evict) — retry
};
// Ready = every authored slot's model has built its parts. Emitter-only models build an
// EMPTY part list, which is `Some` — the readiness test is the option, not the length.
let ready = paths
.iter()
.flatten()
.all(|p| glows.models.get(p).is_some_and(|dm| dm.parts.is_some()));
if !ready {
continue;
}
let mut spawned = 0usize;
for (slot, path) in paths
.iter()
.enumerate()
.filter_map(|(i, p)| p.as_ref().map(|p| (i, p)))
{
let Some(dm) = glows.models.get(path) else {
continue;
};
// Slot index == M2 attachment id on the ITEM model (module doc). **A model missing
// that point hangs nothing there**, and that is the reference's behaviour, not a
// shortcut: `0x712f70` stores the attach-lookup miss as `0xffff`
// (`0x710310`'s bounds return), and every consumer of that field —
// `0x7140aa`/`0x718668`/`0x719266` — tests `cmp 0xffff; je` and skips the child
// outright. It is a common case, not a corner: Ironfoe's hammer authors only ids
// 2/3/4, so three of its visual's five copies draw and two do not.
let Some(at) =
crate::portrait::attachment_point(&item.skeleton, &item.attachments, slot as u16)
else {
debug!(
"item glow: display {} has no attachment {slot} for {path}",
glow.display
);
continue;
};
let instance = commands
.spawn((Transform::from_translation(at), Visibility::default()))
.id();
commands.entity(root).add_child(instance);
attach_effect_visuals(
&mut commands,
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
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
None, // the glow models author one looping sequence — the default pick
);
spawned += 1;
}
commands.entity(root).insert(ItemGlowAttached);
// Counted from what actually spawned, never from what the row *authors* — the two differ
// whenever the item model lacks a point (above), and a count of authored paths reads as a
// glow that isn't there.
let authored = paths.iter().flatten().count();
if !*logged && spawned > 0 {
*logged = true;
info!(
"item glow: display {} visual {} → {spawned} of {authored} model(s) attached \
(the first this session)",
glow.display, glow.visual,
);
}
debug!(
"item glow: display {} visual {} → {spawned} of {authored} model(s)",
glow.display, glow.visual,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog(visual_rows: &[u32], enchant_rows: &[(u32, i32)]) -> ItemGlows {
let visuals = visual_rows
.iter()
.map(|id| {
(
*id,
std::array::from_fn(|i| {
(i == 3).then(|| format!("Spells\\Enchantments\\Row{id}.mdx"))
}),
)
})
.collect();
ItemGlows::new(
ItemVisualCatalog::from_visuals(visuals),
EnchantVisualCatalog::from_visuals(enchant_rows.iter().copied().collect()),
)
}
/// The base-or-enchant fork (`0x62ec70`): an intrinsic visual wins and **suppresses** the
/// enchant's, an item without one takes the first enchant slot that carries a visual, and
/// enchant slots with no visual are walked past rather than ending the scan.
#[test]
fn base_visual_wins_over_enchant_and_suppresses_it() {
let glows = catalog(&[25, 61], &[(1, 61), (7, 25)]);
// Base 25 exists → base, even with an enchant that also carries one.
assert_eq!(effective_visual(&glows, 25, [1]), 25);
// No base → the enchant's.
assert_eq!(effective_visual(&glows, 0, [1]), 61);
// A visual-less enchant in the way: the scan continues to the one that has it.
assert_eq!(effective_visual(&glows, 0, [999, 7]), 25);
// Nothing anywhere.
assert_eq!(effective_visual(&glows, 0, [999]), 0);
// The shipped `-1` base is not a row: it neither glows nor suppresses the enchant.
assert_eq!(effective_visual(&glows, -1, [1]), 61);
assert_eq!(effective_visual(&glows, -1, []), 0);
}
}
+96 -22
View File
@@ -5,16 +5,26 @@
//! a per-frame MCSH sample at the object's node position and a linear intensity ramp (`0x69e770`, the
//! step constant `[0x810808] = 3.3333`/s), not a static spawn-time bake.
//!
//! **OPEN, and it is this file's whole premise (decision 0796 §4).** §9's chain is byte-verified up to
//! its last link — "the unit's own light node fills the unit's committed light" — and that link is
//! INFERRED, not verified. Observation contradicts it: a player + three NPCs across two of the
//! reference's own apitraces commit their sun at gain **exactly 1.0**, never 2.5 or 0.5, while the same
//! frames' ADT doodads span the full 0.5/0.85/1.0/1.874/2.5 ramp (wow-re
//! `trace-forensics-northshire-d3d` §5) — the signature of `0x672a20`'s null-node fallback, i.e. units
//! may not be on the terrain-shade chain at all. If that lands, this system keeps its GameObject and
//! ambient-word duties and loses its unit/player intensity ramp. Not pre-emptively torn out: the
//! mechanism is an open wow-re follow-up, and the shader's `min(I,1)` already confines the visible
//! error to a unit on MCSH-shadowed ground.
//! **SETTLED (0809), and it split this file's premise in two.** §9's chain is byte-verified up to its
//! last link — "the unit's own light node fills the unit's committed light" — and that link was
//! INFERRED. Observation contradicted it: a player + three NPCs across two of the reference's own
//! apitraces commit their sun at gain **exactly 1.0**, never 2.5 or 0.5, while the same frames' ADT
//! doodads span the full 0.5/0.85/1.0/1.874/2.5 ramp (wow-re `trace-forensics-northshire-d3d` §5).
//! wow-re settled it in `unit-mcsh-shadow-target.md`: the 2.5/0.5 **target** law really is byte-shared
//! (one 2.5 site `69e4ad`, one MCSH sampler `0x69b350`, one `0x69e280` — so §9 was right that far), but
//! **delivery** splits at `0x672a20`. With `[model+0x3c0] == 0` the null fallback commits the raw
//! day/night ambient/diffuse with no intensity multiply anywhere — hardwired ×1.0, position-independent,
//! never sampling MCSH — and a unit's node is born UNREGISTERED (`0x670db0`'s birth-register gate
//! `670fca` is skipped for the model-arg-0 spawn). Their verdict to us: *"benilla must NOT light units
//! on the 2.5/0.5 static-doodad law by default; the outdoor unit base is day/night ×1.0."*
//!
//! So this system keeps the whole chain for **GameObjects** (which consume the node/bake) and, for
//! **units and players**, keeps only its ambient-word duty: their intensity is pinned flat at the
//! day/night point ([`GroundShade::null_fallback`]). What is still ours to answer, and wow-re says so
//! explicitly, is the **registration lifecycle** — which unit states carry `[model+0x3c0]` = node and
//! so earn the 2.5/0.5 chain back (their traces show a standing Stormwind player at ×2.5 against the
//! running Northshire player's ×1.0). Until that is measured, unregistered is the modelled default,
//! which is the state their traces show for a moving unit.
//!
//! Structure mirrors the reference's one-light-node-per-object: [`GroundShade`] lives on the **net
//! entity root** (the `[obj+0xe0]` twin), sampled at the root's feet and ramped there; the resulting
@@ -57,9 +67,12 @@ const RESAMPLE_DIST_SQ: f32 = 0.25;
/// chase runs at its own 3.3333).
const AMBIENT_RAMP_PER_SEC: f32 = 2.0;
/// The shade mix `t` encoding the DAY/NIGHT intensity 1.0 — an indoor entity's target (the
/// reference's interior `[+0xf8] = 1.0`): `intensity = mix(2.5, 0.5, t)` ⇒ `t = 0.75`.
const INDOOR_T: f32 = 0.75;
/// The shade mix `t` encoding the DAY/NIGHT intensity 1.0: `intensity = mix(2.5, 0.5, t)` ⇒
/// `t = 0.75`. **Two distinct mechanisms land on this same value** and the difference matters if
/// either moves: an indoor entity's node target (the reference's interior `[+0xf8] = 1.0`, written at
/// `69e36b` behind the `[+0xc]&2` interior gate), and `0x672a20`'s null-node fallback for an
/// unregistered unit (no intensity multiply at all — see [`GroundShade::null_fallback`]).
const DAYNIGHT_T: f32 = 0.75;
/// Settled-ramp epsilon (on `t` and each ambient channel) — under half a tag/colour byte.
const RAMP_EPS: f32 = 1.0 / 640.0;
@@ -70,17 +83,17 @@ const RAMP_EPS: f32 = 1.0 / 640.0;
/// word chase (`[+0x9c]`→`[+0xf4]`) — the pair `0x69e770` steps every frame.
#[derive(Component)]
pub(crate) struct GroundShade {
/// Current shade mix (0 = intensity 2.5, 1 = 0.5; [`INDOOR_T`] = the day/night 1.0) — what the
/// Current shade mix (0 = intensity 2.5, 1 = 0.5; [`DAYNIGHT_T`] = the day/night 1.0) — what the
/// parts' tags show, and what the interior bake fold scales its diffuse word by.
t: f32,
/// Where `t` is ramping to (0/1 from the last MCSH sample outdoors; [`INDOOR_T`] indoors).
/// Where `t` is ramping to (0/1 from the last MCSH sample outdoors; [`DAYNIGHT_T`] indoors).
target: f32,
/// Root position at the last sample (the movement gate).
last_pos: Vec3,
/// Whether the first sample landed (it snaps `t = target`; a spawn never plays a ramp-in).
sampled: bool,
/// The interior verdict, published by the classifier (`crate::interior`) — indoors the MCSH
/// sample is overridden by the day/night target ([`INDOOR_T`]).
/// sample is overridden by the day/night target ([`DAYNIGHT_T`]).
pub(crate) indoor: bool,
/// Standing on an outdoor-class WMO surface (street/deck/porch — `MOGI & 0x48`), published by
/// the classifier: the MCSH verdict of the terrain BENEATH the building is overridden by the
@@ -96,6 +109,19 @@ pub(crate) struct GroundShade {
/// entry (from the scene ambient, so walking into a warm room ramps rather than pops).
pub(crate) ambient: Vec3,
pub(crate) ambient_target: Vec3,
/// This root's drawn model takes `0x672a20`'s **null-node fallback** — true for units and
/// players, false for GameObjects (0809; wow-re `unit-mcsh-shadow-target.md` §3/§4). The
/// fallback commits the raw day/night pair with no per-node multiply, so the intensity is a
/// hardwired ×1.0 that never samples MCSH and cannot vary with position: `t` pins to
/// [`DAYNIGHT_T`] outdoors instead of chasing the terrain verdict. The MCSH sample still runs
/// (it is cheap, gated on movement, and `self.target` stays live) so that the day this models
/// the registration lifecycle — the one piece wow-re handed back to us — a registered unit
/// resumes the real chase with no state to rebuild.
///
/// Set once from the entity kind at node attach; a live display-id swap cannot change the kind
/// (`entities::attach`, decision 0776 makes the same argument for `ContainmentAttach`), which is
/// why `insert_if_new` keeping the old value is correct rather than merely tolerable.
null_fallback: bool,
/// The last effective target the `WOW_INTERIOR_LOG` instrument printed (log-on-change; starts
/// off-scale so the first resolved target always prints).
logged_target: f32,
@@ -112,24 +138,38 @@ impl Default for GroundShade {
on_wmo: false,
ambient: Vec3::ZERO,
ambient_target: Vec3::ZERO,
null_fallback: false,
logged_target: -1.0,
}
}
}
impl GroundShade {
/// The node state for a freshly attached root of this entity kind — the delivery split of
/// `0x672a20` (0809): a **GameObject** consumes its light node (the 2.5/0.5 terrain-shade
/// chain); a **unit or player** is born with its node unregistered and takes the null fallback's
/// flat day/night ×1.0. See [`Self::null_fallback`].
pub(crate) fn for_kind(kind: benilla_protocol::EntityKind) -> Self {
Self {
null_fallback: !matches!(kind, benilla_protocol::EntityKind::GameObject),
..Self::default()
}
}
/// The node's current committed intensity (`[node+0xa4]`): 2.5 lit → 0.5 MCSH-shadowed, the
/// day/night 1.0 at [`INDOOR_T`] — the bake fold multiplies its diffuse word by this.
/// day/night 1.0 at [`DAYNIGHT_T`] — the bake fold multiplies its diffuse word by this.
pub(crate) fn intensity(&self) -> f32 {
2.5 - 2.0 * self.t
}
/// The intensity chase's EFFECTIVE target: indoors the day/night point overrides the MCSH
/// verdict, and on an outdoor-class WMO surface the LIT point does (`self.target` keeps the
/// raw sample, so stepping back onto terrain resumes from it).
/// verdict; a null-fallback root (unit/player) takes that same point everywhere outdoors,
/// because its commit never reads a per-node intensity at all; and on an outdoor-class WMO
/// surface the LIT point overrides (`self.target` keeps the raw sample, so a GameObject
/// stepping back onto terrain resumes from it).
fn effective_target(&self) -> f32 {
if self.indoor {
INDOOR_T
if self.indoor || self.null_fallback {
DAYNIGHT_T
} else if self.on_wmo {
0.0
} else {
@@ -168,6 +208,10 @@ impl Plugin for EntityShadePlugin {
/// Sample + ramp each shaded root, then push the byte to its parts' tags (change-gated per part).
#[allow(clippy::type_complexity)]
// A Bevy system's params are not an argument list to shorten — each is a distinct world access the
// scheduler needs by name, and the card pass below deliberately takes its own disjoint `MeshTag`
// query rather than smuggling one through a shared `ParamSet`.
#[allow(clippy::too_many_arguments)]
pub(crate) fn update_ground_shade(
time: Res<Time>,
streamer: Option<Res<TerrainStreamer>>,
@@ -181,9 +225,19 @@ pub(crate) fn update_ground_shade(
children: Query<&Children>,
// Parts are matched by carrying a `MeshTag`; interior-classified ones are skipped (their payload
// is the packed floor colour). Fading parts are NOT skipped — shade and fade own disjoint fields.
mut parts: Query<(&mut MeshTag, Option<&InteriorLit>)>,
mut parts: Query<
(&mut MeshTag, Option<&InteriorLit>),
Without<crate::billboard::BillboardCard>,
>,
// A card is a world ROOT (the facing system owns its transform), so the descendant walk below
// cannot reach one — it carries its owner instead. Disjoint from `parts` by the filter above.
mut cards: Query<(&crate::billboard::BillboardCard, &mut MeshTag)>,
// Reused across frames: every entity in a shaded root's tree → that root's shade byte, so the
// card pass is one lookup per card instead of a walk per card.
mut tree_shade: Local<bevy::platform::collections::HashMap<Entity, u8>>,
mut self_log: Local<f32>,
) {
tree_shade.clear();
let Some(streamer) = streamer else {
return;
};
@@ -274,7 +328,11 @@ pub(crate) fn update_ground_shade(
// Push to every part below the root (body submeshes are direct children; held items/helm ride
// joint entities deeper down — same full-tree walk as the self-fade). Change-gated per part on
// the byte, so a settled entity writes nothing and never re-triggers render extraction.
tree_shade.insert(root, byte);
for part in children.iter_descendants(root) {
// Recorded whether or not it is a drawable part: a card can follow a JOINT
// (`following_joint` — the swinging lamp's glow), and a joint carries no `MeshTag`.
tree_shade.insert(part, byte);
let Ok((mut tag, lit)) = parts.get_mut(part) else {
continue;
};
@@ -286,6 +344,22 @@ pub(crate) fn update_ground_shade(
}
}
}
// The cards (0788's loose end). A card belongs to the same light node as the body it hangs off —
// the reference shades every batch of an object through one node (0778) — but it is a world root,
// so the walk above skips it and it kept the lit rung while its owner dimmed. Since 0809 that only
// ever showed on a **GameObject**: a unit's node is pinned to the day/night point, so its body no
// longer drops away from a card's default in the first place.
for (card, mut tag) in &mut cards {
let Some(byte) = card
.follows()
.and_then(|owner| tree_shade.get(&owner).copied())
else {
continue; // a fixed terrain doodad's card — its shade rides the material selector
};
if shade_of(tag.0) != byte {
tag.0 = with_shade(tag.0, byte);
}
}
}
/// One linear ramp step toward a target, never past it (the binary's clamp-no-overshoot chase).
+217
View File
@@ -0,0 +1,217 @@
//! The per-instance body **TINT** — a unit's whole model multiplied by one colour (decision 0812):
//! the render channel for the aura state kit's CharProc 1 ([`crate::aura_visual`]), which had the
//! nodes modelled and nowhere to put them.
//!
//! **What it is in the reference.** A CM2 model instance carries two per-instance colour slots
//! beside its fade alpha (`model+0x180`): a **modulate** tint at `model+0x184/188/18c` (setter
//! `0x710cf0`, default `1,1,1`) and an **additive** highlight emissive at `+0x190/194/198`
//! (`0x710d40`, default `0,0,0`). benilla already carries the additive one — that is
//! [`crate::mesh_tag::HIGHLIGHT_BIT`], the hover/target brighten — because its colour is a single
//! config constant and fits in a flag bit. This module is its modulate sibling, which needs a real
//! per-instance RGB.
//!
//! **Where it lands in the draw (VERIFIED, wow-re `models/models.md`).** The animate kernel
//! `0x714260` derives `[ebx+0x1a0..0x1a8] = tint × inputColorA` per frame (per-channel `fmul` at
//! `0x7142cc`/`0x7142de`/`0x7142ed`); the batcher `0x70c3a9`–`0x70c4d3` clamps it to [0,1],
//! byte-packs `0xAARRGGBB` and issues gx **SetState(1, modulatedColor)** at `0x70c8ad`, which drives
//! the device-init `GL_COLOR_MATERIAL` / `GL_AMBIENT_AND_DIFFUSE` current-colour path. So the tint
//! is the **material ambient+diffuse colour**: it multiplies the light sum *inside* the [0,1] clamp
//! and NOT the emission terms — structurally identical to what a WMO batch's MOCV already does in
//! `wow_model.wgsl`, which is where the shader multiplies it. With lighting off (an UNLIT batch) the
//! same state is a plain `glColor` modulate on the texture, so the fullbright path takes it too —
//! unlike the highlight, whose GL_EMISSION is dead there.
//!
//! (wow-re's `ghost-death-visuals.md` §4 still flags this combine as its one INFERRED hop and asks
//! for a models-node follow-up; `models.md` is that follow-up, and it is VERIFIED — two independent
//! traces plus gx's applicator-arm enumeration. We build on the verified note.)
//!
//! **The channel: no new `MeshTag` bits.** `MeshTag` bits 19..=29 already carry the instance's rig
//! slot ([`crate::rig_palette`]) — allocated per unit, written into every skinned part's tag at
//! spawn, and preserved by every runtime tag writer by construction. The vertex stage reads it to
//! index the palette region; the fragment stage now reads the *same* slot to index this table. That
//! is what makes the tint free of the bit budget the alpha and probe payloads fight over (5 spare
//! bits outdoors, **zero** indoors): it needs none.
//!
//! The rig field is safe to read on an unskinned instance because `WOW_RIG_SKIN` is keyed on the
//! mesh's vertex layout (`WowModelExt::specialize` tests `ATTRIBUTE_WOW_JOINT_INDEX`), never on the
//! tag — so a static mesh carrying a nonzero slot is not skinned by it, and the field is a plain
//! per-instance index. Slot **0** is the world's no-rig sentinel (terrain, WMO, doodads, clutter,
//! every unskinned part), so it is never written and always reads identity.
//!
//! **`0` means identity, and that is the client's own packing.** `0x60d840` builds the node value as
//! `round(param) | 0xff000000` (`0x60d8cc`) — an authored colour always carries a full alpha byte,
//! so a `0` word cannot be a real tint. A zeroed region is therefore a no-op by construction: the
//! portrait booths' studio buffers, slot 0, and every frame before anything is tinted all cost
//! nothing and change no pixel. The one shipped kit that authors a **black** body tint (spell 27200
//! Defile, kit 6531, `param0 = 0`) survives it, because we store `0xFF000000` for it, not `0`.
//!
//! **Not eased.** The reference applies the tint head node on *change* (`unit+0xd04`
//! change-detection → `×1/255` → `0x710cf0`), with none of the 1000 ms cubic ramp its sibling alpha
//! gets (`0x614f80`). A tint appears and disappears instantly, and that is faithful.
use std::sync::Arc;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::renderer::RenderQueue;
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::mesh_tag::MAX_RIG_SLOTS;
/// Bytes per slot in the tint region: one packed `0xFFRRGGBB` word.
const SLOT_BYTES: u64 = 4;
/// The "no tint" word — see the module doc. Zero is identity, so a zeroed buffer is inert.
pub(crate) const IDENTITY: u32 = 0;
/// Byte offset of the instance-tint region inside a `wow_light`-layout buffer: between the rig slot
/// table and the palette rows, mirroring `wow_model.wgsl`'s struct order (the palette array is
/// runtime-sized, so it has to stay last).
pub(crate) fn region_offset() -> u64 {
crate::rig_palette::rig_table_region_offset() + MAX_RIG_SLOTS as u64 * SLOT_BYTES
}
/// Bytes this region adds to every `wow_light`-layout buffer (8 KB at 2048 slots).
pub(crate) fn region_bytes() -> u64 {
MAX_RIG_SLOTS as u64 * SLOT_BYTES
}
/// Pack an RGB triple the way the reference does (`0x60d840`: `param | 0xff000000`) — so the word is
/// never [`IDENTITY`], even for an authored black.
pub(crate) fn pack(rgb: [u8; 3]) -> u32 {
0xff00_0000 | (u32::from(rgb[0]) << 16) | (u32::from(rgb[1]) << 8) | u32::from(rgb[2])
}
/// The live per-slot tint table, indexed by the instance's `MeshTag` rig slot. `Arc`-shared so the
/// render-world extract is a pointer bump, and generation-stamped so a world with nothing tinted
/// uploads nothing at all.
#[derive(Resource, Clone, ExtractResource)]
pub(crate) struct InstanceTints {
slots: Arc<Vec<u32>>,
generation: u64,
}
impl Default for InstanceTints {
fn default() -> Self {
Self {
slots: Arc::new(vec![IDENTITY; MAX_RIG_SLOTS]),
generation: 0,
}
}
}
impl InstanceTints {
/// Set slot `slot`'s tint word (already packed — [`pack`], or [`IDENTITY`] to clear). Slot 0 is
/// the world's no-rig sentinel and is never written: everything unskinned shares it, so a write
/// there would tint terrain, doodads and WMO surfaces at once.
pub(crate) fn set(&mut self, slot: u16, word: u32) {
let i = slot as usize;
if i == 0 || i >= MAX_RIG_SLOTS || self.slots[i] == word {
return;
}
Arc::make_mut(&mut self.slots)[i] = word;
self.generation += 1;
}
/// Clear a slot back to identity — called on the aura's reap edge AND from `RigSkin`'s free
/// hook, so a slot can never carry a dead unit's tint into the next unit that allocates it.
pub(crate) fn clear(&mut self, slot: u16) {
self.set(slot, IDENTITY);
}
/// This slot's current word (`IDENTITY` when untinted, or for an out-of-range slot). Test-only:
/// the render path reads the uploaded region, never this.
#[cfg(test)]
pub(crate) fn get(&self, slot: u16) -> u32 {
self.slots.get(slot as usize).copied().unwrap_or(IDENTITY)
}
}
/// Render world (`PrepareResources`): write the whole 8 KB region when anything changed. Gated on
/// the generation, so a world with no tinted unit — the overwhelmingly common case — never touches
/// the queue. Whole-region writes rather than per-slot ranges: the table is smaller than one
/// palette rig's rows, and aura edges are rare (a hundredth the traffic of the pose upload beside
/// it, which is the one that needed range coalescing).
fn upload_instance_tints(
queue: Res<RenderQueue>,
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
tints: Option<Res<InstanceTints>>,
mut last: Local<Option<u64>>,
) {
let (Some(shared), Some(tints)) = (shared, tints) else {
return;
};
if *last == Some(tints.generation) {
return;
}
*last = Some(tints.generation);
queue.write_buffer(
&shared.0,
region_offset(),
bytemuck::cast_slice(tints.slots.as_slice()),
);
}
pub(crate) fn plugin(app: &mut App) {
app.init_resource::<InstanceTints>()
.add_plugins(ExtractResourcePlugin::<InstanceTints>::default());
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(
Render,
upload_instance_tints.in_set(RenderSystems::PrepareResources),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The reference's own packing: the full alpha byte rides every authored colour, which is what
/// keeps `0` free as the identity sentinel — including for the one kit that authors black.
#[test]
fn packing_matches_the_reference_and_never_collides_with_identity() {
// The ghost aura's node value, byte-for-byte (wow-re: `round(9222653) | 0xff000000`).
assert_eq!(pack([0x8c, 0xb9, 0xfd]), 0xff8c_b9fd);
// Spell 27200 Defile authors param0 = 0 — a real black body tint, not an absent one.
assert_eq!(pack([0, 0, 0]), 0xff00_0000);
assert_ne!(pack([0, 0, 0]), IDENTITY);
}
/// Slot 0 is shared by every unskinned instance in the world (terrain, WMO, doodads, clutter):
/// writing a tint there would colour the whole scene, so the setter refuses it.
#[test]
fn slot_zero_is_never_written() {
let mut t = InstanceTints::default();
t.set(0, pack([255, 0, 0]));
assert_eq!(t.get(0), IDENTITY);
assert_eq!(t.generation, 0, "and it costs no upload");
}
/// A world with nothing tinted never bumps the generation, so it never uploads; a real change
/// bumps once, and a redundant re-write of the same colour does not.
#[test]
fn the_generation_tracks_real_changes_only() {
let mut t = InstanceTints::default();
assert_eq!(t.generation, 0);
t.set(7, pack([0x8c, 0xb9, 0xfd]));
assert_eq!(t.generation, 1);
t.set(7, pack([0x8c, 0xb9, 0xfd]));
assert_eq!(t.generation, 1, "same colour, no upload");
t.clear(7);
assert_eq!(t.generation, 2);
assert_eq!(t.get(7), IDENTITY);
t.clear(7);
assert_eq!(t.generation, 2, "already clear");
}
/// An out-of-range slot is dropped rather than panicking — the 11-bit tag field cannot express
/// one, but the setter takes a `u16` and must not trust it.
#[test]
fn an_out_of_range_slot_is_dropped() {
let mut t = InstanceTints::default();
t.set(u16::MAX, pack([1, 2, 3]));
assert_eq!(t.generation, 0);
assert_eq!(t.get(u16::MAX), IDENTITY);
}
}
+15 -36
View File
@@ -69,7 +69,7 @@ pub(crate) const LIGHT_HEADER_ROWS: usize = 21;
/// from the (ambient, diffuse, sun_dir) triple: rows 0-2 (ambient/diffuse/sun, w = enables), the
/// SH block (rows 6-11 + row 12 `.xyz`; DC = ambient in the `.w` lanes), and the sun's SH DC
/// redistribution (row 17 `.yzw`, at intensity 1). Leaves every other row — and row 12 `.w`
/// (point gain) / row 17 `.x` (SIDN) — untouched.
/// (free) / row 17 `.x` (SIDN) — untouched.
///
/// The sun's bands are the `Model2.bls` closed form — the SAME [`sh::prop_probe_coeffs`] fold the
/// interior lane runs, per the disassembly of the shipped ARB program (wow-re
@@ -81,13 +81,13 @@ pub(crate) const LIGHT_HEADER_ROWS: usize = 21;
/// never goes meaningfully negative — the old trace-fit's ~¼-strength lobe with a negative back
/// side (shadow-side characters turned blue as the warm channels floored at 0) is superseded.
///
/// **The SH block (rows 6-11, row 12 `.xyz`, row 17 `.yzw`) is read only under the response A/B**
/// ([`m2_sh_response`], `WOW_M2_SH=1` → row 12 `.w`). 0410 took the exterior M2 lane off this curve
/// onto the hard-cutoff FFP matte on the director's look call and nothing consumed the rows for
/// months; 0796 refuted the fidelity premise behind that retirement (the reference's M2 lane IS
/// this SH shader), which reopens the call and puts the eval back beside the cutoff so the two can
/// be seen. The interior-prop and glue-rig lanes are unaffected — they fold their own probes
/// through the per-instance `prop_probes` table, not these rows.
/// **The SH block (rows 6-11, row 12 `.xyz`, row 17 `.yzw`) is the live exterior M2 response**
/// (0803). It was dormant for months — 0410 took the lane off this curve onto a hard-cutoff FFP
/// matte on the director's look call and nothing consumed the rows — until 0796 refuted the fidelity
/// premise behind that retirement (the reference's M2 lane IS this SH shader) and 0799 put the two
/// side by side for the call. Anything that stops writing these rows now renders every exterior
/// doodad and creature black. The interior-prop and glue-rig lanes are unaffected — they fold their
/// own probes through the per-instance `prop_probes` table, not these rows.
///
/// This is the ONE packer for the scene light ([`build_light_data`]) AND the portrait booth's
/// studio light (`portrait::setup_booths`): the booth used to hand-copy the layout and rendered
@@ -113,7 +113,7 @@ pub(crate) fn pack_model_core_rows(
rows[6][3] = ambient[0]; // the DC lanes carry ambient alone
rows[7][3] = ambient[1];
rows[8][3] = ambient[2];
rows[12][0] = sun[6].x; // 12 sh_c16 xyz — .w belongs to the point-light gain dial
rows[12][0] = sun[6].x; // 12 sh_c16 xyz — .w is a free lane (see the struct comment)
rows[12][1] = sun[6].y;
rows[12][2] = sun[6].z;
// 17 `.yzw` — the sun's SH DC redistribution at intensity 1 (`D·(4/17)(0.375+0.9375(uₓ²+u_y²))`
@@ -142,19 +142,6 @@ pub(crate) fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
rgb.map(|c| c.max(0.0))
}
/// `WOW_M2_SH=1` ⇒ the exterior M2 doodad/entity lane evaluates the reference's own order-2
/// `Model2.bls` response instead of the shipped hard-cutoff FFP matte. The **one open director call**
/// on this lane (0796 §5): the reference genuinely runs the SH lobe, and 0410 replaced it with the
/// cutoff on a look call made when both lanes were believed reference-real — only one is.
///
/// Read once (the process can't change its mind mid-run) and published to the shaders in row 12 `.w`.
/// Deliberately NOT a resource or a debug-menu toggle: it is a short-lived instrument for one A/B,
/// and the cheapest thing to delete when the call lands.
fn m2_sh_response() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| matches!(std::env::var("WOW_M2_SH").as_deref(), Ok("1")))
}
/// Capacity of the packed point-light table (fixed-size in the WGSL mirror structs — keep in sync).
/// 256 lights × 2 rows × 16 B = 8 KB — generous for the densest streamed village/city interior set;
/// [`build_light_data`] packs the nearest-to-camera first when over capacity.
@@ -299,14 +286,6 @@ fn build_light_data(
// Rows 0-2, the SH block 6-12.xyz, and the sun DC (17.yzw) — the shared model-light core
// (also the portrait booth's packer). Row 20 (point_count) is the point-table pack's below.
pack_model_core_rows(rows, l.ambient, l.diffuse, l.sun_dir);
// 12 `.w` — the exterior M2 RESPONSE A/B (0796 §5), the one open director call on this lane.
// 0 (default) = the shipped hard-cutoff FFP matte, 0410's look call. 1 = the reference's own
// response, the order-2 `Model2.bls` lobe over the rows packed just above. Not a calibration
// dial (0750/0751's kind, rightly retired) — it is a two-way switch between two fully specified
// laws, and it exists because §5 is a look question that only the director can answer and they
// cannot answer it without seeing both. It goes the moment they call it: whichever side loses,
// this lane and the loser's code come out together.
rows[12][3] = if m2_sh_response() { 1.0 } else { 0.0 };
// The dynamic point-light table (decision 0278): every spawned point light within
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
@@ -441,7 +420,7 @@ fn upload_light(
mod tests {
use super::*;
/// GOLDEN — the exterior M2 **response A/B** (0796 §5): the SH branch of `wow_model.wgsl` must
/// GOLDEN — the **live exterior M2 response** (0803): `wow_model.wgsl`'s doodad/entity lane must
/// reproduce `E = A + I·D·(4/17)(0.375 + 2μ + 1.875μ²)` off the rows [`pack_model_core_rows`]
/// writes, with `A` NOT scaling by the per-instance intensity and every sun band scaling by it
/// exactly once (never I²).
@@ -449,9 +428,9 @@ mod tests {
/// This exists because the capture harness cannot check it. `visual.sh`-style captures are
/// bit-deterministic on static scenes (canal-noon: MAE 0.000) but NOT on the entity/GameObject
/// scenarios this lane owns — measured run-to-run at MAE 5.1 (chest-shade-rear) and 8.8
/// (creature-sun-rear), a noise floor far above the ~0.24 signal the A/B produces. So the lane's
/// correctness is pinned HERE, deterministically, and the captures are only good for "it
/// compiles and it moves pixels".
/// (creature-sun-rear), a noise floor far above the ~0.24 signal the response change produces
/// (0799 §2). So the lane's correctness is pinned HERE, deterministically, and the captures are
/// only good for "it compiles and it moves pixels".
///
/// `eval_sh_lane` mirrors the WGSL lane-for-lane on purpose — read the two side by side; a
/// channel or row swap in the shader is caught by eye against this, not by this test.
@@ -506,8 +485,8 @@ mod tests {
}
}
// The peak is calibrated to the FFP peak by construction (the 16/17 accumulate scale) — so
// the A/B changes NOTHING on a surface square to the sun, and the whole visible difference
// lives on the shadow side. This is why the switch reads subtle rather than dramatic.
// moving onto this curve changed NOTHING on a surface square to the sun, and the whole
// visible difference lives on the shadow side. That is why 0803 read subtle, not dramatic.
let peak = eval_sh_lane(&rows, u, 1.0);
for ch in 0..3 {
let ffp_peak = ambient[ch] + diffuse[ch]; // ambient + D·max(N·L,0) at N·L = 1
+5
View File
@@ -25,6 +25,7 @@ mod area_trigger;
mod art_scope;
mod asset_churn;
mod assets;
mod aura_visual;
mod bgwin;
mod billboard;
mod blob_shadow;
@@ -56,6 +57,7 @@ mod go_anim;
mod go_templates;
mod ground_fx;
mod hover_log;
mod instance_tint;
mod interact;
mod interior;
mod items;
@@ -456,6 +458,9 @@ fn main() -> AppExit {
// The owned skin palette (decision 0720): every skinned rig's joint matrices, computed by
// us and skinned in wow_model.wgsl — Bevy's SkinnedMesh lane is fully replaced.
.add_plugins(rig_palette::plugin)
// The per-instance body tint (decision 0812), on the same slot index as that palette: the aura
// state kit's CharProc-1 colour, uploaded to its own region of the shared light buffer.
.add_plugins(instance_tint::plugin)
.add_plugins(BowstringPlugin)
.add_plugins(QuestMarkersPlugin)
// Frame-time HUD + diagnostics — the performance standard.
+11 -1
View File
@@ -195,6 +195,16 @@ pub(crate) fn with_shade(tag: u32, shade: u8) -> u32 {
(tag & !(ALPHA_MASK | SHADE_MASK)) | carried_alpha(tag) | (u32::from(shade) << SHADE_SHIFT)
}
/// Read back the alpha field of a tag as a fraction — the decoder twin of [`alpha_bits`], for a
/// writer's change gate and for the tests that assert what actually reached an instance. A whole
/// payload of `0` is the shader's *untagged ⇒ opaque* sentinel, so it reads `1.0`.
pub(crate) fn alpha_of(tag: u32) -> f32 {
if tag == 0 {
return 1.0;
}
(tag & ALPHA_MASK) as f32 / ALPHA_MAX
}
/// Read back the shade byte of an exterior-payload tag (the shade writer's change gate).
pub(crate) fn shade_of(tag: u32) -> u8 {
((tag & SHADE_MASK) >> SHADE_SHIFT) as u8
@@ -222,7 +232,7 @@ pub(crate) fn describe(tag: u32) -> String {
};
format!(
"{tag:#010x}{flags} α {:.3} shade {} / slot {} rig {}",
(tag & ALPHA_MASK) as f32 / ALPHA_MAX,
alpha_of(tag),
shade_of(tag),
(tag & PROBE_MASK) >> PROBE_SHIFT,
rig_of(tag),
+7 -6
View File
@@ -74,13 +74,14 @@ pub(crate) struct MatKey {
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum ShadeSel {
/// Lit ground, the boosted intensity family (the binary's 2.5): ADT map doodads on unshadowed
/// ground, and every entity M2 (§9: units carry the same 2.5/0.5 chain as doodads).
/// ground, and every entity M2.
///
/// The entity half is **contradicted by observation** — a player + three NPCs across two
/// reference apitraces all commit at gain exactly 1.0 while the same frames' doodads span
/// 0.5..2.5 (decision 0796 §4). Left as-is deliberately: the shader's `min(I,1)` already lands
/// a lit-ground unit on 1.0, so only a unit on MCSH-shadowed ground renders wrong, and §9's
/// contradicted link is the INFERRED one wow-re still has open.
/// Entities still select this, but what it *means* for them changed in 0809. The selector is
/// only the static half — the per-instance `MeshTag` shade byte carries the rest, and
/// [`crate::entity_shade`] now pins that byte to the day/night ×1.0 for units and players
/// (`0x672a20`'s null-node fallback commits with no per-node intensity multiply at all), while
/// GameObjects keep the real 2.5/0.5 chase. So for an entity this variant reads "on the light-node
/// path"; the node decides the amplitude.
Lit,
/// Lit ground, intensity 1.0: an exterior WMO MODD prop — §8b, byte-verified never to reach
/// the 2.5 site (a Stormwind street fountain is NOT brightened like an Elwynn tree).
+7 -1
View File
@@ -690,7 +690,13 @@ impl Plugin for ParticlePlugin {
.chain()
.in_set(crate::billboard::BillboardPlace)
.after(crate::billboard::billboard_joint_palette)
.after(crate::creature_anim::finalize_rig_worlds),
.after(crate::creature_anim::finalize_rig_worlds)
// …and after the CARD facing pass, for the same reason one step further out:
// an equipped item's emitter rides a mesh-less billboard *frame* card (its
// bone chain reaches a billboard bone and an item model has no rig to carry
// the palette replacement — decision 0813), so that card's transform has to be
// this frame's before births read it.
.after(crate::billboard::face_billboards),
);
}
}
+11 -5
View File
@@ -510,7 +510,7 @@ pub(super) fn seat_camera(
#[allow(clippy::type_complexity)]
pub(crate) fn apply_self_model_fade(
rig: Res<CameraControl>,
self_player: Query<Entity, With<SelfPlayer>>,
self_player: Query<(Entity, Option<&crate::aura_visual::AuraNodes>), With<SelfPlayer>>,
children_of: Query<&Children>,
mut parts: Query<
(
@@ -541,22 +541,29 @@ pub(crate) fn apply_self_model_fade(
// Steady opaque: nothing to author and nothing to release.
return;
}
let Ok(root) = self_player.single() else {
let Ok((root, aura)) = self_player.single() else {
*was_fading = false;
return;
};
// Our own live aura translucency (stealth, invisibility, ghost — `crate::aura_visual`) is a
// FACTOR of this fade, not a rival author: this system runs last on the self body and writes the
// alpha field verbatim, so a zoom-in while stealthed must carry the aura's term or the feather
// would silently re-opaque the character to 1.0 × the camera ramp. Folding it in here also makes
// the release edge honest — it releases at the *product*, so a fade ending while still stealthed
// hands the material back only if the body is genuinely opaque again.
let feather = rig.self_fade_alpha * crate::aura_visual::root_alpha(aura);
// The walked set doubles as "which anchors belong to this model" for the card pass — built only
// while fading, so the steady state (the early return above) never pays for it.
let mut walked = EntityHashSet::default();
apply_self_fade_to_descendants(
root,
rig.self_fade_alpha,
feather,
&children_of,
&mut parts,
&mut reauthor,
&mut walked,
);
let alpha = rig.self_fade_alpha.clamp(0.0, 1.0);
let alpha = feather.clamp(0.0, 1.0);
for (card, mut tag, anim) in &mut cards {
if !card
.follows()
@@ -747,7 +754,6 @@ mod tests {
let info = BillboardInfo {
bone: 0,
pivot: Vec3::new(0.0, 2.14, 0.0), // the eye-glow bone, head height
normal: Vec3::Z,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
+159 -6
View File
@@ -14,8 +14,27 @@ pub(super) struct BoothPart {
pub(super) skinned: Option<Handle<Mesh>>,
pub(super) static_mesh: Handle<Mesh>,
pub(super) material: Handle<WowModelMaterial>,
/// The batch's **animated material alpha** (`ModelSubmesh::alpha_anim`) — a time-varying
/// colour-alpha/weight loop or, far more often here, a plain authored dimming *constant*. Every
/// world lane has sampled this into the render-alpha `MeshTag` since decision 0130 phase 2; the
/// booths never did, so a booth batch drew at alpha 1.0 whatever the artist authored (decision
/// 0807 — B121's black corners were UI_Tauren's 0.55 `LENSALPHA` vignette at full strength).
/// `None` for the overwhelming majority of batches, which are opaque.
pub(super) alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
}
/// Marks a booth part whose render-alpha `MeshTag` is driven by its own
/// [`MatAnim`](crate::doodad_anim::MatAnim) sample — the booth twin of the world lane's writer.
///
/// The world path's writer is the *visibility authority* (`debug_panel::apply_model_visibility`),
/// which is scoped to `ModelPart` + `GlobalTransform` and culls by distance to the **world** camera.
/// A booth part must never enter that query — it would be far-clipped against a camera it has
/// nothing to do with — so the booth owns this one small writer instead
/// ([`push_booth_mat_alpha`]). Marker-scoped rather than inferred from "has no `ModelPart`", so the
/// two lanes cannot silently overlap.
#[derive(Component)]
pub(super) struct BoothMatAlpha;
/// One bone rider headed into a booth bake ([`PortraitRider`], studio-lit).
pub(super) struct BoothRider {
pub(super) mesh: Handle<Mesh>,
@@ -104,13 +123,23 @@ pub(super) fn spawn_booth_model(
)>();
let Some((skeleton, ibp, anims)) = rig.filter(|(s, _, _)| !s.joints.is_empty()) else {
for p in parts {
commands.spawn((
let mut child = commands.spawn((
Mesh3d(p.static_mesh.clone()),
MeshMaterial3d(p.material.clone()),
Transform::IDENTITY,
layer.clone(),
ChildOf(root),
));
// The authored material alpha reaches the boneless bake too — it is a property of the
// batch, not of the rig.
if let Some(anim) = &p.alpha_anim {
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None);
child.insert((
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(mat_anim.current)),
mat_anim,
BoothMatAlpha,
));
}
}
return Vec::new();
};
@@ -182,13 +211,30 @@ pub(super) fn spawn_booth_model(
layer.clone(),
ChildOf(root),
));
if use_rig {
// The batch's authored material alpha, sampled onto the render-alpha tag field — the same
// `MatAnim` the world lanes use, in its self-driving form (`drives_tag`: nothing else writes
// a booth part's alpha). The rig field rides the same tag, so compose rather than overwrite;
// `alpha_bits` floors a true zero at ≈0 so the shader's whole-payload-`0` *untagged ⇒
// opaque* sentinel can't fire on a batch the artist authored invisible.
let rig_tag = if use_rig {
crate::mesh_tag::rig_bits(rig_slot)
} else {
0
};
if let Some(anim) = &p.alpha_anim {
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None);
child.insert((
crate::rig_palette::RigPart(root),
bevy::mesh::MeshTag(
crate::mesh_tag::rig_bits(rig_slot) | crate::mesh_tag::alpha_bits(1.0),
),
bevy::mesh::MeshTag(rig_tag | crate::mesh_tag::alpha_bits(mat_anim.current)),
mat_anim,
BoothMatAlpha,
));
} else if use_rig {
child.insert(bevy::mesh::MeshTag(
rig_tag | crate::mesh_tag::alpha_bits(1.0),
));
}
if use_rig {
child.insert(crate::rig_palette::RigPart(root));
}
}
for r in riders {
@@ -291,3 +337,110 @@ pub(super) fn face_booth_billboards(
tf.rotation = joint.rotation().inverse() * basis;
}
}
/// Push each booth part's sampled material alpha onto its render-alpha `MeshTag` field — the booth's
/// own tiny twin of the world visibility authority's write (see [`BoothMatAlpha`] for why the world
/// one can't serve here).
///
/// `doodad_anim::sample_mat_anim` already ticks **every** `MatAnim` in the world, booth parts
/// included, so this only moves the sampled value onto the channel: `with_alpha` writes the alpha
/// field alone, so the rig slot (and the probe slot the rig lane reads from the same tag) ride
/// through untouched. Write-on-change, so the overwhelmingly common case — an authored *constant*
/// like UI_Tauren's 0.55 vignette — costs one compare per part per frame and never re-batches.
pub(super) fn push_booth_mat_alpha(
mut parts: Query<(&crate::doodad_anim::MatAnim, &mut bevy::mesh::MeshTag), With<BoothMatAlpha>>,
) {
for (anim, mut tag) in &mut parts {
let bits = crate::mesh_tag::with_alpha(tag.0, anim.current);
if tag.0 != bits {
tag.0 = bits;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doodad_anim::MatAnim;
/// A constant authored alpha, the shape every glue scene actually uses (UI_Tauren's vignette is
/// `0.55..0.55`, its ground shadow `0.99..0.99`).
fn constant_alpha(v: f32) -> std::sync::Arc<benilla_formats::AlphaAnim> {
std::sync::Arc::new(
benilla_formats::AlphaAnim::new(vec![benilla_formats::AlphaSeq {
color: None,
weight: Some(benilla_formats::ScalarAnim {
period: 0.0,
step: true,
keys: vec![(0.0, v)],
}),
}])
.expect("a dimming constant is worth carrying"),
)
}
/// The writer moves the sampled alpha onto the tag **and leaves the rig slot alone** — the whole
/// reason it goes through `with_alpha` rather than assigning the field. A booth part is skinned,
/// so a writer that clobbered bits 19..=29 would silently unbind its palette.
#[test]
fn the_booth_alpha_writer_preserves_the_rig_slot() {
let mut app = App::new();
app.add_systems(Update, push_booth_mat_alpha);
let rig_slot = 7u16;
let anim = MatAnim::driving_tag(constant_alpha(0.55), 0.0, None);
let part = app
.world_mut()
.spawn((
bevy::mesh::MeshTag(
crate::mesh_tag::rig_bits(rig_slot) | crate::mesh_tag::alpha_bits(1.0),
),
anim,
BoothMatAlpha,
))
.id();
app.update();
let tag = app
.world()
.entity(part)
.get::<bevy::mesh::MeshTag>()
.unwrap();
assert_eq!(
crate::mesh_tag::rig_of(tag.0),
rig_slot,
"the palette slot must survive an alpha write"
);
let alpha = crate::mesh_tag::alpha_of(tag.0);
assert!(
(alpha - 0.55).abs() <= 1.0 / 63.0,
"authored 0.55 reached the tag (got {alpha})"
);
}
/// An unmarked part is not ours to write: the world lane's parts carry `MatAnim` too, and their
/// alpha is composed by the visibility authority against the fade and the interior classifier.
#[test]
fn the_booth_alpha_writer_ignores_unmarked_parts() {
let mut app = App::new();
app.add_systems(Update, push_booth_mat_alpha);
let part = app
.world_mut()
.spawn((
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(1.0)),
MatAnim::driving_tag(constant_alpha(0.1), 0.0, None),
))
.id();
app.update();
assert_eq!(
crate::mesh_tag::alpha_of(
app.world()
.entity(part)
.get::<bevy::mesh::MeshTag>()
.unwrap()
.0
),
1.0,
"no BoothMatAlpha marker ⇒ untouched"
);
}
}
+1 -1
View File
@@ -124,7 +124,7 @@ fn bind_bone_global(skeleton: &benilla_assets::ModelSkeleton, bone: u16) -> Opti
/// A model attachment's **bind-pose position** in Bevy model space (its bone's pivot + the
/// bind-relative offset) — the glue background scenes anchor the create character on their
/// attachment 0 this way (the stage spot sitting on camera 0's axis in every UI_* scene audited).
pub(super) fn attachment_point(
pub(crate) fn attachment_point(
skeleton: &benilla_assets::ModelSkeleton,
attachments: &[benilla_assets::ModelAttachment],
id: u16,
+76 -1
View File
@@ -306,6 +306,20 @@ pub(crate) struct PreviewBillboard {
pub(crate) kind: benilla_formats::BillboardKind,
}
/// One **item glow** on the assembled preview (decision 0805): the `Spells\Enchantments\*.mdx`
/// effect a held weapon's `ItemVisuals` id hangs on the weapon's own attachment point. Carried as
/// the glow model's particle emitters — all but three of the 35 shipped glow models are pure
/// emitters — plus the composed seat: the BODY bone the weapon rides, and the offset of the
/// weapon's own attachment point expressed in that bone's frame. The booth spawns one host there
/// and owns the emitters off it, exactly as [`sync_glue_scene`] does for a backdrop's braziers; a
/// glow's rare geometry rides the ordinary [`PreviewRider`] list at the same seat.
#[derive(Clone)]
pub(crate) struct PreviewGlow {
pub(crate) bone: u16,
pub(crate) offset: Vec3,
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
}
/// The entities-side builder's output (decisions 0423 + 0465): the geoset-filtered, composited
/// parts for the current look (+ its equipment riders for a Select look), plus the body displayId
/// (for the booth's framing/rig) and a revision that bumps on every real change — a new successful
@@ -317,6 +331,8 @@ pub(crate) struct GluePreviewBake {
pub(crate) display_id: u32,
pub(crate) parts: Vec<PreviewPart>,
pub(crate) riders: Vec<PreviewRider>,
/// The held items' glow effects (decision 0805) — see [`PreviewGlow`].
pub(crate) glows: Vec<PreviewGlow>,
/// The look's character billboard batches (the eye-glow) — geoset-gated like `parts`, but seated
/// on their billboard bone's joint and camera-faced by the booth, not drawn as plain body meshes.
pub(crate) billboards: Vec<PreviewBillboard>,
@@ -585,6 +601,11 @@ pub(super) fn sync_glue_scene(
skinned: Some(s.skinned_mesh.clone()),
static_mesh: s.mesh.clone(),
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
// `CLOUDS`, a 0.73 gradient. Drawn at 1.0 the vignette blacked out the frame
// corners — B121.
alpha_anim: s.alpha_anim.clone(),
}
})
.collect();
@@ -787,6 +808,11 @@ pub(super) fn sync_glue_booth(
skinned: p.skinned_mesh.clone(),
static_mesh: p.static_mesh.clone(),
material: relight(&p.material, &mut scene, &mut booth_light, &mut materials),
// `None`, and a KNOWN GAP (decision 0807): a mirrored `PreviewPart` doesn't carry
// the batch's alpha loops, so a character batch with an authored dimming constant
// previews at 1.0 here. Closing it means threading `alpha_anim` through the
// appearance-assembly layer — deliberately not folded into the B121 fix.
alpha_anim: None,
});
}
// The equipment riders (a Select look — helm/shoulders/sheathed weapons, decision 0465):
@@ -814,7 +840,7 @@ pub(super) fn sync_glue_booth(
})
.collect();
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
let joints = spawn_booth_model(
&mut commands,
&mut palettes,
booth.root,
@@ -829,6 +855,55 @@ pub(super) fn sync_glue_booth(
bake.grip, // close each hand that draws a weapon (the ref's paperdoll rule)
&booth_billboards,
);
// The held items' glows (decision 0805): one host per glow at its weapon's attachment
// point, with the effect model's emitters owned by it — the scene-brazier recipe (0539
// §5), so they billboard against THIS camera and fog on the scene's own light. Children
// of the booth root through their joint, so the next re-bake's `despawn_related` reaps
// them. The reference glows a weapon at character select through the very same attach
// primitive it uses in the world (`0x472c91` → `0x47a0c0` → `0x4798c0`), and the enum
// carries no enchant, so what shows here is the item's intrinsic visual only.
let mut glow_emitters = 0usize;
for glow in &bake.glows {
let Some(&joint) = joints.get(glow.bone as usize) else {
continue;
};
let host = commands
.spawn((
Transform::from_translation(glow.offset),
Visibility::default(),
ChildOf(joint),
booth.layer.clone(),
))
.id();
for em in &glow.emitters {
let Some(e) = crate::particles::spawn_emitter(
&mut commands,
em,
Transform::IDENTITY,
Some((host, [0.0; 3])), // whole-model owner: a glow model poses at rest
Some(host), // an attached model — it swings with the weapon
Some(host),
crate::particles::EmitClock::Pinned, // the glow loops forever
) else {
continue;
};
commands
.entity(e)
.insert((booth.layer.clone(), ChildOf(host)));
if let Some(buf) = &scene_buf {
commands
.entity(e)
.insert(crate::particles::buffer::EffectLightOverride(buf.clone()));
}
glow_emitters += 1;
}
}
if glow_emitters > 0 {
info!(
"glue booth: {glow_emitters} item-glow emitter(s) up on {} glow instance(s)",
bake.glows.len()
);
}
// The character-only fallback framing (no scene up — art missing / still loading): the
// body-frame transform with an aspect-aware projection, because the booth target is
// window-sized now (the square-true `WowPortraitProjection` would stretch on it). While a
+35 -10
View File
@@ -77,14 +77,14 @@ use crate::target::Selection;
use crate::terrain::WowModelMaterial;
mod framing;
pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
use framing::{body_frame, frame, PORTRAIT_FOV};
pub(crate) use framing::{head_anchor, PortraitAnchors};
mod booth;
use booth::{spawn_booth_model, BoothBillboardSpec, BoothMotion, BoothPart, BoothRider};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, GlueLook, GluePreview, GluePreviewBake, GlueScene, PreviewBillboard, PreviewPart,
PreviewRider, SelectLook, GLUE_SLOT,
CreateLook, GlueLook, GluePreview, GluePreviewBake, GlueScene, PreviewBillboard, PreviewGlow,
PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
};
mod light;
use light::{material_variant, model_pane_light_rows, studio_light_rows, BoothLight};
@@ -398,6 +398,13 @@ impl Plugin for PortraitPlugin {
// Re-face each booth's eye-glow cards to its own camera (reads last-propagate joint
// globals; unordered w.r.t. the syncs — a fresh card just faces forward one frame).
.add_systems(Update, booth::face_booth_billboards)
// The booth twin of the world visibility authority's render-alpha write (decision 0807):
// push each booth part's sampled `MatAnim` onto its tag. Ordered after the shared
// sampler, the only producer of `MatAnim::current`, so it moves THIS frame's value.
.add_systems(
Update,
booth::push_booth_mat_alpha.after(crate::doodad_anim::sample_mat_anim),
)
// The phase-3 preview instrument (`WOW_CREATE_TEST`, decision 0423): inert without the env.
.add_systems(Update, glue_booth::drive_create_test)
// `WOW_BOOTH_DUMP=<token>:<path>:<secs>` — photograph a booth's render target to disk
@@ -407,8 +414,8 @@ impl Plugin for PortraitPlugin {
}
}
/// A fresh transparent render-target image (RGBA8 sRGB) of `size²`, usable as a camera target and
/// sampled by the UI. Portrait slots pass [`PORTRAIT_SIZE`]; the paper doll passes [`PAPERDOLL_SIZE`].
/// A fresh transparent render-target image of `size²`, usable as a camera target and sampled by the
/// UI. Portrait slots pass [`PORTRAIT_SIZE`]; the paper doll passes [`PAPERDOLL_SIZE`].
fn new_target_image(size: u32) -> Image {
let mut image = Image::new_fill(
Extent3d {
@@ -417,11 +424,24 @@ fn new_target_image(size: u32) -> Image {
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0, 0, 0, 0],
// NON-sRGB on purpose, measured (see the Hdr note below): with an Srgb label the composited
// portrait read one net sRGB-decode too dark vs the world render of the same model. Raw bytes
// + the swapchain's single encode lands the bake at world parity, pixel-verified.
TextureFormat::Rgba8Unorm,
&[0; 8],
// **Un-encoded on purpose, and at float precision — the two are separate choices.**
//
// Un-encoded (no `…Srgb` label) is measured: with an Srgb label the composited portrait read
// one net sRGB-decode too dark vs the world render of the same model, because the UI arc
// composites in GAMMA bytes and does its one decode at the end (`ui_gamma`, decisions
// 0254/0541) — a booth target that pre-encodes lands a second encode in that chain. So the
// target holds the booth's own un-encoded values and the UI shader encodes them.
//
// FLOAT, not `Rgba8Unorm`, is B126 (decision 0804). Quantizing *un-encoded* values to 8 bits
// is a precision collapse exactly where the eye is most sensitive: the only display levels
// reachable below display byte 100 are `srgb(k/255)` = 0, 13, 22, 28, 34, 38, … — ~25 steps
// where the 8-bit gamma backbuffer this feeds has 100. That is the reported "colors are 16
// bit instead of 32" banding on the glue screens' trees and skybox, measured on the
// histogram of a char-select capture (15 of 16 predicted ladder values hit). The pipeline's
// *semantics* are unchanged by this — same values, same single encode downstream, just not
// rounded to a 256-step linear grid on the way through.
TextureFormat::Rgba16Float,
RenderAssetUsages::default(),
);
image.texture_descriptor.usage =
@@ -788,6 +808,9 @@ fn sync_portraits(
skinned: p.skinned_mesh.clone(),
static_mesh: p.static_mesh.clone(),
material: booth_light.studio.variant(&p.material, &mut wow_mats),
// `None` — the same known gap as the glue preview's (decision 0807): a
// mirrored `PortraitPart` doesn't carry the batch's alpha loops.
alpha_anim: None,
})
.collect();
let booth_riders: Vec<BoothRider> = riders
@@ -1035,6 +1058,8 @@ fn sync_body_booth(
skinned: p.skinned_mesh.clone(),
static_mesh: p.static_mesh.clone(),
material: booth_light.pane.variant(&p.material, wow_mats),
// `None` — the same known gap as the glue preview's (decision 0807).
alpha_anim: None,
})
.collect();
let booth_riders: Vec<BoothRider> = riders
+104 -5
View File
@@ -133,6 +133,9 @@ fn bake_test(
skinned: Some(s.skinned_mesh.clone()),
static_mesh: s.mesh.clone(),
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.
alpha_anim: s.alpha_anim.clone(),
}
})
.collect::<Vec<BoothPart>>()
@@ -283,11 +286,10 @@ pub(super) fn dump_booth_target(
commands
.spawn(Screenshot::image(booth.target.clone()))
.observe(move |shot: On<ScreenshotCaptured>| {
// The booth target is deliberately non-sRGB (`new_target_image`), which bevy's stock
// `save_to_disk` refuses to convert — relabel the identical bytes as sRGB for the PNG.
let mut img = shot.image.clone();
img.texture_descriptor.format =
bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb;
let Some(img) = encode_target_readback(&shot.image) else {
warn!("WOW_BOOTH_DUMP: unexpected target format, nothing saved");
return;
};
match img.try_into_dynamic() {
Ok(dyn_img) => match dyn_img.save(&out) {
Ok(()) => info!("WOW_BOOTH_DUMP: saved {}", out.display()),
@@ -297,3 +299,100 @@ pub(super) fn dump_booth_target(
}
});
}
/// Turn a booth render-target readback into something the PNG encoder can write — and, more to the
/// point, into what the **screen** shows.
///
/// The targets are `Rgba16Float` holding **un-encoded** values (`super::new_target_image`): the
/// display encode is the UI arc's, applied when the glue/paper-doll tree samples the target
/// (`crate::ui_gamma`), and a readback bypasses that lane entirely. So it happens here. Without it
/// the PNG is ~2.2× dark — which is precisely what this instrument produced for its whole first life
/// (it relabeled the un-encoded 8-bit bytes as sRGB and saved them verbatim), and a dump that reads
/// darker than the screen is a debugging instrument that lies about the thing it exists to show.
///
/// `None` on an unexpected format, so a future target-format change is a loud warning rather than a
/// garbled PNG.
fn encode_target_readback(shot: &Image) -> Option<Image> {
use bevy::asset::RenderAssetUsages;
use bevy::render::render_resource::{TextureDimension, TextureFormat};
if shot.texture_descriptor.format != TextureFormat::Rgba16Float {
return None;
}
let src = shot.data.as_ref()?;
let mut out = Vec::with_capacity(src.len() / 2);
for texel in src.chunks_exact(8) {
for (c, half_pair) in texel.chunks_exact(2).enumerate() {
let v = half::f16::from_le_bytes([half_pair[0], half_pair[1]]).to_f32();
// The sRGB transfer function for colour (channel 3 is plain coverage, never encoded) —
// the same curve the swapchain's `…Srgb` write applies to the live frame.
let encoded = match c {
3 => v,
_ if v <= 0.003_130_8 => v * 12.92,
_ => 1.055 * v.powf(1.0 / 2.4) - 0.055,
};
out.push((encoded.clamp(0.0, 1.0) * 255.0).round() as u8);
}
}
Some(Image::new(
shot.texture_descriptor.size,
TextureDimension::D2,
out,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::asset::RenderAssetUsages;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
fn readback(format: TextureFormat, data: Vec<u8>) -> Image {
Image::new(
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
TextureDimension::D2,
data,
format,
RenderAssetUsages::default(),
)
}
/// The dump encodes colour and passes alpha through — the screen's transfer function, applied
/// where the readback bypassed the UI lane that would have applied it.
#[test]
fn the_booth_dump_encodes_colour_and_leaves_alpha_alone() {
let texel = |v: f32| half::f16::from_f32(v).to_le_bytes().to_vec();
let mut data = Vec::new();
// Mid grey, then the darkest step an 8-bit LINEAR target could hold, then opaque alpha.
data.extend(texel(0.5));
data.extend(texel(1.0 / 255.0));
data.extend(texel(0.0));
data.extend(texel(1.0));
let out = encode_target_readback(&readback(TextureFormat::Rgba16Float, data))
.expect("float readback encodes");
let bytes = out.data.as_ref().expect("encoded bytes");
// 0.5 linear is display 188 — NOT 128. That gap is the whole reason this exists: for its
// first life the instrument wrote the 128.
assert_eq!(bytes[0], 188);
// The old 8-bit linear target's second code lands on 13 — the bottom of the ladder B126
// measured, and the reason a float target has ~4× the levels down here.
assert_eq!(bytes[1], 13);
assert_eq!(bytes[2], 0);
// Alpha is coverage, never encoded.
assert_eq!(bytes[3], 255);
}
/// A format the encoder doesn't know is a loud `None`, not a garbled PNG.
#[test]
fn an_unexpected_readback_format_refuses() {
let img = readback(TextureFormat::Rgba8Unorm, vec![1, 2, 3, 4]);
assert!(encode_target_readback(&img).is_none());
}
}
+37 -9
View File
@@ -58,14 +58,19 @@ pub(crate) fn rig_table_region_offset() -> u64 {
crate::lighting::prop_probe_region_offset() + (7 * crate::lighting::MAX_PROP_PROBES * 16) as u64
}
/// Byte offset of the palette rows themselves (after the slot table).
/// Byte offset of the palette rows themselves — after the slot table AND the per-instance tint
/// table that shares this slot index ([`crate::instance_tint`], decision 0812). The rows stay last
/// because `wow_model.wgsl` declares them as the struct's one runtime-sized array.
pub(crate) fn palette_region_offset() -> u64 {
rig_table_region_offset() + (MAX_RIG_SLOTS * 4) as u64
crate::instance_tint::region_offset() + crate::instance_tint::region_bytes()
}
/// Total bytes both regions add to every `wow_light`-layout buffer (`lighting::light_blob_bytes`).
/// Total bytes the slot-indexed regions add to every `wow_light`-layout buffer
/// (`lighting::light_blob_bytes`): the rig slot table, the instance-tint table, the palette rows.
pub(crate) fn palette_regions_bytes() -> u64 {
(MAX_RIG_SLOTS * 4) as u64 + MAX_PALETTE_BONES as u64 * BONE_BYTES
(MAX_RIG_SLOTS * 4) as u64
+ crate::instance_tint::region_bytes()
+ MAX_PALETTE_BONES as u64 * BONE_BYTES
}
/// The main-world palette table: rows + slot table behind `Arc` (the extract is a pointer bump),
@@ -430,6 +435,13 @@ fn free_rig_skin(mut world: DeferredWorld, ctx: HookContext) {
.map(|r| r.slot)
.expect("on_remove runs with the component still present");
world.resource_mut::<RigPalettes>().free(slot);
// The slot indexes the per-instance TINT table too (decision 0812), and slots are recycled — so
// clear it on the same edge that frees it. Structural, not disciplinary: whatever kills a rig
// (despawn, gear/display rebuild) cannot leave a dead unit's colour behind for the next unit
// that allocates the slot, and no tinted-unit teardown path has to remember to.
if let Some(mut tints) = world.get_resource_mut::<crate::instance_tint::InstanceTints>() {
tints.clear(slot);
}
}
/// On every joint entity (planted by `entities::spawn_joints`): the rig root whose palette this
@@ -717,15 +729,31 @@ mod tests {
#[test]
fn region_layout_is_consistent() {
// The table sits after the probe region, palettes after the table, and the blob-size
// extension covers both exactly (wow_model.wgsl mirrors this layout).
// The slot table sits after the probe region, the per-instance tint table (decision 0812 —
// same slot index) after it, the palette rows last, and the blob-size extension covers all
// three exactly (wow_model.wgsl mirrors this layout, and wgpu validates the bound size
// against the shader's struct at draw time — a mismatch here is a draw-time failure, which
// is why this is asserted rather than trusted).
assert_eq!(
palette_region_offset() - rig_table_region_offset(),
(MAX_RIG_SLOTS * 4) as u64
crate::instance_tint::region_offset() - rig_table_region_offset(),
(MAX_RIG_SLOTS * 4) as u64,
"the tint table follows the slot table"
);
assert_eq!(
palette_region_offset() - crate::instance_tint::region_offset(),
crate::instance_tint::region_bytes(),
"the palette rows follow the tint table"
);
assert_eq!(
palette_regions_bytes(),
(MAX_RIG_SLOTS * 4) as u64 + (MAX_PALETTE_BONES as u64) * BONE_BYTES
(MAX_RIG_SLOTS * 4) as u64
+ crate::instance_tint::region_bytes()
+ (MAX_PALETTE_BONES as u64) * BONE_BYTES
);
// One word per addressable slot — the shader declares `array<u32, 2048>` for both tables.
assert_eq!(
crate::instance_tint::region_bytes(),
(MAX_RIG_SLOTS * 4) as u64
);
}
}
@@ -13,8 +13,9 @@ use crate::particles;
/// Bevy `PointLight` intensity (lumens) for an M2 light of unit `diffuse_intensity`. Bevy stores the GPU
/// colour as `linear_color × intensity/(4π)` (bevy_pbr light.rs), so `4π` makes the shader read exactly
/// `diffuse_color × diffuse_intensity` — a clean, predictable base. `wow_model.wgsl` then applies the
/// faithful WoW falloff `1/(0.7d+0.03d²)` (decision 0016) and the director gain (`sh_c16.w`); tune the
/// look via that gain, not here.
/// faithful WoW falloff `1/(0.7d+0.03d²)` (decision 0016). There is no gain dial any
/// more: the 0273 `sh_c16.w` gain is retired and the commit is the reference's raw product
/// (`global_light::commit_raw`).
const POINT_LIGHT_INTENSITY: f32 = 4.0 * std::f32::consts::PI;
/// The packed per-light `range` (yd) — since 0285 this is the ≤3-nearest **selection-candidacy**
/// radius around the receiving unit's anchor, not an evaluation cutoff (a committed GL light has