diff --git a/CHANGELOG.md b/CHANGELOG.md index 87267ab..0ecfa45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,7 +109,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 +282,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 +593,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/Modules/Libs/QuestieLoader.lua b/Modules/Libs/QuestieLoader.lua index 484e0b7..a34e59e 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,7 @@ 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 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 +41,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. @@ -136,4 +136,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/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..a27132f 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 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..b1b8071 100644 --- a/Modules/QuestieInit.lua +++ b/Modules/QuestieInit.lua @@ -279,7 +279,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() @@ -390,7 +390,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 diff --git a/Modules/QuestieLearner.lua b/Modules/QuestieLearner.lua index 16a1a27..019fcef 100644 --- a/Modules/QuestieLearner.lua +++ b/Modules/QuestieLearner.lua @@ -210,6 +210,16 @@ 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 + -- 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 @@ -565,6 +575,89 @@ 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 LIVE_NPC_UPDATE_DELAY = 0.5 + +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 + +local function _FlushNpcLiveUpdates() + local pending = _Learner.pendingNpcLiveUpdates + _Learner.pendingNpcLiveUpdates = {} + _Learner.pendingNpcLiveUpdateTimer = nil + + for npcId in pairs(pending) do + if _ApplyNpcLiveUpdate(npcId) then + _InvalidateSpawnListsForNPC(npcId) + end + end +end + +local function _QueueNpcLiveUpdate(npcId) + _Learner.pendingNpcLiveUpdates = _Learner.pendingNpcLiveUpdates or {} + _Learner.pendingNpcLiveUpdates[npcId] = true + + if _Learner.pendingNpcLiveUpdateTimer then return end + + local timer = QuestieCompat and QuestieCompat.C_Timer + if timer and timer.After then + _Learner.pendingNpcLiveUpdateTimer = true + timer.After(LIVE_NPC_UPDATE_DELAY, _FlushNpcLiveUpdates) + else + _FlushNpcLiveUpdates() + end +end + ------------------------------------------------------------------------ -- CrossLinkAfterNPC: called when a new NPC is first learned. -- Scans all learned quests for any reference to this npcId and stitches @@ -887,48 +980,14 @@ 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 - end + -- Live injection is intentionally batched: repeated kill/log/loot events for + -- the same NPC update saved evidence immediately, then flush QuestieDB once. + _QueueNpcLiveUpdate(npcId) 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 @@ -1196,9 +1255,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 +1344,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 @@ -3568,9 +3623,44 @@ end function _Learner:BroadcastIfCommsAvailable(typ, id, data) 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 + timer.After(2, FlushBroadcasts) + else + FlushBroadcasts() end end 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/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/docs/changelog.html b/docs/changelog.html index e1da012..eff8d96 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -278,9 +278,9 @@
-

Session 34 — Turtle WoW Cleanup (2026-05-04)

+

Session 34 — Unsupported Legacy Server Cleanup (2026-05-04)

@@ -426,7 +426,7 @@
  • [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).
  • [Feature — Session Export] Added session_export skill for standardized documentation and session artifact exports.
  • -
  • [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.
  • [Fix — Zone Mapping Bug] Fixed incorrect key assignment in QuestiePluginAPI:InjectZoneTables(). This caused zone lookups to fail, resulting in "No UiMapID or fitting parentAreaId" errors for custom zone IDs.
  • @@ -450,7 +450,7 @@

    v1.4.8 — Plugin Synchronization & Lua 5.0 Refactor