From 9c1f8649eb4b2480889e41b50982de84f2500177 Mon Sep 17 00:00:00 2001 From: Jrc13245 Date: Wed, 10 Dec 2025 17:56:57 -0500 Subject: [PATCH] update channeling to respect arcane missiles haste --- Conditionals.lua | 328 +++++++++++++++++++++++++++++++++++------------ Core.lua | 154 +++++++++++++++++----- Utility.lua | 280 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 632 insertions(+), 130 deletions(-) diff --git a/Conditionals.lua b/Conditionals.lua index 946081e..ab025d1 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -5,6 +5,165 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} +-- Permanent cache for name normalization (underscores to spaces) +local _normalizedNames = {} + +-- Cached name normalization +function CleveRoids.NormalizeName(name) + if not name then return name end + local c = _normalizedNames[name] + if c then return c end + c = string.gsub(name, "_", " ") + _normalizedNames[name] = c + return c +end + +-- Direct passthrough functions (no caching overhead for normal usage) +function CleveRoids.GetCachedTime() + return GetTime() +end + +function CleveRoids.GetCachedPlayerHealthPercent() + local max = UnitHealthMax("player") + return max > 0 and (100 * UnitHealth("player") / max) or 0 +end + +function CleveRoids.GetCachedPlayerPowerPercent() + local max = UnitManaMax("player") + return max > 0 and (100 * UnitMana("player") / max) or 0 +end + +function CleveRoids.GetCachedPlayerPower() + return UnitMana("player") +end + +function CleveRoids.GetCachedTargetHealthPercent() + if not UnitExists("target") then return 0 end + local max = UnitHealthMax("target") + return max > 0 and (100 * UnitHealth("target") / max) or 0 +end + +-- Cooldown uses original function directly +function CleveRoids.GetCachedCooldown(name, ignoreGCD) + return CleveRoids._GetCooldownUncached(name, ignoreGCD) +end + +-- No-ops for compatibility +function CleveRoids.ClearFrameCache() end +function CleveRoids.TrackButtonPress() end +function CleveRoids.GetCacheStats() return nil end +function CleveRoids.SetCacheTTL(ms) end +function CleveRoids.SetMacroThrottle(ms) end +function CleveRoids.GetMacroThrottle() return 0 end + +-- ============================================================================ +-- PERFORMANCE: Cache spell name -> spell ID mappings for debuff lookups +-- This avoids iterating personalDebuffs/sharedDebuffs and calling SpellInfo() repeatedly +local _spellNameToIDs = {} -- [spellName] = { spellID1, spellID2, ... } +local _spellNameToIDsBuilt = false + +-- Build the spell name to ID cache (called once on first debuff check) +local function BuildSpellNameCache() + if _spellNameToIDsBuilt then return end + _spellNameToIDsBuilt = true + + local lib = CleveRoids.libdebuff + if not lib then return end + + local gsub = string.gsub + + if lib.personalDebuffs then + for sid, _ in pairs(lib.personalDebuffs) do + local name = SpellInfo(sid) + if name then + name = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") + if not _spellNameToIDs[name] then + _spellNameToIDs[name] = {} + end + table.insert(_spellNameToIDs[name], sid) + end + end + end + + if lib.sharedDebuffs then + for sid, _ in pairs(lib.sharedDebuffs) do + local name = SpellInfo(sid) + if name then + name = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") + if not _spellNameToIDs[name] then + _spellNameToIDs[name] = {} + end + -- Avoid duplicates + local found = false + for _, existingId in ipairs(_spellNameToIDs[name]) do + if existingId == sid then found = true; break end + end + if not found then + table.insert(_spellNameToIDs[name], sid) + end + end + end + end +end + +-- Get cached spell IDs for a spell name +local function GetSpellIDsForName(spellName) + BuildSpellNameCache() + return _spellNameToIDs[spellName] +end + +-- Invalidate cache (call if debuff lists change) +function CleveRoids.InvalidateSpellNameCache() + _spellNameToIDs = {} + _spellNameToIDsBuilt = false +end + +-- PERFORMANCE: Equipment cache for HasGearEquipped (avoids 19-slot scan per call) +-- Invalidated on UNIT_INVENTORY_CHANGED via CleveRoids.InvalidateEquipmentCache() +local _equippedItemIDs = {} -- [slot] = itemID (number) +local _equippedItemNames = {} -- [slot] = itemName (lowercase string) +local _equipmentCacheValid = false + +local function BuildEquipmentCache() + if _equipmentCacheValid then return end + _equipmentCacheValid = true + + -- Clear old data + for i = 1, 19 do + _equippedItemIDs[i] = nil + _equippedItemNames[i] = nil + end + + local string_find = string.find + local string_lower = string.lower + + for slot = 1, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + local _, _, id = string_find(link, "item:(%d+)") + local _, _, nameInBrackets = string_find(link, "%[(.+)%]") + + if id then + _equippedItemIDs[slot] = tonumber(id) + end + if nameInBrackets then + _equippedItemNames[slot] = string_lower(nameInBrackets) + elseif id then + -- Fallback: resolve via GetItemInfo + local itemName = GetItemInfo(tonumber(id)) + if itemName then + _equippedItemNames[slot] = string_lower(itemName) + end + end + end + end +end + +-- Invalidate equipment cache (call on UNIT_INVENTORY_CHANGED) +function CleveRoids.InvalidateEquipmentCache() + _equipmentCacheValid = false +end + --This table maps stat keys to the functions that retrieve their values. local stat_checks = { -- Base Stats (Corrected to use the 'effective' stat with gear) @@ -49,12 +208,14 @@ local stat_checks = { shadow_res = function() local _, val = UnitResistance("player", 6); return val end } -local function And(t,func) +-- PERFORMANCE: Avoid creating wrapper tables for single values +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 - t = { [1] = t } + return func(t) and true or false end - for k,v in pairs(t) do + for k, v in pairs(t) do if not func(v) then return false end @@ -62,12 +223,13 @@ local function And(t,func) return true end -local function Or(t,func) +local function Or(t, func) if type(func) ~= "function" then return false end + -- PERFORMANCE: Handle non-table case without allocation if type(t) ~= "table" then - t = { [1] = t } + return func(t) and true or false end - for k,v in pairs(t) do + for k, v in pairs(t) do if func(v) then return true end @@ -81,8 +243,10 @@ end -- - AND separator (&) uses And() logic -> ALL values must match local function Multi(t, func, conditionals, condition) if type(func) ~= "function" then return false end + + -- PERFORMANCE: Handle non-table case without allocation if type(t) ~= "table" then - t = { [1] = t } + return func(t) and true or false end -- Check operator type from metadata @@ -93,10 +257,16 @@ local function Multi(t, func, conditionals, condition) if operatorType == "AND" then -- AND separator (&): ALL must match - return And(t, func) + for k, v in pairs(t) do + if not func(v) then return false end + end + return true else -- OR separator (/) [default]: ANY can match - return Or(t, func) + for k, v in pairs(t) do + if func(v) then return true end + end + return false end end @@ -107,8 +277,10 @@ end -- This matches natural language: "no X or Y" intuitively means "neither X nor Y" local function NegatedMulti(t, func, conditionals, condition) if type(func) ~= "function" then return false end + + -- PERFORMANCE: Handle non-table case without allocation if type(t) ~= "table" then - t = { [1] = t } + return func(t) and true or false end -- Check operator type from metadata @@ -120,10 +292,16 @@ 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) - return Or(t, func) + for k, v in pairs(t) do + if func(v) then return true end + end + return false else -- OR separator (/) [default]: ALL negations must pass (missing all) - return And(t, func) + for k, v in pairs(t) do + if not func(v) then return false end + end + return true end end @@ -342,29 +520,20 @@ end function CleveRoids.HasGearEquipped(gearId) if not gearId then return false end + -- PERFORMANCE: Build/refresh equipment cache if needed + BuildEquipmentCache() + -- Handle both numeric IDs and string IDs like "5196" local wantId = tonumber(gearId) local wantName = (type(gearId) == "string" and not wantId) and string.lower(gearId) or nil + -- PERFORMANCE: Use cached data instead of scanning all slots for slot = 1, 19 do - local link = GetInventoryItemLink("player", slot) - if link then - local _, _, id = string.find(link, "item:(%d+)") - local _, _, nameInBrackets = string.find(link, "%[(.+)%]") - - if wantId and id and tonumber(id) == wantId then - return true - end - if wantName and nameInBrackets and string.lower(nameInBrackets) == wantName then - return true - end - -- Fallback: resolve via GetItemInfo - if wantName and not nameInBrackets and id then - local itemName = GetItemInfo(tonumber(id)) - if itemName and string.lower(itemName) == wantName then - return true - end - end + if wantId and _equippedItemIDs[slot] == wantId then + return true + end + if wantName and _equippedItemNames[slot] == wantName then + return true end end return false @@ -1048,50 +1217,50 @@ end -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed function CleveRoids.ValidateCooldown(args, ignoreGCD) if not args then return false end - if type(args) ~= "table" then - -- Normalize the spell name by replacing underscores with spaces - local normalized = string.gsub(args, "_", " ") - -- NEW: If this is a numeric slot (1-19), resolve to the equipped item's name - local slotNum = tonumber(normalized) + local name + if type(args) ~= "table" then + -- PERFORMANCE: Use cached normalization + name = CleveRoids.NormalizeName(args) + + -- If this is a numeric slot (1-19), resolve to the equipped item's name + local slotNum = tonumber(name) if slotNum and slotNum >= 1 and slotNum <= 19 then local link = GetInventoryItemLink("player", slotNum) if link then - -- Extract item name from link local _, _, itemName = string.find(link, "%[(.+)%]") - if itemName then - normalized = itemName - end + if itemName then name = itemName end end end - - args = {name = normalized} + args = {name = name} else - -- Also normalize if it's already a table if args.name then - args.name = string.gsub(args.name, "_", " ") + -- PERFORMANCE: Use cached normalization + name = CleveRoids.NormalizeName(args.name) - -- NEW: If this is a numeric slot (1-19), resolve to the equipped item's name - local slotNum = tonumber(args.name) + -- If this is a numeric slot (1-19), resolve to the equipped item's name + local slotNum = tonumber(name) if slotNum and slotNum >= 1 and slotNum <= 19 then local link = GetInventoryItemLink("player", slotNum) if link then - -- Extract item name from link local _, _, itemName = string.find(link, "%[(.+)%]") - if itemName then - args.name = itemName - end + if itemName then name = itemName end end end + args.name = name + else + name = args.name end end + -- PERFORMANCE: GetCooldown is now cached per-frame local expires = CleveRoids.GetCooldown(args.name, ignoreGCD) + local now = CleveRoids.GetCachedTime() if not args.operator and not args.amount then - return expires > GetTime() + return expires > now elseif CleveRoids.operators[args.operator] then - return CleveRoids.comparators[args.operator](expires - GetTime(), args.amount) + return CleveRoids.comparators[args.operator](expires - now, args.amount) end end @@ -1262,33 +1431,11 @@ function CleveRoids.ValidateUnitDebuff(unit, args) guid = CleveRoids.NormalizeGUID(guid) if not guid then return false end - -- Find ALL spell IDs that match this name (all ranks) - local matchingSpellIDs = {} - if CleveRoids.libdebuff.personalDebuffs then - for sid, _ in pairs(CleveRoids.libdebuff.personalDebuffs) do - local name = SpellInfo(sid) - if name then - name = string.gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") - if name == args.name then - table.insert(matchingSpellIDs, sid) - end - end - end - end - if CleveRoids.libdebuff.sharedDebuffs then - for sid, _ in pairs(CleveRoids.libdebuff.sharedDebuffs) do - local name = SpellInfo(sid) - if name then - name = string.gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") - if name == args.name then - table.insert(matchingSpellIDs, sid) - end - end - end - end + -- PERFORMANCE: Use cached spell name -> ID mapping instead of iterating every call + local matchingSpellIDs = GetSpellIDsForName(args.name) or {} -- Check tracking table for ANY rank of this spell: Did player cast this? Is timer valid? - if table.getn(matchingSpellIDs) > 0 then + if matchingSpellIDs and table.getn(matchingSpellIDs) > 0 then for _, spellID in ipairs(matchingSpellIDs) do local rec = CleveRoids.libdebuff.objects[guid] and CleveRoids.libdebuff.objects[guid][spellID] -- For shared debuffs (Sunder, Faerie Fire, etc.), accept any caster (including nil) @@ -1668,7 +1815,8 @@ function CleveRoids.ValidateWeaponImbue(slot, imbueName) end -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed -function CleveRoids.GetCooldown(name, ignoreGCD) +-- PERFORMANCE: Uncached version - called by GetCachedCooldown +function CleveRoids._GetCooldownUncached(name, ignoreGCD) if not name then return 0 end -- Check if it's a spell first @@ -1684,12 +1832,17 @@ function CleveRoids.GetCooldown(name, ignoreGCD) -- Convert remaining seconds to absolute expiry time if remaining and remaining > 0 then - return GetTime() + remaining + return CleveRoids.GetCachedTime() + remaining end return 0 end +-- PERFORMANCE: Cached wrapper - use this for conditional checks +function CleveRoids.GetCooldown(name, ignoreGCD) + return CleveRoids.GetCachedCooldown(name, ignoreGCD) +end + -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed -- Returns the cooldown of the given spellName or nil if no such spell was found function CleveRoids.GetSpellCooldown(spellName, ignoreGCD) @@ -2530,7 +2683,8 @@ CleveRoids.Keywords = { -- Handle multi-comparison (e.g., >50&<80) if args.comparisons and type(args.comparisons) == "table" then - local powerPercent = 100 / UnitManaMax("player") * UnitMana("player") + -- PERFORMANCE: Use cached player power + local powerPercent = CleveRoids.GetCachedPlayerPowerPercent() -- ALL comparisons must pass (AND logic) for _, comp in ipairs(args.comparisons) do @@ -2580,7 +2734,8 @@ CleveRoids.Keywords = { -- Handle multi-comparison (e.g., >500&<1000) if args.comparisons and type(args.comparisons) == "table" then - local power = UnitMana("player") + -- PERFORMANCE: Use cached player power + local power = CleveRoids.GetCachedPlayerPower() -- ALL comparisons must pass (AND logic) for _, comp in ipairs(args.comparisons) do @@ -2627,7 +2782,15 @@ CleveRoids.Keywords = { if args.comparisons and type(args.comparisons) == "table" then local unit = conditionals.target or "target" if not UnitExists(unit) then return false end - local hp = 100 * UnitHealth(unit) / UnitHealthMax(unit) + + -- PERFORMANCE: Use cached health for target + local hp + if unit == "target" then + hp = CleveRoids.GetCachedTargetHealthPercent() + else + local maxHp = UnitHealthMax(unit) + hp = maxHp > 0 and (100 * UnitHealth(unit) / maxHp) or 0 + end -- ALL comparisons must pass (AND logic) for _, comp in ipairs(args.comparisons) do @@ -2706,7 +2869,8 @@ CleveRoids.Keywords = { -- Handle multi-comparison (e.g., >50&<80) if args.comparisons and type(args.comparisons) == "table" then - local hp = 100 * UnitHealth("player") / UnitHealthMax("player") + -- PERFORMANCE: Use cached player health + local hp = CleveRoids.GetCachedPlayerHealthPercent() -- ALL comparisons must pass (AND logic) for _, comp in ipairs(args.comparisons) do diff --git a/Core.lua b/Core.lua index 7fbd1bd..134dae5 100644 --- a/Core.lua +++ b/Core.lua @@ -53,6 +53,33 @@ local string_gsub = string.gsub local table_insert = table.insert local table_getn = table.getn +-- PERFORMANCE: Module-level constant for boolean conditionals (avoid per-call table creation) +local BOOLEAN_CONDITIONALS = { + combat = true, + nocombat = true, + stealth = true, + nostealth = true, + channeled = true, + nochanneled = true, + checkchanneled = true, + checkcasting = true, + dead = true, + alive = true, + help = true, + harm = true, + exists = true, + party = true, + raid = true, + resting = true, + noresting = true, + isplayer = true, + isnpc = true, + mhimbue = true, + nomhimbue = true, + ohimbue = true, + noohimbue = true, +} + local requirementCheckFrame = CreateFrame("Frame") requirementCheckFrame:RegisterEvent("ADDON_LOADED") requirementCheckFrame:SetScript("OnEvent", function() @@ -1587,34 +1614,8 @@ function CleveRoids.ParseMsg(msg) -- No args → the action is the implicit argument if not args or args == "" then if not conditionals[condition] then - -- Check if this is a boolean conditional (combat, stealth, channeled, etc.) - local booleanConditionals = { - combat = true, - nocombat = true, - stealth = true, - nostealth = true, - channeled = true, - nochanneled = true, - checkchanneled = true, - checkcasting = true, - dead = true, - alive = true, - help = true, - harm = true, - exists = true, - party = true, - raid = true, - resting = true, - noresting = true, - isplayer = true, - isnpc = true, - mhimbue = true, - nomhimbue = true, - ohimbue = true, - noohimbue = true, - } - - if booleanConditionals[condition] then + -- PERFORMANCE: Use module-level constant instead of creating table per-call + if BOOLEAN_CONDITIONALS[condition] then conditionals[condition] = true else conditionals[condition] = conditionals.action @@ -3635,13 +3636,22 @@ function CleveRoids.Frame:UNIT_CASTEVENT(caster,target,action,spell_id,cast_time -- For CHANNEL events, capture duration for checkchanneled conditional if action == "CHANNEL" then CleveRoids.channelStartTime = GetTime() - CleveRoids.channelDuration = cast_time / 1000 -- Convert ms to seconds + -- Try to get accurate duration from spell tooltip (reflects haste) + local tooltipDuration = CleveRoids.GetChannelDurationFromTooltipByID(spell_id) + if tooltipDuration then + CleveRoids.channelDuration = tooltipDuration + else + -- Fallback to UNIT_CASTEVENT duration (may not reflect haste) + CleveRoids.channelDuration = cast_time / 1000 + end end -- For START events, capture cast time for checkcasting conditional if action == "START" then CleveRoids.castStartTime = GetTime() - CleveRoids.castDuration = cast_time / 1000 -- Convert ms to seconds + -- For cast-time spells, UNIT_CASTEVENT duration is usually accurate + -- but we could add tooltip scanning here too if needed + CleveRoids.castDuration = cast_time / 1000 end end @@ -3933,6 +3943,11 @@ end function CleveRoids.Frame:UNIT_INVENTORY_CHANGED() if arg1 ~= "player" then return end + -- PERFORMANCE: Invalidate equipment cache for HasGearEquipped + if CleveRoids.InvalidateEquipmentCache then + CleveRoids.InvalidateEquipmentCache() + end + -- In combat: Skip ALL processing - EquipBagItem already handles cache invalidation -- This eliminates lag from IndexEquippedItems during rapid gear swapping if UnitAffectingCombat("player") then @@ -5219,6 +5234,85 @@ SlashCmdList["CLEVEROID"] = function(msg) return end + -- tooltipdebug (debug spell tooltip scanning) + if cmd == "tooltipdebug" or cmd == "ttdebug" or cmd == "spelltooltip" then + local spellName = val + if val2 and val2 ~= "" then + spellName = val .. " " .. val2 + end + if not spellName or spellName == "" then + spellName = "Arcane Missiles" -- Default to Arcane Missiles + end + -- Strip underscores + spellName = string.gsub(spellName, "_", " ") + + CleveRoids.Print("|cff88ff88=== Tooltip Scan Debug: " .. spellName .. " ===|r") + + -- Find highest rank spell in spellbook (same logic as GetSpellSlotByName) + local slot, bookType = nil, nil + local foundRank = nil + local i = 1 + while true do + local name, rank = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + if name == spellName then + -- Keep updating to find the last (highest) rank + slot = i + bookType = BOOKTYPE_SPELL + foundRank = rank + end + i = i + 1 + end + + if not slot then + CleveRoids.Print("|cffff0000Spell not found in spellbook!|r") + return + end + + CleveRoids.Print("Found in spellbook slot: " .. slot .. " (rank: " .. (foundRank or "none") .. ")") + + -- Create tooltip if needed + if not CleveRoidsDebugTooltip then + CreateFrame("GameTooltip", "CleveRoidsDebugTooltip", nil, "GameTooltipTemplate") + CleveRoidsDebugTooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + end + + CleveRoidsDebugTooltip:ClearLines() + CleveRoidsDebugTooltip:SetSpell(slot, bookType) + + CleveRoids.Print("Tooltip lines (" .. CleveRoidsDebugTooltip:NumLines() .. "):") + for lineNum = 1, CleveRoidsDebugTooltip:NumLines() do + local leftText = getglobal("CleveRoidsDebugTooltipTextLeft" .. lineNum) + local rightText = getglobal("CleveRoidsDebugTooltipTextRight" .. lineNum) + + local leftStr = leftText and leftText:GetText() or "" + local rightStr = rightText and rightText:GetText() or "" + + if leftStr and leftStr ~= "" then + CleveRoids.Print(" L" .. lineNum .. ": " .. leftStr) + -- Check for duration pattern + local duration = string.match(leftStr, "for (%d+%.?%d*) sec") + if duration then + CleveRoids.Print(" |cff00ff00^ Found 'for X sec': " .. duration .. "s|r") + end + local duration2 = string.match(leftStr, "over (%d+%.?%d*) sec") + if duration2 then + CleveRoids.Print(" |cff00ff00^ Found 'over X sec': " .. duration2 .. "s|r") + end + end + if rightStr and rightStr ~= "" then + CleveRoids.Print(" R" .. lineNum .. ": " .. rightStr) + end + end + + -- Show what GetSpellDurationFromTooltip returns + local cachedDuration = CleveRoids.GetSpellDurationFromTooltip and CleveRoids.GetSpellDurationFromTooltip(spellName) + CleveRoids.Print(" ") + CleveRoids.Print("GetSpellDurationFromTooltip result: " .. (cachedDuration and (cachedDuration .. "s") or "|cffff0000nil|r")) + + return + end + -- Unknown command fallback CleveRoids.Print("Usage:") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid - Show current settings") diff --git a/Utility.lua b/Utility.lua index d6e9e12..237943b 100644 --- a/Utility.lua +++ b/Utility.lua @@ -24,6 +24,131 @@ function CleveRoids.NormalizeGUID(guid) return tostring(guid) end +-- Hidden tooltip for scanning spell info +local SpellScanTooltip = nil + +-- Create hidden tooltip for scanning (once) +local function GetSpellScanTooltip() + if not SpellScanTooltip then + SpellScanTooltip = CreateFrame("GameTooltip", "CleveRoidsSpellScanTooltip", nil, "GameTooltipTemplate") + SpellScanTooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + end + return SpellScanTooltip +end + +-- Cache for spell durations from tooltip +local cachedSpellDurations = {} +local spellDurationCacheTime = {} +local SPELL_CACHE_DURATION = 0.5 -- Re-scan every 0.5 seconds (haste can change mid-fight) + +-- Get a spell's slot in the spellbook by spell ID +local function GetSpellSlotByID(targetSpellID) + local i = 1 + while true do + local spellName = GetSpellName(i, BOOKTYPE_SPELL) + if not spellName then break end + -- Get spell ID for this slot using SpellInfo if available + if SpellInfo then + local _, _, spellID = GetSpellName(i, BOOKTYPE_SPELL) + -- Try to get ID from the spell slot + local slot, book = i, BOOKTYPE_SPELL + -- SpellInfo needs the spell name to get ID, but we can check via GetSpellTexture match + end + i = i + 1 + end + return nil, nil +end + +-- Get a spell's slot in the spellbook by name (finds highest rank by default) +-- If targetRank is specified (e.g., "Rank 5"), finds that specific rank +local function GetSpellSlotByName(targetSpellName, targetRank) + local foundSlot = nil + local foundRank = nil + local i = 1 + while true do + local spellName, spellRank = GetSpellName(i, BOOKTYPE_SPELL) + if not spellName then break end + if spellName == targetSpellName then + if targetRank then + -- Looking for specific rank + if spellRank == targetRank then + return i, BOOKTYPE_SPELL + end + else + -- Looking for highest rank - keep scanning to find the last one + foundSlot = i + foundRank = spellRank + end + end + i = i + 1 + end + -- Return highest rank found (last match in spellbook order) + return foundSlot, BOOKTYPE_SPELL +end + +-- Parse channel duration from tooltip text +-- Matches patterns like "for 5 sec" or "over 8 sec" at the end of descriptions +local function ParseChannelDurationFromText(text) + if not text then return nil end + -- Match "for X sec" pattern (channels like Arcane Missiles, Drain Life) + local duration = string.match(text, "for (%d+%.?%d*) sec") + if duration then + return tonumber(duration) + end + -- Match "over X sec" pattern (some DoT/HoT tooltips) + duration = string.match(text, "over (%d+%.?%d*) sec") + if duration then + return tonumber(duration) + end + return nil +end + +-- Scan spell tooltip for channel/cast duration +-- Returns duration in seconds, or nil if not found +function CleveRoids.GetSpellDurationFromTooltip(spellName) + if not spellName then return nil end + + -- Check cache + local now = GetTime() + if cachedSpellDurations[spellName] and spellDurationCacheTime[spellName] then + if now - spellDurationCacheTime[spellName] < SPELL_CACHE_DURATION then + return cachedSpellDurations[spellName] + end + end + + local slot, bookType = GetSpellSlotByName(spellName) + if not slot then return nil end + + local tooltip = GetSpellScanTooltip() + tooltip:ClearLines() + tooltip:SetSpell(slot, bookType) + + -- Scan tooltip lines for duration + for i = 1, tooltip:NumLines() do + local leftText = getglobal("CleveRoidsSpellScanTooltipTextLeft" .. i) + if leftText then + local text = leftText:GetText() + local duration = ParseChannelDurationFromText(text) + if duration then + -- Cache the result + cachedSpellDurations[spellName] = duration + spellDurationCacheTime[spellName] = now + return duration + end + end + end + + return nil +end + +-- Get channel duration for a spell by ID (looks up name first) +function CleveRoids.GetChannelDurationFromTooltipByID(spellID) + if not spellID or not SpellInfo then return nil end + local spellName = SpellInfo(spellID) + if not spellName then return nil end + return CleveRoids.GetSpellDurationFromTooltip(spellName) +end + if type(hooksecurefunc) ~= "function" then function hooksecurefunc(arg1, arg2, arg3) local tgt, fname, post @@ -86,11 +211,31 @@ function CleveRoids.Seq(_, i) return (i or 0) + 1 end +-- PERFORMANCE: Trim cache to avoid repeated gsub pattern matching +local _trimCache = {} +local _trimCacheSize = 0 +local _TRIM_CACHE_MAX = 512 + function CleveRoids.Trim(str) if not str then return nil end - return string.gsub(str,"^%s*(.-)%s*$", "%1") + + -- PERFORMANCE: Check cache first + local cached = _trimCache[str] + if cached ~= nil then + return cached + end + + local result = string.gsub(str, "^%s*(.-)%s*$", "%1") + + -- Cache the result (limit cache size) + if _trimCacheSize < _TRIM_CACHE_MAX then + _trimCache[str] = result + _trimCacheSize = _trimCacheSize + 1 + end + + return result end do @@ -208,29 +353,114 @@ function CleveRoids.splitString(str, seperatorPattern) return tbl end -function CleveRoids.splitStringIgnoringQuotes(str, separator) - local result = {} - local temp = "" - local insideQuotes = false - local separators = {} +-- PERFORMANCE: Cache for splitStringIgnoringQuotes results +-- Key: str .. "|" .. separator_key, Value: result table +local _splitCache = {} +local _splitCacheSize = 0 +local _SPLIT_CACHE_MAX = 256 -- Limit cache size to prevent unbounded growth - if type(separator) == "table" then - for _, s in separator do - separators[s] = s +-- PERFORMANCE: Pre-built separator tables for common cases +local _defaultSeparator = { [";"] = ";" } +local _commaSeparator = { [","] = ",", [" "] = " " } +local _colonSeparator = { [":"] = ":" } + +-- PERFORMANCE: Reusable result table pool +local _splitResultPool = {} +local _splitResultPoolSize = 0 + +local function getSplitResult() + if _splitResultPoolSize > 0 then + local r = _splitResultPool[_splitResultPoolSize] + _splitResultPool[_splitResultPoolSize] = nil + _splitResultPoolSize = _splitResultPoolSize - 1 + -- Clear the table for reuse + for k in pairs(r) do r[k] = nil end + return r + end + return {} +end + +-- Return a result to the pool (call when done iterating) +function CleveRoids.ReleaseSplitResult(result) + if result and _splitResultPoolSize < 16 then + _splitResultPoolSize = _splitResultPoolSize + 1 + _splitResultPool[_splitResultPoolSize] = result + end +end + +function CleveRoids.splitStringIgnoringQuotes(str, separator) + if not str then return {""} end + + -- PERFORMANCE: Build cache key and check cache + local sepKey + if separator == nil then + sepKey = ";" + elseif type(separator) == "table" then + -- For table separators, use a simple identifier + if separator[1] == "," and separator[2] == " " then + sepKey = ",_" + else + sepKey = "t" end else - separators[separator or ";"] = separator or ";" + sepKey = separator end - for i = 1, string.len(str) do - local char = string.sub(str, i, i) + local cacheKey = str .. "|" .. sepKey + local cached = _splitCache[cacheKey] + if cached then + -- Return a copy to prevent mutation issues + local copy = getSplitResult() + for i = 1, table.getn(cached) do + copy[i] = cached[i] + end + return copy + end + + local result = getSplitResult() + local temp = "" + local insideQuotes = false + local separators + + -- PERFORMANCE: Use pre-built separator tables when possible + if separator == nil then + separators = _defaultSeparator + elseif type(separator) == "table" then + if separator[1] == "," and separator[2] == " " then + separators = _commaSeparator + elseif separator[1] == ":" then + separators = _colonSeparator + else + separators = {} + for _, s in separator do + separators[s] = s + end + end + else + if separator == ";" then + separators = _defaultSeparator + elseif separator == ":" then + separators = _colonSeparator + else + separators = { [separator] = separator } + end + end + + -- PERFORMANCE: Use local references and avoid repeated function calls + local strlen = string.len(str) + local strsub = string.sub + local Trim = CleveRoids.Trim + local tinsert = table.insert + + for i = 1, strlen do + local char = strsub(str, i, i) if char == "\"" then insideQuotes = not insideQuotes temp = temp .. char - elseif char == separators[char] and not insideQuotes then - temp = CleveRoids.Trim(temp) - if temp ~= "" then table.insert(result, temp) end + elseif separators[char] and not insideQuotes then + temp = Trim(temp) + if temp ~= "" then tinsert(result, temp) end temp = "" else temp = temp .. char @@ -238,11 +468,25 @@ function CleveRoids.splitStringIgnoringQuotes(str, separator) end if temp ~= "" then - temp = CleveRoids.Trim(temp) - table.insert(result, temp) + temp = Trim(temp) + tinsert(result, temp) end - return (next(result) and result or {""}) + if not next(result) then + result[1] = "" + end + + -- PERFORMANCE: Cache the result (store a copy) + if _splitCacheSize < _SPLIT_CACHE_MAX then + local cacheCopy = {} + for i = 1, table.getn(result) do + cacheCopy[i] = result[i] + end + _splitCache[cacheKey] = cacheCopy + _splitCacheSize = _splitCacheSize + 1 + end + + return result end function CleveRoids.Print(...)