diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a3816..d07004a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/GentleGC.lua b/GentleGC.lua index 1e67805..4b9ac0b 100644 --- a/GentleGC.lua +++ b/GentleGC.lua @@ -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 diff --git a/GentleGC.toc b/GentleGC.toc index 33f7450..8c748da 100644 --- a/GentleGC.toc +++ b/GentleGC.toc @@ -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 diff --git a/README.md b/README.md index 3ec7bcc..a0df51d 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 `, `/ggc garbage|headroom `, `/ggc garbagepct <%>`, `/ggc pressure <%>` (share of the room, default 70), `/ggc slow ` (careful mode, default 100), `/ggc allocwarn ` @@ -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 diff --git a/tests/README.md b/tests/README.md index 3da7feb..3fd163c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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) | diff --git a/tests/test_native_memory.lua b/tests/test_native_memory.lua new file mode 100644 index 0000000..083aa9a --- /dev/null +++ b/tests/test_native_memory.lua @@ -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")