1443 lines
44 KiB
Lua
1443 lines
44 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 ICON_OFFSET_Y = UI.ICON_OFFSET_Y or 15
|
|
local ICON_SPACING = UI.ICON_SPACING or 2
|
|
local UPDATE_INTERVAL = 0.05
|
|
|
|
-- 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.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 MAX_VISIBLE_ICONS = 8
|
|
|
|
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(40)
|
|
|
|
local texture = icon:CreateTexture(nil, "ARTWORK")
|
|
texture:SetAllPoints(icon)
|
|
icon.texture = texture
|
|
|
|
local timer = icon:CreateFontString(nil, "OVERLAY")
|
|
timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", UI.TIMER_SIZE or 8, "OUTLINE")
|
|
timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0)
|
|
timer:SetTextColor(1, 1, 1)
|
|
icon.timer = timer
|
|
|
|
local stack = icon:CreateFontString(nil, "OVERLAY")
|
|
stack:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", 8, "OUTLINE")
|
|
stack:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1)
|
|
stack:SetTextColor(1, 1, 1)
|
|
stack:SetText("")
|
|
icon.stack = stack
|
|
|
|
icon.index = index
|
|
icon:Hide()
|
|
|
|
return icon
|
|
end
|
|
|
|
local function 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, plate)
|
|
container:SetWidth(width)
|
|
container:SetHeight(size)
|
|
container:SetFrameLevel(40)
|
|
|
|
if plate.healthbar then
|
|
container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, ICON_OFFSET_Y)
|
|
else
|
|
container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24)
|
|
end
|
|
|
|
container.icons = {}
|
|
for i = 1, MAX_VISIBLE_ICONS do
|
|
local icon = CreateAuraIcon(container, i)
|
|
if i == 1 then
|
|
icon:SetPoint("LEFT", container, "LEFT", 0, 0)
|
|
else
|
|
icon:SetPoint("LEFT", container.icons[i - 1], "RIGHT", ICON_SPACING, 0)
|
|
end
|
|
container.icons[i] = icon
|
|
end
|
|
|
|
container:Hide()
|
|
plate.BNPAuraContainer = container
|
|
end
|
|
|
|
local function GetPlateGUID(plate)
|
|
if not plate or not plate.GetName then return nil end
|
|
|
|
-- SuperWoW stores the unit GUID/token directly on the nameplate name slot.
|
|
-- Use that exact token, just like Tank Mode and ShaguTweaks do. This avoids
|
|
-- unreliable UnitExists() resolution and, importantly, avoids same-name
|
|
-- fallback collisions between two mobs with identical names.
|
|
local token = plate:GetName(1)
|
|
if token and token ~= "" then return token end
|
|
return nil
|
|
end
|
|
|
|
local function GetTargetGUID()
|
|
local exists, guid = UnitExists("target")
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
local function ClearGUIDAuraState(guid)
|
|
if not guid then return end
|
|
BNP.guidAuras[guid] = nil
|
|
if BNP.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
|
|
|
|
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
|
|
|
|
-- Death chat only gives us a unit name, not the exact GUID.
|
|
-- If multiple living/cached units share that same name, clearing all of them
|
|
-- would incorrectly remove valid debuffs from the survivors.
|
|
local matchGUID = nil
|
|
local matches = 0
|
|
local guid, knownName
|
|
|
|
for guid, knownName in pairs(BNP.guidNames or {}) do
|
|
if knownName == name and BNP.guidAuras[guid] then
|
|
matches = matches + 1
|
|
matchGUID = guid
|
|
|
|
if matches > 1 then
|
|
-- Ambiguous same-name death: do nothing here.
|
|
-- Exact GUID death cleanup will handle units we can identify safely.
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
if matches == 1 and matchGUID then
|
|
ClearGUIDAuraState(matchGUID)
|
|
end
|
|
end
|
|
|
|
local deathChatFrame = CreateFrame("Frame")
|
|
deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
|
|
deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
|
|
deathChatFrame:SetScript("OnEvent", function()
|
|
local raw = arg1
|
|
if not raw then return end
|
|
local _, _, deadName = string.find(raw, "^(.+) dies%.$")
|
|
if deadName then ClearAuraStateByName(deadName) end
|
|
end)
|
|
|
|
-- Only successful casts by the local player are allowed to create aura
|
|
-- entries. This prevents target scans from importing debuffs cast by other
|
|
-- players. SuperWoW supplies caster GUID, target GUID and spell ID.
|
|
local function GetPlayerGUID()
|
|
local exists, guid = UnitExists("player")
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
local function DefHasSpellID(def, spellID)
|
|
if not def or not spellID then return false end
|
|
|
|
local i
|
|
if def.spellIDs then
|
|
for i = 1, table.getn(def.spellIDs) do
|
|
if def.spellIDs[i] == spellID then return true end
|
|
end
|
|
end
|
|
|
|
if def.durations and def.durations[spellID] then return true end
|
|
return false
|
|
end
|
|
|
|
local function FindAuraDef(spellID)
|
|
if not spellID or not SpellInfo then return nil, nil end
|
|
local spellName, _, texture = SpellInfo(spellID)
|
|
if not spellName and not texture then return nil, nil end
|
|
|
|
-- Exact IDs are authoritative and must win over shared spell textures.
|
|
local _, def
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if DefHasSpellID(def, spellID) then
|
|
return def, texture, spellID
|
|
end
|
|
end
|
|
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if NameMatches(def, spellName) then
|
|
return def, texture, spellID
|
|
end
|
|
end
|
|
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then
|
|
return def, texture, spellID
|
|
end
|
|
end
|
|
|
|
return nil, nil
|
|
end
|
|
|
|
|
|
-- Paladin Judgements --------------------------------------------------------
|
|
-- SuperWoW reports the button press as Judgement [20271], while the target
|
|
-- receives a different aura ID (e.g. Light 20344, Justice 20184, Crusader
|
|
-- 20302, Wisdom 20353). Therefore normal CAST-ID == AURA-ID confirmation
|
|
-- cannot work for Judgements.
|
|
local PALADIN_JUDGEMENT_CAST_ID = 20271
|
|
|
|
local function IsJudgementDef(def)
|
|
if not def or not def.key then return false end
|
|
return string.find(def.key, "^judgement_") and true or false
|
|
end
|
|
|
|
local function FindJudgementAuraOnUnit(unit)
|
|
if playerClass ~= "PALADIN" or not unit then return nil end
|
|
|
|
local i, texture, stacks, dtype, auraSpellID
|
|
for i = 1, 32 do
|
|
texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i)
|
|
if not texture then break end
|
|
|
|
if auraSpellID then
|
|
local auraName = nil
|
|
if SpellInfo then auraName = SpellInfo(auraSpellID) end
|
|
|
|
local _, def
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if IsJudgementDef(def) then
|
|
local explicitMatch = false
|
|
local j
|
|
if def.spellIDs then
|
|
for j = 1, table.getn(def.spellIDs) do
|
|
if def.spellIDs[j] == auraSpellID then
|
|
explicitMatch = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if explicitMatch or NameMatches(def, auraName) then
|
|
return def, auraSpellID, texture
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
|
|
-- Turtle WoW Dark Harvest (spell 52552).
|
|
-- It is reported by SuperWoW as CHANNEL; arg5 is channel length in ms.
|
|
-- A 30% shorter tick interval means aura time advances at 1/0.70 speed.
|
|
local DARK_HARVEST_SPELL_ID = 52552
|
|
local DARK_HARVEST_SPEED = 1 / 0.70
|
|
local DARK_HARVEST_AURAS = {
|
|
corruption=true, curse_of_agony=true, siphon_life=true,
|
|
curse_of_doom=true, drain_life=true, drain_soul=true,
|
|
}
|
|
BNP.darkHarvest = nil
|
|
|
|
local function StartDarkHarvest(guid, durationMS)
|
|
local now=GetTime()
|
|
local duration=tonumber(durationMS) or 8000
|
|
if duration > 100 then duration=duration/1000 end
|
|
BNP.darkHarvest={guid=guid, lastUpdate=now, ends=now+duration}
|
|
end
|
|
|
|
local function UpdateDarkHarvest(now)
|
|
local st=BNP.darkHarvest
|
|
if not st then return end
|
|
local untilTime=now
|
|
if untilTime > st.ends then untilTime=st.ends end
|
|
local delta=untilTime-st.lastUpdate
|
|
if delta > 0 then
|
|
local cache=BNP.guidAuras[st.guid]
|
|
if cache then
|
|
local bonus=delta*(DARK_HARVEST_SPEED-1)
|
|
local key,aura
|
|
for key,aura in pairs(cache) do
|
|
if DARK_HARVEST_AURAS[key] and type(aura)=="table" and aura.expires and aura.expires > st.lastUpdate then
|
|
aura.expires=aura.expires-bonus
|
|
end
|
|
end
|
|
end
|
|
st.lastUpdate=untilTime
|
|
end
|
|
if now >= st.ends then BNP.darkHarvest=nil end
|
|
end
|
|
|
|
|
|
-- Cast confirmation ---------------------------------------------------------
|
|
-- UNIT_CASTEVENT "CAST" means the player finished the cast, but it does not
|
|
-- guarantee that the hostile aura actually landed. Resist/Miss/Immune must
|
|
-- therefore never create a visible timer.
|
|
--
|
|
-- We first remember the cast as "pending". It becomes a real aura only after
|
|
-- UnitDebuff confirms the matching aura on that exact SuperWoW GUID.
|
|
-- Never infer success from CAST alone: melee abilities can still miss/dodge,
|
|
-- and hostile targets may resist or be immune after the cast event.
|
|
BNP.pendingAuras = BNP.pendingAuras or {}
|
|
BNP.pendingAoEAuras = BNP.pendingAoEAuras or {}
|
|
|
|
-- Pending failures are matched by exact SuperWoW UNIT_CASTEVENT data.
|
|
-- Never use global combat-text "miss/resist" lines here: in a raid those can
|
|
-- belong to another spell and previously caused unrelated pending DoTs to die.
|
|
|
|
local function FailPendingAura(targetGUID, spellID)
|
|
if not targetGUID or not spellID then return end
|
|
local entries = BNP.pendingAuras[targetGUID]
|
|
if not entries then return end
|
|
|
|
local def = FindAuraDef(spellID)
|
|
if def and entries[def.key] then
|
|
entries[def.key] = nil
|
|
end
|
|
|
|
-- Generic Paladin Judgement failure.
|
|
if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then
|
|
entries.__judgement = nil
|
|
end
|
|
end
|
|
|
|
local PENDING_TIMEOUT = 4.0
|
|
|
|
local function HasPendingAuras()
|
|
local guid, entries, key, pending
|
|
for guid, entries in pairs(BNP.pendingAuras) do
|
|
for key, pending in pairs(entries) do
|
|
if type(pending) == "table" then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function GetUnitTokenForPlate(plate)
|
|
if not plate or not plate.GetName then return nil end
|
|
return plate:GetName(1)
|
|
end
|
|
|
|
local function FindMatchingAuraOnUnit(unit, def, castSpellID)
|
|
if not unit or not def then return nil, nil, nil end
|
|
|
|
local i
|
|
for i = 1, 32 do
|
|
local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i)
|
|
if not texture then break end
|
|
|
|
if auraSpellID then
|
|
if auraSpellID == castSpellID or DefHasSpellID(def, auraSpellID) then
|
|
return auraSpellID, texture, tonumber(stacks) or 0
|
|
end
|
|
|
|
local auraName = SpellInfo and SpellInfo(auraSpellID) or nil
|
|
if NameMatches(def, auraName) then
|
|
return auraSpellID, texture, tonumber(stacks) or 0
|
|
end
|
|
end
|
|
|
|
if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then
|
|
return auraSpellID or castSpellID, texture, tonumber(stacks) or 0
|
|
end
|
|
end
|
|
|
|
return nil, nil, nil
|
|
end
|
|
|
|
local function CommitPendingAura(guid, key, pending)
|
|
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
|
|
|
|
-- Only replace mutually-exclusive curses after the new curse is confirmed
|
|
-- on the target. A resisted curse must never remove the existing one.
|
|
if def.category == "CURSE" and def.curseExclusive ~= false then
|
|
local _, oldDef
|
|
for _, oldDef in ipairs(AURA_DEFS) do
|
|
if oldDef.category == "CURSE" and oldDef.curseExclusive ~= false then
|
|
cache[oldDef.key] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
cache._nextOrder = (cache._nextOrder or 0) + 1
|
|
local aura = cache[key] or {}
|
|
aura.order = cache._nextOrder
|
|
aura.duration = pending.duration
|
|
aura.expires = GetTime() + pending.duration
|
|
aura.spellID = pending.spellID
|
|
aura.texture = pending.texture or def.texture
|
|
aura.stacks = tonumber(pending.stacks) or 0
|
|
aura.confirmedAt = GetTime()
|
|
aura.missingScans = nil
|
|
cache[key] = aura
|
|
end
|
|
|
|
local function ConfirmPendingForUnit(unit, guid, now)
|
|
if not unit or not guid then return end
|
|
local pendingForGUID = BNP.pendingAuras[guid]
|
|
if not pendingForGUID then return end
|
|
|
|
local key, pending
|
|
for key, pending in pairs(pendingForGUID) do
|
|
if type(pending) == "table" then
|
|
if pending.judgementProbe then
|
|
local def, auraSpellID, texture = FindJudgementAuraOnUnit(unit)
|
|
|
|
if def and auraSpellID then
|
|
local duration = def.duration
|
|
if BNP.GetSpellbookDurationForDef then
|
|
duration = BNP:GetSpellbookDurationForDef(def) or duration
|
|
end
|
|
|
|
local confirmed = {
|
|
def = def,
|
|
spellID = auraSpellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = pending.created,
|
|
}
|
|
|
|
CommitPendingAura(guid, def.key, confirmed)
|
|
pendingForGUID[key] = nil
|
|
elseif now - pending.created > PENDING_TIMEOUT then
|
|
-- No Judgement aura appeared: resist, miss, immune or failed cast.
|
|
pendingForGUID[key] = nil
|
|
end
|
|
else
|
|
local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID)
|
|
if auraSpellID then
|
|
local confirmed = {
|
|
def = pending.def,
|
|
spellID = auraSpellID,
|
|
texture = auraTexture or pending.texture,
|
|
duration = pending.duration,
|
|
created = pending.created,
|
|
stacks = auraStacks or 0,
|
|
}
|
|
CommitPendingAura(guid, key, confirmed)
|
|
pendingForGUID[key] = nil
|
|
elseif now - pending.created > PENDING_TIMEOUT then
|
|
-- Strict confirmation only:
|
|
-- A completed cast is NOT proof that a hostile aura landed.
|
|
-- Dodge, miss, resist, immune and evade can all occur after CAST.
|
|
-- If the exact aura never appears on this GUID, never create a timer.
|
|
pendingForGUID[key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function CleanupPending(now)
|
|
local guid, entries, key, pending
|
|
for guid, entries in pairs(BNP.pendingAuras) do
|
|
local any = false
|
|
for key, pending in pairs(entries) do
|
|
if type(pending) == "table" then
|
|
if now - pending.created > PENDING_TIMEOUT then
|
|
entries[key] = nil
|
|
else
|
|
any = true
|
|
end
|
|
end
|
|
end
|
|
if not any then BNP.pendingAuras[guid] = nil end
|
|
end
|
|
end
|
|
|
|
|
|
local AOE_PENDING_TIMEOUT = 1.5
|
|
|
|
local function QueueAoEAura(def, spellID, texture, duration)
|
|
if not def or not spellID then return end
|
|
BNP.pendingAoEAuras[def.key] = {
|
|
def = def,
|
|
spellID = spellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = GetTime(),
|
|
}
|
|
end
|
|
|
|
local function ConfirmAoEAurasOnUnit(unit, guid)
|
|
if not unit or not guid then return end
|
|
local key, pending
|
|
for key, pending in pairs(BNP.pendingAoEAuras) do
|
|
if type(pending) == "table" then
|
|
local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID)
|
|
if auraSpellID then
|
|
CommitPendingAura(guid, pending.def.key, {
|
|
def = pending.def,
|
|
spellID = auraSpellID,
|
|
texture = auraTexture or pending.texture,
|
|
duration = pending.duration,
|
|
created = pending.created,
|
|
stacks = auraStacks or 0,
|
|
})
|
|
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 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
|
|
FailPendingAura(targetGUID, spellID)
|
|
return
|
|
end
|
|
|
|
-- Dark Harvest is a CHANNEL event, not CAST. Handle only this exact
|
|
-- Turtle spell here so normal aura creation remains CAST-only.
|
|
if eventType == "CHANNEL" and spellID == DARK_HARVEST_SPELL_ID then
|
|
StartDarkHarvest(targetGUID, arg5)
|
|
return
|
|
end
|
|
|
|
if eventType ~= "CAST" 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
|
|
local pendingForGUID = BNP.pendingAuras[targetGUID]
|
|
if not pendingForGUID then
|
|
pendingForGUID = {}
|
|
BNP.pendingAuras[targetGUID] = pendingForGUID
|
|
end
|
|
|
|
pendingForGUID.__judgement = {
|
|
judgementProbe = true,
|
|
created = GetTime(),
|
|
}
|
|
return
|
|
end
|
|
|
|
local def, texture = FindAuraDef(spellID)
|
|
if not def then return end
|
|
|
|
local duration = nil
|
|
if BNP.GetSpellbookDurationForDef then
|
|
duration = BNP:GetSpellbookDurationForDef(def)
|
|
end
|
|
duration = duration or (def.durations and def.durations[spellID]) or def.duration
|
|
|
|
if def.aoe then
|
|
QueueAoEAura(def, spellID, texture, duration)
|
|
return
|
|
end
|
|
|
|
if not targetGUID then return end
|
|
|
|
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(),
|
|
}
|
|
|
|
-- 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 function FindSharedAuraOnUnit(unit, def)
|
|
if not unit or not def then return nil, nil, nil, nil end
|
|
|
|
local i
|
|
for i = 1, 32 do
|
|
local a1, a2, a3, a4, a5, a6, a7, a8, a9, a10 = UnitDebuff(unit, i)
|
|
local texture = a1
|
|
local auraSpellID = a4
|
|
if not texture then break end
|
|
|
|
local auraName = nil
|
|
if auraSpellID and SpellInfo then
|
|
auraName = SpellInfo(auraSpellID)
|
|
end
|
|
|
|
if AuraMatches(def, auraName, texture) then
|
|
-- SuperWoW/extended clients may append duration/expiration data.
|
|
-- Probe plausible numeric return pairs without assuming one exact fork.
|
|
local duration, expires
|
|
local vals = { a5, a6, a7, a8, a9, a10 }
|
|
local n
|
|
for n = 1, table.getn(vals) - 1 do
|
|
local d = vals[n]
|
|
local e = vals[n + 1]
|
|
if type(d) == "number" and type(e) == "number"
|
|
and d > 0 and d <= 3600 and e > GetTime() then
|
|
duration = d
|
|
expires = e
|
|
break
|
|
end
|
|
end
|
|
|
|
return auraSpellID, texture, duration, expires
|
|
end
|
|
end
|
|
|
|
return nil, nil, nil, nil
|
|
end
|
|
|
|
local function SyncSharedAuras(unit, guid, now)
|
|
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 = FindSharedAuraOnUnit(unit, def)
|
|
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
|
|
|
|
if liveExpires and liveExpires > now then
|
|
-- Best case: the client exposes the real remaining duration.
|
|
aura.duration = liveDuration
|
|
aura.expires = liveExpires
|
|
aura.sharedEstimated = nil
|
|
elseif not aura.expires then
|
|
-- Vanilla fallback: the original application time is unavailable.
|
|
-- Start a local countdown when BNP first sees the foreign mark.
|
|
-- This is intentionally marked as estimated and will still be
|
|
-- removed immediately when the real aura disappears.
|
|
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
|
|
cache[def.key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
-- Live aura removal ---------------------------------------------------------
|
|
-- Timers alone are not enough for CC/roots/traps: effects may be dispelled,
|
|
-- broken by damage, removed by trinkets, or otherwise end early.
|
|
--
|
|
-- We NEVER create auras from this scan. It may only remove already-confirmed
|
|
-- own auras from the GUID cache when their exact spell ID is no longer present.
|
|
local REMOVAL_SCAN_INTERVAL = 0.10
|
|
local REMOVAL_GRACE = 0.35
|
|
local NORMAL_REMOVAL_MISSING_SCANS = 4
|
|
|
|
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 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 SyncAuraRemoval(unit, guid, now)
|
|
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
|
|
|
|
local present = {}
|
|
local i
|
|
for i = 1, 32 do
|
|
local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i)
|
|
if not texture then break end
|
|
if auraSpellID then
|
|
present[auraSpellID] = true
|
|
|
|
local trackedKey, trackedAura
|
|
for trackedKey, trackedAura in pairs(cache) do
|
|
if type(trackedAura) == "table" and trackedAura.spellID == auraSpellID then
|
|
trackedAura.stacks = tonumber(stacks) or 0
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local key, aura
|
|
for key, aura in pairs(cache) do
|
|
if type(aura) == "table" and aura.spellID and aura.expires 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 exact live removal on every visible plate
|
|
-- because those effects genuinely need immediate break/dispel detection.
|
|
local playerRemoval = false
|
|
if unit == "target" and UnitIsPlayer and UnitIsPlayer("target") then
|
|
local currentTargetGUID = GetTargetGUID()
|
|
playerRemoval = currentTargetGUID and currentTargetGUID == guid
|
|
end
|
|
|
|
if age >= REMOVAL_GRACE then
|
|
if present[aura.spellID] then
|
|
-- Any positive sighting immediately resets transient-missing state.
|
|
aura.missingScans = nil
|
|
elseif exactRemoval then
|
|
-- CC/roots/traps must disappear immediately when broken/dispelled.
|
|
cache[key] = nil
|
|
elseif playerRemoval then
|
|
-- Normal player DoTs/curses use conservative removal: require
|
|
-- several consecutive missing scans before treating the aura as gone.
|
|
aura.missingScans = (aura.missingScans or 0) + 1
|
|
|
|
if aura.missingScans >= NORMAL_REMOVAL_MISSING_SCANS then
|
|
cache[key] = nil
|
|
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 function GetStablePlateGUID(plate)
|
|
local guid = GetPlateGUID(plate)
|
|
|
|
if not guid then
|
|
plate.BNPAuraGUIDCandidate = nil
|
|
plate.BNPAuraGUIDStable = nil
|
|
return nil
|
|
end
|
|
|
|
if plate.BNPAuraGUIDCandidate ~= guid then
|
|
plate.BNPAuraGUIDCandidate = guid
|
|
plate.BNPAuraGUIDStable = nil
|
|
return nil
|
|
end
|
|
|
|
plate.BNPAuraGUIDStable = guid
|
|
return guid
|
|
end
|
|
|
|
local function UpdatePlate(plate)
|
|
if not plate or not plate:IsShown() then return end
|
|
if not plate.BNPAuraContainer then CreateAuraContainer(plate) end
|
|
|
|
local container = plate.BNPAuraContainer
|
|
if not container then return end
|
|
|
|
local guid = GetStablePlateGUID(plate)
|
|
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
|
|
local cache = guid and BNP.guidAuras[guid] or nil
|
|
local now = GetTime()
|
|
|
|
-- 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
|
|
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
|
|
|
|
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
|
|
end
|
|
elseif cache and aura then
|
|
cache[def.key] = nil
|
|
end
|
|
end
|
|
|
|
table.sort(active, function(a, b)
|
|
return (a.aura.order or 0) < (b.aura.order or 0)
|
|
end)
|
|
|
|
local visibleCount = table.getn(active)
|
|
local iconSize = GetIconSize()
|
|
local maxWidth = iconSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
local rowWidth = 0
|
|
if visibleCount > 0 then
|
|
rowWidth = iconSize * visibleCount + ICON_SPACING * (visibleCount - 1)
|
|
end
|
|
local startX = (maxWidth - rowWidth) / 2
|
|
|
|
for i = 1, table.getn(container.icons) do
|
|
local icon = container.icons[i]
|
|
local entry = active[i]
|
|
|
|
if entry then
|
|
icon:ClearAllPoints()
|
|
icon:SetPoint("LEFT", container, "LEFT", startX + (i - 1) * (iconSize + ICON_SPACING), 0)
|
|
icon.texture:SetTexture(entry.aura.texture or entry.def.texture)
|
|
|
|
local stackCount = tonumber(entry.aura.stacks) or 0
|
|
if icon.stack then
|
|
if stackCount > 1 then
|
|
icon.stack:SetText(tostring(stackCount))
|
|
else
|
|
icon.stack:SetText("")
|
|
end
|
|
end
|
|
|
|
local timerText = FormatTimer(entry.remaining)
|
|
if icon.lastTimerText ~= timerText then
|
|
icon.timer:SetText(timerText)
|
|
icon.lastTimerText = timerText
|
|
end
|
|
|
|
icon:Show()
|
|
else
|
|
icon.lastTimerText = nil
|
|
icon.timer:SetText("")
|
|
if icon.stack then icon.stack:SetText("") end
|
|
icon:Hide()
|
|
end
|
|
end
|
|
|
|
if visibleCount > 0 then
|
|
container:Show()
|
|
else
|
|
container:Hide()
|
|
end
|
|
end
|
|
|
|
|
|
function BNP:RefreshAuraLayout(plate)
|
|
if not plate or not plate.BNPAuraContainer then return end
|
|
|
|
local container = plate.BNPAuraContainer
|
|
local size = GetIconSize()
|
|
local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
|
|
container:SetWidth(width)
|
|
container:SetHeight(size)
|
|
|
|
local i
|
|
for i = 1, table.getn(container.icons) do
|
|
container.icons[i]:SetWidth(size)
|
|
container.icons[i]:SetHeight(size)
|
|
end
|
|
end
|
|
|
|
function BNP:RefreshAllAuraLayouts()
|
|
local plate
|
|
for plate in pairs(BNP.plates or {}) do
|
|
self:RefreshAuraLayout(plate)
|
|
end
|
|
end
|
|
|
|
ScanSpellbook()
|
|
table.insert(BNP.libnameplate.OnInit, CreateAuraContainer)
|
|
table.insert(BNP.libnameplate.OnShow, CreateAuraContainer)
|
|
|
|
-- Keep the proven central update loop and the existing icon implementation.
|
|
-- No CooldownFrame templates and no frames or tables are created here.
|
|
local renderer = CreateFrame("Frame")
|
|
local elapsedTotal = 0
|
|
local removalElapsed = 0
|
|
renderer:SetScript("OnUpdate", function()
|
|
elapsedTotal = elapsedTotal + arg1
|
|
removalElapsed = removalElapsed + arg1
|
|
if elapsedTotal < UPDATE_INTERVAL then return end
|
|
elapsedTotal = 0
|
|
|
|
local now = GetTime()
|
|
UpdateDarkHarvest(now)
|
|
|
|
local hasPending = HasPendingAuras()
|
|
local hasPendingAoE = HasPendingAoEAuras()
|
|
local doRemovalScan = removalElapsed >= REMOVAL_SCAN_INTERVAL
|
|
if doRemovalScan then removalElapsed = 0 end
|
|
|
|
local targetGUID = nil
|
|
if hasPending or hasPendingAoE or doRemovalScan then
|
|
targetGUID = GetTargetGUID()
|
|
end
|
|
|
|
if hasPending and targetGUID then
|
|
-- Current target is the most reliable confirmation source.
|
|
ConfirmPendingForUnit("target", targetGUID, now)
|
|
end
|
|
|
|
if targetGUID then
|
|
CleanupDeadUnit("target", targetGUID, now)
|
|
end
|
|
|
|
if doRemovalScan and targetGUID then
|
|
SyncSharedAuras("target", targetGUID, now)
|
|
|
|
if BNP.guidAuras[targetGUID] then
|
|
SyncAuraRemoval("target", targetGUID, now)
|
|
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
|
|
if plate:IsShown() and (hasPending or hasPendingAoE or doRemovalScan) then
|
|
local guid = GetPlateGUID(plate)
|
|
local token = GetUnitTokenForPlate(plate)
|
|
|
|
if guid and token then
|
|
-- 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.
|
|
if hasPending then
|
|
ConfirmPendingForUnit(token, guid, now)
|
|
end
|
|
|
|
if hasPendingAoE then
|
|
ConfirmAoEAurasOnUnit(token, guid)
|
|
end
|
|
|
|
if doRemovalScan then
|
|
SyncSharedAuras(token, guid, now)
|
|
|
|
-- Current target is already handled above via the real "target" unit.
|
|
-- Non-target exact-removal waits briefly after a target switch so a
|
|
-- transient empty UnitDebuff list cannot delete valid auras.
|
|
local currentTargetGUID = targetGUID
|
|
local isCurrentTarget = currentTargetGUID and currentTargetGUID == guid
|
|
SyncAuraRemoval(token, guid, now)
|
|
end
|
|
end
|
|
end
|
|
|
|
UpdatePlate(plate)
|
|
end
|
|
|
|
if hasPending then
|
|
CleanupPending(now)
|
|
end
|
|
|
|
if hasPendingAoE then
|
|
CleanupPendingAoE(now)
|
|
end
|
|
end)
|
|
|
|
|
|
-- Shadow Vulnerability [17794]: isolated live-aura detector.
|
|
local svElapsed = 0
|
|
local svPresent = {}
|
|
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 svFrame=CreateFrame("Frame")
|
|
svFrame:SetScript("OnUpdate",function()
|
|
if playerClass~="WARLOCK" then return end
|
|
svElapsed=svElapsed+arg1
|
|
if svElapsed<0.10 then return end
|
|
svElapsed=0
|
|
local ok,guid=UnitExists("target")
|
|
if not ok or not guid then return end
|
|
local found,tex,count=false,nil,0
|
|
local i
|
|
for i=1,32 do
|
|
local t,c,dt,id=UnitDebuff("target",i)
|
|
if not t then break end
|
|
if id==17794 then found,tex,count=true,t,tonumber(c) or 0 break end
|
|
end
|
|
local def=SVDef()
|
|
if not def then return end
|
|
if found then
|
|
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
|
|
if not svPresent[guid] then
|
|
aura.expires=GetTime()+10
|
|
aura.duration=10
|
|
end
|
|
aura.spellID=17794
|
|
aura.texture=tex or def.texture
|
|
aura.stacks=count
|
|
aura.confirmedAt=GetTime()
|
|
aura.missingScans=nil
|
|
svPresent[guid]=true
|
|
elseif svPresent[guid] then
|
|
local cache=BNP.guidAuras[guid]
|
|
if cache then cache[def.key]=nil end
|
|
svPresent[guid]=nil
|
|
end
|
|
end)
|
|
|
|
|
|
|
|
-- Shadow Vulnerability refresh detector -------------------------------------
|
|
-- The aura can proc again while already active. The direct aura watcher keeps
|
|
-- existence correct; this helper only refreshes its local 10s timer when a
|
|
-- Shadow Bolt / Drain Soul event is followed by an active aura 17794.
|
|
local svRefreshGUID = nil
|
|
local svRefreshUntil = 0
|
|
local svRefreshElapsed = 0
|
|
|
|
local function IsShadowVulnerabilityTrigger(spellID)
|
|
if not spellID or not SpellInfo then return false end
|
|
local name = SpellInfo(spellID)
|
|
return name == "Shadow Bolt" or name == "Drain Soul"
|
|
end
|
|
|
|
local function RefreshShadowVulnerabilityIfPresent(guid)
|
|
if playerClass ~= "WARLOCK" or not guid then return false end
|
|
|
|
local exists, currentGUID = UnitExists("target")
|
|
if not exists or not currentGUID or currentGUID ~= guid then
|
|
return false
|
|
end
|
|
|
|
local found = false
|
|
local texture = nil
|
|
local stacks = 0
|
|
local i
|
|
for i = 1, 32 do
|
|
local t, c, dtype, auraSpellID = UnitDebuff("target", i)
|
|
if not t then break end
|
|
if auraSpellID == 17794 then
|
|
found = true
|
|
texture = t
|
|
stacks = tonumber(c) or 0
|
|
break
|
|
end
|
|
end
|
|
|
|
if not found then return false end
|
|
|
|
local def = nil
|
|
for i = 1, table.getn(AURA_DEFS) do
|
|
if AURA_DEFS[i] and AURA_DEFS[i].key == "shadow_vulnerability" then
|
|
def = AURA_DEFS[i]
|
|
break
|
|
end
|
|
end
|
|
if not def then return false end
|
|
|
|
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.duration = 10
|
|
aura.expires = GetTime() + 10
|
|
aura.spellID = 17794
|
|
aura.texture = texture or def.texture
|
|
aura.stacks = stacks
|
|
aura.confirmedAt = GetTime()
|
|
aura.missingScans = nil
|
|
|
|
return true
|
|
end
|
|
|
|
local svRefreshEvent = CreateFrame("Frame")
|
|
svRefreshEvent:RegisterEvent("UNIT_CASTEVENT")
|
|
svRefreshEvent:SetScript("OnEvent", function()
|
|
if playerClass ~= "WARLOCK" then return end
|
|
|
|
local playerExists, playerGUID = UnitExists("player")
|
|
if not playerExists or not playerGUID or arg1 ~= playerGUID then return end
|
|
|
|
local targetGUID = arg2
|
|
local eventType = arg3
|
|
local spellID = arg4
|
|
|
|
if eventType ~= "CAST" and eventType ~= "CHANNEL" then return end
|
|
if not IsShadowVulnerabilityTrigger(spellID) then return end
|
|
if not targetGUID then return end
|
|
|
|
-- Give the proc aura a brief moment to appear on the target.
|
|
svRefreshGUID = targetGUID
|
|
svRefreshUntil = GetTime() + 0.8
|
|
svRefreshElapsed = 0
|
|
end)
|
|
|
|
local svRefreshFrame = CreateFrame("Frame")
|
|
svRefreshFrame:SetScript("OnUpdate", function()
|
|
if not svRefreshGUID then return end
|
|
|
|
svRefreshElapsed = svRefreshElapsed + arg1
|
|
if svRefreshElapsed < 0.05 then return end
|
|
svRefreshElapsed = 0
|
|
|
|
if GetTime() > svRefreshUntil then
|
|
svRefreshGUID = nil
|
|
return
|
|
end
|
|
|
|
if RefreshShadowVulnerabilityIfPresent(svRefreshGUID) then
|
|
svRefreshGUID = nil
|
|
end
|
|
end)
|
|
|