diff --git a/Cargo.lock b/Cargo.lock index ca1c5c7f..2cd44b95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,7 @@ dependencies = [ "cosmic-text", "crossbeam-channel", "fontdb", + "half", "image", "kira", "libc", diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 40a936a6..a4df669c 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -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::(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, diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 7f50206f..97a91fef 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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 { 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 diff --git a/crates/benilla-formats/examples/batch_lit.rs b/crates/benilla-formats/examples/batch_lit.rs new file mode 100644 index 00000000..325f4eba --- /dev/null +++ b/crates/benilla-formats/examples/batch_lit.rs @@ -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 "))?; + 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("") + ); + } + println!("{unlit} of {} batches are UNLIT (fullbright)", subs.len()); + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs new file mode 100644 index 00000000..e5436095 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs @@ -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> = 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> = 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) + ); + } +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index a7ae5231..022ce291 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -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(()) diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 65459661..575fb32d 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -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, + }, /// 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(()) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 70f0a977..808bb9f0 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -247,6 +247,90 @@ fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { (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 = 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 = 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 = BTreeMap::new(); + for (i, a) in model.attachments.iter().enumerate() { + scan.entry(a.id).or_insert(i); + } + let lookup: BTreeMap = (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 = 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 = 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 = 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 = 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 = 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 = 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 = 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) ); diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs index 896f2f00..dc2976c5 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs @@ -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!( diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index aed66442..09a1b49a 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -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 { + 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 { 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), }, ); } diff --git a/crates/benilla-formats/src/item_visuals.rs b/crates/benilla-formats/src/item_visuals.rs new file mode 100644 index 00000000..d1b58e9b --- /dev/null +++ b/crates/benilla-formats/src/item_visuals.rs @@ -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; 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; 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; 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, +} + +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 { + self.visuals.get(&enchant_id).copied() + } + + /// Build from an explicit map — tests and synthetic fixtures. + pub fn from_visuals(visuals: HashMap) -> 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 + '_ { + 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 { + 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 = 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 { + 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); + } +} diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs index bec3c766..48290603 100644 --- a/crates/benilla-formats/src/items.rs +++ b/crates/benilla-formats/src/items.rs @@ -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\\` 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); diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 23797464..46ab7759 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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 { ("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", diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index da316a6d..abeacfc9 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -39,6 +39,37 @@ pub struct Skeleton { pub bones: Vec, } +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 { + 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> { 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); + } } diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual.rs index 2f879a03..40729a70 100644 --- a/crates/benilla-formats/src/spell_visual.rs +++ b/crates/benilla-formats/src/spell_visual.rs @@ -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; 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; 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 + '_ { + 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 { + 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 + '_ { + let mut ids: Vec = 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 { + 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`. fn some_unless_none(v: u32) -> Option { (v != 0 && v != NONE_SENTINEL).then_some(v) @@ -341,11 +440,7 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result Result Walk"); + assert_eq!(cat[120].resolved_id, 0, "cat StealthStand -> Stand"); +} diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs index 4f5cd57b..7ae2da5f 100644 --- a/crates/benilla-m2/src/lib.rs +++ b/crates/benilla-m2/src/lib.rs @@ -284,7 +284,10 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { // 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 { 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 { }); } + // 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 { }, pivot_attach_z, attachments: attachment_list, + attach_lookup: attach_lookup_list, event_markers, animation_lookup, playable_animation_lookup, diff --git a/crates/benilla-m2/src/model.rs b/crates/benilla-m2/src/model.rs index 1e11a1f5..f2f11510 100644 --- a/crates/benilla-m2/src/model.rs +++ b/crates/benilla-m2/src/model.rs @@ -263,9 +263,15 @@ pub struct M2Model { pub pivot_attach_z: Option, /// 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, + /// 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, /// 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). diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 7a3d2050..59409baf 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -74,6 +74,23 @@ impl ObjectFields { }) .flatten() } + /// `PLAYER_VISIBLE_ITEM__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 { + (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 { (i < 23).then(|| self.get_guid(FIELD_PLAYER_INV_SLOT_HEAD + 2 * u16::from(i)))? diff --git a/crates/benilla/Cargo.toml b/crates/benilla/Cargo.toml index c5e90863..b68dd929 100644 --- a/crates/benilla/Cargo.toml +++ b/crates/benilla/Cargo.toml @@ -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 diff --git a/crates/benilla/assets/shaders/terrain.wgsl b/crates/benilla/assets/shaders/terrain.wgsl index 7a2903db..bc7c9bfc 100644 --- a/crates/benilla/assets/shaders/terrain.wgsl +++ b/crates/benilla/assets/shaders/terrain.wgsl @@ -59,7 +59,7 @@ struct WowLight { fog_color: vec4, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend). fog_params: vec4, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall. _sh: array, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain. - sh_c16: vec4, // row 12: .w = the dynamic point-light gain (decision 0273); xyz the models' c16 band. + sh_c16: vec4, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired). _water: array, 4>, // rows 13-16: the liquid swatches — unread by terrain. grade: vec4, // reserved (the 0282 interior A/B retired; 0163). Layout only. _wmo_fog: array, 2>, // rows 18-19: the interior fog triple — unread by terrain. diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index d44c96b8..8683472b 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -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, sh_c10_g: vec4, sh_c10_b: vec4, sh_c13_r: vec4, sh_c13_g: vec4, sh_c13_b: vec4, - // 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, _water: array, 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, + // 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, palettes: array>, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var 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((tint_word >> 16u) & 0xffu), + f32((tint_word >> 8u) & 0xffu), + f32(tint_word & 0xffu), + ) * (1.0 / 255.0), + vec3(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(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(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( + let sun_lobe = vec3( 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(0.0), - vec3(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(0.0), vec3(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(highlight), + inst_tint * (lit + point_diffuse) + sidn_e + vec3(highlight), vec3(0.0), vec3(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(highlight), + inst_tint * (lit + point_diffuse) + sidn_e + vec3(highlight), vec3(0.0), vec3(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 diff --git a/crates/benilla/src/aura_visual.rs b/crates/benilla/src/aura_visual.rs new file mode 100644 index 00000000..875be05b --- /dev/null +++ b/crates/benilla/src/aura_visual.rs @@ -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, + mut probe_logged: Local, +) { + 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> = 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, + }, + /// 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 { + 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, + time: Res