42 Commits

Author SHA1 Message Date
Brues eddc49fc60 Make [button:N] mean the invoking button, as retail does
[button:N] was a held-state modifier: IsMouseButtonDown(name), true while you
physically hold that button. Retail's is a property of the activation -- which
button clicked the action button -- and a keybind press counts as button 1
because it activates through the left-click path. Different in kind, and the
visible break was [button:1] Rejuvenation; Regrowth casting Regrowth from a
keybind, where retail casts Rejuvenation.

It now reads ClassicAPI's GetMouseButtonClicked, defaulting to LeftButton when
no click dispatch is running. That default IS the keybind rule: Button:Click()
with no argument reports LeftButton, and a macro run from anywhere else answers
button 1 the same way.

This also reverts 891467c. That commit refreshed the action bar on
GLOBAL_MOUSE_DOWN/UP so the icon could track the held state -- under the real
semantics there is no such state to track, and the icon correctly sits on
whatever button 1 resolves to. PLAYER_TOTEM_UPDATE stays; it shared the comment
and inherits it.

Requires ClassicAPI v1.15.8, up from v1.15.0. v1.15.8 scoped
GetMouseButtonClicked to the click dispatch; before it a HELD button kept the
value alive (by design -- OnDragStart reads it), so turning the camera with
right-click down made every keybind press read RightButton. The function exists
below v1.15.8 and answers wrongly, and a conditional that is quietly wrong is
worse than one that warns, so the floor moved rather than the conditional being
gated.

Bare [button] / [nobutton] survive as an extension retail has no equivalent for,
now meaning "a click dispatch is running" -- the practical "activated by a
keybind, not a click" test, and no longer leaning on anything but the documented
nil.
2026-09-15 01:17:06 -05:00
Brues 54e3c3b3f4 Drop the dead C_LossOfControl gate
The LOSS_OF_CONTROL_ADDED/UPDATE registration was wrapped in a
`type(C_LossOfControl) == "table"` check, to keep an older ClassicAPI without
the namespace from erroring on an unknown event. That can't happen:
C_LossOfControl ships in v1.10.0, and the addon refuses to finish loading below
v1.15.0. Same shape as the health/power fallbacks removed just before this.

GetSchoolLockout's comment claimed it "Returns 0 for a client without
C_LossOfControl", which was not merely obsolete but wrong -- the function
indexes C_LossOfControl unguarded on its first line, so a missing namespace
raises rather than returning 0. The sentence describes a guard that was never
there.

Left alone: the C_Macro.SetMacroDisplay feature-detect in Init.lua looks like
the same pattern but isn't. SetMacroDisplay lands in v1.15.0, exactly the floor,
so a nil there means the client mod is absent outright, and the flag doubles as
the internal "may we publish" guard that ReleaseDisplays clears.
2026-09-14 20:15:42 -05:00
Brues 4fa254b4e6 Drop the unreachable fallbacks in the health/power wrappers
UnitHealthMissing, UnitPower, UnitPowerMax and UnitPowerMissing each carried an
`X or function(...)` fallback computing the value from UnitHealth/UnitMana. None
could run: the addon refuses to finish loading below ClassicAPI v1.15.0, and all
four globals ship at or below v1.10.0. The file header already promised as much
-- "the wrappers below call the API directly -- no fallbacks" -- and these four
were the only ones that didn't.

The UnitPower fallback was worse than dead. It dropped powerType and returned
UnitMana(unit), so a rage or energy read would have quietly answered with mana;
UnitPowerMax did the same with UnitManaMax. Only unreachability kept that from
being a bug.

They were also the file's only plain `API.X =` assignments, bound to the global
at load time. Now all 38 wrappers are `function API.X(...)`, resolving per call
like their neighbours -- no practical difference, since the DLL registers these
globals before any addon Lua runs.

Every caller passes (unit) or (unit, powerType); both pass straight through.
2026-09-14 19:47:46 -05:00
Brues b82d06d061 Add [totem] and [nototem] conditionals
A totem is a summoned creature, not an aura, so nothing in the addon could see
one. Strength of Earth and Mana Spring could be faked through [nomybuff], but
Searing, Magma, Fire Nova, Tremor and Grounding put nothing on the player at
all, and those are the ones worth conditioning a recast on. There was no
workaround.

ClassicAPI backports the TBC totem bar (src/totem/Tracker.cpp), tracking the
player's own summons from their SMSG_SPELL_GO and classifying them by the
summon spell's SUMMON_TOTEM_SLOT1..4 effect, so Turtle's custom totems
self-classify. Ungated like GLOBAL_MOUSE_*: the tracker landed in v1.10.0, far
below the v1.15.0 floor the addon already refuses to run below.

[totem:X] takes an element (fire/earth/water/air), a slot number, or the totem's
own name -- ResolveTotemSlot tries the slot table first, then matches X against
the name standing in each slot, so [totem:Searing_Totem] asks for that totem
rather than whatever holds the fire slot. Bare [totem] takes the action as its
argument the way [mybuff] does, making /cast [nototem] Searing Totem a complete
recast macro.

Comparisons follow the aura precedent exactly: an empty slot reads -1, the same
"missing counts as least" ValidateAura uses, so [totem:Searing_Totem<5] passes
while the totem is expiring AND while it is absent -- one clause, no [nototem]
needed. A totem is either up or it isn't, so #N stack comparisons read 1 for a
standing totem. The parser needed nothing: its name/operator/amount capture is
generic, so multi-comparisons ([totem:X>2&<8]), OR groups and the De Morgan
flip all came for free.

Occupancy is read from GetTotemInfo's totemName, never from the time left.
GetTotemInfo's first return is TOOL presence -- whether the player carries the
slot's Earth/Fire/Water/Air Totem item -- not whether a totem is out, and a
summon spell with no SpellDuration row reports 0 seconds, which is "up with no
timer", not "absent".

Icon refresh needs two feeds. PLAYER_TOTEM_UPDATE covers a slot changing hands
(dropped, expired, killed, recalled). It does not fire as a totem counts toward
zero, and unlike an aura there is no ambient event stream to ride -- UNIT_AURA
is what quietly keeps [mybuff:X<N] honest -- so a [totem:X<N] icon held its last
answer until some unrelated event queued a refresh: the macro sat on the
question mark through the window it should have lit up. Hence the once-a-second
re-test in OnUpdate while any totem timer runs, skipped in realtime mode, which
re-tests everything anyway. Only the icon was ever at stake; the click path
evaluates conditionals live.
2026-09-14 19:28:24 -05:00
Brues 891467cd7e Refresh action bars on ClassicAPI's global mouse events
A [button:N] macro's icon never moved. The conditional reads IsMouseButtonDown
live, but nothing ever noticed that state changing: the OnUpdate's input poll
samples alt/shift/ctrl only, and no event fired for a mouse button, so no
refresh was queued and the icon sat on whatever the macro resolved to with no
button held.

ClassicAPI fires GLOBAL_MOUSE_DOWN / GLOBAL_MOUSE_UP on every raw press and
release, whether or not the click lands on a frame. Registering both and queuing
an action update is the whole fix -- the existing path re-runs TestAction per
action, so the conditional is read fresh and PublishDisplay hands the new answer
to C_Macro.SetMacroDisplay.

Ungated, unlike the Nampower-backed input events next to it: GLOBAL_MOUSE_* has
been in ClassicAPI since v1.0.0, far below the v1.15.0 floor the addon already
refuses to run below.

The handlers set isActionUpdateQueued directly rather than calling
QueueActionUpdate, as KEY_DOWN does: these fire from the message pump, which can
beat VARIABLES_LOADED to the CleveRoidMacros table QueueActionUpdate reads.

arg1 carries the button name and goes deliberately unused. The mask behind
IsMouseButtonDown is maintained by the same hook that fires the event, so
mirroring the payload into Lua would copy state the API already exposes -- and
the refresh covers every macro regardless, since any of them may test any
button.
2026-09-14 15:31:43 -05:00
Brues 87ea8bdd41 Delete the Lua paladin judgement refresh
A paladin's white swing refreshes their own judgement debuff on the victim, and
the server's edit is packet-silent, so pfUI#45 was the visible symptom: the
timer ran to zero while the debuff stayed up. This addon worked around it three
ways at once -- a UNIT_CASTEVENT MAINHAND/OFFHAND handler in Core.lua, an
AUTO_ATTACK_OTHER fallback in Conditionals.lua, and a CHAT_MSG_COMBAT_SELF_HITS
parser in Utility.lua that string-matched "hit"/"crit" and guessed at melee vs
spell by looking for a parenthesis -- each rewriting rec.start and pushing the
result into pfUI by hand.

ClassicAPI mirrors the server rule in the DLL (src/aura/JudgementRefresh.cpp),
keyed off SMSG_ATTACKERSTATEUPDATE, which is the visible trigger for the
invisible edit. It matches judgements by their DBC marker -- SPELLFAMILY_PALADIN
with SPELL_ATTR_EX3_ALWAYS_HIT -- for any paladin's judgements, not only ours.

That last detail retires the auto-detection too. The hardcoded judgementSpells
list could not cover Turtle's custom judgement ranks, so a cast queued a pending
scan that read the target's debuffs 0.5s later, matched names against
"^Judgement", and grew the list at runtime. Matching on the DBC attribute needs
no list to grow. Gone with it: judgementSpells, pendingJudgements,
detectedJudgementDebuffIDs, the pending-scan pass and its queue buffer, and both
cast-site hooks.

The scan also registered each ID it discovered into lib.sharedDebuffs so
[debuff] fallback matching worked for custom IDs. That mattered when detection
went through the tracking store; the read path resolves auras through
C_UnitAuras now, which returns a debuff whether or not we have a row for it.
2026-09-12 20:33:43 -05:00
Brues 53a1cf1441 Delete the Lua Dark Harvest acceleration
Same shape as Carnage, opposite direction: Dark Harvest is a channel that makes
the caster's DoTs on the target tick 30% faster, and the server never tells an
observer the remaining durations changed. So this addon stamped dhStartTime on
every tracked record when the channel began, dhEndTime when it stopped, and
subtracted 30% of the elapsed window from each remaining time on read.

ClassicAPI compresses the ticks in the DLL (src/turtle/DarkHarvest.cpp), so
expirationTime already reflects it.

Removed: GetDarkHarvestReduction, ApplyDarkHarvestStart, ApplyDarkHarvestEnd,
GetTimeRemainingWithDarkHarvest, the channel-start capture on both the
UNIT_CASTEVENT and nampower paths, the channel-stop finalizer in
SPELLCAST_CHANNEL_STOP, the darkHarvestData state, and DarkHarvestSpellIDs.

The darkHarvest flag in Extensions/CursiveCustomSpells.lua stays -- that is
spell metadata this addon exports to Cursive, not tracking of our own.
2026-09-12 20:27:16 -05:00
Brues 073621e233 Delete the Lua Carnage refresh
Carnage refreshes the caster's Rip and Rake when Ferocious Bite procs it, and
1.12 tells an observing caster nothing about it -- no packet carries a debuff's
new remaining duration on another unit. So this addon inferred the proc: track
every Ferocious Bite cast with its target, watch PLAYER_COMBO_POINTS for combo
points failing to drop to 0 within 0.5s, then rewrite the stored Rip and Rake
records and push the new durations into pfUI's tables by hand. Roughly 570 lines
across three files, and it could only ever be a guess, because the roll that
decides whether Carnage procs is server-side.

ClassicAPI does it in the DLL (src/turtle/Carnage.cpp), roll-gated, so
expirationTime is simply correct and there is nothing to infer.

Removed with it: ApplyCarnageRefresh, the Ferocious Bite cast tracking on both
the UNIT_CASTEVENT and nampower paths, the avoided-Bite clearing in the miss
handler, the Rip/Rake cast snapshots on the UNIT_CASTEVENT and SPELL_GO paths,
the Rake landed/failed verification hooks, the proc detector in
ComboPointTracker, and lastRipCast / lastRakeCast / RipSpellIDs / RakeSpellIDs,
which had no readers left afterwards.

carnageDurationOverrides goes too, and it never worked: every reference to it
was a write, a clear, or the 30-second sweep in Core.lua's cleanup. Nothing ever
read it to change a duration. FerociousBiteSpellIDs stays -- the combo-point
snapshot still needs to know a finisher when it sees one.
2026-09-12 20:25:12 -05:00
Brues 3e2b3db31f Read auras positionally where we scan
C_UnitAuras.UnitDebuff / UnitBuff return the same fifteen values as
GetAuraDataByIndex without building a table, and the two index readers are
called in a loop over every aura on a unit -- one table per slot per refresh is
exactly the garbage the positional form exists to avoid. Only the first seven
values are wanted (name, icon, count, dispelType, duration, expirationTime,
source), and the range-locked variants keep the HARMFUL/HELPFUL split without a
filter string.

FindPlayerDebuff / FindPlayerBuff stay on GetUnitAuraBySpellID: one lookup
rather than a scan, so the AuraData table costs a single allocation and buys the
PLAYER filter. The -1-means-unknown rule moves into RemainingFrom so both shapes
share it.
2026-09-12 08:20:12 -05:00
Brues 7101ad167a Re-base the libdebuff read path on C_UnitAuras
The four readers that answered "what is on this unit, for how long, cast by
whom" spliced two sources together: the engine's aura array for existence and
icon, lib.objects for duration and caster. Everything the second half supplied
is now in the AuraData the first half already returns -- duration is the
caster-modified value when ClassicAPI saw the cast, expirationTime gives the
true remaining, sourceUnit names the caster -- so they read one source.

That deletes the overflow-slot rule with them. UnitDebuff used to split its
index space at 16 and map 17-48 onto buff slots, because a debuff can be parked
in a buff slot on an NPC, then filter the results through GetDuration to guess
which of those buffs were really debuffs. isHarmful is the aura's real polarity,
so the HARMFUL range already contains them and the guess is gone.

timeleft keeps its -1-means-unknown convention. ClassicAPI reports
expirationTime 0 for an aura whose cast it never observed -- one predating
login, or a max-stack refresh whose cache entry elapsed -- which is the same
"present but untimed" state the store expressed by having no record, so callers
testing `timeleft > 0` see no change.

GetDebuffCaster, IsOurDebuff and GetAllDebuffsOnTarget are deleted outright
rather than re-based: all three have no callers anywhere in the addon, and they
were the only readers of the slotOwnership and allSlots mirrors.
2026-09-12 07:59:28 -05:00
Brues afc5e513ca Finish the C_UnitAuras swap in CheckImmunity
CheckImmunity asks "is this NPC immune to that school, and if the entry is
conditional, does it hold the buff that grants it" in two places. The
plain-school path was migrated to C_UnitAuras.GetAuraDataBySpellName -- one
by-name lookup across the unit's auras. The split-damage path was not, and was
still walking 32 buff slots calling C_Spell.GetSpellName on each to compare the
name back, twice over, once for the initial school and once for the DoT's.

Both copies were the same twenty lines with the variable names changed, so they
fold into a SchoolImmune(unitId, school, targetName) helper that the split path
calls twice. Same answer, same order of checks: a permanent entry is immune, a
table entry without a buff is immune, a table entry with one is immune only
while the aura is up.

The plain-school path keeps its own inline version -- it is not the same
predicate. It gates on the recorded spell name first for the "unknown" school,
and a table entry carrying neither buff nor spell falls through to false there
where the split path calls it immune. Unifying those is a behavior change, not a
refactor, so it is left alone.
2026-09-12 07:48:31 -05:00
Brues 5e27e6fe58 Name the macro slot count
Three loops walked 1..36 by hand, two of them re-arguing in comments why 36
is the right number. It is: the client addresses 18 account-wide macros at
1-18 and 18 character-specific ones at 19-36, and that range is what
GetMacroInfo and C_Macro.SetMacroDisplay index. Nothing in the API hands it
back -- GetNumMacros() returns how many of each tab are used, which cannot be
summed into a range, because the character block still starts at 19 when the
account block is empty. Blizzard's MAX_MACROS is no better: it lives in the
load-on-demand Blizzard_MacroUI and is nil until the macro window is opened.

So CleveRoids.MAX_MACRO_SLOTS in Init.lua, carrying that reasoning once.
ValidateAllMacros also dropped a numAccountMacros it computed and never read.

ReleaseDisplays keeps sweeping the full range rather than the macros it knows
it published: PublishAllDisplays clears that record on each re-parse, so a
macro claimed before one and gone after it would keep our last published value
forever. Releasing a slot we never claimed costs nothing, and it runs once.
2026-09-11 13:57:11 -05:00
Brues 6edd37a76f Require ClassicAPI v1.15.0
An out-of-date ClassicAPI did not degrade the addon, it decapitated it.
Utility.lua calls ev:RegisterUnitEvent at file scope (v1.15.0), so on an
older build that call raises and Lua abandons the rest of the chunk --
every definition below line 4118 of a 10040-line file, some 6000 lines,
silently never happens. What the user sees is the first one a common path
reaches: PLAYER_TARGET_CHANGED calling a nil ClearResistState, once per
target swap, forever.

The requirement check still said v1.12.1 (the positional
C_UnitAuras.UnitAura), so the affected client sailed through it and the
warning it did print promised only that "dispel-type conditionals will be
unavailable" -- describing a graceful degradation that was not happening.
The gate is now v1.15.0 and says the addon cannot finish loading, with the
installed version in the message the way the Nampower branch does it.
ClassicAPI.lua gains GetVersion() to decode CLASSIC_API_VERSION, keeping
the X*10000 + Y*100 + Z encoding inside that module, and its header notes
that the minimum has to rise with any ClassicAPI call adopted at file
scope -- that is the property that turns a version mismatch into a silent
half-load rather than a missing feature.

C_Macro.SetMacroDisplay also ships in v1.15.0, which retires the last
pre-ClassicAPI display path:

  useClassicAPIDisplay folds into ClassicAPIMacroDisplay. The two were
  always equal -- set together at load, cleared together in
  ReleaseDisplays -- and one of them is a handshake ClassicAPI reads, so
  a second name for the same state could only ever drift.

  TestForAllActiveActions loses the per-slot ACTIONBAR_SLOT_CHANGED
  fan-out. Publishing repaints every slot holding the macro through the
  client's own notifier; the fan-out was what ran when SetMacroDisplay
  was unavailable, and its one remaining reachable state was after
  DisableAddon, where ReleaseDisplays has already handed all 36 macros
  back and ClassicAPI repaints them itself.

The load-time feature detect stays, with a new reason: below the minimum
the addon is already broken, so a nil SetMacroDisplay now means ClassicAPI
is absent outright -- the case the requirement check warns about and then
keeps running.
2026-09-11 13:44:09 -05:00
Brues c8bc4c134d Filter UNIT_AURA by unit token and drop the unit streams in realtime mode
Two handlers opened by testing arg1 against a fixed set of unit tokens, so
every other unit's copy of UNIT_AURA reached Lua only to be compared away
-- in a raid that is the bulk of them. ClassicAPI's RegisterUnitEvent
pushes that filter into the client, so the handler only runs for the units
named. Filtering is per (frame, event), so the other events sharing the
libdebuff frame are unaffected.

  Utility.lua      UNIT_AURA -> "target"            (libdebuff seeding)
  ComboPointTracker UNIT_AURA -> "target", "player"

Extensions register through CleveRoids.RegisterEvent, so ExtensionsManager
gains a RegisterUnitEvent alongside it.

Separately, the main frame's unit state streams (UNIT_AURA / UNIT_HEALTH /
UNIT_POWER, or the _GUID variants under Nampower v2.39+) are now registered
only in event-driven mode. Their handlers are wholly wrapped in
`realtime == 0`, so with realtime on every one of them crossed into Lua and
returned immediately while the OnUpdate did the refreshing. Registration is
applied at load, again at VARIABLES_LOADED once the saved setting is
readable, and whenever /cleveroid realtime flips it.

Those three stay plain RegisterEvent calls: their handlers ignore the unit
and refresh every macro, and a conditional may name any unit
([@party3,hp:<50], @focus, @mouseover), so narrowing the token set would
leave those icons stale. The _GUID variants could not be filtered anyway --
their first argument is a GUID, not a unit token.
2026-09-11 00:42:42 -05:00
Brues 5213bd5474 Migrate to ClassicAPI for macro display, auras and timing
Squash of the classicapi_next branch (10 commits). ClassicAPI is now a hard
requirement, and the Lua-side reimplementations it supersedes are gone:
net -1087 lines across 12 files.

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

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

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

Macro syntax: a clause may carry a leading run of [group] blocks sharing
one action, Blizzard-style -- groups are OR'd, first pass wins, and []
always passes. The ;-separated form is unchanged and mixes freely. @focus
clauses with no focus set now fail quietly instead of printing
"Invalid target" on the way past.
2026-09-11 00:30:36 -05:00
liiora 9f8a5553bc feat: add /stopchanneling command (#5)
* feat: add /stopchannel command

* review: give /stopchanneling a real handler and warn when nampower can't honor it

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-09-10 14:39:16 -05:00
Brues 00caff0c9b Bump the README conditional count to match the wiki 2026-09-10 14:24:46 -05:00
liiora 080c1243b6 feat: add myspellhaste conditional (#4)
* feat: add myspellhaste conditional

* review: fix myspellhaste proxy fallback, document it, register with macrocheck

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-09-10 14:06:27 -05:00
Brues 6f78be8394 Detect the relic slot via UnitHasRelicSlot instead of hardcoded classes
IsRelicSlot no longer tests playerClass against PALADIN/DRUID/SHAMAN. It now
reads CleveRoids.hasRelicSlot, sampled once in PLAYER_LOGIN.

Also folds PerformEquipSwap's slot-18 check into the same helper. Its class list
(HUNTER/WARRIOR/ROGUE/MAGE/WARLOCK/PRIEST) was the exact complement of the relic
classes, so it becomes `not IsRelicSlot(inventoryId)` and the two lists can no
longer drift apart.
2026-09-09 12:50:36 -05:00
Brues 10ad9eb980 Add pkgmeta and luarc 2026-09-09 12:46:48 -05:00
Artur Morozov 0ef4fe1818 add support for 'button' conditional (#3)
* add support for 'button' conditional

* Route [button] through Multi, add [nobutton], register as static

- [button] now reads conditionals.button via Multi(...) instead of indexing
  _groups.button[1].values[1] directly, so OR/AND lists and repeated groups
  behave like every other argument conditional. [button:1/2] now matches left
  or right; previously only the first value was ever checked and the rest were
  silently dropped. This also removes the unguarded _groups reach-in, which
  Multi itself defends against (it checks _groups is present and falls back to
  _operators).

- Adds [nobutton:N] via NegatedMulti, following the convention that every
  conditional ships its negation (nomod, nostance, nokeydown, ...).

- Bare [button] / [nobutton] now mean "any / no mapped mouse button held",
  mirroring bare [mod]. Bare [nobutton] doubles as an "activated by a keybind
  rather than a click" test.

- Registers button/nobutton in STATIC_CONDITIONALS next to mod and keydown, so
  they are checked once up front rather than re-evaluated for every candidate
  unit during multiscan target scanning.

Still open: IsMouseButtonDown reports live physical state, so a keybound macro
has no button held and [button:N] is false for it, and whether the button still
reads down at macro-execution time depends on whether the action button fires on
mouse-down or mouse-up. Needs in-game verification.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-09-09 02:51:51 -05:00
Brues 269c6ba67e Fix the same 0-based slot assumption in the equipment Lua fallbacks
#2 corrected IsItemInSlot's native path, but the same 0-based assumption was
still present in the two Lua fallbacks, which run on nampower < v2.18. Per
nampower's SCRIPTS.md the slot is 1-19 (16=MainHand, 17=OffHand), and
ClassicAPI's GetInventoryItemID is 1-based as well:

- GetEquippedItem's fallback called GetInventoryItemID(slot + 1), contradicting
  both its own native path and its doc comment.
- GetEquippedItems' fallback built 0-18 keys while the native path returns
  1-19, so the two branches handed back differently-keyed tables.

Neither wrapper currently has any callers, so this was latent rather than
user-visible. Also records the 1-indexed convention at the IsItemInSlot call
site, where the misleading "0-indexed" comment used to be.
2026-09-09 02:40:57 -05:00
Sami Lappalainen c7905c2a70 fix for off-by-one error (#2) 2026-09-09 02:38:57 -05:00
Brues 77c0dba2c4 Alias [mouseuse] to [cursor] via a conditional alias table
Adds CleveRoids.conditionalAliases, applied in ParseMsg so a deprecated name
is rewritten to its current keyword before anything downstream (evaluation,
_groups) sees it. This is the mechanism for renamed modifiers, which -- unlike
predicates such as [stl] -> [stealth] -- have no Keywords function an alias
could simply point at.

[mouseuse] now resolves to [cursor], routing old macros through ClassicAPI's
C_Spell.CastAtCursor / C_Item.UseAtCursor. Drops the post-cast block that cast
normally and then faked a mouse click via CameraOrSelectOrMoveStart/Stop to
place the AoE circle, along with its workaround for that call spuriously
starting auto-attack.

MacroErrorChecker seeds VALID_CONDITIONALS from the alias table, so the old
names stay valid syntax without needing a placeholder ignoreKeywords entry.
2026-09-09 02:18:36 -05:00
Brues 61bab6c734 Require ClassicAPI v1.12.1; scan dispel auras via positional UnitAura
scanDispel now reads each slot through the positional C_UnitAuras.UnitAura
(added in ClassicAPI v1.12.1) instead of the table-return GetAuraDataByIndex,
dropping a throwaway AuraData table per slot on the up-to-48-slot scan behind
the [magic]/[curse]/[disease]/[poison]/[dispellable] conditionals.

Since UnitAura is called directly with no fallback, gate on it: the load-time
check in Core.lua warns (matching its warn-don't-disable design) when
ClassicAPI is older than v1.12.1, wiring up the previously-unused
ClassicAPI.HasMinimumVersion.
2026-09-02 14:40:21 -05:00
brues-code daa7d6f2bf Merge pull request #1 from Seraphic8x2244/feedpet-pethappiness
new command /feedpet, new conditional [pethappiness]
2026-08-25 09:43:41 -05:00
Brues 6d73c6b006 Localize Feed Pet via spell ID and fix IsReactive param
- Resolve Feed Pet from spell ID 6991 (C_Spell.GetSpellName) so /feedpet works on non-enUS clients without a Localized.Spells entry
- Fix CleveRoids.IsReactive referencing undefined global spellName instead of its name parameter
2026-08-25 09:42:36 -05:00
Seraphic8x2244 d5baecaf41 new command feedpet, new conditional pethappiness 2026-08-25 09:26:59 -05:00
Brues 5c31232b52 LoadSavedVariablesFirst 2026-08-23 14:40:01 -05:00
Brues d5cae2136c added CleveRoids_LearnedDurations nil check 2026-08-23 14:33:29 -05:00
Brues 004eebc9f7 Coalesce macro/spell rebuilds to cut the login event storm
UPDATE_MACROS and SPELLS_CHANGED each did a full spell + talent + pet +
120-slot action-bar rebuild inline, and both fire several times during
login as the spellbook and macros populate - so the rebuild ran 4-6
times in the first ~2s, most of it duplicated work fanning 120 button
updates out to Blizzard/pfUI/Bongos each pass.

Defer the rebuild instead: UPDATE_MACROS, SPELLS_CHANGED and PLAYER_LOGIN
arm a 0.3s debounce (macroRebuildTime); the update loop runs RebuildMacros
once when the burst settles. RebuildMacros skips the action-bar pass until
`ready` (GetAction early-returns before then, and the +1.5s init timer
builds the bars once anyway), and BAG_UPDATE_DELAYED's bar rebuild is
gated on `ready` too. Net: IndexActionBars runs once at login (the
suppressed init-timer pass) instead of 4-6 times, IndexSpells ~2x instead
of ~5x. Runtime macro edits and bag changes still do a full unsuppressed
rebuild, just debounced.
2026-08-21 21:39:11 -05:00
Brues 63b484b1ea Replace WDB tooltip warmup with GET_ITEM_INFO_RECEIVED handler
ClassicAPI hooks the global GetItemInfo to auto-warm the item cache on a
miss and fires GET_ITEM_INFO_RECEIVED when the async fill lands, so the
tooltip-scan warmup is obsolete: IndexItems's own GetItemInfo calls
already trigger the same warmup, and owned items (bags + equipped) are
priority-prefetched by the engine. The old warmup also assumed the fill
was synchronous, which no longer holds.

Drop DoWDBWarmup and its login scheduling; instead listen for
GET_ITEM_INFO_RECEIVED and run a debounced re-index. IndexItems records
owned itemIDs it could not resolve into pendingItemInfo, and the handler
ignores any fill not in that set (quest DB scans, AH sweeps, chat-link
hovers, inspects) in O(1) so unrelated bursts do not cause reindex churn.
2026-08-21 21:02:59 -05:00
Brues cf47d8caba Use SetSpellByID for tooltip 2026-08-06 00:33:12 -05:00
Brues 71f8f74237 Support #showtooltip spell:<id> and item:<id>
GetSpell/GetItem now resolve explicit spell:/item: ID forms. spell:<id>
prefers the player's spellbook entry via FindSpellBookSlotByID (per-rank
and pet aware) for full cost/cooldown/usability, falling back to an
id-only entry rendered via SetSpellByID for spells not in the book.
item:<id> reuses the existing numeric lookup for location-aware tooltips.

Guard the id-only path against nil spellSlot/cost in TestForActiveAction
and GetActionCooldown so display-only spell references don't crash.

Remove the dead, no-op GetSpellSlotByID stub.
2026-08-06 00:15:19 -05:00
Brues 1a98a05b7e Remove redundant local 'i' declarations
Delete unnecessary local 'i' declarations in Extensions/Mouseover/pfUI.lua (ResolvePfUnit, RegisterPartyScripts, RegisterRaidScripts, RegisterRaidMarkScripts). The for-loop headers already provide a local loop variable, so the explicit locals were redundant and could shadow variables. No functional change.
2026-08-02 15:18:48 -05:00
Brues 64e3f93042 Specifically disable pfUI mouseover hooks when unit attribute exists 2026-08-02 15:11:46 -05:00
Brues 0c86ed67e9 Revert "Drop redundant pfUI mouseover frame hooks; pfUI sets native mouseover"
This reverts commit c5bc69560e.
2026-08-02 15:07:35 -05:00
Brues 60c1b7f235 dont bother hooking into brues-code pfUI 2026-08-01 20:21:37 -05:00
Brues 8c10353567 removed useless GetSpellRecField checks 2026-07-30 20:41:23 -05:00
Brues fcd206756d Replace equipment cache with C_Item.IsEquippedItem
HasGearEquipped now wraps ClassicAPI's native slot-walk, which
short-circuits on first match. Removes BuildEquipmentCache, the
_equipped* cache tables/invalidation, and the InvalidateEquipmentCache
call from PLAYER_EQUIPMENT_CHANGED. [equipped] reads live engine state,
so no cache staleness surface remains; also gains item-link support.
2026-07-30 20:35:39 -05:00
Brues 8d86389a03 Remove dead IndexEquippedItems (no callers) 2026-07-30 20:28:39 -05:00
Brues 8c85ce572a Scope PLAYER_EQUIPMENT_CHANGED to the changed slot
Replace the full IndexItems + action-bar rebuild with a single-slot
IndexEquipSlot using the event's arg1 (slot) and arg2 (hasCurrent),
and drop the now-pointless throttle/deferral. Bag-side deltas remain
covered by BAG_UPDATE_DELAYED; [equipped] runs off its own cache.
2026-07-30 20:28:05 -05:00
20 changed files with 1840 additions and 3572 deletions
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
fetch-depth: 0
- name: Package and release to GitHub
uses: BigWigsMods/packager@v2
uses: brues-code/packager@vCAPI
env:
GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }}
+22
View File
@@ -0,0 +1,22 @@
{
"runtime": {
"version": "Lua 5.1"
},
"diagnostics": {
"disable": ["deprecated"],
"globals": [
"this",
"event",
"arg",
"arg1",
"arg2",
"arg3",
"arg4",
"arg5",
"arg6",
"arg7",
"arg8",
"arg9"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
package-as: SuperCleveRoidMacros
ignore:
- .luarc.json
+47 -22
View File
@@ -3,9 +3,17 @@
ClassicAPI is a client mod (sibling to Nampower/SuperWoW) that backports the
modern C_* API into the 1.12.1 Lua environment. It is a HARD REQUIREMENT of
this addon, so the wrappers below call the API directly — no fallbacks. The
load-time requirement check (Core.lua) uses IsAvailable() to warn when the
DLL is missing; users who don't want ClassicAPI should run the upstream addon.
this addon (ClassicAPI v1.15.8+, which scoped GetMouseButtonClicked to the
click dispatch; v1.15.0 added frame:RegisterUnitEvent), so the
wrappers below call the API directly — no fallbacks. The load-time requirement
check (Core.lua) uses IsAvailable() to warn when the DLL is missing and
HasMinimumVersion() when it's too old; users who don't want ClassicAPI should
run the upstream addon.
The minimum is not advisory: Utility.lua calls frame:RegisterUnitEvent at file
scope, so an older ClassicAPI aborts that chunk and leaves most of the addon
undefined. Raise the Core.lua minimum in step with any new API adopted at file
scope.
Detection: the global CLASSIC_API_VERSION is defined once the client has
booted, encoded as X*10000 + Y*100 + Z for a vX.Y.Z tag (untagged dev builds
@@ -35,6 +43,14 @@ function API.GetVersionNumber()
return CLASSIC_API_VERSION or 0
end
-- Returns the loaded version as major, minor, patch (0, 0, 0 if absent).
function API.GetVersion()
local v = CLASSIC_API_VERSION or 0
local major = math.floor(v / 10000)
local minor = math.floor(v / 100) - major * 100
return major, minor, v - math.floor(v / 100) * 100
end
-- True if the ClassicAPI client mod is loaded at all.
function API.IsAvailable()
return CLASSIC_API_VERSION ~= nil
@@ -53,15 +69,16 @@ end
--------------------------------------------------------------------------------
-- Scan one aura range of `unit` (filter = "HELPFUL" or "HARMFUL") for an aura
-- matching the dispel type. The filtered index self-terminates at the end of the
-- range (nil); 48 is a backstop over vanilla's 32 helpful / 16 harmful slots.
-- matching the dispel type. Uses the positional C_UnitAuras.UnitAura (added in
-- ClassicAPI v1.12.1, below this addon's minimum) -- no table allocated per slot, with
-- dispelName as the 4th return. The filtered index self-terminates at the end of
-- the range (nil name); 48 is a backstop over vanilla's 32 helpful / 16 harmful slots.
local function scanDispel(unit, filter, dispelType, wantAny)
local i = 1
while i <= 48 do
local data = C_UnitAuras.GetAuraDataByIndex(unit, i, filter)
if not data then return false end
local dn = data.dispelName
if dn and dn ~= "" and (wantAny or dn == dispelType) then
local name, _, _, dispelName = C_UnitAuras.UnitAura(unit, i, filter)
if not name then return false end
if dispelName and dispelName ~= "" and (wantAny or dispelName == dispelType) then
return true
end
i = i + 1
@@ -231,8 +248,8 @@ end
-- Pummel / Earth Shock lockout) on the player, or 0 when not kicked. Read from
-- C_LossOfControl, which synthesizes the lockout from the server's own
-- SMSG_SPELL_COOLDOWN packet -- a state no debuff scan can see. Also returns the
-- seconds remaining (nil if ClassicAPI didn't observe the applying cast). Returns
-- 0 for a client without C_LossOfControl. Player-only (vanilla LoC is local-only).
-- seconds remaining (nil if ClassicAPI didn't observe the applying cast).
-- Player-only (vanilla LoC is local-only).
function API.GetSchoolLockout()
local n = C_LossOfControl.GetActiveLossOfControlDataCount() or 0
for i = 1, n do
@@ -283,14 +300,22 @@ function API.GetSpellBonusHealing()
return GetSpellBonusHealing()
end
-- Spell haste for `unit` as a percentage: 0 unhasted, positive when casts are
-- sped up, negative while slowed (Curse of Tongues). 0 for a unit that doesn't
-- resolve. Vanilla has no haste API at all -- ClassicAPI derives this from the
-- UNIT_MOD_CAST_SPEED descriptor field the server folds into the cast time, so
-- it's exact and needs no nampower GetUnitField dependency.
function API.UnitSpellHaste(unit)
return UnitSpellHaste(unit)
end
--------------------------------------------------------------------------------
-- Unit Health
--------------------------------------------------------------------------------
-- Health deficit (max - current) for `unit` in one call. Falls back to
-- UnitHealthMax - UnitHealth without ClassicAPI.
API.UnitHealthMissing = UnitHealthMissing or function(unit)
return (UnitHealthMax(unit) or 0) - (UnitHealth(unit) or 0)
-- Health deficit (max - current) for `unit` in one call.
function API.UnitHealthMissing(unit)
return UnitHealthMissing(unit)
end
--------------------------------------------------------------------------------
@@ -299,18 +324,18 @@ end
-- Current power for a specific Enum.PowerType (0=Mana, 1=Rage, 2=Focus,
-- 3=Energy, 4=Happiness), or the unit's primary power when powerType is omitted.
-- Display-divided (rage reads 0..100). Falls back to UnitMana without ClassicAPI.
API.UnitPower = UnitPower or function(unit, powerType)
return UnitMana(unit)
-- Display-divided (rage reads 0..100).
function API.UnitPower(unit, powerType)
return UnitPower(unit, powerType)
end
API.UnitPowerMax = UnitPowerMax or function(unit, powerType)
return UnitManaMax(unit)
function API.UnitPowerMax(unit, powerType)
return UnitPowerMax(unit, powerType)
end
-- Power deficit (max - current) for the type / primary power, in one call.
API.UnitPowerMissing = UnitPowerMissing or function(unit, powerType)
return (UnitManaMax(unit) or 0) - (UnitMana(unit) or 0)
function API.UnitPowerMissing(unit, powerType)
return UnitPowerMissing(unit, powerType)
end
-- Unit's primary power type as an integer (0=Mana .. 4=Happiness).
+6 -86
View File
@@ -15,23 +15,6 @@ CleveRoids.ComboPointTracking = CleveRoids.ComboPointTracking or {}
-- Structure: CleveRoids_ComboDurations[spellID][comboPoints] = duration
CleveRoids_ComboDurations = CleveRoids_ComboDurations or {}
-- Storage for last Rip cast (for Carnage talent mechanic)
-- Carnage talent: When Ferocious Bite procs Carnage, it refreshes Rip and Rake to their original duration
-- Detection: When combo points don't drop to 0 after FB (they stay at 1 = Carnage proc)
-- Talent Position: Tab 2 (Feral Combat), Talent 17
-- Rank 1: 10% per CP, Rank 2: 20% per CP
CleveRoids.lastRipCast = CleveRoids.lastRipCast or {
duration = nil,
targetGUID = nil,
timestamp = 0
}
CleveRoids.lastRakeCast = CleveRoids.lastRakeCast or {
duration = nil,
targetGUID = nil,
timestamp = 0
}
-- Family membership without hardcoded rank lists. C_Spell.GetSpellName resolves
-- ANY spellID from the client's Spell.dbc -- every rank (so no enumeration or
-- spellbook scan) and TWoW's custom spells alike -- so "is this spellID a Rip?"
@@ -86,10 +69,8 @@ CleveRoids.ComboScalingSpellsByID = {
[9896] = { base = 10, increment = 2, name = "Rip" }, -- Rank 6
}
-- Ferocious Bite spell IDs (for Carnage talent mechanic)
-- Carnage talent: When FB procs Carnage, refreshes Rip and Rake back to their original duration
-- Proc detection: combo points stay at 1 after FB instead of dropping to 0
-- Talent Position: Tab 2 (Feral Combat), Talent 17
-- Ferocious Bite spell IDs. A combo-point finisher, so the cast-time combo
-- snapshot has to know it.
CleveRoids.FerociousBiteSpellIDs = {
[22557] = true, -- Rank 1
[22568] = true, -- Rank 2
@@ -99,10 +80,6 @@ CleveRoids.FerociousBiteSpellIDs = {
[31018] = true, -- Rank 6
}
-- Rip / Rake families (for Carnage talent). Seeded by Rank 1; matches every rank.
CleveRoids.RipSpellIDs = RankSet(1079)
CleveRoids.RakeSpellIDs = RankSet(1822)
-- Combined table for all bleed spells that need immunity detection
-- Used when checking if a cast bleed failed to apply (indicates bleed immunity)
-- NOTE: These are the DEBUFF spell IDs (what appears on target), not cast spell IDs
@@ -168,14 +145,6 @@ CleveRoids.ImmolateSpellIDs = {
[25309] = true, -- Rank 8
}
-- =============================================================================
-- WARLOCK: Dark Harvest Duration Acceleration (TWoW Custom)
-- Channeled spell that accelerates DoT tick rate by 30% while channeling
-- Complex tracking: debuff expires 30% faster while Dark Harvest is active
-- =============================================================================
-- TWoW custom (see MoltenBlast note).
CleveRoids.DarkHarvestSpellIDs = RankSet(52550)
-- =============================================================================
-- DRUID: Rake Debuff Cap Boss Whitelist
-- These bosses are likely to hit the 48 debuff cap, causing Rake to get pushed off
@@ -745,7 +714,7 @@ function Extension.OnLoad()
Extension.RegisterEvent("SPELLCAST_FAILED", "OnSpellcastFailed")
Extension.RegisterEvent("SPELLCAST_INTERRUPTED", "OnSpellcastInterrupted")
Extension.RegisterEvent("PLAYER_TARGET_CHANGED", "OnTargetChanged")
Extension.RegisterEvent("UNIT_AURA", "OnUnitAura")
Extension.RegisterUnitEvent("UNIT_AURA", "OnUnitAura", "target", "player")
Extension.RegisterEvent("PLAYER_COMBO_POINTS", "OnComboPointsChanged")
-- PERFORMANCE OPTIMIZATION: Removed OnUpdate polling for combo points
@@ -765,63 +734,14 @@ function Extension.OnTargetChanged()
CleveRoids.UpdateComboPoints()
end
-- Registered as a unit event for target and player, so arg1 is always one of
-- those two -- no token check needed.
function Extension.OnUnitAura()
if arg1 == "target" or arg1 == "player" then
CleveRoids.UpdateComboPoints()
end
CleveRoids.UpdateComboPoints()
end
function Extension.OnComboPointsChanged()
CleveRoids.UpdateComboPoints()
-- CARNAGE PROC DETECTION (Cursive-style)
-- When Ferocious Bite is used, combo points should drop to 0
-- If Carnage procs, combo points will be 1 instead (the Carnage-granted combo point)
-- Check: After Ferocious Bite (within 0.5s), if combo points > 0, Carnage procced
if CleveRoids.lastFerociousBiteTime and CleveRoids.lastFerociousBiteTargetGUID then
local timeSinceBite = GetTime() - CleveRoids.lastFerociousBiteTime
if timeSinceBite < 0.5 then
local currentCP = CleveRoids.GetComboPoints()
if currentCP > 0 then
-- Carnage procced! Combo points didn't drop to 0 (or rose back to 1)
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cffff00ff[Carnage]|r PROC DETECTED! CP=%d after Ferocious Bite (%.2fs ago)",
currentCP, timeSinceBite)
)
end
-- Apply the Carnage refresh to Rip and Rake
local targetGUID = CleveRoids.lastFerociousBiteTargetGUID
local targetName = CleveRoids.lastFerociousBiteTargetName or "Unknown"
local biteSpellID = CleveRoids.lastFerociousBiteSpellID
-- Call the Carnage refresh function in Utility.lua
if CleveRoids.libdebuff and CleveRoids.libdebuff.ApplyCarnageRefresh then
CleveRoids.libdebuff.ApplyCarnageRefresh(targetGUID, targetName, biteSpellID)
end
-- Clear the tracking to prevent multiple refreshes
CleveRoids.lastFerociousBiteTime = nil
CleveRoids.lastFerociousBiteTargetGUID = nil
CleveRoids.lastFerociousBiteTargetName = nil
CleveRoids.lastFerociousBiteSpellID = nil
end
else
-- Time window expired, clear tracking
if CleveRoids.lastFerociousBiteTime then
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cffff00ff[Carnage]|r No proc - time window expired (%.2fs)", timeSinceBite)
)
end
CleveRoids.lastFerociousBiteTime = nil
CleveRoids.lastFerociousBiteTargetGUID = nil
CleveRoids.lastFerociousBiteTargetName = nil
CleveRoids.lastFerociousBiteSpellID = nil
end
end
end
end
-- Event handlers
+25 -372
View File
@@ -22,19 +22,6 @@ function Extension.DLOG(msg)
end
end
function Extension.FocusNameHook()
local hook = Extension.internal.memberHooks[CleveRoids]["GetFocusName"]
local target = hook.original()
if pfUI and pfUI.uf and pfUI.uf.focus and pfUI.uf.focus.unitname then
target = pfUI.uf.focus.unitname
end
--Extension.DLOG(target)
return target
end
-- Check if pfUI's macrotweak module is loaded
function Extension.IsPfUIMacrotweakLoaded()
if not pfUI then return false end
@@ -92,334 +79,6 @@ function Extension.HandleSendChatMessageHook()
end
end
-- Helper function to check for Carnage duration override
-- Returns override duration and timeleft if found, nil otherwise
local function GetCarnageOverride(effect)
if not effect or not CleveRoids.carnageDurationOverrides then
return nil, nil
end
for spellID, override in pairs(CleveRoids.carnageDurationOverrides) do
local spellName = C_Spell.GetSpellName(spellID)
if spellName then
local baseName = CleveRoids.StripRank(spellName)
if baseName == effect and override.timestamp and (GetTime() - override.timestamp) < 5 then
local timeleft = override.duration - (GetTime() - override.timestamp)
if timeleft < 0 then timeleft = 0 end
return override.duration, timeleft
end
end
end
return nil, nil
end
-- Hook pfUI's libdebuff to use our combo-aware durations
-- NOTE: pfUI 7.6+ (GetUnitField edition) handles combo durations and Carnage internally.
-- We only inject when there's a mismatch between pfUI's data and ours.
function Extension.HookPfUILibdebuff()
if not pfUI or not pfUI.api or not pfUI.api.libdebuff then
return false
end
local pflib = pfUI.api.libdebuff
-- Check if pfUI 7.6+ with GetUnitField-based libdebuff is active
-- If so, pfUI handles combo durations and Carnage internally - we only override on mismatch
local hasPfUI76 = CleveRoids.hasPfUI76
-- pfUI 7.6+ handles all durations internally - no hooks needed
if hasPfUI76 then
Extension.DLOG("Skipped all libdebuff hooks (pfUI 7.6+ handles internally)")
return false
end
-- Hook GetDuration if it exists
-- pfUI's GetDuration signature: function(effect, rank) where effect is spell NAME
if pflib.GetDuration and not Extension.pfLibDebuffHooked then
local originalGetDuration = pflib.GetDuration
pflib.GetDuration = function(self, effect, rank)
local pfuiDuration = originalGetDuration(self, effect, rank)
-- Check for Carnage duration overrides (only if pfUI doesn't have it)
local carnageDuration = GetCarnageOverride(effect)
if carnageDuration then
-- Only override if pfUI's duration is significantly different (>1s difference)
if not pfuiDuration or math.abs(carnageDuration - pfuiDuration) > 1 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI Duration Override]|r %s: Carnage %.1fs (pfUI: %.1fs)",
effect, carnageDuration, pfuiDuration or 0)
)
end
return carnageDuration
end
end
-- Check name-based tracking for fresh combo casts (only if pfUI returned 0 or nil)
if (not pfuiDuration or pfuiDuration == 0) and CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking[effect] then
local tracking = CleveRoids.ComboPointTracking[effect]
if tracking.duration and tracking.confirmed and (GetTime() - tracking.cast_time) < 0.5 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI Duration Override]|r %s: Combo tracking %.1fs (pfUI: %.1fs)",
effect, tracking.duration, pfuiDuration or 0)
)
end
return tracking.duration
end
end
return pfuiDuration
end
Extension.pfLibDebuffHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.GetDuration (mismatch-only mode)")
end
-- Hook AddEffect if it exists (pre-7.6 only - 7.6+ returns early above)
if pflib.AddEffect and not Extension.pfLibAddEffectHooked then
local originalAddEffect = pflib.AddEffect
pflib.AddEffect = function(self, unit, unitlevel, effect, duration, caster)
-- RANK CHECKING: Preserve higher rank's remaining time if lower rank was cast
-- NOTE: 'unit' is a unit NAME (e.g., "Expert Training Dummy"), not a unit ID
-- Defensive: verify libdebuff is a table, not a function
if caster == "player" and type(CleveRoids.libdebuff) == "table" and duration and duration > 0 then
-- Try to find the GUID for this unit name
local unitGUID = nil
-- Check if this is the current target
if UnitName("target") == unit then
unitGUID = CleveRoids.GetGUID("target")
end
-- If we couldn't match to current target, check guidToName mapping
if not unitGUID and CleveRoids.libdebuff.guidToName then
for guid, name in pairs(CleveRoids.libdebuff.guidToName) do
if name == unit then
unitGUID = guid
break
end
end
end
-- Check if a higher rank of this spell is already active
if unitGUID and CleveRoids.libdebuff.objects and CleveRoids.libdebuff.objects[unitGUID] then
-- Find all spell IDs that match this effect name
for spellID, rec in pairs(CleveRoids.libdebuff.objects[unitGUID]) do
if rec and rec.start and rec.duration then
-- Get spell name for this ID
local spellName = C_Spell.GetSpellName(spellID)
if spellName then
local baseName = CleveRoids.StripRank(spellName)
if baseName == effect then
-- Same spell - check if still active
local remaining = rec.duration + rec.start - GetTime()
if remaining > 0 then
-- If incoming duration > remaining time, we're trying to add more time
-- This means either a refresh or lower rank cast - preserve existing timer
if duration > remaining then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI Rank Preserve]|r %s: Preserving timer (%.1fs remaining vs %.1fs incoming)",
effect, remaining, duration)
)
end
-- Preserve the existing timer
duration = remaining
end
break
end
end
end
end
end
end
end
-- Check for Carnage duration overrides FIRST (highest priority)
local carnageDuration = GetCarnageOverride(effect)
if carnageDuration then
duration = duration or carnageDuration
caster = caster or "player" -- Ensure caster is set for UnitOwnDebuff filtering
end
-- Check if this is a combo scaling spell by name
if not duration and CleveRoids.IsComboScalingSpell and CleveRoids.IsComboScalingSpell(effect) then
if CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking[effect] then
local tracking = CleveRoids.ComboPointTracking[effect]
if tracking.duration and tracking.confirmed and (GetTime() - tracking.cast_time) < 0.5 then
duration = tracking.duration
caster = caster or "player"
end
end
end
return originalAddEffect(self, unit, unitlevel, effect, duration, caster)
end
Extension.pfLibAddEffectHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.AddEffect")
end
-- Hook UnitDebuff to return Carnage override duration to display code
-- Only override when pfUI's duration differs significantly from ours
if pflib.UnitDebuff and not Extension.pfLibUnitDebuffHooked then
local originalUnitDebuff = pflib.UnitDebuff
pflib.UnitDebuff = function(self, unit, id)
local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitDebuff(self, unit, id)
-- Only check Carnage override if pfUI returned data but duration might be wrong
if effect then
local carnageDuration, carnageTimeleft = GetCarnageOverride(effect)
if carnageDuration then
-- Only override if there's a significant difference (>1s)
if not duration or math.abs(carnageDuration - duration) > 1 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI UnitDebuff Override]|r %s: Carnage %.1fs/%.1fs (pfUI: %.1fs/%.1fs)",
effect, carnageDuration, carnageTimeleft, duration or 0, timeleft or 0)
)
end
duration = carnageDuration
timeleft = carnageTimeleft
end
end
end
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
end
Extension.pfLibUnitDebuffHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.UnitDebuff (mismatch-only mode)")
end
-- Hook UnitOwnDebuff to return Carnage override duration when selfdebuff is enabled
-- Only override when pfUI's duration differs significantly from ours
if pflib.UnitOwnDebuff and not Extension.pfLibUnitOwnDebuffHooked then
local originalUnitOwnDebuff = pflib.UnitOwnDebuff
pflib.UnitOwnDebuff = function(self, unit, id)
local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitOwnDebuff(self, unit, id)
if effect then
local carnageDuration, carnageTimeleft = GetCarnageOverride(effect)
if carnageDuration then
-- Only override if there's a significant difference (>1s)
if not duration or math.abs(carnageDuration - duration) > 1 then
if Extension.Debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00aaff[pfUI UnitOwnDebuff Override]|r %s: Carnage %.1fs/%.1fs (pfUI: %.1fs/%.1fs)",
effect, carnageDuration, carnageTimeleft, duration or 0, timeleft or 0)
)
end
duration = carnageDuration
timeleft = carnageTimeleft
end
end
-- If UnitOwnDebuff returned nil but we have a Carnage override, synthesize from UnitDebuff
-- This fallback is only needed for edge cases where pfUI doesn't track the debuff yet
elseif not effect and CleveRoids.carnageDurationOverrides then
-- Use pflib:UnitDebuff which includes our Carnage override hook
local baseEffect, baseRank, baseTex, baseStacks, baseDtype, baseDur, baseLeft, _ = pflib:UnitDebuff(unit, id)
if baseEffect then
local carnageDuration2, carnageTimeleft2 = GetCarnageOverride(baseEffect)
if carnageDuration2 then
return baseEffect, baseRank, baseTex, baseStacks, baseDtype, carnageDuration2, carnageTimeleft2, "player"
end
end
end
return effect, rank, texture, stacks, dtype, duration, timeleft, caster
end
Extension.pfLibUnitOwnDebuffHooked = true
Extension.DLOG("Hooked pfUI.api.libdebuff.UnitOwnDebuff (mismatch-only mode)")
end
return Extension.pfLibDebuffHooked or Extension.pfLibAddEffectHooked or Extension.pfLibUnitDebuffHooked or Extension.pfLibUnitOwnDebuffHooked
end
-- Synchronize combo durations to pfUI's libdebuff objects
-- NOTE: pfUI 7.6+ handles combo durations internally - skip sync entirely
function Extension.SyncComboDurationToPfUI(guid, spellID, duration)
if not pfUI or not pfUI.api or not pfUI.api.libdebuff then
return
end
-- pfUI 7.6+ handles all durations internally
if CleveRoids.hasPfUI76 then
return
end
-- Get unit name from GUID
local unitName = nil
local unitLevel = 0
-- Check if this is the current target
local targetGUID = CleveRoids.GetGUID("target")
if targetGUID == guid then
unitName = UnitName("target")
unitLevel = UnitLevel("target") or 0
end
-- If we couldn't find the unit, use GUID to name mapping from libdebuff
if not unitName and CleveRoids.libdebuff and CleveRoids.libdebuff.guidToName then
unitName = CleveRoids.libdebuff.guidToName[guid]
-- Default to level 0 if we don't have the unit targeted
unitLevel = 0
end
if not unitName then
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[pfUI Sync]|r Could not find unit name for GUID")
end
return
end
-- Get spell name from spell ID
local spellName = C_Spell.GetSpellName(spellID)
if not spellName then
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[pfUI Sync]|r Could not find spell name for ID " .. spellID)
end
return
end
-- Remove rank from spell name to match pfUI's format
local effectName = CleveRoids.StripRank(spellName)
-- Update pfUI's stored debuff duration
local pflib = pfUI.api.libdebuff
if pflib.objects and pflib.objects[unitName] then
-- Try both the specific level and level 0 (fallback)
for _, level in ipairs({unitLevel, 0}) do
if pflib.objects[unitName][level] and pflib.objects[unitName][level][effectName] then
local old_duration = pflib.objects[unitName][level][effectName].duration
pflib.objects[unitName][level][effectName].duration = duration
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00ffaa[pfUI Sync]|r Updated %s on %s (L%d): %ds -> %ds",
effectName, unitName, level, old_duration or 0, duration)
)
end
return
end
end
end
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cffaaaa00[pfUI Sync]|r Effect not found in pfUI storage: %s on %s",
effectName, unitName)
)
end
end
-- Register action event handler for pfUI button updates
function Extension.RegisterPfUIActionEventHandler()
if not pfUI or Extension.actionHandlerRegistered then
@@ -429,7 +88,17 @@ function Extension.RegisterPfUIActionEventHandler()
-- Register a handler that will be called whenever CleveRoids updates macro states
if CleveRoids.RegisterActionEventHandler then
Extension.DLOG("Registering pfUI action event handler")
CleveRoids.RegisterActionEventHandler(function(slot, event, ...)
-- Declared without `...` on purpose: in 1.12's Lua 5.0 a vararg function
-- allocates an `arg` table on every call, and this handler never read it.
-- That mattered because UpdateAllManagedCooldowns fans
-- ACTIONBAR_UPDATE_COOLDOWN out across every managed slot -- up to 120 calls
-- -- on each SPELL_UPDATE_COOLDOWN, which fires on every GCD and cooldown
-- tick. Those calls all allocated a table and then did nothing, because the
-- whole body only ever applied to ACTIONBAR_SLOT_CHANGED. Hence the early
-- return before any work.
CleveRoids.RegisterActionEventHandler(function(slot, event)
if event ~= "ACTIONBAR_SLOT_CHANGED" then return end
local button = pfUI.bars and pfUI.bars.buttons and pfUI.bars.buttons[slot]
if Extension.Debug then
@@ -441,23 +110,21 @@ function Extension.RegisterPfUIActionEventHandler()
))
end
-- For slot change events, do a full button update so the icon,
-- cooldown, and tooltip refresh through CleveRoids' hooked
-- GetActionTexture / GetActionCooldown / GameTooltip:SetAction.
-- Full button update so the icon, cooldown and tooltip refresh.
-- pfUI's ButtonMacroScan defers to us for managed macros (leaves
-- spellslot nil), so its ButtonFullUpdate routes through those hooks
-- and follows the active conditional — no manual cooldown override
-- needed.
if event == "ACTIONBAR_SLOT_CHANGED" then
-- Mark the slot for update in pfUI's cache (processed next OnUpdate)
if pfUI.bars and pfUI.bars.update then
pfUI.bars.update[slot] = true
end
-- spellslot nil), so ButtonFullUpdate reads the stock action-bar
-- functions -- which now resolve through the value we publish with
-- C_Macro.SetMacroDisplay, rather than the Lua overrides this addon
-- used to install.
-- Also directly call ButtonFullUpdate if the button exists
if button and pfUI.bars.ButtonFullUpdate then
pfUI.bars.ButtonFullUpdate(button)
end
-- Mark the slot for update in pfUI's cache (processed next OnUpdate)
if pfUI.bars and pfUI.bars.update then
pfUI.bars.update[slot] = true
end
-- Also directly call ButtonFullUpdate if the button exists
if button and pfUI.bars.ButtonFullUpdate then
pfUI.bars.ButtonFullUpdate(button)
end
end)
@@ -474,9 +141,6 @@ function Extension.SetupCompatibility()
if Extension.pfUILoaded then
Extension.DLOG("pfUI detected")
-- Hook libdebuff for combo duration support
Extension.HookPfUILibdebuff()
-- Register action event handler for button updates
Extension.RegisterPfUIActionEventHandler()
@@ -631,16 +295,9 @@ function Extension.SetupPfUIEventHooks(lib)
ev:UnregisterEvent("AURA_CAST_ON_OTHER")
ev:UnregisterEvent("DEBUFF_ADDED_OTHER")
ev:UnregisterEvent("DEBUFF_REMOVED_OTHER")
ev:UnregisterEvent("BUFF_ADDED_OTHER")
ev:UnregisterEvent("BUFF_REMOVED_SELF")
ev:UnregisterEvent("BUFF_REMOVED_OTHER")
-- pfUI 7.6+ also handles cast tracking internally
if lib.hasPfUI76 then
ev:UnregisterEvent("SPELL_START_OTHER")
ev:UnregisterEvent("SPELL_FAILED_OTHER")
end
-- Keep registered: SPELL_START_SELF (channel duration capture before early return),
-- UNIT_DIED (AllCasterAuraTracking + OverflowBuff cleanup), UNIT_CASTEVENT (SuperWoW),
-- PLAYER_TARGET_CHANGED, UNIT_AURA (SeedUnit)
@@ -700,7 +357,6 @@ end
function Extension.OnLoad()
Extension.DLOG("Extension pfUI Loaded.")
Extension.HookMethod(CleveRoids, "GetFocusName", "FocusNameHook", true)
-- Export extension for external access
CleveRoids.Compatibility_pfUI = Extension
@@ -743,7 +399,7 @@ end
function Extension.OnPlayerLogin()
-- Ensure lib.objects is linked correctly (InitPfUIIntegration is idempotent).
if pfUI and not CleveRoids.hasPfUI76 then
if pfUI then
local lib = CleveRoids.libdebuff
if lib and lib.InitPfUIIntegration then
lib:InitPfUIIntegration()
@@ -775,9 +431,6 @@ function Extension.OnPlayerLogin()
-- Print startup status only if pfUI global exists and compatibility was set up
if Extension.pfUILoaded and pfUI then
local statusMsg = "|cff00ff00[SCRM]|r pfUI compatibility loaded"
if CleveRoids.hasPfUI76 then
statusMsg = statusMsg .. " (7.6+ GUID cast tracking)"
end
-- statusMsg = statusMsg .. ". Use /pfuicd for debug."
DEFAULT_CHAT_FRAME:AddMessage(statusMsg)
if not Extension.actionHandlerRegistered then
+339 -406
View File
@@ -197,102 +197,6 @@ local function IsSharedDebuffByIdOrName(lib, spellID, debuffName)
return false
end
-- PERFORMANCE: Equipment cache for HasGearEquipped (avoids 19-slot scan per call)
-- Invalidated on UNIT_INVENTORY_CHANGED via CleveRoids.InvalidateEquipmentCache()
-- Enhanced with Nampower v2.18+ GetEquippedItems when available
local _equippedItemIDs = {} -- [slot] = itemID (number)
local _equippedItemNames = {} -- [slot] = itemName (lowercase string)
local _equipmentCacheValid = false
-- Track if we've warned about GetEquippedItems errors (warn once per session)
local _getEquippedItemsErrorWarned = false
local function BuildEquipmentCache()
if _equipmentCacheValid then return end
_equipmentCacheValid = true
-- Clear old data
for i = 1, 19 do
_equippedItemIDs[i] = nil
_equippedItemNames[i] = nil
end
local string_find = string.find
local string_lower = string.lower
-- Try Nampower GetEquippedItems for faster enumeration
-- Requires v2.22+ because earlier versions (e.g., v2.19.1) have internal bug
local API = CleveRoids.NampowerAPI
local hasValidNampower = API and API.HasMinimumVersion and API.HasMinimumVersion(2, 22, 0)
if hasValidNampower and GetEquippedItems then
-- Use pcall to catch any internal Nampower errors and fall back gracefully
local success, result = pcall(GetEquippedItems, "player")
if not success then
-- Log the error once per session for debugging
if not _getEquippedItemsErrorWarned then
_getEquippedItemsErrorWarned = true
local errMsg = tostring(result)
if CleveRoids.Print then
CleveRoids.Print("|cffff6600Warning:|r GetEquippedItems failed: " .. errMsg)
CleveRoids.Print("Using fallback equipment detection. Consider updating Nampower.")
end
end
-- Fall through to manual enumeration
elseif result and type(result) == "table" then
local usedNampower = false
for nampowerSlot, itemInfo in pairs(result) do
-- Nampower uses 0-indexed slots, WoW API uses 1-indexed
-- tonumber() handles both string and numeric keys from different Nampower versions
-- Skip non-numeric keys (metadata fields, etc.)
local slotNum = tonumber(nampowerSlot)
if slotNum and type(itemInfo) == "table" and itemInfo.itemId then
local slot = slotNum + 1
-- itemInfo must be a table to access .itemId (userdata from some Nampower versions is not indexable)
if slot >= 1 and slot <= 19 then
_equippedItemIDs[slot] = itemInfo.itemId
usedNampower = true
-- Get item name via Nampower API or GetItemInfo
local itemName = API and API.GetItemName and API.GetItemName(itemInfo.itemId)
if not itemName then
itemName = GetItemInfo(itemInfo.itemId)
end
if itemName then
_equippedItemNames[slot] = string_lower(itemName)
end
end
end
end
if usedNampower then
return -- Done with Nampower path
end
-- Fall through to manual enumeration if Nampower returned userdata items
end
end
-- Fallback: manual slot enumeration via ClassicAPI (id + decorated name),
-- no link string built. C_Item.GetItemName carries random-suffix decoration
-- and falls back to the base name internally, so it replaces the old
-- bracket-name / GetItemInfo two-step in a single call.
for slot = 1, 19 do
local id = GetInventoryItemID("player", slot)
if id then
_equippedItemIDs[slot] = id
local name = C_Item.GetItemName({ equipmentSlotIndex = slot })
if name then
_equippedItemNames[slot] = string_lower(name)
end
end
end
end
-- Invalidate equipment cache (call on UNIT_INVENTORY_CHANGED)
function CleveRoids.InvalidateEquipmentCache()
_equipmentCacheValid = false
end
-- ============================================================================
-- PERFORMANCE: Unified item location lookup using CleveRoids.Items cache
-- Returns: { type="inventory"|"bag", inventoryID=N } or { type="bag", bag=N, slot=N }
@@ -875,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
@@ -950,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
@@ -1069,43 +897,6 @@ local function OnAutoAttackOther(attackerGuid, targetGuid, totalDamage, hitInfo,
CleveRoids.LastSwing.resistAmount = totalResist or 0
CleveRoids.LastSwing.targetGuid = targetGuid
-- Paladin: refresh active Judgements on melee hit (Nampower fallback for UNIT_CASTEVENT)
if CleveRoids.playerClass == "PALADIN" and targetGuid then
local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil
if lib and lib.objects then
local normalizedTarget = CleveRoids.NormalizeGUID(targetGuid)
if normalizedTarget and lib.objects[normalizedTarget] then
for spellID, rec in pairs(lib.objects[normalizedTarget]) do
if lib.judgementSpells and lib.judgementSpells[spellID] and rec.start and rec.duration then
local remaining = rec.duration + rec.start - GetTime()
if remaining > 0 and rec.caster == "player" then
rec.start = GetTime()
if CleveRoids.debug then
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
local baseName = CleveRoids.StripRank(spellName) or "Unknown"
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00ffaa[Judgement Refresh]|r Refreshed %s (ID:%d) on melee hit - new duration: %ds",
baseName, spellID, rec.duration)
)
end
-- Sync to pfUI if loaded (pre-7.6 only)
if not CleveRoids.hasPfUI76 and 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")
local targetLevel = UnitLevel("target") or 0
if targetName and baseName then
pfUI.api.libdebuff:AddEffect(targetName, targetLevel, baseName, rec.duration, "player")
end
end
end
end
end
end
end
end
end
-- Process AUTO_ATTACK_SELF event (player being attacked)
@@ -1233,31 +1024,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,
@@ -1271,7 +1042,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] = {}
@@ -1294,7 +1065,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
@@ -1324,24 +1095,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)
@@ -1474,7 +1227,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] = {}
@@ -2060,26 +1813,12 @@ function CleveRoids.CancelAura(auraName)
return false
end
-- ClassicAPI's C_Item.IsEquippedItem walks the 19 equipment slots natively and
-- short-circuits on the first match, so it replaces the old Lua-side equipment
-- cache entirely -- no per-call scan, no invalidation. Accepts itemID, item
-- link, or (case-insensitive, decorated) name.
function CleveRoids.HasGearEquipped(gearId)
if not gearId then return false end
-- PERFORMANCE: Build/refresh equipment cache if needed
BuildEquipmentCache()
-- Handle both numeric IDs and string IDs like "5196"
local wantId = tonumber(gearId)
local wantName = (type(gearId) == "string" and not wantId) and string.lower(gearId) or nil
-- PERFORMANCE: Use cached data instead of scanning all slots
for slot = 1, 19 do
if wantId and _equippedItemIDs[slot] == wantId then
return true
end
if wantName and _equippedItemNames[slot] == wantName then
return true
end
end
return false
return (gearId and C_Item.IsEquippedItem(gearId)) or false
end
@@ -3359,6 +3098,25 @@ function CleveRoids.ValidatePowerLost(unit, operator, amount)
return false
end
-- Checks the given unit's spell haste percentage vs the given amount
-- unit: The unit we're checking
-- operator: valid comparitive operator symbol
-- amount: The required amount, in percent (0 = unhasted, negative = slowed)
-- returns: True or false
-- NOTE: the shared arg parser doesn't accept a sign on the amount, so a
-- specific negative threshold ([myspellhaste:<-10]) can't be written; use
-- [myspellhaste:<0] to test for being slowed at all.
function CleveRoids.ValidateSpellHaste(unit, operator, amount)
if not unit or not operator or not amount then return false end
local haste = CleveRoids.ClassicAPI.UnitSpellHaste(unit)
if CleveRoids.operators[operator] then
return CleveRoids.comparators[operator](haste, amount)
end
return false
end
-- Checks whether or not the given unit has hp in percent vs the given amount
-- unit: The unit we're checking
-- operator: valid comparitive operator symbol
@@ -3569,6 +3327,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
@@ -3827,148 +3630,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
@@ -5005,7 +4716,7 @@ function CleveRoids.ValidatePlayerAuraCount(bigger, amount)
end
function CleveRoids.IsReactive(name)
return CleveRoids.reactiveSpells[spellName] ~= nil
return CleveRoids.reactiveSpells[name] ~= nil
end
-- NOTE: CleveRoids.GetActionButtonInfo is defined in Extensions/Tooltip/Generic.lua
@@ -5513,8 +5224,171 @@ local function MakeTypedPowerKeyword(condKey, unitDefault, powerType)
end
end
local PET_HAPPINESS_STATES = {
unhappy = 1,
content = 2,
happy = 3,
}
local function ResolvePetHappinessState(value)
local state = tonumber(value)
if state then
return state
end
if type(value) ~= "string" then
return nil
end
return PET_HAPPINESS_STATES[GetLowercaseString(value)]
end
-- ClassicAPI's totem bar is TBC-ordered: SUMMON_TOTEM_SLOT1..4 in Spell.dbc.
local TOTEM_SLOTS = {
fire = 1,
earth = 2,
water = 3,
air = 4,
["1"] = 1,
["2"] = 2,
["3"] = 3,
["4"] = 4,
}
-- Resolve a [totem:X] argument to a slot. An element name or slot number names
-- the slot directly; anything else is matched against the name of the totem
-- standing in each slot, so [totem:Searing_Totem] asks for that totem rather
-- than "whatever is in the fire slot". nil when neither matches -- which
-- includes naming a totem that is not currently out.
local function ResolveTotemSlot(name)
if type(name) ~= "string" or name == "" then return nil end
local searchName = GetLowercaseString(
CleveRoids.Trim(string.gsub(CleveRoids.StripRank(name), "_", " ")))
local slot = TOTEM_SLOTS[searchName]
if slot then return slot end
for i = 1, 4 do
-- Second return, not the first: GetTotemInfo's haveTotem reports whether
-- the player carries the slot's TOOL item, not whether a totem is out.
local _, totemName = GetTotemInfo(i)
if totemName and totemName ~= "" and GetLowercaseString(totemName) == searchName then
return i
end
end
end
-- True while any totem slot has a timer running. Drives the OnUpdate re-test
-- that keeps [totem:X<N] icons honest -- see the caller for why polling is the
-- only option. GetTotemTimeLeft, not GetTotemInfo: this asks whether a countdown
-- is in progress (a totem with no timer has nothing to go stale), and it reads
-- the slot alone, where GetTotemInfo walks the bags for the tool item.
function CleveRoids.AnyTotemTimerRunning()
for i = 1, 4 do
local timeLeft = GetTotemTimeLeft(i)
if timeLeft and timeLeft > 0 then return true end
end
return false
end
-- [totem:X] / [nototem:X], shaped like the aura validators: X is a plain name or
-- a parsed comparison entry, comparisons read seconds left, and `#N` stack
-- comparisons read 1 for a standing totem -- a totem is either up or it isn't.
--
-- An empty slot reads -1 on both axes, the same "missing counts as least" the
-- aura path uses, so [totem:Searing_Totem<5] passes while the totem is expiring
-- AND while it is absent: one clause for the whole recast macro.
local function ValidateTotem(args)
if not args then return false end
if type(args) ~= "table" then
args = { name = args }
end
local remaining, stacks = -1, -1
local slot = ResolveTotemSlot(args.name)
if slot then
-- Occupancy comes from the name, not from the time left: a totem whose
-- summon spell carries no SpellDuration row reads 0 seconds, and that is
-- "up with no timer", not "absent".
local _, totemName = GetTotemInfo(slot)
if totemName and totemName ~= "" then
stacks = 1
remaining = GetTotemTimeLeft(slot)
end
end
local ops = CleveRoids.operators
local cmp = CleveRoids.comparators
-- Multi-comparison (e.g. >2&<8) - ALL must pass
if args.comparisons and type(args.comparisons) == "table" then
for _, comp in ipairs(args.comparisons) do
if not ops[comp.operator] then return false end
local value = comp.checkStacks and stacks or remaining
if not cmp[comp.operator](value, comp.amount) then return false end
end
return true
end
if not args.amount and not args.operator and not args.checkStacks then
return stacks == 1
elseif args.amount and ops[args.operator] then
return cmp[args.operator](args.checkStacks and stacks or remaining, args.amount)
else
return false
end
end
-- A list of Conditionals and their functions to validate them
CleveRoids.Keywords = {
-- [button:N] — N is the button that INVOKED this action (1=Left, 2=Right,
-- 3=Middle, 4/5=extra), not one held down as a modifier. That is retail's
-- meaning, and it is why a keybind press counts as button 1: retail activates
-- through the left-click path, so [button:1] passes for a keybind and a
-- left-click alike, while [button:2] passes only for an actual right-click.
-- Routed through Multi so OR/AND lists behave like every other argument
-- conditional ([button:1/2] = invoked by left or right).
--
-- Bare [button] / [nobutton] ask whether a click drove this at all -- the one
-- thing the underlying data says that retail's conditional cannot express,
-- and the practical "activated by a keybind, not a click" test.
button = function(conditionals)
if type(conditionals.button) ~= "table" then
return CleveRoids.WasClickActivated()
end
local invoking = CleveRoids.GetActivatingButton()
return Multi(conditionals.button, function(button)
return CleveRoids.buttons[button] == invoking
end, conditionals, "button")
end,
nobutton = function(conditionals)
if type(conditionals.nobutton) ~= "table" then
return not CleveRoids.WasClickActivated()
end
local invoking = CleveRoids.GetActivatingButton()
return NegatedMulti(conditionals.nobutton, function(button)
return CleveRoids.buttons[button] ~= invoking
end, conditionals, "nobutton")
end,
-- [totem:X] — X names a totem-bar slot (fire/earth/water/air, or 1-4) or the
-- totem itself ([totem:Searing_Totem]). Bare [totem] takes the action as its
-- argument, as [mybuff] does, so /cast [nototem] Searing Totem is the whole
-- recast macro. Time and stack comparisons follow the aura precedent; see
-- ValidateTotem.
totem = function(conditionals)
return Multi(conditionals.totem, function(v)
return ValidateTotem(v)
end, conditionals, "totem")
end,
nototem = function(conditionals)
return NegatedMulti(conditionals.nototem, function(v)
return not ValidateTotem(v)
end, conditionals, "nototem")
end,
exists = function(conditionals)
return UnitExists(conditionals.target)
end,
@@ -6551,6 +6425,30 @@ CleveRoids.Keywords = {
end, conditionals, "mylevel")
end,
myspellhaste = function(conditionals)
return Multi(conditionals.myspellhaste, function(args)
if type(args) ~= "table" then return false end
-- Handle multi-comparison (e.g., >50&<80)
if args.comparisons and type(args.comparisons) == "table" then
local haste = CleveRoids.ClassicAPI.UnitSpellHaste("player")
-- ALL comparisons must pass (AND logic)
for _, comp in ipairs(args.comparisons) do
if not CleveRoids.operators[comp.operator] then
return false
end
if not CleveRoids.comparators[comp.operator](haste, comp.amount) then
return false
end
end
return true
end
return CleveRoids.ValidateSpellHaste("player", args.operator, args.amount)
end, conditionals, "myspellhaste")
end,
myhp = function(conditionals)
return Multi(conditionals.myhp, function(args)
if type(args) ~= "table" then return false end
@@ -7332,6 +7230,39 @@ CleveRoids.Keywords = {
end, conditionals, "nopet")
end,
-- Hunter pet happiness: 1=unhappy, 2=content, 3=happy.
pethappiness = function(conditionals)
local happiness = GetPetHappiness()
local _, isHunterPet = HasPetUI()
if not isHunterPet or not happiness then
return false
end
if conditionals.pethappiness == true then
return true
end
return Multi(conditionals.pethappiness, function(requiredState)
return happiness == ResolvePetHappinessState(requiredState)
end, conditionals, "pethappiness")
end,
nopethappiness = function(conditionals)
local happiness = GetPetHappiness()
local _, isHunterPet = HasPetUI()
if not isHunterPet or not happiness then
return true
end
if conditionals.nopethappiness == true then
return false
end
return NegatedMulti(conditionals.nopethappiness, function(forbiddenState)
return happiness ~= ResolvePetHappinessState(forbiddenState)
end, conditionals, "nopethappiness")
end,
-- [focus] / [nofocus] - whether a focus is set. Covers pfUI's emulated focus
-- and ClassicAPI's native focus token (set via /focus or the FOCUSTARGET keybind).
focus = function(conditionals)
@@ -9390,6 +9321,8 @@ CleveRoids.STATIC_CONDITIONALS = {
inbag = true, noinbag = true,
mod = true, nomod = true,
keydown = true, nokeydown = true,
button = true, nobutton = true,
totem = true, nototem = true,
swimming = true, noswimming = true, swim = true, noswim = true,
indoors = true, noindoors = true, outdoors = true, nooutdoors = true,
rooted = true, norooted = true,
+33 -8
View File
@@ -130,6 +130,12 @@ local StopAttack = function(msg)
CleveRoids.DeferStopAttack()
end
local StopChanneling = function(msg)
-- No Blizzard equivalent: 1.12 has SpellStopCasting (immediate) and nothing
-- that waits for the next channel tick, so this is nampower-only.
CleveRoids.StopChanneling()
end
-- Register slash commands and assign original handlers.
-- These will be hooked immediately after.
SLASH_STARTATTACK1 = "/startattack"
@@ -141,6 +147,9 @@ SlashCmdList.STOPATTACK = StopAttack
SLASH_STOPCASTING1 = "/stopcasting"
SlashCmdList.STOPCASTING = SpellStopCasting
SLASH_STOPCHANNELING1 = "/stopchanneling"
SlashCmdList.STOPCHANNELING = StopChanneling
SLASH_CLEARTARGET1 = "/cleartarget"
SlashCmdList.CLEARTARGET = ClearTarget
@@ -202,6 +211,21 @@ SlashCmdList.STOPCASTING = function(msg)
end
end
-- /stopchanneling hook
CleveRoids.Hooks.STOPCHANNELING_SlashCmd = SlashCmdList.STOPCHANNELING
SlashCmdList.STOPCHANNELING = function(msg)
if CleveRoids.stopMacroFlag then return end
msg = msg or ""
if string.find(msg, "%[") then
-- If conditionals are present, let the function handle it.
-- It will only stop the channel if the conditions are met.
CleveRoids.DoConditionalStopChanneling(msg)
else
-- If no conditionals, run the original command.
CleveRoids.Hooks.STOPCHANNELING_SlashCmd(msg)
end
end
-- /unqueue hook
CleveRoids.Hooks.UNQUEUE_SlashCmd = SlashCmdList.UNQUEUE
SlashCmdList.UNQUEUE = function(msg)
@@ -334,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)
+652 -1042
View File
File diff suppressed because it is too large Load Diff
+41 -25
View File
@@ -697,30 +697,9 @@ local function EnsureCondHighlightPool()
end
end
-- Test whether conditionals pass for a given command + alternative text.
-- Returns: true (passes), false (fails), nil (unconditional / no conditionals)
local function TestConditionalPasses(cmd, alternative)
-- Strip ? tooltip hints (irrelevant for conditional evaluation)
if string.find(alternative, "?", 1, true) then
alternative = string.gsub(alternative, "%?", "")
end
local hasConditional = string.find(alternative, "%[") ~= nil
-- Dynamic commands: delegate to TestAction
if CleveRoids.dynamicCmds[cmd] then
local result = CleveRoids.TestAction(cmd, alternative)
if not hasConditional then
return nil -- unconditional
end
return result ~= nil and result ~= false
end
-- Non-dynamic commands: parse and evaluate Keywords manually
if not hasConditional then
return nil -- unconditional
end
-- Evaluate one single-group clause of a non-dynamic command against Keywords.
-- Returns: true (passes), false (fails), nil (could not parse)
local function EvaluateNonDynamic(alternative)
local ok, action, conditionals = pcall(CleveRoids.GetParsedMsg, alternative)
if not ok or not conditionals then
return nil
@@ -745,6 +724,43 @@ local function TestConditionalPasses(cmd, alternative)
return passes
end
-- Test whether conditionals pass for a given command + alternative text.
-- Returns: true (passes), false (fails), nil (unconditional / no conditionals)
local function TestConditionalPasses(cmd, alternative)
-- Strip ? tooltip hints (irrelevant for conditional evaluation)
if string.find(alternative, "?", 1, true) then
alternative = string.gsub(alternative, "%?", "")
end
local hasConditional = string.find(alternative, "%[") ~= nil
-- Dynamic commands: delegate to TestAction
if CleveRoids.dynamicCmds[cmd] then
local result = CleveRoids.TestAction(cmd, alternative)
if not hasConditional then
return nil -- unconditional
end
return result ~= nil and result ~= false
end
-- Non-dynamic commands: evaluate Keywords manually. `[a][b] X` passes when
-- any of its groups does.
if not hasConditional then
return nil -- unconditional
end
local variants = CleveRoids.ExpandBracketGroups(alternative)
if not variants then
return EvaluateNonDynamic(alternative)
end
for i = 1, variants.n do
if EvaluateNonDynamic(variants[i]) then
return true
end
end
return false
end
-- Find character offset ranges for each semicolon-separated alternative.
-- Returns array of { start, finish } pairs (1-based indices within argsText).
local function FindAlternativeOffsets(argsText)
@@ -1164,7 +1180,7 @@ local function ReportAllMacroErrors()
-- Collect body (syntax) errors per macro. Macro names are no longer
-- restricted (slot/index-based identification), so no name checks here.
for i = 1, 36 do
for i = 1, CleveRoids.MAX_MACRO_SLOTS do
local nameOk, name = pcall(GetMacroInfo, i)
if nameOk and name and name ~= "" then
local errors = {}
+249 -16
View File
@@ -2,18 +2,15 @@
Author: Dennis Werner Garske (DWG) / brian / Mewtiny
License: MIT License
pfUI integration. pfUI's own unitframes now set the native mouseover unit
(Nampower SetMouseoverUnit, via pfUI.uf.OnEnter bound in pfUI.uf:EnableScripts
on every unitframe), so [@mouseover]/[mouseover] resolve against pfUI frames
through the native "mouseover" token -- every consumer checks UnitExists(
"mouseover") before the CleveRoids.mouseoverUnit fallback, so no per-frame
hooking is needed here anymore. What remains is the two things pfUI doesn't
cover:
- Raid-marker rows: NOT unitframes (never go through EnableScripts), so pfUI
sets no mouseover for them. Hooked so hovering registers "mark1".."mark8"
via CleveRoids.mouseoverUnit.
- /pfcast: wrapped so its argument runs through CleveRoids conditionals.
Fixes pfUI mouseover issues by:
- Using a unique source key per pfUI frame (e.g., "pfui:party3", "pfui:raid7")
- Pairing Set/Clear with the same per-frame key
- Resolving a real UnitID when .unit isn't set
- Properly hooking party group[0] (your own party slot) with a safe closure and defaulting to "player"
]]
if pfPlayer and pfPlayer.GetAttribute and pfPlayer:GetAttribute('unit') == 'player' then return end
local _G = _G or getfenv(0)
local CleveRoids = _G.CleveRoids or {}
@@ -37,7 +34,6 @@ local function ResolvePfUnit(frame, fallbackName)
name = strlower(name)
local candidates = { "target", "targettarget", "player", "pet" }
local i
for i = 1, 4 do
table.insert(candidates, "party"..i)
table.insert(candidates, "partypet"..i)
@@ -101,15 +97,242 @@ local function PfClear(frame)
end
end
-- PLAYER
function Extension.RegisterPlayerScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.player then return end
local frame = pfUI.uf.player
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this, "player")
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- TARGET
function Extension.RegisterTargetScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.target then return end
local frame = pfUI.uf.target
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this, "target")
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- TARGETTARGET
function Extension.RegisterTargetTargetScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.targettarget then return end
local frame = pfUI.uf.targettarget
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this, "targettarget")
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- PARTY (pfUI.uf.group[0..4]) -- include 0 to cover your own party slot
function Extension.RegisterPartyScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.group then return end
for i = 0, 4 do
local frame = pfUI.uf.group[i]
if frame then
-- bind loop index for closures (Vanilla-safe)
local idx = i
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
-- For group[0] (your own party frame), default to "player"
local defaultUnit = (idx == 0) and "player" or nil
PfSet(this, defaultUnit)
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
end
end
-- RAID (pfUI.uf.raid[1..40])
function Extension.RegisterRaidScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.raid then return end
for i = 1, 40 do
local frame = pfUI.uf.raid[i]
if frame then
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this)
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
end
end
-- FOCUS
function Extension.RegisterFocusScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.focus then return end
local frame = pfUI.uf.focus
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this) -- ResolvePfUnit handles focus emulation
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- FOCUSTARGET (if your pfUI build provides it)
function Extension.RegisterFocusTargetScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.focustarget then return end
local frame = pfUI.uf.focustarget
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this)
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- PETTARGET (if your pfUI build provides it)
function Extension.RegisterPetTargetScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.pettarget then return end
local frame = pfUI.uf.pettarget
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this)
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- TARGETTARGETTARGET (if your pfUI build provides it)
function Extension.RegisterTargetTargetTargetScripts()
if not pfUI or not pfUI.uf or not pfUI.uf.targettargettarget then return end
local frame = pfUI.uf.targettargettarget
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this)
if onEnterFunc then onEnterFunc(this) end
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- PARTYTARGET (party1target..party4target, plus player's target)
function Extension.RegisterPartyTargetScripts()
if not pfUI or not pfUI.uf then return end
-- This helper function is used to hook any given frame.
local function hookFrame(frame, defaultUnit)
if not frame then return end
local onEnterFunc = frame:GetScript("OnEnter")
local onLeaveFunc = frame:GetScript("OnLeave")
frame:SetScript("OnEnter", function()
PfSet(this, defaultUnit)
-- We remove the call to the original onEnterFunc to prevent overwritten tooltips.
end)
frame:SetScript("OnLeave", function()
PfClear(this)
if onLeaveFunc then onLeaveFunc(this) end
end)
end
-- This helper function is specifically for party member targets (1-4)
local function hookPartyMemberTarget(i, frame)
if not frame then return end
local defaultUnit = "party" .. i .. "target"
hookFrame(frame, defaultUnit)
end
-- Case A: Hook dedicated arrays for party members 1-4
if pfUI.uf.grouptarget then
for i = 1, 4 do hookPartyMemberTarget(i, pfUI.uf.grouptarget[i]) end
end
if pfUI.uf.partytarget then
for i = 1, 4 do hookPartyMemberTarget(i, pfUI.uf.partytarget[i]) end
end
-- Case B: Hook child target frames for party members 1-4
if pfUI.uf.group then
for i = 1, 4 do
local g = pfUI.uf.group[i]
if g and g.target then hookPartyMemberTarget(i, g.target) end
end
end
--- START OF FIX to include party0target ---
-- Case C: Specifically find and hook the player's own target frame (group[0].target)
if pfUI.uf.group and pfUI.uf.group[0] and pfUI.uf.group[0].target then
-- The player's target UnitID is always "target", not "party0target".
hookFrame(pfUI.uf.group[0].target, "target")
end
--- END OF FIX ---
end
-- RAID MARKERS (pfUI raidmarkers module)
-- Rows are plain Buttons with label="mark" and id=1-8. They have no OnEnter/OnLeave
-- by default, so [mouseover] macros are blind to them. We hook each row so hovering
-- registers "mark1".."mark8" through the normal priority system. pfUI's own
-- SetMouseoverUnit path only covers unitframes, so this stays.
-- registers "mark1".."mark8" through the normal priority system.
function Extension.RegisterRaidMarkScripts()
if not pfUI or not pfUI.raidmarkers or not pfUI.raidmarkers.rows then return end
local i
for i = 1, 8 do
local row = pfUI.raidmarkers.rows[i]
if row then
@@ -147,7 +370,17 @@ function Extension.HookPfCast()
end
function Extension.PLAYER_ENTERING_WORLD()
if not pfUI then return end
if not pfUI or not pfUI.uf then return end
Extension.RegisterPlayerScripts()
Extension.RegisterTargetScripts()
Extension.RegisterTargetTargetScripts()
Extension.RegisterPartyScripts()
Extension.RegisterPartyTargetScripts()
Extension.RegisterRaidScripts()
Extension.RegisterFocusScripts()
Extension.RegisterFocusTargetScripts()
Extension.RegisterPetTargetScripts()
Extension.RegisterTargetTargetTargetScripts()
Extension.RegisterRaidMarkScripts()
Extension.HookPfCast()
end
+60 -43
View File
@@ -156,53 +156,27 @@ end
-- Lightweight equipment-only indexing for combat situations
-- Updates existing cache rather than rebuilding it
function CleveRoids.IndexEquippedItems()
local items = CleveRoids.Items or {}
for inventoryID = 1, 19 do
local itemID = GetInventoryItemID("player", inventoryID)
if itemID then
local name, link, _, _, itemType, itemSubType, _, _, texture = GetItemInfo(itemID)
if name then
local count = GetInventoryItemCount("player", inventoryID)
if not items[name] then
items[name] = {
inventoryID = inventoryID,
id = itemID,
name = name,
count = count,
texture = texture,
link = link,
}
items[itemID] = name
local lowerName = string.lower(name)
if lowerName ~= name then
items[lowerName] = name
end
else
-- Update existing entry with current equipment state
items[name].inventoryID = inventoryID
items[name].count = count
end
end
else
-- Slot is now empty - clear inventoryID from any item that was there
-- This is handled lazily by GetItem() fallback, so we skip expensive iteration
end
end
CleveRoids.lastGetItem = nil
CleveRoids.Items = items
end
-- PERFORMANCE: Index a single equipment slot instead of all 20
-- Use when we know exactly which slot changed (e.g., from EquipBagItem)
function CleveRoids.IndexEquipSlot(inventoryID)
-- Use when we know exactly which slot changed (e.g., from EquipBagItem or
-- PLAYER_EQUIPMENT_CHANGED). hasCurrent is the event's arg2 (does the slot now
-- hold an item); pass false to skip the API probe on a slot we know is empty.
function CleveRoids.IndexEquipSlot(inventoryID, hasCurrent)
if not inventoryID then return end
local items = CleveRoids.Items or {}
local itemID = GetInventoryItemID("player", inventoryID)
-- Clear any stale inventoryID still pointing at this slot: the item that was
-- here is now unequipped or swapped out (its real location comes from the
-- paired BAG_UPDATE rebuild). Equip changes are user-paced, so this table
-- scan is off the hot path.
for _, entry in pairs(items) do
if type(entry) == "table" and entry.inventoryID == inventoryID then
entry.inventoryID = nil
end
end
-- hasCurrent == false (arg2) => slot is now empty, nothing to add.
local itemID = hasCurrent ~= false and GetInventoryItemID("player", inventoryID)
if itemID then
local name, itemLink, _, _, itemType, itemSubType, _, _, texture = GetItemInfo(itemID)
if name then
@@ -264,6 +238,14 @@ function CleveRoids.IndexItems()
local items = {}
local NUM_BAG_SLOTS = NUM_BAG_SLOTS -- Upvalue for bag constant
-- Rebuilt each pass: itemIDs the player owns that GetItemInfo couldn't
-- resolve yet (cold cache under ClassicAPI's async warmup). The
-- GET_ITEM_INFO_RECEIVED handler consults this so it only re-indexes for
-- our own uncached items and ignores the flood of unrelated fills (quest
-- DB scans, AH, chat-link hovers, inspects) in O(1).
local pendingItemInfo = {}
CleveRoids.pendingItemInfo = pendingItemInfo
-- PERFORMANCE: Local function references
local GetContainerNumSlots = GetContainerNumSlots
local GetContainerItemInfo = GetContainerItemInfo
@@ -307,6 +289,9 @@ function CleveRoids.IndexItems()
if lowerName ~= name then
items[lowerName] = name
end
else
-- Owned but not cached yet; wait for GET_ITEM_INFO_RECEIVED.
pendingItemInfo[itemID] = true
end
end
end
@@ -344,6 +329,9 @@ function CleveRoids.IndexItems()
if lowerName ~= name then
items[lowerName] = name
end
else
-- Owned but not cached yet; wait for GET_ITEM_INFO_RECEIVED.
pendingItemInfo[itemID] = true
end
end
end
@@ -435,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
@@ -447,6 +437,27 @@ end
function CleveRoids.GetSpell(text)
text = CleveRoids.Trim(text)
-- Explicit "spell:<id>" form. If the player knows the spell, reuse its cached
-- spellbook entry (full cost/cooldown/usability). FindSpellBookSlotByID
-- (ClassicAPI) resolves per-rank IDs and pet spells natively and returns the
-- same bookType string CleveRoids.Spells is keyed by. If the spell is not in
-- either book, fall back to an id-only entry that SetAction renders via
-- SetSpellByID; cost=0 keeps the downstream active-action checks nil-safe.
local _, _, spellId = string.find(text, "^spell:(%d+)$")
if spellId then
spellId = tonumber(spellId)
local slot, book = FindSpellBookSlotByID(spellId)
if slot then
local name, rank = GetSpellInfo(slot, book)
local byName = name and CleveRoids.Spells[book] and CleveRoids.Spells[book][name]
if byName then
return (rank and rank ~= "" and byName[rank]) or byName.highest or byName
end
end
return { id = spellId, texture = C_Spell.GetSpellTexture(spellId) or CleveRoids.unknownTexture, cost = 0 }
end
local rs, _, rank = string.find(text, "[^%s]%((Rank %d+)%)$")
local name = rank and string.sub(text, 1, rs) or text
@@ -534,6 +545,12 @@ end
function CleveRoids.GetItem(text)
if not text or text == "" then return end
-- Explicit "item:<id>" form: force resolution by item ID. Reuses the numeric
-- lookup below (equipped -> bags -> GetItemInfo), so location-aware tooltip
-- rendering (SetInventoryItem/SetBagItem) still applies when the player has it.
local _, _, prefixedId = string.find(text, "^item:(%d+)$")
if prefixedId then text = prefixedId end
local Items = CleveRoids.Items
local item = Items[text] or Items[tostring(text)]
if not item then
+18
View File
@@ -66,6 +66,10 @@ function CleveRoids.RegisterExtension(name)
CleveRoids.RegisterEvent(name, eventName, callbackName)
end
extension.RegisterUnitEvent = function(eventName, callbackName, ...)
CleveRoids.RegisterUnitEvent(name, eventName, callbackName, unpack(arg))
end
extension.Hook = function(functionName, callbackName, dontCallOriginal)
CleveRoids.RegisterHook(name, functionName, callbackName, dontCallOriginal)
end
@@ -146,6 +150,20 @@ function CleveRoids.RegisterEvent(extensionName, eventName, callbackName)
extension.internal.frame:RegisterEvent(eventName)
end
-- Registers a callback for a UNIT_* event, filtered to the given unit tokens.
-- The callback then only runs for those units: the client drops every other
-- unit's copy, instead of all of them reaching Lua to be compared away. Use
-- this over RegisterEvent whenever the handler starts by testing arg1.
-- extensionName: The name of the extension trying to register the callback
-- eventName: The UNIT_* event to register
-- callbackName: The name of the callback that gets called when the event fires
-- ...: the unit tokens to accept (e.g. "player", "target")
function CleveRoids.RegisterUnitEvent(extensionName, eventName, callbackName, ...)
local extension = CleveRoids.Extensions[extensionName]
extension.internal.eventHandlers[eventName] = callbackName
extension.internal.frame:RegisterUnitEvent(eventName, unpack(arg))
end
-- Hooks the given function by it's name
-- extensionName: The name of the extension trying to register the callback
-- functionName: The name of the function that'll be hooked
+41 -6
View File
@@ -19,12 +19,40 @@ CleveRoids.mouseOverResolvers = {}
CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil
CleveRoids.mouseOverUnit = nil
-- Every macro slot the client can hold: 18 account-wide (1-18) followed by 18
-- character-specific (19-36). This is the index space GetMacroInfo and
-- C_Macro.SetMacroDisplay address, and it is fixed -- GetNumMacros() returns how
-- many of each tab are *used*, which cannot be summed into a range, because the
-- character block starts at 19 no matter how few account macros exist. Blizzard's
-- own MAX_MACROS is no help either: it lives in the load-on-demand Blizzard_MacroUI
-- and is nil until the player opens the macro window.
CleveRoids.MAX_MACRO_SLOTS = 36
-- Environment flags
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.
--
-- ClassicAPI stands down from macro display entirely when it sees this addon loaded;
-- ClassicAPIMacroDisplay is what tells it we drive it instead, and ReleaseDisplays
-- clears it to hand every macro back. A fork that leaves the flag unset keeps the old
-- behavior -- both must never drive the same buttons. It doubles as the internal
-- "may we call C_Macro.SetMacroDisplay" guard, so the two can never disagree.
--
-- Feature-detect rather than version-check: SetMacroDisplay ships in ClassicAPI
-- v1.15.0, below this addon's minimum, so a nil here means the client mod is
-- missing outright -- the case Core.lua's requirement check warns about but
-- keeps running.
CleveRoids.ClassicAPIMacroDisplay =
(type(C_Macro) == "table" and C_Macro.SetMacroDisplay ~= nil) and true or false
CleveRoids.ParsedMsg = {}
CleveRoids.ExpandedGroups = {}
CleveRoids.Items = {}
CleveRoids.Spells = {}
CleveRoids.PetSpells = {}
@@ -43,13 +71,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
@@ -124,6 +150,7 @@ CleveRoids.dynamicCmds = {
["/castpet"] = true,
["/castsequence"] = true,
["/use"] = true,
["/feedpet"] = true,
["/equip"] = true,
["/equipmh"] = true,
["/equipoh"] = true,
@@ -186,11 +213,19 @@ CleveRoids.ignoreKeywords = {
_operators = true, -- Metadata for AND/OR operator tracking
_groups = true, -- Grouped conditional values for AND/OR evaluation
multiscan = true, -- Processed before Keywords loop (target resolution)
mouseuse = true, -- Post-cast modifier: auto-click AOE targeting circle at cursor
cursor = true, -- Modifier: place ground-target spell/item at cursor (CastAtCursor)
stopattack = true, -- Post-cast modifier: stop autoattack after cast (CheapShot pattern)
}
-- Deprecated conditional names, rewritten to their current keyword in ParseMsg
-- before anything downstream (evaluation, _groups) sees them. This is the path
-- for renamed *modifiers* -- ignoreKeywords entries, which have no Keywords
-- predicate that an alias could just point at the way [stl] does for [stealth].
-- MacroErrorChecker also reads this table, so the old names stay valid syntax.
CleveRoids.conditionalAliases = {
mouseuse = "cursor", -- pre-ClassicAPI name, back when it clicked the AoE reticle
}
-- TODO: Localize?
CleveRoids.countedItemTypes = {
["Consumable"] = true,
+39 -28
View File
@@ -35,6 +35,14 @@ if CleveRoids.ignoreKeywords then
end
end
-- Deprecated names still valid in macros; ParseMsg rewrites them to the current
-- keyword, so they never reach Keywords/ignoreKeywords under their old name.
if CleveRoids.conditionalAliases then
for alias, _ in pairs(CleveRoids.conditionalAliases) do
VALID_CONDITIONALS[alias] = true
end
end
-- Known valid commands
local VALID_COMMANDS = {
-- Core commands NOT registered via SlashCmdList (so not auto-discoverable):
@@ -498,6 +506,7 @@ local COMMANDS_NO_ACTION_NEEDED = {
["/startattack"] = true,
["/stopattack"] = true,
["/stopcasting"] = true,
["/stopchanneling"] = true,
["/unqueue"] = true,
["/retarget"] = true,
["/stopmacro"] = true,
@@ -552,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",
@@ -661,6 +669,7 @@ local function validateConditional(conditional, args, action)
button = true,
form = true, stance = true,
level = true, mylevel = true,
myspellhaste = true,
distance = true, nodistance = true,
swingtimer = true, stimer = true,
rangedtimer = true, rtimer = true,
@@ -681,7 +690,7 @@ local function validateConditional(conditional, args, action)
if args and type(args) == "string" then
local hasHpOrPower = safeStringFind(baseCond, "hp") or safeStringFind(baseCond, "power") or
safeStringFind(baseCond, "mana") or safeStringFind(baseCond, "energy") or
safeStringFind(baseCond, "rage") or
safeStringFind(baseCond, "rage") or safeStringFind(baseCond, "haste") or
safeStringFind(baseCond, "combo") or baseCond == "stat"
if hasHpOrPower then
local hasOperator = safeStringFind(args, "[<>=~]+")
@@ -839,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)
@@ -862,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
@@ -926,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*)")
@@ -1075,17 +1096,7 @@ function CleveRoids.ValidateAllMacros()
local results = {}
local totalErrors = 0
-- Account-wide macros are indexed from 1 up to GetNumMacros().
-- Character-specific macros occupy the slots immediately following the account-wide ones.
-- In Classic clients, the macro UI has 18 General (Account) slots and 18 Character-Specific slots.
local numAccountMacros = GetNumMacros()
-- The WoW API GetMacroInfo(index) supports indexing up to 36 (1-18 for General, 19-36 for Character)
-- in Classic clients, even though the total is GetNumMacros() + GetNumCharacterMacros() in Retail.
-- To ensure we check all 36 possible slots:
local totalSlots = 36
for i = 1, totalSlots do
for i = 1, CleveRoids.MAX_MACRO_SLOTS do
local nameSuccess, name = pcall(GetMacroInfo, i)
-- Check if GetMacroInfo returned a name (i.e., the slot is used)
+5 -5
View File
@@ -1474,7 +1474,7 @@ end
-- Get all equipped items for a unit
-- Requires v2.18+ for native GetEquippedItems (with Lua fallback for player only)
-- Returns table with slot indices (0-18) as keys, item info tables as values
-- Returns table with slot indices (1-19) as keys, item info tables as values
function API.GetEquippedItems(unitToken)
unitToken = unitToken or "player"
@@ -1489,8 +1489,8 @@ function API.GetEquippedItems(unitToken)
end
local items = {}
for slot = 0, 18 do
local itemId = CleveRoids.ClassicAPI.GetInventoryItemID("player", slot + 1)
for slot = 1, 19 do
local itemId = CleveRoids.ClassicAPI.GetInventoryItemID("player", slot)
if itemId then
items[slot] = {
itemId = itemId,
@@ -1518,7 +1518,7 @@ function API.GetEquippedItem(unitToken, slot)
return nil
end
local itemId = CleveRoids.ClassicAPI.GetInventoryItemID("player", slot + 1) -- 1-indexed
local itemId = CleveRoids.ClassicAPI.GetInventoryItemID("player", slot) -- both are 1-indexed
if itemId then
return {
itemId = itemId,
@@ -1672,7 +1672,7 @@ function API.IsItemInSlot(itemIdOrName, inventorySlot)
-- Use native v2.18 GetEquippedItem if available
if GetEquippedItem then
local slotInfo = GetEquippedItem("player", inventorySlot - 1) -- 0-indexed
local slotInfo = GetEquippedItem("player", inventorySlot) -- 1-indexed (1-19)
if slotInfo and slotInfo.itemId then
local checkId = tonumber(itemIdOrName)
if checkId then
+3 -2
View File
@@ -10,7 +10,7 @@ Enhanced macro addon for World of Warcraft 1.12.1 (Vanilla/Turtle WoW) with dyna
|-----|:--------:|---------|
| [Nampower](https://github.com/brues-code/nampower/releases) (v3.0.0+) | ✅ | Spell queueing, DBC data, auto-attack events |
| [UnitXP_SP3](https://codeberg.org/konaka/UnitXP_SP3/releases) | ✅ | Distance checks, `[multiscan]` enemy scanning |
| [ClassicAPI](https://github.com/brues-code/ClassicAPI/releases) | ✅ | Modern `C_*` API: dispel-type conditionals (`[magic]`, `[curse]`, …), `[moving]` speed |
| [ClassicAPI](https://github.com/brues-code/ClassicAPI/releases) (v1.15.8+) | ✅ | Modern `C_*` API: dispel-type conditionals (`[magic]`, `[curse]`, …), `[moving]` speed, unit-filtered events |
## Installation
@@ -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:**
@@ -46,7 +47,7 @@ See the **[Wiki](https://github.com/brues-code/SuperCleveRoidMacros/wiki)** for
- **[Quick Start](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Quick-Start)** — Syntax, multi-value logic, comparisons, special prefixes
- **[Slash Commands](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Slash-Commands)** — All 35+ commands, priority macros, UnitXP scanning
- **[Conditionals](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Conditionals)** — 70+ conditionals (player & target), extended unit tokens, multiscan
- **[Conditionals](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Conditionals)** — 150+ conditionals (player & target), short aliases, extended unit tokens, multiscan
- **[Reference Tables](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Reference-Tables)** — CC types, damage schools, stat types, swing types
- **[Features](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Features)** — Debuff timers, combo tracking, talent modifiers, TWoW mechanics
- **[Overflow Buff Frame](https://github.com/brues-code/SuperCleveRoidMacros/wiki/Overflow-Buff-Frame)** — Hidden buff display for 32+ buffs
+1
View File
@@ -5,6 +5,7 @@
## Version: @project-version@
## OptionalDeps: ClassicFocus, FocusFrame, pfUI, SuperMacro, Bongos_ActionBar, Cursive, XPerl, LunaUnitFrames, DragonflightReloaded, -Dragonflight3
## SavedVariables: CleveRoidMacros, CleveRoids_LearnedDurations, CleveRoids_AuraTextures, CleveRoids_ImmunityData, CleveRoids_ComboDurations, CleveRoids_SpellSchools
## LoadSavedVariablesFirst: 1
Localization.lua
Init.lua
NampowerAPI.lua
+197 -1501
View File
File diff suppressed because it is too large Load Diff
+57 -9
View File
@@ -25,10 +25,23 @@ API references are line numbers into `C:\Git\ClassicAPI\docs\API.md`.
`applications` (stacks), `duration`.
- **Unlocks:** `[dispellable]` / `[curse]` / `[magic]` target conditionals and
spellID-based (rank/locale-proof) aura matching.
- **Caveat:** `expirationTime` is only populated for `unit=="player"`; it's `0`
for target/focus (vanilla server limitation). Target debuff *timers* still
need the existing libdebuff tracking — only presence/stacks/school/spellId
are reliable cross-unit.
- **~~Caveat: `expirationTime` is player-only~~ — NO LONGER TRUE.** This note said
target/focus `expirationTime` was always `0`, so debuff *timers* had to stay on
libdebuff. ClassicAPI has since added the `Aura::Source` cache: for a non-player
unit `expirationTime` is reconstructed from the observed `SMSG_SPELL_GO`, and
`duration` is the **caster-modified** value (talent extensions like Improved
Shadow Word: Pain included). It also handles combo-point finisher scaling
automatically (Rupture at 4 CP reads 14s, no registration) and ships Carnage's
roll-gated Rip/Rake refresh in the DLL (`src/turtle/Carnage.cpp`, exposed as
`RegisterAuraDurationModifierByTrigger`).
- **Real remaining caveats** (all best-effort, from `docs/API.md`):
- Only auras observed *after login* carry caster/timing; older ones report
`expirationTime` 0 and `sourceUnit` nil.
- Max-stack refresh is a blind spot: re-applying at max stacks (Shadow Weaving
5→5) emits no client-visible change, so the entry elapses and evicts.
- Out-of-range group members are spell-ID only, with `applications` always 1.
- **Consequence:** most of libdebuff is now redundant — see
"libdebuff retirement" below.
- **DONE (slice 1 — dispel-type conditionals):** added `ClassicAPI.lua`
detection module (`CleveRoids.ClassicAPI`, mirrors NampowerAPI's
@@ -150,11 +163,13 @@ Findings from the full Conditionals.lua audit. These look like ClassicAPI
candidates but are **worse** than the current implementation — recorded so we
don't re-investigate.
- **Auras / `ValidateAura`** — `C_UnitAuras.expirationTime` is **player-only**
(`0` for every other unit). It cannot replace the nampower `GetUnitField`
batch read or the remote-duration tracking (libdebuff / overflow slots). The
dispel-*type* path already uses `C_UnitAuras` (that's the one thing it's good
for); aura *timing/stacks* on non-player units must stay on nampower/libdebuff.
- ~~**Auras / `ValidateAura`** — `C_UnitAuras.expirationTime` is player-only~~ —
**STALE, do not trust this entry.** It was written before the `Aura::Source`
cache landed. Non-player `expirationTime`/`duration` now work (caster-modified,
talent extensions included), so this is no longer a reason to keep libdebuff's
remote-duration tracking. See Tier 1 §1 and "libdebuff retirement" for the
current picture and the real caveats. The nampower `GetUnitField` batch read is
a separate question and has not been re-examined.
- **`GetCurrentShapeshiftIndex` form loop / `[stance]`·`[form]`** —
`GetShapeshiftFormID()` returns the **DBC form id** (Cat=1, Bear=5,
Shadowform=28…), NOT the 1-based **bar index** these conditionals compare
@@ -183,6 +198,39 @@ don't re-investigate.
added `[focus]`/`[nofocus]`. `/focus` is provided by ClassicAPI's companion
addon.
## libdebuff retirement
libdebuff exists because vanilla cannot report debuff durations on units other
than the player. ClassicAPI's `Aura::Source` cache now does exactly that, so most
of the library is redundant. This is a staged replacement, not a delete: measure
first, then remove per group.
Current footprint: 28 public `lib:` methods, ~726 internal references in
`Utility.lua`, consumers in 8 files (`Conditionals.lua` 45, `Compatibility/pfUI.lua`
39, `Core.lua` 9). Note pfUI did **not** delete its own libdebuff — v9.0.25 still
ships ~1695 lines of it, re-based on `C_UnitAuras.GetAuraDataByIndex` /
`GetAuraDataBySpellName`. "Re-base on C_UnitAuras", not "remove", is the precedent.
**Group A — replaceable by `C_UnitAuras` (do these first):**
`GetDuration`, `GetDebuffCaster`, `IsOurDebuff`, `UnitBuff`/`UnitDebuff`,
`FindPlayerDebuff`/`FindPlayerBuff`, `GetAllDebuffsOnTarget`, `GetCachedIcon`,
`ApplyCarnageRefresh`, and the Dark Harvest trio (`ApplyDarkHarvestStart`/`End`,
`GetDarkHarvestReduction`, `GetTimeRemainingWithDarkHarvest`) — ClassicAPI ships
Carnage refresh and Dark Harvest tick compression in the DLL.
**Group B — no `C_UnitAuras` equivalent, keep:**
`ShouldApplyDebuffRank`, `DidSpellFail`, `WasSpellReflected`, `DidTargetEvade`,
`ProcessMissReason`, `IsPersonalDebuff`, `GetSpellRank`/`GetSpellBaseName`,
`HasPendingCast`. These are miss/rank/learning logic, not aura state.
**Gate before removing Group A:** confirm parity in-game on one spell where
ClassicAPI does the hard part — Rip under Carnage. Compare
`CleveRoids.libdebuff:GetDuration(spellID)` against
`C_UnitAuras.GetUnitAuraBySpellID(unit, spellID).duration` and the derived
remaining (`expirationTime - GetTime()`), on a target you have debuffed. If those
agree across a Carnage proc, Group A can go. Watch the documented best-effort
gaps: an aura cast before you logged in, and refresh-at-max-stacks.
## Marginal / optional follow-ups
- **`[swimming]`** → `IsSwimming()` would drop the nampower-2.36 version gate +