From 46eb343ff7d42ee3231e9f9a3d82ee8817ef2edd Mon Sep 17 00:00:00 2001 From: Wurmschwanz Date: Fri, 2 Oct 2026 01:19:39 +0200 Subject: [PATCH] Add files via upload --- core/config.lua | 46 +- core/options.lua | 143 +- modules/auras.lua | 36 +- modules/auras.lua.bak_carnage | 4442 +++++++++++++++++++++++++++++++++ modules/personalnameplate.lua | 306 ++- 5 files changed, 4931 insertions(+), 42 deletions(-) create mode 100644 modules/auras.lua.bak_carnage diff --git a/core/config.lua b/core/config.lua index 5aa9cbe..b9d37b3 100644 --- a/core/config.lua +++ b/core/config.lua @@ -82,9 +82,14 @@ BNP.defaults = BNP.defaults or { personalNameplate = false, personalNameplateCombatOnly = true, personalNameplateClassColor = false, + personalNameplateHideLevel = false, personalNameplateHealthText = "both", personalNameplateDebuffs = false, + personalNameplateBuffs = false, personalNameplateDebuffYOffset = 0, + personalNameplateBuffYOffset = 0, + personalNameplateDebuffXOffset = 0, + personalNameplateBuffXOffset = 0, personalNameplateScale = 1.0, personalNameplateYOffset = -90, } @@ -247,9 +252,14 @@ function BNP:InitConfig() if BNP_DB.personalNameplate == nil then BNP_DB.personalNameplate = self.defaults.personalNameplate end if BNP_DB.personalNameplateCombatOnly == nil then BNP_DB.personalNameplateCombatOnly = self.defaults.personalNameplateCombatOnly end if BNP_DB.personalNameplateClassColor == nil then BNP_DB.personalNameplateClassColor = self.defaults.personalNameplateClassColor end + if BNP_DB.personalNameplateHideLevel == nil then BNP_DB.personalNameplateHideLevel = self.defaults.personalNameplateHideLevel end if BNP_DB.personalNameplateHealthText == nil then BNP_DB.personalNameplateHealthText = self.defaults.personalNameplateHealthText end if BNP_DB.personalNameplateDebuffs == nil then BNP_DB.personalNameplateDebuffs = self.defaults.personalNameplateDebuffs end + if BNP_DB.personalNameplateBuffs == nil then BNP_DB.personalNameplateBuffs = self.defaults.personalNameplateBuffs end if BNP_DB.personalNameplateDebuffYOffset == nil then BNP_DB.personalNameplateDebuffYOffset = self.defaults.personalNameplateDebuffYOffset end + if BNP_DB.personalNameplateBuffYOffset == nil then BNP_DB.personalNameplateBuffYOffset = self.defaults.personalNameplateBuffYOffset end + if BNP_DB.personalNameplateDebuffXOffset == nil then BNP_DB.personalNameplateDebuffXOffset = self.defaults.personalNameplateDebuffXOffset end + if BNP_DB.personalNameplateBuffXOffset == nil then BNP_DB.personalNameplateBuffXOffset = self.defaults.personalNameplateBuffXOffset end if BNP_DB.personalNameplateScale == nil then BNP_DB.personalNameplateScale = self.defaults.personalNameplateScale end if BNP_DB.personalNameplateYOffset == nil then BNP_DB.personalNameplateYOffset = self.defaults.personalNameplateYOffset end end @@ -728,6 +738,10 @@ function BNP:IsPersonalNameplateClassColorEnabled() return BNP_DB and BNP_DB.personalNameplateClassColor == true end +function BNP:IsPersonalNameplateLevelHidden() + return BNP_DB and BNP_DB.personalNameplateHideLevel == true +end + function BNP:GetPersonalNameplateHealthTextMode() local mode = (BNP_DB and BNP_DB.personalNameplateHealthText) or self.defaults.personalNameplateHealthText or "both" if mode ~= "off" and mode ~= "percent" and mode ~= "hp" and mode ~= "both" then @@ -740,10 +754,38 @@ function BNP:IsPersonalNameplateDebuffsEnabled() return BNP_DB and BNP_DB.personalNameplateDebuffs == true end +function BNP:IsPersonalNameplateBuffsEnabled() + return BNP_DB and BNP_DB.personalNameplateBuffs == true +end + function BNP:GetPersonalNameplateDebuffYOffset() local value = (BNP_DB and tonumber(BNP_DB.personalNameplateDebuffYOffset)) or self.defaults.personalNameplateDebuffYOffset or 0 - if value < -50 then value = -50 end - if value > 50 then value = 50 end + if value < -100 then value = -100 end + if value > 100 then value = 100 end + if value >= 0 then return math.floor(value + 0.5) end + return math.ceil(value - 0.5) +end + +function BNP:GetPersonalNameplateBuffYOffset() + local value = (BNP_DB and tonumber(BNP_DB.personalNameplateBuffYOffset)) or self.defaults.personalNameplateBuffYOffset or 0 + if value < -100 then value = -100 end + if value > 100 then value = 100 end + if value >= 0 then return math.floor(value + 0.5) end + return math.ceil(value - 0.5) +end + +function BNP:GetPersonalNameplateDebuffXOffset() + local value = (BNP_DB and tonumber(BNP_DB.personalNameplateDebuffXOffset)) or self.defaults.personalNameplateDebuffXOffset or 0 + if value < -100 then value = -100 end + if value > 100 then value = 100 end + if value >= 0 then return math.floor(value + 0.5) end + return math.ceil(value - 0.5) +end + +function BNP:GetPersonalNameplateBuffXOffset() + local value = (BNP_DB and tonumber(BNP_DB.personalNameplateBuffXOffset)) or self.defaults.personalNameplateBuffXOffset or 0 + if value < -100 then value = -100 end + if value > 100 then value = 100 end if value >= 0 then return math.floor(value + 0.5) end return math.ceil(value - 0.5) end diff --git a/core/options.lua b/core/options.lua index 2858db2..86585ce 100644 --- a/core/options.lua +++ b/core/options.lua @@ -825,11 +825,12 @@ function BNP:CreateOptions() local frame = CreateFrame("Frame", "BNPOptionsFrame", UIParent) frame:SetWidth(470) - frame:SetHeight(comboOptionsClass and 735 or 710) + frame:SetHeight(comboOptionsClass and 963 or 883) -- Keep the top edge in roughly the same place and add the extra room - -- downward. The Icons page uses the height for Totems, Raid Marks and - -- Quest Indicators without crowding or clipping. - frame:SetPoint("CENTER", UIParent, "CENTER", 0, -40) + -- downward. Rogue/Druid need more room because the Combo Points section + -- sits above Personal Nameplate; the larger frame lets those controls keep + -- comfortable vertical spacing instead of being packed against the border. + frame:SetPoint("CENTER", UIParent, "CENTER", 0, comboOptionsClass and -109 or -102) frame:SetFrameStrata("DIALOG") frame:SetBackdrop({ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", @@ -1305,11 +1306,11 @@ function BNP:CreateOptions() -- Clean two-column grid: visibility, appearance, text/debuff positioning, -- then plate scale/position. Rogue/Druid get a little extra window height -- so the same layout stays intact below the Combo Points section. - local personalSectionY = comboOptionsClass and -494 or -442 + local personalSectionY = comboOptionsClass and -500 or -448 CreateSection(nameplatesPage, "Personal Nameplate", personalSectionY) -- Row 1: visibility. - local personalNameplate = CreateCheck(nameplatesPage, "Enable Personal Nameplate", personalSectionY - 22, function() + local personalNameplate = CreateCheck(nameplatesPage, "Enable Personal Nameplate", personalSectionY - 24, function() BNP_DB.personalNameplate = this:GetChecked() and true or false if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end if frame.UpdateDependentControls then frame:UpdateDependentControls() end @@ -1323,7 +1324,7 @@ function BNP:CreateOptions() personalNameplate:SetScript("OnLeave", function() GameTooltip:Hide() end) frame.personalNameplateCheck = personalNameplate - local personalCombatOnly = CreateCheck(nameplatesPage, "Combat Only", personalSectionY - 22, function() + local personalCombatOnly = CreateCheck(nameplatesPage, "Combat Only", personalSectionY - 24, function() BNP_DB.personalNameplateCombatOnly = this:GetChecked() and true or false if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end end, 246) @@ -1336,8 +1337,8 @@ function BNP:CreateOptions() personalCombatOnly:SetScript("OnLeave", function() GameTooltip:Hide() end) frame.personalNameplateCombatOnlyCheck = personalCombatOnly - -- Row 2: appearance / aura toggles. - local personalClassColor = CreateCheck(nameplatesPage, "Class Color", personalSectionY - 48, function() + -- Row 2: appearance. + local personalClassColor = CreateCheck(nameplatesPage, "Class Color", personalSectionY - 56, function() BNP_DB.personalNameplateClassColor = this:GetChecked() and true or false if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end end, 22) @@ -1350,10 +1351,25 @@ function BNP:CreateOptions() personalClassColor:SetScript("OnLeave", function() GameTooltip:Hide() end) frame.personalNameplateClassColorCheck = personalClassColor - local personalDebuffs = CreateCheck(nameplatesPage, "Show Debuffs", personalSectionY - 48, function() - BNP_DB.personalNameplateDebuffs = this:GetChecked() and true or false + local personalHideLevel = CreateCheck(nameplatesPage, "Hide Level", personalSectionY - 56, function() + BNP_DB.personalNameplateHideLevel = this:GetChecked() and true or false if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end end, 246) + personalHideLevel:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Hide Level", 1, 0.82, 0) + GameTooltip:AddLine("Hides the level and removes the round level medallion so the Personal Nameplate becomes a closed centered bar.", 1, 1, 1, true) + GameTooltip:Show() + end) + personalHideLevel:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.personalNameplateHideLevelCheck = personalHideLevel + + -- Row 3: aura toggles. Buffs and debuffs are separate so either row can be + -- disabled without doing aura work for that type in the background. + local personalDebuffs = CreateCheck(nameplatesPage, "Show Debuffs", personalSectionY - 88, function() + BNP_DB.personalNameplateDebuffs = this:GetChecked() and true or false + if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end + end, 22) personalDebuffs:SetScript("OnEnter", function() GameTooltip:SetOwner(this, "ANCHOR_RIGHT") GameTooltip:SetText("Show Debuffs", 1, 0.82, 0) @@ -1363,17 +1379,30 @@ function BNP:CreateOptions() personalDebuffs:SetScript("OnLeave", function() GameTooltip:Hide() end) frame.personalNameplateDebuffsCheck = personalDebuffs - -- Row 3: text mode on the left, debuff offset on the right. + local personalBuffs = CreateCheck(nameplatesPage, "Show Buffs", personalSectionY - 88, function() + BNP_DB.personalNameplateBuffs = this:GetChecked() and true or false + if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end + end, 246) + personalBuffs:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Show Buffs", 1, 0.82, 0) + GameTooltip:AddLine("Shows short buffs such as Renew, Rejuvenation and shields. Long buffs over 60 seconds are ignored.", 1, 1, 1, true) + GameTooltip:Show() + end) + personalBuffs:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.personalNameplateBuffsCheck = personalBuffs + + -- Row 4: text mode on the left, debuff Y offset on the right. local personalHealthTextLabel = nameplatesPage:CreateFontString(nil, "OVERLAY", "GameFontNormal") - personalHealthTextLabel:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 28, personalSectionY - 72) + personalHealthTextLabel:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 28, personalSectionY - 120) personalHealthTextLabel:SetText("Health Text") frame.personalNameplateHealthTextLabel = personalHealthTextLabel local personalHealthTextDropdown = CreateFrame("Frame", "BNPPersonalHealthTextDropdown", nameplatesPage, "UIDropDownMenuTemplate") - personalHealthTextDropdown:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 10, personalSectionY - 82) + personalHealthTextDropdown:SetPoint("TOPLEFT", nameplatesPage, "TOPLEFT", 10, personalSectionY - 132) UIDropDownMenu_SetWidth(142, personalHealthTextDropdown) - local personalDebuffYOffset = CreateSlider(nameplatesPage, "Debuff Y Offset", -50, 50, 1, personalSectionY - 82, 246, 150) + local personalDebuffYOffset = CreateSlider(nameplatesPage, "Debuff Y Offset", -100, 100, 1, personalSectionY - 132, 246, 150) personalDebuffYOffset:SetScript("OnValueChanged", function() if not BNP_DB then return end local value = RoundSignedInteger(this:GetValue()) @@ -1390,6 +1419,58 @@ function BNP:CreateOptions() personalDebuffYOffset:SetScript("OnLeave", function() GameTooltip:Hide() end) frame.personalNameplateDebuffYOffsetSlider = personalDebuffYOffset + -- Row 5: Buff Y on the left, Debuff X on the right. + local personalBuffYOffset = CreateSlider(nameplatesPage, "Buff Y Offset", -100, 100, 1, personalSectionY - 176, 28, 150) + personalBuffYOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.personalNameplateBuffYOffset = value + getglobal(this:GetName() .. "Text"):SetText("Buff Y Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end + end) + personalBuffYOffset:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Personal Buff Y Offset", 1, 0.82, 0) + GameTooltip:AddLine("Moves the Personal Nameplate buff row up or down independently from debuffs.", 1, 1, 1, true) + GameTooltip:Show() + end) + personalBuffYOffset:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.personalNameplateBuffYOffsetSlider = personalBuffYOffset + + local personalDebuffXOffset = CreateSlider(nameplatesPage, "Debuff X Offset", -100, 100, 1, personalSectionY - 176, 246, 150) + personalDebuffXOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.personalNameplateDebuffXOffset = value + getglobal(this:GetName() .. "Text"):SetText("Debuff X Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end + end) + personalDebuffXOffset:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Personal Debuff X Offset", 1, 0.82, 0) + GameTooltip:AddLine("Moves the Personal Nameplate debuff row left or right so it does not run into the buff row or other UI.", 1, 1, 1, true) + GameTooltip:Show() + end) + personalDebuffXOffset:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.personalNameplateDebuffXOffsetSlider = personalDebuffXOffset + + local personalBuffXOffset = CreateSlider(nameplatesPage, "Buff X Offset", -100, 100, 1, personalSectionY - 220, 28, 150) + personalBuffXOffset:SetScript("OnValueChanged", function() + if not BNP_DB then return end + local value = RoundSignedInteger(this:GetValue()) + BNP_DB.personalNameplateBuffXOffset = value + getglobal(this:GetName() .. "Text"):SetText("Buff X Offset: " .. (value > 0 and "+" or "") .. value) + if BNP.RefreshPersonalNameplate then BNP:RefreshPersonalNameplate() end + end) + personalBuffXOffset:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Personal Buff X Offset", 1, 0.82, 0) + GameTooltip:AddLine("Moves the Personal Nameplate buff row left or right independently from debuffs.", 1, 1, 1, true) + GameTooltip:Show() + end) + personalBuffXOffset:SetScript("OnLeave", function() GameTooltip:Hide() end) + frame.personalNameplateBuffXOffsetSlider = personalBuffXOffset + local personalHealthModeLabels = { off = "Off", percent = "Percent", @@ -1428,8 +1509,8 @@ function BNP:CreateOptions() frame.personalNameplateHealthTextDropdown = personalHealthTextDropdown frame.SetPersonalHealthTextMode = SetPersonalHealthTextMode - -- Row 4: plate size and position, aligned to the same two columns. - local personalScale = CreateSlider(nameplatesPage, "Scale", 0.70, 1.50, 0.05, personalSectionY - 116, 28, 150) + -- Row 6: remaining aura offset on the left and plate scale on the right. + local personalScale = CreateSlider(nameplatesPage, "Scale", 0.70, 1.50, 0.05, personalSectionY - 220, 246, 150) personalScale:SetScript("OnValueChanged", function() if not BNP_DB then return end local value = Round(this:GetValue(), 0.05) @@ -1446,7 +1527,8 @@ function BNP:CreateOptions() personalScale:SetScript("OnLeave", function() GameTooltip:Hide() end) frame.personalNameplateScaleSlider = personalScale - local personalYOffset = CreateSlider(nameplatesPage, "Y Offset", -250, 150, 5, personalSectionY - 116, 246, 150) + -- Row 7: plate position. + local personalYOffset = CreateSlider(nameplatesPage, "Y Offset", -250, 150, 5, personalSectionY - 264, 28, 150) personalYOffset:SetScript("OnValueChanged", function() if not BNP_DB then return end local value = RoundSignedInteger(this:GetValue() / 5) * 5 @@ -2274,7 +2356,9 @@ function BNP:CreateOptions() local personalEnabled = BNP.IsPersonalNameplateEnabled and BNP:IsPersonalNameplateEnabled() or false SetCheckEnabled(self.personalNameplateCombatOnlyCheck, personalEnabled) SetCheckEnabled(self.personalNameplateClassColorCheck, personalEnabled) + SetCheckEnabled(self.personalNameplateHideLevelCheck, personalEnabled) SetCheckEnabled(self.personalNameplateDebuffsCheck, personalEnabled) + SetCheckEnabled(self.personalNameplateBuffsCheck, personalEnabled) if self.personalNameplateHealthTextDropdown then self.personalNameplateHealthTextDropdown:SetAlpha(personalEnabled and 1.0 or 0.45) if personalEnabled then @@ -2286,7 +2370,7 @@ function BNP:CreateOptions() if self.personalNameplateHealthTextLabel then self.personalNameplateHealthTextLabel:SetTextColor(personalEnabled and 1 or 0.5, personalEnabled and 0.82 or 0.5, personalEnabled and 0 or 0.5) end - local personalSliders = { self.personalNameplateScaleSlider, self.personalNameplateYOffsetSlider, self.personalNameplateDebuffYOffsetSlider } + local personalSliders = { self.personalNameplateScaleSlider, self.personalNameplateYOffsetSlider, self.personalNameplateDebuffYOffsetSlider, self.personalNameplateBuffYOffsetSlider, self.personalNameplateDebuffXOffsetSlider, self.personalNameplateBuffXOffsetSlider } local personalIndex for personalIndex = 1, table.getn(personalSliders) do local personalSlider = personalSliders[personalIndex] @@ -2462,6 +2546,9 @@ function BNP:SyncOptions() if frame.personalNameplateClassColorCheck then frame.personalNameplateClassColorCheck:SetChecked(self.IsPersonalNameplateClassColorEnabled and self:IsPersonalNameplateClassColorEnabled() or false) end + if frame.personalNameplateHideLevelCheck then + frame.personalNameplateHideLevelCheck:SetChecked(self.IsPersonalNameplateLevelHidden and self:IsPersonalNameplateLevelHidden() or false) + end if frame.personalNameplateHealthTextDropdown then local mode = self.GetPersonalNameplateHealthTextMode and self:GetPersonalNameplateHealthTextMode() or "both" local labels = { off = "Off", percent = "Percent", hp = "HP", both = "HP + Percent" } @@ -2471,6 +2558,9 @@ function BNP:SyncOptions() if frame.personalNameplateDebuffsCheck then frame.personalNameplateDebuffsCheck:SetChecked(self.IsPersonalNameplateDebuffsEnabled and self:IsPersonalNameplateDebuffsEnabled() or false) end + if frame.personalNameplateBuffsCheck then + frame.personalNameplateBuffsCheck:SetChecked(self.IsPersonalNameplateBuffsEnabled and self:IsPersonalNameplateBuffsEnabled() or false) + end if frame.personalNameplateScaleSlider then local value = self.GetPersonalNameplateScale and self:GetPersonalNameplateScale() or 1.0 frame.personalNameplateScaleSlider:SetValue(value) @@ -2486,6 +2576,21 @@ function BNP:SyncOptions() frame.personalNameplateDebuffYOffsetSlider:SetValue(value) getglobal(frame.personalNameplateDebuffYOffsetSlider:GetName() .. "Text"):SetText("Debuff Y Offset: " .. (value > 0 and "+" or "") .. value) end + if frame.personalNameplateBuffYOffsetSlider then + local value = self.GetPersonalNameplateBuffYOffset and self:GetPersonalNameplateBuffYOffset() or 0 + frame.personalNameplateBuffYOffsetSlider:SetValue(value) + getglobal(frame.personalNameplateBuffYOffsetSlider:GetName() .. "Text"):SetText("Buff Y Offset: " .. (value > 0 and "+" or "") .. value) + end + if frame.personalNameplateDebuffXOffsetSlider then + local value = self.GetPersonalNameplateDebuffXOffset and self:GetPersonalNameplateDebuffXOffset() or 0 + frame.personalNameplateDebuffXOffsetSlider:SetValue(value) + getglobal(frame.personalNameplateDebuffXOffsetSlider:GetName() .. "Text"):SetText("Debuff X Offset: " .. (value > 0 and "+" or "") .. value) + end + if frame.personalNameplateBuffXOffsetSlider then + local value = self.GetPersonalNameplateBuffXOffset and self:GetPersonalNameplateBuffXOffset() or 0 + frame.personalNameplateBuffXOffsetSlider:SetValue(value) + getglobal(frame.personalNameplateBuffXOffsetSlider:GetName() .. "Text"):SetText("Buff X Offset: " .. (value > 0 and "+" or "") .. value) + end if frame.debuffYOffsetSlider then local value = self:GetDebuffYOffset() frame.debuffYOffsetSlider:SetValue(value) diff --git a/modules/auras.lua b/modules/auras.lua index 6193ace..8b6427d 100644 --- a/modules/auras.lua +++ b/modules/auras.lua @@ -3175,14 +3175,14 @@ local function SyncAuraRemoval(unit, guid, now, snapshot, currentTargetGUID, eve local i local maxIndex = snapshot and snapshot.count or 64 for i = 1, maxIndex do - local texture, stacks, dtype, auraSpellID, source + local texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source if snapshot then local entry = snapshot.entries[i] if not entry then break end - texture, stacks, dtype, auraSpellID, source = entry[1], entry[2], entry[3], entry[4], entry[7] + texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source = + entry[1], entry[2], entry[3], entry[4], entry[5], entry[6], entry[7] else - local duration, expirationTime - texture, stacks, dtype, auraSpellID, duration, expirationTime, source = BNP:_ReadClassicDebuff(unit, i) + texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source = BNP:_ReadClassicDebuff(unit, i) if not texture then break end end @@ -3193,7 +3193,8 @@ local function SyncAuraRemoval(unit, guid, now, snapshot, currentTargetGUID, eve -- aura is safe when it is still ours OR ClassicAPI temporarily lost the -- source metadata. A known foreign source alone is not enough to keep -- our timer alive. - if not BNP:_IsKnownForeignClassicAuraSource(source) then + local knownForeign = BNP:_IsKnownForeignClassicAuraSource(source) + if not knownForeign then BNP._RemovalPresentSafe[auraSpellID] = presentGeneration end @@ -3201,6 +3202,31 @@ local function SyncAuraRemoval(unit, guid, now, snapshot, currentTargetGUID, eve for trackedKey, trackedAura in pairs(cache) do if type(trackedAura) == "table" and trackedAura.spellID == auraSpellID then trackedAura.stacks = tonumber(stacks) or 0 + + -- ClassicAPI is authoritative for the live expiration of our aura. + -- Some talents/procs (for example Turtle WoW Druid Carnage) renew an + -- existing bleed without another Rake/Rip cast. Previously BNP only + -- updated stacks here, so the cached cast timer expired even though + -- the real aura had already received a new expirationTime. + -- + -- Never extend from a known foreign caster. Unknown source remains a + -- valid fallback because some custom-client packets omit ownership. + local apiDuration = tonumber(liveDuration) + local apiExpires = tonumber(liveExpires) + if not knownForeign and apiExpires and apiExpires > now then + local oldExpires = tonumber(trackedAura.expires) + if not oldExpires or math.abs(apiExpires - oldExpires) > 0.05 then + if apiDuration and apiDuration > 0 then + trackedAura.duration = apiDuration + end + trackedAura.expires = apiExpires + trackedAura.classicAPITiming = true + trackedAura.liveConfirmed = true + trackedAura.missingScans = nil + trackedAura.exactMissingScans = nil + RaidTrace("LIVE_REFRESH", guid, trackedKey, auraSpellID) + end + end end end end diff --git a/modules/auras.lua.bak_carnage b/modules/auras.lua.bak_carnage new file mode 100644 index 0000000..6193ace --- /dev/null +++ b/modules/auras.lua.bak_carnage @@ -0,0 +1,4442 @@ +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 + if icon and icon.BNPCooldown and icon.BNPCooldown.SetFrameLevel then + icon.BNPCooldown:SetFrameLevel(level + 1) + end + if icon and icon.BNPTextOverlay and icon.BNPTextOverlay.SetFrameLevel then + icon.BNPTextOverlay:SetFrameLevel(level + 2) + 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 + + -- Aura/CC containers are siblings of the projected nameplate and therefore + -- do not inherit its alpha. In Target Only mode explicitly mirror the plate + -- alpha so hidden non-target plates cannot leave floating DoT/CC icons behind. + -- DesiredAlpha fails open while target identity is unresolved, so this also + -- stays flicker-safe during target switches and dense nameplate recycling. + if BNP.IsTargetOnlyNameplatesEnabled and BNP:IsTargetOnlyNameplatesEnabled() then + local alpha = 1 + if plate and plate.GetAlpha then + local ok, value = pcall(plate.GetAlpha, plate) + if ok and value ~= nil then alpha = value end + end + SetTargetPriorityAuraAlpha(container, alpha) + 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 + + -- Keep timer/stack on their own child frame so they always render above + -- the optional cooldown shade. The shade itself remains untouched. + local textOverlay = CreateFrame("Frame", nil, icon) + textOverlay:SetAllPoints(icon) + if textOverlay.SetFrameLevel and icon.GetFrameLevel then + textOverlay:SetFrameLevel(icon:GetFrameLevel() + 2) + end + icon.BNPTextOverlay = textOverlay + + local timer = textOverlay:CreateFontString(nil, "OVERLAY") + local auraFontSize = (BNP.GetAuraFontSize and BNP:GetAuraFontSize()) or (UI.TIMER_SIZE or 8) + timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", auraFontSize, "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 = textOverlay:CreateFontString(nil, "OVERLAY") + stack:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", auraFontSize, "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 + +-- Optional AuraCore-style cooldown shade. +-- +-- Vanilla 1.12's CooldownFrameTemplate is an animated Model. AuraCore already +-- hit the same class of problems, so BNP deliberately does NOT use that model. +-- Instead it draws a lightweight clockwise grey shade itself. +-- +-- Keep every helper on BNP rather than declaring additional top-level locals: +-- modules/auras.lua is large and Vanilla/Lua has a hard local-variable limit. +-- Exceeding it prevents the entire aura module from loading, which makes every +-- aura disappear even though the actual tracking code is fine. +-- The renderer used to use only 48 visual steps and was refreshed on the +-- normal 0.05s aura tick. Keep aura tracking at that proven cadence, but give +-- the cosmetic shade much finer granularity and animate it independently. +BNP.COOLDOWN_STEPS_PER_QUARTER = 240 +BNP.COOLDOWN_TOTAL_STEPS = 960 +BNP.COOLDOWN_ANIMATION_INTERVAL = 0.025 +BNP.activeAuraCooldownIcons = BNP.activeAuraCooldownIcons or {} + +function BNP:_SetAuraShadeTexture(tex) + tex:SetTexture("Interface\\Buttons\\WHITE8X8") + tex:SetVertexColor(0.02, 0.02, 0.02, 0.82) +end + +function BNP:_EnsureAuraCooldown(icon) + if not icon then return nil end + if icon.BNPCooldown then return icon.BNPCooldown end + + local shade = CreateFrame("Frame", nil, icon) + shade:SetAllPoints(icon) + shade:EnableMouse(false) + if shade.SetFrameLevel and icon.GetFrameLevel then + shade:SetFrameLevel(icon:GetFrameLevel()) + end + shade:Hide() + + local q1 = shade:CreateTexture(nil, "ARTWORK") + local q2 = shade:CreateTexture(nil, "ARTWORK") + local q3 = shade:CreateTexture(nil, "ARTWORK") + local q4 = shade:CreateTexture(nil, "ARTWORK") + self:_SetAuraShadeTexture(q1) + self:_SetAuraShadeTexture(q2) + self:_SetAuraShadeTexture(q3) + self:_SetAuraShadeTexture(q4) + q1:Hide(); q2:Hide(); q3:Hide(); q4:Hide() + + shade.BNPQuarters = { q1, q2, q3, q4 } + shade.BNPLastStep = -1 + icon.BNPCooldown = shade + icon.BNPCooldownStep = -1 + return shade +end + +function BNP:_SetAuraQuarterFull(tex, quarter, icon, halfW, halfH) + tex:ClearAllPoints() + if quarter == 1 then + tex:SetPoint("TOPLEFT", icon, "TOP", 0, 0) + elseif quarter == 2 then + tex:SetPoint("TOPRIGHT", icon, "RIGHT", 0, 0) + elseif quarter == 3 then + tex:SetPoint("BOTTOMRIGHT", icon, "BOTTOM", 0, 0) + else + tex:SetPoint("BOTTOMLEFT", icon, "LEFT", 0, 0) + end + tex:SetWidth(halfW) + tex:SetHeight(halfH) + tex:Show() +end + +function BNP:_SetAuraQuarterPartial(tex, quarter, icon, fraction, halfW, halfH) + if fraction <= 0 then tex:Hide(); return end + if fraction >= 1 then + self:_SetAuraQuarterFull(tex, quarter, icon, halfW, halfH) + return + end + + tex:ClearAllPoints() + if quarter == 1 then + -- 12 -> 3: grow from top-centre towards the right. + tex:SetPoint("TOPLEFT", icon, "TOP", 0, 0) + tex:SetWidth(halfW * fraction) + tex:SetHeight(halfH) + elseif quarter == 2 then + -- 3 -> 6: grow from right-centre downwards. + tex:SetPoint("TOPRIGHT", icon, "RIGHT", 0, 0) + tex:SetWidth(halfW) + tex:SetHeight(halfH * fraction) + elseif quarter == 3 then + -- 6 -> 9: grow from bottom-centre towards the left. + tex:SetPoint("BOTTOMRIGHT", icon, "BOTTOM", 0, 0) + tex:SetWidth(halfW * fraction) + tex:SetHeight(halfH) + else + -- 9 -> 12: grow from left-centre upwards. + tex:SetPoint("BOTTOMLEFT", icon, "LEFT", 0, 0) + tex:SetWidth(halfW) + tex:SetHeight(halfH * fraction) + end + tex:Show() +end + +function BNP:_UpdateAuraCustomShade(icon, shade, step) + if not icon or not shade or not shade.BNPQuarters then return end + if shade.BNPLastStep == step then return end + shade.BNPLastStep = step + icon.BNPCooldownStep = step + + local width = (icon.GetWidth and icon:GetWidth()) or GetIconSize() + local height = (icon.GetHeight and icon:GetHeight()) or GetIconSize() + local halfW = width * 0.5 + local halfH = height * 0.5 + local q + for q = 1, 4 do + local tex = shade.BNPQuarters[q] + local localStep = step - ((q - 1) * self.COOLDOWN_STEPS_PER_QUARTER) + if localStep <= 0 then + tex:Hide() + elseif localStep >= self.COOLDOWN_STEPS_PER_QUARTER then + self:_SetAuraQuarterFull(tex, q, icon, halfW, halfH) + else + self:_SetAuraQuarterPartial(tex, q, icon, localStep / self.COOLDOWN_STEPS_PER_QUARTER, halfW, halfH) + end + end + shade:Show() +end + +function BNP:_HideAuraCooldown(icon) + if not icon or not icon.BNPCooldown then return end + local shade = icon.BNPCooldown + if shade.BNPQuarters then + local i + for i = 1, 4 do shade.BNPQuarters[i]:Hide() end + end + shade.BNPLastStep = -1 + shade:Hide() + icon.BNPCooldownStep = -1 + icon.BNPCooldownStart = nil + icon.BNPCooldownDuration = nil + icon.BNPCooldownExpires = nil + if self.activeAuraCooldownIcons then + self.activeAuraCooldownIcons[icon] = nil + end +end + +function BNP:_UpdateAuraCooldown(icon, aura, def, now) + if not icon then return end + if not self:IsAuraCooldownSpiralEnabled() then + self:_HideAuraCooldown(icon) + return + end + + local expires = aura and tonumber(aura.expires) or nil + local duration = aura and tonumber(aura.duration) or nil + if (not duration or duration <= 0) and def then duration = tonumber(def.duration) end + now = now or GetTime() + if not expires or expires <= now or not duration or duration <= 0 then + self:_HideAuraCooldown(icon) + return + end + + local remaining = expires - now + local effectiveDuration = duration + if remaining > effectiveDuration then effectiveDuration = remaining end + local startTime = expires - effectiveDuration + local progress = (now - startTime) / effectiveDuration + if progress < 0 then progress = 0 end + if progress > 1 then progress = 1 end + + local step = math.floor((progress * self.COOLDOWN_TOTAL_STEPS) + 0.5) + if step < 0 then step = 0 end + if step > self.COOLDOWN_TOTAL_STEPS then step = self.COOLDOWN_TOTAL_STEPS end + + local shade = self:_EnsureAuraCooldown(icon) + if not shade then return end + self:_UpdateAuraCustomShade(icon, shade, step) + icon.BNPCooldownStart = startTime + icon.BNPCooldownDuration = effectiveDuration + icon.BNPCooldownExpires = expires + self.activeAuraCooldownIcons = self.activeAuraCooldownIcons or {} + self.activeAuraCooldownIcons[icon] = true +end + +-- The spiral is cosmetic. Any unexpected old-client UI error is isolated here +-- and can never abort the actual debuff/CC renderer. +function BNP:_SafeUpdateAuraCooldown(icon, aura, def, now) + local ok = pcall(self._UpdateAuraCooldown, self, icon, aura, def, now) + if not ok and icon and icon.BNPCooldown then icon.BNPCooldown:Hide() end +end + +function BNP:RefreshAuraCooldownSpirals() + local enabled = self:IsAuraCooldownSpiralEnabled() + local plate, _, container, i + for plate in pairs(self.plates or {}) do + for _, container in ipairs({ plate.BNPAuraContainer, plate.BNPCCContainer }) do + if container then + for i = 1, table.getn(container.icons or {}) do + local icon = container.icons[i] + if not enabled then + self:_HideAuraCooldown(icon) + elseif icon and icon:IsShown() then + -- The normal renderer fills start/duration on the next 0.05s tick. + icon.BNPCooldownStart = nil + icon.BNPCooldownDuration = nil + end + end + end + end + end +end + +-- Smooth visual-only animator. This does not scan units, auras or ClassicAPI. +-- It touches only icons that already have a live cooldown shade and uses the +-- start/duration cached by the normal renderer. This keeps the expensive aura +-- logic at 20 Hz while the cosmetic sweep can move at up to ~40 Hz. +BNP.AuraCooldownAnimator = BNP.AuraCooldownAnimator or CreateFrame("Frame") +BNP.AuraCooldownAnimator.BNPElapsed = 0 +BNP.AuraCooldownAnimator:SetScript("OnUpdate", function() + if not BNP_DB or BNP_DB.cooldownSpiral ~= true then return end + + this.BNPElapsed = (this.BNPElapsed or 0) + arg1 + if this.BNPElapsed < (BNP.COOLDOWN_ANIMATION_INTERVAL or 0.025) then return end + this.BNPElapsed = 0 + + local active = BNP.activeAuraCooldownIcons + if not active then return end + + local now = GetTime() + local icon + for icon in pairs(active) do + if not icon or not icon.BNPCooldown or not icon:IsShown() then + active[icon] = nil + else + local startTime = tonumber(icon.BNPCooldownStart) + local duration = tonumber(icon.BNPCooldownDuration) + local expires = tonumber(icon.BNPCooldownExpires) + if not startTime or not duration or duration <= 0 or not expires or expires <= now then + BNP:_HideAuraCooldown(icon) + else + local progress = (now - startTime) / duration + if progress < 0 then progress = 0 end + if progress > 1 then progress = 1 end + local step = math.floor((progress * BNP.COOLDOWN_TOTAL_STEPS) + 0.5) + if step < 0 then step = 0 end + if step > BNP.COOLDOWN_TOTAL_STEPS then step = BNP.COOLDOWN_TOTAL_STEPS end + BNP:_UpdateAuraCustomShade(icon, icon.BNPCooldown, step) + end + end + end +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 + BNP:_HideAuraCooldown(container.icons[i]) + 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 + +-- ClassicAPI 1.15.x aura/nameplate bridge ----------------------------------- +-- Keep these helpers on BNP instead of as file-level locals: auras.lua is +-- already close to Lua's local-variable ceiling, and adding a handful of +-- long-lived locals would make the whole file fail to compile on 1.12. +BNP._ClassicAuraUnitDebuff = C_UnitAuras and C_UnitAuras.UnitDebuff +BNP._ClassicGetAuraSlots = C_UnitAuras and C_UnitAuras.GetAuraSlots +BNP._ClassicUnitAuraBySlot = C_UnitAuras and C_UnitAuras.UnitAuraBySlot +BNP._ClassicGetNamePlateForUnit = C_NamePlate and C_NamePlate.GetNamePlateForUnit +BNP._ClassicUnitGUID = UnitGUID +BNP._ClassicAuraSlotScratch = BNP._ClassicAuraSlotScratch or {} + +function BNP:_ReadClassicDebuff(unit, index, filter) + local fn = self._ClassicAuraUnitDebuff + if fn then + -- Allocation-free ClassicAPI positional form: + -- name, icon, count, dispelType, duration, expirationTime, source, + -- isStealable, nameplateShowPersonal, spellId, ... + local name, icon, count, dispelType, duration, expirationTime, source, + isStealable, nameplateShowPersonal, spellID = fn(unit, index, filter) + if not name then return nil end + + -- Return the legacy BNP shape used throughout this module. Duration and + -- expiration occupy slots 5/6 so existing duration probing can consume + -- them without AuraData table allocations or another aura scan. + return icon, count, dispelType, spellID, duration, expirationTime, + source, isStealable, nameplateShowPersonal, name + end + + return UnitDebuff(unit, index) +end + +function BNP:_IsOwnClassicAuraSource(source) + return source == "player" or source == "pet" +end + +function BNP:_IsKnownForeignClassicAuraSource(source) + return source ~= nil and source ~= "" and not self:_IsOwnClassicAuraSource(source) +end + +function BNP:_BindClassicPlateToken(token) + local getPlate = self._ClassicGetNamePlateForUnit + if not token or not getPlate then return end + local plate = getPlate(token) + if not plate then return end + + plate.BNPClassicUnitToken = token + if self._ClassicUnitGUID then + local exactGUID = self._ClassicUnitGUID(token) + plate.BNPClassicGUID = exactGUID + + -- ClassicAPI's NAME_PLATE_UNIT_ADDED mapping is authoritative. The legacy + -- same-name stabilization below intentionally delays GUID switches to hide + -- transient SuperWoW token swaps in dense raids, but doing that for an + -- exact nameplateN mapping can briefly render the previous same-name mob's + -- auras on a recycled frame. Pin the exact GUID immediately instead. + if exactGUID then + plate.BNPAuraGUIDStable = exactGUID + plate.BNPAuraGUIDCandidate = exactGUID + plate.BNPAuraGUIDCandidateCount = 8 + plate.BNPAuraGUIDLastGoodAt = GetTime() + plate.BNPAuraForceFreshIdentity = nil + end + end + + -- The event-driven aura watcher is defined later in this file, after all + -- aura reconciliation helpers exist. Once available, bind this exact plate + -- to UNIT_AURA for only its nameplateN token. + if self._EnsureClassicAuraWatcher then + self:_EnsureClassicAuraWatcher(plate, token) + end +end + +function BNP:_RefreshClassicPlateTokens() + if not self._ClassicGetNamePlateForUnit then return end + local i + for i = 1, 40 do + local token = "nameplate" .. i + if UnitExists(token) then self:_BindClassicPlateToken(token) end + end +end + +if BNP._ClassicGetNamePlateForUnit then + local classicPlateEvents = CreateFrame("Frame") + classicPlateEvents:RegisterEvent("NAME_PLATE_UNIT_ADDED") + classicPlateEvents:RegisterEvent("NAME_PLATE_UNIT_REMOVED") + classicPlateEvents:RegisterEvent("PLAYER_ENTERING_WORLD") + classicPlateEvents:SetScript("OnEvent", function() + if event == "NAME_PLATE_UNIT_ADDED" then + BNP:_BindClassicPlateToken(arg1) + elseif event == "NAME_PLATE_UNIT_REMOVED" then + -- ClassicAPI guarantees that arg1 still resolves to the leaving unit + -- during this event, so clear only the frame that owns this token. + local plate = BNP._ClassicGetNamePlateForUnit(arg1) + if plate and plate.BNPClassicUnitToken == arg1 then + if BNP._ReleaseClassicAuraWatcher then + BNP:_ReleaseClassicAuraWatcher(plate, arg1) + end + plate.BNPClassicUnitToken = nil + plate.BNPClassicGUID = nil + + -- v1.15.6 guarantees that this is the exact leaving frame. Do not keep + -- the old stabilized GUID around for a later recycled same-name plate; + -- that legacy hold is only needed when ClassicAPI identity is absent. + plate.BNPAuraGUIDStable = nil + plate.BNPAuraGUIDCandidate = nil + plate.BNPAuraGUIDCandidateCount = 0 + plate.BNPAuraGUIDLastGoodAt = nil + plate.BNPAuraForceFreshIdentity = true + end + elseif event == "PLAYER_ENTERING_WORLD" then + BNP:_RefreshClassicPlateTokens() + end + end) +end + +local function GetPlateGUID(plate) + if not plate then return nil end + + -- Preferred path: the event-bound ClassicAPI token resolves directly to the + -- exact visible unit and therefore to its stable GUID. + local classicToken = plate.BNPClassicUnitToken + if classicToken and BNP._ClassicUnitGUID then + local guid = BNP._ClassicUnitGUID(classicToken) + if guid then + plate.BNPClassicGUID = guid + return guid + end + end + + -- Fallback: SuperWoW stores the unit GUID/token directly on the nameplate + -- name slot. Keep this path for compatibility and during the one-frame gap + -- before ClassicAPI's synthesized NAME_PLATE_UNIT_ADDED event arrives. + if plate.GetName then + local token = plate:GetName(1) + if token and token ~= "" then return token end + 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.guidOtherDebuffs then BNP.guidOtherDebuffs[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.guidOtherDebuffs then BNP.guidOtherDebuffs[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")) + or (UnitPlayerControlled and UnitPlayerControlled("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, duration, expirationTime, source + for i = 1, 64 do + texture, stacks, dtype, auraSpellID, duration, expirationTime, source = BNP:_ReadClassicDebuff(unit, i) + if not texture then break end + + if auraSpellID and not BNP:_IsKnownForeignClassicAuraSource(source) 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 then + if entries[def.key] then entries[def.key] = nil end + if def.secondaryAuraKey and entries[def.secondaryAuraKey] then + entries[def.secondaryAuraKey] = nil + end + 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 / aura-confirmed utility effects + fear=true, howl_of_terror=true, banish=true, death_coil=true, shadowburn=true, + + -- Hunter traps / CC / talent procs + immolation_trap_effect=true, explosive_trap_effect=true, freezing_trap_effect=true, + serpent_sting=true, viper_sting=true, scorpid_sting=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. Rake is also strict because UNIT_CASTEVENT + -- CAST fires before the melee hit result; requiring the real aura prevents + -- missed/dodged/parried/immune Rakes from creating a false bleed timer. + entangling_roots=true, bash=true, pounce=true, pounce_bleed=true, rake=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 + if def.secondaryAuraKey then keys[def.secondaryAuraKey] = true end + + -- 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 + + -- A failed refresh must not leave a pending confirmation behind. Otherwise + -- the still-active old aura could be mistaken for the newly failed cast on + -- the next UnitDebuff scan and incorrectly restart its timer. + local pending = BNP.pendingAuras and BNP.pendingAuras[guid] + if pending then + for key in pairs(attempt.before or {}) do pending[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 + +-- A positive UnitDebuff sighting proves a first application landed. Disarm +-- that cast attempt immediately so a later, unrelated failed button press for +-- the same spell cannot restore the pre-cast (empty) state. Refresh attempts +-- intentionally stay armed: an already-present aura cannot prove that the +-- refresh succeeded, and a delayed miss/resist must still restore its timer. +-- Keep this on BNP instead of adding another chunk-local function; the Vanilla +-- client has a strict local-variable limit for large Lua files. +function BNP:ResolveNewAuraCastAttempt(guid, key) + if not guid or not key then return false end + + local now = GetTime() + local i + for i = table.getn(BNP.auraCastAttempts), 1, -1 do + local attempt = BNP.auraCastAttempts[i] + local prior = attempt and attempt.before and attempt.before[key] + if attempt and not attempt.done + and attempt.guid == guid + and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW + and prior and not prior.exists then + attempt.done = true + return true + end + end + + return false +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) +-- Some physical/ability immunities use the alternate Vanilla combat string +-- "%s is immune to your %s." (for example bleed-immune mobs hit with Rake). +-- Treat it exactly like SPELLIMMUNESELFOTHER so provisional CAST-primary +-- timers are rolled back globally instead of needing per-spell exceptions. +AddFailurePattern(IMMUNESPELLSELFOTHER) +AddFailurePattern(IMMUNEDAMAGECLASSSELFOTHER) +AddFailurePattern(SPELLMISSSELFOTHER) +AddFailurePattern(SPELLRESISTSELFOTHER) +AddFailurePattern(SPELLEVADEDSELFOTHER) +AddFailurePattern(SPELLDODGEDSELFOTHER) +AddFailurePattern(SPELLDEFLECTEDSELFOTHER) +AddFailurePattern(SPELLREFLECTSELFOTHER) +AddFailurePattern(SPELLPARRIEDSELFOTHER) + +-- Do not match SPELLFAILCASTSELF here. That message describes a newly rejected +-- input (for example pressing Corruption again during the global cooldown), +-- not the result of the earlier UNIT_CASTEVENT CAST. Matching it by spell name +-- could therefore roll back a DoT that already landed. Exact SuperWoW FAIL and +-- the impact-result patterns above still handle real miss/resist/immune cases. + +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 then return nil end + + -- Use the real ClassicAPI nameplateN token whenever possible. This is the + -- token family C_UnitAuras is designed around and avoids GUID/name fallback + -- ambiguity with identical mobs. + local token = plate.BNPClassicUnitToken + if token and UnitExists(token) then return token end + + if plate.GetName then return plate:GetName(1) end + return nil +end + +local function FindMatchingAuraOnUnit(unit, def, castSpellID, snapshot) + if not unit or not def then return nil, nil, nil end + + -- Event-driven ClassicAPI updates pass one slot snapshot through the entire + -- reconciliation chain. Legacy/fallback callers still scan UnitDebuff. + local i = 1 + local maxIndex = snapshot and snapshot.count or 64 + while i <= maxIndex do + local texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source, auraName + if snapshot then + local entry = snapshot.entries[i] + if not entry then break end + texture = entry[1] + stacks = entry[2] + dtype = entry[3] + auraSpellID = entry[4] + liveDuration = entry[5] + liveExpires = entry[6] + source = entry[7] + auraName = entry[10] + else + texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source = BNP:_ReadClassicDebuff(unit, i) + if not texture then break end + if auraSpellID and SpellInfo then auraName = SpellInfo(auraSpellID) end + end + + -- If ClassicAPI knows another unit cast this aura, it cannot confirm our + -- pending cast. Unknown source remains a valid fallback for triggered + -- effects whose caster cannot be reconstructed from packets. + local knownForeign = BNP:_IsKnownForeignClassicAuraSource(source) + + if auraSpellID and not knownForeign then + if auraSpellID == castSpellID or DefHasSpellID(def, auraSpellID) then + return auraSpellID, texture, tonumber(stacks) or 0, tonumber(liveDuration), tonumber(liveExpires), BNP:_IsOwnClassicAuraSource(source) + end + + auraName = auraName or (SpellInfo and SpellInfo(auraSpellID) or nil) + if NameMatches(def, auraName) then + return auraSpellID, texture, tonumber(stacks) or 0, tonumber(liveDuration), tonumber(liveExpires), BNP:_IsOwnClassicAuraSource(source) + end + end + + if not knownForeign and texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then + return auraSpellID or castSpellID, texture, tonumber(stacks) or 0, tonumber(liveDuration), tonumber(liveExpires), BNP:_IsOwnClassicAuraSource(source) + 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 + local commitNow = GetTime() + local liveDuration = tonumber(pending.liveDuration) + local liveExpires = tonumber(pending.liveExpires) + aura.duration = (liveDuration and liveDuration > 0 and liveDuration) or pending.duration + if liveExpires and liveExpires > commitNow then + aura.expires = liveExpires + aura.classicAPITiming = true + else + aura.expires = commitNow + aura.duration + aura.classicAPITiming = nil + end + aura.spellID = pending.spellID + aura.texture = pending.texture or def.texture + aura.stacks = tonumber(pending.stacks) or 0 + aura.confirmedAt = commitNow + aura.missingScans = nil + if pending.sourceVerified then + aura.classicAPISourceVerified = true + elseif pending.liveConfirmed then + -- Do not erase a previous verified source on a refresh whose source cache + -- is temporarily unknown. + aura.classicAPISourceVerified = aura.classicAPISourceVerified or nil + end + + if pending.liveConfirmed then + aura.castPrimary = nil + aura.castPrimaryAt = nil + aura.liveConfirmed = true + BNP:ResolveNewAuraCastAttempt(guid, key) + 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, snapshot) + 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, liveDuration, liveExpires, sourceVerified = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID, snapshot) + 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, + liveDuration = liveDuration, + liveExpires = liveExpires, + sourceVerified = sourceVerified, + 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, snapshot) + 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, liveDuration, liveExpires, sourceVerified = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID, snapshot) + 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, + liveDuration = liveDuration, + liveExpires = liveExpires, + sourceVerified = sourceVerified, + 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 + + -- Turtle WoW scales Rip from 10 to 18 seconds with one to five combo + -- points. Resolve this after tooltip/rank detection so the cast's actual + -- combo-point count remains authoritative. + if def.comboPointDurations and BNP.GetTrackedComboPointsForGUID then + local comboPoints = BNP:GetTrackedComboPointsForGUID(targetGUID) + local comboDuration = def.comboPointDurations[comboPoints] + if comboDuration then duration = comboDuration end + end + + 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) + + -- Pounce triggers a separate 18-second bleed spell. Queue its real spell ID + -- and let the normal UnitDebuff confirmation path prove that it landed on + -- this exact GUID before any icon or timer is shown. Keep this inline: the + -- 1.12 Lua client has a strict local-variable limit for the main chunk. + if def.secondaryAuraKey and def.secondarySpellIDs then + local numericSpellID = tonumber(spellID) or spellID + local secondarySpellID = def.secondarySpellIDs[numericSpellID] + local secondaryDef = nil + + if secondarySpellID then + local _, candidate + for _, candidate in ipairs(AURA_DEFS) do + if candidate.key == def.secondaryAuraKey then + secondaryDef = candidate + break + end + end + end + + if secondaryDef then + local secondaryTexture = texture or secondaryDef.texture + if SpellInfo then + local _, _, foundTexture = SpellInfo(secondarySpellID) + secondaryTexture = foundTexture or secondaryTexture + end + + pendingForGUID[secondaryDef.key] = { + def = secondaryDef, + spellID = secondarySpellID, + texture = secondaryTexture, + duration = secondaryDef.duration or 18, + created = GetTime(), + } + RaidTrace("SECONDARY_PENDING", targetGUID, secondaryDef.key, secondarySpellID) + end + end + + 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() + + -- ClassicAPI v1.15.6 fast path. GetAuraSlots fills our reusable scratch + -- table and UnitAuraBySlot returns positional values, so a full harmful-aura + -- snapshot creates no per-aura tables and walks the descriptor array once. + if BNP._ClassicGetAuraSlots and BNP._ClassicUnitAuraBySlot then + local slots = BNP._ClassicAuraSlotScratch + local _, slotCount = BNP._ClassicGetAuraSlots(unit, "HARMFUL", nil, nil, slots) + slotCount = tonumber(slotCount) or 0 + + for i = 1, slotCount do + local auraName, texture, stacks, dtype, duration, expirationTime, source, + isStealable, nameplateShowPersonal, auraSpellID = + BNP._ClassicUnitAuraBySlot(unit, slots[i]) + if auraName then + count = count + 1 + local entry = entries[count] + if not entry then + entry = {} + entries[count] = entry + end + + entry[1] = texture + entry[2] = stacks + entry[3] = dtype + entry[4] = auraSpellID + entry[5] = duration + entry[6] = expirationTime + entry[7] = source + entry[8] = isStealable + entry[9] = nameplateShowPersonal + entry[10] = auraName + end + end + else + for i = 1, 64 do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = BNP:_ReadClassicDebuff(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 + end + + snap.count = count + return snap +end + +local function ClassicAPIAuraDataAvailable() + return type(BNP._ClassicAuraUnitDebuff) == "function" +end + +-- ClassicAPI exposes hostile aura identity from the real HARMFUL descriptor +-- set (including Turtle debuffs spilled into free buff slots), plus best-effort +-- source/timing data. The positional UnitDebuff form allocates no AuraData +-- tables, so it is safe to use in the throttled nameplate scan. +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 texture, stacks, dtype, spellID, duration, expires, source, + isStealable, nameplateShowPersonal, auraName = BNP:_ReadClassicDebuff(unit, i) + if not texture then break end + + 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 def.spellIDs and spellID then + local _, knownID + for _, knownID in ipairs(def.spellIDs) do + if spellID == knownID then + matches = true + break + end + end + end + if not matches and auraName then matches = NameMatches(def, auraName) end + + if matches then + return spellID, texture, tonumber(duration), tonumber(expires), + tonumber(stacks) or 0, true + end + end + + 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 + + -- Event-driven ClassicAPI callers already captured the exact harmful-aura + -- slots once. Consume that snapshot directly so shared auras do not trigger + -- a second UnitDebuff scan or temporary timing tables. + if snapshot then + local i + for i = 1, snapshot.count do + local entry = snapshot.entries[i] + if not entry then break end + local texture = entry[1] + local auraSpellID = entry[4] + local auraName = entry[10] + 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 + + if sharedMatches == true or (sharedMatches == nil and AuraMatches(def, auraName, texture)) then + return auraSpellID, texture, tonumber(entry[5]), tonumber(entry[6]), + tonumber(entry[2]) or 0, true + end + end + return nil, nil, nil, nil, nil, true + end + + if def.shared then + local spellID, texture, duration, expires, stacks, authoritative = + FindClassicAPISharedAura(unit, def) + if authoritative then return spellID, texture, duration, expires, stacks, true end + end + + local now = GetTime() + local i + for i = 1, 64 do + local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = BNP:_ReadClassicDebuff(unit, i) + if not a1 then break end + + local texture = a1 + local auraSpellID = a4 + 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 + -- Legacy/SuperWoW fallback: probe plausible numeric return pairs without + -- assuming one exact fork. This branch is not used by the v1.15.6 event + -- path, so its compatibility behavior stays unchanged. + 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, eventAuthoritative) + 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 (eventAuthoritative or 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, eventAuthoritative) + 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, eventAuthoritative + ) + 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 eventAuthoritative or now - lastSeen >= 0.40 then cache[def.key] = nil end + end + end + end + end +end + + +-- Optional other-class debuff tracking lives in modules/otherdebuffs.lua. +-- auras.lua only consumes that cache for rendering and supplies live snapshots. + + +-- 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 (def.isCC or 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 + + local auraFontSize = (BNP.GetAuraFontSize and BNP:GetAuraFontSize()) or (UI.TIMER_SIZE or 8) + if icon.BNPAuraFontSize ~= auraFontSize then + if icon.timer then + icon.timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", auraFontSize, "OUTLINE") + end + if icon.stack then + icon.stack:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", auraFontSize, "OUTLINE") + end + icon.BNPAuraFontSize = auraFontSize + 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 + if position == "top" then + -- Match the visual center used by the health text. + cursor = cursor + 3 + end + 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) + -- Prefer reliable aura identity over icon matching. Several unrelated auras + -- reuse the same Blizzard icon (for example Resurrection Sickness and + -- Howl of Terror), so texture matching must never override a known spell. + 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 + + -- A known spell name is authoritative. If it is not one of our CCs, cache + -- the negative result and do not reinterpret it from a shared icon texture. + if auraName and auraName ~= "" then + for _, ccDef in ipairs(GLOBAL_CC_DEFS) do + if NameMatches(ccDef, auraName) 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 + + -- Texture is only a compatibility fallback for aura scans where ClassicAPI + -- cannot provide a usable spell ID/name. + if texture then + local lowerTexture = string.lower(texture) + for _, ccDef in ipairs(GLOBAL_CC_DEFS) do + if ccDef.textureMatch and string.find(lowerTexture, ccDef.textureMatch) then + return ccDef + end + 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, snapshot, eventAuthoritative) + 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 + 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 = BNP:_ReadClassicDebuff(unit, i) + if not texture then break end + 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 + -- Event-driven nameplate watchers do not run the legacy 0.15s foreign-CC + -- poller. Remember that this positive sighting came from UNIT_AURA so the + -- renderer keeps it visible until a later authoritative UNIT_AURA removes + -- it (or the normal aura timer expires). Polling callers reset this flag. + aura.eventDriven = eventAuthoritative and true or false + -- 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 eventAuthoritative or (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 + BNP:_HideAuraCooldown(icon) + 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 + +BNP._AuraEntryOrderLess = BNP._AuraEntryOrderLess or function(a, b) + return (a.aura.order or a.aura.firstSeen or 0) < (b.aura.order or b.aura.firstSeen or 0) +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 = container.BNPSeenOwnCC + if not seenOwn then + seenOwn = {} + container.BNPSeenOwnCC = seenOwn + end + container.BNPSeenOwnCCGeneration = (container.BNPSeenOwnCCGeneration or 0) + 1 + local seenOwnGeneration = container.BNPSeenOwnCCGeneration + + 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] = seenOwnGeneration + end + end + end + end + + -- Other Debuffs intentionally excludes Crowd Control. Foreign CCs are + -- handled only by BNP's dedicated CC tracker below. + + 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 + -- Legacy polled CCs need a freshness timeout because a projected token can + -- go stale. Event-driven CCs are different: after a positive UNIT_AURA + -- snapshot there is intentionally no periodic poll, so lastSeen naturally + -- becomes older than 0.60s while the CC is still active. Keep those entries + -- until SyncForeignCCs receives an authoritative removal event. + local liveFresh = aura.eventDriven or (now - (aura.lastSeen or 0) <= 0.60) + if aura.def and seenOwn[key] ~= seenOwnGeneration and liveFresh 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, BNP._AuraEntryOrderLess) + + 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 + if BNP_DB and BNP_DB.cooldownSpiral == true then + BNP:_SafeUpdateAuraCooldown(icon, entry.aura, entry.def, now) + end + icon:Show() + else + icon.lastTimerText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + BNP:_HideAuraCooldown(icon) + icon:Hide() + end + end + + if visibleCount > 0 then container:Show() else container:Hide() end +end + +local REMOVAL_PRESENT = {} +BNP._RemovalPresentSafe = BNP._RemovalPresentSafe or {} +local removalPresentGeneration = 0 + +local function SyncAuraRemoval(unit, guid, now, snapshot, currentTargetGUID, eventAuthoritative) + 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, source + if snapshot then + local entry = snapshot.entries[i] + if not entry then break end + texture, stacks, dtype, auraSpellID, source = entry[1], entry[2], entry[3], entry[4], entry[7] + else + local duration, expirationTime + texture, stacks, dtype, auraSpellID, duration, expirationTime, source = BNP:_ReadClassicDebuff(unit, i) + if not texture then break end + end + + if auraSpellID then + REMOVAL_PRESENT[auraSpellID] = presentGeneration + + -- For a timer previously confirmed with a known local source, a live + -- aura is safe when it is still ours OR ClassicAPI temporarily lost the + -- source metadata. A known foreign source alone is not enough to keep + -- our timer alive. + if not BNP:_IsKnownForeignClassicAuraSource(source) then + BNP._RemovalPresentSafe[auraSpellID] = presentGeneration + end + + 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" and not aura.sharedLive 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 + local presentMap = aura.classicAPISourceVerified and BNP._RemovalPresentSafe or REMOVAL_PRESENT + if presentMap[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 + BNP:ResolveNewAuraCastAttempt(guid, key) + end + elseif eventAuthoritative then + -- NAME_PLATE UNIT_AURA + GetAuraSlots/UnitAuraBySlot is an exact + -- snapshot for this nameplateN token. Unlike old projected SuperWoW + -- tokens, one missing aura here is authoritative and needs no + -- multi-scan anti-flicker grace. + RaidTrace("EVENT_REMOVE", guid, key, aura.spellID) + cache[key] = nil + 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 + + -- ClassicAPI nameplateN identity is exact, so never run it through the old + -- same-name GUID debounce that exists only for transient SuperWoW tokens. + -- Trusting it immediately prevents a recycled same-name plate from showing + -- the previous mob's aura cache for several renderer ticks. + if plate.BNPClassicUnitToken and BNP._ClassicUnitGUID then + local classicGUID = BNP._ClassicUnitGUID(plate.BNPClassicUnitToken) + if classicGUID then + plate.BNPClassicGUID = classicGUID + plate.BNPAuraGUIDStable = classicGUID + plate.BNPAuraGUIDCandidate = classicGUID + plate.BNPAuraGUIDCandidateCount = AURA_GUID_SWITCH_CONFIRM_UPDATES + plate.BNPAuraGUIDLastGoodAt = now + plate.BNPAuraForceFreshIdentity = nil + return classicGUID + end + end + + local guid = GetPlateGUID(plate) + local stable = plate.BNPAuraGUIDStable + -- Player-controlled units (notably Hunter/Warlock pets) should not inherit + -- the long same-name NPC debounce. Pet nameplates are recycled like normal + -- plates, but holding the previous GUID here can briefly project a CC from + -- the old pet onto the new one. + local plateToken = GetUnitTokenForPlate(plate) + local playerControlled = plateToken and UnitPlayerControlled and UnitPlayerControlled(plateToken) + + -- 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 playerControlled 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 playerControlled or 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 + + -- Selected non-CC live debuffs from other players/classes are appended + -- after the local player's own entries. Crowd Control is handled exclusively + -- by BNP's dedicated CC tracker and never enters this list. + if BNP:WantsOtherDebuffs() and guid then + local otherLive = BNP.guidOtherDebuffs and BNP.guidOtherDebuffs[guid] + local classOrder = BNP.OtherDebuffClassOrder or {} + local classIndex, classKey, defs, defIndex, otherDef, otherAura + for classIndex = 1, table.getn(classOrder) do + if activeCount >= MAX_VISIBLE_ICONS then break end + classKey = classOrder[classIndex] + defs = BNP.OtherDebuffDefs and BNP.OtherDebuffDefs[classKey] or nil + if defs then + for defIndex = 1, table.getn(defs) do + if activeCount >= MAX_VISIBLE_ICONS then break end + otherDef = defs[defIndex] + if BNP:IsOtherDebuffDefSelected(otherDef) then + otherAura = otherLive and otherLive[otherDef.key] or nil + if otherAura and otherAura.livePresence + and not BNP:ActiveAuraHasSpellID(active, activeCount, otherAura.spellID) then + activeCount = activeCount + 1 + local entry = activePool[activeCount] + active[activeCount] = entry + entry.def = otherDef + entry.aura = otherAura + local otherRemaining = otherAura.expires and (otherAura.expires - now) or nil + if otherRemaining and otherRemaining <= 0 then otherRemaining = nil end + entry.remaining = otherRemaining + entry.svRenderRevision = nil + end + end + end + end + end + end + + if activeCount > 1 then + table.sort(active, BNP._AuraEntryOrderLess) + 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 + + if BNP_DB and BNP_DB.cooldownSpiral == true then + BNP:_SafeUpdateAuraCooldown(icon, entry.aura, entry.def, now) + end + icon:Show() + else + icon.lastTimerText = nil + icon.BNPLastTexture = nil + icon.BNPLastStackText = nil + icon.timer:SetText("") + if icon.stack then icon.stack:SetText("") end + BNP:_HideAuraCooldown(icon) + 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 + + +-- ClassicAPI v1.15.6 event-driven aura bridge ------------------------------- +-- Each visible plate gets one lightweight UNIT_AURA watcher filtered to its +-- exact nameplateN token. Aura state is scanned only when that unit actually +-- changes, using GetAuraSlots + UnitAuraBySlot with reusable tables. +function BNP:_ClassicAuraEventsReady() + return self._ClassicGetNamePlateForUnit + and self._ClassicGetAuraSlots + and self._ClassicUnitAuraBySlot + and self._ClassicUnitGUID +end + +function BNP:_ReleaseClassicAuraWatcher(plate, token) + if not plate then return end + local watcher = plate.BNPClassicAuraWatcher + if not watcher then + plate.BNPClassicAuraEventDriven = nil + return + end + + if token and watcher.BNPToken and watcher.BNPToken ~= token then return end + watcher:UnregisterEvent("UNIT_AURA") + watcher.BNPToken = nil + plate.BNPClassicAuraEventDriven = nil +end + +function BNP:_HandleClassicUnitAura(token, plate) + if not self:_ClassicAuraEventsReady() then return end + if not token or not plate or not UnitExists(token) then return end + + -- Guard against a recycled watcher/plate. The v1.15.6 removed-event fix + -- keeps this mapping valid through NAME_PLATE_UNIT_REMOVED, and on normal + -- UNIT_AURA it must resolve back to the same live frame. + local mappedPlate = self._ClassicGetNamePlateForUnit(token) + if mappedPlate ~= plate then return end + + local guid = self._ClassicUnitGUID(token) + if not guid then return end + plate.BNPClassicGUID = guid + + local now = GetTime() + if HarmfulAuraSuppressed(guid, now) then + ClearHarmfulAuraStateOnly(guid) + UpdatePlate(plate, now) + return + end + + local snapshot = CaptureLiveDebuffSnapshot(token, now) + + -- Pending own casts and AoE applications consume the same snapshot instead + -- of re-walking the aura descriptor list. + ConfirmPendingForUnit(token, guid, now, snapshot) + if HasPendingAoEAuras() then + ConfirmAoEAurasOnUnit(token, guid, snapshot) + end + + -- Shared effects, early removals and optional foreign CC all reconcile from + -- this exact event snapshot. eventAuthoritative=true removes the old + -- multi-miss protection that was only required for projected SuperWoW units. + SyncSharedAuras(token, guid, now, snapshot, true) + BNP:SyncOtherDebuffs(token, guid, now, snapshot, true) + if BNP.guidAuras[guid] then + SyncAuraRemoval(token, guid, now, snapshot, GetTargetGUID(), true) + end + if WantsForeignCCs() then + SyncForeignCCs(token, guid, now, snapshot, true) + elseif BNP.guidLiveCCs then + BNP.guidLiveCCs[guid] = nil + end + + plate.BNPClassicAuraLastEvent = now + UpdatePlate(plate, now) + + -- Tiny diagnostics only; no per-event tables are created. + if not self.classicAuraStats then self.classicAuraStats = { events = 0, scans = 0 } end + self.classicAuraStats.events = (self.classicAuraStats.events or 0) + 1 + self.classicAuraStats.scans = (self.classicAuraStats.scans or 0) + 1 +end + +function BNP:_EnsureClassicAuraWatcher(plate, token) + if not plate or not token or not self:_ClassicAuraEventsReady() then return end + + local watcher = plate.BNPClassicAuraWatcher + if not watcher then + watcher = CreateFrame("Frame") + if not watcher.RegisterUnitEvent then return end + watcher.BNPPlate = plate + watcher:SetScript("OnEvent", function() + if event == "UNIT_AURA" and this.BNPToken and arg1 == this.BNPToken then + BNP:_HandleClassicUnitAura(this.BNPToken, this.BNPPlate) + end + end) + plate.BNPClassicAuraWatcher = watcher + end + + if watcher.BNPToken ~= token or not plate.BNPClassicAuraEventDriven then + watcher:UnregisterEvent("UNIT_AURA") + watcher.BNPToken = token + watcher.BNPPlate = plate + local registered = watcher:RegisterUnitEvent("UNIT_AURA", token) + if registered then + plate.BNPClassicAuraEventDriven = true + else + plate.BNPClassicAuraEventDriven = nil + watcher.BNPToken = nil + return + end + end + + -- NAME_PLATE_UNIT_ADDED may expose a unit that already has debuffs. Seed the + -- state once immediately instead of waiting for the next aura change. + self:_HandleClassicUnitAura(token, plate) +end + +-- If the file loaded while plates were already visible (reload/UI restart), +-- bind them now. Future plates are attached from _BindClassicPlateToken(). +if BNP:_ClassicAuraEventsReady() then + BNP:_RefreshClassicPlateTokens() +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. Optional CooldownFrames are created +-- lazily per used icon only when Cooldown Spiral is enabled. +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 + -- If the target has a live ClassicAPI nameplate watcher, UNIT_AURA already + -- performs exact removal/shared synchronization. Do not duplicate that + -- destructive scan every 0.10s. + if BNP._ClassicGetNamePlateForUnit then + local exactTargetPlate = BNP._ClassicGetNamePlateForUnit("target") + if exactTargetPlate and exactTargetPlate.BNPClassicAuraEventDriven then + doTargetRemovalScan = false + end + end + 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 (BNP.WantsOtherDebuffs and BNP:WantsOtherDebuffs()) or CacheHasTrackedAuras(BNP.guidAuras[targetGUID]) then + targetSnapshot = CaptureLiveDebuffSnapshot("target", now) + end + + SyncSharedAuras("target", targetGUID, now, targetSnapshot) + BNP:SyncOtherDebuffs("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 not plate.BNPClassicAuraEventDriven + 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 (BNP.WantsOtherDebuffs and BNP:WantsOtherDebuffs()) or CacheHasTrackedAuras(BNP.guidAuras[stableGUID]) then + snapshot = CaptureLiveDebuffSnapshot(token, now) + end + + SyncSharedAuras(token, stableGUID, now, snapshot) + BNP:SyncOtherDebuffs(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 + local targetEventDriven = false + if BNP._ClassicGetNamePlateForUnit then + local exactTargetPlate = BNP._ClassicGetNamePlateForUnit("target") + targetEventDriven = exactTargetPlate and exactTargetPlate.BNPClassicAuraEventDriven and true or false + end + if targetGUID and not targetEventDriven 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 not plate.BNPClassicAuraEventDriven + and GetForeignCCScanBucket(plate) == foreignCCScanBucket then + -- Foreign CC scans are allowed to write only when the projected token + -- and the renderer agree on the exact GUID. This mirrors the safeguard + -- already used by destructive debuff-removal scans and prevents recycled + -- pet plates from receiving another unit's live CC state. + local rawGUID = GetPlateGUID(plate) + local stableGUID = GetStablePlateGUID(plate, now) + if rawGUID and stableGUID and rawGUID == stableGUID and stableGUID ~= targetGUID then + local token = GetUnitTokenForPlate(plate) + if token then pcall(SyncForeignCCs, token, stableGUID, 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 = BNP:_ReadClassicDebuff(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/personalnameplate.lua b/modules/personalnameplate.lua index 9aadc1e..b8c4d44 100644 --- a/modules/personalnameplate.lua +++ b/modules/personalnameplate.lua @@ -15,6 +15,9 @@ local POWER_HEIGHT = 5 local PERSONAL_DEBUFF_MAX_DURATION = 60 local PERSONAL_DEBUFF_MAX_ICONS = 6 local PERSONAL_DEBUFF_SPACING = 1 +local PERSONAL_BUFF_MAX_DURATION = 60 +local PERSONAL_BUFF_MAX_ICONS = 6 +local PERSONAL_BUFF_SPACING = 1 -- Match BNP health text anchoring: healthbar CENTER +3px. The personal frame -- is wider than the healthbar because of the level medallion, so centering -- debuffs on the whole frame makes the row look shifted to the right. @@ -24,6 +27,14 @@ local PERSONAL_POWER_TEXT_Y = 1 local PERSONAL_CONTENT_CENTER_X = (5 + HEALTH_WIDTH / 2 + PERSONAL_HEALTH_TEXT_X) - (FRAME_WIDTH / 2) local STATUS_TEXTURE = "Interface\\TargetingFrame\\UI-StatusBar" local BORDER_TEXTURE = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\classic_nameplate_border" +-- The classic texture's health-bar portion occupies the first 108/128 pixels; +-- the final 20 pixels are the round level medallion. Cropping at 108 gives us +-- a native-looking closed border when the personal level is hidden. +local NO_LEVEL_BORDER_WIDTH = 108 +local NO_LEVEL_BORDER_TEX_RIGHT = 108 / 128 +local NO_LEVEL_BORDER_X = (FRAME_WIDTH - NO_LEVEL_BORDER_WIDTH) / 2 +local NO_LEVEL_HEALTH_X = (FRAME_WIDTH - HEALTH_WIDTH) / 2 +local NO_LEVEL_POWER_X = NO_LEVEL_HEALTH_X local FONT = "Fonts\\FRIZQT__.TTF" local classFallback = { @@ -97,6 +108,7 @@ local personalFrame local inCombat = false local updateElapsed = 0 local debuffScanElapsed = 0 +local buffScanElapsed = 0 local function FormatValue(value) value = tonumber(value) or 0 @@ -285,6 +297,52 @@ local function CreatePersonalFrame() end debuffHolder:Hide() + -- Short player buffs (HoTs, absorbs, short defensive effects) use a second + -- compact row. When debuffs are visible this row sits directly above them; + -- otherwise it drops down to the normal aura anchor above the plate. + local buffHolder = CreateFrame("Frame", nil, frame) + buffHolder:SetWidth(FRAME_WIDTH) + buffHolder:SetHeight(22) + buffHolder:SetPoint("BOTTOM", frame, "TOP", PERSONAL_CONTENT_CENTER_X, 1) + buffHolder:SetFrameLevel(frame:GetFrameLevel() + 6) + buffHolder:EnableMouse(false) + buffHolder.icons = {} + frame.buffHolder = buffHolder + + for i = 1, PERSONAL_BUFF_MAX_ICONS do + local icon = CreateFrame("Frame", nil, buffHolder) + icon:SetWidth(18) + icon:SetHeight(18) + icon:SetFrameLevel(buffHolder:GetFrameLevel()) + icon:EnableMouse(false) + + local iconBG = CreateSolidTexture(icon, "BACKGROUND") + iconBG:SetPoint("TOPLEFT", icon, "TOPLEFT", -1, 1) + iconBG:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 1, -1) + iconBG:SetVertexColor(0, 0, 0, 0.95) + + local texture = icon:CreateTexture(nil, "ARTWORK") + texture:SetAllPoints(icon) + icon.texture = texture + + local timer = icon:CreateFontString(nil, "OVERLAY") + timer:SetFont(FONT, 8, "OUTLINE") + timer:SetPoint("CENTER", icon, "CENTER", 0, 0) + timer:SetTextColor(1, 1, 1) + icon.timer = timer + + local stack = icon:CreateFontString(nil, "OVERLAY") + stack:SetFont(FONT, 7, "OUTLINE") + stack:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1) + stack:SetTextColor(1, 1, 1) + stack:SetText("") + icon.stack = stack + + icon:Hide() + buffHolder.icons[i] = icon + end + buffHolder:Hide() + personalFrame = frame BNP.personalNameplateFrame = frame return frame @@ -303,9 +361,43 @@ local function GetPlayerHealthColor() return 0.10, 0.95, 0.10 end +local function ApplyLevelLayout(frame) + if not frame then return end + + local hideLevel = BNP.IsPersonalNameplateLevelHidden and BNP:IsPersonalNameplateLevelHidden() + if hideLevel then + frame.border:ClearAllPoints() + frame.border:SetWidth(NO_LEVEL_BORDER_WIDTH) + frame.border:SetTexCoord(0, NO_LEVEL_BORDER_TEX_RIGHT, 0, 1) + frame.border:SetPoint("TOPLEFT", frame, "TOPLEFT", NO_LEVEL_BORDER_X, 0) + + frame.healthbar:ClearAllPoints() + frame.healthbar:SetWidth(HEALTH_WIDTH) + frame.healthbar:SetPoint("TOPLEFT", frame, "TOPLEFT", NO_LEVEL_HEALTH_X, -19) + + frame.powerHolder:ClearAllPoints() + frame.powerHolder:SetPoint("TOPLEFT", frame, "TOPLEFT", NO_LEVEL_POWER_X, -34) + frame.levelText:Hide() + else + frame.border:ClearAllPoints() + frame.border:SetWidth(128) + frame.border:SetTexCoord(0, 1, 0, 1) + frame.border:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + + frame.healthbar:ClearAllPoints() + frame.healthbar:SetWidth(HEALTH_WIDTH) + frame.healthbar:SetPoint("TOPLEFT", frame, "TOPLEFT", 5, -19) + + frame.powerHolder:ClearAllPoints() + frame.powerHolder:SetPoint("TOPLEFT", frame, "TOPLEFT", 3, -34) + end +end + local function UpdateStyle(frame) if not frame then return end + ApplyLevelLayout(frame) + local dark = BNP.IsDarkNameplateBorderEnabled and BNP:IsDarkNameplateBorderEnabled() local hidden = BNP.IsNameplateBorderHidden and BNP:IsNameplateBorderHidden() local blackBG = BNP.IsBlackHealthbarBackgroundEnabled and BNP:IsBlackHealthbarBackgroundEnabled() @@ -332,12 +424,39 @@ local function UpdateStyle(frame) frame.healthBG:SetVertexColor(0, 0, 0, 1) end - if hidden then frame.levelText:Hide() else frame.levelText:Show() end + local hideLevel = BNP.IsPersonalNameplateLevelHidden and BNP:IsPersonalNameplateLevelHidden() + if hidden or hideLevel then frame.levelText:Hide() else frame.levelText:Show() end local hr, hg, hb = GetPlayerHealthColor() frame.healthbar:SetStatusBarColor(hr, hg, hb) end +local function PositionPersonalAuraHolders(frame) + if not frame then return end + + local hideLevel = BNP.IsPersonalNameplateLevelHidden and BNP:IsPersonalNameplateLevelHidden() + local contentCenterX = hideLevel and PERSONAL_HEALTH_TEXT_X or PERSONAL_CONTENT_CENTER_X + + local debuffY = BNP.GetPersonalNameplateDebuffYOffset and BNP:GetPersonalNameplateDebuffYOffset() or 0 + local buffY = BNP.GetPersonalNameplateBuffYOffset and BNP:GetPersonalNameplateBuffYOffset() or 0 + local debuffX = BNP.GetPersonalNameplateDebuffXOffset and BNP:GetPersonalNameplateDebuffXOffset() or 0 + local buffX = BNP.GetPersonalNameplateBuffXOffset and BNP:GetPersonalNameplateBuffXOffset() or 0 + + if frame.debuffHolder then + frame.debuffHolder:ClearAllPoints() + frame.debuffHolder:SetPoint("BOTTOM", frame, "TOP", contentCenterX + debuffX, 1 + debuffY) + end + + if frame.buffHolder then + frame.buffHolder:ClearAllPoints() + if frame.debuffHolder and frame.debuffHolder:IsShown() then + frame.buffHolder:SetPoint("BOTTOM", frame, "TOP", contentCenterX + buffX, 25 + buffY) + else + frame.buffHolder:SetPoint("BOTTOM", frame, "TOP", contentCenterX + buffX, 1 + buffY) + end + end +end + local function UpdatePosition(frame) if not frame then return end local scale = BNP.GetPersonalNameplateScale and BNP:GetPersonalNameplateScale() or 1.0 @@ -346,12 +465,7 @@ local function UpdatePosition(frame) frame:SetScale(scale) frame:ClearAllPoints() frame:SetPoint("CENTER", UIParent, "CENTER", 0, y) - - if frame.debuffHolder then - local debuffY = BNP.GetPersonalNameplateDebuffYOffset and BNP:GetPersonalNameplateDebuffYOffset() or 0 - frame.debuffHolder:ClearAllPoints() - frame.debuffHolder:SetPoint("BOTTOM", frame, "TOP", PERSONAL_CONTENT_CENTER_X, 1 + debuffY) - end + PositionPersonalAuraHolders(frame) end local function GetPersonalDebuffIconSize() @@ -373,7 +487,13 @@ local function ReadPlayerDebuff(index) if type(C_UnitAuras) == "table" and type(C_UnitAuras.GetDebuffDataByIndex) == "function" then local ok, aura = pcall(C_UnitAuras.GetDebuffDataByIndex, "player", index) if not ok or not aura then return nil end - return aura.icon, aura.applications or aura.count or 0, aura.duration, aura.expirationTime, aura.name, aura.spellId or aura.spellID + return aura.icon or aura.texture, aura.applications or aura.count or 0, aura.duration, aura.expirationTime, aura.name, aura.spellId or aura.spellID + end + + if type(C_UnitAuras) == "table" and type(C_UnitAuras.GetAuraDataByIndex) == "function" then + local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex, "player", index, "HARMFUL") + if not ok or not aura then return nil end + return aura.icon or aura.texture, aura.applications or aura.count or 0, aura.duration, aura.expirationTime, aura.name, aura.spellId or aura.spellID end if type(C_UnitAuras) == "table" and type(C_UnitAuras.UnitDebuff) == "function" then @@ -393,6 +513,35 @@ local function ReadPlayerDebuff(index) return nil end +local function ReadPlayerBuff(index) + if type(C_UnitAuras) == "table" and type(C_UnitAuras.GetBuffDataByIndex) == "function" then + local ok, aura = pcall(C_UnitAuras.GetBuffDataByIndex, "player", index) + if not ok or not aura then return nil end + return aura.icon or aura.texture, aura.applications or aura.count or 0, aura.duration, aura.expirationTime, aura.name, aura.spellId or aura.spellID + end + + if type(C_UnitAuras) == "table" and type(C_UnitAuras.GetAuraDataByIndex) == "function" then + local ok, aura = pcall(C_UnitAuras.GetAuraDataByIndex, "player", index, "HELPFUL") + if not ok or not aura then return nil end + return aura.icon or aura.texture, aura.applications or aura.count or 0, aura.duration, aura.expirationTime, aura.name, aura.spellId or aura.spellID + end + + if type(C_UnitAuras) == "table" and type(C_UnitAuras.UnitBuff) == "function" then + local name, icon, count, dispelType, duration, expirationTime, source, isStealable, nameplateShowPersonal, spellID = C_UnitAuras.UnitBuff("player", index) + if not name then return nil end + return icon, count or 0, duration, expirationTime, name, spellID + end + + -- As with debuffs, legacy 1.12 UnitBuff has no dependable duration on its + -- own. Only use it when a custom client supplies duration + expiration. + if UnitBuff then + local texture, count, dispelType, spellID, duration, expirationTime = UnitBuff("player", index) + if not texture then return nil end + return texture, count or 0, duration, expirationTime, nil, spellID + end + return nil +end + local function HidePersonalDebuffs(frame) if not frame or not frame.debuffHolder then return end local i @@ -403,6 +552,7 @@ local function HidePersonalDebuffs(frame) icon:Hide() end frame.debuffHolder:Hide() + PositionPersonalAuraHolders(frame) end local function UpdatePersonalDebuffs(frame, forceScan) @@ -489,6 +639,109 @@ local function UpdatePersonalDebuffs(frame, forceScan) icon:Hide() end frame.debuffHolder:Show() + PositionPersonalAuraHolders(frame) +end + +local function HidePersonalBuffs(frame) + if not frame or not frame.buffHolder then return end + local i + for i = 1, table.getn(frame.buffHolder.icons or {}) do + local icon = frame.buffHolder.icons[i] + icon.timer:SetText("") + icon.stack:SetText("") + icon:Hide() + end + frame.buffHolder:Hide() +end + +local function UpdatePersonalBuffs(frame, forceScan) + if not frame or not frame.buffHolder then return end + if not (BNP.IsPersonalNameplateBuffsEnabled and BNP:IsPersonalNameplateBuffsEnabled()) then + frame.BNPPersonalBuffEntries = nil + HidePersonalBuffs(frame) + return + end + + local now = GetTime() + local entries = frame.BNPPersonalBuffEntries + if forceScan or not entries then + entries = {} + local index + for index = 1, 64 do + local texture, count, duration, expirationTime, name, spellID = ReadPlayerBuff(index) + if not texture then break end + + duration = tonumber(duration) or 0 + expirationTime = tonumber(expirationTime) or 0 + -- Keep this row focused on combat-relevant temporary effects: HoTs, + -- absorbs/shields and short cooldown buffs. Long raid/world buffs are + -- intentionally ignored. + if duration > 0 and duration <= PERSONAL_BUFF_MAX_DURATION and expirationTime > now then + table.insert(entries, { + texture = texture, + count = tonumber(count) or 0, + duration = duration, + expirationTime = expirationTime, + name = name, + spellID = spellID, + }) + if table.getn(entries) >= PERSONAL_BUFF_MAX_ICONS then break end + end + end + frame.BNPPersonalBuffEntries = entries + end + + local visible = 0 + local i + for i = 1, table.getn(entries) do + if entries[i].expirationTime and entries[i].expirationTime > now then + visible = visible + 1 + end + end + + if visible < 1 then + HidePersonalBuffs(frame) + return + end + + local size = GetPersonalDebuffIconSize() + local totalWidth = visible * size + (visible - 1) * PERSONAL_BUFF_SPACING + local startX = -totalWidth / 2 + local displayIndex = 0 + for i = 1, table.getn(entries) do + local entry = entries[i] + if entry.expirationTime and entry.expirationTime > now then + displayIndex = displayIndex + 1 + local icon = frame.buffHolder.icons[displayIndex] + if icon then + if icon.BNPLastSize ~= size then + icon:SetWidth(size) + icon:SetHeight(size) + icon.timer:SetFont(FONT, math.max(7, math.floor(size * 0.44 + 0.5)), "OUTLINE") + icon.stack:SetFont(FONT, math.max(6, math.floor(size * 0.38 + 0.5)), "OUTLINE") + icon.BNPLastSize = size + end + icon:ClearAllPoints() + icon:SetPoint("LEFT", frame.buffHolder, "CENTER", startX + (displayIndex - 1) * (size + PERSONAL_BUFF_SPACING), 0) + if icon.BNPLastTexture ~= entry.texture then + icon.texture:SetTexture(entry.texture) + icon.BNPLastTexture = entry.texture + end + icon.timer:SetText(FormatDebuffTimer(entry.expirationTime - now)) + icon.stack:SetText(entry.count > 1 and tostring(entry.count) or "") + icon:Show() + end + end + end + + for i = displayIndex + 1, table.getn(frame.buffHolder.icons) do + local icon = frame.buffHolder.icons[i] + icon.timer:SetText("") + icon.stack:SetText("") + icon:Hide() + end + frame.buffHolder:Show() + PositionPersonalAuraHolders(frame) end local function UpdateValues(frame) @@ -610,6 +863,7 @@ local function SetRuntimeEnabled(enabled) updater:SetScript("OnUpdate", nil) updateElapsed = 0 debuffScanElapsed = 0 + buffScanElapsed = 0 if personalFrame then personalFrame:Hide() end end end @@ -632,6 +886,7 @@ local function Refresh() if ShouldShow() then frame:Show() UpdatePersonalDebuffs(frame, true) + UpdatePersonalBuffs(frame, true) else frame:Hide() end @@ -646,7 +901,10 @@ function BNP:RefreshPersonalNameplateStyle() if not personalFrame then return end UpdateStyle(personalFrame) UpdateValues(personalFrame) - if personalFrame:IsShown() then UpdatePersonalDebuffs(personalFrame, true) end + if personalFrame:IsShown() then + UpdatePersonalDebuffs(personalFrame, true) + UpdatePersonalBuffs(personalFrame, true) + end end events:SetScript("OnEvent", function() @@ -683,7 +941,10 @@ events:SetScript("OnEvent", function() if personalFrame then UpdateValues(personalFrame) UpdateStyle(personalFrame) - if event == "UNIT_AURA" and personalFrame:IsShown() then UpdatePersonalDebuffs(personalFrame, true) end + if event == "UNIT_AURA" and personalFrame:IsShown() then + UpdatePersonalDebuffs(personalFrame, true) + UpdatePersonalBuffs(personalFrame, true) + end end end) @@ -702,16 +963,29 @@ RuntimeOnUpdate = function() if not personalFrame:IsShown() then personalFrame:Show() debuffScanElapsed = 0.50 + buffScanElapsed = 0.50 end UpdateValues(personalFrame) - debuffScanElapsed = debuffScanElapsed + 0.10 - local forceScan = false - if debuffScanElapsed >= 0.50 then - debuffScanElapsed = 0 - forceScan = true + local forceDebuffScan = false + if BNP.IsPersonalNameplateDebuffsEnabled and BNP:IsPersonalNameplateDebuffsEnabled() then + debuffScanElapsed = debuffScanElapsed + 0.10 + if debuffScanElapsed >= 0.50 then + debuffScanElapsed = 0 + forceDebuffScan = true + end end - UpdatePersonalDebuffs(personalFrame, forceScan) + UpdatePersonalDebuffs(personalFrame, forceDebuffScan) + + local forceBuffScan = false + if BNP.IsPersonalNameplateBuffsEnabled and BNP:IsPersonalNameplateBuffsEnabled() then + buffScanElapsed = buffScanElapsed + 0.10 + if buffScanElapsed >= 0.50 then + buffScanElapsed = 0 + forceBuffScan = true + end + end + UpdatePersonalBuffs(personalFrame, forceBuffScan) elseif personalFrame:IsShown() then personalFrame:Hide() end