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..82594ad2 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,97 @@ 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.) +-- ⚠ 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") +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 @@ -7152,8 +7362,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 +7451,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; @@ -7441,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 @@ -7506,7 +7727,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 +7749,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 +7786,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 +7921,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 +7934,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 +7971,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 +7986,56 @@ 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 + -- ⚠ 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 + 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, @@ -8447,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 @@ -8491,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