---@class MemoryScanner ---@field _StateManager table ---@field _Context table ---@field _ImageCache table ---@field _ErrorHandler table local MemoryScanner = {} ---Initialize MemoryScanner with dependencies ---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table} function MemoryScanner.init(deps) MemoryScanner._StateManager = deps.StateManager MemoryScanner._Context = deps.Context MemoryScanner._ImageCache = deps.ImageCache MemoryScanner._ErrorHandler = deps.ErrorHandler end ---Count items in a table ---@param tbl table ---@return number local function countTable(tbl) local count = 0 for _ in pairs(tbl) do count = count + 1 end return count end ---Calculate memory size estimate for a table (recursive) ---@param tbl table ---@param visited table? Tracking table to prevent circular references ---@param depth number? Current recursion depth ---@return number bytes Estimated memory usage in bytes local function estimateTableSize(tbl, visited, depth) if type(tbl) ~= "table" then return 0 end visited = visited or {} depth = depth or 0 -- Limit recursion depth to prevent stack overflow if depth > 10 then return 0 end -- Check for circular references if visited[tbl] then return 0 end visited[tbl] = true local size = 40 -- Base table overhead (approximate) for k, v in pairs(tbl) do -- Key size if type(k) == "string" then size = size + #k + 24 -- String overhead elseif type(k) == "number" then size = size + 8 else size = size + 8 -- Reference end -- Value size if type(v) == "string" then size = size + #v + 24 elseif type(v) == "number" then size = size + 8 elseif type(v) == "boolean" then size = size + 4 elseif type(v) == "table" then size = size + estimateTableSize(v, visited, depth + 1) elseif type(v) == "function" then size = size + 16 -- Function reference else size = size + 8 -- Other references end end return size end ---Scan StateManager for memory issues ---@return table report Detailed report of StateManager memory usage function MemoryScanner.scanStateManager() local report = { stateCount = 0, stateStoreSize = 0, metadataSize = 0, callSiteCounterSize = 0, orphanedStates = {}, staleStates = {}, largeStates = {}, issues = {}, } if not MemoryScanner._StateManager then table.insert(report.issues, { severity = "error", message = "StateManager not initialized", }) return report end local internal = MemoryScanner._StateManager._getInternalState() local stateStore = internal.stateStore local stateMetadata = internal.stateMetadata local callSiteCounters = internal.callSiteCounters local currentFrame = MemoryScanner._StateManager.getFrameNumber() -- Count states report.stateCount = countTable(stateStore) -- Estimate sizes report.stateStoreSize = estimateTableSize(stateStore) report.metadataSize = estimateTableSize(stateMetadata) report.callSiteCounterSize = estimateTableSize(callSiteCounters) -- Check for orphaned states (metadata without state) for id, _ in pairs(stateMetadata) do if not stateStore[id] then table.insert(report.orphanedStates, id) end end -- Check for stale states (not accessed in many frames) local staleThreshold = 120 -- 2 seconds at 60fps for id, meta in pairs(stateMetadata) do local framesSinceAccess = currentFrame - meta.lastFrame if framesSinceAccess > staleThreshold then table.insert(report.staleStates, { id = id, framesSinceAccess = framesSinceAccess, createdFrame = meta.createdFrame, accessCount = meta.accessCount, }) end end -- Check for large states (may indicate memory bloat) for id, state in pairs(stateStore) do local stateSize = estimateTableSize(state) if stateSize > 1024 then -- More than 1KB table.insert(report.largeStates, { id = id, size = stateSize, keyCount = countTable(state), }) end end -- Check callSiteCounters (should be near 0 after frame cleanup) local callSiteCount = countTable(callSiteCounters) if callSiteCount > 100 then table.insert(report.issues, { severity = "warning", message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount), suggestion = "incrementFrame() may not be called properly, or counters aren't being reset", }) end -- Check for excessive state count if report.stateCount > 500 then table.insert(report.issues, { severity = "warning", message = string.format("High state count: %d states", report.stateCount), suggestion = "Consider reducing element count or implementing more aggressive cleanup", }) end -- Check for orphaned states if #report.orphanedStates > 0 then table.insert(report.issues, { severity = "error", message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates), suggestion = "This indicates a bug in state management - metadata should be cleaned up with state", }) end -- Check for stale states if #report.staleStates > 10 then table.insert(report.issues, { severity = "warning", message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates), suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames", }) end return report end ---Scan Context for memory issues ---@return table report Detailed report of Context memory usage function MemoryScanner.scanContext() local report = { topElementCount = 0, zIndexElementCount = 0, frameElementCount = 0, issues = {}, } if not MemoryScanner._Context then table.insert(report.issues, { severity = "error", message = "Context not initialized", }) return report end -- Count elements report.topElementCount = #MemoryScanner._Context.topElements report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements report.frameElementCount = #MemoryScanner._Context._currentFrameElements -- Check for stale z-index elements (should be cleared each frame) if MemoryScanner._Context._immediateMode then -- In immediate mode, _zIndexOrderedElements should be cleared at frame start -- If it has elements outside of frame rendering, that's a leak if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then table.insert(report.issues, { severity = "warning", message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount), suggestion = "clearFrameElements() may not be called properly in beginFrame()", }) end end -- Check for excessive element count if report.topElementCount > 100 then table.insert(report.issues, { severity = "info", message = string.format("High top-level element count: %d", report.topElementCount), suggestion = "Consider consolidating elements or using fewer top-level containers", }) end return report end ---Scan ImageCache for memory issues ---@return table report Detailed report of ImageCache memory usage function MemoryScanner.scanImageCache() local report = { imageCount = 0, estimatedMemory = 0, issues = {}, } if not MemoryScanner._ImageCache then table.insert(report.issues, { severity = "error", message = "ImageCache not initialized", }) return report end local stats = MemoryScanner._ImageCache.getStats() report.imageCount = stats.count report.estimatedMemory = stats.memoryEstimate -- Check for excessive memory usage (>100MB) if report.estimatedMemory > 100 * 1024 * 1024 then table.insert(report.issues, { severity = "warning", message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024), suggestion = "Consider implementing cache eviction or clearing unused images", }) end -- Check for excessive image count if report.imageCount > 50 then table.insert(report.issues, { severity = "info", message = string.format("ImageCache has %d images", report.imageCount), suggestion = "Review if all cached images are necessary", }) end return report end ---Check if a circular reference is intentional (parent-child, module, or metatable) ---@param path string The current path where circular ref was detected ---@param originalPath string The original path where the table was first seen ---@return boolean True if this is an intentional circular reference local function isIntentionalCircularReference(path, originalPath) -- Pattern 1: child.parent points back to parent -- Example: "topElements.1.children.1.parent" -> "topElements.1" if path:match("%.parent$") then local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$") if parentPath == originalPath then return true end end -- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree -- Example: "topElements.1" -> "topElements.1.children.1.parent" if originalPath:match("%.parent$") then local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$") if childParentPath == path then return true end end -- Pattern 3: Check for nested parent-child cycles -- child.children[n].parent -> child local segments = {} for segment in path:gmatch("[^.]+") do table.insert(segments, segment) end -- Look for .children.N.parent pattern for i = 1, #segments - 2 do if segments[i] == "children" and segments[i + 2] == "parent" then -- Reconstruct path without the .children.N.parent suffix local reconstructedPath = table.concat(segments, ".", 1, i - 1) if reconstructedPath == originalPath then return true end end end -- Pattern 4: Metatable __index self-references (modules) -- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme" if path:match("%.__index$") then local basePath = path:match("^(.+)%.__index$") if basePath == originalPath then return true end end -- Pattern 5: Shared module references (elements sharing same module instances) -- Example: Multiple elements referencing _utils, _Theme, _Blur, etc. -- These start with _ and are typically modules local pathModuleName = path:match("%.(_[%w]+)%.") local originalModuleName = originalPath:match("%.(_[%w]+)%.") if pathModuleName and originalModuleName then -- If both paths reference the same internal module (starting with _), it's intentional if pathModuleName == originalModuleName then return true end end -- Pattern 6: Shared Color/Transform objects between elements -- These are value objects that can be safely shared if path:match("Color") and originalPath:match("Color") then return true end if path:match("Transform") and originalPath:match("Transform") then return true end -- Pattern 7: LayoutEngine holding reference to its element -- Example: "element._layoutEngine.element" -> "element" if path:match("%._layoutEngine%.element$") then local elementPath = path:match("^(.+)%._layoutEngine%.element$") if elementPath == originalPath then return true end end -- Pattern 8: Renderer holding references to element properties -- Example: "element._renderer.cornerRadius" -> "element.cornerRadius" if path:match("%._renderer%.") then local rendererBasePath = path:match("^(.+)%._renderer%.") local originalBasePath = originalPath:match("^(.+)%.") if rendererBasePath == originalBasePath then return true end end -- Pattern 9: Context reference from layout engine (shared singleton) -- Example: "element._layoutEngine._Context.topElements" -> "topElements" if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then return true end return false end ---Detect circular references in a table ---@param tbl table Table to check ---@param path string? Current path (for reporting) ---@param visited table? Tracking table ---@return table[] circularRefs Array of circular reference paths ---@return table[] intentionalRefs Array of intentional parent-child refs local function detectCircularReferences(tbl, path, visited) if type(tbl) ~= "table" then return {}, {} end path = path or "root" visited = visited or {} local circularRefs = {} local intentionalRefs = {} -- Check if we've seen this table before if visited[tbl] then local ref = { path = path, originalPath = visited[tbl], } -- Determine if this is an intentional circular reference if isIntentionalCircularReference(path, visited[tbl]) then table.insert(intentionalRefs, ref) else table.insert(circularRefs, ref) end return circularRefs, intentionalRefs end -- Mark as visited visited[tbl] = path -- Recursively check children for k, v in pairs(tbl) do if type(v) == "table" then local childPath = path .. "." .. tostring(k) local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited) for _, ref in ipairs(childRefs) do table.insert(circularRefs, ref) end for _, ref in ipairs(childIntentionalRefs) do table.insert(intentionalRefs, ref) end end end return circularRefs, intentionalRefs end ---Scan for circular references in immediate mode ---@return table report Detailed report of circular references function MemoryScanner.scanCircularReferences() local report = { stateStoreCircularRefs = {}, stateStoreIntentionalRefs = {}, contextCircularRefs = {}, contextIntentionalRefs = {}, issues = {}, } if MemoryScanner._StateManager then local internal = MemoryScanner._StateManager._getInternalState() report.stateStoreCircularRefs, report.stateStoreIntentionalRefs = detectCircularReferences(internal.stateStore, "stateStore") end if MemoryScanner._Context then report.contextCircularRefs, report.contextIntentionalRefs = detectCircularReferences(MemoryScanner._Context.topElements, "topElements") end -- Report issues only for cross-module circular references if #report.stateStoreCircularRefs > 0 then table.insert(report.issues, { severity = "info", message = string.format("Found %d cross-module circular references in StateManager", #report.stateStoreCircularRefs), suggestion = "These are typically architectural dependencies between modules, not memory leaks", }) end if #report.contextCircularRefs > 0 then table.insert(report.issues, { severity = "info", message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs), suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks", }) end return report end ---Run comprehensive memory scan ---@return table report Complete memory analysis report function MemoryScanner.scan() local startMemory = collectgarbage("count") local report = { timestamp = os.time(), startMemory = startMemory / 1024, -- MB stateManager = MemoryScanner.scanStateManager(), context = MemoryScanner.scanContext(), imageCache = MemoryScanner.scanImageCache(), circularRefs = MemoryScanner.scanCircularReferences(), summary = { totalIssues = 0, criticalIssues = 0, warnings = 0, info = 0, }, } -- Count issues by severity local function countIssues(subReport) for _, issue in ipairs(subReport.issues or {}) do report.summary.totalIssues = report.summary.totalIssues + 1 if issue.severity == "error" then report.summary.criticalIssues = report.summary.criticalIssues + 1 elseif issue.severity == "warning" then report.summary.warnings = report.summary.warnings + 1 elseif issue.severity == "info" then report.summary.info = report.summary.info + 1 end end end countIssues(report.stateManager) countIssues(report.context) countIssues(report.imageCache) countIssues(report.circularRefs) -- Force GC and measure freed memory local beforeGC = collectgarbage("count") collectgarbage("collect") collectgarbage("collect") local afterGC = collectgarbage("count") report.gcAnalysis = { beforeGC = beforeGC / 1024, -- MB afterGC = afterGC / 1024, -- MB freed = (beforeGC - afterGC) / 1024, -- MB freedPercent = ((beforeGC - afterGC) / beforeGC) * 100, } -- Analyze GC effectiveness if report.gcAnalysis.freedPercent < 5 then table.insert(report.stateManager.issues, { severity = "info", message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent), suggestion = "Most memory is still referenced - this is normal if UI is active", }) elseif report.gcAnalysis.freedPercent > 30 then table.insert(report.stateManager.issues, { severity = "warning", message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent), suggestion = "Significant memory was unreferenced - may indicate cleanup issues", }) end return report end ---Format report as human-readable string ---@param report table Memory scan report ---@return string formatted Formatted report function MemoryScanner.formatReport(report) local lines = {} table.insert(lines, "=== FlexLöve Memory Scanner Report ===") table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp))) table.insert(lines, string.format("Memory: %.2f MB", report.startMemory)) table.insert(lines, "") -- Summary table.insert(lines, "--- Summary ---") table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues)) table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues)) table.insert(lines, string.format(" Warnings: %d", report.summary.warnings)) table.insert(lines, string.format(" Info: %d", report.summary.info)) table.insert(lines, "") -- StateManager table.insert(lines, "--- StateManager ---") table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount)) table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024)) table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024)) table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024)) table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates)) table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates)) table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates)) if #report.stateManager.issues > 0 then table.insert(lines, "Issues:") for _, issue in ipairs(report.stateManager.issues) do table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) if issue.suggestion then table.insert(lines, string.format(" → %s", issue.suggestion)) end end end table.insert(lines, "") -- Context table.insert(lines, "--- Context ---") table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount)) table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount)) table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount)) if #report.context.issues > 0 then table.insert(lines, "Issues:") for _, issue in ipairs(report.context.issues) do table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) if issue.suggestion then table.insert(lines, string.format(" → %s", issue.suggestion)) end end end table.insert(lines, "") -- ImageCache table.insert(lines, "--- ImageCache ---") table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount)) table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024)) if #report.imageCache.issues > 0 then table.insert(lines, "Issues:") for _, issue in ipairs(report.imageCache.issues) do table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) if issue.suggestion then table.insert(lines, string.format(" → %s", issue.suggestion)) end end end table.insert(lines, "") -- Circular References table.insert(lines, "--- Circular References ---") table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs)) table.insert(lines, string.format("StateStore (Intentional - parent-child, modules, metatables): %d", #report.circularRefs.stateStoreIntentionalRefs)) table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs)) table.insert(lines, string.format("Context (Intentional - parent-child, modules, metatables): %d", #report.circularRefs.contextIntentionalRefs)) if #report.circularRefs.issues > 0 then table.insert(lines, "Issues:") for _, issue in ipairs(report.circularRefs.issues) do table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message)) if issue.suggestion then table.insert(lines, string.format(" → %s", issue.suggestion)) end end else table.insert(lines, " ✓ No unexpected circular references detected") end table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks") table.insert(lines, "") -- GC Analysis table.insert(lines, "--- Garbage Collection Analysis ---") table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC)) table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC)) table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent)) table.insert(lines, "") table.insert(lines, "=== End Report ===") return table.concat(lines, "\n") end ---Save report to file ---@param report table Memory scan report ---@param filename string? Output filename (default: memory_report.txt) function MemoryScanner.saveReport(report, filename) filename = filename or "memory_report.txt" local formatted = MemoryScanner.formatReport(report) local file = io.open(filename, "w") if file then file:write(formatted) file:close() -- Use ErrorHandler if available, otherwise fall back to print if MemoryScanner._ErrorHandler then MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", { resourceType = "report", path = filename, status = "saved", }) else print(string.format("[MemoryScanner] Report saved to %s", filename)) end else -- Use ErrorHandler if available, otherwise fall back to print if MemoryScanner._ErrorHandler then MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", { resourceType = "report", path = filename, status = "failed to save", }) else print(string.format("[MemoryScanner] Failed to save report to %s", filename)) end end end return MemoryScanner