From 5a4fe7f476aecfecc265821168936ff914fd85a0 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 26 Aug 2026 15:53:16 +0100 Subject: [PATCH 1/8] Dispel overlay: the dispel-type map now moves the tuning signature Field report (tRp, resto shaman DWARF, v5.3.1): "bleeds are now shown with dispel overlay on other players (which are not dwarf) when being a dwarf". The bleed repair is scoped to the player's own frame, and that scoping is correct -- but it could never take effect on a frame that changed hands. For a dwarf shaman the gap slot exists in BOTH plans, own frame and everyone else's, with the same key and the same filter string ("HARMFUL|!RAID_PLAYER_DISPELLABLE"). The only difference is includeDispelTypes: {Poison,Bleed} on self, {Poison} elsewhere. That map was in NEITHER signature, so the struct sig matched (no rebuild) and the tuning sig matched (no re-tune), and the container kept whatever map it was first built with. A frame built while driving the player carried Bleed from then on -- and header children get reassigned to other units by roster churn, which is how a self-only repair ended up lighting bleeds across the raid. The dfDispelSelf latch shipped with that repair was necessary but not sufficient: it forces a re-plan on the self/non-self edge, and the re-plan then produced identical signatures, so nothing was applied. Recomputing is not applying. Serialise the slot's includeDispelTypes into the TUNING half, sorted so the same set always serialises identically -- an unordered pairs() walk would move the signature at random and re-tune on every drive. Tuning rather than structural because SetAuraSlotCandidateFilters is a live mutator, so this re-pushes in place with no teardown and no stranded buttons. --- DandersFrames/Features/Dispel.lua | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/DandersFrames/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua index 0ec301e5..e1915245 100644 --- a/DandersFrames/Features/Dispel.lua +++ b/DandersFrames/Features/Dispel.lua @@ -1569,6 +1569,34 @@ local function dispelFactoryPlanAndSig(db, selfOnly) for i = 1, #slots do st[#st + 1] = slots[i].key -- key set: structural tu[#tu + 1] = slots[i].key .. "=" .. slots[i].filter -- string: tunable + -- ☠☠ THE DISPEL-TYPE MAP IS TUNABLE STATE AND IT WAS IN NEITHER SIGNATURE. + -- Field report (tRp, resto shaman DWARF, v5.3.1): "bleeds are now shown with dispel + -- overlay on other players (which are not dwarf) when being a dwarf". + -- Cause: for a dwarf shaman the gap slot exists in BOTH plans — own frame and + -- everyone else's — with the SAME key and the SAME filter string + -- ("HARMFUL|!RAID_PLAYER_DISPELLABLE"). Only includeDispelTypes differs: + -- {Poison,Bleed} on self, {Poison} elsewhere. With the map absent from both sigs + -- the struct sig matched (no rebuild) AND the tuning sig matched (no re-tune), so + -- the container silently kept whatever map it was first built with. A frame built + -- while driving the player then carried Bleed for the rest of the session — and + -- header children get reassigned to other units by roster churn, which is how a + -- self-only repair ended up lighting bleeds on the whole raid. + -- ⚠ The dfDispelSelf latch added with that repair was necessary but NOT sufficient: + -- it forces a re-PLAN on the self/non-self edge, and the re-plan then produced + -- identical signatures, so nothing was ever applied. Recomputing is not applying. + -- TUNING, not structural: SetAuraSlotCandidateFilters is a live mutator, so this + -- re-pushes in place with no teardown (and no stranded buttons — AddAuraSlot is + -- add-only). + local scf = slots[i].candidateFilters + local idt = scf and scf.includeDispelTypes + if idt then + -- Sorted, so the same set always serialises identically — an unordered pairs() + -- walk would move the signature at random and re-tune every drive. + local types = {} + for k in pairs(idt) do types[#types + 1] = tostring(k) end + table.sort(types) + tu[#tu + 1] = slots[i].key .. ":idt=" .. table.concat(types, ",") + end -- ROLES are structural: the carriers a slot builds are created + bound ONCE in -- the secure init, so toggling the ring / gradient / EDGE strips must rebuild. -- They used to be separate slot keys (and so rode the loop above); now that one From 986483d865aefedb14f9c9a0b3146ed826c92104 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 26 Aug 2026 16:19:27 +0100 Subject: [PATCH 2/8] Debug: log the dispel overlay's TUNE decision The row had no line at all for which signature moved, which is the first thing anyone asks when a setting appears not to apply. It is also the proof for the dispel-type map fix: on a frame whose self-ness flips, the includeDispelTypes map differs while the key set and the filter strings do not, so before that fix neither signature moved and this branch was never reached. --- DandersFrames/Features/Dispel.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/DandersFrames/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua index e1915245..d6dd1daa 100644 --- a/DandersFrames/Features/Dispel.lua +++ b/DandersFrames/Features/Dispel.lua @@ -2707,6 +2707,15 @@ function DF:DriveDispelOverlayFactory(frame, db) -- one button each time (AddAuraSlot is add-only). ApplyTuning routes overlay mode -- through the live slot-side setters, carries its own combat deferral, and rebuilds -- itself under test mode. Records carry onInit — see dispelFilterRecords. + -- "I changed a setting and nothing happened" is always answered by WHICH SIG MOVED, + -- and this row had no such line at all. It is the proof for the dispel-type map fix: + -- on a frame whose self-ness flips (own frame <-> someone else's) the map differs + -- while key set and filter strings do not, so before that fix NEITHER sig moved and + -- this branch was never reached. Seeing a TUNE here on that transition IS the fix + -- working; seeing none means the plan and the signature have drifted apart again. + DF:Debug("DISPEL", "overlay: TUNE unit=%s self=%s %s -> %s", + tostring(frame.unit), tostring(isSelf), + tostring(frame.dispelFactoryTuneSig), tostring(tuneSig)) frame.dispelFactoryTuneSig = tuneSig h:ApplyTuning({ filter = dispelFilterRecords(slots, db, frame) }) -- Fall through: the unit upkeep and style pass below still apply. From 1e392e1c0ab85079ed50d8d3148f59c8ea13399b Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 26 Aug 2026 16:41:28 +0100 Subject: [PATCH 3/8] Dispel: the Shaman poison repair is talent-aware for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe asked the wrong question. Blizzard documents the two calls as answering different things: IsSpellInSpellBook -- "Returns true if a spell should be found in the spellbook. This function can also return true for spells that aren't known, such as override spells granted by an aura linked to class talents." IsSpellKnown -- "Returns true if a player knows a spell." Poison Cleansing Totem is exactly a talent-linked entry, so the spellbook probe answered true for every shaman and the poison gap slot was added whether or not the totem was talented. Field-reported: "it's still showing when they don't have the totem talent. It's a half fix." C_SpellBook.IsSpellKnown is now the primary probe. ⚠ It is NOT nonexistent -- it is in SpellBookDocumentation.lua beside IsSpellInSpellBook, and an earlier review removed it as a bogus fallback on the strength of "as far as I know". Restored as the call that should have been there first, with IsSpellInSpellBook kept below it as a last resort that over-reports rather than leaving the feature dead. Also: the talent handler now bumps the aura layout version itself. The drives are version-gated, so a talent change could only re-plan because ForceRefreshAllFrames ends in InvalidateAuraLayout -- the dispel overlay depending on the Aura Designer's refresh to notice a talent, which breaks silently the moment that call is guarded on AD being enabled. Plus a debug line on the overlay's TUNE decision: the row had none, and "which signature moved" is the first question when a setting appears not to apply. --- DandersFrames/Core.lua | 14 +++++++++++++ DandersFrames/Features/Auras.lua | 35 ++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/DandersFrames/Core.lua b/DandersFrames/Core.lua index 936e4f2b..fbb08661 100644 --- a/DandersFrames/Core.lua +++ b/DandersFrames/Core.lua @@ -7849,6 +7849,20 @@ DF._MainEventDispatcher = function(self, event, arg1) if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.ForceRefreshAllFrames then DF.AuraDesigner.Engine:ForceRefreshAllFrames() end + -- ☠ A TALENT CHANGE IS AN AURA-LAYOUT CHANGE, and nothing here said so. + -- The dispel overlay's plan asks whether the player has Poison Cleansing + -- Totem — a TALENT — and the drives are version-gated, so without a bump + -- the overlay stays on its fast path and keeps the old plan until some + -- unrelated setting happens to move the version. + -- ⚠ It appeared to work only because ForceRefreshAllFrames above ends in + -- InvalidateAuraLayout: a dispel feature silently depending on the Aura + -- Designer's refresh to notice a talent, which is the kind of link nobody + -- would look for when it breaks -- and it breaks the moment that call is + -- guarded on AD being enabled. State it here instead of inheriting it. + -- Cheap: the drives are sig-gated, so an unchanged plan costs a compare. + if DF.InvalidateAuraLayout then + DF:InvalidateAuraLayout() + end end) end diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index a45ce454..29413279 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -171,10 +171,18 @@ DF.DispelTypeMap = DISPEL_TYPES -- the local dump -- so their repair silently no-ops for anyone with that CVar -- off. That is the exact trap the note below records us falling into first. -- --- ⚠ Re-checked on every call, never cached: the totem is a TALENT, and talent --- swaps re-drive the factories through the coalesced PLAYER_TALENT_UPDATE -> --- UpdateAllFrames path, which rebuilds the plans that call this. A cached --- answer would survive exactly the event that changes it. +-- ⚠ Re-checked on every call, never cached: the totem is a TALENT, so a cached answer +-- would survive exactly the event that changes it. +-- +-- ☠ THE RE-DRIVE ROUTE IS NOT WHAT THIS COMMENT USED TO SAY. It claimed talent swaps +-- reach the plans "through the coalesced PLAYER_TALENT_UPDATE -> UpdateAllFrames path". +-- UpdateAllFrames does NOT bump auraLayoutVersion, and the drives are version-gated, so +-- that route on its own leaves the overlay on its fast path and never re-plans. What +-- actually carried it was the AD engine: the same handler calls +-- Engine:ForceRefreshAllFrames, which ends in DF:InvalidateAuraLayout. A dispel feature +-- depending on the Aura Designer's refresh to notice a talent is the kind of link nobody +-- would look for when it breaks, so the talent handler now bumps the version itself +-- (Core.lua) and this no longer rides a sibling feature. -- -- ☠☠ NOT IsPlayerSpell. The first cut used it, and on a 12.1 client that -- function EXISTS ONLY IF A CVAR IS ON: it lives in Blizzard_DeprecatedSpellBook @@ -208,6 +216,25 @@ local function KnowsPoisonCleansingTotem() local sb = C_SpellBook local bank = Enum and Enum.SpellBookSpellBank and Enum.SpellBookSpellBank.Player if sb and bank then + -- ☠☠ IsSpellKnown, NOT IsSpellInSpellBook. THE TWO ANSWER DIFFERENT QUESTIONS and + -- we shipped the wrong one — Blizzard's own documentation says so outright: + -- IsSpellInSpellBook — "Returns true if a spell should be found in the spellbook. + -- This function CAN ALSO RETURN TRUE FOR SPELLS THAT AREN'T KNOWN, such as + -- override spells granted by an aura linked to CLASS TALENTS." + -- IsSpellKnown — "Returns true if a player KNOWS a spell." + -- Poison Cleansing Totem is precisely a talent-linked entry, so the spellbook probe + -- answered true for every shaman alive and the poison gap slot was added whether or + -- not the totem was talented. Field-reported: "it's still showing when they don't + -- have the totem talent. It's a half fix." (Krathe, 2026-08-26.) + -- ⚠ A capability probe must ask about the CAPABILITY. "Would this appear in the + -- spellbook UI" is a rendering question and was never the right one. + if sb.IsSpellKnown then + return sb.IsSpellKnown(POISON_CLEANSING_TOTEM, bank) and true or false + end + -- ⚠ LAST RESORT, AND IT OVER-REPORTS — see above. Kept only so a build without + -- IsSpellKnown still repairs the gap for the shamans who DO have the totem, at the + -- cost of also repairing it for those who do not. Better than the feature being + -- silently dead, worse than being right. if sb.IsSpellInSpellBook then return sb.IsSpellInSpellBook(POISON_CLEANSING_TOTEM, bank, true) and true or false end From 6db1fe21bae61568107fcdb129a4d62b4dc0b39c Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 26 Aug 2026 16:51:02 +0100 Subject: [PATCH 4/8] Dispel: watch the events a talent edit actually raises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-talenting Poison Cleansing Totem never brought the poison gap back, while untalenting removed it — one-way, the same shape as every other missing trigger this week. PLAYER_TALENT_UPDATE is not the signal. A talent edit does not necessarily raise it, and even when something fires, the spell is not KNOWN yet at probe time: the spellbook grant arrives afterwards with SPELLS_CHANGED. Removal appeared to work only because the spell goes away promptly. Watch TRAIT_CONFIG_UPDATED and SPELLS_CHANGED, for the two different lags, and bump the layout version only when the capability actually flips — SPELLS_CHANGED fires constantly. The flip cache exists solely for that gate; the probe itself stays uncached so every real read is live. Shamans only. Also logs the overlay's structural BUILD/REBUILD decision. Adding or removing the gap slot changes the slot key set, so a talent change is structural and never reached the tune branch's line — which is why the log was silent through exactly the transition being tested. --- DandersFrames/Features/Auras.lua | 36 +++++++++++++++++++++++++++++++ DandersFrames/Features/Dispel.lua | 9 ++++++++ 2 files changed, 45 insertions(+) diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index 29413279..e7ddc090 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -274,6 +274,42 @@ function DF:GetEngineDispelFlagGaps(selfOnly) return nil end +-- ☠☠ A TALENT EDIT DOES NOT FIRE A SPEC EVENT, AND THE SPELLBOOK GRANT LANDS LATE. +-- PLAYER_TALENT_UPDATE is not the signal: talent edits do not necessarily raise it, and +-- even when something does fire, the spell is not KNOWN yet when we probe — the grant +-- arrives with SPELLS_CHANGED afterwards. Net effect, field-reported: untalenting the +-- totem removed the poison gap (the spell goes away promptly) while re-talenting it never +-- brought it back, because the only re-plan happened before the spellbook caught up and +-- nothing re-asked afterwards. One-way, which is the same shape as every other missing +-- trigger this week: the state has an edge, and only one side of it had a listener. +-- +-- ⚠ Both events, for the two different lags — the trait config landing, and the spellbook +-- grant that follows it. EllesmereUI reached the same pair from the same bug and says so +-- in its own note; the reasoning is theirs and it holds here. +-- +-- ★ FLIP-GATED. SPELLS_CHANGED fires constantly (every login, every book open, every +-- temporary grant), so the version is bumped only when the CAPABILITY actually changes. +-- The cache here exists solely to detect that flip — the probe itself stays uncached, so +-- every real read is still live. Shamans only: nobody else can hold this talent, so no +-- other class pays an event registration for it. +do + local _, playerClass = UnitClass("player") + if playerClass == "SHAMAN" then + local lastKnown + local totemWatcher = CreateFrame("Frame") + totemWatcher:RegisterEvent("TRAIT_CONFIG_UPDATED") + totemWatcher:RegisterEvent("SPELLS_CHANGED") + totemWatcher:SetScript("OnEvent", function() + local now = KnowsPoisonCleansingTotem() + if now == lastKnown then return end + lastKnown = now + DF:Debug("DISPEL", "Poison Cleansing Totem -> %s (re-planning dispel displays)", + tostring(now)) + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + end) + end +end + -- Build the debuff filter records (native 12.1 category filters). -- Returns nil (show all) or an array of records { filter, key, candidateFilters } -- — the record form normalizeFilters accepts; each record becomes one container diff --git a/DandersFrames/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua index d6dd1daa..9f3a2bad 100644 --- a/DandersFrames/Features/Dispel.lua +++ b/DandersFrames/Features/Dispel.lua @@ -2720,6 +2720,15 @@ function DF:DriveDispelOverlayFactory(frame, db) h:ApplyTuning({ filter = dispelFilterRecords(slots, db, frame) }) -- Fall through: the unit upkeep and style pass below still apply. elseif not h or frame.dispelFactorySig ~= sig then + -- ☠ THE OTHER HALF OF THE TUNE LINE ABOVE, AND THE ONE A TALENT CHANGE TAKES. + -- Adding or removing the poison gap slot changes the slot KEY SET, so it is + -- structural and lands here rather than in the tune branch — which is why the + -- first cut of this logging showed nothing at all while the totem was being + -- talented on and off. A dump that is silent on the transition you are testing is + -- worse than no dump: it reads as "nothing happened". + DF:Debug("DISPEL", "overlay: %s unit=%s self=%s %s -> %s", + h and "REBUILD" or "BUILD", tostring(frame.unit), tostring(isSelf), + tostring(frame.dispelFactorySig), tostring(sig)) if h then h:Destroy() end local filterRecords = dispelFilterRecords(slots, db, frame) h = DF.AuraContainer:Create(frame, { From 9c046fb326e3a1ba8f49de5e894ce4d2d299faca Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 26 Aug 2026 16:59:07 +0100 Subject: [PATCH 5/8] Debug: the overlay's self flag logs as racialSelf It is not 'is this the player's frame' -- it is 'does the racial gap apply here', which is that AND being a dwarf. Printed as 'self' it produced the line 'unit=player self=false', which reads as the unit test being broken rather than a non-dwarf short-circuiting before it runs. A diagnostic that reads as a fault is worse than no diagnostic. --- DandersFrames/Features/Dispel.lua | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/DandersFrames/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua index 9f3a2bad..c094465a 100644 --- a/DandersFrames/Features/Dispel.lua +++ b/DandersFrames/Features/Dispel.lua @@ -2687,6 +2687,11 @@ function DF:DriveDispelOverlayFactory(frame, db) -- (or stopped being) the player would keep the wrong slot set until some unrelated -- rebuild happened by. Costs nothing for anyone who is not a dwarf: RacialGapPossible -- short-circuits before the UnitIsUnit call, and this is the per-UNIT_AURA path. + -- ⚠ NOT "is this the player's frame". It is "does the RACIAL gap apply here" — that AND + -- being a dwarf — which is why it logs as racialSelf. The first cut printed it as + -- `self`, and a line reading `unit=player self=false` looks like the unit test is + -- broken rather than a non-dwarf short-circuiting before it ever runs (field log, + -- 2026-08-26 16:56). A diagnostic that reads as a fault is worse than no diagnostic. local isSelf = RacialGapPossible() and UnitIsUnit(frame.unit, "player") and true or false if h and frame.dfDispelFactoryVersion == ver and frame.dfDispelStyledGen == (h._gen or 0) and frame.dfDispelSelf == isSelf then @@ -2713,7 +2718,7 @@ function DF:DriveDispelOverlayFactory(frame, db) -- while key set and filter strings do not, so before that fix NEITHER sig moved and -- this branch was never reached. Seeing a TUNE here on that transition IS the fix -- working; seeing none means the plan and the signature have drifted apart again. - DF:Debug("DISPEL", "overlay: TUNE unit=%s self=%s %s -> %s", + DF:Debug("DISPEL", "overlay: TUNE unit=%s racialSelf=%s %s -> %s", tostring(frame.unit), tostring(isSelf), tostring(frame.dispelFactoryTuneSig), tostring(tuneSig)) frame.dispelFactoryTuneSig = tuneSig @@ -2726,7 +2731,7 @@ function DF:DriveDispelOverlayFactory(frame, db) -- first cut of this logging showed nothing at all while the totem was being -- talented on and off. A dump that is silent on the transition you are testing is -- worse than no dump: it reads as "nothing happened". - DF:Debug("DISPEL", "overlay: %s unit=%s self=%s %s -> %s", + DF:Debug("DISPEL", "overlay: %s unit=%s racialSelf=%s %s -> %s", h and "REBUILD" or "BUILD", tostring(frame.unit), tostring(isSelf), tostring(frame.dispelFactorySig), tostring(sig)) if h then h:Destroy() end From 62b7e9c4fb36725d19b4bd773201a71e6ee9ae30 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 27 Aug 2026 12:22:11 +0100 Subject: [PATCH 6/8] Dispel: a talent-aware capability table drives "Only Dispellable by You" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's dispel flag is class/spec shaped and talents are not part of it, in BOTH directions: it misses what a talent GRANTS (a shaman's Poison Cleansing Totem) and asserts what a talent GATES (a priest's disease cure, which needs Improved Purify). Both field-reported. DISPEL_SPELLS is a per-class curated list of dispel spells and the types each grants, cross-checked against the addons that already do this on retail rather than authored from memory -- that check found four errors in the from-memory pass (Monk's talent types exactly backwards, Mage and Paladin each missing a spell, Hunter absent entirely, which would have treated every hunter as dispelling nothing) and the field validation then caught two classic-era IDs that do not resolve on retail and were dropped. All eight dispel-capable classes are validated against live characters, including Warlock's pet path -- the one entry probed through the PET spellbook bank, re-checked on UNIT_PET because summoning or swapping a pet changes what the player can cleanse with no spellbook event. Two probes, split by what each answers: talent entries -> C_SpellBook.IsSpellKnown. IsSpellInSpellBook returns true for UNKNOWN talent entries by Blizzard's own documentation -- the original over-report. ⚠ The flag means "the spellbook probe LIES about this spell" (override-linked grants), NOT "this spell is talent-gated": Remove Curse and Detox are talent-gated and track correctly as base entries, verified both ways in game. base entries -> IsSpellInSpellBook with includeOverrides, because a base spell replaced by an upgrade is still usable and only that call follows the override chain. The correction SUBTRACTS from the engine's answer rather than replacing it -- fail-open by design: a wrong ID or an uncurated class can only over-show (what the overlay does today), never hide a dispel the player has. If nothing in a class list probes known, nothing is subtracted at all: negatives from a non-functioning list are worthless. Applied to the dispel overlay and the debuff row's by-me mode together, so the two displays cannot disagree about the same debuff. ALL mode is untouched: it rides DISPELLABLE, which is capability-independent by definition. /df debug dispelcap prints each probe, its conclusion, the resolved spell name (the check that caught both bad IDs), and -- the half that can regress someone -- exactly what would be subtracted for this character. --- DandersFrames/Core.lua | 6 + DandersFrames/Features/Auras.lua | 359 +++++++++++++++++++++++++++++- DandersFrames/Features/Dispel.lua | 24 ++ 3 files changed, 380 insertions(+), 9 deletions(-) diff --git a/DandersFrames/Core.lua b/DandersFrames/Core.lua index fbb08661..673d42e6 100644 --- a/DandersFrames/Core.lua +++ b/DandersFrames/Core.lua @@ -7050,6 +7050,12 @@ DF._MainEventDispatcher = function(self, event, arg1) elseif DF.AuraContainer and DF.AuraContainer.DebugDumpIdentityGate then DF.AuraContainer.DebugDumpIdentityGate() end + elseif msg == "dispelcap" then + -- What can this character ACTUALLY cleanse, talents included, and which + -- spell said so. The engine's flag is class/spec shaped and wrong in both + -- directions on talent-gated dispels; this is the table that is meant to + -- replace it, printed for validation before anything is wired to it. + if DF.DebugDispelCapability then DF:DebugDispelCapability() end elseif msg == "adgate" then -- The AD half of the same question: idgate sees a placement's handle but -- not its chain, its parent-driven links or its badge — so an indicator diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index e7ddc090..fba77cae 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -198,6 +198,315 @@ DF.DispelTypeMap = DISPEL_TYPES -- ★ THREE OUTCOMES, NOT TWO. nil means "cannot tell" and is logged once -- -- distinct from a confident false. A capability probe that cannot run must say -- so rather than quietly answering "no". +-- ============================================================ +-- PLAYER DISPEL CAPABILITY (talent-aware) +-- ============================================================ +-- ☠ THE ENGINE'S FLAG IS CLASS/SPEC SHAPED AND TALENTS ARE NOT PART OF IT — in BOTH +-- directions. It misses capability a talent GRANTS (a shaman's Poison Cleansing Totem) and +-- asserts capability a talent GATES (a priest's disease cure, which needs Improved Purify). +-- Field-reported both ways. +-- +-- ★ THIS IS A SPELLBOOK QUESTION, NOT AN AURA QUESTION, which is why it is answerable at +-- all under the 12.1 lockdown. Aura DATA is sealed; the player's own spellbook never was. I +-- told Krathe more than once that talent-aware dispels were impossible, and that was the +-- two conflated — worth remembering before repeating it. +-- +-- ⚠ EVERY ID IN THIS TABLE NEEDS FIELD VALIDATION, class by class. A wrong ID silently +-- mis-renders dispels for a whole class, and offline there is no way to confirm one. That +-- is what `/df debug dispelcap` is for: it prints what we concluded and the spell that +-- concluded it, so a bad entry shows up as a disagreement with the character in front of +-- you rather than as a quietly wrong overlay. Entries marked UNVERIFIED have not been +-- checked on a live character yet. +-- +-- ⚠ NOT COPIED. Peer libraries solve the same problem (ElvUI's LibDispel, VuhDo's +-- VUHDO_PLAYER_DISPEL_ABILITIES) and the METHOD is theirs — probe the spellbook per class, +-- treat talent entries separately. The table below is ours, retail-only, and deliberately +-- omits the classic/vanilla branches those libraries carry. +-- +-- ☠ `talent = true` selects the probe. C_SpellBook.IsSpellKnown answers "does the player +-- KNOW this", which is the only correct question for a talent; IsSpellInSpellBook answers +-- "should this appear in the spellbook" and returns TRUE FOR UNKNOWN TALENT ENTRIES by +-- Blizzard's own documentation. We shipped the wrong one once already. ⚠ ElvUI's LibDispel +-- makes exactly this split — IsSpellKnown for Improved Purify, the spellbook probe for the +-- shaman totem — which is why their shaman case has the bug we just fixed and their priest +-- case does not. +-- +-- ★ THE IDS AND THE GATING ARE CROSS-CHECKED AGAINST THE ADDONS THAT ALREADY DO THIS ON +-- RETAIL — ElvUI's LibDispel and VuhDo — rather than authored from memory. That check found +-- four errors in my first pass: Monk had the talent's types backwards, Mage and Paladin were +-- each missing a spell, and HUNTER was absent altogether. Spell IDs are game data, not +-- authored code; reading a peer to confirm one is research, and it is the difference between +-- a table that is right and one that merely looks right. +-- ⚠ Retail only. The peers carry classic/TBC branches (Abolish Poison, Devour Magic ranks, +-- Disease Cleansing Totem) that have no place here. +local DISPEL_SPELLS = { + DRUID = { + { id = 88423, types = { Magic = true, Poison = true, Curse = true } }, -- Nature's Cure (Resto) + { id = 2782, types = { Poison = true, Curse = true } }, -- Remove Corruption + }, + EVOKER = { + { id = 360823, types = { Magic = true, Poison = true } }, -- Naturalize (Preservation) + { id = 365585, types = { Poison = true } }, -- Expunge (Devastation) + { id = 374251, types = { Poison = true, Disease = true, Curse = true, Bleed = true } }, -- Cauterizing Flame + { id = 378438, types = { Magic = true } }, -- Scouring Flame (PvP talent) + }, + HUNTER = { + -- ☠ NEARLY MISSED ENTIRELY. Hunters dispel on retail through a talent-granted + -- salve, which is why a from-memory table left the class out and would have treated + -- every hunter as dispelling nothing. + { id = 459517, types = { Poison = true, Disease = true }, talent = true }, + }, + MAGE = { + -- ✅ 2026-08-26: verified BOTH ways on one character — unspecced it reads unknown, + -- with default talents it reads known. ★ So a talent-gated spell does NOT always + -- need `talent = true`: that flag exists only for grants the spellbook probe + -- OVER-reports, which is the override-linked kind (Poison Cleansing Totem). An + -- ordinary talent that simply is or is not in the book tracks correctly as a base + -- entry. Do not flag every talent-gated spell on principle — flag the ones that + -- lie. + { id = 475, types = { Curse = true } }, -- Remove Curse + -- ⚠ Grants Magic AS WELL as Curse — the half a from-memory entry drops. + -- ☠ 412113 WAS HERE AND DOES NOT RESOLVE ON RETAIL — "(name unavailable)" in the + -- first mage dump, the same failure as the paladin's 1152. Taken from LibDispel, + -- where it is probed with no era guard and would have contributed a Magic claim + -- mages do not have. Second import from the same source to fail the name check, + -- which is the argument for the name column existing at all. + }, + MONK = { + { id = 115450, types = { Magic = true, Poison = true, Disease = true } },-- Detox (Mistweaver) + -- ✅ 2026-08-26, confirmed from the talent tooltip itself: SpellID 218164, + -- "Removes all Poison and Disease effects", Rank 0/1 — a talent NOT in the default + -- loadout. So a monk who has not taken it genuinely cannot dispel, and reading + -- unknown here is the right answer rather than a missing capability. + -- ★ Another non-override talent that tracks correctly as a BASE entry, like the + -- mage's Remove Curse — the spellbook probe returned false untalented. See the note + -- on the talent flag above. + { id = 218164, types = { Poison = true, Disease = true } }, -- Detox (Brewmaster/Windwalker) + -- ⚠ Poison/Disease, NOT Magic. My first pass had this one exactly backwards. + { id = 388874, types = { Poison = true, Disease = true }, talent = true },-- Improved Detox + }, + PALADIN = { + { id = 4987, types = { Magic = true, Poison = true, Disease = true } },-- Cleanse (Holy) + -- ☠ 1152 "Purify" WAS HERE AND IS NOT A RETAIL SPELL. LibDispel probes it without a + -- retail guard, so it came across when I said the classic branches were dropped — + -- they were, except the ones the peer does not label. It showed as + -- "(name unavailable)" in the very first paladin dump, which is precisely the check + -- the name column exists for. ⚠ Cross-checking a peer is not the same as inheriting + -- its era assumptions: verify each ID RESOLVES, not just that someone else uses it. + { id = 213644, types = { Poison = true, Disease = true } }, -- Cleanse Toxins + }, + PRIEST = { + { id = 527, types = { Magic = true } }, -- Purify (Disc/Holy) + { id = 32375, types = { Magic = true } }, -- Mass Dispel + { id = 213634, types = { Disease = true } }, -- Purify Disease (Shadow) + -- ★ THE REPORTED CASE. ✅ Confirmed both directions in game, 2026-08-26. + { id = 390632, types = { Disease = true }, talent = true }, -- Improved Purify + }, + SHAMAN = { + { id = 77130, types = { Magic = true, Curse = true } }, -- Purify Spirit (Resto) + { id = 51886, types = { Curse = true } }, -- Cleanse Spirit + -- ✅ Confirmed both directions in game, 2026-08-26 (Krathe). + { id = 383013, types = { Poison = true }, talent = true }, -- Poison Cleansing Totem + }, + WARLOCK = { + -- ☠ A PET SPELL, and the only one here that is. Singe Magic belongs to the Imp, so + -- the probe must ask the PET spellbook rather than the player's. Both peers also + -- re-check it on UNIT_PET, which is why that event joins the capability watcher. + -- ✅ 2026-08-26, verified BOTH ways: unknown with no Imp out, known with one + -- summoned. That is the only live proof the PET bank resolves at all — nothing else + -- in this table takes that branch, so a broken Enum.SpellBookSpellBank.Pet would + -- have shown up as "?" here and nowhere else. + { id = 89808, types = { Magic = true }, pet = true }, -- Singe Magic (Imp) + }, +} + +-- Races whose racial clears a type no class spell covers. ✅ Dwarf/Bleed verified in game. +-- ☠ SELF ONLY — a racial cleanses its own caster. See GetEngineDispelFlagGaps. +local RACIAL_DISPEL = { + Dwarf = { Bleed = true }, -- Stoneform +} + +-- ⚠ Same three-outcome contract as the totem probe: nil means "could not tell", which is +-- NOT the same as false. A capability probe that cannot run must say so. +-- ☠☠ TWO PROBES, AND WHICH ONE IS RIGHT DEPENDS ON THE ENTRY. +-- talent entries -> IsSpellKnown. IsSpellInSpellBook returns TRUE FOR UNKNOWN TALENTS by +-- Blizzard's own doc, which is the over-report that showed poison for every shaman. +-- base entries -> IsSpellInSpellBook with includeOverrides. A base spell replaced by an +-- upgraded version is still usable, and only the spellbook probe follows that chain; +-- IsSpellKnown on the superseded ID can answer false and lose the capability. +-- ⚠ Using one call for both is wrong in one direction or the other, which is why the peers +-- split it exactly here too. I had it as IsSpellKnown for everything and would have started +-- losing overridden base spells. +local function PlayerKnowsSpell(id, isTalent, isPet) + local sb, en = C_SpellBook, Enum and Enum.SpellBookSpellBank + if not (sb and en) then return nil end + local bank = isPet and en.Pet or en.Player + if not bank then return nil end + if isTalent then + if sb.IsSpellKnown then return sb.IsSpellKnown(id, bank) and true or false end + -- No safe fallback for a talent: the spellbook probe would answer yes for one the + -- player does not have, recreating the exact bug this exists to remove. Say + -- "cannot tell" rather than guess. + return nil + end + if sb.IsSpellInSpellBook then + return sb.IsSpellInSpellBook(id, bank, true) and true or false + end + if sb.IsSpellKnown then return sb.IsSpellKnown(id, bank) and true or false end + return nil +end + +-- What can this player actually dispel, right now? Returns a type map plus a per-type note +-- of which spell granted it, for the debug dump. Never cached — a talent edit changes the +-- answer and a cached one would survive exactly that. +function DF:GetPlayerDispelCapability() + local _, class = UnitClass("player") + local types, via = {}, {} + for _, entry in ipairs(DISPEL_SPELLS[class] or {}) do + if PlayerKnowsSpell(entry.id, entry.talent, entry.pet) == true then + for t in pairs(entry.types) do + if not types[t] then + types[t] = true + via[t] = entry.id + end + end + end + end + local _, race = UnitRace("player") + local racial = race and RACIAL_DISPEL[race] + return types, via, racial +end + +-- Types this character demonstrably CANNOT cleanse, for subtracting from the engine's +-- answer. Returns nil when we should not act at all. +-- +-- ★ CORRECT THE ENGINE, DO NOT REPLACE IT — Krathe's call, and the failure modes are the +-- argument. Driving the overlay purely from our own table would fail CLOSED: one wrong +-- spell ID, or a class nobody has curated, and that type silently stops showing at all. +-- Subtracting from the engine's answer fails OPEN — the worst a mistake does is show a +-- type you cannot actually cleanse, which is exactly what the overlay does today and is +-- plainly better than a healer not seeing a debuff they CAN cure. +-- +-- ☠☠ THE FAIL-OPEN IS NOT AUTOMATIC, IT IS THIS PRECONDITION. If NOTHING in the class list +-- probed true, the table is not working for this character — bad IDs, an uncurated class, +-- a spellbook that has not populated yet — and its negatives are worth nothing, so we +-- subtract nothing and leave the engine's answer alone. Only once at least one spell has +-- proven the list functional HERE do we trust it to say what is missing. +-- ⚠ Types the class list does not cover at all are never excluded either: silence is not +-- evidence. A priest list carrying no Curse entry says nothing about curses. +function DF:GetDispelTypesToExclude() + local _, class = UnitClass("player") + local entries = DISPEL_SPELLS[class] + if not entries then return nil end -- uncurated class: not our place to say + + local types = DF:GetPlayerDispelCapability() + if next(types) == nil then return nil end -- the list proved nothing here; do not act + + -- Every type this class can cleanse in principle, per the table above. Covered-but-not- + -- known is what gets SUBTRACTED, so this set is the whole claim the correction rests on + -- -- which is why the table is cross-checked against the peers rather than remembered. + local covered = {} + for _, e in ipairs(entries) do + for t in pairs(e.types) do covered[t] = true end + end + + local exclude + for t in pairs(covered) do + if not types[t] then + exclude = exclude or {} + exclude[t] = true + end + end + return exclude +end + +-- ☠ THE VALIDATION TOOL FOR THE TABLE ABOVE, and the reason it is safe to ship a table of +-- unverified spell IDs at all. Every ID is a claim about a class the author may not have +-- played; this prints the claim and the evidence side by side so a wrong one is visible on +-- the character that disproves it, rather than silently mis-rendering that class's overlay. +-- ⚠ Read the PROBE column: "known" means C_SpellBook.IsSpellKnown said yes, "unknown" means +-- it said no, and "?" means it could not answer — which for a talent entry is deliberate +-- (the spellbook fallback over-reports talents, so it is refused rather than guessed). +-- ☠ DECLARED HERE, BELOW ITS DATA, NOT BESIDE THE OTHER DEBUG DUMPS. The first cut sat +-- above DISPEL_SPELLS and PlayerKnowsSpell, so both compiled as nil GLOBALS — legal Lua +-- that parses clean and dies at the call. The `_ENV` globals diff is what caught it; it is +-- the second time this file has done it to me in one session. +function DF:DebugDispelCapability() + local _, class = UnitClass("player") + local _, race = UnitRace("player") + local o = DF:Out("Dispel Capability", (class or "?") .. " / " .. (race or "?")) + + local entries = DISPEL_SPELLS[class] + o:Section("Spells probed") + if not entries then + o:Line("No dispel spells are curated for this class — it is treated as dispelling " + .. "nothing. If that is wrong, the class is missing from DISPEL_SPELLS.", "WARN") + else + for _, e in ipairs(entries) do + local known = PlayerKnowsSpell(e.id, e.talent, e.pet) + local list = {} + for t in pairs(e.types) do list[#list + 1] = t end + table.sort(list) + local name = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(e.id) + o:Line(string.format("%-7d %-24s %-8s %-22s%s%s", + e.id, tostring(name or "(name unavailable)"), + known == true and "known" or (known == false and "unknown" or "?"), + table.concat(list, "/"), + e.talent and " [talent]" or "", + e.pet and " [pet]" or ""), + known == true and "GOOD" or nil) + end + end + + local types, via, racial = DF:GetPlayerDispelCapability() + o:Section("Conclusion — what DF thinks you can cleanse") + local any = false + for _, t in ipairs({ "Magic", "Curse", "Disease", "Poison", "Bleed" }) do + local from = via[t] + local racialFrom = racial and racial[t] + if types[t] or racialFrom then + any = true + o:Line(string.format("%-8s yes %s", t, + from and ("via spell " .. from) or (racialFrom and ("via the " .. tostring(race) + .. " racial — SELF ONLY") or "")), "GOOD") + else + o:Line(string.format("%-8s no", t)) + end + end + if not any then + o:Line("Nothing — 'Only Dispellable by You' should show an empty overlay.", "WARN") + end + + -- ★ THE CONSEQUENCE, not just the conclusion. Capability alone does not tell you what + -- the overlay will DO — only the subtraction does, and the subtraction is the half that + -- can regress someone. Printing it is what makes this dump a safety check rather than + -- a curiosity. + o:Section("Effect on 'Only Dispellable by You'") + local exclude = DF.GetDispelTypesToExclude and DF:GetDispelTypesToExclude() + if not exclude then + o:Line("Nothing subtracted — the engine's answer stands unchanged.") + o:Line("Either this class is not in the table, nothing it can cleanse in principle " + .. "is missing, or no spell probed known at all — that last one leaves the " + .. "engine alone on purpose.", "NEUTRAL") + else + local list = {} + for t in pairs(exclude) do list[#list + 1] = t end + table.sort(list) + o:Line("Subtracting: " .. table.concat(list, ", "), "GOOD") + o:Line("These are types this class CAN cleanse in principle, that you cannot right " + .. "now. If any of them is wrong, a dispel you have is being hidden — that is " + .. "the one failure worth reporting immediately.", "WARN") + end + + o:Section("Cross-check") + o:Line("Compare the yes/no column against the character in front of you. A row that " + .. "disagrees means that entry's spell ID or its talent gating is wrong, not that " + .. "the feature is broken.", "NEUTRAL") + o:Line("⚠ The check that matters is the NAME column: if a row resolves to a spell that " + .. "is not the one the comment claims, that ID is wrong and its types are being " + .. "attributed to the wrong ability.", "NEUTRAL") +end + local ENGINE_GAP_POISON = { Poison = true } -- ★ THE RACIAL GAP. RAID_PLAYER_DISPELLABLE knows class and spec dispels, and NOTHING a -- class learns removes a Bleed -- only the Dwarf racial, Stoneform. So without this, "Only @@ -292,19 +601,37 @@ end -- The cache here exists solely to detect that flip — the probe itself stays uncached, so -- every real read is still live. Shamans only: nobody else can hold this talent, so no -- other class pays an event registration for it. +-- ⚠ ANY DISPEL-CAPABLE CLASS, not just shamans. The first cut watched SHAMAN alone because +-- the totem was the only known case; a priest's disease cure is talent-gated the same way +-- and would have sat stale until a reload. The predicate is "does this class have a +-- curated dispel list", which is the same set the capability table can answer for. do local _, playerClass = UnitClass("player") - if playerClass == "SHAMAN" then + if DISPEL_SPELLS[playerClass] then + -- The whole capability set, serialised, so ANY talent that changes what this + -- character can cleanse trips it — not just the one spell we happened to name. local lastKnown - local totemWatcher = CreateFrame("Frame") - totemWatcher:RegisterEvent("TRAIT_CONFIG_UPDATED") - totemWatcher:RegisterEvent("SPELLS_CHANGED") - totemWatcher:SetScript("OnEvent", function() - local now = KnowsPoisonCleansingTotem() + local function capabilitySig() + local types = DF:GetPlayerDispelCapability() + local list = {} + for t in pairs(types) do list[#list + 1] = t end + table.sort(list) + return table.concat(list, ",") + end + local capWatcher = CreateFrame("Frame") + capWatcher:RegisterEvent("TRAIT_CONFIG_UPDATED") + capWatcher:RegisterEvent("SPELLS_CHANGED") + -- ⚠ Warlock's only dispel is the Imp's, so summoning or swapping a pet changes what + -- the player can cleanse without any talent or spellbook event firing. Both peers + -- re-check on this event for the same reason. + capWatcher:RegisterUnitEvent("UNIT_PET", "player") + capWatcher:SetScript("OnEvent", function() + local now = capabilitySig() if now == lastKnown then return end + local before = lastKnown lastKnown = now - DF:Debug("DISPEL", "Poison Cleansing Totem -> %s (re-planning dispel displays)", - tostring(now)) + DF:Debug("DISPEL", "dispel capability changed: [%s] -> [%s] (re-planning)", + tostring(before), now) if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end end) end @@ -608,8 +935,22 @@ local function BuildDirectDebuffFilters(db, claimed) end if dispelOn and not (claimed and claimed.dispellable) then if playerMode then + -- ☠ SAME TALENT OVER-REPORT AS THE OVERLAY, SAME SUBTRACTION. This record and + -- the overlay's main slot ride the identical engine flag, so a priest without + -- Improved Purify saw disease here too. Fixing only the overlay would have been + -- worse than fixing neither: the two displays would disagree about the same + -- debuff on the same frame, which reads as a rendering fault rather than a + -- capability one. + -- ⚠ ALL mode is deliberately untouched — it rides DISPELLABLE, which is + -- capability-independent by Blizzard's own definition and correctly shows + -- everything dispellable by anyone. + -- ✅ The row's tuning signature already serialises excludeDispelTypes (cfSig, + -- per record via filterTuningSig), so this moves the sig and re-tunes in place. + local dispelCF = notImportant() + local cantCleanse = DF.GetDispelTypesToExclude and DF:GetDispelTypesToExclude() + if cantCleanse then dispelCF.excludeDispelTypes = cantCleanse end filters[#filters + 1] = { filter = "HARMFUL|" .. dispelToken, - key = "dispel", candidateFilters = cfFor(false, notImportant()) } + key = "dispel", candidateFilters = cfFor(false, dispelCF) } -- ★ ENGINE-FLAG GAP REPAIR (Shaman poison via totem -- see -- DF:GetEngineDispelFlagGaps above for the whole story). A sibling -- record for the types the flag misses: negates the flag token so it diff --git a/DandersFrames/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua index c094465a..28607ba6 100644 --- a/DandersFrames/Features/Dispel.lua +++ b/DandersFrames/Features/Dispel.lua @@ -1456,6 +1456,19 @@ local function dispelSlotPlan(db, selfOnly) local baseFilter, baseCF if byMe then baseFilter = "HARMFUL|RAID_PLAYER_DISPELLABLE" + -- ☠ THE ENGINE'S FLAG OVER-REPORTS TALENT-GATED DISPELS, so subtract what this + -- character demonstrably cannot cleanse. Field-reported: "dispellable by me always + -- shows that a priest can remove disease despite it again being a talent choice". + -- ⚠ SUBTRACT, never replace — see DF:GetDispelTypesToExclude for why, and for the + -- precondition that makes a bad spell ID fail toward over-showing rather than + -- silently hiding a type the player CAN cure. + -- ✅ excludeDispelTypes is a candidate filter, so it sits OUTSIDE the identity gate + -- and is applied by the secure-environment matcher — it works in combat, which is + -- the only time this matters. (Same evidence chain as includeDispelTypes.) + local exclude = DF.GetDispelTypesToExclude and DF:GetDispelTypesToExclude() + if exclude then + baseCF = { excludeDispelTypes = exclude } + end elseif allToken then baseFilter = "HARMFUL|" .. allToken else @@ -1587,7 +1600,18 @@ local function dispelFactoryPlanAndSig(db, selfOnly) -- TUNING, not structural: SetAuraSlotCandidateFilters is a live mutator, so this -- re-pushes in place with no teardown (and no stranded buttons — AddAuraSlot is -- add-only). + -- ⚠ BOTH MAPS, not just the include. The exclude side carries the talent-aware + -- correction, and it moves for exactly the same reasons — a talent edit changes it + -- while the key set and the filter string stay put, which is the staleness this + -- serialisation exists to catch. local scf = slots[i].candidateFilters + local edt = scf and scf.excludeDispelTypes + if edt then + local types = {} + for k in pairs(edt) do types[#types + 1] = tostring(k) end + table.sort(types) + tu[#tu + 1] = slots[i].key .. ":edt=" .. table.concat(types, ",") + end local idt = scf and scf.includeDispelTypes if idt then -- Sorted, so the same set always serialises identically — an unordered pairs() From 607701c81a8db670a35fa7dddecab4f66008c8ec Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 27 Aug 2026 12:22:59 +0100 Subject: [PATCH 7/8] Changelog: talent-aware dispels and the dwarf bleed scoping --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c47d729..9bd0b36f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # DandersFrames Changelog +## [Unreleased] + +### Bug Fixes + +* (Dispel) "Only Dispellable by You" now follows your talents everywhere, live. A Shaman's poison dispel appears and disappears with Poison Cleansing Totem, a Priest without Improved Purify no longer sees diseases they cannot cure, and talent changes apply to the overlay and the debuff row without a reload. (by Krathe) +* (Dispel) Fix a Dwarf's bleed dispel lighting up on other players' frames after roster changes — Stoneform only cleanses yourself, so bleeds now show as dispellable only on your own frame. (by Krathe) + ## [5.3.1] ### New Features From 29b1c8a86060633f74a83298ba86c3e7ff0899d3 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 27 Aug 2026 20:46:14 +0100 Subject: [PATCH 8/8] By-me dispel rides the player-scoped RAID token, not the raid-scoped one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-caught (priest, full raid, 2026-08-27): with "Dispellable By Me" on, the POISON overlay lit raid-wide for a priest who cannot dispel poison. The DISPEL debug log ruled out everything downstream -- racialSelf=false on every build (no dwarf gap slot), e=none (empty exclude, correct for a priest knowing both Purify halves). The filter token itself was wrong. Blizzard's token table (Blizzard_FrameXMLUtil/AuraUtil.lua) is explicit and the names point the wrong way: RAID = harmful auras THE PLAYER can dispel RAID_PLAYER_DISPELLABLE = auras SOMEONE IN THE PLAYER'S RAID can dispel By-me shipped on the raid-scoped token, justified by an "observed player-scoped in practice" note -- an observation made solo and in tiny test groups, the one setting where the two sets are identical. In a real raid with poison-dispellers present, the token behaved exactly as documented. Race, range, and the shaman gap work were all coincidental. Changed in lockstep, per the sibling rule this system already carries: - overlay by-me main slot: HARMFUL|RAID - overlay gap slot: HARMFUL|!RAID -- the old raid-scoped negation had its own hole: any raid-mate able to dispel a gap type (an Evoker covers bleeds) made the aura pass the raid-scoped token, fail the negation, and silently darken the dwarf's self-bleed marker whenever the roster composition changed. - debuff row by-me record + its gap-repair sibling: same token swap. The by-me record and the "raid" category record are now the same set by construction, which is not new overlap -- player-dispellable was already a subset of raid-dispellable, so the raid record was already emptied by its dispel negation whenever both were enabled. neg() just no longer emits the identical component twice. Filter strings live in the TUNING half of the split signatures on both surfaces, so existing sessions re-tune in place on reload -- no stranded slots. ⚠ Unverified in the field yet, and the test HAS to be a mixed raid: solo, the wrong token is indistinguishable from the right one -- which is how it shipped. --- DandersFrames/Features/Auras.lua | 34 +++++++++++++++++++++++++------ DandersFrames/Features/Dispel.lua | 28 +++++++++++++++++++++---- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index fba77cae..0e0e77be 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -126,8 +126,9 @@ DF.DispelTypeMap = DISPEL_TYPES -- map the engine flag misses for the current character, or nil when there is -- no gap -- consumed by the overlay's by-me slot plan (Features/Dispel.lua) -- and the debuff row's by-me records (below), each of which pairs it with a --- "|!RAID_PLAYER_DISPELLABLE" negation so the repair never double-renders --- what the flag already catches. +-- "|!RAID" negation (the player-dispellable token — see the dispelToken note +-- below for why it is RAID and not the raid-scoped RAID_PLAYER_DISPELLABLE) +-- so the repair never double-renders what the flag already catches. -- -- ★ THIS WORKS IN COMBAT, and the reason is worth stating because I twice -- claimed the opposite. ADDON Lua genuinely cannot read aura data in combat @@ -836,7 +837,19 @@ local function BuildDirectDebuffFilters(db, claimed) -- CC needs its Blizzard token; skip the group entirely if unavailable local ccToken = db.debuffFilterCrowdControl and AuraFilters.CrowdControl or nil local raidOn = db.debuffFilterRaid - local dispelToken = AuraFilters.RaidPlayerDispellable or "RAID_PLAYER_DISPELLABLE" + -- ☠ By-me rides "RAID" — "harmful auras the player can dispel" per Blizzard's own + -- token table. It shipped on RaidPlayerDispellable, which despite our "observed + -- player-scoped" note is documented AND field-confirmed raid-scoped: a priest in a + -- raid with poison-dispellers had poison lit as dispellable-by-me (2026-08-27, the + -- overlay's twin of this record — see Features/Dispel.lua for the full account). + -- The solo observations that justified the old token are the one setting where the + -- two tokens are indistinguishable. + -- ⚠ This makes the by-me dispel record and the "raid" category record (both + -- HARMFUL|RAID) the SAME SET by construction. That is not new overlap: player- + -- dispellable was already a subset of raid-dispellable, so the raid record was + -- already emptied by its dispel negation whenever both were on. neg() below just + -- avoids emitting the now-identical component twice. + local dispelToken = AuraFilters.Raid or "RAID" local maxDur = db.debuffMaxDurationEnabled and (db.debuffMaxDurationMinutes or 0) > 0 and (db.debuffMaxDurationMinutes or 0) * 60 or nil local keepImportant = db.debuffMaxDurationKeepImportant @@ -853,14 +866,20 @@ local function BuildDirectDebuffFilters(db, claimed) -- here is the dedup working, not a leak. local function neg(excludeDispel, excludeCC, excludeRaid) local s = "" + -- Tracks whether the dispel negation already emitted "!RAID" — since by-me's + -- token IS "RAID" now, emitting it again for excludeRaid would duplicate the + -- component in the filter string. + local negatedRaid = false if excludeDispel then - if playerMode then s = s .. "|!" .. dispelToken + if playerMode then + s = s .. "|!" .. dispelToken + negatedRaid = (dispelToken == "RAID") elseif dispelTypeToken then s = s .. "|!" .. dispelTypeToken end end if excludeCC and ccToken then s = s .. "|!" .. ccToken end -- "RAID" is negatable (only INCLUDE_NAME_PLATE_ONLY and MAW are not — -- AuraUtil.AuraFilters / IsValidFilterString). - if excludeRaid and raidOn then s = s .. "|!RAID" end + if excludeRaid and raidOn and not negatedRaid then s = s .. "|!RAID" end return s end -- candidateFilters for one record. Hands each record its OWN table (extra @@ -960,7 +979,10 @@ local function BuildDirectDebuffFilters(db, claimed) -- excludes these types while a gap is active), so the include here -- and the exclude there are exact complements. if dispelGap then - filters[#filters + 1] = { filter = "HARMFUL|!" .. dispelToken .. neg(false, true, true), + -- excludeRaid dropped from the neg() call: the record's own base already + -- negates the token, and the token IS "RAID" now — the raid-precedence + -- exclusion the old excludeRaid=true bought is the base negation itself. + filters[#filters + 1] = { filter = "HARMFUL|!" .. dispelToken .. neg(false, true, false), key = "dispelgap", candidateFilters = cfFor(false, notImportant({ includeDispelTypes = dispelGap })) } end diff --git a/DandersFrames/Features/Dispel.lua b/DandersFrames/Features/Dispel.lua index 28607ba6..5e4fcd6f 100644 --- a/DandersFrames/Features/Dispel.lua +++ b/DandersFrames/Features/Dispel.lua @@ -1373,7 +1373,7 @@ end -- "custom" = one slot per dispel type (includeDispelTypes); type known at declare -- time, so the FULL art styles statically from the DF pickers (borders, EDGE -- gradients, intensity, type icons). Rare dual-type overlap accepted. --- Me/all: "HARMFUL|RAID_PLAYER_DISPELLABLE" vs "HARMFUL" + candidateFilters +-- Me/all: "HARMFUL|RAID" (player-dispellable) vs "HARMFUL" + candidateFilters -- includeDispelTypes (the shared DF.DispelTypeMap). ☠ The old route here used a -- ProcessAura policy + processedAuraType=Dispel and was described as "the native -- all-dispellable classification" — it is NOT. That branch is gated on aura.isRaid @@ -1455,7 +1455,21 @@ local function dispelSlotPlan(db, selfOnly) local baseFilter, baseCF if byMe then - baseFilter = "HARMFUL|RAID_PLAYER_DISPELLABLE" + -- ☠☠ "RAID", NOT "RAID_PLAYER_DISPELLABLE" — the names point the WRONG way and + -- this mode shipped on the wrong one. Blizzard's own token table + -- (Blizzard_FrameXMLUtil/AuraUtil.lua) is explicit: + -- RAID = "harmful auras the player can dispel" + -- RAID_PLAYER_DISPELLABLE = "auras SOMEONE IN THE PLAYER'S RAID can dispel" + -- We adopted the raid-scoped token on the strength of "observed player-scoped in + -- practice" — an observation made solo and in tiny test groups, where the two + -- sets are IDENTICAL. In a real raid they diverge exactly per the docs. + -- Field-caught (Krathe, 2026-08-27): a priest with By Me on saw the POISON + -- overlay lit raid-wide — because the raid had poison-dispellers. Nothing to do + -- with race, range, or the shaman gap; DISPEL-channel log showed e=none (empty + -- exclude, correct for a priest) and racialSelf=false on every build. + -- ⚠ Test any future change to this line IN A MIXED RAID: solo, the wrong token + -- is indistinguishable from the right one. + baseFilter = "HARMFUL|RAID" -- ☠ THE ENGINE'S FLAG OVER-REPORTS TALENT-GATED DISPELS, so subtract what this -- character demonstrably cannot cleanse. Field-reported: "dispellable by me always -- shows that a priest can remove disease despite it again being a talent choice". @@ -1520,7 +1534,7 @@ local function dispelSlotPlan(db, selfOnly) slots.mode = byMe and "byme" or "alltypes" slots[#slots + 1] = { key = "main", filter = baseFilter, candidateFilters = baseCF, roles = roles } -- ★ ENGINE-FLAG GAP REPAIR (Shaman poison via totem): a second slot for the - -- dispel types RAID_PLAYER_DISPELLABLE misses -- the whole story, the peer + -- dispel types the engine's player-dispel flag (RAID) misses -- the whole story, the peer -- survey and the honest secrecy limit live on DF:GetEngineDispelFlagGaps -- (Features/Auras.lua). The token is NEGATED here so this slot can only fill -- with an aura the main slot cannot, which keeps the two from double-lighting @@ -1533,7 +1547,13 @@ local function dispelSlotPlan(db, selfOnly) -- class-wide Poison half applies on every frame, because a totem cleanses the group. local gap = DF:GetEngineDispelFlagGaps(selfOnly) if gap then - slots[#slots + 1] = { key = "gap", filter = "HARMFUL|!RAID_PLAYER_DISPELLABLE", + -- ☠ "!RAID" to match the main slot's token (see the comment there). The old + -- "!RAID_PLAYER_DISPELLABLE" negation had its own raid-scope hole: with any + -- raid-mate able to dispel a gap type (an Evoker covers bleeds), the aura + -- passed the raid-scoped token, failed the negation, and this slot went dark + -- — a dwarf would silently lose the self-bleed marker exactly when the raid + -- composition changed. The player-scoped complement cannot be stolen that way. + slots[#slots + 1] = { key = "gap", filter = "HARMFUL|!RAID", candidateFilters = { includeDispelTypes = gap }, roles = roles } end end