perf: reduce CPU hotspots identified in profiler

- HBD.lua: Cache GetPlayerWorldPosition/GetPlayerZonePosition at 50ms
  intervals instead of hammering GetPlayerMapPosition() every frame.
  Invalidate cache on PLAYER_ENTERING_WORLD and ZONE_CHANGED_* events.
  Expected ~97% reduction in C API position calls (45,570 -> ~1,200 / 10min).

- zoneDB.lua: Replace O(n) linear scan in GetAreaIdByUiMapId with an
  O(1) reverse lookup cache (uiMapIdToAreaIdCache) built at Initialize().
  Cache is kept in sync by ApplyCustomZones and name-match fallback now
  caches its result so subsequent calls are also O(1).

- Tooltip.lua: Throttle GameTooltip OnUpdate hook to 100ms intervals
  (was firing every frame at 60-144 Hz). Added _tooltipLastText cache
  to avoid redundant GetText() + CountTooltip() calls when nothing changed.

- QuestieArrow.lua: Hoist _HasMissingCompletedFlag, _GetCompleteIconType,
  _CollectFinisherSpawns, and _CollectObjective out of _CollectQuestTargets
  to module-level functions. These were re-created as closures on every
  UpdateNearestTargets call (1 Hz). Shared per-cycle context is published
  via _arrow_* module upvalues to avoid closure capture overhead.

- QuestieLearnerComms.lua: Four micro-optimizations:
  (1) Reduce ProcessQueues ticker 0.2s -> 0.5s (still 7x faster than
      minChatInterval of 3.5s).
  (2) Cache hidden channel ID at init; lazy refresh on disconnect.
  (3) Drop LibDeflate compress level 9 -> 1 (fraction of CPU cost).
  (4) O(1) messageCacheCount counter replaces O(n) pairs() size scan.
This commit is contained in:
Xurkon
2026-03-29 16:42:43 -05:00
parent 2d9d83f265
commit 05ea31bc53
5 changed files with 288 additions and 142 deletions
+170 -83
View File
@@ -45,6 +45,12 @@ local driverFrame = nil
local sortedTargets = {}
local hasManualTarget = false
-- Shared context written by UpdateNearestTargets, read by hoisted helpers.
-- Avoids closure allocation on every call.
local _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
local _arrow_quest -- current quest being processed by the hoisted helpers
local lastPopulateByQuestId = {}
local function _IsArrowEnabled()
@@ -373,6 +379,156 @@ local function EnsureDriverFrame()
end)
end
-- ---------------------------------------------------------------------------
-- Hoisted helpers for UpdateNearestTargets.
-- These were previously closures recreated on every call; now they are
-- module-level functions that read shared upvalue state set each cycle.
-- ---------------------------------------------------------------------------
local function _HasMissingCompletedFlag(list)
if not list then return false end
for _, obj in pairs(list) do
if obj and obj.Completed == nil then
return true
end
end
return false
end
local function _GetCompleteIconType(quest)
local iconType = Questie.ICON_TYPE_COMPLETE
if QuestieDB and QuestieDB.IsActiveEventQuest and QuestieDB.IsActiveEventQuest(quest.Id) then
iconType = Questie.ICON_TYPE_EVENTQUEST_COMPLETE
elseif QuestieDB and QuestieDB.IsPvPQuest and QuestieDB.IsPvPQuest(quest.Id) then
iconType = Questie.ICON_TYPE_PVPQUEST_COMPLETE
elseif quest.IsRepeatable then
iconType = Questie.ICON_TYPE_REPEATABLE_COMPLETE
end
return iconType
end
local function _CollectFinisherSpawns(finisher, quest)
if not finisher then return end
local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local autoLogic, pZone, pMap = _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
local iconPath = ResolveIconTexture(_GetCompleteIconType(quest))
if finisher.spawns then
for finisherZone, spawns in pairs(finisher.spawns) do
if finisherZone and spawns then
for _, coords in ipairs(spawns) do
if coords and coords[1] and coords[2] then
if coords[1] == -1 or coords[2] == -1 then
local dungeonLocation = ZoneDB:GetDungeonLocation(finisherZone)
if dungeonLocation then
for _, value in ipairs(dungeonLocation) do
local zone = value[1]
local x = value[2]
local y = value[3]
if not (autoLogic and zone ~= pZone and zone ~= pMap) then
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId and x and y then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = x, y = y, uiMapId = uiMapId, title = quest.name, questLevel = quest.level, iconPath = iconPath, distance = dist,
})
end
end
end
end
end
end
else
if not (autoLogic and finisherZone ~= pZone and finisherZone ~= pMap) then
local x = coords[1]
local y = coords[2]
local uiMapId = ZoneDB:GetUiMapIdByAreaId(finisherZone)
if uiMapId then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = x, y = y, uiMapId = uiMapId, title = quest.name, questLevel = quest.level, iconPath = iconPath, distance = dist,
})
end
end
end
end
end
end
end
end
end
end
if finisher.waypoints then
for zone, waypoints in pairs(finisher.waypoints) do
if not (autoLogic and zone ~= pZone and zone ~= pMap) then
if waypoints and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] then
local x = waypoints[1][1][1]
local y = waypoints[1][1][2]
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId and x and y then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = x, y = y, uiMapId = uiMapId, title = quest.name, questLevel = quest.level, iconPath = iconPath, distance = dist,
})
end
end
end
end
end
end
end
end
local function _CollectObjective(objective, quest)
if not objective or not objective.spawnList then return end
if QuestieQuest.ShouldHideObjective(objective) then return end
if objective.Completed == true or objective.Completed == 1 then return end
if objective.Needed and objective.Collected
and type(objective.Needed) == "number" and type(objective.Collected) == "number"
and objective.Collected >= objective.Needed then
return
end
local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local autoLogic, pZone, pMap = _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
for _, spawnData in pairs(objective.spawnList) do
if spawnData and spawnData.Spawns then
for zone, spawns in pairs(spawnData.Spawns) do
if not (autoLogic and zone ~= pZone and zone ~= pMap) then
for _, spawn in pairs(spawns) do
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId then
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(spawn[1] / 100.0, spawn[2] / 100.0, uiMapId)
if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if dist then
if tInst ~= pInst then dist = 500000 + dist * 100 end
table.insert(sortedTargets, {
x = spawn[1], y = spawn[2], uiMapId = uiMapId,
title = quest.name, questLevel = quest.level,
iconPath = ResolveIconTexture(objective.Icon) or ResolveIconTexture(spawnData and spawnData.Icon),
distance = dist,
})
end
end
end
end
end
end
end
end
end
-- Gather all objectives from tracked quests and sort by distance
function QuestieArrow:UpdateNearestTargets()
-- Don't override manual targets with auto-updates
@@ -399,34 +555,23 @@ function QuestieArrow:UpdateNearestTargets()
local playerZoneId = QuestiePlayer:GetCurrentZoneId()
local playerUiMapId = QuestiePlayer:GetCurrentUiMapId()
-- Publish context for hoisted helper functions (avoids closure allocation every call)
_arrow_playerX, _arrow_playerY, _arrow_playerInstance = playerX, playerY, playerInstance
_arrow_usingAutoLogic = usingAutoLogic
_arrow_playerZoneId, _arrow_playerUiMapId = playerZoneId, playerUiMapId
local function _CollectQuestTargets(quest)
if not quest then
return
end
if not quest then return end
-- Avoid spamming QuestieQuest:PopulateQuestLogInfo (it can trigger marker rebuilds and flicker).
-- Only populate when objective completion flags are missing, and throttle per quest id.
if QuestieQuest and QuestieQuest.PopulateQuestLogInfo and quest.Id then
local needsPopulate = false
if not quest.Objectives and not quest.SpecialObjectives then
needsPopulate = true
else
local function _HasMissingCompletedFlag(list)
if not list then return false end
for _, obj in pairs(list) do
if obj and obj.Completed == nil then
return true
end
end
return false
end
if _HasMissingCompletedFlag(quest.Objectives) or _HasMissingCompletedFlag(quest.SpecialObjectives) then
needsPopulate = true
end
elseif _HasMissingCompletedFlag(quest.Objectives) or _HasMissingCompletedFlag(quest.SpecialObjectives) then
needsPopulate = true
end
if needsPopulate then
local now = GetTime()
local last = lastPopulateByQuestId[quest.Id] or 0
@@ -438,21 +583,10 @@ function QuestieArrow:UpdateNearestTargets()
end
local isComplete = quest.isComplete or (QuestieDB.IsComplete(quest.Id) == 1)
if isComplete then
quest.isComplete = true
end
if isComplete then quest.isComplete = true end
local function _GetCompleteIconType()
local iconType = Questie.ICON_TYPE_COMPLETE
if QuestieDB and QuestieDB.IsActiveEventQuest and QuestieDB.IsActiveEventQuest(quest.Id) then
iconType = Questie.ICON_TYPE_EVENTQUEST_COMPLETE
elseif QuestieDB and QuestieDB.IsPvPQuest and QuestieDB.IsPvPQuest(quest.Id) then
iconType = Questie.ICON_TYPE_PVPQUEST_COMPLETE
elseif quest.IsRepeatable then
iconType = Questie.ICON_TYPE_REPEATABLE_COMPLETE
end
return iconType
end
-- (hoisted) _GetCompleteIconType, _CollectFinisherSpawns, _CollectObjective
-- are module-level functions; no closures created here.
local function _CollectFinisherSpawns(finisher)
if not finisher then
@@ -549,53 +683,6 @@ function QuestieArrow:UpdateNearestTargets()
end
end
local function _CollectObjective(objective)
if not objective or not objective.spawnList then
return
end
if QuestieQuest.ShouldHideObjective(objective) then
return
end
if objective.Completed == true or objective.Completed == 1 then
return
end
-- If the objective is numerically fulfilled but the server hasn't sent the Completed flag yet
if objective.Needed and objective.Collected and type(objective.Needed) == "number" and type(objective.Collected) == "number" then
if objective.Collected >= objective.Needed then
return
end
end
for _, spawnData in pairs(objective.spawnList) do
if spawnData and spawnData.Spawns then
for zone, spawns in pairs(spawnData.Spawns) do
-- Auto Logic: Hide distant quests (different zone)
if not (usingAutoLogic and zone ~= playerZoneId and zone ~= playerUiMapId) then
for _, spawn in pairs(spawns) do
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId then
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(spawn[1] / 100.0, spawn[2] / 100.0, uiMapId)
if targetX and targetY and targetInstance then
local dist = HBD:GetWorldDistance(targetInstance, playerX, playerY, targetX, targetY)
if dist then
if targetInstance ~= playerInstance then
dist = 500000 + dist * 100
end
table.insert(sortedTargets, {
x = spawn[1], y = spawn[2], uiMapId = uiMapId, title = quest.name, questLevel = quest.level,
iconPath = ResolveIconTexture(objective.Icon) or ResolveIconTexture(spawnData and spawnData.Icon), distance = dist,
})
end
end
end
end
end
end
end
end
end
-- Main Logic Route for this quest target
@@ -608,7 +695,7 @@ function QuestieArrow:UpdateNearestTargets()
finisher = QuestieDB:GetObject(quest.Finisher.Id)
end
_CollectFinisherSpawns(finisher)
_CollectFinisherSpawns(finisher, quest)
end
-- If the quest is complete, do not add normal objectives to the arrow!
return
@@ -616,12 +703,12 @@ function QuestieArrow:UpdateNearestTargets()
if quest.Objectives then
for _, objective in pairs(quest.Objectives) do
_CollectObjective(objective)
_CollectObjective(objective, quest)
end
end
if quest.SpecialObjectives then
for _, objective in pairs(quest.SpecialObjectives) do
_CollectObjective(objective)
_CollectObjective(objective, quest)
end
end
end
+23 -15
View File
@@ -65,7 +65,10 @@ local rateLimitQueue = {}
-- Deduplication & Quarantine
local messageCache = {}
local messageCacheCount = 0 -- O(1) counter; avoids pairs() scan on every message
local incomingMessageQueue = {}
-- Cached hidden channel ID (avoids GetChannelName every ProcessQueues tick)
local _hiddenChannelId = 0
-- Sender Trust System
local senderTrust = {}
@@ -124,10 +127,11 @@ local function IsDuplicateMessage(serializedData)
if messageCache[hash] then return true end
local count = 0
for _ in pairs(messageCache) do count = count + 1 end
if count > 500 then
-- O(1) size tracking via explicit counter
messageCacheCount = messageCacheCount + 1
if messageCacheCount > 500 then
messageCache = {}
messageCacheCount = 0
end
messageCache[hash] = true
@@ -149,9 +153,11 @@ function QuestieLearnerComms:Initialize()
ChatFrame_RemoveChannel(DEFAULT_CHAT_FRAME, hiddenChannelName)
DebugLog("CRITICAL", "Joined hidden data-sharing channel: " .. hiddenChannelName)
end
-- Cache for use in ProcessQueues (avoids GetChannelName every tick)
_hiddenChannelId = GetChannelName(hiddenChannelName) or 0
-- Process incoming/outgoing queues
QuestieCompat.C_Timer.NewTicker(0.2, function() _QuestieLearnerComms:ProcessQueues() end)
QuestieCompat.C_Timer.NewTicker(0.5, function() _QuestieLearnerComms:ProcessQueues() end)
-- Start Reinforcement Loop (every 60 seconds)
QuestieCompat.C_Timer.NewTicker(60, function() _QuestieLearnerComms:ProcessReinforcement() end)
@@ -209,7 +215,7 @@ function QuestieLearnerComms:BroadcastLearnedData(op, entityType, entityId, data
return
end
serialized = err
local compressed = LibDeflate:CompressDeflate(serialized, {level = 9})
local compressed = LibDeflate:CompressDeflate(serialized, {level = 1})
local encoded = LibDeflate:EncodeForPrint(compressed)
-- 3. Broadcast (Token Bucket logic handled in QueueMessage)
@@ -224,28 +230,30 @@ function _QuestieLearnerComms:ProcessQueues()
-- 1. Refill Tokens
local now = GetTime()
local elapsed = now - lastTokenUpdate
currentTokens = math.min(bucketCapacity, currentTokens + (elapsed * tokenRefillRate))
currentTokens = math_min(bucketCapacity, currentTokens + (elapsed * tokenRefillRate))
lastTokenUpdate = now
-- 2. Drain Outgoing Queue
if table.getn(rateLimitQueue) > 0 and currentTokens >= 1 and (now - lastChatMessageTime) >= minChatInterval then
local msg = table.remove(rateLimitQueue, 1)
if table_getn(rateLimitQueue) > 0 and currentTokens >= 1 and (now - lastChatMessageTime) >= minChatInterval then
local msg = table_remove(rateLimitQueue, 1)
currentTokens = currentTokens - 1
lastChatMessageTime = now
-- Send via Hidden Channel (Global reach)
local channelId = GetChannelName(hiddenChannelName)
if channelId > 0 then
SendChatMessage(msg, "CHANNEL", nil, channelId)
-- Use cached channel ID; refresh lazily if 0 (e.g. after disconnect)
if _hiddenChannelId == 0 then
_hiddenChannelId = GetChannelName(hiddenChannelName) or 0
end
DebugLog("DEVELOP", "Broadcasted message. Tokens left: " .. math.floor(currentTokens))
if _hiddenChannelId > 0 then
SendChatMessage(msg, "CHANNEL", nil, _hiddenChannelId)
end
DebugLog("DEVELOP", "Broadcasted message. Tokens left: " .. math_floor(currentTokens))
end
-- 3. Process Incoming Queue (Combat Aware)
local processCount = InCombatLockdown() and 2 or 6
for i = 1, processCount do
if table.getn(incomingMessageQueue) == 0 then break end
local rawMsg = table.remove(incomingMessageQueue, 1)
if table_getn(incomingMessageQueue) == 0 then break end
local rawMsg = table_remove(incomingMessageQueue, 1)
_QuestieLearnerComms:ProcessRawMessage(rawMsg.text, rawMsg.sender)
end
end
+24 -12
View File
@@ -34,6 +34,12 @@ QuestieTooltips.lookupKeysByQuestId = {
}
local MAX_GROUP_MEMBER_COUNT = 6
-- Throttle: limit the OnUpdate object-tooltip check to 10 times per second.
-- Without this, the callback fires every frame (60144 Hz) and hammers GetText()
-- + CountTooltip() even when the tooltip hasn't changed.
local _tooltipUpdateInterval = 0.10
local _tooltipLastUpdate = 0
local _tooltipLastText = ""
local _InitObjectiveTexts
@@ -491,28 +497,34 @@ function QuestieTooltips:Initialize()
end
end)
-- Fired whenever the cursor hovers something with a tooltip. And then on every frame
-- Fired whenever the cursor hovers something with a tooltip. And then on every frame.
-- Throttled to _tooltipUpdateInterval (100ms) to avoid per-frame C API pressure.
GameTooltip:HookScript("OnUpdate", function(self)
if QuestiePlayer.numberOfGroupMembers > MAX_GROUP_MEMBER_COUNT then
-- When in a raid, we want as little code running as possible
return
end
local now = GetTime()
if now - _tooltipLastUpdate < _tooltipUpdateInterval then return end
_tooltipLastUpdate = now
if (not self.IsForbidden) or (not self:IsForbidden()) then
--Because this is an OnUpdate we need to check that it is actually not a Unit or Item to think its a
-- Only fires for non-unit, non-item, non-spell tooltips (i.e. object/world tooltips)
local uName, unit = self:GetUnit()
local iName, link = self:GetItem()
local sName, spell = self:GetSpell()
if (uName == nil and unit == nil and iName == nil and link == nil and sName == nil and spell == nil) and (
QuestieTooltips.lastGametooltip ~= GameTooltipTextLeft1:GetText() or
(not QuestieTooltips.lastGametooltipCount) or
_QuestieTooltips:CountTooltip() < QuestieTooltips.lastGametooltipCount
or QuestieTooltips.lastGametooltipType ~= "object"
) and (not self.ShownAsMapIcon) then -- We are hovering over a Questie map icon which adds it's own tooltip
_QuestieTooltips:AddObjectDataToTooltip(GameTooltipTextLeft1:GetText())
QuestieTooltips.lastGametooltipCount = _QuestieTooltips:CountTooltip()
if (uName == nil and unit == nil and iName == nil and link == nil and sName == nil and spell == nil) and (not self.ShownAsMapIcon) then
local currentText = GameTooltipTextLeft1:GetText()
if currentText ~= _tooltipLastText
or (not QuestieTooltips.lastGametooltipCount)
or _QuestieTooltips:CountTooltip() < QuestieTooltips.lastGametooltipCount
or QuestieTooltips.lastGametooltipType ~= "object" then
_QuestieTooltips:AddObjectDataToTooltip(currentText)
QuestieTooltips.lastGametooltipCount = _QuestieTooltips:CountTooltip()
_tooltipLastText = currentText
end
QuestieTooltips.lastGametooltip = currentText
end
QuestieTooltips.lastGametooltip = GameTooltipTextLeft1:GetText()
end
end)
end