From 59b4a4f4ce69b978f26099af996a3ed0552c5537 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 31 Jul 2026 17:01:51 +0700 Subject: [PATCH] buff timers tick, ground spells find the floor, stuck drags let go - buff durations count down properly: the bar polls the remaining time every frame and a server-sent span reads as real seconds - ground-targeted spells find the floor: Consecration's and Flamestrike's hovering discs ride the decal projector, and Frost Nova's births ride its expanding ring (the emitter params are per-frame tracks now) - a spell or action stuck on the cursor lets go on a left click, and a macOS hardware-cursor drift heals itself in-process - a stealthed weapon fades right: the fade twin's cutout follows the source blend, so an opaque batch never alpha-tests mid-fade Co-Authored-By: Claude Fable 5 --- .../benilla-formats/examples/dump_emitters.rs | 24 +- crates/benilla-formats/examples/emdump.rs | 9 +- crates/benilla-formats/examples/seamswing.rs | 74 +++++ .../src/bin/benilla-extract/m2dump.rs | 38 ++- .../src/bin/benilla-extract/scan.rs | 86 +++++- crates/benilla-formats/src/emit_timing.rs | 211 +++++++++++++- crates/benilla-formats/src/lib.rs | 2 +- .../benilla-formats/src/models/m2_batches.rs | 24 ++ crates/benilla-formats/src/models/mod.rs | 1 + crates/benilla-formats/src/models/types.rs | 73 +++-- crates/benilla-formats/src/particles.rs | 79 ++---- crates/benilla-formats/tests/ground_quad.rs | 47 ++++ .../tests/m2_billboard_card_face.rs | 1 + crates/benilla-formats/tests/particles.rs | 26 +- crates/benilla-ui/src/script/cursor.rs | 104 +++---- crates/benilla/assets/shaders/wow_model.wgsl | 13 +- crates/benilla/assets/ui/BuffFrame.xml | 52 +++- crates/benilla/assets/ui/ChatFrame.xml | 18 ++ crates/benilla/src/assets/mod.rs | 4 +- crates/benilla/src/aura_visual.rs | 50 +++- crates/benilla/src/aura_visual/tests.rs | 89 ++++++ crates/benilla/src/capture/probes.rs | 2 +- crates/benilla/src/cursor.rs | 48 +++- crates/benilla/src/death.rs | 5 +- .../benilla/src/entities/attach/char_skin.rs | 17 +- crates/benilla/src/entities/display.rs | 15 +- .../benilla/src/entities/equipment/spawn.rs | 147 +++++++++- crates/benilla/src/ground_fx.rs | 5 +- crates/benilla/src/model_fade.rs | 7 +- crates/benilla/src/model_render.rs | 67 ++++- crates/benilla/src/net/apply.rs | 10 +- crates/benilla/src/particles.rs | 10 +- crates/benilla/src/particles/depthdump.rs | 5 +- crates/benilla/src/particles/emit.rs | 111 ++++---- crates/benilla/src/particles/model.rs | 2 +- crates/benilla/src/particles/quads.rs | 5 +- crates/benilla/src/particles/sim.rs | 98 ++++--- crates/benilla/src/pipe_warm.rs | 1 + crates/benilla/src/target/click.rs | 7 +- crates/benilla/src/terrain.rs | 10 +- .../src/terrain_stream/spawn/assemble.rs | 5 +- crates/benilla/src/ui_aura.rs | 257 +++++++++++++++--- crates/benilla/src/ui_script/buff_tests.rs | 62 +++++ crates/benilla/src/ui_script/chat_tests.rs | 119 ++++++++ crates/benilla/src/ui_script/extract/mod.rs | 29 +- crates/benilla/src/ui_script/input.rs | 17 +- 46 files changed, 1677 insertions(+), 409 deletions(-) create mode 100644 crates/benilla-formats/examples/seamswing.rs diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs index 75e54f33..792bcf5c 100644 --- a/crates/benilla-formats/examples/dump_emitters.rs +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -27,16 +27,24 @@ fn main() -> anyhow::Result<()> { " texture {:?} tiles {}x{} head_tail {}", e.texture, e.tile_rows, e.tile_cols, e.head_tail ); + let now = e.params.sample(None, 0.0); println!( - " speed {} var {} vrange {} hrange {} gravity {} life {} drag {}", - e.emission_speed, - e.speed_variation, - e.vertical_range, - e.horizontal_range, - e.gravity, - e.lifespan, + " speed {} var {} vrange {} hrange {} gravity {} life {} drag {} (params at t=0)", + now.emission_speed, + now.speed_variation, + now.vertical_range, + now.horizontal_range, + now.gravity, + now.lifespan, e.drag ); + for (name, slots) in e.params.channel_views() { + for (s, keys) in slots.iter().enumerate() { + if let Some(keys) = keys.filter(|k| k.len() > 1) { + println!(" ANIMATED {name}/s{s}: {keys:?}"); + } + } + } match e.timing.constant_rate() { Some(r) => println!(" rate {r}/s (constant, every sequence)"), None => { @@ -52,7 +60,7 @@ fn main() -> anyhow::Result<()> { } println!( " area {}x{} zsrc {} tail {} spin {}", - e.area_length, e.area_width, e.z_source, e.tail_time, e.spin + now.area_length, now.area_width, now.z_source, e.tail_time, e.spin ); println!( " twinkle speed {} pct {} min {} max {}", diff --git a/crates/benilla-formats/examples/emdump.rs b/crates/benilla-formats/examples/emdump.rs index ef19b7b3..ee3f09bd 100644 --- a/crates/benilla-formats/examples/emdump.rs +++ b/crates/benilla-formats/examples/emdump.rs @@ -27,6 +27,7 @@ fn main() -> anyhow::Result<()> { .iter() .enumerate() { + let now = e.params.sample(None, 0.0); println!( "emitter {i}: bone {:>3} flags {:#06x} model_space={} pos ({:.4},{:.4},{:.4}) blend {:?} \ shape {:?} speed {:.4}±{:.4} grav {:.4} life {:.4} tex {:?}", @@ -38,10 +39,10 @@ fn main() -> anyhow::Result<()> { e.position[2], e.blend, e.shape, - e.emission_speed, - e.speed_variation, - e.gravity, - e.lifespan, + now.emission_speed, + now.speed_variation, + now.gravity, + now.lifespan, e.texture, ); } diff --git a/crates/benilla-formats/examples/seamswing.rs b/crates/benilla-formats/examples/seamswing.rs new file mode 100644 index 00000000..13d44703 --- /dev/null +++ b/crates/benilla-formats/examples/seamswing.rs @@ -0,0 +1,74 @@ +//! How far does a welded billboard bone's geometry actually MOVE when the bone is re-oriented? +//! +//! The question decision 0841's regression turns on. A spherical billboard replaces the bone's +//! world rotation outright, so a vertex weighted to it swings by up to `2·|v − pivot|` — while a +//! **seam** vertex (partially weighted) swings by its weight times that, *and* linear-blend skinning +//! shrinks it toward the pivot on the way (the candy-wrapper). If the seam ring sits ON the pivot, +//! both effects vanish and the flap hinges cleanly; if it sits far out, the seam sweeps through +//! whatever it is welded to and no implementation of the law can avoid it. +//! +//! Usage: `cargo run -p benilla-formats --example seamswing -- ` + +use benilla_formats::open_chain; + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let data = args.next().unwrap_or_else(|| "WoW/Data".into()); + let path = args.next().expect("usage: seamswing "); + let mut chain = open_chain(std::path::Path::new(&data))?; + let bytes = chain.read_file(&path)?; + let denied = benilla_formats::non_separable_billboard_bones(&bytes); + println!("welded billboard bones: {denied:?}"); + + let model = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes))?; + let model = model.model(); + for &b in &denied { + let bone = &model.bones[b as usize]; + let piv = [bone.pivot.x, bone.pivot.y, bone.pivot.z]; + // Every vertex with any weight on this bone: its weight, and its distance from the pivot — + // which is the radius the re-orientation sweeps it around. + let (mut pure, mut seam) = (Vec::new(), Vec::new()); + for v in &model.vertices { + for i in 0..4 { + if v.bone_indices[i] as u16 != b || v.bone_weights[i] == 0 { + continue; + } + let pos = [v.position.x, v.position.y, v.position.z]; + let d = ((pos[0] - piv[0]).powi(2) + + (pos[1] - piv[1]).powi(2) + + (pos[2] - piv[2]).powi(2)) + .sqrt(); + let w = f32::from(v.bone_weights[i]) / 255.0; + if w > 0.999 { + pure.push(d) + } else { + seam.push((w, d)) + } + } + } + let stat = |v: &[f32]| { + if v.is_empty() { + return "—".to_string(); + } + let (mn, mx) = v + .iter() + .fold((f32::MAX, 0.0f32), |(a, b), &d| (a.min(d), b.max(d))); + format!( + "n={} min={mn:.4} max={mx:.4} mean={:.4}", + v.len(), + v.iter().sum::() / v.len() as f32 + ) + }; + let seam_d: Vec = seam.iter().map(|&(_, d)| d).collect(); + println!( + "bone {b} pivot ({:.3}, {:.3}, {:.3})", + piv[0], piv[1], piv[2] + ); + println!(" fully-weighted verts, |v−pivot|: {}", stat(&pure)); + println!(" SEAM verts, |v−pivot|: {}", stat(&seam_d)); + if let Some(&(w, _)) = seam.first() { + println!(" seam weight on this bone: {w:.2}"); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 2108a42f..eeeb63d4 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -277,6 +277,11 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } else { String::new() }; + // The parameter channels sample per frame ([`benilla_formats::EmitParams`]); the compact + // line shows their opening values, and any channel that actually MOVES prints its full + // keyed ramp below — the view whose absence hid Frost Nova's 0.19 → 13.2 yd emission- + // radius ride behind a flat "radius [0.19..0.19]" (decision 0844). + let now = d.params.sample(None, 0.0); println!( " {:?} {:?} {} {rate} life {:.2}s speed {:.2} grav {:.2} drag {:.1}{tail} twinkle [{:.2}..{:.2}] spd {:.1} pct {:.2} spin {:.2}", d.shape, @@ -286,9 +291,9 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { 1 => "tail", _ => "head+tail", }, - d.lifespan, - d.emission_speed, - d.gravity, + now.lifespan, + now.emission_speed, + now.gravity, d.drag, d.twinkle_min, d.twinkle_max, @@ -296,13 +301,24 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { d.twinkle_percent, d.spin, ); + for (name, slots) in d.params.channel_views() { + for (s, keys) in slots.iter().enumerate() { + if let Some(keys) = keys.filter(|k| k.len() > 1) { + let w: Vec = keys + .iter() + .map(|&(t, v)| format!("({t:.3}s, {v:.3})")) + .collect(); + println!(" ANIMATED {name}/s{s}: [{}]", w.join(", ")); + } + } + } // The kernel spread (wow-re part-shape-kernels): a sphere's ranges are latitude/longitude // about +X (area = min/max shell radius); a plane's are the ±θ/±φ cone about +Z (area = // the spawn rectangle). `(lat ±π, lon ±0)` reads directly as the edge-on ring family. let spread = match d.shape { benilla_formats::ParticleShape::Sphere => format!( "radius [{:.2}..{:.2}] lat ±{:.2} lon ±{:.2}", - d.area_length, d.area_width, d.vertical_range, d.horizontal_range + now.area_length, now.area_width, now.vertical_range, now.horizontal_range ), // Spline repurposing (wow-re part-spline-file-layout): area = tMin/tMax, // vRange = tangent-spin ψ, hRange = scatter. @@ -313,20 +329,20 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { s.points[0][0], s.points[0][1], s.points[0][2], - d.area_length, - d.area_width, - d.vertical_range, - d.horizontal_range + now.area_length, + now.area_width, + now.vertical_range, + now.horizontal_range ), None => "spline UNPARSED (degenerate record)".to_string(), }, _ => format!( "area {:.1}x{:.1} cone ±{:.2}/±{:.2}", - d.area_length, d.area_width, d.vertical_range, d.horizontal_range + now.area_length, now.area_width, now.vertical_range, now.horizontal_range ), }; - let zsrc = if d.z_source != 0.0 { - format!(" zSource {:.2}", d.z_source) + let zsrc = if now.z_source != 0.0 { + format!(" zSource {:.2}", now.z_source) } else { String::new() }; diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 859f2914..050f7bbe 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -180,6 +180,12 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { // plane; a closed solid has faces every way round, backface culling never hides it, and // sampling its first triangle answers a question it doesn't have (see `plane_normal`). let mut solid = 0u32; + // …and the other side of the same gate: batches the split REFUSED because their geometry is + // welded to a billboard bone (`RenderSubmesh::welded_billboard`, decisions 0839/0841). These + // carry no `billboard` at all, so the card loop below never sees them — they are counted from + // the flag the render lanes read, which is what makes this a cross-check of `bbscan`'s SEAM + // column (same models, counted from the two ends of the rule) rather than a re-derivation. + let (mut welded, mut weld_models) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { continue; @@ -190,6 +196,9 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; }; let mut lines = Vec::new(); + let weld = subs.iter().filter(|s| s.welded_billboard).count() as u32; + welded += weld; + weld_models += u32::from(weld > 0); for (i, s) in subs.iter().enumerate() { let Some(bb) = &s.billboard else { continue }; cards += 1; @@ -232,7 +241,9 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { eprintln!( "{scanned} models scanned, {cards} billboard batch(es): {toward} toward, \ {away_single} away+single-sided (reference culls these), {away_two} away+two-sided, \ - {edge_on} edge-on/degenerate, {solid} NOT PLANAR (3-D geometry — no facing to decide)" + {edge_on} edge-on/degenerate, {solid} NOT PLANAR (3-D geometry — no facing to decide); \ + plus {welded} WELDED batch(es) across {weld_models} model(s) the split refused \ + (skinned, not carded)" ); Ok(()) } @@ -773,6 +784,10 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .collect(); let (mut scanned, mut hits, mut all_flat, mut mixed) = (0u32, 0u32, 0u32, 0u32); let (mut quad_total, mut other_total) = (0u32, 0u32); + // The hover census: every ground-quad-SHAPED batch whose uniform plane sits ABOVE z = 0 + // (any height — the shape test is the renderer's own, the ceiling is lifted), the population + // that decides where the decal lane's hover ceiling (`GROUND_HOVER_MAX`) can sit. + let mut hovers: Vec<(f32, String)> = Vec::new(); for name in names { let Ok(bytes) = chain.read_file(&name) else { continue; @@ -785,10 +800,17 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let total_batches = subs.len(); let (mut flat_count, mut quad_count, mut other_count) = (0u32, 0u32, 0u32); let mut blend_modes: Vec = Vec::new(); + let mut model_hovers: Vec = Vec::new(); for s in &subs { if s.positions.is_empty() { continue; } + if let Some((_, hover)) = s.ground_quad_hover(f32::INFINITY) { + if hover > 0.01 { + hovers.push((hover, name.clone())); + model_hovers.push(hover); + } + } if !s.positions.iter().all(|v| v[2].abs() <= 0.01) { continue; } @@ -806,7 +828,7 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { other_count += 1; } } - if flat_count == 0 { + if flat_count == 0 && model_hovers.is_empty() { continue; } hits += 1; @@ -818,14 +840,36 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { mixed += 1; } blend_modes.sort(); + let hover_note = if model_hovers.is_empty() { + String::new() + } else { + format!( + " hover[{}]", + model_hovers + .iter() + .map(|h| format!("{h:.2}")) + .collect::>() + .join(" ") + ) + }; println!( - "{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat) blend[{}] {name}", + "{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat){hover_note} blend[{}] {name}", blend_modes.join(" ") ); } eprintln!( "{scanned} models scanned, {hits} with flat batches ({all_flat} all-flat, {mixed} mixed); flat batches: {quad_total} QUAD-1BONE, {other_total} OTHER-FLAT" ); + if !hovers.is_empty() { + hovers.sort_by(|a, b| a.0.total_cmp(&b.0)); + eprintln!( + "{} hovering quad-shaped batches (uniform plane z > 0.01), sorted:", + hovers.len() + ); + for (h, name) in &hovers { + eprintln!(" z {h:>7.3} {name}"); + } + } Ok(()) } @@ -1277,9 +1321,10 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { // instrument could LIST that population, so the swapped pairing outlived both audits // until Gressil's 0.1 × 1.1 blade smoke drew its curtain across the blade. Bucketed by // aspect so a thin curtain (load-bearing) separates from a near-square (invisible). + let now = d.params.sample(None, 0.0); if d.shape == benilla_formats::ParticleShape::Plane { - let lo = d.area_length.abs().min(d.area_width.abs()); - let hi = d.area_length.abs().max(d.area_width.abs()); + let lo = now.area_length.abs().min(now.area_width.abs()); + let hi = now.area_length.abs().max(now.area_width.abs()); if hi > 1e-4 { let aspect = if lo > 1e-4 { hi / lo } else { f32::INFINITY }; if aspect >= 4.0 { @@ -1289,6 +1334,29 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } + // ANIMATED parameter channels (decision 0844): the population the value[0] flatten + // silently mis-rendered — Frost Nova's emission radius riding its ring out, Arcane + // Explosion's riding its dome. Tallied per channel so the census names WHICH knob + // actually moves in the corpus. + const PARAM_KEYS: [&str; 9] = [ + "param-anim:speed", + "param-anim:speedVar", + "param-anim:latitude", + "param-anim:longitude", + "param-anim:gravity", + "param-anim:lifespan", + "param-anim:areaLength", + "param-anim:areaWidth", + "param-anim:zSource", + ]; + for (c, (_, slots)) in d.params.channel_views().iter().enumerate() { + if slots + .iter() + .any(|k| k.is_some_and(|k| k.len() > 1 && k.iter().any(|&(_, v)| v != k[0].1))) + { + hit(PARAM_KEYS[c]); + } + } match d.head_tail { 0 => hit("type:head"), 1 => hit("type:tail"), @@ -1349,15 +1417,15 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { hit("tiles:NON-POW2-COLS"); } } - if d.z_source != 0.0 { + if now.z_source != 0.0 { hit("kernel:zSource"); } - if d.gravity < 0.0 { + if now.gravity < 0.0 { hit("kernel:negative-gravity"); } if d.shape == benilla_formats::ParticleShape::Sphere - && d.vertical_range > 3.0 - && d.horizontal_range == 0.0 + && now.vertical_range > 3.0 + && now.horizontal_range == 0.0 { hit("kernel:edge-on-ring(lat±π,lon0)"); } diff --git a/crates/benilla-formats/src/emit_timing.rs b/crates/benilla-formats/src/emit_timing.rs index fb42a824..304af879 100644 --- a/crates/benilla-formats/src/emit_timing.rs +++ b/crates/benilla-formats/src/emit_timing.rs @@ -1,7 +1,8 @@ -//! Per-sequence **particle emission timing** — the FN1 bake of the emitter's two -//! per-frame-sampled M2Tracks (spawn rate `+0xdc`, enabled gate `+0x1dc`), one loop per FILE -//! sequence slot. Split from [`crate::particles`] (the raw record parse) because it is the -//! *runtime sampling* face: decision 0641's material-alpha structure, one channel over. +//! Per-sequence **particle emission timing** — the FN1 bake of the emitter's per-frame-sampled +//! M2Tracks: spawn rate `+0xdc` + enabled gate `+0x1dc` ([`EmitTiming`]) and the other nine +//! emission parameters ([`EmitParams`]), one loop per FILE sequence slot. Split from +//! [`crate::particles`] (the raw record parse) because it is the *runtime sampling* face: +//! decision 0641's material-alpha structure, one channel over. use benilla_m2::M2ScalarTrack; @@ -135,6 +136,177 @@ impl EmitTiming { } } +/// One frame's sampled emitter **parameters** — the nine per-frame-sampled scalar M2Tracks of the +/// emitter record (bases `+0x34..+0x130`, every one except the rate/enabled pair). The reference's +/// `m2_animate` emitter phase samples ALL ten scalar tracks into the per-emitter animation block +/// (`[model+0x3d0]`, stride 0x16c — wow-re `part-emission-rate-animated.md` §1, the rate channel +/// byte-verified as the template) and pushes them onto the live emitter through its setters each +/// frame. **These are NOT constants**: `Frost_Nova_area` ramps its emission-sphere radius +/// 0.19 → 13.2 yd with the expanding ring, `ArcaneExplosion_Base` 0 → 7.2 yd with the growing +/// dome — flatten either to `value[0]` and every birth lands at the centre (the "born way too +/// close" bug this type exists to fix). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ParamsNow { + /// Initial particle speed (yards/sec). + pub emission_speed: f32, + /// Fractional random speed spread (`speed·(1 ± var·noise)`). + pub speed_variation: f32, + /// Half-angle of the emission cone, radians (sphere: latitude range; spline: tangent spin ψ). + pub vertical_range: f32, + /// Azimuthal spread, radians (sphere: longitude range; spline: scatter jitter). + pub horizontal_range: f32, + /// Downward acceleration (yards/sec²) — a live per-frame emitter field (the integrator reads + /// it every frame, `0x7b2680`), so it samples here rather than baking per particle. + pub gravity: f32, + /// Particle lifetime (seconds). The reference passes the CURRENT value into each spawn + /// (`life_param`, the kernels' `ebp+0xc`), so a birth captures it for life. + pub lifespan: f32, + /// Plane: full x-extent (±½ rect); sphere: MIN radius; spline: tMin. + pub area_length: f32, + /// Plane: full y-extent; sphere: MAX radius; spline: tMax. + pub area_width: f32, + /// zSource — velocity pivot at `(0, 0, z)` (0 = unused). + pub z_source: f32, +} + +impl Default for ParamsNow { + /// The channel defaults a keyless track holds: zeros, except lifespan's loader default 1.0. + fn default() -> Self { + Self { + emission_speed: 0.0, + speed_variation: 0.0, + vertical_range: 0.0, + horizontal_range: 0.0, + gravity: 0.0, + lifespan: 1.0, + area_length: 0.0, + area_width: 0.0, + z_source: 0.0, + } + } +} + +/// The nine emitter parameter tracks, baked one loop per FILE sequence slot — the exact +/// bake/clock/sampling law of [`EmitTiming`]'s rate channel (byte-verified there), applied to its +/// nine sibling tracks. Sample once per sim frame on the emitter's clock and feed births/kill/ +/// integration the result ([`ParamsNow`]). +#[derive(Debug, Clone, Default)] +pub struct EmitParams { + /// Per-channel, per-slot baked loops, in [`ParamsNow`] field order; `None` = keyless there + /// (the channel default stands). + channels: [Vec>; 9], +} + +impl EmitParams { + /// Bake the nine tracks (in [`ParamsNow`] field order) against every file sequence slot. + pub(crate) fn bake(tracks: [&M2ScalarTrack; 9], slots: &[SeqSlot], gseq: &[u32]) -> Self { + Self { + channels: tracks.map(|t| { + slots + .iter() + .map(|&s| bake_track(t, gseq, Some(s), |v| v, |_| false, |_| false)) + .collect() + }), + } + } + + /// Sample every channel `elapsed` seconds into sequence slot `seq` (same slot resolution as + /// [`EmitTiming`]: out-of-range degrades to slot 0). + pub fn sample(&self, seq: Option, elapsed: f32) -> ParamsNow { + let d = ParamsNow::default(); + let at = |i: usize, default: f32| -> f32 { + let ch = &self.channels[i]; + let slot = match seq { + Some(s) if s < ch.len() => s, + _ => 0, + }; + ch.get(slot) + .and_then(|o| o.as_ref()) + .map_or(default, |a| a.sample_or(elapsed, default)) + }; + ParamsNow { + emission_speed: at(0, d.emission_speed), + speed_variation: at(1, d.speed_variation), + vertical_range: at(2, d.vertical_range), + horizontal_range: at(3, d.horizontal_range), + gravity: at(4, d.gravity), + lifespan: at(5, d.lifespan), + area_length: at(6, d.area_length), + area_width: at(7, d.area_width), + z_source: at(8, d.z_source), + } + } + + /// The lifespan channel's peak over every slot — the spawn-cull gate (an animated lifespan + /// may open at 0; an emitter is dead only if it NEVER exceeds 0). A keyless channel reads + /// the loader default; a keyed one folds its own keys ONLY (folding from the default would + /// mask an authored sub-1.0 peak). + pub fn peak_lifespan(&self) -> f32 { + let mut keys = self.channels[5] + .iter() + .flatten() + .flat_map(|a| a.keys.iter().map(|&(_, v)| v)) + .peekable(); + if keys.peek().is_none() { + return ParamsNow::default().lifespan; + } + keys.fold(0.0, f32::max) + } + + /// Constant channels from one [`ParamsNow`] — the test/tool constructor, and the shape every + /// pre-track consumer had. + pub fn constant(now: ParamsNow) -> Self { + let ch = |v: f32| { + vec![Some(ScalarAnim { + period: 0.0, + step: true, + wrap: true, + keys: vec![(0.0, v)], + })] + }; + Self { + channels: [ + ch(now.emission_speed), + ch(now.speed_variation), + ch(now.vertical_range), + ch(now.horizontal_range), + ch(now.gravity), + ch(now.lifespan), + ch(now.area_length), + ch(now.area_width), + ch(now.z_source), + ], + } + } + + /// Per-channel dump view: `(name, per-slot keys)` — `None` = keyless in that slot. The dump + /// instruments print any channel whose keys actually move (the view that would have shown + /// Frost Nova's 0.19 → 13.2 yd radius ramp instead of hiding it behind `value[0]`). + #[allow(clippy::type_complexity)] // a read-only tuple view for the dumps, like `slot_views` + pub fn channel_views(&self) -> [(&'static str, Vec>); 9] { + const NAMES: [&str; 9] = [ + "speed", + "speedVar", + "latitude", + "longitude", + "gravity", + "lifespan", + "areaLength", + "areaWidth", + "zSource", + ]; + let mut i = 0; + NAMES.map(|name| { + let v = self.channels[i] + .iter() + .map(|o| o.as_ref().map(|a| a.keys.as_slice())) + .collect(); + i += 1; + (name, v) + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -243,6 +415,37 @@ mod tests { assert_eq!(t.peak_rate(), 20.0); } + /// The ANIMATED-parameter law — Frost Nova's authored shape: the emission-sphere radius + /// (areaLength = areaWidth) lerps 0.19 → 13.2 yd over 667 ms and holds, riding the ring + /// outward; lifespan ramps beside it. `value[0]` flattening (the bug this bakes away) reads + /// 0.19 for ever and births the whole mist at the caster's feet. + #[test] + fn animated_area_ramp_samples_mid_flight() { + let area = track(1, &[(0, 0.1944), (667, 13.1967), (867, 13.1967)], &[]); + let life = track(1, &[(0, 0.472), (467, 0.8008), (667, 0.7), (867, 0.7)], &[]); + let zero = M2ScalarTrack::default(); + let p = EmitParams::bake( + [ + &zero, &zero, &zero, &zero, &zero, &life, &area, &area, &zero, + ], + &slots(&[((0, 867), false)]), + &[], + ); + let at = |t: f32| p.sample(None, t); + assert!((at(0.0).area_length - 0.1944).abs() < 1e-3, "opens tight"); + let mid = at(0.3335).area_length; + assert!( + (mid - (0.1944 + (13.1967 - 0.1944) * 0.5)).abs() < 0.05, + "mid-ramp radius ≈ 6.7 yd, got {mid}" + ); + assert!((at(0.8).area_width - 13.1967).abs() < 1e-3, "holds wide"); + assert!((at(0.2).lifespan - 0.6127).abs() < 5e-3, "lifespan rides"); + assert_eq!(at(0.5).emission_speed, 0.0, "keyless channel: default"); + assert!((p.peak_lifespan() - 0.8008).abs() < 1e-4); + // The clamped one-shot parks at its tail, never aliasing back to the tight opening. + assert!((at(5.0).area_length - 13.1967).abs() < 1e-3); + } + /// A LOOPING slot wraps its band — a windowed gate re-fires every pass (the precast hold's /// pulsing hand flash), where a clamped slot would have parked. #[test] diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index efc51cf5..50ae6a92 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -135,7 +135,7 @@ pub use spell_visual::{ VisualStages, KIT_CHAR_PROCS, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, }; mod emit_timing; -pub use emit_timing::EmitTiming; +pub use emit_timing::{EmitParams, EmitTiming, ParamsNow}; mod particles; pub use particles::{ parse_m2_particle_emitters, CellRamp, OverLife, OverLifeSample, ParticleBlend, diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index 3b23a44f..18336fed 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -634,6 +634,19 @@ pub fn parse_m2_render_submeshes( emissive, ); sub.billboard = bone.and_then(make_billboard); + // …and the other side of the same gate: this batch holds geometry on a billboard bone + // the split REFUSED, so no rigid placement of it is right and the lane must skin it to + // bend it (decision 0841). Read off the same `separable` table the key above is gated + // on, so the renderer's "is this a card" and "is this welded" can never disagree. + sub.welded_billboard = globals.iter().any(|&g| { + let v = &model.vertices[g as usize]; + (0..4).any(|i| { + let b = v.bone_indices[i] as usize; + v.bone_weights[i] != 0 + && model.bones.get(b).is_some_and(|bone| bone.is_billboard()) + && !separable.get(b).copied().unwrap_or(false) + }) + }); sub.additive = additive; // M2 blend mode 3/4 → additive (glow cards) sub.no_depth_write = no_depth_write; // M2 render flag 0x10 sub.no_depth_test = no_depth_test; // M2 render flag 0x08 @@ -917,6 +930,13 @@ mod tests { seam, 16, "the two 8-vertex 50/50 seam rings are in the mesh" ); + // …and the batch SAYS it is welded — the other half of the same gate, and the flag a lane + // reads to decide it must skin this model (decision 0841). A build where the split key and + // this flag disagreed would draw the spikes rigid and call it fine. + assert!( + subs[0].welded_billboard, + "the welded batch announces itself to the render lanes" + ); // The renderer's predicate, read directly: both billboard bones are the denied ones. let bytes = chain.read_file(path).expect("read the m2"); assert_eq!(non_separable_billboard_bones(&bytes), vec![1, 2]); @@ -943,6 +963,10 @@ mod tests { cards.iter().all(|c| c.positions.len() == 4), "each is its own 4-vertex quad" ); + assert!( + subs.iter().all(|s| !s.welded_billboard), + "…so no batch of it asks a lane to skin anything (decision 0841)" + ); let bytes = chain.read_file(path).expect("read the m2"); assert!( non_separable_billboard_bones(&bytes).is_empty(), diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index d6871568..283ed1b1 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -99,6 +99,7 @@ fn remap_submesh( no_depth_test: false, // set by the M2 path from render flag 0x08 fog_policy: FogPolicy::Scene, // refined by the M2 path (flag 0x02 + the blend table); WMO batches fog with the scene billboard: None, // set by the M2 path after detecting the batch's bone + welded_billboard: false, // set by the M2 path from the separability gate (0839) alpha_anim: None, // set by the M2 path from the batch's colour/weight tracks uv_anim: None, // set by the M2 path from the batch's texture transform rgb_anim: None, // set by the M2 path from the batch's colour RGB track diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index dd045219..51e26b16 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -297,6 +297,13 @@ pub struct RenderSubmesh { /// Set when this batch sits on an M2 **billboard bone** — the renderer faces it to the camera each /// frame (around the bone pivot). `None` for ordinary geometry and all WMO batches. pub billboard: Option, + /// This batch holds geometry bound to a billboard bone the card split **refused** — one whose mesh + /// is welded to the rest of the model (`separable_billboard_bones`, decision 0839). No rigid + /// placement of such a batch exists: the reference blends it per vertex, so a lane that wants the + /// flap to bend must draw the **skinned** form through a joint palette (decision 0841). A lane + /// that draws it static gets it whole and still. `false` for every ordinary batch — including + /// every separable card, which is split out and faced rigidly — and for all WMO batches. + pub welded_billboard: bool, /// The batch's **animated material alpha** (decision 0130 phase 2): its time-varying colour-alpha /// and/or transparency-weight loops, baked by [`mat_anim`](super::mat_anim). `None` when both /// factors are static (baked/culled at build) — the overwhelming majority. The runtime multiplies @@ -319,15 +326,17 @@ pub struct RenderSubmesh { pub wmo_batch: Option, } -/// A render batch that is a single flat **ground-plane quad**: four vertices, all at authored -/// z ≈ 0 (model space, WoW axes — the plane the model's owner stands on), forming an axis-aligned -/// rectangle in model XY, every vertex fully weighted to one bone. This is the authoring pattern -/// of ground-ring spell effects — Battle Shout's six crescents, the paladin auras' rings: flat -/// quads at the caster's feet that bones slide, spin, and scale outward, and that sloped terrain -/// buries per-pixel under a normal depth test (the batches author depth-test ON). The `groundscan` -/// sweep (2026-07-14, full 5875 chain) measures the population: 128 of the 139 flat batches under -/// `Spells\` are exactly this shape. The entity fx lane re-renders such parts as projected surface -/// decals (`benilla::ground_fx`) so they drape the terrain like the selection ring does. +/// A render batch that is a single flat **ground-plane quad**: four vertices sharing one authored +/// horizontal plane at or hovering just above z = 0 (model space, WoW axes — the plane the +/// model's owner stands on; hover ≤ [`GROUND_HOVER_MAX`]), forming an axis-aligned rectangle in +/// model XY, every vertex fully weighted to one bone. This is the authoring pattern of +/// ground-ring spell effects — Battle Shout's six crescents at exactly z = 0, Consecration's burn +/// disc hovering at 0.207: flat quads at the owner's feet that bones slide, spin, and scale +/// outward, and that sloped terrain buries per-pixel under a normal depth test (the batches +/// author depth-test ON). The `groundscan` sweep (2026-07-14 z=0, 2026-07-31 hover; full 5875 +/// chain) measures the population: 128 of the 139 z=0 flat batches under `Spells\` plus the 108 +/// hovering discs are exactly this shape. The entity fx lane re-renders such parts as projected +/// surface decals (`benilla::ground_fx`) so they drape the terrain like the selection ring does. #[derive(Debug, Clone, Copy)] pub struct GroundQuad { /// The M2 bone (global bone-array index) all four vertices skin to — the quad's animated @@ -341,10 +350,20 @@ pub struct GroundQuad { pub uvs: [[f32; 2]; 4], } -/// Vertices further than this from the model's XY plane disqualify a batch as ground-plane flat -/// (the real population authors exactly 0.0; the epsilon only absorbs float noise). +/// Vertices further than this from the quad's own plane disqualify a batch as ground-plane flat +/// (the real population authors exact planes; the epsilon only absorbs float noise). const GROUND_FLAT_EPS: f32 = 0.01; +/// The highest authored **hover** (uniform plane z, model space) a flat quad may sit at and +/// still be the ground-plane shape. z = 0 is the majority authoring (Battle Shout's crescents); +/// a minority hovers the disc just above the owner's ground plane to dodge terrain z-fighting. +/// The `groundscan` hover census (2026-07-31, 5875 chain, `Spells\`) measures the population: +/// 108 quad-shaped batches hover between 0.014 and 0.518 (the paladin aura/seal rings, +/// Consecration's 0.207 disc, Flamestrike's 0.097 burn), then clear air up to 1.389 — the two +/// `GreaterHeal_Low_Base` mid-body glow planes, which must NOT drape to the ground. 1.0 splits +/// the gap. +const GROUND_HOVER_MAX: f32 = 1.0; + impl RenderSubmesh { /// Is this a **billboard card authored back-to-front** — one flat plane whose normal sits in the /// −X half-space the billboard law points *away* from the camera? @@ -399,12 +418,21 @@ impl RenderSubmesh { } /// Detect the flat **ground-plane quad** shape ([`GroundQuad`]) — `None` for everything that - /// isn't exactly it: more than four vertices, any vertex off the z≈0 plane, a multi-bone or - /// partial-weight skin, or corners that don't form an axis-aligned XY rectangle. Billboard - /// batches never match (their geometry is camera-facing by construction, not ground-lying). - /// Deliberately strict: the 11 `OTHER-FLAT` spell batches the population sweep found (Fist of - /// Justice, IceNuke impact) stay on the ordinary render path rather than stretch this shape. + /// isn't exactly it: more than four vertices, vertices off one shared horizontal plane, a + /// plane hovering above [`GROUND_HOVER_MAX`], a multi-bone or partial-weight skin, or corners + /// that don't form an axis-aligned XY rectangle. Billboard batches never match (their + /// geometry is camera-facing by construction, not ground-lying). Deliberately strict: the 11 + /// `OTHER-FLAT` spell batches the population sweep found (Fist of Justice, IceNuke impact) + /// stay on the ordinary render path rather than stretch this shape. pub fn ground_quad(&self) -> Option { + self.ground_quad_hover(GROUND_HOVER_MAX).map(|(q, _)| q) + } + + /// [`Self::ground_quad`] with the hover ceiling as a parameter, returning the plane's + /// authored z alongside the quad — the census entry point (`groundscan` passes `INFINITY` to + /// measure the whole hover population with the renderer's own shape test, so the instrument + /// cannot drift from the mechanism). + pub fn ground_quad_hover(&self, max_hover: f32) -> Option<(GroundQuad, f32)> { if self.billboard.is_some() || self.positions.len() != 4 || self.joints.len() != 4 @@ -413,7 +441,16 @@ impl RenderSubmesh { { return None; } - if self.positions.iter().any(|p| p[2].abs() > GROUND_FLAT_EPS) { + // One shared horizontal plane, at or hovering just above the owner's ground level. The + // corners keep their authored z: the decal projection drapes them onto the receiving + // surface regardless, exactly as it flattens the z = 0 majority. + let hover = self.positions.iter().map(|p| p[2]).sum::() / 4.0; + if !(-GROUND_FLAT_EPS..=max_hover).contains(&hover) + || self + .positions + .iter() + .any(|p| (p[2] - hover).abs() > GROUND_FLAT_EPS) + { return None; } // One bone, full weight, across all four vertices. @@ -468,6 +505,6 @@ impl RenderSubmesh { corners[slot] = *p; uvs[slot] = *uv; } - Some(GroundQuad { bone, corners, uvs }) + Some((GroundQuad { bone, corners, uvs }, hover)) } } diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index 58749ed5..8689ec9e 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -41,7 +41,7 @@ use std::io::Cursor; use anyhow::Result; use benilla_m2::{parse_m2, M2ScalarTrack}; -use crate::emit_timing::EmitTiming; +use crate::emit_timing::{EmitParams, EmitTiming}; use crate::models::SeqSlot; /// Emitter spawn shape (file `emitterType` @ +0x2a). Only `Plane` is needed for campfires/torches; @@ -345,29 +345,16 @@ pub struct ParticleEmitterDef { pub tile_cols: u16, /// 0 = head (camera quad), 1 = tail (speed-stretched), 2 = both. We render head first. pub head_tail: u8, - // --- emission params: value[0] of each emission M2Track (the runtime's own load-time bake) --- - /// Initial particle speed (yards/sec). - pub emission_speed: f32, - /// Fractional random speed spread (`speed·(1 ± var·noise)`). - pub speed_variation: f32, - /// Half-angle of the emission cone, radians (small = a tight upward jet). - pub vertical_range: f32, - /// Azimuthal spread, radians (`2π` = full circle around the up axis). - pub horizontal_range: f32, - /// Downward acceleration (yards/sec²); small/zero for a buoyant flame. - pub gravity: f32, - /// Particle lifetime (seconds) — killed at this age. - pub lifespan: f32, /// The two per-frame-sampled emission tracks — spawn rate (`+0xdc`) and the ON/OFF gate - /// (`+0x1dc`) — baked **one loop per sequence** ([`EmitTiming`]). The other nine emission - /// M2Tracks stay `value[0]`-baked: constant in every model this renderer draws (verified - /// across the spurt family + the ambient props; a named residual if a model ever authors one). + /// (`+0x1dc`) — baked **one loop per sequence** ([`EmitTiming`]). pub timing: EmitTiming, - /// Plane emitter full extents (yards); spawn points are drawn from the `±½·extent` rectangle. - pub area_length: f32, - pub area_width: f32, - /// zSource / gravity2 — a pull toward a point above the emitter (0 = unused). - pub z_source: f32, + /// The other NINE emission M2Tracks (speed, speedVar, latitude, longitude, gravity, lifespan, + /// areaLength, areaWidth, zSource — bases `+0x34..+0x130`), baked the same way and sampled by + /// the sim **each frame on the emitter's clock** ([`EmitParams`]). These animate for real: + /// Frost Nova rides its emission radius 0.19 → 13.2 yd out with the expanding ring, Arcane + /// Explosion 0 → 7.2 yd with the growing dome — the retired `value[0]` flatten birthed both + /// entirely at the caster's feet (decision 0844). + pub params: EmitParams, /// Velocity **drag** (file +0x194; a plain scalar, not a track — the builder copies it straight to /// the runtime emitter's `+0x1e0` at `m2-decomp.c:8950`, *outside* the ten track setters). Each /// frame the runtime applies `vel −= min(dt·drag, 1)·vel` (verified `particle_integrate` @ @@ -574,21 +561,6 @@ fn le_vec3(b: &[u8], o: usize) -> [f32; 3] { [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)] } -/// Read value[0] of a vanilla M2Track (28 bytes) whose record begins at `track`. Track tail: -/// `values{count @ +0x14, offset @ +0x18}`, values are `f32`. Returns `default` if the track is empty -/// or points out of range (the runtime's load-time bake reads exactly this first value). -fn track_first(b: &[u8], track: usize, default: f32) -> f32 { - if track + 0x1c > b.len() { - return default; - } - let n = le_u32(b, track + 0x14); - let ofs = le_u32(b, track + 0x18) as usize; - if n == 0 || ofs + 4 > b.len() { - return default; - } - le_f32(b, ofs) -} - /// Read one raw vanilla M2Track (28 bytes at `track`) into the shared typed shape — **absolute** /// key timestamps plus the per-sequence `ranges` windows the FN1 bake indexes. `benilla-m2` skips /// the cosmetic chunks, so the two emitter timing tracks are lifted here; `elem`/`read` decode one @@ -805,21 +777,29 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result= 16); - assert_eq!(d.area_length, 0.0, "tMin"); - assert_eq!(d.area_width, 1.0, "tMax"); - assert_eq!(d.vertical_range, 0.0, "no tangent spin"); + let now = d.params.sample(None, 0.0); + assert_eq!(now.area_length, 0.0, "tMin"); + assert_eq!(now.area_width, 1.0, "tMax"); + assert_eq!(now.vertical_range, 0.0, "no tangent spin"); assert!(d.burst(), "one puff of standing flames"); // The chain starts high (the authored descent) — a sane, in-model coordinate. assert!( diff --git a/crates/benilla-formats/tests/ground_quad.rs b/crates/benilla-formats/tests/ground_quad.rs index 15503220..f21650a4 100644 --- a/crates/benilla-formats/tests/ground_quad.rs +++ b/crates/benilla-formats/tests/ground_quad.rs @@ -40,6 +40,53 @@ fn battle_shout_crescents_detect_as_ground_quads() { assert_eq!(bones, [1, 2, 3, 5, 6, 7], "one quad per slide bone"); } +/// The HOVER population (the `groundscan` hover census, 2026-07-31): Consecration authors its +/// 23-yard burn disc at z = 0.097 and its center glow at 0.207 — flat, uniform-z, just above the +/// ground plane to dodge terrain z-fighting. Both must detect (the widened ceiling, +/// `GROUND_HOVER_MAX`), with the authored hover preserved on the corners; and Flamestrike's model +/// must decal exactly its two flat discs while its 3-D flame column, ribbons, and billboard glow +/// stay off the lane. +#[test] +fn hovering_discs_detect_as_ground_quads() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open chain"); + + let bytes = chain + .read_file("spells\\consecration_impact_base.m2") + .expect("Consecration impact-base model"); + let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("submeshes"); + assert_eq!(subs.len(), 2, "glow + burn disc"); + let mut hovers: Vec = subs + .iter() + .map(|s| { + let quad = s.ground_quad().expect("both Consecration discs detect"); + let z = quad.corners[0][2]; + assert!( + quad.corners.iter().all(|c| c[2] == z), + "uniform authored plane" + ); + z + }) + .collect(); + hovers.sort_by(f32::total_cmp); + assert!((hovers[0] - 0.097).abs() < 1e-3, "burn disc hover"); + assert!((hovers[1] - 0.207).abs() < 1e-3, "center glow hover"); + + let bytes = chain + .read_file("Spells\\Flamestrike_Impact_Base.m2") + .expect("Flamestrike impact-base model"); + let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("submeshes"); + let quads = subs.iter().filter(|s| s.ground_quad().is_some()).count(); + assert_eq!( + quads, 2, + "exactly the burn disc + center glow — flames/ribbons/billboard stay meshes" + ); +} + #[test] fn character_model_detects_no_ground_quads() { let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs index eea031f7..0e7e21df 100644 --- a/crates/benilla-formats/tests/m2_billboard_card_face.rs +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -130,6 +130,7 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() { scale_anim: None, seq_translations: vec![], }), + welded_billboard: false, alpha_anim: None, uv_anim: None, rgb_anim: None, diff --git a/crates/benilla-formats/tests/particles.rs b/crates/benilla-formats/tests/particles.rs index 3866eab1..41631fee 100644 --- a/crates/benilla-formats/tests/particles.rs +++ b/crates/benilla-formats/tests/particles.rs @@ -32,13 +32,14 @@ fn campfire_emitters_match_real_bytes() { for e in &emitters { assert_eq!(e.shape, ParticleShape::Plane); assert_eq!(e.blend, ParticleBlend::Add); - assert!(e.lifespan > 0.0 && e.lifespan.is_finite()); + let now = e.params.sample(None, 0.0); + assert!(now.lifespan > 0.0 && now.lifespan.is_finite()); let rate = e .timing .constant_rate() .expect("ambient prop rates are constant tracks"); assert!(rate > 0.0 && rate.is_finite()); - assert!(e.horizontal_range > 6.0, "campfire emits in a full ring"); + assert!(now.horizontal_range > 6.0, "campfire emits in a full ring"); // Texture resolves to a real .blp via the M2 textures table. assert!( e.texture.as_deref().is_some_and(|t| !t.is_empty()), @@ -49,10 +50,11 @@ fn campfire_emitters_match_real_bytes() { // Glow/smoke plume: wide 20° cone, long life, low rate, single cell. let glow = &emitters[0]; + let glow_now = glow.params.sample(None, 0.0); assert!( - (glow.lifespan - 4.0).abs() < 1e-3, + (glow_now.lifespan - 4.0).abs() < 1e-3, "glow lifespan ~4.0, got {}", - glow.lifespan + glow_now.lifespan ); assert!( (glow.timing.constant_rate().unwrap() - 6.0).abs() < 1e-3, @@ -60,14 +62,15 @@ fn campfire_emitters_match_real_bytes() { glow.timing.constant_rate() ); assert_eq!((glow.tile_rows, glow.tile_cols), (1, 1)); - assert!(glow.vertical_range > 0.3, "glow has a wide cone"); + assert!(glow_now.vertical_range > 0.3, "glow has a wide cone"); // Flame: short life, high rate, narrow cone, 4×4 cell animation. let flame = &emitters[1]; + let flame_now = flame.params.sample(None, 0.0); assert!( - (flame.lifespan - 1.5).abs() < 1e-3, + (flame_now.lifespan - 1.5).abs() < 1e-3, "flame lifespan ~1.5, got {}", - flame.lifespan + flame_now.lifespan ); assert!( (flame.timing.constant_rate().unwrap() - 20.0).abs() < 1e-3, @@ -79,7 +82,10 @@ fn campfire_emitters_match_real_bytes() { (4, 4), "flame has a 4×4 flicker atlas" ); - assert!(flame.vertical_range < 0.2, "flame is a tight upward jet"); + assert!( + flame_now.vertical_range < 0.2, + "flame is a tight upward jet" + ); // Drag (file +0x194): the velocity-decay term the verified integrator applies as // `vel −= min(dt·drag, 1)·vel`. The campfire's smoke/glow plume carries a gentle 0.5 (contained @@ -107,9 +113,9 @@ fn campfire_emitters_match_real_bytes() { e.shape, e.blend, e.texture, - e.lifespan, + e.params.sample(None, 0.0).lifespan, e.timing.constant_rate(), - e.vertical_range, + e.params.sample(None, 0.0).vertical_range, e.tile_rows, e.tile_cols ); diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 0cd6650b..c1b60b31 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -1,10 +1,9 @@ //! The cursor payload (decision 0216, slice 1) — the client's payload-mode global `[0xb4d900]` //! as a typed enum, and the drag-gesture mechanics (`RegisterForDrag`/`OnDragStart`/`OnDragStop`/ //! `OnReceiveDrag`) that transition it. One seam for every surface — bags, the paper doll -//! ([`doll`], decision 0208 phase 1b), and action bars ([`bar`], decision 0216 §7/0218 §4) today; -//! the spellbook lands on it in a later slice ([`CursorSpell`] is anticipated by the enum, not -//! yet produced anywhere) — so sounds, `CURSOR_UPDATE`, grid events, and lock display can't drift -//! apart per window. +//! ([`doll`], decision 0208 phase 1b), action bars ([`bar`], decision 0216 §7/0218 §4), and the +//! spellbook ([`super::spellbook`]'s `PickupSpell`, slice 5 — the [`CursorSpell`] producer) — so +//! sounds, `CURSOR_UPDATE`, grid events, and lock display can't drift apart per window. //! //! [`container`](super::container)'s `pickup_container_item`, [`doll`]'s //! `pickup_inventory_item`, and [`bar`]'s `pickup_action`/`place_action` (the surface-specific @@ -41,9 +40,10 @@ pub const EQUIPMENT_BAG: i64 = -100; // ───────────────────────────────────────────────────────────────────────────────────────────── /// What the cursor carries — the client's payload-mode global [0xb4d900] as a typed enum -/// (wow-re item-pickup-place-sound.md: 1 = live item; spell/macro and money/preview arms -/// anticipated, not yet built). One transition seam for every surface, so sounds, CURSOR_UPDATE, -/// and lock display can't drift apart per window (decision 0216). +/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell; our Action arm is the +/// client's bar-slot pickup; the money/macro/preview arms stay unbuilt). One transition seam for +/// every surface, so sounds, CURSOR_UPDATE, and lock display can't drift apart per window +/// (decision 0216). #[derive(Clone, Debug, PartialEq)] pub enum CursorPayload { Item(CursorItem), @@ -85,9 +85,9 @@ pub struct CursorItem { pub bar_placeable: bool, } -/// A spell payload (`PickupSpell`, not yet built — anticipated by the enum for the spellbook -/// slice): the spellbook slot it was picked from, its book (`"spell"`/`"pet"`, the Era -/// `GetCursorInfo` shape), and the resolved spell id. +/// A spell payload ([`super::spellbook`]'s `PickupSpell` produces it): the spellbook slot it was +/// picked from, its book (`"spell"`/`"pet"`, the Era `GetCursorInfo` shape), and the resolved +/// spell id. #[derive(Clone, Debug, PartialEq)] pub struct CursorSpell { pub book_slot: u32, @@ -99,9 +99,9 @@ pub struct CursorSpell { pub passive: bool, } -/// An action-slot payload (`PickupAction`, not yet built — anticipated by the enum for the -/// action-bar slice): the source action slot, the packed action's kind byte (SPELL/MACRO/ITEM — -/// decision 0216's `CMSG_SET_ACTION_BUTTON` type byte), and the action id itself. +/// An action-slot payload ([`bar`]'s `PickupAction`/`place_action` hop produces it): the source +/// action slot, the packed action's kind byte (SPELL/MACRO/ITEM — decision 0216's +/// `CMSG_SET_ACTION_BUTTON` type byte), and the action id itself. #[derive(Clone, Debug, PartialEq)] pub struct CursorAction { pub src_slot: u32, @@ -194,9 +194,10 @@ pub(crate) fn clear_cursor(model: &mut Model) { /// [`WorldPick::Nothing`] in tests/captures). Routes the left world-drop /// ([`world_drop_click`]): over an `Object` no payload drops at all (the object leg `0x492ce0` /// clears only the displayId-PREVIEW gate `[0xb4b41c]` — an arm benilla doesn't carry — and -/// dispatches SELECT/INTERACT with the payload still held); over `Terrain` only an ITEM drops -/// (the `DELETE_ITEM_CONFIRM` popup via `0x5e0320` — a spell/action payload survives a ground -/// click); over `Nothing` the item pops and any other payload silently clears (`0x492d30`). +/// dispatches SELECT/INTERACT with the payload still held); over `Terrain` an ITEM drops (the +/// `DELETE_ITEM_CONFIRM` popup via `0x5e0320`) and a spell/action payload clears silently +/// (decision 0843's deliberate divergence — the reference keeps it there); over `Nothing` the +/// item pops and any other payload silently clears (`0x492d30`). #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum WorldPick { /// No world hit at all (sky / beyond the world) — the reference's state 0. @@ -219,13 +220,15 @@ pub enum WorldPick { /// - `Terrain`: an item payload fires `DELETE_ITEM_CONFIRM(name, quality)` and STAYS held (the /// reference popup's `OnAccept` calls `DeleteCursorItem`, `OnCancel` `ClearCursor`, and its /// `OnUpdate` auto-hides when the cursor empties — the engine must not pre-clear); a -/// spell/action payload SURVIVES a ground click (`0x492c90` clears non-items only on the -/// right button). +/// spell/action payload clears silently — a DELIBERATE divergence (decision 0843, the +/// director's call): the reference's terrain leg keeps a non-item payload on the left click +/// (`0x492c90`/`0x5e0320` clear non-items only on the right button), which leaves a spell +/// stuck to the cursor with no left-handed way off it. /// - `Nothing`: the item pops the same popup; any other payload clears silently (`0x492d30`'s -/// non-item arm). +/// non-item arm — here the reference agrees). /// -/// Returns whether the click was consumed as a drop (an empty cursor, an `Object` pick, or a -/// surviving non-item payload on `Terrain` consume nothing). +/// Returns whether the click was consumed as a drop (an empty cursor or an `Object` pick +/// consume nothing). pub(crate) fn world_drop_click(model: &mut Model) -> bool { match (&model.cursor, model.world_pick) { (Some(CursorPayload::Item(item)), WorldPick::Terrain | WorldPick::Nothing) => { @@ -233,7 +236,10 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool { queue_delete_item_confirm(model, &item); true } - (Some(CursorPayload::Spell(_) | CursorPayload::Action(_)), WorldPick::Nothing) => { + ( + Some(CursorPayload::Spell(_) | CursorPayload::Action(_)), + WorldPick::Terrain | WorldPick::Nothing, + ) => { clear_cursor(model); true } @@ -307,8 +313,8 @@ impl super::UiScript { } } - /// The full cursor payload, any arm — for callers that will also draw/react to a spell/action - /// drag (not yet built; anticipated for later slices). + /// The full cursor payload, any arm — what the app's hardware-cursor drive + /// (`benilla::cursor`) and capture stand-in draw, and what the world-click consumers gate on. pub fn cursor_payload(&self) -> Option { self.model_ref().cursor.clone() } @@ -546,36 +552,32 @@ mod tests { assert!(s.cursor_payload().is_none()); } - /// The world-drop pick routing (decisions 0571 + 0574 — wow-re §11): over `Terrain` an item - /// drops (popup) but a spell/action payload SURVIVES the ground click; over `Nothing` the - /// non-item arm clears silently. + /// The world-drop pick routing (decisions 0571 + 0574 — wow-re §11, amended by 0843): a + /// spell/action payload clears silently on BOTH empty-world legs — terrain included, the + /// 0843 divergence (the reference keeps it on terrain; the director wants the left click to + /// dismiss) — while an item keeps its byte-faithful popup flow. #[test] - fn world_drop_terrain_keeps_a_spell_payload_nothing_clears_it() { + fn world_drop_terrain_and_nothing_both_clear_a_spell_payload() { use super::{world_drop_click, WorldPick}; let mut s = UiScript::new().unwrap(); - s.set_cursor_for_test(CursorPayload::Spell(CursorSpell { - passive: false, - book_slot: 3, - book_type: "spell".into(), - spell_id: 133, - texture: None, - })); - s.model_mut().world_pick = WorldPick::Terrain; - assert!( - !world_drop_click(&mut s.model_mut()), - "terrain: a spell payload is not a drop" - ); - assert!( - s.cursor_payload().is_some(), - "the spell survives the ground click" - ); - - s.model_mut().world_pick = WorldPick::Nothing; - assert!(world_drop_click(&mut s.model_mut())); - assert!( - s.cursor_payload().is_none(), - "over nothing it clears silently" - ); + for pick in [WorldPick::Terrain, WorldPick::Nothing] { + s.set_cursor_for_test(CursorPayload::Spell(CursorSpell { + passive: false, + book_slot: 3, + book_type: "spell".into(), + spell_id: 133, + texture: None, + })); + s.model_mut().world_pick = pick; + assert!( + world_drop_click(&mut s.model_mut()), + "{pick:?}: the dismiss consumes the click" + ); + assert!( + s.cursor_payload().is_none(), + "{pick:?}: the spell clears silently" + ); + } // An item drops (the popup path) on BOTH empty-world legs, never over an object. let item = CursorPayload::Item(CursorItem { diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index f853ed73..fe3edd01 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -487,8 +487,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp // VERIFIED reference behaviour (`RECONCILE-fade-render-state.md`): a fading doodad is the SAME draw // as the steady cutout — the alpha-test ref scales WITH the fade so the effective cutoff stays a // constant `tex0.a < 224/255` (STABLE silhouette, never grows/snaps) — with blend on and source - // alpha = `tex0.a × fade`. On the blend twin (`model_flags.y`) `AlphaMode::Blend` does no discard, - // so we re-apply that hard cutout here on the UNFADED alpha; `specialize` keeps depth-write ON. + // alpha = `tex0.a × fade`. On the blend twin `AlphaMode::Blend` does no discard, so we re-apply + // that hard cutout here on the UNFADED alpha — but ONLY for a twin whose SOURCE batch alpha-tests + // (clutter_fade.z bit 10, model_render's TWIN_CUTOUT_MARKER): the reference keys ALPHAREF on the + // STORED blend mode (m2-blend-promotion-zfill.md §2), so a fading/stealthed OPAQUE batch blends + // with no alpha test at all. Keying the discard on the twin bit itself cut every texel under + // 224/255 out of promoted Opaque batches — Gressil's blade body erased to its rune pattern under + // stealth (decision 0842). `specialize` keeps depth-write ON for every twin (`model_flags.y`). // The payload is TYPED (mesh_tag.rs, decisions 0173/0720): bits 0-5 = the fade alpha (6-bit // fraction; a whole payload of 0 = the untagged ⇒ opaque sentinel), bits 19-29 = the skin // rig slot (vertex-stage concern — the fragment never reads it, but it rides every payload, @@ -530,13 +535,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp vec3(1.0), tint_word == 0u, ); - if (m.model_flags.y > 0.5 && base_color.a < VANILLA_ALPHA_KEY) { + if ((u32(m.clutter_fade.z) & 1024u) != 0u && base_color.a < VANILLA_ALPHA_KEY) { discard; } // DEPTH-PRIME TWIN (the zfill pipeline variant — terrain.rs `specialize`, the reference's // M2UseZFill clone command, wow-re m2-blend-promotion-zfill.md §4): colour writes are masked // off at the pipeline, so on that variant only the discards above (farclip wall, the - // model_flags.y cutout) shape what depth gets written; the lighting/fog below is computed and + // bit-10 twin cutout) shape what depth gets written; the lighting/fog below is computed and // thrown away. (A shader-def early return here would be the natural spelling, but naga's MSL // backend miscompiles the dead tail — "redefinition of '_tmp'" — so the twin pays the colour // math instead. Episodes are transient; the cost is bounded.) diff --git a/crates/benilla/assets/ui/BuffFrame.xml b/crates/benilla/assets/ui/BuffFrame.xml index 6e98834f..9f52772b 100644 --- a/crates/benilla/assets/ui/BuffFrame.xml +++ b/crates/benilla/assets/ui/BuffFrame.xml @@ -27,6 +27,13 @@ reference's "until cancelled". So `expirationTime > 0` is our untilCancelled test: a timed aura counts down and flashes under 31s; a permanent one shows no timer and never flashes. + THE COUNTDOWN IS A POLL, NOT A CACHE. The aura EVENT is the discrete-change edge (icon, stacks, + dispel class); the remaining time is re-read from UnitBuff/UnitDebuff in OnUpdate, every frame, + by every visible button. That is the reference's own division of labour — BuffButton_Update + caches the buff INDEX on PLAYER_AURAS_CHANGED, BuffButton_OnUpdate calls GetPlayerBuffTimeLeft + with it each frame (ref BuffFrame.lua l.128-130) — and caching the expiry on the event instead + is what made refreshed buffs freeze at "0 s" and freshly cast ones show no timer (0846). + RIGHT-CLICK CANCEL. RegisterForClicks("RightButtonUp") + CancelUnitBuff (the Era name for the reference's CancelPlayerBuff) — resolved within the button's own filter so a debuff button's id never resolves against the buff list. The server cancels by spell id and refuses anything the @@ -127,18 +134,23 @@ end end - -- Pull the button's aura (Nth of its filter, in the app's display order) and repaint the - -- discrete parts: icon, stack count, debuff-border tint. Stash the expiration for the - -- per-frame countdown. No aura ⇒ hide the button (which hides its icon/border with it) and - -- blank its duration string. - function BenillaBuffButton_Update(button) + -- The button's aura: the Nth of its own filter, in the app's display order. The ONE lookup + -- both handlers use — the ref's BuffButton_Update and BuffButton_OnUpdate likewise resolve + -- the same buffIndex, one on the event and one per frame. + function BenillaBuffButton_Aura(button) local id = button:GetID() - local name, icon, count, debuffType, duration, expirationTime if button.buffFilter == "HARMFUL" then - name, icon, count, debuffType, duration, expirationTime = UnitDebuff("player", id) - else - name, icon, count, debuffType, duration, expirationTime = UnitBuff("player", id) + return UnitDebuff("player", id) end + return UnitBuff("player", id) + end + + -- Pull the button's aura and repaint the discrete parts: icon, stack count, debuff-border + -- tint. Those are what the aura-change event exists for. The countdown is NOT cached here — + -- see BenillaBuffButton_OnUpdate. No aura ⇒ hide the button (which hides its icon/border + -- with it) and blank its duration string. + function BenillaBuffButton_Update(button) + local name, icon, count, debuffType = BenillaBuffButton_Aura(button) local durationText = getglobal(button:GetName() .. "Duration") if not name then @@ -150,7 +162,6 @@ button:Show() getglobal(button:GetName() .. "Icon"):SetTexture(icon or BENILLA_FALLBACK_ICON) - button.expirationTime = expirationTime -- Debuff border, tinted by dispel class (harmful buttons only — helpful ones have no -- $parentBorder, so getglobal is nil and we skip it). @@ -169,13 +180,24 @@ end end - -- Per-frame: count the stashed expiration down against GetTime(), flash in the last 31s, and - -- write the abbreviated timer (highlight-white under 60s). expirationTime == 0 is a permanent - -- aura (no duration on the wire) — our "until cancelled": no timer, no flash. + -- Per-frame: RE-READ the expiration (never a cached one), count it down against GetTime(), + -- flash in the last 31s, and write the abbreviated timer (highlight-white under 60s). + -- expirationTime == 0 is a permanent aura (no duration on the wire) — our "until + -- cancelled": no timer, no flash. + -- + -- The re-read every frame is the reference's own shape, and it is load-bearing: ref + -- BuffFrame.lua l.128-130 calls GetPlayerBuffTimeLeft(this.buffIndex) inside + -- BuffButton_OnUpdate, caching only the INDEX on PLAYER_AURAS_CHANGED. A duration that + -- changes without any discrete change — a recast, a DoT reapplied, cast pushback, the + -- server's replay of every duration at world entry — fires no aura event, so a button that + -- cached its expiry on the event would keep counting the OLD one down, sit at "0 s" while + -- the buff is still live, and never show a timer for an aura whose duration landed after + -- its icon did. Both were live defects; the poll is what makes them unrepresentable + -- (decision 0846). function BenillaBuffButton_OnUpdate(button) local durationText = getglobal(button:GetName() .. "Duration") - local expirationTime = button.expirationTime - if not expirationTime or expirationTime <= 0 then + local name, _, _, _, _, expirationTime = BenillaBuffButton_Aura(button) + if not name or not expirationTime or expirationTime <= 0 then button:SetAlpha(1.0) durationText:SetText("") return diff --git a/crates/benilla/assets/ui/ChatFrame.xml b/crates/benilla/assets/ui/ChatFrame.xml index 2e21ba97..de82f50e 100644 --- a/crates/benilla/assets/ui/ChatFrame.xml +++ b/crates/benilla/assets/ui/ChatFrame.xml @@ -186,6 +186,18 @@ end end + -- A completed left click on the chat body dismisses a held SPELL/ACTION payload + -- (decision 0843, the director's call — no ref counterpart: the reference keeps a + -- non-item payload on any frame click). Items are deliberately excluded: an item + -- dismissal is a destroy, which only the world-drop popup flow may offer. + function BenillaChatFrame_DismissCursor(button) + if button ~= "LeftButton" then return end + local kind = GetCursorInfo() + if kind == "spell" or kind == "action" then + ClearCursor() + end + end + -- MessageFrameScrollButton_OnLoad/_OnUpdate + the ref OnClick pair: a press arms the -- hold-repeat (initial delay 0), each 0.05s while held scrolls WITHOUT the sound; the -- release click scrolls once WITH the sound (ref FloatingChatFrame.xml's DownButton/ @@ -533,6 +545,9 @@ -- WoW wheel convention: +1 per notch up, -1 down. Up = older messages. if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end + + BenillaChatFrame_DismissCursor(arg1) + SetItemRef(arg1, arg2, arg3) @@ -562,6 +577,9 @@ if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end + + BenillaChatFrame_DismissCursor(arg1) + SetItemRef(arg1, arg2, arg3) diff --git a/crates/benilla/src/assets/mod.rs b/crates/benilla/src/assets/mod.rs index e422151f..0dbc0e0e 100644 --- a/crates/benilla/src/assets/mod.rs +++ b/crates/benilla/src/assets/mod.rs @@ -383,8 +383,8 @@ impl WorldAssets { base, extension: WowModelExt { clutter_fade, - // x = WMO (FFP N·L × MOCV, not SH); y = distance-fade blend variant (depth-write-on + - // stable cutout via `specialize`/the shader — see WowModelKey). + // x = WMO (FFP N·L × MOCV, not SH); y = distance-fade blend variant (depth-write-on + // via `specialize` — see WowModelKey; no live caller passes it here). model_flags: Vec4::new( if is_wmo { 1.0 } else { 0.0 }, if is_fade_variant { 1.0 } else { 0.0 }, diff --git a/crates/benilla/src/aura_visual.rs b/crates/benilla/src/aura_visual.rs index d216e5fe..8f6c295e 100644 --- a/crates/benilla/src/aura_visual.rs +++ b/crates/benilla/src/aura_visual.rs @@ -72,12 +72,17 @@ //! 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. +//! The tint reaches a unit **whole** — the reference's `0x714000` recursion, where an attached model +//! composes onto its parent CM2's computed colours — by two routes, because benilla has two kinds of +//! attached model: +//! +//! - the ordinary one **borrows its wearer's slot**: an item's parts, cards and boneless geosets all +//! spawn carrying the unit's instance bits (decision 0820), so they read the unit's table entry; +//! - a **rigged** one carries its OWN slot, because the vertex stage indexes the skin palette with +//! that same field and cannot borrow it (a spell-effect instance; since 0841, the seven shoulder +//! models that weld geometry to a billboard bone). [`chained_tint`] walks such an instance's +//! [`crate::model_fade::ParentModel`] link up to the unit — the recursion itself, and the same walk +//! `ModelAlphas` already does for alpha. //! //! 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 @@ -223,15 +228,16 @@ impl AuraNodes { /// 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)>, + rigs: Query<(Entity, &crate::rig_palette::RigSkin)>, + chain: Query<(Option<&AuraNodes>, Option<&crate::model_fade::ParentModel>)>, mut tints: ResMut, mut probe_logged: Local, ) { let probe = tint_probe(); let mut painted = 0usize; - for (nodes, rig) in &rigs { + for (root, rig) in &rigs { let word = probe - .or_else(|| nodes.and_then(AuraNodes::head_tint)) + .or_else(|| chained_tint(&chain, root)) .map_or(crate::instance_tint::IDENTITY, crate::instance_tint::pack); tints.set(rig.slot, word); painted += 1; @@ -242,6 +248,32 @@ pub(crate) fn apply_aura_tint( } } +/// The tint a rig instance renders with: its own head node, else the nearest one **up its +/// [`crate::model_fade::ParentModel`] chain** — the reference's `0x714000` recursion, which composes +/// an attached model's colours onto its parent's computed ones (the same walk [`ModelAlphas`] does +/// for alpha, and the same law `mesh_tag` cites for why an item's parts carry their wearer's slot). +/// +/// It exists because a rigged **item** carries its own instance slot rather than its wearer's +/// (decision 0841 — the vertex stage indexes the palette with that field, so it cannot be borrowed): +/// without the walk, a dwarf's Stoneform tint would stop at exactly the pauldrons the rig was added +/// for. A despawned link ends the walk. +fn chained_tint( + chain: &Query<(Option<&AuraNodes>, Option<&crate::model_fade::ParentModel>)>, + instance: Entity, +) -> Option<[u8; 3]> { + let mut at = instance; + for _ in 0..crate::model_fade::MAX_MODEL_CHAIN { + let Ok((nodes, parent)) = chain.get(at) else { + return None; + }; + if let Some(rgb) = nodes.and_then(AuraNodes::head_tint) { + return Some(rgb); + } + at = parent?.0; + } + None +} + /// `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 diff --git a/crates/benilla/src/aura_visual/tests.rs b/crates/benilla/src/aura_visual/tests.rs index f05ffeff..830edb59 100644 --- a/crates/benilla/src/aura_visual/tests.rs +++ b/crates/benilla/src/aura_visual/tests.rs @@ -591,6 +591,95 @@ fn the_ghost_tint_reaches_the_instance_table_and_clears_with_the_aura() { assert_eq!(tints.get(slot), crate::instance_tint::IDENTITY); } +/// **The chain (decision 0841).** A rigged ATTACHED model — a spell-effect instance, or one of the +/// seven shoulder models whose welded billboard geometry made the item lane rig — carries its own +/// instance slot, because the vertex stage indexes the skin palette with that same field and cannot +/// borrow the wearer's. Its tint therefore has to come up the `ParentModel` link, which is the +/// reference's own route for an attached model's colours (`0x714000`). Without this walk, adding the +/// rig would have un-tinted exactly the pauldrons it was added for. +#[test] +fn a_rigged_attachment_inherits_its_wearers_tint_through_the_model_chain() { + let mut app = app_with_tint_channel(); + let (body, shoulder) = { + let mut palettes = app + .world_mut() + .resource_mut::(); + let mut alloc = |bones| { + crate::rig_palette::RigSkin::allocate_bones(&mut palettes, bones, Handle::default()) + .expect("a fresh palette has room") + }; + (alloc(8), alloc(3)) + }; + let (body_slot, shoulder_slot) = (body.slot, shoulder.slot); + assert_ne!(body_slot, shoulder_slot, "two rigs, two slots"); + let unit = app + .world_mut() + .spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])), + body, + )) + .id(); + // The item root: its own rig, no aura nodes of its own, chained to the unit wearing it. + app.world_mut() + .spawn((shoulder, crate::model_fade::ParentModel(unit))); + + app.update(); + let tints = app + .world() + .resource::(); + assert_eq!(tints.get(body_slot), 0xff8c_b9fd, "the body is ghost-blue"); + assert_eq!( + tints.get(shoulder_slot), + 0xff8c_b9fd, + "and so is the pauldron riding it" + ); + + // The aura drops: both ends of the chain go back to identity, not just the unit's own slot. + app.world_mut() + .entity_mut(unit) + .insert(crate::net::ObjectStore(ObjectFields::from_pairs(&[( + 95, 0x00, + )]))); + app.update(); + let tints = app + .world() + .resource::(); + assert_eq!(tints.get(body_slot), crate::instance_tint::IDENTITY); + assert_eq!(tints.get(shoulder_slot), crate::instance_tint::IDENTITY); +} + +/// An unchained rig — a placed doodad, a world effect — has no parent to inherit from and must stay +/// its own colour. The walk's terminating case, and the guard against a chain lookup that +/// "helpfully" falls back to some other unit's tint. +#[test] +fn an_unchained_rig_inherits_nothing() { + let mut app = app_with_tint_channel(); + let (body, loose) = { + let mut palettes = app + .world_mut() + .resource_mut::(); + let mut alloc = |bones| { + crate::rig_palette::RigSkin::allocate_bones(&mut palettes, bones, Handle::default()) + .expect("a fresh palette has room") + }; + (alloc(8), alloc(3)) + }; + let loose_slot = loose.slot; + app.world_mut().spawn(( + crate::net::ObjectStore(ObjectFields::from_pairs(&[(47, GHOST), (95, 0x0E)])), + body, + )); + app.world_mut().spawn(loose); + app.update(); + assert_eq!( + app.world() + .resource::() + .get(loose_slot), + crate::instance_tint::IDENTITY, + "a tinted unit standing next to it changes nothing" + ); +} + /// A tinted unit going away must not leave its colour on the slot for whoever allocates it next — /// the leak that would paint an unrelated creature ghost-blue. Covered structurally by `RigSkin`'s /// free hook, so it holds for a despawn AND for a gear/display rebuild that replaces the component. diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index eb144dc8..e5a2f4e5 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -1131,7 +1131,7 @@ fn fire_particle_census( d.blend, d.flags, rate_keys.join(","), - d.lifespan, + d.params.sample(None, 0.0).lifespan, d.texture.as_deref().unwrap_or("-"), e.live(), // The frame's composed MODEL alpha (decision 0827/0833) — the number that answers diff --git a/crates/benilla/src/cursor.rs b/crates/benilla/src/cursor.rs index 34f893bb..b412c5ed 100644 --- a/crates/benilla/src/cursor.rs +++ b/crates/benilla/src/cursor.rs @@ -408,6 +408,10 @@ mod macos { mut rects_disabled: Local, mut decode_failed: Local>, mut last_set: Local>, + // The raw pointer of the NSCursor we last `set` — the drift detector's baseline + // (`WOW_CURSOR_TRACE`). Kept alive by the `cursors`/`payload_cursors` caches, and only + // ever compared, never dereferenced. + mut last_ptr: Local, ) { let Some(cursors) = cursors else { return; @@ -445,11 +449,12 @@ mod macos { let stem = mode.0.stem(); // The held payload's cursor wins over the classified mode (falls back to the mode cursor // when nothing is held, or its icon hasn't resolved/decoded this frame). The KEY names what - // we chose, so the set below fires only on a real cursor change — `NSCursor::set` is a - // WindowServer round-trip that intermittently stalls the main thread for milliseconds, and - // asserting it unconditionally every frame was the single biggest line of the 0366 - // frame-time tail. `disableCursorRects` (below, once) is what makes the one-shot set safe: - // AppKit no longer reverts the cursor on mouse-move. + // we chose, so the set below fires only on a real cursor change or a detected drift — + // `NSCursor::set` is a WindowServer round-trip that intermittently stalls the main thread + // for milliseconds, and asserting it unconditionally every frame was the single biggest + // line of the 0366 frame-time tail. `disableCursorRects` (below, once) stops AppKit's + // cursor-RECT reconciliation (the every-mouse-move revert); the drift check in the set + // block below catches what that can't — the other in-process `set` callers. let key = held_icon .as_ref() .filter(|icon| payload_cursors.0.contains_key(icon.as_str())) @@ -483,10 +488,35 @@ mod macos { } else if !looking && *was_looking { NSCursor::unhide(); } - if !looking && last_set.as_deref() != Some(key.as_str()) { - if let Some(cursor) = cursor { - cursor.set(); - *last_set = Some(key); + if !looking { + // Assert on a key change OR on detected **drift**. The set is not fire-and-forget: + // other in-process actors — winit's AppKit view (tracking-area `cursorUpdate:` / + // `mouseEntered:` handlers) and AppKit's own activation resets — call `[NSCursor + // set]` with THEIR cursor (the arrow) at unpredictable moments, replacing ours. + // The mode cursors self-healed by accident (their `key` churns as the hover + // classification changes), but a held payload's key is CONSTANT for the whole + // carry, so one usurped set left the icon gone for good while the payload was + // still held — the "spell disappears from the cursor but is still stuck to it" + // bug (reproduced via WOW_CURSOR_TRACE; the drift fired with no input at all). + // `currentCursor` is the app-local top of the cursor stack — a plain ObjC read, + // no WindowServer round-trip — so checking it every frame is free, and `set` + // still fires only on change-or-drift, preserving the 0366 frame-time rule. + let drifted = *last_ptr != 0 + && Retained::as_ptr(&NSCursor::currentCursor()) as usize != *last_ptr; + if drifted || last_set.as_deref() != Some(key.as_str()) { + if let Some(cursor) = cursor { + cursor.set(); + *last_ptr = Retained::as_ptr(cursor) as usize; + // `WOW_CURSOR_TRACE=1` — the assert/usurp timeline instrument. + if std::env::var_os("WOW_CURSOR_TRACE").is_some() { + eprintln!( + "[cursor-trace] set {key} ({:#x}){}", + *last_ptr, + if drifted { " [healed drift]" } else { "" } + ); + } + *last_set = Some(key); + } } } } diff --git a/crates/benilla/src/death.rs b/crates/benilla/src/death.rs index fa71cfd6..af60805e 100644 --- a/crates/benilla/src/death.rs +++ b/crates/benilla/src/death.rs @@ -157,7 +157,10 @@ fn feed_death( self_q: Query<(&ObjectStore, &Transform), With>, self_guid: Res, death_net: Res, - time: Res