app+protocol: nampower's combat-log family — 35 events off one decode

Every packet this family reports was already decoded: `combat_log/`
builds its chat lines from exactly these structs. `ui_np_events.rs` is a
second consumer of that decode, not a second decoder, which is also why
its arguments cannot drift from the chat log's.

Built: `SPELL_DAMAGE_EVENT_*`, `AUTO_ATTACK_*`, the three `SPELL_HEAL_*`,
the three `SPELL_ENERGIZE_*`, `SPELL_MISS_*`, `DAMAGE_SHIELD_*`,
`ENVIRONMENTAL_DMG_*`, `SPELL_DISPEL_BY_*`, `UNIT_DIED`, the
`*_UPDATE_DURATION_SELF` pair, the thirteen `UNIT_*_GUID`/
`PLAYER_GUILD_UPDATE_GUID` rows and `UNIT_COMBAT_GUID`.

**A registration is the switch.** Upstream's README calls every
`NP_Enable*Events` CVar deprecated — "registering the event is
preferred" — and three of the eleven families have no CVar at all, so a
CVar-only reading would leave `SPELL_DAMAGE_EVENT_*` unreachable for
every addon and `SPELL_ENERGIZE_*` unreachable for all of them, since
nothing installed writes its CVar. `refresh_listeners` reads the VM's own
registration table (the same one `GetFramesRegisteredForEvent` reads), so
a family is wanted when its CVar is set OR something is listening. That
is also what keeps the cost honest: with nobody registered a damage
packet queues nothing, rather than building eight arguments for a fire
that reaches no frame.

Nine `NP_*` CVars move from stored-only to LIVE, and
`NP_EnableSpellEnergizeEvents` is registered for the first time — the
first pass took the names off `NampowerSettings`' writes rather than off
the DLL's own CVar table. The two reasons written at the auto-attack and
heal rows were both about the *cast* path, and both were answered by the
combat-log decode that was already there.

The descriptor indices the GUID-keyed family watches are written out and
then cross-checked against the two tables the tree already keeps —
`unit_rec::FIELDS` and the `FIELD_UNIT_*` constants. A hand-written
descriptor index is exactly the number that is plausibly wrong and
silently so: the event simply never fires, which no amount of play
distinguishes from "the server never sent one".

`AttackerState` gains `sub_damage_count`, which is `AUTO_ATTACK_*`'s
arg6; vmangos writes one block, but a server splitting a swing across
schools sends more.

Two test notes worth keeping: the edge tests run through the schedule,
because `run_system_once` builds a fresh system each call and the
`MessageReader` cursor restarts — a test written that way passes whatever
the system does with the second edge. And the data-gated suite held at
exactly 269, after the event gate correctly named all 35 new names.

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 16:53:28 -06:00
co-authored by Claude Opus 5
parent a093f3c476
commit 68f397af72
8 changed files with 1892 additions and 61 deletions
+35 -61
View File
@@ -1289,44 +1289,42 @@ pub(crate) const REGISTERED: &[Registered] = &[
// GUID-keyed unit events, so an addon can cut the stream to the units it watches.
// `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all six at setup.
//
// STORED ONLY, and the reason is one step further back than the other `NP_Enable*` rows:
// benilla fires no GUID-keyed unit event at all — no `UNIT_HEALTH_GUID`, `UNIT_MANA_GUID`,
// `UNIT_RAGE_GUID`, `UNIT_ENERGY_GUID` or `UNIT_COMBAT_GUID` — so there is no stream for a
// filter to narrow. Registering them is what stops the engine's unknown-CVar warning and lets
// the addon's writes persist; it is not a claim that the events arrive. Defaults are
// upstream's own documented `0`.
// **All six are LIVE**, which reverses the reason written here for one round: it said there
// was no stream for a filter to narrow, which was true until `ui_np_events.rs` built one.
// The thirteen `UNIT_*_GUID` names and `UNIT_COMBAT_GUID` now fire off the descriptor-edge
// stream `net::FieldChanged` already carries, so the switches select rather than pretend.
// Defaults stay upstream's own documented `0`.
ours(
"NP_EnableUnitEventsGuid",
"0",
"nampower v2 addon-compat, STORED ONLY: benilla fires no GUID-keyed unit event, so there \
is no stream for this switch to open",
"nampower v2 addon-compat, LIVE: the gate on the thirteen `UNIT_*_GUID` names and \
`UNIT_COMBAT_GUID` — see crate::ui_np_events::NpEventGates",
),
ours(
"NP_EnableUnitEventsGuidFiltering",
"0",
"nampower v2 addon-compat, STORED ONLY: the filter half of `NP_EnableUnitEventsGuid`, and \
there is nothing to filter",
"nampower v2 addon-compat, LIVE: whether the four token switches below narrow the family \
at all; off, every unit passes — see crate::ui_np_events::UnitContext::passes",
),
ours(
"NP_EnableUnitEventsMouseover",
"0",
"nampower v2 addon-compat, STORED ONLY: which tokens fire the GUID-keyed unit events, \
none of which this client raises",
"nampower v2 addon-compat, LIVE: lets the mouseover through the filter above",
),
ours(
"NP_EnableUnitEventsParty",
"0",
"nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`",
"nampower v2 addon-compat, LIVE: lets a party member through the filter",
),
ours(
"NP_EnableUnitEventsPet",
"0",
"nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`",
"nampower v2 addon-compat, LIVE: lets the player's pet through the filter",
),
ours(
"NP_EnableUnitEventsRaid",
"0",
"nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`",
"nampower v2 addon-compat, LIVE: lets a raid member through the filter",
),
ours(
"NP_EnableSpellGoEvents",
@@ -1334,17 +1332,31 @@ pub(crate) const REGISTERED: &[Registered] = &[
"nampower v2 addon-compat, LIVE: the gate on `SPELL_GO_SELF`/`_OTHER`, whose two target \
counts come off the same `SMSG_SPELL_GO` hit and miss lists",
),
// Both were STORED ONLY for the reason written at each: no melee swing edge, and nothing
// reading `SMSG_SPELLHEALLOG`. Both reasons were about the *cast* path, and both turned out
// to be answered from the combat-log decode that was already there — the swing is
// `SMSG_ATTACKERSTATEUPDATE`, which `combat_log/` has parsed all along to write its chat
// line. `ui_np_events.rs` is the second consumer of that same decode.
ours(
"NP_EnableAutoAttackEvents",
"0",
"nampower v2 addon-compat, STORED ONLY: benilla fires no `AUTO_ATTACK_SELF`/`_OTHER` — \
the melee swing raises no cast edge here to hang one on",
"nampower v2 addon-compat, LIVE: the gate on `AUTO_ATTACK_SELF`/`_OTHER`, off \
`SMSG_ATTACKERSTATEUPDATE` — see crate::ui_np_events::NpEventGates",
),
ours(
"NP_EnableSpellHealEvents",
"0",
"nampower v2 addon-compat, STORED ONLY: benilla fires no `SPELL_HEAL_BY_SELF`/`_OTHER`/\
`_ON_SELF` — nothing reads `SMSG_SPELLHEALLOG`'s amount",
"nampower v2 addon-compat, LIVE: the gate on `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF`, \
off `SMSG_SPELLHEALLOG` and a `PERIODIC_HEAL` tick",
),
// The seventh `NP_Enable*` row, registered a round after the other six: upstream has it and
// nothing installed here writes it, which is why it was missed — the first pass took the
// names off `NampowerSettings`' own writes rather than off the DLL's CVar table.
ours(
"NP_EnableSpellEnergizeEvents",
"0",
"nampower v2 addon-compat, LIVE: the gate on `SPELL_ENERGIZE_BY_SELF`/`_BY_OTHER`/\
`_ON_SELF`, off `SMSG_SPELLENERGIZELOG` and a `PERIODIC_ENERGIZE` tick",
),
// ── SuperWoW-era `ShowVKeyCastbar` ────────────────────────────────────────────────────────
//
@@ -4925,48 +4937,10 @@ mod tests {
"NP_RetryServerRejectedSpells",
"NampowerSettings' behaviour toggles",
),
// nampower v2's event-class switches. Three are deliberately NOT in this list —
// `NP_EnableAuraCastEvents`, `NP_EnableSpellStartEvents` and
// `NP_EnableSpellGoEvents` — because `ui_cast_events.rs` reads each as a real event
// gate and must keep proving so. The two below name event classes benilla emits none
// of (each row in `REGISTERED` says what it would take), so pfUI's libdebuff — which
// writes all five at `PLAYER_LOGIN` — is their only reader.
(
"NP_EnableAutoAttackEvents",
"pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no AUTO_ATTACK_* here",
),
(
"NP_EnableSpellHealEvents",
"pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no SPELL_HEAL_* here",
),
// The six unit-event switches: `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all
// six at setup. One step further back than the four above — there is no GUID-keyed
// unit event here for a token filter to narrow, so nothing could read them even in
// principle until that family exists.
(
"NP_EnableUnitEventsGuid",
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
),
(
"NP_EnableUnitEventsGuidFiltering",
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
),
(
"NP_EnableUnitEventsMouseover",
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
),
(
"NP_EnableUnitEventsParty",
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
),
(
"NP_EnableUnitEventsPet",
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
),
(
"NP_EnableUnitEventsRaid",
"SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here",
),
// **Every `NP_Enable*` row has left this list.** All seven are read by
// `ui_cast_events.rs` or `ui_np_events.rs` as a real event gate and must keep
// proving so — which is the whole point of the orphan test: the day one of them
// stops being read, it belongs back here with a reason, not silently.
// SuperWoW-era plate cast bar: pfUI's nameplates module writes 0 at load. benilla's
// V-key plates draw no cast bar, so nothing here reads it — see the row's own note.
(
+2
View File
@@ -67,6 +67,7 @@ use crate::ui_mail::UiMailPlugin;
use crate::ui_merchant::UiMerchantPlugin;
use crate::ui_mirror::UiMirrorPlugin;
use crate::ui_net::UiNetPlugin;
use crate::ui_np_events::NpEventsPlugin;
use crate::ui_party::UiPartyPlugin;
use crate::ui_pass::PlayerUiPlugin;
use crate::ui_pet::UiPetPlugin;
@@ -261,6 +262,7 @@ impl PluginGroup for GamePlugins {
.add(UiCastPlugin)
.add(UiMirrorPlugin)
.add(SuperWowEventsPlugin)
.add(NpEventsPlugin)
.add(UiSelfCastPlugin)
.add(crate::combat_text::CombatTextPlugin)
.add(crate::nameplates::NameplatesPlugin)
+1
View File
@@ -177,6 +177,7 @@ mod ui_merchant;
mod ui_mirror;
mod ui_models;
mod ui_net;
mod ui_np_events;
mod ui_party;
mod ui_pass;
mod ui_pet;
File diff suppressed because it is too large Load Diff
@@ -2760,6 +2760,168 @@ mod tests {
"BAG_NEW_ITEMS_UPDATED",
"ClassicAPI's new-item marker, fired from its own callback registry",
),
// ── nampower's combat-log family (`ui_np_events.rs`) ──────────────────────────
//
// All of these exist for one reason, so it is written once here rather than
// thirty-five times: 1.12 reports a hit by composing a **localized sentence** and
// firing `CHAT_MSG_SPELL_*` with it, so every damage meter of the era is a parser of
// grammar. nampower hooks the same packet handlers and fires the numbers. An addon
// written against it registers the exact spelling below or hears nothing.
(
"SPELL_DAMAGE_EVENT_SELF",
"nampower: a spell's damage as numbers — target, caster, spell id, amount, the \
absorb/block/resist triple, hitInfo, school, and the spell's own effect columns. \
The alternative on the 1.12 surface is parsing CHAT_MSG_SPELL_SELF_DAMAGE's \
sentence, which is localized and lossy",
),
(
"SPELL_DAMAGE_EVENT_OTHER",
"nampower: the same hit when somebody else dealt it; the split is by caster",
),
(
"AUTO_ATTACK_SELF",
"nampower: a melee swing's own report off SMSG_ATTACKERSTATEUPDATE, with the \
victim state and the sub-damage count. 1.12 fires UNIT_COMBAT for the player \
and target tokens only, and names no attacker",
),
(
"AUTO_ATTACK_OTHER",
"nampower: the swing half above for every other attacker",
),
(
"SPELL_HEAL_BY_SELF",
"nampower: a heal's amount off SMSG_SPELLHEALLOG, which 1.12 publishes only as \
chat text. pfUI's libpredict reads the family to correct its own prediction",
),
(
"SPELL_HEAL_BY_OTHER",
"nampower: a heal cast by anyone else",
),
(
"SPELL_HEAL_ON_SELF",
"nampower: a heal landing on the player, whoever cast it. A self-heal fires this \
AND BY_SELF, which is the only reading under which all three names mean \
something distinct",
),
(
"SPELL_ENERGIZE_BY_SELF",
"nampower: a power gain's type and amount off SMSG_SPELLENERGIZELOG. 1.12's own \
UNIT_MANA says a bar moved and never says why",
),
(
"SPELL_ENERGIZE_BY_OTHER",
"nampower: a power gain somebody else caused",
),
(
"SPELL_ENERGIZE_ON_SELF",
"nampower: a power gain landing on the player; the same two-of-three shape as \
the heal family",
),
(
"SPELL_MISS_SELF",
"nampower: one row of SMSG_SPELLLOGMISS, with the miss reason as a number rather \
than as the word a chat line would have spelled it",
),
(
"SPELL_MISS_OTHER",
"nampower: the miss half above for another caster",
),
(
"DAMAGE_SHIELD_SELF",
"nampower: a Thorns-style return hit off SMSG_SPELLDAMAGESHIELD; arg1 is the \
shield's bearer, which is the packet's own first guid",
),
(
"DAMAGE_SHIELD_OTHER",
"nampower: the return hit above when the shield was not the player's",
),
(
"ENVIRONMENTAL_DMG_SELF",
"nampower: falling, drowning, lava and fatigue as a type and three numbers, off \
SMSG_ENVIRONMENTALDAMAGELOG",
),
(
"ENVIRONMENTAL_DMG_OTHER",
"nampower: the same for another unit",
),
(
"SPELL_DISPEL_BY_SELF",
"nampower: one event per aura a dispel removed, by spell id. 1.12's chat line \
names the aura in the player's own locale and nothing else",
),
(
"SPELL_DISPEL_BY_OTHER",
"nampower: a dispel somebody else landed",
),
(
"UNIT_DIED",
"nampower: a unit's death by guid. 1.12 has PLAYER_DEAD for the player alone and \
nothing at all for anyone else — a nameplate addon otherwise learns of a death \
only by polling UnitIsDead. Ungated, as upstream gates it with nothing",
),
(
"BUFF_UPDATE_DURATION_SELF",
"nampower: SMSG_UPDATE_AURA_DURATION as a slot and a time. 1.12 exposes a \
duration through GetPlayerBuffTimeLeft only by polling, so a bar either polls \
every frame or drifts",
),
(
"DEBUFF_UPDATE_DURATION_SELF",
"nampower: the harmful half of the pair above, split at aura slot 32",
),
// The thirteen GUID-keyed unit events share one argument shape — `guid, isPlayer, \
// isTarget, isMouseover, isPet, partyIndex, raidIndex` — and one reason: 1.12's own \
// `UNIT_*` events carry a **unit token**, so they fire only for the handful of units \
// the player happens to have a token for. An addon watching a crowd (a raid frame \
// set, a nameplate stack) cannot hear about a unit it has no token for at all.
(
"UNIT_HEALTH_GUID",
"nampower: UNIT_HEALTH for a unit the player holds no token for",
),
("UNIT_MANA_GUID", "nampower: UNIT_MANA by guid"),
("UNIT_RAGE_GUID", "nampower: UNIT_RAGE by guid"),
("UNIT_ENERGY_GUID", "nampower: UNIT_ENERGY by guid"),
(
"UNIT_PET_GUID",
"nampower: UNIT_PET by guid, off the summon field",
),
("UNIT_FLAGS_GUID", "nampower: UNIT_FLAGS by guid"),
(
"UNIT_AURA_GUID",
"nampower: UNIT_AURA by guid, on an edge anywhere in the unit's aura region",
),
(
"UNIT_DYNAMIC_FLAGS_GUID",
"nampower: UNIT_DYNAMIC_FLAGS by guid (tapped, lootable, feign death)",
),
(
"UNIT_NAME_UPDATE_GUID",
"nampower: UNIT_NAME_UPDATE by guid. The one name in the family whose edge is \
not a descriptor field — it comes from SMSG_NAME_QUERY_RESPONSE",
),
(
"UNIT_PORTRAIT_UPDATE_GUID",
"nampower: UNIT_PORTRAIT_UPDATE by guid, off the native display id",
),
(
"UNIT_MODEL_CHANGED_GUID",
"nampower: UNIT_MODEL_CHANGED by guid, off the display id",
),
(
"UNIT_INVENTORY_CHANGED_GUID",
"nampower: UNIT_INVENTORY_CHANGED by guid, off another player's public \
PLAYER_VISIBLE_ITEM block",
),
(
"PLAYER_GUILD_UPDATE_GUID",
"nampower: PLAYER_GUILD_UPDATE by guid, off PLAYER_GUILDID",
),
(
"UNIT_COMBAT_GUID",
"nampower: 1.12's UNIT_COMBAT with a guid and the hit's school, so a damage \
display can follow a unit it has no token for. The action word is the \
reference's own classification, shared with the stock event",
),
(
"QUEST_TURNED_IN",
"ClassicAPI. Carries the one fact reachable nowhere else on this surface: the \
@@ -22,6 +22,18 @@
use benilla_protocol::ObjectFields;
use benilla_ui::script::RecValue;
/// The descriptor index [`FIELDS`] gives for one name, for a reader that needs the index and
/// not the kind — `ui_np_events.rs`'s own index table checks itself against this, which is the
/// point of exposing it: two hand-written tables of the same numbers, each able to catch the
/// other, rather than one table nothing disagrees with.
#[cfg(test)]
pub(crate) fn index_of(name: &str) -> Option<u16> {
FIELDS
.iter()
.find(|(n, _, _)| *n == name)
.map(|(_, i, _)| *i)
}
/// `OBJECT_END`: every `UnitFields` index is relative to this.
const OBJECT_END: u16 = 6;
@@ -31,6 +31,10 @@ pub struct AttackerState {
/// `meleeSpellId` (`rec+0xac`), nonzero for an on-next-swing ability such as Heroic Strike; the
/// reference only ever compares it to zero (`0x625e40`, `0x6246ca`).
pub melee_spell_id: u32,
/// How many sub-damage blocks the packet carried. vmangos writes one, but the field is the
/// `subDamageCount` nampower's `AUTO_ATTACK_*` events report (`arg6`), so it is kept rather
/// than inferred: a server that splits a swing across schools sends more than one.
pub sub_damage_count: u8,
/// The first sub-damage block's school (0 physical to 6 arcane, `GetFirstSchoolInMask`), 0
/// with no blocks; a non-physical swing takes the combat log's `…SCHOOL` wording.
pub school: u8,
@@ -87,6 +91,7 @@ pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result<AttackerState
victim,
hit_info,
damage,
sub_damage_count: subs,
victim_state,
absorb,
resist,
+16
View File
@@ -60,6 +60,22 @@ impl UiScript {
fire_event_into(&self.lua, event, args);
}
/// Whether any frame is registered for `event`.
///
/// For a producer whose work is only worth doing if somebody is listening — nampower's
/// combat-log family, whose own CVar switches upstream now calls *deprecated* in favour of
/// exactly this test ("registering the event is preferred"). The same lookup
/// `GetFramesRegisteredForEvent` reads, so the two can never disagree about who is listening.
#[must_use]
pub fn has_listener(&self, event: &str) -> bool {
self.lua
.app_data_ref::<Model>()
.expect("model app_data set")
.event_to_frames
.get(event)
.is_some_and(|f| !f.is_empty())
}
/// [`Self::fire_event`], answering what the dispatch reached ([`EventReach`]). Nothing about
/// the fire changes; the number is simply kept rather than dropped, for the one caller that
/// has to explain a silent event afterwards (`benilla-app`'s `area::feed_zone_events`).