16 Commits

Author SHA1 Message Date
Brues 37ca7cef40 Document the CC, immunity and DR design
Records why the three identities are separate, the DBC mechanic table each
[cc:type] now follows, and which DR pools the immunity safeguard may act on.

Every vMaNGOS and nampower claim behind the preceding four commits is cited
to the source line that establishes it, so the next person to touch this can
check the premises instead of re-deriving them: the DRTYPE_ALL set is three
stun pools and nothing else, Charge and Intercept are the only triggered
stuns forced into the controlled pool, and SPELL_MISS puts the spell ID in
arg3.

Also notes the mouseover learning gap as designed behavior rather than a bug,
since the failure on the other side is permanent bad SavedVariables data.
2026-09-15 08:10:12 -05:00
Brues 4474a607f7 Stop DR suppressing NPC immunity learning past its window
The DR safeguard keyed recentCCHits by ccType and never reset the counter,
so three stuns on a target left it at count >= 3 forever. Every later
IMMUNE on that target was read as diminishing returns and no permanent
immunity was ever learned from it again.

Count within a window instead: a hit more than DR_RESET_WINDOW seconds
after the last one starts over at 1, matching the 20s DR decay the skip
check already used. Both the hit path and the skip check now key on the DR
pool from GetSpellImmunityDRType rather than the mechanic, so only the three
groups that actually diminish against creatures can hold learning back.

Learning itself records immunityType, the exact mechanic, so a target that
resists Gouge is no longer written down as stun-immune.

CheckImmunity also resolves a spell name through GetSpellImmunityType now,
so bare [immune]/[noimmune] answers for mechanic immunity and not just
schools.
2026-09-15 08:10:12 -05:00
Brues ca6c55a5e7 Give every [cc:type] its own exact DBC mechanic
stun matched mechanics 12, 14 and 30 at once through CCMechanicGroups, so
[cc:stun] fired on a gouged or sapped target and there was no way to ask
about any of the three on its own.

Name each mechanic instead: knockout (14) and sap (30) join the table, with
fumble (6), turn (23) and interrupt (26) filling the remaining gaps, and
grip stays as an alias of fumble so existing macros keep working. The group
indirection had no entries left afterwards, so it and its lookup branch are
gone; aliases in CCMechanics already cover every name that shares a mechanic.

CCTypesLossOfControl is untouched, so bare [cc] and [cc:any] still span the
whole loss-of-control set.
2026-09-15 08:10:12 -05:00
Brues c1a7bdb51b Fix the Nampower SPELL_MISS argument order
TriggerSpellMissEvent signals casterGuid, targetGuid, spellId, missInfo for
both SPELL_MISS_SELF and SPELL_MISS_OTHER. Both handlers read arg1 as the
spell ID, so every miss dispatched a GUID string where an ID was expected
and no miss ever reached the immunity recorder through the Nampower path.

Name mechanic 14 knockout to match the 1.12 DBC, and normalise CC type input
through one place so incap and incapacitate stay accepted as aliases for it.
2026-09-15 08:10:12 -05:00
Brues 5d5f987555 Learn NPC CC immunity per exact DBC mechanic
Learned immunity shared one coarse bucket with the [cc:*] conditionals, so
mechanic 13 (freeze), 14 (knockout) and 30 (sap) all collapsed into "stun".
An NPC immune to Gouge was then recorded as stun-immune, and every later
Kidney Shot was suppressed against a target that had never resisted one.

Split the two identities. MECHANIC_TO_IMMUNITY_TYPE keeps the mechanic
one-to-one for learning, and GetSpellImmunityType resolves it from the DBC
spell/effect mechanic fields, falling back to the aura type only where a
spell leaves those empty.

The DR safeguard needs the real Vanilla DR pool rather than the mechanic:
GetSpellImmunityDRType separates controlled stun, triggered stun and Kidney
Shot, since vMaNGOS marks only those three DRTYPE_ALL against creatures.
The remaining staged groups are player-only and must not hold back learning.
2026-09-15 08:03:43 -05:00
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
8 changed files with 705 additions and 1468 deletions
+14 -14
View File
@@ -3,7 +3,8 @@
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 (ClassicAPI v1.15.0+, which added frame:RegisterUnitEvent), so the
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
@@ -247,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
@@ -312,10 +313,9 @@ 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
--------------------------------------------------------------------------------
@@ -324,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).
+2 -82
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
@@ -773,55 +742,6 @@ 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
+146 -73
View File
@@ -897,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 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)
@@ -4884,7 +4847,8 @@ end
-- ============================================================================
-- Maps CC type names to mechanic constants (matches DBC mechanic IDs)
-- Note: Some types map to multiple mechanics via CCMechanicGroups below
-- One name, one mechanic: [cc:type] follows the exact DBC mechanic. Several
-- names may share a mechanic as aliases, but no name spans two mechanics.
CleveRoids.CCMechanics = {
-- Movement/control impairment
charm = 1, -- Mind Control, Seduction
@@ -4893,31 +4857,30 @@ CleveRoids.CCMechanics = {
disarm = 3, -- Disarm, Riposte disarm
distract = 4, -- Distract (Rogue ability)
fear = 5, -- Fear, Psychic Scream, Howl of Terror
grip = 6, -- Grip effects
fumble = 6, -- DBC Fumble mechanic
grip = 6, -- Legacy alias for fumble
root = 7, -- Entangling Roots, Frost Nova, Improved Hamstring
pacify = 8, -- Pacify effects
silence = 9, -- Silence, Kick, Counterspell (lockout)
sleep = 10, -- Hibernate, Wyvern Sting sleep
snare = 11, -- Hamstring, Wing Clip, Crippling Poison
slow = 11, -- Alias for snare
stun = 12, -- Consolidated: Stun(12) + Knockout(14) + Sap(30)
freeze = 13, -- Freeze effects (Frost Nova freeze)
stun = 12, -- Stun (Cheap Shot, Kidney Shot, Hammer of Justice)
freeze = 13, -- Freeze effects
knockout = 14, -- Knockout (Gouge, Repentance)
bleed = 15, -- Rend, Garrote, Deep Wounds
polymorph = 17, -- Polymorph (all variants)
banish = 18, -- Banish (Warlock)
shackle = 20, -- Shackle Undead
turn = 23, -- Turn effects
horror = 24, -- Death Coil (Warlock), Intimidating Shout (horror)
interrupt = 26, -- Interrupt mechanic
daze = 27, -- Dazed effects
}
-- Mechanic groups: CC types that check multiple DBC mechanics
-- Used when a single conditional should match several related effects
CleveRoids.CCMechanicGroups = {
stun = {12, 14, 30}, -- Stun(12), Knockout/Gouge(14), Sap(30)
sap = 30, -- Sap/Sapped mechanic
}
-- CC types that count as "crowd controlled" (loss of control)
-- Note: Mechanics 12, 14, 30 are all consolidated under "stun" for conditionals
-- Individual DBC mechanics remain distinct; this table only powers [cc]/[cc:any].
CleveRoids.CCTypesLossOfControl = {
[1] = true, -- charm
[2] = true, -- disoriented
@@ -4926,12 +4889,12 @@ CleveRoids.CCTypesLossOfControl = {
[10] = true, -- sleep
[12] = true, -- stun (Cheap Shot, Kidney Shot, etc.)
[13] = true, -- freeze
[14] = true, -- knockout/gouge (now part of stun group)
[14] = true, -- knockout/gouge
[17] = true, -- polymorph
[18] = true, -- banish
[20] = true, -- shackle
[24] = true, -- horror
[30] = true, -- sap (now part of stun group)
[30] = true, -- sap
}
-- Check if BuffLib is available with full mechanic support
@@ -4998,19 +4961,6 @@ function CleveRoids.ValidateUnitCC(unit, ccType)
return CleveRoids.ValidateUnitAnyCrowdControl(unit)
end
-- Check if this CC type maps to a group of mechanics
local mechanicGroup = CleveRoids.CCMechanicGroups[ccTypeLower]
if mechanicGroup then
-- Check all mechanics in the group (e.g., stun checks 12, 14, 30)
for _, mechanic in ipairs(mechanicGroup) do
if CleveRoids.ValidateUnitCCSingleMechanic(unit, mechanic) then
return true
end
end
return false
end
-- Single mechanic lookup
local mechanic = CleveRoids.CCMechanics[ccTypeLower]
if not mechanic then return false end
@@ -5278,32 +5228,154 @@ local function ResolvePetHappinessState(value)
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] — true while mouse button N is held (1=Left, 2=Right, 3=Middle,
-- 4/5=extra). Routed through Multi so OR/AND lists and repeated groups behave
-- like every other argument conditional ([button:1/2] = left or right).
-- [button] with no argument — true if any mapped mouse button is held.
-- [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.AnyMouseButtonDown()
return CleveRoids.WasClickActivated()
end
local invoking = CleveRoids.GetActivatingButton()
return Multi(conditionals.button, function(button)
local name = CleveRoids.buttons[button]
return name and IsMouseButtonDown(name) or false
return CleveRoids.buttons[button] == invoking
end, conditionals, "button")
end,
nobutton = function(conditionals)
if type(conditionals.nobutton) ~= "table" then
return not CleveRoids.AnyMouseButtonDown()
return not CleveRoids.WasClickActivated()
end
local invoking = CleveRoids.GetActivatingButton()
return NegatedMulti(conditionals.nobutton, function(button)
local name = CleveRoids.buttons[button]
return not (name and IsMouseButtonDown(name))
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,
@@ -9237,6 +9309,7 @@ CleveRoids.STATIC_CONDITIONALS = {
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,
+42 -95
View File
@@ -201,9 +201,13 @@ requirementCheckFrame:SetScript("OnEvent", function()
-- v1.15.0 added frame:RegisterUnitEvent, which Utility.lua calls at file scope.
-- On an older build that call raises, aborting the rest of the chunk -- so this
-- is not a degraded-features warning, it's "the addon did not finish loading".
local hasClassicAPI1150 = hasClassicAPI and CleveRoids.ClassicAPI.HasMinimumVersion(1, 15, 0)
-- v1.15.8 scoped GetMouseButtonClicked to the click dispatch. Below it the
-- function exists but a held button keeps its value, so [button:N] reads the
-- button you are turning the camera with -- wrong quietly, which is the worse
-- failure and why the floor moved rather than the conditional being gated.
local hasClassicAPI1158 = hasClassicAPI and CleveRoids.ClassicAPI.HasMinimumVersion(1, 15, 8)
if not hasNampower30 or not hasUnitXP or not hasClassicAPI or not hasClassicAPI1150 then
if not hasNampower30 or not hasUnitXP or not hasClassicAPI or not hasClassicAPI1158 then
-- Show warnings (don't disable — tearing down a partially-initialized addon causes hangs)
if not hasNampower then
CleveRoids.Print("|cFFFF9900WARNING:|r |cFF00FFFFAvitasia's Nampower v3.0.0+|r is required:")
@@ -223,9 +227,9 @@ requirementCheckFrame:SetScript("OnEvent", function()
CleveRoids.Print("|cFFFF9900WARNING:|r |cFF00FFFFClassicAPI|r is required:")
CleveRoids.Print("https://github.com/brues-code/ClassicAPI")
CleveRoids.Print("Dispel-type and movement conditionals will be unavailable without it.")
elseif not hasClassicAPI1150 then
elseif not hasClassicAPI1158 then
local major, minor, patch = CleveRoids.ClassicAPI.GetVersion()
CleveRoids.Print(format("|cFFFF9900WARNING:|r |cFF00FFFFClassicAPI v1.15.0+|r is required (you have v%d.%d.%d):", major, minor, patch))
CleveRoids.Print(format("|cFFFF9900WARNING:|r |cFF00FFFFClassicAPI v1.15.8+|r is required (you have v%d.%d.%d):", major, minor, patch))
CleveRoids.Print("https://github.com/brues-code/ClassicAPI")
CleveRoids.Print("The addon cannot finish loading on this version -- update ClassicAPI.")
end
@@ -4190,6 +4194,21 @@ function CleveRoids.OnUpdate(self)
end
end
-- A totem's timer runs down with nothing behind it: PLAYER_TOTEM_UPDATE fires
-- when a slot changes hands, never as one counts toward zero, so a
-- [totem:X<N] icon would hold the answer from its last refresh until some
-- unrelated event queued another. Unlike an aura, there is no ambient event
-- stream to ride -- UNIT_AURA is what quietly keeps [mybuff:X<N] honest -- so
-- a re-test once a second while any totem timer runs is the whole mechanism.
-- Only the icon is at stake: the click path evaluates conditionals live.
-- Realtime mode re-tests everything each tick and needs none of this.
if CRM.realtime == 0 and time >= (CR.nextTotemPoll or 0) then
CR.nextTotemPoll = time + 1
if CR.AnyTotemTimerRunning() then
CR.isActionUpdateQueued = true
end
end
-- Check the saved variable to decide which update mode to use.
if CRM.realtime == 1 then
-- Realtime Mode: Force an update on every throttled tick for maximum responsiveness.
@@ -4260,20 +4279,6 @@ function CleveRoids.OnUpdate(self)
if (time - CR.lastCleanupTime) >= CR.CLEANUP_INTERVAL then
CR.lastCleanupTime = time
-- MEMORY: Clean up carnageDurationOverrides older than 30 seconds
-- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation
local carnageOverrides = CR.carnageDurationOverrides
if carnageOverrides then
local spellID, data = next(carnageOverrides)
while spellID do
local nextID = next(carnageOverrides, spellID)
if data.timestamp and (time - data.timestamp) > 30 then
carnageOverrides[spellID] = nil
end
spellID, data = nextID, nextID and carnageOverrides[nextID]
end
end
-- MEMORY: Clean up old ComboPointTracking entries (older than 60 seconds)
-- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation
local comboTracking = CR.ComboPointTracking
@@ -4461,12 +4466,8 @@ CleveRoids.Frame:RegisterEvent("UPDATE_SHAPESHIFT_FORM")
CleveRoids.Frame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
CleveRoids.Frame:RegisterEvent("PLAYER_STARTED_MOVING")
-- ClassicAPI loss-of-control (school-interrupt lockout) for [locked]/[nolocked].
-- Gated on the namespace so an older ClassicAPI without it doesn't error on an
-- unknown event.
if type(C_LossOfControl) == "table" then
CleveRoids.Frame:RegisterEvent("LOSS_OF_CONTROL_ADDED")
CleveRoids.Frame:RegisterEvent("LOSS_OF_CONTROL_UPDATE")
end
CleveRoids.Frame:RegisterEvent("LOSS_OF_CONTROL_ADDED")
CleveRoids.Frame:RegisterEvent("LOSS_OF_CONTROL_UPDATE")
-- The unit state streams that drive icon refresh: GUID events when Nampower
-- provides them (v2.39+, one event per unit change rather than one per token),
-- else the stock per-token events. These cannot become RegisterUnitEvent calls
@@ -4530,6 +4531,11 @@ if CleveRoids.NampowerAPI.features.hasKeyEvents then
CleveRoids.Frame:RegisterEvent("KEY_UP")
end
-- ClassicAPI totem-bar tracking, for [totem:X] icon refresh. Ungated, unlike the
-- Nampower features above: it long predates the ClassicAPI floor the addon
-- already refuses to run below.
CleveRoids.Frame:RegisterEvent("PLAYER_TOTEM_UPDATE")
-- NOTE: SuperMacro hook installation is handled by Compatibility/SuperMacro.lua
-- which has the complete implementation including the INTERCEPT path for all commands
@@ -4630,58 +4636,11 @@ function CleveRoids.Frame:ADDON_LOADED(addon)
end
function CleveRoids.Frame:UNIT_CASTEVENT(caster,target,action,spell_id,cast_time)
-- Handle melee swings for judgement refresh
-- Melee swings carry no spell to resolve. ClassicAPI mirrors the server's
-- paladin judgement-refresh rule off SMSG_ATTACKERSTATEUPDATE in the DLL
-- (src/aura/JudgementRefresh.cpp), so there is nothing to do here.
if action == "MAINHAND" or action == "OFFHAND" then
-- Only process if this is the player's melee swing
if caster == CleveRoids.playerGuid and CleveRoids.playerClass == "PALADIN" then
-- Refresh judgements on the target
-- Defensive: verify libdebuff is a table before accessing properties
local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil
if target and lib and lib.objects then
local normalizedTarget = CleveRoids.NormalizeGUID(target)
if normalizedTarget and lib.objects[normalizedTarget] then
-- Refresh all active Judgements on the target
for spellID, rec in pairs(lib.objects[normalizedTarget]) do
-- Check if this is a judgement by spell ID
if lib.judgementSpells and lib.judgementSpells[spellID] and rec.start and rec.duration then
-- Only refresh if the Judgement is still active and was cast by player
local remaining = rec.duration + rec.start - GetTime()
if remaining > 0 and rec.caster == "player" then
-- Refresh the Judgement by updating the start time
rec.start = GetTime()
local spellName = C_Spell.GetSpellName(spellID)
local baseName = CleveRoids.StripRank(spellName) or "Unknown"
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00ffaa[Judgement Refresh]|r Refreshed %s (ID:%d) on %s hit - new duration: %ds",
baseName, spellID, action, rec.duration)
)
end
-- Also sync to pfUI if it's loaded (pre-7.6 only)
if pfUI and pfUI.api and pfUI.api.libdebuff then
local targetName = (lib.guidToName and lib.guidToName[normalizedTarget]) or UnitName("target")
local targetLevel = UnitLevel("target") or 0
if targetName then
pfUI.api.libdebuff:AddEffect(targetName, targetLevel, baseName, rec.duration, "player")
if CleveRoids.debug then
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff00ffaa[pfUI Judgement Refresh]|r Synced %s refresh to pfUI", baseName)
)
end
end
end
end
end
end
end
end
end
return -- Still return early after processing melee
return
end
-- Debug channel tracking
@@ -5075,26 +5034,6 @@ function CleveRoids.Frame:SPELLCAST_CHANNEL_STOP()
CleveRoids.CurrentSpell.spellName = ""
CleveRoids.CurrentSpell.castingSpellId = nil
-- WARLOCK DARK HARVEST: Mark channeling as ended
-- Credits: Avitasia / Cursive addon
if CleveRoids.darkHarvestData and CleveRoids.darkHarvestData.isActive then
CleveRoids.darkHarvestData.isActive = false
CleveRoids.darkHarvestData.endTime = GetTime()
-- Apply Dark Harvest end to all DoTs on target (finalizes reduction)
if CleveRoids.libdebuff and CleveRoids.libdebuff.ApplyDarkHarvestEnd then
CleveRoids.libdebuff.ApplyDarkHarvestEnd(CleveRoids.darkHarvestData.targetGUID)
end
if CleveRoids.debug then
local activeTime = CleveRoids.darkHarvestData.endTime - CleveRoids.darkHarvestData.startTime
DEFAULT_CHAT_FRAME:AddMessage(
string.format("|cff9482c9[Dark Harvest]|r Channel ended after %.1fs (DoT acceleration stopped)",
activeTime)
)
end
end
-- Force immediate action update
CleveRoids.TestForAllActiveActions()
end
@@ -5648,6 +5587,14 @@ function CleveRoids.Frame:KEY_UP()
CleveRoids.isActionUpdateQueued = true
end
-- ClassicAPI PLAYER_TOTEM_UPDATE: arg1 = the slot (1 Fire .. 4 Air) whose totem
-- was dropped, expired, killed or recalled. The slot goes unread and every macro
-- is refreshed: a [totem:X] macro may name any slot, and [nototem:X] flips on any
-- of them.
function CleveRoids.Frame:PLAYER_TOTEM_UPDATE()
CleveRoids.QueueActionUpdate()
end
-- Base SendChatMessage captured at first hook; used to break a hook cycle.
local baseSendChatMessage = SendChatMessage
local sendingChatMessage = false
+3 -2
View File
@@ -45,8 +45,9 @@ CleveRoids.supported = CleveRoids.hasTurtle
-- "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, 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.
-- 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
+1 -1
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) (v1.15.0+) | ✅ | Modern `C_*` API: dispel-type conditionals (`[magic]`, `[curse]`, …), `[moving]` speed, unit-filtered events |
| [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
+309 -1201
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
# CC, Learned Immunity and Diminishing Returns
How SuperCleveRoidMacros classifies crowd control, what it learns about NPC
immunity, and why diminishing returns only ever acts as a brake on that
learning. Source citations are into `C:\Git\vmangos` and `C:\Git\nampower`.
## Three identities, never conflated
```text
ccType Exact DBC mechanic. Backs [cc:*] and CC landing verification.
Conditionals.lua, CleveRoids.CCMechanics
immunityType Exact DBC mechanic, recorded against an NPC name when a CC
spell comes back IMMUNE.
Utility.lua, MECHANIC_TO_IMMUNITY_TYPE / GetSpellImmunityType
DR type The real Vanilla diminishing-return pool. Internal only.
Utility.lua, GetSpellImmunityDRType
```
The first two are the same taxonomy used for different purposes; the third is
a separate axis and is never exposed to macros. Folding any pair together is
what produced the bugs this design replaces: a target that resisted Gouge got
written down as stun-immune, and every later Kidney Shot was suppressed
against a target that had never resisted one.
## `[cc:*]` follows the DBC mechanic exactly
One conditional, one mechanic. Several names may alias a single mechanic, but
no name spans two:
```text
[cc:stun] -> mechanic 12 only
[cc:freeze] -> mechanic 13 only
[cc:knockout] -> mechanic 14 only
[cc:sap] -> mechanic 30 only
[cc:daze] -> mechanic 27 only
```
Bare `[cc]` and `[cc:any]` still mean "any loss-of-control effect" and are
driven by `CleveRoids.CCTypesLossOfControl`, which is deliberately independent
of the per-mechanic table. An aggregate does not require redefining the parts.
Current aliases:
```text
slow -> snare
disoriented -> disorient
grip -> fumble (mechanic 6 is DBC Fumble)
incap / incapacitate / incapacitated -> knockout (immunity input only)
```
Aliases exist so published macros keep working. They resolve to the canonical
mechanic name before anything is stored, so SavedVariables only ever hold
canonical keys.
## Vanilla DBC mechanic taxonomy
The identities SCRM names. Where a mechanic is named at all, it is named as
itself:
| DBC mechanic | ID | Canonical `ccType` |
|---|---:|---|
| Charm | 1 | `charm` |
| Disoriented | 2 | `disorient` |
| Disarm | 3 | `disarm` |
| Distract | 4 | `distract` |
| Fear | 5 | `fear` |
| Fumble | 6 | `fumble` |
| Root | 7 | `root` |
| Pacify | 8 | `pacify` |
| Silence | 9 | `silence` |
| Sleep | 10 | `sleep` |
| Snare | 11 | `snare` |
| Stun | 12 | `stun` |
| Freeze | 13 | `freeze` |
| Knockout | 14 | `knockout` |
| Bleed | 15 | `bleed` |
| Polymorph | 17 | `polymorph` |
| Banish | 18 | `banish` |
| Shackle | 20 | `shackle` |
| Turn | 23 | `turn` |
| Horror | 24 | `horror` |
| Interrupt | 26 | `interrupt` |
| Daze | 27 | `daze` |
| Sapped | 30 | `sap` |
Mechanics 16, 19, 21, 22, 25, 28 and 29 (Bandage, Shield, Mount, Persuade,
Invulnerability, Discovery, Immune Shield) are real DBC values but are not
crowd control, so they get no conditional.
## Diminishing returns
DR matters here for exactly one reason: a target at DR level 4 returns
`IMMUNE`, and that must not be recorded as permanent immunity. Nothing else in
the addon consumes DR state, and there is no player-facing DR feature.
### Only three pools can diminish a creature
`GetDiminishingReturnsGroupType` in
`vmangos/src/game/Spells/SpellEntry.h:52-79` returns `DRTYPE_ALL` for exactly
three groups:
```text
DIMINISHING_CONTROL_STUN
DIMINISHING_TRIGGER_STUN
DIMINISHING_KIDNEYSHOT
```
Every other staged group — sleep, both roots, fear, Warlock fear, charm,
polymorph, silence, disarm, Death Coil, freeze, banish, knockout — is
`DRTYPE_PLAYER`, and `Unit.cpp:7881` applies those only when the victim is a
player. So on an NPC target they can never generate a DR immunity, and they
must never hold back immunity learning.
This is why `GetSpellImmunityDRType` returns nil for everything that is not
mechanic 12. It is not an approximation; it is the complete `DRTYPE_ALL` set.
### Classifying a stun into its pool
`SpellEntry::GetDiminishingReturnsGroup(bool triggered)`
(`vmangos/src/game/Spells/SpellEntry.cpp:281-432`) resolves the pool, and the
`triggered` argument means the runtime fact of whether an aura triggered the
cast — this is not a pure DBC property. SCRM reconstructs it from nampower
`SPELL_CAST_EVENT` correlation, which only fires for client-initiated casts:
a spell ID with a recent `CleveRoids.pendingCasts` entry was cast deliberately,
one without reached `SPELL_GO`/`SPELL_MISS` as a proc.
```text
Kidney Shot -> stun_kidneyshot
Charge / Intercept stun -> stun_control
other mechanic-12, client cast -> stun_control
other mechanic-12, proc -> stun_trigger
```
Kidney Shot is matched on rogue family bit 21 (`0x00200000`,
`SpellClassMask.h:228`) rather than a bare ID list, so custom ranks that keep
their DBC family data still classify correctly. Charge (7922) and Intercept
(20253, 20614, 20615) are internally triggered but explicitly returned as
controlled stun by `SpellEntry.cpp:389-399`; they are the only such exceptions
in 1.12.
### Pools that exist but do not apply
Recorded so nobody re-adds them to the safeguard. Knockout and Sap are
distinct mechanics that share `DIMINISHING_KNOCKOUT`
(`SpellEntry.cpp:424-425`), and Horror maps to `DIMINISHING_DEATHCOIL`
(`SpellEntry.cpp:428-429`). Both are `DRTYPE_PLAYER`. `DIMINISHING_LIMITONLY`
is a PvP duration cap rather than a staged pool, and `DIMINISHING_NONE` is not
a pool at all.
### The safeguard itself
`recentCCHits[targetGUID][drType]` counts landed CC per DR pool, so the three
stun pools keep separate histories. A hit more than `DR_RESET_WINDOW` (20s)
after the previous one in that pool restarts the count at 1, matching the DR
decay the skip check uses. Without that reset the counter only ever climbed,
so three stuns on a target permanently disqualified it from ever teaching the
addon anything again.
An `IMMUNE` result is treated as DR, and discarded, only while the pool holds
three or more hits inside the window. Otherwise it is learned.
## nampower `SPELL_MISS` arguments
`TriggerSpellMissEvent` (`nampower/spellevents.cpp:888-917`) signals both
`SPELL_MISS_SELF` and `SPELL_MISS_OTHER` with the same layout:
```text
arg1 = casterGuid arg2 = targetGuid arg3 = spellId arg4 = missInfo
```
`SPELL_MISS_SELF` carries `casterGuid` too, even though it is by definition
the player — the event is chosen by comparing the caster to the active player
GUID, not by changing the payload. Reading `arg1` as the spell ID hands a GUID
string to the immunity recorder and silently loses every miss.
## Known limitation: mouseover casts
`ProcessSpellMissSelf` refuses to learn permanent immunity when it cannot
resolve a queryable unit, because the temporary-immunity-buff check needs one
— an NPC under Divine Shield must not be recorded as permanently immune. A CC
cast at a mouseover that is not the current target can therefore return
`IMMUNE` without being learned.
This is the safeguard working as designed, not a known bug. Do not loosen it
without a reproduction showing a real failure, since the failure mode on the
other side is permanent bad data in `CleveRoids_ImmunityData`.