app+protocol: nampower's ten cast events, off the queue that already had them

All ten ride `ui_cast_events.rs`'s existing `CastEventFeed` rows — the
same rows `UNIT_CASTEVENT` and the cast windows are folded from — with a
new `NpCastColumns` carrying the fields nampower's shapes want and
SuperWoW's one 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 over the same packets is exactly how that happens.

`NP_EnableSpellStartEvents` and `NP_EnableSpellGoEvents` go from
stored-only to LIVE. Their rows said the 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. `itemId` is the cast item's entry
through `item_entry` (now shared with `item_cooldown`, which had the
same two hops inline) — `SessionEvent::SpellStart` was dropping the
guid the GO always carried, so it carries it now. `spellType` is
`AttributesEx & 0x44` for a channel and the auto-repeat bit for a shot.
`channel_ms` is `SpellDuration.dbc`. The GO's two counts are the
packet's own lists, capped at 255 because the server writes both as
`u8`s. `SPELL_FAILED_SELF`'s result byte is the failing
`SMSG_CAST_RESULT`'s reason — the number 1.12 turns into a red line and
then discards, so no addon on that surface can see why a cast failed.

`corpseOwnerGuid` is the one column that really is absent, and it is
not sent at all: 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 and written
down — the targets block reads the corpse guid and drops it, and the
owner is a further hop through that corpse's own CORPSE_FIELD_OWNER.

Two subtleties, both pinned by tests:

- 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 — pfUI's
  reader expects exactly that row and falls back to arg7 "only for true
  channels like Blizzard where arg6 is 0/nil". With the word, every
  cast-tracking addon on this realm would get a SuperWoW START whose
  arg5 is 0, which is a cast bar told to run for no time. So the
  suppression moved from the producer to the fire, and SuperWoW's word
  behaves exactly as it did before the row existed.

- 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 are literals now,
with their rows, and the probe confirms it: renaming either row makes
the gate name the event.

Tests read pfUI's own `local` bindings back out rather than a list of
values in push order, as the aura-cast block beside them does. A
positional shape got backwards here fails in silence — the handler binds
arg3 to casterGuid whatever arg3 happens to be — and this family is
where that would be most expensive, since pfUI's swing timer resets off
the START and its whole debuff bookkeeping gates on the GO's two counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 08:00:19 -06:00
co-authored by Claude Opus 5
parent ecee994ab6
commit dc59594389
8 changed files with 877 additions and 68 deletions
+21 -25
View File
@@ -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::<crate::ui_cast_events::AuraCastFeed>();
app.init_resource::<crate::ui_cast_events::NpCastGates>();
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",
+91 -10
View File
@@ -239,6 +239,7 @@ fn on_spell_start(In(ev): In<SessionEvent>, 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<SessionEvent>, 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<SessionEvent>, 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<u32> {
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());
}
}
+619 -22
View File
@@ -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<u64>,
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<crate::cvars::CvarChanged>, mut feed: ResMut<AuraCastFeed>) {
pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut feed: ResMut<AuraCastFeed>,
mut gates: ResMut<NpCastGates>,
) {
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<CastEventFeed>,
mut windows: ResMut<CastWindows>,
mut auras: ResMut<AuraCastFeed>,
gates: Res<NpCastGates>,
self_guid: Res<crate::net::SelfGuid>,
) {
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::<CastEventFeed>()
.init_resource::<CastWindows>()
.init_resource::<AuraCastFeed>()
.init_resource::<NpCastGates>()
// 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::<CastEventFeed>();
app.init_resource::<CastWindows>();
app.init_resource::<AuraCastFeed>();
app.init_resource::<NpCastGates>();
app.init_resource::<crate::net::SelfGuid>();
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::<CastEventFeed>();
app.init_resource::<CastWindows>();
app.init_resource::<AuraCastFeed>();
app.init_resource::<NpCastGates>();
app.init_resource::<crate::net::SelfGuid>();
app.world_mut()
.resource_mut::<CastEventFeed>()
.push(0x2a, None, CastEdge::Cast, 133, 0);
@@ -1004,6 +1249,8 @@ mod tests {
app.init_resource::<CastEventFeed>();
app.init_resource::<CastWindows>();
app.init_resource::<AuraCastFeed>();
app.init_resource::<NpCastGates>();
app.init_resource::<crate::net::SelfGuid>();
let mut script = UiScript::new().unwrap();
script.set_now(100.0);
script.set_spellbook(SpellBookState {
@@ -1063,6 +1310,8 @@ mod tests {
app.init_resource::<CastEventFeed>();
app.init_resource::<CastWindows>();
app.init_resource::<AuraCastFeed>();
app.init_resource::<NpCastGates>();
app.init_resource::<crate::net::SelfGuid>();
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::<CastEventFeed>()
.init_resource::<CastWindows>()
.init_resource::<AuraCastFeed>()
.init_resource::<NpCastGates>()
.init_resource::<crate::ui_cast::CastBarFeed>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
@@ -2433,7 +2690,9 @@ end)
let mut app = App::new();
app.init_resource::<CastEventFeed>()
.init_resource::<CastWindows>()
.init_resource::<AuraCastFeed>();
.init_resource::<AuraCastFeed>()
.init_resource::<NpCastGates>()
.init_resource::<crate::net::SelfGuid>();
app.world_mut().resource_mut::<AuraCastFeed>().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::<CastEventFeed>()
.init_resource::<CastWindows>()
.init_resource::<AuraCastFeed>()
.init_resource::<NpCastGates>()
.init_resource::<crate::net::SelfGuid>();
app.world_mut().resource_mut::<NpCastGates>().start = start;
app.world_mut().resource_mut::<NpCastGates>().go = go;
app.world_mut().resource_mut::<crate::net::SelfGuid>().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<String> {
app.world()
.non_send_resource::<UiScript>()
.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::<CastEventFeed>();
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::<CastEventFeed>()
.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::<CastEventFeed>();
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::<CastEventFeed>().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::<CastEventFeed>();
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::<NpCastGates>().go = true;
app.world_mut().resource_mut::<CastEventFeed>().push(
ME,
Some(0x63),
CastEdge::Cast,
133,
0,
);
app.world_mut().resource_mut::<CastEventFeed>().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::<UiScript>()
.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::<CastEventFeed>();
// 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::<UiScript>()
.eval::<Vec<String>>("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::<CastWindows>().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::<CastEventFeed>()
.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::<AuraCastFeed>().add_observer(on_cvar);
app.init_resource::<AuraCastFeed>()
.init_resource::<NpCastGates>()
.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(),
@@ -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 \
+4
View File
@@ -552,6 +552,10 @@ pub enum SessionEvent {
target: Option<u64>,
/// The `CAST_FLAG_AMMO` tail: nocked ammo for any caster (`0x60ba30` from `0x6e78b6`).
ammo_display_id: Option<u32>,
/// 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<u64>,
},
/// The cast launched (`SMSG_SPELL_GO`); the server times impact off `Spell.dbc` Speed itself.
SpellGo {
+14 -8
View File
@@ -203,14 +203,20 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
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 {
+7 -1
View File
@@ -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:?}"),
}
+48 -2
View File
@@ -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` |