From 5384fcd5bc627e457ddfef80ef815201844e8f8c Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 31 Jul 2026 17:02:14 +0700 Subject: [PATCH] roots wear their ice, and Arcane Intellect's flare settles down - root and snare state visuals are read from the spell kit's tenth effect slot and planted in the world at the unit's feet, where they belong - Arcane Intellect's flare is one size cast to cast now: a joint-lane card was taking the sequence twinkle twice (joint and card), sometimes swelling it to 2.5 yards - refusing a gear piece now names the reason instead of printing a bare count Co-Authored-By: Claude Fable 5 --- .../examples/dump_bone_keys.rs | 15 ++ crates/benilla-formats/examples/gseqdump.rs | 28 ++++ crates/benilla-formats/src/lib.rs | 2 +- crates/benilla-formats/src/spell_visual.rs | 95 ++++++++++-- crates/benilla/src/billboard.rs | 94 ++++++++++- crates/benilla/src/capture/mod.rs | 10 +- crates/benilla/src/capture/probe_rig.rs | 63 +++++++- crates/benilla/src/entities.rs | 10 +- crates/benilla/src/entities/spell_fx.rs | 146 +++++++++++++++--- 9 files changed, 421 insertions(+), 42 deletions(-) create mode 100644 crates/benilla-formats/examples/gseqdump.rs diff --git a/crates/benilla-formats/examples/dump_bone_keys.rs b/crates/benilla-formats/examples/dump_bone_keys.rs index 3932db33..5cef3387 100644 --- a/crates/benilla-formats/examples/dump_bone_keys.rs +++ b/crates/benilla-formats/examples/dump_bone_keys.rs @@ -64,6 +64,21 @@ fn main() { q[0], q[1], q[2], q[3] ); } + // T/S values too, not just counts — for a flare or a rise the VALUES are the + // effect (the gseqdump lesson, here for sequence keys), and per-axis (non-)uniformity + // of a scale key is load-bearing for the billboard law's per-axis preserve. + for (t, v) in bk.translation.iter().take(8) { + println!( + " trans t={t:.3} ({:+.3},{:+.3},{:+.3})", + v[0], v[1], v[2] + ); + } + for (t, v) in bk.scale.iter().take(8) { + println!( + " scale t={t:.3} ({:+.3},{:+.3},{:+.3})", + v[0], v[1], v[2] + ); + } } } } diff --git a/crates/benilla-formats/examples/gseqdump.rs b/crates/benilla-formats/examples/gseqdump.rs new file mode 100644 index 00000000..3ccf9fdd --- /dev/null +++ b/crates/benilla-formats/examples/gseqdump.rs @@ -0,0 +1,28 @@ +//! `gseqdump ` — every global-sequence bone channel with its RAW KEYS. +//! +//! `benilla-extract m2anim` names which bones carry global-sequence channels and their periods, +//! but never the key VALUES — and for a twinkle (a scale loop on a star card) the values ARE the +//! effect: a 0→1 flicker and a 0→20 flare are the same one-line summary. Output is Blizzard +//! data — pipe it to the scratchpad, never into the repo. + +fn main() -> anyhow::Result<()> { + let virt = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: gseqdump "))?; + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let bytes = chain.read_file(&virt)?; + for g in benilla_formats::parse_m2_global_sequence_bones(&bytes) { + println!("bone {}", g.bone); + if let Some(t) = &g.translation { + println!(" T period {}ms keys {:?}", t.period_ms, t.keys); + } + if let Some(r) = &g.rotation { + println!(" R period {}ms keys {:?}", r.period_ms, r.keys); + } + if let Some(s) = &g.scale { + println!(" S period {}ms keys {:?}", s.period_ms, s.keys); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 50ae6a92..f1d2c820 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -132,7 +132,7 @@ pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX mod spell_visual; pub use spell_visual::{ char_proc_type, load_spell_visual_catalog, CharProc, SpellVisualCatalog, VisualKit, - VisualStages, KIT_CHAR_PROCS, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, + VisualStages, KIT_CHAR_PROCS, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, WORLD_EFFECT_TAG, }; mod emit_timing; pub use emit_timing::{EmitParams, EmitTiming, ParamsNow}; diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual.rs index 40729a70..3dfc0edf 100644 --- a/crates/benilla-formats/src/spell_visual.rs +++ b/crates/benilla-formats/src/spell_visual.rs @@ -31,8 +31,18 @@ //! decision 0099 phase 3, read here since that phase). Each slot's attach tag is a compile-time //! immediate in the client's slot loop (`0x60edf0`, byte-cited push sites — wow-re //! `spell-visual-apply.md` §1.3) and a **direct M2 `AttachmentID`**: in kit-field order, -//! [`KIT_SLOT_TAGS`]. Field 12 is the missile effect slot (phase 4), field 14 a visual-group -//! fallback id — neither is read here. **Fields 15–34 are the four `CharProc` slots** — the +//! [`KIT_SLOT_TAGS`]. **Field 12 (`kit+0x30`) is a TENTH effect slot** — read here as +//! [`VisualKit::world_effect`] (decision 0848): wow-re pinned its consumer as "the missile +//! slot" (`0x60edf0` plays it inline via `0x61fcf0`, a `CEffect`-family node stamped with a +//! missile marker — `spell-visual-apply.md` §1.4), but the shipped table's population is +//! **body/ground state models**, never projectiles: the aura-state family the nine slots miss +//! (Frost Nova's kit 285 → `Frost_Nova_state.mdx`, Net's 744 → `Net_State.mdx`, Entangling +//! Roots' 66, Web's 746) plus caster-feet rings on cast/impact kits (Thunderclap's 349, +//! Flamestrike's 420, Vanish's 389). Same `CEffect` lifecycle as the nine (spell-id-tagged on +//! the unit's `+0xb4` list, the stage sets lifetime — §1.5). Placement is byte-pinned (wow-re +//! `kit30-effect-slot.md`, folded back 0850): no bone — a one-time **world plant** at the +//! owner's position/facing/scale, [`WORLD_EFFECT_TAG`]. Field 14 is a visual-group fallback +//! id — not read here. **Fields 15–34 are the four `CharProc` slots** — the //! *character* half of a kit (the body's own alpha/tint, as opposed to the attach-point emitters): //! five parallel 4-element arrays, `CharProcType[4]` @+0x3c then `CharParamZero/One/Two/Three[4]` //! @+0x4c/+0x5c/+0x6c/+0x7c (wow-re `spellvisual-schema.md`, byte-pinned: all 20 consumed by the @@ -87,6 +97,16 @@ const SPELL_VISUAL_KIT_FIELDS: usize = 35; /// Base(0x13), LeftHand(0x15), RightHand(0x16), Breath(0x11), Special1–3(0x17–0x19). pub const KIT_SLOT_TAGS: [u16; 9] = [0x14, 0x22, 0x13, 0x15, 0x16, 0x11, 0x17, 0x18, 0x19]; +/// The sentinel tag [`VisualKit::effects`] yields for [`VisualKit::world_effect`] (kit field +/// 12) — the client's own **−1**: `0x61fcf0` passes no attach tag at all (`0x61fd23: push -1`, +/// `node+0x24` stays the ctor's −1), which in the placement walk `0x620be0` skips the entire +/// bone pipeline and instead triggers a **one-time world plant** (`0x620c86`): transform = +/// `translate(owner position) × yaw(owner facing) × scale(owner scale)`, baked at spawn — the +/// model does NOT ride a bone and does not turn with the unit afterwards (wow-re +/// `kit30-effect-slot.md`, §5 byte-arbitrated; decision 0848). Consumers key on this tag to +/// plant in world space rather than attach. +pub const WORLD_EFFECT_TAG: u16 = u16::MAX; + /// The missile **destination-attachment** ordinal table — the client's `0x860a18`, dumped /// byte-for-byte (wow-re `spell-visual-apply.md` §5, 11 live entries): [`KIT_SLOT_TAGS`] in /// kit-field order plus `0xf`/`0x10` at ordinals 9/10. `SpellVisual` field 9 indexes here to the @@ -196,6 +216,13 @@ pub struct VisualKit { /// order — slot `i` attaches at [`KIT_SLOT_TAGS`]`[i]`. Both none-sentinels fold to `None` /// like [`Self::anim_id`]. Iterate populated slots via [`Self::effects`]. pub effect_slots: [Option; 9], + /// Kit field 12 (`kit+0x30`) — the **tenth effect slot** (module docs; decision 0848). The + /// client plays it inline in `PlaySpellVisualKit` via `0x61fcf0`, same `CEffect` lifecycle as + /// the nine bone-attach slots. On the shipped table it carries the kit's body/ground **state + /// model** — the frozen ice, the net, the roots, the Thunderclap ring — never a projectile + /// (the real missile is `SpellVisual` field 7's, [`VisualStages::missile_model`]). Folded + /// into [`Self::effects`] at [`WORLD_EFFECT_TAG`]. + pub world_effect: Option, /// The four `CharProc` slots (kit fields 15–34, transposed out of the five parallel arrays). /// An unfilled slot is `None` — see [`char_proc_slot`] for the sentinel law; iterate the filled /// ones via [`Self::char_procs`]. **Two consumers, two halves of the same table:** the *body* @@ -223,12 +250,14 @@ impl VisualKit { /// The populated emitter slots as `(M2 attachment id, SpellVisualEffectName id)` pairs, in /// kit-field order — the client fires **all** populated slots at **every** stage (stage sets - /// lifetime policy only, wow-re `spell-visual-apply.md` §1.3). + /// lifetime policy only, wow-re `spell-visual-apply.md` §1.3) — then [`Self::world_effect`] + /// last, under the [`WORLD_EFFECT_TAG`] world-plant sentinel (decisions 0848/0850). pub fn effects(&self) -> impl Iterator + '_ { self.effect_slots .iter() .enumerate() .filter_map(|(i, e)| e.map(|id| (KIT_SLOT_TAGS[i], id))) + .chain(self.world_effect.map(|id| (WORLD_EFFECT_TAG, id))) } } @@ -460,6 +489,7 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result Vec { + /// slots (3..11), the world-effect slot (field 12), sound (field 13), then zeroed trailing + /// columns to fill 35 fields. + fn spell_visual_kit_row(id: u32, anim: u32, sound: u32, world: u32) -> Vec { let mut rec = Vec::new(); rec.extend(u32le(id)); rec.extend(u32le(0)); // field 1, unpinned rec.extend(u32le(anim)); // field 2 - for _ in 3..13 { - rec.extend(u32le(0)); // fields 3..12 (9 emitter slots + missile) + for _ in 3..12 { + rec.extend(u32le(0)); // fields 3..11 (9 emitter slots) } + rec.extend(u32le(world)); // field 12 rec.extend(u32le(sound)); // field 13 for _ in 14..SPELL_VISUAL_KIT_FIELDS { rec.extend(u32le(0)); @@ -572,7 +604,7 @@ mod tests { // SPELL_VISUAL_FIELDS/SPELL_VISUAL_KIT_FIELDS. let sv = spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0); assert_eq!(sv.len(), SPELL_VISUAL_FIELDS * 4, "64B record"); - let svk = spell_visual_kit_row(1, 0, 0); + let svk = spell_visual_kit_row(1, 0, 0, 0); assert_eq!(svk.len(), SPELL_VISUAL_KIT_FIELDS * 4, "140B record"); } @@ -639,9 +671,9 @@ mod tests { #[test] fn kit_anim_and_sound_fold_both_none_sentinels_to_none() { let mut records = Vec::new(); - records.extend(spell_visual_kit_row(38, 53, 1484)); // Fireball's cast kit - records.extend(spell_visual_kit_row(1, 0, 0)); // plain-zero "none" - records.extend(spell_visual_kit_row(2, u32::MAX, u32::MAX)); // the -1 "none" form + records.extend(spell_visual_kit_row(38, 53, 1484, 0)); // Fireball's cast kit + records.extend(spell_visual_kit_row(1, 0, 0, 0)); // plain-zero "none" + records.extend(spell_visual_kit_row(2, u32::MAX, u32::MAX, u32::MAX)); // the -1 "none" form let bytes = build_wdbc(3, SPELL_VISUAL_KIT_FIELDS as u32, &records); let rs = parse( &bytes, @@ -703,6 +735,24 @@ mod tests { assert_eq!(VisualKit::default().effects().count(), 0); } + /// The tenth slot (field 12, decision 0848) rides `effects()` after the nine, at the interim + /// Base anchor — one iterator, so every kit consumer (the aura-state watcher, kit pushes, + /// cast/impact plays) picks it up without knowing it exists. + #[test] + fn kit_world_effect_joins_effects_at_base() { + let kit = VisualKit { + // Frost Nova's state-kit shape: Head sparkle (slot 0) + the feet ice in field 12. + effect_slots: [Some(54), None, None, None, None, None, None, None, None], + world_effect: Some(284), + ..Default::default() + }; + assert_eq!( + kit.effects().collect::>(), + vec![(0x14, 54), (WORLD_EFFECT_TAG, 284)], + "the nine first, then the world-plant slot" + ); + } + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent) — this /// crate's established gate (`anim_data.rs`, `spell_catalog.rs`, …). fn vanilla_data_dir() -> std::path::PathBuf { @@ -830,6 +880,29 @@ mod tests { Some("Spells\\Fire_Precast_Hand.mdx"), "SpellVisualEffectName field 2 = the effect model path" ); + + // The tenth slot (field 12, decision 0848) on the real table — the root/snare state + // family the nine slots miss. Frost Nova (spell 122 → visual 17): state kit 285's feet + // ice; Net (spell 6533 → visual 683): state kit 744's net wrap, a kit with NO ordinary + // slots at all. + let frost_nova_state = cat + .kit(cat.stages(17).expect("Frost Nova's visual").state) + .expect("Frost Nova's state kit"); + assert_eq!(frost_nova_state.world_effect, Some(284)); + assert_eq!( + cat.effect_path(284), + Some("Spells\\Frost_Nova_state.mdx"), + "the frozen-feet model rides field 12" + ); + let net_state = cat + .kit(cat.stages(683).expect("Net's visual").state) + .expect("Net's state kit"); + assert_eq!( + net_state.effects().collect::>(), + vec![(WORLD_EFFECT_TAG, 594)], + "the net wrap is the kit's ONLY effect — invisible without field 12" + ); + assert_eq!(cat.effect_path(594), Some("Spells\\Net_State.mdx")); // The impact kit's chest burst — the phase-4 arrival hand-off will play this. assert_eq!( cat.kit(stages.impact) diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index 032e8923..e41f2f4e 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -107,9 +107,19 @@ impl BillboardCard { /// Build a card riding a live JOINT — an animated host's billboard bone (the swinging lamp, /// the mount's lights). The joint's frame already bakes the bone pivot (the 0130 rig identity /// `joint = root · M_bone · T(pivot)`), so the card's local pivot is the joint origin. + /// + /// The joint also already carries the bone's global-sequence scale — every joint/anchor lane + /// runs a [`crate::creature_anim::GlobalSeqDrive`] over the same bone list, and `re_place` + /// reads the composed result back as [`Self::scale`] — so the card must NOT sample its own + /// copy of the track. Keeping it multiplied the twinkle in twice, at two different clocks + /// (the drive's spawn clock × the card's position-hash phase): squared peaks at random + /// alignment — Arcane Intellect's sometimes-2.5-yd lens flare (decision 0851). The sampler + /// stays on the rigless lanes ([`Self::new`]/[`Self::following`]), where the card is the only + /// thing animating. pub fn following_joint(info: &BillboardInfo, joint: Entity) -> Self { let mut card = Self::following(info, joint); card.local_pivot = Vec3::ZERO; + card.scale_anim = None; card } @@ -441,6 +451,8 @@ pub(crate) fn face_billboards( &mut Visibility, // Whether the exterior-scene cull owns this card's `Visibility` — see the mirror below. Has, + // Read for the trace probe below (the tag's low bits are the card's render alpha). + Option<&bevy::mesh::MeshTag>, ), Without, >, @@ -452,12 +464,29 @@ pub(crate) fn face_billboards( // for every billboard; never a per-pivot aim). let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up()); let elapsed_ms = time.elapsed().as_millis() as u32; - for (entity, mut card, mut tf, mut global, mut visibility, gated) in &mut cards { + for (entity, mut card, mut tf, mut global, mut visibility, gated, tag) in &mut cards { if let Some(owner) = card.follow { match owners.get(owner) { Ok((gt, vis)) => { let pivot = card.local_pivot; card.re_place(gt.compute_transform(), pivot); + // The card-provenance trace (`WOW_MOVE_TRACE`, ~2 Hz): where each following + // card sits, how big it renders, and at what alpha. An invisible spell-fx + // card has exactly three possible causes — placement, scale, alpha — and a + // screenshot cannot tell them apart; this line does (it split Arcane + // Intellect's "missing" stars into scale ✓ / place ✓ / alpha ×0.3 — the + // faithful stealth-aura compose, not a defect). + if crate::dbg_trace::enabled() && elapsed_ms % 512 < 20 { + crate::dbg_trace::line( + "card", + &format!( + "card={entity} owner={owner} scale={:.3} a={:.2} pivot={:.2?}", + card.scale, + tag.map_or(-1.0, |t| crate::mesh_tag::alpha_of(t.0)), + card.world_pivot + ), + ); + } // A card is visually part of its owner — mirror a HIDDEN owner, because the // card is a world root and inherits nothing. The live case: the sea-crossing // transport's off-map leg hides the boat subtree (`tick_transports`); without @@ -612,6 +641,69 @@ mod tests { ); } + /// A JOINT-lane card takes the global-sequence twinkle from the joint ALONE (decision 0851): + /// every joint/anchor lane runs a `GlobalSeqDrive` that writes the bone's scale track onto + /// the joint, and `re_place` reads the composed result back as the card's scale — a card that + /// also sampled its own copy multiplied the twinkle in twice, at two clocks offset by the + /// position-hash phase (Arcane Intellect's sometimes-2.5-yd lens flare). The rigless lanes + /// keep the sampler: there the card is the only thing animating. + #[test] + fn joint_lane_takes_the_twinkle_from_the_joint_alone() { + let mut app = App::new(); + app.init_resource::