Files
Guda-ClassicAPI/Core/Utils.lua
T
2026-04-09 12:51:45 +04:00

1497 lines
52 KiB
Lua

-- Guda Utility Functions
local addon = Guda
local Utils = {}
addon.Modules.Utils = Utils
--=============================================================================
-- Timer Frame Pool (prevents memory leaks from temporary timers)
-- Global function so all modules can use it
--=============================================================================
local timerPool = {}
local TIMER_POOL_MAX = 10 -- Limit pool size to prevent unbounded growth
function Guda_ScheduleTimer(delay, callback)
-- Try to get a frame from the pool
local frame = table.remove(timerPool)
if not frame then
frame = CreateFrame("Frame")
end
frame.elapsed = 0
frame.delay = delay
frame.callback = callback
frame:SetScript("OnUpdate", function()
this.elapsed = this.elapsed + arg1
if this.elapsed >= this.delay then
this:SetScript("OnUpdate", nil)
this:Hide()
-- Return frame to pool if not full
if table.getn(timerPool) < TIMER_POOL_MAX then
table.insert(timerPool, this)
end
-- Execute callback
this.callback()
end
end)
frame:Show()
end
--=============================================================================
-- Tooltip Scan Caching
-- Caches results of expensive tooltip scanning operations
--=============================================================================
local tooltipCache = {
questItem = {}, -- IsQuestItem results
specialText = {}, -- HasSpecialTooltipText results
bindOnEquip = {}, -- IsBindOnEquip results
uniqueItem = {}, -- IsUniqueItem results
restoreTag = {}, -- GetConsumableRestoreTag results
}
local tooltipCacheStats = {
hits = 0,
misses = 0,
}
-- Clear all tooltip caches
function Utils:ClearTooltipCache()
tooltipCache.questItem = {}
tooltipCache.specialText = {}
tooltipCache.bindOnEquip = {}
tooltipCache.uniqueItem = {}
tooltipCache.restoreTag = {}
tooltipCacheStats.hits = 0
tooltipCacheStats.misses = 0
addon:Debug("Tooltip cache cleared")
end
-- Get tooltip cache statistics
function Utils:GetTooltipCacheStats()
local total = tooltipCacheStats.hits + tooltipCacheStats.misses
local hitRate = total > 0 and (tooltipCacheStats.hits / total * 100) or 0
return {
hits = tooltipCacheStats.hits,
misses = tooltipCacheStats.misses,
total = total,
hitRate = hitRate,
}
end
-- Generate cache key from item link
local function GetTooltipCacheKey(itemLink)
if not itemLink then return nil end
-- Extract item ID from link for stable caching
local _, _, itemID = string.find(itemLink, "item:(%d+)")
return itemID
end
--=============================================================================
-- Frame Budget System (Baganator-inspired performance optimization)
-- Prevents any single operation from causing frame lag by spreading work
-- across multiple frames when operations exceed a time budget.
--=============================================================================
-- Frame budget configuration
local FRAME_BUDGET_SECONDS = 0.1 -- 100ms budget per frame (same as Baganator)
local lastEntryTime = 0
local workQueue = {}
local workQueueFrame = nil
-- Report that we're starting work (call at the beginning of expensive operations)
-- This resets the frame budget timer
function Utils:ReportEntry()
lastEntryTime = GetTime()
end
-- Check if we've exceeded the frame budget
-- Returns true if we should defer remaining work to the next frame
function Utils:CheckTimeout()
return (GetTime() - lastEntryTime) > FRAME_BUDGET_SECONDS
end
-- Queue work to be executed in the next frame
-- callback: function to call
-- context: optional context/owner for the callback (for debugging/cleanup)
function Utils:QueueWork(callback, context)
if type(callback) ~= "function" then
addon:Error("QueueWork: callback must be a function")
return
end
table.insert(workQueue, {callback = callback, context = context or "unknown"})
-- Create the work queue processor frame if it doesn't exist
if not workQueueFrame then
workQueueFrame = CreateFrame("Frame", "Guda_WorkQueueFrame", UIParent)
workQueueFrame.elapsed = 0
workQueueFrame:Hide()
workQueueFrame:SetScript("OnUpdate", function()
-- Process queued work with frame budget
Utils:ReportEntry()
local processedCount = 0
local maxPerFrame = 50 -- Safety limit to prevent infinite loops
while table.getn(workQueue) > 0 and processedCount < maxPerFrame do
-- Check if we've exceeded frame budget
if Utils:CheckTimeout() then
-- Still have work but exceeded budget, continue next frame
addon:Debug("Frame budget exceeded, deferring %d items to next frame", table.getn(workQueue))
return
end
local work = table.remove(workQueue, 1)
if work and work.callback then
local success, err = pcall(work.callback)
if not success then
addon:Error("QueueWork callback error [%s]: %s", tostring(work.context), tostring(err))
end
end
processedCount = processedCount + 1
end
-- All work done, hide the frame to stop OnUpdate
if table.getn(workQueue) == 0 then
workQueueFrame:Hide()
end
end)
end
-- Show the frame to start processing
workQueueFrame:Show()
end
-- Clear all queued work (useful when frame is hidden)
function Utils:ClearWorkQueue()
workQueue = {}
if workQueueFrame then
workQueueFrame:Hide()
end
end
-- Get the number of items in the work queue (for debugging)
function Utils:GetWorkQueueSize()
return table.getn(workQueue)
end
-- Process items in batches with frame budget awareness
-- items: table of items to process
-- processor: function(item, index) called for each item
-- onComplete: optional function called when all items are processed
-- batchSize: optional number of items to process before checking timeout (default 10)
function Utils:ProcessWithBudget(items, processor, onComplete, batchSize)
if not items or table.getn(items) == 0 then
if onComplete then onComplete() end
return
end
batchSize = batchSize or 10
local index = 1
local totalItems = table.getn(items)
local function processNextBatch()
Utils:ReportEntry()
local batchCount = 0
while index <= totalItems and batchCount < batchSize do
if Utils:CheckTimeout() then
-- Budget exceeded, queue continuation
Utils:QueueWork(processNextBatch, "ProcessWithBudget")
return
end
local item = items[index]
if item then
local success, err = pcall(processor, item, index)
if not success then
addon:Error("ProcessWithBudget processor error at index %d: %s", index, tostring(err))
end
end
index = index + 1
batchCount = batchCount + 1
end
-- Check if we have more items
if index <= totalItems then
-- More items to process, queue next batch
Utils:QueueWork(processNextBatch, "ProcessWithBudget")
else
-- All done
if onComplete then
local success, err = pcall(onComplete)
if not success then
addon:Error("ProcessWithBudget onComplete error: %s", tostring(err))
end
end
end
end
-- Start processing
processNextBatch()
end
-- Performance metrics tracking
local performanceStats = {
budgetExceededCount = 0,
totalUpdates = 0,
lastUpdateDuration = 0,
averageUpdateDuration = 0,
}
-- Get the current frame budget setting (in seconds)
function Utils:GetFrameBudget()
return FRAME_BUDGET_SECONDS
end
-- Set the frame budget (in seconds, min 0.016 = 60fps, max 0.5)
function Utils:SetFrameBudget(seconds)
if type(seconds) ~= "number" then return end
FRAME_BUDGET_SECONDS = math.max(0.016, math.min(0.5, seconds))
addon:Debug("Frame budget set to %.3f seconds", FRAME_BUDGET_SECONDS)
end
-- Record the end of an update cycle for performance tracking
function Utils:RecordUpdateEnd()
local duration = GetTime() - lastEntryTime
performanceStats.lastUpdateDuration = duration
performanceStats.totalUpdates = performanceStats.totalUpdates + 1
-- Update rolling average
local alpha = 0.1 -- Smoothing factor
performanceStats.averageUpdateDuration = performanceStats.averageUpdateDuration * (1 - alpha) + duration * alpha
if duration > FRAME_BUDGET_SECONDS then
performanceStats.budgetExceededCount = performanceStats.budgetExceededCount + 1
end
end
-- Get performance statistics
function Utils:GetPerformanceStats()
return {
frameBudget = FRAME_BUDGET_SECONDS,
lastUpdateDuration = performanceStats.lastUpdateDuration,
averageUpdateDuration = performanceStats.averageUpdateDuration,
totalUpdates = performanceStats.totalUpdates,
budgetExceededCount = performanceStats.budgetExceededCount,
workQueueSize = table.getn(workQueue),
}
end
-- Reset performance statistics
function Utils:ResetPerformanceStats()
performanceStats.budgetExceededCount = 0
performanceStats.totalUpdates = 0
performanceStats.lastUpdateDuration = 0
performanceStats.averageUpdateDuration = 0
end
-- Print current performance statistics (for debugging)
function Utils:PrintPerformanceStats()
local stats = self:GetPerformanceStats()
addon:Print(Guda_L["=== Guda Performance Stats ==="])
addon:Print(Guda_L["Frame Budget: %.0fms"], stats.frameBudget * 1000)
addon:Print(Guda_L["Last Update: %.1fms"], stats.lastUpdateDuration * 1000)
addon:Print("Avg Update: %.1fms", stats.averageUpdateDuration * 1000)
addon:Print(Guda_L["Total Updates: %d"], stats.totalUpdates)
addon:Print(Guda_L["Budget Exceeded: %d times"], stats.budgetExceededCount)
addon:Print("Work Queue: %d items", stats.workQueueSize)
end
--=============================================================================
-- SafeCall: Nil-safe module method invocation
-- Replaces verbose nil-checks like:
-- if addon and addon.Modules and addon.Modules.Utils and addon.Modules.Utils.Method then
-- return addon.Modules.Utils:Method(arg1, arg2)
-- end
-- With:
-- return Utils:SafeCall("Utils", "Method", arg1, arg2)
--=============================================================================
-- Call a method on a module safely, returns nil if module/method doesn't exist
-- Parameters:
-- moduleName: Name of the module in addon.Modules (e.g., "Utils", "DB", "BagFrame")
-- methodName: Name of the method to call (e.g., "GetQualityColor", "GetSetting")
-- ...: Arguments to pass to the method
-- Returns: The return value(s) of the method, or nil if not callable
function Utils:SafeCall(moduleName, methodName, ...)
if not addon or not addon.Modules then
return nil
end
local module = addon.Modules[moduleName]
if not module then
return nil
end
local method = module[methodName]
if not method or type(method) ~= "function" then
return nil
end
-- Call method with module as self (for : style calls)
return method(module, unpack(arg))
end
-- Check if a module method exists without calling it
function Utils:HasMethod(moduleName, methodName)
if not addon or not addon.Modules then
return false
end
local module = addon.Modules[moduleName]
if not module then
return false
end
local method = module[methodName]
return method ~= nil and type(method) == "function"
end
-- Get a module reference safely
function Utils:GetModule(moduleName)
if not addon or not addon.Modules then
return nil
end
return addon.Modules[moduleName]
end
-- Format money (copper to gold/silver/copper string) - WoW 1.12.1 version
function Utils:FormatMoney(copper, showZero, useColors)
if not copper or copper == 0 then
if useColors then
return "|cFFFFFFFF" .. "0" .. "|r" .. "|cFFEDA55F" .. "c" .. "|r"
else
return "0c"
end
end
local gold = math.floor(copper / 10000)
local silver = math.floor(mod(copper, 10000) / 100)
local bronze = mod(copper, 100)
local str = ""
if useColors then
-- Colored version for tooltips - white numbers, colored g/s/c letters
if gold > 0 then
str = str .. "|cFFFFFFFF" .. gold .. "|r" .. "|cFFFFD700" .. "g" .. "|r "
end
if silver > 0 or gold > 0 then
str = str .. "|cFFFFFFFF" .. silver .. "|r" .. "|cFFC7C7CF" .. "s" .. "|r "
end
str = str .. "|cFFFFFFFF" .. bronze .. "|r" .. "|cFFEDA55F" .. "c" .. "|r"
else
-- Plain text version
if gold > 0 then
str = str .. gold .. "g "
end
if silver > 0 or gold > 0 then
str = str .. silver .. "s "
end
str = str .. bronze .. "c"
end
return str
end
-- Get item info with caching
local itemCache = {}
-- Extract itemID from itemLink (Lua 5.0 compatible)
-- Returns: itemID as number, or nil if extraction fails
function Utils:ExtractItemID(itemLink)
if not itemLink then return nil end
local _, _, itemID = string.find(itemLink, "item:(%d+)")
return itemID and tonumber(itemID) or nil
end
-- Get item info with error handling (does not cache)
-- Turtle WoW GetItemInfo signature:
-- itemName, itemLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice
function Utils:GetItemInfoSafe(itemID)
if not itemID then
addon:Debug("GetItemInfoSafe: nil itemID")
return nil
end
local itemName, itemLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice = GetItemInfo(itemID)
if not itemName then
addon:Debug("GetItemInfoSafe: GetItemInfo failed for itemID %d", itemID)
return nil
end
return itemName, itemLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice
end
-- Get item info with caching (wrapper for backward compatibility)
function Utils:GetItemInfo(itemLink)
if not itemLink then return nil end
if itemCache[itemLink] then
return unpack(itemCache[itemLink])
end
local itemID = self:ExtractItemID(itemLink)
if not itemID then return nil end
local itemName, retLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice = self:GetItemInfoSafe(itemID)
if itemName then
itemCache[itemLink] = {itemName, retLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice}
return itemName, retLink, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice
end
return nil
end
-- Create a single shared tooltip for all scanning operations
-- This tooltip is used by: Utils, ItemDetection, SortEngine
local scanTooltip = CreateFrame("GameTooltip", "GudaScanTooltip", nil, "GameTooltipTemplate")
scanTooltip:SetOwner(WorldFrame, "ANCHOR_NONE")
local SCAN_TOOLTIP_NAME = "GudaScanTooltip"
-- Public getter for the shared scan tooltip (used by ItemDetection, SortEngine)
function Utils:GetScanTooltip()
return scanTooltip, SCAN_TOOLTIP_NAME
end
-- Get item link from mailbox attachment (WoW 1.12.1 workaround)
function Utils:GetInboxItemLink(index, itemIndex)
-- Try global function first (if it exists on this server/version)
if GetInboxItemLink then
-- Turtle WoW might support (index, itemIndex) for multiple attachments
local link = GetInboxItemLink(index, itemIndex or 1)
if link then return link end
-- Fallback to single argument if that failed
link = GetInboxItemLink(index)
if link then return link end
end
-- In 1.12.1, GameTooltip:GetHyperlink() does not exist.
-- Let's try to use GetItemInfo(name) as the primary way.
local name, texture, count, quality = GetInboxItem(index, itemIndex or 1)
if name then
local itemName, link = GetItemInfo(name)
if link then
return link
end
end
return nil
end
-- Get quality color
function Utils:GetQualityColor(quality)
local color = addon.Constants.QUALITY_COLORS[quality] or addon.Constants.QUALITY_COLORS[1]
return color.r, color.g, color.b
end
-- Extract RGB color from an item link's |cffRRGGBB prefix (the title color)
-- Returns r, g, b (0-1 floats) or nil if no color found
function Utils:GetLinkColor(itemLink)
if not itemLink then return nil end
-- Lua 5.0: use string.find with captures instead of string.match
local _, _, hex = string.find(itemLink, "|c(%x%x%x%x%x%x%x%x)")
if not hex then return nil end
-- hex is AARRGGBB
local r = tonumber(string.sub(hex, 3, 4), 16) / 255
local g = tonumber(string.sub(hex, 5, 6), 16) / 255
local b = tonumber(string.sub(hex, 7, 8), 16) / 255
return r, g, b
end
-- Create colored text
function Utils:ColorText(text, r, g, b)
local red = math.floor(r * 255)
local green = math.floor(g * 255)
local blue = math.floor(b * 255)
return string.format("|cFF%02X%02X%02X%s|r", red, green, blue, text)
end
-- Deep copy table
function Utils:DeepCopy(orig)
local orig_type = type(orig)
local copy
if orig_type == 'table' then
copy = {}
for orig_key, orig_value in next, orig, nil do
copy[Utils:DeepCopy(orig_key)] = Utils:DeepCopy(orig_value)
end
setmetatable(copy, Utils:DeepCopy(getmetatable(orig)))
else
copy = orig
end
return copy
end
-- Class colors (cached at module level to avoid table creation per call)
local CLASS_COLORS = {
WARRIOR = {r = 0.78, g = 0.61, b = 0.43},
PALADIN = {r = 0.96, g = 0.55, b = 0.73},
HUNTER = {r = 0.67, g = 0.83, b = 0.45},
ROGUE = {r = 1.00, g = 0.96, b = 0.41},
PRIEST = {r = 1.00, g = 1.00, b = 1.00},
SHAMAN = {r = 0.00, g = 0.44, b = 0.87},
MAGE = {r = 0.41, g = 0.80, b = 0.94},
WARLOCK = {r = 0.58, g = 0.51, b = 0.79},
DRUID = {r = 1.00, g = 0.49, b = 0.04},
_default = {r = 0.5, g = 0.5, b = 0.5},
}
-- Get class color (uses cached table)
function Utils:GetClassColor(class)
return CLASS_COLORS[class] or CLASS_COLORS._default
end
-- Format time ago
function Utils:FormatTimeAgo(timestamp)
local diff = time() - timestamp
if diff < 60 then
return "Just now"
elseif diff < 3600 then
return math.floor(diff / 60) .. "m ago"
elseif diff < 86400 then
return math.floor(diff / 3600) .. "h ago"
else
return math.floor(diff / 86400) .. "d ago"
end
end
-- Create hidden tooltip for scanning (only once)
local function GetScanTooltip()
return scanTooltip
end
-- Helper: Check if a color is yellow/gold (Use:, Equip:, Chance on hit: effects)
-- Yellow in WoW tooltips can be various shades:
-- Gold: RGB ~(1, 0.82, 0) or (255, 209, 0)
-- Yellow: RGB ~(1, 1, 0)
-- Light gold: RGB ~(1, 0.85, 0.1)
local function IsYellowColor(r, g, b)
if not r or not g or not b then return false end
-- Yellow/Gold: high red (>0.8), medium-high green (>0.5), low blue (<0.4)
return r > 0.8 and g > 0.5 and b < 0.4
end
-- Helper: Check if a color is green (set bonuses, special properties)
-- Green in WoW tooltips is typically RGB ~(0, 1, 0) or (0.12, 1, 0)
local function IsGreenColor(r, g, b)
if not r or not g or not b then return false end
-- Green: low red (<0.4), high green (>0.7), low blue (<0.4)
return r < 0.4 and g > 0.7 and b < 0.4
end
-- Fast pattern lookup table for special text detection (Lua 5.0 optimized)
-- Uses prefix-based lookup to avoid iterating all patterns
local SPECIAL_TEXT_PREFIXES = {
["use"] = true, -- use:, use :
["equ"] = true, -- equip:, equip :
["cha"] = true, -- chance on hit, chance to
["inc"] = true, -- increases
["imp"] = true, -- improves
["res"] = true, -- restores, resistance
["reg"] = true, -- regenerate
["gen"] = true, -- generates
["abs"] = true, -- absorbs
["red"] = true, -- reduces
["gra"] = true, -- grants
["giv"] = true, -- gives
["tea"] = true, -- teaches
["lea"] = true, -- learn
["cre"] = true, -- creates
["sum"] = true, -- summons
["tel"] = true, -- teleports
["ope"] = true, -- opens
["act"] = true, -- activates
["arm"] = true, -- armor
["dam"] = true, -- damage
["hea"] = true, -- healing, health
["man"] = true, -- mana
["spi"] = true, -- spirit
["int"] = true, -- intellect
["sta"] = true, -- stamina
["str"] = true, -- strength
["agi"] = true, -- agility
}
-- Full patterns for validation after prefix match (only checked if prefix matches)
local SPECIAL_TEXT_PATTERNS = {
"use:", "equip:", "chance on", "chance to",
"increases", "improves", "restores", "regenerate", "generates",
"absorbs", "reduces", "grants", "gives", "teaches", "learn",
"creates", "summons", "teleports", "opens", "activates",
"resistance", "armor", "damage", "healing", "mana", "health",
"spirit", "intellect", "stamina", "strength", "agility",
}
-- Fast check if text contains special patterns (uses prefix lookup first)
local function HasSpecialTextPattern(textLower)
-- Quick prefix check (3 chars) to avoid full pattern scan
local prefix = string.sub(textLower, 1, 3)
if not SPECIAL_TEXT_PREFIXES[prefix] then
return false
end
-- Prefix matched - do full pattern check
for _, pattern in ipairs(SPECIAL_TEXT_PATTERNS) do
if string.find(textLower, pattern) then
return true
end
end
return false
end
-- Check if an item's tooltip contains yellow or green description text
-- This indicates the item has a use effect, equip effect, or special property
-- Returns: hasSpecialText (boolean), textType ("yellow", "green", or nil)
function Utils:HasSpecialTooltipText(bagID, slotID, itemLink)
bagID = tonumber(bagID)
slotID = tonumber(slotID)
-- Get item link for cache key if not provided
local cacheLink = itemLink
if not cacheLink and bagID and slotID then
cacheLink = GetContainerItemLink(bagID, slotID)
end
-- Check cache first
local cacheKey = GetTooltipCacheKey(cacheLink)
if cacheKey and tooltipCache.specialText[cacheKey] ~= nil then
tooltipCacheStats.hits = tooltipCacheStats.hits + 1
local cached = tooltipCache.specialText[cacheKey]
return cached.hasSpecial, cached.textType
end
tooltipCacheStats.misses = tooltipCacheStats.misses + 1
local tooltip = GetScanTooltip()
if not tooltip then return false, nil end
tooltip:ClearLines()
-- Set the tooltip to the item
if bagID and slotID then
tooltip:SetBagItem(bagID, slotID)
elseif itemLink then
local _, _, itemString = string.find(itemLink, "(item:%d+:%d+:%d+:%d+)")
if itemString then
tooltip:SetHyperlink(itemString)
else
return false, nil
end
else
return false, nil
end
local numLines = tooltip:NumLines() or 0
if numLines == 0 then
-- Cache negative result
if cacheKey then
tooltipCache.specialText[cacheKey] = { hasSpecial = false, textType = nil }
end
return false, nil
end
-- Scan tooltip lines for yellow or green text
for i = 2, numLines do -- Start from line 2 (skip item name on line 1)
local leftLine = getglobal("GudaScanTooltipTextLeft" .. i)
if leftLine and leftLine:IsShown() then
local text = leftLine:GetText()
local r, g, b = leftLine:GetTextColor()
if text and r and g and b then
local textLower = string.lower(text)
-- Check for yellow/gold text (Use:, Equip:, Chance on hit:, etc.)
if IsYellowColor(r, g, b) then
-- Use optimized prefix-based pattern check
if HasSpecialTextPattern(textLower) then
addon:Debug("HasSpecialTooltipText: YELLOW match in: %s", text)
-- Cache positive result
if cacheKey then
tooltipCache.specialText[cacheKey] = { hasSpecial = true, textType = "yellow" }
end
return true, "yellow"
end
end
-- Check for green text (set bonuses, enchants, special properties)
-- Green text is ALWAYS considered special (no pattern check needed)
if IsGreenColor(r, g, b) then
addon:Debug("HasSpecialTooltipText: Found GREEN text: %s (r=%.2f g=%.2f b=%.2f)", text, r, g, b)
-- Cache positive result
if cacheKey then
tooltipCache.specialText[cacheKey] = { hasSpecial = true, textType = "green" }
end
return true, "green"
end
-- Also check for "Use:" or "Equip:" regardless of color (some items may have different colors)
if string.find(textLower, "^use:") or string.find(textLower, "^equip:") then
addon:Debug("HasSpecialTooltipText: Found Use/Equip text: %s", text)
-- Cache positive result
if cacheKey then
tooltipCache.specialText[cacheKey] = { hasSpecial = true, textType = "yellow" }
end
return true, "yellow"
end
end
end
-- Also check right side of tooltip
local rightLine = getglobal("GudaScanTooltipTextRight" .. i)
if rightLine and rightLine:IsShown() then
local text = rightLine:GetText()
local r, g, b = rightLine:GetTextColor()
if text and r and g and b then
if IsYellowColor(r, g, b) or IsGreenColor(r, g, b) then
addon:Debug("HasSpecialTooltipText: Found special text (right): %s", text)
local textType = IsYellowColor(r, g, b) and "yellow" or "green"
-- Cache positive result
if cacheKey then
tooltipCache.specialText[cacheKey] = { hasSpecial = true, textType = textType }
end
return true, textType
end
end
end
end
-- Cache negative result
if cacheKey then
tooltipCache.specialText[cacheKey] = { hasSpecial = false, textType = nil }
end
return false, nil
end
-- Check if an item is a quest item by scanning its tooltip (internal helper)
-- Returns: isQuestItem, isQuestStarter
local function ScanTooltipForQuest(tooltip, tooltipName)
local isQuestItem = false
local isQuestStarter = false
for i = 1, tooltip:NumLines() do
local line = getglobal(tooltipName .. "TextLeft" .. i)
if line then
local text = line:GetText()
if text then
local tl = string.lower(text)
-- Check for quest starter patterns first
if string.find(tl, "quest starter") or
string.find(tl, "this item begins a quest") or
string.find(tl, "starts a quest") then
isQuestItem = true
isQuestStarter = true
break
-- Check for regular quest item patterns
elseif string.find(tl, "quest item") or
string.find(tl, "manual") then
isQuestItem = true
-- Don't break, might still find a quest starter pattern
end
end
end
end
return isQuestItem, isQuestStarter
end
-- Check if item has "Permanently..." text (enchanting scrolls/vellums)
-- These should NOT be considered quest items even if they have Quest category
local function IsPermanentEnchantItem(tooltip, tooltipName)
if not tooltip then return false end
local numLines = tooltip:NumLines() or 0
for i = 1, numLines do
local line = getglobal(tooltipName .. "TextLeft" .. i)
if line then
local text = line:GetText()
if text then
local tl = string.lower(text)
if string.find(tl, "permanently") then
return true
end
end
end
end
return false
end
-- Consolidated quest item detection function
-- Handles tooltip scanning, category checks, equipment filtering, and QuestItemsDB lookup
-- Parameters:
-- bagID, slotID: Required for tooltip scanning (can be nil for other char items)
-- itemData: Optional item data table with link, class/category, type fields
-- isOtherChar: Boolean, true if checking an item from another character's saved data
-- isBank: Boolean, true if the item is in the bank
-- Returns: isQuestItem (boolean), isQuestStarter (boolean)
function Utils:IsQuestItem(bagID, slotID, itemData, isOtherChar, isBank)
bagID = tonumber(bagID)
slotID = tonumber(slotID)
local isQuestItem = false
local isQuestStarter = false
-- Get item link and category info
local itemLink = itemData and itemData.link
local itemCategory = itemData and (itemData.class or itemData.category) or ""
local itemType = itemData and itemData.type or ""
local itemID
-- For live items, query the link directly
if not isOtherChar and bagID and slotID then
itemLink = GetContainerItemLink(bagID, slotID)
end
if itemLink then
itemID = self:ExtractItemID(itemLink)
if itemID then
local _, _, _, _, cat, typ = self:GetItemInfoSafe(itemID)
itemCategory = cat or itemCategory
itemType = typ or itemType
end
end
-- Early exit: exclude known non-quest items that the API misclassifies as "Quest"
if itemID and addon.Constants and addon.Constants.QUEST_CATEGORY_EXCLUSIONS and addon.Constants.QUEST_CATEGORY_EXCLUSIONS[itemID] then
return false, false
end
-- Check if item is equipment (should not be classified as quest unless explicitly Quest category)
local isEquipment = (itemCategory == "Weapon" or itemCategory == "Armor" or
itemType == "Weapon" or itemType == "Armor")
local isQuestCategory = (itemCategory == "Quest" or itemType == "Quest")
-- Priority 1: If explicitly categorized as Quest, check if it's actually an enchant item first
if isQuestCategory then
-- Check tooltip for "Permanently" (enchanting scrolls should not be quest items)
if not isOtherChar and bagID and slotID then
local tooltip = GetScanTooltip()
tooltip:ClearLines()
tooltip:SetBagItem(bagID, slotID)
if IsPermanentEnchantItem(tooltip, "GudaScanTooltip") then
return false, false -- Not a quest item, it's an enchant scroll
end
-- Also scan for quest starter text
local _, starterDetected = ScanTooltipForQuest(tooltip, "GudaScanTooltip")
return true, starterDetected
end
return true, false
end
-- Priority 2: Tooltip scanning for current character items
if not isOtherChar and bagID and slotID then
local tooltip = GetScanTooltip()
tooltip:ClearLines()
-- Handle bank items differently
if isBank and bagID == -1 then
if tooltip.SetInventoryItem then
tooltip:SetInventoryItem("player", 39 + slotID)
else
tooltip:SetBagItem(bagID, slotID)
end
else
tooltip:SetBagItem(bagID, slotID)
end
isQuestItem, isQuestStarter = ScanTooltipForQuest(tooltip, "GudaScanTooltip")
-- Filter out equipment that has quest-like text but isn't categorized as Quest
if isQuestItem and isEquipment and not isQuestCategory then
isQuestItem = false
isQuestStarter = false
end
end
-- Priority 3: Check QuestItemsDB for known faction-specific quest items
if not isQuestItem and itemID and addon.IsQuestItemByID then
local playerFaction = UnitFactionGroup("player")
if addon:IsQuestItemByID(itemID, playerFaction) then
isQuestItem = true
end
end
return isQuestItem, isQuestStarter
end
-- Legacy compatibility wrapper - keep the old function name working
function Utils:IsQuestItemTooltip(bagID, slotID)
local isQuest, _ = self:IsQuestItem(bagID, slotID, nil, false, false)
return isQuest
end
-- Check if an item has a gray title in its tooltip or link
function Utils:IsItemGrayTooltip(bagID, slotID, itemLink)
-- Check link first if provided (works for other characters too)
if itemLink and string.find(itemLink, "|cff9d9d9d") then
return true
end
if not bagID or not slotID then return false end
local tooltip = GetScanTooltip()
tooltip:ClearLines()
tooltip:SetBagItem(bagID, slotID)
local line = getglobal("GudaScanTooltipTextLeft1")
if line then
local text = line:GetText()
if text then
-- Poor quality color code is |cff9d9d9d
if string.find(text, "|cff9d9d9d") then
return true
end
end
end
return false
end
-- Get bag slot count
function Utils:GetBagSlotCount(bagID)
if bagID == -1 then
-- Bank has 24 slots in vanilla
return 24
elseif bagID == -2 then
-- Keyring - vanilla WoW has 12-32 slots depending on version
local slots = GetContainerNumSlots(bagID)
if not slots or slots == 0 then
-- Fallback: keyring typically has 12 slots in vanilla
return 12
end
return slots
else
return GetContainerNumSlots(bagID) or 0
end
end
-- Check if bag is valid
function Utils:IsBagValid(bagID)
if bagID == 0 or bagID == -1 or bagID == -2 then
return true
end
return GetContainerNumSlots(bagID) and GetContainerNumSlots(bagID) > 0
end
-- Truncate text to length
function Utils:TruncateText(text, maxLen)
if not text then return "" end
if string.len(text) <= maxLen then
return text
end
return string.sub(text, 1, maxLen - 3) .. "..."
end
-- Returns: "soul", "herb", "enchant", "quiver", "ammo", or nil
-- This is the consolidated bag type detection function with tooltip fallback
-- =============================================================================
-- Locale-independent type resolution
-- =============================================================================
-- WoW's GetItemInfo returns LOCALIZED type/subtype strings ("Quiver" on enUS,
-- "箭袋" on zhCN, etc.). We resolve the local-client strings at runtime by
-- querying a few reference itemIDs that exist in every WoW 1.12 client, so
-- the rest of the code can match against the local language without hardcoding.
local localizedBagTypes = {} -- lowercased localized subType -> "quiver"/"ammo"/...
local localizedProjectiles = {} -- localized subType (any case) -> "arrow"/"bullet"
local localizedTypesResolved = false
-- Locale-specific subType strings live in Localization.lua (Guda_LSubtypes).
-- Avoids GetItemInfo cache cold-start: lookups are pure table reads, populated
-- once on first use from the current client locale (with enUS as a fallback).
local function ResolveLocalizedTypes()
if localizedTypesResolved then return end
if not Guda_LSubtypes then return end
local clientLocale = (GetLocale and GetLocale()) or "enUS"
local map = Guda_LSubtypes[clientLocale] or Guda_LSubtypes.enUS
local en = Guda_LSubtypes.enUS or {}
-- Merge enUS first (so English clients on a partial locale still work),
-- then the active locale on top.
local sources = { en, map }
for i = 1, 2 do
local src = sources[i]
if src then
if src.quiver then localizedBagTypes[string.lower(src.quiver)] = "quiver" end
if src.ammo then localizedBagTypes[string.lower(src.ammo)] = "ammo" end
if src.soul then localizedBagTypes[string.lower(src.soul)] = "soul" end
if src.herb then localizedBagTypes[string.lower(src.herb)] = "herb" end
if src.enchant then localizedBagTypes[string.lower(src.enchant)] = "enchant" end
if src.arrow then localizedProjectiles[src.arrow] = "arrow" end
if src.bullet then localizedProjectiles[src.bullet] = "bullet" end
end
end
localizedTypesResolved = true
end
function Utils:GetSpecializedBagType(bagID)
-- Skip backpack, bank, and keyring
if bagID == 0 or bagID == -1 or bagID == -2 then
return nil
end
-- Get the bag item
local invSlot = ContainerIDToInventoryID(bagID)
if not invSlot then
return nil
end
local link = GetInventoryItemLink("player", invSlot)
if not link then
return nil
end
local itemID = self:ExtractItemID(link)
-- Try GetItemInfo first (more reliable when available)
if itemID then
local _, _, _, _, _, itemType = self:GetItemInfoSafe(itemID)
if itemType then
local typeLower = string.lower(itemType)
-- Locale-independent: match against resolved local-client strings
ResolveLocalizedTypes()
if localizedBagTypes[typeLower] then
return localizedBagTypes[typeLower]
end
if string.find(typeLower, "soul bag") or string.find(typeLower, "soul pouch") then
return "soul"
end
if string.find(typeLower, "herb bag") then
return "herb"
end
if string.find(typeLower, "enchanting bag") then
return "enchant"
end
if string.find(typeLower, "quiver") then
return "quiver"
end
if string.find(typeLower, "ammo pouch") then
return "ammo"
end
end
end
-- Fallback: tooltip scanning for all bag types
local tooltip = GetScanTooltip()
tooltip:ClearLines()
tooltip:SetInventoryItem("player", invSlot)
for i = 1, tooltip:NumLines() do
local line = getglobal("GudaScanTooltipTextLeft" .. i)
if line then
local text = line:GetText()
if text then
local textLower = string.lower(text)
-- Soul Bag / Soul Pouch
if string.find(textLower, "soul bag") or string.find(textLower, "soul pouch") or
(string.find(textLower, "soul") and (string.find(textLower, "bag") or string.find(textLower, "pouch"))) then
return "soul"
end
-- Herb Bag
if string.find(textLower, "herb bag") then
return "herb"
end
-- Enchanting Bag
if string.find(textLower, "enchanting bag") then
return "enchant"
end
-- Quiver
if string.find(textLower, "quiver") then
return "quiver"
end
-- Ammo Pouch
if string.find(textLower, "ammo pouch") then
return "ammo"
end
end
end
end
return nil
end
-- Simple helper to check if a bag is of a specific type
function Utils:IsBagType(bagID, bagType)
return self:GetSpecializedBagType(bagID) == bagType
end
-- Convenience wrappers for common bag type checks
function Utils:IsAmmoQuiverBag(bagID)
local bagType = self:GetSpecializedBagType(bagID)
return bagType == "quiver" or bagType == "ammo"
end
function Utils:IsHerbBag(bagID)
return self:IsBagType(bagID, "herb")
end
function Utils:IsSoulBag(bagID)
return self:IsBagType(bagID, "soul")
end
-- Get container priority for sorting (higher = more important)
function Utils:GetContainerPriority(bagID)
local bagType = self:GetSpecializedBagType(bagID)
if bagType == "enchant" then
return 50
elseif bagType == "herb" then
return 45
elseif bagType == "soul" then
return 40
elseif bagType == "quiver" then
return 30
elseif bagType == "ammo" then
return 20
else
return 10 -- Regular bag
end
end
-- Soul Shard item ID
local SOUL_SHARD_ID = 6265
-- Check if item is a Soul Shard
function Utils:IsSoulShard(itemLink)
if not itemLink then return false end
local itemID = self:ExtractItemID(itemLink)
return itemID == SOUL_SHARD_ID
end
-- Extract hyperlink from item link for tooltip scanning
local function ExtractHyperlink(itemLink)
if not itemLink then return nil end
local _, _, hyperlink = string.find(itemLink, "|H(.+)|h")
return hyperlink
end
-- Detect if a consumable has 'Use: Restores' or mentions 'while eating'/'while drinking'
function Utils:GetConsumableRestoreTag(bagID, slotID, itemLink)
if not bagID or not slotID then return nil end
-- Get item link for cache key if not provided
local cacheLink = itemLink or GetContainerItemLink(bagID, slotID)
-- Check cache first
local cacheKey = GetTooltipCacheKey(cacheLink)
if cacheKey and tooltipCache.restoreTag[cacheKey] ~= nil then
tooltipCacheStats.hits = tooltipCacheStats.hits + 1
local cached = tooltipCache.restoreTag[cacheKey]
-- Return nil if cached as false, otherwise return the tag
return cached ~= false and cached or nil
end
tooltipCacheStats.misses = tooltipCacheStats.misses + 1
local tooltip = GetScanTooltip()
tooltip:ClearLines()
tooltip:SetBagItem(bagID, slotID)
local tag = nil
for i = 1, tooltip:NumLines() do
local line = getglobal("GudaScanTooltipTextLeft" .. i)
if line then
local text = line:GetText()
if text then
local tl = string.lower(text)
if string.find(tl, "while eating") then
tag = "eat"
break
elseif string.find(tl, "while drinking") then
tag = "drink"
break
elseif string.find(tl, "use: restores") then
tag = "restore"
end
end
end
end
-- Cache the result (nil becomes false for cache check purposes)
if cacheKey then
tooltipCache.restoreTag[cacheKey] = tag or false
end
return tag
end
-- Check if item is Arrow or Bullet (for Quiver routing).
-- Locale-independent: also matches against the local client's translated
-- subtype strings, resolved from reference vanilla items at runtime.
function Utils:IsArrowOrBullet(itemType)
if not itemType then return false end
if itemType == "Arrow" or itemType == "Bullet" then return true end
ResolveLocalizedTypes()
return localizedProjectiles[itemType] ~= nil
end
-- Check if item is Ammo (any type - for Ammo Pouch)
function Utils:IsAmmo(itemType)
return self:IsArrowOrBullet(itemType)
end
-- Returns "arrow", "bullet", or nil for the given item subtype string.
function Utils:GetProjectileKind(itemType)
if not itemType then return nil end
if itemType == "Arrow" then return "arrow" end
if itemType == "Bullet" then return "bullet" end
ResolveLocalizedTypes()
return localizedProjectiles[itemType]
end
-- Get preferred container type for an item
-- Returns: "soul", "herb", "enchant", "quiver", "ammo", or nil
function Utils:GetItemPreferredContainer(itemLink)
if not itemLink then return nil end
-- Check for soul shards first
if self:IsSoulShard(itemLink) then
return "soul"
end
-- Get item info using utility function
local itemID = self:ExtractItemID(itemLink)
if not itemID then return nil end
local itemName, _, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture = self:GetItemInfoSafe(itemID)
if not itemType then return nil end
-- Locale-independent projectile routing. Don't gate on itemCategory string
-- (it's localized); the projectile kind lookup already covers both English
-- and the local client's strings via runtime-resolved reference items.
local projectileKind = self:GetProjectileKind(itemType)
if projectileKind == "arrow" then
return "quiver"
elseif projectileKind == "bullet" then
return "ammo"
end
-- Route herbs to herb bags (robust: category/subtype OR texture pattern fallback)
if self:IsHerbItem(itemLink) then
return "herb"
end
-- Route enchanting materials to enchanting bags
if self:IsEnchantingItem(itemLink) then
return "enchant"
end
return nil
end
-- Check if an item is "Binds when equipped" by scanning its tooltip
-- For bank items, use itemLink since SetBagItem may not work for bank slots
function Utils:IsBindOnEquip(bagID, slotID, itemLink)
if not bagID or not slotID then return false end
-- Get item link for cache key if not provided
local cacheLink = itemLink or GetContainerItemLink(bagID, slotID)
-- Check cache first
local cacheKey = GetTooltipCacheKey(cacheLink)
if cacheKey and tooltipCache.bindOnEquip[cacheKey] ~= nil then
tooltipCacheStats.hits = tooltipCacheStats.hits + 1
return tooltipCache.bindOnEquip[cacheKey]
end
tooltipCacheStats.misses = tooltipCacheStats.misses + 1
local tooltip = GetScanTooltip()
if not tooltip then return false end
tooltip:ClearLines()
-- Try SetBagItem first (works for regular bags and bank when open)
tooltip:SetBagItem(bagID, slotID)
local numLines = tooltip:NumLines()
-- If no lines and we have itemLink, try SetHyperlink with itemString as fallback
if (not numLines or numLines == 0) and itemLink then
-- Extract itemString from link (format: item:12345:0:0:0...)
local _, _, itemString = string.find(itemLink, "(item:%d+:%d+:%d+:%d+)")
if itemString then
tooltip:ClearLines()
tooltip:SetHyperlink(itemString)
numLines = tooltip:NumLines()
end
end
if not numLines or numLines == 0 then
if cacheKey then tooltipCache.bindOnEquip[cacheKey] = false end
return false
end
-- Check tooltip lines for "Binds when equipped"
for i = 1, numLines do
local line = getglobal("GudaScanTooltipTextLeft" .. i)
if line then
local text = line:GetText()
if text and string.find(string.lower(text), "binds when equipped") then
if cacheKey then tooltipCache.bindOnEquip[cacheKey] = true end
return true
end
end
end
if cacheKey then tooltipCache.bindOnEquip[cacheKey] = false end
return false
end
-- Check if an item is "Unique" by scanning its tooltip
function Utils:IsUniqueItem(bagID, slotID, itemLink)
if not bagID or not slotID then return false end
-- Get item link for cache key if not provided
local cacheLink = itemLink or GetContainerItemLink(bagID, slotID)
-- Check cache first
local cacheKey = GetTooltipCacheKey(cacheLink)
if cacheKey and tooltipCache.uniqueItem[cacheKey] ~= nil then
tooltipCacheStats.hits = tooltipCacheStats.hits + 1
return tooltipCache.uniqueItem[cacheKey]
end
tooltipCacheStats.misses = tooltipCacheStats.misses + 1
local tooltip = GetScanTooltip()
if not tooltip then
if cacheKey then tooltipCache.uniqueItem[cacheKey] = false end
return false
end
tooltip:ClearLines()
-- Try SetBagItem first (works for regular bags and bank when open)
tooltip:SetBagItem(bagID, slotID)
local numLines = tooltip:NumLines()
-- If no lines and we have itemLink, try SetHyperlink with itemString as fallback
if (not numLines or numLines == 0) and itemLink then
local _, _, itemString = string.find(itemLink, "(item:%d+:%d+:%d+:%d+)")
if itemString then
tooltip:ClearLines()
tooltip:SetHyperlink(itemString)
numLines = tooltip:NumLines()
end
end
if not numLines or numLines == 0 then
if cacheKey then tooltipCache.uniqueItem[cacheKey] = false end
return false
end
-- Check tooltip lines for "Unique" (but not "Unique-Equipped")
for i = 1, numLines do
local line = getglobal("GudaScanTooltipTextLeft" .. i)
if line then
local text = line:GetText()
if text then
local textLower = string.lower(text)
-- Match "unique" but not "unique-equipped"
if textLower == "unique" or string.find(textLower, "^unique$") or string.find(textLower, "^unique%s") then
if cacheKey then tooltipCache.uniqueItem[cacheKey] = true end
return true
end
end
end
end
if cacheKey then tooltipCache.uniqueItem[cacheKey] = false end
return false
end
-- Check if an item is equipment (armor, weapon, or other equippable)
-- Returns: true if item is equipment, false otherwise
function Utils:IsEquipment(itemLink)
if not itemLink then return false end
local itemID = self:ExtractItemID(itemLink)
if not itemID then return false end
local itemName, _, itemRarity, itemLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture, itemEquipLoc = self:GetItemInfoSafe(itemID)
-- Check if item has an equip location (most reliable method)
if itemEquipLoc and itemEquipLoc ~= "" and itemEquipLoc ~= "INVTYPE_BAG" then
return true
end
-- Fallback: Check category (in case itemEquipLoc is not set)
if itemCategory then
local categoryLower = string.lower(itemCategory)
if categoryLower == "armor" or categoryLower == "weapon" then
return true
end
end
return false
end
-- Determine if an item is a herb (for routing to herb bags)
-- NEW rule (per request): require BOTH
-- 1) itemCategory == "Trade Goods"
-- 2) texture contains "INV_Misc_Herb" (case-insensitive; prefix tolerated)
function Utils:IsHerbItem(itemLink)
if not itemLink then return false end
local itemID = self:ExtractItemID(itemLink)
if not itemID then return false end
local name, _, quality, iLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture = self:GetItemInfoSafe(itemID)
if not itemTexture then
return false
end
-- Texture path is locale-independent (English in every WoW client),
-- so we don't need to gate on the localized "Trade Goods" category.
local tex = string.lower(itemTexture)
tex = string.gsub(tex, "^interface\\\\icons\\\\", "")
if string.find(tex, "inv_misc_herb") or string.find(tex, "misc_herb") then
return true
end
return false
end
-- Determine if an item is an enchanting material (for routing to enchanting bags)
-- Rules:
-- 1) itemCategory == "Trade Goods"
-- 2) (itemSubType == "Enchanting") OR texture contains "INV_Enchant" (case-insensitive)
function Utils:IsEnchantingItem(itemLink)
if not itemLink then return false end
local itemID = self:ExtractItemID(itemLink)
if not itemID then return false end
local name, _, quality, iLevel, itemCategory, itemType, itemStackCount, itemSubType, itemTexture = self:GetItemInfoSafe(itemID)
-- Explicit English subtype (rare but possible on some servers)
if itemType == "Enchanting" or itemSubType == "Enchanting" then
return true
end
if not itemTexture then
return false
end
-- Texture path is locale-independent — primary signal for non-English clients.
local tex = string.lower(itemTexture)
tex = string.gsub(tex, "^interface\\\\icons\\\\", "")
if string.find(tex, "inv_enchant") or string.find(tex, "enchant") then
return true
end
return false
end
-- Check if a bag is Enchanting Bag
function Utils:IsEnchantBag(bagID)
return self:IsBagType(bagID, "enchant")
end