Files
SuperCleveRoidMacros/Extensions/MultiTargetTracker.lua
T

2566 lines
90 KiB
Lua

--[[
Multi-Target Debuff Tracker Extension
Tracks player's debuffs across multiple enemy targets
Displays: Target indicator, raid icon, health bar with name, debuff icons with timers
Styled to match Cursive/ShaguScan UI
]]
local _G = _G or getfenv(0)
local CleveRoids = _G.CleveRoids or {}
local Extension = CleveRoids.RegisterExtension("MultiTargetTracker")
-- ============================================================================
-- Configuration
-- ============================================================================
local MAX_TRACKED_TARGETS = 15
local MAX_ICONS_PER_ROW = 8
local UPDATE_THROTTLE = 0.1 -- 10 Hz update rate
-- Layout dimensions (Cursive-style)
local CONFIG = {
-- Bar dimensions
barHeight = 20,
barSpacing = 2,
-- First section (target indicator + raid icon)
targetIndicatorSize = 8,
raidIconSize = 16,
-- Second section (health bar)
healthBarWidth = 140,
-- Third section (debuff icons)
debuffIconSize = 20,
debuffIconSpacing = 2,
-- General
padding = 2,
titleHeight = 16,
textSize = 10,
}
-- Tooltip-style backdrop (matching Cursive)
local BACKDROP_BORDER = {
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 8,
insets = { left = 2, right = 2, top = 2, bottom = 2 }
}
local BACKDROP_BACKGROUND = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
tile = true, tileSize = 16,
insets = { left = 0, right = 0, top = 0, bottom = 0 }
}
-- Colors
local COLORS = {
borderNormal = { 0.2, 0.2, 0.2, 1 },
borderHover = { 1, 1, 1, 1 },
borderCombat = { 0.8, 0.2, 0.2, 1 },
healthDefault = { 1, 0.8, 0.2, 1 }, -- Gold/yellow
timerNormal = { 1, 1, 1 },
timerExpiring = { 1, 0.2, 0.2 }, -- Red when <3s
nameText = { 1, 1, 1, 1 },
titleText = { 1, 0.82, 0, 1 }, -- Gold
}
-- ============================================================================
-- Local State
-- ============================================================================
local trackedTargets = {} -- Key: normalized GUID, Value: { guid, name, addedTime }
local trackedOrder = {} -- Array of GUIDs in order added (for FIFO removal)
local targetRows = {} -- Pool of row frames
local mainFrame = nil
local contentFrame = nil
local lastUpdate = 0
local isFrameVisible = false
local isUnlocked = false
local lib = nil -- Reference to CleveRoids.libdebuff
-- Debug flag for MultiTargetTracker
local MTT_DEBUG = false
-- Cache for enemy status
local confirmedEnemies = {}
-- Cache for player's known spells (populated on load)
local playerKnownSpells = {}
-- Build cache of player's known spell IDs by scanning spellbook
local function BuildPlayerSpellCache()
playerKnownSpells = {}
local spellIndex = 1
while true do
local spellName, spellRank = GetSpellName(spellIndex, BOOKTYPE_SPELL)
if not spellName then break end
-- Get the spell ID using SpellInfo (SuperWoW)
if SpellInfo then
local id = CleveRoids.Spells and CleveRoids.Spells[spellName]
if id then
playerKnownSpells[id] = true
end
end
spellIndex = spellIndex + 1
end
-- Also check CleveRoids.Spells directly (more reliable)
if CleveRoids.Spells then
for name, id in pairs(CleveRoids.Spells) do
playerKnownSpells[id] = true
end
end
end
-- Check if player can cast a spell (has it in spellbook)
local function PlayerCanCastSpell(spellID)
if not spellID then return false end
-- Check our cache first
if playerKnownSpells[spellID] then
return true
end
-- For spells not in cache, check if base spell name is known
-- (handles different ranks of the same spell)
if SpellInfo then
local spellName = SpellInfo(spellID)
if spellName and CleveRoids.Spells then
-- Strip rank suffix for comparison
local baseName = string.gsub(spellName, "%s*%(%s*Rank%s+%d+%s*%)", "")
for knownName, knownID in pairs(CleveRoids.Spells) do
local knownBase = string.gsub(knownName, "%s*%(%s*Rank%s+%d+%s*%)", "")
if knownBase == baseName then
return true
end
end
end
end
return false
end
-- ============================================================================
-- Player Cast Tracking (Clean Implementation)
-- ============================================================================
--
-- This tracking system uses Nampower's SPELL_CAST_EVENT which ONLY fires for
-- the local player's casts. This eliminates the GUID comparison issues between
-- Nampower's internal GUID system and SuperWoW's GUID system.
--
-- Data Flow:
-- 1. SPELL_CAST_EVENT fires → Record pending cast with target
-- 2. DEBUFF_ADDED_OTHER fires → Match against pending casts
-- 3. If match → Mark as player's debuff, add to tracker
--
-- ============================================================================
-- Pending casts waiting for debuff confirmation
-- Key: "spellID_normalizedTargetGUID", Value: { time, spellID, targetGuid, targetName }
local pendingCasts = {}
local CAST_CONFIRMATION_WINDOW = 3.0 -- Time window to match cast with debuff application
-- AoE debuff spells - these hit multiple targets, so pending cast tracking
-- uses spell-only keys instead of spell+target keys
local AOE_DEBUFF_SPELLS = {
-- Warrior - Thunder Clap
[6343] = true, -- Thunder Clap (Rank 1)
[8198] = true, -- Thunder Clap (Rank 2)
[8205] = true, -- Thunder Clap (Rank 3)
[11580] = true, -- Thunder Clap (Rank 4)
[11581] = true, -- Thunder Clap (Rank 5)
-- Warrior - Challenging Shout (AoE taunt)
[1161] = true, -- Challenging Shout
-- Druid - Demoralizing Roar (AoE debuff)
[99] = true, -- Demoralizing Roar (Rank 1)
[1735] = true, -- Demoralizing Roar (Rank 2)
[9490] = true, -- Demoralizing Roar (Rank 3)
[9747] = true, -- Demoralizing Roar (Rank 4)
[9898] = true, -- Demoralizing Roar (Rank 5)
-- Warrior - Demoralizing Shout (AoE debuff)
[1160] = true, -- Demoralizing Shout (Rank 1)
[6190] = true, -- Demoralizing Shout (Rank 2)
[11554] = true, -- Demoralizing Shout (Rank 3)
[11555] = true, -- Demoralizing Shout (Rank 4)
[11556] = true, -- Demoralizing Shout (Rank 5)
-- Warlock - Howl of Terror (AoE fear)
[5484] = true, -- Howl of Terror (Rank 1)
[17928] = true, -- Howl of Terror (Rank 2)
-- Priest - Psychic Scream (AoE fear)
[8122] = true, -- Psychic Scream (Rank 1)
[8124] = true, -- Psychic Scream (Rank 2)
[10888] = true, -- Psychic Scream (Rank 3)
[10890] = true, -- Psychic Scream (Rank 4)
}
-- Pending AoE casts - separate table for AoE spells that uses spell-only keys
-- Key: spellID, Value: { time, spellID }
local pendingAoECasts = {}
-- Excluded spells - abilities that fire SPELL_CAST_EVENT but aren't debuffs
-- These are filtered out to reduce debug spam
local EXCLUDED_SPELLS = {
-- Direct damage abilities (not debuffs)
[23881] = true, -- Bloodthirst
[23892] = true, -- Bloodthirst (higher rank)
[23893] = true, -- Bloodthirst
[23894] = true, -- Bloodthirst
[29707] = true, -- Heroic Strike (various ranks)
[11567] = true, -- Heroic Strike
[11566] = true, -- Heroic Strike
[11565] = true, -- Heroic Strike
[1608] = true, -- Heroic Strike
[285] = true, -- Heroic Strike
[284] = true, -- Heroic Strike
[78] = true, -- Heroic Strike (Rank 1)
-- Self-buffs (not enemy debuffs)
[12970] = true, -- Flurry (self-buff)
}
-- Non-stacking debuffs - don't show stack count in UI for these
local NON_STACKING_DEBUFFS = {
-- Thunderfury (both effects)
[21992] = true, -- Thunderfury main
[27648] = true, -- Thunderfury chain
-- Warrior debuffs that don't stack
[355] = true, -- Taunt
[1161] = true, -- Challenging Shout
}
-- Proc debuffs and special spells that need UNIT_CASTEVENT tracking
-- (SPELL_CAST_EVENT may not provide target GUID for these)
local PROC_DEBUFFS = {
-- Thunderfury, Blessed Blade of the Windseeker
[21992] = true, -- Thunderfury (Nature damage + resistance reduction)
[27648] = true, -- Thunderfury chain effect
-- Warrior - Thunder Clap (AoE spell - cast ID = debuff ID)
[6343] = true, -- Thunder Clap (Rank 1)
[8198] = true, -- Thunder Clap (Rank 2)
[8205] = true, -- Thunder Clap (Rank 3)
[11580] = true, -- Thunder Clap (Rank 4)
[11581] = true, -- Thunder Clap (Rank 5)
-- Warrior - Taunts (SPELL_CAST_EVENT doesn't provide target GUID)
[355] = true, -- Taunt
[1161] = true, -- Challenging Shout
[694] = true, -- Mocking Blow (Rank 1)
[7400] = true, -- Mocking Blow (Rank 2)
[7402] = true, -- Mocking Blow (Rank 3)
[20559] = true, -- Mocking Blow (Rank 4)
[20560] = true, -- Mocking Blow (Rank 5)
}
-- Duration cache from AURA_CAST_ON_OTHER events
-- Key: spellID, Value: duration in seconds
local auraCastDurations = {}
-- ============================================================================
-- Duration Lookup System
-- Priority: AURA_CAST cache → lib tables → GetSpellRec → default
-- ============================================================================
-- SpellDuration.dbc lookup table (common duration indices)
-- Format: durationIndex = baseDurationMs
local SPELL_DURATION_INDEX = {
[1] = 0, -- Instant
[3] = 10000, -- 10 seconds
[4] = 15000, -- 15 seconds
[5] = 30000, -- 30 seconds
[6] = 60000, -- 1 minute
[7] = 120000, -- 2 minutes
[8] = 180000, -- 3 minutes
[9] = 300000, -- 5 minutes
[11] = 600000, -- 10 minutes
[12] = 900000, -- 15 minutes
[21] = 6000, -- 6 seconds
[22] = 12000, -- 12 seconds
[28] = 20000, -- 20 seconds
[29] = 45000, -- 45 seconds
[35] = 8000, -- 8 seconds
[36] = 18000, -- 18 seconds
[37] = 24000, -- 24 seconds
[39] = 21000, -- 21 seconds
[85] = 9000, -- 9 seconds
[557] = 22000, -- 22 seconds (Thunder Clap R4)
[558] = 14000, -- 14 seconds (Thunder Clap R2)
}
-- Get duration for a spell using all available sources
local function GetSpellDuration(spellId)
local lib = CleveRoids.libdebuff
-- 1. Check AURA_CAST cache (most accurate, includes modifiers)
if auraCastDurations[spellId] then
return auraCastDurations[spellId]
end
-- 2. Check libdebuff tables
if lib then
if lib.personalDebuffs and lib.personalDebuffs[spellId] then
return lib.personalDebuffs[spellId]
end
if lib.sharedDebuffs and lib.sharedDebuffs[spellId] then
return lib.sharedDebuffs[spellId]
end
if lib.durations and lib.durations[spellId] then
return lib.durations[spellId]
end
end
-- 3. Try GetSpellRec from Nampower
if GetSpellRec then
local spellRec = GetSpellRec(spellId)
if spellRec and spellRec.durationIndex then
local durationMs = SPELL_DURATION_INDEX[spellRec.durationIndex]
if durationMs then
return durationMs / 1000
end
end
end
-- 4. Default fallback
return 30
end
-- Check if a spell is a known debuff (should be tracked)
local function IsKnownDebuffSpell(spellId)
-- FIRST: Check exclusion list (passive procs, abilities we don't want)
if EXCLUDED_SPELLS[spellId] then
return false
end
local lib = CleveRoids.libdebuff
-- Check our tracking tables
if PROC_DEBUFFS[spellId] then return true end
if AOE_DEBUFF_SPELLS[spellId] then return true end
-- Check libdebuff tables
if lib then
if lib.personalDebuffs and lib.personalDebuffs[spellId] then return true end
if lib.sharedDebuffs and lib.sharedDebuffs[spellId] then return true end
if lib.durations and lib.durations[spellId] then return true end
end
return false
end
-- Forward declaration for ResolveTargetName (defined after GetUnitForGUID)
local ResolveTargetName
-- Helper to create a cast key from spell and target
local function MakeCastKey(spellID, targetGuid)
local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid)
if normalizedGuid then
return tostring(spellID) .. "_" .. normalizedGuid
end
return nil
end
-- Record a player cast (called from SPELL_CAST_EVENT)
local function RecordPlayerCast(spellID, targetGuid, targetName)
if not spellID then return end
local key = MakeCastKey(spellID, targetGuid)
if key then
pendingCasts[key] = {
time = GetTime(),
spellID = spellID,
targetGuid = CleveRoids.NormalizeGUID(targetGuid),
targetName = targetName or "Unknown"
}
end
-- For AoE spells, also record in the AoE table (spell-only key)
if AOE_DEBUFF_SPELLS[spellID] then
pendingAoECasts[spellID] = {
time = GetTime(),
spellID = spellID
}
end
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellID) or "?"
local isAoE = AOE_DEBUFF_SPELLS[spellID] and " (AoE)" or ""
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ffff[MTT]|r Cast: %s on %s%s",
spellName, targetName or "?", isAoE))
end
end
-- Check if we have a pending cast for this spell+target
local function GetPendingCast(spellID, targetGuid)
-- First check target-specific pending casts
local key = MakeCastKey(spellID, targetGuid)
if key then
local pending = pendingCasts[key]
if pending and (GetTime() - pending.time) <= CAST_CONFIRMATION_WINDOW then
return pending
end
end
-- For AoE spells, also check the AoE pending cast table
if AOE_DEBUFF_SPELLS[spellID] then
local aoePending = pendingAoECasts[spellID]
if aoePending and (GetTime() - aoePending.time) <= CAST_CONFIRMATION_WINDOW then
return aoePending
end
end
return nil
end
-- Consume (remove) a pending cast after confirmation
local function ConsumePendingCast(spellID, targetGuid)
local key = MakeCastKey(spellID, targetGuid)
if key then
pendingCasts[key] = nil
end
end
-- Cleanup old pending casts
local function CleanupPendingCasts()
local now = GetTime()
local expireTime = CAST_CONFIRMATION_WINDOW * 2
-- Cleanup target-specific pending casts
for key, data in pairs(pendingCasts) do
if (now - data.time) > expireTime then
pendingCasts[key] = nil
end
end
-- Cleanup AoE pending casts
for spellID, data in pairs(pendingAoECasts) do
if (now - data.time) > expireTime then
pendingAoECasts[spellID] = nil
end
end
end
-- Helper function to check if a caster GUID matches the player
-- The caster field might be the string "player" (set by us) or the actual player GUID (from Nampower)
local function IsCasterPlayer(casterGuid)
if not casterGuid then return false end
if casterGuid == "player" then return true end
-- Compare against actual player GUID
local _, playerGuid = UnitExists("player")
if playerGuid then
return CleveRoids.GUIDsMatch(casterGuid, playerGuid)
end
return false
end
-- ============================================================================
-- Utility Functions
-- ============================================================================
local function FormatTimer(seconds)
if seconds <= 0 then
return ""
elseif seconds < 10 then
return string.format("%.1f", seconds)
elseif seconds < 60 then
return string.format("%d", seconds)
else
local mins = math.floor(seconds / 60)
local secs = math.mod(seconds, 60)
return string.format("%d:%02d", mins, secs)
end
end
local function FormatHealth(hp)
if not hp then return "?" end
if hp >= 1000000 then
return string.format("%.1fm", hp / 1000000)
elseif hp >= 1000 then
return string.format("%.1fk", hp / 1000)
else
return tostring(hp)
end
end
local function TruncateName(name, maxLen)
if not name then return "?" end
if string.len(name) <= maxLen then
return name
end
return string.sub(name, 1, maxLen - 2) .. ".."
end
local function GetTimerColor(seconds)
if seconds < 3 then
return COLORS.timerExpiring[1], COLORS.timerExpiring[2], COLORS.timerExpiring[3]
else
return COLORS.timerNormal[1], COLORS.timerNormal[2], COLORS.timerNormal[3]
end
end
-- Check if focus unit is available
local hasFocusUnit = false
if pcall(function() return UnitExists("focus") end) then
hasFocusUnit = true
end
local function CheckUnit(unit)
local exists, unitGUID = UnitExists(unit)
if exists and unitGUID then
return true, unitGUID
end
return false, nil
end
-- Calculate section widths
local function GetFirstSectionWidth()
return CONFIG.targetIndicatorSize + CONFIG.raidIconSize + CONFIG.padding
end
local function GetSecondSectionWidth()
return CONFIG.healthBarWidth + CONFIG.padding
end
local function GetThirdSectionWidth()
return MAX_ICONS_PER_ROW * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing)
end
local function GetBarWidth()
return GetFirstSectionWidth() + GetSecondSectionWidth() + GetThirdSectionWidth() + CONFIG.padding * 2
end
-- ============================================================================
-- GUID Health/Unit Lookup
-- ============================================================================
local function GetUnitForGUID(guid)
local priorityUnits = { "target", "mouseover", "targettarget", "pettarget" }
if hasFocusUnit then
table.insert(priorityUnits, "focus")
table.insert(priorityUnits, "focustarget")
end
for i = 1, table.getn(priorityUnits) do
local unit = priorityUnits[i]
local exists, unitGUID = CheckUnit(unit)
if exists and unitGUID then
if CleveRoids.NormalizeGUID(unitGUID) == guid then
return unit
end
end
end
-- Check party targets
for i = 1, 4 do
local unit = "party" .. i .. "target"
local exists, unitGUID = CheckUnit(unit)
if exists and unitGUID then
if CleveRoids.NormalizeGUID(unitGUID) == guid then
return unit
end
end
end
-- Check raid targets
for i = 1, 40 do
local unit = "raid" .. i .. "target"
local exists, unitGUID = CheckUnit(unit)
if exists and unitGUID then
if CleveRoids.NormalizeGUID(unitGUID) == guid then
return unit
end
end
end
return nil
end
-- Helper function to resolve target name from GUID using multiple methods
-- (Defined here after GetUnitForGUID is available)
ResolveTargetName = function(guid)
if not guid then return nil end
-- Method 1: Try UnitName directly with GUID (SuperWoW may support this)
local name = UnitName(guid)
if name and name ~= "" and name ~= "Unknown" then
return name
end
-- Method 2: Try to find a unit token for this GUID and use that
local unit = GetUnitForGUID(CleveRoids.NormalizeGUID(guid))
if unit then
name = UnitName(unit)
if name and name ~= "" then
return name
end
end
-- Method 3: Check libdebuff's GUID-to-name cache
if lib and lib.guidToName then
local normalizedGuid = CleveRoids.NormalizeGUID(guid)
if normalizedGuid and lib.guidToName[normalizedGuid] then
return lib.guidToName[normalizedGuid]
end
end
-- Method 4: Check our tracked targets (we might have cached the name before)
local normalizedGuid = CleveRoids.NormalizeGUID(guid)
if normalizedGuid and trackedTargets[normalizedGuid] then
return trackedTargets[normalizedGuid].name
end
return nil -- Could not resolve name
end
local function GetHealthForGUID(guid)
-- SuperWoW allows UnitHealth/UnitHealthMax to take GUIDs directly
if CleveRoids.hasSuperwow and guid then
local health = UnitHealth(guid)
local maxHealth = UnitHealthMax(guid)
if health and maxHealth and maxHealth > 0 then
return health, maxHealth, nil
end
end
-- Fallback: Check via unit lookup
local unit = GetUnitForGUID(guid)
if unit then
local health = UnitHealth(unit)
local maxHealth = UnitHealthMax(unit)
if maxHealth and maxHealth > 0 then
return health, maxHealth, unit
end
end
return nil, nil, nil
end
local function IsGUIDDead(guid)
-- SuperWoW allows UnitIsDead to take a GUID directly
if CleveRoids.hasSuperwow and guid then
local isDead = UnitIsDead(guid)
if isDead then
return true
end
end
-- Fallback: Check via unit lookup
local unit = GetUnitForGUID(guid)
if unit then
return UnitIsDead(unit)
end
-- If we can't find the unit, assume not dead (will be pruned when debuffs expire)
return false
end
local function IsEnemyGUID(guid)
if not guid then return false, nil end
-- Check if this is the player's GUID
local _, playerGuid = UnitExists("player")
if playerGuid and CleveRoids.GUIDsMatch(playerGuid, guid) then
return false, "player"
end
local unit = GetUnitForGUID(guid)
if unit then
-- Check if it's the player or a friendly unit
if UnitIsUnit(unit, "player") then
return false, unit
end
local isEnemy = UnitIsEnemy("player", unit) or UnitCanAttack("player", unit)
return isEnemy, unit
end
return nil, nil -- Unknown
end
local function GetSpellTexture(spellID)
if SpellInfo then
local name, rank, icon = SpellInfo(spellID)
return icon
end
return nil
end
-- ============================================================================
-- Target Management
-- ============================================================================
local function AddTrackedTarget(guid, name, skipEnemyCheck, rawGuid)
if not guid then return end
-- Keep raw GUID before normalizing (for API calls like UnitDebuff)
local originalGuid = rawGuid or guid
guid = CleveRoids.NormalizeGUID(guid)
if not guid then return end
-- Already tracking?
if trackedTargets[guid] then
if name then
trackedTargets[guid].name = name
end
-- Update raw GUID if provided (might have better format)
if rawGuid and rawGuid ~= trackedTargets[guid].rawGuid then
trackedTargets[guid].rawGuid = rawGuid
end
return
end
-- Enemy check
if not skipEnemyCheck then
if confirmedEnemies[guid] then
-- Already confirmed
else
local isEnemy, unit = IsEnemyGUID(guid)
if isEnemy == false then
return
elseif isEnemy == true then
confirmedEnemies[guid] = true
else
-- Assume enemy when casting debuffs
confirmedEnemies[guid] = true
end
end
end
-- Enforce max limit (FIFO)
while table.getn(trackedOrder) >= MAX_TRACKED_TARGETS do
local oldestGUID = trackedOrder[1]
table.remove(trackedOrder, 1)
trackedTargets[oldestGUID] = nil
end
-- Add new target
trackedTargets[guid] = {
guid = guid,
rawGuid = originalGuid, -- Store raw GUID for API calls like UnitDebuff
name = name or (lib and lib.guidToName and lib.guidToName[guid]) or "Unknown",
addedTime = GetTime(),
}
table.insert(trackedOrder, guid)
-- Show frame
if mainFrame and not isFrameVisible then
mainFrame:Show()
isFrameVisible = true
end
end
local function RemoveTrackedTarget(guid)
if not guid then return end
trackedTargets[guid] = nil
confirmedEnemies[guid] = nil
for i = table.getn(trackedOrder), 1, -1 do
if trackedOrder[i] == guid then
table.remove(trackedOrder, i)
break
end
end
if table.getn(trackedOrder) == 0 and mainFrame and isFrameVisible and not isUnlocked then
mainFrame:Hide()
isFrameVisible = false
end
end
local function GetActivePlayerDebuffs(guid)
if not lib or not lib.objects or not lib.objects[guid] then
return nil, 0
end
local debuffs = {}
local count = 0
local now = GetTime()
for spellID, rec in pairs(lib.objects[guid]) do
-- FIRST: Only show debuffs we actually want to track
-- This filters out abilities like Bloodthirst, Master Strike, Deep Wounds (talent proc)
if not IsKnownDebuffSpell(spellID) then
-- Skip unknown spells entirely
else
-- Check if this debuff should be shown as player's
local isPlayerDebuff = false
if IsCasterPlayer(rec.caster) then
-- Explicitly marked as player
isPlayerDebuff = true
elseif PROC_DEBUFFS[spellID] then
-- Proc debuffs (items/talents) are always from the player
isPlayerDebuff = true
elseif lib.sharedDebuffs and lib.sharedDebuffs[spellID] then
-- For shared debuffs, include if caster is player OR we have a pending cast
if IsCasterPlayer(rec.caster) then
isPlayerDebuff = true
elseif GetPendingCast(spellID, guid) then
isPlayerDebuff = true
elseif rec.caster == nil and PlayerCanCastSpell(spellID) then
-- Shared debuff with no caster - only claim if player can cast this spell
-- This prevents showing other players' Sunder Armor on a Druid, etc.
isPlayerDebuff = true
end
end
if isPlayerDebuff then
if rec.start and rec.duration then
local remaining = rec.duration + rec.start - now
if remaining > 0 then
count = count + 1
debuffs[count] = {
spellID = spellID,
remaining = remaining,
duration = rec.duration,
stacks = rec.stacks or 0,
}
end
end
end
end -- end IsKnownDebuffSpell check
end
return debuffs, count
end
local function IsGUIDInCombat(guid)
-- SuperWoW allows UnitAffectingCombat to take a GUID directly
if CleveRoids.hasSuperwow and guid then
local inCombat = UnitAffectingCombat(guid)
if inCombat then
return true
end
end
-- Fallback: Check via unit lookup
local unit = GetUnitForGUID(guid)
if unit then
return UnitAffectingCombat(unit)
end
return false
end
local function CheckAndPruneTargets()
local toRemove = {}
local playerOutOfCombat = not UnitAffectingCombat("player")
for guid, info in pairs(trackedTargets) do
-- Check if dead - always remove dead targets
if IsGUIDDead(guid) then
table.insert(toRemove, guid)
else
-- Check if no active debuffs
local debuffs, count = GetActivePlayerDebuffs(guid)
if count == 0 then
-- Remove if target out of combat OR player out of combat
if not IsGUIDInCombat(guid) or playerOutOfCombat then
table.insert(toRemove, guid)
end
end
end
end
for i = 1, table.getn(toRemove) do
RemoveTrackedTarget(toRemove[i])
end
end
local function ClearAllTargets()
trackedTargets = {}
trackedOrder = {}
confirmedEnemies = {}
if mainFrame and isFrameVisible then
mainFrame:Hide()
isFrameVisible = false
end
CleveRoids.Print("Multi-Target Tracker: Cleared all targets")
end
-- Forward declarations
local ScanCombatTargets
local ScanAllTrackedDebuffs
-- ============================================================================
-- UI Creation (Cursive-style)
-- ============================================================================
local function CreateDebuffIcon(parent, index)
local icon = CreateFrame("Frame", nil, parent)
icon:SetWidth(CONFIG.debuffIconSize)
icon:SetHeight(CONFIG.debuffIconSize)
-- Icon texture
icon.texture = icon:CreateTexture(nil, "ARTWORK")
icon.texture:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1)
icon.texture:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1)
icon.texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)
-- Timer text (centered on icon like Cursive)
icon.timer = icon:CreateFontString(nil, "OVERLAY", "GameFontNormal")
icon.timer:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "OUTLINE")
icon.timer:SetTextColor(1, 1, 1)
icon.timer:SetAllPoints(icon)
-- Stack count (bottom right)
icon.stacks = icon:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
icon.stacks:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1)
icon.stacks:SetFont(STANDARD_TEXT_FONT, 9, "OUTLINE")
icon.stacks:SetTextColor(1, 1, 1, 1)
icon:Hide()
return icon
end
local function CreateTargetRow(parent, index)
local barWidth = GetBarWidth()
local row = CreateFrame("Frame", nil, parent)
row:SetWidth(barWidth)
row:SetHeight(CONFIG.barHeight)
row.hover = false
-- ========================================
-- First Section: Target Indicator + Raid Icon
-- ========================================
local firstSection = CreateFrame("Frame", nil, row)
firstSection:SetPoint("LEFT", row, "LEFT", 0, 0)
firstSection:SetWidth(GetFirstSectionWidth())
firstSection:SetHeight(CONFIG.barHeight)
row.firstSection = firstSection
-- Target indicator arrow (shows when this unit is targeted)
local targetIndicator = firstSection:CreateTexture(nil, "OVERLAY")
targetIndicator:SetWidth(CONFIG.targetIndicatorSize)
targetIndicator:SetHeight(CONFIG.targetIndicatorSize)
targetIndicator:SetPoint("LEFT", firstSection, "LEFT", 0, 0)
-- Use play button triangle as arrow pointing right (towards the target)
targetIndicator:SetTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up")
targetIndicator:SetVertexColor(1, 1, 0, 1) -- Yellow for visibility
targetIndicator:Hide()
row.targetIndicator = targetIndicator
-- Raid icon
local raidIcon = firstSection:CreateTexture(nil, "OVERLAY")
raidIcon:SetWidth(CONFIG.raidIconSize)
raidIcon:SetHeight(CONFIG.raidIconSize)
raidIcon:SetPoint("RIGHT", firstSection, "RIGHT", 0, 0)
raidIcon:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons")
raidIcon:Hide()
row.raidIcon = raidIcon
-- ========================================
-- Second Section: Health Bar (clickable)
-- ========================================
local secondSection = CreateFrame("Button", nil, row)
secondSection:SetPoint("LEFT", firstSection, "RIGHT", 0, 0)
secondSection:SetWidth(GetSecondSectionWidth())
secondSection:SetHeight(CONFIG.barHeight)
secondSection.parent = row
row.secondSection = secondSection
-- Health bar with backdrop
local healthBar = CreateFrame("StatusBar", nil, secondSection)
healthBar:SetPoint("LEFT", secondSection, "LEFT", CONFIG.padding, 0)
healthBar:SetWidth(CONFIG.healthBarWidth)
healthBar:SetHeight(CONFIG.barHeight - 4)
healthBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
healthBar:SetStatusBarColor(COLORS.healthDefault[1], COLORS.healthDefault[2], COLORS.healthDefault[3], 1)
healthBar:SetMinMaxValues(0, 100)
healthBar:SetValue(100)
row.healthBar = healthBar
-- Health bar background
healthBar:SetBackdrop(BACKDROP_BACKGROUND)
healthBar:SetBackdropColor(0, 0, 0, 0.8)
-- Health bar border
local healthBorder = CreateFrame("Frame", nil, healthBar)
healthBorder:SetBackdrop(BACKDROP_BORDER)
healthBorder:SetBackdropBorderColor(COLORS.borderNormal[1], COLORS.borderNormal[2], COLORS.borderNormal[3], 1)
healthBorder:SetPoint("TOPLEFT", healthBar, "TOPLEFT", -2, 2)
healthBorder:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", 2, -2)
row.healthBorder = healthBorder
-- Name text (on health bar)
local nameText = healthBar:CreateFontString(nil, "OVERLAY", "GameFontWhite")
nameText:SetPoint("TOPLEFT", healthBar, "TOPLEFT", 2, -2)
nameText:SetWidth(CONFIG.healthBarWidth - 35)
nameText:SetHeight(CONFIG.barHeight - 4)
nameText:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "THINOUTLINE")
nameText:SetJustifyH("LEFT")
nameText:SetTextColor(COLORS.nameText[1], COLORS.nameText[2], COLORS.nameText[3], 1)
row.nameText = nameText
-- HP text (right side of health bar)
local hpText = healthBar:CreateFontString(nil, "OVERLAY", "GameFontWhite")
hpText:SetPoint("TOPRIGHT", healthBar, "TOPRIGHT", -2, -2)
hpText:SetWidth(30)
hpText:SetHeight(CONFIG.barHeight - 4)
hpText:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "THINOUTLINE")
hpText:SetJustifyH("RIGHT")
row.hpText = hpText
-- Click handlers for targeting
secondSection:RegisterForClicks("LeftButtonUp", "RightButtonUp")
secondSection:SetScript("OnClick", function()
if this.parent.guid then
if arg1 == "LeftButton" then
TargetUnit(this.parent.guid)
elseif arg1 == "RightButton" then
RemoveTrackedTarget(this.parent.guid)
end
end
end)
-- Hover effects (white border on hover like Cursive)
secondSection:SetScript("OnEnter", function()
this.parent.hover = true
if this.parent.healthBorder then
this.parent.healthBorder:SetBackdropBorderColor(COLORS.borderHover[1], COLORS.borderHover[2], COLORS.borderHover[3], 1)
end
-- Show tooltip
if this.parent.guid then
GameTooltip_SetDefaultAnchor(GameTooltip, this)
GameTooltip:SetUnit(this.parent.guid)
GameTooltip:Show()
end
end)
secondSection:SetScript("OnLeave", function()
this.parent.hover = false
GameTooltip:Hide()
end)
-- ========================================
-- Third Section: Debuff Icons
-- ========================================
local thirdSection = CreateFrame("Frame", nil, row)
thirdSection:SetPoint("LEFT", secondSection, "RIGHT", 0, 0)
thirdSection:SetWidth(GetThirdSectionWidth())
thirdSection:SetHeight(CONFIG.barHeight)
row.thirdSection = thirdSection
-- Create debuff icon pool
row.icons = {}
for i = 1, MAX_ICONS_PER_ROW do
row.icons[i] = CreateDebuffIcon(thirdSection, i)
local xOffset = (i - 1) * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing)
row.icons[i]:SetPoint("LEFT", thirdSection, "LEFT", xOffset, 0)
end
row:Hide()
return row
end
local function CreateMainFrame()
local barWidth = GetBarWidth()
local frame = CreateFrame("Frame", "CleveRoidsMultiTargetFrame", UIParent)
frame:SetWidth(barWidth + 8)
frame:SetHeight(CONFIG.titleHeight + 50)
frame:SetPoint("CENTER", UIParent, "CENTER", 200, 100)
frame:SetFrameStrata("MEDIUM")
frame:SetMovable(true)
frame:EnableMouse(true)
frame:SetClampedToScreen(true)
-- No background - transparent frame
frame:SetBackdrop(nil)
-- Title bar
local titleBar = CreateFrame("Frame", nil, frame)
titleBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2)
titleBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -2, -2)
titleBar:SetHeight(CONFIG.titleHeight)
titleBar:EnableMouse(true)
frame.titleBar = titleBar
-- Title text
local titleText = titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
titleText:SetPoint("LEFT", titleBar, "LEFT", 4, 0)
titleText:SetFont(STANDARD_TEXT_FONT, 11, "THINOUTLINE")
titleText:SetText("Debuff Tracker")
titleText:SetTextColor(COLORS.titleText[1], COLORS.titleText[2], COLORS.titleText[3], 1)
frame.titleText = titleText
-- Close button
local closeBtn = CreateFrame("Button", nil, titleBar, "UIPanelCloseButton")
closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", 2, 0)
closeBtn:SetWidth(18)
closeBtn:SetHeight(18)
closeBtn:SetScript("OnClick", function()
if mainFrame then
mainFrame:Hide()
isFrameVisible = false
end
end)
-- Dragging
titleBar:SetScript("OnMouseDown", function()
if arg1 == "LeftButton" then
frame:StartMoving()
end
end)
titleBar:SetScript("OnMouseUp", function()
frame:StopMovingOrSizing()
SaveFramePosition()
end)
-- Content frame for rows
contentFrame = CreateFrame("Frame", nil, frame)
contentFrame:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 2, -CONFIG.padding)
contentFrame:SetPoint("TOPRIGHT", titleBar, "BOTTOMRIGHT", -2, -CONFIG.padding)
contentFrame:SetHeight(1)
-- Create row pool
for i = 1, MAX_TRACKED_TARGETS do
targetRows[i] = CreateTargetRow(contentFrame, i)
targetRows[i]:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 0, -((i - 1) * (CONFIG.barHeight + CONFIG.barSpacing)))
end
-- OnUpdate handler
frame:SetScript("OnUpdate", function()
local now = GetTime()
if now - lastUpdate < UPDATE_THROTTLE then
return
end
lastUpdate = now
-- Scan for combat targets
ScanCombatTargets()
-- Scan debuffs directly from game (catches missed events)
ScanAllTrackedDebuffs()
if table.getn(trackedOrder) == 0 and not isUnlocked then
return
end
UpdateFrame()
end)
frame:Hide()
return frame
end
-- ============================================================================
-- UI Update Functions
-- ============================================================================
local function UpdateDebuffIcons(row, guid)
local debuffs, count = GetActivePlayerDebuffs(guid)
-- Hide all icons first
for i = 1, MAX_ICONS_PER_ROW do
if row.icons[i] then
row.icons[i]:Hide()
row.icons[i].timer:Hide()
end
end
if not debuffs or count == 0 then
return
end
-- Sort by remaining time (shortest first)
table.sort(debuffs, function(a, b)
return a.remaining < b.remaining
end)
-- Show active debuffs
for i = 1, math.min(count, MAX_ICONS_PER_ROW) do
local debuff = debuffs[i]
local icon = row.icons[i]
if icon and debuff then
local texture = GetSpellTexture(debuff.spellID)
if texture then
icon.texture:SetTexture(texture)
else
icon.texture:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
end
-- Timer text (Cursive style - centered)
local timerText = FormatTimer(debuff.remaining)
icon.timer:SetText(timerText)
local r, g, b = GetTimerColor(debuff.remaining)
icon.timer:SetTextColor(r, g, b, 1)
icon.timer:Show()
-- Stack count (only show if stacks > 1 AND not a known non-stacking debuff)
local showStacks = debuff.stacks and debuff.stacks > 1 and not NON_STACKING_DEBUFFS[debuff.spellID]
if showStacks then
icon.stacks:SetText(tostring(debuff.stacks))
icon.stacks:Show()
else
icon.stacks:SetText("")
icon.stacks:Hide()
end
icon:Show()
end
end
end
local function UpdateRow(row, guid, info)
if not row or not guid or not info then
row:Hide()
return
end
row.guid = guid
-- Get unit for this GUID (may be nil if not currently visible as a unit)
local unit = GetUnitForGUID(guid)
-- For SuperWoW, we can use GUID directly for many functions
local unitOrGuid = unit or (CleveRoids.hasSuperwow and guid) or nil
-- Update target indicator (shows if this is player's target)
local isCurrentTarget = false
if CleveRoids.hasSuperwow then
-- Check if this GUID matches current target's GUID
local _, targetGUID = UnitExists("target")
if targetGUID then
isCurrentTarget = (CleveRoids.NormalizeGUID(targetGUID) == guid)
end
elseif unit then
isCurrentTarget = UnitIsUnit("target", unit)
end
if isCurrentTarget then
row.targetIndicator:Show()
else
row.targetIndicator:Hide()
end
-- Update raid icon
local raidIndex = nil
if unitOrGuid then
raidIndex = GetRaidTargetIndex(unitOrGuid)
end
if raidIndex then
SetRaidTargetIconTexture(row.raidIcon, raidIndex)
row.raidIcon:Show()
else
row.raidIcon:Hide()
end
-- Update name
local displayName = TruncateName(info.name, 14)
row.nameText:SetText(displayName)
-- Update health (SuperWoW can use GUID directly)
local health, maxHealth = GetHealthForGUID(guid)
if health and maxHealth and maxHealth > 0 then
local percent = (health / maxHealth) * 100
row.healthBar:SetValue(percent)
row.hpText:SetText(FormatHealth(health))
-- Color based on unit type (like Cursive)
local r, g, b = 1, 0.8, 0.2 -- Default gold
if unitOrGuid then
if UnitIsPlayer(unitOrGuid) then
local _, class = UnitClass(unitOrGuid)
if class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then
r = RAID_CLASS_COLORS[class].r
g = RAID_CLASS_COLORS[class].g
b = RAID_CLASS_COLORS[class].b
end
end
end
row.healthBar:SetStatusBarColor(r, g, b, 1)
else
row.healthBar:SetValue(100)
row.hpText:SetText("?")
row.healthBar:SetStatusBarColor(0.4, 0.4, 0.4, 1)
end
-- Update border color based on hover/combat state (Cursive style)
if not row.hover then
local inCombat = false
if unitOrGuid then
inCombat = UnitAffectingCombat(unitOrGuid)
end
if inCombat then
row.healthBorder:SetBackdropBorderColor(COLORS.borderCombat[1], COLORS.borderCombat[2], COLORS.borderCombat[3], 1)
else
row.healthBorder:SetBackdropBorderColor(COLORS.borderNormal[1], COLORS.borderNormal[2], COLORS.borderNormal[3], 1)
end
end
-- Update debuff icons
UpdateDebuffIcons(row, guid)
row:Show()
end
function UpdateFrame()
-- First, prune dead targets and those with no debuffs
CheckAndPruneTargets()
local numTargets = table.getn(trackedOrder)
if numTargets == 0 then
if isUnlocked then
-- Show placeholder for positioning
local row = targetRows[1]
if row then
row.nameText:SetText("(Unlocked)")
row.healthBar:SetValue(100)
row.healthBar:SetStatusBarColor(0.3, 0.3, 0.3)
row.hpText:SetText("Move")
row.targetIndicator:Hide()
row.raidIcon:Hide()
for i = 1, MAX_ICONS_PER_ROW do
if row.icons[i] then row.icons[i]:Hide() end
end
row.guid = nil
row:Show()
end
for i = 2, MAX_TRACKED_TARGETS do
targetRows[i]:Hide()
end
local contentHeight = CONFIG.barHeight + CONFIG.barSpacing
local totalHeight = CONFIG.titleHeight + contentHeight + 8
if mainFrame then
mainFrame:SetHeight(totalHeight)
contentFrame:SetHeight(contentHeight)
if not isFrameVisible then
mainFrame:Show()
isFrameVisible = true
end
end
return
end
if mainFrame and isFrameVisible then
mainFrame:Hide()
isFrameVisible = false
end
return
end
-- Update each row
for i = 1, MAX_TRACKED_TARGETS do
local row = targetRows[i]
if i <= numTargets then
local guid = trackedOrder[i]
local info = trackedTargets[guid]
UpdateRow(row, guid, info)
else
row:Hide()
end
end
-- Adjust frame height
local contentHeight = numTargets * (CONFIG.barHeight + CONFIG.barSpacing)
local totalHeight = CONFIG.titleHeight + contentHeight + 8
if mainFrame then
mainFrame:SetHeight(totalHeight)
contentFrame:SetHeight(contentHeight)
end
end
-- ============================================================================
-- Frame Position Persistence
-- ============================================================================
function SaveFramePosition()
if not mainFrame then return end
local point, relativeTo, relativePoint, xOfs, yOfs = mainFrame:GetPoint()
CleveRoidMacros = CleveRoidMacros or {}
CleveRoidMacros.multiTargetPos = {
point = point,
relativePoint = relativePoint,
x = xOfs,
y = yOfs,
}
end
local function RestoreFramePosition()
if not mainFrame then return end
CleveRoidMacros = CleveRoidMacros or {}
local pos = CleveRoidMacros.multiTargetPos
if pos and pos.point then
mainFrame:ClearAllPoints()
mainFrame:SetPoint(pos.point, UIParent, pos.relativePoint or pos.point, pos.x or 0, pos.y or 0)
end
end
-- ============================================================================
-- lib:AddEffect Hook
-- ============================================================================
local function IsPlayerAppliedDebuff(spellID, caster)
-- Explicitly marked as player
if IsCasterPlayer(caster) then
return true
end
-- Proc debuffs (items/talents) are always from the player
if PROC_DEBUFFS[spellID] then
return true
end
-- For shared debuffs, we rely on the caster field being set to "player"
-- The pending cast system handles this in DEBUFF_ADDED_OTHER
return false
end
local function HookAddEffect()
if not lib or not lib.AddEffect then
CleveRoids.Print("MultiTargetTracker: Could not hook libdebuff.AddEffect")
return
end
local originalAddEffect = lib.AddEffect
lib.AddEffect = function(self, guid, unitName, spellID, duration, stacks, caster)
-- Call original first to ensure the record exists
originalAddEffect(self, guid, unitName, spellID, duration, stacks, caster)
-- Normalize GUID for our lookups
local normalizedGuid = CleveRoids.NormalizeGUID(guid)
-- Check if we have a pending cast for this spell+target
local pending = GetPendingCast(spellID, guid)
if pending then
-- This is our debuff - force set caster and refresh timer
if normalizedGuid and lib.objects[normalizedGuid] and lib.objects[normalizedGuid][spellID] then
lib.objects[normalizedGuid][spellID].start = GetTime()
lib.objects[normalizedGuid][spellID].duration = duration or lib.objects[normalizedGuid][spellID].duration
lib.objects[normalizedGuid][spellID].caster = "player"
end
AddTrackedTarget(guid, unitName, false, guid) -- guid is raw here
elseif IsCasterPlayer(caster) then
-- Explicitly marked as player's debuff
AddTrackedTarget(guid, unitName, false, guid) -- guid is raw here
end
end
end
-- ============================================================================
-- SPELL_CAST_EVENT Handler (Nampower) - PRIMARY
-- From wiki: "This will only fire for spells you (and certain pets) initiated"
-- ============================================================================
local function SetupSpellCastEventTracking()
if not CleveRoids.hasNampower then
return
end
local frame = CreateFrame("Frame", "CleveRoidsMTTSpellCastFrame", UIParent)
frame:RegisterEvent("SPELL_CAST_EVENT")
frame:SetScript("OnEvent", function()
-- SPELL_CAST_EVENT args (from Nampower wiki):
-- arg1 = success (1 if cast succeeded, 0 if failed)
-- arg2 = spellId
-- arg3 = castType (1=NORMAL, 2=NON_GCD, 3=ON_SWING, 4=CHANNEL, 5=TARGETING, 6=TARGETING_NON_GCD)
-- arg4 = targetGuid (string like "0xF5300000000000A5", or "0x000000000" if no explicit target)
-- arg5 = itemId (0 if not triggered by item)
local success = arg1
local spellId = arg2
local targetGuid = arg4
-- Only track successful casts
if success ~= 1 or not spellId then return end
-- Only track known debuff spells (filter out attacks like Bloodthirst, Heroic Strike)
if not IsKnownDebuffSpell(spellId) then
return
end
-- For AoE spells, targetGuid might be "0x000000000" or the current target
-- We still want to record the cast for DEBUFF_ADDED_OTHER matching
local isAoE = AOE_DEBUFF_SPELLS[spellId]
-- Skip non-AoE spells with no valid target
if not isAoE and (not targetGuid or targetGuid == "0x000000000") then
return
end
-- Get target name using multiple resolution methods
local targetName = nil
if targetGuid and targetGuid ~= "0x000000000" then
targetName = ResolveTargetName(targetGuid)
end
-- Record this cast for matching with DEBUFF_ADDED_OTHER
RecordPlayerCast(spellId, targetGuid, targetName)
-- Refresh existing debuff timers
-- DEBUFF_ADDED_OTHER only fires on new applications, not refreshes
local libdebuff = CleveRoids.libdebuff
if libdebuff and libdebuff.objects then
local duration = GetSpellDuration(spellId)
if isAoE then
-- AoE spell: refresh ALL tracked targets with this debuff
local refreshCount = 0
for guid, debuffs in pairs(libdebuff.objects) do
-- Try both number and string keys (Lua distinguishes them)
local rec = debuffs[spellId] or debuffs[tostring(spellId)]
if rec then
-- For AoE refreshes, we trust the pending cast system
-- If we just cast this AoE spell, refresh all instances of this debuff
-- The caster field may not be set yet for new targets hit by this AoE
local shouldRefresh = IsCasterPlayer(rec.caster) or pendingAoECasts[spellId]
if shouldRefresh then
rec.start = GetTime()
rec.duration = duration
rec.caster = "player" -- Ensure caster is set for future refreshes
refreshCount = refreshCount + 1
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
local name = UnitName(guid) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff88ffff[MTT]|r AoE refresh: %s on %s (dur:%.1f)",
spellName, name, duration))
end
end
end
end
else
-- Single-target spell: refresh only the targeted enemy
local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid)
if normalizedGuid and libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] then
libdebuff.objects[normalizedGuid][spellId].start = GetTime()
libdebuff.objects[normalizedGuid][spellId].duration = duration
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff88ffff[MTT]|r Debuff refresh: %s on %s (dur:%.1f)",
spellName, targetName or "?", duration))
end
end
end
end
end)
-- Periodic cleanup of old pending casts
frame:SetScript("OnUpdate", function()
this.elapsed = (this.elapsed or 0) + arg1
if this.elapsed > 1 then
this.elapsed = 0
CleanupPendingCasts()
end
end)
end
-- ============================================================================
-- Direct Debuff Scanning (SuperWoW)
-- Scans target debuffs directly to catch debuffs missed by events
-- ============================================================================
local DEBUFF_SCAN_INTERVAL = 0.5 -- Scan every 0.5 seconds
local lastDebuffScan = 0
-- Scan a target's debuffs and update libdebuff.objects
-- Also removes debuffs that are no longer present (overwritten, dispelled, etc.)
-- @param normalizedGuid - Normalized GUID for lib.objects lookup
-- @param rawGuid - Raw GUID for API calls (UnitDebuff, etc.)
local function ScanTargetDebuffs(normalizedGuid, rawGuid)
if not CleveRoids.hasSuperwow then return end
local lib = CleveRoids.libdebuff
if not lib then return end
-- Use raw GUID for API calls, normalized for table lookups
local apiGuid = rawGuid or normalizedGuid
normalizedGuid = CleveRoids.NormalizeGUID(normalizedGuid)
if not normalizedGuid then return end
local now = GetTime()
-- Build a set of spell IDs currently on the target
local presentDebuffs = {}
-- SuperWoW supports UnitDebuff with raw GUID directly
-- Scan debuff slots 1-16 (regular) and 17-48 (overflow in buff slots)
local scannedAny = false
for slot = 1, 48 do
-- SuperWoW UnitDebuff(guid, slot) returns: texture, stacks, debuffType, spellID
-- NOTE: Does NOT return duration, timeLeft, or caster - only presence info
local texture, stacks, debuffType, spellId = UnitDebuff(apiGuid, slot)
-- Stop at nil for slots 1-16 (dense), continue for 17-48 (sparse overflow)
if not texture then
if slot <= 16 then
break
end
elseif spellId then
-- Record that this debuff is present on target
presentDebuffs[spellId] = true
scannedAny = true
end
end
-- Only do removal detection if the scan actually returned data
-- If UnitDebuff returned nothing, the GUID might be inaccessible (out of range, etc.)
if not scannedAny then
-- Check if there are ANY debuffs on this target in general
-- If the target should have debuffs (from our tracking) but scan found none,
-- it's likely a GUID access issue, not actual removal
if lib.objects[normalizedGuid] then
local hasTracked = false
for spellId, rec in pairs(lib.objects[normalizedGuid]) do
if rec.caster == "player" then
hasTracked = true
break
end
end
if hasTracked then
-- We're tracking debuffs but scan found nothing - skip removal detection
-- Don't spam debug - this is normal for GUIDs we can't access
return
end
end
end
-- Check for debuffs we're tracking that are no longer on the target
-- (overwritten by stronger effects, dispelled, etc.)
if lib.objects[normalizedGuid] then
local toRemove = {}
for spellId, rec in pairs(lib.objects[normalizedGuid]) do
-- Only check debuffs we applied (caster = player)
if rec.caster == "player" and IsKnownDebuffSpell(spellId) then
-- If debuff is not present on target anymore, mark for removal
if not presentDebuffs[spellId] then
-- Double-check it's not just expired naturally
local remaining = (rec.start + rec.duration) - now
if remaining > 0.5 then
-- Debuff should still be active but isn't on target - it was removed!
table.insert(toRemove, spellId)
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
local targetName = UnitName(apiGuid) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cffff8800[MTT]|r Removed: %s on %s (overwritten/dispelled, %.1fs left)",
spellName, targetName, remaining))
end
end
end
end
end
-- Remove the debuffs that are no longer present
for _, spellId in ipairs(toRemove) do
lib.objects[normalizedGuid][spellId] = nil
end
end
end
-- Scan tracked targets for debuffs
-- SuperWoW supports UnitDebuff with GUIDs directly
ScanAllTrackedDebuffs = function()
if not CleveRoids.hasSuperwow then return end
local now = GetTime()
if now - lastDebuffScan < DEBUFF_SCAN_INTERVAL then
return
end
lastDebuffScan = now
-- Scan current target first (most reliable) - use raw GUID
if UnitExists("target") then
local _, targetGuid = UnitExists("target")
if targetGuid then
ScanTargetDebuffs(targetGuid, targetGuid) -- raw GUID for API calls
end
end
-- Also scan other tracked targets (SuperWoW supports GUID access)
for normalizedGuid, info in pairs(trackedTargets) do
-- Use stored raw GUID for API calls
local rawGuid = info.rawGuid or normalizedGuid
ScanTargetDebuffs(normalizedGuid, rawGuid)
end
end
-- ============================================================================
-- UNIT_CASTEVENT Handler (SuperWoW) - BACKUP
-- Used when Nampower is not available, or as additional tracking
-- ============================================================================
local function SetupUnitCastEventTracking()
if not CleveRoids.hasSuperwow then
return
end
local frame = CreateFrame("Frame", "CleveRoidsMTTUnitCastFrame", UIParent)
frame:RegisterEvent("UNIT_CASTEVENT")
frame:SetScript("OnEvent", function()
-- UNIT_CASTEVENT args (from SuperWoW wiki):
-- arg1 = casterGUID
-- arg2 = targetGUID
-- arg3 = event type ("START", "CAST", "FAIL", "CHANNEL", "MAINHAND", "OFFHAND")
-- arg4 = spell id
-- arg5 = cast duration
local casterGuid = arg1
local targetGuid = arg2
local eventType = arg3
local spellId = arg4
-- Check if caster is player by comparing GUIDs (SuperWoW GUIDs are consistent)
local _, playerGuid = UnitExists("player")
if not playerGuid or casterGuid ~= playerGuid then
return
end
-- Only track cast starts and instant casts
if eventType ~= "START" and eventType ~= "CAST" then
return
end
if not spellId or not targetGuid then return end
-- Skip empty GUIDs (can happen for some AoE spells)
if targetGuid == "" or targetGuid == "0x0" or targetGuid == "0x000000000" then
return
end
-- Skip self-targeted spells (buffs like Flurry go on player, not enemies)
if targetGuid == playerGuid then
return
end
-- Get target name using multiple resolution methods
local targetName = ResolveTargetName(targetGuid)
-- Skip if we can't resolve the target name (avoid "Unknown" entries)
if not targetName then return end
-- Record this cast (backup to SPELL_CAST_EVENT)
RecordPlayerCast(spellId, targetGuid, targetName)
end)
end
-- ============================================================================
-- AURA_CAST_ON_OTHER Handler (Nampower) - Duration data + caster verification
-- Requires CVar NP_EnableAuraCastEvents=1
-- ============================================================================
local function SetupAuraCastTracking()
if not CleveRoids.hasNampower then
return
end
-- Check if AURA_CAST events are enabled (they're off by default!)
local auraCastEnabled = GetCVar and GetCVar("NP_EnableAuraCastEvents")
if auraCastEnabled ~= "1" then
DEFAULT_CHAT_FRAME:AddMessage("|cffff8800[MTT]|r Warning: AURA_CAST events disabled. Run: /run SetCVar('NP_EnableAuraCastEvents','1')")
end
local frame = CreateFrame("Frame", "CleveRoidsMTTAuraCastFrame", UIParent)
frame:RegisterEvent("AURA_CAST_ON_OTHER")
frame:SetScript("OnEvent", function()
-- AURA_CAST_ON_OTHER args (from Nampower wiki):
-- arg1 = spellId
-- arg2 = casterGuid
-- arg3 = targetGuid
-- arg4 = effect
-- arg5 = effectAuraName
-- arg6 = effectAmplitude
-- arg7 = effectMiscValue
-- arg8 = durationMs (duration in milliseconds!)
-- arg9 = auraCapStatus
local spellId = arg1
local casterGuid = arg2
local targetGuid = arg3
local durationMs = arg8
if not spellId or not targetGuid then return end
-- Safety check: skip if target is the player (self-buff)
-- This prevents tracking self-buffs like Lightning Shield on enemies
local _, playerGuid = UnitExists("player")
if playerGuid and CleveRoids.GUIDsMatch(playerGuid, targetGuid) then
return -- Self-targeted aura, skip
end
-- Cache duration from AURA_CAST_ON_OTHER (most accurate, includes client modifiers)
if durationMs and durationMs > 0 and IsCasterPlayer(casterGuid) then
auraCastDurations[spellId] = durationMs / 1000
end
-- Check pending casts for NEW debuff applications
local pending = GetPendingCast(spellId, targetGuid)
-- Also check if this is a REFRESH of an existing debuff we're already tracking
local libdebuff = CleveRoids.libdebuff
local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid)
local isRefresh = false
if libdebuff and normalizedGuid then
local existingDebuff = libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId]
if existingDebuff and IsCasterPlayer(existingDebuff.caster) then
-- This is a refresh of our existing debuff!
isRefresh = true
end
end
-- For AoE spells, also treat as refresh if we have an AoE pending cast AND
-- the target already has this debuff from us (tracked in our system)
if not isRefresh and AOE_DEBUFF_SPELLS[spellId] and pending then
-- AoE spell with pending cast - check if target is already tracked with this debuff
if libdebuff and normalizedGuid and libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] then
isRefresh = true
end
end
if not pending and not isRefresh then
-- Not our debuff (no pending cast and not a refresh of existing tracked debuff)
return
end
-- libdebuff and normalizedGuid already set above
if not libdebuff or not normalizedGuid then return end
-- Get duration in seconds (from milliseconds) - key value from this event!
local duration = durationMs and (durationMs / 1000) or 0
-- Fallback to unified duration lookup
if duration <= 0 then
duration = GetSpellDuration(spellId)
end
-- Get target name using multiple resolution methods
local targetName = ResolveTargetName(targetGuid)
-- Skip if we can't resolve the target name (avoid "Unknown" entries)
if not targetName then return end
-- Update debuff record with accurate duration from DBC
libdebuff.objects[normalizedGuid] = libdebuff.objects[normalizedGuid] or {}
local hasExisting = libdebuff.objects[normalizedGuid][spellId]
libdebuff.objects[normalizedGuid][spellId] = libdebuff.objects[normalizedGuid][spellId] or {}
libdebuff.objects[normalizedGuid][spellId].start = GetTime()
libdebuff.objects[normalizedGuid][spellId].duration = duration
libdebuff.objects[normalizedGuid][spellId].caster = "player"
-- Force stacks to 1 for non-stacking debuffs
if NON_STACKING_DEBUFFS[spellId] then
libdebuff.objects[normalizedGuid][spellId].stacks = 1
else
libdebuff.objects[normalizedGuid][spellId].stacks = libdebuff.objects[normalizedGuid][spellId].stacks or 1
end
-- Add target to tracker (pass raw targetGuid for API calls)
AddTrackedTarget(normalizedGuid, targetName, false, targetGuid)
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00[MTT]|r %s: %s on %s (%.0fs)",
hasExisting and "Refresh" or "Track",
spellName, targetName, duration))
end
end)
end
-- ============================================================================
-- DEBUFF_ADDED_OTHER Handler (Nampower) - Debuff confirmation
-- ============================================================================
local function SetupDebuffAddedTracking()
if not CleveRoids.hasNampower then
return
end
local frame = CreateFrame("Frame", "CleveRoidsMTTDebuffAddedFrame", UIParent)
frame:RegisterEvent("DEBUFF_ADDED_OTHER")
frame:SetScript("OnEvent", function()
-- DEBUFF_ADDED_OTHER args (from Nampower wiki):
-- arg1 = guid (target who received the debuff)
-- arg2 = slot (1-based debuff slot)
-- arg3 = spellId
-- arg4 = stackCount
-- arg5 = auraLevel (caster level)
local targetGuid = arg1
local spellId = arg3
local stackCount = arg4 or 1
if not targetGuid or not spellId then return end
-- Safety check: skip if target is the player (self-buff)
local _, playerGuid = UnitExists("player")
if playerGuid and CleveRoids.GUIDsMatch(playerGuid, targetGuid) then
return -- Self-targeted aura, skip
end
-- Only track known debuff spells
local isKnown = IsKnownDebuffSpell(spellId)
-- Debug: Show if we received DEBUFF_ADDED for known spells
if MTT_DEBUG and isKnown then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
local targetName = UnitName(targetGuid) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00[MTT]|r DEBUFF_ADDED: %s (id=%d) on %s",
spellName, spellId, targetName))
end
if not isKnown then
return
end
-- Check if we have a pending cast for this spell+target
local pending = GetPendingCast(spellId, targetGuid)
-- Also check if this is a REFRESH of an existing debuff we're already tracking
local libdebuff = CleveRoids.libdebuff
local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid)
local isRefresh = false
if libdebuff and normalizedGuid then
local existingDebuff = libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId]
if existingDebuff and IsCasterPlayer(existingDebuff.caster) then
-- This is a refresh of our existing debuff!
isRefresh = true
end
end
-- For AoE spells, also treat as refresh if we have an AoE pending cast AND
-- the target already has this debuff tracked
if not isRefresh and AOE_DEBUFF_SPELLS[spellId] and pending then
if libdebuff and normalizedGuid and libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] then
isRefresh = true
end
end
if not pending and not isRefresh then
-- No pending cast and not a refresh - not our debuff
return
end
-- We have a pending cast or refresh - this is our debuff!
if not libdebuff or not normalizedGuid then return end
-- Get duration using unified lookup system
local duration = GetSpellDuration(spellId)
-- Get target name using multiple resolution methods
local targetName = ResolveTargetName(targetGuid)
-- Skip if we can't resolve the target name (avoid "Unknown" entries)
if not targetName then return end
-- Add target to tracker (pass raw targetGuid for API calls)
AddTrackedTarget(normalizedGuid, targetName, false, targetGuid)
-- Update debuff record
if libdebuff.objects then
libdebuff.objects[normalizedGuid] = libdebuff.objects[normalizedGuid] or {}
local hasExisting = libdebuff.objects[normalizedGuid][spellId]
libdebuff.objects[normalizedGuid][spellId] = libdebuff.objects[normalizedGuid][spellId] or {}
libdebuff.objects[normalizedGuid][spellId].start = GetTime()
libdebuff.objects[normalizedGuid][spellId].duration = duration
libdebuff.objects[normalizedGuid][spellId].caster = "player"
-- Force stacks to 1 for non-stacking debuffs
libdebuff.objects[normalizedGuid][spellId].stacks = NON_STACKING_DEBUFFS[spellId] and 1 or stackCount
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00[MTT]|r %s: %s on %s (%.0fs)",
hasExisting and "Refresh" or "Track",
spellName, targetName, duration))
end
end
end)
end
-- ============================================================================
-- BUFF_ADDED_OTHER Handler (Nampower) - Debuff overflow tracking
-- When debuff bar is full, debuffs appear in buff slots 17-48
-- Also catches aura refreshes that might not trigger DEBUFF_ADDED_OTHER
-- ============================================================================
local function SetupBuffAddedTracking()
if not CleveRoids.hasNampower then
return
end
local frame = CreateFrame("Frame", "CleveRoidsMTTBuffAddedFrame", UIParent)
frame:RegisterEvent("BUFF_ADDED_OTHER")
frame:SetScript("OnEvent", function()
-- BUFF_ADDED_OTHER args (same as DEBUFF_ADDED_OTHER):
-- arg1 = guid (target who received the buff)
-- arg2 = slot (1-based slot)
-- arg3 = spellId
-- arg4 = stackCount
-- arg5 = auraLevel (caster level)
local targetGuid = arg1
local spellId = arg3
local stackCount = arg4 or 1
if not targetGuid or not spellId then return end
-- Safety check: skip if target is the player (self-buff)
local _, playerGuid = UnitExists("player")
if playerGuid and CleveRoids.GUIDsMatch(playerGuid, targetGuid) then
return -- Self-targeted aura, skip
end
-- Only process if this is a debuff we're already tracking (refresh in overflow slot)
local libdebuff = CleveRoids.libdebuff
local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid)
if not libdebuff or not normalizedGuid then return end
local existingDebuff = libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId]
if not existingDebuff or existingDebuff.caster ~= "player" then
-- Not a debuff we're tracking
return
end
-- This is a refresh of our existing debuff (appearing in buff overflow)!
-- Get duration from our known durations
local duration = existingDebuff.duration
or (libdebuff.personalDebuffs and libdebuff.personalDebuffs[spellId])
or (libdebuff.sharedDebuffs and libdebuff.sharedDebuffs[spellId])
or (libdebuff.durations and libdebuff.durations[spellId])
or 30
-- Update debuff record
libdebuff.objects[normalizedGuid][spellId].start = GetTime()
libdebuff.objects[normalizedGuid][spellId].stacks = stackCount
if MTT_DEBUG then
local spellName = SpellInfo and SpellInfo(spellId) or "?"
local targetName = ResolveTargetName(targetGuid) or "?"
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00[MTT]|r Refresh: %s on %s (%.0fs)",
spellName, targetName, duration))
end
end)
end
-- ============================================================================
-- Death Detection (immediate removal of dead units)
-- Uses UNIT_DIED from Nampower when available
-- ============================================================================
local function SetupDeathDetection()
local deathFrame = CreateFrame("Frame", "CleveRoidsMTTDeathFrame", UIParent)
-- Register for target changes and health updates to catch deaths quickly
deathFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
deathFrame:RegisterEvent("UNIT_HEALTH")
deathFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
-- UNIT_DIED from Nampower (most reliable)
if CleveRoids.hasNampower then
deathFrame:RegisterEvent("UNIT_DIED")
end
-- Player leaving combat - cleanup targets with no active debuffs
deathFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
deathFrame:SetScript("OnEvent", function()
if event == "PLAYER_REGEN_ENABLED" then
-- Player left combat - immediately prune targets with no active debuffs
local toRemove = {}
for guid, info in pairs(trackedTargets) do
if IsGUIDDead(guid) then
table.insert(toRemove, guid)
else
local debuffs, count = GetActivePlayerDebuffs(guid)
if count == 0 then
table.insert(toRemove, guid)
end
end
end
for i = 1, table.getn(toRemove) do
RemoveTrackedTarget(toRemove[i])
end
if MTT_DEBUG and table.getn(toRemove) > 0 then
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cff00ff00[MTT]|r Left combat, removed %d targets with no debuffs",
table.getn(toRemove)))
end
elseif event == "UNIT_DIED" then
-- UNIT_DIED args (from Nampower wiki):
-- arg1 = guid of the unit that died
local deadGuid = arg1
if deadGuid then
local normalizedGUID = CleveRoids.NormalizeGUID(deadGuid)
if normalizedGUID and trackedTargets[normalizedGUID] then
if MTT_DEBUG then
local info = trackedTargets[normalizedGUID]
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cffff0000[MTT]|r UNIT_DIED: %s", info.name or "?"))
end
RemoveTrackedTarget(normalizedGUID)
end
end
elseif event == "CHAT_MSG_COMBAT_HOSTILE_DEATH" then
-- arg1 = death message like "Mob Name dies."
if arg1 then
-- Extract name from "X dies." message
local deadName = string.gsub(arg1, " dies%.$", "")
if deadName and deadName ~= "" then
-- Find and remove any tracked target with this name
for guid, info in pairs(trackedTargets) do
if info.name == deadName then
RemoveTrackedTarget(guid)
break
end
end
end
end
elseif event == "PLAYER_TARGET_CHANGED" or event == "UNIT_HEALTH" then
-- Quick check: if current target is dead and tracked, remove it
if UnitExists("target") and UnitIsDead("target") then
local _, targetGUID = UnitExists("target")
if targetGUID then
local normalizedGUID = CleveRoids.NormalizeGUID(targetGUID)
if normalizedGUID and trackedTargets[normalizedGUID] then
RemoveTrackedTarget(normalizedGUID)
end
end
end
-- Also do a quick scan of all tracked targets for deaths (SuperWoW)
if CleveRoids.hasSuperwow then
local toRemove = {}
for guid, info in pairs(trackedTargets) do
if UnitIsDead(guid) then
table.insert(toRemove, guid)
end
end
for i = 1, table.getn(toRemove) do
RemoveTrackedTarget(toRemove[i])
end
end
end
end)
end
-- ============================================================================
-- Combat Target Scanning
-- ============================================================================
local combatScanInterval = 0.5
local lastCombatScan = 0
ScanCombatTargets = function()
if not CleveRoids.hasUnitXP then
return
end
if not UnitAffectingCombat("player") then
return
end
local now = GetTime()
if now - lastCombatScan < combatScanInterval then
return
end
lastCombatScan = now
local hadTarget = UnitExists("target")
local currentTargetGuid = nil
if hadTarget then
local _, guid = UnitExists("target")
currentTargetGuid = guid
end
local firstGuid = nil
local maxIterations = 30
for i = 1, maxIterations do
local found = UnitXP("target", "nextEnemyConsideringDistance")
if not found then break end
if not UnitExists("target") then break end
local _, currentGuid = UnitExists("target")
if not currentGuid then break end
if firstGuid == nil then
firstGuid = currentGuid
elseif currentGuid == firstGuid then
break
end
local normalizedGuid = CleveRoids.NormalizeGUID(currentGuid)
if normalizedGuid and not trackedTargets[normalizedGuid] then
local name = UnitName("target")
confirmedEnemies[normalizedGuid] = true
AddTrackedTarget(normalizedGuid, name, true, currentGuid) -- pass raw GUID
end
end
-- Restore original target state
if currentTargetGuid then
TargetUnit(currentTargetGuid)
else
-- User had no target before scan - clear whatever UnitXP selected
ClearTarget()
end
end
-- ============================================================================
-- Multiscan Integration
-- ============================================================================
local function GetTrackedTargetGUIDs()
local guids = {}
for i = 1, table.getn(trackedOrder) do
local guid = trackedOrder[i]
if trackedTargets[guid] then
table.insert(guids, guid)
end
end
return guids
end
local function GetTrackedTargetInfo(guid)
return trackedTargets[guid]
end
local function IsTracked(guid)
if not guid then return false end
guid = CleveRoids.NormalizeGUID(guid)
return trackedTargets[guid] ~= nil
end
local function GetTrackedCount()
return table.getn(trackedOrder)
end
local function FindTrackedTarget(priority, validateFunc)
if table.getn(trackedOrder) == 0 then
return nil
end
local bestGuid = nil
local bestScore = nil
for i = 1, table.getn(trackedOrder) do
local guid = trackedOrder[i]
local info = trackedTargets[guid]
if info then
if validateFunc and not validateFunc(guid) then
-- Skip
else
local score = nil
if priority == "oldest" then
return guid
elseif priority == "newest" then
bestGuid = guid
elseif priority == "lowestdebuff" then
local debuffs, count = GetActivePlayerDebuffs(guid)
if debuffs and count > 0 then
for j = 1, count do
local remaining = debuffs[j].remaining
if not score or remaining < score then
score = remaining
end
end
end
elseif priority == "highestdebuff" then
local debuffs, count = GetActivePlayerDebuffs(guid)
if debuffs and count > 0 then
for j = 1, count do
local remaining = debuffs[j].remaining
if not score or remaining > score then
score = remaining
end
end
end
if score then score = -score end
elseif priority == "fewestdebuffs" then
local _, count = GetActivePlayerDebuffs(guid)
score = count
elseif priority == "mostdebuffs" then
local _, count = GetActivePlayerDebuffs(guid)
score = -count
end
if score then
if not bestScore or score < bestScore then
bestScore = score
bestGuid = guid
end
end
end
end
end
return bestGuid
end
-- ============================================================================
-- Extension Lifecycle
-- ============================================================================
-- Sync all our debuff durations to pfUI's libdebuff
local function SyncDurationsToPfUI()
if not pfUI or not pfUI.api or not pfUI.api.libdebuff then
return
end
local pflib = pfUI.api.libdebuff
pflib.debuffs = pflib.debuffs or {}
local count = 0
-- Sync personal debuffs
if lib.personalDebuffs then
for spellID, duration in pairs(lib.personalDebuffs) do
local spellName = SpellInfo(spellID)
if spellName then
-- Strip rank from name
local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "")
-- Only update if our duration is different (prefer longer/more accurate)
if not pflib.debuffs[baseName] or pflib.debuffs[baseName] < duration then
pflib.debuffs[baseName] = duration
count = count + 1
end
end
end
end
-- Sync shared debuffs
if lib.sharedDebuffs then
for spellID, duration in pairs(lib.sharedDebuffs) do
local spellName = SpellInfo(spellID)
if spellName then
local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "")
if not pflib.debuffs[baseName] or pflib.debuffs[baseName] < duration then
pflib.debuffs[baseName] = duration
count = count + 1
end
end
end
end
-- Sync proc debuffs
for spellID, _ in pairs(PROC_DEBUFFS) do
local duration = lib.durations and lib.durations[spellID]
if duration then
local spellName = SpellInfo(spellID)
if spellName then
local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "")
if not pflib.debuffs[baseName] or pflib.debuffs[baseName] ~= duration then
pflib.debuffs[baseName] = duration
count = count + 1
end
end
end
end
end
function Extension.OnLoad()
lib = CleveRoids.libdebuff
if not lib then
CleveRoids.Print("MultiTargetTracker: libdebuff not found, extension disabled")
return
end
-- Build cache of player's known spells for attribution checks
BuildPlayerSpellCache()
-- Add item proc debuffs to shared debuffs table (if not already present)
-- Thunderfury, Blessed Blade of the Windseeker
lib.sharedDebuffs = lib.sharedDebuffs or {}
lib.sharedDebuffs[21992] = lib.sharedDebuffs[21992] or 12 -- Thunderfury (12s duration)
lib.sharedDebuffs[27648] = lib.sharedDebuffs[27648] or 12 -- Thunderfury secondary effect
-- Also add to durations table for GetDuration lookups
lib.durations = lib.durations or {}
lib.durations[21992] = lib.durations[21992] or 12
lib.durations[27648] = lib.durations[27648] or 12
mainFrame = CreateMainFrame()
HookAddEffect()
-- Enable AURA_CAST events in Nampower (required for AURA_CAST_ON_OTHER to fire)
if CleveRoids.hasNampower and SetCVar then
SetCVar("NP_EnableAuraCastEvents", "1")
end
-- Setup tracking handlers based on wiki documentation:
-- SPELL_CAST_EVENT: "only fires for spells you (and certain pets) initiated"
-- DEBUFF_ADDED_OTHER: confirms debuff landed, provides stacks
-- AURA_CAST_ON_OTHER: provides durationMs from DBC
-- UNIT_CASTEVENT: SuperWoW backup with consistent GUIDs
-- UNIT_DIED: reliable death detection from Nampower
SetupSpellCastEventTracking() -- Primary: Nampower SPELL_CAST_EVENT (only YOUR casts)
SetupUnitCastEventTracking() -- Backup: SuperWoW UNIT_CASTEVENT
SetupDebuffAddedTracking() -- Confirmation: DEBUFF_ADDED_OTHER
SetupBuffAddedTracking() -- Overflow/refresh: BUFF_ADDED_OTHER (debuffs in buff slots 17-48)
SetupAuraCastTracking() -- Duration data + refresh: AURA_CAST_ON_OTHER
SetupDeathDetection() -- Cleanup: UNIT_DIED + fallbacks
RestoreFramePosition()
-- Sync our durations to pfUI (delayed slightly to ensure pfUI is loaded)
local syncFrame = CreateFrame("Frame")
syncFrame:SetScript("OnUpdate", function()
this.elapsed = (this.elapsed or 0) + arg1
if this.elapsed > 1 then
SyncDurationsToPfUI()
this:SetScript("OnUpdate", nil)
end
end)
CleveRoids.Print("Multi-Target Tracker loaded. Use /cleveroid mtt for commands.")
end
-- Export API
CleveRoids.MultiTargetTracker = {
-- Target management
AddTarget = AddTrackedTarget,
RemoveTarget = RemoveTrackedTarget,
ClearTargets = ClearAllTargets,
-- Query functions
GetTrackedGUIDs = GetTrackedTargetGUIDs,
GetTargetInfo = GetTrackedTargetInfo,
IsTracked = IsTracked,
GetTrackedCount = GetTrackedCount,
FindTrackedTarget = FindTrackedTarget,
GetActiveDebuffs = GetActivePlayerDebuffs,
-- Frame control
Show = function()
if mainFrame then
mainFrame:Show()
isFrameVisible = true
end
end,
Hide = function()
if mainFrame then
mainFrame:Hide()
isFrameVisible = false
end
end,
Toggle = function()
if mainFrame then
if isFrameVisible then
mainFrame:Hide()
isFrameVisible = false
else
mainFrame:Show()
isFrameVisible = true
end
end
end,
IsVisible = function()
return isFrameVisible
end,
-- Frame positioning
Unlock = function()
isUnlocked = true
if mainFrame then
mainFrame:Show()
isFrameVisible = true
if mainFrame.titleText then
mainFrame.titleText:SetText("Debuff Tracker (UNLOCKED)")
mainFrame.titleText:SetTextColor(0, 1, 0, 1)
end
end
CleveRoids.Print("Multi-Target Tracker: Unlocked - drag to move, then /cleveroid mtt lock")
end,
Lock = function()
isUnlocked = false
if mainFrame and mainFrame.titleText then
mainFrame.titleText:SetText("Debuff Tracker")
mainFrame.titleText:SetTextColor(COLORS.titleText[1], COLORS.titleText[2], COLORS.titleText[3], 1)
end
if table.getn(trackedOrder) == 0 and mainFrame then
mainFrame:Hide()
isFrameVisible = false
end
CleveRoids.Print("Multi-Target Tracker: Locked")
end,
ResetPosition = function()
CleveRoidMacros = CleveRoidMacros or {}
CleveRoidMacros.multiTargetPos = nil
if mainFrame then
mainFrame:ClearAllPoints()
mainFrame:SetPoint("CENTER", UIParent, "CENTER", 200, 100)
end
CleveRoids.Print("Multi-Target Tracker: Position reset")
end,
-- Debug mode
Debug = function(enable)
if enable == nil then
MTT_DEBUG = not MTT_DEBUG
else
MTT_DEBUG = enable
end
CleveRoids.Print("Multi-Target Tracker: Debug " .. (MTT_DEBUG and "ON" or "OFF"))
end,
-- Dump current tracking state for debugging
DumpState = function()
CleveRoids.Print("=== MTT State Dump ===")
CleveRoids.Print("Tracked targets: " .. table.getn(trackedOrder))
for i, guid in ipairs(trackedOrder) do
local info = trackedTargets[guid]
local debuffs, count = GetActivePlayerDebuffs(guid)
CleveRoids.Print(string.format(" %d. %s (GUID:%s) - %d debuffs",
i, info and info.name or "?", string.sub(tostring(guid), 1, 16), count))
-- Show lib.objects state
local libdebuff = CleveRoids.libdebuff
if libdebuff and libdebuff.objects then
local hasRecord = libdebuff.objects[guid] and true or false
CleveRoids.Print(string.format(" lib.objects[guid]: %s", hasRecord and "EXISTS" or "nil"))
if hasRecord then
for spellID, rec in pairs(libdebuff.objects[guid]) do
local name = SpellInfo and SpellInfo(spellID) or "?"
local remaining = rec.duration and rec.start and (rec.duration + rec.start - GetTime()) or 0
local stacksInfo = rec.stacks and rec.stacks > 1 and (" x" .. rec.stacks) or ""
CleveRoids.Print(string.format(" - %s: %.1fs, caster=%s%s",
name, remaining, tostring(rec.caster), stacksInfo))
end
end
end
end
end,
}
_G["CleveRoids"] = CleveRoids