Files
BlizzNameplatesPlus/modules/auras.lua
T
2026-09-29 21:20:49 +02:00

4381 lines
155 KiB
Lua

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
-- 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.pendingAuras then BNP.pendingAuras[guid] = nil end
if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end
if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end
if BNP.guidNames then BNP.guidNames[guid] = nil end
end
-- Full-immunity cleanse bridge ----------------------------------------------
-- Divine Shield and Ice Block remove harmful effects. BNP tracks some of
-- those effects from cast events/timers, so a server cleanse must also wipe
-- every local harmful-aura cache for that exact GUID. While the immunity is
-- active, keep a short renewable suppression window so stale UnitDebuff /
-- pending-cast data cannot repopulate icons for one frame.
BNP.guidHarmfulAuraSuppressionUntil = BNP.guidHarmfulAuraSuppressionUntil or {}
local function ClearHarmfulAuraStateOnly(guid)
if not guid then return end
BNP.guidAuras[guid] = nil
if BNP.svStateByGUID then BNP.svStateByGUID[guid] = nil end
if BNP.svExpiryByGUID then BNP.svExpiryByGUID[guid] = nil end
if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end
if BNP.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end
if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end
if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end
if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end
-- A failed/immune cast rollback must never restore pre-cleanse timers after
-- Bubble/Ice Block. Drop all outstanding attempts tied to this GUID.
if BNP.auraCastAttempts then
local i
for i = table.getn(BNP.auraCastAttempts), 1, -1 do
local attempt = BNP.auraCastAttempts[i]
if attempt and attempt.guid == guid then
table.remove(BNP.auraCastAttempts, i)
end
end
end
end
local function HarmfulAuraSuppressed(guid, now)
if not guid then return false end
local untilTime = BNP.guidHarmfulAuraSuppressionUntil[guid]
if not untilTime then return false end
now = now or GetTime()
if now < untilTime then return true end
BNP.guidHarmfulAuraSuppressionUntil[guid] = nil
return false
end
function BNP:SuppressHarmfulAurasForImmunity(guid, seconds)
if not guid then return end
local now = GetTime()
local untilTime = now + math.max(tonumber(seconds) or 0.35, 0.10)
local current = self.guidHarmfulAuraSuppressionUntil[guid] or 0
if untilTime > current then
self.guidHarmfulAuraSuppressionUntil[guid] = untilTime
end
ClearHarmfulAuraStateOnly(guid)
end
function BNP:IsHarmfulAuraSuppressed(guid, now)
return HarmfulAuraSuppressed(guid, now)
end
local function CleanupDeadUnit(unit, guid, now)
if not unit or not guid or not UnitIsDeadOrGhost then return false end
if AuraCacheProtected and AuraCacheProtected(guid, now) then return false end
if UnitIsDeadOrGhost(unit) then
ClearGUIDAuraState(guid)
return true
end
return false
end
-- Fallback for players/NPCs whose nameplate disappears before the periodic
-- dead-state check sees them. Vanilla combat chat reports "<name> 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
fear=true, howl_of_terror=true, banish=true, death_coil=true,
-- Hunter traps / CC / talent procs
immolation_trap_effect=true, explosive_trap_effect=true, freezing_trap_effect=true,
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
-- Live aura removal ---------------------------------------------------------
-- Timers alone are not enough for CC/roots/traps: effects may be dispelled,
-- broken by damage, removed by trinkets, or otherwise end early.
--
-- We NEVER create auras from this scan. It may only remove already-confirmed
-- own auras from the GUID cache when their exact spell ID is no longer present.
local REMOVAL_SCAN_INTERVAL = 0.10
local REMOVAL_GRACE = 0.35
local NORMAL_REMOVAL_MISSING_SCANS = 4
-- Projected non-target nameplate tokens can briefly return an incomplete
-- UnitDebuff list when many plates overlap in raids. CC used to be deleted on
-- the first such miss, which made Banish/roots/stuns blink. Require a few
-- consecutive misses for non-target CC while keeping the real target strict.
local EXACT_NON_TARGET_MISSING_SCANS = 3
local function CacheHasTrackedAuras(cache)
if not cache then return false end
local key, aura
for key, aura in pairs(cache) do
if type(aura) == "table" and aura.spellID and aura.expires then
return true
end
end
return false
end
local CHANNEL_LIVE_REMOVAL = {
drain_life=true, drain_soul=true,
mind_flay=true, starshards=true, mind_control=true,
}
local EXACT_LIVE_REMOVAL = {
-- Warlock
fear=true, howl_of_terror=true, banish=true, death_coil=true,
-- Hunter
freezing_trap_effect=true, improved_wing_clip=true, improved_concussive_shot=true,
scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true,
intimidation=true, scare_beast=true,
-- Mage
polymorph=true, frost_nova=true, counterspell_silence=true, impact=true, frostbite=true,
-- Priest
psychic_scream=true, silence=true, blackout=true, shackle_undead=true, mind_control=true,
-- Paladin
hammer_of_justice=true, repentance=true, turn_undead=true,
-- Rogue
gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, kick_silenced=true,
-- Druid
entangling_roots=true, bash=true, pounce=true, hibernate=true, feral_charge_effect=true,
-- Warrior
improved_hamstring=true, intimidating_shout=true, disarm=true, charge_stun=true,
intercept_stun=true, concussion_blow=true, revenge_stun=true, pummel=true, shield_bash=true,
-- Shaman / custom roots
earthgrab=true,
}
local function NeedsExactLiveRemoval(key)
return EXACT_LIVE_REMOVAL[key] and true or false
end
local function IsCrowdControlDef(def)
return def and EXACT_LIVE_REMOVAL[def.key] and true or false
end
local function GetEntryIconSize(entry, forceCC)
if forceCC or (entry and IsCrowdControlDef(entry.def)) then
return GetCCIconSize()
end
return GetIconSize()
end
local function ApplyIconDimensions(icon, size)
if not icon or not size then return end
if icon.BNPLayoutSize ~= size then
icon:SetWidth(size)
icon:SetHeight(size)
icon.BNPLayoutSize = size
end
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
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
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)
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 CacheHasTrackedAuras(BNP.guidAuras[targetGUID]) then
targetSnapshot = CaptureLiveDebuffSnapshot("target", now)
end
SyncSharedAuras("target", targetGUID, now, targetSnapshot)
if BNP.guidAuras[targetGUID] then
SyncAuraRemoval("target", targetGUID, now, targetSnapshot, targetGUID)
end
end
-- The normal renderer always runs. Extra GUID/token/UnitDebuff work is only
-- performed while pending casts exist or during the throttled removal scan.
for plate in pairs(BNP.plates) do
local plateRemovalDue = plate:IsShown() and 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 CacheHasTrackedAuras(BNP.guidAuras[stableGUID]) then
snapshot = CaptureLiveDebuffSnapshot(token, now)
end
SyncSharedAuras(token, stableGUID, now, snapshot)
-- Non-target exact-removal also requires multiple misses below, so
-- crowded raid aura lists cannot make Banish/CC blink.
SyncAuraRemoval(token, stableGUID, now, snapshot, targetGUID)
end
end
end
end
UpdatePlate(plate, now)
end
if hasPending then
CleanupPending(now)
end
if hasPendingAoE then
CleanupPendingAoE(now)
end
end)
-- Foreign CC scanner ---------------------------------------------------------
-- Runs on its own frame so this optional feature can never interrupt the proven
-- own-debuff renderer/update loop.
local FOREIGN_CC_SCAN_INTERVAL = 0.15
local FOREIGN_CC_BUCKET_INTERVAL = FOREIGN_CC_SCAN_INTERVAL / 2
local foreignCCElapsed = 0
local foreignCCTargetElapsed = 0
local foreignCCScanBucket = 0
local foreignCCNextBucket = 0
local function GetForeignCCScanBucket(plate)
if plate.BNPForeignCCScanBucket == nil then
plate.BNPForeignCCScanBucket = foreignCCNextBucket
foreignCCNextBucket = 1 - foreignCCNextBucket
end
return plate.BNPForeignCCScanBucket
end
local foreignCCScanner = CreateFrame("Frame")
foreignCCScanner:SetScript("OnUpdate", function()
if not WantsForeignCCs() then return end
foreignCCElapsed = foreignCCElapsed + arg1
foreignCCTargetElapsed = foreignCCTargetElapsed + arg1
if foreignCCElapsed < FOREIGN_CC_BUCKET_INTERVAL then return end
foreignCCElapsed = 0
foreignCCScanBucket = 1 - foreignCCScanBucket
local now = GetTime()
local targetGUID = GetTargetGUID()
if foreignCCTargetElapsed >= FOREIGN_CC_SCAN_INTERVAL then
foreignCCTargetElapsed = 0
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