Files
pfUI/libs/libdebuff.lua
T
Brues 00292ca3b4 Focus/Nameplates now respond solely to UNIT_* events
commit 70b1e66c2d
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sun Jul 26 18:30:18 2026 -0500

    Use SetSize/SetShown and simplify minimap/map

commit 7b6bfe9975
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 11:27:14 2026 -0500

    Include raidpet frames in /pftest test mode

    The unit-frame test toggle (showall) only previews frames that exist and
    are positioned. Raidpet frames are created when raidpet.visible == "1",
    but LayoutPets' collapse mode only positions pets whose raidpet<N> unit
    actually exists, so solo/in test mode they stayed hidden.

    Add a showall branch to LayoutPets that mirror-lays every pet cell and
    shows it (bypassing collapse and the roster gate), still guarded by the
    existing showpets check so nothing happens when raidpet is disabled. Call
    LayoutPets from the /pftest handler so the grid updates immediately on
    toggle-on and restores to the normal layout on toggle-off.

commit 4c65c38647
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:34:33 2026 -0500

    Remove unused RangeCache local in UnitInRange

commit 6c96bbbe6c
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:34:33 2026 -0500

    Use Clamp() for the two-sided clamps in ui-widgets

    Replace the hand-rolled math.min/math.max and if/elseif clamp idioms in the
    status bar and scroll frame with Clamp(). Equivalent for every value seen
    (min <= max always holds), just clearer.

commit 830e0a0be1
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:34:33 2026 -0500

    Drop vanilla GetItemInfo shim for C_Item.GetItemInfo

    The compat/vanilla.lua GetItemInfo override reshaped vanilla's tuple into
    retail order by inserting nil at slot 4 (itemLevel), but truncated at 8
    fields, dropping itemEquipLoc/itemTexture. ClassicAPI's global GetItemInfo
    keeps the vanilla shape (its hook only warms the item cache), so the shim
    stayed necessary but incomplete.

    Remove it and point the three callers at ClassicAPI's C_Item.GetItemInfo,
    which returns the full 18-field retail tuple. Positions 1-8 are identical
    to the old shim output, so bags/roll are unchanged; character.lua's
    itemSlot (equipLoc, field 9) was always nil under the truncating shim and
    now resolves correctly for ShaguScore.

commit c0da63657d
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:09:34 2026 -0500

    Various cleanup

    Removes version compatibility checks for TBC/Vanilla, consolidating code to target a single WoW version. Refactors repetitive SetWidth/SetHeight calls to SetSize for cleaner code. Removes duplicate function definitions from unitxp module and simplifies conditional show/hide operations using SetShown.

commit 11a6302c2d
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 09:53:37 2026 -0500

    Delegate pfUI.api.strsplit to ClassicAPI's strsplit

    Replace the Lua pattern-based implementation with a thin wrapper around
    ClassicAPI's C-level strsplit. Keeps the pfUI.api.strsplit entry point for
    backwards compatibility with addons that call it, while dropping the
    redundant reimplementation.

    Behavioral note: the old version used ([^delim]+) which silently collapsed
    empty fields; delegating to real strsplit now preserves them
    ("a,,b" -> "a", "", "b"). All in-repo callers split numeric color tuples,
    version strings, or build name-keyed lookup tables, none of which are
    affected by empty-field preservation.

commit 67c126eac8
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 01:01:02 2026 -0500

    Bump ClassicAPI minimum version to 1.8.0

commit a6cf157518
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 00:49:50 2026 -0500

    read spell rank via C_Spell.GetSpellSubtext

    The rank string comes from the spell subtext ("Rank N"), which ClassicAPI's
    C_Spell.GetSpellSubtext returns directly -- so drop nampower's
    GetSpellRecField(spellId, "rank") here. The existing "Rank " gsub parse is
    unchanged.

commit 4b1ba99b4f
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 00:33:20 2026 -0500

    Move player info overlay onto ClassicAPI (drop Nampower)

    The haste/spell-power overlay read both values through Nampower and disabled
    itself entirely without it. Move both to ClassicAPI so it works on any
    ClassicAPI client:

    - Spell power: GetSpellPower("net") (nampower) -> GetSpellBonusDamage(i)
      (ClassicAPI, same per-school field). Merged the build+scan into one loop;
      the default school seeds the tiebreak so equal schools still favor it.
    - Haste: GetUnitField("player", "modCastSpeed") -> UnitSpellHaste("player"),
      which returns the haste percentage directly (the (1/modCastSpeed-1)*100
      conversion is now baked in, off the same UNIT_MOD_CAST_SPEED field, signed).
    - Dropped the "if not GetUnitField then return" gate -- UpdateInfoText no
      longer touches Nampower, so the overlay runs everywhere.

commit 7df4aa6d50
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 00:32:44 2026 -0500

    Hook the real global _G.UnitHealth for feign death

    Inside a RegisterModule body, `function UnitHealth(...)` defines UnitHealth on
    the pfUI environment, not the real global -- so the feign-death real-HP fix
    only reached callers that resolve UnitHealth through pfUI's env, and missed
    _G consumers (Blizzard frames, other addons). Hook _G.UnitHealth explicitly
    (and capture oldUnitHealth from _G) so the un-gate applies everywhere.

commit b0bf2fd869
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 21:10:58 2026 -0500

    Refactor nampower module

    Simplify reactive spell storage by using spell IDs instead of texture/name tables. Update to use modern C_Spell APIs (GetSpellTexture, GetSpellName) instead of manual texture paths. Consolidate SetWidth/SetHeight calls to SetSize and use print() instead of DEFAULT_CHAT_FRAME:AddMessage().

commit 0b06961333
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 20:54:23 2026 -0500

    Use GetNamePlateForUnit for target lookups; drop dead ScanGuid block

    - Replace the three GetNamePlateForGUID(UnitGUID("target")) round-trips with
      GetNamePlateForUnit("target"), which resolves the token to a GUID internally
      -- no UnitGUID string detour, and it returns nil for no-target/no-plate so
      the UnitExists guards collapse. GetNamePlateForGUID is left for raw-GUID cases.
    - Remove the dead libunitscan.ScanGuid nameplate block (ScanGuid was deleted
      from libunitscan long ago, so the guard was never true) -- it carried the
      last GetUnitField("npcFlags") read.

commit 3e6210086e
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 20:54:23 2026 -0500

    Drop Nampower stats system and polling from unit frames

    GetUnitStats now reads health/power straight from UnitHealth/UnitPower
    (the descriptor fields the server broadcasts) -- for a real unit token
    GetUnitField read nothing different, so the whole Nampower-vs-fallback
    apparatus was measuring a distinction that no longer exists. Removed:

    - The stats system: pfUI.uf.stats, pfUIStatsFrame + UpdateStatsDisplay, the
      lastUnitStats change-cache, the /pfuistats slash command, and every counter
      increment (event/heartbeat/earlyReturn/throttledSkip/nampower/fallback).
    - All GetUnitField health reads (GetUnitStats, heal-prediction, health-gradient
      color) -- collapsed to the UnitHealth/UnitHealthMax they already fell back to.
    - The heartbeat-polling backstop and its fallbackThrottle/lastEventUpdate deps.
      Frames now refresh on events only; eventless frames still use their own tick,
      and range/aggro still run in the eventless-actions block.

commit 63001b7b0c
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 20:09:42 2026 -0500

    Move nameplates onto ClassicAPI stable nameplate tokens

    ClassicAPI now assigns retail-exact, per-plate-stable "nameplateN" tokens and
    fires vanilla UNIT_* events for them, so the nameplate module can key off the
    token instead of Nampower/SuperWoW GUID primitives:

    - Events: UNIT_AURA_GUID/UNIT_FLAGS_GUID -> UNIT_AURA/UNIT_FLAGS, matched by
      the "nameplateN" token (guarded on the token prefix, since these also fire
      for target/party/raid). Registered unconditionally -- no GetUnitField gate.
    - Health: GetUnitField(guid, "health"/"maxHealth") -> UnitHealth/UnitHealthMax
      on the plate's cached token. Same UNIT_FIELD_HEALTH the server broadcasts
      (real HP on Turtle; the ~= 100 scaled-vs-real guard is unchanged).
    - Combat/target in GetCombatStateColor: GetUnitField("flags") + HasFlag ->
      UnitAffectingCombat(token); GetUnitField("target") + SuperWoW "<guid>target"
      -> the "nameplateNtarget" chain. Dropped the now-dead HasFlag,
      UNIT_FLAG_IN_COMBAT, and guidTargetTokenCache.
    - GetCastInfo now takes the token directly; every caller already holds it, so
      the UnitTokenFromGUID(guid) round-trip is gone (unused module-wide now).

    NAME_PLATE_UNIT_ADDED caches the token as plate.nameplate.unit alongside the
    GUID; REMOVED clears both. GUID is retained only as the stable cache key
    (debuffCache/threatMemory/combatColorCache). Only GetUnitField("npcFlags")
    remains -- it has no token/vanilla equivalent.

commit ce1c49fcbb
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 18:20:06 2026 -0500

    Make focus/focustarget event-driven via ClassicAPI unit events

    ClassicAPI now fires UNIT_* (health/mana/aura/...) with arg1 == "focus" and
    arg1 == "focustarget", observed per-unit like target/party/raid. Both frames
    already registered those events (focus/focustarget are in pfValidUnits) and
    their OnEvent already matches arg1 == label, so the 0.2s polling ticks were
    pure workarounds for the missing events. Drop both ticks; the frames now
    refresh on-event like target, with range/glow still on the shared 0.5s state
    pass and PLAYER_FOCUS_CHANGED still driving assign/clear.
2026-07-26 18:32:36 -05:00

1687 lines
62 KiB
Lua

-- load pfUI environment
setfenv(1, pfUI:GetEnvironment())
--[[ libdebuff - GetUnitField Edition ]]--
-- A pfUI library that detects and saves all ongoing debuffs of players, NPCs and enemies.
--
-- MAJOR REWRITE: Now uses GetUnitField for slot mapping instead of manual shifting.
-- Key insight: GetUnitField returns STABLE aura slots (33-48) that DON'T shift when
-- debuffs expire. Only the display slots (UnitDebuff returns 1,2,3...) are compacted.
--
-- This eliminates ~400 lines of error-prone shift logic while maintaining full
-- multi-caster tracking support.
--
-- 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 (libpredict HoT tracking) and the
-- libdebuff_*_hooks broadcast surface (subscribers in actionbar / swingtimer
-- react to SPELL_GO and SPELL_FAILED).
-- 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
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
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"]["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
-- ============================================================================
-- 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, 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 = 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