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:
@@ -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,
|
||||
|
||||
@@ -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 {:?}",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)],
|
||||
}),
|
||||
}])
|
||||
|
||||
@@ -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
@@ -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, >, 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, >, 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, >, 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)],
|
||||
}),
|
||||
}])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user