diff --git a/Conditionals.lua b/Conditionals.lua index 9d2363b..ed6bb0a 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -524,16 +524,19 @@ local function AndNotEqualsNumber(t, target) end -- PERFORMANCE: Avoid creating wrapper tables for single values +-- PERFORMANCE: All helpers use next() instead of pairs() to avoid iterator closure allocation. +-- In Lua 5.0 (WoW 1.12), pairs() creates a closure each call. These helpers are invoked +-- for every conditional keyword on every macro line during action evaluation. local function And(t, func) if type(func) ~= "function" then return false end -- PERFORMANCE: Handle non-table case without allocation if type(t) ~= "table" then return func(t) and true or false end - for k, v in pairs(t) do - if not func(v) then - return false - end + local k, v = next(t) + while k do + if not func(v) then return false end + k, v = next(t, k) end return true end @@ -544,10 +547,10 @@ local function Or(t, func) if type(t) ~= "table" then return func(t) and true or false end - for k, v in pairs(t) do - if func(v) then - return true - end + local k, v = next(t) + while k do + if func(v) then return true end + k, v = next(t, k) end return false end @@ -569,23 +572,28 @@ local function Multi(t, func, conditionals, condition) if conditionals and conditionals._groups and conditionals._groups[condition] then local groups = conditionals._groups[condition] -- All groups must pass (AND between groups) - for _, group in ipairs(groups) do + -- PERFORMANCE: Index-based loop instead of ipairs() to avoid iterator closure + local numGroups = table.getn(groups) + for gi = 1, numGroups do + local group = groups[gi] local groupPassed = false local groupOp = group.operator or "OR" + local values = group.values + local numValues = table.getn(values) if groupOp == "AND" then -- AND within group: ALL values must match groupPassed = true - for _, v in ipairs(group.values) do - if not func(v) then + for vi = 1, numValues do + if not func(values[vi]) then groupPassed = false break end end else -- OR within group: ANY value can match - for _, v in ipairs(group.values) do - if func(v) then + for vi = 1, numValues do + if func(values[vi]) then groupPassed = true break end @@ -606,14 +614,18 @@ local function Multi(t, func, conditionals, condition) if operatorType == "AND" then -- AND separator (&): ALL must match - for k, v in pairs(t) do + local k, v = next(t) + while k do if not func(v) then return false end + k, v = next(t, k) end return true else -- OR separator (/) [default]: ANY can match - for k, v in pairs(t) do + local k, v = next(t) + while k do if func(v) then return true end + k, v = next(t, k) end return false end @@ -637,15 +649,20 @@ local function NegatedMulti(t, func, conditionals, condition) if conditionals and conditionals._groups and conditionals._groups[condition] then local groups = conditionals._groups[condition] -- All groups must pass (AND between groups) - for _, group in ipairs(groups) do + -- PERFORMANCE: Index-based loop instead of ipairs() to avoid iterator closure + local numGroups = table.getn(groups) + for gi = 1, numGroups do + local group = groups[gi] local groupPassed = false local groupOp = group.operator or "OR" + local values = group.values + local numValues = table.getn(values) -- FLIPPED from positive conditionals (De Morgan's law for intuitive behavior) if groupOp == "AND" then -- AND separator (&) FLIPPED: ANY negation can pass (missing at least one) - for _, v in ipairs(group.values) do - if func(v) then + for vi = 1, numValues do + if func(values[vi]) then groupPassed = true break end @@ -653,8 +670,8 @@ local function NegatedMulti(t, func, conditionals, condition) else -- OR separator (/) FLIPPED: ALL negations must pass (missing all) groupPassed = true - for _, v in ipairs(group.values) do - if not func(v) then + for vi = 1, numValues do + if not func(values[vi]) then groupPassed = false break end @@ -676,14 +693,18 @@ local function NegatedMulti(t, func, conditionals, condition) -- FLIPPED from positive conditionals (De Morgan's law for intuitive behavior) if operatorType == "AND" then -- AND separator (&): ANY negation can pass (missing at least one) - for k, v in pairs(t) do + local k, v = next(t) + while k do if func(v) then return true end + k, v = next(t, k) end return false else -- OR separator (/) [default]: ALL negations must pass (missing all) - for k, v in pairs(t) do + local k, v = next(t) + while k do if not func(v) then return false end + k, v = next(t, k) end return true end @@ -804,6 +825,8 @@ CleveRoids.AuraCapStatus = { playerLastUpdate = 0, -- Target aura cap status (tracked per-GUID) targetCapStatus = {}, -- [guid] = { buffCapped, debuffCapped, timestamp } + -- PERFORMANCE: Throttle cleanup to avoid O(n) scan on every aura event + lastCleanupTime = 0, } -- All-caster aura duration tracking (from AURA_CAST events) @@ -968,11 +991,19 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu if not CleveRoids.AllCasterAuraTracking[targetGuid] then CleveRoids.AllCasterAuraTracking[targetGuid] = {} end - CleveRoids.AllCasterAuraTracking[targetGuid][spellId] = { - start = now, - duration = durationMs / 1000, -- Convert ms to seconds - casterGuid = casterGuid, - } + -- PERFORMANCE: Reuse existing entry table when possible + local existing = CleveRoids.AllCasterAuraTracking[targetGuid][spellId] + if existing then + existing.start = now + existing.duration = durationMs / 1000 + existing.casterGuid = casterGuid + else + CleveRoids.AllCasterAuraTracking[targetGuid][spellId] = { + start = now, + duration = durationMs / 1000, -- Convert ms to seconds + casterGuid = casterGuid, + } + end -- Debug output when enabled if CleveRoids.debug then @@ -986,26 +1017,41 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu -- Store cap status for this target GUID (if available) if auraCapStatus then - CleveRoids.AuraCapStatus.targetCapStatus[targetGuid] = { - buffCapped = HasHitFlag(auraCapStatus, AURA_CAP_BUFF_FULL), - debuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_DEBUFF_FULL), - timestamp = now, - } + -- PERFORMANCE: Reuse existing entry table when possible + local entry = CleveRoids.AuraCapStatus.targetCapStatus[targetGuid] + if not entry then + entry = {} + CleveRoids.AuraCapStatus.targetCapStatus[targetGuid] = entry + end + entry.buffCapped = HasHitFlag(auraCapStatus, AURA_CAP_BUFF_FULL) + entry.debuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_DEBUFF_FULL) + entry.timestamp = now end - -- Cleanup old entries (older than 60 seconds) to prevent memory leak - local count = 0 - for guid, data in pairs(CleveRoids.AuraCapStatus.targetCapStatus) do - count = count + 1 + -- PERFORMANCE: Throttle cleanup to every 5 seconds instead of every aura event + -- Previously this O(n) scan ran on EVERY aura application (10-50+/sec in raids) + if (now - CleveRoids.AuraCapStatus.lastCleanupTime) < 5 then return end + CleveRoids.AuraCapStatus.lastCleanupTime = now + + -- Cleanup old cap status entries (older than 60 seconds) + local guid, data = next(CleveRoids.AuraCapStatus.targetCapStatus) + while guid do + local nextGuid = next(CleveRoids.AuraCapStatus.targetCapStatus, guid) if now - data.timestamp > 60 then CleveRoids.AuraCapStatus.targetCapStatus[guid] = nil end + guid = nextGuid + data = nextGuid and CleveRoids.AuraCapStatus.targetCapStatus[nextGuid] end -- Cleanup old aura tracking entries - for guid, spells in pairs(CleveRoids.AllCasterAuraTracking) do + local tGuid, spells = next(CleveRoids.AllCasterAuraTracking) + while tGuid do + local nextTGuid = next(CleveRoids.AllCasterAuraTracking, tGuid) local hasActive = false - for sid, auraData in pairs(spells) do + local sid, auraData = next(spells) + while sid do + local nextSid = next(spells, sid) if auraData.start and auraData.duration then local remaining = auraData.duration + auraData.start - now if remaining <= 0 then @@ -1014,11 +1060,14 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu hasActive = true end end + sid = nextSid + auraData = nextSid and spells[nextSid] end - -- Remove empty target entries if not hasActive then - CleveRoids.AllCasterAuraTracking[guid] = nil + CleveRoids.AllCasterAuraTracking[tGuid] = nil end + tGuid = nextTGuid + spells = nextTGuid and CleveRoids.AllCasterAuraTracking[nextTGuid] end end @@ -1051,6 +1100,45 @@ autoAttackFrame:SetScript("OnEvent", function() end end + -- Check for aura duration update events (v2.30+) + if API and API.features and API.features.hasAuraDurationEvents then + this:RegisterEvent("BUFF_UPDATE_DURATION_SELF") + this:RegisterEvent("DEBUFF_UPDATE_DURATION_SELF") + end + + elseif event == "BUFF_UPDATE_DURATION_SELF" or event == "DEBUFF_UPDATE_DURATION_SELF" then + -- v2.30+: Player aura duration was refreshed + -- arg1 = auraSlot (0-based raw slot index) + local auraSlot = arg1 + if auraSlot and API and API.GetPlayerAuraDuration then + local spellId, durationMs, expirationTimeMs = API.GetPlayerAuraDuration(auraSlot) + if spellId and spellId > 0 and durationMs and durationMs > 0 then + local _, playerGUID = UnitExists("player") + if playerGUID then + if not CleveRoids.AllCasterAuraTracking[playerGUID] then + CleveRoids.AllCasterAuraTracking[playerGUID] = {} + end + local durationSec = durationMs / 1000 + local now = GetTime() + local expirationSec = expirationTimeMs and expirationTimeMs / 1000 + local startTime = expirationSec and (expirationSec - durationSec) or now + CleveRoids.AllCasterAuraTracking[playerGUID][spellId] = { + start = startTime, + duration = durationSec, + casterGuid = playerGUID, + } + + if CleveRoids.debug then + local spellName = SpellInfo and SpellInfo(spellId) or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff88ff88[AuraDurUpdate]|r %s (slot:%d, ID:%d) dur=%.1fs", + spellName, auraSlot, spellId, durationSec + )) + end + end + end + end + elseif event == "AUTO_ATTACK_OTHER" then -- arg1=attackerGuid, arg2=targetGuid, arg3=totalDamage, arg4=hitInfo, arg5=victimState, -- arg6=subDamageCount, arg7=blockedAmount, arg8=totalAbsorb, arg9=totalResist @@ -1181,6 +1269,15 @@ function CleveRoids.IsPlayerBuffCapped() return CleveRoids.AuraCapStatus.playerBuffCapped end + -- v2.30+ fast path: use raw slot counting via GetPlayerAuraDuration + local API = CleveRoids.NampowerAPI + if API then + local count = API.CountPlayerBuffSlots() + if count then + return count >= 32 + end + end + -- Fallback: count player buffs manually local count = 0 for i = 0, 31 do @@ -1197,6 +1294,15 @@ function CleveRoids.IsPlayerDebuffCapped() return CleveRoids.AuraCapStatus.playerDebuffCapped end + -- v2.30+ fast path: use raw slot counting via GetPlayerAuraDuration + local API = CleveRoids.NampowerAPI + if API then + local count = API.CountPlayerDebuffSlots() + if count then + return count >= 16 + end + end + -- Fallback: count player debuffs local count = 0 for i = 0, 15 do @@ -2303,17 +2409,25 @@ function CleveRoids.IsPlayerMoving() end -- Fallback: use continuously tracked position history from OnUpdate - -- Core.lua tracks position at ~100 Hz with 4-sample history (matches MonkeySpeed's 0.01s). + -- 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. -- This gives instant start-of-movement detection (first sample shows delta). local history = CleveRoids._positionHistory + local count = CleveRoids._posHistoryCount or 0 + local headIdx = CleveRoids._posHistoryIndex or 0 - if history and table.getn(history) >= 2 then - -- Check all consecutive pairs in history - moving if ANY shows movement - -- This detects start-of-movement immediately when first delta appears - for i = 2, table.getn(history) do - local prev = history[i - 1] - local curr = history[i] + 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 = ((headIdx - currOffset - 1) % 4) + 1 + local prevIdx = ((headIdx - prevOffset - 1) % 4) + 1 + local prev = history[prevIdx] + local curr = history[currIdx] local dx = curr.x - prev.x local dy = curr.y - prev.y local dist = math.sqrt(dx * dx + dy * dy) @@ -3923,14 +4037,25 @@ function CleveRoids.GetItemCooldown(item) end function CleveRoids.ValidatePlayerAuraCount(bigger, amount) - -- Count player buffs by iterating all 32 slots - -- (matches IsPlayerBuffCapped logic for consistency) - local count = 0 - for i = 0, 31 do - if GetPlayerBuffTexture(GetPlayerBuff(i, "HELPFUL")) then - count = count + 1 + -- Count player buffs + local count + + -- v2.30+ fast path: use raw slot counting via GetPlayerAuraDuration + local API = CleveRoids.NampowerAPI + if API then + count = API.CountPlayerBuffSlots() + end + + -- Fallback: iterate all 32 slots manually + if not count then + count = 0 + for i = 0, 31 do + if GetPlayerBuffTexture(GetPlayerBuff(i, "HELPFUL")) then + count = count + 1 + end end end + if bigger == 0 then return count < tonumber(amount) else @@ -4670,17 +4795,19 @@ CleveRoids.Keywords = { end, equipped = function(conditionals) - local itemsToCheck = {} + -- PERFORMANCE: Or()/And() handle non-table values natively, so pass strings directly + -- instead of wrapping in a temporary table (avoids allocation per evaluation) + local itemsToCheck -- Case 1: conditionals.equipped is a string (e.g., [equipped]ItemName) if type(conditionals.equipped) == "string" then - table.insert(itemsToCheck, conditionals.equipped) + itemsToCheck = conditionals.equipped -- Case 2: conditionals.equipped is a table (e.g., [equipped:Shields]) elseif type(conditionals.equipped) == "table" and table.getn(conditionals.equipped) > 0 then itemsToCheck = conditionals.equipped -- Case 3: No value provided, check the action elseif conditionals.action then - table.insert(itemsToCheck, conditionals.action) + itemsToCheck = conditionals.action else return false end @@ -4692,17 +4819,19 @@ CleveRoids.Keywords = { end, noequipped = function(conditionals) - local itemsToCheck = {} + -- PERFORMANCE: Or()/And() handle non-table values natively, so pass strings directly + -- instead of wrapping in a temporary table (avoids allocation per evaluation) + local itemsToCheck -- Case 1: conditionals.noequipped is a string (e.g., [noequipped]ItemName) if type(conditionals.noequipped) == "string" then - table.insert(itemsToCheck, conditionals.noequipped) + itemsToCheck = conditionals.noequipped -- Case 2: conditionals.noequipped is a table (e.g., [noequipped:Shields]) elseif type(conditionals.noequipped) == "table" and table.getn(conditionals.noequipped) > 0 then itemsToCheck = conditionals.noequipped -- Case 3: No value provided, check the action elseif conditionals.action then - table.insert(itemsToCheck, conditionals.action) + itemsToCheck = conditionals.action else return false end diff --git a/Core.lua b/Core.lua index 2ca8946..a3dfbd9 100644 --- a/Core.lua +++ b/Core.lua @@ -3797,10 +3797,9 @@ function CleveRoids.OnUpdate(self) -- ========================================================================= -- CONTINUOUS POSITION TRACKING (for [moving] fallback without MonkeySpeed) -- ========================================================================= - -- Track player position at ~100 Hz with 4-sample history for snappy movement detection. + -- Track player position at ~100 Hz with 4-sample circular buffer for snappy movement detection. -- Matches MonkeySpeed's 0.01s detection interval for similar responsiveness. - -- This provides instant start-of-movement detection and quick stop detection. - -- Without this, [moving] only updates when macros execute (stale data). + -- PERFORMANCE: Pre-allocated circular buffer eliminates ~100 table allocs/sec and table.remove shifting. local posTrackInterval = 0.01 -- 10ms between position samples (100 Hz, matches MonkeySpeed) local lastPosTime = CleveRoids._positionTrackTime or 0 @@ -3810,24 +3809,40 @@ function CleveRoids.OnUpdate(self) if UnitPosition then local x, y = UnitPosition("player") if x and y then - -- Initialize history buffer if needed - if not CleveRoids._positionHistory then - CleveRoids._positionHistory = {} - end - - -- Add new sample to history + -- Initialize circular buffer if needed (pre-allocate 4 entry tables) local history = CleveRoids._positionHistory - table.insert(history, { x = x, y = y, time = time }) - - -- Keep only last 4 samples (40ms window at 10ms intervals) - while table.getn(history) > 4 do - table.remove(history, 1) + if not history then + history = { + { x = 0, y = 0, time = 0 }, + { x = 0, y = 0, time = 0 }, + { x = 0, y = 0, time = 0 }, + { x = 0, y = 0, time = 0 }, + } + CleveRoids._positionHistory = history + CleveRoids._posHistoryIndex = 0 + CleveRoids._posHistoryCount = 0 end - -- Also maintain legacy vars for any code that uses them directly - if table.getn(history) >= 2 then - CleveRoids._previousPlayerPos = history[table.getn(history) - 1] - CleveRoids._currentPlayerPos = history[table.getn(history)] + -- Advance circular index (1-based, wraps at 4) + local idx = (CleveRoids._posHistoryIndex % 4) + 1 + CleveRoids._posHistoryIndex = idx + + -- Reuse existing entry (zero allocation) + local entry = history[idx] + entry.x = x + entry.y = y + entry.time = time + + -- Track how many samples we have (caps at 4) + if CleveRoids._posHistoryCount < 4 then + CleveRoids._posHistoryCount = CleveRoids._posHistoryCount + 1 + end + + -- Maintain legacy vars for any code that uses them directly + if CleveRoids._posHistoryCount >= 2 then + local prevIdx = ((idx - 2) % 4) + 1 + CleveRoids._previousPlayerPos = history[prevIdx] + CleveRoids._currentPlayerPos = entry end end end diff --git a/NampowerAPI.lua b/NampowerAPI.lua index 4ce5385..4f68705 100644 --- a/NampowerAPI.lua +++ b/NampowerAPI.lua @@ -58,11 +58,22 @@ - GetItemIconTexture(displayInfoId) - Get item texture path from display info ID - GetSpellIconTexture(spellIconId) - Get spell texture path from spell icon ID + Nameplate GUID (v2.28+): + - CSimpleFrame:GetName(1) returns nameplate unit GUID + + Ammo Query (v2.29+): + - GetAmmo() - Returns equipped ammo item ID and total count + + Aura Slot Extensions (v2.30+): + - auraSlot parameter on BUFF/DEBUFF_ADDED/REMOVED events (raw 0-based slot) + - BUFF_UPDATE_DURATION_SELF / DEBUFF_UPDATE_DURATION_SELF events + - GetPlayerAuraDuration(auraSlot) - Get duration info for player aura by slot + Settings Integration: - Reads from NampowerSettings addon when available - Falls back to CVars when addon not present - Current version: v2.27.0 + Current version: v2.30.0 ]] local _G = _G or getfenv(0) @@ -197,6 +208,17 @@ API.VERSION_REQUIREMENTS = { -- v2.27+ - Texture lookup functions ["GetItemIconTexture"] = { 2, 27, 0, "GetItemIconTexture" }, ["GetSpellIconTexture"] = { 2, 27, 0, "GetSpellIconTexture" }, + + -- v2.28+ - Nameplate GUID via CSimpleFrame:GetName(1) + ["NameplateGUID"] = { 2, 28, 0 }, + + -- v2.29+ - Ammo query + ["GetAmmo"] = { 2, 29, 0, "GetAmmo" }, + + -- v2.30+ - Aura slot extensions + ["AuraSlotParameter"] = { 2, 30, 0 }, + ["AuraDurationEvents"] = { 2, 30, 0 }, + ["GetPlayerAuraDuration"] = { 2, 30, 0, "GetPlayerAuraDuration" }, } -- Check if a specific feature is available @@ -315,6 +337,17 @@ local function InitializeFeatures() f.hasGetItemIconTexture = API.HasFeature("GetItemIconTexture") f.hasGetSpellIconTexture = API.HasFeature("GetSpellIconTexture") + -- v2.28+ Nameplate GUID + f.hasNameplateGUID = API.HasFeature("NameplateGUID") + + -- v2.29+ Ammo query + f.hasGetAmmo = API.HasFeature("GetAmmo") + + -- v2.30+ Aura slot extensions + f.hasAuraSlotParameter = API.HasFeature("AuraSlotParameter") + f.hasAuraDurationEvents = API.HasFeature("AuraDurationEvents") + f.hasGetPlayerAuraDuration = API.HasFeature("GetPlayerAuraDuration") + -- Runtime detection for enhanced spell functions (verify by testing) if f.hasEnhancedSpellFunctions and GetSpellTexture then local success, result = pcall(function() @@ -1897,7 +1930,7 @@ API.CAST_TYPE = { } -- Buff/debuff event names (v2.18+) --- Parameters: guid, slot, spellId, stackCount, auraLevel (v2.20+) +-- Parameters: guid, slot, spellId, stackCount, auraLevel (v2.20+), auraSlot (v2.30+, raw 0-based) API.AURA_EVENTS = { "BUFF_ADDED_SELF", "BUFF_REMOVED_SELF", @@ -1925,6 +1958,14 @@ API.AURA_CAP_STATUS = { BOTH_FULL = 3, } +-- Aura duration update event names (v2.30+) +-- Fires when a buff/debuff duration is refreshed on the player +-- Parameters: auraSlot (0-based raw slot index) +API.AURA_DURATION_EVENTS = { + "BUFF_UPDATE_DURATION_SELF", -- Player buff duration refreshed + "DEBUFF_UPDATE_DURATION_SELF", -- Player debuff duration refreshed +} + -- Auto-attack event names (v2.24+, requires NP_EnableAutoAttackEvents=1) -- Parameters: attackerGuid, targetGuid, totalDamage, hitInfo, victimState, -- subDamageCount, blockedAmount, totalAbsorb, totalResist @@ -2763,6 +2804,57 @@ function API.QueueScript(script, priority) return false end +-- Get equipped ammo ID and total count (v2.29+) +-- Returns: ammoId, ammoCount (or nil, nil if unavailable) +function API.GetAmmo() + if not API.features.hasGetAmmo or not _G.GetAmmo then + return nil, nil + end + return _G.GetAmmo() +end + +-- Get player aura duration info by raw slot index (v2.30+) +-- auraSlot: 0-based raw aura slot (0-31 = buffs, 32-47 = debuffs) +-- Returns: spellId, durationMs, expirationTimeMs (or nil if unavailable) +function API.GetPlayerAuraDuration(auraSlot) + if not API.features.hasGetPlayerAuraDuration or not _G.GetPlayerAuraDuration then + return nil, nil, nil + end + return _G.GetPlayerAuraDuration(auraSlot) +end + +-- Count occupied player buff slots using GetPlayerAuraDuration (v2.30+) +-- Returns: count of occupied buff slots (0-32), or nil if unavailable +function API.CountPlayerBuffSlots() + if not API.features.hasGetPlayerAuraDuration or not _G.GetPlayerAuraDuration then + return nil + end + local count = 0 + for i = 0, 31 do + local spellId = _G.GetPlayerAuraDuration(i) + if spellId and spellId > 0 then + count = count + 1 + end + end + return count +end + +-- Count occupied player debuff slots using GetPlayerAuraDuration (v2.30+) +-- Returns: count of occupied debuff slots (0-16), or nil if unavailable +function API.CountPlayerDebuffSlots() + if not API.features.hasGetPlayerAuraDuration or not _G.GetPlayerAuraDuration then + return nil + end + local count = 0 + for i = 32, 47 do + local spellId = _G.GetPlayerAuraDuration(i) + if spellId and spellId > 0 then + count = count + 1 + end + end + return count +end + -- Clear spell type cache (call on spec change) function API.ClearSpellTypeCache() API.spellTypeCache = {} diff --git a/Utility.lua b/Utility.lua index cbf83fa..bb3b8ab 100644 --- a/Utility.lua +++ b/Utility.lua @@ -4759,6 +4759,7 @@ ev:SetScript("OnEvent", function() local slot = arg2 local spellId = arg3 local stacks = arg4 or 1 + local auraSlot = arg6 -- v2.30+: raw 0-based aura slot index (stable, no shifting) if not guid or not slot or not spellId then return end guid = CleveRoids.NormalizeGUID(guid) @@ -4767,28 +4768,44 @@ ev:SetScript("OnEvent", function() if not spellName then return end local _, playerGUID = UnitExists("player") + local isOurs = lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] ~= nil -- Update slot info in ownDebuffs if this is our debuff - if lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then + if isOurs then lib.ownDebuffs[guid][spellName].slot = slot + if auraSlot then + lib.ownDebuffs[guid][spellName].auraSlot = auraSlot + end end - -- Update allSlots for slot tracking - lib.allSlots[guid] = lib.allSlots[guid] or {} - lib.allSlots[guid][slot] = { - spellName = spellName, - casterGuid = playerGUID, -- Default to player, updated if other caster known - isOurs = lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] ~= nil, - } - - -- Check allAuraCasts for caster info if not ours + -- Determine caster GUID + local casterGuid = playerGUID -- Default to player if lib.allAuraCasts[guid] and lib.allAuraCasts[guid][spellName] then - for casterGuid, _ in pairs(lib.allAuraCasts[guid][spellName]) do - lib.allSlots[guid][slot].casterGuid = casterGuid + for cGuid, _ in pairs(lib.allAuraCasts[guid][spellName]) do + casterGuid = cGuid break end end + -- v2.30+: Use stable auraSlot for slotOwnership (no shifting needed) + if auraSlot then + lib.slotOwnership[guid] = lib.slotOwnership[guid] or {} + lib.slotOwnership[guid][auraSlot] = { + casterGuid = casterGuid, + spellName = spellName, + spellId = spellId, + isOurs = isOurs, + } + end + + -- Legacy: Update allSlots for slot tracking (pre-v2.30 or fallback) + lib.allSlots[guid] = lib.allSlots[guid] or {} + lib.allSlots[guid][slot] = { + spellName = spellName, + casterGuid = casterGuid, + isOurs = isOurs, + } + elseif event == "DEBUFF_REMOVED_OTHER" then -- Skip if pfUI enhanced tracking is active if lib.hasPfUIEnhanced then return end @@ -4796,6 +4813,7 @@ ev:SetScript("OnEvent", function() local guid = arg1 local slot = arg2 local spellId = arg3 + local auraSlot = arg6 -- v2.30+: raw 0-based aura slot index (stable, no shifting) if not guid or not slot then return end guid = CleveRoids.NormalizeGUID(guid) @@ -4812,11 +4830,16 @@ ev:SetScript("OnEvent", function() lib.ownSlots[guid][slot] = nil end - -- Remove from allSlots and shift slots down + -- v2.30+: Remove from stable slotOwnership (no shifting needed!) + if auraSlot and lib.slotOwnership[guid] then + lib.slotOwnership[guid][auraSlot] = nil + end + + -- Legacy: Remove from allSlots and shift slots down (pre-v2.30 or fallback) if lib.allSlots[guid] and lib.allSlots[guid][slot] then lib.allSlots[guid][slot] = nil - -- Shift slots down + -- Shift slots down (only needed for legacy slot tracking) local maxSlot = 0 for s in pairs(lib.allSlots[guid]) do if s > maxSlot then maxSlot = s end @@ -4849,6 +4872,9 @@ ev:SetScript("OnEvent", function() if lib.allSlots[guid] then lib.allSlots[guid] = nil end + if lib.slotOwnership[guid] then + lib.slotOwnership[guid] = nil + end if lib.allAuraCasts[guid] then lib.allAuraCasts[guid] = nil end @@ -7980,7 +8006,8 @@ function CleveRoids.ClearReactiveProc(spellName) end -- Parse combat log for reactive ability triggers -function CleveRoids.ParseReactiveCombatLog() +-- lowerMsg: optional pre-lowercased message from dispatcher (avoids redundant string.lower allocation) +function CleveRoids.ParseReactiveCombatLog(lowerMsg) if not arg1 then return end local message = arg1 @@ -7991,7 +8018,8 @@ function CleveRoids.ParseReactiveCombatLog() end -- PERFORMANCE: Quick keyword check - most messages won't match - local lowerMsg = lower(message) + -- Use pre-computed lowerMsg if provided by dispatcher, otherwise compute it + lowerMsg = lowerMsg or lower(message) if not (strfind(lowerMsg, "dodge") or strfind(lowerMsg, "parry") or strfind(lowerMsg, "block")) then return end @@ -8420,7 +8448,8 @@ function CleveRoids.CheckResistState(resistType) end -- Parse combat log for resist messages -local function ParseResistCombatLog() +-- lowerMsg: optional pre-lowercased message from dispatcher (avoids redundant string.lower allocation) +local function ParseResistCombatLog(lowerMsg) local message = arg1 -- Formatted message (localized) if not message then return end @@ -8429,7 +8458,8 @@ local function ParseResistCombatLog() if string.len(message) < 15 then return end -- PERFORMANCE: Quick keyword check before expensive pattern matching - local lowerMsg = string.lower(message) + -- Use pre-computed lowerMsg if provided by dispatcher, otherwise compute it + lowerMsg = lowerMsg or string.lower(message) if not string.find(lowerMsg, "resist") then return end -- Get current target info for matching @@ -8603,19 +8633,45 @@ unifiedCombatLogFrame:RegisterEvent("RAW_COMBATLOG") unifiedCombatLogFrame:SetScript("OnEvent", function() if event ~= "RAW_COMBATLOG" then return end - -- 1. Debuff fade learning (for duration auto-learning) - HandleDebuffFade() + -- PERFORMANCE: Dispatcher-level triage to avoid calling handlers that will immediately return. + -- Each handler previously did its own nil/length/keyword checks independently, resulting in + -- redundant work and unnecessary function call overhead. Now we do one pass of keyword checks + -- and only call relevant handlers. Also shares string.lower() between reactive and resist handlers. + local msg = arg1 + local raw = arg2 + + -- 1. Debuff fade learning (checks raw/arg2 for "fades from") + if raw and string.len(raw) >= 12 and find(raw, "fades from") then + HandleDebuffFade() + end + + -- All remaining handlers need arg1 + if not msg then return end + local msgLen = string.len(msg) + if msgLen < 8 then return end -- 2. Immunity detection (immune/reflect/evade from combat log) - ParseImmunityCombatLog() + if find(msg, "immune") or find(msg, "reflect") or find(msg, "evade") then + ParseImmunityCombatLog() + end -- 3. "Afflicted by" detection for hidden CC (e.g., Pounce stun) - ParseAfflictedCombatLog() + if find(msg, "afflicted by") then + ParseAfflictedCombatLog() + end + + -- PERFORMANCE: Compute lowerMsg once, share between reactive and resist handlers + -- Previously each handler called string.lower() independently (2 string allocations) + local lowerMsg = lower(msg) -- 4. Reactive ability procs (always run - AUTO_ATTACK events only cover white swings, -- yellow ability dodges like Mortal Strike/Heroic Strike come through combat log) - CleveRoids.ParseReactiveCombatLog() + if strfind(lowerMsg, "dodge") or strfind(lowerMsg, "parry") or strfind(lowerMsg, "block") then + CleveRoids.ParseReactiveCombatLog(lowerMsg) + end -- 5. Resist tracking - ParseResistCombatLog() + if msgLen >= 15 and strfind(lowerMsg, "resist") then + ParseResistCombatLog(lowerMsg) + end end)