From b81e481348eb4301a8297cbb4e3d71d075e30c5a Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 8 Sep 2026 16:23:48 +0700 Subject: [PATCH] afk and dnd work, the stun swirl shows, and the world drop is back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /afk and /dnd send instead of printing Unknown chat type: the flag sets, shows over your head at once, the four echo lines are the client's own, and moving clears AFK under the real autoClearAFK CVar (2082, 2088) - the stun swirl and the other state-kit visuals show — a cache the login map change cleared lost them nine runs in ten — and a state kit's animation id is the base recompute the reference runs, never a play, so the stray Stun one-shot is gone (2085) - dropping an item on the world brings up the destroy prompt again: since the stock WorldFrame landed, the world is a frame, and the drop's gate had been written for the absence of one (2089, B380) - the mouse no longer snaps to the window's centre when the loading cover drops on /logout; the cover hands the pointer back only where it knows it is (2090) Co-Authored-By: Claude Fable 5.1 --- crates/benilla-app/src/aura_visual/tests.rs | 1 + crates/benilla-app/src/creature_anim.rs | 29 + .../benilla-app/src/creature_anim/driver.rs | 45 +- .../src/creature_anim/driver/tests.rs | 103 ++++ .../src/creature_anim/spell_visual.rs | 70 ++- .../src/creature_anim/spell_visual/tests.rs | 193 ++++++- crates/benilla-app/src/cvars.rs | 10 + crates/benilla-app/src/entities/dest_fx.rs | 23 +- crates/benilla-app/src/entities/missile.rs | 14 +- .../benilla-app/src/entities/spell_fx/mod.rs | 117 +++- .../benilla-app/src/loading_screen/input.rs | 195 ++++++- crates/benilla-app/src/nameplates.rs | 18 + crates/benilla-app/src/ui_chat.rs | 15 +- crates/benilla-app/src/ui_chat/away.rs | 520 ++++++++++++++++++ crates/benilla-app/src/ui_chat/edit.rs | 30 +- crates/benilla-app/src/ui_chat/input/mod.rs | 85 ++- crates/benilla-app/src/ui_chat/input/parse.rs | 22 +- crates/benilla-app/src/ui_chat/tests.rs | 20 +- .../benilla-app/src/ui_script/chat_tests.rs | 73 +++ .../src/ui_script/delete_item_tests.rs | 68 ++- .../src/ui_script/options_tests.rs | 1 - crates/benilla-app/src/ui_script/test_ui.rs | 72 +++ crates/benilla-app/tests/cover_input_wall.rs | 9 + .../src/bin/benilla-extract/kitanim.rs | 226 ++++++++ .../src/bin/benilla-extract/main.rs | 12 + crates/benilla-ui/src/script/cursor.rs | 7 +- crates/benilla-ui/src/script/cursor/drag.rs | 119 ++++ crates/benilla-ui/src/script/pointer.rs | 58 +- 28 files changed, 2039 insertions(+), 116 deletions(-) create mode 100644 crates/benilla-app/src/ui_chat/away.rs create mode 100644 crates/benilla-formats/src/bin/benilla-extract/kitanim.rs diff --git a/crates/benilla-app/src/aura_visual/tests.rs b/crates/benilla-app/src/aura_visual/tests.rs index e58a0333..70165c45 100644 --- a/crates/benilla-app/src/aura_visual/tests.rs +++ b/crates/benilla-app/src/aura_visual/tests.rs @@ -247,6 +247,7 @@ fn app_with_chains() -> App { app.add_message::(); app.add_message::(); app.add_message::(); + app.add_message::(); // The water-plane twin map the alpha author composes with (empty here — no water in a fixture). app.init_resource::(); app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index d7ac061f..fb9edeba 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -667,6 +667,34 @@ pub(crate) struct EmoteAnim { pub(crate) seq: u64, } +/// A **state kit's animation id, which is a comparison and never a play** — `PlaySpellVisualKit`'s +/// stage-2 leg (VERIFIED, wow-re `state-kit-anim-and-stun-pose.md` §1/§2; decision 2085). +/// +/// `0x60edf0`'s tail has exactly one site that hands a kit's `+0x8` to the play primitive +/// (`0x60f3c5 call 0x5fe2f0`), and **stage 2 is diverted around it**: `0x60f387 jne` takes the +/// stage-2 leg, which compares the unit's currently-armed id (`0x5fdb50` — upper-body key bone +/// preferred, bone 0 as the fallback) against `kit.AnimID` and either does nothing (`je 0x60f3ca`, +/// already playing it) or runs `0x5fd9e0(unit, -1)` — a **base-animation recompute** — before +/// leaving the block for good. Both `SpellVisual` field-4 consumers hardcode stage 2 (the aura +/// watcher `0x5ff4c6` and the impact hand-off `0x61dced`), so no path in the image ever plays a +/// state kit's anim. +/// +/// It is therefore an animation-**cutting** mechanism, and the cut is not incidental: Charge +/// (22911) plays `Knockdown`(121) from its impact kit and then cuts it with its own state kit's +/// `Stun`(14), because 121 ≠ 14 forces the recompute. That is also why no stun ever shows the +/// `Stun` pose despite 23 kits naming it — the selector `0x5fd8b0` cannot produce 14 (its literal +/// outputs are enumerated in the note), so the recompute lands on `Stand`. +/// +/// The driver holds the comparison because the armed id lives there. An aura **refresh** in place +/// emits nothing: the watcher `0x604d00` fires neither arm when a slot is rewritten with the same +/// id and a non-zero flags nibble, which our per-unit armed-id diff reproduces by construction. +#[derive(Message, Clone, Copy)] +pub(crate) struct BaseAnimRecompute { + pub(crate) entity: Entity, + /// The state kit's `AnimID` — the right-hand side of `0x60f390`'s compare, never played. + pub(crate) anim_id: u16, +} + /// Rear up a rider's mount (decision 0441 P2): resolved to a [`EmoteAnim`] one-shot of /// MountSpecial(94) on the unit's MOUNT CHILD entity by [`flourish_to_anim`] — the §5-verified /// routing (the flourish plays on the mount model; the rider holds Mount(91) throughout). @@ -1138,6 +1166,7 @@ impl Plugin for CreatureAnimPlugin { .init_resource::() .init_resource::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index b098f7cb..9468e622 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -35,8 +35,8 @@ use super::select::{ }; use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony}; use super::{ - find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, CastHold, DefenseAnim, - EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, SheathRequest, + find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, BaseAnimRecompute, CastHold, + DefenseAnim, EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, SheathRequest, SheathSwapMessage, SwingImpact, SwingMessage, SwingSlowdown, Wielded, WoundAnim, }; @@ -357,6 +357,9 @@ pub(super) fn drive_animations( // headless test worlds that build no net seam; a missing cache reads exactly like a // template we have not received, which is the reference's own null-record leg. Option>, + // The **stage-2 base recomputes** ([`BaseAnimRecompute`]) — in the tuple for the same + // reason as its neighbours: this system sits on the 16-SystemParam ceiling. + MessageReader, ), // The variation roll's LCG state (decision 0114 — the client's single CRT `_rand` stream, // shared by every play; [`select::msvc_rand`]). @@ -365,7 +368,7 @@ pub(super) fn drive_animations( // diff-only filter; see the trace block after the mode machine). mut anim_trace_last: Local>, ) { - let (emote_sounds, loot_kneel, time, names) = aux; + let (emote_sounds, loot_kneel, time, names, mut recomputes) = aux; let dt = time.delta_secs(); // This frame's one-shot PLAY CALLS (swings + anim-emotes), gathered per unit and replayed // in the client's call order below ([`PlaySeq`] stamps — the net drain stamps packet order, @@ -428,6 +431,13 @@ pub(super) fn drive_animations( .or_default() .push((OneShotReq::Emote(e.anim_id), e.seq)); } + // This frame's **stage-2 base recomputes** — a state kit's anim id, which is never played + // ([`BaseAnimRecompute`]). Last wins: the reference makes the comparison per kit play, and two + // in one frame leave the second's verdict standing. + let mut pending_recompute: bevy::ecs::entity::EntityHashMap = default(); + for r in recomputes.read() { + pending_recompute.insert(r.entity, r.anim_id); + } // This frame's sheath requests, keyed by unit (a later request replaces an earlier — the // client's last SetSheatheState call wins). let mut pending_sheath: bevy::ecs::entity::EntityHashMap = default(); @@ -1214,6 +1224,35 @@ pub(super) fn drive_animations( } } } + // ── The **stage-2 base recompute** (`0x60f389`–`0x60f399`, decision 2085): a state kit + // that names an animation compares it against the id the unit is already playing + // (`0x5fdb50` — the upper-body key bone if it holds one, else bone 0) and, on a + // difference, runs `0x5fd9e0(unit, -1)`. It never plays the id, and on a match it does + // nothing at all. + // + // It runs HERE, after this frame's one-shot arms, because that ordering is the whole + // observable effect: the impact kit (stage 1) plays first and the state kit (stage 2) + // then cuts it. Charge (22911) is the case that names itself — `Knockdown`(121) from kit + // 348, cut by kit 349's `Stun`(14) because 121 ≠ 14. + // + // Expressed as the re-selection, not a re-arm: clearing the gait target IS the recompute + // (decision 1655), and dropping `Mode::Swing` is what ends a one-shot. **Named residual:** + // a unit inside a Special (a pose, an airborne arc) is left alone — the reference's + // recompute re-enters the selector chain, which would land back in the same state, and + // forcing `Mode::Gait` here would replay the pose's enter clip instead. + if let Some(&want) = pending_recompute.get(&entity) { + let armed = drv.overlay.map(|ov| ov.id).or_else(|| { + tr.get_main_animation() + .and_then(|n| anims.clips.iter().find(|c| c.node == n)) + .map(|c| c.anim_id) + }); + if armed != Some(want) && matches!(drv.mode, Mode::Swing { .. } | Mode::Gait) { + drv.deferred = None; // a normal arm clears the cache (`0x5fe48e`) + drv.mode = Mode::Gait; + drv.gait = None; // recompute a fresh gait next frame + } + } + // ── **The mode machine** — the base track's whole decision, lifted into [`mode`] // (decision 0933). It is the one phase of this pass with a clean input boundary: a fixed // set of already-computed facts in, the driver + player + transitions out, and none of diff --git a/crates/benilla-app/src/creature_anim/driver/tests.rs b/crates/benilla-app/src/creature_anim/driver/tests.rs index d4242d23..3430485c 100644 --- a/crates/benilla-app/src/creature_anim/driver/tests.rs +++ b/crates/benilla-app/src/creature_anim/driver/tests.rs @@ -175,6 +175,7 @@ fn app() -> App { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::(); @@ -4178,3 +4179,105 @@ fn a_combat_over_combat_fast_path_does_not_raise_the_anim_edge() { pending trail arm survives" ); } + +/// The **stage-2 base recompute** (decision 2085): a state kit's animation id is compared against +/// what the unit is already playing (`0x5fdb50`) and, on a difference, spent on +/// `0x5fd9e0(unit, -1)` — never played. So the recompute is an animation *cutter*, and the case +/// that names it is a spell cutting its own impact clip: Charge's kit 348 plays `Knockdown`(121), +/// kit 349 names `Stun`(14), and 121 ≠ 14 ends the Knockdown. +/// +/// The match arm is the other half and is not decoration: `0x60f393 je 0x60f3ca` leaves the block +/// having done nothing at all, so a state kit naming the clip already running must not restart or +/// cut it. +mod stage_two_recompute { + use super::*; + use crate::creature_anim::Mode; + + fn model() -> ModelAnimations { + ModelAnimations { + graph: Handle::default(), + clips: vec![ + clip(0, 1, true), // Stand + clip(121, 2, false), // Knockdown — the impact kit's one-shot + ], + hand_close: [None, None], + playable_animation_lookup: Vec::new(), + animation_lookup: Vec::new(), + global_bones: Vec::new(), + first_seq: None, + pose: Default::default(), + } + } + + fn victim(app: &mut App) -> Entity { + app.world_mut() + .spawn(( + model(), + AnimationPlayer::default(), + AnimationTransitions::new(), + AnimDriver::default(), + )) + .id() + } + + /// A one-shot is armed, then a state kit naming a DIFFERENT id lands: the clip ends. + #[test] + fn a_differing_state_kit_anim_cuts_the_one_shot() { + let mut app = app(); + let unit = victim(&mut app); + + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 121, + seq: 1, + }); + app.update(); + assert!( + matches!( + app.world().entity(unit).get::().unwrap().mode, + Mode::Swing { id: 121, .. } + ), + "the impact kit's Knockdown holds the base slot" + ); + + app.world_mut() + .write_message(crate::creature_anim::BaseAnimRecompute { + entity: unit, + anim_id: 14, // Stun — what kit 349 names, and never plays + }); + app.update(); + assert_eq!( + app.world().entity(unit).get::().unwrap().mode, + Mode::Gait, + "121 != 14 recomputes the base, which is what ends the Knockdown" + ); + } + + /// …and a state kit naming the id already playing does nothing at all. + #[test] + fn a_matching_state_kit_anim_leaves_the_one_shot_alone() { + let mut app = app(); + let unit = victim(&mut app); + + app.world_mut().write_message(EmoteAnim { + entity: unit, + anim_id: 121, + seq: 1, + }); + app.update(); + + app.world_mut() + .write_message(crate::creature_anim::BaseAnimRecompute { + entity: unit, + anim_id: 121, + }); + app.update(); + assert!( + matches!( + app.world().entity(unit).get::().unwrap().mode, + Mode::Swing { id: 121, .. } + ), + "`je 0x60f3ca` — already playing it, so the leg does nothing" + ); + } +} diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 62cdc95a..06a634ca 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -22,7 +22,9 @@ use crate::items::Items; use crate::net::{NetCommands, NetEntity, ObjectStore}; use benilla_assets::{LockRecover, WorldAssets}; -use super::{CastEvent, CastEventKind, CastHold, EmoteAnim, SpellGoTargets, WoundAnim}; +use super::{ + BaseAnimRecompute, CastEvent, CastEventKind, CastHold, EmoteAnim, SpellGoTargets, WoundAnim, +}; /// The client's literal missile-model fallback when the visual chain resolves to nothing /// (`0x860c9c` — a checkerboard cube, shipped in the real MPQs; faithful, not a joke). @@ -531,11 +533,12 @@ type KitSpawnWriters<'w> = ( MessageWriter<'w, ChainProcPlay>, MessageWriter<'w, SpellKitShake>, MessageWriter<'w, crate::weapon_trail::TrailArm>, + MessageWriter<'w, BaseAnimRecompute>, ); /// The writer set one discrete kit play fans out to — bundled so [`play_kit`] threads through the /// router's arms as one argument (each writer keeps its own system-param lifetime). -struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7> { +struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7, 'w8> { oneshots: &'a mut MessageWriter<'w1, EmoteAnim>, wounds: &'a mut MessageWriter<'w2, WoundAnim>, sounds: &'a mut MessageWriter<'w3, SpellKitSound>, @@ -547,6 +550,9 @@ struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'w7> { /// The kit's weapon-trail ARM, if its `CharProc` slots name one — the dispatcher's type-8 /// case (2076). A latch, not a play: nothing is drawn until the unit's next animation. trails: &'a mut MessageWriter<'w7, crate::weapon_trail::TrailArm>, + /// The **stage-2** base recompute — what a state kit's anim id spends itself on instead of + /// playing ([`BaseAnimRecompute`], decision 2085). + recomputes: &'a mut MessageWriter<'w8, BaseAnimRecompute>, } /// One kit played as a **discrete event** on a unit — the client's `PlaySpellVisualKit` @@ -571,13 +577,21 @@ struct KitPlay { sound: bool, /// The stage this play is, for the instances' animation lifecycle ([`FxStage`]). stage: FxStage, - /// Whether the kit tail's anim branch may lay a wound for an `AnimID ∈ [8,10]` — the - /// client's `0x60f383`: a **stage-2** (state) play runs the base recompute only and never - /// reaches the `[8,10]` test, so a state kit naming a wound anim wounds nobody (wow-re - /// `charproc-rate-override-wound-gate.md` §7 (A), decision 2063). The other exclusion there — - /// stage 4 with a zero cast time — never reaches this branch in benilla: a precast kit's anim - /// becomes the [`CastHold`], not a `play_kit` call. - wound: bool, + /// Is this the **stage-2** (state-kit) play? It is the client's `0x60f383 + /// cmp [ebx+0x10],2`, and it forks the whole anim branch — which is why it is the stage and + /// not, as it was until decision 2085, a "may this lay a wound" flag. + /// + /// Stage 2 is **foreclosed from the play site**: `0x60f387 jne` diverts it around the sole + /// `0x60f3c5 call 0x5fe2f0` that ever hands a kit's `AnimID` to the animation primitive, and + /// its leg leaves the block with `jmp 0x60f3ca` (VERIFIED, wow-re + /// `state-kit-anim-and-stun-pose.md` §1). So a state kit neither plays its anim nor reaches + /// the `[8,10]` wound test (the half decision 2063 already had): the id is only ever the + /// right-hand side of a comparison against what the unit is already playing, and a mismatch + /// spends itself on a base recompute ([`BaseAnimRecompute`]). + /// + /// The other exclusion at `0x60f3a0` — stage 4 with a zero cast time — never reaches this + /// branch in benilla: a precast kit's anim becomes the [`CastHold`], not a `play_kit` call. + stage_2: bool, } impl KitPlay { @@ -588,7 +602,7 @@ impl KitPlay { effects: true, sound: true, stage: FxStage::OneShot, - wound: true, + stage_2: false, }; } @@ -602,12 +616,13 @@ fn play_kit( out: &mut KitOut, ) { if let Some(anim_id) = kit.anim_id { - if (8..=10).contains(&anim_id) { + if play.stage_2 { + // Stage 2: a COMPARISON, never a play ([`KitPlay::stage_2`]). The driver holds the + // armed id, so it makes the comparison and spends the mismatch on the recompute. + out.recomputes.write(BaseAnimRecompute { entity, anim_id }); + } else if (8..=10).contains(&anim_id) { // `0x60f3b8: push 0; call 0x60ea70` — severity 0, the kit's own id is NOT what plays. - // A state-stage play never gets here in the client ([`KitPlay::wound`]). - if play.wound { - out.wounds.write(WoundAnim { entity }); - } + out.wounds.write(WoundAnim { entity }); } else { out.oneshots.write(EmoteAnim { entity, @@ -722,7 +737,8 @@ fn play_impact( |s: &VisualStages| s.state, KitPlay { effects: false, - wound: false, + stage_2: true, + stage: FxStage::State, ..KitPlay::DISCRETE }, ), @@ -773,12 +789,13 @@ pub(super) fn route_cast_visuals( }; // Disjoint field borrows: the beam writer rides `out` for the whole body while the missile and // burst writers stay free for the GO loop below. - let (missiles, bursts, chains, shakes, trails) = ( + let (missiles, bursts, chains, shakes, trails, recomputes) = ( &mut spawns.0, &mut spawns.1, &mut spawns.2, &mut spawns.3, &mut spawns.4, + &mut spawns.5, ); let mut out = KitOut { oneshots: &mut oneshots, @@ -788,6 +805,7 @@ pub(super) fn route_cast_visuals( chain: chains, shakes, trails, + recomputes, }; // The `holds` query is one command-flush stale: an instant cast's START and GO drain from @@ -1318,6 +1336,7 @@ pub(crate) fn arm_aura_state_fx( mut fx: MessageWriter, mut procs: MessageWriter, mut sounds: MessageWriter, + mut recomputes: MessageWriter, mut armed: Local>>, ) { let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed()) @@ -1369,7 +1388,11 @@ pub(crate) fn arm_aura_state_fx( .char_procs() .filter_map(crate::aura_visual::node_for) .collect(); - if effects.is_empty() && nodes.is_empty() && kit.sound.is_none() { + if effects.is_empty() + && nodes.is_empty() + && kit.sound.is_none() + && kit.anim_id.is_none() + { continue; // a state kit that does nothing we model — nothing to arm or reap } if !effects.is_empty() { @@ -1397,6 +1420,15 @@ pub(crate) fn arm_aura_state_fx( if let Some(kit_sound) = kit.sound { sounds.write(SpellKitSound::Play { entity, kit_sound }); } + // …and the ADD edge's stage-2 leg: the kit's anim id is a COMPARISON against what the + // unit is already playing, spent on a base recompute when they differ and on nothing + // when they agree ([`BaseAnimRecompute`], decision 2085). It is emitted only on this + // edge, which is the reference's own shape: an aura REFRESHED in place rewrites its + // slot with the same id and a non-zero flags nibble, and `0x604d00` fires neither of + // its two arms for that — no re-spawn, no re-arm, no recompute. + if let Some(anim_id) = kit.anim_id { + recomputes.write(BaseAnimRecompute { entity, anim_id }); + } next.push(spell_id); } *prev = next; @@ -1729,6 +1761,7 @@ pub(super) fn replay_morph_kit( mut chain: MessageWriter, mut shakes: MessageWriter, mut trails: MessageWriter, + mut recomputes: MessageWriter, ) { for swap in swaps.read() { // Consume-and-clear even when the kit resolves to nothing — the reference clears @@ -1751,6 +1784,7 @@ pub(super) fn replay_morph_kit( chain: &mut chain, shakes: &mut shakes, trails: &mut trails, + recomputes: &mut recomputes, }; play_kit( swap.entity, diff --git a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs index 9c1cff07..67028125 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -45,6 +45,7 @@ fn app() -> App { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -207,6 +208,7 @@ fn precast_kit_sound_rings_once_at_start() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -493,7 +495,8 @@ fn aura_state_kit_arms_persistent_and_reaps_on_aura_end() { app.add_message::(); app.add_message::() .add_message::() - .add_message::(); + .add_message::() + .add_message::(); app.init_resource::(); app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables_with_paths( HashMap::from([( @@ -634,6 +637,7 @@ fn a_harmful_go_wounds_each_hit_once_and_a_missile_arrival_always() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -741,6 +745,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -854,6 +859,7 @@ fn a_targetless_dest_go_spawns_a_ground_missile_whose_arrival_sounds_at_the_poin .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -1343,6 +1349,7 @@ fn real_shooter(weapon: &RealRanged) -> Option<(App, Entity)> { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() @@ -1535,3 +1542,187 @@ fn a_shooter_with_no_ranged_weapon_resolves_no_clip_at_all() { "and no pull to hold" ); } + +/// **A state kit's animation id is a comparison, never a play** (decision 2085; VERIFIED wow-re +/// `state-kit-anim-and-stun-pose.md` §1): `0x60edf0`'s tail has exactly one site that hands a +/// kit's `AnimID` to the animation primitive (`0x60f3c5 call 0x5fe2f0`) and `0x60f387 jne` +/// diverts stage 2 around it. Both `SpellVisual` field-4 consumers hardcode stage 2 — the aura +/// watcher and this one, the impact hand-off `0x61dced` — so the state kit's id is only ever the +/// right-hand side of `0x60f390`'s compare, spent on a base recompute. +/// +/// The subject is the real Silithus chain, because it is also the case that names the mechanism: +/// a Dredge Striker's **Charge** (22911 → visual 3783) plays `Knockdown`(121) from impact kit 348 +/// and then **cuts its own Knockdown** with state kit 349's `Stun`(14), 121 ≠ 14 forcing the +/// recompute. Before this, benilla played the 14 as a second one-shot — a `Stun` pose the +/// reference never shows on anything. +#[test] +fn a_state_kits_anim_is_a_recompute_and_never_a_second_play() { + const CHARGE: u32 = 22911; + const CHARGE_VISUAL: u32 = 3783; + const IMPACT_KIT: u32 = 348; + const STATE_KIT: u32 = 349; + const KNOCKDOWN: u16 = 121; + const STUN: u16 = 14; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::(); + app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( + HashMap::from([( + CHARGE_VISUAL, + VisualStages { + impact: IMPACT_KIT, + state: STATE_KIT, + ..Default::default() + }, + )]), + HashMap::from([ + ( + IMPACT_KIT, + VisualKit { + anim_id: Some(KNOCKDOWN), + ..Default::default() + }, + ), + ( + STATE_KIT, + VisualKit { + anim_id: Some(STUN), + ..Default::default() + }, + ), + ]), + ))); + app.insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(HashMap::from([( + CHARGE, + SpellDisplay { + visual: CHARGE_VISUAL, + ..Default::default() + }, + )])), + ..crate::ui_action::Spells::empty_for_tests() + }); + app.add_systems(Update, route_cast_visuals); + + let caster = app.world_mut().spawn_empty().id(); + let victim = app.world_mut().spawn_empty().id(); + app.world_mut() + .write_message(cast_event(caster, CHARGE, CastEventKind::Go)); + app.world_mut().write_message(SpellGoTargets { + caster, + spell_id: CHARGE, + hits: vec![victim], + misses: Vec::new(), + dest: None, + ammo_display_id: None, + seq: 1, + }); + app.update(); + + let plays: Vec<(Entity, u16)> = app + .world_mut() + .resource_mut::>() + .drain() + .map(|e| (e.entity, e.anim_id)) + .collect(); + assert_eq!( + plays, + vec![(victim, KNOCKDOWN)], + "the impact kit plays; the state kit must not add a second one-shot" + ); + let recomputes: Vec<(Entity, u16)> = app + .world_mut() + .resource_mut::>() + .drain() + .map(|r| (r.entity, r.anim_id)) + .collect(); + assert_eq!( + recomputes, + vec![(victim, STUN)], + "the state kit's id is spent on the stage-2 recompute instead" + ); +} + +/// A state kit whose **whole visual is its animation id** still arms (decision 2085). The aura +/// watcher's entry test asks "does this kit do anything we model?", and the anim was missing from +/// it — the B114 shape one level over, where an effects-only test dropped Stealth's proc-only kit. +/// 15 of the shipped state kits are anim-only (`benilla-extract kitanim`), among them kit 586's +/// `Stun`(14) — Sneezing Fit, Smoke Bomb and kin. +#[test] +fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() { + use benilla_protocol::messages::ObjectFields; + + const SPELL: u32 = 6902; // Sneezing Fit + const VISUAL: u32 = 517; + const STATE_KIT: u32 = 586; // anim 14, no effect slot, no sound, no CharProc + const STUN: u16 = 14; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_message::() + .add_message::() + .add_message::() + .add_message::(); + app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( + HashMap::from([( + VISUAL, + VisualStages { + state: STATE_KIT, + ..Default::default() + }, + )]), + HashMap::from([( + STATE_KIT, + VisualKit { + anim_id: Some(STUN), + ..Default::default() + }, + )]), + ))); + app.insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(HashMap::from([( + SPELL, + SpellDisplay { + visual: VISUAL, + ..Default::default() + }, + )])), + ..crate::ui_action::Spells::empty_for_tests() + }); + app.add_systems(Update, super::arm_aura_state_fx); + + let unit = app + .world_mut() + .spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[ + (47, SPELL), + (95, 0x0E), + ]))) + .id(); + app.update(); + + let recomputes: Vec<(Entity, u16)> = app + .world_mut() + .resource_mut::>() + .drain() + .map(|r| (r.entity, r.anim_id)) + .collect(); + assert_eq!( + recomputes, + vec![(unit, STUN)], + "an anim-only state kit is not nothing — its ADD edge owes a recompute" + ); +} diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 366b4917..4f18e144 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -221,6 +221,16 @@ pub(crate) const REGISTERED: &[Registered] = &[ "1667: that host has not resolved since 2019, so shipping it makes every first launch a \ DNS failure; benilla dials the machine it is running on", ), + // The implicit AFK clear (2088) — a REAL 1.12 CVar, byte-read off its own registration + // (`0x5e24d4 push 0x82e748`, handle taken from the store AFTER the call at `0x5e24ef` into + // `[0xc4d68c]`, whose single reader `0x5eb84b` tests `[cvar+0x28]` for non-zero; wow-re + // `ui/scratch/afk-dnd-command-law.md` §10). Registered default `"1"`. + // + // It gates FIVE implicit clears, not one: any chat send whose type is not `0x14` (which is why + // `/dnd` clears AFK before marking), plus Jump, forward/back, strafe and turn + // (`0x513d36`/`0x514e23`/`0x514f0b`/`0x514fca`). With the CVar off the clear is a **total** + // no-op — no echo, no mirror write, no packet. + same("autoClearAFK", "1"), same("MasterVolume", "1"), same("SoundVolume", "1"), same("MusicVolume", "0.4"), diff --git a/crates/benilla-app/src/entities/dest_fx.rs b/crates/benilla-app/src/entities/dest_fx.rs index c4618067..0236d66b 100644 --- a/crates/benilla-app/src/entities/dest_fx.rs +++ b/crates/benilla-app/src/entities/dest_fx.rs @@ -40,11 +40,9 @@ use bevy::prelude::*; use crate::creature_anim::{SpellKitSound, SpellVisuals}; use crate::net::{NetEntity, ObjectStore}; -use benilla_assets::m2_url; use benilla_protocol::EntityKind; -use super::spell_fx::{attach_effect_visuals, SpellFx}; -use super::{DisplayModel, ModelHandle}; +use super::spell_fx::{attach_effect_visuals, ensure_model, SpellFx}; /// The client's hardcoded shard-model table (`0x870e24`, 7 entries — wow-re /// `dynobject-visual-machine.md` Q-A1). `CharParamZero`'s decoded small int indexes it. @@ -274,18 +272,20 @@ pub(super) fn attach_ground_fx_models( mut commands: Commands, time: Res