diff --git a/Core/CacheWarmer.lua b/Core/CacheWarmer.lua index bfb240b..a8a3da8 100644 --- a/Core/CacheWarmer.lua +++ b/Core/CacheWarmer.lua @@ -22,18 +22,27 @@ function CacheWarmer:WarmBagScanner() end end --- Walk player bags 0-4 and queue one ItemDetection:GetItemProperties per --- occupied slot, plus Utils:GetConsumableRestoreTag for consumables. The --- work queue spreads them across frames within the existing 100ms-per-frame --- budget. This warms both tooltip caches the bag-open path reads --- (ItemDetection.detectionCache and Utils.tooltipCache.restoreTag), so the --- first bag open doesn't pay for synchronous tooltip scans. +-- Walk player bags 0-4 and queue tooltip-scan work for every cache the +-- bag-open path reads, so the first DisplayItemsByCategory never blocks on +-- a cold lookup. Targets: +-- * ItemDetection.detectionCache — used for isJunk / isQuestItem rules +-- * Utils.tooltipCache.restoreTag — used for consumable food/drink grouping +-- * Utils.tooltipCache.bindOnEquip — used for the isBoE rule +-- * CategoryManager.categoryCache — used directly by the layout path +-- Queue order is FIFO and budget-limited to 100ms/frame, so this all runs +-- in the 0.5s–~2s window after PLAYER_LOGIN. function CacheWarmer:WarmItemDetectionCache() local Utils = Guda.Modules.Utils local ItemDetection = Guda.Modules.ItemDetection + local CategoryManager = Guda.Modules.CategoryManager + local BagScanner = Guda.Modules.BagScanner if not (Utils and Utils.QueueWork) then return end if not (ItemDetection and ItemDetection.GetItemProperties) then return end + -- WarmBagScanner ran just before us, so GetBagData() is a cache hit and + -- returns the already-populated itemData with class/subclass/quality. + local bagData = BagScanner and BagScanner.GetBagData and BagScanner:GetBagData() + for bagID = 0, 4 do local numSlots = GetContainerNumSlots(bagID) if numSlots and numSlots > 0 then @@ -42,6 +51,7 @@ function CacheWarmer:WarmItemDetectionCache() if link then -- Capture upvalues so the closure has stable bag/slot/link. local b, s, l = bagID, slotID, link + Utils:QueueWork(function() -- Minimal itemData — GetItemProperties only needs -- .link to compute the cache key; bagID/slotID feed @@ -49,16 +59,57 @@ function CacheWarmer:WarmItemDetectionCache() ItemDetection:GetItemProperties({ link = l }, b, s) end, "CacheWarmer.itemDetection") - -- Warm the consumable restoreTag cache too. Only consumables - -- ever carry the relevant tooltip lines, so check class via - -- GetItemInfo (cached by the client after the detection scan - -- above) and queue only matching items. + -- Consumable restoreTag cache (only consumables carry + -- "while eating" / "while drinking" / "use: restores"). Utils:QueueWork(function() local _, _, _, _, itemType = GetItemInfo(l) if itemType == "Consumable" and Utils.GetConsumableRestoreTag then Utils:GetConsumableRestoreTag(b, s, l) end end, "CacheWarmer.restoreTag") + + -- BoE tooltip cache. Only weapons/armor can be BoE; skip + -- everything else so we don't scan consumables / trade + -- goods tooltips for nothing. + Utils:QueueWork(function() + local _, _, _, _, _, class = GetItemInfo(l) + if (class == "Weapon" or class == "Armor") and Utils.IsBindOnEquip then + Utils:IsBindOnEquip(b, s, l) + end + end, "CacheWarmer.boe") + + -- CategoryManager cache. Snapshot itemData fields because + -- BagScanner uses a pool and may recycle the table later. + -- The restoreTag field is populated inside the callback + -- (not here) because the restoreTag warmer runs first via + -- FIFO queue order — so by the time this fires, the + -- tooltipCache.restoreTag lookup is a cache hit. + if CategoryManager and CategoryManager.CategorizeItem and bagData then + local bag = bagData[b] + local slotData = bag and bag.slots and bag.slots[s] + if slotData and slotData.link then + local snapshot = { + link = slotData.link, + name = slotData.name, + class = slotData.class, + type = slotData.type, + subclass = slotData.subclass, + quality = slotData.quality, + texture = slotData.texture, + equipSlot = slotData.equipSlot, + } + Utils:QueueWork(function() + -- Pull restoreTag from warm cache so rules that + -- key on it (Food/Drink categories) match. + if snapshot.class == "Consumable" + and Utils.GetConsumableRestoreTag then + snapshot.restoreTag = + Utils:GetConsumableRestoreTag(b, s, snapshot.link) + end + CategoryManager:CategorizeItem(snapshot, b, s, false) + end, "CacheWarmer.category") + end + end end end end @@ -71,5 +122,23 @@ function CacheWarmer:Initialize() Guda_ScheduleTimer(0.5, function() CacheWarmer:WarmBagScanner() CacheWarmer:WarmItemDetectionCache() + + -- Completion marker — runs after all prior warmers drain. Re-layouts + -- the bag so items that fell back to their class bucket on the first + -- (cold-cache) render now land in their real categories, and re-sweeps + -- tints for items whose detection finished while the bag was already + -- open. + local Utils = Guda.Modules.Utils + if Utils and Utils.QueueWork then + Utils:QueueWork(function() + if Guda_BagFrame and Guda_BagFrame:IsShown() + and Guda.Modules.BagFrame and Guda.Modules.BagFrame.Update then + Guda.Modules.BagFrame:Update() + end + if Guda_BagFrame_UpdateAllUsabilityTints then + Guda_BagFrame_UpdateAllUsabilityTints() + end + end, "CacheWarmer.completionSweep") + end end) end diff --git a/Core/CategoryManager.lua b/Core/CategoryManager.lua index 29f3aaf..88b47b8 100644 --- a/Core/CategoryManager.lua +++ b/Core/CategoryManager.lua @@ -1056,6 +1056,17 @@ function CategoryManager:GetCategoriesByPriority() return sorted end +-- Cache-only variant. Returns the cached category id if present, or nil. +-- Used by the bag-open layout path so it never triggers cold-cache rule +-- evaluation (which can cascade into tooltip scans per item). CacheWarmer +-- populates the cache in the background; bags re-layout once warmup finishes. +function CategoryManager:CategorizeItemCached(itemData, isOtherChar) + if not itemData or not itemData.link then return nil end + local cacheKey = GetCacheKey(itemData.link, isOtherChar) + if not cacheKey then return nil end + return categoryCache[cacheKey] +end + -- Categorize an item using the rule engine -- Returns category ID or "Miscellaneous" as fallback function CategoryManager:CategorizeItem(itemData, bagID, slotID, isOtherChar) diff --git a/Core/ItemDetection.lua b/Core/ItemDetection.lua index 7f88eec..2cead63 100644 --- a/Core/ItemDetection.lua +++ b/Core/ItemDetection.lua @@ -581,6 +581,18 @@ function ItemDetection:IsUnusable(itemData, bagID, slotID) return props.isUnusable end +-- Cache-only lookup. Returns true/false when cached, nil when unknown +-- (no tooltip scan, no side effects). Lets the layout path skip tinting +-- on cold cache without blocking on ~80 tooltip scans. +function ItemDetection:IsUnusableCached(itemData) + if not itemData or not itemData.link then return nil end + local cacheKey = GetCacheKey(itemData.link) + if not cacheKey then return nil end + local props = detectionCache[cacheKey] + if not props then return nil end + return props.isUnusable +end + function ItemDetection:GetCharges(itemData, bagID, slotID) if not bagID or not slotID then return nil end local slotKey = bagID .. ":" .. slotID diff --git a/UI/BagFrame.lua b/UI/BagFrame.lua index fb108ec..d05edaf 100644 --- a/UI/BagFrame.lua +++ b/UI/BagFrame.lua @@ -102,25 +102,28 @@ local function CancelDeferredUsabilityCheck() end end --- Update usability tints on all visible item buttons -local function UpdateAllUsabilityTints() +-- Update usability tints on all visible item buttons — cache-only. +-- Every SetBagItem tooltip scan on a cold item can block 50-500ms, so we +-- never trigger scans from the bag-open path. Tints appear for items +-- already cached; uncached items stay untinted until CacheWarmer fills +-- them in (its BagFrame hook triggers a re-sweep when done). +function Guda_BagFrame_UpdateAllUsabilityTints() if not Guda_BagFrame or not Guda_BagFrame:IsShown() then return end - -- Don't scan ~80 tooltips synchronously while the user is dragging the - -- frame — it can stall the engine for multiple seconds on cold caches. - -- The caller will reschedule after the drag ends. if isFrameMoving then return end for _, bagParent in pairs(bagParents) do if bagParent and bagParent.itemButtons then for button in pairs(bagParent.itemButtons) do if button.hasItem and button:IsShown() and Guda_ItemButton_UpdateUsableTint then - Guda_ItemButton_UpdateUsableTint(button) + Guda_ItemButton_UpdateUsableTint(button, true) -- cacheOnly end end end end end +local UpdateAllUsabilityTints = Guda_BagFrame_UpdateAllUsabilityTints + -- Schedule a deferred usability check with debouncing local function ScheduleDeferredUsabilityCheck() -- Create frame on first use diff --git a/UI/FrameHelpers.lua b/UI/FrameHelpers.lua index 4b60f54..697525c 100644 --- a/UI/FrameHelpers.lua +++ b/UI/FrameHelpers.lua @@ -42,9 +42,19 @@ function Guda_CategorizeItem(itemData, bagID, slotID, categories, specialItems, return end - -- Use CategoryManager rule engine if available, otherwise fall back to legacy logic + -- Use CategoryManager rule engine if available, otherwise fall back to legacy logic. + -- Fast path: cache-only lookup. CacheWarmer populates categoryCache in the + -- background; on cold miss we skip rule evaluation (which would otherwise + -- trigger tooltip scans per item) and use itemData.class as a rough bucket. + -- CacheWarmer's completion marker re-triggers BagFrame:Update so items land + -- in their real categories once warmup finishes. if addon.Modules.CategoryManager then - cat = addon.Modules.CategoryManager:CategorizeItem(itemData, bagID, slotID, isOtherChar) + if addon.Modules.CategoryManager.CategorizeItemCached then + cat = addon.Modules.CategoryManager:CategorizeItemCached(itemData, isOtherChar) + end + if not cat then + cat = itemData.class or "Miscellaneous" + end if not categories[cat] then cat = "Miscellaneous" end table.insert(categories[cat], {bagID = bagID, slotID = slotID, itemData = itemData}) return diff --git a/UI/ItemButton.lua b/UI/ItemButton.lua index dcfd0d4..5755b18 100644 --- a/UI/ItemButton.lua +++ b/UI/ItemButton.lua @@ -848,8 +848,10 @@ end -- The old IsItemUnusable, IsRedColor, and durabilityPattern have been removed -- to prevent redundant tooltip scanning - all detection is now centralized. --- Apply/remove red tint on item texture for unusable items -local function Guda_ItemButton_UpdateUsableTint(self) +-- Apply/remove red tint on item texture for unusable items. +-- When cacheOnly is true, skips the tooltip scan on cold cache and returns +-- untinted — the deferred pass in BagFrame fills in tints later. +local function Guda_ItemButton_UpdateUsableTint(self, cacheOnly) -- Clear any existing tint/overlay first if self.unusableOverlay and self.unusableOverlay.Hide then self.unusableOverlay:Hide() @@ -880,7 +882,16 @@ local function Guda_ItemButton_UpdateUsableTint(self) -- Use cached detection from ItemDetection module (avoids duplicate tooltip scans) local unusable = false if self.itemData and addon.Modules.ItemDetection then - unusable = addon.Modules.ItemDetection:IsUnusable(self.itemData, self.bagID, self.slotID) + if cacheOnly then + local cached = addon.Modules.ItemDetection:IsUnusableCached(self.itemData) + if cached == nil then + -- Unknown; leave untinted. Deferred pass fills this in later. + return + end + unusable = cached + else + unusable = addon.Modules.ItemDetection:IsUnusable(self.itemData, self.bagID, self.slotID) + end end -- Ensure overlay exists (created in OnLoad, but be defensive) @@ -1885,9 +1896,10 @@ function Guda_ItemButton_SetItem(self, bagID, slotID, itemData, isBank, otherCha if cd and cd.Hide then cd:Hide() end end - -- Update unusable red overlay tint + -- Update unusable red overlay tint (cache-only on the layout path; + -- deferred pass in BagFrame runs the full scan spread via QueueWork). if Guda_ItemButton_UpdateUsableTint then - Guda_ItemButton_UpdateUsableTint(self) + Guda_ItemButton_UpdateUsableTint(self, true) end else -- Clear empty slot