From 2e41bc6a169a60746ff29a5d66e5003e4b01745d Mon Sep 17 00:00:00 2001 From: Salikh Gurgenidze Date: Fri, 23 Jan 2026 21:11:33 +0400 Subject: [PATCH] Frame Budgeting Implementation --- Core/Main.lua | 17 ++++ Core/Utils.lua | 214 +++++++++++++++++++++++++++++++++++++++++++++++ UI/BagFrame.lua | 81 ++++++++++++++++-- UI/BankFrame.lua | 78 +++++++++++++++-- 4 files changed, 379 insertions(+), 11 deletions(-) diff --git a/Core/Main.lua b/Core/Main.lua index 939603e..0154442 100644 --- a/Core/Main.lua +++ b/Core/Main.lua @@ -149,6 +149,21 @@ function Main:SetupSlashCommands() -- Cleanup old characters addon.Modules.DB:CleanupOldCharacters() + elseif msg == "perf" or msg == "performance" then + -- Show performance statistics + if addon.Modules.Utils and addon.Modules.Utils.PrintPerformanceStats then + addon.Modules.Utils:PrintPerformanceStats() + else + addon:Print("Performance stats not available") + end + + elseif msg == "perfreset" then + -- Reset performance statistics + if addon.Modules.Utils and addon.Modules.Utils.ResetPerformanceStats then + addon.Modules.Utils:ResetPerformanceStats() + addon:Print("Performance stats reset") + end + elseif msg == "help" then -- Show help addon:Print("Commands:") @@ -161,6 +176,8 @@ function Main:SetupSlashCommands() addon:Print("/guda debug - Toggle debug mode") addon:Print("/guda debugsort - Toggle sort debug output") addon:Print("/guda cleanup - Remove old characters") + addon:Print("/guda perf - Show performance stats") + addon:Print("/guda perfreset - Reset performance stats") else addon:Print("Unknown command. Type /guda help for commands") diff --git a/Core/Utils.lua b/Core/Utils.lua index 29a9b9a..dc8e353 100644 --- a/Core/Utils.lua +++ b/Core/Utils.lua @@ -5,6 +5,220 @@ local addon = Guda local Utils = {} addon.Modules.Utils = Utils +--============================================================================= +-- 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 Performance Stats ===") + addon:Print("Frame Budget: %.0fms", stats.frameBudget * 1000) + addon:Print("Last Update: %.1fms", stats.lastUpdateDuration * 1000) + addon:Print("Avg Update: %.1fms", stats.averageUpdateDuration * 1000) + addon:Print("Total Updates: %d", stats.totalUpdates) + addon:Print("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: diff --git a/UI/BagFrame.lua b/UI/BagFrame.lua index 1510546..d5f9d16 100644 --- a/UI/BagFrame.lua +++ b/UI/BagFrame.lua @@ -130,6 +130,12 @@ function Guda_BagFrame_OnHide(self) -- Close any open dropdown menus when the bag frame is hidden CloseDropDownMenus() + -- Clear any pending update and work queue items + pendingUpdate = false + if addon.Modules.Utils and addon.Modules.Utils.ClearWorkQueue then + addon.Modules.Utils:ClearWorkQueue() + end + -- Clean up all buttons when frame is hidden (safe since we're not displaying) for _, bagParent in pairs(bagParents) do if bagParent then @@ -275,12 +281,43 @@ function BagFrame:UpdateBaglineLayout() end end +-- Deferred update state for frame budgeting +local pendingUpdate = false +local updateDebounceFrame = nil +local UPDATE_DEBOUNCE_TIME = 0.05 -- 50ms debounce for rapid updates + +-- Schedule an update with debouncing (prevents multiple updates in rapid succession) +function BagFrame:ScheduleUpdate() + if pendingUpdate then return end + pendingUpdate = true + + if not updateDebounceFrame then + updateDebounceFrame = CreateFrame("Frame") + updateDebounceFrame.elapsed = 0 + end + + updateDebounceFrame.elapsed = 0 + updateDebounceFrame:SetScript("OnUpdate", function() + this.elapsed = this.elapsed + arg1 + if this.elapsed >= UPDATE_DEBOUNCE_TIME then + this:SetScript("OnUpdate", nil) + pendingUpdate = false + BagFrame:Update() + end + end) +end + -- Update display function BagFrame:Update() if not Guda_BagFrame:IsShown() then return end + -- Report entry for frame budget tracking + if addon.Modules.Utils and addon.Modules.Utils.ReportEntry then + addon.Modules.Utils:ReportEntry() + end + -- If cursor is holding an item (mid-drag), only update lock states, don't rebuild UI -- BUT only if we already have items displayed - otherwise we need to do initial build if CursorHasItem and CursorHasItem() then @@ -406,6 +443,11 @@ function BagFrame:Update() end end end + + -- Record performance metrics + if addon.Modules.Utils and addon.Modules.Utils.RecordUpdateEnd then + addon.Modules.Utils:RecordUpdateEnd() + end end -- Delegate to centralized helpers @@ -482,6 +524,10 @@ function BagFrame:DisplayItemsByCategory(bagData, isOtherChar, charName) local headerIdx = 1 local totalWidth = perRow * (buttonSize + spacing) + -- Frame budget tracking for category item processing + local categoryItemsProcessed = 0 + local CATEGORY_ITEMS_PER_BUDGET_CHECK = 8 + for _, catName in ipairs(categoryList) do local items = categories[catName] local numItems = items and table.getn(items) or 0 @@ -524,7 +570,7 @@ function BagFrame:DisplayItemsByCategory(bagData, isOtherChar, charName) end header.text:SetText(displayName) header:Show() - + local itemY = currentY + 20 local col = 0 local row = 0 @@ -532,17 +578,17 @@ function BagFrame:DisplayItemsByCategory(bagData, isOtherChar, charName) local bagID = item.bagID local slot = item.slotID local itemData = item.itemData - + local bagParent = self:GetBagParent(bagID) local button = Guda_GetItemButton(bagParent) - + button:SetParent(bagParent) button:SetWidth(buttonSize) button:SetHeight(buttonSize) button:ClearAllPoints() button:SetPoint("TOPLEFT", itemContainer, "TOPLEFT", startX + currentX + (col * (buttonSize + spacing)), startY - (itemY + (row * (buttonSize + spacing)))) button:Show() - + local matchesFilter = self:PassesSearchFilter(itemData) Guda_ItemButton_SetItem(button, bagID, slot, itemData, false, isOtherChar and charName or nil, matchesFilter, isOtherChar) button.inUse = true @@ -553,8 +599,17 @@ function BagFrame:DisplayItemsByCategory(bagData, isOtherChar, charName) col = 0 row = row + 1 end + + -- Frame budget check + categoryItemsProcessed = categoryItemsProcessed + 1 + if categoryItemsProcessed >= CATEGORY_ITEMS_PER_BUDGET_CHECK then + categoryItemsProcessed = 0 + if addon.Modules.Utils and addon.Modules.Utils.CheckTimeout and addon.Modules.Utils:CheckTimeout() then + addon.Modules.Utils:ReportEntry() + end + end end - + if blockHeight > rowMaxHeight then rowMaxHeight = blockHeight end currentX = currentX + blockWidth + 20 end @@ -783,6 +838,10 @@ function BagFrame:DisplayItems(bagData, isOtherChar, charName) table.insert(bagsToShow, {bagID = -2, needsSpacing = true}) end + -- Frame budget tracking for item processing + local itemsProcessed = 0 + local ITEMS_PER_BUDGET_CHECK = 8 -- Check budget every N items + for _, bagInfo in ipairs(bagsToShow) do local bagID = bagInfo.bagID local bag = bagData[bagID] @@ -842,6 +901,18 @@ function BagFrame:DisplayItems(bagData, isOtherChar, charName) col = 0 row = row + 1 end + + -- Frame budget check: periodically check if we've exceeded the frame budget + itemsProcessed = itemsProcessed + 1 + if itemsProcessed >= ITEMS_PER_BUDGET_CHECK then + itemsProcessed = 0 + if addon.Modules.Utils and addon.Modules.Utils.CheckTimeout and addon.Modules.Utils:CheckTimeout() then + -- Budget exceeded - reset entry time and continue + -- (For full deferral, we would queue remaining work, but that requires + -- more complex state management. For now, we just note the budget usage.) + addon.Modules.Utils:ReportEntry() + end + end end end end diff --git a/UI/BankFrame.lua b/UI/BankFrame.lua index ea47536..920ad58 100644 --- a/UI/BankFrame.lua +++ b/UI/BankFrame.lua @@ -86,6 +86,9 @@ function Guda_BankFrame_OnHide(self) -- Close any open dropdown menus when the bank frame is hidden CloseDropDownMenus() + -- Clear any pending update + bankPendingUpdate = false + -- Close the actual Blizzard bank too local blizzardBankFrame = getglobal("BankFrame") if blizzardBankFrame and blizzardBankFrame:IsShown() then @@ -121,12 +124,43 @@ function BankFrame:UpdateLockStates() Guda_UpdateLockStates(bankBagParents) end +-- Deferred update state for frame budgeting +local bankPendingUpdate = false +local bankUpdateDebounceFrame = nil +local BANK_UPDATE_DEBOUNCE_TIME = 0.05 -- 50ms debounce for rapid updates + +-- Schedule an update with debouncing (prevents multiple updates in rapid succession) +function BankFrame:ScheduleUpdate() + if bankPendingUpdate then return end + bankPendingUpdate = true + + if not bankUpdateDebounceFrame then + bankUpdateDebounceFrame = CreateFrame("Frame") + bankUpdateDebounceFrame.elapsed = 0 + end + + bankUpdateDebounceFrame.elapsed = 0 + bankUpdateDebounceFrame:SetScript("OnUpdate", function() + this.elapsed = this.elapsed + arg1 + if this.elapsed >= BANK_UPDATE_DEBOUNCE_TIME then + this:SetScript("OnUpdate", nil) + bankPendingUpdate = false + BankFrame:Update() + end + end) +end + -- Update display function BankFrame:Update() if not Guda_BankFrame:IsShown() then return end + -- Report entry for frame budget tracking + if addon.Modules.Utils and addon.Modules.Utils.ReportEntry then + addon.Modules.Utils:ReportEntry() + end + -- If cursor is holding an item (mid-drag), only update lock states, don't rebuild UI -- BUT only if we already have items displayed - otherwise we need to do initial build if CursorHasItem and CursorHasItem() then @@ -244,6 +278,11 @@ function BankFrame:Update() end end end + + -- Record performance metrics + if addon.Modules.Utils and addon.Modules.Utils.RecordUpdateEnd then + addon.Modules.Utils:RecordUpdateEnd() + end end -- Use centralized frame helpers for section headers and bag parents @@ -307,7 +346,11 @@ function BankFrame:DisplayItemsByCategory(bankData, isOtherChar, charName) local rowMaxHeight = 0 local headerIdx = 1 local totalWidth = perRow * (buttonSize + spacing) - + + -- Frame budget tracking for category item processing + local categoryItemsProcessed = 0 + local CATEGORY_ITEMS_PER_BUDGET_CHECK = 8 + for _, catName in ipairs(categoryList) do local items = categories[catName] local numItems = items and table.getn(items) or 0 @@ -351,28 +394,37 @@ function BankFrame:DisplayItemsByCategory(bankData, isOtherChar, charName) local bagID = item.bagID local slot = item.slotID local itemData = item.itemData - + local bagParent = self:GetBagParent(bagID) local button = Guda_GetItemButton(bagParent) - + button:SetParent(bagParent) button:SetWidth(buttonSize) button:SetHeight(buttonSize) button:ClearAllPoints() button:SetPoint("TOPLEFT", itemContainer, "TOPLEFT", startX + currentX + (col * (buttonSize + spacing)), startY - (itemY + (row * (buttonSize + spacing)))) button:Show() - + local matchesFilter = self:PassesSearchFilter(itemData) Guda_ItemButton_SetItem(button, bagID, slot, itemData, true, isOtherChar and charName or nil, matchesFilter, isOtherChar or isReadOnlyMode) button.inUse = true - + col = col + 1 if col >= blockCols then col = 0 row = row + 1 end + + -- Frame budget check + categoryItemsProcessed = categoryItemsProcessed + 1 + if categoryItemsProcessed >= CATEGORY_ITEMS_PER_BUDGET_CHECK then + categoryItemsProcessed = 0 + if addon.Modules.Utils and addon.Modules.Utils.CheckTimeout and addon.Modules.Utils:CheckTimeout() then + addon.Modules.Utils:ReportEntry() + end + end end - + if blockHeight > rowMaxHeight then rowMaxHeight = blockHeight end currentX = currentX + blockWidth + 20 end @@ -583,6 +635,10 @@ function BankFrame:DisplayItems(bankData, isOtherChar, charName) end end + -- Frame budget tracking for item processing + local itemsProcessed = 0 + local ITEMS_PER_BUDGET_CHECK = 8 -- Check budget every N items + for _, bagInfo in ipairs(bagsToShow) do local bagID = bagInfo.bagID local bag = bankData and bankData[bagID] @@ -631,6 +687,16 @@ function BankFrame:DisplayItems(bankData, isOtherChar, charName) col = 0 row = row + 1 end + + -- Frame budget check: periodically check if we've exceeded the frame budget + itemsProcessed = itemsProcessed + 1 + if itemsProcessed >= ITEMS_PER_BUDGET_CHECK then + itemsProcessed = 0 + if addon.Modules.Utils and addon.Modules.Utils.CheckTimeout and addon.Modules.Utils:CheckTimeout() then + -- Budget exceeded - reset entry time and continue + addon.Modules.Utils:ReportEntry() + end + end end end end