From 0f803aca4db5b109ae6b91c9912432ed7f9c2fce Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Mon, 2 Nov 2026 19:36:30 +0000 Subject: [PATCH] Optional ClassicAPI support When ClassicAPI is installed and "Use ClassicAPI" is on (Mods tab, reload): - Detect ClassicAPI and each feature separately; show it in the Mods tab - Read auras with C_UnitAuras.GetAuraSlots (fill form) + UnitAuraBySlot, reusing entries, with ClassicAPI durations, expirations and casters - UNIT_AURA_DELTA: refresh-only events update stacks and timers in place without rebuilding the aura icons or the health bar - Aura timers without SuperWoW; native Cooldown frames with one shared text driver instead of a Model and OnUpdateModel per icon - UNIT_IN_RANGE_UPDATE, UNIT_DISTANCE_CHECK_UPDATE, UNIT_CONNECTION and INCOMING_RESURRECT_CHANGED; UnitHasIncomingResurrection in res checks - HealPredict learns heals from COMBAT_LOG_EVENT_UNFILTERED when the GUID formats match SuperWoW's - Distance and line of sight when UnitXP SP3 and SuperWoW are missing Without ClassicAPI, or with the option off, behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019W3rwwTK1hwTEWHqsan2mV --- CHANGELOG-lowgc.md | 16 ++- PTUnitFrame.lua | 202 +++++++++++++++++++++++++------ Puppeteer.lua | 6 + PuppeteerSettings.lua | 1 + README.md | 21 +++- core/EventHandler.lua | 107 ++++++++++++++++- core/UnitTracker.lua | 9 ++ gui/Settings.lua | 38 +++++- libs/HealPredict.lua | 56 ++++++++- libs/PTUnit.lua | 273 ++++++++++++++++++++++++++++++++++++------ libs/Util.lua | 143 ++++++++++++++++++++-- 11 files changed, 779 insertions(+), 93 deletions(-) diff --git a/CHANGELOG-lowgc.md b/CHANGELOG-lowgc.md index a2c7fe6..1acd689 100644 --- a/CHANGELOG-lowgc.md +++ b/CHANGELOG-lowgc.md @@ -23,6 +23,19 @@ Each [skrymsli](https://github.com/skrymsli/Puppeteer) commit was only kept if i - **Resurrection timeout dropped every resurrection on a target.** When one caster's resurrection passed its 20-second limit, the cleanup removed the whole target entry, hiding other casters' resurrections that were still in progress. Now only the expired caster is removed, and the target entry goes once it is empty. (Found in razzeee/Puppeteer's summoning commit.) +## ClassicAPI support + +All of it is optional and feature-detected. Without ClassicAPI, or with **Use ClassicAPI** off (Mods tab, needs a reload), every path is the one described below. + +- **Aura scan (`libs/PTUnit.lua`).** `C_UnitAuras.GetAuraSlots` fills one reused slot buffer, and `C_UnitAuras.UnitAuraBySlot` returns each aura positionally. The scan keeps the per-slot entry reuse, so it creates no tables after an entry's first use. Each entry keeps its own reused time table for ClassicAPI's duration, expiration and caster. SuperWoW/Nampower tracked times are the fallback when ClassicAPI has none. Tooltips use `GameTooltip:SetUnitAura` with the C_UnitAuras index. +- **Aura deltas (`core/EventHandler.lua`).** For real unit tokens, `UNIT_AURA_DELTA` replaces `UNIT_AURA`. An event with only updated auras re-reads stacks and timers in place (`PTUnit:RefreshClassicAuras`) and updates the shown icons (`PTUnitFrame:RefreshAuras`). It skips the icon rebuild, sorting and health update. Added or removed auras, or a changed list, take the full path. SuperWoW GUID arguments still use `UNIT_AURA`. +- **Aura timers (`PTUnitFrame.lua`).** With ClassicAPI, icons use a native `Cooldown` frame (no bling, no built-in numbers) instead of a `Model` with an `OnUpdateModel` script. One shared driver updates the countdown text of visible timers, and only rewrites the text when the second changes. Second strings 0–60 are cached. +- **Events.** `UNIT_IN_RANGE_UPDATE` and `UNIT_DISTANCE_CHECK_UPDATE` re-read distance and sight for that unit. `UNIT_CONNECTION` updates health, power and range. `INCOMING_RESURRECT_CHANGED` updates the health text, and `UnitHasIncomingResurrection` is added to resurrection checks. +- **Heal learning (`libs/HealPredict.lua`, SuperWoW only).** `COMBAT_LOG_EVENT_UNFILTERED` replaces the four combat-log chat frames for `SPELL_HEAL`, `SPELL_PERIODIC_HEAL` and `SPELL_AURA_REMOVED`. Direct heals are only learned when they match the caster's last cast (by ID, or by name). It is switched on at the first `PLAYER_ENTERING_WORLD`, and only if ClassicAPI's player GUID string equals SuperWoW's. +- **Distance and sight (`libs/Util.lua`).** `UnitDistanceSquared` is the distance provider only without UnitXP SP3 and SuperWoW. `UnitInLineOfSight` is the sight provider only without UnitXP SP3. + +Not used: ClassicAPI's `collectgarbage` options (no-ops on the 5.0 collector) and the new duration text bindings (Puppeteer's flashing text colours don't map onto a formatter). + ## Why WoW 1.12 runs Lua 5.0, whose collector stops the game and walks the whole heap whenever enough garbage piles up. @@ -85,7 +98,8 @@ plain 1.12 client gave the same results. ## About ClassicAPI ClassicAPI backports the modern API and Lua 5.1 syntax, but it still runs on 1.12's Lua 5.0 VM and documents no -collector changes. It would not reduce GC pauses, so this build does not depend on it. +collector changes. It does not shorten GC pauses by itself, so this build does not depend on it. It does let Puppeteer +do less Lua work (see "ClassicAPI support" above), which is used when it is installed. ## Flat bar textures diff --git a/PTUnitFrame.lua b/PTUnitFrame.lua index 445549c..8fc7739 100644 --- a/PTUnitFrame.lua +++ b/PTUnitFrame.lua @@ -223,12 +223,13 @@ function PTUnitFrame:UpdateSight() end end -local preciseDistance = util.CanClientGetPreciseDistance() +local CanClientGetPreciseDistance = util.CanClientGetPreciseDistance -- Read on use: ClassicAPI may provide it once options load local RANGE_PREFIX_FAR = GetColorPrefix(0.75, 0.75, 0.75) local RANGE_PREFIX_OUT = GetColorPrefix(1, 0.3, 0.3) local RANGE_PREFIX_NEAR = GetColorPrefix(1, 0.6, 0) function PTUnitFrame:UpdateRangeText() local dist = ceil(self.distance) + local preciseDistance = CanClientGetPreciseDistance() local prefix if dist >= (preciseDistance and self:GetShowDistanceThreshold() or 28) and dist < 9999 then if dist > 80 then @@ -855,7 +856,7 @@ local AURA_DURATION_TEXT_FLASH_THRESHOLD = 5 local AURA_DURATION_TEXT_LOW_THRESHOLD = 30 -- A map of all seconds below the flash threshold to an array of colors to interpolate local durationTextFlashColorsRange -if util.IsSuperWowPresent() then +do -- Built for every client, since ClassicAPI can enable timers after this file loads local flashColorsReset = {{1, 1, 0.75}, {1, 0.7, 0.55}, {1, 0.6, 0.6}} local flashColors = {{1, 1, 0.25}, {1, 0.6, 0.35}, {1, 0.4, 0.4}} @@ -872,6 +873,96 @@ if util.IsSuperWowPresent() then durationTextFlashColorsRange[seconds] = {colors[2], colors[1]} end end +-- Whole-second strings for the duration text, so a ticking timer doesn't convert a number every second +local secondsTexts = {} +for i = 0, 60 do + secondsTexts[i] = tostring(i) +end + +-- Aura timers drawn by ClassicAPI's native Cooldown frames. The sweep animates in the client; only the +-- duration text is updated here, by one shared driver instead of a script on every icon. +local nativeCooldownsUsable -- nil until the first try +local function CreateNativeCooldown(parent) + if nativeCooldownsUsable == false or not util.IsClassicAPIActive("Cooldown") then + return nil + end + local ok, cooldown = pcall(CreateFrame, "Cooldown", nil, parent, "CooldownFrameTemplate") + if not ok or not cooldown or not cooldown.SetCooldown or not cooldown.SetDrawBling + or not cooldown.SetHideCountdownNumbers then + nativeCooldownsUsable = false + if ok and cooldown then + cooldown:Hide() + end + return nil + end + nativeCooldownsUsable = true + cooldown.native = true + cooldown:SetDrawBling(false) -- No flash each time an aura runs out + cooldown:SetHideCountdownNumbers(true) -- Puppeteer draws its own text + return cooldown +end + +local activeAuraTimers = {} -- Key: aura component with a native cooldown | Value: true +local auraTimerDriver = CreateFrame("Frame", "PTAuraTimerDriver") + +local function UpdateNativeTimerText(component, now) + local duration = component.duration + local durationText = component.durationText + local secondsPrecise = duration.timerEnd - now + if secondsPrecise <= 0 then + durationText:SetSeconds(nil) + activeAuraTimers[component] = nil + return + end + local seconds = floor(secondsPrecise) + if seconds <= (duration.displayAt or AURA_DURATION_TEXT_FLASH_THRESHOLD) then + local changed = durationText.seconds ~= seconds + if changed then + durationText:SetSeconds(seconds) + end + if seconds <= AURA_DURATION_TEXT_FLASH_THRESHOLD then + durationText:SetTextColor( + util.InterpolateColorsNoTable(durationTextFlashColorsRange[seconds], secondsPrecise - seconds)) + elseif changed then + if seconds <= AURA_DURATION_TEXT_LOW_THRESHOLD then + durationText:SetTextColor(1, 1, 0.25) + else + durationText:SetTextColor(1, 1, 1) + end + end + elseif durationText.seconds ~= nil then + durationText:SetSeconds(nil) + end +end + +local function AuraTimerDriver_OnUpdate() + local now = GetTime() + for component in pairs(activeAuraTimers) do + if component.frame:IsVisible() then + UpdateNativeTimerText(component, now) + end + end + if next(activeAuraTimers) == nil then + auraTimerDriver:SetScript("OnUpdate", nil) + end +end + +local function StartNativeTimer(component) + activeAuraTimers[component] = true + if not auraTimerDriver:GetScript("OnUpdate") then + auraTimerDriver:SetScript("OnUpdate", AuraTimerDriver_OnUpdate) + end + UpdateNativeTimerText(component, GetTime()) +end + +-- Hides an aura icon's timer sweep and text +local function ClearAuraTimer(component) + activeAuraTimers[component] = nil + component.durationText:SetSeconds(nil) + CooldownFrame_SetTimer(component.duration, 0, 0, 0) + component.hasTimer = nil +end + -- "Nm" duration strings, cached so they aren't rebuilt every time a timer ticks local minutesTexts = {} function GetMinutesText(minutes) @@ -896,9 +987,9 @@ function PTUnitFrame:AllocateAura() local stackText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") stackText:SetTextColor(1, 1, 1) - -- Duration display, only used when SuperWoW is present - if util.IsSuperWowPresent() then - local duration = CreateFrame("Model", nil, frame, "CooldownFrameTemplate") + -- Duration display, only used when SuperWoW or ClassicAPI provides aura times + if util.HasAuraTimers() then + local duration = CreateNativeCooldown(frame) or CreateFrame("Model", nil, frame, "CooldownFrameTemplate") duration.noCooldownCount = true duration:SetAlpha(0.8) local durationOverlayFrame = CreateFrame("Frame", nil, frame) @@ -911,7 +1002,7 @@ function PTUnitFrame:AllocateAura() self:SetText("") return end - self:SetText(seconds <= 60 and seconds or GetMinutesText(ceil(seconds / 60))) + self:SetText(seconds <= 60 and secondsTexts[seconds] or GetMinutesText(ceil(seconds / 60))) local fontSize = ceil(frame:GetHeight() * (seconds < 540 and (seconds < 10 and 0.6 or 0.45) or 0.35)) if fontSize ~= self.fontSize then self.fontSize = fontSize @@ -933,6 +1024,10 @@ function PTUnitFrame:AllocateAura() end duration:SetScript("OnUpdate", nil) end + if duration.native then + return {["frame"] = frame, ["icon"] = icon, ["border"] = border, ["stackText"] = stackText, + ["overlay"] = durationOverlayFrame, ["durationText"] = durationText, ["duration"] = duration} + end local SetSequenceTime = duration.SetSequenceTime local GetTime = GetTime duration:SetScript("OnUpdateModel", function() @@ -1075,9 +1170,8 @@ function PTUnitFrame:ReleaseAuras() table.insert(self.auraButtonPool, button) end - if util.IsSuperWowPresent() then - aura.durationText:SetSeconds(nil) - CooldownFrame_SetTimer(aura.duration, 0, 0, 0) + if aura.duration then + ClearAuraTimer(aura) end table.insert(self.auraIconPool, aura) @@ -1108,10 +1202,13 @@ local enemyDebuffSorter = function(a, b) if not a.time and not b.time then return a.name > b.name end - if UnitIsUnit(a.time.owner, "player") and not UnitIsUnit(b.time.owner, "player") then + -- ClassicAPI times have no owner when the cast wasn't seen + local aMine = a.time.owner ~= nil and UnitIsUnit(a.time.owner, "player") + local bMine = b.time.owner ~= nil and UnitIsUnit(b.time.owner, "player") + if aMine and not bMine then return true end - if not UnitIsUnit(a.time.owner, "player") and UnitIsUnit(b.time.owner, "player") then + if not aMine and bMine then return false end return (a.time.startTime + a.time.duration) < (b.time.startTime + b.time.duration) @@ -1177,7 +1274,7 @@ function PTUnitFrame:UpdateAuras() if not enemy then table.sort(debuffs, self.DebuffSorter) util.AppendArrayElements(debuffs, typedDebuffs) - elseif util.IsSuperWowPresent() then + elseif util.HasAuraTimers() then table.sort(debuffs, enemyDebuffSorter) end @@ -1224,7 +1321,10 @@ function PTUnitFrame:ApplyAuraTooltip(auraFrame) tooltip:SetOwner(auraFrame, "ANCHOR_BOTTOMLEFT") local unit = self:GetResolvedUnit() if auraData then - if type == "Buff" then + if auraData.filter and tooltip.SetUnitAura then + -- ClassicAPI auras are indexed in C_UnitAuras order, read from the cache's unit + tooltip:SetUnitAura(cache.Unit or unit, index, auraData.filter) + elseif type == "Buff" then tooltip:SetUnitBuff(unit, index) else tooltip:SetUnitDebuff(unit, index) @@ -1298,6 +1398,11 @@ function PTUnitFrame:CreateAura(component, aura, xOffset, yOffset, type, size) frame.auraData = aura frame.auraIndex = aura.index frame.auraType = type + -- Kept so RefreshAuras can update the icon in place + component.xOffset = xOffset + component.yOffset = yOffset + component.auraKind = type + component.size = size local icon = component.icon icon:SetAllPoints(frame) @@ -1306,24 +1411,30 @@ function PTUnitFrame:CreateAura(component, aura, xOffset, yOffset, type, size) local button = component.button button:SetAllPoints(frame) - if util.IsSuperWowPresent() then + local durationUI = component.duration + if durationUI then local overlay = component.overlay overlay:SetAllPoints() - local duration = component.duration - duration:SetAllPoints() - duration:SetScale(size * 0.0275) + durationUI:SetAllPoints() + if not durationUI.native then + durationUI:SetScale(size * 0.0275) + end end + local stackText = component.stackText if stacks > 1 then - local stackText = component.stackText stackText:SetPoint("CENTER", frame, "CENTER", 0, 0) stackText:SetFont("Fonts\\FRIZQT__.TTF", math.ceil(size * (stacks < 10 and 0.75 or 0.6))) stackText:SetText(stacks) + else + stackText:SetText("") end + -- ClassicAPI times have no owner when the cast wasn't seen + local castByPlayer = aura.time ~= nil and aura.time.owner ~= nil and UnitIsUnit(aura.time.owner, "player") if type == "Buff" then - if selfBorderedBuffs[aura.name] and aura.time and UnitIsUnit(aura.time.owner, "player") then + if selfBorderedBuffs[aura.name] and castByPlayer then local border = component.border border:Show() border:SetVertexColor(1, 1, 0) @@ -1334,34 +1445,53 @@ function PTUnitFrame:CreateAura(component, aura, xOffset, yOffset, type, size) local border = component.border border:Show() local color = debuffTypeBorderColors[aura.type] or debuffTypeBorderColors["Other"] - if aura.time and UnitIsUnit(aura.time.owner, "player") and self:IsEnemy() then + if castByPlayer and self:IsEnemy() then border:SetVertexColor(1, 1, 0) else border:SetVertexColor(color[1], color[2], color[3]) end end - if aura.time then - local startTime = aura.time.startTime - local endTime = aura.time.endTime - local duration = aura.time.duration - if endTime - GetTime() > -4 then -- Don't display duration if the predicted time has lapsed - local durationUI = component.duration + if not durationUI then + return + end + local auraTime = aura.time + if auraTime and auraTime.endTime - GetTime() > -4 then -- Don't display duration if the predicted time has lapsed + local startTime = auraTime.startTime + local duration = auraTime.duration - CooldownFrame_SetTimer(durationUI, startTime, duration, 1) + CooldownFrame_SetTimer(durationUI, startTime, duration, 1) + component.hasTimer = true - if duration < 60 then - durationUI.displayAt = PTOptions.ShowAuraTimesAt.Short - elseif duration <= 60 * 2 then - durationUI.displayAt = PTOptions.ShowAuraTimesAt.Medium - else - durationUI.displayAt = PTOptions.ShowAuraTimesAt.Long - end + if duration < 60 then + durationUI.displayAt = PTOptions.ShowAuraTimesAt.Short + elseif duration <= 60 * 2 then + durationUI.displayAt = PTOptions.ShowAuraTimesAt.Medium + else + durationUI.displayAt = PTOptions.ShowAuraTimesAt.Long + end - -- To prevent having a frame where the duration is not updated - component.durationText:SetSeconds(nil) + -- To prevent having a frame where the duration is not updated + component.durationText:SetSeconds(nil) + if durationUI.native then + durationUI.timerEnd = startTime + duration + StartNativeTimer(component) + else util.CallWithThis(durationUI, durationUI:GetScript("OnUpdateModel")) end + elseif component.hasTimer then -- A refreshed aura that lost its time + ClearAuraTimer(component) + end +end + +-- Updates the shown aura icons in place, for auras that were only refreshed (new duration or stack count). +-- The aura list itself must be unchanged; otherwise use UpdateAuras. +function PTUnitFrame:RefreshAuras() + for _, component in ipairs(self.auraIcons) do + local aura = component.frame.auraData + if aura and component.size then + self:CreateAura(component, aura, component.xOffset, component.yOffset, component.auraKind, component.size) + end end end diff --git a/Puppeteer.lua b/Puppeteer.lua index ee6e034..c0e4fca 100644 --- a/Puppeteer.lua +++ b/Puppeteer.lua @@ -237,6 +237,8 @@ end function OnAddonLoaded() PuppeteerSettings.SetDefaults() + util.ApplyClassicAPIOptions() + PTUnit.ApplyClassicAPIOptions() if PTBindings == nil then GenerateDefaultBindings() @@ -411,6 +413,10 @@ function OnAddonLoaded() initUnitFrames() StartUnitTracker() + RegisterClassicAPIEvents() + if PTHealPredict then + PTHealPredict.EnableClassicCombatLog() + end PuppeteerLib:RegisterEvent("Banzai_UnitGainedAggro", function(unit) if PTGuidRoster then diff --git a/PuppeteerSettings.lua b/PuppeteerSettings.lua index 903b17c..a4b38ad 100644 --- a/PuppeteerSettings.lua +++ b/PuppeteerSettings.lua @@ -149,6 +149,7 @@ function SetDefaults() ["UseHealPredictions"] = true, ["PVPFlagProtection"] = true, ["SetMouseover"] = true, + ["UseClassicAPI"] = true, -- Use ClassicAPI features when the mod is present (requires reload) ["ShowRaidMana"] = true, ["LFTAutoRole"] = true, -- WoW 1.18.1 Clients ["TestUI"] = false, diff --git a/README.md b/README.md index 497d586..a3328e7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Puppeteer +[![ClassicAPI](https://img.shields.io/badge/ClassicAPI-Optional-yellow.svg)](https://github.com/brues-code/ClassicAPI) +[![SuperWoW](https://img.shields.io/badge/SuperWoW-Optional-yellow.svg)](https://github.com/balakethelock/SuperWoW) +[![Nampower](https://img.shields.io/badge/Nampower-Optional-yellow.svg)](https://github.com/brues-code/nampower) +[![UnitXP](https://img.shields.io/badge/UnitXP__SP3-Optional-yellow.svg)](https://github.com/brues-code/UnitXP_SP3) + A unit frames addon for World of Warcraft Vanilla 1.12, tailored for healers and useful for any class. **Low-garbage build of [OldManAlpha/Puppeteer](https://github.com/OldManAlpha/Puppeteer)** @@ -70,7 +75,21 @@ Saved selections previously named **Modern Blizzard** automatically migrate to * ## Optional client mods -Puppeteer includes support for SuperWoW, UnitXP SP3, and Nampower. Available enhancements depend on the installed provider and version. Check Puppeteer's client-mod information in its settings for detection and available features. +Puppeteer includes support for SuperWoW, UnitXP SP3, Nampower and ClassicAPI. Available enhancements depend on the installed provider and version. Check **`/pt` → Options → Mods** for detection and available features. + +### ClassicAPI + +With [ClassicAPI](https://github.com/brues-code/ClassicAPI) 1.18.10 or newer, Puppeteer: + +- Reads all buffs and debuffs in one pass (`C_UnitAuras.GetAuraSlots`), with their spell IDs, dispel types, timers and casters. No tooltip scanning, and debuffs the server moved into buff slots show as debuffs. +- Updates timers and stack counts in place when an aura is only refreshed (`UNIT_AURA_DELTA`), instead of rebuilding the aura icons and health bar. +- Shows aura timers without SuperWoW. Your own auras are exact. Other casters' auras have timers when ClassicAPI saw the cast. +- Draws aura timer sweeps with native Cooldown frames. The countdown text is updated by one shared driver instead of a script on every icon. +- Updates range, online status, visibility and incoming resurrections as they change (`UNIT_IN_RANGE_UPDATE`, `UNIT_CONNECTION`, `UNIT_DISTANCE_CHECK_UPDATE`, `INCOMING_RESURRECT_CHANGED`). Resurrections are seen from any nearby caster, including players without HealComm. +- With SuperWoW, learns heal amounts from `COMBAT_LOG_EVENT_UNFILTERED` instead of parsing combat log chat text. It works in every client language. +- Without UnitXP SP3, provides distance (friendly and enemy) and line-of-sight. Without SuperWoW, it also provides distance. UnitXP SP3 and SuperWoW are still preferred when installed. + +Each feature is detected separately, so an older ClassicAPI only loses the parts it lacks. Turn it all off with **Use ClassicAPI** in the Mods tab, then reload. Blizzard Modern textures and white-number settings do not require ShaguTweaks or BetterBlizzFrames at runtime. diff --git a/core/EventHandler.lua b/core/EventHandler.lua index 4d8eb2d..ea9932e 100644 --- a/core/EventHandler.lua +++ b/core/EventHandler.lua @@ -65,11 +65,8 @@ RegisterEventHandler({"UNIT_MANA", "UNIT_RAGE", "UNIT_ENERGY", "UNIT_FOCUS", "UN raidFrameGroup:RequestRaidManaUpdate() end end) -RegisterEventHandler("UNIT_AURA", function() - local unit = arg1 - if not IsRelevantUnit(unit) then - return - end +-- Rescans the unit's auras and rebuilds its aura icons +local function UpdateUnitAuras(unit) if unit == "player" then util.MarkSpellCostCacheDirty() end @@ -78,7 +75,107 @@ RegisterEventHandler("UNIT_AURA", function() ui:UpdateAuras() ui:UpdateHealth() -- Update health because there may be an aura that changes health bar color end +end + +-- True once UNIT_AURA_DELTA handles the real unit tokens (see RegisterClassicAPIEvents) +local auraDeltaActive = false +local AllRealUnitsSet = util.AllRealUnitsSet + +RegisterEventHandler("UNIT_AURA", function() + local unit = arg1 + if not IsRelevantUnit(unit) then + return + end + -- ClassicAPI reports the same changes for these tokens through UNIT_AURA_DELTA, with what changed. + -- Other arguments (SuperWoW GUIDs of custom units) still come only through UNIT_AURA, and so do + -- members out of view, whose auras arrive with the party stats rather than the unit's own fields. + if auraDeltaActive and AllRealUnitsSet[unit] and UnitIsVisible(unit) then + return + end + UpdateUnitAuras(unit) end) + +-- ClassicAPI events, registered once options are loaded and the unit frames exist +function RegisterClassicAPIEvents() + if util.IsClassicAPIActive("AuraDelta") then + auraDeltaActive = true + RegisterEventHandler("UNIT_AURA_DELTA", function() + local unit = arg1 + if not IsRelevantUnit(unit) then + return + end + local info = arg2 -- Valid only during this handler + if info and not info.isFullUpdate and not info.addedAuraInstanceIDs and not info.removedAuraInstanceIDs then + -- Only refreshes and stack changes: update the timers and stacks in place + if PTUnit.Get(unit):RefreshClassicAuras() then + if unit == "player" then + util.MarkSpellCostCacheDirty() + end + for ui in UnitFrames(unit) do + ui:RefreshAuras() + end + return + end + end + UpdateUnitAuras(unit) + end) + end + + if util.IsClassicAPIActive("Resurrection") then + RegisterEventHandler("INCOMING_RESURRECT_CHANGED", function() + local unit = arg1 + if not IsRelevantUnit(unit) then + return + end + for ui in UnitFrames(unit) do + ui:UpdateHealth() + end + end) + end + + if util.IsClassicAPIActive("ConnectionEvent") then + RegisterEventHandler("UNIT_CONNECTION", function() + local unit = arg1 + if not IsRelevantUnit(unit) then + return + end + PTUnit.Get(unit):UpdateDistance() + for ui in UnitFrames(unit) do + ui:UpdateHealth() + ui:UpdatePower() + ui:UpdateRange() + end + end) + end + + -- Visibility and the 40 yard check change state right away instead of at the next tracking scan + local function UpdateUnitRange() + local unit = arg1 + if not IsRelevantUnit(unit) then + return + end + local cache = PTUnit.Get(unit) + local distanceChanged = cache:UpdateDistance() + local sightChanged = cache:UpdateSight() + if distanceChanged or sightChanged then + for ui in UnitFrames(unit) do + if distanceChanged then + ui:UpdateRange() + end + if sightChanged then + ui:UpdateSight() + end + end + end + end + if util.IsClassicAPIActive("VisibilityEvent") then + RegisterEventHandler("UNIT_DISTANCE_CHECK_UPDATE", UpdateUnitRange) + end + if util.IsClassicAPIActive("RangeEvent") then + -- RegisterEvent covers pet, party and raid members and their pets, which ClassicAPI checks every frame + RegisterEventHandler("UNIT_IN_RANGE_UPDATE", UpdateUnitRange) + end +end RegisterEventHandler({"PARTY_MEMBERS_CHANGED", "RAID_ROSTER_UPDATE"}, function() CheckGroupThrottled() end) diff --git a/core/UnitTracker.lua b/core/UnitTracker.lua index 5db8b1c..b2eaf9b 100644 --- a/core/UnitTracker.lua +++ b/core/UnitTracker.lua @@ -139,5 +139,14 @@ end function StartUnitTracker() LoadTrackingOptions() + -- Providers can change once options are loaded (ClassicAPI distance and sight) + if not preciseDistance and util.CanClientGetPreciseDistance() then + preciseDistance = true + nextRangeUpdate = GetTime() + 0.6 + end + if not sightTrackingEnabled and util.CanClientSightCheck() then + sightTrackingEnabled = true + nextSightUpdate = GetTime() + 0.6 + end UnitTrackerFrame:SetScript("OnUpdate", RunTrackingScan) end \ No newline at end of file diff --git a/gui/Settings.lua b/gui/Settings.lua index 63cb0e9..2c46c7b 100644 --- a/gui/Settings.lua +++ b/gui/Settings.lua @@ -660,6 +660,7 @@ function CreateTab_Options_Mods(panel) ["SuperWoW"] = {1, 0.4, 0.4}, ["UnitXP SP3"] = {0.4, 0.4, 1}, ["Nampower"] = {0.4, 1, 0.4}, + ["ClassicAPI"] = {0.75, 0.5, 1}, ["VanillaUtils"] = {1, 1, 1} } local detectedTexts = { @@ -668,7 +669,7 @@ function CreateTab_Options_Mods(panel) ["Outdated"] = colorize("Mod Detected (Out of Date)", 1, 1, 0).." - %s" } local detectedLabelTexts = {} - for _, mod in ipairs({"SuperWoW", "UnitXP SP3", "Nampower"}) do + for _, mod in ipairs({"SuperWoW", "UnitXP SP3", "Nampower", "ClassicAPI"}) do detectedLabelTexts[mod] = string.format(detectedTexts[util.GetModVersionState(mod)], util.GetModVersionText(mod)) end local superWowLabel = CreateLabel(container, colorize("SuperWoW", modColors["SuperWoW"])) @@ -747,6 +748,41 @@ function CreateTab_Options_Mods(panel) local nampowerLinkLabel = CreateLabel(container, "Link:") :SetPoint("RIGHT", nampowerLink, "LEFT", -5, 0) + -- ClassicAPI + + local classicAPILabel = CreateLabel(container, colorize("ClassicAPI", modColors["ClassicAPI"])) + :SetPoint("TOP", nampowerLink, "BOTTOM", 0, -20) + :SetFontSize(14) + local classicAPIDetectedLabel = CreateLabel(container, detectedLabelTexts["ClassicAPI"]) + :SetPoint("TOP", classicAPILabel, "BOTTOM", 0, -5) + :SetFontSize(10) + :SetFontFlags("OUTLINE") + + local classicAPIInfo = CreateLabel(container, "ClassicAPI provides the following enhancements:\n\n".. + "• Reads all buffs and debuffs in one pass, including their timers and casters\n".. + "• Updates buff/debuff timers and stacks in place when an aura is refreshed\n".. + "• Draws aura timer sweeps natively, without a per-icon Lua update\n".. + "• Updates range, online status, visibility and incoming resurrections as they change\n".. + "• Learns heal amounts from the combat log in any client language (with SuperWoW)\n".. + "• Distance and line-of-sight when UnitXP SP3 is not installed") + :SetJustifyH("LEFT") + :SetWidth(TEXT_WIDTH) + :SetPoint("TOP", classicAPIDetectedLabel, "BOTTOM", 0, -10) + local classicAPILink = CreateLinkEditbox(container, "https://github.com/brues-code/ClassicAPI") + :SetPoint("TOP", classicAPIInfo, "BOTTOM", 0, -5) + :SetSize(300, 20) + local classicAPILinkLabel = CreateLabel(container, "Link:") + :SetPoint("RIGHT", classicAPILink, "LEFT", -5, 0) + + layout:ignoreNext() + local useClassicAPICheckbox = factory:checkbox("Use ClassicAPI", {"Requires ClassicAPI Mod To Work", + "If enabled, Puppeteer uses the ClassicAPI features listed above", + "Requires a reload to take effect"}, "UseClassicAPI") + :SetPoint("TOP", classicAPILink, "BOTTOM", 0, -10) + if not util.IsClassicAPIPresent() then + useClassicAPICheckbox:Disable() + end + -- local modFeaturesLabel = CreateLabel(container, "Mod Enhancements") -- :SetPoint("TOP", nampowerLink, "BOTTOM", 0, -25) diff --git a/libs/HealPredict.lua b/libs/HealPredict.lua index 72efa7a..f03b474 100644 --- a/libs/HealPredict.lua +++ b/libs/HealPredict.lua @@ -264,13 +264,18 @@ end local GENERIC_CHANGE_FACTOR = 0.05 local PLAYER_CHANGE_FACTOR = 0.25 -function UpdateCache(heal, name) +-- spellID and spellName are optional; when given (from the ClassicAPI combat log), a heal from another +-- spell leaves the last cast in place instead of being learned as that cast's amount +function UpdateCache(heal, name, spellID, spellName) name = name or UnitName("player") local lastCastedSpell = LastCastedSpells[name] if not lastCastedSpell then return end + if spellID and lastCastedSpell["spellID"] ~= spellID and SpellInfo(lastCastedSpell["spellID"]) ~= spellName then + return + end LastCastedSpells[name] = nil local spellID = lastCastedSpell["spellID"] @@ -830,6 +835,55 @@ selfAuraCombatLogFrame:SetScript("OnEvent", function() end end) +-- ClassicAPI's COMBAT_LOG_EVENT_UNFILTERED replaces the chat combat log parsing above: it works in every +-- client language, carries GUIDs and a crit flag, and needs no string matching. It is switched on at the +-- first PLAYER_ENTERING_WORLD, once ClassicAPI's GUIDs are confirmed to match SuperWoW's. +local classicCombatLogFrame = CreateFrame("Frame", "PTHealPredictClassicCombatLog") + +local function ClassicCombatLog_OnEvent() + local _, subevent, _, sourceGUID, sourceName, _, _, destGUID, destName, _, _, spellID, spellName, _, amount, + _, _, critical = CombatLogGetCurrentEventInfo() + if subevent == "SPELL_HEAL" then + -- Crits would skew the learned amounts, as in the chat parsing + if critical or not amount or not sourceName then + return + end + UpdateCache(amount, sourceName, spellID, spellName) + elseif subevent == "SPELL_PERIODIC_HEAL" then + if not amount or not spellName or not destGUID or not sourceGUID then + return + end + UpdateCacheHot(spellName, amount, destGUID, destName, sourceGUID, sourceName) + elseif subevent == "SPELL_AURA_REMOVED" then + if spellName and destGUID then + RemoveHoT(spellName, destGUID) + end + end +end + +classicCombatLogFrame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + classicCombatLogFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") + local _, superwowGuid = UnitExists("player") + if not superwowGuid or superwowGuid ~= UnitGUID("player") then + return -- Different GUID formats; keep the chat parsing + end + combatLogFrame:UnregisterAllEvents() + periodicCombatLogFrame:UnregisterAllEvents() + auraCombatLogFrame:UnregisterAllEvents() + selfAuraCombatLogFrame:UnregisterAllEvents() + classicCombatLogFrame:SetScript("OnEvent", ClassicCombatLog_OnEvent) + classicCombatLogFrame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") + end +end) + +function EnableClassicCombatLog() + if not PTUtil.IsClassicAPIActive("CombatLog") or not UnitGUID then + return + end + classicCombatLogFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +end + -- Set the GUIDs to listen to function SetRelevantGUIDs(guidArray) local relevant = RelevantGUIDs diff --git a/libs/PTUnit.lua b/libs/PTUnit.lua index 3fb8e57..195a09e 100644 --- a/libs/PTUnit.lua +++ b/libs/PTUnit.lua @@ -13,6 +13,11 @@ local AllUnitsSet = util.AllUnitsSet local superwow = util.IsSuperWowPresent() local canGetAuraIDs = util.CanClientGetAuraIDs() +-- ClassicAPI aura reading, decided once options load (see ApplyClassicAPIOptions) +local classicAuras = false +local classicResurrection = false +local GetAuraSlots, UnitAuraBySlot + local compost = PTPool -- Non-instance variable @@ -22,7 +27,8 @@ PTUnit.Cached = {} PTUnit.Unit = nil PTUnit.AurasPopulated = false --- Buff/debuff entry contents: {"name", "stacks", "texture", "index", "type", "id"(SuperWoW only)} +-- Buff/debuff entry contents: {"name", "stacks", "texture", "index", "type", "id"(SuperWoW/ClassicAPI only), "time"} +-- With ClassicAPI, "index" is the C_UnitAuras index and "filter" is "HELPFUL" or "HARMFUL" (used for tooltips) PTUnit.Buffs = {} -- Array of all buffs PTUnit.BuffsMap = {} -- Key: Name | Value: Array of buffs with key's name PTUnit.BuffsIDSet = {} -- Set of currently applied buff IDs @@ -46,6 +52,20 @@ PTUnit.Distance = 0 PTUnit.InSight = true PTUnit.IsNew = false +function ApplyClassicAPIOptions() + classicAuras = util.IsClassicAPIActive("AuraSlots") + if classicAuras then + GetAuraSlots = C_UnitAuras.GetAuraSlots + UnitAuraBySlot = C_UnitAuras.UnitAuraBySlot + canGetAuraIDs = true -- Spell IDs come with every aura + end + classicResurrection = util.IsClassicAPIActive("Resurrection") +end + +function UsesClassicAuras() + return classicAuras +end + -- Non-GUID function function CreateCaches() if superwow then @@ -203,24 +223,35 @@ function PTUnit:IsInSight() return self.InSight end +-- ClassicAPI sees resurrections from any nearby caster, including players without HealComm +local function hasClassicResurrection(unit) + return classicResurrection and UnitHasIncomingResurrection(unit) == true +end + function PTUnit:IsBeingResurrected() if not self.Unit then return false end if PTHealPredict then - return PTHealPredict.IsBeingResurrected(self.Unit) + return PTHealPredict.IsBeingResurrected(self.Unit) or hasClassicResurrection(self.Unit) end - return Puppeteer.HealComm:UnitisResurrecting(UnitName(self.Unit)) + return Puppeteer.HealComm:UnitisResurrecting(UnitName(self.Unit)) or hasClassicResurrection(self.Unit) end function PTUnit:GetResurrectionCasts() if not self.Unit then return 0 end + local count if PTHealPredict then - return PTHealPredict.GetResurrectionCount(self.Unit) + count = PTHealPredict.GetResurrectionCount(self.Unit) + else + count = Puppeteer.HealComm:UnitisResurrecting(UnitName(self.Unit)) and 1 or 0 end - return Puppeteer.HealComm:UnitisResurrecting(UnitName(self.Unit)) and 1 or 0 + if count == 0 and hasClassicResurrection(self.Unit) then + count = 1 + end + return count end -- Aura storage is reused across rescans so that UNIT_AURA produces no garbage: @@ -310,6 +341,187 @@ function PTUnit:ClearAuras() end local claimedAuras = {} + +-- The SuperWoW/Nampower aura time with the most time left for this spell ID that no other aura claimed yet +local function FindTrackedTime(self, id, time) + local auraTimes = id and self.AuraTimes[id] + if not auraTimes then + return nil + end + local auraTime = nil + local highestDuration = -100 + for owner, aura in pairs(auraTimes) do + local timeLeft = aura.endTime - time + if not claimedAuras[aura] and timeLeft > highestDuration then + auraTime = aura + highestDuration = timeLeft + end + end + if auraTime then + claimedAuras[auraTime] = 1 + end + return auraTime +end + +-- ClassicAPI aura reading. One slot enumeration per range, then a positional read per aura, +-- so a scan creates no tables after each entry's first use. +local slotBuffer = {} + +-- Fills the entry's own time table from ClassicAPI's duration and expiration. Returns nil when unknown. +local function ApplyClassicTime(entry, duration, expirationTime, source, now) + if not expirationTime or expirationTime <= 0 or not duration or duration <= 0 then + return nil + end + local startTime = expirationTime - duration + if startTime > now then -- A talent-extended aura must not start in the future + startTime = now + duration = expirationTime - now + end + if duration <= 0 then + return nil + end + local auraTime = entry.classicTime + if not auraTime then + auraTime = {} + entry.classicTime = auraTime + end + auraTime.startTime = startTime + auraTime.endTime = expirationTime + auraTime.duration = duration + auraTime.owner = source -- A unit token, or nil when the cast was not seen + auraTime.ownerName = source and UnitName(source) or nil + return auraTime +end + +function PTUnit:ScanClassicAuras(unit, time) + local buffs = self.Buffs + local buffsMap = self.BuffsMap + local buffsIDSet = self.BuffsIDSet + local buffEntries = self.BuffEntries + local trackedHealingBuffs = PuppeteerSettings.TrackedHealingBuffs + local _, count = GetAuraSlots(unit, "HELPFUL", nil, nil, slotBuffer) + for index = 1, count or 0 do + local name, texture, stacks, type, duration, expirationTime, source, _, _, id = + UnitAuraBySlot(unit, slotBuffer[index]) + if name then + if trackedHealingBuffs[name] then + self.HasHealingModifier = true + end + local buff = GetEntry(buffEntries, getn(buffs) + 1) + buff.name = name + buff.index = index + buff.filter = "HELPFUL" + buff.texture = texture + buff.stacks = stacks or 0 + buff.type = type or "" + buff.id = id + buff.time = ApplyClassicTime(buff, duration, expirationTime, source, time) or FindTrackedTime(self, id, time) + local nameArray = buffsMap[name] + if not nameArray then + nameArray = GetArray() + buffsMap[name] = nameArray + end + if id ~= nil then + buffsIDSet[id] = true + end + table.insert(nameArray, buff) + table.insert(buffs, buff) + end + end + + local afflictedDebuffTypes = self.AfflictedDebuffTypes + local trackedDebuffTypes = self.TrackedDebuffTypes + local debuffs = self.Debuffs + local debuffsMap = self.DebuffsMap + local debuffsIDSet = self.DebuffsIDSet + local typedDebuffs = self.TypedDebuffs -- Dispellable debuffs + local debuffEntries = self.DebuffEntries + local trackedHealingDebuffs = PuppeteerSettings.TrackedHealingDebuffs + local importantDebuffs = PuppeteerSettings.ImportantDebuffs + local ignoredDispellableDebuffs = PuppeteerSettings.IgnoredDispellableDebuffs + -- HARMFUL also returns debuffs the server moved into free buff slots, which UnitDebuff misses + _, count = GetAuraSlots(unit, "HARMFUL", nil, nil, slotBuffer) + for index = 1, count or 0 do + local name, texture, stacks, type, duration, expirationTime, source, _, _, id = + UnitAuraBySlot(unit, slotBuffer[index]) + if name then + type = type or "" + if trackedHealingDebuffs[name] then + self.HasHealingModifier = true + end + if importantDebuffs[name] then + self.HasImportantDebuff = true + end + local debuff = GetEntry(debuffEntries, getn(debuffs) + 1) + debuff.name = name + debuff.index = index + debuff.filter = "HARMFUL" + debuff.texture = texture + debuff.stacks = stacks or 0 + debuff.type = type + debuff.id = id + debuff.time = ApplyClassicTime(debuff, duration, expirationTime, source, time) or FindTrackedTime(self, id, time) + local nameArray = debuffsMap[name] + if not nameArray then + nameArray = GetArray() + debuffsMap[name] = nameArray + end + if id ~= nil then + debuffsIDSet[id] = true + end + table.insert(nameArray, debuff) + if type ~= "" then + afflictedDebuffTypes[type] = 1 + if not ignoredDispellableDebuffs[name] then + trackedDebuffTypes[type] = 1 + end + local typeArray = typedDebuffs[type] + if not typeArray then + typeArray = GetArray() + typedDebuffs[type] = typeArray + end + table.insert(typeArray, debuff) + end + table.insert(debuffs, debuff) + end + end +end + +-- Re-reads stacks and timers of the scanned auras in place. Returns false if the aura list itself +-- changed, in which case a full UpdateAuras is needed. +local function RefreshClassicList(unit, filter, list, now) + local _, count = GetAuraSlots(unit, filter, nil, nil, slotBuffer) + count = count or 0 + if count ~= getn(list) then + return false + end + for index = 1, count do + local name, _, stacks, _, duration, expirationTime, source, _, _, id = UnitAuraBySlot(unit, slotBuffer[index]) + local entry = list[index] + if not name or entry.id ~= id or entry.index ~= index then + return false + end + entry.stacks = stacks or 0 + local classicTime = ApplyClassicTime(entry, duration, expirationTime, source, now) + if classicTime then + entry.time = classicTime + elseif entry.time == entry.classicTime then + entry.time = nil + end + end + return true +end + +-- For UNIT_AURA_DELTA events that only refreshed auras (new duration or stack count) +function PTUnit:RefreshClassicAuras() + local unit = self.Unit + if not classicAuras or not unit or self == PTUnit or not self.AurasPopulated then + return false + end + local now = GetTime() + return RefreshClassicList(unit, "HELPFUL", self.Buffs, now) and RefreshClassicList(unit, "HARMFUL", self.Debuffs, now) +end + function PTUnit:UpdateAuras() local unit = self.Unit @@ -326,11 +538,20 @@ function PTUnit:UpdateAuras() return end + local time = GetTime() + + if classicAuras then + self:ScanClassicAuras(unit, time) + self.AurasPopulated = true + util.ClearTable(claimedAuras) + Puppeteer.EndTiming("PTUnitAuraUpdate") + return + end + local buffs = self.Buffs local buffsMap = self.BuffsMap local buffsIDSet = self.BuffsIDSet local buffEntries = self.BuffEntries - local time = GetTime() for index = 1, 32 do local texture, stacks, id = UnitBuff(unit, index) if not texture then @@ -340,21 +561,7 @@ function PTUnit:UpdateAuras() if PuppeteerSettings.TrackedHealingBuffs[name] then self.HasHealingModifier = true end - local auraTime = nil - if id and self.AuraTimes[id] then - local auraTimes = self.AuraTimes[id] - local highestDuration = -100 - for owner, aura in pairs(auraTimes) do - local timeLeft = aura.endTime - time - if not claimedAuras[aura] and timeLeft > highestDuration then - auraTime = aura - highestDuration = timeLeft - end - end - if auraTime then - claimedAuras[auraTime] = 1 - end - end + local auraTime = FindTrackedTime(self, id, time) local buff = GetEntry(buffEntries, index) buff.name = name buff.index = index @@ -395,21 +602,7 @@ function PTUnit:UpdateAuras() if PuppeteerSettings.ImportantDebuffs[name] then self.HasImportantDebuff = true end - local auraTime = nil - if id and self.AuraTimes[id] then - local auraTimes = self.AuraTimes[id] - local highestDuration = -100 - for owner, aura in pairs(auraTimes) do - local timeLeft = aura.endTime - time - if not claimedAuras[aura] and timeLeft > highestDuration then - auraTime = aura - highestDuration = timeLeft - end - end - if auraTime then - claimedAuras[auraTime] = 1 - end - end + local auraTime = FindTrackedTime(self, id, time) local debuff = GetEntry(debuffEntries, index) debuff.name = name debuff.index = index @@ -542,10 +735,12 @@ function PTUnit:ApplyTimedAura(spellName, spellID, owner, duration, isNampower) end function PTUnit:GetAuraTimeRemaining(name) - if not superwow then - return + local auraTime = superwow and self.AuraTimesByName[name] or nil + if not auraTime and classicAuras then -- ClassicAPI times are kept on the aura entries + local array = self.BuffsMap[name] or self.DebuffsMap[name] + local entry = array and array[1] + auraTime = entry and entry.time end - local auraTime = self.AuraTimesByName[name] if not auraTime then return end diff --git a/libs/Util.lua b/libs/Util.lua index 812eed3..2d24461 100644 --- a/libs/Util.lua +++ b/libs/Util.lua @@ -47,11 +47,38 @@ if GetNampowerVersion then end VanillaUtils = pcall(UnitXP, "unitPosition", "player") +-- ClassicAPI (github.com/brues-code/ClassicAPI). Every feature is detected on its own, so an older +-- ClassicAPI only loses the parts it lacks. CLASSIC_API_VERSION is X*10000 + Y*100 + Z (0 on dev builds). +ClassicAPI = CLASSIC_API_VERSION ~= nil +ClassicAPIRecommended = 11810 -- 1.18.10: the unit events used below +do + local function eventValid(event) + return C_EventUtils ~= nil and C_EventUtils.IsEventValid ~= nil and C_EventUtils.IsEventValid(event) and true or false + end + local auras = C_UnitAuras + ClassicAPIFeatures = { + -- C_UnitAuras slot enumeration; the fill-a-table form is checked on first use (see UseClassicAuraSlots) + AuraSlots = ClassicAPI and auras ~= nil and auras.GetAuraSlots ~= nil and auras.UnitAuraBySlot ~= nil, + AuraDelta = ClassicAPI and eventValid("UNIT_AURA_DELTA"), + Cooldown = ClassicAPI, -- Native Cooldown frames are checked on first use (see CreateNativeCooldown) + Distance = ClassicAPI and UnitDistanceSquared ~= nil, + Sight = ClassicAPI and UnitInLineOfSight ~= nil, + RangeEvent = ClassicAPI and eventValid("UNIT_IN_RANGE_UPDATE"), + ConnectionEvent = ClassicAPI and eventValid("UNIT_CONNECTION"), + VisibilityEvent = ClassicAPI and eventValid("UNIT_DISTANCE_CHECK_UPDATE"), + Resurrection = ClassicAPI and UnitHasIncomingResurrection ~= nil and eventValid("INCOMING_RESURRECT_CHANGED"), + CombatLog = ClassicAPI and CombatLogGetCurrentEventInfo ~= nil and eventValid("COMBAT_LOG_EVENT_UNFILTERED") + } +end +-- Set by ApplyClassicAPIOptions once saved options are loaded; false until then and when turned off +ClassicAPIEnabled = false + EnabledMods = { ["SuperWoW"] = SuperWoW and SuperWoWFeatureLevel, ["Nampower"] = Nampower and NampowerFeatureLevel, ["UnitXP SP3"] = UnitXPSP3 and 0, - ["VanillaUtils"] = VanillaUtils and 0 + ["VanillaUtils"] = VanillaUtils and 0, + ["ClassicAPI"] = ClassicAPI and (CLASSIC_API_VERSION or 0) } -- Providers are ordered by preference @@ -64,17 +91,17 @@ ModFeatures = { { Name = "Aura Timers", Description = "See timers on buffs and debuffs, with Nampower providing many more than SuperWoW.", - Providers = {{"Nampower", Nampower_v3_0}, {"SuperWoW"}} + Providers = {{"Nampower", Nampower_v3_0}, {"SuperWoW"}, {"ClassicAPI"}} }, { Name = "Friendly Distance", Description = "See precise distance to friendly players, with UnitXP SP3 being more accurate.", - Providers = {{"UnitXP SP3"}, {"SuperWoW"}} + Providers = {{"UnitXP SP3"}, {"SuperWoW"}, {"ClassicAPI"}} }, { Name = "Enemy Distance", Description = "See precise distance to enemies.", - Providers = {{"UnitXP SP3"}} + Providers = {{"UnitXP SP3"}, {"ClassicAPI"}} }, { Name = "Friendly Position", @@ -104,7 +131,7 @@ ModFeatures = { { Name = "LOS", Description = "See when someone is out of your line-of-sight.", - Providers = {{"UnitXP SP3"}} + Providers = {{"UnitXP SP3"}, {"ClassicAPI"}} }, { Name = "Spell Queue", @@ -196,8 +223,34 @@ function GetNampowerVersionState() return GetModState(IsNampowerPresent(), IsNampowerUpToDate()) end +function IsClassicAPIPresent() + return ClassicAPI +end + +function GetClassicAPIVersionText() + if not ClassicAPI then + return "" + end + local version = CLASSIC_API_VERSION or 0 + if version <= 0 then + return "Development Build" + end + return "Version "..math.floor(version / 10000).."."..math.mod(math.floor(version / 100), 100).."."..math.mod(version, 100) +end + +function IsClassicAPIUpToDate() + local version = CLASSIC_API_VERSION or 0 + return version <= 0 or version >= ClassicAPIRecommended +end + +function GetClassicAPIVersionState() + return GetModState(IsClassicAPIPresent(), IsClassicAPIUpToDate()) +end + function GetModVersionText(mod) - if mod == "SuperWoW" then + if mod == "ClassicAPI" then + return GetClassicAPIVersionText() + elseif mod == "SuperWoW" then return GetSuperWoWVersionText() elseif mod == "UnitXP SP3" then return GetUnitXPSP3VersionText() @@ -210,7 +263,9 @@ function GetModVersionText(mod) end function GetModVersionState(mod) - if mod == "SuperWoW" then + if mod == "ClassicAPI" then + return GetClassicAPIVersionState() + elseif mod == "SuperWoW" then return GetSuperWoWVersionState() elseif mod == "UnitXP SP3" then return GetUnitXPSP3VersionState() @@ -1555,9 +1610,32 @@ else -- sad GetDistanceBetween = GetDistanceBetween_Vanilla end +-- ClassicAPI: center-to-center distance from the player. Used only when neither UnitXP SP3 nor SuperWoW +-- provides distance (see ApplyClassicAPIOptions). +function GetDistanceBetween_ClassicAPI(unit1, unit2) + if unit1 ~= "player" then + return GetDistanceBetween_Vanilla(unit1, unit2) + end + + if not UnitIsConnected(unit2) then + return 0 + end + + if not UnitIsVisible(unit2) then + return 9999 + end + + local distanceSquared, checked = UnitDistanceSquared(unit2) + if not checked then + return 9999 + end + return math.sqrt(distanceSquared) +end + +local classicDistance = false -- SuperWoW cannot provide precise distance for enemies function CanClientGetPreciseDistance(alsoEnemies) - return UnitXPSP3 or (SuperWoW and not alsoEnemies) + return UnitXPSP3 or (SuperWoW and not alsoEnemies) or classicDistance end function GetUnitPosition(unit) @@ -1592,8 +1670,54 @@ do -- This is done to prevent crashes from checking sight too early end) end +local classicSight = false function CanClientSightCheck() - return UnitXPSP3 + return UnitXPSP3 or classicSight +end + +-- ClassicAPI features that are both available and turned on. Filled by ApplyClassicAPIOptions. +ClassicAPIActive = {} + +-- Called once saved options are loaded, before any unit cache is created. +-- The "Use ClassicAPI" option needs a reload to change, since frames and caches are built around it. +function ApplyClassicAPIOptions() + ClassicAPIEnabled = ClassicAPI and PTOptions and PTOptions.UseClassicAPI and true or false + local active = ClassicAPIActive + for feature, available in pairs(ClassicAPIFeatures) do + active[feature] = ClassicAPIEnabled and available and true or nil + end + -- The fill-a-table form of GetAuraSlots writes the slot count to t.n; older versions return slot ids instead + if active.AuraSlots then + local probe = {} + local ok = pcall(C_UnitAuras.GetAuraSlots, "player", "HELPFUL", nil, nil, probe) + if not ok or probe.n == nil then + active.AuraSlots = nil + end + end + -- Aura deltas are only used together with the slot scan, which they refresh in place + if not active.AuraSlots then + active.AuraDelta = nil + end + + if active.Distance and not UnitXPSP3 and not SuperWoW then + GetDistanceBetween = GetDistanceBetween_ClassicAPI + classicDistance = true + end + if active.Sight and not UnitXPSP3 then + IsInSight = function(unit) + return UnitInLineOfSight(unit) ~= false + end + classicSight = true + end +end + +function IsClassicAPIActive(feature) + return ClassicAPIActive[feature] ~= nil +end + +-- Whether aura icons can show timers: SuperWoW/Nampower tracking, or ClassicAPI aura durations +function HasAuraTimers() + return SuperWoW or ClassicAPIActive.AuraSlots ~= nil end -- From pfQuest @@ -1652,6 +1776,7 @@ function IsClient1181() end AllUnitsSet = ToSet(AllUnits) +AllRealUnitsSet = ToSet(AllRealUnits) FocusUnitsSet = ToSet(FocusUnits) RunLater(function() PTLocale.Values(ResurrectionSpells)