Files
Guda-ClassicAPI/Data/BagScanner.lua
T

532 lines
16 KiB
Lua

-- Guda Bag Scanner
-- Scans and stores bag contents with caching and event pending tracking
local addon = Guda
local BagScanner = {}
addon.Modules.BagScanner = BagScanner
-- Cache for bag data to avoid re-scanning all slots on every update
local bagCache = nil
local cacheValid = false
-- Event pending tracking
local eventPending = false
local dirtySlots = {} -- dirtySlots[bagID][slotID] = true
local dirtyBags = {} -- BAG_UPDATE only identifies a bag, not the exact slot
--=====================================================
-- Item Data Pool
-- Reuses item data tables instead of creating new ones
--=====================================================
local itemDataPool = {}
local ITEM_DATA_POOL_MAX = 300
local function AcquireItemData()
return table.remove(itemDataPool) or {}
end
local function ReleaseItemData(data)
if not data then return end
data.link = nil
data.itemID = nil
data.texture = nil
data.count = nil
data.quality = nil
data.name = nil
data.iLevel = nil
data.type = nil
data.class = nil
data.subclass = nil
data.equipSlot = nil
data.locked = nil
data.readable = nil
data.lootable = nil
data.isBound = nil
if table.getn(itemDataPool) < ITEM_DATA_POOL_MAX then
table.insert(itemDataPool, data)
end
end
local function ReleaseBagCacheData(bagData)
if bagData and bagData.slots then
for _, itemData in pairs(bagData.slots) do
if itemData then
ReleaseItemData(itemData)
end
end
end
end
local function ReleaseAllBagCacheData()
if not bagCache then return end
for _, bagData in pairs(bagCache) do
ReleaseBagCacheData(bagData)
end
end
-- Export the pool helpers so BankScanner can share the same pool instead of
-- permanently consuming tables acquired through BagScanner:ScanSlot().
function BagScanner:AcquireItemData()
return AcquireItemData()
end
function BagScanner:ReleaseItemData(data)
ReleaseItemData(data)
end
function BagScanner:ReleaseContainerData(containerData)
ReleaseBagCacheData(containerData)
end
function BagScanner:GetItemDataPoolSize()
return table.getn(itemDataPool)
end
-- Clear the bag cache (releases pooled data)
function BagScanner:ClearCache()
ReleaseAllBagCacheData()
bagCache = nil
cacheValid = false
dirtySlots = {}
dirtyBags = {}
eventPending = false
end
function BagScanner:IsEventPending()
return eventPending
end
function BagScanner:ClearEventPending()
eventPending = false
end
function BagScanner:MarkSlotDirty(bagID, slotID)
if not dirtySlots[bagID] then
dirtySlots[bagID] = {}
end
dirtySlots[bagID][slotID] = true
eventPending = true
end
function BagScanner:GetAndClearDirtySlots()
local dirty = dirtySlots
dirtySlots = {}
return dirty
end
local function GetLiveSlotInfo(bagID, slotID)
local api = addon.Modules.ClassicAPI
if api and api.GetContainerItemInfo then
return api:GetContainerItemInfo(bagID, slotID)
end
local texture, count, locked, quality, readable, lootable =
GetContainerItemInfo(bagID, slotID)
local link = GetContainerItemLink(bagID, slotID)
if not texture and not link then return nil end
local itemID = nil
if link then
local _, _, idStr = string.find(link, "item:(%d+)")
if idStr then itemID = tonumber(idStr) end
end
return {
iconFileID = texture,
stackCount = count or 1,
isLocked = locked and true or false,
quality = quality,
isReadable = readable and true or false,
hasLoot = lootable and true or false,
hyperlink = link,
itemID = itemID,
}
end
-- Refresh one cached bag in place. BAG_UPDATE does not provide the changed
-- slot on 1.12, so we cheaply compare live per-slot identity/count state and
-- only rebuild item metadata for slots that actually changed.
function BagScanner:RefreshBagInPlace(bagID)
if not bagCache then return end
local oldBag = bagCache[bagID]
if not oldBag then
bagCache[bagID] = self:ScanBag(bagID)
return
end
local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID)
local bagType = addon.Modules.Utils:GetSpecializedBagType(bagID) or "regular"
if not addon.Modules.Utils:IsBagValid(bagID) or oldBag.numSlots ~= numSlots then
ReleaseBagCacheData(oldBag)
bagCache[bagID] = self:ScanBag(bagID)
return
end
local freeSlots = 0
for slotID = 1, numSlots do
local liveInfo = GetLiveSlotInfo(bagID, slotID)
local oldData = oldBag.slots[slotID]
if not liveInfo then
if oldData then
ReleaseItemData(oldData)
oldBag.slots[slotID] = nil
end
freeSlots = freeSlots + 1
else
local liveItemID = tonumber(liveInfo.itemID)
local liveLink = liveInfo.hyperlink
local liveCount = liveInfo.stackCount or 1
local liveLocked = liveInfo.isLocked and true or false
local changed = false
if not oldData then
changed = true
elseif oldData.itemID ~= liveItemID then
changed = true
elseif oldData.link ~= liveLink then
changed = true
elseif oldData.count ~= liveCount then
changed = true
elseif (oldData.locked and true or false) ~= liveLocked then
changed = true
elseif liveInfo.quality ~= nil and oldData.quality ~= liveInfo.quality then
changed = true
elseif liveInfo.iconFileID and oldData.texture ~= liveInfo.iconFileID then
changed = true
end
if changed then
if oldData then ReleaseItemData(oldData) end
oldBag.slots[slotID] = self:ScanSlot(bagID, slotID)
end
if not oldBag.slots[slotID] then
freeSlots = freeSlots + 1
end
end
end
oldBag.numSlots = numSlots
oldBag.freeSlots = freeSlots
oldBag.bagType = bagType
end
-- Get cached bag data, or scan if cache is invalid
function BagScanner:GetBagData()
if cacheValid and bagCache then
local bagsToRefresh = dirtyBags
dirtyBags = {}
for bagID in pairs(bagsToRefresh) do
self:RefreshBagInPlace(bagID)
end
-- Exact dirty-slot updates take precedence only when the whole bag was
-- not already refreshed above.
for bagID, slots in pairs(dirtySlots) do
if not bagsToRefresh[bagID]
and bagID ~= nil and type(slots) == "table" then
if bagCache[bagID] then
for slotID in pairs(slots) do
if type(slotID) == "number" and slotID >= 1 then
local oldData = bagCache[bagID].slots[slotID]
local newData = self:ScanSlot(bagID, slotID)
local wasEmpty = oldData == nil
local isEmpty = newData == nil
if oldData then ReleaseItemData(oldData) end
bagCache[bagID].slots[slotID] = newData
if wasEmpty and not isEmpty then
bagCache[bagID].freeSlots = bagCache[bagID].freeSlots - 1
elseif not wasEmpty and isEmpty then
bagCache[bagID].freeSlots = bagCache[bagID].freeSlots + 1
end
end
end
else
bagCache[bagID] = self:ScanBag(bagID)
end
end
end
dirtySlots = {}
eventPending = false
return bagCache
end
-- Cache miss - release the previous runtime cache before rebuilding it so
-- its pooled item tables are not abandoned to the Lua GC.
ReleaseAllBagCacheData()
bagCache = self:ScanBags()
cacheValid = true
dirtySlots = {}
dirtyBags = {}
eventPending = false
return bagCache
end
function BagScanner:InvalidateCache()
cacheValid = false
eventPending = true
end
-- Existing BagFrame code calls InvalidateBag() frequently. Keep the public API
-- but preserve the cached tables: mark only that bag dirty and refresh it in
-- place on the next GetBagData(). Bag-size changes are detected automatically.
function BagScanner:InvalidateBag(bagID)
if bagID == nil then return end
dirtyBags[bagID] = true
eventPending = true
end
function BagScanner:ScanBags()
local bagData = {}
for _, bagID in ipairs(addon.Constants.BAGS) do
bagData[bagID] = self:ScanBag(bagID)
end
bagData[-2] = self:ScanBag(-2)
return bagData
end
function BagScanner:ScanBag(bagID)
local bagType = addon.Modules.Utils:GetSpecializedBagType(bagID) or "regular"
local bag = {
slots = {},
numSlots = addon.Modules.Utils:GetBagSlotCount(bagID),
freeSlots = 0,
bagType = bagType,
}
if not addon.Modules.Utils:IsBagValid(bagID) then
return bag
end
for slot = 1, bag.numSlots do
local itemData = self:ScanSlot(bagID, slot)
bag.slots[slot] = itemData
if not itemData then
bag.freeSlots = bag.freeSlots + 1
end
end
return bag
end
-- Scan a single slot. ClassicAPI supplies itemID/link/count/lock/quality in a
-- single structured call, avoiding Vanilla link parsing and several redundant
-- container API calls on the hot path.
function BagScanner:ScanSlot(bagID, slot)
if bagID == nil or slot == nil or slot < 1 then
return nil
end
local api = addon.Modules.ClassicAPI
local info = GetLiveSlotInfo(bagID, slot)
if not info then return nil end
local itemID = tonumber(info.itemID)
if not itemID and api and api.GetContainerItemID then
itemID = api:GetContainerItemID(bagID, slot, info)
end
local itemLink = info.hyperlink
if not itemLink and api and api.GetContainerItemLink then
itemLink = api:GetContainerItemLink(bagID, slot, info)
end
local texture = info.iconFileID
local itemCount = info.stackCount or 1
local locked = info.isLocked and true or false
local quality = info.quality
-- Static item fields still come through Guda's existing item-info cache.
-- This call is cached by Utils and only occurs when the slot metadata is
-- actually rebuilt, not on every BAG_UPDATE comparison.
local name, link, itemQuality, iLevel, itemCategory, itemType,
itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice
if itemLink then
name, link, itemQuality, iLevel, itemCategory, itemType,
itemStackCount, itemSubType, itemTexture, itemEquipLoc, itemSellPrice =
addon.Modules.Utils:GetItemInfo(itemLink)
end
-- ClassicAPI intentionally omits cache-dependent icon/name fields while
-- static item data is cold. Preserve Vanilla behavior with a rare fallback.
if not texture then
local vanillaTexture, vanillaCount, vanillaLocked, vanillaQuality =
GetContainerItemInfo(bagID, slot)
texture = vanillaTexture or itemTexture
if vanillaCount then itemCount = vanillaCount end
if vanillaLocked ~= nil then locked = vanillaLocked and true or false end
if quality == nil then quality = vanillaQuality end
end
local itemData = AcquireItemData()
itemData.link = itemLink or link
itemData.itemID = itemID
itemData.texture = texture or itemTexture
itemData.count = itemCount or 1
itemData.quality = quality or itemQuality or 0
itemData.name = name or info.itemName
itemData.iLevel = iLevel
itemData.type = itemType
itemData.class = itemCategory
itemData.subclass = itemSubType
itemData.equipSlot = itemEquipLoc
itemData.locked = locked
itemData.readable = info.isReadable
itemData.lootable = info.hasLoot
itemData.isBound = info.isBound
-- Override known Turtle WoW items that the API reports as Quest although
-- they behave as consumables.
if itemCategory == "Quest" and addon.Constants
and addon.Constants.QUEST_CATEGORY_EXCLUSIONS then
local exclusionID = itemID
if not exclusionID and itemData.link then
exclusionID = addon.Modules.Utils:ExtractItemID(itemData.link)
end
if exclusionID and addon.Constants.QUEST_CATEGORY_EXCLUSIONS[exclusionID] then
itemData.class = "Consumable"
itemData.type = "Consumable"
end
end
return itemData
end
local function CloneItemData(itemData)
if not itemData then return nil end
return {
link = itemData.link,
itemID = itemData.itemID,
texture = itemData.texture,
count = itemData.count,
quality = itemData.quality,
name = itemData.name,
iLevel = itemData.iLevel,
type = itemData.type,
class = itemData.class,
subclass = itemData.subclass,
equipSlot = itemData.equipSlot,
locked = itemData.locked,
readable = itemData.readable,
lootable = itemData.lootable,
isBound = itemData.isBound,
}
end
local function CloneBagDataForDatabase(runtimeData)
local snapshot = {}
if not runtimeData then return snapshot end
for bagID, bag in pairs(runtimeData) do
local bagCopy = {
slots = {},
numSlots = bag.numSlots,
freeSlots = bag.freeSlots,
bagType = bag.bagType,
}
if bag.slots then
for slotID, itemData in pairs(bag.slots) do
bagCopy.slots[slotID] = CloneItemData(itemData)
end
end
snapshot[bagID] = bagCopy
end
return snapshot
end
-- Save from the already-refreshed runtime cache instead of scanning every bag
-- a second time when the bag frame opens.
function BagScanner:SaveToDatabase()
local runtimeData = self:GetBagData()
addon.Modules.DB:SaveBags(CloneBagDataForDatabase(runtimeData))
addon:Debug("Bag data saved to database")
if addon.Modules.Tooltip and addon.Modules.Tooltip.ClearCache then
addon.Modules.Tooltip:ClearCache()
end
end
function BagScanner:Initialize()
local eventFrame = CreateFrame("Frame")
self.eventFrame = eventFrame
eventFrame:RegisterEvent("BAG_UPDATE")
eventFrame:RegisterEvent("BAG_UPDATE_DELAYED")
eventFrame:SetScript("OnEvent", function()
eventPending = true
if event == "BAG_UPDATE" and arg1 ~= nil then
-- Keep the cache structure alive. The next reader compares the
-- affected bag in place and only rebuilds changed slots.
dirtyBags[arg1] = true
end
end)
addon:Debug("Bag scanner initialized with ClassicAPI incremental cache")
end
--=====================================================
-- Consolidated from Data/ItemCategoryOverrides.lua
--=====================================================
-- Turtle WoW item category corrections for stale server-side item classifications.
-- Keep this list intentionally small and explicit to avoid affecting real quest items.
-- Lua 5.0 compatible.
local addon = Guda
if not addon or not addon.Modules or not addon.Modules.BagScanner then return end
local CATEGORY_OVERRIDES = {
[730] = "Trade Goods", -- Murloc Eye: no longer a quest item on Turtle WoW
}
-- ItemDetection checks this table at runtime before treating API/tooltip data as
-- authoritative quest metadata. Extend the existing exclusions rather than
-- replacing them so the Juju fixes remain intact.
if addon.Constants then
addon.Constants.QUEST_CATEGORY_EXCLUSIONS = addon.Constants.QUEST_CATEGORY_EXCLUSIONS or {}
for itemID in pairs(CATEGORY_OVERRIDES) do
addon.Constants.QUEST_CATEGORY_EXCLUSIONS[itemID] = true
end
end
local BagScanner = addon.Modules.BagScanner
local OriginalScanSlot = BagScanner.ScanSlot
if OriginalScanSlot then
function BagScanner:ScanSlot(bagID, slotID)
local itemData = OriginalScanSlot(self, bagID, slotID)
if not itemData then return nil end
local itemID = tonumber(itemData.itemID)
if not itemID and itemData.link and addon.Modules.Utils then
itemID = addon.Modules.Utils:ExtractItemID(itemData.link)
end
local category = itemID and CATEGORY_OVERRIDES[itemID] or nil
if category then
itemData.class = category
itemData.type = category
end
return itemData
end
end
addon:Debug("Turtle WoW item category overrides enabled")