diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 590ccd5e..4f17c2ee 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1289,44 +1289,42 @@ pub(crate) const REGISTERED: &[Registered] = &[ // GUID-keyed unit events, so an addon can cut the stream to the units it watches. // `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all six at setup. // - // STORED ONLY, and the reason is one step further back than the other `NP_Enable*` rows: - // benilla fires no GUID-keyed unit event at all — no `UNIT_HEALTH_GUID`, `UNIT_MANA_GUID`, - // `UNIT_RAGE_GUID`, `UNIT_ENERGY_GUID` or `UNIT_COMBAT_GUID` — so there is no stream for a - // filter to narrow. Registering them is what stops the engine's unknown-CVar warning and lets - // the addon's writes persist; it is not a claim that the events arrive. Defaults are - // upstream's own documented `0`. + // **All six are LIVE**, which reverses the reason written here for one round: it said there + // was no stream for a filter to narrow, which was true until `ui_np_events.rs` built one. + // The thirteen `UNIT_*_GUID` names and `UNIT_COMBAT_GUID` now fire off the descriptor-edge + // stream `net::FieldChanged` already carries, so the switches select rather than pretend. + // Defaults stay upstream's own documented `0`. ours( "NP_EnableUnitEventsGuid", "0", - "nampower v2 addon-compat, STORED ONLY: benilla fires no GUID-keyed unit event, so there \ - is no stream for this switch to open", + "nampower v2 addon-compat, LIVE: the gate on the thirteen `UNIT_*_GUID` names and \ + `UNIT_COMBAT_GUID` — see crate::ui_np_events::NpEventGates", ), ours( "NP_EnableUnitEventsGuidFiltering", "0", - "nampower v2 addon-compat, STORED ONLY: the filter half of `NP_EnableUnitEventsGuid`, and \ - there is nothing to filter", + "nampower v2 addon-compat, LIVE: whether the four token switches below narrow the family \ + at all; off, every unit passes — see crate::ui_np_events::UnitContext::passes", ), ours( "NP_EnableUnitEventsMouseover", "0", - "nampower v2 addon-compat, STORED ONLY: which tokens fire the GUID-keyed unit events, \ - none of which this client raises", + "nampower v2 addon-compat, LIVE: lets the mouseover through the filter above", ), ours( "NP_EnableUnitEventsParty", "0", - "nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`", + "nampower v2 addon-compat, LIVE: lets a party member through the filter", ), ours( "NP_EnableUnitEventsPet", "0", - "nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`", + "nampower v2 addon-compat, LIVE: lets the player's pet through the filter", ), ours( "NP_EnableUnitEventsRaid", "0", - "nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`", + "nampower v2 addon-compat, LIVE: lets a raid member through the filter", ), ours( "NP_EnableSpellGoEvents", @@ -1334,17 +1332,31 @@ pub(crate) const REGISTERED: &[Registered] = &[ "nampower v2 addon-compat, LIVE: the gate on `SPELL_GO_SELF`/`_OTHER`, whose two target \ counts come off the same `SMSG_SPELL_GO` hit and miss lists", ), + // Both were STORED ONLY for the reason written at each: no melee swing edge, and nothing + // reading `SMSG_SPELLHEALLOG`. Both reasons were about the *cast* path, and both turned out + // to be answered from the combat-log decode that was already there — the swing is + // `SMSG_ATTACKERSTATEUPDATE`, which `combat_log/` has parsed all along to write its chat + // line. `ui_np_events.rs` is the second consumer of that same decode. ours( "NP_EnableAutoAttackEvents", "0", - "nampower v2 addon-compat, STORED ONLY: benilla fires no `AUTO_ATTACK_SELF`/`_OTHER` — \ - the melee swing raises no cast edge here to hang one on", + "nampower v2 addon-compat, LIVE: the gate on `AUTO_ATTACK_SELF`/`_OTHER`, off \ + `SMSG_ATTACKERSTATEUPDATE` — see crate::ui_np_events::NpEventGates", ), ours( "NP_EnableSpellHealEvents", "0", - "nampower v2 addon-compat, STORED ONLY: benilla fires no `SPELL_HEAL_BY_SELF`/`_OTHER`/\ - `_ON_SELF` — nothing reads `SMSG_SPELLHEALLOG`'s amount", + "nampower v2 addon-compat, LIVE: the gate on `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF`, \ + off `SMSG_SPELLHEALLOG` and a `PERIODIC_HEAL` tick", + ), + // The seventh `NP_Enable*` row, registered a round after the other six: upstream has it and + // nothing installed here writes it, which is why it was missed — the first pass took the + // names off `NampowerSettings`' own writes rather than off the DLL's CVar table. + ours( + "NP_EnableSpellEnergizeEvents", + "0", + "nampower v2 addon-compat, LIVE: the gate on `SPELL_ENERGIZE_BY_SELF`/`_BY_OTHER`/\ + `_ON_SELF`, off `SMSG_SPELLENERGIZELOG` and a `PERIODIC_ENERGIZE` tick", ), // ── SuperWoW-era `ShowVKeyCastbar` ──────────────────────────────────────────────────────── // @@ -4925,48 +4937,10 @@ mod tests { "NP_RetryServerRejectedSpells", "NampowerSettings' behaviour toggles", ), - // nampower v2's event-class switches. Three are deliberately NOT in this list — - // `NP_EnableAuraCastEvents`, `NP_EnableSpellStartEvents` and - // `NP_EnableSpellGoEvents` — because `ui_cast_events.rs` reads each as a real event - // gate and must keep proving so. The two below name event classes benilla emits none - // of (each row in `REGISTERED` says what it would take), so pfUI's libdebuff — which - // writes all five at `PLAYER_LOGIN` — is their only reader. - ( - "NP_EnableAutoAttackEvents", - "pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no AUTO_ATTACK_* here", - ), - ( - "NP_EnableSpellHealEvents", - "pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no SPELL_HEAL_* here", - ), - // The six unit-event switches: `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all - // six at setup. One step further back than the four above — there is no GUID-keyed - // unit event here for a token filter to narrow, so nothing could read them even in - // principle until that family exists. - ( - "NP_EnableUnitEventsGuid", - "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", - ), - ( - "NP_EnableUnitEventsGuidFiltering", - "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", - ), - ( - "NP_EnableUnitEventsMouseover", - "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", - ), - ( - "NP_EnableUnitEventsParty", - "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", - ), - ( - "NP_EnableUnitEventsPet", - "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", - ), - ( - "NP_EnableUnitEventsRaid", - "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", - ), + // **Every `NP_Enable*` row has left this list.** All seven are read by + // `ui_cast_events.rs` or `ui_np_events.rs` as a real event gate and must keep + // proving so — which is the whole point of the orphan test: the day one of them + // stops being read, it belongs back here with a reason, not silently. // SuperWoW-era plate cast bar: pfUI's nameplates module writes 0 at load. benilla's // V-key plates draw no cast bar, so nothing here reads it — see the row's own note. ( diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index d0967ede..7c01fc2e 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -67,6 +67,7 @@ use crate::ui_mail::UiMailPlugin; use crate::ui_merchant::UiMerchantPlugin; use crate::ui_mirror::UiMirrorPlugin; use crate::ui_net::UiNetPlugin; +use crate::ui_np_events::NpEventsPlugin; use crate::ui_party::UiPartyPlugin; use crate::ui_pass::PlayerUiPlugin; use crate::ui_pet::UiPetPlugin; @@ -261,6 +262,7 @@ impl PluginGroup for GamePlugins { .add(UiCastPlugin) .add(UiMirrorPlugin) .add(SuperWowEventsPlugin) + .add(NpEventsPlugin) .add(UiSelfCastPlugin) .add(crate::combat_text::CombatTextPlugin) .add(crate::nameplates::NameplatesPlugin) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 7b1b3174..6f774231 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -177,6 +177,7 @@ mod ui_merchant; mod ui_mirror; mod ui_models; mod ui_net; +mod ui_np_events; mod ui_party; mod ui_pass; mod ui_pet; diff --git a/crates/benilla-app/src/ui_np_events.rs b/crates/benilla-app/src/ui_np_events.rs new file mode 100644 index 00000000..f3303a00 --- /dev/null +++ b/crates/benilla-app/src/ui_np_events.rs @@ -0,0 +1,1659 @@ +//! nampower's combat-log and GUID-keyed unit events: the structured form of everything the +//! reference only ever published as chat text. +//! +//! **What this family is for.** 1.12 reports a hit by writing a sentence into the combat log and +//! firing `CHAT_MSG_SPELL_*` with that sentence; every damage meter of the era is a parser of +//! localized text. nampower hooks the same packet handlers and fires the numbers instead — the +//! caster, the target, the spell id, the amount — so an addon reads fields rather than grammar. +//! Upstream's `EVENTS.md` is the argument spec; the names are taken off the shipped DLL's own +//! registration table (`docs/ADDON_COMPAT.md`, "The event table, accounted for"). +//! +//! **Why it is cheap here.** Every packet this family reports is already decoded: the chat lines +//! in `combat_log/` are built from exactly these structs. This module is a second consumer of +//! that decode, not a second decoder — which is also why its arguments cannot drift from the +//! chat log's, since both read one `SessionEvent`. +//! +//! **Packet handlers queue, one system fires.** The `net_handler`s below push rows into +//! [`NpEventFeed`]; [`fire_np_events`] drains it inside `UnitFeed`, after the unit and aura feeds +//! have pushed this tick's state. That ordering is the contract: an addon woken by +//! `UNIT_DIED` and then asking `UnitHealth` must not be told the unit is alive, and one woken by +//! `SPELL_DAMAGE_EVENT_SELF` must see the health the hit produced. The same rule, and the same +//! reason, as [`crate::ui_cast_events`]'s own drain. +//! +//! **The `_SELF`/`_OTHER` split is by actor**, and the actor is whichever guid the packet puts +//! first — nampower passes a packet's fields in the order the wire has them, which is what makes +//! the argument lists below checkable rather than guessed. The three-way families (`SPELL_HEAL_*`, +//! `SPELL_ENERGIZE_*`) fire `BY_SELF`/`BY_OTHER` off the caster and `ON_SELF` off the target, so a +//! self-heal raises two events; upstream's three names only make sense if it does. + +use benilla_protocol::events::{SessionEvent, SessionEventKind}; +use benilla_ui::script::{guid_token, ScriptValue, UiScript}; +use bevy::prelude::*; + +use crate::net::handlers::NetHandlerApp; +use crate::net::{FieldChanged, SelfGuid}; +use crate::ui_unit::UnitFeed; + +/// nampower's own gate on the auto-attack pair; upstream's documented default is `0`. +pub(crate) const CVAR_AUTO_ATTACK: &str = "NP_EnableAutoAttackEvents"; +/// The gate on the three `SPELL_HEAL_*` names. +pub(crate) const CVAR_HEAL: &str = "NP_EnableSpellHealEvents"; +/// The gate on the three `SPELL_ENERGIZE_*` names. +pub(crate) const CVAR_ENERGIZE: &str = "NP_EnableSpellEnergizeEvents"; +/// The gate on the whole GUID-keyed unit family. +pub(crate) const CVAR_UNIT_EVENTS: &str = "NP_EnableUnitEventsGuid"; +/// Whether the per-token switches below narrow that family at all. +pub(crate) const CVAR_UNIT_FILTERING: &str = "NP_EnableUnitEventsGuidFiltering"; +pub(crate) const CVAR_UNIT_MOUSEOVER: &str = "NP_EnableUnitEventsMouseover"; +pub(crate) const CVAR_UNIT_PARTY: &str = "NP_EnableUnitEventsParty"; +pub(crate) const CVAR_UNIT_PET: &str = "NP_EnableUnitEventsPet"; +pub(crate) const CVAR_UNIT_RAID: &str = "NP_EnableUnitEventsRaid"; + +/// The switches this module reads, seeded by the registry's boot-time `CvarChanged` as +/// [`crate::ui_cast_events::AuraCastFeed::enabled`] is. +/// +/// `pub(crate)` throughout for `cvars.rs`'s end-to-end test, which drives the real registry +/// rather than these fields. +#[derive(Resource, Default)] +pub(crate) struct NpEventGates { + pub(crate) auto_attack: bool, + pub(crate) heal: bool, + pub(crate) energize: bool, + pub(crate) unit_events: bool, + pub(crate) unit_filtering: bool, + pub(crate) mouseover: bool, + pub(crate) party: bool, + pub(crate) pet: bool, + pub(crate) raid: bool, + /// Which families are worth queueing this frame, [`refresh_listeners`]'s answer. + want: Want, +} + +/// Whether anything will hear each family, per frame. +/// +/// **A registration is a switch.** Upstream's own README calls every `NP_Enable*Events` CVar +/// *deprecated* — "registering the event is preferred" — so a current addon turns the stream on +/// by listening rather than by writing a number, and the three CVars this module reads are the +/// backward-compatible half. pfUI writes them (`libs/libdebuff.lua:56-88`); nothing writes +/// `NP_EnableSpellEnergizeEvents` at all, and under a CVar-only reading that family could never +/// fire for anyone. +/// +/// The families with no CVar are listener-gated and nothing else, which is also what makes this +/// cheap rather than merely correct: a damage packet otherwise builds eight `ScriptValue`s and +/// two formatted strings for an event no frame is registered for, once per hit, for every unit +/// in sight. An event nobody hears is not observable, so not building it changes nothing an +/// addon could detect. +#[derive(Default)] +struct Want { + damage: bool, + auto_attack: bool, + heal: bool, + energize: bool, + miss: bool, + environmental: bool, + shield: bool, + dispel: bool, + died: bool, + aura_duration: bool, + unit: bool, +} + +#[cfg(test)] +impl Want { + /// Every family wanted, for a test about a producer rather than about the gating. + fn all() -> Self { + Self { + damage: true, + auto_attack: true, + heal: true, + energize: true, + miss: true, + environmental: true, + shield: true, + dispel: true, + died: true, + aura_duration: true, + unit: true, + } + } +} + +/// Recompute [`Want`] from the VM's own registration table, once per frame and ahead of this +/// module's own producers. +/// +/// One frame of staleness is the deliberate shape: an addon registers at `PLAYER_LOGIN`, long +/// before the first packet it cares about, and the alternative — asking the VM from inside a +/// packet handler — would mean handing every `net_handler` the VM. +fn refresh_listeners(script: Option>, mut gates: ResMut) { + let Some(script) = script else { + gates.want = Want::default(); + return; + }; + let any = |names: &[&str]| names.iter().any(|n| script.has_listener(n)); + let (cvar_auto, cvar_heal, cvar_energize, cvar_unit) = ( + gates.auto_attack, + gates.heal, + gates.energize, + gates.unit_events, + ); + gates.want = Want { + damage: any(&["SPELL_DAMAGE_EVENT_SELF", "SPELL_DAMAGE_EVENT_OTHER"]), + auto_attack: cvar_auto || any(&["AUTO_ATTACK_SELF", "AUTO_ATTACK_OTHER"]), + heal: cvar_heal + || any(&[ + "SPELL_HEAL_BY_SELF", + "SPELL_HEAL_BY_OTHER", + "SPELL_HEAL_ON_SELF", + ]), + energize: cvar_energize + || any(&[ + "SPELL_ENERGIZE_BY_SELF", + "SPELL_ENERGIZE_BY_OTHER", + "SPELL_ENERGIZE_ON_SELF", + ]), + miss: any(&["SPELL_MISS_SELF", "SPELL_MISS_OTHER"]), + environmental: any(&["ENVIRONMENTAL_DMG_SELF", "ENVIRONMENTAL_DMG_OTHER"]), + shield: any(&["DAMAGE_SHIELD_SELF", "DAMAGE_SHIELD_OTHER"]), + dispel: any(&["SPELL_DISPEL_BY_SELF", "SPELL_DISPEL_BY_OTHER"]), + died: any(&["UNIT_DIED"]), + aura_duration: any(&["BUFF_UPDATE_DURATION_SELF", "DEBUFF_UPDATE_DURATION_SELF"]), + unit: cvar_unit + || any(&[ + "UNIT_HEALTH_GUID", + "UNIT_MANA_GUID", + "UNIT_RAGE_GUID", + "UNIT_ENERGY_GUID", + "UNIT_PET_GUID", + "UNIT_FLAGS_GUID", + "UNIT_AURA_GUID", + "UNIT_DYNAMIC_FLAGS_GUID", + "UNIT_NAME_UPDATE_GUID", + "UNIT_PORTRAIT_UPDATE_GUID", + "UNIT_MODEL_CHANGED_GUID", + "UNIT_INVENTORY_CHANGED_GUID", + "PLAYER_GUILD_UPDATE_GUID", + "UNIT_COMBAT_GUID", + ]), + }; +} + +pub(crate) fn on_cvar(ev: On, mut gates: ResMut) { + // One `if` per row rather than a table of `&mut` fields: the borrow checker refuses the + // table, and a `match` on the name would need the names as patterns, which they are not. + if ev.is(CVAR_AUTO_ATTACK) { + gates.auto_attack = ev.flag(); + } + if ev.is(CVAR_HEAL) { + gates.heal = ev.flag(); + } + if ev.is(CVAR_ENERGIZE) { + gates.energize = ev.flag(); + } + if ev.is(CVAR_UNIT_EVENTS) { + gates.unit_events = ev.flag(); + } + if ev.is(CVAR_UNIT_FILTERING) { + gates.unit_filtering = ev.flag(); + } + if ev.is(CVAR_UNIT_MOUSEOVER) { + gates.mouseover = ev.flag(); + } + if ev.is(CVAR_UNIT_PARTY) { + gates.party = ev.flag(); + } + if ev.is(CVAR_UNIT_PET) { + gates.pet = ev.flag(); + } + if ev.is(CVAR_UNIT_RAID) { + gates.raid = ev.flag(); + } +} + +/// The descriptor indices one GUID-keyed unit event watches each. +/// +/// Written out rather than imported because `benilla-protocol`'s `field` module exports only the +/// handful of indices it already needed, and a mixture of imported constants and bare numbers is +/// how a table like this goes quietly wrong. Every entry is cross-checked instead, by +/// [`tests::every_watched_index_agrees_with_the_tree`], against the two sources the tree already +/// has: `ui_script::unit_rec::FIELDS`, whose indices are derived from 1.12's own block sizes, and +/// the `FIELD_UNIT_*` constants `benilla-protocol` exports. +mod index { + /// `OBJECT_END`, which every `UnitFields` index is relative to; `unit_rec` says the same. + const OBJECT_END: u16 = 6; + pub(super) const SUMMON: u16 = OBJECT_END + 2; + pub(super) const HEALTH: u16 = 22; + pub(super) const MANA: u16 = 23; + pub(super) const RAGE: u16 = 24; + pub(super) const ENERGY: u16 = 26; + pub(super) const MAX_HEALTH: u16 = 28; + pub(super) const MAX_MANA: u16 = 29; + pub(super) const FLAGS: u16 = 46; + /// The aura region: spell ids, then flags, levels and application counts. `UNIT_AURA_GUID` + /// takes an edge anywhere in it, because every one of those words is part of "this unit's + /// auras changed" — the same span `unit_rec`'s own test refuses to name a field inside. + pub(super) const AURA_FIRST: u16 = 47; + pub(super) const AURA_LAST: u16 = 124; + pub(super) const DISPLAY_ID: u16 = 131; + pub(super) const NATIVE_DISPLAY_ID: u16 = 132; + pub(super) const DYNAMIC_FLAGS: u16 = 143; + pub(super) const GUILD_ID: u16 = 191; + /// `PLAYER_VISIBLE_ITEM_1_CREATOR` to the last of the nineteen slots, twelve fields each: + /// what another player's equipment change moves, and the only such change this client sees. + pub(super) const VISIBLE_ITEM_FIRST: u16 = 258; + pub(super) const VISIBLE_ITEM_LAST: u16 = VISIBLE_ITEM_FIRST + 12 * 19 - 1; +} + +/// Which GUID-keyed unit event one descriptor edge raises. One variant per upstream name, so the +/// `fire_event` call site can hold the name as a literal — which `reference_ui.rs`'s beyond-1.12 +/// event gate requires, and which is worth having anyway: a name reached through a variable is a +/// name no gate can check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnitEventKind { + Health, + Mana, + Rage, + Energy, + Pet, + Flags, + Aura, + DynamicFlags, + NameUpdate, + PortraitUpdate, + ModelChanged, + InventoryChanged, + GuildUpdate, +} + +/// One queued event, holding exactly the arguments its names take and nothing else: anything +/// needing `Spell.dbc` or the object manager is resolved at push time, so the drain is a +/// formatting pass that cannot fail halfway. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum NpRow { + /// `SPELL_DAMAGE_EVENT_SELF`/`_OTHER`. `mitigation` is upstream's comma-joined + /// `absorb,block,resist`; `effects` and `aura_type` its comma-joined + /// `effect1,effect2,effect3,auraType`. + Damage { + target: u64, + caster: u64, + spell_id: u32, + amount: u32, + mitigation: (u32, u32, i32), + hit_info: u32, + school: u32, + effects: [u32; 3], + aura_type: u32, + }, + /// `AUTO_ATTACK_SELF`/`_OTHER`. + AutoAttack { + attacker: u64, + target: u64, + damage: u32, + hit_info: u32, + victim_state: u32, + sub_damage_count: u32, + blocked: u32, + absorb: u32, + resist: i32, + }, + /// `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF`. + Heal { + target: u64, + caster: u64, + spell_id: u32, + amount: u32, + critical: bool, + periodic: bool, + }, + /// `SPELL_ENERGIZE_BY_SELF`/`_BY_OTHER`/`_ON_SELF`. + Energize { + target: u64, + caster: u64, + spell_id: u32, + power: u32, + amount: u32, + periodic: bool, + }, + /// `SPELL_MISS_SELF`/`_OTHER`, one per row of the packet's miss list. + Miss { + caster: u64, + target: u64, + spell_id: u32, + miss_info: u32, + }, + /// `ENVIRONMENTAL_DMG_SELF`/`_OTHER`. + Environmental { + unit: u64, + damage_type: u32, + damage: u32, + absorb: u32, + resist: i32, + }, + /// `DAMAGE_SHIELD_SELF`/`_OTHER`. `bearer` wears the shield and `struck` took the return hit, + /// which is the wire's own order (`SMSG_SPELLDAMAGESHIELD`: victim, attacker). + Shield { + bearer: u64, + struck: u64, + damage: u32, + school: u32, + }, + /// `SPELL_DISPEL_BY_SELF`/`_OTHER`, one per aura the dispel removed. + Dispel { + caster: u64, + target: u64, + spell_id: u32, + }, + /// `UNIT_DIED`. + Died { guid: u64 }, + /// `BUFF_UPDATE_DURATION_SELF` / `DEBUFF_UPDATE_DURATION_SELF`. Self-only, as + /// `SMSG_UPDATE_AURA_DURATION` is on the wire. + AuraDuration { + harmful: bool, + slot: u32, + duration_ms: u32, + spell_id: u32, + }, + /// One of the thirteen GUID-keyed unit events. + Unit { kind: UnitEventKind, guid: u64 }, + /// `UNIT_COMBAT_GUID`, the GUID-keyed form of 1.12's own `UNIT_COMBAT`. + Combat { + guid: u64, + action: &'static str, + damage: u32, + school: u32, + hit_info: u32, + }, +} + +/// The producers' queue, drained once per tick by [`fire_np_events`]. +#[derive(Resource, Default)] +pub(crate) struct NpEventFeed { + rows: Vec, +} + +impl NpEventFeed { + fn push(&mut self, row: NpRow) { + self.rows.push(row); + } + + /// `pub(crate)` for the tests, which seat rows without a packet. + #[cfg(test)] + pub(crate) fn queue(&mut self, row: NpRow) { + self.rows.push(row); + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.rows.len() + } +} + +/// A spell's three effect ids and its first aura type, for `SPELL_DAMAGE_EVENT`'s `effectAuraStr`. +/// +/// **The aura type is the spell's, not the tick's**, in the one case where they can differ: a +/// periodic tick's own `SMSG_PERIODICAURALOG` aura word is carried through +/// [`benilla_protocol::messages::PeriodicTick`]'s discriminant rather than as a number, and +/// `Spell.dbc`'s `EffectApplyAuraName` is the same value for every tick of a given spell. Without +/// the catalogue both come back zero, which is honest: a zeroed triple says "not known", where an +/// invented one would claim a pure-damage spell applies an aura. +fn effect_columns(spells: Option<&crate::ui_action::Spells>, spell_id: u32) -> ([u32; 3], u32) { + let Some(display) = spells.and_then(|s| s.catalog.get(spell_id)) else { + return ([0; 3], 0); + }; + let aura = display + .effect_apply_aura + .iter() + .copied() + .find(|a| *a != 0) + .unwrap_or(0); + (display.effects, aura) +} + +fn on_attacker_state( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.auto_attack { + return; + } + if let SessionEvent::AttackerState(s) = ev { + feed.push(NpRow::AutoAttack { + attacker: s.attacker, + target: s.victim, + damage: s.damage, + hit_info: s.hit_info, + victim_state: s.victim_state, + sub_damage_count: u32::from(s.sub_damage_count), + blocked: s.blocked, + absorb: s.absorb, + resist: s.resist, + }); + } +} + +fn on_spell_damage_log( + In(ev): In, + mut feed: ResMut, + gates: Res, + spells: Option>, +) { + if !gates.want.damage { + return; + } + if let SessionEvent::SpellDamageLog(s) = ev { + let (effects, aura_type) = effect_columns(spells.as_deref(), s.spell_id); + feed.push(NpRow::Damage { + target: s.target, + caster: s.attacker, + spell_id: s.spell_id, + amount: s.damage, + mitigation: (s.absorb, s.blocked, s.resist), + hit_info: s.hit_info, + school: u32::from(s.school), + effects, + aura_type, + }); + } +} + +/// A DoT, HoT or regen tick: upstream reports the damage kind through the same +/// `SPELL_DAMAGE_EVENT` as a direct hit, and the heal and energize kinds through their own +/// families with `periodic` set — which is what the flag is for. +/// +/// `ManaLeech` raises nothing: upstream has no event for it, and reporting it as an energize +/// would double-count a tick that already reports as damage on the victim's side. +fn on_periodic_aura_log( + In(ev): In, + mut feed: ResMut, + gates: Res, + spells: Option>, +) { + use benilla_protocol::messages::PeriodicTick; + if let SessionEvent::PeriodicAuraLog(s) = ev { + for tick in &s.ticks { + match *tick { + PeriodicTick::Damage { + amount, + school, + absorb, + resist, + } if gates.want.damage => { + let (effects, aura_type) = effect_columns(spells.as_deref(), s.spell_id); + feed.push(NpRow::Damage { + target: s.target, + caster: s.caster, + spell_id: s.spell_id, + amount, + mitigation: (absorb, 0, resist), + hit_info: 0, + school, + effects, + aura_type, + }); + } + PeriodicTick::Heal { amount } if gates.want.heal => feed.push(NpRow::Heal { + target: s.target, + caster: s.caster, + spell_id: s.spell_id, + amount, + critical: false, + periodic: true, + }), + PeriodicTick::Energize { power, amount } if gates.want.energize => { + feed.push(NpRow::Energize { + target: s.target, + caster: s.caster, + spell_id: s.spell_id, + power, + amount, + periodic: true, + }); + } + _ => {} + } + } + } +} + +fn on_spell_heal_log( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.heal { + return; + } + if let SessionEvent::SpellHealLog(s) = ev { + feed.push(NpRow::Heal { + target: s.target, + caster: s.healer, + spell_id: s.spell_id, + amount: s.amount, + critical: s.critical, + periodic: false, + }); + } +} + +fn on_spell_energize_log( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.energize { + return; + } + if let SessionEvent::SpellEnergizeLog(s) = ev { + feed.push(NpRow::Energize { + target: s.target, + caster: s.caster, + spell_id: s.spell_id, + power: s.power, + amount: s.amount, + periodic: false, + }); + } +} + +/// `UNIT_NAME_UPDATE_GUID`, the one name in this family whose edge is not a descriptor field: a +/// name arrives from `SMSG_NAME_QUERY_RESPONSE`, asked once per guid by +/// [`crate::names::NameCache`]. So this is a second handler on the same packet +/// [`crate::names`] fills the cache from — registered after it, so the cache already answers by +/// the time the event reaches Lua. +fn on_player_name( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.unit { + return; + } + if let SessionEvent::PlayerName { guid, .. } = ev { + feed.push(NpRow::Unit { + kind: UnitEventKind::NameUpdate, + guid, + }); + } +} + +fn on_damage_shield( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.shield { + return; + } + if let SessionEvent::DamageShield(s) = ev { + feed.push(NpRow::Shield { + bearer: s.victim, + struck: s.attacker, + damage: s.damage, + school: s.school, + }); + } +} + +fn on_environmental_damage_log( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.environmental { + return; + } + if let SessionEvent::EnvironmentalDamageLog(s) = ev { + feed.push(NpRow::Environmental { + unit: s.victim, + damage_type: u32::from(s.damage_type), + damage: s.damage, + absorb: s.absorb, + resist: s.resist, + }); + } +} + +fn on_spell_log_miss( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.miss { + return; + } + if let SessionEvent::SpellLogMiss(s) = ev { + for (target, miss_info) in &s.misses { + feed.push(NpRow::Miss { + caster: s.caster, + target: *target, + spell_id: s.spell_id, + miss_info: u32::from(*miss_info), + }); + } + } +} + +fn on_spell_dispel_log( + In(ev): In, + mut feed: ResMut, + gates: Res, +) { + if !gates.want.dispel { + return; + } + if let SessionEvent::SpellDispelLog(s) = ev { + for spell_id in &s.spell_ids { + feed.push(NpRow::Dispel { + caster: s.caster, + target: s.victim, + spell_id: *spell_id, + }); + } + } +} + +/// `SMSG_UPDATE_AURA_DURATION` carries a slot and a time and nothing else, so the spell id comes +/// from the player's own aura block and the buff/debuff half from the slot number — the same +/// 0..31 / 32..47 split [`crate::ui_aura`] reads the block with. +fn on_aura_duration( + In(ev): In, + mut feed: ResMut, + gates: Res, + me: Query<&crate::net::ObjectStore, With>, +) { + if !gates.want.aura_duration { + return; + } + if let SessionEvent::AuraDuration { slot, remaining_ms } = ev { + let spell_id = me + .iter() + .next() + .and_then(|s| s.0.unit_aura(slot)) + .map_or(0, |a| a.spell_id); + feed.push(NpRow::AuraDuration { + harmful: slot >= benilla_protocol::messages::UNIT_AURA_POSITIVE_SLOTS, + slot: u32::from(slot), + duration_ms: remaining_ms, + spell_id, + }); + } +} + +/// Which event an index raises, or `None` for the great majority of the descriptor that raises +/// none. +/// +/// The value/max pairs share one name, as 1.12's own `UNIT_HEALTH` and `UNIT_MANA` do: a bar +/// redraws on either. `UNIT_NAME_UPDATE_GUID` is deliberately absent from this table — a name is +/// not a descriptor field here, it arrives from `SMSG_NAME_QUERY_RESPONSE`, so its edge is not an +/// edge this stream carries (see the module's own note). +fn watched(index: u16) -> Option { + Some(match index { + index::HEALTH | index::MAX_HEALTH => UnitEventKind::Health, + index::MANA | index::MAX_MANA => UnitEventKind::Mana, + index::RAGE => UnitEventKind::Rage, + index::ENERGY => UnitEventKind::Energy, + // Two dwords: a guid. + i if i == index::SUMMON || i == index::SUMMON + 1 => UnitEventKind::Pet, + index::FLAGS => UnitEventKind::Flags, + i if (index::AURA_FIRST..=index::AURA_LAST).contains(&i) => UnitEventKind::Aura, + index::DYNAMIC_FLAGS => UnitEventKind::DynamicFlags, + index::DISPLAY_ID => UnitEventKind::ModelChanged, + index::NATIVE_DISPLAY_ID => UnitEventKind::PortraitUpdate, + i if (index::VISIBLE_ITEM_FIRST..=index::VISIBLE_ITEM_LAST).contains(&i) => { + UnitEventKind::InventoryChanged + } + index::GUILD_ID => UnitEventKind::GuildUpdate, + _ => return None, + }) +} + +/// `UNIT_DIED` and the thirteen GUID-keyed unit events, all off one descriptor-edge stream. +/// +/// A death is the health dword crossing to zero from above, not the dead flag: the flag is also +/// set by a create of an already-dead corpse, and `FieldChanged` deliberately raises nothing for +/// a create — so taking the edge is what keeps "died" meaning *died* rather than "was first seen +/// dead". `UNIT_DIED` is ungated, as upstream gates it with nothing. +fn watch_fields( + mut edges: MessageReader, + mut feed: ResMut, + gates: Res, +) { + for e in edges.read() { + if !e.is_unit() { + continue; + } + if gates.want.died && e.index == index::HEALTH && e.new == 0 && e.old > 0 { + feed.push(NpRow::Died { guid: e.guid }); + } + if !gates.want.unit { + continue; + } + let kind = watched(e.index); + let Some(kind) = kind else { continue }; + feed.push(NpRow::Unit { kind, guid: e.guid }); + } +} + +/// `UNIT_COMBAT_GUID`, off the same [`crate::ui_unit::UnitCombatFeedback`] stream 1.12's own +/// `UNIT_COMBAT` is drained from — so the action word is the reference's own classification +/// (`melee_feedback`), not a second reading of the hit bits. +/// +/// Stock `UNIT_COMBAT` fires only for the tokens naming a unit, which is why the GUID form exists +/// at all: this one fires for every unit the feedback names. +fn watch_unit_combat( + mut events: MessageReader, + mut feed: ResMut, + gates: Res, + guids: Query<&crate::net::Guid>, +) { + if !gates.want.unit { + return; + } + for ev in events.read() { + let Ok(guid) = guids.get(ev.unit) else { + continue; + }; + feed.push(NpRow::Combat { + guid: guid.0, + action: ev.action, + damage: ev.amount, + school: ev.school, + hit_info: 0, + }); + } +} + +/// The seven trailing columns every GUID-keyed unit event shares: who this guid is, as the +/// player's own roster sees it. +/// +/// `isPlayer` is the active player, not "is a player character": upstream's own handler example +/// reads it to skip events about itself, and the five that follow are all "which of my tokens is +/// this", which only makes sense for the same reading. +struct UnitContext { + is_player: bool, + is_target: bool, + is_mouseover: bool, + is_pet: bool, + party_index: i64, + raid_index: i64, +} + +impl UnitContext { + fn args(&self, guid: u64) -> Vec { + vec![ + ScriptValue::Str(guid_token(guid)), + ScriptValue::Int(i64::from(self.is_player)), + ScriptValue::Int(i64::from(self.is_target)), + ScriptValue::Int(i64::from(self.is_mouseover)), + ScriptValue::Int(i64::from(self.is_pet)), + ScriptValue::Int(self.party_index), + ScriptValue::Int(self.raid_index), + ] + } + + /// Whether the per-token switches let this unit through. + /// + /// The player and the target are always in: upstream registers switches for mouseover, party, + /// pet and raid and for nothing else, which is itself the evidence that those two are not + /// filterable. With filtering off every unit passes, which is the default. + fn passes(&self, gates: &NpEventGates) -> bool { + if !gates.unit_filtering { + return true; + } + self.is_player + || self.is_target + || (self.is_mouseover && gates.mouseover) + || (self.is_pet && gates.pet) + || (self.party_index > 0 && gates.party) + || (self.raid_index > 0 && gates.raid) + } +} + +/// The inputs the seven shared columns are read from, grouped because the drain is already at +/// Bevy's parameter ceiling without them. +#[derive(bevy::ecs::system::SystemParam)] +pub(crate) struct ContextInputs<'w> { + /// `Option` like [`crate::ui_unit::UnitTokens`]'s own: a UI-only harness lacks these plugins, + /// and a bare `Res` would panic rather than answer "no pet bar". + pet: Option>, + hovered: Option>, + selection: Option>, + group: Option>, +} + +impl ContextInputs<'_> { + fn context(&self, guid: u64, me: Option) -> UnitContext { + let party_index = self + .group + .as_ref() + .and_then(|g| { + g.party_slots() + .position(|m| m.guid == guid) + .map(|i| i64::try_from(i).unwrap_or(0) + 1) + }) + .unwrap_or(0); + let raid_index = self + .group + .as_ref() + .and_then(|g| { + (1..=40).find(|n| crate::ui_party::raid_row_guid(g, me, *n) == Some(guid)) + }) + .map_or(0, |n| i64::try_from(n).unwrap_or(0)); + UnitContext { + is_player: me == Some(guid), + is_target: self.selection.as_ref().and_then(|s| s.guid) == Some(guid), + is_mouseover: self.hovered.as_ref().and_then(|h| h.guid) == Some(guid), + is_pet: self.pet.as_ref().is_some_and(|p| p.spells.pet_guid == guid), + party_index, + raid_index, + } + } +} + +/// Drain [`NpEventFeed`] into Lua, in the order the packets arrived. +pub(crate) fn fire_np_events( + script: Option>, + mut feed: ResMut, + gates: Res, + me: Res, + clock: Res, + ctx: ContextInputs, +) { + let Some(mut script) = script else { + // No VM: the rows are dropped rather than held, exactly as the cast-edge drain does. A + // queue kept across a VM build would replay a fight's worth of events into a fresh UI. + feed.rows.clear(); + return; + }; + if feed.rows.is_empty() { + return; + } + let me = me.0; + let mine = |guid: u64| me == Some(guid) && guid != 0; + for row in std::mem::take(&mut feed.rows) { + match row { + NpRow::Damage { + target, + caster, + spell_id, + amount, + mitigation: (absorb, blocked, resist), + hit_info, + school, + effects, + aura_type, + } => { + let args = vec![ + ScriptValue::Str(guid_token(target)), + ScriptValue::Str(guid_token(caster)), + ScriptValue::Int(i64::from(spell_id)), + ScriptValue::Int(i64::from(amount)), + ScriptValue::Str(format!("{absorb},{blocked},{resist}")), + ScriptValue::Int(i64::from(hit_info)), + ScriptValue::Int(i64::from(school)), + ScriptValue::Str(format!( + "{},{},{},{aura_type}", + effects[0], effects[1], effects[2] + )), + ]; + if mine(caster) { + script.fire_event("SPELL_DAMAGE_EVENT_SELF", args); + } else { + script.fire_event("SPELL_DAMAGE_EVENT_OTHER", args); + } + } + NpRow::AutoAttack { + attacker, + target, + damage, + hit_info, + victim_state, + sub_damage_count, + blocked, + absorb, + resist, + } => { + let args = vec![ + ScriptValue::Str(guid_token(attacker)), + ScriptValue::Str(guid_token(target)), + ScriptValue::Int(i64::from(damage)), + ScriptValue::Int(i64::from(hit_info)), + ScriptValue::Int(i64::from(victim_state)), + ScriptValue::Int(i64::from(sub_damage_count)), + ScriptValue::Int(i64::from(blocked)), + ScriptValue::Int(i64::from(absorb)), + ScriptValue::Int(i64::from(resist)), + ]; + if mine(attacker) { + script.fire_event("AUTO_ATTACK_SELF", args); + } else { + script.fire_event("AUTO_ATTACK_OTHER", args); + } + } + NpRow::Heal { + target, + caster, + spell_id, + amount, + critical, + periodic, + } => { + let args = || { + vec![ + ScriptValue::Str(guid_token(target)), + ScriptValue::Str(guid_token(caster)), + ScriptValue::Int(i64::from(spell_id)), + ScriptValue::Int(i64::from(amount)), + ScriptValue::Int(i64::from(critical)), + ScriptValue::Int(i64::from(periodic)), + ] + }; + if mine(caster) { + script.fire_event("SPELL_HEAL_BY_SELF", args()); + } else { + script.fire_event("SPELL_HEAL_BY_OTHER", args()); + } + if mine(target) { + script.fire_event("SPELL_HEAL_ON_SELF", args()); + } + } + NpRow::Energize { + target, + caster, + spell_id, + power, + amount, + periodic, + } => { + let args = || { + vec![ + ScriptValue::Str(guid_token(target)), + ScriptValue::Str(guid_token(caster)), + ScriptValue::Int(i64::from(spell_id)), + ScriptValue::Int(i64::from(power)), + ScriptValue::Int(i64::from(amount)), + ScriptValue::Int(i64::from(periodic)), + ] + }; + if mine(caster) { + script.fire_event("SPELL_ENERGIZE_BY_SELF", args()); + } else { + script.fire_event("SPELL_ENERGIZE_BY_OTHER", args()); + } + if mine(target) { + script.fire_event("SPELL_ENERGIZE_ON_SELF", args()); + } + } + NpRow::Miss { + caster, + target, + spell_id, + miss_info, + } => { + let args = vec![ + ScriptValue::Str(guid_token(caster)), + ScriptValue::Str(guid_token(target)), + ScriptValue::Int(i64::from(spell_id)), + ScriptValue::Int(i64::from(miss_info)), + ]; + if mine(caster) { + script.fire_event("SPELL_MISS_SELF", args); + } else { + script.fire_event("SPELL_MISS_OTHER", args); + } + } + NpRow::Environmental { + unit, + damage_type, + damage, + absorb, + resist, + } => { + let args = vec![ + ScriptValue::Str(guid_token(unit)), + ScriptValue::Int(i64::from(damage_type)), + ScriptValue::Int(i64::from(damage)), + ScriptValue::Int(i64::from(absorb)), + ScriptValue::Int(i64::from(resist)), + ]; + if mine(unit) { + script.fire_event("ENVIRONMENTAL_DMG_SELF", args); + } else { + script.fire_event("ENVIRONMENTAL_DMG_OTHER", args); + } + } + NpRow::Shield { + bearer, + struck, + damage, + school, + } => { + let args = vec![ + ScriptValue::Str(guid_token(bearer)), + ScriptValue::Str(guid_token(struck)), + ScriptValue::Int(i64::from(damage)), + ScriptValue::Int(i64::from(school)), + ]; + if mine(bearer) { + script.fire_event("DAMAGE_SHIELD_SELF", args); + } else { + script.fire_event("DAMAGE_SHIELD_OTHER", args); + } + } + NpRow::Dispel { + caster, + target, + spell_id, + } => { + let args = vec![ + ScriptValue::Str(guid_token(caster)), + ScriptValue::Str(guid_token(target)), + ScriptValue::Int(i64::from(spell_id)), + ]; + if mine(caster) { + script.fire_event("SPELL_DISPEL_BY_SELF", args); + } else { + script.fire_event("SPELL_DISPEL_BY_OTHER", args); + } + } + NpRow::Died { guid } => { + script.fire_event("UNIT_DIED", vec![ScriptValue::Str(guid_token(guid))]); + } + NpRow::AuraDuration { + harmful, + slot, + duration_ms, + spell_id, + } => { + // `expirationTimeMs` is upstream's third argument: the clock the duration runs + // out on, which the packet does not carry. **Ours is `GetTime()` in ms, not the + // reference's `GetTickCount`** — that counter is not reachable from Lua at all, + // so an addon can only ever compare this against `GetTime`, and the difference + // the argument is actually used for (`expiration - duration` = now) is the same + // either way. + let expires = (clock.ui_now * 1000.0) as i64 + i64::from(duration_ms); + let args = vec![ + ScriptValue::Int(i64::from(slot)), + ScriptValue::Int(i64::from(duration_ms)), + ScriptValue::Int(expires), + ScriptValue::Int(i64::from(spell_id)), + ]; + if harmful { + script.fire_event("DEBUFF_UPDATE_DURATION_SELF", args); + } else { + script.fire_event("BUFF_UPDATE_DURATION_SELF", args); + } + } + NpRow::Unit { kind, guid } => { + let c = ctx.context(guid, me); + if !c.passes(&gates) { + continue; + } + match kind { + UnitEventKind::Health => script.fire_event("UNIT_HEALTH_GUID", c.args(guid)), + UnitEventKind::Mana => script.fire_event("UNIT_MANA_GUID", c.args(guid)), + UnitEventKind::Rage => script.fire_event("UNIT_RAGE_GUID", c.args(guid)), + UnitEventKind::Energy => script.fire_event("UNIT_ENERGY_GUID", c.args(guid)), + UnitEventKind::Pet => script.fire_event("UNIT_PET_GUID", c.args(guid)), + UnitEventKind::Flags => script.fire_event("UNIT_FLAGS_GUID", c.args(guid)), + UnitEventKind::Aura => script.fire_event("UNIT_AURA_GUID", c.args(guid)), + UnitEventKind::DynamicFlags => { + script.fire_event("UNIT_DYNAMIC_FLAGS_GUID", c.args(guid)); + } + UnitEventKind::NameUpdate => { + script.fire_event("UNIT_NAME_UPDATE_GUID", c.args(guid)); + } + UnitEventKind::PortraitUpdate => { + script.fire_event("UNIT_PORTRAIT_UPDATE_GUID", c.args(guid)); + } + UnitEventKind::ModelChanged => { + script.fire_event("UNIT_MODEL_CHANGED_GUID", c.args(guid)); + } + UnitEventKind::InventoryChanged => { + script.fire_event("UNIT_INVENTORY_CHANGED_GUID", c.args(guid)); + } + UnitEventKind::GuildUpdate => { + script.fire_event("PLAYER_GUILD_UPDATE_GUID", c.args(guid)); + } + } + } + NpRow::Combat { + guid, + action, + damage, + school, + hit_info, + } => { + let c = ctx.context(guid, me); + if !c.passes(&gates) { + continue; + } + script.fire_event( + "UNIT_COMBAT_GUID", + vec![ + ScriptValue::Str(guid_token(guid)), + ScriptValue::Str(action.to_string()), + ScriptValue::Int(i64::from(damage)), + ScriptValue::Int(i64::from(school)), + ScriptValue::Int(i64::from(hit_info)), + ScriptValue::Int(i64::from(c.is_pet)), + ScriptValue::Int(c.party_index), + ScriptValue::Int(c.raid_index), + ], + ); + } + } + } +} + +pub(crate) struct NpEventsPlugin; + +impl Plugin for NpEventsPlugin { + fn build(&self, app: &mut App) { + use SessionEventKind as K; + app.add_observer(on_cvar) + .init_resource::() + .init_resource::() + .net_handler(K::AttackerState, on_attacker_state) + .net_handler(K::SpellDamageLog, on_spell_damage_log) + .net_handler(K::PeriodicAuraLog, on_periodic_aura_log) + .net_handler(K::SpellHealLog, on_spell_heal_log) + .net_handler(K::SpellEnergizeLog, on_spell_energize_log) + .net_handler(K::DamageShield, on_damage_shield) + .net_handler(K::EnvironmentalDamageLog, on_environmental_damage_log) + .net_handler(K::SpellLogMiss, on_spell_log_miss) + .net_handler(K::SpellDispelLog, on_spell_dispel_log) + .net_handler(K::AuraDuration, on_aura_duration) + .net_handler(K::PlayerName, on_player_name) + .add_systems( + Update, + ( + // The listener refresh first, so this frame's producers test this frame's + // answer rather than the one before it. + refresh_listeners, + (watch_fields, watch_unit_combat), + // After the unit feed and the aura events, so a handler asking about the unit + // it was just told about reads this tick's health, power and auras rather + // than last tick's — the rule `docs/METHOD.md` states for every VM feed, and + // the one an addon can actually catch us breaking. + fire_np_events + .after(crate::ui_unit::UnitFeedDispatch) + .after(crate::ui_aura::AuraEvents), + ) + .chain() + .in_set(UnitFeed), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 0x0000_0000_0000_0007; + const MOB: u64 = 0xF130_0000_0000_DEAD; + + /// A spy that records every event this module fires, as `NAME|arg1|arg2|…`. `getglobal` is + /// how 1.12 reaches `argN` from a loop, and the loop stops at the first absent one, so a + /// recorded line is exactly the argument list the module passed. + const SPY: &str = r#" +seen = {} +local f = CreateFrame("Frame") +f:SetScript("OnEvent", function() + local line = event + local i = 1 + while true do + local v = getglobal("arg" .. i) + if v == nil then break end + line = line .. "|" .. tostring(v) + i = i + 1 + end + table.insert(seen, line) +end) +for _, name in ipairs(WATCH) do f:RegisterEvent(name) end +"#; + + fn app(names: &[&str]) -> App { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(crate::ui_script::UiClock { + anchor: std::time::Instant::now(), + ui_now: 10.0, + }); + app.world_mut().resource_mut::().0 = Some(ME); + app.world_mut().resource_mut::().want = Want::all(); + let script = UiScript::new().unwrap(); + let list = names + .iter() + .map(|n| format!("{n:?}")) + .collect::>() + .join(","); + script.run(&format!("WATCH = {{{list}}}")).unwrap(); + script.run(SPY).unwrap(); + app.insert_non_send_resource(script); + app + } + + /// An app running the real [`watch_fields`] in `Update`, with no VM: the feed is read back + /// as rows, so these tests pin the producer rather than the formatting. + fn edge_app() -> App { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::>() + .add_systems(Update, watch_fields); + // Every family wanted: these tests are about which edge produces what, not about the + // gating, which `nothing_is_queued_when_no_frame_is_listening` covers on its own. + app.world_mut().resource_mut::().want = Want::all(); + app + } + + fn fired(app: &mut App) -> Vec { + app.world_mut().run_system_once(fire_np_events).unwrap(); + app.world_mut() + .non_send_resource_mut::() + .eval::>("local s = seen seen = {} return s") + .unwrap() + } + + fn tok(guid: u64) -> String { + guid_token(guid) + } + + /// Every index this module watches, against the two tables the tree already keeps: the + /// derived `unit_rec::FIELDS` and the constants `benilla-protocol` exports. A hand-written + /// descriptor index is exactly the kind of number that is plausibly wrong and silently so — + /// the event would simply never fire, which no amount of play-testing distinguishes from + /// "the server never sent one". + #[test] + fn every_watched_index_agrees_with_the_tree() { + use benilla_protocol::field as f; + let by_name = |want: &str| -> u16 { + crate::ui_script::unit_rec::index_of(want) + .unwrap_or_else(|| panic!("unit_rec has no field named {want}")) + }; + for (got, name) in [ + (index::SUMMON, "summon"), + (index::HEALTH, "health"), + (index::MANA, "power1"), + (index::RAGE, "power2"), + (index::ENERGY, "power4"), + (index::MAX_HEALTH, "maxHealth"), + (index::MAX_MANA, "maxPower1"), + (index::FLAGS, "flags"), + (index::DISPLAY_ID, "displayId"), + (index::NATIVE_DISPLAY_ID, "nativeDisplayId"), + (index::DYNAMIC_FLAGS, "dynamicFlags"), + ] { + assert_eq!(got, by_name(name), "index for {name}"); + } + // The four the protocol crate exports: a second, independent source for the same numbers. + assert_eq!(index::HEALTH, f::FIELD_UNIT_HEALTH); + assert_eq!(index::MAX_HEALTH, f::FIELD_UNIT_MAXHEALTH); + assert_eq!(index::FLAGS, f::FIELD_UNIT_FLAGS); + assert_eq!(index::DYNAMIC_FLAGS, f::FIELD_UNIT_DYNAMIC_FLAGS); + assert_eq!(index::AURA_FIRST, f::FIELD_UNIT_AURA); + // The aura region covers the spell ids, the flags and the application counts, and stops + // before `auraState`, which is a unit's own field and not one of its auras. + const { + // A const block, because both sides are constants and clippy is right that a runtime + // assert over two constants is a test that cannot fail at the time it is run. + assert!(index::AURA_LAST >= f::FIELD_UNIT_AURAAPPLICATIONS); + } + assert!(index::AURA_LAST < by_name("auraState")); + // No watched index may be inside the aura span, or one edge would raise two events. + for i in index::AURA_FIRST..=index::AURA_LAST { + assert_eq!( + watched(i), + Some(UnitEventKind::Aura), + "field {i} is inside the aura span and must raise UNIT_AURA_GUID" + ); + } + // The visible-item span is nineteen twelve-field slots and stops before the inventory + // guids, which are the player's own and not public. + assert_eq!( + index::VISIBLE_ITEM_LAST + 1, + f::FIELD_PLAYER_INV_SLOT_HEAD, + "the visible-item block runs right up to INV_SLOT_HEAD" + ); + } + + /// The shape pfUI-era consumers read: eight arguments, the mitigation triple as one + /// comma-joined string, the effect triple plus the aura type as another. + #[test] + fn a_spell_damage_event_carries_upstreams_eight_arguments() { + let mut app = app(&["SPELL_DAMAGE_EVENT_SELF", "SPELL_DAMAGE_EVENT_OTHER"]); + app.world_mut() + .resource_mut::() + .queue(NpRow::Damage { + target: MOB, + caster: ME, + spell_id: 133, + amount: 420, + mitigation: (10, 0, 5), + hit_info: 2, + school: 4, + effects: [2, 0, 0], + aura_type: 0, + }); + assert_eq!( + fired(&mut app), + vec![format!( + "SPELL_DAMAGE_EVENT_SELF|{mob}|{me}|133|420|10,0,5|2|4|2,0,0,0", + mob = tok(MOB), + me = tok(ME) + )], + "arg1 target, arg2 caster, arg3 spellId, arg4 amount, arg5 absorb,block,resist, \ + arg6 hitInfo, arg7 school, arg8 effect1,effect2,effect3,auraType" + ); + } + + /// The split is by the actor, and the actor is the guid the packet puts first. + #[test] + fn the_self_other_split_follows_the_actor() { + let mut app = app(&[ + "SPELL_DAMAGE_EVENT_SELF", + "SPELL_DAMAGE_EVENT_OTHER", + "AUTO_ATTACK_SELF", + "AUTO_ATTACK_OTHER", + "DAMAGE_SHIELD_SELF", + "DAMAGE_SHIELD_OTHER", + ]); + let mut feed = |row| app.world_mut().resource_mut::().queue(row); + // A mob's spell on us: OTHER, because the mob cast it. + feed(NpRow::Damage { + target: ME, + caster: MOB, + spell_id: 1, + amount: 1, + mitigation: (0, 0, 0), + hit_info: 0, + school: 0, + effects: [0; 3], + aura_type: 0, + }); + // Our swing on a mob: SELF, by the attacker. + feed(NpRow::AutoAttack { + attacker: ME, + target: MOB, + damage: 7, + hit_info: 0, + victim_state: 1, + sub_damage_count: 1, + blocked: 0, + absorb: 0, + resist: 0, + }); + // Our Thorns firing: SELF, by the shield's bearer, which is the packet's first guid. + feed(NpRow::Shield { + bearer: ME, + struck: MOB, + damage: 3, + school: 1, + }); + let got = fired(&mut app); + assert_eq!( + got.iter() + .map(|l| l.split('|').next().unwrap()) + .collect::>(), + vec![ + "SPELL_DAMAGE_EVENT_OTHER", + "AUTO_ATTACK_SELF", + "DAMAGE_SHIELD_SELF" + ], + "{got:?}" + ); + } + + /// A self-heal raises **two** events, which is the only reading under which upstream's three + /// names all mean something: `BY_SELF` is "I healed", `ON_SELF` is "I was healed". + #[test] + fn a_self_heal_raises_both_halves_and_a_heal_on_someone_else_raises_one() { + let mut app = app(&[ + "SPELL_HEAL_BY_SELF", + "SPELL_HEAL_BY_OTHER", + "SPELL_HEAL_ON_SELF", + ]); + app.world_mut() + .resource_mut::() + .queue(NpRow::Heal { + target: ME, + caster: ME, + spell_id: 2050, + amount: 100, + critical: true, + periodic: false, + }); + assert_eq!( + fired(&mut app), + vec![ + format!("SPELL_HEAL_BY_SELF|{me}|{me}|2050|100|1|0", me = tok(ME)), + format!("SPELL_HEAL_ON_SELF|{me}|{me}|2050|100|1|0", me = tok(ME)), + ] + ); + app.world_mut() + .resource_mut::() + .queue(NpRow::Heal { + target: MOB, + caster: ME, + spell_id: 2050, + amount: 100, + critical: false, + periodic: true, + }); + assert_eq!( + fired(&mut app), + vec![format!( + "SPELL_HEAL_BY_SELF|{mob}|{me}|2050|100|0|1", + mob = tok(MOB), + me = tok(ME) + )], + "a heal on another unit is not ON_SELF, and periodic is the sixth argument" + ); + } + + /// `UNIT_DIED` is the health dword's edge to zero, not the dead flag and not a create: a unit + /// first seen dead did not die while we were watching. + #[test] + fn a_death_is_the_health_edge_and_not_a_first_sight() { + // Through the schedule, not `run_system_once`: that call builds a fresh system each + // time, so the `MessageReader`'s cursor restarts and every earlier edge is read again. + // A test written that way passes whatever the system does with the second edge. + let mut app = edge_app(); + // The GUID-keyed family off, so each assertion below is about the death alone: a health + // edge otherwise queues `UNIT_HEALTH_GUID` beside it. + app.world_mut().resource_mut::().want.unit = false; + let edge = |index: u16, old: u32, new: u32| FieldChanged { + entity: Entity::from_raw_u32(1).unwrap(), + guid: MOB, + kind: benilla_protocol::messages::ObjectType::Unit, + index, + old, + new, + }; + let mut send = |e: FieldChanged| { + app.world_mut() + .resource_mut::>() + .write(e); + app.update(); + std::mem::take(&mut app.world_mut().resource_mut::().rows) + }; + assert_eq!( + send(edge(index::HEALTH, 100, 0)), + vec![NpRow::Died { guid: MOB }] + ); + assert!( + send(edge(index::HEALTH, 0, 0)).is_empty(), + "zero to zero is a resend, not a death" + ); + assert!( + send(edge(index::HEALTH, 50, 20)).is_empty(), + "a wound is not a death" + ); + } + + /// With the GUID-keyed family's own switch off, a descriptor edge still queues the death — + /// the two are gated separately, because upstream gates `UNIT_DIED` with nothing. + #[test] + fn the_unit_family_has_its_own_switch_and_the_death_does_not_share_it() { + let mut app = edge_app(); + app.world_mut().resource_mut::().want.unit = false; + let edge = FieldChanged { + entity: Entity::from_raw_u32(1).unwrap(), + guid: MOB, + kind: benilla_protocol::messages::ObjectType::Unit, + index: index::HEALTH, + old: 100, + new: 0, + }; + app.world_mut() + .resource_mut::>() + .write(edge); + app.update(); + assert_eq!( + app.world().resource::().len(), + 1, + "the death alone, with the GUID-keyed family unwanted" + ); + + app.world_mut().resource_mut::().want.unit = true; + app.world_mut() + .resource_mut::>() + .write(edge); + app.update(); + assert_eq!( + app.world().resource::().len(), + 3, + "with it on: the first death, plus this edge's death and its UNIT_HEALTH_GUID" + ); + } + + /// The seven trailing columns, and the filter. The player and the target are not filterable, + /// because upstream ships no switch for them. + #[test] + fn the_token_filter_never_shuts_out_the_player_or_the_target() { + let mut gates = NpEventGates { + unit_events: true, + unit_filtering: true, + ..Default::default() + }; + let player = UnitContext { + is_player: true, + is_target: false, + is_mouseover: false, + is_pet: false, + party_index: 0, + raid_index: 0, + }; + let mouseover = UnitContext { + is_player: false, + is_target: false, + is_mouseover: true, + is_pet: false, + party_index: 0, + raid_index: 0, + }; + assert!(player.passes(&gates), "the player is never filtered out"); + assert!( + !mouseover.passes(&gates), + "the mouseover waits for its own switch" + ); + gates.mouseover = true; + assert!(mouseover.passes(&gates)); + // Filtering off is the default, and then everything passes. + gates.unit_filtering = false; + gates.mouseover = false; + assert!(mouseover.passes(&gates)); + } + + /// The aura-duration pair splits on the slot, at the same 32 the aura block itself does. + #[test] + fn an_aura_duration_splits_buff_from_debuff_by_slot() { + let mut app = app(&["BUFF_UPDATE_DURATION_SELF", "DEBUFF_UPDATE_DURATION_SELF"]); + let mut queue = |slot: u32, harmful: bool| { + app.world_mut() + .resource_mut::() + .queue(NpRow::AuraDuration { + harmful, + slot, + duration_ms: 5000, + spell_id: 1234, + }) + }; + queue(3, false); + queue(35, true); + assert_eq!( + fired(&mut app), + vec![ + // `ui_now` is 10.0 s in the harness, so the expiry is 10 000 + 5 000 ms. + "BUFF_UPDATE_DURATION_SELF|3|5000|15000|1234".to_string(), + "DEBUFF_UPDATE_DURATION_SELF|35|5000|15000|1234".to_string(), + ] + ); + } + + /// **A registration is the switch.** Upstream calls the `NP_Enable*Events` CVars deprecated + /// in favour of this, and three of this module's eleven families have no CVar at all — so + /// without it `SPELL_DAMAGE_EVENT_*` would be unreachable for every addon, and + /// `SPELL_ENERGIZE_*` unreachable for all of them, since nothing installed writes its CVar. + /// + /// It is also what keeps the cost honest: with nobody listening, a damage packet must queue + /// nothing rather than build eight arguments for a fire that reaches no frame. + #[test] + fn a_registration_turns_a_family_on_and_nothing_listening_turns_it_off() { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .add_systems(Update, refresh_listeners); + let script = UiScript::new().unwrap(); + app.insert_non_send_resource(script); + + app.update(); + assert!( + !app.world().resource::().want.damage, + "nothing is registered, so the family is not wanted" + ); + + app.world_mut() + .non_send_resource_mut::() + .run(r#"local f = CreateFrame("Frame") f:RegisterEvent("SPELL_DAMAGE_EVENT_OTHER")"#) + .unwrap(); + app.update(); + let gates = app.world().resource::(); + assert!( + gates.want.damage, + "one frame registered for either name is enough" + ); + assert!( + !gates.want.heal && !gates.want.unit, + "and it turns on that family only" + ); + + // The CVar half still works on its own, for the addons that write it instead. + app.world_mut().resource_mut::().heal = true; + app.update(); + assert!(app.world().resource::().want.heal); + } + + /// With no VM the queue is emptied rather than held: a queue kept across a `/reload` would + /// replay a fight's worth of events into a fresh UI, which is worse than losing them. + #[test] + fn the_queue_is_dropped_when_there_is_no_vm() { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(crate::ui_script::UiClock { + anchor: std::time::Instant::now(), + ui_now: 0.0, + }); + app.world_mut() + .resource_mut::() + .queue(NpRow::Died { guid: MOB }); + app.world_mut().run_system_once(fire_np_events).unwrap(); + assert_eq!(app.world().resource::().len(), 0); + } +} diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 15a4ee6f..d6321b49 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -2760,6 +2760,168 @@ mod tests { "BAG_NEW_ITEMS_UPDATED", "ClassicAPI's new-item marker, fired from its own callback registry", ), + // ── nampower's combat-log family (`ui_np_events.rs`) ────────────────────────── + // + // All of these exist for one reason, so it is written once here rather than + // thirty-five times: 1.12 reports a hit by composing a **localized sentence** and + // firing `CHAT_MSG_SPELL_*` with it, so every damage meter of the era is a parser of + // grammar. nampower hooks the same packet handlers and fires the numbers. An addon + // written against it registers the exact spelling below or hears nothing. + ( + "SPELL_DAMAGE_EVENT_SELF", + "nampower: a spell's damage as numbers — target, caster, spell id, amount, the \ + absorb/block/resist triple, hitInfo, school, and the spell's own effect columns. \ + The alternative on the 1.12 surface is parsing CHAT_MSG_SPELL_SELF_DAMAGE's \ + sentence, which is localized and lossy", + ), + ( + "SPELL_DAMAGE_EVENT_OTHER", + "nampower: the same hit when somebody else dealt it; the split is by caster", + ), + ( + "AUTO_ATTACK_SELF", + "nampower: a melee swing's own report off SMSG_ATTACKERSTATEUPDATE, with the \ + victim state and the sub-damage count. 1.12 fires UNIT_COMBAT for the player \ + and target tokens only, and names no attacker", + ), + ( + "AUTO_ATTACK_OTHER", + "nampower: the swing half above for every other attacker", + ), + ( + "SPELL_HEAL_BY_SELF", + "nampower: a heal's amount off SMSG_SPELLHEALLOG, which 1.12 publishes only as \ + chat text. pfUI's libpredict reads the family to correct its own prediction", + ), + ( + "SPELL_HEAL_BY_OTHER", + "nampower: a heal cast by anyone else", + ), + ( + "SPELL_HEAL_ON_SELF", + "nampower: a heal landing on the player, whoever cast it. A self-heal fires this \ + AND BY_SELF, which is the only reading under which all three names mean \ + something distinct", + ), + ( + "SPELL_ENERGIZE_BY_SELF", + "nampower: a power gain's type and amount off SMSG_SPELLENERGIZELOG. 1.12's own \ + UNIT_MANA says a bar moved and never says why", + ), + ( + "SPELL_ENERGIZE_BY_OTHER", + "nampower: a power gain somebody else caused", + ), + ( + "SPELL_ENERGIZE_ON_SELF", + "nampower: a power gain landing on the player; the same two-of-three shape as \ + the heal family", + ), + ( + "SPELL_MISS_SELF", + "nampower: one row of SMSG_SPELLLOGMISS, with the miss reason as a number rather \ + than as the word a chat line would have spelled it", + ), + ( + "SPELL_MISS_OTHER", + "nampower: the miss half above for another caster", + ), + ( + "DAMAGE_SHIELD_SELF", + "nampower: a Thorns-style return hit off SMSG_SPELLDAMAGESHIELD; arg1 is the \ + shield's bearer, which is the packet's own first guid", + ), + ( + "DAMAGE_SHIELD_OTHER", + "nampower: the return hit above when the shield was not the player's", + ), + ( + "ENVIRONMENTAL_DMG_SELF", + "nampower: falling, drowning, lava and fatigue as a type and three numbers, off \ + SMSG_ENVIRONMENTALDAMAGELOG", + ), + ( + "ENVIRONMENTAL_DMG_OTHER", + "nampower: the same for another unit", + ), + ( + "SPELL_DISPEL_BY_SELF", + "nampower: one event per aura a dispel removed, by spell id. 1.12's chat line \ + names the aura in the player's own locale and nothing else", + ), + ( + "SPELL_DISPEL_BY_OTHER", + "nampower: a dispel somebody else landed", + ), + ( + "UNIT_DIED", + "nampower: a unit's death by guid. 1.12 has PLAYER_DEAD for the player alone and \ + nothing at all for anyone else — a nameplate addon otherwise learns of a death \ + only by polling UnitIsDead. Ungated, as upstream gates it with nothing", + ), + ( + "BUFF_UPDATE_DURATION_SELF", + "nampower: SMSG_UPDATE_AURA_DURATION as a slot and a time. 1.12 exposes a \ + duration through GetPlayerBuffTimeLeft only by polling, so a bar either polls \ + every frame or drifts", + ), + ( + "DEBUFF_UPDATE_DURATION_SELF", + "nampower: the harmful half of the pair above, split at aura slot 32", + ), + // The thirteen GUID-keyed unit events share one argument shape — `guid, isPlayer, \ + // isTarget, isMouseover, isPet, partyIndex, raidIndex` — and one reason: 1.12's own \ + // `UNIT_*` events carry a **unit token**, so they fire only for the handful of units \ + // the player happens to have a token for. An addon watching a crowd (a raid frame \ + // set, a nameplate stack) cannot hear about a unit it has no token for at all. + ( + "UNIT_HEALTH_GUID", + "nampower: UNIT_HEALTH for a unit the player holds no token for", + ), + ("UNIT_MANA_GUID", "nampower: UNIT_MANA by guid"), + ("UNIT_RAGE_GUID", "nampower: UNIT_RAGE by guid"), + ("UNIT_ENERGY_GUID", "nampower: UNIT_ENERGY by guid"), + ( + "UNIT_PET_GUID", + "nampower: UNIT_PET by guid, off the summon field", + ), + ("UNIT_FLAGS_GUID", "nampower: UNIT_FLAGS by guid"), + ( + "UNIT_AURA_GUID", + "nampower: UNIT_AURA by guid, on an edge anywhere in the unit's aura region", + ), + ( + "UNIT_DYNAMIC_FLAGS_GUID", + "nampower: UNIT_DYNAMIC_FLAGS by guid (tapped, lootable, feign death)", + ), + ( + "UNIT_NAME_UPDATE_GUID", + "nampower: UNIT_NAME_UPDATE by guid. The one name in the family whose edge is \ + not a descriptor field — it comes from SMSG_NAME_QUERY_RESPONSE", + ), + ( + "UNIT_PORTRAIT_UPDATE_GUID", + "nampower: UNIT_PORTRAIT_UPDATE by guid, off the native display id", + ), + ( + "UNIT_MODEL_CHANGED_GUID", + "nampower: UNIT_MODEL_CHANGED by guid, off the display id", + ), + ( + "UNIT_INVENTORY_CHANGED_GUID", + "nampower: UNIT_INVENTORY_CHANGED by guid, off another player's public \ + PLAYER_VISIBLE_ITEM block", + ), + ( + "PLAYER_GUILD_UPDATE_GUID", + "nampower: PLAYER_GUILD_UPDATE by guid, off PLAYER_GUILDID", + ), + ( + "UNIT_COMBAT_GUID", + "nampower: 1.12's UNIT_COMBAT with a guid and the hit's school, so a damage \ + display can follow a unit it has no token for. The action word is the \ + reference's own classification, shared with the stock event", + ), ( "QUEST_TURNED_IN", "ClassicAPI. Carries the one fact reachable nowhere else on this surface: the \ diff --git a/crates/benilla-app/src/ui_script/unit_rec.rs b/crates/benilla-app/src/ui_script/unit_rec.rs index 3eab08e6..0099e1c8 100644 --- a/crates/benilla-app/src/ui_script/unit_rec.rs +++ b/crates/benilla-app/src/ui_script/unit_rec.rs @@ -22,6 +22,18 @@ use benilla_protocol::ObjectFields; use benilla_ui::script::RecValue; +/// The descriptor index [`FIELDS`] gives for one name, for a reader that needs the index and +/// not the kind — `ui_np_events.rs`'s own index table checks itself against this, which is the +/// point of exposing it: two hand-written tables of the same numbers, each able to catch the +/// other, rather than one table nothing disagrees with. +#[cfg(test)] +pub(crate) fn index_of(name: &str) -> Option { + FIELDS + .iter() + .find(|(n, _, _)| *n == name) + .map(|(_, i, _)| *i) +} + /// `OBJECT_END`: every `UnitFields` index is relative to this. const OBJECT_END: u16 = 6; diff --git a/crates/benilla-protocol/src/messages/attack.rs b/crates/benilla-protocol/src/messages/attack.rs index 4498a0a3..d2946abe 100644 --- a/crates/benilla-protocol/src/messages/attack.rs +++ b/crates/benilla-protocol/src/messages/attack.rs @@ -31,6 +31,10 @@ pub struct AttackerState { /// `meleeSpellId` (`rec+0xac`), nonzero for an on-next-swing ability such as Heroic Strike; the /// reference only ever compares it to zero (`0x625e40`, `0x6246ca`). pub melee_spell_id: u32, + /// How many sub-damage blocks the packet carried. vmangos writes one, but the field is the + /// `subDamageCount` nampower's `AUTO_ATTACK_*` events report (`arg6`), so it is kept rather + /// than inferred: a server that splits a swing across schools sends more than one. + pub sub_damage_count: u8, /// The first sub-damage block's school (0 physical to 6 arcane, `GetFirstSchoolInMask`), 0 /// with no blocks; a non-physical swing takes the combat log's `…SCHOOL` wording. pub school: u8, @@ -87,6 +91,7 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result bool { + self.lua + .app_data_ref::() + .expect("model app_data set") + .event_to_frames + .get(event) + .is_some_and(|f| !f.is_empty()) + } + /// [`Self::fire_event`], answering what the dispatch reached ([`EventReach`]). Nothing about /// the fire changes; the number is simply kept rather than dropped, for the one caller that /// has to explain a silent event afterwards (`benilla-app`'s `area::feed_zone_events`).