mirror of
https://github.com/brues-code/pfUI.git
synced 2026-09-25 09:06:03 +00:00
1f72b93ea5
"Target Debuff Bar" should now correctly show only own debuffs (there will be still debuffs that are not shown up properly like AOE spells or procs)
2137 lines
78 KiB
Lua
2137 lines
78 KiB
Lua
-- load pfUI environment
|
|
setfenv(1, pfUI:GetEnvironment())
|
|
|
|
--[[ libdebuff - GetUnitField Edition ]]--
|
|
-- A pfUI library that detects and saves all ongoing debuffs of players, NPCs and enemies.
|
|
--
|
|
-- MAJOR REWRITE: Now uses GetUnitField for slot mapping instead of manual shifting.
|
|
-- Key insight: GetUnitField returns STABLE aura slots (33-48) that DON'T shift when
|
|
-- debuffs expire. Only the display slots (UnitDebuff returns 1,2,3...) are compacted.
|
|
--
|
|
-- This eliminates ~400 lines of error-prone shift logic while maintaining full
|
|
-- multi-caster tracking support.
|
|
--
|
|
-- libdebuff:UnitDebuff(unit, id)
|
|
-- Returns debuff informations on the given effect of the specified unit.
|
|
-- name, rank, texture, stacks, dtype, duration, timeleft, caster
|
|
|
|
-- return instantly if we're not on a vanilla client
|
|
if pfUI.client > 11200 then return end
|
|
|
|
-- return instantly when another libdebuff is already active
|
|
if pfUI.api.libdebuff then return end
|
|
|
|
-- fix a typo (missing $) in ruRU capture index
|
|
if GetLocale() == "ruRU" then
|
|
SPELLREFLECTSELFOTHER = gsub(SPELLREFLECTSELFOTHER, "%%2s", "%%2%$s")
|
|
end
|
|
|
|
local libdebuff = CreateFrame("Frame", "pfdebuffsScanner", UIParent)
|
|
local scanner = libtipscan:GetScanner("libdebuff")
|
|
local _, class = UnitClass("player")
|
|
local lastspell
|
|
|
|
-- Nampower Support
|
|
local hasNampower = false
|
|
|
|
-- Set hasNampower immediately for functionality
|
|
if GetNampowerVersion then
|
|
local major, minor, patch = GetNampowerVersion()
|
|
patch = patch or 0
|
|
-- Minimum required version: 3.0.0 (GetUnitGUID support)
|
|
if major > 3 or (major == 3 and minor > 0) or (major == 3 and minor == 0 and patch >= 0) then
|
|
hasNampower = true
|
|
end
|
|
end
|
|
|
|
-- Nampower startup check: show version info and ensure CVars are set.
|
|
-- Runs on first OnUpdate after PLAYER_ENTERING_WORLD to give Nampower time to initialize.
|
|
local nampowerCheckFrame = CreateFrame("Frame")
|
|
nampowerCheckFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
nampowerCheckFrame:SetScript("OnEvent", function()
|
|
-- Defer to next frame so Nampower is fully initialized
|
|
this:SetScript("OnUpdate", function()
|
|
this:SetScript("OnUpdate", nil)
|
|
this:UnregisterAllEvents()
|
|
this:SetScript("OnEvent", nil)
|
|
|
|
if GetNampowerVersion then
|
|
local major, minor, patch = GetNampowerVersion()
|
|
patch = patch or 0
|
|
local versionString = major .. "." .. minor .. "." .. patch
|
|
|
|
if major > 3 or (major == 3 and minor > 0) or (major == 3 and minor == 0 and patch >= 0) then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r Nampower v" .. versionString .. " detected - GetUnitField mode enabled!")
|
|
|
|
if SetCVar and GetCVar then
|
|
local cvarsToEnable = {
|
|
"NP_EnableSpellStartEvents",
|
|
"NP_EnableSpellGoEvents",
|
|
"NP_EnableAuraCastEvents",
|
|
"NP_EnableAutoAttackEvents",
|
|
"NP_EnableSpellHealEvents",
|
|
}
|
|
local enabledCount = 0
|
|
local alreadyEnabledCount = 0
|
|
local failedCount = 0
|
|
|
|
for _, cvar in ipairs(cvarsToEnable) do
|
|
local success, currentValue = pcall(GetCVar, cvar)
|
|
if success and currentValue then
|
|
if currentValue == "1" then
|
|
alreadyEnabledCount = alreadyEnabledCount + 1
|
|
else
|
|
local setSuccess = pcall(SetCVar, cvar, "1")
|
|
if setSuccess then enabledCount = enabledCount + 1
|
|
else failedCount = failedCount + 1 end
|
|
end
|
|
else
|
|
failedCount = failedCount + 1
|
|
end
|
|
end
|
|
|
|
if enabledCount > 0 then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r Enabled " .. enabledCount .. " Nampower CVars")
|
|
elseif alreadyEnabledCount == table.getn(cvarsToEnable) then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r All required Nampower CVars already enabled")
|
|
end
|
|
if failedCount > 0 then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffffcc00[libdebuff]|r Warning: Could not check/set " .. failedCount .. " CVars")
|
|
end
|
|
end
|
|
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff] Debuff tracking disabled! Please update Nampower to v3.0.0 or higher.|r")
|
|
StaticPopup_Show("LIBDEBUFF_NAMPOWER_UPDATE", versionString)
|
|
end
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff] Nampower not found! Debuff tracking disabled.|r")
|
|
StaticPopup_Show("LIBDEBUFF_NAMPOWER_MISSING")
|
|
end
|
|
end)
|
|
end)
|
|
|
|
-- ============================================================================
|
|
-- DATA STRUCTURES (Simplified - no more manual slot tracking!)
|
|
-- ============================================================================
|
|
|
|
-- ownDebuffs: [targetGUID][spellName] = {startTime, duration, texture, rank}
|
|
-- Timer data for OUR debuffs only
|
|
pfUI.libdebuff_own = pfUI.libdebuff_own or {}
|
|
local ownDebuffs = pfUI.libdebuff_own
|
|
|
|
-- allAuraCasts: [targetGUID][spellName][casterGuid] = {startTime, duration, rank}
|
|
-- Timer data for ALL debuffs (multi-caster support)
|
|
pfUI.libdebuff_all_auras = pfUI.libdebuff_all_auras or {}
|
|
local allAuraCasts = pfUI.libdebuff_all_auras
|
|
|
|
-- slotOwnership: [targetGUID][auraSlot] = {casterGuid, spellName, spellId}
|
|
-- Maps REAL aura slots (33-48) to caster info - NO SHIFTING NEEDED!
|
|
pfUI.libdebuff_slot_ownership = pfUI.libdebuff_slot_ownership or {}
|
|
local slotOwnership = pfUI.libdebuff_slot_ownership
|
|
|
|
-- displayToAura: [targetGUID][displaySlot] = auraSlot
|
|
-- Maps DISPLAY slots (1-16) to REAL aura slots (33-48) for DEBUFF_REMOVED correlation
|
|
pfUI.libdebuff_display_to_aura = pfUI.libdebuff_display_to_aura or {}
|
|
local displayToAura = pfUI.libdebuff_display_to_aura
|
|
|
|
-- pendingCasts: [targetGUID][spellName] = {casterGuid, rank, time}
|
|
-- Temporary storage from SPELL_GO to correlate with DEBUFF_ADDED
|
|
pfUI.libdebuff_pending = pfUI.libdebuff_pending or {}
|
|
local pendingCasts = pfUI.libdebuff_pending
|
|
|
|
-- Spell Icon Cache: [spellId] = texture
|
|
pfUI.libdebuff_icon_cache = pfUI.libdebuff_icon_cache or {}
|
|
local iconCache = pfUI.libdebuff_icon_cache
|
|
|
|
-- Cast Tracking: [casterGuid] = {spellID, spellName, icon, startTime, duration, endTime}
|
|
-- Shared with nameplates for cast-bar display
|
|
pfUI.libdebuff_casts = pfUI.libdebuff_casts or {}
|
|
pfUI.libdebuff_item_icons = pfUI.libdebuff_item_icons or {} -- [casterGuid] = icon (persists across SPELL_GO)
|
|
|
|
-- Cleveroids API: [targetGUID][spellID] = {start, duration, caster, stacks}
|
|
pfUI.libdebuff_objects_guid = pfUI.libdebuff_objects_guid or {}
|
|
local objectsByGuid = pfUI.libdebuff_objects_guid
|
|
|
|
-- LEGACY: Keep these for backwards compatibility (external modules might check them)
|
|
pfUI.libdebuff_own_slots = pfUI.libdebuff_own_slots or {}
|
|
pfUI.libdebuff_all_slots = pfUI.libdebuff_all_slots or {}
|
|
|
|
-- Deduplication: Track recent AURA_CAST events to ignore duplicates
|
|
-- [targetGuid][spellName][casterGuid] = timestamp
|
|
pfUI.libdebuff_recent_casts = pfUI.libdebuff_recent_casts or {}
|
|
local recentCasts = pfUI.libdebuff_recent_casts
|
|
|
|
-- Callbacks fired after SPELL_GO_SELF is processed: fn(spellId, arg1, arg2, arg3, arg4, arg5, arg6, arg7)
|
|
pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {}
|
|
|
|
-- Callbacks fired after SPELL_GO_OTHER is processed: fn(spellId, casterGuid, targetGuid)
|
|
pfUI.libdebuff_spell_go_other_hooks = pfUI.libdebuff_spell_go_other_hooks or {}
|
|
|
|
-- Callbacks fired after SPELL_START_SELF is processed: fn(spellId, casterGuid, targetGuid, castTime)
|
|
pfUI.libdebuff_spell_start_self_hooks = pfUI.libdebuff_spell_start_self_hooks or {}
|
|
|
|
-- Callbacks fired after SPELL_START_OTHER is processed: fn(spellId, casterGuid, targetGuid, castTime)
|
|
pfUI.libdebuff_spell_start_other_hooks = pfUI.libdebuff_spell_start_other_hooks or {}
|
|
|
|
-- Callbacks fired after SPELL_FAILED_OTHER is processed: fn(casterGuid, spellId)
|
|
pfUI.libdebuff_spell_failed_other_hooks = pfUI.libdebuff_spell_failed_other_hooks or {}
|
|
|
|
-- Callbacks fired after AURA_CAST_ON_SELF is processed: fn(spellId, casterGuid, targetGuid)
|
|
pfUI.libdebuff_aura_cast_on_self_hooks = pfUI.libdebuff_aura_cast_on_self_hooks or {}
|
|
|
|
-- Callbacks fired after AURA_CAST_ON_OTHER is processed: fn(spellId, casterGuid, targetGuid)
|
|
pfUI.libdebuff_aura_cast_on_other_hooks = pfUI.libdebuff_aura_cast_on_other_hooks or {}
|
|
|
|
-- Callbacks fired after DEBUFF_ADDED_OTHER is processed: fn(guid, luaSlot, spellId, stackCount)
|
|
pfUI.libdebuff_debuff_added_other_hooks = pfUI.libdebuff_debuff_added_other_hooks or {}
|
|
|
|
-- Callbacks fired after DEBUFF_REMOVED_OTHER is processed: fn(guid, luaSlot, spellId, stackCount)
|
|
pfUI.libdebuff_debuff_removed_other_hooks = pfUI.libdebuff_debuff_removed_other_hooks or {}
|
|
|
|
-- Callbacks fired after UNIT_HEALTH is processed: fn(unitToken)
|
|
pfUI.libdebuff_unit_health_hooks = pfUI.libdebuff_unit_health_hooks or {}
|
|
|
|
-- Callbacks fired after PLAYER_TARGET_CHANGED is processed: fn()
|
|
pfUI.libdebuff_player_target_changed_hooks = pfUI.libdebuff_player_target_changed_hooks or {}
|
|
|
|
-- Callbacks fired after UNIT_DIED is processed: fn(guid)
|
|
pfUI.libdebuff_unit_died_hooks = pfUI.libdebuff_unit_died_hooks or {}
|
|
|
|
-- Callbacks fired after SPELL_CAST_EVENT is processed: fn(success, spellId, castType, targetGuid)
|
|
pfUI.libdebuff_spell_cast_hooks = pfUI.libdebuff_spell_cast_hooks or {}
|
|
local AURA_CAST_DEDUPE_WINDOW = 0.1 -- Ignore duplicates within 100ms
|
|
|
|
-- Captured combo points from SPELL_CAST_EVENT (before client consumes them)
|
|
-- SPELL_CAST_EVENT fires BEFORE UnitAura updates, so GetComboPoints() still works
|
|
local capturedCP = nil
|
|
|
|
-- Pending cast info for libpredict (heal prediction target tracking)
|
|
-- SPELL_CAST_EVENT fires with targetGuid BEFORE SPELLCAST_START,
|
|
-- which allows libpredict to know the correct target for queued casts.
|
|
-- Fields: { spellId, spellName, targetGuid, time }
|
|
pfUI.libpredict_pending_cast = pfUI.libpredict_pending_cast or {}
|
|
|
|
-- ============================================================================
|
|
-- STATIC POPUP DIALOGS
|
|
-- ============================================================================
|
|
|
|
StaticPopupDialogs["LIBDEBUFF_NAMPOWER_UPDATE"] = {
|
|
text = "|cffff0000!!!WARNING!!!|r\n\nNampower Update Required!\n\nYour current version: %s\nRequired version: 3.0.0+\n\nPlease update Nampower to continue using pfUI!",
|
|
button1 = "Show Download",
|
|
button2 = "Dismiss",
|
|
timeout = 0,
|
|
whileDead = 1,
|
|
hideOnEscape = 0,
|
|
preferredIndex = 3,
|
|
OnAccept = function()
|
|
pfUI.chat.urlcopy.CopyText("https://github.com/me0wg4ming/pfUI/wiki/How-to-install-nampower")
|
|
end,
|
|
}
|
|
|
|
StaticPopupDialogs["LIBDEBUFF_NAMPOWER_MISSING"] = {
|
|
text = "|cffff0000!!!WARNING!!!|r\n\nNampower Not Found!\n\nNampower 3.0.0+ is required for pfUI to function correctly.\n\nPlease install Nampower!",
|
|
button1 = "Show Download",
|
|
button2 = "Dismiss",
|
|
timeout = 0,
|
|
whileDead = 1,
|
|
hideOnEscape = 0,
|
|
preferredIndex = 3,
|
|
OnAccept = function()
|
|
pfUI.chat.urlcopy.CopyText("https://github.com/me0wg4ming/pfUI/wiki/How-to-install-nampower")
|
|
end,
|
|
}
|
|
|
|
-- ============================================================================
|
|
-- SPELL DATA TABLES
|
|
-- ============================================================================
|
|
|
|
-- Debuffs that only ONE player can have on target (overwrites other casters)
|
|
local selfOverwriteDebuffs = {
|
|
["Faerie Fire"] = true,
|
|
["Faerie Fire (Feral)"] = true,
|
|
["Demoralizing Shout"] = true,
|
|
["Demoralizing Roar"] = true,
|
|
["Hunter's Mark"] = true,
|
|
["Sunder Armor"] = true,
|
|
["Thunder Clap"] = true,
|
|
["Expose Armor"] = true,
|
|
["Curse of Weakness"] = true,
|
|
["Curse of Recklessness"] = true,
|
|
["Curse of the Elements"] = true,
|
|
["Curse of Shadow"] = true,
|
|
["Curse of Tongues"] = true,
|
|
["Curse of Exhaustion"] = true,
|
|
["Judgement of Wisdom"] = true,
|
|
["Judgement of Light"] = true,
|
|
["Judgement of the Crusader"] = true,
|
|
["Judgement of Justice"] = true,
|
|
["Shadow Weaving"] = true,
|
|
["Winter's Chill"] = true,
|
|
}
|
|
|
|
-- Debuff pairs that overwrite each other
|
|
local debuffOverwritePairs = {
|
|
["Faerie Fire"] = "Faerie Fire (Feral)",
|
|
["Faerie Fire (Feral)"] = "Faerie Fire",
|
|
["Demoralizing Shout"] = "Demoralizing Roar",
|
|
["Demoralizing Roar"] = "Demoralizing Shout",
|
|
}
|
|
|
|
-- Combopoint-based abilities: Only show timers for OUR casts
|
|
-- Format: [spellName] = { base = N, perCP = N }
|
|
-- Duration formula: duration = base + combopoints * perCP
|
|
local combopointAbilities = {
|
|
-- Druid
|
|
["Rip"] = { base = 8, perCP = 2 },
|
|
|
|
-- Rogue
|
|
["Rupture"] = { base = 6, perCP = 2 },
|
|
["Kidney Shot"] = { base = 1, perCP = 1 },
|
|
["Slice and Dice"] = { base = 9, perCP = 3 },
|
|
["Expose Armor"] = { base = 30, perCP = 0 }, -- fixed 30s
|
|
}
|
|
|
|
-- ============================================================================
|
|
-- HELPER FUNCTIONS
|
|
-- ============================================================================
|
|
|
|
-- Check if spell is a combo-point ability
|
|
local function IsComboPointAbility(spellName)
|
|
if not spellName then return false end
|
|
return combopointAbilities[spellName] ~= nil
|
|
end
|
|
|
|
-- Get combo-point spell data (base duration and per-CP bonus)
|
|
local function GetComboPointData(spellName)
|
|
if not spellName then return nil, nil end
|
|
local cpData = combopointAbilities[spellName]
|
|
if cpData then
|
|
return cpData.base, cpData.perCP
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
-- Player GUID Cache
|
|
local playerGUID = nil
|
|
local function GetPlayerGUID()
|
|
if not playerGUID and GetUnitGUID then
|
|
local guid = GetUnitGUID("player")
|
|
playerGUID = guid
|
|
end
|
|
return playerGUID
|
|
end
|
|
|
|
-- Debug Stats
|
|
pfUI.libdebuff_debugstats = pfUI.libdebuff_debugstats or {
|
|
enabled = false,
|
|
trackAllUnits = false,
|
|
aura_cast = 0,
|
|
debuff_added = 0,
|
|
debuff_removed = 0,
|
|
getunitfield_calls = 0,
|
|
}
|
|
local debugStats = pfUI.libdebuff_debugstats
|
|
|
|
local function DebugGuid(guid)
|
|
if not guid then return "nil" end
|
|
local str = tostring(guid)
|
|
if string.len(str) > 4 then
|
|
return string.sub(str, -4)
|
|
end
|
|
return str
|
|
end
|
|
|
|
local function IsCurrentTarget(guid)
|
|
if debugStats.trackAllUnits then return true end
|
|
if not guid or not GetUnitGUID then return false end
|
|
local targetGuid = GetUnitGUID("target")
|
|
return targetGuid == guid
|
|
end
|
|
|
|
local function GetDebugTimestamp()
|
|
return string.format("[%.3f]", GetTime())
|
|
end
|
|
|
|
-- Speichert die Ranks der zuletzt gecasteten Spells
|
|
pfUI.libdebuff_lastranks = pfUI.libdebuff_lastranks or {}
|
|
local lastCastRanks = pfUI.libdebuff_lastranks
|
|
|
|
-- Speichert Spells die gefailed sind
|
|
pfUI.libdebuff_lastfailed = pfUI.libdebuff_lastfailed or {}
|
|
local lastFailedSpells = pfUI.libdebuff_lastfailed
|
|
|
|
-- Get spell icon texture (with caching)
|
|
function libdebuff:GetSpellIcon(spellId)
|
|
if not spellId or type(spellId) ~= "number" or spellId <= 0 then
|
|
return "Interface\\Icons\\INV_Misc_QuestionMark"
|
|
end
|
|
|
|
if iconCache[spellId] then
|
|
return iconCache[spellId]
|
|
end
|
|
|
|
local texture = nil
|
|
|
|
if GetSpellRecField and GetSpellIconTexture then
|
|
local spellIconId = GetSpellRecField(spellId, "spellIconID")
|
|
if spellIconId and type(spellIconId) == "number" and spellIconId > 0 then
|
|
texture = GetSpellIconTexture(spellIconId)
|
|
-- GetSpellIconTexture may return short name, needs full path for SetTexture
|
|
if texture and not string.find(texture, "\\") then
|
|
texture = "Interface\\Icons\\" .. texture
|
|
end
|
|
end
|
|
end
|
|
|
|
if not texture then
|
|
texture = "Interface\\Icons\\INV_Misc_QuestionMark"
|
|
end
|
|
|
|
iconCache[spellId] = texture
|
|
return texture
|
|
end
|
|
|
|
pfUI.libdebuff_GetSpellIcon = function(spellId)
|
|
return libdebuff:GetSpellIcon(spellId)
|
|
end
|
|
|
|
function libdebuff:DidSpellFail(spell)
|
|
if not spell then return false end
|
|
local data = lastFailedSpells[spell]
|
|
if data and (GetTime() - data.time) < 1 then
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- CORE: GetUnitField-based Slot Mapping (THE KEY INNOVATION!)
|
|
-- ============================================================================
|
|
|
|
-- Cache for GetDebuffSlotMap to reduce GetUnitField calls
|
|
-- [guid] = {map, timestamp}
|
|
local slotMapCache = {}
|
|
local SLOT_MAP_CACHE_DURATION = 0.05 -- 50ms cache (1-2 frames)
|
|
|
|
-- Dispel type mapping: SpellRec.dispel index -> Blizzard DebuffTypeColor key
|
|
local dispelTypeMap = {
|
|
[1] = "Magic",
|
|
[2] = "Curse",
|
|
[3] = "Disease",
|
|
[4] = "Poison",
|
|
}
|
|
|
|
-- Get current debuff state directly from WoW via GetUnitField
|
|
-- Returns: { [displaySlot] = {auraSlot, spellId, spellName, stacks, texture, dtype} }
|
|
local function GetDebuffSlotMap(guid)
|
|
if not guid or not GetUnitField then
|
|
return nil
|
|
end
|
|
|
|
-- Check cache first
|
|
local now = GetTime()
|
|
local cached = slotMapCache[guid]
|
|
if cached and cached.map and (now - cached.timestamp) < SLOT_MAP_CACHE_DURATION then
|
|
return cached.map
|
|
end
|
|
|
|
local auras = GetUnitField(guid, "aura")
|
|
if not auras then return nil end
|
|
|
|
-- Fetch stacks array (reusable reference - extract values immediately)
|
|
local auraApps = GetUnitField(guid, "auraApplications")
|
|
|
|
if debugStats.enabled then
|
|
debugStats.getunitfield_calls = debugStats.getunitfield_calls + 1
|
|
end
|
|
|
|
local map = {}
|
|
local displaySlot = 0
|
|
|
|
-- Debuff aura slots are 33-48
|
|
for auraSlot = 33, 48 do
|
|
local spellId = auras[auraSlot]
|
|
if spellId and spellId > 0 then
|
|
displaySlot = displaySlot + 1
|
|
local spellName = GetSpellRecField and GetSpellRecField(spellId, "name")
|
|
local texture = libdebuff:GetSpellIcon(spellId)
|
|
|
|
-- Get stacks from auraApplications (0-indexed, so +1 for display)
|
|
local stacks = (auraApps and auraApps[auraSlot] or 0) + 1
|
|
|
|
-- Get debuff type from SpellRec DBC
|
|
local dtype = nil
|
|
if GetSpellRecField then
|
|
local dispelId = GetSpellRecField(spellId, "dispel")
|
|
if dispelId and dispelId > 0 then
|
|
dtype = dispelTypeMap[dispelId]
|
|
end
|
|
end
|
|
|
|
map[displaySlot] = {
|
|
auraSlot = auraSlot,
|
|
spellId = spellId,
|
|
spellName = spellName or "Unknown",
|
|
stacks = stacks,
|
|
texture = texture,
|
|
dtype = dtype
|
|
}
|
|
end
|
|
end
|
|
|
|
-- Cache the result (separate from buffMap to avoid cross-invalidation)
|
|
if not slotMapCache[guid] then
|
|
slotMapCache[guid] = { timestamp = now }
|
|
end
|
|
slotMapCache[guid].map = map
|
|
slotMapCache[guid].timestamp = now
|
|
|
|
return map
|
|
end
|
|
|
|
-- Get caster info for a specific aura slot
|
|
local function GetSlotCaster(guid, auraSlot, spellName)
|
|
-- First check our ownership tracking
|
|
if slotOwnership[guid] and slotOwnership[guid][auraSlot] then
|
|
local ownership = slotOwnership[guid][auraSlot]
|
|
-- Verify spell name matches (slot might have been reused)
|
|
if ownership.spellName == spellName then
|
|
return ownership.casterGuid, ownership.isOurs
|
|
end
|
|
end
|
|
|
|
-- Fallback: Check ownDebuffs
|
|
local myGuid = GetPlayerGUID()
|
|
if ownDebuffs[guid] and ownDebuffs[guid][spellName] then
|
|
return myGuid, true
|
|
end
|
|
|
|
-- Fallback: Check allAuraCasts for any caster
|
|
if allAuraCasts[guid] and allAuraCasts[guid][spellName] then
|
|
for casterGuid, data in pairs(allAuraCasts[guid][spellName]) do
|
|
local timeleft = (data.startTime + data.duration) - GetTime()
|
|
if timeleft > 0 then
|
|
return casterGuid, (casterGuid == myGuid)
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil, false
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- CLEANUP FUNCTIONS
|
|
-- ============================================================================
|
|
|
|
local lastRangeCheck = 0
|
|
|
|
-- Recycled buffers for cleanup (avoids table creation per call)
|
|
local _cleanupBuf1 = {}
|
|
local _cleanupBuf2 = {}
|
|
|
|
local function CleanupUnit(guid)
|
|
if not guid then return false end
|
|
|
|
local cleaned = false
|
|
|
|
if ownDebuffs[guid] then
|
|
ownDebuffs[guid] = nil
|
|
cleaned = true
|
|
end
|
|
|
|
if slotOwnership[guid] then
|
|
slotOwnership[guid] = nil
|
|
cleaned = true
|
|
end
|
|
|
|
if allAuraCasts[guid] then
|
|
allAuraCasts[guid] = nil
|
|
cleaned = true
|
|
end
|
|
|
|
if objectsByGuid[guid] then
|
|
objectsByGuid[guid] = nil
|
|
cleaned = true
|
|
end
|
|
|
|
if pendingCasts[guid] then
|
|
pendingCasts[guid] = nil
|
|
cleaned = true
|
|
end
|
|
|
|
if debugStats.enabled and cleaned and IsCurrentTarget(guid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[CLEANUP]|r GUID %s", DebugGuid(guid)))
|
|
end
|
|
|
|
return cleaned
|
|
end
|
|
|
|
local function CleanupExpiredTimers(guid)
|
|
local now = GetTime()
|
|
|
|
-- Cleanup ownDebuffs
|
|
if ownDebuffs[guid] then
|
|
local n = 0
|
|
for spellName, data in pairs(ownDebuffs[guid]) do
|
|
local timeleft = (data.startTime + data.duration) - now
|
|
if timeleft < -2 then -- Grace period
|
|
n = n + 1
|
|
_cleanupBuf1[n] = spellName
|
|
end
|
|
end
|
|
for i = 1, n do
|
|
ownDebuffs[guid][_cleanupBuf1[i]] = nil
|
|
_cleanupBuf1[i] = nil
|
|
end
|
|
end
|
|
|
|
-- Cleanup allAuraCasts
|
|
if allAuraCasts[guid] then
|
|
for spellName, casterTable in pairs(allAuraCasts[guid]) do
|
|
local n2 = 0
|
|
for casterGuid, data in pairs(casterTable) do
|
|
local timeleft = (data.startTime + data.duration) - now
|
|
if timeleft < -2 then
|
|
n2 = n2 + 1
|
|
_cleanupBuf2[n2] = casterGuid
|
|
end
|
|
end
|
|
for i = 1, n2 do
|
|
allAuraCasts[guid][spellName][_cleanupBuf2[i]] = nil
|
|
_cleanupBuf2[i] = nil
|
|
end
|
|
-- Remove empty spell tables
|
|
local hasCasters = false
|
|
for _ in pairs(allAuraCasts[guid][spellName]) do
|
|
hasCasters = true
|
|
break
|
|
end
|
|
if not hasCasters then
|
|
allAuraCasts[guid][spellName] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function CleanupOutOfRangeUnits()
|
|
local now = GetTime()
|
|
-- Cleanup expired pending entries (every 1s)
|
|
if not pfUI.libdebuff_last_pending_cleanup then pfUI.libdebuff_last_pending_cleanup = 0 end
|
|
if now - pfUI.libdebuff_last_pending_cleanup >= 1 then
|
|
pfUI.libdebuff_last_pending_cleanup = now
|
|
for guid, spells in pairs(ownDebuffs) do
|
|
for spellName, data in pairs(spells) do
|
|
if data.pending then
|
|
local timeleft = (data.startTime + data.duration) - now
|
|
if timeleft < -2 then
|
|
spells[spellName] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if now - lastRangeCheck < 10 then return end
|
|
lastRangeCheck = now
|
|
|
|
local allGuids = {}
|
|
for guid in pairs(ownDebuffs) do allGuids[guid] = true end
|
|
for guid in pairs(slotOwnership) do allGuids[guid] = true end
|
|
for guid in pairs(allAuraCasts) do allGuids[guid] = true end
|
|
for guid in pairs(objectsByGuid) do allGuids[guid] = true end
|
|
for guid in pairs(pendingCasts) do allGuids[guid] = true end
|
|
|
|
for guid in pairs(allGuids) do
|
|
local exists = UnitExists and UnitExists(guid)
|
|
local isDead = UnitIsDead and UnitIsDead(guid)
|
|
|
|
if not exists or isDead then
|
|
CleanupUnit(guid)
|
|
end
|
|
end
|
|
|
|
-- Cleanup old lastCastRanks
|
|
for spell, data in pairs(lastCastRanks) do
|
|
if now - data.time > 3 then
|
|
lastCastRanks[spell] = nil
|
|
end
|
|
end
|
|
|
|
-- Cleanup old lastFailedSpells
|
|
for spell, data in pairs(lastFailedSpells) do
|
|
if now - data.time > 2 then
|
|
lastFailedSpells[spell] = nil
|
|
end
|
|
end
|
|
|
|
-- Cleanup old pendingCasts
|
|
for guid, spells in pairs(pendingCasts) do
|
|
for spell, data in pairs(spells) do
|
|
if now - data.time > 1 then
|
|
pendingCasts[guid][spell] = nil
|
|
end
|
|
end
|
|
local isEmpty = true
|
|
for _ in pairs(pendingCasts[guid]) do
|
|
isEmpty = false
|
|
break
|
|
end
|
|
if isEmpty then
|
|
pendingCasts[guid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- DURATION FUNCTIONS
|
|
-- ============================================================================
|
|
|
|
function libdebuff:GetDuration(effect, rank)
|
|
if L["debuffs"][effect] then
|
|
local rank = rank and tonumber((string.gsub(rank, RANK, ""))) or 0
|
|
local rank = L["debuffs"][effect][rank] and rank or libdebuff:GetMaxRank(effect)
|
|
local duration = L["debuffs"][effect][rank]
|
|
|
|
if effect == L["dyndebuffs"]["Rupture"] then
|
|
local cp = GetComboPoints() or 0
|
|
duration = duration + cp*2
|
|
elseif effect == L["dyndebuffs"]["Kidney Shot"] then
|
|
local cp = GetComboPoints() or 0
|
|
duration = duration + cp*1
|
|
elseif effect == "Rip" or effect == L["dyndebuffs"]["Rip"] then
|
|
local cp = GetComboPoints() or 0
|
|
duration = 8 + cp*2
|
|
elseif effect == L["dyndebuffs"]["Demoralizing Shout"] then
|
|
local _,_,_,_,count = GetTalentInfo(2,1)
|
|
if count and count > 0 then duration = duration + ( duration / 100 * (count*10)) end
|
|
elseif effect == L["dyndebuffs"]["Shadow Word: Pain"] then
|
|
local _,_,_,_,count = GetTalentInfo(3,4)
|
|
if count and count > 0 then duration = duration + count * 3 end
|
|
elseif effect == L["dyndebuffs"]["Frostbolt"] then
|
|
local _,_,_,_,count = GetTalentInfo(3,7)
|
|
if count and count > 0 then duration = duration + count end
|
|
elseif effect == L["dyndebuffs"]["Gouge"] then
|
|
local _,_,_,_,count = GetTalentInfo(3,3)
|
|
if count and count > 0 then duration = duration + (count*.5) end
|
|
end
|
|
return duration
|
|
else
|
|
return 0
|
|
end
|
|
end
|
|
|
|
function libdebuff:GetMaxRank(effect)
|
|
local max = 0
|
|
for id in pairs(L["debuffs"][effect]) do
|
|
if id > max then max = id end
|
|
end
|
|
return max
|
|
end
|
|
|
|
function libdebuff:UpdateDuration(unit, unitlevel, effect, duration)
|
|
if not unit or not effect or not duration then return end
|
|
unitlevel = unitlevel or 0
|
|
|
|
if libdebuff.objects[unit] and libdebuff.objects[unit][unitlevel] and libdebuff.objects[unit][unitlevel][effect] then
|
|
libdebuff.objects[unit][unitlevel][effect].duration = duration
|
|
end
|
|
end
|
|
|
|
function libdebuff:UpdateUnits()
|
|
if not pfUI.uf or not pfUI.uf.target then return end
|
|
pfUI.uf:RefreshUnit(pfUI.uf.target, "aura")
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- LEGACY API (for turtle-wow.lua compatibility)
|
|
-- ============================================================================
|
|
|
|
libdebuff.pending = {}
|
|
libdebuff.objects = {}
|
|
|
|
function libdebuff:AddPending(unit, unitlevel, effect, duration, caster, rank)
|
|
if not unit or duration <= 0 then return end
|
|
if not L["debuffs"][effect] then return end
|
|
if libdebuff.pending[3] then return end
|
|
|
|
libdebuff.pending[1] = unit
|
|
libdebuff.pending[2] = unitlevel or 0
|
|
libdebuff.pending[3] = effect
|
|
libdebuff.pending[4] = duration
|
|
libdebuff.pending[5] = caster
|
|
libdebuff.pending[6] = rank
|
|
|
|
QueueFunction(libdebuff.PersistPending)
|
|
end
|
|
|
|
function libdebuff:RemovePending()
|
|
libdebuff.pending[1] = nil
|
|
libdebuff.pending[2] = nil
|
|
libdebuff.pending[3] = nil
|
|
libdebuff.pending[4] = nil
|
|
libdebuff.pending[5] = nil
|
|
libdebuff.pending[6] = nil
|
|
end
|
|
|
|
function libdebuff:PersistPending(effect)
|
|
if not libdebuff.pending[3] then return end
|
|
|
|
if libdebuff.pending[3] == effect or ( effect == nil and libdebuff.pending[3] ) then
|
|
local p1, p2, p3, p4, p5, p6 = libdebuff.pending[1], libdebuff.pending[2], libdebuff.pending[3], libdebuff.pending[4], libdebuff.pending[5], libdebuff.pending[6]
|
|
libdebuff.AddEffect(libdebuff, p1, p2, p3, p4, p5, p6)
|
|
end
|
|
|
|
libdebuff:RemovePending()
|
|
end
|
|
|
|
function libdebuff:AddEffect(unit, unitlevel, effect, duration, caster, rank)
|
|
if not rank and caster == "player" and effect then
|
|
if libdebuff.pending[3] == effect and libdebuff.pending[6] then
|
|
rank = libdebuff.pending[6]
|
|
elseif lastCastRanks[effect] and (GetTime() - lastCastRanks[effect].time) < 2 then
|
|
rank = lastCastRanks[effect].rank
|
|
end
|
|
end
|
|
|
|
if not unit then return end
|
|
unitlevel = unitlevel or 0
|
|
|
|
-- Create tables if needed
|
|
libdebuff.objects[unit] = libdebuff.objects[unit] or {}
|
|
libdebuff.objects[unit][unitlevel] = libdebuff.objects[unit][unitlevel] or {}
|
|
|
|
-- Get duration from spell database if not provided
|
|
if not duration or duration == 0 then
|
|
duration = libdebuff:GetDuration(effect, rank)
|
|
end
|
|
|
|
-- Store/update effect
|
|
local now = GetTime()
|
|
local existing = libdebuff.objects[unit][unitlevel][effect]
|
|
|
|
if existing then
|
|
existing.start = now
|
|
existing.duration = duration
|
|
existing.caster = caster
|
|
existing.rank = rank
|
|
else
|
|
libdebuff.objects[unit][unitlevel][effect] = {
|
|
start = now,
|
|
duration = duration,
|
|
caster = caster,
|
|
rank = rank
|
|
}
|
|
end
|
|
|
|
lastspell = libdebuff.objects[unit][unitlevel][effect]
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- MAIN API: UnitDebuff (GetUnitField-based)
|
|
-- ============================================================================
|
|
|
|
local cache = {}
|
|
|
|
function libdebuff:UnitDebuff(unit, displaySlot)
|
|
local unitname = UnitName(unit)
|
|
local unitlevel = UnitLevel(unit)
|
|
local duration, timeleft = nil, -1
|
|
local rank = nil
|
|
local caster = nil
|
|
local effect = nil
|
|
local texture = nil
|
|
local stacks = 0
|
|
local dtype = nil
|
|
|
|
-- Nampower: Use GetUnitField for ALL debuff data (no Blizzard UnitDebuff needed)
|
|
if hasNampower and GetUnitGUID then
|
|
local guid = GetUnitGUID(unit)
|
|
if not guid then
|
|
-- Safety fallback: no GUID available (should not happen with Nampower)
|
|
local bTexture, bStacks, bDtype = UnitDebuff(unit, displaySlot)
|
|
if bTexture then
|
|
scanner:SetUnitDebuff(unit, displaySlot)
|
|
effect = scanner:Line(1) or ""
|
|
end
|
|
return effect, rank, bTexture, bStacks, bDtype, duration, timeleft, caster
|
|
end
|
|
|
|
-- Get current slot map from GetUnitField (cached 50ms)
|
|
local slotMap = GetDebuffSlotMap(guid)
|
|
if not slotMap or not slotMap[displaySlot] then
|
|
return nil
|
|
end
|
|
|
|
local slotData = slotMap[displaySlot]
|
|
effect = slotData.spellName
|
|
texture = slotData.texture
|
|
stacks = slotData.stacks
|
|
dtype = slotData.dtype
|
|
local auraSlot = slotData.auraSlot
|
|
|
|
-- Get caster info for this slot
|
|
local slotCasterGuid, isOurs = GetSlotCaster(guid, auraSlot, effect)
|
|
|
|
if isOurs then
|
|
-- OUR debuff - get timer from ownDebuffs
|
|
if ownDebuffs[guid] and ownDebuffs[guid][effect] then
|
|
local data = ownDebuffs[guid][effect]
|
|
local remaining = (data.startTime + data.duration) - GetTime()
|
|
if remaining > 0 then
|
|
duration = data.duration
|
|
timeleft = remaining
|
|
caster = "player"
|
|
rank = data.rank
|
|
elseif remaining > -1 then
|
|
-- Grace period - show 0 timeleft
|
|
duration = data.duration
|
|
timeleft = 0
|
|
caster = "player"
|
|
rank = data.rank
|
|
end
|
|
end
|
|
else
|
|
-- OTHER player's debuff - get timer from allAuraCasts
|
|
if slotCasterGuid and allAuraCasts[guid] and allAuraCasts[guid][effect] then
|
|
local data = allAuraCasts[guid][effect][slotCasterGuid]
|
|
if data then
|
|
local remaining = (data.startTime + data.duration) - GetTime()
|
|
if remaining > 0 and data.duration > 0 then
|
|
duration = data.duration
|
|
timeleft = remaining
|
|
caster = "other"
|
|
rank = data.rank
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Fallback: Search all casters if specific one not found
|
|
if not duration and allAuraCasts[guid] and allAuraCasts[guid][effect] then
|
|
for anyCasterGuid, data in pairs(allAuraCasts[guid][effect]) do
|
|
local remaining = (data.startTime + data.duration) - GetTime()
|
|
if remaining > 0 and data.duration > 0 then
|
|
duration = data.duration
|
|
timeleft = remaining
|
|
caster = "other"
|
|
rank = data.rank
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- FALLBACK: Legacy (non-Nampower) system
|
|
-- ============================================================================
|
|
|
|
local bTexture, bStacks, bDtype = UnitDebuff(unit, displaySlot)
|
|
texture = bTexture
|
|
stacks = bStacks
|
|
dtype = bDtype
|
|
|
|
if texture then
|
|
scanner:SetUnitDebuff(unit, displaySlot)
|
|
effect = scanner:Line(1) or ""
|
|
end
|
|
|
|
if effect and libdebuff.objects[unitname] then
|
|
for level, effects in pairs(libdebuff.objects[unitname]) do
|
|
if effects[effect] and effects[effect].duration then
|
|
local timeleft = effects[effect].start and
|
|
effects[effect].start + effects[effect].duration - GetTime()
|
|
|
|
if timeleft and timeleft > 0 then
|
|
return effect, effects[effect].rank, texture, stacks, dtype,
|
|
effects[effect].duration, timeleft, effects[effect].caster
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- API: UnitOwnDebuff (only OUR debuffs)
|
|
-- ============================================================================
|
|
|
|
-- Pre-defined sort function for UnitOwnDebuff (avoids closure creation per call)
|
|
local _ownDebuffSortFunc = function(a, b)
|
|
if a.data.startTime == b.data.startTime then
|
|
return a.spellName < b.spellName
|
|
end
|
|
return a.data.startTime < b.data.startTime
|
|
end
|
|
|
|
function libdebuff:UnitOwnDebuff(unit, id)
|
|
if hasNampower and GetUnitGUID then
|
|
local guid = GetUnitGUID(unit)
|
|
if guid and ownDebuffs[guid] then
|
|
-- Build sorted list of our active debuffs
|
|
local sortedDebuffs = {}
|
|
local now = GetTime()
|
|
|
|
local toRemove = nil
|
|
for spellName, data in pairs(ownDebuffs[guid]) do
|
|
local timeleft = (data.startTime + data.duration) - now
|
|
if timeleft > 0 then
|
|
local count = table.getn(sortedDebuffs) + 1
|
|
sortedDebuffs[count] = {
|
|
spellName = spellName,
|
|
data = data,
|
|
timeleft = timeleft
|
|
}
|
|
elseif data.pending then
|
|
if timeleft < -2 then
|
|
toRemove = toRemove or {}
|
|
toRemove[spellName] = true
|
|
end
|
|
else
|
|
toRemove = toRemove or {}
|
|
toRemove[spellName] = true
|
|
end
|
|
end
|
|
if toRemove then
|
|
for spellName in pairs(toRemove) do
|
|
ownDebuffs[guid][spellName] = nil
|
|
end
|
|
end
|
|
|
|
-- Sort by startTime (oldest first = lowest display slot)
|
|
-- If startTime is equal (e.g. after Carnage refresh), use spellName for stable sorting
|
|
table.sort(sortedDebuffs, _ownDebuffSortFunc)
|
|
|
|
-- Return debuff at position 'id'
|
|
if sortedDebuffs[id] then
|
|
local entry = sortedDebuffs[id]
|
|
local texture = entry.data.texture or "Interface\\Icons\\INV_Misc_QuestionMark"
|
|
local displayTimeleft = entry.timeleft > 0 and entry.timeleft or 0
|
|
|
|
-- Get dtype from SpellRec DBC via stored spellId
|
|
local entryDtype = nil
|
|
if entry.data.spellId and GetSpellRecField then
|
|
local dispelId = GetSpellRecField(entry.data.spellId, "dispel")
|
|
if dispelId and dispelId > 0 then
|
|
entryDtype = dispelTypeMap[dispelId]
|
|
end
|
|
end
|
|
|
|
return entry.spellName, entry.data.rank, texture, 1, entryDtype, entry.data.duration, displayTimeleft, "player"
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Fallback: Iterate through all debuffs and filter
|
|
for k in pairs(cache) do cache[k] = nil end
|
|
local count = 1
|
|
for i=1,16 do
|
|
local effect, rank, texture, stacks, dtype, duration, timeleft, caster = libdebuff:UnitDebuff(unit, i)
|
|
if effect and not cache[effect] and caster and caster == "player" then
|
|
cache[effect] = true
|
|
if count == id then
|
|
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
|
|
else
|
|
count = count + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- API: GetBestAuraCast (for libpredict HoT tracking)
|
|
-- ============================================================================
|
|
|
|
function libdebuff:GetBestAuraCast(guid, spellName)
|
|
if not guid or not spellName then return nil end
|
|
|
|
-- Check ownDebuffs first (for our casts)
|
|
if ownDebuffs[guid] and ownDebuffs[guid][spellName] then
|
|
local data = ownDebuffs[guid][spellName]
|
|
local timeleft = (data.startTime + data.duration) - GetTime()
|
|
if timeleft > 0 then
|
|
return data.startTime, data.duration, timeleft, data.rank, GetPlayerGUID()
|
|
end
|
|
end
|
|
|
|
-- Check allAuraCasts (for any caster)
|
|
if allAuraCasts[guid] and allAuraCasts[guid][spellName] then
|
|
local bestData = nil
|
|
local bestCaster = nil
|
|
local bestTimeleft = 0
|
|
|
|
for casterGuid, data in pairs(allAuraCasts[guid][spellName]) do
|
|
local timeleft = (data.startTime + data.duration) - GetTime()
|
|
if timeleft > bestTimeleft then
|
|
bestTimeleft = timeleft
|
|
bestData = data
|
|
bestCaster = casterGuid
|
|
end
|
|
end
|
|
|
|
if bestData and bestTimeleft > 0 then
|
|
return bestData.startTime, bestData.duration, bestTimeleft, bestData.rank, bestCaster
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- API: GetEnhancedDebuffs (for external modules)
|
|
-- ============================================================================
|
|
|
|
function libdebuff:GetEnhancedDebuffs(targetGUID)
|
|
if not targetGUID then return nil end
|
|
local result = {}
|
|
|
|
if ownDebuffs[targetGUID] then
|
|
local myGuid = GetPlayerGUID()
|
|
for spellName, data in pairs(ownDebuffs[targetGUID]) do
|
|
local timeleft = (data.startTime + data.duration) - GetTime()
|
|
if timeleft > 0 then
|
|
result[spellName] = result[spellName] or {}
|
|
result[spellName][myGuid] = {
|
|
startTime = data.startTime,
|
|
duration = data.duration,
|
|
texture = data.texture,
|
|
rank = data.rank
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- NAMPOWER EVENT HANDLING
|
|
-- ============================================================================
|
|
|
|
if hasNampower then
|
|
-- Carnage Talent Rank
|
|
local carnageRank = 0
|
|
local function UpdateCarnageRank()
|
|
if class ~= "DRUID" then return end
|
|
local _, _, _, _, rank = GetTalentInfo(2, 17)
|
|
carnageRank = rank or 0
|
|
end
|
|
|
|
-- Persistent Carnage check frame (reused instead of CreateFrame per Bite)
|
|
local carnageState = nil -- {targetGuid, checkTime}
|
|
local carnageCheckFrame = CreateFrame("Frame")
|
|
carnageCheckFrame:Hide()
|
|
carnageCheckFrame:SetScript("OnUpdate", function()
|
|
if not carnageState then
|
|
this:Hide()
|
|
return
|
|
end
|
|
if GetTime() < carnageState.checkTime then return end
|
|
|
|
-- Check if we gained a combo point (indicates Carnage proc)
|
|
local cp = GetComboPoints() or 0
|
|
|
|
if cp > 0 then
|
|
-- Carnage triggered! Refresh Rip & Rake
|
|
local guid = carnageState.targetGuid
|
|
local refreshTime = GetTime()
|
|
local myGuid = GetPlayerGUID()
|
|
|
|
-- Refresh in ownDebuffs - only if timer still active
|
|
if ownDebuffs[guid] then
|
|
if ownDebuffs[guid]["Rip"] then
|
|
local timeleft = (ownDebuffs[guid]["Rip"].startTime + ownDebuffs[guid]["Rip"].duration) - refreshTime
|
|
if timeleft > 0 then
|
|
ownDebuffs[guid]["Rip"].startTime = refreshTime
|
|
if debugStats.enabled then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[CARNAGE]|r Rip refreshed (CP detected)")
|
|
end
|
|
end
|
|
end
|
|
if ownDebuffs[guid]["Rake"] then
|
|
local timeleft = (ownDebuffs[guid]["Rake"].startTime + ownDebuffs[guid]["Rake"].duration) - refreshTime
|
|
if timeleft > 0 then
|
|
ownDebuffs[guid]["Rake"].startTime = refreshTime
|
|
if debugStats.enabled then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[CARNAGE]|r Rake refreshed (CP detected)")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Refresh in allAuraCasts - only if timer still active
|
|
if allAuraCasts[guid] then
|
|
if allAuraCasts[guid]["Rip"] and allAuraCasts[guid]["Rip"][myGuid] then
|
|
local d = allAuraCasts[guid]["Rip"][myGuid]
|
|
if (d.startTime + d.duration) > refreshTime then
|
|
d.startTime = refreshTime
|
|
end
|
|
end
|
|
if allAuraCasts[guid]["Rake"] and allAuraCasts[guid]["Rake"][myGuid] then
|
|
local d = allAuraCasts[guid]["Rake"][myGuid]
|
|
if (d.startTime + d.duration) > refreshTime then
|
|
d.startTime = refreshTime
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Trigger UI updates
|
|
if pfTarget and GetUnitGUID("target") then
|
|
local currentTargetGuid = GetUnitGUID("target")
|
|
if currentTargetGuid == guid then
|
|
pfTarget.update_aura = true
|
|
end
|
|
end
|
|
|
|
if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then
|
|
pfUI.nameplates:OnAuraUpdate(guid)
|
|
end
|
|
end
|
|
|
|
carnageState = nil
|
|
this:Hide()
|
|
end)
|
|
|
|
local frame = CreateFrame("Frame")
|
|
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
frame:RegisterEvent("PLAYER_TALENT_UPDATE")
|
|
frame:RegisterEvent("PLAYER_LOGOUT")
|
|
frame:RegisterEvent("SPELLCAST_CHANNEL_STOP")
|
|
frame:RegisterEvent("SPELL_START_SELF")
|
|
frame:RegisterEvent("SPELL_START_OTHER")
|
|
frame:RegisterEvent("SPELL_GO_SELF")
|
|
frame:RegisterEvent("SPELL_GO_OTHER")
|
|
frame:RegisterEvent("SPELL_FAILED_OTHER")
|
|
frame:RegisterEvent("UNIT_DIED")
|
|
frame:RegisterEvent("SPELL_CAST_EVENT")
|
|
frame:RegisterEvent("AURA_CAST_ON_SELF")
|
|
frame:RegisterEvent("AURA_CAST_ON_OTHER")
|
|
frame:RegisterEvent("DEBUFF_ADDED_OTHER")
|
|
frame:RegisterEvent("DEBUFF_REMOVED_OTHER")
|
|
frame:RegisterEvent("PLAYER_TARGET_CHANGED")
|
|
frame:RegisterEvent("UNIT_HEALTH")
|
|
|
|
frame:SetScript("OnEvent", function()
|
|
if event == "PLAYER_LOGOUT" then
|
|
this:UnregisterAllEvents()
|
|
this:SetScript("OnEvent", nil)
|
|
return
|
|
|
|
elseif event == "SPELLCAST_CHANNEL_STOP" then
|
|
-- Channel interrupted by player - clear ownDebuffs for the channeled spell immediately.
|
|
-- DEBUFF_REMOVED fires later (0.5-1s server lag), causing phantom debuff display.
|
|
-- We look up the active channel cast and pre-clear ownDebuffs for its target.
|
|
local myGuid = GetPlayerGUID()
|
|
local castData = myGuid and pfUI.libdebuff_casts[myGuid]
|
|
if castData and castData.event == "CHANNEL" and castData.spellName then
|
|
local spellName = castData.spellName
|
|
local targetGuid = GetUnitGUID and GetUnitGUID("target")
|
|
if targetGuid and ownDebuffs[targetGuid] and ownDebuffs[targetGuid][spellName] then
|
|
local data = ownDebuffs[targetGuid][spellName]
|
|
-- Only clear if the timer is still active (not already expired naturally)
|
|
local remaining = (data.startTime + data.duration) - GetTime()
|
|
if remaining > 0 then
|
|
ownDebuffs[targetGuid][spellName] = nil
|
|
end
|
|
end
|
|
pfUI.libdebuff_casts[myGuid] = nil
|
|
end
|
|
|
|
elseif event == "PLAYER_ENTERING_WORLD" then
|
|
GetPlayerGUID()
|
|
UpdateCarnageRank()
|
|
|
|
elseif event == "PLAYER_TALENT_UPDATE" then
|
|
UpdateCarnageRank()
|
|
|
|
elseif event == "UNIT_HEALTH" then
|
|
local guid = arg1
|
|
if guid and UnitIsDead and UnitIsDead(guid) then
|
|
CleanupUnit(guid)
|
|
end
|
|
if pfUI.libdebuff_unit_health_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_unit_health_hooks) do
|
|
fn(arg1)
|
|
end
|
|
end
|
|
|
|
elseif event == "SPELL_START_SELF" or event == "SPELL_START_OTHER" then
|
|
local itemId = arg1
|
|
local spellId = arg2
|
|
local casterGuid = arg3
|
|
local spellType = arg8 or 0 -- 0=Normal, 1=Channel, 2=Autorepeating
|
|
-- arg6=castTime, arg7=channel duration
|
|
-- prefer arg6 if present — some spells (e.g. Volley post-rework) still send
|
|
-- arg8=1 but now have a real cast time in arg6, so we only fall back to arg7
|
|
-- when arg6 is nil (true channels like Blizzard)
|
|
-- arg6=castTime (ms), arg7=channel duration (ms), arg8=spellType
|
|
-- For channels: arg6=0 (no cast time), arg7=duration, spellType=1
|
|
-- For normal casts: arg6=castTime, arg7=0, spellType=0
|
|
-- Note: "not arg6" is wrong in Lua since 0 is truthy - use arg6 == 0 or nil
|
|
local castTime = (arg6 and arg6 > 0) and arg6 or arg7
|
|
local isChannel = spellType == 1 and (not arg6 or arg6 == 0)
|
|
|
|
if not casterGuid or not spellId then return end
|
|
|
|
-- Get spell name via Nampower
|
|
local spellName = nil
|
|
if GetSpellRec then
|
|
local rec = GetSpellRec(spellId)
|
|
spellName = rec and rec.name or nil
|
|
end
|
|
|
|
|
|
local icon = libdebuff:GetSpellIcon(spellId)
|
|
|
|
-- Use item icon for item-triggered casts
|
|
if itemId and itemId > 0 and GetItemStatsField and GetItemIconTexture then
|
|
local displayInfoId = GetItemStatsField(itemId, "displayInfoID")
|
|
if displayInfoId then
|
|
local itemIcon = GetItemIconTexture(displayInfoId)
|
|
if itemIcon then
|
|
-- GetItemIconTexture returns short name (e.g. "INV_Gizmo_08"), needs full path
|
|
if not string.find(itemIcon, "\\") then
|
|
itemIcon = "Interface\\Icons\\" .. itemIcon
|
|
end
|
|
icon = itemIcon
|
|
end
|
|
end
|
|
-- Store in persistent item icon cache (survives SPELL_GO clearing libdebuff_casts)
|
|
pfUI.libdebuff_item_icons[casterGuid] = {
|
|
icon = icon,
|
|
name = GetItemStatsField and GetItemStatsField(itemId, "displayName") or nil
|
|
}
|
|
else
|
|
pfUI.libdebuff_item_icons[casterGuid] = nil
|
|
end
|
|
|
|
pfUI.libdebuff_casts[casterGuid] = {
|
|
spellID = spellId,
|
|
itemID = itemId and itemId > 0 and itemId or nil,
|
|
spellName = spellName,
|
|
icon = icon,
|
|
startTime = GetTime(),
|
|
duration = castTime and castTime / 1000 or 0,
|
|
endTime = castTime and (GetTime() + castTime / 1000) or nil,
|
|
event = isChannel and "CHANNEL" or "START"
|
|
}
|
|
|
|
if event == "SPELL_START_SELF" and pfUI.libdebuff_spell_start_self_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_spell_start_self_hooks) do
|
|
fn(spellId, casterGuid, arg4, castTime)
|
|
end
|
|
elseif event == "SPELL_START_OTHER" and pfUI.libdebuff_spell_start_other_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_spell_start_other_hooks) do
|
|
fn(spellId, casterGuid, arg4, castTime)
|
|
end
|
|
end
|
|
|
|
elseif event == "SPELL_GO_SELF" or event == "SPELL_GO_OTHER" then
|
|
local itemId = arg1
|
|
local spellId = arg2
|
|
local casterGuid = arg3
|
|
local targetGuid = arg4
|
|
local numHit = arg6 or 0
|
|
local numMissed = arg7 or 0
|
|
|
|
-- Clear cast bar only if SPELL_GO matches the active cast
|
|
-- (Reactive procs like Frost Armor trigger SPELL_GO but shouldn't clear the castbar)
|
|
-- Don't clear channels on SPELL_GO - channels persist until duration expires or SPELL_FAILED
|
|
if casterGuid and pfUI.libdebuff_casts[casterGuid] then
|
|
if pfUI.libdebuff_casts[casterGuid].spellID == spellId and pfUI.libdebuff_casts[casterGuid].event ~= "CHANNEL" then
|
|
pfUI.libdebuff_casts[casterGuid] = nil
|
|
end
|
|
end
|
|
|
|
-- Fire registered SPELL_GO_SELF hooks BEFORE miss guard
|
|
-- (Swingtimer needs to see ALL casts, even misses, for swing reset)
|
|
if event == "SPELL_GO_SELF" and pfUI.libdebuff_spell_go_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_spell_go_hooks) do
|
|
fn(spellId, arg1, arg2, arg3, arg4, arg5, arg6, arg7)
|
|
end
|
|
end
|
|
|
|
if numMissed > 0 or numHit == 0 then return end
|
|
|
|
local spellName = GetSpellRecField and GetSpellRecField(spellId, "name")
|
|
local spellRankString = GetSpellRecField and GetSpellRecField(spellId, "rank")
|
|
if not spellName then return end
|
|
|
|
local castRank = 0
|
|
if spellRankString and spellRankString ~= "" then
|
|
castRank = tonumber((string.gsub(spellRankString, "Rank ", ""))) or 0
|
|
end
|
|
|
|
-- Store in pendingCasts for DEBUFF_ADDED correlation
|
|
if targetGuid then
|
|
pendingCasts[targetGuid] = pendingCasts[targetGuid] or {}
|
|
pendingCasts[targetGuid][spellName] = {
|
|
casterGuid = casterGuid,
|
|
rank = castRank,
|
|
time = GetTime()
|
|
}
|
|
end
|
|
|
|
-- selfdebuff mode: write ownDebuffs immediately on confirmed hit
|
|
-- so buffwatch shows our debuffs even when over the 16 debuff cap
|
|
if event == "SPELL_GO_SELF" and targetGuid and not isNullTarget then
|
|
local selfdebuffMode = pfUI_config and pfUI_config.buffbar and
|
|
pfUI_config.buffbar.tdebuff and pfUI_config.buffbar.tdebuff.selfdebuff == "1"
|
|
if selfdebuffMode then
|
|
local myGuid2 = GetPlayerGUID()
|
|
if casterGuid == myGuid2 then
|
|
local duration = libdebuff:GetDuration(spellName, castRank) or 0
|
|
if duration > 0 then
|
|
local texture = libdebuff:GetSpellIcon(spellId)
|
|
ownDebuffs[targetGuid] = ownDebuffs[targetGuid] or {}
|
|
-- Downrank protection
|
|
local existing = ownDebuffs[targetGuid][spellName]
|
|
local blocked = false
|
|
if existing and existing.rank and castRank > 0 and existing.rank > castRank then
|
|
local existingTimeleft = (existing.startTime + existing.duration) - GetTime()
|
|
if existingTimeleft > 0 then blocked = true end
|
|
end
|
|
if not blocked then
|
|
ownDebuffs[targetGuid][spellName] = {
|
|
startTime = GetTime(),
|
|
duration = duration,
|
|
texture = texture,
|
|
rank = castRank,
|
|
spellId = spellId,
|
|
stacks = 1,
|
|
pending = true,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Store rank for our casts
|
|
local myGuid = GetPlayerGUID()
|
|
if casterGuid == myGuid then
|
|
lastCastRanks[spellName] = {
|
|
rank = castRank,
|
|
time = GetTime()
|
|
}
|
|
end
|
|
|
|
-- CARNAGE TALENT: Ferocious Bite refreshes Rip & Rake
|
|
-- Check for combo point gain after Bite (indicates Carnage proc)
|
|
-- Carnage gives +1 CP immediately after Bite if it procs
|
|
if class == "DRUID" and carnageRank >= 1 and spellName == "Ferocious Bite" and casterGuid == myGuid then
|
|
if targetGuid and numHit > 0 then
|
|
-- Schedule delayed check (50ms to allow CP to register)
|
|
carnageState = {
|
|
targetGuid = targetGuid,
|
|
checkTime = GetTime() + 0.05
|
|
}
|
|
carnageCheckFrame:Show()
|
|
end
|
|
end
|
|
|
|
-- Fire registered SPELL_GO_OTHER hooks
|
|
if event == "SPELL_GO_OTHER" and pfUI.libdebuff_spell_go_other_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_spell_go_other_hooks) do
|
|
fn(spellId, casterGuid, targetGuid)
|
|
end
|
|
end
|
|
|
|
elseif event == "UNIT_DIED" then
|
|
if pfUI.libdebuff_unit_died_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_unit_died_hooks) do
|
|
fn(arg1)
|
|
end
|
|
end
|
|
|
|
elseif event == "SPELL_FAILED_OTHER" then
|
|
local casterGuid = arg1
|
|
local spellId = arg2
|
|
|
|
if casterGuid and pfUI.libdebuff_casts[casterGuid] then
|
|
-- Only clear if spellID matches to avoid clearing a cast that already moved on
|
|
if pfUI.libdebuff_casts[casterGuid].spellID == spellId then
|
|
pfUI.libdebuff_casts[casterGuid] = nil
|
|
end
|
|
end
|
|
if pfUI.libdebuff_spell_failed_other_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_spell_failed_other_hooks) do
|
|
fn(casterGuid, arg2)
|
|
end
|
|
end
|
|
|
|
elseif event == "SPELL_CAST_EVENT" then
|
|
-- Capture combo points BEFORE they're consumed
|
|
-- This event fires when YOU cast a spell (before server processes it)
|
|
local success = arg1
|
|
local spellId = arg2
|
|
local castType = arg3
|
|
local targetGuid = arg4
|
|
|
|
if success ~= 1 or not spellId then return end
|
|
|
|
-- Get spell name
|
|
local spellName = nil
|
|
if GetSpellRec then
|
|
local rec = GetSpellRec(spellId)
|
|
spellName = rec and rec.name or nil
|
|
end
|
|
|
|
|
|
-- Store pending cast info for libpredict (heal prediction target tracking)
|
|
-- This allows libpredict to resolve the correct target for Nampower queued casts,
|
|
-- where CastSpellByName hook fires while current_cast is set and spell_queue
|
|
-- cannot be updated. SPELL_CAST_EVENT fires right before SPELLCAST_START.
|
|
if spellName and targetGuid and targetGuid ~= "" and targetGuid ~= "0x0000000000000000" then
|
|
pfUI.libpredict_pending_cast.spellId = spellId
|
|
pfUI.libpredict_pending_cast.spellName = spellName
|
|
pfUI.libpredict_pending_cast.targetGuid = targetGuid
|
|
pfUI.libpredict_pending_cast.time = GetTime()
|
|
else
|
|
-- No explicit target - clear pending so libpredict falls back to spell_queue
|
|
pfUI.libpredict_pending_cast.spellId = nil
|
|
pfUI.libpredict_pending_cast.spellName = nil
|
|
pfUI.libpredict_pending_cast.targetGuid = nil
|
|
pfUI.libpredict_pending_cast.time = nil
|
|
end
|
|
|
|
-- Only capture CPs for combo-point abilities
|
|
if spellName and IsComboPointAbility(spellName) then
|
|
capturedCP = GetComboPoints() or 0
|
|
end
|
|
|
|
-- Fire registered SPELL_CAST_EVENT hooks
|
|
if pfUI.libdebuff_spell_cast_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_spell_cast_hooks) do
|
|
fn(success, spellId, castType, targetGuid)
|
|
end
|
|
end
|
|
|
|
elseif event == "AURA_CAST_ON_SELF" or event == "AURA_CAST_ON_OTHER" then
|
|
local spellId = arg1
|
|
local casterGuid = arg2
|
|
local targetGuid = arg3
|
|
local effect = arg4
|
|
local effectAuraName = arg5
|
|
local effectAmplitude = arg6
|
|
local effectMiscValue = arg7
|
|
local durationMs = arg8
|
|
local auraCapStatus = arg9
|
|
|
|
if not spellId then return end
|
|
if not targetGuid or targetGuid == "" or targetGuid == "0x0000000000000000" then return end
|
|
|
|
local spellName = GetSpellRecField and GetSpellRecField(spellId, "name")
|
|
if not spellName then return end
|
|
|
|
-- Deduplicate: Ignore if we processed this exact cast recently (within 100ms)
|
|
-- Nampower fires multiple AURA_CAST events for multi-effect spells (e.g. Faerie Fire has 3 effects)
|
|
recentCasts[targetGuid] = recentCasts[targetGuid] or {}
|
|
recentCasts[targetGuid][spellName] = recentCasts[targetGuid][spellName] or {}
|
|
|
|
local now = GetTime()
|
|
local lastCastTime = recentCasts[targetGuid][spellName][casterGuid]
|
|
|
|
if lastCastTime and (now - lastCastTime) < AURA_CAST_DEDUPE_WINDOW then
|
|
return -- Duplicate event, ignore
|
|
end
|
|
|
|
recentCasts[targetGuid][spellName][casterGuid] = now
|
|
|
|
-- Rank aus spellId ermitteln
|
|
local rankNum = 0
|
|
local rankString = GetSpellRecField(spellId, "rank")
|
|
if rankString and rankString ~= "" then
|
|
rankNum = tonumber((string.gsub(rankString, "Rank ", ""))) or 0
|
|
end
|
|
|
|
local duration = durationMs and (durationMs / 1000) or 0
|
|
local startTime = GetTime()
|
|
local myGuid = GetPlayerGUID()
|
|
local isOurs = (myGuid and casterGuid == myGuid)
|
|
|
|
if debugStats.enabled and isOurs then
|
|
debugStats.aura_cast = debugStats.aura_cast + 1
|
|
end
|
|
|
|
-- Combo-point abilities: Calculate duration based on CPs used
|
|
if IsComboPointAbility(spellName) then
|
|
if isOurs then
|
|
-- OWN casts: use captured CPs from SPELL_CAST_EVENT (if available)
|
|
local cp = capturedCP or 0
|
|
local base, perCP = GetComboPointData(spellName)
|
|
if base and perCP then
|
|
duration = base + cp * perCP
|
|
else
|
|
-- Fallback to legacy database
|
|
duration = libdebuff:GetDuration(spellName, rankNum)
|
|
end
|
|
capturedCP = nil -- consumed
|
|
else
|
|
-- OTHER players: CP unknown, no timer (except Expose Armor = fixed 30s)
|
|
local base, perCP = GetComboPointData(spellName)
|
|
if perCP and perCP == 0 and base then
|
|
duration = base -- fixed duration (Expose Armor)
|
|
else
|
|
duration = 0 -- CP unknown for other players
|
|
end
|
|
end
|
|
elseif duration == 0 then
|
|
-- Non-CP managed spells: use database if AURA_CAST returned 0
|
|
duration = libdebuff:GetDuration(spellName, rankNum) or 0
|
|
end
|
|
|
|
-- Store in allAuraCasts
|
|
if targetGuid and targetGuid ~= "" and targetGuid ~= "0x0000000000000000" then
|
|
allAuraCasts[targetGuid] = allAuraCasts[targetGuid] or {}
|
|
allAuraCasts[targetGuid][spellName] = allAuraCasts[targetGuid][spellName] or {}
|
|
|
|
-- Downrank Protection: Check BEFORE clearing old casters!
|
|
-- For selfOverwrite debuffs, check ALL existing casters
|
|
if selfOverwriteDebuffs[spellName] then
|
|
for otherCaster, existingData in pairs(allAuraCasts[targetGuid][spellName]) do
|
|
if existingData.rank and rankNum and rankNum > 0 then
|
|
local existingTimeleft = (existingData.startTime + existingData.duration) - GetTime()
|
|
if existingTimeleft > 0 and rankNum < existingData.rank then
|
|
-- Lower rank cannot overwrite higher rank - block the update
|
|
if debugStats.enabled then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[DOWNRANK BLOCKED]|r %s: Rank %d from %s cannot overwrite Rank %d from %s (%.1fs left)",
|
|
spellName, rankNum, DebugGuid(casterGuid), existingData.rank, DebugGuid(otherCaster), existingTimeleft))
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- For non-selfOverwrite: Check only same caster
|
|
local existingData = allAuraCasts[targetGuid][spellName][casterGuid]
|
|
if existingData and existingData.rank and rankNum and rankNum > 0 then
|
|
local existingTimeleft = (existingData.startTime + existingData.duration) - GetTime()
|
|
if existingTimeleft > 0 and rankNum < existingData.rank then
|
|
if debugStats.enabled and isOurs then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[DOWNRANK BLOCKED]|r %s: Rank %d cannot overwrite Rank %d (%.1fs left)",
|
|
spellName, rankNum, existingData.rank, existingTimeleft))
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Handle self-overwrite debuffs (clear other casters)
|
|
if selfOverwriteDebuffs[spellName] then
|
|
local n = 0
|
|
for otherCaster in pairs(allAuraCasts[targetGuid][spellName]) do
|
|
if otherCaster ~= casterGuid then
|
|
n = n + 1
|
|
_cleanupBuf1[n] = otherCaster
|
|
end
|
|
end
|
|
for i = 1, n do
|
|
allAuraCasts[targetGuid][spellName][_cleanupBuf1[i]] = nil
|
|
_cleanupBuf1[i] = nil
|
|
end
|
|
|
|
-- Clear from ownDebuffs if we're being overwritten
|
|
if not isOurs and ownDebuffs[targetGuid] and ownDebuffs[targetGuid][spellName] then
|
|
ownDebuffs[targetGuid][spellName] = nil
|
|
end
|
|
end
|
|
|
|
-- Handle variant pairs (Faerie Fire <-> Faerie Fire (Feral))
|
|
if debuffOverwritePairs[spellName] then
|
|
local otherVariant = debuffOverwritePairs[spellName]
|
|
if allAuraCasts[targetGuid][otherVariant] and allAuraCasts[targetGuid][otherVariant][casterGuid] then
|
|
allAuraCasts[targetGuid][otherVariant][casterGuid] = nil
|
|
end
|
|
end
|
|
|
|
-- Store timer data
|
|
allAuraCasts[targetGuid][spellName][casterGuid] = {
|
|
startTime = startTime,
|
|
duration = duration,
|
|
rank = rankNum
|
|
}
|
|
|
|
-- UPDATE slotOwnership for selfOverwrite refreshes
|
|
-- (DEBUFF_ADDED doesn't fire on refresh, so we must update here!)
|
|
if selfOverwriteDebuffs[spellName] and slotOwnership[targetGuid] then
|
|
for auraSlot, ownership in pairs(slotOwnership[targetGuid]) do
|
|
if ownership.spellName == spellName then
|
|
-- Update the casterGuid and isOurs for this slot
|
|
ownership.casterGuid = casterGuid
|
|
ownership.isOurs = isOurs
|
|
|
|
if debugStats.enabled and IsCurrentTarget(targetGuid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[SLOT UPDATED]|r aura=%d %s newCaster=%s isOurs=%s",
|
|
auraSlot, spellName, DebugGuid(casterGuid), tostring(isOurs)))
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if debugStats.enabled and IsCurrentTarget(targetGuid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cff00ffff[AURA_CAST]|r %s target=%s caster=%s isOurs=%s dur=%.1fs",
|
|
GetDebugTimestamp(), spellName, DebugGuid(targetGuid), DebugGuid(casterGuid), tostring(isOurs), duration))
|
|
end
|
|
|
|
end
|
|
|
|
-- Notify nameplates
|
|
if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then
|
|
pfUI.nameplates:OnAuraUpdate(targetGuid)
|
|
end
|
|
|
|
-- Notify unitframes of debuff updates (UNIT_AURA doesn't fire on refreshes!)
|
|
-- Check player
|
|
if GetUnitGUID("player") then
|
|
local playerGuid = GetUnitGUID("player")
|
|
if playerGuid == targetGuid and pfPlayer then
|
|
pfPlayer.update_aura = true
|
|
end
|
|
end
|
|
|
|
-- Check target
|
|
if GetUnitGUID("target") then
|
|
local targetUnitGuid = GetUnitGUID("target")
|
|
if targetUnitGuid == targetGuid and pfTarget then
|
|
pfTarget.update_aura = true
|
|
end
|
|
end
|
|
|
|
-- Only track in ownDebuffs if it's OUR debuff
|
|
if not isOurs then return end
|
|
if targetGuid == myGuid then return end -- Skip self-buffs
|
|
if not targetGuid or targetGuid == "" or targetGuid == "0x0000000000000000" then return end
|
|
|
|
-- Get texture
|
|
local texture = libdebuff:GetSpellIcon(spellId)
|
|
|
|
-- Store in ownDebuffs only if entry already exists (confirmed hit via DEBUFF_ADDED).
|
|
-- AURA_CAST fires on both hit AND miss - creating a new entry here would show
|
|
-- phantom debuffs when the cast misses or is interrupted before hitting.
|
|
if not ownDebuffs[targetGuid] or not ownDebuffs[targetGuid][spellName] then return end
|
|
|
|
ownDebuffs[targetGuid] = ownDebuffs[targetGuid] or {}
|
|
local data = ownDebuffs[targetGuid][spellName]
|
|
if not data then return end -- race condition: cleared by DEBUFF_REMOVED between init and use
|
|
|
|
-- Downrank Protection: Check if existing debuff is still active and has higher rank
|
|
if data.startTime and data.duration and data.rank and rankNum > 0 then
|
|
local existingTimeleft = (data.startTime + data.duration) - GetTime()
|
|
if existingTimeleft > 0 then
|
|
-- Existing debuff is still active
|
|
if rankNum < data.rank then
|
|
-- Lower rank cannot overwrite higher rank - block the update
|
|
if debugStats.enabled then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[DOWNRANK BLOCKED]|r %s: Rank %d cannot overwrite Rank %d (%.1fs left)",
|
|
spellName, rankNum, data.rank, existingTimeleft))
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
data.startTime = startTime
|
|
data.duration = duration
|
|
data.texture = texture
|
|
data.rank = rankNum
|
|
data.spellId = spellId
|
|
|
|
-- Handle variant pairs for ownDebuffs
|
|
if debuffOverwritePairs[spellName] then
|
|
local otherVariant = debuffOverwritePairs[spellName]
|
|
if ownDebuffs[targetGuid][otherVariant] then
|
|
ownDebuffs[targetGuid][otherVariant] = nil
|
|
end
|
|
end
|
|
|
|
-- Store for Cleveroids API
|
|
objectsByGuid[targetGuid] = objectsByGuid[targetGuid] or {}
|
|
objectsByGuid[targetGuid][spellId] = {
|
|
start = startTime,
|
|
duration = duration,
|
|
caster = "player",
|
|
stacks = 1
|
|
}
|
|
if event == "AURA_CAST_ON_SELF" and pfUI.libdebuff_aura_cast_on_self_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_aura_cast_on_self_hooks) do
|
|
fn(spellId, casterGuid, targetGuid)
|
|
end
|
|
elseif event == "AURA_CAST_ON_OTHER" and pfUI.libdebuff_aura_cast_on_other_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_aura_cast_on_other_hooks) do
|
|
fn(spellId, casterGuid, targetGuid)
|
|
end
|
|
end
|
|
|
|
elseif event == "DEBUFF_ADDED_OTHER" then
|
|
local guid = arg1
|
|
local displaySlot = arg2 -- Display slot (1-16), compacted
|
|
local spellId = arg3
|
|
local stacks = arg4
|
|
local auraSlot_0based = arg6 -- Nampower 2.29+: raw slot 0-based (32-47)
|
|
|
|
-- Convert 0-based (Nampower event) to 1-based (Lua GetUnitField array)
|
|
local auraSlot = auraSlot_0based and (auraSlot_0based + 1) or nil
|
|
|
|
-- Invalidate slot map cache for this GUID
|
|
slotMapCache[guid] = nil
|
|
|
|
local spellName = GetSpellRecField and GetSpellRecField(spellId, "name")
|
|
if not spellName then return end
|
|
|
|
if debugStats.enabled then
|
|
debugStats.debuff_added = debugStats.debuff_added + 1
|
|
end
|
|
|
|
-- If unit is dead, cleanup and skip
|
|
if UnitIsDead and UnitIsDead(guid) then
|
|
CleanupUnit(guid)
|
|
return
|
|
end
|
|
|
|
-- Get auraSlot from event parameter (Nampower 2.29+)
|
|
-- Fallback to GetUnitField lookup if not available
|
|
if not auraSlot then
|
|
local slotMap = GetDebuffSlotMap(guid)
|
|
if slotMap and slotMap[displaySlot] then
|
|
auraSlot = slotMap[displaySlot].auraSlot
|
|
end
|
|
end
|
|
|
|
-- Fallback: Calculate aura slot if GetUnitField didn't work
|
|
-- (This assumes no gaps, which isn't always true, but better than nothing)
|
|
if not auraSlot then
|
|
auraSlot = 32 + displaySlot
|
|
end
|
|
|
|
-- Get caster from pendingCasts (SPELL_GO correlation)
|
|
local casterGuid = nil
|
|
if pendingCasts[guid] and pendingCasts[guid][spellName] then
|
|
local pending = pendingCasts[guid][spellName]
|
|
if GetTime() - pending.time < 0.5 then
|
|
casterGuid = pending.casterGuid
|
|
pendingCasts[guid][spellName] = nil
|
|
end
|
|
end
|
|
|
|
-- Fallback: Check allAuraCasts for most recent caster
|
|
if not casterGuid and allAuraCasts[guid] and allAuraCasts[guid][spellName] then
|
|
local mostRecent = nil
|
|
local mostRecentTime = 0
|
|
for casterId, data in pairs(allAuraCasts[guid][spellName]) do
|
|
if data.startTime > mostRecentTime then
|
|
mostRecentTime = data.startTime
|
|
mostRecent = casterId
|
|
end
|
|
end
|
|
if mostRecent then
|
|
casterGuid = mostRecent
|
|
end
|
|
end
|
|
|
|
local myGuid = GetPlayerGUID()
|
|
local isOurs = (myGuid and casterGuid == myGuid)
|
|
|
|
-- Fallback: Check ownDebuffs timing
|
|
if not isOurs and not casterGuid then
|
|
if ownDebuffs[guid] and ownDebuffs[guid][spellName] then
|
|
local age = GetTime() - ownDebuffs[guid][spellName].startTime
|
|
if age < 0.5 then
|
|
isOurs = true
|
|
casterGuid = myGuid
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Store slot ownership (KEY: auraSlot is STABLE, no shifting needed!)
|
|
slotOwnership[guid] = slotOwnership[guid] or {}
|
|
slotOwnership[guid][auraSlot] = {
|
|
casterGuid = casterGuid,
|
|
spellName = spellName,
|
|
spellId = spellId,
|
|
isOurs = isOurs
|
|
}
|
|
|
|
-- Store displaySlot → auraSlot mapping for DEBUFF_REMOVED
|
|
displayToAura[guid] = displayToAura[guid] or {}
|
|
displayToAura[guid][displaySlot] = auraSlot
|
|
|
|
if debugStats.enabled and IsCurrentTarget(guid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cff00ff00[DEBUFF_ADDED]|r display=%d aura=%d %s caster=%s isOurs=%s",
|
|
GetDebugTimestamp(), displaySlot, auraSlot, spellName, DebugGuid(casterGuid), tostring(isOurs)))
|
|
end
|
|
|
|
-- CRITICAL FIX: Update ownDebuffs here too for refresh timing!
|
|
-- This prevents the gap between DEBUFF_REMOVED and AURA_CAST where buffwatch shows nothing
|
|
if isOurs and casterGuid then
|
|
local myGuid = GetPlayerGUID()
|
|
if myGuid and casterGuid == myGuid then
|
|
-- Check if we have timer data from allAuraCasts
|
|
if allAuraCasts[guid] and allAuraCasts[guid][spellName] and allAuraCasts[guid][spellName][casterGuid] then
|
|
local auraData = allAuraCasts[guid][spellName][casterGuid]
|
|
local texture = libdebuff:GetSpellIcon(spellId)
|
|
|
|
ownDebuffs[guid] = ownDebuffs[guid] or {}
|
|
ownDebuffs[guid][spellName] = {
|
|
startTime = auraData.startTime,
|
|
duration = auraData.duration,
|
|
texture = texture,
|
|
rank = auraData.rank or 0,
|
|
spellId = spellId
|
|
}
|
|
|
|
if debugStats.enabled and IsCurrentTarget(guid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff00ff[OWNDEBUFF SYNC]|r %s from DEBUFF_ADDED", spellName))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Cleanup expired timers
|
|
CleanupExpiredTimers(guid)
|
|
|
|
-- Notify nameplates
|
|
if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then
|
|
pfUI.nameplates:OnAuraUpdate(guid)
|
|
end
|
|
if pfUI.libdebuff_debuff_added_other_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_debuff_added_other_hooks) do
|
|
fn(arg1, arg2, arg3, arg4)
|
|
end
|
|
end
|
|
|
|
elseif event == "DEBUFF_REMOVED_OTHER" then
|
|
local guid = arg1
|
|
local displaySlot = arg2 -- Display slot (1-16), compacted
|
|
local spellId = arg3
|
|
local auraSlot_0based = arg6 -- Nampower 2.29+: raw slot 0-based (32-47)
|
|
|
|
-- Convert 0-based (Nampower event) to 1-based (Lua GetUnitField array)
|
|
local auraSlot = auraSlot_0based and (auraSlot_0based + 1) or nil
|
|
|
|
-- Invalidate slot map cache for this GUID
|
|
slotMapCache[guid] = nil
|
|
|
|
local spellName = (GetSpellRecField and GetSpellRecField(spellId, "name")) or "?"
|
|
|
|
if debugStats.enabled then
|
|
debugStats.debuff_removed = debugStats.debuff_removed + 1
|
|
if IsCurrentTarget(guid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cffff9900[DEBUFF_REMOVED]|r display=%d aura=%d (0based=%d) %s",
|
|
GetDebugTimestamp(), displaySlot, auraSlot or -1, auraSlot_0based or -1, spellName))
|
|
end
|
|
end
|
|
|
|
-- If unit is dead, cleanup all
|
|
if UnitIsDead and UnitIsDead(guid) then
|
|
CleanupUnit(guid)
|
|
return
|
|
end
|
|
|
|
-- Get auraSlot from event parameter (Nampower 2.29+)
|
|
-- Fallback to displayToAura mapping if not available
|
|
local foundAuraSlot = auraSlot
|
|
if not foundAuraSlot and displayToAura[guid] and displayToAura[guid][displaySlot] then
|
|
foundAuraSlot = displayToAura[guid][displaySlot]
|
|
end
|
|
|
|
local wasOurs = false
|
|
local removedCasterGuid = nil
|
|
|
|
if foundAuraSlot then
|
|
-- Get ownership info for this specific slot
|
|
if slotOwnership[guid] and slotOwnership[guid][foundAuraSlot] then
|
|
local ownership = slotOwnership[guid][foundAuraSlot]
|
|
wasOurs = ownership.isOurs
|
|
removedCasterGuid = ownership.casterGuid
|
|
end
|
|
|
|
-- Clear both mappings (with nil-checks)
|
|
if slotOwnership[guid] then
|
|
slotOwnership[guid][foundAuraSlot] = nil
|
|
end
|
|
if displayToAura[guid] then
|
|
displayToAura[guid][displaySlot] = nil
|
|
end
|
|
|
|
if debugStats.enabled and IsCurrentTarget(guid) then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("%s |cffff9900[SLOT CLEARED]|r aura=%d [arg6] %s wasOurs=%s caster=%s",
|
|
GetDebugTimestamp(), foundAuraSlot, spellName, tostring(wasOurs), DebugGuid(removedCasterGuid)))
|
|
end
|
|
end
|
|
|
|
-- Remove from ownDebuffs if it was ours
|
|
if wasOurs and ownDebuffs[guid] and ownDebuffs[guid][spellName] then
|
|
local age = GetTime() - ownDebuffs[guid][spellName].startTime
|
|
-- Only delete if not recently renewed
|
|
if age > 1 then
|
|
ownDebuffs[guid][spellName] = nil
|
|
end
|
|
end
|
|
|
|
-- Remove from allAuraCasts
|
|
if removedCasterGuid and allAuraCasts[guid] and allAuraCasts[guid][spellName] then
|
|
if allAuraCasts[guid][spellName][removedCasterGuid] then
|
|
local auraData = allAuraCasts[guid][spellName][removedCasterGuid]
|
|
local age = GetTime() - auraData.startTime
|
|
-- Only delete if not recently refreshed
|
|
if age > 1 then
|
|
allAuraCasts[guid][spellName][removedCasterGuid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Cleanup expired timers
|
|
CleanupExpiredTimers(guid)
|
|
|
|
-- Notify nameplates
|
|
if pfUI.nameplates and pfUI.nameplates.OnAuraUpdate then
|
|
pfUI.nameplates:OnAuraUpdate(guid)
|
|
end
|
|
if pfUI.libdebuff_debuff_removed_other_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_debuff_removed_other_hooks) do
|
|
fn(arg1, arg2, arg3, arg4)
|
|
end
|
|
end
|
|
|
|
elseif event == "PLAYER_TARGET_CHANGED" then
|
|
if not GetUnitGUID then return end
|
|
local targetGuid = GetUnitGUID("target")
|
|
|
|
if targetGuid and targetGuid ~= "" then
|
|
-- Invalidate slot map cache on retarget
|
|
-- Prevents stale slot mappings after untarget/retarget cycles
|
|
slotMapCache[targetGuid] = nil
|
|
-- Cleanup expired timers for new target
|
|
CleanupExpiredTimers(targetGuid)
|
|
end
|
|
if pfUI.libdebuff_player_target_changed_hooks then
|
|
for _, fn in pairs(pfUI.libdebuff_player_target_changed_hooks) do
|
|
fn()
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Periodic cleanup
|
|
CleanupOutOfRangeUnits()
|
|
end)
|
|
|
|
-- Cleveroids API
|
|
if CleveRoids then
|
|
CleveRoids.libdebuff = libdebuff
|
|
libdebuff.objects = objectsByGuid
|
|
end
|
|
end
|
|
|
|
-- add libdebuff to pfUI API
|
|
pfUI.api.libdebuff = libdebuff
|
|
|
|
-- Expose debugStats for external access
|
|
libdebuff.debugStats = debugStats
|
|
|
|
-- ============================================================================
|
|
-- DEBUG COMMANDS
|
|
-- ============================================================================
|
|
|
|
_G.SLASH_LIBDEBUGSTATS1 = "/libdebugstats"
|
|
_G.SlashCmdList["LIBDEBUGSTATS"] = function(msg)
|
|
msg = string.lower(msg or "")
|
|
|
|
if msg == "start" then
|
|
debugStats.enabled = true
|
|
debugStats.trackAllUnits = false
|
|
debugStats.aura_cast = 0
|
|
debugStats.debuff_added = 0
|
|
debugStats.debuff_removed = 0
|
|
debugStats.getunitfield_calls = 0
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[libdebuff]|r Debug tracking STARTED")
|
|
|
|
elseif msg == "stop" then
|
|
debugStats.enabled = false
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[libdebuff]|r Debug tracking STOPPED")
|
|
|
|
elseif msg == "stats" then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff=== LIBDEBUFF STATS (GetUnitField Edition) ===|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("AURA_CAST events: %d", debugStats.aura_cast))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("DEBUFF_ADDED events: %d", debugStats.debuff_added))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("DEBUFF_REMOVED events: %d", debugStats.debuff_removed))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("GetUnitField calls: %d", debugStats.getunitfield_calls))
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00No manual slot shifting needed!|r")
|
|
|
|
elseif msg == "target" then
|
|
if not GetUnitGUID("target") then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff]|r No target!")
|
|
return
|
|
end
|
|
|
|
local guid = GetUnitGUID("target")
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff=== TARGET DEBUFF STATE ===|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("GUID: %s", tostring(guid)))
|
|
|
|
-- Show GetUnitField slot map
|
|
local slotMap = GetDebuffSlotMap(guid)
|
|
if slotMap then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00GetUnitField Slots:|r")
|
|
for displaySlot, data in pairs(slotMap) do
|
|
local casterGuid, isOurs = GetSlotCaster(guid, data.auraSlot, data.spellName)
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format(" Display %d (aura %d): %s [caster=%s, ours=%s]",
|
|
displaySlot, data.auraSlot, data.spellName, DebugGuid(casterGuid), tostring(isOurs)))
|
|
end
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff9900No debuffs via GetUnitField|r")
|
|
end
|
|
|
|
-- Show ownDebuffs
|
|
if ownDebuffs[guid] then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ownDebuffs:|r")
|
|
for spell, data in pairs(ownDebuffs[guid]) do
|
|
local timeleft = (data.startTime + data.duration) - GetTime()
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format(" %s: dur=%.1f left=%.1f", spell, data.duration, timeleft))
|
|
end
|
|
end
|
|
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[libdebuff] GetUnitField Edition - Commands:|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats start - Start debug tracking")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats stop - Stop debug tracking")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats stats - Show statistics")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" /libdebugstats target - Show target debuff state")
|
|
end
|
|
end
|
|
|
|
_G.SLASH_MEMCHECK1 = "/memcheck"
|
|
_G.SlashCmdList["MEMCHECK"] = function()
|
|
local function countTable(t)
|
|
local count = 0
|
|
if not t then return 0 end
|
|
for _ in pairs(t) do count = count + 1 end
|
|
return count
|
|
end
|
|
|
|
local function countNestedEntries(t)
|
|
local total = 0
|
|
if not t then return 0 end
|
|
for _, nested in pairs(t) do
|
|
if type(nested) == "table" then
|
|
total = total + countTable(nested)
|
|
end
|
|
end
|
|
return total
|
|
end
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========== LIBDEBUFF MEMORY (GetUnitField Edition) ==========|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00Primary Tables:|r"))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format(" ownDebuffs: %d GUIDs, %d debuffs", countTable(ownDebuffs), countNestedEntries(ownDebuffs)))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format(" slotOwnership: %d GUIDs, %d slots", countTable(slotOwnership), countNestedEntries(slotOwnership)))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format(" allAuraCasts: %d GUIDs", countTable(allAuraCasts)))
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format(" pendingCasts: %d GUIDs", countTable(pendingCasts)))
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00No ownSlots/allSlots (eliminated by GetUnitField approach!)|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff============================================================|r")
|
|
end
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff33ff99[libdebuff]|r GetUnitField Edition loaded!") |