diff --git a/Docs/PERFORMANCE.md b/Docs/PERFORMANCE.md new file mode 100644 index 0000000..83002a4 --- /dev/null +++ b/Docs/PERFORMANCE.md @@ -0,0 +1,60 @@ +# Performance notes + +## Player cache (`ShaguTweaks_player_cache`) + +The account-wide player cache feeds Social Colors, Chat Levels and the +nameplate/chat class colors. It is read at every login and `/reload` and +written at every logout, so its size is a direct load-time and memory cost. +Earlier versions let it grow without limit; one account reached 4.1 MB. + +`libs/libunitscan.lua` now owns expiry and runs it once per session, at the +first loading screen: + +| Entry | Kept for | +|---|---| +| Player whose class is known | 180 days after last sighting | +| Player whose class was never learned | 90 days after last sighting | +| Whole cache | at most 10,000 players; the oldest sightings go first | + +- Every sighting refreshes the age: mouseover, target, nameplate, party, + raid, guild roster, friend list, `/who` and chat. +- Guild members and friends are refreshed by their roster events every + session, so they do not expire while they stay in the guild or friend list. +- Entries saved before this change have no age. They are dated from the + cache's existing grace start (`players_cleanup`), as the old Social Colors + cleanup did. The first login after the update can remove many entries. +- A player's GUID is saved only while the class is unknown. Such entries are + resolved later with `GetPlayerInfoByGUID` when the engine knows the GUID. + +The separate 180-day cleanup in Social Colors is removed. It ran only every +180 days and only while that module was enabled. + +## Event handling + +- Chat: a speaker whose class is known and who spoke within the hour needs + no GUID lookup or write. +- Guild roster: a full rescan runs at most once a minute unless the member + count changed. Many addons request the roster every few seconds. +- Mouseover, target, nameplate and group sightings skip `UnitGUID` when + `UnitClass` already gave the class. +- A name missing from the cache queries ClassicAPI's player cache once + instead of twice. + +## Range Color + +The module shared Blizzard's `rangeTimer` and tested it against 0.2 seconds. +Once the timer had reset, that test was always true, so the full range check +ran on every frame for every ranged action button. It now keeps its own +0.2-second timer. Blizzard's own range check runs again, so the hotkey text +turns red out of range, as in the default UI. + +## Not changed + +- Health Numbers and Big Health use one-shot deferred updates only. +- Both castbars update only while visible; Energy Tick hides at full mana + out of combat. +- Free Slot Count counts slots once per `BAG_UPDATE_DELAYED`. Disable it if a + bag addon already shows the count. + +Nothing here has been measured in game yet. To compare before and after, see +[Measuring performance](CLASSICAPI_UPDATES.md#measuring-performance). diff --git a/README.md b/README.md index 3f456ba..4b7e204 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ For detailed provenance and licensing information, see: - [PROJECT_IDENTITY.md](PROJECT_IDENTITY.md) - [Docs/ASSET_PROVENANCE.md](Docs/ASSET_PROVENANCE.md) - [Docs/MODERN_MANA.md](Docs/MODERN_MANA.md) +- [Docs/PERFORMANCE.md](Docs/PERFORMANCE.md) — player cache limits and other performance notes - [Docs/UPSTREAM_README.md](Docs/UPSTREAM_README.md) — the Dusk fork's README, kept for reference - [LICENSES/](LICENSES) diff --git a/libs/libunitscan.lua b/libs/libunitscan.lua index c2150fd..7eb7960 100644 --- a/libs/libunitscan.lua +++ b/libs/libunitscan.lua @@ -5,7 +5,15 @@ local API = ShaguTweaks.API local units = { players = {}, mobs = {} } local queue = { } -local PASSIVE_CACHE_TTL = 90 * 24 * 60 * 60 + +-- Player cache limits. ShaguTweaks_player_cache is parsed at every login and +-- written at every logout, so its size is a direct load-time and memory cost. +local DAY = 24 * 60 * 60 +local PASSIVE_CACHE_TTL = 90 * DAY -- players whose class was never learned +local KNOWN_CACHE_TTL = 180 * DAY -- every other player, counted from last sighting +local MAX_CACHED_PLAYERS = 10000 -- oldest sightings go first above this +local CHAT_REFRESH = 60 * 60 -- re-resolve a known chat speaker at most hourly +local GUILD_SCAN_INTERVAL = 60 -- seconds between full guild roster scans local libunitscan = CreateFrame("Frame", "ShaguTweaksUnitScan", UIParent) @@ -289,21 +297,46 @@ end ShaguTweaks.GetPlayerCache = EnsurePlayerCache -local function EnrichPlayerFromClassicAPI(name) - if not name or not API or not API.GetCachedPlayerInfoByName then return end +-- Same player name, ignoring case. Compare first so the common exact match +-- does not create two lowercase strings. +local function SameName(a, b) + return a == b or string.lower(a) == string.lower(b) +end - local _, class, _, _, _, _, _, guid = API.GetCachedPlayerInfoByName(name) - class = class and class ~= "" and class or nil - guid = guid and guid ~= "" and guid or nil - if not class and not guid then return end +-- New player records carry a timestamp from the start, so the login cleanup +-- can age every entry, not only chat-only ones. +local function GetOrCreatePlayer(name) + local data = units.players[name] + if not data then + data = { lastseen_ts = time() } + units.players[name] = data + end + return data +end - -- Keep ShaguTweaks keyed by the exact name it already knows. This avoids - -- duplicate SavedVariable entries if another cache normalizes name casing. - units.players[name] = units.players[name] or {} - units.players[name].class = class or units.players[name].class - units.players[name].guid = guid or units.players[name].guid +-- Store a class. A GUID is kept only while the class is unknown: it is then +-- the one way to learn it later through GetPlayerInfoByGUID. Known players +-- need no GUID in SavedVariables, and nothing else reads it. +local function SetPlayerClass(data, class, guid) + if class then data.class = class end + if data.class then + data.guid = nil + elseif guid then + data.guid = guid + end +end - return name +-- A class-less entry that kept its GUID: ask the engine's name cache. +local function EnrichPlayerFromStoredGUID(name, data) + if not data or data.class or not data.guid then return end + if not API or not API.GetPlayerInfoByGUID then return end + + local _, class, _, _, _, apiName = API.GetPlayerInfoByGUID(data.guid) + if not class or class == "" or not apiName or apiName == "" then return end + if not SameName(apiName, name) then return end + + SetPlayerClass(data, class) + return true end local function EnrichPlayerFromCurrentChat(name) @@ -319,11 +352,9 @@ local function EnrichPlayerFromCurrentChat(name) local _, class, _, _, _, apiName = API.GetPlayerInfoByGUID(guid) class = class and class ~= "" and class or nil if not apiName or apiName == "" or not class then return end - if string.lower(apiName) ~= string.lower(name) then return end + if not SameName(apiName, name) then return end - units.players[name] = units.players[name] or {} - units.players[name].class = class - units.players[name].guid = guid + SetPlayerClass(GetOrCreatePlayer(name), class, guid) return name end @@ -333,34 +364,41 @@ function ShaguTweaks.GetUnitData(name, active) EnsurePlayerCache() - -- Persistent ClassicAPI data can answer class-only queries without making - -- another SavedVariables record. Keep our level and social history fields. - if (not units.players[name] or not units.players[name].class) - and API and API.HasPersistentPlayerCache and API.HasPersistentPlayerCache() then - local _, class, _, _, _, resolvedName, _, guid = API.GetCachedPlayerInfoByName(name) - if class and class ~= "" and resolvedName - and string.lower(resolvedName) == string.lower(name) + local data = units.players[name] + + if (not data or not data.class) and not EnrichPlayerFromStoredGUID(name, data) then + -- ClassicAPI's player cache by name. Query it once: the persistent-storage + -- check below and the plain enrichment both use the same result. + local class, guid, resolvedName, _ + if API and API.GetCachedPlayerInfoByName then + _, class, _, _, _, resolvedName, _, guid = API.GetCachedPlayerInfoByName(name) + class = class and class ~= "" and class or nil + guid = guid and guid ~= "" and guid or nil + end + + -- Persistent ClassicAPI data can answer class-only queries without making + -- another SavedVariables record. Keep our level and social history fields. + if class and resolvedName and SameName(resolvedName, name) + and API.HasPersistentPlayerCache and API.HasPersistentPlayerCache() and API.RememberPlayerInfo(resolvedName, class, guid) then - local data = units.players[name] return class, data and data.level, data and data.elite, true end - end - if not units["players"][name] then - EnrichPlayerFromClassicAPI(name) - if not units["players"][name] then - EnrichPlayerFromCurrentChat(name) + -- Keep ShaguTweaks keyed by the exact name it already knows. This avoids + -- duplicate SavedVariable entries if another cache normalizes name casing. + if class or guid then + data = GetOrCreatePlayer(name) + SetPlayerClass(data, class, guid) end - elseif not units["players"][name].class then - EnrichPlayerFromClassicAPI(name) - if not units["players"][name].class then + + if not data or not data.class then EnrichPlayerFromCurrentChat(name) + data = units.players[name] end end - if units["players"][name] then - local ret = units["players"][name] - return ret.class, ret.level, ret.elite, true + if data then + return data.class, data.level, data.elite, true end if units["mobs"][name] then @@ -372,13 +410,28 @@ function ShaguTweaks.GetUnitData(name, active) end end -local function AddData(db, name, class, level, elite, guid) +local function AddData(db, name, class, level, elite, guid, now) if not name then return end - units[db][name] = units[db][name] or {} - units[db][name].class = class or units[db][name].class - units[db][name].level = level or units[db][name].level - units[db][name].elite = elite or units[db][name].elite - units[db][name].guid = guid or units[db][name].guid + + local data = units[db][name] + if not data then + data = {} + units[db][name] = data + end + + if level then data.level = level end + if elite then data.elite = elite end + + if db == "players" then + SetPlayerClass(data, class, guid) + -- Every sighting refreshes the cache age. "lastseen" stays the chat and + -- friend-list date that Social Colors shows; this is only for expiry. + data.lastseen_ts = now or time() + else + if class then data.class = class end + if guid then data.guid = guid end + end + queue[name] = nil end @@ -387,6 +440,17 @@ local function RememberPlayer(name) EnsurePlayerCache() + local now = time() + local data = units.players[name] + + -- Busy channels repeat the same speakers. A speaker whose class is known + -- and who was already seen in chat within the hour needs no GUID lookup + -- and no write. + if data and data.class and type(data.lastseen) == "number" + and now - data.lastseen < CHAT_REFRESH then + return + end + -- ClassicAPI exposes the GUID of the CHAT_MSG_* sender synchronously. -- Resolve class from the engine's existing name cache only: this is a pure -- cache read and never sends a /who or any other network query. @@ -401,29 +465,68 @@ local function RememberPlayer(name) -- Skip only NEW chat-only records after native persistent storage succeeds. -- Existing records may contain levels/history and must retain timestamps. - if not units.players[name] and API and API.RememberPlayerInfo + if not data and API and API.RememberPlayerInfo and API.RememberPlayerInfo(name, class, guid) then return end - units.players[name] = units.players[name] or {} - units.players[name].class = class or units.players[name].class - units.players[name].guid = guid or units.players[name].guid - local now = time() - units.players[name].lastseen = now - units.players[name].lastseen_ts = now + data = GetOrCreatePlayer(name) + SetPlayerClass(data, class, guid) + data.lastseen = now + data.lastseen_ts = now end -local function PrunePassivePlayers() +-- One pass per session, at the first PLAYER_ENTERING_WORLD. +local function PrunePlayers(realmdb) + local players = units.players local now = time() - for name, data in pairs(units.players) do - -- Normalize any empty legacy/API class token back to an unknown value. - if data and data.class == "" then data.class = nil end + -- Entries written before every sighting carried a timestamp have no age. + -- Date them from the cache's grace start, as the old Social Colors cleanup + -- did, so they expire on schedule unless the player is seen again. + local graceStart = tonumber(realmdb.players_cleanup) + if not graceStart then + graceStart = now + realmdb.players_cleanup = now + end - -- Keep every player whose class was learned. Only anonymous/passive chat - -- entries expire, and only when they carry our numeric last-seen timestamp. - if data and not data.class and data.lastseen_ts and - now - data.lastseen_ts > PASSIVE_CACHE_TTL then - units.players[name] = nil + local count = 0 + for name, data in pairs(players) do + if type(data) ~= "table" then + players[name] = nil + else + -- Normalize any empty legacy/API class token back to an unknown value. + if data.class == "" then data.class = nil end + if data.class then data.guid = nil end + + local seen = GetPlayerTimestamp(data) + if not seen then + seen = graceStart + data.lastseen_ts = graceStart + end + + local ttl = data.class and KNOWN_CACHE_TTL or PASSIVE_CACHE_TTL + if now - seen > ttl then + players[name] = nil + else + count = count + 1 + end + end + end + + -- Hard cap: drop the oldest sightings. Guild members and friends are + -- refreshed by their roster events every session, so they stay. + if count > MAX_CACHED_PLAYERS then + local stamps, n = {}, 0 + for _, data in pairs(players) do + n = n + 1 + stamps[n] = GetPlayerTimestamp(data) or 0 + end + table.sort(stamps) + + local cutoff = stamps[n - MAX_CACHED_PLAYERS] + for name, data in pairs(players) do + if (GetPlayerTimestamp(data) or 0) <= cutoff then + players[name] = nil + end end end end @@ -446,6 +549,15 @@ local passiveChatEvents = { } local useNameplateUnitEvents = API and API.nameplateevents +local prunedThisSession = false +local lastGuildScan, lastGuildCount + +-- A GUID is only stored for players whose class is unknown, so skip the call +-- when UnitClass already answered. +local function GUIDIfClassless(unit, class) + if class or not API or not API.UnitGUID then return end + return API.UnitGUID(unit) +end libunitscan:RegisterEvent("PLAYER_ENTERING_WORLD") libunitscan:RegisterEvent("PLAYER_LOGOUT") @@ -479,18 +591,21 @@ libunitscan:SetScript("OnEvent", function() -- Ensure the saved database is already bound even if another module queried -- it earlier while restoring UI state. From here onward SavedVariables are -- settled, so future cache reads can skip migration checks completely. - EnsurePlayerCache() + local _, realmdb = EnsurePlayerCache() cacheMigrationFinalized = true - -- One cheap cleanup pass at login. Known classes are kept forever. - PrunePassivePlayers() + -- PLAYER_ENTERING_WORLD also fires at every loading screen. Expire and + -- cap the cache once per session only. + if not prunedThisSession then + prunedThisSession = true + PrunePlayers(realmdb) + end -- update own character details local name = UnitName("player") local _, class = UnitClass("player") local level = UnitLevel("player") - local guid = API and API.UnitGUID and API.UnitGUID("player") - AddData("players", name, class, level, nil, guid) + AddData("players", name, class, level, nil, GUIDIfClassless("player", class)) elseif event == "NAME_PLATE_UNIT_ADDED" then -- ClassicAPI exposes an exact nameplateN unit token here. Cache players @@ -502,8 +617,7 @@ libunitscan:SetScript("OnEvent", function() local name = UnitName(unit) local _, class = UnitClass(unit) local level = UnitLevel(unit) - local guid = API and API.UnitGUID and API.UnitGUID(unit) - AddData("players", name, class, level, nil, guid) + AddData("players", name, class, level, nil, GUIDIfClassless(unit, class)) end elseif passiveChatEvents[event] then @@ -513,48 +627,60 @@ libunitscan:SetScript("OnEvent", function() elseif event == "FRIENDLIST_UPDATE" then local name, class, level + local now = time() for i = 1, GetNumFriends() do name, level, class = GetFriendInfo(i) class = L["class"][class] or nil -- friendlist updates due to friend going off-line return level 0, let's not overwrite good older values level = level > 0 and level or nil - AddData("players", name, class, level) + AddData("players", name, class, level, nil, nil, now) end elseif event == "GUILD_ROSTER_UPDATE" then - local name, class, level, _ - for i = 1, GetNumGuildMembers() do - name, _, _, level, class = GetGuildRosterInfo(i) - class = L["class"][class] or nil - AddData("players", name, class, level) + -- Many addons request the roster every few seconds. A class and level + -- cache gains nothing from rescanning a large guild each time, so scan + -- at most once a minute unless the member count changed. + local count = GetNumGuildMembers() + local now = time() + if count > 0 and not (lastGuildScan and count == lastGuildCount + and now - lastGuildScan < GUILD_SCAN_INTERVAL) then + lastGuildScan, lastGuildCount = now, count + + local name, class, level, _ + for i = 1, count do + name, _, _, level, class = GetGuildRosterInfo(i) + class = L["class"][class] or nil + AddData("players", name, class, level, nil, nil, now) + end end elseif event == "RAID_ROSTER_UPDATE" then local name, class, SubGroup, level, _ + local now = time() for i = 1, GetNumRaidMembers() do name, _, SubGroup, level, class = GetRaidRosterInfo(i) class = L["class"][class] or nil - local guid = API and API.UnitGUID and API.UnitGUID("raid" .. i) - AddData("players", name, class, level, nil, guid) + AddData("players", name, class, level, nil, GUIDIfClassless("raid" .. i, class), now) end elseif event == "PARTY_MEMBERS_CHANGED" then local name, class, level, unit, _ + local now = time() for i = 1, GetNumPartyMembers() do unit = "party" .. i _, class = UnitClass(unit) name = UnitName(unit) level = UnitLevel(unit) - local guid = API and API.UnitGUID and API.UnitGUID(unit) - AddData("players", name, class, level, nil, guid) + AddData("players", name, class, level, nil, GUIDIfClassless(unit, class), now) end elseif event == "WHO_LIST_UPDATE" then local name, class, level, _ + local now = time() for i = 1, GetNumWhoResults() do name, _, level, _, class, _ = GetWhoInfo(i) class = L["class"][class] or nil - AddData("players", name, class, level) + AddData("players", name, class, level, nil, nil, now) end elseif event == "UPDATE_MOUSEOVER_UNIT" or event == "PLAYER_TARGET_CHANGED" then @@ -564,8 +690,7 @@ libunitscan:SetScript("OnEvent", function() _, class = UnitClass(scan) level = UnitLevel(scan) name = UnitName(scan) - local guid = API and API.UnitGUID and API.UnitGUID(scan) - AddData("players", name, class, level, nil, guid) + AddData("players", name, class, level, nil, GUIDIfClassless(scan, class)) else _, class = UnitClass(scan) elite = UnitClassification(scan) diff --git a/mods/actionbar-range-color.lua b/mods/actionbar-range-color.lua index 82981d8..6dd3665 100644 --- a/mods/actionbar-range-color.lua +++ b/mods/actionbar-range-color.lua @@ -50,14 +50,28 @@ local function GetActionRange(action) return NormalizeRange(IsActionInRange(action)) end +local RANGE_INTERVAL = TOOLTIP_UPDATE_TIME or 0.2 + module.enable = function(self) hooksecurefunc("ActionButton_OnUpdate", function(elapsed) + -- Blizzard sets rangeTimer only for actions that have a range. Use our + -- own countdown instead of sharing it: decrementing rangeTimer here and + -- testing "<= 0.2" ran this check on every frame for every ranged + -- button, and kept Blizzard's own range check from ever firing. if this.rangeTimer then - this.rangeTimer = this.rangeTimer - elapsed - if this.rangeTimer <= 0.2 then + local timer = (this.shaguRangeTimer or 0) - (elapsed or 0) + if timer > 0 then + this.shaguRangeTimer = timer + else + this.shaguRangeTimer = RANGE_INTERVAL local action = ActionButton_GetPagedID(this) local inRange = GetActionRange(action) - local icon = _G[this:GetName() .. "Icon"] + -- Look the icon up once instead of building its name each time. + local icon = this.shaguRangeIcon + if not icon then + icon = _G[this:GetName() .. "Icon"] + this.shaguRangeIcon = icon + end if icon then if inRange == 0 then @@ -78,8 +92,6 @@ module.enable = function(self) end end end - - this.rangeTimer = TOOLTIP_UPDATE_TIME end end end, true) diff --git a/mods/social-colors.lua b/mods/social-colors.lua index b88626f..778ff25 100644 --- a/mods/social-colors.lua +++ b/mods/social-colors.lua @@ -10,7 +10,6 @@ local GetExpansion = ShaguTweaks.GetExpansion local cmatch = ShaguTweaks.cmatch local rgbhex = ShaguTweaks.rgbhex local friendinfo = gsub(gsub(FRIENDS_LEVEL_TEMPLATE,"%%s","%%s %%s"),"%%d","%%s") -local PLAYER_CACHE_MAX_AGE = 180 * 24 * 60 * 60 local function TouchPlayer(entry) if not entry then return end @@ -56,43 +55,6 @@ local function GetCachedFriendDisplay(data, name) return cname, clevel end -local function CleanupPlayerDB(playerdb, realmdb) - if not time then return end - - local now = time() - local lastCleanup = tonumber(realmdb.players_cleanup) or 0 - - -- Start the 180-day grace period without touching existing entries. - if lastCleanup == 0 then - realmdb.players_cleanup = now - return - end - - if now - lastCleanup < PLAYER_CACHE_MAX_AGE then return end - - for name, data in pairs(playerdb) do - if type(data) ~= "table" then - playerdb[name] = nil - else - -- The shared cache also contains libunitscan-only entries. Prefer the - -- numeric Social Colors timestamp, then the passive scanner timestamp. - -- Entries without either timestamp have survived a full grace period and - -- can safely expire just like the old per-character cache did. - local lastSeen = type(data.lastseen) == "number" and data.lastseen or nil - local lastSeenTs = tonumber(data.lastseen_ts) - if lastSeenTs and (not lastSeen or lastSeenTs > lastSeen) then - lastSeen = lastSeenTs - end - - if not lastSeen or now - lastSeen >= PLAYER_CACHE_MAX_AGE then - playerdb[name] = nil - end - end - end - - realmdb.players_cleanup = now -end - local module = ShaguTweaks:register({ title = T["Social Colors"], description = T["Show class colors in Who, Guild, Friends and Chat."], @@ -182,16 +144,10 @@ module.enable = function(self) HookChatColors() local socialmod = CreateFrame("Frame", "ShaguTweaksSocialMod", UIParent) - socialmod:RegisterEvent("PLAYER_ENTERING_WORLD") + -- Cache expiry lives in libs/libunitscan.lua, which runs whether or not + -- this module is enabled. socialmod:RegisterEvent("CHAT_MSG_SYSTEM") socialmod:SetScript("OnEvent", function() - if event == "PLAYER_ENTERING_WORLD" then - local playerdb, realmdb = GetPlayerCache() - CleanupPlayerDB(playerdb, realmdb) - this:UnregisterEvent("PLAYER_ENTERING_WORLD") - return - end - local currentdb = GetPlayerCache() local name = cmatch(arg1, _G.ERR_FRIEND_ONLINE_SS) or cmatch(arg1, _G.ERR_FRIEND_OFFLINE_S) if name and currentdb[name] then