Match ShaguTweaks border themes, add modern health and cast textures, and make the level number adjustable. Keep target color on the border and use a dark masked level inset.
4616 lines
166 KiB
Lua
4616 lines
166 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
|
|
|
|
-- Remember the actual attachment, not a screen coordinate. Some 1.12
|
|
-- nameplate frames are recycled after Blizzard has touched their regions.
|
|
-- A cheap, throttled check below can then repair a lost anchor without
|
|
-- calling ClearAllPoints() on every render tick.
|
|
if container.GetPoint then
|
|
container.BNPAnchorPoint, container.BNPAnchorRelative,
|
|
container.BNPAnchorRelativePoint, container.BNPAnchorX,
|
|
container.BNPAnchorY = container:GetPoint(1)
|
|
end
|
|
container.BNPAnchorRightSpacing = (position == "right") and GetRightAuraSpacing(plate) or nil
|
|
end
|
|
|
|
-- Only validate the attachment periodically. The icon layout itself is
|
|
-- cached independently; reflowing all icons every 0.05s is unnecessary.
|
|
function BNP:VerifyAuraContainerAnchor(plate, container, isCC)
|
|
if not plate or not container or not container.GetPoint then return end
|
|
|
|
local point, relative, relativePoint, x, y = container:GetPoint(1)
|
|
local expectedAnchor = plate.healthbar or plate
|
|
local position = isCC and GetCCPosition() or GetDebuffPosition()
|
|
local expectedRightSpacing = (position == "right") and GetRightAuraSpacing(plate) or nil
|
|
if point ~= container.BNPAnchorPoint
|
|
or relative ~= container.BNPAnchorRelative
|
|
or relativePoint ~= container.BNPAnchorRelativePoint
|
|
or math.abs((x or 0) - (container.BNPAnchorX or 0)) > 0.01
|
|
or math.abs((y or 0) - (container.BNPAnchorY or 0)) > 0.01
|
|
or relative ~= expectedAnchor
|
|
or container.BNPAnchorRightSpacing ~= expectedRightSpacing
|
|
or (container.GetNumPoints and container:GetNumPoints() ~= 1) then
|
|
AnchorAuraContainer(plate, container, isCC)
|
|
end
|
|
end
|
|
|
|
function BNP:RefreshAuraLayoutForPlate(plate)
|
|
if not plate then return end
|
|
if plate.BNPAuraContainer then AnchorAuraContainer(plate, plate.BNPAuraContainer, false) end
|
|
if plate.BNPCCContainer then AnchorAuraContainer(plate, plate.BNPCCContainer, true) end
|
|
plate.BNPAuraLastNameHidden = (BNP.IsPlateNameHidden and BNP:IsPlateNameHidden(plate)) or (plate.BNPNameHidden and true or false)
|
|
end
|
|
|
|
-- Aura rows are anchored to the projected nameplate but parented beside it.
|
|
-- That keeps their opacity independent from transient Vanilla nameplate alpha
|
|
-- changes while preserving the native world/nameplate layer.
|
|
|
|
-- SuperWoW GUID cache. Auras are learned while a unit is the current target
|
|
-- and remain attached to that unit's unique GUID after the target changes.
|
|
BNP.guidAuras = BNP.guidAuras or {}
|
|
BNP.guidNames = BNP.guidNames or {}
|
|
BNP.guidLiveCCs = BNP.guidLiveCCs or {}
|
|
-- Shadow Vulnerability is a shared proc aura whose refresh can be invisible to
|
|
-- vanilla UnitDebuff (same spell ID, same stack count, no duration data). Keep
|
|
-- its countdown in a dedicated GUID state so generic shared-aura scans can
|
|
-- never accidentally replace or freeze the timer.
|
|
BNP.svExpiryByGUID = BNP.svExpiryByGUID or {}
|
|
-- Fully isolated Shadow Vulnerability state. Generic shared-aura code may
|
|
-- mirror this state for rendering, but it must never own or rewrite the timer.
|
|
BNP.svStateByGUID = BNP.svStateByGUID or {}
|
|
-- Monotonic render revision per GUID. A confirmed refresh increments this so
|
|
-- the normal nameplate renderer can invalidate any cached timer text safely.
|
|
BNP.svRenderRevisionByGUID = BNP.svRenderRevisionByGUID or {}
|
|
|
|
BNP.pvpAuraProtection = BNP.pvpAuraProtection or {}
|
|
local PVP_AURA_PROTECTION_TIME = 6.0
|
|
|
|
local function ProtectAuraCache(guid, reason)
|
|
if not guid then return end
|
|
BNP.pvpAuraProtection[guid] = {
|
|
untilTime = GetTime() + PVP_AURA_PROTECTION_TIME,
|
|
reason = reason,
|
|
}
|
|
end
|
|
|
|
local function AuraCacheProtected(guid, now)
|
|
local state = guid and BNP.pvpAuraProtection[guid]
|
|
if not state then return false end
|
|
if now <= (state.untilTime or 0) then return true end
|
|
BNP.pvpAuraProtection[guid] = nil
|
|
return false
|
|
end
|
|
|
|
local CLASS_AURAS = {
|
|
WARLOCK = BNP.WarlockAuras,
|
|
PRIEST = BNP.PriestAuras,
|
|
WARRIOR = BNP.WarriorAuras,
|
|
ROGUE = BNP.RogueAuras,
|
|
HUNTER = BNP.HunterAuras,
|
|
MAGE = BNP.MageAuras,
|
|
DRUID = BNP.DruidAuras,
|
|
SHAMAN = BNP.ShamanAuras,
|
|
PALADIN = BNP.PaladinAuras,
|
|
}
|
|
local AURA_DEFS = CLASS_AURAS[playerClass] or {}
|
|
local HAS_SHARED_AURAS = false
|
|
do
|
|
local _, def
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if def.shared then
|
|
HAS_SHARED_AURAS = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local MAX_VISIBLE_ICONS = 8
|
|
|
|
local function PositionAuraIcon(icon, container, index, visibleCount, iconSize, isCC)
|
|
if not icon or not container then return end
|
|
local position = isCC and GetCCPosition() or GetDebuffPosition()
|
|
local step = iconSize + ICON_SPACING
|
|
|
|
icon:ClearAllPoints()
|
|
|
|
if position == "left" or position == "bottom_right" or position == "top_right" then
|
|
icon:SetPoint("RIGHT", container, "RIGHT", -((index - 1) * step), 0)
|
|
elseif position == "right" or position == "bottom_left" or position == "top_left" then
|
|
icon:SetPoint("LEFT", container, "LEFT", (index - 1) * step, 0)
|
|
else
|
|
local maxWidth = iconSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
local rowWidth = 0
|
|
if visibleCount and visibleCount > 0 then
|
|
rowWidth = iconSize * visibleCount + ICON_SPACING * (visibleCount - 1)
|
|
end
|
|
local startX = (maxWidth - rowWidth) / 2
|
|
icon:SetPoint("LEFT", container, "LEFT", startX + (index - 1) * step, 0)
|
|
end
|
|
end
|
|
|
|
local function GetAuraFrameLevel(plate)
|
|
local plateLevel = 0
|
|
if plate and plate.GetFrameLevel then
|
|
plateLevel = tonumber(plate:GetFrameLevel()) or plateLevel
|
|
end
|
|
return math.max(0, plateLevel + 1)
|
|
end
|
|
|
|
local function ApplyAuraFrameLevel(plate, container)
|
|
if not container then return end
|
|
|
|
-- Aura containers are siblings of the projected plate (not children) so they
|
|
-- do not inherit transient plate alpha. Mirror only the plate's native strata
|
|
-- and relative level; never modify the projected nameplate's own strata.
|
|
if plate and plate.GetFrameStrata and container.GetFrameStrata and container.SetFrameStrata then
|
|
local strata = plate:GetFrameStrata()
|
|
if strata and container:GetFrameStrata() ~= strata then
|
|
container:SetFrameStrata(strata)
|
|
end
|
|
end
|
|
|
|
local level = GetAuraFrameLevel(plate)
|
|
if container.GetFrameLevel and container.SetFrameLevel and
|
|
container:GetFrameLevel() ~= level then
|
|
container:SetFrameLevel(level)
|
|
end
|
|
|
|
local i
|
|
for i = 1, table.getn(container.icons or {}) do
|
|
local icon = container.icons[i]
|
|
if icon and icon.GetFrameLevel and icon.SetFrameLevel and
|
|
icon:GetFrameLevel() ~= level then
|
|
icon:SetFrameLevel(level)
|
|
end
|
|
if icon and icon.BNPCooldown and icon.BNPCooldown.SetFrameLevel then
|
|
icon.BNPCooldown:SetFrameLevel(level + 1)
|
|
end
|
|
if icon and icon.BNPTextOverlay and icon.BNPTextOverlay.SetFrameLevel then
|
|
icon.BNPTextOverlay:SetFrameLevel(level + 2)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Target auras stay opaque and non-target auras remain consistently faded.
|
|
-- This is identity-based and never changes with camera or screen position.
|
|
local TARGET_PRIORITY_AURA_ALPHA = 0.35
|
|
local TARGET_PRIORITY_PLATE_HOLD = 0.25
|
|
local targetPriorityPlate = nil
|
|
local targetPriorityGUID = nil
|
|
local targetPriorityPlateSeenAt = -100
|
|
|
|
local function RefreshTargetPriorityIdentity()
|
|
local exists, guid = UnitExists("target")
|
|
if not exists or not guid then
|
|
targetPriorityPlate = nil
|
|
targetPriorityGUID = nil
|
|
targetPriorityPlateSeenAt = -100
|
|
return
|
|
end
|
|
|
|
local now = GetTime()
|
|
|
|
-- A real target switch invalidates the previous frame immediately. For the
|
|
-- same GUID, however, keep the last exact ClassicAPI target plate through a
|
|
-- very short nil result. Dense raid stacks can make projected plate lookup
|
|
-- flicker for a frame; dropping the cached target here made its DoTs/Banish
|
|
-- alternate between opaque and the 0.35 non-target alpha.
|
|
if targetPriorityGUID ~= guid then
|
|
targetPriorityPlate = nil
|
|
targetPriorityPlateSeenAt = -100
|
|
end
|
|
targetPriorityGUID = guid
|
|
|
|
local resolved = nil
|
|
if C_NamePlate and
|
|
type(C_NamePlate.GetNamePlateForUnit) == "function" then
|
|
local ok, plate = pcall(C_NamePlate.GetNamePlateForUnit, "target")
|
|
if ok and plate then resolved = plate end
|
|
end
|
|
|
|
if resolved then
|
|
targetPriorityPlate = resolved
|
|
targetPriorityPlateSeenAt = now
|
|
elseif targetPriorityPlate and
|
|
now - targetPriorityPlateSeenAt > TARGET_PRIORITY_PLATE_HOLD then
|
|
targetPriorityPlate = nil
|
|
end
|
|
end
|
|
|
|
local function SetTargetPriorityAuraAlpha(container, alpha)
|
|
if not container then return end
|
|
alpha = alpha or 1
|
|
if container.SetAlpha and
|
|
(not container.GetAlpha or container:GetAlpha() ~= alpha) then
|
|
container:SetAlpha(alpha)
|
|
end
|
|
end
|
|
|
|
local function ApplyTargetAuraPriority(plate, container)
|
|
if not container then return end
|
|
|
|
-- Aura/CC containers are siblings of the projected nameplate and therefore
|
|
-- do not inherit its alpha. In Target Only mode explicitly mirror the plate
|
|
-- alpha so hidden non-target plates cannot leave floating DoT/CC icons behind.
|
|
-- DesiredAlpha fails open while target identity is unresolved, so this also
|
|
-- stays flicker-safe during target switches and dense nameplate recycling.
|
|
if BNP.IsTargetOnlyNameplatesEnabled and BNP:IsTargetOnlyNameplatesEnabled() then
|
|
local alpha = 1
|
|
if plate and plate.GetAlpha then
|
|
local ok, value = pcall(plate.GetAlpha, plate)
|
|
if ok and value ~= nil then alpha = value end
|
|
end
|
|
SetTargetPriorityAuraAlpha(container, alpha)
|
|
return
|
|
end
|
|
|
|
-- Flicker fix: aura opacity must not depend on transient target/nameplate
|
|
-- resolution. In dense raid stacks ClassicAPI/SuperWoW can briefly resolve
|
|
-- a projected plate differently, which previously made the whole debuff/CC
|
|
-- row alternate between alpha 1.0 and 0.35. This alpha is separate from the
|
|
-- user-facing Non-Target Alpha slider, so setting that slider to 100% did
|
|
-- not disable the flicker. Keep aura containers fully opaque and let target
|
|
-- priority be handled by nameplate layering only.
|
|
SetTargetPriorityAuraAlpha(container, 1)
|
|
end
|
|
|
|
function BNP:RefreshAuraPriorityAlpha()
|
|
RefreshTargetPriorityIdentity()
|
|
|
|
local plate
|
|
for plate in pairs(self.plates or {}) do
|
|
ApplyAuraFrameLevel(plate, plate.BNPAuraContainer)
|
|
ApplyAuraFrameLevel(plate, plate.BNPCCContainer)
|
|
ApplyTargetAuraPriority(plate, plate.BNPAuraContainer)
|
|
ApplyTargetAuraPriority(plate, plate.BNPCCContainer)
|
|
end
|
|
end
|
|
|
|
local function CleanText(text)
|
|
if not text then return nil end
|
|
text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "")
|
|
text = string.gsub(text, "|r", "")
|
|
return text
|
|
end
|
|
|
|
local function NameMatches(def, name)
|
|
if not name then return false end
|
|
local lower = string.lower(name)
|
|
for _, known in ipairs(def.names) do
|
|
if lower == string.lower(known) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function AuraMatches(def, effect, texture)
|
|
if NameMatches(def, effect) then return true end
|
|
if texture and def.textureMatch then
|
|
return string.find(string.lower(texture), def.textureMatch) and true or false
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function ScanSpellbook()
|
|
if table.getn(AURA_DEFS) == 0 then return end
|
|
|
|
local i = 1
|
|
while true do
|
|
local name = GetSpellName(i, BOOKTYPE_SPELL)
|
|
if not name then break end
|
|
|
|
local texture = GetSpellTexture(i, BOOKTYPE_SPELL)
|
|
local lowerTexture = texture and string.lower(texture) or ""
|
|
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if NameMatches(def, name) or (def.textureMatch and string.find(lowerTexture, def.textureMatch)) then
|
|
def.localizedName = name
|
|
def.texture = texture or def.texture
|
|
end
|
|
end
|
|
|
|
i = i + 1
|
|
end
|
|
if BNP.RefreshSpellbookDurations then BNP:RefreshSpellbookDurations() end
|
|
end
|
|
|
|
local function CreateAuraIcon(parent, index)
|
|
local icon = CreateFrame("Frame", nil, parent)
|
|
local size = GetIconSize()
|
|
icon:SetWidth(size)
|
|
icon:SetHeight(size)
|
|
icon:SetFrameLevel(parent.GetFrameLevel and parent:GetFrameLevel() or 0)
|
|
|
|
local texture = icon:CreateTexture(nil, "ARTWORK")
|
|
texture:SetAllPoints(icon)
|
|
icon.texture = texture
|
|
|
|
-- Keep timer/stack on their own child frame so they always render above
|
|
-- the optional cooldown shade. The shade itself remains untouched.
|
|
local textOverlay = CreateFrame("Frame", nil, icon)
|
|
textOverlay:SetAllPoints(icon)
|
|
if textOverlay.SetFrameLevel and icon.GetFrameLevel then
|
|
textOverlay:SetFrameLevel(icon:GetFrameLevel() + 2)
|
|
end
|
|
icon.BNPTextOverlay = textOverlay
|
|
|
|
local timer = textOverlay:CreateFontString(nil, "OVERLAY")
|
|
local auraFontSize = (BNP.GetAuraFontSize and BNP:GetAuraFontSize()) or (UI.TIMER_SIZE or 8)
|
|
timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", auraFontSize, "OUTLINE")
|
|
timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0)
|
|
timer:SetTextColor(1, 1, 1)
|
|
icon.timer = timer
|
|
|
|
local stack = textOverlay:CreateFontString(nil, "OVERLAY")
|
|
stack:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", auraFontSize, "OUTLINE")
|
|
stack:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1)
|
|
stack:SetTextColor(1, 1, 1)
|
|
stack:SetText("")
|
|
icon.stack = stack
|
|
|
|
icon.index = index
|
|
icon:Hide()
|
|
|
|
return icon
|
|
end
|
|
|
|
-- Optional AuraCore-style cooldown shade.
|
|
--
|
|
-- Vanilla 1.12's CooldownFrameTemplate is an animated Model. AuraCore already
|
|
-- hit the same class of problems, so BNP deliberately does NOT use that model.
|
|
-- Instead it draws a lightweight clockwise grey shade itself.
|
|
--
|
|
-- Keep every helper on BNP rather than declaring additional top-level locals:
|
|
-- modules/auras.lua is large and Vanilla/Lua has a hard local-variable limit.
|
|
-- Exceeding it prevents the entire aura module from loading, which makes every
|
|
-- aura disappear even though the actual tracking code is fine.
|
|
-- The renderer used to use only 48 visual steps and was refreshed on the
|
|
-- normal 0.05s aura tick. Keep aura tracking at that proven cadence, but give
|
|
-- the cosmetic shade much finer granularity and animate it independently.
|
|
BNP.COOLDOWN_STEPS_PER_QUARTER = 240
|
|
BNP.COOLDOWN_TOTAL_STEPS = 960
|
|
BNP.COOLDOWN_ANIMATION_INTERVAL = 0.025
|
|
BNP.activeAuraCooldownIcons = BNP.activeAuraCooldownIcons or {}
|
|
|
|
function BNP:_SetAuraShadeTexture(tex)
|
|
tex:SetTexture("Interface\\Buttons\\WHITE8X8")
|
|
tex:SetVertexColor(0.02, 0.02, 0.02, 0.82)
|
|
end
|
|
|
|
function BNP:_EnsureAuraCooldown(icon)
|
|
if not icon then return nil end
|
|
if icon.BNPCooldown then return icon.BNPCooldown end
|
|
|
|
local shade = CreateFrame("Frame", nil, icon)
|
|
shade:SetAllPoints(icon)
|
|
shade:EnableMouse(false)
|
|
if shade.SetFrameLevel and icon.GetFrameLevel then
|
|
shade:SetFrameLevel(icon:GetFrameLevel())
|
|
end
|
|
shade:Hide()
|
|
|
|
local q1 = shade:CreateTexture(nil, "ARTWORK")
|
|
local q2 = shade:CreateTexture(nil, "ARTWORK")
|
|
local q3 = shade:CreateTexture(nil, "ARTWORK")
|
|
local q4 = shade:CreateTexture(nil, "ARTWORK")
|
|
self:_SetAuraShadeTexture(q1)
|
|
self:_SetAuraShadeTexture(q2)
|
|
self:_SetAuraShadeTexture(q3)
|
|
self:_SetAuraShadeTexture(q4)
|
|
q1:Hide(); q2:Hide(); q3:Hide(); q4:Hide()
|
|
|
|
shade.BNPQuarters = { q1, q2, q3, q4 }
|
|
shade.BNPLastStep = -1
|
|
icon.BNPCooldown = shade
|
|
icon.BNPCooldownStep = -1
|
|
return shade
|
|
end
|
|
|
|
function BNP:_SetAuraQuarterFull(tex, quarter, icon, halfW, halfH)
|
|
tex:ClearAllPoints()
|
|
if quarter == 1 then
|
|
tex:SetPoint("TOPLEFT", icon, "TOP", 0, 0)
|
|
elseif quarter == 2 then
|
|
tex:SetPoint("TOPRIGHT", icon, "RIGHT", 0, 0)
|
|
elseif quarter == 3 then
|
|
tex:SetPoint("BOTTOMRIGHT", icon, "BOTTOM", 0, 0)
|
|
else
|
|
tex:SetPoint("BOTTOMLEFT", icon, "LEFT", 0, 0)
|
|
end
|
|
tex:SetWidth(halfW)
|
|
tex:SetHeight(halfH)
|
|
tex:Show()
|
|
end
|
|
|
|
function BNP:_SetAuraQuarterPartial(tex, quarter, icon, fraction, halfW, halfH)
|
|
if fraction <= 0 then tex:Hide(); return end
|
|
if fraction >= 1 then
|
|
self:_SetAuraQuarterFull(tex, quarter, icon, halfW, halfH)
|
|
return
|
|
end
|
|
|
|
tex:ClearAllPoints()
|
|
if quarter == 1 then
|
|
-- 12 -> 3: grow from top-centre towards the right.
|
|
tex:SetPoint("TOPLEFT", icon, "TOP", 0, 0)
|
|
tex:SetWidth(halfW * fraction)
|
|
tex:SetHeight(halfH)
|
|
elseif quarter == 2 then
|
|
-- 3 -> 6: grow from right-centre downwards.
|
|
tex:SetPoint("TOPRIGHT", icon, "RIGHT", 0, 0)
|
|
tex:SetWidth(halfW)
|
|
tex:SetHeight(halfH * fraction)
|
|
elseif quarter == 3 then
|
|
-- 6 -> 9: grow from bottom-centre towards the left.
|
|
tex:SetPoint("BOTTOMRIGHT", icon, "BOTTOM", 0, 0)
|
|
tex:SetWidth(halfW * fraction)
|
|
tex:SetHeight(halfH)
|
|
else
|
|
-- 9 -> 12: grow from left-centre upwards.
|
|
tex:SetPoint("BOTTOMLEFT", icon, "LEFT", 0, 0)
|
|
tex:SetWidth(halfW)
|
|
tex:SetHeight(halfH * fraction)
|
|
end
|
|
tex:Show()
|
|
end
|
|
|
|
function BNP:_UpdateAuraCustomShade(icon, shade, step)
|
|
if not icon or not shade or not shade.BNPQuarters then return end
|
|
if shade.BNPLastStep == step then return end
|
|
shade.BNPLastStep = step
|
|
icon.BNPCooldownStep = step
|
|
|
|
local width = (icon.GetWidth and icon:GetWidth()) or GetIconSize()
|
|
local height = (icon.GetHeight and icon:GetHeight()) or GetIconSize()
|
|
local halfW = width * 0.5
|
|
local halfH = height * 0.5
|
|
local q
|
|
for q = 1, 4 do
|
|
local tex = shade.BNPQuarters[q]
|
|
local localStep = step - ((q - 1) * self.COOLDOWN_STEPS_PER_QUARTER)
|
|
if localStep <= 0 then
|
|
tex:Hide()
|
|
elseif localStep >= self.COOLDOWN_STEPS_PER_QUARTER then
|
|
self:_SetAuraQuarterFull(tex, q, icon, halfW, halfH)
|
|
else
|
|
self:_SetAuraQuarterPartial(tex, q, icon, localStep / self.COOLDOWN_STEPS_PER_QUARTER, halfW, halfH)
|
|
end
|
|
end
|
|
shade:Show()
|
|
end
|
|
|
|
function BNP:_HideAuraCooldown(icon)
|
|
if not icon or not icon.BNPCooldown then return end
|
|
local shade = icon.BNPCooldown
|
|
if shade.BNPQuarters then
|
|
local i
|
|
for i = 1, 4 do shade.BNPQuarters[i]:Hide() end
|
|
end
|
|
shade.BNPLastStep = -1
|
|
shade:Hide()
|
|
icon.BNPCooldownStep = -1
|
|
icon.BNPCooldownStart = nil
|
|
icon.BNPCooldownDuration = nil
|
|
icon.BNPCooldownExpires = nil
|
|
if self.activeAuraCooldownIcons then
|
|
self.activeAuraCooldownIcons[icon] = nil
|
|
end
|
|
end
|
|
|
|
function BNP:_UpdateAuraCooldown(icon, aura, def, now)
|
|
if not icon then return end
|
|
if not self:IsAuraCooldownSpiralEnabled() then
|
|
self:_HideAuraCooldown(icon)
|
|
return
|
|
end
|
|
|
|
local expires = aura and tonumber(aura.expires) or nil
|
|
local duration = aura and tonumber(aura.duration) or nil
|
|
if (not duration or duration <= 0) and def then duration = tonumber(def.duration) end
|
|
now = now or GetTime()
|
|
if not expires or expires <= now or not duration or duration <= 0 then
|
|
self:_HideAuraCooldown(icon)
|
|
return
|
|
end
|
|
|
|
local remaining = expires - now
|
|
local effectiveDuration = duration
|
|
if remaining > effectiveDuration then effectiveDuration = remaining end
|
|
local startTime = expires - effectiveDuration
|
|
local progress = (now - startTime) / effectiveDuration
|
|
if progress < 0 then progress = 0 end
|
|
if progress > 1 then progress = 1 end
|
|
|
|
local step = math.floor((progress * self.COOLDOWN_TOTAL_STEPS) + 0.5)
|
|
if step < 0 then step = 0 end
|
|
if step > self.COOLDOWN_TOTAL_STEPS then step = self.COOLDOWN_TOTAL_STEPS end
|
|
|
|
local shade = self:_EnsureAuraCooldown(icon)
|
|
if not shade then return end
|
|
self:_UpdateAuraCustomShade(icon, shade, step)
|
|
icon.BNPCooldownStart = startTime
|
|
icon.BNPCooldownDuration = effectiveDuration
|
|
icon.BNPCooldownExpires = expires
|
|
self.activeAuraCooldownIcons = self.activeAuraCooldownIcons or {}
|
|
self.activeAuraCooldownIcons[icon] = true
|
|
end
|
|
|
|
-- The spiral is cosmetic. Any unexpected old-client UI error is isolated here
|
|
-- and can never abort the actual debuff/CC renderer.
|
|
function BNP:_SafeUpdateAuraCooldown(icon, aura, def, now)
|
|
local ok = pcall(self._UpdateAuraCooldown, self, icon, aura, def, now)
|
|
if not ok and icon and icon.BNPCooldown then icon.BNPCooldown:Hide() end
|
|
end
|
|
|
|
function BNP:RefreshAuraCooldownSpirals()
|
|
local enabled = self:IsAuraCooldownSpiralEnabled()
|
|
local plate, _, container, i
|
|
for plate in pairs(self.plates or {}) do
|
|
for _, container in ipairs({ plate.BNPAuraContainer, plate.BNPCCContainer }) do
|
|
if container then
|
|
for i = 1, table.getn(container.icons or {}) do
|
|
local icon = container.icons[i]
|
|
if not enabled then
|
|
self:_HideAuraCooldown(icon)
|
|
elseif icon and icon:IsShown() then
|
|
-- The normal renderer fills start/duration on the next 0.05s tick.
|
|
icon.BNPCooldownStart = nil
|
|
icon.BNPCooldownDuration = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Smooth visual-only animator. This does not scan units, auras or ClassicAPI.
|
|
-- It touches only icons that already have a live cooldown shade and uses the
|
|
-- start/duration cached by the normal renderer. This keeps the expensive aura
|
|
-- logic at 20 Hz while the cosmetic sweep can move at up to ~40 Hz.
|
|
BNP.AuraCooldownAnimator = BNP.AuraCooldownAnimator or CreateFrame("Frame")
|
|
BNP.AuraCooldownAnimator.BNPElapsed = 0
|
|
BNP.AuraCooldownAnimator:SetScript("OnUpdate", function()
|
|
if not BNP_DB or BNP_DB.cooldownSpiral ~= true then return end
|
|
|
|
this.BNPElapsed = (this.BNPElapsed or 0) + arg1
|
|
if this.BNPElapsed < (BNP.COOLDOWN_ANIMATION_INTERVAL or 0.025) then return end
|
|
this.BNPElapsed = 0
|
|
|
|
local active = BNP.activeAuraCooldownIcons
|
|
if not active then return end
|
|
|
|
local now = GetTime()
|
|
local icon
|
|
for icon in pairs(active) do
|
|
if not icon or not icon.BNPCooldown or not icon:IsShown() then
|
|
active[icon] = nil
|
|
else
|
|
local startTime = tonumber(icon.BNPCooldownStart)
|
|
local duration = tonumber(icon.BNPCooldownDuration)
|
|
local expires = tonumber(icon.BNPCooldownExpires)
|
|
if not startTime or not duration or duration <= 0 or not expires or expires <= now then
|
|
BNP:_HideAuraCooldown(icon)
|
|
else
|
|
local progress = (now - startTime) / duration
|
|
if progress < 0 then progress = 0 end
|
|
if progress > 1 then progress = 1 end
|
|
local step = math.floor((progress * BNP.COOLDOWN_TOTAL_STEPS) + 0.5)
|
|
if step < 0 then step = 0 end
|
|
if step > BNP.COOLDOWN_TOTAL_STEPS then step = BNP.COOLDOWN_TOTAL_STEPS end
|
|
BNP:_UpdateAuraCustomShade(icon, icon.BNPCooldown, step)
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
local function HidePlateAuraFrames(plate)
|
|
if not plate then return end
|
|
|
|
local containers = { plate.BNPAuraContainer, plate.BNPCCContainer }
|
|
local _, container, i
|
|
for _, container in ipairs(containers) do
|
|
if container then
|
|
for i = 1, table.getn(container.icons or {}) do
|
|
BNP:_HideAuraCooldown(container.icons[i])
|
|
container.icons[i]:Hide()
|
|
end
|
|
container:Hide()
|
|
end
|
|
end
|
|
end
|
|
|
|
local function InstallAuraHideGuard(plate)
|
|
if not plate or plate.BNPAuraHideGuard then return end
|
|
|
|
local oldOnHide = plate:GetScript("OnHide")
|
|
plate:SetScript("OnHide", function()
|
|
if oldOnHide then oldOnHide() end
|
|
local current = this or plate
|
|
HidePlateAuraFrames(current)
|
|
-- If Blizzard reuses this frame for another unit, do not hold the old GUID
|
|
-- through the next OnShow. The next visible identity must be reconfirmed.
|
|
current.BNPAuraForceFreshIdentity = true
|
|
current.BNPAuraGUIDCandidate = nil
|
|
current.BNPAuraGUIDCandidateCount = 0
|
|
end)
|
|
plate.BNPAuraHideGuard = true
|
|
end
|
|
|
|
local function GetAuraVisualParent(plate)
|
|
-- Aura rows must not inherit the projected nameplate's alpha. In large raids
|
|
-- the Vanilla client can transiently adjust/recycle a non-target plate's alpha
|
|
-- while resolving dense stacks. A child frame inherits that alpha even when
|
|
-- its own alpha is forced to 1, which made debuffs pulse grey/colored.
|
|
-- Use the nameplate's native parent (normally WorldFrame) as a sibling parent
|
|
-- and keep anchoring to the real healthbar so position still follows the plate.
|
|
if plate and plate.GetParent then
|
|
local parent = plate:GetParent()
|
|
if parent then return parent end
|
|
end
|
|
return WorldFrame or UIParent
|
|
end
|
|
|
|
local function CreateAuraContainer(plate)
|
|
if not plate then return end
|
|
BNP:RegisterPlate(plate)
|
|
if plate.BNPAuraContainer then return end
|
|
|
|
local size = GetIconSize()
|
|
local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
local container = CreateFrame("Frame", nil, GetAuraVisualParent(plate))
|
|
container:SetWidth(width)
|
|
container:SetHeight(size)
|
|
container:SetFrameLevel(GetAuraFrameLevel(plate))
|
|
|
|
AnchorAuraContainer(plate, container, false)
|
|
|
|
container.icons = {}
|
|
for i = 1, MAX_VISIBLE_ICONS do
|
|
local icon = CreateAuraIcon(container, i)
|
|
PositionAuraIcon(icon, container, i, 0, size)
|
|
container.icons[i] = icon
|
|
end
|
|
|
|
container:Hide()
|
|
plate.BNPAuraContainer = container
|
|
ApplyAuraFrameLevel(plate, container)
|
|
InstallAuraHideGuard(plate)
|
|
end
|
|
|
|
local function CreateCCContainer(plate)
|
|
if not plate then return end
|
|
BNP:RegisterPlate(plate)
|
|
if plate.BNPCCContainer then return end
|
|
|
|
local size = GetCCIconSize()
|
|
local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
local container = CreateFrame("Frame", nil, GetAuraVisualParent(plate))
|
|
container:SetWidth(width)
|
|
container:SetHeight(size)
|
|
container:SetFrameLevel(GetAuraFrameLevel(plate))
|
|
|
|
AnchorAuraContainer(plate, container, true)
|
|
|
|
container.icons = {}
|
|
for i = 1, MAX_VISIBLE_ICONS do
|
|
local icon = CreateAuraIcon(container, i)
|
|
PositionAuraIcon(icon, container, i, 0, size, true)
|
|
container.icons[i] = icon
|
|
end
|
|
|
|
container:Hide()
|
|
plate.BNPCCContainer = container
|
|
ApplyAuraFrameLevel(plate, container)
|
|
end
|
|
|
|
-- ClassicAPI 1.15.x aura/nameplate bridge -----------------------------------
|
|
-- Keep these helpers on BNP instead of as file-level locals: auras.lua is
|
|
-- already close to Lua's local-variable ceiling, and adding a handful of
|
|
-- long-lived locals would make the whole file fail to compile on 1.12.
|
|
BNP._ClassicAuraUnitDebuff = C_UnitAuras and C_UnitAuras.UnitDebuff
|
|
BNP._ClassicGetAuraSlots = C_UnitAuras and C_UnitAuras.GetAuraSlots
|
|
BNP._ClassicUnitAuraBySlot = C_UnitAuras and C_UnitAuras.UnitAuraBySlot
|
|
BNP._ClassicGetNamePlateForUnit = C_NamePlate and C_NamePlate.GetNamePlateForUnit
|
|
BNP._ClassicUnitGUID = UnitGUID
|
|
BNP._ClassicAuraSlotScratch = BNP._ClassicAuraSlotScratch or {}
|
|
|
|
function BNP:_ReadClassicDebuff(unit, index, filter)
|
|
local fn = self._ClassicAuraUnitDebuff
|
|
if fn then
|
|
-- Allocation-free ClassicAPI positional form:
|
|
-- name, icon, count, dispelType, duration, expirationTime, source,
|
|
-- isStealable, nameplateShowPersonal, spellId, ...
|
|
local name, icon, count, dispelType, duration, expirationTime, source,
|
|
isStealable, nameplateShowPersonal, spellID = fn(unit, index, filter)
|
|
if not name then return nil end
|
|
|
|
-- Return the legacy BNP shape used throughout this module. Duration and
|
|
-- expiration occupy slots 5/6 so existing duration probing can consume
|
|
-- them without AuraData table allocations or another aura scan.
|
|
return icon, count, dispelType, spellID, duration, expirationTime,
|
|
source, isStealable, nameplateShowPersonal, name
|
|
end
|
|
|
|
return UnitDebuff(unit, index)
|
|
end
|
|
|
|
function BNP:_IsOwnClassicAuraSource(source)
|
|
if source == "player" or source == "pet" then return true end
|
|
if not source or source == "" or not UnitExists then return false end
|
|
-- Some source paths supply a GUID/token alias rather than "player"/"pet".
|
|
local exists, guid = UnitExists("player")
|
|
if exists and guid and source == guid then return true end
|
|
exists, guid = UnitExists("pet")
|
|
if exists and guid and source == guid then return true end
|
|
if UnitIsUnit and UnitExists(source) then
|
|
return UnitIsUnit(source, "player") or UnitIsUnit(source, "pet") or false
|
|
end
|
|
return false
|
|
end
|
|
|
|
function BNP:_IsKnownForeignClassicAuraSource(source)
|
|
return source ~= nil and source ~= "" and not self:_IsOwnClassicAuraSource(source)
|
|
end
|
|
|
|
function BNP:_BindClassicPlateToken(token)
|
|
local getPlate = self._ClassicGetNamePlateForUnit
|
|
if not token or not getPlate then return end
|
|
local plate = getPlate(token)
|
|
if not plate then return end
|
|
|
|
plate.BNPClassicUnitToken = token
|
|
if self._ClassicUnitGUID then
|
|
local exactGUID = self._ClassicUnitGUID(token)
|
|
plate.BNPClassicGUID = exactGUID
|
|
|
|
-- ClassicAPI's NAME_PLATE_UNIT_ADDED mapping is authoritative. The legacy
|
|
-- same-name stabilization below intentionally delays GUID switches to hide
|
|
-- transient SuperWoW token swaps in dense raids, but doing that for an
|
|
-- exact nameplateN mapping can briefly render the previous same-name mob's
|
|
-- auras on a recycled frame. Pin the exact GUID immediately instead.
|
|
if exactGUID then
|
|
plate.BNPAuraGUIDStable = exactGUID
|
|
plate.BNPAuraGUIDCandidate = exactGUID
|
|
plate.BNPAuraGUIDCandidateCount = 8
|
|
plate.BNPAuraGUIDLastGoodAt = GetTime()
|
|
plate.BNPAuraForceFreshIdentity = nil
|
|
end
|
|
end
|
|
|
|
-- The event-driven aura watcher is defined later in this file, after all
|
|
-- aura reconciliation helpers exist. Once available, bind this exact plate
|
|
-- to UNIT_AURA for only its nameplateN token.
|
|
if self._EnsureClassicAuraWatcher then
|
|
self:_EnsureClassicAuraWatcher(plate, token)
|
|
end
|
|
end
|
|
|
|
function BNP:_RefreshClassicPlateTokens()
|
|
if not self._ClassicGetNamePlateForUnit then return end
|
|
local i
|
|
for i = 1, 40 do
|
|
local token = "nameplate" .. i
|
|
if UnitExists(token) then self:_BindClassicPlateToken(token) end
|
|
end
|
|
end
|
|
|
|
if BNP._ClassicGetNamePlateForUnit then
|
|
local classicPlateEvents = CreateFrame("Frame")
|
|
classicPlateEvents:RegisterEvent("NAME_PLATE_UNIT_ADDED")
|
|
classicPlateEvents:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
|
|
classicPlateEvents:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
classicPlateEvents:SetScript("OnEvent", function()
|
|
if event == "NAME_PLATE_UNIT_ADDED" then
|
|
BNP:_BindClassicPlateToken(arg1)
|
|
elseif event == "NAME_PLATE_UNIT_REMOVED" then
|
|
-- ClassicAPI guarantees that arg1 still resolves to the leaving unit
|
|
-- during this event, so clear only the frame that owns this token.
|
|
local plate = BNP._ClassicGetNamePlateForUnit(arg1)
|
|
if plate and plate.BNPClassicUnitToken == arg1 then
|
|
if BNP._ReleaseClassicAuraWatcher then
|
|
BNP:_ReleaseClassicAuraWatcher(plate, arg1)
|
|
end
|
|
plate.BNPClassicUnitToken = nil
|
|
plate.BNPClassicGUID = nil
|
|
|
|
-- v1.15.6 guarantees that this is the exact leaving frame. Do not keep
|
|
-- the old stabilized GUID around for a later recycled same-name plate;
|
|
-- that legacy hold is only needed when ClassicAPI identity is absent.
|
|
plate.BNPAuraGUIDStable = nil
|
|
plate.BNPAuraGUIDCandidate = nil
|
|
plate.BNPAuraGUIDCandidateCount = 0
|
|
plate.BNPAuraGUIDLastGoodAt = nil
|
|
plate.BNPAuraForceFreshIdentity = true
|
|
end
|
|
elseif event == "PLAYER_ENTERING_WORLD" then
|
|
BNP:_RefreshClassicPlateTokens()
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function GetPlateGUID(plate)
|
|
if not plate then return nil end
|
|
|
|
-- Preferred path: the event-bound ClassicAPI token resolves directly to the
|
|
-- exact visible unit and therefore to its stable GUID.
|
|
local classicToken = plate.BNPClassicUnitToken
|
|
if classicToken and BNP._ClassicUnitGUID then
|
|
local guid = BNP._ClassicUnitGUID(classicToken)
|
|
if guid then
|
|
plate.BNPClassicGUID = guid
|
|
return guid
|
|
end
|
|
end
|
|
|
|
-- Fallback: SuperWoW stores the unit GUID/token directly on the nameplate
|
|
-- name slot. Keep this path for compatibility and during the one-frame gap
|
|
-- before ClassicAPI's synthesized NAME_PLATE_UNIT_ADDED event arrives.
|
|
if plate.GetName then
|
|
local token = plate:GetName(1)
|
|
if token and token ~= "" then return token end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function GetTargetGUID()
|
|
local exists, guid = UnitExists("target")
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
local function ClearGUIDAuraState(guid)
|
|
if not guid then return end
|
|
BNP.guidAuras[guid] = nil
|
|
if BNP.svStateByGUID then BNP.svStateByGUID[guid] = nil end
|
|
if BNP.svExpiryByGUID then BNP.svExpiryByGUID[guid] = nil end
|
|
if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end
|
|
if BNP.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end
|
|
if BNP.guidOtherDebuffs then BNP.guidOtherDebuffs[guid] = nil end
|
|
if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end
|
|
if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end
|
|
if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end
|
|
if BNP.guidNames then BNP.guidNames[guid] = nil end
|
|
end
|
|
|
|
-- Full-immunity cleanse bridge ----------------------------------------------
|
|
-- Divine Shield and Ice Block remove harmful effects. BNP tracks some of
|
|
-- those effects from cast events/timers, so a server cleanse must also wipe
|
|
-- every local harmful-aura cache for that exact GUID. While the immunity is
|
|
-- active, keep a short renewable suppression window so stale UnitDebuff /
|
|
-- pending-cast data cannot repopulate icons for one frame.
|
|
BNP.guidHarmfulAuraSuppressionUntil = BNP.guidHarmfulAuraSuppressionUntil or {}
|
|
|
|
local function ClearHarmfulAuraStateOnly(guid)
|
|
if not guid then return end
|
|
BNP.guidAuras[guid] = nil
|
|
if BNP.svStateByGUID then BNP.svStateByGUID[guid] = nil end
|
|
if BNP.svExpiryByGUID then BNP.svExpiryByGUID[guid] = nil end
|
|
if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end
|
|
if BNP.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end
|
|
if BNP.guidOtherDebuffs then BNP.guidOtherDebuffs[guid] = nil end
|
|
if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end
|
|
if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end
|
|
if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end
|
|
|
|
-- A failed/immune cast rollback must never restore pre-cleanse timers after
|
|
-- Bubble/Ice Block. Drop all outstanding attempts tied to this GUID.
|
|
if BNP.auraCastAttempts then
|
|
local i
|
|
for i = table.getn(BNP.auraCastAttempts), 1, -1 do
|
|
local attempt = BNP.auraCastAttempts[i]
|
|
if attempt and attempt.guid == guid then
|
|
table.remove(BNP.auraCastAttempts, i)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function HarmfulAuraSuppressed(guid, now)
|
|
if not guid then return false end
|
|
local untilTime = BNP.guidHarmfulAuraSuppressionUntil[guid]
|
|
if not untilTime then return false end
|
|
now = now or GetTime()
|
|
if now < untilTime then return true end
|
|
BNP.guidHarmfulAuraSuppressionUntil[guid] = nil
|
|
return false
|
|
end
|
|
|
|
function BNP:SuppressHarmfulAurasForImmunity(guid, seconds)
|
|
if not guid then return end
|
|
local now = GetTime()
|
|
local untilTime = now + math.max(tonumber(seconds) or 0.35, 0.10)
|
|
local current = self.guidHarmfulAuraSuppressionUntil[guid] or 0
|
|
if untilTime > current then
|
|
self.guidHarmfulAuraSuppressionUntil[guid] = untilTime
|
|
end
|
|
ClearHarmfulAuraStateOnly(guid)
|
|
end
|
|
|
|
function BNP:IsHarmfulAuraSuppressed(guid, now)
|
|
return HarmfulAuraSuppressed(guid, now)
|
|
end
|
|
|
|
local function CleanupDeadUnit(unit, guid, now)
|
|
if not unit or not guid or not UnitIsDeadOrGhost then return false end
|
|
if AuraCacheProtected and AuraCacheProtected(guid, now) then return false end
|
|
if UnitIsDeadOrGhost(unit) then
|
|
ClearGUIDAuraState(guid)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
|
|
-- Fallback for players/NPCs whose nameplate disappears before the periodic
|
|
-- dead-state check sees them. Vanilla combat chat reports "<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 / aura-confirmed utility effects
|
|
fear=true, howl_of_terror=true, banish=true, death_coil=true, shadowburn=true,
|
|
|
|
-- Hunter traps / CC / talent procs
|
|
immolation_trap_effect=true, explosive_trap_effect=true, freezing_trap_effect=true,
|
|
serpent_sting=true, viper_sting=true, scorpid_sting=true,
|
|
improved_scorpid_sting=true, improved_wing_clip=true, improved_concussive_shot=true,
|
|
scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true,
|
|
intimidation=true, scare_beast=true,
|
|
|
|
-- Mage CC / proc effects
|
|
polymorph=true, frost_nova=true, counterspell=true, counterspell_silence=true,
|
|
ignite=true, impact=true, frostbite=true, fire_vulnerability=true, winters_chill=true,
|
|
|
|
-- Priest CC / proc effects
|
|
psychic_scream=true, silence=true, blackout=true, touch_of_weakness=true,
|
|
shackle_undead=true, mind_control=true,
|
|
|
|
-- Paladin CC
|
|
hammer_of_justice=true, repentance=true, turn_undead=true,
|
|
|
|
-- Rogue CC / poison procs
|
|
gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true,
|
|
kick_silenced=true, crippling_poison=true, mind_numbing_poison=true,
|
|
wound_poison=true, deadly_poison=true,
|
|
|
|
-- Druid CC / secondary effects. Rake is also strict because UNIT_CASTEVENT
|
|
-- CAST fires before the melee hit result; requiring the real aura prevents
|
|
-- missed/dodged/parried/immune Rakes from creating a false bleed timer.
|
|
entangling_roots=true, bash=true, pounce=true, pounce_bleed=true, rake=true,
|
|
hibernate=true, feral_charge_effect=true,
|
|
|
|
-- Warrior CC / talent procs
|
|
improved_hamstring=true, intimidating_shout=true, disarm=true,
|
|
charge_stun=true, intercept_stun=true, concussion_blow=true,
|
|
revenge_stun=true, pummel=true, shield_bash=true, deep_wound=true,
|
|
|
|
-- Shaman totem / proc effects
|
|
earthbind=true, earthgrab=true, frostbrand_attack=true,
|
|
}
|
|
|
|
local function GetTrackingMode(def)
|
|
if not def or not def.key then return "AURA" end
|
|
if def.category == "CURSE" then return "AURA" end
|
|
if def.aoe then return "AURA" end
|
|
local cat = string.upper(tostring(def.category or ""))
|
|
if cat == "CC" or cat == "ROOT" or cat == "STUN"
|
|
or cat == "TRAP" or cat == "SILENCE" or cat == "PROC" then
|
|
return "AURA"
|
|
end
|
|
if STRICT_AURA_KEYS[def.key] then return "AURA" end
|
|
return "CAST"
|
|
end
|
|
|
|
local function IsCastPrimary(def)
|
|
return GetTrackingMode(def) == "CAST"
|
|
end
|
|
|
|
local AURA_ATTEMPT_WINDOW = 5.0
|
|
BNP.auraCastAttempts = BNP.auraCastAttempts or {}
|
|
|
|
local function CopyAuraState(aura)
|
|
if type(aura) ~= "table" then return nil end
|
|
local copy = {}
|
|
local k, v
|
|
for k, v in pairs(aura) do copy[k] = v end
|
|
return copy
|
|
end
|
|
|
|
local function BuildAffectedKeys(def)
|
|
local keys = {}
|
|
if not def or not def.key then return keys end
|
|
|
|
keys[def.key] = true
|
|
if def.secondaryAuraKey then keys[def.secondaryAuraKey] = true end
|
|
|
|
-- Any Warlock curse cast can change the target's complete curse state.
|
|
-- Snapshot every tracked curse so a later resist/fail can restore exactly
|
|
-- what was active before the attempt. This also supports Turtle mechanics
|
|
-- where Curse of Agony may coexist with one regular curse.
|
|
if def.category == "CURSE" then
|
|
local _, oldDef
|
|
for _, oldDef in ipairs(AURA_DEFS) do
|
|
if oldDef.category == "CURSE" then
|
|
keys[oldDef.key] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
return keys
|
|
end
|
|
|
|
local function StartAuraCastAttempt(guid, def, spellID, affectedKeys)
|
|
if not guid or not spellID then return nil end
|
|
|
|
local keys = affectedKeys or BuildAffectedKeys(def)
|
|
local cache = BNP.guidAuras[guid]
|
|
local before = {}
|
|
local key
|
|
|
|
for key in pairs(keys) do
|
|
local aura = cache and cache[key]
|
|
before[key] = {
|
|
exists = type(aura) == "table" and true or false,
|
|
aura = CopyAuraState(aura),
|
|
}
|
|
end
|
|
|
|
local spellName = nil
|
|
if SpellInfo then spellName = SpellInfo(spellID) end
|
|
if not spellName and def and def.localizedName then spellName = def.localizedName end
|
|
if not spellName and def and def.names then spellName = def.names[1] end
|
|
|
|
local attempt = {
|
|
guid = guid,
|
|
key = def and def.key or "__special",
|
|
spellID = spellID,
|
|
spellName = spellName,
|
|
time = GetTime(),
|
|
before = before,
|
|
done = false,
|
|
}
|
|
|
|
table.insert(BNP.auraCastAttempts, attempt)
|
|
while table.getn(BNP.auraCastAttempts) > 32 do
|
|
table.remove(BNP.auraCastAttempts, 1)
|
|
end
|
|
|
|
return attempt
|
|
end
|
|
|
|
local function RollbackAuraCastAttempt(attempt, reason)
|
|
if not attempt or attempt.done then return false end
|
|
|
|
local guid = attempt.guid
|
|
local cache = BNP.guidAuras[guid]
|
|
if not cache then
|
|
cache = {}
|
|
BNP.guidAuras[guid] = cache
|
|
end
|
|
|
|
local key, state
|
|
for key, state in pairs(attempt.before or {}) do
|
|
if state.exists and state.aura then
|
|
cache[key] = CopyAuraState(state.aura)
|
|
else
|
|
cache[key] = nil
|
|
end
|
|
end
|
|
|
|
-- A failed refresh must not leave a pending confirmation behind. Otherwise
|
|
-- the still-active old aura could be mistaken for the newly failed cast on
|
|
-- the next UnitDebuff scan and incorrectly restart its timer.
|
|
local pending = BNP.pendingAuras and BNP.pendingAuras[guid]
|
|
if pending then
|
|
for key in pairs(attempt.before or {}) do pending[key] = nil end
|
|
end
|
|
|
|
attempt.done = true
|
|
RaidTrace("ROLLBACK", guid, attempt.key, attempt.spellID)
|
|
return true
|
|
end
|
|
|
|
local function RollbackAuraAttemptBySpell(guid, spellID, reason)
|
|
if not spellID then return false end
|
|
local now = GetTime()
|
|
local i
|
|
|
|
for i = table.getn(BNP.auraCastAttempts), 1, -1 do
|
|
local attempt = BNP.auraCastAttempts[i]
|
|
if attempt and not attempt.done
|
|
and attempt.spellID == spellID
|
|
and (not guid or attempt.guid == guid)
|
|
and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW then
|
|
return RollbackAuraCastAttempt(attempt, reason)
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
local function CleanupAuraCastAttempts(now)
|
|
local i
|
|
for i = table.getn(BNP.auraCastAttempts), 1, -1 do
|
|
local attempt = BNP.auraCastAttempts[i]
|
|
if not attempt or attempt.done or now - (attempt.time or 0) > AURA_ATTEMPT_WINDOW then
|
|
table.remove(BNP.auraCastAttempts, i)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- A positive UnitDebuff sighting proves a first application landed. Disarm
|
|
-- that cast attempt immediately so a later, unrelated failed button press for
|
|
-- the same spell cannot restore the pre-cast (empty) state. Refresh attempts
|
|
-- intentionally stay armed: an already-present aura cannot prove that the
|
|
-- refresh succeeded, and a delayed miss/resist must still restore its timer.
|
|
-- Keep this on BNP instead of adding another chunk-local function; the Vanilla
|
|
-- client has a strict local-variable limit for large Lua files.
|
|
function BNP:ResolveNewAuraCastAttempt(guid, key)
|
|
if not guid or not key then return false end
|
|
|
|
local now = GetTime()
|
|
local i
|
|
for i = table.getn(BNP.auraCastAttempts), 1, -1 do
|
|
local attempt = BNP.auraCastAttempts[i]
|
|
local prior = attempt and attempt.before and attempt.before[key]
|
|
if attempt and not attempt.done
|
|
and attempt.guid == guid
|
|
and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW
|
|
and prior and not prior.exists then
|
|
attempt.done = true
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- ShaguPlates does more than just seed debuffs from UNIT_CASTEVENT CAST:
|
|
-- libdebuff also watches the local player's failure combat messages and
|
|
-- reverts the last application on miss/resist/immune/evade/etc. BNP mirrors
|
|
-- that safety, but keeps the exact SuperWoW GUID and can restore the previous
|
|
-- timer when a refresh fails.
|
|
local failurePatterns = {}
|
|
|
|
local function EscapeLuaPattern(text)
|
|
return string.gsub(text, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
|
|
end
|
|
|
|
local function CombatFormatToPattern(formatText)
|
|
if not formatText or formatText == "" then return nil end
|
|
|
|
local text = formatText
|
|
text = string.gsub(text, "%%%%", "__BNP_PERCENT__")
|
|
text = string.gsub(text, "%%%d+%$s", "__BNP_STRING__")
|
|
text = string.gsub(text, "%%s", "__BNP_STRING__")
|
|
text = string.gsub(text, "%%%d+%$d", "__BNP_NUMBER__")
|
|
text = string.gsub(text, "%%d", "__BNP_NUMBER__")
|
|
text = EscapeLuaPattern(text)
|
|
text = string.gsub(text, "__BNP_STRING__", ".+")
|
|
text = string.gsub(text, "__BNP_NUMBER__", "%%d+")
|
|
text = string.gsub(text, "__BNP_PERCENT__", "%%%%")
|
|
return "^" .. text .. "$"
|
|
end
|
|
|
|
local function AddFailurePattern(formatText)
|
|
local pattern = CombatFormatToPattern(formatText)
|
|
if pattern then table.insert(failurePatterns, pattern) end
|
|
end
|
|
|
|
AddFailurePattern(SPELLIMMUNESELFOTHER)
|
|
-- Some physical/ability immunities use the alternate Vanilla combat string
|
|
-- "%s is immune to your %s." (for example bleed-immune mobs hit with Rake).
|
|
-- Treat it exactly like SPELLIMMUNESELFOTHER so provisional CAST-primary
|
|
-- timers are rolled back globally instead of needing per-spell exceptions.
|
|
AddFailurePattern(IMMUNESPELLSELFOTHER)
|
|
AddFailurePattern(IMMUNEDAMAGECLASSSELFOTHER)
|
|
AddFailurePattern(SPELLMISSSELFOTHER)
|
|
AddFailurePattern(SPELLRESISTSELFOTHER)
|
|
AddFailurePattern(SPELLEVADEDSELFOTHER)
|
|
AddFailurePattern(SPELLDODGEDSELFOTHER)
|
|
AddFailurePattern(SPELLDEFLECTEDSELFOTHER)
|
|
AddFailurePattern(SPELLREFLECTSELFOTHER)
|
|
AddFailurePattern(SPELLPARRIEDSELFOTHER)
|
|
|
|
-- Do not match SPELLFAILCASTSELF here. That message describes a newly rejected
|
|
-- input (for example pressing Corruption again during the global cooldown),
|
|
-- not the result of the earlier UNIT_CASTEVENT CAST. Matching it by spell name
|
|
-- could therefore roll back a DoT that already landed. Exact SuperWoW FAIL and
|
|
-- the impact-result patterns above still handle real miss/resist/immune cases.
|
|
|
|
local function IsLocalFailureMessage(raw)
|
|
if not raw or raw == "" then return false end
|
|
local i
|
|
for i = 1, table.getn(failurePatterns) do
|
|
if string.find(raw, failurePatterns[i]) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function RollbackAuraAttemptFromCombatMessage(raw)
|
|
if not IsLocalFailureMessage(raw) then return false end
|
|
|
|
local now = GetTime()
|
|
local i
|
|
for i = table.getn(BNP.auraCastAttempts), 1, -1 do
|
|
local attempt = BNP.auraCastAttempts[i]
|
|
if attempt and not attempt.done
|
|
and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW
|
|
and attempt.spellName
|
|
and string.find(raw, attempt.spellName, 1, true) then
|
|
return RollbackAuraCastAttempt(attempt, "COMBAT_FAIL")
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
local auraFailureFrame = CreateFrame("Frame")
|
|
auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER")
|
|
auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
auraFailureFrame:SetScript("OnEvent", function()
|
|
RollbackAuraAttemptFromCombatMessage(arg1)
|
|
end)
|
|
|
|
local function HasPendingAuras()
|
|
local guid, entries, key, pending
|
|
for guid, entries in pairs(BNP.pendingAuras) do
|
|
for key, pending in pairs(entries) do
|
|
if type(pending) == "table" then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function GetUnitTokenForPlate(plate)
|
|
if not plate then return nil end
|
|
|
|
-- Use the real ClassicAPI nameplateN token whenever possible. This is the
|
|
-- token family C_UnitAuras is designed around and avoids GUID/name fallback
|
|
-- ambiguity with identical mobs.
|
|
local token = plate.BNPClassicUnitToken
|
|
if token and UnitExists(token) then return token end
|
|
|
|
if plate.GetName then return plate:GetName(1) end
|
|
return nil
|
|
end
|
|
|
|
local function FindMatchingAuraOnUnit(unit, def, castSpellID, snapshot)
|
|
if not unit or not def then return nil, nil, nil end
|
|
|
|
-- Event-driven ClassicAPI updates pass one slot snapshot through the entire
|
|
-- reconciliation chain. Legacy/fallback callers still scan UnitDebuff.
|
|
local i = 1
|
|
local maxIndex = snapshot and snapshot.count or 64
|
|
while i <= maxIndex do
|
|
local texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source, auraName
|
|
if snapshot then
|
|
local entry = snapshot.entries[i]
|
|
if not entry then break end
|
|
texture = entry[1]
|
|
stacks = entry[2]
|
|
dtype = entry[3]
|
|
auraSpellID = entry[4]
|
|
liveDuration = entry[5]
|
|
liveExpires = entry[6]
|
|
source = entry[7]
|
|
auraName = entry[10]
|
|
else
|
|
texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source = BNP:_ReadClassicDebuff(unit, i)
|
|
if not texture then break end
|
|
if auraSpellID and SpellInfo then auraName = SpellInfo(auraSpellID) end
|
|
end
|
|
|
|
-- If ClassicAPI knows another unit cast this aura, it cannot confirm our
|
|
-- pending cast. Unknown source remains a valid fallback for triggered
|
|
-- effects whose caster cannot be reconstructed from packets.
|
|
local knownForeign = BNP:_IsKnownForeignClassicAuraSource(source)
|
|
|
|
if auraSpellID and not knownForeign then
|
|
if auraSpellID == castSpellID or DefHasSpellID(def, auraSpellID) then
|
|
return auraSpellID, texture, tonumber(stacks) or 0, tonumber(liveDuration), tonumber(liveExpires), BNP:_IsOwnClassicAuraSource(source)
|
|
end
|
|
|
|
auraName = auraName or (SpellInfo and SpellInfo(auraSpellID) or nil)
|
|
if NameMatches(def, auraName) then
|
|
return auraSpellID, texture, tonumber(stacks) or 0, tonumber(liveDuration), tonumber(liveExpires), BNP:_IsOwnClassicAuraSource(source)
|
|
end
|
|
end
|
|
|
|
if not knownForeign and texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then
|
|
return auraSpellID or castSpellID, texture, tonumber(stacks) or 0, tonumber(liveDuration), tonumber(liveExpires), BNP:_IsOwnClassicAuraSource(source)
|
|
end
|
|
|
|
i = i + 1
|
|
end
|
|
|
|
return nil, nil, nil
|
|
end
|
|
|
|
local function ReconcileLiveCurseCache(unit, guid)
|
|
if playerClass ~= "WARLOCK" or not unit or not guid then return end
|
|
|
|
local cache = BNP.guidAuras[guid]
|
|
if not cache then return end
|
|
|
|
-- Curse casts are already strict aura-confirmed. Once a new curse has really
|
|
-- appeared, use that same live target aura state to decide which older curse
|
|
-- timers are still valid. This avoids hard-coding whether CoA is exclusive:
|
|
-- if the server keeps two curses, BNP keeps two; if one was replaced, BNP
|
|
-- removes only the aura that is actually gone.
|
|
local _, curseDef
|
|
for _, curseDef in ipairs(AURA_DEFS) do
|
|
if curseDef.category == "CURSE" and cache[curseDef.key] then
|
|
local cachedAura = cache[curseDef.key]
|
|
local liveSpellID = FindMatchingAuraOnUnit(
|
|
unit,
|
|
curseDef,
|
|
cachedAura and cachedAura.spellID or nil
|
|
)
|
|
|
|
if not liveSpellID then
|
|
RaidTrace("CURSE_REMOVE", guid, curseDef.key, cachedAura and cachedAura.spellID)
|
|
cache[curseDef.key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function CommitPendingAura(guid, key, pending, unit)
|
|
if not guid or not pending then return end
|
|
|
|
local def = pending.def
|
|
local cache = BNP.guidAuras[guid]
|
|
if not cache then
|
|
cache = {}
|
|
BNP.guidAuras[guid] = cache
|
|
end
|
|
|
|
-- Do not guess curse exclusivity here. Warlock curses are strict aura-
|
|
-- confirmed and are reconciled against the live target state after commit.
|
|
-- This supports both normal Vanilla replacement and Turtle dual-curse rules.
|
|
|
|
cache._nextOrder = (cache._nextOrder or 0) + 1
|
|
local aura = cache[key] or {}
|
|
aura.order = cache._nextOrder
|
|
local commitNow = GetTime()
|
|
local liveDuration = tonumber(pending.liveDuration)
|
|
local liveExpires = tonumber(pending.liveExpires)
|
|
aura.duration = (liveDuration and liveDuration > 0 and liveDuration) or pending.duration
|
|
if liveExpires and liveExpires > commitNow then
|
|
aura.expires = liveExpires
|
|
aura.classicAPITiming = true
|
|
else
|
|
aura.expires = commitNow + aura.duration
|
|
aura.classicAPITiming = nil
|
|
end
|
|
aura.spellID = pending.spellID
|
|
aura.texture = pending.texture or def.texture
|
|
aura.stacks = tonumber(pending.stacks) or 0
|
|
aura.confirmedAt = commitNow
|
|
aura.missingScans = nil
|
|
if pending.sourceVerified then
|
|
aura.classicAPISourceVerified = true
|
|
elseif pending.liveConfirmed then
|
|
-- Do not erase a previous verified source on a refresh whose source cache
|
|
-- is temporarily unknown.
|
|
aura.classicAPISourceVerified = aura.classicAPISourceVerified or nil
|
|
end
|
|
|
|
if pending.liveConfirmed then
|
|
aura.castPrimary = nil
|
|
aura.castPrimaryAt = nil
|
|
aura.liveConfirmed = true
|
|
BNP:ResolveNewAuraCastAttempt(guid, key)
|
|
elseif pending.castPrimary then
|
|
aura.castPrimary = true
|
|
aura.castPrimaryAt = GetTime()
|
|
aura.liveConfirmed = nil
|
|
end
|
|
|
|
cache[key] = aura
|
|
|
|
if def.category == "CURSE" and unit then
|
|
ReconcileLiveCurseCache(unit, guid)
|
|
end
|
|
|
|
RaidTrace("CONFIRM", guid, key, aura.spellID)
|
|
end
|
|
|
|
local function ConfirmPendingForUnit(unit, guid, now, snapshot)
|
|
if not unit or not guid then return end
|
|
local pendingForGUID = BNP.pendingAuras[guid]
|
|
if not pendingForGUID then return end
|
|
|
|
local key, pending
|
|
for key, pending in pairs(pendingForGUID) do
|
|
if type(pending) == "table" then
|
|
if pending.judgementProbe then
|
|
local def, auraSpellID, texture = FindJudgementAuraOnUnit(unit)
|
|
|
|
if def and auraSpellID then
|
|
local duration = def.duration
|
|
if BNP.GetSpellbookDurationForDef then
|
|
duration = BNP:GetSpellbookDurationForDef(def) or duration
|
|
end
|
|
|
|
local confirmed = {
|
|
def = def,
|
|
spellID = auraSpellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = pending.created,
|
|
liveConfirmed = true,
|
|
}
|
|
|
|
CommitPendingAura(guid, def.key, confirmed, unit)
|
|
pendingForGUID[key] = nil
|
|
elseif now - pending.created > PENDING_TIMEOUT then
|
|
RaidTrace("TIMEOUT", guid, key, pending.spellID)
|
|
-- No Judgement aura appeared: resist, miss, immune or failed cast.
|
|
pendingForGUID[key] = nil
|
|
end
|
|
else
|
|
local auraSpellID, auraTexture, auraStacks, liveDuration, liveExpires, sourceVerified = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID, snapshot)
|
|
if auraSpellID then
|
|
local confirmed = {
|
|
def = pending.def,
|
|
spellID = auraSpellID,
|
|
texture = auraTexture or pending.texture,
|
|
duration = pending.duration,
|
|
created = pending.created,
|
|
stacks = auraStacks or 0,
|
|
liveDuration = liveDuration,
|
|
liveExpires = liveExpires,
|
|
sourceVerified = sourceVerified,
|
|
liveConfirmed = true,
|
|
}
|
|
CommitPendingAura(guid, key, confirmed, unit)
|
|
pendingForGUID[key] = nil
|
|
elseif now - pending.created > PENDING_TIMEOUT then
|
|
RaidTrace("TIMEOUT", guid, key, pending.spellID)
|
|
-- Effects not approved for the raid fallback remain strict.
|
|
pendingForGUID[key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function CleanupPending(now)
|
|
local guid, entries, key, pending
|
|
for guid, entries in pairs(BNP.pendingAuras) do
|
|
local any = false
|
|
for key, pending in pairs(entries) do
|
|
if type(pending) == "table" then
|
|
if now - pending.created > PENDING_TIMEOUT then
|
|
entries[key] = nil
|
|
else
|
|
any = true
|
|
end
|
|
end
|
|
end
|
|
if not any then BNP.pendingAuras[guid] = nil end
|
|
end
|
|
end
|
|
|
|
|
|
local AOE_PENDING_TIMEOUT = 1.5
|
|
|
|
local function QueueAoEAura(def, spellID, texture, duration)
|
|
if not def or not spellID then return end
|
|
BNP.pendingAoEAuras[def.key] = {
|
|
def = def,
|
|
spellID = spellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = GetTime(),
|
|
}
|
|
end
|
|
|
|
local function ConfirmAoEAurasOnUnit(unit, guid, snapshot)
|
|
if not unit or not guid then return end
|
|
local key, pending
|
|
for key, pending in pairs(BNP.pendingAoEAuras) do
|
|
if type(pending) == "table" then
|
|
local auraSpellID, auraTexture, auraStacks, liveDuration, liveExpires, sourceVerified = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID, snapshot)
|
|
if auraSpellID then
|
|
CommitPendingAura(guid, pending.def.key, {
|
|
def = pending.def,
|
|
spellID = auraSpellID,
|
|
texture = auraTexture or pending.texture,
|
|
duration = pending.duration,
|
|
created = pending.created,
|
|
stacks = auraStacks or 0,
|
|
liveDuration = liveDuration,
|
|
liveExpires = liveExpires,
|
|
sourceVerified = sourceVerified,
|
|
liveConfirmed = true,
|
|
}, unit)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function HasPendingAoEAuras()
|
|
local key, pending
|
|
for key, pending in pairs(BNP.pendingAoEAuras) do
|
|
if type(pending) == "table" then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function CleanupPendingAoE(now)
|
|
local key, pending
|
|
for key, pending in pairs(BNP.pendingAoEAuras) do
|
|
if type(pending) == "table" and now - (pending.created or 0) > AOE_PENDING_TIMEOUT then
|
|
BNP.pendingAoEAuras[key] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local recentChannelAuras = {}
|
|
|
|
local function ChannelKey(guid, spellID)
|
|
return tostring(guid or "-") .. ":" .. tostring(spellID or 0)
|
|
end
|
|
|
|
local castEvents = CreateFrame("Frame")
|
|
castEvents:RegisterEvent("UNIT_CASTEVENT")
|
|
castEvents:SetScript("OnEvent", function()
|
|
local casterGUID = arg1
|
|
local targetGUID = arg2
|
|
local eventType = arg3
|
|
local spellID = arg4
|
|
local playerGUID = GetPlayerGUID()
|
|
|
|
if not playerGUID or casterGUID ~= playerGUID then return end
|
|
|
|
if eventType == "FAIL" then
|
|
RaidTrace("FAIL", targetGUID, "-", spellID)
|
|
FailPendingAura(targetGUID, spellID)
|
|
RollbackAuraAttemptBySpell(targetGUID, spellID, "UNIT_FAIL")
|
|
return
|
|
end
|
|
|
|
-- Dark Harvest has its own channel implementation and must not enter the
|
|
-- generic aura pipeline below.
|
|
if eventType == "CHANNEL" and spellID == DARK_HARVEST_SPELL_ID then
|
|
StartDarkHarvest(targetGUID, arg5)
|
|
return
|
|
end
|
|
|
|
if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end
|
|
|
|
-- Shadow audit only: this never changes the existing lookup or renderer.
|
|
if BNP.AuditSpellDB then BNP:AuditSpellDB(spellID) end
|
|
|
|
-- Paladin Judgement is a generic cast (20271). The actual debuff ID is only
|
|
-- known after it lands on the target, so defer identification to UnitDebuff.
|
|
if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then
|
|
if not targetGUID then return end
|
|
|
|
if targetGUID then
|
|
local judgementKeys = {}
|
|
local _, judgementDef
|
|
for _, judgementDef in ipairs(AURA_DEFS) do
|
|
if IsJudgementDef(judgementDef) then judgementKeys[judgementDef.key] = true end
|
|
end
|
|
StartAuraCastAttempt(targetGUID, { key = "__judgement" }, spellID, judgementKeys)
|
|
end
|
|
|
|
local pendingForGUID = BNP.pendingAuras[targetGUID]
|
|
if not pendingForGUID then
|
|
pendingForGUID = {}
|
|
BNP.pendingAuras[targetGUID] = pendingForGUID
|
|
end
|
|
|
|
pendingForGUID.__judgement = {
|
|
judgementProbe = true,
|
|
created = GetTime(),
|
|
}
|
|
return
|
|
end
|
|
|
|
-- Shadow Vulnerability uses one aura ID per talent rank. It is a shared
|
|
-- raid debuff and has its own any-Warlock path below, so none of its ranks
|
|
-- should enter the normal own-cast pending pipeline.
|
|
local sid = tonumber(spellID)
|
|
if playerClass == "WARLOCK" and (sid == 17794 or sid == 17797 or sid == 17798 or sid == 17799 or sid == 17800) then return end
|
|
|
|
local def, texture = FindAuraDef(spellID)
|
|
if not def then return end
|
|
|
|
-- A few SuperWoW channel paths can omit arg2 briefly. A channel cannot move to
|
|
-- a different target after it starts, so the real current target is a safe
|
|
-- fallback here (unlike blindly doing this for every normal CAST event).
|
|
if eventType == "CHANNEL" and not targetGUID then
|
|
targetGUID = GetTargetGUID()
|
|
end
|
|
|
|
-- Channeled hostile effects are applied when SuperWoW emits CHANNEL, not when
|
|
-- the channel later ends. Some clients may additionally emit CAST at the end;
|
|
-- suppress that duplicate so the timer cannot restart after the channel. If a
|
|
-- server exposes only CAST, the fallback remains available.
|
|
if eventType == "CHANNEL" then
|
|
if not def.channel then return end
|
|
local channelMS = tonumber(arg5) or 0
|
|
local hold = channelMS > 0 and (channelMS / 1000) or (def.duration or 1)
|
|
recentChannelAuras[ChannelKey(targetGUID, spellID)] = GetTime() + hold + 0.75
|
|
elseif def.channel then
|
|
local untilTime = recentChannelAuras[ChannelKey(targetGUID, spellID)]
|
|
if untilTime and GetTime() <= untilTime then return end
|
|
end
|
|
|
|
local duration = nil
|
|
-- Resolve the exact rank that was cast. This preserves tooltip-derived talent
|
|
-- or server modifications for that rank, while still falling back to the
|
|
-- explicit rank table when the exact spellbook slot cannot be resolved.
|
|
if BNP.GetSpellbookDurationForSpellID then
|
|
duration = BNP:GetSpellbookDurationForSpellID(def, spellID)
|
|
end
|
|
duration = duration or (def.durations and def.durations[spellID])
|
|
if not duration and BNP.GetSpellbookDurationForDef then
|
|
duration = BNP:GetSpellbookDurationForDef(def)
|
|
end
|
|
duration = duration or def.duration
|
|
|
|
-- Turtle WoW scales Rip from 10 to 18 seconds with one to five combo
|
|
-- points. Resolve this after tooltip/rank detection so the cast's actual
|
|
-- combo-point count remains authoritative.
|
|
if def.comboPointDurations and BNP.GetTrackedComboPointsForGUID then
|
|
local comboPoints = BNP:GetTrackedComboPointsForGUID(targetGUID)
|
|
local comboDuration = def.comboPointDurations[comboPoints]
|
|
if comboDuration then duration = comboDuration end
|
|
end
|
|
|
|
if def.aoe then
|
|
QueueAoEAura(def, spellID, texture, duration)
|
|
return
|
|
end
|
|
|
|
if not targetGUID then return end
|
|
|
|
StartAuraCastAttempt(targetGUID, def, spellID)
|
|
|
|
local pendingForGUID = BNP.pendingAuras[targetGUID]
|
|
if not pendingForGUID then
|
|
pendingForGUID = {}
|
|
BNP.pendingAuras[targetGUID] = pendingForGUID
|
|
end
|
|
|
|
pendingForGUID[def.key] = {
|
|
def = def,
|
|
spellID = spellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = GetTime(),
|
|
}
|
|
|
|
RaidTrace("CAST", targetGUID, def.key, spellID)
|
|
|
|
-- Pounce triggers a separate 18-second bleed spell. Queue its real spell ID
|
|
-- and let the normal UnitDebuff confirmation path prove that it landed on
|
|
-- this exact GUID before any icon or timer is shown. Keep this inline: the
|
|
-- 1.12 Lua client has a strict local-variable limit for the main chunk.
|
|
if def.secondaryAuraKey and def.secondarySpellIDs then
|
|
local numericSpellID = tonumber(spellID) or spellID
|
|
local secondarySpellID = def.secondarySpellIDs[numericSpellID]
|
|
local secondaryDef = nil
|
|
|
|
if secondarySpellID then
|
|
local _, candidate
|
|
for _, candidate in ipairs(AURA_DEFS) do
|
|
if candidate.key == def.secondaryAuraKey then
|
|
secondaryDef = candidate
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if secondaryDef then
|
|
local secondaryTexture = texture or secondaryDef.texture
|
|
if SpellInfo then
|
|
local _, _, foundTexture = SpellInfo(secondarySpellID)
|
|
secondaryTexture = foundTexture or secondaryTexture
|
|
end
|
|
|
|
pendingForGUID[secondaryDef.key] = {
|
|
def = secondaryDef,
|
|
spellID = secondarySpellID,
|
|
texture = secondaryTexture,
|
|
duration = secondaryDef.duration or 18,
|
|
created = GetTime(),
|
|
}
|
|
RaidTrace("SECONDARY_PENDING", targetGUID, secondaryDef.key, secondarySpellID)
|
|
end
|
|
end
|
|
|
|
if IsCastPrimary(def) then
|
|
local direct = {
|
|
def = def,
|
|
spellID = spellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = GetTime(),
|
|
stacks = 0,
|
|
castPrimary = true,
|
|
}
|
|
CommitPendingAura(targetGUID, def.key, direct)
|
|
local cache = BNP.guidAuras and BNP.guidAuras[targetGUID]
|
|
if cache and cache[def.key] then
|
|
cache[def.key].castPrimary = true
|
|
cache[def.key].castPrimaryAt = GetTime()
|
|
cache[def.key].liveConfirmed = nil
|
|
end
|
|
pendingForGUID[def.key] = nil
|
|
RaidTrace("DIRECT", targetGUID, def.key, spellID)
|
|
return
|
|
end
|
|
|
|
-- Fast path: if the cast target is still our current target, try to confirm
|
|
-- immediately. The normal pending loop remains as the retry path.
|
|
local currentTargetGUID = GetTargetGUID()
|
|
if currentTargetGUID and currentTargetGUID == targetGUID then
|
|
ConfirmPendingForUnit("target", targetGUID, GetTime())
|
|
end
|
|
end)
|
|
|
|
|
|
|
|
-- Paladin Judgement melee refresh ------------------------------------------
|
|
-- Vanilla Judgement debuffs refresh when the judging Paladin lands a melee
|
|
-- strike on that target. The aura itself does not get re-cast, so UNIT_CASTEVENT
|
|
-- cannot update our timer. Refresh only an already-confirmed OWN Judgement.
|
|
local function RefreshOwnJudgementForGUID(guid)
|
|
if playerClass ~= "PALADIN" or not guid then return end
|
|
|
|
local cache = BNP.guidAuras[guid]
|
|
if not cache then return end
|
|
|
|
local _, def
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if IsJudgementDef(def) then
|
|
local aura = cache[def.key]
|
|
if type(aura) == "table" and aura.expires then
|
|
local duration = aura.duration or def.duration or 10
|
|
aura.duration = duration
|
|
aura.expires = GetTime() + duration
|
|
aura.confirmedAt = GetTime()
|
|
aura.missingScans = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- SuperWoW gives us the target GUID of the player's current target. For normal
|
|
-- auto-attacks, the combat text event is sufficient to know a successful melee
|
|
-- hit occurred; matching the current target GUID keeps the refresh scoped to
|
|
-- the unit actually being attacked.
|
|
local judgementMeleeFrame = CreateFrame("Frame")
|
|
judgementMeleeFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS")
|
|
judgementMeleeFrame:SetScript("OnEvent", function()
|
|
if playerClass ~= "PALADIN" then return end
|
|
|
|
local guid = GetTargetGUID()
|
|
if not guid then return end
|
|
|
|
-- CHAT_MSG_COMBAT_SELF_HITS only fires for landed melee attacks (hit/crit).
|
|
RefreshOwnJudgementForGUID(guid)
|
|
end)
|
|
|
|
|
|
|
|
-- Holy Strike also counts as a melee-style Judgement refresh on Turtle/Octo.
|
|
-- It is reported via CHAT_MSG_SPELL_SELF_DAMAGE rather than the normal melee
|
|
-- hit event and does not expose a useful spell ID in this client.
|
|
local holyStrikeRefreshFrame = CreateFrame("Frame")
|
|
holyStrikeRefreshFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
holyStrikeRefreshFrame:SetScript("OnEvent", function()
|
|
if playerClass ~= "PALADIN" then return end
|
|
|
|
local raw = arg1
|
|
if not raw then return end
|
|
|
|
-- Only successful Holy Strike hit messages refresh the timer.
|
|
-- Miss/resist/immune lines do not match this pattern.
|
|
if not string.find(raw, "^Your Holy Strike hits ") then return end
|
|
|
|
local guid = GetTargetGUID()
|
|
if not guid then return end
|
|
|
|
-- Extra safety: when the target name is available, require it to be present
|
|
-- in the combat-log line so a stale/current target cannot refresh another GUID.
|
|
local targetName = UnitName("target")
|
|
if targetName and targetName ~= "" and not string.find(raw, targetName, 1, true) then
|
|
return
|
|
end
|
|
|
|
RefreshOwnJudgementForGUID(guid)
|
|
end)
|
|
|
|
|
|
-- Enemy Feign Death / Vanish protection.
|
|
local pvpDisappearFrame = CreateFrame("Frame")
|
|
pvpDisappearFrame:RegisterEvent("UNIT_CASTEVENT")
|
|
pvpDisappearFrame:SetScript("OnEvent", function()
|
|
local casterGUID = arg1
|
|
local eventType = arg3
|
|
local spellID = arg4
|
|
|
|
if not casterGUID or not spellID or not SpellInfo then return end
|
|
if eventType ~= "CAST" and eventType ~= "START" then return end
|
|
|
|
local spellName = SpellInfo(spellID)
|
|
|
|
-- Abilities that can temporarily drop target/nameplate visibility without
|
|
-- actually cleansing existing harmful effects.
|
|
local TEMPORARY_DISAPPEAR = {
|
|
["Feign Death"] = true,
|
|
["Vanish"] = true,
|
|
["Shadowmeld"] = true,
|
|
}
|
|
|
|
if spellName and TEMPORARY_DISAPPEAR[spellName] then
|
|
ProtectAuraCache(casterGUID, spellName)
|
|
end
|
|
end)
|
|
|
|
|
|
|
|
-- Shared/global aura exceptions ---------------------------------------------
|
|
-- Most BNP debuffs are strictly "own casts only". A very small number of
|
|
-- effects are globally unique on a target and are useful even when applied by
|
|
-- another player. These are explicitly opted in via def.shared = true.
|
|
local LIVE_DEBUFF_SNAPSHOT = { entries = {}, count = 0, time = 0 }
|
|
|
|
-- Capture UnitDebuff once and reuse that exact snapshot for shared-aura sync
|
|
-- and removal sync in the same 0.10s scan. This avoids scanning the same
|
|
-- SuperWoW unit twice (or more) per plate while keeping behavior identical.
|
|
local function CaptureLiveDebuffSnapshot(unit, now)
|
|
local snap = LIVE_DEBUFF_SNAPSHOT
|
|
local entries = snap.entries
|
|
local count = 0
|
|
local i
|
|
|
|
snap.time = now or GetTime()
|
|
|
|
-- ClassicAPI v1.15.6 fast path. GetAuraSlots fills our reusable scratch
|
|
-- table and UnitAuraBySlot returns positional values, so a full harmful-aura
|
|
-- snapshot creates no per-aura tables and walks the descriptor array once.
|
|
if BNP._ClassicGetAuraSlots and BNP._ClassicUnitAuraBySlot then
|
|
local slots = BNP._ClassicAuraSlotScratch
|
|
local _, slotCount = BNP._ClassicGetAuraSlots(unit, "HARMFUL", nil, nil, slots)
|
|
slotCount = tonumber(slotCount) or 0
|
|
|
|
for i = 1, slotCount do
|
|
local auraName, texture, stacks, dtype, duration, expirationTime, source,
|
|
isStealable, nameplateShowPersonal, auraSpellID =
|
|
BNP._ClassicUnitAuraBySlot(unit, slots[i])
|
|
if auraName or (auraSpellID and auraSpellID > 0) then
|
|
count = count + 1
|
|
local entry = entries[count]
|
|
if not entry then
|
|
entry = {}
|
|
entries[count] = entry
|
|
end
|
|
|
|
entry[1] = texture
|
|
entry[2] = stacks
|
|
entry[3] = dtype
|
|
entry[4] = auraSpellID
|
|
entry[5] = duration
|
|
entry[6] = expirationTime
|
|
entry[7] = source
|
|
entry[8] = isStealable
|
|
entry[9] = nameplateShowPersonal
|
|
entry[10] = auraName
|
|
end
|
|
end
|
|
else
|
|
for i = 1, 64 do
|
|
local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = BNP:_ReadClassicDebuff(unit, i)
|
|
if not a1 then break end
|
|
|
|
count = count + 1
|
|
local entry = entries[count]
|
|
if not entry then
|
|
entry = {}
|
|
entries[count] = entry
|
|
end
|
|
|
|
entry[1] = a1
|
|
entry[2] = a2
|
|
entry[3] = a3
|
|
entry[4] = a4
|
|
entry[5] = a5
|
|
entry[6] = a6
|
|
entry[7] = a7
|
|
entry[8] = a8
|
|
entry[9] = a9
|
|
entry[10] = a10
|
|
end
|
|
end
|
|
|
|
snap.count = count
|
|
return snap
|
|
end
|
|
|
|
local function ClassicAPIAuraDataAvailable()
|
|
return type(BNP._ClassicAuraUnitDebuff) == "function"
|
|
end
|
|
|
|
-- ClassicAPI exposes hostile aura identity from the real HARMFUL descriptor
|
|
-- set (including Turtle debuffs spilled into free buff slots), plus best-effort
|
|
-- source/timing data. The positional UnitDebuff form allocates no AuraData
|
|
-- tables, so it is safe to use in the throttled nameplate scan.
|
|
local function FindClassicAPISharedAura(unit, def)
|
|
if not ClassicAPIAuraDataAvailable() then return nil, nil, nil, nil, nil, false end
|
|
|
|
local i
|
|
for i = 1, 64 do
|
|
local texture, stacks, dtype, spellID, duration, expires, source,
|
|
isStealable, nameplateShowPersonal, auraName = BNP:_ReadClassicDebuff(unit, i)
|
|
if not texture then break end
|
|
|
|
local matches = false
|
|
if def.sharedSpellIDs and spellID then
|
|
local _, knownID
|
|
for _, knownID in ipairs(def.sharedSpellIDs) do
|
|
if spellID == knownID then
|
|
matches = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not matches and def.spellIDs and spellID then
|
|
local _, knownID
|
|
for _, knownID in ipairs(def.spellIDs) do
|
|
if spellID == knownID then
|
|
matches = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not matches and auraName then matches = NameMatches(def, auraName) end
|
|
|
|
if matches then
|
|
return spellID, texture, tonumber(duration), tonumber(expires),
|
|
tonumber(stacks) or 0, true
|
|
end
|
|
end
|
|
|
|
return nil, nil, nil, nil, nil, true
|
|
end
|
|
|
|
local function FindSharedAuraOnUnit(unit, def, snapshot)
|
|
if not unit or not def then return nil, nil, nil, nil, nil, false end
|
|
|
|
-- Event-driven ClassicAPI callers already captured the exact harmful-aura
|
|
-- slots once. Consume that snapshot directly so shared auras do not trigger
|
|
-- a second UnitDebuff scan or temporary timing tables.
|
|
if snapshot then
|
|
local i
|
|
for i = 1, snapshot.count do
|
|
local entry = snapshot.entries[i]
|
|
if not entry then break end
|
|
local texture = entry[1]
|
|
local auraSpellID = entry[4]
|
|
local auraName = entry[10]
|
|
local sharedMatches = nil
|
|
|
|
if def.sharedSpellIDs and auraSpellID then
|
|
sharedMatches = false
|
|
local _, sharedSpellID
|
|
for _, sharedSpellID in ipairs(def.sharedSpellIDs) do
|
|
if auraSpellID == sharedSpellID then
|
|
sharedMatches = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if sharedMatches == true or (sharedMatches == nil and AuraMatches(def, auraName, texture)) then
|
|
return auraSpellID, texture, tonumber(entry[5]), tonumber(entry[6]),
|
|
tonumber(entry[2]) or 0, true
|
|
end
|
|
end
|
|
return nil, nil, nil, nil, nil, true
|
|
end
|
|
|
|
if def.shared then
|
|
local spellID, texture, duration, expires, stacks, authoritative =
|
|
FindClassicAPISharedAura(unit, def)
|
|
if authoritative then return spellID, texture, duration, expires, stacks, true end
|
|
end
|
|
|
|
local now = GetTime()
|
|
local i
|
|
for i = 1, 64 do
|
|
local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = BNP:_ReadClassicDebuff(unit, i)
|
|
if not a1 then break end
|
|
|
|
local texture = a1
|
|
local auraSpellID = a4
|
|
local auraName = nil
|
|
if auraSpellID and SpellInfo then auraName = SpellInfo(auraSpellID) end
|
|
|
|
local sharedMatches = nil
|
|
if def.sharedSpellIDs and auraSpellID then
|
|
sharedMatches = false
|
|
local _, sharedSpellID
|
|
for _, sharedSpellID in ipairs(def.sharedSpellIDs) do
|
|
if auraSpellID == sharedSpellID then
|
|
sharedMatches = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Some different class talents use the same localized aura name. Shared
|
|
-- effects may therefore opt into exact aura IDs instead of name matching.
|
|
if sharedMatches == true or (sharedMatches == nil and AuraMatches(def, auraName, texture)) then
|
|
-- Legacy/SuperWoW fallback: probe plausible numeric return pairs without
|
|
-- assuming one exact fork. This branch is not used by the v1.15.6 event
|
|
-- path, so its compatibility behavior stays unchanged.
|
|
local duration, expires
|
|
local vals = { a5, a6, a7, a8, a9, a10 }
|
|
local n
|
|
for n = 1, table.getn(vals) - 1 do
|
|
local d = vals[n]
|
|
local e = vals[n + 1]
|
|
if type(d) == "number" and type(e) == "number"
|
|
and d > 0 and d <= 3600 and e > now then
|
|
duration = d
|
|
expires = e
|
|
break
|
|
end
|
|
end
|
|
|
|
return auraSpellID, texture, duration, expires, tonumber(a2) or 0, false
|
|
end
|
|
end
|
|
|
|
return nil, nil, nil, nil, nil, false
|
|
end
|
|
|
|
local SV_SHARED_MISSING_GRACE = 0.45
|
|
|
|
local function SyncShadowVulnerabilityPresence(unit, guid, now, def, auraSpellID, texture, liveStacks, liveDuration, liveExpires, classicAPIAuthoritative, eventAuthoritative)
|
|
local cache = BNP.guidAuras[guid]
|
|
local state = BNP.svStateByGUID[guid]
|
|
|
|
-- ClassicAPI can keep an expired AuraData row around briefly before the
|
|
-- next UNIT_AURA cleanup. A positive expirationTime in the past is proof
|
|
-- that this row is stale; never turn it into a fresh estimated 10 seconds.
|
|
-- A newer RAW_COMBATLOG application may temporarily outrun the API update,
|
|
-- so preserve that independently proven future expiration when present.
|
|
local previousExpires = state and tonumber(state.expires) or nil
|
|
local explicitExpired = liveExpires and liveExpires > 0 and liveExpires <= now
|
|
local provenFuture = state and state.provenExpires and state.provenExpires > now
|
|
if explicitExpired and not provenFuture then
|
|
BNP.svStateByGUID[guid] = nil
|
|
BNP.svExpiryByGUID[guid] = nil
|
|
if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end
|
|
if cache then cache[def.key] = nil end
|
|
return
|
|
end
|
|
|
|
-- Some ClassicAPI builds replace expirationTime with 0/nil while retaining
|
|
-- the expired AuraData row for another update or two. Once ClassicAPI has
|
|
-- answered authoritatively, never manufacture a timer from that incomplete
|
|
-- row. Keep only an already-known future expiry or a proven raw reapply.
|
|
local classicRowWithoutFutureExpiry = classicAPIAuthoritative and
|
|
(auraSpellID or texture) and not (liveExpires and liveExpires > now)
|
|
local previousFuture = previousExpires and previousExpires > now
|
|
if classicRowWithoutFutureExpiry and not provenFuture and not previousFuture then
|
|
BNP.svStateByGUID[guid] = nil
|
|
BNP.svExpiryByGUID[guid] = nil
|
|
if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end
|
|
if cache then cache[def.key] = nil end
|
|
return
|
|
end
|
|
|
|
if auraSpellID or texture then
|
|
if not state then
|
|
state = {
|
|
present = true,
|
|
lastSeen = now,
|
|
}
|
|
BNP.svStateByGUID[guid] = state
|
|
end
|
|
|
|
state.present = true
|
|
state.lastSeen = now
|
|
state.duration = (liveDuration and liveDuration > 0 and liveDuration) or state.duration or def.duration or 10
|
|
|
|
if state.provenExpires and state.provenExpires <= now then state.provenExpires = nil end
|
|
local apiExpires = liveExpires and liveExpires > now and liveExpires or nil
|
|
local chosenExpires = apiExpires
|
|
if state.provenExpires and (not chosenExpires or state.provenExpires > chosenExpires) then
|
|
chosenExpires = state.provenExpires
|
|
end
|
|
if not chosenExpires and previousExpires and previousExpires > now then chosenExpires = previousExpires end
|
|
if not chosenExpires then
|
|
chosenExpires = now + state.duration
|
|
state.estimated = true
|
|
else
|
|
state.estimated = nil
|
|
end
|
|
|
|
state.expires = chosenExpires
|
|
state.apiExpires = apiExpires
|
|
if previousExpires and chosenExpires > previousExpires + 0.05 then
|
|
state.lastRefresh = now
|
|
BNP.svRenderRevisionByGUID[guid] = (BNP.svRenderRevisionByGUID[guid] or 0) + 1
|
|
state.renderRevision = BNP.svRenderRevisionByGUID[guid]
|
|
RaidTrace("SV_CLASSICAPI_REFRESH", guid, def.key, auraSpellID or 17794)
|
|
end
|
|
|
|
state.spellID = auraSpellID or state.spellID or 17794
|
|
state.texture = texture or state.texture or def.texture
|
|
state.stacks = tonumber(liveStacks) or state.stacks or 0
|
|
BNP.svExpiryByGUID[guid] = state.expires
|
|
|
|
if not cache then
|
|
cache = {}
|
|
BNP.guidAuras[guid] = cache
|
|
end
|
|
local aura = cache[def.key]
|
|
if not aura then
|
|
cache._nextOrder = (cache._nextOrder or 0) + 1
|
|
aura = { order = cache._nextOrder }
|
|
cache[def.key] = aura
|
|
end
|
|
|
|
aura.sharedLive = true
|
|
aura.spellID = state.spellID
|
|
aura.texture = state.texture
|
|
aura.stacks = state.stacks
|
|
aura.duration = state.duration
|
|
aura.expires = state.expires
|
|
aura.confirmedAt = aura.confirmedAt or now
|
|
aura.sharedLastSeen = now
|
|
aura.sharedEstimated = state.estimated and true or nil
|
|
return
|
|
end
|
|
|
|
-- One empty SuperWoW/UnitDebuff snapshot is not authoritative. Only remove
|
|
-- the shared state after no token has positively seen it for a short grace.
|
|
if state and (eventAuthoritative or now - (state.lastSeen or 0) >= SV_SHARED_MISSING_GRACE) then
|
|
BNP.svStateByGUID[guid] = nil
|
|
BNP.svExpiryByGUID[guid] = nil
|
|
if BNP.svRenderRevisionByGUID then BNP.svRenderRevisionByGUID[guid] = nil end
|
|
if cache then cache[def.key] = nil end
|
|
end
|
|
end
|
|
|
|
local function SyncSharedAuras(unit, guid, now, snapshot, eventAuthoritative)
|
|
if not unit or not guid then return end
|
|
|
|
local _, def
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if def.shared then
|
|
local auraSpellID, texture, liveDuration, liveExpires, liveStacks, classicAPIAuthoritative =
|
|
FindSharedAuraOnUnit(unit, def, snapshot)
|
|
|
|
-- Shadow Vulnerability is deliberately isolated from the generic shared
|
|
-- aura timer path. Its GUID state is the only owner of its countdown.
|
|
if def.key == "shadow_vulnerability" then
|
|
SyncShadowVulnerabilityPresence(
|
|
unit, guid, now, def, auraSpellID, texture, liveStacks, liveDuration,
|
|
liveExpires, classicAPIAuthoritative, eventAuthoritative
|
|
)
|
|
else
|
|
local cache = BNP.guidAuras[guid]
|
|
|
|
if auraSpellID or texture then
|
|
if not cache then
|
|
cache = {}
|
|
BNP.guidAuras[guid] = cache
|
|
end
|
|
|
|
local aura = cache[def.key]
|
|
-- Never overwrite an accurately timed own aura with the shared/live
|
|
-- fallback. Shared entries exist only when we don't own the timer.
|
|
if not aura or aura.sharedLive then
|
|
if not aura then
|
|
cache._nextOrder = (cache._nextOrder or 0) + 1
|
|
aura = { order = cache._nextOrder }
|
|
end
|
|
|
|
aura.sharedLive = true
|
|
aura.spellID = auraSpellID
|
|
aura.texture = texture or def.texture
|
|
aura.confirmedAt = now
|
|
aura.sharedLastSeen = now
|
|
|
|
local previousStacks = tonumber(aura.stacks) or 0
|
|
local currentStacks = tonumber(liveStacks) or 0
|
|
if currentStacks > previousStacks and aura.expires then
|
|
aura.duration = def.duration or aura.duration or 10
|
|
aura.expires = now + aura.duration
|
|
aura.sharedEstimated = true
|
|
RaidTrace("SHARED_STACK_REFRESH", guid, def.key, auraSpellID)
|
|
end
|
|
aura.stacks = currentStacks
|
|
|
|
if liveExpires and liveExpires > now then
|
|
aura.duration = liveDuration
|
|
aura.expires = liveExpires
|
|
aura.sharedEstimated = nil
|
|
elseif not aura.expires then
|
|
aura.duration = def.duration or 120
|
|
aura.expires = now + aura.duration
|
|
aura.sharedEstimated = true
|
|
end
|
|
|
|
cache[def.key] = aura
|
|
end
|
|
elseif cache and cache[def.key] and cache[def.key].sharedLive then
|
|
local aura = cache[def.key]
|
|
local lastSeen = aura.sharedLastSeen or aura.confirmedAt or 0
|
|
if eventAuthoritative or now - lastSeen >= 0.40 then cache[def.key] = nil end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- Optional other-class debuff tracking lives in modules/otherdebuffs.lua.
|
|
-- auras.lua only consumes that cache for rendering and supplies live snapshots.
|
|
|
|
|
|
-- Live aura removal ---------------------------------------------------------
|
|
-- Timers alone are not enough for CC/roots/traps: effects may be dispelled,
|
|
-- broken by damage, removed by trinkets, or otherwise end early.
|
|
--
|
|
-- We NEVER create auras from this scan. It may only remove already-confirmed
|
|
-- own auras from the GUID cache when their exact spell ID is no longer present.
|
|
local REMOVAL_SCAN_INTERVAL = 0.10
|
|
local REMOVAL_GRACE = 0.35
|
|
local NORMAL_REMOVAL_MISSING_SCANS = 4
|
|
-- Projected non-target nameplate tokens can briefly return an incomplete
|
|
-- UnitDebuff list when many plates overlap in raids. CC used to be deleted on
|
|
-- the first such miss, which made Banish/roots/stuns blink. Require a few
|
|
-- consecutive misses for non-target CC while keeping the real target strict.
|
|
local EXACT_NON_TARGET_MISSING_SCANS = 3
|
|
|
|
local function CacheHasTrackedAuras(cache)
|
|
if not cache then return false end
|
|
local key, aura
|
|
for key, aura in pairs(cache) do
|
|
if type(aura) == "table" and aura.spellID and aura.expires then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local CHANNEL_LIVE_REMOVAL = {
|
|
drain_life=true, drain_soul=true,
|
|
mind_flay=true, starshards=true, mind_control=true,
|
|
}
|
|
|
|
local EXACT_LIVE_REMOVAL = {
|
|
-- Warlock
|
|
fear=true, howl_of_terror=true, banish=true, death_coil=true,
|
|
-- Hunter
|
|
freezing_trap_effect=true, improved_wing_clip=true, improved_concussive_shot=true,
|
|
scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true,
|
|
intimidation=true, scare_beast=true,
|
|
-- Mage
|
|
polymorph=true, frost_nova=true, counterspell_silence=true, impact=true, frostbite=true,
|
|
-- Priest
|
|
psychic_scream=true, silence=true, blackout=true, shackle_undead=true, mind_control=true,
|
|
-- Paladin
|
|
hammer_of_justice=true, repentance=true, turn_undead=true,
|
|
-- Rogue
|
|
gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, kick_silenced=true,
|
|
-- Druid
|
|
entangling_roots=true, bash=true, pounce=true, hibernate=true, feral_charge_effect=true,
|
|
-- Warrior
|
|
improved_hamstring=true, intimidating_shout=true, disarm=true, charge_stun=true,
|
|
intercept_stun=true, concussion_blow=true, revenge_stun=true, pummel=true, shield_bash=true,
|
|
-- Shaman / custom roots
|
|
earthgrab=true,
|
|
}
|
|
|
|
local function NeedsExactLiveRemoval(key)
|
|
return EXACT_LIVE_REMOVAL[key] and true or false
|
|
end
|
|
|
|
local function IsCrowdControlDef(def)
|
|
return def and (def.isCC or EXACT_LIVE_REMOVAL[def.key]) and true or false
|
|
end
|
|
|
|
local function GetEntryIconSize(entry, forceCC)
|
|
if forceCC or (entry and IsCrowdControlDef(entry.def)) then
|
|
return GetCCIconSize()
|
|
end
|
|
return GetIconSize()
|
|
end
|
|
|
|
local function ApplyIconDimensions(icon, size)
|
|
if not icon or not size then return end
|
|
if icon.BNPLayoutSize ~= size then
|
|
icon:SetWidth(size)
|
|
icon:SetHeight(size)
|
|
icon.BNPLayoutSize = size
|
|
BNP:ApplyAuraBorderColor(icon)
|
|
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, auraName)
|
|
-- Foreign CCs must be identified by exact spell identity, never by icon.
|
|
-- Blizzard reuses many spell textures across unrelated buffs/debuffs, so a
|
|
-- texture fallback can turn harmless effects into ghost CC icons. ClassicAPI
|
|
-- supplies either the aura spell ID or the exact aura name on supported paths.
|
|
if spellID and GLOBAL_CC_BY_ID[spellID] then return GLOBAL_CC_BY_ID[spellID] end
|
|
|
|
if (not auraName or auraName == "") and spellID and SpellInfo then
|
|
auraName = SpellInfo(spellID)
|
|
end
|
|
|
|
-- An unnamed/incomplete read must not permanently blacklist this spell.
|
|
-- Cache misses only for the resolved name and retry when metadata improves.
|
|
if spellID and auraName and GLOBAL_CC_NEGATIVE[spellID] == auraName then return nil end
|
|
local _, ccDef
|
|
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
|
|
end
|
|
|
|
if spellID and auraName and auraName ~= "" then GLOBAL_CC_NEGATIVE[spellID] = auraName end
|
|
return nil
|
|
end
|
|
|
|
-- Reuse the already-read UNIT_AURA snapshot for Tank Mode. This does not
|
|
-- depend on aura display settings and never triggers an additional API scan.
|
|
function BNP:CacheTankControlSnapshot(plate, guid, snapshot)
|
|
if not self.IsTankNoTargetEnabled or not self:IsTankNoTargetEnabled()
|
|
or not self:IsTankModeEnabled() then
|
|
plate.BNPTankControlGUID = nil
|
|
plate.BNPTankControlled = nil
|
|
plate.BNPTankChargeStun = nil
|
|
return
|
|
end
|
|
local controlled, chargeStun = false, false
|
|
local i
|
|
for i = 1, snapshot.count do
|
|
local entry = snapshot.entries[i]
|
|
local def = ResolveGlobalCCDef(entry[4], entry[10])
|
|
if def and self.TankDisablingCC and self.TankDisablingCC[def.key] then
|
|
controlled = true
|
|
-- The applied Charge stun can arrive before the combat flag. It is
|
|
-- itself evidence of an opening attack; a cast attempt alone is not.
|
|
if def.key == "charge_stun" then chargeStun = true; break end
|
|
end
|
|
end
|
|
plate.BNPTankControlGUID = guid
|
|
plate.BNPTankControlled = controlled
|
|
plate.BNPTankChargeStun = chargeStun
|
|
end
|
|
|
|
-- One initial read when enabling the option on an already visible plate.
|
|
-- Subsequent updates come from the existing UNIT_AURA watcher only.
|
|
function BNP:PrimeTankControlSnapshot(plate, guid, now)
|
|
local token = plate.BNPClassicUnitToken
|
|
if not token or not self._ClassicGetNamePlateForUnit or not self._ClassicUnitGUID then return end
|
|
if self._ClassicGetNamePlateForUnit(token) ~= plate or self._ClassicUnitGUID(token) ~= guid then return end
|
|
self:CacheTankControlSnapshot(plate, guid, CaptureLiveDebuffSnapshot(token, now))
|
|
end
|
|
|
|
local function WantsForeignCCs()
|
|
return BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled()
|
|
and BNP.ShowOtherPlayersCCs and BNP:ShowOtherPlayersCCs()
|
|
end
|
|
|
|
-- "Show CCs from Other Players" must only show a foreign CC when ClassicAPI
|
|
-- gives us a caster we can positively identify as player-controlled. Treating
|
|
-- missing/unresolved source metadata as "probably a player" can turn unrelated
|
|
-- NPC auras into ghost CC icons (for example a Paladin-looking seal icon with no
|
|
-- Paladin present). Own player/pet CCs are admitted separately below.
|
|
function BNP:_IsAllowedForeignCCSource(source)
|
|
if not source or source == "" then return false end
|
|
if self:_IsOwnClassicAuraSource(source) then return false end
|
|
|
|
if not UnitExists or not UnitExists(source) then return false end
|
|
if not UnitPlayerControlled then return false end
|
|
return UnitPlayerControlled(source) and true or false
|
|
end
|
|
|
|
-- Actual live CCs can be triggered by a pet or proc without a matching
|
|
-- player CAST. Reuse the same CC scan/snapshot, including for own effects.
|
|
function BNP:WantsLiveCCs()
|
|
return self.AreCrowdControlEnabled and self:AreCrowdControlEnabled()
|
|
end
|
|
|
|
function BNP:_AppendLiveCCs(guid, active, pool, count, now)
|
|
if not self:WantsLiveCCs() then return count end
|
|
local live = guid and self.guidLiveCCs[guid]
|
|
if not live then return count end
|
|
local foreignEnabled = WantsForeignCCs()
|
|
local key, aura
|
|
for key, aura in pairs(live) do
|
|
if count >= MAX_VISIBLE_ICONS then break end
|
|
local fresh = aura.eventDriven or now - (aura.lastSeen or 0) <= 0.60
|
|
if aura.def and fresh and (aura.isOwn or foreignEnabled) then
|
|
local duplicate = false
|
|
local i
|
|
for i = 1, count do
|
|
if active[i].def and active[i].def.key == key then duplicate = true; break end
|
|
end
|
|
if not duplicate 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
|
|
entry.svRenderRevision = nil
|
|
end
|
|
end
|
|
end
|
|
return count
|
|
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 BNP:WantsLiveCCs() 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, liveDuration, liveExpires, source, auraName
|
|
if snapshot then
|
|
local entry = snapshot.entries[i]
|
|
if not entry then break end
|
|
texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source, auraName =
|
|
entry[1], entry[2], entry[3], entry[4], entry[5], entry[6], entry[7], entry[10]
|
|
else
|
|
local isStealable, nameplateShowPersonal
|
|
texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source,
|
|
isStealable, nameplateShowPersonal, auraName = BNP:_ReadClassicDebuff(unit, i)
|
|
if not texture then break end
|
|
end
|
|
|
|
-- Most harmful auras are not CCs. Resolve identity first, so source/GUID
|
|
-- checks are only needed for the few effects that can actually be shown.
|
|
local ccDef = ResolveGlobalCCDef(auraSpellID, auraName)
|
|
local ownSource = false
|
|
if ccDef then
|
|
ownSource = BNP:_IsOwnClassicAuraSource(source)
|
|
if not ownSource and not (WantsForeignCCs() and BNP:_IsAllowedForeignCCSource(source)) then
|
|
ccDef = nil
|
|
end
|
|
end
|
|
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.isOwn = ownSource and true or false
|
|
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.
|
|
liveExpires = tonumber(liveExpires)
|
|
liveDuration = tonumber(liveDuration)
|
|
aura.duration = (liveDuration and liveDuration > 0 and liveDuration) or ccDef.duration
|
|
if liveExpires and liveExpires > now then
|
|
aura.expires = liveExpires
|
|
elseif not aura.expires and aura.duration then
|
|
aura.expires = now + aura.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
|
|
|
|
-- Reuse the live list for own triggered/pet CCs and enabled foreign CCs.
|
|
count = BNP:_AppendLiveCCs(guid, active, pool, count, now)
|
|
|
|
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)
|
|
BNP:ApplyAuraBorderColor(icon)
|
|
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, liveDuration, liveExpires, source
|
|
if snapshot then
|
|
local entry = snapshot.entries[i]
|
|
if not entry then break end
|
|
texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, source =
|
|
entry[1], entry[2], entry[3], entry[4], entry[5], entry[6], entry[7]
|
|
else
|
|
texture, stacks, dtype, auraSpellID, liveDuration, liveExpires, 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.
|
|
local knownForeign = BNP:_IsKnownForeignClassicAuraSource(source)
|
|
if not knownForeign 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
|
|
|
|
-- ClassicAPI is authoritative for the live expiration of our aura.
|
|
-- Some talents/procs (for example Turtle WoW Druid Carnage) renew an
|
|
-- existing bleed without another Rake/Rip cast. Previously BNP only
|
|
-- updated stacks here, so the cached cast timer expired even though
|
|
-- the real aura had already received a new expirationTime.
|
|
--
|
|
-- Never extend from a known foreign caster. Unknown source remains a
|
|
-- valid fallback because some custom-client packets omit ownership.
|
|
local apiDuration = tonumber(liveDuration)
|
|
local apiExpires = tonumber(liveExpires)
|
|
if not knownForeign and apiExpires and apiExpires > now then
|
|
local oldExpires = tonumber(trackedAura.expires)
|
|
if not oldExpires or math.abs(apiExpires - oldExpires) > 0.05 then
|
|
if apiDuration and apiDuration > 0 then
|
|
trackedAura.duration = apiDuration
|
|
end
|
|
trackedAura.expires = apiExpires
|
|
trackedAura.classicAPITiming = true
|
|
trackedAura.liveConfirmed = true
|
|
trackedAura.missingScans = nil
|
|
trackedAura.exactMissingScans = nil
|
|
RaidTrace("LIVE_REFRESH", guid, trackedKey, auraSpellID)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
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
|
|
|
|
now = now or GetTime()
|
|
-- A reused plate may acquire its level width after OnShow, or have a native
|
|
-- anchor rewritten by the client. Check just 2.5 times/sec per visible plate,
|
|
-- not during every fast aura/icon update.
|
|
if now >= (plate.BNPNextAuraAnchorCheck or 0) then
|
|
plate.BNPNextAuraAnchorCheck = now + 0.40
|
|
BNP:VerifyAuraContainerAnchor(plate, container, false)
|
|
BNP:VerifyAuraContainerAnchor(plate, plate.BNPCCContainer, true)
|
|
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
|
|
|
|
-- Shared-row layouts must include the same live CCs as the separate row.
|
|
-- Previously other-player CCs disappeared entirely in a shared row.
|
|
if not UseDedicatedCCContainer() then
|
|
activeCount = BNP:_AppendLiveCCs(guid, active, activePool, activeCount, now)
|
|
end
|
|
|
|
-- Selected non-CC live debuffs from other players/classes are appended
|
|
-- after the local player's own entries. Crowd Control is handled exclusively
|
|
-- by BNP's dedicated CC tracker and never enters this list.
|
|
if BNP:WantsOtherDebuffs() and guid then
|
|
local otherLive = BNP.guidOtherDebuffs and BNP.guidOtherDebuffs[guid]
|
|
local classOrder = BNP.OtherDebuffClassOrder or {}
|
|
local classIndex, classKey, defs, defIndex, otherDef, otherAura
|
|
for classIndex = 1, table.getn(classOrder) do
|
|
if activeCount >= MAX_VISIBLE_ICONS then break end
|
|
classKey = classOrder[classIndex]
|
|
defs = BNP.OtherDebuffDefs and BNP.OtherDebuffDefs[classKey] or nil
|
|
if defs then
|
|
for defIndex = 1, table.getn(defs) do
|
|
if activeCount >= MAX_VISIBLE_ICONS then break end
|
|
otherDef = defs[defIndex]
|
|
if BNP:IsOtherDebuffDefSelected(otherDef) then
|
|
otherAura = otherLive and otherLive[otherDef.key] or nil
|
|
if otherAura and otherAura.livePresence
|
|
and not BNP:ActiveAuraHasSpellID(active, activeCount, otherAura.spellID) then
|
|
activeCount = activeCount + 1
|
|
local entry = activePool[activeCount]
|
|
active[activeCount] = entry
|
|
entry.def = otherDef
|
|
entry.aura = otherAura
|
|
local otherRemaining = otherAura.expires and (otherAura.expires - now) or nil
|
|
if otherRemaining and otherRemaining <= 0 then otherRemaining = nil end
|
|
entry.remaining = otherRemaining
|
|
entry.svRenderRevision = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if activeCount > 1 then
|
|
table.sort(active, BNP._AuraEntryOrderLess)
|
|
end
|
|
|
|
local visibleCount = activeCount
|
|
local iconSize = GetIconSize()
|
|
local ccIconSize = GetCCIconSize()
|
|
local position = GetDebuffPosition()
|
|
local layoutMask = 0
|
|
local maskValue = 1
|
|
for i = 1, visibleCount do
|
|
if active[i] and IsCrowdControlDef(active[i].def) then
|
|
layoutMask = layoutMask + maskValue
|
|
end
|
|
maskValue = maskValue * 2
|
|
end
|
|
|
|
local layoutChanged = container.BNPLastVisibleCount ~= visibleCount
|
|
or container.BNPLastIconSize ~= iconSize
|
|
or container.BNPLastCCIconSize ~= ccIconSize
|
|
or container.BNPLastPosition ~= position
|
|
or container.BNPLastLayoutMask ~= layoutMask
|
|
if layoutChanged then
|
|
container.BNPLastVisibleCount = visibleCount
|
|
container.BNPLastIconSize = iconSize
|
|
container.BNPLastCCIconSize = ccIconSize
|
|
container.BNPLastPosition = position
|
|
container.BNPLastLayoutMask = layoutMask
|
|
if visibleCount > 0 then
|
|
-- CCs may share the normal row for Left/Right (and optionally Top), so
|
|
-- lay out each icon using its own configured size without touching tracking.
|
|
LayoutActiveIcons(container, active, visibleCount, false)
|
|
end
|
|
end
|
|
|
|
for i = 1, table.getn(container.icons) do
|
|
local icon = container.icons[i]
|
|
local entry = active[i]
|
|
|
|
if entry then
|
|
local texture = entry.aura.texture or entry.def.texture
|
|
if icon.BNPLastTexture ~= texture then
|
|
icon.texture:SetTexture(texture)
|
|
icon.BNPLastTexture = texture
|
|
BNP:ApplyAuraBorderColor(icon)
|
|
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)
|
|
self:CacheTankControlSnapshot(plate, guid, snapshot)
|
|
|
|
-- Pending own casts and AoE applications consume the same snapshot instead
|
|
-- of re-walking the aura descriptor list.
|
|
ConfirmPendingForUnit(token, guid, now, snapshot)
|
|
if HasPendingAoEAuras() then
|
|
ConfirmAoEAurasOnUnit(token, guid, snapshot)
|
|
end
|
|
|
|
-- Shared effects, early removals and optional foreign CC all reconcile from
|
|
-- this exact event snapshot. eventAuthoritative=true removes the old
|
|
-- multi-miss protection that was only required for projected SuperWoW units.
|
|
SyncSharedAuras(token, guid, now, snapshot, true)
|
|
BNP:SyncOtherDebuffs(token, guid, now, snapshot, true)
|
|
if BNP.guidAuras[guid] then
|
|
SyncAuraRemoval(token, guid, now, snapshot, GetTargetGUID(), true)
|
|
end
|
|
if self:WantsLiveCCs() then
|
|
SyncForeignCCs(token, guid, now, snapshot, true)
|
|
elseif BNP.guidLiveCCs then
|
|
BNP.guidLiveCCs[guid] = nil
|
|
end
|
|
|
|
plate.BNPClassicAuraLastEvent = now
|
|
UpdatePlate(plate, now)
|
|
-- Reflect confirmed CC appearance/removal in this event, without waiting
|
|
-- for the 0.10s nameplate poller. The snapshot above is already cached.
|
|
if self.UpdateTankModePlate and self.IsTankModeEnabled and self:IsTankModeEnabled()
|
|
and self:IsTankNoTargetEnabled() then
|
|
self:UpdateTankModePlate(plate)
|
|
end
|
|
|
|
-- 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)
|
|
-- The existing-container fast paths above intentionally do not create new
|
|
-- frames. They must still reset the attachment and icon-layout cache whenever
|
|
-- Blizzard reuses a nameplate for another mob (even with the same aura count).
|
|
table.insert(BNP.libnameplate.OnShow, function(plate)
|
|
if not plate then return end
|
|
BNP:RefreshAuraLayoutForPlate(plate)
|
|
BNP:RefreshAuraLayout(plate)
|
|
plate.BNPNextAuraAnchorCheck = nil
|
|
end)
|
|
|
|
-- Keep the proven central update loop. Optional CooldownFrames are created
|
|
-- lazily per used icon only when Cooldown Spiral is enabled.
|
|
local renderer = CreateFrame("Frame")
|
|
local elapsedTotal = 0
|
|
local removalElapsed = 0
|
|
local removalScanBucket = 0
|
|
local nextRemovalBucket = 0
|
|
|
|
local function GetRemovalScanBucket(plate)
|
|
if plate.BNPAuraRemovalScanBucket == nil then
|
|
plate.BNPAuraRemovalScanBucket = nextRemovalBucket
|
|
nextRemovalBucket = 1 - nextRemovalBucket
|
|
end
|
|
return plate.BNPAuraRemovalScanBucket
|
|
end
|
|
renderer:SetScript("OnUpdate", function()
|
|
elapsedTotal = elapsedTotal + arg1
|
|
removalElapsed = removalElapsed + arg1
|
|
if elapsedTotal < UPDATE_INTERVAL then return end
|
|
elapsedTotal = 0
|
|
|
|
local now = GetTime()
|
|
UpdateDarkHarvest(now)
|
|
RefreshTargetPriorityIdentity()
|
|
|
|
CleanupAuraCastAttempts(now)
|
|
|
|
local hasPending = HasPendingAuras()
|
|
local hasPendingAoE = HasPendingAoEAuras()
|
|
local doTargetRemovalScan = removalElapsed >= REMOVAL_SCAN_INTERVAL
|
|
if doTargetRemovalScan then
|
|
removalElapsed = 0
|
|
-- If the target has a live ClassicAPI nameplate watcher, UNIT_AURA already
|
|
-- performs exact removal/shared synchronization. Do not duplicate that
|
|
-- destructive scan every 0.10s.
|
|
if BNP._ClassicGetNamePlateForUnit then
|
|
local exactTargetPlate = BNP._ClassicGetNamePlateForUnit("target")
|
|
if exactTargetPlate and exactTargetPlate.BNPClassicAuraEventDriven then
|
|
doTargetRemovalScan = false
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Non-target destructive scans used to hit every visible plate in one 0.10s
|
|
-- burst. Alternate two buckets on the existing 0.05s renderer tick instead:
|
|
-- every individual plate keeps the same ~0.10s cadence, but CPU/UnitDebuff
|
|
-- work is spread across two frames to reduce micro-stutter.
|
|
removalScanBucket = 1 - removalScanBucket
|
|
|
|
local targetGUID = GetTargetGUID()
|
|
|
|
local targetSuppressed = targetGUID and HarmfulAuraSuppressed(targetGUID, now)
|
|
if targetSuppressed then
|
|
ClearHarmfulAuraStateOnly(targetGUID)
|
|
end
|
|
|
|
if hasPending and targetGUID and not targetSuppressed then
|
|
-- Current target is the most reliable confirmation source.
|
|
ConfirmPendingForUnit("target", targetGUID, now)
|
|
end
|
|
|
|
if targetGUID and not targetSuppressed then
|
|
CleanupDeadUnit("target", targetGUID, now)
|
|
end
|
|
|
|
if doTargetRemovalScan and targetGUID and not targetSuppressed then
|
|
local targetSnapshot = nil
|
|
if HAS_SHARED_AURAS or (BNP.WantsOtherDebuffs and BNP:WantsOtherDebuffs()) or CacheHasTrackedAuras(BNP.guidAuras[targetGUID]) then
|
|
targetSnapshot = CaptureLiveDebuffSnapshot("target", now)
|
|
end
|
|
|
|
SyncSharedAuras("target", targetGUID, now, targetSnapshot)
|
|
BNP:SyncOtherDebuffs("target", targetGUID, now, targetSnapshot)
|
|
|
|
if BNP.guidAuras[targetGUID] then
|
|
SyncAuraRemoval("target", targetGUID, now, targetSnapshot, targetGUID)
|
|
end
|
|
end
|
|
|
|
-- The normal renderer always runs. Extra GUID/token/UnitDebuff work is only
|
|
-- performed while pending casts exist or during the throttled removal scan.
|
|
for plate in pairs(BNP.plates) do
|
|
local plateRemovalDue = plate:IsShown() and not plate.BNPClassicAuraEventDriven
|
|
and GetRemovalScanBucket(plate) == removalScanBucket
|
|
if plate:IsShown() and (hasPending or hasPendingAoE or plateRemovalDue) then
|
|
local guid = GetPlateGUID(plate)
|
|
local token = GetUnitTokenForPlate(plate)
|
|
|
|
if guid and token then
|
|
local guidSuppressed = HarmfulAuraSuppressed(guid, now)
|
|
if guidSuppressed then
|
|
ClearHarmfulAuraStateOnly(guid)
|
|
end
|
|
|
|
-- Do NOT call UnitIsDeadOrGhost() on SuperWoW nameplate tokens.
|
|
-- During target changes/recycling those tokens can briefly report an
|
|
-- invalid dead state and wipe the entire GUID aura cache.
|
|
local isCurrentTarget = targetGUID and targetGUID == guid
|
|
|
|
-- The real "target" unit was already confirmed above and is more
|
|
-- authoritative than its projected nameplate token. Avoid duplicate
|
|
-- confirmation work for that same GUID.
|
|
if hasPending and not isCurrentTarget and not guidSuppressed then
|
|
ConfirmPendingForUnit(token, guid, now)
|
|
end
|
|
|
|
if hasPendingAoE and not guidSuppressed then
|
|
ConfirmAoEAurasOnUnit(token, guid)
|
|
end
|
|
|
|
if plateRemovalDue and not isCurrentTarget and not guidSuppressed then
|
|
-- Removal/shared scans are destructive and therefore must never use
|
|
-- a raw projected GUID while the renderer still considers the plate
|
|
-- identity unstable. This is the key raid-stack safeguard: one stale
|
|
-- SuperWoW token can no longer remove auras from the wrong GUID.
|
|
local stableGUID = plate.BNPAuraGUIDStable
|
|
local identityTrusted = stableGUID and stableGUID == guid
|
|
if identityTrusted then
|
|
local snapshot = nil
|
|
if HAS_SHARED_AURAS or (BNP.WantsOtherDebuffs and BNP:WantsOtherDebuffs()) or CacheHasTrackedAuras(BNP.guidAuras[stableGUID]) then
|
|
snapshot = CaptureLiveDebuffSnapshot(token, now)
|
|
end
|
|
|
|
SyncSharedAuras(token, stableGUID, now, snapshot)
|
|
BNP:SyncOtherDebuffs(token, stableGUID, now, snapshot)
|
|
|
|
-- Non-target exact-removal also requires multiple misses below, so
|
|
-- crowded raid aura lists cannot make Banish/CC blink.
|
|
SyncAuraRemoval(token, stableGUID, now, snapshot, targetGUID)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
UpdatePlate(plate, now)
|
|
end
|
|
|
|
if hasPending then
|
|
CleanupPending(now)
|
|
end
|
|
|
|
if hasPendingAoE then
|
|
CleanupPendingAoE(now)
|
|
end
|
|
end)
|
|
|
|
|
|
-- Foreign CC scanner ---------------------------------------------------------
|
|
-- Runs on its own frame so this optional feature can never interrupt the proven
|
|
-- own-debuff renderer/update loop.
|
|
local FOREIGN_CC_SCAN_INTERVAL = 0.15
|
|
local FOREIGN_CC_BUCKET_INTERVAL = FOREIGN_CC_SCAN_INTERVAL / 2
|
|
local foreignCCElapsed = 0
|
|
local foreignCCTargetElapsed = 0
|
|
local foreignCCScanBucket = 0
|
|
local foreignCCNextBucket = 0
|
|
|
|
local function GetForeignCCScanBucket(plate)
|
|
if plate.BNPForeignCCScanBucket == nil then
|
|
plate.BNPForeignCCScanBucket = foreignCCNextBucket
|
|
foreignCCNextBucket = 1 - foreignCCNextBucket
|
|
end
|
|
return plate.BNPForeignCCScanBucket
|
|
end
|
|
|
|
local foreignCCScanner = CreateFrame("Frame")
|
|
foreignCCScanner:SetScript("OnUpdate", function()
|
|
if not BNP:WantsLiveCCs() 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
|