Release 2.8.0: live per-addon memory from ClassicAPI

/ggc addons reads GetAddOnMemoryUsage when ClassicAPI provides it, so the
!!!!GentleGC_Memory companion is only needed without it. /ggc also shows the
client's scriptMemory limit and warns when scriptProfile is on.
Adds tests/test_native_memory.lua.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwtugSE5NbbiWQCbbKX
This commit is contained in:
2026-11-02 15:11:01 +00:00
co-authored by Claude Sonnet 5.5
parent 9b9b9c6779
commit db6f7d90cc
6 changed files with 257 additions and 8 deletions
+12 -1
View File
@@ -3,7 +3,18 @@
All notable changes to this OctoWoW edition of Gentle GC. Versions follow
`GentleGC.toc`. Dates are the commit dates (UTC).
## Unreleased
## 2.8.0 - 2026-11-02
### Added
- With ClassicAPI, `/ggc addons` reads each addon's memory live from the client
(`UpdateAddOnMemoryUsage` / `GetAddOnMemoryUsage`): no companion, no extra
collections at the loading screen, current numbers at any time, and a note on
addons that grew by 100 KB or more since the last check. The
`!!!!GentleGC_Memory` companion is now only needed without ClassicAPI, and is
still used when the client returns nothing.
- `/ggc` shows the client's `scriptMemory` limit and warns when `scriptProfile`
(CPU profiling) is on, since profiling slows every Lua call.
- `tests/test_native_memory.lua`.
### Repository
- README shows client-mod badges (ClassicAPI and weirdperformance, both optional).
+78
View File
@@ -27,6 +27,8 @@
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
* /ggc addons reads each addon's memory live (GetAddOnMemoryUsage), so the
!!!!GentleGC_Memory companion is only needed without ClassicAPI
with weirdperformance.dll (WeirdUtils), whose collector does one full mark
per cycle and then frees memory in small chunks:
* recognize the chunks, stay out of a running cycle, and schedule only
@@ -1031,7 +1033,57 @@ ShowAddonSummary = function()
Size(m.total or 0), table.getn(m.rows), Biggest(m, 3)))
end
-- With ClassicAPI the client reports each addon's Lua memory itself
-- (UpdateAddOnMemoryUsage / GetAddOnMemoryUsage), at any time and without the
-- companion's collections at the loading screen. It recounts everything on each
-- update, so it only runs when asked for, never per frame.
local nativeLast = nil -- session: addon memory (KB) at the last native check
local function HasNativeMem()
return type(UpdateAddOnMemoryUsage) == "function" and type(GetAddOnMemoryUsage) == "function"
and type(GetNumAddOns) == "function" and type(GetAddOnInfo) == "function"
end
-- Prints the live per-addon list; false when it can't (then the companion's
-- login measurement is used instead).
local function PrintNativeAddons(all)
if not HasNativeMem() or not pcall(UpdateAddOnMemoryUsage) then return false end
local rows, sum, now = {}, 0, {}
for i = 1, GetNumAddOns() do
local name = GetAddOnInfo(i)
if name then
local ok, kb = pcall(GetAddOnMemoryUsage, i)
if ok and type(kb) == "number" and kb > 0 then
table.insert(rows, { name, kb })
sum = sum + kb
now[name] = kb
end
end
end
if table.getn(rows) == 0 then return false end
table.sort(rows, function(a, b) return a[2] > b[2] end)
local used = gcinfo()
Msg(format("addon memory now (from ClassicAPI): %s in %d addons; Lua heap %s.",
Size(sum), table.getn(rows), Size(used)))
local shown = table.getn(rows)
if not all and shown > ADDONS_TOP then shown = ADDONS_TOP end
for i = 1, shown do
local name, kb = rows[i][1], rows[i][2]
local extra = ""
if nativeLast and nativeLast[name] and kb - nativeLast[name] >= 100 then
extra = format(" (+%s since the last check)", Size(kb - nativeLast[name]))
end
DEFAULT_CHAT_FRAME:AddMessage(format(" %9s %s%s", Size(kb), name, extra))
end
if shown < table.getn(rows) then
DEFAULT_CHAT_FRAME:AddMessage(format(" ...and %d more (/ggc addons all)", table.getn(rows) - shown))
end
nativeLast = now
return true
end
local function PrintAddons(all)
if PrintNativeAddons(all) then return end
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.")
@@ -1069,6 +1121,10 @@ local function PrintAddons(all)
end
local function MeasureNext()
if HasNativeMem() then
Msg("no measuring needed: ClassicAPI reports each addon's memory live. Just /ggc addons.")
return
end
if not GentleGCMemory or type(GentleGCMemoryDB) ~= "table" then
PrintAddons(false) -- explains the missing companion
return
@@ -1175,12 +1231,34 @@ local function PrintStatus()
", C_Timer " .. (hasTicker and "on" or "not available (using OnUpdate)"))
end
-- The client's interface memory limit and CPU profiling, which change how the
-- client frees memory and how fast Lua runs.
local function PrintClientInfo()
if type(GetCVar) ~= "function" then return end
local ok, limit = pcall(GetCVar, "scriptMemory")
local ok2, prof = pcall(GetCVar, "scriptProfile")
limit = ok and tonumber(limit) or nil
if limit then
if limit == 0 then
Msg("client: interface memory limit off")
else
Msg(format("client: interface memory limit %s - the client frees what it can when the interface passes it, "
.. "which shows up as collections nobody ran", Size(limit)))
end
end
if ok2 and prof == "1" then
Msg("client: CPU profiling is ON (scriptProfile 1): it slows every Lua function call. "
.. "Turn it off (/console scriptProfile 0, then /reload) unless you are measuring.")
end
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()
PrintClientInfo()
elseif cmd == "now" then
if inCombat then Msg("in combat - not collecting") else Collect("manual") end
elseif cmd == "debug" then
+1 -1
View File
@@ -2,7 +2,7 @@
## Title: Gentle GC
## Notes: Schedules Lua garbage collection for moments you won't notice
## Author: Weird Vibes (MarcelineVQ); forks by TheRealFayz, jrc13245
## Version: 2.7.1
## Version: 2.8.0
## SavedVariables: GentleGCDB
GentleGC.lua
+19 -6
View File
@@ -2,7 +2,7 @@
[![ClassicAPI](https://img.shields.io/badge/ClassicAPI-Optional-yellow.svg)](https://github.com/brues-code/ClassicAPI) [![weirdperformance](https://img.shields.io/badge/weirdperformance-Optional-yellow.svg)](https://codeberg.org/MarcelineVQ/WeirdUtils)
Gentle Garbage Collection, unified edition (v2.7.1), maintained for OctoWoW.
Gentle Garbage Collection, unified edition (v2.8.0), maintained for OctoWoW.
For WoW 1.18.1 clients (Lua 5.0).
@@ -12,7 +12,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the version history.
Unzip into `Interface/AddOns`. The zip has two folders:
`GentleGC` (the addon) and `!!!!GentleGC_Memory` (optional, per-addon memory;
see below). Keep both folder names exactly as they are. If you download the
see below; not needed with ClassicAPI). Keep both folder names exactly as they are. If you download the
repository instead, move `!!!!GentleGC_Memory` out of the `GentleGC` folder
so it sits next to it in `Interface/AddOns`.
@@ -68,8 +68,10 @@ under 75 ms. `/ggc slow <ms>` sets the limit; `/ggc slow 0` turns it off.
and whether careful mode is on; and how fast garbage is being created.
- `/ggc debug 1`: chat summary every 5 minutes. `/ggc debug 2` also prints each collection. `/ggc debug 0` turns it off.
- `/ggc now`: collect immediately.
- `/ggc addons`: memory per addon (needs `!!!!GentleGC_Memory`). `/ggc addons all`
lists every addon; `/ggc addons measure` measures again on the next `/reload`.
- `/ggc addons`: memory per addon. With ClassicAPI it is read live from the
client; without it, it needs `!!!!GentleGC_Memory`. `/ggc addons all` lists
every addon; `/ggc addons measure` measures again on the next `/reload`
(companion only).
- `/ggc interval|maxinterval|report <sec>`, `/ggc garbage|headroom <mb>`,
`/ggc garbagepct <%>`, `/ggc pressure <%>` (share of the room, default 70),
`/ggc slow <ms>` (careful mode, default 100), `/ggc allocwarn <MB/s>`
@@ -100,7 +102,14 @@ Off by default.
## Per-addon memory
`/ggc addons`: the 1.12 client has no per-addon
**With ClassicAPI** the client reports each addon's Lua memory itself
(`GetAddOnMemoryUsage`), so `/ggc addons` shows a live list at any time, with
no companion and no extra collections at the loading screen. Each call recounts
all memory, so it only runs when you type the command. Addons that have grown by
100 KB or more since your last `/ggc addons` are marked. Blizzard addons and
addons that haven't loaded report 0 and are left out.
**Without ClassicAPI** (or if it returns nothing) the old method is used. The 1.12 client has no per-addon
memory numbers, only the size of the whole Lua heap. The optional
`!!!!GentleGC_Memory` folder loads before every other addon (that's what the
`!!!!` is for) and, each time another addon finishes loading, collects garbage
@@ -167,7 +176,11 @@ pfQuest, for example, can hold 100 MB while idle.
## Client mods
- ClassicAPI: supported, as above. Its once-per-frame timer also runs
GentleGC's background check, in place of a frame OnUpdate.
GentleGC's background check, in place of a frame OnUpdate, and its
`GetAddOnMemoryUsage` gives `/ggc addons` live per-addon memory. `/ggc` also
reports the client's `scriptMemory` limit (the client frees what it can when
the interface passes it) and warns if `scriptProfile` (CPU profiling, which
slows every Lua call) is on.
- weirdperformance (WeirdUtils): supported. It replaces the game's collector:
each cycle does one full mark, then frees memory in small chunks, setting
the threshold to 128 KB above the memory in use between chunks. Gentle GC
+1
View File
@@ -19,4 +19,5 @@ Single test: `lua5.0 tests/test_stock.lua GentleGC.lua`
| test_classicapi.lua | ClassicAPI client mod |
| test_weirdperf.lua | weirdperformance.dll collector |
| test_unknown_context.lua | event context shown for unattributed collections and threshold changes |
| test_native_memory.lua | ClassicAPI per-addon memory, companion fallback, client info lines |
| test_memory.lua | per-addon memory (also needs `!!!!GentleGC_Memory/GentleGC_Memory.lua` as the second argument) |
+146
View File
@@ -0,0 +1,146 @@
-- Native per-addon memory (ClassicAPI), the companion fallback, and the client info lines.
-- Run: lua5.0 tests/test_native_memory.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 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 `x'\n" end
return s
end
-- the client's collector: full collections leave the threshold at 1.25x live,
-- and take 10 ms + 8 ms per MB freed (simulated on the clock)
local origCG = collectgarbage
local function setThreshold() local u = gcinfo(); origCG(math.floor(u * 1.25)) end
local timed = false
collectgarbage = function(n)
if n == nil or n == 0 then
local before = gcinfo()
origCG(); setThreshold()
if timed then now = now + 0.010 + 0.008 * (before - gcinfo()) / 1024 end
else
origCG(n)
end
end
local unwrapped = collectgarbage -- what an addon holds if it loaded before us
LIVE = {}
for i = 1, 300000 do LIVE[i] = { i } end
origCG(); setThreshold()
dofile(arg[1])
local F = FRAMES[1]
event, arg1 = "ADDON_LOADED", "GentleGC"; this = F; F.scripts.OnEvent()
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
local function clear() log = {} end
local function has(pat) return count(pat) > 0 end
print("1. without ClassicAPI's functions: the companion is asked for")
clear()
cmd("addons")
check("explains the companion", has("per%-addon memory needs"))
cmd("addons measure")
check("measure falls through to the companion text", has("per%-addon memory needs"))
-- a stand-in for ClassicAPI's profiling functions
local names = {}
local kbs = {}
local ADDONS = 20
for i = 1, ADDONS do names[i] = "Addon" .. i; kbs[i] = i * 100 end -- 100 KB .. 2000 KB
names[21] = "Blizzard_Thing"; kbs[21] = 0 -- Blizzard / not loaded: 0
local updates = 0
function GetNumAddOns() return table.getn(names) end
function GetAddOnInfo(i) return names[i] end
function UpdateAddOnMemoryUsage() updates = updates + 1 end
function GetAddOnMemoryUsage(i)
if i < 1 or i > table.getn(names) then error("bad index") end
return kbs[i]
end
print("2. with them: a live list, biggest first, top 15")
clear()
cmd("addons")
check("one update per listing", updates == 1)
check("header says where it comes from", has("addon memory now %(from ClassicAPI%)"))
check("total counts the 20 loaded addons", has("in 20 addons"))
check("zero rows (not loaded) are left out", not has("Blizzard_Thing"))
check("biggest is first", string.find(log[2], "Addon20") ~= nil)
check("15 rows shown", count("Addon%d+") == 15)
check("says how many more", has("and 5 more"))
check("no growth on the first check", not has("since the last check"))
print("3. 'all' lists every addon")
clear()
cmd("addons all")
check("20 rows", count("Addon%d+") == 20)
print("4. growth since the last check is shown")
kbs[3] = kbs[3] + 3000
kbs[4] = kbs[4] + 50 -- under 100 KB: not worth a note
clear()
cmd("addons all")
check("Addon3 grew", string.find(lastMatch("Addon3 "), "%+2%.9 MB since the last check") ~= nil or string.find(lastMatch("Addon3"), "since the last check") ~= nil)
check("Addon4's small change is not flagged", string.find(lastMatch("Addon4 "), "since the last check") == nil)
print("5. measure is not needed")
clear()
cmd("addons measure")
check("says so", has("no measuring needed"))
print("6. a client where the update call errors falls back")
local good = UpdateAddOnMemoryUsage
function UpdateAddOnMemoryUsage() error("nope") end
clear()
cmd("addons")
check("falls back to the companion text", has("per%-addon memory needs"))
UpdateAddOnMemoryUsage = good
print("7. everything reporting 0 falls back")
local saved = {}
for i = 1, table.getn(kbs) do saved[i] = kbs[i]; kbs[i] = 0 end
clear()
cmd("addons")
check("falls back", has("per%-addon memory needs"))
for i = 1, table.getn(kbs) do kbs[i] = saved[i] end
print("8. client info lines in /ggc")
clear()
cmd("status")
check("no cvar function: no client lines", not has("client:"))
local cvars = { scriptMemory = "0", scriptProfile = "0" }
function GetCVar(k) return cvars[k] end
-- the slash command prints them after the status block
clear()
cmd("status")
check("limit off", has("client: interface memory limit off"))
check("profiling off prints no warning", not has("CPU profiling is ON"))
cvars.scriptMemory = "65536"; cvars.scriptProfile = "1"
clear()
cmd("status")
check("limit shown", has("interface memory limit 64.0 MB"))
check("profiling warning", has("CPU profiling is ON"))
print(FAILED and "SOME TESTS FAILED" or "ALL TESTS PASSED")