Migrate to ClassicAPI for macro display, auras and timing

Squash of the classicapi_next branch (10 commits). ClassicAPI is now a hard
requirement, and the Lua-side reimplementations it supersedes are gone:
net -1087 lines across 12 files.

Macro display: resolved macro actions are published through
C_Macro.SetMacroDisplay, so the client draws macro buttons and the
action-bar function overrides this addon used to install are removed.
Ownership is per macro -- ClassicAPI keeps the macros we never claim.

Auras and timing: ClassicAPI is the source of truth for non-player aura
timing via the positional C_UnitAuras.UnitAura, replacing the write-only
buff tracking tables and the overflow-slot fallback.

Cleanup: the hasPfUI76 flag and the pfUI 7.6 branches it gated, two
silently-shadowed code paths, and per-call allocations in the event and
publish paths (SPELL_CAST_EVENT merged). Packaging moves to
brues-code/packager@vCAPI.

Macro syntax: a clause may carry a leading run of [group] blocks sharing
one action, Blizzard-style -- groups are OR'd, first pass wins, and []
always passes. The ;-separated form is unchanged and mixes freely. @focus
clauses with no focus set now fail quietly instead of printing
"Invalid target" on the way past.
This commit is contained in:
Brues
2026-09-11 00:30:36 -05:00
parent 9f8a5553bc
commit 5213bd5474
12 changed files with 729 additions and 1816 deletions
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
fetch-depth: 0
- name: Package and release to GitHub
uses: BigWigsMods/packager@v2
uses: brues-code/packager@vCAPI
env:
GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }}
+25 -372
View File
@@ -22,19 +22,6 @@ function Extension.DLOG(msg)
end
end
function Extension.FocusNameHook()
local hook = Extension.internal.memberHooks[CleveRoids]["GetFocusName"]
local target = hook.original()
if pfUI and pfUI.uf and pfUI.uf.focus and pfUI.uf.focus.unitname then
target = pfUI.uf.focus.unitname
end
--Extension.DLOG(target)
return target
end
-- Check if pfUI's macrotweak module is loaded
function Extension.IsPfUIMacrotweakLoaded()
if not pfUI then return false end
@@ -92,334 +79,6 @@ function Extension.HandleSendChatMessageHook()
end
end
-- Helper function to check for Carnage duration override
-- Returns override duration and timeleft if found, nil otherwise
local function GetCarnageOverride(effect)
if not effect or not CleveRoids.carnageDurationOverrides then
return nil, nil
end
for spellID, override in pairs(CleveRoids.carnageDurationOverrides) do
local spellName = C_Spell.GetSpellName(spellID)
if spellName then
local baseName = CleveRoids.StripRank(spellName)
if baseName == effect and override.timestamp and (GetTime() - override.timestamp) < 5 then
local timeleft = override.duration - (GetTime() - override.timestamp)
if timeleft < 0 then timeleft = 0 end
return override.duration, timeleft
end
end
end
return nil, nil
end
-- Hook pfUI's libdebuff to use our combo-aware durations
-- NOTE: pfUI 7.6+ (GetUnitField edition) handles combo durations and Carnage internally.
-- We only inject when there's a mismatch between pfUI's data and ours.
function Extension.HookPfUILibdebuff()
if not pfUI or not pfUI.api or not pfUI.api.libdebuff then
return false
end
local pflib = pfUI.api.libdebuff
-- Check if pfUI 7.6+ with GetUnitField-based libdebuff is active
-- If so, pfUI handles combo durations and Carnage internally - we only override on mismatch
local hasPfUI76 = CleveRoids.hasPfUI76
-- pfUI 7.6+ handles all durations internally - no hooks needed
if hasPfUI76 then
Extension.DLOG("Skipped all libdebuff hooks (pfUI 7.6+ handles internally)")
return false
end
-- Hook GetDuration if it exists
-- pfUI's GetDuration signature: function(effect, rank) where effect is spell NAME
if pflib.GetDuration and not Extension.pfLibDebuffHooked then
local originalGetDuration = pflib.GetDuration
pflib.GetDuration = function(self, effect, rank)
local pfuiDuration = originalGetDuration(self, effect, rank)
-- Check for Carnage duration overrides (only if pfUI doesn't have it)
local carnageDuration = GetCarnageOverride(effect)
if carnageDuration then
-- Only override if pfUI's duration is significantly different (>1s difference)
if not pfuiDuration or math.abs(carnageDuration - pfuiDuration) > 1 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI Duration Override]|r %s: Carnage %.1fs (pfUI: %.1fs)",
effect, carnageDuration, pfuiDuration or 0)
)
end
return carnageDuration
end
end
-- Check name-based tracking for fresh combo casts (only if pfUI returned 0 or nil)
if (not pfuiDuration or pfuiDuration == 0) and CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking[effect] then
local tracking = CleveRoids.ComboPointTracking[effect]
if tracking.duration and tracking.confirmed and (GetTime() - tracking.cast_time) < 0.5 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI Duration Override]|r %s: Combo tracking %.1fs (pfUI: %.1fs)",
effect, tracking.duration, pfuiDuration or 0)
)
end
return tracking.duration
end
end
return pfuiDuration
end
Extension.pfLibDebuffHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.GetDuration (mismatch-only mode)")
end
-- Hook AddEffect if it exists (pre-7.6 only - 7.6+ returns early above)
if pflib.AddEffect and not Extension.pfLibAddEffectHooked then
local originalAddEffect = pflib.AddEffect
pflib.AddEffect = function(self, unit, unitlevel, effect, duration, caster)
-- RANK CHECKING: Preserve higher rank's remaining time if lower rank was cast
-- NOTE: 'unit' is a unit NAME (e.g., "Expert Training Dummy"), not a unit ID
-- Defensive: verify libdebuff is a table, not a function
if caster == "player" and type(CleveRoids.libdebuff) == "table" and duration and duration > 0 then
-- Try to find the GUID for this unit name
local unitGUID = nil
-- Check if this is the current target
if UnitName("target") == unit then
unitGUID = CleveRoids.GetGUID("target")
end
-- If we couldn't match to current target, check guidToName mapping
if not unitGUID and CleveRoids.libdebuff.guidToName then
for guid, name in pairs(CleveRoids.libdebuff.guidToName) do
if name == unit then
unitGUID = guid
break
end
end
end
-- Check if a higher rank of this spell is already active
if unitGUID and CleveRoids.libdebuff.objects and CleveRoids.libdebuff.objects[unitGUID] then
-- Find all spell IDs that match this effect name
for spellID, rec in pairs(CleveRoids.libdebuff.objects[unitGUID]) do
if rec and rec.start and rec.duration then
-- Get spell name for this ID
local spellName = C_Spell.GetSpellName(spellID)
if spellName then
local baseName = CleveRoids.StripRank(spellName)
if baseName == effect then
-- Same spell - check if still active
local remaining = rec.duration + rec.start - GetTime()
if remaining > 0 then
-- If incoming duration > remaining time, we're trying to add more time
-- This means either a refresh or lower rank cast - preserve existing timer
if duration > remaining then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI Rank Preserve]|r %s: Preserving timer (%.1fs remaining vs %.1fs incoming)",
effect, remaining, duration)
)
end
-- Preserve the existing timer
duration = remaining
end
break
end
end
end
end
end
end
end
-- Check for Carnage duration overrides FIRST (highest priority)
local carnageDuration = GetCarnageOverride(effect)
if carnageDuration then
duration = duration or carnageDuration
caster = caster or "player" -- Ensure caster is set for UnitOwnDebuff filtering
end
-- Check if this is a combo scaling spell by name
if not duration and CleveRoids.IsComboScalingSpell and CleveRoids.IsComboScalingSpell(effect) then
if CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking[effect] then
local tracking = CleveRoids.ComboPointTracking[effect]
if tracking.duration and tracking.confirmed and (GetTime() - tracking.cast_time) < 0.5 then
duration = tracking.duration
caster = caster or "player"
end
end
end
return originalAddEffect(self, unit, unitlevel, effect, duration, caster)
end
Extension.pfLibAddEffectHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.AddEffect")
end
-- Hook UnitDebuff to return Carnage override duration to display code
-- Only override when pfUI's duration differs significantly from ours
if pflib.UnitDebuff and not Extension.pfLibUnitDebuffHooked then
local originalUnitDebuff = pflib.UnitDebuff
pflib.UnitDebuff = function(self, unit, id)
local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitDebuff(self, unit, id)
-- Only check Carnage override if pfUI returned data but duration might be wrong
if effect then
local carnageDuration, carnageTimeleft = GetCarnageOverride(effect)
if carnageDuration then
-- Only override if there's a significant difference (>1s)
if not duration or math.abs(carnageDuration - duration) > 1 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI UnitDebuff Override]|r %s: Carnage %.1fs/%.1fs (pfUI: %.1fs/%.1fs)",
effect, carnageDuration, carnageTimeleft, duration or 0, timeleft or 0)
)
end
duration = carnageDuration
timeleft = carnageTimeleft
end
end
end
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
end
Extension.pfLibUnitDebuffHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.UnitDebuff (mismatch-only mode)")
end
-- Hook UnitOwnDebuff to return Carnage override duration when selfdebuff is enabled
-- Only override when pfUI's duration differs significantly from ours
if pflib.UnitOwnDebuff and not Extension.pfLibUnitOwnDebuffHooked then
local originalUnitOwnDebuff = pflib.UnitOwnDebuff
pflib.UnitOwnDebuff = function(self, unit, id)
local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitOwnDebuff(self, unit, id)
if effect then
local carnageDuration, carnageTimeleft = GetCarnageOverride(effect)
if carnageDuration then
-- Only override if there's a significant difference (>1s)
if not duration or math.abs(carnageDuration - duration) > 1 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI UnitOwnDebuff Override]|r %s: Carnage %.1fs/%.1fs (pfUI: %.1fs/%.1fs)",
effect, carnageDuration, carnageTimeleft, duration or 0, timeleft or 0)
)
end
duration = carnageDuration
timeleft = carnageTimeleft
end
end
-- If UnitOwnDebuff returned nil but we have a Carnage override, synthesize from UnitDebuff
-- This fallback is only needed for edge cases where pfUI doesn't track the debuff yet
elseif not effect and CleveRoids.carnageDurationOverrides then
-- Use pflib:UnitDebuff which includes our Carnage override hook
local baseEffect, baseRank, baseTex, baseStacks, baseDtype, baseDur, baseLeft, _ = pflib:UnitDebuff(unit, id)
if baseEffect then
local carnageDuration2, carnageTimeleft2 = GetCarnageOverride(baseEffect)
if carnageDuration2 then
return baseEffect, baseRank, baseTex, baseStacks, baseDtype, carnageDuration2, carnageTimeleft2, "player"
end
end
end
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
end
Extension.pfLibUnitOwnDebuffHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.UnitOwnDebuff (mismatch-only mode)")
end
return Extension.pfLibDebuffHooked or Extension.pfLibAddEffectHooked or Extension.pfLibUnitDebuffHooked or Extension.pfLibUnitOwnDebuffHooked
end
-- Synchronize combo durations to pfUI's libdebuff objects
-- NOTE: pfUI 7.6+ handles combo durations internally - skip sync entirely
function Extension.SyncComboDurationToPfUI(guid, spellID, duration)
if not pfUI or not pfUI.api or not pfUI.api.libdebuff then
return
end
-- pfUI 7.6+ handles all durations internally
if CleveRoids.hasPfUI76 then
return
end
-- Get unit name from GUID
local unitName = nil
local unitLevel = 0
-- Check if this is the current target
local targetGUID = CleveRoids.GetGUID("target")
if targetGUID == guid then
unitName = UnitName("target")
unitLevel = UnitLevel("target") or 0
end
-- If we couldn't find the unit, use GUID to name mapping from libdebuff
if not unitName and CleveRoids.libdebuff and CleveRoids.libdebuff.guidToName then
unitName = CleveRoids.libdebuff.guidToName[guid]
-- Default to level 0 if we don't have the unit targeted
unitLevel = 0
end
if not unitName then
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[pfUI Sync]|r Could not find unit name for GUID")
end
return
end
-- Get spell name from spell ID
local spellName = C_Spell.GetSpellName(spellID)
if not spellName then
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[pfUI Sync]|r Could not find spell name for ID " .. spellID)
end
return
end
-- Remove rank from spell name to match pfUI's format
local effectName = CleveRoids.StripRank(spellName)
-- Update pfUI's stored debuff duration
local pflib = pfUI.api.libdebuff
if pflib.objects and pflib.objects[unitName] then
-- Try both the specific level and level 0 (fallback)
for _, level in ipairs({unitLevel, 0}) do
if pflib.objects[unitName][level] and pflib.objects[unitName][level][effectName] then
local old_duration = pflib.objects[unitName][level][effectName].duration
pflib.objects[unitName][level][effectName].duration = duration
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00ffaa[pfUI Sync]|r Updated %s on %s (L%d): %ds -> %ds",
effectName, unitName, level, old_duration or 0, duration)
)
end
return
end
end
end
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cffaaaa00[pfUI Sync]|r Effect not found in pfUI storage: %s on %s",
effectName, unitName)
)
end
end
-- Register action event handler for pfUI button updates
function Extension.RegisterPfUIActionEventHandler()
if not pfUI or Extension.actionHandlerRegistered then
@@ -429,7 +88,17 @@ function Extension.RegisterPfUIActionEventHandler()
-- Register a handler that will be called whenever CleveRoids updates macro states
if CleveRoids.RegisterActionEventHandler then
Extension.DLOG("Registering pfUI action event handler")
CleveRoids.RegisterActionEventHandler(function(slot, event, ...)
-- Declared without `...` on purpose: in 1.12's Lua 5.0 a vararg function
-- allocates an `arg` table on every call, and this handler never read it.
-- That mattered because UpdateAllManagedCooldowns fans
-- ACTIONBAR_UPDATE_COOLDOWN out across every managed slot -- up to 120 calls
-- -- on each SPELL_UPDATE_COOLDOWN, which fires on every GCD and cooldown
-- tick. Those calls all allocated a table and then did nothing, because the
-- whole body only ever applied to ACTIONBAR_SLOT_CHANGED. Hence the early
-- return before any work.
CleveRoids.RegisterActionEventHandler(function(slot, event)
if event ~= "ACTIONBAR_SLOT_CHANGED" then return end
local button = pfUI.bars and pfUI.bars.buttons and pfUI.bars.buttons[slot]
if Extension.Debug then
@@ -441,23 +110,21 @@ function Extension.RegisterPfUIActionEventHandler()
))
end
-- For slot change events, do a full button update so the icon,
-- cooldown, and tooltip refresh through CleveRoids' hooked
-- GetActionTexture / GetActionCooldown / GameTooltip:SetAction.
-- Full button update so the icon, cooldown and tooltip refresh.
-- pfUI's ButtonMacroScan defers to us for managed macros (leaves
-- spellslot nil), so its ButtonFullUpdate routes through those hooks
-- and follows the active conditional — no manual cooldown override
-- needed.
if event == "ACTIONBAR_SLOT_CHANGED" then
-- Mark the slot for update in pfUI's cache (processed next OnUpdate)
if pfUI.bars and pfUI.bars.update then
pfUI.bars.update[slot] = true
end
-- spellslot nil), so ButtonFullUpdate reads the stock action-bar
-- functions -- which now resolve through the value we publish with
-- C_Macro.SetMacroDisplay, rather than the Lua overrides this addon
-- used to install.
-- Also directly call ButtonFullUpdate if the button exists
if button and pfUI.bars.ButtonFullUpdate then
pfUI.bars.ButtonFullUpdate(button)
end
-- Mark the slot for update in pfUI's cache (processed next OnUpdate)
if pfUI.bars and pfUI.bars.update then
pfUI.bars.update[slot] = true
end
-- Also directly call ButtonFullUpdate if the button exists
if button and pfUI.bars.ButtonFullUpdate then
pfUI.bars.ButtonFullUpdate(button)
end
end)
@@ -474,9 +141,6 @@ function Extension.SetupCompatibility()
if Extension.pfUILoaded then
Extension.DLOG("pfUI detected")
-- Hook libdebuff for combo duration support
Extension.HookPfUILibdebuff()
-- Register action event handler for button updates
Extension.RegisterPfUIActionEventHandler()
@@ -631,16 +295,9 @@ function Extension.SetupPfUIEventHooks(lib)
ev:UnregisterEvent("AURA_CAST_ON_OTHER")
ev:UnregisterEvent("DEBUFF_ADDED_OTHER")
ev:UnregisterEvent("DEBUFF_REMOVED_OTHER")
ev:UnregisterEvent("BUFF_ADDED_OTHER")
ev:UnregisterEvent("BUFF_REMOVED_SELF")
ev:UnregisterEvent("BUFF_REMOVED_OTHER")
-- pfUI 7.6+ also handles cast tracking internally
if lib.hasPfUI76 then
ev:UnregisterEvent("SPELL_START_OTHER")
ev:UnregisterEvent("SPELL_FAILED_OTHER")
end
-- Keep registered: SPELL_START_SELF (channel duration capture before early return),
-- UNIT_DIED (AllCasterAuraTracking + OverflowBuff cleanup), UNIT_CASTEVENT (SuperWoW),
-- PLAYER_TARGET_CHANGED, UNIT_AURA (SeedUnit)
@@ -700,7 +357,6 @@ end
function Extension.OnLoad()
Extension.DLOG("Extension pfUI Loaded.")
Extension.HookMethod(CleveRoids, "GetFocusName", "FocusNameHook", true)
-- Export extension for external access
CleveRoids.Compatibility_pfUI = Extension
@@ -743,7 +399,7 @@ end
function Extension.OnPlayerLogin()
-- Ensure lib.objects is linked correctly (InitPfUIIntegration is idempotent).
if pfUI and not CleveRoids.hasPfUI76 then
if pfUI then
local lib = CleveRoids.libdebuff
if lib and lib.InitPfUIIntegration then
lib:InitPfUIIntegration()
@@ -775,9 +431,6 @@ function Extension.OnPlayerLogin()
-- Print startup status only if pfUI global exists and compatibility was set up
if Extension.pfUILoaded and pfUI then
local statusMsg = "|cff00ff00[SCRM]|r pfUI compatibility loaded"
if CleveRoids.hasPfUI76 then
statusMsg = statusMsg .. " (7.6+ GUID cast tracking)"
end
-- statusMsg = statusMsg .. ". Use /pfuicd for debug."
DEFAULT_CHAT_FRAME:AddMessage(statusMsg)
if not Extension.actionHandlerRegistered then
+93 -254
View File
@@ -779,14 +779,6 @@ CleveRoids.DownrankBlocked = CleveRoids.DownrankBlocked or {}
function CleveRoids.GetAuraTrackingData(targetGuid)
if not targetGuid then return nil, false end
-- pfUI path: read directly from pfUI's table (has downrank protection built-in)
if CleveRoids.hasPfUI76 and pfUI and pfUI.libdebuff_all_auras then
local data = pfUI.libdebuff_all_auras[targetGuid]
if data then return data, true end
-- Fall through: our table may have test entries even when pfUI is active
end
-- Standalone path (or pfUI had no data for this GUID)
local data = CleveRoids.AllCasterAuraTracking[targetGuid]
if data then return data, false end
return nil, false
@@ -854,74 +846,6 @@ function CleveRoids.GetAllCasterAuraTimeRemaining(targetGuid, spellId)
return nil
end
-- Helper to find aura by name (or spell ID string) for a target
-- Returns player's entry for personal debuffs, any caster for shared auras.
-- Reads from pfUI.libdebuff_all_auras when pfUI 7.6+ is active.
function CleveRoids.FindAllCasterAuraByName(targetGuid, searchName)
if not targetGuid or not searchName then return nil, nil end
local targetData, isPfUI = CleveRoids.GetAuraTrackingData(targetGuid)
if not targetData then return nil, nil end
-- Resolve spell ID to name for direct lookup
local searchID = tonumber(searchName)
if searchID then
local resolvedName = C_Spell.GetSpellName(searchID)
if not resolvedName then return nil, nil end
searchName = resolvedName
end
local now = GetTime()
-- Try exact match first (O(1) hash lookup)
local casters = targetData[searchName]
-- Case-insensitive fallback
if not casters then
local searchLower = string.lower(searchName)
for spellName, c in pairs(targetData) do
local baseName = CleveRoids.StripRank(spellName)
if string.lower(baseName) == searchLower then
casters = c
break
end
end
end
if not casters then return nil, nil end
local playerGuid = CleveRoids.GetGUID("player")
-- Always check player's own entry first
if playerGuid and casters[playerGuid] then
local auraData = casters[playerGuid]
local startTime = AuraStart(auraData, isPfUI)
if startTime and auraData.duration then
local remaining = auraData.duration + startTime - now
if remaining > 0 then return remaining, playerGuid end
end
end
-- Get a spellId from any entry to check personal vs shared
local anySpellId = nil
for _, aData in pairs(casters) do
anySpellId = aData.spellId
break
end
-- Personal debuff and player has no active entry → don't use other players' data
if IsPersonalAura(anySpellId, searchName) then return nil, nil end
-- Shared aura: return any active caster's entry
for cGuid, auraData in pairs(casters) do
local startTime = AuraStart(auraData, isPfUI)
if startTime and auraData.duration then
local remaining = auraData.duration + startTime - now
if remaining > 0 then return remaining, cGuid end
end
end
return nil, nil
end
-- HitInfo bitfield values (from NampowerAPI.lua, duplicated for local access)
-- Converted to decimal for Lua 5.0 compatibility (no hex literals)
local HITINFO_MISS = 16 -- 0x10
@@ -995,7 +919,7 @@ local function OnAutoAttackOther(attackerGuid, targetGuid, totalDamage, hitInfo,
end
-- Sync to pfUI if loaded (pre-7.6 only)
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff then
if pfUI and pfUI.api and pfUI.api.libdebuff then
local spellName = C_Spell.GetSpellName(spellID) or nil
local baseName = CleveRoids.StripRank(spellName)
local targetName = (lib.guidToName and lib.guidToName[normalizedTarget]) or UnitName("target")
@@ -1137,31 +1061,11 @@ local function OnAuraCastSelf(spellId, casterGuid, targetGuid, effect, effectAur
end
end
-- NEW: Populate ownBuffCasts and allBuffAuras for all player buffs (not just overflow)
-- Use the isBuffNotDebuff result determined above (avoids redundant slot scanning)
local lib = CleveRoids.libdebuff
if isBuffNotDebuff and spellId and durationMs and durationMs > 0 and lib and not lib.hasPfUIEnhanced then
local spellName = C_Spell.GetSpellName(spellId)
if spellName then
local playerGuid = CleveRoids.GetGUID("player")
if playerGuid then
lib.ownBuffCasts[playerGuid] = lib.ownBuffCasts[playerGuid] or {}
lib.ownBuffCasts[playerGuid][spellName] = {
startTime = now,
duration = durationMs / 1000,
spellId = spellId,
casterGuid = casterGuid,
}
lib.allBuffAuras[playerGuid] = lib.allBuffAuras[playerGuid] or {}
lib.allBuffAuras[playerGuid][spellName] = lib.allBuffAuras[playerGuid][spellName] or {}
lib.allBuffAuras[playerGuid][spellName][casterGuid or "unknown"] = {
startTime = now,
duration = durationMs / 1000,
rank = 0,
}
end
end
end
-- Removed: this populated lib.ownBuffCasts and lib.allBuffAuras for every player
-- buff. Both tables were write-only -- populated here and on BUFF_ADDED_OTHER,
-- swept periodically, cleared on removal and death, and never read for aura state.
-- Player buff timing now comes from C_UnitAuras, which reads expirationTime out of
-- the engine's own player-buff table.
end
local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAuraName,
@@ -1175,7 +1079,7 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu
-- full downrank protection — we read from that table via GetAuraTrackingData().
if spellId and durationMs and durationMs > 0 then
local spellName = C_Spell.GetSpellName(spellId)
if spellName and not CleveRoids.hasPfUI76 then
if spellName then
CleveRoids._allCasterAuraDirty = true
if not CleveRoids.AllCasterAuraTracking[targetGuid] then
CleveRoids.AllCasterAuraTracking[targetGuid] = {}
@@ -1198,7 +1102,7 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu
string.format("|cffff6600[AuraTrack]|r %s Rank %d blocked by Rank %d (%.1fs left) on %s",
spellName, newRank, existingRank, timeleft,
string.sub(tostring(targetGuid), 1, 16)))
spellName = nil -- skip pendingBuffCasts below too
spellName = nil -- downranked: don't record this cast
end
end
end
@@ -1228,24 +1132,6 @@ local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAu
string.sub(tostring(casterGuid), 1, 16), durationMs / 1000))
end
-- Store in pendingBuffCasts for BUFF_ADDED_OTHER to confirm as buff
-- (AURA_CAST_ON_OTHER fires for both buffs and debuffs; BUFF_ADDED_OTHER confirms buff)
local lib = CleveRoids.libdebuff
if lib and not lib.hasPfUIEnhanced then
local spellNameForPending = C_Spell.GetSpellName(spellId)
if spellNameForPending then
local normTargetGuid = CleveRoids.NormalizeGUID(targetGuid)
if normTargetGuid then
lib.pendingBuffCasts[normTargetGuid] = lib.pendingBuffCasts[normTargetGuid] or {}
lib.pendingBuffCasts[normTargetGuid][spellId] = {
casterGuid = CleveRoids.NormalizeGUID(casterGuid),
duration = durationMs / 1000,
spellName = spellNameForPending,
time = now,
}
end
end
end
end
-- Store cap status for this target GUID (if available)
@@ -1378,7 +1264,7 @@ autoAttackFrame:SetScript("OnEvent", function()
if spellId and spellId > 0 and durationMs and durationMs > 0 then
local playerGUID = CleveRoids.GetGUID("player")
local durSpellName = C_Spell.GetSpellName(spellId)
if playerGUID and durSpellName and not CleveRoids.hasPfUI76 then
if playerGUID and durSpellName then
CleveRoids._allCasterAuraDirty = true
if not CleveRoids.AllCasterAuraTracking[playerGUID] then
CleveRoids.AllCasterAuraTracking[playerGUID] = {}
@@ -3478,6 +3364,51 @@ function CleveRoids.ClearSpellNameCaches()
_baseNameCacheSize = 0
end
-- Resolve one aura on `unit` straight from ClassicAPI. This is the source of truth
-- for aura state: C_UnitAuras reads the unit's own descriptor, and its Aura::Source
-- cache reconstructs duration/expirationTime for ANY unit from the observed
-- SMSG_SPELL_GO -- caster-modified, so talent extensions and combo-point finisher
-- scaling are already applied, and Carnage's roll-gated Rip/Rake refresh is handled
-- in the DLL rather than mirrored here.
--
-- Matching stays ours: by spellID when the conditional gave a number, else by
-- lowercased name (C_UnitAuras' own by-name lookup is case-sensitive and exact,
-- which would miss [debuff:thunder_clap]).
--
-- Returns found, stacks, remaining, spellId. `remaining` is -1 for an aura with no
-- duration (permanent), nil when ClassicAPI has no timing for it -- an aura cast
-- before we logged in, or one refreshed at max stacks -- and seconds otherwise.
-- Presence and stacks are always reliable; only timing is best-effort.
local function ResolveUnitAuraViaClassicAPI(unit, searchID, searchName, isbuff)
local filter = isbuff and "HELPFUL" or "HARMFUL"
local i = 1
while i <= 48 do
local name, _, count, _, duration, expirationTime, _, _, _, spellId =
C_UnitAuras.UnitAura(unit, i, filter)
if not name then break end
local hit
if searchID then
hit = (spellId == searchID)
elseif searchName then
hit = (_string_lower(name) == searchName)
end
if hit then
local remaining
if expirationTime and expirationTime > 0 then
remaining = expirationTime - GetTime()
if remaining < 0 then remaining = 0 end
elseif duration == 0 then
remaining = -1 -- no duration: permanent aura
end
return true, count or 0, remaining, spellId
end
i = i + 1
end
return false
end
function CleveRoids.ValidateAura(unit, args, isbuff)
if not args or not UnitExists(unit) then return false end
@@ -3736,148 +3667,56 @@ function CleveRoids.ValidateAura(unit, args, isbuff)
end
end
-- allBuffAuras fallback for player buff timing: when slow path found the buff but
-- returned no remaining time, check lib.allBuffAuras for cached AURA_CAST timing.
if found and remaining == nil and isPlayer and isbuff and searchName then
local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil
if lib and lib.allBuffAuras then
local playerGuid = CleveRoids.GetGUID("player")
if playerGuid and lib.allBuffAuras[playerGuid] then
-- Try exact name match first
local casters = lib.allBuffAuras[playerGuid][args.name]
-- Try lowercase match if exact didn't work
if not casters then
for bName, c in pairs(lib.allBuffAuras[playerGuid]) do
if _string_lower(bName) == searchName then
casters = c
break
end
end
end
if casters then
for _, cData in pairs(casters) do
local elapsed = GetTime() - (cData.startTime or 0)
remaining = cData.duration > 0 and (cData.duration - elapsed) or -1
break
end
end
end
-- Player timing gap-fill, from ClassicAPI. Replaces the lib.allBuffAuras lookup
-- that cached AURA_CAST start/duration for this: for the player, C_UnitAuras reads
-- expirationTime out of the engine's own player-buff table, so it is the more
-- authoritative source, not a fallback. Only runs when the scans above found the
-- aura but produced no time.
if found and remaining == nil and isPlayer and (searchID or searchName) then
local _, _, capRemaining = ResolveUnitAuraViaClassicAPI(unit, searchID, searchName, isbuff)
if capRemaining ~= nil then
remaining = capRemaining
end
end
-- Non-player overflow fallback: buff exists in server slots 33-48 (no client slot)
-- AllCasterAuraTracking already has data from AURA_CAST_ON_OTHER for all aura applications.
-- If the normal scan didn't find the buff, check there for presence + duration.
-- Guard: verify the spell isn't a visible debuff on the target (AllCasterAuraTracking
-- stores both buffs and debuffs, so without this check [buff:DebuffName] could false-positive).
if not found and not isPlayer and isbuff and (searchID or searchName) then
local targetGuid = CleveRoids.GetGUID(unit)
if targetGuid then
-- Check if the spell is in a visible debuff slot — if so, it's a debuff, not a buff
local isDebuff = false
local di = 1
while true do
local dtex, _, _, dspellId = UnitDebuff(unit, di)
if not dtex then break end
if dspellId then
if searchID then
if dspellId == searchID then
isDebuff = true
break
end
elseif searchName then
local lowerName = GetLowercaseSpellName(dspellId)
if lowerName and lowerName == searchName then
isDebuff = true
break
end
end
end
di = di + 1
end
if not isDebuff then
local trackRemaining = CleveRoids.FindAllCasterAuraByName(targetGuid,
searchID and tostring(searchID) or args.name)
if trackRemaining then
found = true
stacks = 0
remaining = trackRemaining
end
end
end
end
-- Removed: the non-player overflow fallback. It existed because a buff can sit in
-- server slots 33-48 with no client slot, so it read presence/duration out of
-- AllCasterAuraTracking, guarded by a UnitDebuff slot scan to stop [buff:Name]
-- matching a debuff (that table stores both). ClassicAPI makes all of it moot: its
-- HELPFUL/HARMFUL filters select on each aura's real polarity flag rather than
-- which slot range it happens to occupy, so "a debuff parked in a buff slot still
-- reads harmful" (docs/API.md) and an overflowed buff still reads helpful. The
-- ClassicAPI resolution above therefore already covers the overflow case, and it
-- classifies more accurately than the slot-range guard did.
local ops = CleveRoids.operators
local cmp = CleveRoids.comparators
-- For non-player units with time comparisons, try to get time from tracking systems
-- Non-player aura timing, straight from ClassicAPI. This replaces the old
-- lib.allBuffAuras lookup and the AllCasterAuraTracking / FindAllCasterAuraByName
-- fallback beneath it: both existed only because vanilla cannot report a timer for
-- an aura on another unit, which the Aura::Source cache now does. It also drops
-- the libdebuff UnitBuff timeleft bug those comments worked around.
local nonPlayerAuraTimeRemaining = nil
if not isPlayer and args.name then
-- NOTE: libdebuff's UnitBuff has a bug where timeleft returns incorrect values
-- (showing ~1000s instead of actual remaining time). Skip it for buff time checks
-- and rely on all-caster tracking from AURA_CAST events instead.
-- Fast path: Check lib.allBuffAuras (spellName-indexed, O(1) lookup)
-- More efficient than FindAllCasterAuraByName which does ID→name translation
if nonPlayerAuraTimeRemaining == nil and isbuff then
local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil
if lib and lib.allBuffAuras then
local targetGuid = CleveRoids.GetGUID(unit)
if targetGuid then
local buffEntries = lib.allBuffAuras[targetGuid]
if buffEntries then
-- Try exact name match first
local casters = buffEntries[args.name]
-- Try lowercase match if exact didn't work
if not casters and searchName then
for bName, c in pairs(buffEntries) do
if _string_lower(bName) == searchName then
casters = c
break
end
end
end
if casters then
for _, cData in pairs(casters) do
local elapsed = GetTime() - (cData.startTime or 0)
local rem = cData.duration > 0 and (cData.duration - elapsed) or -1
if rem == nil or rem > 0 or cData.duration <= 0 then
nonPlayerAuraTimeRemaining = rem
if not found then
found = true
stacks = 0
end
break
end
end
end
end
end
if not isPlayer and (searchID or searchName) then
local capFound, capStacks, capRemaining =
ResolveUnitAuraViaClassicAPI(unit, searchID, searchName, isbuff)
if capFound then
if not found then
found = true
stacks = capStacks or 0
end
-- Left nil when ClassicAPI has no timing (aura predates login, or a
-- max-stack refresh); the caller then falls back to its found-and-0 default.
nonPlayerAuraTimeRemaining = capRemaining
end
-- Second try: All-caster tracking from AURA_CAST events (works for any caster)
-- Only use if libdebuff didn't find it (libdebuff has more accurate timing for player casts)
if nonPlayerAuraTimeRemaining == nil then
local targetGuid = CleveRoids.GetGUID(unit)
if targetGuid then
local remaining, casterGuid = CleveRoids.FindAllCasterAuraByName(targetGuid, args.name)
-- Debug output when enabled
if CleveRoids.debug then
local hasData = CleveRoids.AllCasterAuraTracking[targetGuid] ~= nil
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cffff9900[AuraLookup]|r %s on GUID %s: hasData=%s, remaining=%s",
tostring(args.name), string.sub(tostring(targetGuid), 1, 16),
tostring(hasData), tostring(remaining)
))
end
if remaining then
nonPlayerAuraTimeRemaining = remaining
end
end
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(string.format(
"|cffff9900[AuraLookup]|r %s on %s: found=%s, stacks=%s, remaining=%s",
tostring(args.name), tostring(unit), tostring(capFound),
tostring(capStacks), tostring(capRemaining)
))
end
end
+9 -8
View File
@@ -358,14 +358,15 @@ SlashCmdList.RUNMACRO = function(msg)
return CleveRoids.ExecuteMacroByName(CleveRoids.Trim(msg))
end
-- Global RunMacro wrapper for user convenience (delegates to namespaced internal function)
-- This pattern ensures internal logic uses CleveRoids.ExecuteMacroByName and won't break
-- if another addon overwrites the global RunMacro
-- NOTE: When SuperMacro is also loaded, Compatibility/SuperMacro.lua redirects this to
-- SuperMacro_RunMacro so macros go through RunLine (where CRM commands are intercepted)
function RunMacro(name)
return CleveRoids.ExecuteMacroByName(name)
end
-- The global RunMacro is installed by Core.lua, which loads before this file. Do not
-- redefine it here: Core's hook accepts a macro index as well as a name (Blizzard's
-- RunMacro takes either), delegates to SuperMacro when that is driving execution,
-- falls back to the saved Blizzard original when a macro will not resolve, and clears
-- the stop/skip flags at the start of a top-level run, which is what lets /stopmacro,
-- /skipmacro and /firstaction work across parent/child macro boundaries. A plain
-- name-only wrapper here silently replaced all of that.
-- When SuperMacro is loaded, Compatibility/SuperMacro.lua redirects the global to
-- SuperMacro_RunMacro so macros go through RunLine (where CRM commands are intercepted).
SLASH_RETARGET1 = "/retarget"
SlashCmdList.RETARGET = function(msg)
+350 -792
View File
File diff suppressed because it is too large Load Diff
+40 -24
View File
@@ -697,30 +697,9 @@ local function EnsureCondHighlightPool()
end
end
-- Test whether conditionals pass for a given command + alternative text.
-- Returns: true (passes), false (fails), nil (unconditional / no conditionals)
local function TestConditionalPasses(cmd, alternative)
-- Strip ? tooltip hints (irrelevant for conditional evaluation)
if string.find(alternative, "?", 1, true) then
alternative = string.gsub(alternative, "%?", "")
end
local hasConditional = string.find(alternative, "%[") ~= nil
-- Dynamic commands: delegate to TestAction
if CleveRoids.dynamicCmds[cmd] then
local result = CleveRoids.TestAction(cmd, alternative)
if not hasConditional then
return nil -- unconditional
end
return result ~= nil and result ~= false
end
-- Non-dynamic commands: parse and evaluate Keywords manually
if not hasConditional then
return nil -- unconditional
end
-- Evaluate one single-group clause of a non-dynamic command against Keywords.
-- Returns: true (passes), false (fails), nil (could not parse)
local function EvaluateNonDynamic(alternative)
local ok, action, conditionals = pcall(CleveRoids.GetParsedMsg, alternative)
if not ok or not conditionals then
return nil
@@ -745,6 +724,43 @@ local function TestConditionalPasses(cmd, alternative)
return passes
end
-- Test whether conditionals pass for a given command + alternative text.
-- Returns: true (passes), false (fails), nil (unconditional / no conditionals)
local function TestConditionalPasses(cmd, alternative)
-- Strip ? tooltip hints (irrelevant for conditional evaluation)
if string.find(alternative, "?", 1, true) then
alternative = string.gsub(alternative, "%?", "")
end
local hasConditional = string.find(alternative, "%[") ~= nil
-- Dynamic commands: delegate to TestAction
if CleveRoids.dynamicCmds[cmd] then
local result = CleveRoids.TestAction(cmd, alternative)
if not hasConditional then
return nil -- unconditional
end
return result ~= nil and result ~= false
end
-- Non-dynamic commands: evaluate Keywords manually. `[a][b] X` passes when
-- any of its groups does.
if not hasConditional then
return nil -- unconditional
end
local variants = CleveRoids.ExpandBracketGroups(alternative)
if not variants then
return EvaluateNonDynamic(alternative)
end
for i = 1, variants.n do
if EvaluateNonDynamic(variants[i]) then
return true
end
end
return false
end
-- Find character offset ranges for each semicolon-separated alternative.
-- Returns array of { start, finish } pairs (1-based indices within argsText).
local function FindAlternativeOffsets(argsText)
+3 -1
View File
@@ -423,7 +423,9 @@ function CleveRoids.IndexActionSlot(slot)
end
end
end
CleveRoids.TestForActiveAction(CleveRoids.GetAction(slot))
local actions = CleveRoids.GetAction(slot)
CleveRoids.TestForActiveAction(actions)
CleveRoids.PublishDisplay(actions)
CleveRoids.SendEventForAction(slot, "ACTIONBAR_SLOT_CHANGED", slot)
end
+18 -5
View File
@@ -24,7 +24,22 @@ CleveRoids.hasSuperwow = SetAutoloot and true or false
CleveRoids.hasTurtle = (type(_G.TURTLE_WOW_VERSION) ~= "nil")
CleveRoids.supported = CleveRoids.hasTurtle
-- ClassicAPI macro display (C_Macro.SetMacroDisplay): hand ClassicAPI the resolved
-- action per macro instead of replacing the action-bar globals in Lua, so the icon,
-- tooltip, cooldown sweep, range and usable state all come from the client -- including
-- the drag cursor and the macro window grid, which Lua cannot reach.
--
-- Feature-detect rather than version-check: the API is unreleased, so
-- CLASSIC_API_VERSION reports the dev sentinel. ClassicAPI stands down from macro
-- display entirely when it sees this addon loaded; ClassicAPIMacroDisplay is what
-- tells it we drive it instead. A fork that leaves the flag unset keeps the old
-- behavior -- both must never drive the same buttons.
CleveRoids.useClassicAPIDisplay =
(type(C_Macro) == "table" and C_Macro.SetMacroDisplay ~= nil) and true or false
CleveRoids.ClassicAPIMacroDisplay = CleveRoids.useClassicAPIDisplay
CleveRoids.ParsedMsg = {}
CleveRoids.ExpandedGroups = {}
CleveRoids.Items = {}
CleveRoids.Spells = {}
CleveRoids.PetSpells = {}
@@ -43,13 +58,11 @@ CleveRoids.unknownTexture = "Interface\\Icons\\INV_Misc_QuestionMark"
CleveRoids.spell_tracking = {}
-- GUID-based cast tracking (populated by pfUI 7.6 or standalone SPELL_START events)
-- Format: [casterGuid] = {spellID, spellName, icon, startTime, duration, endTime}
-- GUID-based cast tracking, populated from our own SPELL_START handlers and pruned
-- in OnUpdate. Format: [casterGuid] = {spellID, spellName, icon, startTime, duration,
-- endTime}
CleveRoids.castTracking = {}
-- pfUI 7.6+ with Nampower 2.31.0+ detected (GUID-based cast tracking available)
CleveRoids.hasPfUI76 = false
-- Combo point tracking (initialized early for /cast hook)
CleveRoids.lastComboPoints = 0
CleveRoids.lastComboPointsTime = 0
+27 -16
View File
@@ -561,7 +561,6 @@ end
local ERROR_TYPES = {
INVALID_CONDITIONAL = "Invalid conditional",
MISMATCHED_BRACKETS = "Mismatched brackets",
EMPTY_CONDITIONAL = "Empty conditional block",
INVALID_OPERATOR = "Invalid operator",
MISSING_ARGUMENT = "Missing argument",
INVALID_COMMAND = "Unknown command",
@@ -849,16 +848,19 @@ local function validateLine(line, lineNum)
actionPart = "/" .. actionPart -- Add leading slash if missing after split
end
-- Parse conditionals if present - use non-greedy match
local condStart = safeStringFind(actionPart, "%[")
-- Parse the leading run of [conditional] blocks, if any. `[a][b] X` is
-- Blizzard-style OR chaining: every block is validated, and the action
-- is what follows the last one.
local blocks = {}
local condEnd = nil
local conditionBlock = nil
local condStart = safeStringFind(actionPart, "%[")
local len = safeStringLen(actionPart)
if condStart then
while condStart do
-- Find matching closing bracket
local depth = 0
local inQuotes = false
local len = safeStringLen(actionPart)
local closePos = nil
for i = condStart, len do
local char = safeStringSub(actionPart, i, i)
@@ -872,23 +874,30 @@ local function validateLine(line, lineNum)
elseif char == "]" then
depth = depth - 1
if depth == 0 then
condEnd = i
conditionBlock = safeStringSub(actionPart, condStart + 1, i - 1)
closePos = i
break
end
end
end
end
if not closePos then break end
table.insert(blocks, safeStringSub(actionPart, condStart + 1, closePos - 1))
condEnd = closePos
-- Another block directly after this one (whitespace allowed)?
local _, wsEnd = safeStringFind(actionPart, "^%s*", closePos + 1)
local nextPos = (wsEnd or closePos) + 1
if safeStringSub(actionPart, nextPos, nextPos) == "[" then
condStart = nextPos
else
condStart = nil
end
end
if conditionBlock then
if safeTrim(conditionBlock) == "" then
table.insert(localErrors, {
type = ERROR_TYPES.EMPTY_CONDITIONAL,
line = lineNum,
message = "Empty conditional block []"
})
else
for _, conditionBlock in ipairs(blocks) do
-- `[]` is the always-true group; nothing to validate
if safeTrim(conditionBlock) ~= "" then
-- Check for invalid @ target syntax
local _, _, target = safeStringFind(conditionBlock, "(@[^%s,]+)")
if target and not safeStringFind(target, "^@[a-z]+%d*") then
@@ -936,7 +945,9 @@ local function validateLine(line, lineNum)
end
end
end
end
if condEnd then
-- Check for action after conditionals
-- Extract the command from this action part
local _, _, cmdFromAction = safeStringFind(actionPart, "^(/[a-z]+%d*)")
+1
View File
@@ -27,6 +27,7 @@ Enhanced macro addon for World of Warcraft 1.12.1 (Vanilla/Turtle WoW) with dyna
- **Arguments** use colon: `[mod:alt]`, `[hp:>50]`
- **Negation** with `no` prefix: `[nobuff]`, `[nomod:alt]`
- **Target** with `@`: `[@mouseover,help]`, `[@party1,hp:<50]`
- **Fallback groups**, Blizzard-style: `[@mouseover,help][@focus,help][] Rejuvenation` tries each `[...]` in order and the first that passes casts the shared spell; `[]` always passes. Mixes freely with `;`
- **Spell names** with spaces: `"Mark of the Wild"` or `Mark_of_the_Wild`
**Multi-value logic:**
+105 -334
View File
@@ -614,6 +614,76 @@ function CleveRoids.splitStringIgnoringQuotes(str, separator)
return result
end
-- Splits a macro clause into its leading run of [group] blocks and the action
-- that follows, Blizzard-style: `[a][b] Spell` is two groups sharing one action.
-- Returns prefix (the leading whitespace and ? ! ~ flag characters, kept so a
-- variant re-parses exactly like the original), groups (array with .n, each
-- entry including its brackets) and restStart (index of the action text; 1 when
-- there are no groups). Quote-aware for " so a ] inside a quoted argument does
-- not close a group; not nesting-aware. An unclosed [ ends the scan at that [,
-- which is what the old greedy "%[(.+)%]" produced for such input.
function CleveRoids.ScanBracketGroups(msg)
local groups = { n = 0 }
if not msg then return "", groups, 1 end
local len = string.len(msg)
local _, prefixEnd = string.find(msg, "^[%s%?!~]*")
local prefix = string.sub(msg, 1, prefixEnd)
local i = prefixEnd + 1
while i <= len and string.sub(msg, i, i) == "[" do
local close = nil
local inQuotes = false
for j = i + 1, len do
local c = string.sub(msg, j, j)
if c == "\"" then
inQuotes = not inQuotes
elseif c == "]" and not inQuotes then
close = j
break
end
end
if not close then break end
groups.n = groups.n + 1
groups[groups.n] = string.sub(msg, i, close)
local _, wsEnd = string.find(msg, "^%s*", close + 1)
i = wsEnd + 1
end
if groups.n == 0 then
return prefix, groups, 1
end
return prefix, groups, i
end
-- Single-group variants of a multi-group clause: `[a][b] Spell` becomes
-- { "[a] Spell", "[b] Spell", n = 2 }. nil for anything with fewer than two
-- groups so callers take their normal path without allocating. Memoised per
-- clause string in CleveRoids.ExpandedGroups (false marks "nothing to expand").
function CleveRoids.ExpandBracketGroups(msg)
if not msg or not string.find(msg, "%[") then return nil end
local cached = CleveRoids.ExpandedGroups[msg]
if cached ~= nil then
return cached or nil
end
local variants = nil
local prefix, groups, restStart = CleveRoids.ScanBracketGroups(msg)
if groups.n > 1 then
local rest = string.sub(msg, restStart)
if rest ~= "" then rest = " " .. rest end
variants = { n = groups.n }
for i = 1, groups.n do
variants[i] = prefix .. groups[i] .. rest
end
end
CleveRoids.ExpandedGroups[msg] = variants or false
return variants
end
function CleveRoids.Print(...)
local c = "|cFF4477FFCleveR|r|cFFFFFFFFoid :: |r"
local out = ""
@@ -773,17 +843,10 @@ lib.iconCache = lib.iconCache or {} -- [spellId] = texture (shared with
-- Buff tracking tables (parallel to debuff tables, standalone Nampower mode only)
-- Buff tables are NOT linked to pfUI — pfUI has no public buff tracking tables.
-- They remain empty when hasPfUIEnhanced=true since all buff event handlers are gated by it.
lib.ownBuffCasts = lib.ownBuffCasts or {} -- [targetGUID][buffName] = {startTime, duration, spellId, casterGuid}
-- Player's own buff casts on self or others (AURA_CAST events)
lib.allBuffAuras = lib.allBuffAuras or {} -- [targetGUID][buffName][casterGuid] = {startTime, duration, rank}
-- All buffs from all casters on any unit (confirmed by BUFF_ADDED events)
lib.pendingBuffCasts = lib.pendingBuffCasts or {} -- [targetGUID][spellId] = {casterGuid, duration, spellName, time}
-- Temp storage from AURA_CAST_ON_OTHER, consumed by BUFF_ADDED_OTHER
-- Flag indicating whether enhanced pfUI tracking is available
lib.hasPfUIEnhanced = false
lib.hasStandaloneNampower = false
lib.hasPfUI76 = false
-- Check if pfUI v7.4.3+ with enhanced libdebuff is available
function lib:HasEnhancedPfUILibdebuff()
@@ -844,33 +907,6 @@ function lib:HasEnhancedPfUILibdebuff()
return true
end
-- Check if pfUI v7.6+ with enhanced cast tracking is available
-- pfUI 7.6+ requires Nampower v2.37.0+ and exposes additional tables
function lib:HasPfUI76()
if not pfUI then return false end
local v = pfUI.version
if not v or not v.major then return false end
-- Version comparison: 7.6+
if v.major < 7 then return false end
if v.major == 7 and (v.minor or 0) < 6 then return false end
-- Verify Nampower v2.40.0+ (pfUI 7.6+ hard requirement, bumped from 2.38 on 2026-02-21;
-- v2.40.0 fixes packed GUID parsing that caused target GUIDs to appear as 0x000000000
-- for some players, which directly affects cast tracking reliability)
if not GetNampowerVersion then return false end
local npMajor, npMinor, npPatch = GetNampowerVersion()
npPatch = npPatch or 0
if npMajor < 2 then return false end
if npMajor == 2 and npMinor < 40 then return false end
-- Verify the new tables exist
if not pfUI.libdebuff_casts then return false end
if not pfUI.libdebuff_objects_guid then return false end
return true
end
-- Icon caching helper: DBC lookup via GetSpellRecField
function lib:GetCachedIcon(spellId)
@@ -904,19 +940,6 @@ function lib:InitPfUIIntegration()
lib.hasPfUIEnhanced = true
lib.hasStandaloneNampower = false
-- Check for pfUI 7.6+ additional tables (cast tracking, GUID objects, icon cache)
if lib:HasPfUI76() then
CleveRoids.hasPfUI76 = true
lib.hasPfUI76 = true
CleveRoids.castTracking = pfUI.libdebuff_casts
lib.iconCache = pfUI.libdebuff_icon_cache or lib.iconCache
-- lib.objects is already set by pfUI's CleveRoids.libdebuff = libdebuff override
-- but explicitly sync if pfUI.libdebuff_objects_guid is available
if pfUI.libdebuff_objects_guid then
lib.objects = pfUI.libdebuff_objects_guid
end
end
-- Unregister chat log events since SPELL_GO provides miss detection
if CleveRoidsLibDebuffLearnFrame then
CleveRoidsLibDebuffLearnFrame:UnregisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
@@ -933,10 +956,9 @@ function lib:InitPfUIIntegration()
if CleveRoids.debug then
local v = pfUI.version
local tierMsg = lib.hasPfUI76 and " (7.6+ cast tracking)" or ""
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff33ff99[libdebuff]|r pfUI v%d.%d.%d enhanced tracking enabled%s",
v.major, v.minor, v.fix or 0, tierMsg)
string.format("|cff33ff99[libdebuff]|r pfUI v%d.%d.%d enhanced tracking enabled",
v.major, v.minor, v.fix or 0)
)
end
@@ -1306,57 +1328,6 @@ function lib:CleanupStaleTrackingData()
end
end
-- Clean ownBuffCasts (player-cast buffs on targets)
if lib.ownBuffCasts then
for guid, buffs in pairs(lib.ownBuffCasts) do
for buffName, data in pairs(buffs) do
local elapsed = now - (data.startTime or 0)
local dur = data.duration or 0
-- Remove if expired (duration > 0 and past end time) or stale (no duration and old)
if (dur > 0 and elapsed > dur) or (dur <= 0 and elapsed > staleTime) then
buffs[buffName] = nil
end
end
if not next(buffs) then
lib.ownBuffCasts[guid] = nil
end
end
end
-- Clean allBuffAuras (all-caster buff tracking)
if lib.allBuffAuras then
for guid, buffs in pairs(lib.allBuffAuras) do
for buffName, casters in pairs(buffs) do
for cGuid, data in pairs(casters) do
local elapsed = now - (data.startTime or 0)
local dur = data.duration or 0
if (dur > 0 and elapsed > dur) or (dur <= 0 and elapsed > staleTime) then
casters[cGuid] = nil
end
end
if not next(casters) then
buffs[buffName] = nil
end
end
if not next(buffs) then
lib.allBuffAuras[guid] = nil
end
end
end
-- Clean pendingBuffCasts (short-lived correlation data, 2 second TTL)
if lib.pendingBuffCasts then
for guid, spells in pairs(lib.pendingBuffCasts) do
for spellId, data in pairs(spells) do
if (now - (data.time or 0)) > 2 then
spells[spellId] = nil
end
end
if not next(spells) then
lib.pendingBuffCasts[guid] = nil
end
end
end
end
-- Get the caster GUID for a debuff on a target
@@ -2141,7 +2112,7 @@ function lib:ShouldApplyDebuffRank(targetGUID, newSpellID)
end
-- Also clean up pfUI's tracking to prevent it from showing old ranks
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff and targetName then
if pfUI and pfUI.api and pfUI.api.libdebuff and targetName then
local pflib = pfUI.api.libdebuff
if pflib.objects and pflib.objects[targetName] then
@@ -2291,38 +2262,6 @@ function lib:AddEffect(guid, unitName, spellID, duration, stacks, caster)
end
end
-- PFUI INTEGRATION: Inject all tracked debuffs into pfUI's libdebuff (pre-7.6 only)
-- pfUI 7.6+ handles all duration tracking internally via GetUnitField
if pfUI and pfUI.api and pfUI.api.libdebuff and unitName and not CleveRoids.hasPfUI76 then
local pflib = pfUI.api.libdebuff
local spellName = C_Spell.GetSpellName(spellID)
if spellName and pflib.AddEffect then
-- Get target level for pfUI's tracking structure
local targetLevel = UnitLevel(guid) or UnitLevel("target") or 1
-- Strip rank from spell name for pfUI (it uses base names)
local baseName = CleveRoids.StripRank(spellName)
-- Also register the duration in pfUI's duration table
if pflib.debuffs then
pflib.debuffs[baseName] = duration
end
-- Add the effect to pfUI's tracking
-- Use "player" as caster for pfUI compatibility (it expects this format)
pflib:AddEffect(unitName, targetLevel, baseName, duration, "player")
if CleveRoids.debug then
local casterStr = (caster == "player") and "player" or "other"
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00ff00[pfUI Inject]|r %s (%ds) on %s (level %d) [caster: %s]",
baseName, duration, unitName, targetLevel, casterStr)
)
end
end
end
if CleveRoids.debug then
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
CleveRoids.DebugChanged("addeffect_" .. spellID .. "_" .. tostring(guid),
@@ -2528,20 +2467,6 @@ local function SeedUnit(unit)
existing.start = GetTime()
existing.duration = duration
-- PFUI INTEGRATION: Inject refreshed timer into pfUI (pre-7.6 only)
if pfUI and pfUI.api and pfUI.api.libdebuff and unitName and not CleveRoids.hasPfUI76 then
local pflib = pfUI.api.libdebuff
local spellName = C_Spell.GetSpellName(spellID)
if spellName and pflib.AddEffect then
local targetLevel = UnitLevel(unit) or 1
local baseName = CleveRoids.StripRank(spellName)
if pflib.debuffs then
pflib.debuffs[baseName] = duration
end
pflib:AddEffect(unitName, targetLevel, baseName, duration, "player")
end
end
if CleveRoids.debug then
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
DEFAULT_CHAT_FRAME:AddMessage(
@@ -2617,20 +2542,6 @@ local function SeedUnit(unit)
existing.start = GetTime()
existing.duration = duration
-- PFUI INTEGRATION: Inject refreshed timer into pfUI (pre-7.6 only)
if pfUI and pfUI.api and pfUI.api.libdebuff and unitName and not CleveRoids.hasPfUI76 then
local pflib = pfUI.api.libdebuff
local spellName = C_Spell.GetSpellName(spellID)
if spellName and pflib.AddEffect then
local targetLevel = UnitLevel(unit) or 1
local baseName = CleveRoids.StripRank(spellName)
if pflib.debuffs then
pflib.debuffs[baseName] = duration
end
pflib:AddEffect(unitName, targetLevel, baseName, duration, "player")
end
end
if CleveRoids.debug then
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
DEFAULT_CHAT_FRAME:AddMessage(
@@ -2890,7 +2801,7 @@ function lib.ApplyCarnageRefresh(targetGUID, targetName, biteSpellID)
-- DON'T call pfUI's AddEffect - just update the existing entry directly
-- pfUI will pick up the new duration through our GetDuration/UnitDebuff hooks
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff then
if pfUI and pfUI.api and pfUI.api.libdebuff then
local pflib = pfUI.api.libdebuff
local ripSpellName = C_Spell.GetSpellName(ripSpellID)
local baseName = CleveRoids.StripRank(ripSpellName) or "Rip"
@@ -3003,7 +2914,7 @@ function lib.ApplyCarnageRefresh(targetGUID, targetName, biteSpellID)
-- DON'T call pfUI's AddEffect - just update the existing entry directly
-- pfUI will pick up the new duration through our GetDuration/UnitDebuff hooks
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff then
if pfUI and pfUI.api and pfUI.api.libdebuff then
local pflib = pfUI.api.libdebuff
local rakeSpellName = C_Spell.GetSpellName(rakeSpellID)
local baseName = CleveRoids.StripRank(rakeSpellName) or "Rake"
@@ -4227,7 +4138,6 @@ if CleveRoids.hasNampower then
-- BUFF_ADDED/REMOVED events require v2.30+ for auraSlot and state args
if npMajor > 2 or (npMajor == 2 and npMinor >= 30) then
ev:RegisterEvent("BUFF_ADDED_OTHER")
ev:RegisterEvent("BUFF_REMOVED_SELF")
ev:RegisterEvent("BUFF_REMOVED_OTHER")
end
@@ -4586,7 +4496,7 @@ ev:SetScript("OnEvent", function()
-- Update pfUI's duration database directly (pre-7.6 only)
-- pfUI 7.6+ handles combo durations internally via GetStoredComboPoints()
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs then
if pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs then
pfUI.api.libdebuff.debuffs[baseName] = duration
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
@@ -4842,12 +4752,6 @@ ev:SetScript("OnEvent", function()
end
end
-- Sync combo duration to pfUI if it's loaded
if comboPoints and CleveRoids.Compatibility_pfUI and
CleveRoids.Compatibility_pfUI.SyncComboDurationToPfUI then
CleveRoids.Compatibility_pfUI.SyncComboDurationToPfUI(targetGUID, spellID, duration)
end
-- ALWAYS set up learning for combo spells (even if we have calculated duration)
if comboPoints then
lib.learnCastTimers[targetGUID] = lib.learnCastTimers[targetGUID] or {}
@@ -5008,7 +4912,6 @@ ev:SetScript("OnEvent", function()
end
-- pfUI 7.6 manages castTracking via its own SPELL_START handler
if lib.hasPfUI76 then return end
local spellId = arg2
local casterGuid = arg3
@@ -5055,7 +4958,6 @@ ev:SetScript("OnEvent", function()
elseif event == "SPELL_FAILED_OTHER" then
-- pfUI 7.6 manages castTracking cleanup itself
if lib.hasPfUI76 then return end
local casterGuid = arg1
if casterGuid and CleveRoids.castTracking[casterGuid] then
@@ -5074,18 +4976,23 @@ ev:SetScript("OnEvent", function()
-- When pfUI is available, we use its tables directly instead.
elseif event == "SPELL_GO_SELF" or event == "SPELL_GO_OTHER" then
-- Skip if pfUI enhanced tracking is active (it handles this)
if lib.hasPfUIEnhanced then return end
-- Clear cast tracking entry - cast completed/fired (standalone mode only).
-- Clear the cast tracking entry FIRST, before the hasPfUIEnhanced bail below.
-- We always own castTracking (SPELL_START populates it unconditionally), whereas
-- hasPfUIEnhanced only gates the miss/debuff work further down. Clearing used to
-- sit after that bail, so with pfUI's enhanced libdebuff tables present
-- SPELL_START kept adding entries while this cleanup never ran and castTracking
-- grew without bound.
-- v2.40+: Save the SPELL_START targetGuid before clearing so we can fall back
-- to it below when SPELL_GO arg4 is empty (e.g. AoE spells with no single target).
local startTargetGuid
if not lib.hasPfUI76 and arg3 and CleveRoids.castTracking[arg3] then
if arg3 and CleveRoids.castTracking[arg3] then
startTargetGuid = CleveRoids.castTracking[arg3].targetGuid
CleveRoids.castTracking[arg3] = nil
end
-- Skip the remaining miss/debuff work if pfUI enhanced tracking is active
if lib.hasPfUIEnhanced then return end
local spellId = arg2
local casterGuid = arg3
-- v2.40+: SPELL_GO targetGuid is now correct for friendly player GUIDs.
@@ -5524,17 +5431,11 @@ ev:SetScript("OnEvent", function()
confirmed = true
}
-- Update pfUI's duration database directly (pre-7.6 only)
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs then
if pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs then
pfUI.api.libdebuff.debuffs[baseName] = debuffDuration
end
end
-- Sync combo duration to pfUI
if debuffComboPoints and CleveRoids.Compatibility_pfUI and
CleveRoids.Compatibility_pfUI.SyncComboDurationToPfUI then
CleveRoids.Compatibility_pfUI.SyncComboDurationToPfUI(targetGuid, spellId, debuffDuration)
end
-- Set up learning for combo spells
if debuffComboPoints then
lib.learnCastTimers[targetGuid] = lib.learnCastTimers[targetGuid] or {}
@@ -5816,49 +5717,6 @@ ev:SetScript("OnEvent", function()
end
end
-- NAMPOWER v2.30+ BUFF_ADDED_OTHER - Confirm pending AURA_CAST_ON_OTHER data as buff
elseif event == "BUFF_ADDED_OTHER" then
if lib.hasPfUIEnhanced then return end
local guid = CleveRoids.NormalizeGUID(arg1)
local spellId = arg3
local state = arg7 -- 0=added, 1=removed, 2=modified
if not guid or not spellId then return end
-- Consume pending AURA_CAST data for this buff
local pending = lib.pendingBuffCasts[guid] and lib.pendingBuffCasts[guid][spellId]
if pending then
local spellName = pending.spellName or (C_Spell.GetSpellName(spellId))
if spellName then
local casterGuid = pending.casterGuid
local duration = pending.duration
local now = GetTime()
-- allBuffAuras: confirmed buff from any caster
lib.allBuffAuras[guid] = lib.allBuffAuras[guid] or {}
lib.allBuffAuras[guid][spellName] = lib.allBuffAuras[guid][spellName] or {}
lib.allBuffAuras[guid][spellName][casterGuid or "unknown"] = {
startTime = now,
duration = duration or 0,
rank = 0,
}
-- ownBuffCasts: only if player is the caster
local playerGuid = CleveRoids.GetGUID("player")
if playerGuid and casterGuid == playerGuid then
lib.ownBuffCasts[guid] = lib.ownBuffCasts[guid] or {}
lib.ownBuffCasts[guid][spellName] = {
startTime = now,
duration = duration or 0,
spellId = spellId,
casterGuid = casterGuid,
}
end
end
lib.pendingBuffCasts[guid][spellId] = nil
end
-- NAMPOWER v2.30+ BUFF_REMOVED_SELF - Player's own buffs removed
elseif event == "BUFF_REMOVED_SELF" then
if lib.hasPfUIEnhanced then return end
@@ -5869,22 +5727,6 @@ ev:SetScript("OnEvent", function()
if state == 2 then return end -- Stack decrease only, not full removal
local spellName = C_Spell.GetSpellName(spellId)
local playerGuid = CleveRoids.GetGUID("player")
if spellName and playerGuid then
if lib.ownBuffCasts[playerGuid] then
lib.ownBuffCasts[playerGuid][spellName] = nil
if not next(lib.ownBuffCasts[playerGuid]) then
lib.ownBuffCasts[playerGuid] = nil
end
end
if lib.allBuffAuras[playerGuid] then
lib.allBuffAuras[playerGuid][spellName] = nil
if not next(lib.allBuffAuras[playerGuid]) then
lib.allBuffAuras[playerGuid] = nil
end
end
end
-- Also prune from OverflowBuffs (existing system)
if spellId and CleveRoids.OverflowBuffs and CleveRoids.OverflowBuffs[spellId] then
@@ -5910,28 +5752,6 @@ ev:SetScript("OnEvent", function()
if state == 2 then return end -- Stack decrease only, not full removal
local spellName = C_Spell.GetSpellName(spellId)
if spellName then
if lib.allBuffAuras[guid] then
lib.allBuffAuras[guid][spellName] = nil
if not next(lib.allBuffAuras[guid]) then
lib.allBuffAuras[guid] = nil
end
end
if lib.ownBuffCasts[guid] then
lib.ownBuffCasts[guid][spellName] = nil
if not next(lib.ownBuffCasts[guid]) then
lib.ownBuffCasts[guid] = nil
end
end
end
-- Clean pending correlation data
if lib.pendingBuffCasts[guid] then
lib.pendingBuffCasts[guid][spellId] = nil
if not next(lib.pendingBuffCasts[guid]) then
lib.pendingBuffCasts[guid] = nil
end
end
-- Also clean AllCasterAuraTracking (keyed by spellName)
if spellName and CleveRoids.AllCasterAuraTracking[guid] then
@@ -5983,18 +5803,9 @@ ev:SetScript("OnEvent", function()
end
-- Clean up buff tracking tables
if lib.ownBuffCasts[guid] then
lib.ownBuffCasts[guid] = nil
end
if lib.allBuffAuras[guid] then
lib.allBuffAuras[guid] = nil
end
if lib.pendingBuffCasts[guid] then
lib.pendingBuffCasts[guid] = nil
end
-- Clean up cast tracking for this unit (they can't be casting if dead)
if not lib.hasPfUI76 and CleveRoids.castTracking[guid] then
if CleveRoids.castTracking[guid] then
CleveRoids.castTracking[guid] = nil
end
@@ -6359,7 +6170,7 @@ evLearn:SetScript("OnEvent", function()
-- Sync refresh to pfUI
local targetName = lib.guidToName[targetGUID]
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff and targetName then
if pfUI and pfUI.api and pfUI.api.libdebuff and targetName then
local pflib = pfUI.api.libdebuff
local spellName = C_Spell.GetSpellName(flameShockID)
if spellName and pflib.AddEffect then
@@ -6411,15 +6222,7 @@ evCleanup:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" or event == "PLAYER_DEAD" then
-- Keep only current target's data
local currentGUID = CleveRoids.GetGUID("target")
if lib.hasPfUI76 then
-- pfUI76: lib.objects is pfUI.libdebuff_objects_guid - never replace the reference!
-- pfUI handles its own cleanup; just clear SCRM-side entries
for guid in pairs(lib.objects) do
if guid ~= currentGUID then
lib.objects[guid] = nil
end
end
elseif currentGUID then
if currentGUID then
local temp = lib.objects[currentGUID]
lib.objects = {}
if temp then
@@ -6536,7 +6339,7 @@ evJudgement:SetScript("OnEvent", function()
end
-- Also sync to pfUI if it's loaded (pre-7.6 only)
if not CleveRoids.hasPfUI76 and pfUI and pfUI.api and pfUI.api.libdebuff then
if pfUI and pfUI.api and pfUI.api.libdebuff then
local targetName = lib.guidToName[targetGUID] or UnitName("target")
local targetLevel = UnitLevel("target") or 0
local spellName = C_Spell.GetSpellName(spellID)
@@ -9246,52 +9049,20 @@ function CleveRoids.ParseReactiveCombatLog(lowerMsg)
end
end
-- Clear reactive proc when spell is cast
function CleveRoids.ClearReactiveProcOnCast(spellName)
if not spellName then return end
-- Check if this is a reactive spell
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells[spellName] then
CleveRoids.ClearReactiveProc(spellName)
CleveRoids.QueueActionUpdate()
end
end
-- Hook UNIT_CASTEVENT to clear reactive procs
local originalUnitCastEvent = CleveRoids.Frame and CleveRoids.Frame.UNIT_CASTEVENT
if originalUnitCastEvent then
CleveRoids.Frame.UNIT_CASTEVENT = function(...)
-- Call original handler first
if type(originalUnitCastEvent) == "function" then
originalUnitCastEvent(unpack(arg))
end
-- Clear reactive proc and resist state on spell cast start
if arg1 == "player" and arg2 == "START" and arg4 then
CleveRoids.ClearReactiveProcOnCast(arg4)
CleveRoids.ClearResistState()
end
end
end
-- Hook SPELL_START_SELF to clear reactive procs (Nampower fallback when SuperWoW not available)
local originalSpellStartSelf = CleveRoids.Frame and CleveRoids.Frame.SPELL_START_SELF
if originalSpellStartSelf and not CleveRoids.hasSuperwow then
CleveRoids.Frame.SPELL_START_SELF = function(...)
-- Call original handler first
if type(originalSpellStartSelf) == "function" then
originalSpellStartSelf(unpack(arg))
end
-- Clear reactive proc and resist state on spell cast start
-- SPELL_START_SELF args: casterGuid, targetGuid, spellId, ...
local spellId = arg[3]
if spellId then
CleveRoids.ClearReactiveProcOnCast(spellId)
CleveRoids.ClearResistState()
end
end
end
-- Removed: ClearReactiveProcOnCast plus wrappers around Frame.UNIT_CASTEVENT and
-- Frame.SPELL_START_SELF that tried to clear a reactive proc when its spell was cast.
-- The feature never worked, for three independent reasons:
-- 1. Both wrappers guarded on `CleveRoids.Frame and CleveRoids.Frame.<handler>`,
-- but CleveRoids.Frame is created in Core.lua, which loads AFTER this file, so
-- the guard was always false and neither wrapper was ever installed.
-- 2. The UNIT_CASTEVENT wrapper tested arg2 == "START", but that signature is
-- (caster, target, action, spell_id, cast_time) -- the action is arg3, so it was
-- comparing the target.
-- 3. Both passed a spell *ID* to a function that looks the name up in
-- CleveRoids.reactiveSpells, which is keyed by name, so nothing could match.
-- They also each allocated an `arg` table per call and unpack()'d it. Deleting is
-- behaviour-preserving; if clearing a reactive proc on cast is still wanted it needs
-- to be written fresh against the real handler signatures.
-- NAMPOWER v2.24+ AUTO_ATTACK EVENT HANDLER FOR REACTIVE ABILITIES
-- Uses native events for dodge/parry/block detection when available.
+57 -9
View File
@@ -25,10 +25,23 @@ API references are line numbers into `C:\Git\ClassicAPI\docs\API.md`.
`applications` (stacks), `duration`.
- **Unlocks:** `[dispellable]` / `[curse]` / `[magic]` target conditionals and
spellID-based (rank/locale-proof) aura matching.
- **Caveat:** `expirationTime` is only populated for `unit=="player"`; it's `0`
for target/focus (vanilla server limitation). Target debuff *timers* still
need the existing libdebuff tracking — only presence/stacks/school/spellId
are reliable cross-unit.
- **~~Caveat: `expirationTime` is player-only~~ — NO LONGER TRUE.** This note said
target/focus `expirationTime` was always `0`, so debuff *timers* had to stay on
libdebuff. ClassicAPI has since added the `Aura::Source` cache: for a non-player
unit `expirationTime` is reconstructed from the observed `SMSG_SPELL_GO`, and
`duration` is the **caster-modified** value (talent extensions like Improved
Shadow Word: Pain included). It also handles combo-point finisher scaling
automatically (Rupture at 4 CP reads 14s, no registration) and ships Carnage's
roll-gated Rip/Rake refresh in the DLL (`src/turtle/Carnage.cpp`, exposed as
`RegisterAuraDurationModifierByTrigger`).
- **Real remaining caveats** (all best-effort, from `docs/API.md`):
- Only auras observed *after login* carry caster/timing; older ones report
`expirationTime` 0 and `sourceUnit` nil.
- Max-stack refresh is a blind spot: re-applying at max stacks (Shadow Weaving
5→5) emits no client-visible change, so the entry elapses and evicts.
- Out-of-range group members are spell-ID only, with `applications` always 1.
- **Consequence:** most of libdebuff is now redundant — see
"libdebuff retirement" below.
- **DONE (slice 1 — dispel-type conditionals):** added `ClassicAPI.lua`
detection module (`CleveRoids.ClassicAPI`, mirrors NampowerAPI's
@@ -150,11 +163,13 @@ Findings from the full Conditionals.lua audit. These look like ClassicAPI
candidates but are **worse** than the current implementation — recorded so we
don't re-investigate.
- **Auras / `ValidateAura`** — `C_UnitAuras.expirationTime` is **player-only**
(`0` for every other unit). It cannot replace the nampower `GetUnitField`
batch read or the remote-duration tracking (libdebuff / overflow slots). The
dispel-*type* path already uses `C_UnitAuras` (that's the one thing it's good
for); aura *timing/stacks* on non-player units must stay on nampower/libdebuff.
- ~~**Auras / `ValidateAura`** — `C_UnitAuras.expirationTime` is player-only~~ —
**STALE, do not trust this entry.** It was written before the `Aura::Source`
cache landed. Non-player `expirationTime`/`duration` now work (caster-modified,
talent extensions included), so this is no longer a reason to keep libdebuff's
remote-duration tracking. See Tier 1 §1 and "libdebuff retirement" for the
current picture and the real caveats. The nampower `GetUnitField` batch read is
a separate question and has not been re-examined.
- **`GetCurrentShapeshiftIndex` form loop / `[stance]`·`[form]`** —
`GetShapeshiftFormID()` returns the **DBC form id** (Cat=1, Bear=5,
Shadowform=28…), NOT the 1-based **bar index** these conditionals compare
@@ -183,6 +198,39 @@ don't re-investigate.
added `[focus]`/`[nofocus]`. `/focus` is provided by ClassicAPI's companion
addon.
## libdebuff retirement
libdebuff exists because vanilla cannot report debuff durations on units other
than the player. ClassicAPI's `Aura::Source` cache now does exactly that, so most
of the library is redundant. This is a staged replacement, not a delete: measure
first, then remove per group.
Current footprint: 28 public `lib:` methods, ~726 internal references in
`Utility.lua`, consumers in 8 files (`Conditionals.lua` 45, `Compatibility/pfUI.lua`
39, `Core.lua` 9). Note pfUI did **not** delete its own libdebuff — v9.0.25 still
ships ~1695 lines of it, re-based on `C_UnitAuras.GetAuraDataByIndex` /
`GetAuraDataBySpellName`. "Re-base on C_UnitAuras", not "remove", is the precedent.
**Group A — replaceable by `C_UnitAuras` (do these first):**
`GetDuration`, `GetDebuffCaster`, `IsOurDebuff`, `UnitBuff`/`UnitDebuff`,
`FindPlayerDebuff`/`FindPlayerBuff`, `GetAllDebuffsOnTarget`, `GetCachedIcon`,
`ApplyCarnageRefresh`, and the Dark Harvest trio (`ApplyDarkHarvestStart`/`End`,
`GetDarkHarvestReduction`, `GetTimeRemainingWithDarkHarvest`) — ClassicAPI ships
Carnage refresh and Dark Harvest tick compression in the DLL.
**Group B — no `C_UnitAuras` equivalent, keep:**
`ShouldApplyDebuffRank`, `DidSpellFail`, `WasSpellReflected`, `DidTargetEvade`,
`ProcessMissReason`, `IsPersonalDebuff`, `GetSpellRank`/`GetSpellBaseName`,
`HasPendingCast`. These are miss/rank/learning logic, not aura state.
**Gate before removing Group A:** confirm parity in-game on one spell where
ClassicAPI does the hard part — Rip under Carnage. Compare
`CleveRoids.libdebuff:GetDuration(spellID)` against
`C_UnitAuras.GetUnitAuraBySpellID(unit, spellID).duration` and the derived
remaining (`expirationTime - GetTime()`), on a target you have debuffed. If those
agree across a Carnage proc, Group A can go. Watch the documented best-effort
gaps: an aura cast before you logged in, and refresh-at-max-stacks.
## Marginal / optional follow-ups
- **`[swimming]`** → `IsSwimming()` would drop the nampower-2.36 version gate +