diff --git a/Compatibility/pfUI.lua b/Compatibility/pfUI.lua index 981720f..91d276c 100644 --- a/Compatibility/pfUI.lua +++ b/Compatibility/pfUI.lua @@ -124,7 +124,7 @@ function Extension.HookPfUILibdebuff() -- Check if pfUI 7.6+ with GetUnitField-based libdebuff is active -- If so, pfUI handles combo durations and Carnage internally - we only override on mismatch - local hasPfUI76 = CleveRoids.hasPfUI76 or (pfUI.libdebuff_slot_ownership ~= nil) + local hasPfUI76 = CleveRoids.hasPfUI76 -- pfUI 7.6+ handles all durations internally - no hooks needed if hasPfUI76 then @@ -351,7 +351,7 @@ function Extension.SyncComboDurationToPfUI(guid, spellID, duration) end -- pfUI 7.6+ handles all durations internally - if CleveRoids.hasPfUI76 or (pfUI.libdebuff_slot_ownership ~= nil) then + if CleveRoids.hasPfUI76 then return end diff --git a/Conditionals.lua b/Conditionals.lua index 29f37bf..dbab3c9 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -149,6 +149,33 @@ function CleveRoids.InvalidateSpellNameCache() _spellNameToIDsBuilt = false end +-- Check if a debuff is shared by spell ID or by name (for custom/Turtle WoW IDs). +-- Falls back to checking if the debuff NAME matches any known shared debuff name +-- in the spell name cache, even if the specific spell ID is unregistered. +local function IsSharedDebuffByIdOrName(lib, spellID, debuffName) + if not lib then return false end + -- Direct ID check (fast path) + if lib.IsPersonalDebuff and lib:IsPersonalDebuff(spellID) == false then + return true + end + -- Name-based fallback: if ANY spell ID for this name is in sharedDebuffs, treat as shared. + -- This handles custom server spell IDs (e.g., Turtle WoW Judgement variants) that have + -- the same debuff name but different IDs from the hardcoded vanilla entries. + if debuffName then + local knownIDs = GetSpellIDsForName(debuffName) + if knownIDs then + for _, kid in ipairs(knownIDs) do + if lib.sharedDebuffs and lib.sharedDebuffs[kid] then + -- Also register this new spell ID so future checks are fast + lib.sharedDebuffs[spellID] = lib.sharedDebuffs[kid] + return true + end + end + end + end + return false +end + -- PERFORMANCE: Equipment cache for HasGearEquipped (avoids 19-slot scan per call) -- Invalidated on UNIT_INVENTORY_CHANGED via CleveRoids.InvalidateEquipmentCache() -- Enhanced with Nampower v2.18+ GetEquippedItems when available @@ -849,6 +876,14 @@ CleveRoids.AuraCapStatus = { lastCleanupTime = 0, } +-- Overflow buff tracking: buffs applied while buff-capped (v2.34+) +-- GetUnitField "aura" returns 48 slots: [1-32] = buff slots, [33-48] = debuff slots. +-- Beyond these 48 client slots, the server can hold additional overflow buffs with +-- no aura slot at all -- invisible to GetPlayerBuff and GetPlayerAuraDuration. +-- Populated from AURA_CAST_ON_SELF when auraCapStatus indicates buff bar full. +-- Format: [spellId] = { timestamp = GetTime(), durationSec = durationMs/1000 } +CleveRoids.OverflowBuffs = {} + -- All-caster aura duration tracking (from AURA_CAST events) -- Tracks buff/debuff durations from ANY caster, not just player -- Structure: [targetGuid][spellId] = { start, duration, casterGuid } @@ -994,10 +1029,98 @@ local function OnAuraCastSelf(spellId, casterGuid, targetGuid, effect, effectAur local now = GetTime() CleveRoids.AuraCapStatus.playerLastUpdate = now + local buffCapped = HasHitFlag(auraCapStatus, AURA_CAP_BUFF_FULL) + -- Check buff bar full (bit 1) - CleveRoids.AuraCapStatus.playerBuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_BUFF_FULL) + CleveRoids.AuraCapStatus.playerBuffCapped = buffCapped -- Check debuff bar full (bit 2) CleveRoids.AuraCapStatus.playerDebuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_DEBUFF_FULL) + + -- Determine if this aura is a buff (not debuff) — debuffs land in GetPlayerAuraDuration + -- slots 32-47, so checking those slots identifies them regardless of cap status. + local isBuffNotDebuff = false + if spellId and spellId > 0 and effectAuraName and effectAuraName > 0 then + local isDebuff = false + if _G.GetPlayerAuraDuration then + for slot = 32, 47 do + local sid = _G.GetPlayerAuraDuration(slot) + if sid and sid == spellId then + isDebuff = true + break + end + end + end + isBuffNotDebuff = not isDebuff + + -- Track overflow buffs: if buff bar is full, this buff has no client aura slot. + -- Store spellId + duration so /cancelaura and [mybuff] can find it. + if buffCapped and isBuffNotDebuff then + local entry = CleveRoids.OverflowBuffs[spellId] + if not entry then + entry = {} + CleveRoids.OverflowBuffs[spellId] = entry + end + entry.timestamp = now + entry.durationSec = durationMs and (durationMs / 1000) or 0 + end + end + + if not buffCapped and next(CleveRoids.OverflowBuffs) then + -- No longer buff-capped: some overflow buffs may have gotten real slots. + -- Only remove entries that now appear in a visible aura slot. + -- The server does NOT auto-migrate overflow buffs into freed slots, + -- so we can't blindly clear everything. + if _G.GetPlayerAuraDuration then + local visibleSpells = {} + for slot = 0, 31 do + local sid = _G.GetPlayerAuraDuration(slot) + if sid and sid > 0 then + visibleSpells[sid] = true + end + end + for k in pairs(CleveRoids.OverflowBuffs) do + if visibleSpells[k] then + CleveRoids.OverflowBuffs[k] = nil + end + end + end + -- Also prune expired entries + for k, entry in pairs(CleveRoids.OverflowBuffs) do + if entry.durationSec and entry.durationSec > 0 then + local elapsed = now - (entry.timestamp or 0) + if elapsed > entry.durationSec then + CleveRoids.OverflowBuffs[k] = nil + end + end + end + end + + -- NEW: Populate ownBuffCasts and allBuffAuras for all player buffs (not just overflow) + -- Use the isBuffNotDebuff result determined above (avoids redundant slot scanning) + local lib = CleveRoids.libdebuff + if isBuffNotDebuff and spellId and durationMs and durationMs > 0 and lib and not lib.hasPfUIEnhanced then + local spellName = SpellInfo and SpellInfo(spellId) + if spellName then + local _, playerGuidRaw = UnitExists("player") + local playerGuid = playerGuidRaw and CleveRoids.NormalizeGUID(playerGuidRaw) + if playerGuid then + lib.ownBuffCasts[playerGuid] = lib.ownBuffCasts[playerGuid] or {} + lib.ownBuffCasts[playerGuid][spellName] = { + startTime = now, + duration = durationMs / 1000, + spellId = spellId, + casterGuid = casterGuid, + } + lib.allBuffAuras[playerGuid] = lib.allBuffAuras[playerGuid] or {} + lib.allBuffAuras[playerGuid][spellName] = lib.allBuffAuras[playerGuid][spellName] or {} + lib.allBuffAuras[playerGuid][spellName][casterGuid or "unknown"] = { + startTime = now, + duration = durationMs / 1000, + rank = 0, + } + end + end + end end local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAuraName, @@ -1033,6 +1156,25 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu spellName, spellId, string.sub(tostring(targetGuid), 1, 16), durationMs / 1000 )) end + + -- NEW: Store in pendingBuffCasts for BUFF_ADDED_OTHER to confirm as buff + -- (AURA_CAST_ON_OTHER fires for both buffs and debuffs; BUFF_ADDED_OTHER confirms buff) + local lib = CleveRoids.libdebuff + if lib and not lib.hasPfUIEnhanced then + local spellNameForPending = SpellInfo and SpellInfo(spellId) + if spellNameForPending then + local normTargetGuid = CleveRoids.NormalizeGUID(targetGuid) + if normTargetGuid then + lib.pendingBuffCasts[normTargetGuid] = lib.pendingBuffCasts[normTargetGuid] or {} + lib.pendingBuffCasts[normTargetGuid][spellId] = { + casterGuid = CleveRoids.NormalizeGUID(casterGuid), + duration = durationMs / 1000, + spellName = spellNameForPending, + time = now, + } + end + end + end end -- Store cap status for this target GUID (if available) @@ -1663,6 +1805,58 @@ end function CleveRoids.CancelAura(auraName) local ix = 0 auraName = string.lower(string.gsub(auraName, "_"," ")) + + -- v2.34+ path: cancel by spell ID (works for buff-capped overflow auras too) + local API = CleveRoids.NampowerAPI + if API and API.features.hasCancelPlayerAuraSpellId and CleveRoids.hasSuperwow then + -- First scan visible buffs via GetPlayerBuff (fast, covers normal case) + while true do + local aura_ix = GetPlayerBuff(ix, "HELPFUL") + ix = ix + 1 + if aura_ix == -1 then break end + local bid = GetPlayerBuffID(aura_ix) + bid = (bid < -1) and (bid + 65536) or bid + if string.lower(SpellInfo(bid)) == auraName then + _G.CancelPlayerAuraSpellId(bid, 1) + return true + end + end + + -- Not found in visible buffs - scan all 32 raw aura slots + -- GetPlayerAuraDuration reads unit data fields directly (same 32 slots but bypasses UI filtering) + if API.features.hasGetPlayerAuraDuration and _G.GetPlayerAuraDuration then + for slot = 0, 31 do + local spellId = _G.GetPlayerAuraDuration(slot) + if spellId and spellId > 0 then + local name = SpellInfo(spellId) + if name and string.lower(name) == auraName then + _G.CancelPlayerAuraSpellId(spellId, 1) + return true + end + end + end + end + + -- Final fallback: check overflow buff tracking (buffs applied while buff-capped + -- that have NO client aura slot - tracked via AURA_CAST_ON_SELF events) + for spellId, entry in pairs(CleveRoids.OverflowBuffs) do + -- Skip expired overflow entries + local elapsed = GetTime() - (entry.timestamp or 0) + if entry.durationSec and entry.durationSec > 0 and elapsed > entry.durationSec then + CleveRoids.OverflowBuffs[spellId] = nil + else + local name = SpellInfo(spellId) + if name and string.lower(name) == auraName then + _G.CancelPlayerAuraSpellId(spellId, 1) + CleveRoids.OverflowBuffs[spellId] = nil + return true + end + end + end + return false + end + + -- Legacy path for older Nampower versions while true do local aura_ix = GetPlayerBuff(ix,"HELPFUL") ix = ix + 1 @@ -2451,11 +2645,16 @@ end --- Falls back to basic position checking if MonkeySpeed isn't available --- @return boolean True if player is moving function CleveRoids.IsPlayerMoving() - -- If MonkeySpeed is available, use it for accurate detection + -- If MonkeySpeed is available, use it for accurate speed-based detection if CleveRoids.HasMonkeySpeed() then return (MonkeySpeed.m_fSpeed or 0) > 0 end + -- Nampower 2.36+ provides direct movement flag query (catches jumping, falling, pitching) + if CleveRoids.NampowerAPI.features.hasPlayerIsMoving then + return PlayerIsMoving() == 1 + end + -- Fallback: use continuously tracked position history from OnUpdate -- Core.lua tracks position at ~100 Hz with 4-sample circular buffer (matches MonkeySpeed's 0.01s). -- Movement is detected if ANY consecutive pair of samples shows movement. @@ -2465,25 +2664,18 @@ function CleveRoids.IsPlayerMoving() local headIdx = CleveRoids._posHistoryIndex or 0 if history and count >= 2 then - -- Iterate consecutive pairs in circular buffer order (oldest to newest) - -- Head is the most recent entry, walk backwards through the ring - local samplesAvail = count - for i = 1, samplesAvail - 1 do - -- prevIdx and currIdx walk from oldest to newest - local currOffset = samplesAvail - i -- e.g., 3,2,1,0 for count=4 - local prevOffset = samplesAvail - i + 1 -- e.g., 4,3,2,1 - local currIdx = math.mod((headIdx - currOffset - 1), 4) + 1 - local prevIdx = math.mod((headIdx - prevOffset - 1), 4) + 1 - local prev = history[prevIdx] - local curr = history[currIdx] + -- Compare the most recent pair: headIdx (newest) vs. the slot before it. + -- Use (headIdx - 2 + 4) mod 4 to safely wrap backwards in the 1-based ring. + -- This avoids Lua 5.0 math.mod returning negative values for negative inputs. + local prevIdx = math.mod((headIdx - 2 + 4), 4) + 1 + local curr = history[headIdx] + local prev = history[prevIdx] + if curr and prev then local dx = curr.x - prev.x local dy = curr.y - prev.y - local dist = math.sqrt(dx * dx + dy * dy) - if dist > 0.001 then - return true -- Any movement in recent history = moving - end + return math.sqrt(dx * dx + dy * dy) > 0.001 end - return false -- No movement in any recent sample = stopped + return false end -- Legacy fallback for code that might not have history yet @@ -3281,6 +3473,112 @@ function CleveRoids.ValidateAura(unit, args, isbuff) end end + -- Overflow buff fallback: player buffs in server slots 33-48 (no client slot) + -- Check AURA_CAST_ON_SELF tracked overflow data for presence + duration + if not found and isPlayer and isbuff and next(CleveRoids.OverflowBuffs) then + local now = GetTime() + for spellId, entry in pairs(CleveRoids.OverflowBuffs) do + -- Prune expired entries + local elapsed = now - (entry.timestamp or 0) + if entry.durationSec and entry.durationSec > 0 and elapsed > entry.durationSec then + CleveRoids.OverflowBuffs[spellId] = nil + else + local match = false + if searchID then + match = (spellId == searchID) + elseif searchName then + local lowerName = GetLowercaseSpellName(spellId) + match = (lowerName and lowerName == searchName) + end + if match then + found = true + stacks = 0 + -- Compute remaining time from apply timestamp + duration + if entry.durationSec and entry.durationSec > 0 then + remaining = entry.durationSec - elapsed + if remaining < 0 then remaining = 0 end + else + remaining = -1 -- Unknown duration (permanent buff) + end + break + end + end + end + end + + -- allBuffAuras fallback for player buff timing: when slow path found the buff but + -- returned no remaining time, check lib.allBuffAuras for cached AURA_CAST timing. + if found and remaining == nil and isPlayer and isbuff and searchName then + local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil + if lib and lib.allBuffAuras then + local _, playerGuidRaw = UnitExists("player") + local playerGuid = CleveRoids.NormalizeGUID(playerGuidRaw) + if playerGuid and lib.allBuffAuras[playerGuid] then + -- Try exact name match first + local casters = lib.allBuffAuras[playerGuid][args.name] + -- Try lowercase match if exact didn't work + if not casters then + for bName, c in pairs(lib.allBuffAuras[playerGuid]) do + if _string_lower(bName) == searchName then + casters = c + break + end + end + end + if casters then + for _, cData in pairs(casters) do + local elapsed = GetTime() - (cData.startTime or 0) + remaining = cData.duration > 0 and (cData.duration - elapsed) or -1 + break + end + end + end + end + end + + -- Non-player overflow fallback: buff exists in server slots 33-48 (no client slot) + -- AllCasterAuraTracking already has data from AURA_CAST_ON_OTHER for all aura applications. + -- If the normal scan didn't find the buff, check there for presence + duration. + -- Guard: verify the spell isn't a visible debuff on the target (AllCasterAuraTracking + -- stores both buffs and debuffs, so without this check [buff:DebuffName] could false-positive). + if not found and not isPlayer and isbuff and (searchID or searchName) then + local _, targetGuid = UnitExists(unit) + if targetGuid then + -- Check if the spell is in a visible debuff slot — if so, it's a debuff, not a buff + local isDebuff = false + local di = 1 + while true do + local dtex, _, _, dspellId = UnitDebuff(unit, di) + if not dtex then break end + if dspellId then + if searchID then + if dspellId == searchID then + isDebuff = true + break + end + elseif searchName then + local lowerName = GetLowercaseSpellName(dspellId) + if lowerName and lowerName == searchName then + isDebuff = true + break + end + end + end + di = di + 1 + end + + if not isDebuff then + local trackRemaining = CleveRoids.FindAllCasterAuraByName(targetGuid, + searchID and tostring(searchID) or args.name) + if trackRemaining then + found = true + stacks = 0 + remaining = trackRemaining + end + end + end + end + local ops = CleveRoids.operators local cmp = CleveRoids.comparators @@ -3291,6 +3589,46 @@ function CleveRoids.ValidateAura(unit, args, isbuff) -- (showing ~1000s instead of actual remaining time). Skip it for buff time checks -- and rely on all-caster tracking from AURA_CAST events instead. + -- Fast path: Check lib.allBuffAuras (spellName-indexed, O(1) lookup) + -- More efficient than FindAllCasterAuraByName which does ID→name translation + if nonPlayerAuraTimeRemaining == nil and isbuff then + local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil + if lib and lib.allBuffAuras then + local _, targetGuid = UnitExists(unit) + targetGuid = targetGuid and CleveRoids.NormalizeGUID(targetGuid) + if targetGuid then + local buffEntries = lib.allBuffAuras[targetGuid] + if buffEntries then + -- Try exact name match first + local casters = buffEntries[args.name] + -- Try lowercase match if exact didn't work + if not casters and searchName then + for bName, c in pairs(buffEntries) do + if _string_lower(bName) == searchName then + casters = c + break + end + end + end + if casters then + for _, cData in pairs(casters) do + local elapsed = GetTime() - (cData.startTime or 0) + local rem = cData.duration > 0 and (cData.duration - elapsed) or -1 + if rem == nil or rem > 0 or cData.duration <= 0 then + nonPlayerAuraTimeRemaining = rem + if not found then + found = true + stacks = 0 + end + break + end + end + end + end + end + end + end + -- Second try: All-caster tracking from AURA_CAST events (works for any caster) -- Only use if libdebuff didn't find it (libdebuff has more accurate timing for player casts) if nonPlayerAuraTimeRemaining == nil then @@ -3506,7 +3844,7 @@ function CleveRoids.ValidateUnitDebuff(unit, args) if gufResult ~= nil then -- GetUnitField was available and searched if gufResult then - local isShared = lib and lib.IsPersonalDebuff and lib:IsPersonalDebuff(gufSpellId) == false + local isShared = IsSharedDebuffByIdOrName(lib, gufSpellId, args.name) if isShared then found = true stacks = gufStacks or 0 @@ -3540,7 +3878,8 @@ function CleveRoids.ValidateUnitDebuff(unit, args) if matched then -- IMPORTANT: Only use fallback for SHARED debuffs -- Personal debuffs must come from tracking table (caster check) - local isShared = lib and lib.IsPersonalDebuff and lib:IsPersonalDebuff(debuffSpellID) == false + -- Uses name-based fallback for custom spell IDs (e.g., Turtle WoW Judgements) + local isShared = IsSharedDebuffByIdOrName(lib, debuffSpellID, args.name) if isShared then found = true texture = tex @@ -3570,7 +3909,8 @@ function CleveRoids.ValidateUnitDebuff(unit, args) end if matched then -- IMPORTANT: Only use fallback for SHARED debuffs - local isShared = lib and lib.IsPersonalDebuff and lib:IsPersonalDebuff(buffSpellID) == false + -- Uses name-based fallback for custom spell IDs (e.g., Turtle WoW Judgements) + local isShared = IsSharedDebuffByIdOrName(lib, buffSpellID, args.name) if isShared then found = true texture = tex @@ -4347,6 +4687,7 @@ CleveRoids.CCTypesLossOfControl = { [1] = true, -- charm [2] = true, -- disoriented [5] = true, -- fear + [7] = true, -- root (Entangling Roots, Frost Nova, etc.) [10] = true, -- sleep [12] = true, -- stun (Cheap Shot, Kidney Shot, etc.) [13] = true, -- freeze @@ -6219,14 +6560,28 @@ CleveRoids.Keywords = { end, conditionals, "nopet") end, + -- [swimming] - Player is currently swimming (Nampower v2.36+) swimming = function(conditionals) - -- Check if "Aquatic Form" is in the reactive list and usable - return CleveRoids.IsReactiveUsable("Aquatic Form") + if not CleveRoids.NampowerAPI.features.hasPlayerIsSwimming then + if not CleveRoids._swimmingErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swimming] conditional requires Nampower v2.36.0 or newer.", 1, 0.5, 0.5) + CleveRoids._swimmingErrorShown = true + end + return false + end + return PlayerIsSwimming() == 1 end, + -- [noswimming] - Player is NOT swimming (Nampower v2.36+) noswimming = function(conditionals) - -- Check if "Aquatic Form" is NOT usable - return not CleveRoids.IsReactiveUsable("Aquatic Form") + if not CleveRoids.NampowerAPI.features.hasPlayerIsSwimming then + if not CleveRoids._swimmingErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swimming] conditional requires Nampower v2.36.0 or newer.", 1, 0.5, 0.5) + CleveRoids._swimmingErrorShown = true + end + return false + end + return PlayerIsSwimming() ~= 1 end, distance = function(conditionals) diff --git a/Core.lua b/Core.lua index 29b6d83..c303348 100644 --- a/Core.lua +++ b/Core.lua @@ -490,6 +490,10 @@ frame:SetScript("OnEvent", function() if type(CleveRoidMacros.refresh) ~= "number" then CleveRoidMacros.refresh = 5 end + + if type(CleveRoidMacros.macrocheck) ~= "number" then + CleveRoidMacros.macrocheck = 1 -- enabled by default + end end) -- Queues a full update of all action bars. @@ -805,10 +809,10 @@ function CleveRoids.TestForActiveAction(actions) if action.sequence then newSequence = action.sequence newActiveAction = CleveRoids.GetCurrentSequenceAction(newSequence) - -- Check if current step is null/pass/noop - if so, skip this sequence for icon + -- Check if current step is a stop step - if so, skip this sequence for icon if newActiveAction and newActiveAction.action then local stepLower = string.lower(CleveRoids.Trim(newActiveAction.action)) - if stepLower == "null" or stepLower == "pass" or stepLower == "noop" then + if stepLower == "nil" or stepLower == "null" or stepLower == "pass" or stepLower == "noop" then hasActive = false newActiveAction = nil newSequence = nil @@ -1877,17 +1881,11 @@ function CleveRoids.ParseMsg(msg) -- With a condition block present, build out the conditionals table - -- optional spam/cancel flags (apply only when we actually have a [] block) + -- ! prefix with explicit conditionals: store as a flag for execution-time anti-toggle, + -- rather than injecting an implicit conditional that gets AND'd with user conditions. + -- The anti-toggle check happens in DoWithConditionals before CastSpellByName. if noSpam and noSpam ~= "" then - local spamCond = CleveRoids.GetSpammableConditional(action) - if spamCond then - conditionals[spamCond] = { action } - -- Also create _groups entry so Multi() finds it when combined with explicit conditionals - if not conditionals._groups then - conditionals._groups = {} - end - conditionals._groups[spamCond] = { { values = { action }, operator = "OR" } } - end + conditionals.noSpam = true end if cancelAura and cancelAura ~= "" then conditionals.cancelaura = action @@ -2372,7 +2370,7 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo conditionals.target = origTarget return false end - -- Set target to the found GUID for soft-casting via SuperWoW + -- Set target to the found GUID for soft-casting via SuperWoW or Nampower v2.37+ conditionals.target = scanResult -- Don't need to retarget since we're using GUID directly needRetarget = false @@ -2404,7 +2402,7 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end end - if conditionals.target ~= nil and targetBeforeAction and not (CleveRoids.hasSuperwow and action == CastSpellByName) then + if conditionals.target ~= nil and targetBeforeAction and not (action == CastSpellByName and (CleveRoids.hasSuperwow or CleveRoids.hasCastSpellByNameUnitToken)) then if not UnitIsUnit("target", conditionals.target) then if SpellIsTargeting() then SpellStopCasting() @@ -2477,11 +2475,33 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end end if action == CastSpellByName then - -- Special case: !Attack should use AttackTarget() which doesn't toggle - -- This is more reliable than CastSpellByName("Attack") which toggles on/off + -- ! prefix anti-toggle: prevent CastSpellByName from toggling off active spells. + -- For Attack/AutoShot/Shoot, use the non-toggling AttackTarget() API. + -- For self-buffs/shapeshifts, skip if already active on the player. + if conditionals.noSpam then + local spamType = CleveRoids.spamConditions[msg] + if spamType == "checkchanneled" then + -- Attack/AutoShot/Shoot: use non-toggling API + if msg == CleveRoids.Localized.Attack then + AttackTarget() + if needRetarget then TargetLastTarget() end + conditionals.target = origTarget + return true + end + -- AutoShot/Shoot: checkchanneled prevents toggle (already handled below) + else + -- Self-buff/shapeshift: skip if already active on player + if CleveRoids.ValidatePlayerBuff(msg) then + if needRetarget then TargetLastTarget() end + conditionals.target = origTarget + return false + end + end + end + -- Legacy path: !Attack without explicit conditionals (checkchanneled injected) if msg == CleveRoids.Localized.Attack and conditionals.checkchanneled then AttackTarget() - elseif CleveRoids.hasSuperwow and conditionals.target then + elseif (CleveRoids.hasSuperwow or CleveRoids.hasCastSpellByNameUnitToken) and conditionals.target then -- Let Nampower DLL handle queuing natively via its CastSpellByName hook CastSpellByName(castMsg, conditionals.target) else @@ -2581,6 +2601,23 @@ function CleveRoids.DoTarget(msg) -- Save original target GUID for potential restoration (SuperWoW returns GUID as 2nd value) local _, originalTargetGuid = UnitExists("target") + -- Handle [multiscan:priority] - use ResolveMultiscanTarget for enemy scanning + -- ResolveMultiscanTarget handles its own target save/restore internally + if conditionals.multiscan then + local scanResult = CleveRoids.ResolveMultiscanTarget(conditionals, conditionals.target) + if scanResult then + TargetUnit(scanResult) + return true + end + -- No match found - restore original target if scanning changed it + if originalTargetGuid and UnitExists(originalTargetGuid) then + TargetUnit(originalTargetGuid) + elseif not originalTargetGuid then + ClearTarget() + end + return false + end + -- Track if an explicit target was specified via @unit syntax local explicitTarget = conditionals.target @@ -3181,16 +3218,15 @@ function CleveRoids.EquipBagItem(msg, slotOrOffhand) end end - -- Item not found via v2.18 lookup + -- Item not found via v2.18 lookup - fall through to legacy path if CleveRoids.equipDebugLog then - CleveRoids.Print("|cffff8800[EquipLog] Item '" .. tostring(msg) .. "' not found via v2.18 FindPlayerItemSlot|r") + CleveRoids.Print("|cffff8800[EquipLog] Item '" .. tostring(msg) .. "' not found via v2.18 FindPlayerItemSlot, trying legacy fallback|r") -- Debug: Try direct FindPlayerItemSlot call if FindPlayerItemSlot then local bag, slot = FindPlayerItemSlot(msg) CleveRoids.Print("|cff888888[EquipLog] Direct FindPlayerItemSlot('" .. msg .. "') = bag:" .. tostring(bag) .. " slot:" .. tostring(slot) .. "|r") end end - return false end -- Fallback for older Nampower versions @@ -3294,13 +3330,25 @@ function CleveRoids.EquipBagItem(msg, slotOrOffhand) if EquipItemByName then local ok = pcall(EquipItemByName, msg, invslot) if ok then - -- Invalidate cache entry if it exists - if CleveRoids.Items then - CleveRoids.Items[msg] = nil - CleveRoids.Items[string_lower(msg)] = nil + -- Verify the item actually landed in the target slot + -- EquipItemByName may silently no-op for same-named items in paired slots + -- (e.g., dual-wielding Scimitar: MH copy found first, "equipped" to same slot) + local newLink = GetInventoryItemLink("player", invslot) + if newLink then + local _, _, newName = string_find(newLink, "|h%[(.-)%]|h") + if newName and string_lower(newName) == string_lower(msg) then + if CleveRoids.Items then + CleveRoids.Items[msg] = nil + CleveRoids.Items[string_lower(msg)] = nil + end + InvalidateDisplacedItem() + return true + end + end + -- Verification failed - EquipItemByName didn't place item in target slot + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[EquipLog] EquipItemByName('" .. msg .. "', " .. invslot .. ") succeeded but item not in target slot, trying fallback|r") end - InvalidateDisplacedItem() - return true end end @@ -3336,12 +3384,23 @@ function CleveRoids.EquipBagItem(msg, slotOrOffhand) if item.name and EquipItemByName and item.name ~= msg then local ok = pcall(EquipItemByName, item.name, invslot) if ok then - if CleveRoids.Items then - CleveRoids.Items[item.name] = nil - CleveRoids.Items[string_lower(item.name)] = nil + -- Verify the item actually landed in the target slot (same guard as above) + local newLink = GetInventoryItemLink("player", invslot) + if newLink then + local _, _, newName = string_find(newLink, "|h%[(.-)%]|h") + if newName and string_lower(newName) == string_lower(item.name) then + if CleveRoids.Items then + CleveRoids.Items[item.name] = nil + CleveRoids.Items[string_lower(item.name)] = nil + end + InvalidateDisplacedItem() + return true + end + end + -- Verification failed - fall through to manual cursor-based equip + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[EquipLog] EquipItemByName('" .. item.name .. "', " .. invslot .. ") succeeded but item not in target slot, trying manual fallback|r") end - InvalidateDisplacedItem() - return true end end @@ -3634,15 +3693,15 @@ function CleveRoids.DoCastSequence(sequence) )) end - -- Check for "null" step - does nothing, allows macro to fall through - -- Usage: /castsequence reset=target Sunder Armor, null + -- Check for stop steps - does nothing, allows macro to fall through + -- Sequence stays on this step until a reset condition fires (target/combat/time/modifier) + -- Usage: /castsequence reset=target Sunder Armor, nil local actionLower = string.lower(CleveRoids.Trim(active.action)) - if actionLower == "null" or actionLower == "pass" or actionLower == "noop" then - -- Update sequence state (so per-target tracking works) + if actionLower == "nil" or actionLower == "null" or actionLower == "pass" or actionLower == "noop" then sequence.status = 0 sequence.lastUpdate = GetTime() if CleveRoids.debug then - DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[Sequence]|r Hit null step, falling through") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[Sequence]|r Hit stop step, falling through") end -- Don't cast anything, just return - allows macro to continue to next line return @@ -3840,7 +3899,7 @@ function CleveRoids.OnUpdate(self) -- Maintain legacy vars for any code that uses them directly if CleveRoids._posHistoryCount >= 2 then - local prevIdx = math.mod((idx - 2), 4) + 1 + local prevIdx = math.mod((idx - 2 + 4), 4) + 1 CleveRoids._previousPlayerPos = history[prevIdx] CleveRoids._currentPlayerPos = entry end @@ -5149,6 +5208,18 @@ function CleveRoids.Frame:SPELLCAST_CHANNEL_START() end end + -- v2.38+: Override channelDuration with the accurate server-provided value stored by + -- SPELL_START_SELF (which fires before this event). This beats the tooltip scan or + -- UNIT_CASTEVENT cast_time fallback, because the server already applied all modifiers. + if CleveRoids._v238ChannelDuration then + if not spellId or not CleveRoids._v238ChannelSpellId + or CleveRoids._v238ChannelSpellId == spellId then + CleveRoids.channelDuration = CleveRoids._v238ChannelDuration + end + CleveRoids._v238ChannelDuration = nil + CleveRoids._v238ChannelSpellId = nil + end + -- Force immediate action update CleveRoids.TestForAllActiveActions() end @@ -5785,11 +5856,13 @@ SlashCmdList["CLEVEROID"] = function(msg) CleveRoids.Print("Current Settings:") DEFAULT_CHAT_FRAME:AddMessage("realtime (force fast updates, CPU intensive) = " .. CleveRoidMacros.realtime .. " (Default: 0)") DEFAULT_CHAT_FRAME:AddMessage("refresh (updates per second) = " .. CleveRoidMacros.refresh .. " (Default: 5)") + DEFAULT_CHAT_FRAME:AddMessage("macrocheck (syntax checker) = " .. CleveRoidMacros.macrocheck .. " (Default: 1)") DEFAULT_CHAT_FRAME:AddMessage("debug (show learning messages) = " .. (CleveRoids.debug and "1" or "0") .. " (Default: 0)") DEFAULT_CHAT_FRAME:AddMessage(" ") CleveRoids.Print("Available Commands:") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid realtime 0 or 1 - Force realtime updates") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid refresh X - Set refresh rate (1-10 updates/sec)") + DEFAULT_CHAT_FRAME:AddMessage("/cleveroid macrocheck 0 or 1 - Enable/disable macro syntax checker") if CleveRoids.hasSuperwow then DEFAULT_CHAT_FRAME:AddMessage("/cleveroid learn - Manually set spell duration") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid forget - Forget learned duration(s)") @@ -5824,6 +5897,8 @@ SlashCmdList["CLEVEROID"] = function(msg) DEFAULT_CHAT_FRAME:AddMessage('/cleveroid tankdebug - Debug tank targeting conditionals') DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00All-Caster Aura Tracking:|r") DEFAULT_CHAT_FRAME:AddMessage('/cleveroid auradebug - Debug buff/debuff time tracking from all casters') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Overflow Buff Frame:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid overflowtest - Toggle overflow buff frame test mode') return end @@ -5854,6 +5929,23 @@ SlashCmdList["CLEVEROID"] = function(msg) return end + -- macrocheck + if cmd == "macrocheck" then + local num = tonumber(val) + if num == 0 or num == 1 then + CleveRoidMacros.macrocheck = num + if num == 1 then + CleveRoids.Print("Macro syntax checker |cff00ff00enabled|r") + else + CleveRoids.Print("Macro syntax checker |cffff0000disabled|r (reload UI to take effect)") + end + else + CleveRoids.Print("Usage: /cleveroid macrocheck 0 or 1 - Enable/disable macro syntax checker (Default: 1)") + CleveRoids.Print("Current macrocheck = " .. tostring(CleveRoidMacros.macrocheck)) + end + return + end + -- learn (manual set duration) if cmd == "learn" then if not CleveRoids.hasSuperwow then @@ -6559,6 +6651,22 @@ SlashCmdList["CLEVEROID"] = function(msg) return end + -- overflowtest (toggle overflow buff frame test mode) + if cmd == "overflowtest" then + if CleveRoids.ToggleOverflowTest then + CleveRoids.ToggleOverflowTest() + else + CleveRoids.Print("|cffff0000OverflowBuffFrame extension not loaded|r") + end + return + end + + -- Delegate to extension console handlers (chain pattern) + if CleveRoids.HandleConsoleCommand then + CleveRoids.HandleConsoleCommand(msg) + return + end + -- Unknown command fallback CleveRoids.Print("Usage:") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid - Show current settings") diff --git a/Extensions/MacroErrorUI.lua b/Extensions/MacroErrorUI.lua index b2304db..29b28ea 100644 --- a/Extensions/MacroErrorUI.lua +++ b/Extensions/MacroErrorUI.lua @@ -29,11 +29,21 @@ local pendingValidation = false local lastSelectedMacro = nil local updateFrame = nil local hooked = false +local measureFs = nil -- hidden FontString for measuring text width (wrap detection) -- Cache for current errors (avoids re-validation on every frame) local currentErrors = nil local nameHighlight = nil -- Yellow backdrop behind macro name when name has errors +-- Whitelist GUI state +local whitelistButton = nil +local whitelistPopup = nil +local WHITELIST_ROW_POOL_SIZE = 8 +local whitelistRows = {} -- pre-allocated label+remove rows + +-- Forward declarations (defined later, referenced by whitelist GUI) +local RequestValidation + -- ============================================================================ -- Error Panel (Option A) -- ============================================================================ @@ -45,7 +55,7 @@ local function CreateErrorPanel() -- Anchor to the scroll frame (text edit area) instead of the full MacroFrame, -- which can extend far below the visible UI on extended macro clients local scrollRef = MacroFrameScrollFrame or MacroFrame - panel:SetPoint("TOPLEFT", scrollRef, "BOTTOMLEFT", 0, -27) + panel:SetPoint("TOPLEFT", scrollRef, "BOTTOMLEFT", -20, -27) panel:SetPoint("TOPRIGHT", scrollRef, "BOTTOMRIGHT", 0, -27) panel:SetHeight(1) panel:SetFrameStrata("DIALOG") @@ -186,18 +196,61 @@ local function UpdateLineHighlights(errors) local editWidth = MacroFrameText:GetWidth() if editWidth < 10 then editWidth = 260 end -- fallback + -- Create measurement FontString lazily (same font as EditBox, hidden) + if not measureFs then + measureFs = (MacroFrame or UIParent):CreateFontString(nil, "OVERLAY") + measureFs:Hide() + end + local fontPath, fontSize, fontFlags = MacroFrameText:GetFont() + if fontPath then + measureFs:SetFont(fontPath, fontSize, fontFlags) + end + + -- Split macro text into logical lines for wrap calculation + local text = MacroFrameText:GetText() or "" + local logicalLines = {} + local lineStart = 1 + while true do + local nlPos = string.find(text, "\n", lineStart, true) + if nlPos then + table.insert(logicalLines, string.sub(text, lineStart, nlPos - 1)) + lineStart = nlPos + 1 + else + table.insert(logicalLines, string.sub(text, lineStart)) + break + end + end + + -- Calculate visual line count for each logical line + local visualCounts = {} + for i, line in ipairs(logicalLines) do + if line == "" or editWidth <= 0 then + visualCounts[i] = 1 + else + measureFs:SetText(line) + local textWidth = measureFs:GetStringWidth() + visualCounts[i] = math.max(1, math.ceil(textWidth / editWidth)) + end + end + local highlightIdx = 1 for lineNum, _ in pairs(errorLines) do if highlightIdx > HIGHLIGHT_POOL_SIZE then break end local tex = lineHighlights[highlightIdx] - -- Position: top text inset + (lineNum-1) lines down - local yOffset = -topInset - ((lineNum - 1) * lineHeight) + + -- Y offset: sum visual lines of all preceding logical lines + local yLines = 0 + for i = 1, lineNum - 1 do + yLines = yLines + (visualCounts[i] or 1) + end + local yOffset = -topInset - (yLines * lineHeight) + local highlightHeight = (visualCounts[lineNum] or 1) * lineHeight tex:ClearAllPoints() tex:SetPoint("TOPLEFT", MacroFrameText, "TOPLEFT", -2, yOffset) tex:SetWidth(editWidth + 4) - tex:SetHeight(lineHeight) + tex:SetHeight(highlightHeight) tex:Show() highlightIdx = highlightIdx + 1 @@ -246,6 +299,300 @@ local function UpdateNameHighlight(hasNameErrors) end end +-- ============================================================================ +-- Command Whitelist GUI +-- ============================================================================ + +local function RefreshWhitelistDisplay() + if not whitelistPopup then return end + + local commands = CleveRoids.GetWhitelistedCommandsList() + local count = table.getn(commands) + + -- Update rows + for i = 1, WHITELIST_ROW_POOL_SIZE do + local row = whitelistRows[i] + if row then + if i <= count then + row.label:SetText(commands[i]) + row.frame:Show() + else + row.label:SetText("") + row.frame:Hide() + end + end + end + + -- Show overflow indicator + local overflowText = whitelistPopup.overflowText + if count > WHITELIST_ROW_POOL_SIZE then + overflowText:SetText("|cff888888... and " .. (count - WHITELIST_ROW_POOL_SIZE) .. " more|r") + overflowText:Show() + else + overflowText:SetText("") + overflowText:Hide() + end + + -- Dynamic height: title(20) + gap(8) + editbox(18) + rows(18 each) + padding + local rowsShown = count + if rowsShown > WHITELIST_ROW_POOL_SIZE then rowsShown = WHITELIST_ROW_POOL_SIZE end + local height = 8 + 20 + 8 + 18 + 2 + (rowsShown * 18) + if count > WHITELIST_ROW_POOL_SIZE then + height = height + 14 + end + height = height + 8 -- bottom padding + whitelistPopup:SetHeight(height) + + -- Re-trigger error checking so results update in real time + RequestValidation() +end + +local function CreateWhitelistPopup() + if whitelistPopup then return end + + local popup = CreateFrame("Frame", "CleveRoidsWhitelistPopup", UIParent) + popup:SetWidth(220) + popup:SetHeight(120) + popup:SetPoint("CENTER", UIParent, "CENTER", 0, 100) + popup:SetFrameStrata("FULLSCREEN_DIALOG") + popup:SetMovable(true) + popup:EnableMouse(true) + + -- pfUI pixel-perfect flat style + popup:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Buttons\\WHITE8x8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 }, + }) + popup:SetBackdropColor(0.08, 0.08, 0.1, 0.95) + popup:SetBackdropBorderColor(0.2, 0.2, 0.2, 1) + popup:Hide() + + -- Drag handling + popup:RegisterForDrag("LeftButton") + popup:SetScript("OnDragStart", function() + this:StartMoving() + end) + popup:SetScript("OnDragStop", function() + this:StopMovingOrSizing() + end) + + -- Title + local title = popup:CreateFontString(nil, "OVERLAY") + title:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE") + title:SetPoint("TOPLEFT", popup, "TOPLEFT", 8, -8) + title:SetText("Whitelisted Commands") + title:SetTextColor(0.9, 0.8, 0.5, 1) + + -- Close button (flat X) + local closeBtn = CreateFrame("Button", nil, popup) + closeBtn:SetWidth(16) + closeBtn:SetHeight(16) + closeBtn:SetPoint("TOPRIGHT", popup, "TOPRIGHT", -4, -4) + local closeLbl = closeBtn:CreateFontString(nil, "OVERLAY") + closeLbl:SetFont("Fonts\\FRIZQT__.TTF", 12, "OUTLINE") + closeLbl:SetPoint("CENTER", 0, 0) + closeLbl:SetText("x") + closeLbl:SetTextColor(0.6, 0.6, 0.6, 1) + closeBtn:SetScript("OnEnter", function() + closeLbl:SetTextColor(1, 0.3, 0.3, 1) + end) + closeBtn:SetScript("OnLeave", function() + closeLbl:SetTextColor(0.6, 0.6, 0.6, 1) + end) + closeBtn:SetScript("OnClick", function() + popup:Hide() + end) + + -- EditBox (flat style) + local editBox = CreateFrame("EditBox", "CleveRoidsWhitelistEditBox", popup) + editBox:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -8) + editBox:SetWidth(148) + editBox:SetHeight(18) + editBox:SetAutoFocus(false) + editBox:SetMaxLetters(60) + editBox:SetFont("Fonts\\FRIZQT__.TTF", 10, "") + editBox:SetTextColor(1, 1, 1, 1) + editBox:SetTextInsets(4, 4, 0, 0) + editBox:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Buttons\\WHITE8x8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 }, + }) + editBox:SetBackdropColor(0.05, 0.05, 0.07, 0.9) + editBox:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + + -- Add button (flat style) + local addBtn = CreateFrame("Button", nil, popup) + addBtn:SetPoint("LEFT", editBox, "RIGHT", 4, 0) + addBtn:SetWidth(40) + addBtn:SetHeight(18) + addBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Buttons\\WHITE8x8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 }, + }) + addBtn:SetBackdropColor(0.15, 0.15, 0.15, 0.9) + addBtn:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + local addLbl = addBtn:CreateFontString(nil, "OVERLAY") + addLbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + addLbl:SetPoint("CENTER", 0, 0) + addLbl:SetText("Add") + addLbl:SetTextColor(0.8, 0.8, 0.8, 1) + addBtn:SetScript("OnEnter", function() + this:SetBackdropColor(0.25, 0.25, 0.25, 0.95) + this:SetBackdropBorderColor(0.45, 0.45, 0.5, 1) + addLbl:SetTextColor(1, 1, 1, 1) + end) + addBtn:SetScript("OnLeave", function() + this:SetBackdropColor(0.15, 0.15, 0.15, 0.9) + this:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + addLbl:SetTextColor(0.8, 0.8, 0.8, 1) + end) + + local function AddCommand() + local text = editBox:GetText() + if not text or CleveRoids.Trim(text) == "" then return end + text = CleveRoids.Trim(text) + CleveRoids.AddWhitelistedCommand(text) + editBox:SetText("") + editBox:ClearFocus() + RefreshWhitelistDisplay() + end + + addBtn:SetScript("OnClick", AddCommand) + editBox:SetScript("OnEnterPressed", function() + AddCommand() + end) + editBox:SetScript("OnEscapePressed", function() + this:ClearFocus() + end) + + -- Anchor for rows: below the editbox row + local rowAnchor = editBox + + -- Pre-allocate row pool + for i = 1, WHITELIST_ROW_POOL_SIZE do + local rowFrame = CreateFrame("Frame", nil, popup) + rowFrame:SetHeight(16) + rowFrame:SetPoint("TOPLEFT", rowAnchor, "BOTTOMLEFT", 0, -2) + rowFrame:SetPoint("RIGHT", popup, "RIGHT", -8, 0) + rowFrame:Hide() + + local label = rowFrame:CreateFontString(nil, "OVERLAY") + label:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + label:SetPoint("LEFT", rowFrame, "LEFT", 2, 0) + label:SetJustifyH("LEFT") + label:SetTextColor(0.7, 0.7, 0.7, 1) + + -- Flat X remove button + local removeBtn = CreateFrame("Button", nil, rowFrame) + removeBtn:SetWidth(14) + removeBtn:SetHeight(14) + removeBtn:SetPoint("RIGHT", rowFrame, "RIGHT", -1, 0) + local removeLbl = removeBtn:CreateFontString(nil, "OVERLAY") + removeLbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + removeLbl:SetPoint("CENTER", 0, 0) + removeLbl:SetText("x") + removeLbl:SetTextColor(0.5, 0.5, 0.5, 1) + removeBtn.removeLbl = removeLbl + removeBtn.rowIndex = i + + removeBtn:SetScript("OnEnter", function() + this.removeLbl:SetTextColor(1, 0.3, 0.3, 1) + end) + removeBtn:SetScript("OnLeave", function() + this.removeLbl:SetTextColor(0.5, 0.5, 0.5, 1) + end) + removeBtn:SetScript("OnClick", function() + local idx = this.rowIndex + local row = whitelistRows[idx] + if row and row.label then + local cmd = row.label:GetText() + if cmd and cmd ~= "" then + CleveRoids.RemoveWhitelistedCommand(cmd) + RefreshWhitelistDisplay() + end + end + end) + + whitelistRows[i] = { frame = rowFrame, label = label, removeBtn = removeBtn } + rowAnchor = rowFrame + end + + -- Overflow text + local overflowText = popup:CreateFontString(nil, "OVERLAY") + overflowText:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE") + overflowText:SetPoint("TOPLEFT", whitelistRows[WHITELIST_ROW_POOL_SIZE].frame, "BOTTOMLEFT", 2, -2) + overflowText:SetJustifyH("LEFT") + overflowText:SetTextColor(0.5, 0.5, 0.5, 1) + overflowText:Hide() + popup.overflowText = overflowText + + whitelistPopup = popup +end + +local function CreateWhitelistButton() + if whitelistButton then return end + if not MacroFrame then return end + + local btn = CreateFrame("Button", "CleveRoidsWhitelistButton", MacroFrame) + btn:SetWidth(80) + btn:SetHeight(18) + btn:SetPoint("BOTTOMRIGHT", MacroFrame, "BOTTOMRIGHT", 8, 52) + btn:SetFrameStrata("DIALOG") + + -- pfUI pixel-perfect flat style + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Buttons\\WHITE8x8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 }, + }) + btn:SetBackdropColor(0.1, 0.1, 0.1, 0.85) + btn:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + + local label = btn:CreateFontString(nil, "OVERLAY") + label:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") + label:SetPoint("CENTER", btn, "CENTER", 0, 0) + label:SetText("Whitelist") + label:SetTextColor(0.8, 0.8, 0.8, 1) + btn.label = label + + -- Hover highlight + btn:SetScript("OnEnter", function() + this:SetBackdropColor(0.2, 0.2, 0.2, 0.95) + this:SetBackdropBorderColor(0.45, 0.45, 0.5, 1) + this.label:SetTextColor(1, 1, 1, 1) + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:SetText("Command Whitelist") + GameTooltip:AddLine("Add third-party addon commands so the", 1, 1, 1, true) + GameTooltip:AddLine("error checker stops flagging them.", 1, 1, 1, true) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + this:SetBackdropColor(0.1, 0.1, 0.1, 0.85) + this:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) + this.label:SetTextColor(0.8, 0.8, 0.8, 1) + GameTooltip:Hide() + end) + + btn:SetScript("OnClick", function() + CreateWhitelistPopup() + if whitelistPopup:IsVisible() then + whitelistPopup:Hide() + else + RefreshWhitelistDisplay() + whitelistPopup:Show() + end + end) + + whitelistButton = btn +end + -- ============================================================================ -- Validation & Debounce -- ============================================================================ @@ -382,7 +729,7 @@ local function RunValidation() UpdateNameHighlight(hasNameErrors) end -local function RequestValidation() +RequestValidation = function() lastKeystroke = GetTime() pendingValidation = true end @@ -563,6 +910,10 @@ local function ClearAll() if nameHighlight then nameHighlight:Hide() end + + if whitelistPopup then + whitelistPopup:Hide() + end end -- ============================================================================ @@ -591,8 +942,9 @@ local function InstallHooks() if origOnShow then origOnShow() end - -- Create panel lazily on first show + -- Create UI lazily on first show CreateErrorPanel() + CreateWhitelistButton() -- Reset state and validate lastSelectedMacro = MacroFrame.selectedMacro RunValidation() @@ -633,6 +985,9 @@ function Extension.OnAddonLoaded() end function Extension.OnLoad() + -- Skip if macro checker is disabled + if CleveRoidMacros and CleveRoidMacros.macrocheck == 0 then return end + -- Skip if SuperMacro is loaded (detected at load time) if SuperMacroFrame ~= nil then return end diff --git a/Extensions/OverflowBuffFrame.lua b/Extensions/OverflowBuffFrame.lua new file mode 100644 index 0000000..e921c92 --- /dev/null +++ b/Extensions/OverflowBuffFrame.lua @@ -0,0 +1,635 @@ +--[[ + OverflowBuffFrame Extension + Two frames displaying server-side overflow buffs (no client aura slot) for + the player and the current target (if a group/party member). + Player data: CleveRoids.OverflowBuffs (AURA_CAST_ON_SELF when buff-capped). + Target data: CleveRoids.AllCasterAuraTracking filtered by UnitBuff visibility. + Each frame shows 2 rows of 8 icons = 16 slots. + + Author: Mewtiny + License: MIT License +]] +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +-- ============================================================================ +-- Constants +-- ============================================================================ +local ICON_SIZE = 24 +local ICON_SPACING = 2 +local ICONS_PER_ROW = 8 +local NUM_ROWS = 2 +local ICONS_PER_FRAME = ICONS_PER_ROW * NUM_ROWS -- 16 +local ROW_SPACING = 16 -- vertical gap between rows (icon + duration text) +local DURATION_FONT_SIZE = 10 +local DURATION_OFFSET_Y = -3 +local UPDATE_INTERVAL = 0.2 +local LABEL_HEIGHT = 12 + +-- ============================================================================ +-- State +-- ============================================================================ +local playerFrame = nil +local targetFrame = nil +local playerIcons = {} -- [1..16] +local targetIcons = {} -- [1..16] +local playerLabelFs = nil +local targetLabelFs = nil +local updateFrame = nil +local updateElapsed = 0 +local testMode = false +local dirty = false -- Flag for deferred rebuild (avoids event ordering race) +local lastPlayerCount = 0 +local lastTargetCount = 0 + +-- ============================================================================ +-- Duration Formatting (matches pfUI api.lua:1324 style) +-- ============================================================================ +local function FormatDuration(seconds) + if not seconds or seconds <= 0 then return "" end + if seconds >= 100 then + return math.floor(seconds / 60 + 0.5) .. "m" + elseif seconds > 5 then + return math.floor(seconds + 0.5) .. "s" + else + return string.format("%.1f", seconds) + end +end + +-- Forward declarations (needed by OnClick closures in CreateIconButton) +local RebuildFrame + +-- ============================================================================ +-- Icon Button Creation +-- ============================================================================ +local function CreateIconButton(parent, index, iconTable) + local btn = CreateFrame("Button", nil, parent) + btn:SetWidth(ICON_SIZE) + btn:SetHeight(ICON_SIZE) + btn:RegisterForClicks("RightButtonUp") + + -- Dark 1px border backdrop (pfUI style) + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8x8", + edgeFile = "Interface\\Buttons\\WHITE8x8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 }, + }) + btn:SetBackdropColor(0, 0, 0, 0.6) + btn:SetBackdropBorderColor(0.1, 0.1, 0.1, 0.9) + + -- Icon texture (inset by 1px for border) + local icon = btn:CreateTexture(nil, "ARTWORK") + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 1, -1) + icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -1, 1) + icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + icon:Hide() + + -- Duration text below icon + local dur = btn:CreateFontString(nil, "OVERLAY") + dur:SetFont("Fonts\\FRIZQT__.TTF", DURATION_FONT_SIZE, "OUTLINE") + dur:SetPoint("TOP", btn, "BOTTOM", 0, DURATION_OFFSET_Y) + dur:SetJustifyH("CENTER") + dur:SetTextColor(1, 1, 1, 1) + dur:SetText("") + + -- Right-click to cancel player overflow buffs + btn:SetScript("OnClick", function() + local data = iconTable[index] + if not data or not data.spellId then return end + if data.source ~= "player" then return end + + -- CancelPlayerAuraSpellId(spellId, ignoreMissing) + -- ignoreMissing=1 is required for overflow buffs that have no client aura slot + if CancelPlayerAuraSpellId then + CancelPlayerAuraSpellId(data.spellId, 1) + end + + -- Remove from overflow tracking + if CleveRoids.OverflowBuffs then + CleveRoids.OverflowBuffs[data.spellId] = nil + end + + -- Hide tooltip and rebuild + GameTooltip:Hide() + RebuildFrame() + end) + + -- Tooltip + btn:SetScript("OnEnter", function() + local data = iconTable[index] + if not data or not data.spellId then return end + GameTooltip:SetOwner(btn, "ANCHOR_BOTTOMLEFT") + local spellName = SpellInfo and SpellInfo(data.spellId) or ("Spell " .. data.spellId) + if spellName then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + GameTooltip:AddLine(baseName, 1, 1, 1) + end + if data.remaining and data.remaining > 0 then + GameTooltip:AddLine(FormatDuration(data.remaining) .. " remaining", 0.7, 0.7, 0.7) + end + if data.source == "player" then + GameTooltip:AddLine("Right-click to cancel", 0.8, 0.8, 0.5) + else + GameTooltip:AddLine("Overflow buff (target)", 0.5, 1.0, 0.8) + end + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + btn:Hide() + + return { + button = btn, + icon = icon, + duration = dur, + spellId = nil, + remaining = nil, + source = nil, + } +end + +-- ============================================================================ +-- Frame Creation +-- ============================================================================ + +-- Compute frame dimensions +local FRAME_WIDTH = ICONS_PER_ROW * (ICON_SIZE + ICON_SPACING) - ICON_SPACING +local FRAME_HEIGHT = LABEL_HEIGHT + NUM_ROWS * (ICON_SIZE + ROW_SPACING) + +local function CreateBuffFrame(name, savedPosKey, defaultY, labelColor, iconTable) + local frame = CreateFrame("Frame", name, UIParent) + frame:SetWidth(FRAME_WIDTH) + frame:SetHeight(FRAME_HEIGHT) + frame:SetPoint("TOP", UIParent, "TOP", 0, defaultY) + frame:SetFrameStrata("MEDIUM") + frame:SetMovable(true) + frame:EnableMouse(false) + frame:Hide() + + -- Section label + local label = frame:CreateFontString(nil, "OVERLAY") + label:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE") + label:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + label:SetTextColor(labelColor[1], labelColor[2], labelColor[3], 0.8) + label:SetText("") + + -- Create 16 icon slots in 2 rows of 8 + for i = 1, ICONS_PER_FRAME do + iconTable[i] = CreateIconButton(frame, i, iconTable) + local row = math.floor((i - 1) / ICONS_PER_ROW) + local col = math.mod(i - 1, ICONS_PER_ROW) + local xOff = col * (ICON_SIZE + ICON_SPACING) + local yOff = -(LABEL_HEIGHT + row * (ICON_SIZE + ROW_SPACING)) + iconTable[i].button:SetPoint("TOPLEFT", frame, "TOPLEFT", xOff, yOff) + end + + -- Shift+drag to move + frame:SetScript("OnMouseDown", function() + if IsShiftKeyDown() then + frame:StartMoving() + end + end) + frame:SetScript("OnMouseUp", function() + frame:StopMovingOrSizing() + local _, _, _, x, y = frame:GetPoint(1) + CleveRoidMacros = CleveRoidMacros or {} + CleveRoidMacros[savedPosKey] = { x = x, y = y } + end) + + -- Restore saved position + CleveRoidMacros = CleveRoidMacros or {} + if CleveRoidMacros[savedPosKey] then + frame:ClearAllPoints() + frame:SetPoint("TOP", UIParent, "TOP", + CleveRoidMacros[savedPosKey].x or 0, + CleveRoidMacros[savedPosKey].y or defaultY) + end + + return frame, label +end + +-- ============================================================================ +-- Data Collection +-- ============================================================================ + +local function GetPlayerOverflowBuffs() + local results = {} + local overflowBuffs = CleveRoids.OverflowBuffs + if not overflowBuffs then return results end + + local now = GetTime() + for spellId, entry in pairs(overflowBuffs) do + if entry.timestamp and entry.durationSec then + local remaining = entry.durationSec - (now - entry.timestamp) + if remaining > 0 then + table.insert(results, { + spellId = spellId, + remaining = remaining, + source = "player", + }) + end + end + end + + table.sort(results, function(a, b) return a.remaining > b.remaining end) + return results +end + +local function GetTargetOverflowBuffs() + local results = {} + + if not UnitExists("target") then return results end + if not testMode and not (UnitInParty("target") or UnitInRaid("target")) then return results end + + local _, targetGuid = UnitExists("target") + if not targetGuid then return results end + + local trackingData = CleveRoids.AllCasterAuraTracking + if not trackingData or not trackingData[targetGuid] then return results end + + -- Build set of visible buff textures on target + local visibleBuffs = {} + for i = 1, 32 do + local texture = UnitBuff("target", i) + if not texture then break end + visibleBuffs[texture] = true + end + + local now = GetTime() + local lib = CleveRoids.libdebuff + + for spellId, auraData in pairs(trackingData[targetGuid]) do + if auraData.start and auraData.duration then + local remaining = auraData.duration + auraData.start - now + if remaining > 0 then + local isVisible = false + if not auraData._testEntry then + local spellIcon = lib and lib:GetCachedIcon(spellId) + isVisible = spellIcon and visibleBuffs[spellIcon] + end + if not isVisible then + table.insert(results, { + spellId = spellId, + remaining = remaining, + source = "target", + }) + end + end + end + end + + table.sort(results, function(a, b) return a.remaining > b.remaining end) + return results +end + +-- ============================================================================ +-- Layout +-- ============================================================================ + +local function PopulateFrame(frame, label, labelText, iconTable, buffs, countRef) + local count = table.getn(buffs) + if count > ICONS_PER_FRAME then count = ICONS_PER_FRAME end + + local lib = CleveRoids.libdebuff + + for i = 1, count do + local data = iconTable[i] + local buff = buffs[i] + + local texture = lib and lib:GetCachedIcon(buff.spellId) + if texture then + data.icon:SetTexture(texture) + else + data.icon:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") + end + data.icon:Show() + + data.spellId = buff.spellId + data.remaining = buff.remaining + data.source = buff.source + data.duration:SetText(FormatDuration(buff.remaining)) + data.button:EnableMouse(true) + data.button:Show() + end + + -- Hide unused + for i = count + 1, ICONS_PER_FRAME do + local data = iconTable[i] + data.button:Hide() + data.icon:Hide() + data.duration:SetText("") + data.spellId = nil + data.remaining = nil + data.source = nil + end + + if count > 0 then + label:SetText(labelText) + frame:EnableMouse(true) + frame:Show() + else + label:SetText("") + frame:EnableMouse(false) + frame:Hide() + end + + return count +end + +RebuildFrame = function() + if not playerFrame then return end + + local pBuffs = GetPlayerOverflowBuffs() + lastPlayerCount = PopulateFrame(playerFrame, playerLabelFs, "Overflow (You)", playerIcons, pBuffs) + + local tBuffs = GetTargetOverflowBuffs() + local targetName = UnitExists("target") and UnitName("target") or "Target" + lastTargetCount = PopulateFrame(targetFrame, targetLabelFs, "Overflow (" .. targetName .. ")", targetIcons, tBuffs) +end + +-- Refresh only duration text (no full rebuild) +local function RefreshDurations() + local anyExpired = false + local now = GetTime() + + -- Player durations + if playerFrame and playerFrame:IsVisible() then + for i = 1, lastPlayerCount do + local data = playerIcons[i] + if data.spellId then + local entry = CleveRoids.OverflowBuffs and CleveRoids.OverflowBuffs[data.spellId] + if entry and entry.timestamp and entry.durationSec then + local remaining = entry.durationSec - (now - entry.timestamp) + if remaining > 0 then + data.remaining = remaining + data.duration:SetText(FormatDuration(remaining)) + else + anyExpired = true + end + else + anyExpired = true + end + end + end + end + + -- Target durations + if targetFrame and targetFrame:IsVisible() then + local targetGuid = nil + if UnitExists("target") then + local _, guid = UnitExists("target") + targetGuid = guid + end + for i = 1, lastTargetCount do + local data = targetIcons[i] + if data.spellId and targetGuid then + local remaining = CleveRoids.GetAllCasterAuraTimeRemaining(targetGuid, data.spellId) or 0 + if remaining > 0 then + data.remaining = remaining + data.duration:SetText(FormatDuration(remaining)) + else + anyExpired = true + end + else + anyExpired = true + end + end + end + + if anyExpired then + RebuildFrame() + end +end + +-- ============================================================================ +-- Test Mode +-- ============================================================================ + +local TEST_PLAYER_SPELL_IDS = { + 1126, -- Mark of the Wild + 1243, -- Power Word: Fortitude + 1461, -- Arcane Intellect + 14752, -- Divine Spirit + 976, -- Shadow Protection + 6307, -- Blood Pact + 20217, -- Blessing of Kings + 19740, -- Blessing of Might + 21849, -- Gift of the Wild + 25898, -- Greater Blessing of Kings + 25899, -- Greater Blessing of Sanctuary + 25890, -- Greater Blessing of Light + 10938, -- Greater Power Word: Fortitude + 10157, -- Greater Arcane Intellect + 20911, -- Blessing of Sanctuary + 25782, -- Greater Blessing of Might +} + +local TEST_TARGET_SPELL_IDS = { + 467, -- Thorns + 10060, -- Power Infusion + 17007, -- Leader of the Pack + 24932, -- Leader of the Pack aura + 19506, -- Trueshot Aura + 8936, -- Regrowth + 774, -- Rejuvenation + 139, -- Renew + 1022, -- Blessing of Protection + 6346, -- Fear Ward + 1044, -- Blessing of Freedom + 10958, -- Shadow Protection (rank) + 27681, -- Prayer of Spirit + 21562, -- Prayer of Fortitude + 20914, -- Blessing of Sanctuary (rank) + 25916, -- Greater Blessing of Wisdom +} + +local testTargetGuid = nil +local testStartTime = 0 -- when test mode was enabled (for duration calc) + +-- Remove test entries from a specific GUID in AllCasterAuraTracking +local function ClearTestTargetData(guid) + if not guid or not CleveRoids.AllCasterAuraTracking then return end + local targetData = CleveRoids.AllCasterAuraTracking[guid] + if not targetData then return end + for spellId, entry in pairs(targetData) do + if entry._testEntry then + targetData[spellId] = nil + end + end + if not next(targetData) then + CleveRoids.AllCasterAuraTracking[guid] = nil + end +end + +-- Inject test entries for the current target +local function InjectTestTargetData() + if not UnitExists("target") then return end + local _, targetGuid = UnitExists("target") + if not targetGuid then return end + + -- Clean up old test target if it changed + if testTargetGuid and testTargetGuid ~= targetGuid then + ClearTestTargetData(testTargetGuid) + end + + testTargetGuid = targetGuid + CleveRoids.AllCasterAuraTracking = CleveRoids.AllCasterAuraTracking or {} + if not CleveRoids.AllCasterAuraTracking[targetGuid] then + CleveRoids.AllCasterAuraTracking[targetGuid] = {} + end + + local targetDurations = { 8, 15, 25, 40, 55, 90, 150, 240, 400, 500, 700, 1000, 1300, 1600, 2000, 3000 } + for i = 1, table.getn(TEST_TARGET_SPELL_IDS) do + local spellId = TEST_TARGET_SPELL_IDS[i] + local dur = targetDurations[i] or 60 + CleveRoids.AllCasterAuraTracking[targetGuid][spellId] = { + start = testStartTime, + duration = dur, + casterGuid = "test", + _testEntry = true, + } + end +end + +local function EnableTestMode() + testMode = true + testStartTime = GetTime() + CleveRoids.OverflowBuffs = CleveRoids.OverflowBuffs or {} + + local playerDurations = { 5, 10, 30, 45, 60, 120, 180, 300, 450, 600, 900, 1200, 1500, 1800, 2400, 3600 } + + for i = 1, table.getn(TEST_PLAYER_SPELL_IDS) do + local spellId = TEST_PLAYER_SPELL_IDS[i] + local dur = playerDurations[i] or 60 + CleveRoids.OverflowBuffs[spellId] = { + timestamp = testStartTime, + durationSec = dur, + _testEntry = true, + } + end + + -- Inject target test data if we have a target + InjectTestTargetData() + + CleveRoids.Print("|cff00ff00Overflow buff frame test mode enabled|r") + CleveRoids.Print(" Shift+drag to reposition the frames") + + RebuildFrame() +end + +local function DisableTestMode() + testMode = false + + if CleveRoids.OverflowBuffs then + for spellId, entry in pairs(CleveRoids.OverflowBuffs) do + if entry._testEntry then + CleveRoids.OverflowBuffs[spellId] = nil + end + end + end + + ClearTestTargetData(testTargetGuid) + testTargetGuid = nil + + CleveRoids.Print("|cffff9900Overflow buff frame test mode disabled|r") + + RebuildFrame() +end + +local function ToggleTestMode() + if testMode then + DisableTestMode() + else + EnableTestMode() + end +end + +CleveRoids.ToggleOverflowTest = ToggleTestMode + +-- ============================================================================ +-- OnUpdate Handler +-- ============================================================================ +local function OnUpdate() + updateElapsed = updateElapsed + arg1 + -- Check dirty flag every frame for responsive rebuilds after AURA_CAST events. + -- By the time OnUpdate fires, the core handler has already updated OverflowBuffs. + if dirty then + dirty = false + updateElapsed = 0 + RebuildFrame() + return + end + if updateElapsed < UPDATE_INTERVAL then return end + updateElapsed = 0 + RefreshDurations() +end + +-- ============================================================================ +-- Extension Registration +-- ============================================================================ +local ext = CleveRoids.RegisterExtension("OverflowBuffFrame") + +ext.OnTargetChanged = function() + -- Re-inject test data for the new target during test mode + if testMode then + InjectTestTargetData() + end + RebuildFrame() +end + +ext.OnAuraCast = function() + -- Defer rebuild to OnUpdate: the core handler that populates OverflowBuffs + -- registers for AURA_CAST_ON_SELF later (PLAYER_ENTERING_WORLD) than this + -- extension (ADDON_LOADED), so it fires AFTER us. A direct RebuildFrame() + -- here would read stale data. + dirty = true +end + +ext.OnGroupChanged = function() + RebuildFrame() +end + +ext.OnPlayerDead = function() + if CleveRoids.OverflowBuffs then + if testMode then + for spellId, entry in pairs(CleveRoids.OverflowBuffs) do + if not entry._testEntry then + CleveRoids.OverflowBuffs[spellId] = nil + end + end + else + CleveRoids.OverflowBuffs = {} + end + end + RebuildFrame() +end + +ext.OnLoad = function() + playerFrame, playerLabelFs = CreateBuffFrame( + "CleveRoidsOverflowPlayer", "overflowPlayerPos", -100, + { 0.4, 0.7, 1.0 }, playerIcons) + + targetFrame, targetLabelFs = CreateBuffFrame( + "CleveRoidsOverflowTarget", "overflowTargetPos", -170, + { 0.4, 1.0, 0.7 }, targetIcons) + + ext.RegisterEvent("PLAYER_TARGET_CHANGED", "OnTargetChanged") + ext.RegisterEvent("PARTY_MEMBERS_CHANGED", "OnGroupChanged") + ext.RegisterEvent("RAID_ROSTER_UPDATE", "OnGroupChanged") + ext.RegisterEvent("PLAYER_DEAD", "OnPlayerDead") + + if CleveRoids.hasNampower then + ext.RegisterEvent("AURA_CAST_ON_SELF", "OnAuraCast") + end + + -- Shared OnUpdate frame + updateFrame = CreateFrame("Frame") + updateFrame:SetScript("OnUpdate", OnUpdate) + + RebuildFrame() +end + +_G["CleveRoids"] = CleveRoids diff --git a/Init.lua b/Init.lua index f6b2274..bf62857 100644 --- a/Init.lua +++ b/Init.lua @@ -42,7 +42,7 @@ CleveRoids.spell_tracking = {} -- Format: [casterGuid] = {spellID, spellName, icon, startTime, duration, endTime} CleveRoids.castTracking = {} --- pfUI 7.6+ with Nampower 2.27.2+ detected (GUID-based cast tracking available) +-- pfUI 7.6+ with Nampower 2.31.0+ detected (GUID-based cast tracking available) CleveRoids.hasPfUI76 = false -- Combo point tracking (initialized early for /cast hook) @@ -123,6 +123,8 @@ CleveRoids.dynamicCmds = { ["/equip12"] = true, ["/equip13"] = true, ["/equip14"] = true, + ["/applymain"] = true, + ["/applyoff"] = true, } -- Equipment swap queue system @@ -161,6 +163,7 @@ CleveRoids.ignoreKeywords = { action = true, ignoretooltip = true, cancelaura = true, + noSpam = true, -- ! prefix flag: prevent toggle-off at execution time _operators = true, -- Metadata for AND/OR operator tracking _groups = true, -- Grouped conditional values for AND/OR evaluation multiscan = true, -- Processed before Keywords loop (target resolution) @@ -321,6 +324,8 @@ initFrame:SetScript("OnEvent", function() CleveRoids.hasGetUnitData = API.features.hasGetUnitData CleveRoids.hasGetSpellModifiers = API.features.hasGetSpellModifiers CleveRoids.hasEnhancedSpellFunctions = API.features.hasEnhancedSpellFunctions + -- v2.37+: CastSpellByName supports unit token strings as 2nd param + CleveRoids.hasCastSpellByNameUnitToken = API.features.hasCastSpellByNameUnitToken end -- Initialize the API diff --git a/MacroErrorChecker.lua b/MacroErrorChecker.lua index d37a146..fbcf78e 100644 --- a/MacroErrorChecker.lua +++ b/MacroErrorChecker.lua @@ -422,6 +422,59 @@ local VALID_COMMANDS = { ["/yes"] = true, } +-- ============================================================================ +-- Command Whitelist API (for third-party addon commands) +-- ============================================================================ + +local function GetWhitelistedCommands() + if not CleveRoidMacros then CleveRoidMacros = {} end + if not CleveRoidMacros.whitelistedCommands then + CleveRoidMacros.whitelistedCommands = {} + end + return CleveRoidMacros.whitelistedCommands +end + +function CleveRoids.AddWhitelistedCommand(cmd) + if not cmd or cmd == "" then return end + cmd = string.lower(cmd) + -- Auto-prepend "/" if missing + if string.sub(cmd, 1, 1) ~= "/" then + cmd = "/" .. cmd + end + local whitelist = GetWhitelistedCommands() + whitelist[cmd] = true +end + +function CleveRoids.RemoveWhitelistedCommand(cmd) + if not cmd or cmd == "" then return end + cmd = string.lower(cmd) + if string.sub(cmd, 1, 1) ~= "/" then + cmd = "/" .. cmd + end + local whitelist = GetWhitelistedCommands() + whitelist[cmd] = nil +end + +function CleveRoids.IsWhitelistedCommand(cmd) + if not cmd or cmd == "" then return false end + cmd = string.lower(cmd) + if string.sub(cmd, 1, 1) ~= "/" then + cmd = "/" .. cmd + end + local whitelist = GetWhitelistedCommands() + return whitelist[cmd] == true +end + +function CleveRoids.GetWhitelistedCommandsList() + local whitelist = GetWhitelistedCommands() + local list = {} + for cmd, _ in pairs(whitelist) do + table.insert(list, cmd) + end + table.sort(list) + return list +end + -- Commands that can have conditionals without actions -- e.g., /petattack [harm] or /target [exists,hp:<=20] local COMMANDS_NO_ACTION_NEEDED = { @@ -679,7 +732,7 @@ local function validateLine(line, lineNum) local _, _, cmd = safeStringFind(line, "^(/[a-z]+%d*)") if cmd then local lowerCmd = string.lower(cmd) - if not VALID_COMMANDS[lowerCmd] then + if not VALID_COMMANDS[lowerCmd] and not CleveRoids.IsWhitelistedCommand(lowerCmd) then table.insert(localErrors, { type = ERROR_TYPES.INVALID_COMMAND, line = lineNum, @@ -1102,6 +1155,11 @@ end -- Slash command SLASH_MACROCHECK1 = "/macrocheck" SlashCmdList.MACROCHECK = function(msg) + if CleveRoidMacros and CleveRoidMacros.macrocheck == 0 then + CleveRoids.Print("Macro syntax checker is disabled. Enable with /cleveroid macrocheck 1") + return + end + local success, result = pcall(function() msg = safeTrim(msg or "") @@ -1117,4 +1175,6 @@ SlashCmdList.MACROCHECK = function(msg) end end -CleveRoids.Print("Macro syntax checker loaded. Use /macrocheck [macroname] or /macrocheck all") +if not CleveRoidMacros or CleveRoidMacros.macrocheck ~= 0 then + CleveRoids.Print("Macro syntax checker loaded. Use /macrocheck [macroname] or /macrocheck all") +end diff --git a/NampowerAPI.lua b/NampowerAPI.lua index 1a8aca2..6c2a7b1 100644 --- a/NampowerAPI.lua +++ b/NampowerAPI.lua @@ -85,7 +85,43 @@ - Reads from NampowerSettings addon when available - Falls back to CVars when addon not present - Current version: v2.33.0 + Aura Cancel Functions (v2.34+): + - CancelPlayerAuraSlot(auraSlot) - Cancel buff/debuff by raw 0-based aura slot + - CancelPlayerAuraSpellId(spellId, [ignoreMissing]) - Cancel buff/debuff by spell ID + Pass ignoreMissing=1 to skip aura-slot presence check (for server-side + overflow buffs that have no client aura slot in the 48-slot array) + + GUID String Fix & Talent Helper (v2.35+): + - GetUnitData/GetUnitField now return GUID-type fields (charm, summon, charmedBy, + summonedBy, createdBy, target, persuaded, channelObject) as hex strings + (e.g., "0x0600000012345678") instead of raw numbers to avoid Lua 64-bit precision loss + - LearnTalentRank(talentPage, talentIndex, rank) - Learn a specific talent rank directly + + Player State Functions (v2.36+): + - PlayerIsMoving() - Returns 1 if player is moving, nil if stationary + - PlayerIsRooted() - Returns 1 if player is rooted, nil if not + - PlayerIsSwimming() - Returns 1 if player is swimming, nil if not + + Unit Token Cast & Mouseover (v2.37+): + - CastSpellByName(spellName, unitToken) - 2nd param now accepts unit token strings + (e.g. "mouseover", "party1", "focus") in addition to GUIDs and 1 (self). + Enables soft-casting on unit tokens without changing the player's visible target. + - SetMouseoverUnit(unitToken) - Programmatically set the mouseover unit. + Previously only available via SuperWoW; now also provided by Nampower. + + Spell Duration & Enhanced SPELL_START (v2.38+): + - GetSpellDuration(spellId, [ignoreModifiers]) - Returns duration in ms. + For channeling spells: channel duration. For others: first aura effect duration. + Pass ignoreModifiers=1 to get base duration (ignores talents/buffs). + Returns 0 if spell has no duration, nil if spellId invalid. + - SPELL_START_SELF / SPELL_START_OTHER now include two new parameters: + arg7 = duration (ms) - channel duration for channeling spells, 0 otherwise + arg8 = spellType - 0=Normal, 1=Channeling, 2=Autorepeating + Use spellType to distinguish channeling spells (SPELL_START_OTHER carries both + cast-time and channeling spells since there is no separate SPELL_CHANNEL_START + packet for other players). + + Current version: v2.38.0 ]] local _G = _G or getfenv(0) @@ -242,6 +278,27 @@ API.VERSION_REQUIREMENTS = { -- v2.33+ - UPDATE_DURATION spellId parameter and GetCastInfo guid fix ["DurationEventSpellId"] = { 2, 33, 0 }, ["GetCastInfoGuidFix"] = { 2, 33, 0 }, + + -- v2.34+ - Aura cancel functions + ["CancelPlayerAuraSlot"] = { 2, 34, 0, "CancelPlayerAuraSlot" }, + ["CancelPlayerAuraSpellId"] = { 2, 34, 0, "CancelPlayerAuraSpellId" }, + + -- v2.35+ - GUID string fix and talent helper + ["GuidStringFormat"] = { 2, 35, 0 }, -- GetUnitData/GetUnitField return GUID fields as hex strings + ["LearnTalentRank"] = { 2, 35, 0, "LearnTalentRank" }, + + -- v2.36+ - Player state functions + ["PlayerIsMoving"] = { 2, 36, 0, "PlayerIsMoving" }, + ["PlayerIsRooted"] = { 2, 36, 0, "PlayerIsRooted" }, + ["PlayerIsSwimming"] = { 2, 36, 0, "PlayerIsSwimming" }, + + -- v2.37+ - Unit token cast support and SetMouseoverUnit + ["CastSpellByNameUnitToken"]= { 2, 37, 0 }, -- CastSpellByName accepts unit token strings as 2nd param + ["SetMouseoverUnit"] = { 2, 37, 0, "SetMouseoverUnit" }, + + -- v2.38+ - GetSpellDuration and enhanced SPELL_START parameters + ["GetSpellDuration"] = { 2, 38, 0, "GetSpellDuration" }, + ["SpellStartSpellType"] = { 2, 38, 0 }, -- duration + spellType params added to SPELL_START events } -- Check if a specific feature is available @@ -382,6 +439,27 @@ local function InitializeFeatures() f.hasDurationEventSpellId = API.HasFeature("DurationEventSpellId") f.hasGetCastInfoGuidFix = API.HasFeature("GetCastInfoGuidFix") + -- v2.34+ Aura cancel functions + f.hasCancelPlayerAuraSlot = API.HasFeature("CancelPlayerAuraSlot") + f.hasCancelPlayerAuraSpellId = API.HasFeature("CancelPlayerAuraSpellId") + + -- v2.35+ GUID string format and talent helper + f.hasGuidStringFormat = API.HasFeature("GuidStringFormat") + f.hasLearnTalentRank = API.HasFeature("LearnTalentRank") + + -- v2.36+ Player state functions + f.hasPlayerIsMoving = API.HasFeature("PlayerIsMoving") + f.hasPlayerIsRooted = API.HasFeature("PlayerIsRooted") + f.hasPlayerIsSwimming = API.HasFeature("PlayerIsSwimming") + + -- v2.37+ Unit token cast support and SetMouseoverUnit + f.hasCastSpellByNameUnitToken = API.HasFeature("CastSpellByNameUnitToken") + f.hasSetMouseoverUnit = API.HasFeature("SetMouseoverUnit") + + -- v2.38+ GetSpellDuration and enhanced SPELL_START parameters + f.hasGetSpellDuration = API.HasFeature("GetSpellDuration") + f.hasSpellStartSpellType = API.HasFeature("SpellStartSpellType") + -- Runtime detection for enhanced spell functions (verify by testing) if f.hasEnhancedSpellFunctions and GetSpellTexture then local success, result = pcall(function() @@ -2982,5 +3060,63 @@ function API.GetSpellPower(mode) return _G.GetSpellPower(mode) end +-- Get duration of a spell in milliseconds (v2.38+) +-- For channeling spells: returns the channel duration. +-- For non-channeling spells: returns the first aura effect duration. +-- spellId: spell ID to look up +-- ignoreModifiers: pass 1 to ignore talent/buff modifiers (returns base duration) +-- Returns: duration in ms (0 if spell has no duration), or nil if spellId invalid +function API.GetSpellDuration(spellId, ignoreModifiers) + if not API.features.hasGetSpellDuration or not _G.GetSpellDuration then + return nil + end + return _G.GetSpellDuration(spellId, ignoreModifiers) +end + +-------------------------------------------------------------------------------- +-- AURA CANCEL FUNCTIONS (v2.34+) +-------------------------------------------------------------------------------- + +-- Cancel a player aura by raw 0-based aura slot (v2.34+) +-- auraSlot: 0-31 for buffs, 32-47 for debuffs +function API.CancelPlayerAuraSlot(auraSlot) + if not API.features.hasCancelPlayerAuraSlot or not _G.CancelPlayerAuraSlot then + return false + end + _G.CancelPlayerAuraSlot(auraSlot) + return true +end + +-- Cancel a player aura by spell ID (v2.34+) +-- spellId: the spell ID to cancel +-- ignoreMissing: pass 1 to skip aura-slot presence check (useful for buff-capped hidden auras) +function API.CancelPlayerAuraSpellId(spellId, ignoreMissing) + if not API.features.hasCancelPlayerAuraSpellId or not _G.CancelPlayerAuraSpellId then + return false + end + _G.CancelPlayerAuraSpellId(spellId, ignoreMissing) + return true +end + +-------------------------------------------------------------------------------- +-- TALENT HELPER (v2.35+) +-------------------------------------------------------------------------------- + +-- Learn a specific talent rank directly (v2.35+) +-- talentPage: 1-3 (talent tab) +-- talentIndex: 1-32 (talent position within tab) +-- rank: 1-5 (rank to learn) +function API.LearnTalentRank(talentPage, talentIndex, rank) + if not API.features.hasLearnTalentRank or not _G.LearnTalentRank then + return false + end + if not talentPage or not talentIndex or not rank then return false end + if talentPage < 1 or talentPage > 3 then return false end + if talentIndex < 1 or talentIndex > 32 then return false end + if rank < 1 or rank > 5 then return false end + _G.LearnTalentRank(talentPage, talentIndex, rank) + return true +end + -- Expose API globally for other addons _G.CleveRoidsNampowerAPI = API diff --git a/README.md b/README.md index 299ab2d..6effe24 100644 --- a/README.md +++ b/README.md @@ -154,63 +154,56 @@ These always evaluate against the player. Cannot be redirected with `@unit`. | Conditional | Description | No | Multi | Example | NP | Addon | |-------------|-------------|:--:|:-----:|---------|:--:|-------| -| ***State & Modifiers*** | | | | | | | -| `mod` | Modifier key pressed | ✅ | ✅ | `[mod:alt/ctrl]` | | | -| `combat` | In combat (player default, or unit) | ✅ | ✅ | `[combat]` `[combat:target]` | | | -| `form` / `stance` | Shapeshift form or stance index | ✅ | ✅ | `[form:1/3]` `[stance:2]` | | | -| `stealth` | In stealth (Rogue/Druid) | ✅ | — | `[stealth]` | | | -| `group` | Player is in group type | ✅ | ✅ | `[group:party/raid]` | | | -| `resting` | In rest area | ✅ | — | `[resting]` | | | -| `swimming` | Swimming (Druid only) | ✅ | — | `[swimming]` | | | -| `moving` | Moving / speed % | ✅ | ✅ | `[moving]` `[moving:>100&<200]` | | MonkeySpeed (speed %) | -| `zone` | Current zone name | ✅ | ✅ | `[zone:Ironforge/Stormwind]` | | | -| ***Resources*** | | | | | | | -| `myhp` | Player HP % | — | ✅ | `[myhp:<30]` `[myhp:>20&<50]` | | | -| `myrawhp` | Player raw HP value | — | ✅ | `[myrawhp:>1000]` | | | -| `myhplost` | Player HP deficit (max - current) | — | ✅ | `[myhplost:>500]` | | | -| `mypower` | Player mana/rage/energy % | — | ✅ | `[mypower:>50]` | | | -| `myrawpower` | Player raw power value | — | ✅ | `[myrawpower:>=40]` | | | -| `mypowerlost` | Player power deficit | — | ✅ | `[mypowerlost:>200]` | | | -| `druidmana` | Druid mana while shapeshifted | — | ✅ | `[druidmana:>=500]` | | | -| `combo` | Combo points | ✅ | ✅ | `[combo:>=4]` | | | -| `stat` | Player stat value | ✅ | ✅ | `[stat:agi>100]` `[stat:ap>1000]` | | | -| `mylevel` | Player level | — | ✅ | `[mylevel:=60]` | | | -| ***Player Auras*** | | | | | | | -| `mybuff` | Player has buff (with time/stacks) | ✅ | ✅ | `[mybuff:Thorns<5]` `[nomybuff:MotW/GotW]` | | | -| `mydebuff` | Player has debuff (with time/stacks) | ✅ | ✅ | `[mydebuff:Curse_of_Agony]` | | | -| `mybuffcount` | Player buff slot count (32 max) | ✅ | ✅ | `[mybuffcount:>15]` `[nomybuffcount:>28]` | | | -| `mybuffcapped` | Player buff bar full (32) | ✅ | — | `[nomybuffcapped]` | v2.20 | | -| `mydebuffcapped` | Player debuff bar full (16) | ✅ | — | `[nomydebuffcapped]` | v2.20 | | -| `mycc` | Player has CC effect | ✅ | ✅ | `[mycc:stun/silence]` `[nomycc]` | | | -| ***Spells & Cooldowns*** | | | | | | | -| `cooldown` | Spell/item CD remaining (ignores GCD) | ✅ | ✅ | `[cooldown:Sprint<5]` | | | | `cdgcd` | Spell/item CD remaining (includes GCD) | ✅ | ✅ | `[cdgcd:Sprint>0]` | | | -| `gcd` | GCD is active / time remaining | ✅ | ✅ | `[gcd]` `[gcd:<1]` | | | -| `usable` | Spell/item is usable now | ✅ | ✅ | `[usable:Overpower]` | | | -| `reactive` | Reactive ability available (on bar) | ✅ | ✅ | `[reactive:Overpower]` | | | -| `known` | Spell/talent known (with rank) | ✅ | ✅ | `[known:Berserk]` `[known:Frostbolt>#2]` | | | -| `selfcasting` | Player is casting or channeling | ✅ | ✅ | `[selfcasting]` `[noselfcasting:Hearthstone]` | | | -| `checkcasting` | True if NOT casting (specific spell) | — | ✅ | `[checkcasting]` `[checkcasting:Frostbolt]` | | | | `channeled` | Player is channeling | ✅ | ✅ | `[channeled]` `[channeled:Arcane_Missiles]` | | | +| `checkcasting` | True if NOT casting (specific spell) | — | ✅ | `[checkcasting]` `[checkcasting:Frostbolt]` | | | | `checkchanneled` | True if NOT channeling (specific spell) | — | ✅ | `[checkchanneled]` `[checkchanneled:Evocation]` | | | -| `spellcasttime` | Spell cast time from tooltip (real-time) | ✅ | ✅ | `[spellcasttime:>1.5]` `[spellcasttime:Frostbolt<2]` | | | -| `queuedspell` | Spell is queued | ✅ | ✅ | `[queuedspell]` `[queuedspell:Fireball]` | v2.12 | | -| `onswingpending` | On-swing spell pending | ✅ | — | `[onswingpending]` | v2.12 | | -| ***Equipment*** | | | | | | | +| `combat` | In combat (player default, or unit) | ✅ | ✅ | `[combat]` `[combat:target]` | | | +| `combo` | Combo points | ✅ | ✅ | `[combo:>=4]` | | | +| `cooldown` | Spell/item CD remaining (ignores GCD) | ✅ | ✅ | `[cooldown:Sprint<5]` | | | +| `druidmana` | Druid mana while shapeshifted | — | ✅ | `[druidmana:>=500]` | | | | `equipped` | Item or weapon type equipped | ✅ | ✅ | `[equipped:Daggers/Swords]` | | | -| `mhimbue` | Main hand has temporary imbue | ✅ | ✅ | `[mhimbue:Instant_Poison<300]` `[mhimbue:>#5]` | | | -| `ohimbue` | Off hand has temporary imbue | ✅ | ✅ | `[noohimbue]` `[ohimbue:Crippling_Poison]` | | | -| `pet` | Has active pet (with family) | ✅ | ✅ | `[pet]` `[pet:Cat/Wolf]` | | | -| ***Melee Tracking*** | | | | | | | -| `lastswing` | Player melee swing type/timing | ✅ | ✅ | `[lastswing:dodge]` `[lastswing:<2]` | v2.24 | | -| `incominghit` | Incoming attack type/timing | ✅ | ✅ | `[incominghit:crushing]` `[noincominghit:crit]` | v2.24 | | -| `swingtimer` | Swing timer % elapsed | ✅ | ✅ | `[swingtimer:>80]` | | SP_SwingTimer | -| `stimer` | Alias for swingtimer | ✅ | ✅ | `[stimer:<15]` | | SP_SwingTimer | -| `slamclip` / `noslamclip` | Slam now will/won't clip auto-attack | ✅ | — | `[noslamclip]` | | SP_SwingTimer | -| `nextslamclip` / `nonextslamclip` | Instant now will/won't cause next Slam to clip | ✅ | — | `[nonextslamclip]` | | SP_SwingTimer | -| ***Target Existence*** | | | | | | | +| `form` / `stance` | Shapeshift form or stance index | ✅ | ✅ | `[form:1/3]` `[stance:2]` | | | +| `gcd` | GCD is active / time remaining | ✅ | ✅ | `[gcd]` `[gcd:<1]` | | | +| `group` | Player is in group type | ✅ | ✅ | `[group:party/raid]` | | | | `hastarget` | Player has a target | — | — | `[hastarget]` | | | +| `incominghit` | Incoming attack type/timing | ✅ | ✅ | `[incominghit:crushing]` `[noincominghit:crit]` | v2.24 | | +| `known` | Spell/talent known (with rank) | ✅ | ✅ | `[known:Berserk]` `[known:Frostbolt>#2]` | | | +| `lastswing` | Player melee swing type/timing | ✅ | ✅ | `[lastswing:dodge]` `[lastswing:<2]` | v2.24 | | +| `mhimbue` | Main hand has temporary imbue | ✅ | ✅ | `[mhimbue:Instant_Poison<300]` `[mhimbue:>#5]` | | | +| `mod` | Modifier key pressed | ✅ | ✅ | `[mod:alt/ctrl]` | | | +| `moving` | Moving / speed % | ✅ | ✅ | `[moving]` `[moving:>100&<200]` | | MonkeySpeed (speed %) | +| `mybuff` | Player has buff (with time/stacks) | ✅ | ✅ | `[mybuff:Thorns<5]` `[nomybuff:MotW/GotW]` | | | +| `mybuffcapped` | Player buff bar full (32) | ✅ | — | `[nomybuffcapped]` | v2.20 | | +| `mybuffcount` | Player buff slot count (32 max) | ✅ | ✅ | `[mybuffcount:>15]` `[nomybuffcount:>28]` | | | +| `mycc` | Player has CC effect | ✅ | ✅ | `[mycc:stun/silence]` `[nomycc]` | | | +| `mydebuff` | Player has debuff (with time/stacks) | ✅ | ✅ | `[mydebuff:Curse_of_Agony]` | | | +| `mydebuffcapped` | Player debuff bar full (16) | ✅ | — | `[nomydebuffcapped]` | v2.20 | | +| `myhp` | Player HP % | — | ✅ | `[myhp:<30]` `[myhp:>20&<50]` | | | +| `myhplost` | Player HP deficit (max - current) | — | ✅ | `[myhplost:>500]` | | | +| `mylevel` | Player level | — | ✅ | `[mylevel:=60]` | | | +| `mypower` | Player mana/rage/energy % | — | ✅ | `[mypower:>50]` | | | +| `mypowerlost` | Player power deficit | — | ✅ | `[mypowerlost:>200]` | | | +| `myrawhp` | Player raw HP value | — | ✅ | `[myrawhp:>1000]` | | | +| `myrawpower` | Player raw power value | — | ✅ | `[myrawpower:>=40]` | | | +| `nextslamclip` / `nonextslamclip` | Instant now will/won't cause next Slam to clip | ✅ | — | `[nonextslamclip]` | | SP_SwingTimer | | `notarget` | Player has no target | — | — | `[notarget]` | | | +| `ohimbue` | Off hand has temporary imbue | ✅ | ✅ | `[noohimbue]` `[ohimbue:Crippling_Poison]` | | | +| `onswingpending` | On-swing spell pending | ✅ | — | `[onswingpending]` | v2.12 | | +| `pet` | Has active pet (with family) | ✅ | ✅ | `[pet]` `[pet:Cat/Wolf]` | | | +| `queuedspell` | Spell is queued | ✅ | ✅ | `[queuedspell]` `[queuedspell:Fireball]` | v2.12 | | +| `reactive` | Reactive ability available (on bar) | ✅ | ✅ | `[reactive:Overpower]` | | | +| `resting` | In rest area | ✅ | — | `[resting]` | | | +| `selfcasting` | Player is casting or channeling | ✅ | ✅ | `[selfcasting]` `[noselfcasting:Hearthstone]` | | | +| `slamclip` / `noslamclip` | Slam now will/won't clip auto-attack | ✅ | — | `[noslamclip]` | | SP_SwingTimer | +| `spellcasttime` | Spell cast time from tooltip (real-time) | ✅ | ✅ | `[spellcasttime:>1.5]` `[spellcasttime:Frostbolt<2]` | | | +| `stat` | Player stat value | ✅ | ✅ | `[stat:agi>100]` `[stat:ap>1000]` | | | +| `stealth` | In stealth (Rogue/Druid) | ✅ | — | `[stealth]` | | | +| `stimer` | Alias for swingtimer | ✅ | ✅ | `[stimer:<15]` | | SP_SwingTimer | +| `swimming` | Swimming (Druid only) | ✅ | — | `[swimming]` | | | +| `swingtimer` | Swing timer % elapsed | ✅ | ✅ | `[swingtimer:>80]` | | SP_SwingTimer | +| `usable` | Spell/item is usable now | ✅ | ✅ | `[usable:Overpower]` | | | +| `zone` | Current zone name | ✅ | ✅ | `[zone:Ironforge/Stormwind]` | | | ### Target Conditionals (default @target) @@ -218,62 +211,53 @@ These default to checking the current target. Most can be redirected with `@unit | Conditional | Description | No | Multi | Example | NP | Addon | |-------------|-------------|:--:|:-----:|---------|:--:|-------| -| ***Existence & Disposition*** | | | | | | | -| `exists` | Unit exists | ✅ | — | `[@mouseover,exists]` | | | | `alive` | Unit is alive | ✅ | — | `[alive]` | | | -| `dead` | Unit is dead | ✅ | — | `[dead]` | | | -| `help` | Unit is friendly | ✅ | — | `[help]` | | | -| `harm` | Unit is hostile | ✅ | — | `[harm]` | | | -| `isplayer` | Unit is a player | ✅ | — | `[isplayer]` | | | -| `isnpc` | Unit is an NPC | ✅ | — | `[isnpc]` | | | -| ***Resources*** | | | | | | | -| `hp` | Target HP % | — | ✅ | `[hp:<20]` `[hp:>30&<70]` | | | -| `rawhp` | Target raw HP value | — | ✅ | `[rawhp:>5000]` | | | -| `hplost` | Target HP deficit (max - current) | — | ✅ | `[hplost:>1000]` | | | -| `power` | Target mana/rage/energy % | — | ✅ | `[power:<30]` | | | -| `rawpower` | Target raw power value | — | ✅ | `[rawpower:>500]` | | | -| `powerlost` | Target power deficit | — | ✅ | `[powerlost:>100]` | | | -| `powertype` | Target power type | ✅ | ✅ | `[powertype:mana/rage]` | | | -| `level` | Target level (skull = 63) | — | ✅ | `[level:>60]` | | | -| ***Classification*** | | | | | | | -| `class` | Target class (players only) | ✅ | ✅ | `[class:Warrior/Priest]` | | | -| `type` | Creature type | ✅ | ✅ | `[type:Undead/Beast]` | | | -| `name` | Exact name match (case-insensitive) | ✅ | ✅ | `[name:Onyxia]` `[noname:Critter/Dummy]` | | | -| ***Target Auras*** | | | | | | | +| `behind` | Behind target / count behind | ✅ | ✅ | `[behind]` `[behind:>=2]` | | UnitXP_SP3 | | `buff` | Target has buff (with time/stacks) | ✅ | ✅ | `[buff:Shield>#3]` `[nobuff:Renew/Rejuv]` | | | -| `debuff` | Target has debuff (with time/stacks) | ✅ | ✅ | `[debuff:Moonfire<4]` `[nodebuff:Rake]` | | | | `buffcapped` | Target buff bar full (32) | ✅ | — | `[nobuffcapped]` | v2.20 | | -| `debuffcapped` | Target debuff bar full | ✅ | — | `[nodebuffcapped]` | v2.20 | | -| `cc` | Target has CC effect | ✅ | ✅ | `[cc:stun/fear]` `[nocc:polymorph]` | | | -| `cursive` | GUID-based debuff tracking (time remaining) | ✅ | ✅ | `[cursive:Rake<3]` `[nocursive:Rip]` | | Cursive | -| ***Casting*** | | | | | | | | `casting` | Target is casting (specific spell) | ✅ | ✅ | `[casting]` `[casting:Heal]` | | | | `casttime` | Target cast time remaining (seconds) | ✅ | ✅ | `[casttime:<0.5]` | | | +| `cc` | Target has CC effect | ✅ | ✅ | `[cc:stun/fear]` `[nocc:polymorph]` | | | | `channeltime` | Target channel time remaining (seconds) | ✅ | ✅ | `[channeltime:<0.5]` | | | -| ***Relationship*** | | | | | | | -| `party` | Unit is in your party | ✅ | ✅ | `[party]` `[party:focus]` | | | -| `raid` | Unit is in your raid | ✅ | ✅ | `[raid]` `[raid:mouseover]` | | | -| `member` | Target is in party or raid | ✅ | — | `[member]` | | | -| `targeting` | Unit is targeting you or a tank | ✅ | ✅ | `[targeting:player]` `[notargeting:tank]` | | pfUI (for tank) | -| `istank` | Unit is marked as tank | ✅ | — | `[istank]` `[@focus,istank]` | | pfUI | -| `tag` | Target is tapped by anyone | ✅ | — | `[tag]` `[notag]` | | | -| `mytag` | Target is tapped by you | ✅ | — | `[mytag]` `[nomytag]` | | | -| `othertag` | Target is tapped by someone else | ✅ | — | `[othertag]` `[noothertag]` | | | -| ***Range & Position*** | | | | | | | +| `class` | Target class (players only) | ✅ | ✅ | `[class:Warrior/Priest]` | | | +| `cursive` | GUID-based debuff tracking (time remaining) | ✅ | ✅ | `[cursive:Rake<3]` `[nocursive:Rip]` | | Cursive | +| `dead` | Unit is dead | ✅ | — | `[dead]` | | | +| `debuff` | Target has debuff (with time/stacks) | ✅ | ✅ | `[debuff:Moonfire<4]` `[nodebuff:Rake]` | | | +| `debuffcapped` | Target debuff bar full | ✅ | — | `[nodebuffcapped]` | v2.20 | | | `distance` | Distance in yards | ✅ | ✅ | `[distance:<40]` | | UnitXP_SP3 | -| `meleerange` | In melee range / count in melee | ✅ | ✅ | `[meleerange]` `[meleerange:>1]` | | UnitXP_SP3 | -| `inrange` | In spell range / count in range | ✅ | ✅ | `[inrange:Charge]` `[inrange:Multi-Shot>1]` | | UnitXP_SP3 (count) | -| `outrange` | Out of spell range / count out | ✅ | ✅ | `[outrange:Charge]` `[outrange:Charge>0]` | | UnitXP_SP3 (count) | -| `insight` | In line of sight / count in LoS | ✅ | ✅ | `[insight]` `[insight:>0]` | | UnitXP_SP3 | -| `behind` | Behind target / count behind | ✅ | ✅ | `[behind]` `[behind:>=2]` | | UnitXP_SP3 | -| `multiscan` | Scan enemies by priority, soft-cast | — | ✅ | `[multiscan:nearest]` `[multiscan:skull]` | | UnitXP_SP3 | -| ***Immunity*** | | | | | | | +| `exists` | Unit exists | ✅ | — | `[@mouseover,exists]` | | | +| `harm` | Unit is hostile | ✅ | — | `[harm]` | | | +| `help` | Unit is friendly | ✅ | — | `[help]` | | | +| `hp` | Target HP % | — | ✅ | `[hp:<20]` `[hp:>30&<70]` | | | +| `hplost` | Target HP deficit (max - current) | — | ✅ | `[hplost:>1000]` | | | | `immune` | Target is immune (school or CC type) | ✅ | ✅ | `[noimmune:fire]` `[noimmune:stun]` | | | +| `inrange` | In spell range / count in range | ✅ | ✅ | `[inrange:Charge]` `[inrange:Multi-Shot>1]` | | UnitXP_SP3 (count) | +| `insight` | In line of sight / count in LoS | ✅ | ✅ | `[insight]` `[insight:>0]` | | UnitXP_SP3 | +| `isnpc` | Unit is an NPC | ✅ | — | `[isnpc]` | | | +| `isplayer` | Unit is a player | ✅ | — | `[isplayer]` | | | +| `istank` | Unit is marked as tank | ✅ | — | `[istank]` `[@focus,istank]` | | pfUI | +| `level` | Target level (skull = 63) | — | ✅ | `[level:>60]` | | | +| `meleerange` | In melee range / count in melee | ✅ | ✅ | `[meleerange]` `[meleerange:>1]` | | UnitXP_SP3 | +| `member` | Target is in party or raid | ✅ | — | `[member]` | | | +| `multiscan` | Scan enemies by priority, soft-cast | — | ✅ | `[multiscan:nearest]` `[multiscan:skull]` | | UnitXP_SP3 | +| `mytag` | Target is tapped by you | ✅ | — | `[mytag]` `[nomytag]` | | | +| `name` | Exact name match (case-insensitive) | ✅ | ✅ | `[name:Onyxia]` `[noname:Critter/Dummy]` | | | +| `othertag` | Target is tapped by someone else | ✅ | — | `[othertag]` `[noothertag]` | | | +| `outrange` | Out of spell range / count out | ✅ | ✅ | `[outrange:Charge]` `[outrange:Charge>0]` | | UnitXP_SP3 (count) | +| `party` | Unit is in your party | ✅ | ✅ | `[party]` `[party:focus]` | | | +| `power` | Target mana/rage/energy % | — | ✅ | `[power:<30]` | | | +| `powerlost` | Target power deficit | — | ✅ | `[powerlost:>100]` | | | +| `powertype` | Target power type | ✅ | ✅ | `[powertype:mana/rage]` | | | +| `raid` | Unit is in your raid | ✅ | ✅ | `[raid]` `[raid:mouseover]` | | | +| `rawhp` | Target raw HP value | — | ✅ | `[rawhp:>5000]` | | | +| `rawpower` | Target raw power value | — | ✅ | `[rawpower:>500]` | | | | `resisted` | Last spell was resisted | ✅ | ✅ | `[resisted]` `[resisted:full/partial]` | | | -| ***Addon Integrations*** | | | | | | | +| `tag` | Target is tapped by anyone | ✅ | — | `[tag]` `[notag]` | | | +| `targeting` | Unit is targeting you or a tank | ✅ | ✅ | `[targeting:player]` `[notargeting:tank]` | | pfUI (for tank) | | `threat` | Threat % on target (100 = pull) | ✅ | ✅ | `[threat:>80]` | | TWThreat | -| `ttk` | Time to kill target (seconds) | ✅ | ✅ | `[ttk:<10]` | | TimeToKill | | `tte` | Time to execute threshold (seconds) | ✅ | ✅ | `[tte:<5]` | | TimeToKill | +| `ttk` | Time to kill target (seconds) | ✅ | ✅ | `[ttk:<10]` | | TimeToKill | +| `type` | Creature type | ✅ | ✅ | `[type:Undead/Beast]` | | | ### Multi-Unit Count Mode diff --git a/SuperCleveRoidMacros.toc b/SuperCleveRoidMacros.toc index 1d66ce0..de63299 100644 --- a/SuperCleveRoidMacros.toc +++ b/SuperCleveRoidMacros.toc @@ -2,7 +2,7 @@ ## Author: DWG, _brain, Torio, yani9o, Avitasia ## Title: [Super]CleveRoidMacros ## Notes: /cleveroid for settings -## Version: 2.0 +## Version: 2.1 ## OptionalDeps: ClassicFocus, FocusFrame, pfUI, SuperMacro, Bongos_ActionBar, Cursive, XPerl, LunaUnitFrames, DragonflightReloaded, -Dragonflight3 ## SavedVariables: CleveRoidMacros, CleveRoids_LearnedDurations, CleveRoids_AuraTextures, CleveRoids_ImmunityData, CleveRoids_ComboDurations, CleveRoids_SpellSchools Localization.lua @@ -43,4 +43,5 @@ Extensions\Mouseover\Cursive.lua Extensions\Tooltip\Generic.lua Extensions\CursiveCustomSpells.lua +Extensions\OverflowBuffFrame.lua diff --git a/Utility.lua b/Utility.lua index 7f034e4..37575c1 100644 --- a/Utility.lua +++ b/Utility.lua @@ -328,7 +328,8 @@ do local isUpdatingMouseover = false local function apply(unit) - if CleveRoids.hasSuperwow and _G.SetMouseoverUnit then + if _G.SetMouseoverUnit then + -- Available via SuperWoW or Nampower v2.37+ -- Set flag so UPDATE_MOUSEOVER_UNIT handler knows we triggered this CleveRoids.__mo.selfTriggered = true -- Use empty string instead of nil to properly clear mouseover. @@ -658,7 +659,7 @@ lib.guidToName = lib.guidToName or {} -- duration calculation, and miss/dodge/parry/resist/immune detection. -- Enhanced tables (populated by pfUI or standalone Nampower handlers) -lib.ownDebuffs = lib.ownDebuffs or {} -- [targetGUID][spellName] = {startTime, duration, texture, rank, slot} +lib.ownDebuffs = lib.ownDebuffs or {} -- [targetGUID][spellName] = {startTime, duration, texture, rank, spellId} lib.ownSlots = lib.ownSlots or {} -- [targetGUID][slot] = spellName (LEGACY - empty when pfUI 7.6+ is active) lib.allSlots = lib.allSlots or {} -- [targetGUID][slot] = {spellName, casterGuid, isOurs} (LEGACY - empty when pfUI 7.6+ is active) lib.slotOwnership = lib.slotOwnership or {} -- [targetGUID][auraSlot] = {casterGuid, spellName, spellId, isOurs} (pfUI 7.6+ GetUnitField edition) @@ -667,6 +668,16 @@ lib.pendingCasts = lib.pendingCasts or {} -- [targetGUID][spellName] = {caster lib.recentMisses = lib.recentMisses or {} -- [targetGUID][spellName] = {time, spellId, targetName, reason} for miss/dodge/parry detection lib.iconCache = lib.iconCache or {} -- [spellId] = texture (shared with pfUI 7.6 or standalone) +-- Buff tracking tables (parallel to debuff tables, standalone Nampower mode only) +-- Buff tables are NOT linked to pfUI — pfUI has no public buff tracking tables. +-- They remain empty when hasPfUIEnhanced=true since all buff event handlers are gated by it. +lib.ownBuffCasts = lib.ownBuffCasts or {} -- [targetGUID][buffName] = {startTime, duration, spellId, casterGuid} + -- Player's own buff casts on self or others (AURA_CAST events) +lib.allBuffAuras = lib.allBuffAuras or {} -- [targetGUID][buffName][casterGuid] = {startTime, duration, rank} + -- All buffs from all casters on any unit (confirmed by BUFF_ADDED events) +lib.pendingBuffCasts = lib.pendingBuffCasts or {} -- [targetGUID][spellId] = {casterGuid, duration, spellName, time} + -- Temp storage from AURA_CAST_ON_OTHER, consumed by BUFF_ADDED_OTHER + -- Flag indicating whether enhanced pfUI tracking is available lib.hasPfUIEnhanced = false lib.hasStandaloneNampower = false @@ -702,7 +713,7 @@ function lib:HasEnhancedPfUILibdebuff() end -- Verify Nampower version based on pfUI version: - -- - pfUI 7.6+ (GetUnitField edition): requires Nampower v2.27.2+ + -- - pfUI 7.6+ (GetUnitField edition): requires Nampower v2.37.0+ -- - pfUI 7.4.3 to 7.5.x (legacy): requires Nampower v2.26+ if not GetNampowerVersion then return false end local npMajor, npMinor, npPatch = GetNampowerVersion() @@ -712,10 +723,9 @@ function lib:HasEnhancedPfUILibdebuff() local isPfUI76 = pfUI.libdebuff_slot_ownership ~= nil if isPfUI76 then - -- pfUI 7.6+ requires Nampower v2.27.2+ + -- pfUI 7.6+ requires Nampower v2.37.0+ if npMajor < 2 then return false end - if npMajor == 2 and npMinor < 27 then return false end - if npMajor == 2 and npMinor == 27 and npPatch < 2 then return false end + if npMajor == 2 and npMinor < 37 then return false end else -- Legacy pfUI 7.4.3-7.5.x requires Nampower v2.26+ if npMajor < 2 or (npMajor == 2 and npMinor < 26) then return false end @@ -733,7 +743,7 @@ function lib:HasEnhancedPfUILibdebuff() end -- Check if pfUI v7.6+ with enhanced cast tracking is available --- pfUI 7.6 requires Nampower v2.27.2+ and exposes additional tables +-- pfUI 7.6+ requires Nampower v2.37.0+ and exposes additional tables function lib:HasPfUI76() if not pfUI then return false end @@ -744,13 +754,12 @@ function lib:HasPfUI76() if v.major < 7 then return false end if v.major == 7 and (v.minor or 0) < 6 then return false end - -- Verify Nampower v2.27.2+ (pfUI 7.6 hard requirement) + -- Verify Nampower v2.38.0+ (pfUI 7.6+ hard requirement, bumped from 2.37 in pfUI update 2026-02-18) if not GetNampowerVersion then return false end local npMajor, npMinor, npPatch = GetNampowerVersion() npPatch = npPatch or 0 if npMajor < 2 then return false end - if npMajor == 2 and npMinor < 27 then return false end - if npMajor == 2 and npMinor == 27 and npPatch < 2 then return false end + if npMajor == 2 and npMinor < 38 then return false end -- Verify the new tables exist if not pfUI.libdebuff_casts then return false end @@ -1184,6 +1193,58 @@ function lib:CleanupStaleTrackingData() end end end + + -- Clean ownBuffCasts (player-cast buffs on targets) + if lib.ownBuffCasts then + for guid, buffs in pairs(lib.ownBuffCasts) do + for buffName, data in pairs(buffs) do + local elapsed = now - (data.startTime or 0) + local dur = data.duration or 0 + -- Remove if expired (duration > 0 and past end time) or stale (no duration and old) + if (dur > 0 and elapsed > dur) or (dur <= 0 and elapsed > staleTime) then + buffs[buffName] = nil + end + end + if not next(buffs) then + lib.ownBuffCasts[guid] = nil + end + end + end + + -- Clean allBuffAuras (all-caster buff tracking) + if lib.allBuffAuras then + for guid, buffs in pairs(lib.allBuffAuras) do + for buffName, casters in pairs(buffs) do + for cGuid, data in pairs(casters) do + local elapsed = now - (data.startTime or 0) + local dur = data.duration or 0 + if (dur > 0 and elapsed > dur) or (dur <= 0 and elapsed > staleTime) then + casters[cGuid] = nil + end + end + if not next(casters) then + buffs[buffName] = nil + end + end + if not next(buffs) then + lib.allBuffAuras[guid] = nil + end + end + end + + -- Clean pendingBuffCasts (short-lived correlation data, 2 second TTL) + if lib.pendingBuffCasts then + for guid, spells in pairs(lib.pendingBuffCasts) do + for spellId, data in pairs(spells) do + if (now - (data.time or 0)) > 2 then + spells[spellId] = nil + end + end + if not next(spells) then + lib.pendingBuffCasts[guid] = nil + end + end + end end -- Get the caster GUID for a debuff on a target @@ -2996,10 +3057,16 @@ delayedTrackingFrame:SetScript("OnUpdate", function() if not lib.detectedJudgementDebuffIDs[debuffSpellID] then lib.detectedJudgementDebuffIDs[debuffSpellID] = true lib.judgementSpells[debuffSpellID] = true -- Add to refresh list + -- Also register as shared debuff so [debuff]/[nodebuff] fallback detection works + -- for custom Turtle WoW spell IDs not in the hardcoded sharedDebuffs table + if not lib.sharedDebuffs[debuffSpellID] then + lib.sharedDebuffs[debuffSpellID] = 10 -- Standard judgement duration + CleveRoids.InvalidateSpellNameCache() + end if debug then DEFAULT_CHAT_FRAME:AddMessage( - _string_format("|cff00ffff[Judgement Detect]|r Found debuff %s (ID:%d) from cast (ID:%d) - added to refresh list", + _string_format("|cff00ffff[Judgement Detect]|r Found debuff %s (ID:%d) from cast (ID:%d) - added to refresh + shared list", debuffName, debuffSpellID, pending.castSpellID) ) end @@ -3685,6 +3752,13 @@ if CleveRoids.hasNampower then ev:RegisterEvent("DEBUFF_REMOVED_OTHER") ev:RegisterEvent("UNIT_DIED") -- Instant cleanup on target death + -- BUFF_ADDED/REMOVED events require v2.30+ for auraSlot and state args + if npMajor > 2 or (npMajor == 2 and npMinor >= 30) then + ev:RegisterEvent("BUFF_ADDED_OTHER") + ev:RegisterEvent("BUFF_REMOVED_SELF") + ev:RegisterEvent("BUFF_REMOVED_OTHER") + end + if CleveRoids.debug then DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Registered UNIT_DIED for instant cleanup (v2.26+)") end @@ -4043,7 +4117,13 @@ ev:SetScript("OnEvent", function() }) -- If this is a Judgement spell cast by a Paladin, schedule a scan to find the actual debuff ID - if CleveRoids.playerClass == "PALADIN" and lib.judgementSpells[spellID] then + -- Check by ID (known judgement debuffs) OR by name (Turtle WoW custom cast spell IDs) + local isJudgementCast = lib.judgementSpells[spellID] + if not isJudgementCast and CleveRoids.playerClass == "PALADIN" then + local castName = _SpellInfo(spellID) + isJudgementCast = castName and _string_find(castName, "^Judgement") + end + if CleveRoids.playerClass == "PALADIN" and isJudgementCast then table.insert(lib.pendingJudgements, { timestamp = GetTime(), castSpellID = spellID, @@ -4370,19 +4450,38 @@ ev:SetScript("OnEvent", function() -- and these handlers return early (pfUI handles it). elseif event == "SPELL_START_SELF" or event == "SPELL_START_OTHER" then + -- v2.38+: For player's own channeling spells, pre-store the accurate server channel duration + -- BEFORE the pfUI early-return so SPELLCAST_CHANNEL_START (which fires later) can use it. + -- arg8 == 1 means spellType=Channeling; arg7 is the channel duration in ms. + if event == "SPELL_START_SELF" and arg8 == 1 and arg7 and arg7 > 0 then + CleveRoids._v238ChannelDuration = arg7 / 1000 + CleveRoids._v238ChannelSpellId = arg2 + end -- pfUI 7.6 manages castTracking via its own SPELL_START handler if lib.hasPfUI76 then return end local spellId = arg2 local casterGuid = arg3 - local castTime = arg6 -- milliseconds + local castTime = arg6 -- cast time in milliseconds + -- v2.38+: arg7 = channel duration (ms, channeling spells only), arg8 = spellType (0=Normal,1=Channeling,2=Autorepeating) + local channelDuration = arg7 + local spellType = arg8 if not casterGuid or not spellId then return end local spellName = SpellInfo and SpellInfo(spellId) local icon = lib:GetCachedIcon(spellId) local now = GetTime() - local durationSec = castTime and (castTime / 1000) or 0 + + -- For channeling spells (v2.38+: spellType==1), use the channel duration. + -- For normal cast-time spells, use castTime. Fall back to castTime if params unavailable. + local durationMs + if spellType == 1 and channelDuration and channelDuration > 0 then + durationMs = channelDuration + else + durationMs = castTime or 0 + end + local durationSec = durationMs / 1000 CleveRoids.castTracking[casterGuid] = { spellID = spellId, @@ -4394,9 +4493,10 @@ ev:SetScript("OnEvent", function() } if CleveRoids.debug then + local typeStr = spellType == 1 and "channel" or (spellType == 2 and "auto" or "cast") DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cff00ccff[SPELL_START]|r %s (ID:%d) cast by %s - %.1fs", - spellName or "Unknown", spellId, casterGuid, durationSec) + string.format("|cff00ccff[SPELL_START]|r %s (ID:%d) cast by %s - %.1fs [%s]", + spellName or "Unknown", spellId, casterGuid, durationSec, typeStr) ) end @@ -4876,6 +4976,125 @@ ev:SetScript("OnEvent", function() end end + -- NAMPOWER v2.30+ BUFF_ADDED_OTHER - Confirm pending AURA_CAST_ON_OTHER data as buff + elseif event == "BUFF_ADDED_OTHER" then + if lib.hasPfUIEnhanced then return end + + local guid = CleveRoids.NormalizeGUID(arg1) + local spellId = arg3 + local state = arg7 -- 0=added, 1=removed, 2=modified + + if not guid or not spellId then return end + + -- Consume pending AURA_CAST data for this buff + local pending = lib.pendingBuffCasts[guid] and lib.pendingBuffCasts[guid][spellId] + if pending then + local spellName = pending.spellName or (SpellInfo and SpellInfo(spellId)) + if spellName then + local casterGuid = pending.casterGuid + local duration = pending.duration + local now = GetTime() + + -- allBuffAuras: confirmed buff from any caster + lib.allBuffAuras[guid] = lib.allBuffAuras[guid] or {} + lib.allBuffAuras[guid][spellName] = lib.allBuffAuras[guid][spellName] or {} + lib.allBuffAuras[guid][spellName][casterGuid or "unknown"] = { + startTime = now, + duration = duration or 0, + rank = 0, + } + + -- ownBuffCasts: only if player is the caster + local _, playerGuidRaw = UnitExists("player") + local playerGuid = playerGuidRaw and CleveRoids.NormalizeGUID(playerGuidRaw) + if playerGuid and casterGuid == playerGuid then + lib.ownBuffCasts[guid] = lib.ownBuffCasts[guid] or {} + lib.ownBuffCasts[guid][spellName] = { + startTime = now, + duration = duration or 0, + spellId = spellId, + casterGuid = casterGuid, + } + end + end + lib.pendingBuffCasts[guid][spellId] = nil + end + + -- NAMPOWER v2.30+ BUFF_REMOVED_SELF - Player's own buffs removed + elseif event == "BUFF_REMOVED_SELF" then + if lib.hasPfUIEnhanced then return end + + local spellId = arg3 + local state = arg7 + if not spellId then return end + if state == 2 then return end -- Stack decrease only, not full removal + + local spellName = SpellInfo and SpellInfo(spellId) + local _, playerGuidRaw = UnitExists("player") + local playerGuid = playerGuidRaw and CleveRoids.NormalizeGUID(playerGuidRaw) + + if spellName and playerGuid then + if lib.ownBuffCasts[playerGuid] then + lib.ownBuffCasts[playerGuid][spellName] = nil + if not next(lib.ownBuffCasts[playerGuid]) then + lib.ownBuffCasts[playerGuid] = nil + end + end + if lib.allBuffAuras[playerGuid] then + lib.allBuffAuras[playerGuid][spellName] = nil + if not next(lib.allBuffAuras[playerGuid]) then + lib.allBuffAuras[playerGuid] = nil + end + end + end + + -- Also prune from OverflowBuffs (existing system) + if spellId and CleveRoids.OverflowBuffs and CleveRoids.OverflowBuffs[spellId] then + CleveRoids.OverflowBuffs[spellId] = nil + end + + -- NAMPOWER v2.30+ BUFF_REMOVED_OTHER - Buffs removed from other units + elseif event == "BUFF_REMOVED_OTHER" then + if lib.hasPfUIEnhanced then return end + + local guid = CleveRoids.NormalizeGUID(arg1) + local spellId = arg3 + local state = arg7 + if not guid or not spellId then return end + if state == 2 then return end -- Stack decrease only, not full removal + + local spellName = SpellInfo and SpellInfo(spellId) + if spellName then + if lib.allBuffAuras[guid] then + lib.allBuffAuras[guid][spellName] = nil + if not next(lib.allBuffAuras[guid]) then + lib.allBuffAuras[guid] = nil + end + end + if lib.ownBuffCasts[guid] then + lib.ownBuffCasts[guid][spellName] = nil + if not next(lib.ownBuffCasts[guid]) then + lib.ownBuffCasts[guid] = nil + end + end + end + + -- Clean pending correlation data + if lib.pendingBuffCasts[guid] then + lib.pendingBuffCasts[guid][spellId] = nil + if not next(lib.pendingBuffCasts[guid]) then + lib.pendingBuffCasts[guid] = nil + end + end + + -- Also clean AllCasterAuraTracking (existing system) + if CleveRoids.AllCasterAuraTracking[guid] then + CleveRoids.AllCasterAuraTracking[guid][spellId] = nil + if not next(CleveRoids.AllCasterAuraTracking[guid]) then + CleveRoids.AllCasterAuraTracking[guid] = nil + end + end + -- NAMPOWER v2.26+ UNIT_DIED - Instant cleanup on target death elseif event == "UNIT_DIED" then local guid = arg1 @@ -4908,11 +5127,41 @@ ev:SetScript("OnEvent", function() lib.recentMisses[guid] = nil end + -- Clean up buff tracking tables + if lib.ownBuffCasts[guid] then + lib.ownBuffCasts[guid] = nil + end + if lib.allBuffAuras[guid] then + lib.allBuffAuras[guid] = nil + end + if lib.pendingBuffCasts[guid] then + lib.pendingBuffCasts[guid] = nil + end + -- Clean up cast tracking for this unit (they can't be casting if dead) if not lib.hasPfUI76 and CleveRoids.castTracking[guid] then CleveRoids.castTracking[guid] = nil end + -- Clean up overflow buff tracking (death removes all buffs) + local _, playerGUID = UnitExists("player") + if playerGUID and guid == playerGUID then + -- Player died: clear all overflow buff entries and reset cap status + if CleveRoids.OverflowBuffs then + for k in pairs(CleveRoids.OverflowBuffs) do + CleveRoids.OverflowBuffs[k] = nil + end + end + if CleveRoids.AuraCapStatus then + CleveRoids.AuraCapStatus.playerBuffCapped = false + CleveRoids.AuraCapStatus.playerDebuffCapped = false + end + end + -- Clean up all-caster aura tracking for the dead unit + if CleveRoids.AllCasterAuraTracking and CleveRoids.AllCasterAuraTracking[guid] then + CleveRoids.AllCasterAuraTracking[guid] = nil + end + -- Clean up GUID to name mapping (after 5 seconds to allow final lookups) -- Actually, keep it for a bit in case we need it for immunity detection -- lib.guidToName[guid] = nil