From 707b2dc3360cca2e9338595c8d048f4ba7956c2a Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Fri, 30 Oct 2026 16:45:52 +0000 Subject: [PATCH] Add README.md --- README.md | 2190 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2190 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c16671 --- /dev/null +++ b/README.md @@ -0,0 +1,2190 @@ +diff --git a/!!!!GentleGC_Memory/!!!!GentleGC_Memory.toc b/!!!!GentleGC_Memory/!!!!GentleGC_Memory.toc +new file mode 100644 +index 0000000..8e8b17c +--- /dev/null ++++ b/!!!!GentleGC_Memory/!!!!GentleGC_Memory.toc +@@ -0,0 +1,8 @@ ++## Interface: 11200 ++## Title: Gentle GC - Memory ++## Notes: Measures how much memory each addon keeps, at the loading screen. Results: /ggc addons (needs Gentle GC) ++## Author: GentleGC contributors ++## Version: 2.5 ++## SavedVariables: GentleGCMemoryDB ++ ++GentleGC_Memory.lua +diff --git a/!!!!GentleGC_Memory/GentleGC_Memory.lua b/!!!!GentleGC_Memory/GentleGC_Memory.lua +new file mode 100644 +index 0000000..0535157 +--- /dev/null ++++ b/!!!!GentleGC_Memory/GentleGC_Memory.lua +@@ -0,0 +1,118 @@ ++--[[ ++ Gentle GC - Memory: how much memory each addon keeps. ++ ++ The 1.12 client has no per-addon memory numbers (GetAddOnMemoryUsage came ++ in 2.x); gcinfo() only gives the size of the whole Lua heap. So this addon ++ loads before the others (the "!!!!" sorts first) and, each time another ++ addon finishes loading, runs a full collection and reads gcinfo(). The rise ++ since the previous addon is what that addon kept: its code, its data and ++ its saved variables. Same approach as AddonMemory by Salahaja. ++ ++ It only measures at the loading screen, where the extra collections can't ++ be seen, and only when it's useful: ++ * the first login after it's installed ++ * when the set of enabled addons has changed since the last measurement ++ * the next /reload after /ggc addons measure ++ Any other time it does nothing. Gentle GC reads the results (/ggc addons). ++--]] ++ ++local ME = "!!!!GentleGC_Memory" ++ ++local M = { measuring = false, justMeasured = false } ++GentleGCMemory = M ++ ++-- Our own reference, taken before Gentle GC wraps the global, so these ++-- measuring collections aren't reported as another addon's. ++local collect = collectgarbage ++ ++local run, last ++local frame = CreateFrame("Frame") ++ ++local function Live() ++ collect() ++ return (gcinfo()) ++end ++ ++-- The enabled addons, as one string: a change means a new measurement. ++local function Signature() ++ local names = {} ++ for i = 1, GetNumAddOns() do ++ local name, _, _, enabled = GetAddOnInfo(i) ++ if name and enabled then table.insert(names, name) end ++ end ++ table.sort(names) ++ return table.concat(names, ",") ++end ++ ++local function Start(db) ++ local sig = Signature() ++ local reason ++ if db.measureNext then ++ reason = "requested" ++ elseif not db.measured then ++ reason = "first login" ++ elseif db.signature ~= sig then ++ reason = "addons changed" ++ end ++ db.measureNext = nil ++ if not reason then return end ++ ++ M.measuring = true ++ run = { ++ when = date and date("%Y-%m-%d %H:%M") or "?", ++ reason = reason, ++ base = Live(), ++ before = {}, ++ rows = {}, ++ stages = {}, ++ } ++ last = run.base ++ -- anything that loaded before us is inside the base number; name it ++ for i = 1, GetNumAddOns() do ++ local name = GetAddOnInfo(i) ++ if name and name ~= ME and IsAddOnLoaded(name) then table.insert(run.before, name) end ++ end ++ db.signature = sig ++end ++ ++local function Record(list, label) ++ local kb = Live() ++ table.insert(list, { label, kb - last }) ++ last = kb ++end ++ ++local function Finish() ++ local total = 0 ++ for i = 1, table.getn(run.rows) do total = total + run.rows[i][2] end ++ run.total = total ++ run.after = last ++ GentleGCMemoryDB.measured = run ++ M.measuring = false ++ M.justMeasured = true ++ run = nil ++end ++ ++frame:RegisterEvent("ADDON_LOADED") ++frame:RegisterEvent("PLAYER_LOGIN") ++frame:RegisterEvent("PLAYER_ENTERING_WORLD") ++frame:SetScript("OnEvent", function() ++ if event == "ADDON_LOADED" then ++ if arg1 == ME then ++ if type(GentleGCMemoryDB) ~= "table" then GentleGCMemoryDB = {} end ++ Start(GentleGCMemoryDB) ++ elseif M.measuring then ++ -- this addon's files and saved variables; setup it does in its own ++ -- ADDON_LOADED handler runs after ours and lands on the next addon ++ Record(run.rows, arg1) ++ end ++ elseif event == "PLAYER_LOGIN" then ++ if M.measuring then Record(run.stages, "after the last addon loaded (setup, saved variables)") end ++ elseif event == "PLAYER_ENTERING_WORLD" then ++ if M.measuring then ++ Record(run.stages, "addons setting up at login") ++ Finish() ++ end ++ -- load-on-demand addons come later, in play: never measured ++ frame:UnregisterAllEvents() ++ end ++end) +diff --git a/GentleGC.lua b/GentleGC.lua +new file mode 100644 +index 0000000..b098928 +--- /dev/null ++++ b/GentleGC.lua +@@ -0,0 +1,1015 @@ ++--[[ ++ GentleGC - unified edition (for WoW 1.18.1 clients, Lua 5.0) ++ ++ Lua 5.0 has no incremental collector: every collection is a full ++ stop-the-world mark + sweep, and the automatic collector fires whenever the ++ heap reaches its threshold. Stock Lua 5.0 sets that threshold to 2x live ++ memory after every collection, but the 1.12 client replaces that rule ++ (luaM_adjustGC): threshold = live + max(min(carry-over headroom, allocator ++ pool capacity), live/4), so at least 1.25x and variable. GentleGC measures ++ the ratio from full collections and plans with the lowest one seen. ++ We can't make a single collection cheaper, but we can choose WHEN it happens: ++ ++ * collect at moments the player won't feel (loading screens, AFK, NPC ++ windows, right after combat) -- from jrc13245's fork ++ * run from event handlers, keeping the OnUpdate path only as a fallback ++ -- from jrc13245's fork ++ * keep reporting on its own timer, separate from collecting ++ -- from TheRealFayz's fork ++ and new in this version: ++ * skip collections that would free little (a pause costs about the same ++ whether it frees 1 MB or 100 MB, because marking live data dominates) ++ * collect pre-emptively out of combat when the heap is close to the ++ threshold, so the automatic collector doesn't fire on the next pull ++ * raise the threshold for the duration of combat so the automatic ++ collector is pushed to after the fight, then restore it ++ * saved settings and a /ggc slash command ++ optional, used only when the ClassicAPI client mod is installed: ++ * collect when you've been standing still for a few seconds (GetUnitSpeed) ++ * run the once-a-second check from C_Timer instead of a frame OnUpdate ++ when collections are slow (default: averaging 100 ms or more): ++ * careful mode: no stand-still or idle collections, only loading screens, ++ death, taxi, NPC windows, AFK, after combat, and pressure ++ diagnostics: ++ * per-addon memory, measured at the loading screen by the optional ++ !!!!GentleGC_Memory companion (it has to load before other addons) ++ * collections run by other addons are timed and attributed by name ++ * garbage creation rate (MB/s), with a warning when it is very high ++ * /ggc hitch: report frames that take too long, and whether a garbage ++ collection (ours, the game's, or another addon's) happened in them ++]] ++ ++local ADDON = "GentleGC" ++ ++-- The collectgarbage GentleGC itself uses. The global is wrapped further down ++-- so collections run by other addons can be attributed; our own calls go ++-- straight to the function that was there when we loaded. ++local rawCollect = collectgarbage ++ ++local DEFAULTS = { ++ debug = 0, -- 0 off, 1 periodic summary, 2 also every collection ++ minInterval = 30, -- seconds: never collect more often than this ++ maxInterval = 300, -- seconds: out-of-combat fallback collection ++ minGarbageMB = 4, -- skip a collection that would free less than this... ++ minGarbagePct = 15, -- ...or less than this % of live memory (the larger wins) ++ pressureRoomPct = 70, -- out of combat: collect early once garbage fills this % of the room ++ -- between live memory and the auto-GC threshold (70 = 85% of a 2x threshold) ++ combatHeadroomMB = 32, -- extra room before auto-GC during combat (0 = off) ++ reportInterval = 300, -- seconds between debug summaries ++ stillSeconds = 3, -- ClassicAPI only: collect after standing still this long (0 = off) ++ hitchMs = 0, -- freeze detector: report frames longer than this (0 = off) ++ slowMs = 100, -- careful mode when collections average this long (0 = never) ++ allocWarnMB = 10, -- warn when garbage is created faster than this, MB/s (0 = off) ++} ++ ++local db = {} ++ ++local function ApplyDefaults() ++ if type(GentleGCDB) ~= "table" then GentleGCDB = {} end ++ db = GentleGCDB ++ for k, v in pairs(DEFAULTS) do ++ if db[k] == nil then db[k] = v end ++ end ++ db.pressurePct = nil -- replaced by pressureRoomPct in 2.3 ++end ++ApplyDefaults() -- usable immediately; re-run once saved settings load ++ ++local frame = CreateFrame("Frame") ++local inCombat = false ++local lastGC = 0 -- GetTime() of our last collection ++local liveKB = 0 -- heap size right after the last collection (live-data estimate) ++local savedThreshold = nil -- threshold before we raised it for combat ++local knownThreshold = 0 -- last threshold we saw or set (detects automatic GCs) ++local ratioLast, ratioMin = nil, nil -- threshold / live after full collections (last, lowest) ++local lastUsed = 0 -- heap at the last sample (per frame) ++local allocTotalKB, allocPeriodKB, allocWindowKB = 0, 0, 0 ++local allocRate = 0 -- KB/s, ~30 s moving average ++local nextAllocWarn = 0 ++local pauseAvg = nil -- ms, moving average of our own collections ++local careful = false ++local extByName = {} -- session: collections run by other addons, by addon name ++local extSeq, extMs, extName = 0, 0, nil -- last external collection (freeze detector) ++do ++ local used, threshold = gcinfo() ++ -- before any collection we can only assume the stock 2x ratio; the first ++ -- collection (usually the login loading screen) measures the real one ++ liveKB = math.min(used, math.floor(threshold / 2)) ++ knownThreshold = threshold ++ lastUsed = used ++end ++local tick, reportElapsed = 0, 0 ++local hasSpeed, hasTicker = false, false -- ClassicAPI features, detected at load ++local stillFor, lastCheck = 0, 0 ++local loadTime = 0 -- the idle fallback counts from login ++-- for the freeze detector: counters, because GetTime() only ticks every ~15ms ++local ownCollectSeq, ownCollectMs, ownCollectReason = 0, 0, nil ++local ownThresholdSeq = 0 -- bumped when we change the threshold without collecting ++local EnterCombatThreshold -- defined below, used by Sync() ++local StartScheduler -- defined below, called on ADDON_LOADED ++local HitchLoading, HitchEnable -- freeze detector, defined below ++local ShowAddonSummary -- per-addon memory, defined below ++ ++local function NewStats() return { count = 0, freed = 0, skipped = 0, ms = 0, worst = 0, auto = 0, autoCombat = 0, ext = 0, extCombat = 0 } end ++local period, total = NewStats(), NewStats() ++ ++--------------------------------------------------------------------------- ++-- helpers ++--------------------------------------------------------------------------- ++ ++local function Msg(text) ++ DEFAULT_CHAT_FRAME:AddMessage("|cff88ff88GentleGC|r " .. text) ++end ++ ++local function DebugLevel() ++ -- old builds used the global `ggc_debug`; still honour it ++ if ggc_debug then return 2 end ++ return db.debug or 0 ++end ++ ++local function MB(kb) return kb / 1024 end ++ ++local function ResetStats(s) ++ s.count, s.freed, s.skipped, s.ms, s.worst, s.auto, s.autoCombat, s.ext, s.extCombat = 0, 0, 0, 0, 0, 0, 0, 0, 0 ++end ++ ++-- Wall-clock timing. debugprofilestop() has ms precision but resets a timer ++-- other addons may use, so it is only touched while debugging or detecting freezes. ++local function StartTimer() ++ if (DebugLevel() > 0 or db.hitchMs > 0) and debugprofilestart then ++ debugprofilestart() ++ return nil ++ end ++ return GetTime() ++end ++ ++local function StopTimer(t0) ++ if t0 == nil then return debugprofilestop() end ++ return (GetTime() - t0) * 1000 ++end ++ ++-- How much would a collection free? Estimate: everything allocated since ++-- the last collection beyond the live set we measured then. ++local function GarbageKB() ++ return gcinfo() - liveKB ++end ++ ++-- Allocation accounting: heap growth between samples is new allocation. ++local function AddAlloc(kb) ++ if kb <= 0 then return end ++ allocTotalKB = allocTotalKB + kb ++ allocPeriodKB = allocPeriodKB + kb ++ allocWindowKB = allocWindowKB + kb ++end ++ ++local function Account(used) ++ AddAlloc(used - lastUsed) ++ lastUsed = used ++end ++ ++-- Record a full collection's threshold ratio. The client's rule makes it ++-- vary (at least 1.25x), so planning uses the lowest ratio seen. ++local function LearnRatio(after, threshold) ++ if after <= 1024 then return end ++ local f = threshold / after ++ if f <= 1.05 or f >= 10 then return end ++ local first = ratioMin == nil ++ ratioLast = f ++ if not ratioMin or f < ratioMin then ratioMin = f end ++ if first and DebugLevel() >= 1 then ++ Msg(format("collector threshold measured: %.2fx live memory", f)) ++ end ++end ++ ++local function CountExternal(name, ms, freed) ++ for _, s in ipairs({ period, total }) do ++ s.ext = s.ext + 1 ++ if inCombat then s.extCombat = s.extCombat + 1 end ++ end ++ name = name or "unknown addon" ++ extByName[name] = (extByName[name] or 0) + 1 ++ extSeq, extMs, extName = extSeq + 1, ms, name ++ if DebugLevel() >= 2 then ++ Msg(format("collection by %s%s%s", name, ++ freed and format(": freed %.1fmb", MB(freed)) or "", ++ ms and format(" in %dms", ms) or "")) ++ end ++end ++ ++-- After someone else's collection the collector has set a fresh threshold, ++-- and any raised combat threshold is gone: in combat, grant the headroom again. ++local function AfterForeignCollection() ++ savedThreshold = nil ++ if inCombat then EnterCombatThreshold() end ++end ++ ++-- Detect collections we didn't run and thresholds we didn't set. A heap ++-- drop means a collection; a changed threshold without a drop means someone ++-- set it with collectgarbage(n). For a collection: the game's ++-- collector only fires when the heap reaches the threshold, so if the heap ++-- was well below it one frame earlier, another addon ran it. ++local function Sync() ++ local used, threshold = gcinfo() ++ -- the heap only shrinks through a collection, which may leave the ++ -- threshold unchanged if live memory happens to be the same as before ++ if threshold == knownThreshold and used >= lastUsed - 1024 then Account(used); return end ++ local oldThreshold = knownThreshold ++ knownThreshold = threshold ++ if oldThreshold == 0 then Account(used); return end ++ ++ if used < lastUsed - 1024 and GentleGCMemory and GentleGCMemory.measuring then ++ -- the memory companion collecting between addons at the loading screen ++ liveKB = used ++ lastUsed = used ++ return ++ end ++ ++ if used < lastUsed - 1024 then ++ local est = used ++ if ratioMin then est = math.min(used, math.floor(threshold / ratioMin)) end ++ liveKB = est ++ if lastUsed < oldThreshold * 0.9 then ++ AddAlloc(used - est) ++ CountExternal(nil, nil, lastUsed - est) ++ else ++ AddAlloc(oldThreshold - lastUsed) ++ AddAlloc(used - est) ++ for _, s in ipairs({ period, total }) do ++ s.auto = s.auto + 1 ++ if inCombat then s.autoCombat = s.autoCombat + 1 end ++ end ++ if DebugLevel() >= 2 then ++ Msg(format("automatic GC detected%s (live ~%.1fmb)", inCombat and " in combat" or "", MB(liveKB))) ++ end ++ end ++ lastUsed = used ++ AfterForeignCollection() ++ else ++ -- threshold set by someone without collecting; live memory is unchanged ++ Account(used) ++ if inCombat then EnterCombatThreshold() end ++ end ++end ++ ++-- The garbage share at which the automatic collector fires is (ratio - 1): ++-- 25% of live on this client, 100% on stock Lua. Never demand more than half. ++local function EffectiveGarbagePct() ++ return math.min(db.minGarbagePct, ((ratioMin or 2) - 1) * 50) ++end ++ ++local function WorthCollecting() ++ Sync() ++ local need = math.max(db.minGarbageMB * 1024, liveKB * EffectiveGarbagePct() / 100) ++ return GarbageKB() >= need ++end ++ ++-- Heap size at which a pressure collection runs: a share of the room between ++-- live memory and the threshold, so it works for any ratio. ++local function PressurePointKB(threshold) ++ local room = threshold - liveKB ++ if room <= 0 then return threshold * 0.9 end ++ return liveKB + room * db.pressureRoomPct / 100 ++end ++ ++-- Careful mode: collections are slow enough to be seen during play. ++local function UpdateCareful(ms) ++ pauseAvg = pauseAvg and (pauseAvg * 0.7 + ms * 0.3) or ms ++ local was = careful ++ if db.slowMs <= 0 then ++ careful = false ++ elseif careful then ++ careful = pauseAvg >= db.slowMs * 0.75 ++ else ++ careful = pauseAvg >= db.slowMs ++ end ++ if careful ~= was and DebugLevel() >= 1 then ++ if careful then ++ Msg(format("collections average %dms: careful mode - no stand-still or idle collections", pauseAvg)) ++ else ++ Msg(format("collections average %dms: normal mode", pauseAvg)) ++ end ++ end ++end ++ ++--------------------------------------------------------------------------- ++-- collection ++--------------------------------------------------------------------------- ++ ++local function Collect(reason) ++ Sync() ++ local before, thresholdBefore = gcinfo() ++ local t0 = StartTimer() ++ rawCollect() -- full collection; the client sets a fresh threshold ++ local ms = StopTimer(t0) ++ local after, threshold = gcinfo() ++ ++ if threshold == thresholdBefore and after >= before - 256 then ++ -- nothing happened: an addon that wraps collectgarbage blocked the call ++ lastGC = GetTime() ++ if DebugLevel() >= 1 then ++ Msg(reason .. ": collection did not run (another addon blocks collectgarbage)") ++ end ++ return ++ end ++ ++ liveKB = after ++ lastUsed = after ++ LearnRatio(after, threshold) ++ UpdateCareful(ms) ++ knownThreshold = threshold ++ lastGC = GetTime() ++ savedThreshold = nil -- collectgarbage() already set a fresh threshold ++ ownCollectSeq, ownCollectMs, ownCollectReason = ownCollectSeq + 1, ms, reason ++ ++ local freed = before - after ++ for _, s in ipairs({ period, total }) do ++ s.count = s.count + 1 ++ s.freed = s.freed + freed ++ s.ms = s.ms + ms ++ if ms > s.worst then s.worst = ms end ++ end ++ ++ if DebugLevel() >= 2 then ++ Msg(format("%s: %.1fmb -> %.1fmb (freed %.1fmb, next auto-GC at %.1fmb) in %dms", ++ reason, MB(before), MB(after), MB(freed), MB(threshold), ms)) ++ end ++end ++ ++-- reason: label for debug output ++-- ignoreInterval: true for moments where a pause is free (loading screen) ++-- or urgent (heap about to trigger the automatic collector) ++-- silent: don't count a skip (periodic checks retry every second) ++local function TryCollect(reason, ignoreInterval, silent) ++ if inCombat then return end ++ if not ignoreInterval and GetTime() - lastGC < db.minInterval then return end ++ if not WorthCollecting() then ++ if silent then return end ++ period.skipped = period.skipped + 1 ++ total.skipped = total.skipped + 1 ++ return ++ end ++ Collect(reason) ++end ++ ++--------------------------------------------------------------------------- ++-- combat: push the automatic collector past the end of the fight ++--------------------------------------------------------------------------- ++ ++EnterCombatThreshold = function() ++ if db.combatHeadroomMB <= 0 then return end ++ local used, threshold = gcinfo() ++ local want = used + db.combatHeadroomMB * 1024 ++ if want > threshold then ++ savedThreshold = savedThreshold or threshold ++ rawCollect(want) -- raising the threshold never triggers a collection ++ local _, t = gcinfo() ++ knownThreshold = t ++ ownThresholdSeq = ownThresholdSeq + 1 ++ end ++end ++ ++local function EnterCombat() ++ Sync() ++ inCombat = true ++ EnterCombatThreshold() ++end ++ ++local function LeaveCombat() ++ Sync() ++ inCombat = false ++ local used = gcinfo() ++ if savedThreshold and used >= savedThreshold then ++ -- we deferred a collection that was due during the fight: do it now ++ Collect("post-combat (deferred)") ++ return ++ end ++ TryCollect("post-combat") ++ if savedThreshold then ++ rawCollect(savedThreshold) ++ savedThreshold = nil ++ local _, t = gcinfo() ++ knownThreshold = t ++ ownThresholdSeq = ownThresholdSeq + 1 ++ end ++end ++ ++--------------------------------------------------------------------------- ++-- other addons' collections ++--------------------------------------------------------------------------- ++ ++local gfind = string.gfind or string.gmatch ++ ++-- Name the addon that called us, from the call stack (Interface\AddOns\Name\...). ++local function CallerAddon() ++ if not debugstack then return nil end ++ local ok, stack = pcall(debugstack) ++ if not ok or type(stack) ~= "string" then return nil end ++ for name in gfind(stack, "[Aa][Dd][Dd][Oo][Nn][Ss][\\/]([^\\/]+)[\\/]") do ++ if name ~= ADDON then return name end ++ end ++ return nil ++end ++ ++-- Wraps the global so a collection another addon runs is timed, attributed, ++-- and doesn't throw off our live-memory estimate. Queries ("count" and other ++-- string options) pass straight through without allocating. ++local function WrappedCollect(a, b) ++ if type(a) == "string" and a ~= "collect" then return rawCollect(a, b) end ++ Sync() ++ local before = gcinfo() ++ local t0 = GetTime() ++ local r = rawCollect(a, b) ++ local ms = (GetTime() - t0) * 1000 ++ local after, threshold = gcinfo() ++ local full = a == nil or a == 0 or a == "collect" ++ if after < before - 256 or (full and threshold ~= knownThreshold) then ++ -- a full collection (or a threshold set below the heap, which collects) ++ liveKB = after ++ lastUsed = after ++ knownThreshold = threshold ++ LearnRatio(after, threshold) ++ CountExternal(CallerAddon(), ms, before - after) ++ AfterForeignCollection() ++ elseif threshold ~= knownThreshold then ++ knownThreshold = threshold ++ Account(after) ++ if inCombat then EnterCombatThreshold() end ++ end ++ return r ++end ++ ++if type(rawCollect) == "function" then ++ collectgarbage = WrappedCollect ++end ++ ++-- Per-frame heap sample: one gcinfo() call, no allocation. It keeps lastUsed ++-- one frame old, which is what tells the game's collector apart from a ++-- collection run by an addon we couldn't wrap. ++local sampleFrame -- created on ADDON_LOADED ++local function SampleOnUpdate() ++ local used, threshold = gcinfo() ++ if threshold ~= knownThreshold or used < lastUsed - 1024 then ++ Sync() ++ else ++ AddAlloc(used - lastUsed) ++ lastUsed = used ++ end ++end ++ ++--------------------------------------------------------------------------- ++-- events ++--------------------------------------------------------------------------- ++ ++-- Moments when a short stall goes unnoticed. Window-*close* events and ++-- frequent sub-zone events are left out: closing means you're about to move, ++-- and ZONE_CHANGED/INDOORS fire constantly (and in combat). ++local SAFE_EVENTS = { ++ PLAYER_DEAD = "dead", ++ PLAYER_ALIVE = "released", ++ PLAYER_UNGHOST = "resurrected", ++ PLAYER_CAMPING = "logout", ++ PLAYER_CONTROL_LOST = "flight/control lost", ++ ZONE_CHANGED_NEW_AREA = "new zone", ++ MERCHANT_SHOW = "merchant", ++ BANKFRAME_OPENED = "bank", ++ MAIL_SHOW = "mailbox", ++ AUCTION_HOUSE_SHOW = "auction house", ++ TRAINER_SHOW = "trainer", ++ TAXIMAP_OPENED = "flight master", ++} ++ ++frame:RegisterEvent("ADDON_LOADED") ++frame:RegisterEvent("PLAYER_LEAVING_WORLD") -- loading screen is about to show ++frame:RegisterEvent("PLAYER_ENTERING_WORLD") ++frame:RegisterEvent("PLAYER_REGEN_DISABLED") ++frame:RegisterEvent("PLAYER_REGEN_ENABLED") ++frame:RegisterEvent("PLAYER_FLAGS_CHANGED") -- AFK ++for e in pairs(SAFE_EVENTS) do frame:RegisterEvent(e) end ++ ++frame:SetScript("OnEvent", function() ++ if event == "ADDON_LOADED" then ++ if arg1 ~= ADDON then return end ++ ApplyDefaults() ++ StartScheduler() ++ HitchEnable() ++ frame:UnregisterEvent("ADDON_LOADED") ++ elseif event == "PLAYER_REGEN_DISABLED" then ++ EnterCombat() ++ elseif event == "PLAYER_REGEN_ENABLED" then ++ LeaveCombat() ++ elseif event == "PLAYER_LEAVING_WORLD" or event == "PLAYER_ENTERING_WORLD" then ++ HitchLoading(event == "PLAYER_LEAVING_WORLD") ++ if event == "PLAYER_ENTERING_WORLD" and GentleGCMemory and GentleGCMemory.justMeasured then ++ GentleGCMemory.justMeasured = false ++ ShowAddonSummary() ++ end ++ inCombat = UnitAffectingCombat("player") and true or false ++ TryCollect("loading screen", true) ++ elseif event == "PLAYER_FLAGS_CHANGED" then ++ if UnitIsAFK("player") then TryCollect("afk") end ++ elseif SAFE_EVENTS[event] then ++ TryCollect(SAFE_EVENTS[event]) ++ end ++end) ++ ++-- Once-a-second check: auto-GC detection, pressure, stand-still, fallback, report. ++local function Check(dt) ++ reportElapsed = reportElapsed + dt ++ Sync() ++ ++ -- garbage creation rate, ~30 s moving average ++ if dt > 0 then ++ local inst = allocWindowKB / dt ++ allocWindowKB = 0 ++ allocRate = allocRate + (inst - allocRate) * math.min(1, dt / 30) ++ end ++ ++ if inCombat then ++ stillFor = 0 ++ else ++ if hasSpeed then ++ local speed = GetUnitSpeed("player") ++ local falling = IsFalling and IsFalling() ++ if speed == 0 and not falling then stillFor = stillFor + dt else stillFor = 0 end ++ end ++ ++ local used, threshold = gcinfo() ++ if used >= PressurePointKB(threshold) and GetTime() - lastGC >= 5 then ++ -- The automatic collector is about to fire anyway and would cost the ++ -- same pause, so collect now, out of combat, without the garbage minimum. ++ Collect("pressure") ++ elseif careful then ++ -- collections are slow enough to see: wait for a loading screen, NPC ++ -- window, taxi, death, AFK or the end of combat (or pressure) ++ elseif hasSpeed and db.stillSeconds > 0 and stillFor >= db.stillSeconds then ++ TryCollect("standing still", false, true) ++ elseif GetTime() - math.max(lastGC, loadTime) >= db.maxInterval then ++ TryCollect("idle", false, true) ++ end ++ ++ local now = GetTime() ++ if db.allocWarnMB > 0 and allocRate > db.allocWarnMB * 1024 and now >= nextAllocWarn ++ and now - loadTime > 60 then ++ nextAllocWarn = now + 600 ++ local room = threshold - liveKB ++ Msg(format("garbage is being created at %.1f MB/s (last 30 s): at this rate the heap fills up every ~%ds. " ++ .. "/ggc hitch shows which events the memory jumps follow.", ++ MB(allocRate), room > 0 and room / allocRate or 0)) ++ end ++ end ++ ++ if reportElapsed >= db.reportInterval then ++ if DebugLevel() >= 1 and (period.count > 0 or period.skipped > 0 or period.auto > 0 or period.ext > 0) then ++ Msg(format("last %d min: %d collections (%d skipped), freed %.1fmb, avg %dms, worst %dms; %d automatic (%d in combat), %d by other addons; garbage %.1f MB/s; now %.1fmb", ++ reportElapsed / 60, period.count, period.skipped, MB(period.freed), ++ period.count > 0 and period.ms / period.count or 0, period.worst, ++ period.auto, period.autoCombat, period.ext, MB(allocPeriodKB / reportElapsed), MB(gcinfo()))) ++ end ++ allocPeriodKB = 0 ++ ResetStats(period) ++ reportElapsed = 0 ++ end ++end ++ ++local function OnUpdate() ++ tick = tick + arg1 ++ if tick < 1 then return end ++ local dt = tick ++ tick = 0 ++ Check(dt) ++end ++ ++-- C_Timer callbacks run once per frame from the world tick, outside the ++-- frame OnUpdate walk. Without ClassicAPI, fall back to a frame OnUpdate. ++StartScheduler = function() ++ sampleFrame = sampleFrame or CreateFrame("Frame") ++ sampleFrame:SetScript("OnUpdate", SampleOnUpdate) ++ hasSpeed = type(GetUnitSpeed) == "function" ++ hasTicker = type(C_Timer) == "table" and type(C_Timer.NewTicker) == "function" ++ lastCheck = GetTime() ++ loadTime = lastCheck ++ if hasTicker then ++ frame:SetScript("OnUpdate", nil) ++ C_Timer.NewTicker(1, function() ++ local now = GetTime() ++ local dt = now - lastCheck ++ lastCheck = now ++ Check(dt) ++ end) ++ else ++ frame:SetScript("OnUpdate", OnUpdate) ++ end ++end ++ ++--------------------------------------------------------------------------- ++-- freeze detector (/ggc hitch) ++-- ++-- Runs on its own frame's OnUpdate, only while enabled. Each frame it reads ++-- the clock and gcinfo() (no allocation). When the gap since the previous ++-- frame exceeds the limit, it reports what happened inside that gap: ++-- * a GentleGC collection (with its measured duration) ++-- * a collection we didn't run: the game's automatic GC, or another ++-- addon calling collectgarbage() (detected by the heap shrinking or ++-- the threshold changing) ++-- * no collection at all: the freeze came from something else ++-- plus how much Lua memory was allocated during it and which frame was ++-- under the mouse (the window being dragged, usually). ++-- ++-- It also records every game event (RegisterAllEvents) into a fixed ring ++-- buffer: name, first argument, time and Lua memory, assigning numbers and ++-- existing values only, so nothing is allocated per event. A report lists ++-- the events that fired during the freeze and where the time went: the ++-- longest gap between them, and the event the biggest memory jump followed. ++--------------------------------------------------------------------------- ++ ++local hitchFrame = CreateFrame("Frame") ++local hPrevTime, hPrevUsed, hPrevThreshold = 0, 0, 0 ++local hPrevCollectSeq, hPrevThresholdSeq, hPrevExtSeq = 0, 0, 0 ++local hLoading, hSkip = false, 0 ++local hitchLog = {} -- last 10 freezes, newest last ++local HITCH_LOG_SIZE = 10 ++ ++-- event ring buffer ++local EV_N = 64 ++local evFrame = CreateFrame("Frame") ++local evName, evArg, evTime, evMem = {}, {}, {}, {} ++local evPos, evSeq = 0, 0 -- last written slot, events recorded so far ++local hFrameStartSeq = 0 -- evSeq at the previous OnUpdate ++local hasAllEvents = evFrame.RegisterAllEvents ~= nil ++ ++local function EvOnEvent() ++ evSeq = evSeq + 1 ++ evPos = evPos + 1 ++ if evPos > EV_N then evPos = 1 end ++ evName[evPos] = event ++ evArg[evPos] = arg1 ++ evTime[evPos] = GetTime() ++ evMem[evPos] = gcinfo() ++end ++ ++local function ArgText(a) ++ local t = type(a) ++ if t == "string" then ++ if string.len(a) > 24 then a = string.sub(a, 1, 24) .. "..." end ++ return " (" .. a .. ")" ++ elseif t == "number" then ++ return " (" .. a .. ")" ++ end ++ return "" ++end ++ ++-- Describe the events recorded after event number startSeq. ++-- frameStart/frameEnd: GetTime() at the two OnUpdates; memStart/memEnd: gcinfo() then. ++local function EventReport(startSeq, frameStart, frameEnd, memStart, memEnd) ++ if not hasAllEvents then return "" end ++ local n = evSeq - startSeq ++ if n <= 0 then return "; no game events fired during it" end ++ local kept = n ++ if kept > EV_N then kept = EV_N end ++ ++ -- slots oldest -> newest ++ local first = evPos - kept + 1 ++ if first < 1 then first = first + EV_N end ++ ++ local counts, order = {}, {} ++ local bestGap, bestAfter, bestBefore = -1, nil, nil ++ local bestAlloc, bestAllocAfter = 0, nil ++ local prevT, prevM, prevLabel = frameStart, memStart, nil ++ if kept < n then prevT, prevM = nil, nil end -- start of the frame was lost ++ local i = first ++ for k = 1, kept do ++ local name = evName[i] ++ if not counts[name] then counts[name] = 0; table.insert(order, name) end ++ counts[name] = counts[name] + 1 ++ local label = name .. ArgText(evArg[i]) ++ if prevT then ++ local g = evTime[i] - prevT ++ if g > bestGap then bestGap, bestAfter, bestBefore = g, prevLabel, label end ++ local a = evMem[i] - prevM ++ if a > bestAlloc then bestAlloc, bestAllocAfter = a, prevLabel or "frame start" end ++ end ++ prevT, prevM, prevLabel = evTime[i], evMem[i], label ++ i = i + 1 ++ if i > EV_N then i = 1 end ++ end ++ local g = frameEnd - prevT ++ if g > bestGap then bestGap, bestAfter, bestBefore = g, prevLabel, nil end ++ local a = memEnd - prevM ++ if a > bestAlloc then bestAlloc, bestAllocAfter = a, prevLabel end ++ ++ table.sort(order, function(x, y) return counts[x] > counts[y] end) ++ local parts = {} ++ for k = 1, math.min(4, table.getn(order)) do ++ local nm = order[k] ++ table.insert(parts, counts[nm] > 1 and (nm .. " x" .. counts[nm]) or nm) ++ end ++ if table.getn(order) > 4 then table.insert(parts, "...") end ++ ++ local text = format("; %d event%s: %s", n, n == 1 and "" or "s", table.concat(parts, ", ")) ++ if kept < n then text = text .. format(" (last %d kept)", kept) end ++ ++ local where ++ if not bestAfter then ++ where = "before the first event" ++ elseif not bestBefore then ++ where = "after " .. bestAfter .. ", the last event" ++ else ++ where = "after " .. bestAfter ++ end ++ text = text .. format("; longest gap %.2fs %s", bestGap, where) ++ if bestAlloc > 1024 then ++ text = text .. format("; %.1fmb allocated after %s", MB(bestAlloc), bestAllocAfter) ++ end ++ return text ++end ++ ++local function FocusName() ++ if not GetMouseFocus then return nil end ++ local f = GetMouseFocus() ++ if not f or not f.GetName then return nil end ++ local name = f:GetName() ++ -- walk up to the first named ancestor for anonymous child frames ++ local depth = 0 ++ while not name and f.GetParent and depth < 10 do ++ f = f:GetParent() ++ if not f or not f.GetName then break end ++ name = f:GetName() ++ depth = depth + 1 ++ end ++ return name ++end ++ ++local function HitchReset() ++ hPrevTime = GetTime() ++ hPrevUsed, hPrevThreshold = gcinfo() ++ hPrevCollectSeq, hPrevThresholdSeq, hPrevExtSeq = ownCollectSeq, ownThresholdSeq, extSeq ++ hFrameStartSeq = evSeq ++end ++ ++local function HitchOnUpdate() ++ local now = GetTime() ++ local used, threshold = gcinfo() ++ local gap = now - hPrevTime ++ local prevTime, prevUsed, prevThreshold = hPrevTime, hPrevUsed, hPrevThreshold ++ local startSeq = hFrameStartSeq ++ local weCollected = ownCollectSeq ~= hPrevCollectSeq ++ local weMovedThreshold = ownThresholdSeq ~= hPrevThresholdSeq ++ local otherAddon = extSeq ~= hPrevExtSeq ++ hPrevTime, hPrevUsed, hPrevThreshold = now, used, threshold ++ hPrevCollectSeq, hPrevThresholdSeq, hPrevExtSeq = ownCollectSeq, ownThresholdSeq, extSeq ++ hFrameStartSeq = evSeq ++ ++ if hLoading then return end ++ if hSkip > 0 then hSkip = hSkip - 1; return end ++ if gap * 1000 < db.hitchMs then return end ++ ++ local cause ++ if weCollected then ++ cause = format("GentleGC collection (%s) took %dms", ownCollectReason or "?", ownCollectMs) ++ elseif otherAddon then ++ cause = format("garbage collection run by %s%s", extName or "another addon", ++ extMs and format(" took %dms", extMs) or "") ++ elseif used < prevUsed - 256 or (threshold ~= prevThreshold and not weMovedThreshold) then ++ cause = format("the game's automatic garbage collection: heap %.1fmb -> %.1fmb", ++ MB(prevUsed), MB(used)) ++ else ++ cause = "no garbage collection - something else caused it" ++ end ++ ++ local grew = used - prevUsed ++ local alloc = "" ++ if grew > 1024 then alloc = format("; %.1fmb of Lua memory allocated during it", MB(grew)) end ++ local focus = FocusName() ++ local where = focus and ("; mouse over " .. focus) or "" ++ local combat = inCombat and " (in combat)" or "" ++ ++ local events = EventReport(startSeq, prevTime, now, prevUsed, used) ++ ++ local line = format("freeze %.2fs%s: %s%s%s%s", gap, combat, cause, alloc, where, events) ++ Msg("|cffff8888" .. line .. "|r") ++ ++ table.insert(hitchLog, format("%s %s", date and date("%H:%M:%S") or format("%.0f", now), line)) ++ if table.getn(hitchLog) > HITCH_LOG_SIZE then table.remove(hitchLog, 1) end ++end ++ ++-- loading screens are long "frames" by design; ignore them ++HitchLoading = function(leaving) ++ hLoading = leaving ++ if not leaving then ++ hSkip = 3 ++ HitchReset() ++ end ++end ++ ++HitchEnable = function() ++ if db.hitchMs > 0 then ++ HitchReset() ++ hSkip = 1 ++ hitchFrame:SetScript("OnUpdate", HitchOnUpdate) ++ if hasAllEvents then ++ evFrame:SetScript("OnEvent", EvOnEvent) ++ evFrame:RegisterAllEvents() ++ end ++ else ++ hitchFrame:SetScript("OnUpdate", nil) ++ if hasAllEvents then ++ evFrame:UnregisterAllEvents() ++ evFrame:SetScript("OnEvent", nil) ++ end ++ end ++end ++ ++--------------------------------------------------------------------------- ++-- per-addon memory (measured by the !!!!GentleGC_Memory companion) ++--------------------------------------------------------------------------- ++ ++local COMPANION = "!!!!GentleGC_Memory" ++local ADDONS_TOP = 15 ++ ++local function Size(kb) ++ if kb < 0 then return "-" .. Size(-kb) end ++ if kb < 1024 then return format("%d KB", kb) end ++ return format("%.1f MB", kb / 1024) ++end ++ ++local function Measurement() ++ return type(GentleGCMemoryDB) == "table" and GentleGCMemoryDB.measured or nil ++end ++ ++local function SortedRows(m) ++ local rows = {} ++ for i = 1, table.getn(m.rows) do table.insert(rows, m.rows[i]) end ++ table.sort(rows, function(a, b) return a[2] > b[2] end) ++ return rows ++end ++ ++local function Biggest(m, n) ++ local rows, parts = SortedRows(m), {} ++ for i = 1, math.min(n, table.getn(rows)) do ++ table.insert(parts, rows[i][1] .. " " .. Size(rows[i][2])) ++ end ++ return table.concat(parts, ", ") ++end ++ ++ShowAddonSummary = function() ++ local m = Measurement() ++ if not m then return end ++ Msg(format("measured your addons at this loading screen: %s in %d addons. Biggest: %s. /ggc addons lists them all.", ++ Size(m.total or 0), table.getn(m.rows), Biggest(m, 3))) ++end ++ ++local function PrintAddons(all) ++ if not GentleGCMemory then ++ Msg("per-addon memory needs the " .. COMPANION .. " folder from the Gentle GC zip in Interface/AddOns. " ++ .. "It has to load before your other addons, which is what the !!!! is for.") ++ return ++ end ++ local m = Measurement() ++ if not m or table.getn(m.rows) == 0 then ++ Msg("no per-addon measurement yet: /ggc addons measure, then /reload.") ++ return ++ end ++ local rows = SortedRows(m) ++ Msg(format("addon memory at login on %s: your addons kept %s; the game's own interface %s.", ++ m.when or "?", Size(m.total or 0), Size(m.base or 0))) ++ local shown = table.getn(rows) ++ if not all and shown > ADDONS_TOP then shown = ADDONS_TOP end ++ for i = 1, shown do ++ DEFAULT_CHAT_FRAME:AddMessage(format(" %9s %s", Size(rows[i][2]), rows[i][1])) ++ end ++ if shown < table.getn(rows) then ++ DEFAULT_CHAT_FRAME:AddMessage(format(" ...and %d more (/ggc addons all)", table.getn(rows) - shown)) ++ end ++ for i = 1, table.getn(m.stages or {}) do ++ DEFAULT_CHAT_FRAME:AddMessage(format(" then %s: %s", m.stages[i][1], Size(m.stages[i][2]))) ++ end ++ if m.before and table.getn(m.before) > 0 then ++ DEFAULT_CHAT_FRAME:AddMessage(" loaded before the measurement, so counted in the interface number: " ++ .. table.concat(m.before, ", ")) ++ end ++ -- if the client held its load events back, one row gets everything ++ if rows[1] and (m.total or 0) > 10240 and rows[1][2] > m.total * 0.8 and table.getn(rows) > 3 then ++ Msg("one addon got almost all of it: this client may send its load events late, so the split may not be per addon.") ++ end ++ local grown = liveKB - (m.after or liveKB) ++ Msg(format("live memory now ~%s, %s%s since that measurement.", Size(liveKB), grown >= 0 and "+" or "", Size(grown))) ++end ++ ++local function MeasureNext() ++ if not GentleGCMemory or type(GentleGCMemoryDB) ~= "table" then ++ PrintAddons(false) -- explains the missing companion ++ return ++ end ++ GentleGCMemoryDB.measureNext = true ++ Msg("type /reload to measure every addon. The loading screen takes a little longer, once.") ++end ++ ++--------------------------------------------------------------------------- ++-- /ggc ++--------------------------------------------------------------------------- ++ ++local OPTIONS = { ++ interval = "minInterval", ++ maxinterval = "maxInterval", ++ garbage = "minGarbageMB", ++ garbagepct = "minGarbagePct", ++ pressure = "pressureRoomPct", ++ headroom = "combatHeadroomMB", ++ report = "reportInterval", ++ still = "stillSeconds", ++ slow = "slowMs", ++ allocwarn = "allocWarnMB", ++} ++ ++local function PrintStatus() ++ Sync() ++ local used, threshold = gcinfo() ++ Msg(format("heap %.1fmb, auto-GC at %.1fmb, live ~%.1fmb, garbage ~%.1fmb%s", ++ MB(used), MB(threshold), MB(liveKB), MB(GarbageKB()), inCombat and " (in combat)" or "")) ++ if ratioMin then ++ Msg(format("collector: threshold %.2fx live after the last full collection, lowest %.2fx; collecting from %.1f%% garbage, pressure at %.1fmb", ++ ratioLast, ratioMin, EffectiveGarbagePct(), MB(PressurePointKB(threshold)))) ++ else ++ Msg(format("collector: threshold ratio not measured yet (no full collection this session); collecting from %.1f%% garbage, pressure at %.1fmb", ++ EffectiveGarbagePct(), MB(PressurePointKB(threshold)))) ++ end ++ if pauseAvg then ++ Msg(format("collections take ~%dms: %s", pauseAvg, careful ++ and "careful mode - only loading screens, death, taxi, NPC windows, AFK, after combat and pressure" ++ or "normal mode")) ++ end ++ Msg(format("session: %d collections, %d skipped, freed %.1fmb, avg %dms, worst %dms; %d automatic (%d in combat)", ++ total.count, total.skipped, MB(total.freed), ++ total.count > 0 and total.ms / total.count or 0, total.worst, total.auto, total.autoCombat)) ++ if total.ext > 0 then ++ local parts = {} ++ for name, n in pairs(extByName) do table.insert(parts, name .. " " .. n) end ++ table.sort(parts) ++ Msg(format("by other addons: %d (%d in combat) - %s", total.ext, total.extCombat, table.concat(parts, ", "))) ++ end ++ local up = GetTime() - loadTime ++ if up >= 60 then ++ Msg(format("garbage created: %.1f MB/s now (30 s average), %.1f MB/s this session", ++ MB(allocRate), MB(allocTotalKB / up))) ++ else ++ Msg(format("garbage created: %.1f MB/s now (30 s average)", MB(allocRate))) ++ end ++ Msg(format("debug=%d interval=%ds maxinterval=%ds garbage=%dmb garbagepct=%d%% pressure=%d%% headroom=%dmb report=%ds still=%ds slow=%dms allocwarn=%dmb", ++ DebugLevel(), db.minInterval, db.maxInterval, db.minGarbageMB, db.minGarbagePct, ++ db.pressureRoomPct, db.combatHeadroomMB, db.reportInterval, db.stillSeconds, db.slowMs, db.allocWarnMB)) ++ local m = Measurement() ++ if m and table.getn(m.rows) > 0 then ++ Msg(format("biggest addons (measured %s): %s - /ggc addons", m.when or "?", Biggest(m, 3))) ++ end ++ Msg(db.hitchMs > 0 and ("freeze detector: on (" .. db.hitchMs .. "ms)") or "freeze detector: off (/ggc hitch)") ++ Msg("ClassicAPI: stand-still " .. (hasSpeed and "on" or "not available") .. ++ ", C_Timer " .. (hasTicker and "on" or "not available (using OnUpdate)")) ++end ++ ++SLASH_GENTLEGC1 = "/ggc" ++SLASH_GENTLEGC2 = "/gentlegc" ++SlashCmdList["GENTLEGC"] = function(msg) ++ local _, _, cmd, val = string.find(string.lower(msg or ""), "^%s*(%S*)%s*(.-)%s*$") ++ if cmd == "" or cmd == "status" then ++ PrintStatus() ++ elseif cmd == "now" then ++ if inCombat then Msg("in combat - not collecting") else Collect("manual") end ++ elseif cmd == "debug" then ++ local n = tonumber(val) ++ if n == nil then n = (db.debug > 0) and 0 or 1 end ++ db.debug = n ++ ggc_debug = nil ++ Msg("debug " .. n .. (n == 0 and " (off)" or n == 1 and " (summaries)" or " (every collection)")) ++ elseif cmd == "hitch" then ++ local n = tonumber(val) ++ if n == nil then n = (db.hitchMs > 0) and 0 or 250 end ++ db.hitchMs = n ++ HitchEnable() ++ if n > 0 then ++ Msg("freeze detector on: reporting frames longer than " .. n .. "ms (/ggc hitch 0 to stop)") ++ else ++ Msg("freeze detector off") ++ end ++ elseif cmd == "addons" then ++ if val == "measure" then ++ MeasureNext() ++ else ++ PrintAddons(val == "all") ++ end ++ elseif cmd == "hitches" then ++ if table.getn(hitchLog) == 0 then ++ Msg(db.hitchMs > 0 and "no freezes recorded this session" or "freeze detector is off - /ggc hitch to turn it on") ++ else ++ for _, l in ipairs(hitchLog) do Msg(l) end ++ end ++ elseif cmd == "reset" then ++ for k, v in pairs(DEFAULTS) do db[k] = v end ++ HitchEnable() ++ Msg("settings reset to defaults") ++ elseif OPTIONS[cmd] and tonumber(val) then ++ db[OPTIONS[cmd]] = tonumber(val) ++ Msg(cmd .. " = " .. val) ++ else ++ Msg("/ggc [status] | now | debug [0-2] | addons [all|measure] | hitch [ms] | hitches | reset") ++ Msg("/ggc interval|maxinterval|report|still garbage|headroom|allocwarn garbagepct|pressure <%> slow ") ++ end ++end +diff --git a/GentleGC.toc b/GentleGC.toc +new file mode 100644 +index 0000000..488b3fd +--- /dev/null ++++ b/GentleGC.toc +@@ -0,0 +1,8 @@ ++## Interface: 11200 ++## Title: Gentle GC ++## Notes: Schedules Lua garbage collection for moments you won't notice ++## Author: Weird Vibes (MarcelineVQ); forks by TheRealFayz, jrc13245 ++## Version: 2.5 ++## SavedVariables: GentleGCDB ++ ++GentleGC.lua +diff --git a/tests/test_classicapi.lua b/tests/test_classicapi.lua +new file mode 100644 +index 0000000..93ac00a +--- /dev/null ++++ b/tests/test_classicapi.lua +@@ -0,0 +1,71 @@ ++-- WoW + ClassicAPI stubs ++format = string.format ++local now = 1000 ++function GetTime() return now end ++local combat, speed, falling = false, 7, false ++function UnitAffectingCombat() return combat end ++function UnitIsAFK() return false end ++function GetUnitSpeed(u) return speed, 7, 0, 4.7 end ++function IsFalling() return falling end ++local log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++SlashCmdList = {} ++local tickerFn, tickerPeriod ++C_Timer = { NewTicker = function(p, f) tickerPeriod, tickerFn = p, f; return {} end } ++FRAMES = {} ++function CreateFrame() ++ local F = { scripts = {} } ++ function F:RegisterEvent() end ++ function F:UnregisterEvent() end ++ function F:SetScript(k, f) self.scripts[k] = f end ++ table.insert(FRAMES, F) ++ return F ++end ++local function fire(e, a1) event, arg1 = e, a1; F.scripts.OnEvent() end ++local function seconds(n) for i = 1, n do now = now + 1; tickerFn() end end ++ ++LIVE = {} ++for i = 1, 300000 do LIVE[i] = { i } end ++local function makeGarbage(mb) local t = {}; for i = 1, mb * 6000 do t[i] = { i, i } end end ++local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end ++local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end ++ ++collectgarbage() ++dofile(arg[1]) ++F = FRAMES[1] ++HF = FRAMES[2] ++fire("ADDON_LOADED", "GentleGC") ++SlashCmdList.GENTLEGC("debug 2") ++ ++check("C_Timer ticker used (1s)", tickerFn ~= nil and tickerPeriod == 1) ++check("no frame OnUpdate when C_Timer exists", F.scripts.OnUpdate == nil) ++ ++makeGarbage(12) ++speed = 7; seconds(10) ++check("no stand-still collection while moving", count("standing still:") == 0) ++ ++speed = 0; seconds(2) ++check("not yet after 2s still", count("standing still:") == 0) ++seconds(2) ++check("collects after 3s standing still", count("standing still:") == 1) ++ ++seconds(20) ++check("does not repeat while still with no new garbage", count("standing still:") == 1) ++SlashCmdList.GENTLEGC("status") ++check("idle retries don't inflate skip count", count("collections, 0 skipped") == 1) ++ ++makeGarbage(12); seconds(15) ++check("respects minInterval, then collects again", count("standing still:") == 2) ++ ++makeGarbage(12); falling = true; speed = 0; now = now + 60; seconds(5) ++check("falling is not standing still", count("standing still:") == 2) ++falling = false ++ ++combat = true; fire("PLAYER_REGEN_DISABLED"); seconds(10) ++check("never while in combat", count("standing still:") == 2) ++combat = false; fire("PLAYER_REGEN_ENABLED") ++ ++SlashCmdList.GENTLEGC("still 0"); makeGarbage(12); now = now + 60; seconds(10) ++check("still 0 disables it", count("standing still:") == 2 or count("standing still:") == 3 and false) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") +diff --git a/tests/test_combat_auto.lua b/tests/test_combat_auto.lua +new file mode 100644 +index 0000000..3b7ffca +--- /dev/null ++++ b/tests/test_combat_auto.lua +@@ -0,0 +1,15 @@ ++format = string.format; local now = 1000; function GetTime() return now end ++function UnitAffectingCombat() return true end function UnitIsAFK() return false end ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) print(" " .. m) end }; SlashCmdList = {} ++local F; function CreateFrame() local G = { e = {} }; F = F or G; local F = G; function F:RegisterEvent(x) end; function F:UnregisterEvent() end; function F:SetScript(k, f) self[k] = f end; return F end ++collectgarbage(); dofile(arg[1]); event, arg1 = "ADDON_LOADED", "GentleGC"; F.OnEvent() ++SlashCmdList.GENTLEGC("headroom 4"); SlashCmdList.GENTLEGC("debug 2") ++event = "PLAYER_REGEN_DISABLED"; F.OnEvent() ++local _, t1 = gcinfo() ++local keep = {} for i = 1, 60000 do keep[i] = {i} end -- live growth past raised threshold ++keep = nil; local g = {} for i = 1, 60000 do g[i] = {i} end g = nil ++arg1 = 1.1; F.OnUpdate() ++local used, t2 = gcinfo() ++print("re-raised headroom after in-combat auto GC:", t2 >= used + 4*1024 - 1 and "PASS" or "FAIL", t1, t2, used) ++event = "PLAYER_REGEN_ENABLED"; F.OnEvent() ++SlashCmdList.GENTLEGC("status") +diff --git a/tests/test_factor.lua b/tests/test_factor.lua +new file mode 100644 +index 0000000..13d9bb3 +--- /dev/null ++++ b/tests/test_factor.lua +@@ -0,0 +1,128 @@ ++-- Collector-ratio tests: simulates the 1.12 client's collector, which sets the ++-- threshold to 1.25x live (at least) instead of stock Lua 5.0's 2x. ++-- Run: lua5.0 tests/test_factor.lua GentleGC.lua ++format = string.format ++local now = 1000 ++function GetTime() return now end ++function UnitAffectingCombat() return false end ++function UnitIsAFK() return false end ++local log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++SlashCmdList = {} ++FRAMES = {} ++function CreateFrame() ++ local f = { scripts = {} } ++ function f:RegisterEvent() end ++ function f:UnregisterEvent() end ++ function f:SetScript(k, fn) self.scripts[k] = fn end ++ table.insert(FRAMES, f) ++ return f ++end ++ ++local FACTOR = 1.25 ++local origCG = collectgarbage ++local function setThreshold() local u = gcinfo(); origCG(math.floor(u * FACTOR)) end ++-- the modded collector: a full collection leaves the threshold at FACTOR x live ++collectgarbage = function(n) ++ if n == nil or n == 0 then origCG(); setThreshold() else origCG(n) end ++end ++-- the game's own automatic collection under the mod ++local function autoGC() origCG(); setThreshold() end ++ ++LIVE = {} ++for i = 1, 300000 do LIVE[i] = { i } end ++autoGC() ++ ++dofile(arg[1]) ++local F = FRAMES[1] ++local function fire(e, a1) event, arg1 = e, a1; this = F; F.scripts.OnEvent() end ++fire("ADDON_LOADED", "GentleGC") ++local cmd = SlashCmdList.GENTLEGC ++cmd("debug 2") ++SF = FRAMES[4] ++ ++local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end ++local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end ++local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end ++local function tick(sec) local n = sec * 10; for i = 1, n do now = now + 0.1; arg1 = 0.1; this = F; F.scripts.OnUpdate() end end ++local function liveMB() local _, _, v = string.find(lastMatch("heap .* live ~"), "live ~([%d%.]+)mb"); return tonumber(v) end ++local function realLiveMB() local u = gcinfo(); return u / 1024 end ++-- heap creeps toward the threshold over frames (the sampler sees each one), ++-- then the game's collector fires at the threshold ++local SF = FRAMES[4] ++local keepAlive = {} ++local function growToThresholdThenAutoGC() ++ local _, t = gcinfo() ++ local junk = {} ++ local i = 0 ++ while gcinfo() < t * 0.98 do ++ for k = 1, 300 do i = i + 1; junk[i] = { i } end ++ if SF then SF.scripts.OnUpdate() end ++ end ++ junk = nil ++ autoGC() ++end ++-- garbage that stays below the 1.25x threshold so Lua never collects on its own ++local function garbage(kb) ++ local target = gcinfo() + kb ++ local t = {} ++ local i = 0 ++ while gcinfo() < target do i = i + 1; t[i] = { i, i } end ++end ++ ++print("1. automatic GC before GentleGC has measured the ratio") ++now = now + 60 ++growToThresholdThenAutoGC() ++tick(1.1) ++check("automatic GC detected", count("automatic GC detected") == 1) ++cmd("status") ++local _, thr = gcinfo() ++check("live estimate is not threshold/2", liveMB() > (thr / 2) / 1024 + 1) ++check("status says the ratio isn't measured yet", count("not measured yet") == 1) ++ ++print("2. no useless collection right after it (the bug)") ++now = now + 60 ++fire("MERCHANT_SHOW") ++check("skipped: almost no garbage", count("merchant:") == 0) ++ ++print("3. own collection measures the ratio") ++cmd("now") ++cmd("status") ++check("ratio measured as 1.25x", string.find(lastMatch("collector:"), "threshold 1%.25x live after the last full collection, lowest 1%.25x") ~= nil) ++check("garbage minimum capped at half the 25% room", string.find(lastMatch("collector:"), "collecting from 12%.5%% garbage") ~= nil) ++ ++print("4. automatic GC after measuring: live estimate is right") ++growToThresholdThenAutoGC() ++tick(1.1) ++cmd("status") ++local real = realLiveMB() ++check("live estimate within 1mb of real live memory", math.abs(liveMB() - real) < 1) ++now = now + 60 ++fire("BANKFRAME_OPENED") ++check("still no useless collection", count("bank:") == 0) ++ ++print("5. enough garbage: normal collections still happen") ++local live = gcinfo() ++garbage(math.floor(live * 0.14)) -- 14% of live: above the 12.5% minimum ++now = now + 60 ++fire("MAIL_SHOW") ++check("mailbox collection ran", count("mailbox:") == 1) ++check("it freed real garbage", string.find(lastMatch("mailbox:"), "freed [1-9]") ~= nil) ++ ++print("6. pressure fires before the 1.25x threshold") ++now = now + 60 ++local l2, t2 = gcinfo() ++-- pressure point = live + 70% of the room = 1.175x live; stop short of 1.25x ++garbage(math.floor(l2 * 0.19)) ++local used = gcinfo() ++check("heap is between pressure point and threshold", used > l2 * 1.175 and used < t2) ++tick(1.1) ++check("pressure collection ran", count("pressure:") == 1) ++check("pressure collection freed garbage", string.find(lastMatch("pressure:"), "freed [1-9]") ~= nil) ++check("no automatic GC needed", count("automatic GC detected") == 2) ++ ++print("7. pressure can't loop") ++tick(10) ++check("one pressure collection only", count("pressure:") == 1) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") +diff --git a/tests/test_hitch.lua b/tests/test_hitch.lua +new file mode 100644 +index 0000000..ab69aee +--- /dev/null ++++ b/tests/test_hitch.lua +@@ -0,0 +1,232 @@ ++-- Freeze detector tests. Run: lua5.0 tests/test_hitch.lua GentleGC.lua ++format = string.format ++local now = 1000 ++function GetTime() return now end ++local combat = false ++function UnitAffectingCombat() return combat end ++function UnitIsAFK() return false end ++local log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++SlashCmdList = {} ++ ++-- a dragged window: an anonymous child of a named frame ++local auxFrame = { GetName = function() return "AuxFrame" end } ++local auxButton = { GetName = function() return nil end, GetParent = function() return auxFrame end } ++local focus = nil ++function GetMouseFocus() return focus end ++ ++FRAMES = {} ++function CreateFrame() ++ local f = { scripts = {} } ++ function f:RegisterEvent() end ++ function f:UnregisterEvent() end ++ function f:SetScript(k, fn) self.scripts[k] = fn end ++ table.insert(FRAMES, f) ++ return f ++end ++ ++LIVE = {} ++for i = 1, 300000 do LIVE[i] = { i } end ++collectgarbage() ++dofile(arg[1]) ++local F, HF = FRAMES[1], FRAMES[2] ++local function fire(e, a1) event, arg1 = e, a1; F.scripts.OnEvent() end ++fire("ADDON_LOADED", "GentleGC") ++local cmd = SlashCmdList.GENTLEGC ++ ++-- one rendered frame taking `sec` seconds; `during` runs inside it ++local function frame(sec, during) ++ if during then during() end ++ now = now + sec ++ arg1 = sec ++ if F.scripts.OnUpdate then F.scripts.OnUpdate() end ++ if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end ++end ++local function frames(n) for i = 1, n do frame(0.016) end end ++local function last() return log[table.getn(log)] or "" end ++local function freezes() local n = 0; for _, m in ipairs(log) do if string.find(m, "freeze %d") then n = n + 1 end end; return n end ++local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end ++ ++check("off by default", HF.scripts.OnUpdate == nil) ++cmd("hitch") ++check("/ggc hitch turns it on at 250ms", HF.scripts.OnUpdate ~= nil and GentleGCDB.hitchMs == 250) ++ ++frames(5); frame(0.2); frames(3) ++check("normal and 200ms frames are not reported", freezes() == 0) ++ ++print("-- freeze with no GC while dragging AuxFrame") ++focus = auxButton ++frame(2.0) ++check("2s freeze reported", freezes() == 1) ++check("blamed on something other than GC", string.find(last(), "no garbage collection") ~= nil) ++check("names the window under the mouse", string.find(last(), "mouse over AuxFrame") ~= nil) ++focus = nil ++frames(3) ++ ++print("-- freeze containing the game's automatic GC") ++-- the heap creeps up to just below the threshold over normal frames... ++local SF = FRAMES[4] ++do ++ local junk = {} ++ local _, t = gcinfo() ++ while gcinfo() < t * 0.97 do ++ for i = 1, 500 do table.insert(junk, { i }) end ++ SF.scripts.OnUpdate() ++ end ++ junk = nil ++end ++-- ...and the collector fires inside a long frame ++frame(0.6, function() ++ local _, t = gcinfo() ++ local junk = {} ++ while true do -- allocate until Lua collects on its own ++ local used, thr = gcinfo() ++ if thr ~= t then break end ++ table.insert(junk, { 1, 2, 3 }) ++ if table.getn(junk) > 5000 then junk = {} end ++ end ++end) ++check("automatic GC identified", string.find(last(), "the game's automatic garbage collection") ~= nil) ++frames(3) ++ ++print("-- freeze containing a GentleGC collection") ++local g = {}; for i = 1, 60000 do g[i] = { i } end; g = nil ++frame(0.4, function() cmd("now") end) ++check("GentleGC collection identified", string.find(last(), "GentleGC collection %(manual%)") ~= nil) ++frames(3) ++ ++print("-- freeze that allocates a lot without collecting") ++local keep = {} ++frame(0.5, function() for i = 1, 40000 do keep[i] = { i, i } end end) ++check("allocation reported", string.find(last(), "allocated during it") ~= nil) ++check("still not blamed on GC", string.find(last(), "no garbage collection") ~= nil) ++keep = nil ++frames(3) ++ ++print("-- entering combat (threshold raise) inside a freeze is not a GC") ++combat = true ++frame(0.5, function() fire("PLAYER_REGEN_DISABLED") end) ++check("threshold raise not mistaken for GC", string.find(last(), "no garbage collection") ~= nil) ++check("marked in combat", string.find(last(), "%(in combat%)") ~= nil) ++combat = false; fire("PLAYER_REGEN_ENABLED"); frames(3) ++ ++print("-- loading screens are ignored") ++local before = freezes() ++fire("PLAYER_LEAVING_WORLD"); frame(15); frame(5) ++fire("PLAYER_ENTERING_WORLD"); frame(1.5); frame(1.0); frame(0.8) ++check("no reports for loading screen or the frames right after", freezes() == before) ++frame(1.0) ++check("reports again afterwards", freezes() == before + 1) ++ ++print("-- history and off") ++local n0 = table.getn(log) ++cmd("hitches") ++check("/ggc hitches lists recorded freezes", table.getn(log) - n0 >= 5) ++cmd("hitch 0") ++check("/ggc hitch 0 turns it off", HF.scripts.OnUpdate == nil) ++local b = freezes(); frame(3); check("no reports when off", freezes() == b) ++cmd("hitch 500"); frame(0.4); check("custom limit respected (400ms < 500ms)", freezes() == b) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") ++ ++--------------------------------------------------------------------------- ++print("== event recording (client with RegisterAllEvents)") ++-- reload the addon on frames that support RegisterAllEvents ++FRAMES = {} ++log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++local regAll = 0 ++function CreateFrame() ++ local f = { scripts = {}, all = false } ++ function f:RegisterEvent() end ++ function f:UnregisterEvent() end ++ function f:RegisterAllEvents() self.all = true; regAll = regAll + 1 end ++ function f:UnregisterAllEvents() self.all = false end ++ function f:SetScript(k, fn) self.scripts[k] = fn end ++ table.insert(FRAMES, f) ++ return f ++end ++GentleGCDB = nil ++SlashCmdList = {} ++dofile(arg[1]) ++F, HF = FRAMES[1], FRAMES[2] ++local EF = FRAMES[3] ++event, arg1 = "ADDON_LOADED", "GentleGC"; F.scripts.OnEvent() ++cmd = SlashCmdList.GENTLEGC ++local function last2() return log[table.getn(log)] or "" end ++local function ev(name, a1, dt) ++ if dt then now = now + dt end ++ if EF.all and EF.scripts.OnEvent then event, arg1 = name, a1; EF.scripts.OnEvent() end ++end ++local function endFrame(dt) ++ now = now + (dt or 0) ++ arg1 = 0.016 ++ if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end ++end ++local function quiet(n) for i = 1, n do now = now + 0.016; endFrame() end end ++ ++check("event frame not listening while detector off", EF.all == false) ++cmd("hitch") ++check("RegisterAllEvents on /ggc hitch", EF.all == true and regAll == 1) ++quiet(3) ++ ++-- 0.5s spent right after an addon message ++ev("UNIT_AURA", "raid3", 0.005) ++ev("UNIT_AURA", "raid7", 0.005) ++ev("CHAT_MSG_ADDON", "BigWigs", 0.005) ++ev("UNIT_HEALTH", "raid1", 0.50) ++endFrame(0.01) ++local l = last2() ++check("lists events with counts", string.find(l, "4 events: UNIT_AURA x2") ~= nil) ++check("finds the longest gap after CHAT_MSG_ADDON (BigWigs)", string.find(l, "longest gap 0%.50s after CHAT_MSG_ADDON %(BigWigs%)") ~= nil) ++quiet(2) ++ ++-- freeze with no events at all ++endFrame(0.6) ++check("no events reported", string.find(last2(), "no game events fired during it") ~= nil) ++quiet(2) ++ ++-- time lost before the first event ++ev("BAG_UPDATE", 0, 0.7) ++endFrame(0.01) ++check("gap before the first event", string.find(last2(), "longest gap 0%.70s before the first event") ~= nil) ++quiet(2) ++ ++-- time lost after the last event ++ev("GUILD_ROSTER_UPDATE", nil, 0.01) ++endFrame(0.45) ++check("gap after the last event", string.find(last2(), "after GUILD_ROSTER_UPDATE, the last event") ~= nil) ++quiet(2) ++ ++-- memory jump attributed to an event ++local keep ++ev("PLAYER_TARGET_CHANGED", nil, 0.01) ++ev("AUCTION_ITEM_LIST_UPDATE", nil, 0.01) ++keep = {}; for i = 1, 30000 do keep[i] = { i, i } end ++ev("UNIT_AURA", "target", 0.4) ++endFrame(0.01) ++check("allocation attributed to AUCTION_ITEM_LIST_UPDATE", string.find(last2(), "allocated after AUCTION_ITEM_LIST_UPDATE") ~= nil) ++keep = nil ++quiet(2) ++ ++-- more events than the ring holds ++for i = 1, 100 do ev("COMBAT_TEXT_UPDATE", "x", 0.003) end ++endFrame(0.01) ++check("ring overflow reported", string.find(last2(), "100 events: COMBAT_TEXT_UPDATE x64 %(last 64 kept%)") ~= nil) ++quiet(2) ++ ++-- long argument is shortened ++ev("CHAT_MSG_ADDON", "AVeryLongAddonPrefixThatGoesOnAndOn", 0.01) ++endFrame(0.5) ++check("long argument shortened", string.find(last2(), "AVeryLongAddonPrefixThat%.%.%.") ~= nil) ++quiet(2) ++ ++-- events from an earlier frame aren't blamed ++ev("SPELLS_CHANGED", nil, 0.01); endFrame(0.01) ++endFrame(0.5) ++check("previous frame's events not included", string.find(last2(), "no game events fired during it") ~= nil) ++ ++cmd("hitch 0") ++check("UnregisterAllEvents on /ggc hitch 0", EF.all == false) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") +diff --git a/tests/test_memory.lua b/tests/test_memory.lua +new file mode 100644 +index 0000000..79d16a6 +--- /dev/null ++++ b/tests/test_memory.lua +@@ -0,0 +1,154 @@ ++-- Per-addon memory: the !!!!GentleGC_Memory companion measuring a simulated ++-- login, and Gentle GC reporting it. ++-- Run: lua5.0 tests/test_memory.lua GentleGC.lua "!!!!GentleGC_Memory/GentleGC_Memory.lua" ++local MAIN, COMPANION = arg[1], arg[2] ++format = string.format ++local now = 1000 ++function GetTime() return now end ++function UnitAffectingCombat() return false end ++function UnitIsAFK() return false end ++function date() return "2026-10-21 12:00" end ++local log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++ ++local origCG = collectgarbage ++local ADDONS, LOADED ++ ++-- frames, with events dispatched in registration order like the client ++local FRAMES ++function CreateFrame() ++ local f = { scripts = {}, events = {} } ++ function f:RegisterEvent(e) self.events[e] = true end ++ function f:UnregisterEvent(e) self.events[e] = nil end ++ function f:UnregisterAllEvents() self.events = {} end ++ function f:RegisterAllEvents() end ++ function f:SetScript(k, fn) self.scripts[k] = fn end ++ table.insert(FRAMES, f) ++ return f ++end ++local function fire(e, a1) ++ for _, f in ipairs(FRAMES) do ++ if f.events[e] and f.scripts.OnEvent then event, arg1 = e, a1; this = f; f.scripts.OnEvent() end ++ end ++end ++ ++function GetNumAddOns() return table.getn(ADDONS) end ++function GetAddOnInfo(i) ++ local a = type(i) == "number" and ADDONS[i] or nil ++ if not a then return nil end ++ return a.name, a.name, "", a.enabled and 1 or nil ++end ++function IsAddOnLoaded(name) return LOADED[name] and 1 or nil end ++ ++local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end ++local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end ++local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end ++ ++-- an addon's data: tables kept in a global, roughly `mb` megabytes ++KEEP = {} ++local function allocate(name, mb) ++ local t = {} ++ for i = 1, mb * 9000 do t[i] = { i } end ++ KEEP[name] = t ++end ++ ++-- one full login: every enabled addon in order, then login and world events ++-- sizes: MB per addon; withCompanion: install the companion ++local function login(sizes, withCompanion) ++ FRAMES, LOADED, KEEP = {}, {}, {} ++ collectgarbage = origCG ++ SlashCmdList = {} ++ GentleGCMemory = nil ++ origCG() ++ for _, a in ipairs(ADDONS) do ++ if a.enabled and (withCompanion or a.name ~= "!!!!GentleGC_Memory") then ++ if a.name == "!!!!GentleGC_Memory" then ++ dofile(COMPANION) ++ elseif a.name == "GentleGC" then ++ dofile(MAIN) ++ else ++ allocate(a.name, sizes[a.name] or 0) ++ end ++ LOADED[a.name] = true ++ fire("ADDON_LOADED", a.name) ++ end ++ end ++ allocate("variables", 0.5) ++ fire("PLAYER_LOGIN") ++ allocate("login setup", 3) ++ fire("PLAYER_ENTERING_WORLD") ++end ++ ++local SIZES = { ["!!!ClassicAPI"] = 1, Atlas = 2, Bagshui = 5, pfQuest = 20, Wrekkit = 1 } ++ADDONS = { ++ { name = "!!!ClassicAPI", enabled = true }, ++ { name = "!!!!GentleGC_Memory", enabled = true }, ++ { name = "Atlas", enabled = true }, ++ { name = "Bagshui", enabled = true }, ++ { name = "GentleGC", enabled = true }, ++ { name = "pfQuest", enabled = true }, ++ { name = "Wrekkit", enabled = true }, ++} ++-- (the client loads "!!!!" before "!!!", but the test puts ClassicAPI first to ++-- check the "loaded before" list) ++ ++local function rowMB(name) ++ local m = GentleGCMemoryDB.measured ++ for i = 1, table.getn(m.rows) do if m.rows[i][1] == name then return m.rows[i][2] / 1024 end end ++end ++local function near(v, want) return v and math.abs(v - want) <= math.max(0.35, want * 0.15) end ++ ++print("1. first login with the companion: measured") ++login(SIZES, true) ++local m = GentleGCMemoryDB and GentleGCMemoryDB.measured ++check("a measurement was saved", m ~= nil and table.getn(m.rows) >= 5) ++check("first login is the reason", m and m.reason == "first login") ++check("Atlas ~2 MB", near(rowMB("Atlas"), 2)) ++check("Bagshui ~5 MB", near(rowMB("Bagshui"), 5)) ++check("pfQuest ~20 MB", near(rowMB("pfQuest"), 20)) ++check("Wrekkit ~1 MB", near(rowMB("Wrekkit"), 1)) ++check("ClassicAPI listed as loaded before", m and m.before[1] == "!!!ClassicAPI") ++check("summary printed after loading", string.find(lastMatch("measured your addons"), "Biggest: pfQuest") ~= nil) ++check("companion stopped measuring", GentleGCMemory.measuring == false) ++ ++print("2. /ggc addons") ++local cmd = SlashCmdList.GENTLEGC ++cmd("addons") ++check("header line", count("addon memory at login on 2026%-10%-21") == 1) ++local first ++for i = 1, table.getn(log) do if string.find(log[i], "addon memory at login") then first = log[i + 1] end end ++check("biggest addon on the first row", first and string.find(first, "pfQuest") ~= nil) ++check("login stage shown", count("then addons setting up at login") == 1) ++check("loaded-before line", count("counted in the interface number: !!!ClassicAPI") == 1) ++cmd("") ++check("status names the biggest addons", string.find(lastMatch("biggest addons"), "pfQuest") ~= nil) ++check("measuring collections not counted as other addons'", count("by other addons") == 0) ++check("nor as automatic", string.find(lastMatch("session:"), "0 automatic") ~= nil) ++ ++print("3. next login, same addons: not measured again") ++local when = m ++login(SIZES, true) ++check("same measurement kept", GentleGCMemoryDB.measured == when) ++check("no summary this time", count("measured your addons") == 1) ++ ++print("4. an addon added: measured again") ++table.insert(ADDONS, { name = "ShaguTweaks", enabled = true }) ++SIZES.ShaguTweaks = 3 ++login(SIZES, true) ++check("new measurement", GentleGCMemoryDB.measured ~= when and GentleGCMemoryDB.measured.reason == "addons changed") ++check("ShaguTweaks ~3 MB", near(rowMB("ShaguTweaks"), 3)) ++ ++print("5. /ggc addons measure: measured on the next reload") ++local before = GentleGCMemoryDB.measured ++SlashCmdList.GENTLEGC("addons measure") ++check("asks for a reload", count("type /reload to measure") == 1) ++login(SIZES, true) ++check("measured because it was requested", GentleGCMemoryDB.measured ~= before and GentleGCMemoryDB.measured.reason == "requested") ++ ++print("6. without the companion") ++GentleGCMemoryDB = nil ++login(SIZES, false) ++SlashCmdList.GENTLEGC("addons") ++check("explains the companion folder", count("needs the !!!!GentleGC_Memory folder") == 1) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") +diff --git a/tests/test_raid.lua b/tests/test_raid.lua +new file mode 100644 +index 0000000..70f2144 +--- /dev/null ++++ b/tests/test_raid.lua +@@ -0,0 +1,242 @@ ++-- Raid-scale behaviour: careful mode when collections are slow, collections ++-- run by other addons, and the garbage creation rate. ++-- Run: lua5.0 tests/test_raid.lua GentleGC.lua ++format = string.format ++local now = 1000 ++function GetTime() return now end ++local combat, speed = false, 7 ++function UnitAffectingCombat() return combat end ++function UnitIsAFK() return false end ++function GetUnitSpeed() return speed, 7, 0, 4.7 end -- ClassicAPI: enables stand-still ++local log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++SlashCmdList = {} ++FRAMES = {} ++function CreateFrame() ++ local f = { scripts = {} } ++ function f:RegisterEvent() end ++ function f:UnregisterEvent() end ++ function f:SetScript(k, fn) self.scripts[k] = fn end ++ table.insert(FRAMES, f) ++ return f ++end ++ ++-- the call stack another addon's collectgarbage() call is made from ++local caller = nil ++function debugstack() ++ local s = "Interface\\AddOns\\GentleGC\\GentleGC.lua:400: in function `WrappedCollect'\n" ++ if caller then s = s .. "Interface\\AddOns\\" .. caller .. "\\core.lua:12: in function `FreeMemory'\n" end ++ return s .. "[C]: ?\n" ++end ++ ++-- a slow collector: every full collection advances the clock by `gcCost` ++local gcCost = 0.3 ++local origCG = collectgarbage ++collectgarbage = function(n) ++ if n == nil or n == 0 then now = now + gcCost end ++ return origCG(n) ++end ++local unwrappedCG = collectgarbage -- what an addon that localized it before us holds ++ ++LIVE = {} ++for i = 1, 300000 do LIVE[i] = { i } end ++origCG() ++ ++dofile(arg[1]) ++local F = FRAMES[1] ++local function fire(e, a1) event, arg1 = e, a1; this = F; F.scripts.OnEvent() end ++fire("ADDON_LOADED", "GentleGC") ++local SF = FRAMES[4] ++local cmd = SlashCmdList.GENTLEGC ++cmd("debug 2") ++ ++local function count(pat) local n = 0; for _, m in ipairs(log) do if string.find(m, pat) then n = n + 1 end end; return n end ++local function lastMatch(pat) for i = table.getn(log), 1, -1 do if string.find(log[i], pat) then return log[i] end end; return "" end ++local function check(name, c) print((c and "PASS " or "FAIL ") .. name); if not c then FAILED = true end end ++-- one rendered frame: per-frame sampler, then the main frame's OnUpdate ++local HF = FRAMES[2] ++local function frame(dt) ++ now = now + dt ++ SF.scripts.OnUpdate() ++ arg1 = dt; this = F ++ F.scripts.OnUpdate() ++ if HF.scripts.OnUpdate then HF.scripts.OnUpdate() end ++end ++local function seconds(n) for i = 1, n * 10 do frame(0.1) end end ++local function garbageMB(mb) ++ local t = {} ++ for i = 1, mb * 7000 do t[i] = { i, i } end ++end ++local function liveMB() local _, _, v = string.find(lastMatch("heap .* live ~"), "live ~([%d%.]+)mb"); return tonumber(v) end ++ ++--------------------------------------------------------------------------- ++print("1. slow collections switch to careful mode") ++garbageMB(8) ++fire("PLAYER_LEAVING_WORLD") ++fire("PLAYER_ENTERING_WORLD") ++check("loading-screen collection ran", count("loading screen:") >= 1) ++cmd("status") ++check("status shows careful mode", string.find(lastMatch("collections take"), "~[23]%d%dms: careful mode") ~= nil) ++ ++print("2. careful mode: no stand-still or idle collections") ++garbageMB(8) ++speed = 0 ++seconds(10) ++check("no stand-still collection", count("standing still:") == 0) ++now = now + 400 ++seconds(3) ++check("no idle collection", count("idle:") == 0) ++speed = 7 ++ ++print("3. careful mode: NPC windows and pressure still collect") ++fire("MERCHANT_SHOW") ++check("merchant collection ran", count("merchant:") == 1) ++local _, thr = gcinfo() ++do ++ local junk = {} ++ local i = 0 ++ while gcinfo() < thr * 0.9 do ++ for k = 1, 500 do i = i + 1; junk[i] = { i } end ++ if i > 30000 then junk = {}; i = 0 end ++ SF.scripts.OnUpdate() ++ end ++end ++seconds(6) ++check("pressure collection ran", count("pressure:") == 1) ++ ++print("4. fast collections switch back to normal") ++gcCost = 0.01 ++for k = 1, 6 do now = now + 40; garbageMB(8); fire("MAIL_SHOW") end ++cmd("status") ++check("back to normal mode", string.find(lastMatch("collections take"), "normal mode") ~= nil) ++garbageMB(8) ++now = now + 40 ++speed = 0 ++seconds(5) ++check("stand-still collection runs again", count("standing still:") == 1) ++speed = 7 ++ ++print("5. /ggc slow 0 turns careful mode off") ++gcCost = 0.3 ++cmd("slow 0") ++for k = 1, 3 do now = now + 40; garbageMB(8); fire("BANKFRAME_OPENED") end ++cmd("status") ++check("normal mode despite slow collections", string.find(lastMatch("collections take"), "normal mode") ~= nil) ++cmd("slow 100") ++gcCost = 0.01 ++ ++--------------------------------------------------------------------------- ++print("6. another addon's collectgarbage() is attributed, not 'automatic'") ++local autoBefore = count("automatic GC detected") ++garbageMB(10) ++seconds(1) ++caller = "Wrekkit" ++collectgarbage() ++caller = nil ++seconds(2) ++check("logged as Wrekkit's collection", string.find(lastMatch("collection by"), "collection by Wrekkit: freed") ~= nil) ++check("not counted as automatic", count("automatic GC detected") == autoBefore) ++cmd("status") ++check("status lists it by name", string.find(lastMatch("by other addons"), "Wrekkit 1") ~= nil) ++local real = gcinfo() / 1024 ++check("live estimate matches real live memory", math.abs(liveMB() - real) < 1) ++ ++print("7. an addon setting the threshold (no collection) changes nothing") ++local liveBefore = liveMB() ++caller = "FpsWatch" ++local u = gcinfo() ++collectgarbage(u * 3) ++caller = nil ++seconds(2) ++cmd("status") ++check("no collection counted", string.find(lastMatch("by other addons"), "FpsWatch") == nil) ++check("no automatic GC counted", count("automatic GC detected") == autoBefore) ++check("live estimate unchanged", math.abs(liveMB() - liveBefore) < 0.5) ++origCG(math.floor(u * 1.5) + 2048) -- (back to a normal threshold for the next steps) ++seconds(1) ++ ++print("8. an addon we couldn't wrap: detected from the heap dropping below the threshold") ++garbageMB(10) ++seconds(1) ++unwrappedCG() ++seconds(2) ++check("counted as another addon's (unknown)", string.find(lastMatch("collection by"), "collection by unknown addon") ~= nil) ++check("still not counted as automatic", count("automatic GC detected") == autoBefore) ++ ++print("9. the game's collector firing at the threshold is still 'automatic'") ++do ++ local _, t = gcinfo() ++ local junk = {} ++ local i = 0 ++ local _, cur = gcinfo() ++ while cur == t do ++ for k = 1, 500 do i = i + 1; junk[i] = { i } end ++ if i > 30000 then junk = {}; i = 0 end ++ SF.scripts.OnUpdate() ++ _, cur = gcinfo() ++ end ++end ++seconds(1) ++check("counted as automatic", count("automatic GC detected") == autoBefore + 1) ++ ++print("10. another addon collecting in combat: combat headroom is restored") ++combat = true; fire("PLAYER_REGEN_DISABLED") ++garbageMB(6) ++caller = "Wrekkit" ++collectgarbage() ++caller = nil ++local used, t = gcinfo() ++check("threshold raised again after it", t >= used + 32 * 1024 - 64) ++combat = false; fire("PLAYER_REGEN_ENABLED") ++seconds(1) ++ ++print("11. freeze detector names the addon") ++cmd("hitch") ++seconds(1) ++garbageMB(10) ++caller = "Wrekkit" ++now = now + 0.4 ++collectgarbage() ++caller = nil ++frame(0.05) ++check("freeze blamed on Wrekkit", string.find(lastMatch("freeze"), "run by Wrekkit") ~= nil) ++cmd("hitch 0") ++ ++--------------------------------------------------------------------------- ++print("12. garbage creation rate") ++cmd("allocwarn 1") ++now = now + 120 ++for s = 1, 40 do ++ for f = 1, 10 do ++ garbageMB(0.2) -- ~2 MB/s ++ frame(0.1) ++ end ++end ++cmd("status") ++local _, _, rate = string.find(lastMatch("garbage created"), "([%d%.]+) MB/s now") ++rate = tonumber(rate) ++check("rate is measured (about 2 MB/s)", rate and rate > 1.2 and rate < 3.5) ++check("warning shown above the limit", count("garbage is being created at") == 1) ++for s = 1, 20 do for f = 1, 10 do garbageMB(0.2); frame(0.1) end end ++check("warning not repeated within 10 minutes", count("garbage is being created at") == 1) ++ ++--------------------------------------------------------------------------- ++print("13. a wrapper that blocks collectgarbage is reported, not counted") ++FRAMES = {} ++log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(_, m) table.insert(log, m); print(" " .. m) end } ++GentleGCDB = nil ++SlashCmdList = {} ++collectgarbage = function(n) end -- an addon loaded before us that swallows every call ++dofile(arg[1]) ++F = FRAMES[1] ++fire("ADDON_LOADED", "GentleGC") ++SlashCmdList.GENTLEGC("debug 1") ++garbageMB(8) ++SlashCmdList.GENTLEGC("now") ++check("reported as blocked", count("did not run") == 1) ++SlashCmdList.GENTLEGC("status") ++check("not counted as a collection", string.find(lastMatch("session:"), "0 collections") ~= nil) ++check("ratio not learned from it", count("not measured yet") == 1) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") +diff --git a/tests/test_stock.lua b/tests/test_stock.lua +new file mode 100644 +index 0000000..58dfe2b +--- /dev/null ++++ b/tests/test_stock.lua +@@ -0,0 +1,133 @@ ++-- WoW API stubs ------------------------------------------------------------ ++format = string.format ++local now = 1000 ++function GetTime() return now end ++local combat, afk = false, false ++function UnitAffectingCombat() return combat end ++function UnitIsAFK() return afk end ++local log = {} ++DEFAULT_CHAT_FRAME = { AddMessage = function(self, m) table.insert(log, m); print(" chat: " .. m) end } ++SlashCmdList = {} ++FRAMES = {} ++function CreateFrame() ++ local F = { scripts = {}, events = {} } ++ function F:RegisterEvent(e) self.events[e] = true end ++ function F:UnregisterEvent(e) self.events[e] = nil end ++ function F:SetScript(k, f) self.scripts[k] = f end ++ table.insert(FRAMES, F) ++ return F ++end ++local function fire(e, a1) ++ assert(F.events[e], "not registered: " .. e) ++ event, arg1 = e, a1; this = F ++ F.scripts.OnEvent() ++end ++local function update(sec) -- advance time in 0.1s frames ++ local n = sec * 10 ++ for i = 1, n do now = now + 0.1; arg1 = 0.1; this = F; F.scripts.OnUpdate() end ++end ++ ++-- live data (~8 MB) and a garbage generator ------------------------------- ++LIVE = {} ++for i = 1, 60000 do LIVE[i] = { i, "x" .. i } end ++local function makeGarbage(mb) ++ local t = {} ++ for i = 1, mb * 6000 do t[i] = { i, i } end -- roughly 1 MB per 6000 small tables ++end ++ ++collectgarbage() ++dofile(arg[1]) ++F = FRAMES[1] ++HF = FRAMES[2] ++fire("ADDON_LOADED", "GentleGC") ++local cmd = SlashCmdList.GENTLEGC ++cmd("debug 2") ++ ++local function count() local n = 0; for _, m in ipairs(log) do if string.find(m, "%->") then n = n + 1 end end; return n end ++local function check(name, cond) print((cond and "PASS " or "FAIL ") .. name); if not cond then FAILED = true end end ++ ++print("1. login loading screen collects") ++makeGarbage(6); fire("PLAYER_ENTERING_WORLD"); check("collected at login", count() == 1) ++ ++print("2. small garbage is skipped") ++makeGarbage(1); now = now + 60; fire("MERCHANT_SHOW"); check("skipped (little garbage)", count() == 1) ++ ++print("3. interval respected") ++makeGarbage(6); fire("BANKFRAME_OPENED"); fire("MAIL_SHOW") ++check("one collection for two quick events", count() == 2) ++makeGarbage(6); fire("MAIL_SHOW"); check("within minInterval -> no collect", count() == 2) ++ ++print("4. combat: threshold raised, no automatic GC, deferred collection after") ++now = now + 100 ++local used0, thr0 = gcinfo() ++fire("PLAYER_REGEN_DISABLED"); combat = true ++local _, thr1 = gcinfo() ++check("threshold raised by headroom", thr1 >= used0 + 32 * 1024 - 1) ++-- allocate past the ORIGINAL threshold but keep it alive so we can see it ++local keep = {} ++local target = thr0 + 1024 ++while gcinfo() < target do table.insert(keep, { 1, 2, 3 }) end ++local usedMid = gcinfo() ++check("heap went past old threshold without auto-GC", usedMid > thr0) ++update(20); check("no collection in combat (fallback timer)", count() == 2) ++fire("ZONE_CHANGED_NEW_AREA"); check("no collection on safe event in combat", count() == 2) ++keep = nil ++fire("PLAYER_REGEN_ENABLED"); combat = false ++check("collected after combat", count() == 3) ++local u2, t2 = gcinfo(); check("threshold back to normal (2x live)", t2 <= u2 * 2 + 64) ++ ++print("5. short fight with nothing to collect restores threshold") ++now = now + 100 ++local _, tb = gcinfo(); fire("PLAYER_REGEN_DISABLED"); fire("PLAYER_REGEN_ENABLED") ++local _, ta = gcinfo(); check("threshold restored", ta == tb); check("no collection", count() == 3) ++ ++print("6. pressure: collect pre-emptively near threshold, out of combat") ++now = now + 40 ++local keep2 = {} ++local _, thr = gcinfo() ++while gcinfo() < thr * 0.86 do table.insert(keep2, { 1, 2 }) end ++keep2 = nil ++update(1.5); check("pressure collection", count() == 4) ++ ++print("7. fallback idle collection after maxInterval") ++makeGarbage(8); update(310); check("idle collection", count() == 5) ++ ++print("8. AFK") ++makeGarbage(8); now = now + 60; afk = true; fire("PLAYER_FLAGS_CHANGED"); afk = false ++check("afk collection", count() == 6) ++ ++print("8b. automatic GC is detected and resynced") ++cmd("debug 2") ++local nAuto = 0 ++for _, m in ipairs(log) do if string.find(m, "automatic GC detected") then nAuto = nAuto + 1 end end ++-- the heap grows frame by frame until Lua collects on its own ++local SF = FRAMES[4] ++local _, t0 = gcinfo() ++local junk = {} ++local t = t0 ++while t == t0 do -- (a local declared inside repeat isn't visible to until in Lua 5.0) ++ for i = 1, 2000 do table.insert(junk, { i }) end ++ if table.getn(junk) > 20000 then junk = {} end ++ SF.scripts.OnUpdate() ++ _, t = gcinfo() ++end ++junk = nil ++update(1.1) ++local nAuto2 = 0 ++for _, m in ipairs(log) do if string.find(m, "automatic GC detected") then nAuto2 = nAuto2 + 1 end end ++check("automatic GC detected", nAuto2 > nAuto) ++local u, t = gcinfo() ++check("pressure not falsely triggered after auto GC", u < t * 0.85) ++ ++print("9. slash commands") ++cmd("interval 45"); check("interval set", GentleGCDB.minInterval == 45) ++cmd("now"); check("manual collect", count() >= 7) ++cmd(""); cmd("bogus"); cmd("reset"); check("reset", GentleGCDB.minInterval == 30) ++ ++print("10. summary report") ++cmd("debug 1"); update(301) ++local found = false ++for _, m in ipairs(log) do if string.find(m, "last %d+ min") then found = true end end ++check("summary printed", found) ++ ++print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED") \ No newline at end of file