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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-31 17:01:51 +07:00
co-authored by Claude Fable 5
parent ab65981ebf
commit 59b4a4f4ce
46 changed files with 1677 additions and 409 deletions
@@ -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 {}",
+5 -4
View File
@@ -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,
);
}
@@ -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 -- <WoW/Data> <internal\path.m2>`
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 <data> <path.m2>");
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::<f32>() / v.len() as f32
)
};
let seam_d: Vec<f32> = 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(())
}
@@ -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<String> = 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()
};
@@ -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<String> = Vec::new();
let mut model_hovers: Vec<f32> = 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::<Vec<_>>()
.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)");
}
+207 -4
View File
@@ -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<Option<ScalarAnim>>; 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<usize>, 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<Option<&[(f32, f32)]>>); 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]
+1 -1
View File
@@ -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,
@@ -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(),
+1
View File
@@ -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
+55 -18
View File
@@ -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<Billboard>,
/// 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<WmoBatchClass>,
}
/// 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<GroundQuad> {
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::<f32>() / 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))
}
}
+30 -49
View File
@@ -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<Vec<ParticleEmitterDef
tile_rows: tiles.0,
tile_cols: tiles.1,
head_tail: bytes[e + 0x2c],
emission_speed: track_first(bytes, e + 0x34, 0.0),
speed_variation: track_first(bytes, e + 0x50, 0.0),
vertical_range: track_first(bytes, e + 0x6c, 0.0),
horizontal_range: track_first(bytes, e + 0x88, 0.0),
gravity: track_first(bytes, e + 0xa4, 0.0),
lifespan: track_first(bytes, e + 0xc0, 1.0),
timing: EmitTiming::bake(
&read_raw_track(bytes, e + 0xdc, 4, le_f32),
&read_raw_track(bytes, e + 0x1dc, 1, |b, o| f32::from(b[o] != 0)),
&seq_slots,
&gseq,
),
area_length: track_first(bytes, e + 0xf8, 0.0),
area_width: track_first(bytes, e + 0x114, 0.0),
z_source: track_first(bytes, e + 0x130, 0.0),
// The nine parameter tracks, full-key ([`EmitParams`] field order — bases 0x1c apart,
// the emitter phase's own walk). NOT `value[0]`: several effects animate them.
params: EmitParams::bake(
[
&read_raw_track(bytes, e + 0x34, 4, le_f32), // speed
&read_raw_track(bytes, e + 0x50, 4, le_f32), // speedVar
&read_raw_track(bytes, e + 0x6c, 4, le_f32), // latitude
&read_raw_track(bytes, e + 0x88, 4, le_f32), // longitude
&read_raw_track(bytes, e + 0xa4, 4, le_f32), // gravity
&read_raw_track(bytes, e + 0xc0, 4, le_f32), // lifespan
&read_raw_track(bytes, e + 0xf8, 4, le_f32), // areaLength
&read_raw_track(bytes, e + 0x114, 4, le_f32), // areaWidth
&read_raw_track(bytes, e + 0x130, 4, le_f32), // zSource
],
&seq_slots,
&gseq,
),
drag: le_f32(bytes, e + 0x194),
tail_time: le_f32(bytes, e + 0x17c),
angular_velocity_min: le_vec3(bytes, e + 0x19c),
@@ -984,9 +964,10 @@ mod tests {
let s = d.spline.as_ref().expect("chain parses");
assert_eq!(s.points.len() % 3, 1, "3K+1 control points");
assert!(s.points.len() >= 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!(
@@ -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<f32> = 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");
@@ -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,
+16 -10
View File
@@ -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
);
+53 -51
View File
@@ -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<CursorPayload> {
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 {
+9 -4
View File
@@ -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<f32>(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.)
+37 -15
View File
@@ -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
+18
View File
@@ -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
</OnMouseWheel>
<OnClick>
BenillaChatFrame_DismissCursor(arg1)
</OnClick>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
@@ -562,6 +577,9 @@
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnClick>
BenillaChatFrame_DismissCursor(arg1)
</OnClick>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
+2 -2
View File
@@ -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 },
+41 -9
View File
@@ -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<crate::instance_tint::InstanceTints>,
mut probe_logged: Local<bool>,
) {
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
+89
View File
@@ -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::<crate::rig_palette::RigPalettes>();
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::<crate::instance_tint::InstanceTints>();
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::<crate::instance_tint::InstanceTints>();
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::<crate::rig_palette::RigPalettes>();
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::<crate::instance_tint::InstanceTints>()
.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.
+1 -1
View File
@@ -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
+39 -9
View File
@@ -408,6 +408,10 @@ mod macos {
mut rects_disabled: Local<bool>,
mut decode_failed: Local<HashSet<String>>,
mut last_set: Local<Option<String>>,
// 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<usize>,
) {
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);
}
}
}
}
+4 -1
View File
@@ -157,7 +157,10 @@ fn feed_death(
self_q: Query<(&ObjectStore, &Transform), With<SelfPlayer>>,
self_guid: Res<SelfGuid>,
death_net: Res<DeathNet>,
time: Res<Time>,
// Real, not virtual: this reads a stamp the net apply path wrote on `Time<Real>` (see the
// aura tuple there) — a server-sent countdown lives in real seconds, and the two ends of the
// comparison must be the same clock.
time: Res<Time<Real>>,
mut feed: ResMut<DeathFeedState>,
mut names: ResMut<crate::names::NameCache>,
net: Res<NetCommands>,
@@ -223,9 +223,11 @@ pub(super) type CharSkinMaterials = (
/// Build the (steady, interior-matte, fade-blend, interior-bake, interior-bake-blend) variants for
/// one character texture at one blend + sidedness. The body is opaque, hair is alpha-cut (its own
/// blend) — so the caller passes the blend; the fade variants are always `Blend` (the feathers ramp
/// alpha; the bake-blend keeps the probe light through a feather — 0355). All sky-lit;
/// `model_material` dedups them by (texture, blend, sidedness, …).
/// blend) — so the caller passes the blend, and the fade variants carry it too as the SOURCE blend
/// (`model_material` builds them `AlphaMode::Blend` regardless; the source decides the twin's
/// 224/255 cutout marker — decision 0842. The feathers ramp alpha; the bake-blend keeps the probe
/// light through a feather — 0355). All sky-lit; `model_material` dedups them by (texture, blend,
/// sidedness, …).
fn build_char_materials(
tex: Handle<Image>,
blend: ModelBlend,
@@ -236,13 +238,14 @@ fn build_char_materials(
) -> MatQuint {
// The depth-prime twin (decision 0831): body/hair batches are opaque/alpha-cut and always
// z-writing, so every character part twins; `cutout` mirrors the colour twin's 224/255
// discard exactly as the model-built parts' twins do. Built before `mk` captures the caches.
// discard exactly as the model-built parts' twins do — only an AlphaKey source alpha-tests
// while fading (decision 0842). Built before `mk` captures the caches.
let zfill = crate::model_render::zfill_material(
cache,
materials,
Some(tex.clone()),
two_sided,
blend != ModelBlend::Blend,
blend == ModelBlend::AlphaTest,
light,
);
let mut mk = |blend, shade: ShadeSel, fade, probe| {
@@ -282,9 +285,9 @@ fn build_char_materials(
(
mk(blend, ShadeSel::Lit, false, false),
mk(blend, ShadeSel::Matte, false, false),
mk(ModelBlend::Blend, ShadeSel::Lit, true, false),
mk(blend, ShadeSel::Lit, true, false),
mk(blend, ShadeSel::Matte, false, true),
mk(ModelBlend::Blend, ShadeSel::Matte, true, true),
mk(blend, ShadeSel::Matte, true, true),
zfill,
)
}
+10 -5
View File
@@ -457,8 +457,9 @@ pub(super) fn build_parts(
// The depth-prime twin (wow-re `m2-blend-promotion-zfill.md` §4): every batch
// that can fade AND writes depth gets one. `cutout` mirrors what the part's
// colour pass discards while translucent — the fade twin's hard 224/255 for
// Opaque/AlphaKey sources, nothing for authored-Blend sources (their colour
// pass is the plain blend material) — so depth and colour coverage agree.
// AlphaKey sources only (an Opaque source never alpha-tests, steady or
// promoted — §2; decision 0842), nothing for authored-Blend sources (their
// colour pass is the plain blend material) — so depth and colour coverage agree.
let zfill = if sub.no_depth_write || sub.no_depth_test {
None
} else {
@@ -469,7 +470,7 @@ pub(super) fn build_parts(
materials,
texture.clone(),
sub.two_sided,
b != ModelBlend::Blend,
b == ModelBlend::AlphaTest,
light,
)),
}
@@ -477,11 +478,14 @@ pub(super) fn build_parts(
let fade_blend = match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
ModelBlend::Blend => Some(exterior.clone()),
// The SOURCE blend rides into the twin (Opaque or AlphaKey here): with
// fade_variant it still builds AlphaMode::Blend, but the source decides
// the twin's 224/255 cutout marker (decision 0842).
_ => Some(model_material(
cache,
materials,
texture.clone(),
ModelBlend::Blend,
sub.blend,
sub.two_sided,
false,
false, // exterior
@@ -540,11 +544,12 @@ pub(super) fn build_parts(
match sub.blend {
ModelBlend::Mod | ModelBlend::Mod2x => None,
ModelBlend::Blend => Some(bake.clone()),
// Source blend through to the twin, like the exterior twin above (0842).
_ => Some(model_material(
cache,
materials,
texture.clone(),
ModelBlend::Blend,
sub.blend,
sub.two_sided,
false,
true, // interior-PROP mode — the probe lane
+142 -5
View File
@@ -127,11 +127,11 @@ pub(in crate::entities) fn attach_held_items(
// writes (`entities::attach`), and the held effects' draw-order rung is measured in the
// world yards that scale produces.
Option<&Transform>,
// The WEARER's rig, for its instance slot: every part spawned below carries it in its tag so
// the wearer's body tint reaches its helm, shoulders and held items (decision 0812 — the
// The WEARER's rig, for its instance slot: an item's parts carry it in their tag so the
// wearer's body tint reaches its helm, shoulders and held items (decision 0812 — the
// reference's attached models inherit the parent CM2's computed colours, `0x714000`). Never
// used to skin these parts: they are static meshes, and the vertex stage's slot read is gated
// on the mesh's own joint attributes.
// used to SKIN those parts: they draw the static mesh, and the vertex stage's slot read is
// gated on the mesh's own joint attributes — an item model spawns no rig at all (0847).
Option<&crate::rig_palette::RigSkin>,
)>,
held: Option<Res<ItemDisplays>>,
@@ -249,7 +249,7 @@ fn spawn_slot(
hs: &HeldSlot,
) -> Option<Entity> {
let entity = ctx.wearer;
let (bones, joined, now, rig_tag) = (ctx.bones, ctx.joined, ctx.now, ctx.rig_tag);
let (bones, joined, now) = (ctx.bones, ctx.joined, ctx.now);
let dm = held.models.get(&(hs.display, hs.kind))?;
let parts = dm.parts.as_ref()?;
// Body model has no such attach point (a non-character skeleton) — hold nothing.
@@ -301,6 +301,15 @@ fn spawn_slot(
});
}
}
// **An item model spawns no rig** — including the seven displays whose geometry is welded to a
// billboard bone. 0841 gave those a joint palette so the reference's per-vertex blend would
// bend them; the director reported the result and the measurement backed them, so it was
// WITHDRAWN (decision 0847). The welded flap is a 0.29 yd spike hinged on a pivot that sits in
// the plate, and a spherical billboard re-orients it outright — so for a wide band of camera
// angles it sweeps *through* the plate it is welded to, and the two opaque surfaces z-fight
// along a seam that moves with every step. Whole and still is the shipped look; the open
// question is 0847's, not a knob here.
let rig_tag = ctx.rig_tag;
// Billboard batches (the torch's glow card) collected for the world-root card spawn
// below — as plain children they'd render at the item root (the grip), not the
// authored pivot (the torch head). Decision 0153.
@@ -603,6 +612,105 @@ mod tests {
}
}
/// A mesh handle distinguishable from the static form's `Handle::default()` — so "which of the
/// two forms did the part actually spawn with" is an assertion rather than a guess.
fn skinned_handle() -> Handle<Mesh> {
Handle::Uuid(
bevy::asset::uuid::Uuid::from_u128(0x5c1_11ed),
std::marker::PhantomData,
)
}
/// Spawn a wearer with one SHOULDER slot whose display carries **everything a rig would need** —
/// a billboard-bearing skeleton, bind poses, and a skinned mesh form distinct from the static one
/// — and run the attach. Returns the part tags, the wearer's own instance slot, the item root's
/// palette slot (`None` when it spawned no rig, which is the law), and the mesh the part drew.
fn attach_a_shoulder() -> (Vec<u32>, u16, Option<u16>, Option<Handle<Mesh>>) {
const KIND: ItemModelKind = ItemModelKind::ShoulderLeft;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<crate::rig_palette::RigPalettes>();
let mut displays = ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(HashMap::new()),
);
let mut dm = empty_display();
let mut p = part(false);
p.skinned_mesh = Some(skinned_handle());
dm.parts = Some(vec![p]);
// The shoulder's own skeleton: a root plus one spherical-billboard spike, the shape
// `LShoulder_Plate_PVPAlliance_A_01` authors. Both halves are needed — the gate is
// "welded geometry AND a skeleton to pose it with".
dm.skeleton = benilla_assets::ModelSkeleton {
joints: vec![
benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
ignore_parent_rotation: false,
},
benilla_assets::ModelJoint {
parent: 0,
local_translation: Vec3::Y,
billboard: Some(benilla_formats::BillboardKind::Spherical),
ignore_parent_rotation: false,
},
],
spine_bone: None,
head_bone: None,
};
dm.inverse_bindposes = Some(Handle::default());
displays.models.insert((7, KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::SHOULDER_LEFT, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
let mut items = HeldItems::default();
items.slots[4] = Some(HeldSlot {
display: 7,
kind: KIND,
attach: attach_id::SHOULDER_LEFT,
visual: NO_GLOW,
});
let skin = crate::rig_palette::RigSkin::allocate_bones(
app.world_mut()
.resource_mut::<crate::rig_palette::RigPalettes>()
.as_mut(),
8,
Handle::default(),
)
.expect("a fresh palette has room");
let wearer_slot = skin.slot;
app.world_mut()
.spawn((items, bones, Transform::default(), skin));
app.add_systems(Update, attach_held_items);
app.update();
let tags: Vec<u32> = app
.world_mut()
.query::<&MeshTag>()
.iter(app.world())
.map(|t| t.0)
.collect();
// The item root's rig is the one that is NOT the wearer's (the wearer holds its own).
let item_rig = app
.world_mut()
.query::<&crate::rig_palette::RigSkin>()
.iter(app.world())
.map(|r| r.slot)
.find(|&s| s != wearer_slot);
let mesh = app
.world_mut()
.query::<&Mesh3d>()
.iter(app.world())
.map(|m| m.0.clone())
.next();
(tags, wearer_slot, item_rig, mesh)
}
/// Spawn a wearer with one helm slot and run the attach. Returns the spawned parts' tags plus the
/// wearer's own instance slot (`0` when `rigged` is false — a boneless wearer).
fn attach_a_helm(rigged: bool, interior: bool) -> (Vec<u32>, u16) {
@@ -699,6 +807,35 @@ mod tests {
assert_eq!(crate::mesh_tag::rig_of(tags[0]), 0);
}
/// **The item lane spawns no rig (decision 0847).** 0841 gave the seven displays that weld
/// geometry to a billboard bone a joint palette, so the reference's per-vertex blend would bend
/// the flap; the director reported the result and the measurement backed them — the flap is a
/// 0.29 yd spike hinged on a pivot inside the plate, so a spherical billboard sweeps it *through*
/// the plate and the two opaque surfaces z-fight along a seam that moves with every step. It was
/// withdrawn, and the lane no longer consults weldedness at all.
///
/// Pinned on the input that used to trip it: a display carrying a billboard-bearing skeleton,
/// bind poses and a skinned mesh form still allocates **no** palette slot, keeps its WEARER's
/// instance slot (0812's tint route) and draws the **static** mesh. A re-land has to delete this
/// test, which is the point — 0847's open fidelity question gets answered first, rather than
/// rediscovered from a bug report.
#[test]
fn an_item_spawns_no_rig_even_with_everything_one_would_need() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_shoulder();
assert!(wearer_slot >= 1, "the wearer really has a rig of its own");
assert!(
item_rig.is_none(),
"no palette slot is allocated for an item"
);
assert_eq!(tags.len(), 1);
assert_eq!(
crate::mesh_tag::rig_of(tags[0]),
wearer_slot,
"the part indexes the WEARER's slot"
);
assert_eq!(mesh, Some(Handle::default()), "…and draws the static mesh");
}
/// **What a booth can see of an equipped item** (decision 0822, `#bugs` B118's paper-doll half).
/// An item model's camera-facing batch spawns as a world-ROOT card and its emitters as free
/// owner-followed entities — neither is a unit descendant, so the portrait / paper-doll booths,
+3 -2
View File
@@ -2,8 +2,9 @@
//! shared decal projector (`decal.rs`), after the selection ring and the unit blob shadow.
//!
//! A base-anchored effect model's flat ground-plane quads (Battle Shout's six crescents, the
//! paladin auras' rings — [`benilla_formats::GroundQuad`], 128 of the 139 flat batches under
//! `Spells\` per the `groundscan` sweep) author every vertex at z = 0 with depth-test ON: drawn as
//! paladin auras' rings, Consecration's and Flamestrike's hovering burn discs —
//! [`benilla_formats::GroundQuad`], the `groundscan` z=0 + hover populations) author every
//! vertex on one ground-level plane (z = 0, or hovering fractions of a yard above it) with depth-test ON: drawn as
//! free geometry they are buried per-pixel by the first up-slope and float over the first
//! down-slope. The real 1.12 client draws them exactly that way (standard M2 batch pipeline —
//! no ground conform exists in the spell-visual chain); this lane is a deliberate modern
+4 -3
View File
@@ -175,9 +175,10 @@ pub struct ModelAlpha(pub f32);
#[derive(Component, Clone, Copy, Debug, PartialEq)]
pub struct ParentModel(pub Entity);
/// How far [`ModelAlphas`] follows [`ParentModel`] before giving up. The real chains are 1–3 links
/// (unit → item → glow); the bound is a cycle backstop, not a policy.
const MAX_MODEL_CHAIN: usize = 8;
/// How far a [`ParentModel`] walk goes before giving up — [`ModelAlphas`], and the tint's twin walk
/// in [`crate::aura_visual`]. The real chains are 1–3 links (unit → item → glow); the bound is a
/// cycle backstop, not a policy.
pub(crate) const MAX_MODEL_CHAIN: usize = 8;
/// The composed render alpha of a model instance — [`ModelAlpha`] multiplied along the
/// [`ParentModel`] chain, i.e. the reference's `0x714000` recursion as a lookup. This is the number
+52 -15
View File
@@ -128,6 +128,14 @@ pub(crate) type MaterialCache = crate::art_scope::SpatialCache<MatKey, Handle<Wo
/// marks the `AlphaMode::Blend` twin every feather pass rides — the doodad distance fade AND the
/// entity appear/despawn/aura fades (`entities::display` builds its twins with it too); steady
/// materials pass `false`. `specialize` keys depth-write-ON on this bit for the twin.
///
/// A twin builder passes the **SOURCE batch's blend mode** (never `ModelBlend::Blend`): the twin
/// always blends — the reference's promotion is transparent-pass membership, not a per-mode state
/// (`m2-blend-promotion-zfill.md` §1) — but the alpha TEST under promotion keys on the *stored*
/// blend mode (§2): AlphaKey keeps its 224/255 cutout while blending, Opaque runs with no alpha
/// test at all. The source blend is what sets [`TWIN_CUTOUT_MARKER`] right (decision 0842: a twin
/// built as `Blend` cut every texel under 224/255 out of a stealthed Opaque batch, which erased
/// Gressil's blade body and left only its high-alpha rune pattern).
#[allow(clippy::too_many_arguments)]
pub(crate) fn model_material(
cache: &mut MaterialCache,
@@ -187,17 +195,23 @@ pub(crate) fn model_material(
// additive marker is `clutter_fade.z` bit 2 — the shader gates the gamma-premultiply on the
// SAME bit specialize keys on (a stale "model_flags.w == 2.0" claim here once desynced the
// two: blend went pure-add while the premultiply never fired — the flat-square regression).
let alpha_mode = if is_additive {
// `WOW_NO_ALPHATEST=1` draws every cutout batch opaque — the A/B for "is the alpha test
// itself discarding this surface?", which is otherwise indistinguishable in the pixels
// from the surface losing a depth test or never being submitted. (B38: the flip
// survives it unchanged, so the cutout is not what removes the awning.) It suppresses the
// fade twin's cutout marker too, so the A/B answers the same question mid-fade.
let source_cutout =
blend == ModelBlend::AlphaTest && std::env::var("WOW_NO_ALPHATEST").is_err();
let alpha_mode = if is_additive || fade_variant {
// A fade twin blends whatever its source blend is — the reference's promotion is
// transparent-pass membership (`m2-blend-promotion-zfill.md` §1); the source blend the
// caller passed decides only the cutout marker below.
AlphaMode::Blend
} else {
match blend {
ModelBlend::Opaque => AlphaMode::Opaque,
// `WOW_NO_ALPHATEST=1` draws every cutout batch opaque — the A/B for "is the alpha test
// itself discarding this surface?", which is otherwise indistinguishable in the pixels
// from the surface losing a depth test or never being submitted. (B38: the flip
// survives it unchanged, so the cutout is not what removes the awning.)
ModelBlend::AlphaTest if std::env::var("WOW_NO_ALPHATEST").is_ok() => AlphaMode::Opaque,
ModelBlend::AlphaTest => AlphaMode::Mask(VANILLA_ALPHA_KEY_REF),
ModelBlend::AlphaTest if source_cutout => AlphaMode::Mask(VANILLA_ALPHA_KEY_REF),
ModelBlend::AlphaTest => AlphaMode::Opaque,
// Mod/Mod2x ride the transparent pass (they multiply what's already drawn, so the
// scene under them must exist); `specialize` swaps the actual blend state to the
// byte-verified multiply factors via the marker bits below (decision 0528).
@@ -252,6 +266,12 @@ pub(crate) fn model_material(
// Bits 7/8 = the MULTIPLY blends (decision 0528): `specialize` swaps the pipeline
// to the byte-verified factors — Mod DST_COLOR/ZERO, Mod2x DST_COLOR/SRC_COLOR
// (exact on the 0161 gamma lane: the framebuffer holds gamma, like the reference).
// Bit 10 = TWIN CUTOUT (decision 0842): this fade twin's SOURCE batch alpha-tests,
// so the shader re-applies the hard 224/255 cutout on the unfaded alpha (the
// reference's promoted-AlphaKey ALPHAREF = A×224 — the same fixed silhouette). An
// Opaque source sets no bit: the reference disables its alpha test outright, steady
// and promoted alike (`m2-blend-promotion-zfill.md` §2 keys ALPHAREF on the STORED
// blend mode, mode 0 → ref 0).
f32::from(
u16::from(no_depth_write)
| (u16::from(no_depth_test) << 1)
@@ -263,7 +283,8 @@ pub(crate) fn model_material(
) << 3)
| (u16::from(fog_policy as u8) << 4)
| (u16::from(blend == ModelBlend::Mod) << 7)
| (u16::from(blend == ModelBlend::Mod2x) << 8),
| (u16::from(blend == ModelBlend::Mod2x) << 8)
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER),
),
0.0,
),
@@ -350,6 +371,13 @@ pub(crate) fn model_material(
/// `clutter_fade.z` marker bit 9: this material is a depth-prime twin ([`zfill_material`]).
pub(crate) const ZFILL_MARKER: u16 = 1 << 9;
/// `clutter_fade.z` marker bit 10: this twin's SOURCE batch alpha-tests, so the shader re-applies
/// the hard 224/255 cutout while blending (`wow_model.wgsl`). Set from the source blend mode by
/// [`model_material`] (fade twins) and [`zfill_material`] (depth-prime twins); never on an
/// Opaque-source twin — the reference runs mode 0 with the alpha test disabled, steady and
/// promoted alike (`m2-blend-promotion-zfill.md` §2; decision 0842).
pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10;
/// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is
/// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all**
/// of a fading model's twins draw before **any** of its colour parts — the reference achieves the
@@ -369,10 +397,12 @@ pub(crate) const ZFILL_SORT_BIAS: f32 = -8.0;
/// depth-writing batches draws first, so every colour fragment behind the model's own nearest
/// surface fails the depth test — one blended layer everywhere, no self-overlap darkening.
///
/// `cutout` mirrors what the part's COLOUR pass discards, and must keep mirroring it: a twin that
/// writes depth where the colour pass discards leaves an invisible depth wall inside the cutout
/// holes. Opaque/AlphaKey sources ride the fade twin's hard 224/255 cutout (`model_flags.y`);
/// authored-Blend sources discard nothing (their colour pass is the plain blend material).
/// `cutout` = **the source batch alpha-tests** (AlphaKey). It mirrors what the part's COLOUR pass
/// discards, and must keep mirroring it: a twin that writes depth where the colour pass discards
/// leaves an invisible depth wall inside the cutout holes. Only an AlphaKey source rides the fade
/// twin's hard 224/255 cutout ([`TWIN_CUTOUT_MARKER`]); Opaque sources never alpha-test
/// (`m2-blend-promotion-zfill.md` §2/§4 — the reference's twin keeps mode 1's test, mode 0 has
/// none; decision 0842) and authored-Blend sources discard nothing either.
///
/// Everything that shapes only the *colour* (lighting lane, shade, fog, tint) is canonicalized so
/// twins dedupe maximally — the `WOW_ZFILL` fragment returns before any of it.
@@ -426,10 +456,17 @@ pub(crate) fn zfill_material(
let handle = materials.add(ExtendedMaterial {
base,
extension: WowModelExt {
clutter_fade: Vec4::new(0.0, 0.0, f32::from(ZFILL_MARKER), 0.0),
// `model_flags.y` (the fade-twin bit) drives the shader's hard 224/255 cutout — set
// Bit 10 (the twin-cutout marker) drives the shader's hard 224/255 cutout — set
// exactly when the colour pass discards, so depth and colour coverage agree.
model_flags: Vec4::new(0.0, if cutout { 1.0 } else { 0.0 }, 0.0, 0.0),
clutter_fade: Vec4::new(
0.0,
0.0,
f32::from(ZFILL_MARKER | if cutout { TWIN_CUTOUT_MARKER } else { 0 }),
0.0,
),
// Not a fade twin (`model_flags.y` = 0): the zfill pipeline branch forces its own
// depth-write, and the cutout rides bit 10 above, so the bit has no work here.
model_flags: Vec4::ZERO,
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
sidn: Vec4::ZERO,
+9 -1
View File
@@ -221,9 +221,17 @@ pub(super) fn apply_net_updates(
// self-only `SMSG_UPDATE_AURA_DURATION` lands here keyed by raw slot, timestamped for the
// `ui_aura` slot-join — plus the ping clock the Pong arm measures round trips against.
// Grouped as a tuple to stay under Bevy's 16-SystemParam ceiling.
//
// The stamp clock is `Time<Real>`, NOT the default virtual one, and both its readers
// (`aura_duration`, `corpse_reclaim_delay`) are the reason: a span the SERVER sends us counts
// down in real seconds, so it must be stamped and read on a real clock. `Time<Virtual>` clamps
// at 250 ms per frame (`max_delta`), which is right for simulation — a 2 s hitch must not
// teleport animations — and wrong here: every long frame would quietly ADD that much to every
// buff timer, so an aura would vanish with seconds still showing on its clock. Measured on a
// hitchy run: the virtual clock lost 20 s against real in 33 s (decision 0846).
mut aura: (
ResMut<crate::ui_aura::AuraDurations>,
Res<Time>,
Res<Time<Real>>,
Res<super::PingShared>,
Query<&mut super::UnitSpeeds>,
// The PlayAnimation call-order counter (`creature_anim::PlaySeq`): every
+8 -2
View File
@@ -80,6 +80,12 @@ struct Particle {
pos: Vec3,
vel: Vec3,
age: f32,
/// This particle's lifetime, captured at birth from the emitter's **current sampled**
/// lifespan channel (the reference passes it into each spawn as the kernels' `life_param` —
/// wow-re `part-shape-kernels.md`; the channel ANIMATES, e.g. Frost Nova 0.47 → 0.80 s, so a
/// shared `def.lifespan` is wrong twice over). Kill at `age >= life`; over-life ramps
/// normalize by it.
life: f32,
/// Spawn-time random phase for the twinkle LUT index (the reference hashes the particle's
/// pointer; same role — de-sync the flicker across particles).
phase: u32,
@@ -538,7 +544,7 @@ pub fn spawn_emitter(
// Gate on the rate track's PEAK over every sequence, not its first key: a one-shot burst
// emitter (the blood spurt's starflash/glowball, 0140 fold-back) keys `0 → 200 → 0` — value[0]
// is 0 but it absolutely emits.
if def.lifespan <= 0.0 || def.timing.peak_rate() <= 0.0 {
if def.params.peak_lifespan() <= 0.0 || def.timing.peak_rate() <= 0.0 {
return None; // emits nothing
}
let (host, seq) = match clock {
@@ -652,7 +658,7 @@ pub(crate) fn wire_child_emitters(
.take(4)
.filter_map(|em| {
let texture = em.texture.clone()?;
if em.def.lifespan <= 0.0 || em.def.timing.peak_rate() <= 0.0 {
if em.def.params.peak_lifespan() <= 0.0 || em.def.timing.peak_rate() <= 0.0 {
return None;
}
Some(ChildEmitter {
+1 -4
View File
@@ -172,10 +172,7 @@ pub(super) fn dump_emitter(
let half = particle_half(def, placement, p);
// The additive contribution's other factor: the over-life colour this particle's quad
// carries (raw authored values, as `expand_quads` pushes them).
let rgba = def
.over_life
.sample((p.age / def.lifespan).clamp(0.0, 1.0))
.color;
let rgba = def.over_life.sample((p.age / p.life).clamp(0.0, 1.0)).color;
let Some((ndc, viewz)) = project(center) else {
info!("PARTICLE_DEPTHDUMP f={fidx} p{i} behind the camera");
continue;
+64 -47
View File
@@ -50,7 +50,11 @@ pub(super) fn rand_s11(state: &mut u32) -> f32 {
/// an optional along-velocity scatter jitter — see the arm's comment.
/// - Any shape with `zSource ≠ 0`: direction is **radial from the pivot** `(0, 0, zSource)` toward
/// the birth point (fountains that arc outward from a point below the basin).
pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3) {
pub(super) fn emit_local(
def: &ParticleEmitterDef,
now: &benilla_formats::ParamsNow,
rng: &mut u32,
) -> (Vec3, Vec3) {
let origin = Vec3::from(def.position);
// The emitter-frame R(+Z, 90°) applied at every branch's return — see the law note at the
// tail. Per the wow-re bytes the prepend is per-EMITTER and subclass-independent ("gated
@@ -67,19 +71,19 @@ pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3
// falls through to the plane kernel, as before.
if let (benilla_formats::ParticleShape::Spline, Some(spline)) = (def.shape, &def.spline) {
let (t0, t1) = (
def.area_length.clamp(0.0, 1.0),
def.area_width.clamp(0.0, 1.0),
now.area_length.clamp(0.0, 1.0),
now.area_width.clamp(0.0, 1.0),
);
let t = t0 + rand01(rng) * (t1 - t0);
let mut pos = origin + Vec3::from(spline.eval(t));
let dir = if def.z_source != 0.0 {
(pos - origin - Vec3::new(0.0, 0.0, def.z_source)).normalize_or(Vec3::Z)
} else if def.vertical_range != 0.0 {
let dir = if now.z_source != 0.0 {
(pos - origin - Vec3::new(0.0, 0.0, now.z_source)).normalize_or(Vec3::Z)
} else if now.vertical_range != 0.0 {
let tangent = Vec3::from(spline.tangent(t)).normalize_or(Vec3::Z);
let (s, c) = (rand_s11(rng) * def.vertical_range).sin_cos();
let (s, c) = (rand_s11(rng) * now.vertical_range).sin_cos();
let dir = Vec3::Z * c + tangent.cross(Vec3::Z) * s + tangent * (tangent.z * (1.0 - c));
if def.horizontal_range != 0.0 {
pos += rand01(rng) * def.horizontal_range * dir;
if now.horizontal_range != 0.0 {
pos += rand01(rng) * now.horizontal_range * dir;
}
dir
} else {
@@ -93,9 +97,9 @@ pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3
// ZERO-radius sphere (the fireball impact's plume burst: min = max = 0) spraying uniformly
// instead of collapsing every direction to the degenerate normalize fallback.
let (local, shell) = if def.shape == benilla_formats::ParticleShape::Sphere {
let r = def.area_length + rand01(rng) * (def.area_width - def.area_length).max(0.0);
let lat = rand_s11(rng) * def.vertical_range;
let lon = rand_s11(rng) * def.horizontal_range;
let r = now.area_length + rand01(rng) * (now.area_width - now.area_length).max(0.0);
let lat = rand_s11(rng) * now.vertical_range;
let lon = rand_s11(rng) * now.horizontal_range;
let (slat, clat) = lat.sin_cos();
let (slon, clon) = lon.sin_cos();
let shell = Vec3::new(clat * clon, clat * slon, slat); // unit by construction
@@ -106,16 +110,16 @@ pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3
// rectangle's ORIENTATION but not its distribution; that is the whole bug.
(
Vec3::new(
rand_s11(rng) * 0.5 * def.area_length,
rand_s11(rng) * 0.5 * def.area_width,
rand_s11(rng) * 0.5 * now.area_length,
rand_s11(rng) * 0.5 * now.area_width,
0.0,
),
None,
)
};
let dir = if def.z_source != 0.0 {
let dir = if now.z_source != 0.0 {
// Radial from the (0, 0, zSource) pivot — degenerate at the pivot itself falls back to +Z.
(local - Vec3::new(0.0, 0.0, def.z_source)).normalize_or(Vec3::Z)
(local - Vec3::new(0.0, 0.0, now.z_source)).normalize_or(Vec3::Z)
} else if let Some(shell) = shell {
if def.sphere_up() {
Vec3::Z
@@ -123,8 +127,8 @@ pub(super) fn emit_local(def: &ParticleEmitterDef, rng: &mut u32) -> (Vec3, Vec3
shell // radial outward — the same unit draw as the birth point
}
} else {
let theta = rand_s11(rng) * def.vertical_range;
let phi = rand_s11(rng) * def.horizontal_range;
let theta = rand_s11(rng) * now.vertical_range;
let phi = rand_s11(rng) * now.horizontal_range;
let (st, ct) = theta.sin_cos();
let (sp, cp) = phi.sin_cos();
Vec3::new(st * cp, st * sp, ct)
@@ -146,7 +150,23 @@ pub(crate) mod tests {
use super::*;
use benilla_formats::{CellRamp, OverLife, ParticleShape};
/// A minimal def for kernel tests — only the fields [`emit_local`] reads matter.
/// The kernel-test parameter set (the values the old flattened def carried).
pub(crate) fn now() -> benilla_formats::ParamsNow {
benilla_formats::ParamsNow {
emission_speed: 1.0,
speed_variation: 0.0,
vertical_range: 0.5,
horizontal_range: std::f32::consts::PI,
gravity: 0.0,
lifespan: 1.0,
area_length: 2.0,
area_width: 4.0,
z_source: 0.0,
}
}
/// A minimal def for kernel tests — only the fields [`emit_local`] reads matter; the
/// sampled-parameter side is [`now`] (constant-baked into `params` for the sim tests).
pub(crate) fn def(shape: ParticleShape) -> ParticleEmitterDef {
ParticleEmitterDef {
flags: 0,
@@ -158,16 +178,8 @@ pub(crate) mod tests {
tile_rows: 1,
tile_cols: 1,
head_tail: 0,
emission_speed: 1.0,
speed_variation: 0.0,
vertical_range: 0.5,
horizontal_range: std::f32::consts::PI,
gravity: 0.0,
lifespan: 1.0,
timing: benilla_formats::EmitTiming::constant(10.0),
area_length: 2.0,
area_width: 4.0,
z_source: 0.0,
params: benilla_formats::EmitParams::constant(now()),
drag: 0.0,
tail_time: 0.0,
spline: None,
@@ -207,11 +219,12 @@ pub(crate) mod tests {
#[test]
fn plane_kernel_rect_bounds_and_symmetric_cone() {
let d = def(ParticleShape::Plane);
let n = now();
let mut rng = 12345u32;
let (mut neg, mut pos) = (false, false);
let (mut max_dx, mut max_dy) = (0.0f32, 0.0f32);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &mut rng);
let (p, dir) = emit_local(&d, &n, &mut rng);
assert!(
(p.x - 1.0).abs() <= 2.0 + 1e-4,
"x within ±½·width of position (post-R)"
@@ -246,9 +259,10 @@ pub(crate) mod tests {
#[test]
fn sphere_kernel_radius_bounds_and_radial_velocity() {
let d = def(ParticleShape::Sphere);
let n = now();
let mut rng = 99u32;
for _ in 0..256 {
let (p, dir) = emit_local(&d, &mut rng);
let (p, dir) = emit_local(&d, &n, &mut rng);
let rel = p - Vec3::new(1.0, 2.0, 3.0);
let r = rel.length();
assert!(
@@ -267,15 +281,16 @@ pub(crate) mod tests {
/// itself (`0x7b8fba` reuses the sincos pair), never a normalize of the (zero) birth offset.
#[test]
fn zero_radius_sphere_still_disperses() {
let mut d = def(ParticleShape::Sphere);
d.area_length = 0.0;
d.area_width = 0.0;
d.vertical_range = std::f32::consts::PI; // the MoltenBlast plume: full ±π latitude fan
d.horizontal_range = 0.0;
let d = def(ParticleShape::Sphere);
let mut n = now();
n.area_length = 0.0;
n.area_width = 0.0;
n.vertical_range = std::f32::consts::PI; // the MoltenBlast plume: full ±π latitude fan
n.horizontal_range = 0.0;
let mut rng = 4242u32;
let (mut up, mut down, mut fwd, mut back) = (false, false, false, false);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &mut rng);
let (p, dir) = emit_local(&d, &n, &mut rng);
assert_eq!(p, Vec3::new(1.0, 2.0, 3.0), "births at the centre");
assert!((dir.length() - 1.0).abs() < 1e-4, "unit direction");
assert_eq!(
@@ -307,14 +322,15 @@ pub(crate) mod tests {
x(2.0),
x(3.0), // one straight segment along +X
]);
d.area_length = 0.25; // tMin
d.area_width = 0.75; // tMax
d.vertical_range = 0.0;
d.z_source = 0.0;
let mut n = now();
n.area_length = 0.25; // tMin
n.area_width = 0.75; // tMax
n.vertical_range = 0.0;
n.z_source = 0.0;
let mut rng = 31u32;
// The R(+Z,90°) emitter frame (`emit_local`'s tail) turns the authored +X chain to +Y.
for _ in 0..64 {
let (p, dir) = emit_local(&d, &mut rng);
let (p, dir) = emit_local(&d, &n, &mut rng);
let local = p - Vec3::new(1.0, 2.0, 3.0); // minus the record position
assert!(
(0.75 - 1e-3..=2.25 + 1e-3).contains(&local.y),
@@ -325,11 +341,11 @@ pub(crate) mod tests {
assert_eq!(dir, Vec3::ZERO, "no spin, no zSource: the flame stands");
}
// A spin range fans +Z about the +X tangent — the kernel's YZ fan lands in XZ post-R.
d.vertical_range = 1.0;
d.horizontal_range = 0.5; // scatter
n.vertical_range = 1.0;
n.horizontal_range = 0.5; // scatter
let (mut low, mut high) = (false, false);
for _ in 0..256 {
let (p, dir) = emit_local(&d, &mut rng);
let (p, dir) = emit_local(&d, &n, &mut rng);
assert!(dir.y.abs() < 1e-4, "fan in the XZ plane (post-R)");
assert!((dir.length() - 1.0).abs() < 1e-4);
assert!(dir.z > 0.0, "±1 rad about +Z stays upward");
@@ -345,11 +361,12 @@ pub(crate) mod tests {
/// zSource ≠ 0 redirects any shape's velocity radially away from the (0, 0, zSource) pivot.
#[test]
fn z_source_pivots_the_velocity() {
let mut d = def(ParticleShape::Plane);
d.z_source = -1.0; // pivot below the emitter → births fly up-and-outward
let d = def(ParticleShape::Plane);
let mut n = now();
n.z_source = -1.0; // pivot below the emitter → births fly up-and-outward
let mut rng = 7u32;
for _ in 0..64 {
let (p, dir) = emit_local(&d, &mut rng);
let (p, dir) = emit_local(&d, &n, &mut rng);
let rel = (p - Vec3::new(1.0, 2.0, 3.0)) - Vec3::new(0.0, 0.0, -1.0);
assert!(rel.normalize().dot(dir) > 0.999, "radial from the pivot");
}
+1 -1
View File
@@ -165,7 +165,7 @@ pub(super) fn update_model_particles(
}
continue;
};
let u = (p.age / emitter.def.lifespan).clamp(0.0, 1.0);
let u = (p.age / p.life).clamp(0.0, 1.0);
// 3-D model particles carry no texture atlas — only colour and size reach them.
let ol = emitter.def.over_life.sample(u);
let (mut rgba, size) = (ol.color, ol.size);
+3 -2
View File
@@ -93,7 +93,7 @@ pub(super) fn draw_gated(def: &ParticleEmitterDef, p: &Particle) -> bool {
/// over-life samples for other attributes too. **If that expression changes, change this.**
pub(super) fn particle_half(def: &ParticleEmitterDef, placement: &Transform, p: &Particle) -> f32 {
let noise = twinkle_noise(def.twinkle_speed, p.age, p.phase);
let u_age = (p.age / def.lifespan).clamp(0.0, 1.0);
let u_age = (p.age / p.life).clamp(0.0, 1.0);
let scale = if def.scale_size_by_instance() {
placement.scale.x.max(1e-4)
} else {
@@ -166,7 +166,7 @@ pub(super) fn expand_quads(
if def.twinkle_percent < 1.0 && noise > def.twinkle_percent {
continue;
}
let u_age = (p.age / def.lifespan).clamp(0.0, 1.0);
let u_age = (p.age / p.life).clamp(0.0, 1.0);
let ol = def.over_life.sample(u_age);
let (mut rgba, size) = (ol.color, ol.size);
// The MODEL's render alpha, folded into the ALPHA channel only — exactly where and how the
@@ -326,6 +326,7 @@ mod tests {
pos: Vec3::ZERO,
vel: Vec3::ZERO,
age: 0.0,
life: 1.0,
phase: 0,
fresh: false,
quat: Quat::IDENTITY,
+58 -40
View File
@@ -17,7 +17,6 @@ use super::{
/// The emitter-constant inputs of one integrator step ([`integrate_particle`]).
struct StepEnv {
dt: f32,
lifespan: f32,
gravity: f32,
drag: f32,
anchored: bool,
@@ -40,7 +39,9 @@ struct StepEnv {
fn integrate_particle(p: &mut Particle, env: &StepEnv) -> bool {
let (dt, g) = (env.dt, env.gravity);
p.age += dt;
if p.age >= env.lifespan {
// Kill at the particle's OWN birth-sampled lifetime (the emitter's lifespan channel
// animates — decision 0844; the reference feeds each spawn the current value).
if p.age >= p.life {
return false;
}
// FOLLOW-DELTA (file 0x4000, wow-re `part-emitter-motion.md` §2): the shared per-frame
@@ -110,6 +111,7 @@ fn inherit_trigger(accum: &mut f32, held: &mut Vec3, dt: f32, delta: Vec3, live:
#[allow(clippy::too_many_arguments)] // the birth fold's full frame, same as the parent path
fn drive_child(
child: &mut ChildEmitter,
now: &benilla_formats::ParamsNow,
parent: &[Particle],
rate: f32,
emitting: bool,
@@ -132,10 +134,9 @@ fn drive_child(
);
while child.accumulator >= 1.0 && child.particles.len() < MAX_PARTICLES {
child.accumulator -= 1.0;
let (base, dir) = emit_local(&child.def, &mut child.rng);
let (base, dir) = emit_local(&child.def, now, &mut child.rng);
let local = base - origin;
let speed = child.def.emission_speed
* (1.0 + child.def.speed_variation * rand_s11(&mut child.rng));
let speed = now.emission_speed * (1.0 + now.speed_variation * rand_s11(&mut child.rng));
let fold = |v: Vec3| {
if anchored {
attach_inv
@@ -146,13 +147,14 @@ fn drive_child(
};
let mut vel = fold(dir * speed);
if child.def.inherits_emitter_motion() {
vel += (1.0 + child.def.speed_variation * rand_s11(&mut child.rng)) * p.vel;
vel += (1.0 + now.speed_variation * rand_s11(&mut child.rng)) * p.vel;
}
let phase = next_u32(&mut child.rng);
child.particles.push(Particle {
pos: p.pos + fold(local),
vel,
age: 0.0,
life: now.lifespan,
phase,
fresh: true,
quat: Quat::IDENTITY,
@@ -560,6 +562,34 @@ pub(super) fn simulate_particles(
);
}
// The sequence clock (`EmitTiming`, decision 0641's structure one channel over): a HOSTED
// emitter reads its host's live playing sequence + clip time each frame — the reference's
// `m2_animate` emitter phase samples the CURRENT sequence record, which is why a quest
// GameObject's explosion fires in its one-shot clips and reads OFF in every idle window
// (bug B27). A host with no live player yet keeps its last slot at that slot's opening
// pose. Pinned lanes (doodads, effect rigs, booths) run their slot on the spawn-age
// clock; the baked loops wrap a looping band and end-hold a clamped one.
let (clock_seq, elapsed_s) = match *host {
Some(h) => match hosts
.get(h)
.ok()
.and_then(|(p, a)| crate::doodad_anim::playing_seq(p, a))
{
Some((s, t)) => {
*seq = Some(s);
(Some(s), t)
}
None => (*seq, 0.0),
},
None => (*seq, *age),
};
// This frame's emitter PARAMETERS — the nine per-frame-sampled channels, on the same
// clock as the rate track (the reference's `m2_animate` emitter phase samples all ten;
// wow-re `part-emission-rate-animated.md` §1). Frost Nova rides its emission radius
// 0.19 → 13.2 yd out with the ring; Arcane Explosion 0 → 7.2 yd with the dome — births
// MUST read the frame's values, not `value[0]` (decision 0844).
let now = def.params.sample(clock_seq, elapsed_s);
// 1. Age + integrate the live pool. The verified vanilla integrator (`particle_integrate`
// @ 0x7b2680): pos += dt·v with the gravity term on the UP axis (up += dt·v_up − ½·g·dt²;
// v_up −= g·dt), then **drag**: v −= min(dt·drag, 1)·v (exponential velocity decay,
@@ -568,8 +598,9 @@ pub(super) fn simulate_particles(
// stay a ~0.06 yd flicker; without it the particle coasts 3.3 yd to the ceiling. The
// frame decides the up axis: WoW +Z in model mode, Bevy +Y in world mode (the math is
// frame-independent otherwise — the reference integrates world-space particles with the
// same kernel). Kill at age ≥ lifespan.
let g = def.gravity;
// same kernel). Kill at age ≥ the particle's birth-sampled life. Gravity is a LIVE
// per-frame emitter field (the integrator reads it each frame in the reference).
let g = now.gravity;
let drag = def.drag;
// Sphere KILL-OUTBOUND emitters: the emitter origin in the stored frame — `def.position`
// composed exactly like a birth (anchored: the live placement/attach fold; model mode:
@@ -586,7 +617,6 @@ pub(super) fn simulate_particles(
});
let env = StepEnv {
dt,
lifespan: def.lifespan,
gravity: g,
drag,
anchored,
@@ -613,27 +643,6 @@ pub(super) fn simulate_particles(
// one-shot effects' choreography (a 200 ms hand flash inside a 1.0 s clip; the impact
// model's six staggered windows). Live particles are untouched — they finish their
// lifespans exactly like the drain above.
// The sequence clock (`EmitTiming`, decision 0641's structure one channel over): a HOSTED
// emitter reads its host's live playing sequence + clip time each frame — the reference's
// `m2_animate` emitter phase samples the CURRENT sequence record, which is why a quest
// GameObject's explosion fires in its one-shot clips and reads OFF in every idle window
// (bug B27). A host with no live player yet keeps its last slot at that slot's opening
// pose. Pinned lanes (doodads, effect rigs, booths) run their slot on the spawn-age
// clock; `EmitTiming` wraps a looping band and end-holds a clamped one.
let (clock_seq, elapsed_s) = match *host {
Some(h) => match hosts
.get(h)
.ok()
.and_then(|(p, a)| crate::doodad_anim::playing_seq(p, a))
{
Some((s, t)) => {
*seq = Some(s);
(Some(s), t)
}
None => (*seq, 0.0),
},
None => (*seq, *age),
};
let emitting = !*draining && def.timing.emitting(clock_seq, elapsed_s);
let rate = def.timing.rate(clock_seq, elapsed_s);
let dist_lod =
@@ -652,9 +661,9 @@ pub(super) fn simulate_particles(
}
while *accumulator >= 1.0 && particles.len() < MAX_PARTICLES {
*accumulator -= 1.0;
let (base, dir) = emit_local(def, rng);
let (base, dir) = emit_local(def, &now, rng);
let speed =
def.emission_speed * (1.0 + def.speed_variation * (rand01(rng) * 2.0 - 1.0));
now.emission_speed * (1.0 + now.speed_variation * (rand01(rng) * 2.0 - 1.0));
// Anchored mode bakes the emitter's ROTATION + scale at birth (the reference's
// `0x7bca80`/`0x7bcb40` birth transforms) but stores relative to its position — the
// per-frame anchor supplies the translation at render; on an attached model the
@@ -690,7 +699,7 @@ pub(super) fn simulate_particles(
// flag): `vel += (1 + S11·speedVariation) · inherit`, its own S11 draw, in the
// stored frame (the block runs after the reference's space fold).
let vel = if def.inherits_emitter_motion() && *inherit_vel != Vec3::ZERO {
vel + (1.0 + def.speed_variation * rand_s11(rng))
vel + (1.0 + now.speed_variation * rand_s11(rng))
* to_stored(*inherit_vel, attach_inv, placement)
} else {
vel
@@ -735,6 +744,7 @@ pub(super) fn simulate_particles(
pos,
vel,
age: 0.0,
life: now.lifespan,
phase,
fresh: true,
quat,
@@ -751,8 +761,10 @@ pub(super) fn simulate_particles(
for child in children.iter_mut() {
let c_emitting = !*draining && child.def.timing.emitting(None, *age);
let c_rate = child.def.timing.rate(None, *age);
let c_now = child.def.params.sample(None, *age);
drive_child(
child,
&c_now,
particles,
c_rate,
c_emitting,
@@ -764,8 +776,7 @@ pub(super) fn simulate_particles(
);
let c_env = StepEnv {
dt,
lifespan: child.def.lifespan,
gravity: child.def.gravity,
gravity: c_now.gravity,
drag: child.def.drag,
anchored,
kill_origin: None,
@@ -899,6 +910,7 @@ mod tests {
pos,
vel,
age: 0.0,
life: 10.0,
phase: 0,
fresh: false,
quat: Quat::IDENTITY,
@@ -909,7 +921,6 @@ mod tests {
fn env(kill_origin: Option<Vec3>, follow: Vec3) -> StepEnv {
StepEnv {
dt: 0.1,
lifespan: 10.0,
gravity: 0.0,
drag: 0.0,
anchored: true,
@@ -1001,13 +1012,18 @@ mod tests {
#[test]
fn child_drive_scales_with_the_parent_pool() {
let mut child = ChildEmitter::bare(benilla_formats::ParticleEmitterDef {
emission_speed: 0.0,
flags: 0x40, // inherit the parent particle's velocity
area_length: 0.0, // point emitter: births land exactly ON the parent particle
area_width: 0.0,
flags: 0x40, // inherit the parent particle's velocity
..crate::particles::tests::plain_def()
});
child.def.timing = benilla_formats::EmitTiming::constant(100.0);
// Point emitter, zero own speed: births land exactly ON the parent particle and the
// whole birth velocity is the inherited term.
let c_now = benilla_formats::ParamsNow {
emission_speed: 0.0,
area_length: 0.0,
area_width: 0.0,
..crate::particles::emit::tests::now()
};
// Two live parents, far apart, distinct velocities.
let parents = [
particle(Vec3::X * 10.0, Vec3::Y * 3.0),
@@ -1016,6 +1032,7 @@ mod tests {
// 100/s × 0.1 s × 2 calls = 20 births per frame.
super::drive_child(
&mut child,
&c_now,
&parents,
100.0,
true,
@@ -1041,6 +1058,7 @@ mod tests {
let n = child.particles.len();
super::drive_child(
&mut child,
&c_now,
&[],
100.0,
true,
+1
View File
@@ -560,6 +560,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
no_depth_test: false,
fog_policy: FogPolicy::Scene,
billboard: None,
welded_billboard: false,
alpha_anim: None,
uv_anim: None,
rgb_anim: None,
+4 -3
View File
@@ -104,9 +104,10 @@ pub(super) fn select_on_click(
}
// Clicked nothing targetable → deselect (only sends the clear if we actually had a
// target). The one exception is a payload held over NOTHING (sky): the ref's
// nothing-leg deselect is no-payload-gated. A terrain click deselects even with a
// surviving spell/action payload (`0x5e03bb` is target-gated, not payload-gated) —
// an item over terrain never reaches here (its press was consumed for the drop).
// nothing-leg deselect is no-payload-gated. Since 0843 ANY payload's empty-world left
// press is consumed for the drop/dismiss (input.rs's `would_drop`), so a payload
// normally never reaches this arm at all — the gate still holds the nothing-leg law
// for the residual GameObject-hover case (`Object` pick, unit arm empty).
_ => {
if !payload_held.0 || occlusion.distance.is_finite() {
clear(&mut selection, &net, engaged);
+6 -4
View File
@@ -35,7 +35,8 @@ pub type WowModelMaterial = ExtendedMaterial<StandardMaterial, WowModelExt>;
/// reference (`RECONCILE-fade-render-state.md` / `-clutter-fade-render-state.md`):
/// - `fade` (`model_flags.y`) = the **M2-doodad** fade blend twin: `AlphaMode::Blend` (transparent pass,
/// depth-write normally OFF) → `specialize` forces depth-write back ON so a fading haystack's near
/// cross-quads occlude its far ones. Silhouette stable (the shader re-applies the 224/255 cutout).
/// cross-quads occlude its far ones. Silhouette stable for an AlphaKey source (the shader re-applies
/// the 224/255 cutout via `clutter_fade.z` bit 10 — an Opaque source never alpha-tests; 0842).
/// - `clutter` (`clutter_fade.w`) = ground clutter: an `AlphaMode::Mask` (alpha-mask pass, depth-write
/// already ON) that the reference also **blends** (prog 201: `SRC_ALPHA/ONE_MINUS_SRC_ALPHA`) so the
/// ~70 yd ramp fades opacity. `specialize` forces that over-blend on in place; the 128/255 discard +
@@ -103,8 +104,9 @@ pub struct WowModelExt {
pub clutter_fade: Vec4,
/// Per-material flags (set at material creation, NOT light). `x` = **is_wmo** (>0.5 ⇒ FFP directional
/// `ambient + sun·N·L` × MOCV at sun-scale 1, no exterior terrain-shade). `y` = **fade variant** (>0.5 ⇒
/// the distance-fade BLEND twin → the shader re-applies the hard 224/255 cutout for a stable silhouette
/// and `specialize` forces depth-write ON; see [`WowModelKey`]). `zw` reserved.
/// the distance-fade BLEND twin → `specialize` forces depth-write ON; the 224/255 stable-silhouette
/// cutout rides `clutter_fade.z` bit 10 separately, set only for AlphaKey sources — 0842; see
/// [`WowModelKey`]). `zw` reserved.
#[uniform(100)]
pub model_flags: Vec4,
/// Per-material MCSH terrain-shade **selector** (`wow_model.wgsl`, the exterior doodad matte). `x` picks
@@ -269,7 +271,7 @@ impl MaterialExtension for WowModelExt {
// batches (the material's negative sort bias) — so each colour fragment passes
// GreaterEqual only at the model's own nearest surface, and interior/overlapped layers
// fail. One blended layer everywhere: no self-overlap darkening on a stealthed body.
// The fragment still runs its discards (farclip, the cutout when `model_flags.y`);
// The fragment still runs its discards (farclip, the bit-10 twin cutout);
// its colour output is computed and masked (an early return would be the natural
// spelling, but naga's MSL backend miscompiles the dead tail — see the shader note).
if let Some(frag) = descriptor.fragment.as_mut() {
@@ -206,11 +206,14 @@ pub(crate) fn spawn_model_entities(
) {
cutout.clone()
} else {
// The SOURCE blend (Opaque or AlphaKey here) rides into the twin: fade_variant builds
// AlphaMode::Blend either way, and the source decides the twin's 224/255 cutout marker
// (only an AlphaKey source alpha-tests while fading — decision 0842).
model_material(
mat_cache,
materials,
sub.texture.clone(),
ModelBlend::Blend,
sub.blend,
two_sided,
is_wmo,
interior,
+218 -39
View File
@@ -12,11 +12,20 @@
//! a dropped aura closes its gap, a new aura appends at the end.
//! - **Durations arrive out of band, and before the slot is named.** `SMSG_UPDATE_AURA_DURATION`
//! ([`AuraDurations`], filled by the net apply path) is keyed by raw slot and lands *before* the
//! `UNIT_FIELD_AURA` delta that says which spell sits there (verified, decision 0257 B6). The feed
//! joins a duration to an aura by slot, gated on the aura having appeared no later than the packet
//! — so a stale timer left in a recycled slot by a since-expired aura is never shown on the
//! permanent aura that took its place (the reference avoids this via a DBC "until cancelled" flag
//! we don't parse; the arrival-time gate is our equivalent, decision 0257 §3).
//! `UNIT_FIELD_AURA` delta that says which spell sits there (verified, decision 0257 B6; measured
//! at one frame, ~50 ms, on a fresh apply — decision 0846). So a stamp's slot is **empty at the
//! moment it arrives**, and the only thing that may invalidate it is the aura it would be joined
//! to, never the slot's momentary occupancy: the feed keeps every stamp and gates the *join* on
//! the aura having appeared no earlier than the packet. That gate is what stops a timer left by a
//! since-expired occupant showing on the permanent aura that recycled its slot (the reference
//! avoids this via a DBC "until cancelled" flag we don't parse — decision 0257 §3).
//!
//! And one thing the bar does **not** get from here: the running countdown. Rust recomputes each
//! aura's `expirationTime` every frame, but the event this feed fires is the reference's
//! discrete-change edge (`PLAYER_AURAS_CHANGED`), which a duration refresh does not trip. The
//! reference resolves that the same way — `BuffButton_OnUpdate` re-reads `GetPlayerBuffTimeLeft`
//! **every frame** and caches only the buff *index* on the event — and so does `BuffFrame.xml`.
//! A bar that cached the expiry on the event instead is decision 0846's second defect.
//!
//! Scope: the **local player** (decisions 0255/0257) and the **target** (the target frame's aura
//! rows — 0255's deferred slice). The target's list is the byte-verified *other-unit* law
@@ -46,20 +55,29 @@ use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// The self-only aura durations, keyed by raw `UNIT_FIELD_AURA` slot — the decoded
/// `SMSG_UPDATE_AURA_DURATION` payload, stamped with the Bevy time it arrived. Written by the net
/// `SMSG_UPDATE_AURA_DURATION` payload, stamped with the real time it arrived. Written by the net
/// apply path (which owns the event stream), read by [`feed_auras`]. A slot's entry is overwritten
/// by each fresh packet (apply/refresh) and pruned when the feed sees the slot go empty.
/// by each fresh packet (apply/refresh) and dropped only when the avatar goes — never per-frame on
/// slot occupancy, which would delete every stamp in the frame before its own aura arrives
/// (decision 0846). Bounded by construction: one entry per raw slot, ≤ 48.
#[derive(Resource, Default)]
pub(crate) struct AuraDurations {
by_slot: HashMap<u8, DurationStamp>,
}
struct DurationStamp {
/// Full duration in seconds — the packet's `remaining_ms`, which at apply/refresh is the total.
/// The span in seconds the last packet reported. On an apply or a refresh this **is** the
/// aura's full duration (vmangos sends `GetAuraDuration()` right after setting it), which is
/// what `UnitBuff`'s Era-shaped `duration` return wants. On a re-send it is only the
/// *remaining* — `Map::Add` replays every holder's current duration at world entry, and cast
/// pushback (`DelaySpellAuraHolder`) reports a shortened one. The 1.12 wire carries no maximum
/// at all, for anyone, so there is nothing better to report; the buff bar draws
/// [`Self::expires_at`] and never this.
total: f64,
/// The Bevy-clock instant the aura runs out (`received_at + total`).
/// The real-clock instant the aura runs out (`received_at + total`). Real, never virtual: see
/// the net apply path's aura tuple — a server-sent span counts down in real seconds.
expires_at: f64,
/// The Bevy-clock instant the packet arrived — the freshness gate against a recycled slot.
/// The real-clock instant the packet arrived — the freshness gate against a recycled slot.
received_at: f64,
}
@@ -67,6 +85,11 @@ impl AuraDurations {
/// Record a `SMSG_UPDATE_AURA_DURATION`. Called from the net apply drain (decision 0257).
pub(crate) fn set(&mut self, slot: u8, remaining_ms: u32, now: f64) {
let total = f64::from(remaining_ms) / 1000.0;
if trace_period().is_some() {
info!(
"aura trace: SMSG_UPDATE_AURA_DURATION slot {slot} = {remaining_ms} ms @ {now:.2}"
);
}
self.by_slot.insert(
slot,
DurationStamp {
@@ -84,7 +107,7 @@ impl AuraDurations {
struct CachedAura {
slot: u8,
spell_id: u32,
/// The Bevy-clock instant this aura first entered the cache — the duration freshness gate.
/// The real-clock instant this aura first entered the cache — the duration freshness gate.
appeared_at: f64,
flags: u8,
level: u8,
@@ -139,6 +162,12 @@ fn reconcile(cache: &mut Vec<CachedAura>, live: &[UnitAuraSlot], now: f64) {
c.level = a.level;
c.stacks = a.stacks;
} else {
if trace_period().is_some() {
info!(
"aura trace: slot {} took spell {} (descriptor delta) @ {now:.2}",
a.slot, a.spell_id
);
}
cache.push(CachedAura {
slot: a.slot,
spell_id: a.spell_id,
@@ -169,6 +198,75 @@ struct AuraFeedMemory {
/// token is cleared). The guid joins the key so a target *switch* re-fires `UNIT_AURA` even
/// between two units whose lists happen to project identically.
target_last: Option<(u64, Vec<AuraProjection>)>,
/// The Bevy-clock instant [`trace_timers`] last printed (`BENILLA_AURA_TRACE`).
traced_at: f64,
}
/// The `BENILLA_AURA_TRACE` period in seconds, or `None` when the trace is off. A set-but-unparsable
/// value means "on, at the default 1 Hz" rather than off — a typo shouldn't silently disarm the
/// instrument you asked for (the `WOW_MOVE_TRACE=1` lesson: a knob that quietly means nothing).
fn trace_period() -> Option<f64> {
static PERIOD: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
*PERIOD.get_or_init(|| {
let v = std::env::var("BENILLA_AURA_TRACE").ok()?;
Some(
v.trim()
.parse::<f64>()
.ok()
.filter(|p| *p > 0.0)
.unwrap_or(1.0),
)
})
}
/// One trace tick: for every aura the player's bar is drawing, print the app's remaining time next
/// to the remaining time the *button's own Lua* would render from what it last cached. The bar's
/// countdown is a per-frame poll in the reference (`BuffButton_OnUpdate` → `GetPlayerBuffTimeLeft`,
/// extracted 1.12 `BuffFrame.lua` l.128-130), so these two must agree every frame; a row where they
/// don't is a stale button, which is exactly the "the timer sticks / never appears" symptom.
///
/// The button a given aura lands on is its index *within its own filter* (the bar's ids are
/// 1-based per filter): helpful auras fill `BuffButton0..15`, harmful `BuffButton16..23`.
fn trace_timers(script: &UiScript, cache: &[CachedAura], list: &[AuraState], bevy_now: f64) {
let script_now = script.now();
info!(
"aura trace: {} aura(s), GetTime()={script_now:.2} app clock={bevy_now:.2} (skew {:+.2})",
list.len(),
script_now - bevy_now
);
let (mut helpful_n, mut harmful_n) = (0usize, 0usize);
for (c, a) in cache.iter().zip(list) {
let button = if a.helpful {
helpful_n += 1;
helpful_n - 1
} else {
harmful_n += 1;
15 + harmful_n
};
let app_left = if a.expiration_time > 0.0 {
format!("{:.1}s", a.expiration_time - script_now)
} else {
"permanent".to_string()
};
// What the bar is actually DRAWING — the rendered duration string, not an intermediate.
// The button holds no expiry of its own (it re-reads one every frame), so the text is the
// only readback there is, and it is the right one: it is what the director sees.
let lua = script
.eval::<String>(&format!(
r#"local b = getglobal("BuffButton{button}")
local d = getglobal("BuffButton{button}Duration")
if not b then return "no such button" end
return string.format("shown=%s draws %q",
tostring(b:IsShown()), d and d:GetText() or "")"#
))
.unwrap_or_else(|e| format!("<lua error: {e}>"));
info!(
" slot {:>2} spell {:>5} {:<26} BuffButton{button:<2} app left {app_left:<10} lua {lua}",
c.slot,
a.spell_id,
a.name.as_deref().unwrap_or("<unknown spell>"),
);
}
}
fn projection_of(list: &[AuraState]) -> Vec<AuraProjection> {
@@ -225,7 +323,7 @@ fn feed_auras(
spells: Option<Res<Spells>>,
mut durations: ResMut<AuraDurations>,
mut cache: ResMut<PlayerAuraCache>,
time: Res<Time>,
time: Res<Time<Real>>,
mut mem: Local<AuraFeedMemory>,
) {
let Some(mut script) = script else {
@@ -238,6 +336,11 @@ fn feed_auras(
script.set_auras("target", None);
script.set_tracking(None);
cache.auras.clear();
// The stamps go with the cache: nothing prunes them per-frame (see the note in the
// join below), so losing the avatar is where they are dropped. A stale one could not
// survive a re-entry anyway — the freshness gate rejects a stamp older than the aura —
// but leaving the map to accumulate across logins would be sloppy state.
durations.by_slot.clear();
*mem = AuraFeedMemory::default();
}
return;
@@ -260,13 +363,15 @@ fn feed_auras(
.collect();
reconcile(&mut cache.auras, &live, bevy_now);
// Prune durations for slots that no longer hold any aura — that's the only moment we can be sure
// a stamp is stale (a still-occupied slot may legitimately keep a permanent aura with no timer).
// Keyed on every OCCUPIED slot, hidden or not: the reference's expiry array is raw-slot state,
// independent of the display filter.
durations
.by_slot
.retain(|slot, _| occupied.iter().any(|a| a.slot == *slot));
// NOTE: there is deliberately no "prune the stamps of empty slots" pass here. It was the whole
// of the "a freshly cast buff shows no timer, ever" defect: the duration packet leads its own
// descriptor delta (decision 0257 B6 — measured at one frame, ~50 ms, on a fresh apply), so at
// the instant a stamp arrives its slot is still EMPTY, and an occupancy prune deletes it
// sub-millisecond later, before the aura it belongs to can appear. Staleness belongs to the
// freshness gate below, which compares a stamp against the aura it would be joined to rather
// than against a slot's momentary emptiness — the defence 0257 §3 built for exactly this.
// The map cannot grow: it is keyed by raw slot (≤ 48), each entry overwritten by the next
// packet for that slot, and cleared with the rest of the aura state when the avatar goes.
let script_now = script.now();
@@ -275,16 +380,12 @@ fn feed_auras(
.iter()
.map(|c| {
let display = catalog.and_then(|cat| cat.get(c.spell_id));
// A duration counts only if it arrived no earlier than this aura appeared (minus a
// tick's slack): a stamp older than the aura belongs to a since-expired occupant of the
// recycled slot (decision 0257 §3). Apply/refresh sends the packet just before the
// descriptor delta, so a live aura's stamp is always within that slack.
let (duration, expiration_time) = durations
.by_slot
.get(&c.slot)
.filter(|d| d.received_at >= c.appeared_at - DURATION_SLACK)
.map(|d| (d.total, script_now + (d.expires_at - bevy_now)))
.unwrap_or((0.0, 0.0));
let (duration, expiration_time) = join_duration(
durations.by_slot.get(&c.slot),
c.appeared_at,
bevy_now,
script_now,
);
AuraState {
spell_id: c.spell_id,
name: display.map(|d| d.name.clone()),
@@ -307,6 +408,18 @@ fn feed_auras(
let tracking = tracking_state_of(catalog, &occupied);
let tracking_spell = tracking.as_ref().map(|t| t.spell_id);
// The TIMER trace (`BENILLA_AURA_TRACE=<secs>`, default 1 s): the instrument for "the countdown
// is wrong" reports. Every tick it prints, per live aura, the app's own remaining time beside
// what the *bar's Lua* actually holds — the two halves that can silently disagree, since the
// app recomputes the expiry every frame while a button only re-reads it when something makes it
// repaint. A row whose `lua left` has drifted from `app left` is the disagreement, named.
if let Some(period) = trace_period() {
if bevy_now - mem.traced_at >= period {
mem.traced_at = bevy_now;
trace_timers(&script, &cache.auras, &list, bevy_now);
}
}
// Edge-trigger UNIT_AURA on a discrete change (the reference's PLAYER_AURAS_CHANGED), never on
// the countdown alone — that's a per-frame OnUpdate on the button, not an event. The tracking
// spell joins the key: a tracking *switch* changes no visible list, only the minimap icon.
@@ -425,11 +538,34 @@ fn feed_auras(
}
/// The apply/refresh-to-descriptor slack: a duration packet is accepted for an aura if it arrived no
/// more than this long before the aura appeared. Generous versus the sub-frame lead the server
/// actually gives (the packet precedes the same-tick descriptor delta), tight versus the seconds a
/// stale recycled-slot stamp would be off by.
/// more than this long before the aura appeared. Generous versus the **measured** lead — one client
/// frame, ~50 ms, on a fresh apply against the live server (decision 0846) — and tight versus the
/// seconds a stale recycled-slot stamp would be off by.
const DURATION_SLACK: f64 = 1.0;
/// Join a slot's duration stamp to the aura now sitting in that slot, yielding `UnitBuff`'s
/// `(duration, expirationTime)` pair on the **script** clock (`0.0, 0.0` = no timer, our "until
/// cancelled"). This is the *only* thing that may reject a stamp, and it is the right place for it:
/// the stamp is compared against the aura it would be joined to, never against the slot's momentary
/// occupancy. `SMSG_UPDATE_AURA_DURATION` leads its own descriptor delta (decision 0257 B6), so at
/// the instant a stamp arrives its slot is *empty* — a rule phrased on occupancy deletes exactly the
/// packet that matters, which is decision 0846's first defect.
///
/// A stamp that predates the aura by more than [`DURATION_SLACK`] belonged to a since-expired
/// occupant of a recycled slot, and is dropped (decision 0257 §3 — our stand-in for the reference's
/// unparsed `SpellDuration.dbc` "until cancelled" flag).
fn join_duration(
stamp: Option<&DurationStamp>,
appeared_at: f64,
bevy_now: f64,
script_now: f64,
) -> (f64, f64) {
stamp
.filter(|d| d.received_at >= appeared_at - DURATION_SLACK)
.map(|d| (d.total, script_now + (d.expires_at - bevy_now)))
.unwrap_or((0.0, 0.0))
}
/// Drain the spell ids `CancelUnitBuff` queued this frame and send one `CMSG_CANCEL_AURA` each. The
/// server cancels by spell, not slot (decision 0257 B8); it refuses anything the wire's
/// `AFLAG_CANCELABLE` bit didn't allow, which the binding already gated on.
@@ -601,22 +737,65 @@ mod tests {
/// stale-recycled-slot defence — while a stamp from around the apply is accepted.
#[test]
fn a_duration_is_joined_only_when_it_is_no_older_than_the_aura() {
// A stamp received at t=100 is stale for an aura that only appeared at t=200.
// A stamp received at t=100 is stale for an aura that only appeared at t=200 — it belonged
// to the slot's previous, since-expired occupant.
let stale = DurationStamp {
total: 30.0,
expires_at: 130.0,
received_at: 100.0,
};
assert!(
stale.received_at < 200.0 - DURATION_SLACK,
"a stamp seconds older than the aura is rejected"
assert_eq!(
join_duration(Some(&stale), 200.0, 200.0, 5000.0),
(0.0, 0.0),
"a stamp seconds older than the aura is rejected — no timer, not a wrong one"
);
// A stamp received just before the aura appeared (the real apply→descriptor lead) is fresh.
// A stamp received just before the aura appeared (the real apply→descriptor lead) joins,
// and its expiry is rebased from the Bevy clock onto the script clock Lua counts against.
let fresh = DurationStamp {
total: 30.0,
expires_at: 230.0,
received_at: 200.0 - 0.1,
received_at: 199.9,
};
assert!(fresh.received_at >= 200.0 - DURATION_SLACK, "accepted");
assert_eq!(
join_duration(Some(&fresh), 200.0, 200.0, 5000.0),
(30.0, 5030.0)
);
// No stamp at all is a permanent aura: the wire sends no packet for one (decision 0257).
assert_eq!(join_duration(None, 200.0, 200.0, 5000.0), (0.0, 0.0));
}
/// **Decision 0846's first defect, pinned.** `SMSG_UPDATE_AURA_DURATION` LEADS the descriptor
/// delta that names its slot (0257 B6; live-measured at one client frame, ~50 ms, on a fresh
/// apply). So at the instant the stamp arrives its slot is **empty** — and the feed used to run
/// a per-frame "drop the stamps of unoccupied slots" pass, which deleted it sub-millisecond
/// later, before the aura it belonged to could appear. Every freshly cast buff then showed no
/// timer, forever, while a *refresh* (whose slot is already occupied) worked — the "sometimes"
/// in the report.
///
/// The invariant that replaced it: nothing removes a stamp on occupancy, and the join is what
/// decides. Here the aura arrives a frame *after* its stamp, and must still get its timer.
#[test]
fn a_stamp_that_arrives_one_frame_before_its_aura_still_joins_it() {
let mut durations = AuraDurations::default();
// t = 15.10 — the packet lands. Nothing occupies slot 0 yet; the cache is empty.
durations.set(0, 300_000, 15.10);
let mut cache = Vec::new();
assert!(cache.is_empty());
// t = 15.15 — the next frame's descriptor delta names slot 0.
reconcile(&mut cache, &[slot(0, 1126)], 15.15);
let (total, expiry) = join_duration(
durations.by_slot.get(&0),
cache[0].appeared_at,
15.15,
900.0,
);
assert_eq!(total, 300.0, "the stamp survived the gap");
// Script clock 900 + the 300 s that started 50 ms ago: the bar counts down from the
// packet's instant, not from the descriptor delta that named the slot.
assert!(
(expiry - 1199.95).abs() < 1e-9,
"expiry rebased onto the script clock, got {expiry}"
);
}
}
@@ -322,3 +322,65 @@ fn an_emptied_bar_hides_every_button() {
assert_eq!(text(&s, "BuffButton0Duration"), "");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Decision 0846's second defect, pinned.** The bar's countdown is a per-frame POLL, not a value
/// cached when the aura event fires — exactly as the reference does it (`BuffButton_OnUpdate` →
/// `GetPlayerBuffTimeLeft`, extracted 1.12 `BuffFrame.lua` l.128-130, which caches only the buff
/// *index* on `PLAYER_AURAS_CHANGED`).
///
/// This is what makes a *refresh* work. A recast, a reapplied DoT, cast pushback and the server's
/// replay of every duration at world entry all change an aura's expiry while changing nothing
/// discrete about it — same spell, same stacks, same dispel class — so the feed's change key does
/// not trip and **no aura event fires**. The push below therefore deliberately does NOT fire one.
/// Against the old event-cached bar the button kept counting the stale expiry down, reached zero
/// and sat at `"0 s"` for as long as the buff lived (live-measured: `app left 59.9s` beside
/// `lua left -9.9s`).
#[test]
fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
let mut s = harness();
let mark = |expiry: f64| {
aura(
1126,
"Mark of the Wild",
"Interface\\Icons\\Spell_Nature_Regeneration",
true,
1,
None,
expiry,
true,
)
};
// 20s out. After the tick GetTime()=0.1 -> 19.9s -> "19 s" (SecondsToTimeAbbrev floors via %d).
push(&mut s, vec![mark(20.0)]);
frame(&mut s, 0.1);
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
// The refresh: a new expiry, everything discrete unchanged — so the feed pushes the list and
// fires NOTHING. The button must still pick the new expiry up on its next frame.
s.set_auras("player", Some(vec![mark(300.0)]));
frame(&mut s, 0.1);
assert_eq!(
text(&s, "BuffButton0Duration"),
"5 m",
"the countdown re-reads the aura every frame; it does not wait for an event"
);
// ...and the flash follows it back out of the warning window (20s pulsed, 300s is solid).
assert!(
(alpha(&s, "BuffButton0") - 1.0).abs() < 1e-6,
"no longer inside the 31s warning window, so full alpha"
);
// The same poll is what shows a timer that arrived AFTER the icon did — the fresh-apply case,
// where the duration packet's stamp lands a frame later than the descriptor delta.
s.set_auras("player", Some(vec![mark(0.0)])); // icon up, no duration joined yet
frame(&mut s, 0.1);
assert_eq!(text(&s, "BuffButton0Duration"), "", "no timer yet");
s.set_auras("player", Some(vec![mark(60.0)])); // the stamp joins, still no event
frame(&mut s, 0.1);
assert_eq!(
text(&s, "BuffButton0Duration"),
"59 s",
"a duration that lands after the icon still reaches the bar"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+119
View File
@@ -227,3 +227,122 @@ fn chat_box_arrows_edit_and_history_recalls() {
"Down past the newest restores the in-progress draft"
);
}
/// Decision 0843 — the chat body as a dismiss surface, the director's stuck-spell gesture end to
/// end through the shipped XML: a spell dragged out of the spellbook and RELEASED over the chat
/// keeps carrying (a drag release is never a click — 0218's byte-verified trigger), the follow-up
/// completed LEFT CLICK on the chat body dismisses it, and the same click leaves an ITEM payload
/// untouched (a silent item dismissal would be a destroy — only the world-drop popup offers that).
#[test]
fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
use benilla_ui::script::{
ContainerSlot, ContainerState, SpellBookState, SpellSlotView, SpellTabView,
};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ChatFrame.xml",
"SpellBookFrame.xml",
] {
load_xml(&s, f);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(SpellBookState {
tabs: vec![SpellTabView {
name: "Fire".into(),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
offset: 0,
num_spells: 1,
}],
slots: vec![SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
current: false,
cooldown: None,
}],
});
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.resolve();
// Drag Fireball off its book button (press → past the 4px threshold → the payload is up).
let (l, r, t, b) = (
s.eval::<f32>("return BenillaSpellButton1:GetLeft()")
.unwrap(),
s.eval::<f32>("return BenillaSpellButton1:GetRight()")
.unwrap(),
s.eval::<f32>("return BenillaSpellButton1:GetTop()")
.unwrap(),
s.eval::<f32>("return BenillaSpellButton1:GetBottom()")
.unwrap(),
);
let (x1, y1) = ((l + r) * 0.5, (t + b) * 0.5);
s.mouse_button(x1, y1, "LeftButton", true);
s.mouse_move(x1 + 20.0, y1);
assert!(
s.cursor_payload().is_some(),
"OnDragStart picked the spell up"
);
// Release the DRAG over the chat body: keeps carrying (OnClick never fires on a drag).
let (cx, cy) = (200.0, 150.0); // inside ChatFrame1 (BOTTOMLEFT 32,85 + 430×120)
s.mouse_move(cx, cy);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(
s.cursor_payload().is_some(),
"a drag release over the chat keeps carrying"
);
// The completed click on the chat body dismisses the stuck spell.
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.cursor_payload().is_none(),
"a chat click dismisses a spell payload"
);
// An ITEM payload survives the same click untouched.
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: None,
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(
s.cursor_item().is_some(),
"a chat click never touches an item payload"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+17 -12
View File
@@ -547,20 +547,25 @@ pub(super) fn drive_script(
}
}
}
// The item held on the cursor draws last (a 32×32 icon at the mouse, over the whole UI) — but
// ONLY in capture mode (decision 0216 §5): a normal run shows it as the hardware cursor
// The payload held on the cursor draws last (a 32×32 icon at the mouse, over the whole UI) —
// but ONLY in capture mode (decision 0216 §5): a normal run shows it as the hardware cursor
// instead (`crate::cursor`), which can't appear in a screenshot's pixels, so the quad is the
// capture harness's stand-in. Purely visual either way — the drag state lives in the engine;
// the wire settles the actual move.
// capture harness's stand-in. Any arm, matching the hardware cursor's own `payload_icon`
// (item/spell/action alike — the item-only read here predated the spell/action producers).
// Purely visual either way — the drag state lives in the engine; the wire settles the move.
if capture.is_some() {
if let (Some(cursor), Some(pos)) = (script.cursor_item(), window.cursor_position()) {
if let Some(path) = cursor.texture.as_deref() {
if let Some(handle) = assets
.as_mut()
.and_then(|a| a.sprite_texture(path, &mut images))
{
out.push(cursor_icon_quad(pos, handle));
}
use benilla_ui::script::CursorPayload;
let texture = script.cursor_payload().and_then(|p| match p {
CursorPayload::Item(i) => i.texture,
CursorPayload::Spell(s) => s.texture,
CursorPayload::Action(a) => a.texture,
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
.as_mut()
.and_then(|a| a.sprite_texture(&texture, &mut images))
{
out.push(cursor_icon_quad(pos, handle));
}
}
}
+7 -10
View File
@@ -138,17 +138,14 @@ pub(super) fn feed_ui_input(
// byte-verified trigger — but the press is when the world click-pick and
// camera orbit-start would act, so they must yield now, exactly as for a
// hovered click). "Would drop" mirrors `world_drop_click`'s pick routing
// (decisions 0571 + 0574): an item over terrain/nothing, a spell/action over
// nothing. Anything else (any payload over an object, a spell/action over
// terrain) is NOT consumed — the reference runs SELECT / the terrain deselect
// with the payload still held, so the click must reach the world.
// (decisions 0571 + 0574, amended by 0843): ANY payload over terrain/nothing
// drops (the item's popup, the spell/action's silent dismiss). Only a payload
// over an OBJECT is not consumed — the reference runs SELECT with the payload
// still held, so that click must reach the world.
if btn == MouseButton::Left && hover.0.is_none() {
use benilla_ui::script::{CursorPayload, WorldPick};
let would_drop = match script.cursor_payload() {
Some(CursorPayload::Item(_)) => world_pick != WorldPick::Object,
Some(_) => world_pick == WorldPick::Nothing,
None => false,
};
use benilla_ui::script::WorldPick;
let would_drop =
script.cursor_payload().is_some() && world_pick != WorldPick::Object;
if would_drop {
click_consumed.0 = true;
}