diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 2c7d384d..4ada2df9 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -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 { 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, mut bubbles: ResMut 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!( diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 4f17c2ee..27e1107f 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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. ( diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index c1200fc1..2ca3bc5c 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -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, mut local: ResMut) { + 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>, mut group: ResMut, @@ -711,6 +741,7 @@ pub(super) fn drain_party( names: Res, self_q: Query<&Guid, With>, commands: Res, + solo_marks: Res, mut tutorials: Option>, ) { 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] diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index b2b2d242..95dce4a6 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -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::() + .init_resource::() .init_resource::() .add_systems( Update, diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 6f5b9c29..b7208845 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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`