483 lines
15 KiB
Lua
483 lines
15 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
|