From c0bf6bccf7ae8271fe576696961c89a6e074d07f Mon Sep 17 00:00:00 2001 From: Maelareth Date: Thu, 27 Aug 2026 11:09:36 +0200 Subject: [PATCH 1/4] feat(aura-designer): a candidate-filter gate at the engine chokepoint, both container lanes Adds the machinery that lets a feature darken its own aura displays on a switch - built for the Power Infusion Helper (following PR) but written as engine capability. The chokepoint: every engine write of candidate filters funnels through one of two lanes. Group/overlay/row containers pass recordCandidateFilters, which substitutes a populated match-nothing map when the config carries the ownership mark and the gate is shut (or the unit's role is excluded). Stored config is never touched: a rebuild produces something already gated rather than something corrected afterwards, which removes the re-assert race a config-swapping design would carry against every future rebuild path. The slot lane: placed indicators (AcquireSlot) never passed that funnel - their map went straight from config into the engine at six sites, so a gated placed indicator was gated when it fell back to the per-indicator container (in combat) and ungated when the slot path served it. SlotHandle:_cf() derives the same substitution at read time; routed through it are the create-time declare, the tuning push, the cine and death latch re-parses, the recovery re-push, and the combat-exit replay. The one deliberate exception is the identity-gate vulnerability recompute, which must see the real selection or gated slots would leave the cinematic-latch population and return from a cutscene fail-open. The accessor is nil-faithful: never nil for a real selection (the recovery edge burns once and treats nil as nothing-to-repair), never a value for a nil one. Ownership is config.dfGate, stamped by whoever builds a config - one stampGate helper at every call site so a missed site reads as an absent line in a known list, plus self-stamps in the builders that already hold the effect config. A gateOwns predicate centralises which marks the gate owns. An earlier design carried a synthetic spell id in real filter data; rejected because profiles travel and get decoded, the resolved maps are cached and shared between consumers, and debuff groups carry no config-wide candidate set to mark. The broadcast walks both registries narrowed to owned entries - the native setters have no equality guard, so an unnarrowed walk would re-parse every container in the addon per flip. In lockdown the slot lane defers through the standard regen replay. Role exclusion resolves per unit at the same chokepoint (player's own role from spec, others from the group, failing open on NONE), with an exported predicate so non-container consumers (sound) can give the same answer. --- DandersFrames/AuraDesigner/Factory.lua | 229 +++++++++++++++++++-- DandersFrames/Frames/AuraContainer.lua | 265 ++++++++++++++++++++++++- 2 files changed, 464 insertions(+), 30 deletions(-) diff --git a/DandersFrames/AuraDesigner/Factory.lua b/DandersFrames/AuraDesigner/Factory.lua index 24408dc1..1dddc257 100644 --- a/DandersFrames/AuraDesigner/Factory.lua +++ b/DandersFrames/AuraDesigner/Factory.lua @@ -498,6 +498,23 @@ local function resolveConditions(spec, typeCfg, isSpecPool) if type(g) ~= "table" or type(g.triggers) ~= "table" then return nil end if #g.triggers > 0 then groups[#groups + 1] = g end end + -- ☠ RETURNING nil HERE MEANS "NO CHAIN", WHICH MEANS THE EFFECT RENDERS AS A PLAIN UNION. + -- Correct for a hand-edited effect: one group is just a union, and the user sees what they + -- built. It is a trap for anything BUILDING CHAINS PROGRAMMATICALLY, because the two ways of + -- reaching this line look nothing alike from the caller's side: + -- * two groups, both populated -> a chain, as asked for + -- * two groups, one of them EMPTIED -> skipped above, count drops to one, and the effect + -- silently becomes a DUPLICATE of the simpler signal + -- it was meant to narrow -- same trigger, same + -- behaviour, two effects contending over a surface. + -- The failure reads as a rendering bug (why are these two identical?) rather than as a + -- config one, which is where the time goes. + -- ⚠ The Power Infusion helper met this in slice 3a: its "cooldown AND (potion OR trinket)" + -- signal has an amplifier group that empties when the user unticks both. The guard that + -- works is at the CALLER -- do not create the effect at all while a group would be empty -- + -- because that is the only place that knows an empty group was a choice rather than a + -- half-finished edit. Comment requested by Danders, 2026-08-23, so the next caller meets + -- this as documentation instead of as a symptom. if #groups < 2 then return nil end -- one group is just a plain union if c.mode == "ALL" then @@ -943,6 +960,41 @@ local AD_CHAIN_GATE_OFFSET = -3 local AD_TEXT_CHAIN_GATE_OFFSET = 30 -- ============================================================ +-- === HELPER-GATE OWNERSHIP === +-- Stamps `dfGate` onto a container config so AuraContainer's funnel can recognise it as the +-- Power Infusion Helper's and darken it. `src` is the effect config the recipe wrote, which +-- carries `pihSignal`; anything without that mark is left completely untouched. +-- +-- ONE HELPER, CALLED AT EVERY SITE -- Danders' condition when he took this route over +-- threading a parameter through six shared builders. The point is that a site which forgets to +-- stamp reads as an ABSENT LINE in a known list rather than as an invisible omission. Do not +-- inline the assignment at a call site; add the call. +-- +-- Returns its first argument, so it wraps a builder in place: +-- DF.AuraContainer:Create(frame, stampGate(buildBorderConfig(...), cfg)) +-- Builders that already receive the effect config (placed indicators take `indicator`, filter +-- groups take `group`) stamp INSIDE themselves instead -- there is nothing to forget there. +-- +-- SHOW-WHEN-MISSING IS NOT STAMPED, deliberately. applyGroupTuning early-returns on +-- mode == "missing", so a missing-mode container cannot be live-gated at all; stamping it +-- would advertise a gate that never fires. The helper does not offer SWM on gated effects. +-- ☠ THE INFUSED SIGNAL IS EXEMPT FROM THE GATE, and the exemption is the signal. +-- Casting Power Infusion is what starts its cooldown, so "someone has my Power Infusion" is +-- only ever true while the gate is dark -- a gated infused mark turns on and is hidden in the +-- same instant, every time (field-found: the violet was invisible until the gate was switched +-- off). Ungated, it shows where the buff went for its 15 seconds while everything else stays +-- hidden. The exemption also skips the role exclusions, accepted: "this player genuinely has +-- Power Infusion" is true whatever their role. One predicate so the rule cannot drift apart +-- across the stamp sites. +local function gateOwns(sig) + return (sig and sig ~= "infused") and true or nil +end + +local function stampGate(config, src) + if config and src and gateOwns(src.pihSignal) then config.dfGate = true end + return config +end + -- Build an OVERLAY-TINT container config (health-bar tint, background tint). mode="overlay": -- the slot covers the host region and its tint texture (child of the slot) inherits the -- slot's secret visibility; DF.AuraContainer handles SetEnabled-last + combat deferral. @@ -2102,6 +2154,10 @@ end local function buildPlacedConfig(frame, unit, map, indicator, isSquare, borderSpec, defs, mine) return { + -- Helper ownership: this builder already holds the effect config, so it stamps itself + -- rather than being wrapped by its callers. See stampGate (and gateOwns for why the + -- infused signal is exempt). + dfGate = gateOwns(indicator.pihSignal), unit = unit, mode = "row", max = 1, @@ -2542,6 +2598,7 @@ end -- the icon/square placed indicators — resolveLevel's absolute value, nothing added. local function buildBarConfig(frame, unit, map, indicator, borderSpec, defs, mine) return { + dfGate = gateOwns(indicator.pihSignal), -- stamps itself; see stampGate + gateOwns unit = unit, mode = "row", max = 1, @@ -3301,6 +3358,7 @@ local function buildFilterGroupConfig(frame, map, group, mine, defs) local borderSpec = buildGroupBorderSpec(frame, group) local filt = poolFilter(group, mine) return { + dfGate = gateOwns(group.pihSignal), -- stamps itself; see stampGate + gateOwns unit = frame.unit, mode = "row", max = math.max(1, tonumber(group.maxIcons) or 8), @@ -4146,6 +4204,128 @@ local function reconcileSoundNow(frame) end end +-- ============================================================ +-- HELPER SOUND -- ARMED / DISARMED (Power Infusion helper) +-- ============================================================ +-- ☠ SOUND IS NOT A CONTAINER, so the candidate-filter gate cannot reach it. Left alone it +-- would keep announcing burst windows while the helper is meant to be silent -- worse than +-- having no sound at all, because a signal that lies costs more than one that is missing. It +-- therefore gets its own edge action: unregister on gate-close, re-register on gate-open. +-- +-- ☠ ITS OWN STORE, NEVER store.sound. reconcileSoundNow tears down every entry in store.sound +-- that is not in the `desired` set it just built -- and helper registrations are never in it, +-- because collectDesiredSounds deliberately refuses filter-owned records. Put them there and +-- the next reconcile silently kills them. +-- +-- ☠ WHY BYPASSING collectDesiredSounds IS LEGITIMATE HERE. That bar exists because the native +-- path is per (unit, spellID), so a 600-spell filter would mean 600 registrations per unit. +-- The helper's list is bounded and class-narrowed below, which is the case the rule was not +-- written for. The rule itself stays, and SyncSound's combat deferral is untouched. +-- +-- Registration legality in combat is WATCHED, not assumed: AddAuraSound / RemoveAuraSound are +-- legal in lockdown, a registration made mid-combat audibly fires, and registering for an +-- ALREADY-ACTIVE aura fires nothing -- so a gate re-open mid-fight replays no backlog. + +-- Class narrowing. The native path costs one registration per (unit, spellID): the helper's +-- ~60 cooldowns across a 5-man party is ~300, and a raid ~1200, toggled twice per gate cycle. +-- A unit's CLASS is readable (only spec is secret), so each unit only needs its own class's +-- cooldowns -- roughly a sixfold cut that scales with group size. +-- +-- ⚠ NARROWING IS BY THE TARGET UNIT'S CLASS, and a record's `class` is the class that OWNS the +-- spell -- the CASTER's. For burst cooldowns those coincide (they are self-buffs), which is +-- why this is right for the helper's real list. For a buff cast ON someone else it is wrong: +-- Power Word: Shield is class=PRIEST but lands on anyone. The helper's own list is only ever +-- self-buffs, so narrowing is always on: `Factory._helperSoundNarrow` is read but no longer +-- written, the switch having gone with the test commands. Set it false from a debug session +-- if a cast-on-others buff ever needs registering. +-- Every id in the map is now a real spell. It did not used to be: an earlier ownership marker +-- rode here as a synthetic id and had to be filtered back out, because registering a sound on it +-- armed a trigger that could never fire AND made the registration count look healthy while +-- nothing was listening. The mark moved onto the container config (`dfGate`), so the whole +-- exclusion went with it. +local function helperSoundMapFor(unit, map) + if not map then return nil end + local R = DF.FilterRegistry + local _, classFile = UnitClass(unit) + local narrow = (Factory._helperSoundNarrow ~= false) + local out, n = {}, 0 + for spellID in pairs(map) do + local rec = narrow and R and R.ByID and R.ByID[spellID] or nil + -- Unknown class, or no record to attribute the spell to, means we cannot narrow -- + -- so keep it rather than silently shrinking the helper's own list. + if (not narrow) or (not classFile) or (not rec) + or rec.class == nil or rec.class == classFile then + out[spellID] = true; n = n + 1 + end + end + return n > 0 and out or nil +end + +-- `cfg` is the helper's sound choice: { soundLSMKey = ... } or { soundFile = ... }. +-- ☠ SILENT UNTIL CHOSEN. No sound configured resolves to nothing and registers nothing -- an +-- audio cue nobody asked for is the fastest way to have the feature switched off wholesale. +-- Returns count, reason -- a bare 0 has six different meanings and they point different ways. +function Factory:SetHelperSoundsArmed(frame, armed, map, cfg) + if not frame or not frame.unit then return 0, "no frame/unit" end + if not soundAPIAvailable() then return 0, "sound API unavailable" end + local store = frame.dfADFactory + if not store then return 0, "frame has no AD store" end + + -- Tear down first, always. Disarming and re-arming both start from nothing, so a leaked + -- registration cannot accumulate across edges -- the failure mode this file's own notes + -- warn about. + local live = store.helperSound + if live then + for _, id in ipairs(live.ids or {}) do unregisterAuraSound(id) end + store.helperSound = nil + end + if not armed then return 0, "disarmed" end + + -- ☠ NEVER THE PLAYER'S OWN UNIT. The native sound path has NO CASTER FILTER, so an + -- othersOnly effect's sound still fires for the player's own casts. Not fixable in the + -- registration -- but unitToken is per registration, so we simply never register for + -- ourselves. This is also the Twins of the Sun Priestess case: that talent copies every + -- Power Infusion back onto the priest. + if UnitIsUnit(frame.unit, "player") then return 0, "own unit (never registered, by design)" end + + -- ☠ ROLE EXCLUSION HOLDS HERE TOO. The visual gate skips excluded roles at the + -- container funnel, which sound never passes through -- without this, a tank's cooldown + -- played the cue while nothing marked them: a signal with nobody to act on. Checked at + -- arm time, the same staleness window as everything else on this path. + if DF.AuraContainer and DF.AuraContainer.IsHelperRoleExcluded + and DF.AuraContainer.IsHelperRoleExcluded(frame.unit) then + return 0, "role excluded" + end + + local argKey, argVal = resolveSoundArg(cfg or {}) + if not argKey then return 0, "sound name did not resolve" end + + local narrowed = helperSoundMapFor(frame.unit, map) + if not narrowed then return 0, "no spells after class narrowing" end + + local adDB = DF.ResolveAuraDesigner and DF:ResolveAuraDesigner(frame) + local channel = resolveSoundChannel(adDB) + local ids = {} + for spellID in pairs(narrowed) do + -- "applied" only: the helper announces a window OPENING. Dropped / stackGained are not + -- signals this feature has. + local id = registerAuraSound("applied", frame.unit, spellID, argKey, argVal, channel) + if id ~= nil then ids[#ids + 1] = id end + end + store.helperSound = { ids = ids } + return #ids, (#ids > 0) and "ok" or "AddAuraSound returned nothing" +end + +-- Release helper registrations for one frame. Called from ClearFrame alongside the container +-- stores, because a registration outliving its frame is a leak with no owner. +function Factory:ClearHelperSounds(frame) + local store = frame and frame.dfADFactory + local live = store and store.helperSound + if not live then return end + for _, id in ipairs(live.ids or {}) do unregisterAuraSound(id) end + store.helperSound = nil +end + -- Frames whose sound reconcile is deferred to combat-end (weak-keyed so a dropped frame GCs). Factory._soundPending = Factory._soundPending or setmetatable({}, { __mode = "k" }) local soundRegenFrame @@ -4794,18 +4974,18 @@ local function syncBorderEntry(bd, frame, key, cfg, map, mine) local entry = bd[key] if not entry then - local handle = DF.AuraContainer:Create(frame, buildBorderConfig(frame.unit, map, spec, filt, drawAbove, pdSpec)) + local handle = DF.AuraContainer:Create(frame, stampGate(buildBorderConfig(frame.unit, map, spec, filt, drawAbove, pdSpec), cfg)) if handle then bd[key] = { handle = handle, structSig = structSig, tuningSig = tuningSig, coSig = coSig } end elseif entry.structSig ~= structSig then entry.structSig, entry.tuningSig, entry.coSig = structSig, tuningSig, coSig - entry.handle:Rebuild(buildBorderConfig(frame.unit, map, spec, filt, drawAbove, pdSpec), structSig) + entry.handle:Rebuild(stampGate(buildBorderConfig(frame.unit, map, spec, filt, drawAbove, pdSpec), cfg), structSig) else if entry.tuningSig ~= tuningSig then entry.tuningSig = tuningSig - entry.handle:ApplyTuning(buildBorderConfig(frame.unit, map, spec, filt, drawAbove, pdSpec)) + entry.handle:ApplyTuning(stampGate(buildBorderConfig(frame.unit, map, spec, filt, drawAbove, pdSpec), cfg)) end if entry.coSig ~= coSig then entry.coSig = coSig @@ -5060,7 +5240,7 @@ local function syncHealthbarTint(hb, frame, healthBar, spec, key, cfg, map, mine syncConditionChain(hb, key, healthBar, frame.unit, chainHB, filt, "flat" .. (pdColor and "|pd" or "") .. "|l" .. tostring(lvlOffset), tconcat({ "flat", tostring(r), tostring(g), tostring(b), tostring(blend), tostring(sublevel), pdColor and colSig(pdColor) or "-" }, "|"), - function(m, f) return buildOverlayTintConfig(frame.unit, m, r, g, b, blend, lvlOffset, f, tintOpts) end, + function(m, f) return stampGate(buildOverlayTintConfig(frame.unit, m, r, g, b, blend, lvlOffset, f, tintOpts), cfg) end, function(h) h:ApplyStyle({ overlay = { tintColor = { r, g, b, blend }, tintPandemicColor = tintOpts.pandemicColor, sublevel = tintOpts.sublevel } }) end, AD_CHAIN_GATE_OFFSET) @@ -5072,7 +5252,7 @@ local function syncHealthbarTint(hb, frame, healthBar, spec, key, cfg, map, mine syncConditionChain(hb, key, healthBar, frame.unit, chainHB, filt, "cover" .. (pdColor and "|pd" or "") .. "|l" .. tostring(lvlOffset), tconcat({ "fill", tostring(r), tostring(g), tostring(b), tostring(alpha), tostring(tex), tostring(clampTo), tostring(sublevel), pdColor and colSig(pdColor) or "-" }, "|"), - function(m, f) return buildHealthFillConfig(frame.unit, m, r, g, b, alpha, tex, clampTo, f, tintOpts) end, + function(m, f) return stampGate(buildHealthFillConfig(frame.unit, m, r, g, b, alpha, tex, clampTo, f, tintOpts), cfg) end, function(h) h:ApplyStyle({ overlay = { healthFill = { texture = tex, color = { r, g, b }, alpha = alpha, clampTo = clampTo, pandemicColor = tintOpts.pandemicColor }, sublevel = tintOpts.sublevel } }) end, @@ -5151,7 +5331,7 @@ local function syncHealthbarTint(hb, frame, healthBar, spec, key, cfg, map, mine local coSig = tconcat({ "flat", tostring(r), tostring(g), tostring(b), tostring(blend), tostring(sublevel), pdColor and colSig(pdColor) or "-" }, "|") if not entry then - local handle = DF.AuraContainer:Create(healthBar, buildOverlayTintConfig(frame.unit, map, r, g, b, blend, lvlOffset, filt, tintOpts)) + local handle = DF.AuraContainer:Create(healthBar, stampGate(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, lvlOffset, filt, tintOpts), cfg)) if handle then hb[key] = { handle = handle, structSig = structSig, tuningSig = tuningSig, coSig = coSig } @@ -5159,12 +5339,12 @@ local function syncHealthbarTint(hb, frame, healthBar, spec, key, cfg, map, mine end elseif entry.structSig ~= structSig then entry.structSig, entry.tuningSig, entry.coSig = structSig, tuningSig, coSig - entry.handle:Rebuild(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, lvlOffset, filt, tintOpts), structSig) + entry.handle:Rebuild(stampGate(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, lvlOffset, filt, tintOpts), cfg), structSig) created = true else if entry.tuningSig ~= tuningSig then entry.tuningSig = tuningSig - entry.handle:ApplyTuning(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, lvlOffset, filt, tintOpts)) + entry.handle:ApplyTuning(stampGate(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, lvlOffset, filt, tintOpts), cfg)) end if entry.coSig ~= coSig then entry.coSig = coSig @@ -5186,7 +5366,7 @@ local function syncHealthbarTint(hb, frame, healthBar, spec, key, cfg, map, mine local coSig = tconcat({ "fill", tostring(r), tostring(g), tostring(b), tostring(alpha), tostring(tex), tostring(clampTo), tostring(sublevel), pdColor and colSig(pdColor) or "-" }, "|") if not entry then - local handle = DF.AuraContainer:Create(healthBar, buildHealthFillConfig(frame.unit, map, r, g, b, alpha, tex, clampTo, filt, tintOpts)) + local handle = DF.AuraContainer:Create(healthBar, stampGate(buildHealthFillConfig(frame.unit, map, r, g, b, alpha, tex, clampTo, filt, tintOpts), cfg)) if handle then hb[key] = { handle = handle, structSig = structSig, tuningSig = tuningSig, coSig = coSig } @@ -5194,14 +5374,14 @@ local function syncHealthbarTint(hb, frame, healthBar, spec, key, cfg, map, mine end elseif entry.structSig ~= structSig then entry.structSig, entry.tuningSig, entry.coSig = structSig, tuningSig, coSig - entry.handle:Rebuild(buildHealthFillConfig(frame.unit, map, r, g, b, alpha, tex, clampTo, filt, tintOpts), structSig) + entry.handle:Rebuild(stampGate(buildHealthFillConfig(frame.unit, map, r, g, b, alpha, tex, clampTo, filt, tintOpts), cfg), structSig) created = true else if entry.tuningSig ~= tuningSig then -- A tuning pass keeps the SAME slot and the same cover, so it -- only needs the style re-applied, not a rebuild. entry.tuningSig = tuningSig - entry.handle:ApplyTuning(buildHealthFillConfig(frame.unit, map, r, g, b, alpha, tex, clampTo, filt, tintOpts)) + entry.handle:ApplyTuning(stampGate(buildHealthFillConfig(frame.unit, map, r, g, b, alpha, tex, clampTo, filt, tintOpts), cfg)) end if entry.coSig ~= coSig then entry.coSig = coSig @@ -5252,7 +5432,7 @@ local function syncBackgroundTint(bg, store, frame, spec, key, cfg, map, mine, a syncConditionChain(bg, key, bgHost, frame.unit, chainBG, filt, "bgtint" .. (pdColor and "|pd" or ""), tconcat({ "bg", tostring(r), tostring(g), tostring(b), tostring(blend), tostring(sublevel), pdColor and colSig(pdColor) or "-" }, "|"), - function(m, f) return buildOverlayTintConfig(frame.unit, m, r, g, b, blend, 0, f, tintOpts) end, + function(m, f) return stampGate(buildOverlayTintConfig(frame.unit, m, r, g, b, blend, 0, f, tintOpts), cfg) end, function(h) h:ApplyStyle({ overlay = { tintColor = { r, g, b, blend }, tintPandemicColor = tintOpts.pandemicColor, sublevel = tintOpts.sublevel } }) end, AD_CHAIN_GATE_OFFSET) @@ -5307,7 +5487,7 @@ local function syncBackgroundTint(bg, store, frame, spec, key, cfg, map, mine, a local entry = bg[key] local created = false if not entry then - local handle = DF.AuraContainer:Create(bgAnchor, buildOverlayTintConfig(frame.unit, map, r, g, b, blend, 0, filt, tintOpts)) + local handle = DF.AuraContainer:Create(bgAnchor, stampGate(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, 0, filt, tintOpts), cfg)) if handle then bg[key] = { handle = handle, structSig = structSig, tuningSig = tuningSig, coSig = coSig } @@ -5315,12 +5495,12 @@ local function syncBackgroundTint(bg, store, frame, spec, key, cfg, map, mine, a end elseif entry.structSig ~= structSig then entry.structSig, entry.tuningSig, entry.coSig = structSig, tuningSig, coSig - entry.handle:Rebuild(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, 0, filt, tintOpts), structSig) + entry.handle:Rebuild(stampGate(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, 0, filt, tintOpts), cfg), structSig) created = true else if entry.tuningSig ~= tuningSig then entry.tuningSig = tuningSig - entry.handle:ApplyTuning(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, 0, filt, tintOpts)) + entry.handle:ApplyTuning(stampGate(buildOverlayTintConfig(frame.unit, map, r, g, b, blend, 0, filt, tintOpts), cfg)) end if entry.coSig ~= coSig then entry.coSig = coSig @@ -5604,7 +5784,7 @@ function Factory:SyncFrame(frame) return syncConditionChain(bd, bestName, frame, frame.unit, chainLinks, filt, "da=" .. tostring(drawAboveBD) .. (pdChain and "|pd" or ""), borderSpecSig(bestSpec) .. (pdChain and ("|pd=" .. colSig(bestCfg.pandemicColor)) or ""), - function(map, f) return buildBorderConfig(frame.unit, map, bestSpec, f, drawAboveBD, pdChain) end, + function(map, f) return stampGate(buildBorderConfig(frame.unit, map, bestSpec, f, drawAboveBD, pdChain), bestCfg) end, function(h) h:ApplyStyle({ border = { spec = bestSpec, pandemicSpec = pdChain } }) end, AD_CHAIN_GATE_OFFSET) and true or false end @@ -5667,12 +5847,16 @@ function Factory:SyncFrame(frame) syncConditionChain(st, bestName, frame, frame.unit, chainTX, filt, "mirrorhost", colSig(bestCfg.color), function(map, f) - return buildMirrorHostConfig(frame.unit, map, function(host) + -- stampGate: the chain's final visual is a container like any + -- other. Its four sibling consumers stamp; this one unstamped + -- left a helper signal moved onto a text surface permanently + -- exempt from the gate. + return stampGate(buildMirrorHostConfig(frame.unit, map, function(host) local e = st[bestName] if e then e.host = host end st._lastHost = host TDRender:EnableMirrors(frame, cat, host, color) - end, f) + end, f), bestCfg) end, -- A colour edit re-registers on the stashed host; EnableMirrors is -- idempotent per parent and restamps the colour. @@ -5700,7 +5884,7 @@ function Factory:SyncFrame(frame) local entry = st[bestName] if not entry then local handle = DF.AuraContainer:Create(frame, - buildMirrorHostConfig(frame.unit, bestMap, onHost, filt)) + stampGate(buildMirrorHostConfig(frame.unit, bestMap, onHost, filt), bestCfg)) if handle then st[bestName] = { handle = handle, structSig = structSig, tuningSig = tuningSig, coSig = coSig, @@ -5720,7 +5904,7 @@ function Factory:SyncFrame(frame) -- unless the callback's captured state is folded into the key. -- (This branch is unreachable today: a constant sig never differs.) entry.structSig, entry.tuningSig, entry.coSig = structSig, tuningSig, coSig - entry.handle:Rebuild(buildMirrorHostConfig(frame.unit, bestMap, onHost, filt)) + entry.handle:Rebuild(stampGate(buildMirrorHostConfig(frame.unit, bestMap, onHost, filt), bestCfg)) elseif entry.tuningSig ~= tuningSig then -- Selection edit only: swap the include map on the live slot. Kept as a -- branch of this elseif chain (rather than folded into the else) so the @@ -5730,7 +5914,7 @@ function Factory:SyncFrame(frame) -- entry.host is deliberately untouched: the slot survives a tuning pass, -- so onHost does not re-fire and the stashed host stays valid. entry.tuningSig = tuningSig - entry.handle:ApplyTuning(buildMirrorHostConfig(frame.unit, bestMap, onHost, filt)) + entry.handle:ApplyTuning(stampGate(buildMirrorHostConfig(frame.unit, bestMap, onHost, filt), bestCfg)) elseif entry.coSig ~= coSig then entry.coSig = coSig entry.handle:ApplyStyle({ overlay = { mirrorHost = { onHost = onHost } } }) @@ -6134,6 +6318,9 @@ function Factory:ClearFrame(frame) teardownExcept(store.fgroups or {}, nil) -- filter-group containers (A5) teardownExcept(store.dgroups or {}, nil) -- debuff-group containers (C1) teardownExcept(store.nametext or {}, nil) + -- ☠ HELPER SOUND. Not a container, so no teardownExcept arm covers it -- a registration + -- outliving its frame is a leak with no owner. + Factory:ClearHelperSounds(frame) teardownExcept(store.healthtext or {}, nil) -- Release the Text Designer mirror covers owned by the two text containers above. if DF.TextDesigner and DF.TextDesigner.Render then diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 14e99a8f..e65dc89c 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -671,10 +671,129 @@ local function deriveSort(config) return sortMethod, sortDirection end +-- ============================================================ +-- HELPER GATE (Power Infusion helper) -- THE PUSH CHOKEPOINT +-- ============================================================ +-- ☠ WHY THE GATE LIVES HERE AND NOWHERE ELSE. +-- Every engine write of candidate filters funnels through ONE of TWO lanes: +-- * group/overlay/row containers: recordCandidateFilters below, reached from `_build` +-- (declaring groups) and `applyGroupTuning` (pushing the cfByKey map). +-- * SLOT handles (placed icons/squares/bars): SlotHandle:_cf(), the slot lane's twin, +-- which every slot-path push reads instead of the raw `_lastCandidateFilters` stash. +-- Nothing reaches the engine except through one of the two. (The `cfOf` closure and the +-- diagnostic dump are debug-only readers and never reach the engine.) The slot lane exists +-- because AcquireSlot bypasses this funnel entirely -- without it the same placed indicator +-- was gated when built out of combat (the Create fallback) and ungated when built in it. +-- +-- Gating HERE rather than in stored config is the whole design: +-- * Stored config always carries the LIVE map, so no signature ever lies and the Factory +-- needs no knowledge of gate state. +-- * A rebuild produces a fresh config that is EXACTLY AS GATED as the old one, because +-- gating happens after config, on the way out. +-- * There is therefore nothing to clobber and no race to keep winning. The first design +-- (swap the live map, re-assert after every rebuild) was a race against every rebuild +-- path anyone might add later; this removes the thing being clobbered instead. +-- +-- ☠ AND IT DISSOLVES THE INTENT-VS-REALITY SPLIT. There is no second copy of the truth: the +-- engine value is DERIVED from this switch by every pusher, on every push. Edge detection +-- becomes an optimisation of WHEN to broadcast, never a record of what containers hold, so a +-- spent edge cannot strand reality -- the next flush of any kind re-derives from the switch. +-- +-- The dead map is a populated set matching nothing, never an empty table: an empty include +-- set reads as "no selection", which the engine is free to treat as "everything passes". +local HELPER_GATE_DEAD_CF = { includeSpellIDs = { [1] = true } } +local helperGateDark = false + +-- ☠ OWNERSHIP: `config.dfGate`. The gate must only ever darken our own effects. The first cut +-- asked "does this container watch the spell we care about", which also caught a USER'S +-- effect on the same spell -- darkened by a feature they never enabled, for a reason nothing +-- on screen explains. Harmless while the helper watched one throwaway buff; unacceptable once +-- it watches sixty real cooldowns. +-- +-- The mark is a plain flag on the container config, stamped by whoever built it. It says +-- nothing about content, so it cannot be confused with a spell, and a user's effect cannot +-- acquire it by accident. +-- +-- ⛔ AN EARLIER CUT PUT A SYNTHETIC SPELL ID IN THE HELPER'S OWN FILTER DATA AND READ THAT. +-- It worked, and it was wrong for three reasons that only surfaced once it was written down: +-- * A fake id in real data TRAVELS. Profiles are exported, imported and decoded, and nobody +-- reading one a year from now could explain what 1999000060 was. +-- * It could not mark everything. buildDebuffGroupConfig carries no config-wide candidate +-- filters at all, so a debuff group was unmarkable; and a filter group's resolved map is +-- CACHED AND SHARED between every consumer of the same filter, so stamping an id into one +-- would have leaked the mark into unrelated effects. +-- * It rode into the sound path as if it were a spell, so helperSoundMapFor had to filter it +-- back out -- a fake registration that could never fire, inflating the count while nothing +-- real was listening. +-- Danders ruled the field name and we chose the route (each caller stamps the returned config, +-- rather than six builders taking a new parameter). See Factory.lua's `stampGate`. +-- +-- ⚠ NO "ARMED" FLAG. There was one, and it only ever caused a bug: ownership is a property of +-- the CONFIG, not of whether anything has flipped a switch yet. Gating is decided below by +-- (gate shut OR role excluded), so with neither true nothing darkens regardless -- which is +-- what the flag was for. Its only real effect was that role exclusion silently did nothing +-- until an unrelated gate command happened to arm it first. + +-- ═══ ROLE EXCLUSION ═══ +-- Never mark someone you would not infuse. The cooldown gate is ONE switch for everyone; this +-- is PER UNIT, and it works at the same chokepoint because the container config carries +-- `unit` (buildBorderConfig et al, Factory.lua:1028). +-- +-- ⚠ FAILS OPEN, DELIBERATELY. DF:GetUnitRole answers nil or "NONE" when a group has no +-- assigned roles -- common in hand-made groups, never in queued content. Everyone then reads +-- as "no role" and nothing is excluded. Marking a tank you did not want is a much smaller +-- failure than silently hiding the signal on the damage dealers you did. +-- +-- ⚠ AND IT CAN BE STALE. UnitGroupRolesAssigned is the ASSIGNED role, not the spec's role: +-- switching spec mid-dungeon does not update it until the group re-forms (watched 2026-08-23). +-- Unfixable for other players -- their spec is secret in 12.1. +local helperExcludedRoles = nil -- e.g. { TANK = true, HEALER = true } + +-- ⭐ THE PLAYER'S OWN ROLE COMES FROM THEIR SPEC, EVERYONE ELSE'S FROM THE GROUP. +-- `UnitGroupRolesAssigned` reports the role the group was FORMED with, so it goes stale after a +-- mid-run respec until the group re-forms -- a healer who switched to damage still reads HEALER +-- and the helper would keep skipping them. +-- +-- ☠ FIXED HERE, NOT IN DF:GetUnitRole. Danders' ruling, 2026-08-23: that function serves every +-- consumer in the addon, and reordering its preference would change behaviour everywhere to fix +-- a case that only bites the local player after a respec without a regroup. Wide blast radius, +-- narrow win, and not this feature's change to make. So the helper prefers the better answer for +-- the one unit it can get it for, and leaves the shared function alone. If the flip is ever +-- right addon-wide it should be its own change with its own testing, not a passenger on ours. +local function helperUnitRole(unit) + if UnitIsUnit and UnitIsUnit(unit, "player") and GetSpecialization and GetSpecializationRole then + local spec = GetSpecialization() + local role = spec and GetSpecializationRole(spec) + if role and role ~= "NONE" then return role end + end + return DF.GetUnitRole and DF:GetUnitRole(unit) +end + +local function helperRoleExcluded(unit) + if not (helperExcludedRoles and unit) then return false end + local role = helperUnitRole(unit) + if not role or role == "NONE" then return false end -- fail open + return helperExcludedRoles[role] == true +end + +-- Shared with the SOUND path (Factory): sound registers per unit and never passes the +-- container funnel, so role exclusion must be answerable from outside it -- or a cue plays +-- for a unit nothing marks. +function AuraContainer.IsHelperRoleExcluded(unit) return helperRoleExcluded(unit) end + -- A record's candidateFilters REPLACES the config-wide set for that group/slot -- (the dispel overlay's per-type slots) — see normalizeFilters. local function recordCandidateFilters(rec, config) - return rec.candidateFilters or config.candidateFilters + local cf = rec.candidateFilters or config.candidateFilters + -- Two independent reasons to go dark: the cooldown switch (everyone at once) and this + -- unit's role (this frame only). Both resolve to the same dead map. + -- Ownership is read off the CONFIG, never off the map -- see `config.dfGate` above. That is + -- why a record carrying its own candidateFilters is still gated correctly: the mark and the + -- content are separate things now. + if config.dfGate and (helperGateDark or helperRoleExcluded(config.unit)) then + return HELPER_GATE_DEAD_CF + end + return cf end -- IDENTITY-GATE EXPOSURE (12.1, live-confirmed 2026-07-17, widened 2026-07-18). @@ -6015,6 +6134,81 @@ function Handle:ApplyTuning(tuning) end end +-- ═══ HELPER GATE: BROADCAST AND BACKSTOP ═══ +-- Does this handle carry one of ours? Checked against STORED config, never the gated result, +-- so it answers the same either side of an edge. +-- One field, and no walk over the records: the mark lives on the config itself, so a group +-- whose records carry their own candidate filters is recognised the same as any other. +local function helperGateHandleIsOurs(h) + local cfg = h and h.config + return (cfg and cfg.dfGate) and true or false +end + +-- Flip the switch and broadcast. Returns how many containers were re-pushed. +-- ☠ The broadcast is an ALREADY-CLEARED in-combat operation: applyGroupTuning is what the +-- consumers already call, and the probe cleared it. Only the table it carries changes. Note +-- it pushes filter strings and max/sort alongside candidate filters, so a gate edge +-- early-flushes any _pendingTuning a container queued mid-combat. +-- +-- ☠ ONLY OUR CONTAINERS. applyGroupTuning runs an immediate UpdateAllAuras per group key and +-- has no equality guard of its own, so broadcasting to every handle in the addon would cost a +-- full aura re-parse on each one for a gate flip that concerns a handful. +function AuraContainer.SetHelperGate(dark) + helperGateDark = dark and true or false + local n = 0 + for h in pairs(AuraContainer._handles or {}) do + local b = h and h.backend + if b and b.applyGroupTuning and not h._destroyed and helperGateHandleIsOurs(h) then + -- ⚠ pcall(fn, self) not pcall(function() ... end) -- this file's own rule, recorded + -- at applyGroupTuning's tail: the closure form allocates one per call for no gain, + -- and protection is identical. A gate edge walks every owned handle twice a Power + -- Infusion cycle, in combat, so this is exactly the path that rule was written for. + local ok = pcall(b.applyGroupTuning, b) + if ok then n = n + 1 end + end + end + -- ☠ SLOTS TOO, NARROWED TO OURS. SetAuraSlotCandidateFilters has no engine-side + -- equality guard -- every call clears and re-parses -- so an unnarrowed walk would + -- re-parse every placed indicator in the addon per gate flip. Read off the module table, + -- not a local: the registry is declared thousands of lines below this function, and a + -- later-declared local here would silently read as a nil global (this file has been + -- bitten by exactly that; see the note above GateAppliesTo). + for h in pairs(AuraContainer._slotHandles or {}) do + if h.config and h.config.dfGate and h._applyHelperGate then + local ok, applied = pcall(h._applyHelperGate, h) + if ok and applied then n = n + 1 end + end + end + return n +end + +function AuraContainer.GetHelperGate() return helperGateDark end + +function AuraContainer.SetHelperExcludedRoles(roles) + helperExcludedRoles = roles + return AuraContainer.SetHelperGate(helperGateDark) -- re-push so it takes effect now +end + +function AuraContainer.GetHelperExcludedRoles() return helperExcludedRoles end + +-- Backstop for pushes swallowed during lockdown by the pcall'd native setters. Idempotent, +-- out of combat, and the same shape the identity gate already uses for its combat-exit +-- re-verify. +-- +-- ☠ ROLES ARE READ AT PUSH TIME, so a role that changes after the last push is not seen: set +-- the exclusions, swap spec, and the container keeps the answer from before the swap. Re-push +-- on anything that can move a role. (This makes us notice promptly when the game's answer +-- CHANGES. It cannot make the game's answer FRESH -- see the staleness note above.) +local helperGateRegen = CreateFrame("Frame") +helperGateRegen:RegisterEvent("PLAYER_REGEN_ENABLED") +helperGateRegen:RegisterEvent("PLAYER_ROLES_ASSIGNED") +helperGateRegen:RegisterEvent("GROUP_ROSTER_UPDATE") +helperGateRegen:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED") +helperGateRegen:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED") +helperGateRegen:SetScript("OnEvent", function() + AuraContainer.SetHelperGate(helperGateDark) +end) + -- Force a re-scan of the container. 68569: UpdateAllAuras() is an addon-callable -- dirty-mark (processed on the next OnUpdate while visible) — the real refresh. Use on -- a dynamic-unit consumer (target/focus/mouseover) when the underlying unit changes but @@ -7152,8 +7346,12 @@ function AuraContainer:AcquireSlot(frame, slotKey, spec) owner = owner, key = slotKey, liveFilter = filter, parked = false, config = config, }, SlotHandle) + -- Stashed BEFORE the declare so the declare can read through _cf(): a helper-owned + -- slot born while the gate is dark must be born gated, not corrected one push later. + handle._lastCandidateFilters = spec.candidateFilters + local okS, btn = pcall(owner.container.AddAuraSlot, owner.container, slotKey, filter, { - candidateFilters = spec.candidateFilters, + candidateFilters = handle:_cf(), sortMethod = spec.sortMethod, sortDirection = spec.sortDirection, initializeFrame = function(b) @@ -7237,7 +7435,6 @@ function AuraContainer:AcquireSlot(frame, slotKey, spec) -- reach a first paint before then. handle._idGateVulnerable = filterVulnerableToIdentityGate(filter, spec.candidateFilters) handle._idGateSourceRelative = filterSourceRelative(filter, spec.candidateFilters) - handle._lastCandidateFilters = spec.candidateFilters handle:_applyIdentityGate() -- ☠ SEED THE LATCHES TOO — both are edge-driven, and a slot born AFTER the edge hears -- nothing. SetUnitDeathLatched / CineLatchAll loop the registries at the transition; @@ -7506,7 +7703,7 @@ function SlotHandle:_setCineLatch(on, skipReparse) elseif wantReparse then local c = self.owner and self.owner.container if c then - pcall(c.SetAuraSlotCandidateFilters, c, self.key, self._lastCandidateFilters) + pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) end end end @@ -7528,7 +7725,7 @@ function SlotHandle:_setDeathLatch(on) end if not on and self._lastCandidateFilters ~= nil then local c = self.owner and self.owner.container - if c then pcall(c.SetAuraSlotCandidateFilters, c, self.key, self._lastCandidateFilters) end + if c then pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) end end end @@ -7565,7 +7762,7 @@ function SlotHandle:_noteGateRecovery(can) registerSlotRegen(self) return end - pcall(c.SetAuraSlotCandidateFilters, c, self.key, self._lastCandidateFilters) + pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) -- After the bounce, matching the Handle: the slot un-parks already re-parsed. -- skipReparse: the candidate re-push above IS that bounce. self:_setCineLatch(nil, true) @@ -7700,7 +7897,7 @@ function SlotHandle:ApplyTuning(filter, candidateFilters, sortMethod, sortDirect -- only one of them leaves a slot flagged from its previous configuration. Pure stored -- state, so it is correct to do even in lockdown -- only the secure pushes defer. if filterChanged or candidatesChanged then - local cf = self._lastCandidateFilters + local cf = self._lastCandidateFilters -- RAW on purpose -- see _cf()'s header self._idGateVulnerable = filterVulnerableToIdentityGate(self.liveFilter, cf) self._idGateSourceRelative = filterSourceRelative(self.liveFilter, cf) end @@ -7713,7 +7910,10 @@ function SlotHandle:ApplyTuning(filter, candidateFilters, sortMethod, sortDirect return false end if candidatesChanged then - pcall(c.SetAuraSlotCandidateFilters, c, self.key, candidateFilters) + -- Through _cf(): the argument is the LIVE map by definition, and a tuning pass on a + -- gated-dark slot must not un-gate it -- the same clobber the container lane's + -- chokepoint design exists to prevent. + pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) end -- Re-evaluate before pushing, so a slot that just became vulnerable is dark on the -- very first pass rather than showing one frame of the wrong player's auras. @@ -7747,7 +7947,10 @@ function SlotHandle:_replayTuning() local c = self.owner and self.owner.container if not c then return end if self._lastCandidateFilters ~= nil then - pcall(c.SetAuraSlotCandidateFilters, c, self.key, self._lastCandidateFilters) + -- Through _cf(), not the raw stash: this replay is the combat-exit drain for every + -- deferred push, including the helper gate's own -- raw here meant a gated slot + -- came out of combat un-gated. + pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) end -- Verdict first (population order matches ApplyTuning): a slot that became -- vulnerable during combat must come out of it dark, not flash one frame open. @@ -7759,6 +7962,50 @@ function SlotHandle:_replayTuning() end end +-- ═══ HELPER GATE: THE SLOT LANE ═══ +-- The slot-path twin of recordCandidateFilters: same test, same dead map, derived at READ +-- time. The stash itself is never overwritten -- destroying the live map is the failure the +-- container lane's design already rejected, and here it would also strand the recovery +-- edge, which treats "no filter list" as "nothing to repair" and fires exactly once. +-- +-- ⚠ NIL-FAITHFUL BY CONSTRUCTION: returns nil exactly when the stash is nil, never +-- otherwise. Every nil test at the read sites keeps its meaning, and the recovery edge can +-- never be burned by a gated slot that has a real selection. +-- +-- ⚠ THE ONE READ THAT STAYS RAW is ApplyTuning's vulnerability recompute: +-- _idGateVulnerable is a property of the user's REAL selection, and deriving it from the +-- dead map would drop a gated-dark slot out of the cinematic-latch population -- it would +-- come back from a cutscene fail-open. +-- +-- ☠ CANDIDATE LANE ONLY. The identity gate's verdicts actuate through the shared +-- owner anchor because gate/cine/death are UNIT-level facts (see _pushFilter). Ours is +-- per-EFFECT -- one helper icon beside a user's own indicator on the same unit -- so it +-- must never touch the anchor or the filter string. +function SlotHandle:_cf() + local cf = self._lastCandidateFilters + if cf ~= nil and self.config and self.config.dfGate + and (helperGateDark or helperRoleExcluded(self.owner and self.owner.unit)) then + return HELPER_GATE_DEAD_CF + end + return cf +end + +-- One gate edge, one slot: re-push the (now re-derived) candidates. The lockdown branch is +-- the recovery path's own shape -- no native tuning setter runs in combat, and +-- _replayTuning drains the deferral on the way out, itself reading through _cf(). +function SlotHandle:_applyHelperGate() + if self._lastCandidateFilters == nil then return false end + local c = self.owner and self.owner.container + if not c then return false end + if InCombatLockdown() then + self._pendingTuning = true + registerSlotRegen(self) + return true + end + pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) + return true +end + -- In-place cosmetic restyle, mirroring Handle:ApplyStyle. Re-runs the engine's region -- pipeline against the updated config -- the same call the per-indicator path makes, so a -- migrated consumer keeps every live cosmetic it has today (colours, sizes, fonts, From e72d4cd52cb43bb7fc98ebe8a7dd943105931772 Mon Sep 17 00:00:00 2001 From: Maelareth Date: Thu, 27 Aug 2026 11:10:04 +0200 Subject: [PATCH 2/4] feat(aura-designer): the Power Infusion Helper A priest-only recipe in the Aura Designer, added from the Other Buffs tab. One click marks the group members worth infusing; everything it shows goes dark while the player's own Power Infusion is on cooldown (with a switch to keep it always on), so it only speaks up when they can act on it. Three signals, each an ordinary frame-level effect the user can move between Border, Health Bar, Background, the two text colours, a placed Icon or Square, or None: - Big cooldown: a curated offensive-cooldown list (plus four offensive racials), others-only so a group member's own press lights their frame. - Big cooldown with a trinket or potion: the same list AND an amplifier (combat potions / on-use trinkets, opt-in ticks) via a two-group condition chain - the one signal that judges a combination, so the one that can only be a colour. - Already has active Power Infusion: marks where the buff went. Its caster rule is inverted (PI on a teammate is always the priest's own cast) and it is exempt from the gate - its subject is created by the gate's own trigger, so gated it could never show. Both were field-found: the signal had never rendered anywhere until they were fixed. Icons: each signal can also render as a Filter Group - one icon per matching aura, the aura's own artwork. Cooldowns and amplifiers share a row (others-only); the infused icon is its own one-icon group with the opposite caster rule. State derives from the groups' own selections, so hand-editing them on the Layout Groups tab and the panel ticks can never disagree. The gate: cast-driven shut (the only unambiguous "it just went down"), flag- driven reopen with an unreadable state resolved from charges, a 0.5s poll only while dark (nothing fires when a cooldown quietly expires), and a diagnostic at /df debug pi showing intent, the chokepoint's answer, per-frame sound registrations and live gated containers. Events register only while a helper exists, so non-priests pay nothing. Sound: per-unit, class-narrowed registrations riding the gate's edges plus roster changes (debounced) - never for the player's own unit, and never for a role the exclusions hide, so a cue cannot fire for a unit nothing marks. Role exclusion (tanks and healers by default, untickable) resolves at the container chokepoint per unit. Settings live on the helper, not per effect: one copy of the roles, the amplifiers, the gate switch and the sound, applied by the resident half on login, profile switch, and every panel change - the addon stays correct with the settings panel never opened. Removing the helper removes everything it built; the shared spell lists are only deleted when no helper mark remains in either mode. Field-verified across two group sessions and extensive solo rounds: every signal, every surface, the cold start, the profile lifecycle, mid-combat deferred builds arriving gated, and the leak check. Depends on the candidate-filter gate machinery (previous PR): this is the feature that stamps config.dfGate. --- CHANGELOG.md | 2 + DandersFrames/AuraDesigner/Engine.lua | 572 ++++++ DandersFrames/Core/Profile.lua | 7 + DandersFrames/Locales/enUS.lua | 67 + .../AuraDesigner/UI/Cards.lua | 1707 +++++++++++++++++ .../AuraDesigner/UI/Groups.lua | 6 +- .../AuraDesigner/UI/Indicators.lua | 13 +- 7 files changed, 2372 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a94e9099..23a96c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### New Features +* (Aura Designer) New **Power Infusion Helper** for priests, added from the Other Buffs tab. One click marks the group members worth infusing: tick "Big cooldown" for anyone who has used a major damage cooldown, "Big cooldown with a trinket or potion" for someone going all in, and "Already has active Power Infusion" so you don't double up. Everything it shows goes dark while your own Power Infusion is on cooldown, so it only speaks up when you can act on it — with a switch to keep it always on. Choose where each one appears — border, health bar, background or text colour — pick which classes count, and add a sound for when someone becomes worth infusing. Tanks and healers are skipped unless you say otherwise. (by Maelareth) + ### Bug Fixes ### Changes diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index fb8b67ac..cb84f102 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -11,6 +11,12 @@ local addonName, DF = ... local wipe = table.wipe +-- Hot-path globals, cached once: the cooldown watcher and its ticker read these on every +-- event and every tick, all fight long. +local C_Spell = C_Spell +local C_Timer = C_Timer +local issecretvalue = issecretvalue + DF.AuraDesigner = DF.AuraDesigner or {} @@ -116,3 +122,569 @@ function Engine:ForceRefreshAllFrames() -- would double-fire the one below and still not cover anything new. end end + +-- ============================================================ +-- POWER INFUSION HELPER -- THE GATE +-- ============================================================ +-- Decides WHEN the helper's marks go dark, and broadcasts the edge. The settings panel and the +-- recipe live on the Options side (AuraDesigner/UI/Cards.lua); this is the resident half, and +-- it must work with the settings panel never having been opened. +-- +-- The gate itself lives in AuraContainer (recordCandidateFilters). This file only decides +-- WHEN it is shut and broadcasts the edge. That split is the point: config is never touched, +-- so a rebuild produces something already gated rather than something we correct. +-- +-- Superseded design, for the record: a per-container map swap plus a re-assert after every +-- rebuild. It worked, and it was a race we would have had to keep winning against every +-- rebuild path added later. Watched failing 2026-08-23 -- clobber recorded in combat, +-- rendered at combat end. +-- ============================================================ + +-- The spell whose cooldown drives the gate. Power Infusion, and the panel offers no way to +-- change it: "which spell hides this" is a question about plumbing rather than about the +-- feature, and nobody asked for it. Left as a value rather than a constant because the +-- mechanism is not priest-specific -- any "I have a strong thing ready" cooldown works -- so a +-- picker could return without the engine changing. +local PI_SPELL_ID = 10060 -- Power Infusion + +local pihGateOpen = true -- true = show (gate spell ready), false = dark (on cooldown) + +-- ☠ MANUAL OVERRIDE. The slash driver and the watcher both write this state; without a notion +-- of who is driving, the watcher stamps over a hand-set gate on the very next global cooldown +-- -- which reads exactly like an external overwrite and is not one. Cost us a round. +-- nil = watcher drives; true/false = held by hand until `/df debug pi auto`. +local pihManual = nil + +-- The helper sound choice, written by the settings panel through PIH_SetSound and restored on +-- login by PIH_ApplySaved. ⚠ SILENT UNTIL CHOSEN -- nil registers nothing, because an +-- audio cue nobody asked for is the fastest way to have a feature switched off wholesale. +local pihSoundCfg = nil + +-- ☠ NOT PARTY-ONLY, AND IT WAS. This read hardcoded the party preset while the settings panel +-- writes to whichever mode the Aura Designer is editing -- so a helper configured in RAID mode +-- had its gate, its role exclusions and its sound silently dropped on every load, while its +-- indicators carried on rendering from the raid pool. It would have read as the gate simply +-- not working, with nothing on screen to explain it. Caught in review, before anyone met it. +-- +-- ⚠ FIRST PRESET THAT HAS A HELPER WINS, PARTY FIRST. The gate is ONE switch for the whole +-- addon, so two presets carrying different helper settings is an ambiguity no read can resolve +-- -- taking the first is a choice, not a derivation. Party first because that is where the +-- feature is used. If this ever needs to differ per mode, the gate has to become per-mode +-- first, and that is a bigger change than a better read. +local PIH_MODES = { "party", "raid" } +local function pihSettings() + if not DF.GetModeBaseAuraDesigner then return nil end + for _, mode in ipairs(PIH_MODES) do + local adDB = DF:GetModeBaseAuraDesigner(mode) + local s = adDB and adDB.pihelper + -- ☠ A pihelper TABLE ALONE IS NOT A HELPER. Remove leaves the table behind on + -- purpose (behaviour survives a remove), so a preset that ONCE had a helper would + -- otherwise shadow the preset that has one now -- settings configured in raid mode + -- reverting to party leftovers on every reload. The recorded list id only exists + -- while a helper is actually installed, so it is the installed test. + if s and s.cooldownFilterID then return s end + end + return nil +end + +-- Resolve the helper filter's spell map, for the sound registrations. +-- ☠ THIS RESOLVED THE WRONG FILTER ONCE, AND THE SOUND COULD THEREFORE NEVER PLAY. +-- It looked the list up BY NAME, and the name it used belonged to a throwaway test filter that +-- only existed if a developer had built it by hand. The recipe builds "Power Infusion Helper". +-- On every real install the lookup missed, the map came back nil, `helperSoundMapFor` bailed on +-- its first line, and every registration was skipped: zero sounds, always. ⚠ AND THE TEST +-- FOR IT PASSED -- it asked whether the SETTING survived a reload, which it did perfectly. A +-- test that never asks whether a sound comes out cannot tell a working feature from an inert +-- one. Caught in review, not in the field. +-- +-- ⚠ BY ID, NOT BY NAME, and there is no name fallback any more. A custom filter can be +-- renamed in the Filter Designer, so the recipe records the id it created and this reads that. +local function pihResolvedMap() + local R = DF.FilterRegistry + if not (R and R.ResolveSelection) then return nil end + local s = pihSettings() + local id = s and s.cooldownFilterID + if not (id and R.GetCustomFilter and R:GetCustomFilter(id)) then return nil end + local res = R:ResolveSelection({ customs = { [id] = true } }) + return (res and res.kind == "include") and res.map or nil +end + +-- Arm or disarm helper sound on every AD frame. Mirrors the visual gate: closed = silent. +-- ☠ SKIPS THE RESOLVE WHEN NOTHING COULD PLAY. With no sound chosen -- the shipped +-- default -- arming would resolve the whole spell list and walk every frame just to register +-- nothing. The DISARM pass still walks: teardown is the thing that actually silences. +-- The last arm pass, remembered for the status readout: how many registrations, over how +-- many frames, and when. ☠ A field failure ("no sound in the dungeon after a reload") +-- arrived with a readout that showed every SETTING healthy -- because the readout could not +-- see the per-frame wiring. These three numbers are what would have named it in one look. +local pihLastArmCount, pihLastArmFrames, pihLastArmAt = 0, 0, nil + +local function pihSoundsArmed(armed) + local Factory = DF.AuraDesigner and DF.AuraDesigner.Factory + if not (Factory and Factory.SetHelperSoundsArmed) then return 0 end + if armed and not pihSoundCfg then armed = false end + local map = armed and pihResolvedMap() or nil + local n, frames = 0, 0 + local function visit(frame) + if frame and DF:IsAuraDesignerEnabled(frame) then + frames = frames + 1 + local got = Factory:SetHelperSoundsArmed(frame, armed, map, pihSoundCfg) + n = n + (got or 0) + end + end + if DF.IteratePartyFrames then DF:IteratePartyFrames(visit) end + if DF.IterateRaidFrames then DF:IterateRaidFrames(visit) end + if DF.IteratePinnedFrames then DF.IteratePinnedFrames(visit) end + pihLastArmCount, pihLastArmFrames = n, frames + pihLastArmAt = date and date("%H:%M:%S") or "?" + return n +end + +-- Flip the gate. ☠ No early return on an unchanged state: our variable records INTENT, never +-- what any container is carrying, and the two are allowed to differ -- a rebuild restores the +-- live map in config while this still reads "dark". An early return made "/df debug pi off" decline +-- to act while the border was lit. +-- ☠☠ NOTHING FIRES WHEN A COOLDOWN QUIETLY EXPIRES. `SPELL_UPDATE_COOLDOWN` fires when +-- cooldowns START or change, not when one runs out on its own. Watched 2026-08-23: the gate +-- shut on a Dispersion cast, Dispersion's cooldown ended, and the border stayed dark until the +-- player cast something unrelated -- which fired the event as a side effect of the GCD. +-- +-- Earlier tests hid this because the player was casting throughout, so the reopen always had +-- an event to ride on. It is the exact mirror of the GCD bug above: that was an event firing +-- when it should not matter, this is no event firing when it should. +-- +-- So while the gate is DARK we poll. Only while dark, one boolean read per tick, and it stops +-- itself the moment the spell is ready -- so the cost is a couple of reads per second during a +-- cooldown and nothing at all the rest of the time. +-- Reads FLAGS ONLY. `isActive` is plain in combat and `isOnGCD` is guarded below; startTime / +-- duration / modRate all seal and none of them is touched, so nothing here compares a secret. +-- +-- ☠☠ BUT `isActive` CANNOT TELL A REAL COOLDOWN FROM THE GLOBAL COOLDOWN. Casting ANY spell +-- makes EVERY spell report active for the duration of the GCD. Watched 2026-08-23: with the +-- gate pointed at Dispersion, casting Power Word: Shield made Dispersion read unready and the +-- gate shut. With Power Infusion the flaw is masked -- its cooldown is minutes long, so the +-- GCD flicker hides inside a real cooldown -- but it is still there: every spell the player +-- casts would blink the helper off for a moment. +-- +-- ⇒ SO THIS IS ONLY EVER USED FOR "IS IT READY AGAIN", NEVER FOR "HAS IT JUST GONE DOWN". +-- Opening on `not isActive` is safe: the GCD lapsing and the real cooldown ending both mean +-- genuinely ready. Shutting is driven by the CAST instead -- see the watcher below. +-- ⭐⭐ A REAL COOLDOWN IS `isActive` AND NOT `isOnGCD`. Danders' answer to our GCD finding +-- (2026-08-23), and it replaces the workaround rather than sitting beside it: `isActive` alone +-- reads true for EVERY spell while the global cooldown runs, so the helper blinked off whenever +-- the player cast anything. `isOnGCD` is the sibling flag that says which of the two it is, and +-- both stay readable in combat while startTime / duration / modRate seal. +-- +-- Shape follows DandersCDM's `ClassifyCooldown` (Display/CooldownBar.lua), which credits +-- Ellesmere's hooks for the same discriminator -- "no duration/magnitude math, only the clean +-- bool flags". Danders pasted that function on 2026-08-24, so the branches below are checked +-- against the original rather than against a paraphrase of it. +-- +-- ⚠ WE DELIBERATELY DO NOT COPY ITS DURATION FALLBACK, and the reason is our own rule. CDM +-- compares `duration` against the GCD when `isOnGCD` is missing, because CDM also serves clients +-- whose info table genuinely lacks the field. Ours never will, and Danders checked the history: +-- nobody has ever observed `isOnGCD` sealing. That branch would be one we could never exercise. +-- The `issecretvalue` GUARD stays -- a compare on a sealed value throws, so it prevents a hard +-- error rather than being dead weight -- but when it fires we resolve from charges instead. +-- +-- ⚠ CHARGES, and this is where the old fail-safe hurt. With the flags readable a charge spell +-- needs no special handling: a charge in hand reads not-active (or active + isOnGCD during the +-- global), and zero charges reads active and NOT on GCD, which is exactly "genuinely on +-- cooldown". With the flags UNREADABLE, "assume on cooldown" would darken the helper while the +-- player still held a charge and could infuse right now. `currentCharges` stays non-secret and +-- answers precisely that, so it is what the unknown case resolves from. +-- +-- ⚠ Latent, not live. The shipped panel has no gate-spell picker, so the gate spell is always +-- Power Infusion, which has no charges. This is correctness for a capability that exists +-- underneath, not a fix for anything a user can hit today. +-- +-- ⚠ Charges also fire their own event -- SPELL_UPDATE_COOLDOWN does not cover a charge coming +-- back. SPELL_UPDATE_CHARGES is registered with the watcher below for that reason. +local function pihReadCharges(spellID) + if not (C_Spell and C_Spell.GetSpellCharges) then return nil end + local c = C_Spell.GetSpellCharges(spellID) + if not c then return nil end + local cur = c.currentCharges + -- Secret check MUST precede everything else: even a nil test on a secret throws on 12.1. + if issecretvalue and issecretvalue(cur) then return nil end + if type(cur) ~= "number" then return nil end + return cur +end + +local function pihReadReady() + local info = C_Spell and C_Spell.GetSpellCooldown and C_Spell.GetSpellCooldown(PI_SPELL_ID) + if not info then return true end + if info.isActive ~= true then return true end + + local gcd = info.isOnGCD + local sealed = issecretvalue and issecretvalue(gcd) + if gcd ~= nil and not sealed then + -- Active AND merely the global cooldown = not a real cooldown = still ready. + return gcd == true + end + + -- No usable flag. A charge in hand means usable, whatever the spell cooldown claims. + local charges = pihReadCharges(PI_SPELL_ID) + if charges ~= nil then return charges >= 1 end + + -- Nothing readable either way. Treat as on cooldown: the failure we can afford is a helper + -- that hides when it did not have to, not one that marks people we cannot infuse. + return false +end + +local pihReadyTicker + +local function pihStopTicker() + if pihReadyTicker then pihReadyTicker:Cancel(); pihReadyTicker = nil end +end + +local function pihSet(dark) + pihGateOpen = not dark + local n = 0 + if DF.AuraContainer and DF.AuraContainer.SetHelperGate then + n = DF.AuraContainer.SetHelperGate(dark) + end + -- Sound rides the SAME edge as the visuals. It is not a container, so the gate cannot + -- reach it -- without this it would keep announcing while we are silent. + pihSoundsArmed(not dark) + + if dark then + -- ⚠ Never under a manual hold: the tick body refuses to act while held (below), + -- so a ticker started here would idle at 2 Hz for the rest of the session. Handing + -- control back re-enters through pihSet and starts it then, if still dark. + if not pihReadyTicker and pihManual == nil and C_Timer and C_Timer.NewTicker then + pihReadyTicker = C_Timer.NewTicker(0.5, function() + -- Held by hand: never fight a gate the user is holding themselves. + if pihManual ~= nil then return end + if pihReadReady() then + pihStopTicker() + if not pihGateOpen then pihSet(false) end + end + end) + end + else + pihStopTicker() + end + return n +end + +-- ☠ THE GATE CAN BE SWITCHED OFF ENTIRELY. "Hide while Power Infusion is on cooldown" is the +-- whole point of the helper, so it defaults on -- but someone who just wants to see burst +-- windows can turn it off, and then the helper never hides. +-- +-- Off means FORCE OPEN and stay there: the watcher stops driving, so a cooldown starting or +-- ending changes nothing. Not "ignore the events" -- the gate is genuinely open, which is what +-- the setting says. +local pihGateEnabled = true + +function Engine:PIH_SetGateEnabled(on) + pihGateEnabled = on and true or false + if not pihGateEnabled then + pihManual = nil + pihSet(false) -- open, and nothing will shut it + else + -- ⚠ Re-enabling releases a manual hold too. Without this, "gate enabled" and + -- "held by hand" could both be true at once, with the watcher suspended and nothing + -- on screen to say so. + pihManual = nil + local ready = pihReadReady() + pihSet(not ready) -- resume from the real cooldown state + end + return pihGateEnabled +end + +-- ☠ THE SOUND CHOICE HAS TO BE APPLIED, NOT MERELY STORED. An early version kept it +-- only in the file-local above, which dies on reload, and the login path never armed it -- a +-- player who picked a sound and logged out had picked nothing. +-- The panel saves the key with the helper's other settings; this is the one place that turns a +-- saved key into live registrations, and it is called from both the panel and the login path. +-- An empty or missing key means SILENT: no sound was ever a default, and an audio cue nobody +-- asked for is the fastest way to have a feature switched off wholesale. +function Engine:PIH_SetSound(lsmKey) + pihSoundCfg = (type(lsmKey) == "string" and lsmKey ~= "") and { soundLSMKey = lsmKey } or nil + -- Armed only while the gate is open: sound is not a container, so nothing the gate does to + -- the visuals reaches it -- it needs its own edge action or it announces windows during the + -- exact minutes the helper is meant to be silent. + return pihSoundsArmed(pihGateOpen and pihSoundCfg ~= nil) +end + +-- ☠ THE RESIDENT HALF READS THE SAVED SETTINGS ITSELF. The panel that writes them lives in +-- the load-on-demand options addon, so anything that only applied when the panel was open +-- would silently not apply to a player who never opens their settings -- which is most of +-- them, most of the time. §1b's whole point. +-- +-- Reads whichever preset actually has a helper installed, party first (see pihSettings); a +-- party/raid split sharing one preset shares the helper, which is the addon's model for +-- every other effect. +-- +-- ☠ ALSO THE RESET PATH. Called on login AND after a profile switch, and the new +-- profile may have no helper -- in which case everything the old one pushed must come back +-- out: roles, the gate, and above all the sound registrations, which would otherwise keep +-- playing for a helper that no longer exists anywhere. +local pihSyncWatcher -- defined beside the watcher below; registration follows helper existence +function Engine:PIH_ApplySaved() + local s = pihSettings() + if not s then + if DF.AuraContainer and DF.AuraContainer.SetHelperExcludedRoles then + DF.AuraContainer.SetHelperExcludedRoles(nil) + end + pihManual = nil + pihGateEnabled = true + Engine:PIH_SetSound(nil) -- tears down every live registration + pihSet(false) -- open; nothing is left to hide + if pihSyncWatcher then pihSyncWatcher() end + return false + end + + if DF.AuraContainer and DF.AuraContainer.SetHelperExcludedRoles then + local any = false + for _ in pairs(s.roles or {}) do any = true break end + DF.AuraContainer.SetHelperExcludedRoles(any and s.roles or nil) + end + Engine:PIH_SetGateEnabled(s.gateEnabled ~= false) + -- After the gate, never before: SetSound arms against the gate's current state, so calling + -- it first would arm against the state we are about to leave. + Engine:PIH_SetSound(s.soundOn and s.soundLSMKey or nil) + if pihSyncWatcher then pihSyncWatcher() end + return true +end + +-- Public seam for the panel: create, remove and apply all change whether a helper exists, +-- which is what decides the watcher's registrations. +function Engine:PIH_SyncWatcher() if pihSyncWatcher then pihSyncWatcher() end end + + +-- ☠ SHUT ON THE CAST, OPEN ON THE COOLDOWN CLEARING. +-- §4b originally specified "read isActive, edge-detect, done" and explicitly REJECTED watching +-- the cast, on the grounds that predicting a cooldown's LENGTH would be a second source of +-- truth that could drift. That reasoning still stands and is not what this does: nothing here +-- predicts a duration. The cast is used only as the unambiguous "it has just gone down" +-- signal, and the cooldown itself still decides when it comes back. +-- +-- Rejected alternative: only shut if the spell still reads unready after ~1.6s (longer than +-- any GCD). Simpler, no new events -- and it breaks under sustained casting, where the GCD +-- never lapses and therefore looks exactly like a real cooldown. +local pihWatcher = CreateFrame("Frame") +pihWatcher:RegisterEvent("PLAYER_ENTERING_WORLD") + +-- ☠ THE OTHER EVENTS ONLY EXIST WHILE A HELPER DOES. SPELL_UPDATE_COOLDOWN fires on +-- every global cooldown for every class, and the panel is priest-gated -- a permanent +-- registration would cost most users a cooldown read per GCD in service of a feature they +-- cannot even add. Login stays permanent: it is what discovers whether a helper exists. +-- +-- ⚠ CHARGES FIRE THEIR OWN EVENT. A charge returning is a spell becoming usable again, +-- and SPELL_UPDATE_COOLDOWN does not fire for it -- so a charge-based gate spell would come +-- back ready with nothing to tell us. Power Infusion has no charges today; registered because +-- the capability underneath is not priest-specific and the failure would be silent. Danders' +-- own cooldown addon registers the pair for the same reason. +-- +-- ⚠ UNIT_SPELLCAST_SUCCEEDED is filtered at the C level (RegisterUnitEvent): only the +-- player's own cast can shut the gate, and unfiltered this event is every cast by every +-- tracked unit -- party, raid, pets -- all discarded one line into the handler. +-- +-- GROUP_ROSTER_UPDATE is for SOUND: registrations are per unit and are otherwise only made +-- on gate edges, so anyone who joined after the last edge got no cue -- and the player's own +-- no-register guard went stale when sorting moved them to another token. +local PIH_WATCH_EVENTS = { "SPELL_UPDATE_COOLDOWN", "SPELL_UPDATE_CHARGES", + "UNIT_SPELLCAST_SUCCEEDED", "GROUP_ROSTER_UPDATE" } +local pihWatching = false +pihSyncWatcher = function() + local want = pihSettings() ~= nil + if want == pihWatching then return end + pihWatching = want + for _, ev in ipairs(PIH_WATCH_EVENTS) do + if not want then + pihWatcher:UnregisterEvent(ev) + elseif ev == "UNIT_SPELLCAST_SUCCEEDED" and pihWatcher.RegisterUnitEvent then + pihWatcher:RegisterUnitEvent(ev, "player") + else + pihWatcher:RegisterEvent(ev) + end + end +end + +local pihRosterPending = false +pihWatcher:SetScript("OnEvent", function(_, event, unit, _, spellID) + if event == "GROUP_ROSTER_UPDATE" then + -- Debounced: forming a group fires this in bursts, and one re-arm covers them all. + -- Deliberately OUTSIDE the gate-enabled/manual guards below: gate off means the + -- helper always shows, and its sound still has to reach a late joiner. + if pihSoundCfg and not pihRosterPending and C_Timer and C_Timer.After then + pihRosterPending = true + C_Timer.After(0.5, function() + pihRosterPending = false + pihSoundsArmed(pihGateOpen) + end) + end + return + end + if event ~= "PLAYER_ENTERING_WORLD" then + if not pihGateEnabled then return end -- switched off: nothing shuts or opens it + if pihManual ~= nil then return end + end + + if event == "UNIT_SPELLCAST_SUCCEEDED" then + -- The only thing that shuts the gate. Our own cast of the gate spell, nothing else. + if unit ~= "player" or spellID ~= PI_SPELL_ID then return end + if not pihGateOpen then return end + local n = pihSet(true) + DF:Debug("AURADESIGNER", "PIH gate -> DARK on cast (%d container%s)", n, n == 1 and "" or "s") + return + end + + if event == "PLAYER_ENTERING_WORLD" then + Engine:PIH_ApplySaved() -- saved settings, before any gate decision + -- ☠ RE-CHECK THE SWITCH AFTER APPLYING, because at login the file-locals + -- still hold their initialisers until ApplySaved loads the saved values. Without + -- this, a saved "don't hide" was overridden by the cooldown read below: reload + -- mid-cooldown and the helper hid anyway -- the exact opposite of the setting -- + -- for the rest of that cooldown. + if not pihGateEnabled or pihManual ~= nil then return end + -- ☠ THE ONE PLACE isActive MAY SHUT THE GATE. On load we never saw the cast, so a + -- reload mid-cooldown would otherwise leave the helper showing for the rest of it. + -- Safe here specifically because nothing is being cast at this instant, so a true + -- reading is a real cooldown rather than a GCD. + local ready = pihReadReady() + if ready ~= pihGateOpen then pihSet(not ready) end + return + end + + -- SPELL_UPDATE_COOLDOWN / SPELL_UPDATE_CHARGES: OPENING ONLY, still. + -- ⚠ pihReadReady can now tell a real cooldown from a global one, so this COULD shut the gate + -- as well. It deliberately does not. The cast event shuts on an unambiguous fact -- the + -- player pressed it -- where shutting from here would mean trusting a flag read at whatever + -- instant a chatty event happened to fire. One shut path, one open path, and the read that + -- was wrong before is only used where a wrong answer cannot shut anything. + -- ⚠ Cheapest test first: this branch only ever OPENS the gate, so with the gate + -- already open there is nothing to do and no reason to pay for a cooldown read -- and + -- this event fires on every global cooldown, all fight long. + if pihGateOpen then return end + local ready = pihReadReady() + if not ready then return end + local n = pihSet(false) + DF:Debug("AURADESIGNER", "PIH gate -> OPEN, cooldown cleared (%d container%s)", + n, n == 1 and "" or "s") +end) + +-- === DIAGNOSTIC COMMAND === +-- WHAT SURVIVED, AND WHY. This began as the feature's entire control surface -- twelve +-- subcommands driving a throwaway filter, a settable gate spell, role lists, sound and a +-- rebuild probe. Every one of those is either in the settings panel now or was scaffolding for +-- a feature that did not exist yet, so it went with the rest of the test rig. +-- +-- Three states stayed, and they are not scaffolding: forcing the gate open or dark is the only +-- way to watch the helper's behaviour without sitting out a real Power Infusion cooldown -- and +-- Power Infusion needs a friendly target, so without this EVERY check of the gate would need a +-- second player in the group. +-- +-- Registered through DF:RegisterDebugSlash rather than as a loose SLASH_ global, so it lists +-- itself in the debug registry beside every other diagnostic instead of being reachable only by +-- already knowing it exists. +-- +-- THE COMMAND IS "/df debug pi". "/dfpi" below is the REGISTRY SPELLING, not a working bind: +-- RegisterDebugSlash routes a /df-prefixed alias to DebugSlashBySub and deliberately creates no +-- SLASH_ global, because the addon retired the one-word /dfsomething forms -- they filled the +-- global slash namespace to document a spelling nobody needed twice. Same shape as /dfarena and +-- /dfpinned. During development this WAS a bare /dfpi; anyone whose fingers remember that needs +-- the long form now. +DF:RegisterDebugSlash("DFPI", "Power Infusion Helper: force the gate open or dark, or show its state", false, "/dfpi") +SlashCmdList["DFPI"] = function(msg) + msg = (msg or ""):gsub("^%s+", ""):gsub("%s+$", ""):lower() + + if msg == "off" or msg == "dark" then + pihManual = false + DF:Out("PI Helper", "gate DARK (held by hand)") + :Field("containers re-pushed", pihSet(true)) + :Line("watcher suspended -- \"/df debug pi auto\" hands it back", "neutral") + return + end + + if msg == "on" or msg == "open" then + pihManual = true + DF:Out("PI Helper", "gate OPEN (held by hand)") + :Field("containers re-pushed", pihSet(false)) + :Line("watcher suspended -- \"/df debug pi auto\" hands it back", "neutral") + return + end + + if msg == "auto" then + pihManual = nil + local ready = pihReadReady() + DF:Out("PI Helper", "watcher resumed") + :Field("gate", ready and "OPEN" or "DARK") + :Field("containers re-pushed", pihSet(not ready)) + return + end + + -- INTENT AND REALITY ARE PRINTED SEPARATELY, ON PURPOSE. Our variable records what the gate + -- was last TOLD; the chokepoint records what containers are actually being handed. They are + -- allowed to differ -- a rebuild restores the live map in config while the gate still reads + -- "dark" -- and a readout that collapsed them into one line would hide exactly the + -- disagreement it exists to show. + local dark = false + if DF.AuraContainer and DF.AuraContainer.GetHelperGate then + dark = DF.AuraContainer.GetHelperGate() + end + local out = DF:Out("PI Helper", "status") + out:Field("gate intends", pihGateOpen and "OPEN" or "DARK") + :Field("chokepoint says", dark and "DARK" or "OPEN", + dark == (not pihGateOpen) and "good" or "bad") + :Field("gate enabled", tostring(pihGateEnabled)) + :Field("gate spell", ("%d (%s)"):format(PI_SPELL_ID, + tostring((C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(PI_SPELL_ID)) or "?"))) + :Field("gate spell ready", tostring(pihReadReady())) + :Field("driven by", pihManual ~= nil and "HAND (watcher suspended)" or "watcher") + :Field("sound", pihSoundCfg and (pihSoundCfg.soundLSMKey or "custom") or "silent (none chosen)") + -- RESOLVE IT HERE. A LibSharedMedia pack may register a sound whose NAME contains an + -- inline texture escape -- SharedMedia_Causese ships one carrying the Power Infusion + -- icon. Picked from the dropdown it works perfectly: the key is stored verbatim and + -- resolved to a file path long before anything reaches the sound API, so the escape + -- never travels. Printing the resolved path is how you tell a bad choice from a silent + -- one without playing it. + :Field("sound resolves to", (function() + if not pihSoundCfg then return "n/a" end + local p = DF.GetSoundPath and DF:GetSoundPath(pihSoundCfg.soundLSMKey) + return tostring(p or pihSoundCfg.soundFile or "NOTHING -- will not play") + end)(), (function() + if not pihSoundCfg then return "neutral" end + local p = DF.GetSoundPath and DF:GetSoundPath(pihSoundCfg.soundLSMKey) + return (p or pihSoundCfg.soundFile) and "good" or "bad" + end)()) + :Field("roles excluded", (function() + local r = DF.AuraContainer and DF.AuraContainer.GetHelperExcludedRoles + and DF.AuraContainer.GetHelperExcludedRoles() + if not r then return "nobody" end + local t = {}; for k in pairs(r) do t[#t + 1] = k end; table.sort(t) + return table.concat(t, ", ") + end)()) + :Field("watching events", pihWatching and "yes" or "no (no helper installed)") + -- The per-frame wiring, which no setting above can show. Registrations counted at the + -- LAST arm pass (armed on zero frames = the login-ordering failure); containers + -- counted LIVE off both registries. + :Field("sound registrations", ("%d over %d frame%s%s"):format( + pihLastArmCount, pihLastArmFrames, pihLastArmFrames == 1 and "" or "s", + pihLastArmAt and (" (last armed " .. pihLastArmAt .. ")") or ""), + (pihSoundCfg and pihGateOpen and pihLastArmCount == 0) and "bad" or "neutral") + :Field("gated containers live", (function() + local AC = DF.AuraContainer + local n = 0 + for h in pairs((AC and AC._handles) or {}) do + if h.config and h.config.dfGate then n = n + 1 end + end + for h in pairs((AC and AC._slotHandles) or {}) do + if h.config and h.config.dfGate then n = n + 1 end + end + return n + end)()) + -- ☠ The chain is REASSEMBLED here on purpose: a conditional line built as + -- `cond and text or nil` fed a nil straight into the printer's concatenation and the + -- readout crashed in the field -- precisely when test mode was OFF, which no dev session + -- ever ran it in. A diagnostic must not have a state in which it throws. + local out2 = out + if DF.testMode or DF.raidTestMode then + -- applyGroupTuning refuses in test mode, so a gate edge redraws nothing there -- + -- indistinguishable from a broken gate unless the readout says so. + out2 = out2:Line("test mode is ON: gate changes do not redraw test previews", "neutral") + end + out2:Hints("/df debug pi off", "/df debug pi on", "/df debug pi auto") +end diff --git a/DandersFrames/Core/Profile.lua b/DandersFrames/Core/Profile.lua index 47bce868..e825e474 100644 --- a/DandersFrames/Core/Profile.lua +++ b/DandersFrames/Core/Profile.lua @@ -456,6 +456,13 @@ function DF:SetProfile(name) -- the new profile directly with no stale overlay DF:FullProfileRefresh() + -- The Power Infusion Helper's gate, role exclusions and sound registrations follow the + -- profile's own settings; without this the OLD profile's sound kept playing after a + -- switch, for a helper the new profile may not even have. + if DF.AuraDesigner and DF.AuraDesigner.Engine and DF.AuraDesigner.Engine.PIH_ApplySaved then + DF.AuraDesigner.Engine:PIH_ApplySaved() + end + -- ★ THE COMPLETION MARKER. PROFILE logged the START of a switch and nothing else, with -- eleven one-time migrations and a full refresh running in between -- so a log showing -- "cleared runtime state before switching" and then nothing was indistinguishable from diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index 60b32628..7d7c4d36 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -2823,4 +2823,71 @@ L["Interrupted: %s"] = true -- are unique to this tooltip. L["Left-Click:"] = true L["Right-Click:"] = true + +-- Power Infusion Helper (Aura Designer, priest only). One block, one card, and the card +-- flips between adding and removing -- so the two titles are a pair and must stay one +-- verb apart in every locale. +L["POWER INFUSION HELPER"] = true +L["Add the helper"] = true +L["Remove the helper"] = true +L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."] = true +L["Deletes its indicators and its spell lists. Nothing else is touched."] = true +-- The three signals. Adding the helper turns on the first one only; the other two are ticked +-- on afterwards, so each label has to stand alone with just the line beneath it for context. +L["What to Show"] = true +L["Big cooldown"] = true +-- How the surface pickers behave. What the controls cannot show on their own: which +-- surfaces stack, and which pick one winner. +L["Health Bar and Background can show several indicators at once."] = true +L["Border and Text colours show only one at a time."] = true +-- Surface picker. Every surface is listed; one already held by a signal on the same spell list +-- says what picking it does, because the two trade places rather than one being refused. +L["%s (swap with %s)"] = true +L["Big cooldown with a trinket or potion"] = true +L["Already has active Power Infusion"] = true +-- Clash warnings. Shown only on the three surfaces that take a single winner, and each names +-- the remedy that already exists rather than describing the problem. +-- The offender is NAMED: "something else colours the border" sends someone hunting through +-- their own effects list, where a name turns the warning into an instruction. %s is that name, +-- or the "%s and %d more" form when several contend. +-- The second %s is L["Give this aura its own border"] -- the checkbox's own label key rides +-- as a placeholder so a translator renders it once and the sentence can never drift from the +-- control it points at. +L["%s already colours the border. Only one can show — tick '%s' on one of them, or move this signal somewhere else."] = true +L["%s already colours this text. Only one can show — raise this signal's priority, or move it somewhere else."] = true +L["%s and %d more"] = true +L["Another effect"] = true +-- Shared settings. These live on the helper, not on each effect: they are statements about +-- who you would infuse, and there is only one answer per player. +L["Combat potions"] = true +L["On-use trinkets"] = true +L["Trinkets and Potions"] = true +L["Never Show On"] = true +L["Only applies when the group has roles."] = true +L["Hide the helper while Power Infusion is on cooldown"] = true +-- Only watch. Classes rather than specs because the spell data records a class and nothing +-- finer; the pointer names the editor that does go spell by spell, so the limit is not a +-- dead end. +L["Classes to Watch"] = true +L["Tick what makes someone worth infusing. It shows on your group frames."] = true +L["To add or remove single spells, open the list itself."] = true +L["Untick a class to ignore its cooldowns."] = true +-- Sound. The helper owns this entry outright: the generic effects list refuses to show sound +-- on a filter-owned record, so it offers no row and no delete button for it either. +L["Play a sound when someone becomes worth infusing"] = true +L["Only plays while the helper is showing."] = true +-- Show When Missing's greyed-out reason on a helper effect (Indicators.lua GateSWM): the +-- missing-mode render path is the one place the helper's cooldown gate cannot reach. +L["Not available on a Power Infusion Helper signal."] = true +-- The icons controls: an "As icons" tick beside the colour dropdown on the two signals that +-- can be a list ("No colour" in the menu is what makes icons-only reachable), plus the +-- amplifiers include nested under burst's tick. +L["As icons"] = true +L["Their trinkets and potions as icons"] = true +L["Move and size the icons under Layout Groups."] = true +-- Row labels. The first two share one spell list on purpose, so without these the +-- rows read identically -- distinguishable only by their type badge. +L["PI Helper — Big cooldown"] = true +L["PI Helper — Big cooldown with a trinket or potion"] = true +L["PI Helper — Already has active Power Infusion"] = true --@end-do-not-package@ diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index a06e3a4b..342e03bc 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -22,6 +22,7 @@ local C_NOTICE = GUI.Colors.notice local OPTS = P.OPTS local GetAuraDesignerDB = P.GetAuraDesignerDB local GetThemeColor = P.GetThemeColor + local ApplyBackdrop = P.ApplyBackdrop local CreateCardShell = P.CreateCardShell local ShowBuffCoexistPopup = P.ShowBuffCoexistPopup @@ -72,6 +73,1243 @@ local RefreshPlacedIndicators = P.RefreshPlacedIndicators local RefreshPreviewEffects = P.RefreshPreviewEffects local BuildTypeContent = P.BuildTypeContent +-- ============================================================ +-- POWER INFUSION HELPER -- THE RECIPE (slice 3a) +-- ============================================================ +-- One click adds the helper with ONE signal running -- the burst window. The other two are +-- ticked on afterwards if the user wants them. One click removes the lot. +-- +-- ☠ THE EFFECTS ARE THE RECORD. There is no second copy of "which signals are on" kept in +-- settings and reconciled against what is on screen. Each effect carries a mark saying which +-- signal it is, and every question is answered by looking for the mark: is a helper added, is +-- strong window on, which surface is burst using. One truth, so nothing can drift out of step +-- with it -- and deleting a helper row by hand from Active Indicators simply unticks that +-- signal, because nothing is left holding a contrary opinion. +-- +-- ⚠ WHICH IS WHY REMOVING FORGETS THE COLOURS, and that is the house rule rather than a gap. +-- The Aura Designer keeps a record's settings when its last effect is deleted ONLY for a spell +-- the user picked into the pool themselves; records the addon built for them -- ad-hoc ones, +-- and anything driven by a spell list, which is what the helper uses -- are pruned on the spot. +-- S.CleanupAdHocAura says why: an entry holding nothing is cruft in the profile. Remembering +-- would be an exception carved out of a rule written for exactly this category. +-- Behaviour settings (never-mark, the amplifiers, the gate) are NOT effects, live where every +-- other setting in the addon lives, and persist as they always did. Appearance dies with the +-- effect it belongs to. That line is drawn once and holds in both directions. +-- +-- It writes nothing new in kind: ordinary custom filters, ordinary frame-level effects with +-- ordinary condition groups -- the shapes the From a Filter picker produces by hand. +-- +-- ☠ THE MARK IS NOT OPTIONAL. `cfg.pihSignal` on each effect is what reaches the +-- engine as `config.dfGate` and makes the effect OURS to the gate -- without it, nothing the +-- recipe creates is gated. (This used to be a synthetic spell id seeded into the filter; see +-- pihEnsureFilter for why that was replaced.) +-- ============================================================ + +local PIH_FILTERS = { + cooldowns = "Power Infusion Helper", + amplifiers = "Power Infusion Helper (amplifiers)", + infused = "Power Infusion Helper (infused)", +} + +local PIH_PI_SPELL_ID = 10060 -- Power Infusion, for the "already infused" mark + +-- Seeded from the curated sets, confirmed present in SpellDB: +-- offensiveCooldowns (45) racials (13) consumables (6, the potions) trinketsItems (41) +-- ☠ FOUR RACIALS BY NAME, NOT THE WHOLE CATEGORY. The plan seeded all thirteen with the note +-- "Fireblood et al are ordinary burst". That was wrong: `racials` is not "offensive racials", it +-- is every racial ability, and nine of the thirteen are nothing of the kind -- Shadowmeld, +-- Darkflight, Spatial Rift, Stoneform, Gift of the Naaru, Regeneratin', Bull Rush, Thorn Bloom +-- and Hyper Organic Light Originator. The helper would have lit up when someone stealthed or ran +-- away, which is the opposite of worth infusing. +-- +-- ⚠ AND THE DATA CANNOT TELL THEM APART. Every racial record carries `cats = { racials = true }` +-- and nothing else -- checked, not assumed -- so there is no category to intersect with and an +-- explicit list is the only honest option. The cost is maintenance: a new racial in a future +-- patch will not appear here on its own. Accepted, because the failure mode of the alternative +-- is a helper that fires on Shadowmeld and the failure mode of this one is a helper that misses +-- a racial nobody has had time to notice yet. +-- Not a class token, and it cannot collide with one: class files are uppercase letters only. +local PIH_RACIAL_TOKEN = "@racials" +local PIH_RACIAL_IDS = { + 273104, -- Fireblood (Dark Iron Dwarf) -- primary stat + 274739, -- Ancestral Call (Mag'har Orc) -- secondary stat + 20572, -- Blood Fury (Orc) -- attack / spell power + 26297, -- Berserking (Troll) -- haste +} + +local PIH_SEED = { + cooldowns = { "offensiveCooldowns" }, + amplifiers = { potions = "consumables", trinkets = "trinketsItems" }, +} + +-- ☠ OUR CURATION, NOT THE DATABASE'S. The category is Danders' and serves his buff bar and his +-- defensive icon too, so a spell that is wrong FOR US gets dropped here rather than recategorised +-- there. Anything in this list is a judgement about the Power Infusion helper only. +-- ⚠ Two of these are arguably miscategorised at source as well. That is a separate, low-priority +-- report to him and NOT a reason to edit shared data. +local PIH_EXCLUDE = { + -- Augmentation's raid cooldown. It buffs ALLIES rather than the Evoker, so it is not a + -- "this player is bursting" signal at all -- the Evoker casting it is enabling everyone + -- else. Belongs with the power externals. (User's call, 2026-08-24.) + [442204] = true, -- Breath of Eons + -- Brewmaster only. A reasonable entry in a general offensive list and a poor Power Infusion + -- trigger: a tank pressing it is not who you are looking for. + [325153] = true, -- Exploding Keg + -- Leaves no visible buff on the paladin -- it shows in logs and nowhere the game can match. + -- Replaced below by Avenging Wrath, which does. + [1234189] = true, -- Execution Sentence + + -- ⚠ THE TEST FOR ALL THREE BELOW: does the spell leave a buff ON THE CASTER? The helper + -- matches auras on a unit, so a beam aimed at the ground and an ability that only damages + -- the target have nothing for it to find -- they would sit in the list doing nothing for as + -- long as it exists. Same reason Execution Sentence went. + -- ⚠ Cut deliberately narrowly. Leaving a spell that never fires costs nothing but clutter; + -- cutting one that WOULD have fired costs a real infusion window, silently. So only the + -- clear cases go, and four newer entries nobody could speak to with confidence stayed in. + [202770] = true, -- Fury of Elune (Balance druid, a beam on the target area) + [357210] = true, -- Deep Breath (Evoker movement plus damage, not a burst window) + [204066] = true, -- Lunar Beam (Guardian druid -- the tank spec -- and a ground effect) +} + +-- ⚠ SPELLS THE CATEGORY MISSES. Avenging Wrath is filed under raidDefensives, which is fair for +-- Protection and wrong for Retribution -- it is that spec's burst window and the paladin entry +-- the helper actually wants. Added by id so the shared categorisation stays untouched. +local PIH_EXTRA_IDS = { + 31884, -- Avenging Wrath (alts 454351 ride along with the record) +} + +-- ⭐ ONE DEFINITION OF WHAT THE HELPER WATCHES. The seeder, the class list and the class ticks +-- all read this. Four places used to walk the category independently, which is three chances for +-- a curation change to land in some of them and not the others -- and the class tick reading a +-- different set from the seeder is exactly the kind of drift nobody notices until a tick stops +-- clearing itself. +local function pihSeedRecords() + local R = DF.FilterRegistry + local out, seen = {}, {} + for _, catKey in ipairs(PIH_SEED.cooldowns) do + for _, rec in ipairs((R and R.ByCategory and R.ByCategory[catKey]) or {}) do + if rec.id and not PIH_EXCLUDE[rec.id] and not seen[rec.id] then + seen[rec.id] = true + out[#out + 1] = rec + end + end + end + for _, id in ipairs(PIH_EXTRA_IDS) do + local rec = R and R.ByID and R.ByID[id] + if rec and rec.id and not seen[rec.id] then + seen[rec.id] = true + out[#out + 1] = rec + end + end + return out +end + +-- The same set as flat ids, plus the racials, which is what the seeder wants. +local function pihSeedIDs() + local out = {} + for _, rec in ipairs(pihSeedRecords()) do out[#out + 1] = rec.id end + for _, id in ipairs(PIH_RACIAL_IDS) do out[#out + 1] = id end + return out +end + +-- The three signals, in the order they read on the panel. +-- +-- ⚠ `surface` is the DEFAULT ONLY. The surface a signal actually occupies is wherever its +-- mark is found, so moving one (3b's dropdowns) needs no stored field and no conversion of +-- anyone's saved settings -- the effect moves and the mark moves with it. +-- +-- ☠ BURST AND STRONG SHARE ONE RECORD, because they share one spell list on purpose: trimming +-- a spell should trim it for both. A record holds one effect per surface, so those two cannot +-- merely CLASH on a surface -- the second would overwrite the first and a signal would vanish. +-- pihCreateSignal refuses that rather than letting it happen quietly, and the surface +-- dropdown resolves it by SWAPPING the two signals (see P.PIH_SetSurface). +local PIH_SIGNALS = { + burst = { surface = "border", color = { 1.00, 0.82, 0.25 }, list = "cooldowns" }, + strong = { surface = "healthbar", color = { 1.00, 0.35, 0.20 }, list = "cooldowns" }, + infused = { surface = "background", color = { 0.55, 0.35, 0.95 }, list = "infused" }, +} + +-- ☠ THE BORDER KEEPS ITS COLOUR UNDER A DIFFERENT NAME. DF.Border:BuildSpec reads +-- `BorderColor`; every other frame-level surface reads plain `color`. Writing `color` on a +-- border is neither an error nor a warning -- the field sits there unread while the ring paints +-- the white it was created with. The first pass of this recipe did exactly that and shipped a +-- burst window that was white instead of gold; found by reading BuildSpec, not by looking at +-- it, because a white border still looks like a border that works. +-- ⚠ Derived from the SURFACE rather than stored per signal, so moving a signal to another +-- surface carries its colour across instead of leaving it behind under a name nothing reads. +local function pihColorKey(surface) + return (surface == "border") and "BorderColor" or "color" +end + +-- Localised at call time, not at file scope: the same locale-timing rule the effect-label +-- tables in Groups.lua follow. +local function pihLabel(key) + if key == "burst" then return L["PI Helper — Big cooldown"] end + if key == "strong" then return L["PI Helper — Big cooldown with a trinket or potion"] end + if key == "infused" then return L["PI Helper — Already has active Power Infusion"] end +end + +local function pihFilterIdByName(name) + local R = DF.FilterRegistry + if not (R and R.ReadStore) then return nil end + local store = R:ReadStore() + for id, f in pairs((store and store.customFilters) or {}) do + if f and f.name == name then return id end + end + return nil +end + +-- Create-or-find, then seed. Idempotent: AddSpellToCustom answers "exists" for a duplicate, +-- so re-running the recipe repairs rather than doubles. +-- `wipeFirst` empties the list before re-seeding, which is how the amplifier list is rewritten +-- in place -- see pihSyncAmplifierFilter for why it must keep its id. +-- Ownership is NOT in this list. It used to be -- a synthetic id seeded alongside the real +-- spells, which the gate read back out of the resolved map. That worked and was still wrong: a +-- fake id in real data travels with an exported profile and is unexplainable a year later. The +-- mark now lives on the effect (`cfg.pihSignal`) and reaches the engine as `config.dfGate`. +local function pihEnsureFilter(name, presetKeys, extraIDs, wipeFirst) + local R = DF.FilterRegistry + if not (R and R.CreateCustomFilter) then return nil end + local existing = pihFilterIdByName(name) + local id = existing or R:CreateCustomFilter(name) + if not id then return nil end + if wipeFirst then + local f = R:GetCustomFilter(id) + if f then f.spells, f.rawIDs = {}, {} end + end + -- ☠ SEED ONLY WHAT WE JUST BUILT. Re-seeding an existing list on every create would undo + -- both kinds of trimming the user is entitled to: the class ticks, and any hand edit made + -- on the Filters page. A list that quietly refills itself is not a list anyone can own. + -- (The amplifier list passes wipeFirst and so is always rebuilt -- correctly, because its + -- contents ARE the two amplifier ticks and nothing else.) + if (not existing) or wipeFirst then + for _, catKey in ipairs(presetKeys or {}) do + local recs = R.ByCategory and R.ByCategory[catKey] + for _, rec in ipairs(recs or {}) do R:AddSpellToCustom(id, rec.id) end + end + for _, sid in ipairs(extraIDs or {}) do R:AddSpellToCustom(id, sid) end + end + return id +end + +-- ───────────────────────────────────────────────────────────── +-- WHAT EXISTS -- read off the marks, never off a stored list +-- ───────────────────────────────────────────────────────────── +-- Scans the WHOLE pool rather than the helper's own records. If a spell list is renamed or +-- deleted underneath us, our effects must still be findable -- otherwise they become orphans +-- ☠☠ THE HELPER LIVES IN THE *OTHER BUFFS* POOL, ALWAYS, AND THE POOL IS NOT A PREFERENCE. +-- It decides the caster filter before anything else gets a say -- poolFilter returns +-- "HELPFUL|PLAYER" for a My Buffs record and never reaches the othersOnly branch at all. So a +-- helper built there asks for "cooldowns cast by ME", and a group member's own cooldown is cast +-- by THEM. It can never match. The Others Only flag we set on every signal was being overruled +-- by the pool it happened to be created in. +-- +-- ⚠ FIELD-FOUND 2026-08-24, AND NOTHING SOLO COULD HAVE CAUGHT IT: with only your own frame on +-- screen, your own casts DO satisfy "cast by me", and the editor preview draws from config +-- without applying a pool filter at all -- which is why the border looked right in every solo +-- pass. It took a Demon Hunter pressing Metamorphosis: sound fired (it registers per unit and +-- spell, with no pool and no caster filter) while nothing drew. +-- +-- ⚠ READS take adDB.otherAuras directly and never GetOtherAuras, which CREATES the table -- +-- merely looking at a panel must not write to the profile. WRITES go through the accessor, +-- which is where lazy creation belongs. +local function pihOtherPoolRead() + local adDB = GetAuraDesignerDB() + local pool = adDB and adDB.otherAuras + return (type(pool) == "table") and pool or nil +end + +local function pihOtherPoolWrite() + return P.GetOtherAuras and P.GetOtherAuras() or nil +end + +local function pihFound() + local out = {} + local pool = pihOtherPoolRead() + if type(pool) ~= "table" then return out end + local keys = P.FRAME_LEVEL_TYPE_KEYS or {} + for auraName, auraCfg in pairs(pool) do + if type(auraCfg) == "table" then + for _, typeKey in ipairs(keys) do + local cfg = auraCfg[typeKey] + if type(cfg) == "table" and cfg.pihSignal then + out[cfg.pihSignal] = { auraName = auraName, typeKey = typeKey, cfg = cfg } + end + end + -- Placed instances carry the mark too (Icon / Square surfaces). The hit's + -- typeKey is the instance's type, and indicatorID is what tells every consumer + -- this representation is an instance rather than a frame effect. + for _, inst in ipairs(auraCfg.indicators or {}) do + if type(inst) == "table" and inst.pihSignal then + out[inst.pihSignal] = { auraName = auraName, typeKey = inst.type, + cfg = inst, indicatorID = inst.id } + end + end + end + end + return out +end + +-- ───────────────────────────────────────────────────────────── +-- THE COOLDOWN-ICON GROUP (a Filter Group carrying the burst signal) +-- ───────────────────────────────────────────────────────────── +-- ☠ A FOURTH WAY TO SHOW THE SAME SIGNAL, NOT A FOURTH SIGNAL. A placed icon pins +-- max = 1 and shows ONE arbitrary cooldown; a Filter Group shows every matching cooldown the +-- unit has running, one icon each -- the richer read of the burst window, and Danders' +-- recommendation ("build it on merit, not as a fallback"). It is also the gate's native +-- shape: the group builds through AuraContainer:Create with a config-wide candidate set, and +-- buildFilterGroupConfig stamps dfGate from the group's own pihSignal mark -- no new gate +-- code anywhere. +-- +-- ⚠ BURST ONLY. Conditions are frame-level, so the strong window cannot ride a group, +-- for the same reason it cannot be an Icon or a Square. +-- +-- The group is ordinary Layout Groups data marked with pihSignal -- the same doctrine as the +-- effects: the marks ARE the record. Hand-deleting it from the Layout Groups tab reads as +-- the tick going off, and nothing is left holding a contrary opinion. +-- Two groups, found by their mark: "burst" is the shared cooldowns/amplifiers row +-- (others-only), "infused" is its own one-icon group -- SEPARATE because one container has +-- ONE caster rule, and infused needs the opposite rule from everything else (own casts +-- allowed; it IS an own cast). User's design, second group session. +local function pihIconGroup(sig) + local groups = P.GetOtherLayoutGroups and P.GetOtherLayoutGroups(false) + for _, g in ipairs(groups or {}) do + if type(g) == "table" and g.pihSignal == (sig or "burst") then return g end + end + return nil +end + +local function pihAnyIconGroup() + local groups = P.GetOtherLayoutGroups and P.GetOtherLayoutGroups(false) + for _, g in ipairs(groups or {}) do + if type(g) == "table" and g.pihSignal then return g end + end + return nil +end + +-- The icon group counts as existing: without this, unticking all three signals while the +-- icons stay on would flip the card back to "Add" and hide the panel -- stranding a running +-- group with no control left that can reach it. +function P.PIH_Exists() + return next(pihFound()) ~= nil or pihAnyIconGroup() ~= nil +end + +-- "On" means "shows somewhere": a colour effect, the icon group, or both. This is what lets +-- the master tick survive "None" -- an icons-only signal is still a signal. +-- ⚠ Strong's icon representation is its AMPLIFIER HALF (icons cannot make the +-- cooldown-AND-amplifier judgement), which is why its tick is labelled by what it shows. +local PIH_ICON_OF = { burst = "cooldowns", strong = "amplifiers", infused = "infused" } +function P.PIH_SignalOn(key) + if pihFound()[key] ~= nil then return true end + local which = PIH_ICON_OF[key] + return (which and P.PIH_IconsShow and P.PIH_IconsShow(which)) or false +end + +-- ───────────────────────────────────────────────────────────── +-- SHARED SETTINGS +-- ───────────────────────────────────────────────────────────── +-- ☠ ONE COPY, ON THE HELPER, NOT ON EACH EFFECT. Stored on the Aura Designer config so it +-- follows the preset, like everything else the helper writes. The engine already treats role +-- exclusion and the gate as a single switch for the whole helper, so per-effect storage would +-- have been a second source of truth that could disagree with the thing doing the work. +function P.PIH_Settings() + local adDB = GetAuraDesignerDB() + if not adDB then return {} end + -- ⚠ TANKS AND HEALERS EXCLUDED BY DEFAULT. You infuse damage dealers; marking the healer + -- is noise on every pull. The user can untick either. + -- gateEnabled: the whole point of the helper, so it defaults ON. + adDB.pihelper = adDB.pihelper or + { roles = { TANK = true, HEALER = true }, gateEnabled = true } + adDB.pihelper.roles = adDB.pihelper.roles or {} + return adDB.pihelper +end + +-- Push the shared settings into the running engine. Config alone changes nothing: the gate +-- reads its own state, so a saved setting that was never pushed is a setting that does not +-- apply until something else happens to re-derive it. +function P.PIH_Apply() + local s = P.PIH_Settings() + if DF.AuraContainer and DF.AuraContainer.SetHelperExcludedRoles then + local any = false + for _ in pairs(s.roles or {}) do any = true break end + DF.AuraContainer.SetHelperExcludedRoles(any and s.roles or nil) + end + -- Gate off means "never hide": force the gate open and leave it there. + local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine + if Engine and Engine.PIH_SetGateEnabled then Engine:PIH_SetGateEnabled(s.gateEnabled ~= false) end + -- After the gate, never before: the sound arms against the gate's current state, so doing + -- it first would arm against the state we are about to leave. + if P.PIH_ApplySound then P.PIH_ApplySound() end + -- The watcher's event registrations follow whether a helper exists at all. + if Engine and Engine.PIH_SyncWatcher then Engine:PIH_SyncWatcher() end +end + +-- ───────────────────────────────────────────────────────────── +-- BUILDING AND UNBUILDING ONE SIGNAL +-- ───────────────────────────────────────────────────────────── +local function pihRefresh() + if DF.InvalidateAuraLayout then DF:InvalidateAuraLayout() end + if DF.UpdateAllFrames then DF:UpdateAllFrames() end + local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine + if Engine and Engine.ForceRefreshAllFrames then Engine:ForceRefreshAllFrames() end + -- The editor's own surfaces, same pair the picker's paths always call: without these a + -- deleted square or layout group stays PAINTED on the preview canvas until a reload -- + -- field-found as "changing surface doesn't remove the square", when the data was right + -- and only the picture was stale. + if RefreshPlacedIndicators then RefreshPlacedIndicators() end + if RefreshPreviewEffects then RefreshPreviewEffects() end +end + +-- ☠ THE AMPLIFIER LIST KEEPS ITS ID ACROSS A CHANGE. Strong window's conditions name this +-- list by reference, so deleting and re-creating it would leave those conditions pointing at a +-- list that no longer exists -- a signal that quietly stops firing and reads as a bug in the +-- gate. The contents are rewritten in place instead. +local function pihSyncAmplifierFilter(s) + local presets = {} + if s.potions then presets[#presets + 1] = PIH_SEED.amplifiers.potions end + if s.trinkets then presets[#presets + 1] = PIH_SEED.amplifiers.trinkets end + if #presets == 0 then + -- ⚠ Wipe in place rather than just declining: the "As icons" ticks may still + -- point at this list, and an early return left it holding the previous ticks' spells + -- -- icons for amplifiers the user had switched off. + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.amplifiers) + local f = id and R and R.GetCustomFilter and R:GetCustomFilter(id) + if f then f.spells, f.rawIDs = {}, {} end + return nil + end + return pihEnsureFilter(PIH_FILTERS.amplifiers, presets, nil, true) +end + +local function pihCreateSignal(key, surfaceOverride) + local def = PIH_SIGNALS[key] + if not def then return false, "no such signal" end + if pihFound()[key] then return true, "already on" end + local tgt = surfaceOverride or def.surface + + local s = P.PIH_Settings() + + local cdId = pihEnsureFilter(PIH_FILTERS.cooldowns, nil, pihSeedIDs()) + if not cdId then return false, "could not build the cooldown list" end + -- ☠ RECORDED FOR THE RESIDENT HALF, WHICH CANNOT SEE THIS FILE. The sound registrations run + -- in the always-loaded addon and need this list; they used to find it by NAME and were + -- looking for the scaffolding filter, so they resolved nothing and no sound could ever play. + -- The id travels in the helper's own settings, which the resident half already reads. + -- ⚠ An ID rather than a name: a custom filter can be renamed in the Filter Designer. + s.cooldownFilterID = cdId + local cdRef = DF:MakeADFilterRef("custom", cdId) + if not cdRef then return false, "could not name the cooldown list" end + + local ref = cdRef + if def.list == "infused" then + local infId = pihEnsureFilter(PIH_FILTERS.infused, nil, { PIH_PI_SPELL_ID }) + if not infId then return false, "could not build the infused list" end + ref = DF:MakeADFilterRef("custom", infId) + if not ref then return false, "could not name the infused list" end + end + + local conditions + if key == "strong" then + -- ☠ THE EMPTY-AMPLIFIER TRAP. resolveConditions SKIPS an empty group and then bails on + -- fewer than two groups -- at which point the effect falls back to a PLAIN UNION and + -- strong window silently becomes an exact duplicate of burst window: same trigger, same + -- behaviour, two effects contending for a surface over nothing. + -- So with no amplifier ticked, strong window is not created at all. That is the honest + -- state: the signal has nothing left to distinguish, so it should not exist. + local ampId = pihSyncAmplifierFilter(s) + if not ampId then return false, "this signal needs a potion or a trinket ticked" end + local ampRef = DF:MakeADFilterRef("custom", ampId) + if not ampRef then return false, "could not name the amplifier list" end + -- A cooldown AND (a potion OR a trinket). One group of each, combined ALL -- the union + -- inside a group is free: "one group is just a plain union" (Factory.lua:501). + conditions = { mode = "ALL", groups = { { triggers = { cdRef } }, { triggers = { ampRef } } } } + end + + -- ☠ A PLACED TARGET MINTS AN INSTANCE, not a frame effect -- different store + -- (auraCfg.indicators), different creation call, and no sharing concerns: instances are + -- per-id, so two signals as icons coexist where two frame effects on one key cannot. + -- Strong never reaches here as placed -- its menu does not offer these (a placed + -- indicator cannot make the cooldown-AND-amplifier judgement) -- but refuse anyway: + -- a guard that relies on the menu is a guard that relies on every future menu. + if tgt == "icon" or tgt == "square" then + if key == "strong" then return false, "that signal cannot be an icon" end + local inst = CreateIndicatorInstance and CreateIndicatorInstance(ref, tgt) + if not inst then return false, "could not create the indicator" end + inst.pihSignal = key + -- ⚠ OTHERS ONLY IS PER INSTANCE on the placed path -- poolFilter reads it off + -- the indicator, not the record. Forgetting it is the My-Buffs-pool trap; but see + -- pihCreateSignal's frame branch for why INFUSED must be the exception -- with it, + -- that signal could never fire at all. + inst.othersOnly = (key ~= "infused") or nil + -- A square has a colour; an icon shows the aura's own artwork. + if tgt == "square" then + inst.color = { r = def.color[1], g = def.color[2], b = def.color[3], a = 1 } + end + return true + end + + -- ⚠ REFUSE A SURFACE ANOTHER SIGNAL IS SITTING ON. Two effects cannot share one surface on + -- one record: the second simply replaces the first. Unreachable on the defaults; the guard + -- is here for 3b, where the user can move a signal. + local pool = pihOtherPoolRead() + local occupant = pool and pool[ref] and pool[ref][tgt] + if type(occupant) == "table" and occupant.pihSignal and occupant.pihSignal ~= key then + return false, "that surface is already taken by another signal" + end + + local cfg = EnsureTypeConfig(ref, tgt, pihOtherPoolWrite()) + if not cfg then return false, "could not create the effect" end + -- ☠ THE MARK. This one field is what makes every question above answerable. + cfg.pihSignal = key + -- Its own row label. Burst and strong share one spell list, so without this they read + -- identically in the effects list. + cfg.label = pihLabel(key) + cfg[pihColorKey(tgt)] = { r = def.color[1], g = def.color[2], b = def.color[3], a = 1 } + -- ☠ TINT, NOT REPLACE. A health-bar effect's generic default is Replace, which + -- repaints the whole bar and covers every other tint -- the exact collision the panel's + -- own note says cannot happen. Tint is the mode that stacks. Only healthbar has a mode. + if tgt == "healthbar" then cfg.mode = "Tint" end + -- ⚠ OTHERS ONLY -- EXCEPT FOR INFUSED, AND THE EXCEPTION IS THE SIGNAL. For the + -- cooldown signals, "cast by someone else" is what makes them about OTHER PLAYERS (and + -- keeps Twins of the Sun Priestess from lighting our own frame after every cast). But + -- Power Infusion on a teammate is ALWAYS the priest's own cast -- an others-only infused + -- mark filters out the one thing it exists to show. Field-found in the second group + -- session: the violet had never rendered anywhere, since the day it was built. Twins + -- copying PI onto the priest now lights their own frame violet, which is simply true. + cfg.othersOnly = (key ~= "infused") or nil + cfg.enabled = true + cfg.conditions = conditions -- nil on purpose for the unchained signals: clears a stale chain + return true +end + +local function pihDeleteSignal(key) + local hit = pihFound()[key] + if not hit then return false end + local pool = pihOtherPoolRead() + local auraCfg = pool and pool[hit.auraName] + if hit.indicatorID and auraCfg and type(auraCfg.indicators) == "table" then + -- A placed representation: remove the instance, not a frame key. Direct removal + -- rather than RemoveIndicatorInstance for the same reason the prune below bypasses + -- CleanupAdHocAura -- that helper resolves the pool off the OPEN TAB, and ours is + -- always the Other pool. + for i, inst in ipairs(auraCfg.indicators) do + if inst.id == hit.indicatorID then table.remove(auraCfg.indicators, i) break end + end + elseif auraCfg then + auraCfg[hit.typeKey] = nil + end + -- Drops the record once its last effect is gone -- the same prune the generic delete button + -- runs, so unticking here and deleting the row there leave the profile identical. + -- ⚠ NOT S.CleanupAdHocAura. It prunes an emptied record out of `CurrentAuraPool()` -- the + -- pool of whichever tab is open -- and ours are always in the Other Buffs pool, so it would + -- do nothing whenever the user happened to be on My Buffs. Same rule, same test + -- (AuraHoldsNoEffects, its own predicate), applied to the pool the record is actually in. + if pool and type(auraCfg) == "table" and P.AuraHoldsNoEffects + and P.AuraHoldsNoEffects(auraCfg) then + pool[hit.auraName] = nil + end + return true +end + + +-- ───────────────────────────────────────────────────────────── +-- WHICH SURFACE A SIGNAL DRAWS ON +-- ───────────────────────────────────────────────────────────── +local PIH_SURFACE_ORDER = { "border", "healthbar", "background", "nametext", "healthtext" } + +-- ☠ ONLY THREE OF THE FIVE CONTEND, and the difference is watched in game, not read. +-- Border, name text and health text resolve through `pickWinner`, which takes ONE winner per +-- surface from config alone and tears every other candidate down. Health bar and background +-- tints are MULTI -- `collectFrameTints` renders each on its own presence-gated container, +-- because a static pick cannot ask what is actually on a unit when presence is secret. +-- So a clash warning on those two would be a lie, and a warning that cannot be true is worse +-- than no warning at all. +local PIH_CONTENDED = { border = true, nametext = true, healthtext = true } + +-- The exact candidacy test each contended surface applies, copied from the call sites rather +-- than approximated -- a warning that fires when the user has ALREADY applied the fix is worse +-- than one that never fires. +-- border : ShowBorder ~= false and borderMode ~= "custom" (Factory.lua:5682) +-- ⭐ "Give this aura its own border" opts an effect OUT of the contest entirely +-- (collectStackedBorders), so it must not count as a clash. +-- name/health: c.color and not c.showWhenMissing (the TEXT_MIRROR_TYPES pick) +local function pihContends(surface, cfg) + if type(cfg) ~= "table" or cfg.enabled == false then return false end + if surface == "border" then + return cfg.ShowBorder ~= false and cfg.borderMode ~= "custom" + end + return cfg.color ~= nil and not cfg.showWhenMissing +end + +-- Both aura pools, READ-ONLY. +-- ☠ NEVER THROUGH GetOtherAuras: that accessor CREATES adDB.otherAuras, and merely looking at +-- a settings panel must not write to the profile. The same rule CurrentAuraPool follows. +local function pihPools() + local out = {} + local adDB = GetAuraDesignerDB() + if not adDB then return out end + local spec = ResolveSpec and ResolveSpec() + local mine = spec and adDB.auras and adDB.auras[spec] + if type(mine) == "table" then out[#out + 1] = mine end + if type(adDB.otherAuras) == "table" then out[#out + 1] = adDB.otherAuras end + return out +end + +-- What an effect calls itself, in the same order the effects list resolves it: its own label +-- first (only helper effects carry one today), then the registry's name for a filter-owned +-- record, then the pool key -- which for an ordinary record IS the aura's name. +local function pihEffectName(auraName, cfg) + if type(cfg) == "table" and cfg.label then return cfg.label end + local named = DF.ADFilterRefDisplayName and DF:ADFilterRefDisplayName(auraName) + return named or auraName +end + +-- How many of the USER'S OWN effects would fight this signal for the surface, and what the +-- first one is called. Ours are skipped: two helper signals on one contended surface are +-- prevented outright by the menu, so counting them here would report the same fact twice in +-- two different voices. +-- Scans BOTH pools, because pickWinner does -- a clash living on the other tab is still a clash. +-- ⚠ NAMING THE OFFENDER IS THE POINT. "Something else colours the border" sends someone hunting +-- through their own effects list; naming it turns the warning into an instruction. When several +-- contend, the count says so rather than pretending the named one is the only problem. +function P.PIH_ClashOn(surface) + if not PIH_CONTENDED[surface] then return 0, nil end + local n, name = 0, nil + for _, pool in ipairs(pihPools()) do + for auraName, auraCfg in pairs(pool) do + if type(auraCfg) == "table" then + local cfg = auraCfg[surface] + if type(cfg) == "table" and not cfg.pihSignal and pihContends(surface, cfg) then + n = n + 1 + if not name then name = pihEffectName(auraName, cfg) end + end + end + end + end + return n, name +end + +-- Which OTHER helper signal is sitting on this surface, if any. +-- ⚠ ONLY A SIGNAL ON THE *SAME RECORD* BLOCKS A SURFACE, and the first version of this got +-- that wrong -- it refused ANY signal sharing a surface, which quietly forbade a configuration +-- that works perfectly. +-- +-- Burst and strong window live on ONE record, because they share one spell list on purpose. A +-- record holds one effect per surface, so those two on the same surface is an overwrite: the +-- second replaces the first and a signal disappears. Genuinely impossible. +-- +-- ☠ "Already infused" is a DIFFERENT record, and there the answer flips. Two effects on +-- different records CAN share a health bar or a background -- watched in game 2026-08-23, two +-- tints on one unit rendered both colours mixed, because collectFrameTints is multi. Blocking +-- that was us inventing a limit the engine does not have. On border or either text it is a real +-- contest rather than an impossibility, and a contest is what the clash warning is for. +local function pihSurfaceTakenBy(surface, exceptKey) + local mine = PIH_SIGNALS[exceptKey] + if not mine then return nil end + for key, hit in pairs(pihFound()) do + local other = PIH_SIGNALS[key] + if key ~= exceptKey and hit.typeKey == surface and other and other.list == mine.list then + return key + end + end + return nil +end + +-- The same question for the CLASH WARNING, which cares about contention rather than +-- impossibility: another of our signals, on a different record, on a surface that takes a +-- single winner. PIH_ClashOn deliberately skips our own effects when counting the user's -- +-- this is what puts the ones that genuinely contend back in. +local function pihSiblingContends(surface, exceptKey) + if not PIH_CONTENDED[surface] then return nil end + local mine = PIH_SIGNALS[exceptKey] + if not mine then return nil end + for key, hit in pairs(pihFound()) do + local other = PIH_SIGNALS[key] + -- Through the real candidacy test: a sibling that opted OUT of the contest + -- (custom-mode border, disabled) is not a clash, and warning about it would survive + -- the very fix the warning names. + if key ~= exceptKey and hit.typeKey == surface and other and other.list ~= mine.list + and pihContends(surface, hit.cfg) then + return key + end + end + return nil +end +P.PIH_SiblingContends = pihSiblingContends + +-- Does OUR OWN signal actually enter the contest on this surface? The warning has to vanish +-- when the named fix is applied to our effect itself -- a warning that survives its own +-- remedy teaches people to ignore warnings. +function P.PIH_SelfContends(surface, key) + if not PIH_CONTENDED[surface] then return false end + local hit = pihFound()[key] + return (hit and pihContends(surface, hit.cfg)) and true or false +end + +function P.PIH_SurfaceOf(key) + local hit = pihFound()[key] + if hit then return hit.typeKey end + -- Icons-only: the signal is on with no colour, and the dropdown says so. + local which = PIH_ICON_OF[key] + if which and P.PIH_IconsShow and P.PIH_IconsShow(which) then return "none" end + return nil +end + +-- The dropdown's option set, rebuilt per signal because what is available depends on where the +-- other two are sitting. +-- ⭐ EVERY SURFACE IS LISTED, AND AN OCCUPIED ONE SAYS WHAT PICKING IT DOES. +function P.PIH_SurfaceOptions(key) + local labels = S.FRAME_LEVEL_LABELS or {} + local opts = { _order = {} } + for _, surface in ipairs(PIH_SURFACE_ORDER) do + -- Naming the swap is what makes a taken row honest. Two earlier answers were worse and + -- are worth knowing about before anyone changes this back: + -- + -- ☠ GREYING IT IS NOT AVAILABLE. The dropdown has no disabled-row concept. `header = true` + -- is the only thing that stops a row being clickable, and it is the GROUP LABEL treatment, + -- not a disabled state: it uppercases the text, shrinks it to 0.85, draws a separator, and + -- sets a flag that INDENTS EVERY ROW BELOW IT -- so one unavailable entry turned the rest + -- of the menu into its children. Asked for as a real `disabled` row; until it exists, + -- greying here is a misuse of somebody else's mechanism. + -- + -- ⚠ HIDING IT WAS THE OTHER ANSWER, and the user rejected it for the right reason: a + -- missing row reads as "that was never possible", when it is possible and simply taken. + local label = labels[surface] or surface + local takenBy = pihSurfaceTakenBy(surface, key) + opts[surface] = takenBy and format(L["%s (swap with %s)"], label, pihLabel(takenBy)) or label + opts._order[#opts._order + 1] = surface + end + -- "None" makes colour VISIBLY optional -- it is the entry that lets one row enumerate + -- colour-only / icons-only / both. First in the list (user's call): an opt-out reads as + -- the baseline you depart from, not a footnote you discover. On every signal, strong + -- included -- an icons-and-sound-only setup is first-class, and strong's icon half + -- (the amplifiers) is reachable without forcing a colour. L["None"] is the addon's + -- existing key, reused. + opts.none = L["None"] + table.insert(opts._order, 1, "none") + -- Placed surfaces, after the colours: one Icon at a spot you choose (the aura's own + -- artwork), or a Square (a flat colour block -- the quietest signal there is). Gated + -- and role-excluded like everything else since the slot lane landed. Not on strong: a + -- placed indicator cannot make its cooldown-AND-amplifier judgement. + if key ~= "strong" then + opts.icon = L["Icon"] + opts.square = L["Square"] + opts._order[#opts._order + 1] = "icon" + opts._order[#opts._order + 1] = "square" + end + return opts +end + +-- ☠ THE COLOUR TRAVELS; NOTHING ELSE DOES. Decided 2026-08-23 with the user. The five surfaces +-- do not share a settings vocabulary -- a border has a style, a thickness and an inset, a health +-- bar has Replace-vs-Tint and a blend -- so carrying settings across would mean inventing +-- equivalences that do not exist. The colour is the one thing every surface genuinely has, and +-- it is read from the OLD surface's key and written to the NEW one, because a border keeps its +-- colour under a different name (see pihColorKey). +-- What travels when a signal moves: its colour and its condition chain, nothing else. Captured +-- BEFORE anything is deleted, because a swap deletes both effects before rebuilding either. +local function pihCapture(hit) + return { + colour = hit.cfg[pihColorKey(hit.typeKey)], + conditions = hit.cfg.conditions, + } +end + +local function pihPlace(key, auraName, surface, carried) + if surface == "icon" or surface == "square" then + if key == "strong" then return false end + local inst = CreateIndicatorInstance and CreateIndicatorInstance(auraName, surface) + if not inst then return false end + inst.pihSignal = key + inst.othersOnly = (key ~= "infused") or nil -- infused = own cast; see pihCreateSignal + if surface == "square" then + -- Colourless carry falls back to the signal's default, same as the frame branch + -- below -- the store's default square is white. + local c = carried and carried.colour + if not c then + local d = PIH_SIGNALS[key] and PIH_SIGNALS[key].color + c = d and { r = d[1], g = d[2], b = d[3], a = 1 } or nil + end + if c then inst.color = { r = c.r, g = c.g, b = c.b, a = c.a or 1 } end + end + return true + end + local cfg = EnsureTypeConfig(auraName, surface, pihOtherPoolWrite()) + if not cfg then return false end + cfg.pihSignal = key + cfg.label = pihLabel(key) + cfg.othersOnly = (key ~= "infused") or nil -- infused = own cast; see pihCreateSignal + cfg.enabled = true + cfg.conditions = carried and carried.conditions or nil + -- No colour to carry (an Icon has none) falls back to the signal's OWN default, exactly + -- like fresh creation -- the alternative was the store's default, which is WHITE: + -- field-found as "the border didn't appear", because a thin white ring on a path where + -- every border had been gold is a border nobody can see. + local c = carried and carried.colour + if not c then + local d = PIH_SIGNALS[key] and PIH_SIGNALS[key].color + c = d and { r = d[1], g = d[2], b = d[3], a = 1 } or nil + end + if c then cfg[pihColorKey(surface)] = { r = c.r, g = c.g, b = c.b, a = c.a or 1 } end + if surface == "healthbar" then cfg.mode = "Tint" end -- same reason as pihCreateSignal + return true +end + +-- ☠ PICKING AN OCCUPIED SURFACE SWAPS THE TWO SIGNALS. Decided with the user 2026-08-24, after +-- the alternatives were tried and rejected in turn: greying the row misuses the dropdown's group +-- heading and mangles the menu; hiding it makes a possible thing look impossible and reads as +-- "you could never have had that"; refusing on click is a control that looks like it works. +-- Swapping is the only version where every row in the list is a real option and none of them +-- lies -- and it is almost certainly what someone meant, since they wanted that surface for the +-- other signal in the first place. +-- +-- Only ever fires between signals on the SAME record, which is the only case that cannot simply +-- coexist; see pihSurfaceTakenBy. +function P.PIH_SetSurface(key, surface) + if not PIH_SIGNALS[key] then return false, "no such signal" end + local found = pihFound() + local hit = found[key] + + -- "No colour": drop the effect and nothing else. With icons on, the signal lives on as + -- icons-only; with icons off there is nothing left and the signal honestly reads off. + if surface == "none" then + if hit then pihDeleteSignal(key); pihRefresh() end + return true + end + -- Coming FROM icons-only: no effect exists to move, so create one where asked. Fresh + -- default colour -- there was no colour to carry. + if not hit then + local ok, why = pihCreateSignal(key, surface) + pihRefresh() + return ok, why + end + if hit.typeKey == surface then return true end + + -- ☠ A MOVE TOUCHING A PLACED REPRESENTATION takes the simple route: capture, + -- delete, recreate on the same record. No swap machinery -- instances are per-id and + -- never contend -- and the frame-swap path below would try to nil a frame key the + -- instance does not live under. + if hit.indicatorID or surface == "icon" or surface == "square" then + local carried = pihCapture(hit) + pihDeleteSignal(key) + if not pihPlace(key, hit.auraName, surface, carried) then + return false, "could not create the effect" + end + pihRefresh() + return true + end + + local pool = pihOtherPoolRead() + local auraCfg = pool and pool[hit.auraName] + if not auraCfg then return false, "the record went missing" end + + local swapKey = pihSurfaceTakenBy(surface, key) + local swapHit = swapKey and found[swapKey] or nil + + -- Anything else sitting there is not ours to move. Cannot happen on a record identified by a + -- helper spell list, but refusing beats overwriting something we never read. + if auraCfg[surface] ~= nil and not swapHit then return false, "that surface is occupied" end + + local mine, theirs = pihCapture(hit), swapHit and pihCapture(swapHit) or nil + local vacated = hit.typeKey + + auraCfg[vacated] = nil + if swapHit then auraCfg[swapHit.typeKey] = nil end + + if not pihPlace(key, hit.auraName, surface, mine) then + return false, "could not create the effect" + end + if swapHit then pihPlace(swapKey, swapHit.auraName, vacated, theirs) end + + pihRefresh() + return true +end + +-- ───────────────────────────────────────────────────────────── +-- SOUND +-- ───────────────────────────────────────────────────────────── +-- ☠ THE HELPER OWNS THIS ENTRY END TO END. The generic effects list refuses to show `sound` on +-- a filter-owned record -- the native path registers per spell ID, so one big filter would mean +-- one registration per spell in it -- which means it offers no row and no delete button for it +-- either. So the control lives here, and PIH_Remove clears it, because nothing else can. +-- ⚠ Two settings, not one: the key remembers WHICH sound, the switch remembers WHETHER. Turning +-- it off and on again should not make someone hunt for their sound a second time. +function P.PIH_ApplySound() + local s = P.PIH_Settings() + local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine + if Engine and Engine.PIH_SetSound then + Engine:PIH_SetSound(s.soundOn and s.soundLSMKey or nil) + end +end + +function P.PIH_SetSoundOn(on) + P.PIH_Settings().soundOn = on and true or nil + P.PIH_ApplySound() +end + +-- ───────────────────────────────────────────────────────────── +-- ONLY WATCH -- whose cooldowns count +-- ───────────────────────────────────────────────────────────── +-- ⚠ CLASSES, NOT SPECS, AND THAT IS THE DATA RATHER THAN A CHOICE. Every record in the spell +-- database carries a class and nothing finer -- there is no spec field in it anywhere. Offering +-- "only watch Fire Mages" would mean hand-authoring which spec each of forty-five cooldowns +-- belongs to and re-authoring it every patch: a dataset to maintain, not a control to build. +-- ⚠ RACIALS ARE TAGGED "ALL" and belong to everyone, so no class tick ever removes one. +local function pihClassList() + local R = DF.FilterRegistry + local present = {} + for _, rec in ipairs(pihSeedRecords()) do + if rec.class and rec.class ~= "ALL" then present[rec.class] = true end + end + local out = {} + -- The registry's own canonical order, read at call time because SpellPicker.lua loads AFTER + -- this file. Borrowed rather than restated so the helper's list reads in the same order as + -- the spell picker's instead of in a second order of our own invention. + for _, token in ipairs((R and R.PickerClassOrder) or {}) do + if present[token] then out[#out + 1] = token end + end + -- ⚠ RACIALS RIDE LAST, AS A PSEUDO-CLASS. They belong to no class -- every racial record is + -- tagged "ALL" -- so the loop above can never surface them, and without a row of their own + -- they would be the one part of the list nothing in this panel could switch off. Last + -- because it is not a class, and the registry's own spell lists group "All Classes" last too. + out[#out + 1] = PIH_RACIAL_TOKEN + return out +end +P.PIH_ClassList = pihClassList + +-- ☠ READ OFF THE LIST, NOT OFF A SETTING. A tick is on when the list still holds at least one +-- of that class's cooldowns -- so the box and the Filter Designer are two views of one thing +-- rather than two records that can disagree. Remove Avatar and the rest by hand over there and +-- Warrior unticks itself here; add one back and it re-ticks. The same reason the signals +-- themselves are read off the effects: a second copy of the truth only ever drifts. +function P.PIH_ClassOn(classFile) + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + local f = id and R and R.GetCustomFilter and R:GetCustomFilter(id) + -- No list yet means nothing has been taken away yet. + if not f then return true end + if classFile == PIH_RACIAL_TOKEN then + for _, sid in ipairs(PIH_RACIAL_IDS) do + if f.spells[sid] or f.rawIDs[sid] then return true end + end + return false + end + for _, rec in ipairs(pihSeedRecords()) do + if rec.class == classFile and (f.spells[rec.id] or f.rawIDs[rec.id]) then + return true + end + end + return false +end + +-- Adds or removes exactly one class's cooldowns from the helper's list. Surgical on purpose: +-- a wipe-and-refill would also undo every hand edit made on the Filters page, and hand editing +-- is the finer control this one deliberately does not try to replace. +local function pihApplyClass(classFile, on) + local R = DF.FilterRegistry + local id = pihFilterIdByName(PIH_FILTERS.cooldowns) + if not (id and R) then return end + if classFile == PIH_RACIAL_TOKEN then + -- The same four the list was seeded from, so ticking it back restores exactly what was + -- taken away rather than the whole racial category. + for _, sid in ipairs(PIH_RACIAL_IDS) do + if on then R:AddSpellToCustom(id, sid) else R:RemoveSpellFromCustom(id, sid) end + end + return + end + for _, rec in ipairs(pihSeedRecords()) do + if rec.class == classFile then + if on then R:AddSpellToCustom(id, rec.id) + else R:RemoveSpellFromCustom(id, rec.id) end + end + end +end + +-- ⚠ NOTHING IS STORED. An earlier pass kept an "excluded classes" table beside the list and +-- re-applied it whenever the list was rebuilt. That was a second copy of the truth, and it went +-- out of step the moment anyone edited the list in the Filter Designer: the box would still +-- show Warrior unticked while the spells were back, or the reverse. The tick reads the list, the +-- click edits the list, and there is nothing in between for the two to disagree about. +function P.PIH_SetClassOn(classFile, on) + pihApplyClass(classFile, on) + pihRefresh() +end + +-- ───────────────────────────────────────────────────────────── +-- ADD / REMOVE / TICK +-- ───────────────────────────────────────────────────────────── +-- ⚠ ADDING TURNS ON ONE SIGNAL. Not everything it could build: a click that produces three +-- indicators the user did not choose is a click that has decided for them, and two of the three +-- are situational. Burst window is the one that is always worth having. +-- The Layout Groups names are stored data, like the three filter names -- raw, never L[]. +local PIH_ICON_GROUP_NAME = "PI Helper — Cooldown icons" +local PIH_INFUSED_GROUP_NAME = "PI Helper — Infused icon" + +-- The three lists the icons box can show. State is READ OFF THE GROUP'S OWN SELECTION -- +-- one tick per list, no stored copy -- so editing the group by hand on the Layout Groups tab +-- and using these ticks can never disagree. +local PIH_ICON_LIST_NAMES = { + cooldowns = PIH_FILTERS.cooldowns, + amplifiers = PIH_FILTERS.amplifiers, + infused = PIH_FILTERS.infused, +} + +function P.PIH_IconsShow(which) + if which == "infused" then return pihIconGroup("infused") ~= nil end + local g = pihIconGroup("burst") + if not (g and g.filterSelection and g.filterSelection.customs) then return false end + local id = pihFilterIdByName(PIH_ICON_LIST_NAMES[which]) + return (id and g.filterSelection.customs[id]) and true or false +end + +function P.PIH_SetIconsShow(which, on) + if not PIH_ICON_LIST_NAMES[which] then return false, "no such list" end + + -- ☠ INFUSED IS ITS OWN GROUP -- one icon, own position, and the OPPOSITE caster + -- rule from the shared row (own casts allowed: it IS an own cast). Existence is the + -- state; the last thing to derive is nothing. + if which == "infused" then + local ig = pihIconGroup("infused") + if not on then + if ig and P.DeleteLayoutGroup then P.DeleteLayoutGroup(ig.id) end + pihRefresh() + return true + end + if ig then return true end + local id = pihEnsureFilter(PIH_FILTERS.infused, nil, { PIH_PI_SPELL_ID }) + if not (id and P.CreateLayoutGroup) then return false, "could not build the list" end + ig = P.CreateLayoutGroup(PIH_INFUSED_GROUP_NAME, "filter") + if not ig then return false, "could not create the group" end + ig.pihSignal = "infused" + -- NO othersOnly here, on purpose -- the whole reason this group exists apart. + ig.maxIcons = 1 + ig.iconsPerRow = 1 + -- The other corner, so the two icon groups never overlap at their defaults. + ig.anchor = "TOPRIGHT" + ig.filterSelection.customs[id] = true + pihRefresh() + return true + end + + local g = pihIconGroup("burst") + + if not on then + if not g then return true end + local id = pihFilterIdByName(PIH_ICON_LIST_NAMES[which]) + if id and g.filterSelection and g.filterSelection.customs then + g.filterSelection.customs[id] = nil + end + -- The last list going deletes the group: the marks are the record, and a group + -- showing nothing is a record of nothing. Through the shared delete, which also + -- sweeps the expanded-card key -- its tab-routed store is safe here because this + -- panel only exists on the Other Buffs tab. + if g.filterSelection and not next(g.filterSelection.customs or {}) then + if P.DeleteLayoutGroup then P.DeleteLayoutGroup(g.id) end + end + pihRefresh() + return true + end + + -- ☠ EACH TICK BUILDS ITS OWN LIST IF IT MUST. The box cannot depend on What to + -- Show -- icons-only is a legitimate setup -- so a list no signal ever created is created + -- here, the same way the signals create theirs. + local id + if which == "cooldowns" then + local st = P.PIH_Settings() + id = st and st.cooldownFilterID + if not id then + id = pihEnsureFilter(PIH_FILTERS.cooldowns, nil, pihSeedIDs()) + -- Recording the id is what makes the helper EXIST to the resident half (the + -- gate's watcher keys on it), so an icons-only setup still gets the gate. + if id and st then st.cooldownFilterID = id end + end + elseif which == "amplifiers" then + local st = P.PIH_Settings() + -- Same first-click rule as the strong signal: with neither amplifier ticked there is + -- nothing to show, so the first tick turns both on rather than appearing inert. + if not (st.potions or st.trinkets) then st.potions, st.trinkets = true, true end + id = pihSyncAmplifierFilter(st) + elseif which == "infused" then + id = pihEnsureFilter(PIH_FILTERS.infused, nil, { PIH_PI_SPELL_ID }) + end + if not id then return false, "could not build the list" end + + if not g then + if not P.CreateLayoutGroup then return false, "layout groups unavailable" end + g = P.CreateLayoutGroup(PIH_ICON_GROUP_NAME, "filter") + if not g then return false, "could not create the group" end + -- ☠ THE MARK is ownership, not content: whichever lists are ticked, this is + -- the one field that puts the icons under "hide while Power Infusion is on cooldown" + -- and the role exclusions (buildFilterGroupConfig reads it and stamps dfGate). + g.pihSignal = "burst" + -- ☠ OTHERS ONLY IS NOT INHERITED FROM ANYTHING. poolFilter reads it off THIS + -- group; without it the filter is plain HELPFUL -- anyone's casts, including the + -- priest's own cooldowns lighting icons on their own frame. The exact trap the first + -- group test found on the effects, closed here at create time. + g.othersOnly = true + end + g.filterSelection.customs[id] = true + pihRefresh() + return true +end + +function P.PIH_Create() + local ok, why = pihCreateSignal("burst") + if ok then + -- ☠ PUSH THE DEFAULTS NOW. Creating writes the settings table (tanks and + -- healers excluded, gate on) but writing is not applying -- without this push the + -- engine ran on its own defaults until a reload or the first tick of any control, + -- so a freshly added helper marked the tank while the panel said it would not. + P.PIH_Apply() + pihRefresh() + end + return ok, why +end + +function P.PIH_Remove() + local pool = pihOtherPoolRead() + local found = pihFound() + local names, n = {}, 0 + for _, hit in pairs(found) do names[hit.auraName] = true; n = n + 1 end + + -- ☠ THE WHOLE RECORD GOES, not only the marked surfaces. A helper record can carry a + -- `sound` entry that the generic effects list refuses to show on a filter-owned record + -- (Groups.lua) -- so it offers no delete button for it, and anything left behind there is + -- unreachable. Safe to take wholesale: a record here is identified BY a helper spell list, + -- so nothing of the user's own can be sitting on it. + if pool then + for name in pairs(names) do pool[name] = nil end + end + + -- The lists go too. They exist only to feed these effects, and three "Power Infusion + -- Helper" entries left in the filter list after the helper is gone are cruft only their + -- author could explain. + -- ☠ BUT THE LISTS ARE ACCOUNT-WIDE AND THE HELPER IS PER-PRESET. Deleting them + -- while another preset still carries helper effects leaves that helper referencing lists + -- that no longer exist -- signals that silently render nothing, with no missing row to + -- explain it. So they only go when no helper mark remains in either mode of this profile. + -- ⚠ Another PROFILE's helper is not scanned: profiles are separate saved-variable + -- branches with their own preset resolution, and walking them all from here is machinery + -- out of proportion to the case. A cross-profile remove leaving orphaned references is + -- accepted and recorded. + local marksElsewhere = false + if DF.GetModeBaseAuraDesigner then + for _, mode in ipairs({ "party", "raid" }) do + local adDB = DF:GetModeBaseAuraDesigner(mode) + for _, auraCfg in pairs((adDB and adDB.otherAuras) or {}) do + if type(auraCfg) == "table" then + for _, tCfg in pairs(auraCfg) do + if type(tCfg) == "table" and tCfg.pihSignal then + marksElsewhere = true + break + end + end + end + if marksElsewhere then break end + end + -- Icon groups reference the cooldown list by id, so they hold it alive too. + for _, g in ipairs((adDB and adDB.otherLayoutGroups) or {}) do + if type(g) == "table" and g.pihSignal then marksElsewhere = true break end + end + if marksElsewhere then break end + end + end + if not marksElsewhere then + local R = DF.FilterRegistry + for _, name in pairs(PIH_FILTERS) do + local id = pihFilterIdByName(name) + if id and R and R.DeleteCustomFilter then R:DeleteCustomFilter(id) end + end + end + + -- The icon groups go with the signals: they are the signals in another shape, and a + -- helper that no longer exists must not leave icons running. + local ig = pihAnyIconGroup() + while ig and P.DeleteLayoutGroup do + P.DeleteLayoutGroup(ig.id) + ig = pihAnyIconGroup() + end + + -- ☠ SOUND IS NOT A CONTAINER, so nothing above reaches it. Removing the helper has to + -- silence it explicitly or the announcements outlive the feature that made them. + -- The SETTING is left alone: it is behaviour, and behaviour survives a remove. + local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine + if Engine and Engine.PIH_SetSound then Engine:PIH_SetSound(nil) end + + -- The recorded list id goes with the list. Leaving it would point the resident half at a + -- filter that no longer exists -- harmless today, and exactly the kind of stale pointer that + -- reads as a bug the next time someone adds a helper and it resolves the wrong thing. + local st = P.PIH_Settings() + if st then st.cooldownFilterID = nil end + + -- Re-derive the engine from whatever helper remains (another preset's, or none). This + -- resets roles and the gate, and releases the watcher's registrations when nothing is + -- left to drive. + if Engine and Engine.PIH_ApplySaved then Engine:PIH_ApplySaved() end + + pihRefresh() + return true, ("removed %d signal(s) and their spell lists"):format(n) +end + +function P.PIH_SetSignal(key, on) + local s = P.PIH_Settings() + if on then + -- ☠ STRONG WINDOW BRINGS ITS AMPLIFIERS WITH IT. It means "a cooldown AND something + -- extra"; with no amplifier ticked there is no extra, and the signal would be created + -- as a duplicate of burst or not at all. Ticking it with neither on turns both on, so + -- the tick does what it says on the first click rather than appearing to do nothing. + if key == "strong" and not (s.potions or s.trinkets) then + s.potions, s.trinkets = true, true + end + pihCreateSignal(key) + P.PIH_Apply() -- see PIH_Create: writing settings is not applying them + else + pihDeleteSignal(key) + -- Off means off everywhere: the signal's own icon list goes with it, or the master + -- tick would re-read as on from the icons it left behind. + if PIH_ICON_OF[key] then P.PIH_SetIconsShow(PIH_ICON_OF[key], false) end + end + pihRefresh() +end + +function P.PIH_SetRole(role, on) + local s = P.PIH_Settings() + -- PIH_Settings hands back a bare table when there is no Aura Designer config to write to, + -- and indexing a field that table does not have is an error rather than a no-op. + s.roles = s.roles or {} + s.roles[role] = on and true or nil + P.PIH_Apply() +end + +-- ☠ UNTICKING THE LAST AMPLIFIER TAKES STRONG WINDOW WITH IT -- see the empty-amplifier trap in +-- pihCreateSignal. This is not a value change; it is the difference between a signal existing +-- and not existing. +function P.PIH_SetAmplifier(which, on) + local s = P.PIH_Settings() + s[which] = on and true or false + if not (s.potions or s.trinkets) then + -- Both off: the judgement loses its second half AND the icon list empties, so both + -- representations go -- or the strong row would read "on" while showing nothing. + pihDeleteSignal("strong") + P.PIH_SetIconsShow("amplifiers", false) + pihRefresh() + elseif P.PIH_SignalOn("strong") then + pihSyncAmplifierFilter(s) -- rewritten in place, so the effect keeps pointing at it + pihRefresh() + end +end + +function P.PIH_SetGateEnabled(on) + P.PIH_Settings().gateEnabled = on and true or false + P.PIH_Apply() +end + +-- The cooldown list's registry id, for deep-linking straight to it in the Filter Designer. +-- nil before the helper exists, which is also when the button that uses it must be dead. +function P.PIH_CooldownFilterID() + return pihFilterIdByName(PIH_FILTERS.cooldowns) +end + -- ============================================================ -- GLOBAL VIEW (used by Global tab) -- ============================================================ @@ -2615,6 +3853,475 @@ S.BuildEffectsTab = function() addBlock:SetPoint("RIGHT", parent, "RIGHT", -8, 0) yPos = yPos - (addBlock.layoutHeight + 10) + -- ── POWER INFUSION HELPER (priest only) ── + -- ⚠ A SEPARATE BLOCK, not a fourth card in the one above. Those three answer "what shape + -- of indicator do you want" and then ask which spell; this one asks nothing and builds a + -- whole configured feature. Putting it beside them would imply it belongs to the same + -- question, and a card that behaves differently from its neighbours is a lying control. + -- + -- ☠ The card becomes REMOVE once a helper exists on this preset, so there is one place to + -- look for both. Create and remove are the same feature seen from either side. + -- + -- ☠ OTHER BUFFS ONLY, AND THAT IS NOT TIDINESS -- IT IS THE ONLY TAB WHERE IT WORKS. + -- The pool a record lives in decides its caster filter before anything else: My Buffs means + -- "auras I cast", and poolFilter returns that before it ever consults othersOnly. The helper + -- watches OTHER people's cooldowns, so My Buffs is the one place it is guaranteed to match + -- nothing. It was addable there and silently did nothing, which is a lying control. + -- + -- ⚠ The recipe already writes into the Other Buffs pool wherever it is invoked from, so this + -- is no longer about correctness -- it is about not offering a button whose result lives + -- somewhere the user was not looking. Its indicators appear in that tab's list; the card + -- should be in the same place as the thing it creates. + -- ⭐ And a side benefit the user named: My Buffs is where most people work, and the helper's + -- rows would be clutter there for everyone who never uses it. + if select(2, UnitClass("player")) == "PRIEST" and S.activeBuffTab == "other" then + local exists = P.PIH_Exists() + local pihBlock = GUI:CreateChoiceCardGroup(parent, { + title = L["POWER INFUSION HELPER"], + accent = tc, + onToggle = function() S.SwitchTab("effects") end, + cards = { + { + title = exists and L["Remove the helper"] or L["Add the helper"], + desc = exists + and L["Deletes its indicators and its spell lists. Nothing else is touched."] + or L["Shows who is worth infusing, and goes dark while your Power Infusion is on cooldown."], + art = { kind = "border", color = { 1.00, 0.82, 0.25 } }, + onClick = function() + if P.PIH_Exists() then P.PIH_Remove() else P.PIH_Create() end + S.SwitchTab("effects") + end, + }, + }, + }) + pihBlock:SetPoint("TOPLEFT", 8, yPos) + pihBlock:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + yPos = yPos - (pihBlock.layoutHeight + GUI.Space.section) + + -- ── THE SETTINGS, FOLDED WITH THE CARD ── + -- ☠ GATED ON pihBlock.expanded, NOT ONLY ON THE HELPER EXISTING. The card group carries + -- its own collapsing header and publishes whether it is open; without asking, folding + -- the header away would hide the card and leave its settings stranded below a closed + -- section, attached to nothing visible. One header, the whole helper. + -- + -- ☠ SHARED BEHAVIOUR LIVES HERE, NOT ON EACH EFFECT ROW. The mockup put "Never mark", + -- "Only watch" and the gating cooldown on every effect. It was drawn before the engine + -- existed, and the engine made them ONE switch for the whole helper. Three copies of + -- "never on tanks" that can disagree is not flexibility -- it is four states where one + -- is meaningful and three are bug reports. + -- Appearance (colour, border style, which surface) stays on the effect rows, because + -- that genuinely differs per signal and is where the AD already puts appearance. + if exists and pihBlock.expanded then + -- ☠ INDENTED, AND THAT IS THE WHOLE POINT OF THE CHANGE. These boxes used to start at + -- the same left edge as "Add an indicator" and "Active indicators", so a column of + -- five same-level boxes read as five sections rather than as one section and the + -- four boxes belonging to it. Nothing said which header owned them. Ten pixels of + -- indent is what says it -- the hierarchy was always there, it just was not drawn. + -- ⚠ 20 IS THE ADDON'S INDENT STEP, not a number picked here: the page layout engine + -- reads `widget.indent` and multiplies by 20 per level. That flag cannot be used + -- directly -- this column lays itself out by hand rather than going through the page + -- engine -- so the step is borrowed instead of the mechanism, which at least keeps + -- one indent width in the addon rather than two. + local PIH_INDENT = 20 + local function pihGroup(header, buildFn, opts) + local group = GUI:CreateSettingsGroup(parent, + (parent:GetWidth() or 320) - (PIH_INDENT + 18), opts) + group.padding = 10 + group:AddWidget(GUI:CreateHeader(parent, header), GUI.RowHeight.sectionHeader) + buildFn(group) + local h = group:LayoutChildren() + group:SetPoint("TOPLEFT", PIH_INDENT, yPos) + group:SetPoint("RIGHT", parent, "RIGHT", -8, 0) + -- The named scale, not a number that looks about right. GUI.Space carries a note + -- about an audit that found 58 spacers using 9 different values for two intents, + -- and three files each inventing their own for the same one. + yPos = yPos - (h + GUI.Space.section) + end + + -- ☠☠ EVERY NOTE IN THIS COLUMN TAKES AN EXPLICIT HEIGHT, which inverts CreateLabel's + -- usual advice on purpose -- and getting that inversion wrong cost three rounds of + -- the user's time, so here is the whole mechanism. + -- + -- A label builds itself 380px wide, measures, and gets a ONE-LINE answer. The group + -- then narrows it to the real width and the text wraps to three or four lines. The + -- label notices and fires a correction -- but only when the call site left the height + -- alone, and that correction re-flows the GROUP, then asks the COLUMN, which stacks + -- its children at fixed offsets and does nothing. So the group keeps the one-line + -- height, the label spills out of its bottom edge, and the next box is drawn on top + -- of it. That is how the note went "missing" and the boxes overlapped in the same + -- breath: the same fault, seen from two ends. + -- + -- ⭐ CreateLabel's own comment names the escape hatch without calling it one: labels + -- "with a call-site height are exactly the ones nothing else ever touches again". A + -- pinned height is the ONLY safe kind of note here, because pinning is what stops the + -- converge that this column cannot absorb. + -- + -- ⚠ AND MEASURING INSTEAD IS NOT AVAILABLE. CreateLabel's own note records the + -- attempt: "Do NOT try to Reflow()+Remeasure() synchronously here to get a correct + -- height at creation. Tried 2026-08-05 and it does not work: nothing has been drawn + -- yet at card build time, so GetStringHeight still returns 0". So the height has to + -- be predicted, and the only question is how well. + -- + -- ⚠ DERIVED FROM THE REAL WIDTH, not a constant. The first version used a flat 38 + -- characters per line "to be safe"; the truth at this panel's width is nearer 68, so + -- every note claimed twice the lines it needed and left visible gaps above the first + -- tick and below the last note -- field-reported with a screenshot 2026-08-24. + -- The per-character width is deliberately a shade wider than the font renders, so the + -- count errs toward MORE lines, which is the safe direction; and because it reads the + -- panel width it stays right when the panel is resized, not only at one size. The + -- measured figure and why it is rounded up are on the constant below. + -- The byte count makes an em dash worth three, which errs the same way. + -- Delete all of this the day the column publishes a `dfAD_ReflowWidgets` seam. + local PIH_NOTE_LINE = 13 + local PIH_NOTE_W = (parent:GetWidth() or 320) - (PIH_INDENT + 18) - 24 + -- ⭐ 6 PIXELS PER CHARACTER, AND THAT NUMBER WAS MEASURED, NOT PICKED. + -- It was 8, which is where seven rounds of gaps came from: at a note width of 400 the + -- panel fits ~74 characters on a line, so 400/74 is about 5.4 -- and reserving for 50 + -- meant every long note claimed half again as many lines as it needed. The big one + -- asked for 8 lines to hold 5. + -- ⚠ 6 rather than 5.4 on purpose: it still errs long, by roughly a line on a + -- paragraph, and that line is the margin a longer translation gets to grow into. + -- ☠ Do NOT try to verify this from the widget. A probe that dumped every note's + -- measured height reported 30 for all of them whatever the text, because a pinned + -- label's frame never grows -- the FontString simply draws past it. The slot IS the + -- layout here; the frame height is not evidence of anything. + local function pihLines(text) + local cpl = math.max(20, math.floor(PIH_NOTE_W / 6)) + -- ⚠ Colour escapes are not characters anyone can see. Counting them would add + -- twelve bytes per highlighted word and inflate the box by a line or two of pure + -- whitespace, which is the fault this estimator exists to avoid. + local plain = tostring(text):gsub("||r", "") + return math.max(1, math.ceil(#plain / cpl)) + end + -- ⭐ GUI:CreateNote, not a hand-coloured CreateLabel. It IS the toned-note widget -- + -- a label with the tone's own accent baked in through ToneHex, so a caution note here + -- is the same yellow as every caution note in the addon rather than three numbers + -- typed at this call site. `tone` names come from INFO_BANNER_TONES: info, caution, + -- danger, success. (The tone adds a colour escape to the string, which the byte count + -- below then treats as a dozen characters -- harmless, and it errs long.) + local function pihNote(g, text, tone) + if not text or text == "" then return end + local w = tone and GUI:CreateNote(parent, text, { tone = tone }) + or GUI:CreateLabel(parent, text) + g:AddWidget(w, pihLines(text) * PIH_NOTE_LINE + (GUI.RowHeight.labelPad or 19)) + end + + -- ☠☠ NOT A BANNER, AND THE REASON IS A RACE RATHER THAN A SIZE. + -- Six shapes were tried and the symptom alternated between an overlap and a large + -- gap FROM THE SAME BUILD -- "half the time it's overlap, the other half it's a huge + -- gap". That is not a wrong constant; a wrong constant is wrong the same way every + -- time. It is a timing race, and no number can win one. + -- + -- ⭐ Verified, and the asymmetry is the whole story: AddWidget stamps + -- `_slotHeightExplicit` when a call site pins a height (Sections.lua:125). + -- CreateLabel CHECKS it (Sections.lua:479) and skips its re-measure entirely, so a + -- pinned label is fixed at build and never corrects itself. CreateInfoBanner never + -- checks it -- its DoRecomputeHeight and TriggerHostRelayout run whatever you passed. + -- So the box's final height depends on when the panel happened to be built relative + -- to the banner's TWO measure passes: before it settles, the column reserved too + -- little and the next group is overlapped; after, the group shrinks under a + -- reservation already spent and the space becomes a gap. + -- + -- ⚠ A box therefore cannot be made deterministic from this side. Getting one back + -- means CreateInfoBanner honouring `_slotHeightExplicit` the way CreateLabel does -- + -- Danders' file, a real request with a checked premise, and NOT the reflow seam we + -- nearly asked for and withdrew. + + -- Each tick creates or deletes one ordinary effect, which is why the rows below + -- also appear in Active Indicators: they ARE indicators, and hiding them there + -- would mean a row you can see the colour of but cannot find. + local function signalRow(g, key, label) + g:AddWidget(GUI:CreateCheckbox(parent, label, nil, nil, nil, + function() return P.PIH_SignalOn(key) end, + function(v) + P.PIH_SetSignal(key, v) + S.SwitchTab("effects") -- the dependent groups appear and vanish with it + end)) + + -- The surface picker, and it only exists while the signal does: "where does + -- this draw" is not a question about a signal that draws nothing. + local surface = P.PIH_SurfaceOf(key) + if not surface then return end + + -- Inline, so the checkbox above is its label. A second heading saying + -- "Surface" over every row would triple the words for no added meaning. + g:AddWidget(GUI:CreateDropdown(parent, label, P.PIH_SurfaceOptions(key), + nil, nil, nil, + -- ⚠ NEVER nil: this widget survives a profile switch for one frame, + -- and the shared dropdown's display refresh treats a nil answer as "try + -- the saved-variable fallback", which was never given -- a Lua error on + -- every profile switch away from the helper. "none" is a value the menu + -- owns, so a dying row reads honestly until it is rebuilt away. + function() return P.PIH_SurfaceOf(key) or "none" end, + function(v) + P.PIH_SetSurface(key, v) + S.SwitchTab("effects") -- the other rows' menus re-grey around it + end, + -- ⚠ An INLINE dropdown does not own its slot: CreateDropdown only stamps + -- fixedRowHeight on the standalone form, so this literal is authoritative and + -- a hand-guessed one gets read. Content is 24 tall; the tight gap is right + -- because hiding the label makes it a compact row -- its control sits beside + -- its name (the checkbox above) rather than under it. + { inline = true }), 24 + GUI.RowGapTight) + + -- ⚠ THE CLASH WARNING, AND IT IS SCOPED ON PURPOSE. pickWinner decides from + -- config alone and never asks what is on the unit, so a clash is fully knowable + -- while someone is setting it up -- no guessing, no "this might happen". + -- It appears only on the three surfaces that actually take a single winner, and + -- it names the fix that exists rather than describing the problem. + -- ⚠ Only while OUR effect is actually in the contest: the named fix + -- can be applied to our own signal too (custom-mode border), and the warning + -- must go when it is. + local selfIn = P.PIH_SelfContends(surface, key) + local clashes, who = 0, nil + if selfIn then clashes, who = P.PIH_ClashOn(surface) end + -- ⚠ OUR OWN SIBLING COUNTS TOO, on a contended surface across records. + -- PIH_ClashOn skips anything carrying a helper mark, because two signals on one + -- record are prevented outright rather than warned about. "Already infused" is + -- on its own record though, so it can genuinely lose a border or a text to one + -- of the other two -- a real contest that would otherwise go unwarned precisely + -- because it was ours. + local sibling = selfIn and P.PIH_SiblingContends + and P.PIH_SiblingContends(surface, key) or nil + if sibling then + clashes = clashes + 1 + who = who or pihLabel(sibling) + end + if clashes > 0 then + who = who or L["Another effect"] + -- More than one contender: naming only the first would read as "fix this + -- one and you are done", which would not be true. + if clashes > 1 then who = format(L["%s and %d more"], who, clashes - 1) end + -- A CAUTION BOX, the addon's own construct for a warning panel -- the same + -- one the click-casting dialog and the profiler use. It briefly became gold + -- text on the belief that the box was what broke the layout; it was not, and + -- a warning that looks like every other warning is worth the box. + -- The checkbox's own label key rides as a placeholder so a translator + -- renders it ONCE -- hardcoding the words here let the sentence and the + -- control it points at drift apart in any other language. + pihNote(g, (surface == "border") + and format(L["%s already colours the border. Only one can show — tick '%s' on one of them, or move this signal somewhere else."], who, L["Give this aura its own border"]) + or format(L["%s already colours this text. Only one can show — raise this signal's priority, or move it somewhere else."], who), + "caution") + end + + -- Icons sit BESIDE the colour dropdown, equal weight: with "None" in the + -- menu, one row enumerates colour-only / icons-only / both. Every row has + -- the same flow -- tick, dropdown, icons -- which is what three earlier + -- shapes kept breaking by parking the trinkets control under the wrong + -- signal. ⚠ Strong's tick is labelled by what it SHOWS -- its + -- amplifier half -- because icons cannot make its cooldown-AND-amplifier + -- judgement; a bare "As icons" there would over-promise. The colour tint + -- stays the only display that judges. + local which = PIH_ICON_OF[key] + local iconLabel = (key == "strong") + and L["Their trinkets and potions as icons"] or L["As icons"] + g:AddWidget(GUI:CreateCheckbox(parent, iconLabel, nil, nil, nil, + function() return P.PIH_IconsShow(which) end, + function(v) + P.PIH_SetIconsShow(which, v) + S.SwitchTab("effects") + end)) + end + + pihGroup(L["What to Show"], function(g) + -- ☠ A LABEL IN THE FIRST BOX, NOT A FREE-FLOATING INFO BANNER. The banner was + -- built and removed the same day: it measures its own height a frame after it is + -- drawn, and this column stacks its children at fixed offsets with no reflow + -- seam -- so the banner grew from its 34px placeholder and landed on top of the + -- box below it. That failure is documented in GUI:RelayoutHost, which names the + -- same symptom on the indicator cards ("the Duration Bar header overlapping the + -- Pandemic section's collapse bar") and fixes it through `dfAD_ReflowWidgets`, + -- a seam the indicator cards publish and this column does not. + -- ⚠ Inside a group, a measured label re-flows its host and settles. Outside one + -- it has nothing to tell. Same converge, different owner. + pihNote(g, + L["Tick what makes someone worth infusing. It shows on your group frames."]) + + signalRow(g, "burst", L["Big cooldown"]) + signalRow(g, "strong", L["Big cooldown with a trinket or potion"]) + signalRow(g, "infused", L["Already has active Power Infusion"]) + + + -- ☠ A LABEL, NOT AN INFO BANNER, AND THIS IS THE SECOND TIME THE SAME TRAP HAS + -- CAUGHT US. A banner starts life 34px tall and measures its real height a frame + -- after it draws, then asks its host to re-flow. Inside a settings group the + -- GROUP does re-flow -- which is why putting it in one looked like the fix -- but + -- the group then asks the COLUMN, and this column publishes no + -- `dfAD_ReflowWidgets` seam, so every box below it stays where the old height + -- put it. Four lines of prose starting from a 34px estimate is a big enough jump + -- to land on the next box: "Trinkets and Potions is being overlapped by What to + -- Show", field-reported 2026-08-24. + -- + -- ⚠ THE DIFFERENCE IS THE SIZE OF THE LIE, not the widget. CreateLabel measures + -- itself too, but it starts at 40px, which already covers the two-line notes + -- these boxes use -- so its correction is small or zero and nothing visibly + -- moves. A banner's is not. Until the column grows a reflow seam (raised with + -- Danders), prose here has to be short enough that its first guess is right. + -- + -- ⚠ AND THE TEXT SHRANK FOR THE SAME REASON IT COULD AFFORD TO: two of the three + -- facts it carried are already on screen where they matter. The swap is written + -- into the dropdown entry itself ("Health Bar (swap with Big cooldown)"), and + -- the single-winner warning appears, naming the offender, exactly when it + -- applies. Only the stacking rule had nowhere else to live. + -- ☠ A TOGGLE, NOT A SPELL PICKER. An earlier pass let the user choose which + -- cooldown gates the helper. The machinery is not priest-specific so it was + -- easy -- but nobody asked for it, and "which spell hides this" is a question + -- about plumbing rather than about the feature. The helper exists to say who is + -- worth infusing; it hides when you cannot infuse. One idea, one switch. + -- (The capability stays underneath for testing.) + -- + -- ⚠ IT SITS HERE RATHER THAN IN A BOX OF ITS OWN. A whole titled group around a + -- single checkbox is more chrome than the setting is worth, and this label says + -- what it does without a header to lean on -- which is the test for whether a + -- control can live under a heading that does not quite describe it. + g:AddWidget(GUI:CreateCheckbox(parent, + L["Hide the helper while Power Infusion is on cooldown"], nil, nil, nil, + function() return P.PIH_Settings().gateEnabled ~= false end, + function(v) P.PIH_SetGateEnabled(v) end)) + + + -- ☠☠ TWO ONE-LINE NOTES, AND THE LENGTH IS THE WHOLE POINT. + -- Seven attempts went into sizing one long paragraph here, and the readout that + -- finally produced evidence said this: notes of 38-53 characters reserved their + -- space to within 2px, while the 359-character one was out by 93. Short notes are + -- exact; long ones drift, whatever constant is used. So the fix is not a better + -- estimate, it is text that fits on one line -- where ceil() has nothing to round + -- up and the estimate cannot be wrong. + -- + -- ⚠ WHAT WAS CUT, AND WHY THESE TWO SURVIVED. The paragraph had four sentences. + -- The swap rule is already written into the dropdown entry itself ("Health Bar + -- (swap with Big cooldown)"), and it appears at the moment it matters rather than + -- in advance. That "Already has active Power Infusion" can share follows from the + -- two lines below. These two are the only facts nothing else on the panel ever + -- states, so they are the two that had to stay. + -- + -- ⚠ No inline highlighting left either: these name display types, not + -- indicators, and the display-type names are short and already capitalised. + pihNote(g, L["Health Bar and Background can show several indicators at once."]) + pihNote(g, L["Border and Text colours show only one at a time."]) + -- The one navigational fact text is genuinely needed for. Only while the + -- icon group exists: position is not a question about icons that are not there. + if pihAnyIconGroup() then + pihNote(g, L["Move and size the icons under Layout Groups."]) + end + end) + + -- ☠ ONLY WHILE STRONG WINDOW IS ON. These two are what the signal MEANS, so on + -- their own they are a question about nothing. Shown rather than greyed, because a + -- greyed pair would invite the reading that strong window works without them. + if P.PIH_SignalOn("strong") then + pihGroup(L["Trinkets and Potions"], function(g) + -- ☠ UNTICKING BOTH TAKES THE SIGNAL WITH IT. Strong window is "a cooldown + -- AND (a potion OR a trinket)". With neither ticked the amplifier group is + -- empty, resolveConditions skips it, bails on fewer than two groups, and the + -- effect silently degrades into a duplicate of the burst signal. So the + -- recipe deletes it instead, and ticking one back brings it into existence. + g:AddWidget(GUI:CreateCheckbox(parent, L["Combat potions"], nil, nil, nil, + function() return P.PIH_Settings().potions == true end, + function(v) P.PIH_SetAmplifier("potions", v); S.SwitchTab("effects") end)) + g:AddWidget(GUI:CreateCheckbox(parent, L["On-use trinkets"], nil, nil, nil, + function() return P.PIH_Settings().trinkets == true end, + function(v) P.PIH_SetAmplifier("trinkets", v); S.SwitchTab("effects") end)) + end) + end + + pihGroup(L["Never Show On"], function(g) + -- ⚠ FAILS OPEN. A group with no assigned roles reads as "no role" for everyone + -- and nothing is excluded. Marking a tank you did not want is a smaller failure + -- than silently hiding the signal on the damage dealers you did. + g:AddWidget(GUI:CreateCheckbox(parent, L["Tanks"], nil, nil, nil, + function() return (P.PIH_Settings().roles or {}).TANK == true end, + function(v) P.PIH_SetRole("TANK", v) end)) + g:AddWidget(GUI:CreateCheckbox(parent, L["Healers"], nil, nil, nil, + function() return (P.PIH_Settings().roles or {}).HEALER == true end, + function(v) P.PIH_SetRole("HEALER", v) end)) + pihNote(g, + L["Only applies when the group has roles."]) + end) + + -- ☠ COLLAPSIBLE, AND THIRTEEN ROWS IS WHY. Everything else in this panel is two or + -- three ticks; a class list is as long as the game has classes, and most people + -- will never open it. The summary on the header carries the state while it is + -- folded, so the box does not have to be open to be honest. + pihGroup(L["Classes to Watch"], function(g) + -- ☠ ABOVE THE TICKS, NOT BELOW THEM. Fourteen rows is far enough that a line + -- underneath is a line nobody reads -- it arrives after the reader has already + -- decided what the box does. The one sentence that explains the box goes where + -- the reader still needs it. + pihNote(g, L["Untick a class to ignore its cooldowns."]) + + -- ☠ ABOVE THE LIST, NOT UNDER IT. Fourteen ticks is far enough that a button at + -- the bottom is a button nobody scrolls to -- and this is the escape hatch for + -- the thing the list cannot do (single spells), so it has to be visible while + -- someone is still deciding the list is not enough. + -- + -- ⭐ GUI:OpenFilterInDesigner, NOT a bare SelectTab. It switches the page AND + -- scrolls to this filter, selects it and pulses it. Its own comment records why: + -- the hand-written version "landed you on the page with nothing indicated, which + -- is indistinguishable from a broken link" -- which is exactly what was here. + pihNote(g, + L["To add or remove single spells, open the list itself."]) + local cfID = P.PIH_CooldownFilterID and P.PIH_CooldownFilterID() + local fdBtn = GUI:CreateButton(parent, L["Filter Designer"], 140, 22, function() + GUI:OpenFilterInDesigner("custom", cfID) + -- ⚠ TWICE, ONE FRAME APART, AND THAT IS A WORKAROUND. _fdFocusFilter reads + -- GetVerticalScrollRange to clamp its scroll, and on the page's FIRST build + -- that range is still 0 -- so the clamp pins the scroll at the top and the + -- row it selected and pulsed is somewhere below the fold. The second call + -- runs after layout, when the range is real. The proper fix is a deferred + -- retry inside _fdFocusFilter itself; that file is Danders' and it is on the + -- list for him rather than edited from here. + if C_Timer and C_Timer.After then + C_Timer.After(0, function() GUI:OpenFilterInDesigner("custom", cfID) end) + end + end) + if not (cfID and GUI.Pages and GUI.Pages["auras_filterdesigner"]) then + fdBtn:Disable() + fdBtn.Text:SetTextColor(0.4, 0.4, 0.4) + end + g:AddWidget(fdBtn, 28) + + for _, token in ipairs(P.PIH_ClassList()) do + local classFile = token + -- Read at call time: SpellPicker.lua loads after this file, so the display + -- helper does not exist yet at file scope. + local name = (classFile == "@racials") and L["Racials"] + or ((DF.FilterRegistry and DF.FilterRegistry.ClassDisplayName + and DF.FilterRegistry.ClassDisplayName(classFile)) or classFile) + g:AddWidget(GUI:CreateCheckbox(parent, name, nil, nil, nil, + function() return P.PIH_ClassOn(classFile) end, + function(v) P.PIH_SetClassOn(classFile, v) end)) + end + -- ⚠ NO showSummary. The collapsed summary concatenates every child label, which for + -- thirteen classes and a two-line note is a wall of text rather than a summary. The + -- header alone says what is folded away, which is what a summary was for. + end, { collapsible = true, collapseKey = "pihelper:onlywatch" }) + + pihGroup(L["Sound Alert"], function(g) + -- ☠ TWO SETTINGS, NOT ONE. The key remembers WHICH sound, the switch remembers + -- WHETHER -- so turning it off and back on does not make anyone hunt for their + -- sound a second time. Silent until chosen, either way: a cue nobody asked for + -- is the fastest route to the whole feature being switched off. + g:AddWidget(GUI:CreateCheckbox(parent, L["Play a sound when someone becomes worth infusing"], + nil, nil, nil, + function() return P.PIH_Settings().soundOn == true end, + function(v) P.PIH_SetSoundOn(v); S.SwitchTab("effects") end)) + if P.PIH_Settings().soundOn then + g:AddWidget(GUI:CreateSoundDropdown(parent, L["Sound"], + P.PIH_Settings(), "soundLSMKey", + function() P.PIH_ApplySound() end), GUI.RowHeight.dropdown) + -- ⚠ Stated rather than discovered in a fight: sound rides the same gate as + -- the visuals, and it announces new windows only -- a window already open + -- when the gate re-opens stays silent, because the visuals already carry it. + pihNote(g, + L["Only plays while the helper is showing."]) + end + end) + + end + end + -- ── ACTIVE INDICATORS heading ── local activeHeader = parent:CreateFontString(nil, "OVERLAY") GUI:SetSettingsFont(activeHeader, 9, "") diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index c5a69cb7..2db1145e 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1382,7 +1382,11 @@ local function CollectAllEffects() tinsert(effects, { source = "frame", auraName = auraName, - displayName = displayName, + -- An effect may carry its OWN label. Without this every effect built + -- on one filter reads identically in the list -- distinguishable only + -- by its type badge, which says what it draws and not what it means. + -- Optional and nil everywhere else, so existing effects are unchanged. + displayName = auraCfg[typeKey].label or displayName, typeKey = typeKey, config = auraCfg[typeKey], }) diff --git a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua index 02aa5d80..e4e963f5 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Indicators.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Indicators.lua @@ -118,7 +118,18 @@ local function BuildTypeContent(parent, typeKey, auraName, width, optProxy, yOff -- host to hang the next gate on. So it is greyed rather than left live over a render -- path that silently ignores it. local function GateSWM(cb) - if not (cb and auraName and P.GetEffectConditionGroups) then return end + if not cb then return end + -- ☠ NEVER ON A HELPER SIGNAL. Show-when-missing reroutes the effect down the + -- missing-mode build, which the Power Infusion Helper's cooldown gate cannot reach + -- (applyGroupTuning refuses mode == "missing") -- ticking it here would silently + -- exempt the effect from "hide while Power Infusion is on cooldown", with nothing on + -- screen to say so. Greyed with the reason, per the house rule. + if proxy and proxy.pihSignal then + cb:SetEnabled(false) + cb.tooltip = L["Not available on a Power Infusion Helper signal."] + return + end + if not (auraName and P.GetEffectConditionGroups) then return end local groups = P.GetEffectConditionGroups(auraName, typeKey) if groups and #groups > 1 then cb:SetEnabled(false) From d3bec08e24a153c2d13a8301338024d685e3d30a Mon Sep 17 00:00:00 2001 From: Maelareth Date: Sat, 29 Aug 2026 17:26:02 +0200 Subject: [PATCH 3/4] fix(aura-designer): review follow-ups on the gate machinery - The gate broadcast skips parked slots: a parked slot renders nothing, so pushing candidates at it is pure work, and it stays in the registry by necessity because Restore has to find it again. Restore now re-pushes candidates through the accessor, so a gate edge that flipped while the slot was parked is picked up on un-park rather than lost. - The container debug dump reports the raw selection instead of the gated one. It read through the gating accessor, so a gated row logged the dead match-nothing placeholder rather than the user's real filter - on the one diagnostic line most likely to be read while debugging that exact gate. - The gate's combat-exit backstop registers its five events only while a consumer exists, matching the pattern its sibling watcher already used. Driven by a single "does a consumer exist" test so the two registrations cannot disagree. --- DandersFrames/Frames/AuraContainer.lua | 52 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index e65dc89c..82594ad2 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -6199,16 +6199,32 @@ function AuraContainer.GetHelperExcludedRoles() return helperExcludedRoles end -- the exclusions, swap spec, and the container keeps the answer from before the swap. Re-push -- on anything that can move a role. (This makes us notice promptly when the game's answer -- CHANGES. It cannot make the game's answer FRESH -- see the staleness note above.) +-- ⚠ REGISTERED ONLY WHILE A HELPER EXISTS, the same pattern the engine's watcher +-- already uses. These five events fire for every player of the addon, and a non-user would +-- otherwise walk both handle registries on every roster change for a feature they cannot +-- enable. Hygiene rather than cost since the walk is narrowed to owned entries -- but the +-- pattern was already established next door. Driven from the engine's own "does a helper +-- exist" test, so the two registrations can never disagree. +local PIH_REGEN_EVENTS = { "PLAYER_REGEN_ENABLED", "PLAYER_ROLES_ASSIGNED", + "GROUP_ROSTER_UPDATE", "PLAYER_SPECIALIZATION_CHANGED", + "ACTIVE_TALENT_GROUP_CHANGED" } local helperGateRegen = CreateFrame("Frame") -helperGateRegen:RegisterEvent("PLAYER_REGEN_ENABLED") -helperGateRegen:RegisterEvent("PLAYER_ROLES_ASSIGNED") -helperGateRegen:RegisterEvent("GROUP_ROSTER_UPDATE") -helperGateRegen:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED") -helperGateRegen:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED") +local helperGateRegistered = false helperGateRegen:SetScript("OnEvent", function() AuraContainer.SetHelperGate(helperGateDark) end) +-- Idempotent; safe to call on every settings change. +function AuraContainer.SetHelperGateActive(active) + active = active and true or false + if active == helperGateRegistered then return end + helperGateRegistered = active + for _, ev in ipairs(PIH_REGEN_EVENTS) do + if active then helperGateRegen:RegisterEvent(ev) + else helperGateRegen:UnregisterEvent(ev) end + end +end + -- Force a re-scan of the container. 68569: UpdateAllAuras() is an addon-callable -- dirty-mark (processed on the next OnUpdate while visible) — the real refresh. Use on -- a dynamic-unit consumer (target/focus/mouseover) when the underlying unit changes but @@ -7638,6 +7654,14 @@ end function SlotHandle:Restore() if not self.parked then return true end self.parked = false + -- ⚠ RE-PUSH THE CANDIDATES, not just the filter string: the helper gate skips + -- parked slots (see _applyHelperGate), so an edge that flipped while this one was parked + -- never reached the engine. Reading through _cf() means un-parking lands on the CURRENT + -- verdict rather than whatever was last pushed before the park. + local c = self.owner and self.owner.container + if c and self._lastCandidateFilters ~= nil and not InCombatLockdown() then + pcall(c.SetAuraSlotCandidateFilters, c, self.key, self:_cf()) + end local ok = self:_pushFilter() if not ok or InCombatLockdown() then self._pendingTuning = true @@ -7995,6 +8019,12 @@ end -- _replayTuning drains the deferral on the way out, itself reading through _cf(). function SlotHandle:_applyHelperGate() if self._lastCandidateFilters == nil then return false end + -- ⚠ SKIP PARKED SLOTS. A parked slot renders nothing, so pushing candidates at it + -- is pure work -- and it is never unregistered from the registry because Restore has to + -- find it again (unlike a Handle, which nils itself out on destroy). Restore re-pushes + -- through the accessor, so a gate edge that happened while parked is picked up there + -- rather than lost. Caught in Danders' PR review. + if self.parked then return false end local c = self.owner and self.owner.container if not c then return false end if InCombatLockdown() then @@ -8694,7 +8724,13 @@ function AuraContainer.DebugDumpIdentityGate() seenF[rec.f] = true fParts[#fParts + 1] = rec.f end - local cf = recordCandidateFilters(rec, cfg) + -- ☠ THE RAW SELECTION, NOT THE GATED ONE. This read through + -- recordCandidateFilters, so a helper row sitting dark reported the dead + -- match-nothing placeholder instead of the user's real selection -- on the + -- one diagnostic line most likely to be read while debugging that exact + -- gate. Same expression the build path's own cfOf uses. Caught in Danders' + -- PR review. + local cf = rec.candidateFilters or cfg.candidateFilters if cf and cf.includeSpellIDs then inc = true end if cf and cf.excludeSpellIDs then exc = true end end @@ -8738,6 +8774,10 @@ function AuraContainer.DebugDumpIdentityGate() -- its own _applyIdentityGate and its own _gateHidden, so a dump that reports only -- Handles is blind to the half the AD uses -- and the AD is where the stuck-indicator -- reports came from. + -- The slot dump below already reports the RAW stash (`_lastCandidateFilters`), which + -- is what a diagnostic wants: the gated view is derived, and printing it would hide the + -- user's real selection behind the placeholder. The Handle dump above had that wrong and + -- is fixed. local sn, svuln = 0, 0 for s in pairs(AuraContainer._slotHandles or {}) do sn = sn + 1 From 843143da83f6f9c19861cb1f85430ddb27e36ccf Mon Sep 17 00:00:00 2001 From: Maelareth Date: Sat, 29 Aug 2026 17:26:16 +0200 Subject: [PATCH 4/4] fix(pi-helper): review follow-ups - the stored localised label, and five more - BLOCKER: the signal's display name was written onto the effect config, which lives in the profile - a translated string turned into saved data. Built on an English client and switched to German, the stored row name stays English while the surface dropdown resolves live and shows German. The mark is the stored truth; the label derives from it at render, through one exported resolver so the panel and the effects list cannot disagree. Placed rows gain the same derivation, which they never had. - The secret guard was defeated by its own evaluation order: `and` runs left to right, so the nil comparison ran before the check meant to prevent it, and comparing against a sealed value is what throws. - Refusals reach the debug log; they returned a reason nothing read, so a failure looked exactly like a dead control. - The surface dropdown greys in place instead of vanishing - it belongs to a feature toggle. The Trinkets and Potions group stays hidden, with its existing rationale. - Unticking the last signal re-derives the way Remove does, and "does a helper exist" is now one question answered from the marks - the same derivation the panel uses. It was two: the engine counted a recorded list id that outlives the signals, so emptying the helper left its events registered and its sound armed while the panel correctly showed it gone. - The Filter Designer button uses the shared disabled treatment, and the changelog entry is trimmed to four sentences. --- CHANGELOG.md | 2 +- DandersFrames/AuraDesigner/Engine.lua | 60 +++++++++++++--- .../AuraDesigner/UI/Cards.lua | 68 +++++++++++++++---- .../AuraDesigner/UI/Groups.lua | 18 +++-- 4 files changed, 119 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a96c08..d3d7d513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### New Features -* (Aura Designer) New **Power Infusion Helper** for priests, added from the Other Buffs tab. One click marks the group members worth infusing: tick "Big cooldown" for anyone who has used a major damage cooldown, "Big cooldown with a trinket or potion" for someone going all in, and "Already has active Power Infusion" so you don't double up. Everything it shows goes dark while your own Power Infusion is on cooldown, so it only speaks up when you can act on it — with a switch to keep it always on. Choose where each one appears — border, health bar, background or text colour — pick which classes count, and add a sound for when someone becomes worth infusing. Tanks and healers are skipped unless you say otherwise. (by Maelareth) +* (Aura Designer) New **Power Infusion Helper** for priests, added from the Other Buffs tab. One click marks the group members worth infusing: tick "Big cooldown" for anyone who has used a major damage cooldown, "Big cooldown with a trinket or potion" for someone going all in, and "Already has active Power Infusion" so you don't double up. Everything it shows goes dark while your own Power Infusion is on cooldown, so it only speaks up when you can act on it, and a switch keeps it always on if you prefer. Choose where each signal appears — border, health bar, background, text colour, an icon or a square — pick which classes count, and add a sound; tanks and healers are skipped unless you say otherwise. (by Maelareth) ### Bug Fixes diff --git a/DandersFrames/AuraDesigner/Engine.lua b/DandersFrames/AuraDesigner/Engine.lua index cb84f102..bf61b900 100644 --- a/DandersFrames/AuraDesigner/Engine.lua +++ b/DandersFrames/AuraDesigner/Engine.lua @@ -172,17 +172,46 @@ local pihSoundCfg = nil -- feature is used. If this ever needs to differ per mode, the gate has to become per-mode -- first, and that is a bigger change than a better read. local PIH_MODES = { "party", "raid" } + +-- ☠ "DOES A HELPER EXIST" IS ONE QUESTION, ANSWERED FROM THE MARKS -- the same +-- derivation the panel uses, so the two halves cannot disagree. An earlier version tested for +-- the recorded list id instead, and the two definitions drifted apart in exactly one state: +-- untick every signal without pressing Remove, and the panel correctly reported the helper +-- gone while the engine kept its event registrations and its armed sound, because the list id +-- outlives the signals (the spell list is still there; nothing points at it). Field-found. +-- +-- ⚠ The marks ARE the record -- effects, placed instances and icon groups all carry +-- pihSignal -- so scanning for one is the definition, not a proxy for it. Cheap: it runs on +-- settings changes and login, never in a frame update. +local function pihHasHelper(adDB) + if type(adDB) ~= "table" then return false end + for _, auraCfg in pairs(adDB.otherAuras or {}) do + if type(auraCfg) == "table" then + for _, v in pairs(auraCfg) do + if type(v) == "table" and v.pihSignal then return true end + end + for _, inst in ipairs(auraCfg.indicators or {}) do + if type(inst) == "table" and inst.pihSignal then return true end + end + end + end + for _, g in ipairs(adDB.otherLayoutGroups or {}) do + if type(g) == "table" and g.pihSignal then return true end + end + return false +end + +-- ⚠ FIRST PRESET THAT HAS A HELPER WINS, PARTY FIRST. The gate is ONE switch for the +-- whole addon, so two presets carrying different helper settings is an ambiguity no read can +-- resolve -- taking the first is a choice, not a derivation. Party first because that is where +-- the feature is used. A preset whose settings table survived a Remove no longer shadows one +-- that actually has a helper, because the marks decide. local function pihSettings() if not DF.GetModeBaseAuraDesigner then return nil end for _, mode in ipairs(PIH_MODES) do local adDB = DF:GetModeBaseAuraDesigner(mode) local s = adDB and adDB.pihelper - -- ☠ A pihelper TABLE ALONE IS NOT A HELPER. Remove leaves the table behind on - -- purpose (behaviour survives a remove), so a preset that ONCE had a helper would - -- otherwise shadow the preset that has one now -- settings configured in raid mode - -- reverting to party leftovers on every reload. The recorded list id only exists - -- while a helper is actually installed, so it is the installed test. - if s and s.cooldownFilterID then return s end + if s and pihHasHelper(adDB) then return s end end return nil end @@ -318,7 +347,12 @@ local function pihReadReady() local gcd = info.isOnGCD local sealed = issecretvalue and issecretvalue(gcd) - if gcd ~= nil and not sealed then + -- ☠ SEALED TEST FIRST. `and` evaluates left to right, so writing this as + -- `gcd ~= nil and not sealed` runs the nil comparison BEFORE the guard that exists to + -- prevent it -- and a comparison against a sealed value throws. The guard was decorative + -- in exactly the branch it was written for. pihReadCharges gets the order right and says + -- why; caught in Danders' PR review. + if not sealed and gcd ~= nil then -- Active AND merely the global cooldown = not a real cooldown = still ready. return gcd == true end @@ -490,6 +524,12 @@ local PIH_WATCH_EVENTS = { "SPELL_UPDATE_COOLDOWN", "SPELL_UPDATE_CHARGES", local pihWatching = false pihSyncWatcher = function() local want = pihSettings() ~= nil + -- The container's own backstop frame follows the same fact, from the same test -- one + -- definition of "a helper exists" driving both registrations. Called unconditionally + -- (it is idempotent) so it self-corrects even when our own state has not moved. + if DF.AuraContainer and DF.AuraContainer.SetHelperGateActive then + DF.AuraContainer.SetHelperGateActive(want) + end if want == pihWatching then return end pihWatching = want for _, ev in ipairs(PIH_WATCH_EVENTS) do @@ -664,7 +704,11 @@ SlashCmdList["DFPI"] = function(msg) :Field("sound registrations", ("%d over %d frame%s%s"):format( pihLastArmCount, pihLastArmFrames, pihLastArmFrames == 1 and "" or "s", pihLastArmAt and (" (last armed " .. pihLastArmAt .. ")") or ""), - (pihSoundCfg and pihGateOpen and pihLastArmCount == 0) and "bad" or "neutral") + -- ⚠ Only a fault WITH A GROUP: solo there is no unit to register on (we + -- never register the player's own), so zero is the right answer and a red zero + -- would teach the reader to ignore the line that matters. + (pihSoundCfg and pihGateOpen and pihLastArmCount == 0 + and GetNumGroupMembers and GetNumGroupMembers() > 1) and "bad" or "neutral") :Field("gated containers live", (function() local AC = DF.AuraContainer local n = 0 diff --git a/DandersFrames_Options/AuraDesigner/UI/Cards.lua b/DandersFrames_Options/AuraDesigner/UI/Cards.lua index 342e03bc..ad6ca7c1 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Cards.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Cards.lua @@ -243,12 +243,24 @@ end -- Localised at call time, not at file scope: the same locale-timing rule the effect-label -- tables in Groups.lua follow. +-- +-- ☠ RESOLVED AT RENDER, NEVER STORED. An earlier version wrote this string onto the +-- effect config as `cfg.label` -- which lives in the PROFILE, so a translated string became +-- saved data. Build the helper on an English client and switch to German: the stored row name +-- stays English forever while the surface dropdown resolves live and shows German, two names +-- for one signal disagreeing on screen. The addon's own rule ("never store L[...] as a db +-- value") says it plainly; caught in Danders' PR review, and it blocked the merge because bad +-- data outlives the fix. `pihSignal` is the stored truth and the label is derived from it. local function pihLabel(key) if key == "burst" then return L["PI Helper — Big cooldown"] end if key == "strong" then return L["PI Helper — Big cooldown with a trinket or potion"] end if key == "infused" then return L["PI Helper — Already has active Power Infusion"] end end +-- The effects list (Groups.lua) resolves helper rows through this: same derivation, one +-- definition, so the list and the panel can never disagree about what a signal is called. +P.PIH_SignalLabel = pihLabel + local function pihFilterIdByName(name) local R = DF.FilterRegistry if not (R and R.ReadStore) then return nil end @@ -561,9 +573,6 @@ local function pihCreateSignal(key, surfaceOverride) if not cfg then return false, "could not create the effect" end -- ☠ THE MARK. This one field is what makes every question above answerable. cfg.pihSignal = key - -- Its own row label. Burst and strong share one spell list, so without this they read - -- identically in the effects list. - cfg.label = pihLabel(key) cfg[pihColorKey(tgt)] = { r = def.color[1], g = def.color[2], b = def.color[3], a = 1 } -- ☠ TINT, NOT REPLACE. A health-bar effect's generic default is Replace, which -- repaints the whole bar and covers every other tint -- the exact collision the panel's @@ -659,7 +668,8 @@ end -- first (only helper effects carry one today), then the registry's name for a filter-owned -- record, then the pool key -- which for an ordinary record IS the aura's name. local function pihEffectName(auraName, cfg) - if type(cfg) == "table" and cfg.label then return cfg.label end + -- Derived from the mark; see pihLabel for why nothing is stored. + if type(cfg) == "table" and cfg.pihSignal then return pihLabel(cfg.pihSignal) end local named = DF.ADFilterRefDisplayName and DF:ADFilterRefDisplayName(auraName) return named or auraName end @@ -837,7 +847,6 @@ local function pihPlace(key, auraName, surface, carried) local cfg = EnsureTypeConfig(auraName, surface, pihOtherPoolWrite()) if not cfg then return false end cfg.pihSignal = key - cfg.label = pihLabel(key) cfg.othersOnly = (key ~= "infused") or nil -- infused = own cast; see pihCreateSignal cfg.enabled = true cfg.conditions = carried and carried.conditions or nil @@ -1155,6 +1164,10 @@ end function P.PIH_Create() local ok, why = pihCreateSignal("burst") + -- See PIH_SetSignal: a silent refusal is indistinguishable from a dead button. + if not ok then + DF:DebugWarn("AURADESIGNER", "PIH: could not add the helper -- %s", tostring(why)) + end if ok then -- ☠ PUSH THE DEFAULTS NOW. Creating writes the settings table (tanks and -- healers excluded, gate on) but writing is not applying -- without this push the @@ -1261,13 +1274,26 @@ function P.PIH_SetSignal(key, on) if key == "strong" and not (s.potions or s.trinkets) then s.potions, s.trinkets = true, true end - pihCreateSignal(key) + local ok, why = pihCreateSignal(key) + -- ⚠ A REFUSAL MUST LEAVE A TRACE. All of these paths return a reason and nothing + -- read it, so a failure (the registry not ready, a list that would not build) looked + -- exactly like a dead control: click, nothing, no message anywhere. + if not ok then DF:DebugWarn("AURADESIGNER", "PIH: signal %s not created -- %s", + tostring(key), tostring(why)) end P.PIH_Apply() -- see PIH_Create: writing settings is not applying them else pihDeleteSignal(key) -- Off means off everywhere: the signal's own icon list goes with it, or the master -- tick would re-read as on from the icons it left behind. if PIH_ICON_OF[key] then P.PIH_SetIconsShow(PIH_ICON_OF[key], false) end + -- ⚠ RE-DERIVE, AS REMOVE DOES. Unticking the LAST signal leaves no helper, and + -- without this the resident half keeps its event registrations and its sound armed for + -- a helper with nothing left in it. Remove gets that reset; this path reaches the same + -- state one tick at a time and got nothing. Caught in Danders' PR review. + if not P.PIH_Exists() then + local Engine = DF.AuraDesigner and DF.AuraDesigner.Engine + if Engine and Engine.PIH_ApplySaved then Engine:PIH_ApplySaved() end + end end pihRefresh() end @@ -4038,14 +4064,19 @@ S.BuildEffectsTab = function() S.SwitchTab("effects") -- the dependent groups appear and vanish with it end)) - -- The surface picker, and it only exists while the signal does: "where does - -- this draw" is not a question about a signal that draws nothing. + -- ⚠ GREYED, NOT HIDDEN. The signal tick is a FEATURE TOGGLE, and the + -- house rule is grey-in-place for those; hiding is for mode choices, where a + -- control genuinely does not apply. A vanished dropdown also loses the one + -- thing worth seeing while the signal is off: where it WOULD draw. + -- (The Trinkets and Potions group below stays hidden, deliberately, and says + -- why -- greying that pair would imply strong window works without them.) local surface = P.PIH_SurfaceOf(key) - if not surface then return end + local signalOff = (surface == nil) + if signalOff then surface = "none" end -- Inline, so the checkbox above is its label. A second heading saying -- "Surface" over every row would triple the words for no added meaning. - g:AddWidget(GUI:CreateDropdown(parent, label, P.PIH_SurfaceOptions(key), + local dd = GUI:CreateDropdown(parent, label, P.PIH_SurfaceOptions(key), nil, nil, nil, -- ⚠ NEVER nil: this widget survives a profile switch for one frame, -- and the shared dropdown's display refresh treats a nil answer as "try @@ -4054,7 +4085,9 @@ S.BuildEffectsTab = function() -- owns, so a dying row reads honestly until it is rebuilt away. function() return P.PIH_SurfaceOf(key) or "none" end, function(v) - P.PIH_SetSurface(key, v) + local ok, why = P.PIH_SetSurface(key, v) + if not ok then DF:DebugWarn("AURADESIGNER", + "PIH: surface %s refused -- %s", tostring(v), tostring(why)) end S.SwitchTab("effects") -- the other rows' menus re-grey around it end, -- ⚠ An INLINE dropdown does not own its slot: CreateDropdown only stamps @@ -4062,7 +4095,9 @@ S.BuildEffectsTab = function() -- a hand-guessed one gets read. Content is 24 tall; the tight gap is right -- because hiding the label makes it a compact row -- its control sits beside -- its name (the checkbox above) rather than under it. - { inline = true }), 24 + GUI.RowGapTight) + { inline = true }) + if signalOff and dd.SetEnabled then dd:SetEnabled(false) end + g:AddWidget(dd, 24 + GUI.RowGapTight) -- ⚠ THE CLASH WARNING, AND IT IS SCOPED ON PURPOSE. pickWinner decides from -- config alone and never asks what is on the unit, so a clash is fully knowable @@ -4277,8 +4312,13 @@ S.BuildEffectsTab = function() end end) if not (cfID and GUI.Pages and GUI.Pages["auras_filterdesigner"]) then - fdBtn:Disable() - fdBtn.Text:SetTextColor(0.4, 0.4, 0.4) + -- ⚠ THE SHARED TREATMENT, not a hand-written grey. CreateButton routes + -- through StyleButton, which owns SetDisabled: dim backdrop, faint border, label + -- alpha, wash suppressed. Disable() plus a literal text colour rendered a NORMAL + -- backdrop with grey text, visibly unlike every other disabled button in the + -- addon. Caught in Danders' PR review. + if fdBtn.SetDisabled then fdBtn:SetDisabled(true) + else fdBtn:Disable(); fdBtn.Text:SetTextColor(0.4, 0.4, 0.4) end end g:AddWidget(fdBtn, 28) diff --git a/DandersFrames_Options/AuraDesigner/UI/Groups.lua b/DandersFrames_Options/AuraDesigner/UI/Groups.lua index 2db1145e..4d57860f 100644 --- a/DandersFrames_Options/AuraDesigner/UI/Groups.lua +++ b/DandersFrames_Options/AuraDesigner/UI/Groups.lua @@ -1364,7 +1364,10 @@ local function CollectAllEffects() tinsert(effects, { source = "placed", auraName = auraName, - displayName = displayName, + -- Same derivation as the frame-level rows below: a marked indicator + -- (a helper Icon or Square) names itself, from the mark. + displayName = (indicator.pihSignal and P.PIH_SignalLabel + and P.PIH_SignalLabel(indicator.pihSignal)) or displayName, indicatorID = indicator.id, typeKey = indicator.type, config = indicator, @@ -1382,11 +1385,14 @@ local function CollectAllEffects() tinsert(effects, { source = "frame", auraName = auraName, - -- An effect may carry its OWN label. Without this every effect built - -- on one filter reads identically in the list -- distinguishable only - -- by its type badge, which says what it draws and not what it means. - -- Optional and nil everywhere else, so existing effects are unchanged. - displayName = auraCfg[typeKey].label or displayName, + -- A marked effect names ITSELF. Without this every effect built on one + -- filter reads identically in the list -- distinguishable only by its + -- type badge, which says what it draws and not what it means. + -- ⚠ DERIVED FROM THE MARK, never a stored string: a saved label + -- is a translated string frozen into the profile, so it would keep the + -- locale it was created in while every other name followed the client. + displayName = (auraCfg[typeKey].pihSignal and P.PIH_SignalLabel + and P.PIH_SignalLabel(auraCfg[typeKey].pihSignal)) or displayName, typeKey = typeKey, config = auraCfg[typeKey], })