app: the rest of nampower's CVar table — 49/49, five of them live

`strings nampower.dll | grep -oE '\bNP_[A-Za-z]+\b'` is the DLL's whole
CVar table and `comm -23` against `cvars.rs` now prints nothing. Fifteen
names were missing, for the same reason the function list started wrong:
the first pass took them off `NampowerSettings`' writes rather than off
the binary.

Live: `NP_ChatBubbleDistance` (the same `BubbleConfig::range_sq`
SuperWoW's `ChatBubbleRange` writes — both DLLs hook one cull, so last
write wins there too), the three `NP_ChatBubbles*` kind toggles, and
`NP_EnableLocalSetRaidTarget`, which routes a solo `SetRaidTarget` to the
local board instead of a packet the server has nobody to echo to.

Two deliberate deviations, both about what a player who never touched a
setting gets:

`NP_ChatBubbleDistance` ships 20 yd, the reference's own cull, not
upstream's 60. Tripling the distance bubbles appear at is a visible
change to the game nobody asked for; writing 60 gets 60.

The six `NP_EnableUnitEvents*` rows keep a default of 0, where upstream's
README says 1 and its own `EVENTS.md` says 0. The disagreement costs
nothing under the registration rule the event family uses: a family is
wanted when its CVar is set or something registered the event.

Nine stored-only rows each say what building them would take. One is a
real gap rather than a shrug: `NP_PreventMountingWhenBuffCapped`
defaults ON upstream, so a real install refuses a mount cast at 32 buffs
instead of letting the server eat it. Everything it needs is here; the
test belongs inside `send_spell_cast`, which already takes 22 parameters
and none of them is the player's own descriptor — and it refuses a press,
so it wants a live test rather than being bolted on at the end of a
round.

`KEY_DOWN`/`KEY_UP` stay unbuilt, which leaves nampower's event table at
57 of 59 with both absences explained. They need the client's numeric
`KEY_*` enum, a table in `WoW.exe` this round did not read: the DLL
publishes four of its values, enough to see the shape (ASCII in bank 0,
specials at 0x200, function keys at 0x300) and not enough to name a key
in bank 2 past the first. A guessed code is worse than no event, since an
addon switching on arg1 would silently never match — and they are the
keyboard hook the press-interception queue uses, so they belong with that
decision.

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 17:19:35 -06:00
co-authored by Claude Opus 5
parent 68f397af72
commit fc4eb2c827
5 changed files with 359 additions and 2 deletions
+59 -1
View File
@@ -49,7 +49,20 @@ pub(crate) struct BubbleConfig {
/// hardcoded 20 yd (see [`MAX_DIST_SQ`]'s comment) made tunable. No shipped default is
/// published for this one either (only the mod's documented 10-200 yd valid range), so this
/// keeps today's exact 20 yd rather than guess at a different one.
///
/// **nampower's `NP_ChatBubbleDistance` writes the same field**, and that is not a conflict
/// to resolve: both DLLs hook this one cull, so with both injected the last write wins, which
/// is what happens here too. Its own documented default is 60 yd, deliberately *not* applied
/// — see the row in `cvars.rs`.
pub(crate) range_sq: f32,
/// nampower's `NP_ChatBubblesWhisper`: whispers and whisper-informs, which the reference
/// bubbles never.
pub(crate) whisper: bool,
/// nampower's `NP_ChatBubblesRaid` — the raid *leader* and *warning* kinds, which is what
/// upstream's own wording names, not plain raid chat.
pub(crate) raid: bool,
/// nampower's `NP_ChatBubblesBattleground`: the battleground and battleground-leader kinds.
pub(crate) battleground: bool,
}
impl Default for BubbleConfig {
@@ -58,6 +71,10 @@ impl Default for BubbleConfig {
all: true,
party: false,
range_sq: MAX_DIST_SQ,
// nampower's own defaults for the three: off.
whisper: false,
raid: false,
battleground: false,
}
}
}
@@ -102,6 +119,13 @@ fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option<bool> {
match kind {
K::Party => Some(cfg.party),
K::Say | K::Yell | K::MonsterSay | K::MonsterYell => Some(cfg.all),
// nampower's three extra kinds. Each stays `Some(false)` rather than `None` when its
// switch is off, because the two answers differ where it matters: `None` is "this kind
// never bubbles", which is what the trace prints and what a future reader would take as
// settled, and these kinds now can.
K::Whisper | K::WhisperInform => Some(cfg.whisper),
K::RaidLeader | K::RaidWarning => Some(cfg.raid),
K::Battleground | K::BattlegroundLeader => Some(cfg.battleground),
_ => None,
}
}
@@ -672,6 +696,12 @@ pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut bubbles: ResMut<Bub
"chatbubbles" => bubbles.all = ev.flag(),
"chatbubblesparty" => bubbles.party = ev.flag(),
"chatbubblerange" => bubbles.range_sq = ev.num().clamp(10.0, 200.0).powi(2),
// nampower's four. The distance shares `range_sq` with SuperWoW's name above and takes
// the same clamp: a bubble cull of 0 or of a mile is not a setting either mod offers.
"np_chatbubbledistance" => bubbles.range_sq = ev.num().clamp(10.0, 200.0).powi(2),
"np_chatbubbleswhisper" => bubbles.whisper = ev.flag(),
"np_chatbubblesraid" => bubbles.raid = ev.flag(),
"np_chatbubblesbattleground" => bubbles.battleground = ev.flag(),
_ => {}
}
}
@@ -739,6 +769,7 @@ mod tests {
all: true,
party: true,
range_sq: MAX_DIST_SQ,
..Default::default()
};
for k in [K::Say, K::Yell, K::MonsterSay, K::MonsterYell] {
assert_eq!(bubble_cvar(k, &on), Some(true));
@@ -751,7 +782,6 @@ mod tests {
for k in [
K::Guild,
K::Officer,
K::Whisper,
K::Emote,
K::TextEmote,
K::System,
@@ -760,6 +790,33 @@ mod tests {
] {
assert_eq!(bubble_cvar(k, &on), None, "{k:?} must not bubble in v1");
}
// nampower's three extra kinds: off by its own default, and `Some(false)` rather than
// `None` — the distinction matters, since `None` is the answer for a kind that can never
// bubble. `K::Whisper` was in the list above until this family was built.
let np = BubbleConfig {
whisper: true,
raid: true,
battleground: true,
..Default::default()
};
for k in [
K::Whisper,
K::WhisperInform,
K::RaidLeader,
K::RaidWarning,
K::Battleground,
K::BattlegroundLeader,
] {
assert_eq!(bubble_cvar(k, &on), Some(false), "{k:?} is off by default");
assert_eq!(bubble_cvar(k, &np), Some(true), "{k:?} with its switch on");
}
// Plain raid chat is *not* one of them: upstream's `NP_ChatBubblesRaid` names the leader
// and warning kinds, and reading it as "raid chat" would bubble every /raid line.
assert_eq!(
bubble_cvar(K::Raid, &np),
None,
"plain /raid still never bubbles"
);
// Party off with say on is the default pair, so this also pins the boot state.
let no_party = BubbleConfig::default();
@@ -778,6 +835,7 @@ mod tests {
all: false,
party: true,
range_sq: MAX_DIST_SQ,
..Default::default()
};
assert_eq!(bubble_cvar(K::Say, &no_say), Some(false));
assert_eq!(
+164
View File
@@ -1349,6 +1349,125 @@ pub(crate) const REGISTERED: &[Registered] = &[
"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 rest of the shipped DLL's CVar table ──────────────────────────────────────────────
//
// Fifteen names the first pass missed, and the reason it missed them is worth keeping: it
// took the list off `NampowerSettings`' own writes rather than off the DLL. `strings
// nampower.dll | grep -oE '\bNP_[A-Za-z]+\b'` is the whole table, and `comm -23` against
// this file's own rows is the check. Defaults below are upstream's README, which is also the
// one source that disagrees with `EVENTS.md`: the README gives the six `NP_EnableUnitEvents*`
// rows a default of **1** where `EVENTS.md` says 0. Those rows keep 0, because a default is
// not a behaviour here — `ui_np_events.rs` fires a family when its CVar is set OR something
// registered the event, and every addon that wants the stream does one or the other.
ours(
"NP_ChatBubbleDistance",
"20",
"nampower addon-compat, LIVE: the chat-bubble cull in yards, writing the same \
`BubbleConfig::range_sq` SuperWoW's `ChatBubbleRange` writes. Upstream's own default is \
**60**; this row ships **20**, the reference's own hardcoded cull, because a default is \
what a player who has never touched the setting gets, and tripling the distance bubbles \
appear at is a visible change to the game nobody asked for. Writing 60 gets 60",
),
ours(
"NP_ChatBubblesWhisper",
"0",
"nampower addon-compat, LIVE: bubbles for whisper and whisper-inform, which the \
reference bubbles never — see crate::chat_bubble::bubble_cvar",
),
ours(
"NP_ChatBubblesRaid",
"0",
"nampower addon-compat, LIVE: bubbles for the raid-leader and raid-warning kinds, which \
is what upstream's own wording names rather than plain raid chat",
),
ours(
"NP_ChatBubblesBattleground",
"0",
"nampower addon-compat, LIVE: bubbles for the battleground and battleground-leader kinds",
),
ours(
"NP_EnableLocalSetRaidTarget",
"1",
"nampower addon-compat, LIVE: `SetRaidTarget` falls back to the local-only marker table \
when the player is in no group, instead of sending a packet the server drops. The local \
path itself is SuperWoW's `\"local\"` argument, already built \
(`PartyRequest::SetRaidTarget.local`); this switch is what makes it the solo default, as \
upstream's own default of 1 does — see crate::ui_party::feed::LocalRaidTarget",
),
// ── Stored only, each with what it would take ─────────────────────────────────────────────
ours(
"NP_SpamProtectionEnabled",
"1",
"nampower addon-compat, STORED ONLY: blocks repeat presses while the server has not yet \
answered the first cast. That is the press-interception half of the queue, which is not \
built — see `NP_QueueCastTimeSpells` and the queue section of docs/ADDON_COMPAT.md",
),
ours(
"NP_QuickcastOnDoubleCast",
"0",
"nampower addon-compat, STORED ONLY: a second press casts a terrain-targeting spell at \
the cursor. Same press-interception half; `NP_QuickcastTargetingSpells` beside it is \
the one-press form of the same idea and is stored for the same reason",
),
ours(
"NP_PreventRightClickPvPAttack",
"0",
"nampower addon-compat, STORED ONLY: right-click on a PvP-flagged player does not start \
an attack. The sibling `NP_PreventRightClickTargetChange` is stored for the same reason \
— both need a hook in the right-click interact path, which here decides target, \
interact and attack in one pass (`target::cursor_mode`)",
),
ours(
"NP_PreventMountingWhenBuffCapped",
"1",
"nampower addon-compat, STORED ONLY, and this one is a real gap rather than a shrug: \
upstream defaults it ON, so a real install refuses a mount cast at 32 buffs instead of \
letting the server eat it. Everything it needs is here — `SPELL_AURA_MOUNTED` in the \
spell's `effect_apply_aura`, all 32 positive aura slots occupied, \
`unit_mount_display_id() == 0` — but the test belongs inside \
`spell::cast_send::send_spell_cast`, which already takes 22 parameters and none of them \
is the player's own descriptor. It also refuses a press, so it wants a live test rather \
than being bolted on",
),
ours(
"NP_EnableEnhancedTooltips",
"1",
"nampower addon-compat, STORED ONLY: upstream's own tooltip additions — a proc's chance, \
an internal cooldown, an item's cooldown as text. Authored content rather than a \
binding: the numbers come from `Spell.dbc` columns this client reads, but what the \
lines say and where they sit in the tooltip is upstream's design, and guessing it would \
produce a tooltip that is neither ours nor theirs",
),
ours(
"NP_PreserveGreaterDemonAutocast",
"1",
"nampower addon-compat, STORED ONLY: remembers a Felguard's or Doomguard's autocast \
toggles across a resummon. Needs pet-autocast state keyed by demon rather than by the \
live pet guid, and the three `*AutocastData` rows below are where upstream keeps it",
),
// The three data rows are not settings at all: upstream's own README calls them "managed
// automatically". They are registered so a write round-trips and persists — the same reason
// every stored-only row exists — and nothing here will ever read one, because the blob's
// shape is the DLL's private business.
ours(
"NP_FelguardAutocastData",
"",
"nampower addon-compat, STORED ONLY: upstream's own saved-state blob for \
`NP_PreserveGreaterDemonAutocast`, not a setting a player or an addon sets",
),
ours(
"NP_DoomguardAutocastData",
"",
"nampower addon-compat, STORED ONLY: see `NP_FelguardAutocastData`",
),
ours(
"NP_InfernalAutocastData",
"",
"nampower addon-compat, STORED ONLY: see `NP_FelguardAutocastData`. The one name in the \
DLL's CVar table that upstream's README does not document at all — it is in the binary, \
so it is registered; its default is this client's guess at nothing rather than a \
documented value",
),
// 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.
@@ -4568,6 +4687,11 @@ mod tests {
assert_eq!(
strings,
vec![
// nampower's three saved-state blobs. Not settings: upstream's README calls them
// "managed automatically", and they hold a pet's autocast toggles as text.
"NP_DoomguardAutocastData",
"NP_FelguardAutocastData",
"NP_InfernalAutocastData",
"graphicsQuality",
"gxApi",
"gxResolution",
@@ -4941,6 +5065,46 @@ mod tests {
// `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.
// The nine `NP_*` rows registered for the DLL's own table rather than for a reader
// here. Each row in `REGISTERED` says what building it would take; the reader named
// below is whoever actually writes it today, which for most of these is the settings
// panel and nothing else.
(
"NP_SpamProtectionEnabled",
"NampowerSettings panel writes it; the press-interception queue is not built",
),
(
"NP_QuickcastOnDoubleCast",
"NampowerSettings panel writes it; same press-interception half",
),
(
"NP_PreventRightClickPvPAttack",
"NampowerSettings panel writes it; no hook in the right-click interact path",
),
(
"NP_PreventMountingWhenBuffCapped",
"NampowerSettings panel writes it; the cast path has no refusal for it yet",
),
(
"NP_EnableEnhancedTooltips",
"NampowerSettings panel writes it; upstream's tooltip lines are not authored here",
),
(
"NP_PreserveGreaterDemonAutocast",
"NampowerSettings panel writes it; no per-demon autocast memory here",
),
(
"NP_FelguardAutocastData",
"upstream's own saved-state blob, managed automatically; nothing here reads it",
),
(
"NP_DoomguardAutocastData",
"upstream's own saved-state blob; see NP_FelguardAutocastData",
),
(
"NP_InfernalAutocastData",
"upstream's own saved-state blob; see NP_FelguardAutocastData",
),
// 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.
(
+57
View File
@@ -704,6 +704,36 @@ fn member_for_token<'a>(group: &'a GroupState, token: &str) -> Option<&'a GroupM
}
/// Drain the Lua party intents into their `CMSG_*` sends.
/// nampower's `NP_EnableLocalSetRaidTarget`, upstream's own default **on**: with no group,
/// `SetRaidTarget` marks this client's own board instead of sending a packet the server has
/// nobody to echo it to.
///
/// Worth one resource rather than a bool on [`GroupState`]: that resource is the roster the
/// server sends, and a client setting does not belong in it.
#[derive(Resource)]
pub(crate) struct LocalRaidTarget(pub(crate) bool);
impl Default for LocalRaidTarget {
fn default() -> Self {
Self(true)
}
}
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut local: ResMut<LocalRaidTarget>) {
if ev.is("NP_EnableLocalSetRaidTarget") {
local.0 = ev.flag();
}
}
/// Whether a `SetRaidTarget` marks this client's own board instead of sending.
///
/// `explicit` is SuperWoW's `"local"` argument, which always wins; the other two are nampower's
/// solo fallback. Split out as a function because it is the whole of the rule and the drain it
/// sits in needs a VM and seven resources to stand up — the rule is worth a test either way.
fn marks_locally(explicit: bool, solo_fallback: bool, in_group: bool) -> bool {
explicit || (solo_fallback && !in_group)
}
pub(super) fn drain_party(
script: Option<NonSendMut<UiScript>>,
mut group: ResMut<GroupState>,
@@ -711,6 +741,7 @@ pub(super) fn drain_party(
names: Res<NameCache>,
self_q: Query<&Guid, With<SelfPlayer>>,
commands: Res<NetCommands>,
solo_marks: Res<LocalRaidTarget>,
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
) {
let Some(mut script) = script else {
@@ -823,6 +854,11 @@ pub(super) fn drain_party(
t => member_for_token(&group, t).map(|m| m.guid),
};
let Some(guid) = guid else { continue };
// Solo, the wire leg marks nothing: the server answers `MSG_RAID_TARGET_UPDATE`
// by echoing to the group, and there is no group to echo to — so the mark simply
// never appears. nampower's switch turns that into the local path, which is the
// one this client already had for SuperWoW's `"local"` argument.
let local = marks_locally(local, solo_marks.0, group.in_group);
if local {
// SuperWoW's `"local"`: this client's own board only, nothing on the wire —
// the same mark the server's own echo would otherwise apply.
@@ -1350,6 +1386,27 @@ pub(crate) fn synthetic_raid(
#[cfg(test)]
mod tests {
use super::marks_locally;
/// nampower's `NP_EnableLocalSetRaidTarget`, which upstream defaults on: solo, the wire leg
/// marks nothing at all, because the server answers by echoing to a group that is not there.
#[test]
fn a_solo_raid_target_marks_the_local_board_and_a_grouped_one_still_sends() {
// Solo, switch on: local.
assert!(marks_locally(false, true, false));
// Solo, switch off: sent, and the mark never appears — upstream's pre-switch behaviour.
assert!(!marks_locally(false, false, false));
// In a group: sent, whatever the switch says, because the echo is what marks everyone.
assert!(!marks_locally(false, true, true));
assert!(!marks_locally(false, false, true));
// SuperWoW's explicit `"local"` wins in every combination; it is a different request.
for group in [false, true] {
for switch in [false, true] {
assert!(marks_locally(true, switch, group));
}
}
}
use super::*;
#[test]
+2
View File
@@ -26,7 +26,9 @@ impl Plugin for UiPartyPlugin {
fn build(&self, app: &mut App) {
net::register(app);
// A UI-only harness has no sound stack; the list handler queues the join chime.
app.add_observer(feed::on_cvar);
app.init_resource::<GroupState>()
.init_resource::<feed::LocalRaidTarget>()
.init_resource::<crate::sound::MessageSounds>()
.add_systems(
Update,
+77 -1
View File
@@ -966,7 +966,83 @@ from the consumer side — it destructures `arg2`=spellId, `arg3`=casterGuid, `a
| **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`), `SPELL_CAST_EVENT` (`ui_self_cast.rs`), and `SPELL_QUEUE_EVENT` (`ui_spellbook.rs`, off `spell/queue.rs`'s own rows — every mutation of the queue goes through `queue`/`pop`, so a code cannot be dropped by a caller that forgets to emit one) |
| **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_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, 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` |
| **To fire, needs its own producer** | `KEY_DOWN`/`KEY_UP`, and nothing else — see "nampower's combat-log family" below for the other thirty-five, which are now fired. The pair needs the client's own numeric `KEY_*` enum, which is a table in `WoW.exe` this round did not read: the DLL's strings publish four of its values (`KEY_NONE=-1`, `KEY_SHIFT=0`, `KEY_A=65`, `KEY_ESCAPE=512`, `KEY_F1=768`), enough to see the shape — ASCII in bank 0, specials at `0x200`, function keys at `0x300` — and not enough to name a key in bank 2 past the first. A guessed code is worse than no event: an addon switching on `arg1` would silently never match. They are also nampower's **keyboard hook**, the same hook the press-interception queue uses, so they belong with that decision rather than ahead of it |
### nampower's combat-log family — 35 events off a decode that was already there
The whole of the "needs its own producer" bucket above, bar two keys. `ui_np_events.rs`.
**Why it was cheap, and why the earlier reasons were wrong.** The rows above said
`AUTO_ATTACK_*` needed "a melee-swing edge no path here raises" and `SPELL_HEAL_*` needed "the
heal amount off `SMSG_SPELLHEALLOG`, which nothing reads yet". Both were about the **cast** path,
and both were false about the tree: `combat_log/` has parsed `SMSG_ATTACKERSTATEUPDATE`,
`SMSG_SPELLHEALLOG`, `SMSG_SPELLENERGIZELOG`, `SMSG_SPELLLOGMISS`, `SMSG_SPELLDISPELLOG`,
`SMSG_SPELLDAMAGESHIELD`, `SMSG_ENVIRONMENTALDAMAGELOG` and `SMSG_PERIODICAURALOG` since it was
written — that is where the chat lines come from. This module is a second consumer of the same
`SessionEvent`, which is also what stops its arguments drifting from the chat log's.
That is the general lesson of the last three rounds, now three for three: of the reasons written
down for why a nampower feature was not built, the ones that turned out to be wrong were wrong in
the same way — they described what one subsystem could not do, when the answer was in a different
subsystem that already did it.
| Event | Source | Note |
|---|---|---|
| `SPELL_DAMAGE_EVENT_SELF`/`_OTHER` | `SpellDamageLog`, and a `PERIODIC_DAMAGE` tick | `arg5` is upstream's comma-joined `absorb,block,resist`; `arg8` its `effect1,effect2,effect3,auraType` off `Spell.dbc` |
| `AUTO_ATTACK_SELF`/`_OTHER` | `AttackerState` | `sub_damage_count` was added to the struct for `arg6`: vmangos writes one block, a server splitting a swing across schools writes more |
| `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF` | `SpellHealLog`, `PERIODIC_HEAL` | A self-heal fires **two**, which is the only reading under which all three names mean something |
| `SPELL_ENERGIZE_BY_SELF`/`_BY_OTHER`/`_ON_SELF` | `SpellEnergizeLog`, `PERIODIC_ENERGIZE` | Same two-of-three shape |
| `SPELL_MISS_SELF`/`_OTHER` | `SpellLogMiss` | One event per row of the packet's own miss list |
| `DAMAGE_SHIELD_SELF`/`_OTHER` | `DamageShield` | `arg1` is the shield's **bearer**, which is the packet's first guid — nampower passes a packet's fields in wire order, which is what makes these argument lists checkable rather than guessed |
| `ENVIRONMENTAL_DMG_SELF`/`_OTHER` | `EnvironmentalDamageLog` | |
| `SPELL_DISPEL_BY_SELF`/`_OTHER` | `SpellDispelLog` | One per aura removed |
| `UNIT_DIED` | a `UNIT_FIELD_HEALTH` edge to zero | **The edge, not the dead flag**: the flag is also set by a create of an already-dead corpse, and `FieldChanged` deliberately raises nothing for a create — so the edge is what keeps "died" meaning died rather than "was first seen dead" |
| `BUFF_UPDATE_DURATION_SELF` / `DEBUFF_UPDATE_DURATION_SELF` | `SMSG_UPDATE_AURA_DURATION` | The packet carries a slot and a time; the spell id comes from the player's own aura block and the half from the slot, at the same 32 the block splits at. `arg3` is `GetTime()`×1000, **not** the reference's `GetTickCount` — that counter is unreachable from Lua, so an addon can only compare against `GetTime`, and the difference the argument is used for (`expiration − duration` = now) is the same either way |
| the thirteen `UNIT_*_GUID` / `PLAYER_GUILD_UPDATE_GUID` | `net::FieldChanged` | One shared producer over the descriptor-edge stream, one field to one name. `UNIT_NAME_UPDATE_GUID` is the exception: a name is not a descriptor field here, it arrives from `SMSG_NAME_QUERY_RESPONSE`, so it has its own handler on that packet |
| `UNIT_COMBAT_GUID` | `UnitCombatFeedback` | The same stream 1.12's own `UNIT_COMBAT` drains, so the action word is the reference's own classification rather than a second reading of the hit bits. Stock fires only for the tokens naming a unit, which is why the GUID form exists |
**A registration is the switch.** Upstream's README calls every `NP_Enable*Events` CVar
*deprecated* — "registering the event is preferred" — so `refresh_listeners` reads the VM's own
registration table (the same one `GetFramesRegisteredForEvent` reads) and a family is wanted when
its CVar is set **or** something is listening. Without that, three of the eleven families have no
CVar at all and would be unreachable for every addon, and `SPELL_ENERGIZE_*` unreachable for all
of them, since nothing installed writes its switch. It is also what keeps the cost honest: with
nobody registered, a damage packet queues nothing rather than building eight arguments and two
formatted strings for a fire that reaches no frame.
**The descriptor indices are written out and then cross-checked**, against `unit_rec::FIELDS`
(derived from 1.12's own block sizes) and the `FIELD_UNIT_*` constants `benilla-protocol` exports.
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".
**Nothing installed registers one of the thirty-five.** They are built because the surface is the
deliverable, not because an addon here is waiting — so this round changes nothing visible in play,
and that is the honest expectation to set before testing it.
### The CVar table, 49/49
`strings nampower.dll | grep -oE '\bNP_[A-Za-z]+\b'` is the DLL's whole CVar table, and
`comm -23` against `cvars.rs`'s own rows now prints nothing. Fifteen names were missing, and the
reason is the same mistake the function list started from: the first pass took the list off
`NampowerSettings`' own writes rather than off the binary.
| Row | State | Note |
|---|---|---|
| `NP_ChatBubbleDistance` | **LIVE** | Writes the same `BubbleConfig::range_sq` SuperWoW's `ChatBubbleRange` writes — not a conflict to resolve, since both DLLs hook one cull and the last write wins there too. Ships **20** yd, the reference's own cull, not upstream's 60: a default is what a player who never touched the setting gets, and tripling the distance bubbles appear at is a visible change nobody asked for |
| `NP_ChatBubblesWhisper` / `Raid` / `Battleground` | **LIVE** | Three kinds the reference bubbles never. `Raid` is the raid-**leader** and **warning** kinds, which is upstream's own wording — reading it as "raid chat" would bubble every `/raid` line |
| `NP_EnableLocalSetRaidTarget` | **LIVE** | Solo, the wire leg marks nothing at all: the server answers by echoing to a group that is not there. This switch routes it to the local board instead — the path SuperWoW's `"local"` argument already built |
| `NP_EnableSpellEnergizeEvents` | **LIVE** | The seventh `NP_Enable*`; nothing installed writes it, which is why it was missed |
| `NP_SpamProtectionEnabled`, `NP_QuickcastOnDoubleCast` | stored | The press-interception half of the queue, which is a separate decision |
| `NP_PreventRightClickPvPAttack` | stored | Needs a hook in the right-click interact path, which decides target, interact and attack in one pass; its sibling `NP_PreventRightClickTargetChange` is stored for the same reason |
| `NP_PreventMountingWhenBuffCapped` | stored — **a real gap** | Upstream defaults it **on**, so a real install refuses a mount cast at 32 buffs rather than letting the server eat it. Everything it needs is here (`SPELL_AURA_MOUNTED`, all 32 positive slots occupied, `unit_mount_display_id() == 0`); the test belongs inside `send_spell_cast`, which already takes 22 parameters and none of them is the player's own descriptor. It also refuses a press, so it wants a live test rather than being bolted on |
| `NP_EnableEnhancedTooltips` | stored | Authored content rather than a binding: the numbers are in `Spell.dbc` columns this client reads, but what the lines say and where they sit is upstream's design, and guessing it produces a tooltip that is neither ours nor theirs |
| `NP_PreserveGreaterDemonAutocast` + the three `*AutocastData` | stored | Needs pet-autocast state keyed by demon rather than by the live pet guid. The three data rows are not settings at all — upstream's README calls them "managed automatically" — so they are registered to round-trip a write and nothing here will read one |
**Two upstream documents disagree** about the six `NP_EnableUnitEvents*` defaults: `EVENTS.md`
says 0, the README says 1. Those rows keep 0, and the disagreement costs nothing here, because a
default is not a behaviour under the registration rule above — every addon that wants the stream
either writes the CVar or registers the event.
**ClassicAPI's 70** are the remaining bulk and are tracked in `docs/classic-api-functions.md`