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 COMBO_LAYOUT_CLASS = (playerClass == "ROGUE" or playerClass == "DRUID") local COMBO_LAYOUT_OFFSET = 16 local ROW_SPACING = 2 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 UseSeparateCCRow() return BNP.IsSeparateCCRowEnabled and BNP:IsSeparateCCRowEnabled() end local function GetAuraOffsetY() return GetBaseAuraOffsetY() end local function GetCCOffsetY() local y = GetBaseAuraOffsetY() if UseSeparateCCRow() and BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() then y = y + GetIconSize() + ROW_SPACING end return y end 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.guidLiveCCs = BNP.guidLiveCCs 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, GetAuraOffsetY()) 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 CreateCCContainer(plate) if not plate then return end BNP:RegisterPlate(plate) if plate.BNPCCContainer 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(41) if plate.healthbar then container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, GetCCOffsetY()) 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.BNPCCContainer = 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.guidLiveCCs then BNP.guidLiveCCs[guid] = nil end if BNP.pendingAuras then BNP.pendingAuras[guid] = nil end if BNP.pvpAuraProtection then BNP.pvpAuraProtection[guid] = nil end if BNP.darkHarvest and BNP.darkHarvest.guid == guid then BNP.darkHarvest = nil end if BNP.guidNames then BNP.guidNames[guid] = nil end end local function CleanupDeadUnit(unit, guid, now) if not unit or not guid or not UnitIsDeadOrGhost then return false end if AuraCacheProtected and AuraCacheProtected(guid, now) then return false end if UnitIsDeadOrGhost(unit) then ClearGUIDAuraState(guid) return true end return false end -- Fallback for players/NPCs whose nameplate disappears before the periodic -- dead-state check sees them. Vanilla combat chat reports " dies." only, -- so this fallback must NEVER clear multiple same-name GUIDs. local function ClearAuraStateByName(name) if not name or name == "" then return end -- Combat death chat gives us only a name, never the exact GUID. -- Count ALL known GUIDs with this name, not only GUIDs that currently have -- tracked auras. Otherwise, if two same-name mobs exist but only one has our -- debuffs, the death of the other mob could wrongly clear the survivor. local matchGUID = nil local matches = 0 local guid, knownName for guid, knownName in pairs(BNP.guidNames or {}) do if knownName == name then matches = matches + 1 matchGUID = guid if matches > 1 then -- Ambiguous same-name death: never clear anything by name. -- Exact GUID-based target death cleanup remains authoritative. return end end end if matches == 1 and matchGUID then ClearGUIDAuraState(matchGUID) end end -- Boss / NPC evade-reset cleanup -------------------------------------------- -- On a real reset the server restores the NPC to full health and drops combat, -- while BNP's local aura timers would otherwise keep running. Track only the -- current hostile NPC and clear its exact GUID cache when that reset signature -- is observed. local resetWatch = {} local resetElapsed = 0 local function CheckCurrentTargetReset(now) local exists, guid = UnitExists("target") if not exists or not guid then resetWatch.guid = nil resetWatch.wasDamaged = nil return end if UnitIsPlayer and UnitIsPlayer("target") then resetWatch.guid = guid resetWatch.wasDamaged = nil return end if resetWatch.guid ~= guid then resetWatch.guid = guid resetWatch.wasDamaged = nil end if not UnitHealth or not UnitHealthMax then return end local hp = tonumber(UnitHealth("target")) local maxhp = tonumber(UnitHealthMax("target")) if not hp or not maxhp or maxhp <= 0 then return end local pct = hp / maxhp if pct < 0.95 then resetWatch.wasDamaged = true end if not resetWatch.wasDamaged then return end if pct < 0.995 then return end -- Prefer the target's combat state when available. If the API is missing, -- require the player to be out of combat as a conservative fallback. local outOfCombat = false if UnitAffectingCombat then outOfCombat = not UnitAffectingCombat("target") elseif PlayerFrame and PlayerFrame.inCombat ~= nil then outOfCombat = not PlayerFrame.inCombat end if not outOfCombat then return end if BNP.guidAuras and BNP.guidAuras[guid] then ClearGUIDAuraState(guid) if RaidTrace then RaidTrace("RESET", guid, "-", nil) end end resetWatch.wasDamaged = nil end local resetFrame = CreateFrame("Frame") resetFrame:SetScript("OnUpdate", function() resetElapsed = resetElapsed + arg1 if resetElapsed < 0.10 then return end resetElapsed = 0 CheckCurrentTargetReset(GetTime()) end) local deathChatFrame = CreateFrame("Frame") deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH") deathChatFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") deathChatFrame:SetScript("OnEvent", function() local raw = arg1 if not raw then return end local _, _, deadName = string.find(raw, "^(.+) dies%.$") if deadName then ClearAuraStateByName(deadName) end end) -- Only successful casts by the local player are allowed to create aura -- entries. This prevents target scans from importing debuffs cast by other -- players. SuperWoW supplies caster GUID, target GUID and spell ID. local function GetPlayerGUID() local exists, guid = UnitExists("player") if exists and guid then return guid end return nil end local function DefHasSpellID(def, spellID) if not def or not spellID then return false end local i if def.spellIDs then for i = 1, table.getn(def.spellIDs) do if def.spellIDs[i] == spellID then return true end end end if def.durations and def.durations[spellID] then return true end return false end local function FindAuraDef(spellID) if not spellID or not SpellInfo then return nil, nil end local spellName, _, texture = SpellInfo(spellID) if not spellName and not texture then return nil, nil end -- Exact IDs are authoritative and must win over shared spell textures. local _, def for _, def in ipairs(AURA_DEFS) do if DefHasSpellID(def, spellID) then return def, texture, spellID end end for _, def in ipairs(AURA_DEFS) do if NameMatches(def, spellName) then return def, texture, spellID end end for _, def in ipairs(AURA_DEFS) do if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then return def, texture, spellID end end return nil, nil end -- Paladin Judgements -------------------------------------------------------- -- SuperWoW reports the button press as Judgement [20271], while the target -- receives a different aura ID (e.g. Light 20344, Justice 20184, Crusader -- 20302, Wisdom 20353). Therefore normal CAST-ID == AURA-ID confirmation -- cannot work for Judgements. local PALADIN_JUDGEMENT_CAST_ID = 20271 local function IsJudgementDef(def) if not def or not def.key then return false end return string.find(def.key, "^judgement_") and true or false end local function FindJudgementAuraOnUnit(unit) if playerClass ~= "PALADIN" or not unit then return nil end local i, texture, stacks, dtype, auraSpellID for i = 1, 64 do texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) if not texture then break end if auraSpellID then local auraName = nil if SpellInfo then auraName = SpellInfo(auraSpellID) end local _, def for _, def in ipairs(AURA_DEFS) do if IsJudgementDef(def) then local explicitMatch = false local j if def.spellIDs then for j = 1, table.getn(def.spellIDs) do if def.spellIDs[j] == auraSpellID then explicitMatch = true break end end end if explicitMatch or NameMatches(def, auraName) then return def, auraSpellID, texture end end end end end return nil end -- Turtle WoW Dark Harvest (spell 52552). -- It is reported by SuperWoW as CHANNEL; arg5 is channel length in ms. -- A 30% shorter tick interval means aura time advances at 1/0.70 speed. local DARK_HARVEST_SPELL_ID = 52552 local DARK_HARVEST_SPEED = 1 / 0.70 local DARK_HARVEST_AURAS = { corruption=true, curse_of_agony=true, siphon_life=true, curse_of_doom=true, drain_life=true, drain_soul=true, } BNP.darkHarvest = nil local function StartDarkHarvest(guid, durationMS) local now=GetTime() local duration=tonumber(durationMS) or 8000 if duration > 100 then duration=duration/1000 end BNP.darkHarvest={guid=guid, lastUpdate=now, ends=now+duration} end local function UpdateDarkHarvest(now) local st=BNP.darkHarvest if not st then return end local untilTime=now if untilTime > st.ends then untilTime=st.ends end local delta=untilTime-st.lastUpdate if delta > 0 then local cache=BNP.guidAuras[st.guid] if cache then local bonus=delta*(DARK_HARVEST_SPEED-1) local key,aura for key,aura in pairs(cache) do if DARK_HARVEST_AURAS[key] and type(aura)=="table" and aura.expires and aura.expires > st.lastUpdate then aura.expires=aura.expires-bonus end end end st.lastUpdate=untilTime end if now >= st.ends then BNP.darkHarvest=nil end end -- Raid confirmation trace ---------------------------------------------------- -- Tiny always-on ring buffer for the last tracked own-aura transitions. -- No chat output and no scanning; entries are only added at existing events. BNP.raidTrace = BNP.raidTrace or {} BNP.raidTraceMax = 30 local function RaidTrace(stage, guid, key, spellID) local t = BNP.raidTrace table.insert(t, { time = GetTime(), stage = stage, guid = guid, key = key, spellID = spellID, }) while table.getn(t) > BNP.raidTraceMax do table.remove(t, 1) end end -- Cast confirmation --------------------------------------------------------- -- UNIT_CASTEVENT "CAST" means the player finished the cast, but it does not -- guarantee that the hostile aura actually landed. Resist/Miss/Immune must -- therefore never create a visible timer. -- -- We first remember the cast as "pending". It becomes a real aura only after -- UnitDebuff confirms the matching aura on that exact SuperWoW GUID. -- Never infer success from CAST alone: melee abilities can still miss/dodge, -- and hostile targets may resist or be immune after the cast event. BNP.pendingAuras = BNP.pendingAuras or {} BNP.pendingAoEAuras = BNP.pendingAoEAuras or {} -- Pending failures are matched by exact SuperWoW UNIT_CASTEVENT data. -- Never use global combat-text "miss/resist" lines here: in a raid those can -- belong to another spell and previously caused unrelated pending DoTs to die. local function FailPendingAura(targetGUID, spellID) if not targetGUID or not spellID then return end local entries = BNP.pendingAuras[targetGUID] if not entries then return end local def = FindAuraDef(spellID) if def and entries[def.key] then entries[def.key] = nil end -- Generic Paladin Judgement failure. if playerClass == "PALADIN" and spellID == PALADIN_JUDGEMENT_CAST_ID then entries.__judgement = nil end end local PENDING_TIMEOUT = 4.0 -- Shagu-style authoritative own-cast tracking -------------------------------- -- Exact own SuperWoW CAST on an exact GUID creates normal direct single-target -- debuffs immediately. The application remains provisional for a short window: -- SuperWoW FAIL and localized combat-log miss/resist/immune messages can roll it -- back, including restoring the old timer after a resisted refresh. local STRICT_AURA_KEYS = { -- Shared / proc / secondary effects shadow_vulnerability=true, hunters_mark=true, -- Warlock CC fear=true, howl_of_terror=true, banish=true, death_coil=true, -- Hunter traps / CC / talent procs immolation_trap_effect=true, explosive_trap_effect=true, freezing_trap_effect=true, improved_scorpid_sting=true, improved_wing_clip=true, improved_concussive_shot=true, scatter_shot=true, wyvern_sting=true, entrapment=true, counterattack=true, intimidation=true, scare_beast=true, -- Mage CC / proc effects polymorph=true, frost_nova=true, counterspell=true, counterspell_silence=true, ignite=true, impact=true, frostbite=true, fire_vulnerability=true, winters_chill=true, -- Priest CC / proc effects psychic_scream=true, silence=true, blackout=true, touch_of_weakness=true, shackle_undead=true, mind_control=true, -- Paladin CC hammer_of_justice=true, repentance=true, turn_undead=true, -- Rogue CC / poison procs gouge=true, kidney_shot=true, cheap_shot=true, sap=true, blind=true, kick_silenced=true, crippling_poison=true, mind_numbing_poison=true, wound_poison=true, deadly_poison=true, -- Druid CC / secondary effects entangling_roots=true, bash=true, pounce=true, pounce_bleed=true, hibernate=true, feral_charge_effect=true, -- Warrior CC / talent procs improved_hamstring=true, intimidating_shout=true, disarm=true, charge_stun=true, intercept_stun=true, concussion_blow=true, revenge_stun=true, pummel=true, shield_bash=true, deep_wound=true, -- Shaman totem / proc effects earthbind=true, earthgrab=true, frostbrand_attack=true, } local function GetTrackingMode(def) if not def or not def.key then return "AURA" end if def.category == "CURSE" then return "AURA" end if def.aoe then return "AURA" end local cat = string.upper(tostring(def.category or "")) if cat == "CC" or cat == "ROOT" or cat == "STUN" or cat == "TRAP" or cat == "SILENCE" or cat == "PROC" then return "AURA" end if STRICT_AURA_KEYS[def.key] then return "AURA" end return "CAST" end local function IsCastPrimary(def) return GetTrackingMode(def) == "CAST" end local AURA_ATTEMPT_WINDOW = 5.0 BNP.auraCastAttempts = BNP.auraCastAttempts or {} local function CopyAuraState(aura) if type(aura) ~= "table" then return nil end local copy = {} local k, v for k, v in pairs(aura) do copy[k] = v end return copy end local function BuildAffectedKeys(def) local keys = {} if not def or not def.key then return keys end keys[def.key] = true -- Any Warlock curse cast can change the target's complete curse state. -- Snapshot every tracked curse so a later resist/fail can restore exactly -- what was active before the attempt. This also supports Turtle mechanics -- where Curse of Agony may coexist with one regular curse. if def.category == "CURSE" then local _, oldDef for _, oldDef in ipairs(AURA_DEFS) do if oldDef.category == "CURSE" then keys[oldDef.key] = true end end end return keys end local function StartAuraCastAttempt(guid, def, spellID, affectedKeys) if not guid or not spellID then return nil end local keys = affectedKeys or BuildAffectedKeys(def) local cache = BNP.guidAuras[guid] local before = {} local key for key in pairs(keys) do local aura = cache and cache[key] before[key] = { exists = type(aura) == "table" and true or false, aura = CopyAuraState(aura), } end local spellName = nil if SpellInfo then spellName = SpellInfo(spellID) end if not spellName and def and def.localizedName then spellName = def.localizedName end if not spellName and def and def.names then spellName = def.names[1] end local attempt = { guid = guid, key = def and def.key or "__special", spellID = spellID, spellName = spellName, time = GetTime(), before = before, done = false, } table.insert(BNP.auraCastAttempts, attempt) while table.getn(BNP.auraCastAttempts) > 32 do table.remove(BNP.auraCastAttempts, 1) end return attempt end local function RollbackAuraCastAttempt(attempt, reason) if not attempt or attempt.done then return false end local guid = attempt.guid local cache = BNP.guidAuras[guid] if not cache then cache = {} BNP.guidAuras[guid] = cache end local key, state for key, state in pairs(attempt.before or {}) do if state.exists and state.aura then cache[key] = CopyAuraState(state.aura) else cache[key] = nil end end attempt.done = true RaidTrace("ROLLBACK", guid, attempt.key, attempt.spellID) return true end local function RollbackAuraAttemptBySpell(guid, spellID, reason) if not spellID then return false end local now = GetTime() local i for i = table.getn(BNP.auraCastAttempts), 1, -1 do local attempt = BNP.auraCastAttempts[i] if attempt and not attempt.done and attempt.spellID == spellID and (not guid or attempt.guid == guid) and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW then return RollbackAuraCastAttempt(attempt, reason) end end return false end local function CleanupAuraCastAttempts(now) local i for i = table.getn(BNP.auraCastAttempts), 1, -1 do local attempt = BNP.auraCastAttempts[i] if not attempt or attempt.done or now - (attempt.time or 0) > AURA_ATTEMPT_WINDOW then table.remove(BNP.auraCastAttempts, i) end end end -- ShaguPlates does more than just seed debuffs from UNIT_CASTEVENT CAST: -- libdebuff also watches the local player's failure combat messages and -- reverts the last application on miss/resist/immune/evade/etc. BNP mirrors -- that safety, but keeps the exact SuperWoW GUID and can restore the previous -- timer when a refresh fails. local failurePatterns = {} local function EscapeLuaPattern(text) return string.gsub(text, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") end local function CombatFormatToPattern(formatText) if not formatText or formatText == "" then return nil end local text = formatText text = string.gsub(text, "%%%%", "__BNP_PERCENT__") text = string.gsub(text, "%%%d+%$s", "__BNP_STRING__") text = string.gsub(text, "%%s", "__BNP_STRING__") text = string.gsub(text, "%%%d+%$d", "__BNP_NUMBER__") text = string.gsub(text, "%%d", "__BNP_NUMBER__") text = EscapeLuaPattern(text) text = string.gsub(text, "__BNP_STRING__", ".+") text = string.gsub(text, "__BNP_NUMBER__", "%%d+") text = string.gsub(text, "__BNP_PERCENT__", "%%%%") return "^" .. text .. "$" end local function AddFailurePattern(formatText) local pattern = CombatFormatToPattern(formatText) if pattern then table.insert(failurePatterns, pattern) end end AddFailurePattern(SPELLIMMUNESELFOTHER) AddFailurePattern(IMMUNEDAMAGECLASSSELFOTHER) AddFailurePattern(SPELLMISSSELFOTHER) AddFailurePattern(SPELLRESISTSELFOTHER) AddFailurePattern(SPELLEVADEDSELFOTHER) AddFailurePattern(SPELLDODGEDSELFOTHER) AddFailurePattern(SPELLDEFLECTEDSELFOTHER) AddFailurePattern(SPELLREFLECTSELFOTHER) AddFailurePattern(SPELLPARRIEDSELFOTHER) AddFailurePattern(SPELLFAILCASTSELF) local function IsLocalFailureMessage(raw) if not raw or raw == "" then return false end local i for i = 1, table.getn(failurePatterns) do if string.find(raw, failurePatterns[i]) then return true end end return false end local function RollbackAuraAttemptFromCombatMessage(raw) if not IsLocalFailureMessage(raw) then return false end local now = GetTime() local i for i = table.getn(BNP.auraCastAttempts), 1, -1 do local attempt = BNP.auraCastAttempts[i] if attempt and not attempt.done and now - (attempt.time or 0) <= AURA_ATTEMPT_WINDOW and attempt.spellName and string.find(raw, attempt.spellName, 1, true) then return RollbackAuraCastAttempt(attempt, "COMBAT_FAIL") end end return false end local auraFailureFrame = CreateFrame("Frame") auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER") auraFailureFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") auraFailureFrame:SetScript("OnEvent", function() RollbackAuraAttemptFromCombatMessage(arg1) end) local function HasPendingAuras() local guid, entries, key, pending for guid, entries in pairs(BNP.pendingAuras) do for key, pending in pairs(entries) do if type(pending) == "table" then return true end end end return false end local function GetUnitTokenForPlate(plate) if not plate or not plate.GetName then return nil end return plate:GetName(1) end local function FindMatchingAuraOnUnit(unit, def, castSpellID) if not unit or not def then return nil, nil, nil end -- SuperWoW can expose more than the stock 16/32 debuff slots. In a 40-player -- raid our own debuff may therefore sit beyond slot 32. Stopping at 32 made -- valid casts time out instead of entering guidAuras. local i = 1 while i <= 64 do local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) if not texture then break end if auraSpellID then if auraSpellID == castSpellID or DefHasSpellID(def, auraSpellID) then return auraSpellID, texture, tonumber(stacks) or 0 end local auraName = SpellInfo and SpellInfo(auraSpellID) or nil if NameMatches(def, auraName) then return auraSpellID, texture, tonumber(stacks) or 0 end end if texture and def.textureMatch and string.find(string.lower(texture), def.textureMatch) then return auraSpellID or castSpellID, texture, tonumber(stacks) or 0 end i = i + 1 end return nil, nil, nil end local function ReconcileLiveCurseCache(unit, guid) if playerClass ~= "WARLOCK" or not unit or not guid then return end local cache = BNP.guidAuras[guid] if not cache then return end -- Curse casts are already strict aura-confirmed. Once a new curse has really -- appeared, use that same live target aura state to decide which older curse -- timers are still valid. This avoids hard-coding whether CoA is exclusive: -- if the server keeps two curses, BNP keeps two; if one was replaced, BNP -- removes only the aura that is actually gone. local _, curseDef for _, curseDef in ipairs(AURA_DEFS) do if curseDef.category == "CURSE" and cache[curseDef.key] then local cachedAura = cache[curseDef.key] local liveSpellID = FindMatchingAuraOnUnit( unit, curseDef, cachedAura and cachedAura.spellID or nil ) if not liveSpellID then RaidTrace("CURSE_REMOVE", guid, curseDef.key, cachedAura and cachedAura.spellID) cache[curseDef.key] = nil end end end end local function CommitPendingAura(guid, key, pending, unit) if not guid or not pending then return end local def = pending.def local cache = BNP.guidAuras[guid] if not cache then cache = {} BNP.guidAuras[guid] = cache end -- Do not guess curse exclusivity here. Warlock curses are strict aura- -- confirmed and are reconciled against the live target state after commit. -- This supports both normal Vanilla replacement and Turtle dual-curse rules. cache._nextOrder = (cache._nextOrder or 0) + 1 local aura = cache[key] or {} aura.order = cache._nextOrder aura.duration = pending.duration aura.expires = GetTime() + pending.duration aura.spellID = pending.spellID aura.texture = pending.texture or def.texture aura.stacks = tonumber(pending.stacks) or 0 aura.confirmedAt = GetTime() aura.missingScans = nil if pending.liveConfirmed then aura.castPrimary = nil aura.castPrimaryAt = nil aura.liveConfirmed = true elseif pending.castPrimary then aura.castPrimary = true aura.castPrimaryAt = GetTime() aura.liveConfirmed = nil end cache[key] = aura if def.category == "CURSE" and unit then ReconcileLiveCurseCache(unit, guid) end RaidTrace("CONFIRM", guid, key, aura.spellID) end local function ConfirmPendingForUnit(unit, guid, now) if not unit or not guid then return end local pendingForGUID = BNP.pendingAuras[guid] if not pendingForGUID then return end local key, pending for key, pending in pairs(pendingForGUID) do if type(pending) == "table" then if pending.judgementProbe then local def, auraSpellID, texture = FindJudgementAuraOnUnit(unit) if def and auraSpellID then local duration = def.duration if BNP.GetSpellbookDurationForDef then duration = BNP:GetSpellbookDurationForDef(def) or duration end local confirmed = { def = def, spellID = auraSpellID, texture = texture or def.texture, duration = duration, created = pending.created, liveConfirmed = true, } CommitPendingAura(guid, def.key, confirmed, unit) pendingForGUID[key] = nil elseif now - pending.created > PENDING_TIMEOUT then RaidTrace("TIMEOUT", guid, key, pending.spellID) -- No Judgement aura appeared: resist, miss, immune or failed cast. pendingForGUID[key] = nil end else local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID) if auraSpellID then local confirmed = { def = pending.def, spellID = auraSpellID, texture = auraTexture or pending.texture, duration = pending.duration, created = pending.created, stacks = auraStacks or 0, liveConfirmed = true, } CommitPendingAura(guid, key, confirmed, unit) pendingForGUID[key] = nil elseif now - pending.created > PENDING_TIMEOUT then RaidTrace("TIMEOUT", guid, key, pending.spellID) -- Effects not approved for the raid fallback remain strict. pendingForGUID[key] = nil end end end end end local function CleanupPending(now) local guid, entries, key, pending for guid, entries in pairs(BNP.pendingAuras) do local any = false for key, pending in pairs(entries) do if type(pending) == "table" then if now - pending.created > PENDING_TIMEOUT then entries[key] = nil else any = true end end end if not any then BNP.pendingAuras[guid] = nil end end end local AOE_PENDING_TIMEOUT = 1.5 local function QueueAoEAura(def, spellID, texture, duration) if not def or not spellID then return end BNP.pendingAoEAuras[def.key] = { def = def, spellID = spellID, texture = texture or def.texture, duration = duration, created = GetTime(), } end local function ConfirmAoEAurasOnUnit(unit, guid) if not unit or not guid then return end local key, pending for key, pending in pairs(BNP.pendingAoEAuras) do if type(pending) == "table" then local auraSpellID, auraTexture, auraStacks = FindMatchingAuraOnUnit(unit, pending.def, pending.spellID) if auraSpellID then CommitPendingAura(guid, pending.def.key, { def = pending.def, spellID = auraSpellID, texture = auraTexture or pending.texture, duration = pending.duration, created = pending.created, stacks = auraStacks or 0, liveConfirmed = true, }, unit) end end end end local function HasPendingAoEAuras() local key, pending for key, pending in pairs(BNP.pendingAoEAuras) do if type(pending) == "table" then return true end end return false end local function CleanupPendingAoE(now) local key, pending for key, pending in pairs(BNP.pendingAoEAuras) do if type(pending) == "table" and now - (pending.created or 0) > AOE_PENDING_TIMEOUT then BNP.pendingAoEAuras[key] = nil end end end local 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 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 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 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 StartAuraCastAttempt(targetGUID, def, spellID) local pendingForGUID = BNP.pendingAuras[targetGUID] if not pendingForGUID then pendingForGUID = {} BNP.pendingAuras[targetGUID] = pendingForGUID end pendingForGUID[def.key] = { def = def, spellID = spellID, texture = texture or def.texture, duration = duration, created = GetTime(), } RaidTrace("CAST", targetGUID, def.key, spellID) if IsCastPrimary(def) then local direct = { def = def, spellID = spellID, texture = texture or def.texture, duration = duration, created = GetTime(), stacks = 0, castPrimary = true, } CommitPendingAura(targetGUID, def.key, direct) local cache = BNP.guidAuras and BNP.guidAuras[targetGUID] if cache and cache[def.key] then cache[def.key].castPrimary = true cache[def.key].castPrimaryAt = GetTime() cache[def.key].liveConfirmed = nil end pendingForGUID[def.key] = nil RaidTrace("DIRECT", targetGUID, def.key, spellID) return end -- Fast path: if the cast target is still our current target, try to confirm -- immediately. The normal pending loop remains as the retry path. local currentTargetGUID = GetTargetGUID() if currentTargetGUID and currentTargetGUID == targetGUID then ConfirmPendingForUnit("target", targetGUID, GetTime()) end end) -- Paladin Judgement melee refresh ------------------------------------------ -- Vanilla Judgement debuffs refresh when the judging Paladin lands a melee -- strike on that target. The aura itself does not get re-cast, so UNIT_CASTEVENT -- cannot update our timer. Refresh only an already-confirmed OWN Judgement. local function RefreshOwnJudgementForGUID(guid) if playerClass ~= "PALADIN" or not guid then return end local cache = BNP.guidAuras[guid] if not cache then return end local _, def for _, def in ipairs(AURA_DEFS) do if IsJudgementDef(def) then local aura = cache[def.key] if type(aura) == "table" and aura.expires then local duration = aura.duration or def.duration or 10 aura.duration = duration aura.expires = GetTime() + duration aura.confirmedAt = GetTime() aura.missingScans = nil end end end end -- SuperWoW gives us the target GUID of the player's current target. For normal -- auto-attacks, the combat text event is sufficient to know a successful melee -- hit occurred; matching the current target GUID keeps the refresh scoped to -- the unit actually being attacked. local judgementMeleeFrame = CreateFrame("Frame") judgementMeleeFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") judgementMeleeFrame:SetScript("OnEvent", function() if playerClass ~= "PALADIN" then return end local guid = GetTargetGUID() if not guid then return end -- CHAT_MSG_COMBAT_SELF_HITS only fires for landed melee attacks (hit/crit). RefreshOwnJudgementForGUID(guid) end) -- Holy Strike also counts as a melee-style Judgement refresh on Turtle/Octo. -- It is reported via CHAT_MSG_SPELL_SELF_DAMAGE rather than the normal melee -- hit event and does not expose a useful spell ID in this client. local holyStrikeRefreshFrame = CreateFrame("Frame") holyStrikeRefreshFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") holyStrikeRefreshFrame:SetScript("OnEvent", function() if playerClass ~= "PALADIN" then return end local raw = arg1 if not raw then return end -- Only successful Holy Strike hit messages refresh the timer. -- Miss/resist/immune lines do not match this pattern. if not string.find(raw, "^Your Holy Strike hits ") then return end local guid = GetTargetGUID() if not guid then return end -- Extra safety: when the target name is available, require it to be present -- in the combat-log line so a stale/current target cannot refresh another GUID. local targetName = UnitName("target") if targetName and targetName ~= "" and not string.find(raw, targetName, 1, true) then return end RefreshOwnJudgementForGUID(guid) end) -- Enemy Feign Death / Vanish protection. local pvpDisappearFrame = CreateFrame("Frame") pvpDisappearFrame:RegisterEvent("UNIT_CASTEVENT") pvpDisappearFrame:SetScript("OnEvent", function() local casterGUID = arg1 local eventType = arg3 local spellID = arg4 if not casterGUID or not spellID or not SpellInfo then return end if eventType ~= "CAST" and eventType ~= "START" then return end local spellName = SpellInfo(spellID) -- Abilities that can temporarily drop target/nameplate visibility without -- actually cleansing existing harmful effects. local TEMPORARY_DISAPPEAR = { ["Feign Death"] = true, ["Vanish"] = true, ["Shadowmeld"] = true, } if spellName and TEMPORARY_DISAPPEAR[spellName] then ProtectAuraCache(casterGUID, spellName) end end) -- Shared/global aura exceptions --------------------------------------------- -- Most BNP debuffs are strictly "own casts only". A very small number of -- effects are globally unique on a target and are useful even when applied by -- another player. These are explicitly opted in via def.shared = true. local function FindSharedAuraOnUnit(unit, def) if not unit or not def then return nil, nil, nil, nil end local i for i = 1, 64 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 IsCrowdControlDef(def) return def and EXACT_LIVE_REMOVAL[def.key] and true or false end local function ShouldDisplayAuraDef(def) if IsCrowdControlDef(def) then if BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled() then return false end if UseSeparateCCRow() then return false end return true end return not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled() end -- Separate CC display ------------------------------------------------------- -- This layer is intentionally independent from guidAuras. It may display live -- CCs from other players, but it can never create, refresh, remove or alter an -- own DoT/debuff timer. local GLOBAL_CC_DEFS = {} local GLOBAL_CC_BY_ID = {} local GLOBAL_CC_NEGATIVE = {} local seenGlobalCC = {} local function BuildGlobalCCDefs() local _, defs, _, ccDef, _, sid for _, defs in pairs(CLASS_AURAS) do for _, ccDef in ipairs(defs or {}) do if IsCrowdControlDef(ccDef) and not seenGlobalCC[ccDef.key] then seenGlobalCC[ccDef.key] = true table.insert(GLOBAL_CC_DEFS, ccDef) if ccDef.spellIDs then for _, sid in ipairs(ccDef.spellIDs) do GLOBAL_CC_BY_ID[sid] = ccDef end end if ccDef.durations then for sid in pairs(ccDef.durations) do GLOBAL_CC_BY_ID[sid] = ccDef end end end end end end BuildGlobalCCDefs() local function ResolveGlobalCCDef(spellID, texture) if spellID and GLOBAL_CC_BY_ID[spellID] then return GLOBAL_CC_BY_ID[spellID] end if spellID and GLOBAL_CC_NEGATIVE[spellID] then return nil end local auraName = spellID and SpellInfo and SpellInfo(spellID) or nil local _, ccDef for _, ccDef in ipairs(GLOBAL_CC_DEFS) do if NameMatches(ccDef, auraName) or (texture and ccDef.textureMatch and string.find(string.lower(texture), ccDef.textureMatch)) then if spellID then GLOBAL_CC_BY_ID[spellID] = ccDef end return ccDef end end if spellID then GLOBAL_CC_NEGATIVE[spellID] = true end return nil end local function WantsForeignCCs() return BNP.AreCrowdControlEnabled and BNP:AreCrowdControlEnabled() and BNP.ShowOtherPlayersCCs and BNP:ShowOtherPlayersCCs() end local function SyncForeignCCs(unit, guid, now) if not unit or not guid then return end if not WantsForeignCCs() then BNP.guidLiveCCs[guid] = nil return end local seen = {} local live = BNP.guidLiveCCs[guid] local i for i = 1, 64 do local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i) if not texture then break end local ccDef = ResolveGlobalCCDef(auraSpellID, texture) if ccDef then seen[ccDef.key] = true if not live then live = {} BNP.guidLiveCCs[guid] = live end local aura = live[ccDef.key] if not aura then aura = { firstSeen = now, order = now } live[ccDef.key] = aura end aura.def = ccDef aura.spellID = auraSpellID aura.texture = texture or ccDef.texture aura.stacks = tonumber(stacks) or 0 aura.lastSeen = now -- Vanilla/SuperWoW does not reliably provide foreign application time. -- Use a conservative local estimate for text only; live aura presence is -- authoritative for appearance/removal. if not aura.expires and ccDef.duration then aura.expires = now + ccDef.duration end end end if live then local key, aura for key, aura in pairs(live) do if not seen[key] and now - (aura.lastSeen or 0) >= 0.20 then live[key] = nil end end end end local function HideCCRow(plate) local container = plate and plate.BNPCCContainer if not container then return end local i for i = 1, table.getn(container.icons or {}) do local icon = container.icons[i] icon.lastTimerText = nil icon.timer:SetText("") if icon.stack then icon.stack:SetText("") end icon:Hide() end container:Hide() end local function FormatCCTimer(remaining) if not remaining or remaining <= 0 then return "" end if remaining >= 3600 then return math.ceil(remaining / 3600) .. "h" elseif remaining >= 60 then return math.ceil(remaining / 60) .. "m" end return tostring(math.ceil(remaining)) end local function UpdateCCRow(plate, guid, cache, now) if not UseSeparateCCRow() or (BNP.AreCrowdControlEnabled and not BNP:AreCrowdControlEnabled()) then HideCCRow(plate) return end if not plate.BNPCCContainer then CreateCCContainer(plate) end local container = plate.BNPCCContainer if not container then return end local active = container.activeAuras if not active then active = {}; container.activeAuras = active end local pool = container.activeAuraPool if not pool then pool = {} for i = 1, MAX_VISIBLE_ICONS do pool[i] = {} end container.activeAuraPool = pool end local oldCount = table.getn(active) for i = 1, oldCount do active[i] = nil end local count = 0 local seenOwn = {} local _, ccDef for _, ccDef in ipairs(AURA_DEFS) do if IsCrowdControlDef(ccDef) then local aura = cache and cache[ccDef.key] local remaining = aura and aura.expires and (aura.expires - now) or nil local sharedLive = aura and aura.sharedLive if aura and (sharedLive or (remaining and remaining > 0)) then count = count + 1 if count <= MAX_VISIBLE_ICONS then local entry = pool[count] active[count] = entry entry.def = ccDef entry.aura = aura entry.remaining = remaining seenOwn[ccDef.key] = true end end end end if WantsForeignCCs() then local live = guid and BNP.guidLiveCCs[guid] local key, aura for key, aura in pairs(live or {}) do if count >= MAX_VISIBLE_ICONS then break end if aura.def and not seenOwn[key] and now - (aura.lastSeen or 0) <= 0.30 then count = count + 1 local entry = pool[count] active[count] = entry entry.def = aura.def entry.aura = aura entry.remaining = aura.expires and (aura.expires - now) or nil end end end table.sort(active, function(a, b) return (a.aura.order or a.aura.firstSeen or 0) < (b.aura.order or b.aura.firstSeen or 0) end) local visibleCount = table.getn(active) local iconSize = 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 local i 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 = FormatCCTimer(entry.remaining) if icon.lastTimerText ~= timerText then icon.timer:SetText(timerText); icon.lastTimerText = timerText end icon:Show() else icon.lastTimerText = nil icon.timer:SetText("") if icon.stack then icon.stack:SetText("") end icon:Hide() end end if visibleCount > 0 then container:Show() else container:Hide() end end local 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, 64 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 -- Positive live sighting upgrades a CAST-seeded timer. Once upgraded, -- normal dispel/early-removal logic is safe again. aura.missingScans = nil if aura.castPrimary then aura.castPrimary = nil aura.castPrimaryAt = nil aura.liveConfirmed = true end elseif exactRemoval then RaidTrace("REMOVE", guid, key, aura.spellID) -- CC/roots/traps must disappear immediately when broken/dispelled. cache[key] = nil 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 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 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 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 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 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 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 UpdateCCRow(plate, guid, cache, now) end function BNP:RefreshAuraLayout(plate) if not plate then return end local size = GetIconSize() local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1) local containers = { plate.BNPAuraContainer, plate.BNPCCContainer } local _, container for _, container in ipairs(containers) do if container then container:SetWidth(width) container:SetHeight(size) local i for i = 1, table.getn(container.icons or {}) do container.icons[i]:SetWidth(size) container.icons[i]:SetHeight(size) end end end end function BNP:RefreshAllAuraLayouts() local plate for plate in pairs(BNP.plates or {}) do if plate and plate.healthbar then if plate.BNPAuraContainer then plate.BNPAuraContainer:ClearAllPoints() plate.BNPAuraContainer:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, GetAuraOffsetY()) end if plate.BNPCCContainer then plate.BNPCCContainer:ClearAllPoints() plate.BNPCCContainer:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, GetCCOffsetY()) end end self:RefreshAuraLayout(plate) end end ScanSpellbook() table.insert(BNP.libnameplate.OnInit, CreateAuraContainer) table.insert(BNP.libnameplate.OnInit, CreateCCContainer) table.insert(BNP.libnameplate.OnShow, CreateAuraContainer) table.insert(BNP.libnameplate.OnShow, CreateCCContainer) -- Keep the proven central update loop and the existing icon implementation. -- No CooldownFrame templates and no frames or tables are created here. local renderer = CreateFrame("Frame") local elapsedTotal = 0 local removalElapsed = 0 renderer:SetScript("OnUpdate", function() elapsedTotal = elapsedTotal + arg1 removalElapsed = removalElapsed + arg1 if elapsedTotal < UPDATE_INTERVAL then return end elapsedTotal = 0 local now = GetTime() UpdateDarkHarvest(now) CleanupAuraCastAttempts(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) -- 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 foreignCCElapsed = 0 local foreignCCScanner = CreateFrame("Frame") foreignCCScanner:SetScript("OnUpdate", function() if not WantsForeignCCs() then return end foreignCCElapsed = foreignCCElapsed + arg1 if foreignCCElapsed < FOREIGN_CC_SCAN_INTERVAL then return end foreignCCElapsed = 0 local now = GetTime() local targetGUID = GetTargetGUID() if targetGUID then pcall(SyncForeignCCs, "target", targetGUID, now) end local plate for plate in pairs(BNP.plates or {}) do if plate and plate:IsShown() then local guid = GetPlateGUID(plate) if guid and guid ~= targetGUID then local token = GetUnitTokenForPlate(plate) if token then pcall(SyncForeignCCs, token, guid, now) end end end end end) -- Shadow Vulnerability [17794]: 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, 64 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) 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