diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb7dbac..1e414d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/Compatibility/pfUI.lua b/Compatibility/pfUI.lua index a237db9..398dd64 100644 --- a/Compatibility/pfUI.lua +++ b/Compatibility/pfUI.lua @@ -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 diff --git a/Conditionals.lua b/Conditionals.lua index 5dcf3dc..0cdc179 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -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 diff --git a/Console.lua b/Console.lua index 133a23f..9840df1 100644 --- a/Console.lua +++ b/Console.lua @@ -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) diff --git a/Core.lua b/Core.lua index 4930659..97eea0a 100644 --- a/Core.lua +++ b/Core.lua @@ -531,6 +531,12 @@ function CleveRoids.DisableAddon(reason) -- Mark state CleveRoids.disabled = true + -- Hand macro display back to ClassicAPI's own #showtooltip parser before we go + -- quiet, or every claimed macro would keep the last value we published. + if CleveRoids.ReleaseDisplays then + CleveRoids.ReleaseDisplays() + end + -- Stop main frame activity if CleveRoids.Frame then if CleveRoids.Frame.UnregisterAllEvents then @@ -773,7 +779,7 @@ function CleveRoids.TestForActiveAction(actions) else -- First pass: find first action with conditionals that passes for _, action in ipairs(actions.list) do - local result = CleveRoids.TestAction(action.cmd, action.args) + local result, passedClause = CleveRoids.TestAction(action.cmd, action.args) -- Check if action has conditionals local _, conditionals = CleveRoids.GetParsedMsg(action.args) @@ -800,6 +806,13 @@ function CleveRoids.TestForActiveAction(actions) if not newActiveAction then hasActive = false end else newActiveAction = action + -- Range/usable checks read the passing group's @unit, which for a + -- multi-group clause need not be group 1's (action.conditionals). + action.activeConditionals = nil + if passedClause and passedClause ~= action.args then + local _, passedConds = CleveRoids.GetParsedMsg(passedClause) + action.activeConditionals = passedConds + end -- Resolve nested macro references for #showtooltip propagation -- If inner macro doesn't resolve, continue to next action local macroName = CleveRoids.GetMacroNameFromAction(action.action) @@ -874,7 +887,8 @@ function CleveRoids.TestForActiveAction(actions) -- Enhanced nampower range check with spell ID support if IsSpellInRange then - local unit = actions.active.conditionals and actions.active.conditionals.target or "target" + local conds = actions.active.activeConditionals or actions.active.conditionals + local unit = conds and conds.target or "target" if unit == "focus" then unit = CleveRoids.GetFocusUnitId() elseif unit == "focustarget" then @@ -1087,6 +1101,79 @@ function CleveRoids.TestForActiveAction(actions) return changed end +-------------------------------------------------------------------------------- +-- ClassicAPI macro display +-------------------------------------------------------------------------------- + +-- The value to hand C_Macro.SetMacroDisplay for an active action. The nested hop +-- mirrors the {MacroName} fallback the GameTooltip.SetAction override used: when +-- `action` is a macro reference, `action.macro` holds the inner macro's +-- #showtooltip action info (see CreateActionInfo), and `action.action` is the +-- literal "{Name}" text, which names no spell or item. +local function DisplayValueFor(action) + if not action then return nil end + if type(action.macro) == "table" and action.macro.action then + return action.macro.action + end + return action.action +end + +-- Last value handed to SetMacroDisplay, per macro index. +-- +-- Publishing is NOT free. The client repaints every slot holding the macro through +-- its own notifier, and that comes back to us as ACTIONBAR_SLOT_CHANGED -> +-- ClearAction + IndexActionSlot -> TestForActiveAction -> publish. Publishing a value +-- that has not changed therefore re-indexes slots for nothing and feeds itself. +-- GetAction and IndexActionSlot both call in on paths that fire constantly, so the +-- no-op check below is what keeps that from turning into a churn loop. +local publishedDisplay = {} + +-- Publish one macro's resolved action. Nothing is re-evaluated for us, so this must +-- be called whenever the answer changes; the published value stands until replaced. +-- +-- `false` is deliberate when there is no active action: it means "this macro is mine, +-- nothing matched" and shows the question mark. Skipping the call instead would hand +-- the macro back to ClassicAPI's own #showtooltip parser. +function CleveRoids.PublishDisplay(actions) + if not CleveRoids.useClassicAPIDisplay then return end + local macroID = actions and actions.macroID + if not macroID then return end -- SuperMacro macros have no Blizzard index + + local value = DisplayValueFor(actions.active) or false + if publishedDisplay[macroID] == value then return end + publishedDisplay[macroID] = value + + C_Macro.SetMacroDisplay(macroID, value) +end + +-- Republish every parsed macro. Used after login and after a re-parse, since +-- ClassicAPI re-evaluates nothing for us. Covers macros that aren't on a bar too, +-- which is what keeps the macro window grid's icons correct. +function CleveRoids.PublishAllDisplays() + if not CleveRoids.useClassicAPIDisplay then return end + -- Forget what we published so every macro republishes once. Callers reach here + -- after login and after a re-parse, where a cached value could otherwise + -- suppress the publish a freshly rebuilt macro still needs. + publishedDisplay = {} + for _, macro in pairs(CleveRoids.Macros) do + if type(macro) == "table" and macro.actions and macro.actions.macroID then + CleveRoids.TestForActiveAction(macro.actions) + CleveRoids.PublishDisplay(macro.actions) + end + end +end + +-- Hand every macro back to ClassicAPI's own parser and stop claiming ownership. +function CleveRoids.ReleaseDisplays() + if not CleveRoids.useClassicAPIDisplay then return end + for i = 1, 36 do + C_Macro.SetMacroDisplay(i, nil) + end + publishedDisplay = {} + CleveRoids.ClassicAPIMacroDisplay = false + CleveRoids.useClassicAPIDisplay = false +end + -- PERFORMANCE: Static buffer references for hot path local _actionsToSlotsBuffer = CleveRoids._actionsToSlotsBuffer local _slotsBuffer = CleveRoids._slotsBuffer @@ -1130,10 +1217,16 @@ function CleveRoids.TestForAllActiveActions() local slots = actionsToSlots[actions] local stateChanged = CleveRoids.TestForActiveAction(actions) if stateChanged then - -- Send event to ALL slots that use this macro - local count = slots._count - for j = 1, count do - CleveRoids.SendEventForAction(slots[j], "ACTIONBAR_SLOT_CHANGED", slots[j]) + if CleveRoids.useClassicAPIDisplay then + -- Publishing repaints every slot holding this macro through the + -- client's own notifier, so the per-slot fan-out below is redundant. + CleveRoids.PublishDisplay(actions) + else + -- Send event to ALL slots that use this macro + local count = slots._count + for j = 1, count do + CleveRoids.SendEventForAction(slots[j], "ACTIONBAR_SLOT_CHANGED", slots[j]) + end end end -- Clear for reuse (reset count and clear buffer reference) @@ -1179,6 +1272,7 @@ function CleveRoids.GetAction(slot) if macro then actions = macro.actions CleveRoids.TestForActiveAction(actions) + CleveRoids.PublishDisplay(actions) CleveRoids.Actions[slot] = actions CleveRoids.SendEventForAction(slot, "ACTIONBAR_SLOT_CHANGED", slot) return actions @@ -1193,29 +1287,29 @@ end -- PERFORMANCE: Static buffer for arg backup local _originalArgsBuffer = CleveRoids._originalArgsBuffer -function CleveRoids.SendEventForAction(slot, event, ...) +-- Prebuilt global names. Building "arg" .. i inline cost a concatenation per +-- iteration, three ten-iteration loops per call, on a function that runs once per +-- action slot (up to 120 per full index). +local ARG_GLOBALS = { + "arg1", "arg2", "arg3", "arg4", "arg5", + "arg6", "arg7", "arg8", "arg9", "arg10", +} + +-- Takes eventArg explicitly rather than `...`: in 1.12's Lua 5.0 a vararg function +-- allocates an `arg` table on every call, and every caller passes exactly one extra +-- value (the slot). The old arg.n fan-out below collapsed to a single case. +function CleveRoids.SendEventForAction(slot, event, eventArg) local old_this = this -- PERFORMANCE: Reuse static buffer instead of creating table each call local original_global_args = _originalArgsBuffer for i = 1, 10 do - original_global_args[i] = _G["arg" .. i] + original_global_args[i] = _G[ARG_GLOBALS[i]] end - if type(arg) == "table" then - - local n_varargs_from_arg_table = arg.n or 0 - for i = 1, 10 do - if i <= n_varargs_from_arg_table then - _G["arg" .. i] = arg[i] - else - _G["arg" .. i] = nil - end - end - else - for i = 1, 10 do - _G["arg" .. i] = nil - end + _G.arg1 = eventArg + for i = 2, 10 do + _G[ARG_GLOBALS[i]] = nil end local button_to_call_event_on @@ -1253,7 +1347,7 @@ function CleveRoids.SendEventForAction(slot, event, ...) this = old_this for i = 1, 10 do - _G["arg" .. i] = original_global_args[i] + _G[ARG_GLOBALS[i]] = original_global_args[i] end -- FIX: Skip addon handler calls during initial indexing to prevent 120 rapid calls @@ -1261,30 +1355,9 @@ function CleveRoids.SendEventForAction(slot, event, ...) return end - if type(arg) == "table" and arg.n then - - if arg.n == 0 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event) end - elseif arg.n == 1 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1]) end - elseif arg.n == 2 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1], arg[2]) end - elseif arg.n == 3 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1], arg[2], arg[3]) end - elseif arg.n == 4 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1], arg[2], arg[3], arg[4]) end - elseif arg.n == 5 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1], arg[2], arg[3], arg[4], arg[5]) end - elseif arg.n == 6 then - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1], arg[2], arg[3], arg[4], arg[5], arg[6]) end - else - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do fn_h(slot, event, arg[1], arg[2], arg[3], arg[4], arg[5], arg[6], arg[7]) end - end - else - - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do - fn_h(slot, event) - end + local handlers = CleveRoids.actionEventHandlers + for i = 1, table.getn(handlers) do + handlers[i](slot, event, eventArg) end end @@ -1563,37 +1636,6 @@ function CleveRoids.FixEmptyTargetSetTarget(conditionals, name, hook) return false end --- Returns the name of the focus target or nil -function CleveRoids.GetFocusName() - return UnitName('focus') -end - --- Attempts to target the focus target. --- returns: Whether or not it succeeded -function CleveRoids.TryTargetFocus() - -- ClassicAPI native focus token: exact target switch, no name matching. - if UnitExists("focus") then - TargetUnit("focus") - return UnitExists("target") - end - - -- Fallback: pfUI / name-based focus. - local name = CleveRoids.GetFocusName() - - if not name then - return false - end - - TargetByName(name, true) - - if not UnitExists("target") or (string.lower(UnitName("target")) ~= name) then - -- The target switch failed (out of range, LoS, etc.) - return false - end - - return true -end - -- Returns the resolved token, or nil when no focus is set so @focus clauses -- silently fall through to the next macro alternative (no warning spam). function CleveRoids.GetFocusUnitId() @@ -1750,6 +1792,10 @@ local function BuildMacro(cacheKey, macroID, name, texture, body) body = body, actions = {}, } + -- Back-reference so the update loop, which walks `actions` objects rather than + -- macros, can name the macro to C_Macro.SetMacroDisplay. nil for SuperMacro + -- macros, which have no Blizzard index and so cannot be published. + macro.actions.macroID = macroID macro.actions.list = {} -- build a list of testable actions for the macro @@ -1861,12 +1907,20 @@ function CleveRoids.ParseMsg(msg) conditionals.ignoretooltip = ignorecount CleveRoids._ignoretooltip = ignorecount - -- capture a single [...] conditional block if present - local _, cbEnd, conditionBlock = string.find(msg, "%[(.+)%]") + -- Leading [group] blocks and the action after them. A clause with several + -- groups (`[a][b] Spell`) parses here as group 1 plus the shared action: + -- DoWithConditionals and TestAction expand such a clause into one-group + -- variants before parsing, so the callers that do parse a multi-group + -- clause directly only rely on the action and on conditionals being non-nil. + local _, groups, restStart = CleveRoids.ScanBracketGroups(msg) + local conditionBlock = nil + if groups.n > 0 then + conditionBlock = string.sub(groups[1], 2, -2) + end - -- split off flags/action after the condition block (or from start if none) + -- split off flags/action after the groups (or from the start if there are none) local _, _, noSpam, cancelAura, action = string.find( - string.sub(msg, (cbEnd or 0) + 1), + groups.n > 0 and string.sub(msg, restStart) or msg, "^%s*(!?)(~?)([^!~]+.*)" ) action = CleveRoids.Trim(action or "") @@ -2225,6 +2279,17 @@ function CleveRoids.AdvanceSequence(sequence) end function CleveRoids.TestAction(cmd, args) + -- `[a][b] Spell`: first passing group wins. The second return names the + -- clause that passed so the display path can read that group's @unit. + local variants = CleveRoids.ExpandBracketGroups(args) + if variants then + for i = 1, variants.n do + local r, passed = CleveRoids.TestAction(cmd, variants[i]) + if r then return r, passed end + end + return + end + local msg, conditionals = CleveRoids.GetParsedMsg(args) -- Nil-safe guards @@ -2253,26 +2318,12 @@ function CleveRoids.TestAction(cmd, args) return end - if conditionals.target == "focus" then + -- ClassicAPI's native focus token. No focus set: the clause fails quietly, + -- exactly as the cast path does, so display and execution agree. + if conditionals.target == "focus" or conditionals.target == "focustarget" then local focusUnitId = CleveRoids.GetFocusUnitId() - if focusUnitId then - conditionals.target = focusUnitId - else - if not CleveRoids.GetFocusName() then - return - end - conditionals.target = "target" - end - elseif conditionals.target == "focustarget" then - local focusUnitId = CleveRoids.GetFocusUnitId() - if focusUnitId then - conditionals.target = focusUnitId .. "target" - else - if not CleveRoids.GetFocusName() then - return - end - conditionals.target = "targettarget" - end + if not focusUnitId then return end + conditionals.target = focusUnitId .. (conditionals.target == "focustarget" and "target" or "") end if conditionals.target == "mouseover" then @@ -2308,7 +2359,7 @@ function CleveRoids.TestAction(cmd, args) CleveRoids._isTestingAction = false conditionals.target = origTarget - return CleveRoids.GetMacroNameFromAction(msg) or msg + return CleveRoids.GetMacroNameFromAction(msg) or msg, args end function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBeforeAction, action) @@ -2321,6 +2372,17 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo return false end + -- `[a][b] Spell`: try each group as its own clause, first pass wins -- the + -- same walk DoCast makes over `[a] Spell; [b] Spell`. + local variants = CleveRoids.ExpandBracketGroups(msg) + if variants then + for i = 1, variants.n do + local r = CleveRoids.DoWithConditionals(variants[i], hook, fixEmptyTargetFunc, targetBeforeAction, action) + if r then return r end + end + return false + end + local msg, conditionals = CleveRoids.GetParsedMsg(msg) -- Debug: Log parsed msg and action type @@ -2392,26 +2454,17 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo -- CleveRoids.SetHelp(conditionals) + -- ClassicAPI's native focus token. No focus set: fail the clause quietly so + -- `[@focus,...] X; X` and `[@focus][] X` fall through like @mouseover does, + -- instead of announcing "Invalid target" on the way past. if conditionals.target == "focus" or conditionals.target == "focustarget" then - local isFocusTarget = conditionals.target == "focustarget" local focusUnitId = CleveRoids.GetFocusUnitId() - - if focusUnitId then - -- Use the resolved pfUI unit token directly (avoids changing the player's target) - conditionals.target = focusUnitId .. (isFocusTarget and "target" or "") - needRetarget = false - else - -- return false if pfUI is installed and no focus is set instead of "invalid target" - if pfUI and pfUI.uf and (not pfUI.uf.focus or pfUI.uf.focus.label == nil or pfUI.uf.focus.label == "") then return false end - -- Fall back to targeting focus by name - if not CleveRoids.TryTargetFocus() then - UIErrorsFrame:AddMessage(SPELL_FAILED_BAD_TARGETS, 1.0, 0.0, 0.0, 1.0) - conditionals.target = origTarget - return false - end - conditionals.target = isFocusTarget and "targettarget" or "target" - needRetarget = true + if not focusUnitId then + conditionals.target = origTarget + return false end + conditionals.target = focusUnitId .. (conditionals.target == "focustarget" and "target" or "") + needRetarget = false end -- Resolve named raid marks (skull/cross/etc.) and mark1-mark8 to native mark# unit tokens @@ -2641,22 +2694,38 @@ local function ResolvePfCastUnit() return nil end +-- One /pfcast clause. If conditionals are present but no explicit @unit, inject the +-- pfUI-resolved unit so all conditionals ([help], [nodebuff:X], etc.) evaluate against +-- the same unit pfUI would cast on, and the final CastSpellByName gets the correct +-- unit token. Module-level to avoid a closure per call. +local function PfCastClause(v) + if string.find(v, "%[") and not string.find(v, "@") then + local unit = ResolvePfCastUnit() + if unit then + v = string.gsub(v, "%[", "[@" .. unit .. ",", 1) + end + end + return CleveRoids.DoWithConditionals(v, CleveRoids.Hooks.PFCAST_SlashCmd, CleveRoids.FixEmptyTarget, false, CastSpellByName) +end + -- /pfcast with CleveRoids conditionals: evaluate conditionals then cast via pfUI's mouseover chain. -- Called from the SlashCmdList.PFCAST hook (set up by Extensions/Mouseover/pfUI.lua after pfUI loads). function CleveRoids.DoPfCast(msg) local parts = CleveRoids.splitStringIgnoringQuotes(msg) for i = 1, table.getn(parts) do - local v = parts[i] - -- If conditionals are present but no explicit @unit, inject the pfUI-resolved unit so - -- all conditionals ([help], [nodebuff:X], etc.) evaluate against the same unit pfUI - -- would cast on, and the final CastSpellByName gets the correct unit token. - if string.find(v, "%[") and not string.find(v, "@") then - local unit = ResolvePfCastUnit() - if unit then - v = string.gsub(v, "%[", "[@" .. unit .. ",", 1) + -- Expand `[a][b] Spell` here rather than leaving it to DoWithConditionals so + -- every group without its own @unit gets the injection, not just the first. + local variants = CleveRoids.ExpandBracketGroups(parts[i]) + local handled + if variants then + for j = 1, variants.n do + handled = PfCastClause(variants[j]) + if handled then break end end + else + handled = PfCastClause(parts[i]) end - if CleveRoids.DoWithConditionals(v, CleveRoids.Hooks.PFCAST_SlashCmd, CleveRoids.FixEmptyTarget, false, CastSpellByName) then + if handled then if CleveRoids.stopOnCastFlag then CleveRoids.stopMacroFlag = true end @@ -2694,6 +2763,27 @@ function CleveRoids.DoTarget(msg) return false end + -- Conditional /target takes `;` clauses and `[a][b]` groups like /cast does: + -- the first clause or group that finds a unit wins. A single-group clause + -- falls through to the resolution below. + if msg and string.find(msg, "%[") then + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + local n = table.getn(parts) + if n > 1 then + for i = 1, n do + if CleveRoids.DoTarget(parts[i]) then return true end + end + return false + end + local variants = CleveRoids.ExpandBracketGroups(parts[1]) + if variants then + for i = 1, variants.n do + if CleveRoids.DoTarget(variants[i]) then return true end + end + return false + end + end + local action, conditionals = CleveRoids.GetParsedMsg(msg) if action ~= "" or type(conditionals) ~= "table" or not next(conditionals) then @@ -3975,6 +4065,12 @@ function CleveRoids.OnUpdate(self) CR._suppressActionHandlers = false CR.TestForAllActiveActions() + + -- First publish of the session. Deferred to here rather than done at + -- load because SetMacroDisplay returns false until the player exists + -- in the world. + CR.PublishAllDisplays() + CR.lastUpdate = time -- FIX: Force refresh ALL Blizzard action buttons after initialization @@ -4130,18 +4226,30 @@ function CleveRoids.OnUpdate(self) guid, cast = nextGuid, nextGuid and spell_tracking[nextGuid] end - -- Clean stale castTracking entries (standalone mode only, pfUI 7.6 manages its own) - if not CR.hasPfUI76 then - local ct = CR.castTracking - local ctGuid, ctEntry = next(ct) - while ctGuid do - local nextCtGuid = next(ct, ctGuid) - if ctEntry.endTime and time > ctEntry.endTime + 0.5 then + -- Clean stale castTracking entries. Instant casts are stored with endTime = nil + -- (SPELL_START sets it only when the cast has a duration), so an endTime-only + -- check could never evict them and every instant cast by every unit in range + -- accumulated forever. Fall back to startTime for those: the matching SPELL_GO + -- lands within a frame or two, so anything still here after the grace period is + -- an entry whose completion we never observed. + local ct = CR.castTracking + local ctGuid, ctEntry = next(ct) + while ctGuid do + local nextCtGuid = next(ct, ctGuid) + if ctEntry then + if ctEntry.endTime then + if time > ctEntry.endTime + 0.5 then + ct[ctGuid] = nil + end + elseif ctEntry.startTime and time > ctEntry.startTime + 1 then + -- No endTime: an instant cast. Its SPELL_GO lands within a frame or + -- two, so a second is generous, and anything older is an entry whose + -- completion we never saw. ct[ctGuid] = nil end - ctGuid = nextCtGuid - ctEntry = nextCtGuid and ct[nextCtGuid] end + ctGuid = nextCtGuid + ctEntry = nextCtGuid and ct[nextCtGuid] end -- PERFORMANCE OPTIMIZATION: Run memory cleanup less frequently (every 5 seconds instead of every frame) @@ -4210,104 +4318,8 @@ function CleveRoids.OnUpdate(self) end end --- Initialize the nested table for the GameTooltip hooks if it doesn't exist -if not CleveRoids.Hooks.GameTooltip then CleveRoids.Hooks.GameTooltip = {} end - --- Save the original GameTooltip.SetAction function before we override it -CleveRoids.Hooks.GameTooltip.SetAction = GameTooltip.SetAction - --- Now, define our custom version of the function -function GameTooltip.SetAction(self, slot) - if not slot then return end - local actions = CleveRoids.GetAction(slot) - - -- If this is our macro but has no active action, show just the macro name - if actions and not actions.active then - local macroName = GetActionText(slot) - if macroName then - GameTooltip:SetText(macroName) - GameTooltip:Show() - return - end - end - - local action_to_display_info = nil - if actions then - -- Only show spell/item tooltip when there's an active action - if actions.active then - action_to_display_info = actions.active - end - end - - if action_to_display_info and action_to_display_info.action then - local action_name = action_to_display_info.action - - -- NEW: Check if action is a slot ID for tooltip - local slotId = tonumber(action_name) - if slotId and slotId >= 1 and slotId <= 19 then - -- Use the more specific SetInventoryItem function to prevent conflicts with other addons. - GameTooltip:SetInventoryItem("player", slotId) - GameTooltip:Show() - return - end - -- End new logic - - local current_spell_data = CleveRoids.GetSpell(action_name) - if current_spell_data and current_spell_data.id then - GameTooltip:SetSpellByID(current_spell_data.id) - GameTooltip:Show() - return - end - - local current_item_data = CleveRoids.GetItem(action_name) - if current_item_data then - -- Use specific functions based on where the item is located. - if current_item_data.inventoryID then - GameTooltip:SetInventoryItem("player", current_item_data.inventoryID) - elseif current_item_data.bagID and current_item_data.slot then - GameTooltip:SetBagItem(current_item_data.bagID, current_item_data.slot) - else - -- Fallback to the original method if location is unknown. - GameTooltip:SetHyperlink(current_item_data.link) - end - GameTooltip:Show() - return - end - - if action_to_display_info.macro and type(action_to_display_info.macro) == "table" then - local nested_action_info = action_to_display_info.macro - local nested_action_name = nested_action_info.action - - current_spell_data = CleveRoids.GetSpell(nested_action_name) - if current_spell_data and current_spell_data.id then - if current_spell_data.spellSlot and current_spell_data.bookType then - GameTooltip:SetSpell(current_spell_data.spellSlot, current_spell_data.bookType) - else - GameTooltip:SetSpellByID(current_spell_data.id) - end - GameTooltip:Show() - return - end - - current_item_data = CleveRoids.GetItem(nested_action_name) - if current_item_data then - if current_item_data.inventoryID then - GameTooltip:SetInventoryItem("player", current_item_data.inventoryID) - elseif current_item_data.bagID and current_item_data.slot then - GameTooltip:SetBagItem(current_item_data.bagID, current_item_data.slot) - else - GameTooltip:SetHyperlink(current_item_data.link) - end - GameTooltip:Show() - return - end - end - end - - -- If none of our custom logic handled it, call the original function we saved earlier. - CleveRoids.Hooks.GameTooltip.SetAction(self, slot) -end - +-- Not a display override: this invalidates our per-slot caches when an action is +-- dragged off a slot, so it stays regardless of who draws the button. CleveRoids.Hooks.PickupAction = PickupAction function PickupAction(slot) if not slot then return end @@ -4317,224 +4329,14 @@ function PickupAction(slot) return CleveRoids.Hooks.PickupAction(slot) end -CleveRoids.Hooks.ActionHasRange = ActionHasRange -function ActionHasRange(slot) - if not slot then return nil end - local actions = CleveRoids.GetAction(slot) - -- Only override for our macros with #showtooltip - if actions and actions.tooltip then - -- If we have an active action with valid range data, use it - if actions.active then - if actions.active.inRange ~= -1 then - return 1 -- Has range check with valid data - else - -- For channeled spells (inRange == -1), try proxy slot lookup - local spellName = actions.active.spell and actions.active.spell.name - local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) - if proxySlot then - return CleveRoids.Hooks.ActionHasRange(proxySlot) - end - end - end - -- FIX: Even without actions.active, if the macro has potential spell actions, - -- return 1 to ensure pfUI calls IsActionInRange (which will do proper checks) - -- This fixes Super macros where actions.active might not be populated yet - if actions.tooltip.spell or actions.tooltip.type == "spell" then - return 1 -- Tooltip is a spell - has range - end - -- Check if any action in the list is a spell - if actions.list then - for i = 1, table.getn(actions.list) do - local action = actions.list[i] - if action and (action.spell or action.type == "spell") then - return 1 -- Has at least one spell action - end - end - end - end - -- Not a macro we're tracking - pass through to original - return CleveRoids.Hooks.ActionHasRange(slot) -end - -CleveRoids.Hooks.IsActionInRange = IsActionInRange -function IsActionInRange(slot, unit) - if not slot then return nil end - local actions = CleveRoids.GetAction(slot) - -- Only override for our macros with #showtooltip - if actions and actions.tooltip then - -- If we have an active spell action, use its range data - if actions.active and actions.active.type == "spell" then - if actions.active.inRange ~= -1 then - return actions.active.inRange - else - -- For channeled spells (inRange == -1), try proxy slot lookup - local spellName = actions.active.spell and actions.active.spell.name - local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) - if proxySlot then - return CleveRoids.Hooks.IsActionInRange(proxySlot, unit) - end - end - end - -- FIX: If no active action but macro has tooltip spell, check its range directly - -- This ensures range coloring works even when actions.active hasn't been populated - if not actions.active and actions.tooltip.spell then - local spell = actions.tooltip.spell - local spellName = spell.name - if spellName and IsSpellInRange then - local targetUnit = unit or "target" - if UnitExists(targetUnit) then - -- FIX: Wrap in pcall - spell might not be in spellbook (items, other class spells, etc.) - local ok, result = pcall(IsSpellInRange, spellName, targetUnit) - if ok then - if result == 0 then - return 0 -- Out of range - elseif result == 1 then - return 1 -- In range - end - end - end - end - -- Try proxy slot as fallback - local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) - if proxySlot then - return CleveRoids.Hooks.IsActionInRange(proxySlot, unit) - end - end - end - -- Not a macro we're tracking - pass through to original - return CleveRoids.Hooks.IsActionInRange(slot, unit) -end - +-- These two aliases outlived the overrides they were saved for: ClassicAPI now +-- draws the buttons, but callers still need the unmodified globals, which is all +-- these names are now. IsReactiveUsable (Conditionals.lua) reads the raw usable +-- state of a proxy slot; DeferStopAttack and the auto-attack drift check read the +-- true current-action state, which the old override could not report because it +-- only echoed our cached flag. CleveRoids.Hooks.OriginalIsUsableAction = IsUsableAction -CleveRoids.Hooks.IsUsableAction = IsUsableAction -function IsUsableAction(slot, unit) - if not slot then return nil, nil end - local actions = CleveRoids.GetAction(slot) - - -- If this is one of our macros AND it uses #showtooltip - if actions and actions.tooltip then - -- IMPORTANT: Only override usability when #showtooltip is present - -- Macros without #showtooltip should use default game behavior - if actions.active then - -- We have an active action - return its usable state - -- FIX: Convert oom boolean to number (1/nil) for Blizzard's button code - local oomValue = actions.active.oom and 1 or nil - return actions.active.usable, oomValue - else - -- FIX: If no active action but tooltip has a spell, check its usability directly - -- This ensures usability coloring works even when actions.active hasn't been populated - if actions.tooltip.spell then - local spell = actions.tooltip.spell - local spellName = spell.name - if spellName and IsSpellUsable then - -- FIX: Wrap in pcall - spell might not be in spellbook (items, other class spells, etc.) - local ok, usable, notEnoughMana = pcall(IsSpellUsable, spellName) - if ok then - if usable == 1 then - return 1, nil -- Usable - elseif notEnoughMana == 1 then - return nil, 1 -- Out of mana - else - return nil, nil -- Not usable (wrong stance, etc.) - end - end - end - -- Fallback: check if spell has a cost and compare to current mana - if spell.cost and spell.cost > 0 then - local currentMana = UnitMana("player") - if currentMana < spell.cost then - return nil, 1 -- Out of mana - end - end - -- Default to usable if we can't determine otherwise - return 1, nil - elseif actions.tooltip.item then - -- FIX: Handle item tooltips - items are always usable unless on cooldown - -- The cooldown is handled separately by GetActionCooldown hook - return 1, nil - end - -- No tooltip spell or item - this macro's conditionals all failed - -- Return nil to make the icon dark - return nil, nil - end - else - -- Not our macro OR no #showtooltip - use game's default behavior - return CleveRoids.Hooks.IsUsableAction(slot, unit) - end -end - CleveRoids.Hooks.IsCurrentAction = IsCurrentAction -function IsCurrentAction(slot) - if not slot then return nil end - local actions = CleveRoids.GetAction(slot) - - -- When no action is active (all conditionals failed), don't check tooltip's "current" status - if actions and not actions.active and not actions.explicitTooltip and actions.list and table.getn(actions.list) > 0 then - return CleveRoids.Hooks.IsCurrentAction(slot) - end - - -- Use the same priority as GetActionTexture: active first, then tooltip - local actionToCheck = (actions and actions.active) or (actions and actions.tooltip) - - if not actionToCheck then - return CleveRoids.Hooks.IsCurrentAction(slot) - else - local name - if actionToCheck.spell then - if CleveRoids.IsAutoAttackSpell(actionToCheck.spell) then - return CleveRoids.CurrentSpell.autoAttack and 1 or nil - end - - local rank = actionToCheck.spell.rank or actionToCheck.spell.highest.rank - name = actionToCheck.spell.name..(rank and ("("..rank..")")) - - -- Check if this spell is currently queued or being cast via Nampower - -- Get spell ID for comparison - local spellId = actionToCheck.spell.id - if not spellId and GetSpellIdForName then - spellId = GetSpellIdForName(name) - end - - if spellId then - -- Prefer GetCastInfo (Nampower 2.18+) for cleaner API - if GetCastInfo then - local ok, info = pcall(GetCastInfo) - if ok and info and info.spellId == spellId then - -- Spell is actively being cast/channeled - return true - end - end - - -- Also check GetCurrentCastingInfo for queued spell detection - if GetCurrentCastingInfo then - local castId, visId, autoId, casting, channeling = GetCurrentCastingInfo() - - -- Show glow if actively casting/channeling this spell - if (casting == 1 and castId == spellId) or (channeling == 1 and visId == spellId) then - return true - end - -- Show glow if this spell is queued (castId set but not yet casting) - if casting == 0 and channeling == 0 and castId == spellId then - return true - end - end - end - elseif actionToCheck.item then - name = actionToCheck.item.name - end - - return CleveRoids.Hooks.IsCurrentAction(CleveRoids.GetProxyActionSlot(name) or slot) - end -end - -local function GetSlotMacroTexture(slot) - local kind, macroId = CleveRoids.ClassicAPI.GetActionInfo(slot) - if kind == "macro" and macroId then - local _, texture = GetMacroInfo(macroId) - return texture - end - return nil -end local function IsAutoAttackSpell(spell) if not spell then return false end @@ -4548,258 +4350,6 @@ local function IsAutoAttackSpell(spell) end CleveRoids.IsAutoAttackSpell = IsAutoAttackSpell -CleveRoids.Hooks.GetActionTexture = GetActionTexture -function GetActionTexture(slot) - if not slot then return nil end - local actions = CleveRoids.GetAction(slot) - - -- Check if this is one of our macros - if actions and (actions.active or actions.tooltip) then - - -- This block handles the case where all conditionals fail and no explicit - -- #showtooltip was set. It defaults to the macro's chosen icon. - if not actions.active and not actions.explicitTooltip and actions.list and table.getn(actions.list) > 0 then - -- Get the macro's own icon as fallback - local macroTexture = GetSlotMacroTexture(slot) - - -- When no conditionals pass, use macro icon (not first action's icon) - -- The actions.tooltip is just the first action which didn't pass conditionals - if macroTexture then - return macroTexture - end - - -- Should never reach here, but return unknown as last resort - return CleveRoids.unknownTexture - end - - -- Prioritize active action, fall back to tooltip - local a = actions.active or actions.tooltip - - -- Handle numeric slot actions (e.g., /use 13) - local slotId = tonumber(a.action) - if slotId and slotId >= 1 and slotId <= 19 then - local currentTexture = GetInventoryItemTexture("player", slotId) - if currentTexture then - return currentTexture - end - - -- Slot is empty, fall back to macro icon - local macroTexture = GetSlotMacroTexture(slot) - if macroTexture then - return macroTexture - end - return CleveRoids.unknownTexture - end - - -- *** THIS IS THE FIX *** - -- If an action is active, return its texture directly. - -- If no action is active, return the tooltip's texture. - -- If neither has a texture, fall back to the macro's icon. - local texture = (actions.active and actions.active.texture) or (actions.tooltip and actions.tooltip.texture) - - -- Check if this is a shapeshift form spell and use active texture if toggled on - if a and a.spell and a.action then - -- Strip rank info and underscores from spell name for comparison - local spellName = string.gsub(a.action, "%s*%(.-%)%s*$", "") - spellName = string.gsub(spellName, "_", " ") - -- Check all shapeshift forms to see if this spell matches and is active - for i = 1, GetNumShapeshiftForms() do - local icon, name, isActive, isCastable = GetShapeshiftFormInfo(i) - if name and string.lower(name) == string.lower(spellName) and isActive and icon then - texture = icon - break - end - end - end - - -- Check if this is a toggled buff ability (Prowl, Shadowmeld) and swap icon based on buff state - -- Note: Stealth is handled above by shapeshift form logic - if a and a.spell and a.action then - local spellName = string.gsub(a.action, "%s*%(.-%)%s*$", "") - spellName = string.gsub(spellName, "_", " ") - - -- Check if this is one of our toggled buff abilities (not shapeshift forms) - local toggledAbilities = { - [CleveRoids.Localized.Spells["Prowl"]] = true, - [CleveRoids.Localized.Spells["Shadowmeld"]] = true, - } - - if toggledAbilities[spellName] then - -- Check if the buff is active - if CleveRoids.ValidatePlayerBuff(spellName) then - -- Buff is active, use the active texture from auraTextures - local activeTexture = CleveRoids.auraTextures[spellName] - if activeTexture then - texture = activeTexture - end - end - end - end - - if a and a.spell and CleveRoids.IsAutoAttackSpell(a.spell) then - local mainHandTexture = GetInventoryItemTexture("player", 16) - if mainHandTexture then - texture = mainHandTexture - end - end - - if texture then - return texture - end - - -- Final fallback: get the macro's icon - local macroTexture = GetSlotMacroTexture(slot) - if macroTexture then - return macroTexture - end - - -- Should never reach here - return CleveRoids.unknownTexture - - end - - -- Not one of our macros, use the original function - return CleveRoids.Hooks.GetActionTexture(slot) -end - --- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed -CleveRoids.Hooks.GetActionCooldown = GetActionCooldown -function GetActionCooldown(slot) - -- Guard against nil/invalid slot - if not slot then return 0, 0, 0 end - - local actions = CleveRoids.GetAction(slot) - -- Check for actions.active OR actions.tooltip - if actions and (actions.active or actions.tooltip) then - - -- When no action is active (all conditionals failed) but tooltip exists from #showtooltip, - -- don't show the tooltip's cooldown - fall back to the original function (no cooldown) - -- This matches the icon behavior in GetActionTexture (lines 3958-3977) - if not actions.active and not actions.explicitTooltip and actions.list and table.getn(actions.list) > 0 then - return CleveRoids.Hooks.GetActionCooldown(slot) - end - - -- Prioritize the active action, but fall back to the tooltip action - local a = actions.active or actions.tooltip - - local slotId = tonumber(a.action) - if slotId and slotId >= 1 and slotId <= 19 then - return GetInventoryItemCooldown("player", slotId) - end - - if a.spell and a.spell.spellSlot then - return GetSpellCooldown(a.spell.spellSlot, a.spell.bookType) - elseif a.item then - if a.item.bagID and a.item.slot then - return GetContainerItemCooldown(a.item.bagID, a.item.slot) - elseif a.item.inventoryID then - return GetInventoryItemCooldown("player", a.item.inventoryID) - end - end - - -- DEBUG: Log when we fall through without finding spell/item data (once per slot) - if CleveRoids.cooldownDebug and not CleveRoids.cooldownDebugLogged[slot] then - CleveRoids.cooldownDebugLogged[slot] = true - DEFAULT_CHAT_FRAME:AddMessage(string.format( - "|cffff0000[CD Issue]|r Slot %d: No cooldown data! action='%s' type=%s spell=%s item=%s", - slot, - tostring(a.action), - tostring(a.type), - tostring(a.spell ~= nil), - tostring(a.item ~= nil) - )) - DEFAULT_CHAT_FRAME:AddMessage("|cffff8800[CD Issue]|r This slot will show incorrect cooldown. Report macro text to developer.") - end - - -- Fallback to original function (preserves SuperWoW's #showtooltip handling) - return CleveRoids.Hooks.GetActionCooldown(slot) - else - return CleveRoids.Hooks.GetActionCooldown(slot) - end -end - -CleveRoids.Hooks.GetActionCount = GetActionCount -function GetActionCount(slot) - -- Guard against nil/invalid slot - if not slot then return 0 end - - local action = CleveRoids.GetAction(slot) - local count - - -- When no action is active (all conditionals failed), don't show tooltip's count - if action and not action.active and not action.explicitTooltip and action.list and table.getn(action.list) > 0 then - return CleveRoids.Hooks.GetActionCount(slot) - end - - -- Use the same priority as GetActionTexture: active first, then tooltip - local actionToCheck = (action and action.active) or (action and action.tooltip) - if actionToCheck then - - local slotId = tonumber(actionToCheck.action) - if slotId and slotId >= 1 and slotId <= 19 then - return GetInventoryItemCount("player", slotId) - end - - if actionToCheck.item then - count = CleveRoids.GetLiveItemCount(actionToCheck.item.name or actionToCheck.action) - - elseif actionToCheck.spell then - local reagentId = actionToCheck.spell.reagentId - if not reagentId then - local ss, bt = actionToCheck.spell.spellSlot, actionToCheck.spell.bookType - if ss and bt then - local _, _, rid = CleveRoids.GetSpellCost(ss, bt, actionToCheck.spell.id) - actionToCheck.spell.reagentId = rid -- cache itemID so we don't re-derive every frame - reagentId = rid - end - end - if reagentId then - count = CleveRoids.GetReagentCount(reagentId) -- id-based bag scan - end - end - end - - return count or CleveRoids.Hooks.GetActionCount(slot) -end - -CleveRoids.Hooks.IsConsumableAction = IsConsumableAction -function IsConsumableAction(slot) - -- Guard against nil/invalid slot - if not slot then return nil end - - local action = CleveRoids.GetAction(slot) - - -- When no action is active (all conditionals failed), don't show tooltip's consumable status - if action and not action.active and not action.explicitTooltip and action.list and table.getn(action.list) > 0 then - return CleveRoids.Hooks.IsConsumableAction(slot) - end - - -- Use the same priority as GetActionTexture: active first, then tooltip - local actionToCheck = (action and action.active) or (action and action.tooltip) - if actionToCheck then - - local slotId = tonumber(actionToCheck.action) - if slotId and slotId >= 1 and slotId <= 19 then - local _, count = GetInventoryItemCount("player", slotId) - if count and count > 0 then return 1 end - end - - if actionToCheck.item and - (CleveRoids.countedItemTypes[actionToCheck.item.type] - or CleveRoids.countedItemTypes[actionToCheck.item.name]) - then - return 1 - end - - - if actionToCheck.spell and actionToCheck.spell.reagentId then - return 1 - end - end - - return CleveRoids.Hooks.IsConsumableAction(slot) -end - -- ============================================================================ -- RunMacro Hook - Use our own macro execution system -- ============================================================================ @@ -4868,7 +4418,13 @@ end CleveRoids.Frame = CreateFrame("Frame") CleveRoids.Frame:SetScript("OnUpdate", CleveRoids.OnUpdate) -CleveRoids.Frame:SetScript("OnEvent", function(...) +-- Declared with no parameters on purpose. In 1.12's Lua 5.0 a function written +-- `function(...)` builds a fresh `arg` table on every single call, and this fires +-- for all ~48 registered events -- including the UNIT_HEALTH / UNIT_AURA / +-- UNIT_*_GUID power streams, which tick continuously for every unit in range. That +-- was a table per event, purely as garbage: the handler reads the event globals +-- (event, this, arg1..arg10), never the vararg. +CleveRoids.Frame:SetScript("OnEvent", function() CleveRoids.Frame[event](this,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10) end) @@ -5084,7 +4640,7 @@ function CleveRoids.Frame:UNIT_CASTEVENT(caster,target,action,spell_id,cast_time 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 and lib.guidToName[normalizedTarget]) or UnitName("target") local targetLevel = UnitLevel("target") or 0 @@ -5215,9 +4771,77 @@ end -- Nampower SPELL_CAST_EVENT handler for reliable channel tracking -- Also handles spell_tracking clearing and cast sequence advancement (Nampower fallback for UNIT_CASTEVENT) +-- Nampower SPELL_CAST_EVENT. This one handler covers three separate concerns that +-- used to live in two same-named functions, the second of which silently replaced +-- the first (Lua assigns in file order), so the channel/sequence half never ran: +-- 1. [casting] conditional state + cast bookkeeping (lastCastSpell, pendingCasts, +-- combo-point capture for finishers). +-- 2. Channel start detection. +-- 3. The Nampower fallback for spell_tracking and cast-sequence advancement, used +-- when SuperWoW is absent and UNIT_CASTEVENT never fires. +-- Args arrive from the dispatcher as (self, arg1..arg10). function CleveRoids.Frame:SPELL_CAST_EVENT(success, spellId, castType, targetGuid, itemId) local CHANNEL = 4 + -- BUGFIX: Update casting state on spell cast events (for [casting] conditional) + if CleveRoids.UpdateCastingState then + CleveRoids.UpdateCastingState() + end + + if success == 1 then + CleveRoids.lastCastSpell = { + spellId = spellId, + castType = castType, + targetGuid = targetGuid, + timestamp = GetTime() + } + local name = C_Spell.GetSpellName(spellId) + if name then + CleveRoids.lastCastSpell.spellName = name + end + + -- Track pending cast for SPELL_GO correlation (reactive ability detection) + -- Keyed by spellId so concurrent casts don't overwrite each other + CleveRoids.pendingCasts = CleveRoids.pendingCasts or {} + + -- Clean up consumed entries older than 5 seconds (lightweight, runs per-cast) + local cleanupTime = GetTime() - 5 + for id, entry in pairs(CleveRoids.pendingCasts) do + if entry.consumed and entry.consumedAt and entry.consumedAt < cleanupTime then + CleveRoids.pendingCasts[id] = nil + end + end + + CleveRoids.pendingCasts[spellId] = { + castType = castType, + targetGuid = targetGuid, + timestamp = GetTime(), + comboPoints = nil, + } + + -- Capture combo points NOW (before server consumes them) + -- SPELL_CAST_EVENT fires client-side, so CP are guaranteed available + if (CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellId)) or + (CleveRoids.FerociousBiteSpellIDs and CleveRoids.FerociousBiteSpellIDs[spellId]) then + local cp = CleveRoids.GetComboPoints and CleveRoids.GetComboPoints() or 0 + if cp > 0 then + CleveRoids.pendingCasts[spellId].comboPoints = cp + if CleveRoids.debug then + local castSpellName = C_Spell.GetSpellName(spellId) or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff88[SPELL_CAST_EVENT]|r Captured %d CP for %s (ID:%d)", + cp, castSpellName, spellId) + ) + end + end + end + else + -- Cast failed - clear pending entry for this spell + if CleveRoids.pendingCasts then + CleveRoids.pendingCasts[spellId] = nil + end + end + if castType == CHANNEL and success == 1 then -- Channel started successfully CleveRoids.CurrentSpell.type = "channeled" @@ -5685,6 +5309,7 @@ end function CleveRoids.RebuildMacros() CleveRoids.currentSequence = nil CleveRoids.ParsedMsg = {} + CleveRoids.ExpandedGroups = {} CleveRoids.Macros = {} CleveRoids.Actions = {} CleveRoids.Sequences = {} @@ -5699,6 +5324,9 @@ function CleveRoids.RebuildMacros() -- updates out to Blizzard/pfUI/Bongos buttons. if CleveRoids.ready then CleveRoids.IndexActionBars() + -- Macros were re-parsed above, so every published value is stale. Covers + -- macros that aren't on a bar, which IndexActionBars never visits. + CleveRoids.PublishAllDisplays() end if CleveRoidMacros.realtime == 0 then @@ -5851,29 +5479,33 @@ function CleveRoids.Frame:SPELL_UPDATE_COOLDOWN() CleveRoids.UpdateAllManagedCooldowns() end --- Helper function to notify addon handlers about cooldown updates --- NOTE: Blizzard action buttons are handled natively via ActionButton_OnEvent which calls --- our GetActionCooldown hook. We do NOT call CooldownFrame_SetTimer directly to avoid --- conflicts with SuperWoW's own GetActionCooldown handling for #showtooltip macros. --- Track which slots have been logged to avoid spam +-- Helper function to notify addon handlers about cooldown updates. +-- Blizzard's own buttons need nothing from us: ActionButton_OnEvent reads the stock +-- action-bar functions, which resolve through the value published with +-- C_Macro.SetMacroDisplay. This exists only for third-party bars that keep their own +-- cooldown frames -- Bongos does; pfUI's handler ignores the event outright. +-- +-- Runs on every SPELL_UPDATE_COOLDOWN (so every GCD and cooldown tick) and touches +-- every managed slot, up to 120. Keep the per-slot work here and in the registered +-- handlers allocation-free. CleveRoids.cooldownDebugLogged = {} function CleveRoids.UpdateAllManagedCooldowns() local Actions = CleveRoids.Actions if not Actions then return end - -- Only notify third-party handlers (pfUI/Bongos) - -- Blizzard buttons update themselves via GetActionCooldown hook - local handlerCount = CleveRoids.actionEventHandlers and table.getn(CleveRoids.actionEventHandlers) or 0 + local handlers = CleveRoids.actionEventHandlers + local handlerCount = handlers and table.getn(handlers) or 0 if handlerCount == 0 then return end - for slot, actions in pairs(Actions) do + local slot, actions = next(Actions) + while slot do if actions then - -- Notify action event handlers (for pfUI/Bongos) about the cooldown update - for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do - fn_h(slot, "ACTIONBAR_UPDATE_COOLDOWN") + for i = 1, handlerCount do + handlers[i](slot, "ACTIONBAR_UPDATE_COOLDOWN") end end + slot, actions = next(Actions, slot) end end -- PERFORMANCE OPTIMIZATION: Throttled event handlers to reduce CPU spam @@ -5978,75 +5610,6 @@ function CleveRoids.Frame:SPELL_QUEUE_EVENT() end end -function CleveRoids.Frame:SPELL_CAST_EVENT() - if event == "SPELL_CAST_EVENT" then - local success = arg1 - local spellId = arg2 - local castType = arg3 - local targetGuid = arg4 - - -- BUGFIX: Update casting state on spell cast events (for [casting] conditional) - if CleveRoids.UpdateCastingState then - CleveRoids.UpdateCastingState() - end - - if success == 1 then - CleveRoids.lastCastSpell = { - spellId = spellId, - castType = castType, - targetGuid = targetGuid, - timestamp = GetTime() - } - local name = C_Spell.GetSpellName(spellId) - if name then - CleveRoids.lastCastSpell.spellName = name - end - - -- Track pending cast for SPELL_GO correlation (reactive ability detection) - -- Keyed by spellId so concurrent casts don't overwrite each other - CleveRoids.pendingCasts = CleveRoids.pendingCasts or {} - - -- Clean up consumed entries older than 5 seconds (lightweight, runs per-cast) - local cleanupTime = GetTime() - 5 - for id, entry in pairs(CleveRoids.pendingCasts) do - if entry.consumed and entry.consumedAt and entry.consumedAt < cleanupTime then - CleveRoids.pendingCasts[id] = nil - end - end - - CleveRoids.pendingCasts[spellId] = { - castType = castType, - targetGuid = targetGuid, - timestamp = GetTime(), - comboPoints = nil, - } - - -- Capture combo points NOW (before server consumes them) - -- SPELL_CAST_EVENT fires client-side, so CP are guaranteed available - if (CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellId)) or - (CleveRoids.FerociousBiteSpellIDs and CleveRoids.FerociousBiteSpellIDs[spellId]) then - local cp = CleveRoids.GetComboPoints and CleveRoids.GetComboPoints() or 0 - if cp > 0 then - CleveRoids.pendingCasts[spellId].comboPoints = cp - if CleveRoids.debug then - local castSpellName = C_Spell.GetSpellName(spellId) or "Unknown" - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cff00ff88[SPELL_CAST_EVENT]|r Captured %d CP for %s (ID:%d)", - cp, castSpellName, spellId) - ) - end - end - end - else - -- Cast failed - clear pending entry for this spell - if CleveRoids.pendingCasts then - CleveRoids.pendingCasts[spellId] = nil - end - end - end -end - - -- Nampower v2.41+: KEY_DOWN/KEY_UP events -- arg1=keyCode (int), arg2=metaKeyState (Shift=1,Ctrl=2,Alt=4), arg3=repeat, arg4=time -- Meta key codes (0=Shift, 1=Ctrl, 2=Alt) are skipped — they are already tracked via IsXKeyDown() @@ -6933,12 +6496,7 @@ SlashCmdList["CLEVEROID"] = function(msg) CleveRoids.Print("|cffffaa00Tracked Auras:|r") local trackingCount = 0 local now = GetTime() - -- Determine which backing table to iterate - local isPfUI = CleveRoids.hasPfUI76 and pfUI and pfUI.libdebuff_all_auras - local backingTable = isPfUI and pfUI.libdebuff_all_auras or CleveRoids.AllCasterAuraTracking or {} - if isPfUI then - CleveRoids.Print(" (reading from pfUI.libdebuff_all_auras)") - end + local backingTable = CleveRoids.AllCasterAuraTracking or {} for targetGuid, spellNames in pairs(backingTable) do local unitName = nil -- Try to find unit name for this GUID (use pcall to handle invalid units like "focus") diff --git a/Extensions/MacroErrorUI.lua b/Extensions/MacroErrorUI.lua index 0951802..dc46741 100644 --- a/Extensions/MacroErrorUI.lua +++ b/Extensions/MacroErrorUI.lua @@ -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) diff --git a/Extensions/Tooltip/Generic.lua b/Extensions/Tooltip/Generic.lua index fbffc4e..7ef91cb 100644 --- a/Extensions/Tooltip/Generic.lua +++ b/Extensions/Tooltip/Generic.lua @@ -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 diff --git a/Init.lua b/Init.lua index 0cdd1bd..e716c27 100644 --- a/Init.lua +++ b/Init.lua @@ -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 diff --git a/MacroErrorChecker.lua b/MacroErrorChecker.lua index c91c953..1dbc07c 100644 --- a/MacroErrorChecker.lua +++ b/MacroErrorChecker.lua @@ -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*)") diff --git a/README.md b/README.md index 5705b9b..dbd54b6 100644 --- a/README.md +++ b/README.md @@ -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:** diff --git a/Utility.lua b/Utility.lua index c76a4e1..72a4297 100644 --- a/Utility.lua +++ b/Utility.lua @@ -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.`, +-- 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. diff --git a/docs/CLASSICAPI-TODO.md b/docs/CLASSICAPI-TODO.md index b4a71c2..288cb75 100644 --- a/docs/CLASSICAPI-TODO.md +++ b/docs/CLASSICAPI-TODO.md @@ -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 +