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:
Sam
2026-07-31 17:02:14 +07:00
co-authored by Claude Fable 5
parent 59b4a4f4ce
commit 5384fcd5bc
9 changed files with 421 additions and 42 deletions
@@ -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(())
}
+1 -1
View File
@@ -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};
+84 -11
View File
@@ -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)
+93 -1
View File
@@ -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
+9 -1
View File
@@ -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 {
+60 -3
View File
@@ -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();
+8 -2
View File
@@ -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,
+123 -23
View File
@@ -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.