diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index b8379978..f712d485 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1260,26 +1260,27 @@ pub(crate) const REGISTERED: &[Registered] = &[ which benilla folds out of `SMSG_SPELL_GO` plus `Spell.dbc`'s aura effects — see \ crate::ui_cast_events::AuraCastFeed", ), - // The other four name event classes nothing here emits. They are stored so the write + // `SPELL_START_*` and `SPELL_GO_*` are LIVE too, as of the round that read nampower's own + // registration table: the columns their rows were said to be missing turned out to be one + // `Spell.dbc` lookup (`spellType`, a channel's length) and one guid hop the item-cooldown + // path already made (`itemId`) — see `ui_cast_events.rs`'s `NpCastColumns`, which names the + // one column that really is absent (`corpseOwnerGuid`) and what it would take. + // + // The remaining two name event classes nothing here emits. They are stored so the write // round-trips and `GetCVar` answers, and consumed by nothing — the same deliberate, // documented shape as the 22 stored-only queue rows above, and for the same reason: the // alternative is not "they work", it is the engine warning this task started from. // // What each would need, stated so the gap is a gap and not a mystery: - // - `SPELL_START_*`/`SPELL_GO_*` carry nampower's own 9- and 8-argument shapes (itemId, - // spellId, caster, target, castFlags, …, corpseOwnerGuid). The packets are already folded - // into `CastEdge::Start`/`Cast` (`ui_cast_events.rs`), so the data is largely there, but the - // `itemId`/`spellType`/`corpseOwnerGuid` columns are not, and pfUI's consumers of those two - // (libpredict's HoT sender, swingtimer, actionbar's prowl hook) key on arguments this - // client would have to guess at. // - `AUTO_ATTACK_*` needs a melee-swing edge no path here raises (`CastEdge`'s own note: the // swing path produces no cast edge at all). // - `SPELL_HEAL_*` needs the heal amount off `SMSG_SPELLHEALLOG`, which nothing reads yet. ours( "NP_EnableSpellStartEvents", "0", - "nampower v2 addon-compat, STORED ONLY: benilla fires no `SPELL_START_SELF`/`_OTHER` — \ - their shape wants itemId/spellType/corpseOwnerGuid columns this client does not carry", + "nampower v2 addon-compat, LIVE: the gate on `SPELL_START_SELF`/`_OTHER`, fired off the \ + same `SMSG_SPELL_START` row `UNIT_CASTEVENT` reads — see \ + crate::ui_cast_events::NpCastGates", ), // The six `NP_EnableUnitEventsX` switches (nampower v2.33+): which unit tokens fire the // GUID-keyed unit events, so an addon can cut the stream to the units it watches. @@ -1327,8 +1328,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ ours( "NP_EnableSpellGoEvents", "0", - "nampower v2 addon-compat, STORED ONLY: benilla fires no `SPELL_GO_SELF`/`_OTHER` — same \ - missing columns as the start pair", + "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", ), ours( "NP_EnableAutoAttackEvents", @@ -4287,9 +4288,11 @@ mod tests { |app| { app.add_observer(crate::world_backdrop::on_cvar); }, - // nampower v2's `AURA_CAST_ON_*` switch (`docs/ADDON_COMPAT.md`). + // nampower v2's three live event switches: `AURA_CAST_ON_*` and the START/GO pair + // (`docs/ADDON_COMPAT.md`). |app| { app.init_resource::(); + app.init_resource::(); app.add_observer(crate::ui_cast_events::on_cvar); }, ]; @@ -4920,19 +4923,12 @@ mod tests { "NP_RetryServerRejectedSpells", "NampowerSettings' behaviour toggles", ), - // nampower v2's event-class switches. `NP_EnableAuraCastEvents` is deliberately NOT - // in this list: `ui_cast_events.rs` reads it as the `AURA_CAST_ON_*` gate and must - // keep proving so. These four 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_EnableSpellStartEvents", - "pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no SPELL_START_* here", - ), - ( - "NP_EnableSpellGoEvents", - "pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no SPELL_GO_* here", - ), + // 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", diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index bf574462..51800627 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -239,6 +239,7 @@ fn on_spell_start(In(ev): In, mut l: Lifecycle, mut sc: Scene) { cast_time_ms, target, ammo_display_id, + item_caster, } = ev { spell_start( @@ -264,8 +265,51 @@ fn on_spell_start(In(ev): In, mut l: Lifecycle, mut sc: Scene) { // `spell_start` just used for the stock bar, read from the one body so the two bars cannot // disagree about which spells have one. The event itself is ungated by it — `arg5` is // documented as the wire's cast time, not as "the bar's length". - if cast_time_ms > 0 { - l.cast_events_lua.push( + // + // nampower's `SPELL_START_SELF`/`_OTHER` ride the same row, with the columns that event + // carries and `UNIT_CASTEVENT` has no place for: the cast item's entry, the wire's own + // cast flags, the spell's kind, and a channel's length. The kind and the length come off + // `Spell.dbc` because the wire says nothing about either — a channeled spell's START + // carries `cast_time_ms` 0 and its duration arrives a round trip later in + // `MSG_CHANNEL_START`, which is exactly the case pfUI's reader falls back to `arg7` for. + // + // **A channel's START is queued although its cast time is 0.** vmangos sends + // `SMSG_SPELL_START` for a channeled spell with `cast_time_ms` 0 — the channel's own + // length arrives a round trip later in `MSG_CHANNEL_START` — so the `cast_time_ms > 0` + // test alone would drop it, and nampower's `SPELL_START_*` would never fire for Mind + // Flay or Blizzard. pfUI's reader expects exactly that row: it falls back to `arg7` + // "only for true channels like Blizzard where arg6 is 0/nil" + // (`libdebuff.lua:913-916`). `UNIT_CASTEVENT`'s own START is still suppressed for it, at + // the fire — see `drain_cast_edges` — so SuperWoW's word behaves exactly as it did + // before this row existed. + let channeled = l + .spells + .as_ref() + .and_then(|s| s.catalog.get(spell_id)) + .is_some_and(|d| d.tooltip_channeled()); + if cast_time_ms > 0 || channeled { + let def = l.spells.as_ref().and_then(|s| s.catalog.get(spell_id)); + let np = crate::ui_cast_events::NpCastColumns { + item_id: item_caster + .and_then(|guid| item_entry(guid, &l.index, &sc.stores)) + .unwrap_or(0), + cast_flags, + spell_type: match def { + Some(d) if d.tooltip_channeled() => 1, + Some(d) if d.auto_repeat() => 2, + _ => 0, + }, + channel_ms: match def { + Some(d) if d.tooltip_channeled() => crate::ui_cast_events::aura_duration_ms( + l.spells + .as_ref() + .and_then(|s| s.durations.get(d.duration_index)), + ), + _ => 0, + }, + ..Default::default() + }; + l.cast_events_lua.push_detailed( caster, target, crate::ui_cast_events::CastEdge::Start { @@ -273,6 +317,7 @@ fn on_spell_start(In(ev): In, mut l: Lifecycle, mut sc: Scene) { }, spell_id, cast_time_ms, + np, ); } } @@ -298,6 +343,13 @@ fn on_spell_go( } = ev { let engaged = !sc.engaged_self.is_empty(); + // nampower's two `SPELL_GO_*` counts, taken before `spell_go` consumes the lists. Capped + // at 255 because that is the wire's own width: the server writes both counts as `u8`s + // (vmangos `Spell.cpp:4657`), so a longer list cannot have arrived. + let (num_hit, num_missed) = ( + u8::try_from(hits.len()).unwrap_or(u8::MAX), + u8::try_from(misses.len()).unwrap_or(u8::MAX), + ); // nampower v2's `AURA_CAST_ON_SELF`/`_OTHER`, queued before `spell_go` consumes the hit // list. Pushed, not fired: these handlers hold no `UiScript`. Nothing below writes this // queue, so the position is about borrowing `hits`, not about ordering. @@ -368,12 +420,27 @@ fn on_spell_go( ); // SuperWoW's `UNIT_CASTEVENT` CAST: the cast completed. No duration on this edge — the // length was START's, and an instant never had one. - l.cast_events_lua.push( + // + // nampower's `SPELL_GO_SELF`/`_OTHER` ride the same row. Their two counts come off the + // packet's own hit and miss lists, and they matter: pfUI gates its whole debuff + // bookkeeping on `numMissed > 0 or numHit == 0` (`libdebuff.lua:947`), so this is the one + // column in that family where a wrong number changes what a player sees. + let np = crate::ui_cast_events::NpCastColumns { + item_id: item_caster + .and_then(|guid| item_entry(guid, &l.index, &sc.stores)) + .unwrap_or(0), + cast_flags, + num_hit, + num_missed, + ..Default::default() + }; + l.cast_events_lua.push_detailed( caster, target, crate::ui_cast_events::CastEdge::Cast, spell_id, 0, + np, ); } } @@ -805,13 +872,21 @@ fn cast_result( // Before this edge existed the window outlived its cast: vmangos answers a mid-cast // deselect with `SendCastResult(INTERRUPTED)` and nothing else, so `UnitCastingInfo` kept // reporting a cast that had stopped, right up to its original end. + // + // nampower's `SPELL_FAILED_SELF` rides the same row, carrying the `SpellCastResult` byte + // as `arg2`. `failedByServer` is 1 at the fire because this packet *is* the server's + // reply; a client-side refusal of ours reaches no cast edge at all (see `fire_np_cast`). if let Some(me) = self_guid.0 { - cast_windows.push( + cast_windows.push_detailed( me, None, crate::ui_cast_events::CastEdge::ResultFail, spell_id, 0, + crate::ui_cast_events::NpCastColumns { + spell_result: reason.unwrap_or(0), + ..Default::default() + }, ); } // Our cast died (vmangos never sends `SMSG_SPELL_FAILURE`): end the self avatar's cast @@ -1391,6 +1466,17 @@ fn spell_cooldowns( /// `SMSG_ITEM_COOLDOWN` (`0x6e95d0`): the fixed 30 s use cooldown, keyed on the item instance's /// template entry, as the reference resolves it. +/// An item instance guid to its template entry, the two hops every item-keyed read here makes +/// (the guid index, then the object's own `OBJECT_FIELD_ENTRY`). `None` for an item this client +/// holds no object for, which is what a cast item belonging to someone else looks like. +fn item_entry(item_guid: u64, index: &GuidIndex, stores: &Query<&mut ObjectStore>) -> Option { + index + .0 + .get(&item_guid) + .and_then(|&e| stores.get(e).ok()) + .and_then(|s| s.0.object_entry()) +} + fn item_cooldown( item_guid: u64, spell_id: u32, @@ -1399,12 +1485,7 @@ fn item_cooldown( cooldowns: &mut Cooldowns, ) { debug!("net: item cooldown — item {item_guid:#x} spell {spell_id}"); - let entry = index - .0 - .get(&item_guid) - .and_then(|&e| stores.get(e).ok()) - .and_then(|s| s.0.object_entry()); - if let Some(entry) = entry { + if let Some(entry) = item_entry(item_guid, index, stores) { cooldowns.apply_wire_item_cooldown(entry, spell_id, Instant::now()); } } diff --git a/crates/benilla-app/src/ui_cast_events.rs b/crates/benilla-app/src/ui_cast_events.rs index 1f6e8f7f..9b142d28 100644 --- a/crates/benilla-app/src/ui_cast_events.rs +++ b/crates/benilla-app/src/ui_cast_events.rs @@ -177,6 +177,46 @@ impl CastEdge { } } +/// nampower's own cast columns: the packet fields its `SPELL_START_*`/`SPELL_GO_*`/ +/// `SPELL_FAILED_SELF` family reports and `UNIT_CASTEVENT` has no place for. +/// +/// **On the same row, not a second queue.** This file's header gives the rule: an addon that hears +/// an event and then asks a getter must not be able to get an answer that contradicts it, and the +/// only guarantee of that is one row per packet, folded once. A parallel queue for nampower's +/// family would be a second reader of the same packets that can drift — the exact failure the one +/// queue exists to prevent. `Default` is "nothing extra", which is what the edges with no nampower +/// event of their own carry. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct NpCastColumns { + /// `arg1` of `SPELL_START_*` and `SPELL_GO_*`: the entry of the item whose use cast this, 0 + /// for a spell. Read nowhere in grom's corpus — pfUI's libdebuff names it `itemId` and passes + /// it straight to its hooks, none of which touch it (`libdebuff.lua:931,941`) — but it is + /// `arg1`, so every later column's position depends on it being present. + pub(crate) item_id: u32, + /// `arg5`, the wire's own `castFlags`: always `0x2` on START and `0x100` on GO, plus + /// `CAST_FLAG_AMMO` `0x20` for a ranged spell (vmangos `Spell.h:53`). + pub(crate) cast_flags: u16, + /// `arg8` of `SPELL_START_*`: `0` normal, `1` channel, `2` autorepeat — the spell's own kind, + /// off `Spell.dbc`, since the wire says nothing about it. + pub(crate) spell_type: u8, + /// `arg7` of `SPELL_START_*`: a channel's length in ms, 0 otherwise. + /// + /// Not on the wire — `SMSG_SPELL_START` for a channeled spell carries `cast_time_ms` 0 and + /// the duration arrives a round trip later in `MSG_CHANNEL_START`. The consumer knows: pfUI + /// reads `arg6` first and falls back to this "only for true channels like Blizzard where + /// arg6 is 0/nil" (`libdebuff.lua:913-916`), so this is the `SpellDuration.dbc` length, under + /// exactly the conditions [`aura_duration_ms`] will answer one at all. + pub(crate) channel_ms: u32, + /// `arg6`/`arg7` of `SPELL_GO_*`: how many targets the launch hit and missed, off the + /// packet's own two lists. pfUI gates its whole debuff bookkeeping on these + /// (`if numMissed > 0 or numHit == 0 then return end`, `libdebuff.lua:947`), which is the one + /// place in this family where a wrong number changes what a player sees. + pub(crate) num_hit: u8, + pub(crate) num_missed: u8, + /// `arg2` of `SPELL_FAILED_SELF`: the `SpellCastResult` byte the server replied with. + pub(crate) spell_result: u8, +} + /// One queued cast edge, in `UNIT_CASTEVENT`'s argument order where it has one. #[derive(Debug, Clone, Copy)] pub(crate) struct CastEventRow { @@ -192,6 +232,8 @@ pub(crate) struct CastEventRow { /// `arg5`, ms: the cast or channel length, 0 for an edge that has none. On [`CastEdge::Delay`] /// the pushback, on [`CastEdge::ChannelUpdate`] the time left. pub(crate) duration_ms: u32, + /// What nampower's own family reports beyond this. + pub(crate) np: NpCastColumns, } /// The producers' queue, drained once per tick by [`drain_cast_edges`]. @@ -226,6 +268,29 @@ impl CastEventFeed { edge: CastEdge, spell_id: u32, duration_ms: u32, + ) { + self.push_detailed( + caster, + target, + edge, + spell_id, + duration_ms, + NpCastColumns::default(), + ); + } + + /// [`Self::push`] for the three edges that carry nampower's extra columns — `SMSG_SPELL_START`, + /// `SMSG_SPELL_GO` and the failing `SMSG_CAST_RESULT`. Same queue, same row, same caster gate: + /// the two entry points differ only in whether the caller has anything to put in + /// [`NpCastColumns`]. + pub(crate) fn push_detailed( + &mut self, + caster: u64, + target: Option, + edge: CastEdge, + spell_id: u32, + duration_ms: u32, + np: NpCastColumns, ) { if caster == 0 { return; @@ -236,6 +301,7 @@ impl CastEventFeed { edge, spell_id, duration_ms, + np, }); } } @@ -352,10 +418,20 @@ pub(crate) struct AuraCastFeed { /// Apply a change of the event switch. The registry fires this at boot with the registered /// default, so `enabled` is never read before it has been seeded. -pub(crate) fn on_cvar(ev: On, mut feed: ResMut) { +pub(crate) fn on_cvar( + ev: On, + mut feed: ResMut, + mut gates: ResMut, +) { if ev.is(CVAR_AURA_CAST_EVENTS) { feed.enabled = ev.flag(); } + if ev.is(CVAR_SPELL_START_EVENTS) { + gates.start = ev.flag(); + } + if ev.is(CVAR_SPELL_GO_EVENTS) { + gates.go = ev.flag(); + } } impl AuraCastFeed { @@ -566,6 +642,154 @@ impl CastWindows { } } +// ─── nampower's own cast-edge family ────────────────────────────────────────────────────────── + +/// The two switches nampower's START and GO classes ride behind, its own spellings, both +/// documented `default 0` (upstream `EVENTS.md`) and registered in `cvars.rs`. +/// +/// Both are written to `"1"` at login by the addons that want them — `NampowerAPI.lua:2894-2902` +/// and pfUI's `libs/libdebuff.lua:56-88` — so in a real session they are on, and the default +/// matters only for a client with neither addon. +pub(crate) const CVAR_SPELL_START_EVENTS: &str = "NP_EnableSpellStartEvents"; +pub(crate) const CVAR_SPELL_GO_EVENTS: &str = "NP_EnableSpellGoEvents"; + +/// The applied values of the two switches above. +/// +/// A resource of its own, not a field on [`CastEventFeed`]: the gate here can only sit at the +/// *fire*, because the START and GO rows are queued regardless — `UNIT_CASTEVENT` reads them and +/// the cast windows are folded from them. That is the difference from [`AuraCastFeed::enabled`], +/// which gates the *push* and so saves a whole catalogue walk when it is off. +/// +/// The other five events in this family — `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF` and +/// the channel pair — have no switch at all in the DLL's own CVar table, so they fire +/// unconditionally, as they do upstream. +#[derive(Resource, Default)] +pub(crate) struct NpCastGates { + /// `pub(crate)` for `cvars.rs`'s end-to-end test, which drives the real registry. + pub(crate) start: bool, + pub(crate) go: bool, +} + +/// nampower's cast-edge events for one row, in upstream's own argument order (`EVENTS.md`, and +/// corroborated column by column from the consumer side by pfUI's `libs/libdebuff.lua:909-945`, +/// which destructures `arg2` as the spell id, `arg3` as the caster, `arg4` as the target, `arg6` +/// as the cast time and `arg7` as the channel duration). +/// +/// **The `_SELF`/`_OTHER` split is by caster**, which is where this family differs from the aura +/// one next door — that one splits by *target* (see [`AuraCastRow::on_self`]) — and the two are +/// not a guess either way: upstream's own wording for the aura events is "casts that land on the +/// active player", while here pfUI handles `SPELL_START_SELF` and `SPELL_START_OTHER` in one arm +/// and then reads `arg3` to learn the caster, which only makes sense if the name already settled +/// it. +/// +/// Four edges raise nothing here: +/// - [`CastEdge::Abort`], the client's own cancel: the server answers it with +/// `SMSG_SPELL_FAILED_OTHER` a round trip later, and that is what raises the FAIL — firing one +/// now would double an event an addon counts. Same reasoning as its `UNIT_CASTEVENT` row. +/// - An instant's START, which is never queued at all (`spell::net` skips `cast_time_ms == 0`). +/// - `SPELL_DELAYED_OTHER`, which upstream states never fires either, because the server sends +/// `SMSG_SPELL_DELAYED` only to the affected player. +/// - A client-side cast failure as `SPELL_FAILED_SELF` with `failedByServer == 0`. This client's +/// local refusals funnel through `CastErrors` (`ui_action/errors.rs`), which carries a spell id +/// and a reason byte and reaches no cast edge at all — the same absent half `SelfCastSend` +/// writes down for `SPELL_CAST_EVENT`'s `success == 0`, and for the same reason. +/// +/// `corpseOwnerGuid`, the last argument of both START and GO, is never sent: a trailing nil in +/// Lua is indistinguishable from an absent return, and upstream sends nil for every cast without +/// a corpse target, which is all but a handful (resurrections and corpse-targeted hunter pets). +/// For those, the gap is real and bounded — `read_spell_cast_targets` reads the corpse guid and +/// drops it, and the *owner* is a further hop through that corpse object's own +/// `CORPSE_FIELD_OWNER`. Nothing in grom's corpus reads the column. +fn fire_np_cast( + script: &mut UiScript, + row: &CastEventRow, + named: u32, + me: u64, + gates: &NpCastGates, +) { + let caster = || ScriptValue::Str(benilla_ui::script::guid_token(row.caster)); + let target = || ScriptValue::Str(benilla_ui::script::guid_token(row.target)); + let int = |n: u32| ScriptValue::Int(i64::from(n)); + let is_self = row.caster != 0 && row.caster == me; + match row.edge { + CastEdge::Start { .. } if gates.start => { + let args = vec![ + int(row.np.item_id), + int(row.spell_id), + caster(), + target(), + int(u32::from(row.np.cast_flags)), + int(row.duration_ms), + int(row.np.channel_ms), + int(u32::from(row.np.spell_type)), + ]; + // The names are LITERALS at each call so `reference_ui.rs`'s beyond-1.12 event gate + // can see them: its walker reads the string argument here and cannot follow a name + // through a variable. `ui_aura.rs`'s own fire sites carry the same note, which is the + // house rule this file had previously broken for the aura pair below. + if is_self { + script.fire_event("SPELL_START_SELF", args); + } else { + script.fire_event("SPELL_START_OTHER", args); + } + } + CastEdge::Cast if gates.go => { + let args = vec![ + int(row.np.item_id), + int(row.spell_id), + caster(), + target(), + int(u32::from(row.np.cast_flags)), + int(u32::from(row.np.num_hit)), + int(u32::from(row.np.num_missed)), + ]; + if is_self { + script.fire_event("SPELL_GO_SELF", args); + } else { + script.fire_event("SPELL_GO_OTHER", args); + } + } + // `SMSG_SPELL_FAILED_OTHER` is the "other" packet whatever it names, and it names us too + // when the server kills our own cast — upstream hooks that one packet and this is the + // event it raises, so the caster rides as `arg1` rather than the name deciding it. + CastEdge::Fail => { + script.fire_event("SPELL_FAILED_OTHER", vec![caster(), int(row.spell_id)]); + } + // A failing `SMSG_CAST_RESULT`, which is only ever our own cast's reply: `failedByServer` + // is 1 because that is where this row comes from. + CastEdge::ResultFail => { + script.fire_event( + "SPELL_FAILED_SELF", + vec![ + int(row.spell_id), + int(u32::from(row.np.spell_result)), + ScriptValue::Int(1), + ], + ); + } + CastEdge::Delay => { + script.fire_event("SPELL_DELAYED_SELF", vec![caster(), int(row.duration_ms)]); + } + // The channel pair is self-only on the wire (`MSG_CHANNEL_*` is sent to the caster alone), + // which is why upstream marks both "self only" and neither has an `_OTHER` sibling. + // `named` rather than `row.spell_id`: `MSG_CHANNEL_UPDATE` carries no spell id, so the + // live window names it — the same substitution the token events make. + CastEdge::Channel => { + script.fire_event( + "SPELL_CHANNEL_START", + vec![int(named), target(), int(row.duration_ms)], + ); + } + CastEdge::ChannelUpdate => { + script.fire_event( + "SPELL_CHANNEL_UPDATE", + vec![int(named), target(), int(row.duration_ms)], + ); + } + CastEdge::Start { .. } | CastEdge::Cast | CastEdge::Abort => {} + } +} + /// Fold every queued edge into the cast windows and fire its events — SuperWoW's /// `UNIT_CASTEVENT`, then ClassicAPI's `UNIT_SPELLCAST_*` family — in packet order, each row's /// state reaching the VM before any of its own events do. @@ -586,6 +810,8 @@ pub(crate) fn drain_cast_edges( mut feed: ResMut, mut windows: ResMut, mut auras: ResMut, + gates: Res, + self_guid: Res, ) { let Some(mut script) = script else { // No VM (a capture or headless run): drop the rows so the queue stays bounded, as @@ -621,7 +847,14 @@ pub(crate) fn drain_cast_edges( if folded { script.set_unit_casts(windows.snapshot()); } - if let Some(word) = row.edge.word() { + // A START whose wire cast time is 0 raises no `UNIT_CASTEVENT` word. That is this + // client's existing behaviour preserved rather than a new rule: such a row used not to be + // queued at all (`spell::net` tested `cast_time_ms > 0`), and it is queued now only so + // nampower's `SPELL_START_*` can fire for a **channel**, whose START really does carry 0. + // An instant is the other case, and the wiki's own START/CAST split says its only edge is + // the CAST. + let np_only_start = matches!(row.edge, CastEdge::Start { .. }) && row.duration_ms == 0; + if let (Some(word), false) = (row.edge.word(), np_only_start) { script.fire_event( "UNIT_CASTEVENT", vec![ @@ -633,6 +866,10 @@ pub(crate) fn drain_cast_edges( ], ); } + // nampower's own cast family, behind SuperWoW's one word and ahead of ClassicAPI's + // tokens. The three are independent hooks in a real install, so no addon can observe an + // order between them; this one is placed second because it is the second that existed. + fire_np_cast(&mut script, &row, named, self_guid.0.unwrap_or(0), &gates); // ClassicAPI's token events, once per live token per event — the fan-out and the // `(arg1..arg5)` shape are `benilla-ui`'s `script/cast_events.rs`, which is where the // token set and `spell_display` both live. Event-major rather than token-major: when one @@ -648,25 +885,28 @@ pub(crate) fn drain_cast_edges( // in a real install, so no addon can observe an order between them; what *is* observable is // that the cast edges fire exactly where they did before this queue existed. for row in auras.rows.drain(..) { - let name = if row.on_self { - "AURA_CAST_ON_SELF" + let args = vec![ + ScriptValue::Int(i64::from(row.spell_id)), + ScriptValue::Str(benilla_ui::script::guid_token(row.caster)), + ScriptValue::Str(benilla_ui::script::guid_token(row.target)), + ScriptValue::Int(i64::from(row.effect)), + ScriptValue::Int(i64::from(row.aura_name)), + ScriptValue::Int(i64::from(row.amplitude)), + ScriptValue::Int(i64::from(row.misc_value)), + ScriptValue::Int(i64::from(row.duration_ms)), + ScriptValue::Int(row.aura_cap), + ]; + // Both names written out as LITERALS, for the reason `ui_aura.rs`'s own fire sites give: + // `reference_ui.rs`'s beyond-1.12 event gate reads the string argument at the call and + // cannot follow a name through a variable. Routing these two through one `&str` compiled + // fine and left that gate blind to the pair for as long as they have existed — the same + // mistake, in the same shape, that the aura-edge fire in `ui_aura.rs` already carries a + // warning about. + if row.on_self { + script.fire_event("AURA_CAST_ON_SELF", args); } else { - "AURA_CAST_ON_OTHER" - }; - script.fire_event( - name, - vec![ - ScriptValue::Int(i64::from(row.spell_id)), - ScriptValue::Str(benilla_ui::script::guid_token(row.caster)), - ScriptValue::Str(benilla_ui::script::guid_token(row.target)), - ScriptValue::Int(i64::from(row.effect)), - ScriptValue::Int(i64::from(row.aura_name)), - ScriptValue::Int(i64::from(row.amplitude)), - ScriptValue::Int(i64::from(row.misc_value)), - ScriptValue::Int(i64::from(row.duration_ms)), - ScriptValue::Int(row.aura_cap), - ], - ); + script.fire_event("AURA_CAST_ON_OTHER", args); + } } } @@ -678,6 +918,7 @@ impl Plugin for SuperWowEventsPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() // In `UnitFeed`, like the mirror-timer drain: the edges are produced by the net // handlers earlier in the frame and must reach Lua before this tick's `OnUpdate`. // After the local cancel, whose `Abort` edge this drains the same frame — the same @@ -752,6 +993,8 @@ mod tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); + app.init_resource::(); app.insert_non_send_resource(UiScript::new().unwrap()); // A frame that records every UNIT_CASTEVENT it hears, so the assertion reads the real // dispatch path rather than the queue. @@ -807,6 +1050,8 @@ mod tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); + app.init_resource::(); app.world_mut() .resource_mut::() .push(0x2a, None, CastEdge::Cast, 133, 0); @@ -1004,6 +1249,8 @@ mod tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); + app.init_resource::(); let mut script = UiScript::new().unwrap(); script.set_now(100.0); script.set_spellbook(SpellBookState { @@ -1063,6 +1310,8 @@ mod tests { app.init_resource::(); app.init_resource::(); app.init_resource::(); + app.init_resource::(); + app.init_resource::(); let mut script = UiScript::new().unwrap(); script.set_now(100.0); script.set_spellbook(SpellBookState { @@ -1138,6 +1387,7 @@ mod tests { edge, spell_id, duration_ms, + np: NpCastColumns::default(), } } @@ -1159,6 +1409,12 @@ mod tests { /// The player's and the mob's GUIDs, as the feeds name them. const ME: u64 = 0x2a; + + /// A GUID as every `compat/` event writes it, so a test asserts the spelling once rather than + /// repeating sixteen digits. + fn tok(guid: u64) -> String { + benilla_ui::script::guid_token(guid) + } const MOB: u64 = 0x63; /// A second mob, for the multi-hit fan-out of nampower's aura events. const MOB2: u64 = 0x64; @@ -1362,6 +1618,7 @@ end) app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -2433,7 +2690,9 @@ end) let mut app = App::new(); app.init_resource::() .init_resource::() - .init_resource::(); + .init_resource::() + .init_resource::() + .init_resource::(); app.world_mut().resource_mut::().enabled = enabled; let mut script = UiScript::new().unwrap(); script.set_unit( @@ -2636,11 +2895,349 @@ end) .is_empty()); } + // ── nampower's own cast-edge family ─────────────────────────────────────────────────────── + // + // Same discipline as the aura-cast block above: the assertion reads **pfUI's own `local` + // bindings** back out, not a list of values in the order this file happens to push them. A + // positional shape got backwards here fails in silence — the handler binds `arg3` to + // `casterGuid` whatever `arg3` happens to be — and the START/GO pair is where that would be + // most expensive, because pfUI's swing timer resets off one and its debuff bookkeeping gates + // on the other's two counts. + + /// `pfUI/libs/libdebuff.lua:909-945`, the START and GO arms transcribed, with its own comment + /// about `arg6`/`arg7` kept because it states the rule the `channel_ms` column exists for. + const PFUI_NP_CAST: &str = r#" +seen = {} +local f = CreateFrame("Frame") +for _, e in ipairs({"SPELL_START_SELF", "SPELL_START_OTHER", "SPELL_GO_SELF", "SPELL_GO_OTHER", + "SPELL_FAILED_SELF", "SPELL_FAILED_OTHER", "SPELL_DELAYED_SELF", + "SPELL_CHANNEL_START", "SPELL_CHANNEL_UPDATE"}) do + f:RegisterEvent(e) +end +f:SetScript("OnEvent", function() + if event == "SPELL_START_SELF" or event == "SPELL_START_OTHER" then + local spellId = arg2 + local casterGuid = arg3 + -- arg6=castTime (ms), arg7=channel duration (ms). Prefer arg6 when set. + local castTime = (arg6 and arg6 > 0) and arg6 or arg7 + if not casterGuid or not spellId then return end + table.insert(seen, event .. "|item=" .. arg1 .. "|spell=" .. spellId + .. "|caster=" .. casterGuid .. "|target=" .. arg4 .. "|flags=" .. arg5 + .. "|cast=" .. castTime .. "|type=" .. arg8) + elseif event == "SPELL_GO_SELF" or event == "SPELL_GO_OTHER" then + local itemId = arg1 + local spellId = arg2 + local casterGuid = arg3 + local targetGuid = arg4 + local numHit = arg6 or 0 + local numMissed = arg7 or 0 + table.insert(seen, event .. "|item=" .. itemId .. "|spell=" .. spellId + .. "|caster=" .. casterGuid .. "|target=" .. targetGuid + .. "|hit=" .. numHit .. "|missed=" .. numMissed) + elseif event == "SPELL_FAILED_SELF" then + table.insert(seen, event .. "|spell=" .. arg1 .. "|result=" .. arg2 .. "|byServer=" .. arg3) + elseif event == "SPELL_FAILED_OTHER" then + table.insert(seen, event .. "|caster=" .. arg1 .. "|spell=" .. arg2) + elseif event == "SPELL_DELAYED_SELF" then + table.insert(seen, event .. "|caster=" .. arg1 .. "|delay=" .. arg2) + else + table.insert(seen, event .. "|spell=" .. arg1 .. "|target=" .. arg2 .. "|ms=" .. arg3) + end +end) +"#; + + /// An app with the drain, the spy, and both switches on as every addon that wants them leaves + /// them at login. + fn np_cast_app(start: bool, go: bool) -> App { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + app.world_mut().resource_mut::().start = start; + app.world_mut().resource_mut::().go = go; + app.world_mut().resource_mut::().0 = Some(ME); + let script = UiScript::new().unwrap(); + script.run(PFUI_NP_CAST).unwrap(); + app.insert_non_send_resource(script); + app + } + + fn np_seen(app: &App) -> Vec { + app.world() + .non_send_resource::() + .eval("return seen") + .unwrap() + } + + /// The START pair: every column in its documented place, the `_SELF`/`_OTHER` split by + /// **caster**, and a channel's length in `arg7` where `arg6` is the cast time — which is the + /// one pair pfUI's own reader picks between. + #[test] + fn the_start_pair_carries_every_column_and_splits_by_caster() { + let mut app = np_cast_app(true, true); + { + let mut feed = app.world_mut().resource_mut::(); + feed.push_detailed( + ME, + Some(0x63), + CastEdge::Start { window: true }, + 133, + 3_000, + NpCastColumns { + item_id: 13_444, + cast_flags: 0x2, + spell_type: 0, + channel_ms: 0, + ..Default::default() + }, + ); + // A channel: the wire's cast time is 0, which is why the row is queued at all and + // why `arg7` is what the consumer falls back to. + feed.push_detailed( + 0x63, + Some(ME), + CastEdge::Start { window: true }, + 5_143, + 0, + NpCastColumns { + spell_type: 1, + channel_ms: 8_000, + ..Default::default() + }, + ); + } + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert_eq!( + np_seen(&app), + vec![ + format!( + "SPELL_START_SELF|item=13444|spell=133|caster={}|target={}|flags=2|cast=3000\ + |type=0", + tok(ME), + tok(0x63) + ), + format!( + "SPELL_START_OTHER|item=0|spell=5143|caster={}|target={}|flags=0|cast=8000\ + |type=1", + tok(0x63), + tok(ME) + ), + ], + "the player's cast is SELF and the mob's is OTHER, and the channel's length reaches \ + the consumer through arg7 because arg6 is not a real cast time" + ); + } + + /// The GO pair's two counts, which gate pfUI's whole debuff bookkeeping + /// (`if numMissed > 0 or numHit == 0 then return end`). + #[test] + fn the_go_pair_reports_the_hit_and_miss_counts() { + let mut app = np_cast_app(true, true); + app.world_mut() + .resource_mut::() + .push_detailed( + ME, + Some(0x63), + CastEdge::Cast, + 133, + 0, + NpCastColumns { + cast_flags: 0x100, + num_hit: 3, + num_missed: 1, + ..Default::default() + }, + ); + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert_eq!( + np_seen(&app), + vec![format!( + "SPELL_GO_SELF|item=0|spell=133|caster={}|target={}|hit=3|missed=1", + tok(ME), + tok(0x63) + )] + ); + } + + /// The five ungated events: both failure shapes, pushback, and the channel pair whose spell + /// id comes from the live window because `MSG_CHANNEL_UPDATE` carries nothing but a duration. + #[test] + fn the_failure_pushback_and_channel_events_carry_their_own_shapes() { + let mut app = np_cast_app(false, false); + { + let mut feed = app.world_mut().resource_mut::(); + feed.push(ME, None, CastEdge::Channel, 5_143, 8_000); + feed.push(0x63, None, CastEdge::Fail, 116, 0); + feed.push(ME, None, CastEdge::Delay, 0, 500); + feed.push_detailed( + ME, + None, + CastEdge::ResultFail, + 133, + 0, + NpCastColumns { + spell_result: 0x23, + ..Default::default() + }, + ); + } + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert_eq!( + np_seen(&app), + vec![ + format!("SPELL_CHANNEL_START|spell=5143|target={}|ms=8000", tok(0)), + format!("SPELL_FAILED_OTHER|caster={}|spell=116", tok(0x63)), + format!("SPELL_DELAYED_SELF|caster={}|delay=500", tok(ME)), + "SPELL_FAILED_SELF|spell=133|result=35|byServer=1".to_string(), + ], + "packet order; the result byte is the server's own SpellCastResult (0x23 = 35), and \ + failedByServer is 1 because this row is the server's reply" + ); + + // The channel's own stop, which names its spell from the window it is about. + app.world_mut().resource_mut::().push( + ME, + None, + CastEdge::ChannelUpdate, + 0, + 0, + ); + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert_eq!( + np_seen(&app).last().map(String::as_str), + Some(format!("SPELL_CHANNEL_UPDATE|spell=5143|target={}|ms=0", tok(0)).as_str()), + "the packet names no spell, so the live window does" + ); + } + + /// The two switches. Off, the START and GO pair is silent and the rest is not — which is the + /// whole shape of upstream's own gating, where only those two classes have a CVar. + #[test] + fn the_two_switches_gate_only_the_start_and_go_pair() { + let mut app = np_cast_app(false, false); + { + let mut feed = app.world_mut().resource_mut::(); + feed.push(ME, Some(0x63), CastEdge::Start { window: true }, 133, 3_000); + feed.push(ME, Some(0x63), CastEdge::Cast, 133, 0); + feed.push(0x63, None, CastEdge::Fail, 116, 0); + } + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert_eq!( + np_seen(&app), + vec![format!("SPELL_FAILED_OTHER|caster={}|spell=116", tok(0x63))], + "both switches off: only the ungated event fires" + ); + + // One switch at a time, so neither is standing in for the other. + app.world_mut().resource_mut::().go = true; + app.world_mut().resource_mut::().push( + ME, + Some(0x63), + CastEdge::Cast, + 133, + 0, + ); + app.world_mut().resource_mut::().push( + ME, + Some(0x63), + CastEdge::Start { window: true }, + 133, + 3_000, + ); + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert_eq!( + np_seen(&app).len(), + 2, + "the GO fires, the START still does not" + ); + assert!(np_seen(&app)[1].starts_with("SPELL_GO_SELF")); + } + + /// A channel's START is queued although its wire cast time is 0, so nampower's + /// `SPELL_START_*` can fire for Mind Flay and Blizzard — and `UNIT_CASTEVENT`'s own START is + /// still suppressed for it, which is this client's behaviour before that row existed. + /// + /// The two halves are asserted together because the whole point is that adding one did not + /// change the other: SuperWoW's word is what every cast-tracking addon on this realm already + /// registers, and a START with `arg5 = 0` reaching one of those is a cast bar told to run for + /// no time at all. + #[test] + fn a_channels_start_reaches_nampower_but_not_unit_castevent() { + let mut app = np_cast_app(true, true); + app.world_mut() + .non_send_resource_mut::() + .run( + r#" + words = {} + local g = CreateFrame("Frame") + g:RegisterEvent("UNIT_CASTEVENT") + g:SetScript("OnEvent", function() table.insert(words, arg3 .. "/" .. arg5) end) + "#, + ) + .unwrap(); + { + let mut feed = app.world_mut().resource_mut::(); + // The channel's START (cast time 0), then a real 3 s cast's. + feed.push_detailed( + ME, + None, + CastEdge::Start { window: true }, + 5_143, + 0, + NpCastColumns { + spell_type: 1, + channel_ms: 8_000, + ..Default::default() + }, + ); + feed.push(ME, None, CastEdge::Start { window: true }, 133, 3_000); + } + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + + assert_eq!( + np_seen(&app).len(), + 2, + "nampower hears both — the channel through arg7, the cast through arg6" + ); + assert_eq!( + app.world() + .non_send_resource::() + .eval::>("return words") + .unwrap(), + vec!["START/3000".to_string()], + "SuperWoW hears only the cast: a START with arg5 = 0 is not an edge this client has \ + ever raised" + ); + // And the channel opened no window either, which is `MSG_CHANNEL_START`'s job. + assert_eq!( + app.world().resource::().0.len(), + 1, + "one window, the 3 s cast's" + ); + } + + /// An abort raises nothing in this family, for the same reason it raises no `UNIT_CASTEVENT`: + /// the server answers the cancel with `SMSG_SPELL_FAILED_OTHER` a round trip later, and + /// firing one now would double an event an addon counts. + #[test] + fn a_local_abort_raises_no_nampower_event() { + let mut app = np_cast_app(true, true); + app.world_mut() + .resource_mut::() + .push(ME, None, CastEdge::Abort, 133, 0); + app.world_mut().run_system_once(drain_cast_edges).unwrap(); + assert!(np_seen(&app).is_empty()); + } + /// The switch is a real `CvarChanged` observer, so pfUI's `SetCVar` reaches it. #[test] fn the_cvar_observer_moves_the_switch_both_ways() { let mut app = App::new(); - app.init_resource::().add_observer(on_cvar); + app.init_resource::() + .init_resource::() + .add_observer(on_cvar); let fire = |app: &mut App, new: &str| { app.world_mut().trigger(crate::cvars::CvarChanged { name: CVAR_AURA_CAST_EVENTS.to_string(), diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index d027be17..658bab8f 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -2663,6 +2663,79 @@ mod tests { "DEBUFF_REMOVED_SELF", "nampower: the removal half of the pair above", ), + ( + "AURA_CAST_ON_SELF", + "nampower: an aura landing on the player, with the applying effect's own \ + Spell.dbc columns. Fired here since this family was built; it reached this list \ + only when its two fire sites were changed from one `&str` variable to two \ + literals, because this gate's walker reads the string at the call. The same \ + blindness, in the same shape, as the one `ui_aura.rs` carries a warning about", + ), + ( + "AURA_CAST_ON_OTHER", + "nampower: the other half of the pair above, split by TARGET rather than caster — \ + upstream's own wording is \"casts that land on the active player\"", + ), + ( + "SPELL_START_SELF", + "nampower: the player's own cast beginning, with the spell id, both GUIDs, the \ + wire's cast flags, the cast time, a channel's length and the spell's kind. \ + 1.12's SPELLCAST_START carries a label and a duration and names neither spell \ + nor target, so nothing on that surface can correlate a cast with what it was \ + cast at. pfUI's libdebuff and its swingtimer both register this one \ + (libdebuff.lua:863, swingtimer.lua:727), and libpredict hangs its heal and \ + resurrection prediction off a hook it fires", + ), + ( + "SPELL_START_OTHER", + "nampower: another unit's cast beginning, the `_OTHER` half of the pair above. \ + The split is by caster, which is how pfUI reads it — it handles both names in \ + one arm and then reads arg3 for the caster, which only makes sense if the name \ + already settled it", + ), + ( + "SPELL_GO_SELF", + "nampower: the player's own cast launching, carrying how many targets it hit and \ + missed. Those two counts are the gate on pfUI's whole debuff bookkeeping \ + (`if numMissed > 0 or numHit == 0 then return end`, libdebuff.lua:947), which \ + 1.12 cannot express at all: SPELLCAST_STOP is argless", + ), + ( + "SPELL_GO_OTHER", + "nampower: another unit's cast launching, the `_OTHER` half of the pair above", + ), + ( + "SPELL_FAILED_SELF", + "nampower: the player's own cast refused, with the server's SpellCastResult byte \ + — the number 1.12 turns into a red line and then discards, so no addon can see \ + WHY a cast failed. SuperCleveRoidMacros registers it beside SPELL_START_SELF \ + (Core.lua:4904)", + ), + ( + "SPELL_FAILED_OTHER", + "nampower: a cast interrupted, named by caster. Same packet SuperWoW reports as \ + UNIT_CASTEVENT's FAIL word, under nampower's own name and shape, because an \ + addon registers whichever mod it was written against", + ), + ( + "SPELL_DELAYED_SELF", + "nampower: pushback on the player's own cast, in ms. 1.12's SPELLCAST_DELAYED \ + carries the same number but no caster, and upstream's own note is that \ + SPELL_DELAYED_OTHER never fires at all because the server sends the packet only \ + to the affected player", + ), + ( + "SPELL_CHANNEL_START", + "nampower: the player's own channel beginning, with the spell id 1.12's \ + SPELLCAST_CHANNEL_START does not carry (it gets a label and a duration). \ + Self-only, as MSG_CHANNEL_START is on the wire", + ), + ( + "SPELL_CHANNEL_UPDATE", + "nampower: the channel's remaining time re-quoted, 0 ending it. Self-only for the \ + same reason, and the spell id comes from the live window because the packet \ + carries nothing but the duration", + ), ( "SPELL_CAST_EVENT", "nampower: the player's own cast as the client committed it, carrying the spell \ diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 6a1a0d2f..b0be084a 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -552,6 +552,10 @@ pub enum SessionEvent { target: Option, /// The `CAST_FLAG_AMMO` tail: nocked ammo for any caster (`0x60ba30` from `0x6e78b6`). ammo_display_id: Option, + /// The cast item when the first guid is not the caster, as [`Self::SpellGo`] carries it — + /// nampower's `SPELL_START_*` reports the item as `arg1`, so the START needs the same + /// column the GO already had. + item_caster: Option, }, /// The cast launched (`SMSG_SPELL_GO`); the server times impact off `Spell.dbc` Speed itself. SpellGo { diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 35f66cf1..ec8abe93 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -203,14 +203,20 @@ pub fn decode(packet: ServerPacket) -> Vec { ServerPacket::AiReaction { unit, reaction } => { vec![SessionEvent::AiReaction { unit, reaction }] } - ServerPacket::SpellStart(s) => vec![SessionEvent::SpellStart { - caster: spell_caster(s.item_or_caster, s.caster), - spell_id: s.spell_id, - cast_flags: s.cast_flags, - cast_time_ms: s.cast_time_ms, - target: s.targets.unit_target, - ammo_display_id: s.ammo_display_id, - }], + ServerPacket::SpellStart(s) => { + let caster = spell_caster(s.item_or_caster, s.caster); + vec![SessionEvent::SpellStart { + caster, + spell_id: s.spell_id, + cast_flags: s.cast_flags, + cast_time_ms: s.cast_time_ms, + target: s.targets.unit_target, + ammo_display_id: s.ammo_display_id, + // Same test the GO arm below makes: a GameObject's own guid in slot 1 is not an + // item. + item_caster: (s.item_or_caster != caster).then_some(s.item_or_caster), + }] + } ServerPacket::SpellGo(s) => { let caster = spell_caster(s.item_or_caster, s.caster); vec![SessionEvent::SpellGo { diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index 2843eff8..3cfcdba2 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -388,6 +388,11 @@ fn spell_visual_wire_golden() { cast_time_ms, target, ammo_display_id, + // This body writes a distinct guid in slot 1, as an item-cast START does, so the + // event surfaces it — the same `Some(1)` the GO arm below asserts off the same + // shape. nampower's `SPELL_START_*` reports the cast item as `arg1`, which is why + // the event carries it at all. + item_caster, } => assert_eq!( ( caster, @@ -396,8 +401,9 @@ fn spell_visual_wire_golden() { cast_time_ms, target, ammo_display_id, + item_caster, ), - (creature, 133, 0x2, 3000, Some(0x55), None) + (creature, 133, 0x2, 3000, Some(0x55), None, Some(1)) ), other => panic!("spell start event, got {other:?}"), } diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 1ca2cfb4..13b9452c 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -632,6 +632,52 @@ Three decisions worth having written down, because each had a tempting wrong ans fields. Nothing in the corpus reads the failing branch — `Core.lua:5177` is one `if success == 1 then` with no else. +### nampower's cast events — the ten off the one queue + +**Built.** All ten ride `ui_cast_events.rs`'s existing `CastEventFeed` rows — the same rows +`UNIT_CASTEVENT` and the cast windows are folded from — with `NpCastColumns` carrying the fields +nampower's shapes want and SuperWoW's word has no place for. One row per packet, folded once, which +is this file's own rule: an addon woken by one of these and then asking a getter must not be able +to get a contradicting answer, and a parallel queue reading the same packets is exactly how that +happens. + +`NP_EnableSpellStartEvents` and `NP_EnableSpellGoEvents` are now **live** (they were stored-only), +gating the START and GO pairs; the other five have no switch in the DLL's own CVar table and fire +unconditionally, as upstream's do. Both are written to `"1"` at login by `NampowerAPI.lua:2894-2902` +and by pfUI's `libs/libdebuff.lua:56-88`. + +The stored-only rows claimed their shapes wanted "itemId/spellType/corpseOwnerGuid columns this +client does not carry". Two of those three turned out to be a `Spell.dbc` lookup and a guid hop the +item-cooldown path already made: + +| Column | Where it comes from | +|---|---| +| `itemId` (`arg1` of START and GO) | the cast item's guid through the same two hops `item_cooldown` makes (the guid index, then `OBJECT_FIELD_ENTRY`), now one shared `item_entry` helper. `SMSG_SPELL_START` carries the guid and `SessionEvent::SpellStart` was dropping it; it carries it now, as the GO's always did | +| `spellType` (`arg8` of START: 0 normal, 1 channel, 2 autorepeat) | the spell's own attributes — `AttributesEx & 0x44` for a channel, the auto-repeat bit for a shot. The wire says nothing about either | +| `channel_ms` (`arg7` of START) | `SpellDuration.dbc`, under exactly the conditions `aura_duration_ms` will answer one at all. Not on the wire: a channeled spell's START carries `cast_time_ms` 0 and the length arrives a round trip later in `MSG_CHANNEL_START` — which is the case pfUI's reader names ("only for true channels like Blizzard where arg6 is 0/nil") | +| `numTargetsHit`/`numTargetsMissed` (`arg6`/`arg7` of GO) | the packet's own two lists, capped at 255 because the server writes both counts as `u8`s | +| `spellResult` (`arg2` of `SPELL_FAILED_SELF`) | the failing `SMSG_CAST_RESULT`'s reason byte — the number 1.12 turns into a red line and discards | +| `corpseOwnerGuid` | **not sent**, and this is the one that really is absent. A trailing nil in Lua is indistinguishable from an absent return, and upstream sends nil for every cast without a corpse target. For the handful that have one the gap is bounded: `read_spell_cast_targets` reads the corpse guid and drops it, and the *owner* is a further hop through that corpse object's `CORPSE_FIELD_OWNER`. Nothing in grom's corpus reads the column | + +Two subtleties worth having written down: + +- **A channel's START is queued although its wire cast time is 0, and `UNIT_CASTEVENT`'s START is + still suppressed for it.** Without the row, `SPELL_START_SELF` would never fire for Mind Flay or + Blizzard; with the word, every cast-tracking addon on this realm would get a SuperWoW START whose + `arg5` is 0 — a cast bar told to run for no time. The suppression moved from the producer + (`cast_time_ms > 0`) to the fire, so SuperWoW's word behaves exactly as it did before the row + existed. An instant is the other zero case, and the wiki's own START/CAST split says its only + edge is the CAST. +- **The `_SELF`/`_OTHER` split is by caster here, and by *target* for the aura family next door.** + Neither is a guess: upstream's wording for the aura pair is "casts that land on the active + player", and for this one pfUI handles both names in a single arm and then reads `arg3` to learn + the caster — which only makes sense if the name already settled it. + +**A gate hole closed on the way past.** `AURA_CAST_ON_SELF`/`_OTHER` were fired through one `&str` +variable, and `reference_ui.rs`'s beyond-1.12 event gate reads the string literal *at the call* — so +it had been blind to that pair for as long as they had existed. Both fire sites are literals now, +with their rows; verified by probe (renaming either row makes the gate name it). + ### The whole registered list, accounted for With the registration table read (the section above), the surface is no longer a count of what a @@ -665,9 +711,9 @@ from the consumer side — it destructures `arg2`=spellId, `arg3`=casterGuid, `a | Bucket | Names | |---|---| -| **Fired** | `BUFF_ADDED_SELF`, `BUFF_REMOVED_SELF`, `DEBUFF_ADDED_SELF`, `DEBUFF_REMOVED_SELF`, `DEBUFF_ADDED_OTHER`, `DEBUFF_REMOVED_OTHER`, `AURA_CAST_ON_SELF`, `AURA_CAST_ON_OTHER` (`ui_aura.rs`, `ui_cast_events.rs`), `SPELL_CAST_EVENT` (`ui_self_cast.rs`) | +| **Fired** | the aura eight — `BUFF_ADDED_SELF`, `BUFF_REMOVED_SELF`, `DEBUFF_ADDED_SELF`, `DEBUFF_REMOVED_SELF`, `DEBUFF_ADDED_OTHER`, `DEBUFF_REMOVED_OTHER`, `AURA_CAST_ON_SELF`, `AURA_CAST_ON_OTHER` (`ui_aura.rs`, `ui_cast_events.rs`) — the cast ten: `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`, `SPELL_CHANNEL_UPDATE` (`ui_cast_events.rs`), and `SPELL_CAST_EVENT` (`ui_self_cast.rs`) | | **Deliberately absent** | `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` — diffing the helpful half of every streamed unit, which nothing registers (the `AuraEdge` doc says so at the enum); `SPELL_QUEUE_EVENT` — the queue; `SPELL_DELAYED_OTHER` — upstream's own note is that the server only sends the packet to the affected player, so it never fires there either | -| **To fire, off packets already parsed** | `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`, `SPELL_CHANNEL_UPDATE` — all of these are edges `ui_cast_events.rs`'s one queue already carries for `UNIT_CASTEVENT`; what each needs beyond today's row is named in that file | + | **To fire, needs its own producer** | `UNIT_DIED`, the thirteen `UNIT_*_GUID`/`PLAYER_GUILD_UPDATE_GUID` rows (one shape: `guid, isPlayer, isTarget, isMouseover, isPet, partyIndex, raidIndex`, gated by the `NP_EnableUnitEvents*` CVars already registered), `UNIT_COMBAT_GUID`, `SPELL_DAMAGE_EVENT_SELF`/`_OTHER`, `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF`, `SPELL_ENERGIZE_*`, `SPELL_MISS_SELF`/`_OTHER`, `SPELL_DISPEL_BY_*`, `DAMAGE_SHIELD_*`, `ENVIRONMENTAL_DMG_*`, `AUTO_ATTACK_SELF`/`_OTHER`, `BUFF_UPDATE_DURATION_SELF`, `DEBUFF_UPDATE_DURATION_SELF`, `KEY_DOWN`/`KEY_UP` |