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:
Generated
+1
@@ -584,6 +584,7 @@ dependencies = [
|
||||
"cosmic-text",
|
||||
"crossbeam-channel",
|
||||
"fontdb",
|
||||
"half",
|
||||
"image",
|
||||
"kira",
|
||||
"libc",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user