From 8f94f72a6c6139d2febe412c8da18252ca5181ac Mon Sep 17 00:00:00 2001 From: Wurmschwanz Date: Mon, 7 Sep 2026 22:04:42 +0200 Subject: [PATCH] Add files via upload --- BlizzNameplatesPlus.toc | 44 + core/bridge.lua | 10 + core/compat_shagutweaks.lua | 94 + core/config.lua | 357 ++ core/core.lua | 43 + core/libnameplate.lua | 173 + core/options.lua | 1589 +++++++++ core/ui.lua | 15 + media/shagu_target_glow.tga | Bin 0 -> 824 bytes media/target_arrow_chevron_thick.tga | Bin 0 -> 16428 bytes media/target_arrow_chevron_thin.tga | Bin 0 -> 16428 bytes media/target_arrow_diamond_tip_thick.tga | Bin 0 -> 16428 bytes media/target_arrow_diamond_tip_thin.tga | Bin 0 -> 16428 bytes media/target_arrow_double_chevron_thick.tga | Bin 0 -> 16428 bytes media/target_arrow_double_chevron_thin.tga | Bin 0 -> 16428 bytes media/target_arrow_slim_triangle_thick.tga | Bin 0 -> 16428 bytes media/target_arrow_slim_triangle_thin.tga | Bin 0 -> 16428 bytes media/target_arrow_thick.tga | Bin 0 -> 16428 bytes media/target_arrow_thin.tga | Bin 0 -> 16428 bytes media/target_arrow_triangle_thick.tga | Bin 0 -> 16428 bytes media/target_arrow_triangle_thin.tga | Bin 0 -> 16428 bytes modules/aurarecorder.lua | 1155 ++++++ modules/auras.lua | 3523 +++++++++++++++++++ modules/castbar.lua | 386 ++ modules/classcolors.lua | 279 ++ modules/combopoints.lua | 311 ++ modules/commands.lua | 592 ++++ modules/debug.lua | 43 + modules/fullalpha.lua | 815 +++++ modules/guiddebug.lua | 73 + modules/healthpercent.lua | 243 ++ modules/immunities.lua | 1035 ++++++ modules/names.lua | 148 + modules/scale.lua | 434 +++ modules/shagu_darkmode.lua | 67 + modules/spellprobe.lua | 227 ++ modules/tankmode.lua | 189 + modules/totems.lua | 417 +++ modules/unknown.lua | 498 +++ modules/visualguard.lua | 46 + spells/druid.lua | 85 + spells/hunter.lua | 97 + spells/mage.lua | 70 + spells/paladin.lua | 60 + spells/priest.lua | 78 + spells/registry_shadow.lua | 153 + spells/rogue.lua | 75 + spells/shaman.lua | 40 + spells/warlock.lua | 172 + spells/warrior.lua | 113 + 50 files changed, 13749 insertions(+) create mode 100644 BlizzNameplatesPlus.toc create mode 100644 core/bridge.lua create mode 100644 core/compat_shagutweaks.lua create mode 100644 core/config.lua create mode 100644 core/core.lua create mode 100644 core/libnameplate.lua create mode 100644 core/options.lua create mode 100644 core/ui.lua create mode 100644 media/shagu_target_glow.tga create mode 100644 media/target_arrow_chevron_thick.tga create mode 100644 media/target_arrow_chevron_thin.tga create mode 100644 media/target_arrow_diamond_tip_thick.tga create mode 100644 media/target_arrow_diamond_tip_thin.tga create mode 100644 media/target_arrow_double_chevron_thick.tga create mode 100644 media/target_arrow_double_chevron_thin.tga create mode 100644 media/target_arrow_slim_triangle_thick.tga create mode 100644 media/target_arrow_slim_triangle_thin.tga create mode 100644 media/target_arrow_thick.tga create mode 100644 media/target_arrow_thin.tga create mode 100644 media/target_arrow_triangle_thick.tga create mode 100644 media/target_arrow_triangle_thin.tga create mode 100644 modules/aurarecorder.lua create mode 100644 modules/auras.lua create mode 100644 modules/castbar.lua create mode 100644 modules/classcolors.lua create mode 100644 modules/combopoints.lua create mode 100644 modules/commands.lua create mode 100644 modules/debug.lua create mode 100644 modules/fullalpha.lua create mode 100644 modules/guiddebug.lua create mode 100644 modules/healthpercent.lua create mode 100644 modules/immunities.lua create mode 100644 modules/names.lua create mode 100644 modules/scale.lua create mode 100644 modules/shagu_darkmode.lua create mode 100644 modules/spellprobe.lua create mode 100644 modules/tankmode.lua create mode 100644 modules/totems.lua create mode 100644 modules/unknown.lua create mode 100644 modules/visualguard.lua create mode 100644 spells/druid.lua create mode 100644 spells/hunter.lua create mode 100644 spells/mage.lua create mode 100644 spells/paladin.lua create mode 100644 spells/priest.lua create mode 100644 spells/registry_shadow.lua create mode 100644 spells/rogue.lua create mode 100644 spells/shaman.lua create mode 100644 spells/warlock.lua create mode 100644 spells/warrior.lua diff --git a/BlizzNameplatesPlus.toc b/BlizzNameplatesPlus.toc new file mode 100644 index 0000000..8e77ce6 --- /dev/null +++ b/BlizzNameplatesPlus.toc @@ -0,0 +1,44 @@ +## Interface: 11200 +## Title: Blizz Nameplates+ +## Notes: Lightweight personal debuffs and class colors for Blizzard nameplates. Requires SuperWoW and ClassicAPI. +## Author: Wurmschwanz; nameplate discovery inspired by Shagu's Vanilla projects +## OptionalDeps: ShaguTweaks +## Version: 1.0.9 +## SavedVariablesPerCharacter: BNP_DB + +core\core.lua +core\compat_shagutweaks.lua +core\ui.lua +core\config.lua +core\bridge.lua +core\libnameplate.lua +core\options.lua +spells\warlock.lua +spells\priest.lua +spells\warrior.lua +spells\rogue.lua +spells\hunter.lua +spells\mage.lua +spells\druid.lua +spells\shaman.lua +spells\paladin.lua +spells\registry_shadow.lua +modules\debug.lua +modules\spellprobe.lua +modules\scale.lua +modules\names.lua +modules\fullalpha.lua +modules\shagu_darkmode.lua +modules\visualguard.lua +modules\unknown.lua +modules\auras.lua +modules\immunities.lua +modules\totems.lua +modules\aurarecorder.lua +modules\classcolors.lua +modules\castbar.lua +modules\guiddebug.lua +modules\tankmode.lua +modules\healthpercent.lua +modules\combopoints.lua +modules\commands.lua diff --git a/core/bridge.lua b/core/bridge.lua new file mode 100644 index 0000000..3921255 --- /dev/null +++ b/core/bridge.lua @@ -0,0 +1,10 @@ +BNP = BNP or {} + +function BNP:RegisterPlate(plate) + plate = plate or this + if not plate then return end + if BNP.plates[plate] then return end + + BNP.plates[plate] = true + BNP.detected = BNP.detected + 1 +end diff --git a/core/compat_shagutweaks.lua b/core/compat_shagutweaks.lua new file mode 100644 index 0000000..a4ccf97 --- /dev/null +++ b/core/compat_shagutweaks.lua @@ -0,0 +1,94 @@ +BNP = BNP or {} + +-- Robust ShaguTweaks compatibility. +-- BNP owns Scale / Class Colors / Castbar while loaded. +-- ShaguTweaks settings are NOT changed. + +BNP.shaguCompat = BNP.shaguCompat or { + patched = {}, + registerHooked = false, +} + +local wanted = { + ["Nameplate Scale"] = true, + ["Nameplate Class Colors"] = true, + ["Nameplate Castbar"] = true, +} + +local function IsWantedTitle(title) + if not title then return false end + + if wanted[title] then return true end + + if ShaguTweaks and ShaguTweaks.T then + if title == ShaguTweaks.T["Nameplate Scale"] then return true end + if title == ShaguTweaks.T["Nameplate Class Colors"] then return true end + if title == ShaguTweaks.T["Nameplate Castbar"] then return true end + end + + return false +end + +local function DisableModule(mod) + if not mod or type(mod) ~= "table" then return false end + if not IsWantedTitle(mod.title) then return false end + if mod.BNPCompatibilityPatched then return true end + + mod.BNPOriginalEnable = mod.enable + mod.enable = function(self) + return + end + + mod.BNPCompatibilityPatched = true + BNP.shaguCompat.patched[mod.title or "?"] = true + return true +end + +local function PatchExistingModules() + if not ShaguTweaks or not ShaguTweaks.mods then return end + + local title, mod + for title, mod in pairs(ShaguTweaks.mods) do + -- Some registered module tables use localized title as the map key. + if mod and not mod.title then mod.title = title end + DisableModule(mod) + end +end + +local function HookRegister() + if not ShaguTweaks or not ShaguTweaks.register then return end + if BNP.shaguCompat.registerHooked then return end + + local originalRegister = ShaguTweaks.register + + ShaguTweaks.register = function(self, mod) + local registered = originalRegister(self, mod) + DisableModule(registered or mod) + return registered + end + + BNP.shaguCompat.registerHooked = true +end + +function BNP:ApplyShaguTweaksCompatibility() + if not ShaguTweaks then return false end + + -- Covers BNP loading before Shagu's modules register. + HookRegister() + + -- Covers BNP loading after Shagu's modules registered. + PatchExistingModules() + + return true +end + +-- Try immediately in case ShaguTweaks is already loaded. +BNP:ApplyShaguTweaksCompatibility() + +-- Also retry after every addon load. This safely covers all Vanilla/Turtle +-- load-order variations without touching ShaguTweaks saved settings. +local loader = CreateFrame("Frame") +loader:RegisterEvent("ADDON_LOADED") +loader:SetScript("OnEvent", function() + BNP:ApplyShaguTweaksCompatibility() +end) diff --git a/core/config.lua b/core/config.lua new file mode 100644 index 0000000..fd135e7 --- /dev/null +++ b/core/config.lua @@ -0,0 +1,357 @@ +BNP = BNP or {} + +BNP.defaults = BNP.defaults or { + nameplateScale = 1.0, + nameplateYOffset = 0, + comboPointsYOffset = 0, + nonTargetAlpha = 1.0, + iconSize = 18, + ccIconSize = 18, + debuffYOffset = 0, + ccYOffset = 0, + debuffPosition = "top", + ccPosition = "top", + tankMode = false, + classColors = true, + hidePlayerNames = false, + hideNPCNames = false, + castbars = true, + debuffs = true, + crowdControl = true, + separateCCRow = true, + showOtherCCs = true, + pvpImmunities = true, + immunityIconSize = 18, + immunityPosition = "top", + immunityYOffset = 0, + castbarHeight = 6, + castbarSpacing = 4, + minimapAngle = 225, + healthPercent = false, + healthText = "off", + targetFocus = true, + targetGlowColor = "white", + targetGlowSize = 0, + targetGlowOpacity = 1.0, + targetArrows = false, + targetArrowColor = "match", + targetArrowSize = 14, + targetArrowThick = false, + targetArrowStyle = "chevron", + comboPoints = true, + totemIndicators = true, + totemIconSize = 24, +} + +function BNP:InitConfig() + BNP_DB = BNP_DB or {} + + if BNP_DB.nameplateScale == nil then BNP_DB.nameplateScale = self.defaults.nameplateScale end + if BNP_DB.nameplateYOffset == nil then BNP_DB.nameplateYOffset = self.defaults.nameplateYOffset end + if BNP_DB.comboPointsYOffset == nil then BNP_DB.comboPointsYOffset = self.defaults.comboPointsYOffset end + if BNP_DB.nonTargetAlpha == nil then BNP_DB.nonTargetAlpha = self.defaults.nonTargetAlpha end + if BNP_DB.iconSize == nil then BNP_DB.iconSize = self.defaults.iconSize end + if BNP_DB.ccIconSize == nil then BNP_DB.ccIconSize = BNP_DB.iconSize or self.defaults.ccIconSize end + if BNP_DB.debuffYOffset == nil then BNP_DB.debuffYOffset = self.defaults.debuffYOffset end + if BNP_DB.ccYOffset == nil then BNP_DB.ccYOffset = self.defaults.ccYOffset end + if BNP_DB.debuffPosition == nil then BNP_DB.debuffPosition = self.defaults.debuffPosition end + if BNP_DB.ccPosition == nil then BNP_DB.ccPosition = BNP_DB.debuffPosition or self.defaults.ccPosition end + if BNP_DB.tankMode == nil then BNP_DB.tankMode = self.defaults.tankMode end + if BNP_DB.classColors == nil then BNP_DB.classColors = self.defaults.classColors end + if BNP_DB.hidePlayerNames == nil then BNP_DB.hidePlayerNames = self.defaults.hidePlayerNames end + if BNP_DB.hideNPCNames == nil then BNP_DB.hideNPCNames = self.defaults.hideNPCNames end + if BNP_DB.castbars == nil then BNP_DB.castbars = self.defaults.castbars end + if BNP_DB.debuffs == nil then BNP_DB.debuffs = self.defaults.debuffs end + if BNP_DB.crowdControl == nil then BNP_DB.crowdControl = (BNP_DB.debuffs ~= false) and true or false end + if BNP_DB.separateCCRow == nil then BNP_DB.separateCCRow = self.defaults.separateCCRow end + if BNP_DB.showOtherCCs == nil then BNP_DB.showOtherCCs = self.defaults.showOtherCCs end + if BNP_DB.pvpImmunities == nil then BNP_DB.pvpImmunities = self.defaults.pvpImmunities end + if BNP_DB.immunityIconSize == nil then BNP_DB.immunityIconSize = BNP_DB.iconSize or self.defaults.immunityIconSize end + if BNP_DB.immunityPosition == nil then BNP_DB.immunityPosition = self.defaults.immunityPosition end + if BNP_DB.immunityYOffset == nil then BNP_DB.immunityYOffset = self.defaults.immunityYOffset end + -- Position migration from the temporary all-Bottom test build. + if BNP_DB.debuffPosition == "bottom" then BNP_DB.debuffPosition = "bottom_mid" end + if BNP_DB.ccPosition == "bottom" then BNP_DB.ccPosition = "top" end + if BNP_DB.immunityPosition == "bottom" then BNP_DB.immunityPosition = "top" end + if BNP_DB.castbarHeight == nil then BNP_DB.castbarHeight = self.defaults.castbarHeight end + if BNP_DB.castbarSpacing == nil then BNP_DB.castbarSpacing = self.defaults.castbarSpacing end + if BNP_DB.minimapAngle == nil then BNP_DB.minimapAngle = self.defaults.minimapAngle end + if BNP_DB.healthPercent == nil then BNP_DB.healthPercent = self.defaults.healthPercent end + if BNP_DB.healthText == nil then + -- Backwards-compatible migration from the old Health % checkbox. + BNP_DB.healthText = BNP_DB.healthPercent and "percent" or self.defaults.healthText + end + if BNP_DB.targetFocus == nil then BNP_DB.targetFocus = self.defaults.targetFocus end + if BNP_DB.targetGlowColor == nil then BNP_DB.targetGlowColor = self.defaults.targetGlowColor end + if BNP_DB.targetGlowSize == nil then BNP_DB.targetGlowSize = self.defaults.targetGlowSize end + if BNP_DB.targetGlowOpacity == nil then BNP_DB.targetGlowOpacity = self.defaults.targetGlowOpacity end + if BNP_DB.targetArrows == nil then BNP_DB.targetArrows = self.defaults.targetArrows end + if BNP_DB.targetArrowColor == nil then BNP_DB.targetArrowColor = self.defaults.targetArrowColor end + if BNP_DB.targetArrowSize == nil then BNP_DB.targetArrowSize = self.defaults.targetArrowSize end + if BNP_DB.targetArrowThick == nil then BNP_DB.targetArrowThick = self.defaults.targetArrowThick end + if BNP_DB.targetArrowStyle == nil then BNP_DB.targetArrowStyle = self.defaults.targetArrowStyle end + if BNP_DB.comboPoints == nil then BNP_DB.comboPoints = self.defaults.comboPoints end + if BNP_DB.totemIndicators == nil then BNP_DB.totemIndicators = self.defaults.totemIndicators end + if BNP_DB.totemIconSize == nil then BNP_DB.totemIconSize = self.defaults.totemIconSize end +end + +function BNP:GetNameplateScale() + return (BNP_DB and tonumber(BNP_DB.nameplateScale)) or self.defaults.nameplateScale +end + +function BNP:GetNameplateYOffset() + local value = (BNP_DB and tonumber(BNP_DB.nameplateYOffset)) or self.defaults.nameplateYOffset or 0 + if value < 0 then value = 0 end + if value > 50 then value = 50 end + return value +end + + +local function ClampVisualYOffset(value) + value = tonumber(value) or 0 + if value < -50 then value = -50 end + if value > 50 then value = 50 end + return value >= 0 and math.floor(value + 0.5) or math.ceil(value - 0.5) +end + +function BNP:GetComboPointsYOffset() + return ClampVisualYOffset(BNP_DB and BNP_DB.comboPointsYOffset or self.defaults.comboPointsYOffset) +end + +function BNP:GetDebuffYOffset() + return ClampVisualYOffset(BNP_DB and BNP_DB.debuffYOffset or self.defaults.debuffYOffset) +end + +function BNP:GetCCYOffset() + return ClampVisualYOffset(BNP_DB and BNP_DB.ccYOffset or self.defaults.ccYOffset) +end + +function BNP:GetImmunityYOffset() + return ClampVisualYOffset(BNP_DB and BNP_DB.immunityYOffset or self.defaults.immunityYOffset) +end + +function BNP:GetNonTargetAlpha() + local value = (BNP_DB and tonumber(BNP_DB.nonTargetAlpha)) or self.defaults.nonTargetAlpha or 1.0 + if value < 0.30 then value = 0.30 end + if value > 1.00 then value = 1.00 end + return value +end + +function BNP:GetIconSize() + return (BNP_DB and tonumber(BNP_DB.iconSize)) or self.defaults.iconSize +end + +function BNP:GetCCIconSize() + local value = (BNP_DB and tonumber(BNP_DB.ccIconSize)) or self.defaults.ccIconSize or self:GetIconSize() + if value < 12 then value = 12 end + if value > 32 then value = 32 end + return value +end + +function BNP:GetDebuffPosition() + local position = (BNP_DB and BNP_DB.debuffPosition) or self.defaults.debuffPosition or "top" + -- Migrate the first Bottom implementation to the new centered Bottom option. + if position == "bottom" then position = "bottom_mid" end + if position ~= "top" and position ~= "top_left" and position ~= "top_right" + and position ~= "left" and position ~= "right" + and position ~= "bottom_mid" and position ~= "bottom_left" and position ~= "bottom_right" then + position = "top" + end + return position +end + +function BNP:GetCCPosition() + local position = (BNP_DB and BNP_DB.ccPosition) or self.defaults.ccPosition or "top" + -- Bottom is intentionally not supported for CCs. + if position ~= "top" and position ~= "left" and position ~= "right" then + position = "top" + end + return position +end + +function BNP:GetImmunityIconSize() + local value = (BNP_DB and tonumber(BNP_DB.immunityIconSize)) or self.defaults.immunityIconSize or self:GetIconSize() + if value < 12 then value = 12 end + if value > 32 then value = 32 end + return value +end + +function BNP:GetImmunityPosition() + local position = (BNP_DB and BNP_DB.immunityPosition) or self.defaults.immunityPosition or "top" + -- Bottom is intentionally not supported for immunities. + if position ~= "top" and position ~= "left" and position ~= "right" then + position = "top" + end + return position +end + + +function BNP:IsTankModeEnabled() + return BNP_DB and BNP_DB.tankMode and true or false +end + +function BNP:AreClassColorsEnabled() + return not BNP_DB or BNP_DB.classColors ~= false +end + +function BNP:HidePlayerNamesEnabled() + return BNP_DB and BNP_DB.hidePlayerNames and true or false +end + +function BNP:HideNPCNamesEnabled() + return BNP_DB and BNP_DB.hideNPCNames and true or false +end + +function BNP:AreCastbarsEnabled() + return not BNP_DB or BNP_DB.castbars ~= false +end + +function BNP:GetCastbarHeight() + local value = (BNP_DB and tonumber(BNP_DB.castbarHeight)) or self.defaults.castbarHeight + if value < 4 then value = 4 end + if value > 14 then value = 14 end + return value +end + +function BNP:GetCastbarSpacing() + local value = (BNP_DB and tonumber(BNP_DB.castbarSpacing)) or self.defaults.castbarSpacing or 4 + if value < 0 then value = 0 end + if value > 10 then value = 10 end + return value +end + +function BNP:GetHealthTextMode() + local mode = (BNP_DB and BNP_DB.healthText) or self.defaults.healthText or "off" + if mode ~= "off" and mode ~= "percent" and mode ~= "hp" and mode ~= "both" then + mode = "off" + end + return mode +end + +function BNP:IsHealthPercentEnabled() + local mode = self:GetHealthTextMode() + return mode == "percent" or mode == "both" +end + +function BNP:IsHealthTextEnabled() + return self:GetHealthTextMode() ~= "off" +end + +function BNP:IsTargetFocusEnabled() + return not BNP_DB or BNP_DB.targetFocus ~= false +end + +function BNP:GetTargetGlowColor() + local key = (BNP_DB and BNP_DB.targetGlowColor) or self.defaults.targetGlowColor or "white" + + local colors = { + white = { 1.00, 1.00, 1.00 }, + gold = { 1.00, 0.82, 0.10 }, + blue = { 0.25, 0.55, 1.00 }, + green = { 0.25, 1.00, 0.35 }, + red = { 1.00, 0.20, 0.20 }, + purple = { 0.75, 0.35, 1.00 }, + } + + local c = colors[key] or colors.white + return c[1], c[2], c[3], key +end + + +function BNP:GetTargetGlowSize() + local value = (BNP_DB and tonumber(BNP_DB.targetGlowSize)) or self.defaults.targetGlowSize or 0 + if value < 0 then value = 0 end + if value > 20 then value = 20 end + return value +end + +function BNP:GetTargetGlowOpacity() + local value = (BNP_DB and tonumber(BNP_DB.targetGlowOpacity)) or self.defaults.targetGlowOpacity or 1.0 + if value < 0.20 then value = 0.20 end + if value > 1.00 then value = 1.00 end + return value +end + +function BNP:AreTargetArrowsEnabled() + return BNP_DB and BNP_DB.targetArrows == true +end + +function BNP:GetTargetArrowColor() + local key = (BNP_DB and BNP_DB.targetArrowColor) or self.defaults.targetArrowColor or "match" + if key == "match" then + local r, g, b = self:GetTargetGlowColor() + return r, g, b, key + end + + local colors = { + white = { 1.00, 1.00, 1.00 }, + gold = { 1.00, 0.82, 0.10 }, + blue = { 0.25, 0.55, 1.00 }, + green = { 0.25, 1.00, 0.35 }, + red = { 1.00, 0.20, 0.20 }, + purple = { 0.75, 0.35, 1.00 }, + } + + local c = colors[key] or colors.white + return c[1], c[2], c[3], key +end + +function BNP:GetTargetArrowSize() + local value = (BNP_DB and tonumber(BNP_DB.targetArrowSize)) or self.defaults.targetArrowSize or 14 + if value < 10 then value = 10 end + if value > 24 then value = 24 end + return value +end + +function BNP:AreTargetArrowsThick() + return BNP_DB and BNP_DB.targetArrowThick == true +end + +function BNP:GetTargetArrowStyle() + local style = (BNP_DB and BNP_DB.targetArrowStyle) or self.defaults.targetArrowStyle or "chevron" + if style ~= "chevron" and style ~= "triangle" and style ~= "slim_triangle" + and style ~= "double_chevron" and style ~= "diamond_tip" then + style = "chevron" + end + return style +end + +function BNP:AreComboPointsEnabled() + return not BNP_DB or BNP_DB.comboPoints ~= false +end + +function BNP:AreTotemIndicatorsEnabled() + return not BNP_DB or BNP_DB.totemIndicators ~= false +end + +function BNP:GetTotemIconSize() + local value = (BNP_DB and tonumber(BNP_DB.totemIconSize)) or self.defaults.totemIconSize or 24 + if value < 16 then value = 16 end + if value > 36 then value = 36 end + return value +end + + +function BNP:AreDebuffsEnabled() + return not BNP_DB or BNP_DB.debuffs ~= false +end + +function BNP:AreCrowdControlEnabled() + return not BNP_DB or BNP_DB.crowdControl ~= false +end + +function BNP:AreAnyAurasEnabled() + return self:AreDebuffsEnabled() or self:AreCrowdControlEnabled() +end + +function BNP:IsSeparateCCRowEnabled() + return not BNP_DB or BNP_DB.separateCCRow ~= false +end + +function BNP:ShowOtherPlayersCCs() + return not BNP_DB or BNP_DB.showOtherCCs ~= false +end + +function BNP:ArePvPImmunitiesEnabled() + return not BNP_DB or BNP_DB.pvpImmunities ~= false +end diff --git a/core/core.lua b/core/core.lua new file mode 100644 index 0000000..bfd2d99 --- /dev/null +++ b/core/core.lua @@ -0,0 +1,43 @@ +BNP = BNP or {} +BNP.version = "1.0.9" +BNP.plates = BNP.plates or {} +BNP.detected = 0 +BNP.debugEnabled = false + +function BNP:Print(msg) + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99Blizz Nameplates+:|r " .. tostring(msg)) + end +end + +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("VARIABLES_LOADED") +eventFrame:SetScript("OnEvent", function() + BNP_DB = BNP_DB or {} + + if BNP.InitConfig then BNP:InitConfig() end + if BNP.CreateMinimapButton then BNP:CreateMinimapButton() end + if BNP.InstallTankMode then BNP:InstallTankMode() end + + if BNP_DB.debugEnabled == nil then BNP_DB.debugEnabled = false end + BNP.debugEnabled = BNP_DB.debugEnabled + + local classicNameplates = CLASSIC_API_VERSION and C_NamePlate and + type(C_NamePlate.GetNamePlateForUnit) == "function" + + if CombatLogAdd and SpellInfo and classicNameplates then + BNP:Print(BNP.version .. " loaded. SuperWoW + ClassicAPI nameplate tracking active.") + BNP:Print("Dragon command test build loaded.") + if ShaguTweaks then + BNP:ApplyShaguTweaksCompatibility() + local count = 0 + local k + for k in pairs(BNP.shaguCompat.patched or {}) do count = count + 1 end + BNP:Print("ShaguTweaks compatibility active (" .. tostring(count) .. "/3 nameplate modules suppressed).") + end + elseif not CombatLogAdd or not SpellInfo then + BNP:Print("ERROR: SuperWoW API not detected. Blizz Nameplates+ requires SuperWoW.") + else + BNP:Print("ERROR: ClassicAPI nameplate API not detected. Please update ClassicAPI.dll.") + end +end) diff --git a/core/libnameplate.lua b/core/libnameplate.lua new file mode 100644 index 0000000..3991b27 --- /dev/null +++ b/core/libnameplate.lua @@ -0,0 +1,173 @@ +BNP = BNP or {} + +BNP.libnameplate = BNP.libnameplate or { + OnInit = {}, + OnShow = {}, + OnUpdate = {}, +} + +local lib = BNP.libnameplate +local known = {} +local initializedChildren = 0 + +local function FindHealthBar(frame) + if not frame or not frame.GetChildren then return nil end + + local children = { frame:GetChildren() } + local i + for i = 1, table.getn(children) do + local child = children[i] + if child and child.GetObjectType and child:GetObjectType() == "StatusBar" then + return child + end + end + + return nil +end + +local function IsNamePlate(frame) + if not frame or not frame.GetObjectType then return false end + if frame:GetObjectType() ~= "Button" then return false end + + -- Fast path for untouched Blizzard nameplates. + local firstRegion = frame:GetRegions() + if firstRegion and firstRegion.GetObjectType and firstRegion.GetTexture and + firstRegion:GetObjectType() == "Texture" and + firstRegion:GetTexture() == "Interface\\Tooltips\\Nameplate-Border" then + return true + end + + -- Compatibility fallback: some UI/skin addons replace, blank or hide the + -- native border before BNP's WorldFrame scan sees the plate. Do not make + -- nameplate discovery depend on a mutable texture path. + local replacement = frame.nameplate + local original = replacement and replacement.original + + -- Known replacement-overlay shape: the addon kept references to Blizzard's + -- original health/name/level objects even if it already reparented them. + if original and (original.health or original.healthbar) and + original.name and original.level then + return true + end + + -- Generic safe fallback. Keep the structural test deliberately strict to + -- avoid mistaking unrelated WorldFrame Buttons for nameplates. + if not firstRegion or not firstRegion.GetObjectType or + firstRegion:GetObjectType() ~= "Texture" then + return false + end + if not FindHealthBar(frame) then return false end + + local fontStrings = 0 + local regions = { frame:GetRegions() } + local i + for i = 1, table.getn(regions) do + local region = regions[i] + if region and region.GetObjectType and region:GetObjectType() == "FontString" then + fontStrings = fontStrings + 1 + if fontStrings >= 2 then return true end + end + end + + return false +end + +local function AttachPlateShortcuts(plate) + if plate.BNPShortcutsReady then return end + + -- Do not assume another addon left the native StatusBar as the first child. + local replacement = plate.nameplate + local original = replacement and replacement.original + plate.healthbar = (original and (original.health or original.healthbar)) or + FindHealthBar(plate) + + -- Vanilla region order: + -- 1 border, 2 glow, 3 name, 4 level, 5 level icon, 6 raid icon. + -- Fall back to common replacement-overlay references only when direct native + -- regions are unavailable; normal Blizzard plates keep the exact old path. + local regions = { plate:GetRegions() } + plate.border = (original and original.border) or regions[1] + plate.glow = regions[2] or (original and original.glow) + plate.name = regions[3] or (original and original.name) + plate.level = regions[4] or (original and original.level) + plate.levelicon = regions[5] or (original and (original.levelicon or original.bossicon)) + plate.raidicon = regions[6] or (replacement and replacement.raidicon) or + (original and original.raidicon) + + plate.BNPShortcutsReady = true +end + +local function Fire(list, plate) + plate = plate or this + if not plate then return end + + local i + for i = 1, table.getn(list) do + if list[i] then + pcall(list[i], plate) + end + end +end + +local function RegisterPlate(plate) + plate = plate or this + if not plate then return end + if known[plate] then return end + known[plate] = true + + AttachPlateShortcuts(plate) + + if BNP.RegisterPlate then + BNP:RegisterPlate(plate) + end + + Fire(lib.OnInit, plate) + if plate:IsShown() then + Fire(lib.OnShow, plate) + end + + -- Run OnShow callbacks when Blizzard reuses the same plate. + local oldOnShow = plate:GetScript("OnShow") + plate:SetScript("OnShow", function(self) + local current = self or this or plate + if not current then return end + if oldOnShow then oldOnShow(current) end + AttachPlateShortcuts(current) + Fire(lib.OnShow, current) + end) +end + +local scanner = CreateFrame("Frame") +local scanElapsed = 0 + +scanner:SetScript("OnUpdate", function() + scanElapsed = scanElapsed + arg1 + if scanElapsed < 0.10 then return end + scanElapsed = 0 + + local count = WorldFrame:GetNumChildren() + + -- Full scan only when WorldFrame gained children. This mirrors the lightweight + -- discovery strategy used by Shagu's Vanilla nameplate code. + if count > initializedChildren then + local children = { WorldFrame:GetChildren() } + local i + for i = 1, table.getn(children) do + local frame = children[i] + if not known[frame] and IsNamePlate(frame) then + RegisterPlate(frame) + end + end + initializedChildren = count + end + + -- Compatibility callback list for modules that want a throttled plate update. + if table.getn(lib.OnUpdate) > 0 then + local plate + for plate in pairs(known) do + if plate:IsShown() then + Fire(lib.OnUpdate, plate) + end + end + end +end) diff --git a/core/options.lua b/core/options.lua new file mode 100644 index 0000000..e0f41a2 --- /dev/null +++ b/core/options.lua @@ -0,0 +1,1589 @@ +BNP = BNP or {} + +local _, optionsPlayerClass = UnitClass("player") +local comboOptionsClass = (optionsPlayerClass == "ROGUE" or optionsPlayerClass == "DRUID") + +local function Round(value, step) + return math.floor((value / step) + 0.5) * step +end + +local function RoundSignedInteger(value) + value = tonumber(value) or 0 + if value >= 0 then return math.floor(value + 0.5) end + return math.ceil(value - 0.5) +end + +local sliderCount = 0 +local function CreateSlider(parent, label, minValue, maxValue, step, y, x, width) + sliderCount = sliderCount + 1 + local slider = CreateFrame("Slider", "BNPOptionsSlider" .. sliderCount, parent, "OptionsSliderTemplate") + slider:SetPoint("TOPLEFT", parent, "TOPLEFT", x or 28, y) + slider:SetWidth(width or 220) + slider:SetHeight(16) + slider:SetMinMaxValues(minValue, maxValue) + slider:SetValueStep(step) + getglobal(slider:GetName() .. "Low"):SetText(tostring(minValue)) + getglobal(slider:GetName() .. "High"):SetText(tostring(maxValue)) + getglobal(slider:GetName() .. "Text"):SetText(label) + return slider +end + +local function CreateCheck(parent, label, y, onclick, x) + local check = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate") + check:SetPoint("TOPLEFT", parent, "TOPLEFT", x or 22, y) + check:SetWidth(24) + check:SetHeight(24) + + local text = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + text:SetPoint("LEFT", check, "RIGHT", 4, 0) + text:SetText(label) + check.BNPLabel = text + + check:SetScript("OnClick", onclick) + return check +end + +-- 1.12-safe emergency recorder ------------------------------------------------ +-- +-- Keep a compact recorder directly in this already proven options file. The +-- full diagnostic module replaces BNP.OpenAuraRecorder later when it loads. +-- If an old/private 1.12 client refuses that larger module, this fallback still +-- provides the window and the ClassicAPI aura data we actually need. + +local emergency = BNP.emergencyAuraRecorder or {} +BNP.emergencyAuraRecorder = emergency +emergency.lines = emergency.lines or {} +emergency.active = false + +local function EmergencyValue(value, limit) + if value == nil then return "nil" end + local text = tostring(value) + text = string.gsub(text, "\r", "\\r") + text = string.gsub(text, "\n", "\\n") + text = string.gsub(text, "\t", "\\t") + limit = limit or 240 + if string.len(text) > limit then text = string.sub(text, 1, limit) .. "..." end + return text +end + +local function EmergencyAppend(text) + text = EmergencyValue(text, 1300) + if string.len(table.concat(emergency.lines, "\n")) + string.len(text) > 60000 then + if not emergency.limitHit then + table.insert(emergency.lines, "[REPORT LIMIT REACHED - recording stopped]") + emergency.limitHit = true + emergency.active = false + end + return + end + table.insert(emergency.lines, text) + emergency.dirty = true +end + +local function EmergencyFields(data) + if type(data) ~= "table" then return EmergencyValue(data) end + local fields = {} + local key, value + for key, value in pairs(data) do + local kind = type(value) + if kind ~= "table" and kind ~= "function" and kind ~= "userdata" then + table.insert(fields, EmergencyValue(key, 80) .. "=" .. EmergencyValue(value, 180)) + end + end + table.sort(fields) + return table.concat(fields, " | ") +end + +local function EmergencyGUID(unit) + local exists, guid = UnitExists(unit) + if exists and guid then return guid end + if UnitGUID then + local ok, value = pcall(UnitGUID, unit) + if ok then return value end + end + return nil +end + +local function EmergencyWantedAura(aura) + if type(aura) ~= "table" then return true end + local spellID = tonumber(aura.spellId or aura.spellID) + local name = string.lower(tostring(aura.name or "")) + if spellID == 17794 or spellID == 17797 or spellID == 17798 or spellID == 17799 or spellID == 17800 then return true end + if name == "shadow vulnerability" or name == "schattenverwundbarkeit" then return true end + + local playerGUID = EmergencyGUID("player") + if aura.sourceGUID and playerGUID then return aura.sourceGUID == playerGUID end + if aura.sourceUnit then return aura.sourceUnit == "player" end + return false +end + +local function EmergencyIsShadowBolt(spellID, spellName) + local numeric = tonumber(spellID) + if numeric == 686 or numeric == 695 or numeric == 705 or numeric == 1088 or + numeric == 1106 or numeric == 7641 or numeric == 11659 or numeric == 11660 or + numeric == 11661 or numeric == 25307 then return true end + local name = string.lower(tostring(spellName or "")) + return name == "shadow bolt" or name == "schattenblitz" +end + +local function EmergencyRefresh() + if not emergency.edit then return end + emergency.edit:SetText(table.concat(emergency.lines, "\n")) + emergency.edit:SetHeight(math.max(300, table.getn(emergency.lines) * 16 + 30)) + if emergency.status then + if emergency.active then + emergency.status:SetText("RECORDING | Target: " .. EmergencyValue(UnitName("target") or "none", 100)) + emergency.status:SetTextColor(0.2, 1, 0.35) + else + emergency.status:SetText("STOPPED | " .. tostring(table.getn(emergency.lines)) .. " lines") + emergency.status:SetTextColor(1, 0.82, 0) + end + end + emergency.dirty = false +end + +local function EmergencySnapshot(reason, force) + if not UnitExists("target") then + if force then EmergencyAppend("AURA_SNAPSHOT | reason=" .. reason .. " | no target") end + return + end + + local auraLines = {} + local signatureParts = {} + local count = 0 + local classic = type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" + local i + + if classic then + for i = 1, 64 do + local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, "target", i) + if not ok then + EmergencyAppend("CLASSICAPI_ERROR | " .. EmergencyValue(aura, 800)) + break + end + if not aura then break end + if EmergencyWantedAura(aura) then + count = count + 1 + local fields = EmergencyFields(aura) + table.insert(signatureParts, fields) + table.insert(auraLines, " CLASSIC_AURA " .. tostring(i) .. " | " .. fields) + end + end + else + for i = 1, 64 do + local ok, texture, stacks, dispelType, spellID, r5, r6, r7, r8, r9, r10 = pcall(UnitDebuff, "target", i) + if not ok or not texture then break end + count = count + 1 + local fields = "spellId=" .. EmergencyValue(spellID) .. + " | stacks=" .. EmergencyValue(stacks) .. + " | type=" .. EmergencyValue(dispelType) .. + " | texture=" .. EmergencyValue(texture, 160) .. + " | r5=" .. EmergencyValue(r5) .. " | r6=" .. EmergencyValue(r6) .. + " | r7=" .. EmergencyValue(r7) .. " | r8=" .. EmergencyValue(r8) .. + " | r9=" .. EmergencyValue(r9) .. " | r10=" .. EmergencyValue(r10) + table.insert(signatureParts, fields) + table.insert(auraLines, " LEGACY_AURA " .. tostring(i) .. " | " .. fields) + end + end + + local signature = table.concat(signatureParts, "||") + if emergency.baselineOnly then + emergency.lastSignature = signature + return + end + if not force and signature == emergency.lastSignature then return end + emergency.lastSignature = signature + EmergencyAppend( + "[+" .. string.format("%.3f", GetTime() - (emergency.startedAt or GetTime())) .. "] AURA_SNAPSHOT" .. + " | reason=" .. EmergencyValue(reason) .. + " | guid=" .. EmergencyValue(EmergencyGUID("target")) .. + " | source=" .. (classic and "ClassicAPI" or "UnitDebuff") .. + " | count=" .. tostring(count) + ) + if count == 0 then + EmergencyAppend(" AURA none") + else + for i = 1, table.getn(auraLines) do EmergencyAppend(auraLines[i]) end + end +end + +local function EmergencyUnregister() + if emergency.eventFrame then pcall(emergency.eventFrame.UnregisterAllEvents, emergency.eventFrame) end +end + +local function EmergencyStop(reason) + if not emergency.active then EmergencyRefresh() return end + EmergencySnapshot("final", true) + emergency.active = false + EmergencyUnregister() + if emergency.frame then emergency.frame:SetScript("OnUpdate", nil) end + EmergencyAppend("=== END | reason=" .. EmergencyValue(reason or "user") .. " ===") + EmergencyRefresh() +end + +local function EmergencyStart() + EmergencyUnregister() + emergency.lines = {} + emergency.limitHit = false + emergency.lastSignature = nil + emergency.startedAt = GetTime() + emergency.elapsed = 0 + emergency.active = true + if emergency.frame and emergency.onUpdate then emergency.frame:SetScript("OnUpdate", emergency.onUpdate) end + EmergencyAppend("BNP_CLASSICAPI_AURA_REPORT v2") + EmergencyAppend( + "ClassicAPI | version=" .. EmergencyValue(CLASSIC_API_VERSION or "not detected") .. + " | C_UnitAuras=" .. ((type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function") and "yes" or "no") .. + " | CopyToClipboard=" .. (CopyToClipboard and "yes" or "no") + ) + EmergencyAppend("Aura filter | own auras + Shadow Vulnerability from any Warlock") + EmergencyAppend("Target | name=" .. EmergencyValue(UnitName("target")) .. " | guid=" .. EmergencyValue(EmergencyGUID("target"))) + EmergencyAppend("Test | proc aura, refresh it while active, then let it expire") + EmergencyAppend("=== LIVE RECORDING ===") + + local events = { + "UNIT_CASTEVENT", "UNIT_AURA", "PLAYER_TARGET_CHANGED", + "UNIT_SPELLCAST_SENT", "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP", + "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START", "UNIT_SPELLCAST_CHANNEL_STOP", + } + local i + for i = 1, table.getn(events) do pcall(emergency.eventFrame.RegisterEvent, emergency.eventFrame, events[i]) end + -- Learn the target's existing auras without printing them. Only changes + -- occurring after Start Recording belong in the diagnostic report. + emergency.baselineOnly = true + EmergencySnapshot("baseline", false) + emergency.baselineOnly = false + EmergencyRefresh() +end + +local function EmergencyCreateWindow() + if emergency.frame then return emergency.frame end + + local frame = CreateFrame("Frame", "BNPEmergencyAuraRecorderFrame", UIParent) + frame:SetWidth(720) + frame:SetHeight(520) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 20) + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:SetScript("OnHide", function() EmergencyStop("window closed") end) + emergency.frame = frame + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOP", frame, "TOP", 0, -18) + title:SetText("Missing Spell / Aura Recorder") + + local status = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + status:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -50) + status:SetWidth(660) + status:SetJustifyH("LEFT") + emergency.status = status + + local start = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + start:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -76) + start:SetWidth(126) + start:SetHeight(24) + start:SetText("Start Recording") + start:SetScript("OnClick", EmergencyStart) + + local stop = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + stop:SetPoint("LEFT", start, "RIGHT", 8, 0) + stop:SetWidth(82) + stop:SetHeight(24) + stop:SetText("Stop") + stop:SetScript("OnClick", function() EmergencyStop("user") end) + + local copy = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + copy:SetPoint("LEFT", stop, "RIGHT", 8, 0) + copy:SetWidth(110) + copy:SetHeight(24) + copy:SetText(CopyToClipboard and "Copy Report" or "Select All") + copy:SetScript("OnClick", function() + EmergencyRefresh() + local report = table.concat(emergency.lines, "\n") + if CopyToClipboard and pcall(CopyToClipboard, report) then + emergency.status:SetText("COPIED | Paste with Ctrl+V") + emergency.status:SetTextColor(0.2, 1, 0.35) + else + emergency.edit:SetFocus() + emergency.edit:HighlightText() + emergency.status:SetText("SELECTED | Press Ctrl+C") + end + end) + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) + + local scroll = CreateFrame("ScrollFrame", "BNPEmergencyAuraRecorderScroll", frame, "UIPanelScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -112) + scroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -48, 30) + + local edit = CreateFrame("EditBox", "BNPEmergencyAuraRecorderEdit", scroll) + edit:SetWidth(640) + edit:SetHeight(300) + edit:SetMultiLine(true) + edit:SetAutoFocus(false) + edit:SetFontObject(ChatFontNormal) + edit:SetMaxLetters(65000) + edit:SetScript("OnEscapePressed", function() this:ClearFocus() end) + scroll:SetScrollChild(edit) + emergency.edit = edit + + emergency.eventFrame = CreateFrame("Frame", "BNPEmergencyAuraRecorderEvents") + emergency.eventFrame:SetScript("OnEvent", function() + if not emergency.active then return end + if event == "UNIT_AURA" and arg1 ~= "target" then return end + if event == "UNIT_CASTEVENT" and arg1 ~= EmergencyGUID("player") then return end + EmergencyAppend( + "[+" .. string.format("%.3f", GetTime() - emergency.startedAt) .. "] EVENT | " .. EmergencyValue(event) .. + " | a1=" .. EmergencyValue(arg1) .. " | a2=" .. EmergencyValue(arg2) .. + " | a3=" .. EmergencyValue(arg3) .. " | a4=" .. EmergencyValue(arg4) .. + " | a5=" .. EmergencyValue(arg5) .. " | a6=" .. EmergencyValue(arg6) + ) + emergency.forceSnapshot = event == "PLAYER_TARGET_CHANGED" or + (event == "UNIT_CASTEVENT" and EmergencyIsShadowBolt(arg4)) or + (event == "UNIT_SPELLCAST_SENT" and EmergencyIsShadowBolt(arg4, arg5)) or + (event ~= "UNIT_SPELLCAST_SENT" and string.find(tostring(event or ""), "UNIT_SPELLCAST_", 1, true) and EmergencyIsShadowBolt(arg3, arg4)) + end) + + emergency.onUpdate = function() + if not emergency.active then return end + emergency.elapsed = (emergency.elapsed or 0) + arg1 + if emergency.elapsed >= 0.12 then + emergency.elapsed = 0 + EmergencySnapshot(emergency.forceSnapshot and "event" or "poll", emergency.forceSnapshot) + emergency.forceSnapshot = false + if emergency.dirty then EmergencyRefresh() end + end + end + + return frame +end + +-- This remains available even if the full recorder loads, so that module can +-- fall back when a private client rejects one of the larger window calls. +function BNP:OpenEmergencyAuraRecorder() + local frame = EmergencyCreateWindow() + frame:Show() + EmergencyRefresh() +end + +function BNP:StartEmergencyAuraRecorder() + EmergencyStart() +end + +-- This is intentionally defined before the large optional recorder module. +-- A successful module load overwrites it; otherwise the button still works. +function BNP:OpenAuraRecorder() + self:OpenEmergencyAuraRecorder() +end + +function BNP:CreateOptions() + if self.optionsFrame then return end + + local frame = CreateFrame("Frame", "BNPOptionsFrame", UIParent) + frame:SetWidth(430) + frame:SetHeight(500) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 40) + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:Hide() + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOP", frame, "TOP", 0, -18) + title:SetText("Blizz Nameplates+") + + local version = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + version:SetPoint("TOP", title, "BOTTOM", 0, -4) + version:SetText("Version " .. tostring(BNP.version or "?")) + frame.versionText = version + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) + + local function CreatePage() + local page = CreateFrame("Frame", nil, frame) + page:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -104) + page:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -18, 22) + page:Hide() + return page + end + + local function CreateSection(parent, label, y) + local text = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + text:SetPoint("TOPLEFT", parent, "TOPLEFT", 8, y) + text:SetText(label) + + local line = parent:CreateTexture(nil, "ARTWORK") + line:SetTexture(1, 1, 1, 0.16) + line:SetPoint("LEFT", text, "RIGHT", 8, 0) + line:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + line:SetHeight(1) + + return text + end + + local function SetCheckEnabled(check, enabled) + if not check then return end + if enabled then + if check.Enable then check:Enable() end + check:SetAlpha(1.0) + if check.BNPLabel then check.BNPLabel:SetTextColor(1, 0.82, 0) end + else + if check.Disable then check:Disable() end + check:SetAlpha(0.45) + if check.BNPLabel then check.BNPLabel:SetTextColor(0.5, 0.5, 0.5) end + end + end + + -- Compact tab navigation. Totems stays separate from aura tracking so PvP + -- nameplate indicators never touch the debuff/CC state machine. + frame.pages = { + nameplates = CreatePage(), + auras = CreatePage(), + totems = CreatePage(), + castbar = CreatePage(), + target = CreatePage(), + tools = CreatePage(), + } + frame.tabs = {} + + local tabDefs = { + { key = "nameplates", label = "Nameplates", width = 76 }, + { key = "auras", label = "Auras", width = 52 }, + { key = "totems", label = "Totems", width = 58 }, + { key = "castbar", label = "Castbar", width = 60 }, + { key = "target", label = "Target", width = 56 }, + { key = "tools", label = "Tools", width = 50 }, + } + + function frame:ShowTab(key) + local pageKey, page + for pageKey, page in pairs(self.pages) do + if pageKey == key then page:Show() else page:Hide() end + end + + local i + for i = 1, table.getn(tabDefs) do + local def = tabDefs[i] + local button = self.tabs[def.key] + if button then + if def.key == key then + if button.Disable then button:Disable() end + button:SetAlpha(1.0) + else + if button.Enable then button:Enable() end + button:SetAlpha(0.80) + end + end + end + self.selectedTab = key + end + + local tabX = 28 + local i + for i = 1, table.getn(tabDefs) do + local def = tabDefs[i] + local button = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + button:SetWidth(def.width) + button:SetHeight(24) + button:SetPoint("TOPLEFT", frame, "TOPLEFT", tabX, -68) + button:SetText(def.label) + button.BNPTabKey = def.key + button:SetScript("OnClick", function() + frame:ShowTab(this.BNPTabKey) + end) + frame.tabs[def.key] = button + tabX = tabX + def.width + 4 + end + + -- NAMEPLATES TAB --------------------------------------------------------- + local nameplatesPage = frame.pages.nameplates + CreateSection(nameplatesPage, "Nameplates", -4) + + local scale = CreateSlider(nameplatesPage, "Nameplate Scale", 0.70, 1.50, 0.05, -38) + scale:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = Round(this:GetValue(), 0.05) + BNP_DB.nameplateScale = value + getglobal(this:GetName() .. "Text"):SetText("Nameplate Scale: " .. string.format("%.2f", value)) + if BNP.ApplyNameplateScaleAll then BNP:ApplyNameplateScaleAll() end + end) + frame.scaleSlider = scale + + local yOffset = CreateSlider(nameplatesPage, "Nameplate Y Offset", 0, 50, 1, -86) + yOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.nameplateYOffset = value + getglobal(this:GetName() .. "Text"):SetText("Nameplate Y Offset: +" .. value) + if BNP.ApplyNameplateYOffsetAll then BNP:ApplyNameplateYOffsetAll() end + end) + frame.yOffsetSlider = yOffset + + local nonTargetAlpha = CreateSlider(nameplatesPage, "Non-Target Alpha", 30, 100, 5, -134) + nonTargetAlpha:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local percent = math.floor((this:GetValue() / 5) + 0.5) * 5 + if percent < 30 then percent = 30 end + if percent > 100 then percent = 100 end + BNP_DB.nonTargetAlpha = percent / 100 + getglobal(this:GetName() .. "Text"):SetText("Non-Target Alpha: " .. percent .. "%") + if BNP.RefreshNonTargetAlpha then BNP:RefreshNonTargetAlpha() end + end) + frame.nonTargetAlphaSlider = nonTargetAlpha + + local classColors = CreateCheck(nameplatesPage, "Class Colors", -180, function() + BNP_DB.classColors = this:GetChecked() and true or false + if BNP.RefreshClassColors then BNP:RefreshClassColors() end + end) + frame.classColorsCheck = classColors + + local hidePlayerNames = CreateCheck(nameplatesPage, "Hide Player Names", -180, function() + BNP_DB.hidePlayerNames = this:GetChecked() and true or false + if BNP.RefreshNameVisibility then BNP:RefreshNameVisibility() end + end, 190) + frame.hidePlayerNamesCheck = hidePlayerNames + + local tank = CreateCheck(nameplatesPage, "Tank Mode", -208, function() + BNP_DB.tankMode = this:GetChecked() and true or false + if BNP.UpdateTankMode then BNP:UpdateTankMode() end + end) + tank:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Tank Mode", 1, 0.82, 0) + GameTooltip:AddLine("Green: the unit is targeting you.", 1, 1, 1) + GameTooltip:AddLine("Red: the unit is targeting someone else.", 1, 1, 1) + GameTooltip:Show() + end) + tank:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.tankCheck = tank + + local hideNPCNames = CreateCheck(nameplatesPage, "Hide NPC/Mob Names", -208, function() + BNP_DB.hideNPCNames = this:GetChecked() and true or false + if BNP.RefreshNameVisibility then BNP:RefreshNameVisibility() end + end, 190) + frame.hideNPCNamesCheck = hideNPCNames + + local healthTextLabel = nameplatesPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") + healthTextLabel:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 218, -242) + healthTextLabel:SetText("Health Text") + + local healthTextDropdown = CreateFrame("Frame", "BNPHealthTextDropdown", nameplatesPage, "UIDropDownMenuTemplate") + healthTextDropdown:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 200, -254) + UIDropDownMenu_SetWidth(150, healthTextDropdown) + + local healthModeLabels = { + off = "Off", + percent = "Percent", + hp = "HP", + both = "HP + Percent", + } + + local function SetHealthTextMode(mode) + if not healthModeLabels[mode] then mode = "off" end + BNP_DB.healthText = mode + BNP_DB.healthPercent = (mode == "percent" or mode == "both") + UIDropDownMenu_SetSelectedValue(healthTextDropdown, mode) + UIDropDownMenu_SetText(healthModeLabels[mode], healthTextDropdown) + if BNP.RefreshHealthPercent then BNP:RefreshHealthPercent() end + end + + UIDropDownMenu_Initialize(healthTextDropdown, function() + local modes = { "off", "percent", "hp", "both" } + local n + for n = 1, table.getn(modes) do + local mode = modes[n] + local info = {} + info.text = healthModeLabels[mode] + info.value = mode + info.func = function() SetHealthTextMode(this.value) end + info.checked = (BNP:GetHealthTextMode() == mode) + UIDropDownMenu_AddButton(info) + end + end) + frame.healthTextDropdown = healthTextDropdown + frame.SetHealthTextMode = SetHealthTextMode + + if comboOptionsClass then + local comboPoints = CreateCheck(nameplatesPage, "Combo Points", -236, function() + BNP_DB.comboPoints = this:GetChecked() and true or false + if BNP.RefreshComboPoints then BNP:RefreshComboPoints() end + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end, 22) + frame.comboPointsCheck = comboPoints + + local comboYOffset = CreateSlider(nameplatesPage, "Combo Point Y Offset", -50, 50, 1, -274, 28, 150) + comboYOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.comboPointsYOffset = value + getglobal(this:GetName() .. "Text"):SetText("Combo Point Y Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshComboPoints then BNP:RefreshComboPoints() end + end) + frame.comboPointsYOffsetSlider = comboYOffset + end + + -- AURAS TAB -------------------------------------------------------------- + local aurasPage = frame.pages.auras + CreateSection(aurasPage, "Auras & Crowd Control", -4) + + local icon = CreateSlider(aurasPage, "Aura Icon Size", 12, 32, 1, -38, 28, 150) + icon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.iconSize = value + getglobal(this:GetName() .. "Text"):SetText("Aura Icon Size: " .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.iconSlider = icon + + local debuffYOffset = CreateSlider(aurasPage, "Debuff Y Offset", -50, 50, 1, -38, 220, 150) + debuffYOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.debuffYOffset = value + getglobal(this:GetName() .. "Text"):SetText("Debuff Y Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.debuffYOffsetSlider = debuffYOffset + + local ccIcon = CreateSlider(aurasPage, "CC Icon Size", 12, 32, 1, -86, 28, 150) + ccIcon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.ccIconSize = value + getglobal(this:GetName() .. "Text"):SetText("CC Icon Size: " .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.ccIconSlider = ccIcon + + local ccYOffset = CreateSlider(aurasPage, "CC Y Offset", -50, 50, 1, -86, 220, 150) + ccYOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.ccYOffset = value + getglobal(this:GetName() .. "Text"):SetText("CC Y Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.ccYOffsetSlider = ccYOffset + + local debuffs = CreateCheck(aurasPage, "Debuffs", -134, function() + BNP_DB.debuffs = this:GetChecked() and true or false + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.debuffsCheck = debuffs + + local debuffPositionDropdown = CreateFrame("Frame", "BNPDebuffPositionDropdown", aurasPage, "UIDropDownMenuTemplate") + debuffPositionDropdown:SetPoint("TOPLEFT", aurasPage, "TOPLEFT", 188, -128) + UIDropDownMenu_SetWidth(112, debuffPositionDropdown) + + local debuffPositionLabels = { + top = "Top Mid", + top_left = "Top Left", + top_right = "Top Right", + left = "Left", + right = "Right", + bottom_mid = "Bottom Mid", + bottom_left = "Bottom Left", + bottom_right = "Bottom Right", + } + + local function SetDebuffPosition(position) + if not debuffPositionLabels[position] then position = "top" end + BNP_DB.debuffPosition = position + UIDropDownMenu_SetSelectedValue(debuffPositionDropdown, position) + UIDropDownMenu_SetText(debuffPositionLabels[position], debuffPositionDropdown) + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshTotemIndicators then BNP:RefreshTotemIndicators() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end + + UIDropDownMenu_Initialize(debuffPositionDropdown, function() + local positions = { "top", "top_left", "top_right", "left", "right", "bottom_mid", "bottom_left", "bottom_right" } + local n + for n = 1, table.getn(positions) do + local position = positions[n] + local info = {} + info.text = debuffPositionLabels[position] + info.value = position + info.func = function() SetDebuffPosition(this.value) end + info.checked = (BNP:GetDebuffPosition() == position) + UIDropDownMenu_AddButton(info) + end + end) + frame.debuffPositionDropdown = debuffPositionDropdown + frame.SetDebuffPosition = SetDebuffPosition + + local debuffPositionLabel = aurasPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + debuffPositionLabel:SetPoint("BOTTOMLEFT", debuffPositionDropdown, "TOPLEFT", 18, 5) + debuffPositionLabel:SetText("Debuff Position") + frame.debuffPositionLabel = debuffPositionLabel + + local crowdControl = CreateCheck(aurasPage, "Crowd Control", -190, function() + BNP_DB.crowdControl = this:GetChecked() and true or false + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.crowdControlCheck = crowdControl + + local ccPositionDropdown = CreateFrame("Frame", "BNPCCPositionDropdown", aurasPage, "UIDropDownMenuTemplate") + ccPositionDropdown:SetPoint("TOPLEFT", aurasPage, "TOPLEFT", 188, -184) + UIDropDownMenu_SetWidth(92, ccPositionDropdown) + + local ccPositionLabels = { + top = "Top", + left = "Left", + right = "Right", + } + + local function SetCCPosition(position) + if not ccPositionLabels[position] then position = "top" end + BNP_DB.ccPosition = position + UIDropDownMenu_SetSelectedValue(ccPositionDropdown, position) + UIDropDownMenu_SetText(ccPositionLabels[position], ccPositionDropdown) + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end + + UIDropDownMenu_Initialize(ccPositionDropdown, function() + local positions = { "top", "left", "right" } + local n + for n = 1, table.getn(positions) do + local position = positions[n] + local info = {} + info.text = ccPositionLabels[position] + info.value = position + info.func = function() SetCCPosition(this.value) end + info.checked = (BNP:GetCCPosition() == position) + UIDropDownMenu_AddButton(info) + end + end) + frame.ccPositionDropdown = ccPositionDropdown + frame.SetCCPosition = SetCCPosition + + local ccPositionLabel = aurasPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + ccPositionLabel:SetPoint("BOTTOMLEFT", ccPositionDropdown, "TOPLEFT", 18, 5) + ccPositionLabel:SetText("CC Position") + frame.ccPositionLabel = ccPositionLabel + + local separateCCRow = CreateCheck(aurasPage, "Display CCs in Separate Row", -222, function() + BNP_DB.separateCCRow = this:GetChecked() and true or false + if BNP.RefreshAllAuraLayouts then BNP:RefreshAllAuraLayouts() end + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end, 42) + frame.separateCCRowCheck = separateCCRow + + local showOtherCCs = CreateCheck(aurasPage, "Show CCs from Other Players", -250, function() + BNP_DB.showOtherCCs = this:GetChecked() and true or false + if BNP.RefreshDebuffVisibility then BNP:RefreshDebuffVisibility() end + end, 42) + frame.showOtherCCsCheck = showOtherCCs + + local pvpImmunities = CreateCheck(aurasPage, "PvP Immunities", -302, function() + BNP_DB.pvpImmunities = this:GetChecked() and true or false + if BNP.RefreshImmunityVisibility then BNP:RefreshImmunityVisibility() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end, 42) + frame.pvpImmunitiesCheck = pvpImmunities + + local immunityPositionDropdown = CreateFrame("Frame", "BNPImmunityPositionDropdown", aurasPage, "UIDropDownMenuTemplate") + immunityPositionDropdown:SetPoint("TOPLEFT", aurasPage, "TOPLEFT", 188, -296) + UIDropDownMenu_SetWidth(92, immunityPositionDropdown) + + local immunityPositionLabels = { + top = "Top", + left = "Left", + right = "Right", + } + + local function SetImmunityPosition(position) + if not immunityPositionLabels[position] then position = "top" end + BNP_DB.immunityPosition = position + UIDropDownMenu_SetSelectedValue(immunityPositionDropdown, position) + UIDropDownMenu_SetText(immunityPositionLabels[position], immunityPositionDropdown) + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end + + UIDropDownMenu_Initialize(immunityPositionDropdown, function() + local positions = { "top", "left", "right" } + local n + for n = 1, table.getn(positions) do + local position = positions[n] + local info = {} + info.text = immunityPositionLabels[position] + info.value = position + info.func = function() SetImmunityPosition(this.value) end + info.checked = (BNP:GetImmunityPosition() == position) + UIDropDownMenu_AddButton(info) + end + end) + frame.immunityPositionDropdown = immunityPositionDropdown + frame.SetImmunityPosition = SetImmunityPosition + + local immunityPositionLabel = aurasPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + immunityPositionLabel:SetPoint("BOTTOMLEFT", immunityPositionDropdown, "TOPLEFT", 18, 5) + immunityPositionLabel:SetText("Immunity Position") + frame.immunityPositionLabel = immunityPositionLabel + + local immunityIcon = CreateSlider(aurasPage, "Immunity Icon Size", 12, 32, 1, -350, 28, 150) + immunityIcon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.immunityIconSize = value + getglobal(this:GetName() .. "Text"):SetText("Immunity Icon Size: " .. value) + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.immunityIconSlider = immunityIcon + + local immunityYOffset = CreateSlider(aurasPage, "Immunity Y Offset", -50, 50, 1, -350, 220, 150) + immunityYOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.immunityYOffset = value + getglobal(this:GetName() .. "Text"):SetText("Immunity Y Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshAllImmunityLayouts then BNP:RefreshAllImmunityLayouts() end + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.immunityYOffsetSlider = immunityYOffset + + -- TOTEMS TAB ------------------------------------------------------------- + local totemsPage = frame.pages.totems + CreateSection(totemsPage, "Totem Indicators", -4) + + local totemIndicators = CreateCheck(totemsPage, "Enable Totem Icons", -42, function() + BNP_DB.totemIndicators = this:GetChecked() and true or false + if BNP.RefreshTotemIndicators then BNP:RefreshTotemIndicators() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end) + frame.totemIndicatorsCheck = totemIndicators + + local totemIcon = CreateSlider(totemsPage, "Totem Icon Size", 16, 36, 1, -92) + totemIcon:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.totemIconSize = value + getglobal(this:GetName() .. "Text"):SetText("Totem Icon Size: " .. value) + if BNP.RefreshTotemIndicators then BNP:RefreshTotemIndicators() end + end) + frame.totemIconSlider = totemIcon + + local totemNote = totemsPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + totemNote:SetPoint("TOPLEFT", totemsPage, "TOPLEFT", 28, -150) + totemNote:SetWidth(340) + totemNote:SetJustifyH("LEFT") + totemNote:SetText("Replaces all visible shaman totem nameplates with compact icons. There are no individual totem filters.") + + + -- CASTBAR TAB ------------------------------------------------------------ + local castbarPage = frame.pages.castbar + CreateSection(castbarPage, "Castbar", -4) + + local castbars = CreateCheck(castbarPage, "Castbars", -42, function() + local enabled = this:GetChecked() and true or false + if BNP.SetCastbarsEnabled then BNP:SetCastbarsEnabled(enabled) else BNP_DB.castbars = enabled end + end) + frame.castbarsCheck = castbars + + local castbarTest = CreateCheck(castbarPage, "Test Castbars", -42, function() + if BNP.SetCastbarTestMode then BNP:SetCastbarTestMode(this:GetChecked() and true or false) end + end, 164) + castbarTest:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Test Castbars", 1, 0.82, 0) + GameTooltip:AddLine("Shows a looping simulated castbar on every visible nameplate.", 1, 1, 1, true) + GameTooltip:AddLine("Use it to adjust height and spacing live. The test mode is not saved.", 0.8, 0.8, 0.8, true) + GameTooltip:Show() + end) + castbarTest:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.castbarTestCheck = castbarTest + + local castbarHeight = CreateSlider(castbarPage, "Castbar Height", 4, 14, 1, -94) + castbarHeight:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.castbarHeight = value + getglobal(this:GetName() .. "Text"):SetText("Castbar Height: " .. value) + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() elseif BNP.RefreshCastbarHeights then BNP:RefreshCastbarHeights() end + end) + frame.castbarHeightSlider = castbarHeight + + local castbarSpacing = CreateSlider(castbarPage, "Castbar Spacing", 0, 10, 1, -148) + castbarSpacing:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.castbarSpacing = value + getglobal(this:GetName() .. "Text"):SetText("Castbar Spacing: " .. value .. " px") + if BNP.RefreshCastbarLayout then BNP:RefreshCastbarLayout() end + end) + frame.castbarSpacingSlider = castbarSpacing + + -- TARGET TAB ------------------------------------------------------------- + local targetPage = frame.pages.target + CreateSection(targetPage, "Target", -4) + + local targetFocus = CreateCheck(targetPage, "Target Glow", -42, function() + BNP_DB.targetFocus = this:GetChecked() and true or false + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end) + frame.targetFocusCheck = targetFocus + + local glowColorLabel = targetPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") + glowColorLabel:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 28, -88) + glowColorLabel:SetText("Target Color") + frame.glowColorLabel = glowColorLabel + + local glowColorDropdown = CreateFrame("Frame", "BNPTargetGlowColorDropdown", targetPage, "UIDropDownMenuTemplate") + glowColorDropdown:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 10, -100) + UIDropDownMenu_SetWidth(150, glowColorDropdown) + + local glowColorLabels = { + white = "White", + gold = "Gold", + blue = "Blue", + green = "Green", + red = "Red", + purple = "Purple", + } + + local function SetTargetGlowColor(color) + if not glowColorLabels[color] then color = "white" end + BNP_DB.targetGlowColor = color + UIDropDownMenu_SetSelectedValue(glowColorDropdown, color) + UIDropDownMenu_SetText(glowColorLabels[color], glowColorDropdown) + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end + + UIDropDownMenu_Initialize(glowColorDropdown, function() + local colors = { "white", "gold", "blue", "green", "red", "purple" } + local n + for n = 1, table.getn(colors) do + local color = colors[n] + local info = {} + info.text = glowColorLabels[color] + info.value = color + info.func = function() SetTargetGlowColor(this.value) end + info.checked = ((BNP_DB and BNP_DB.targetGlowColor) or "white") == color + UIDropDownMenu_AddButton(info) + end + end) + frame.glowColorDropdown = glowColorDropdown + frame.SetTargetGlowColor = SetTargetGlowColor + + local glowSize = CreateSlider(targetPage, "Target Glow Size", 0, 20, 1, -168) + glowSize:SetWidth(145) + glowSize:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.targetGlowSize = value + getglobal(this:GetName() .. "Text"):SetText("Target Glow Size: +" .. value .. " px") + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end) + frame.targetGlowSizeSlider = glowSize + + local glowOpacity = CreateSlider(targetPage, "Target Glow Opacity", 20, 100, 5, -222) + glowOpacity:SetWidth(145) + glowOpacity:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.targetGlowOpacity = value / 100 + getglobal(this:GetName() .. "Text"):SetText("Target Glow Opacity: " .. value .. "%") + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end) + frame.targetGlowOpacitySlider = glowOpacity + + local targetArrows = CreateCheck(targetPage, "Target Arrows", -42, function() + BNP_DB.targetArrows = this:GetChecked() and true or false + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + end) + targetArrows:ClearAllPoints() + targetArrows:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 204, -42) + frame.targetArrowsCheck = targetArrows + + local arrowColorLabel = targetPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") + arrowColorLabel:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 210, -88) + arrowColorLabel:SetText("Arrow Color") + frame.arrowColorLabel = arrowColorLabel + + local arrowColorDropdown = CreateFrame("Frame", "BNPTargetArrowColorDropdown", targetPage, "UIDropDownMenuTemplate") + arrowColorDropdown:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 192, -100) + UIDropDownMenu_SetWidth(140, arrowColorDropdown) + + local arrowColorLabels = { + match = "Match Target Color", + white = "White", + gold = "Gold", + blue = "Blue", + green = "Green", + red = "Red", + purple = "Purple", + } + + local function SetTargetArrowColor(color) + if not arrowColorLabels[color] then color = "match" end + BNP_DB.targetArrowColor = color + UIDropDownMenu_SetSelectedValue(arrowColorDropdown, color) + UIDropDownMenu_SetText(arrowColorLabels[color], arrowColorDropdown) + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end + + UIDropDownMenu_Initialize(arrowColorDropdown, function() + local colors = { "match", "white", "gold", "blue", "green", "red", "purple" } + local n + for n = 1, table.getn(colors) do + local color = colors[n] + local info = {} + info.text = arrowColorLabels[color] + info.value = color + info.func = function() SetTargetArrowColor(this.value) end + info.checked = ((BNP_DB and BNP_DB.targetArrowColor) or "match") == color + UIDropDownMenu_AddButton(info) + end + end) + frame.arrowColorDropdown = arrowColorDropdown + frame.SetTargetArrowColor = SetTargetArrowColor + + local arrowStyleLabel = targetPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") + arrowStyleLabel:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 210, -142) + arrowStyleLabel:SetText("Arrow Style") + frame.arrowStyleLabel = arrowStyleLabel + + local arrowStyleDropdown = CreateFrame("Frame", "BNPTargetArrowStyleDropdown", targetPage, "UIDropDownMenuTemplate") + arrowStyleDropdown:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 192, -154) + UIDropDownMenu_SetWidth(140, arrowStyleDropdown) + + local arrowStyleLabels = { + chevron = "Chevron", + triangle = "Triangle", + slim_triangle = "Slim Triangle", + double_chevron = "Double Chevron", + diamond_tip = "Diamond Tip", + } + + local function SetTargetArrowStyle(style) + if not arrowStyleLabels[style] then style = "chevron" end + BNP_DB.targetArrowStyle = style + UIDropDownMenu_SetSelectedValue(arrowStyleDropdown, style) + UIDropDownMenu_SetText(arrowStyleLabels[style], arrowStyleDropdown) + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end + + UIDropDownMenu_Initialize(arrowStyleDropdown, function() + local styles = { "chevron", "triangle", "slim_triangle", "double_chevron", "diamond_tip" } + local n + for n = 1, table.getn(styles) do + local style = styles[n] + local info = {} + info.text = arrowStyleLabels[style] + info.value = style + info.func = function() SetTargetArrowStyle(this.value) end + info.checked = ((BNP_DB and BNP_DB.targetArrowStyle) or "chevron") == style + UIDropDownMenu_AddButton(info) + end + end) + frame.arrowStyleDropdown = arrowStyleDropdown + frame.SetTargetArrowStyle = SetTargetArrowStyle + + local arrowSize = CreateSlider(targetPage, "Target Arrow Size", 10, 24, 1, -222) + arrowSize:ClearAllPoints() + arrowSize:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 210, -222) + arrowSize:SetWidth(145) + arrowSize:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = math.floor(this:GetValue() + 0.5) + BNP_DB.targetArrowSize = value + getglobal(this:GetName() .. "Text"):SetText("Target Arrow Size: " .. value) + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end) + frame.targetArrowSizeSlider = arrowSize + + local thickArrows = CreateCheck(targetPage, "Thick Arrows", -276, function() + BNP_DB.targetArrowThick = this:GetChecked() and true or false + if BNP.RefreshTargetFocus then BNP:RefreshTargetFocus() end + end) + thickArrows:ClearAllPoints() + thickArrows:SetPoint("TOPLEFT", targetPage, "TOPLEFT", 204, -276) + frame.targetArrowThickCheck = thickArrows + + -- TOOLS TAB -------------------------------------------------------------- + local toolsPage = frame.pages.tools + CreateSection(toolsPage, "Tools", -4) + + local auraRecorder = CreateFrame("Button", nil, toolsPage, "UIPanelButtonTemplate") + auraRecorder:SetPoint("TOPLEFT", toolsPage, "TOPLEFT", 28, -48) + auraRecorder:SetWidth(254) + auraRecorder:SetHeight(24) + auraRecorder:SetText("Missing Spell / Aura...") + auraRecorder:SetScript("OnClick", function() BNP:OpenAuraRecorder() end) + auraRecorder:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Missing Spell / Aura Recorder", 1, 0.82, 0) + GameTooltip:AddLine("Records target aura IDs, SuperWoW events and refresh signals in one copyable report.", 1, 1, 1, true) + GameTooltip:Show() + end) + auraRecorder:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.auraRecorderButton = auraRecorder + + local note = toolsPage:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + note:SetPoint("TOPLEFT", toolsPage, "TOPLEFT", 28, -88) + note:SetWidth(330) + note:SetJustifyH("LEFT") + note:SetText("Diagnostic tools for testing missing spells and aura refresh behavior.") + + function frame:UpdateDependentControls() + local ccEnabled = BNP:AreCrowdControlEnabled() + local debuffsEnabled = BNP:AreDebuffsEnabled() + local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top" + local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition + local separateRowAvailable = ccEnabled and debuffsEnabled and (debuffPosition == "top" or debuffPosition == "top_left" or debuffPosition == "top_right") and ccPosition == "top" + + SetCheckEnabled(self.separateCCRowCheck, separateRowAvailable) + if self.separateCCRowCheck then + self.separateCCRowCheck:SetChecked(separateRowAvailable and BNP:IsSeparateCCRowEnabled() or false) + end + SetCheckEnabled(self.showOtherCCsCheck, ccEnabled) + if self.debuffYOffsetSlider then + self.debuffYOffsetSlider:SetAlpha(debuffsEnabled and 1.0 or 0.45) + if self.debuffYOffsetSlider.EnableMouse then self.debuffYOffsetSlider:EnableMouse(debuffsEnabled) end + end + if self.ccYOffsetSlider then + self.ccYOffsetSlider:SetAlpha(ccEnabled and 1.0 or 0.45) + if self.ccYOffsetSlider.EnableMouse then self.ccYOffsetSlider:EnableMouse(ccEnabled) end + end + + local comboEnabled = BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() or false + if self.comboPointsYOffsetSlider then + self.comboPointsYOffsetSlider:SetAlpha(comboEnabled and 1.0 or 0.45) + if self.comboPointsYOffsetSlider.EnableMouse then self.comboPointsYOffsetSlider:EnableMouse(comboEnabled) end + end + + local immunitiesEnabled = BNP.ArePvPImmunitiesEnabled and BNP:ArePvPImmunitiesEnabled() or false + if self.immunityIconSlider then + self.immunityIconSlider:SetAlpha(immunitiesEnabled and 1.0 or 0.45) + if self.immunityIconSlider.EnableMouse then self.immunityIconSlider:EnableMouse(immunitiesEnabled) end + end + if self.immunityYOffsetSlider then + self.immunityYOffsetSlider:SetAlpha(immunitiesEnabled and 1.0 or 0.45) + if self.immunityYOffsetSlider.EnableMouse then self.immunityYOffsetSlider:EnableMouse(immunitiesEnabled) end + end + if self.immunityPositionLabel then + self.immunityPositionLabel:SetTextColor(immunitiesEnabled and 1 or 0.5, immunitiesEnabled and 1 or 0.5, immunitiesEnabled and 1 or 0.5) + end + if self.immunityPositionDropdown then + self.immunityPositionDropdown:SetAlpha(immunitiesEnabled and 1.0 or 0.45) + if immunitiesEnabled then + if UIDropDownMenu_EnableDropDown then UIDropDownMenu_EnableDropDown(self.immunityPositionDropdown) end + else + if UIDropDownMenu_DisableDropDown then UIDropDownMenu_DisableDropDown(self.immunityPositionDropdown) end + end + end + + local totemsEnabled = BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() or false + if self.totemIconSlider then + self.totemIconSlider:SetAlpha(totemsEnabled and 1.0 or 0.45) + if self.totemIconSlider.EnableMouse then self.totemIconSlider:EnableMouse(totemsEnabled) end + end + + local glowEnabled = BNP:IsTargetFocusEnabled() + local arrowsEnabled = BNP.AreTargetArrowsEnabled and BNP:AreTargetArrowsEnabled() or false + local targetColorEnabled = glowEnabled or arrowsEnabled + if self.glowColorLabel then + self.glowColorLabel:SetTextColor(targetColorEnabled and 1 or 0.5, targetColorEnabled and 0.82 or 0.5, targetColorEnabled and 0 or 0.5) + end + if self.glowColorDropdown then + self.glowColorDropdown:SetAlpha(targetColorEnabled and 1.0 or 0.45) + if targetColorEnabled then + if UIDropDownMenu_EnableDropDown then UIDropDownMenu_EnableDropDown(self.glowColorDropdown) end + else + if UIDropDownMenu_DisableDropDown then UIDropDownMenu_DisableDropDown(self.glowColorDropdown) end + end + end + if self.targetGlowSizeSlider then + self.targetGlowSizeSlider:SetAlpha(glowEnabled and 1.0 or 0.45) + if self.targetGlowSizeSlider.EnableMouse then self.targetGlowSizeSlider:EnableMouse(glowEnabled) end + end + if self.targetGlowOpacitySlider then + self.targetGlowOpacitySlider:SetAlpha(glowEnabled and 1.0 or 0.45) + if self.targetGlowOpacitySlider.EnableMouse then self.targetGlowOpacitySlider:EnableMouse(glowEnabled) end + end + if self.arrowColorLabel then + self.arrowColorLabel:SetTextColor(arrowsEnabled and 1 or 0.5, arrowsEnabled and 0.82 or 0.5, arrowsEnabled and 0 or 0.5) + end + if self.arrowColorDropdown then + self.arrowColorDropdown:SetAlpha(arrowsEnabled and 1.0 or 0.45) + if arrowsEnabled then + if UIDropDownMenu_EnableDropDown then UIDropDownMenu_EnableDropDown(self.arrowColorDropdown) end + else + if UIDropDownMenu_DisableDropDown then UIDropDownMenu_DisableDropDown(self.arrowColorDropdown) end + end + end + if self.arrowStyleLabel then + self.arrowStyleLabel:SetTextColor(arrowsEnabled and 1 or 0.5, arrowsEnabled and 0.82 or 0.5, arrowsEnabled and 0 or 0.5) + end + if self.arrowStyleDropdown then + self.arrowStyleDropdown:SetAlpha(arrowsEnabled and 1.0 or 0.45) + if arrowsEnabled then + if UIDropDownMenu_EnableDropDown then UIDropDownMenu_EnableDropDown(self.arrowStyleDropdown) end + else + if UIDropDownMenu_DisableDropDown then UIDropDownMenu_DisableDropDown(self.arrowStyleDropdown) end + end + end + if self.targetArrowSizeSlider then + self.targetArrowSizeSlider:SetAlpha(arrowsEnabled and 1.0 or 0.45) + if self.targetArrowSizeSlider.EnableMouse then self.targetArrowSizeSlider:EnableMouse(arrowsEnabled) end + end + SetCheckEnabled(self.targetArrowThickCheck, arrowsEnabled) + end + + frame:ShowTab("nameplates") + self.optionsFrame = frame +end + +function BNP:SyncOptions() + self:CreateOptions() + local frame = self.optionsFrame + frame.scaleSlider:SetValue(self:GetNameplateScale()) + if frame.yOffsetSlider then + local yOffset = self:GetNameplateYOffset() + frame.yOffsetSlider:SetValue(yOffset) + getglobal(frame.yOffsetSlider:GetName() .. "Text"):SetText("Nameplate Y Offset: +" .. yOffset) + end + if frame.comboPointsYOffsetSlider then + local value = self:GetComboPointsYOffset() + frame.comboPointsYOffsetSlider:SetValue(value) + getglobal(frame.comboPointsYOffsetSlider:GetName() .. "Text"):SetText("Combo Point Y Offset: " .. (value > 0 and "+" or "") .. value) + end + if frame.debuffYOffsetSlider then + local value = self:GetDebuffYOffset() + frame.debuffYOffsetSlider:SetValue(value) + getglobal(frame.debuffYOffsetSlider:GetName() .. "Text"):SetText("Debuff Y Offset: " .. (value > 0 and "+" or "") .. value) + end + if frame.ccYOffsetSlider then + local value = self:GetCCYOffset() + frame.ccYOffsetSlider:SetValue(value) + getglobal(frame.ccYOffsetSlider:GetName() .. "Text"):SetText("CC Y Offset: " .. (value > 0 and "+" or "") .. value) + end + if frame.immunityYOffsetSlider then + local value = self:GetImmunityYOffset() + frame.immunityYOffsetSlider:SetValue(value) + getglobal(frame.immunityYOffsetSlider:GetName() .. "Text"):SetText("Immunity Y Offset: " .. (value > 0 and "+" or "") .. value) + end + if frame.nonTargetAlphaSlider then + local alphaPercent = math.floor((self:GetNonTargetAlpha() * 100) + 0.5) + frame.nonTargetAlphaSlider:SetValue(alphaPercent) + getglobal(frame.nonTargetAlphaSlider:GetName() .. "Text"):SetText("Non-Target Alpha: " .. alphaPercent .. "%") + end + frame.iconSlider:SetValue(self:GetIconSize()) + if frame.ccIconSlider then + local ccSize = self:GetCCIconSize() + frame.ccIconSlider:SetValue(ccSize) + getglobal(frame.ccIconSlider:GetName() .. "Text"):SetText("CC Icon Size: " .. ccSize) + end + if frame.immunityIconSlider then + local immunitySize = self:GetImmunityIconSize() + frame.immunityIconSlider:SetValue(immunitySize) + getglobal(frame.immunityIconSlider:GetName() .. "Text"):SetText("Immunity Icon Size: " .. immunitySize) + end + if frame.totemIconSlider then + local totemSize = self:GetTotemIconSize() + frame.totemIconSlider:SetValue(totemSize) + getglobal(frame.totemIconSlider:GetName() .. "Text"):SetText("Totem Icon Size: " .. totemSize) + end + frame.castbarHeightSlider:SetValue(self:GetCastbarHeight()) + if frame.castbarSpacingSlider then + local spacing = self:GetCastbarSpacing() + frame.castbarSpacingSlider:SetValue(spacing) + getglobal(frame.castbarSpacingSlider:GetName() .. "Text"):SetText("Castbar Spacing: " .. spacing .. " px") + end + frame.classColorsCheck:SetChecked(self:AreClassColorsEnabled()) + if frame.hidePlayerNamesCheck then frame.hidePlayerNamesCheck:SetChecked(self:HidePlayerNamesEnabled()) end + if frame.hideNPCNamesCheck then frame.hideNPCNamesCheck:SetChecked(self:HideNPCNamesEnabled()) end + if frame.debuffsCheck then frame.debuffsCheck:SetChecked(self:AreDebuffsEnabled()) end + if frame.debuffPositionDropdown then + local position = self:GetDebuffPosition() + local labels = { top = "Top Mid", top_left = "Top Left", top_right = "Top Right", left = "Left", right = "Right", bottom_mid = "Bottom Mid", bottom_left = "Bottom Left", bottom_right = "Bottom Right" } + UIDropDownMenu_SetSelectedValue(frame.debuffPositionDropdown, position) + UIDropDownMenu_SetText(labels[position] or "Top Mid", frame.debuffPositionDropdown) + end + if frame.ccPositionDropdown then + local position = self:GetCCPosition() + local labels = { top = "Top", left = "Left", right = "Right" } + UIDropDownMenu_SetSelectedValue(frame.ccPositionDropdown, position) + UIDropDownMenu_SetText(labels[position] or "Top", frame.ccPositionDropdown) + end + if frame.crowdControlCheck then frame.crowdControlCheck:SetChecked(self:AreCrowdControlEnabled()) end + if frame.separateCCRowCheck then frame.separateCCRowCheck:SetChecked(self:IsSeparateCCRowEnabled()) end + if frame.showOtherCCsCheck then frame.showOtherCCsCheck:SetChecked(self:ShowOtherPlayersCCs()) end + if frame.pvpImmunitiesCheck then frame.pvpImmunitiesCheck:SetChecked(self:ArePvPImmunitiesEnabled()) end + if frame.immunityPositionDropdown then + local position = self:GetImmunityPosition() + local labels = { top = "Top", left = "Left", right = "Right" } + UIDropDownMenu_SetSelectedValue(frame.immunityPositionDropdown, position) + UIDropDownMenu_SetText(labels[position] or "Top", frame.immunityPositionDropdown) + end + if frame.totemIndicatorsCheck then frame.totemIndicatorsCheck:SetChecked(self:AreTotemIndicatorsEnabled()) end + frame.castbarsCheck:SetChecked(self:AreCastbarsEnabled()) + if frame.castbarTestCheck then + frame.castbarTestCheck:SetChecked(self.IsCastbarTestMode and self:IsCastbarTestMode() or false) + end + frame.tankCheck:SetChecked(self:IsTankModeEnabled()) + if frame.comboPointsCheck and comboOptionsClass then + frame.comboPointsCheck:SetChecked(self:AreComboPointsEnabled()) + end + if frame.healthTextDropdown then + local mode = self:GetHealthTextMode() + UIDropDownMenu_SetSelectedValue(frame.healthTextDropdown, mode) + local labels = { off = "Off", percent = "Percent", hp = "HP", both = "HP + Percent" } + UIDropDownMenu_SetText(labels[mode] or "Off", frame.healthTextDropdown) + end + frame.targetFocusCheck:SetChecked(self:IsTargetFocusEnabled()) + if frame.targetArrowsCheck then + frame.targetArrowsCheck:SetChecked(self.AreTargetArrowsEnabled and self:AreTargetArrowsEnabled() or false) + end + if frame.arrowColorDropdown then + local _, _, _, color = self:GetTargetArrowColor() + local labels = { + match = "Match Target Color", + white = "White", + gold = "Gold", + blue = "Blue", + green = "Green", + red = "Red", + purple = "Purple", + } + UIDropDownMenu_SetSelectedValue(frame.arrowColorDropdown, color or "match") + UIDropDownMenu_SetText(labels[color] or "Match Target Color", frame.arrowColorDropdown) + end + if frame.arrowStyleDropdown then + local style = self:GetTargetArrowStyle() + local labels = { + chevron = "Chevron", + triangle = "Triangle", + slim_triangle = "Slim Triangle", + double_chevron = "Double Chevron", + diamond_tip = "Diamond Tip", + } + UIDropDownMenu_SetSelectedValue(frame.arrowStyleDropdown, style) + UIDropDownMenu_SetText(labels[style] or "Chevron", frame.arrowStyleDropdown) + end + if frame.targetArrowSizeSlider then + local arrowSize = self:GetTargetArrowSize() + frame.targetArrowSizeSlider:SetValue(arrowSize) + getglobal(frame.targetArrowSizeSlider:GetName() .. "Text"):SetText("Target Arrow Size: " .. arrowSize) + end + if frame.targetArrowThickCheck then + frame.targetArrowThickCheck:SetChecked(self.AreTargetArrowsThick and self:AreTargetArrowsThick() or false) + end + + if frame.glowColorDropdown then + local _, _, _, color = self:GetTargetGlowColor() + local labels = { + white = "White", + gold = "Gold", + blue = "Blue", + green = "Green", + red = "Red", + purple = "Purple", + } + UIDropDownMenu_SetSelectedValue(frame.glowColorDropdown, color or "white") + UIDropDownMenu_SetText(labels[color] or "White", frame.glowColorDropdown) + end + if frame.targetGlowSizeSlider then + local glowSize = self:GetTargetGlowSize() + frame.targetGlowSizeSlider:SetValue(glowSize) + getglobal(frame.targetGlowSizeSlider:GetName() .. "Text"):SetText("Target Glow Size: +" .. glowSize .. " px") + end + if frame.targetGlowOpacitySlider then + local opacityPercent = math.floor((self:GetTargetGlowOpacity() * 100) + 0.5) + frame.targetGlowOpacitySlider:SetValue(opacityPercent) + getglobal(frame.targetGlowOpacitySlider:GetName() .. "Text"):SetText("Target Glow Opacity: " .. opacityPercent .. "%") + end + + if frame.UpdateDependentControls then frame:UpdateDependentControls() end + frame.versionText:SetText("Version " .. tostring(self.version or "?")) +end + +function BNP:ToggleOptions() + self:SyncOptions() + if self.optionsFrame:IsShown() then + self.optionsFrame:Hide() + else + self.optionsFrame:Show() + end +end + +function BNP:CreateMinimapButton() + if self.minimapButton then return end + + BNP_DB = BNP_DB or {} + if BNP_DB.minimapAngle == nil then BNP_DB.minimapAngle = 225 end + + local button = CreateFrame("Button", "BNPMinimapButton", Minimap) + button:SetWidth(31) + button:SetHeight(31) + button:SetFrameStrata("MEDIUM") + button:SetFrameLevel((Minimap:GetFrameLevel() or 1) + 8) + button:RegisterForClicks("LeftButtonUp") + button:RegisterForDrag("LeftButton") + button:EnableMouse(true) + button:SetMovable(true) + + -- Classic round minimap-button background. + local bg = button:CreateTexture(nil, "BACKGROUND") + bg:SetTexture("Interface\\Minimap\\UI-Minimap-Background") + bg:SetWidth(22) + bg:SetHeight(22) + bg:SetPoint("CENTER", button, "CENTER", 0, 0) + button.bg = bg + + -- Addon identity: simple BN+ lettering instead of a spell/item icon. + -- Rendered directly by the client, so no external texture file is needed. + local iconBG = button:CreateTexture(nil, "ARTWORK") + iconBG:SetTexture(0.03, 0.03, 0.03, 1) + iconBG:SetWidth(20) + iconBG:SetHeight(20) + iconBG:SetPoint("CENTER", button, "CENTER", 0, 0) + button.iconBG = iconBG + + local label = button:CreateFontString(nil, "OVERLAY") + label:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + label:SetPoint("CENTER", button, "CENTER", 0, 0) + label:SetText("BN+") + label:SetTextColor(0.20, 0.60, 1.00) + button.label = label + + -- Standard Blizzard circular minimap border. + local border = button:CreateTexture(nil, "OVERLAY") + border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + border:SetWidth(54) + border:SetHeight(54) + border:SetPoint("TOPLEFT", button, "TOPLEFT", 0, 0) + button.border = border + + local highlight = button:CreateTexture(nil, "HIGHLIGHT") + highlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + highlight:SetBlendMode("ADD") + highlight:SetAllPoints(button) + button.highlight = highlight + + local function UpdatePosition() + local angle = tonumber(BNP_DB.minimapAngle) or 225 + local radius = 78 + local rad = math.rad(angle) + + button:ClearAllPoints() + button:SetPoint( + "CENTER", + Minimap, + "CENTER", + math.cos(rad) * radius, + math.sin(rad) * radius + ) + end + + local function UpdateAngleFromCursor() + local mx, my = Minimap:GetCenter() + local scale = 1 + if UIParent.GetEffectiveScale then + scale = UIParent:GetEffectiveScale() or 1 + elseif UIParent.GetScale then + scale = UIParent:GetScale() or 1 + end + local cx, cy = GetCursorPosition() + cx = cx / scale + cy = cy / scale + + local dx = cx - mx + local dy = cy - my + + local angle + if math.atan2 then + angle = math.deg(math.atan2(dy, dx)) + else + if dx == 0 then + angle = dy >= 0 and 90 or -90 + else + angle = math.deg(math.atan(dy / dx)) + if dx < 0 then angle = angle + 180 end + end + end + BNP_DB.minimapAngle = angle + UpdatePosition() + end + + button:SetScript("OnDragStart", function() + this:SetScript("OnUpdate", UpdateAngleFromCursor) + end) + + button:SetScript("OnDragStop", function() + this:SetScript("OnUpdate", nil) + UpdateAngleFromCursor() + end) + + button:SetScript("OnClick", function() + BNP:ToggleOptions() + end) + + button:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + GameTooltip:SetText("Blizz Nameplates+") + GameTooltip:AddLine("Left-click: Open settings", 1, 1, 1) + GameTooltip:AddLine("Drag: Move minimap button", 0.8, 0.8, 0.8) + GameTooltip:Show() + end) + + button:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + UpdatePosition() + self.minimapButton = button +end diff --git a/core/ui.lua b/core/ui.lua new file mode 100644 index 0000000..57b6782 --- /dev/null +++ b/core/ui.lua @@ -0,0 +1,15 @@ +BNP = BNP or {} + +-- Central visual constants. Keeping these values in one place lets us polish +-- the Blizzard-style presentation without touching aura tracking logic. +BNP.UI = BNP.UI or { + ICON_SIZE = 18, + ICON_OFFSET_Y = 15, + ICON_SPACING = 2, + TIMER_FONT = "Fonts\\FRIZQT__.TTF", + TIMER_SIZE = 8, + TIMER_OFFSET_X = 0, + TIMER_OFFSET_Y = 0, + TIMER_SHADOW_X = 1, + TIMER_SHADOW_Y = -1, +} diff --git a/media/shagu_target_glow.tga b/media/shagu_target_glow.tga new file mode 100644 index 0000000000000000000000000000000000000000..8c8e74c5ab1d312b08eb4b4878f78cd4c4d6e4ac GIT binary patch literal 824 zcmb`Gy$ZrG6opfiHaZFJ(zS!`?kW^S!KyfS_aPJ)CpYma99`YI_)ztHAr&`&1E)0i zoUgfQQ)8N@8ZFZ@u{|)|aS6(@tVz*E7t1W7;=C;ZOv+YjOEJNOYV0dGfy-_H>P)4E z)RFp929)DuzJfy{aLfbbSy%&UE$so1fO}uTAz>X8vZ7}6Lw76&caqMqmykPO!68Ai z23g_kneEUW3jtig3-GH%1&0L5=ozQ$M9QV3bdd`18+hR>I3!5gKjJ?&|K+Ru`jb7t lK<}X;y?aPLK8`OBxcd$G5{&t5FV0XXvJd0a4H@z5hkjE(U%|8uG@?3zOM&nwJUrGRDJLpTjte zr^oeQezQOp$O2g)3uJ*TkOj6c@bmX<|JwV@EBpF*lhoU<-1Bce#q*1q8NGW_X{`}2 z(zG-$7vf}^7oFCY@uHiS>g7V5O!K1C+cIW!)02Iih?8kPWO`G^i*9A!Vx@oCiF2u<+FFLI)<3%?u)ysuAndU{Ow`I)erYHM25hv4p z$n>U+7v1z^A1C6xr}slbgQNJb|XHf^`_a@j`7a7ZRs9g&7A1n zgW`WJ_sTu5Hg7QNdwRcauqWQIW`Qh_1+qXE$O2g)3-~Q?{`fk6FQ3c%+w-1u*{!73zFF5#rr`lXziuh zvv2#hd$p5RC7=Y9fD%vwNKqn=T)B96GER z#qorvIl56jRydScF^b~}PxEx6+$%gvEOBwZ!_yqyT<%p4C04jL-{EPFZZ7vKhw{F{ z#c_9kn&XWqA1fS6tQf`dgr_;WQ9V{Tlvpu};|WjmbferWJW4EaalXUT9Nk>*RSqRq zxHjM6X^w6#-`@XaGj1`&NYIt(~t~sl9eqk-B%vP@_=kJ)YTIUzmg38?M_0;?w zh1J_d011diXQ<-Go^U!QN6 J!}_tjw;QE_*!lnf literal 0 HcmV?d00001 diff --git a/media/target_arrow_diamond_tip_thick.tga b/media/target_arrow_diamond_tip_thick.tga new file mode 100644 index 0000000000000000000000000000000000000000..376f45e60c966338dc506993b7dc2dde9e369bf5 GIT binary patch literal 16428 zcmeH^O-{o=422_B9D@tM8M-M_Bm@!^#QiS6z}DeKXr@=OXv0b7=%2E zweQ|HZ+hS4J=fY4|P%TIETqlrzDxJheb$^Fx$UYa^T zRZN1$Nn0CZV4tG(vDDk?xSbXkYY*2%pKkT=^yw)%9Ucd7FULrp>g7lD%W1Kd2zFEk z)~ua|;zs7QsWBBuW^@+Du%3eEW#rZAeK!nNR;JH%+s)!+=58%)Crn0mw$1p~#pa91 zzuDN{A{bdSY{uIz79TTrYvDU#vZAwnruA+#CnK*;kGWwmqcSmu?GzL*GN(+H_@_vH zG6$YWfi4r`G(A&hZp|6OUZicYR5D)7PGR-@NH# zB=0$wvoQ$y6vcP>R)X={SO2rHVm26Fx5$ezbJH!kH+>UAoo z^88d!xFZ21fCP{L5X^4b(=?C zocB7C_->9HwKm>Dwvp;1sgn`KbxEAKJ#jX?OB61m zYet{W<+iA&)5TO;I8kTB81c4H*)8gLWO3CNZlf7-HnL44e$9IsQGA!ivDX`Kq1)K= zkUZCs%WXh8G6$L4Wgi<%>6H-_M+fXL}vW%8p&K_Zz?LH9{tE|`>^f5 z%XZm5eca#XITx4<%mwBGbAh?QTwpGsFYxEzPcLijS$^$b{?Au;_x<()pNIyp9atSNO_r=o;nBFJjnde20y$DmzETuycHeoxUnNN5!yn ze21OBDmzETuycHeoxUnNN5!yne21OBDmzETuycI%PS+@BjuFE?<2!70jdJE0G3+zG z!$#LAXPyzmKI1!Vbd7T688PfLzQab>C}*A#!#?9XY;;xGIVy&o<2&s1RoOWzhMnU( z?DSRHIVy&o<2&s1RoOWzhMnU(?DSRHIVy&o) zxzkf+=cX8Tj<4M5sj_oZ3_HhH?(|gIxhaO7<12T1Mmh6~81@-oxzRDonP0@P&-luX zj#1A1B8Gj&S8jBSa^@E?>@&V{qhpjazldR<@zonWRd#-gVdwbDot`Q?H^s1XeC1A0 zm7SYn*g3v(r>Dx!O)=~oU%As$W#^_Cc8;&y=^5qBFJjnde20y$$GqZqWca?qcld_R zdw#@gXZU=C@9^ze_p^ML8=UdD-{1^C>|9_jFc+8$%mwBGbAh?QT;M$}@cQG|_4n=P P?fbXSZ!fp6*Du>&RuP+w literal 0 HcmV?d00001 diff --git a/media/target_arrow_double_chevron_thin.tga b/media/target_arrow_double_chevron_thin.tga new file mode 100644 index 0000000000000000000000000000000000000000..858e02b205fe5dc033962fbea57bd0adf53af37b GIT binary patch literal 16428 zcmeH@J5B^a3`9pn9D@tM8AujsB?J;basP_|9avbCy?M5F15eW3qk48#ZQIQj-{bbU z-QC7(&S_v8mA6}cjY2;Z3fE|uU*=G z2D-=cbYF+wX0UumCOXq*usro!t>61!Z?ePkWLKBgX0Uu$E;83)rCzdB{BL-SWh1wbpvKe03f& z&w95!@!Fx?`=>iBPxn>ot#`{;W}&mJcgs`1mHNH+^(ONzPj*#nt#`{;=OOc~cgqv6 zUD}-C$PUXN*;&o33}&X~&F*2RfoWhGmi*EjJ9-%H-q#~Q!HE#c|?t>5Ie#TRK1-l9EvSJNYD5k5Mb zL|@w*>6LJa&rv(%YwbPKC4B9@UjAr5q)&Knj$Yn>_@Em(yf;TLFXv&#di6>?x)tLk z{)kz8JO0+Z73GGv@Kw*In#X$8A~41Jkobb)6z_uH{P?9ujVz* zRr{`KS)A80&vT2pdOfFo^y*mV-P-@$^8d=cdjsBpH{cC;1Kxl);0<^Kx7xtz{qy)W QeN1n!kKfPJ%ke4v0=&7`#t8-@)Y&lPh(%S6=#9otmY_9J6}trysS4cL|SD z8R<)1f?Jvs;XO}Zxg(fpP@yw~g? zWSE|%a&QNAseL)VCG*hR>YR)vXPL`$4}GaUcX)VtXQLmrN7fP^UK#0Qb&1?wp3J({ zlOEJt{f@l1Cok_(ADMset}VHF$LhU#j=s$o?D%> z=d812vKe_sZRc8kpX1^;>Xtc|vXd{Sm+rUzJ;pqpwa&Mee~EwXUN7f!{JpT{aNNRw uJ*N-NssS~i2GoEWPy=c}4X6P%pa#y^z~}qd^?Upn-(DYop2wH#)9?q}nHR5`z&RIrw`faVqx11t z@Wt>el>1jWJ=YroM}GB8_cM0MKA^qlaso_dU!?sH{gYUl-QVa&^nr5Yb_rVUl!uXv z1r^gt^b0Efzo?3E+;^z+I9|o6-m9<#cnH8Y)*hK z>|xji^i8Doo)^2VjpL!HoB&;@W3XBWTEmj&-q$-i`qMrrDkp#y_WoGcsP1zPG|y=A z?QayB6Bxc?!F$0HZLMR;Bt9rPC)fw`u&c(A$OmE?SM0mea)NzOa{@_$Nv}D9dtdxe zbHXm=gxqhRnfu+&3E>_rXFuSB-KrCw#M&eHICCFNyH0Seb+;5Bq~l~wH0uNojU}oV zrQgvLsQg>Rvqi?4=7eYTC1M#@TqmukzRfufJT^d;6YK{)LlMikK2Qv(zI+(@v(JSI zzn`5QW&DrPo$x;>i62(8pQ8N%m5e+0I>+n(V&o6&$v)1_GT!jqB#!km{cnOmoXdN? zcAsYd$kz{}S?52-*Pk2O_fU&D+;3xjj$;2p^k=resW3*b_an5oBA^&1xi|J&(R0^3 zk-hKR4A;Eb73=RKyBT7)wefjc^XDUbxPFG*(~Nbk6X(Ylw2x=b-d~T5by~5`rMjD* zXWIS$;bS>i#^v=s?O5;E`TUP8>)eFB#yZYGoUeMlcAquYdw#01PL9JBxyE`J7vtMk zu^#tb?d_VIHS{Yg+4HgP{S{WROP`~XNq+x!4X^XFk9D&r^RG3a_Z&Y707-dzM+Sd- zt*D02eqT@7-Dce3ehYgJ{fVkRZF4_k_UvnTvktQGy4Gv+4p341`XJ`!aeQ!4$FRfR z0ZU_}s910EHv4cZ59s$^<->QVB<1ybFE-Yj%*{T$eg{;~;nR0O&0c)0H<_D#xWoM@ zb`ce~*Fg67>rIwP`!G8n`kw!huf^Z8ykGbDre)z&EGQo=>zY{Qhb(LOX)zDF4&3`< s&YS#k&i5sI(-*a0Jy1PRJy3!N*00@Ixw(3Mb?xfuTbEX^teolm2miiHK>z>% literal 0 HcmV?d00001 diff --git a/media/target_arrow_thin.tga b/media/target_arrow_thin.tga new file mode 100644 index 0000000000000000000000000000000000000000..1dcaebaf5fc6846e7daa1940871b57e12e5d6e39 GIT binary patch literal 16428 zcmeI1JBt)S6oq?r0}U3@jn+*}3>Ab$gH6_8T^2;a7iNm6oBBTt#7GVN4<>?ODv16B z13?7?K?Eb;<9B-Mw8c2;rRxHd-`KaQ44n|{N!Ty3u zZYB=qu&d1nVM}^|_BAT`K5Pc}Y3Cga#W{Au3xiF*wLVXne~h%xxL>M~18~{*_Y!9N zJ>3q>#d|td%>j1tH31CH`I8CkPg?1ym;-UMl{l!PCV)r&-;3Tr?R(>;s}u3^E8SID z6Tr5LD$XV2I1qmKS5*_h|JJ!oq? z?H1a*njrkBV(N@u)Z!J~MDFVeiga3XFMUSFXQEV2yD*?5`L&p0Ky`3&cHZu0H|$V&T|#l?|S4q$qi4D>m?Prq0e0BBG=ng??tY6dk-K_ zy@pqa7o6+udXD#BM!kmDp7np%@Wp=Dli%9&SMZ)dCEAz&3w!Zvc;U_kyyoD#2te|E zYE|F$9CvPmx&2nt`o9cuke}VO`*qFnZ}a)3+h9^1@VDqd<0;fM@SdZ zcj#MmMe}Hj+^*59rU=U@2h#B@?q;& z@qp#w)UDuv!$s|}?19Q6ski7s!o%&gnz!0wYSB)f3uojnrqKk`dd2RdVh)e zdS^YiRzAqDHFuRcd;V22Yv%k<|LdCGVpbRk17RQxgn=*+2Eu?1179ECr=RU}dw+ZS Kec4`5&*==FH@uYq literal 0 HcmV?d00001 diff --git a/media/target_arrow_triangle_thin.tga b/media/target_arrow_triangle_thin.tga new file mode 100644 index 0000000000000000000000000000000000000000..3a9b21399ffaa53e839d849b937bb26ca4ecb771 GIT binary patch literal 16428 zcmeH_OAf*?3g|PaY&((yRv$QYLBMx&tYXHgDB~mPgxX&4ZSsrq7at8jjjt3m$42H2oI1 z;|!YlYkK0lR@NF8Nv{>&bPfrFCY!!zZLeYC_E=-3=HaqPa@0FkYZ9)ttX0O+Tx(3+ z^U@oZ&$xWKoMkr7Y&ovAI_GW=nKe%8+yfVPMv5<2 maxLength then + text = string.sub(text, 1, maxLength) .. "..." + end + return text +end + +local function GetUnitGUID(unit) + if not unit then return nil end + local ok, exists, guid = pcall(UnitExists, unit) + if ok and exists and guid then return guid end + if UnitGUID then + local guidOK, classicGUID = pcall(UnitGUID, unit) + if guidOK and classicGUID then return classicGUID end + end + return nil +end + +local function SpellLabel(spellID) + if not spellID then return "nil/?" end + if SpellInfo then + local ok, name, rank = pcall(SpellInfo, spellID) + if ok and name then + local label = tostring(spellID) .. "/" .. CleanValue(name, 100) + if rank and rank ~= "" then label = label .. " " .. CleanValue(rank, 60) end + return label + end + end + if GetSpellInfo then + local ok, name, rank = pcall(GetSpellInfo, spellID) + if ok and name then + local label = tostring(spellID) .. "/" .. CleanValue(name, 100) + if rank and rank ~= "" then label = label .. " " .. CleanValue(rank, 60) end + return label + end + end + return tostring(spellID) .. "/?" +end + +local function RelativeTime() + if not recorder.startedAt then return "+0.000" end + return string.format("+%.3f", GetTime() - recorder.startedAt) +end + +local function ClassicAPIVersionLabel() + if CLASSIC_API_VERSION == nil then return "not detected" end + local numeric = tonumber(CLASSIC_API_VERSION) + if not numeric then return CleanValue(CLASSIC_API_VERSION) end + local major = math.floor(numeric / 10000) + local minor = math.floor((numeric - (major * 10000)) / 100) + local patch = numeric - (major * 10000) - (minor * 100) + return tostring(numeric) .. " (" .. tostring(major) .. "." .. tostring(minor) .. "." .. tostring(patch) .. ")" +end + +local function AppendLine(text, important) + text = CleanValue(text, 1400) + local charLimit = important and REPORT_CHAR_LIMIT or CAPTURE_CHAR_LIMIT + local lineLimit = important and REPORT_LINE_LIMIT or CAPTURE_LINE_LIMIT + local nextChars = (recorder.charCount or 0) + string.len(text) + 1 + + if nextChars > charLimit or table.getn(recorder.lines) >= lineLimit then + if not recorder.limitHit then + local marker = "[CAPTURE LIMIT REACHED - recording stopped before the report became too large]" + table.insert(recorder.lines, marker) + recorder.charCount = (recorder.charCount or 0) + string.len(marker) + 1 + recorder.limitHit = true + recorder.stopRequested = true + recorder.dirty = true + end + return false + end + + table.insert(recorder.lines, text) + recorder.charCount = nextChars + recorder.dirty = true + return true +end + +local function AppendTimed(kind, text) + return AppendLine("[" .. RelativeTime() .. "] " .. tostring(kind) .. " | " .. tostring(text or ""), false) +end + +local function ShallowFields(data) + if type(data) ~= "table" then return CleanValue(data) end + local fields = {} + local key, value + for key, value in pairs(data) do + local valueType = type(value) + if valueType ~= "table" and valueType ~= "function" and valueType ~= "userdata" then + table.insert(fields, CleanValue(key, 80) .. "=" .. CleanValue(value, 220)) + end + end + table.sort(fields) + if table.getn(fields) == 0 then return "(no scalar fields)" end + return table.concat(fields, " | ") +end + +local function IsRelatedText(raw) + if not raw or raw == "" then return false end + raw = tostring(raw) + + if recorder.traceGUID and string.find(raw, tostring(recorder.traceGUID), 1, true) then return true end + if recorder.playerGUID and string.find(raw, tostring(recorder.playerGUID), 1, true) then return true end + if recorder.traceName and recorder.traceName ~= "" and string.find(raw, recorder.traceName, 1, true) then return true end + if recorder.playerName and recorder.playerName ~= "" and string.find(raw, recorder.playerName, 1, true) then return true end + return false +end + +local function CurrentSnapshotUnit() + local targetGUID = GetUnitGUID("target") + if recorder.traceGUID and targetGUID == recorder.traceGUID then return "target" end + if recorder.traceGUID and UnitTokenFromGUID then + local ok, token = pcall(UnitTokenFromGUID, recorder.traceGUID) + if ok and token then return token end + end + if recorder.traceGUID then return recorder.traceGUID end + if UnitName("target") then return "target" end + return nil +end + +local function QueueSnapshot(delay, reason, force) + local pending = recorder.pendingSnapshots + if table.getn(pending) >= 18 then return end + table.insert(pending, { + at = GetTime() + (delay or 0), + reason = reason or "event", + force = force and true or false, + }) +end + +local function HasClassicAuraAPI() + return type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" +end + +local function IsShadowVulnerability(spellID, spellName) + local numeric = tonumber(spellID) + if SHADOW_VULNERABILITY_IDS[numeric] then return true end + local name = string.lower(tostring(spellName or "")) + return name == "shadow vulnerability" or name == "schattenverwundbarkeit" +end + +local function IsShadowBolt(spellID, spellName) + local numeric = tonumber(spellID) + if SHADOW_BOLT_IDS[numeric] then return true end + local name = spellName + if (not name or name == "") and numeric and SpellInfo then + local ok, resolved = pcall(SpellInfo, numeric) + if ok then name = resolved end + end + name = string.lower(tostring(name or "")) + return name == "shadow bolt" or name == "schattenblitz" +end + +local function ShouldCaptureClassicAura(aura) + if type(aura) ~= "table" then return true end + local spellID = tonumber(aura.spellId or aura.spellID) + if IsShadowVulnerability(spellID, aura.name) then return true end + + if aura.sourceGUID and recorder.playerGUID then return aura.sourceGUID == recorder.playerGUID end + if aura.sourceUnit then return aura.sourceUnit == "player" end + -- ClassicAPI may omit source data for foreign auras. Unknown ownership is not + -- enough to include a normal aura; Shadow Vulnerability was allowed above. + return false +end + +local function SnapshotClassicAPIAuras(unit, reason, force) + if not HasClassicAuraAPI() then return end + + local signatureParts = {} + local auraLines = {} + local count = 0 + local shadowOnly = force and string.find(tostring(reason or ""), "Shadow", 1, true) + local i + for i = 1, 64 do + local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, unit, i) + if not ok then + if force then + AppendTimed( + "CLASSICAPI_AURA_SNAPSHOT", + "reason=" .. CleanValue(reason) .. " | unit=" .. CleanValue(unit) .. + " | error=" .. CleanValue(aura) + ) + end + return + end + if not aura then break end + + if ShouldCaptureClassicAura(aura) and (not shadowOnly or IsShadowVulnerability(aura.spellId or aura.spellID, aura.name)) then + count = count + 1 + local fields = ShallowFields(aura) + table.insert(signatureParts, fields) + + local remaining = "unknown" + local expiration = tonumber(aura.expirationTime) + if expiration and expiration > 0 then + remaining = string.format("%.3f", expiration - GetTime()) + end + table.insert( + auraLines, + " CLASSIC_AURA " .. tostring(i) .. + " | remaining=" .. remaining .. + " | " .. fields + ) + end + end + + local signature = table.concat(signatureParts, "||") + local snapshotKey = (shadowOnly and "classic-shadow:" or "classic:") .. tostring(recorder.traceGUID or unit) + local previous = recorder.lastAuraSignatures and recorder.lastAuraSignatures[snapshotKey] + local changed = previous ~= signature + + recorder.lastAuraSignatures = recorder.lastAuraSignatures or {} + recorder.lastAuraSignatures[snapshotKey] = signature + if recorder.baselineOnly then return end + if not changed and not force then return end + + AppendTimed( + "CLASSICAPI_AURA_SNAPSHOT", + "reason=" .. CleanValue(reason) .. + " | unit=" .. CleanValue(unit) .. + " | guid=" .. CleanValue(recorder.traceGUID) .. + " | count=" .. tostring(count) .. + " | changed=" .. (changed and "1" or "0") + ) + if count == 0 then + AppendLine(" CLASSIC_AURA none", false) + else + for i = 1, table.getn(auraLines) do AppendLine(auraLines[i], false) end + end +end + +local function SnapshotAuras(reason, force) + local unit = CurrentSnapshotUnit() + if not unit then + if force then AppendTimed("AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | no target unit") end + return + end + + -- ClassicAPI exposes a richer AuraData table including spellId, duration, + -- expirationTime and (where observed) sourceUnit/sourceGUID. Keep this + -- snapshot independent from legacy UnitDebuff so a duration-only refresh is + -- visible even when the vanilla aura slots themselves do not change. + SnapshotClassicAPIAuras(unit, reason, force) + if HasClassicAuraAPI() then return end + + local signatureParts = {} + local auraLines = {} + local count = 0 + local i + for i = 1, 64 do + local ok, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = pcall(UnitDebuff, unit, i) + if not ok then + if force then + AppendTimed("AURA_SNAPSHOT", "reason=" .. CleanValue(reason) .. " | UnitDebuff error=" .. CleanValue(a1)) + end + return + end + if not a1 then break end + + count = count + 1 + local spellName = "?" + local spellRank = nil + if a4 and SpellInfo then + local spellOK, name, rank = pcall(SpellInfo, a4) + if spellOK and name then spellName = name end + if spellOK then spellRank = rank end + end + + table.insert(signatureParts, + CleanValue(a1) .. ":" .. CleanValue(a2) .. ":" .. CleanValue(a3) .. ":" .. + CleanValue(a4) .. ":" .. CleanValue(a5) .. ":" .. CleanValue(a6) .. ":" .. + CleanValue(a7) .. ":" .. CleanValue(a8) .. ":" .. CleanValue(a9) .. ":" .. CleanValue(a10) + ) + + table.insert(auraLines, + " AURA " .. tostring(i) .. + " | id=" .. CleanValue(a4) .. + " | name=" .. CleanValue(spellName, 120) .. + " | rank=" .. CleanValue(spellRank, 80) .. + " | stacks=" .. CleanValue(a2) .. + " | type=" .. CleanValue(a3) .. + " | texture=" .. CleanValue(a1, 180) .. + " | r5=" .. CleanValue(a5) .. + " | r6=" .. CleanValue(a6) .. + " | r7=" .. CleanValue(a7) .. + " | r8=" .. CleanValue(a8) .. + " | r9=" .. CleanValue(a9) .. + " | r10=" .. CleanValue(a10) + ) + end + + local signature = table.concat(signatureParts, "||") + local snapshotKey = tostring(recorder.traceGUID or unit) + local previous = recorder.lastAuraSignatures and recorder.lastAuraSignatures[snapshotKey] + local changed = previous ~= signature + + recorder.lastAuraSignatures = recorder.lastAuraSignatures or {} + recorder.lastAuraSignatures[snapshotKey] = signature + + if recorder.baselineOnly then return end + + if not changed and not force then return end + + AppendTimed( + "AURA_SNAPSHOT", + "reason=" .. CleanValue(reason) .. + " | unit=" .. CleanValue(unit) .. + " | guid=" .. CleanValue(recorder.traceGUID) .. + " | count=" .. tostring(count) .. + " | changed=" .. (changed and "1" or "0") + ) + + if count == 0 then + AppendLine(" AURA none", false) + else + for i = 1, table.getn(auraLines) do AppendLine(auraLines[i], false) end + end +end + +local function UpdateTarget(reason) + local exists, guid = UnitExists("target") + local name = UnitName("target") + if exists and guid then + local changed = guid ~= recorder.traceGUID + recorder.traceGUID = guid + recorder.traceName = name or "?" + if changed then + AppendTimed( + "TARGET", + "reason=" .. CleanValue(reason) .. " | name=" .. CleanValue(recorder.traceName) .. " | guid=" .. CleanValue(guid) + ) + QueueSnapshot(0.03, "target changed", false) + end + elseif not recorder.traceGUID then + AppendTimed("TARGET", "reason=" .. CleanValue(reason) .. " | no target / no SuperWoW GUID") + else + AppendTimed("TARGET", "reason=" .. CleanValue(reason) .. " | target cleared; keeping guid=" .. CleanValue(recorder.traceGUID)) + end +end + +local function IsRelevantCast(casterGUID, targetGUID) + if recorder.playerGUID and casterGUID == recorder.playerGUID then return true end + return false +end + +local function HandleCastEvent() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + local duration = arg5 + if not IsRelevantCast(casterGUID, targetGUID) then return end + + AppendTimed( + "UNIT_CASTEVENT", + "type=" .. CleanValue(eventType) .. + " | spell=" .. SpellLabel(spellID) .. + " | caster=" .. CleanValue(casterGUID) .. + " | target=" .. CleanValue(targetGUID) .. + " | castDuration=" .. CleanValue(duration) + ) + + if IsShadowBolt(spellID) then + QueueSnapshot(0.20, "200ms after Shadow Bolt UNIT_CASTEVENT", true) + end +end + +local function HandleRawCombatLog() + local originalEvent = arg1 + local raw = arg2 + if not IsRelatedText(raw) then return end + local lowerRaw = string.lower(tostring(raw or "")) + local ownSignal = false + if recorder.playerGUID and string.find(lowerRaw, string.lower(tostring(recorder.playerGUID)), 1, true) then ownSignal = true end + if recorder.playerName and string.find(lowerRaw, string.lower(tostring(recorder.playerName)), 1, true) then ownSignal = true end + local sharedSignal = string.find(lowerRaw, "shadow vulnerability", 1, true) or + string.find(lowerRaw, "schattenverwundbarkeit", 1, true) or + string.find(lowerRaw, "17794", 1, true) or string.find(lowerRaw, "17797", 1, true) or + string.find(lowerRaw, "17798", 1, true) or string.find(lowerRaw, "17799", 1, true) or + string.find(lowerRaw, "17800", 1, true) + if not ownSignal and not sharedSignal then return end + AppendTimed("RAW_COMBATLOG", "event=" .. CleanValue(originalEvent) .. " | raw=" .. CleanValue(raw, 1100)) + + if sharedSignal then + QueueSnapshot(0.04, "after Shadow Vulnerability RAW_COMBATLOG", true) + QueueSnapshot(0.30, "300ms after Shadow Vulnerability RAW_COMBATLOG", true) + end +end + +local function HandleStructuredCombatLog() + local timestamp = arg1 + local eventType = arg2 + local sourceGUID = arg3 + local sourceName = arg4 + local sourceFlags = arg5 + local destGUID = arg6 + local destName = arg7 + local destFlags = arg8 + local spellID = arg9 + local spellName = arg10 + local spellSchool = arg11 + + local relevant = recorder.playerGUID and sourceGUID == recorder.playerGUID + if recorder.traceGUID and destGUID == recorder.traceGUID and IsShadowVulnerability(spellID, spellName) then relevant = true end + if not relevant then return end + + AppendTimed( + "SUPERWOW_COMBAT_LOG_EVENT", + "timestamp=" .. CleanValue(timestamp) .. + " | event=" .. CleanValue(eventType) .. + " | source=" .. CleanValue(sourceGUID) .. "/" .. CleanValue(sourceName, 100) .. "/" .. CleanValue(sourceFlags) .. + " | dest=" .. CleanValue(destGUID) .. "/" .. CleanValue(destName, 100) .. "/" .. CleanValue(destFlags) .. + " | spell=" .. CleanValue(spellID) .. "/" .. CleanValue(spellName, 120) .. + " | school=" .. CleanValue(spellSchool) .. + " | p12=" .. CleanValue(arg12) .. " | p13=" .. CleanValue(arg13) .. + " | p14=" .. CleanValue(arg14) .. " | p15=" .. CleanValue(arg15) .. + " | p16=" .. CleanValue(arg16) .. " | p17=" .. CleanValue(arg17) .. + " | p18=" .. CleanValue(arg18) .. " | p19=" .. CleanValue(arg19) .. + " | p20=" .. CleanValue(arg20) + ) + + if IsShadowVulnerability(spellID, spellName) then + QueueSnapshot(0.03, "after structured Shadow Vulnerability", true) + QueueSnapshot(0.25, "250ms after structured Shadow Vulnerability", true) + end +end + +local function HandleUnitAura() + local unit = arg1 + if unit ~= "target" then return end + local guid = GetUnitGUID(unit) + AppendTimed("UNIT_AURA", "unit=" .. CleanValue(unit) .. " | guid=" .. CleanValue(guid)) + QueueSnapshot(0.02, "after UNIT_AURA", false) +end + +local function HandleClassicAPISpellcast(eventName) + local unit = arg1 + local targetName = nil + local castGUID, spellID, spellName, rank + + if eventName == "UNIT_SPELLCAST_SENT" then + targetName = arg2 + castGUID = arg3 + spellID = arg4 + spellName = arg5 + rank = arg6 + else + castGUID = arg2 + spellID = arg3 + spellName = arg4 + rank = arg5 + end + + local unitGUID = GetUnitGUID(unit) + local relevant = unit == "player" + if recorder.traceGUID and (unitGUID == recorder.traceGUID or unit == recorder.traceGUID) then relevant = true end + if targetName and recorder.traceName and targetName == recorder.traceName then relevant = true end + if not relevant then return end + + AppendTimed( + "CLASSICAPI_" .. tostring(eventName), + "unit=" .. CleanValue(unit) .. + " | unitGUID=" .. CleanValue(unitGUID) .. + " | castGUID=" .. CleanValue(castGUID) .. + " | spell=" .. CleanValue(spellID) .. "/" .. CleanValue(spellName, 120) .. + " | rank=" .. CleanValue(rank, 80) .. + " | targetName=" .. CleanValue(targetName, 120) + ) + + -- UNIT_CASTEVENT supplies the single post-cast control snapshot. Keeping the + -- modern spellcast events as text avoids three duplicate aura dumps per cast. +end + +local function HandleCombatText(eventName) + local raw = arg1 + if not SELF_COMBAT_EVENTS[eventName] then + if not IsRelatedText(raw) then return end + local lower = string.lower(tostring(raw or "")) + local ownSignal = string.find(lower, "your ", 1, true) + if recorder.playerName and string.find(lower, string.lower(tostring(recorder.playerName)), 1, true) then ownSignal = true end + local sharedSignal = string.find(lower, "shadow vulnerability", 1, true) or + string.find(lower, "schattenverwundbarkeit", 1, true) + if not ownSignal and not sharedSignal then return end + end + AppendTimed( + eventName, + "a1=" .. CleanValue(raw, 1050) .. + " | a2=" .. CleanValue(arg2, 160) .. + " | a3=" .. CleanValue(arg3, 160) .. + " | a4=" .. CleanValue(arg4, 160) + ) +end + +local eventFrame = recorder.eventFrame or CreateFrame("Frame", "BNPAuraRecorderEventFrame") +recorder.eventFrame = eventFrame +eventFrame:SetScript("OnEvent", function() + if not recorder.active then return end + local eventName = event + + if eventName == "UNIT_CASTEVENT" then + HandleCastEvent() + elseif eventName == "RAW_COMBATLOG" then + HandleRawCombatLog() + elseif eventName == "SUPERWOW_COMBAT_LOG_EVENT" then + HandleStructuredCombatLog() + elseif eventName == "UNIT_AURA" then + HandleUnitAura() + elseif eventName == "PLAYER_TARGET_CHANGED" then + UpdateTarget("PLAYER_TARGET_CHANGED") + elseif CLASSICAPI_SPELLCAST_EVENTS[eventName] then + HandleClassicAPISpellcast(eventName) + else + HandleCombatText(eventName) + end +end) + +local function SafeRegister(eventName) + if type(C_EventUtils) == "table" and type(C_EventUtils.IsEventValid) == "function" then + local validOK, valid = pcall(C_EventUtils.IsEventValid, eventName) + if validOK and not valid then return false end + end + local ok = pcall(eventFrame.RegisterEvent, eventFrame, eventName) + if ok then recorder.registered[eventName] = true end + return ok +end + +local function UnregisterRecorderEvents() + pcall(eventFrame.UnregisterAllEvents, eventFrame) + recorder.registered = {} +end + +local function StatusText() + local target = tostring(recorder.traceName or "no target") + local guid = tostring(recorder.traceGUID or "no GUID") + local lineCount = table.getn(recorder.lines) + if recorder.copyNotice then return recorder.copyNotice end + if recorder.active then + return "RECORDING | Target: " .. target .. " | " .. guid .. " | " .. tostring(lineCount) .. " lines" + end + if recorder.limitHit then + return "STOPPED (size limit) | " .. tostring(lineCount) .. " lines | Copy the report" + end + return "STOPPED | Target: " .. target .. " | " .. tostring(lineCount) .. " lines | Copy the report" +end + +local function RefreshRecorderText(force) + if not recorder.frame or not recorder.editBox then return end + if not force and not recorder.dirty then return end + + local report = table.concat(recorder.lines, "\n") + recorder.editBox:SetText(report) + + local visualLines = 0 + local i + for i = 1, table.getn(recorder.lines) do + local length = string.len(recorder.lines[i] or "") + visualLines = visualLines + math.max(1, math.ceil(length / 96)) + end + recorder.editBox:SetHeight(math.max(330, (visualLines * 13) + 24)) + if recorder.scrollFrame and recorder.scrollFrame.UpdateScrollChildRect then + recorder.scrollFrame:UpdateScrollChildRect() + end + + if recorder.status then + recorder.status:SetText(StatusText()) + if recorder.copyNotice then + recorder.status:SetTextColor(0.2, 1, 0.35) + elseif recorder.active then + recorder.status:SetTextColor(0.2, 1, 0.35) + elseif recorder.limitHit then + recorder.status:SetTextColor(1, 0.35, 0.2) + else + recorder.status:SetTextColor(1, 0.82, 0) + end + end + + if recorder.stopButton then + if recorder.active then recorder.stopButton:Enable() else recorder.stopButton:Disable() end + end + recorder.dirty = false +end + +local function DumpScalarTable(label, data, important) + if type(data) ~= "table" then + AppendLine(label .. " | " .. CleanValue(data), important) + return + end + AppendLine(label .. " | " .. ShallowFields(data), important) +end + +local function DumpInternalDiagnostics() + AppendLine("", true) + AppendLine("=== INTERNAL BNP DIAGNOSTICS ===", true) + AppendLine("Target | name=" .. CleanValue(recorder.traceName) .. " | guid=" .. CleanValue(recorder.traceGUID), true) + + local guid = recorder.traceGUID + local cache = guid and BNP.guidAuras and BNP.guidAuras[guid] + AppendLine("--- GUID AURA CACHE ---", true) + if not cache then + AppendLine("CACHE | empty", true) + else + local cacheKey, aura + local cacheCount = 0 + for cacheKey, aura in pairs(cache) do + if type(aura) == "table" then + cacheCount = cacheCount + 1 + AppendLine("CACHE | key=" .. CleanValue(cacheKey) .. " | " .. ShallowFields(aura), true) + end + end + if cacheCount == 0 then AppendLine("CACHE | no aura tables | " .. ShallowFields(cache), true) end + end + + AppendLine("--- SHADOW VULNERABILITY STATE ---", true) + local svState = guid and BNP.svStateByGUID and BNP.svStateByGUID[guid] + if svState then DumpScalarTable("SV_STATE", svState, true) else AppendLine("SV_STATE | none", true) end + + local rendered = false + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and plate.GetName and plate:GetName(1) == guid then + local container = plate.BNPAuraContainer + if container and container.icons then + local iconIndex + for iconIndex = 1, table.getn(container.icons) do + local icon = container.icons[iconIndex] + if icon and icon.BNPSVRenderedExpires then + AppendLine( + "SV_RENDER | icon=" .. tostring(iconIndex) .. + " | timerText=" .. CleanValue(icon.timer and icon.timer:GetText()) .. + " | renderedExpires=" .. CleanValue(icon.BNPSVRenderedExpires) .. + " | revision=" .. CleanValue(icon.BNPSVRenderRevision), + true + ) + rendered = true + end + end + end + end + end + if not rendered then AppendLine("SV_RENDER | no visible SV icon", true) end + + AppendLine("--- PENDING AURAS ---", true) + local pending = guid and BNP.pendingAuras and BNP.pendingAuras[guid] + if not pending then + AppendLine("PENDING | none", true) + else + local pendingKey, pendingAura + for pendingKey, pendingAura in pairs(pending) do + AppendLine("PENDING | key=" .. CleanValue(pendingKey) .. " | " .. ShallowFields(pendingAura), true) + end + end + + AppendLine("--- LAST TRACKED AURA TRANSITIONS ---", true) + local trace = BNP.raidTrace or {} + local start = math.max(1, table.getn(trace) - 29) + local i + if table.getn(trace) == 0 then AppendLine("RAID_TRACE | none", true) end + for i = start, table.getn(trace) do + local traceEntry = trace[i] + AppendLine("RAID_TRACE " .. tostring(i) .. " | " .. ShallowFields(traceEntry), true) + end + + AppendLine("--- UNKNOWN SPELL / AURA COLLECTOR ---", true) + local unknown = BNP.unknownBeta + if not unknown or table.getn(unknown.order or {}) == 0 then + AppendLine("UNKNOWN | no indexed entries", true) + else + for i = 1, table.getn(unknown.order) do + local unknownKey = unknown.order[i] + local unknownEntry = unknown.entries and unknown.entries[unknownKey] + if unknownEntry then + AppendLine("UNKNOWN | key=" .. CleanValue(unknownKey) .. " | " .. ShallowFields(unknownEntry), true) + end + end + end + if unknown and unknown.combat then + local effectKey, effect + for effectKey, effect in pairs(unknown.combat) do + AppendLine("UNKNOWN_EFFECT | key=" .. CleanValue(effectKey, 220) .. " | " .. ShallowFields(effect), true) + end + end + + AppendLine("--- DIRECT PLAYER CAST EVENTS ---", true) + local directCount = 0 + local directKey, directEntry + for directKey, directEntry in pairs(BNP.unknownDirectEvents or {}) do + directCount = directCount + 1 + AppendLine("DIRECT | key=" .. CleanValue(directKey) .. " | " .. ShallowFields(directEntry), true) + end + if directCount == 0 then AppendLine("DIRECT | none", true) end + + AppendLine("--- SPELLDB SHADOW AUDIT ---", true) + local spellDB = BNP.SpellDBShadow + if not spellDB then + AppendLine("SPELLDB | unavailable", true) + else + DumpScalarTable("SPELLDB_STATS", spellDB.stats, true) + local recent = spellDB.recent or {} + for i = 1, table.getn(recent) do + AppendLine("SPELLDB_RECENT " .. tostring(i) .. " | " .. ShallowFields(recent[i]), true) + end + end + + AppendLine("--- SHADOW VULNERABILITY PROBE ---", true) + local probe = BNP.svProbe + if not probe then + AppendLine("SV_PROBE | unavailable", true) + else + if probe.last then DumpScalarTable("SV_PROBE_LAST", probe.last, true) else AppendLine("SV_PROBE_LAST | none", true) end + local probeEvents = probe.events or {} + for i = 1, table.getn(probeEvents) do + AppendLine("SV_PROBE_EVENT " .. tostring(i) .. " | " .. ShallowFields(probeEvents[i]), true) + end + local rawEvents = probe.raw or {} + for i = 1, table.getn(rawEvents) do + AppendLine("SV_PROBE_RAW " .. tostring(i) .. " | " .. ShallowFields(rawEvents[i]), true) + end + end +end + +local updateFrame = recorder.updateFrame or CreateFrame("Frame", "BNPAuraRecorderUpdateFrame") +recorder.updateFrame = updateFrame + +local function RecorderOnUpdate() + recorder.scanElapsed = (recorder.scanElapsed or 0) + arg1 + recorder.editElapsed = (recorder.editElapsed or 0) + arg1 + + if recorder.scanElapsed >= AURA_SCAN_INTERVAL then + recorder.scanElapsed = 0 + SnapshotAuras("poll change", false) + + local now = GetTime() + local i = 1 + while i <= table.getn(recorder.pendingSnapshots) do + local pending = recorder.pendingSnapshots[i] + if pending and now >= (pending.at or 0) then + table.remove(recorder.pendingSnapshots, i) + SnapshotAuras(pending.reason, pending.force) + else + i = i + 1 + end + end + end + + if recorder.editElapsed >= EDIT_REFRESH_INTERVAL then + recorder.editElapsed = 0 + RefreshRecorderText(false) + end + + if recorder.stopRequested then + BNP:StopAuraRecorder("capture limit") + end +end + +function BNP:StartAuraRecorder() + -- New Recording is also a clean restart when a capture is already running. + if recorder.active then + recorder.active = false + UnregisterRecorderEvents() + updateFrame:SetScript("OnUpdate", nil) + end + + UnregisterRecorderEvents() + recorder.lines = {} + recorder.charCount = 0 + recorder.limitHit = false + recorder.stopRequested = false + recorder.copyNotice = nil + recorder.pendingSnapshots = {} + recorder.lastAuraSignatures = {} + recorder.startedAt = GetTime() + recorder.stoppedAt = nil + recorder.scanElapsed = 0 + recorder.editElapsed = 0 + recorder.playerGUID = GetUnitGUID("player") + recorder.playerName = UnitName("player") or "?" + recorder.traceGUID = nil + recorder.traceName = nil + recorder.active = true + + local registeredCount = 0 + local i + for i = 1, table.getn(EVENT_NAMES) do + if SafeRegister(EVENT_NAMES[i]) then registeredCount = registeredCount + 1 end + end + local classicSpellcastCount = 0 + local classicEventName + for classicEventName in pairs(CLASSICAPI_SPELLCAST_EVENTS) do + if recorder.registered[classicEventName] then classicSpellcastCount = classicSpellcastCount + 1 end + end + + AppendLine("BNP_MISSING_SPELL_AURA_REPORT v1", true) + AppendLine("Addon | version=" .. CleanValue(BNP.version), true) + AppendLine( + "SuperWoW | string=" .. CleanValue(SUPERWOW_STRING) .. + " | version=" .. CleanValue(SUPERWOW_VERSION) .. + " | CombatLogAdd=" .. (CombatLogAdd and "yes" or "no") .. + " | SpellInfo=" .. (SpellInfo and "yes" or "no"), + true + ) + AppendLine( + "ClassicAPI | version=" .. ClassicAPIVersionLabel() .. + " | C_UnitAuras=" .. (HasClassicAuraAPI() and "yes" or "no") .. + " | UnitGUID=" .. (UnitGUID and "yes" or "no") .. + " | UnitTokenFromGUID=" .. (UnitTokenFromGUID and "yes" or "no") .. + " | CopyToClipboard=" .. (CopyToClipboard and "yes" or "no") .. + " | spellcastEvents=" .. tostring(classicSpellcastCount), + true + ) + if GetBuildInfo then + local version, build, buildDate, interfaceVersion = GetBuildInfo() + AppendLine( + "Client | version=" .. CleanValue(version) .. " | build=" .. CleanValue(build) .. + " | date=" .. CleanValue(buildDate) .. " | interface=" .. CleanValue(interfaceVersion) .. + " | locale=" .. CleanValue(GetLocale and GetLocale()), + true + ) + end + local _, class = UnitClass("player") + AppendLine( + "Player | name=" .. CleanValue(recorder.playerName) .. " | class=" .. CleanValue(class) .. + " | guid=" .. CleanValue(recorder.playerGUID), + true + ) + AppendLine( + "Sources | registered=" .. tostring(registeredCount) .. "/" .. tostring(table.getn(EVENT_NAMES)) .. + " | RAW_COMBATLOG=" .. (recorder.registered.RAW_COMBATLOG and "yes" or "no") .. + " | STRUCTURED=" .. (recorder.registered.SUPERWOW_COMBAT_LOG_EVENT and "yes" or "no") .. + " | CLASSICAPI_AURAS=" .. (HasClassicAuraAPI() and "yes" or "no"), + true + ) + AppendLine("Aura filter | own auras + Shadow Vulnerability from any Warlock", true) + AppendLine("Test | apply aura, refresh while active, then let it expire or remove it", true) + AppendLine("=== LIVE RECORDING ===", true) + + UpdateTarget("recording started") + -- Establish a silent baseline. Existing foreign auras must not appear just + -- because the user pressed Start Recording; only later changes are logged. + recorder.baselineOnly = true + SnapshotAuras("baseline", false) + recorder.baselineOnly = false + + updateFrame:SetScript("OnUpdate", RecorderOnUpdate) + recorder.dirty = true + RefreshRecorderText(true) +end + +function BNP:StopAuraRecorder(reason) + if not recorder.active then + RefreshRecorderText(true) + return + end + + recorder.stopRequested = false + SnapshotAuras("final before stop", true) + recorder.active = false + recorder.stoppedAt = GetTime() + UnregisterRecorderEvents() + updateFrame:SetScript("OnUpdate", nil) + DumpInternalDiagnostics() + AppendLine("=== END REPORT | reason=" .. CleanValue(reason or "user") .. " | elapsed=" .. + string.format("%.3f", recorder.stoppedAt - (recorder.startedAt or recorder.stoppedAt)) .. "s ===", true) + recorder.dirty = true + RefreshRecorderText(true) +end + +function BNP:ClearAuraRecorder() + if recorder.active then self:StopAuraRecorder("cleared") end + recorder.lines = {} + recorder.charCount = 0 + recorder.limitHit = false + recorder.stopRequested = false + recorder.copyNotice = nil + recorder.pendingSnapshots = {} + recorder.lastAuraSignatures = {} + recorder.traceGUID = nil + recorder.traceName = nil + recorder.dirty = true + RefreshRecorderText(true) +end + +function BNP:SelectAllAuraRecorderText() + if not recorder.editBox then return end + RefreshRecorderText(true) + recorder.editBox:SetFocus() + recorder.editBox:HighlightText() +end + +function BNP:CopyAuraRecorderText() + RefreshRecorderText(true) + local report = table.concat(recorder.lines, "\n") + + if CopyToClipboard then + local ok = pcall(CopyToClipboard, report) + if ok then + recorder.copyNotice = "COPIED TO CLIPBOARD | Paste the report with Ctrl+V" + recorder.dirty = true + RefreshRecorderText(true) + return true + end + end + + -- Older ClassicAPI builds and plain 1.12 clients have no direct clipboard + -- function. Keep the normal edit-box path as a complete fallback. + self:SelectAllAuraRecorderText() + recorder.copyNotice = "TEXT SELECTED | Press Ctrl+C, then paste the report" + recorder.dirty = true + RefreshRecorderText(true) + recorder.editBox:SetFocus() + recorder.editBox:HighlightText() + return false +end + +function BNP:CreateAuraRecorderWindow() + if recorder.frame then return recorder.frame end + + local frame = CreateFrame("Frame", "BNPAuraRecorderFrame", UIParent) + frame:SetWidth(760) + frame:SetHeight(560) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 20) + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:SetScript("OnHide", function() + if recorder.active then BNP:StopAuraRecorder("window closed") end + end) + frame:Hide() + recorder.frame = frame + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOP", frame, "TOP", 0, -18) + title:SetText("Missing Spell / Aura Recorder") + + local instructions = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + instructions:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -46) + instructions:SetWidth(706) + instructions:SetJustifyH("LEFT") + if CopyToClipboard then + instructions:SetText("1. Select the affected unit. 2. Start Recording. 3. Apply the aura, refresh it while active, then let it expire or remove it. 4. Stop. 5. Copy Report and paste it.") + else + instructions:SetText("1. Select the affected unit. 2. Start Recording. 3. Apply the aura, refresh it while active, then let it expire or remove it. 4. Stop. 5. Select All, press Ctrl+C and paste it.") + end + + local status = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + status:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -86) + status:SetWidth(706) + status:SetJustifyH("LEFT") + recorder.status = status + + local startButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + startButton:SetPoint("TOPLEFT", frame, "TOPLEFT", 26, -112) + startButton:SetWidth(126) + startButton:SetHeight(24) + startButton:SetText("Start Recording") + startButton:SetScript("OnClick", function() BNP:StartAuraRecorder() end) + recorder.startButton = startButton + + local stopButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + stopButton:SetPoint("LEFT", startButton, "RIGHT", 8, 0) + stopButton:SetWidth(82) + stopButton:SetHeight(24) + stopButton:SetText("Stop") + stopButton:SetScript("OnClick", function() BNP:StopAuraRecorder("user") end) + recorder.stopButton = stopButton + + local clearButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + clearButton:SetPoint("LEFT", stopButton, "RIGHT", 8, 0) + clearButton:SetWidth(82) + clearButton:SetHeight(24) + clearButton:SetText("Clear") + clearButton:SetScript("OnClick", function() BNP:ClearAuraRecorder() end) + + local selectButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + selectButton:SetPoint("LEFT", clearButton, "RIGHT", 8, 0) + selectButton:SetWidth(108) + selectButton:SetHeight(24) + selectButton:SetText(CopyToClipboard and "Copy Report" or "Select All") + selectButton:SetScript("OnClick", function() BNP:CopyAuraRecorderText() end) + + local copyHint = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + copyHint:SetPoint("LEFT", selectButton, "RIGHT", 10, 0) + copyHint:SetText(CopyToClipboard and "then paste" or "then Ctrl+C") + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) + + local textBG = CreateFrame("Frame", nil, frame) + textBG:SetPoint("TOPLEFT", frame, "TOPLEFT", 24, -146) + textBG:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -24, 48) + textBG:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 } + }) + textBG:SetBackdropColor(0.015, 0.015, 0.015, 0.95) + textBG:SetBackdropBorderColor(0.35, 0.35, 0.35, 1) + + local scroll = CreateFrame("ScrollFrame", "BNPAuraRecorderScrollFrame", textBG, "UIPanelScrollFrameTemplate") + scroll:SetPoint("TOPLEFT", textBG, "TOPLEFT", 8, -8) + scroll:SetPoint("BOTTOMRIGHT", textBG, "BOTTOMRIGHT", -28, 8) + recorder.scrollFrame = scroll + + local edit = CreateFrame("EditBox", "BNPAuraRecorderEditBox", scroll) + edit:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0) + edit:SetWidth(676) + edit:SetHeight(330) + edit:SetMultiLine(true) + edit:SetAutoFocus(false) + edit:SetFontObject(ChatFontNormal) + edit:SetTextColor(0.92, 0.92, 0.92) + edit:SetMaxLetters(65000) + edit:EnableMouse(true) + edit:SetScript("OnEscapePressed", function() this:ClearFocus() end) + edit:SetScript("OnTextChanged", function() + if recorder.scrollFrame and recorder.scrollFrame.UpdateScrollChildRect then + recorder.scrollFrame:UpdateScrollChildRect() + end + end) + scroll:SetScrollChild(edit) + recorder.editBox = edit + + local footer = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + footer:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 26, 24) + footer:SetWidth(706) + footer:SetJustifyH("LEFT") + footer:SetText("The recorder changes no tracking settings. It is completely inactive after Stop or Close.") + + RefreshRecorderText(true) + return frame +end + +function BNP:OpenAuraRecorder() + local function OpenFullRecorder() + local frame = BNP:CreateAuraRecorderWindow() + frame:Show() + RefreshRecorderText(true) + end + + local ok = pcall(OpenFullRecorder) + if not ok and self.OpenEmergencyAuraRecorder then + self:OpenEmergencyAuraRecorder() + end +end diff --git a/modules/auras.lua b/modules/auras.lua new file mode 100644 index 0000000..4b977d8 --- /dev/null +++ b/modules/auras.lua @@ -0,0 +1,3523 @@ +if not BNP.libnameplate then return end +if not CombatLogAdd or not SpellInfo then return end + +local _, playerClass = UnitClass("player") +local UI = BNP.UI or {} +local ICON_SIZE = UI.ICON_SIZE or 18 +local function GetIconSize() + return (BNP.GetIconSize and BNP:GetIconSize()) or ICON_SIZE +end +local function GetCCIconSize() + return (BNP.GetCCIconSize and BNP:GetCCIconSize()) or GetIconSize() +end +local function GetDebuffYOffset() + if BNP.GetDebuffYOffset then return BNP:GetDebuffYOffset() end + return 0 +end +local function GetCCYOffset() + if BNP.GetCCYOffset then return BNP:GetCCYOffset() end + return 0 +end +local ICON_OFFSET_Y = UI.ICON_OFFSET_Y or 15 +local COMBO_LAYOUT_CLASS = (playerClass == "ROGUE" or playerClass == "DRUID") +local COMBO_LAYOUT_OFFSET = 16 +local ROW_SPACING = 2 +local NAME_HIDDEN_COMPACT_SHIFT = 10 + +local function GetBaseAuraOffsetY() + if COMBO_LAYOUT_CLASS and BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() then + return ICON_OFFSET_Y + COMBO_LAYOUT_OFFSET + end + return ICON_OFFSET_Y +end + +local function GetDebuffPosition() + if BNP.GetDebuffPosition then return BNP:GetDebuffPosition() end + return "top" +end + +local function GetCCPosition() + if BNP.GetCCPosition then return BNP:GetCCPosition() end + return GetDebuffPosition() +end + +local function IsTopDebuffPosition(position) + return position == "top" or position == "top_left" or position == "top_right" +end + +local function IsBottomDebuffPosition(position) + return position == "bottom_mid" or position == "bottom_left" or position == "bottom_right" +end + +local function SharesTopRowFamily() + -- Top Left/Right are horizontal alignments of the same top aura row. + -- CCs only have a generic Top position, so when the user disables the + -- separate CC row they must share the debuff container instead of creating + -- a second centered container that can overlap larger CC icons. + return IsTopDebuffPosition(GetDebuffPosition()) and GetCCPosition() == "top" +end + +local function UseSeparateCCRow() + -- The separate-row option applies to every top alignment, not only Top Mid. + if BNP.AreDebuffsEnabled and not BNP:AreDebuffsEnabled() then return false end + if BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled() then return false end + return SharesTopRowFamily() + and BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled() +end + +local function UseDedicatedCCContainer() + -- Independent Y offsets require separate containers whenever the user moves + -- CCs away from the debuff row. At the default 0/0 values the proven shared + -- row behavior remains unchanged. + if SharesTopRowFamily() then + if GetCCYOffset() ~= GetDebuffYOffset() then return true end + return UseSeparateCCRow() + end + + -- Other genuinely different positions use the independent CC container. + return GetCCPosition() ~= GetDebuffPosition() or UseSeparateCCRow() +end + +local function GetAuraOffsetY() + return GetBaseAuraOffsetY() + GetDebuffYOffset() +end + +local function GetCCOffsetY() + local y = GetBaseAuraOffsetY() + if UseSeparateCCRow() and BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() then + y = y + GetIconSize() + ROW_SPACING + end + return y + GetCCYOffset() +end + +local ICON_SPACING = UI.ICON_SPACING or 2 +local SIDE_SPACING = 4 +local BOTTOM_SPACING = 4 +local UPDATE_INTERVAL = 0.05 + +local function GetSideCCOffsetY() + if UseSeparateCCRow() and BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() then + return GetIconSize() + ROW_SPACING + GetCCYOffset() + end + return GetCCYOffset() +end + +local function GetLevelRegionWidth(region) + if not region then return 0 end + if region.IsShown and not region:IsShown() then return 0 end + if region.GetStringWidth then + local width = tonumber(region:GetStringWidth()) or 0 + if width > 0 then return width end + end + if region.GetWidth then + return math.max(0, tonumber(region:GetWidth()) or 0) + end + return 0 +end + +local function GetRightAuraSpacing(plate) + local extra = 0 + if plate then + extra = extra + GetLevelRegionWidth(plate.level) + extra = extra + GetLevelRegionWidth(plate.levelicon) + end + + if extra > 0 then + extra = extra + 6 + end + + return SIDE_SPACING + extra +end + +local function AnchorAuraContainer(plate, container, isCC) + if not plate or not container then return end + container:ClearAllPoints() + + local position = isCC and GetCCPosition() or GetDebuffPosition() + -- When BNP hides the Blizzard name, reclaim that empty vertical space. + -- Only Top rows move; Left/Right are already centered directly on the bar. + local nameHidden = (BNP.IsPlateNameHidden and BNP:IsPlateNameHidden(plate)) or plate.BNPNameHidden + local compactShift = (nameHidden and (isCC and position == "top" or (not isCC and IsTopDebuffPosition(position)))) and -NAME_HIDDEN_COMPACT_SHIFT or 0 + local anchor = plate.healthbar or plate + + if position == "left" then + container:SetPoint("RIGHT", anchor, "LEFT", -SIDE_SPACING, isCC and GetSideCCOffsetY() or GetDebuffYOffset()) + elseif position == "right" then + container:SetPoint("LEFT", anchor, "RIGHT", GetRightAuraSpacing(plate), isCC and GetSideCCOffsetY() or GetDebuffYOffset()) + elseif not isCC and IsBottomDebuffPosition(position) then + if position == "bottom_left" then + container:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -BOTTOM_SPACING + GetDebuffYOffset()) + elseif position == "bottom_right" then + container:SetPoint("TOPRIGHT", anchor, "BOTTOMRIGHT", 0, -BOTTOM_SPACING + GetDebuffYOffset()) + else + container:SetPoint("TOP", anchor, "BOTTOM", 0, -BOTTOM_SPACING + GetDebuffYOffset()) + end + elseif not isCC and position == "top_left" then + if plate.healthbar then + container:SetPoint("BOTTOMLEFT", plate.healthbar, "TOPLEFT", 0, GetAuraOffsetY() + compactShift) + else + container:SetPoint("BOTTOMLEFT", plate, "BOTTOMLEFT", 0, 24 + GetDebuffYOffset() + compactShift) + end + elseif not isCC and position == "top_right" then + if plate.healthbar then + container:SetPoint("BOTTOMRIGHT", plate.healthbar, "TOPRIGHT", 0, GetAuraOffsetY() + compactShift) + else + container:SetPoint("BOTTOMRIGHT", plate, "BOTTOMRIGHT", 0, 24 + GetDebuffYOffset() + compactShift) + end + else + if plate.healthbar then + container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, (isCC and GetCCOffsetY() or GetAuraOffsetY()) + compactShift) + else + container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24 + (isCC and (GetCCOffsetY() - GetBaseAuraOffsetY()) or GetDebuffYOffset()) + compactShift) + end + end +end + +function BNP:RefreshAuraLayoutForPlate(plate) + if not plate then return end + if plate.BNPAuraContainer then AnchorAuraContainer(plate, plate.BNPAuraContainer, false) end + if plate.BNPCCContainer then AnchorAuraContainer(plate, plate.BNPCCContainer, true) end + plate.BNPAuraLastNameHidden = (BNP.IsPlateNameHidden and BNP:IsPlateNameHidden(plate)) or (plate.BNPNameHidden and true or false) +end + +-- Aura rows are anchored to the projected nameplate but parented beside it. +-- That keeps their opacity independent from transient Vanilla nameplate alpha +-- changes while preserving the native world/nameplate layer. + +-- SuperWoW GUID cache. Auras are learned while a unit is the current target +-- and remain attached to that unit's unique GUID after the target changes. +BNP.guidAuras = BNP.guidAuras or {} +BNP.guidNames = BNP.guidNames or {} +BNP.guidLiveCCs = BNP.guidLiveCCs or {} +-- Shadow Vulnerability is a shared proc aura whose refresh can be invisible to +-- vanilla UnitDebuff (same spell ID, same stack count, no duration data). Keep +-- its countdown in a dedicated GUID state so generic shared-aura scans can +-- never accidentally replace or freeze the timer. +BNP.svExpiryByGUID = BNP.svExpiryByGUID or {} +-- Fully isolated Shadow Vulnerability state. Generic shared-aura code may +-- mirror this state for rendering, but it must never own or rewrite the timer. +BNP.svStateByGUID = BNP.svStateByGUID or {} +-- Monotonic render revision per GUID. A confirmed refresh increments this so +-- the normal nameplate renderer can invalidate any cached timer text safely. +BNP.svRenderRevisionByGUID = BNP.svRenderRevisionByGUID or {} + +BNP.pvpAuraProtection = BNP.pvpAuraProtection or {} +local PVP_AURA_PROTECTION_TIME = 6.0 + +local function ProtectAuraCache(guid, reason) + if not guid then return end + BNP.pvpAuraProtection[guid] = { + untilTime = GetTime() + PVP_AURA_PROTECTION_TIME, + reason = reason, + } +end + +local function AuraCacheProtected(guid, now) + local state = guid and BNP.pvpAuraProtection[guid] + if not state then return false end + if now <= (state.untilTime or 0) then return true end + BNP.pvpAuraProtection[guid] = nil + return false +end + +local CLASS_AURAS = { + WARLOCK = BNP.WarlockAuras, + PRIEST = BNP.PriestAuras, + WARRIOR = BNP.WarriorAuras, + ROGUE = BNP.RogueAuras, + HUNTER = BNP.HunterAuras, + MAGE = BNP.MageAuras, + DRUID = BNP.DruidAuras, + SHAMAN = BNP.ShamanAuras, + PALADIN = BNP.PaladinAuras, +} +local AURA_DEFS = CLASS_AURAS[playerClass] or {} +local HAS_SHARED_AURAS = false +do + local _, def + for _, def in ipairs(AURA_DEFS) do + if def.shared then + HAS_SHARED_AURAS = true + break + end + end +end +local MAX_VISIBLE_ICONS = 8 + +local function PositionAuraIcon(icon, container, index, visibleCount, iconSize, isCC) + if not icon or not container then return end + local position = isCC and GetCCPosition() or GetDebuffPosition() + local step = iconSize + ICON_SPACING + + icon:ClearAllPoints() + + if position == "left" or position == "bottom_right" or position == "top_right" then + icon:SetPoint("RIGHT", container, "RIGHT", -((index - 1) * step), 0) + elseif position == "right" or position == "bottom_left" or position == "top_left" then + icon:SetPoint("LEFT", container, "LEFT", (index - 1) * step, 0) + else + local maxWidth = iconSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local rowWidth = 0 + if visibleCount and visibleCount > 0 then + rowWidth = iconSize * visibleCount + ICON_SPACING * (visibleCount - 1) + end + local startX = (maxWidth - rowWidth) / 2 + icon:SetPoint("LEFT", container, "LEFT", startX + (index - 1) * step, 0) + end +end + +local function GetAuraFrameLevel(plate) + local plateLevel = 0 + if plate and plate.GetFrameLevel then + plateLevel = tonumber(plate:GetFrameLevel()) or plateLevel + end + return math.max(0, plateLevel + 1) +end + +local function ApplyAuraFrameLevel(plate, container) + if not container then return end + + -- Aura containers are siblings of the projected plate (not children) so they + -- do not inherit transient plate alpha. Mirror only the plate's native strata + -- and relative level; never modify the projected nameplate's own strata. + if plate and plate.GetFrameStrata and container.GetFrameStrata and container.SetFrameStrata then + local strata = plate:GetFrameStrata() + if strata and container:GetFrameStrata() ~= strata then + container:SetFrameStrata(strata) + end + end + + local level = GetAuraFrameLevel(plate) + if container.GetFrameLevel and container.SetFrameLevel and + container:GetFrameLevel() ~= level then + container:SetFrameLevel(level) + end + + local i + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + if icon and icon.GetFrameLevel and icon.SetFrameLevel and + icon:GetFrameLevel() ~= level then + icon:SetFrameLevel(level) + end + end +end + +-- Target auras stay opaque and non-target auras remain consistently faded. +-- This is identity-based and never changes with camera or screen position. +local TARGET_PRIORITY_AURA_ALPHA = 0.35 +local TARGET_PRIORITY_PLATE_HOLD = 0.25 +local targetPriorityPlate = nil +local targetPriorityGUID = nil +local targetPriorityPlateSeenAt = -100 + +local function RefreshTargetPriorityIdentity() + local exists, guid = UnitExists("target") + if not exists or not guid then + targetPriorityPlate = nil + targetPriorityGUID = nil + targetPriorityPlateSeenAt = -100 + return + end + + local now = GetTime() + + -- A real target switch invalidates the previous frame immediately. For the + -- same GUID, however, keep the last exact ClassicAPI target plate through a + -- very short nil result. Dense raid stacks can make projected plate lookup + -- flicker for a frame; dropping the cached target here made its DoTs/Banish + -- alternate between opaque and the 0.35 non-target alpha. + if targetPriorityGUID ~= guid then + targetPriorityPlate = nil + targetPriorityPlateSeenAt = -100 + end + targetPriorityGUID = guid + + local resolved = nil + if C_NamePlate and + type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok and plate then resolved = plate end + end + + if resolved then + targetPriorityPlate = resolved + targetPriorityPlateSeenAt = now + elseif targetPriorityPlate and + now - targetPriorityPlateSeenAt > TARGET_PRIORITY_PLATE_HOLD then + targetPriorityPlate = nil + end +end + +local function SetTargetPriorityAuraAlpha(container, alpha) + if not container then return end + alpha = alpha or 1 + if container.SetAlpha and + (not container.GetAlpha or container:GetAlpha() ~= alpha) then + container:SetAlpha(alpha) + end +end + +local function ApplyTargetAuraPriority(plate, container) + if not container then return end + + -- Flicker fix: aura opacity must not depend on transient target/nameplate + -- resolution. In dense raid stacks ClassicAPI/SuperWoW can briefly resolve + -- a projected plate differently, which previously made the whole debuff/CC + -- row alternate between alpha 1.0 and 0.35. This alpha is separate from the + -- user-facing Non-Target Alpha slider, so setting that slider to 100% did + -- not disable the flicker. Keep aura containers fully opaque and let target + -- priority be handled by nameplate layering only. + SetTargetPriorityAuraAlpha(container, 1) +end + +function BNP:RefreshAuraPriorityAlpha() + RefreshTargetPriorityIdentity() + + local plate + for plate in pairs(self.plates or {}) do + ApplyAuraFrameLevel(plate, plate.BNPAuraContainer) + ApplyAuraFrameLevel(plate, plate.BNPCCContainer) + ApplyTargetAuraPriority(plate, plate.BNPAuraContainer) + ApplyTargetAuraPriority(plate, plate.BNPCCContainer) + end +end + +local function CleanText(text) + if not text then return nil end + text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") + text = string.gsub(text, "|r", "") + return text +end + +local function NameMatches(def, name) + if not name then return false end + local lower = string.lower(name) + for _, known in ipairs(def.names) do + if lower == string.lower(known) then return true end + end + return false +end + +local function AuraMatches(def, effect, texture) + if NameMatches(def, effect) then return true end + if texture and def.textureMatch then + return string.find(string.lower(texture), def.textureMatch) and true or false + end + return false +end + +local function ScanSpellbook() + if table.getn(AURA_DEFS) == 0 then return end + + local i = 1 + while true do + local name = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + + local texture = GetSpellTexture(i, BOOKTYPE_SPELL) + local lowerTexture = texture and string.lower(texture) or "" + + for _, def in ipairs(AURA_DEFS) do + if NameMatches(def, name) or (def.textureMatch and string.find(lowerTexture, def.textureMatch)) then + def.localizedName = name + def.texture = texture or def.texture + end + end + + i = i + 1 + end + if BNP.RefreshSpellbookDurations then BNP:RefreshSpellbookDurations() end +end + +local function CreateAuraIcon(parent, index) + local icon = CreateFrame("Frame", nil, parent) + local size = GetIconSize() + icon:SetWidth(size) + icon:SetHeight(size) + icon:SetFrameLevel(parent.GetFrameLevel and parent:GetFrameLevel() or 0) + + local texture = icon:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(icon) + icon.texture = texture + + local timer = icon:CreateFontString(nil, "OVERLAY") + timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", UI.TIMER_SIZE or 8, "OUTLINE") + timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0) + timer:SetTextColor(1, 1, 1) + icon.timer = timer + + local stack = icon:CreateFontString(nil, "OVERLAY") + stack:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", 8, "OUTLINE") + stack:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1) + stack:SetTextColor(1, 1, 1) + stack:SetText("") + icon.stack = stack + + icon.index = index + icon:Hide() + + return icon +end + +local function HidePlateAuraFrames(plate) + if not plate then return end + + local containers = { plate.BNPAuraContainer, plate.BNPCCContainer } + local _, container, i + for _, container in ipairs(containers) do + if container then + for i = 1, table.getn(container.icons or {}) do + container.icons[i]:Hide() + end + container:Hide() + end + end +end + +local function InstallAuraHideGuard(plate) + if not plate or plate.BNPAuraHideGuard then return end + + local oldOnHide = plate:GetScript("OnHide") + plate:SetScript("OnHide", function() + if oldOnHide then oldOnHide() end + local current = this or plate + HidePlateAuraFrames(current) + -- If Blizzard reuses this frame for another unit, do not hold the old GUID + -- through the next OnShow. The next visible identity must be reconfirmed. + current.BNPAuraForceFreshIdentity = true + current.BNPAuraGUIDCandidate = nil + current.BNPAuraGUIDCandidateCount = 0 + end) + plate.BNPAuraHideGuard = true +end + +local function GetAuraVisualParent(plate) + -- Aura rows must not inherit the projected nameplate's alpha. In large raids + -- the Vanilla client can transiently adjust/recycle a non-target plate's alpha + -- while resolving dense stacks. A child frame inherits that alpha even when + -- its own alpha is forced to 1, which made debuffs pulse grey/colored. + -- Use the nameplate's native parent (normally WorldFrame) as a sibling parent + -- and keep anchoring to the real healthbar so position still follows the plate. + if plate and plate.GetParent then + local parent = plate:GetParent() + if parent then return parent end + end + return WorldFrame or UIParent +end + +local function CreateAuraContainer(plate) + if not plate then return end + BNP:RegisterPlate(plate) + if plate.BNPAuraContainer then return end + + local size = GetIconSize() + local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local container = CreateFrame("Frame", nil, GetAuraVisualParent(plate)) + container:SetWidth(width) + container:SetHeight(size) + container:SetFrameLevel(GetAuraFrameLevel(plate)) + + AnchorAuraContainer(plate, container, false) + + container.icons = {} + for i = 1, MAX_VISIBLE_ICONS do + local icon = CreateAuraIcon(container, i) + PositionAuraIcon(icon, container, i, 0, size) + container.icons[i] = icon + end + + container:Hide() + plate.BNPAuraContainer = container + ApplyAuraFrameLevel(plate, container) + InstallAuraHideGuard(plate) +end + +local function CreateCCContainer(plate) + if not plate then return end + BNP:RegisterPlate(plate) + if plate.BNPCCContainer then return end + + local size = GetCCIconSize() + local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local container = CreateFrame("Frame", nil, GetAuraVisualParent(plate)) + container:SetWidth(width) + container:SetHeight(size) + container:SetFrameLevel(GetAuraFrameLevel(plate)) + + AnchorAuraContainer(plate, container, true) + + container.icons = {} + for i = 1, MAX_VISIBLE_ICONS do + local icon = CreateAuraIcon(container, i) + PositionAuraIcon(icon, container, i, 0, size, true) + container.icons[i] = icon + end + + container:Hide() + plate.BNPCCContainer = container + ApplyAuraFrameLevel(plate, container) +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + + -- SuperWoW stores the unit GUID/token directly on the nameplate name slot. + -- Use that exact token, just like Tank Mode and ShaguTweaks do. This avoids + -- unreliable UnitExists() resolution and, importantly, avoids same-name + -- fallback collisions between two mobs with identical names. + local token = plate:GetName(1) + if token and token ~= "" then return token end + return nil +end + +local function GetTargetGUID() + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +end + +local function ClearGUIDAuraState(guid) + if not guid then return end + BNP.guidAuras[guid] = nil + if BNP.svStateByGUID then BNP.svStateByGUID[guid] = nil end + if BNP.svExpiryByGUID then BNP.svExpiryByGUID[guid] = nil end + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if BNP.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end + if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end + if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end + if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end + if BNP.guidNames then BNP.guidNames[guid] = nil end +end + +-- Full-immunity cleanse bridge ---------------------------------------------- +-- Divine Shield and Ice Block remove harmful effects. BNP tracks some of +-- those effects from cast events/timers, so a server cleanse must also wipe +-- every local harmful-aura cache for that exact GUID. While the immunity is +-- active, keep a short renewable suppression window so stale UnitDebuff / +-- pending-cast data cannot repopulate icons for one frame. +BNP.guidHarmfulAuraSuppressionUntil = BNP.guidHarmfulAuraSuppressionUntil or {} + +local function ClearHarmfulAuraStateOnly(guid) + if not guid then return end + BNP.guidAuras[guid] = nil + if BNP.svStateByGUID then BNP.svStateByGUID[guid] = nil end + if BNP.svExpiryByGUID then BNP.svExpiryByGUID[guid] = nil end + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if BNP.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end + if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end + if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end + if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end + + -- A failed/immune cast rollback must never restore pre-cleanse timers after + -- Bubble/Ice Block. Drop all outstanding attempts tied to this GUID. + if BNP.auraCastAttempts then + local i + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if attempt and attempt.guid == guid then + table.remove(BNP.auraCastAttempts, i) + end + end + end +end + +local function HarmfulAuraSuppressed(guid, now) + if not guid then return false end + local untilTime = BNP.guidHarmfulAuraSuppressionUntil[guid] + if not untilTime then return false end + now = now or GetTime() + if now < untilTime then return true end + BNP.guidHarmfulAuraSuppressionUntil[guid] = nil + return false +end + +function BNP:SuppressHarmfulAurasForImmunity(guid, seconds) + if not guid then return end + local now = GetTime() + local untilTime = now + math.max(tonumber(seconds) or 0.35, 0.10) + local current = self.guidHarmfulAuraSuppressionUntil[guid] or 0 + if untilTime > current then + self.guidHarmfulAuraSuppressionUntil[guid] = untilTime + end + ClearHarmfulAuraStateOnly(guid) +end + +function BNP:IsHarmfulAuraSuppressed(guid, now) + return HarmfulAuraSuppressed(guid, now) +end + +local function CleanupDeadUnit(unit, guid, now) + if not unit or not guid or not UnitIsDeadOrGhost then return false end + if AuraCacheProtected and AuraCacheProtected(guid, now) then return false end + if UnitIsDeadOrGhost(unit) then + ClearGUIDAuraState(guid) + return true + end + return false +end + + +-- Fallback for players/NPCs whose nameplate disappears before the periodic +-- dead-state check sees them. Vanilla combat chat reports " dies." only, +-- so this fallback must NEVER clear multiple same-name GUIDs. +local function ClearAuraStateByName(name) + if not name or name == "" then return end + + -- Combat death chat gives us only a name, never the exact GUID. + -- Count ALL known GUIDs with this name, not only GUIDs that currently have + -- tracked auras. Otherwise, if two same-name mobs exist but only one has our + -- debuffs, the death of the other mob could wrongly clear the survivor. + local matchGUID = nil + local matches = 0 + local guid, knownName + + for guid, knownName in pairs(BNP.guidNames or {}) do + if knownName == name then + matches = matches + 1 + matchGUID = guid + + if matches > 1 then + -- Ambiguous same-name death: never clear anything by name. + -- Exact GUID-based target death cleanup remains authoritative. + return + end + end + end + + if matches == 1 and matchGUID then + ClearGUIDAuraState(matchGUID) + end +end + + +-- Boss / NPC evade-reset cleanup -------------------------------------------- +-- On a real reset the server restores the NPC to full health and drops combat, +-- while BNP's local aura timers would otherwise keep running. Track only the +-- current hostile NPC and clear its exact GUID cache when that reset signature +-- is observed. +local resetWatch = {} +local resetElapsed = 0 + +local function CheckCurrentTargetReset(now) + local exists, guid = UnitExists("target") + if not exists or not guid then + resetWatch.guid = nil + resetWatch.wasDamaged = nil + return + end + + if UnitIsPlayer and UnitIsPlayer("target") then + resetWatch.guid = guid + resetWatch.wasDamaged = nil + return + end + + if resetWatch.guid ~= guid then + resetWatch.guid = guid + resetWatch.wasDamaged = nil + end + + if not UnitHealth or not UnitHealthMax then return end + local hp = tonumber(UnitHealth("target")) + local maxhp = tonumber(UnitHealthMax("target")) + if not hp or not maxhp or maxhp <= 0 then return end + + local pct = hp / maxhp + if pct < 0.95 then + resetWatch.wasDamaged = true + end + + if not resetWatch.wasDamaged then return end + if pct < 0.995 then return end + + -- Prefer the target's combat state when available. If the API is missing, + -- require the player to be out of combat as a conservative fallback. + local outOfCombat = false + if UnitAffectingCombat then + outOfCombat = not UnitAffectingCombat("target") + elseif PlayerFrame and PlayerFrame.inCombat ~= nil then + outOfCombat = not PlayerFrame.inCombat + end + + if not outOfCombat then return end + + if BNP.guidAuras and BNP.guidAuras[guid] then + ClearGUIDAuraState(guid) + if RaidTrace then RaidTrace("RESET", guid, "-", nil) end + end + + resetWatch.wasDamaged = nil +end + +local resetFrame = CreateFrame("Frame") +resetFrame:SetScript("OnUpdate", function() + resetElapsed = resetElapsed + arg1 + if resetElapsed < 0.10 then return end + resetElapsed = 0 + CheckCurrentTargetReset(GetTime()) +end) + +local deathChatFrame = CreateFrame("Frame") +deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH") +deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") +deathChatFrame:SetScript("OnEvent", function() + local raw = arg1 + if not raw then return end + local _, _, deadName = string.find(raw, "^(.+) dies%.$") + if deadName then ClearAuraStateByName(deadName) end +end) + +-- Only successful casts by the local player are allowed to create aura +-- entries. This prevents target scans from importing debuffs cast by other +-- players. SuperWoW supplies caster GUID, target GUID and spell ID. +local function GetPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then return guid end + return nil +end + +local function DefHasSpellID(def, spellID) + if not def or not spellID then return false end + + local i + if def.spellIDs then + for i = 1, table.getn(def.spellIDs) do + if def.spellIDs[i] == spellID then return true end + end + end + + if def.durations and def.durations[spellID] then return true end + return false +end + +local function FindAuraDef(spellID) + if not spellID or not SpellInfo then return nil, nil end + local spellName, _, texture = SpellInfo(spellID) + if not spellName and not texture then return nil, nil end + + -- Exact IDs are authoritative and must win over shared spell textures. + local _, def + for _, def in ipairs(AURA_DEFS) do + if DefHasSpellID(def, spellID) then + return def, texture, spellID + end + end + + for _, def in ipairs(AURA_DEFS) do + if NameMatches(def, spellName) then + return def, texture, spellID + end + end + + for _, def in ipairs(AURA_DEFS) do + if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then + return def, texture, spellID + end + end + + return nil, nil +end + + +-- Paladin Judgements -------------------------------------------------------- +-- SuperWoW reports the button press as Judgement [20271], while the target +-- receives a different aura ID (e.g. Light 20344, Justice 20184, Crusader +-- 20302, Wisdom 20353). Therefore normal CAST-ID == AURA-ID confirmation +-- cannot work for Judgements. +local PALADIN_JUDGEMENT_CAST_ID = 20271 + +local function IsJudgementDef(def) + if not def or not def.key then return false end + return string.find(def.key, "^judgement_") and true or false +end + +local function FindJudgementAuraOnUnit(unit) + if playerClass ~= "PALADIN" or not unit then return nil end + + local i, texture, stacks, dtype, auraSpellID + for i = 1, 64 do + texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + if auraSpellID then + local auraName = nil + if SpellInfo then auraName = SpellInfo(auraSpellID) end + + local _, def + for _, def in ipairs(AURA_DEFS) do + if IsJudgementDef(def) then + local explicitMatch = false + local j + if def.spellIDs then + for j = 1, table.getn(def.spellIDs) do + if def.spellIDs[j] == auraSpellID then + explicitMatch = true + break + end + end + end + + if explicitMatch or NameMatches(def, auraName) then + return def, auraSpellID, texture + end + end + end + end + end + + return nil +end + + +-- Turtle WoW Dark Harvest (spell 52552). +-- It is reported by SuperWoW as CHANNEL; arg5 is channel length in ms. +-- A 30% shorter tick interval means aura time advances at 1/0.70 speed. +local DARK_HARVEST_SPELL_ID = 52552 +local DARK_HARVEST_SPEED = 1 / 0.70 +local DARK_HARVEST_AURAS = { + corruption=true, curse_of_agony=true, siphon_life=true, + curse_of_doom=true, drain_life=true, drain_soul=true, +} +BNP.darkHarvest = nil + +local function StartDarkHarvest(guid, durationMS) + local now=GetTime() + local duration=tonumber(durationMS) or 8000 + if duration > 100 then duration=duration/1000 end + BNP.darkHarvest={guid=guid, lastUpdate=now, ends=now+duration} +end + +local function UpdateDarkHarvest(now) + local st=BNP.darkHarvest + if not st then return end + local untilTime=now + if untilTime > st.ends then untilTime=st.ends end + local delta=untilTime-st.lastUpdate + if delta > 0 then + local cache=BNP.guidAuras[st.guid] + if cache then + local bonus=delta*(DARK_HARVEST_SPEED-1) + local key,aura + for key,aura in pairs(cache) do + if DARK_HARVEST_AURAS[key] and type(aura)=="table" and aura.expires and aura.expires > st.lastUpdate then + aura.expires=aura.expires-bonus + end + end + end + st.lastUpdate=untilTime + end + if now >= st.ends then BNP.darkHarvest=nil end +end + + + +-- Raid confirmation trace ---------------------------------------------------- +-- Tiny always-on ring buffer for the last tracked own-aura transitions. +-- No chat output and no scanning; entries are only added at existing events. +BNP.raidTrace = BNP.raidTrace or {} +BNP.raidTraceMax = 30 + +local function RaidTrace(stage, guid, key, spellID) + local t = BNP.raidTrace + table.insert(t, { + time = GetTime(), + stage = stage, + guid = guid, + key = key, + spellID = spellID, + }) + while table.getn(t) > BNP.raidTraceMax do + table.remove(t, 1) + end +end + +-- Cast confirmation --------------------------------------------------------- +-- UNIT_CASTEVENT "CAST" means the player finished the cast, but it does not +-- guarantee that the hostile aura actually landed. Resist/Miss/Immune must +-- therefore never create a visible timer. +-- +-- We first remember the cast as "pending". It becomes a real aura only after +-- UnitDebuff confirms the matching aura on that exact SuperWoW GUID. +-- Never infer success from CAST alone: melee abilities can still miss/dodge, +-- and hostile targets may resist or be immune after the cast event. +BNP.pendingAuras = BNP.pendingAuras or {} +BNP.pendingAoEAuras = BNP.pendingAoEAuras or {} + +-- Pending failures are matched by exact SuperWoW UNIT_CASTEVENT data. +-- Never use global combat-text "miss/resist" lines here: in a raid those can +-- belong to another spell and previously caused unrelated pending DoTs to die. + +local function FailPendingAura(targetGUID, spellID) + if not targetGUID or not spellID then return end + local entries = BNP.pendingAuras[targetGUID] + if not entries then return end + + local def = FindAuraDef(spellID) + if def and entries[def.key] then + entries[def.key] = nil + end + + -- Generic Paladin Judgement failure. + if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then + entries.__judgement = nil + end +end + +local PENDING_TIMEOUT = 4.0 + +-- Shagu-style authoritative own-cast tracking -------------------------------- +-- Exact own SuperWoW CAST on an exact GUID creates normal direct single-target +-- debuffs immediately. The application remains provisional for a short window: +-- SuperWoW FAIL and localized combat-log miss/resist/immune messages can roll it +-- back, including restoring the old timer after a resisted refresh. +local STRICT_AURA_KEYS = { + -- Shared / proc / secondary effects + shadow_vulnerability=true, hunters_mark=true, + + -- Warlock CC + fear=true, howl_of_terror=true, banish=true, death_coil=true, + + -- Hunter traps / CC / talent procs + immolation_trap_effect=true, explosive_trap_effect=true, freezing_trap_effect=true, + improved_scorpid_sting=true, improved_wing_clip=true, improved_concussive_shot=true, + scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true, + intimidation=true, scare_beast=true, + + -- Mage CC / proc effects + polymorph=true, frost_nova=true, counterspell=true, counterspell_silence=true, + ignite=true, impact=true, frostbite=true, fire_vulnerability=true, winters_chill=true, + + -- Priest CC / proc effects + psychic_scream=true, silence=true, blackout=true, touch_of_weakness=true, + shackle_undead=true, mind_control=true, + + -- Paladin CC + hammer_of_justice=true, repentance=true, turn_undead=true, + + -- Rogue CC / poison procs + gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, + kick_silenced=true, crippling_poison=true, mind_numbing_poison=true, + wound_poison=true, deadly_poison=true, + + -- Druid CC / secondary effects + entangling_roots=true, bash=true, pounce=true, pounce_bleed=true, + hibernate=true, feral_charge_effect=true, + + -- Warrior CC / talent procs + improved_hamstring=true, intimidating_shout=true, disarm=true, + charge_stun=true, intercept_stun=true, concussion_blow=true, + revenge_stun=true, pummel=true, shield_bash=true, deep_wound=true, + + -- Shaman totem / proc effects + earthbind=true, earthgrab=true, frostbrand_attack=true, +} + +local function GetTrackingMode(def) + if not def or not def.key then return "AURA" end + if def.category == "CURSE" then return "AURA" end + if def.aoe then return "AURA" end + local cat = string.upper(tostring(def.category or "")) + if cat == "CC" or cat == "ROOT" or cat == "STUN" + or cat == "TRAP" or cat == "SILENCE" or cat == "PROC" then + return "AURA" + end + if STRICT_AURA_KEYS[def.key] then return "AURA" end + return "CAST" +end + +local function IsCastPrimary(def) + return GetTrackingMode(def) == "CAST" +end + +local AURA_ATTEMPT_WINDOW = 5.0 +BNP.auraCastAttempts = BNP.auraCastAttempts or {} + +local function CopyAuraState(aura) + if type(aura) ~= "table" then return nil end + local copy = {} + local k, v + for k, v in pairs(aura) do copy[k] = v end + return copy +end + +local function BuildAffectedKeys(def) + local keys = {} + if not def or not def.key then return keys end + + keys[def.key] = true + + -- Any Warlock curse cast can change the target's complete curse state. + -- Snapshot every tracked curse so a later resist/fail can restore exactly + -- what was active before the attempt. This also supports Turtle mechanics + -- where Curse of Agony may coexist with one regular curse. + if def.category == "CURSE" then + local _, oldDef + for _, oldDef in ipairs(AURA_DEFS) do + if oldDef.category == "CURSE" then + keys[oldDef.key] = true + end + end + end + + return keys +end + +local function StartAuraCastAttempt(guid, def, spellID, affectedKeys) + if not guid or not spellID then return nil end + + local keys = affectedKeys or BuildAffectedKeys(def) + local cache = BNP.guidAuras[guid] + local before = {} + local key + + for key in pairs(keys) do + local aura = cache and cache[key] + before[key] = { + exists = type(aura) == "table" and true or false, + aura = CopyAuraState(aura), + } + end + + local spellName = nil + if SpellInfo then spellName = SpellInfo(spellID) end + if not spellName and def and def.localizedName then spellName = def.localizedName end + if not spellName and def and def.names then spellName = def.names[1] end + + local attempt = { + guid = guid, + key = def and def.key or "__special", + spellID = spellID, + spellName = spellName, + time = GetTime(), + before = before, + done = false, + } + + table.insert(BNP.auraCastAttempts, attempt) + while table.getn(BNP.auraCastAttempts) > 32 do + table.remove(BNP.auraCastAttempts, 1) + end + + return attempt +end + +local function RollbackAuraCastAttempt(attempt, reason) + if not attempt or attempt.done then return false end + + local guid = attempt.guid + local cache = BNP.guidAuras[guid] + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + + local key, state + for key, state in pairs(attempt.before or {}) do + if state.exists and state.aura then + cache[key] = CopyAuraState(state.aura) + else + cache[key] = nil + end + end + + attempt.done = true + RaidTrace("ROLLBACK", guid, attempt.key, attempt.spellID) + return true +end + +local function RollbackAuraAttemptBySpell(guid, spellID, reason) + if not spellID then return false end + local now = GetTime() + local i + + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if attempt and not attempt.done + and attempt.spellID == spellID + and (not guid or attempt.guid == guid) + and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW then + return RollbackAuraCastAttempt(attempt, reason) + end + end + + return false +end + +local function CleanupAuraCastAttempts(now) + local i + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if not attempt or attempt.done or now - (attempt.time or 0) > AURA_ATTEMPT_WINDOW then + table.remove(BNP.auraCastAttempts, i) + end + end +end + +-- ShaguPlates does more than just seed debuffs from UNIT_CASTEVENT CAST: +-- libdebuff also watches the local player's failure combat messages and +-- reverts the last application on miss/resist/immune/evade/etc. BNP mirrors +-- that safety, but keeps the exact SuperWoW GUID and can restore the previous +-- timer when a refresh fails. +local failurePatterns = {} + +local function EscapeLuaPattern(text) + return string.gsub(text, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + +local function CombatFormatToPattern(formatText) + if not formatText or formatText == "" then return nil end + + local text = formatText + text = string.gsub(text, "%%%%", "__BNP_PERCENT__") + text = string.gsub(text, "%%%d+%$s", "__BNP_STRING__") + text = string.gsub(text, "%%s", "__BNP_STRING__") + text = string.gsub(text, "%%%d+%$d", "__BNP_NUMBER__") + text = string.gsub(text, "%%d", "__BNP_NUMBER__") + text = EscapeLuaPattern(text) + text = string.gsub(text, "__BNP_STRING__", ".+") + text = string.gsub(text, "__BNP_NUMBER__", "%%d+") + text = string.gsub(text, "__BNP_PERCENT__", "%%%%") + return "^" .. text .. "$" +end + +local function AddFailurePattern(formatText) + local pattern = CombatFormatToPattern(formatText) + if pattern then table.insert(failurePatterns, pattern) end +end + +AddFailurePattern(SPELLIMMUNESELFOTHER) +AddFailurePattern(IMMUNEDAMAGECLASSSELFOTHER) +AddFailurePattern(SPELLMISSSELFOTHER) +AddFailurePattern(SPELLRESISTSELFOTHER) +AddFailurePattern(SPELLEVADEDSELFOTHER) +AddFailurePattern(SPELLDODGEDSELFOTHER) +AddFailurePattern(SPELLDEFLECTEDSELFOTHER) +AddFailurePattern(SPELLREFLECTSELFOTHER) +AddFailurePattern(SPELLPARRIEDSELFOTHER) +AddFailurePattern(SPELLFAILCASTSELF) + +local function IsLocalFailureMessage(raw) + if not raw or raw == "" then return false end + local i + for i = 1, table.getn(failurePatterns) do + if string.find(raw, failurePatterns[i]) then return true end + end + return false +end + +local function RollbackAuraAttemptFromCombatMessage(raw) + if not IsLocalFailureMessage(raw) then return false end + + local now = GetTime() + local i + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + if attempt and not attempt.done + and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW + and attempt.spellName + and string.find(raw, attempt.spellName, 1, true) then + return RollbackAuraCastAttempt(attempt, "COMBAT_FAIL") + end + end + + return false +end + +local auraFailureFrame = CreateFrame("Frame") +auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER") +auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") +auraFailureFrame:SetScript("OnEvent", function() + RollbackAuraAttemptFromCombatMessage(arg1) +end) + +local function HasPendingAuras() + local guid, entries, key, pending + for guid, entries in pairs(BNP.pendingAuras) do + for key, pending in pairs(entries) do + if type(pending) == "table" then + return true + end + end + end + return false +end + +local function GetUnitTokenForPlate(plate) + if not plate or not plate.GetName then return nil end + return plate:GetName(1) +end + +local function FindMatchingAuraOnUnit(unit, def, castSpellID) + if not unit or not def then return nil, nil, nil end + + -- SuperWoW can expose more than the stock 16/32 debuff slots. In a 40-player + -- raid our own debuff may therefore sit beyond slot 32. Stopping at 32 made + -- valid casts time out instead of entering guidAuras. + local i = 1 + while i <= 64 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + if auraSpellID then + if auraSpellID == castSpellID or DefHasSpellID(def, auraSpellID) then + return auraSpellID, texture, tonumber(stacks) or 0 + end + + local auraName = SpellInfo and SpellInfo(auraSpellID) or nil + if NameMatches(def, auraName) then + return auraSpellID, texture, tonumber(stacks) or 0 + end + end + + if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then + return auraSpellID or castSpellID, texture, tonumber(stacks) or 0 + end + + i = i + 1 + end + + return nil, nil, nil +end + +local function ReconcileLiveCurseCache(unit, guid) + if playerClass ~= "WARLOCK" or not unit or not guid then return end + + local cache = BNP.guidAuras[guid] + if not cache then return end + + -- Curse casts are already strict aura-confirmed. Once a new curse has really + -- appeared, use that same live target aura state to decide which older curse + -- timers are still valid. This avoids hard-coding whether CoA is exclusive: + -- if the server keeps two curses, BNP keeps two; if one was replaced, BNP + -- removes only the aura that is actually gone. + local _, curseDef + for _, curseDef in ipairs(AURA_DEFS) do + if curseDef.category == "CURSE" and cache[curseDef.key] then + local cachedAura = cache[curseDef.key] + local liveSpellID = FindMatchingAuraOnUnit( + unit, + curseDef, + cachedAura and cachedAura.spellID or nil + ) + + if not liveSpellID then + RaidTrace("CURSE_REMOVE", guid, curseDef.key, cachedAura and cachedAura.spellID) + cache[curseDef.key] = nil + end + end + end +end + +local function CommitPendingAura(guid, key, pending, unit) + if not guid or not pending then return end + + local def = pending.def + local cache = BNP.guidAuras[guid] + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + + -- Do not guess curse exclusivity here. Warlock curses are strict aura- + -- confirmed and are reconciled against the live target state after commit. + -- This supports both normal Vanilla replacement and Turtle dual-curse rules. + + cache._nextOrder = (cache._nextOrder or 0) + 1 + local aura = cache[key] or {} + aura.order = cache._nextOrder + aura.duration = pending.duration + aura.expires = GetTime() + pending.duration + aura.spellID = pending.spellID + aura.texture = pending.texture or def.texture + aura.stacks = tonumber(pending.stacks) or 0 + aura.confirmedAt = GetTime() + aura.missingScans = nil + + if pending.liveConfirmed then + aura.castPrimary = nil + aura.castPrimaryAt = nil + aura.liveConfirmed = true + elseif pending.castPrimary then + aura.castPrimary = true + aura.castPrimaryAt = GetTime() + aura.liveConfirmed = nil + end + + cache[key] = aura + + if def.category == "CURSE" and unit then + ReconcileLiveCurseCache(unit, guid) + end + + RaidTrace("CONFIRM", guid, key, aura.spellID) +end + +local function ConfirmPendingForUnit(unit, guid, now) + if not unit or not guid then return end + local pendingForGUID = BNP.pendingAuras[guid] + if not pendingForGUID then return end + + local key, pending + for key, pending in pairs(pendingForGUID) do + if type(pending) == "table" then + if pending.judgementProbe then + local def, auraSpellID, texture = FindJudgementAuraOnUnit(unit) + + if def and auraSpellID then + local duration = def.duration + if BNP.GetSpellbookDurationForDef then + duration = BNP:GetSpellbookDurationForDef(def) or duration + end + + local confirmed = { + def = def, + spellID = auraSpellID, + texture = texture or def.texture, + duration = duration, + created = pending.created, + liveConfirmed = true, + } + + CommitPendingAura(guid, def.key, confirmed, unit) + pendingForGUID[key] = nil + elseif now - pending.created > PENDING_TIMEOUT then + RaidTrace("TIMEOUT", guid, key, pending.spellID) + -- No Judgement aura appeared: resist, miss, immune or failed cast. + pendingForGUID[key] = nil + end + else + local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID) + if auraSpellID then + local confirmed = { + def = pending.def, + spellID = auraSpellID, + texture = auraTexture or pending.texture, + duration = pending.duration, + created = pending.created, + stacks = auraStacks or 0, + liveConfirmed = true, + } + CommitPendingAura(guid, key, confirmed, unit) + pendingForGUID[key] = nil + elseif now - pending.created > PENDING_TIMEOUT then + RaidTrace("TIMEOUT", guid, key, pending.spellID) + -- Effects not approved for the raid fallback remain strict. + pendingForGUID[key] = nil + end + end + end + end +end + +local function CleanupPending(now) + local guid, entries, key, pending + for guid, entries in pairs(BNP.pendingAuras) do + local any = false + for key, pending in pairs(entries) do + if type(pending) == "table" then + if now - pending.created > PENDING_TIMEOUT then + entries[key] = nil + else + any = true + end + end + end + if not any then BNP.pendingAuras[guid] = nil end + end +end + + +local AOE_PENDING_TIMEOUT = 1.5 + +local function QueueAoEAura(def, spellID, texture, duration) + if not def or not spellID then return end + BNP.pendingAoEAuras[def.key] = { + def = def, + spellID = spellID, + texture = texture or def.texture, + duration = duration, + created = GetTime(), + } +end + +local function ConfirmAoEAurasOnUnit(unit, guid) + if not unit or not guid then return end + local key, pending + for key, pending in pairs(BNP.pendingAoEAuras) do + if type(pending) == "table" then + local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID) + if auraSpellID then + CommitPendingAura(guid, pending.def.key, { + def = pending.def, + spellID = auraSpellID, + texture = auraTexture or pending.texture, + duration = pending.duration, + created = pending.created, + stacks = auraStacks or 0, + liveConfirmed = true, + }, unit) + end + end + end +end + +local function HasPendingAoEAuras() + local key, pending + for key, pending in pairs(BNP.pendingAoEAuras) do + if type(pending) == "table" then return true end + end + return false +end + +local function CleanupPendingAoE(now) + local key, pending + for key, pending in pairs(BNP.pendingAoEAuras) do + if type(pending) == "table" and now - (pending.created or 0) > AOE_PENDING_TIMEOUT then + BNP.pendingAoEAuras[key] = nil + end + end +end + +local recentChannelAuras = {} + +local function ChannelKey(guid, spellID) + return tostring(guid or "-") .. ":" .. tostring(spellID or 0) +end + +local castEvents = CreateFrame("Frame") +castEvents:RegisterEvent("UNIT_CASTEVENT") +castEvents:SetScript("OnEvent", function() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + local playerGUID = GetPlayerGUID() + + if not playerGUID or casterGUID ~= playerGUID then return end + + if eventType == "FAIL" then + RaidTrace("FAIL", targetGUID, "-", spellID) + FailPendingAura(targetGUID, spellID) + RollbackAuraAttemptBySpell(targetGUID, spellID, "UNIT_FAIL") + return + end + + -- Dark Harvest has its own channel implementation and must not enter the + -- generic aura pipeline below. + if eventType == "CHANNEL" and spellID == DARK_HARVEST_SPELL_ID then + StartDarkHarvest(targetGUID, arg5) + return + end + + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + + -- Shadow audit only: this never changes the existing lookup or renderer. + if BNP.AuditSpellDB then BNP:AuditSpellDB(spellID) end + + -- Paladin Judgement is a generic cast (20271). The actual debuff ID is only + -- known after it lands on the target, so defer identification to UnitDebuff. + if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then + if not targetGUID then return end + + if targetGUID then + local judgementKeys = {} + local _, judgementDef + for _, judgementDef in ipairs(AURA_DEFS) do + if IsJudgementDef(judgementDef) then judgementKeys[judgementDef.key] = true end + end + StartAuraCastAttempt(targetGUID, { key = "__judgement" }, spellID, judgementKeys) + end + + local pendingForGUID = BNP.pendingAuras[targetGUID] + if not pendingForGUID then + pendingForGUID = {} + BNP.pendingAuras[targetGUID] = pendingForGUID + end + + pendingForGUID.__judgement = { + judgementProbe = true, + created = GetTime(), + } + return + end + + -- Shadow Vulnerability uses one aura ID per talent rank. It is a shared + -- raid debuff and has its own any-Warlock path below, so none of its ranks + -- should enter the normal own-cast pending pipeline. + local sid = tonumber(spellID) + if playerClass == "WARLOCK" and (sid == 17794 or sid == 17797 or sid == 17798 or sid == 17799 or sid == 17800) then return end + + local def, texture = FindAuraDef(spellID) + if not def then return end + + -- A few SuperWoW channel paths can omit arg2 briefly. A channel cannot move to + -- a different target after it starts, so the real current target is a safe + -- fallback here (unlike blindly doing this for every normal CAST event). + if eventType == "CHANNEL" and not targetGUID then + targetGUID = GetTargetGUID() + end + + -- Channeled hostile effects are applied when SuperWoW emits CHANNEL, not when + -- the channel later ends. Some clients may additionally emit CAST at the end; + -- suppress that duplicate so the timer cannot restart after the channel. If a + -- server exposes only CAST, the fallback remains available. + if eventType == "CHANNEL" then + if not def.channel then return end + local channelMS = tonumber(arg5) or 0 + local hold = channelMS > 0 and (channelMS / 1000) or (def.duration or 1) + recentChannelAuras[ChannelKey(targetGUID, spellID)] = GetTime() + hold + 0.75 + elseif def.channel then + local untilTime = recentChannelAuras[ChannelKey(targetGUID, spellID)] + if untilTime and GetTime() <= untilTime then return end + end + + local duration = nil + -- Resolve the exact rank that was cast. This preserves tooltip-derived talent + -- or server modifications for that rank, while still falling back to the + -- explicit rank table when the exact spellbook slot cannot be resolved. + if BNP.GetSpellbookDurationForSpellID then + duration = BNP:GetSpellbookDurationForSpellID(def, spellID) + end + duration = duration or (def.durations and def.durations[spellID]) + if not duration and BNP.GetSpellbookDurationForDef then + duration = BNP:GetSpellbookDurationForDef(def) + end + duration = duration or def.duration + + if def.aoe then + QueueAoEAura(def, spellID, texture, duration) + return + end + + if not targetGUID then return end + + StartAuraCastAttempt(targetGUID, def, spellID) + + local pendingForGUID = BNP.pendingAuras[targetGUID] + if not pendingForGUID then + pendingForGUID = {} + BNP.pendingAuras[targetGUID] = pendingForGUID + end + + pendingForGUID[def.key] = { + def = def, + spellID = spellID, + texture = texture or def.texture, + duration = duration, + created = GetTime(), + } + + RaidTrace("CAST", targetGUID, def.key, spellID) + + if IsCastPrimary(def) then + local direct = { + def = def, + spellID = spellID, + texture = texture or def.texture, + duration = duration, + created = GetTime(), + stacks = 0, + castPrimary = true, + } + CommitPendingAura(targetGUID, def.key, direct) + local cache = BNP.guidAuras and BNP.guidAuras[targetGUID] + if cache and cache[def.key] then + cache[def.key].castPrimary = true + cache[def.key].castPrimaryAt = GetTime() + cache[def.key].liveConfirmed = nil + end + pendingForGUID[def.key] = nil + RaidTrace("DIRECT", targetGUID, def.key, spellID) + return + end + + -- Fast path: if the cast target is still our current target, try to confirm + -- immediately. The normal pending loop remains as the retry path. + local currentTargetGUID = GetTargetGUID() + if currentTargetGUID and currentTargetGUID == targetGUID then + ConfirmPendingForUnit("target", targetGUID, GetTime()) + end +end) + + + +-- Paladin Judgement melee refresh ------------------------------------------ +-- Vanilla Judgement debuffs refresh when the judging Paladin lands a melee +-- strike on that target. The aura itself does not get re-cast, so UNIT_CASTEVENT +-- cannot update our timer. Refresh only an already-confirmed OWN Judgement. +local function RefreshOwnJudgementForGUID(guid) + if playerClass ~= "PALADIN" or not guid then return end + + local cache = BNP.guidAuras[guid] + if not cache then return end + + local _, def + for _, def in ipairs(AURA_DEFS) do + if IsJudgementDef(def) then + local aura = cache[def.key] + if type(aura) == "table" and aura.expires then + local duration = aura.duration or def.duration or 10 + aura.duration = duration + aura.expires = GetTime() + duration + aura.confirmedAt = GetTime() + aura.missingScans = nil + end + end + end +end + +-- SuperWoW gives us the target GUID of the player's current target. For normal +-- auto-attacks, the combat text event is sufficient to know a successful melee +-- hit occurred; matching the current target GUID keeps the refresh scoped to +-- the unit actually being attacked. +local judgementMeleeFrame = CreateFrame("Frame") +judgementMeleeFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") +judgementMeleeFrame:SetScript("OnEvent", function() + if playerClass ~= "PALADIN" then return end + + local guid = GetTargetGUID() + if not guid then return end + + -- CHAT_MSG_COMBAT_SELF_HITS only fires for landed melee attacks (hit/crit). + RefreshOwnJudgementForGUID(guid) +end) + + + +-- Holy Strike also counts as a melee-style Judgement refresh on Turtle/Octo. +-- It is reported via CHAT_MSG_SPELL_SELF_DAMAGE rather than the normal melee +-- hit event and does not expose a useful spell ID in this client. +local holyStrikeRefreshFrame = CreateFrame("Frame") +holyStrikeRefreshFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") +holyStrikeRefreshFrame:SetScript("OnEvent", function() + if playerClass ~= "PALADIN" then return end + + local raw = arg1 + if not raw then return end + + -- Only successful Holy Strike hit messages refresh the timer. + -- Miss/resist/immune lines do not match this pattern. + if not string.find(raw, "^Your Holy Strike hits ") then return end + + local guid = GetTargetGUID() + if not guid then return end + + -- Extra safety: when the target name is available, require it to be present + -- in the combat-log line so a stale/current target cannot refresh another GUID. + local targetName = UnitName("target") + if targetName and targetName ~= "" and not string.find(raw, targetName, 1, true) then + return + end + + RefreshOwnJudgementForGUID(guid) +end) + + +-- Enemy Feign Death / Vanish protection. +local pvpDisappearFrame = CreateFrame("Frame") +pvpDisappearFrame:RegisterEvent("UNIT_CASTEVENT") +pvpDisappearFrame:SetScript("OnEvent", function() + local casterGUID = arg1 + local eventType = arg3 + local spellID = arg4 + + if not casterGUID or not spellID or not SpellInfo then return end + if eventType ~= "CAST" and eventType ~= "START" then return end + + local spellName = SpellInfo(spellID) + + -- Abilities that can temporarily drop target/nameplate visibility without + -- actually cleansing existing harmful effects. + local TEMPORARY_DISAPPEAR = { + ["Feign Death"] = true, + ["Vanish"] = true, + ["Shadowmeld"] = true, + } + + if spellName and TEMPORARY_DISAPPEAR[spellName] then + ProtectAuraCache(casterGUID, spellName) + end +end) + + + +-- Shared/global aura exceptions --------------------------------------------- +-- Most BNP debuffs are strictly "own casts only". A very small number of +-- effects are globally unique on a target and are useful even when applied by +-- another player. These are explicitly opted in via def.shared = true. +local LIVE_DEBUFF_SNAPSHOT = { entries = {}, count = 0, time = 0 } + +-- Capture UnitDebuff once and reuse that exact snapshot for shared-aura sync +-- and removal sync in the same 0.10s scan. This avoids scanning the same +-- SuperWoW unit twice (or more) per plate while keeping behavior identical. +local function CaptureLiveDebuffSnapshot(unit, now) + local snap = LIVE_DEBUFF_SNAPSHOT + local entries = snap.entries + local count = 0 + local i + + snap.time = now or GetTime() + + for i = 1, 64 do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = UnitDebuff(unit, i) + if not a1 then break end + + count = count + 1 + local entry = entries[count] + if not entry then + entry = {} + entries[count] = entry + end + + entry[1] = a1 + entry[2] = a2 + entry[3] = a3 + entry[4] = a4 + entry[5] = a5 + entry[6] = a6 + entry[7] = a7 + entry[8] = a8 + entry[9] = a9 + entry[10] = a10 + end + + snap.count = count + return snap +end + +local function ClassicAPIAuraDataAvailable() + return type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" +end + +-- ClassicAPI exposes the real hostile-aura duration and absolute expiration. +-- Use it only for the isolated shared Shadow Vulnerability path; all normal own +-- debuffs keep their existing proven tracking pipeline unchanged. +local function FindClassicAPISharedAura(unit, def) + if not ClassicAPIAuraDataAvailable() then return nil, nil, nil, nil, nil, false end + + local i + for i = 1, 64 do + local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, unit, i) + if not ok then return nil, nil, nil, nil, nil, false end + if not aura then break end + + local spellID = tonumber(aura.spellId or aura.spellID) + local matches = false + if def.sharedSpellIDs and spellID then + local _, knownID + for _, knownID in ipairs(def.sharedSpellIDs) do + if spellID == knownID then + matches = true + break + end + end + end + if not matches and aura.name then matches = NameMatches(def, aura.name) end + + if matches then + return spellID, aura.icon, tonumber(aura.duration), tonumber(aura.expirationTime), + tonumber(aura.applications) or 0, true + end + end + + -- A successful empty ClassicAPI scan is authoritative for absence. The + -- existing short missing grace protects against transient token updates. + return nil, nil, nil, nil, nil, true +end + +local function FindSharedAuraOnUnit(unit, def, snapshot) + if not unit or not def then return nil, nil, nil, nil, nil, false end + + if def.key == "shadow_vulnerability" then + local spellID, texture, duration, expires, stacks, authoritative = + FindClassicAPISharedAura(unit, def) + if authoritative then return spellID, texture, duration, expires, stacks, true end + end + + local maxIndex = snapshot and snapshot.count or 64 + local now = snapshot and snapshot.time or GetTime() + local i + for i = 1, maxIndex do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 + + if snapshot then + local entry = snapshot.entries[i] + if not entry then break end + a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = + entry[1], entry[2], entry[3], entry[4], entry[5], + entry[6], entry[7], entry[8], entry[9], entry[10] + else + a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = UnitDebuff(unit, i) + if not a1 then break end + end + + local texture = a1 + local auraSpellID = a4 + if not texture then break end + + local auraName = nil + if auraSpellID and SpellInfo then + auraName = SpellInfo(auraSpellID) + end + + local sharedMatches = nil + if def.sharedSpellIDs and auraSpellID then + sharedMatches = false + local _, sharedSpellID + for _, sharedSpellID in ipairs(def.sharedSpellIDs) do + if auraSpellID == sharedSpellID then + sharedMatches = true + break + end + end + end + + -- Some different class talents use the same localized aura name. Shared + -- effects may therefore opt into exact aura IDs instead of name matching. + if sharedMatches == true or (sharedMatches == nil and AuraMatches(def, auraName, texture)) then + -- SuperWoW/extended clients may append duration/expiration data. + -- Probe plausible numeric return pairs without assuming one exact fork. + local duration, expires + local vals = { a5, a6, a7, a8, a9, a10 } + local n + for n = 1, table.getn(vals) - 1 do + local d = vals[n] + local e = vals[n + 1] + if type(d) == "number" and type(e) == "number" + and d > 0 and d <= 3600 and e > now then + duration = d + expires = e + break + end + end + + return auraSpellID, texture, duration, expires, tonumber(a2) or 0, false + end + end + + return nil, nil, nil, nil, nil, false +end + +local SV_SHARED_MISSING_GRACE = 0.45 + +local function SyncShadowVulnerabilityPresence(unit, guid, now, def, auraSpellID, texture, liveStacks, liveDuration, liveExpires, classicAPIAuthoritative) + local cache = BNP.guidAuras[guid] + local state = BNP.svStateByGUID[guid] + + -- ClassicAPI can keep an expired AuraData row around briefly before the + -- next UNIT_AURA cleanup. A positive expirationTime in the past is proof + -- that this row is stale; never turn it into a fresh estimated 10 seconds. + -- A newer RAW_COMBATLOG application may temporarily outrun the API update, + -- so preserve that independently proven future expiration when present. + local previousExpires = state and tonumber(state.expires) or nil + local explicitExpired = liveExpires and liveExpires > 0 and liveExpires <= now + local provenFuture = state and state.provenExpires and state.provenExpires > now + if explicitExpired and not provenFuture then + BNP.svStateByGUID[guid] = nil + BNP.svExpiryByGUID[guid] = nil + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if cache then cache[def.key] = nil end + return + end + + -- Some ClassicAPI builds replace expirationTime with 0/nil while retaining + -- the expired AuraData row for another update or two. Once ClassicAPI has + -- answered authoritatively, never manufacture a timer from that incomplete + -- row. Keep only an already-known future expiry or a proven raw reapply. + local classicRowWithoutFutureExpiry = classicAPIAuthoritative and + (auraSpellID or texture) and not (liveExpires and liveExpires > now) + local previousFuture = previousExpires and previousExpires > now + if classicRowWithoutFutureExpiry and not provenFuture and not previousFuture then + BNP.svStateByGUID[guid] = nil + BNP.svExpiryByGUID[guid] = nil + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if cache then cache[def.key] = nil end + return + end + + if auraSpellID or texture then + if not state then + state = { + present = true, + lastSeen = now, + } + BNP.svStateByGUID[guid] = state + end + + state.present = true + state.lastSeen = now + state.duration = (liveDuration and liveDuration > 0 and liveDuration) or state.duration or def.duration or 10 + + if state.provenExpires and state.provenExpires <= now then state.provenExpires = nil end + local apiExpires = liveExpires and liveExpires > now and liveExpires or nil + local chosenExpires = apiExpires + if state.provenExpires and (not chosenExpires or state.provenExpires > chosenExpires) then + chosenExpires = state.provenExpires + end + if not chosenExpires and previousExpires and previousExpires > now then chosenExpires = previousExpires end + if not chosenExpires then + chosenExpires = now + state.duration + state.estimated = true + else + state.estimated = nil + end + + state.expires = chosenExpires + state.apiExpires = apiExpires + if previousExpires and chosenExpires > previousExpires + 0.05 then + state.lastRefresh = now + BNP.svRenderRevisionByGUID[guid] = (BNP.svRenderRevisionByGUID[guid] or 0) + 1 + state.renderRevision = BNP.svRenderRevisionByGUID[guid] + RaidTrace("SV_CLASSICAPI_REFRESH", guid, def.key, auraSpellID or 17794) + end + + state.spellID = auraSpellID or state.spellID or 17794 + state.texture = texture or state.texture or def.texture + state.stacks = tonumber(liveStacks) or state.stacks or 0 + BNP.svExpiryByGUID[guid] = state.expires + + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + local aura = cache[def.key] + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + cache[def.key] = aura + end + + aura.sharedLive = true + aura.spellID = state.spellID + aura.texture = state.texture + aura.stacks = state.stacks + aura.duration = state.duration + aura.expires = state.expires + aura.confirmedAt = aura.confirmedAt or now + aura.sharedLastSeen = now + aura.sharedEstimated = state.estimated and true or nil + return + end + + -- One empty SuperWoW/UnitDebuff snapshot is not authoritative. Only remove + -- the shared state after no token has positively seen it for a short grace. + if state and now - (state.lastSeen or 0) >= SV_SHARED_MISSING_GRACE then + BNP.svStateByGUID[guid] = nil + BNP.svExpiryByGUID[guid] = nil + if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end + if cache then cache[def.key] = nil end + end +end + +local function SyncSharedAuras(unit, guid, now, snapshot) + if not unit or not guid then return end + + local _, def + for _, def in ipairs(AURA_DEFS) do + if def.shared then + local auraSpellID, texture, liveDuration, liveExpires, liveStacks, classicAPIAuthoritative = + FindSharedAuraOnUnit(unit, def, snapshot) + + -- Shadow Vulnerability is deliberately isolated from the generic shared + -- aura timer path. Its GUID state is the only owner of its countdown. + if def.key == "shadow_vulnerability" then + SyncShadowVulnerabilityPresence( + unit, guid, now, def, auraSpellID, texture, liveStacks, liveDuration, + liveExpires, classicAPIAuthoritative + ) + else + local cache = BNP.guidAuras[guid] + + if auraSpellID or texture then + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + + local aura = cache[def.key] + -- Never overwrite an accurately timed own aura with the shared/live + -- fallback. Shared entries exist only when we don't own the timer. + if not aura or aura.sharedLive then + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + end + + aura.sharedLive = true + aura.spellID = auraSpellID + aura.texture = texture or def.texture + aura.confirmedAt = now + aura.sharedLastSeen = now + + local previousStacks = tonumber(aura.stacks) or 0 + local currentStacks = tonumber(liveStacks) or 0 + if currentStacks > previousStacks and aura.expires then + aura.duration = def.duration or aura.duration or 10 + aura.expires = now + aura.duration + aura.sharedEstimated = true + RaidTrace("SHARED_STACK_REFRESH", guid, def.key, auraSpellID) + end + aura.stacks = currentStacks + + if liveExpires and liveExpires > now then + aura.duration = liveDuration + aura.expires = liveExpires + aura.sharedEstimated = nil + elseif not aura.expires then + aura.duration = def.duration or 120 + aura.expires = now + aura.duration + aura.sharedEstimated = true + end + + cache[def.key] = aura + end + elseif cache and cache[def.key] and cache[def.key].sharedLive then + local aura = cache[def.key] + local lastSeen = aura.sharedLastSeen or aura.confirmedAt or 0 + if now - lastSeen >= 0.40 then cache[def.key] = nil end + end + end + end + end +end + + +-- Live aura removal --------------------------------------------------------- +-- Timers alone are not enough for CC/roots/traps: effects may be dispelled, +-- broken by damage, removed by trinkets, or otherwise end early. +-- +-- We NEVER create auras from this scan. It may only remove already-confirmed +-- own auras from the GUID cache when their exact spell ID is no longer present. +local REMOVAL_SCAN_INTERVAL = 0.10 +local REMOVAL_GRACE = 0.35 +local NORMAL_REMOVAL_MISSING_SCANS = 4 +-- Projected non-target nameplate tokens can briefly return an incomplete +-- UnitDebuff list when many plates overlap in raids. CC used to be deleted on +-- the first such miss, which made Banish/roots/stuns blink. Require a few +-- consecutive misses for non-target CC while keeping the real target strict. +local EXACT_NON_TARGET_MISSING_SCANS = 3 + +local function CacheHasTrackedAuras(cache) + if not cache then return false end + local key, aura + for key, aura in pairs(cache) do + if type(aura) == "table" and aura.spellID and aura.expires then + return true + end + end + return false +end + +local CHANNEL_LIVE_REMOVAL = { + drain_life=true, drain_soul=true, + mind_flay=true, starshards=true, mind_control=true, +} + +local EXACT_LIVE_REMOVAL = { + -- Warlock + fear=true, howl_of_terror=true, banish=true, death_coil=true, + -- Hunter + freezing_trap_effect=true, improved_wing_clip=true, improved_concussive_shot=true, + scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true, + intimidation=true, scare_beast=true, + -- Mage + polymorph=true, frost_nova=true, counterspell_silence=true, impact=true, frostbite=true, + -- Priest + psychic_scream=true, silence=true, blackout=true, shackle_undead=true, mind_control=true, + -- Paladin + hammer_of_justice=true, repentance=true, turn_undead=true, + -- Rogue + gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, kick_silenced=true, + -- Druid + entangling_roots=true, bash=true, pounce=true, hibernate=true, feral_charge_effect=true, + -- Warrior + improved_hamstring=true, intimidating_shout=true, disarm=true, charge_stun=true, + intercept_stun=true, concussion_blow=true, revenge_stun=true, pummel=true, shield_bash=true, + -- Shaman / custom roots + earthgrab=true, +} + +local function NeedsExactLiveRemoval(key) + return EXACT_LIVE_REMOVAL[key] and true or false +end + +local function IsCrowdControlDef(def) + return def and EXACT_LIVE_REMOVAL[def.key] and true or false +end + +local function GetEntryIconSize(entry, forceCC) + if forceCC or (entry and IsCrowdControlDef(entry.def)) then + return GetCCIconSize() + end + return GetIconSize() +end + +local function ApplyIconDimensions(icon, size) + if not icon or not size then return end + if icon.BNPLayoutSize ~= size then + icon:SetWidth(size) + icon:SetHeight(size) + icon.BNPLayoutSize = size + end +end + +local function LayoutActiveIcons(container, active, visibleCount, forceCC) + if not container then return end + local position = forceCC and GetCCPosition() or GetDebuffPosition() + local sizes = {} + local totalWidth = 0 + local maxSize = 0 + local i + + for i = 1, visibleCount do + local size = GetEntryIconSize(active[i], forceCC) + sizes[i] = size + totalWidth = totalWidth + size + if size > maxSize then maxSize = size end + end + if visibleCount > 1 then totalWidth = totalWidth + ICON_SPACING * (visibleCount - 1) end + + local maxConfigured = math.max(GetIconSize(), GetCCIconSize()) + local maxWidth = maxConfigured * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + container:SetWidth(maxWidth) + container:SetHeight(maxSize > 0 and maxSize or maxConfigured) + + local cursor + if position == "left" or position == "bottom_right" or position == "top_right" then + cursor = 0 + for i = 1, visibleCount do + local icon = container.icons[i] + local size = sizes[i] + ApplyIconDimensions(icon, size) + icon:ClearAllPoints() + icon:SetPoint("RIGHT", container, "RIGHT", -cursor, 0) + cursor = cursor + size + ICON_SPACING + end + elseif position == "right" or position == "bottom_left" or position == "top_left" then + cursor = 0 + for i = 1, visibleCount do + local icon = container.icons[i] + local size = sizes[i] + ApplyIconDimensions(icon, size) + icon:ClearAllPoints() + icon:SetPoint("LEFT", container, "LEFT", cursor, 0) + cursor = cursor + size + ICON_SPACING + end + else + cursor = (maxWidth - totalWidth) / 2 + for i = 1, visibleCount do + local icon = container.icons[i] + local size = sizes[i] + ApplyIconDimensions(icon, size) + icon:ClearAllPoints() + icon:SetPoint("LEFT", container, "LEFT", cursor, 0) + cursor = cursor + size + ICON_SPACING + end + end +end + +local function ShouldDisplayAuraDef(def) + if IsCrowdControlDef(def) then + if BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled() then return false end + if UseDedicatedCCContainer() then return false end + return true + end + return not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() +end + + +-- Separate CC display ------------------------------------------------------- +-- This layer is intentionally independent from guidAuras. It may display live +-- CCs from other players, but it can never create, refresh, remove or alter an +-- own DoT/debuff timer. +local GLOBAL_CC_DEFS = {} +local GLOBAL_CC_BY_ID = {} +local GLOBAL_CC_NEGATIVE = {} +local seenGlobalCC = {} + +local function BuildGlobalCCDefs() + local _, defs, _, ccDef, _, sid + for _, defs in pairs(CLASS_AURAS) do + for _, ccDef in ipairs(defs or {}) do + if IsCrowdControlDef(ccDef) and not seenGlobalCC[ccDef.key] then + seenGlobalCC[ccDef.key] = true + table.insert(GLOBAL_CC_DEFS, ccDef) + if ccDef.spellIDs then + for _, sid in ipairs(ccDef.spellIDs) do GLOBAL_CC_BY_ID[sid] = ccDef end + end + if ccDef.durations then + for sid in pairs(ccDef.durations) do GLOBAL_CC_BY_ID[sid] = ccDef end + end + end + end + end +end +BuildGlobalCCDefs() + +local function ResolveGlobalCCDef(spellID, texture) + if spellID and GLOBAL_CC_BY_ID[spellID] then return GLOBAL_CC_BY_ID[spellID] end + if spellID and GLOBAL_CC_NEGATIVE[spellID] then return nil end + + local auraName = spellID and SpellInfo and SpellInfo(spellID) or nil + local _, ccDef + for _, ccDef in ipairs(GLOBAL_CC_DEFS) do + if NameMatches(ccDef, auraName) + or (texture and ccDef.textureMatch and string.find(string.lower(texture), ccDef.textureMatch)) then + if spellID then GLOBAL_CC_BY_ID[spellID] = ccDef end + return ccDef + end + end + + if spellID then GLOBAL_CC_NEGATIVE[spellID] = true end + return nil +end + +local function WantsForeignCCs() + return BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() + and BNP.ShowOtherPlayersCCs and BNP:ShowOtherPlayersCCs() +end + +local FOREIGN_CC_SEEN = {} +local foreignCCSeenGeneration = 0 + +local function SyncForeignCCs(unit, guid, now) + if not unit or not guid then return end + if not WantsForeignCCs() then + BNP.guidLiveCCs[guid] = nil + return + end + + foreignCCSeenGeneration = foreignCCSeenGeneration + 1 + local seenGeneration = foreignCCSeenGeneration + local live = BNP.guidLiveCCs[guid] + local i + for i = 1, 64 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + local ccDef = ResolveGlobalCCDef(auraSpellID, texture) + if ccDef then + FOREIGN_CC_SEEN[ccDef.key] = seenGeneration + if not live then + live = {} + BNP.guidLiveCCs[guid] = live + end + + local aura = live[ccDef.key] + if not aura then + aura = { firstSeen = now, order = now } + live[ccDef.key] = aura + end + aura.def = ccDef + aura.spellID = auraSpellID + aura.texture = texture or ccDef.texture + aura.stacks = tonumber(stacks) or 0 + aura.lastSeen = now + aura.missingScans = nil + -- Vanilla/SuperWoW does not reliably provide foreign application time. + -- Use a conservative local estimate for text only; live aura presence is + -- authoritative for appearance/removal. + if not aura.expires and ccDef.duration then + aura.expires = now + ccDef.duration + end + end + end + + if live then + local key, aura + for key, aura in pairs(live) do + if FOREIGN_CC_SEEN[key] ~= seenGeneration then + aura.missingScans = (aura.missingScans or 0) + 1 + -- A single empty projected UnitDebuff scan is common with tightly + -- stacked raid nameplates. Keep the last positive CC sighting through + -- two misses so foreign Banish/CC does not visibly blink. + if aura.missingScans >= 3 and now - (aura.lastSeen or 0) >= 0.40 then + live[key] = nil + end + else + aura.missingScans = nil + end + end + end +end + +local function HideCCRow(plate) + local container = plate and plate.BNPCCContainer + if not container then return end + local i + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + icon.lastTimerText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + icon:Hide() + end + container:Hide() +end + +local function FormatCCTimer(remaining) + if not remaining or remaining <= 0 then return "" end + if remaining >= 3600 then + return math.ceil(remaining / 3600) .. "h" + elseif remaining >= 60 then + return math.ceil(remaining / 60) .. "m" + end + return tostring(math.ceil(remaining)) +end + +local function UpdateCCRow(plate, guid, cache, now) + if not UseDedicatedCCContainer() + or (BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled()) then + HideCCRow(plate) + return + end + + if not plate.BNPCCContainer then CreateCCContainer(plate) end + local container = plate.BNPCCContainer + if not container then return end + + local active = container.activeAuras + if not active then active = {}; container.activeAuras = active end + local pool = container.activeAuraPool + if not pool then + pool = {} + for i = 1, MAX_VISIBLE_ICONS do pool[i] = {} end + container.activeAuraPool = pool + end + local oldCount = table.getn(active) + for i = 1, oldCount do active[i] = nil end + local count = 0 + local seenOwn = {} + + local _, ccDef + for _, ccDef in ipairs(AURA_DEFS) do + if IsCrowdControlDef(ccDef) then + local aura = cache and cache[ccDef.key] + local remaining = aura and aura.expires and (aura.expires - now) or nil + local sharedLive = aura and aura.sharedLive + if aura and (sharedLive or (remaining and remaining > 0)) then + count = count + 1 + if count <= MAX_VISIBLE_ICONS then + local entry = pool[count] + active[count] = entry + entry.def = ccDef + entry.aura = aura + entry.remaining = remaining + seenOwn[ccDef.key] = true + end + end + end + end + + if WantsForeignCCs() then + local live = guid and BNP.guidLiveCCs[guid] + local key, aura + for key, aura in pairs(live or {}) do + if count >= MAX_VISIBLE_ICONS then break end + if aura.def and not seenOwn[key] and now - (aura.lastSeen or 0) <= 0.60 then + count = count + 1 + local entry = pool[count] + active[count] = entry + entry.def = aura.def + entry.aura = aura + entry.remaining = aura.expires and (aura.expires - now) or nil + end + end + end + + table.sort(active, function(a, b) + return (a.aura.order or a.aura.firstSeen or 0) < (b.aura.order or b.aura.firstSeen or 0) + end) + + local visibleCount = table.getn(active) + local iconSize = GetCCIconSize() + local position = GetCCPosition() + local layoutChanged = container.BNPLastVisibleCount ~= visibleCount + or container.BNPLastCCIconSize ~= iconSize + or container.BNPLastPosition ~= position + if layoutChanged then + container.BNPLastVisibleCount = visibleCount + container.BNPLastCCIconSize = iconSize + container.BNPLastPosition = position + if visibleCount > 0 then LayoutActiveIcons(container, active, visibleCount, true) end + end + + local i + for i = 1, table.getn(container.icons) do + local icon = container.icons[i] + local entry = active[i] + if entry then + icon.texture:SetTexture(entry.aura.texture or entry.def.texture) + local stackCount = tonumber(entry.aura.stacks) or 0 + if icon.stack then + if stackCount > 1 then icon.stack:SetText(tostring(stackCount)) else icon.stack:SetText("") end + end + local timerText = FormatCCTimer(entry.remaining) + if icon.lastTimerText ~= timerText then icon.timer:SetText(timerText); icon.lastTimerText = timerText end + icon:Show() + else + icon.lastTimerText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + icon:Hide() + end + end + + if visibleCount > 0 then container:Show() else container:Hide() end +end + +local REMOVAL_PRESENT = {} +local removalPresentGeneration = 0 + +local function SyncAuraRemoval(unit, guid, now, snapshot, currentTargetGUID) + if not unit or not guid then return end + if AuraCacheProtected(guid, now) then return end + local cache = BNP.guidAuras[guid] + if not CacheHasTrackedAuras(cache) then return end + + removalPresentGeneration = removalPresentGeneration + 1 + local presentGeneration = removalPresentGeneration + local i + local maxIndex = snapshot and snapshot.count or 64 + for i = 1, maxIndex do + local texture, stacks, dtype, auraSpellID + if snapshot then + local entry = snapshot.entries[i] + if not entry then break end + texture, stacks, dtype, auraSpellID = entry[1], entry[2], entry[3], entry[4] + else + texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + end + + if auraSpellID then + REMOVAL_PRESENT[auraSpellID] = presentGeneration + + local trackedKey, trackedAura + for trackedKey, trackedAura in pairs(cache) do + if type(trackedAura) == "table" and trackedAura.spellID == auraSpellID then + trackedAura.stacks = tonumber(stacks) or 0 + end + end + end + end + + -- Resolve current-target identity once per scan, not once per tracked aura. + -- Knowing the target GUID for projected units also lets exact CC removal stay + -- immediate on the real target but conservative on crowded non-target plates. + currentTargetGUID = currentTargetGUID or GetTargetGUID() + local playerRemoval = false + if unit == "target" and UnitIsPlayer and UnitIsPlayer("target") then + playerRemoval = currentTargetGUID and currentTargetGUID == guid + end + + local key, aura + for key, aura in pairs(cache) do + if type(aura) == "table" and aura.spellID and aura.expires and key ~= "shadow_vulnerability" then + -- Give the client a tiny grace period immediately after confirmation so + -- transient aura-list updates cannot remove a freshly-landed effect. + local age = now - (aura.confirmedAt or 0) + local exactRemoval = NeedsExactLiveRemoval(key) + + -- Normal DoTs on player nameplates are only safe to live-remove from the + -- CURRENT target. During target switches, non-target SuperWoW nameplate + -- tokens can briefly expose an incomplete/empty UnitDebuff list. Treating + -- that as a dispel caused valid DoTs to disappear from the GUID cache. + -- + -- CC/root/trap effects still use live removal on every visible plate. The + -- real target is immediate; projected non-targets require a few misses so + -- one crowded-raid scan cannot make Banish/CC blink. + -- Channeled debuffs should end when the channel is interrupted/cancelled, + -- but only trust the real current-target UnitDebuff list for this. Busy + -- non-target nameplate tokens remain intentionally non-authoritative. + local channelRemoval = CHANNEL_LIVE_REMOVAL[key] + and currentTargetGUID and currentTargetGUID == guid + + if age >= REMOVAL_GRACE then + if REMOVAL_PRESENT[aura.spellID] == presentGeneration then + -- Positive live sighting upgrades a CAST-seeded timer. Once upgraded, + -- normal dispel/early-removal logic is safe again. + aura.missingScans = nil + aura.exactMissingScans = nil + if aura.castPrimary then + aura.castPrimary = nil + aura.castPrimaryAt = nil + aura.liveConfirmed = true + end + elseif channelRemoval then + RaidTrace("CHANNEL_REMOVE", guid, key, aura.spellID) + cache[key] = nil + elseif exactRemoval then + if currentTargetGUID and currentTargetGUID == guid then + RaidTrace("REMOVE", guid, key, aura.spellID) + -- The real target unit is authoritative, so CC can still disappear + -- immediately when it is broken/dispelled. + cache[key] = nil + else + -- Non-target SuperWoW tokens can return one empty/incomplete aura + -- list while plates overlap. Do not turn that one bad sample into + -- a visible Banish/CC blink. + aura.exactMissingScans = (aura.exactMissingScans or 0) + 1 + if aura.exactMissingScans >= EXACT_NON_TARGET_MISSING_SCANS then + RaidTrace("REMOVE", guid, key, aura.spellID) + cache[key] = nil + end + end + elseif playerRemoval then + if aura.castPrimary then + -- CAST-seeded auras may be omitted by crowded raid UnitDebuff lists. + -- Do not use that same missing list to remove it early. + aura.missingScans = nil + else + -- Normal positively-confirmed player DoTs/curses use conservative + -- removal and still react to real dispels. + aura.missingScans = (aura.missingScans or 0) + 1 + + if aura.missingScans >= NORMAL_REMOVAL_MISSING_SCANS then + RaidTrace("REMOVE", guid, key, aura.spellID) + cache[key] = nil + end + end + end + end + end + end +end + +local function FormatTimer(remaining) + if not remaining or remaining <= 0 then return "" end + + -- Keep long durations compact on the small nameplate icons. + -- Examples: 300s -> 5m, 61s -> 2m, 59s -> 59. + if remaining >= 3600 then + return math.ceil(remaining / 3600) .. "h" + elseif remaining >= 60 then + return math.ceil(remaining / 60) .. "m" + end + + return tostring(math.ceil(remaining)) +end + +-- Nameplate GUID stability --------------------------------------------------- +-- Blizzard/SuperWoW can briefly recycle a visible plate while LOS/occlusion +-- changes (for example when a wall is between the player and nearby mobs). +-- During that tiny transition plate:GetName(1) may still expose the previous +-- unit GUID. Rendering that GUID's cache causes "ghost DoTs" on an unrelated +-- mob for a frame or two. +-- +-- Require the same GUID on two consecutive renderer updates before using its +-- aura cache. This does not change aura tracking itself; it only gates display. +local AURA_GUID_TRANSIENT_HOLD = 0.75 +local AURA_GUID_CONFIRM_UPDATES = 2 +local AURA_GUID_SWITCH_CONFIRM_UPDATES = 8 + +local function PinExactTargetPlate(plate, now) + if not plate then return nil end + + -- RefreshTargetPriorityIdentity() already resolves the exact ClassicAPI target + -- plate once at the start of every renderer tick. Reuse that result here. The + -- old code repeated the same protected GetNamePlateForUnit("target") call for + -- every visible plate at 20 Hz, which was pure duplicate work in large raids. + local targetGUID = targetPriorityGUID + local targetPlate = targetPriorityPlate + if not targetGUID or not targetPlate or targetPlate ~= plate then return nil end + + -- The real target is the one projected unit ClassicAPI can resolve exactly. + -- Pinning it here prevents a crowded SuperWoW plate token from replacing the + -- target GUID for a few renderer ticks. When the player switches away, this + -- stable mapping remains useful for that now-non-target plate. + plate.BNPAuraGUIDStable = targetGUID + plate.BNPAuraGUIDCandidate = targetGUID + plate.BNPAuraGUIDCandidateCount = AURA_GUID_SWITCH_CONFIRM_UPDATES + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return targetGUID +end + +local function StableNameStillMatches(plate, stableGUID) + if not plate or not stableGUID or not plate.name or not plate.name.GetText then + return true + end + local oldName = BNP.guidNames and BNP.guidNames[stableGUID] + local visibleName = plate.name:GetText() + if oldName and visibleName and oldName ~= "" and visibleName ~= "" then + return oldName == visibleName + end + return true +end + +local function GetStablePlateGUID(plate, now) + if not plate then return nil end + now = now or GetTime() + + -- Exact current-target frame identity always wins over projected token data. + local exactTargetGUID = PinExactTargetPlate(plate, now) + if exactTargetGUID then return exactTargetGUID end + + local guid = GetPlateGUID(plate) + local stable = plate.BNPAuraGUIDStable + + -- Dense 40-player stacks can briefly lose the projected token. Keep the last + -- confirmed identity long enough to bridge those gaps instead of hiding the + -- whole icon row for one or two renderer ticks. + if not guid then + plate.BNPAuraGUIDCandidate = nil + plate.BNPAuraGUIDCandidateCount = 0 + if stable and plate.BNPAuraGUIDLastGoodAt and + now - plate.BNPAuraGUIDLastGoodAt <= AURA_GUID_TRANSIENT_HOLD and + not plate.BNPAuraForceFreshIdentity then + return stable + end + return nil + end + + if stable and guid == stable then + plate.BNPAuraGUIDCandidate = guid + plate.BNPAuraGUIDCandidateCount = AURA_GUID_SWITCH_CONFIRM_UPDATES + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return stable + end + + if plate.BNPAuraGUIDCandidate ~= guid then + plate.BNPAuraGUIDCandidate = guid + plate.BNPAuraGUIDCandidateCount = 1 + + -- If Blizzard really reused the frame for a differently named unit, switch + -- quickly. Same-name raid mobs are exactly where transient GUID/token swaps + -- happen, so keep the previous stable GUID until the new candidate persists. + if stable and not plate.BNPAuraForceFreshIdentity and + StableNameStillMatches(plate, stable) then + return stable + end + return nil + end + + plate.BNPAuraGUIDCandidateCount = (plate.BNPAuraGUIDCandidateCount or 1) + 1 + + local required = AURA_GUID_SWITCH_CONFIRM_UPDATES + if plate.BNPAuraForceFreshIdentity or + (stable and not StableNameStillMatches(plate, stable)) then + required = AURA_GUID_CONFIRM_UPDATES + end + + if plate.BNPAuraGUIDCandidateCount < required then + if stable and not plate.BNPAuraForceFreshIdentity and + StableNameStillMatches(plate, stable) then + return stable + end + return nil + end + + plate.BNPAuraGUIDStable = guid + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return guid +end + +-- Shared identity helper for sibling nameplate modules (for example PvP +-- immunities). Reusing the same stabilized GUID prevents those modules from +-- reintroducing the dense-raid/nameplate-token flicker fixed above. +BNP.GetStablePlateAuraGUID = GetStablePlateGUID + +local function UpdatePlate(plate, now) + if BNP.AreAnyAurasEnabled and not BNP:AreAnyAurasEnabled() then + if plate and plate.BNPAuraContainer then + local i + for i = 1, MAX_VISIBLE_ICONS do + local icon = plate.BNPAuraContainer.icons and plate.BNPAuraContainer.icons[i] + if icon then icon:Hide() end + end + plate.BNPAuraContainer:Hide() + end + HideCCRow(plate) + return + end + + if not plate or not plate:IsShown() then + HidePlateAuraFrames(plate) + return + end + if not plate.BNPAuraContainer then CreateAuraContainer(plate) end + + local container = plate.BNPAuraContainer + if not container then return end + + -- Nameplate frames are recycled. Re-anchor only when the hidden-name state + -- changes so Player/NPC hide options immediately compact Top aura rows. + local nameHidden = (BNP.IsPlateNameHidden and BNP:IsPlateNameHidden(plate)) or (plate.BNPNameHidden and true or false) + if plate.BNPAuraLastNameHidden ~= nameHidden then + plate.BNPAuraLastNameHidden = nameHidden + AnchorAuraContainer(plate, plate.BNPAuraContainer, false) + if plate.BNPCCContainer then AnchorAuraContainer(plate, plate.BNPCCContainer, true) end + end + + local guid = GetStablePlateGUID(plate, now) + if guid and plate.name and plate.name.GetText then + local visibleName = plate.name:GetText() + if visibleName and visibleName ~= "" then BNP.guidNames[guid] = visibleName end + end + now = now or GetTime() + + -- A full-immunity cleanse (Divine Shield / Ice Block) is authoritative. + -- Hide both harmful rows immediately and do not let timer/cast caches render + -- again until the short suppression window is no longer being renewed by + -- the immunity module. + if guid and HarmfulAuraSuppressed(guid, now) then + ClearHarmfulAuraStateOnly(guid) + HidePlateAuraFrames(plate) + return + end + + local cache = guid and BNP.guidAuras[guid] or nil + + -- Reuse one small table per plate. Active auras are packed without gaps and + -- sorted by the order in which they were first applied to this GUID. + local active = container.activeAuras + if not active then + active = {} + container.activeAuras = active + end + local activePool = container.activeAuraPool + if not activePool then + activePool = {} + for i = 1, MAX_VISIBLE_ICONS do activePool[i] = {} end + container.activeAuraPool = activePool + end + local previousCount = table.getn(active) + for i = 1, previousCount do active[i] = nil end + local activeCount = 0 + + for _, def in ipairs(AURA_DEFS) do + if ShouldDisplayAuraDef(def) then + local aura = cache and cache[def.key] + local remaining = aura and aura.expires and (aura.expires - now) or nil + local sharedLive = aura and aura.sharedLive + local svRenderRevision = nil + + if def.key == "shadow_vulnerability" and guid then + local svState = BNP.svStateByGUID[guid] + if svState and svState.present then + -- Shadow Vulnerability rendering is owned entirely by svState. + -- Do not require the mirrored generic cache entry to already exist: + -- rebuild it from state if another cleanup path removed it. + if not cache then + cache = {} + BNP.guidAuras[guid] = cache + end + aura = cache[def.key] + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + cache[def.key] = aura + end + + aura.sharedLive = true + aura.sharedEstimated = true + aura.spellID = svState.spellID or aura.spellID or 17794 + aura.texture = svState.texture or aura.texture or def.texture + aura.stacks = tonumber(svState.stacks) or aura.stacks or 0 + aura.duration = def.duration or 10 + aura.expires = svState.expires + + remaining = svState.expires and (svState.expires - now) or nil + sharedLive = true + svRenderRevision = BNP.svRenderRevisionByGUID[guid] or 0 + else + remaining = nil + sharedLive = false + aura = nil + end + end + + if sharedLive or (remaining and remaining > 0) then + if sharedLive and remaining and remaining <= 0 then + remaining = nil + end + if activeCount < MAX_VISIBLE_ICONS then + activeCount = activeCount + 1 + local entry = activePool[activeCount] + active[activeCount] = entry + entry.def = def + entry.aura = aura + entry.remaining = remaining + entry.svRenderRevision = svRenderRevision + end + elseif cache and aura then + cache[def.key] = nil + end + end + end + + if activeCount > 1 then + table.sort(active, function(a, b) + return (a.aura.order or 0) < (b.aura.order or 0) + end) + end + + local visibleCount = activeCount + local iconSize = GetIconSize() + local ccIconSize = GetCCIconSize() + local position = GetDebuffPosition() + local layoutMask = 0 + local maskValue = 1 + for i = 1, visibleCount do + if active[i] and IsCrowdControlDef(active[i].def) then + layoutMask = layoutMask + maskValue + end + maskValue = maskValue * 2 + end + + local layoutChanged = container.BNPLastVisibleCount ~= visibleCount + or container.BNPLastIconSize ~= iconSize + or container.BNPLastCCIconSize ~= ccIconSize + or container.BNPLastPosition ~= position + or container.BNPLastLayoutMask ~= layoutMask + if layoutChanged then + container.BNPLastVisibleCount = visibleCount + container.BNPLastIconSize = iconSize + container.BNPLastCCIconSize = ccIconSize + container.BNPLastPosition = position + container.BNPLastLayoutMask = layoutMask + if visibleCount > 0 then + -- CCs may share the normal row for Left/Right (and optionally Top), so + -- lay out each icon using its own configured size without touching tracking. + LayoutActiveIcons(container, active, visibleCount, false) + end + end + + for i = 1, table.getn(container.icons) do + local icon = container.icons[i] + local entry = active[i] + + if entry then + local texture = entry.aura.texture or entry.def.texture + if icon.BNPLastTexture ~= texture then + icon.texture:SetTexture(texture) + icon.BNPLastTexture = texture + end + + local stackCount = tonumber(entry.aura.stacks) or 0 + local stackText = stackCount > 1 and tostring(stackCount) or "" + if icon.stack and icon.BNPLastStackText ~= stackText then + icon.stack:SetText(stackText) + icon.BNPLastStackText = stackText + end + + local timerText = FormatTimer(entry.remaining) + if entry.def and entry.def.key == "shadow_vulnerability" then + local revision = entry.svRenderRevision or 0 + if icon.BNPSVRenderRevision ~= revision then + -- A confirmed proc/refresh happened. Invalidate the text cache so the + -- refreshed countdown is written by the normal renderer, never from + -- inside a combat-log event callback. + icon.lastTimerText = nil + icon.BNPSVRenderRevision = revision + end + icon.BNPSVRenderedExpires = entry.aura and entry.aura.expires or nil + icon.BNPSVRenderedRemaining = entry.remaining + else + icon.BNPSVRenderRevision = nil + icon.BNPSVRenderedExpires = nil + icon.BNPSVRenderedRemaining = nil + end + + if icon.lastTimerText ~= timerText then + icon.timer:SetText(timerText) + icon.lastTimerText = timerText + end + + icon:Show() + else + icon.lastTimerText = nil + icon.BNPLastTexture = nil + icon.BNPLastStackText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + icon:Hide() + end + end + + if visibleCount > 0 then + container:Show() + else + container:Hide() + end + + UpdateCCRow(plate, guid, cache, now) + ApplyAuraFrameLevel(plate, container) + ApplyAuraFrameLevel(plate, plate.BNPCCContainer) + ApplyTargetAuraPriority(plate, container) + ApplyTargetAuraPriority(plate, plate.BNPCCContainer) +end + + +function BNP:RefreshAuraLayout(plate) + if not plate then return end + local auraSize = GetIconSize() + local ccSize = GetCCIconSize() + local maxSize = math.max(auraSize, ccSize) + local width = maxSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) + local containers = { plate.BNPAuraContainer, plate.BNPCCContainer } + local index, container + for index, container in ipairs(containers) do + if container then + ApplyAuraFrameLevel(plate, container) + local defaultSize = (index == 2) and ccSize or auraSize + container:SetWidth(width) + container:SetHeight(defaultSize) + container.BNPLastVisibleCount = nil + container.BNPLastIconSize = nil + container.BNPLastCCIconSize = nil + container.BNPLastPosition = nil + container.BNPLastLayoutMask = nil + local i + for i = 1, table.getn(container.icons or {}) do + ApplyIconDimensions(container.icons[i], defaultSize) + end + end + end +end + +function BNP:RefreshAllAuraLayouts() + local plate + for plate in pairs(BNP.plates or {}) do + if plate then + if plate.BNPAuraContainer then + AnchorAuraContainer(plate, plate.BNPAuraContainer, false) + end + if plate.BNPCCContainer then + AnchorAuraContainer(plate, plate.BNPCCContainer, true) + end + end + self:RefreshAuraLayout(plate) + end +end + +ScanSpellbook() +table.insert(BNP.libnameplate.OnInit, CreateAuraContainer) +table.insert(BNP.libnameplate.OnInit, CreateCCContainer) +table.insert(BNP.libnameplate.OnShow, CreateAuraContainer) +table.insert(BNP.libnameplate.OnShow, CreateCCContainer) + +-- Keep the proven central update loop and the existing icon implementation. +-- No CooldownFrame templates and no frames or tables are created here. +local renderer = CreateFrame("Frame") +local elapsedTotal = 0 +local removalElapsed = 0 +local removalScanBucket = 0 +local nextRemovalBucket = 0 + +local function GetRemovalScanBucket(plate) + if plate.BNPAuraRemovalScanBucket == nil then + plate.BNPAuraRemovalScanBucket = nextRemovalBucket + nextRemovalBucket = 1 - nextRemovalBucket + end + return plate.BNPAuraRemovalScanBucket +end +renderer:SetScript("OnUpdate", function() + elapsedTotal = elapsedTotal + arg1 + removalElapsed = removalElapsed + arg1 + if elapsedTotal < UPDATE_INTERVAL then return end + elapsedTotal = 0 + + local now = GetTime() + UpdateDarkHarvest(now) + RefreshTargetPriorityIdentity() + + CleanupAuraCastAttempts(now) + + local hasPending = HasPendingAuras() + local hasPendingAoE = HasPendingAoEAuras() + local doTargetRemovalScan = removalElapsed >= REMOVAL_SCAN_INTERVAL + if doTargetRemovalScan then removalElapsed = 0 end + + -- Non-target destructive scans used to hit every visible plate in one 0.10s + -- burst. Alternate two buckets on the existing 0.05s renderer tick instead: + -- every individual plate keeps the same ~0.10s cadence, but CPU/UnitDebuff + -- work is spread across two frames to reduce micro-stutter. + removalScanBucket = 1 - removalScanBucket + + local targetGUID = GetTargetGUID() + + local targetSuppressed = targetGUID and HarmfulAuraSuppressed(targetGUID, now) + if targetSuppressed then + ClearHarmfulAuraStateOnly(targetGUID) + end + + if hasPending and targetGUID and not targetSuppressed then + -- Current target is the most reliable confirmation source. + ConfirmPendingForUnit("target", targetGUID, now) + end + + if targetGUID and not targetSuppressed then + CleanupDeadUnit("target", targetGUID, now) + end + + if doTargetRemovalScan and targetGUID and not targetSuppressed then + local targetSnapshot = nil + if HAS_SHARED_AURAS or CacheHasTrackedAuras(BNP.guidAuras[targetGUID]) then + targetSnapshot = CaptureLiveDebuffSnapshot("target", now) + end + + SyncSharedAuras("target", targetGUID, now, targetSnapshot) + + if BNP.guidAuras[targetGUID] then + SyncAuraRemoval("target", targetGUID, now, targetSnapshot, targetGUID) + end + end + + -- The normal renderer always runs. Extra GUID/token/UnitDebuff work is only + -- performed while pending casts exist or during the throttled removal scan. + for plate in pairs(BNP.plates) do + local plateRemovalDue = plate:IsShown() and GetRemovalScanBucket(plate) == removalScanBucket + if plate:IsShown() and (hasPending or hasPendingAoE or plateRemovalDue) then + local guid = GetPlateGUID(plate) + local token = GetUnitTokenForPlate(plate) + + if guid and token then + local guidSuppressed = HarmfulAuraSuppressed(guid, now) + if guidSuppressed then + ClearHarmfulAuraStateOnly(guid) + end + + -- Do NOT call UnitIsDeadOrGhost() on SuperWoW nameplate tokens. + -- During target changes/recycling those tokens can briefly report an + -- invalid dead state and wipe the entire GUID aura cache. + local isCurrentTarget = targetGUID and targetGUID == guid + + -- The real "target" unit was already confirmed above and is more + -- authoritative than its projected nameplate token. Avoid duplicate + -- confirmation work for that same GUID. + if hasPending and not isCurrentTarget and not guidSuppressed then + ConfirmPendingForUnit(token, guid, now) + end + + if hasPendingAoE and not guidSuppressed then + ConfirmAoEAurasOnUnit(token, guid) + end + + if plateRemovalDue and not isCurrentTarget and not guidSuppressed then + -- Removal/shared scans are destructive and therefore must never use + -- a raw projected GUID while the renderer still considers the plate + -- identity unstable. This is the key raid-stack safeguard: one stale + -- SuperWoW token can no longer remove auras from the wrong GUID. + local stableGUID = plate.BNPAuraGUIDStable + local identityTrusted = stableGUID and stableGUID == guid + if identityTrusted then + local snapshot = nil + if HAS_SHARED_AURAS or CacheHasTrackedAuras(BNP.guidAuras[stableGUID]) then + snapshot = CaptureLiveDebuffSnapshot(token, now) + end + + SyncSharedAuras(token, stableGUID, now, snapshot) + + -- Non-target exact-removal also requires multiple misses below, so + -- crowded raid aura lists cannot make Banish/CC blink. + SyncAuraRemoval(token, stableGUID, now, snapshot, targetGUID) + end + end + end + end + + UpdatePlate(plate, now) + end + + if hasPending then + CleanupPending(now) + end + + if hasPendingAoE then + CleanupPendingAoE(now) + end +end) + + +-- Foreign CC scanner --------------------------------------------------------- +-- Runs on its own frame so this optional feature can never interrupt the proven +-- own-debuff renderer/update loop. +local FOREIGN_CC_SCAN_INTERVAL = 0.15 +local FOREIGN_CC_BUCKET_INTERVAL = FOREIGN_CC_SCAN_INTERVAL / 2 +local foreignCCElapsed = 0 +local foreignCCTargetElapsed = 0 +local foreignCCScanBucket = 0 +local foreignCCNextBucket = 0 + +local function GetForeignCCScanBucket(plate) + if plate.BNPForeignCCScanBucket == nil then + plate.BNPForeignCCScanBucket = foreignCCNextBucket + foreignCCNextBucket = 1 - foreignCCNextBucket + end + return plate.BNPForeignCCScanBucket +end + +local foreignCCScanner = CreateFrame("Frame") +foreignCCScanner:SetScript("OnUpdate", function() + if not WantsForeignCCs() then return end + foreignCCElapsed = foreignCCElapsed + arg1 + foreignCCTargetElapsed = foreignCCTargetElapsed + arg1 + if foreignCCElapsed < FOREIGN_CC_BUCKET_INTERVAL then return end + foreignCCElapsed = 0 + foreignCCScanBucket = 1 - foreignCCScanBucket + + local now = GetTime() + local targetGUID = GetTargetGUID() + if foreignCCTargetElapsed >= FOREIGN_CC_SCAN_INTERVAL then + foreignCCTargetElapsed = 0 + if targetGUID then pcall(SyncForeignCCs, "target", targetGUID, now) end + end + + -- Same per-plate 0.15s cadence as before, split over two smaller passes. + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and GetForeignCCScanBucket(plate) == foreignCCScanBucket then + local guid = GetPlateGUID(plate) + if guid and guid ~= targetGUID then + local token = GetUnitTokenForPlate(plate) + if token then pcall(SyncForeignCCs, token, guid, now) end + end + end + end +end) + + +-- Shadow Vulnerability [17794/17797/17798/17799/17800]: shared Warlock raid debuff ------------------- +-- Improved Shadow Bolt's Shadow Vulnerability belongs to the TARGET, not to a +-- single BNP player. If any Warlock procs any Shadow Vulnerability rank, every Warlock running BNP +-- should display the same aura. +-- +-- Tracking sources: +-- * SyncSharedAuras() is authoritative for appearance/removal and scans the +-- real target plus visible SuperWoW nameplate tokens every 0.10s. +-- * RAW_COMBATLOG is the primary refresh source for other Warlocks. SuperWoW +-- puts GUIDs into the raw combat text, so an actual Shadow Vulnerability +-- application can be tied to the exact target without guessing by name. +-- * UNIT_CASTEVENT for any SV rank remains as a harmless compatibility +-- fast-path for clients/servers that publish the proc itself as a cast event. +-- * Charge/stack increases are used as an additional live fallback. +-- +-- No caster ownership is stored or required here. +local SHADOW_VULNERABILITY_IDS = { + [17794] = true, -- Rank 1 + [17797] = true, -- Rank 2 + [17798] = true, -- Rank 3 + [17799] = true, -- Rank 4 + [17800] = true, -- Rank 5 +} + +local function IsShadowVulnerabilitySpellID(spellID) + local sid = tonumber(spellID) + if sid and SHADOW_VULNERABILITY_IDS[sid] then return true end + + -- Custom 1.12 servers may keep the visible aura on the classic ID while + -- publishing a hidden proc/cast event on a custom spell ID. Accept that + -- event by its exact localized spell name as well. + if sid and SpellInfo then + local name = SpellInfo(sid) + if name == "Shadow Vulnerability" or name == "Schattenverwundbarkeit" then + return true + end + end + return false +end + +-- Small diagnostic state for the one thing vanilla does not expose directly: +-- the remaining duration of hostile debuffs. This lets /bnp raidcheck show +-- whether a refresh produced any observable client-side signal (stack change, +-- UNIT_AURA, hidden UNIT_CASTEVENT, or changed UnitDebuff return values). +BNP.svProbe = BNP.svProbe or { events = {} } +BNP.svProbe.events = BNP.svProbe.events or {} +BNP.svProbe.raw = BNP.svProbe.raw or {} +local function SVProbeEvent(stage, guid, spellID, extra) + local t = BNP.svProbe.events + table.insert(t, { + time = GetTime(), stage = stage, guid = guid, spellID = spellID, extra = extra, + }) + while table.getn(t) > 18 do table.remove(t, 1) end +end + +local function SnapshotSVUnit(unit, guid, reason) + if not unit then return end + local i + for i = 1, 64 do + local a1,a2,a3,a4,a5,a6,a7,a8,a9,a10 = UnitDebuff(unit, i) + if not a1 then break end + if IsShadowVulnerabilitySpellID(a4) then + BNP.svProbe.last = { + time = GetTime(), reason = reason, unit = unit, guid = guid, index = i, + spellID = a4, stacks = a2, a3 = a3, a5 = a5, a6 = a6, a7 = a7, + a8 = a8, a9 = a9, a10 = a10, + } + return + end + end +end + +local function SVDef() + local i + for i = 1, table.getn(AURA_DEFS) do + if AURA_DEFS[i] and AURA_DEFS[i].key == "shadow_vulnerability" then + return AURA_DEFS[i] + end + end +end + +local function FindSharedShadowVulnerabilityByGUID(guid, def) + if not guid or not def then return nil, nil, nil, nil end + + local targetGUID = GetTargetGUID() + if targetGUID and targetGUID == guid then + local spellID, texture, duration, expires, stacks = FindSharedAuraOnUnit("target", def) + if spellID or texture then return spellID, texture, duration, expires, stacks end + end + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and GetPlateGUID(plate) == guid then + local token = GetUnitTokenForPlate(plate) + if token then + local spellID, texture, duration, expires, stacks = FindSharedAuraOnUnit(token, def) + if spellID or texture then return spellID, texture, duration, expires, stacks end + end + end + end + + return nil, nil, nil, nil, nil +end + +local function RefreshSharedShadowVulnerability(guid) + if playerClass ~= "WARLOCK" or not guid then return false end + local def = SVDef() + if not def then return false end + + local now = GetTime() + local liveSpellID, texture, liveDuration, liveExpires, liveStacks = + FindSharedShadowVulnerabilityByGUID(guid, def) + + BNP.guidAuras[guid] = BNP.guidAuras[guid] or {} + local cache = BNP.guidAuras[guid] + local aura = cache[def.key] + if not aura then + cache._nextOrder = (cache._nextOrder or 0) + 1 + aura = { order = cache._nextOrder } + cache[def.key] = aura + end + + aura.sharedLive = true + aura.sharedEstimated = nil + aura.spellID = liveSpellID or aura.spellID or 17794 + aura.texture = texture or aura.texture or def.texture + aura.confirmedAt = now + aura.stacks = tonumber(liveStacks) or aura.stacks or 0 + if liveSpellID or texture then aura.sharedLastSeen = now end + + -- A real RAW_COMBATLOG re-application is proof of a fresh duration. Prefer + -- ClassicAPI's absolute expiration when it has already updated; otherwise + -- keep the proven local now+duration value until the API catches up. + aura.duration = (liveDuration and liveDuration > 0 and liveDuration) or def.duration or 10 + local state = BNP.svStateByGUID[guid] + if not state then + state = {} + BNP.svStateByGUID[guid] = state + end + state.present = true + state.duration = aura.duration + state.provenExpires = now + aura.duration + state.rawRefreshAt = now + local apiExpires = liveExpires and liveExpires > now and liveExpires or nil + state.apiExpires = apiExpires + if apiExpires and apiExpires > state.provenExpires then + state.expires = apiExpires + else + state.expires = state.provenExpires + end + state.lastRefresh = now + BNP.svRenderRevisionByGUID[guid] = (BNP.svRenderRevisionByGUID[guid] or 0) + 1 + state.renderRevision = BNP.svRenderRevisionByGUID[guid] + if liveSpellID or texture then state.lastSeen = now end + state.spellID = liveSpellID or state.spellID or aura.spellID or 17794 + state.texture = texture or state.texture or aura.texture or def.texture + state.stacks = tonumber(liveStacks) or state.stacks or 0 + + BNP.svExpiryByGUID[guid] = state.expires + aura.spellID = state.spellID + aura.texture = state.texture + aura.stacks = state.stacks + aura.expires = state.expires + aura.sharedEstimated = nil + + RaidTrace("SV_SHARED_REFRESH", guid, def.key, aura.spellID or 17794) + return true +end + +-- SuperWoW's raw event contains the exact affected GUID. Unlike cast/channel +-- heuristics, an explicit "is afflicted by Shadow Vulnerability" line proves +-- that the proc really happened, including procs caused by another Warlock. +local svRawEvent = CreateFrame("Frame") +pcall(svRawEvent.RegisterEvent, svRawEvent, "RAW_COMBATLOG") +svRawEvent:SetScript("OnEvent", function() + if playerClass ~= "WARLOCK" or event ~= "RAW_COMBATLOG" then return end + local raw = tostring(arg2 or "") + local lower = string.lower(raw) + local hasName = string.find(lower, "shadow vulnerability", 1, true) or + string.find(lower, "schattenverwundbarkeit", 1, true) + if not hasName then return end + if not string.find(lower, "is afflicted", 1, true) and + not string.find(lower, "wird von", 1, true) then return end + + local _, _, guid = string.find(raw, "^(0x%x+)") + if not guid then return end + if RefreshSharedShadowVulnerability(guid) then + SVProbeEvent("SV_RAW_REFRESH", guid, 17794, "proven application") + RaidTrace("SV_RAW_REFRESH", guid, "shadow_vulnerability", 17794) + end +end) + +-- Shadow Vulnerability refresh: legacy compatibility fallback --------------- +-- +-- The original v1.0.5 build was the most reliable version on this client. +-- It did not try to prove the hidden proc itself. Instead, every successful +-- Shadow Bolt CAST / Drain Soul CHANNEL opened a very short verification +-- window. If Shadow Vulnerability was live on that exact target GUID, the +-- local countdown was restarted at 10 seconds. +-- +-- Keep that behavior only as a compatibility fallback when ClassicAPI aura +-- data is unavailable. ClassicAPI users must never refresh from a cast alone: +-- the report proved that this can reset the local clock without a real proc. +-- Unlike old v1.0.5, this listener accepts the trigger from ANY Warlock, not +-- only the local player, as long as UNIT_CASTEVENT supplies the target GUID. +local SHADOW_BOLT_IDS = { + [686]=true, [695]=true, [705]=true, [1088]=true, [1106]=true, + [7641]=true, [11659]=true, [11660]=true, [11661]=true, [25307]=true, +} + +local DRAIN_SOUL_IDS = { + [1120]=true, [8288]=true, [8289]=true, [11675]=true, + [51687]=true, -- Octo/custom rank observed in UNIT_CASTEVENT +} + +local function IsShadowBoltSpellID(spellID) + spellID = tonumber(spellID) + if spellID and SHADOW_BOLT_IDS[spellID] then return true end + if not spellID or not SpellInfo then return false end + local spellName = SpellInfo(spellID) + return spellName == "Shadow Bolt" or spellName == "Schattenblitz" +end + +local function IsDrainSoulSpellID(spellID) + spellID = tonumber(spellID) + if spellID and DRAIN_SOUL_IDS[spellID] then return true end + if not spellID or not SpellInfo then return false end + local spellName = SpellInfo(spellID) + return spellName == "Drain Soul" or spellName == "Seelendieb" +end + +local function ShadowVulnerabilityIsLive(guid) + if not guid then return false end + local def = SVDef() + if not def then return false end + local spellID, texture = FindSharedShadowVulnerabilityByGUID(guid, def) + return IsShadowVulnerabilitySpellID(spellID) or texture ~= nil +end + +-- One pending verification per target GUID so several Warlocks can cast at +-- different mobs during the same frame without overwriting each other. +local sv105Pending = {} +local sv105Elapsed = 0 +local SV105_VERIFY_WINDOW = 0.80 +local SV105_VERIFY_INTERVAL = 0.05 + +local function QueueSV105Refresh(guid, spellID, casterGUID, eventType) + if not guid then return end + sv105Pending[guid] = GetTime() + SV105_VERIFY_WINDOW + SVProbeEvent("SV_105_ARM", guid, spellID, tostring(eventType or "?") .. ":" .. tostring(casterGUID or "?")) + + -- Mid-duration refreshes already have the aura visible. Handle them in the + -- same event immediately so the icon number jumps back to 10 without waiting + -- for the next scanner tick. + if ShadowVulnerabilityIsLive(guid) then + if RefreshSharedShadowVulnerability(guid) then + sv105Pending[guid] = nil + SVProbeEvent("SV_105_REFRESH", guid, spellID, "immediate") + RaidTrace("SV_105_REFRESH", guid, "shadow_vulnerability", spellID) + end + end +end + +local sv105Event = CreateFrame("Frame") +sv105Event:RegisterEvent("UNIT_CASTEVENT") +sv105Event:SetScript("OnEvent", function() + if playerClass ~= "WARLOCK" then return end + if ClassicAPIAuraDataAvailable() then return end + + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = tonumber(arg4) + + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + if not IsShadowBoltSpellID(spellID) and not IsDrainSoulSpellID(spellID) then return end + + -- Old v1.0.5 used only the local player's exact target GUID. For the shared + -- version we trust arg2 for any Warlock. Only our own cast may fall back to + -- the current target when this old SuperWoW build omits arg2. + if not targetGUID then + local playerGUID = GetPlayerGUID() + if playerGUID and casterGUID == playerGUID then targetGUID = GetTargetGUID() end + end + if not targetGUID then return end + + QueueSV105Refresh(targetGUID, spellID, casterGUID, eventType) +end) + +local sv105RefreshFrame = CreateFrame("Frame") +sv105RefreshFrame:SetScript("OnUpdate", function() + if playerClass ~= "WARLOCK" or not next(sv105Pending) then return end + if ClassicAPIAuraDataAvailable() then + sv105Pending = {} + return + end + + sv105Elapsed = sv105Elapsed + arg1 + if sv105Elapsed < SV105_VERIFY_INTERVAL then return end + sv105Elapsed = 0 + + local now = GetTime() + local guid, untilTime + for guid, untilTime in pairs(sv105Pending) do + if now > untilTime then + sv105Pending[guid] = nil + elseif ShadowVulnerabilityIsLive(guid) then + if RefreshSharedShadowVulnerability(guid) then + sv105Pending[guid] = nil + SVProbeEvent("SV_105_REFRESH", guid, 17794, "verified") + RaidTrace("SV_105_REFRESH", guid, "shadow_vulnerability", 17794) + end + end + end +end) + +function BNP:RefreshDebuffVisibility() + if not self:AreCrowdControlEnabled() or not self:ShowOtherPlayersCCs() then + BNP.guidLiveCCs = {} + end + self:RefreshAllAuraLayouts() + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate.BNPAuraContainer then + if self:AreAnyAurasEnabled() then + UpdatePlate(plate) + else + local i + for i = 1, MAX_VISIBLE_ICONS do + local icon = plate.BNPAuraContainer.icons and plate.BNPAuraContainer.icons[i] + if icon then icon:Hide() end + end + plate.BNPAuraContainer:Hide() + HideCCRow(plate) + end + end + end +end diff --git a/modules/castbar.lua b/modules/castbar.lua new file mode 100644 index 0000000..44283dd --- /dev/null +++ b/modules/castbar.lua @@ -0,0 +1,386 @@ +BNP = BNP or {} + +local casts = {} +local BAR_HEIGHT = 6 + +-- Runtime-only visual preview. This is intentionally not saved in BNP_DB so +-- a /reload always returns to normal cast detection. +local castbarTestMode = false +local testCastStart = 0 +local TEST_CAST_DURATION = 4 +local TEST_CAST_TEXTURE = "Interface\\Icons\\Spell_Fire_Fireball02" + +local testCast = { + name = "Test Cast", + texture = TEST_CAST_TEXTURE, + startTime = 0, + endTime = 0, + duration = TEST_CAST_DURATION, + channel = false, +} + +local function ResetTestCast(now) + now = now or GetTime() + testCastStart = now + testCast.startTime = now + testCast.endTime = now + TEST_CAST_DURATION + testCast.duration = TEST_CAST_DURATION +end + +local function GetTestCast(now) + if testCastStart == 0 or now >= testCast.endTime then + ResetTestCast(now) + end + return testCast +end + +local function GetCastbarHeight() + if BNP.GetCastbarHeight then return BNP:GetCastbarHeight() end + return BAR_HEIGHT +end +local BAR_GAP = 4 + +local function GetCastbarSpacing() + if BNP.GetCastbarSpacing then return BNP:GetCastbarSpacing() end + return BAR_GAP +end + +local BOTTOM_AURA_EDGE_GAP = 4 +local BOTTOM_AURA_ROW_SPACING = 2 + +local function FrameShown(frame) + return frame and frame.IsShown and frame:IsShown() and true or false +end + +local function IsBottomDebuffPosition(position) + return position == "bottom_mid" or position == "bottom_left" or position == "bottom_right" +end + +local function GetBottomAuraClearance(plate) + if not plate then return 0 end + + local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top" + local debuffsEnabled = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() + local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18 + + if debuffsEnabled and IsBottomDebuffPosition(debuffPosition) and FrameShown(plate.BNPAuraContainer) then + return BOTTOM_AURA_EDGE_GAP + auraSize + end + return 0 +end + +local function ApplyCastbarLayout(plate, bar) + if not plate or not plate.healthbar or not bar then return end + local gap = GetCastbarSpacing() + GetBottomAuraClearance(plate) + + bar:ClearAllPoints() + bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 15, -gap) + bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14, -gap) + + if bar.icon then + bar.icon:ClearAllPoints() + bar.icon:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 0, -gap) + end + bar.BNPBottomAuraClearance = GetBottomAuraClearance(plate) +end + +local function Enabled() + if BNP.AreCastbarsEnabled then return BNP:AreCastbarsEnabled() end + return not BNP_DB or BNP_DB.castbars ~= false +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + + local token = plate:GetName(1) + if not token then return nil end + + local exists, guid = UnitExists(token) + if exists and guid then return guid end + + -- On SuperWoW the plate token itself can already be the GUID. + return token +end + +local function SpellData(spellID) + if SpellInfo and spellID then + local name, _, texture = SpellInfo(spellID) + return name or "Casting", texture + end + return "Casting", nil +end + +local function CreateCastbar(plate) + if plate.BNPCastbar then return plate.BNPCastbar end + if not plate.healthbar then return nil end + + local parent = plate.BNPScaleWrapper or plate + local bar = CreateFrame("StatusBar", nil, parent) + bar:SetHeight(GetCastbarHeight()) + -- Center the castbar under the complete Blizzard nameplate. + -- Extend beneath the level badge instead of stopping at the healthbar edge. + -- Compact row: icon + castbar together span the complete plate width. + -- The icon begins at the healthbar's left edge; the bar continues directly + -- beside it and ends beneath the level badge. + local gap = GetCastbarSpacing() + GetBottomAuraClearance(plate) + bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 15, -gap) + bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14, -gap) + bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") + bar:SetStatusBarColor(1.0, 0.8, 0.0) + bar:SetMinMaxValues(0, 1) + bar:SetValue(0) + bar:SetFrameLevel((plate.healthbar:GetFrameLevel() or 1) + 1) + bar:Hide() + + local bg = bar:CreateTexture(nil, "BACKGROUND") + bg:SetTexture("Interface\\TargetingFrame\\UI-StatusBar") + bg:SetVertexColor(0.10, 0.10, 0.00, 0.85) + bg:SetAllPoints(bar) + + local text = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + text:SetPoint("CENTER", bar, "CENTER", 0, 0) + text:SetTextColor(1, 1, 1) + bar.text = text + + -- Spell icon to the left of the castbar. + local icon = CreateFrame("Frame", nil, parent) + icon:SetWidth(14) + icon:SetHeight(14) + icon:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", 0, -gap) + icon:SetFrameLevel(bar:GetFrameLevel() + 1) + + local texture = icon:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(icon) + icon.texture = texture + + local border = icon:CreateTexture(nil, "OVERLAY") + border:SetTexture("Interface\\Buttons\\UI-Quickslot2") + border:SetPoint("CENTER", icon, "CENTER", 0, 0) + border:SetWidth(23) + border:SetHeight(23) + icon.border = border + + icon:Hide() + bar.icon = icon + + plate.BNPCastbar = bar + return bar +end + +local function Hide(plate) + if plate and plate.BNPCastbar then + plate.BNPCastbar:Hide() + if plate.BNPCastbar.icon then + plate.BNPCastbar.icon:Hide() + end + end +end + +-- Exact SuperWoW event path used by ShaguTweaks' superwow module: +-- START / CAST / CHANNEL create cast state; FAIL removes it. +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("UNIT_CASTEVENT") +eventFrame:SetScript("OnEvent", function() + local guid = arg1 + local eventType = arg3 + local spellID = arg4 + local timerMS = tonumber(arg5) or 0 + + if not guid then return end + + if eventType == "START" or eventType == "CAST" or eventType == "CHANNEL" then + if timerMS <= 0 then + -- Instant casts do not need a visible castbar. + casts[guid] = nil + return + end + + local name, texture = SpellData(spellID) + local now = GetTime() + local duration = timerMS / 1000 + + casts[guid] = { + name = name, + texture = texture, + startTime = now, + endTime = now + duration, + duration = duration, + channel = eventType == "CHANNEL", + } + elseif eventType == "FAIL" then + casts[guid] = nil + end +end) + +local activeCastPlates = {} + +local function MarkCastPlateActive(plate) + if not plate then return end + activeCastPlates[plate] = true +end + +local function MarkCastPlateInactive(plate) + if not plate then return end + activeCastPlates[plate] = nil + Hide(plate) +end + +-- Lightweight discovery pass. This does NOT animate bars; it only checks +-- visible plates for active cast state at 20 Hz. +local discovery = CreateFrame("Frame") +local discoveryElapsed = 0 +discovery:SetScript("OnUpdate", function() + discoveryElapsed = discoveryElapsed + arg1 + if discoveryElapsed < 0.05 then return end + discoveryElapsed = 0 + + if not Enabled() and not castbarTestMode then + local plate + for plate in pairs(activeCastPlates) do + MarkCastPlateInactive(plate) + end + return + end + + local now = GetTime() + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() then + if castbarTestMode then + -- Preview every visible nameplate. No GUID/cast lookup is needed. + MarkCastPlateActive(plate) + else + local guid = GetPlateGUID(plate) + local cast = guid and casts[guid] + + if cast and now < cast.endTime then + MarkCastPlateActive(plate) + elseif activeCastPlates[plate] then + MarkCastPlateInactive(plate) + end + end + elseif activeCastPlates[plate] then + MarkCastPlateInactive(plate) + end + end +end) + +-- Frame-perfect animation remains unchanged visually, but only active +-- castbars are updated every rendered frame. +local updater = CreateFrame("Frame") +updater:SetScript("OnUpdate", function() + if not Enabled() and not castbarTestMode then return end + + local now = GetTime() + local plate + for plate in pairs(activeCastPlates) do + if not plate:IsShown() then + MarkCastPlateInactive(plate) + else + local guid + local cast + + if castbarTestMode then + cast = GetTestCast(now) + else + guid = GetPlateGUID(plate) + cast = guid and casts[guid] + end + + if not cast or now >= cast.endTime then + if (not castbarTestMode) and guid and cast and now >= cast.endTime then + casts[guid] = nil + end + MarkCastPlateInactive(plate) + else + local bar = CreateCastbar(plate) + if bar then + local bottomClearance = GetBottomAuraClearance(plate) + if bar.BNPBottomAuraClearance ~= bottomClearance then + ApplyCastbarLayout(plate, bar) + end + + local current = now - cast.startTime + if current < 0 then current = 0 end + if current > cast.duration then current = cast.duration end + + bar:SetMinMaxValues(0, cast.duration) + + if cast.channel then + bar:SetValue(cast.duration - current) + else + bar:SetValue(current) + end + + bar.text:SetText(cast.name) + bar:SetAlpha(1) + + if bar.icon then + if cast.texture then + bar.icon.texture:SetTexture(cast.texture) + bar.icon:SetAlpha(1) + bar.icon:Show() + else + bar.icon:Hide() + end + end + + bar:Show() + end + end + end + end +end) + + +function BNP:IsCastbarTestMode() + return castbarTestMode and true or false +end + +function BNP:SetCastbarTestMode(enabled) + castbarTestMode = enabled and true or false + + if castbarTestMode then + ResetTestCast(GetTime()) + else + -- Remove every preview immediately. Real casts are rediscovered on the + -- next lightweight discovery pass without touching their stored state. + local plate + for plate in pairs(activeCastPlates) do + MarkCastPlateInactive(plate) + end + end + + if self.optionsFrame and self.optionsFrame.castbarTestCheck then + self.optionsFrame.castbarTestCheck:SetChecked(castbarTestMode) + end +end + + +function BNP:RefreshCastbarLayout() + local plate + local height = GetCastbarHeight() + + for plate in pairs(BNP.plates or {}) do + if plate and plate.BNPCastbar then + plate.BNPCastbar:SetHeight(height) + ApplyCastbarLayout(plate, plate.BNPCastbar) + end + end +end + +function BNP:RefreshCastbarHeights() + self:RefreshCastbarLayout() +end + +function BNP:SetCastbarsEnabled(enabled) + BNP_DB = BNP_DB or {} + BNP_DB.castbars = enabled and true or false + + if not enabled then + local plate + for plate in pairs(BNP.plates) do Hide(plate) end + end + + self:Print("Nameplate castbars " .. (enabled and "enabled." or "disabled.")) +end diff --git a/modules/classcolors.lua b/modules/classcolors.lua new file mode 100644 index 0000000..fc0f54b --- /dev/null +++ b/modules/classcolors.lua @@ -0,0 +1,279 @@ +BNP = BNP or {} + +local fallbackColors = { + WARRIOR = { 0.78, 0.61, 0.43 }, + MAGE = { 0.41, 0.80, 0.94 }, + ROGUE = { 1.00, 0.96, 0.41 }, + DRUID = { 1.00, 0.49, 0.04 }, + HUNTER = { 0.67, 0.83, 0.45 }, + SHAMAN = { 0.00, 0.44, 0.87 }, + PRIEST = { 1.00, 1.00, 1.00 }, + WARLOCK = { 0.58, 0.51, 0.79 }, + PALADIN = { 0.96, 0.55, 0.73 }, +} + +local function GetColor(class) + if RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then + local c = RAID_CLASS_COLORS[class] + return c.r, c.g, c.b + end + + local c = fallbackColors[class] + if c then return c[1], c[2], c[3] end +end + +local function Enabled() + if BNP.AreClassColorsEnabled then return BNP:AreClassColorsEnabled() end + return not BNP_DB or BNP_DB.classColors ~= false +end + +local function ClearClassCache(plate) + if not plate then return end + plate.BNPClassColorApplied = nil + plate.BNPClassColorToken = nil + plate.BNPClassGUID = nil + plate.BNPClassR = nil + plate.BNPClassG = nil + plate.BNPClassB = nil +end + +local function ClearNativeColor(plate) + if not plate then return end + plate.BNPClassNativeGUID = nil + plate.BNPClassNativeR = nil + plate.BNPClassNativeG = nil + plate.BNPClassNativeB = nil + plate.BNPClassNativeA = nil +end + +local function ResolvePlateUnit(plate) + if not plate or not plate.GetName then return nil, nil end + + local token = plate:GetName(1) + if not token or not UnitExists(token) then + return token, nil + end + + local exists, guid = UnitExists(token) + if exists then + return token, guid + end + + return token, nil +end + +local function CaptureNativeColor(plate, guid) + if not plate or not plate.healthbar or not guid then return end + if plate.BNPClassNativeGUID == guid and plate.BNPClassNativeR then return end + + local r, g, b, a = plate.healthbar:GetStatusBarColor() + plate.BNPClassNativeGUID = guid + plate.BNPClassNativeR = r + plate.BNPClassNativeG = g + plate.BNPClassNativeB = b + plate.BNPClassNativeA = a or 1 +end + +local function GetFallbackNativeColor(token) + if not token or not UnitExists(token) then return nil end + + if UnitCanAttack and UnitCanAttack("player", token) then + return 1, 0, 0, 1 + end + + if UnitIsFriend and UnitIsFriend("player", token) then + return 0, 1, 0, 1 + end + + return 1, 1, 0, 1 +end + +local function RestoreNativeColor(plate) + if not plate or not plate.healthbar then + ClearClassCache(plate) + return false + end + + local token, guid = ResolvePlateUnit(plate) + + if token and guid and UnitIsPlayer(token) then + if plate.BNPClassNativeGUID == guid and plate.BNPClassNativeR then + plate.healthbar:SetStatusBarColor( + plate.BNPClassNativeR, + plate.BNPClassNativeG, + plate.BNPClassNativeB, + plate.BNPClassNativeA or 1 + ) + else + local r, g, b, a = GetFallbackNativeColor(token) + if r then plate.healthbar:SetStatusBarColor(r, g, b, a or 1) end + end + end + + ClearClassCache(plate) + return true +end + +local function ApplyClassColor(plate) + if not Enabled() then + RestoreNativeColor(plate) + return false + end + + if not plate or not plate.healthbar then return false end + + local token, guid = ResolvePlateUnit(plate) + + -- BNP only owns PLAYER class colors. + -- NPC/neutral/friendly-NPC colors stay completely under Blizzard control. + if not token or not guid or not UnitIsPlayer(token) then + ClearClassCache(plate) + return false + end + + local _, class = UnitClass(token) + local r, g, b = GetColor(class) + if not r then + ClearClassCache(plate) + return false + end + + -- Remember Blizzard's original player color before BNP overwrites it so the + -- Class Colors checkbox can restore the plate immediately without /reload. + CaptureNativeColor(plate, guid) + + plate.BNPClassColorApplied = true + plate.BNPClassColorToken = token + plate.BNPClassGUID = guid + plate.BNPClassR = r + plate.BNPClassG = g + plate.BNPClassB = b + + plate.healthbar:SetStatusBarColor(r, g, b) + return true +end + +BNP.ApplyClassColor = ApplyClassColor +BNP.RestoreClassColor = RestoreNativeColor + +function BNP:RefreshClassColors() + local plate + for plate in pairs(self.plates or {}) do + if plate and plate:IsShown() and plate.healthbar then + if Enabled() then + ClearClassCache(plate) + ApplyClassColor(plate) + else + RestoreNativeColor(plate) + end + end + end +end + +local function EnforceClassColor(plate) + if not Enabled() then return end + if not plate or not plate.healthbar or not plate.BNPClassGUID then return end + + local token, guid = ResolvePlateUnit(plate) + + -- Never enforce a cached color after Blizzard recycled the frame. + if not token or not guid or guid ~= plate.BNPClassGUID or not UnitIsPlayer(token) then + ClearClassCache(plate) + ClearNativeColor(plate) + return + end + + local r, g, b, a = plate.healthbar:GetStatusBarColor() + if math.abs(r - plate.BNPClassR) > 0.01 + or math.abs(g - plate.BNPClassG) > 0.01 + or math.abs(b - plate.BNPClassB) > 0.01 then + -- Blizzard can rewrite a player plate back to its native reaction color. + -- Keep that latest native value so disabling class colors restores cleanly. + plate.BNPClassNativeGUID = guid + plate.BNPClassNativeR = r + plate.BNPClassNativeG = g + plate.BNPClassNativeB = b + plate.BNPClassNativeA = a or 1 + + plate.healthbar:SetStatusBarColor( + plate.BNPClassR, + plate.BNPClassG, + plate.BNPClassB + ) + end +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + + ClearClassCache(current) + ClearNativeColor(current) + ApplyClassColor(current) + + local bar = current.healthbar + if bar and not current.BNPClassColorHooked then + local oldValueChanged = bar:GetScript("OnValueChanged") + bar:SetScript("OnValueChanged", function() + if oldValueChanged then oldValueChanged() end + EnforceClassColor(current) + end) + current.BNPClassColorHooked = true + end + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + ClearClassCache(current) + ClearNativeColor(current) + ApplyClassColor(current) + end) +end + +-- Blizzard may rewrite player colors during combat/threat updates. +-- Re-apply only while the plate still resolves to the SAME player GUID. +-- If a recycled frame now represents an NPC/neutral unit, drop the cache +-- immediately and never touch its Blizzard color. +local elapsed = 0 +local scanBucket = 0 +local nextBucket = 0 + +local function GetClassScanBucket(plate) + if plate.BNPClassScanBucket == nil then + plate.BNPClassScanBucket = nextBucket + nextBucket = 1 - nextBucket + end + return plate.BNPClassScanBucket +end + +local updater = CreateFrame("Frame") +updater:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.05 then return end + elapsed = 0 + + if not Enabled() then return end + + -- Split the old 0.10s all-plate burst into two 0.05s buckets. Every plate is + -- still checked once per 0.10s, but large BG/raid updates no longer land in + -- one Lua spike. + scanBucket = 1 - scanBucket + + local plate + for plate in pairs(BNP.plates or {}) do + if plate:IsShown() and plate.healthbar and GetClassScanBucket(plate) == scanBucket then + if plate.BNPClassGUID then + -- EnforceClassColor already validates token/GUID/player identity. The old + -- updater resolved the same unit once here and a second time inside it. + EnforceClassColor(plate) + else + local token, guid = ResolvePlateUnit(plate) + if token and guid and UnitIsPlayer(token) then + ApplyClassColor(plate) + end + end + end + end +end) diff --git a/modules/combopoints.lua b/modules/combopoints.lua new file mode 100644 index 0000000..05fb565 --- /dev/null +++ b/modules/combopoints.lua @@ -0,0 +1,311 @@ +BNP = BNP or {} + +local _, playerClass = UnitClass("player") +local COMBO_CLASS = (playerClass == "ROGUE" or playerClass == "DRUID") + +local MAX_POINTS = 5 +local POINT_GAP = 1 + +local function Enabled() + return COMBO_CLASS and BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() +end + +local function GetTargetGUID() + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +end + +local function GetComboCount() + if not GetComboPoints then return 0 end + local count = tonumber(GetComboPoints()) or 0 + if count < 0 then count = 0 end + if count > MAX_POINTS then count = MAX_POINTS end + return count +end + +local function GetSourcePoint() + if getglobal then + return getglobal("TargetFrameComboPoint1") + end + return nil +end + +local function GetSourceSize() + local source = GetSourcePoint() + local w, h = 12, 16 + + if source then + if source.GetWidth then w = tonumber(source:GetWidth()) or w end + if source.GetHeight then h = tonumber(source:GetHeight()) or h end + end + + if w <= 0 then w = 12 end + if h <= 0 then h = 16 end + return w, h +end + +local function GetComboYOffset() + if BNP.GetComboPointsYOffset then return BNP:GetComboPointsYOffset() end + return 0 +end + +local function AnchorComboHolder(plate, holder) + if not plate or not holder or not plate.name then return end + holder:ClearAllPoints() + holder:SetPoint("BOTTOM", plate.name, "TOP", 0, 2 + GetComboYOffset()) +end + +local function ApplyTargetFrameScale(holder) + if not holder then return end + + local source = GetSourcePoint() + local parent = holder:GetParent() + + local sourceScale = 1.0 + local parentScale = 1.0 + + -- Match the actual on-screen scale of Blizzard's TargetFrame combo points. + if source and source.GetEffectiveScale then + sourceScale = tonumber(source:GetEffectiveScale()) or 1.0 + elseif UIParent and UIParent.GetEffectiveScale then + sourceScale = tonumber(UIParent:GetEffectiveScale()) or 1.0 + elseif UIParent and UIParent.GetScale then + sourceScale = tonumber(UIParent:GetScale()) or 1.0 + end + + if parent and parent.GetEffectiveScale then + parentScale = tonumber(parent:GetEffectiveScale()) or 1.0 + end + + if sourceScale <= 0 then sourceScale = 1.0 end + if parentScale <= 0 then parentScale = 1.0 end + + holder:SetScale(sourceScale / parentScale) +end + +local function CopySourceTextureAppearance(dst, src) + if not dst or not src then return end + + if src.GetTexture and dst.SetTexture then + dst:SetTexture(src:GetTexture()) + end + + if src.GetTexCoord and dst.SetTexCoord then + local l, r, t, b = src:GetTexCoord() + if l then dst:SetTexCoord(l, r, t, b) end + end + + if src.GetVertexColor and dst.SetVertexColor then + local r, g, b, a = src:GetVertexColor() + if r then dst:SetVertexColor(r, g, b, a or 1) end + end + + if src.GetBlendMode and dst.SetBlendMode then + local mode = src:GetBlendMode() + if mode then dst:SetBlendMode(mode) end + end +end + +local function CreateExactPoint(parent) + local source = GetSourcePoint() + local pointW, pointH = GetSourceSize() + + local point = CreateFrame("Frame", nil, parent) + point:SetWidth(pointW) + point:SetHeight(pointH) + + local copied = false + + -- Clone the actual texture regions from Blizzard's TargetFrame combo point. + -- This guarantees the same client artwork/skin instead of guessing atlas cuts. + if source and source.GetRegions then + local regions = { source:GetRegions() } + local i + + for i = 1, table.getn(regions) do + local src = regions[i] + if src and src.GetObjectType and src:GetObjectType() == "Texture" then + local tex = point:CreateTexture(nil, "ARTWORK") + CopySourceTextureAppearance(tex, src) + + local w = src.GetWidth and tonumber(src:GetWidth()) or pointW + local h = src.GetHeight and tonumber(src:GetHeight()) or pointH + if not w or w <= 0 then w = pointW end + if not h or h <= 0 then h = pointH end + + tex:SetWidth(w) + tex:SetHeight(h) + + -- Preserve the standard TargetFrame relative offsets where possible. + local anchored = false + if src.GetPoint then + local pnt, rel, relp, x, y = src:GetPoint(1) + if pnt then + -- Source textures are normally anchored to their own combo frame. + -- Re-anchor them to our clone with the same point/offset. + tex:SetPoint(pnt, point, relp or pnt, x or 0, y or 0) + anchored = true + end + end + + if not anchored then + tex:SetPoint("CENTER", point, "CENTER", 0, 0) + end + + copied = true + end + end + end + + -- Fallback only if the client does not expose TargetFrameComboPoint1 regions. + if not copied then + local base = point:CreateTexture(nil, "BACKGROUND") + base:SetTexture("Interface\\ComboFrame\\ComboPoint") + base:SetWidth(12) + base:SetHeight(16) + base:SetPoint("TOPLEFT", point, "TOPLEFT", 0, 0) + base:SetTexCoord(0, 0.375, 0, 1) + + local highlight = point:CreateTexture(nil, "ARTWORK") + highlight:SetTexture("Interface\\ComboFrame\\ComboPoint") + highlight:SetWidth(8) + highlight:SetHeight(16) + highlight:SetPoint("TOPLEFT", point, "TOPLEFT", 2, 0) + highlight:SetTexCoord(0.375, 0.5625, 0, 1) + end + + point:Hide() + return point +end + +local function EnsurePoints(plate) + if plate.BNPComboPoints then + AnchorComboHolder(plate, plate.BNPComboPoints) + ApplyTargetFrameScale(plate.BNPComboPoints) + return plate.BNPComboPoints + end + + if not plate or not plate.name then return nil end + + local pointW, pointH = GetSourceSize() + local parent = plate.BNPScaleWrapper or plate + + local holder = CreateFrame("Frame", nil, parent) + holder:SetWidth(MAX_POINTS * pointW + (MAX_POINTS - 1) * POINT_GAP) + holder:SetHeight(pointH) + + -- Keep the approved base position and add only the user's independent Y offset. + AnchorComboHolder(plate, holder) + holder:SetFrameLevel((plate:GetFrameLevel() or 1) + 12) + holder.points = {} + holder:Hide() + + local i + for i = 1, MAX_POINTS do + local point = CreateExactPoint(holder) + + if i == 1 then + point:SetPoint("LEFT", holder, "LEFT", 0, 0) + else + point:SetPoint("LEFT", holder.points[i - 1], "RIGHT", POINT_GAP, 0) + end + + holder.points[i] = point + end + + ApplyTargetFrameScale(holder) + plate.BNPComboPoints = holder + return holder +end + +local function HidePoints(plate) + local holder = plate and plate.BNPComboPoints + if not holder then return end + + local i + for i = 1, MAX_POINTS do + holder.points[i]:Hide() + end + holder:Hide() +end + +local function UpdatePlate(plate) + if not plate or not plate:IsShown() then return end + + if not Enabled() then + HidePoints(plate) + return + end + + local targetGUID = GetTargetGUID() + local plateGUID = plate.GetName and plate:GetName(1) or nil + + if not targetGUID or not plateGUID or targetGUID ~= plateGUID then + HidePoints(plate) + return + end + + local count = GetComboCount() + if count <= 0 then + HidePoints(plate) + return + end + + local holder = EnsurePoints(plate) + if not holder then return end + ApplyTargetFrameScale(holder) + + local i + for i = 1, MAX_POINTS do + if i <= count then + holder.points[i]:Show() + else + holder.points[i]:Hide() + end + end + + holder:Show() +end + +function BNP:RefreshComboPoints() + local plate + for plate in pairs(BNP.plates or {}) do + if plate:IsShown() then + UpdatePlate(plate) + else + HidePoints(plate) + end + end + + if BNP.RefreshAllAuraLayouts then + BNP:RefreshAllAuraLayouts() + end +end + +table.insert(BNP.libnameplate.OnInit, function(plate) + if COMBO_CLASS then + EnsurePoints(plate) + HidePoints(plate) + end +end) + +table.insert(BNP.libnameplate.OnShow, function(plate) + if COMBO_CLASS then + HidePoints(plate) + UpdatePlate(plate) + end +end) + +table.insert(BNP.libnameplate.OnUpdate, function(plate) + if COMBO_CLASS then UpdatePlate(plate) end +end) + +if COMBO_CLASS then + local events = CreateFrame("Frame") + events:RegisterEvent("PLAYER_TARGET_CHANGED") + events:RegisterEvent("PLAYER_COMBO_POINTS") + events:SetScript("OnEvent", function() + BNP:RefreshComboPoints() + end) +end diff --git a/modules/commands.lua b/modules/commands.lua new file mode 100644 index 0000000..c30698b --- /dev/null +++ b/modules/commands.lua @@ -0,0 +1,592 @@ +-- Elite dragon render probe --------------------------------------------------- +-- Kept directly in commands.lua on purpose so /bnp dragontest cannot depend +-- on a separate module loading successfully. This is render-only: it does not +-- alter level text, classification state, nameplate discovery, or saved data. +local function BNP_FindTargetPlateForDragonTest() + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok and plate then return plate end + end + + local exists, guid = UnitExists("target") + if not exists or not guid then return nil end + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and plate.GetName then + local ok, token = pcall(plate.GetName, plate, 1) + if ok and token == guid then return plate end + end + end + return nil +end + +local function BNP_ToggleDirectDragonRenderTest() + if not UnitExists("target") then + BNP:Print("Target a visible unit first.") + return + end + + local plate = BNP_FindTargetPlateForDragonTest() + if not plate then + BNP:Print("Target nameplate not found. Keep the target nameplate visible and try again.") + return + end + + local healthbar = plate.healthbar + if not healthbar and plate.GetChildren then + healthbar = plate:GetChildren() + plate.healthbar = healthbar + end + if not healthbar then + BNP:Print("Target healthbar not found.") + return + end + + if not plate.BNPDirectDragonTestFrame then + -- Put the dragon directly on the healthbar BACKGROUND layer. + -- This keeps the exact size/position/scaling, but lets the bar, border + -- and level text draw in front of it at all times. + local t = healthbar:CreateTexture(nil, "BACKGROUND") + t:SetWidth(37) + t:SetHeight(28) + t:SetPoint("CENTER", healthbar, "RIGHT", 20, -1) + t:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame-Elite") + t:SetTexCoord(0.58, 1.00, 0.00, 0.78125) + + t:Hide() + plate.BNPDirectDragonTestFrame = t + end + + local f = plate.BNPDirectDragonTestFrame + if f:IsShown() then + f:Hide() + BNP:Print("Dragon test OFF.") + else + f:Show() + local classification = UnitClassification and UnitClassification("target") or "?" + BNP:Print("Dragon test ON. Classification: " .. tostring(classification) .. ".") + end +end + + +-- Automatic Elite / World Boss dragon --------------------------------------- +-- Keep this in commands.lua deliberately: /bnp dragontest and /bnp eliteinfo +-- prove that this file and this exact plate/render path work on the 1.12 client. +-- The automatic path reuses the SAME frame created by dragontest. +local BNP_EliteAutoNextScan = 0 +local BNP_EliteAutoState = { + ticks = 0, + visible = 0, + elite = 0, + shown = 0, + lastClass = "nil", + lastError = "none", +} + +local function BNP_GetOrCreateAutoDragon(plate) + if not plate then return nil end + + -- Reuse the exact proven dragontest frame if it already exists. + if plate.BNPDirectDragonTestFrame then + return plate.BNPDirectDragonTestFrame + end + + local healthbar = plate.healthbar + if not healthbar and plate.GetChildren then + healthbar = plate:GetChildren() + plate.healthbar = healthbar + end + if not healthbar then return nil end + + -- Same render path as /bnp dragontest: a BACKGROUND texture directly + -- on the healthbar, so the dragon always stays behind bar/level elements. + local t = healthbar:CreateTexture(nil, "BACKGROUND") + t:SetWidth(37) + t:SetHeight(28) + t:SetPoint("CENTER", healthbar, "RIGHT", 20, -1) + t:SetTexture("Interface\\TargetingFrame\\UI-TargetingFrame-Elite") + t:SetTexCoord(0.58, 1.00, 0.00, 0.78125) + + t:Hide() + plate.BNPDirectDragonTestFrame = t + return t +end + +local function BNP_UpdateEliteDragonPlate(plate, unit) + if not plate or not plate:IsShown() then + if plate and plate.BNPDirectDragonTestFrame then + plate.BNPDirectDragonTestFrame:Hide() + end + return + end + + local token = unit + if not token and plate.GetName then + token = plate:GetName(1) + end + + local classification = nil + if token and UnitClassification then + classification = UnitClassification(token) + end + + local dragon = BNP_GetOrCreateAutoDragon(plate) + if not dragon then return end + + if classification == "elite" or classification == "worldboss" then + dragon:Show() + else + dragon:Hide() + end +end + +local function BNP_RunEliteAutoScan() + local visible = 0 + local elite = 0 + local shown = 0 + local lastClass = "nil" + + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() then + visible = visible + 1 + + local classification = nil + local token = nil + if plate.GetName then + token = plate:GetName(1) + end + if token and UnitClassification then + classification = UnitClassification(token) + end + lastClass = tostring(classification or "nil") + + local dragon = BNP_GetOrCreateAutoDragon(plate) + if dragon then + if classification == "elite" or classification == "worldboss" then + elite = elite + 1 + dragon:Show() + shown = shown + 1 + else + dragon:Hide() + end + end + elseif plate and plate.BNPDirectDragonTestFrame then + plate.BNPDirectDragonTestFrame:Hide() + end + end + + BNP_EliteAutoState.visible = visible + BNP_EliteAutoState.elite = elite + BNP_EliteAutoState.shown = shown + BNP_EliteAutoState.lastClass = lastClass +end + +-- Instant path --------------------------------------------------------------- +-- 1) BNP's own OnShow fires immediately when an already-known Blizzard plate +-- is shown/reused. +if BNP.libnameplate and BNP.libnameplate.OnShow then + table.insert(BNP.libnameplate.OnShow, function(plate) + BNP_UpdateEliteDragonPlate(plate) + end) +end + +-- 2) ClassicAPI's nameplate event can resolve the unit token directly on the +-- frame it belongs to. This is the fastest path for newly appearing plates. +local BNP_EliteInstantEvents = CreateFrame("Frame", nil, UIParent) +local addedOK = pcall(BNP_EliteInstantEvents.RegisterEvent, BNP_EliteInstantEvents, "NAME_PLATE_UNIT_ADDED") +local removedOK = pcall(BNP_EliteInstantEvents.RegisterEvent, BNP_EliteInstantEvents, "NAME_PLATE_UNIT_REMOVED") + +if addedOK or removedOK then + BNP_EliteInstantEvents:SetScript("OnEvent", function() + local unit = arg1 + if not unit then return end + + if event == "NAME_PLATE_UNIT_ADDED" then + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, unit) + if ok and plate then + BNP_UpdateEliteDragonPlate(plate, unit) + end + end + elseif event == "NAME_PLATE_UNIT_REMOVED" then + -- The fallback pass clears hidden/recycled frames. If ClassicAPI still + -- exposes the frame at removal time, hide it immediately as well. + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, unit) + if ok and plate and plate.BNPDirectDragonTestFrame then + plate.BNPDirectDragonTestFrame:Hide() + end + end + end + end) +end + +-- Apply immediately to any plates that were already visible when this module +-- loaded. Afterwards the 0.50s scan is only a safety net. +pcall(BNP_RunEliteAutoScan) + +local BNP_EliteAutoFrame = CreateFrame("Frame", nil, UIParent) +BNP_EliteAutoFrame:SetScript("OnUpdate", function() + local now = GetTime() + if now < BNP_EliteAutoNextScan then return end + BNP_EliteAutoNextScan = now + 0.50 + BNP_EliteAutoState.ticks = BNP_EliteAutoState.ticks + 1 + + local ok, err = pcall(BNP_RunEliteAutoScan) + if not ok then + BNP_EliteAutoState.lastError = tostring(err or "unknown") + else + BNP_EliteAutoState.lastError = "none" + end +end) +BNP_EliteAutoFrame:Show() + + +local function BNP_PrintEliteInfo() + DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffBNP ELITEINFO|r") + + local targetExists, targetGUID = UnitExists("target") + local targetName = UnitName("target") or "?" + local targetClass = UnitClassification and UnitClassification("target") or "?" + local targetLevel = UnitLevel and UnitLevel("target") or "?" + + DEFAULT_CHAT_FRAME:AddMessage( + "TARGET | "..tostring(targetName).. + " | level "..tostring(targetLevel).. + " | class "..tostring(targetClass).. + " | guid "..tostring(targetGUID or "nil") + ) + + if not targetExists then + DEFAULT_CHAT_FRAME:AddMessage("No target. Target a visible Elite and run /bnp eliteinfo again.") + return + end + + local apiTargetPlate = nil + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, frame = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok then apiTargetPlate = frame end + end + DEFAULT_CHAT_FRAME:AddMessage("C_NamePlate(target) | "..tostring(apiTargetPlate and "FOUND" or "nil")) + + local matchedPlate = nil + local visible = 0 + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() then + visible = visible + 1 + local rawToken = nil + if plate.GetName then + local ok, value = pcall(plate.GetName, plate, 1) + if ok then rawToken = value end + end + + local directMatch = apiTargetPlate and plate == apiTargetPlate + local guidMatch = targetGUID and rawToken == targetGUID + if directMatch or guidMatch then + matchedPlate = plate + local rawExists, rawGUID = false, nil + if rawToken then rawExists, rawGUID = UnitExists(rawToken) end + local rawClass = "nil" + if rawToken and UnitClassification then + local ok, value = pcall(UnitClassification, rawToken) + if ok then rawClass = value end + end + DEFAULT_CHAT_FRAME:AddMessage( + "BNP MATCH | direct "..tostring(directMatch and 1 or 0).. + " | guid "..tostring(guidMatch and 1 or 0).. + " | token "..tostring(rawToken or "nil") + ) + DEFAULT_CHAT_FRAME:AddMessage( + "BNP TOKEN | exists "..tostring(rawExists and 1 or 0).. + " | guid "..tostring(rawGUID or "nil").. + " | class "..tostring(rawClass or "nil") + ) + break + end + end + end + DEFAULT_CHAT_FRAME:AddMessage("BNP visible plates | "..tostring(visible).." | target match "..tostring(matchedPlate and "YES" or "NO")) + + local existing = 0 + local guidMatchUnit = nil + local frameMatchUnit = nil + local i + for i = 1, 40 do + local unit = "nameplate"..tostring(i) + local exists, guid = UnitExists(unit) + if exists then + existing = existing + 1 + local class = UnitClassification and UnitClassification(unit) or "?" + local frame = nil + if C_NamePlate and type(C_NamePlate.GetNamePlateForUnit) == "function" then + local ok, value = pcall(C_NamePlate.GetNamePlateForUnit, unit) + if ok then frame = value end + end + + if targetGUID and guid == targetGUID then + guidMatchUnit = unit + DEFAULT_CHAT_FRAME:AddMessage( + "TOKEN GUID MATCH | "..unit.. + " | class "..tostring(class).. + " | frame "..tostring(frame and "FOUND" or "nil") + ) + end + + if apiTargetPlate and frame == apiTargetPlate then + frameMatchUnit = unit + DEFAULT_CHAT_FRAME:AddMessage( + "TOKEN FRAME MATCH | "..unit.. + " | guid "..tostring(guid or "nil").. + " | class "..tostring(class) + ) + end + end + end + + DEFAULT_CHAT_FRAME:AddMessage( + "nameplateN | existing "..tostring(existing).. + " | guidMatch "..tostring(guidMatchUnit or "NONE").. + " | frameMatch "..tostring(frameMatchUnit or "NONE") + ) +end + +SLASH_BLIZZNAMEPLATESPLUS1 = "/bnp" +SlashCmdList["BLIZZNAMEPLATESPLUS"] = function(msg) + msg = string.lower(msg or "") + + if msg == "" or msg == "config" or msg == "options" then + if BNP.ToggleOptions then BNP:ToggleOptions() end + elseif msg == "spellprobe" or msg == "spellbook" then + if BNP.SpellbookProbe then BNP:SpellbookProbe() else BNP:Print("Spellbook probe is not loaded.") end + elseif msg == "spellprobe corruption" then + if BNP.SpellbookProbe then BNP:SpellbookProbe("corruption") end + elseif msg == "spellprobe agony" then + if BNP.SpellbookProbe then BNP:SpellbookProbe("agony") end + elseif msg == "spellprobe siphon" then + if BNP.SpellbookProbe then BNP:SpellbookProbe("siphon") end + elseif msg == "unknown" or msg == "learn" then + if BNP.PrintUnknownBeta then BNP:PrintUnknownBeta(false) else BNP:Print("Unknown collector is not loaded.") end + elseif msg == "unknown verbose" or msg == "learn verbose" then + if BNP.PrintUnknownBeta then BNP:PrintUnknownBeta(true) else BNP:Print("Unknown collector is not loaded.") end + elseif msg == "unknown reset" or msg == "learn reset" then + if BNP.ResetUnknownBeta then BNP:ResetUnknownBeta() end + BNP.unknownDirectEvents = {} + elseif msg == "auras" or msg == "targetauras" then + if BNP.PrintTargetAuraProbe then + BNP:PrintTargetAuraProbe() + else + BNP:Print("Target aura probe is not loaded.") + end + elseif msg == "raidcheck" then + local exists, guid = UnitExists("target") + DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffBNP RAIDCHECK|r") + if not exists or not guid then + DEFAULT_CHAT_FRAME:AddMessage("No target / no target GUID.") + return + end + DEFAULT_CHAT_FRAME:AddMessage("Target: "..tostring(UnitName("target") or "?").." | GUID: "..tostring(guid)) + local cache=BNP.guidAuras and BNP.guidAuras[guid] + local n=0 + if cache then + local key,aura + for key,aura in pairs(cache) do + if type(aura)=="table" and aura.spellID then + n=n+1 + local mode = aura.castPrimary and "CAST" or (aura.liveConfirmed and "LIVE" or "?") + DEFAULT_CHAT_FRAME:AddMessage( + "CACHE | "..tostring(key).." | ID "..tostring(aura.spellID).." | "..mode.. + " | stacks "..tostring(aura.stacks or 0).. + " | left "..(aura.expires and string.format("%.1f", aura.expires-GetTime()) or "?") + ) + end + end + end + if n==0 then DEFAULT_CHAT_FRAME:AddMessage("CACHE: EMPTY") end + local svState = BNP.svStateByGUID and BNP.svStateByGUID[guid] + if svState then + DEFAULT_CHAT_FRAME:AddMessage( + "SV STATE | present "..tostring(svState.present and 1 or 0).. + " | ID "..tostring(svState.spellID or "?").. + " | left "..(svState.expires and string.format("%.1f", svState.expires-GetTime()) or "?").. + " | refreshed "..(svState.lastRefresh and string.format("%.1fs ago", GetTime()-svState.lastRefresh) or "?") + ) + else + DEFAULT_CHAT_FRAME:AddMessage("SV STATE: none") + end + + -- Renderer-side SV diagnostics: tells us what the actual visible icon has + -- consumed from the GUID state, not just what the cache contains. + local rendered = false + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and plate.GetName and plate:GetName(1) == guid then + local c = plate.BNPAuraContainer + if c and c.icons then + local ri + for ri = 1, table.getn(c.icons) do + local icon = c.icons[ri] + if icon and icon.BNPSVRenderedExpires then + DEFAULT_CHAT_FRAME:AddMessage( + "SV RENDER | icon "..tostring(ri).. + " | text "..tostring(icon.timer and icon.timer:GetText() or "?").. + " | left "..string.format("%.1f", (icon.BNPSVRenderedExpires or GetTime())-GetTime()).. + " | rev "..tostring(icon.BNPSVRenderRevision or 0) + ) + rendered = true + break + end + end + end + end + end + if not rendered then DEFAULT_CHAT_FRAME:AddMessage("SV RENDER: no visible SV icon") end + + local pending = BNP.pendingAuras and BNP.pendingAuras[guid] + if pending then + DEFAULT_CHAT_FRAME:AddMessage("-- PENDING FOR TARGET --") + local pk,pv + for pk,pv in pairs(pending) do + if type(pv)=="table" then + DEFAULT_CHAT_FRAME:AddMessage( + "PENDING | "..tostring(pk).. + " | ID "..tostring(pv.spellID or "?").. + " | age "..string.format("%.1fs", GetTime()-(pv.created or GetTime())) + ) + end + end + else + DEFAULT_CHAT_FRAME:AddMessage("PENDING: none") + end + + DEFAULT_CHAT_FRAME:AddMessage("-- LAST TRACKED CAST EVENTS --") + local trace=BNP.raidTrace or {} + local start=math.max(1,table.getn(trace)-14) + local ti + for ti=start,table.getn(trace) do + local e=trace[ti] + DEFAULT_CHAT_FRAME:AddMessage( + tostring(e.stage).. + " | "..tostring(e.key or "-").. + " | ID "..tostring(e.spellID or "?").. + " | GUID "..tostring(e.guid or "nil").. + " | "..string.format("%.1fs ago",GetTime()-(e.time or GetTime())) + ) + end + + DEFAULT_CHAT_FRAME:AddMessage("-- LIVE TARGET AURAS (up to 64) --") + local i + for i=1,64 do + local tex,stacks,dtype,id=UnitDebuff("target",i) + if not tex then break end + local name=(id and SpellInfo and SpellInfo(id)) or "?" + DEFAULT_CHAT_FRAME:AddMessage("LIVE "..tostring(i).." | ID "..tostring(id or "?").." | "..tostring(name).." | stacks "..tostring(stacks or 0)) + end + + local svp = BNP.svProbe + if svp then + DEFAULT_CHAT_FRAME:AddMessage("-- SV PROBE --") + local last = svp.last + if last then + DEFAULT_CHAT_FRAME:AddMessage( + "SV LIVE | "..tostring(last.reason or "?").." | unit "..tostring(last.unit or "?").. + " | idx "..tostring(last.index or "?").." | ID "..tostring(last.spellID or "?").. + " | stacks "..tostring(last.stacks or 0) + ) + DEFAULT_CHAT_FRAME:AddMessage( + "RET a3="..tostring(last.a3).." a5="..tostring(last.a5).." a6="..tostring(last.a6).. + " a7="..tostring(last.a7).." a8="..tostring(last.a8).." a9="..tostring(last.a9).." a10="..tostring(last.a10) + ) + else + DEFAULT_CHAT_FRAME:AddMessage("SV LIVE: no captured snapshot") + end + local ev = svp.events or {} + local es = math.max(1, table.getn(ev)-9) + local ei + for ei=es,table.getn(ev) do + local e=ev[ei] + DEFAULT_CHAT_FRAME:AddMessage( + tostring(e.stage).." | ID "..tostring(e.spellID or "?").." | GUID "..tostring(e.guid or "nil").. + " | "..tostring(e.extra or "-").." | "..string.format("%.1fs ago",GetTime()-(e.time or GetTime())) + ) + end + + DEFAULT_CHAT_FRAME:AddMessage("-- SV RAW WINDOW --") + local raw = svp.raw or {} + if table.getn(raw) == 0 then + DEFAULT_CHAT_FRAME:AddMessage("RAW: no matching lines captured") + else + local rs = math.max(1, table.getn(raw)-13) + local ri + for ri=rs,table.getn(raw) do + local r=raw[ri] + DEFAULT_CHAT_FRAME:AddMessage( + "RAW "..string.format("%.1fs",GetTime()-(r.time or GetTime())).. + " | "..tostring(r.event or "?").." | "..tostring(r.raw or "") + ) + end + end + end + elseif msg == "direct" then + if BNP.PrintUnknownDirectEvents then + BNP:PrintUnknownDirectEvents() + else + BNP:Print("Direct event collector is not loaded.") + end + elseif msg == "spelldb" or msg == "audit" then + if BNP.PrintSpellDBAudit then BNP:PrintSpellDBAudit() else BNP:Print("SpellDB audit is not loaded.") end + elseif msg == "spelldb reset" or msg == "audit reset" then + if BNP.ResetSpellDBAudit then BNP:ResetSpellDBAudit() end + elseif msg == "guid" or msg == "guiddebug" or msg == "diag" then + if BNP.PrintGUIDDiagnostic then BNP:PrintGUIDDiagnostic() else BNP:Print("GUID diagnostics are not loaded.") end + elseif msg == "dragontest" then + BNP_ToggleDirectDragonRenderTest() + elseif msg == "eliteinfo" then + BNP_PrintEliteInfo() + elseif msg == "eliteauto" then + BNP:Print("Elite auto | ticks " .. tostring(BNP_EliteAutoState.ticks) .. " | visible " .. tostring(BNP_EliteAutoState.visible) .. " | elite " .. tostring(BNP_EliteAutoState.elite) .. " | shown " .. tostring(BNP_EliteAutoState.shown) .. " | lastClass " .. tostring(BNP_EliteAutoState.lastClass) .. " | error " .. tostring(BNP_EliteAutoState.lastError)) + elseif msg == "status" then + local visible = 0 + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() then visible = visible + 1 end + end + BNP:Print("registered: " .. BNP.detected .. ", visible: " .. visible .. ", test spell: " .. tostring(BNP.corruptionName or "not found")) + elseif msg == "cache" then + local name = UnitName("target") + if not name then + BNP:Print("No target selected.") + return + end + local remaining = BNP.FindCachedCorruption and BNP.FindCachedCorruption(name) + BNP:Print("Cache for " .. name .. ": " .. tostring(remaining or "no Corruption found")) + elseif msg == "castbars on" then + if BNP.SetCastbarsEnabled then BNP:SetCastbarsEnabled(true) end + elseif msg == "castbars off" then + if BNP.SetCastbarsEnabled then BNP:SetCastbarsEnabled(false) end + elseif msg == "scale" then + BNP:Print("Current nameplate scale: " .. tostring(BNP:GetNameplateScale())) + elseif msg == "scale reset" then + if BNP.ResetNameplateScale then BNP:ResetNameplateScale() end + elseif string.find(msg, "^scale%s+") then + local _, _, value = string.find(msg, "^scale%s+([0-9%.]+)") + if BNP.SetNameplateScale then BNP:SetNameplateScale(value) end + elseif msg == "classcolors on" then + BNP_DB = BNP_DB or {} + BNP_DB.classColors = true + BNP:Print("Class colors enabled.") + elseif msg == "classcolors off" then + BNP_DB = BNP_DB or {} + BNP_DB.classColors = false + BNP:Print("Class colors disabled.") + elseif msg == "debug on" then + BNP:SetDebug(true) + elseif msg == "debug off" then + BNP:SetDebug(false) + elseif msg == "debug" then + BNP:SetDebug(not BNP.debugEnabled) + else + BNP:Print("Commands: /bnp unknown, /bnp direct, /bnp spellprobe, /bnp spelldb, /bnp guid, /bnp dragontest, /bnp eliteinfo, /bnp eliteauto, /bnp status, /bnp castbars on/off, /bnp scale <0.5-1.5>, /bnp scale reset, /bnp classcolors on/off, /bnp debug on, /bnp debug off") + end +end diff --git a/modules/debug.lua b/modules/debug.lua new file mode 100644 index 0000000..7cd623a --- /dev/null +++ b/modules/debug.lua @@ -0,0 +1,43 @@ +if not BNP.libnameplate then return end + +local function CreateMarker(plate) + if not plate then return end + BNP:RegisterPlate(plate) + + if not plate.BNPMarker then + local marker = CreateFrame("Frame", nil, plate) + marker:SetWidth(14) + marker:SetHeight(14) + marker:SetPoint("BOTTOM", plate, "TOP", 0, 34) + marker:SetFrameLevel(30) + + marker.texture = marker:CreateTexture(nil, "OVERLAY") + marker.texture:SetAllPoints(marker) + marker.texture:SetTexture("Interface\\Buttons\\WHITE8X8") + marker.texture:SetVertexColor(1, 0, 0, 1) + + marker.text = marker:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + marker.text:SetPoint("CENTER", marker, "CENTER", 0, 0) + marker.text:SetText("+") + marker.text:SetTextColor(1, 1, 1) + plate.BNPMarker = marker + end + + if BNP.debugEnabled then plate.BNPMarker:Show() else plate.BNPMarker:Hide() end +end + +table.insert(BNP.libnameplate.OnInit, CreateMarker) +table.insert(BNP.libnameplate.OnShow, CreateMarker) + +function BNP:SetDebug(enabled) + BNP.debugEnabled = enabled + BNP_DB = BNP_DB or {} + BNP_DB.debugEnabled = enabled + local plate + for plate in pairs(BNP.plates) do + if plate.BNPMarker then + if enabled then plate.BNPMarker:Show() else plate.BNPMarker:Hide() end + end + end + BNP:Print("Debug-Marker " .. (enabled and "aktiviert" or "deaktiviert") .. ".") +end diff --git a/modules/fullalpha.lua b/modules/fullalpha.lua new file mode 100644 index 0000000..ab25510 --- /dev/null +++ b/modules/fullalpha.lua @@ -0,0 +1,815 @@ +BNP = BNP or {} + +local NON_TARGET_ALPHA = 0.55 +local TARGET_RESOLVE_CACHE_TIME = 0.05 +local ALPHA_IDENTITY_HOLD_TIME = 0.20 +local FOREIGN_TAG_SCAN_INTERVAL = 0.10 + +local targetResolveCacheGUID = nil +local targetResolveCacheAt = -100 +local targetResolveCacheFound = false +local classicTargetCacheGUID = nil +local classicTargetCacheAt = -100 +local classicTargetCachePlate = nil +local classicTargetCacheAvailable = false +local targetGUIDCacheAt = -100 +local targetGUIDCacheValue = nil +local TARGET_GUID_CACHE_TIME = 0.02 + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local token = plate:GetName(1) + if not token then return nil end + local exists, guid = UnitExists(token) + if exists and guid then return guid end + return token +end + +local function GetVerifiedPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local token = plate:GetName(1) + if not token then return nil end + + -- Alpha must only be lowered after SuperWoW has positively resolved the + -- projected plate token. While rotating the camera Blizzard can recycle and + -- re-show a plate before its token is live again. During that short window + -- treating nil/stale identity as "non-target" caused random low-alpha pops. + local exists, guid = UnitExists(token) + if exists and guid then return guid end + return nil +end + +local function GetTargetGUID() + -- Many per-frame plate paths ask for the same target GUID. Cache it for a + -- tiny window (roughly one rendered frame); PLAYER_TARGET_CHANGED explicitly + -- invalidates this cache, so target switches still update immediately. + local now = GetTime() + if now - targetGUIDCacheAt < TARGET_GUID_CACHE_TIME then + return targetGUIDCacheValue + end + + local exists, guid = UnitExists("target") + if exists and guid then targetGUIDCacheValue = guid else targetGUIDCacheValue = nil end + targetGUIDCacheAt = now + return targetGUIDCacheValue +end + +local function InvalidateTargetResolution() + targetResolveCacheGUID = nil + targetResolveCacheAt = -100 + targetResolveCacheFound = false + classicTargetCacheGUID = nil + classicTargetCacheAt = -100 + classicTargetCachePlate = nil + classicTargetCacheAvailable = false + targetGUIDCacheAt = -100 + targetGUIDCacheValue = nil +end + +local function GetClassicAPITargetPlate(targetGUID) + local now = GetTime() + if classicTargetCacheGUID == targetGUID and + now - classicTargetCacheAt < TARGET_RESOLVE_CACHE_TIME then + return classicTargetCachePlate, classicTargetCacheAvailable + end + + local available = C_NamePlate and + type(C_NamePlate.GetNamePlateForUnit) == "function" + local targetPlate = nil + + if available then + local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target") + if ok then targetPlate = plate end + end + + classicTargetCacheGUID = targetGUID + classicTargetCacheAt = now + classicTargetCachePlate = targetPlate + classicTargetCacheAvailable = available and true or false + return targetPlate, classicTargetCacheAvailable +end + +local function GetPlateToken(plate) + if not plate or not plate.GetName then return nil end + return plate:GetName(1) +end + +local function GetRegionText(region) + if not region or not region.GetText then return nil end + return region:GetText() +end + +local function PlateMatchesTargetVisual(plate) + if not UnitName then return false end + + local targetName = UnitName("target") + local plateName = GetRegionText(plate and plate.name) + if not targetName or not plateName or targetName ~= plateName then + return false + end + + -- Same-name mobs are common. A known, different level therefore rules the + -- plate out. If the level is hidden/"??", keeping another same-name plate at + -- full alpha is the safe failure mode; it is preferable to dimming the real + -- target because a projected token still carries its previous GUID. + if UnitLevel then + local targetLevel = UnitLevel("target") + local plateLevel = tonumber(GetRegionText(plate and plate.level)) + if targetLevel and targetLevel > 0 and plateLevel and plateLevel > 0 and + targetLevel ~= plateLevel then + return false + end + end + + return true +end + +local function IsPlateCurrentTarget(plate, targetGUID) + if not plate or not targetGUID then return false end + + local token = GetPlateToken(plate) + if token and UnitIsUnit then + local ok, sameUnit = pcall(UnitIsUnit, token, "target") + if ok and sameUnit then return true end + end + + local plateGUID = GetVerifiedPlateGUID(plate) + if plateGUID and plateGUID == targetGUID then return true end + + -- SuperWoW can briefly return the old, but still syntactically valid, GUID + -- for a recycled projected nameplate. The displayed Blizzard name/level has + -- already switched by then and protects the actual target from being dimmed. + return PlateMatchesTargetVisual(plate) +end + +local function HasResolvedVisibleTarget(targetGUID) + local now = GetTime() + if targetResolveCacheGUID == targetGUID and + now - targetResolveCacheAt < TARGET_RESOLVE_CACHE_TIME then + return targetResolveCacheFound + end + + local found = false + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate.IsShown and plate:IsShown() and + IsPlateCurrentTarget(plate, targetGUID) then + found = true + break + end + end + + targetResolveCacheGUID = targetGUID + targetResolveCacheAt = now + targetResolveCacheFound = found + return found +end + +local function IsForeignTagged(plate) + if not plate or not plate.GetName then return false end + local token = plate:GetName(1) + if not token then return false end + + local exists = UnitExists(token) + if not exists then return false end + + if UnitCanAttack and not UnitCanAttack("player", token) then + return false + end + + if not UnitIsTapped or not UnitIsTapped(token) then + return false + end + + if UnitIsTappedByPlayer and UnitIsTappedByPlayer(token) then + return false + end + + return true +end + +local function DesiredAlpha(plate, targetGUID) + -- No selected target: keep all nameplates fully visible. + targetGUID = targetGUID or GetTargetGUID() + if not targetGUID then return 1 end + + local classicTargetPlate, classicTargetAvailable = + GetClassicAPITargetPlate(targetGUID) + + if classicTargetAvailable then + -- Exact ClassicAPI frame resolution makes the old transition hold + -- unnecessary. More importantly, it lets us keep alpha stable even when a + -- crowded projected nameplate token briefly stops resolving. The previous + -- code treated an unresolved non-target token as alpha 1 and then faded it + -- again on the next tick, which made both the plate and its aura children + -- visibly pulse/flash in large stacked raids. + if not classicTargetPlate then return 1 end + + -- The exact target frame is authoritative and must always stay opaque, + -- even while its SuperWoW token is transiently unavailable. + if plate == classicTargetPlate then return 1 end + + -- GUID equality remains as a conservative fallback in case a client build + -- returns a wrapper/alias for the target nameplate rather than the exact + -- frame object tracked by BNP. + local plateGUID = GetVerifiedPlateGUID(plate) + if plateGUID and plateGUID == targetGUID then return 1 end + + -- Once ClassicAPI has positively identified the real target frame, every + -- other visible plate is safely a non-target. Do not bounce it to alpha 1 + -- merely because its projected token was nil for one raid-stack tick. + if BNP.GetNonTargetAlpha then + return BNP:GetNonTargetAlpha() + end + return 1 + end + + -- On target changes and recycled OnShow transitions, the projected token can + -- temporarily expose its previous valid GUID. Keep the plate neutral until + -- Blizzard/SuperWoW have had time to publish the new identity. This is only + -- the legacy path used when exact ClassicAPI nameplate resolution is absent. + if plate.BNPAlphaIdentityHoldUntil and + GetTime() < plate.BNPAlphaIdentityHoldUntil then + return 1 + end + + -- GUID equality is preferred. UnitIsUnit and the displayed Blizzard + -- name/level are conservative fallbacks for the stale-valid-GUID window. + if IsPlateCurrentTarget(plate, targetGUID) then + return 1 + end + + -- Do not dim anything until the selected target itself can be associated + -- with a currently visible plate. Uncertainty must fail to full opacity. + if not HasResolvedVisibleTarget(targetGUID) then + return 1 + end + + -- A recycled/reappearing plate can exist for a moment before SuperWoW has + -- attached a trustworthy GUID again. Never dim an unidentified plate; doing + -- so is what caused the random low-alpha flash when rotating the camera. + local plateGUID = GetVerifiedPlateGUID(plate) + if not plateGUID then return 1 end + + -- Current target always stays fully visible. + if plateGUID == targetGUID then + return 1 + end + + -- Only positively identified non-target plates use the configured alpha. + if BNP.GetNonTargetAlpha then + return BNP:GetNonTargetAlpha() + end + + return 1 +end + +local function SetLevelSafe(frame, level) + if frame and frame.SetFrameLevel and level then + if frame:GetFrameLevel() ~= level then + frame:SetFrameLevel(level) + end + end +end + +local function ApplyTargetFrameLevel(plate) + if not plate or not plate:IsShown() then return end + + -- IMPORTANT: + -- Never change the FrameStrata of projected Vanilla nameplates. + -- Their native client/world layer keeps them behind Blizzard UI frames. + -- Permanent target priority is therefore implemented only with FrameLevel. + + if plate.BNPBaseFrameLevel == nil then + plate.BNPBaseFrameLevel = plate:GetFrameLevel() or 0 + end + + local wrapper = plate.BNPScaleWrapper + if wrapper and plate.BNPBaseWrapperLevel == nil then + plate.BNPBaseWrapperLevel = wrapper:GetFrameLevel() or plate.BNPBaseFrameLevel + end + + local healthbar = plate.healthbar + if healthbar and plate.BNPBaseHealthbarLevel == nil then + plate.BNPBaseHealthbarLevel = healthbar:GetFrameLevel() or 1 + end + + -- ApplyTargetAlpha runs immediately before this function and publishes the + -- exact ClassicAPI/SuperWoW target identity on the plate. + local target = plate.BNPIsCurrentTarget and true or false + + local plateLevel = plate.BNPBaseFrameLevel + local wrapperLevel = plate.BNPBaseWrapperLevel or plateLevel + local healthbarLevel = plate.BNPBaseHealthbarLevel or 1 + + if target then + -- Raise the target only relative to other nameplates while preserving the + -- native projected-nameplate strata beneath the Blizzard interface. + plateLevel = plateLevel + 50 + wrapperLevel = wrapperLevel + 50 + healthbarLevel = healthbarLevel + 50 + end + + SetLevelSafe(plate, plateLevel) + if wrapper then + SetLevelSafe(wrapper, wrapperLevel) + end + if healthbar then + -- The Blizzard border/name are regions of the raised wrapper, but the + -- actual StatusBar is its own frame and must be raised explicitly. + SetLevelSafe(healthbar, healthbarLevel) + end + + -- Keep BNP's castbar above the healthbar (and therefore above the enlarged + -- target-glow texture, which belongs to the healthbar). The target plate is + -- raised dynamically by +50 levels, so a castbar that was created before a + -- target switch must follow that new level too. Otherwise the glow can draw + -- over the castbar when Target Glow Size is increased. + local castbar = plate.BNPCastbar + if castbar then + local castbarLevel = healthbarLevel + 1 + SetLevelSafe(castbar, castbarLevel) + if castbar.icon then + SetLevelSafe(castbar.icon, castbarLevel + 1) + end + end +end + +local function ApplyForeignTagVisual(plate, forceScan) + if not plate or not plate:IsShown() then return end + + local bar = plate.healthbar + if not bar or not bar.SetStatusBarColor then return end + + -- UnitExists/UnitCanAttack/UnitIsTapped are relatively expensive when called + -- for every nameplate on every rendered frame. Cache the ownership result for + -- 0.10s, but still enforce the already-known grey color every frame so client + -- color rewrites cannot reintroduce the old grey/red flicker. + local now = GetTime() + local foreign = plate.BNPForeignTagCached + if forceScan or not plate.BNPForeignTagScanAt or + now - plate.BNPForeignTagScanAt >= FOREIGN_TAG_SCAN_INTERVAL then + foreign = IsForeignTagged(plate) and true or false + plate.BNPForeignTagCached = foreign + plate.BNPForeignTagScanAt = now + end + + if foreign then + if not plate.BNPForeignTagGrey then + local r, g, b, a = bar:GetStatusBarColor() + plate.BNPForeignTagOldR = r + plate.BNPForeignTagOldG = g + plate.BNPForeignTagOldB = b + plate.BNPForeignTagOldA = a + plate.BNPForeignTagGrey = true + end + + -- Neutral grey: visually distinct from Target Focus transparency. Only + -- rewrite the StatusBar when something actually changed its color. + local r, g, b = bar:GetStatusBarColor() + if math.abs((r or 0) - 0.45) > 0.01 or + math.abs((g or 0) - 0.45) > 0.01 or + math.abs((b or 0) - 0.45) > 0.01 then + bar:SetStatusBarColor(0.45, 0.45, 0.45, plate.BNPForeignTagOldA or 1) + end + elseif plate.BNPForeignTagGrey then + -- Restore the color that BNP/Shagu/class-color logic had before the mob + -- became foreign-tagged. Clear state so future color changes can be learned. + bar:SetStatusBarColor( + plate.BNPForeignTagOldR or 1, + plate.BNPForeignTagOldG or 0, + plate.BNPForeignTagOldB or 0, + plate.BNPForeignTagOldA or 1 + ) + plate.BNPForeignTagGrey = nil + plate.BNPForeignTagOldR = nil + plate.BNPForeignTagOldG = nil + plate.BNPForeignTagOldB = nil + plate.BNPForeignTagOldA = nil + end +end + +local function GetGlowVisualParent(plate) + if not plate then return nil end + + local anchor = plate.healthbar or plate + local visualParent = plate.BNPScaleWrapper + + if not visualParent and anchor and anchor.GetParent then + visualParent = anchor:GetParent() + end + + return visualParent or plate +end + +local function EnsureTargetGlow(plate) + if not plate then return nil end + if plate.BNPTargetGlow then return plate.BNPTargetGlow end + + local anchor = plate.healthbar or plate + if not anchor then return nil end + + -- Keep Blizzard's original healthbar/frame layering completely untouched. + -- The glow belongs to the healthbar itself on BACKGROUND, so it stays behind + -- the statusbar fill without raising/reparenting the Blizzard healthbar. + -- + -- Direct port of ShaguPlates' target glow geometry: + -- nameplate.glow = nameplate:CreateTexture(nil, "BACKGROUND") + -- glow anchored to health CENTER + -- width = health width + 60 + -- height = health height + 30 + -- + -- BNP's only intentional visual change is +3 px on X. + local glow = anchor:CreateTexture(nil, "BACKGROUND") + glow:SetTexture("Interface\\AddOns\\BlizzNameplatesPlus\\media\\shagu_target_glow.tga") + glow:SetPoint("CENTER", anchor, "CENTER", 3, 0) + + local width = anchor.GetWidth and anchor:GetWidth() or 120 + local height = anchor.GetHeight and anchor:GetHeight() or 8 + local glowSize = BNP.GetTargetGlowSize and BNP:GetTargetGlowSize() or 0 + glow:SetWidth(width + 60 + (glowSize * 2)) + glow:SetHeight(height + 30 + (glowSize * 2)) + + local r, g, b = 1, 1, 1 + if BNP.GetTargetGlowColor then + r, g, b = BNP:GetTargetGlowColor() + end + glow:SetVertexColor(r, g, b, 1) + glow:Hide() + + plate.BNPTargetGlow = glow + return glow +end + +local function ApplyTargetGlow(plate) + if not plate or not plate:IsShown() then return end + + local enabled = BNP.IsTargetFocusEnabled and BNP:IsTargetFocusEnabled() + local isTarget = plate.BNPIsCurrentTarget and true or false + + -- Do not create one glow texture for every nameplate. Only the actual target + -- needs it; previously even disabled/non-target plates allocated and updated + -- their own texture every frame. + if not enabled or not isTarget then + local existing = plate.BNPTargetGlow + if existing and existing.IsShown and existing:IsShown() then existing:Hide() end + return + end + + local glow = EnsureTargetGlow(plate) + if not glow then return end + + local anchor = plate.healthbar or plate + local width = anchor.GetWidth and anchor:GetWidth() or 120 + local height = anchor.GetHeight and anchor:GetHeight() or 8 + local glowSize = BNP.GetTargetGlowSize and BNP:GetTargetGlowSize() or 0 + + -- Geometry only changes when the healthbar dimensions or the user setting + -- changes. Avoid ClearAllPoints/SetPoint/SetWidth/SetHeight every frame. + if glow.BNPLastAnchor ~= anchor or + glow.BNPLastWidth ~= width or glow.BNPLastHeight ~= height or + glow.BNPLastSize ~= glowSize then + glow:ClearAllPoints() + glow:SetPoint("CENTER", anchor, "CENTER", 3, 0) + glow:SetWidth(width + 60 + (glowSize * 2)) + glow:SetHeight(height + 30 + (glowSize * 2)) + glow.BNPLastAnchor = anchor + glow.BNPLastWidth = width + glow.BNPLastHeight = height + glow.BNPLastSize = glowSize + end + + local r, g, b = 1, 1, 1 + if BNP.GetTargetGlowColor then r, g, b = BNP:GetTargetGlowColor() end + if glow.BNPLastR ~= r or glow.BNPLastG ~= g or glow.BNPLastB ~= b then + glow:SetVertexColor(r, g, b, 1) + glow.BNPLastR, glow.BNPLastG, glow.BNPLastB = r, g, b + end + + local glowOpacity = BNP.GetTargetGlowOpacity and BNP:GetTargetGlowOpacity() or 1 + if glow.BNPLastOpacity ~= glowOpacity then + glow:SetAlpha(glowOpacity) + glow.BNPLastOpacity = glowOpacity + end + + if not glow:IsShown() then glow:Show() end +end + + +local TARGET_ARROW_TEXTURES = { + chevron = { + thin = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_chevron_thin.tga", + thick = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_chevron_thick.tga", + }, + triangle = { + thin = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_triangle_thin.tga", + thick = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_triangle_thick.tga", + }, + slim_triangle = { + thin = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_slim_triangle_thin.tga", + thick = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_slim_triangle_thick.tga", + }, + double_chevron = { + thin = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_double_chevron_thin.tga", + thick = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_double_chevron_thick.tga", + }, + diamond_tip = { + thin = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_diamond_tip_thin.tga", + thick = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\target_arrow_diamond_tip_thick.tga", + }, +} + +local function GetTargetArrowTexture(style, thick) + local textures = TARGET_ARROW_TEXTURES[style] or TARGET_ARROW_TEXTURES.chevron + return thick and textures.thick or textures.thin +end + +local function GetTargetArrowOffsets(style) + local leftGap, rightGap, fallbackGap = -5, 2, 5 + if style == "double_chevron" then + leftGap, rightGap, fallbackGap = -7, 4, 7 + elseif style == "diamond_tip" then + leftGap, rightGap, fallbackGap = -6, 3, 6 + elseif style == "triangle" or style == "slim_triangle" then + leftGap, rightGap, fallbackGap = -5, 3, 5 + end + return leftGap, rightGap, fallbackGap +end + +local function GetRightTargetArrowSide(plate) + if not plate then return nil end + if plate.BNPDirectDragonTestFrame and plate.BNPDirectDragonTestFrame.IsShown and + plate.BNPDirectDragonTestFrame:IsShown() then + return plate.BNPDirectDragonTestFrame + elseif plate.levelicon and plate.levelicon.IsShown and plate.levelicon:IsShown() then + return plate.levelicon + elseif plate.level and plate.level.IsShown and plate.level:IsShown() then + return plate.level + end + return nil +end + +local function AnchorRightTargetArrow(plate, arrow, style) + if not plate or not arrow then return end + local anchor = plate.healthbar or plate + style = style or (BNP.GetTargetArrowStyle and BNP:GetTargetArrowStyle()) or "chevron" + local _, rightGap, fallbackGap = GetTargetArrowOffsets(style) + local side = GetRightTargetArrowSide(plate) + local anchorKey = side or anchor + + -- The level/elite objects move with the nameplate automatically. Re-anchor + -- only when the actual side object or selected style changes. + if arrow.BNPLastAnchorKey == anchorKey and arrow.BNPLastAnchorStyle == style then + return + end + + arrow:ClearAllPoints() + if side then + arrow:SetPoint("LEFT", side, "RIGHT", rightGap, -1) + else + arrow:SetPoint("LEFT", anchor, "RIGHT", fallbackGap, -1) + end + arrow.BNPLastAnchorKey = anchorKey + arrow.BNPLastAnchorStyle = style +end + +local function EnsureTargetArrows(plate) + if not plate then return nil, nil end + if plate.BNPTargetArrowLeft and plate.BNPTargetArrowRight then + return plate.BNPTargetArrowLeft, plate.BNPTargetArrowRight + end + + local anchor = plate.healthbar or plate + if not anchor or not anchor.CreateTexture then return nil, nil end + + local left = anchor:CreateTexture(nil, "OVERLAY") + left:SetTexture(GetTargetArrowTexture("chevron", false)) + left:SetWidth(14) + left:SetHeight(14) + left:SetTexCoord(0, 1, 0, 1) + left:SetPoint("RIGHT", anchor, "LEFT", -5, 0) + left.BNPLastAnchorStyle = "chevron" + left:Hide() + + local right = anchor:CreateTexture(nil, "OVERLAY") + right:SetTexture(GetTargetArrowTexture("chevron", false)) + right:SetWidth(14) + right:SetHeight(14) + right:SetTexCoord(1, 0, 0, 1) + AnchorRightTargetArrow(plate, right, "chevron") + right:Hide() + + plate.BNPTargetArrowLeft = left + plate.BNPTargetArrowRight = right + return left, right +end + +local function ApplyTargetArrows(plate) + if not plate or not plate:IsShown() then return end + + local enabled = BNP.AreTargetArrowsEnabled and BNP:AreTargetArrowsEnabled() + local totemIconOnly = plate.BNPTotemLastKey and + BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() + local isTarget = plate.BNPIsCurrentTarget and true or false + + -- As with Target Glow, arrow textures only need to exist on a plate that has + -- actually been the target. This avoids two permanent textures per nameplate. + if not enabled or totemIconOnly or not isTarget then + local oldLeft, oldRight = plate.BNPTargetArrowLeft, plate.BNPTargetArrowRight + if oldLeft and oldLeft.IsShown and oldLeft:IsShown() then oldLeft:Hide() end + if oldRight and oldRight.IsShown and oldRight:IsShown() then oldRight:Hide() end + return + end + + local left, right = EnsureTargetArrows(plate) + if not left or not right then return end + + local anchor = plate.healthbar or plate + local arrowSize = BNP.GetTargetArrowSize and BNP:GetTargetArrowSize() or 14 + local thick = BNP.AreTargetArrowsThick and BNP:AreTargetArrowsThick() + local style = BNP.GetTargetArrowStyle and BNP:GetTargetArrowStyle() or "chevron" + local texture = GetTargetArrowTexture(style, thick) + local leftGap = GetTargetArrowOffsets(style) + + if left.BNPLastTexture ~= texture then + left:SetTexture(texture) + right:SetTexture(texture) + left.BNPLastTexture = texture + right.BNPLastTexture = texture + end + + if left.BNPLastSize ~= arrowSize then + left:SetWidth(arrowSize) + left:SetHeight(arrowSize) + right:SetWidth(arrowSize) + right:SetHeight(arrowSize) + left.BNPLastSize = arrowSize + right.BNPLastSize = arrowSize + end + + -- TexCoords are constant after creation; only anchors depend on style. + if left.BNPLastAnchorStyle ~= style then + left:ClearAllPoints() + left:SetPoint("RIGHT", anchor, "LEFT", leftGap, 0) + left.BNPLastAnchorStyle = style + end + AnchorRightTargetArrow(plate, right, style) + + local r, g, b = 1, 1, 1 + if BNP.GetTargetArrowColor then + r, g, b = BNP:GetTargetArrowColor() + elseif BNP.GetTargetGlowColor then + r, g, b = BNP:GetTargetGlowColor() + end + if left.BNPLastR ~= r or left.BNPLastG ~= g or left.BNPLastB ~= b then + left:SetVertexColor(r, g, b, 1) + right:SetVertexColor(r, g, b, 1) + left.BNPLastR, left.BNPLastG, left.BNPLastB = r, g, b + right.BNPLastR, right.BNPLastG, right.BNPLastB = r, g, b + end + + if not left:IsShown() then left:Show() end + if not right:IsShown() then right:Show() end +end + + +local function ApplyTargetAlpha(plate) + if not plate or not plate:IsShown() then return end + local targetGUID = GetTargetGUID() + local totemIconOnly = plate.BNPTotemLastKey and + BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() + local wanted = totemIconOnly and 1 or DesiredAlpha(plate, targetGUID) + if plate:GetAlpha() ~= wanted then + plate:SetAlpha(wanted) + end + + -- Publish the same resolved target identity for detached aura containers. + -- They cannot inherit it through their shared WorldFrame parent. + local targetPlate = nil + local targetAvailable = false + local isCurrentTarget = false + if targetGUID then + targetPlate, targetAvailable = GetClassicAPITargetPlate(targetGUID) + if targetAvailable and targetPlate then + isCurrentTarget = plate == targetPlate + if not isCurrentTarget then + local plateGUID = GetVerifiedPlateGUID(plate) + isCurrentTarget = plateGUID and plateGUID == targetGUID + end + else + isCurrentTarget = IsPlateCurrentTarget(plate, targetGUID) + end + end + plate.BNPIsCurrentTarget = isCurrentTarget and true or false + + -- ClassicAPI returns the underlying target nameplate frame itself. Force it + -- opaque after every alpha application. Even if SuperWoW momentarily reports + -- the recycled frame's previous valid GUID, both writes happen in the same + -- Lua update and the real target finishes the frame at alpha 1. + if targetGUID then + if targetAvailable and targetPlate and targetPlate.GetAlpha and targetPlate.SetAlpha then + targetPlate.BNPIsCurrentTarget = true + local ok, alpha = pcall(targetPlate.GetAlpha, targetPlate) + if not ok or alpha ~= 1 then + pcall(targetPlate.SetAlpha, targetPlate, 1) + end + end + end +end + +local function InstallAlphaGuard(plate) + plate = plate or this + if not plate or plate.BNPFullAlphaGuard then return end + + plate.BNPAlphaIdentityHoldUntil = GetTime() + ALPHA_IDENTITY_HOLD_TIME + + local oldOnUpdate = plate:GetScript("OnUpdate") + plate:SetScript("OnUpdate", function() + if oldOnUpdate then oldOnUpdate() end + local current = this or plate + + -- Apply the optional Y-offset after Blizzard has had a chance to update + -- the projected nameplate for this frame. This uses the OnUpdate BNP + -- already needs for alpha/glow/target-level handling. + if BNP.MaintainNameplateYOffset and BNP_DB and + BNP_DB.nameplateYOffset and BNP_DB.nameplateYOffset > 0 then + BNP:MaintainNameplateYOffset(current) + end + + ApplyTargetAlpha(current) + ApplyTargetGlow(current) + ApplyTargetArrows(current) + ApplyForeignTagVisual(current) + ApplyTargetFrameLevel(current) + end) + + plate.BNPFullAlphaGuard = true + ApplyTargetAlpha(plate) +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + InstallAlphaGuard(plate or this) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + InvalidateTargetResolution() + current.BNPAlphaIdentityHoldUntil = GetTime() + ALPHA_IDENTITY_HOLD_TIME + if current.GetAlpha and current.SetAlpha and current:GetAlpha() ~= 1 then + current:SetAlpha(1) + end + InstallAlphaGuard(current) + ApplyTargetAlpha(current) + ApplyTargetGlow(current) + ApplyTargetArrows(current) + ApplyForeignTagVisual(current, true) + ApplyTargetFrameLevel(current) + end) +end + +local targetEvents = CreateFrame("Frame") +targetEvents:RegisterEvent("PLAYER_TARGET_CHANGED") +targetEvents:SetScript("OnEvent", function() + InvalidateTargetResolution() + + local targetGUID = GetTargetGUID() + local _, classicTargetAvailable = GetClassicAPITargetPlate(targetGUID) + local holdUntil = GetTime() + ALPHA_IDENTITY_HOLD_TIME + local plate + for plate in pairs(BNP.plates or {}) do + if classicTargetAvailable then + plate.BNPAlphaIdentityHoldUntil = nil + ApplyTargetAlpha(plate) + else + plate.BNPAlphaIdentityHoldUntil = holdUntil + if plate.GetAlpha and plate.SetAlpha and plate:GetAlpha() ~= 1 then + plate:SetAlpha(1) + end + end + ApplyTargetFrameLevel(plate) + ApplyTargetArrows(plate) + end + + if BNP.RefreshAuraPriorityAlpha then BNP:RefreshAuraPriorityAlpha() end +end) + +function BNP:RefreshTargetFocus() + InvalidateTargetResolution() + local plate + for plate in pairs(self.plates or {}) do + ApplyTargetAlpha(plate) + ApplyTargetGlow(plate) + ApplyTargetArrows(plate) + ApplyForeignTagVisual(plate) + end +end + +function BNP:RefreshNonTargetAlpha() + InvalidateTargetResolution() + local plate + for plate in pairs(self.plates or {}) do + ApplyTargetAlpha(plate) + end +end diff --git a/modules/guiddebug.lua b/modules/guiddebug.lua new file mode 100644 index 0000000..0eedb2d --- /dev/null +++ b/modules/guiddebug.lua @@ -0,0 +1,73 @@ +-- SuperWoW GUID probe. +-- This module does not alter aura tracking or rendering. It only inspects the +-- extended UnitExists return value and the unit token exposed by nameplates. + +local function CleanText(text) + if not text then return nil end + text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") + text = string.gsub(text, "|r", "") + return text +end + +local function SafePlateToken(plate) + if not plate or not plate.GetName then return nil end + + -- SuperWoW extends frame:GetName(index). ShaguTweaks already uses + -- plate:GetName(1) for its SuperWoW nameplate castbar implementation. + local ok, token = pcall(function() return plate:GetName(1) end) + if ok then return token end + return nil +end + +local function UnitExistsData(unit) + if not unit then return nil, nil end + local ok, exists, guid = pcall(UnitExists, unit) + if not ok then return nil, nil end + return exists, guid +end + +function BNP:GetSuperWoWGUID(unit) + local exists, guid = UnitExistsData(unit) + if exists and guid then return guid end + return nil +end + +function BNP:GetPlateSuperWoWData(plate) + local token = SafePlateToken(plate) + local exists, guid = UnitExistsData(token) + return token, exists, guid +end + +function BNP:PrintGUIDDiagnostic() + BNP:Print("=== SuperWoW GUID Diagnose ===") + + local targetName = UnitName("target") + local targetExists, targetGUID = UnitExistsData("target") + BNP:Print("Target: " .. tostring(targetName or "kein Ziel")) + BNP:Print("UnitExists(target): " .. tostring(targetExists) .. " | GUID: " .. tostring(targetGUID)) + + local visible = 0 + local sameName = 0 + local guidMatches = 0 + local index = 0 + + for plate in pairs(BNP.plates) do + if plate and plate:IsShown() then + visible = visible + 1 + local plateName = plate.name and plate.name.GetText and CleanText(plate.name:GetText()) or nil + + if not targetName or plateName == targetName then + sameName = sameName + 1 + index = index + 1 + local token, exists, guid = BNP:GetPlateSuperWoWData(plate) + if targetGUID and guid and targetGUID == guid then guidMatches = guidMatches + 1 end + + BNP:Print("Plate " .. index .. ": " .. tostring(plateName)) + BNP:Print(" Token: " .. tostring(token) .. " | Exists: " .. tostring(exists)) + BNP:Print(" GUID: " .. tostring(guid) .. " | Target-Match: " .. tostring(targetGUID and guid == targetGUID or false)) + end + end + end + + BNP:Print("Sichtbar: " .. visible .. " | gleicher Name: " .. sameName .. " | GUID-Matches: " .. guidMatches) +end diff --git a/modules/healthpercent.lua b/modules/healthpercent.lua new file mode 100644 index 0000000..d4fbe8e --- /dev/null +++ b/modules/healthpercent.lua @@ -0,0 +1,243 @@ +BNP = BNP or {} + +local function Enabled() + return BNP.IsHealthTextEnabled and BNP:IsHealthTextEnabled() +end + +local function CreateHealthText(plate) + if plate.BNPHealthPercent then return plate.BNPHealthPercent end + if not plate or not plate.healthbar then return nil end + + -- Parent the text directly to the Blizzard health StatusBar. This keeps it + -- above the bar texture and automatically follows every frame-level change + -- made for Target Glow / permanent target priority. No separate holder can + -- become stale during target switches or nameplate recycling. + local anchor = plate.healthbar + local text = anchor:CreateFontString(nil, "OVERLAY") + text:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE") + text:SetPoint("CENTER", anchor, "CENTER", 3, 0) + text:SetTextColor(1, 1, 1) + text:Hide() + + plate.BNPHealthPercent = text + return text +end + +local function GetPercentFromBar(plate) + if not plate or not plate.healthbar then return nil end + + local minValue, maxValue = plate.healthbar:GetMinMaxValues() + local value = plate.healthbar:GetValue() + + if value and minValue and maxValue and maxValue > minValue then + local pct = ((value - minValue) / (maxValue - minValue)) * 100 + return math.floor(pct + 0.5) + end + + return nil +end + +local function GetPercentFromUnit(plate) + if not plate or not plate.GetName then return nil end + + local token = plate:GetName(1) + if not token or not UnitExists(token) then return nil end + if not UnitHealth or not UnitHealthMax then return nil end + + local health = UnitHealth(token) + local maxHealth = UnitHealthMax(token) + + if health and maxHealth and maxHealth > 0 then + return math.floor((health / maxHealth) * 100 + 0.5) + end + + return nil +end + +local function GetHealthFromUnit(plate) + if not plate or not plate.GetName then return nil, nil end + + local token = plate:GetName(1) + if not token or not UnitExists(token) then return nil, nil end + if not UnitHealth or not UnitHealthMax then return nil, nil end + + local health = tonumber(UnitHealth(token)) + local maxHealth = tonumber(UnitHealthMax(token)) + + if health and maxHealth and maxHealth > 0 then + return health, maxHealth + end + + return nil, nil +end + +local function GetHealthFromBar(plate) + if not plate or not plate.healthbar then return nil, nil end + + local minValue, maxValue = plate.healthbar:GetMinMaxValues() + local value = plate.healthbar:GetValue() + + value = tonumber(value) + minValue = tonumber(minValue) + maxValue = tonumber(maxValue) + + if value and minValue and maxValue and maxValue > minValue then + return value - minValue, maxValue - minValue + end + + return nil, nil +end + +local function FormatHealth(value) + value = tonumber(value) + if not value then return nil end + if value < 0 then value = 0 end + + -- Keep boss/player values readable inside the compact Blizzard healthbar. + if value >= 1000000 then + local shown = math.floor((value / 1000000) * 10 + 0.5) / 10 + return tostring(shown) .. "m" + elseif value >= 10000 then + return tostring(math.floor(value / 1000 + 0.5)) .. "k" + elseif value >= 1000 then + local shown = math.floor((value / 1000) * 10 + 0.5) / 10 + return tostring(shown) .. "k" + end + + return tostring(math.floor(value + 0.5)) +end + +local function UpdatePlate(plate) + if not plate or not plate.healthbar then return end + + local text = plate.BNPHealthPercent + + if not Enabled() or not plate:IsShown() then + if text then text:Hide() end + return + end + + text = text or CreateHealthText(plate) + if not text then return end + + local mode = BNP:GetHealthTextMode() + local health, maxHealth = GetHealthFromUnit(plate) + + -- Fallback for clients/units where the SuperWoW unit token is temporarily + -- unavailable. This may be normalized health on stock Vanilla. + if health == nil or maxHealth == nil then + health, maxHealth = GetHealthFromBar(plate) + end + + local pct = nil + if health and maxHealth and maxHealth > 0 then + pct = math.floor((health / maxHealth) * 100 + 0.5) + else + pct = GetPercentFromBar(plate) + if pct == nil then pct = GetPercentFromUnit(plate) end + end + + if pct ~= nil then + if pct < 0 then pct = 0 end + if pct > 100 then pct = 100 end + end + + local hpText = FormatHealth(health) + local display = nil + + if mode == "percent" then + if pct ~= nil then display = tostring(pct) .. "%" end + elseif mode == "hp" then + display = hpText + elseif mode == "both" then + if hpText and pct ~= nil then + display = hpText .. " | " .. tostring(pct) .. "%" + elseif hpText then + display = hpText + elseif pct ~= nil then + display = tostring(pct) .. "%" + end + end + + if not display or display == "" then + text:Hide() + return + end + + if text.BNPLastHealthText ~= display then + text:SetText(display) + text.BNPLastHealthText = display + end + text:Show() +end + +-- Intentionally simple fallback: only reads values and updates text. +-- No SetScript calls on healthbar, no SetAlpha, no color changes. +local updater = CreateFrame("Frame") +local elapsed = 0 +local healthScanBucket = 0 +local healthNextBucket = 0 + +local function GetHealthScanBucket(plate) + if plate.BNPHealthScanBucket == nil then + plate.BNPHealthScanBucket = healthNextBucket + healthNextBucket = 1 - healthNextBucket + end + return plate.BNPHealthScanBucket +end + +updater:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.05 then return end + elapsed = 0 + + -- Health text is off by default. Do not walk every known nameplate when there + -- is literally nothing to update. Option changes refresh immediately. + if not Enabled() then return end + + -- Keep the old 0.10s per-plate update rate, but distribute it over two frames. + healthScanBucket = 1 - healthScanBucket + + local plate + for plate in pairs(BNP.plates or {}) do + if GetHealthScanBucket(plate) == healthScanBucket then + if plate:IsShown() then + UpdatePlate(plate) + elseif plate.BNPHealthPercent then + plate.BNPHealthPercent:Hide() + end + end + end +end) + +function BNP:RefreshHealthPercent() + local plate + for plate in pairs(self.plates or {}) do + if plate and plate:IsShown() then + UpdatePlate(plate) + elseif plate and plate.BNPHealthPercent then + plate.BNPHealthPercent:Hide() + end + end +end + +-- Refresh immediately when Blizzard reuses a nameplate instead of waiting for +-- the next 0.10 s fallback pass. +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if current then UpdatePlate(current) end + end) +end + +-- Target switches can change nameplate frame levels in the same frame. Because +-- the text now belongs to the healthbar this is only a value/visibility refresh, +-- not a layering workaround. +local healthEvents = CreateFrame("Frame") +healthEvents:RegisterEvent("PLAYER_TARGET_CHANGED") +healthEvents:RegisterEvent("PLAYER_ENTERING_WORLD") +healthEvents:SetScript("OnEvent", function() + if BNP.RefreshHealthPercent then + BNP:RefreshHealthPercent() + end +end) diff --git a/modules/immunities.lua b/modules/immunities.lua new file mode 100644 index 0000000..bac2853 --- /dev/null +++ b/modules/immunities.lua @@ -0,0 +1,1035 @@ +if not BNP or not BNP.libnameplate then return end + +-- PvP immunity / anti-fear indicators (strict whitelist) ------------------- +-- Keep this deliberately close to BNP's proven aura/nameplate path: +-- SuperWoW's projected nameplate token (plate:GetName(1)) is used directly +-- with UnitBuff, just like auras.lua uses it directly with UnitDebuff. +-- No GUID -> token reverse conversion and no C_UnitAuras dependency here. + +BNP.guidImmunities = BNP.guidImmunities or {} + +local UI = BNP.UI or {} +local MAX_ICONS = 4 +local ICON_SPACING = UI.ICON_SPACING or 2 +local SIDE_SPACING = 4 +local BOTTOM_SPACING = 4 +local BASE_TOP_OFFSET = UI.ICON_OFFSET_Y or 15 +local COMBO_LAYOUT_OFFSET = 16 +local MISSING_GRACE = 0.20 +local NAME_HIDDEN_COMPACT_SHIFT = 10 + +local _, playerClass = UnitClass("player") + +local IMM_ERR_LAST = {} +local function ReportImmunityError(stage, err) + local now = GetTime and GetTime() or 0 + local last = IMM_ERR_LAST[stage] or -100 + if now - last < 1.5 then return end + IMM_ERR_LAST[stage] = now + local msg = "|cffff4444BNP IMM ERROR [" .. tostring(stage) .. "]|r " .. tostring(err) + if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then + DEFAULT_CHAT_FRAME:AddMessage(msg) + end +end + +local IMMUNITY_DEFS = { + { + key = "divine_shield", + names = { "Divine Shield", "Gottesschild" }, + spellIDs = { 642, 1020 }, + durations = { [642] = 10, [1020] = 12 }, + defaultDuration = 12, + textureMatches = { "spell_holy_divineintervention" }, + selfOnly = true, + }, + { + key = "divine_protection", + names = { "Divine Protection", "Göttlicher Schutz" }, + spellIDs = { 498, 5573 }, + durations = { [498] = 6, [5573] = 8 }, + defaultDuration = 8, + selfOnly = true, + }, + { + key = "blessing_of_protection", + names = { "Blessing of Protection", "Segen des Schutzes" }, + spellIDs = { 1022, 5599, 10278 }, + durations = { [1022] = 6, [5599] = 8, [10278] = 10 }, + defaultDuration = 10, + textureMatches = { "spell_holy_sealofprotection" }, + }, + { + key = "ice_block", + names = { "Ice Block", "Eisblock" }, + spellIDs = { 11958 }, + durations = { [11958] = 10 }, + defaultDuration = 10, + textureMatches = { "spell_frost_frost" }, + selfOnly = true, + }, + { + key = "berserker_rage", + names = { "Berserker Rage", "Berserkerwut" }, + spellIDs = { 18499 }, + durations = { [18499] = 10 }, + defaultDuration = 10, + selfOnly = true, + }, + { + key = "death_wish", + names = { "Death Wish", "Todeswunsch" }, + spellIDs = { 12328 }, + durations = { [12328] = 30 }, + defaultDuration = 30, + selfOnly = true, + }, + { + key = "recklessness", + names = { "Recklessness", "Tollkühnheit" }, + spellIDs = { 1719 }, + durations = { [1719] = 15 }, + defaultDuration = 15, + selfOnly = true, + }, + { + key = "fear_ward", + names = { "Fear Ward", "Furchtzauberschutz" }, + spellIDs = { 6346 }, + durations = { [6346] = 600 }, + defaultDuration = 600, + }, + { + key = "will_of_the_forsaken", + names = { "Will of the Forsaken", "Wille der Verlassenen" }, + spellIDs = { 7744 }, + durations = { [7744] = 5 }, + defaultDuration = 5, + selfOnly = true, + }, +} + +local DEF_BY_ID = {} +local DEF_BY_NAME = {} + +local function Lower(value) + if type(value) ~= "string" or value == "" then return nil end + return string.lower(value) +end + +local function DefDuration(def, spellID) + if def and def.durations and spellID and def.durations[spellID] then + return def.durations[spellID] + end + return def and def.defaultDuration or nil +end + +local function BuildLookup() + local i, j, def, id, name + for i = 1, table.getn(IMMUNITY_DEFS) do + def = IMMUNITY_DEFS[i] + if def.names then + for j = 1, table.getn(def.names) do + name = Lower(def.names[j]) + if name then DEF_BY_NAME[name] = def end + end + end + if def.spellIDs then + for j = 1, table.getn(def.spellIDs) do + id = def.spellIDs[j] + DEF_BY_ID[id] = def + if SpellInfo then + local spellName = SpellInfo(id) + spellName = Lower(spellName) + if spellName then DEF_BY_NAME[spellName] = def end + end + end + end + end +end +BuildLookup() + +local function TextureMatches(def, texture) + local t = Lower(texture) + if not def or not t or not def.textureMatches then return false end + local i + for i = 1, table.getn(def.textureMatches) do + local pattern = Lower(def.textureMatches[i]) + if pattern and string.find(t, pattern, 1, true) then return true end + end + return false +end + +local function FindDef(spellID, name, texture) + -- STRICT WHITELIST: an aura is only an immunity when its spell ID or exact + -- spell name is configured above. Do not guess from a shared icon/texture. + -- Many unrelated server buffs reuse Blizzard icons (e.g. armor/world buffs), + -- which previously allowed false positives such as Forest Armor. + local sid = tonumber(spellID) + if sid then + return DEF_BY_ID[sid] + end + + local n = Lower(name) + if n then + return DEF_BY_NAME[n] + end + + return nil +end + +local function GetIconSize() + if BNP.GetImmunityIconSize then return BNP:GetImmunityIconSize() end + if BNP.GetIconSize then return BNP:GetIconSize() end + return 18 +end + +local function GetPosition() + if BNP.GetImmunityPosition then return BNP:GetImmunityPosition() end + return "top" +end + +local function GetImmunityYOffset() + if BNP.GetImmunityYOffset then return BNP:GetImmunityYOffset() end + return 0 +end + +local function GetVisualParent(plate) + if plate and plate.GetParent then + local p = plate:GetParent() + if p then return p end + end + return WorldFrame or UIParent +end + +local function GetBaseTopOffset() + local y = BASE_TOP_OFFSET + if (playerClass == "ROGUE" or playerClass == "DRUID") + and BNP.AreComboPointsEnabled and BNP:AreComboPointsEnabled() then + y = y + COMBO_LAYOUT_OFFSET + end + return y +end + +local function IsTopDebuffPosition(position) + return position == "top" or position == "top_left" or position == "top_right" +end + +local function GetTopOffset() + local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18 + local ccSize = BNP.GetCCIconSize and BNP:GetCCIconSize() or auraSize + local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top" + local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition + local debuffs = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() + local ccs = not BNP.AreCrowdControlEnabled or BNP:AreCrowdControlEnabled() + local separateCC = ccs and debuffs and ccPosition == "top" and IsTopDebuffPosition(debuffPosition) + and BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled() + local y = GetBaseTopOffset() + + if separateCC then + y = y + auraSize + ICON_SPACING + ccSize + ICON_SPACING + else + local rowHeight = 0 + if debuffs and IsTopDebuffPosition(debuffPosition) then rowHeight = math.max(rowHeight, auraSize) end + if ccs and ccPosition == "top" then rowHeight = math.max(rowHeight, ccSize) end + if rowHeight > 0 then y = y + rowHeight + ICON_SPACING end + end + return y +end + +local function FrameShown(frame) + return frame and frame.IsShown and frame:IsShown() and true or false +end + +local function GetBottomOffset(plate) + local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18 + local ccSize = BNP.GetCCIconSize and BNP:GetCCIconSize() or auraSize + local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top" + local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition + local debuffs = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() + local ccs = not BNP.AreCrowdControlEnabled or BNP:AreCrowdControlEnabled() + local separateCC = ccs and debuffs and ccPosition == "bottom" and debuffPosition == "bottom" + and BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled() + + local harmfulDepth = 0 + if separateCC then + -- The CC row is physically the second row, so preserve the first-row + -- offset whenever that second row is visible. + if FrameShown(plate and plate.BNPCCContainer) then + harmfulDepth = auraSize + ICON_SPACING + ccSize + elseif FrameShown(plate and plate.BNPAuraContainer) then + harmfulDepth = auraSize + end + else + if debuffs and debuffPosition == "bottom" and FrameShown(plate and plate.BNPAuraContainer) then + harmfulDepth = math.max(harmfulDepth, auraSize) + end + if ccs and ccPosition == "bottom" then + if ccPosition == debuffPosition then + if FrameShown(plate and plate.BNPAuraContainer) then harmfulDepth = math.max(harmfulDepth, ccSize) end + elseif FrameShown(plate and plate.BNPCCContainer) then + harmfulDepth = math.max(harmfulDepth, ccSize) + end + end + end + + local y = BOTTOM_SPACING + if harmfulDepth > 0 then y = y + harmfulDepth + ICON_SPACING end + return y +end + +local function GetSideOffset(position) + local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18 + local ccSize = BNP.GetCCIconSize and BNP:GetCCIconSize() or auraSize + local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top" + local ccPosition = BNP.GetCCPosition and BNP:GetCCPosition() or debuffPosition + local debuffs = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() + local ccs = not BNP.AreCrowdControlEnabled or BNP:AreCrowdControlEnabled() + local rowHeight = 0 + if debuffs and debuffPosition == position then rowHeight = math.max(rowHeight, auraSize) end + if ccs and ccPosition == position then rowHeight = math.max(rowHeight, ccSize) end + if rowHeight > 0 then return (rowHeight / 2) + ICON_SPACING + (GetIconSize() / 2) end + return 0 +end + +local function GetRegionWidth(region) + if not region then return 0 end + if region.IsShown and not region:IsShown() then return 0 end + if region.GetStringWidth then + local w = tonumber(region:GetStringWidth()) or 0 + if w > 0 then return w end + end + if region.GetWidth then return math.max(0, tonumber(region:GetWidth()) or 0) end + return 0 +end + +local function GetRightSpacing(plate) + local extra = GetRegionWidth(plate and plate.level) + GetRegionWidth(plate and plate.levelicon) + if extra > 0 then extra = extra + 6 end + return SIDE_SPACING + extra +end + +local function AnchorContainer(plate, container) + if not plate or not container then return end + container:ClearAllPoints() + local position = GetPosition() + local anchor = plate.healthbar or plate + + if position == "left" then + container:SetPoint("RIGHT", anchor, "LEFT", -SIDE_SPACING, GetSideOffset("left") + GetImmunityYOffset()) + elseif position == "right" then + container:SetPoint("LEFT", anchor, "RIGHT", GetRightSpacing(plate), GetSideOffset("right") + GetImmunityYOffset()) + else + local nameHidden = (BNP.IsPlateNameHidden and BNP:IsPlateNameHidden(plate)) or plate.BNPNameHidden + local compactShift = nameHidden and -NAME_HIDDEN_COMPACT_SHIFT or 0 + if plate.healthbar then + container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, GetTopOffset() + GetImmunityYOffset() + compactShift) + else + container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24 + GetTopOffset() + GetImmunityYOffset() + compactShift) + end + end +end + +function BNP:RefreshImmunityLayoutForPlate(plate) + if not plate or not plate.BNPImmunityContainer then return end + AnchorContainer(plate, plate.BNPImmunityContainer) +end + +local function ApplyFrameLevel(plate, container) + if not plate or not container then return end + if plate.GetFrameStrata and container.GetFrameStrata and container.SetFrameStrata then + local strata = plate:GetFrameStrata() + if strata and container:GetFrameStrata() ~= strata then + container:SetFrameStrata(strata) + end + end + if plate.GetFrameLevel and container.GetFrameLevel and container.SetFrameLevel then + local level = math.max(0, (tonumber(plate:GetFrameLevel()) or 0) + 1) + if container:GetFrameLevel() ~= level then container:SetFrameLevel(level) end + local i + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + if icon and icon.GetFrameLevel and icon.SetFrameLevel and icon:GetFrameLevel() ~= level then + icon:SetFrameLevel(level) + end + end + end +end + +local function CreateIcon(parent, index) + local size = GetIconSize() + local icon = CreateFrame("Frame", nil, parent) + icon:SetWidth(size) + icon:SetHeight(size) + + local texture = icon:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(icon) + icon.texture = texture + + local timer = icon:CreateFontString(nil, "OVERLAY") + timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", UI.TIMER_SIZE or 8, "OUTLINE") + timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0) + timer:SetTextColor(1, 1, 1) + icon.timer = timer + + icon.index = index + icon:Hide() + return icon +end + +local function LayoutContainer(plate, container) + if not plate or not container then return end + local size = GetIconSize() + local position = GetPosition() + local i + + if container.BNPLastSize ~= size or container.BNPLastIconPosition ~= position then + container.BNPLastSize = size + container.BNPLastIconPosition = position + container:SetWidth(size * MAX_ICONS + ICON_SPACING * (MAX_ICONS - 1)) + container:SetHeight(size) + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + icon:SetWidth(size) + icon:SetHeight(size) + icon:ClearAllPoints() + if position == "left" then + icon:SetPoint("RIGHT", container, "RIGHT", -((i - 1) * (size + ICON_SPACING)), 0) + else + icon:SetPoint("LEFT", container, "LEFT", (i - 1) * (size + ICON_SPACING), 0) + end + end + end + + -- Cheap layout key; avoids touching anchors every scan. + local layoutKey = tostring(position) .. ":" .. + tostring(BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top") .. ":" .. + tostring(BNP.GetCCPosition and BNP:GetCCPosition() or "top") .. ":" .. + tostring(BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled()) .. ":" .. + tostring(BNP.GetIconSize and BNP:GetIconSize() or 18) .. ":" .. + tostring(BNP.GetCCIconSize and BNP:GetCCIconSize() or 18) .. ":" .. tostring(size) .. ":" .. + tostring(plate.BNPNameHidden and true or false) .. ":" .. + tostring(FrameShown(plate.BNPAuraContainer)) .. ":" .. + tostring(FrameShown(plate.BNPCCContainer)) + + if container.BNPLastLayoutKey ~= layoutKey then + container.BNPLastLayoutKey = layoutKey + AnchorContainer(plate, container) + end + ApplyFrameLevel(plate, container) +end + +local function HideContainer(plate) + local container = plate and plate.BNPImmunityContainer + if not container then return end + local i + for i = 1, table.getn(container.icons or {}) do container.icons[i]:Hide() end + container:Hide() +end + +local function InstallHideGuard(plate) + if not plate or plate.BNPImmunityHideGuard then return end + local oldOnHide = plate:GetScript("OnHide") + plate:SetScript("OnHide", function() + if oldOnHide then oldOnHide() end + HideContainer(this or plate) + end) + plate.BNPImmunityHideGuard = true +end + +local function CreateContainer(plate) + if not plate or plate.BNPImmunityContainer then return end + if BNP.RegisterPlate then BNP:RegisterPlate(plate) end + + local container = CreateFrame("Frame", nil, GetVisualParent(plate)) + container.icons = {} + local i + for i = 1, MAX_ICONS do container.icons[i] = CreateIcon(container, i) end + plate.BNPImmunityContainer = container + LayoutContainer(plate, container) + container:Hide() + InstallHideGuard(plate) +end + +local function GetCache(guid) + if not guid then return nil end + local cache = BNP.guidImmunities[guid] + if not cache then + cache = { _nextOrder = 0 } + BNP.guidImmunities[guid] = cache + end + return cache +end + +local function CommitSeen(guid, def, spellID, texture, duration, expires, now, source) + if not guid or not def then return end + local cache = GetCache(guid) + local entry = cache[def.key] + if not entry then + cache._nextOrder = (cache._nextOrder or 0) + 1 + entry = { order = cache._nextOrder } + cache[def.key] = entry + end + + entry.spellID = tonumber(spellID) or entry.spellID + entry.texture = texture or entry.texture + entry.duration = tonumber(duration) or entry.duration or DefDuration(def, tonumber(spellID)) + entry.lastSeen = now + entry.missingSince = nil + if source == "capi" then + entry.capiConfirmed = true + entry.capiMissingSince = nil + elseif source == "unitbuff" then + entry.unitBuffConfirmed = true + entry.unitBuffMissingSince = nil + end + + expires = tonumber(expires) + if expires and expires > now then + entry.expires = expires + elseif not entry.expires or entry.expires <= now then + local d = entry.duration or DefDuration(def, entry.spellID) + if d then entry.expires = now + d end + end +end + +-- These full immunities also cleanse harmful effects in Classic. Keep the +-- harmful-debuff module hard-suppressed while the immunity entry is active. +local CLEANSE_ALL = { + divine_shield = true, + ice_block = true, +} + +local function ApplyCleanseSuppression(guid, def, entry, now) + if not guid or not def or not CLEANSE_ALL[def.key] then return end + if not entry then return end + local remaining = entry.expires and (entry.expires - now) or nil + if remaining and remaining <= 0 then return end + if BNP.SuppressHarmfulAurasForImmunity then + -- Renewable short hold: immediate cleanse now, but if Ice Block/Bubble is + -- clicked off early the suppression naturally expires within a fraction + -- of a second instead of hiding new debuffs until the guessed full timer. + BNP:SuppressHarmfulAurasForImmunity(guid, 0.45) + end +end + +local function ParseDurationExpiry(now, a5, a6, a7, a8, a9, a10) + local vals = { a5, a6, a7, a8, a9, a10 } + local i + for i = 1, table.getn(vals) - 1 do + local d = tonumber(vals[i]) + local e = tonumber(vals[i + 1]) + if d and e and d > 0 and d <= 3600 and e > now then return d, e end + end + return nil, nil +end + +local function ReconcileMissingSource(guid, source, seen, now) + local cache = guid and BNP.guidImmunities[guid] or nil + if not cache then return end + + local d, def, entry, confirmedKey, missingKey + if source == "capi" then + confirmedKey = "capiConfirmed" + missingKey = "capiMissingSince" + else + confirmedKey = "unitBuffConfirmed" + missingKey = "unitBuffMissingSince" + end + + for d = 1, table.getn(IMMUNITY_DEFS) do + def = IMMUNITY_DEFS[d] + entry = cache[def.key] + if entry and entry[confirmedKey] then + if seen and seen[def.key] then + entry[missingKey] = nil + else + if not entry[missingKey] then entry[missingKey] = now end + if now - entry[missingKey] >= MISSING_GRACE then + -- Once a live aura source has positively confirmed this exact + -- immunity, that same source becomes authoritative for early + -- removals (Ice Block clicked off, Bubble canceled/removed, dispel). + cache[def.key] = nil + end + end + end + end +end + +local IMMUNITY_SEEN = {} + +local function ResetImmunitySeen() + local key + for key in pairs(IMMUNITY_SEEN) do IMMUNITY_SEEN[key] = nil end + return IMMUNITY_SEEN +end + +local function ScanClassicAPIBuffs(unit, guid, now) + if not unit or not guid then return false end + if type(C_UnitAuras) ~= "table" then return false end + + local seen = ResetImmunitySeen() + + -- Preferred path: ask ClassicAPI directly for the whitelisted spell IDs. + -- Its aura cache is evicted by the client's real OnAuraRemoved hook, so an + -- Ice Block that is clicked off disappears here immediately instead of + -- living until the guessed cast timer ends. + if type(C_UnitAuras.GetUnitAuraBySpellID) == "function" then + local anyCallWorked = false + local d, j, def, sid, ok, aura + for d = 1, table.getn(IMMUNITY_DEFS) do + def = IMMUNITY_DEFS[d] + if def.spellIDs then + for j = 1, table.getn(def.spellIDs) do + sid = def.spellIDs[j] + ok, aura = pcall(C_UnitAuras.GetUnitAuraBySpellID, unit, sid, "HELPFUL") + if ok then + anyCallWorked = true + if type(aura) == "table" then + local auraID = tonumber(aura.spellId or aura.spellID) or sid + local matched = FindDef(auraID, aura.name, aura.icon or aura.texture) + if matched then + seen[matched.key] = true + CommitSeen(guid, matched, auraID, aura.icon or aura.texture, + tonumber(aura.duration), tonumber(aura.expirationTime), now, "capi") + break + end + end + else + -- If this build rejects the exact getter, fall back to index scan + -- below and never use a failed call as negative evidence. + anyCallWorked = false + break + end + end + end + if not anyCallWorked and d == 1 then break end + end + + if anyCallWorked then + ReconcileMissingSource(guid, "capi", seen, now) + return true + end + end + + local getter = nil + local useFilter = false + if type(C_UnitAuras.GetBuffDataByIndex) == "function" then + getter = C_UnitAuras.GetBuffDataByIndex + elseif type(C_UnitAuras.GetAuraDataByIndex) == "function" then + getter = C_UnitAuras.GetAuraDataByIndex + useFilter = true + end + if not getter then return false end + + local completed = true + local i + for i = 1, 48 do + local ok, aura + if useFilter then + ok, aura = pcall(getter, unit, i, "HELPFUL") + else + ok, aura = pcall(getter, unit, i) + end + if not ok then completed = false break end + if not aura then break end + + if type(aura) == "table" then + local spellID = tonumber(aura.spellId or aura.spellID) + local def = FindDef(spellID, aura.name, aura.icon or aura.texture) + if def then + seen[def.key] = true + CommitSeen(guid, def, spellID, aura.icon or aura.texture, + tonumber(aura.duration), tonumber(aura.expirationTime), now, "capi") + end + end + end + + if completed then ReconcileMissingSource(guid, "capi", seen, now) end + return completed +end + +local function ScanUnitBuffs(unit, guid, now) + if not unit or not guid or not UnitBuff then return false end + + local seen = ResetImmunitySeen() + local completed = true + local i + for i = 1, 64 do + local ok, texture, applications, buffID, a4, a5, a6, a7, a8, a9, a10 = pcall(UnitBuff, unit, i) + if not ok then completed = false break end + if not texture then break end + + local spellID = tonumber(buffID) + if not spellID then + if type(a4) == "number" then spellID = a4 + elseif type(a5) == "number" then spellID = a5 end + end + if spellID and spellID < -1 then spellID = spellID + 65536 end + + local auraName = nil + if spellID and SpellInfo then auraName = SpellInfo(spellID) end + local def = FindDef(spellID, auraName, texture) + if def then + seen[def.key] = true + local duration, expires = ParseDurationExpiry(now, a4, a5, a6, a7, a8, a9, a10) + CommitSeen(guid, def, spellID, texture, duration, expires, now, "unitbuff") + end + end + + -- UnitBuff is only allowed to remove an immunity after UnitBuff itself has + -- positively seen that exact whitelist entry at least once. This preserves + -- the cast-event fallback on clients where hostile helpful auras are hidden, + -- while still making clicked-off buffs disappear immediately when visible. + if completed then ReconcileMissingSource(guid, "unitbuff", seen, now) end + return completed +end + +local function ResolveClassicAuraUnit(guid, targetGUID) + if not guid then return nil end + if targetGUID and guid == targetGUID then return "target" end + + if type(UnitTokenFromGUID) == "function" then + local ok, token = pcall(UnitTokenFromGUID, guid) + if ok and token and token ~= "" then return token end + end + return nil +end + +local function FormatTimer(remaining) + if not remaining or remaining <= 0 then return "" end + if remaining >= 3600 then return math.ceil(remaining / 3600) .. "h" end + if remaining >= 60 then return math.ceil(remaining / 60) .. "m" end + return tostring(math.ceil(remaining)) +end + +local function Render(plate, guid, now) + local container = plate and plate.BNPImmunityContainer + if not container then return end + LayoutContainer(plate, container) + + local cache = guid and BNP.guidImmunities[guid] or nil + + -- Reuse a tiny render list/pool per container. This path runs frequently in + -- BGs, so avoid allocating a new active table and item tables every scan. + local active = container.BNPActiveImmunities + if not active then active = {}; container.BNPActiveImmunities = active end + local pool = container.BNPActiveImmunityPool + if not pool then + pool = {} + local poolIndex + for poolIndex = 1, MAX_ICONS do pool[poolIndex] = {} end + container.BNPActiveImmunityPool = pool + end + local oldCount = table.getn(active) + local clearIndex + for clearIndex = 1, oldCount do active[clearIndex] = nil end + + local activeCount = 0 + local d, def, entry, remaining + for d = 1, table.getn(IMMUNITY_DEFS) do + def = IMMUNITY_DEFS[d] + entry = cache and cache[def.key] or nil + remaining = entry and entry.expires and (entry.expires - now) or nil + if entry and (not entry.expires or (remaining and remaining > 0)) then + ApplyCleanseSuppression(guid, def, entry, now) + if activeCount < MAX_ICONS then + activeCount = activeCount + 1 + local item = pool[activeCount] + item.def = def + item.entry = entry + item.remaining = remaining + active[activeCount] = item + end + elseif cache and entry and entry.expires and remaining and remaining <= 0 then + cache[def.key] = nil + end + end + + if activeCount > 1 then + table.sort(active, function(a, b) return (a.entry.order or 0) < (b.entry.order or 0) end) + end + + local count = activeCount + local size = GetIconSize() + local visibleWidth = size + if count > 1 then visibleWidth = size * count + ICON_SPACING * (count - 1) end + container:SetWidth(visibleWidth) + + local i + for i = 1, MAX_ICONS do + local icon = container.icons[i] + local item = active[i] + if item then + local texture = item.entry.texture + if not texture and item.entry.spellID and SpellInfo then + local _, _, spellTexture = SpellInfo(item.entry.spellID) + texture = spellTexture + end + if texture then icon.texture:SetTexture(texture) end + icon.timer:SetText(FormatTimer(item.remaining)) + icon:Show() + else + icon.timer:SetText("") + icon:Hide() + end + end + + if count > 0 then container:Show() else container:Hide() end +end + +local function GetTargetGUID() + if not UnitExists then return nil end + local exists, guid = UnitExists("target") + if exists and guid then return guid end + return nil +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local token = plate:GetName(1) + if token and token ~= "" then return token end + return nil +end + +local function UpdatePlate(plate) + if not plate or not plate:IsShown() then + HideContainer(plate) + return + end + if BNP.ArePvPImmunitiesEnabled and not BNP:ArePvPImmunitiesEnabled() then + HideContainer(plate) + return + end + if not plate.BNPImmunityContainer then CreateContainer(plate) end + + local now = GetTime() + local guid = BNP.GetStablePlateAuraGUID and BNP.GetStablePlateAuraGUID(plate, now) or GetPlateGUID(plate) + if not guid then HideContainer(plate) return end + + local targetGUID = GetTargetGUID() + local unit = nil + if targetGUID and targetGUID == guid then + unit = "target" + else + unit = GetPlateGUID(plate) + end + + -- Scan the two aura surfaces independently. ClassicAPI nameplateN tokens are + -- preferred when available; SuperWoW's raw GUID token remains the legacy + -- UnitBuff fallback. Do not let one resolver's player check block the other. + local classicUnit = ResolveClassicAuraUnit(guid, targetGUID) + local classicCanScan = classicUnit ~= nil + if classicCanScan and UnitIsPlayer then + classicCanScan = UnitIsPlayer(classicUnit) and true or false + end + if classicCanScan then ScanClassicAPIBuffs(classicUnit, guid, now) end + + local legacyCanScan = unit ~= nil + if legacyCanScan and UnitIsPlayer then + legacyCanScan = UnitIsPlayer(unit) and true or false + end + if legacyCanScan then ScanUnitBuffs(unit, guid, now) end + + Render(plate, guid, now) +end + +-- UNIT_CASTEVENT is a fast timer seed. Once ClassicAPI/UnitBuff has +-- positively confirmed the aura, that live source also removes it early when +-- the buff is canceled, dispelled, or otherwise ends before its timer. +local castFrame = CreateFrame("Frame") +castFrame:RegisterEvent("UNIT_CASTEVENT") +local function HandleImmunityCastEvent() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = tonumber(arg4) + if spellID and spellID < -1 then spellID = spellID + 65536 end + + -- Keep the last raw event for /bnpimm diagnostics. This is intentionally + -- cheap and lets us verify instant enemy casts without another addon. + BNP._immLastCast = { + casterGUID = casterGUID, + targetGUID = targetGUID, + eventType = eventType, + spellID = spellID, + time = GetTime(), + } + + if not casterGUID or not spellID then return end + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + + local texture = nil + local spellName = nil + if SpellInfo then + spellName, _, texture = SpellInfo(spellID) + end + local def = DEF_BY_ID[spellID] or FindDef(spellID, spellName, texture) + if not def then return end + + local guid = def.selfOnly and casterGUID or (targetGUID or casterGUID) + local now = GetTime() + local duration = DefDuration(def, spellID) + if duration then + CommitSeen(guid, def, spellID, texture, duration, now + duration, now) + local cache = BNP.guidImmunities and BNP.guidImmunities[guid] + ApplyCleanseSuppression(guid, def, cache and cache[def.key], now) + end +end + +castFrame:SetScript("OnEvent", function() + local ok, err = pcall(HandleImmunityCastEvent) + if not ok then ReportImmunityError("cast", err) end +end) + +function BNP:RefreshImmunityVisibility() + local plate + for plate in pairs(BNP.plates or {}) do + if plate then + if self:ArePvPImmunitiesEnabled() then + if not plate.BNPImmunityContainer then CreateContainer(plate) end + else + HideContainer(plate) + end + end + end +end + +function BNP:RefreshAllImmunityLayouts() + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate.BNPImmunityContainer then + plate.BNPImmunityContainer.BNPLastLayoutKey = nil + plate.BNPImmunityContainer.BNPLastSize = nil + plate.BNPImmunityContainer.BNPLastIconPosition = nil + LayoutContainer(plate, plate.BNPImmunityContainer) + end + end +end + +-- Small temporary diagnostic for this test build. Target a bubbled player and +-- type /bnpimm. It prints the raw UnitBuff slots BNP sees, including spell ID +-- when SuperWoW exposes it. This makes server/client differences obvious without +-- relying on another addon's truncated error window. +SLASH_BNPIMMUNITYDEBUG1 = "/bnpimm" +SlashCmdList["BNPIMMUNITYDEBUG"] = function() + if not DEFAULT_CHAT_FRAME then return end + local exists, guid = UnitExists("target") + DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99BNP IMM DEBUG|r targetGUID=" .. tostring(guid or "nil") .. " player=" .. tostring(UnitIsPlayer and UnitIsPlayer("target"))) + + local last = BNP._immLastCast + if last then + DEFAULT_CHAT_FRAME:AddMessage(" last CASTEVENT type=" .. tostring(last.eventType) .. " id=" .. tostring(last.spellID) .. " caster=" .. tostring(last.casterGUID) .. " target=" .. tostring(last.targetGUID)) + else + DEFAULT_CHAT_FRAME:AddMessage(" last CASTEVENT: none seen") + end + + if not exists then return end + + local capiFound = 0 + if type(C_UnitAuras) == "table" then + local getter = nil + local useFilter = false + if type(C_UnitAuras.GetBuffDataByIndex) == "function" then + getter = C_UnitAuras.GetBuffDataByIndex + elseif type(C_UnitAuras.GetAuraDataByIndex) == "function" then + getter = C_UnitAuras.GetAuraDataByIndex + useFilter = true + end + DEFAULT_CHAT_FRAME:AddMessage(" ClassicAPI aura getter=" .. tostring(getter ~= nil)) + if getter then + local i + for i = 1, 16 do + local ok, aura + if useFilter then ok, aura = pcall(getter, "target", i, "HELPFUL") + else ok, aura = pcall(getter, "target", i) end + if not ok then + DEFAULT_CHAT_FRAME:AddMessage(" CAPI error slot " .. tostring(i) .. ": " .. tostring(aura)) + break + end + if not aura then break end + if type(aura) == "table" then + local sid = tonumber(aura.spellId or aura.spellID) + local def = FindDef(sid, aura.name, aura.icon or aura.texture) + if def then capiFound = capiFound + 1 end + if def then + DEFAULT_CHAT_FRAME:AddMessage(" CAPI MATCH " .. tostring(i) .. " id=" .. tostring(sid) .. " name=" .. tostring(aura.name) .. " tex=" .. tostring(aura.icon or aura.texture)) + end + else + DEFAULT_CHAT_FRAME:AddMessage(" CAPI slot " .. tostring(i) .. " returned " .. type(aura) .. ": " .. tostring(aura)) + end + end + end + else + DEFAULT_CHAT_FRAME:AddMessage(" ClassicAPI C_UnitAuras: unavailable") + end + + local ubFound = 0 + local i + for i = 1, 32 do + local ok, texture, stacks, buffID, a4, a5, a6 = pcall(UnitBuff, "target", i) + if not ok then + DEFAULT_CHAT_FRAME:AddMessage(" UnitBuff error slot " .. tostring(i) .. ": " .. tostring(texture)) + break + end + if not texture then break end + local spellID = tonumber(buffID) + if not spellID and type(a4) == "number" then spellID = a4 end + if spellID and spellID < -1 then spellID = spellID + 65536 end + local def = FindDef(spellID, spellID and SpellInfo and SpellInfo(spellID) or nil, texture) + if def then ubFound = ubFound + 1 end + if def then + DEFAULT_CHAT_FRAME:AddMessage(" UB MATCH " .. tostring(i) .. " id=" .. tostring(spellID) .. " tex=" .. tostring(texture)) + end + end + if capiFound == 0 and ubFound == 0 then + DEFAULT_CHAT_FRAME:AddMessage(" no configured immunity matched live target buffs") + end +end + +-- Sanity-check the whole module callback so one optional PvP feature can never +-- stop any other BNP nameplate module. +local function SafeCreateContainer(plate) + local ok, err = pcall(CreateContainer, plate) + if not ok then ReportImmunityError("create", err) end +end + +local function SafeUpdatePlate(plate) + local ok, err = pcall(UpdatePlate, plate) + if not ok then ReportImmunityError("update", err) end +end + +table.insert(BNP.libnameplate.OnInit, SafeCreateContainer) +table.insert(BNP.libnameplate.OnShow, SafeCreateContainer) + +-- Immunity scans are one of the heavier PvP paths (ClassicAPI + UnitBuff). +-- The old libnameplate callback scanned every visible player in the same 0.10s +-- frame. Split that work into two 0.05s buckets while preserving the same +-- ~0.10s per-nameplate scan cadence. +local immunityElapsed = 0 +local immunityScanBucket = 0 +local immunityNextBucket = 0 + +local function GetImmunityScanBucket(plate) + if plate.BNPImmunityScanBucket == nil then + plate.BNPImmunityScanBucket = immunityNextBucket + immunityNextBucket = 1 - immunityNextBucket + end + return plate.BNPImmunityScanBucket +end + +local immunityScanner = CreateFrame("Frame") +immunityScanner:SetScript("OnUpdate", function() + immunityElapsed = immunityElapsed + arg1 + if immunityElapsed < 0.05 then return end + immunityElapsed = 0 + + if BNP.ArePvPImmunitiesEnabled and not BNP:ArePvPImmunitiesEnabled() then return end + + immunityScanBucket = 1 - immunityScanBucket + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate:IsShown() and GetImmunityScanBucket(plate) == immunityScanBucket then + SafeUpdatePlate(plate) + end + end +end) diff --git a/modules/names.lua b/modules/names.lua new file mode 100644 index 0000000..e28db32 --- /dev/null +++ b/modules/names.lua @@ -0,0 +1,148 @@ +BNP = BNP or {} + +-- Independent name visibility controls for Blizzard nameplates. +-- BNP only hides the name FontString; health bars, level text, raid icons, +-- auras, immunities and click behavior remain untouched. + +local function ResolvePlateType(plate) + if not plate or not plate.GetName then return nil end + + local token = plate:GetName(1) + if not token then return nil end + + local exists = UnitExists(token) + if not exists then return nil end + + if UnitIsPlayer and UnitIsPlayer(token) then + return "player" + end + + return "npc" +end + +local function ShouldHide(unitType) + if unitType == "player" then + if BNP.HidePlayerNamesEnabled then return BNP:HidePlayerNamesEnabled() end + return BNP_DB and BNP_DB.hidePlayerNames and true or false + elseif unitType == "npc" then + if BNP.HideNPCNamesEnabled then return BNP:HideNPCNamesEnabled() end + return BNP_DB and BNP_DB.hideNPCNames and true or false + end + return false +end + +-- Authoritative query used by aura/immunity layout. Do not rely only on the +-- transient BNPNameHidden marker: on recycled/projected Vanilla nameplates the +-- name FontString and aura module callbacks can update in a different order. +function BNP:IsPlateNameHidden(plate) + if not plate then return false end + + -- This helper is queried by the aura renderer at 20 Hz. In the default state + -- (both hide-name options off) return immediately without resolving a unit + -- token for every visible plate. + if not BNP_DB or (not BNP_DB.hidePlayerNames and not BNP_DB.hideNPCNames) then + return false + end + + local unitType = ResolvePlateType(plate) or plate.BNPNameUnitType + if unitType then + return ShouldHide(unitType) + end + + -- If identity is momentarily unresolved, keep the last BNP-owned state so a + -- recycled plate does not jump vertically for a single scan. + return plate.BNPNameHidden and true or false +end + +local function RefreshPlateAuraLayout(plate) + if not plate then return end + if BNP.RefreshAuraLayoutForPlate then BNP:RefreshAuraLayoutForPlate(plate) end + if BNP.RefreshImmunityLayoutForPlate then BNP:RefreshImmunityLayoutForPlate(plate) end +end + +local function RestoreIfBNPHid(plate) + if not plate or not plate.name then return end + if plate.BNPNameHidden then + plate.name:Show() + plate.BNPNameHidden = nil + end +end + +local function UpdatePlateName(plate, resetIdentity) + if not plate or not plate.name then return end + + if resetIdentity then + -- Blizzard reuses nameplate frames. Never carry a hidden name from the + -- previous unit into a newly shown plate while its projected token is not + -- resolved yet. + plate.BNPNameUnitType = nil + RestoreIfBNPHid(plate) + end + + local unitType = ResolvePlateType(plate) + if unitType then + plate.BNPNameUnitType = unitType + else + unitType = plate.BNPNameUnitType + end + + if not unitType then return end + + if ShouldHide(unitType) then + if not plate.BNPNameHidden then + plate.name:Hide() + plate.BNPNameHidden = true + elseif plate.name.IsShown and plate.name:IsShown() then + -- The client may re-show Blizzard regions while updating/recycling a + -- plate. Re-enforce only names BNP explicitly owns as hidden. + plate.name:Hide() + end + else + RestoreIfBNPHid(plate) + end +end + +function BNP:RefreshNameVisibility() + local plate + for plate in pairs(BNP.plates or {}) do + if plate and plate.IsShown and plate:IsShown() then + UpdatePlateName(plate, false) + -- Re-anchor immediately when the option is toggled instead of waiting + -- for the aura module's next identity/update cycle. + RefreshPlateAuraLayout(plate) + elseif plate then + RestoreIfBNPHid(plate) + plate.BNPNameUnitType = nil + end + end +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + UpdatePlateName(current, true) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + UpdatePlateName(current, true) + end) + + -- libnameplate updates are already throttled to 0.10s. This is deliberate: + -- if Blizzard re-shows the name FontString, the configured hidden state is + -- restored without adding a per-frame OnUpdate to every nameplate. + table.insert(BNP.libnameplate.OnUpdate, function(plate) + local current = plate or this + if not current or not current:IsShown() then return end + + -- Both hide options are disabled by default. In that common case avoid + -- UnitExists/UnitIsPlayer work for every visible plate on every 0.10s pass. + -- If BNP previously hid this specific name, still run once to restore it. + local anyHideEnabled = BNP_DB and (BNP_DB.hidePlayerNames or BNP_DB.hideNPCNames) + if not anyHideEnabled and not current.BNPNameHidden then return end + + UpdatePlateName(current, false) + end) +end diff --git a/modules/scale.lua b/modules/scale.lua new file mode 100644 index 0000000..8794e26 --- /dev/null +++ b/modules/scale.lua @@ -0,0 +1,434 @@ +BNP = BNP or {} + +local MIN_SCALE = 0.50 +local MAX_SCALE = 1.50 +local DEFAULT_SCALE = 1.00 + +local function Clamp(value) + if value < MIN_SCALE then return MIN_SCALE end + if value > MAX_SCALE then return MAX_SCALE end + return value +end + +function BNP:GetNameplateScale() + BNP_DB = BNP_DB or {} + return Clamp(tonumber(BNP_DB.nameplateScale) or DEFAULT_SCALE) +end + +local function EffectiveScale() + local uiScale = 1.0 + if UIParent and UIParent.GetScale then + uiScale = UIParent:GetScale() or 1.0 + end + return uiScale * BNP:GetNameplateScale() +end + +local function CaptureAnchorState(object, plate, wrapper) + if not object or not object.GetPoint then return nil end + + local state = { + object = object, + points = {}, + } + + local count = 1 + if object.GetNumPoints then + count = object:GetNumPoints() or 1 + if count < 1 then count = 1 end + end + + local i + for i = 1, count do + local point, relativeTo, relativePoint, x, y = object:GetPoint(i) + if point then + if relativeTo == plate then + relativeTo = wrapper + end + + state.points[table.getn(state.points) + 1] = { + point = point, + relativeTo = relativeTo or wrapper, + relativePoint = relativePoint or point, + x = x or 0, + y = y or 0, + } + end + end + + return state +end + +local function AnchorStateMatches(state) + if not state or not state.object or not state.object.GetPoint then return false end + + local object = state.object + local expectedCount = table.getn(state.points) + local currentCount = expectedCount + + if object.GetNumPoints then + currentCount = object:GetNumPoints() or 0 + end + + if currentCount ~= expectedCount then + return false + end + + local i + for i = 1, expectedCount do + local expected = state.points[i] + local point, relativeTo, relativePoint, x, y = object:GetPoint(i) + + if point ~= expected.point + or relativeTo ~= expected.relativeTo + or relativePoint ~= expected.relativePoint + or math.abs((x or 0) - expected.x) > 0.01 + or math.abs((y or 0) - expected.y) > 0.01 then + return false + end + end + + return true +end + +local function RestoreAnchorState(state, wrapper) + if not state or not state.object then return end + + local object = state.object + + if object.GetParent and object.SetParent and object:GetParent() ~= wrapper then + object:SetParent(wrapper) + end + + -- Do nothing in the common case. Blizzard normally only rewrites a subset + -- of nameplate anchors while moving, most notably the level FontString. + if AnchorStateMatches(state) then return end + + if not object.ClearAllPoints or not object.SetPoint then return end + + object:ClearAllPoints() + + local i + for i = 1, table.getn(state.points) do + local p = state.points[i] + object:SetPoint( + p.point, + p.relativeTo or wrapper, + p.relativePoint or p.point, + p.x, + p.y + ) + end +end + +local function SetHoverGlow(plate, shown) + if not plate or not plate.glow then return end + + if shown then + if plate.glow.Show then + plate.glow:Show() + end + else + if plate.glow.Hide then + plate.glow:Hide() + end + end +end + +-- SuperWoW exposes the attached unit GUID through nameplate:GetName(1). +-- Its SetMouseoverUnit(unit) API is specifically intended for custom unit +-- frames: set it on OnEnter and clear it on OnLeave. BNP's scale/Y-offset +-- wrapper owns the mouse hit area, so without this bridge the client never +-- receives a real "mouseover" unit even though clicks are forwarded to the +-- original Blizzard nameplate. +local function GetPlateMouseoverUnit(plate) + if not plate or not plate.GetName then return nil end + + local ok, unit = pcall(function() return plate:GetName(1) end) + if not ok or not unit then return nil end + + if UnitExists then + local existsOK, exists = pcall(UnitExists, unit) + if existsOK and not exists then return nil end + end + + return unit +end + +local function ShowPlateTooltip(wrapper, unit) + if not wrapper or not unit or not GameTooltip or not GameTooltip.SetUnit then return end + + -- SetMouseoverUnit() has already made "mouseover" a valid unit. Prefer + -- that token so tooltip addons see the same unit surface as mouseover macros. + if GameTooltip_SetDefaultAnchor then + GameTooltip_SetDefaultAnchor(GameTooltip, wrapper) + elseif GameTooltip.SetOwner then + GameTooltip:SetOwner(wrapper, "ANCHOR_CURSOR") + end + + local ok = pcall(function() GameTooltip:SetUnit("mouseover") end) + if not ok then + pcall(function() GameTooltip:SetUnit(unit) end) + end + + if GameTooltip.Show then GameTooltip:Show() end +end + +local function SetWrapperMouseover(plate, wrapper) + local unit = GetPlateMouseoverUnit(plate) + if not unit then return end + + -- Keep ownership so a late OnLeave from an overlapping/recycled plate cannot + -- clear the mouseover unit that a newer wrapper has just established. + BNP.BNPMouseoverOwner = wrapper + wrapper.BNPMouseoverUnit = unit + + if SetMouseoverUnit then + local ok = pcall(SetMouseoverUnit, unit) + if ok then + ShowPlateTooltip(wrapper, unit) + end + end +end + +local function ClearWrapperMouseover(wrapper) + if BNP.BNPMouseoverOwner ~= wrapper then return end + + BNP.BNPMouseoverOwner = nil + wrapper.BNPMouseoverUnit = nil + + if SetMouseoverUnit then + pcall(SetMouseoverUnit) + end + + if GameTooltip and GameTooltip.Hide then + GameTooltip:Hide() + end +end + +local function SetYOffsetMouseMode(plate, wrapper, offset) + if not plate or not wrapper then return end + + -- Always use the visual wrapper as BNP's interactive frame, including at + -- Y Offset 0. Previously offset 0 used Blizzard's native Button while + -- positive offsets used BNPScaleWrapper. That split mouse path caused the + -- original Blizzard mouseover glow to disappear at offset 0. + -- + -- Keeping clicks, hit geometry and hover feedback on the same frame also + -- guarantees that the clickable area follows the visible plate at every + -- configured Y offset. + if plate.EnableMouse then + plate:EnableMouse(false) + end + + if wrapper.EnableMouse then + wrapper:EnableMouse(true) + end + + if wrapper.RegisterForClicks then + -- Preserve the classic Blizzard nameplate mouse behavior: + -- left click selects the unit, right click selects it and starts auto attack. + wrapper:RegisterForClicks("LeftButtonUp", "RightButtonUp") + end + + if not wrapper.BNPClickForwardInstalled then + wrapper:SetScript("OnClick", function() + -- The wrapper exists only because BNP visually offsets/scales the original + -- Blizzard plate. Keep Blizzard's native nameplate click semantics by + -- forwarding the ACTUAL mouse button to the original Button. + -- + -- parent:Click() without an argument always simulates LeftButton. That was + -- the reason right click turned into a plain target-select while BNP was + -- enabled. Do not call AttackTarget() manually here: it toggles auto attack + -- and is not equivalent to Blizzard's native nameplate right-click path. + local mouseButton = arg1 or "LeftButton" + local current = this or wrapper + local parent = current and current.plate or plate + if parent and parent:IsShown() and parent.Click then + parent:Click(mouseButton) + end + end) + wrapper.BNPClickForwardInstalled = true + end + + if not wrapper.BNPMouseoverFeedbackInstalled then + wrapper:SetScript("OnEnter", function() + local current = this or wrapper + local parent = current and current.plate or plate + SetHoverGlow(parent, true) + SetWrapperMouseover(parent, current) + end) + + wrapper:SetScript("OnLeave", function() + local current = this or wrapper + local parent = current and current.plate or plate + SetHoverGlow(parent, false) + ClearWrapperMouseover(current) + end) + + -- A projected nameplate can disappear/recycle while the cursor is still + -- inside its old bounds. Clear SuperWoW's synthetic mouseover in that case + -- as well so "mouseover" can never remain stuck on a vanished unit. + wrapper:SetScript("OnHide", function() + local current = this or wrapper + ClearWrapperMouseover(current) + end) + + wrapper.BNPMouseoverFeedbackInstalled = true + end +end + +local function ApplyWrapperOffset(plate, wrapper) + if not plate or not wrapper then return end + + local offset = 0 + if BNP.GetNameplateYOffset then + offset = BNP:GetNameplateYOffset() + end + + -- Move only the child visual container. The WorldFrame-projected Blizzard + -- nameplate itself keeps its original anchor and can continue to be + -- repositioned by the client every frame. + wrapper:ClearAllPoints() + wrapper:SetPoint("TOPLEFT", plate, "TOPLEFT", 0, offset) + wrapper:SetPoint("BOTTOMRIGHT", plate, "BOTTOMRIGHT", 0, offset) + + SetYOffsetMouseMode(plate, wrapper, offset) +end + +local function EnsureScaleWrapper(plate) + if not plate or plate.BNPScaleWrapper then return end + + local originalWidth = plate:GetWidth() + local originalHeight = plate:GetHeight() + + local wrapper = CreateFrame("Button", nil, plate) + wrapper:SetAllPoints(plate) + wrapper.plate = plate + wrapper.originalWidth = originalWidth + wrapper.originalHeight = originalHeight + wrapper.BNPAnchorStates = {} + wrapper:EnableMouse(false) + + plate.BNPScaleWrapper = wrapper + + -- Capture the ORIGINAL Blizzard anchors before changing parentage. + -- Those stored anchors become BNP's authoritative visual layout whenever + -- Y-offset is enabled. + if plate.healthbar then + local state = CaptureAnchorState(plate.healthbar, plate, wrapper) + if state then + table.insert(wrapper.BNPAnchorStates, state) + end + + plate.healthbar:SetParent(wrapper) + RestoreAnchorState(state, wrapper) + plate.healthbar:SetFrameLevel(1) + end + + local regions = { plate:GetRegions() } + local i, object + for i, object in pairs(regions) do + if object and object.SetParent then + local state = CaptureAnchorState(object, plate, wrapper) + if state then + table.insert(wrapper.BNPAnchorStates, state) + end + + object:SetParent(wrapper) + RestoreAnchorState(state, wrapper) + end + end + + ApplyWrapperOffset(plate, wrapper) +end + +function BNP:ApplyNameplateScale(plate) + if not plate then return end + EnsureScaleWrapper(plate) + + local wrapper = plate.BNPScaleWrapper + if not wrapper then return end + + local scale = EffectiveScale() + wrapper:SetScale(scale) + ApplyWrapperOffset(plate, wrapper) + + -- ShaguTweaks also adjusts the original frame bounds so Blizzard's world + -- positioning remains correct while the child visuals honor UI scale. + plate:SetWidth(wrapper.originalWidth * scale) + plate:SetHeight(wrapper.originalHeight * scale) +end + +function BNP:ApplyNameplateScaleAll() + local plate + for plate in pairs(self.plates) do + self:ApplyNameplateScale(plate) + end +end + +function BNP:ApplyNameplateYOffsetAll() + local plate + for plate in pairs(self.plates or {}) do + if plate then + self:ApplyNameplateScale(plate) + end + end +end + +function BNP:MaintainNameplateYOffset(plate) + if not plate or not plate:IsShown() then return end + if not self.GetNameplateYOffset or self:GetNameplateYOffset() <= 0 then return end + + EnsureScaleWrapper(plate) + + local wrapper = plate.BNPScaleWrapper + if not wrapper then return end + + -- Never rediscover regions through plate:GetRegions() here. Once BNP moves + -- Blizzard regions into the wrapper they are no longer guaranteed to be + -- returned by plate:GetRegions(). Keep and maintain the original region + -- objects captured during wrapper creation instead. + local states = wrapper.BNPAnchorStates or {} + local i + for i = 1, table.getn(states) do + RestoreAnchorState(states[i], wrapper) + end + + ApplyWrapperOffset(plate, wrapper) +end + +function BNP:SetNameplateScale(value) + value = tonumber(value) + if not value then + self:Print("Usage: /bnp scale 0.5-1.5") + return + end + + value = Clamp(value) + BNP_DB = BNP_DB or {} + BNP_DB.nameplateScale = value + self:ApplyNameplateScaleAll() + + self:Print("Nameplate scale multiplier set to " .. tostring(value) .. ".") +end + +function BNP:ResetNameplateScale() + BNP_DB = BNP_DB or {} + BNP_DB.nameplateScale = DEFAULT_SCALE + self:ApplyNameplateScaleAll() + self:Print("Nameplate scale multiplier reset to 1.0 (Shagu/UI scale behavior).") +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + BNP:ApplyNameplateScale(current) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + BNP:ApplyNameplateScale(current) + end) +end diff --git a/modules/shagu_darkmode.lua b/modules/shagu_darkmode.lua new file mode 100644 index 0000000..3b02ebe --- /dev/null +++ b/modules/shagu_darkmode.lua @@ -0,0 +1,67 @@ +BNP = BNP or {} + +-- ShaguTweaks Darkened UI compatibility ------------------------------------- +-- ShaguTweaks darkens a nameplate once and marks it with plate.darkened=true. +-- Blizzard can later recycle that same plate and restore its original gold +-- textures while the flag remains true. Shagu then skips the recycled plate. +-- +-- BNP does not implement its own dark mode here. It simply asks ShaguTweaks +-- to run its own DarkenFrame() again whenever Blizzard reuses a nameplate. + +local pending = {} + +local function ShaguDarkModeActive() + return ShaguTweaks + and ShaguTweaks.DarkMode + and ShaguTweaks.DarkenFrame +end + +local function ReapplyShaguDarkMode(plate) + if not plate or not plate:IsShown() then return end + if not ShaguDarkModeActive() then return end + + -- Clear Shagu's one-time marker because this Blizzard plate may have been + -- recycled for another unit and had its textures reset. + plate.darkened = nil + + -- Use ShaguTweaks' own implementation/configuration as the single source of + -- truth. Its blacklist already protects StatusBars, spell icons, portraits, + -- etc. from being incorrectly darkened. + ShaguTweaks.DarkenFrame(plate) + plate.darkened = true +end + +local function QueueReapply(plate) + if not plate then return end + + -- Immediate pass handles normal OnShow. + ReapplyShaguDarkMode(plate) + + -- Blizzard may restore some nameplate texture state immediately after + -- OnShow. Do one delayed pass and then stop; no permanent scanner is needed. + pending[plate] = GetTime() + 0.08 +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + QueueReapply(plate or this) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + QueueReapply(plate or this) + end) +end + +local updater = CreateFrame("Frame") +updater:SetScript("OnUpdate", function() + if not next(pending) then return end + + local now = GetTime() + local plate, due + for plate, due in pairs(pending) do + if now >= due then + pending[plate] = nil + ReapplyShaguDarkMode(plate) + end + end +end) diff --git a/modules/spellprobe.lua b/modules/spellprobe.lua new file mode 100644 index 0000000..636dc47 --- /dev/null +++ b/modules/spellprobe.lua @@ -0,0 +1,227 @@ +BNP = BNP or {} + +local wanted = { + corruption = { "Corruption", "Verderbnis" }, + agony = { "Curse of Agony", "Fluch der Pein" }, + siphon = { "Siphon Life", "Lebensentzug" }, + shadow_word_pain = { "Shadow Word: Pain", "Schattenwort: Schmerz" }, +} + +local function isWanted(name, list) + if not name then return false end + local lower = string.lower(name) + local i + for i = 1, table.getn(list) do + if lower == string.lower(list[i]) then return true end + end + return false +end + +local function normalizeNumber(value) + if not value then return nil end + value = string.gsub(value, ",", ".") + return tonumber(value) +end + +-- WoW 1.12 uses Lua 5.0. Use string.find captures instead of string.match. +local function durationFromLine(text) + if not text then return nil end + local lower = string.lower(text) + local _, _, num + + -- German: 14,95 Sek. / 14.95 Sekunden + _, _, num = string.find(lower, "([0-9]+[%,%.]?[0-9]*)%s*sek") + if num then return normalizeNumber(num) end + + -- English: 14.95 sec / seconds + _, _, num = string.find(lower, "([0-9]+[%,%.]?[0-9]*)%s*sec") + if num then return normalizeNumber(num) end + + -- Generic short form: "over 14.95 s" / "14,95 s" + _, _, num = string.find(lower, "over%s+([0-9]+[%,%.]?[0-9]*)%s+s") + if num then return normalizeNumber(num) end + + return nil +end + +local function getTooltip() + local tooltip = getglobal("BNPSpellProbeTooltip") + if tooltip then return tooltip end + + tooltip = CreateFrame("GameTooltip", "BNPSpellProbeTooltip", UIParent, "GameTooltipTemplate") + tooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + return tooltip +end + +local function scanSpell(slot) + local tooltip = getTooltip() + tooltip:ClearLines() + tooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + tooltip:SetSpell(slot, BOOKTYPE_SPELL) + + local lines = {} + local bestDuration = nil + local i + for i = 1, tooltip:NumLines() do + local fs = getglobal("BNPSpellProbeTooltipTextLeft" .. i) + local text = fs and fs:GetText() + if text and text ~= "" then + table.insert(lines, text) + local parsed = durationFromLine(text) + if parsed and parsed > 0 then + bestDuration = parsed + end + end + end + + return bestDuration, lines +end + +local function findSpell(wantedNames) + local slot = 1 + local found = nil + while true do + local name, rank = GetSpellName(slot, BOOKTYPE_SPELL) + if not name then break end + if isWanted(name, wantedNames) then + found = { slot = slot, name = name, rank = rank } + -- keep scanning so the highest learned rank wins + end + slot = slot + 1 + end + return found +end + +local function findSpellByID(def, spellID) + if not def or not spellID or not SpellInfo then return nil end + + local wantedName, wantedRank = SpellInfo(spellID) + if not wantedName then return nil end + + local slot = 1 + local name, rank + while true do + name, rank = GetSpellName(slot, BOOKTYPE_SPELL) + if not name then break end + + if isWanted(name, def.names or { wantedName }) then + -- SuperWoW SpellInfo exposes the localized rank when available. Matching + -- both name and rank lets us scan the exact spellbook rank that was cast + -- instead of always inheriting the highest learned rank's tooltip. + if wantedRank and wantedRank ~= "" and rank == wantedRank then + return { slot = slot, name = name, rank = rank } + end + end + + slot = slot + 1 + end + + return nil +end + + +BNP.spellbookDurations = BNP.spellbookDurations or {} +BNP.spellbookDurationsByID = BNP.spellbookDurationsByID or {} + +function BNP:RefreshSpellbookDurations() + -- Exact-rank tooltip values can change after talent/spellbook updates. + BNP.spellbookDurationsByID = {} + local id + for id, names in pairs(wanted) do + local spell = findSpell(names) + if spell then + local ok, duration = pcall(scanSpell, spell.slot) + if ok and duration and duration > 0 then + BNP.spellbookDurations[id] = duration + BNP.spellbookDurations[string.lower(spell.name)] = duration + end + end + end +end + +function BNP:GetSpellbookDurationForSpellID(def, spellID) + if not def or not spellID then return nil end + BNP.spellbookDurationsByID = BNP.spellbookDurationsByID or {} + + if BNP.spellbookDurationsByID[spellID] then + return BNP.spellbookDurationsByID[spellID] + end + + local spell = findSpellByID(def, spellID) + if spell then + local ok, duration = pcall(scanSpell, spell.slot) + if ok and duration and duration > 0 then + BNP.spellbookDurationsByID[spellID] = duration + return duration + end + end + + return nil +end + +function BNP:GetSpellbookDurationForDef(def) + if not def then return nil end + BNP.spellbookDurations = BNP.spellbookDurations or {} + + if def.key and BNP.spellbookDurations[def.key] then + return BNP.spellbookDurations[def.key] + end + + if def.names then + local i + for i = 1, table.getn(def.names) do + local key = string.lower(def.names[i]) + if BNP.spellbookDurations[key] then + return BNP.spellbookDurations[key] + end + end + + -- Generic on-demand scan: works for every class, not only the original + -- Warlock/Priest probe list. + local spell = findSpell(def.names) + if spell then + local ok, duration = pcall(scanSpell, spell.slot) + if ok and duration and duration > 0 then + if def.key then BNP.spellbookDurations[def.key] = duration end + BNP.spellbookDurations[string.lower(spell.name)] = duration + return duration + end + end + end + + return nil +end + +function BNP:SpellbookProbe(key) + self:RefreshSpellbookDurations() + local keys = {} + if key and key ~= "" and wanted[key] then + table.insert(keys, key) + else + table.insert(keys, "corruption") + table.insert(keys, "agony") + table.insert(keys, "siphon") + table.insert(keys, "shadow_word_pain") + end + + self:Print("=== Spellbook Tooltip Probe ===") + local k + for k = 1, table.getn(keys) do + local id = keys[k] + local spell = findSpell(wanted[id]) + if not spell then + self:Print(id .. ": not found in spellbook") + else + local ok, duration, lines = pcall(scanSpell, spell.slot) + if not ok then + self:Print(spell.name .. ": tooltip scan ERROR: " .. tostring(duration)) + else + self:Print(spell.name .. " " .. tostring(spell.rank or "") .. " | Slot " .. spell.slot .. " | Duration: " .. tostring(duration or "NOT DETECTED")) + local i + for i = 1, table.getn(lines) do + self:Print(" " .. i .. ": " .. lines[i]) + end + end + end + end +end diff --git a/modules/tankmode.lua b/modules/tankmode.lua new file mode 100644 index 0000000..510d9fe --- /dev/null +++ b/modules/tankmode.lua @@ -0,0 +1,189 @@ +BNP = BNP or {} + +-- Tank Mode for SuperWoW GUID nameplates. +-- GREEN = hostile NPC currently targets the player. +-- RED = hostile NPC currently targets another unit. +-- Idle hostile NPCs keep their normal Blizzard color. + +local GREEN_R, GREEN_G, GREEN_B = 0.00, 1.00, 0.00 +local RED_R, RED_G, RED_B = 1.00, 0.00, 0.00 + +local playerGUID = nil + +local function GetPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then playerGUID = guid end + return playerGUID +end + +local function GetPlateGUID(plate) + if not plate or not plate.GetName then return nil end + local guid = plate:GetName(1) + if guid and guid ~= "" then return guid end + return nil +end + +local function GetTargetGUID(guid) + if not guid then return nil end + local exists, targetGUID = UnitExists(guid .. "target") + if exists then return targetGUID end + return nil +end + +local function GetBar(plate) + return plate and (plate.healthbar or plate.healthBar) or nil +end + +local function IsHostileNPC(guid) + if not guid or not UnitExists(guid) then return false end + if UnitIsPlayer(guid) then return false end + return UnitCanAttack("player", guid) and true or false +end + +local function IsForeignTaggedGUID(guid) + if not guid or not UnitExists(guid) then return false end + if UnitCanAttack and not UnitCanAttack("player", guid) then return false end + if not UnitIsTapped or not UnitIsTapped(guid) then return false end + if UnitIsTappedByPlayer and UnitIsTappedByPlayer(guid) then return false end + return true +end + +local function RestoreNormalColor(plate, bar) + -- Do not restore a cached RGB value. Outside an active Tank Mode state, + -- Blizzard owns the NPC healthbar color completely. + plate.BNPTankActive = nil + plate.BNPTankR = nil + plate.BNPTankG = nil + plate.BNPTankB = nil +end + +local function ClearTankState(plate) + if not plate then return end + plate.BNPTankActive = nil + plate.BNPTankR = nil + plate.BNPTankG = nil + plate.BNPTankB = nil +end + +local function ApplyTankColor(plate, bar, r, g, b) + bar:SetStatusBarColor(r, g, b, 1) + plate.BNPTankActive = true + plate.BNPTankR = r + plate.BNPTankG = g + plate.BNPTankB = b +end + +local function EnforceTankColor(plate) + if not plate or not plate.BNPTankActive then return end + local bar = GetBar(plate) + if not bar or not plate.BNPTankR then return end + + local r, g, b = bar:GetStatusBarColor() + if math.abs(r - plate.BNPTankR) > 0.01 + or math.abs(g - plate.BNPTankG) > 0.01 + or math.abs(b - plate.BNPTankB) > 0.01 then + bar:SetStatusBarColor(plate.BNPTankR, plate.BNPTankG, plate.BNPTankB, 1) + end +end + +function BNP:UpdateTankModePlate(plate) + if not plate or not plate:IsShown() then return end + + local bar = GetBar(plate) + if not bar then return end + + if not self:IsTankModeEnabled() then + RestoreNormalColor(plate, bar) + return + end + + local guid = GetPlateGUID(plate) + if not IsHostileNPC(guid) then + -- NPC reaction colors belong to Blizzard. Never restore a stale cached + -- Tank Mode color onto neutral/friendly/recycled nameplates. + ClearTankState(plate) + return + end + + -- Foreign-tag grey has priority over Tank Mode. Previously Tank Mode wrote + -- RED every 0.10s while fullalpha.lua restored GREY every frame, producing + -- the visible grey/red flicker. Clear any cached tank color and let the + -- foreign-tag renderer own the bar until the mob is ours/untapped again. + if IsForeignTaggedGUID(guid) then + ClearTankState(plate) + return + end + + local myGUID = GetPlayerGUID() + if not myGUID then return end + + local targetGUID = GetTargetGUID(guid) + + if targetGUID and targetGUID == myGUID then + ApplyTankColor(plate, bar, GREEN_R, GREEN_G, GREEN_B) + elseif targetGUID then + ApplyTankColor(plate, bar, RED_R, RED_G, RED_B) + else + RestoreNormalColor(plate, bar) + end +end + +function BNP:RefreshTankMode() + local plate + for plate in pairs(self.plates or {}) do + if plate and plate:IsShown() then + self:UpdateTankModePlate(plate) + end + end +end + +function BNP:UpdateTankMode() + self:RefreshTankMode() +end + +function BNP:InstallTankMode() + if self.BNPTankModeInstalled then return end + if not self.libnameplate or not self.libnameplate.OnUpdate then return end + + GetPlayerGUID() + + table.insert(self.libnameplate.OnInit, function(plate) + local current = plate or this + if not current then return end + + ClearTankState(current) + + local bar = GetBar(current) + if bar and not current.BNPTankColorHooked then + local old = bar:GetScript("OnValueChanged") + bar:SetScript("OnValueChanged", function() + if old then old() end + EnforceTankColor(current) + end) + current.BNPTankColorHooked = true + end + end) + + table.insert(self.libnameplate.OnShow, function(plate) + local current = plate or this + if not current then return end + ClearTankState(current) + end) + + table.insert(self.libnameplate.OnUpdate, function(plate, elapsed) + local current = plate or this + if not current then return end + + -- Tank Mode is optional. Avoid all GUID/target/tap queries while it is off. + -- Only clean an old BNP-owned state if one is still present. + if not BNP:IsTankModeEnabled() then + if current.BNPTankActive then ClearTankState(current) end + return + end + + BNP:UpdateTankModePlate(current) + EnforceTankColor(current) + end) + + self.BNPTankModeInstalled = true +end diff --git a/modules/totems.lua b/modules/totems.lua new file mode 100644 index 0000000..dd3d043 --- /dev/null +++ b/modules/totems.lua @@ -0,0 +1,417 @@ +if not BNP.libnameplate then return end + +-- Lightweight PvP totem indicators ------------------------------------------ +-- Totem detection is intentionally nameplate/name based. It never reads or +-- modifies the aura cache, combat log timers or CC tracking. + +local TOTEM_DEFS = { + { key = "earthbind", spellID = 2484, names = { "Earthbind Totem", "Totem der Erdbindung" }, texture = "Interface\\Icons\\Spell_Nature_StrengthOfEarthTotem02" }, + { key = "stoneskin", spellID = 8071, names = { "Stoneskin Totem", "Totem der Steinhaut" }, texture = "Interface\\Icons\\Spell_Nature_StoneSkinTotem" }, + { key = "strengthofearth", spellID = 8075, names = { "Strength of Earth Totem", "Totem der Erdstärke" }, texture = "Interface\\Icons\\Spell_Nature_EarthBindTotem" }, + { key = "tremor", spellID = 8143, names = { "Tremor Totem", "Totem des Erdstosses", "Totem des Erdstoßes" }, texture = "Interface\\Icons\\Spell_Nature_TremorTotem" }, + { key = "grounding", spellID = 8177, names = { "Grounding Totem", "Totem der Erdung" }, texture = "Interface\\Icons\\Spell_Nature_GroundingTotem" }, + { key = "stoneclaw", spellID = 5730, names = { "Stoneclaw Totem", "Totem der Steinklaue" }, texture = "Interface\\Icons\\Spell_Nature_StoneClawTotem" }, + + { key = "searing", spellID = 3599, names = { "Searing Totem", "Totem der Verbrennung" }, texture = "Interface\\Icons\\Spell_Fire_SearingTotem" }, + { key = "flametongue", spellID = 8227, names = { "Flametongue Totem", "Totem der Flammenzunge" }, texture = "Interface\\Icons\\Spell_Nature_GuardianWard" }, + { key = "magma", spellID = 8190, names = { "Magma Totem", "Totem des glühenden Magmas" }, texture = "Interface\\Icons\\Spell_Fire_SelfDestruct" }, + { key = "firenova", spellID = 1535, names = { "Fire Nova Totem", "Totem der Feuernova" }, texture = "Interface\\Icons\\Spell_Fire_SealOfFire" }, + { key = "frostres", spellID = 8181, names = { "Frost Resistance Totem", "Totem des Frostwiderstands" }, texture = "Interface\\Icons\\Spell_FrostResistanceTotem_01" }, + { key = "fireres", spellID = 8184, names = { "Fire Resistance Totem", "Totem des Feuerwiderstands" }, texture = "Interface\\Icons\\Spell_FireResistanceTotem_01" }, + + { key = "healingstream", spellID = 5394, names = { "Healing Stream Totem", "Totem des heilenden Flusses" }, texture = "Interface\\Icons\\INV_Spear_04" }, + { key = "manaspring", spellID = 5675, names = { "Mana Spring Totem", "Totem der Manaquelle" }, texture = "Interface\\Icons\\Spell_Nature_ManaRegenTotem" }, + { key = "poisoncleansing", spellID = 8166, names = { "Poison Cleansing Totem", "Totem der Giftreinigung" }, texture = "Interface\\Icons\\Spell_Nature_PoisonCleansingTotem" }, + { key = "diseasecleansing", spellID = 8170, names = { "Disease Cleansing Totem", "Totem der Krankheitsreinigung" }, texture = "Interface\\Icons\\Spell_Nature_DiseaseCleansingTotem" }, + { key = "manatide", spellID = 16190, names = { "Mana Tide Totem", "Totem der Manaflut" }, texture = "Interface\\Icons\\Spell_Frost_SummonWaterElemental" }, + { key = "natureres", spellID = 10595, names = { "Nature Resistance Totem", "Totem des Naturwiderstands" }, texture = "Interface\\Icons\\Spell_Nature_NatureResistanceTotem" }, + + { key = "windfury", spellID = 8512, names = { "Windfury Totem", "Totem des Windzorns" }, texture = "Interface\\Icons\\Spell_Nature_Windfury" }, + { key = "sentry", spellID = 6495, names = { "Sentry Totem", "Totem des Wachens" }, texture = "Interface\\Icons\\Spell_Nature_RemoveCurse" }, + { key = "graceofair", spellID = 8835, names = { "Grace of Air Totem", "Totem der Luftanmut" }, texture = "Interface\\Icons\\Spell_Nature_InvisibilityTotem" }, + { key = "tranquilair", spellID = 25908, names = { "Tranquil Air Totem", "Totem der beruhigenden Winde" }, texture = "Interface\\Icons\\Spell_Nature_Brilliance" }, + { key = "windwall", spellID = 15107, names = { "Windwall Totem", "Totem der Windmauer" }, texture = "Interface\\Icons\\Spell_Nature_EarthBind" }, +} + + +local function CleanName(text) + if not text then return nil end + text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") + text = string.gsub(text, "|r", "") + text = string.gsub(text, "^%s+", "") + text = string.gsub(text, "%s+$", "") + if text == "" then return nil end + return text +end + +local function NormalizeTotemName(text) + text = CleanName(text) + if not text then return nil end + + text = string.lower(text) + -- Vanilla totem NPCs often carry their spell rank in the NPC name, e.g. + -- "Searing Totem VI" or "Windfury Totem II". Match the base totem name. + text = string.gsub(text, "%s+rank%s+%d+$", "") + text = string.gsub(text, "%s+%d+$", "") + text = string.gsub(text, "%s+[ivxlcdm]+$", "") + text = string.gsub(text, "%s+$", "") + return text +end + +local function AddName(def, name) + if not name or name == "" then return end + local normalized = NormalizeTotemName(name) + local i + for i = 1, table.getn(def.names) do + if NormalizeTotemName(def.names[i]) == normalized then return end + end + table.insert(def.names, name) +end + +local definitionsResolved = false +local function ResolveDefinitions() + if definitionsResolved then return end + definitionsResolved = true + + if not SpellInfo then return end + local i + for i = 1, table.getn(TOTEM_DEFS) do + local def = TOTEM_DEFS[i] + local ok, name, _, texture = pcall(SpellInfo, def.spellID) + if ok then + AddName(def, name) + if texture and texture ~= "" then def.texture = texture end + end + end +end + +local GENERIC_TOTEM_DEF = { + key = "generic", + names = {}, + texture = "Interface\\Icons\\Spell_Nature_StoneClawTotem", +} + +local function FindTotemDefinition(name) + local normalized = NormalizeTotemName(name) + if not normalized then return nil end + + local i, n + for i = 1, table.getn(TOTEM_DEFS) do + local def = TOTEM_DEFS[i] + for n = 1, table.getn(def.names) do + if normalized == NormalizeTotemName(def.names[n]) then return def end + end + end + + -- Future/custom server totems still get icon-only treatment even when BNP + -- does not yet know their exact spell icon. Only accept "totem" as a + -- complete word though: player names such as "Totemtiger" must never be + -- mistaken for a totem just because they contain those five letters. + if normalized == "totem" + or string.sub(normalized, 1, 6) == "totem " + or string.sub(normalized, -6) == " totem" + or string.find(normalized, " totem ", 1, true) then + return GENERIC_TOTEM_DEF + end + + return nil +end + +local function GetPlateName(plate) + if plate and plate.name and plate.name.GetText then + local name = CleanName(plate.name:GetText()) + if name then return name end + end + + if plate and plate.GetName and UnitName then + local token = plate:GetName(1) + if token then + local ok, name = pcall(UnitName, token) + if ok then return CleanName(name) end + end + end + + return nil +end + +local function PositionIndicator(plate, indicator) + if not plate or not indicator then return end + indicator:ClearAllPoints() + + -- Icon-only mode: replace the visible nameplate at its original position. + if plate.healthbar then + indicator:SetPoint("CENTER", plate.healthbar, "CENTER", 0, 0) + else + indicator:SetPoint("CENTER", plate, "CENTER", 0, 0) + end +end + +local function RememberAndHideVisual(object, state) + if not object or object == state.indicator then return end + if not object.SetAlpha or not object.GetAlpha then return end + + if state.alpha[object] == nil then + state.alpha[object] = object:GetAlpha() + end + object:SetAlpha(0) +end + +local TOTEM_VISUAL_RESCAN_INTERVAL = 0.50 + +local function CaptureTotemPlateVisuals(plate, state, now) + if not plate or not state then return end + + -- Capturing every projected region/child allocates temporary Lua tables. + -- Doing that every rendered frame produced needless GC pressure in BGs with + -- several totems. Re-scan only occasionally so late-created BNP children + -- (castbar/combo/etc.) are still discovered without per-frame allocations. + local regions = { plate:GetRegions() } + local i + for i = 1, table.getn(regions) do + RememberAndHideVisual(regions[i], state) + end + + local children = { plate:GetChildren() } + for i = 1, table.getn(children) do + RememberAndHideVisual(children[i], state) + end + + state.nextCapture = (now or GetTime()) + TOTEM_VISUAL_RESCAN_INTERVAL +end + +local function SuppressTotemPlateVisuals(plate, forceCapture) + if not plate then return end + + local indicator = plate.BNPTotemIndicator + local state = plate.BNPTotemVisualState + local now = GetTime() + if not state then + state = { alpha = {}, indicator = indicator, nextCapture = 0 } + plate.BNPTotemVisualState = state + else + state.indicator = indicator + end + + -- Important PvP totems should remain clearly visible even when BNP's + -- non-target alpha option would normally fade the underlying nameplate. + if plate.SetAlpha and plate.GetAlpha and plate:GetAlpha() ~= 1 then plate:SetAlpha(1) end + + if forceCapture or not state.nextCapture or now >= state.nextCapture then + CaptureTotemPlateVisuals(plate, state, now) + end + + -- Blizzard can restore alpha on its native regions every frame. Re-enforce + -- only the already-cached objects here; this loop allocates nothing. + local object + for object in pairs(state.alpha or {}) do + if object ~= indicator and object.GetAlpha and object.SetAlpha and object:GetAlpha() ~= 0 then + object:SetAlpha(0) + end + end + + -- BNPTotemIndicator is itself a direct child of the plate. + if indicator and indicator.SetAlpha and indicator.GetAlpha and indicator:GetAlpha() ~= 1 then + indicator:SetAlpha(1) + end +end + +local function RestoreTotemPlateVisuals(plate) + if not plate then return end + local state = plate.BNPTotemVisualState + if not state then return end + + local object, alpha + for object, alpha in pairs(state.alpha or {}) do + if object and object.SetAlpha then + object:SetAlpha(alpha or 1) + end + end + + plate.BNPTotemVisualState = nil +end + +-- Blizzard can restore parts of a projected nameplate every frame. Once a +-- plate has been identified as a totem, suppress every normal visual again +-- after the plate's native/fullalpha OnUpdate has run. The replacement icon +-- remains a child of the plate and is explicitly excluded, so it keeps the +-- same proven projection/positioning path as the working detection version. +local function InstallTotemVisualGuard(plate) + if not plate or plate.BNPTotemVisualGuard then return end + + local oldOnUpdate = plate:GetScript("OnUpdate") + plate:SetScript("OnUpdate", function() + if oldOnUpdate then oldOnUpdate() end + local current = this or plate + if not current then return end + + if current.BNPTotemLastKey and + BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() then + SuppressTotemPlateVisuals(current) + if current.BNPTotemIndicator then + if current.BNPTotemIndicator.GetAlpha and current.BNPTotemIndicator:GetAlpha() ~= 1 then + current.BNPTotemIndicator:SetAlpha(1) + end + if not current.BNPTotemIndicator:IsShown() then current.BNPTotemIndicator:Show() end + end + elseif current.BNPTotemVisualState then + RestoreTotemPlateVisuals(current) + end + end) + + plate.BNPTotemVisualGuard = true +end + +local function InstallTotemHideGuard(plate) + if not plate or plate.BNPTotemHideGuard then return end + + local oldOnHide = plate:GetScript("OnHide") + plate:SetScript("OnHide", function() + if oldOnHide then oldOnHide() end + local current = this or plate + if not current then return end + + if current.BNPTotemIndicator then current.BNPTotemIndicator:Hide() end + RestoreTotemPlateVisuals(current) + current.BNPTotemLastName = nil + current.BNPTotemLastKey = nil + end) + + plate.BNPTotemHideGuard = true +end + +local function ApplyIndicatorLayout(plate, force) + local indicator = plate and plate.BNPTotemIndicator + if not indicator then return end + + local size = BNP.GetTotemIconSize and BNP:GetTotemIconSize() or 24 + local level = plate.GetFrameLevel and ((plate:GetFrameLevel() or 0) + 2) or 2 + + if not force and + indicator.BNPLastSize == size and + indicator.BNPLastFrameLevel == level then + return + end + + indicator.BNPLastSize = size + indicator.BNPLastFrameLevel = level + indicator:SetWidth(size) + indicator:SetHeight(size) + if indicator.SetFrameLevel then indicator:SetFrameLevel(level) end + PositionIndicator(plate, indicator) +end + +local function CreateIndicator(plate) + if not plate or plate.BNPTotemIndicator then return end + + local indicator = CreateFrame("Frame", nil, plate) + local size = BNP.GetTotemIconSize and BNP:GetTotemIconSize() or 24 + indicator:SetWidth(size) + indicator:SetHeight(size) + indicator:SetFrameLevel((plate.GetFrameLevel and plate:GetFrameLevel() or 0) + 2) + + local border = indicator:CreateTexture(nil, "BACKGROUND") + border:SetTexture(0, 0, 0, 1) + border:SetPoint("TOPLEFT", indicator, "TOPLEFT", -1, 1) + border:SetPoint("BOTTOMRIGHT", indicator, "BOTTOMRIGHT", 1, -1) + indicator.border = border + + local texture = indicator:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(indicator) + texture:SetTexCoord(0.07, 0.93, 0.07, 0.93) + indicator.texture = texture + + indicator:Hide() + plate.BNPTotemIndicator = indicator + ApplyIndicatorLayout(plate, true) +end + +local function UpdateIndicator(plate, force) + if not plate then return end + CreateIndicator(plate) + local indicator = plate.BNPTotemIndicator + if not indicator then return end + + ApplyIndicatorLayout(plate, force) + + if not (BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled()) then + indicator:Hide() + RestoreTotemPlateVisuals(plate) + plate.BNPTotemLastName = nil + plate.BNPTotemLastKey = nil + return + end + + ResolveDefinitions() + local name = GetPlateName(plate) + + -- Nameplate identity is stable most of the time. If the displayed name did + -- not change, reuse the previous totem decision and avoid UnitIsPlayer/name + -- normalization work on every 0.10s libnameplate update. + if not force and name == plate.BNPTotemLastName then + if plate.BNPTotemLastKey then + if not indicator:IsShown() then indicator:Show() end + SuppressTotemPlateVisuals(plate) + else + if indicator:IsShown() then indicator:Hide() end + RestoreTotemPlateVisuals(plate) + end + return + end + + -- Hard safety guard: a player nameplate can never be a shaman totem. + -- SuperWoW nameplate tokens make this check reliable in BGs and prevent + -- false positives even if a player deliberately uses a totem-like name. + local token = plate.GetName and plate:GetName(1) or nil + if token and UnitIsPlayer and UnitIsPlayer(token) then + indicator:Hide() + RestoreTotemPlateVisuals(plate) + plate.BNPTotemLastName = name + plate.BNPTotemLastKey = nil + return + end + + plate.BNPTotemLastName = name + plate.BNPTotemLastKey = nil + + local def = FindTotemDefinition(name) + if not def then + indicator:Hide() + RestoreTotemPlateVisuals(plate) + return + end + + plate.BNPTotemLastKey = def.key + indicator.texture:SetTexture(def.texture) + indicator:Show() + SuppressTotemPlateVisuals(plate, force) +end + +function BNP:RefreshTotemIndicators() + ResolveDefinitions() + local plate + for plate in pairs(self.plates or {}) do + if plate then UpdateIndicator(plate, true) end + end +end + +-- Public table keeps the totem definitions centralized and easy to extend. +BNP.TotemIndicatorDefinitions = TOTEM_DEFS + +table.insert(BNP.libnameplate.OnInit, function(plate) + CreateIndicator(plate) + InstallTotemVisualGuard(plate) + InstallTotemHideGuard(plate) + UpdateIndicator(plate, true) +end) + +table.insert(BNP.libnameplate.OnShow, function(plate) + InstallTotemVisualGuard(plate) + InstallTotemHideGuard(plate) + plate.BNPTotemLastName = nil + plate.BNPTotemLastKey = nil + UpdateIndicator(plate, true) +end) + +table.insert(BNP.libnameplate.OnUpdate, function(plate) + UpdateIndicator(plate, false) +end) diff --git a/modules/unknown.lua b/modules/unknown.lua new file mode 100644 index 0000000..15c2e01 --- /dev/null +++ b/modules/unknown.lua @@ -0,0 +1,498 @@ +BNP = BNP or {} +BNP.unknownDirectEvents = BNP.unknownDirectEvents or {} + +-- Beta spell collector ------------------------------------------------------- +-- Purpose: help testers report missing Turtle/custom spell IDs without +-- editing Lua files. It records unknown own cast IDs and unknown debuff IDs +-- that newly appear shortly after one of the player's casts. +-- +-- It is intentionally diagnostic only: it never changes tracking/rendering. + +BNP.unknownBeta = BNP.unknownBeta or { + entries = {}, + order = {}, + recentCasts = {}, + seenByGUID = {}, + activeUntil = 0, +} + +local _, playerClass = UnitClass("player") + +local CLASS_AURAS = { + WARLOCK = BNP.WarlockAuras, + PRIEST = BNP.PriestAuras, + WARRIOR = BNP.WarriorAuras, + ROGUE = BNP.RogueAuras, + HUNTER = BNP.HunterAuras, + MAGE = BNP.MageAuras, + DRUID = BNP.DruidAuras, + SHAMAN = BNP.ShamanAuras, + PALADIN = BNP.PaladinAuras, +} +local AURA_DEFS = CLASS_AURAS[playerClass] or {} + +local function GetPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then return guid end + return nil +end + +local function GetUnitGUID(unit) + local exists, guid = UnitExists(unit) + if exists and guid then return guid end + return nil +end + +local function IsExplicitKnownID(spellID) + if not spellID then return false end + + -- Dark Harvest is handled by dedicated Warlock logic. + if spellID == 52552 then return true end + + local i, j, def + for i = 1, table.getn(AURA_DEFS) do + def = AURA_DEFS[i] + + if def.spellIDs then + for j = 1, table.getn(def.spellIDs) do + if def.spellIDs[j] == spellID then return true end + end + end + + if def.durations and def.durations[spellID] then + return true + end + end + + return false +end + +local function SpellName(spellID) + if not spellID or not SpellInfo then return "?" end + local name = SpellInfo(spellID) + return name or "?" +end + +local function AddEntry(kind, spellID, spellName, guid, trigger, confidence) + if not spellID then return end + + local key = tostring(kind) .. ":" .. tostring(spellID) + local data = BNP.unknownBeta + local old = data.entries[key] + + if old then + old.count = (old.count or 1) + 1 + old.lastSeen = GetTime() + if confidence == "TARGET" then old.confidence = confidence end + if trigger and not old.trigger then old.trigger = trigger end + return + end + + local entry = { + kind = kind, + spellID = spellID, + name = spellName or SpellName(spellID), + class = playerClass or "?", + guid = guid, + trigger = trigger, + confidence = confidence or "?", + count = 1, + firstSeen = GetTime(), + lastSeen = GetTime(), + } + + data.entries[key] = entry + table.insert(data.order, key) +end + +local function RememberCast(spellID, targetGUID, eventType) + local data = BNP.unknownBeta + local now = GetTime() + data.activeUntil = now + 8.0 + local cast = { + time = now, + spellID = spellID, + name = SpellName(spellID), + targetGUID = targetGUID, + eventType = eventType, + } + + table.insert(data.recentCasts, cast) + while table.getn(data.recentCasts) > 20 do + table.remove(data.recentCasts, 1) + end + + -- Unknown own CAST/CHANNEL IDs are useful too. Some trap/proc systems expose + -- the effect as a separate cast event before the aura is seen. + if spellID and not IsExplicitKnownID(spellID) then + AddEntry("CAST", spellID, cast.name, targetGUID, nil, "OWN") + end +end + +local function FindRecentTrigger(guid, now) + local casts = BNP.unknownBeta.recentCasts + local i + for i = table.getn(casts), 1, -1 do + local cast = casts[i] + local age = now - cast.time + + if age > 6 then + break + end + + if cast.targetGUID and guid and cast.targetGUID == guid then + return cast, "TARGET" + end + + -- Short global fallback catches effects such as traps where the placement + -- cast may not carry the eventual victim GUID. + if age <= 1.5 then + return cast, "RECENT" + end + end + + return nil, nil +end + +local function ScanUnit(unit, guid, now) + if not unit or not guid then return end + + local seen = BNP.unknownBeta.seenByGUID[guid] + if not seen then + seen = {} + BNP.unknownBeta.seenByGUID[guid] = seen + end + + local current = {} + local i + for i = 1, 32 do + local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) + if not texture then break end + + if auraSpellID then + current[auraSpellID] = true + + if not seen[auraSpellID] and not IsExplicitKnownID(auraSpellID) then + local trigger, confidence = FindRecentTrigger(guid, now) + if trigger then + AddEntry( + "AURA", + auraSpellID, + SpellName(auraSpellID), + guid, + tostring(trigger.name) .. " [" .. tostring(trigger.spellID) .. "]", + confidence + ) + end + end + end + end + + -- Keep only currently present aura IDs so a later re-application can be + -- detected again without growing this table forever. + BNP.unknownBeta.seenByGUID[guid] = current +end + + +-- Indirect Effect Audit: traps/procs may never expose a UnitDebuff ID. +BNP.unknownBeta.combat = BNP.unknownBeta.combat or {} +local EFFECT_WINDOW = 8.0 + + +local function IsUsefulEffectLine(raw) + if not raw or raw == "" then return false end + local lower = string.lower(raw) + + -- Ignore obvious player-action/noise lines. + if string.find(lower, "you cast ", 1, true) then return false end + if string.find(lower, "auto shot", 1, true) then return false end + if string.find(lower, "login", 1, true) then return false end + + -- Keep lines that actually describe a harmful aura/effect or its periodic damage. + if string.find(lower, "afflicted by", 1, true) then return true end + if string.find(lower, "is afflicted", 1, true) then return true end + if string.find(lower, "is affected by", 1, true) then return true end + if string.find(lower, "suffers", 1, true) then return true end + if string.find(lower, "periodic", 1, true) then return true end + + return false +end + +local function AddCombatEffect(raw) + if not IsUsefulEffectLine(raw) then return end + local now=GetTime() + local casts=BNP.unknownBeta.recentCasts + local trigger=nil + local i + for i=table.getn(casts),1,-1 do + local c=casts[i] + if now-c.time > EFFECT_WINDOW then break end + if c.spellID ~= 75 then trigger=c break end + end + if not trigger then return end + if string.find(string.lower(raw),"auto shot",1,true) then return end + + local key=tostring(trigger.spellID)..":"..raw + local old=BNP.unknownBeta.combat[key] + if old then old.count=old.count+1 return end + BNP.unknownBeta.combat[key]={ + raw=raw, triggerID=trigger.spellID, triggerName=trigger.name, count=1 + } +end + +local combatFrame=CreateFrame("Frame") +local combatEvents={ + "CHAT_MSG_COMBAT_SELF_HITS", + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE", + "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE", +} +local cei +for cei=1,table.getn(combatEvents) do combatFrame:RegisterEvent(combatEvents[cei]) end +combatFrame:SetScript("OnEvent",function() AddCombatEffect(arg1) end) + +local castFrame = CreateFrame("Frame") +castFrame:RegisterEvent("UNIT_CASTEVENT") +castFrame:SetScript("OnEvent", function() + local playerGUID = GetPlayerGUID() + if not playerGUID or arg1 ~= playerGUID then return end + + local eventType = arg3 + if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end + + RememberCast(arg4, arg2, eventType) +end) + +local scanner = CreateFrame("Frame") +local elapsed = 0 +scanner:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed < 0.35 then return end + elapsed = 0 + + local now = GetTime() + if not BNP.unknownBeta.activeUntil or now > BNP.unknownBeta.activeUntil then + return + end + + local targetGUID = GetUnitGUID("target") + if targetGUID then + ScanUnit("target", targetGUID, now) + end + + local plate + for plate in pairs(BNP.plates) do + if plate:IsShown() and plate.GetName then + local token = plate:GetName(1) + if token then + local guid = GetUnitGUID(token) + if guid then + ScanUnit(token, guid, now) + end + end + end + end +end) + +function BNP:PrintUnknownBeta(verbose) + local data = self.unknownBeta + self:Print("=== BNP Beta: Unknown Auras (" .. tostring(playerClass or "?") .. ") ===") + + local auraCount = 0 + local i + for i = 1, table.getn(data.order) do + local entry = data.entries[data.order[i]] + if entry and entry.kind == "AURA" then + auraCount = auraCount + 1 + local line = + tostring(auraCount) .. + " | AURA | ID " .. tostring(entry.spellID) .. + " | " .. tostring(entry.name) .. + " | x" .. tostring(entry.count or 1) + + if entry.confidence then + line = line .. " | " .. tostring(entry.confidence) + end + + self:Print(line) + + if entry.trigger then + self:Print(" Trigger: " .. tostring(entry.trigger)) + end + end + end + + local combatCount = 0 + local ck + for ck in pairs(data.combat or {}) do combatCount = combatCount + 1 end + + if combatCount > 0 then + self:Print("--- Indirect Effects ---") + local key, e + for key, e in pairs(data.combat) do + self:Print("EFFECT | Trigger " .. tostring(e.triggerName) .. " [" .. tostring(e.triggerID) .. "] | x" .. tostring(e.count)) + self:Print(" " .. tostring(e.raw)) + end + end + + if auraCount == 0 and combatCount == 0 then + self:Print("No unknown negative auras/effects found since login.") + end + + if verbose then + self:Print("--- Verbose: unknown player cast IDs ---") + local castCount = 0 + for i = 1, table.getn(data.order) do + local entry = data.entries[data.order[i]] + if entry and entry.kind == "CAST" then + castCount = castCount + 1 + self:Print( + tostring(castCount) .. + " | CAST | ID " .. tostring(entry.spellID) .. + " | " .. tostring(entry.name) .. + " | x" .. tostring(entry.count or 1) + ) + end + end + if castCount == 0 then + self:Print("No unknown cast IDs.") + end + else + self:Print("Open Missing Spell / Aura in the settings to copy a complete report.") + end +end + +function BNP:ResetUnknownBeta() + self.unknownBeta.entries = {} + self.unknownBeta.order = {} + self.unknownBeta.recentCasts = {} + self.unknownBeta.seenByGUID = {} + self.unknownBeta.combat = {} + self.unknownBeta.activeUntil = 0 + self:Print("Unknown spell/effect list cleared.") +end + + + +-- Target Aura Probe ---------------------------------------------------------- +-- Diagnostic helper for proc/talent auras that the normal unknown collector +-- may miss because they were already present in its seen-state. +function BNP:PrintTargetAuraProbe() + local exists, guid = UnitExists("target") + if not exists or not guid then + self:Print("Target Aura Probe: no target selected.") + return + end + + self:Print("=== BNP Target Negative Auras ===") + self:Print("Target: " .. tostring(UnitName("target") or "?") .. " | GUID " .. tostring(guid)) + + local count = 0 + local i + for i = 1, 32 do + local texture, stacks, dtype, auraSpellID = UnitDebuff("target", i) + if not texture then break end + + count = count + 1 + local name = auraSpellID and SpellName(auraSpellID) or "?" + self:Print( + tostring(count) .. + " | ID " .. tostring(auraSpellID or "?") .. + " | " .. tostring(name) .. + " | stacks " .. tostring(stacks or 0) .. + " | type " .. tostring(dtype or "?") + ) + end + + if count == 0 then + self:Print("No negative target auras found.") + end +end + +-- Direct player spell collector -------------------------------------------- +-- Captures successful player spell events that do not create a persistent +-- aura and therefore never appear in the normal unknown-aura collector. + +local function GetLocalPlayerGUID() + local exists, guid = UnitExists("player") + if exists and guid then return guid end + return nil +end + +local function RememberDirectEvent(eventType, spellID, targetGUID, extra) + if not spellID then return end + + local name = nil + if SpellInfo then name = SpellInfo(spellID) end + name = name or ("Spell " .. tostring(spellID)) + + local key = tostring(eventType) .. ":" .. tostring(spellID) + local entry = BNP.unknownDirectEvents[key] + + if not entry then + entry = { + eventType = eventType, + spellID = spellID, + name = name, + targetGUID = targetGUID, + count = 0, + extra = extra, + last = GetTime(), + } + BNP.unknownDirectEvents[key] = entry + end + + entry.count = (entry.count or 0) + 1 + entry.targetGUID = targetGUID or entry.targetGUID + entry.extra = extra or entry.extra + entry.last = GetTime() +end + +local directFrame = CreateFrame("Frame") +directFrame:RegisterEvent("UNIT_CASTEVENT") +directFrame:SetScript("OnEvent", function() + local casterGUID = arg1 + local targetGUID = arg2 + local eventType = arg3 + local spellID = arg4 + local playerGUID = GetLocalPlayerGUID() + + if not playerGUID or casterGUID ~= playerGUID then return end + if not spellID then return end + + if eventType == "START" or eventType == "CAST" or eventType == "CHANNEL" then + RememberDirectEvent(eventType, spellID, targetGUID, arg5) + end +end) + +function BNP:PrintUnknownDirectEvents() + local list = {} + local key, entry + + for key, entry in pairs(self.unknownDirectEvents or {}) do + table.insert(list, entry) + end + + table.sort(list, function(a, b) + return (a.last or 0) > (b.last or 0) + end) + + if table.getn(list) == 0 then + self:Print("--- Direct Player Events: none captured ---") + return + end + + self:Print("--- Direct Player Events ---") + + local i, e + for i = 1, table.getn(list) do + e = list[i] + self:Print( + tostring(i) .. " | " .. + tostring(e.eventType) .. " | ID " .. + tostring(e.spellID) .. " | " .. + tostring(e.name) .. " | x" .. + tostring(e.count or 1) + ) + end +end diff --git a/modules/visualguard.lua b/modules/visualguard.lua new file mode 100644 index 0000000..a15697f --- /dev/null +++ b/modules/visualguard.lua @@ -0,0 +1,46 @@ +BNP = BNP or {} + +-- Safe one-shot native visual repair --------------------------------------- +-- Some UI/skin addons can alter the native Blizzard nameplate border or +-- border before BNP discovers the plate. Repair the native border only +-- when a plate is initialized/shown. There is intentionally NO heartbeat and +-- no layout/parent/anchor enforcement here, so BNP never fights WorldFrame +-- movement while the player/camera is moving. + +local BORDER_TEXTURE = "Interface\\Tooltips\\Nameplate-Border" +local HEALTH_TEXTURE = "Interface\\TargetingFrame\\UI-StatusBar" + +local function IsTotemIconOnly(plate) + return plate and plate.BNPTotemLastKey and + BNP.AreTotemIndicatorsEnabled and BNP:AreTotemIndicatorsEnabled() +end + +local function RestoreBorder(plate) + local border = plate and plate.border + if not border then return end + + if border.GetTexture and border.SetTexture and border:GetTexture() ~= BORDER_TEXTURE then + border:SetTexture(BORDER_TEXTURE) + end + + if border.IsShown and border.Show and not border:IsShown() then + border:Show() + end +end + +function BNP:RepairNativeNameplateVisuals(plate) + if not plate then return end + if IsTotemIconOnly(plate) then return end + + RestoreBorder(plate) +end + +if BNP.libnameplate then + table.insert(BNP.libnameplate.OnInit, function(plate) + BNP:RepairNativeNameplateVisuals(plate or this) + end) + + table.insert(BNP.libnameplate.OnShow, function(plate) + BNP:RepairNativeNameplateVisuals(plate or this) + end) +end diff --git a/spells/druid.lua b/spells/druid.lua new file mode 100644 index 0000000..17be58b --- /dev/null +++ b/spells/druid.lua @@ -0,0 +1,85 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.DruidAuras = { + { + key = "moonfire", + names = { "Moonfire" }, + duration = 18, + }, + { + key = "rake", + names = { "Rake" }, + duration = 9, + }, + { + key = "rip", + names = { "Rip" }, + duration = 12, + }, + { + key = "faerie_fire", + names = { "Faerie Fire" }, + duration = 40, + }, + { + key = "faerie_fire_feral", + names = { "Faerie Fire (Feral)" }, + duration = 40, + }, + { + key = "entangling_roots", + names = { "Entangling Roots" }, + duration = 27, + }, + { + key = "insect_swarm", + names = { "Insect Swarm" }, + duration = 18, + }, + { + key = "bash", + names = { "Bash" }, + duration = 4, + }, + { + key = "pounce", + names = { "Pounce" }, + duration = 2, + }, + { + key = "pounce_bleed", + names = { "Pounce Bleed" }, + duration = 18, + }, + { + key = "hibernate", + names = { "Hibernate" }, + duration = 40, + }, + { + key = "demoralizing_roar", + names = { "Demoralizing Roar" }, + duration = 30, + }, + { + key = "feral_charge_effect", + names = { "Feral Charge Effect" }, + duration = 4, + }, + { + key = "challenging_roar", + names = { "Challenging Roar" }, + duration = 6, + }, + { + key = "lacerate", + names = { "Lacerate" }, + duration = 8, + }, + { + key = "mangle", + names = { "Mangle" }, + duration = 2, + }, +} diff --git a/spells/hunter.lua b/spells/hunter.lua new file mode 100644 index 0000000..0d4524c --- /dev/null +++ b/spells/hunter.lua @@ -0,0 +1,97 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.HunterAuras = { + { + key = "immolation_trap_effect", + names = { "Immolation Trap Effect" }, + duration = 15, + spellIDs = { 14298 }, + }, + { + key = "explosive_trap_effect", + names = { "Explosive Trap Effect" }, + duration = 20, + }, + { + key = "freezing_trap_effect", + names = { "Freezing Trap Effect" }, + duration = 20, + }, + { + key = "serpent_sting", + names = { "Serpent Sting" }, + duration = 15, + }, + { + key = "viper_sting", + names = { "Viper Sting" }, + duration = 8, + }, + { + key = "scorpid_sting", + names = { "Scorpid Sting" }, + duration = 20, + }, + { + key = "improved_scorpid_sting", + names = { "Improved Scorpid Sting" }, + duration = 20, + }, + { + key = "wing_clip", + names = { "Wing Clip" }, + duration = 10, + }, + { + key = "improved_wing_clip", + names = { "Improved Wing Clip" }, + duration = 5, + }, + { + key = "concussive_shot", + names = { "Concussive Shot" }, + duration = 4, + }, + { + key = "improved_concussive_shot", + names = { "Improved Concussive Shot" }, + duration = 3, + }, + { + key = "scatter_shot", + names = { "Scatter Shot" }, + duration = 4, + }, + { + key = "wyvern_sting", + names = { "Wyvern Sting" }, + duration = 12, + }, + { + key = "entrapment", + names = { "Entrapment" }, + duration = 5, + }, + { + key = "counterattack", + names = { "Counterattack" }, + duration = 5, + }, + { + key = "intimidation", + names = { "Intimidation" }, + duration = 3, + }, + { + key = "scare_beast", + names = { "Scare Beast" }, + duration = 20, + }, + { + key = "hunters_mark", + names = { "Hunter's Mark" }, + duration = 120, + shared = true, + }, +} diff --git a/spells/mage.lua b/spells/mage.lua new file mode 100644 index 0000000..cefc483 --- /dev/null +++ b/spells/mage.lua @@ -0,0 +1,70 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.MageAuras = { + { + key = "frostbolt", + names = { "Frostbolt" }, + duration = 9, + }, + { + key = "fireball", + names = { "Fireball" }, + duration = 8, + }, + { + key = "pyroblast", + names = { "Pyroblast" }, + duration = 12, + }, + { + key = "polymorph", + names = { "Polymorph" }, + duration = 50, + }, + { + key = "cone_of_cold", + names = { "Cone of Cold" }, + duration = 8, + }, + { + key = "frost_nova", + names = { "Frost Nova" }, + duration = 8, + }, + { + key = "counterspell", + names = { "Counterspell" }, + duration = 10, + }, + { + key = "counterspell_silence", + names = { "Counterspell - Silenced" }, + duration = 4, + }, + { + key = "ignite", + names = { "Ignite" }, + duration = 4, + }, + { + key = "impact", + names = { "Impact" }, + duration = 2, + }, + { + key = "frostbite", + names = { "Frostbite" }, + duration = 5, + }, + { + key = "fire_vulnerability", + names = { "Fire Vulnerability" }, + duration = 30, + }, + { + key = "winters_chill", + names = { "Winter's Chill" }, + duration = 15, + }, +} diff --git a/spells/paladin.lua b/spells/paladin.lua new file mode 100644 index 0000000..1930b03 --- /dev/null +++ b/spells/paladin.lua @@ -0,0 +1,60 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.PaladinAuras = { + { + key = "judgement_light", + names = { "Judgement of Light" }, + duration = 10, + spellIDs = { 20344 }, + }, + { + key = "judgement_wisdom", + names = { "Judgement of Wisdom" }, + duration = 10, + spellIDs = { 20355 }, + }, + { + key = "judgement_justice", + names = { "Judgement of Justice" }, + duration = 10, + spellIDs = { 20184 }, + }, + { + key = "judgement_crusader", + names = { "Judgement of the Crusader" }, + duration = 10, + spellIDs = { 20302 }, + }, + { + key = "hammer_of_justice", + names = { "Hammer of Justice" }, + duration = 6, + }, + { + key = "repentance", + names = { "Repentance" }, + duration = 6, + }, + { + key = "turn_undead", + names = { "Turn Undead" }, + duration = 20, + }, + { + key = "hand_of_reckoning", + names = { "Hand of Reckoning" }, + duration = 3, + }, + { + key = "crusader_strike", + names = { "Crusader Strike" }, + duration = 30, + + -- Server variants differ: some apply a real hostile Crusader Strike + -- aura, while others only grant a player-side buff. PROC forces BNP's + -- strict UnitDebuff-confirmation path so the cast itself can never create + -- a fake enemy-nameplate debuff. + category = "PROC", + }, +} diff --git a/spells/priest.lua b/spells/priest.lua new file mode 100644 index 0000000..0ebaff3 --- /dev/null +++ b/spells/priest.lua @@ -0,0 +1,78 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.PriestAuras = { + { + key = "shadow_word_pain", + names = { "Shadow Word: Pain", "Schattenwort: Schmerz" }, + duration = 18, + }, + { + key = "devouring_plague", + names = { "Devouring Plague" }, + duration = 24, + }, + { + key = "vampiric_embrace", + names = { "Vampiric Embrace" }, + duration = 60, + }, + { + key = "mind_flay", + channel = true, + names = { "Mind Flay" }, + duration = 3, + }, + { + key = "psychic_scream", + names = { "Psychic Scream" }, + duration = 8, + }, + { + key = "silence", + names = { "Silence" }, + duration = 5, + }, + { + key = "hex_of_weakness", + names = { "Hex of Weakness" }, + duration = 120, + }, + { + key = "touch_of_weakness", + names = { "Touch of Weakness" }, + duration = 120, + }, + { + key = "holy_fire", + names = { "Holy Fire" }, + duration = 10, + }, + { + key = "blackout", + names = { "Blackout" }, + duration = 3, + }, + { + key = "starshards", + channel = true, + names = { "Starshards" }, + duration = 6, + }, + { + key = "shadow_vulnerability", + names = { "Shadow Vulnerability" }, + duration = 15, + }, + { + key = "shackle_undead", + names = { "Shackle Undead" }, + duration = 50, + }, + { + key = "mind_control", + channel = true, + names = { "Mind Control" }, + duration = 60, + }, +} diff --git a/spells/registry_shadow.lua b/spells/registry_shadow.lua new file mode 100644 index 0000000..c3e2356 --- /dev/null +++ b/spells/registry_shadow.lua @@ -0,0 +1,153 @@ +BNP = BNP or {} + +-- Shadow-only spell registry. +-- This module never controls tracking or rendering. It only audits whether a +-- successful local cast could be resolved by a future SpellDB implementation. +BNP.SpellDBShadow = BNP.SpellDBShadow or { + byID = {}, + byName = {}, + byTexture = {}, + stats = { total = 0, id = 0, name = 0, texture = 0, unknown = 0, ignored = 0 }, + recent = {}, +} + +local DB = BNP.SpellDBShadow + +-- Successful SuperWoW cast events which are not hostile target auras and must +-- not count as missing SpellDB entries. More can be added as Turtle exposes +-- additional proc/engine events during testing. +local IGNORED_SPELL_IDS = { + [17941] = true, -- Shadow Trance / Nightfall proc + [836] = true, -- LOGINEFFECT engine event +} + +local function Lower(value) + if not value then return nil end + return string.lower(value) +end + +local function RegisterDef(def) + if not def then return end + + if def.names then + for _, name in ipairs(def.names) do + DB.byName[Lower(name)] = def + end + end + + if def.textureMatch then + DB.byTexture[Lower(def.textureMatch)] = def + end + + if def.durations then + for spellID in pairs(def.durations) do + DB.byID[spellID] = def + end + end + + if def.spellIDs then + for _, spellID in ipairs(def.spellIDs) do + DB.byID[spellID] = def + end + end +end + +for _, def in ipairs(BNP.WarlockAuras or {}) do + RegisterDef(def) +end + +local function FindByTexture(texture) + local lowerTexture = Lower(texture) + if not lowerTexture then return nil end + + for fragment, def in pairs(DB.byTexture) do + if string.find(lowerTexture, fragment) then + return def + end + end + + return nil +end + +local function PushRecent(spellID, spellName, result, key) + local recent = DB.recent + table.insert(recent, 1, { + id = spellID, + name = spellName or "?", + result = result, + key = key or "?", + }) + + while table.getn(recent) > 10 do + table.remove(recent) + end +end + +function BNP:AuditSpellDB(spellID) + if not spellID or not SpellInfo then return end + + if IGNORED_SPELL_IDS[spellID] then + DB.stats.ignored = (DB.stats.ignored or 0) + 1 + return + end + + local spellName, _, texture = SpellInfo(spellID) + local def = DB.byID[spellID] + local result = "ID" + + if not def and spellName then + def = DB.byName[Lower(spellName)] + result = "NAME" + end + + if not def and texture then + def = FindByTexture(texture) + result = "TEXTURE" + end + + DB.stats.total = DB.stats.total + 1 + + if def then + if result == "ID" then + DB.stats.id = DB.stats.id + 1 + elseif result == "NAME" then + DB.stats.name = DB.stats.name + 1 + else + DB.stats.texture = DB.stats.texture + 1 + end + PushRecent(spellID, spellName, result, def.key) + else + DB.stats.unknown = DB.stats.unknown + 1 + PushRecent(spellID, spellName, "UNKNOWN", "-") + end +end + +function BNP:PrintSpellDBAudit() + local stats = DB.stats + self:Print("SpellDB Schattenmodus – Renderer unverändert.") + self:Print("Casts: " .. stats.total .. " | ID: " .. stats.id .. " | Name: " .. stats.name .. " | Textur: " .. stats.texture .. " | Unbekannt: " .. stats.unknown .. " | Ignoriert: " .. (stats.ignored or 0)) + + if table.getn(DB.recent) == 0 then + self:Print("Noch keine eigenen erfolgreichen Casts protokolliert.") + return + end + + self:Print("Letzte Casts:") + for i = 1, table.getn(DB.recent) do + local entry = DB.recent[i] + self:Print(entry.name .. " [" .. tostring(entry.id) .. "] -> " .. entry.result .. " (" .. entry.key .. ")") + end +end + +function BNP:ResetSpellDBAudit() + DB.stats.total = 0 + DB.stats.id = 0 + DB.stats.name = 0 + DB.stats.texture = 0 + DB.stats.unknown = 0 + DB.stats.ignored = 0 + for i = table.getn(DB.recent), 1, -1 do + table.remove(DB.recent, i) + end + self:Print("SpellDB-Protokoll zurückgesetzt.") +end diff --git a/spells/rogue.lua b/spells/rogue.lua new file mode 100644 index 0000000..d685997 --- /dev/null +++ b/spells/rogue.lua @@ -0,0 +1,75 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.RogueAuras = { + { + key = "garrote", + names = { "Garrote" }, + duration = 18, + }, + { + key = "rupture", + names = { "Rupture" }, + duration = 6, + }, + { + key = "gouge", + names = { "Gouge" }, + duration = 4, + }, + { + key = "kidney_shot", + names = { "Kidney Shot" }, + duration = 6, + }, + { + key = "cheap_shot", + names = { "Cheap Shot" }, + duration = 4, + }, + { + key = "sap", + names = { "Sap" }, + duration = 45, + }, + { + key = "blind", + names = { "Blind" }, + duration = 10, + }, + { + key = "expose_armor", + names = { "Expose Armor" }, + duration = 30, + }, + { + key = "kick_silenced", + names = { "Kick - Silenced" }, + duration = 2, + }, + { + key = "hemorrhage", + names = { "Hemorrhage" }, + duration = 15, + }, + { + key = "crippling_poison", + names = { "Crippling Poison" }, + duration = 12, + }, + { + key = "mind_numbing_poison", + names = { "Mind-numbing Poison" }, + duration = 10, + }, + { + key = "wound_poison", + names = { "Wound Poison" }, + duration = 15, + }, + { + key = "deadly_poison", + names = { "Deadly Poison", "Deadly Poison II", "Deadly Poison III", "Deadly Poison IV", "Deadly Poison V" }, + duration = 12, + }, +} diff --git a/spells/shaman.lua b/spells/shaman.lua new file mode 100644 index 0000000..3a23dd9 --- /dev/null +++ b/spells/shaman.lua @@ -0,0 +1,40 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.ShamanAuras = { + { + key = "flame_shock", + names = { "Flame Shock" }, + duration = 12, + }, + { + key = "frost_shock", + names = { "Frost Shock" }, + duration = 8, + }, + { + key = "stormstrike", + names = { "Stormstrike" }, + duration = 12, + }, + { + key = "earth_shock", + names = { "Earth Shock" }, + duration = 2, + }, + { + key = "earthbind", + names = { "Earthbind" }, + duration = 5, + }, + { + key = "earthgrab", + names = { "Earthgrab" }, + duration = 4, + }, + { + key = "frostbrand_attack", + names = { "Frostbrand Attack" }, + duration = 8, + }, +} diff --git a/spells/warlock.lua b/spells/warlock.lua new file mode 100644 index 0000000..6c838a1 --- /dev/null +++ b/spells/warlock.lua @@ -0,0 +1,172 @@ +BNP = BNP or {} + +-- Warlock debuffs supported by the SuperWoW own-cast tracker. +-- Durations are Vanilla defaults. Rank-specific values are used where ranks +-- materially change the duration. Server-specific effects such as Dark +-- Harvest are intentionally handled in a later compatibility pass. +BNP.WarlockAuras = { + { + key = "corruption", + names = { "Corruption", "Verderbnis" }, + texture = "Interface\\Icons\\Spell_Shadow_AbominationExplosion", + textureMatch = "abominationexplosion", + duration = 18, + durations = { [172] = 12, [6222] = 15, [6223] = 18, [7648] = 18, [11671] = 18, [11672] = 18 }, + }, + { + key = "curse_of_agony", + category = "CURSE", + names = { "Curse of Agony", "Fluch der Pein" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfSargeras", + textureMatch = "curseofsargeras", + duration = 24, + spellIDs = { 980, 1014, 6217, 11711, 11712, 11713 }, + }, + { + key = "siphon_life", + names = { "Siphon Life", "Lebensentzug" }, + texture = "Interface\\Icons\\Spell_Shadow_Requiem", + textureMatch = "requiem", + duration = 30, + spellIDs = { 18265, 18879, 18880, 18881 }, + }, + { + key = "immolate", + names = { "Immolate", "Feuerbrand" }, + texture = "Interface\\Icons\\Spell_Fire_Immolation", + textureMatch = "immolation", + duration = 15, + spellIDs = { 348, 707, 1094, 2941, 11665, 11667, 11668 }, + }, + { + key = "curse_of_doom", + category = "CURSE", + names = { "Curse of Doom", "Fluch der Verdammnis" }, + texture = "Interface\\Icons\\Spell_Shadow_AuraOfDarkness", + textureMatch = "auraofdarkness", + duration = 60, + spellIDs = { 603, 30910 }, + }, + { + key = "curse_of_weakness", + category = "CURSE", + names = { "Curse of Weakness", "Fluch der Schwäche" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfMannoroth", + textureMatch = "curseofmannoroth", + duration = 120, + spellIDs = { 702, 1108, 6205, 7646, 11707, 11708 }, + }, + { + key = "curse_of_recklessness", + category = "CURSE", + names = { "Curse of Recklessness", "Fluch der Tollkühnheit" }, + texture = "Interface\\Icons\\Spell_Shadow_UnholyStrength", + textureMatch = "unholystrength", + duration = 120, + spellIDs = { 704, 7658, 7659, 11717 }, + }, + { + key = "curse_of_tongues", + category = "CURSE", + names = { "Curse of Tongues", "Fluch der Sprachen" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfTounges", + textureMatch = "curseoftounges", + duration = 30, + spellIDs = { 1714, 11719 }, + }, + { + key = "curse_of_elements", + category = "CURSE", + names = { "Curse of the Elements", "Curse of Elements", "Fluch der Elemente" }, + texture = "Interface\\Icons\\Spell_Shadow_ChillTouch", + textureMatch = "chilltouch", + duration = 300, + spellIDs = { 1490, 11721, 11722 }, + }, + { + key = "curse_of_shadow", + category = "CURSE", + names = { "Curse of Shadow", "Fluch der Schatten" }, + texture = "Interface\\Icons\\Spell_Shadow_CurseOfAchimonde", + textureMatch = "curseofachimonde", + duration = 300, + spellIDs = { 17862, 17937 }, + }, + { + key = "curse_of_exhaustion", + category = "CURSE", + names = { "Curse of Exhaustion", "Fluch der Erschöpfung" }, + texture = "Interface\\Icons\\Spell_Shadow_GrimWard", + textureMatch = "grimward", + duration = 12, + spellIDs = { 18223 }, + }, + { + key = "fear", + names = { "Fear", "Furcht" }, + texture = "Interface\\Icons\\Spell_Shadow_Possession", + textureMatch = "possession", + duration = 20, + durations = { [5782] = 10, [6213] = 15, [6215] = 20 }, + }, + { + key = "howl_of_terror", + names = { "Howl of Terror", "Schreckensgeheul" }, + texture = "Interface\\Icons\\Spell_Shadow_DeathScream", + textureMatch = "deathscream", + duration = 15, + durations = { [5484] = 10, [17928] = 15 }, + }, + { + key = "banish", + names = { "Banish", "Verbannen" }, + texture = "Interface\\Icons\\Spell_Shadow_Cripple", + textureMatch = "cripple", + duration = 30, + durations = { [710] = 20, [18647] = 30 }, + }, + { + key = "death_coil", + names = { "Death Coil", "Todesmantel" }, + texture = "Interface\\Icons\\Spell_Shadow_DeathCoil", + textureMatch = "deathcoil", + duration = 3, + spellIDs = { 6789, 17925, 17926 }, + }, + { + key = "drain_life", + channel = true, + names = { "Drain Life", "Blutsauger" }, + texture = "Interface\\Icons\\Spell_Shadow_LifeDrain02_Purple", + textureMatch = "lifedrain02", + duration = 5, + spellIDs = { 689, 699, 709, 7651, 11699, 11700 }, + }, + { + key = "drain_soul", + channel = true, + names = { "Drain Soul", "Seelendieb" }, + texture = "Interface\\Icons\\Spell_Shadow_Haunting", + textureMatch = "haunting", + duration = 15, + spellIDs = { 1120, 8288, 8289, 11675 }, + }, + { + key = "enslave_demon", + names = { "Enslave Demon", "Dämonensklave" }, + texture = "Interface\\Icons\\Spell_Shadow_EnslaveDemon", + textureMatch = "enslavedemon", + duration = 300, + spellIDs = { 1098, 11725, 11726 }, + }, + { + key = "shadow_vulnerability", + names = { "Shadow Vulnerability" }, + duration = 10, + spellIDs = { 17794, 17797, 17798, 17799, 17800 }, + -- Shadow Vulnerability uses a different aura spell ID for each talent rank. + -- Track every rank so BNP also sees/refreshes Rank 5 [17800]. + shared = true, + sharedSpellIDs = { 17794, 17797, 17798, 17799, 17800 }, + }, +} diff --git a/spells/warrior.lua b/spells/warrior.lua new file mode 100644 index 0000000..93f613b --- /dev/null +++ b/spells/warrior.lua @@ -0,0 +1,113 @@ +BNP = BNP or {} + +-- Expanded against ShaguPlates enUS debuff tables. +BNP.WarriorAuras = { + { + key = "rend", + names = { "Rend" }, + duration = 21, + }, + { + key = "hamstring", + names = { "Hamstring" }, + duration = 15, + }, + { + key = "improved_hamstring", + names = { "Improved Hamstring" }, + duration = 5, + }, + { + key = "thunder_clap", + names = { "Thunder Clap" }, + duration = 30, + aoe = true, + }, + { + key = "demoralizing_shout", + names = { "Demoralizing Shout" }, + duration = 30, + aoe = true, + }, + { + key = "sunder_armor", + names = { "Sunder Armor" }, + duration = 30, + }, + { + key = "piercing_howl", + names = { "Piercing Howl" }, + duration = 6, + aoe = true, + }, + { + key = "mortal_strike", + names = { "Mortal Strike" }, + duration = 10, + }, + { + key = "intimidating_shout", + names = { "Intimidating Shout" }, + duration = 8, + -- Intimidating Shout applies its CC to the primary target and nearby + -- enemies. Treat it as an AoE aura so every affected visible GUID is + -- live-confirmed and receives its own 8-second timer. + aoe = true, + }, + { + key = "mocking_blow", + names = { "Mocking Blow" }, + duration = 6, + }, + { + key = "disarm", + names = { "Disarm" }, + duration = 10, + }, + { + key = "taunt", + names = { "Taunt" }, + duration = 3, + }, + { + key = "challenging_shout", + names = { "Challenging Shout" }, + duration = 6, + aoe = true, + }, + { + key = "charge_stun", + names = { "Charge Stun" }, + duration = 1, + }, + { + key = "intercept_stun", + names = { "Intercept Stun" }, + duration = 3, + }, + { + key = "concussion_blow", + names = { "Concussion Blow" }, + duration = 5, + }, + { + key = "revenge_stun", + names = { "Revenge Stun" }, + duration = 3, + }, + { + key = "pummel", + names = { "Pummel" }, + duration = 4, + }, + { + key = "shield_bash", + names = { "Shield Bash" }, + duration = 6, + }, + { + key = "deep_wound", + names = { "Deep Wound", "Deep Wounds" }, + duration = 12, + }, +}