-- 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. -- -- The public per-aura readers (UnitDebuff, UnitOwnDebuff) were retired in favor -- of ClassicAPI's C_UnitAuras (which now provides sourceUnit/sourceGUID and -- non-player expirationTime). What remains in libdebuff is the cast-event -- bookkeeping consumed by GetBestAuraCast / GetEnhancedDebuffs and the -- libdebuff_casts / libdebuff_*_hooks broadcast surface. -- 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 _, 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 (UnitGUID 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 -- ============================================================================ local function CopyNampowerLink() pfUI.chat.urlcopy.CopyText("https://github.com/brues-code/nampower") end 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 = CopyNampowerLink, } 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 = CopyNampowerLink, } -- ============================================================================ -- 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 UnitGUID then local guid = UnitGUID("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 UnitGUID then return false end local targetGuid = UnitGUID("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 = C_Spell.GetSpellTexture(spellId) or "Interface\\Icons\\INV_Misc_QuestionMark" 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 -- ============================================================================ -- 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 UnitGUID("target") then local currentTargetGuid = UnitGUID("target") if currentTargetGuid == guid then pfTarget.update_aura = true end 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 = UnitGUID and UnitGUID("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 local spellName = C_Spell.GetSpellName(spellId) local icon = libdebuff:GetSpellIcon(spellId) -- Use item icon for item-triggered casts if itemId and itemId > 0 then icon = C_Item.GetItemIconByID(itemId) or icon pfUI.libdebuff_item_icons[casterGuid] = { icon = icon, name = GetItemInfo(itemId), } 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 local spellName = C_Spell.GetSpellName(spellId) -- 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 unitframes of debuff updates (UNIT_AURA doesn't fire on refreshes!) -- Check player if UnitGUID("player") then local playerGuid = UnitGUID("player") if playerGuid == targetGuid and pfPlayer then pfPlayer.update_aura = true end end -- Check target if UnitGUID("target") then local tarUnitGUID = UnitGUID("target") if tarUnitGUID == 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) 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) 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 UnitGUID then return end local targetGuid = UnitGUID("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 UnitGUID("target") then DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[libdebuff]|r No target!") return end local guid = UnitGUID("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!")