effect clocks run true, and Orgrimmar's ground items stop strobing

- spell shimmer timing is right: shared clocks tick together, each cast anchors its own
- the flickering ground items in Orgrimmar are fixed, a transparent draw-order tie
- static scenery answers the mouse again, and the freeze debuff's crackle rings at the target
- bug reports moved to Discord, GitHub issues are closed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-02 18:00:04 +07:00
co-authored by Claude Fable 5
parent 0827173781
commit eb239ddf25
33 changed files with 1204 additions and 441 deletions
@@ -28,7 +28,7 @@ fn main() -> anyhow::Result<()> {
continue;
};
let samples: Vec<f32> = (0..24u16)
.map(|k| a.sample(Some(slot), 3.0 * f32::from(k) / 24.0))
.map(|k| a.sample(Some(slot), 3.0 * f32::from(k) / 24.0, 0.0))
.collect();
let lo = samples.iter().copied().fold(f32::MAX, f32::min);
let hi = samples.iter().copied().fold(f32::MIN, f32::max);
@@ -27,7 +27,7 @@ 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);
let now = e.params.sample(None, 0.0, 0.0);
println!(
" speed {} var {} vrange {} hrange {} gravity {} life {} drag {} (params at t=0)",
now.emission_speed,
+1 -1
View File
@@ -27,7 +27,7 @@ fn main() -> anyhow::Result<()> {
.iter()
.enumerate()
{
let now = e.params.sample(None, 0.0);
let now = e.params.sample(None, 0.0, 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 {:?}",
+72 -14
View File
@@ -1,11 +1,18 @@
//! How far does a welded billboard bone's geometry actually MOVE when the bone is re-oriented?
//! Where does a welded billboard bone's geometry SIT relative to its pivot — how far, and along
//! which local axis?
//!
//! 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.
//! Two readings, and the second is the one that decides anything. **Distance** answers the seam
//! question: a partially-weighted seam vertex is dragged by its weight times the re-orientation and
//! linear-blend skinning shrinks it toward the pivot on the way (the candy-wrapper), so a seam ring
//! sitting ON the pivot hinges cleanly and one sitting far out tears.
//!
//! **Direction answers whether the geometry moves at all.** A spherical billboard maps the bone's
//! local axes onto fixed camera axes, so an offset lying along a single local axis lands on the same
//! camera axis from every angle: it does not sweep, it only loses its roll and its foreshortening.
//! Only an offset spread across the axes sweeps. Magnitude alone cannot tell those apart, and
//! reading a 0.29 yd radius as a 0.29 yd arc is exactly how decision 0847 withdrew a correct change
//! — the spikes it measured run **along** their bone (worst vertex 12° off axis), so the sweep it
//! described never existed (wow-re `billboard-bone-law.md` §6.3, and this tool's own output).
//!
//! Usage: `cargo run -p benilla-formats --example seamswing -- <WoW/Data> <internal\path.m2>`
@@ -33,14 +40,15 @@ fn main() -> anyhow::Result<()> {
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 off = [
v.position.x - piv[0],
v.position.y - piv[1],
v.position.z - piv[2],
];
let d = (off[0].powi(2) + off[1].powi(2) + off[2].powi(2)).sqrt();
let w = f32::from(v.bone_weights[i]) / 255.0;
if w > 0.999 {
pure.push(d)
pure.push((d, off))
} else {
seam.push((w, d))
}
@@ -60,15 +68,65 @@ fn main() -> anyhow::Result<()> {
)
};
let seam_d: Vec<f32> = seam.iter().map(|&(_, d)| d).collect();
let pure_d: Vec<f32> = pure.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!(" fully-weighted verts, |v−pivot|: {}", stat(&pure_d));
println!(" SEAM verts, |v−pivot|: {}", stat(&seam_d));
if let Some(&(w, _)) = seam.first() {
println!(" seam weight on this bone: {w:.2}");
}
// DIRECTION, which is what actually decides whether re-orientation moves the geometry.
// A spherical billboard maps the bone's local axes to fixed camera axes, so an offset that
// lies along ONE local axis lands on that camera axis from every angle: it does not sweep,
// it only loses its roll and its foreshortening. An offset spread across the axes does
// sweep. Magnitude alone cannot tell these apart — decision 0847 read a 0.29 yd radius as a
// 0.29 yd arc and withdrew a correct change on the strength of it.
if !pure.is_empty() {
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
for &(_, o) in &pure {
for k in 0..3 {
lo[k] = lo[k].min(o[k]);
hi[k] = hi[k].max(o[k]);
}
}
// The DIRECTION axis is the one the offsets are largest along — NOT the one they are
// most spread across. Those differ here and confusing them inverts the reading: this
// spike runs along z (|z| ≈ 0.29) but is widest across y (spread 0.105, its cross
// section). Report both, named, so neither can be mistaken for the other.
let mean_abs =
|k: usize| pure.iter().map(|&(_, o)| o[k].abs()).sum::<f32>() / pure.len() as f32;
let along = (0..3)
.max_by(|&a, &c| mean_abs(a).total_cmp(&mean_abs(c)))
.unwrap();
let widest = (0..3)
.max_by(|&a, &c| (hi[a] - lo[a]).total_cmp(&(hi[c] - lo[c])))
.unwrap();
// How far the WORST vertex strays from that one axis — the number that decides whether
// "runs along a single axis" is a fair description of the whole batch or only its mean.
let worst_deg = pure
.iter()
.filter(|&&(d, _)| d > 1e-6)
.map(|&(d, o)| (o[along].abs() / d).clamp(-1.0, 1.0).acos().to_degrees())
.fold(0.0f32, f32::max);
println!(
" offset from pivot, per axis: x[{:+.3},{:+.3}] y[{:+.3},{:+.3}] z[{:+.3},{:+.3}]",
lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]
);
println!(
" → RUNS ALONG {} (mean |{}| = {:.3}); widest across {} (cross section {:.3}); \
worst vertex {worst_deg:.1}° off {}",
["x", "y", "z"][along],
["x", "y", "z"][along],
mean_abs(along),
["x", "y", "z"][widest],
hi[widest] - lo[widest],
["x", "y", "z"][along],
);
}
}
Ok(())
}
@@ -281,7 +281,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) {
// 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);
let now = d.params.sample(None, 0.0, 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,
@@ -1157,7 +1157,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> {
let (lo, hi) = match &sub.alpha_anim {
None => (1.0, 1.0),
Some(a) => (0..=32)
.map(|k| a.sample(Some(i), period * k as f32 / 32.0))
.map(|k| a.sample(Some(i), period * k as f32 / 32.0, 0.0))
.fold((f32::MAX, f32::MIN), |(lo, hi), v| (lo.min(v), hi.max(v))),
};
let cell = if (hi - lo).abs() < 1e-4 {
@@ -1321,7 +1321,7 @@ 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);
let now = d.params.sample(None, 0.0, 0.0);
if d.shape == benilla_formats::ParticleShape::Plane {
let lo = now.area_length.abs().min(now.area_width.abs());
let hi = now.area_length.abs().max(now.area_width.abs());
@@ -1770,7 +1770,7 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
// sampling grid is coarse on purpose — a batch that so much as flickers non-zero is drawn.
let hidden_in = |sub: &benilla_formats::RenderSubmesh, slot: usize| -> bool {
sub.alpha_anim.as_ref().is_some_and(|a| {
(0..=16u16).all(|k| a.sample(Some(slot), f32::from(k) * 0.25) <= 0.0)
(0..=16u16).all(|k| a.sample(Some(slot), f32::from(k) * 0.25, 0.0) <= 0.0)
})
};
let (mut first, mut any, mut varies) = (0usize, 0usize, 0usize);
+62 -36
View File
@@ -68,21 +68,23 @@ impl EmitTiming {
}
/// Is the gate ON, `elapsed` seconds into sequence slot `seq`? A slot with no baked gate is
/// ON (the loader default).
pub fn emitting(&self, seq: Option<usize>, elapsed: f32) -> bool {
/// ON (the loader default). `shared_now` is the world's shared elapsed seconds — a
/// gseq-tagged gate routes to it ([`crate::models::KeyAnim::clock`]).
pub fn emitting(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> bool {
self.enabled
.get(self.idx(seq))
.and_then(|o| o.as_ref())
.is_none_or(|a| a.sample_or(elapsed, 1.0) > 0.5)
.is_none_or(|a| a.sample_or(a.clock(elapsed, shared_now), 1.0) > 0.5)
}
/// The spawn rate (particles/sec), `elapsed` seconds into sequence slot `seq`. A slot with no
/// baked rate spawns nothing. Floored at 0 (a track tail may legitimately go negative).
pub fn rate(&self, seq: Option<usize>, elapsed: f32) -> f32 {
/// `shared_now` routes a gseq-tagged rate loop to the shared world clock.
pub fn rate(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> f32 {
self.rate
.get(self.idx(seq))
.and_then(|o| o.as_ref())
.map_or(0.0, |a| a.sample_or(elapsed, 0.0))
.map_or(0.0, |a| a.sample_or(a.clock(elapsed, shared_now), 0.0))
.max(0.0)
}
@@ -128,6 +130,7 @@ impl EmitTiming {
period: 0.0,
step: true,
wrap: true,
gseq: false,
keys: vec![(0.0, rate)],
})],
enabled: vec![None],
@@ -211,8 +214,9 @@ impl EmitParams {
}
/// 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 {
/// [`EmitTiming`]: out-of-range degrades to slot 0). `shared_now` routes gseq-tagged
/// channels to the shared world clock.
pub fn sample(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> ParamsNow {
let d = ParamsNow::default();
let at = |i: usize, default: f32| -> f32 {
let ch = &self.channels[i];
@@ -220,9 +224,9 @@ impl EmitParams {
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))
ch.get(slot).and_then(|o| o.as_ref()).map_or(default, |a| {
a.sample_or(a.clock(elapsed, shared_now), default)
})
};
ParamsNow {
emission_speed: at(0, d.emission_speed),
@@ -261,6 +265,7 @@ impl EmitParams {
period: 0.0,
step: true,
wrap: true,
gseq: false,
keys: vec![(0.0, v)],
})]
};
@@ -344,10 +349,14 @@ mod tests {
&slots(&[((0, 1000), true)]),
&[],
);
assert_eq!(t.rate(None, 0.050), 0.0, "before the key: step holds 0");
assert_eq!(t.rate(None, 0.070), 30.0, "at/after the key: 30");
assert_eq!(t.rate(None, 0.500), 30.0, "held to the band end");
assert!(t.emitting(None, 0.5), "no gate track = always on");
assert_eq!(
t.rate(None, 0.050, 0.0),
0.0,
"before the key: step holds 0"
);
assert_eq!(t.rate(None, 0.070, 0.0), 30.0, "at/after the key: 30");
assert_eq!(t.rate(None, 0.500, 0.0), 30.0, "held to the band end");
assert!(t.emitting(None, 0.5, 0.0), "no gate track = always on");
}
/// The LINEAR ramp law (the BloodSpurt shape `0 → 100 → 0`, interp 1): mid-ramp pours the
@@ -360,12 +369,15 @@ mod tests {
&slots(&[((0, 1000), true)]),
&[],
);
assert!((t.rate(None, 0.050) - 50.0).abs() < 1e-4, "rising mid-ramp");
assert!(
(t.rate(None, 0.150) - 50.0).abs() < 1e-4,
(t.rate(None, 0.050, 0.0) - 50.0).abs() < 1e-4,
"rising mid-ramp"
);
assert!(
(t.rate(None, 0.150, 0.0) - 50.0).abs() < 1e-4,
"falling mid-ramp"
);
assert_eq!(t.rate(None, 0.500), 0.0, "the ramp self-closes");
assert_eq!(t.rate(None, 0.500, 0.0), 0.0, "the ramp self-closes");
}
/// The per-sequence window law — the B27 shape, synthesized: an enabled gate authored ON
@@ -393,23 +405,26 @@ mod tests {
);
// Idle (slot 1): the collapsed window (1,1) resolves to keys[1] = OFF — at every time,
// wrap included.
assert!(!t.emitting(Some(1), 0.0));
assert!(!t.emitting(Some(1), 0.25));
assert!(!t.emitting(Some(1), 400.0));
assert!(!t.emitting(Some(1), 0.0, 0.0));
assert!(!t.emitting(Some(1), 0.25, 0.0));
assert!(!t.emitting(Some(1), 400.0, 0.0));
// The one-shot clip (slot 0): ON at its start, OFF from 333 ms — and the clamped clock
// HOLDS that tail at/after the band end instead of aliasing back to the ON start.
assert!(t.emitting(Some(0), 0.1));
assert!(!t.emitting(Some(0), 0.5));
assert!(!t.emitting(Some(0), 1.0), "t == period must not alias to 0");
assert!(t.emitting(Some(0), 0.1, 0.0));
assert!(!t.emitting(Some(0), 0.5, 0.0));
assert!(
!t.emitting(Some(0), 5.0),
!t.emitting(Some(0), 1.0, 0.0),
"t == period must not alias to 0"
);
assert!(
!t.emitting(Some(0), 5.0, 0.0),
"parked long past the end: still off"
);
// The later clip (slot 2): its degenerate window is the ON key.
assert!(t.emitting(Some(2), 0.05));
assert!(t.emitting(Some(2), 0.05, 0.0));
// Unknown/out-of-range degrades to slot 0 (the doodad law).
assert!(t.emitting(None, 0.1));
assert!(!t.emitting(Some(9), 0.5));
assert!(t.emitting(None, 0.1, 0.0));
assert!(!t.emitting(Some(9), 0.5, 0.0));
// The rate is a whole-track constant: same in every slot.
assert_eq!(t.constant_rate(), Some(20.0));
assert_eq!(t.peak_rate(), 20.0);
@@ -431,7 +446,7 @@ mod tests {
&slots(&[((0, 867), false)]),
&[],
);
let at = |t: f32| p.sample(None, t);
let at = |t: f32| p.sample(None, t, 0.0);
assert!((at(0.0).area_length - 0.1944).abs() < 1e-3, "opens tight");
let mid = at(0.3335).area_length;
assert!(
@@ -457,9 +472,12 @@ mod tests {
&slots(&[((0, 1000), true)]),
&[],
);
assert!(t.emitting(None, 0.1), "first pass: inside the window");
assert!(!t.emitting(None, 0.8), "first pass: past it");
assert!(t.emitting(None, 1.1), "second pass: the window re-fires");
assert!(t.emitting(None, 0.1, 0.0), "first pass: inside the window");
assert!(!t.emitting(None, 0.8, 0.0), "first pass: past it");
assert!(
t.emitting(None, 1.1, 0.0),
"second pass: the window re-fires"
);
}
/// **The dead-slot-0 shape** — `BlastedLandsLightningbolt01.m2`'s emitter 2, synthesized
@@ -494,17 +512,25 @@ mod tests {
);
// Slot 0 — what the old pinned consumer sampled. Silent across its whole band.
for s in [0.0, 0.3, 0.6, 0.9, 1.2] {
assert_eq!(t.rate(Some(0), s), 0.0, "slot 0 is a flat zero at {s}s");
assert_eq!(
t.rate(Some(0), s, 0.0),
0.0,
"slot 0 is a flat zero at {s}s"
);
}
assert_eq!(
t.rate(None, 0.5),
t.rate(None, 0.5, 0.0),
0.0,
"`None` degrades to slot 0 — also silent"
);
// Slot 1 — what the arm actually rolled. The strike is here, and only here.
assert_eq!(t.rate(Some(1), 0.0), 0.0, "slot 1 opens closed");
assert_eq!(t.rate(Some(1), 0.316), 30.0, "the burst fires mid-band");
assert_eq!(t.rate(Some(1), 0.5), 0.0, "and self-closes");
assert_eq!(t.rate(Some(1), 0.0, 0.0), 0.0, "slot 1 opens closed");
assert_eq!(
t.rate(Some(1), 0.316, 0.0),
30.0,
"the burst fires mid-band"
);
assert_eq!(t.rate(Some(1), 0.5, 0.0), 0.0, "and self-closes");
// The trap that hid it: the build-time cull sees a live emitter either way.
assert_eq!(
t.peak_rate(),
+32 -2
View File
@@ -86,10 +86,37 @@ pub struct KeyAnim<V> {
/// onto its **opening** value: an elemental's Death sequence fades every batch to alpha 0 and
/// then — one frame later, for ever — snapped back to a fully opaque body frozen in mid-air.
pub wrap: bool,
/// **Which clock feeds the loop** — the other half of the clock law, also decided at bake
/// time. `true` = a global-sequence loop: the reference's cursor is
/// `(sceneClock − instanceAttachTime) % duration` (wow-re `gseq-anchor.md`, byte-verified:
/// one free-running per-scene ms clock, snapshotted ONCE per model instance at attach —
/// `CM2Model+0x68`). Per-INSTANCE anchoring, stamped at attach; sequence tracks instead
/// re-arm their cursor at every play. `false` = a sequence-band loop: clocked by the host's
/// playing-clip time. Consumers route via [`Self::clock`], passing their instance's anchored
/// gseq cursor — the spell-fx lane passes the raw scene clock (its pooled-instance
/// emulation). Decisions 0855/0856: sampling a gseq loop on a per-play clock pinned every
/// Arcane Intellect cast's twinkles to phase 0 (clustered, low, identical cast to cast).
pub gseq: bool,
/// `(secs from loop start, value)`, time-ascending.
pub keys: Vec<(f32, V)>,
}
impl<V> KeyAnim<V> {
/// The sampling time for this loop, given the caller's two clocks: `band_t` — the host's
/// playing-clip / spawn-age seconds (the per-play arm cursor) — and `gseq_now`, the
/// instance's global-sequence cursor (`sceneClock − attachTime`; the spell-fx pool lane
/// passes the raw scene clock — see [`Self::gseq`]). A gseq loop reads `gseq_now`
/// (pre-wrapped in f64, so a long-uptime cursor keeps millisecond precision through the f32
/// narrowing); everything else keeps its band clock.
pub fn clock(&self, band_t: f32, gseq_now: f64) -> f32 {
if self.gseq && self.period > 0.0 {
(gseq_now % f64::from(self.period)) as f32
} else {
band_t
}
}
}
impl<V: Lerp> KeyAnim<V> {
/// Sample at `elapsed` seconds on this loop's own clock ([`Self::wrap`]); `empty` is the
/// channel's identity for the keyless case (the bake never emits that — each channel's
@@ -205,12 +232,13 @@ pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
}
let keys: Vec<(u32, V)> = track.keys.iter().map(|&(t, v)| (t, proj(v))).collect();
let step = track.interp == 0;
// A constant has no clock at all (`period == 0` short-circuits the sampler), so `wrap` is
// arbitrary — `true` keeps it the identity value it has always been.
// A constant has no clock at all (`period == 0` short-circuits the sampler), so `wrap` and
// `gseq` are arbitrary — `true`/`false` keep it the identity value it has always been.
let constant = |v: V| KeyAnim {
period: 0.0,
step,
wrap: true,
gseq: false,
keys: vec![(0.0, v)],
};
// An all-equal track is a constant in every band: dropped when the static path owns it, else a
@@ -236,6 +264,7 @@ pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
// A global sequence runs its own free clock, the same loop in every animation — it
// always wraps, whatever the playing sequence's own loop flag says.
wrap: true,
gseq: true,
keys: keys.iter().map(|&(t, v)| (t as f32 / 1000.0, v)).collect(),
});
}
@@ -280,6 +309,7 @@ pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
step,
// The band's own loop flag IS this loop's clock: a one-shot sequence holds its tail.
wrap: looping,
gseq: false,
keys: baked,
})
}
+56 -25
View File
@@ -53,10 +53,13 @@ pub struct AlphaSeq {
}
impl AlphaSeq {
/// The combined factor at `elapsed` seconds into this sequence.
pub fn sample(&self, elapsed: f32) -> f32 {
let c = self.color.as_ref().map_or(1.0, |a| a.sample(elapsed));
let w = self.weight.as_ref().map_or(1.0, |a| a.sample(elapsed));
/// The combined factor at `elapsed` seconds into this sequence, with `shared_now` the world's
/// shared elapsed seconds — each factor routes to its own clock ([`KeyAnim::clock`]: a gseq
/// loop reads the shared clock, a band loop the sequence time).
pub fn sample(&self, elapsed: f32, shared_now: f64) -> f32 {
let at = |a: &ScalarAnim| a.sample(a.clock(elapsed, shared_now));
let c = self.color.as_ref().map_or(1.0, at);
let w = self.weight.as_ref().map_or(1.0, at);
c * w
}
@@ -104,9 +107,10 @@ impl AlphaAnim {
.unwrap_or(IDENTITY)
}
/// The combined factor `elapsed` seconds into sequence slot `seq`.
pub fn sample(&self, seq: Option<usize>, elapsed: f32) -> f32 {
self.seq(seq).sample(elapsed)
/// The combined factor `elapsed` seconds into sequence slot `seq` (`shared_now` = the world's
/// shared elapsed seconds, for the gseq-clocked factors).
pub fn sample(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> f32 {
self.seq(seq).sample(elapsed, shared_now)
}
/// Whether ANY sequence can drive this batch's alpha to zero — i.e. whether the batch is ever
@@ -296,21 +300,21 @@ mod tests {
for step in 0..16u16 {
let t = 2.667 * f32::from(step) / 16.0;
assert_eq!(
a.sample(Some(STAND), t),
a.sample(Some(STAND), t, 0.0),
0.0,
"batch {batch} is hidden {t}s into Stand"
);
}
for slot in [1, 2, 7, 8, 9, 11] {
assert_eq!(
a.sample(Some(slot), 0.3),
a.sample(Some(slot), 0.3, 0.0),
0.0,
"batch {batch}, sequence {slot}"
);
}
// Death brings it in — the authored reason the geometry exists at all.
let peak = (0..32u16)
.map(|s| a.sample(Some(DEATH), 3.0 * f32::from(s) / 32.0))
.map(|s| a.sample(Some(DEATH), 3.0 * f32::from(s) / 32.0, 0.0))
.fold(0.0f32, f32::max);
assert!(
peak > 0.9,
@@ -322,7 +326,7 @@ mod tests {
let drawn = subs[batch]
.alpha_anim
.as_ref()
.is_none_or(|a| a.sample(Some(STAND), 0.5) > 0.0);
.is_none_or(|a| a.sample(Some(STAND), 0.5, 0.0) > 0.0);
assert!(drawn, "batch {batch} draws in Stand");
}
// **The nearest-key trap.** Batch 2 (the 271-vertex body) keys 1.0 at 3333 ms and 0.0 at
@@ -333,7 +337,7 @@ mod tests {
// "nearest".
let body = subs[2].alpha_anim.as_ref();
for slot in [5, 6, 7, 8, 9, 12] {
let v = body.map_or(1.0, |a| a.sample(Some(slot), 0.5));
let v = body.map_or(1.0, |a| a.sample(Some(slot), 0.5, 0.0));
assert!(
v > 0.0,
"the voidwalker body draws in sequence {slot} (got {v})"
@@ -378,14 +382,14 @@ mod tests {
for step in 0..8u16 {
let t = f32::from(step) * 0.4;
assert_eq!(
a.sample(Some(slot), t),
a.sample(Some(slot), t, 0.0),
0.0,
"batch {batch} is hidden {t}s into sequence {slot}"
);
}
}
let peak = (0..64u16)
.map(|k| a.sample(Some(DEATH), 4.167 * f32::from(k) / 64.0))
.map(|k| a.sample(Some(DEATH), 4.167 * f32::from(k) / 64.0, 0.0))
.fold(0.0f32, f32::max);
assert!(peak > 0.3, "batch {batch} appears on death (peak {peak})");
}
@@ -397,7 +401,7 @@ mod tests {
let v = sub
.alpha_anim
.as_ref()
.map_or(1.0, |a| a.sample(Some(0), 0.5));
.map_or(1.0, |a| a.sample(Some(0), 0.5, 0.0));
assert!(v > 0.0, "batch {i} draws while standing (got {v})");
}
let face = subs[8]
@@ -405,12 +409,12 @@ mod tests {
.as_ref()
.expect("the face batch is alpha-keyed");
assert_eq!(
face.sample(Some(0), 0.5),
face.sample(Some(0), 0.5, 0.0),
0.0,
"the scream face hides while standing"
);
let scream = (0..64u16)
.map(|k| face.sample(Some(1), f32::from(k) / 64.0))
.map(|k| face.sample(Some(1), f32::from(k) / 64.0, 0.0))
.fold(0.0f32, f32::max);
assert!(
scream > 0.5,
@@ -500,6 +504,32 @@ mod tests {
assert!((a.sample(1.5 + 0.375) - 0.6).abs() < 1e-4); // wraps
}
/// The clock router (decision 0855): a gseq loop reads the SHARED world clock — the
/// reference's free-running Phase B cursor, no per-instance arming — pre-wrapped in f64 so a
/// long-uptime `shared_now` keeps precision; a band loop keeps the host's clip time. Sampling
/// a gseq loop on a spawn-anchored clock is the Arcane Intellect sparkle bug: every cast's
/// twinkles land at phase 0.
#[test]
fn gseq_loops_read_the_shared_clock() {
let g = bake_scalar_anim(
&track(1, 1, &[(0, 0.2), (750, 1.0), (1500, 0.2)]),
&[9999, 1500],
None,
)
.unwrap();
assert!(g.gseq);
// band_t (first arg) is ignored; the shared clock decides the phase (mid-ramp → 0.6).
assert!((g.sample(g.clock(0.0, 1500.375)) - 0.6).abs() < 1e-3);
let b = ranged(1, &[(1000, 0.0), (1500, 1.0)], &[(0, 1)]);
let b = bake_at(&b, 0, (1000, 2000)).unwrap();
assert!(!b.gseq);
assert_eq!(
b.clock(0.25, 999.0),
0.25,
"a band loop keeps its own clock"
);
}
/// A sequence-timeline track keeps only the first sequence's band, rebased to seconds — keys
/// belonging to other sequences (outside the band) don't bleed in.
#[test]
@@ -660,7 +690,7 @@ mod tests {
.filter(|(_, s)| {
s.alpha_anim
.as_ref()
.is_some_and(|a| a.sample(Some(DEATH), 2.999) <= 0.0)
.is_some_and(|a| a.sample(Some(DEATH), 2.999, 0.0) <= 0.0)
})
.map(|(i, _)| i)
.collect();
@@ -673,7 +703,7 @@ mod tests {
let a = subs[i].alpha_anim.as_ref().unwrap();
for t in [3.0f32, 3.008, 10.0, 600.0] {
assert_eq!(
a.sample(Some(DEATH), t),
a.sample(Some(DEATH), t, 0.0),
0.0,
"batch {i} came back at t={t} — the parked clock aliased to the band head"
);
@@ -695,6 +725,7 @@ mod tests {
period: 0.0,
step: false,
wrap: true,
gseq: false,
keys: vec![(0.0, v)],
})
}
@@ -708,7 +739,7 @@ mod tests {
weight: dim(0.5),
}])
.expect("a dimming pair is worth carrying");
assert!((both.sample(None, 7.0) - 0.25).abs() < 1e-6);
assert!((both.sample(None, 7.0, 0.0) - 0.25).abs() < 1e-6);
assert_eq!(AlphaAnim::new(vec![AlphaSeq::default()]), None);
}
@@ -729,11 +760,11 @@ mod tests {
},
])
.unwrap();
assert_eq!(a.sample(Some(0), 0.0), 0.0);
assert_eq!(a.sample(Some(1), 0.0), 1.0);
assert_eq!(a.sample(Some(2), 0.0), 0.5);
assert_eq!(a.sample(Some(99), 0.0), 0.0, "out of range ⇒ slot 0");
assert_eq!(a.sample(None, 0.0), 0.0, "unknown sequence ⇒ slot 0");
assert_eq!(a.sample(Some(0), 0.0, 0.0), 0.0);
assert_eq!(a.sample(Some(1), 0.0, 0.0), 1.0);
assert_eq!(a.sample(Some(2), 0.0, 0.0), 0.5);
assert_eq!(a.sample(Some(99), 0.0, 0.0), 0.0, "out of range ⇒ slot 0");
assert_eq!(a.sample(None, 0.0, 0.0), 0.0, "unknown sequence ⇒ slot 0");
assert!(a.ever_hides());
let never = AlphaAnim::new(vec![AlphaSeq {
color: None,
@@ -364,6 +364,45 @@ const GROUND_FLAT_EPS: f32 = 0.01;
/// the gap.
const GROUND_HOVER_MAX: f32 = 1.0;
/// The empty batch — test scaffolding (a picker or dress test wants *a* `RenderSubmesh`, not a
/// meaningful one). No geometry, `Opaque`, WMO-style repeat sampling; every flag at its
/// nothing-authored value.
impl Default for RenderSubmesh {
fn default() -> Self {
Self {
positions: Vec::new(),
normals: Vec::new(),
uvs: Vec::new(),
indices: Vec::new(),
texture: None,
skin_slot: None,
geoset_id: 0,
char_slot: None,
blend: ModelBlend::Opaque,
wrap_x: true,
wrap_y: true,
two_sided: false,
joints: Vec::new(),
weights: Vec::new(),
vertex_colors: Vec::new(),
interior: false,
emissive: false,
sidn: None,
window: false,
additive: false,
no_depth_write: false,
no_depth_test: false,
fog_policy: FogPolicy::Scene,
billboard: None,
welded_billboard: false,
alpha_anim: None,
uv_anim: None,
rgb_anim: None,
wmo_batch: None,
}
}
}
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?
+22 -19
View File
@@ -872,7 +872,7 @@ mod tests {
let defs = parse_m2_particle_emitters(&bytes).expect("parse emitters");
assert_eq!(defs.len(), 4);
// Emitter 0 — the red spray: starts hot (100/s at t=0), the old bake's one working case.
assert_eq!(defs[0].timing.rate(None, 0.0), 100.0);
assert_eq!(defs[0].timing.rate(None, 0.0, 0.0), 100.0);
// Emitter 3 — the starflash: additive, STARFLASH texture, burst-keyed (first key 0,
// peak 20/s inside the 67–100 ms window, dead again by 133 ms).
let flash = &defs[3];
@@ -881,17 +881,17 @@ mod tests {
.as_deref()
.is_some_and(|t| t.to_ascii_uppercase().contains("STARFLASH")));
assert_eq!(flash.blend, ParticleBlend::Add);
assert_eq!(flash.timing.rate(None, 0.0), 0.0, "silent at t=0");
assert_eq!(flash.timing.rate(None, 0.0, 0.0), 0.0, "silent at t=0");
assert_eq!(flash.timing.peak_rate(), 20.0);
assert_eq!(flash.timing.rate(None, 0.080), 20.0);
assert_eq!(flash.timing.rate(None, 0.200), 0.0);
assert_eq!(flash.timing.rate(None, 0.080, 0.0), 20.0);
assert_eq!(flash.timing.rate(None, 0.200, 0.0), 0.0);
// Emitters 1/2 — the glowball droplets, same burst shape, peaks 200.
assert_eq!(defs[1].timing.peak_rate(), 200.0);
assert_eq!(defs[2].timing.peak_rate(), 200.0);
// The spray's enabled track cuts emission at 500 ms — exactly where its rate track goes
// negative (the authored tail the old always-on read let the floor-at-0 hide).
assert!(defs[0].timing.emitting(None, 0.4));
assert!(!defs[0].timing.emitting(None, 0.6));
assert!(defs[0].timing.emitting(None, 0.4, 0.0));
assert!(!defs[0].timing.emitting(None, 0.6, 0.0));
}
/// The real `Feint_Impact_Chest.m2` (the Eviscerate/Feint impact) — the BURST-flag split
@@ -921,9 +921,9 @@ mod tests {
assert!(impact[3].burst(), "crescents are a one-shot burst");
// The STEP rate law that arms the burst count: silent before the 67 ms key, the full
// value at it, held past it (where the burst latch keeps it from ever mattering).
assert_eq!(impact[0].timing.rate(None, 0.050), 0.0);
assert_eq!(impact[0].timing.rate(None, 0.067), 30.0);
assert_eq!(impact[0].timing.rate(None, 1.500), 30.0);
assert_eq!(impact[0].timing.rate(None, 0.050, 0.0), 0.0);
assert_eq!(impact[0].timing.rate(None, 0.067, 0.0), 30.0);
assert_eq!(impact[0].timing.rate(None, 1.500, 0.0), 30.0);
let cast = parse_m2_particle_emitters(
&chain
.read_file("Spells\\Eviscerate_Cast_Hands.m2")
@@ -964,7 +964,7 @@ 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);
let now = d.params.sample(None, 0.0);
let now = d.params.sample(None, 0.0, 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");
@@ -1001,11 +1001,11 @@ mod tests {
assert_eq!(cast.len(), 3);
for em in &cast {
assert!(
em.timing.emitting(None, 0.1),
em.timing.emitting(None, 0.1, 0.0),
"the flash is ON at its start"
);
assert!(
!em.timing.emitting(None, 0.25),
!em.timing.emitting(None, 0.25, 0.0),
"and OFF from 200 ms — the 1.0 s clip does not burn through"
);
}
@@ -1016,22 +1016,25 @@ mod tests {
)
.expect("parse emitters");
assert_eq!(impact.len(), 6);
assert_eq!(impact[0].timing.rate(None, 0.0), 0.0);
assert_eq!(impact[0].timing.rate(None, 0.0, 0.0), 0.0);
assert_eq!(
impact[0].timing.rate(None, 0.133),
impact[0].timing.rate(None, 0.133, 0.0),
60.0,
"the plume bursts at impact"
);
assert!(impact[1].timing.emitting(None, 0.1));
assert!(impact[1].timing.emitting(None, 0.1, 0.0));
assert!(
!impact[1].timing.emitting(None, 0.4),
!impact[1].timing.emitting(None, 0.4, 0.0),
"shockwave window is 300 ms"
);
assert!(
!impact[4].timing.emitting(None, 0.05),
!impact[4].timing.emitting(None, 0.05, 0.0),
"smoke starts staggered…"
);
assert!(impact[4].timing.emitting(None, 0.2));
assert!(!impact[4].timing.emitting(None, 0.7), "…and ends by 567 ms");
assert!(impact[4].timing.emitting(None, 0.2, 0.0));
assert!(
!impact[4].timing.emitting(None, 0.7, 0.0),
"…and ends by 567 ms"
);
}
}
+10 -10
View File
@@ -32,7 +32,7 @@ fn campfire_emitters_match_real_bytes() {
for e in &emitters {
assert_eq!(e.shape, ParticleShape::Plane);
assert_eq!(e.blend, ParticleBlend::Add);
let now = e.params.sample(None, 0.0);
let now = e.params.sample(None, 0.0, 0.0);
assert!(now.lifespan > 0.0 && now.lifespan.is_finite());
let rate = e
.timing
@@ -50,7 +50,7 @@ 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);
let glow_now = glow.params.sample(None, 0.0, 0.0);
assert!(
(glow_now.lifespan - 4.0).abs() < 1e-3,
"glow lifespan ~4.0, got {}",
@@ -66,7 +66,7 @@ fn campfire_emitters_match_real_bytes() {
// Flame: short life, high rate, narrow cone, 4×4 cell animation.
let flame = &emitters[1];
let flame_now = flame.params.sample(None, 0.0);
let flame_now = flame.params.sample(None, 0.0, 0.0);
assert!(
(flame_now.lifespan - 1.5).abs() < 1e-3,
"flame lifespan ~1.5, got {}",
@@ -113,9 +113,9 @@ fn campfire_emitters_match_real_bytes() {
e.shape,
e.blend,
e.texture,
e.params.sample(None, 0.0).lifespan,
e.params.sample(None, 0.0, 0.0).lifespan,
e.timing.constant_rate(),
e.params.sample(None, 0.0).vertical_range,
e.params.sample(None, 0.0, 0.0).vertical_range,
e.tile_rows,
e.tile_cols
);
@@ -461,29 +461,29 @@ fn barrel_explode_emitters_are_off_at_rest_and_fire_in_their_clips() {
for slot in [1usize, 2] {
for t in [0.0f32, 0.1, 0.3, 5.0] {
assert!(
!e.timing.emitting(Some(slot), t),
!e.timing.emitting(Some(slot), t, 0.0),
"emitter {i} must be OFF at rest (slot {slot}, t {t})"
);
}
}
// The one-shot explode clip (slot 0, anim 157, 1 s clamped): the choreography fires
// somewhere inside it…
let fires = (0..100).any(|k| e.timing.emitting(Some(0), k as f32 * 0.01));
let fires = (0..100).any(|k| e.timing.emitting(Some(0), k as f32 * 0.01, 0.0));
assert!(fires, "emitter {i} fires inside the explode clip");
// …and the clamped clock PARKS OFF at/after the clip end — the exact end state the old
// rebase got wrong (it clamped a later clip's ON key onto the band end).
assert!(
!e.timing.emitting(Some(0), 1.0),
!e.timing.emitting(Some(0), 1.0, 0.0),
"emitter {i} must be off at the clip end"
);
assert!(
!e.timing.emitting(Some(0), 30.0),
!e.timing.emitting(Some(0), 30.0, 0.0),
"emitter {i} must stay off parked past the clip"
);
}
// The Destroy clip (slot 3, anim 150) fires the explosion too — the quest's actual payoff.
let destroy_fires = emitters
.iter()
.any(|e| (0..100).any(|k| e.timing.emitting(Some(3), k as f32 * 0.01)));
.any(|e| (0..100).any(|k| e.timing.emitting(Some(3), k as f32 * 0.01, 0.0)));
assert!(destroy_fires, "the Destroy clip plays the explosion");
}
+1
View File
@@ -227,6 +227,7 @@ fn app_with_chains() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<crate::creature_anim::SpellKitFx>();
app.add_message::<crate::creature_anim::SpellKitSound>();
app.add_message::<AuraProc>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
+82 -18
View File
@@ -49,10 +49,20 @@ pub struct BillboardCard {
/// The billboard bone's looping scale animation (the lamppost glow "breathe"), sampled each frame
/// and multiplied into [`Self::scale`]. `None` for a static card (no global-sequence scale track).
scale_anim: Option<BoneScaleAnim>,
/// Per-instance phase offset (ms) into the global sequence, hashed from the world position — so a
/// row of identical lampposts breathes out of lockstep (each prop instance arms its own clock) rather
/// than blinking in unison. No-op when there's no `scale_anim`.
phase_ms: u32,
/// The armed-sequence cursor offset (ms, negated so a wrapping ADD subtracts): sampling the
/// [`Self::seq_translation`] loop runs on `elapsed − arm_ms` — the reference's per-play
/// phase for SEQUENCE tracks (`cursor = clock − startOffset`, re-baked at every arm). `0`
/// until [`Self::arm_seq_translation`] arms a loop.
arm_neg_ms: u32,
/// The gseq [`Self::scale_anim`]'s ATTACH anchor (ms): `None` until the first placement pass
/// stamps it — the reference snapshots the scene clock once per model instance at attach
/// (`CM2Model+0x68`, wow-re `gseq-anchor.md`; decision 0856), so a row of lampposts streamed
/// in on different frames breathes at per-instance phases, while same-frame spawns share
/// one. Distinct from [`Self::arm_neg_ms`]: the gseq anchor is stamped once per instance,
/// the sequence cursor re-arms per play. (An earlier position-hash de-sync here emulated
/// the per-instance spread with an invented mechanism; 0855 briefly removed phase entirely —
/// both superseded by the byte law.)
gseq_attach_ms: Option<u32>,
/// The bone's armed first-sequence **translation** loop (the questgiver `?` marker's bob, keys in
/// Bevy axes) — sampled each frame on the same clock/phase and added at the pivot, rotated by
/// [`Self::placement_rot`]. `None` (every doodad card today) = the static pivot; only the marker
@@ -78,16 +88,13 @@ impl BillboardCard {
/// regardless of how the prop is turned).
pub fn new(info: &BillboardInfo, placement: Transform) -> Self {
let world_pivot = placement.transform_point(info.pivot);
// A stable per-instance phase from the world position (same hashing as the particle RNG seed).
let phase_ms = world_pivot.x.to_bits().wrapping_mul(0x9E37_79B9)
^ world_pivot.y.to_bits().rotate_left(11)
^ world_pivot.z.to_bits().rotate_left(22);
Self {
world_pivot,
scale: placement.scale.x,
kind: info.kind,
scale_anim: info.scale_anim.clone(),
phase_ms,
arm_neg_ms: 0,
gseq_attach_ms: None,
seq_translation: None,
placement_rot: placement.rotation,
follow: None,
@@ -142,7 +149,8 @@ impl BillboardCard {
scale: 1.0,
kind,
scale_anim: None,
phase_ms: 0,
arm_neg_ms: 0,
gseq_attach_ms: None,
seq_translation: None,
placement_rot: Quat::IDENTITY,
follow: Some(owner),
@@ -152,8 +160,7 @@ impl BillboardCard {
/// Arm the card's first-sequence translation loop (the questgiver `?` bob) with the client's
/// arm-time cursor: sampling runs on `elapsed − arm_ms` (the loop starts at its first key the
/// moment the marker attaches, like the real arm at status receive). Overrides the position-hash
/// phase — markers of one NPC swap in place and must not inherit a doodad-flavored phase.
/// moment the marker attaches, like the real arm at status receive).
pub(crate) fn with_seq_translation(mut self, anim: Option<BoneScaleAnim>, arm_ms: u32) -> Self {
self.arm_seq_translation(anim, arm_ms);
self
@@ -164,7 +171,7 @@ impl BillboardCard {
/// [`Self::with_seq_translation`].
pub(crate) fn arm_seq_translation(&mut self, anim: Option<BoneScaleAnim>, arm_ms: u32) {
if anim.is_some() {
self.phase_ms = arm_ms.wrapping_neg();
self.arm_neg_ms = arm_ms.wrapping_neg();
}
self.seq_translation = anim;
}
@@ -519,18 +526,25 @@ pub(crate) fn face_billboards(
}
}
}
// The gseq attach anchor: stamped on the card's first placement pass — the reference's
// once-per-instance scene-clock snapshot (decision 0856).
let attach_ms = *card.gseq_attach_ms.get_or_insert(elapsed_ms);
let card = &*card;
let rotation = billboard_basis(card.kind, card.placement_rot, fwd, right, up);
// The bone's global-sequence scale pulse (the lamppost glow "breathe"), sampled at this prop's
// own phase into the loop. `Vec3::ONE` (no-op) when the card has no scale track.
// The bone's global-sequence scale pulse (the lamppost glow "breathe"), on the instance's
// anchored cursor (`sceneNow − attach`): instances stamped on different frames pulse at
// per-instance phases, same-frame spawns in phase — the reference's anchor law.
// `Vec3::ONE` (no-op) when the card has no scale track.
let pulse = card.scale_anim.as_ref().map_or(Vec3::ONE, |a| {
Vec3::from_array(a.sample(elapsed_ms.wrapping_add(card.phase_ms)))
Vec3::from_array(a.sample(elapsed_ms.wrapping_sub(attach_ms)))
});
// The armed first-sequence translation loop (the questgiver `?` bob): a model-local offset
// at the pivot, pointed by the placement rotation and sized by its scale.
// at the pivot, pointed by the placement rotation and sized by its scale — on the ARM
// cursor (`elapsed − arm_ms`), the sequence-track half of the clock law.
let bob = card.seq_translation.as_ref().map_or(Vec3::ZERO, |a| {
card.placement_rot
* (Vec3::from_array(a.sample(elapsed_ms.wrapping_add(card.phase_ms))) * card.scale)
* (Vec3::from_array(a.sample(elapsed_ms.wrapping_add(card.arm_neg_ms)))
* card.scale)
});
*tf = Transform {
translation: card.world_pivot + bob,
@@ -577,6 +591,56 @@ mod tests {
use super::*;
use benilla_assets::BillboardInfo;
/// **Geometry lying along a bone's own axis does not sweep** — the fact decision 0847 got wrong
/// and 0853 restored, pinned here in our own basis rather than trusted to algebra.
///
/// The R14 pauldron's spikes run along WoW **−Z** (`seamswing`: mean |z| 0.284/0.291 against
/// ≤0.06 on x and y, worst vertex 12° off axis), which is Bevy local **−Y**. A spherical
/// billboard maps WoW Z to the camera's up, so that direction must come out as **−up — screen
/// DOWN — from every camera orientation**, never tracing an arc. 0847 read the spikes' 0.29 yd
/// length as a 0.29 yd arc through the plate and withdrew a correct change on it; if that arc
/// were real, this test is where it would show up as a direction that moves with the camera.
///
/// The `kept_rot` argument is swept too: the spherical arm discards the pre-billboard rotation
/// outright (wow-re `billboard-bone-law.md` §1), so the wearer's shoulder yaw must not reach
/// the result — which is *also* why the spike stops following the shoulder, the real visible
/// difference the arm makes (§6.3).
#[test]
fn a_spike_along_its_bone_axis_points_screen_down_from_every_angle() {
// Bevy local −Y is the pauldron spike's run axis (WoW −Z through coords.rs' X→−Z, Y→−X,
// Z→+Y).
let spike_local = Vec3::NEG_Y;
for (yaw, pitch) in [
(0.0, 0.0),
(0.7, 0.0),
(2.4, 0.0),
(-1.9, 0.0),
(std::f32::consts::PI, 0.0),
(0.0, 0.5),
(1.2, -0.6),
(-2.8, 0.9),
] {
let cam =
Transform::from_rotation(Quat::from_rotation_y(yaw) * Quat::from_rotation_x(pitch));
let (fwd, right, up) = (*cam.forward(), *cam.right(), *cam.up());
for kept in [
Quat::IDENTITY,
Quat::from_rotation_y(1.1),
Quat::from_rotation_x(-0.8),
Quat::from_rotation_z(2.2),
] {
let got =
billboard_basis(BillboardKind::Spherical, kept, fwd, right, up) * spike_local;
assert!(
got.distance(-up) < 1e-5,
"spike must point screen-down, not sweep: cam yaw {yaw} pitch {pitch}, \
kept {kept:?} → {got:?}, expected {:?}",
-up
);
}
}
}
/// A FOLLOWING card (decision 0153 — the entity-path glow cards) re-seats from its owner's
/// live global transform each frame and despawns with it: the brazier glow burns at the bowl
/// (owner translation + authored pivot), never the model origin — and dies when the owner
+7 -11
View File
@@ -37,13 +37,12 @@
//! `viewport_to_world` works in logical units and a Retina capture is 2× the logical window.
use bevy::mesh::MeshTag;
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::probes::ProbeClock;
use crate::interact::WorldObject;
use crate::interact::{cast_pick_ray, PickParts, WorldObject};
use crate::player::WorldCamera;
use crate::terrain::WowModelMaterial;
@@ -190,7 +189,7 @@ fn fire_pick(
// the probe keys on it too and stays a pure instrument: nothing outside this file reads it.
objects: Query<Entity, Pickable>,
names: HitNames,
mut ray_cast: MeshRayCast,
parts: PickParts,
) {
if probe.taken >= probe.count || probe.pixels.is_empty() || time.elapsed_secs() < probe.next_at
{
@@ -210,14 +209,11 @@ fn fire_pick(
warn!("pick ({}, {}): outside the viewport", pixel.x, pixel.y);
continue;
};
let filter = |e: Entity| pickable.contains(&e);
// `never_early_exit` is the whole reason this exists: the default stops at the first hit,
// and the rival surface behind it is exactly what we came for.
let settings = MeshRayCastSettings::default()
.with_visibility(RayCastVisibility::VisibleInView)
.with_filter(&filter)
.never_early_exit();
let hits = ray_cast.cast_ray(ray, &settings);
// `all_hits` is the whole reason this exists: the nearest hit alone cannot name the rival
// surface behind it, which is exactly what we came for. The cast reads each part's
// RESIDENT geometry (decision 0857) — the render meshes are `RENDER_WORLD`-only since
// 0834, so a `MeshRayCast` here reports nothing for any static model.
let hits = cast_pick_ray(ray, &pickable, &parts, true);
// The camera's GLOBAL transform, bit-exact, on the line that carries the cast index — so
// "did the camera move on this frame?" is answerable against "was this frame dim?" without
// aligning two logs by hand. It is deliberately *this* transform: it is the one
+1 -1
View File
@@ -1131,7 +1131,7 @@ fn fire_particle_census(
d.blend,
d.flags,
rate_keys.join(","),
d.params.sample(None, 0.0).lifespan,
d.params.sample(None, 0.0, 0.0).lifespan,
d.texture.as_deref().unwrap_or("-"),
e.live(),
// The frame's composed MODEL alpha (decision 0827/0833) — the number that answers
+114 -69
View File
@@ -1,15 +1,25 @@
//! Global-sequence bone channels — free-clock loops *independent* of the playing animation (the
//! character eye-blink eyelid scale; resting fidget pulses). benilla's per-sequence reader deliberately
//! drops them: they key off their own global-sequence timer, not the playing sequence's time band
//! (`benilla_formats::parse_m2_animations`). This samples them on a per-instance clock and writes the
//! driven joint components *after* the [`AnimationPlayer`] posed the skeleton.
//! character eye-blink eyelid scale; resting fidget pulses; a spell effect's star twinkles).
//! benilla's per-sequence reader deliberately drops them: they key off their own global-sequence
//! timer, not the playing sequence's time band (`benilla_formats::parse_m2_animations`). This
//! samples them on the instance's global-sequence clock and writes the driven joint components
//! *after* the [`AnimationPlayer`] posed the skeleton.
//!
//! Ground truth (wow-5875-re `animation.md` / `doodad-anim-host.md`): global sequences loop with **zero
//! arming**, clock-driven off the model's own time field, wrapped modulo each sequence's duration —
//! `globalSequences[gseq]`. There is no per-frame re-arm; the default-animation op4 and the global
//! sequences coexist, the latter purely clock-driven. The canonical consumer is the eyelid: its scale is
//! `0` (lid retracted, eye open) for ~96% of the loop and `1` (lid full, eye shut) for ~100 ms — the
//! blink. Without this pass the eyelid sits at its default identity scale (full size) forever: eyes shut.
//! Ground truth (wow-5875-re `gseq-anchor.md`, byte-verified): the cursor is
//! `(sceneClock − instanceAttachTime) % duration` — ONE free-running per-scene ms clock
//! (`[scene+0xc]`, advanced once per scene update), snapshotted ONCE per model instance at attach
//! (`CM2Model+0x68`, written unconditionally at `0x70eae1`). So a fresh instance starts its
//! global sequences at phase 0, and two instances attached on different frames run at different
//! phases — per-INSTANCE anchoring, not per-play arming (sequence tracks re-arm per play; the
//! gseq anchor is stamped once). Spell effects are NOT an exception: the lifecycle is
//! byte-verified fresh-per-play (wow-re `gseq-instance-lifecycle.md`: CreateModel always
//! alloc→ctor→attach, teardown is a hard free, no pooling), and the director's own 3-cast
//! apitrace shows the impact flash at the same +16-frame offset every cast — the apparent
//! cast-to-cast scatter is particle randomness plus the moving cast-anim hands, not clock
//! phase (decisions 0855/0856/0858). The canonical creature consumer is the eyelid:
//! its scale is `0` (lid retracted, eye open) for ~96% of the loop and `1` (lid full, eye shut)
//! for ~100 ms — the blink. Without this pass the eyelid sits at its default identity scale
//! (full size) forever: eyes shut.
use bevy::prelude::*;
@@ -23,17 +33,21 @@ enum SeqTarget {
}
/// Per-instance driver for a model's global-sequence bone channels: each channel's write target
/// paired with its baked channels, plus the free-running clock they wrap on. Attached beside the
/// paired with its baked channels, plus the instance's clock **anchor** — the attach-time
/// snapshot of the shared scene clock (`CM2Model+0x68`, module docs). Attached beside the
/// [`AnimationPlayer`] on a skinned instance whose model carries any global-sequence track.
#[derive(Component)]
pub(crate) struct GlobalSeqDrive {
/// `(write target, its baked global-sequence channels)`.
bones: Vec<(SeqTarget, GlobalBone)>,
/// Seconds since spawn — the model clock each channel wraps (`t mod period`).
elapsed: f32,
/// Paused: skip sampling entirely (the doodad host gates animation to drawn instances — wow-re
/// `doodad-anim-host.md`: the ref's kernel ticks at draw time, so a culled model isn't evaluated).
/// Creatures never pause. A resume re-syncs the clock via [`Self::sync`], so pausing is drift-free.
/// The attach snapshot of the shared clock (secs): `None` until the first animate tick stamps
/// it (the instance's attach — the ref writes `+0x68` once, at attach).
anchor: Option<f64>,
/// Paused: skip the joint writes (the doodad host gates animation to drawn instances — wow-re
/// `doodad-anim-host.md`: the ref's kernel ticks at draw time, so a culled model isn't
/// evaluated). Creatures never pause. Resuming needs no re-seek: the cursor is a pure
/// function of the shared clock and the attach anchor, so a re-appearing doodad shows the
/// pose the clock dictates.
paused: bool,
}
@@ -52,7 +66,7 @@ impl GlobalSeqDrive {
.collect();
(!bones.is_empty()).then_some(Self {
bones,
elapsed: 0.0,
anchor: None,
paused: false,
})
}
@@ -67,48 +81,41 @@ impl GlobalSeqDrive {
.collect();
(!bones.is_empty()).then_some(Self {
bones,
elapsed: 0.0,
anchor: None,
paused: false,
})
}
/// Pause/resume sampling (the doodad draw gate). While paused the joints hold their last pose.
/// Pause/resume the joint writes (the doodad draw gate). While paused the joints hold their
/// last pose; resume lands on the anchored cursor with nothing to catch up.
pub(crate) fn set_paused(&mut self, paused: bool) {
self.paused = paused;
}
/// Re-seat the clock at `elapsed` seconds since spawn — the resume-from-pause seek that keeps the
/// channel clock-indexed like the ref's (`cursor = clock − startOffset`): a re-appearing doodad
/// shows the pose the shared clock dictates, and same-frame spawns stay in phase across pauses.
pub(crate) fn sync(&mut self, elapsed: f32) {
self.elapsed = elapsed;
}
}
/// Sample each instance's global-sequence channels on its own clock and write the driven bone —
/// in the pose post-pass window ([`super::PosePost`], the same as the body twist), so the model
/// compose folds it. A channel overwrites only its own component; a bone the playing animation
/// never keyed (the eyelid) keeps its rest translation/rotation and takes only the global scale,
/// so the eye opens and blinks over whatever gait is playing.
/// Sample every drive's channels at its anchored cursor (`sharedNow − anchor`, stamping the
/// anchor on the first tick — the attach) and write the driven bone — in the pose post-pass
/// window ([`super::PosePost`], the same as the body twist), so the model compose folds it. A
/// channel overwrites only its own component; a bone the playing animation never keyed (the
/// eyelid) keeps its rest translation/rotation and takes only the global scale, so the eye opens
/// and blinks over whatever gait is playing. The cursor wraps per channel in f64 (`t % period`)
/// so a long-uptime clock keeps millisecond precision through the f32 sampler.
fn apply_global_sequences(
time: Res<Time>,
mut drives: Query<(Entity, &mut GlobalSeqDrive, Has<super::AnimParked>)>,
mut joints: Query<&mut Transform>,
mut rigs: Query<&mut super::RigPose>,
) {
let dt = time.delta_secs();
let now = time.elapsed_secs_f64();
for (host, mut drive, parked) in &mut drives {
if drive.paused {
// The attach stamp happens even while parked/paused — the ref stamps +0x68 at attach,
// not at first draw.
let t = now - *drive.anchor.get_or_insert(now);
// A parked or paused instance skips only the WRITES — the cursor is absolute
// (decision 0448's absolute-clock ruling, now literal: nothing per-instance advances).
if drive.paused || parked {
continue;
}
// A parked unit's channel clock keeps running — only the bone writes stop (decision
// 0448: units are absolute-clock, never freeze-and-resume; the doodad lane pauses via
// [`GlobalSeqDrive::set_paused`] + [`GlobalSeqDrive::sync`] instead and never parks).
drive.elapsed += dt;
if parked {
continue;
}
let t = drive.elapsed;
let mut rig = rigs.get_mut(host).ok();
for (target, bone) in &drive.bones {
let tf: &mut Transform = match target {
@@ -127,14 +134,15 @@ fn apply_global_sequences(
tf
}
};
let at = |period: f32| (t % f64::from(period.max(1e-3))) as f32;
if let Some(c) = &bone.translation {
tf.translation = c.sample(t);
tf.translation = c.sample(at(c.period));
}
if let Some(c) = &bone.rotation {
tf.rotation = c.sample(t);
tf.rotation = c.sample(at(c.period));
}
if let Some(c) = &bone.scale {
tf.scale = c.sample(t);
tf.scale = c.sample(at(c.period));
}
}
}
@@ -168,40 +176,77 @@ mod tests {
}
}
/// The driver writes the sampled global-sequence scale onto the mapped joint each tick — the eyelid
/// reads `1` (shut) when the clock sits in the blink window and `0` (open) the rest of the loop, so a
/// skinned character actually opens and blinks instead of freezing lid-down. Time advances by real
/// dt; both clocks sit on a constant plateau of the channel, so the assert is jitter-proof.
/// A linear-ramp channel (`scale = t`, long period) — phase differences are visible at any
/// absolute elapsed.
fn ramp() -> GlobalBone {
GlobalBone {
bone: 0,
translation: None,
rotation: None,
scale: Some(GlobalSeqChannel {
period: 100.0,
keys: vec![(0.0, Vec3::ZERO), (100.0, Vec3::splat(100.0))],
}),
}
}
/// The anchor law (0856): a FRESH drive stamps its attach on its first tick, so a drive
/// spawned later reads a SMALLER cursor than one spawned earlier (per-instance phase — two
/// creatures attached on different frames blink at different times). Ticks are
/// deterministic via `TimeUpdateStrategy::ManualDuration`.
#[test]
fn driver_writes_eyelid_scale_from_the_clock() {
fn fresh_drives_anchor_at_attach() {
let mut app = App::new();
app.add_plugins(MinimalPlugins); // Time
app.add_plugins(MinimalPlugins);
app.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
std::time::Duration::from_millis(60),
));
app.add_systems(Update, apply_global_sequences);
// Two instances: one clock parked mid-blink (shut), one parked in the open tail.
let shut_joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut().spawn(GlobalSeqDrive {
bones: vec![(SeqTarget::Joint(shut_joint), eyelid_bone())],
elapsed: 0.06, // inside [0.033, 0.100] → scale 1
paused: false,
});
let open_joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut().spawn(GlobalSeqDrive {
bones: vec![(SeqTarget::Joint(open_joint), eyelid_bone())],
elapsed: 3.0, // inside [0.133, 6.633] → scale 0
paused: false,
});
let early_joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut()
.spawn(GlobalSeqDrive::new(&[ramp()], &[early_joint]).expect("a keyed channel maps"));
app.update();
app.update();
// A drive attached two ticks later.
let late_joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut()
.spawn(GlobalSeqDrive::new(&[ramp()], &[late_joint]).expect("a keyed channel maps"));
app.update();
app.update();
let scale = |e: Entity| app.world().entity(e).get::<Transform>().unwrap().scale;
let s = |e: Entity| app.world().entity(e).get::<Transform>().unwrap().scale.x;
assert!(
scale(shut_joint).abs_diff_eq(Vec3::ONE, 1e-3),
"eyelid shut mid-blink"
s(early_joint) > s(late_joint),
"the later attach reads a smaller cursor (per-instance anchor): early {} vs late {}",
s(early_joint),
s(late_joint)
);
assert!(
scale(open_joint).abs_diff_eq(Vec3::ZERO, 1e-3),
"eyelid open the rest of the loop"
s(late_joint) > 0.0,
"the late drive did tick from its own anchor (got {})",
s(late_joint)
);
}
/// The eyelid channel itself still samples correctly at a given cursor — the blink window
/// reads shut, the long tail reads open (the channel sampler is untouched by the anchor
/// work; only who supplies `t` changed).
#[test]
fn eyelid_channel_samples_by_clock_value() {
let bone = eyelid_bone();
let c = bone.scale.as_ref().unwrap();
assert!(
c.sample(0.06).abs_diff_eq(Vec3::ONE, 1e-3),
"shut mid-blink"
);
assert!(
c.sample(3.0).abs_diff_eq(Vec3::ZERO, 1e-3),
"open in the tail"
);
assert!(
c.sample(3.0 + 2.0 * 6.633).abs_diff_eq(Vec3::ZERO, 1e-3),
"wraps on its period"
);
}
}
@@ -62,6 +62,12 @@ pub(crate) enum SpellKitSound {
/// The cast/channel hold ended (GO / fail / channel-clear / a replacing cast) — reap the
/// unit's tracked hold loop, if any (the client kills the effect's sound at `0x614150`).
StopHold { entity: Entity },
/// Stop exactly `kit_sound`'s channels on this unit — the aura-drop reap of a LOOPING
/// **state**-kit sound (wow-re `kit-sound-leg.md`: a looping kit sound rides a tracked,
/// spell-id-tagged CEffect on the unit's list, and `0x614150` stops it with a 0.15 s fade
/// when the aura leaves). Kit-scoped so an unrelated hold loop on the same unit survives;
/// a one-shot kit is never tracked, so for it this is a no-op.
StopKit { entity: Entity, kit_sound: u32 },
}
/// A persistent effect instance's **lifetime class** — which owner's reap can kill it.
@@ -317,9 +323,11 @@ struct KitOut<'a, 'w1, 'w2, 'w3, 'w4> {
/// - `persistent`: the effect models' lifetime (the precast/channel hold vs self-terminating).
/// - `effects`: whether the models spawn here at all. `false` for the impact hand-off's STATE
/// stage — its models belong to the aura's life and [`arm_aura_state_fx`] owns them.
/// - `sound`: whether the kit's sound rings. The STATE stage's sound is SELF-ONLY
/// (byte-verified: the stage-2 play's sound leg is gated on IsActivePlayer, `0x5fa6d0` —
/// wow-re `state-kit-aura-lifecycle.md`), so observers never hear another unit's buff kit.
/// - `sound`: whether the kit's sound rings — **ungated and positional at the unit, every
/// stage** (wow-re `kit-sound-leg.md`, decision 0852: `0x60edf0`'s sound leg carries no self
/// test; the old "state sound is self-only" reading was `0x5ff43e`'s gate on a *different*
/// aura-apply cue, and `0x5fa6d0` appears nowhere in the kit play). The flag exists for
/// plays that deliberately hand the sound to another owner (none today).
#[derive(Clone, Copy)]
struct KitPlay {
persistent: bool,
@@ -380,16 +388,17 @@ fn play_kit(
/// kit plays its anim + sound only: its **effect models live for the aura's life**, owned by
/// [`arm_aura_state_fx`]'s slot watcher (a single instance, begun when the aura lands in the
/// slots and reaped when it leaves — the same packet burst as the GO, so there is no visible
/// gap). Named approximation: the client's small stage-2 gate `0x61dc20` (37 B, content
/// unpinned) is not modeled. `weapon_visual` = the CASTER's ranged fallback visual (already
/// resolved — the caster may be gone by a missile arrival), through which a basic shot's impact
/// kit resolves. `is_self` gates the state stage's sound (self-only, see [`play_kit`]).
#[allow(clippy::too_many_arguments)] // one hand-off's full input set
/// gap). The state sound rings **ungated and positional** (wow-re `kit-sound-leg.md`, decision
/// 0852: `0x60edf0`'s sound leg has no self test — the old self-only reading belonged to a
/// different aura-apply cue); the same-frame duplicate against the aura watcher's ADD-edge play
/// collapses in the sound router. Named approximation: the client's small stage-2 gate
/// `0x61dc20` (37 B, content unpinned) is not modeled. `weapon_visual` = the CASTER's ranged
/// fallback visual (already resolved — the caster may be gone by a missile arrival), through
/// which a basic shot's impact kit resolves.
fn play_impact(
entity: Entity,
spell_id: u32,
weapon_visual: Option<u32>,
is_self: bool,
seq: u64,
spells: &crate::ui_action::Spells,
visuals: &SpellVisualCatalog,
@@ -404,7 +413,6 @@ fn play_impact(
|s: &VisualStages| s.state,
KitPlay {
effects: false,
sound: is_self,
..KitPlay::DISCRETE
},
),
@@ -452,7 +460,7 @@ pub(super) fn route_cast_visuals(
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut weapon_src: WeaponVisualSrc,
units: Query<(Entity, &ObjectStore, Has<crate::net::SelfPlayer>)>,
units: Query<(Entity, &ObjectStore)>,
holds: Query<&CastHold>,
mut channel_cache: Local<EntityHashMap<u32>>,
) {
@@ -675,7 +683,6 @@ pub(super) fn route_cast_visuals(
ev.entity,
ev.spell_id,
weapon_visual,
units.get(ev.entity).is_ok_and(|(.., is_self)| is_self),
ev.seq,
spells,
&visuals.0,
@@ -733,7 +740,6 @@ pub(super) fn route_cast_visuals(
target,
go.spell_id,
wv,
units.get(target).is_ok_and(|(.., is_self)| is_self),
go.seq,
spells,
&visuals.0,
@@ -796,7 +802,7 @@ pub(super) fn route_cast_visuals(
// unit's channel loop (the self-only MSG_CHANNEL_* never carry this — decision 0099). Only
// *edges* act, so a held channel's clip is started once (the client's dedup guard) and a
// cleared field releases the hold even when the interrupt had no other wire trace.
for (entity, store, _) in &units {
for (entity, store) in &units {
let cur = store.0.unit_channel_spell();
let prev = channel_cache.get(&entity).copied().unwrap_or(0);
if cur == prev {
@@ -877,12 +883,14 @@ pub(super) fn route_cast_visuals(
/// The trigger and reap are byte-verified (wow-re `state-kit-aura-lifecycle.md`, §5 2026-07-14):
/// the aura watcher `0x604d00 → 0x6123f0 → 0x5ff350` reads `SpellVisual` field 4 and plays the
/// kit at stage 2 (`0x5ff4c2: push 2`); the remove path `0x612320 → 0x5ff290` reaps with
/// `0x614150(spellId, force=1)`. The ADD edge's sound is SELF-gated (`0x5fa6d0` IsActivePlayer),
/// so observers hear nothing — and its body anim is NOT code-suppressed (`0x60edf0`'s tail plays
/// `kit+0x08` unconditionally). This watcher plays effects only — correct for every state kit
/// whose anim column is the none-sentinel (food's 409 is), and a **named residual** for a state
/// kit that carries a real anim: that ADD-edge replay isn't built (no live kit demonstrates it;
/// build it from the verdict when one shows).
/// `0x614150(spellId, force=1)`. The ADD edge's kit sound rings **ungated and positional** (wow-re
/// `kit-sound-leg.md`, decision 0852 — its §5 corrected the earlier "SELF-gated" reading: the
/// `0x5fa6d0` gate at `0x5ff43e` covers only a separate spellRec-driven aura-apply cue, and both
/// branches fall through to the ungated kit play at `0x5ff4c6`); a looping kit sound is tracked
/// and stops with the aura ([`SpellKitSound::StopKit`]). The body anim is NOT code-suppressed
/// (`0x60edf0`'s tail plays `kit+0x08` unconditionally) — this watcher plays effects + sound,
/// with a **named residual** for a state kit carrying a real anim: that ADD-edge replay isn't
/// built (no live kit demonstrates it; build it from the verdict when one shows).
///
/// `armed` tracks exactly the (unit, spell) pairs this watcher began, so the REMOVE edge never
/// reaps another owner's persistent instances (a channel hold whose spell also rides an aura).
@@ -905,6 +913,7 @@ pub(crate) fn arm_aura_state_fx(
spells: Option<Res<crate::ui_action::Spells>>,
mut fx: MessageWriter<SpellKitFx>,
mut procs: MessageWriter<AuraProc>,
mut sounds: MessageWriter<SpellKitSound>,
mut armed: Local<EntityHashMap<Vec<u32>>>,
) {
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
@@ -925,6 +934,14 @@ pub(crate) fn arm_aura_state_fx(
class: FxClass::AuraState,
});
procs.write(AuraProc::Reap { entity, spell_id });
// A LOOPING state-kit sound is a tracked hold that dies with the aura
// (`0x614150`'s 0.15 s fade — 0852); kit-scoped, a no-op for one-shots.
if let Some(kit_sound) =
resolve_kit(spells, &visuals.0, spell_id, |s| s.state, || None)
.and_then(|k| k.sound)
{
sounds.write(SpellKitSound::StopKit { entity, kit_sound });
}
}
}
let mut next = Vec::with_capacity(prev.len());
@@ -941,7 +958,7 @@ pub(crate) fn arm_aura_state_fx(
.char_procs()
.filter_map(crate::aura_visual::node_for)
.collect();
if effects.is_empty() && nodes.is_empty() {
if effects.is_empty() && nodes.is_empty() && kit.sound.is_none() {
continue; // a state kit that does nothing we model — nothing to arm or reap
}
if !effects.is_empty() {
@@ -960,6 +977,14 @@ pub(crate) fn arm_aura_state_fx(
nodes,
});
}
// The ADD edge rings the kit sound — ungated and positional (0852: the kit play's
// sound leg has no self test; the old self-only reading belonged to a different
// aura-apply cue). Covers the streamed-in-mid-aura unit no impact play can; the
// same-frame duplicate against the impact hand-off's state flash collapses in the
// sound router.
if let Some(kit_sound) = kit.sound {
sounds.write(SpellKitSound::Play { entity, kit_sound });
}
next.push(spell_id);
}
*prev = next;
@@ -438,9 +438,11 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<SpellKitFx>();
// The watcher's other fan-out: the kit's CharProc edges (`crate::aura_visual`). Food's kit 409
// carries none, so nothing is asserted here — the message just has to exist for the writer.
// The watcher's other fan-outs: the kit's CharProc edges (`crate::aura_visual`) and its
// sound leg (0852). Food's kit 409 carries neither, so nothing is asserted here — the
// messages just have to exist for the writers.
app.add_message::<crate::aura_visual::AuraProc>();
app.add_message::<SpellKitSound>();
app.init_resource::<FxLog>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables_with_paths(
HashMap::from([(
+30 -22
View File
@@ -258,14 +258,11 @@ pub(crate) struct DoodadAnimHost {
pub fade: (f32, Vec3),
/// The looping first-sequence graph node + its duration (secs); `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
/// `Time::elapsed_secs` at spawn — the [`GlobalSeqDrive`]'s clock origin. Global sequences run
/// on the free shared clock with no arm at all, so a resume re-seats them from this and phase
/// stays a pure function of the shared clock, never of pause history.
pub spawned_at: f32,
/// `Time::elapsed_secs` at the current **arm** — the player's clock origin, which is NOT
/// `spawned_at` any more: the variation re-arms every play-window, and a resume must seek to
/// `(now − armed_at) mod duration` or it lands at the phase of a clip this host stopped playing
/// several windows ago.
/// `Time::elapsed_secs` at the current **arm** — the player's clock origin: the variation
/// re-arms every play-window, and a resume must seek to `(now − armed_at) mod duration` or it
/// lands at the phase of a clip this host stopped playing several windows ago. (The
/// [`GlobalSeqDrive`] needs no origin at all — global sequences sample the shared world clock
/// directly, decision 0855.)
pub armed_at: f32,
/// When the armed play-window ends, on the shared clock — the reference's `windowHi`
/// (`[model+0xac]`), rewritten by every arm. `now ≥ window_hi` ⇒ re-roll. A host is born with
@@ -421,7 +418,6 @@ fn gate_doodad_anim(
continue;
}
host.active = drawn;
let t = now - host.spawned_at;
if let Some(mut p) = player {
if drawn {
if let Some((node, duration)) = host.clip {
@@ -442,10 +438,9 @@ fn gate_doodad_anim(
}
}
if let Some(mut d) = drive {
// No re-seek on resume: the drive samples the shared world clock (decision 0855), so
// a re-appearing doodad lands on the phase the clock dictates by construction.
d.set_paused(!drawn);
if drawn {
d.sync(t);
}
}
}
}
@@ -493,6 +488,10 @@ pub(crate) struct MatAnim {
/// the wearer's appear-fade and interior classifier rather than the world-model visibility
/// authority's. See [`Self::resting`].
unit_lane: bool,
/// The gseq factors' ATTACH anchor (secs on the shared clock): `None` until the first
/// [`sample_mat_anim`] pass stamps it — the reference snapshots the scene clock once per
/// model instance at attach (`CM2Model+0x68`, wow-re `gseq-anchor.md`; decisions 0856/0858).
gseq_attach: Option<f64>,
/// The last sampled combined factor (colour-alpha × weight), read by the visibility authority.
pub current: f32,
}
@@ -503,7 +502,9 @@ impl MatAnim {
now: f32,
frozen: bool,
) -> Self {
let current = anim.sample(None, 0.0);
// The seed sample: both clocks at 0 — nothing armed, and the attach anchor (stamped on
// the first live pass) makes the gseq cursor open at 0 too.
let current = anim.sample(None, 0.0, 0.0);
Self {
anim,
host: None,
@@ -512,6 +513,7 @@ impl MatAnim {
frozen,
drives_tag: false,
unit_lane: false,
gseq_attach: None,
current,
}
}
@@ -528,7 +530,7 @@ impl MatAnim {
let mut m = Self::new(anim, now, false);
m.drives_tag = true;
m.seq = seq;
m.current = m.anim.sample(seq, 0.0);
m.current = m.anim.sample(seq, 0.0, 0.0);
m
}
@@ -606,6 +608,10 @@ pub(crate) fn sample_mat_anim(
mut q: Query<&mut MatAnim>,
) {
let now = time.elapsed_secs();
// The scene clock, full-precision (a long-uptime f32 elapsed drifts whole milliseconds,
// which a 433 ms twinkle shows): the base the per-instance gseq attach anchor subtracts
// from (0856).
let shared = time.elapsed_secs_f64();
for mut m in &mut q {
if m.frozen {
continue;
@@ -625,7 +631,10 @@ pub(crate) fn sample_mat_anim(
None if m.host.is_some() => (m.seq, 0.0),
None => (m.seq, now - m.spawned_at),
};
m.current = m.anim.sample(seq, elapsed);
// The instance's gseq cursor: sceneNow − attach, the anchor stamped on this first pass
// (decisions 0856/0858 — every lane, spell effects included: fresh instance per play).
let attach = *m.gseq_attach.get_or_insert(shared);
m.current = m.anim.sample(seq, elapsed, shared - attach);
}
}
@@ -633,9 +642,10 @@ pub(crate) fn sample_mat_anim(
/// batch material carrying a texture-transform translation loop, keyed by material asset id.
/// [`tick_anim_materials`] re-samples a *drawn* entry's offset into the material's `sun_scale.zw`
/// each frame — one shared uniform per material, so every instance of a model batch scrolls in
/// phase. Exactly faithful for gseq loops (the reference's free-running shared clock); a recorded,
/// invisible divergence for seq-band loops (the reference phases those per instance at arm time —
/// meaningless for a seamless scroll). Entries drop when the material asset does.
/// phase. A recorded, invisible divergence for BOTH clock laws (0856): the reference phases a
/// seq-band loop per play (arm cursor) and a gseq loop per instance (attach anchor,
/// `gseq-anchor.md`), but one uniform per material cannot phase per instance — meaningless for a
/// seamless scroll either way. Entries drop when the material asset does.
#[derive(Resource, Default)]
pub(crate) struct UvAnimMaterials(
pub std::collections::HashMap<
@@ -860,6 +870,7 @@ mod tests {
period: 0.0,
step: true,
wrap: true, // period 0: a constant has no clock
gseq: false,
keys: vec![(0.0, 0.0)],
};
std::sync::Arc::new(
@@ -1066,7 +1077,6 @@ mod tests {
meshes: Vec::new(),
fade: (1.0, Vec3::ZERO),
clip: None,
spawned_at: 0.0,
armed_at: 0.0,
window_hi: f32::NEG_INFINITY,
anim_id: Some(0),
@@ -1154,7 +1164,6 @@ mod tests {
meshes: Vec::new(),
fade: (1.0, Vec3::ZERO),
clip: None,
spawned_at: 0.0,
armed_at: 0.0,
window_hi: f32::NEG_INFINITY,
anim_id: None,
@@ -1225,7 +1234,7 @@ mod tests {
}
/// The draw gate stops the player + pauses the gseq drive when every submesh is hidden, and on
/// re-appearing re-arms at the shared-clock position (`(now − spawned_at) mod duration`) — the
/// re-appearing re-arms at the shared-clock position (`(now − armed_at) mod duration`) — the
/// ref's clock-indexed pose, so pause history never desyncs phase.
#[test]
fn gate_pauses_hidden_and_resumes_on_the_shared_clock() {
@@ -1258,7 +1267,6 @@ mod tests {
meshes: vec![mesh],
fade: (1.0, Vec3::ZERO),
clip: Some((node, 2.0)),
spawned_at: 0.0,
armed_at: 0.0,
// Far future: this test exercises the DRAW gate, so no window may expire
// under it and change the armed clip mid-assertion.
@@ -204,6 +204,9 @@ pub(super) fn spawn_part(
material: mats.steady.clone(),
},
dress.object.clone(),
// The picker's triangles (decision 0857): the render meshes are `RENDER_WORLD`-only, so
// the ray pickers read the model's resident geometry instead.
crate::interact::PickMesh(part.geometry.clone()),
DressedPart {
index: index as u32,
card: None,
@@ -332,6 +335,9 @@ fn spawn_billboard_part(
blend: part.blend,
},
dress.object.clone(),
// The picker's triangles (decision 0857) — the caster centres a card at its pivot, the
// same bake the render form draws with.
crate::interact::PickMesh(part.geometry.clone()),
card_follow,
));
// A card takes its MODEL's indoor law through the same constructor as the sibling meshes it was
@@ -385,8 +391,10 @@ mod tests {
fn part(char_slot: Option<CharSkinSlot>, two_sided: bool) -> EntityPart {
EntityPart {
mesh: Handle::default(),
geometry: std::sync::Arc::new(benilla_formats::RenderSubmesh::default()),
aabb: None,
skinned_mesh: None,
welded_billboard: false,
material: mat(0),
material_interior: None,
material_interior_bake: None,
@@ -326,8 +326,10 @@ mod tests {
fn part(geoset: u16) -> EntityPart {
EntityPart {
mesh: Handle::default(),
geometry: std::sync::Arc::new(benilla_formats::RenderSubmesh::default()),
aabb: None,
skinned_mesh: None,
welded_billboard: false,
material: Handle::default(),
material_interior: None,
material_interior_bake: None,
+41 -7
View File
@@ -33,6 +33,11 @@ pub(super) enum ModelHandle {
/// loader).
pub(super) struct EntityPart {
pub(super) mesh: Handle<Mesh>,
/// The batch's decoded geometry — the model's resident CPU copy (`ModelSubmesh::geometry`),
/// cloned onto every spawned part/card as [`crate::interact::PickMesh`] so the ray pickers read
/// triangles from here: the static render form is `RENDER_WORLD`-only (decision 0834), so no
/// main-world mesh data exists to ray-cast (decision 0857).
pub(super) geometry: std::sync::Arc<benilla_formats::RenderSubmesh>,
/// The static form's build-time `Aabb` (decision 0834): the static mesh is `RENDER_WORLD`-only,
/// so a consumer that used to compute a bound from its main-world data (the attach path's
/// picker-volume fallback) reads this instead. `None` for degenerate geometry.
@@ -104,6 +109,12 @@ pub(super) struct EntityPart {
/// owned by the billboard system (a following [`crate::billboard::BillboardCard`], decision
/// 0153: the brazier/torch "glow on the ground" family). `None` for ordinary geometry.
pub(super) billboard: Option<benilla_assets::BillboardInfo>,
/// This part's geometry is **welded** to a billboard bone the card split refused
/// ([`benilla_formats::RenderSubmesh::welded_billboard`], decision 0839): a shoulder flap whose
/// root ring is half-weighted to the plate and whose tip swings to the camera. It draws right
/// only through a joint palette, which is why a display carrying one makes even the **item**
/// lane rig ([`DisplayModel::welds_billboard`], decision 0841). `false` for every ordinary part.
pub(super) welded_billboard: bool,
/// The part's animated material-alpha loops (colour-alpha × transparency-weight, decision 0130
/// phase 2, per-sequence since 0641). The **effect lane** ([`super::spell_fx`]) samples them
/// per instance on its armed slot; the unit/GameObject lane follows the host's live playing
@@ -223,6 +234,20 @@ pub(super) struct DisplayModel {
pub(super) is_character_body: bool,
}
impl DisplayModel {
/// Does any built part weld geometry to a billboard bone
/// ([`EntityPart::welded_billboard`], decision 0839)? Such a part has no correct rigid
/// placement — the reference blends it per vertex — so the lane that draws it must run a joint
/// palette even where it otherwise wouldn't. The rigged lanes always do; this is the gate that
/// makes the **item** lane rig for the seven shoulder models that need it (decision 0841).
/// `false` while the model is still loading, and for the other 9684 models in the game.
pub(super) fn welds_billboard(&self) -> bool {
self.parts
.as_ref()
.is_some_and(|p| p.iter().any(|part| part.welded_billboard))
}
}
/// Resolve a creature display id to a [`DisplayModel`]: load its M2 (no skins — the slots are filled at
/// build time from `textures`). A missing/zero-scale display gets an empty model → cube fallback.
pub(super) fn new_creature_display(
@@ -421,6 +446,11 @@ pub(super) fn build_parts(
dm.object_texture.as_deref(),
asset_server,
);
// The authored batch order (index + 1), into every variant's transparent sort
// bias — one model's coplanar transparent batches (the Naxx items' Mod2x
// sheen + Blend overlay) draw in file order instead of re-flipping a sort tie
// every frame (`model_render::BATCH_ORDER_SORT_EPS`).
let order = u16::try_from(pi + 1).unwrap_or(u16::MAX);
let exterior = model_material(
cache,
materials,
@@ -440,7 +470,7 @@ pub(super) fn build_parts(
// (the MCSH sample at their feet) rides the per-instance MeshTag shade byte
// (`entity_shade`), not the shared material.
ShadeSel::Lit,
0,
order,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
sub.rgb_anim.as_ref(), // animated M2Color tint, seeded at its first key
None, // entity M2: light selection anchors at the instance origin
@@ -496,7 +526,7 @@ pub(super) fn build_parts(
sub.no_depth_test,
sub.fog_policy,
ShadeSel::Lit, // matches the exterior variant above
0,
order,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
sub.rgb_anim.as_ref(),
None, // entity M2 fade twin: same instance-origin anchor
@@ -526,7 +556,7 @@ pub(super) fn build_parts(
sub.no_depth_test,
sub.fog_policy,
ShadeSel::Matte, // unread on the probe lane
0,
order,
None,
sub.rgb_anim.as_ref(),
None,
@@ -560,7 +590,7 @@ pub(super) fn build_parts(
sub.no_depth_test,
sub.fog_policy,
ShadeSel::Matte, // unread on the probe lane
0,
order,
None,
sub.rgb_anim.as_ref(),
None,
@@ -589,7 +619,7 @@ pub(super) fn build_parts(
sub.no_depth_test,
sub.fog_policy,
ShadeSel::Matte, // sun ×1.0, the forced indoor intensity — const so it dedups
0,
order,
None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads)
sub.rgb_anim.as_ref(),
None, // entity M2 interior variant: same instance-origin anchor
@@ -604,6 +634,7 @@ pub(super) fn build_parts(
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
geometry: sub.geometry.clone(),
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
skinned_mesh: skin_forms.get(pi).cloned(),
material: exterior,
@@ -618,6 +649,7 @@ pub(super) fn build_parts(
geoset_id: sub.geoset_id,
char_slot: sub.char_slot,
billboard: sub.billboard.clone(),
welded_billboard: sub.geometry.welded_billboard,
alpha_anim: sub.alpha_anim.clone(),
rgb_anim: sub.rgb_anim.clone(),
ground_quad: sub.ground_quad,
@@ -647,6 +679,7 @@ pub(super) fn build_parts(
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
geometry: sub.geometry.clone(),
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
geoset_id: sub.geoset_id, // 0 for WMO — no geoset selection
char_slot: None, // WMO is never a character body
@@ -682,8 +715,9 @@ pub(super) fn build_parts(
blend: sub.blend,
additive: false, // WMO MOMT carries no additive mode (`RenderSubmesh::additive`)
two_sided: sub.two_sided,
billboard: None, // WMO groups have no billboard bones
alpha_anim: None, // …nor colour/weight loops
billboard: None, // WMO groups have no billboard bones
welded_billboard: false, // …so nothing can be welded to one
alpha_anim: None, // …nor colour/weight loops
rgb_anim: None,
ground_quad: None, // the fx decal lane is M2-only
})
+135 -39
View File
@@ -131,12 +131,16 @@ pub(in crate::entities) fn attach_held_items(
// 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 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).
// gated on the mesh's own joint attributes. The one exception is an item that rigs itself
// (0841) — it carries its own slot instead, and inherits the tint up the `ParentModel` chain.
Option<&crate::rig_palette::RigSkin>,
)>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
mut seats: SeatWriters,
// An item model normally spawns no rig — but a display that welds geometry to a billboard bone
// has no correct rigid placement, so its spawn allocates a palette slot of its own (0841).
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
) {
let Some(held) = held else {
return;
@@ -223,7 +227,9 @@ pub(in crate::entities) fn attach_held_items(
if let Some(old) = root.take() {
commands.entity(old).despawn();
}
*root = wants.and_then(|hs| spawn_slot(&mut commands, &ctx, &held, slot_idx, &hs));
*root = wants.and_then(|hs| {
spawn_slot(&mut commands, &mut palettes, &ctx, &held, slot_idx, &hs)
});
}
let applied = HeldAttached {
applied: next,
@@ -243,6 +249,7 @@ pub(in crate::entities) fn attach_held_items(
/// the ribbons. `None` when the display has no built parts or the body has no such attach point.
fn spawn_slot(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
ctx: &WearerCtx,
held: &ItemDisplays,
slot_idx: usize,
@@ -301,15 +308,58 @@ 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;
// **The item rig** (decisions 0841, withdrawn by 0847, RESTORED by 0854) — the one case an
// attach model runs a joint palette. 0847 pulled it believing a spherical billboard swept the
// spikes through the plate; that was wrong (0853: the spikes run ALONG their bone, worst vertex
// 12° off axis, so the arc never existed), and the byte answer is that the reference billboards
// an attached model exactly as it does a standalone one (wow-re `billboard-bone-law.md` §6).
// A
// display whose geometry is WELDED to a billboard bone (`welds_billboard`: the R14 pauldron's
// two spikes, whose root rings are 50/50 with the plate) has no correct rigid placement at all;
// 0839 stopped tearing it into a card, which left it whole and *still*. The reference bends it
// by blending the billboard bone's camera-replaced palette row per vertex (`m2_vertex_skin`,
// `0x71a460`) — so we build exactly that: the joint hierarchy, a palette slot, and the
// [`crate::billboard::BillboardJointRig`] that rewrites the billboard joints' world rotations.
//
// Nothing else about the item lane's "rests at bind pose" law moves: no `AnimationPlayer`, no
// global-sequence drive — all seven affected models are **keyless** (`m2bones`: not one
// T/R/S key between them), so every non-billboard joint's palette row is the identity the
// static mesh already drew, and the emitters/lights/ribbons below keep riding `root`.
let item_rig = dm
.inverse_bindposes
.as_ref()
.filter(|_| dm.welds_billboard() && !dm.skeleton.joints.is_empty())
.and_then(|ibp| {
let joints = super::super::spawn_joints(commands, root, root, &dm.skeleton);
if let Some(bb) = crate::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root)
{
commands.entity(root).insert(bb);
}
// Slot 0 (table full, warned once) ⇒ no rig: the parts below fall back to the static
// mesh and the wearer's slot, i.e. exactly the pre-0841 look. Never a crash, never a
// missing shoulder.
let rig = crate::rig_palette::RigSkin::allocate(palettes, joints, ibp.clone())?;
let slot = rig.slot;
commands.entity(root).insert(rig);
// The lane is otherwise invisible from outside the renderer — seven models in 9691 take
// it, and a run where the gate silently stopped firing looks exactly like a run where
// nothing was equipped. Naming the slot is what lets a probe say which it was.
debug!(
"item rig: display {} welds a billboard bone → palette slot {slot} ({} bones)",
hs.display,
dm.skeleton.joints.len()
);
Some(slot)
});
// The instance slot every part below carries in its `MeshTag`. Normally the WEARER's — that is
// what puts a tinted body's colour on its helm, shoulders and held items (decision 0812). A
// rigged item must carry its OWN, because the vertex stage indexes the palette with the same
// field; the wearer's tint reaches it through the `ParentModel` chain instead, which is the
// reference's own route for an attached model's colours (`0x714000`, `aura_visual`).
let rig_tag = match item_rig {
Some(slot) => crate::mesh_tag::rig_bits(slot),
None => 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.
@@ -326,17 +376,27 @@ fn spawn_slot(
let set = item_fade_set(part);
let effective = PartFade::resolve(joined, &set);
let (init_mat, tag_alpha) = effective.seed(&set, now);
// A rigged item draws the SKINNED twin — every part of it, not only the welded batch:
// the model rests at bind pose, so each non-billboard bone's palette row is the
// identity and the other batches land pixel-for-pixel where the static mesh had them.
// The rig's absence (or a form that never built one) falls straight back to `mesh`.
let skinned = item_rig.and(part.skinned_mesh.as_ref());
let mut child = parent.spawn((
Mesh3d(part.mesh.clone()),
Mesh3d(skinned.unwrap_or(&part.mesh).clone()),
MeshMaterial3d(init_mat),
Transform::default(),
ModelPart {
kind: ModelKind::Creature,
blend: part.blend,
},
// The picker's triangles (decision 0857): the `WOW_PICK` probe names worn gear
// through `ModelPart`, and the render meshes are `RENDER_WORLD`-only.
crate::interact::PickMesh(part.geometry.clone()),
// The portrait booth mirrors this rider ([`crate::portrait`]): steady
// material (not the fade twin) + where it sits, so the bake can seat it at
// the bone's bind-pose global (the booth spawns no skeleton).
// the bone's bind-pose global (the booth spawns no skeleton). It stays the
// STATIC mesh even for a rigged item — the booth has no palette to skin
// through, and bind pose is what it wants to bake anyway.
crate::portrait::PortraitRider {
static_mesh: part.mesh.clone(),
material: part.material.clone(),
@@ -344,6 +404,15 @@ fn spawn_slot(
offset,
},
));
if skinned.is_some() {
// The palette replaces this part's world matrix outright, so its model-local
// `Aabb` below is not the volume it draws in — the same reason the effect lane's
// skinned parts opt out (`spell_fx`). Seven models' worth of always-drawn parts.
child.insert((
crate::rig_palette::RigPart(root),
bevy::camera::visibility::NoFrustumCulling,
));
}
// Interior-light parity with the body: both material variants + a MeshTag, so the
// classifier relights the weapon inside a WMO room like it does its wielder. While
// joining the unit's appear-fade the tag carries its alpha instead — the classifier
@@ -365,8 +434,10 @@ fn spawn_slot(
// is the same light-collector aliasing the comment above describes.
child.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
// The item part's build-time bound (decision 0834): `calculate_bounds` can no longer
// derive one from the `RENDER_WORLD`-only static form's data.
if let Some(aabb) = part.aabb {
// derive one from the `RENDER_WORLD`-only static form's data. Skipped for a skinned
// part — it opted out of the frustum cull above, and a stale bound would only mislead
// the picker volume that reads it.
if let (None, Some(aabb)) = (skinned, part.aabb) {
child.insert(aabb);
}
if let Some(lit) = part_interior_lit(
@@ -430,6 +501,8 @@ fn spawn_slot(
kind: ModelKind::Creature,
blend: part.blend,
},
// The picker's triangles (decision 0857), pivot-centred by the caster like the bake.
crate::interact::PickMesh(part.geometry.clone()),
BillboardCard::following(&info, root),
));
// The card's build-time bound (decision 0834) — same rule as its mesh siblings above.
@@ -592,8 +665,10 @@ mod tests {
fn part(interior: bool) -> EntityPart {
EntityPart {
mesh: Handle::default(),
geometry: std::sync::Arc::new(benilla_formats::RenderSubmesh::default()),
aabb: None,
skinned_mesh: None,
welded_billboard: false,
material: Handle::default(),
material_interior: interior.then(Handle::default),
material_interior_bake: None,
@@ -621,11 +696,11 @@ mod tests {
)
}
/// 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>>) {
/// Spawn a wearer with one SHOULDER slot whose display optionally welds geometry to a billboard
/// bone (decision 0841), 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), and the mesh the part
/// actually drew with.
fn attach_a_shoulder(welded: bool) -> (Vec<u32>, u16, Option<u16>, Option<Handle<Mesh>>) {
const KIND: ItemModelKind = ItemModelKind::ShoulderLeft;
let mut app = App::new();
app.add_plugins(MinimalPlugins);
@@ -635,6 +710,7 @@ mod tests {
);
let mut dm = empty_display();
let mut p = part(false);
p.welded_billboard = welded;
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
@@ -711,6 +787,11 @@ mod tests {
(tags, wearer_slot, item_rig, mesh)
}
/// The welded case, named for the assertion that reads it.
fn attach_a_welded_shoulder() -> (Vec<u32>, u16, Option<u16>, Option<Handle<Mesh>>) {
attach_a_shoulder(true)
}
/// 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) {
@@ -807,33 +888,47 @@ 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.
/// **The item rig (decision 0841, restored by 0854).** A display whose geometry is welded to a
/// billboard bone has no correct rigid placement — 0839 stopped tearing it into a card, leaving
/// it whole and still. Such an item spawns a rig of its own, and the three things that makes
/// true are asserted together because any one of them alone is a silent no-op: the part draws
/// the **skinned** twin (a static mesh is never skinned — `WOW_RIG_SKIN` compiles off the vertex
/// layout, not the tag), it carries its OWN palette slot rather than the wearer's, and the root
/// really holds the `RigSkin` that slot belongs to.
///
/// 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.
/// 0847 withdrew this and pinned the withdrawal with the mirror of this test; both are gone
/// again. The withdrawal's premise — a spherical billboard sweeping the spikes through the
/// plate — was refuted at the geometry (0853) and at the bytes (wow-re
/// `billboard-bone-law.md` §6), and
/// [`crate::billboard::tests::a_spike_along_its_bone_axis_points_screen_down_from_every_angle`]
/// is the standing guard that the arc stays absent in our own basis.
#[test]
fn an_item_spawns_no_rig_even_with_everything_one_would_need() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_shoulder();
fn a_welded_billboard_item_spawns_its_own_rig() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_welded_shoulder();
let item_slot = item_rig.expect("the welded display allocates a palette slot");
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_ne!(
item_slot, wearer_slot,
"the item's palette is not the wearer's"
);
assert_eq!(tags.len(), 1);
assert_eq!(
crate::mesh_tag::rig_of(tags[0]),
wearer_slot,
"the part indexes the WEARER's slot"
item_slot,
"the part indexes the ITEM's palette, not the body's"
);
assert_eq!(mesh, Some(Handle::default()), "…and draws the static mesh");
assert_eq!(mesh, Some(skinned_handle()), "…and draws the skinned twin");
}
/// The counter-anchor, on the same harness: an ordinary item — the 9684 models that weld
/// nothing — spawns no rig, keeps the wearer's slot (0812's tint route) and keeps the static
/// mesh. If the gate ever widened, this is what would start allocating a palette per torch.
#[test]
fn an_ordinary_item_still_spawns_no_rig() {
let (tags, wearer_slot, item_rig, mesh) = attach_a_shoulder(false);
assert!(item_rig.is_none(), "no palette slot for a rigid item");
assert_eq!(crate::mesh_tag::rig_of(tags[0]), wearer_slot);
assert_eq!(mesh, Some(Handle::default()), "the static mesh, as before");
}
/// **What a booth can see of an equipped item** (decision 0822, `#bugs` B118's paper-doll half).
@@ -956,6 +1051,7 @@ mod tests {
period: 0.0,
step: false,
wrap: true,
gseq: false,
keys: vec![(0.0, 0.3)],
}),
}])
+11 -5
View File
@@ -436,6 +436,9 @@ pub(super) fn attach_effect_visuals(
kind: ModelKind::Creature,
blend: part.blend,
},
// The picker's triangles (decision 0857): the probe names fx batches through
// `ModelPart`, and the render meshes are `RENDER_WORLD`-only.
crate::interact::PickMesh(part.geometry.clone()),
));
if let (true, Some(_)) = (rigged, &part.skinned_mesh) {
child.insert((
@@ -479,6 +482,8 @@ pub(super) fn attach_effect_visuals(
kind: ModelKind::Creature,
blend: part.blend,
},
// The picker's triangles (decision 0857), pivot-centred by the caster like the bake.
crate::interact::PickMesh(part.geometry.clone()),
card,
));
// The card's build-time bound (decision 0834): `calculate_bounds` can no longer derive
@@ -564,11 +569,9 @@ pub(super) fn attach_effect_visuals(
alpha: Some(root),
},
// The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot
// (a missile's InFlight is not file-order-first), on the instance's own spawn clock.
played_seq.map_or(
particles::EmitClock::Pinned,
particles::EmitClock::PinnedSeq,
),
// (a missile's InFlight is not file-order-first) on the spawn clock, and gseq loops
// ride the pooled scene clock (0856).
particles::EmitClock::Effect(played_seq),
);
}
// The same pick, as an `AnimationData` id — the ribbons' per-sequence visibility keys off it,
@@ -643,6 +646,9 @@ pub(super) fn arm_effect_rig(
}
if let Some(drive) = crate::creature_anim::GlobalSeqDrive::new(&anims.global_bones, &joints)
{
// Fresh anchor per play — the byte-verified effect lifecycle (0858): CreateModel
// always allocates+attaches, so every cast's gseq loops open at phase 0, exactly
// like the ref (the director's 3-cast trace: the flash at +16 frames every time).
commands.entity(root).insert(drive);
}
}
+298 -12
View File
@@ -24,7 +24,8 @@
//! Picking only runs while [`InspectMode`] is on (today's sole consumer, driven by the inspector
//! chord); when real player-facing consumers arrive it simply runs whenever any of them is active.
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
use benilla_assets::coords::wow_to_bevy;
use bevy::camera::primitives::Aabb;
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
@@ -37,6 +38,40 @@ use crate::ui_script::PointerOverUi;
mod journal;
/// The resident **pick geometry** of one drawn batch: the model's decoded `RenderSubmesh`, `Arc`-shared
/// with the model asset itself (decision 0857). The render forms are `RENDER_WORLD`-only since 0834 —
/// their main-world vertex data is gone after extract, which is why Bevy's `MeshRayCast` silently
/// stopped hitting every static model (the GO hover, the inspector, `WOW_PICK`). So the pickers read
/// triangles from THIS instead, through the same WoW→Bevy bake the render form was built with
/// (`submesh_to_static_mesh`: `wow_to_bevy` per vertex, billboard cards centred at their pivot).
/// Attached beside `Mesh3d` at every spawn site that also attaches a pick key ([`WorldObject`] /
/// `ModelPart`); a keyed entity without one (the model-less cube fallback) is picked by its `Aabb`.
#[derive(Component, Clone)]
pub struct PickMesh(pub std::sync::Arc<benilla_formats::RenderSubmesh>);
/// One triangle-accurate hit from [`cast_pick_ray`]: the world-space point, the hit triangle's
/// geometric normal (two-sided — the orientation is the authored winding's), and the distance along
/// the ray.
pub struct RayHit {
pub point: Vec3,
pub normal: Vec3,
pub distance: f32,
}
/// The one query every mesh picker casts against: a part's resident geometry, its world pose, the
/// render world's own visibility verdict (the cast honours it, as `RayCastVisibility::VisibleInView`
/// did), and its bound for the broad phase.
pub type PickParts<'w, 's> = Query<
'w,
's,
(
Option<&'static PickMesh>,
&'static GlobalTransform,
&'static ViewVisibility,
Option<&'static Aabb>,
),
>;
/// The identity of a pickable world thing, read by the inspector now and by tooltips/cursor/targeting
/// later. Attached to a thing's renderable mesh entities at spawn.
#[derive(Component, Clone)]
@@ -100,7 +135,7 @@ fn update_mouseover(
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
objects: Query<Entity, With<WorldObject>>,
mut ray_cast: MeshRayCast,
parts: PickParts,
) {
if !inspect.enabled {
if target.entity.is_some() {
@@ -126,7 +161,7 @@ fn update_mouseover(
};
let identified: HashSet<Entity> = objects.iter().collect();
if let Some((entity, point, distance)) =
pick_at_cursor(cursor, camera, cam_tf, &identified, &mut ray_cast)
pick_at_cursor(cursor, camera, cam_tf, &identified, &parts)
{
target.entity = Some(entity);
target.point = point;
@@ -144,17 +179,179 @@ pub fn pick_at_cursor(
camera: &Camera,
cam_tf: &GlobalTransform,
pickable: &HashSet<Entity>,
ray_cast: &mut MeshRayCast,
parts: &PickParts,
) -> Option<(Entity, Vec3, f32)> {
let ray = camera.viewport_to_world(cam_tf, cursor).ok()?;
let filter = |e: Entity| pickable.contains(&e);
let settings = MeshRayCastSettings::default()
.with_visibility(RayCastVisibility::VisibleInView)
.with_filter(&filter);
ray_cast
.cast_ray(ray, &settings)
.first()
.map(|(e, hit)| (*e, hit.point, hit.distance))
cast_pick_ray(ray, pickable, parts, false)
.into_iter()
.next()
.map(|(e, hit)| (e, hit.point, hit.distance))
}
/// Cast `ray` against `pickable`'s **resident geometry** ([`PickMesh`] — decision 0857: the render
/// meshes are `RENDER_WORLD`-only, so there is no main-world mesh data to cast against) and return
/// the hits nearest-first: one per entity (its nearest triangle), the whole list with `all_hits`
/// (the `WOW_PICK` probe's everything-along-the-ray reading) or just the front entity without.
///
/// Broad phase: a world-space slab test on each candidate's `Aabb` (entry distance), nearest entry
/// first, so the narrow walk stops as soon as a confirmed hit is closer than every remaining box. A
/// part with no bound (a `NoFrustumCulling` fx part) is narrow-tested unconditionally; a keyed
/// entity with a bound but no [`PickMesh`] (the model-less cube fallback, whose cuboid *is* its
/// box) takes its box entry as the hit. Triangles test **two-sided**, like the unit picker's narrow
/// phase — a generous pick beats a strict one at silhouette edges.
pub fn cast_pick_ray(
ray: Ray3d,
pickable: &HashSet<Entity>,
parts: &PickParts,
all_hits: bool,
) -> Vec<(Entity, RayHit)> {
let (origin, dir) = (ray.origin, *ray.direction);
let mut candidates: Vec<(f32, Entity)> = Vec::new();
for &entity in pickable {
let Ok((mesh, gt, vis, aabb)) = parts.get(entity) else {
continue;
};
if !vis.get() {
continue; // not drawn in any view this frame — the old cast's VisibleInView rule
}
let entry = match (aabb, mesh) {
(Some(aabb), _) => {
let (min, max) = world_aabb(aabb, gt);
match ray_aabb(origin, dir, min, max) {
Some(t) => t,
None => continue,
}
}
(None, Some(_)) => 0.0,
(None, None) => continue,
};
candidates.push((entry, entity));
}
candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut hits: Vec<(Entity, RayHit)> = Vec::new();
let mut best = f32::INFINITY;
for (entry, entity) in candidates {
if !all_hits && best < entry {
break; // every remaining box starts beyond the confirmed nearest hit
}
let Ok((mesh, gt, _, _)) = parts.get(entity) else {
continue;
};
let hit = match mesh {
Some(m) => ray_pick_mesh(m, gt, origin, dir),
// The cube fallback: its box entry is exactly its surface (the cuboid IS its Aabb).
None => Some(RayHit {
point: origin + dir * entry,
normal: -dir,
distance: entry,
}),
};
if let Some(h) = hit {
best = best.min(h.distance);
hits.push((entity, h));
}
}
hits.sort_unstable_by(|a, b| a.1.distance.total_cmp(&b.1.distance));
if !all_hits {
hits.truncate(1);
}
hits
}
/// The narrow phase for one part: ray-test every triangle of its resident geometry, in **model-local
/// space** — the world ray mapped through the part's inverse affine, under which the ray parameter is
/// unchanged, so a local `t` (against the world-normalized direction's image) *is* the world distance.
/// Vertices go through the render form's own bake (`build_submesh_mesh`): `wow_to_bevy` per vertex,
/// a billboard card centred at its pivot — so the pick tests exactly the surface the part draws
/// (a card's live camera-facing rotation rides its `GlobalTransform`, shared here too).
fn ray_pick_mesh(mesh: &PickMesh, gt: &GlobalTransform, origin: Vec3, dir: Vec3) -> Option<RayHit> {
let geo = &*mesh.0;
let inv = gt.affine().inverse();
let local_origin = inv.transform_point3(origin);
let local_dir = inv.transform_vector3(dir);
let center = geo
.billboard
.as_ref()
.map_or(Vec3::ZERO, |b| wow_to_bevy(b.pivot));
let pos = |i: u32| Some(wow_to_bevy(*geo.positions.get(i as usize)?) - center);
let mut nearest: Option<(f32, [Vec3; 3])> = None;
for t in geo.indices.chunks_exact(3) {
let (Some(a), Some(b), Some(c)) = (pos(t[0]), pos(t[1]), pos(t[2])) else {
continue; // an out-of-range index — corrupt authoring; skip the triangle, not the model
};
let tri = [a, b, c];
if let Some(d) = ray_triangle(local_origin, local_dir, &tri) {
if nearest.is_none_or(|(nd, _)| d < nd) {
nearest = Some((d, tri));
}
}
}
let (t, tri) = nearest?;
let world = tri.map(|v| gt.transform_point(v));
let normal = (world[1] - world[0])
.cross(world[2] - world[0])
.normalize_or_zero();
Some(RayHit {
point: origin + dir * t,
normal,
distance: t,
})
}
/// Ray–triangle intersection (Möller–Trumbore), **two-sided**, returning `t ≥ 0` along `dir`
/// (unnormalized is fine — `t` is in `dir` lengths) or `None` on miss/parallel. Two-sided because a
/// posed mesh can present back faces at silhouette edges and a generous pick beats a strict one.
pub(crate) fn ray_triangle(origin: Vec3, dir: Vec3, tri: &[Vec3; 3]) -> Option<f32> {
let (e1, e2) = (tri[1] - tri[0], tri[2] - tri[0]);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let s = origin - tri[0];
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = e2.dot(q) * inv;
(t >= 0.0).then_some(t)
}
/// A mesh's model-local [`Aabb`] transformed into a world-space axis-aligned box `(min, max)` — its 8
/// corners run through the entity's world transform, then min/max'd. (An AABB of the rotated box: a hair
/// larger than the true oriented box, which only makes the hover more forgiving.)
pub(crate) fn world_aabb(aabb: &Aabb, gt: &GlobalTransform) -> (Vec3, Vec3) {
let center = Vec3::from(aabb.center);
let he = Vec3::from(aabb.half_extents);
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
let world = gt.transform_point(center + he * Vec3::new(sx, sy, sz));
min = min.min(world);
max = max.max(world);
}
}
}
(min, max)
}
/// Ray vs axis-aligned box (the slab test): the entry distance along `dir` if the ray hits, else `None`.
/// `dir` need not be normalized; a zero component is handled by the infinities `recip` produces.
pub(crate) fn ray_aabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3) -> Option<f32> {
let inv = dir.recip();
let t1 = (min - origin) * inv;
let t2 = (max - origin) * inv;
let tmin = t1.min(t2).max_element();
let tmax = t1.max(t2).min_element();
(tmax >= tmin.max(0.0)).then_some(tmin.max(0.0))
}
/// **Ctrl+Cmd+I** (for *inspect*) arms/disarms the inspector — the dev-instrument chord, off the
@@ -541,3 +738,92 @@ impl Plugin for InteractPlugin {
.add_systems(EguiPrimaryContextPass, (inspect_ui, journal::journal_ui));
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_formats::RenderSubmesh;
const TRI: [Vec3; 3] = [
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(0.0, 0.0, 1.0),
];
#[test]
fn ray_triangle_hits_at_the_right_distance() {
let t = ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).expect("hit");
assert!((t - 5.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_is_two_sided() {
// Same triangle hit from below (reversed winding relative to the ray) still connects.
assert!(ray_triangle(Vec3::new(0.0, -5.0, 0.0), Vec3::Y, &TRI).is_some());
}
#[test]
fn ray_triangle_misses_outside_and_behind() {
// Outside the triangle's extent.
assert!(ray_triangle(Vec3::new(3.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).is_none());
// Triangle behind the ray origin (t < 0).
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::Y, &TRI).is_none());
// Parallel to the plane.
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::X, &TRI).is_none());
}
/// [`TRI`]'s pre-image under `wow_to_bevy` (`bevy = (−y, z, −x)` ⇒ `wow = (−bz, −bx, by)`):
/// a submesh authored with THESE WoW-space positions must be picked exactly where the render
/// form draws it, i.e. at [`TRI`] in model-local Bevy space.
fn wow_tri() -> RenderSubmesh {
RenderSubmesh {
positions: vec![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [-1.0, 0.0, 0.0]],
indices: vec![0, 1, 2],
..Default::default()
}
}
/// The picker ↔ render-form bake contract (decision 0857): [`ray_pick_mesh`] must test the
/// resident WoW-axes geometry through the SAME `wow_to_bevy` bake `submesh_to_static_mesh`
/// builds the drawn mesh with, under the part's world transform — the render mesh itself is
/// `RENDER_WORLD`-only (0834), so this path is the only thing keeping static models pickable.
#[test]
fn resident_geometry_picks_where_the_render_form_draws() {
let mesh = PickMesh(std::sync::Arc::new(wow_tri()));
// A translated + uniformly scaled part: the triangle spans x∈[8,12], z∈[-2,2] at y=1.
let gt =
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 0.0).with_scale(Vec3::splat(2.0)));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(10.0, 6.0, 0.0), Vec3::NEG_Y)
.expect("the baked triangle must be hit under its world transform");
assert!((hit.distance - 5.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::new(10.0, 1.0, 0.0), 1e-4));
assert!(hit.normal.abs_diff_eq(Vec3::Y, 1e-4) || hit.normal.abs_diff_eq(-Vec3::Y, 1e-4));
// …and a miss stays a miss.
assert!(ray_pick_mesh(&mesh, &gt, Vec3::new(20.0, 6.0, 0.0), Vec3::NEG_Y).is_none());
}
/// A billboard card's render form is centred at its pivot (`build_submesh_mesh`); the pick must
/// subtract the same pivot or it tests the card a whole pivot-offset away from where it draws.
#[test]
fn billboard_card_picks_pivot_centred() {
let mut sub = wow_tri();
// Pivot at WoW (0, 0, 3) → Bevy (0, 3, 0): the baked card shifts down 3 in model-local y.
sub.billboard = Some(benilla_formats::Billboard {
pivot: [0.0, 0.0, 3.0],
bone: 0,
kind: benilla_formats::BillboardKind::Spherical,
scale_anim: None,
seq_translations: Vec::new(),
});
let mesh = PickMesh(std::sync::Arc::new(sub));
// The card entity sits AT the pivot's world spot (the spawn sites bake
// `transform_point(pivot)` into its translation), so the authored triangle — 3 below the
// pivot in model-local Bevy y — draws at world y = 0: a straight-down ray from y = 8 hits
// at distance 8. Without the pivot subtraction it would (wrongly) hit at y = 3.
let gt = GlobalTransform::from(Transform::from_xyz(0.0, 3.0, 0.0));
let hit = ray_pick_mesh(&mesh, &gt, Vec3::new(0.0, 8.0, 0.0), Vec3::NEG_Y)
.expect("the pivot-centred card must be hit where it draws");
assert!((hit.distance - 8.0).abs() < 1e-4);
assert!(hit.point.abs_diff_eq(Vec3::ZERO, 1e-4));
}
}
+65 -12
View File
@@ -20,6 +20,22 @@ use crate::terrain::{WowModelExt, WowModelMaterial};
/// Source: <https://wowdev.wiki/M2/Rendering> § Alpha Testing.
pub const VANILLA_ALPHA_KEY_REF: f32 = 224.0 / 255.0;
/// Yards of transparent-pass sort bias per authored batch-order step (`MatKey::batch_order` ×
/// this, on `StandardMaterial::depth_bias` — bevy adds that field to the phase item's sort
/// distance; positive = drawn later, the `sky_order` sign law). **Why it exists:** one model's
/// coplanar transparent batches (the Naxx items' Opaque + Mod2x sheen + Blend overlay triples)
/// share one mesh centre, so their sort distances TIE, and a tie is re-resolved every frame by
/// whatever order the queue happens to iterate — in a churning scene (a city) the coplanar layers
/// swap draw order frame to frame and the composite strobes (Mod2x-then-Blend ≠ Blend-then-Mod2x).
/// The reference cannot express the bug: it ties every draw command of one instance to a single
/// sort key and keeps the *authored command order* inside the tie (wow-re
/// `m2-blend-promotion-zfill.md` §4/§6; MOBA file order for WMO). This epsilon reproduces that
/// order through bevy's sort. Sized well under [`benilla_formats::owner_last_rung`]'s 1-yd floor
/// (an effect still draws after every batch of its owner) and far over f32 noise on a ~500 yd
/// sort distance (~4e-5). Not a pipeline axis: `depth_bias` participates in sorting only
/// (`WowModelKey` deliberately excludes it — decision 0837).
pub(crate) const BATCH_ORDER_SORT_EPS: f32 = 1e-3;
/// Material-dedup key: same texture + blend + sidedness + kind + fade-variant → one shared material.
#[derive(PartialEq, Eq, Hash)]
pub(crate) struct MatKey {
@@ -40,10 +56,11 @@ pub(crate) struct MatKey {
/// The batch's static terrain-shade selector ([`ShadeSel`]). Static per placement, so it dedups a
/// lit / matte / shaded material variant.
shade: ShadeSel,
/// WMO authored batch index + 1 (0 = non-WMO). Per-batch, so each biased batch is its own
/// pipeline variant — see `terrain::WowModelKey::wmo_batch_order` for the why (the byte-verified
/// MOBA draw-order determinism, wow-5875-re models/scratch/wmo-batch-blend-depth-state.md).
wmo_batch_order: u16,
/// Authored batch index + 1 (0 = a legacy/unordered caller). Every ordered batch (M2 and WMO)
/// folds it into the material's transparent SORT bias ([`BATCH_ORDER_SORT_EPS`]); WMO batches
/// additionally ride it into the vertex-stage clip-z nudge (the byte-verified MOBA draw-order
/// determinism, wow-5875-re models/scratch/wmo-batch-blend-depth-state.md).
batch_order: u16,
/// The batch's UV-animation identity (decision 0130 phase 3): the `Arc<UvAnim>` pointer, so
/// batches scrolling on different loops never share a material (their `sun_scale.zw` offsets
/// diverge every frame) while every instance of the same model batch does. Sound because the
@@ -152,7 +169,9 @@ pub(crate) fn model_material(
no_depth_test: bool,
fog_policy: benilla_formats::FogPolicy,
shade: ShadeSel,
wmo_batch_order: u16,
// Authored batch index + 1 (0 = unordered): the transparent-pass ORDER of this batch among its
// model's other batches — see [`MatKey::batch_order`] and [`BATCH_ORDER_SORT_EPS`].
batch_order: u16,
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
// The WMO batch's MOBA section (`None` for M2 batches) — with `is_interior`, it picks the
@@ -177,7 +196,7 @@ pub(crate) fn model_material(
no_depth_test,
fog_policy,
shade,
wmo_batch_order,
batch_order,
uv_anim: uv_anim.map(|a| std::sync::Arc::as_ptr(a) as usize),
rgb_anim: rgb_anim.map(|a| std::sync::Arc::as_ptr(a) as usize),
wmo_class,
@@ -220,12 +239,16 @@ pub(crate) fn model_material(
};
// Single-sided unless the M2's 0x04 flag is set (many canopy planes are one-directional).
let cull_mode = if two_sided { None } else { Some(Face::Back) };
// The authored batch order as a transparent sort bias ([`BATCH_ORDER_SORT_EPS`]): one model's
// coplanar batches draw in file order instead of re-flipping a sort tie every frame.
let depth_bias = f32::from(batch_order) * BATCH_ORDER_SORT_EPS;
let base = match texture {
Some(image) => StandardMaterial {
base_color_texture: Some(image),
alpha_mode,
double_sided: two_sided,
cull_mode,
depth_bias,
..default()
},
// No resolved texture → the reference DISABLES the texture stage and draws the batch in
@@ -239,6 +262,7 @@ pub(crate) fn model_material(
alpha_mode,
double_sided: two_sided,
cull_mode,
depth_bias,
..default()
},
};
@@ -325,11 +349,15 @@ pub(crate) fn model_material(
// shared clock (frozen in captures).
sun_scale: {
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
let order = if matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) {
0.0
} else {
f32::from(wmo_batch_order)
};
// The clip-z nudge stays WMO-only: M2 coplanar layers pass GreaterEqual at exactly
// equal depth (same mesh, same transform, same vertex path), and their ORDER is
// the sort bias above — nudging their depth would be an unverified extra.
let order =
if !is_wmo || matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) {
0.0
} else {
f32::from(batch_order)
};
Vec4::new(shade.selector(), order, uv0[0], uv0[1])
},
// The animated M2Color tint's first key (identity white for static batches — their
@@ -431,7 +459,7 @@ pub(crate) fn zfill_material(
no_depth_test: false,
fog_policy: benilla_formats::FogPolicy::Scene,
shade: ShadeSel::Lit,
wmo_batch_order: 0,
batch_order: 0,
uv_anim: None,
rgb_anim: None,
wmo_class: None,
@@ -534,4 +562,29 @@ mod tests {
let out2 = replace_fog_policy(f32::from(0u16), FogPolicy::Grey) as u16;
assert_eq!(out2, (FogPolicy::Grey as u16) << 4);
}
/// The authored-batch-order sort epsilon sits in its working band: any realistic model's
/// last batch still biases UNDER the effect lane's owner-last rung floor (an item's glow
/// particles must draw after every batch of the item — 0719/0721), and one step is far above
/// f32 noise on a far-scene sort distance (~4e-5 at 500 yd), so the tie actually breaks.
/// Pins the constant against a careless resize in either direction — either end failing is
/// the Naxx-item strobe (coplanar layers re-flipping a sort tie every frame) or its dual
/// (effects interleaving into their owner's batches).
#[test]
fn batch_order_sort_eps_sits_between_f32_noise_and_the_effect_rung() {
// 512 batches is comfortably past any shipped model's batch table.
let deepest = super::BATCH_ORDER_SORT_EPS * 512.0;
assert!(
deepest < benilla_formats::owner_last_rung(0.0),
"a model's last batch must still sort before its own effects' rung"
);
let far_noise = 500.0_f32 * f32::EPSILON;
assert!(
super::BATCH_ORDER_SORT_EPS > 8.0 * far_noise,
"one order step must dominate f32 noise on a far sort distance"
);
// And bevy's pipeline key truncates it to 0 — the bias may never mint pipelines (0837):
// sub-1.0 biases all cast to the same i32 constant.
assert_eq!(deepest as i32, 0);
}
}
+5 -4
View File
@@ -549,7 +549,7 @@ pub fn spawn_emitter(
}
let (host, seq) = match clock {
EmitClock::Pinned => (None, None),
EmitClock::PinnedSeq(s) => (None, Some(s)),
EmitClock::Effect(s) => (None, s),
EmitClock::Host(h) => (Some(h), None),
};
// The owner's reach is model-local; the rung is a view-space distance, so it takes the
@@ -688,9 +688,10 @@ pub enum EmitClock {
/// still correct there), the portrait booths, and any lane with no rig to ask.
#[default]
Pinned,
/// A fixed armed slot on the spawn-age clock — a spell effect's rig arms one clip at spawn
/// and the emitters ride it (a missile's InFlight is not file-order-first).
PinnedSeq(usize),
/// The SPELL-FX lane: the rig's armed slot (a missile's InFlight is not file-order-first;
/// `None` = slot 0) on the spawn-age clock, like every pinned lane — the effect instance is
/// byte-verified fresh per play (decision 0858), so its gseq loops open at phase 0 with it.
Effect(Option<usize>),
/// A live host's `AnimationPlayer` decides the slot **and the clip time** each frame — units
/// and GameObjects, whose playing sequence changes; the reference's `m2_animate` emitter
/// phase samples the CURRENT sequence record (wow-re `part-emission-rate-animated.md` §2).
+10 -6
View File
@@ -588,7 +588,10 @@ pub(super) fn simulate_particles(
// 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);
// The instance's gseq cursor (0856/0858): the spawn age IS `sceneNow − attach` — the
// emitter spawns with its instance, and every lane's instance is fresh per play.
let gseq_now = f64::from(*age);
let now = def.params.sample(clock_seq, elapsed_s, gseq_now);
// 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²;
@@ -643,8 +646,8 @@ 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.
let emitting = !*draining && def.timing.emitting(clock_seq, elapsed_s);
let rate = def.timing.rate(clock_seq, elapsed_s);
let emitting = !*draining && def.timing.emitting(clock_seq, elapsed_s, gseq_now);
let rate = def.timing.rate(clock_seq, elapsed_s, gseq_now);
let dist_lod =
(1.0 - (placement.translation.distance(e_cam_pos) - 50.0) * 0.02).clamp(0.25, 1.0);
let burst = accumulate_emission(
@@ -759,9 +762,10 @@ pub(super) fn simulate_particles(
// A draining parent stops driving; child pools live out their spans (the despawn
// above waits for them).
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);
// A child cloud is its own fresh instance: its gseq cursor is its own age (0856).
let c_emitting = !*draining && child.def.timing.emitting(None, *age, f64::from(*age));
let c_rate = child.def.timing.rate(None, *age, f64::from(*age));
let c_now = child.def.params.sample(None, *age, f64::from(*age));
drive_child(
child,
&c_now,
+1
View File
@@ -504,6 +504,7 @@ mod tests {
period: 0.0,
step: true,
wrap: true, // period 0: a constant has no clock
gseq: false,
keys: vec![(0.0, v)],
}),
}])
+18
View File
@@ -43,9 +43,19 @@ fn route_spell_kit_sounds(
return;
};
let listener = listener.pos;
// Same-drain dedup: the client rings a kit's sound once per PlaySpellVisualKit call, and a
// state kit is legitimately played twice in one packet burst (the impact hand-off's flash +
// the aura watcher's ADD edge — both real reference callers, 0852). One frame, one ring;
// plays in different frames (a missile impact preceding the aura by a beat) both ring, as
// the reference's two calls would.
let mut played_now: Vec<(Entity, u32)> = Vec::new();
for ev in events.read() {
match *ev {
SpellKitSound::Play { entity, kit_sound } => {
if played_now.contains(&(entity, kit_sound)) {
continue;
}
played_now.push((entity, kit_sound));
let pos = transforms.get(entity).map(|t| t.translation).ok();
let looping = kit_looping(&kits, kit_sound);
// The kit player is otherwise invisible in logs — this line is what a headless
@@ -90,6 +100,14 @@ fn route_spell_kit_sounds(
super::kit::stop_source_kit(&mut out, entity, kit_sound);
}
}
SpellKitSound::StopKit { entity, kit_sound } => {
// Kit-scoped (an aura-drop reap, 0852): stop exactly this kit's channels; the
// ledger entry goes only if it is this kit, so an unrelated hold loop survives.
if hold_loops.get(&entity) == Some(&kit_sound) {
hold_loops.remove(&entity);
}
super::kit::stop_source_kit(&mut out, entity, kit_sound);
}
}
}
}
+6 -84
View File
@@ -11,14 +11,13 @@ use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::camera::primitives::Aabb;
use bevy::mesh::{Indices, VertexAttributeValues};
use bevy::picking::mesh_picking::ray_cast::MeshRayCast;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::collision::PickOccluder;
use crate::creature_anim::AnimDriver;
use crate::debug_panel::ModelKind;
use crate::interact::WorldObject;
use crate::interact::{ray_aabb, ray_triangle, world_aabb, PickParts, WorldObject};
use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
use crate::player::{CameraControl, WorldCamera};
use crate::ui_script::PointerOverUi;
@@ -302,8 +301,9 @@ pub(super) fn update_hover(
/// Recompute the **GameObject** under the cursor each frame into [`HoveredObject`] (decision 0236):
/// a mesh-accurate ray pick against GameObject parts *only*, reusing the inspector's picker
/// ([`crate::interact::pick_at_cursor`] — bevy `MeshRayCast`, which hits the colliderless props a
/// physics ray misses). Kept separate from [`update_hover`]'s unit pick because a GameObject is
/// ([`crate::interact::pick_at_cursor`] — the resident-geometry caster of decision 0857, which hits
/// the colliderless props a physics ray misses and the `RENDER_WORLD`-only static forms Bevy's
/// `MeshRayCast` lost at 0834). Kept separate from [`update_hover`]'s unit pick because a GameObject is
/// usable-but-not-*selected*: this drives the Interact cursor and the right-click USE, never
/// selection. Inert while mouse-looking (cursor hidden) or over the dev UI, exactly like the unit
/// pick. Cheap — only the handful of GO parts on screen are in the pick set.
@@ -326,7 +326,7 @@ pub(super) fn update_hovered_object(
// The faction term of eligibility (decision 0764): the GO's own template reaction toward us.
factions: Option<Res<super::ring::Factions>>,
self_q: Query<&ObjectStore, With<crate::net::SelfPlayer>>,
mut ray_cast: MeshRayCast,
parts: PickParts,
) {
hovered.target = None;
hovered.guid = None;
@@ -362,7 +362,7 @@ pub(super) fn update_hovered_object(
return;
}
let Some((hit, _point, distance)) =
crate::interact::pick_at_cursor(cursor, camera, cam_tf, &pickable, &mut ray_cast)
crate::interact::pick_at_cursor(cursor, camera, cam_tf, &pickable, &parts)
else {
return;
};
@@ -497,62 +497,6 @@ fn ray_posed_mesh(
hits
}
/// Ray–triangle intersection (Möller–Trumbore), **two-sided**, returning `t ≥ 0` along `dir`
/// (unnormalized is fine — `t` is in `dir` lengths) or `None` on miss/parallel. Two-sided because a
/// posed mesh can present back faces at silhouette edges and a generous pick beats a strict one.
fn ray_triangle(origin: Vec3, dir: Vec3, tri: &[Vec3; 3]) -> Option<f32> {
let (e1, e2) = (tri[1] - tri[0], tri[2] - tri[0]);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let s = origin - tri[0];
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
return None;
}
let t = e2.dot(q) * inv;
(t >= 0.0).then_some(t)
}
/// A mesh's model-local [`Aabb`] transformed into a world-space axis-aligned box `(min, max)` — its 8
/// corners run through the entity's world transform, then min/max'd. (An AABB of the rotated box: a hair
/// larger than the true oriented box, which only makes the hover more forgiving.)
fn world_aabb(aabb: &Aabb, gt: &GlobalTransform) -> (Vec3, Vec3) {
let center = Vec3::from(aabb.center);
let he = Vec3::from(aabb.half_extents);
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
for sx in [-1.0, 1.0] {
for sy in [-1.0, 1.0] {
for sz in [-1.0, 1.0] {
let world = gt.transform_point(center + he * Vec3::new(sx, sy, sz));
min = min.min(world);
max = max.max(world);
}
}
}
(min, max)
}
/// Ray vs axis-aligned box (the slab test): the entry distance along `dir` if the ray hits, else `None`.
/// `dir` need not be normalized; a zero component is handled by the infinities `recip` produces.
fn ray_aabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3) -> Option<f32> {
let inv = dir.recip();
let t1 = (min - origin) * inv;
let t2 = (max - origin) * inv;
let tmin = t1.min(t2).max_element();
let tmax = t1.max(t2).min_element();
(tmax >= tmin.max(0.0)).then_some(tmin.max(0.0))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -563,18 +507,6 @@ mod tests {
Vec3::new(0.0, 0.0, 1.0),
];
#[test]
fn ray_triangle_hits_at_the_right_distance() {
let t = ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).expect("hit");
assert!((t - 5.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_is_two_sided() {
// Same triangle hit from below (reversed winding relative to the ray) still connects.
assert!(ray_triangle(Vec3::new(0.0, -5.0, 0.0), Vec3::Y, &TRI).is_some());
}
/// The picker ↔ mesh-builder attribute contract: `ray_posed_mesh` must read the WOW joint
/// attributes the skinned twin is authored with ([`benilla_assets::ATTRIBUTE_WOW_JOINT_INDEX`],
/// decision 0720) — reading Bevy's standard skin attributes made every unit silently
@@ -616,14 +548,4 @@ mod tests {
.expect("the posed pick must hit the WOW-attributed mesh");
assert!((t - 3.0).abs() < 1e-5);
}
#[test]
fn ray_triangle_misses_outside_and_behind() {
// Outside the triangle's extent.
assert!(ray_triangle(Vec3::new(3.0, 5.0, 0.0), Vec3::NEG_Y, &TRI).is_none());
// Triangle behind the ray origin (t < 0).
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::Y, &TRI).is_none());
// Parallel to the plane.
assert!(ray_triangle(Vec3::new(0.0, 5.0, 0.0), Vec3::X, &TRI).is_none());
}
}
@@ -131,21 +131,20 @@ pub(crate) fn spawn_model_entities(
out.push(h.root);
}
let mut skinned_meshes: Vec<Entity> = Vec::new();
for (wmo_batch_idx, sub) in submeshes.iter().enumerate() {
for (batch_idx, sub) in submeshes.iter().enumerate() {
// The batch's app-built render form (decision 0834). Callers gate spawning on the forms
// being complete, so a miss here is a broken contract — skip the batch rather than panic.
let Some((stat_mesh, stat_aabb)) = forms.stat.get(wmo_batch_idx) else {
let Some((stat_mesh, stat_aabb)) = forms.stat.get(batch_idx) else {
continue;
};
// WMO batches carry their authored order (index + 1; 0 = non-WMO) into the material so the
// pipeline can bias coplanar layers to resolve in MOBA file order — the byte-verified client
// behaviour (wow-5875-re models/scratch/wmo-batch-blend-depth-state.md). Every WMO batch gets
// it (the filmed farmhouse layers are OPAQUE batches, not blends). Doodads/M2 pass 0.
let wmo_batch_order = if is_wmo {
u16::try_from(wmo_batch_idx + 1).unwrap_or(u16::MAX)
} else {
0
};
// Every batch (WMO and M2) carries its authored order (index + 1) into the material's
// transparent sort bias, so one model's coplanar layers draw in file order instead of
// re-flipping a sort tie every frame (`model_render::BATCH_ORDER_SORT_EPS`). WMO batches
// additionally ride it into the clip-z nudge that resolves coplanar layers in MOBA file
// order — the byte-verified client behaviour (wow-5875-re
// models/scratch/wmo-batch-blend-depth-state.md); `model_material` gates that half on
// `is_wmo`.
let batch_order = u16::try_from(batch_idx + 1).unwrap_or(u16::MAX);
let interior = sub.interior || interior_slot.is_some();
// A billboard card is culled by the SAME rule as any other batch — its material's `0x04`
// flag, nothing else. This site used to force `|| sub.billboard.is_some()`, on the reasoning
@@ -187,7 +186,7 @@ pub(crate) fn spawn_model_entities(
sub.no_depth_test,
sub.fog_policy,
shade,
wmo_batch_order,
batch_order,
sub.uv_anim.as_ref(),
sub.rgb_anim.as_ref(),
sub.wmo_batch,
@@ -224,7 +223,7 @@ pub(crate) fn spawn_model_entities(
sub.no_depth_test,
sub.fog_policy,
shade,
wmo_batch_order,
batch_order,
sub.uv_anim.as_ref(),
sub.rgb_anim.as_ref(),
sub.wmo_batch,
@@ -286,6 +285,10 @@ pub(crate) fn spawn_model_entities(
kind,
blend: sub.blend,
},
// The picker's triangles (decision 0857): the render forms are `RENDER_WORLD`-only,
// so the inspector/probe rays read the model's resident geometry. The caster
// centres a card at its pivot, the same bake the render form draws with.
crate::interact::PickMesh(sub.geometry.clone()),
mesh_tag,
card,
));
@@ -305,7 +308,7 @@ pub(crate) fn spawn_model_entities(
// (or a contract break) falls back to the static form, un-rigged, rather than warn
// the picker with a joint-less "skinned" mesh.
let skinned_mesh = (skin.is_some() && rig_slot != 0)
.then(|| forms.skin.and_then(|s| s.get(wmo_batch_idx)).cloned())
.then(|| forms.skin.and_then(|s| s.get(batch_idx)).cloned())
.flatten();
let use_rig = skinned_mesh.is_some();
let part_tag = if use_rig {
@@ -321,6 +324,8 @@ pub(crate) fn spawn_model_entities(
kind,
blend: sub.blend,
},
// The picker's triangles (decision 0857) — same rule as the card above.
crate::interact::PickMesh(sub.geometry.clone()),
part_tag,
));
// Static parts carry the build-time Aabb (the RENDER_WORLD rule above); a rigged
@@ -386,7 +391,6 @@ pub(crate) fn spawn_model_entities(
meshes: skinned_meshes,
fade: (radius, transform.transform_point(local_center)),
clip: h.clip,
spawned_at: now,
armed_at: now,
// Born already expired, so the first frame runs the holder setup's `variationIdx = -1`
// arm over the loader's var-0 seed — the reference's own two-stage load (decision 0768).