Frame Budgeting Implementation

This commit is contained in:
Salikh Gurgenidze
2026-01-23 21:11:33 +04:00
parent 638e563b27
commit 2e41bc6a16
4 changed files with 379 additions and 11 deletions
+17
View File
@@ -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")
+214
View File
@@ -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:
+76 -5
View File
@@ -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
+72 -6
View File
@@ -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