diff --git a/Data/BagScanner.lua b/Data/BagScanner.lua index eee5d37..c681308 100644 --- a/Data/BagScanner.lua +++ b/Data/BagScanner.lua @@ -10,45 +10,49 @@ addon.Modules.BagScanner = BagScanner local bagCache = nil local cacheValid = false --- Event pending tracking (like Baganator's IsBagEventPending) +-- Event pending tracking local eventPending = false -local dirtySlots = {} -- Track specific slots that changed: dirtySlots[bagID][slotID] = true +local dirtySlots = {} -- dirtySlots[bagID][slotID] = true +local dirtyBags = {} -- BAG_UPDATE only identifies a bag, not the exact slot --===================================================== --- Item Data Pool (Baganator-inspired memory optimization) +-- Item Data Pool -- Reuses item data tables instead of creating new ones --===================================================== local itemDataPool = {} -local ITEM_DATA_POOL_MAX = 200 -- Limit pool size to prevent unbounded growth +local ITEM_DATA_POOL_MAX = 300 --- Acquire item data table from pool local function AcquireItemData() return table.remove(itemDataPool) or {} end --- Release item data table back to pool local function ReleaseItemData(data) - if data and table.getn(itemDataPool) < ITEM_DATA_POOL_MAX then - -- Clear all fields for reuse - data.link = 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 + 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 --- Release all item data in a bag cache entry local function ReleaseBagCacheData(bagData) if bagData and bagData.slots then - for slotID, itemData in pairs(bagData.slots) do + for _, itemData in pairs(bagData.slots) do if itemData then ReleaseItemData(itemData) end @@ -56,29 +60,49 @@ local function ReleaseBagCacheData(bagData) 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() - if bagCache then - for bagID, bagData in pairs(bagCache) do - ReleaseBagCacheData(bagData) - end - end + ReleaseAllBagCacheData() bagCache = nil cacheValid = false dirtySlots = {} + dirtyBags = {} + eventPending = false end --- Check if a bag event is pending (use before transfers) function BagScanner:IsEventPending() return eventPending end --- Clear the pending flag (call after processing) function BagScanner:ClearEventPending() eventPending = false end --- Mark a specific slot as dirty (incremental tracking) function BagScanner:MarkSlotDirty(bagID, slotID) if not dirtySlots[bagID] then dirtySlots[bagID] = {} @@ -87,30 +111,139 @@ function BagScanner:MarkSlotDirty(bagID, slotID) eventPending = true end --- Get dirty slots and clear them 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 - -- Process any dirty slots incrementally + 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 bagID ~= nil and type(slots) == "table" then + if not bagsToRefresh[bagID] + and bagID ~= nil and type(slots) == "table" then if bagCache[bagID] then for slotID in pairs(slots) do - -- Validate slotID is a valid number 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 - -- Update free slot count - local wasEmpty = (oldData == nil) - local isEmpty = (newData == nil) if wasEmpty and not isEmpty then bagCache[bagID].freeSlots = bagCache[bagID].freeSlots - 1 elseif not wasEmpty and isEmpty then @@ -119,51 +252,49 @@ function BagScanner:GetBagData() end end else - -- Bag not in cache, scan it bagCache[bagID] = self:ScanBag(bagID) end end end + dirtySlots = {} + eventPending = false return bagCache end - -- Cache miss - do full scan + -- 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 --- Invalidate cache (force full re-scan on next update) function BagScanner:InvalidateCache() cacheValid = false end --- Invalidate a specific bag in the cache function BagScanner:InvalidateBag(bagID) - if not bagCache then return end + if not bagCache or not bagCache[bagID] then return end + ReleaseBagCacheData(bagCache[bagID]) bagCache[bagID] = nil end --- Scan all bags and return data (full scan) function BagScanner:ScanBags() local bagData = {} - -- Scan regular bags for _, bagID in ipairs(addon.Constants.BAGS) do bagData[bagID] = self:ScanBag(bagID) end - -- Also scan keyring (bagID -2) bagData[-2] = self:ScanBag(-2) - return bagData end --- Scan a single bag function BagScanner:ScanBag(bagID) - -- Determine bag type local bagType = addon.Modules.Utils:GetSpecializedBagType(bagID) or "regular" local bag = { @@ -180,7 +311,6 @@ function BagScanner:ScanBag(bagID) 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 @@ -189,86 +319,160 @@ function BagScanner:ScanBag(bagID) return bag end --- Scan a single slot (uses item data pooling) +-- 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) - -- Validate parameters to prevent API errors if bagID == nil or slot == nil or slot < 1 then return nil end - local texture, itemCount, locked, quality, readable, lootable = GetContainerItemInfo(bagID, slot) + local api = addon.Modules.ClassicAPI + local info = GetLiveSlotInfo(bagID, slot) + if not info then return nil end - if not texture then - return nil + local itemID = tonumber(info.itemID) + if not itemID and api and api.GetContainerItemID then + itemID = api:GetContainerItemID(bagID, slot, info) end - -- In 1.12.1, must use GetContainerItemLink separately! - local itemLink = GetContainerItemLink(bagID, slot) + 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 - -- Get item info - 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) + 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 - -- Use pooled item data table instead of creating new one local itemData = AcquireItemData() - itemData.link = itemLink - itemData.texture = texture + 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 + itemData.name = name or info.itemName itemData.iLevel = iLevel itemData.type = itemType itemData.class = itemCategory - -- Override misclassified Quest items (e.g. Juju consumables) to Consumable - if itemCategory == "Quest" and itemLink and addon.Constants and addon.Constants.QUEST_CATEGORY_EXCLUSIONS then - local itemID = addon.Modules.Utils:ExtractItemID(itemLink) - if itemID and addon.Constants.QUEST_CATEGORY_EXCLUSIONS[itemID] then + 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 - itemData.subclass = itemSubType - itemData.equipSlot = itemEquipLoc - itemData.locked = locked + return itemData end --- Save current bags to database +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 bagData = self:ScanBags() - addon.Modules.DB:SaveBags(bagData) + local runtimeData = self:GetBagData() + addon.Modules.DB:SaveBags(CloneBagDataForDatabase(runtimeData)) addon:Debug("Bag data saved to database") - -- Clear tooltip cache so counts update immediately if addon.Modules.Tooltip and addon.Modules.Tooltip.ClearCache then addon.Modules.Tooltip:ClearCache() end end --- Initialize with event pending tracking function BagScanner:Initialize() local eventFrame = CreateFrame("Frame") self.eventFrame = eventFrame - -- Register bag update events for pending tracking eventFrame:RegisterEvent("BAG_UPDATE") eventFrame:RegisterEvent("BAG_UPDATE_DELAYED") eventFrame:SetScript("OnEvent", function() eventPending = true - -- Mark the specific bag as having changes - if arg1 then - if not dirtySlots[arg1] then - dirtySlots[arg1] = {} - end - -- We don't know which slot, so mark entire bag for rescan - -- by invalidating it - if bagCache and bagCache[arg1] then - bagCache[arg1] = nil - end + + 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 event pending tracking") + addon:Debug("Bag scanner initialized with ClassicAPI incremental cache") end