diff --git a/libs/libdebuff.lua b/libs/libdebuff.lua new file mode 100644 index 0000000..9b6cb56 --- /dev/null +++ b/libs/libdebuff.lua @@ -0,0 +1,2144 @@ +-- 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 {} + +-- Callbacks fired when a cast is identified as a downrank of an already active debuff. +-- fn(spellName, castRank, activeRank, targetGuid, casterGuid) +-- External addons can use this to avoid re-implementing downrank detection themselves. +pfUI.libdebuff_downrank_blocked_hooks = pfUI.libdebuff_downrank_blocked_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!) +-- ============================================================================ + +-- 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 + + local auras = GetUnitField(guid, "aura") + if not auras then return nil end + + 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) + local stacks = (auraApps and auraApps[auraSlot] or 0) + 1 + 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 + + 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 this cast is a downrank of an already active debuff, fire the downrank blocked hook + -- so external addons (e.g. SuperCleveRoidMacros) don't need to re-implement this check. + if targetGuid then + pendingCasts[targetGuid] = pendingCasts[targetGuid] or {} + local isDownrankBlocked = false + if castRank > 0 then + local existingOwn = ownDebuffs[targetGuid] and ownDebuffs[targetGuid][spellName] + if existingOwn and existingOwn.rank and existingOwn.rank > castRank then + local existingTimeleft = (existingOwn.startTime + existingOwn.duration) - GetTime() + if existingTimeleft > 0 then + isDownrankBlocked = true + -- Fire hook so external addons know this cast was downrank-blocked + if pfUI.libdebuff_downrank_blocked_hooks then + for _, fn in pairs(pfUI.libdebuff_downrank_blocked_hooks) do + fn(spellName, castRank, existingOwn.rank, targetGuid, casterGuid) + end + end + end + end + end + -- Always write pendingCasts — let consumers use the hook to handle downrank themselves + pendingCasts[targetGuid][spellName] = { + casterGuid = casterGuid, + rank = castRank, + time = GetTime(), + downrankBlocked = isDownrankBlocked + } + 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) + + -- Write ownDebuffs if: + -- (a) Entry already exists (refresh of active debuff), OR + -- (b) pendingCasts has an entry for this spell (SPELL_GO confirmed a hit is in-flight, + -- not a miss). This allows the downrank hook in SPELL_GO to fire correctly on first cast. + -- Without (b), ownDebuffs is empty until DEBUFF_ADDED (~300ms later), meaning the downrank + -- check in SPELL_GO has no data to work with on the first cast. + local entryExists = ownDebuffs[targetGuid] and ownDebuffs[targetGuid][spellName] + local pendingConfirm = pendingCasts[targetGuid] and pendingCasts[targetGuid][spellName] + if not entryExists and not pendingConfirm then return end + + ownDebuffs[targetGuid] = ownDebuffs[targetGuid] or {} + local data = ownDebuffs[targetGuid][spellName] + -- For first-cast path (pendingConfirm but no existing entry): create a new entry + if not data then + if not pendingConfirm then return end -- race condition: cleared by DEBUFF_REMOVED + data = {} + ownDebuffs[targetGuid][spellName] = data + end + + -- 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 + + 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 + + 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 + -- 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!") \ No newline at end of file