Bound the player cache and stop per-frame range checks

- libunitscan: expire the shared player cache once per session (180 days
  for known classes, 90 for class-less, at most 10,000 players). Every
  sighting now stamps an age, so roster and mouseover entries can expire.
- libunitscan: keep a player's GUID only while the class is unknown and
  resolve such entries later with GetPlayerInfoByGUID.
- libunitscan: skip the GUID lookup for known chat speakers seen within
  the hour, throttle full guild rescans to once a minute, skip UnitGUID
  when UnitClass answered, and query ClassicAPI's player cache once per
  miss instead of twice.
- Social Colors: drop its separate 180-day cleanup.
- Range Color: use its own 0.2 s timer. Sharing Blizzard's rangeTimer ran
  the check every frame for every ranged button.
- Docs/PERFORMANCE.md: document the cache rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwusM4i1CYaHQAYixJ7
This commit is contained in:
2026-11-02 00:09:59 +00:00
co-authored by Claude Opus 5.5
parent e4c7741226
commit 1ae8398a2d
5 changed files with 283 additions and 129 deletions
+60
View File
@@ -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).
+1
View File
@@ -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)
+203 -78
View File
@@ -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)
+17 -5
View File
@@ -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)
+2 -46
View File
@@ -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