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 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! `gseqdump <internal\path.m2>` — 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 <m2 path>"))?;
|
||||
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(())
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<u32>; 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<u32>,
|
||||
/// 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<Item = (u16, u32)> + '_ {
|
||||
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<SpellVisualCatalog
|
||||
anim_id,
|
||||
sound,
|
||||
effect_slots,
|
||||
world_effect: u32_at(r, 12).and_then(some_unless_none),
|
||||
char_proc_slots,
|
||||
},
|
||||
);
|
||||
@@ -548,15 +578,17 @@ mod tests {
|
||||
}
|
||||
|
||||
/// One `SpellVisualKit` row: id, an unpinned field 1, anim (field 2), nine zeroed emitter
|
||||
/// slots (3..12), sound (field 13), then zeroed trailing columns to fill 35 fields.
|
||||
fn spell_visual_kit_row(id: u32, anim: u32, sound: u32) -> Vec<u8> {
|
||||
/// 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<u8> {
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
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)
|
||||
|
||||
@@ -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<crate::exterior_cull::ExteriorScene>,
|
||||
// Read for the trace probe below (the tag's low bits are the card's render alpha).
|
||||
Option<&bevy::mesh::MeshTag>,
|
||||
),
|
||||
Without<WorldCamera>,
|
||||
>,
|
||||
@@ -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::<Time>();
|
||||
app.add_systems(Update, face_billboards);
|
||||
app.world_mut().spawn((
|
||||
crate::player::WorldCamera,
|
||||
GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 10.0)),
|
||||
));
|
||||
// The twinkle track, a flat ×2 — visible wherever it is applied.
|
||||
let info = BillboardInfo {
|
||||
bone: 0,
|
||||
pivot: Vec3::ZERO,
|
||||
kind: BillboardKind::Spherical,
|
||||
scale_anim: Some(BoneScaleAnim {
|
||||
duration_ms: 1000,
|
||||
interp: false,
|
||||
keys: vec![(0, [2.0, 2.0, 2.0])],
|
||||
}),
|
||||
seq_translations: vec![],
|
||||
};
|
||||
// The joint arrives already composed by the rig: drive-written twinkle ×2 × parent
|
||||
// flare ×3 = 6. Sampling the track again on top would render ×12.
|
||||
let joint = app
|
||||
.world_mut()
|
||||
.spawn(GlobalTransform::from(Transform::from_scale(Vec3::splat(
|
||||
6.0,
|
||||
))))
|
||||
.id();
|
||||
let joint_card = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
BillboardCard::following_joint(&info, joint),
|
||||
Transform::IDENTITY,
|
||||
))
|
||||
.id();
|
||||
// The rigless lane: a plain anchor at scale 1 — the card's own sampler is the only
|
||||
// twinkle writer.
|
||||
let anchor = app.world_mut().spawn(GlobalTransform::IDENTITY).id();
|
||||
let rigless_card = app
|
||||
.world_mut()
|
||||
.spawn((BillboardCard::following(&info, anchor), Transform::IDENTITY))
|
||||
.id();
|
||||
app.update();
|
||||
let scale = |e: Entity| app.world().entity(e).get::<Transform>().unwrap().scale;
|
||||
assert_eq!(
|
||||
scale(joint_card),
|
||||
Vec3::splat(6.0),
|
||||
"joint lane: the composed joint scale, once — no self-sample on top"
|
||||
);
|
||||
assert_eq!(
|
||||
scale(rigless_card),
|
||||
Vec3::splat(2.0),
|
||||
"rigless lane: the card's own sampler still runs"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The billboard FRAME an equipped item's emitter rides** (decision 0813), with the real
|
||||
/// numbers of nazriel's B118 item (`LShoulder_Mail_PVPAlliance_C_01`: billboard bone 1 pivot
|
||||
/// `(-0.012, 0.162, -0.060)`, sparkle emitter position `(-0.252, 0.178, -0.046)`, both raw WoW
|
||||
|
||||
@@ -143,7 +143,9 @@ pub(crate) fn scenario_active() -> bool {
|
||||
/// fixture ON the terrain via a down-ray — required to see a ground-anchored effect's projected
|
||||
/// surface decals, `crate::ground_fx`; default 0 = the mid-air point), `WOW_FX_HOLD` (=1 keeps
|
||||
/// the fixture alive past one sequence pass — previewing a persistent HOLD kit's steady state;
|
||||
/// default 0 = the game's discrete-instance reap at one pass, then the pool drains).
|
||||
/// default 0 = the game's discrete-instance reap at one pass, then the pool drains),
|
||||
/// `WOW_FX_UP` (yards to raise the fixture above its resolved seat — for models authored below
|
||||
/// their anchor, whose opening frames the terrain otherwise swallows; default 0).
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct FxViewRequest {
|
||||
pub(crate) model_path: String,
|
||||
@@ -168,6 +170,11 @@ pub(crate) struct FxViewRequest {
|
||||
pub(crate) ground: bool,
|
||||
/// See `WOW_FX_HOLD` above.
|
||||
pub(crate) hold: bool,
|
||||
/// `WOW_FX_UP` — raise the fixture this many yards above its resolved seat (default 0).
|
||||
/// The escape hatch for models authored BELOW their anchor (Arcane Intellect's star cluster
|
||||
/// starts 1.6 yd under its attach point): at the default seat the terrain swallows the
|
||||
/// opening of the animation, and no camera angle can look through the ground.
|
||||
pub(crate) up: f32,
|
||||
}
|
||||
|
||||
/// The fixture's live state, written by `entities::spell_fx::drive_fx_view` and the phase
|
||||
@@ -415,6 +422,7 @@ impl Plugin for CapturePlugin {
|
||||
turn: knob("WOW_FX_TURN", 0.0),
|
||||
ground: knob("WOW_FX_GROUND", 0.0) > 0.5,
|
||||
hold: knob("WOW_FX_HOLD", 0.0) > 0.5,
|
||||
up: knob("WOW_FX_UP", 0.0),
|
||||
})
|
||||
.init_resource::<FxViewState>();
|
||||
Scenario {
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! been hand-assembling it out of GM commands. Mined from 507 MB of this project's own session
|
||||
//! transcripts, the standing tax is: `.go xyz` 750×, `.additem` 434×, `.modify` 345×, `.gm off`
|
||||
//! 257×, `.revive` **121×**, `.learn <one spell id>` a few dozen times — while
|
||||
//! `.character premade` (86 ready-made BiS/twink gear + talent templates sitting in this deploy's
|
||||
//! world DB, every class at 19/29/39/49/60) has been used **zero** times, because nobody knew it
|
||||
//! was there. That is the shape of the problem: the primitives exist and are undiscoverable, so
|
||||
//! `.character premade` (96 ready-made BiS/twink **gear** templates plus 53 **talent** ones sitting
|
||||
//! in this deploy's world DB, every class at 19/29/39/49/60) has been used **zero** times, because
|
||||
//! nobody knew it was there. That is the shape of the problem: the primitives exist and are undiscoverable, so
|
||||
//! each session re-derives a worse version of them.
|
||||
//!
|
||||
//! The rig is the one verb that hides all of it: name a body, get a body.
|
||||
@@ -61,6 +61,15 @@
|
||||
//! make the server *list* the templates its class has instead of applying one — the discovery path,
|
||||
//! since the catalog lives in the world DB and not in any client data.
|
||||
//!
|
||||
//! **Two things about `gear:` that have each cost a session.** First, the templates are not all full
|
||||
//! sets: of the 96 in this deploy, `pvp-r14-hunter-fx` holds 5 items and the priest/warrior
|
||||
//! `heal-r14`/`tank-r14` hold 8, against a 16.8 mean — so a sparse body can be exactly what the
|
||||
//! template says, and only a body under `GEAR_FLOOR` is evidence of a refusal. Second, applying gear
|
||||
//! **strips before it dresses** (`ApplyPremadeGearTemplateToPlayer` unequips all 19 slots, then
|
||||
//! `StoreNewItemInBestSlots` per item), so re-dressing a body leaves the old copy in its bags and
|
||||
//! mails the overflow once they fill. Repeated rigs on one character accumulate; a fresh rig-named
|
||||
//! character (give `WOW_RIG` a race+class) starts with empty bags and is the cheaper path.
|
||||
//!
|
||||
//! Non-combat throughout: the rig creates, configures, places and stops. It never fights, so the
|
||||
//! unattended-combat ban (method.md) is untouched.
|
||||
|
||||
@@ -86,6 +95,23 @@ const STEP_SECS: f32 = 0.8;
|
||||
/// the teleport all land as descriptor deltas a frame or two later.
|
||||
const VERIFY_SECS: f32 = 2.0;
|
||||
|
||||
/// The fewest items any real gear template in this deploy's world DB actually holds, so a body that
|
||||
/// wears fewer than this after a `gear:` **cannot** be the template landing — something refused.
|
||||
///
|
||||
/// Derived, not invented: `SELECT COUNT(*) … player_premade_item GROUP BY entry` over the 96 rows of
|
||||
/// `player_premade_item_template` runs 5…23 (mean 16.8). Only three templates are partial —
|
||||
/// `pvp-r14-hunter-fx` (5), and the priest/warrior `heal-r14`/`tank-r14` (8 each) — so a **low but
|
||||
/// legitimate** count is a real outcome and this floor deliberately sits under it: the warning fires
|
||||
/// only for a body that is essentially naked. (Entry 910 has one item row and no template row at
|
||||
/// all — an orphan; asking for it by entry errors server-side.)
|
||||
const GEAR_FLOOR: usize = 5;
|
||||
|
||||
/// Did a `gear:` ask come back with a body that cannot be wearing the template? `gear:?` is the
|
||||
/// discovery path — it lists and dresses nothing, so it never counts as a refusal.
|
||||
fn gear_was_refused(gear: Option<&str>, equipped: usize) -> bool {
|
||||
gear.is_some_and(|g| g != "?") && equipped < GEAR_FLOOR
|
||||
}
|
||||
|
||||
pub(crate) struct ProbeRigPlugin;
|
||||
|
||||
impl Plugin for ProbeRigPlugin {
|
||||
@@ -425,6 +451,22 @@ fn drive_rig(
|
||||
);
|
||||
}
|
||||
}
|
||||
// The other failure that reads as success: `.character premade gear` went out, the body
|
||||
// came back wearing almost nothing, and every later reading is of the wrong body. The
|
||||
// server unequips all 19 slots *before* it equips (`ApplyPremadeGearTemplateToPlayer` →
|
||||
// `AutoUnequipItemFromSlot` → `StoreNewItemInBestSlots`, vmangos `ObjectMgr.cpp`), so a
|
||||
// near-naked body means the equip half was refused while the strip half already ran —
|
||||
// the set is in the bags, or mailed if they were full. A session that misses this line
|
||||
// measures a probe in its underwear.
|
||||
if gear_was_refused(rig.spec.gear.as_deref(), equipped) {
|
||||
warn!(
|
||||
"rig: asked for gear but the body wears only {equipped} item(s) — the leanest \
|
||||
real template in this deploy holds {GEAR_FLOOR}, so the equip half was \
|
||||
refused (the strip half runs first, so the set is in the bags or was mailed). \
|
||||
Cheapest fix: rig a FRESH body — give WOW_RIG a race+class so it creates a \
|
||||
new character with empty bags, rather than re-dressing this one."
|
||||
);
|
||||
}
|
||||
rig.phase = RigPhase::Done;
|
||||
}
|
||||
RigPhase::Done => {}
|
||||
@@ -611,6 +653,21 @@ fn class_id(word: &str) -> Option<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_naked_body_after_a_gear_ask_is_a_refusal_but_a_lean_template_is_not() {
|
||||
// The three partial templates this deploy really ships (5 and 8 items) must stay quiet —
|
||||
// the floor exists to catch a stripped body, not to second-guess the world DB.
|
||||
assert!(!gear_was_refused(Some("pvp-r14-hunter-fx"), 5));
|
||||
assert!(!gear_was_refused(Some("tank-r14"), 8));
|
||||
assert!(
|
||||
gear_was_refused(Some("dps-preraid-bis"), 1),
|
||||
"a lone bow is the trap"
|
||||
);
|
||||
assert!(gear_was_refused(Some("dps-preraid-bis"), 0));
|
||||
assert!(!gear_was_refused(Some("?"), 0), "discovery dresses nothing");
|
||||
assert!(!gear_was_refused(None, 0), "no gear asked, no claim made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_spec_parses_in_any_order() {
|
||||
let a = RigSpec::parse("tauren druid 60 gear:heal-preraid-bis").unwrap();
|
||||
|
||||
@@ -447,8 +447,14 @@ impl Plugin for EntitiesPlugin {
|
||||
// is at `chain()`'s 20-element ceiling.
|
||||
(attach_item_glows, attach_spell_fx),
|
||||
// One nested (unordered) element — two independent free-model attach
|
||||
// passes; the outer tuple is at `chain()`'s 20-element ceiling.
|
||||
(attach_missile_models, attach_ground_fx_models),
|
||||
// passes, plus the world-plant tender (0850: sweeps orphaned plants,
|
||||
// re-plants root-aura ones on owner displacement); the outer tuple is at
|
||||
// `chain()`'s 20-element ceiling.
|
||||
(
|
||||
attach_missile_models,
|
||||
attach_ground_fx_models,
|
||||
spell_fx::tend_world_plants,
|
||||
),
|
||||
// Tick the live per-instance tint clones AFTER the attach passes registered
|
||||
// them, so a clone's first drawn frame is already on its own clock.
|
||||
spell_fx::tick_fx_tint,
|
||||
|
||||
@@ -194,6 +194,7 @@ pub(crate) fn drive_fx_view(
|
||||
) {
|
||||
pos.y -= hit.distance;
|
||||
}
|
||||
pos.y += req.up;
|
||||
return commands
|
||||
.spawn((
|
||||
crate::net::Guid(FXVIEW_UNIT_GUID),
|
||||
@@ -230,6 +231,7 @@ pub(crate) fn drive_fx_view(
|
||||
pos.y -= hit.distance;
|
||||
}
|
||||
}
|
||||
pos.y += req.up;
|
||||
commands
|
||||
.spawn((
|
||||
Transform::from_translation(pos)
|
||||
@@ -657,7 +659,8 @@ struct FxInstance {
|
||||
/// Which owner's reap can kill a persistent instance (cast router vs aura watcher) — the
|
||||
/// client's reap-walk discriminator next to the spell id ([`FxClass`]).
|
||||
class: FxClass,
|
||||
/// The M2 attachment id to hang from ([`benilla_formats::KIT_SLOT_TAGS`]).
|
||||
/// The M2 attachment id to hang from ([`benilla_formats::KIT_SLOT_TAGS`]), or
|
||||
/// [`benilla_formats::WORLD_EFFECT_TAG`] for the field-12 world-plant slot (0848/0850).
|
||||
tag: u16,
|
||||
/// The model-cache key.
|
||||
path: String,
|
||||
@@ -674,6 +677,69 @@ pub(super) struct FxAttached {
|
||||
instances: Vec<FxInstance>,
|
||||
}
|
||||
|
||||
/// A **world-planted** kit instance root (the field-12 slot, decisions 0848/0850): a free world
|
||||
/// entity, NOT a scene child of its owner — so [`tend_world_plants`] owns the two jobs the tree
|
||||
/// would otherwise do. Byte law (wow-re `kit30-effect-slot.md`): planted once at the owner's
|
||||
/// position × yaw × scale; a **root-aura** spell's plant (`EffectApplyAuraName` 26 anywhere in
|
||||
/// the spell — the client's flag 0x4000) re-plants when the owner is displaced (knockback,
|
||||
/// blink), re-baking facing and scale.
|
||||
#[derive(Component)]
|
||||
pub(super) struct WorldPlantFx {
|
||||
/// The unit the instance belongs to — despawn tracking (the client's node dies with its
|
||||
/// owner) and the re-plant source.
|
||||
owner: Entity,
|
||||
/// Re-plant on owner displacement (the 0x4000 leg) — root-aura persistents only.
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
/// The client's re-plant displacement gate: distance² > 1e-3 (`0x620580`'s squared-distance
|
||||
/// compare; world units).
|
||||
const REPLANT_EPS_SQ: f32 = 1e-3;
|
||||
|
||||
/// The plant transform (`0x620a90`): `translate(owner position) × yaw(owner facing about the up
|
||||
/// axis) × scale(owner scale)` — yaw ONLY (a swimming or mount-tilted body plants level), baked
|
||||
/// at spawn.
|
||||
fn world_plant_transform(owner: &GlobalTransform) -> Transform {
|
||||
let (scale, rotation, translation) = owner.to_scale_rotation_translation();
|
||||
let (yaw, _, _) = rotation.to_euler(EulerRot::YXZ);
|
||||
Transform {
|
||||
translation,
|
||||
rotation: Quat::from_rotation_y(yaw),
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any of the spell's three `EffectApplyAuraName` slots is `SPELL_AURA_MOD_ROOT` (26) —
|
||||
/// the client's `spellRec+0x16c[0..2] == 0x1a` scan that arms the re-plant flag 0x4000 (wow-re
|
||||
/// `kit30-effect-slot.md`; the column *name* is INFERRED there, the byte behaviour VERIFIED).
|
||||
/// Exactly the field-12 state family: Frost Nova, Net, Web, Entangling Roots, Frostbite.
|
||||
fn spell_has_root_aura(spells: Option<&crate::ui_action::Spells>, spell_id: u32) -> bool {
|
||||
const SPELL_AURA_MOD_ROOT: u32 = 26;
|
||||
spells
|
||||
.and_then(|s| s.catalog.get(spell_id))
|
||||
.is_some_and(|d| d.effect_apply_aura.contains(&SPELL_AURA_MOD_ROOT))
|
||||
}
|
||||
|
||||
/// Tend the live world plants: despawn a plant whose owner is gone (a scene child would get this
|
||||
/// from the tree; a free root must be swept), and re-plant a `follow` instance whose owner was
|
||||
/// displaced past [`REPLANT_EPS_SQ`] — the client's `0x620580` per-frame leg, which re-bakes
|
||||
/// position, facing AND scale on displacement.
|
||||
pub(super) fn tend_world_plants(
|
||||
mut commands: Commands,
|
||||
mut plants: Query<(Entity, &WorldPlantFx, &mut Transform)>,
|
||||
owners: Query<&GlobalTransform>,
|
||||
) {
|
||||
for (root, plant, mut t) in &mut plants {
|
||||
let Ok(owner) = owners.get(plant.owner) else {
|
||||
commands.entity(root).despawn();
|
||||
continue;
|
||||
};
|
||||
if plant.follow && owner.translation().distance_squared(t.translation) > REPLANT_EPS_SQ {
|
||||
*t = world_plant_transform(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the router's [`SpellKitFx`] edges: `Begin` records instances (and creates their model
|
||||
/// cache entries), `Reap` despawns the matching spell's persistent instances. Ordered per unit —
|
||||
/// a GO's reap-then-begin lands in emission order, so the precast dies before the release flash
|
||||
@@ -772,12 +838,20 @@ pub(super) fn resolve_spell_fx(
|
||||
|
||||
/// Spawn pending instances whose model finished building, and run the self-termination clock.
|
||||
/// The instance root is a child of the attach joint at the attachment offset (the cascade above),
|
||||
/// so the whole effect — meshes and particle emitters alike — rides the animating bone.
|
||||
/// so the whole effect — meshes and particle emitters alike — rides the animating bone. The one
|
||||
/// exception is the kit's **world-plant slot** ([`benilla_formats::WORLD_EFFECT_TAG`], kit field
|
||||
/// 12): its root is a free world entity at the owner's position/facing/scale, [`WorldPlantFx`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn attach_spell_fx(
|
||||
mut commands: Commands,
|
||||
mut units: Query<(Entity, &mut FxAttached, Option<&BoneAttach>)>,
|
||||
mut units: Query<(
|
||||
Entity,
|
||||
&mut FxAttached,
|
||||
Option<&BoneAttach>,
|
||||
&GlobalTransform,
|
||||
)>,
|
||||
fx: Option<Res<SpellFx>>,
|
||||
spells: Option<Res<crate::ui_action::Spells>>,
|
||||
time: Res<Time>,
|
||||
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut tint_reg: ResMut<FxTintAnims>,
|
||||
@@ -788,7 +862,7 @@ pub(super) fn attach_spell_fx(
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
for (unit, mut att, bones) in &mut units {
|
||||
for (unit, mut att, bones, unit_gt) in &mut units {
|
||||
att.instances.retain_mut(|inst| {
|
||||
// Self-termination (the cast-release flash ran its span).
|
||||
if let Some(expires) = inst.expires {
|
||||
@@ -821,25 +895,51 @@ pub(super) fn attach_spell_fx(
|
||||
if dm.parts.is_none() {
|
||||
return true; // model still loading — spawn on a later pass
|
||||
}
|
||||
// The attach cascade: the slot's tag, the client's two fallbacks, else the unit root.
|
||||
let point = bones.and_then(|b| {
|
||||
std::iter::once(inst.tag)
|
||||
.chain(ATTACH_FALLBACKS)
|
||||
.find_map(|tag| b.points.get(&tag).copied().map(|p| (tag, p)))
|
||||
.and_then(|(tag, (bone, offset))| {
|
||||
b.anchor(bone).map(|joint| (tag, joint, offset))
|
||||
})
|
||||
});
|
||||
// Ground-anchored: the cascade landed on the model's BASE point (`0x13`) or fell
|
||||
// through to the unit root — the two feet-level anchors. A hand/head/chest-anchored
|
||||
// instance keeps its flat quads as ordinary geometry (the ProtectionFrom* chest
|
||||
// shields author flat quads too — they must NOT decal to the ground).
|
||||
let ground_anchor = point.is_none_or(|(tag, ..)| tag == 0x13);
|
||||
let (parent, offset) = point.map_or((unit, Vec3::ZERO), |(_, j, o)| (j, o));
|
||||
let root = commands
|
||||
.spawn((Transform::from_translation(offset), Visibility::default()))
|
||||
.id();
|
||||
commands.entity(parent).add_child(root);
|
||||
// The world-plant slot (kit field 12, decisions 0848/0850): the client passes NO
|
||||
// attach tag (`0x61fcf0` pushes −1), skips the bone pipeline entirely and plants the
|
||||
// model once, world-space, at the owner's position × yaw × scale (`0x620c86` via
|
||||
// `0x620a90`) — it does not ride a bone and does not turn with the unit afterwards.
|
||||
// A root-aura spell's plant additionally re-plants on owner displacement
|
||||
// ([`tend_world_plants`] — the client's 0x4000 flag leg).
|
||||
let planted = inst.tag == benilla_formats::WORLD_EFFECT_TAG;
|
||||
// Ground-anchored: a world plant sits at the feet by construction; otherwise the
|
||||
// cascade landed on the model's BASE point (`0x13`) or fell through to the unit
|
||||
// root — the feet-level anchors. A hand/head/chest-anchored instance keeps its flat
|
||||
// quads as ordinary geometry (the ProtectionFrom* chest shields author flat quads
|
||||
// too — they must NOT decal to the ground).
|
||||
let (root, ground_anchor) = if planted {
|
||||
let follow =
|
||||
inst.persistent && spell_has_root_aura(spells.as_deref(), inst.spell_id);
|
||||
let root = commands
|
||||
.spawn((
|
||||
world_plant_transform(unit_gt),
|
||||
Visibility::default(),
|
||||
WorldPlantFx {
|
||||
owner: unit,
|
||||
follow,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
(root, true)
|
||||
} else {
|
||||
// The attach cascade: the slot's tag, the client's two fallbacks, else the unit
|
||||
// root.
|
||||
let point = bones.and_then(|b| {
|
||||
std::iter::once(inst.tag)
|
||||
.chain(ATTACH_FALLBACKS)
|
||||
.find_map(|tag| b.points.get(&tag).copied().map(|p| (tag, p)))
|
||||
.and_then(|(tag, (bone, offset))| {
|
||||
b.anchor(bone).map(|joint| (tag, joint, offset))
|
||||
})
|
||||
});
|
||||
let ground_anchor = point.is_none_or(|(tag, ..)| tag == 0x13);
|
||||
let (parent, offset) = point.map_or((unit, Vec3::ZERO), |(_, j, o)| (j, o));
|
||||
let root = commands
|
||||
.spawn((Transform::from_translation(offset), Visibility::default()))
|
||||
.id();
|
||||
commands.entity(parent).add_child(root);
|
||||
(root, ground_anchor)
|
||||
};
|
||||
// The one shared effect-visuals body — rig, skinned parts, joint-riding
|
||||
// cards/emitters/ribbons, ground decals, material animation. Parts were checked
|
||||
// ready above, so this always attaches.
|
||||
|
||||
Reference in New Issue
Block a user