diff --git a/CHANGELOG.md b/CHANGELOG.md index 87267ab..98a6616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [Unreleased] - Performance Refactor Branches + +### Performance + +- **[QuestieLearner - Kill/Pin Refresh Throttling]** Debounced learner-triggered map-pin refreshes so heavy kill streaks do not redraw pins on every event. Added a maximum wait cap so batched updates still flush predictably instead of being pushed out forever by constant activity. +- **[QuestieLearner - Bystander Kill Suppression]** Changed visible nearby `UNIT_DIED` handling so kills from other players can update short-lived correlation evidence without immediately running full learner injection or pin refresh work. +- **[QuestieLearner - PARTY_KILL Event-Order Fix]** Fixed an edge case where a `UNIT_DIED` debounce entry could suppress a later authoritative `PARTY_KILL` for the same GUID. The debounce now tracks event type and allows the player's/group's kill event through while still suppressing true duplicates. +- **[QuestieLearner - Immediate Spawn Pin Rendering]** Learner kill evidence now becomes spawn coordinates immediately in learner mode so learned NPC kills can spawn map pins without waiting for the later confidence merge path. +- **[QuestieLearner - Live Performance Options]** Added Advanced-tab controls for learner intensity, pin refresh delay, maximum pin refresh wait, minimum kills before learned pins, and live NPC update delay so users can tune the system for low-end PCs or heavy-activity zones. +- **[QuestieComms - User-Controlled Intensity]** Added Advanced-tab QuestieComms controls, including a full disable switch and live throttles for queue processing, quest-state broadcasts, and bulk sync pacing. +- **[QuestieComms - Disable Gate Fix]** Scoped the comms enable helper so the disable switch no longer calls a nil global and every send/process entry point consistently respects the setting. +- **[Arrow - Low-End Performance Controls]** Added live Arrow update throttles to reduce repeated nearest-target and coordinate work while preserving existing arrow behavior. +- **[Measured Hot Paths - Phase 3]** Landed measured optimizations on the phase 3 branch for literal localization caching, available quest redraw batching, `QuestieDB.IsDoable` batch reads, hot profile aliases, `GetTime()` hoists, NPC fallback lookup caching, and validate-cache allocation cleanup. + +### Bug Fixes + +- **[Error Suppression - Debug Modes]** Moved missing quest and other non-fatal database/error spam out of normal chat output and into Questie debug-critical/developer output. Fatal startup failures remain loud. +- **[Tooltip Data Precedence]** Updated tooltip handling so QuestieLearner defers to AscensionDB-owned tooltip/objective data instead of hiding or replacing server-plugin data for active quests. +- **[QuestieQuest - Unavailable Quest Guard]** Guarded the available-quest draw thread so unresolved quest IDs are skipped safely instead of crashing the thread, and deduped the skip log so the same missing quest does not spam every redraw. +- **[QuestieLearner - Data Source Mode Cohesion]** Reworked the Auto / Learner / Static / Neither data-source modes so switching between them applies live and consistently. A single missing static sub-table (npc/object/quest/item) no longer locks the whole addon into learner mode — only the genuinely-missing store falls back. Static and Neither no longer silently fall back to learner records, switching modes now clears the per-zone quest cache, and the mode switch drives a full pin/tracker redraw through `QuestieQuest:SmoothReset()` (the previous redraw call imported a mis-named module and silently did nothing). +- **[QuestieLearner - Pin Refresh Latency]** Collapsed a redundant second debounce stage on the live-learn pin-refresh path. Newly learned spawns now redraw within a single debounce window instead of waiting out both the NPC live-update delay and a separate pin-refresh delay, roughly halving perceived pin-update latency in the Balanced and Low presets. Also fixed a latent infinite timer re-arm that could occur once the pin flush was triggered directly. +- **[Map - Dense Pin Clustering Aggressiveness Knob]** Re-implemented density-adaptive clustering for crowded kill objectives, now controlled by a new "Dense pin clustering aggressiveness" slider on the Advanced tab (0 = show every pin, higher = tighter consolidation where many pins share a zone). Coincident pins are always deduplicated regardless of the clustering settings, and the intentional per-zone (Sunstrider Isle) and object-icon range overrides are preserved. +- **[QuestieLearner - One Pin Per Spawn, Not Per Kill]** Fixed learner kill evidence rendering a separate pin for every kill. Because kill coordinates are the player's position at kill time (and respawns carry fresh GUIDs), repeated kills at the same spot drifted just enough to dodge the exact-match dedup. The immediate learner-mode spawn builder now merges evidence within a small radius into one pin per physical spawn, and the weighted spawn-evidence merge groups kills by coordinate bucket so a single spot can actually accumulate enough evidence to clear the confidence threshold. The merge distance is exposed as a new "Spawn Pin Dedup Radius" slider on the Advanced tab (0 = show every distinct position, higher = fewer/tighter pins per spawn) which redraws live. + +### Branch / Release Notes + +- The most complete performance candidate is not yet a single branch: `questie-learner-comms-improvements` has the latest learner/comms/arrow controls, while `phase3-measured-perf` has the broader measured hot-path changes. +- Before release, merge into a dedicated integration branch, remove or revalidate the stale `QuestieMap.ProcessQueue` profile-local commit that was reverted on `main`, fix the unrelated Arrow asset test expectation mismatch, and validate in game with minimap open, nearby-player kills, looting, comms toggles, Arrow throttles, and learner-only/static-only mode switching. + ## [1.6.3] ### Bug Fixes @@ -109,7 +138,7 @@ ### Maintenance -- **Removed Turtle WoW references** — Deleted Turtle WoW row from the server compatibility table and removed Turtle WoW load tip from installation instructions in README.md. Turtle WoW is shutting down and no longer has a supported plugin. +- **Removed unsupported legacy server references** — Deleted an unsupported legacy server row from the server compatibility table and removed its load tip from installation instructions in README.md. ### Documentation @@ -282,7 +311,7 @@ Addressed micro-stutters and FPS drops (190 → sub-100) reported during high-fr - **[Feature — Enhanced Logging]** Improved Stage 3 initialization logging to provide detailed reporting on custom data injection. Developers can now verify the exact number of NPCs, Objects, and Items injected by plugins directly from the `DEVELOP` log. - **[Fix — Ascension Zone Mapping]** Fixed a regression in `QuestieCompat` where `uiMapData` for Ascension-specific zones was not correctly propagating to the global mapping table, restoring map pin functionality for seasonal and custom zones. - **[Fix — QuestieLearner]** Centralized zone/area ID lookup in `l10n` module to prevent `GetAreaIdByLocalName` nil errors (Fixes Project Ebonhold runtime crash). -- **[Fix — Custom Server Compilation]** Fixed database compilation not running on custom servers (Ascension, Ebonhold, Turtle WoW, etc.) where plugins inject data after initial load. +- **[Fix — Custom Server Compilation]** Fixed database compilation not running on custom servers (Ascension, Ebonhold, etc.) where plugins inject data after initial load. - Modified `Modules/QuestieInit.lua` Stage1 to defer compilation to Stage3 for custom servers, ensuring plugins finish injecting data before compilation runs. - Added `l10n:Initialize()` and `QuestieCorrections:MinimalInit()` calls when deferring to Stage3, as Stage2 (`QuestieJourney:Initialize()`) requires `hiddenQuests` to be populated. - Added "Bronzebeard" and "Warcraft Reborn" to Ascension realm detection patterns in `Modules/QuestieServer.lua`. @@ -593,7 +622,7 @@ Addressed micro-stutters and FPS drops (190 → sub-100) reported during high-fr ### Core & Stability (v1.3.5) -- **[Lua 5.0]** Globally polyfilled `string.match` and `string.gmatch` using `string.find` and `string.gfind` to ensure universal compatibility with legacy WoW clients (e.g., Turtle WoW). +- **[Lua 5.0]** Globally polyfilled `string.match` and `string.gmatch` using `string.find` and `string.gfind` to ensure universal compatibility with legacy WoW clients. - **[AceTimer]** Patched embedded `AceTimer-3.0` instances in ElvUI and OG-RaidHelper to resolve `math.mod` errors on Lua 5.0 clients. - **[Colors]** Updated `CreateColor` polyfill with `SetRGB`, `SetRGBA`, `SetColor`, and `GetColor` methods. - **[Comm]** Improved cross-client data sharing stability. diff --git a/Database/QuestieDB.lua b/Database/QuestieDB.lua index 0e6fa78..cec7cb0 100644 --- a/Database/QuestieDB.lua +++ b/Database/QuestieDB.lua @@ -204,6 +204,132 @@ QuestieDB.NPCPointers = _dummyHandle.pointers QuestieDB.ObjectPointers = _dummyHandle.pointers QuestieDB.ItemPointers = _dummyHandle.pointers +-- Set by QuestieInit when the compiled/static base DB fails to load. +-- When this is true, learner should force itself on so the addon still works +-- even if the static database is unavailable or partially missing. +QuestieDB.baseDatabaseMissing = false +QuestieDB.baseDatabaseMissingKeys = {} + +-- The four core static stores. The base DB is only considered fully "missing" +-- (which forces learner mode) when EVERY one of these failed to load. +QuestieDB._baseDatabaseStores = { "npcData", "objectData", "questData", "itemData" } + +-- True only when a specific static store failed to load. Reads use this so a +-- single missing/mismatched store falls back to learner data for that store +-- alone, instead of locking the whole addon into learner mode. +function QuestieDB:IsStoreMissing(storeKey) + return QuestieDB.baseDatabaseMissingKeys and QuestieDB.baseDatabaseMissingKeys[storeKey] == true +end + +-- Clears every per-entity cache. Used when the data source mode changes so that +-- quest/npc/item/object AND per-zone results are rebuilt against the new mode. +function QuestieDB:ClearModeCaches() + _QuestieDB.questCache = {} + _QuestieDB.itemCache = {} + _QuestieDB.npcCache = {} + _QuestieDB.objectCache = {} + _QuestieDB.zoneCache = {} +end + +function QuestieDB:IsBaseDatabaseMissing() + if QuestieDB.baseDatabaseMissing ~= true then + return false + end + -- Only report the base DB as missing when EVERY core store failed. A partial + -- failure (e.g. itemData missing but npcData present) must not override the + -- user's Data Source Mode selection — per-read fallback handles the gaps. + local keys = QuestieDB.baseDatabaseMissingKeys + if not keys then + return false + end + local stores = QuestieDB._baseDatabaseStores + for i = 1, 4 do + if not keys[stores[i]] then + return false + end + end + return true +end + +local function _GetLearnerSettings() + local ld = Questie and Questie.dbLearner and Questie.dbLearner.global + return ld and ld.settings or nil +end + +local function _GetLearnerRecord(storeName, id) + local ld = Questie and Questie.dbLearner and Questie.dbLearner.global + if not ld then + return nil + end + + local store = ld[storeName] + if not store then + return nil + end + + return store[id] or store[tostring(id)] +end + +-- Map-percent radius used to collapse per-GUID kill evidence into one pin per +-- physical spawn. Kill coordinates are the player's position at kill time, so +-- repeated kills (and respawns, which carry fresh GUIDs) land on slightly +-- different coords. Without this merge, every kill would render its own pin. +-- A distance test (rather than a grid bucket) avoids the boundary artifact where +-- two near-identical coords straddle a cell edge and split into separate pins. +-- The radius is user-tunable via the learner "Spawn Pin Dedup Radius" knob; 0 +-- disables merging (every distinct coord shown). +local GUID_SPAWN_DEDUP_RADIUS = 4.0 + +local function _GetSpawnDedupRadius() + local settings = _GetLearnerSettings() + local r = settings and tonumber(settings.spawnDedupRadius) + if r and r >= 0 then + return r + end + return GUID_SPAWN_DEDUP_RADIUS +end + +local function _BuildSpawnTableFromGuidEvidence(evidence) + if type(evidence) ~= "table" then + return nil + end + + local radius = _GetSpawnDedupRadius() + local radiusSq = radius * radius + local spawns = {} + local hasEntries = false + for _, entry in pairs(evidence) do + if type(entry) == "table" and entry.zoneId and entry.x and entry.y then + local zoneId = tonumber(entry.zoneId) + local x = tonumber(entry.x) + local y = tonumber(entry.y) + if zoneId and x and y then + spawns[zoneId] = spawns[zoneId] or {} + local zoneSpawns = spawns[zoneId] + local exists = false + for _, coord in ipairs(zoneSpawns) do + local dx = coord[1] - x + local dy = coord[2] - y + if (dx * dx + dy * dy) <= radiusSq then + exists = true + break + end + end + if not exists then + zoneSpawns[#zoneSpawns + 1] = { x, y } + hasEntries = true + end + end + end + end + + if not hasEntries then + return nil + end + + return spawns +end + ---@type QuestieQuest local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest") ---@type QuestieQuestPrivate @@ -321,6 +447,19 @@ if not Item then end if not Item.CreateFromItemID then + local function SafeGetItemName(itemID) + local numericItemID = tonumber(itemID) + if not numericItemID or numericItemID == 0 then + return "item:" .. tostring(itemID) + end + + local ok, name = pcall(GetItemInfo, numericItemID) + if not ok then + return "item:" .. tostring(numericItemID) + end + return name or ("item:" .. tostring(numericItemID)) + end + function Item:CreateFromItemID(itemID) local obj = {} @@ -332,8 +471,7 @@ if not Item.CreateFromItemID then end function obj:GetItemName() - local name = GetItemInfo(itemID) - return name or ("item:" .. tostring(itemID)) + return SafeGetItemName(itemID) end return obj @@ -520,11 +658,24 @@ function QuestieDB:GetObject(objectId) return _QuestieDB.objectCache[objectId]; end - -- Try to get from compiled DB first - local rawdata = QuestieDB.QueryObject(objectId, QuestieDB._objectAdapterQueryOrder) + local settings = _GetLearnerSettings() + local mode = settings and settings.dataSourceMode or "auto" + local learnerRecord = _GetLearnerRecord("objects", objectId) - -- Check for overrides - local override = QuestieDB.objectDataOverrides and (QuestieDB.objectDataOverrides[objectId] or QuestieDB.objectDataOverrides[tostring(objectId)]) + local rawdata + local override + if mode == "learner" or QuestieDB:IsStoreMissing("objectData") then + rawdata = learnerRecord + override = nil + else + rawdata = QuestieDB.QueryObject(objectId, QuestieDB._objectAdapterQueryOrder) + if not rawdata and learnerRecord and mode == "auto" then + -- Only "auto" overlays learner data on top of the static DB. "static" + -- and "none" must never silently fall back to learner records. + rawdata = learnerRecord + end + override = QuestieDB.objectDataOverrides and (QuestieDB.objectDataOverrides[objectId] or QuestieDB.objectDataOverrides[tostring(objectId)]) + end if not rawdata and not override then Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB:GetObject] data not found for objectID:", objectId) @@ -566,8 +717,23 @@ function QuestieDB:GetItem(itemId) return _QuestieDB.itemCache[itemId]; end - local rawdata = QuestieDB.QueryItem(itemId, QuestieDB._itemAdapterQueryOrder) - local override = QuestieDB.itemDataOverrides and (QuestieDB.itemDataOverrides[itemId] or QuestieDB.itemDataOverrides[tostring(itemId)]) + local settings = _GetLearnerSettings() + local mode = settings and settings.dataSourceMode or "auto" + local learnerRecord = _GetLearnerRecord("items", itemId) + local rawdata + local override + if mode == "learner" or QuestieDB:IsStoreMissing("itemData") then + rawdata = learnerRecord + override = nil + else + rawdata = QuestieDB.QueryItem(itemId, QuestieDB._itemAdapterQueryOrder) + if not rawdata and learnerRecord and mode == "auto" then + -- Only "auto" overlays learner data on top of the static DB. "static" + -- and "none" must never silently fall back to learner records. + rawdata = learnerRecord + end + override = QuestieDB.itemDataOverrides and (QuestieDB.itemDataOverrides[itemId] or QuestieDB.itemDataOverrides[tostring(itemId)]) + end if not rawdata and not override then Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB:GetItem] data not found for itemID:", itemId) @@ -807,7 +973,9 @@ end function QuestieDB.GetSuppressedNPCs(zoneId) local suppressed = {} local ld = Questie.dbLearner.global - if ld and ld.settings and ld.settings.enabled and ld.settings.prioritizeMyData and ld.npcs then + local mode = ld and ld.settings and ld.settings.dataSourceMode or "auto" + if mode == "auto" or mode == "learner" then + if ld and ld.settings and ld.settings.enabled and ld.npcs then local threshold = ld.settings.minConfidencePins or 2 local npcId, entry = next(ld.npcs) while npcId do @@ -817,6 +985,7 @@ function QuestieDB.GetSuppressedNPCs(zoneId) end npcId, entry = next(ld.npcs, npcId) end + end end return suppressed end @@ -828,7 +997,9 @@ end function QuestieDB.GetSuppressedObjects(zoneId) local suppressed = {} local ld = Questie.dbLearner.global - if ld and ld.settings and ld.settings.enabled and ld.settings.prioritizeMyData and ld.objects then + local mode = ld and ld.settings and ld.settings.dataSourceMode or "auto" + if mode == "auto" or mode == "learner" then + if ld and ld.settings and ld.settings.enabled and ld.objects then local threshold = ld.settings.minConfidencePins or 2 local objId, entry = next(ld.objects) while objId do @@ -838,6 +1009,7 @@ function QuestieDB.GetSuppressedObjects(zoneId) end objId, entry = next(ld.objects, objId) end + end end return suppressed end @@ -1429,8 +1601,23 @@ function QuestieDB.GetQuest(questId, ...) -- /dump QuestieDB.GetQuest(867) return _QuestieDB.questCache[questId]; end - local rawdata = QuestieDB.QueryQuest(questId, QuestieDB._questAdapterQueryOrder) - local overrideData = QuestieDB.questDataOverrides and (QuestieDB.questDataOverrides[questId] or QuestieDB.questDataOverrides[tostring(questId)]) + local settings = _GetLearnerSettings() + local mode = settings and settings.dataSourceMode or "auto" + local learnerRecord = _GetLearnerRecord("quests", questId) + local rawdata + local overrideData + if mode == "learner" or QuestieDB:IsStoreMissing("questData") then + rawdata = learnerRecord + overrideData = nil + else + rawdata = QuestieDB.QueryQuest(questId, QuestieDB._questAdapterQueryOrder) + if not rawdata and learnerRecord and mode == "auto" then + -- Only "auto" overlays learner data on top of the static DB. "static" + -- and "none" must never silently fall back to learner records. + rawdata = learnerRecord + end + overrideData = QuestieDB.questDataOverrides and (QuestieDB.questDataOverrides[questId] or QuestieDB.questDataOverrides[tostring(questId)]) + end if (not rawdata) then rawdata = overrideData @@ -2029,8 +2216,23 @@ function QuestieDB:GetNPC(npcId) return _QuestieDB.npcCache[npcId] end - local rawdata = QuestieDB.QueryNPC(npcId, QuestieDB._npcAdapterQueryOrder) - local override = QuestieDB.npcDataOverrides and (QuestieDB.npcDataOverrides[npcId] or QuestieDB.npcDataOverrides[tostring(npcId)]) + local settings = _GetLearnerSettings() + local mode = settings and settings.dataSourceMode or "auto" + local learnerRecord = _GetLearnerRecord("npcs", npcId) + local rawdata + local override + if mode == "learner" or QuestieDB:IsStoreMissing("npcData") then + rawdata = learnerRecord + override = nil + else + rawdata = QuestieDB.QueryNPC(npcId, QuestieDB._npcAdapterQueryOrder) + if not rawdata and learnerRecord and mode == "auto" then + -- Only "auto" overlays learner data on top of the static DB. "static" + -- and "none" must never silently fall back to learner records. + rawdata = learnerRecord + end + override = QuestieDB.npcDataOverrides and (QuestieDB.npcDataOverrides[npcId] or QuestieDB.npcDataOverrides[tostring(npcId)]) + end if not rawdata and not override then Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB:GetNPC] data not found for npcID:", npcId) @@ -2060,6 +2262,14 @@ function QuestieDB:GetNPC(npcId) local friendlyToFaction = npc.friendlyToFaction npc.friendly = (not friendlyToFaction) and true or factionReactions[friendlyToFaction] + if (not npc.spawns or next(npc.spawns) == nil) and mode == "learner" then + local guidSpawns = rawdata[8] + local learnedSpawns = _BuildSpawnTableFromGuidEvidence(guidSpawns) + if learnedSpawns then + npc.spawns = learnedSpawns + end + end + _QuestieDB.npcCache[npcId] = npc return npc end diff --git a/Modules/Arrow/QuestieArrow.lua b/Modules/Arrow/QuestieArrow.lua index a886ce4..af12ad1 100644 --- a/Modules/Arrow/QuestieArrow.lua +++ b/Modules/Arrow/QuestieArrow.lua @@ -37,9 +37,9 @@ local ARROW_SHEET_ROWS = 12 local ARROW_TOTAL_CELLS = ARROW_SHEET_COLS * ARROW_SHEET_ROWS local ARROW_DEFAULT_STYLE = "arrow1" -local UPDATE_THROTTLE_SECONDS = 0.05 -local RECALC_NEAREST_SECONDS = 1.0 -local TRACKER_REFRESH_THROTTLE_SECONDS = 0.5 +local DEFAULT_UPDATE_THROTTLE_SECONDS = 0.05 +local DEFAULT_RECALC_NEAREST_SECONDS = 1.0 +local DEFAULT_TRACKER_REFRESH_THROTTLE_SECONDS = 0.5 ---@type Frame? local arrowFrame = nil @@ -57,6 +57,7 @@ local hasManualTarget = false 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 _arrow_zoneUiMapCache = {} local lastPopulateByQuestId = {} @@ -73,6 +74,36 @@ local function _IsArrowEnabled() return Questie.db.profile.arrowEnabled ~= false end +local function _GetProfileNumber(key, defaultValue, minValue, maxValue) + if not Questie or not Questie.db or not Questie.db.profile then + return defaultValue + end + + local value = Questie.db.profile[key] + if type(value) ~= "number" then + return defaultValue + end + if value < minValue then + return minValue + end + if value > maxValue then + return maxValue + end + return value +end + +local function _GetArrowUpdateThrottle() + return _GetProfileNumber("arrowUpdateThrottle", DEFAULT_UPDATE_THROTTLE_SECONDS, 0.03, 0.5) +end + +local function _GetArrowRecalcInterval() + return _GetProfileNumber("arrowRecalcInterval", DEFAULT_RECALC_NEAREST_SECONDS, 0.5, 10.0) +end + +local function _GetArrowTrackerRefreshThrottle() + return _GetProfileNumber("arrowTrackerRefreshThrottle", DEFAULT_TRACKER_REFRESH_THROTTLE_SECONDS, 0.25, 5.0) +end + local function _GetArrowScale() if not Questie or not Questie.db or not Questie.db.profile then return 1 @@ -151,6 +182,37 @@ local function _FormatDistance(dist) return string.format("%.1f%s", value, suffix) end +local function _SortTargetByDistance(a, b) + return a.distance < b.distance +end + +local function _GetUiMapIdForZone(zone) + if not zone then return nil end + local cached = _arrow_zoneUiMapCache[zone] + if cached ~= nil then + return cached + end + + local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone) + _arrow_zoneUiMapCache[zone] = uiMapId or false + return uiMapId +end + +local function _AddArrowTarget(x, y, uiMapId, title, questLevel, iconPath, worldX, worldY, worldInstance, distance) + table.insert(sortedTargets, { + x = x, + y = y, + uiMapId = uiMapId, + title = title, + questLevel = questLevel, + iconPath = iconPath, + worldX = worldX, + worldY = worldY, + worldInstance = worldInstance, + distance = distance, + }) +end + local function _GetArrowStyleKey() if not Questie or not Questie.db or not Questie.db.profile then return ARROW_DEFAULT_STYLE @@ -766,7 +828,7 @@ EnsureArrowFrame = function() objectiveFrame:Show() end - if (self._lastUpdate or 0) + UPDATE_THROTTLE_SECONDS > now then + if (self._lastUpdate or 0) + _GetArrowUpdateThrottle() > now then return end self._lastUpdate = now @@ -779,8 +841,12 @@ EnsureArrowFrame = function() return end - local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0, - target.uiMapId) + local targetX, targetY, targetInstance = target.worldX, target.worldY, target.worldInstance + if not targetX or not targetY or not targetInstance then + targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0, + target.uiMapId) + target.worldX, target.worldY, target.worldInstance = targetX, targetY, targetInstance + end if not targetX or not targetY or not targetInstance then if objectiveFrame then objectiveFrame.distance:SetText("Distance: --") @@ -859,7 +925,11 @@ EnsureArrowFrame = function() self.arrow:SetAlpha(alpha) if objectiveFrame then - objectiveFrame.distance:SetText("Distance: " .. _FormatDistance(dist)) + local distanceText = "Distance: " .. _FormatDistance(dist) + if objectiveFrame._lastDistanceText ~= distanceText then + objectiveFrame.distance:SetText(distanceText) + objectiveFrame._lastDistanceText = distanceText + end end end @@ -884,7 +954,7 @@ local function EnsureDriverFrame() driverFrame:SetScript("OnUpdate", function(self) local now = GetTime() - if (self._lastRecalc or 0) + RECALC_NEAREST_SECONDS < now then + if (self._lastRecalc or 0) + _GetArrowRecalcInterval() < now then self._lastRecalc = now if not _IsArrowEnabled() then if arrowFrame then @@ -944,16 +1014,16 @@ local function _CollectFinisherSpawns(finisher, quest) local y = value[3] -- Zone filtering disabled (zone ID vs area ID mismatch) if true then - local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone) + local uiMapId = _GetUiMapIdForZone(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, - }) + if tInst ~= pInst then + dist = 500000 + dist * 100 + end + _AddArrowTarget(x, y, uiMapId, quest.name, quest.level, iconPath, tX, tY, tInst, dist) end end end @@ -965,16 +1035,16 @@ local function _CollectFinisherSpawns(finisher, quest) if true then local x = coords[1] local y = coords[2] - local uiMapId = ZoneDB:GetUiMapIdByAreaId(finisherZone) + local uiMapId = _GetUiMapIdForZone(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, - }) + if tInst ~= pInst then + dist = 500000 + dist * 100 + end + _AddArrowTarget(x, y, uiMapId, quest.name, quest.level, iconPath, tX, tY, tInst, dist) end end end @@ -992,16 +1062,16 @@ local function _CollectFinisherSpawns(finisher, quest) 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) + local uiMapId = _GetUiMapIdForZone(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, - }) + if tInst ~= pInst then + dist = 500000 + dist * 100 + end + _AddArrowTarget(x, y, uiMapId, quest.name, quest.level, iconPath, tX, tY, tInst, dist) end end end @@ -1041,8 +1111,8 @@ local function _CollectObjective(objective, quest) print(string.format(" zone=%s filtered=%s (pZone=%s pMap=%s)", tostring(zone), tostring(zoneFiltered), tostring(pZone), tostring(pMap))) end if not zoneFiltered then + local uiMapId = _GetUiMapIdForZone(zone) for _, spawn in pairs(spawns) do - local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone) if debugCollect then print(string.format(" spawn=(%.1f,%.1f) uiMapId=%s", spawn[1], spawn[2], tostring(uiMapId))) end @@ -1051,16 +1121,24 @@ local function _CollectObjective(objective, quest) 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 + if tInst ~= pInst then + dist = 500000 + dist * 100 + end if debugCollect then print(string.format(" ADDED dist=%.0f", dist)) 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, - }) + _AddArrowTarget( + spawn[1], + spawn[2], + uiMapId, + quest.name, + quest.level, + ResolveIconTexture(objective.Icon) or ResolveIconTexture(spawnData and spawnData.Icon), + tX, + tY, + tInst, + dist + ) end end end @@ -1096,6 +1174,7 @@ function QuestieArrow:UpdateNearestTargets() local usingAutoLogic = Questie.db.profile.autoTrackQuests or not hasTracked local playerZoneId = QuestiePlayer:GetCurrentZoneId() local playerUiMapId = QuestiePlayer:GetCurrentUiMapId() + _arrow_zoneUiMapCache = {} -- Publish context for hoisted helper functions (avoids closure allocation every call) _arrow_playerX, _arrow_playerY, _arrow_playerInstance = playerX, playerY, playerInstance @@ -1209,7 +1288,7 @@ function QuestieArrow:UpdateNearestTargets() end -- Sort by distance - table.sort(sortedTargets, function(a, b) return a.distance < b.distance end) + table.sort(sortedTargets, _SortTargetByDistance) end function QuestieArrow:Refresh() @@ -1456,7 +1535,7 @@ function QuestieArrow:Initialize() end local now = GetTime() - if (lastTrackerRefresh + TRACKER_REFRESH_THROTTLE_SECONDS) > now then + if (lastTrackerRefresh + _GetArrowTrackerRefreshThrottle()) > now then return end diff --git a/Modules/Libs/QuestieLib.lua b/Modules/Libs/QuestieLib.lua index 5fc0cbe..de236ff 100644 --- a/Modules/Libs/QuestieLib.lua +++ b/Modules/Libs/QuestieLib.lua @@ -427,19 +427,25 @@ function QuestieLib:CacheItemNames(questId) if (quest and quest.ObjectiveData) then for _, objectiveDB in pairs(quest.ObjectiveData) do if objectiveDB.Type == "item" then - if not ((QuestieDB.ItemPointers or QuestieDB.itemData)[objectiveDB.Id]) then - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLib:CacheItemNames] Requesting item information for missing itemId:", objectiveDB.Id) - local item = Item:CreateFromItemID(objectiveDB.Id) + local itemId = objectiveDB.Id + if not itemId then + Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLib:CacheItemNames] Skipping item objective without itemId for quest:", questId) + elseif not ((QuestieDB.ItemPointers or QuestieDB.itemData)[itemId]) then + Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLib:CacheItemNames] Requesting item information for missing itemId:", itemId) + local item = Item:CreateFromItemID(itemId) item:ContinueOnItemLoad( function() local itemName = item:GetItemName() - if not QuestieDB.itemDataOverrides[objectiveDB.Id] then - QuestieDB.itemDataOverrides[objectiveDB.Id] = { itemName, { questId }, {}, {} } + if not itemId then + return + end + if not QuestieDB.itemDataOverrides[itemId] then + QuestieDB.itemDataOverrides[itemId] = { itemName, { questId }, {}, {} } else - QuestieDB.itemDataOverrides[objectiveDB.Id][1] = itemName + QuestieDB.itemDataOverrides[itemId][1] = itemName end Questie:Debug(Questie.DEBUG_DEVELOP, - "[QuestieLib:CacheItemNames] Created item information for item:", itemName, ":", objectiveDB.Id) + "[QuestieLib:CacheItemNames] Created item information for item:", itemName, ":", itemId) end) end end diff --git a/Modules/Libs/QuestieLoader.lua b/Modules/Libs/QuestieLoader.lua index 484e0b7..ec5e0df 100644 --- a/Modules/Libs/QuestieLoader.lua +++ b/Modules/Libs/QuestieLoader.lua @@ -1,5 +1,5 @@ -- Shim for Lua 5.2+ (Retail) where table.getn was removed. --- We cannot use the '#' length operator directly because Lua 5.0 (Turtle WoW) +-- We cannot use the '#' length operator directly because Lua 5.0 -- will trigger a compile-time syntax error parsing the file. -- Using loadstring bypasses the 5.0 compiler and safely injects the # operator in 5.2+. if not table.getn then @@ -19,7 +19,112 @@ if not math.mod then end end --- Shim for Lua 5.0 (Turtle WoW) where string.match is missing. +-- Shim for Lua 5.0 where the bit library may be missing. +-- Questie uses band/bor/bxor/lshift/rshift in core runtime code, so we provide +-- a pure-Lua fallback when the host does not expose one. +if not bit or not bit.band or not bit.bor or not bit.bxor or not bit.lshift or not bit.rshift then + local bitlib = bit or {} + local U32 = 4294967296 + local U32_MAX = 4294967295 + local floor = math.floor + local mod = math.mod or function(a, b) + return a - floor(a / b) * b + end + + local function normalizeU32(n) + n = tonumber(n) or 0 + n = floor(n) + if n < 0 then + n = U32 + mod(n, U32) + elseif n >= U32 then + n = mod(n, U32) + end + return n + end + + local function bitAt(n, mask) + return mod(floor(n / mask), 2) + end + + local function band32(a, b) + a = normalizeU32(a) + b = normalizeU32(b) + local result = 0 + local mask = 1 + for _ = 1, 32 do + if bitAt(a, mask) == 1 and bitAt(b, mask) == 1 then + result = result + mask + end + mask = mask * 2 + end + return normalizeU32(result) + end + + local function bor32(a, b) + a = normalizeU32(a) + b = normalizeU32(b) + local result = 0 + local mask = 1 + for _ = 1, 32 do + if bitAt(a, mask) == 1 or bitAt(b, mask) == 1 then + result = result + mask + end + mask = mask * 2 + end + return normalizeU32(result) + end + + local function bxor32(a, b) + a = normalizeU32(a) + b = normalizeU32(b) + local result = 0 + local mask = 1 + for _ = 1, 32 do + if bitAt(a, mask) ~= bitAt(b, mask) then + result = result + mask + end + mask = mask * 2 + end + return normalizeU32(result) + end + + local function lshift32(a, disp) + a = normalizeU32(a) + disp = tonumber(disp) or 0 + if disp <= 0 then + return normalizeU32(math.floor(a / (2 ^ (-disp)))) + elseif disp >= 32 then + return 0 + end + return normalizeU32(a * (2 ^ disp)) + end + + local function rshift32(a, disp) + a = normalizeU32(a) + disp = tonumber(disp) or 0 + if disp <= 0 then + return normalizeU32(a * (2 ^ (-disp))) + elseif disp >= 32 then + return 0 + end + return normalizeU32(floor(a / (2 ^ disp))) + end + + local function bnot32(a) + return normalizeU32(U32_MAX - normalizeU32(a)) + end + + bitlib.band = bitlib.band or band32 + bitlib.bor = bitlib.bor or bor32 + bitlib.bxor = bitlib.bxor or bxor32 + bitlib.lshift = bitlib.lshift or lshift32 + bitlib.rshift = bitlib.rshift or rshift32 + bitlib.bnot = bitlib.bnot or bnot32 + bit = bitlib + _G.bit = bitlib +end + +-- Shim for Lua 5.0 where string.match is missing. -- Supports up to 5 captures (sufficient for all Questie uses). if not string.match then string.match = function(str, pattern, init) @@ -41,7 +146,7 @@ if not string.gmatch then string.gmatch = string.gfind end --- Shim for Lua 5.0 (Turtle WoW) where select() was not yet implemented. +-- Shim for Lua 5.0 where select() was not yet implemented. -- Fix #7: The original used a `while n > 0` loop that never decremented n, -- making the loop body run exactly once before returning. Use a plain -- sequential block instead so the intent is obvious. @@ -57,6 +162,44 @@ if not select then end end +-- Shim for Lua 5.0 where strsplit is missing. +if not strsplit then + strsplit = function(separator, text, max) + if text == nil then + return nil + end + if separator == "" then + return text + end + + local results = {} + local resultCount = 0 + local startPos = 1 + local maxSplits = tonumber(max) + + while true do + if maxSplits and resultCount >= (maxSplits - 1) then + resultCount = resultCount + 1 + results[resultCount] = string.sub(text, startPos) + break + end + + local sepStart, sepEnd = string.find(text, separator, startPos, true) + if not sepStart then + resultCount = resultCount + 1 + results[resultCount] = string.sub(text, startPos) + break + end + + resultCount = resultCount + 1 + results[resultCount] = string.sub(text, startPos, sepStart - 1) + startPos = sepEnd + 1 + end + + return unpack(results, 1, resultCount) + end +end + -- The only public class except for Questie ---@class QuestieLoader QuestieLoader = QuestieLoader or {} @@ -136,4 +279,3 @@ function Questie:Colorize(str, color) -- Initial stub: just return the string without color or with basic color return str end - diff --git a/Modules/Libs/QuestiePluginAPI.lua b/Modules/Libs/QuestiePluginAPI.lua index 9ef4e45..a152996 100644 --- a/Modules/Libs/QuestiePluginAPI.lua +++ b/Modules/Libs/QuestiePluginAPI.lua @@ -21,7 +21,7 @@ function QuestiePluginAPI:HasPendingPlugins() return self.pendingPluginsCount > 0 end ---- Returns the flavor key of the loaded DB plugin ("WotLK", "Classic", "TBC", "Turtle", "Ascension", etc.) +--- Returns the flavor key of the loaded DB plugin ("WotLK", "Classic", "TBC", "Ascension", etc.) ---@return string|nil function QuestiePluginAPI:GetLoadedFlavor() return self.loadedDBFlavor @@ -276,7 +276,7 @@ function QuestiePlugin:InjectUiMapData(customUiMapData) end --- Signals that the plugin has finished loading. This automatically cleans up necessary caches. ----@param flavorKey string|nil Optional flavor label e.g. "WotLK", "Classic", "Turtle" +---@param flavorKey string|nil Optional flavor label e.g. "WotLK", "Classic", "Ascension" function QuestiePlugin:FinishLoading(flavorKey) local QuestieDB = QuestieLoader:ImportModule("QuestieDB") if not QuestieDB then diff --git a/Modules/Libs/QuestieSerializer.lua b/Modules/Libs/QuestieSerializer.lua index 46d90b4..654888b 100644 --- a/Modules/Libs/QuestieSerializer.lua +++ b/Modules/Libs/QuestieSerializer.lua @@ -6,6 +6,8 @@ local QuestieSerializer = QuestieLoader:CreateModule("QuestieSerializer"); ---@type QuestieStreamLib local QuestieStreamLib = QuestieLoader:ImportModule("QuestieStreamLib"); +local mod = math.mod + function QuestieSerializer:Hash(value) if not value or type(value) ~= "string" or (string.len(value) <= 0) then @@ -70,14 +72,14 @@ local function floatBitsToInt(n) else expo = expo + 0x7E mant = floor((mant * 2.0 - 1.0) * ldexp(0.5, 24)) - return _pack(sign + floor(expo / 0x2), (expo % 0x2) * 0x80 + floor(mant / 0x10000), floor(mant / 0x100) % 0x100, mant % 0x100) + return _pack(sign + floor(expo / 0x2), mod(expo, 0x2) * 0x80 + floor(mant / 0x10000), mod(floor(mant / 0x100), 0x100), mod(mant, 0x100)) end end local function intBitsToFloat(int) local b1, b2, b3, b4 = _unpack(int) local sign = b1 > 0x7F - local expo = (b1 % 0x80) * 0x2 + floor(b2 / 0x80) - local mant = ((b2 % 0x80) * 0x100 + b3) * 0x100 + b4 + local expo = mod(b1, 0x80) * 0x2 + floor(b2 / 0x80) + local mant = ((mod(b2, 0x80)) * 0x100 + b3) * 0x100 + b4 if sign then sign = -1 else diff --git a/Modules/Map/QuestieMapUtils.lua b/Modules/Map/QuestieMapUtils.lua index a92a440..a16cb1b 100644 --- a/Modules/Map/QuestieMapUtils.lua +++ b/Modules/Map/QuestieMapUtils.lua @@ -10,6 +10,11 @@ local HBD = QuestieCompat.HBD or LibStub("HereBeDragonsQuestie-2.0") local ZOOM_MODIFIER = 1; +-- Pins whose world-coordinate distance is within this epsilon are treated as the +-- same physical spot and always merged, independent of the clustering range. This +-- is the baseline pin deduplication that runs even when clustering is turned off. +QuestieMap.utils.COINCIDENT_EPSILON = 0.2; + -- All the speed we can get is worth it. local tinsert = table.insert local next = next @@ -146,7 +151,11 @@ function QuestieMap.utils:CalcHotzones(points, rangeR, count) -- Do not cluster icons if they have no coordinates and aX ~= 0 and aY ~= 0 and point2.worldX ~= 0 and point2.worldY ~= 0 then local distance = QuestieLib:Euclid(aX, aY, point2.worldX, point2.worldY) - if (distance < movingRange) then + -- Always deduplicate pins that sit on (essentially) the same spot, + -- even when clustering is disabled (movingRange == 0). This keeps two + -- icons from stacking on an identical coordinate regardless of the + -- clusterDensityAggressiveness / clusterLevelHotzone settings. + if (distance < movingRange) or (distance <= QuestieMap.utils.COINCIDENT_EPSILON) then point2.touched = true tinsert(notes, point2) end diff --git a/Modules/Network/QuestieComms.lua b/Modules/Network/QuestieComms.lua index 5369a64..13857d4 100644 --- a/Modules/Network/QuestieComms.lua +++ b/Modules/Network/QuestieComms.lua @@ -47,6 +47,7 @@ local suggestUpdate = true; -- forward declaration local _DoYell +local IsQuestieCommsEnabled --Not used, contains a list of hashes for quest, used to compare change. --_QuestieComms.questHashes = {}; @@ -189,6 +190,7 @@ end -- Local Functions -- function _QuestieComms:BroadcastQuestUpdate(questId) -- broadcast quest update to group or raid + if not IsQuestieCommsEnabled() then return end Questie:Debug(Questie.DEBUG_INFO, "[QuestieComms:BroadcastQuestUpdate] Questid", questId) if(questId) then local partyType = QuestiePlayer:GetGroupType() @@ -220,6 +222,7 @@ end -- Removes the quest from everyones external quest-log function _QuestieComms:BroadcastQuestRemove(questId) -- broadcast quest update to group or raid + if not IsQuestieCommsEnabled() then return end local partyType = QuestiePlayer:GetGroupType() Questie:Debug(Questie.DEBUG_COMMS, "[QuestieComms:BroadcastQuestRemove] QuestId:", questId, "partyType:", tostring(partyType)) if partyType then @@ -256,6 +259,106 @@ for class, index in pairs(_classToIndex) do _indexToClass[index] = class end +local DEFAULT_QUEST_LIST_PACKET_SIZE_LIMIT = 200 +local DEFAULT_QUEST_LIST_INITIAL_JITTER = 3 +local DEFAULT_QUEST_LIST_BLOCK_INTERVAL = 3 + +local function GetProfileNumber(key, defaultValue, minValue, maxValue) + if not Questie or not Questie.db or not Questie.db.profile then + return defaultValue + end + + local value = Questie.db.profile[key] + if type(value) ~= "number" then + return defaultValue + end + if value < minValue then + return minValue + end + if value > maxValue then + return maxValue + end + return value +end + +local function GetQuestListPacketSizeLimit() + return GetProfileNumber("questieCommsQuestListPacketSize", DEFAULT_QUEST_LIST_PACKET_SIZE_LIMIT, 100, 500) +end + +local function GetQuestListInitialJitter() + return GetProfileNumber("questieCommsQuestListInitialJitter", DEFAULT_QUEST_LIST_INITIAL_JITTER, 0, 10) +end + +local function GetQuestListBlockInterval() + return GetProfileNumber("questieCommsQuestListBlockInterval", DEFAULT_QUEST_LIST_BLOCK_INTERVAL, 0.5, 10) +end + +IsQuestieCommsEnabled = function() + if not Questie or not Questie.db or not Questie.db.profile then + return true + end + return Questie.db.profile.questieCommsEnabled ~= false +end + +local function GetSerializedPacketSize(packet) + return string.len(QuestieSerializer:Serialize(packet)) +end + +local function QueuePush(queue, queueState, value) + queueState.tail = queueState.tail + 1 + queue[queueState.tail] = value +end + +local function QueuePop(queue, queueState) + if queueState.head > queueState.tail then + queueState.head = 1 + queueState.tail = 0 + return nil + end + + local value = queue[queueState.head] + queue[queueState.head] = nil + queueState.head = queueState.head + 1 + + if queueState.head > queueState.tail then + queueState.head = 1 + queueState.tail = 0 + end + + return value +end + +local function GetQuestDataPacketV2Size(questId) + local questObject = QuestieDB.GetQuest(questId) + local rawObjectives = QuestLogCache.GetQuestObjectives(questId) -- DO NOT MODIFY THE RETURNED TABLE + if (not rawObjectives) or (not questObject) or (not questObject.Objectives) or (not next(questObject.Objectives)) then + return 0 + end + + local questPacket = {} + local offset = 1 + local count = 0 + + questPacket[offset] = questId + local countOffset = offset + 1 + offset = offset + 2 + + for objectiveIndex, objective in pairs(rawObjectives) do -- DO NOT MODIFY THE RETURNED TABLE + local objectiveData = questObject.Objectives[objectiveIndex] + if objectiveData then + questPacket[offset] = objectiveData.Id + questPacket[offset + 1] = string.byte(string.sub(objective.type, 1, 1)) + questPacket[offset + 2] = objective.numFulfilled + questPacket[offset + 3] = objective.numRequired + offset = offset + 4 + count = count + 1 + end + end + + questPacket[countOffset] = count + return GetSerializedPacketSize(questPacket) +end + function QuestieComms:PopulateQuestDataPacketV2_noclass_renameme(questId, quest, offset) local questObject = QuestieDB.GetQuest(questId); @@ -472,6 +575,7 @@ QuestieComms._yellQueue = {} QuestieComms._isYelling = false function QuestieComms:YellProgress(questId) + if not IsQuestieCommsEnabled() then return end if Questie.db.profile.disableYellComms or badYellLocations[C_Map.GetBestMapForUnit("player")] or QuestiePlayer.numberOfGroupMembers > 4 then return end @@ -489,10 +593,12 @@ end _QuestieComms._isBroadcasting = false _QuestieComms._needsNewBroadcast = false _QuestieComms._nextBroadcastData = {} +_QuestieComms._nextBroadcastDataState = { head = 1, tail = 0 } function _QuestieComms:BroadcastQuestLog(eventName, sendMode, targetPlayer) -- broadcast quest update to group or raid + if not IsQuestieCommsEnabled() then return end if _QuestieComms._isBroadcasting then - tinsert(_QuestieComms._nextBroadcastData, {eventName, sendMode, targetPlayer}) + QueuePush(_QuestieComms._nextBroadcastData, _QuestieComms._nextBroadcastDataState, {eventName, sendMode, targetPlayer}) return end local partyType = QuestiePlayer:GetGroupType() @@ -543,33 +649,37 @@ function _QuestieComms:BroadcastQuestLog(eventName, sendMode, targetPlayer) -- b end end) + local questListPacketSizeLimit = GetQuestListPacketSizeLimit() local rawQuestList = {} local blocks = {} + local blockState = { head = 1, tail = 0 } local entryCount = 0 local blockCount = 2 -- the extra tick allows checking tremove() == nil to set _isBroadcasting=false + local blockSerializedSize = 2 for _, entry in pairs(sorted) do local quest = QuestieComms:CreateQuestDataPacket(entry.questId); --print("[CommsSendOrder][Block " .. (blockCount - 1) .. "] " .. QuestieDB.QueryQuestSingle(entry.questId, "name")) - entryCount = entryCount + 1 - rawQuestList[quest.id] = quest; - if string.len(QuestieSerializer:Serialize(rawQuestList)) > 200 then--extra space for packet metadata and CTL stuff - rawQuestList[quest.id] = nil - tinsert(blocks, rawQuestList) - rawQuestList = { - [quest.id] = quest - } - entryCount = 1 + local questPacketSize = GetSerializedPacketSize(quest) + if entryCount ~= 0 and (blockSerializedSize + questPacketSize) > questListPacketSizeLimit then + QueuePush(blocks, blockState, rawQuestList) + rawQuestList = {} + entryCount = 0 + blockSerializedSize = 2 blockCount = blockCount + 1 end + + entryCount = entryCount + 1 + rawQuestList[quest.id] = quest; + blockSerializedSize = blockSerializedSize + questPacketSize end if entryCount ~= 0 then - tinsert(blocks, rawQuestList) -- add the last block + QueuePush(blocks, blockState, rawQuestList) -- add the last block _QuestieComms._isBroadcasting = true -- hopefully reduce server load by staggering responses - C_Timer.After(random() * 3, function() - C_Timer.NewTicker(3, function() - local block = tremove(blocks, 1) + C_Timer.After(random() * GetQuestListInitialJitter(), function() + C_Timer.NewTicker(GetQuestListBlockInterval(), function() + local block = QueuePop(blocks, blockState) if block then -- send the block local questPacket = _QuestieComms:CreatePacket(_QuestieComms.QC_ID_BROADCAST_FULL_QUESTLIST); @@ -593,7 +703,7 @@ function _QuestieComms:BroadcastQuestLog(eventName, sendMode, targetPlayer) -- b questPacket:write(); else _QuestieComms._isBroadcasting = false - local nextBroadcast = tremove(_QuestieComms._nextBroadcastData, 1) + local nextBroadcast = QueuePop(_QuestieComms._nextBroadcastData, _QuestieComms._nextBroadcastDataState) if nextBroadcast then _QuestieComms:BroadcastQuestLog(unpack(nextBroadcast)) end @@ -606,10 +716,12 @@ end _QuestieComms._isBroadcastingV2 = false _QuestieComms._nextBroadcastDataV2 = {} +_QuestieComms._nextBroadcastDataV2State = { head = 1, tail = 0 } function _QuestieComms:BroadcastQuestLogV2(eventName, sendMode, targetPlayer) -- broadcast quest update to group or raid + if not IsQuestieCommsEnabled() then return end if _QuestieComms._isBroadcastingV2 then - tinsert(_QuestieComms._nextBroadcastDataV2, {eventName, sendMode, targetPlayer}) + QueuePush(_QuestieComms._nextBroadcastDataV2, _QuestieComms._nextBroadcastDataV2State, {eventName, sendMode, targetPlayer}) return end local partyType = QuestiePlayer:GetGroupType() @@ -660,36 +772,41 @@ function _QuestieComms:BroadcastQuestLogV2(eventName, sendMode, targetPlayer) -- end end) + local questListPacketSizeLimit = GetQuestListPacketSizeLimit() local rawQuestList = {} local blocks = {} + local blockState = { head = 1, tail = 0 } local entryCount = 0 local blockCount = 2 -- the extra tick allows checking tremove() == nil to set _isBroadcasting=false + local blockSerializedSize = 2 local offset = 2 for _, entry in pairs(sorted) do --print("[CommsSendOrder][Block " .. (blockCount - 1) .. "] " .. QuestieDB.QueryQuestSingle(entry.questId, "name")) - entryCount = entryCount + 1 - - offset = QuestieComms:PopulateQuestDataPacketV2_noclass_renameme(entry.questId, rawQuestList, offset) - - if string.len(QuestieSerializer:Serialize(rawQuestList)) > 200 then--extra space for packet metadata and CTL stuff + local questPacketSize = GetQuestDataPacketV2Size(entry.questId) + if entryCount ~= 0 and (blockSerializedSize + questPacketSize) > questListPacketSizeLimit then rawQuestList[1] = entryCount - tinsert(blocks, rawQuestList) + QueuePush(blocks, blockState, rawQuestList) rawQuestList = {} entryCount = 0 + blockSerializedSize = 2 blockCount = blockCount + 1 offset = 2 end + + entryCount = entryCount + 1 + offset = QuestieComms:PopulateQuestDataPacketV2_noclass_renameme(entry.questId, rawQuestList, offset) + blockSerializedSize = blockSerializedSize + questPacketSize end if entryCount ~= 0 or blockCount ~= 2 then rawQuestList[1] = entryCount - tinsert(blocks, rawQuestList) -- add the last block + QueuePush(blocks, blockState, rawQuestList) -- add the last block _QuestieComms._isBroadcastingV2 = true -- hopefully reduce server load by staggering responses - C_Timer.After(random() * 3, function() - C_Timer.NewTicker(3, function() - local block = tremove(blocks, 1) + C_Timer.After(random() * GetQuestListInitialJitter(), function() + C_Timer.NewTicker(GetQuestListBlockInterval(), function() + local block = QueuePop(blocks, blockState) if block then -- send the block local questPacket = _QuestieComms:CreatePacket(_QuestieComms.QC_ID_BROADCAST_FULL_QUESTLISTV2); @@ -713,7 +830,7 @@ function _QuestieComms:BroadcastQuestLogV2(eventName, sendMode, targetPlayer) -- questPacket:write(); else _QuestieComms._isBroadcastingV2 = false - local nextBroadcast = tremove(_QuestieComms._nextBroadcastDataV2, 1) + local nextBroadcast = QueuePop(_QuestieComms._nextBroadcastDataV2, _QuestieComms._nextBroadcastDataV2State) if nextBroadcast then _QuestieComms:BroadcastQuestLogV2(unpack(nextBroadcast)) end @@ -726,6 +843,7 @@ end -- The "Hi" of questie, request others to send their questlog. function _QuestieComms:RequestQuestLog(eventName) -- broadcast quest update to group or raid + if not IsQuestieCommsEnabled() then return end local partyType = QuestiePlayer:GetGroupType() Questie:Debug(Questie.DEBUG_COMMS, "[QuestieComms] Message", eventName, "partyType:", tostring(partyType)) if partyType then @@ -942,6 +1060,7 @@ _QuestieComms.packets = { -- Renamed Write function function _QuestieComms:Broadcast(packet) + if not IsQuestieCommsEnabled() then return end -- If the priority is not set, it must not be very important if packet.writeMode ~= _QuestieComms.QC_WRITE_WHISPER and (QuestiePlayer.numberOfGroupMembers > 15 or UnitInBattleground("Player")) then -- dont broadcast to large raids @@ -981,6 +1100,7 @@ function _QuestieComms:Broadcast(packet) end function _QuestieComms:OnCommReceived(message, distribution, sender) + if not IsQuestieCommsEnabled() then return end pcall(_QuestieComms.OnCommReceived_unsafe, _QuestieComms, message, distribution, sender) end diff --git a/Modules/Network/QuestieLearnerComms.lua b/Modules/Network/QuestieLearnerComms.lua index 37d72d4..4804ab0 100644 --- a/Modules/Network/QuestieLearnerComms.lua +++ b/Modules/Network/QuestieLearnerComms.lua @@ -19,7 +19,6 @@ local math_min = math.min local math_floor = math.floor local math_random = math.random local table_insert = table.insert -local table_remove = table.remove local table_getn = table.getn -- Dev Logging Flags — defined first so all functions below can call DebugLog @@ -62,11 +61,15 @@ local lastTokenUpdate = GetTime() local minChatInterval = 3.5 local lastChatMessageTime = 0 local rateLimitQueue = {} +local rateLimitQueueHead = 1 +local rateLimitQueueTail = 0 -- Deduplication & Quarantine local messageCache = {} local messageCacheCount = 0 -- O(1) counter; avoids pairs() scan on every message local incomingMessageQueue = {} +local incomingMessageQueueHead = 1 +local incomingMessageQueueTail = 0 -- Cached hidden channel ID (avoids GetChannelName every ProcessQueues tick) local _hiddenChannelId = 0 @@ -138,6 +141,26 @@ local function IsDuplicateMessage(serializedData) return false end +local function GetLearnerSettings() + if Questie and Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings then + return Questie.dbLearner.global.settings + end + return {} +end + +local function GetCommsTuning() + local intensity = GetLearnerSettings().learnerCommsIntensity or "normal" + if intensity == "off" then + return false, 0, 999999, 0, 0 + elseif intensity == "low" then + return true, 4, 6.0, 2, 1 + elseif intensity == "fast" then + return true, 15, 1.5, 10, 4 + end + + return true, 9, 3.5, 6, 2 +end + function QuestieLearnerComms:Initialize() DebugLog("DEVELOP", "Initializing QuestieLearnerComms") @@ -192,6 +215,8 @@ function _QuestieLearnerComms:ProcessReinforcement() end function QuestieLearnerComms:BroadcastLearnedData(op, entityType, entityId, data) + local commsEnabled = GetCommsTuning() + if not commsEnabled then return end if not data or type(data) ~= "table" then return end -- 1. Create Payload (sanitize data to remove functions before serialization) @@ -223,10 +248,20 @@ function QuestieLearnerComms:BroadcastLearnedData(op, entityType, entityId, data end function _QuestieLearnerComms:QueueMessage(encodedMessage) - table.insert(rateLimitQueue, encodedMessage) + local commsEnabled = GetCommsTuning() + if not commsEnabled then return end + rateLimitQueueTail = rateLimitQueueTail + 1 + rateLimitQueue[rateLimitQueueTail] = encodedMessage end function _QuestieLearnerComms:ProcessQueues() + local commsEnabled, tunedBucketCapacity, tunedMinChatInterval, normalIncomingCount, combatIncomingCount = GetCommsTuning() + bucketCapacity = tunedBucketCapacity + tokenRefillRate = bucketCapacity / bucketWindow + minChatInterval = tunedMinChatInterval + currentTokens = math_min(bucketCapacity, currentTokens) + if not commsEnabled then return end + -- 1. Refill Tokens local now = GetTime() local elapsed = now - lastTokenUpdate @@ -234,8 +269,14 @@ function _QuestieLearnerComms:ProcessQueues() 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 rateLimitQueueHead <= rateLimitQueueTail and currentTokens >= 1 and (now - lastChatMessageTime) >= minChatInterval then + local msg = rateLimitQueue[rateLimitQueueHead] + rateLimitQueue[rateLimitQueueHead] = nil + rateLimitQueueHead = rateLimitQueueHead + 1 + if rateLimitQueueHead > rateLimitQueueTail then + rateLimitQueueHead = 1 + rateLimitQueueTail = 0 + end currentTokens = currentTokens - 1 lastChatMessageTime = now @@ -250,10 +291,16 @@ function _QuestieLearnerComms:ProcessQueues() end -- 3. Process Incoming Queue (Combat Aware) - local processCount = InCombatLockdown() and 2 or 6 + local processCount = InCombatLockdown() and combatIncomingCount or normalIncomingCount for i = 1, processCount do - if table_getn(incomingMessageQueue) == 0 then break end - local rawMsg = table_remove(incomingMessageQueue, 1) + if incomingMessageQueueHead > incomingMessageQueueTail then break end + local rawMsg = incomingMessageQueue[incomingMessageQueueHead] + incomingMessageQueue[incomingMessageQueueHead] = nil + incomingMessageQueueHead = incomingMessageQueueHead + 1 + if incomingMessageQueueHead > incomingMessageQueueTail then + incomingMessageQueueHead = 1 + incomingMessageQueueTail = 0 + end _QuestieLearnerComms:ProcessRawMessage(rawMsg.text, rawMsg.sender) end end @@ -262,14 +309,20 @@ end local frame = CreateFrame("Frame") frame:RegisterEvent("CHAT_MSG_CHANNEL") frame:SetScript("OnEvent", function(self, event, msg, sender, _, _, _, _, _, channelId, channelName) + local commsEnabled = GetCommsTuning() + if not commsEnabled then return end if channelName == hiddenChannelName and sender ~= UnitName("player") then - table.insert(incomingMessageQueue, {text = msg, sender = sender}) + incomingMessageQueueTail = incomingMessageQueueTail + 1 + incomingMessageQueue[incomingMessageQueueTail] = {text = msg, sender = sender} end end) function QuestieLearnerComms:OnCommReceived(prefix, message, distribution, sender) + local commsEnabled = GetCommsTuning() + if not commsEnabled then return end if prefix == addonPrefix and sender ~= UnitName("player") then - table.insert(incomingMessageQueue, {text = message, sender = sender}) + incomingMessageQueueTail = incomingMessageQueueTail + 1 + incomingMessageQueue[incomingMessageQueueTail] = {text = message, sender = sender} end end diff --git a/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua b/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua index a62e10d..6bb01ef 100644 --- a/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua +++ b/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua @@ -19,11 +19,77 @@ local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas"); local l10n = QuestieLoader:ImportModule("l10n") ---@type QuestieCompat local QuestieCompat = QuestieLoader:ImportModule("QuestieCompat") +---@type QuestieDB +local QuestieDB = QuestieLoader:ImportModule("QuestieDB") QuestieOptions.tabs.advanced = {} local optionsDefaults = QuestieOptionsDefaults:Load() local _GetLanguages +local function GetLearnerSettings() + Questie.dbLearner = Questie.dbLearner or {} + Questie.dbLearner.global = Questie.dbLearner.global or {} + Questie.dbLearner.global.settings = Questie.dbLearner.global.settings or {} + local settings = Questie.dbLearner.global.settings + if settings.performanceMode == nil then + settings.performanceMode = "balanced" + end + if settings.pinRefreshDelay == nil then + settings.pinRefreshDelay = 0.75 + end + if settings.pinRefreshMode == nil then + settings.pinRefreshMode = "batched" + end + if settings.pinRefreshMaxWait == nil then + settings.pinRefreshMaxWait = 5.0 + end + if settings.liveNpcUpdateDelay == nil then + settings.liveNpcUpdateDelay = 0.75 + end + if settings.learnerCommsIntensity == nil then + settings.learnerCommsIntensity = "normal" + end + if settings.minConfidencePins == nil then + settings.minConfidencePins = 1 + end + if settings.spawnDedupRadius == nil then + settings.spawnDedupRadius = 4.0 + end + return settings +end + +local function ApplyLearnerPerformancePreset(mode) + local settings = GetLearnerSettings() + settings.performanceMode = mode + + if mode == "realtime" then + settings.pinRefreshDelay = 0.1 + settings.pinRefreshMode = "immediate" + settings.pinRefreshMaxWait = 2.0 + settings.liveNpcUpdateDelay = 0.25 + settings.learnerCommsIntensity = "fast" + settings.minConfidencePins = 1 + elseif mode == "low" then + settings.pinRefreshDelay = 2.0 + settings.pinRefreshMode = "batched" + settings.pinRefreshMaxWait = 10.0 + settings.liveNpcUpdateDelay = 2.0 + settings.learnerCommsIntensity = "low" + settings.minConfidencePins = 3 + elseif mode == "balanced" then + settings.pinRefreshDelay = 0.75 + settings.pinRefreshMode = "batched" + settings.pinRefreshMaxWait = 5.0 + settings.liveNpcUpdateDelay = 0.75 + settings.learnerCommsIntensity = "normal" + settings.minConfidencePins = 1 + end + + if mode == "realtime" or mode == "balanced" or mode == "low" then + Questie.db.profile.learnerBroadcast = true + end +end + function QuestieOptions.tabs.advanced:Initialize() -- This needs to be called inside of the Init process for l10n to be fully loaded StaticPopupDialogs["QUESTIE_LANG_CHANGED_RELOAD"] = { @@ -107,6 +173,22 @@ function QuestieOptions.tabs.advanced:Initialize() QuestieOptionsUtils.DetermineTheme() end, }, + clusterDensityAggressiveness = { + type = "range", + order = 1.41, + name = function() return l10n('Dense pin clustering aggressiveness'); end, + desc = function() return l10n('How aggressively crowded kill objectives are consolidated into fewer pins. 0 shows every pin; higher values tighten clustering where many pins share a zone. Coincident pins are always deduplicated.'); end, + width = 1.5, + disabled = function() return (not Questie.db.profile.enabled); end, + min = 0, + max = 100, + step = 5, + get = function(info) return QuestieOptions:GetProfileValue(info); end, + set = function(info, value) + QuestieOptions:SetProfileValue(info, value) + QuestieOptionsUtils:Delay(0.5, QuestieOptions.ClusterRedraw, l10n('Setting dense pin clustering aggressiveness to %s : Redrawing!', value)) + end, + }, quelDanasSpacer1 = QuestieOptionsUtils:Spacer(1.45, (not Questie.IsTBC)), npcrules_group = { type = "group", @@ -157,6 +239,272 @@ function QuestieOptions.tabs.advanced:Initialize() }, }, + learnerPerformanceSpacer = QuestieOptionsUtils:Spacer(1.9), + learnerPerformanceHeader = { + type = "header", + order = 2, + name = function() return l10n('QuestieLearner Performance'); end, + }, + learnerPerformanceMode = { + type = "select", + order = 2.1, + values = { + realtime = l10n("Realtime"), + balanced = l10n("Balanced"), + low = l10n("Low Impact"), + manual = l10n("Manual"), + }, + style = "dropdown", + name = function() return l10n('Performance Mode'); end, + desc = function() return l10n('Controls how aggressively QuestieLearner updates learned pins, live data, and learner comms. Low Impact is recommended for heavy activity zones or low-end computers.'); end, + get = function() return GetLearnerSettings().performanceMode or "balanced" end, + set = function(_, value) + ApplyLearnerPerformancePreset(value) + end, + }, + learnerPinRefreshMode = { + type = "select", + order = 2.2, + values = { + immediate = l10n("Immediate"), + batched = l10n("Batched"), + manual = l10n("Manual / Reload"), + }, + style = "dropdown", + name = function() return l10n('Pin Refresh Behavior'); end, + desc = function() return l10n('Controls when learned pins refresh after QuestieLearner records new data. Manual / Reload records data but avoids live pin redraws until reload or another Questie refresh.'); end, + get = function() return GetLearnerSettings().pinRefreshMode or "batched" end, + set = function(_, value) + local settings = GetLearnerSettings() + settings.pinRefreshMode = value + settings.performanceMode = "manual" + end, + }, + learnerPinRefreshDelay = { + type = "range", + order = 2.3, + name = function() return l10n('Pin Refresh Delay'); end, + desc = function() return l10n('Seconds to wait before refreshing learned quest pins after learner activity. Higher values reduce stutter during kill or loot bursts.'); end, + min = 0.1, + max = 5, + step = 0.1, + width = 1.5, + get = function() return GetLearnerSettings().pinRefreshDelay or 0.5 end, + set = function(_, value) + local settings = GetLearnerSettings() + settings.pinRefreshDelay = value + settings.performanceMode = "manual" + end, + }, + learnerPinRefreshMaxWait = { + type = "range", + order = 2.35, + name = function() return l10n('Pin Refresh Max Wait'); end, + desc = function() return l10n('Maximum seconds learned pins will wait during continuous activity (e.g. nearby players killing mobs) before a forced refresh. The refresh delay resets on each kill, so pins only redraw once things go quiet or this cap is hit. Set to 0 to never force a refresh while activity continues.'); end, + min = 0, + max = 30, + step = 0.5, + width = 1.5, + get = function() return GetLearnerSettings().pinRefreshMaxWait or 5.0 end, + set = function(_, value) + local settings = GetLearnerSettings() + settings.pinRefreshMaxWait = value + settings.performanceMode = "manual" + end, + }, + learnerLiveNpcUpdateDelay = { + type = "range", + order = 2.4, + name = function() return l10n('Live NPC Update Delay'); end, + desc = function() return l10n('Seconds to batch learned NPC live database updates. Higher values reduce work during combat and crowded zones.'); end, + min = 0.25, + max = 5, + step = 0.25, + width = 1.5, + get = function() return GetLearnerSettings().liveNpcUpdateDelay or 0.5 end, + set = function(_, value) + local settings = GetLearnerSettings() + settings.liveNpcUpdateDelay = value + settings.performanceMode = "manual" + end, + }, + learnerMinConfidencePins = { + type = "range", + order = 2.5, + name = function() return l10n('Minimum Kills Before Learned Pins'); end, + desc = function() return l10n('How many matching NPC sightings are needed before QuestieLearner shows learned pins. Higher values reduce one-off pin churn.'); end, + min = 1, + max = 10, + step = 1, + width = 1.5, + get = function() return GetLearnerSettings().minConfidencePins or 1 end, + set = function(_, value) + local settings = GetLearnerSettings() + settings.minConfidencePins = value + settings.performanceMode = "manual" + end, + }, + learnerSpawnDedupRadius = { + type = "range", + order = 2.55, + name = function() return l10n('Spawn Pin Dedup Radius'); end, + desc = function() return l10n('How close two learned kill positions must be (in map %) to merge into a single pin. Higher values show fewer, tighter pins per spawn; 0 shows every distinct position.'); end, + min = 0, + max = 15, + step = 0.5, + width = 1.5, + get = function() return GetLearnerSettings().spawnDedupRadius or 4.0 end, + set = function(_, value) + GetLearnerSettings().spawnDedupRadius = value + -- Spawn tables are cached per NPC; clear so the new radius is + -- applied on the redraw instead of serving stale merged coords. + if QuestieDB and QuestieDB.ClearModeCaches then + QuestieDB:ClearModeCaches() + end + QuestieOptionsUtils:Delay(0.5, QuestieQuest.SmoothReset, l10n('Setting spawn pin dedup radius to %s : Redrawing!', value)) + end, + }, + learnerCommsIntensity = { + type = "select", + order = 2.6, + values = { + off = l10n("Off"), + low = l10n("Low"), + normal = l10n("Normal"), + fast = l10n("Fast"), + }, + style = "dropdown", + name = function() return l10n('Learner Comms Intensity'); end, + desc = function() return l10n('Controls how much learner data Questie processes and sends through learner comms. Lower values reduce CPU and chat-channel work.'); end, + get = function() return GetLearnerSettings().learnerCommsIntensity or "normal" end, + set = function(_, value) + local settings = GetLearnerSettings() + settings.learnerCommsIntensity = value + settings.performanceMode = "manual" + if value == "off" then + Questie.db.profile.learnerBroadcast = false + elseif Questie.db.profile.learnerBroadcast == false then + Questie.db.profile.learnerBroadcast = true + end + end, + }, + + arrowPerformanceSpacer = QuestieOptionsUtils:Spacer(2.69), + arrowPerformanceHeader = { + type = "header", + order = 2.7, + name = function() return l10n('QuestieArrow Performance'); end, + }, + arrowUpdateThrottle = { + type = "range", + order = 2.71, + width = 1.5, + name = function() return l10n('Arrow Movement Update Interval'); end, + desc = function() return l10n('Seconds between arrow rotation and distance updates. Higher values reduce CPU usage but make the arrow feel less smooth.'); end, + min = 0.03, + max = 0.5, + step = 0.01, + get = function() return Questie.db.profile.arrowUpdateThrottle or optionsDefaults.profile.arrowUpdateThrottle end, + set = function(_, value) + Questie.db.profile.arrowUpdateThrottle = value + end, + }, + arrowRecalcInterval = { + type = "range", + order = 2.72, + width = 1.5, + name = function() return l10n('Target Scan Interval'); end, + desc = function() return l10n('Seconds between full nearest-objective scans. Higher values reduce HBD and ZoneDB work in large quest logs.'); end, + min = 0.5, + max = 10, + step = 0.5, + get = function() return Questie.db.profile.arrowRecalcInterval or optionsDefaults.profile.arrowRecalcInterval end, + set = function(_, value) + Questie.db.profile.arrowRecalcInterval = value + local QuestieArrow = QuestieLoader:ImportModule("QuestieArrow") + if QuestieArrow and QuestieArrow.Refresh then + QuestieArrow:Refresh() + end + end, + }, + arrowTrackerRefreshThrottle = { + type = "range", + order = 2.73, + width = 1.5, + name = function() return l10n('Tracker Refresh Throttle'); end, + desc = function() return l10n('Minimum seconds between arrow refreshes triggered by tracker updates. Higher values reduce refresh bursts during quest progress changes.'); end, + min = 0.25, + max = 5, + step = 0.25, + get = function() return Questie.db.profile.arrowTrackerRefreshThrottle or optionsDefaults.profile.arrowTrackerRefreshThrottle end, + set = function(_, value) + Questie.db.profile.arrowTrackerRefreshThrottle = value + end, + }, + + questieCommsPerformanceSpacer = QuestieOptionsUtils:Spacer(2.79), + questieCommsPerformanceHeader = { + type = "header", + order = 2.8, + name = function() return l10n('QuestieComms Performance'); end, + }, + questieCommsEnabled = { + type = "toggle", + order = 2.805, + name = function() return l10n('Enable QuestieComms'); end, + desc = function() return l10n('Enable Questie group quest-progress communication. Disabling this stops outgoing QuestieComms and ignores incoming QuestieComms immediately.'); end, + width = 1.5, + get = function() return Questie.db.profile.questieCommsEnabled ~= false end, + set = function(_, value) + Questie.db.profile.questieCommsEnabled = value + end, + }, + questieCommsQuestListPacketSize = { + type = "range", + order = 2.81, + name = function() return l10n('Quest List Packet Size'); end, + desc = function() return l10n('Maximum serialized payload size per full quest-list block. Lower values create smaller packets but may send more blocks.'); end, + min = 100, + max = 500, + step = 25, + width = 1.5, + disabled = function() return Questie.db.profile.questieCommsEnabled == false end, + get = function() return Questie.db.profile.questieCommsQuestListPacketSize or optionsDefaults.profile.questieCommsQuestListPacketSize end, + set = function(_, value) + Questie.db.profile.questieCommsQuestListPacketSize = value + end, + }, + questieCommsQuestListInitialJitter = { + type = "range", + order = 2.82, + name = function() return l10n('Quest List Initial Jitter'); end, + desc = function() return l10n('Maximum random delay before responding with a full quest list. Higher values spread group responses out to reduce bursts.'); end, + min = 0, + max = 10, + step = 0.5, + width = 1.5, + disabled = function() return Questie.db.profile.questieCommsEnabled == false end, + get = function() return Questie.db.profile.questieCommsQuestListInitialJitter or optionsDefaults.profile.questieCommsQuestListInitialJitter end, + set = function(_, value) + Questie.db.profile.questieCommsQuestListInitialJitter = value + end, + }, + questieCommsQuestListBlockInterval = { + type = "range", + order = 2.83, + name = function() return l10n('Quest List Block Interval'); end, + desc = function() return l10n('Seconds between full quest-list blocks. Higher values reduce comms bursts on slower systems and crowded groups.'); end, + min = 0.5, + max = 10, + step = 0.5, + width = 1.5, + disabled = function() return Questie.db.profile.questieCommsEnabled == false end, + get = function() return Questie.db.profile.questieCommsQuestListBlockInterval or optionsDefaults.profile.questieCommsQuestListBlockInterval end, + set = function(_, value) + Questie.db.profile.questieCommsQuestListBlockInterval = value + end, + }, + Spacer_A = QuestieOptionsUtils:Spacer(2.9), locale_header = { type = "header", diff --git a/Modules/Options/CreditsTab/QuestieOptionsCredits.lua b/Modules/Options/CreditsTab/QuestieOptionsCredits.lua index bff5e61..b247f14 100644 --- a/Modules/Options/CreditsTab/QuestieOptionsCredits.lua +++ b/Modules/Options/CreditsTab/QuestieOptionsCredits.lua @@ -95,7 +95,7 @@ function QuestieOptions.tabs.credits:Initialize() thanks = { type = "description", order = 1, - name = function() return 'The Turtle WoW, Project Ascension, and Project Ebonhold communities for their ongoing support and feedback.'; end, + name = function() return 'The Project Ascension, Project Ebonhold, and custom server communities for their ongoing support and feedback.'; end, fontSize = "medium", }, }, diff --git a/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua b/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua index e34efcb..03c7fdc 100644 --- a/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua +++ b/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua @@ -20,7 +20,6 @@ end local function GetServer() if Questie.IsAscension then return "Ascension" end - if Questie.IsTurtle then return "Turtle" end if Questie.IsEbonhold then return "Ebonhold" end if Questie.IsEra then return "Era" end if Questie.Is335 then return "WotLK" end @@ -47,6 +46,47 @@ local function GetLearnedCounts() return s end +local function ApplyLearnerMode() + local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") + if QuestieLearner and QuestieLearner.ApplyDataSourceMode then + QuestieLearner:ApplyDataSourceMode() + end + + -- SmoothReset is the canonical full refresh: it clears all map/minimap notes + -- and tooltips, recalculates and redraws available quests, re-updates every + -- active quest, and refreshes the tracker. This makes a data-source-mode + -- switch (auto/learner/static/none) take effect everywhere in real time. + local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest") + if QuestieQuest and QuestieQuest.SmoothReset then + QuestieQuest:SmoothReset() + else + local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker") + if QuestieTracker and QuestieTracker.Update then + QuestieTracker:Update() + end + end +end + +local function GetLearnerRuntimeMode() + local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") + if QuestieLearner and QuestieLearner.GetDataSourceMode then + return QuestieLearner:GetDataSourceMode() + end + return (Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.dataSourceMode) or "auto" +end + +local function GetLearnerSelectedMode() + return (Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.dataSourceMode) or "auto" +end + +local function IsLearnerRuntimeEnabled() + local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") + if QuestieLearner and QuestieLearner.IsEnabled then + return QuestieLearner:IsEnabled() + end + return Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.enabled +end + ----------------------------------------------------------------------- -- Export Dialog ----------------------------------------------------------------------- @@ -205,6 +245,68 @@ function QuestieOptions.tabs.database:Initialize() name = function() return l10n("What To Learn") end, }, + learner_enabled = { + type = "toggle", + order = 2.05, + name = function() return l10n("Enable Learner Recording") end, + desc = function() return l10n("Record live learner data. Disable this to stop recording and live learner injection. Learner will still auto-enable if the static DB is missing.") end, + get = function() return IsLearnerRuntimeEnabled() end, + set = function(_, v) + if Questie.dbLearner.global and Questie.dbLearner.global.settings then + Questie.dbLearner.global.settings.enabled = v + ApplyLearnerMode() + end + end, + }, + + data_source_mode = { + type = "select", + order = 2.06, + name = function() return l10n("Data Source Mode") end, + desc = function() + local runtimeMode = GetLearnerRuntimeMode() + if runtimeMode == "learner" and GetLearnerSelectedMode() ~= "learner" then + return l10n("Choose whether Questie should display learner data, static database data, both, or neither. Learner recording can stay enabled independently. The runtime may still force learner if the static DB is unavailable.") + end + return l10n("Choose whether Questie should display learner data, static database data, both, or neither. Learner recording can stay enabled independently.") + end, + values = { + auto = l10n("Auto (current behavior)"), + learner = l10n("Learner Only"), + static = l10n("Static Only"), + none = l10n("Neither (base DB only)"), + }, + get = function() return GetLearnerSelectedMode() end, + set = function(_, v) + if Questie.dbLearner.global and Questie.dbLearner.global.settings then + Questie.dbLearner.global.settings.dataSourceMode = v + Questie.dbLearner.global.settings.prioritizeMyData = (v ~= "static" and v ~= "none") + if v == "learner" then + Questie.dbLearner.global.settings.enabled = true + end + ApplyLearnerMode() + end + end, + }, + + data_source_runtime = { + type = "description", + order = 2.07, + fontSize = "medium", + name = function() + local selectedMode = GetLearnerSelectedMode() + local runtimeMode = GetLearnerRuntimeMode() + if runtimeMode == selectedMode then + return string.format("|cFF5EBAF3Runtime mode:|r %s", l10n(runtimeMode == "learner" and "Learner" or runtimeMode == "static" and "Static" or runtimeMode == "none" and "Neither" or "Auto")) + end + return string.format( + "|cFF5EBAF3Runtime mode:|r %s |cFF888888(selected: %s)|r", + l10n(runtimeMode == "learner" and "Learner" or runtimeMode == "static" and "Static" or runtimeMode == "none" and "Neither" or "Auto"), + l10n(selectedMode == "learner" and "Learner" or selectedMode == "static" and "Static" or selectedMode == "none" and "Neither" or "Auto") + ) + end, + }, + learn_npcs = { type = "toggle", order = 2.1, @@ -434,9 +536,9 @@ function QuestieOptions.tabs.database:Initialize() confirm = true, confirmText = "Are you sure? This cannot be undone.", func = function() - if Questie.db and Questie.db.global then - Questie.dbLearner.global = nil - Questie:Print("|cFFFF4444[Questie-X]|r All learned data has been reset.") + local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") + if QuestieLearner and QuestieLearner.ClearAllData then + QuestieLearner:ClearAllData() end end, }, diff --git a/Modules/Options/QuestieOptionsDefaults.lua b/Modules/Options/QuestieOptionsDefaults.lua index f206e88..8aa9dd1 100644 --- a/Modules/Options/QuestieOptionsDefaults.lua +++ b/Modules/Options/QuestieOptionsDefaults.lua @@ -9,6 +9,11 @@ function QuestieOptionsDefaults:Load() ascensionScalingAsked = false, --Ascension clusterLevelHotzone = 50, + -- How aggressively dense objectives (many pins in one zone) get + -- consolidated. 0 = off (every pin shown); higher tightens the + -- clustering range for crowded kill objectives. Coincident pins are + -- always deduplicated regardless of this value. See QuestieQuest:_DrawObjectiveIcons. + clusterDensityAggressiveness = 35, enableIconLimit = false, iconLimit = 200, availableScale = 1.2, @@ -64,6 +69,13 @@ function QuestieOptionsDefaults:Load() arrowCustomIsSheet = false, arrowFontSize = 10, arrowFont = 'Friz Quadrata TT', + arrowUpdateThrottle = 0.05, + arrowRecalcInterval = 1.0, + arrowTrackerRefreshThrottle = 0.5, + questieCommsEnabled = true, + questieCommsQuestListPacketSize = 200, + questieCommsQuestListInitialJitter = 3, + questieCommsQuestListBlockInterval = 3, debugArrow = false, enableObjectives = true, enableTurnins = true, @@ -77,8 +89,12 @@ function QuestieOptionsDefaults:Load() enableTooltipsQuestLevel = true, showQuestXpAtMaxLevel = true, enableTooltipsNextInChain = true, + learnerBroadcast = true, enableMapIcons = true, enableMiniMapIcons = true, + -- Learner source preference (Database tab) + -- auto = current behavior, learner = learner only, static = static only, none = base DB only + dataSourceMode = "auto", questieShutUp = false, bugWorkarounds = true, hideIconsOnContinents = false, diff --git a/Modules/Quest/AvailableQuests.lua b/Modules/Quest/AvailableQuests.lua index 23cc8e1..b08def4 100644 --- a/Modules/Quest/AvailableQuests.lua +++ b/Modules/Quest/AvailableQuests.lua @@ -35,6 +35,7 @@ local timer -- Keep track of all available quests to unload undoable when abandoning a quest local availableQuests = {} +local unavailableQuestLogged = {} local dungeons = ZoneDB:GetDungeons() @@ -282,12 +283,21 @@ end _DrawAvailableQuest = function(questId) NewThread(function() local quest = QuestieDB.GetQuest(questId) + if not quest then + if not unavailableQuestLogged[questId] then + unavailableQuestLogged[questId] = true + Questie:Debug(Questie.DEBUG_LEARNER, "[AvailableQuests] Skipping unavailable quest during draw:", questId) + end + return + end if (not quest.tagInfoWasCached) then QuestieDB.GetQuestTagInfo(questId) -- cache to load in the tooltip quest.tagInfoWasCached = true end + unavailableQuestLogged[questId] = nil + AvailableQuests.DrawAvailableQuest(quest) end, 0) end diff --git a/Modules/Quest/QuestEventHandler.lua b/Modules/Quest/QuestEventHandler.lua index bf47028..c87b9bd 100644 --- a/Modules/Quest/QuestEventHandler.lua +++ b/Modules/Quest/QuestEventHandler.lua @@ -237,7 +237,8 @@ end ---@param questLogIndex number ---@param questId number function _QuestEventHandler:QuestAccepted(questLogIndex, questId) - questId = questId or select(8, GetQuestLogTitle(questLogIndex)) + local _, _, _, _, _, _, _, questLogQuestId = GetQuestLogTitle(questLogIndex) + questId = questId or questLogQuestId Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_ACCEPTED", questLogIndex, questId) if questLog[questId] and questLog[questId].timer then diff --git a/Modules/Quest/QuestieQuest.lua b/Modules/Quest/QuestieQuest.lua index e46f479..b971871 100644 --- a/Modules/Quest/QuestieQuest.lua +++ b/Modules/Quest/QuestieQuest.lua @@ -1581,8 +1581,13 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI objectiveCenter = { x = x, y = y } end - -- Filter static spawns if prioritizeMyData is enabled and we have high-confidence learned data - if Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.enabled and Questie.dbLearner.global.settings.prioritizeMyData then + -- Filter static spawns only when the learner is allowed to influence display. + local dataSourceMode = Questie.dbLearner + and Questie.dbLearner.global + and Questie.dbLearner.global.settings + and Questie.dbLearner.global.settings.dataSourceMode or "auto" + if Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.enabled + and (dataSourceMode == "auto" or dataSourceMode == "learner") then local zone, _ = next(zones) while zone do local suppressed = (objectiveData.Type == "monster" and QuestieDB.GetSuppressedNPCs(zone)) or (objectiveData.Type == "object" and QuestieDB.GetSuppressedObjects(zone)) @@ -1646,13 +1651,49 @@ _RegisterObjectiveTooltips = function(objective, questId, blockItemTooltips) objective.hasRegisteredTooltips = true end else - -- No spawnList and no Id means there is nothing Questie can draw for this objective. - -- This covers server-tracked trigger objectives (e.g. "complete N quests in zone" for - -- quest 50150) which may have any objectiveType from the server, not just "event". if not objective.Id or objective.Id == 0 then + -- No spawnList and no Id means there is nothing Questie can draw for this objective. + -- This covers server-tracked trigger objectives (e.g. "complete N quests in zone" for + -- quest 50150) which may have any objectiveType from the server, not just "event". objective.hasRegisteredTooltips = true return end + + if objective.Type == "monster" or objective.Type == "object" or objective.Type == "item" then + local tooltipKey = nil + if objective.Type == "monster" then + tooltipKey = "m_" .. objective.Id + elseif objective.Type == "object" then + tooltipKey = "o_" .. objective.Id + elseif objective.Type == "item" then + tooltipKey = "i_" .. objective.Id + end + + if tooltipKey and not objective.hasRegisteredTooltips then + QuestieTooltips:RegisterObjectiveTooltip(questId, tooltipKey, objective) + objective.hasRegisteredTooltips = true + end + + if objective.Type == "item" then + objective.registeredItemTooltips = true + end + return + elseif objective.Type == "killcredit" then + local ids = objective.IdList + if type(ids) ~= "table" then + ids = { objective.Id } + end + + for _, id in ipairs(ids) do + if id then + QuestieTooltips:RegisterObjectiveTooltip(questId, "m_" .. id, objective) + end + end + + objective.hasRegisteredTooltips = true + return + end + Questie:Error("[QuestieQuest]: [Tooltips] " .. l10n("There was an error populating objectives for %s %s %s %s", objective.Description or "No objective text", questId or "No quest id", 0 or "No objective", "No error")) @@ -1806,18 +1847,30 @@ _DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType) local iconCount, orderedList = _GetIconsSortedByDistance(iconsToDraw) - -- Dense kill objectives (like Sunstrider Isle mana wyrms) previously used - -- a lower clustering hotzone here. Leave the old behavior commented so we - -- can restore it quickly if we need to revisit consolidation again. - --[[ - if iconCount >= 20 then - range = math.max(6, math.floor(range * 0.25)) - elseif iconCount >= 12 then - range = math.max(10, math.floor(range * 0.4)) - elseif iconCount >= 6 then - range = math.max(16, math.floor(range * 0.65)) + -- Dense kill objectives (like Sunstrider Isle mana wyrms) consolidate more + -- aggressively the more pins share a zone. This is now user-tunable via the + -- clusterDensityAggressiveness knob (0 = off / show every pin, 100 = the + -- original aggressive consolidation). The per-zone and object overrides below + -- still take precedence, and coincident pins are always deduped in CalcHotzones. + local densityAggression = Questie.db.profile.clusterDensityAggressiveness or 0 + if densityAggression > 0 then + local factor = densityAggression / 100 + if factor > 1 then factor = 1 end + local baseMult + if iconCount >= 20 then + baseMult = 0.25 + elseif iconCount >= 12 then + baseMult = 0.4 + elseif iconCount >= 6 then + baseMult = 0.65 + end + if baseMult then + -- Interpolate between no reduction (factor 0) and the full base + -- multiplier (factor 1) so the knob scales smoothly. + local mult = 1 - (1 - baseMult) * factor + range = math.max(1, math.floor(range * mult)) + end end - --]] if orderedList[1] and orderedList[1].Icon == Questie.ICON_TYPE_OBJECT then -- new clustering / limit code should prevent problems, always show all object notes range = range * 0.2; -- Only use 20% of the default range. diff --git a/Modules/Quest/QuestieQuestPrivates.lua b/Modules/Quest/QuestieQuestPrivates.lua index 209e4b5..8879118 100644 --- a/Modules/Quest/QuestieQuestPrivates.lua +++ b/Modules/Quest/QuestieQuestPrivates.lua @@ -4,6 +4,7 @@ local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest") QuestieQuest.private = QuestieQuest.private or {} ---@class QuestieQuestPrivate local _QuestieQuest = QuestieQuest.private +_QuestieQuest.objectiveFallbackLogged = _QuestieQuest.objectiveFallbackLogged or {} ---@type QuestieDB local QuestieDB = QuestieLoader:ImportModule("QuestieDB") @@ -143,10 +144,16 @@ monster = function(npcId, objective) return nil end - local name = QuestieDB.QueryNPCSingle(npcId, "name") + local dataSourceMode = Questie.dbLearner + and Questie.dbLearner.global + and Questie.dbLearner.global.settings + and Questie.dbLearner.global.settings.dataSourceMode or "auto" + local npcData = QuestieDB:GetNPC(npcId) + local name = npcData and npcData.name or nil if not name or name == "" then -- Last resort: extract NPC name from objective description text. - -- This mirrors the name-parsing logic in the killcredit function. + -- This mirrors the name-parsing logic in the killcredit function and + -- only runs when learner-only mode still needs a display label. if objective then local desc = objective.Description or objective.text if desc then @@ -154,7 +161,8 @@ monster = function(npcId, objective) if not name then name = desc:gsub("%d+/%d+", ""):gsub("[:!?,.%(%)%[%]]", ""):gsub("^%s+", ""):gsub("%s+$", "") end - if name and name ~= "" then + if name and name ~= "" and not _QuestieQuest.objectiveFallbackLogged[npcId] then + _QuestieQuest.objectiveFallbackLogged[npcId] = true Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Using objective description as name fallback for NPC:", npcId, name) end end @@ -165,7 +173,7 @@ monster = function(npcId, objective) return nil end - local spawns = QuestieDB.QueryNPCSingle(npcId, "spawns") + local spawns = npcData and npcData.spawns or {} if (not spawns) then Questie:Debug(Questie.DEBUG_CRITICAL, "Spawn data missing for NPC:", npcId) spawns = {} @@ -173,13 +181,18 @@ monster = function(npcId, objective) local isLearned = false + if dataSourceMode == "none" then + spawns = {} + end + -- Learner safety net: when prioritizeMyData is enabled and the Learner has -- verified spawn data for this NPC, prefer it over compiled DB spawns. -- This catches edge cases where the npcDataOverrides chain doesn't fully -- replace retail positions (e.g. format migration gaps, timing issues). - if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global then + if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global + and (dataSourceMode == "auto" or dataSourceMode == "learner") then local ld = Questie.dbLearner.global - if ld.settings and ld.settings.enabled and ld.settings.prioritizeMyData then + if ld.settings and ld.settings.enabled then local learnedNpc = ld.npcs and ld.npcs[npcId] if learnedNpc then local learnedSpawns = learnedNpc[7] @@ -193,8 +206,12 @@ monster = function(npcId, objective) and QuestieDB.ascensionOverrideKeys["NPC"][npcId] and QuestieDB.ascensionOverrideKeys["NPC"][npcId][7] local hasReliableLearnedSpawns = _CountUniqueSpawnPositions(learnedSpawns) > 1 + local staticHasSpawns = spawns and next(spawns) ~= nil + local canUseLearnerSpawns = dataSourceMode == "learner" + or hasReliableLearnedSpawns + or not staticHasSpawns if learnedSpawns and next(learnedSpawns) and learnedNpc.mc and learnedNpc.mc >= threshold - and hasReliableLearnedSpawns and not ascProtected then + and canUseLearnerSpawns and not ascProtected then Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Preferring learned spawns for NPC:", npcId, "(mc=" .. tostring(learnedNpc.mc) .. ")") spawns = learnedSpawns isLearned = true @@ -203,7 +220,7 @@ monster = function(npcId, objective) end end - local rank = QuestieDB.QueryNPCSingle(npcId, "rank") + local rank = npcData and npcData.rank local enableSpawns = not QuestieCorrections.questNPCBlacklist[npcId] local enableWaypoints = enableSpawns and 2 ~= rank -- a rare mob spawn. todo: option for this @@ -213,7 +230,7 @@ monster = function(npcId, objective) Id = npcId, Name = name, Spawns = enableSpawns and spawns or {}, - Waypoints = enableWaypoints and QuestieDB.QueryNPCSingle(npcId, "waypoints") or {}, + Waypoints = enableWaypoints and (npcData and npcData.waypoints or {}) or {}, Hostile = true, Icon = Questie.ICON_TYPE_SLAY, GetIconScale = _GetIconScaleForMonster, @@ -241,18 +258,47 @@ object = function(objectId, objective) return nil end - local name = QuestieDB.QueryObjectSingle(objectId, "name") + local dataSourceMode = Questie.dbLearner + and Questie.dbLearner.global + and Questie.dbLearner.global.settings + and Questie.dbLearner.global.settings.dataSourceMode or "auto" + local objectData = QuestieDB:GetObject(objectId) + local name = objectData and objectData.name or nil if (not name) then Questie:Debug(Questie.DEBUG_CRITICAL, "Name missing for object:", objectId) return nil end - local spawns = QuestieDB.QueryObjectSingle(objectId, "spawns") + local spawns = objectData and objectData.spawns or {} if (not spawns) then Questie:Debug(Questie.DEBUG_CRITICAL, "Spawn data missing for object:", objectId) spawns = {} end + local isLearned = false + if dataSourceMode == "none" then + spawns = {} + end + + if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global + and (dataSourceMode == "auto" or dataSourceMode == "learner") then + local ld = Questie.dbLearner.global + if ld.settings and ld.settings.enabled then + local learnedObj = ld.objects and ld.objects[objectId] + if learnedObj and learnedObj[4] and next(learnedObj[4]) then + local threshold = ld.settings.minConfidencePins or 1 + local staticHasSpawns = spawns and next(spawns) ~= nil + local canUseLearnerSpawns = dataSourceMode == "learner" + or _CountUniqueSpawnPositions(learnedObj[4]) > 1 + or not staticHasSpawns + if learnedObj.mc and learnedObj.mc >= threshold and canUseLearnerSpawns then + spawns = learnedObj[4] + isLearned = true + end + end + end + end + ---@type SpawnListObject local retObject = { @@ -263,6 +309,7 @@ object = function(objectId, objective) GetIconScale = _GetIconScaleForObject, IconScale = _GetIconScaleForObject(), TooltipKey = "o_" .. objectId, + isLearned = isLearned, } return { diff --git a/Modules/QuestieCompat.lua b/Modules/QuestieCompat.lua index 1b4ab20..a1d6b98 100644 --- a/Modules/QuestieCompat.lua +++ b/Modules/QuestieCompat.lua @@ -241,7 +241,7 @@ end -- old Lua 5.0 host with no secure-call protection), addon code can still call it -- but it will simply be a no-op that prints a warning rather than injecting taint. if not hooksecurefunc then - -- Lua 5.0 hosts (Turtle WoW pre-2.0 or custom servers) have no secure-call model, + -- Lua 5.0 hosts and older custom servers have no secure-call model, -- so raw-hooking is equivalent to what Blizzard would do internally anyway. -- Use a local to avoid polluting _G unnecessarily. local function _rawHook(arg1, arg2, arg3) @@ -278,7 +278,7 @@ end if C_Timer then QuestieCompat.C_Timer = C_Timer else - -- C_Timer polyfill for Lua 5.0/5.1 clients that don't have it (e.g. Turtle WoW pre-2.0). + -- C_Timer polyfill for Lua 5.0/5.1 clients that don't have it. -- Only stored in QuestieCompat namespace, NOT in bare _G. local TickerFrame = CreateFrame("Frame") local tickers = {} @@ -626,4 +626,3 @@ end QuestieCompat.LibUIDropDownMenu = QuestieCompat.LibUIDropDownMenu or {} QuestieCompat.LibUIDropDownMenu.UIDropDownMenu_Menu_NewSize = function() end - diff --git a/Modules/QuestieInit.lua b/Modules/QuestieInit.lua index 588c2b6..6a496e9 100644 --- a/Modules/QuestieInit.lua +++ b/Modules/QuestieInit.lua @@ -128,14 +128,18 @@ local function loadFullDatabase() print("\124cFF4DDBFF [1/9] " .. l10n("Loading database") .. "...") QuestieInit:LoadBaseDB() - Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After LoadBaseDB - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData)) - Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] obj:" .. _dbStats(QuestieDB.objectData) .. " item:" .. _dbStats(QuestieDB.itemData)) + if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then + Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After LoadBaseDB - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData)) + Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] obj:" .. _dbStats(QuestieDB.objectData) .. " item:" .. _dbStats(QuestieDB.itemData)) + end print("\124cFF4DDBFF [2/9] " .. l10n("Applying database corrections") .. "...") coYield() QuestieCorrections:Initialize() - Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After Corrections - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData)) + if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then + Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After Corrections - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData)) + end print("\124cFF4DDBFF [3/9] " .. l10n("Initializing townfolks") .. "...") coYield() @@ -279,7 +283,7 @@ QuestieInit.Stages[1] = function() -- run as a coroutine needsCompilation = (not dbIsCompiled) or (QuestieLib:GetAddonVersionString() ~= dbCompiledOnVersion) or (l10n:GetUILocale() ~= dbCompiledLang) or (Questie.db.global.dbCompiledExpansion ~= WOW_PROJECT_ID) -- Custom servers or presence of DB plugins: always defer to Stage3 to wait for plugin data injection - if Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or Questie.IsTurtle or QuestieServer:IsAnyDBPluginEnabled() then + if Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or QuestieServer:IsAnyDBPluginEnabled() then compilationDeferred = true l10n:Initialize() coYield() @@ -289,7 +293,9 @@ QuestieInit.Stages[1] = function() -- run as a coroutine l10n("Questie DB has updated!") .. "|r|cFFFF6F22 " .. l10n("Data is being processed, this may take a few moments and cause some lag...")) loadFullDatabase() - Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] Before Compile - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData)) + if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then + Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] Before Compile - quest:" .. _dbStats(QuestieDB.questData) .. " npc:" .. _dbStats(QuestieDB.npcData)) + end QuestieDBCompiler:Compile() Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] After Compile - quest:type=" .. type(QuestieDB.questData) .. " npc:type=" .. type(QuestieDB.npcData)) dbCompiled = true @@ -390,7 +396,7 @@ QuestieInit.Stages[3] = function() -- run as a coroutine -- Always re-compile on custom servers to pick up QuestieLearner changes from SavedVariables, -- or if compilation was explicitly deferred/needed. - local isCustomServer = Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or Questie.IsTurtle or QuestieServer:IsAnyDBPluginEnabled() + local isCustomServer = Questie.IsAscension or Questie.IsEbonhold or Questie.IsValanior or QuestieServer:IsAnyDBPluginEnabled() if isCustomServer or needsCompilation or (not Questie.db.global.dbIsCompiled) then Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieInit:Stage3] Starting compilation (Server=" .. tostring(isCustomServer) .. ", Needed=" .. tostring(needsCompilation) .. ")") if not QuestieDB.questData then @@ -502,6 +508,12 @@ end function QuestieInit:LoadDatabase(key) + local function MarkBaseDatabaseMissing() + QuestieDB.baseDatabaseMissing = true + QuestieDB.baseDatabaseMissingKeys = QuestieDB.baseDatabaseMissingKeys or {} + QuestieDB.baseDatabaseMissingKeys[key] = true + end + if type(QuestieDB[key]) == "string" then -- Fix #6: `loadstring` at LOAD TIME is safe, but calling it here during -- event-driven runtime taints any tables produced on WotLK/Era clients. @@ -524,6 +536,7 @@ function QuestieInit:LoadDatabase(key) "[DBDiag] LEGACY DB ('" .. key .. "' is string) on modern client. " .. "Runtime loadstring() would taint this data. " .. "Please reinstall the Questie-X-WotLKDB addon in split-file format.") + MarkBaseDatabaseMissing() QuestieDB[key] = {} return end @@ -537,16 +550,19 @@ function QuestieInit:LoadDatabase(key) QuestieDB[key] = result else Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] ERROR executing('" .. key .. "'): " .. tostring(result)) + MarkBaseDatabaseMissing() QuestieDB[key] = nil end else Questie:Debug(Questie.DEBUG_DEVELOP, "[DBDiag] ERROR loadstring('" .. key .. "'): " .. tostring(loadErr) .. " | len=" .. string.len(QuestieDB[key] or "")) + MarkBaseDatabaseMissing() QuestieDB[key] = nil end elseif type(QuestieDB[key]) == "table" then Questie:Debug(Questie.DEBUG_DEVELOP, "[LoadDatabase] '" .. key .. "' already a table (split-file format), skipping loadstring") else Questie:Debug(Questie.DEBUG_DEVELOP, "Database is missing, this is likely do to era vs tbc: ", key) + MarkBaseDatabaseMissing() end if not QuestieDB[key] then QuestieDB[key] = {} @@ -560,6 +576,9 @@ function QuestieInit:LoadBaseDB() -- Pointer compilation will look at npcDataOverrides etc, which are populated by plugins. -- Base tables (Classic) are loaded here. + QuestieDB.baseDatabaseMissing = false + QuestieDB.baseDatabaseMissingKeys = {} + QuestieInit:LoadDatabase("npcData") QuestieInit:LoadDatabase("objectData") QuestieInit:LoadDatabase("questData") diff --git a/Modules/QuestieLearner.lua b/Modules/QuestieLearner.lua index 16a1a27..2362a1a 100644 --- a/Modules/QuestieLearner.lua +++ b/Modules/QuestieLearner.lua @@ -15,7 +15,9 @@ local l10n = QuestieLoader:ImportModule("l10n") local ZoneDB = QuestieLoader:ImportModule("ZoneDB") local _Learner = QuestieLearner.private or {} -QuestieLearner.private = _Learner + +local GetDataSourceMode +local DeepCopy local floor = math.floor local abs = math.abs @@ -31,8 +33,13 @@ local string_trim = string.trim local string_sub = string.sub local string_len = string.len local string_upper = string.upper +local AceConfigRegistry = LibStub and LibStub("AceConfigRegistry-3.0", true) local function IsAscensionProtected(dbType, id, key) + local mode = GetDataSourceMode and GetDataSourceMode() or "auto" + if mode == "learner" or mode == "none" then + return false + end local protected = QuestieDB and QuestieDB.ascensionOverrideKeys and QuestieDB.ascensionOverrideKeys[dbType] @@ -41,6 +48,56 @@ local function IsAscensionProtected(dbType, id, key) return protected and protected[key] == true end +local function HasAscensionQuestObjectiveData(questId) + return IsAscensionProtected("QUEST", questId, 10) +end + +local function IsBaseDatabaseMissing() + return QuestieDB + and QuestieDB.IsBaseDatabaseMissing + and QuestieDB:IsBaseDatabaseMissing() +end + +local function GetDataSourceMode() + if IsBaseDatabaseMissing() then + return "learner" + end + + local settings = Questie + and Questie.dbLearner + and Questie.dbLearner.global + and Questie.dbLearner.global.settings + if not settings then + return "auto" + end + + local mode = settings.dataSourceMode + if mode == "auto" or mode == "learner" or mode == "static" or mode == "none" then + return mode + end + + if settings.prioritizeMyData == false then + return "static" + end + + return "auto" +end + +local function WipeTable(tbl) + if type(tbl) ~= "table" then return end + for key in pairs(tbl) do + tbl[key] = nil + end +end + +local function CopyTable(dst, src) + WipeTable(dst) + if type(src) ~= "table" then return end + for key, value in pairs(src) do + dst[key] = DeepCopy(value) + end +end + local function NormalizeSpawnZoneKey(zoneKey) -- Convert raw area IDs (e.g. 3431 from GetAreaID()) to the canonical map IDs -- used by AscensionDB and the rendering system (e.g. 1241 for Sunstrider Isle). @@ -123,7 +180,10 @@ _Learner.pendingNpcs = {} _Learner.pendingQuests = {} _Learner.pendingItems = {} _Learner.pendingObjects = {} +_Learner.pendingNetworkMerges = {} _Learner.pendingItemLinks = {} -- queue for async GetItemInfo retries +_Learner.npcNameIndex = nil +_Learner.npcNameIndexDirty = true -- Direct reference to learnedData, set on Initialize QuestieLearner.data = nil @@ -210,6 +270,49 @@ local function CopyWithoutField(data, skippedKey) return copy end +local function DeepCopy(value) + if type(value) ~= "table" then return value end + + local copy = {} + for key, child in pairs(value) do + copy[key] = DeepCopy(child) + end + return copy +end + +local function CaptureStaticOverrideSnapshot() + if _Learner.staticOverrideSnapshot then + return + end + + _Learner.staticOverrideSnapshot = { + npcs = DeepCopy(QuestieDB.npcDataOverrides or {}), + quests = DeepCopy(QuestieDB.questDataOverrides or {}), + items = DeepCopy(QuestieDB.itemDataOverrides or {}), + objects = DeepCopy(QuestieDB.objectDataOverrides or {}), + } +end + +local function RestoreStaticOverridesForMode() + local snapshot = _Learner.staticOverrideSnapshot + local mode = GetDataSourceMode() + + if mode == "learner" or mode == "none" then + WipeTable(QuestieDB.npcDataOverrides) + WipeTable(QuestieDB.questDataOverrides) + WipeTable(QuestieDB.itemDataOverrides) + WipeTable(QuestieDB.objectDataOverrides) + return + end + + if not snapshot then return end + + CopyTable(QuestieDB.npcDataOverrides, snapshot.npcs) + CopyTable(QuestieDB.questDataOverrides, snapshot.quests) + CopyTable(QuestieDB.itemDataOverrides, snapshot.items) + CopyTable(QuestieDB.objectDataOverrides, snapshot.objects) +end + -- Returns the grid-bucket key for a coordinate so nearby points share the same slot local function CoordBucket(x, y) return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID @@ -306,9 +409,17 @@ local function EnsureLearnedData() learnItems = true, learnObjects = true, minConfidencePins = 1, + spawnDedupRadius = 4.0, prioritizeMyData = true, + dataSourceMode = "auto", staleThreshold = 90, -- days pruneVerified = false, -- protect verified data by default + performanceMode = "balanced", + pinRefreshDelay = 0.75, + pinRefreshMode = "batched", + pinRefreshMaxWait = 5.0, + liveNpcUpdateDelay = 0.75, + learnerCommsIntensity = "normal", } else -- Backfill sub-tables that may be missing from older SavedVariables @@ -324,7 +435,44 @@ local function EnsureLearnedData() if s.learnItems == nil then s.learnItems = true end if s.learnObjects == nil then s.learnObjects = true end if s.minConfidencePins == nil then s.minConfidencePins = 1 end + if s.spawnDedupRadius == nil then s.spawnDedupRadius = 4.0 end if s.prioritizeMyData == nil then s.prioritizeMyData = true end + if s.dataSourceMode == nil then + if s.prioritizeMyData == false then + s.dataSourceMode = "static" + else + s.dataSourceMode = "auto" + end + end + if s.dataSourceMode == "static" or s.dataSourceMode == "none" then + s.prioritizeMyData = false + else + s.prioritizeMyData = true + end + if s.staleThreshold == nil then + s.staleThreshold = 90 + end + if s.pruneVerified == nil then + s.pruneVerified = false + end + if s.performanceMode == nil then + s.performanceMode = "balanced" + end + if s.pinRefreshDelay == nil then + s.pinRefreshDelay = 0.75 + end + if s.pinRefreshMode == nil then + s.pinRefreshMode = "batched" + end + if s.pinRefreshMaxWait == nil then + s.pinRefreshMaxWait = 5.0 + end + if s.liveNpcUpdateDelay == nil then + s.liveNpcUpdateDelay = 0.75 + end + if s.learnerCommsIntensity == nil then + s.learnerCommsIntensity = "normal" + end end return true end @@ -335,14 +483,66 @@ end function QuestieLearner:IsEnabled() if not EnsureLearnedData() then return false end + if IsBaseDatabaseMissing() then + return true + end return Questie.dbLearner.global.settings.enabled end +local function NotifyLearnerOptionsChanged() + if AceConfigRegistry and AceConfigRegistry.NotifyChange then + AceConfigRegistry:NotifyChange("Questie") + end +end + function QuestieLearner:GetSettings() if not EnsureLearnedData() then return {} end return Questie.dbLearner.global.settings end +function QuestieLearner:GetDataSourceMode() + if not EnsureLearnedData() then return "auto" end + return GetDataSourceMode() +end + +function QuestieLearner:IsLearnerLiveEnabled() + local mode = self:GetDataSourceMode() + return mode == "auto" or mode == "learner" +end + +function QuestieLearner:ApplyDataSourceMode() + if not EnsureLearnedData() then return end + local settings = Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings + if settings and GetDataSourceMode() == "learner" and settings.enabled == false then + settings.enabled = true + end + CaptureStaticOverrideSnapshot() + RestoreStaticOverridesForMode() + self:InjectLearnedData() + -- Rebuild every per-entity cache (including the per-zone quest cache) so the + -- mode switch takes effect immediately for reads, pins, and zone lookups. + if QuestieDB and QuestieDB.ClearModeCaches then + QuestieDB:ClearModeCaches() + elseif QuestieDB and QuestieDB.private then + QuestieDB.private.questCache = {} + QuestieDB.private.itemCache = {} + QuestieDB.private.npcCache = {} + QuestieDB.private.objectCache = {} + QuestieDB.private.zoneCache = {} + end + QuestieLearner.data = Questie.dbLearner.global +end + +local function GetLearnerSetting(key, defaultValue) + if Questie and Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings then + local value = Questie.dbLearner.global.settings[key] + if value ~= nil then + return value + end + end + return defaultValue +end + ------------------------------------------------------------------------ -- Cross-link engine -- After ANY entity is learned, scan all other learned data and stitch @@ -413,21 +613,120 @@ local function _GetDB() return Questie.dbLearner.global end -- Triggers QuestieQuest:UpdateQuest for every active quest in the player's log -- that is referenced in the provided set (table with questId keys). -- Called after cross-linking so map pins refresh immediately. -local function _RefreshActiveQuestPins(questIdSet) +local _pendingQuestPinRefreshes = {} +local _pendingQuestPinRefreshTimer = nil +local _pendingQuestPinFirstDirty = nil -- GetTime() of first pending change since last flush +local _pendingQuestPinLastActivity = nil -- GetTime() of most recent queued change +local _pendingQuestFrameUnloads = {} + +-- Trailing-debounce gate. When new learner activity keeps arriving, the flush is +-- pushed out by pinRefreshDelay (the "quiet window") so a fast kill streak — or a +-- crowd of nearby players — does not redraw pins every window. pinRefreshMaxWait +-- caps the worst-case latency: once that many seconds have elapsed since the first +-- pending change, the flush fires even if kills are still coming (0 = pure debounce, +-- never force). Only "batched" mode debounces; "immediate" flushes on first fire. +-- Performs the actual pin rebuild for every pending quest. No debounce gate — the +-- caller is responsible for deciding when to fire (either the trailing-debounce +-- wrapper below, or an immediate flush from the already-debounced NPC live-update +-- flush, which makes the redundant second debounce stage unnecessary). +local function _DoFlushActiveQuestPins() + local questIdSet = _pendingQuestPinRefreshes + _pendingQuestPinRefreshes = {} + _pendingQuestPinRefreshTimer = nil + _pendingQuestPinFirstDirty = nil + _pendingQuestPinLastActivity = nil + + if not next(questIdSet) then return end + if GetLearnerSetting("pinRefreshMode", "batched") == "manual" then + _pendingQuestFrameUnloads = {} + return + end if not QuestieQuest or not QuestieQuest.UpdateQuest then return end if not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end - -- Skip scheduling when the set is empty (avoids no-op timer callbacks) - if not next(questIdSet) then return end - local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) + for questId in pairs(questIdSet) do if QuestiePlayer.currentQuestlog[questId] then - if timer then - timer.After(0.1, function() QuestieQuest:UpdateQuest(questId) end) - else - QuestieQuest:UpdateQuest(questId) + if QuestieMap and QuestieMap.UnloadQuestFrames and _pendingQuestFrameUnloads[questId] then + QuestieMap:UnloadQuestFrames(questId) end + QuestieQuest:UpdateQuest(questId) end end + _pendingQuestFrameUnloads = {} +end + +local function _FlushActiveQuestPins() + -- Only defer while there is genuine pending activity. If the timestamps were + -- already cleared (e.g. the NPC live-update flush force-flushed the pins via + -- _DoFlushActiveQuestPins), a leftover timer must NOT treat the nil timestamp + -- as "now" and re-arm forever — it should fall through and flush (a no-op when + -- the pending set is empty). + if GetTime and GetLearnerSetting("pinRefreshMode", "batched") == "batched" + and _pendingQuestPinLastActivity then + local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) + local now = GetTime() + local delay = GetLearnerSetting("pinRefreshDelay", 0.75) + local maxWait = GetLearnerSetting("pinRefreshMaxWait", 5.0) + local quiet = now - _pendingQuestPinLastActivity + local waited = now - (_pendingQuestPinFirstDirty or _pendingQuestPinLastActivity) + if timer and timer.After and quiet < delay and (maxWait <= 0 or waited < maxWait) then + local remaining = delay - quiet + if maxWait > 0 then + local capRemaining = maxWait - waited + if capRemaining < remaining then remaining = capRemaining end + end + if remaining < 0 then remaining = 0 end + -- _pendingQuestPinRefreshTimer stays true so concurrent queues don't double-arm. + timer.After(remaining, _FlushActiveQuestPins) + return + end + end + + _DoFlushActiveQuestPins() +end + +-- When true, _RefreshActiveQuestPins only accumulates pending quests and does NOT +-- arm its own trailing-debounce timer. Set by _FlushNpcLiveUpdates, which already +-- debounced via liveNpcUpdateDelay and force-flushes the pins itself afterwards — +-- so the second debounce stage would only add redundant latency. +local _deferPinRefreshScheduling = false + +local function _RefreshActiveQuestPins(questIdSet) + -- Skip scheduling when the set is empty (avoids no-op timer callbacks) + if not next(questIdSet) then return end + if GetLearnerSetting("pinRefreshMode", "batched") == "manual" then + for questId in pairs(questIdSet) do + _pendingQuestFrameUnloads[questId] = nil + end + return + end + for questId in pairs(questIdSet) do + _pendingQuestPinRefreshes[questId] = true + end + + -- Record activity so the debounce gate in _FlushActiveQuestPins can re-arm. + local now = (GetTime and GetTime()) or 0 + _pendingQuestPinLastActivity = now + if not _pendingQuestPinFirstDirty then + _pendingQuestPinFirstDirty = now + end + + -- Caller (NPC live-update flush) will force-flush; don't arm a redundant timer. + if _deferPinRefreshScheduling then + return + end + + if _pendingQuestPinRefreshTimer then + return + end + + local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) + if timer and timer.After then + _pendingQuestPinRefreshTimer = true + timer.After(GetLearnerSetting("pinRefreshDelay", 0.75), _FlushActiveQuestPins) + else + _FlushActiveQuestPins() + end end -- Invalidates cached objective.spawnList for any active quest whose objectives @@ -543,8 +842,10 @@ local function _InvalidateSpawnListsForNPC(npcId) and (now - _invalidateDebounce[debounceKey]) < debounceWindow if not suppressUnload then _invalidateDebounce[debounceKey] = now - if QuestieMap and QuestieMap.UnloadQuestFrames then + if GetLearnerSetting("pinRefreshMode", "batched") == "immediate" and QuestieMap and QuestieMap.UnloadQuestFrames then QuestieMap:UnloadQuestFrames(questId) + else + _pendingQuestFrameUnloads[questId] = true end end questsToRefresh[questId] = true -- always refresh when data changed @@ -565,6 +866,171 @@ local function _InvalidateSpawnListsForNPC(npcId) end end +------------------------------------------------------------------------ +-- Live learner update batching +-- +-- Kill/loot bursts can call LearnNPC multiple times for the same NPC: +-- combat-log kill, quest-log objective progress, and loot correlation may all +-- arrive within a few frames. Saved learner evidence is updated immediately, +-- but live QuestieDB override/cache invalidation is batched so the large DB +-- layer is not churned on every single kill. + +local function _ApplyNpcLiveUpdate(npcId) + local existing = Questie.dbLearner + and Questie.dbLearner.global + and Questie.dbLearner.global.npcs + and Questie.dbLearner.global.npcs[npcId] + if not existing then return false end + + local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS + if existing.mc < threshold then return false end + if not (QuestieDB and QuestieDB.npcDataOverrides and existing[7] and next(existing[7])) then return false end + + local ovr = QuestieDB.npcDataOverrides[npcId] + if not ovr then + if IsAscensionProtected("NPC", npcId, 7) then + QuestieDB.npcDataOverrides[npcId] = DeepCopy(CopyWithoutField(existing, 7)) + else + QuestieDB.npcDataOverrides[npcId] = DeepCopy(existing) + end + else + -- Merge: fill missing fields; also overwrite empty-string names. + for k, v in pairs(existing) do + if k ~= 7 and not IsAscensionProtected("NPC", npcId, k) and (ovr[k] == nil or (k == 1 and ovr[k] == "")) then + ovr[k] = DeepCopy(v) + end + end + -- Always merge spawn coords. + if existing[7] and not IsAscensionProtected("NPC", npcId, 7) then + ovr[7] = ovr[7] or {} + for zid, coords in pairs(existing[7]) do + ovr[7][zid] = ovr[7][zid] or {} + for _, coord in ipairs(coords) do + InsertIfNewBucket(ovr[7][zid], coord[1], coord[2], GetCoordGridForZone(zid)) + end + end + end + end + + -- Clear the compiled DB cache once per flush so GetNPC rebuilds with the + -- latest coalesced override data instead of once per kill. + if QuestieDB.private and QuestieDB.private.npcCache then + QuestieDB.private.npcCache[npcId] = nil + end + + return true +end + +-- Trailing-debounce gate, mirroring _FlushActiveQuestPins. liveNpcUpdateDelay is +-- the quiet window; pinRefreshMaxWait is the shared worst-case cap (0 = never force). +local function _FlushNpcLiveUpdates() + local timer = QuestieCompat and QuestieCompat.C_Timer + local now = (GetTime and GetTime()) or 0 + local delay = GetLearnerSetting("liveNpcUpdateDelay", 0.75) + local maxWait = GetLearnerSetting("pinRefreshMaxWait", 5.0) + local quiet = now - (_Learner.pendingNpcLiveUpdateLastActivity or now) + local waited = now - (_Learner.pendingNpcLiveUpdateFirstDirty or now) + if GetTime and timer and timer.After and quiet < delay and (maxWait <= 0 or waited < maxWait) then + local remaining = delay - quiet + if maxWait > 0 then + local capRemaining = maxWait - waited + if capRemaining < remaining then remaining = capRemaining end + end + if remaining < 0 then remaining = 0 end + -- pendingNpcLiveUpdateTimer stays true so concurrent queues don't double-arm. + timer.After(remaining, _FlushNpcLiveUpdates) + return + end + + local pending = _Learner.pendingNpcLiveUpdates + _Learner.pendingNpcLiveUpdates = {} + _Learner.pendingNpcLiveUpdateTimer = nil + _Learner.pendingNpcLiveUpdateFirstDirty = nil + _Learner.pendingNpcLiveUpdateLastActivity = nil + + -- This flush already coalesced kills over liveNpcUpdateDelay. Suppress the + -- per-NPC pin-refresh debounce while invalidating, then flush all affected + -- quests once, immediately — instead of waiting out a second pinRefreshDelay. + _deferPinRefreshScheduling = true + for npcId in pairs(pending) do + if _ApplyNpcLiveUpdate(npcId) then + _InvalidateSpawnListsForNPC(npcId) + end + end + _deferPinRefreshScheduling = false + _DoFlushActiveQuestPins() +end + +local function _QueueNpcLiveUpdate(npcId) + if not (QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()) then + return + end + _Learner.pendingNpcLiveUpdates = _Learner.pendingNpcLiveUpdates or {} + _Learner.pendingNpcLiveUpdates[npcId] = true + + -- Record activity so the debounce gate in _FlushNpcLiveUpdates can re-arm. + local now = (GetTime and GetTime()) or 0 + _Learner.pendingNpcLiveUpdateLastActivity = now + if not _Learner.pendingNpcLiveUpdateFirstDirty then + _Learner.pendingNpcLiveUpdateFirstDirty = now + end + + if _Learner.pendingNpcLiveUpdateTimer then return end + + local timer = QuestieCompat and QuestieCompat.C_Timer + if timer and timer.After then + _Learner.pendingNpcLiveUpdateTimer = true + timer.After(GetLearnerSetting("liveNpcUpdateDelay", 0.75), _FlushNpcLiveUpdates) + else + _FlushNpcLiveUpdates() + end +end + +local function _MarkNpcNameIndexDirty() + _Learner.npcNameIndexDirty = true +end + +local function _RebuildNpcNameIndex() + local overrideIndex = {} + local baseIndex = {} + + if QuestieDB and QuestieDB.npcDataOverrides then + for npcId, data in pairs(QuestieDB.npcDataOverrides) do + local name = data and data[1] + if type(name) == "string" and name ~= "" then + overrideIndex[string.lower(name)] = npcId + end + end + end + + if QuestieDB and QuestieDB.npcData then + for npcId, data in pairs(QuestieDB.npcData) do + local name = data and data[1] + if type(name) == "string" and name ~= "" then + local lowerName = string.lower(name) + if overrideIndex[lowerName] == nil and baseIndex[lowerName] == nil then + baseIndex[lowerName] = npcId + end + end + end + end + + _Learner.npcNameIndex = { + override = overrideIndex, + base = baseIndex, + } + _Learner.npcNameIndexDirty = false +end + +local function _EnsureNpcNameIndex() + if _Learner.npcNameIndex and not _Learner.npcNameIndexDirty then + return _Learner.npcNameIndex + end + + _RebuildNpcNameIndex() + return _Learner.npcNameIndex +end + ------------------------------------------------------------------------ -- CrossLinkAfterNPC: called when a new NPC is first learned. -- Scans all learned quests for any reference to this npcId and stitches @@ -573,10 +1039,11 @@ local function CrossLinkAfterNPC(npcId) local learned = _GetDB() local npcData = learned.npcs[npcId] if not npcData then return end - local npcOvr = QuestieDB and QuestieDB.npcDataOverrides + local liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled() + local npcOvr = liveEnabled and QuestieDB and QuestieDB.npcDataOverrides or nil for questId, qData in pairs(learned.quests) do - local qOvr = QuestieDB and QuestieDB.questDataOverrides + local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil -- Quest starters: quest[2][1] lists NPCs that start this quest if qData[2] and qData[2][1] then @@ -643,9 +1110,10 @@ local function CrossLinkAfterQuest(questId) local learned = _GetDB() local qData = learned.quests[questId] if not qData then return end - local qOvr = QuestieDB and QuestieDB.questDataOverrides - local npcOvr = QuestieDB and QuestieDB.npcDataOverrides - local objOvr = QuestieDB and QuestieDB.objectDataOverrides + local liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled() + local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil + local npcOvr = liveEnabled and QuestieDB and QuestieDB.npcDataOverrides or nil + local objOvr = liveEnabled and QuestieDB and QuestieDB.objectDataOverrides or nil -- Starter NPCs: quest[2][1] → npc[10] if qData[2] and qData[2][1] then @@ -685,7 +1153,7 @@ local function CrossLinkAfterQuest(questId) if iData then if not iData[5] then iData[5] = questId - if QuestieDB and QuestieDB.itemDataOverrides then + if liveEnabled and QuestieDB and QuestieDB.itemDataOverrides then local ovr = QuestieDB.itemDataOverrides[qData[11]] or {} QuestieDB.itemDataOverrides[qData[11]] = ovr if not ovr[5] then ovr[5] = questId end @@ -715,8 +1183,9 @@ local function CrossLinkAfterObject(objectId) local learned = _GetDB() local objData = learned.objects[objectId] if not objData then return end - local objOvr = QuestieDB and QuestieDB.objectDataOverrides - local qOvr = QuestieDB and QuestieDB.questDataOverrides + local liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled() + local objOvr = liveEnabled and QuestieDB and QuestieDB.objectDataOverrides or nil + local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil for questId, qData in pairs(learned.quests) do -- Object starters: quest[2][2] @@ -766,7 +1235,8 @@ local function CrossLinkAfterItem(itemId) local learned = _GetDB() local iData = learned.items[itemId] if not iData then return end - local qOvr = QuestieDB and QuestieDB.questDataOverrides + local liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled() + local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil -- If this item starts a quest (item[5]=startQuest), ensure that quest knows -- about it via quest[2][3] (starter items slot) @@ -774,7 +1244,7 @@ local function CrossLinkAfterItem(itemId) if qData[11] == itemId then if not iData[5] then iData[5] = questId - if QuestieDB and QuestieDB.itemDataOverrides then + if liveEnabled and QuestieDB and QuestieDB.itemDataOverrides then local ovr = QuestieDB.itemDataOverrides[itemId] or {} QuestieDB.itemDataOverrides[itemId] = ovr if not ovr[5] then ovr[5] = questId end @@ -802,9 +1272,10 @@ end local function CrossLinkAfterQuestGiver(questId, entityId, typeSlot, isStart) local learned = _GetDB() local qData = learned.quests[questId] - local npcOvr = QuestieDB and QuestieDB.npcDataOverrides - local objOvr = QuestieDB and QuestieDB.objectDataOverrides - local qOvr = QuestieDB and QuestieDB.questDataOverrides + local liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled() + local npcOvr = liveEnabled and QuestieDB and QuestieDB.npcDataOverrides or nil + local objOvr = liveEnabled and QuestieDB and QuestieDB.objectDataOverrides or nil + local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil if typeSlot == 1 then -- NPC ↔ quest @@ -870,7 +1341,10 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS Questie.dbLearner.global.npcs[npcId] = existing end - if name and name ~= "" and (not existing[1] or existing[1] == "") then existing[1] = name end + if name and name ~= "" and (not existing[1] or existing[1] == "") then + existing[1] = name + _MarkNpcNameIndexDirty() + end if level then if not existing[4] or level < existing[4] then existing[4] = level end if not existing[5] or level > existing[5] then existing[5] = level end @@ -887,48 +1361,16 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS existing.ls = time() -- Update last seen existing.mc = (existing.mc or 0) + 1 - local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS - - -- Live injection: update npcDataOverrides only if confidence threshold is met - -- and only if the NPC has actual spawn data from kills (not just player-position fallback) - if existing.mc >= threshold and QuestieDB and QuestieDB.npcDataOverrides and existing[7] and next(existing[7]) then - local ovr = QuestieDB.npcDataOverrides[npcId] - if not ovr then - if IsAscensionProtected("NPC", npcId, 7) then - QuestieDB.npcDataOverrides[npcId] = CopyWithoutField(existing, 7) - else - QuestieDB.npcDataOverrides[npcId] = existing - end - else - -- Merge: fill missing fields; also overwrite empty-string names - for k, v in pairs(existing) do - if not IsAscensionProtected("NPC", npcId, k) and (ovr[k] == nil or (k == 1 and ovr[k] == "")) then ovr[k] = v end - end - -- Always merge spawn coords - if existing[7] and not IsAscensionProtected("NPC", npcId, 7) then - ovr[7] = ovr[7] or {} - for zid, coords in pairs(existing[7]) do - ovr[7][zid] = ovr[7][zid] or {} - for _, coord in ipairs(coords) do - InsertIfNewBucket(ovr[7][zid], coord[1], coord[2], GetCoordGridForZone(zid)) - end - end - end - end - -- Clear the compiled DB cache so GetNPC rebuilds with the new override data. - if QuestieDB.private and QuestieDB.private.npcCache then - QuestieDB.private.npcCache[npcId] = nil - end + -- Live injection is intentionally batched: repeated kill/log/loot events for + -- the same NPC update saved evidence immediately, then flush QuestieDB once. + if self:IsLearnerLiveEnabled() then + _QueueNpcLiveUpdate(npcId) end if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?") CrossLinkAfterNPC(npcId) end - -- Existing NPC got new spawn data: invalidate cached spawnLists - -- for any active quest objective that references this NPC so the - -- map system rebuilds them with fresh data on next update. - _InvalidateSpawnListsForNPC(npcId) _Learner:BroadcastIfCommsAvailable("NPC", npcId, existing) end @@ -1092,12 +1534,17 @@ local function _MergeSpawnEvidence(npcId) entry.y = evidenceY local rx, ry = NormalizeCoordPair(evidenceX, evidenceY) - local key = entry.zoneId .. "|" .. tostring(rx) .. "|" .. tostring(ry) - -- DEBUG: log each entry being grouped - Questie:Debug(Questie.DEBUG_LEARNER, - "[QuestieLearner] _MergeSpawnEvidence GROUPING: spawnUID=", spawnUID, - "entry.x=", entry.x, "entry.y=", entry.y, - "rx=", rx, "ry=", ry, "key=", key) + -- Group kills by a coordinate bucket, not by exact coords. Kill + -- evidence is the player's position at kill time, which drifts a + -- little every kill, so exact keys would treat each kill as its own + -- "location" — producing one pin per kill and never letting any + -- single spot accumulate enough evidence to clear the confidence + -- threshold. Bucketing collapses repeated kills at the same spawn + -- into one location (matching the [7] InsertIfNewBucket behavior). + local grid = GetCoordGridForZone(entry.zoneId) + local bx = floor(rx / grid) + local by = floor(ry / grid) + local key = entry.zoneId .. "|" .. bx .. "|" .. by if not evidence[key] then evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 } end @@ -1136,6 +1583,9 @@ local function _MergeSpawnEvidence(npcId) -- Confidence threshold is bypassed for Sunstrider because spawn points are -- distributed across 5+ locations — no single point ever reaches 60% of kills. local isSunstrider = IsSunstriderNativeZone(topEvidence.zoneId) + local learnerLiveMode = QuestieLearner + and QuestieLearner.IsLearnerLiveEnabled + and QuestieLearner:IsLearnerLiveEnabled() local confidenceThreshold = isSunstrider and 0 or 60 -- Only override if > confidence threshold AND spawn differs from static DB @@ -1157,7 +1607,7 @@ local function _MergeSpawnEvidence(npcId) -- AscensionDB coords with in-game kill evidence, causing wrong pin counts. -- REGRESSION NOTE: If AscensionDB protection check is removed or disabled, -- learner pins will reappear at wrong locations. Do not remove this guard. - if IsAscensionProtected("NPC", npcId, 7) then + if (not learnerLiveMode) and IsAscensionProtected("NPC", npcId, 7) then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "Sunstrider zone but AscensionDB owns spawns — skipping learner injection") @@ -1196,9 +1646,7 @@ local function _MergeSpawnEvidence(npcId) "duplicates", duplicates, "zone", tostring(topEvidence.zoneId)) - if QuestieDB.private and QuestieDB.private.npcCache then - QuestieDB.private.npcCache[npcId] = nil - end + _QueueNpcLiveUpdate(npcId) return promoted > 0 or duplicates > 0 end @@ -1287,10 +1735,8 @@ local function _MergeSpawnEvidence(npcId) "zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence", spawned and "SPAM" or "IGNORED_DUPLICATE") - -- Clear npcCache so GetNPC returns fresh data - if QuestieDB.private and QuestieDB.private.npcCache then - QuestieDB.private.npcCache[npcId] = nil - end + -- Clear/rebuild cache through the same coalesced path used by kill learning. + _QueueNpcLiveUpdate(npcId) return true end @@ -1336,7 +1782,7 @@ function QuestieLearner:LearnQuest(questId, data) existing.mc = (existing.mc or 0) + 1 -- Live injection into questDataOverrides so GetQuest works without reload - if QuestieDB and QuestieDB.questDataOverrides then + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides then local ovr = QuestieDB.questDataOverrides[questId] if not ovr then QuestieDB.questDataOverrides[questId] = existing @@ -1380,7 +1826,7 @@ function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart) table.insert(list, entityId) -- Live injection into questDataOverrides so starters/finishers take effect without reload - if QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, field) then + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, field) then local ovr = QuestieDB.questDataOverrides[questId] or {} QuestieDB.questDataOverrides[questId] = ovr ovr[field] = ovr[field] or {} @@ -1446,7 +1892,7 @@ function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiv end -- 2. Apply to live questDataOverrides immediately (no reload needed) - if QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, 10) then + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, 10) then local ovr = QuestieDB.questDataOverrides[questId] or {} QuestieDB.questDataOverrides[questId] = ovr ovr[10] = ovr[10] or {} @@ -1467,16 +1913,14 @@ function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiv end -- 3. Re-process the quest so PopulateObjective registers tooltips & map pins - if QuestieQuest and QuestieQuest.UpdateQuest then - QuestieCompat.C_Timer.After(0.5, function() - QuestieQuest:UpdateQuest(questId) - end) + if self:IsLearnerLiveEnabled() then + _RefreshActiveQuestPins({ [questId] = true }) end -- 3. Register with tooltip system immediately. Preserve the objective icon so -- nameplates can render the correct learned slay/loot/talk marker. local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips") - if QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip then + if self:IsLearnerLiveEnabled() and QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip and not HasAscensionQuestObjectiveData(questId) then local objectiveIcon local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache") local objectives = QuestLogCache and QuestLogCache.GetQuestObjectives and QuestLogCache.GetQuestObjectives(questId) @@ -1501,6 +1945,69 @@ function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiv "[QuestieLearner] Quest", questId, "objective NPC learned:", npcId, objText) end +-- Adds objectId as an objectObjective for questId ([10][2] in questKeys schema). +function QuestieLearner:LearnQuestObjectiveObject(questId, objectId, objText, objectiveIndex) + if not self:IsEnabled() then return end + if not Questie.dbLearner.global.settings.learnQuests then return end + questId, objectId = tonumber(questId), tonumber(objectId) + objectiveIndex = tonumber(objectiveIndex) + if not questId or questId <= 0 or not objectId or objectId <= 0 then return end + + local existing = Questie.dbLearner.global.quests[questId] or {} + Questie.dbLearner.global.quests[questId] = existing + existing[10] = existing[10] or {} + existing[10][2] = existing[10][2] or {} + local alreadyInSV = false + for _, entry in ipairs(existing[10][2]) do + if entry[1] == objectId then alreadyInSV = true; break end + end + if not alreadyInSV then + table.insert(existing[10][2], { objectId, objText or "" }) + end + + if objectiveIndex then + existing.objIndex = existing.objIndex or {} + local entry = existing.objIndex[objectiveIndex] + if not entry then + existing.objIndex[objectiveIndex] = { type = "object", id = objectId, text = objText or "" } + end + end + + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, 10) then + local ovr = QuestieDB.questDataOverrides[questId] or {} + QuestieDB.questDataOverrides[questId] = ovr + ovr[10] = ovr[10] or {} + ovr[10][2] = ovr[10][2] or {} + local alreadyPresent = false + for _, entry in ipairs(ovr[10][2]) do + if entry[1] == objectId then alreadyPresent = true; break end + end + if not alreadyPresent then + table.insert(ovr[10][2], { objectId, objText or "" }) + end + if objectiveIndex then + ovr.objIndex = ovr.objIndex or {} + ovr.objIndex[objectiveIndex] = existing.objIndex[objectiveIndex] + end + end + + if self:IsLearnerLiveEnabled() then + _RefreshActiveQuestPins({ [questId] = true }) + end + + local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips") + if self:IsLearnerLiveEnabled() and QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip and not HasAscensionQuestObjectiveData(questId) then + QuestieTooltips:RegisterObjectiveTooltip(questId, "o_" .. objectId, { + Index = 0, + Description = objText or "Learned Objective", + Update = function() end + }) + end + + Questie:Debug(Questie.DEBUG_LEARNER, + "[QuestieLearner] Quest", questId, "objective OBJECT learned:", objectId, objText) +end + ------------------------------------------------------------------------ -- Item learning ------------------------------------------------------------------------ @@ -1527,7 +2034,7 @@ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemCl existing.mc = (existing.mc or 0) + 1 -- Live injection into itemDataOverrides so QueryItemSingle works without reload - if QuestieDB and QuestieDB.itemDataOverrides then + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.itemDataOverrides then local ovr = QuestieDB.itemDataOverrides[itemId] if not ovr then QuestieDB.itemDataOverrides[itemId] = existing @@ -1566,7 +2073,7 @@ function QuestieLearner:LearnItemDrop(itemId, npcId) table.insert(existing[2], npcId) -- Live injection: sync drop list to itemDataOverrides - if QuestieDB and QuestieDB.itemDataOverrides and not IsAscensionProtected("ITEM", itemId, 2) then + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.itemDataOverrides and not IsAscensionProtected("ITEM", itemId, 2) then local ovr = QuestieDB.itemDataOverrides[itemId] or {} QuestieDB.itemDataOverrides[itemId] = ovr ovr[2] = ovr[2] or {} @@ -1614,7 +2121,7 @@ function QuestieLearner:LearnObject(objectId, name) existing.mc = (existing.mc or 0) + 1 -- Live injection into objectDataOverrides so QueryObjectSingle works without reload - if QuestieDB and QuestieDB.objectDataOverrides then + if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.objectDataOverrides then local ovr = QuestieDB.objectDataOverrides[objectId] if not ovr then QuestieDB.objectDataOverrides[objectId] = existing @@ -1685,7 +2192,14 @@ function QuestieLearner:InjectLearnedData() if not EnsureLearnedData() then return end if not self:IsEnabled() then QuestieLearner.data = Questie.dbLearner.global - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] InjectLearnedData skipped because learner is disabled") + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] InjectLearnedData skipped because learner is disabled") + return + end + + local mode = self:GetDataSourceMode() + if mode == "static" or mode == "none" then + QuestieLearner.data = Questie.dbLearner.global + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] InjectLearnedData skipped because data source mode is", mode) return end @@ -1850,7 +2364,7 @@ function QuestieLearner:InjectLearnedData() local normalizedZone = NormalizeSpawnZoneKey(data[9]) local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone) if maybeAreaId and maybeAreaId ~= data[9] then - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId) + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId) data[9] = maybeAreaId fieldsFixed = fieldsFixed + 1 end @@ -1861,7 +2375,7 @@ function QuestieLearner:InjectLearnedData() local normalizedZone = NormalizeSpawnZoneKey(data[5]) local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone) if maybeAreaId and maybeAreaId ~= data[5] then - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId) + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId) data[5] = maybeAreaId fieldsFixed = fieldsFixed + 1 end @@ -1933,7 +2447,7 @@ function QuestieLearner:InjectLearnedData() if learned.npcs[objId] then learned.objects[objId] = nil dupObjectsRemoved = dupObjectsRemoved + 1 - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Removed duplicate Object", objId, "(NPC version exists)") + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Removed duplicate Object", objId, "(NPC version exists)") end end if dupObjectsRemoved > 0 then @@ -1942,6 +2456,7 @@ function QuestieLearner:InjectLearnedData() -- 1. NPCs local npcIdsToFix = {} + local npcNameIndexNeedsRebuild = false for npcId, data in pairs(learned.npcs) do local nid = tonumber(npcId) if type(npcId) == "string" and nid then @@ -1954,12 +2469,14 @@ function QuestieLearner:InjectLearnedData() -- and can pollute curated plugin spawn tables. QuestieDB.npcDataOverrides[nid or npcId] = CopyWithoutField(data, 7) npcCount = npcCount + 1 + if data[1] then npcNameIndexNeedsRebuild = true end else local existing = QuestieDB.npcDataOverrides[nid or npcId] -- Adopt other fields if missing for k, v in pairs(data) do if k ~= "mc" and k ~= 7 and existing[k] == nil and not IsAscensionProtected("NPC", nid or npcId, k) then existing[k] = v + if k == 1 then npcNameIndexNeedsRebuild = true end end end end @@ -1969,14 +2486,19 @@ function QuestieLearner:InjectLearnedData() learned.npcs[old] = nil end - -- Diagnostic: log how many NPCs were injected with spawn overrides - local spawnOverrideCount = 0 - for nid, ovr in pairs(QuestieDB.npcDataOverrides) do - if ovr[7] and next(ovr[7]) then - spawnOverrideCount = spawnOverrideCount + 1 + if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then + -- Diagnostic: log how many NPCs were injected with spawn overrides + local spawnOverrideCount = 0 + for nid, ovr in pairs(QuestieDB.npcDataOverrides) do + if ovr[7] and next(ovr[7]) then + spawnOverrideCount = spawnOverrideCount + 1 + end end + Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieLearner] InjectLearnedData: injected", npcCount, "NPCs (", spawnOverrideCount, "with spawn overrides)") + end + if npcNameIndexNeedsRebuild then + _MarkNpcNameIndexDirty() end - Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieLearner] InjectLearnedData: injected", npcCount, "NPCs (", spawnOverrideCount, "with spawn overrides)") -- Purge garbage quest entries: quests with no name [1] and only mc/ls metadata. -- These are Ascension internal tracking artifacts (hash-like IDs) with no real quest data. @@ -2003,7 +2525,7 @@ function QuestieLearner:InjectLearnedData() local qid = tonumber(questId) if not hasRealData or (qid and OBJECTIVE_TEXT_QUEST_IDS[qid]) then local reason = not hasRealData and "garbage" or "objective-text" - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Purged", reason, "quest", questId, data[1] or "?") + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Purged", reason, "quest", questId, data[1] or "?") learned.quests[questId] = nil purgedQuests = purgedQuests + 1 end @@ -2042,7 +2564,7 @@ function QuestieLearner:InjectLearnedData() if sortKey then data[17] = sortKey sortKeysInferred = sortKeysInferred + 1 - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Inferred sortKey", sortKey, "for quest", questId, data[1]) + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Inferred sortKey", sortKey, "for quest", questId, data[1]) end end end @@ -2183,10 +2705,23 @@ end function QuestieLearner:ClearAllData() if not EnsureLearnedData() then return end - Questie.dbLearner.global.npcs = {} - Questie.dbLearner.global.quests = {} - Questie.dbLearner.global.items = {} - Questie.dbLearner.global.objects = {} + local learned = Questie.dbLearner.global + local settings = learned.settings or {} + + for key in pairs(learned) do + if key ~= "settings" then + learned[key] = nil + end + end + + learned.settings = settings + learned.npcs = {} + learned.quests = {} + learned.items = {} + learned.objects = {} + + self:ApplyDataSourceMode() + NotifyLearnerOptionsChanged() Questie:Print("Cleared all learned data.") end @@ -2304,6 +2839,47 @@ local GetObjectIdFromGUID = function(guid) return nil end +local function TraceLearnerEntity(source, guid, unitType, entityId, name) + if not Questie or not Questie.Debug then return end + local prefix = "n/a" + if type(guid) == "string" and guid:sub(1, 2) == "0x" then + prefix = string.upper(guid:sub(3, 6)) + end + Questie:Debug( + Questie.DEBUG_DEVELOP, + "[QuestieLearner:ObjectTrace]", + source, + "guid=", + tostring(guid), + "prefix=", + tostring(prefix), + "unitType=", + tostring(unitType), + "id=", + tostring(entityId), + "name=", + tostring(name) + ) +end + +local function ResolveObjectName(objectId) + if not objectId or objectId <= 0 then return nil end + local name = QuestieDB and QuestieDB.QueryObjectSingle and QuestieDB.QueryObjectSingle(objectId, "name") + if name and name ~= "" then return name end + if l10n and l10n.objectNameLookup then + for localizedName, ids in pairs(l10n.objectNameLookup) do + if ids then + for _, id in ipairs(ids) do + if id == objectId then + return localizedName + end + end + end + end + end + return nil +end + ------------------------------------------------------------------------ -- Event handlers ------------------------------------------------------------------------ @@ -2325,8 +2901,16 @@ function QuestieLearner:OnMouseoverUnit() if guid == _Learner._lastMouseoverGuid then return end _Learner._lastMouseoverGuid = guid - local npcId = GetNpcIdFromGUID(guid) - if not npcId or npcId <= 0 then return end + local entityId, unitType = GetIdAndTypeFromGUID(guid) + local name = UnitName("mouseover") + TraceLearnerEntity("mouseover", guid, unitType, entityId, name) + + if not entityId or entityId <= 0 then return end + if unitType == "GameObject" then + self:LearnObject(entityId, name) + return + end + if unitType ~= "Creature" and unitType ~= "Vehicle" then return end -- Only learn this NPC if it carries the questgiver flag OR if it is already -- known in the database as a starter/finisher (so we can update its coords). @@ -2335,7 +2919,7 @@ function QuestieLearner:OnMouseoverUnit() if not isQuestGiver then -- Silently check raw table — do NOT call GetNPC which logs CRITICAL for every miss - local rawNpc = QuestieDB and QuestieDB.npcData and QuestieDB.npcData[npcId] + local rawNpc = QuestieDB and QuestieDB.npcData and QuestieDB.npcData[entityId] if rawNpc and (rawNpc[10] or rawNpc[11]) then -- known quest starter (key 10) or quest ender (key 11) isQuestGiver = true @@ -2344,7 +2928,6 @@ function QuestieLearner:OnMouseoverUnit() if not isQuestGiver then return end - local name = UnitName("mouseover") local level = UnitLevel("mouseover") local zoneText = GetRealZoneText() local areaId = _Learner.zoneCache[zoneText] @@ -2373,7 +2956,7 @@ function QuestieLearner:OnMouseoverUnit() -- correct areaId (3430 for Sunstrider/Eversong) rather than falling back -- to GetZoneId() which may return the same value but via a different path. -- GetPlayerCoords() fallback in LearnNPC will provide the coordinates. - self:LearnNPC(npcId, name, level, subName, npcFlags, factionString, nil, nil, areaId) + self:LearnNPC(entityId, name, level, subName, npcFlags, factionString, nil, nil, areaId) end function QuestieLearner:OnTargetChanged() @@ -2385,14 +2968,21 @@ function QuestieLearner:OnTargetChanged() if guid == _Learner._lastTargetGuid then return end _Learner._lastTargetGuid = guid - local npcId = GetNpcIdFromGUID(guid) - if not npcId or npcId <= 0 then return end - local name = UnitName("target") + local entityId, unitType = GetIdAndTypeFromGUID(guid) + TraceLearnerEntity("target", guid, unitType, entityId, name) + + if not entityId or entityId <= 0 then return end + if unitType == "GameObject" then + self:LearnObject(entityId, name) + return + end + if unitType ~= "Creature" and unitType ~= "Vehicle" then return end + local level = UnitLevel("target") _Learner.guidNpcCache = _Learner.guidNpcCache or {} - _Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() } + _Learner.guidNpcCache[guid] = { npcId = entityId, name = name, ts = time() } end -- Collects all available quest data from the quest detail/offer screen (before accepting) @@ -2422,6 +3012,7 @@ function QuestieLearner:OnQuestDetail() local npcGuid = UnitGUID("npc") if npcGuid then local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) + TraceLearnerEntity("quest_detail", npcGuid, unitType, entityId, UnitName("npc")) if entityId and entityId > 0 then local entityName = UnitName("npc") if unitType == "GameObject" then @@ -2454,6 +3045,7 @@ function QuestieLearner:OnQuestComplete() local npcGuid = UnitGUID("npc") if npcGuid then local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) + TraceLearnerEntity("quest_complete", npcGuid, unitType, entityId, UnitName("npc")) if entityId and entityId > 0 then local entityName = UnitName("npc") if unitType == "GameObject" then @@ -2468,30 +3060,16 @@ function QuestieLearner:OnQuestComplete() end end --- Helper to find an NPC ID by name (case-insensitive substring match) +-- Helper to find an NPC ID by name (case-insensitive exact match) -- Used for proactive objective mapping when a quest is first accepted. function QuestieLearner:GetNPCIdByName(npcName) if not npcName or npcName == "" then return nil end local lowerName = string.lower(npcName) - - -- Check overrides first (most likely for custom servers) - if QuestieDB.npcDataOverrides then - for id, data in pairs(QuestieDB.npcDataOverrides) do - if data and data[1] and string.lower(data[1]) == lowerName then - return id - end - end - end - -- Check base NPC database - -- Note: QueryNPCSingle is a dummy until initialization, but we can fallback to raw access - local npcData = QuestieDB.npcData or {} - for id, data in pairs(npcData) do - if data and data[1] and string.lower(data[1]) == lowerName then - return id - end - end - return nil + local index = _EnsureNpcNameIndex() + local overrideId = index.override[lowerName] + if overrideId then return overrideId end + return index.base[lowerName] end -- Fires when a quest is accepted. @@ -2566,7 +3144,7 @@ function QuestieLearner:OnQuestAccepted(firstArg, secondArg) local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(logIdx) or 0 for j = 1, numObj do local objText, objType, finished = GetQuestLogLeaderBoard(j, logIdx) - if objText and not finished and (objType == "monster" or objType == "killcredit") then + if objText and not finished and (objType == "monster" or objType == "killcredit" or objType == "object") then local targetName = objText:match("^%d+/%d+%s+(.+)%s*") or objText:match("^(.+):%s*%d+/%d+") if not targetName then targetName = objText:gsub("%d+/%d+", ""):gsub("%d+", ""):gsub("[:!?,.%(%)]", ""):gsub("^%s+", ""):gsub("%s+$", "") @@ -2574,6 +3152,7 @@ function QuestieLearner:OnQuestAccepted(firstArg, secondArg) if targetName and targetName ~= "" then local npcId = nil + local objectId = nil -- For killcredit, try ID-based lookup first using quest objectives data if objType == "killcredit" then local quest = QuestieDB and QuestieDB.GetQuest and QuestieDB.GetQuest(questId) @@ -2603,14 +3182,36 @@ function QuestieLearner:OnQuestAccepted(firstArg, secondArg) end end end + elseif objType == "object" then + local now = time() + local bestObject = nil + for _, obj in pairs(_Learner.recentObjects or {}) do + if obj and obj.name and obj.name ~= "" and (now - (obj.ts or 0)) <= 10 then + local objName = string.lower(obj.name) + local text = string.lower(objText) + local target = string.lower(targetName) + local match = objName == target + or string.find(text, objName, 1, true) + or string.find(objName, text, 1, true) + if match and (not bestObject or (obj.ts or 0) > (bestObject.ts or 0)) then + bestObject = obj + end + end + end + if bestObject and bestObject.objectId then + objectId = bestObject.objectId + end end -- Fallback to name-based lookup - if not npcId then + if not npcId and not objectId then npcId = self:GetNPCIdByName(targetName) end if npcId then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Proactively mapped objective", j, "to NPC", npcId, "(" .. targetName .. ")") self:LearnQuestObjectiveNPC(questId, npcId, objText, j) + elseif objectId then + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Proactively mapped objective", j, "to OBJECT", objectId, "(" .. targetName .. ")") + self:LearnQuestObjectiveObject(questId, objectId, objText, j) end end end @@ -2675,14 +3276,18 @@ function QuestieLearner:OnLootOpened() if not Questie.dbLearner.global.settings.learnItems then return end local targetGuid = UnitGUID("target") - local npcId = targetGuid and GetNpcIdFromGUID(targetGuid) or nil - - -- Also try to record the object if target is a game object + local targetId, targetType = nil, nil + local npcId = nil if targetGuid then - local objId = GetObjectIdFromGUID(targetGuid) - if objId and objId > 0 then - local objName = UnitName("target") - self:LearnObject(objId, objName) + targetId, targetType = GetIdAndTypeFromGUID(targetGuid) + TraceLearnerEntity("loot_target", targetGuid, targetType, targetId, UnitName("target")) + if targetType == "GameObject" and targetId and targetId > 0 then + self:LearnObject(targetId, UnitName("target")) + end + if targetType == "Creature" or targetType == "Vehicle" then + npcId = targetId + else + npcId = GetNpcIdFromGUID(targetGuid) end end @@ -2690,6 +3295,35 @@ function QuestieLearner:OnLootOpened() for i = 1, numItems do local _, lootName, _, _, lootQuality = GetLootSlotInfo(i) if lootName then + if GetLootSourceInfo then + local sources = { GetLootSourceInfo(i) } + local sourceCount = table.getn(sources) + for j = 1, sourceCount, 2 do + local sourceGuid = sources[j] + local sourceQty = sources[j + 1] + Questie:Debug( + Questie.DEBUG_DEVELOP, + "[QuestieLearner:LootSourceTrace]", + "slot=", + i, + "sourceIndex=", + j, + "guid=", + tostring(sourceGuid), + "qty=", + tostring(sourceQty), + "lootName=", + tostring(lootName) + ) + TraceLearnerEntity("loot_source", sourceGuid, nil, sourceQty, lootName) + if type(sourceGuid) == "string" then + local sourceId, sourceType = GetIdAndTypeFromGUID(sourceGuid) + if sourceType == "GameObject" and sourceId and sourceId > 0 then + self:LearnObject(sourceId, nil) + end + end + end + end local link = GetLootSlotLink(i) if link then local itemId = tonumber(string.match(link, "item:(%d+)")) @@ -2711,6 +3345,31 @@ function QuestieLearner:OnLootOpened() end end +function QuestieLearner:OnGameObjectUsed(objectId) + if not self:IsEnabled() then return end + if not Questie.dbLearner.global.settings.learnObjects then return end + objectId = tonumber(objectId) + if not objectId or objectId <= 0 then return end + + local objectName = ResolveObjectName(objectId) + Questie:Debug( + Questie.DEBUG_DEVELOP, + "[QuestieLearner:GameObjectUsedTrace]", + "id=", + tostring(objectId), + "name=", + tostring(objectName) + ) + TraceLearnerEntity("gameobject_used", nil, "GameObject", objectId, objectName) + self:LearnObject(objectId, objectName) + _Learner.recentObjects[objectId] = { + objectId = objectId, + name = objectName, + ts = time(), + zoneId = GetZoneId(), + } +end + function QuestieLearner:OnGossipShow() local npcGuid = UnitGUID("npc") if not npcGuid then return end @@ -2719,6 +3378,7 @@ function QuestieLearner:OnGossipShow() if not id or id <= 0 then return end local name = UnitName("npc") + TraceLearnerEntity("gossip", npcGuid, unitType, id, name) -- Cache the last gossip entity so OnQuestAccepted can associate it after GOSSIP_CLOSED _Learner._lastGossipEntity = { id = id, name = name, unitType = unitType, guid = npcGuid } @@ -2788,6 +3448,7 @@ end ------------------------------------------------------------------------ -- Cache recent kills: guid → {npcId, name, x, y, zoneId, ts} _Learner.recentKills = _Learner.recentKills or {} +_Learner.recentObjects = _Learner.recentObjects or {} -- Previous objective counts for active quests: questId → {[idx] = count} _Learner.prevObjCounts = _Learner.prevObjCounts or {} @@ -2848,6 +3509,34 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, return end + -- Player/group engagement tracking. Quest-progress correlation (OnQuestLogUpdate) + -- must never attribute a *nearby* player's kill to our own objectives. We record + -- which mobs we — or our pet/party/raid — actually damaged, so a kill is only + -- "credited" to us if it was a PARTY_KILL or we recently engaged that GUID. + -- This runs before the kill-event filter so damage events are captured too. + if dstGUID and srcGUID then + local mine = false + local playerGUID = UnitGUID and UnitGUID("player") + if playerGUID and srcGUID == playerGUID then + mine = true + elseif UnitGUID and srcGUID == UnitGUID("pet") then + mine = true + elseif srcFlags and bit and bit.band then + -- Affiliation bits (mine/party/raid) flag damage from our group. + local ours = 0 + if COMBATLOG_OBJECT_AFFILIATION_MINE then ours = ours + COMBATLOG_OBJECT_AFFILIATION_MINE end + if COMBATLOG_OBJECT_AFFILIATION_PARTY then ours = ours + COMBATLOG_OBJECT_AFFILIATION_PARTY end + if COMBATLOG_OBJECT_AFFILIATION_RAID then ours = ours + COMBATLOG_OBJECT_AFFILIATION_RAID end + if ours ~= 0 and bit.band(srcFlags, ours) ~= 0 then + mine = true + end + end + if mine then + _Learner.playerEngaged = _Learner.playerEngaged or {} + _Learner.playerEngaged[dstGUID] = time() + end + end + if eventType == "SPELL_CAST_SUCCESS" then if srcGUID == UnitGUID("player") then self:LearnSpellCast(spellId, spellName, dstGUID, dstName) @@ -2861,19 +3550,34 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, -- Dedupe: if this GUID was processed within the last 5 seconds, skip. -- PARTY_KILL and UNIT_DIED can both fire for the same kill; we only need one. local now = time() - local lastTs = _Learner.killDebounce and _Learner.killDebounce[dstGUID] + local last = _Learner.killDebounce and _Learner.killDebounce[dstGUID] + local lastTs = type(last) == "table" and last.ts or last + local lastEventType = type(last) == "table" and last.eventType or nil if lastTs and (now - lastTs) < 5 then - -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID) - return + -- UNIT_DIED can arrive before PARTY_KILL for our own kill. Never let the + -- bystander-safe cache path suppress the authoritative local kill event. + if eventType ~= "PARTY_KILL" or lastEventType == "PARTY_KILL" then + -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID) + return + end end _Learner.killDebounce = _Learner.killDebounce or {} - _Learner.killDebounce[dstGUID] = now + _Learner.killDebounce[dstGUID] = { ts = now, eventType = eventType } -- Prune entries older than 10 seconds to keep the table bounded - for g, ts in pairs(_Learner.killDebounce) do + for g, entry in pairs(_Learner.killDebounce) do + local ts = type(entry) == "table" and entry.ts or entry if (now - ts) > 10 then _Learner.killDebounce[g] = nil end end + -- Prune stale engagement entries (mobs we damaged but never finished). + if _Learner.playerEngaged then + for g, ts in pairs(_Learner.playerEngaged) do + if (now - ts) > 60 then + _Learner.playerEngaged[g] = nil + end + end + end local npcId = GetNpcIdFromGUID(dstGUID) local name = dstName @@ -2909,14 +3613,21 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, end local zoneId = GetZoneId() local zoneText = GetRealZoneText and GetRealZoneText() or "" + -- Keep the credited flag for quest-progress correlation, but do not use it + -- to suppress learning. The learner should still harvest kill data from + -- nearby players so static import coverage stays as complete as possible. + local engagedTs = _Learner.playerEngaged and _Learner.playerEngaged[dstGUID] + local credited = (eventType == "PARTY_KILL") + or (engagedTs ~= nil and (now - engagedTs) <= 60) _Learner.recentKills[dstGUID] = { - npcId = npcId, - name = name or "", - x = px, - y = py, - zoneId = zoneId, - zone = zoneText, - ts = time(), + npcId = npcId, + name = name or "", + x = px, + y = py, + zoneId = zoneId, + zone = zoneText, + ts = time(), + credited = credited, } if dstName and dstName ~= "" then @@ -2925,6 +3636,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, -- Unconditionally map the spawn position for Ascension DB building self:LearnNPC(npcId, name, nil, nil, nil, nil, px, py, zoneId) + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Combat-log learned NPC:", eventType, npcId, name or "?") -- Phase 2: store per-GUID spawn evidence for weighted merge self:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, px, py) @@ -2957,7 +3669,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, if guidSpawns then local count = 0 for _ in pairs(guidSpawns) do count = count + 1 end - if count >= 1 then + if self:IsLearnerLiveEnabled() and count >= 1 then _MergeSpawnEvidence(npcId) end end @@ -3286,7 +3998,9 @@ function QuestieLearner:OnQuestLogUpdate() local now = time() local bestGuid, bestKill = nil, nil for guid, kill in pairs(_Learner.recentKills) do - if (now - kill.ts) <= 10 then + -- Only correlate kills credited to us; bystander kills + -- (nearby players) must never be learned as our objective. + if kill.credited and (now - kill.ts) <= 10 then if not bestKill or kill.ts > bestKill.ts then bestGuid, bestKill = guid, kill end @@ -3384,12 +4098,13 @@ function QuestieLearner:RegisterEvents() frame:RegisterEvent("QUEST_ACCEPTED") frame:RegisterEvent("LOOT_OPENED") frame:RegisterEvent("GOSSIP_SHOW") + frame:RegisterEvent("GAMEOBJECT_USED") frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") frame:RegisterEvent("GET_ITEM_INFO_RECEIVED") frame:RegisterEvent("UNIT_QUEST_LOG_CHANGED") frame:RegisterEvent("QUEST_REMOVED") - frame:SetScript("OnEvent", function(_, event) + frame:SetScript("OnEvent", function(_, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) if event == "UPDATE_MOUSEOVER_UNIT" then self:OnMouseoverUnit() elseif event == "PLAYER_TARGET_CHANGED" then @@ -3406,6 +4121,8 @@ function QuestieLearner:RegisterEvents() self:OnLootOpened() elseif event == "GOSSIP_SHOW" then self:OnGossipShow() + elseif event == "GAMEOBJECT_USED" then + self:OnGameObjectUsed(arg1) elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then -- arg1..arg10 must be captured HERE before any secondary call wipes them (3.3.5 behavior) -- arg1=timestamp, arg2=eventType, arg3=srcGUID, arg4=srcName, arg5=srcFlags, @@ -3431,7 +4148,7 @@ function QuestieLearner:Initialize() EnsureLearnedData() QuestieLearner.data = Questie.dbLearner.global self:RegisterEvents() - self:InjectLearnedData() + self:ApplyDataSourceMode() _RegisterLearnedSpawnTooltipHook() local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") @@ -3460,7 +4177,7 @@ function QuestieLearner:ScanExistingQuestLog() if not Questie.dbLearner.global.settings.learnQuests then return end if not GetNumQuestLogEntries then return end - Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Scanning existing quest log...") + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Scanning existing quest log...") local count = 0 for i = 1, GetNumQuestLogEntries() do @@ -3567,10 +4284,97 @@ local function _ValidateLearnedSpawnData(data) end function _Learner:BroadcastIfCommsAvailable(typ, id, data) + if Questie and Questie.db and Questie.db.profile and Questie.db.profile.learnerBroadcast == false then + return + end + if GetLearnerSetting("learnerCommsIntensity", "normal") == "off" then + return + end + local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") - if QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData then - local op = (data.mc and data.mc > 1) and "UPDATE" or "NEW" - QuestieLearnerComms:BroadcastLearnedData(op, typ, id, data) + if not (QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData) then + return + end + + _Learner.pendingBroadcasts = _Learner.pendingBroadcasts or {} + local key = typ .. ":" .. tostring(id) + local op = (data.mc and data.mc > 1) and "UPDATE" or "NEW" + local existingPending = _Learner.pendingBroadcasts[key] + _Learner.pendingBroadcasts[key] = { + typ = typ, + id = id, + data = data, + -- Preserve the first-discovery signal while still sending the latest + -- coalesced payload for the entity. + op = (existingPending and existingPending.op == "NEW") and "NEW" or op, + } + + if _Learner.pendingBroadcastTimer then return end + + local timer = QuestieCompat and QuestieCompat.C_Timer + local function FlushBroadcasts() + local pending = _Learner.pendingBroadcasts + _Learner.pendingBroadcasts = {} + _Learner.pendingBroadcastTimer = nil + + local comms = QuestieLoader:ImportModule("QuestieLearnerComms") + if not (comms and comms.BroadcastLearnedData) then return end + + for _, entry in pairs(pending) do + comms:BroadcastLearnedData(entry.op, entry.typ, entry.id, entry.data) + end + end + + if timer and timer.After then + _Learner.pendingBroadcastTimer = true + local delay = 2 + local intensity = GetLearnerSetting("learnerCommsIntensity", "normal") + if intensity == "low" then + delay = 4 + elseif intensity == "fast" then + delay = 1 + end + timer.After(delay, FlushBroadcasts) + else + FlushBroadcasts() + end +end + +local function _QueueIncomingNetworkMerge(typ, id, data, op) + _Learner.pendingNetworkMerges = _Learner.pendingNetworkMerges or {} + local key = typ .. ":" .. tostring(id) + _Learner.pendingNetworkMerges[key] = { + typ = typ, + id = id, + data = data, + op = op, + } + + if _Learner.pendingNetworkMergeTimer then return end + + local timer = QuestieCompat and QuestieCompat.C_Timer + local function FlushNetworkMerges() + local pending = _Learner.pendingNetworkMerges + _Learner.pendingNetworkMerges = {} + _Learner.pendingNetworkMergeTimer = nil + + local anyChanged = false + for _, entry in pairs(pending) do + local changed = QuestieLearner:_ApplyIncomingNetworkMerge(entry.typ, entry.id, entry.data, entry.op) + anyChanged = anyChanged or changed + end + + if anyChanged then + QuestieLearner:InjectLearnedData() + QuestieLearner.data = Questie.dbLearner.global + end + end + + if timer and timer.After then + _Learner.pendingNetworkMergeTimer = true + timer.After(GetLearnerSetting("liveNpcUpdateDelay", 0.75), FlushNetworkMerges) + else + FlushNetworkMerges() end end @@ -3580,36 +4384,43 @@ function QuestieLearner:HandleNetworkData(typ, id, d, op) if not EnsureLearnedData() then return end if not typ or not id or not d then return end + _QueueIncomingNetworkMerge(typ, id, d, op) +end + +function QuestieLearner:_ApplyIncomingNetworkMerge(typ, id, d, op) + if not typ or not id or not d then return false end + local store if typ == "NPC" then - if not Questie.dbLearner.global.settings.learnNpcs then return end + if not Questie.dbLearner.global.settings.learnNpcs then return false end store = Questie.dbLearner.global.npcs elseif typ == "QUEST" then - if not Questie.dbLearner.global.settings.learnQuests then return end + if not Questie.dbLearner.global.settings.learnQuests then return false end store = Questie.dbLearner.global.quests elseif typ == "ITEM" then - if not Questie.dbLearner.global.settings.learnItems then return end + if not Questie.dbLearner.global.settings.learnItems then return false end store = Questie.dbLearner.global.items elseif typ == "OBJECT" then - if not Questie.dbLearner.global.settings.learnObjects then return end + if not Questie.dbLearner.global.settings.learnObjects then return false end store = Questie.dbLearner.global.objects else - return + return false end -- Validate external data before merging to prevent crash on malformed input if not _ValidateLearnedSpawnData(d) then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Rejected malformed network data", typ, id) - return + return false end local existing = store[id] if not existing then store[id] = d store[id].mc = 1 - self:InjectLearnedData() - QuestieLearner.data = Questie.dbLearner.global - return + if typ == "NPC" and type(d[1]) == "string" and d[1] ~= "" then + _MarkNpcNameIndexDirty() + end + return true end local changed = false @@ -3618,6 +4429,9 @@ function QuestieLearner:HandleNetworkData(typ, id, d, op) if k ~= "mc" and existing[k] == nil then existing[k] = v changed = true + if typ == "NPC" and k == 1 and type(v) == "string" and v ~= "" then + _MarkNpcNameIndexDirty() + end end end @@ -3654,9 +4468,10 @@ function QuestieLearner:HandleNetworkData(typ, id, d, op) if changed or (op == "NEW" or op == "UPDATE") then existing.ls = time() -- Refresh timestamp on network confirmation existing.mc = (existing.mc or 0) + 1 - QuestieLearner.data = Questie.dbLearner.global - self:InjectLearnedData() + return true end + + return false end return QuestieLearner diff --git a/Modules/QuestieLearnerExport.lua b/Modules/QuestieLearnerExport.lua index 1296670..81d4d5d 100644 --- a/Modules/QuestieLearnerExport.lua +++ b/Modules/QuestieLearnerExport.lua @@ -39,7 +39,6 @@ end local function GetServerKey() if QuestieServer then if Questie.IsAscension then return "Ascension" end - if Questie.IsTurtle then return "Turtle" end if Questie.IsEbonhold then return "Ebonhold" end if Questie.IsEra then return "Era" end if Questie.Is335 then return "WotLK" end diff --git a/Modules/QuestiePlayer.lua b/Modules/QuestiePlayer.lua index 5f5ba0b..9689051 100644 --- a/Modules/QuestiePlayer.lua +++ b/Modules/QuestiePlayer.lua @@ -120,7 +120,8 @@ function QuestiePlayer:GetCurrentZoneId() return uiMapId end - return ZoneDB.instanceIdToUiMapId[select(8, GetInstanceInfo())] + local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo() + return ZoneDB.instanceIdToUiMapId[instanceMapID] end function QuestiePlayer:GetCurrentUiMapId() @@ -129,7 +130,8 @@ function QuestiePlayer:GetCurrentUiMapId() if uiMapId then return uiMapId end - return ZoneDB.instanceIdToUiMapId[select(8, GetInstanceInfo())] + local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo() + return ZoneDB.instanceIdToUiMapId[instanceMapID] end ---@return number diff --git a/Modules/QuestieServer.lua b/Modules/QuestieServer.lua index 55a12cd..8ea5c3e 100644 --- a/Modules/QuestieServer.lua +++ b/Modules/QuestieServer.lua @@ -17,11 +17,6 @@ Questie.IsWotlk = (WOW_PROJECT_ID == WOW_PROJECT_WRATH_CLASSIC) Questie.IsTBC = (WOW_PROJECT_ID == WOW_PROJECT_BURNING_CRUSADE_CLASSIC) Questie.IsClassicEra = (WOW_PROJECT_ID == WOW_PROJECT_CLASSIC) --- Try GetBuildInfo for Turtle WoW (Interface: 11200, no WOW_PROJECT globals) -local _, _, _, tocVersion = GetBuildInfo() -Questie.IsTurtle = (not Questie.IsRetail and not Questie.IsWotlk and not Questie.IsTBC and - not Questie.IsClassicEra and tocVersion and tocVersion < 20000) - -- Custom server detection Questie.IsEbonhold = false Questie.IsAscension = false @@ -41,7 +36,6 @@ local function GetExpectedPluginFlavor() if Questie.IsAscension then return "AscensionDB", "Questie-X-AscensionDB" end if Questie.IsEbonhold then return "EbonholdDB", "Questie-X-EbonholdDB" end if Questie.IsValanior then return "ValaniorDB", "Questie-X-ValaniorDB" end - if Questie.IsTurtle then return "TurtleDB", "Questie-X-TurtleDB" end if Questie.IsWotlk then return "WotLKDB", "Questie-X-WotLKDB" end if Questie.IsTBC then return "TBCDB", "Questie-X-TBCDB" end if Questie.IsClassicEra then return "ClassicDB", "Questie-X-ClassicDB" end @@ -53,7 +47,7 @@ function QuestieServer:Init() Questie:Debug(Questie.DEBUG_INFO, "[QuestieServer] Realm:", realmName) Questie:Debug(Questie.DEBUG_INFO, "[QuestieServer] WOW_PROJECT_ID:", tostring(WOW_PROJECT_ID)) Questie:Debug(Questie.DEBUG_INFO, "[QuestieServer] IsWotlk:", tostring(Questie.IsWotlk), "IsTBC:", tostring(Questie.IsTBC), - "IsClassicEra:", tostring(Questie.IsClassicEra), "IsTurtle:", tostring(Questie.IsTurtle), + "IsClassicEra:", tostring(Questie.IsClassicEra), "IsAscension:", tostring(Questie.IsAscension), "IsEbonhold:", tostring(Questie.IsEbonhold)) end @@ -64,7 +58,6 @@ function QuestieServer:IsAnyDBPluginEnabled() "Questie-X-WotLKDB", "Questie-X-ClassicDB", "Questie-X-TBCDB", - "Questie-X-TurtleDB", "Questie-X-AscensionDB", "Questie-X-EbonholdDB", "Questie-X-ValaniorDB", diff --git a/Modules/QuestieStream.lua b/Modules/QuestieStream.lua index 4bdb882..9771fee 100644 --- a/Modules/QuestieStream.lua +++ b/Modules/QuestieStream.lua @@ -10,6 +10,7 @@ local unpack_limit = 4096 -- wow api limits unpack to somewhere between 7000-800 local band = bit.band local lshift = bit.lshift local rshift = bit.rshift +local mod = math.mod local stringchar = string.char local stringbyte = string.byte local stringsub = string.sub @@ -128,7 +129,7 @@ end function QuestieStreamLib:_writeByte(val) local p = self._pointer local chunkId = math.floor((p - 1) / 100000) + 1 - local localIdx = ((p - 1) % 100000) + 1 + local localIdx = mod(p - 1, 100000) + 1 if not self._bin[chunkId] then self._bin[chunkId] = {} @@ -152,7 +153,7 @@ function QuestieStreamLib:_readByte() return stringbyte(self._bin, p) else local chunkId = math.floor((p - 1) / 100000) + 1 - local localIdx = ((p - 1) % 100000) + 1 + local localIdx = mod(p - 1, 100000) + 1 if self._bin[chunkId] then return stringbyte(self._bin[chunkId][localIdx] or "\0") end @@ -184,7 +185,7 @@ function QuestieStreamLib:_WriteByte_b89(e) self._level = level self:_writeByte(QSL_ltab[level]) end - local chr = (e % 86) + 33 + local chr = mod(e, 86) + 33 if QSL_ttab[chr] then self:_writeByte(QSL_ttab[chr]) else @@ -267,7 +268,7 @@ function QuestieStreamLib:_ReadInt12Pair_raw() local p = self._pointer self._pointer = p + 3 local a,b,c = stringbyte(self._bin, p, p+2) - local low4bit = a % 16 + local low4bit = mod(a, 16) return low4bit * 256 + b, (a - low4bit) * 16 + c end @@ -372,8 +373,8 @@ end function QuestieStreamLib:_WriteShort(val) --print("wshort: " .. val); - self:WriteByte(rshift(val, 8) % 256); - self:WriteByte(val % 256); + self:WriteByte(mod(rshift(val, 8), 256)); + self:WriteByte(mod(val, 256)); end function QuestieStreamLib:_WriteShort_assert(val) @@ -384,10 +385,10 @@ function QuestieStreamLib:_WriteShort_assert(val) end function QuestieStreamLib:_WriteInt(val) - self:WriteByte(rshift(val, 24) % 256); - self:WriteByte(rshift(val, 16) % 256); - self:WriteByte(rshift(val, 8) % 256); - self:WriteByte(val % 256); + self:WriteByte(mod(rshift(val, 24), 256)); + self:WriteByte(mod(rshift(val, 16), 256)); + self:WriteByte(mod(rshift(val, 8), 256)); + self:WriteByte(mod(val, 256)); end function QuestieStreamLib:_WriteInt_assert(val) @@ -399,9 +400,9 @@ end function QuestieStreamLib:_WriteInt24(val) --print("wi24: " .. val); - self:WriteByte(rshift(val, 16) % 256); - self:WriteByte(rshift(val, 8) % 256); - self:WriteByte(val % 256); + self:WriteByte(mod(rshift(val, 16), 256)); + self:WriteByte(mod(rshift(val, 8), 256)); + self:WriteByte(mod(val, 256)); end function QuestieStreamLib:_WriteInt24_assert(val) @@ -413,8 +414,8 @@ end function QuestieStreamLib:_WriteInt12Pair(val1, val2) self:WriteByte(band(rshift(val1, 8), 15) + lshift(band(rshift(val2, 8), 15), 4)) - self:WriteByte(val1 % 256) - self:WriteByte(val2 % 256) + self:WriteByte(mod(val1, 256)) + self:WriteByte(mod(val2, 256)) end function QuestieStreamLib:_WriteInt12Pair_assert(val1, val2) @@ -430,14 +431,14 @@ function QuestieStreamLib:_WriteInt12Pair_assert(val1, val2) end function QuestieStreamLib:_WriteLong(val) - self:WriteByte(rshift(val, 56) % 256); - self:WriteByte(rshift(val, 48) % 256); - self:WriteByte(rshift(val, 40) % 256); - self:WriteByte(rshift(val, 32) % 256); - self:WriteByte(rshift(val, 24) % 256); - self:WriteByte(rshift(val, 16) % 256); - self:WriteByte(rshift(val, 8) % 256); - self:WriteByte(val % 256); + self:WriteByte(mod(rshift(val, 56), 256)); + self:WriteByte(mod(rshift(val, 48), 256)); + self:WriteByte(mod(rshift(val, 40), 256)); + self:WriteByte(mod(rshift(val, 32), 256)); + self:WriteByte(mod(rshift(val, 24), 256)); + self:WriteByte(mod(rshift(val, 16), 256)); + self:WriteByte(mod(rshift(val, 8), 256)); + self:WriteByte(mod(val, 256)); end function QuestieStreamLib:_WriteLong_assert(val) @@ -476,7 +477,7 @@ end function QuestieStreamLib:Save() local chunks = {} - for i=1, #self._bin do + for i=1, table.getn(self._bin) do table.insert(chunks, table.concat(self._bin[i])) end return table.concat(chunks) diff --git a/Modules/Tooltips/Tooltip.lua b/Modules/Tooltips/Tooltip.lua index 56ca3fc..f7bd5aa 100644 --- a/Modules/Tooltips/Tooltip.lua +++ b/Modules/Tooltips/Tooltip.lua @@ -43,6 +43,31 @@ local _tooltipLastText = "" local _InitObjectiveTexts +local function _GetQuestObjectiveSummary(questId) + if not QuestieDB or not QuestieDB.GetQuest then + return nil + end + + local quest = QuestieDB:GetQuest(questId) + if not quest or not quest.ObjectiveData then + return nil + end + + local summary = {} + for _, objective in ipairs(quest.ObjectiveData) do + local text = objective and (objective.Text or objective.Description) + if type(text) == "string" and text ~= "" then + tinsert(summary, text) + end + end + + if table.getn(summary) == 0 then + return nil + end + + return summary +end + ---@param questId number ---@param key string monster: m_, items: i_, objects: o_ + string name of the objective ---@param objective table @@ -205,7 +230,7 @@ function QuestieTooltips:GetTooltip(key) return nil end - if QuestiePlayer.numberOfGroupMembers > MAX_GROUP_MEMBER_COUNT then + if (QuestiePlayer.numberOfGroupMembers or 0) > MAX_GROUP_MEMBER_COUNT then return nil -- temporary disable tooltips in raids, we should make a proper fix end @@ -228,7 +253,8 @@ function QuestieTooltips:GetTooltip(key) if (not QuestieTooltips.lookupByKey[key]) then local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache") - if QuestieLearner and QuestieLearner.data then + local mode = QuestieLearner and QuestieLearner.GetDataSourceMode and QuestieLearner:GetDataSourceMode() or "auto" + if QuestieLearner and QuestieLearner.data and mode ~= "static" and mode ~= "none" then -- Try to find in learned NPCs or objects local id = tonumber(key:sub(3)) if id then @@ -345,11 +371,27 @@ elseif key:sub(1,2) == "o_" then if QuestieTooltips.lookupByKey[key] then local playerName = UnitName("player") + local hasObjectiveEntries = false + for _, tooltip in next, QuestieTooltips.lookupByKey[key] do + if not tooltip.name then + hasObjectiveEntries = true + break + end + end + for k, tooltip in next, QuestieTooltips.lookupByKey[key] do if tooltip.name then if Questie.db.profile.showQuestsInNpcTooltip then local questString = QuestieLib:GetColoredQuestName(tooltip.questId, Questie.db.profile.enableTooltipsQuestLevel, true, true) tinsert(tooltipLines, questString) + if not hasObjectiveEntries then + local objectiveSummary = _GetQuestObjectiveSummary(tooltip.questId) + if objectiveSummary then + for _, objectiveText in ipairs(objectiveSummary) do + tinsert(tooltipLines, " |cFFcbcbcb" .. objectiveText .. "|r") + end + end + end end else local objective = tooltip.objective diff --git a/Modules/Tracker/QuestieTracker.lua b/Modules/Tracker/QuestieTracker.lua index be3cc90..f456ef1 100644 --- a/Modules/Tracker/QuestieTracker.lua +++ b/Modules/Tracker/QuestieTracker.lua @@ -413,7 +413,7 @@ function QuestieTracker.Initialize() for i = 1, questsWatched do local questIndex = GetQuestIndexForWatch(i) if questIndex then - local questId = select(8, GetQuestLogTitle(questIndex)) + local _, _, _, _, _, _, _, questId = GetQuestLogTitle(questIndex) if questId then tempQuestIDs[i] = questId end @@ -2277,7 +2277,7 @@ function QuestieTracker:HookBaseTracker() -- Intercept and return a Questie boolean value IsQuestWatched = function(index) - local questId = select(8, GetQuestLogTitle(index)) + local _, _, _, _, _, _, _, questId = GetQuestLogTitle(index) if questId == 0 then -- When an objective progresses in TBC "index" is the questId, but when a quest is manually added to the quest watch -- (e.g. shift clicking it in the quest log) "index" is the questLogIndex. diff --git a/README.md b/README.md index f48a7fc..49d695c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ > **Notice:** I am actively working on a major performance refactor. Until I publish a stable release that I am satisfied with, please use the repository version instead of the release asset. +> +> Current refactor focus: reducing QuestieLearner pin redraws during heavy kill activity, adding live Advanced performance controls for QuestieLearner and QuestieComms, adding Arrow throttles for low-end systems, and suppressing non-fatal error spam outside Questie debug modes. These changes are being staged on feature branches first and still need in-game validation before they become the next stable release.
UNIT_DIED handling so kills from other players can update short-lived correlation evidence without immediately running full learner injection or pin refresh work.UNIT_DIED debounce entry could suppress a later authoritative PARTY_KILL for the same GUID. The debounce now tracks event type and allows the player's/group's kill event through while still suppressing true duplicates.QuestieDB.IsDoable batch reads, hot profile aliases, GetTime() hoists, NPC fallback lookup caching, and validate-cache allocation cleanup.QuestieQuest:SmoothReset() (the previous redraw call imported a mis-named module and silently did nothing).questie-learner-comms-improvements with phase3-measured-perf, remove or revalidate the stale reverted QuestieMap.ProcessQueue profile-local commit, fix the unrelated Arrow asset test mismatch, and validate in game before stable release. Test learner-only/static-only switching, immediate spawn pin rendering, and available-quest scanning during real kill/loot gameplay.
+ v1.6.3, then tightened the release filters so handoff notes, local dev settings, and other workspace-only artifacts stay out of the exported release archive.QuestieCompat where uiMapData for Ascension-specific zones was not correctly propagating to the global mapping table, restoring map pin functionality for seasonal and custom zones.l10n module to prevent GetAreaIdByLocalName nil errors (Fixes Project Ebonhold runtime crash).session_export skill for standardized documentation and session artifact exports.QuestiePluginAPI:InjectZoneTables(). This caused zone lookups to fail, resulting in "No UiMapID or fitting parentAreaId" errors for custom zone IDs.QuestiePluginAPI:FinishLoading() and a registration handshake to resolve race conditions during addon initialization. By ensuring that database plugins report their data-injection status before QuestieInit Stage 3 completes, we eliminated "ghost maps" where pins and statistics would fail to render until a manual /reload.pairs() and ipairs() to next() and numeric loops. This refactor targets the Lua 5.0 engine used by legacy clients (e.g., Turtle WoW), which can exhibit inconsistent behavior or performance degradation when using standard iterators in high-frequency database sweeps. This change guarantees stable, universal performance across all WoW versions from 1.12 to 3.3.5+.pairs() and ipairs() to next() and numeric loops. This refactor targets the Lua 5.0 engine used by legacy clients, which can exhibit inconsistent behavior or performance degradation when using standard iterators in high-frequency database sweeps. This change guarantees stable, universal performance across supported WoW versions.MapIconTooltip.lua at line 239. A malformed if statement was trapping execution, preventing tooltips from updating when hovering over Quest objectives on the World Map.AscensionLoader.lua. The loader now utilizes an pattern-matching check against GetRealmName() to correctly identify and apply custom database overrides for all Project Ascension realms, including seasonal and specialized rule-set servers.QuestieInit. The initialization sequence now yields more efficiently to the main UI thread during massive database injections, reducing "frame-stutter" during the initial login sequence while strictly maintaining loading priority for essential UI modules.attempt to call local 'l10n' (a table value) in GameVersionError.lua and implemented a tocVersion guard to prevent the "unsupported client" error on Classic-era private servers (e.g., Turtle WoW).attempt to call local 'l10n' (a table value) in GameVersionError.lua and implemented a tocVersion guard to prevent the "unsupported client" error on Classic-era private servers.GameVersionError.lua strings to correctly identify Questie-X as supporting Classic and private servers.Questie-X-WotLKDB. No direct taint vectors or secure function overrides were found.string.match and string.gmatch using string.find and string.gfind to ensure universal compatibility with legacy WoW clients (e.g., Turtle WoW).string.match and string.gmatch using string.find and string.gfind to ensure universal compatibility with legacy WoW clients.AceTimer-3.0 instances in ElvUI and OG-RaidHelper to resolve math.mod errors on Lua 5.0 clients.CreateColor polyfill with SetRGB, SetRGBA, SetColor, and GetColor methods.Questie-X is currently being refactored to reduce heavy-zone FPS drops. Until a stable release is published, testers should use the repository version rather than the release asset.
+ +Learner-triggered pin refreshes are now debounced with a maximum wait cap. Nearby-player UNIT_DIED events no longer force full learner pin redraws, while authoritative PARTY_KILL events are protected so local/group kills still learn correctly.
The Advanced tab now centralizes live QuestieLearner and QuestieComms tuning, including learner intensity, pin refresh delay, max refresh wait, live NPC update delay, comms disable, queue processing, broadcast pacing, and bulk sync pacing.
+Arrow performance controls reduce repeated nearest-target and coordinate work for low-end systems while preserving the existing arrow behavior.
+The latest learner/comms/arrow controls live on questie-learner-comms-improvements. Broader measured hot-path fixes live on phase3-measured-perf. A final integration branch is still required before stable release testing.
The v1.6.3 release includes the arrow redesign, the minimap pin drift fix, and the Sunstrider map and tooltip corrections. These notes summarize the shipped behavior in the live release.
diff --git a/release_notes.txt b/release_notes.txt new file mode 100644 index 0000000..39eb036 --- /dev/null +++ b/release_notes.txt @@ -0,0 +1,41 @@ +# Questie-X v1.6.3 + +Questie-X v1.6.3 is the release that lands the arrow redesign, the minimap pin drift fix, the configurable minimap range cutoff, and the Sunstrider / tooltip stability pass. + +## Active Performance Refactor Notice + +After v1.6.3, the active repository branches contain a major performance refactor that is not yet published as a stable release asset. Users helping test performance should use the repository version until the next stable release is cut. + +Current branch work includes: + +- QuestieLearner kill/pin refresh debouncing with a maximum wait cap. +- Bystander `UNIT_DIED` suppression so nearby players killing mobs do not force local learner pin redraws. +- A `PARTY_KILL` event-order fix so legitimate local/group kills are not skipped after an earlier `UNIT_DIED`. +- Immediate learner spawn pin rendering so kill evidence can become map pins right away in learner mode. +- Learner-aware DB reads that can fall back to `Questie.dbLearner.global.*` when static data is missing. +- Advanced-tab performance controls for QuestieLearner, QuestieComms, and Arrow throttles. +- A full QuestieComms disable switch with live queue/broadcast/sync throttles. +- Non-fatal missing quest/database messages routed to Questie debug output instead of normal chat spam. +- Available quest draw-thread guarding so missing quest IDs skip safely instead of crashing redraws. +- A measured phase 3 branch with additional localization, quest eligibility, tooltip/map, cache, and allocation hot-path reductions. + +Remaining before stable release: integrate the learner/comms branch with the measured phase 3 branch, remove or revalidate the stale reverted `QuestieMap.ProcessQueue` profile-local commit, fix unrelated Arrow asset test expectations, and validate in-game in heavy kill/loot zones with the minimap open, learner-only/static-only switching, and repeated loot/object objective testing. + +## Highlights + +- Arrow system rebuilt around `Arrow1`-`Arrow4` plus the legacy `arrowold` sheet style. +- Bundled non-sheet arrows now ship as uncompressed 32-bit TGA runtime assets, with preview TGAs reserved for the dropdown swatches. +- The dropdown now detects sprite sheets by texture format and alpha bounds, which keeps sheet-cell logic isolated to `arrowold` and explicitly custom sheet uploads. +- `Arrow2` was rotated 180 degrees so the pointed tip is the top-facing point of the icon instead of the base. +- Arrow dropdown now uses preview swatches and supports detached / attached objective text workflows. +- Minimap pins no longer drift; the fix uses the live view-radius API plus corrected pixel math. +- Minimap icons now have a user-adjustable range cutoff so they can be hidden or shown based on distance in real time. +- Sunstrider Isle coordinates, map pins, and arrow distance handling are normalized for the child map / parent map split. +- Learned tooltip reconstruction now matches the actual QuestieLearner data shape. +- Learner kill evidence now spawns pins immediately in learner mode, instead of waiting for the later evidence merge path. +- The in-game version badge and documentation are synced to `v1.6.3`. + +## Notes + +- The bundled release archive excludes developer-only docs, handoff notes, and workspace artifacts. +- This release is intended to be the clean published base for the current Questie-X branch. diff --git a/workflow/performance-audit-2026-06-03-FULL.md b/workflow/performance-audit-2026-06-03-FULL.md new file mode 100644 index 0000000..56bb49d --- /dev/null +++ b/workflow/performance-audit-2026-06-03-FULL.md @@ -0,0 +1,6155 @@ +# Questie-X Full Performance Audit — 2026-06-03 + +This is a full read of every runtime Lua file in the Questie-X addon +(non-vendored, non-lookup-table). The earlier `performance-audit-2026-06-03.md` +is verified where its claims hold, corrected where they don't, and extended +where it missed things entirely. No file was edited during this audit. + +## Restore point + +``` +git tag -f audit-restore-2026-06-03 HEAD # tag = 581634d +``` + +Working tree was clean at audit start except four known untracked dev artifacts +(`.codex-remote-attachments/`, `Icons/Arrows/DropTestArrow.tga`, +`Icons/Arrows/DropTestArrow_preview.tga`, `Questie-X-v1.6.3.tar`). + +## Coverage — what I actually read + +| Bucket | Count | Read | +|---|---|---| +| Root `.lua` | 1 | Questie.lua | +| Root `.toc` | 3 | Questie.toc, Questie-X.toc, Questie-X-Turtle.toc | +| Modules/ (non-vendored) | 108 | every `.lua` | +| Compat/ (non-vendored) | 8 | every `.lua` | +| Database/ (excl. Corrections tables and QuestXP) | 5 | QuestieDB, compiler, Constants, questDB, itemDB, npcDB, objectDB, MeetingStones, Zones/zoneDB, Zones/zoneTables, QuestXP/QuestieXP | +| Database/Corrections/ | 22 | every `.lua` (skipped deep read — these are data tables, not logic) | +| Localization/l10n.lua + lookupZones + lookupQuestCategories | 3 | yes | +| Localization/lookups/Wotlk + TBC + Classic | 155 | table-shape only (these are all `return { ... }` data, no logic) | +| Tests/ | 12 | every file | + +Total runtime Lua actually read end-to-end: ~130 files. +Total runtime Lua skimmed for shape only: ~177 (lookup tables, correction +data, vendored Libs). + +## Environment + +- `busted` and `lua` are NOT installed in this environment. The earlier + report's "12 pass / 4 fail" busted number was either run elsewhere or + fabricated. I could not reproduce it. +- `selene` 0.27.x is installed at `C:/Users/kance/.cargo/bin/selene`. + Ran against `Questie.lua Modules Compat Database Localization`. + +## Current Branch Status + +This repo now has multiple long-lived work branches. The audit should be read +with branch context in mind because the implementation is intentionally split +to keep reversions easy. + +| Branch | Status | Scope / Notes | +|---|---|---| +| `main` | active release line | Stable public branch. Current README notice tells users to prefer the repo version while the refactor is in progress. | +| `questie-learner-comms-improvements` | active feature branch | Learner/comms performance work branch. This is the branch users are currently asking to review for performance regressions and stability. | +| `phase2-lua50-sweep` | phase-complete | Lua 5.0 compatibility sweep branch. No performance work should be added here unless it is directly required to finish compatibility. | +| `phase3-measured-perf` | phase-complete | Measured hot-path perf branch. Contains the audited `PP1`, `PP2`, `PP5`, `PP6`, and `G2` changes. | +| `error-suppression-debug-only` | phase-complete | Error policy branch. Converts non-fatal errors to debug-critical output and keeps only fatal startup failures loud. | +| `tooltip-ascensiondb-precedence` | phase-complete | Tooltip precedence branch for AscensionDB versus learner tooltip data. | +| `questie-phase1perf` | phase-complete | Earlier phase-one perf/compat stabilization branch. | +| `pr-13` | review snapshot | Historical review branch / snapshot; not the active implementation line. | +| `backup-local-state` | local backup | Safety copy only; not a branch to build new work on. | + +Current worktree note: `questie-learner-comms-improvements` is the active implementation branch being reviewed for the learner/comms performance refactor. The latest review found one real learner event-order bug: a bystander-safe `UNIT_DIED` debounce entry could suppress a later authoritative `PARTY_KILL` for the same GUID. That fix is pending commit on this branch until the validation listed in Pass-49 is complete. + +Recent local validation on the active branch: + +- `Tests/QuestieDB_suppression_spec.lua` passes. +- `Tests/QuestieArrow_spec.lua` passes. +- `Tests/QuestieArrowAssets_spec.lua` currently fails in this workspace with the arrow manifest / bundled-arrow expectation mismatch (`missing style block for arcanearrow`, plus the expected folder list still includes `Minimal1..3`). That failure is outside the learner patch itself, but it means the branch is not fully green yet and needs a follow-up before calling the current branch stable. + +## What Still Needs Testing + +To confirm the current work is stable and genuinely faster, the next validation +pass should cover these areas explicitly: + +1. `QuestieLearner` hot paths in heavy-kill zones + - Verify pin refreshes are batched instead of firing on every kill. + - Confirm bystander kills do not trigger local learner refreshes. + - Verify `learnerBroadcast` off/low/normal/fast settings still behave in live play. +2. `QuestieComms` messaging + - Confirm the helper wiring no longer throws `nil` helper calls. + - Verify missing quest data is silent outside debug modes. + - Confirm comms can be disabled entirely without breaking quest state sync. +3. Arrow update loop + - Verify arrow refresh throttles and target-scan intervals still update in real time. + - Confirm the cached coordinate path does not break nearest-target selection. +4. Map / tooltip updates + - Confirm the tooltip precedence path still prefers AscensionDB-owned data. + - Verify no regressions in world-map pins, minimap pins, or tooltip rendering. +5. Lua 5.0 compatibility + - Continue the phase 2/3 compatibility sweep until every remaining raw + operator / vararg / `select(8, ...)` issue is closed. +6. Real-world performance confirmation + - Test in a crowded combat zone with heavy kill volume. + - Test with the minimap open and closed. + - Test in a zone that previously produced the red error spam. + - Compare frame stability before/after the learner/comms changes using the in-game profiler or a consistent FPS capture. + +## Verification of the earlier report + +### What is confirmed + +1. **`554` tracked files, `406` tracked .lua** — confirmed via `git ls-files`. +2. **`QuestieLib.lua` arity error** at line 33 declares + `local function Ascension_IsScalingEnabled()` (no args) and line 39, 56 call + it with `questId`. Selene reports it as 2 `mismatched_arg_count` errors. + This is a **real correctness bug** — `not (true) == false`, so on Ascension + the function is always treated as "scaling disabled" and the level-scaling + branch in `Ascension_GetEffectiveQuestLevel` / `IsQuestTrivialScaled` is + never taken. Affects every quest the user can accept. +3. **`QuestieDBMIntegration.lua` commented out in both `Questie-X.toc` and + `Questie-X-Turtle.toc`** — confirmed. Module is fully implemented (300+ + lines of HUD map drawing) but never loaded. +4. **`Modules\QuestieSlash.lua` listed twice in Questie-X.toc** — confirmed at + lines 23 and 182. +5. **`floatOnEdge = true` at `Modules/Map/QuestieMap.lua:723`** — confirmed. + This contradicts prior Hindsight memory that `false` was a sensitive fix + for Sunstrider Isle map 1241. The source has `true`; if Hindsight is right, + this is a regression that needs your call. **Not changing without your + go.** +6. **13 front-removal queue paths** — *directionally* correct, off by a few: + - Real `tremove(t, 1)` hits in Modules/: **13** (10 in QuestieComms, + 1 in QuestieCombatQueue, 1 in QuestieMap, 1 in QuestieFramePool, 1 in + QuestieValidateGameCache, 1 in AvailableQuests's start-pending, + 1 in QuestieLearnerComms outgoing rate-limit and incoming). + - Plus 1 `table.remove` in TaskQueue. + - Plus **4 in vendored AceAddon** (Compat/Libs/AceAddon and Libs/AceAddon) + which the report didn't exclude. + - **No `tremove(_, 1)` in QuestieLearner.lua itself** — the report's + "learner queue" claim is wrong. The actual learner-side front removal + lives in QuestieLearnerComms, not QuestieLearner. + +### What is wrong in the earlier report + +1. **"204 non-vendored runtime Lua"** — the actual number is **237** (using + a generous filter that excludes `Libs/`, `Localization/lookups/`, and + `Compat/Libs/`). The report's "204" is a number that doesn't match any + reasonable definition. +2. **"1162 warnings"** in selene — I could not reproduce. Selene's output + is being truncated by repeated `I/O error: operation failed to complete + synchronously, aborting` and my warning count tops out around 410 in the + partial capture. Could be a real 1162 in a different environment, but I + cannot confirm. +3. **"4 BOM parse errors"** — I find **3 BOM files** (real UTF-8 BOM at byte + 0): `Database/Corrections/tbcQuestFixes.lua`, + `Database/Corrections/wotlkItemFixes.lua`, + `Database/Corrections/wotlkQuestFixes.lua`. The report's "4" is off by + one. +4. **"learner queues" using `tremove(_, 1)`** — the actual front removal in + the learner path is in `QuestieLearnerComms.ProcessQueues` at lines 238 + and 256, not in `QuestieLearner.lua`. Report misattributed. +5. **`MapExplorationUpdate` has zero callers.** I searched the whole tree. + The report doesn't call it out, but it sits in `QuestieMapUtils.lua:188` + as dead code. Not a perf issue, just a cleanup. + +### What the earlier report missed entirely + +1. **`Modules/QuestieLearner_spec.lua`** is a 401-line file of `print("PASS:")` + statements that lives in `Modules/`, not `Tests/`. It is NOT a busted spec + (no `describe`/`it` blocks). It pollutes `find Modules -name "*.lua"` + and the file listing. Not in any TOC, so it doesn't run in-game. Should + move to `Tests/` or be deleted. +2. **`Tests/qdbg-*.lua`, `Tests/q8325_spawns.lua`, + `Tests/elvui_terrain_audit.lua`** are in-game `/run` macros and slash + command scripts, NOT busted tests. They will be `dofile()`d by busted and + produce parse errors or output garbage. Busted `.busted` config at + `Tests/.busted` does not exclude them. **This is the actual source of + "4 fail"** — busted can't parse the macro files. +3. **The actual test suite is 3 spec files, ~20 assertions total**: + - `QuestieArrow_spec.lua` — 6 assertions on a pure function. + - `QuestieArrowAssets_spec.lua` — ~12 assertions on a manifest table and + `lfs.dir()` filesystem listing. + - `QuestieDB_suppression_spec.lua` — 6 assertions on spawn-suppression + helpers. + No real coverage of the hot paths (Map, Tracker, Comms, AvailableQuests, + Learner). The test suite is smoke-grade. +4. **BOM in 3 correction files.** Not a perf issue but explains any + "unexpected token" errors the original report saw. +5. **`Availability` of a `MapExplorationUpdate` dead function** (above). +6. **`TaskQueue.lua` has ZERO callers.** 18 lines, 1 dead per-frame + `OnUpdate` doing `table.remove(empty, 1)`. Pure overhead. +7. **`_DrawAvailableQuest` spawns a `NewThread` for every quest in + `QuestieDB.QuestPointers` during `Stage 3` init** — at + `Modules/Quest/AvailableQuests.lua:282-293`. For 10k quests that's + 10k concurrent `C_Timer.NewTicker(0, ...)` coroutines on login. +8. **Two near-identical `FadeLogic`/`SetFade` blocks** in + `QuestieMap.lua:601-661` and `758-809` — ~100 lines of duplicated code. + Maintenance hazard, not perf. +9. **`QuestieSerializer:Serialize` does 3 full O(n) passes per table** at + `Modules/Libs/QuestieSerializer.lua:226-269`: (a) `isArray` which is + itself a `pairs()` loop with `i ~= e` check, (b) `for k,v in pairs()` + to count, (c) the actual write loop. The report's #7 says this is + multiplied N times by the broadcast block-size check; both are true. +10. **`QuestieLib.tunpack` is recursive** at `Modules/Libs/QuestieLib.lua:655-668`. + Every value is a function call + return. For a packet of 50 args that's + 50 function calls. Confirmed hot path. +11. **`QuestieLib.GetColoredQuestName` makes 11+ hash lookups per call** + (line 178). 6 `QueryQuestSingle` calls (each a `GetQuest` + key), + 4 `Is*` lookups (each a `GetQuest` + key), 1 more in `PrintDifficultyColor`. + Called once per visible quest in the tracker update. +12. **`QuestieTracker.lua:222-229` registers an `OnUpdate` handler** that + fires every frame for the first 5 seconds, then is a no-op forever + after — the frame persists. Wasteful OnUpdate. +13. **`QuestieTracker.lua:75` evaluates `DurabilityFrame:GetPoint()` at module + load** and stores it. If `DurabilityFrame` doesn't exist at load (some + addons hide it pre-load, or some locales), this errors silently with + `attempt to index nil`. +14. **`QuestieTracker.lua:222-229` and `:224-226` register BOTH an OnUpdate + and a PLAYER_REGEN_ENABLED event** that call the same + `UpdateNearestQuestItemButton` — duplicated wakeups. +15. **`Modules/Tracker/TrackerLinePool.lua:49-82`** pre-allocates 250 frames + on tracker init, each with a fontstring that has a monkey-patched + `SetText` capturing the line as a closure. 250 closures = 250 function + objects. +16. **`QuestieComms.lua:530-544, 647-661`** table.sort comparator returns + `false` for equal elements which is technically legal but creates an + unstable sort — minor. +17. **`QuestieComms.lua:550-564`** broadcasts: calls + `QuestieSerializer:Serialize(rawQuestList)` on EVERY quest added, until + the serialized length exceeds 200. With 30 quests in the log this is + 30+29+...+1 = 465 serialize calls per broadcast. **This is the report's + #7, and it is correct and severe.** +18. **`QuestieLearnerComms.BroadcastLearnedData` line 217**: confusing + `local success, err = pcall(AceSerializer.Serialize, ...)` then + `serialized = err`. Correct (pcall returns `(true, returnValue)`) but + confusing. No perf impact. +19. **`QuestieLearnerComms` line 253: `InCombatLockdown()` from a 0.5s + ticker.** Safe in WoW 3.3.5a but taint-risk on protected frames; the + rest of Questie already wraps combat-sensitive code in + `QuestieCombatQueue`. Inconsistent. +20. **`Modules/QuestieLearner.lua` line 17: `local _Learner = QuestieLearner.private or {}`** + followed by `QuestieLearner.private = _Learner` — assigning `private` + back to itself if `QuestieLoader:CreateModule` already provided one. + Harmless but redundant. + +## Priority-ranked refactor list (NOT a generic "add abstraction" list) + +I am only listing things where I have line numbers and a real cost. Every +"do not do" item is also something the earlier report did suggest; I am +calling those out. + +### Tier 1 — surgical, low-risk, high-confidence + +1. **Delete `Modules/TaskQueue.lua` entirely.** 18 lines, zero callers, + per-frame OnUpdate running `table.remove(empty, 1)` ~60 times per second + for nothing. Zero risk. Wins ~60 zero-cost calls per second saved. +2. **Move `Modules/QuestieLearner_spec.lua` to `Tests/` or delete it.** + It is not a busted spec. It is not in the TOC. It pollutes `Modules/`. +3. **Move `Tests/qdbg-*.lua`, `Tests/q8325_spawns.lua`, + `Tests/elvui_terrain_audit.lua` to a non-test directory** (`DevTools/` + or `tests-dev/`) and exclude them from `.busted` ROOT. These are + in-game macros, not tests. Busted will not parse them. +4. **Fix the `QuestieLib.lua` `Ascension_IsScalingEnabled` arity bug.** + This is a **correctness** fix, not perf. Either remove the `questId` + arg from the two call sites (line 39, 56) or accept it (drop the + `(questId)` from the function definition). Currently every Ascension + scaled quest is being treated as "scaling disabled". +5. **Remove the duplicate `Modules\QuestieSlash.lua` line from + `Questie-X.toc`.** Line 182 duplicates line 23. Harmless but adds + load time per session. +6. **Replace `table.remove(t, 1)` / `tremove(t, 1)` with `t[1] = t[i]; t[i] = nil; i = i+1` (head index) or a real `LinkedList`/`Deque` for the small hot queues.** + + Concrete queues to fix (in priority order, by call frequency): + - `QuestieLearnerComms.ProcessQueues` lines 238, 256 — runs every 0.5s, + on both outgoing and incoming queues. + - `QuestieCombatQueue.Initialize` ticker (line 25, 33) — runs every 0.1s, + up to 5 dequeues per tick. + - `QuestieComms._nextBroadcastData` / `_nextBroadcastDataV2` (lines 596, + 716) — runs per 3s tick while a broadcast is in progress. + - `QuestieMap.ProcessQueue` (lines 360, 370) — runs every 0.2s. + + For the comm queues, a simple circular buffer (fixed-size array + head + index + size) is enough — no new abstraction needed, no new file needed. + Keep the change local to each module. + +7. **Add `--exclude-pattern` to `.busted`** so the dev macro files aren't + picked up. Or change `ROOT = { "Tests/" }` to + `ROOT = { "Tests/QuestieArrow_spec.lua", "Tests/QuestieArrowAssets_spec.lua", "Tests/QuestieDB_suppression_spec.lua" }`. + +### Tier 2 — surgical, medium-risk, needs your eyes + +8. **`AvailableQuests._DrawAvailableQuest` (line 282-293): stop spawning a + `NewThread` per quest.** Replace with a single coroutine driven by + `ThreadLib.Thread` or a single `C_Timer.NewTicker(0, fn)` that drains + a queue. This is the single biggest contributor to Ascension login + lag. Needs careful reordering of the `_DrawQuestIfAvailable` filter + checks so the threading model still works. Don't touch without + testing on Ascension login + accept-quest. +9. **Cache `QuestieLib.GetColoredQuestName` results per `(questId, level, + isComplete, isRepeatable, isEvent, isPvP, profileFlags)` tuple.** The + tracker update calls this for every visible quest on every refresh. + If a quest's data hasn't changed since last update, the cached + colored string is fine. Use a 2-key cache `(questId, profileKey)`. +10. **Replace `QuestieLib.tunpack`'s recursion (line 655) with + `return unpack(tbl, 1, tbl.n)`.** This is a one-line fix that makes + `tunpack` ~50x faster on long argument lists. Risk is zero — the + contract is unchanged. +11. **`QuestieSerializer` writer (line 226-269): combine the `isArray` + pass and the `for k,v in pairs()` count pass into one.** This + halves the per-serialize cost for the broadcast hot path. +12. **`QuestieComms.BroadcastQuestLog` (line 550-564) and V2 (line 669-682): + track running serialized length incrementally, don't re-serialize the + whole thing per added quest.** Replace the 200-byte threshold check + with a running-size counter. This converts the O(n²) broadcast cost + to O(n). +13. **Convert `Modules/QuestieMap.questIdFrames[questId][frameName] = + frameName` (line 826-827) to store direct frame refs.** Today the + registry stores frame NAMES as both key and value, and every read + does `_G[name]`. Change to `questIdFrames[questId][frameName] = frame` + and update `GetFramesForQuest` to return the direct ref. The + string-name `Load` keys must still be preserved for `Unload` lookup + if anything else uses them, so do this carefully. +14. **`Modules/Map/QuestieMap.lua` lines 170-184 (`RescaleIcons`): iterate + the direct-ref registry** (after fix 13) instead of resolving through + `_G`. Pair with fix 13. + +### Tier 3 — refactor, high risk, do not do unless measured + +15. **The report's "head/tail queue primitive" / "keyed scheduler / + debouncer" / "QuestiePerf abstraction" recommendations.** These are + new abstractions over existing code. The current code is + heterogeneous on purpose: `QuestieCombatQueue` is the + deferred-update queue, `TaskQueue` is dead, `ProcessQueue` is the + draw queue, comms queues are broadcast sequencing. A unified + primitive would either be a thin wrapper (no benefit) or a + semantically-different thing (breaks call sites). **Do not introduce + new abstraction layers without measurement proving the win.** This + is exactly the kind of invasive change that breaks the three + known-fragile areas (minimap drift, Ascension learner, tracker + wrap). +16. **The "shared spatial bucket index" recommendation.** `QuestieMapUtils + .CalcHotzones` IS O(n²) and the function mutates its input (sets + `point.touched`). If you do a spatial index here, the bug in the + existing function (calling it twice empties the result) gets + preserved. **Before refactoring, fix the mutation bug first** — + either return touched-set in a side table, or sort-then-sweep. Then + the spatial index can be evaluated against the simpler baseline. +17. **The "dirty cache for nearest-spawn, arrow, tracker" recommendation.** + Real win in theory. But `QuestieMap.GetNearestSpawn` is called + sparingly (when a quest objective updates, when the arrow updates + target). Profiling it on Sunstrider Isle before adding cache + plumbing. If it's not on the hot path, leave it alone. +18. **Replacing `AvailableQuests._HasProperDistanceToAlreadyAddedSpawns` + with a spatial bucket.** With ≤5 spawns per starter this is + ≤20 sqrt calls per starter. Not a real bottleneck. The + `NewThread` per quest (fix 8) is much larger. + +### Tier 4 — known-fragile, do not touch without measuring + +19. **`floatOnEdge = true` at `QuestieMap.lua:723`.** Hindsight says + `false` was a sensitive Sunstrider Isle fix. Source has `true`. + Inconsistent with the past. **You tell me which is right** — I + cannot tell from static analysis because the Sunstrider map 1241 → + 1941 redirect (line 60-70) is a separate, orthogonal concern. +20. **`Modules/Compat/HBD.lua` minimap pin rendering** — there is + per-frame `[QDPX]` diagnostic code injected here per Hindsight + memory. I read enough of the file to confirm it exists but did not + trace the math in depth. **You said this is an area you've been + debugging — do not touch without explicit instruction.** +21. **`TrackerQuestTimers`, `TrackerBaseFrame`, `TrackerQuestFrame`, + `TrackerHeaderFrame`, `TrackerFadeTicker`** — Tracker was just + fixed for Ascension line wrapping (commit `581634d`). Do not + refactor the tracker. + +## What I did NOT find (that the report claimed) + +- **No learner queue inside `QuestieLearner.lua` using `tremove`.** The + report said the learner uses front-removal; it doesn't. +- **No call sites of `QuestieMapUtils.MapExplorationUpdate`.** Dead + function. Not in the report. +- **No 3rd party library that would benefit from being added.** The + codebase already uses Ace3, HereBeDragons, LibDeflate, LibStub, + XXH, ChatThrottle (AceComm), LibSharedMedia. The remaining + algorithmic problems are local, not library-shaped. +- **No JSON or config parsing hot path.** The DB is precompiled binary + via `Database/compiler.lua`. `QuestieDB.GetQuest` is a hash lookup. + No parser hot path. + +## What the report got 100% right + +- Front-removal queues exist and the highest-frequency ones are in + QuestieMap, QuestieComms, QuestieCombatQueue. (All Tier 1 fix 6 + candidates above.) +- O(n²) clustering exists in `QuestieMapUtils.CalcHotzones` and + `AvailableQuests._HasProperDistanceToAlreadyAddedSpawns`. (Tier 3 + fix 16-18.) +- HBD activeMinimapPins is scanned per frame in + `QuestieMap.ProcessShownMinimapIcons` (line 273). Real cost. +- Frame-name registry pattern is real and `_G[name]`-bound in many + places. (Tier 2 fix 13-14.) +- QuestieComms broadcast serialize is O(n²) and is the report's #7 + confirmed. (Tier 2 fix 12.) +- DBM integration is in a half-stubbed state — implementation + exists, TOC does not load it, callers reference it. (Tier 1 fix in + separate "do before perf" work, not a perf fix.) + +## Verdict on the earlier report + +It identified the right categories. The implementation order it +recommends (blockers first, then queues, then spatial, then +minimap, then refs, then caches, then comms) is sensible. The big +misses are: + +- No measurements — every "win" is theoretical. +- The "shared QuestiePerf / QuestieQueue / scheduler / debouncer" + recommendation is a new abstraction layer that the report doesn't + justify beyond "simpler". On a code base with 3 known-fragile + subsystems, adding new shared infrastructure is a regression risk + not a perf win. +- "Audit 100% of the code base" was not done. File counts and lint + counts were not evidence of code review. +- The "ascension_IsScalingEnabled arity" claim is correct and is the + only blocker I'd treat as urgent because it is a correctness bug, + not a perf issue. + +## Recommended next step + +Run Tier 1 (1-7) as a single low-risk commit. Each item is +independent, each is verifiable in-game, and each is easy to revert +if something breaks Sunstrider pins, tracker wrap, or Ascension +login. Do not proceed to Tier 2 without re-running the Sunstrider +Isle pin test from prior sessions. + +## Restore / re-run + +To restore the audit baseline if you need to: + +``` +git checkout audit-restore-2026-06-03 +``` + +To re-run selene: + +``` +selene --config selene.toml Questie.lua Modules Compat Database Localization +``` + +To re-run busted (when installed): + +``` +busted +``` + +(The `.busted` config at `Tests/.busted` will currently try to load +the macro files as specs and fail. That's why the previous +"4 fail" report was suspicious. Fix 3 in Tier 1 should make busted +clean.) + +--- + +# Pass-2 Addendum — 2026-06-03 (later) + +Files I did NOT read or only skim-read in pass 1, re-read or +fully-read in pass 2: Compat/HBD.lua, Modules/QuestieLearner.lua, +Modules/Quest/QuestieQuest.lua, Modules/QuestieEventHandler.lua, +Modules/QuestieCoordinates.lua, Modules/QuestieMenu/QuestieMenu.lua, +Modules/Arrow/QuestieArrow.lua + QuestieArrow_HEAD.lua (the +HEAD one was untouched and is dead), Modules/QuestieProfiler.lua +(opt-in dev tool, not auto-loaded), Modules/Tracker/QuestieTracker.lua +middle/end, Modules/Tracker/TrackerUtils.lua 200-1390, +Modules/Tracker/TrackerBaseFrame.lua, Modules/Tracker/TrackerFadeTicker.lua, +Modules/FramePool/QuestieFramePool.lua, Modules/FramePool/QuestieFrame.lua +(Load/OnShow parts), Modules/Options/TrackerTab/QuestieOptionsTracker.lua +fadeTicker handlers. + +## New things found in pass 2 + +### Ticker / OnUpdate inventory (complete) + +I said in pass 1 "I claimed 33 tickers in a Hindsight note, never +verified." Now verified. Final counts (Modules/ + Compat/, +excluding vendored Libs/): + +- **`C_Timer.NewTicker` total: 33** (confirmed by ripgrep) +- **`C_Timer.After` total: 45** (confirmed — pass 1 missed this + entire category; many are 0.5-2s "settle" delays that pile up + during busy sessions, but each is one-shot) +- **OnUpdate scripts total: 36** in Modules/Compat (excluding vendored + AceAddon/LibDBIcon) +- **`_G[name]` runtime frame lookups: 32 sites** across 8 files + (QuestieMap 5, QuestieQuest 5, TrackerUtils 6, QuestieMapUtils 1, + QuestieLearner 1, QuestieLoader 3, WorldMapTaintWorkaround 3, + others) + +The full freq breakdown: + +- 100Hz (0.01s) tickers: **5** — QuestieQuest.SmoothReset + (line 424, step machine), QuestieMenu.toggle townsfolk + spawner (line 118, burst-then-cancel), QuestieProfiler + (line 535, opt-in only), plus 2x 0.01s at 0.1s and 0.01s in + the grep output but those are misreads (0.1s and 0.01s). + Real high-freq (0.1s = 10Hz) is a different category. +- 10Hz (0.1s) tickers: **4** — Compat/HBD UpdateMinimapIconPosition + throttled to 1s actually; **QuestieMap.lua:201 fadeLogicCoroutine + resume** is 10Hz, **QuestieCoordinates.lua:146 coords + update** is 10Hz, **QuestieCombatQueue.lua:20 flush is 10Hz**, + **TrackerUtils objectiveFlashTicker is 10Hz** (only during + quest complete, not forever). +- 5Hz (0.2s) tickers: **2** — QuestieMap.ProcessQueue (line 200), + QuestieEventHandler.GroupJoined polling (line 478, cancellable). +- 2Hz (0.5s) tickers: **3** — QuestieLearnerComms processQueues + (line 160), QuestieLearnerComms reinforcement (line 163), + QuestieProfiler update (line 518, opt-in only). +- 1Hz (1.0s) tickers: **3** — QuestieLearner PruneGuidNpcCache + (line 3443, runs every 30 min via the 1800s ticker, not 1s), + QuestieFramePool glowLogicTimer (line 110, **DEAD — see below**), + AvailableQuests cleanupTimer (line 435, every 5s actually). +- 50Hz (0.02s) tickers: **4** — TrackerFadeTicker (1.5s burst), + QuestieOptionsTracker fadeMinMaxButtons (line 473), fadeQuestItemButtons + (line 509), expandButton (line 780). +- 20Hz (0.05s) tickers: **0** (only the arrow OnUpdate at 20Hz, + but that's SetScript not a ticker). +- 0.12s tickers: **1** — TrackerBaseFrame resize (line 421, only + during drag). + +**All 33 tickers accounted for.** None is unbounded-leak on its +own, but cumulatively they wake the engine ~50+ times/second +even when nothing is happening (5Hz draw + 10Hz fade coroutine ++ 10Hz coords + 2Hz comms + 5Hz group polling = ~32 wakes/s). +On 3.3.5a this is small but real. + +### Hindsight memory conflicts resolved + +**`minimapScale` "captured but not applied" claim from Hindsight +(2026-06-01) is OUTDATED relative to current source.** + +The actual code (Compat/HBD.lua:556-557 and :677-678) does: + +``` +minimapWidth = pins.Minimap:GetWidth() * pins.Minimap:GetScale() / 2 +minimapHeight = pins.Minimap:GetHeight() * pins.Minimap:GetScale() / 2 +``` + +`minimapScale` (line 423) is captured as a **change detector** +to force re-render on scale change, not as the width source. +Width/height computation reads `GetScale()` directly each frame. +This is correct. + +**`killDebounce` "unbounded growth" claim from Hindsight is +OUTDATED.** The current code at QuestieLearner.lua:2871-2876 +prunes on every kill: + +```lua +for g, ts in pairs(_Learner.killDebounce) do + if (now - ts) > 10 then + _Learner.killDebounce[g] = nil + end +end +``` + +Table is bounded at ~10s of recent GUIDs. Same pattern in +`_invalidateDebounce` (line 557-563) and `recentKills` (line +2967-2971) and `guidNpcCache` (line 2981-2986). All bounded. + +**This means the pass-1 report's "QuestieLearner.lua: kill debounce +unbounded" claim was based on stale Hindsight. The code has +been patched since.** + +### New dead code / dead branches found in pass 2 + +1. **`Modules/Arrow/QuestieArrow_HEAD.lua` (1056 lines, 42KB) is + NOT in the TOC.** Only `QuestieArrow.lua` and `QuestieArrowAssets.lua` + are listed in Questie-X.toc lines 94-95. The HEAD file uses + `arrowold.tga` and has its own OnUpdate + driver OnUpdate, but + is never loaded. **Delete candidate.** +2. **`Modules/Arrow/QuestieArrow.lua.bak4` (49KB)** — same size as + the live `QuestieArrow.lua` minus current edits. Not in TOC. + **Delete candidate.** +3. **`Modules/FramePool/QuestieFrame.lua:133: + `newFrame.BaseOnUpdate = _Qframe.BaseOnUpdate`** — but + `_Qframe.BaseOnUpdate` is **never defined anywhere.** The + function `GlowUpdate` exists at line 249 but the field name + doesn't match. So `BaseOnUpdate` is always nil and the + `glowLogicTimer` ticker at QuestieFramePool.lua:110 is **never + created.** The "is this a leak" question from earlier in this + audit: no, it's dead code. **Two-line fix:** change line 133 + to `newFrame.BaseOnUpdate = _Qframe.GlowUpdate` and the ticker + actually fires, fixing a real bug where the glow may not + stay in sync with parent size during runtime. **Or delete + the dead ticker code at QuestieFramePool.lua:109-113.** +4. **`Modules/QuestieMenu/QuestieMenu.lua:114: + `UnitFactionGroup("Player")`** — capital "Player" is NOT a + valid unit token. Returns nil. The conditional at line 124 + `(faction == "Alliance" and friendly == "A") or (faction == + "Horde" and friendly == "H")` evaluates to false-or-false for + all NPCs. Only NPCs with `friendly == nil` or `friendly == "AH"` + are shown. **Correctness bug: faction-tagged NPCs (A or H) are + filtered out for everyone, not just opposite faction.** Needs + user verification — possibly intentional fallback for AH-only + data, but the literal code does not match that intent. +5. **`Modules/Options/TrackerTab/QuestieOptionsTracker.lua:486 + and :522: `fadeTickerValue:Cancel()`** — calling `:Cancel()` + on a number (`fadeTickerValue` is a number, decremented 1→0). + **Typo bug.** The branch is also unreachable because + `fadeTickerValue` is initialized to 1 and decrements, never + exceeds 1. So `:Cancel()` on a number is dead. **Should be + `fadeTicker:Cancel()`.** Same pattern at line 522, and a + third one at line 786+ in the expandButton handler. +6. **`Modules/QuestieLearner.lua:3418: `elseif event == "QUEST_REMOVED" + or event == "QUEST_TURNED_IN" then`** — line 3401 already + handles `QUEST_TURNED_IN` and that branch returns. So + `event == "QUEST_TURNED_IN"` at line 3418 is unreachable. + Dead branch. Minor. +7. **`Modules/QuestieQuest.lua:222-224: `if not quest then return end` + inside the `next(QuestiePlayer.currentQuestlog)` loop** — + early-return from the entire function if any questId is not + in the DB. **Bug**: subsequent questIds in the loop are + never cleared. Should be a `break` or guarded differently. + Real but rare since currentQuestlog is kept in sync with DB. +8. **`Modules/QuestieLearner.lua:3443: 30-minute ticker on + `PruneGuidNpcCache`** — was 1800s (= 30 min) and I misread + in pass 1 as 1-second. Correction: it's 30 min, not 1s. + Not 33 tickers/second, my mistake. +9. **`Modules/Quest/QuestieQuest.lua:424: SmoothReset 100Hz step + machine ticker** — I missed in pass 1. The ticker is `0.01s` + (100Hz) and walks a `stepTable` calling thunks. When fully + reset is needed (rare), this fires 100×/sec until done. 64 + quests/tick, 100Hz: ~640ms for 100 quests. **Tick frequency + could be 0.05s (20Hz) without UX impact** — saves ~80 wakes + per reset. +10. **`Modules/Map/QuestieMap.lua:201 fadeLogicCoroutine 10Hz + ticker** — I missed in pass 1. The coroutine iterates + `HBDPins.activeMinimapPins` and calls `FadeLogic`/`GlowUpdate` + per pin (lines 272-280). With 50 active pins, that's + 50 method calls × 10Hz = 500 calls/sec. **The Hindsight + concern about per-frame cost is real but split across + the 10Hz cadence.** +11. **`Modules/Journey/tabs/Search/Search.lua` and + `SearchTab.lua` are 0 bytes (empty files).** Not in TOC. + **Delete candidate.** +12. **`Modules/Options/MapTab/QuestieOptionsMap.lua` (30 lines) + and `MinimapTab/QuestieOptionsMinimap.lua` (31 lines)** — + tiny stubs. Not a perf concern, just noise in file count. + +### Frame-name registry pattern: full inventory + +`_G[frameName]` runtime lookups are concentrated in 8 files: + +| File | Sites | Per-call cost | +|---|---|---| +| Modules/Quest/QuestieQuest.lua | 5 (lines 122, 159, 174, 206, 245) | All in `ShowQuestIcons`/`HideQuestIcons`/`ShowManualIcons`/`HideManualIcons`/`ClearAllNotes` — called per quest, per icon, per toggle | +| Modules/Tracker/TrackerUtils.lua | 6 (lines 163, 232, 264, 283, 327, ~end) | In `FlashObjective` (line 161 iterates `questIdFrames` fully) and `FlashFinisher` (line 261) | +| Modules/Map/QuestieMap.lua | 5 (lines 80, 81, 100, 143) | In frame-name registry init, not per-frame | +| Modules/Map/QuestieMapUtils.lua | 1 (line 191, dead code) | In `MapExplorationUpdate` which is never called | +| Modules/Quest/Libs/QuestieLoader.lua | 3 (lines 100, 101, 102) | In module loader, called once per module import | +| Modules/WorldMapTaintWorkaround.lua | 3 (lines 17, 23, 24) | In taint workaround init, once at startup | +| Modules/Libs/QuestieLearner.lua (or .comms) | 1 | In network data merge | +| Other | 7 | Various one-off | + +The hot ones are QuestieQuest's 5 (called on every ZONE_CHANGED +via `ShowQuestIcons`/`HideQuestIcons` from `ToggleNotes` and the +ZONE_CHANGED handler chain) and TrackerUtils's 6 (called only on +quest-complete flash, not per-frame). **The 5 in QuestieQuest.lua +ARE a per-zone-change cost and a Tier 1 candidate** — a frame +direct-reference cache would eliminate the `_G` lookup. + +### Things still NOT read in pass 2 + +For honesty: I did not read these in either pass: + +- `Database/QuestieDB.lua` (2367 lines) — read some in pass 1 + but not the full `GetQuest`/`GetNPC` implementation. Did + read enough to confirm `_Learner.killDebounce` is the + bottleneck not the DB layer. +- `Database/compiler.lua` (1676 lines) — used at load time + only, irrelevant to per-frame perf. +- `Database/Corrections/*.lua` (22 files, 33K lines) — data + tables, not logic. The BOM errors I verified. +- `Localization/l10n.lua` + `lookupZones.lua` (small). +- `Localization/lookups/*.lua` (155 files) — return-only data. +- `Compat/Compat.lua` (1996 lines) — the shim, mostly + function aliases. Read the relevant parts for `C_Timer`, + `Is335`, `xpcall`, `GetQuestsCompleted`. +- `Compat/Corrections.lua` (72 lines) — return-only data. +- `Compat/QuestReward.lua` (4344 lines) and `QuestTag.lua` + (2019 lines) — function tables, would need a deep read. +- `Compat/UiMapData.lua` (1793 lines) — data, used by HBD. +- `Modules/Auto/QuestieAuto.lua`, `Privates.lua`, `DisallowedIDs.lua` + — read the relevant event hook in pass 1. +- `Modules/Journey/*` (all 14 files) — UI for the search + panel, opens on demand only. +- `Modules/Options/*` (16 files) — settings UI, opens on + demand only. Read the TrackerTab fade ticker hot path. +- `Modules/Migration.lua`, `MinimapIcon.lua`, + `GameVersionError.lua` — single-purpose, low-cost. +- `Modules/Sounds.lua` — 49 lines, only plays on quest accept. + +If the report's findings are right that all per-frame +hot paths live in: QuestieMap, QuestieLib, QuestieComms, +QuestieTracker, QuestieFramePool, Compat/HBD, Modules/Arrow, +Modules/Quest/QuestieQuest, Modules/Quest/AvailableQuests, +Modules/QuestieLearner, and Modules/Libs/QuestieSerializer — +then I've covered 11/12. Missing: `Modules/Quest/QuestEventHandler.lua` +(807 lines, has 2 tickers per earlier inventory — not read in full +in either pass, only the C_Timer.After sites were enumerated). + +### Net delta vs pass 1 + +- **33 confirmed tickers** (pass 1 said "33 in Modules" without + enumeration; pass 2 enumerates them with frequencies and + call sites) +- **45 `C_Timer.After` one-shots** (pass 1 said zero; pass 2 + inventory) +- **36 OnUpdate scripts** (pass 1: only named 4; pass 2: full + inventory) +- **32 `_G[name]` sites** (pass 1: named 12; pass 2: full) +- **2 outdated Hindsight memory entries resolved** (minimapScale + scale-axis bug, killDebounce unbounded) +- **1 NEW correctness bug found:** `UnitFactionGroup("Player")` + capital P → nil → faction filter breaks for tagged NPCs +- **3 NEW typo / dead-branch bugs found:** QuestieOptionsTracker + `:Cancel()` on number (3 sites), QuestieQuest early-return + in loop, QuestieLearner unreachable `QUEST_TURNED_IN` branch +- **1 NEW dead-code path clarified:** QuestieFramePool ticker + code is never reached because BaseOnUpdate is nil +- **4 NEW dead-file candidates:** QuestieArrow_HEAD.lua, + QuestieArrow.lua.bak4, empty Search.lua + SearchTab.lua +- **1 NEW map render layer confirmed:** 10Hz fadeLogicCoroutine + iterates HBDPins.activeMinimapPins — not in pass 1 + +## Pass-2 verdict on the report + +The original 58-line report is still ~60% right, ~40% fabricated +or stale. The pass-1 audit (FULL report) is the substantive +artefact. This pass-2 addendum is a refinement: it pins down the +ticker/OnUpdate/_G inventory that pass-1 left as "trust me, there +are 33", and surfaces 7 additional findings (1 correctness, 3 +bugs, 4 dead-code candidates) that pass 1 missed by file-skipping +patterns. The pass-1 tier list still stands: Tier 1 is safe +without sign-off (now 9 items instead of 7, adding the +FramePool BaseOnUpdate wire-up or dead-code delete, and the +QuestieOptionsTracker `:Cancel()` typo fixes). Everything else +needs measurement or your call first. + +If you're going to merge Tier 1, recommend doing it in the +following groups to make each commit easy to bisect if something +regresses: + +1. **Pure deletions** (zero risk): `Modules/TaskQueue.lua`, + `Modules/Arrow/QuestieArrow_HEAD.lua`, + `Modules/Arrow/QuestieArrow.lua.bak4`, empty `Search.lua` + / `SearchTab.lua`, and the misplaced + `Modules/QuestieLearner_spec.lua` (or move to `Tests/`). +2. **TOC dedup** (zero risk if you re-launch): remove the + duplicate `Modules\QuestieSlash.lua` line in + `Questie-X.toc:182`. +3. **Tier 1 perf wins** (low risk): the 6 `tremove(_, 1)` to + head-index swaps, the + `QuestieLib.Ascension_IsScalingEnabled` arity fix (one-line + parameter rename), the FramePool BaseOnUpdate wire-up + OR dead-code removal (one-line choice). +4. **Tier 1.5 typo fixes** (zero risk to runtime, cosmetic): + the 3 `fadeTickerValue:Cancel()` → `fadeTicker:Cancel()` + in QuestieOptionsTracker.lua. Plus the early-return-in-loop + in QuestieQuest.ClearAllNotes. + +5. **Faction filter investigation** (DO NOT touch without + confirmation): `UnitFactionGroup("Player")` capital P in + QuestieMenu.lua:114. Possibly intentional, possibly not. The + user (you) needs to verify with in-game townfolk visibility + test before changing. + +### Restore / re-run (unchanged from pass 1) + +``` +git checkout audit-restore-2026-06-03 +selene --config selene.toml Questie.lua Modules Compat Database Localization +busted # when installed +``` + +--- + +# Pass-3 Addendum — 2026-06-03 (final) + +Pass 1 + 2 covered 33 tickers / 36 OnUpdates / 32 _G lookups +and the high-frequency rendering / event / quest-log paths. What +they missed: the data layer (l10n, GetQuest cache, IsDoable, +Serialize cost), the per-event full-quest-log scan, the +20+ call-sites of `AvailableQuests.CalculateAndDrawAll` that +each restart a 10K-quest scan, the per-frame `l10n(...)` allocation, +the QuestieStream pool dequeue, and the actual selene warning +count. + +Pass 3 reads the data layer, finishes the event/quest-handler +read, and verifies all numerical claims from passes 1+2 against +the actual current source. + +## New things found in pass 3 + +### l10n is the highest-frequency allocation in the addon + +`Localization/l10n.lua:167-205` is the metamethod-target for +`l10n("key", ...)` calls — and there are **976 such call sites +across Modules/ (pass-3 measurement, ripgrep `l10n(` count). +Per call: + +- Line 172: `local args = {...}` — **allocates a fresh table on + every call.** With 976 sites, including many inside + per-frame/per-event hot paths (TrackerUtils, QuestieQuest, + QuestieTracker, Options), this is the biggest alloc cost in + the addon. +- Line 174-176: `for i, v in ipairs(args) do args[i] = tostring(v)` + — tostring on every arg, even when already a string. +- Line 178, 184: 2 hash lookups (translations[key], + translationEntry[locale]). +- Line 200: `if #args == 0 then return translationValue end` — + fast path for no-args. **However** the metamethod at + line 207 still wraps with `function(_, ...) return _l10n:translate(...) end` + which evaluates `...` before the call. +- Line 181, 187, 192, 197, 204: `unpack(args)` — passes 1 and 2 + flagged `QuestieLib.tunpack` as recursive. **l10n uses bare + `unpack`** which is better, but still has overhead. On the + no-args path (`#args == 0`), unpack is correctly skipped. +- Line 207: `setmetatable(l10n, { __call = function(_, ...) ... end })` + — every `l10n("key")` goes through the metamethod, the + closure allocation, the `...` capture, the dispatch. + +**Total per-call cost (no-args case): 1 metamethod lookup + 1 +table alloc + 1 hash lookup + 1 string check + 1 closure call. +~5x the cost of a direct function call.** For 976 sites, the +no-args case is the common one (most l10n calls pass a string +literal with no format args). + +**Possible fix:** a per-module static cache for resolved +translation strings. On a 5000-quest login the quest log +update fires once, and the tracker update fires ~10 times in +the first second, and most of those l10n calls are for the +same handful of strings ("Completed", "Inactive", "Available +Quest", "Objective", etc.). A cache would drop the alloc + +hash cost for repeat keys to a single hash lookup. Estimated +savings: 90% of l10n allocation pressure. + +This is the single biggest perf miss across all three passes +and a **Tier 2 candidate** (above the per-frame stuff because +it runs on EVERY UI refresh, not just per-frame). + +### GetQuest and GetNPC cache behavior — confirmed correct + +`Database/QuestieDB.lua:1414-1430` (GetQuest) and `:2020-2030` +(GetNPC) both have a `questCache[questId]` / `npcCache[npcId]` +hit-fast-path that returns immediately on cache hit. **Cache +hit = 1 hash lookup + 1 return.** Cache miss = the full +`QueryQuest(questId, ...)` walk + 50-key fill loop. Confirmed: +the caches exist and are correct, but they can be **invalidated +unnecessarily** by: + +- `QuestieLearner.lua:919-921` invalidates `npcCache[npcId] = nil` + on every kill (per-kill cost: 1 hash write). The next GetNPC + for that ID does the full ~50-key rebuild. Per pass 1 + analysis, this is bounded by the kill rate. + +The bigger issue: `QuestieLearner.HandleNetworkData` at +QuestieLearner.lua:3610 and :3658 calls `InjectLearnedData` (the +300+ line function) on EVERY network payload. **Each call +re-injects ALL learned NPCs/Quests/Items/Objects into the +override table, then does a full QuestieDB.npcCache invalidation +implicit (via CopyWithoutField + new table writes).** In a +5-person party receiving 5 NPC broadcasts/min, that's 5 × +full re-injection × 300+ lines per minute. **This is the +biggest comms-driven hot path I missed in pass 1.** + +### IsDoable is the per-quest hot path I missed + +`QuestieDB.lua:893-1068` is `IsDoable(questId, debugPrint)` +— 176 lines. It does: +- 16+ `QueryQuestSingle(questId, "fieldname")` calls +- Each QuerySingle is dispatched through the `QueryQuest.QuerySingle` + function table (set at line 471-492 after init) +- 16+ hash lookups per call + +**Call sites (ripgrep `\.IsDoable\(`): 6 places.** Hot ones: +- `Modules/Quest/AvailableQuests.lua:177` — called per quest + in the main `_DrawQuestIfAvailable` loop. For 10K quests: + **160K+ hash lookups per CalculateAndDrawAll call.** +- `Modules/Quest/AvailableQuests.lua:98` — UnloadUndoable, per + abandon. Bounded. + +**`CalculateAndDrawAll` is called 20+ times in the codebase** +(ripgrep enumeration). The Options tab alone has 4 setting +toggles that each queue a 0.3s-delayed CalculateAndDrawAll +(line 376, 395, 418 of QuestieOptionsGeneral). A user changing +4 settings in rapid succession fires 4 CalculateAndDrawAlls. +The last one wins (line 49 of AvailableQuests cancels the +prior timer), but the debounce is in the wrong layer — it +should be at the UI, not in the worker. + +Each CalculateAndDrawAll = 1 outer NewThread + 1 inner +NewThread-per-quest (10K on Ascension) + 416 yields + 160K+ +hash lookups for IsDoable alone. **This is the single biggest +perf cost in the addon and the report's top recommendation +should be "reduce CalculateAndDrawAll trigger points", not +"shared abstraction layer".** + +### IsDoableVerbose is 263 lines, called from 2 sites + +`QuestieDB.lua:1069-1331` is `IsDoableVerbose(questId, ...)` — +2.5x the size of IsDoable. Used by: +- `QuestieSearchResults.lua:244` (search result display) +- `QuestieSlash.lua:397` (slash command) + +Both are user-initiated, not per-frame. The 263-line cost is +fine here. Not a hot path. Pass. + +### QuestieEventHandler periodic refresh is a 30-sec full rescan + +`Modules/Quest/QuestEventHandler.lua:93-98`: + +```lua +_periodicRefreshTimer = C_Timer.NewTicker(PERIODIC_REFRESH_SECONDS, function() + Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] Periodic refresh: forcing full quest log scan") + doFullQuestLogScan = true + _QuestEventHandler:QuestLogUpdate() +end) +``` + +**Every 30 seconds, forces a full quest log scan** that walks +`QuestiePlayer.currentQuestlog` and calls +`QuestieQuest:UpdateQuest` per quest. For 25 active quests, +this is 25 UpdateQuest calls every 30 seconds. Comment at +line 88-91 admits this is for Ascension reliability. **Bounded +but unmeasured.** Tier 3. + +### `_QuestLogUpdateQueue` and `questLogUpdateQueue` are dual-declared, never initialized + +`QuestEventHandler.lua:6-7`: + +```lua +local _QuestLogUpdateQueue = {} -- Helper module +local questLogUpdateQueue = {} -- The actual queue +``` + +**Both are plain empty tables.** Used at line 511 +(`continueQueuing = _QuestLogUpdateQueue:GetFirst()()` — calls +a method on a plain table, which would ERROR), and at line 294, +304, 560 (`_QuestLogUpdateQueue:Insert(function() ... end)` — +same problem). **Either the original code intended a queue +class that was never wired up, or these are dead refs that +were left in by accident.** This would throw a runtime +"attempt to index a nil value" error the first time +QuestLogUpdate runs with a queued callback. **Real bug if +the queue path is ever exercised; dead-code smell if not.** + +**Cannot verify without in-game test.** The Hindsight memory +mentions this codebase has Phase-1-fix vararg handling tests +and a smoke test. Need to check if the queue path is ever +exercised in the smoke test. + +### `select(8, GetQuestLogTitle(questLogIndex))` is in 2 sites + +- `QuestEventHandler.lua:240` — `questId = questId or select(8, GetQuestLogTitle(questLogIndex))` +- `QuestEventHandler.lua:571` — `local numEntries = select(1, GetNumQuestLogEntries()) or 0` + +Hindsight memory stated "Lua 5.0 lacks a standard `select` +function" — **this is INCORRECT.** Lua 5.0 has `select`. What's +actually missing in Lua 5.0 vs 5.1 is `goto`, `_ENV`, `unpack` +as global, and `string.pack`/`string.unpack`. `select` is +in Lua 5.0 stdlib. **The Hindsight memory should be updated.** + +The `select(8, GetQuestLogTitle(...))` pattern itself is valid +on 3.3.5a (returns the 8th return value of the multi-value +call). It's a perf concern only because it forces a full +tuple unpack. Could be replaced with a fixed local: +`local title, level, _, isHeader, _, _, _, questId = GetQuestLogTitle(idx)`. +Minor. + +### Selene run to completion — corrected numbers + +Pass 1 said "warning count tops out around 410 in the partial +capture". Pass 2 said "original report's 1162 unverified." +**Pass 3: redirected selene output to a file (the I/O error +in pass 1+2 was a terminal-PTY error, not a selene error).** + +**Final selene counts (validated):** + +- **6 errors total** (was 2 in pass 1, was 1162 in original + report — original was wrong on errors, right on warnings) + - **2 `mismatched_arg_count`** at QuestieLib.lua:39, 56 (the + arity bug, confirmed pass 1) + - **4 `parse_error: unexpected character` (BOM)** at: + - `Database/Corrections/tbcQuestFixes.lua` (pass 1 saw) + - `Database/Corrections/wotlkItemFixes.lua` (pass 1 saw) + - `Database/Corrections/wotlkQuestFixes.lua` (pass 1 saw) + - **`Compat/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.lua`** + (pass 1 MISSED — vendored but real) +- **1162 warnings total** (original report confirmed correct): + - 480 `parentheses around conditions` (style) + - 457 `only one statement per line` (style) + - 40 `shadowing variable self` + - 26 `single quotes do not have to be escaped` + - 12 `empty if block` + - 8 `shadowing variable type` + - 6 `empty else block` + - 6 undeclared `SlashCmdList` global (legit global, ignore) + - 5 undeclared `ColorPickerFrame` global (legit global, ignore) + - 4 `shadowing variable x` + +**The original 58-line report's "1162 warnings" is correct.** +My pass-1 ~410 was the partial capture due to I/O aborts, not +an actual lower number. Pass 1 was wrong to say "I could not +reproduce." Should have piped to a file. + +### QuestieLib.lua full read — confirmed pass 1+2 + +`QuestieLib.lua` was 700+ lines. Pass 1 read through 700. Pass +3 verified the rest (700-1000+): +- `tunpack` (line 655-668): recursive, confirmed pass 1. + One-line fix: `return unpack(tbl, 1, tbl.n)`. +- `tpack` (line ~620): uses `select("#", ...)`. Fine. +- `GetColoredQuestName` (line 178): 11+ hash lookups, confirmed + pass 1. The full per-quest-title cost breakdown: + 1 `QueryQuestSingle(id, "name")` (1 hash) + 4 `QuestieDB.Is*` lookups (4 hashes, 1 each) + 1 `QueryQuestSingle(id, "level")` (1 hash) + 1 `l10n("(%d) %s", ...)` (1 metamethod + 1 alloc + 1 format) + 1 `QueryQuestSingle(id, "...")` for color (1 hash) + 1 `PrintDifficultyColor` (1 hash + 1 string concat) + ~11 hash lookups + 1 l10n alloc per call. +- `TextWrap` (line 700+): allocates FontString on first call, + cached. Fine. +- `Euclid` (line ~600): legacy 4-arg `(x,y,i,e)` signature + not `(x1,y1,x2,y2)`. Confirmed pass 1. Used by Hotzones calc. +- `Ascension_IsScalingEnabled` arity bug (line 33, called at + 39, 56): confirmed pass 1. **Real correctness bug on + Ascension.** + +### New findings in pass 3 + +1. **`QuestieDB.IsDoable` is the highest-cost per-quest call.** + 176 lines, 16+ QueryQuestSingle per call. Called per quest + in the main CalculateAndDrawAll loop. **160K+ hash lookups + per full scan.** The right fix is NOT to refactor IsDoable + itself (it's a logic function, hard to safely rewrite) but + to **reduce the number of times CalculateAndDrawAll fires** + by gating event handlers and the Options tab debounce. + +2. **`AvailableQuests.CalculateAndDrawAll` is called from 20+ + sites**, with the Options tab alone calling it 4 times + per setting change. **Top recommendation: move the + debounce to the UI layer** so that toggling 4 settings in + 1 second triggers 1 CalculateAndDrawAll, not 4. + +3. **`l10n(...)` allocates a fresh `{...}` table on EVERY call.** + 976 sites. Tier 2 fix: a per-module static cache for + resolved strings. **Single biggest alloc cost missed across + all three passes.** + +4. **`InjectLearnedData` (300+ line function) is called on + EVERY network payload** (QuestieLearner.lua:3610, 3658). + 5 NPCs/min broadcast × 5 party members = 25 full + re-injections per minute. **Tier 2 fix: batch incoming + payloads into a single InjectLearnedData call per + message-burst window.** + +5. **`_QuestLogUpdateQueue` and `questLogUpdateQueue` are + plain empty tables used as method-bearing objects** at + QuestEventHandler.lua:294, 304, 511, 560. **Real latent + runtime bug** if the queue path is exercised. Needs + in-game smoke test to confirm whether the path is reached + in normal use. + +6. **Hindsight memory is wrong about Lua 5.0 lacking `select`.** + Lua 5.0 has `select`. What's missing is `unpack` (in 5.0 + it's a table field), `_ENV`, `goto`, and `string.pack`. + The `select(8, ...)` patterns in QuestieEventHandler are + valid on 3.3.5a. + +7. **Selene's 1162 warnings is the actual number.** Pass 1's + "410 in partial capture" was wrong. The I/O abort was a + terminal PTY issue, not a selene issue. **The original + 58-line report's 1162 is correct.** + +8. **BOM count is 4, not 3 as pass 1 said.** The fourth is + vendored `Compat/Libs/LibSharedMedia-3.0/LibSharedMedia-3.0.lua`. + The original report's "4" is correct. + +9. **`QuestieStream` uses `tremove(StreamPool)` (line 57) — no + second arg, defaults to `tremove(t)` which is `tremove(t, #t)`, + O(1) at the end. **Not a tremove(_, 1) call.** Pool is + fine.** + +10. **`QuestieFramePool.lua:221: tremove(QuestieFramePool.Routes_Lines, 1)`** + is the lines pool dequeue — O(N) shift on every route line + reuse. Pool size typically small (10-30 lines per quest). + **Low impact but not enumerated in pass 1+2.** + +11. **`Modules/QuestieLib.lua:175 (GetColoredQuestName)`** is + called by **`StaticPopupDialogs["QUESTIE_CONFIRMHIDE"]` at + QuestieFramePool.lua:45** as part of the dialog `text` + setup. This means **every "Are you sure you want to hide + the quest X?" popup does 11 hash lookups + 1 l10n alloc + just to compose the dialog text.** The popup text is set + at popup-show time, not at dialog-creation time, so the + cost is bounded to "user clicks Shift+LeftButton on an + available icon." Bounded but real. + +## Final tier list (consolidated across 3 passes) + +**Tier 1 (zero risk, ship as one commit, each line independent):** +- Delete `Modules/TaskQueue.lua` (18 lines, dead code) +- Delete `Modules/Arrow/QuestieArrow_HEAD.lua` (1056 lines, not in TOC) +- Delete `Modules/Arrow/QuestieArrow.lua.bak4` (49KB backup, not in TOC) +- Delete `Modules/Journey/tabs/Search/Search.lua` and `SearchTab.lua` (0 bytes) +- Move or delete `Modules/QuestieLearner_spec.lua` (401 lines, `print("PASS:")` in Modules/) +- Remove duplicate `Modules\QuestieSlash.lua` line in `Questie-X.toc:182` +- Fix `Modules/Libs/QuestieLib.lua:33` `Ascension_IsScalingEnabled` to take `questId` arg (correctness, not perf) +- Strip the leading BOM bytes from the 4 affected files (3 in `Database/Corrections/`, 1 in `Compat/Libs/LibSharedMedia-3.0/`) +- Swap the 6 confirmed-runtime `tremove(_, 1)` calls for head-index or circular buffer: + - `QuestieFramePool.lua:221` (Routes_Lines) + - `QuestieCombatQueue.lua:25, 33` (combat queue) + - `QuestieMap.lua:360, 370` (map + minimap draw queues) + - `QuestieComms.lua:572, 692` (broadcast blocks) + - `QuestieComms.lua:596, 716` (next broadcast data) + - `QuestieValidateGameCache.lua:119` (callbacks) + - (Note: `QuestieNameplate.lua:196` `tremove(npUnusedFrames)` is `tremove(t)` not `tremove(t,1)` — O(1) at end, NOT Tier 1) + +**Tier 1.5 (zero runtime risk, typo fixes):** +- Fix 3 `fadeTickerValue:Cancel()` → `fadeTicker:Cancel()` in `QuestieOptionsTracker.lua:486, 522, 786` +- Fix `if not quest then return end` inside next() loop in `QuestieQuest.lua:222` to use a guard flag instead of early-return +- Delete dead `elseif event == "QUEST_TURNED_IN"` at `QuestieLearner.lua:3418` +- Wire up the dead `glowLogicTimer` ticker at `QuestieFramePool.lua:109-113` (change `QuestieFrame.lua:133` to `_Qframe.GlowUpdate`) OR delete the dead code +- Investigate `_QuestLogUpdateQueue` / `questLogUpdateQueue` at `QuestEventHandler.lua:6-7` — are these dead refs or is the queue path exercised? + +**Tier 2 (higher risk, needs measurement first):** +- Cache `l10n(...)` results per-module (or per-call-site) to drop the `{...}` alloc + metamethod + hash on repeat keys. 976 sites, biggest alloc cost missed. +- Move the Options-tab CalculateAndDrawAll debounce to the UI layer so 4 setting changes in 1 second = 1 full rescan, not 4. +- Batch `HandleNetworkData` injections in `QuestieLearner` so 5 NPC broadcasts = 1 `InjectLearnedData` call, not 5. +- Add a cache for `QuestieLib.GetColoredQuestName` so the 11+ hash lookups run once per (questId, locale) tuple, not per call site. (Called from `StaticPopupDialogs.QUESTIE_CONFIRMHIDE` text set, per-hide-popup.) + +**Tier 3 (needs measurement or user go, NOT recommended for a Tier 1 commit):** +- Replace `per-quest` `IsDoable` calls with a "batched Doable check" that loads all quest fields into memory once and does all checks in one pass. (The right fix; could be 10x speedup but requires in-game regression testing.) +- Replace `AvailableQuests._DrawAvailableQuest`'s `NewThread` per quest (line 282) with a batched coroutine that processes N quests per yield, like `_CalculateAvailableQuests` does at the outer level. +- Refactor `CalculateAndDrawAll`'s trigger points to gate event-driven scans (e.g. only scan on actual quest log change, not on every ZONE_CHANGED). + +**Do NOT do (rejected from the original report's recommendations):** +- New shared abstraction layers (`QuestiePerf`, `QuestieQueue`, scheduler/debouncer module). These contradict the user's repeated anti-invasive-refactor stance and risk regressing the 3 known-fragile subsystems. +- Refactoring `IsDoable` itself — it's 176 lines of tightly-coupled quest state machine logic, not a generic "check if quest is doable" function. Touching it is high risk. +- Refactoring the `frameName` registry pattern globally. It's a real cost (32 sites, 5 in hot ZONE_CHANGED path) but the fix is local: pass 2 already identified `QuestieQuest.lua:122, 159, 174, 206, 245` as the 5 hot sites. Fix those 5 with a direct-reference cache; leave the rest alone. + +## Final pass-3 verdict + +Pass 1: 60% right, 40% fabricated/stale, foundational artefact. +Pass 2: 12 additional findings, 2 Hindsight conflicts resolved, dead-file inventory completed. +Pass 3: 11 additional findings, data-layer covered, selene +warning count confirmed, CalculateAndDrawAll 20+ call sites +enumerated, IsDoable cost enumerated, l10n alloc cost +identified as the biggest miss. + +**Single most important new finding from pass 3: `l10n(...)` +allocates a fresh `{...}` table on every call across 976 sites +in Modules/.** This is the biggest alloc pressure in the +addon and was missed in both pass 1 and pass 2 because the +localization layer was treated as "just lookups" rather than +"allocates + hashes on every call." + +**Second most important: `AvailableQuests.CalculateAndDrawAll` +fires 20+ times in the codebase, each call rescans 10K quests +and spawns 10K inner NewThreads.** The right fix is gating +trigger points, not refactoring the worker. + +**Third most important: the original 58-line report's 1162 +warnings is correct (pass 1 said it couldn't be reproduced; +the I/O abort was a terminal issue, not a selene issue).** + +## Restore / re-run (final) + +``` +git checkout audit-restore-2026-06-03 +selene --config selene.toml Questie.lua Modules Compat Database Localization 2>&1 | tee /tmp/selene.out +busted # when installed +``` + +The `tee` to a file avoids the terminal I/O abort and gives +the full 1162 warning count. + +--- + +# Pass-4 Addendum — 2026-06-03 (final) + +Pass 1+2+3 covered 33 tickers / 36 OnUpdates / 32 _G lookups +/ 47 C_Timer.After / the data layer (l10n, IsDoable, GetQuest +cache), the per-event full-quest-log scan, the 20+ call sites +of CalculateAndDrawAll, the per-frame l10n allocation, the +QuestieStream pool dequeue, and the selene warning count. + +Pass 4 covers what passes 1-3 didn't: +- The full Tooltip hot path (Tooltip.lua + TooltipHandler.lua + + QuestieCommsData.lua) +- The middle of QuestieDB.lua (IsDoable, GetNearestSpawn, + GetQuestsByZoneId, factionReactions) +- QuestieAnnounce, QuestieSlash, QuestieInit end, + QuestEventHandler end +- The HBDHooks OnMapChanged override +- The Journey window code +- The AvailableQuests 5-sec cleanup ticker +- Re-verification of the pass-2 _QuestLogUpdateQueue finding + +## Resolutions of pass-2 errors + +**Pass 2 flagged `_QuestLogUpdateQueue` at QuestEventHandler.lua:6-7 +as a latent bug because it's declared as a plain empty table +but used as a method-bearing object.** **Pass 4: this was a +false positive.** Lines 683-693 define the `Insert` and +`GetFirst` methods on the table: + +```lua +function _QuestLogUpdateQueue:Insert(callback) + questLogUpdateQueue[questLogUpdateQueueSize] = callback + questLogUpdateQueueSize = questLogUpdateQueueSize + 1 +end + +function _QuestLogUpdateQueue:GetFirst() + questLogUpdateQueueSize = questLogUpdateQueueSize - 1 + return tableRemove(questLogUpdateQueue, 1) +end +``` + +The data lives in `questLogUpdateQueue` and `questLogUpdateQueueSize` +as local upvalues, the methods live on the `_QuestLogUpdateQueue` +table — closure pattern, fully functional. Pass 2 was wrong +to flag it as a bug. + +**Hindsight memory about Lua 5.0 lacking `select` (flagged in +pass 3) — confirmed wrong in pass 4.** The `select(8, +GetQuestLogTitle(...))` at QuestEventHandler.lua:240 and +QuestieDB.lua:2179, 1903, etc. is valid on 3.3.5a. + +## New things found in pass 4 + +### Tooltip.lua: per-tooltip O(party²) cost missed in passes 1-3 + +`Modules/Tooltips/Tooltip.lua:411-451` — the inner loop of +`GetTooltip` does: + +```lua +for questId, questData in next, tooltipData do + for _, playerList in next, questData.objectivesText or {} do + for objectivePlayerName, objectiveInfo in next, playerList do + local playerInfo = QuestiePlayer:GetPartyMemberByName(objectivePlayerName) +``` + +**`GetPartyMemberByName` (QuestiePlayer.lua:170-186) iterates +1-40 party slots per call, with `UnitName(partyX)` and +`UnitClass(partyX)` per iteration.** The combined cost for +a single tooltip show with 5 active quests × 3 objectives × 5 +party members = **75 calls to `GetPartyMemberByName` × 40 slot +iterations = 3000 WoW API calls per tooltip show.** For a +player who hovers a busy NPC in a 5-person group with 5 shared +quests, this is the worst-case per-tooltip cost in the addon +and was completely missed by passes 1-3. + +**Caching opportunity:** build a `name→info` map once at the +top of `GetTooltip` by calling `GetPartyMemberList()` once, +then use the map for the inner loop. One call instead of 75. + +### QuestieDB.IsComplete: double-call anti-pattern (NEW bug) + +`Database/QuestieDB.lua:1355`: + +```lua +local expectedObjectives = QuestieDB.GetQuest(questId) and QuestieDB.GetQuest(questId).ObjectiveData +``` + +**`QuestieDB.GetQuest(questId)` is called TWICE on the same +questId in one expression.** The first call's return value +is only used as a truthiness check, but the call still +executes the type-check, the cache check, the string-to-number +conversion. The second call hits the cache and returns the +real data. **Should be:** + +```lua +local quest = QuestieDB.GetQuest(questId) +if quest and quest.ObjectiveData then + -- use quest.ObjectiveData +end +``` + +`IsComplete` is called from many hot paths (AvailableQuests.lua:155 +checks `IsComplete` per quest in the available-quest loop, +QuestieQuest.lua UpdateQuest, etc.). **The cost is double +the per-call IsComplete cost.** For 10K quests at login +scan: 10K × 2 = 20K GetQuest calls instead of 10K. + +### Database/QuestieDB.lua:1903 — load-time UnitFactionGroup (NEW latent bug) + +```lua +local playerFaction = UnitFactionGroup("player") +local factionReactions = { + A = (playerFaction == "Alliance") or nil, + H = (playerFaction == "Horde") or nil, + AH = true, +} +``` + +**This is at MODULE LOAD TIME (line 1903, top-level statement +in QuestieDB.lua).** When QuestieDB.lua is loaded by the +QuestieLoader depends on module-load order — if QuestieDB +loads before PLAYER_LOGIN, `UnitFactionGroup("player")` +returns nil. **Result: `factionReactions.A` and +`factionReactions.H` are both nil.** + +Then in `GetNPC` at line 2061: +```lua +npc.friendly = (not friendlyToFaction) and true or factionReactions[friendlyToFaction] +``` + +For NPCs with `friendlyToFaction = "A"` (Alliance-tagged), +`npc.friendly` becomes nil. **The Ascension faction filter +stops working.** + +This is **separate from the QuestieMenu.lua:114 "Player" +capital-P bug** (which returns nil due to wrong unit token, +also a faction filter bug). Two distinct faction filter +bugs, both with the same symptom (faction-A and faction-H +NPCs become invisible). + +The Hindsight memory says "User verified in-game faction +filter works correctly" — but that may have been during a +specific session where the load order worked out. **Cannot +verify without in-game re-test.** Flagged for user +verification. + +### QuestieAnnounce.lua:19 — acknowledged unbounded leak (NEW) + +```lua +local alreadySentBandaid = {} -- TODO: rewrite the entire thing its a lost cause +``` + +**The developer themselves commented this as needing a +rewrite.** `alreadySentBandaid[message] = true` at line 123 +adds a unique key per announcement. The table is **never +pruned**. After a long session with many quest accept / +complete / item loot announcements, this table grows to +thousands of entries. **Acknowledged in the source as +needing rewrite, never rewritten.** + +Easy fix: a `C_Timer.After(N, wipe)` periodic, or a +`table.clear(alreadySentBandaid)` on PLAYER_LOGIN. Tier 1.5. + +### QuestieDB.IsDoable: 12 QueryQuestSingle + IsComplete call paths (NEW) + +`Database/QuestieDB.lua:933-1042` — `IsDoable` does: +- Line 933: `QueryQuestSingle(questId, "requiredRaces")` +- Line 941: `QueryQuestSingle(questId, "preQuestSingle")` +- Line 950: `QueryQuestSingle(questId, "requiredClasses")` +- Line 957: `QueryQuestSingle(questId, "requiredMinRep")` +- Line 958: `QueryQuestSingle(questId, "requiredMaxRep")` +- Line 972: `QueryQuestSingle(questId, "requiredSkill")` +- Line 990: `QueryQuestSingle(questId, "preQuestGroup")` +- Line 1000: `QueryQuestSingle(questId, "parentQuest")` +- Line 1006: `QueryQuestSingle(questId, "nextQuestInChain")` +- Line 1016: `QueryQuestSingle(questId, "exclusiveTo")` +- Line 1033: `QueryQuestSingle(questId, "requiredSpecialization")` +- Line 1042: `QueryQuestSingle(questId, "requiredSpell")` + +**12 `QueryQuestSingle` calls per IsDoable. Per CalculateAndDrawAll +on Ascension (10K quests), that's 120K QueryQuestSingle calls. +With the IsComplete double-call (above) folded in for the +IsDoable-then-IsComplete path, it's 24 QueryQuestSingle × 10K += 240K hash lookups per full scan.** Plus the `QuestieReputation`, +`QuestieProfessions`, `IsSpellKnownOrOverridesKnown`, +`IsPlayerSpell`, `C_QuestLog.IsOnQuest`, etc. calls scattered +through the function. + +The IsDoable comment at line 895-908 explicitly says "IsDoable +does the same logic but doesn't return text. These are +maintained separately for performance, because IsDoable is +often called in a loop through every quest in the DB." +**The current per-call cost is 12+ hash lookups + 4-5 WoW API +calls. The right optimization is batched Doable checking +that loads each field once and does all checks in a single +pass.** + +### AvailableQuests.lua:435 — third periodic cleanup ticker (NEW) + +`Modules/Quest/AvailableQuests.lua:435` adds a **5-second +periodic cleanup ticker** that walks all `QuestieMap.questIdFrames` +and unloads any completed-quest frames. **This is the third +periodic cleanup ticker** in the addon (others at +QuestEventHandler.lua:93-98 30-sec and any other in the +codebase). For 100+ active quest frames, this is 100+ +iterations every 5 seconds = 20 iterations/sec average. +Bounded but unmeasured. + +Combined with the QuestEventHandler 30-sec ticker and the +other periodic cleanups, **the addon has at least 3 +different periodic cleanup loops running on different +intervals.** They could be consolidated into a single +periodic-cleanup module. + +### QuestieInit.lua:596 — 50Hz timer shim (NEW ticker not in pass-2 inventory) + +`Modules/QuestieInit.lua:596`: +```lua +elseif coroutine.status(QuestieInit.Thread) ~= "dead" then + C_Timer.After(0.02, resumeInit) -- continue yielding using the timer shim +end +``` + +**This is a 50Hz C_Timer.After shim that drives the init +coroutine.** Runs from init start until the coroutine +dies (end of `StartStageCoroutine`). **Bounded to init +time** (seconds to ~1 minute depending on DB size) but +adds to the C_Timer.After count. + +**Updated C_Timer counts (final):** +- `C_Timer.NewTicker`: 33 +- `C_Timer.After`: **47** (was 45 in pass 2 — the 2 new + ones are the AvailableQuests cleanup timer's C_Timer.After + + this 0.02s init shim) +- `C_Timer.NewTimer`: **5** (NEW category not enumerated + in passes 1-3) + +### QuestieMap.lua:170-184 — `RescaleIcons` does double-loop with `_G[frameName]` lookups (NEW) + +`Modules/Map/QuestieMap.lua:170-184` is `RescaleIcons` — +per the pass-1 finding, it does a double-loop with +`_G[frameName]` lookups. **Per pass 1+2+3 audit, the +double-loop is O(N×_G_lookup_cost) where N is the active +icon count.** Called on every ZONE_CHANGED event. + +The pass-2 audit identified this. **Pass 4 confirms: the +fix is to use a direct-reference cache for the frame +registry** (the `QuestieMap.questIdFrames` table), not +a new global _G look-up chain. + +### QuestieTooltips.lua:411-451 — `tinsert(tempObjectives, 1, text)` is O(N) (NEW) + +`Modules/Tooltips/Tooltip.lua:437`: +```lua +tinsert(tempObjectives, 1, objectiveInfo.text); +``` + +**`tinsert(t, 1, x)` is O(N) shift of the entire table.** +For 5+ entries, this is 5+ shifts per call. Cheap +individually but adds up. Better: track insertion +position manually or use a different structure. + +Also, `tempObjectives = {}` is allocated per-quest (line 413) +inside the outer loop. 5 quests = 5 table allocs. Trivial +but a single outer-scope table with `wipe` would be cheaper. + +### QuestiePlayer.lua:170-186 — `GetPartyMemberByName` 40-iteration scan (NEW) + +`Modules/QuestiePlayer.lua:170-186`: +```lua +for index=1, 40 do + local name = UnitName("party"..index); + local _, classFilename = UnitClass("party"..index); + if name == playerName then + ... + end + if(index > 6 and not UnitInRaid("player")) then + break; + end +end +``` + +**For every lookup, iterates 1-40 slots with `UnitName + +UnitClass` per iteration.** Called from Tooltip.lua 411-451 +multiple times per tooltip show. **Per pass 4 finding +above, this is the bottleneck of the per-tooltip hot path.** + +Easy fix: cache the party member list at tooltip-show time +(see tooltip hot path fix above). + +### HBDHooks.lua:31-33 — `EnumeratePinsByTemplate` re-scales all pins on map change (NEW) + +`Modules/Map/HBDHooks.lua:31-33`: +```lua +for pin in map:EnumeratePinsByTemplate("HereBeDragonsPinsTemplateQuestie") do + QuestieMap.utils:RescaleIcon(pin.icon, mapScale) +end +``` + +**On every map change, iterates ALL Questie pins and +rescales each one.** For 50+ active pins, this is 50+ +RescaleIcon calls per map change. Bounded to map change +events (not per-frame). Pass — already in pass 1+2+3 as +"map change handler" but the per-pin cost was not enumerated. + +### QuestieCommsData.lua:62-67 — async `ContinueOnItemLoad` closure over local (RESOLVED) + +`Modules/Network/QuestieCommsData.lua:62-67` uses +`Item:CreateFromItemID(item.id).ContinueOnItemLoad(callback)` +to fetch item names asynchronously. **On 3.3.5a, the +fake `Item:CreateFromItemID` at QuestieDB.lua:323-340 calls +the callback SYNCHRONOUSLY** (line 327-332: `callback()` is +called immediately if `callback` exists). So the closure +over the local `tooltipData` table mutates it before the +function returns. No bug on 3.3.5a. + +**On Retail (where this was originally written for), the +callback would be truly async and would update the +tooltipData later. The schema mismatch between +`CommsData.GetTooltip` (which returns `{text, fulfilled, required}`) +and `Tooltip.lua` (which expects `{color, text}` per +player) is a Retail-only issue, hidden by the 3.3.5a fake.** + +Confirmed safe on the target platform. + +### QuestieSlash.lua:364-366 — `for _, _ in pairs(complete) do count = count + 1 end` (NEW) + +`Modules/QuestieSlash.lua:364-366`: +```lua +for _, _ in pairs(Questie.db.char.complete) do + questCount = questCount + 1 +end +``` + +**Full iteration of the complete-quest table** just to count +entries. For 10K completed quests on Ascension: 10K hash +iterations per `/questie flex`. The pattern appears in +Options UI code too (per the OptionsTab enumeration). + +Easy fix: maintain a counter on quest accept/complete events. +Low priority — only fires on user slash command. + +### QuestieTracker.lua + TrackerFadeTicker — confirmed correct (NEW) + +`Modules/Tracker/TrackerFadeTicker.lua` (read in pass 4) +is a 50Hz fade-in/out ticker that uses a per-tick alpha +step. Bounded to ~30 ticks per fade (~0.6s). **Confirmed +correctly throttled, no leak.** The Hindsight memory about +freshly-fixed line wrap in 581634d is correct — the +`SetText` monkey-patch in TrackerLinePool.lua:78-82 is the +design. + +### WeaponMasterSkills — confirmed data-only (NEW) + +`Modules/Map/WeaponMasterSkills.lua` (37 lines) is a static +data table + a `title .. "\n - " .. l10n(skill)` helper. +**No hot path. Pass.** + +## Final tally across all 4 passes + +**Files read end-to-end or near-end-to-end in passes 1-4:** +1. Questie-X.toc, Questie.toc, Questie-X-Turtle.toc +2. Questie.lua, QuestieInit.lua +3. Modules/Arrow/QuestieArrow.lua +4. Modules/Arrow/QuestieArrow_HEAD.lua +5. Modules/Compat/Libs (skim) +6. Compat/HBD.lua +7. Database/Constants.lua +8. Database/compiler.lua (skim) +9. Database/QuestieDB.lua +10. Database/Corrections/QuestieCorrections.lua (skim) +11. Database/Corrections/QuestieEvent.lua (skim) +12. Localization/l10n.lua +13. Modules/FramePool/QuestieFrame.lua, QuestieFramePool.lua +14. Modules/Map/QuestieMap.lua +15. Modules/Map/QuestieMapUtils.lua +16. Modules/Map/HBD.lua +17. Modules/Map/HBDHooks.lua +18. Modules/Map/WeaponMasterSkills.lua +19. Modules/Network/QuestieCommsData.lua +20. Modules/Network/QuestieLearnerComms.lua +21. Modules/Quest/AvailableQuests.lua +22. Modules/Quest/QuestEventHandler.lua +23. Modules/Quest/QuestieQuest.lua +24. Modules/QuestieAnnounce.lua +25. Modules/QuestieCoordinates.lua +26. Modules/QuestieEventHandler.lua +27. Modules/QuestieLearner.lua +28. Modules/QuestieLib.lua +29. Modules/QuestieMenu/QuestieMenu.lua +30. Modules/QuestieNameplate.lua +31. Modules/QuestiePlayer.lua +32. Modules/QuestieProfiler.lua +33. Modules/QuestieSlash.lua +34. Modules/QuestieStream.lua +35. Modules/Tracker/QuestieTracker.lua +36. Modules/Tracker/TrackerBaseFrame.lua +37. Modules/Tracker/TrackerFadeTicker.lua +38. Modules/Tracker/TrackerLinePool.lua +39. Modules/Tracker/TrackerUtils.lua +40. Modules/Tooltips/Tooltip.lua +41. Modules/Tooltips/TooltipHandler.lua +42. Modules/WorldMapTaintWorkaround.lua +43. Modules/Journey/QuestieJourney.lua + +**Files I did not read end-to-end:** +- Database/compiler.lua (1676 lines, skim only) +- Database/Corrections/* (4 files, BOM issues only) +- Database/itemDB.lua, npcDB.lua, objectDB.lua, questDB.lua + (never opened — these are likely pure data) +- Database/Zones/zoneTables.lua (3053 lines, pure data) +- Modules/Auto/QuestieAuto.lua, AutoPrivates.lua, DisallowedIDs.lua +- Modules/Journey/tabs/* (only QuestieJourney.lua + tab files exist) +- Modules/Options/* (multiple, partial read) +- Modules/Tutorial/* (2 files) + +The unread files are primarily **Options UI tabs and pure-data +files.** The Options tabs are user-triggered (settings open) +not per-frame, and the data files are pre-compiled lookup +tables that don't execute any logic. **The 4-pass audit +covered all the runtime-hot-path code.** + +## Final pass-4 verdict + +Pass 1: 60% right, 40% fabricated/stale, foundational. +Pass 2: 12 new findings, 2 Hindsight conflicts resolved, +dead-file inventory. +Pass 3: 11 new findings, data layer, selene count, biggest +alloc miss (l10n). +Pass 4: 9 new findings, Tooltip hot path, IsComplete +double-call, factionReactions load-time bug, IsDoable +cost, 3 new ticker categories, 2 pass-2 errors corrected. + +**Single most important new finding from pass 4: +`QuestieTooltips.GetTooltip` runs an O(party²) per-tooltip +cost** because `GetPartyMemberByName` (QuestiePlayer.lua:170-186) +is called per (quest × objective × player) inside the inner +loop. Worst case: 3000 WoW API calls per tooltip show. The +fix is a one-time `name→info` map build at the top of +`GetTooltip`. + +**Second most important: `IsComplete` (QuestieDB.lua:1355) +calls `QuestieDB.GetQuest(questId)` twice on the same +questId in a single expression.** Trivial fix, real cost. + +**Third most important: the `load-time` `UnitFactionGroup("player")` +at QuestieDB.lua:1903 may return nil if QuestieDB loads +before PLAYER_LOGIN, breaking the faction-A / faction-H NPC +filter silently.** Separate from the QuestieMenu.lua:114 +"Player" capital-P bug (also a faction filter bug). +**Two distinct faction filter bugs, both with the same +symptom. Needs in-game verification.** + +**Fourth most important: 47 `C_Timer.After` and 5 +`C_Timer.NewTimer` instances** (pass 4 numbers) — the +addon has a heavy delay-timer surface area that could be +consolidated. + +**Pass 2 errors corrected in pass 4:** +1. `_QuestLogUpdateQueue` flagged as latent bug in pass 2 — + confirmed correct closure pattern. +2. Hindsight memory about Lua 5.0 lacking `select` — also + wrong (already corrected in pass 3). + +## When to stop auditing + +After 4 passes: +- **Performance hot paths** are all identified +- **Memory leaks** are all identified (alreadySentBandaid, + OptionalDeps watch for `frameName` registry, HBD resize + boundary) +- **Correctness bugs** are all identified (faction filter + x2, IsComplete double-call, QuestieLib arity, IsDoableVerbose + maintenance burden, BaseOnUpdate nil assignment) +- **Selene run is complete** to 1162 warnings + 6 errors +- **Tooling gap** confirmed: `busted` and `luac` not + installed locally — cannot validate by running tests, + only by reading source + +The next audit pass would only re-find the same things +with diminishing returns. The user has the full 4-pass +report. **Recommended action: implement Tier 1 first +(deletes, dedupes, typo fixes) with the existing +`audit-restore-2026-06-03` tag as the restore point. +Measure, then implement Tier 2. Skip Tier 3 unless +performance is still a problem after Tier 2.** + +--- + +# Pass-5 Addendum — 2026-06-03 (corrections) + +After pass 4 the user pushed back: "what files did you miss +or lightly read?" and corrected me on one critical fact — +**the addon must work on WoW 1.12 (Turtle WoW), Lua 5.0**. +This pass: +1. Re-verifies every Tier-1 "dead code" claim against the + Turtle TOC at `Questie-X-Turtle.toc`. +2. Corrects pass-1 false positives that the Turtle TOC + reveals. +3. Adds the previously-missed files + (Libs/QuestieLoader, QuestieCompat, QuestieSerializer, + RamerDouglasPeucker, CombatQueue, Auto/Privates) to the + covered set. +4. Runs busted and selene to validate numerical claims. + +## Files I missed in passes 1-4 (final coverage map) + +**Files I had NOT read at all in any prior pass:** + +| File | Pass that read it | Why it matters | +|---|---|---| +| Modules/Libs/QuestieLoader.lua | 5 | All Lua 5.0/5.1/5.2 compat shims (table.getn, math.mod, string.match, string.gmatch, select) live here. They are NOT dead code on 1.12 — they're the implementation. | +| Modules/Libs/QuestieSerializer.lua | 5 | The O(n²) Serialize cost flagged in pass 1. DJB2 hash + MessagePack-style float packing. | +| Modules/Libs/QuestieCombatQueue.lua | 5 | The deferred-combat queue (10Hz drainer). Uses `tremove(_,1)` per drain. | +| Modules/Libs/RamerDouglasPeucker.lua | 5 | O(n log n) / O(n²) Hotzones smoothing. Pass 1+2 flagged; pass 5 verified. | +| Modules/Libs/QuestiePluginAPI.lua | (skipped) | Plugin injection API | +| Modules/QuestieCompat.lua | 5 | The entire 1.12 compat layer: C_Timer, xpcall, hooksecurefunc, Ambiguate, GetCurrentRegion polyfills + `CALIBRATED_MAP_GROUPS` for the Sunstrider fix. **CRITICAL** to understanding 1.12 support. | +| Modules/QuestieReputation.lua | (skipped) | Called per IsDoable. | +| Modules/QuestieProfessions.lua | (skipped) | Called per IsDoable. | +| Modules/Network/QuestieComms.lua | (skim) | The 1071-line comms module. The O(n²) Serialize calls in pass 1 live here. | +| Modules/Quest/QuestLogCache.lua | (skipped) | Quest log data layer, called from QuestEventHandler. | +| Modules/Quest/QuestieQuestPrivates.lua | (skipped) | Private quest helpers | +| Modules/Quest/DailyQuests.lua | (skipped) | Daily quest logic | +| Modules/Quest/IsleOfQuelDanas.lua | (skipped) | Phase logic | +| Modules/Quest/QuestgiverFrame.lua | (skipped) | Quest giver UI | +| Modules/QuestLinks/* (3 files) | (skipped) | Never read | +| Modules/QuestieMenu/* (4 sub-files) | (skipped) | ClassTrainers, Mailboxes, MeetingStones, ProfessionTrainers, Townsfolk | +| Modules/Tracker/QuestieRouteOptimizer.lua | (skipped) | Never read | +| Modules/Tracker/Tracker* (5 more files) | (skipped) | HeaderFrame, Menu, QuestFrame, QuestTimers, ItemButton | +| Modules/Options/* (15+ files) | (partial) | Only 3 lightly read | +| Modules/WorldMapButton/WorldMapButton.lua | (skipped) | Never read | +| Modules/Auto/* (3 files) | (skim) | Only Privates.lua briefly read | +| Modules/Tutorial/* (3 files) | (skipped) | Never read | +| Database/compiler.lua (1676 lines) | (skim) | Only first 100 lines | +| Database/Corrections/* (17 files) | (skim) | Only BOM locations checked | +| Compat/* (7 files) | (skim) | QuestTag/QuestReward/UiMapData/FactionId/Debug are pure data tables; Compat.lua has the 1.12 hooksecurefunc polyfill | +| Modules/Journey/* (12 more files) | (skim) | Only QuestieJourney.lua | + +**That's ~75 of 118 Modules files I had not read** (36% +coverage in passes 1-4, 64% missed). The biggest single +gap was **Modules/Libs/** (5 of 6 files never read) which +contains the entire compat infrastructure that pass 1-4 +erroneously dismissed as "dead code on 3.3.5a." + +## CRITICAL corrections to pass-1 through pass-4 + +### Correction 1: `Modules/TaskQueue.lua` is NOT dead code + +Pass 1 called it "18 lines, zero callers, dead code." +**WRONG.** + +- `Questie-X.toc:112` lists it +- `Questie-X-Turtle.toc:107` lists it +- `Modules/Quest/QuestieQuest.lua:28-29` imports it +- `Modules/Quest/QuestieQuest.lua:560` calls it with **6 + queued functions** per quest accept/abandon + +`TaskQueue` is the **per-frame deferred-cleanup mechanism.** +Each quest accept/abandon queues 6 cleanup operations that +fire one per frame to avoid blocking the game thread: + +```lua +TaskQueue:Queue( + function() QuestieMap:UnloadQuestFrames(questId) end, + function() QuestieTooltips:RemoveQuest(questId) end, + function() ... end, + function() QuestieQuest:PopulateQuestLogInfo(quest) end, + function() Questie:SendMessage("QC_ID_BROADCAST_QUEST_UPDATE", questId) end, + ... +) +``` + +**It has a real `OnUpdate` callback** (`taskQueueEventFrame` +at line 17) that fires once per frame. **NOT a per-tick leak** +as pass 1 implied — the frame's `OnUpdate` IS the consumer. + +**Action required:** REMOVE TaskQueue from the Tier-1 +delete list. Pass 1's "delete TaskQueue.lua" was wrong and +would have broken quest accept/abandon on all targets. + +### Correction 2: `MapExplorationUpdate` is NOT dead code + +Pass 1 said "zero callers found." **WRONG.** + +- `Modules/QuestieEventHandler.lua:70` registers + `MAP_EXPLORATION_UPDATED` → `MapExplorationUpdated` +- `Modules/QuestieEventHandler.lua:323-334` `MapExplorationUpdated` + calls `QuestieMap.utils:MapExplorationUpdate()` +- `Modules/Map/QuestieMapUtils.lua:188-199` iterates ALL + `QuestieMap.questIdFrames` with `_G[frameName]` lookups + +**Real per-event hot path I missed in passes 1-4.** For 100 +quest frames × 1 _G lookup each = 100 _G lookups per +`MAP_EXPLORATION_UPDATED` event. + +Also, `IsExplored` at QuestieMapUtils.lua:140-186 hardcodes +6 WotLK main city map IDs (1453-1458). On 1.12 / TBC this +branch never fires but the function is still called. + +### Correction 3: The Compat shims are NOT dead code on 1.12 + +I called several `QuestieCompat.lua` polyfills "dead on +3.3.5a" in pass 1+4. **The 1.12 Turtle target changes +this.** For Turtle WoW 1.12: + +- **C_Timer polyfill (line 283-336)**: 1.12 has no + `C_Timer`. The polyfill is the **only timer** on 1.12. + Critical. +- **xpcall polyfill (line 22-67)**: 1.12 xpcall drops + extra args. The 25-arg waterfall is the **only way** to + pass args to xpcall on 1.12. Critical. +- **hooksecurefunc polyfill (line 243-274)**: 1.12 has + no secure-call model. The polyfill provides raw + hook-and-call. Necessary. +- **Ambiguate polyfill (line 220-224)**: 1.12 has no + Ambiguate. Needed. +- **GetCurrentRegion polyfill (line 75-91)**: 1.12 has + no GetCurrentRegion. Needed. +- **C_Seasons polyfill (line 196-207)**: 1.12 has no + C_Seasons. Needed. +- **RegisterAddonMessagePrefix polyfill (line 227-231)**: + 1.12 has no RegisterAddonMessagePrefix. Needed. + +**All of these are active on 1.12 and must be preserved.** +Pass 1 was wrong to call them dead. + +### Correction 4: `Is335` is `true` only on 3.3.5a, not 1.12 + +`QuestieCompat.lua:96`: `QuestieCompat.Is335 = (build == 30300)`. +**On 1.12 (Turtle) build ≠ 30300, so `Is335 = false` on +1.12.** This means the 3.3.5a-specific code paths gated +on `Is335` correctly skip on 1.12. The gating is correct. + +But — **the addon's QUESTIE-X.toc has Interface: 30300.** +That means **Questie-X.toc (WotLK) only loads on 3.3.5a. +The Turtle target uses Questie-X-Turtle.toc with Interface: +11200. The two are independent builds.** I had not +internalized this when I dismissed compat shims as dead. + +### Correction 5: `string.match` shim is ACTIVE on 1.12 + +`QuestieLoader.lua:24-37`: `if not string.match then +string.match = function(str, pattern, init) ... end`. + +**On 1.12 (Lua 5.0) `string.match` is missing, so the shim +is installed and ACTIVE.** Every `string.match` call in the +codebase on 1.12 routes through this Lua function (not the +native C). For the many `string.match` calls in +AvailableQuests, QuestieSlash, Tooltip, etc., this is real +per-call cost on 1.12. + +On 3.3.5a WotLK Classic (also Lua 5.0 in some clients) +this may also be active. **The shim is not dead.** + +### Correction 6: `Modules/Arrow/QuestieArrow.lua.bak4` and `QuestieArrow_HEAD.lua` are in .gitignore + +Pass 1 listed both as Tier-1 deletes. **They are already +excluded from git via .gitignore lines:** + +``` +Modules/Arrow/QuestieArrow.lua.bak4 +Modules/Arrow/QuestieArrow_HEAD.lua +Compat/Debug.lua +Questie-X-Turtle.toc +Modules/*_spec.lua +``` + +**They do not pollute the git tree.** They may be +intentional local-dev artifacts. **Deleting them from the +filesystem is harmless but doesn't help git hygiene.** + +The 4 files in the pass 1 Tier-1 list that ARE +git-tracked and DO need cleanup: + +- `Modules/QuestieLearner_spec.lua` — git-tracked, in + Modules/ but is a busted test (not loaded by TOC). The + fix is to move it to `Tests/` or add to .gitignore + (the existing pattern `Modules/*_spec.lua` would + exclude it if the extension matches — let me check) + +`.gitignore` line: `Modules/*_spec.lua` — this **does +match `Modules/QuestieLearner_spec.lua`.** The file is +excluded. So even this one is in .gitignore. **All 4 +"dead files" are already git-excluded.** + +### Correction 7: `selene run to completion` count + +Run on full repo with `selene ... > /tmp/selene_full.out`: + +- **2 errors** (mismatched_arg_count on QuestieLib.lua:39, 56) +- **4 parse_errors** (BOM files) +- **1162 warnings** (same as pass 3+4) + +The "6 total problems" number is correct. **Pass 1 was +right that the original 58-line report's 1162 was correct.** + +The "4 errors" in pass 1 was a misread — the report said +1162 warnings, not 4 errors. **Pass 1 conflated errors +and parse_errors when reading.** + +### Correction 8: Busted test failures (FINAL VERIFIED) + +`cmd.exe /c 'busted'` runs busted 2.3.0 and produces: + +``` +●●●◼◼◼◼●●●●●●●●●● +12 successes / 4 failures / 0 errors / 0 pending : 0.039 seconds +``` + +**The 4 failures are all in `Tests/QuestieArrowAssets_spec.lua`:** + +1. @ 52: "missing style block for arcanearrow" — the + manifest at Modules/Arrow/QuestieArrowAssets.lua is + missing an `arcanearrow` style entry. The test expects + the entry; the manifest has only 7 styles (arrow1-4, + hordearrow, alliancearrow, arrowold). +2. @ 64: same — "only treats arrowold as a bundled sprite + sheet" test fails because `arcanearrow` style block + doesn't exist. +3. @ 78: "Expected to be truthy, but value was: (nil)" — + QuestieArrow.lua does NOT contain the string + `self.arrow:SetTexCoord(0, 1, 0, 1)` (line 82 of test). + The test was written expecting this string to be in + the source; the source has different texture-coord + logic. +4. @ 87: "Passed in: 18 .tga files including + 'DropTestArrow.tga'; Expected: 22 .tga files with + 'Minimal1/2/3.tga' instead." + +**The 4 failures all point to one root cause:** the test +spec was updated to expect a new arrow set +(`ArcaneArrow.tga` + `Minimal{1,2,3}.tga`) but the manifest +and source were not updated to match. **Real test +failures, real inconsistency in the codebase.** This is +NOT a busted installation problem or a stale state +problem — it's an actual test/code mismatch. + +**The 12 passing tests include:** +- Tests/QuestieArrow_spec.lua (likely 8 tests) +- Tests/QuestieDB_suppression_spec.lua (4 tests) + +## Verifications using real tools (busted + luac5.1 + selene) + +Tools found and used: +- `busted` at `C:\Users\kance\Documents\GitHub\tools\luarocks-install\systree\bin\busted.bat` (busted 2.3.0) +- `luac5.1` at `C:\Users\kance\Documents\GitHub\tools\luarocks-install\luac5.1.exe` (Lua 5.1.5) +- `selene` 0.30.0 (system PATH) + +`luac5.1 -p` on the 12 high-value files I missed: +- Modules/Libs/ThreadLib.lua ✓ +- Modules/Libs/QuestieSerializer.lua ✓ +- Modules/Libs/QuestieCombatQueue.lua ✓ +- Modules/Libs/RamerDouglasPeucker.lua ✓ +- Modules/Libs/QuestieLoader.lua ✓ +- Modules/Libs/QuestiePluginAPI.lua ✓ +- Modules/QuestieCompat.lua ✓ +- Database/QuestieDB.lua ✓ +- Modules/Quest/QuestLogCache.lua ✓ +- Modules/QuestieReputation.lua ✓ +- Modules/QuestieProfessions.lua ✓ +- Modules/Network/QuestieComms.lua ✓ + +All 12 parse OK. The selene run on these 12 shows 0 errors ++ 122 warnings (all style lints). + +## Updated Tier 1 list (after pass-5 corrections) + +The 4 file-deletions I recommended in pass 1 are ALL +already in .gitignore. They do not pollute git. Whether +to delete them from the filesystem is the user's call; +they may be intentional local-dev scratch. + +**Tier 1 (zero risk to runtime, real or no git impact):** +- **FIX ASCENSION ARITY BUG**: Add `questId` parameter to + `Ascension_IsScalingEnabled` at QuestieLib.lua:33 (the + arity mismatch is real; selene confirms it). Pass 1 + finding stands. This is a CORRECTNESS fix on Ascension, + not a perf fix. +- **REMOVE DUPLICATE TOC ENTRY**: `Questie-X.toc:182` lists + `Modules\QuestieSlash.lua` twice. Pass 1 finding stands. +- **FIX TYPOS in Options**: 3 `fadeTickerValue:Cancel()` → + `fadeTicker:Cancel()` in QuestieOptionsTracker.lua:486, + 522, 786. Pass 2 finding stands. +- **STRIP BOMs from 4 files**: 3 in Database/Corrections/ + + 1 vendored Compat/Libs/LibSharedMedia-3.0/. Pass 1 + finding stands (with the 4-file correction from pass 3). +- **FIX FramePool BaseOnUpdate wire-up OR delete dead code**: + QuestieFrame.lua:133 assigns `newFrame.BaseOnUpdate = + _Qframe.BaseOnUpdate` but `_Qframe.BaseOnUpdate` is + never defined. Either wire to `_Qframe.GlowUpdate` or + delete the dead branch at QuestieFramePool.lua:109-113. + Pass 2 finding stands. + +**Tier 1.5 (zero runtime risk):** +- 6 `tremove(_, 1)` swaps for head-index or circular buffer + (CombatQueue, Map, Comms, FramePool, etc.). Pass 1 + finding stands. + +**REMOVED from Tier 1 (was wrong):** +- ~~Delete TaskQueue.lua~~ — TaskQueue is heavily used. + Pass 1 was wrong. +- ~~Delete QuestieArrow_HEAD.lua~~ — gitignored anyway. +- ~~Delete QuestieArrow.lua.bak4~~ — gitignored anyway. +- ~~Delete Journey/tabs/Search/Search.lua~~ — never + existed in git (the file was never committed per + .gitignore pattern). +- ~~Delete Journey/tabs/Search/SearchTab.lua~~ — same. +- ~~Move QuestieLearner_spec.lua out of Modules/~~ — file + is in .gitignore (`Modules/*_spec.lua` pattern), not + git-tracked, but IS in `git ls-files` output. The fix + is to either delete the file (local cleanup) or accept + it as a dev artifact. + +## New findings in pass 5 + +### MapExplorationUpdate per-event hot path + +`Modules/Map/QuestieMapUtils.lua:188-199` — the +`MAP_EXPLORATION_UPDATED` event handler iterates ALL +`QuestieMap.questIdFrames` with `_G[frameName]` lookups. +**For 100 quest frames, 100 _G lookups per map-exploration +event.** This is a 33rd _G site I missed. Per pass 1+2+ +3+4 enumeration (32 _G sites), the corrected count is 33. + +**Tier 2 fix**: cache the frame registry as a direct- +reference table (not by name) to skip the _G lookups +entirely. + +### QuestieLib.lua arity bug is on ALL targets, not just Ascension + +`Modules/Libs/QuestieLib.lua:33` defines +`Ascension_IsScalingEnabled()` with no args. Called with +`questId` at lines 39 and 56. The arity mismatch is at +the Lua call level, so **every target** that loads this +file has the bug. The function returns +`Questie.db.profile.enableAscensionScaling` regardless +of questId. On 1.12 / 3.3.5a this is harmless because +`Questie.IsAscension` is false. On Ascension it's a +**per-character (not per-quest) setting bug.** Selene +flags the mismatched_arg_count at both sites. + +**Fix**: `local function Ascension_IsScalingEnabled(questId)`. +The body doesn't use questId but the function should accept +it for the future. Or: use `...` to discard. + +### RamerDouglasPeucker is O(n log n) average, O(n²) worst + +`Modules/Libs/RamerDouglasPeucker.lua:85-109` `simplifyDPStep` +is the classic Ramer-Douglas-Peucker recursive algorithm. +Worst-case O(n²) on a collinear input. For 1000-point +zone geometries, this is 1M ops. Per pass 1, called from +`CalcHotzones` per quest accept. + +**Bounded, not a hot path. Pass.** + +### QuestieCompat.lua:283-308 C_Timer polyfill is the ONLY timer on 1.12 + +The polyfill at line 283 uses a `TickerFrame` with an +`OnUpdate` script that drives a hand-rolled `tickers` table. +The `NewTicker` function returns a table with `:Cancel()` +method. **The 33 `C_Timer.NewTicker` count from my grep +includes only the native calls; the polyfill adds 1 more +synthetic ticker source on 1.12.** + +### QuestieAnnounce.lua acknowledged TODO + +`Modules/QuestieAnnounce.lua:19` has the comment +`-- TODO: rewrite the entire thing its a lost cause` from +the original developer. The `alreadySentBandaid` table is +unbounded. Per pass 4, easy fix: periodic `wipe()`. + +### The `Compat/Compat.lua` 1.12 hooksecurefunc polyfill + +`Compat/Compat.lua:1-50` is the 1.12 compat shim file. It +provides the global `hooksecurefunc` for 1.12. **On 1.12 +this is the implementation, not a shim.** Per pass 1+4, +the global namespace pollution from this is documented as +intentional. + +## Pass-5 verdict + +Pass 1-4 had **two real Tier-1 errors** (TaskQueue is not +dead; MapExplorationUpdate is not dead) and **three Tier-1 +findings that are now corrected** (the 4 "dead file" +deletions are all already .gitignored). + +The substantive performance findings from passes 1-4 still +stand: +- 33 static NewTicker + dynamic per-quest NewThread at + AvailableQuests.lua:283 (10K per Ascension login scan) +- l10n allocates per call across 976 sites +- AvailableQuests.CalculateAndDrawAll called from 20+ sites +- IsDoable does 12 QueryQuestSingle per call +- Tooltip per-party O(N²) inner loop +- 47 C_Timer.After + 5 C_Timer.NewTimer +- IsComplete double-call at QuestieDB.lua:1355 +- UnitFactionGroup("player") load-time bug at QuestieDB.lua:1903 +- alreadySentBandaid unbounded leak (acknowledged) +- 33 _G[name] sites including the new MapExplorationUpdate one + +**My pass-1-4 Tier-1 "delete" recommendations were +fundamentally wrong** because I had not read the Turtle +TOC. The user pushing back was correct. + +**The actual Tier-1 fixes should be** (in priority order): +1. Fix Ascension_IsScalingEnabled arity bug (correctness + on Ascension) +2. Remove duplicate TOC entry +3. Strip BOMs from 4 files +4. Fix fadeTickerValue:Cancel() typos +5. Fix FramePool BaseOnUpdate wire-up (or delete dead + branch) +6. Investigate `frameName` registry's 5 hot _G sites + in QuestieQuest.lua (per pass 2) + +**Skip**: any file deletion. The 4 files I called dead +in pass 1 are all .gitignored. + +## When to actually stop auditing + +After 5 passes the audit IS approaching completeness on +runtime hot paths. **However**, I have NOT yet read: +- ~50% of Modules (Options, Journey, Tracker sub-files, + WorldMapButton, Auto, Tutorial, QuestMenu sub-files, + QuestLinks, Network/QuestieComms, QuestLogCache, + QuestieReputation, QuestieProfessions, QuestgiverFrame, + DailyQuests, IsleOfQuelDanas, QuestieQuestPrivates, + QuestieRouteOptimizer) +- ~95% of Database/Corrections/* (17 files, all + corrections-only data) +- ~95% of Compat/* (mostly data tables + 1 polyfill file + for 1.12) + +**The unread Options files are not hot paths** (user- +triggered settings UI). **The unread Journey files are +not hot paths** (user-triggered window). **The unread +Database/Corrections files are data** (loaded at startup, +no per-frame impact). **The unread Database/compiler.lua +runs once at init** (not per-frame). + +**The unread runtime files that COULD have hot paths:** +- Network/QuestieComms (skimmed, not fully read) +- Quest/QuestLogCache (data layer for quest log scan) +- QuestieReputation, QuestieProfessions (called per + IsDoable — verified API surface, not internals) +- WorldMapButton/WorldMapButton.lua (world map button + UI, not a hot path) +- Quest/QuestgiverFrame.lua (quest giver UI) +- Auto/QuestieAuto.lua, Auto/Privates.lua (gossip + auto-accept, not per-frame) +- Tracker/TrackerQuestFrame.lua, TrackerMenu.lua, + TrackerQuestTimers.lua, TrackerHeaderFrame.lua + (tracker UI, not per-frame) + +**The substantive hot-path coverage is now adequate.** +The remaining unread files are mostly UI and data. + +**Recommendation: don't stop yet — pass 6 found Lua 5.0 +blockers.** See the pass-6 addendum below. + +--- + +# Pass-6 Addendum — 2026-06-03 (Lua 5.0 / 1.12 vararg audit) + +The user pushed back on pass 5's "stop here" +recommendation, and their pushback was correct. +Pass 6 does the **vararg audit** the Hindsight +memory has been flagging — every `function(...)` and +`...` usage that **fails to parse on Lua 5.0**. + +## Tools verified on this host + +- `lua5.1.exe` at `C:\Users\kance\Documents\GitHub\tools\luarocks-install\lua5.1.exe` (5.1.5) +- `luac5.1.exe` at `C:\Users\kance\Documents\GitHub\tools\luarocks-install\luac5.1.exe` +- `busted.bat` at `C:\Users\kance\Documents\GitHub\tools\luarocks-install\systree\bin\busted.bat` (2.3.0) +- `selene` 0.30.0 (system PATH) + +**No Lua 5.0 binary is available on this host.** +All parse checks below use `luac5.1`. The 1.12 +parse failures are identified by source reading +and applying the rule that `function(...)` and +`...` in function bodies are not valid Lua 5.0 +syntax (introduced in Lua 5.1). + +## The 18 `function(...)` sites in pure runtime code + +Excluding vendored libs (AceAddon, HereBeDragons, +CallbackHandler, ChatThrottleLib, AceGUI, +LibStub, AceComm, LibSharedMedia, XXH_Lua) and +excluding the compat shims themselves, the +addon has **18 sites** that use `...` in a +function signature or body. All 18 are in the +Turtle TOC load chain. + +| File | Line | Pattern | +|---|---|---| +| `Modules/QuestieEventHandler.lua` | 71 | `RegisterEvent("MODIFIER_STATE_CHANGED", function(...)` | +| `Modules/QuestieEventHandler.lua` | 74 | `RegisterEvent("PLAYER_ALIVE", function(...)` | +| `Modules/QuestieEventHandler.lua` | 94 | `RegisterEvent("QUEST_DETAIL", function(...)` | +| `Modules/QuestieEventHandler.lua` | 99 | `RegisterEvent("GOSSIP_SHOW", function(...)` | +| `Modules/QuestieEventHandler.lua` | 103 | `RegisterEvent("QUEST_GREETING", function(...)` | +| `Modules/QuestieEventHandler.lua` | 109 | `RegisterEvent("QUEST_COMPLETE", function(...)` | +| `Modules/QuestieEventHandler.lua` | 199 | `RegisterEvent("PLAYER_TARGET_CHANGED", function(...)` | +| `Modules/Quest/QuestEventHandler.lua` | 101 | `hooksecurefunc("StaticPopup_Show", function(...)` | +| `Modules/Libs/QuestieLib.lua` | 647 | `function QuestieLib.tpack(...)` | +| `Modules/Libs/QuestieLib.lua` | 648 | `return { n = select("#", ...), ... }` | +| `Modules/Libs/RamerDouglasPeucker.lua` | 134 | `__call = function(_, ...) return _RamerDouglasPeucker(...) end` | +| `Modules/TaskQueue.lua` | 11 | `function TaskQueue:Queue(...)` | +| `Modules/Quest/QuestgiverFrame.lua` | 153 | `function GossipAvailableQuestButtonMixin:Setup(...)` | +| `Modules/Quest/QuestgiverFrame.lua` | 173 | `function GossipActiveQuestButtonMixin:Setup(...)` | +| `Modules/QuestieProfiler.lua` | 43 | `hook.override = function(...)` | +| `Modules/QuestieProfiler.lua` | 443 | `button:SetScript("OnClick", function(...)` | +| `Modules/QuestLinks/Link.lua` | 368 | `hooksecurefunc("ChatFrame_OnHyperlinkShow", function(...)` | +| `Modules/Libs/QuestieLoader.lua` | 49 | `select = function(index, ...)` (the shim itself) | + +**On Lua 5.0, all 18 sites fail to parse with +`unexpected symbol near '...'`.** The addon will +not load on Turtle WoW 1.12. + +## The select shim is the actual root cause + +`Modules/Libs/QuestieLoader.lua:48-58`: + +```lua +if not select then + select = function(index, ...) + if arg then + if index == "#" then return arg.n end + index = tonumber(index) or 1 + return unpack(arg, index, arg.n) + end + end +end +``` + +**The `function(index, ...)` signature uses `...` +which is a parse error on Lua 5.0.** This file +(`QuestieLoader.lua`) is the **first file loaded +by every TOC** (WotLK: TOC line 2, Turtle: TOC +line 16). **If this file fails to parse, nothing +else loads.** + +The codebase already has the correct fix pattern +for the other shims at lines 5-19 — use +`loadstring` to inject the function body at +runtime so the file's static parse on Lua 5.0 +doesn't see any `...` or `#` operators: + +```lua +if not table.getn then + local loadFunc = loadstring or load + if loadFunc then + table.getn = loadFunc("return function(t) return #t end")() + end +end + +if not math.mod then + local loadFunc = loadstring or load + if loadFunc then + math.mod = loadFunc("return function(a, b) return a % b end")() + end +end +``` + +**The same pattern should be applied to the select +shim.** This is a Tier 0 fix (1-blocker) — the +user has been fighting this exact error and the +fix is already used in the same file. + +## The QuestieLearner OnEvent arg1..arg10 bug + +`Modules/QuestieLearner.lua:3392-3421`: + +```lua +frame:SetScript("OnEvent", function(_, event) + if event == "QUEST_TURNED_IN" then + self:OnQuestTurnedIn(arg1, arg2, arg3) + elseif event == "QUEST_ACCEPTED" then + self:OnQuestAccepted(arg1, arg2) + elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then + self:OnCombatLogEvent(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) + elseif event == "GET_ITEM_INFO_RECEIVED" then + self:OnGetItemInfoReceived(arg1) + elseif event == "QUEST_REMOVED" or event == "QUEST_TURNED_IN" then + self:ClearQuestObjectiveTracking(arg1) + end +end) +``` + +**The closure signature is `function(_, event)` — +no `...` in scope.** The `arg1, arg2, ..., arg10` +references at lines 3402, 3404, 3413, 3415, 3419 +are **global nil references**. On Lua 5.0, the +implicit `arg` table holds the varargs but +`arg1` (single identifier) is unrelated. On +Lua 5.1+, the engine silently passes nils. + +**This means the QuestieLearner combat log +learning and quest accept/turn-in handling is +silently broken on all targets.** The Hindsight +memory's "OnCombatLogEvent" entry corroborates +this. The called functions receive nil for each +`argN` and likely bail. + +**CORRECTNESS BUG, not a perf issue.** + +The fix: change the signature to +`function(_, event, ...)` and capture the args +via `local arg1, arg2, ... = ...` (Lua 5.1+) or +via the `arg` table (Lua 5.0). Or use `select(1, +...), select(2, ...), ...` consistently. + +## QuestieLib.tunpack recursive slowness (NEW in pass 6) + +`Modules/Libs/QuestieLib.lua:655-668`: + +```lua +function QuestieLib.tunpack(tbl) + if tbl.n == 0 then return nil end + local function recursion(i) + if i == tbl.n then return tbl[i] end + return tbl[i], recursion(i + 1) + end + return recursion(1) +end +``` + +**Recursive unpack.** For 50-arg packet = 50 +function calls + 50 return-unwinds. Stack frame +per arg. **Slowest possible implementation.** + +The fix is a single line: + +```lua +function QuestieLib.tunpack(tbl) + if tbl.n == 0 then return nil end + return unpack(tbl, 1, tbl.n) +end +``` + +`unpack` is native C in WoW's Lua. For 50-arg +packet, 1 call instead of 50. The QuestieSerializer +and QuestieComms call this on every broadcast; +for 30 quests in a block, 30× speedup on the +unpack step alone. The fix is also Lua 5.0 +compatible (no varargs in function body). + +**Tier 2 perf fix, easy win.** + +## Verifications run on this host + +### luac5.1 parse check on 195 non-vendored files + +Only 3 files fail to parse on lua5.1 (BOMs): + +``` +$ luac5.1 -p Database/Corrections/tbcQuestFixes.lua +Database/Corrections/tbcQuestFixes.lua:1: unexpected symbol near '\uFEFF' +$ luac5.1 -p Database/Corrections/wotlkItemFixes.lua +Database/Corrections/wotlkItemFixes.lua:1: unexpected symbol near '\uFEFF' +$ luac5.1 -p Database/Corrections/wotlkQuestFixes.lua +Database/Corrections/wotlkQuestFixes.lua:1: unexpected symbol near '\uFEFF' +``` + +(The 4th BOM is in vendored `LibSharedMedia-3.0`, +excluded from the scan.) + +All other 192 files parse OK on lua5.1. The +`function(...)` and `...` constructs are valid +lua5.1 syntax (parses fine, executes fine). + +### Selene full repo run + +``` +$ selene --config selene.toml Questie.lua Modules \ + Compat Database Localization > /tmp/selene_full.out + +Results: +2 errors +1162 warnings +4 parse errors +``` + +Same numbers as pass 3-5. Pass 1's "6 errors" was +a misread (2 errors + 4 parse_errors = 6 +problems, but they're different categories). + +### Busted test run + +``` +$ cmd.exe /c 'busted' + +●●●◼◼◼◼●●●●●●●●●● +12 successes / 4 failures / 0 errors / 0 pending : 0.039 seconds +``` + +Same as pass 5. The 4 failures are all in +`Tests/QuestieArrowAssets_spec.lua` and all point +to the test/code mismatch (test expects +`arcanearrow` + `Minimal{1,2,3}.tga` style blocks +that don't exist in +`Modules/Arrow/QuestieArrowAssets.lua`). + +## Tier-1 priority list (revised after pass 6) + +1. **FIX select shim** at QuestieLoader.lua:48-58. + Use the `loadstring` injection pattern that's + already used for `table.getn` and `math.mod` + shims in the same file. **This is the root + cause of the "addon fails to load on Turtle + WoW 1.12" the user has been fighting.** + +2. **FIX the 18 function(...) sites**. Each one + needs to be rewritten using either the + `loadstring` injection pattern, OR the + `function(arg)` Lua 5.0 idiom. The 7 + `RegisterEvent` calls in QuestieEventHandler.lua + are the most invasive because they pass args + to internal methods. + +3. **FIX the arg1..arg10 closure bug** in + QuestieLearner.lua:3392. Change signature to + `function(_, event, ...)` and use + `local arg1, arg2, ... = ...` to capture, or + use `select(1, ...)`. This is a **CORRECTNESS + bug** that breaks QuestieLearner functionality + on all targets. + +4. **FIX recursive tunpack** at QuestieLib.lua:655. + Replace with `return unpack(tbl, 1, tbl.n)`. + Big perf win on QuestieComms broadcasts. + Lua 5.0 compatible. + +5. **Strip 3 BOMs** from + Database/Corrections/*.lua. Cosmetic. + +6. **FIX the 4 busted test failures** in + Tests/QuestieArrowAssets_spec.lua. Either add + the missing style blocks to + QuestieArrowAssets.lua, or update the test + expectations to match the current state. + +7. **The QuestieLib.lua arity bug** at line 33/39/56 + is real but harmless on non-Ascension targets. + Fix when convenient. + +8. **SKIP file deletions.** All 4 "dead file" + candidates from pass 1 are in .gitignore and + don't pollute git. + +## The real open question + +After 6 passes, **the biggest open question is +whether the addon actually works on Turtle WoW +1.12**. The Hindsight memory documents that the +user has been fighting Lua 5.0 syntax errors. +The 18 `function(...)` sites + the select shim +are the most likely cause. **The user needs to +decide**: + +(a) **Drop 1.12 support** (simplest). Move + `Questie-X-Turtle.toc` to a separate branch + or mark it as unsupported. + +(b) **Apply the loadstring-injection pattern to + every function(...) site** (invasive but + mechanical, 18 file edits). This is the + approach the user has been incrementally + adopting (the shims in QuestieLoader.lua + already use it). + +(c) **Write a `loadstring`-based global + shim/translator that rewrites function + signatures at load time** (a hack, hard + to maintain). + +**Recommendation: option (b).** Each +`function(...)` becomes a `loadstring-injected +function(arg)` and the body uses `arg[1], arg[2]`. +The `arg1..arg10` in QuestieLearner.lua:3392-3421 +is fixable by adding `...` to the signature and +reading `select(1, ...)`, `select(2, ...)`, etc. — +which is itself a shim that works on both 5.0 and +5.1+. + +This is invasive (touches 18 files) but +mechanical. A consistent pattern can be applied +across all sites in a single sitting. + +## The lua 5.0 audit is the missing pass + +Passes 1-5 were all about runtime hot paths and +codebase structure. **Pass 6 is the missing +audit: the 1.12 / Lua 5.0 compat layer.** This +is what the user has been fighting with the +entire time. **The 18 function(...) sites are +the answer to "what did you miss." + +The user is correct that there are still +relevant findings. The Lua 5.0 audit is one of +them. + +--- + +# Pass-7 Addendum — Full Lua Compatibility Audit (2026-06-03) + +## Scope + +Mechanical scan of ALL 363 .lua files (1.5M lines) in the Questie-X codebase +for Lua 5.0 / 1.12 incompatibility patterns. This is the definitive compatibility +audit that pass-6 started but didn't complete. + +## Method + +1. Grepped for 15 distinct incompatibility patterns across all files +2. Categorized hits as CRITICAL (parse error), WARNING (shimmed), or INFO +3. Cross-referenced against the Questie-X-Turtle.toc load chain +4. Separated DATA files (quest names with "..." in strings) from CODE + +## Results Summary + +| Category | Pattern | Hits | In Turtle TOC | +|---|---|---|---| +| CRITICAL | `function(...)` signature | 24 in 15 files | 23 files | +| CRITICAL | `...` in function body | 231 in 27 files | 23 files | +| WARNING | `string.gmatch` | 5 | 5 (shimmed) | +| WARNING | `string.match` | 27 | 27 (shimmed) | +| WARNING | `select()` | 55 | 55 (shimmed) | +| INFO | `math.mod` | 175 | 175 (shimmed) | +| INFO | `table.getn` | 127 | 127 (shimmed) | +| INFO | `loadstring` | 13 | 13 (exists on 5.0) | +| CLEAN | `bit32` | 0 | N/A | +| CLEAN | `goto`/`::label::` | 0 | N/A | +| CLEAN | `string.pack`/`string.unpack` | 0 | N/A | +| CLEAN | `table.unpack` | 0 | N/A | +| CLEAN | `utf8` library | 0 | N/A | +| CLEAN | `//` operator | 0 | N/A (4 false positives in URL strings) | +| CLEAN | `~` bnot | 0 | N/A | + +## The 23 Files That Will Fail on Lua 5.0 (Turtle WoW 1.12) + +These files contain `function(...)` or `...` in function bodies AND are in the +Turtle TOC load chain. They will cause parse errors on Lua 5.0. + +### Tier 1: Loaded at startup (immediate failure) + +| File | Sites | Issue | +|---|---|---| +| `Modules/Libs/QuestieLoader.lua` | 2 | select shim + `addonName = ...` | +| `Modules/TaskQueue.lua` | 1 | `Queue(...)` | +| `Modules/VersionCheck.lua` | 1 | `local addonName, _ = ...` | +| `Questie.lua` | 4 | `Print(...)`, `Error(...)`, `Debug(...)` | + +### Tier 2: Loaded on demand (failure when feature used) + +| File | Sites | Issue | +|---|---|---| +| `Modules/QuestieEventHandler.lua` | 7 | All event handlers | +| `Modules/Quest/QuestEventHandler.lua` | 1 | hooksecurefunc callback | +| `Modules/Quest/QuestgiverFrame.lua` | 2 | Setup overrides | +| `Modules/QuestLinks/ChatFilter.lua` | 1 | Filter function | +| `Modules/QuestLinks/Link.lua` | 1 | hooksecurefunc callback | +| `Modules/QuestieProfiler.lua` | 3 | hook.override + 2 OnClick | +| `Modules/QuestieShutUp.lua` | 1 | FilterFunc | +| `Modules/Auto/QuestieAuto.lua` | 2 | GOSSIP_SHOW, QUEST_PROGRESS | +| `Modules/Tracker/TrackerLinePool.lua` | 1 | OnEvent handler | +| `Modules/Map/QuestieMap.lua` | 1 | QueueDraw | +| `Modules/Libs/QuestieCombatQueue.lua` | 1 | Queue | +| `Modules/Libs/QuestieLib.lua` | 1 | tpack | +| `Modules/Libs/RamerDouglasPeucker.lua` | 1 | __call metamethod | +| `Modules/QuestieValidateGameCache.lua` | 1 | RegisterCallback | +| `Modules/Arrow/QuestieArrow.lua` | 1 | Refresh | +| `Modules/Journey/tabs/QuestsByZone/QuestsByZone.lua` | 1 | OnClick callback | +| `Localization/l10n.lua` | 1 | __call metamethod | +| `Database/QuestieDB.lua` | 1 | GetQuest | +| `Database/Zones/zoneDB.lua` | 1 | Initialize | + +### Tier 3: NOT in Turtle TOC (won't fail on 1.12, but would on 5.0) + +| File | Sites | Issue | +|---|---|---| +| `Modules/Arrow/QuestieArrow_HEAD.lua` | 1 | Refresh | +| `Modules/QuestieDBMIntegration.lua` | 1 | OnEvent handler | +| `Modules/Libs/MessageHandler.lua` | 4 | fire + callbacks | +| `Compat/HBD.lua` | 1 | OnEventHandler | + +## Root Cause Analysis + +The `function(...)` syntax and `...` vararg usage are **Lua 5.1+ features**. +Lua 5.0 (used by WoW 1.12 / Turtle WoW) does not support them. The parser +rejects them with "unexpected symbol near '...'". + +The addon's shim layer in `Modules/Libs/QuestieLoader.lua` shims `select`, +`string.match`, `string.gmath`, `table.getn`, and `math.mod` — but these shims +use `function(index, ...)` and `loadstring` patterns that are themselves +Lua 5.1+ syntax. **The shim file cannot parse on Lua 5.0.** + +## The Fix Pattern + +The codebase already has the correct pattern for Lua 5.0 compatibility: +`loadstring`-injected functions. Used for `table.getn` and `math.mod` shims. + +Example (from QuestieLoader.lua lines 4-10): +```lua +if not table.getn then + local loadFunc = loadstring or load + if loadFunc then + table.getn = loadFunc("return function(t) return #t end")() + end +end +``` + +This pattern must be applied to ALL 23 sites. The `loadstring` call creates a +function from a string, which is parsed at runtime — bypassing the Lua 5.0 +parser's rejection of `...` syntax. + +### Recommended approach: centralized vararg shim + +Instead of modifying all 23 files, create a single `Compat/Varargs.lua` that +provides a `varargs(fn)` wrapper: + +```lua +-- Compat/Varargs.lua +-- Wraps a function so it can use ... syntax on Lua 5.0 +-- On Lua 5.1+, this is a no-op passthrough +-- On Lua 5.0, uses the implicit arg table + +if select("#", 1) == 1 then + -- Lua 5.1+: varargs work natively + QuestieCompat.varargs = function(fn) return fn end +else + -- Lua 5.0: wrap function to use arg table + QuestieCompat.varargs = function(fn) + return function(...) + return fn(...) + end + end +end +``` + +**This doesn't work on Lua 5.0** because the `...` in the wrapper signature is +still a parse error. The only real fix is `loadstring` injection at every site. + +### Alternative: drop 1.12 support + +Move `Questie-X-Turtle.toc` to a separate branch. The 23 incompatibilities are +mechanical but pervasive. Each site needs either: +1. `loadstring` injection (ugly, but works) +2. Rewrite to use explicit args instead of `...` (cleaner, but changes API) + +## Performance Findings + +### `QuestieLib.tpack` / `tunpack` (QuestieLib.lua:647-668) + +`tpack` uses `{ n = select("#", ...), ... }` — the `...` in table constructor +is Lua 5.1+ syntax. On 5.0, this is a parse error. + +`tunpack` uses recursive unpacking — O(n) stack depth for n args. For a 50-arg +packet (QuestieComms broadcast), this is 50 function calls + 50 return unwinds. + +**Fix**: Replace with iterative version: +```lua +function QuestieLib.tunpack(tbl) + if not tbl or not tbl.n or tbl.n == 0 then return nil end + return unpack(tbl, 1, tbl.n) +end +``` + +### `QuestieLearner.lua:3392-3421` — OnEvent closure + +The OnEvent handler at line 3392 has signature `function(_, event)` but the +body references `arg1, arg2, ..., arg10`. On ALL Lua versions, these are global +nil references because the signature doesn't capture them. The called functions +receive nils and silently bail. + +**This is a CORRECTNESS bug, not a perf bug.** The learner's event handler is +silently broken on all targets. + +### `QuestieInit.lua:532` — loadstring DB loading + +On 3.3.5a (tocversion 30300), the `isModernClient` check returns false (30300 +is not in any modern range). The legacy single-file DB is loadstring'd, which +taints the data. This is a known issue per the comment at line 506-510. + +**Fix**: Add 30300 to the modern client ranges, or use `load` instead of +`loadstring` on 3.3.5a. + +## selene lua50_wow Standard + +Created `lua50_wow.yml` — a selene standard library for Lua 5.0 + WoW API: + +- Based on lua51.yml with 5.1+-only APIs removed +- Adds WoW API globals from wow_classic.yml +- Makes shim targets (`table.getn`, `math.mod`, etc.) `full-write` +- Adds `string.gfind` and `bit` (5.0-specific) +- Removes duplicate Lua stdlib entries from WoW globals to preserve function signatures + +Files created: +- `lua50.yml` — Lua 5.0 stdlib only (extends lua51) +- `lua50_wow.yml` — Lua 5.0 + WoW API (standalone, no base dependency) + +Usage: Add `std = "lua50_wow"` to selene.toml (keep existing lint config). + +**Limitation**: selene cannot flag `function(...)` syntax — that's a parse-level +feature, not a semantic one. The mechanical grep audit above is the only way +to find these. + +## Recommendations + +1. **Drop 1.12 support** OR apply `loadstring` injection to all 23 sites +2. Fix `QuestieLib.tunpack` recursion (perf, all targets) +3. Fix `QuestieLearner.lua` OnEvent closure (correctness, all targets) +4. Fix `QuestieInit.lua` loadstring taint on 3.3.5a (correctness) +5. Use `lua50_wow.yml` selene std for ongoing compat linting +6. The `string.gmatch`/`string.match`/`select` shims work correctly — no changes needed + +--- + +# Pass-8 Addendum — Final Consolidated & Corrected Audit (2026-06-04) + +**This pass supersedes the conclusions of passes 1–7 where they conflict.** Passes +1–7 are kept above as the working record, but they contradict each other on +several headline items and are *wrong* on the single biggest Lua 5.0 question. +Pass 8 was re-verified mechanically against the current tree (HEAD `581634d`) +across **all runtime code** (`Modules/`, `Database/`, `Localization/`, `Compat/`, +`Questie.lua` — excluding only vendored `Libs/`, `.history/`, `workflow/`, and +pure data tables), and cross-checked against the repo's **own authoritative shim +comments** in `Modules/Libs/QuestieLoader.lua`, which are ground truth for what +parses on Lua 5.0. + +Counts below replace the earlier per-pass guesses: + +| Metric | Earlier passes | Pass 8 (verified) | +|---|---|---| +| `C_Timer.NewTicker` (non-vendored) | "33" | **31** | +| `C_Timer.After` (non-vendored) | "45–47" | **48** | +| `SetScript("OnUpdate")` (non-vendored) | "36" | **12** (the "36" counted vendored AceAddon/LibDBIcon) | +| `_G[...]` runtime lookups | "32–33" | **43** | +| files using raw `#` operator | *never scanned* | **50 files / 255 sites** | +| raw `%` modulo operator | *never scanned* | **0** (codebase uses `math.mod`) | + +## 8.1 — The biggest miss: raw `#` (and the real Lua 5.0 surface) + +The "definitive" pass-6/7 compatibility audit scanned for `function(...)`, `...`, +`bit32`, `goto`, `string.pack`, `table.unpack`, `utf8`, `//`, `~` — **but never +scanned for the two operators that are the actual reason the shim layer exists.** +The repo says so itself (`QuestieLoader.lua:1–14`): + +> *"We cannot use the '#' length operator directly because Lua 5.0 (Turtle WoW) +> will trigger a compile-time syntax error parsing the file."* +> *"We cannot use the '%' modulo operator directly because Lua 5.0 will trigger a +> compile-time syntax error."* + +Reality across runtime code: + +- **`#` length operator: 255 sites across 50 TOC-loaded files** — every one a + hard Lua 5.0 parse error. Includes hot files: `QuestieMap.lua:359` + (`math.max(#mapDrawQueue, #minimapDrawQueue)`), `QuestEventHandler.lua:154,583,587`, + `QuestieFrame.lua:174`, `QuestieAuto.lua:50,55,70,78`, `QuestieArrow.lua:1410+`, + `QuestieOptions.lua:100,105`, **`l10n.lua:200`**, and all of `Journey/`. +- **`%` modulo: cleanly avoided** — the developer consistently uses `math.mod` + (4 sites). Not an issue. Pass 7's worry about `%` was unfounded; the worry it + *should* have had was `#`. +- **`goto`/labels, `bit32`, `string.pack/unpack`, `table.unpack`, `utf8`, `//`: + genuinely clean** (0 real sites; the one `//` hit is inside a comment string). + +### Correcting the `function(...)` / `...` diagnosis + +Pass 7's framing — *"`function(...)` signatures cause parse errors in 23 files"* — +is **wrong about the cause**. `function(...)` (a vararg **declaration**) is valid +Lua 5.0; the extra args arrive in the implicit `arg` table. Proof from the repo +itself: `QuestieLoader.lua:48` writes the `select` shim as a plain +`select = function(index, ...)` **without** the `loadstring` wrapper it uses for +`#`/`%` — because the developer knows the *signature* parses on 5.0 and the body +then reads `arg`, not `...`. + +What actually breaks on 5.0 is `...` used **as an expression**. The real, +non-comment, non-data sites are narrow: + +| File:line | Construct | +|---|---| +| `Modules/QuestieEventHandler.lua:72,95,96,100,101` (+ more handlers) | `Method(...)` forwarding | +| `Modules/Auto/QuestieAuto.lua:36,91,127,137,177,191,227,246` | `Debug(..., ...)` (debug-only) | +| `Modules/Quest/QuestEventHandler.lua:103,734–744,784` | `local a,b = ...`, `Method(...)`, `select(1, ...)` | +| `Modules/Quest/QuestgiverFrame.lua:154,174` | `oldSetup(self, ...)` | +| `Modules/Arrow/QuestieArrow.lua:1531` | `_OriginalRefresh(self, ...)` | +| `Modules/Map/QuestieMap.lua:327,346,348` | `{ ... }` / `tinsert(q, { ... })` | +| `Modules/Libs/QuestieLib.lua:648` | `{ n = select("#", ...), ... }` (tpack) | +| `Localization/l10n.lua:172` | `local args = {...}` | +| several `Modules/Options/*Tab.lua` | `tabs.x = { ... }` (verify: file-scope vararg capture) | + +**Net correction:** the dominant 5.0 blocker is `#` (50 files), not +`function(...)` (which is fine). The `...`-expression issue is real but ~15 code +sites, not "23 files fail to parse." Pass 7 conflated declaration with expression +and missed the operator entirely. + +### The decisive open question (resolve BEFORE any 5.0 work) + +The codebase contradicts itself: bootstrap files (`QuestieLoader`, +`QuestieCompat`) are meticulously 5.0-safe (loadstring around `#`/`%`), but 50 +other TOC files use raw `#`. Either: + +- **(a) Turtle's client is not strict Lua 5.0** (patched/backported `#`) → the + entire 5.0 panic in passes 6–8 is moot, no work needed; or +- **(b) the target really is 5.0** → Turtle support is broadly broken across 50 + files and "fix the 18 `function(...)` sites" would not make it load. + +**One in-game check settles it:** `/run print(#({1,2,3}))`. If it prints `3`, +`#` is supported and you can stop worrying about all of 8.1. If it errors, +scope is ~50 files (use `table.getn`, which is already shimmed), not 23. *Do not +start remediation until this is answered* — it changes scope by ~2× and may zero +it out. + +## 8.2 — Confirmed FALSE POSITIVES (do NOT action these) + +1. **`Ascension_IsScalingEnabled` "arity bug" is not a bug.** Pass 1 made it the + audit's only urgent blocker (*"scaling always disabled on Ascension"*). **Lua + silently discards extra arguments**; the function returns + `Questie.db.profile.enableAscensionScaling` regardless of `questId`, exactly + as intended. Pass 5 quietly contradicted pass 1 here. It is a `selene` lint + (`mismatched_arg_count`), zero runtime effect. Adding the unused param is fine + to silence the lint — not a fix. +2. **`UnitFactionGroup("Player")` (capital P) is not a bug.** WoW unit tokens are + case-insensitive, *proven by the codebase itself*: capital `"Player"` is used + in **10+ Corrections files** (`classicItemFixes.lua:1368`, + `wotlkNPCFixes.lua:3834`, etc.) as `== "Horde"` to gate database entries. If + capital-P returned nil, the entire faction-conditional DB would silently break. + It works. `QuestieMenu.lua:114` is fine. +3. **Pass 3's "Lua 5.0 has `select`" is wrong** and contradicts the repo's own + shim (`QuestieLoader.lua:46`: *"shim for Lua 5.0 where select() was not yet + implemented"*). `select` is 5.1+. Acting on pass 3 would delete a load-bearing + shim. (Pass 3's `unpack`/`goto`/`_ENV` version claims are also muddled — + `unpack` is a 5.0 global; `goto`/`_ENV` are 5.2.) +4. **`QuestieLearner.lua:3392` `arg1..arg10` is intentional, not "broken on all + targets"** (pass 6). It's a *raw* `frame:SetScript("OnEvent", ...)`, and line + 3411 carries the dev's tested note: *"arg1..arg10 must be captured HERE before + any secondary call wipes them (3.3.5 behavior)."* On 3.3.5a/Vanilla a raw + OnEvent frame exposes the global event args. This is a different dispatch path + than the Ace `RegisterEvent` handlers in 8.1. Worth one in-game confirm, but + not the bug pass 6 claimed. +5. **`TaskQueue.lua` and `MapExplorationUpdate` are NOT dead code** (pass 1) — + already retracted in pass 5. TaskQueue is in both TOCs and called per quest + accept/abandon; MapExplorationUpdate is wired via `MAP_EXPLORATION_UPDATED`. +6. **The 4 "delete-me" files are already `.gitignore`d** (pass 5) — no git impact. +7. **`_QuestLogUpdateQueue` is a valid closure** (pass 4), not a latent crash + (pass 2) — methods defined at `QuestEventHandler.lua:683–693`. + +## 8.3 — Confirmed BUGS (real, verified, with better alternatives) + +| # | Location | Bug | Better alternative | +|---|---|---|---| +| B1 | `QuestieDB.lua:1355` | `IsComplete` calls `GetQuest(questId)` **twice** in one expression | `local q = GetQuest(questId); ... q and q.ObjectiveData` — single call. In a per-quest path, halves the cost. | +| B2 | `QuestieOptionsTracker.lua:486, 522, 797` | `fadeTickerValue:Cancel()` calls `:Cancel()` on a **number** (the ticker object is `fadeTicker`, used correctly at 479/515/786) | `fadeTicker:Cancel()`. Latent error in those branches. | +| B3 | `QuestieAnnounce.lua:19,123` | `alreadySentBandaid` table never pruned (dev TODO: *"rewrite the entire thing its a lost cause"*) — unbounded growth over a session | `wipe(alreadySentBandaid)` on `PLAYER_LOGIN`, or a periodic `C_Timer` clear, or cap by size. | +| B4 | `QuestieDB.lua:1903` | `local playerFaction = UnitFactionGroup("player")` runs at **module load**; if QuestieDB loads before `PLAYER_LOGIN`, `factionReactions.A/.H` are nil → faction-tagged NPCs get `friendly=nil` | Compute lazily on first use, or recompute on `PLAYER_ENTERING_WORLD`. (Token casing is correct here — the risk is load order, distinct from FP #2.) Verify in-game. | +| B5 | `Questie-X.toc:182` | `Modules\QuestieSlash.lua` listed twice (also line 23) | Remove line 182. | +| B6 | 3–4 files | UTF-8 BOM at byte 0: `Database/Corrections/{tbcQuestFixes,wotlkItemFixes,wotlkQuestFixes}.lua` (+ vendored LibSharedMedia) → parse error on strict parsers | Strip the BOM. | +| B7 | `QuestieFramePool.lua:133` / `QuestieFrame.lua` | `newFrame.BaseOnUpdate = _Qframe.BaseOnUpdate` but `_Qframe.BaseOnUpdate` is never defined → `glowLogicTimer` ticker (`QuestieFramePool.lua:109–113`) never fires | Either wire to `_Qframe.GlowUpdate` (if the glow-sync is wanted) **or** delete the dead ticker block. Decide with intent. | + +## 8.4 — Confirmed PERFORMANCE findings (with better alternatives) + +| # | Location | Cost | Better alternative | +|---|---|---|---| +| P1 | `QuestieComms.lua:558` (and V2 ~669) | **O(n²)**: re-serializes the *entire accumulating* `rawQuestList` on every quest added, until length > 200. 30 quests ≈ 465 serialize calls per broadcast | Track running serialized size incrementally: serialize each quest packet once, sum its byte length, and start a new block when the sum crosses the threshold. Converts O(n²)→O(n). | +| P2 | `Localization/l10n.lua:166–207` | `local args = {...}` allocates a table on **every** `l10n()` call (the common case is a literal key with no args), plus a `tostring` loop and `__call` closure dispatch | Add a no-arg fast path *before* allocating (e.g. branch on arg count and return the cached resolved string). A per-key resolved-string cache drops repeat lookups to one hash. Biggest alloc reducer in the addon. | +| P3 | front-removal queues: `QuestieMap.lua:360,370`, `QuestieComms.lua:572,596,692,716`, `QuestieFramePool.lua:221`, `QuestieCombatQueue.lua` (2), `QuestEventHandler.lua:692`, `QuestieValidateGameCache.lua:119`, `TaskQueue.lua:7` | `tremove(t, 1)` is O(n) shift; several run on 0.1–3s tickers | Head-index dequeue (`head`/`tail` integers + nil-out) or a small ring buffer, kept local to each module. No shared abstraction. | +| P4 | `QuestieLib.tunpack` (`QuestieLib.lua:655`) | Recursive unpack: one call+return per element | `return unpack(tbl, 1, tbl.n)` (native C). *Caveat:* `tunpack` shares a file with `tpack` (line 648) whose `{ n = select("#", ...), ... }` is a `...`-expression, so on strict 5.0 `QuestieLib.lua` doesn't load at all — this fix only helps on 5.1+ (where it's still a real comms win). | +| P5 | `Tooltip.lua:411–451` + `QuestiePlayer.lua:170–186` | `GetPartyMemberByName` scans up to 40 unit slots (`UnitName`/`UnitClass` per slot) and is called per (quest × objective × player) inside the tooltip loop → thousands of API calls per busy-NPC tooltip in a full group | Build a `name→info` map once at the top of `GetTooltip` (one `GetPartyMemberList()` pass), then index it in the inner loop. | +| P6 | `tinsert(t, 1, x)` at `Tooltip.lua:437`, `MapIconTooltip.lua:188,889`, `QuestieDB.lua:1710` | O(n) front-insert | Append + reverse once, or build in final order. Tooltip ones are per-tooltip; QuestieDB one is DB-build-time (minor). | +| P7 | `AvailableQuests.CalculateAndDrawAll` (20+ call sites) + `_DrawAvailableQuest` `NewThread`-per-quest (~line 282) | Each call rescans the full quest set and spawns one coroutine per quest (10k on Ascension); Options tab fires it up to 4× per setting change | (a) Move the debounce to the UI layer (1 rescan per burst, not 4); (b) gate event triggers so it doesn't fire on every `ZONE_CHANGED`; (c) batch N quests per yield in one coroutine instead of one coroutine per quest. Highest-value perf work, but needs in-game regression testing. | +| P8 | `QuestieDB.IsDoable` (`QuestieDB.lua:893+`) | 12 `QueryQuestSingle` + several WoW API calls per quest; called per quest in the draw loop | Don't rewrite IsDoable (high risk). Reduce *how often* it runs (P7). If profiling still shows it hot, a batched field-load pass is the eventual fix. | +| P9 | `QuestieLearner.HandleNetworkData` (`QuestieLearner.lua:3610,3658`) | `InjectLearnedData` (300+ lines) runs on **every** network payload | Coalesce incoming payloads into one `InjectLearnedData` per burst window. | + +Confirmed-correct (no action): `GetQuest`/`GetNPC` caches are sound; the kill / +invalidate / guidNpc debounces are all bounded (≤10s windows) — the old +"unbounded killDebounce" Hindsight note is stale; `QuestieStream` and +`QuestieNameplate` pools use `tremove(t)` (O(1) tail), not front-removal; +`QuestieRouteOptimizer.lua` is a removed-stub ("kept for compatibility"). + +## 8.5 — Final prioritized action list + +**Step 0 — settle the platform question (8.1):** in-game `/run print(#({1,2,3}))`. +This gates ~50 files of potential 5.0 work and may make it unnecessary. + +**Tier 1 — safe, real, version-independent (one bisectable commit each group):** +- B5 (TOC dedup), B6 (BOM strip), B2 (`fadeTicker:Cancel` typo ×3), + B1 (`IsComplete` double-call), B3 (bound `alreadySentBandaid`), + P4 (`tunpack` → `unpack`), B7 (FramePool `BaseOnUpdate` decide+fix). +- Add unused `questId` param to `Ascension_IsScalingEnabled` to clear the lint + (cosmetic; not a blocker — see FP #1). + +**Tier 2 — measure first (the prior audit's genuinely good ideas):** +- P1 (comms O(n²)→O(n)), P2 (l10n alloc), P5 (tooltip party map), + P7 (CalculateAndDrawAll gating/debounce), P9 (learner batch). + +**Tier 3 — only if profiling still shows a problem:** +- P3 (queue head-index swaps), P8 (batched IsDoable), spatial index for + `CalcHotzones` (fix its input-mutation bug first), nearest-spawn cache. + +**If Step 0 says strict 5.0:** convert raw `#` → `table.getn` across the 50 +files (mechanical), rewrite the ~15 `...`-expression sites to use `arg`/`unpack` +or `loadstring`, and verify `l10n.lua`, `QuestieLib.lua`, and `QuestieMap.lua` +parse. **If Step 0 says `#` works:** skip all 5.0 work. + +**Do NOT do:** FP #1–7 (above); delete `TaskQueue`/`MapExplorationUpdate`; the +`QuestiePerf`/`QuestieQueue`/scheduler abstraction (regression risk on the 3 +fragile subsystems); the `function(...)`-signature rewrites as framed in pass 7 +(wrong target). + +**Verify in-game (not from static analysis):** B4 (factionReactions load order), +FP #4 (learner `argN` on 3.3.5a), and Step 0. + +## 8.6 — Executable verification (busted) + +The static findings above are now backed by an executable spec — +`Tests/AuditFindings_spec.lua` — so the audit is self-verifying and re-runnable +after fixes. Each test is tagged with its Pass-8 ID (`[B1]`, `[P4]`, `[FP3]`...) +and reads the actual source to assert the claim holds. + +Run: +``` +busted Tests/AuditFindings_spec.lua # just the audit checks +busted # whole suite +``` + +Result at HEAD `581634d`: + +- **`AuditFindings_spec.lua`: 33 / 33 pass** — every Pass-8/9/10/11 finding and + every corrected false positive confirmed against source (19 Pass-8 + 6 Pass-9 + `[N1]`–`[N5]` + 4 Pass-10 `[PP1]`–`[PP4]` + 4 Pass-11 `[11.1]`/`[G1]`/`[G2]`/`[G3]`, + and the corrected `%`-modulo assertion). +- **Full suite: 45 pass / 4 fail.** The 4 failures are *pre-existing* and are + themselves a finding: `Tests/QuestieArrowAssets_spec.lua` expects an arrow set + (`ArcaneArrow.tga` + `Minimal{1,2,3}.tga`, 22 files) that + `Modules/Arrow/QuestieArrowAssets.lua` and the `Icons/Arrows/` folder don't + provide (they still carry `DropTestArrow.tga`, 18 files). This is a real + test↔asset mismatch (originally flagged in pass 5), **not** a busted/env + problem and not introduced here. Decision needed: update the manifest+assets to + match the spec, or update the spec to current assets. Left untouched pending + your call. + +Notes on the spec design: the *false-positive* and *structural-fact* tests +(`8.2`) should pass forever. The *bug*/*perf* tests (`8.3`/`8.4`) are a snapshot — +when a fix lands, invert or remove the matching assertion (e.g. after P4, +`[P4]`'s `is_false(... "return unpack(tbl, 1, tbl.n)")` flips to `is_true`). + +--- + +# Pass-9 Addendum — Genuine File-by-File Audit (2026-06-04) + +Passes 1–8 mixed mechanical scans with targeted reads and skipped files. Pass 9 +is the actual per-file pass. **Method:** + +1. Enumerated **every** runtime `.lua` file (excluding vendored `Libs/`, + `.history/`, `workflow/`, `Tests/`): **308 files** — **135 logic** (have + function defs) + **173 data** (return-table only). ~1.5M lines, but >90% of + that is data tables. +2. Ran a **per-file** mechanical scan over all 308 for every 5.0/bug pattern + (`#`, `%`, literal `...`, `select`, `gmatch`, `goto`, `bit32`, `string.pack`, + `table.unpack`, `utf8`, `//`, `tremove(_,1)`, `tinsert(_,1,)`, double-call, + nil-index, ternary-trap, `bit.`, `strsplit`). +3. **Read** every small/medium logic file in full; targeted-read the large ones + around flagged lines. Verified the 173 data files are genuinely data (the + `#`/`gmatch` "hits" in them are string content / URL comments — see 9.1). + +This pass found **6 issues passes 1–8 missed entirely**, and corrects a Pass-8 +number. + +## 9.1 — Correction to Pass 8: the `#` count was inflated + +Pass 8 said "50 files / 255 sites" of raw `#`. That counted **data files** where +`#` is inside string values (e.g. `lookupNpcs/deDE.lua`: +`"Gnomenkanonenschütze #Shattrath"`) or URL comments (`npcDB.lua`: +`creature_template#rank`). Those are **not** operators. + +**Accurate count (logic files, excluding strings/comments/URLs): 33 files / 134 +sites.** Still the dominant 5.0 parse blocker, still real, but ~half the earlier +figure. The `%`-modulo operator remains **0** (codebase uses `math.mod`). + +Also confirmed clean tree-wide (0 real sites): `goto`/`::labels::`, `bit32.`, +`string.pack`/`string.unpack`, `table.unpack`, `utf8.`, `//`. + +## 9.2 — NEW: `bit` library used unguarded (1.12 RUNTIME failure) + +A category passes 1–8 never checked: WoW APIs/libs **absent on 1.12** (runtime +nil-index, not a parse error). The `bit` library (BitLib) ships with WoW **2.0+**; +**vanilla 1.12 does not have it.** The addon uses it **unguarded** in 5 files, +all in the Turtle TOC: + +| File:line | Code | +|---|---| +| `Questie.lua:1` | `local band = bit.band` | +| `Modules/QuestieStream.lua:10-12` | `local band/lshift/rshift = bit.*` | +| `Database/compiler.lua:18` | `local lshift = bit.lshift` | +| `Database/QuestieDB.lua:218` | `local bitband = bit.band` | +| `Modules/QuestieMenu/Townsfolk.lua:10` | `local ..., bitband = ..., bit.band` | + +On a client without `bit`, every one throws `attempt to index nil value (global +'bit')` at load. **Tell:** the *vendored* `XXH_Lua_Lib.lua:27-32` deliberately +guards this (`local bit = _G.bit; local band = bit and bit.band`) — Questie's own +code does not. If Turtle provides `bit` (its client may), this is moot like the +`#` question; if not, `Questie.lua:1` is one of the first lines to fail. **Verify +in-game:** `/run print(bit and bit.band(3,1))`. + +## 9.3 — NEW: `strsplit` used in runtime but shimmed only in a test + +`strsplit` is a WoW **2.0+** global (absent in vanilla 1.12). It's used in **14 +runtime files** (`QuestieComms`, `QuestieNameplate`, `QuestieLearner`, +`TooltipHandler`, `QuestLinks/Link`, `QuestieDB`, `DailyQuests`, `QuestieDebugOffer`, +`Auto/Privates`, several `Journey/*`, `Database/Corrections/QuestieEvent`). The +**only** `strsplit` shim in the tree is in `Tests/.../QuestieLearner_spec.lua` (a +test mock) — there is **none in `QuestieCompat`**. Same caveat as `bit`: a real +1.12 gap if the Turtle client doesn't provide it. Lower confidence than `bit` +(some call sites are TBC/WotLK-only paths), but it belongs on the verify list. + +## 9.4 — NEW: Options files use `{ ... }` where `{}` was intended (5.0 parse + smell) + +`Modules/Options/QuestieOptions.lua:15` (`QuestieOptions.tabs = { ... }`) and +**14 Options tab files** (`tabs.arrow = { ... }`, `tabs.general = { ... }`, …) +write a literal `{ ... }`. At **file scope**, `...` is WoW's chunk varargs +`(addonName, addonTable)`, so each makes `tabs = {"Questie-X",