diff --git a/crates/benilla-app/src/creature_anim.rs b/crates/benilla-app/src/creature_anim.rs index 04617da9..39672a46 100644 --- a/crates/benilla-app/src/creature_anim.rs +++ b/crates/benilla-app/src/creature_anim.rs @@ -781,6 +781,9 @@ impl Plugin for CreatureAnimPlugin { .add_message::() // The pending-morph latch, the reference's per-unit `[+0xd54]`. .init_resource::() + // SuperWoW's `LootSparkle` switch, seeded by the registry's boot-time fire. + .init_resource::() + .add_observer(spell_visual::on_loot_sparkle_cvar) .add_systems( Startup, ( diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index c977ee9b..b4da4964 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -1082,10 +1082,87 @@ pub(crate) fn arm_aura_state_fx( armed.retain(|e, _| units.contains(*e)); } -/// Arm and reap the loot sparkle on a dead lootable unit and on a corpse object with -/// `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 (`0x5d6de0`): the `"HARDCODED Loot Art"` row at `0x13`. -/// Edge-driven (`0x600440`) with no viewer or distance logic, since the server strips the flag per -/// viewer; the falling edge reaps with no fade (`0x600680`). +/// SuperWoW's `LootSparkle` (`docs/ADDON_COMPAT.md`): the sparkle on a lootable **object**, not +/// just a corpse. The mod's own spelling; registered in `cvars.rs`. +pub(crate) const CVAR_LOOT_SPARKLE: &str = "LootSparkle"; + +/// [`CVAR_LOOT_SPARKLE`]'s applied value, seeded by the boot-time `CvarChanged` observer as +/// `vplates.rs`'s `VPlateMode` is. +#[derive(Resource, Default)] +pub(crate) struct LootSparkleMode(pub(crate) bool); + +/// Apply a change of the switch. +pub(crate) fn on_loot_sparkle_cvar( + ev: On, + mut mode: ResMut, +) { + if ev.is(CVAR_LOOT_SPARKLE) { + mode.0 = ev.flag(); + } +} + +/// `GAMEOBJECT_TYPE_CHEST`, which is also every herb and every mining node +/// (`capture/probe_chest.rs`) — the "lootable treasure" of the mod's own description. +const GO_TYPE_CHEST: i32 = 3; + +/// Whether this object carries the loot sparkle right now. +/// +/// A unit: dead and `UNIT_DYNFLAG_LOOTABLE`. A corpse: `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0, the +/// insignia — bit 0 rising is what calls `0x5d6e30` in the reference, the same row at the same +/// tag. A corpse has no UNIT block, so the unit arm would read its bytes as a live unit's. +/// +/// A GameObject is SuperWoW's `LootSparkle`, and it takes **two** tests, because the mod's own +/// phrase is "lootable treasure" and neither test alone is that: +/// +/// - `GO_DYNFLAG_LO_ACTIVATE`, the server's per-viewer bit, documented on its own field reader as +/// "the sparkle". But vmangos raises it through `GameObject::ActivateToQuest`, so on a chest it +/// means *quest* loot: an ordinary chest, herb or vein need never carry it, and keying on it +/// alone could light up nothing but quest objects. +/// - `GAMEOBJECT_TYPE_CHEST`, which is exactly chests, herbs and mining nodes — treasure by +/// construction, and a type the client reads for itself without the server's help. +/// +/// `GO_FLAG_IN_USE` and `NO_INTERACT` exclude the second arm, so an emptied or disabled chest +/// does not keep sparkling; the activate bit is trusted on its own, since the server raises it +/// only for a viewer who can act on the object. +/// +/// Both arms are a judgement about what the mod does, not a trace of it — no source and no +/// published default — so they are written to be read and corrected: if only quest objects light +/// up, the first arm is doing all the work; if doors and signs do, the second is too wide. +fn shows_loot_sparkle( + kind: benilla_protocol::EntityKind, + store: &ObjectStore, + sparkle_objects: bool, +) -> bool { + use benilla_protocol::EntityKind; + match kind { + EntityKind::Corpse => store.0.corpse_lootable(), + EntityKind::GameObject => { + use crate::target::cursor_mode::{GO_DYNFLAG_ACTIVATE, GO_FLAG_IN_USE_OR_NO_INTERACT}; + if !sparkle_objects { + return false; // the switch off is stock 1.12: an object never sparkles + } + store.0.gameobject_dynamic_flags() & GO_DYNFLAG_ACTIVATE != 0 + || (store.0.gameobject_type_id() == GO_TYPE_CHEST + && store.0.gameobject_flags() & GO_FLAG_IN_USE_OR_NO_INTERACT == 0) + } + _ => store.0.unit_is_dead() && store.0.unit_lootable(), + } +} + +/// Arm and reap the loot sparkle on a dead lootable unit, on a corpse object with +/// `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0 (`0x5d6de0`), and — behind SuperWoW's `LootSparkle` — on a +/// **GameObject** the server says this viewer can activate: the `"HARDCODED Loot Art"` row at +/// `0x13` in each case. Edge-driven (`0x600440`) with no viewer or distance logic, since the +/// server strips the flag per viewer; the falling edge reaps with no fade (`0x600680`). +/// +/// The GameObject arm is the deviation, and it is the mod's whole feature: 1.12 sparkles a +/// lootable corpse and nothing else, where a chest, a herb, a vein or a quest object is found by +/// eye. Sparkling them arrived in 2.3.0 ("interactable game objects sparkle and display their +/// names") and SuperWoW backports it as `LootSparkle`, "the sparkling effect on lootable +/// treasure". The flag it keys on is the server's own per-viewer `GO_DYNFLAG_LO_ACTIVATE` +/// (`GameObject::ActivateToQuest`, documented on the field reader as "the sparkle"), so what +/// lights up is exactly what this character can currently use — not every door and sign in the +/// zone. pub(super) fn arm_loot_fx( // Re-read on an edge of any of the four fields and on first sight (armed at build, `0x5d6e30`). units: Query<(Entity, &ObjectStore, &crate::net::NetEntity)>, @@ -1095,6 +1172,7 @@ pub(super) fn arm_loot_fx( mut fx: MessageWriter, mut armed: Local, mut tutorials: Option>, + sparkle: Res, ) { let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed()); let Some((effect, path)) = visuals.as_ref().and_then(|v| v.0.loot_art_effect()) else { @@ -1104,8 +1182,8 @@ pub(super) fn arm_loot_fx( units.iter().collect::>() } else { use benilla_protocol::field::{ - FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_HEALTH, - FIELD_UNIT_MAXHEALTH, + FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_DYN_FLAGS, FIELD_UNIT_DYNAMIC_FLAGS, + FIELD_UNIT_HEALTH, FIELD_UNIT_MAXHEALTH, }; let mut due: EntityHashSet = arrived.iter().collect(); due.extend( @@ -1117,18 +1195,17 @@ pub(super) fn arm_loot_fx( || e.unit_field(FIELD_UNIT_MAXHEALTH) || (e.kind == benilla_protocol::messages::ObjectType::Corpse && e.index == FIELD_CORPSE_DYNAMIC_FLAGS) + // The per-viewer activate bit: the server sets and strips it as the + // object becomes usable, so the edge is the whole trigger. + || (e.kind == benilla_protocol::messages::ObjectType::GameObject + && e.index == FIELD_GAMEOBJECT_DYN_FLAGS) }) .map(|e| e.entity), ); due.into_iter().filter_map(|e| units.get(e).ok()).collect() }; for (entity, store, net) in scan { - // A corpse object's sparkle: bit 0 rising calls `0x5d6e30`, the same row at the same tag. - let lootable = if net.kind == benilla_protocol::EntityKind::Corpse { - store.0.corpse_lootable() - } else { - store.0.unit_is_dead() && store.0.unit_lootable() - }; + let lootable = shows_loot_sparkle(net.kind, store, sparkle.0); if lootable && armed.insert(entity) { // The Looting tutorial on the rise (`0x60049b`). if let Some(t) = tutorials.as_mut() { @@ -1144,10 +1221,14 @@ pub(super) fn arm_loot_fx( // A unit's loot art loops its first sequence (`0x600640`). A corpse's is a bare // model with no sequence armed (`0x5d6e30`); this plumbing has no such instance, so // it runs `OneShot` persistent. - stage: if net.kind == benilla_protocol::EntityKind::Corpse { - FxStage::OneShot - } else { + stage: if net.kind == benilla_protocol::EntityKind::Unit + || net.kind == benilla_protocol::EntityKind::Player + { FxStage::Relive + } else { + // A corpse's is a bare model with no sequence armed, and an object has no + // sequence of ours to loop either. + FxStage::OneShot }, effects: vec![FxSlot { tag: HARDCODED_FX_ATTACH, 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 3699c56d..964116fc 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual/tests.rs @@ -11,8 +11,8 @@ use super::super::{ CastEvent, CastEventKind, CastHold, EmoteAnim, RangedHold, SheathRequest, WoundAnim, }; use super::{ - route_cast_visuals, KitPush, MissileSpawn, SpellKitFx, SpellKitShake, SpellKitSound, - SpellVisuals, + route_cast_visuals, shows_loot_sparkle, KitPush, MissileSpawn, SpellKitFx, SpellKitShake, + SpellKitSound, SpellVisuals, }; use crate::creature_anim::SpellGoTargets; @@ -1652,3 +1652,70 @@ fn an_anim_only_state_kit_still_arms_on_the_aura_add_edge() { "an anim-only state kit is not nothing — its ADD edge owes a recompute" ); } + +/// SuperWoW's `LootSparkle`: 1.12 sparkles a lootable corpse and nothing else, so the GameObject +/// arm is a deviation that has to be switchable, and the switch off has to answer exactly 1.12. +/// +/// The flag is the server's own per-viewer `GO_DYNFLAG_LO_ACTIVATE`, so this tests the three kinds +/// against the three fields they actually read — and that a corpse never falls into the unit arm, +/// which would read its `CORPSE_*` bytes as a live unit's. +#[test] +fn the_loot_sparkle_covers_a_corpse_always_and_an_object_only_behind_the_switch() { + use crate::net::ObjectStore; + use benilla_protocol::EntityKind; + + /// `UNIT_FIELD_HEALTH` / `MAXHEALTH` / `DYNAMIC_FLAGS`, and `UNIT_DYNFLAG_LOOTABLE`. + const F_HEALTH: u16 = 22; + const F_MAXHEALTH: u16 = 28; + const F_UNIT_DYNFLAGS: u16 = 143; // UNIT_DYNAMIC_FLAGS + const F_CORPSE_DYNFLAGS: u16 = 36; + const F_GO_DYNFLAGS: u16 = 19; + const F_GO_FLAGS: u16 = 9; + const F_GO_TYPE_ID: u16 = 21; + /// `GAMEOBJECT_TYPE_CHEST`, which is also every herb and vein. + const CHEST: u32 = 3; + /// A type that is not treasure: `GAMEOBJECT_TYPE_DOOR`. + const DOOR: u32 = 0; + + let store = + |pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs)); + + // Arm one: the server says this viewer can activate it. Enough on its own, whatever the type + // — this is the quest-object case, and it is the bit named "the sparkle". + let quest_object = store(&[(F_GO_DYNFLAGS, 0x1), (F_GO_TYPE_ID, DOOR)]); + assert!(shows_loot_sparkle( + EntityKind::GameObject, + &quest_object, + true + )); + assert!( + !shows_loot_sparkle(EntityKind::GameObject, &quest_object, false), + "switch off is stock 1.12: an object never sparkles" + ); + + // Arm two: treasure by type, which is what an ordinary chest, herb or vein is. vmangos raises + // the activate bit through `ActivateToQuest`, so none of those need ever carry it. + let vein = store(&[(F_GO_TYPE_ID, CHEST)]); + assert!(shows_loot_sparkle(EntityKind::GameObject, &vein, true)); + assert!(!shows_loot_sparkle(EntityKind::GameObject, &vein, false)); + + // …but not one already in use or marked no-interact: an emptied chest stops sparkling. + let spent = store(&[(F_GO_TYPE_ID, CHEST), (F_GO_FLAGS, 0x1)]); + assert!(!shows_loot_sparkle(EntityKind::GameObject, &spent, true)); + + // And a plain door, with neither arm, stays dark — the whole-zone-sparkles failure. + let door = store(&[(F_GO_TYPE_ID, DOOR)]); + assert!(!shows_loot_sparkle(EntityKind::GameObject, &door, true)); + + // A corpse's insignia bit, which the switch has nothing to do with. + let insignia = store(&[(F_CORPSE_DYNFLAGS, 0x1)]); + assert!(shows_loot_sparkle(EntityKind::Corpse, &insignia, false)); + let empty_corpse = store(&[(F_CORPSE_DYNFLAGS, 0)]); + assert!(!shows_loot_sparkle(EntityKind::Corpse, &empty_corpse, true)); + + // A unit: dead *and* lootable, and alive never. + let dead_lootable = store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100), (F_UNIT_DYNFLAGS, 0x1)]); + assert!(shows_loot_sparkle(EntityKind::Unit, &dead_lootable, false)); + let alive = store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100), (F_UNIT_DYNFLAGS, 0x1)]); + assert!(!shows_loot_sparkle(EntityKind::Unit, &alive, true)); +} diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 9ac29096..ec2c3cfe 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -482,6 +482,17 @@ pub(crate) const REGISTERED: &[Registered] = &[ // Registered at `0x603280`: `ChatBubbles` "1", `ChatBubblesParty` "0". same("ChatBubbles", "1"), same("ChatBubblesParty", "0"), + // SuperWoW's `LootSparkle` (`docs/ADDON_COMPAT.md`): the sparkle on a lootable **object**, + // which 1.12 gives only to a corpse. Defaulted ON rather than to 1.12's behaviour, because + // the mod publishes no default and this install's own play shows the sparkle present with + // SuperWoW loaded — "as if SuperWoW's default config loaded" is what always-on compat means + // here. `SetCVar("LootSparkle", "0")` is stock 1.12. + ours( + "LootSparkle", + "1", + "SuperWoW addon-compat, DEVIATION: 1.12 sparkles only a lootable corpse; see \ + crate::creature_anim::spell_visual::LootSparkleMode", + ), // SuperWoW's `ChatBubbleRange` (`docs/ADDON_COMPAT.md`): the reference's own hardcoded 20 yd // (`chat_bubble.rs`'s `MAX_DIST_SQ`) made tunable; no shipped default is published, so this // keeps today's exact value rather than guess at a different one within the mod's documented diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 57167d01..149447ba 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -214,12 +214,13 @@ fn go_interact_range_sq(type_id: i32) -> f32 { } } /// `GameObjectFlags` IN_USE (`0x1`) and NO_INTERACT (`0x10`), which `0x5f2f80` rejects together. -const GO_FLAG_IN_USE_OR_NO_INTERACT: u32 = 0x11; +pub(crate) const GO_FLAG_IN_USE_OR_NO_INTERACT: u32 = 0x11; /// `GO_FLAG_INTERACT_COND`: usable only while the per-player activate bit is set, the quest gate. const GO_FLAG_INTERACT_COND: u32 = 0x4; /// `GO_DYNFLAG_LO_ACTIVATE` in `GAMEOBJECT_DYN_FLAGS`, set per player by the server -/// (`GameObject::ActivateToQuest`); read only under `INTERACT_COND`. -const GO_DYNFLAG_ACTIVATE: u32 = 0x1; +/// (`GameObject::ActivateToQuest`); read only under `INTERACT_COND` here, and by SuperWoW's +/// `LootSparkle` as the whole test for whether an object sparkles. +pub(crate) const GO_DYNFLAG_ACTIVATE: u32 = 0x1; /// The types whose highlightable slot `+0x14` is constant false, so `0x5f2f80` never runs. fn strategy_never_highlightable(type_id: i32) -> bool { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs index a9d5a74c..e8bdc7d1 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -18,9 +18,10 @@ const FIELD_GAMEOBJECT_POS_X: u16 = 15; const FIELD_GAMEOBJECT_POS_Y: u16 = 16; const FIELD_GAMEOBJECT_POS_Z: u16 = 17; const FIELD_GAMEOBJECT_FACING: u16 = 18; -// The client reads it at GameObject block byte 0x34. vmangos `UpdateFields_1_5_1.h` adds a -// `GAMEOBJECT_TIMESTAMP` that would make it 20, but that is not the 5875 layout. -const FIELD_GAMEOBJECT_DYN_FLAGS: u16 = 19; +/// `GAMEOBJECT_DYN_FLAGS`. The client reads it at GameObject block byte 0x34. vmangos +/// `UpdateFields_1_5_1.h` adds a `GAMEOBJECT_TIMESTAMP` that would make it 20, but that is not +/// the 5875 layout. `pub` for the field watch: SuperWoW's `LootSparkle` is an edge on it. +pub const FIELD_GAMEOBJECT_DYN_FLAGS: u16 = 19; // The client reads it at `[go+0x110]+0x38`, between DYN_FLAGS and TYPE_ID. const FIELD_GAMEOBJECT_FACTION: u16 = 20; const FIELD_GAMEOBJECT_TYPE_ID: u16 = 21; @@ -184,12 +185,13 @@ pub mod field { /// The bit a watcher tests on a raw `UNIT_DYNAMIC_FLAGS` edge (feign death). pub use super::unit::UNIT_DYNFLAG_DEAD; pub use super::{ - FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_STATE, FIELD_PLAYER_FIELD_COINAGE, - FIELD_PLAYER_FLAGS, FIELD_PLAYER_INV_SLOT_HEAD, FIELD_PLAYER_QUEST_LOG_1_1, - FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_AURA, FIELD_UNIT_AURAAPPLICATIONS, - FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BYTES_1, FIELD_UNIT_CHANNEL_SPELL, - FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, - FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH, FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS, + FIELD_CORPSE_DYNAMIC_FLAGS, FIELD_GAMEOBJECT_DYN_FLAGS, FIELD_GAMEOBJECT_STATE, + FIELD_PLAYER_FIELD_COINAGE, FIELD_PLAYER_FLAGS, FIELD_PLAYER_INV_SLOT_HEAD, + FIELD_PLAYER_QUEST_LOG_1_1, FIELD_PLAYER_SKILL_INFO_1_1, FIELD_UNIT_AURA, + FIELD_UNIT_AURAAPPLICATIONS, FIELD_UNIT_AURAFLAGS, FIELD_UNIT_BYTES_1, + FIELD_UNIT_CHANNEL_SPELL, FIELD_UNIT_DYNAMIC_FLAGS, FIELD_UNIT_FACTIONTEMPLATE, + FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH, + FIELD_UNIT_MOUNTDISPLAYID, FIELD_UNIT_NPC_FLAGS, }; } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index b0a338bf..44cf5cb2 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -41,7 +41,8 @@ nothing behind it. | `ChatBubbleRange` | done | `chat_bubble.rs`'s `BubbleConfig::range_sq`, replacing the reference's hardcoded 20 yd. Default `20` (today's exact value) — no shipped default published for this one either, within the mod's documented 10-200 yd range | | `NameplateMotion`, `NameplateVerticalSpacing` | todo | needs locating whatever (if anything) already staggers overlapping nameplates, not investigated | | `FoV` | todo, held back | the wiki's only confirmed default (`"1.57"`) doesn't obviously match benilla's existing hardcoded `benilla-world/src/view.rs`'s `CAM_FOVY = FRAC_PI_4` (0.785 rad, 45°) — 1.57 rad is 90°, double. Without confirming whether SuperWoW's `FoV` cvar and `CAM_FOVY` share the same angle convention (vertical half vs. full, radians interpretation) and whether 1.12's true default really is 45°, defaulting to the wiki's literal `1.57` risks a jarring, wrong-looking lens change rather than exposing the existing value as a tunable knob. Left alone rather than guess | -| Healing text, uncap sounds, selection-circle style, loot sparkle, outline/shadow text, force-show-auras, XP text | todo | not investigated | +| `LootSparkle` | done, CVar `LootSparkle`, default `1` | `creature_anim/spell_visual.rs`'s `arm_loot_fx` grew a **GameObject** arm beside its unit and corpse ones, hanging the same `"HARDCODED Loot Art"` row (`0x13`) at the same tag. 1.12 sparkles a lootable corpse and nothing else — a chest, a herb, a vein or a quest object is found by eye — and sparkling those arrived in **2.3.0** ("interactable game objects sparkle and display their names"), which is what the mod backports: upstream describes it as "the sparkling effect on lootable treasure". The test is the server's own per-viewer `GO_DYNFLAG_LO_ACTIVATE` in `GAMEOBJECT_DYN_FLAGS` (`GameObject::ActivateToQuest`), already parsed and already documented on its field reader as "the sparkle", so what lights up is exactly what this character can currently use rather than every door and sign in the zone — and the rising and falling edges of that bit are the whole trigger, which is also how the corpse arm works. Defaulted **on**: upstream publishes no default, and the sparkle is present on this install with SuperWoW loaded, which is what "as if its default config loaded" means here; `SetCVar("LootSparkle", "0")` is stock 1.12. `FxStage` is `OneShot` for an object as for a corpse — neither has a sequence of ours to loop, where a unit's loot art loops its first (`0x600640`) | +| Healing text, uncap sounds, selection-circle style, outline/shadow text, force-show-auras, XP text | todo | not investigated | | Macro limit 255→510 | done | `script/macros.rs`'s `MAX_MACRO_BODY`; `ui_script/manifest.rs`'s `install_macro_letters_patch` re-patches the stock FrameXML's `SetMaxLetters`/char-count string at boot, since both come from the player's own install | | Targeting-circle spells (Blizzard, Flamestrike) no longer refuse movement | done | `cast_target::is_ground_targeted` (`spell/cast_target.rs`) exempts them in `validator::cast_moving_refusal` (`spell/validator.rs`); the real cast-time interrupt-on-move rule (`inflight.rs`) is untouched | | Ranged abilities (Aimed Shot) firing `SPELLCAST_START` | done | already satisfied — `spell/net.rs`'s `spell_start` already opens the bar on any real `SpellCastTimes.dbc` duration rather than the reference's narrower ranged-spell suppression (see the `has_cast_time` comment there); confirmed by the existing test `a_timed_ranged_cast_opens_the_shot_bar_but_an_instant_shot_does_not` |