diff --git a/crates/benilla-formats/examples/batch_alpha.rs b/crates/benilla-formats/examples/batch_alpha.rs index 957d3e5b..cce5b4f8 100644 --- a/crates/benilla-formats/examples/batch_alpha.rs +++ b/crates/benilla-formats/examples/batch_alpha.rs @@ -28,7 +28,7 @@ fn main() -> anyhow::Result<()> { continue; }; let samples: Vec = (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); diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs index 792bcf5c..3fb09663 100644 --- a/crates/benilla-formats/examples/dump_emitters.rs +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -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, diff --git a/crates/benilla-formats/examples/emdump.rs b/crates/benilla-formats/examples/emdump.rs index ee3f09bd..a4e7e312 100644 --- a/crates/benilla-formats/examples/emdump.rs +++ b/crates/benilla-formats/examples/emdump.rs @@ -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 {:?}", diff --git a/crates/benilla-formats/examples/seamswing.rs b/crates/benilla-formats/examples/seamswing.rs index 13d44703..89de02f8 100644 --- a/crates/benilla-formats/examples/seamswing.rs +++ b/crates/benilla-formats/examples/seamswing.rs @@ -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 -- ` @@ -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 = seam.iter().map(|&(_, d)| d).collect(); + let pure_d: Vec = 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::() / 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(()) } diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index eeeb63d4..bc17cf4b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -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 { diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 050f7bbe..f612801d 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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); diff --git a/crates/benilla-formats/src/emit_timing.rs b/crates/benilla-formats/src/emit_timing.rs index 304af879..9c820de5 100644 --- a/crates/benilla-formats/src/emit_timing.rs +++ b/crates/benilla-formats/src/emit_timing.rs @@ -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, 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, 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, elapsed: f32) -> f32 { + /// `shared_now` routes a gseq-tagged rate loop to the shared world clock. + pub fn rate(&self, seq: Option, 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, 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, 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(), diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs index f4e2ed14..6a419129 100644 --- a/crates/benilla-formats/src/models/key_anim.rs +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -86,10 +86,37 @@ pub struct KeyAnim { /// 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 KeyAnim { + /// 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 KeyAnim { /// 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( } 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( // 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( 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, }) } diff --git a/crates/benilla-formats/src/models/mat_anim.rs b/crates/benilla-formats/src/models/mat_anim.rs index ff4f352a..8ab77fd6 100644 --- a/crates/benilla-formats/src/models/mat_anim.rs +++ b/crates/benilla-formats/src/models/mat_anim.rs @@ -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, 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, 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, diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 51e26b16..8dde96a9 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -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? diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index 8689ec9e..80ce6d77 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -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" + ); } } diff --git a/crates/benilla-formats/tests/particles.rs b/crates/benilla-formats/tests/particles.rs index 41631fee..8a7722f3 100644 --- a/crates/benilla-formats/tests/particles.rs +++ b/crates/benilla-formats/tests/particles.rs @@ -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"); } diff --git a/crates/benilla/src/aura_visual/tests.rs b/crates/benilla/src/aura_visual/tests.rs index 830edb59..0dc904b9 100644 --- a/crates/benilla/src/aura_visual/tests.rs +++ b/crates/benilla/src/aura_visual/tests.rs @@ -227,6 +227,7 @@ fn app_with_chains() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins); app.add_message::(); + app.add_message::(); app.add_message::(); app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( HashMap::from([ diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index e41f2f4e..ab228e1a 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -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, - /// 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, /// 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, 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, 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 diff --git a/crates/benilla/src/capture/pick_probe.rs b/crates/benilla/src/capture/pick_probe.rs index 5feb93b3..b7776020 100644 --- a/crates/benilla/src/capture/pick_probe.rs +++ b/crates/benilla/src/capture/pick_probe.rs @@ -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, 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 diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index e5a2f4e5..b1bda145 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -1131,7 +1131,7 @@ fn fire_particle_census( d.blend, d.flags, rate_keys.join(","), - d.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 diff --git a/crates/benilla/src/creature_anim/global_seq.rs b/crates/benilla/src/creature_anim/global_seq.rs index dabd6a80..7e519058 100644 --- a/crates/benilla/src/creature_anim/global_seq.rs +++ b/crates/benilla/src/creature_anim/global_seq.rs @@ -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, + /// 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