Apply Patch

This commit is contained in:
2026-10-30 16:47:32 +00:00
parent 9327ce34a3
commit 946f50fc29
11 changed files with 2124 additions and 0 deletions
@@ -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
+118
View File
@@ -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)
+1015
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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
+71
View File
@@ -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")
+15
View File
@@ -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")
+128
View File
@@ -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")
+232
View File
@@ -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")
+154
View File
@@ -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")
+242
View File
@@ -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")
+133
View File
@@ -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")