From cb209bfd5280d53d5583c25d7fc0dfb2fb70e758 Mon Sep 17 00:00:00 2001 From: Glyalith Date: Tue, 4 Aug 2026 07:56:00 -0600 Subject: [PATCH 1/5] chore(locales): regenerate _keys.txt (stale against upstream/main) --- Locales/_keys.txt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Locales/_keys.txt b/Locales/_keys.txt index 1a5ad8122..12d53419f 100644 --- a/Locales/_keys.txt +++ b/Locales/_keys.txt @@ -1,6 +1,6 @@ # Auto-generated by .tools/extract-locale-keys.sh -- do not edit by hand. # Canonical list of translatable English keys passed as string literals -# (666 unique). Regenerate after wrapping new strings. Keys passed as +# (658 unique). Regenerate after wrapping new strings. Keys passed as # variables are not listed here -- use the in-game /euiloc harvester for # the complete runtime set. (Raid) @@ -98,7 +98,6 @@ Anchor Anchored Apply Apply All Settings To -Apply the sharer's Blizz UI Enhanced Window Skins and Tooltips, Menus & Popups settings. These are account-wide and will overwrite yours across ALL profiles. Off = keep your own. Apply to Bar Apply to Bar (All Specs) Apply to This Spell @@ -318,17 +317,12 @@ Import Full Account Data Import Profile Import Selected Addons Import a profile from string. -Import the anchor & size-match relationships from this profile. Off = keep your own layout; only the selected modules' own positions/settings come in. -Import the sharer's complete override setup: spec and conditional override values, groups, their custom Unlock Mode layouts, and Buff Manager overrides. This replaces ALL of your own overrides. Off = keep yours untouched. Import will include %1$s of %2$s addons. Importing %1$s In-game countdown unavailable in combat; the boss mod pull timer still started. Include Include CDM Spell Layout? -Include Overrides Include This Spell -Include Window Skins -Include layout Include your Cooldown Manager spell layout (which spells sit on which bars) plus all per-spell settings for any specs you choose. Include: Independent @@ -585,8 +579,6 @@ This is where you can control the settings of Unlock Mode.\n\nElements can be re This option requires %1$s to be %2$s This option requires Subtitle Text to include the Guild Name This preset -This profile string does not carry any Window & Tooltip Skins settings. -This profile string does not carry any override data. This profile was made at %1$d%% UI scale; yours is %2$d%%. Change your UI scale to match the imported profile? This will show all profiles at this scale as UI Scale is not a per-profile setting, but can be changed at any time back to your original value. This setting's active Apply to Bar (All Specs) value will be replaced. This setting's active Apply to Bar value will be replaced. From dc643091d76dcbcc94e2e8c4bfc857e473ff2f24 Mon Sep 17 00:00:00 2001 From: Glyalith Date: Tue, 4 Aug 2026 07:56:12 -0600 Subject: [PATCH 2/5] fix(cdm): don't trust a FocusKick interrupt this character can't cast focusKickInterruptSpellID lives on the bar definition, which is profile-level, while the spell it names is per-spec spellbook content. Nothing reconciled the two, so a Ret Paladin's Rebuke rode the shared profile onto a Holy Paladin, who has no interrupt at all. The same leak produced the earlier report of a bare "47528" (Mind Freeze) in the dropdown. Two consequences, both fixed by validating on read: - The cast-sound handler took the stale id straight to its "is my kick ready" gate. A spell you do not know is never on cooldown, so the gate passed every time and the player was pinged to interrupt casts they cannot interrupt. - The options dropdown rendered the phantom pick as a real selection. Validated on read and never written back: the id is still correct for the spec that set it, so clearing it would destroy that spec's setting the first time the player logged in on another one. Pet-bank interrupts (Axe Toss, pet kicks) are legitimate picks that IsPlayerSpell cannot see, so both banks are checked. The check deliberately sits at fire time rather than at arming time. Arming runs during loading screens, when the spellbook reads empty for reasons unrelated to the player's spec, and folding "not loaded yet" into "cannot cast it" is the exact collapse that unregistered a working proxy in #1178. --- .../EUI_CooldownManager_Options.lua | 18 +++++++- .../EllesmereUICooldownManager.lua | 42 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua b/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua index 6c5a90444..d0267c041 100644 --- a/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua +++ b/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua @@ -17769,8 +17769,16 @@ initFrame:SetScript("OnEvent", function(self) -- "47528"). Give it a NAME but deliberately do NOT add it to -- spellOrder -- it must not appear as a selectable option on a -- bar that no longer holds it. + -- + -- Label it ONLY when this character can actually cast it. The + -- id is profile-level and the spellbook behind it is per-spec, + -- so a spec that shares the profile but not the spell inherits + -- a pick it can never use -- field-reported as a Holy Paladin + -- being shown "Rebuke". Leaving it unlabelled is what makes + -- getValue below fall back to the bar's own contents. local selSid = BD and BD() and BD().focusKickInterruptSpellID - if selSid then + if selSid and (not ns.PlayerKnowsInterrupt + or ns.PlayerKnowsInterrupt(selSid)) then local selKey = tostring(selSid) if not spellValues[selKey] then local selInfo = C_Spell and C_Spell.GetSpellInfo @@ -17793,7 +17801,13 @@ initFrame:SetScript("OnEvent", function(self) getValue = function() local sid = BD().focusKickInterruptSpellID if not sid then return spellOrder[1] end - return tostring(sid) + -- No label means RebuildSpellOptions rejected it: either + -- this character cannot cast it, or it is not on the bar + -- and not castable. Show what the bar actually holds + -- rather than a selection the player never made. + local key = tostring(sid) + if not spellValues[key] then return spellOrder[1] end + return key end, setValue = function(v) if v == "__none" then diff --git a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua index aacf97f97..c400e5838 100644 --- a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua +++ b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua @@ -6565,6 +6565,35 @@ local function RefreshFocusCastProxyUnit() _focusCastProxy:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", unit) end ns.RefreshFocusCastProxyUnit = RefreshFocusCastProxyUnit + +-- Can THIS character actually cast the stored interrupt? +-- +-- focusKickInterruptSpellID lives on the bar definition, which is +-- profile-level: every spec and every character sharing the profile reads the +-- same id, while the spell it names is per-spec spellbook content. Nothing +-- reconciled the two, so a Ret Paladin's Rebuke rode the profile onto a Holy +-- Paladin, who has no interrupt at all. The same leak produced the earlier +-- report of a bare "47528" (a Death Knight's Mind Freeze) in the dropdown. +-- +-- Validate on READ and never write: the id is still correct for the spec that +-- set it, so clearing it here would destroy that spec's setting the first time +-- the player logged in on another one. +-- +-- Pet-bank interrupts (a Warlock's Axe Toss, a Hunter's pet kick) are +-- legitimate picks and IsPlayerSpell does not see them, so check both banks. +function ns.PlayerKnowsInterrupt(sid) + if type(sid) ~= "number" or sid <= 0 then return false end + if IsPlayerSpell and IsPlayerSpell(sid) then return true end + if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook then + if C_SpellBook.IsSpellKnownOrInSpellBook(sid) then return true end + if Enum and Enum.SpellBookSpellBank + and C_SpellBook.IsSpellKnownOrInSpellBook(sid, Enum.SpellBookSpellBank.Pet) then + return true + end + end + return false +end + local function EnsureFocusCastProxy() if _focusCastProxy then -- Demand-gate re-activation: re-register (idempotent; re-applying @@ -6584,6 +6613,19 @@ local function EnsureFocusCastProxy() local soundKey = bd.focusCastSoundKey or "none" if soundKey == "none" then return end local spellID = bd.focusKickInterruptSpellID + -- An explicit pick is only trusted while this character can cast it. + -- A stale profile-level id sails through the cooldown gate below + -- forever -- a spell you do not know is never on cooldown -- so a Holy + -- Paladin inheriting a Ret Paladin's Rebuke would be pinged for every + -- focus cast to interrupt something they have no interrupt for. + -- + -- Deliberately checked HERE and not at arming time: arming runs during + -- loading screens, when the spellbook reads empty for reasons that have + -- nothing to do with the player's spec, and folding "not loaded yet" + -- into "cannot cast it" is what silently unregistered a working proxy + -- in the first place. This handler only runs on a live cast, by which + -- point the spellbook is settled. + if spellID and not ns.PlayerKnowsInterrupt(spellID) then spellID = nil end -- Auto-fallback: if user hasn't explicitly picked a spell, use the -- first positive spell on the bar. The picker exists for users who -- want a specific spell when multiple are on the bar. From 735afe14786a859a0b0be949cca1d91bdf6559ca Mon Sep 17 00:00:00 2001 From: Glyalith Date: Tue, 4 Aug 2026 08:00:31 -0600 Subject: [PATCH 3/5] fix(cdm): validate the FocusKick bar's own spells too, not just the pick Field report: the sound fires on a Holy Paladin. That only happens if the handler resolved an interrupt id, and it has two sources -- the explicit profile-level pick and the bar's assignedSpells. The previous commit only validated the first, so if Rebuke reached this character through the bar's spell list instead the sound still fired. assignedSpells is per-spec, but per-spec is not the same as castable: importing a shared profile with "Include CDM Spell Layout" writes another character's layout into these spec keys wholesale, and talent changes strand entries the same way. The rule is the same whichever list supplies the id -- a spell this character cannot cast is never on cooldown, so it reads as permanently ready to the gate that decides whether to play the sound. --- .../EllesmereUICooldownManager.lua | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua index c400e5838..197636da2 100644 --- a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua +++ b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua @@ -6627,13 +6627,22 @@ local function EnsureFocusCastProxy() -- point the spellbook is settled. if spellID and not ns.PlayerKnowsInterrupt(spellID) then spellID = nil end -- Auto-fallback: if user hasn't explicitly picked a spell, use the - -- first positive spell on the bar. The picker exists for users who - -- want a specific spell when multiple are on the bar. + -- first CASTABLE positive spell on the bar. The picker exists for users + -- who want a specific spell when multiple are on the bar. + -- + -- The bar's own list needs the same check as the explicit pick, not + -- because it crosses specs (assignedSpells is per-spec) but because it + -- can hold spells this spec no longer has: importing a shared profile + -- with "Include CDM Spell Layout" writes another character's layout + -- into these spec keys wholesale, and talent changes strand entries the + -- same way. Whichever list supplies the id, an interrupt this character + -- cannot cast reads as permanently ready to the cooldown gate below. if not spellID or spellID <= 0 then local sd = ns.GetBarSpellData and ns.GetBarSpellData(FOCUSKICK_BAR_KEY) if sd and sd.assignedSpells then for _, sid in ipairs(sd.assignedSpells) do - if type(sid) == "number" and sid > 0 then + if type(sid) == "number" and sid > 0 + and ns.PlayerKnowsInterrupt(sid) then spellID = sid break end From 25e461031924312dd0ec47ebb0743aba61475b6c Mon Sep 17 00:00:00 2001 From: Glyalith Date: Tue, 4 Aug 2026 08:07:55 -0600 Subject: [PATCH 4/5] fix(cdm): resolve the FocusKick interrupt to the form the player can cast Hardening the same gate from the other side. The check now returns the castable id rather than a yes/no, and the caller uses what it returns. A talent swap moves an interrupt between its base and override forms while the stored id stays put. Answering "yes, known" off the base form but leaving the caller holding the un-castable stored id would feed the readiness gate a spell that is never on cooldown -- the same always-ready failure this branch is about, reached from the other direction. Walking to the override, then to the base, and handing back whichever one the spellbook actually has closes both. It also protects the feature it is guarding: without the base/override walk, a legitimate kick stored under the form the player is not currently talented into would read as "cannot cast" and silently kill the sound for someone who can kick. That is the regression risk of adding a spellbook check at all, and it is the worse of the two failures. Resolution stays spellbook-driven throughout. Which specs carry an interrupt is Blizzard's to change -- Preservation has Quell, Mistweaver has Spear Hand Strike, Resto Shaman has Wind Shear, most other healers have nothing -- and a hardcoded table of that goes stale on a patch. --- .../EUI_CooldownManager_Options.lua | 4 +- .../EllesmereUICooldownManager.lua | 58 ++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua b/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua index d0267c041..ad4543c09 100644 --- a/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua +++ b/EllesmereUICooldownManager/EUI_CooldownManager_Options.lua @@ -17777,8 +17777,8 @@ initFrame:SetScript("OnEvent", function(self) -- being shown "Rebuke". Leaving it unlabelled is what makes -- getValue below fall back to the bar's own contents. local selSid = BD and BD() and BD().focusKickInterruptSpellID - if selSid and (not ns.PlayerKnowsInterrupt - or ns.PlayerKnowsInterrupt(selSid)) then + if selSid and (not ns.ResolveCastableInterrupt + or ns.ResolveCastableInterrupt(selSid)) then local selKey = tostring(selSid) if not spellValues[selKey] then local selInfo = C_Spell and C_Spell.GetSpellInfo diff --git a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua index 197636da2..7fbaae2e2 100644 --- a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua +++ b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua @@ -6579,19 +6579,49 @@ ns.RefreshFocusCastProxyUnit = RefreshFocusCastProxyUnit -- set it, so clearing it here would destroy that spec's setting the first time -- the player logged in on another one. -- +-- Asks the SPELLBOOK, never a class/spec table. Which specs carry an interrupt +-- is Blizzard's to change (Preservation has Quell, Mistweaver has Spear Hand +-- Strike, most other healers have nothing), and any hardcoded list of that goes +-- stale on a patch and starts lying in one direction or the other. +-- -- Pet-bank interrupts (a Warlock's Axe Toss, a Hunter's pet kick) are -- legitimate picks and IsPlayerSpell does not see them, so check both banks. -function ns.PlayerKnowsInterrupt(sid) - if type(sid) ~= "number" or sid <= 0 then return false end - if IsPlayerSpell and IsPlayerSpell(sid) then return true end - if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook then - if C_SpellBook.IsSpellKnownOrInSpellBook(sid) then return true end - if Enum and Enum.SpellBookSpellBank - and C_SpellBook.IsSpellKnownOrInSpellBook(sid, Enum.SpellBookSpellBank.Pet) then +-- +-- Returns the id the player can ACTUALLY cast, or nil. Returning the resolved +-- id rather than a boolean matters: the caller feeds it to a cooldown check, +-- and a talent swap moves an interrupt between its base and override forms +-- while the stored id stays put. Answering "yes, known" but leaving the caller +-- holding the un-castable form would put an id that is never on cooldown into +-- the readiness gate -- the same always-ready failure this whole fix is about, +-- just reached from the other side. +function ns.ResolveCastableInterrupt(sid) + if type(sid) ~= "number" or sid <= 0 then return nil end + local function knownInBook(id) + if IsPlayerSpell and IsPlayerSpell(id) then return true end + if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook + and C_SpellBook.IsSpellKnownOrInSpellBook(id) then return true end + return false end - return false + if knownInBook(sid) then return sid end + -- Talented into a replacement, stored id is the base form. + if C_SpellBook and C_SpellBook.FindSpellOverrideByID then + local ovr = C_SpellBook.FindSpellOverrideByID(sid) + if ovr and ovr > 0 and ovr ~= sid and knownInBook(ovr) then return ovr end + end + -- Talented back out, stored id is the replacement form. + if C_Spell and C_Spell.GetBaseSpell then + local base = C_Spell.GetBaseSpell(sid) + if base and base > 0 and base ~= sid and knownInBook(base) then return base end + end + -- Pet bank last: it has no override/base indirection to walk. + if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook + and Enum and Enum.SpellBookSpellBank + and C_SpellBook.IsSpellKnownOrInSpellBook(sid, Enum.SpellBookSpellBank.Pet) then + return sid + end + return nil end local function EnsureFocusCastProxy() @@ -6625,7 +6655,7 @@ local function EnsureFocusCastProxy() -- into "cannot cast it" is what silently unregistered a working proxy -- in the first place. This handler only runs on a live cast, by which -- point the spellbook is settled. - if spellID and not ns.PlayerKnowsInterrupt(spellID) then spellID = nil end + if spellID then spellID = ns.ResolveCastableInterrupt(spellID) end -- Auto-fallback: if user hasn't explicitly picked a spell, use the -- first CASTABLE positive spell on the bar. The picker exists for users -- who want a specific spell when multiple are on the bar. @@ -6641,10 +6671,12 @@ local function EnsureFocusCastProxy() local sd = ns.GetBarSpellData and ns.GetBarSpellData(FOCUSKICK_BAR_KEY) if sd and sd.assignedSpells then for _, sid in ipairs(sd.assignedSpells) do - if type(sid) == "number" and sid > 0 - and ns.PlayerKnowsInterrupt(sid) then - spellID = sid - break + if type(sid) == "number" and sid > 0 then + local castable = ns.ResolveCastableInterrupt(sid) + if castable then + spellID = castable + break + end end end end From 917fdd780f3fc0592fd3ef90b95621b5e7cd5043 Mon Sep 17 00:00:00 2001 From: Glyalith Date: Tue, 4 Aug 2026 08:20:36 -0600 Subject: [PATCH 5/5] refactor(cdm): hoist the spellbook check out of the resolver, no behaviour change The inner helper was a closure rebuilt on every call, and this runs on every cast start of the tracked unit. Lifted to ns.IsSpellInPlayerBook. Also trimmed the header comment down to the three things that are not obvious from the code: validate on read and never write, ask the spellbook rather than a spec table, and return the resolved id rather than a boolean. The Rebuke story it repeated is already at the call site. --- .../EllesmereUICooldownManager.lua | 54 ++++++++----------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua index 7fbaae2e2..2623d3e24 100644 --- a/EllesmereUICooldownManager/EllesmereUICooldownManager.lua +++ b/EllesmereUICooldownManager/EllesmereUICooldownManager.lua @@ -6566,44 +6566,36 @@ local function RefreshFocusCastProxyUnit() end ns.RefreshFocusCastProxyUnit = RefreshFocusCastProxyUnit --- Can THIS character actually cast the stored interrupt? --- --- focusKickInterruptSpellID lives on the bar definition, which is --- profile-level: every spec and every character sharing the profile reads the --- same id, while the spell it names is per-spec spellbook content. Nothing --- reconciled the two, so a Ret Paladin's Rebuke rode the profile onto a Holy --- Paladin, who has no interrupt at all. The same leak produced the earlier --- report of a bare "47528" (a Death Knight's Mind Freeze) in the dropdown. +function ns.IsSpellInPlayerBook(id) + if IsPlayerSpell and IsPlayerSpell(id) then return true end + if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook + and C_SpellBook.IsSpellKnownOrInSpellBook(id) then + return true + end + return false +end + +-- Returns the id of the stored interrupt in the form this character can +-- actually cast, or nil. -- --- Validate on READ and never write: the id is still correct for the spec that --- set it, so clearing it here would destroy that spec's setting the first time --- the player logged in on another one. +-- Validate on READ and never write: focusKickInterruptSpellID is profile-level +-- while the spellbook behind it is per-spec, so the id stays correct for the +-- spec that set it. Clearing it here would destroy that spec's setting the +-- first time the player logged in on another one. -- -- Asks the SPELLBOOK, never a class/spec table. Which specs carry an interrupt --- is Blizzard's to change (Preservation has Quell, Mistweaver has Spear Hand --- Strike, most other healers have nothing), and any hardcoded list of that goes --- stale on a patch and starts lying in one direction or the other. --- +-- is Blizzard's to change, and a hardcoded list of that goes stale on a patch. -- Pet-bank interrupts (a Warlock's Axe Toss, a Hunter's pet kick) are --- legitimate picks and IsPlayerSpell does not see them, so check both banks. +-- legitimate picks that IsPlayerSpell cannot see, so check both banks. -- --- Returns the id the player can ACTUALLY cast, or nil. Returning the resolved --- id rather than a boolean matters: the caller feeds it to a cooldown check, --- and a talent swap moves an interrupt between its base and override forms --- while the stored id stays put. Answering "yes, known" but leaving the caller --- holding the un-castable form would put an id that is never on cooldown into --- the readiness gate -- the same always-ready failure this whole fix is about, --- just reached from the other side. +-- Returning the resolved id rather than a boolean is the point: the caller +-- feeds it to a cooldown check, and a talent swap moves an interrupt between +-- its base and override forms while the stored id stays put. Answering "yes, +-- known" but leaving the caller holding the un-castable form would put an id +-- that is never on cooldown into the readiness gate. function ns.ResolveCastableInterrupt(sid) if type(sid) ~= "number" or sid <= 0 then return nil end - local function knownInBook(id) - if IsPlayerSpell and IsPlayerSpell(id) then return true end - if C_SpellBook and C_SpellBook.IsSpellKnownOrInSpellBook - and C_SpellBook.IsSpellKnownOrInSpellBook(id) then - return true - end - return false - end + local knownInBook = ns.IsSpellInPlayerBook if knownInBook(sid) then return sid end -- Talented into a replacement, stored id is the base form. if C_SpellBook and C_SpellBook.FindSpellOverrideByID then