--[[ 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