From c0bf6bccf7ae8271fe576696961c89a6e074d07f Mon Sep 17 00:00:00 2001 From: Maelareth Date: Thu, 27 Aug 2026 11:09:36 +0200 Subject: [PATCH 1/2] 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 d3bec08e24a153c2d13a8301338024d685e3d30a Mon Sep 17 00:00:00 2001 From: Maelareth Date: Sat, 29 Aug 2026 17:26:02 +0200 Subject: [PATCH 2/2] 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