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 diff --git a/DandersFrames/Core.lua b/DandersFrames/Core.lua index 936e4f2b..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 @@ -7849,6 +7855,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..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 @@ -171,10 +172,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 @@ -190,6 +199,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 @@ -208,6 +526,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 @@ -247,6 +584,60 @@ 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. +-- ⚠ 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 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 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", "dispel capability changed: [%s] -> [%s] (re-planning)", + tostring(before), 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 @@ -446,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 @@ -463,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 @@ -545,8 +954,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 @@ -556,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 0ec301e5..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,34 @@ 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". + -- ⚠ 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 @@ -1507,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 @@ -1520,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 @@ -1569,6 +1602,45 @@ 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). + -- ⚠ 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() + -- 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 @@ -2659,6 +2731,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 @@ -2679,10 +2756,28 @@ 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 racialSelf=%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. 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 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 local filterRecords = dispelFilterRecords(slots, db, frame) h = DF.AuraContainer:Create(frame, {