diff --git a/api/unitframes.lua b/api/unitframes.lua index 1f27a3d9..38f3c2a3 100644 --- a/api/unitframes.lua +++ b/api/unitframes.lua @@ -897,32 +897,6 @@ function pfUI.uf.OnShow() pfUI.uf:RefreshUnit(this, "base") end --- GUID-keyed aura start-time tracker, fed by Nampower's BUFF_ADDED_OTHER / --- BUFF_REMOVED_OTHER. Non-player units have aura.expirationTime == 0 (server --- doesn't broadcast time), so we reconstruct the swirl from "when we saw it --- applied" + aura.duration (Spell.dbc base). Only auras we actually witnessed --- being cast get tracked — pre-existing auras (already on a unit when you --- target them) intentionally show no timer rather than a fabricated one. -pfUI.uf.aura_starts = {} -- {[guid] = {[spellId] = startTime}} - -local auraTracker = CreateFrame("Frame", "pfUF_AuraTracker", UIParent) -auraTracker:RegisterEvent("BUFF_ADDED_OTHER") -auraTracker:RegisterEvent("BUFF_REMOVED_OTHER") -auraTracker:SetScript("OnEvent", function() - -- Nampower arg layout: arg1=guid, arg3=spellId. State code 0=added, 1=removed, - -- 2=modified; we treat any added/modified as "reset start = now" and let the - -- REMOVED event clear the entry. - if not arg1 or not arg3 or arg3 == 0 then return end - if event == "BUFF_REMOVED_OTHER" then - if pfUI.uf.aura_starts[arg1] then - pfUI.uf.aura_starts[arg1][arg3] = nil - end - else - pfUI.uf.aura_starts[arg1] = pfUI.uf.aura_starts[arg1] or {} - pfUI.uf.aura_starts[arg1][arg3] = GetTime() - end -end) - function pfUI.uf.OnEvent() -- Handle shutdown to prevent crash 132 if event == "PLAYER_LOGOUT" then @@ -1752,15 +1726,6 @@ function pfUI.uf:RefreshUnit(unit, component) else CooldownFrame_SetTimer(unit.buffs[i].cd, 0, 0, 0) end - elseif duration > 0 then - local guid = UnitGUID(unitstr) - local guidStarts = guid and pfUI.uf.aura_starts[guid] - local start = guidStarts and guidStarts[spellId] - if start then - CooldownFrame_SetTimer(unit.buffs[i].cd, start, duration, 1) - else - CooldownFrame_SetTimer(unit.buffs[i].cd, 0, 0, 0) - end else CooldownFrame_SetTimer(unit.buffs[i].cd, 0, 0, 0) end diff --git a/libs/libdebuff.lua b/libs/libdebuff.lua index ef5fa872..8fac3b26 100644 --- a/libs/libdebuff.lua +++ b/libs/libdebuff.lua @@ -1,701 +1,17 @@ -- 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 internal debuff plumbing now runs on ClassicAPI's C_UnitAuras (which --- provides sourceUnit/sourceGUID and non-player expirationTime). The public --- per-aura readers (UnitDebuff, UnitOwnDebuff) survive only as thin adapters --- over C_UnitAuras for third-party addons (e.g. pfUI-WeakIcons) that still --- expect the legacy multi-return signature. The rest of libdebuff is the --- cast-event bookkeeping consumed by GetBestAuraCast (libpredict HoT tracking) --- and the libdebuff_*_hooks broadcast surface (subscribers in actionbar / --- swingtimer react to SPELL_GO and SPELL_FAILED). +--[[ libdebuff ]]-- +-- Aura readers over ClassicAPI's C_UnitAuras, which already resolves each +-- aura's caster (sourceGUID) and expiration for non-player units: +-- GetBestAuraCast - live HoT timers for libpredict +-- UnitDebuff/OwnDebuff - libdebuff's legacy multi-return signature, still +-- read by third-party addons (e.g. SuperCleveRoidMacros) -- 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 = UnitClassBase("player") - --- 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 - -EventUtil.ContinueOnPlayerLogin(function() - RunNextFrame(function() - - 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 - 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") - 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 - --- 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 - --- 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", -} - --- ============================================================================ --- HELPER FUNCTIONS --- ============================================================================ - --- 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 = C_Spell.GetSpellName(spellId) - local texture = libdebuff:GetSpellIcon(spellId) - local stacks = (auraApps and auraApps[auraSlot] or 0) + 1 - local dtype = nil - local dispelId = C_Spell.GetSpellDispelType(spellId) - if dispelId and dispelId > 0 then - dtype = dispelTypeMap[dispelId] - 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 --- ============================================================================ - --- Report the live, talent-accurate duration of a matching aura on the player. --- ClassicAPI's C_UnitAuras carries the real (mod-folded) duration straight from --- the engine, so there's no static duration table to maintain. Returns 0 when --- the player has no such aura (e.g. a debuff that only ever lands on enemies). -function libdebuff:GetDuration(effect, rank) - local aura = C_UnitAuras.GetAuraDataBySpellName("player", effect) - return aura and aura.duration or 0 -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 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 +local libdebuff = {} -- ============================================================================ -- API: GetBestAuraCast (for libpredict HoT tracking) @@ -725,12 +41,8 @@ end -- ============================================================================ -- API: UnitDebuff / UnitOwnDebuff (C_UnitAuras adapters) -- ============================================================================ --- Thin readers kept for third-party addons (e.g. pfUI-WeakIcons) that still --- expect libdebuff's legacy multi-return signature: +-- Remap C_UnitAuras' AuraData onto libdebuff's legacy tuple: -- effect, rank, texture, stacks, dtype, duration, timeleft, caster --- ClassicAPI's C_UnitAuras already resolves source and expiration, so these --- just remap its AuraData onto that tuple -- no cast-tracking tables --- (ownDebuffs/allAuraCasts) or GetUnitField slot mapping involved. local function AuraToLegacy(aura) if not aura then return nil end @@ -771,925 +83,5 @@ function libdebuff:UnitOwnDebuff(unit, id) return AuraToLegacy(C_UnitAuras.GetAuraDataByIndex(unit, id, "HARMFUL|PLAYER")) 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 - -- via C_Spell.ChannelInfo. DEBUFF_REMOVED fires later (0.5-1s server lag), - -- causing phantom debuff display without this pre-clear. - local spellName = C_Spell.ChannelInfo() - if spellName then - local targetGuid = UnitGUID and UnitGUID("target") - if targetGuid and ownDebuffs[targetGuid] and ownDebuffs[targetGuid][spellName] then - local data = ownDebuffs[targetGuid][spellName] - local remaining = (data.startTime + data.duration) - GetTime() - if remaining > 0 then - ownDebuffs[targetGuid][spellName] = nil - end - end - end - elseif event == "PLAYER_ENTERING_WORLD" or 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 spellId = arg2 - local casterGuid = arg3 - -- arg6=castTime (ms), arg7=channel duration (ms). Prefer arg6 when set — - -- some spells (e.g. post-rework Volley) flag as channel via arg8 but ship a - -- real cast time in arg6, so we only fall back to arg7 for true channels - -- like Blizzard where arg6 is 0/nil. - local castTime = (arg6 and arg6 > 0) and arg6 or arg7 - if not casterGuid or not spellId then return end - - 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 - - -- Fire registered SPELL_GO_SELF hooks BEFORE miss guard - -- (Swingtimer needs to see ALL casts, even misses, to clear its queue color) - 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 = C_Spell.GetSpellName(spellId) - if not spellName then return end - local spellRankString = C_Spell.GetSpellSubtext(spellId) - - 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 - 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 - - -- 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 = C_Spell.GetSpellName(spellId) - 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 = C_Spell.GetSpellSubtext(spellId) - 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 - - -- Duration comes from nampower's AURA_CAST event. ClassicAPI's - -- C_UnitAuras now resolves combo-scaled durations server-side, so - -- libdebuff only needs the database fallback when AURA_CAST reports 0. - if duration == 0 then - 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 IsPlayerGuid(targetGuid) and pfPlayer then - pfPlayer.update_aura = true - end - - -- Check target - if UnitExists("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 = C_Spell.GetSpellName(spellId) - 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 - 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 = C_Spell.GetSpellName(spellId) 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 diff --git a/libs/libpredict.lua b/libs/libpredict.lua index 2943f233..8a47b7dc 100644 --- a/libs/libpredict.lua +++ b/libs/libpredict.lua @@ -13,34 +13,23 @@ setfenv(1, pfUI:GetEnvironment()) -- including resurrections. It has an option to disable the sending of those -- messages in case HealComm is already active. -- --- HOT TRACKING INTEGRATION (NEW): --- With Nampower enabled, HoT tracking now primarily uses libdebuff's AURA_CAST --- event system for accurate server-side buff/debuff tracking with full rank --- protection. GetHotDuration() first checks libdebuff, then falls back to the --- legacy prediction system for backwards compatibility with non-Nampower clients. --- This provides: --- - Accurate duration from server (no prediction needed) --- - Automatic rank protection (lower ranks won't overwrite higher ranks) --- - Support for multiple casters of same HoT on one target --- - Zero event overhead (libdebuff already tracks all auras) +-- Casts are read from ClassicAPI's UNIT_SPELLCAST_* events and heal amounts +-- from COMBAT_LOG_EVENT_UNFILTERED. GetHotDuration() reads live HoT timers +-- from C_UnitAuras (via libdebuff) and falls back to the HealComm-fed table. -- return instantly when another libpredict is already active if pfUI.api.libpredict then return end --- Check if libdebuff integration is available -local libdebuff_available = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false - --- Check if Nampower is available for SPELL_FAILED events - -local senttarget local heals, ress, events, hots = {}, {}, {}, {} local spell_queue = { "DUMMY", "DUMMYRank 9", "TARGET" } local player = UnitName("player") local cache, gear_string = {}, "" local foreignCache = {} -- [casterName][spellKey] = amount, in-memory cache for other healers local rejuvDuration, renewDuration = 12, 15 --default durations -local ressGuidToName = {} -- [casterGuid] = casterName, for SPELL_FAILED_OTHER cleanup -local healGuidToName = {} -- [casterGuid] = casterName, for SPELL_FAILED_OTHER cleanup +local ressGuidToName = {} -- [casterGuid] = casterName, for interrupted-cast cleanup +local healGuidToName = {} -- [casterGuid] = casterName, for interrupted-cast cleanup +local sentTargets = {} -- [castGUID] = target name of an own cast, from UNIT_SPELLCAST_SENT +local castTargets = {} -- [castGUID] = target name of a foreign cast, from UNIT_SPELLCAST_START local ress_timers = {} -- [target][sender] = expiry_timestamp (60s rez window) local RESS_TIMEOUT = 60 -- Vanilla: rez offer expires after 60s @@ -124,43 +113,25 @@ libpredict:SetScript("OnEvent", function() if origOnEvent then origOnEvent() end end) --- SPELL_START_SELF: own cast started (heals + rez) -pfUI.libdebuff_spell_start_self_hooks = pfUI.libdebuff_spell_start_self_hooks or {} -pfUI.libdebuff_spell_start_self_hooks["libpredict"] = function(spellId, casterGuid, targetGuid, castTime) +-- Own cast-time spell started (heals + rez) +local function OnOwnCastStart(spellId, castGUID) local spellName = C_Spell.GetSpellName(spellId) if not spellName then return end - local pendingTarget = nil - local pendingTargetGuid = nil - local pending = pfUI.libpredict_pending_cast - if pending and pending.spellName == spellName and pending.targetGuid - and pending.time and (GetTime() - pending.time) < 1 then - -- If SPELL_CAST_EVENT target differs from SPELL_START_SELF target, - -- the client redirected the cast (e.g. offline target → selfcast). - -- Discard pending and trust the actual targetGuid from SPELL_START_SELF. - if pending.targetGuid == targetGuid then - local name = UnitName(pending.targetGuid) - if name and name ~= UNKNOWNOBJECT and name ~= UKNOWNBEING then - pendingTarget = name - pendingTargetGuid = pending.targetGuid - end - end - pending.spellId = nil - pending.spellName = nil - pending.targetGuid = nil - pending.time = nil - end - local target = pendingTarget or resolveNameFromGuid(targetGuid) or senttarget or spell_queue[3] - -- resolved GUID: prefer pendingTargetGuid (mouseover/click-to-cast), fall back to targetGuid - local resolvedTargetGuid = pendingTargetGuid or targetGuid - + -- SENT names the unit the cast went out at; self and no-target casts + -- carry none, so fall back to what the cast hooks recorded + local target = sentTargets[castGUID] or spell_queue[3] + local _, _, _, startMs, endMs = C_Spell.CastingInfo() + local castTime = startMs and endMs - startMs or 0 + libpredict.sender.castGUID = castGUID + libpredict.sender.delayMs = 0 libpredict.sender.current_cast = spellName libpredict.sender.current_cast_target = target if L["resurrections"][spellName] then if target then - libpredict:Ress(player, target, casterGuid) + libpredict:Ress(player, target, UnitGUID("player")) libpredict.sender:SendHealCommMsg("Resurrection/" .. target .. "/start/") libpredict.sender:SendResCommMsg("RES " .. target) libpredict.sender.resurrecting = true @@ -235,27 +206,17 @@ pfUI.libdebuff_spell_start_self_hooks["libpredict"] = function(spellId, casterGu return -- skip the generic Heal call below end - -- If the resolved heal target is itself hostile (e.g. self-cast heal while targeting an enemy), - -- store the heal under the player so the player frame shows the prediction. - -- Use resolvedTargetGuid (prefers pendingTargetGuid from mouseover/click-to-cast) so we - -- don't wrongly fall back to player when healing a friend via mouseover with a foe in target. - local healTarget = target - local unitTargetGuid = UnitGUID and UnitGUID("target") - local healTargetIsHostile = resolvedTargetGuid and unitTargetGuid - and resolvedTargetGuid == unitTargetGuid - and UnitExists("target") - and (UnitCanAttack("player", "target") or not UnitIsFriend("player", "target")) - if healTargetIsHostile then - healTarget = player - end - libpredict:Heal(player, healTarget, amount, casttime) - libpredict.sender:SendHealCommMsg("Heal/" .. (healTarget or "") .. "/" .. amount .. "/" .. casttime .. "/") + libpredict:Heal(player, target, amount, casttime) + libpredict.sender:SendHealCommMsg("Heal/" .. (target or "") .. "/" .. amount .. "/" .. casttime .. "/") libpredict.sender.healing = true end end --- SPELL_GO_SELF: own cast landed (HealStop + Regrowth timer) -pfUI.libdebuff_spell_go_hooks["libpredict_sender"] = function(spellId) +-- Own cast landed: HealStop, Regrowth timer, instant HoTs, own rez timer +local function OnOwnCastSucceeded(spellId, castGUID) + local target = sentTargets[castGUID] or spell_queue[3] + sentTargets[castGUID] = nil + libpredict:HealStop(player) local spellName = C_Spell.GetSpellName(spellId) if spellName == REGROWTH then @@ -267,32 +228,57 @@ pfUI.libdebuff_spell_go_hooks["libpredict_sender"] = function(spellId) end libpredict.sender.regrowth_timer = now + 0.1 end + libpredict.sender.castGUID = nil libpredict.sender.current_cast = nil libpredict.sender.current_cast_target = nil + + if not target then return end + + -- Instant HoTs — classify by name (rank-independent) instead of a + -- hardcoded per-rank ID table. + local hotType + if spellName == REJUVENATION then hotType = "Reju" + elseif spellName == RENEW then hotType = "Renew" + end + if hotType then + local duration = hotType == "Reju" and rejuvDuration or renewDuration + local rank = 0 + local rankSub = C_Spell.GetSpellSubtext(spellId) + if rankSub and rankSub ~= "" then + rank = tonumber((string.gsub(rankSub, "Rank ", ""))) or 0 + end + libpredict:Hot(player, target, hotType, duration, nil, "UNIT_SPELLCAST_SUCCEEDED", rank) + libpredict.sender:SendHealCommMsg(hotType .. "/" .. target .. "/" .. duration .. "/" .. rank .. "/") + end + + if isRezSpell(spellId) then + libpredict:RessSetTimer(player, target) + end end --- SPELL_START_OTHER: foreign cast started (heals + rez) --- Signature: fn(spellId, casterGuid, targetGuid, castTime) -pfUI.libdebuff_spell_start_other_hooks = pfUI.libdebuff_spell_start_other_hooks or {} -pfUI.libdebuff_spell_start_other_hooks["libpredict"] = function(spellId, casterGuid, targetGuid, castTime) +-- Foreign cast-time spell started (heals + rez) +local function OnOtherCastStart(unit, spellId, castGUID) local spellName = C_Spell.GetSpellName(spellId) if not spellName then return end + local casterGuid = UnitGUID(unit) local casterName = resolveNameFromGuid(casterGuid) if not casterName then return end + local targetName = UnitSpellTargetName(unit) + if not targetName then return end + castTargets[castGUID] = targetName + -- Resurrection cast if L["resurrections"][spellName] then - local targetName = resolveNameFromGuid(targetGuid) - if targetName then - libpredict:Ress(casterName, targetName, casterGuid) - end + libpredict:Ress(casterName, targetName, casterGuid) return end -- Heal cast: look up cached amount from a previous cast by this healer - local targetName = resolveNameFromGuid(targetGuid) - if not targetName then return end + local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo(unit) + if not startMs then return end + local castTime = endMs - startMs local rankStr = C_Spell.GetSpellSubtext(spellId) or "" local spellKey = spellName .. (rankStr or "") @@ -338,77 +324,26 @@ pfUI.libdebuff_spell_start_other_hooks["libpredict"] = function(spellId, casterG libpredict:Heal(casterName, targetName, amount, castTime, casterGuid) end --- SPELL_GO_SELF: own cast landed - HoTs + own rez timer --- Signature: fn(spellId, arg1, arg2, arg3, arg4, arg5, arg6, arg7) -pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {} -pfUI.libdebuff_spell_go_hooks["libpredict"] = function(spellId, a1, a2, a3, a4, a5, a6, a7) - -- Instant HoTs — classify by name (rank-independent) instead of a - -- hardcoded per-rank ID table. - local spellName = C_Spell.GetSpellName(spellId) - local hotType - if spellName == REJUVENATION then hotType = "Reju" - elseif spellName == RENEW then hotType = "Renew" - end - if hotType then - local targetGuid = a4 - local targetName = resolveNameFromGuid(targetGuid) - if targetName then - local duration - if hotType == "Reju" then duration = rejuvDuration or 12 - elseif hotType == "Renew" then duration = renewDuration or 15 - end - local rank = 0 - local rankSub = C_Spell.GetSpellSubtext(spellId) - if rankSub and rankSub ~= "" then - rank = tonumber((string.gsub(rankSub, "Rank ", ""))) or 0 - end - local playerName = UnitName("player") - libpredict:Hot(playerName, targetName, hotType, duration, nil, "SPELL_GO_SELF", rank) - local rankStr = tostring(rank) - if libpredict.sender and libpredict.sender.SendHealCommMsg then - libpredict.sender:SendHealCommMsg(hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/") - elseif IsInRaid() then - SendAddonMessage("HealComm", hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/", "RAID") - elseif IsInGroup() then - SendAddonMessage("HealComm", hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/", "PARTY") - end - end - end - -- Own rez landed: set timer - if isRezSpell(spellId) then - local targetGuid = a4 - local targetName = resolveNameFromGuid(targetGuid) - local playerName = UnitName("player") - if playerName and targetName then - libpredict:RessSetTimer(playerName, targetName) - end - end - -end - --- SPELL_GO_OTHER: foreign cast landed - rez timer + HealStop --- Signature: fn(spellId, casterGuid, targetGuid) -pfUI.libdebuff_spell_go_other_hooks = pfUI.libdebuff_spell_go_other_hooks or {} -pfUI.libdebuff_spell_go_other_hooks["libpredict"] = function(spellId, casterGuid, targetGuid) +-- Foreign cast landed: rez timer, or the predicted heal is fulfilled +local function OnOtherCastSucceeded(unit, spellId, castGUID) + local casterGuid = UnitGUID(unit) local casterName = resolveNameFromGuid(casterGuid) if not casterName then return end if isRezSpell(spellId) then - local targetName = resolveNameFromGuid(targetGuid) + local targetName = castTargets[castGUID] if targetName then libpredict:RessSetTimer(casterName, targetName) end return end - -- Heal landed → prediction fulfilled, clean up libpredict:HealStop(casterName) end --- SPELL_FAILED_OTHER: remove cancelled resses/heals --- Signature: fn(casterGuid, spellId) -pfUI.libdebuff_spell_failed_other_hooks = pfUI.libdebuff_spell_failed_other_hooks or {} -pfUI.libdebuff_spell_failed_other_hooks["libpredict"] = function(casterGuid, spellId) +-- Foreign cast interrupted: remove cancelled resses/heals +local function OnOtherCastInterrupted(unit) + local casterGuid = UnitGUID(unit) if not casterGuid then return end local ressName = ressGuidToName[casterGuid] if ressName then @@ -567,7 +502,7 @@ function libpredict:ParseChatMessage(sender, msg, comm) libpredict:Heal(sender, target, heal, time) end elseif msgtype == "Ress" then - -- HealComm: only for other players (own resses tracked via SPELL_START_SELF hook) + -- HealComm: only for other players (own resses tracked via UNIT_SPELLCAST_START) local playerName = UnitName("player") if sender ~= playerName then local senderGuid @@ -912,7 +847,7 @@ hooksecurefunc("CastSpell", function(id, bookType) rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil end - -- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback + -- Instant HoTs: UNIT_SPELLCAST_SUCCEEDED and C_UnitAuras, hook method as fallback if effect == REJUVENATION then local target = spell_queue[3] @@ -976,7 +911,7 @@ hooksecurefunc("CastSpellByName", function(effect, target) spell_queue[3] = target or default end - -- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback + -- Instant HoTs: UNIT_SPELLCAST_SUCCEEDED and C_UnitAuras, hook method as fallback if effect == REJUVENATION then local hotTarget = target or default @@ -1037,7 +972,7 @@ hooksecurefunc("UseAction", function(slot, target, selfcast) rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil end - -- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback + -- Instant HoTs: UNIT_SPELLCAST_SUCCEEDED and C_UnitAuras, hook method as fallback if effect == REJUVENATION then local hotTarget = spell_queue[3] @@ -1127,15 +1062,17 @@ libpredict.sender:SetScript("OnUpdate", function() end end) --- Nampower events -libpredict.sender:RegisterEvent("SPELL_FAILED_SELF") -libpredict.sender:RegisterEvent("SPELL_DELAYED_SELF") -libpredict.sender:RegisterEvent("SPELL_HEAL_BY_SELF") -libpredict.sender:RegisterEvent("SPELL_HEAL_BY_OTHER") -- populates foreignCache for other healers - --- force cache updates -libpredict.sender:RegisterUnitEvent("UNIT_INVENTORY_CHANGED", "player") -libpredict.sender:RegisterEvent("SKILL_LINES_CHANGED") +-- Own casts arrive as "player"; every other caster fires once per unit token +-- it currently holds (raidN, target, nameplateN, ...), so other-unit handlers +-- drop the repeats by castGUID. +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_SENT") +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_START") +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_DELAYED") +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED") +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_FAILED") +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED") +libpredict.sender:RegisterEvent("UNIT_SPELLCAST_STOP") +libpredict.sender:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") -- Shared cleanup helper for failed/interrupted casts local function onCastFailed() @@ -1145,7 +1082,7 @@ local function onCastFailed() s:SendHealCommMsg("Healstop") s.healing = nil elseif s.resurrecting then - local target = s.current_cast_target or senttarget or spell_queue[3] + local target = s.current_cast_target or spell_queue[3] libpredict:RessStop(UnitName("player")) s:SendHealCommMsg("Resurrection/stop/") s:SendResCommMsg("RESNO " .. (target or "")) @@ -1157,81 +1094,103 @@ local function onCastFailed() s.regrowth_target_next = nil s.regrowth_start_next = nil end + s.castGUID = nil s.current_cast = nil s.current_cast_target = nil end -libpredict.sender:SetScript("OnEvent", function() - -- ============================================================ - -- NAMPOWER PATH - -- ============================================================ - if event == "SPELL_HEAL_BY_SELF" then - -- arg1=targetGuid, arg2=casterGuid, arg3=spellId, arg4=amount, arg5=critical, arg6=periodic - local spellId = arg3 - local amount = arg4 - local isCrit = arg5 == 1 - local isPeriodic = arg6 == 1 - local spellName = C_Spell.GetSpellName(spellId) - if spellName and spell_queue[1] == spellName then +-- Real heal amounts: own heals refresh the spell cache, other group healers' +-- heals fill foreignCache (one event per hit target for AoE heals like PoH). +local function OnHeal() + -- arg4=sourceGUID, arg12=spellId, arg13=spellName, arg15=amount, arg18=critical + local spellId, spellName, amount, isCrit = arg12, arg13, arg15, arg18 == 1 + if not spellName or not amount or amount <= 0 then return end + + if IsPlayerGuid(arg4) then + if spell_queue[1] == spellName then UpdateCache(spell_queue[2], amount, isCrit) end + return + end - elseif event == "SPELL_HEAL_BY_OTHER" then - -- Fires once per hit target for AoE heals (e.g. PoH). - -- Use this to build a per-caster cache of real heal amounts. - -- arg1=targetGuid, arg2=casterGuid, arg3=spellId, arg4=amount, arg5=critical, arg6=periodic - local casterGuid = arg2 - local spellId = arg3 - local amount = arg4 - local isCrit = arg5 == 1 - local isPeriodic = arg6 == 1 + local casterName = resolveNameFromGuid(arg4) + if not casterName then return end - if isPeriodic or not spellId or not amount or amount <= 0 then return end + local spellKey = spellName .. (C_Spell.GetSpellSubtext(spellId) or "") - local casterName = resolveNameFromGuid(casterGuid) - if not casterName or casterName == player then return end -- own heals handled by SPELL_HEAL_BY_SELF + foreignCache[casterName] = foreignCache[casterName] or {} + local existing = foreignCache[casterName][spellKey] + -- Store highest non-crit value for best prediction accuracy + if isCrit then + -- Estimate base from crit (vanilla crit = 150%) + local base = math.floor(amount * 2 / 3) + if not existing or base > existing then + foreignCache[casterName][spellKey] = base + end + else + if not existing or amount > existing then + foreignCache[casterName][spellKey] = amount + end + end +end - local spellName = C_Spell.GetSpellName(spellId) - if not spellName then return end - local rankStr = C_Spell.GetSpellSubtext(spellId) - local spellKey = spellName .. (rankStr or "") +local lastOtherCast = {} -- [event] = castGUID of the last other-unit event handled - foreignCache[casterName] = foreignCache[casterName] or {} - local existing = foreignCache[casterName][spellKey] - -- Store highest non-crit value for best prediction accuracy - if isCrit then - -- Estimate base from crit (vanilla crit = 150%) - local base = math.floor(amount * 2 / 3) - if not existing or base > existing then - foreignCache[casterName][spellKey] = base - end - else - if not existing or amount > existing then - foreignCache[casterName][spellKey] = amount +libpredict.sender:SetScript("OnEvent", function() + if event == "COMBAT_LOG_EVENT_UNFILTERED" then + if arg2 == "SPELL_HEAL" then OnHeal() end + return + end + + -- UNIT_SPELLCAST_SENT: arg1=unit, arg2=target token, arg3=castGUID, arg4=spellID + -- every other UNIT_SPELLCAST_*: arg1=unit, arg2=castGUID, arg3=spellID + local unit = arg1 + if unit == "player" then + if event == "UNIT_SPELLCAST_SENT" then + sentTargets[arg3] = arg2 ~= "" and UnitName(arg2) or nil + elseif event == "UNIT_SPELLCAST_START" then + OnOwnCastStart(arg3, arg2) + elseif event == "UNIT_SPELLCAST_SUCCEEDED" then + OnOwnCastSucceeded(arg3, arg2) + elseif event == "UNIT_SPELLCAST_FAILED" or event == "UNIT_SPELLCAST_INTERRUPTED" then + sentTargets[arg2] = nil + -- a refused cast (cooldown, range) fails under its own castGUID and + -- must not cancel the heal that is still casting + if arg2 == this.castGUID then onCastFailed() end + elseif event == "UNIT_SPELLCAST_DELAYED" then + -- delayTimeMs is the pushback so far; HealComm wants each step + local _, _, _, _, _, _, _, _, _, _, delayMs = C_Spell.CastingInfo() + if this.healing and delayMs and delayMs > this.delayMs then + local step = delayMs - this.delayMs + this.delayMs = delayMs + libpredict:HealDelay(player, step) + this:SendHealCommMsg("Healdelay/" .. step .. "/") end end + return + end - elseif event == "SPELL_FAILED_SELF" then - onCastFailed() - - elseif event == "SPELL_DELAYED_SELF" then - -- arg1=casterGuid, arg2=delayMs - if libpredict.sender.healing then - libpredict:HealDelay(player, arg2) - libpredict.sender:SendHealCommMsg("Healdelay/" .. arg2 .. "/") - end + if UnitIsUnit(unit, "player") or lastOtherCast[event] == arg2 then return end + lastOtherCast[event] = arg2 + if event == "UNIT_SPELLCAST_START" then + OnOtherCastStart(unit, arg3, arg2) + elseif event == "UNIT_SPELLCAST_SUCCEEDED" then + OnOtherCastSucceeded(unit, arg3, arg2) + elseif event == "UNIT_SPELLCAST_INTERRUPTED" then + OnOtherCastInterrupted(unit) + elseif event == "UNIT_SPELLCAST_STOP" then + castTargets[arg2] = nil end end) function libpredict:GetHotDuration(unit, spell) if unit == UNKNOWNOBJECT or unit == UKNOWNBEING then return end - -- NEW: Try libdebuff first (Nampower AURA_CAST events) - if pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast then + -- Live aura timer first (C_UnitAuras via libdebuff) + if pfUI.api.libdebuff then local guid = UnitGUID(unit) if guid then - -- Get the best (highest rank) aura cast for this spell local spellName = spell -- Map short spell codes to full names @@ -1246,7 +1205,6 @@ function libpredict:GetHotDuration(unit, spell) local start, duration, timeleft, rank, casterGuid = pfUI.api.libdebuff:GetBestAuraCast(guid, spellName) if start and duration and timeleft then - -- SUCCESS: libdebuff has accurate server-side data! if libpredict.debug then DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[GetHotDuration]|r %s on %s via libdebuff: dur=%.1fs timeleft=%.1fs rank=%d", spell, unit, duration, timeleft, rank or 0)) @@ -1256,7 +1214,7 @@ function libpredict:GetHotDuration(unit, spell) end end - -- FALLBACK: Use old prediction system (for non-Nampower clients or no AURA_CAST data) + -- FALLBACK: the HealComm/cast-fed table, for HoTs C_UnitAuras has no timer for local start, duration, timeleft local now = pfUI.uf.now or GetTime() @@ -1290,16 +1248,11 @@ _G.SlashCmdList.HOTDEBUG = function() DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[HoT Tracking Debug]|r") DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r") - -- Check libdebuff availability - local libdebuff_now = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false - - if libdebuff_now then + if pfUI.api.libdebuff then DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[PRIMARY]|r libdebuff integration: ACTIVE") - DEFAULT_CHAT_FRAME:AddMessage(" Using AURA_CAST events for server-side tracking") - DEFAULT_CHAT_FRAME:AddMessage(" Rank protection: ENABLED") + DEFAULT_CHAT_FRAME:AddMessage(" Using C_UnitAuras timers") else DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[PRIMARY]|r libdebuff integration: NOT AVAILABLE") - DEFAULT_CHAT_FRAME:AddMessage(" Reason: Nampower not enabled or libdebuff outdated") end DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[FALLBACK]|r Legacy prediction system: ACTIVE") diff --git a/modules/actionbar.lua b/modules/actionbar.lua index 10526a66..c97b625f 100644 --- a/modules/actionbar.lua +++ b/modules/actionbar.lua @@ -897,6 +897,10 @@ pfUI:RegisterModule("actionbar", function () pageswitch:RegisterEvent("UPDATE_SHAPESHIFT_FORM") pageswitch:RegisterEvent("PLAYER_AURAS_CHANGED") pageswitch:RegisterEvent("PLAYER_LOGOUT") + local PROWL_IDS = { [5215] = true, [6783] = true, [9913] = true } + if class == "DRUID" then + pageswitch:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player") + end pageswitch:SetScript("OnEvent", function() if event == "PLAYER_LOGOUT" then this:UnregisterAllEvents() @@ -919,6 +923,18 @@ pfUI:RegisterModule("actionbar", function () return end + -- Eager prowl detection on the cast itself — zero-latency vs. + -- PLAYER_AURAS_CHANGED, which can land a frame or two behind it. + -- arg3 = spellID + if event == "UNIT_SPELLCAST_SUCCEEDED" then + if PROWL_IDS[arg3] then + inCatForm = true + prowlActive = true + formpaging = true + end + return + end + if event == "UPDATE_SHAPESHIFT_FORM" then -- Authoritative form-change signal — recompute and short-circuit. inCatForm = HasCatForm() @@ -942,20 +958,6 @@ pfUI:RegisterModule("actionbar", function () end end) - -- Eager prowl detection via Nampower SPELL_GO_SELF — zero-latency vs. - -- PLAYER_AURAS_CHANGED, which can land a frame or two behind the cast. - -- Form changes are no longer hooked here; UPDATE_SHAPESHIFT_FORM covers - -- those without needing a per-spellID watchlist. - local PROWL_IDS = { [5215] = true, [6783] = true, [9913] = true } - pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {} - pfUI.libdebuff_spell_go_hooks["actionbar_prowl"] = function(spellId) - if class ~= "DRUID" then return end - if PROWL_IDS[spellId] then - inCatForm = true - prowlActive = true - formpaging = true - end - end pageswitch:SetScript("OnUpdate", function() -- switch actionbar page depending on meta key that is pressed if C.bars.pagemastershift == "1" and IsShiftKeyDown() then diff --git a/modules/swingtimer.lua b/modules/swingtimer.lua index 110716de..93b44cb8 100644 --- a/modules/swingtimer.lua +++ b/modules/swingtimer.lua @@ -635,25 +635,6 @@ pfUI:RegisterModule("swingtimer", function () end end) - -- SPELL_GO hook via libdebuff: an on-next-swing ability (HS / Cleave / - -- Maul) resolving consumes the queue, so drop the queued color. - pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {} - pfUI.libdebuff_spell_go_hooks["swingtimer"] = function(spellId) - if IsOnSwingSpell(spellId) then - S.hsQueued = false; S.cleaveQueued = false; S.maulQueued = false - end - end - - -- SPELL_CAST_EVENT hook: HS/Cleave/Maul queue tracking - pfUI.libdebuff_spell_cast_hooks = pfUI.libdebuff_spell_cast_hooks or {} - pfUI.libdebuff_spell_cast_hooks["swingtimer"] = function(success, spellId) - local kind = ClassifyOnSwingSpell(spellId) - if not kind then return end - S.useSpellQueueEvent = true - SetQueuedKind(kind) - end - - local events = CreateFrame("Frame") events:RegisterEvent("PLAYER_SWING") events:RegisterEvent("PLAYER_ENTERING_WORLD") @@ -661,7 +642,9 @@ pfUI:RegisterModule("swingtimer", function () events:RegisterEvent("PLAYER_REGEN_DISABLED") events:RegisterEvent("PLAYER_REGEN_ENABLED") events:RegisterEvent("ACTIONBAR_SLOT_CHANGED") - events:RegisterUnitEvent("UNIT_DIED", UnitGUID("player")) + events:RegisterEvent("PLAYER_DEAD") + events:RegisterUnitEvent("UNIT_SPELLCAST_SENT", "player") + events:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player") events:RegisterEvent("SPELL_QUEUE_EVENT") events:RegisterEvent("START_AUTOATTACK") events:RegisterEvent("STOP_AUTOATTACK") @@ -679,6 +662,21 @@ pfUI:RegisterModule("swingtimer", function () StartRanged(arg1) end + elseif event == "UNIT_SPELLCAST_SENT" then + -- arg4 = spellID: an on-next-swing ability (HS / Cleave / Maul) queued + local kind = ClassifyOnSwingSpell(arg4) + if kind then + S.useSpellQueueEvent = true + SetQueuedKind(kind) + end + + elseif event == "UNIT_SPELLCAST_SUCCEEDED" then + -- arg3 = spellID: the on-next-swing ability resolved, which consumes + -- the queue, so drop the queued color + if IsOnSwingSpell(arg3) then + S.hsQueued = false; S.cleaveQueued = false; S.maulQueued = false + end + elseif event == "SPELL_QUEUE_EVENT" then local eventCode = arg1 or -1 local spellId = arg2 or 0 @@ -728,10 +726,8 @@ pfUI:RegisterModule("swingtimer", function () S.cleaveQueued = false S.maulQueued = false - elseif event == "UNIT_DIED" then - if IsPlayerGuid(arg1) then - ResetAll() - end + elseif event == "PLAYER_DEAD" then + ResetAll() end end)