diff --git a/DandersFrames/ClickCasting/Bindings.lua b/DandersFrames/ClickCasting/Bindings.lua index 50d256dc..c0a972b1 100644 --- a/DandersFrames/ClickCasting/Bindings.lua +++ b/DandersFrames/ClickCasting/Bindings.lua @@ -2080,7 +2080,7 @@ function CC:GetSlotItemInfo(slotId) local itemId = GetInventoryItemID("player", slotId) if itemId then local itemName, _, _, _, _, _, _, _, _, itemIcon = C_Item.GetItemInfo(itemId) - local spellName = GetItemSpell(itemId) + local spellName = C_Item.GetItemSpell(itemId) return { itemId = itemId, name = itemName, @@ -2097,7 +2097,7 @@ function CC:GetItemInfoById(itemId) if not itemId then return nil end local itemName, _, _, _, _, _, _, _, _, itemIcon = C_Item.GetItemInfo(itemId) if itemName then - local spellName = GetItemSpell(itemId) + local spellName = C_Item.GetItemSpell(itemId) return { itemId = itemId, name = itemName, @@ -2112,7 +2112,7 @@ end -- Get item count in bags function CC:GetItemCount(itemId) if not itemId then return 0 end - return C_Item.GetItemCount(itemId) or GetItemCount(itemId) or 0 + return C_Item.GetItemCount(itemId) or 0 end -- Build macro text for a single binding diff --git a/DandersFrames/ClickCasting/Frames.lua b/DandersFrames/ClickCasting/Frames.lua index 987ab552..fa3819d4 100755 --- a/DandersFrames/ClickCasting/Frames.lua +++ b/DandersFrames/ClickCasting/Frames.lua @@ -1334,8 +1334,25 @@ end -- need to handle clicks independently. function CC:PropagateMouseOnChildren(frame) if not frame or not frame.GetChildren then return end - - local children = {frame:GetChildren()} + + -- ☠☠ GUARD THE FUNCTION YOU CALL. The line above tests that the METHOD EXISTS; it + -- says nothing about whether CALLING it is permitted, and this call is the one place + -- in this function that was not pcall'd — despite the comment below promising + -- "everything here is pcall'd". GetChildren REFUSES to hand forbidden children to + -- tainted code and throws doing it: + -- bad argument #1 to '?' (Attempt to access forbidden object from code tainted by + -- an AddOn - Usage: local (scriptObject)* = self:GetChildren()) + -- ⚠ The IsForbidden check below cannot prevent this. A child can pass it and still + -- have forbidden children OF ITS OWN, so the throw happens one level down, on the + -- recursion at the end of the loop. + -- ☠ And per the note below, a throw here does not skip one child — it abandons the + -- whole PLAYER_ENTERING_WORLD settle callback, taking ApplyGlobalBindings, + -- RunBindingRepair("zone-in") and ResolveColdStartProfile with it. Reported 126x in + -- one session (mist, live 5.3.1), i.e. click-casting recovery failing on every + -- zone-in that frame set was touched. + local ok, children = pcall(function() return { frame:GetChildren() } end) + if not ok or type(children) ~= "table" then return end + for _, child in ipairs(children) do -- Everything here is pcall'd, and anything unreadable counts as "leave it -- alone". This walk recurses over EVERY child of every Blizzard frame we diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index d9654e79..654b490a 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -1295,6 +1295,34 @@ function DF:BuildDebuffFilterRecords(dbLike, claimed) return BuildDirectDebuffFilters(dbLike, claimed) end +-- ☠ HOW MANY GROUPS THE DEBUFF ROW WILL BUILD — i.e. what "Max Debuffs" actually +-- multiplies by. Blizzard caps at maxFrameCount PER AURA GROUP and the engine has NO +-- container-level cap (checked against Blizzard_AuraContainerGroups: maxFrameCount is +-- group state, nothing sums across groups), so a row built from N records can render up +-- to N x max. The row is split whenever the config needs per-group STYLING or mutually +-- exclusive category records — Show All with the Important Debuffs highlight on is THREE +-- groups, and that highlight is ON BY DEFAULT, so the stock configuration already has a +-- ceiling of 3x. Category mode reaches five or six. +-- +-- ⚠ THIS CANNOT BE FIXED BY BUDGETING THE GROUPS. Sharing one budget would need to know +-- how many auras each group will actually match, and the ALL-mode records are separated +-- only by candidate BOOLEANS (isBossOrRoleAura / isPriorityAura) which +-- C_UnitAuras.GetUnitAuraInstanceIDs cannot evaluate — all three carry the same filter +-- string, so a count would be identical for each. Dividing blindly under-shows the +-- common case (a unit with only ordinary debuffs would get max/3), and collapsing the +-- split to honour the cap silently deletes the highlight. Krathe's call, 2026-09-02: +-- never lose debuffs or functionality — SAY SO INSTEAD. +-- +-- Claims are deliberately NOT passed: an Aura Designer claim only ever REMOVES records, +-- so ignoring them yields the worst case, which is what a ceiling should report. Nil +-- records mean the show-all fallback, which is a single group. +function DF:GetDebuffRowGroupCount(dbLike) + if not dbLike then return 1 end + local ok, recs = pcall(BuildDirectDebuffFilters, dbLike, nil) + if not ok or type(recs) ~= "table" then return 1 end + return math.max(1, #recs) +end + -- Build defensive filter table (BIG_DEFENSIVE + EXTERNAL_DEFENSIVE, nil if unavailable) -- Assigned to the forward-declared local at the top of the file so it is -- visible to code defined above this point. @@ -2807,7 +2835,17 @@ function DF:BuildAuraRowConfig(db, prefix, opts) -- while the colorblindMode CVar is on (test mode previews it regardless). local dispel if prefix == "debuff" then - local colorByType = db.debuffBorderColorByType + -- ☠ THE COLOUR RING IS A BORDER, SO IT OBEYS THE BORDER TOGGLE. This read the + -- by-type flag on its own, so a user with Show Border OFF still got a coloured + -- ring on every dispellable debuff — green on poison, brown on bleed — with no + -- visible setting to turn it off, because the by-type checkbox lives INSIDE the + -- Border section and is out of reach while that section is off. The only escape + -- was to re-enable the border, untick by-type, and disable the border again + -- (undëe, live 5.3.1). + -- ⚠ THE SYMBOL IS DELIBERATELY NOT GATED. The colourblind dispel letter is text, + -- not border art, and shipped decoupled on purpose — someone running without + -- borders should still get it. Only the RING answers to debuffShowBorder. + local colorByType = db.debuffBorderColorByType and db.debuffShowBorder ~= false local showSymbol = db.debuffDispelSymbolEnabled == true if colorByType or showSymbol then dispel = { showWhenHarmful = true } @@ -3134,6 +3172,23 @@ end -- is "false" a re-parse that should have happened and did not. local function confirmRetarget(h, label, unit) if not (h and h.Refresh) then return end + -- ★ CONFIRM THE BINDING, NOT JUST THE PARSE. This only ever checked whether a + -- re-parse ran, which says nothing about WHICH UNIT it parsed. The container's + -- GetUnit returns a plain readable string (AuraContainerSharedMixin.unitToken), so + -- the one question that matters — is this row actually pointed at the player whose + -- frame it sits on — is answerable, and was simply never asked. + -- ⚠ Reported shape: a row or indicator showing a THIRD player's aura after roster + -- churn, healed only by /reload. A mismatch here names it outright instead of + -- leaving it to look like a filter fault. + local c = h.backend and h.backend.container + if c and c.GetUnit then + local okU, bound = pcall(c.GetUnit, c) + if okU and type(bound) == "string" and unit and bound ~= unit then + DF:DebugWarn("AURAROW", "%s: retarget MISMATCH - asked for %s, container is" + .. " bound to %s; this row is showing %s's auras", + label, tostring(unit), tostring(bound), tostring(bound)) + end + end local reparsed = h:Refresh() if reparsed then DF:Debug("AURAROW", "%s: retarget re-parsed on %s", label, tostring(unit)) diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index 4ff622a6..72b74a22 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -1042,12 +1042,59 @@ end -- worse story than one, and collapsing them would hide it. _pushOK == false is called out -- separately because a REFUSED push with a replay queued is expected in combat and is not -- the same fault as a push that was never made. +-- ★★★ THE VISIBILITY VERDICT IS DERIVED, NEVER STORED (2026-09-07). +-- +-- ☠☠ WHY: the registry is keyed by UNIT and the actuation was keyed by CONTAINER, and +-- containers are RETARGETED. SetUnitVisibilityLatched(unit, ...) only ever reached +-- handles whose config.unit / owner.unit was that unit AT THAT INSTANT, so a container +-- latched while pointed at unit A and retargeted to unit B before A's clear arrived was +-- never reached by A's clear — and Handle:SetUnit then re-seeded it from B, which during +-- a raid join is very likely also latched. The latch MIGRATED WITH THE CONTAINER instead +-- of staying with the unit, and no clear path could see it again: ReconcileLatches walks +-- the registry and calls the same unit-scoped function, and checkDarkMismatch only warns +-- in the LEAK direction (believed dark, not locked), never the BLANKING one. Only a +-- /reload recovered it, because a reload discards every handle. +-- ☠ FIELD, Krathe 2026-09-07: joined a raid from player housing; the gate trail shows +-- "visibility latch ON unit=raid9" (his OWN token) at 19:19:58, "OFF unit=raid9" at +-- 19:20:12, containers ping-ponging raid8<->raid9 four times in between and a five-way +-- cascade at 19:20:32 — and his Aura Designer icons stayed blank until he reloaded. +-- +-- ⇒ Nothing stores the verdict now. Every actuation ASKS the registry for the unit it is +-- CURRENTLY bound to, so a retarget cannot carry a stale answer and there is nothing left +-- to strand. `_visLatched` survives ONLY as a memo of what was last applied, to gate +-- redundant work — the same shape a peer uses (recompute the probe, memoise the write). +-- ★ It is the converse of [[unit_state_never_edged_on_frame_flags]], which we fixed in +-- August: that said an actuation keyed by UNIT needs an edge keyed by UNIT. This is the +-- half never checked — an edge keyed by UNIT needs an ACTUATION keyed by UNIT too. +-- +-- ⚠ SOURCE-RELATIVE ONLY (see the note above SetUnitVisibilityLatched before changing +-- this). The leak this guard closes is the PLAYER filter token failing open, which can +-- only affect pools whose filter names PLAYER; a plain HELPFUL or HARMFUL|RAID pool has +-- no caster term to fail. Krathe's session: 2 of his 10 container filters were +-- source-relative and all 10 were being darkened. +local function unitVisLatched(unit) + local reg = AuraContainer._invisibleUnits + return (unit and reg and reg[unit]) and true or nil +end +-- Group-container half: the handle's own token. +local function handleVisDark(h) + if not h or not h._idGateSourceRelative then return false end + return unitVisLatched(h.config and h.config.unit) and true or false +end +-- Slot half: slots inherit their unit from the shared owner, never from themselves. +local function slotVisDark(s) + if not s or not s._idGateSourceRelative then return false end + return unitVisLatched(s.owner and s.owner.unit) and true or false +end +AuraContainer._handleVisDark = handleVisDark +AuraContainer._slotVisDark = slotVisDark + local darkWarned = {} local function checkDarkMismatch() local parkCF = AuraContainer.SLOT_PARK_CF if not parkCF then return end for h in pairs(AuraContainer._slotHandles or {}) do - local dark = h.parked or h._deathLatched or h._visLatched + local dark = h.parked or h._deathLatched or slotVisDark(h) if dark and h._cfPushed ~= parkCF and not darkWarned[h.key] then darkWarned[h.key] = true DF:DebugWarn(DBG, @@ -1057,7 +1104,7 @@ local function checkDarkMismatch() tostring(h.key), tostring(h.owner and h.owner.unit), tostring(h.parked and true or false), tostring(h._deathLatched and true or false), - tostring(h._visLatched and true or false), + tostring(slotVisDark(h)), tostring(h._pushOK), h._pendingTuning and ", replay queued" or "") end @@ -6213,8 +6260,10 @@ function Handle:_applyVisibility() -- Post-demolition composition: consumer intent + the death latch. (The identity -- gate's hide and the cinematic latch used to sit here — see the demolition note -- above SetUnitDeathLatched.) + -- ☠ ASKED, NOT REMEMBERED — see unitVisLatched. self._visLatched is a memo of the + -- last application and must never be the authority here: a retarget would carry it. local want = (self._intendedShown ~= false) and not self._deathLatched - and not self._visLatched + and not handleVisDark(self) -- Respect the fake-data park (Edit Mode etc.): while parked, this handle is -- hidden regardless of intent/gate — otherwise a hover-deferred retry could -- ping-pong against the park's own deferred hide. @@ -6276,12 +6325,18 @@ end -- Same actuation, same re-parse on clear (a unit that was outside your world produced -- no aura events while it was away, so the standing parse is stale by definition). -- See AuraContainer.SetUnitVisibilityLatched for what drives it and why it exists. -function Handle:_setVisLatch(on) - on = on or nil - if self._visLatched == on then return end - self._visLatched = on +-- ⚠ THE ARGUMENT IS IGNORED ON PURPOSE. Callers used to pass the verdict they had just +-- computed for SOME unit; the only correct verdict is the one for the unit this handle is +-- bound to RIGHT NOW, so we recompute. Keeping the parameter means every existing caller +-- (SetUnitVisibilityLatched, Handle:SetUnit, the slot-owner retarget) becomes correct +-- without changing a single call site, and a caller that reasons about the wrong unit can +-- no longer poison this handle. +function Handle:_setVisLatch(_on) + local want = handleVisDark(self) or nil + if self._visLatched == want then return end + self._visLatched = want -- memo of what was applied, NOT the authority self:_applyVisibility() - if not on then self:Refresh() end + if not want then self:Refresh() end end -- ============================================================ @@ -8062,7 +8117,8 @@ function SlotHandle:_pushFilter() -- (The identity-gate terms that used to sit here — _gateHidden, _cineLatched, -- _pendingGateReparse — died with the gate; see the demolition note above -- SetUnitDeathLatched.) - local unitHidden = (self._deathLatched or self._visLatched) and true or false + -- ☠ ASKED, NOT REMEMBERED — see unitVisLatched above checkDarkMismatch. + local unitHidden = (self._deathLatched or slotVisDark(self)) and true or false local anchor = self.owner.anchor if anchor then pcall(anchor.SetShown, anchor, not unitHidden) end local dark = (self.parked or unitHidden) and true or false @@ -8179,10 +8235,11 @@ end -- way the death latch does (owner anchor + park string + the CF park lock, all through -- _pushFilter, whose dark->live transition carries the re-parse on clear). -- ⚠ A separate flag, NOT a second writer of _deathLatched — see the handle half. -function SlotHandle:_setVisLatch(on) - on = on or nil - if self._visLatched == on then return end - self._visLatched = on +-- ⚠ Argument ignored, exactly as in the handle half — see the note there. +function SlotHandle:_setVisLatch(_on) + local want = slotVisDark(self) or nil + if self._visLatched == want then return end + self._visLatched = want -- memo of what was applied, NOT the authority local ok = self:_pushFilter() if not ok or InCombatLockdown() then self._pendingTuning = true @@ -8249,7 +8306,7 @@ function SlotHandle:ApplyTuning(filter, candidateFilters, sortMethod, sortDirect -- _pushFilter's dark test. -- Through _cf(), never the raw value: _cf() carries BOTH the helper gate (a tuning -- pass on a gated-dark slot must not un-gate it) and the caster lock. - if candidatesChanged and not (self.parked or self._deathLatched or self._visLatched) then + if candidatesChanged and not (self.parked or self._deathLatched or slotVisDark(self)) then local cfOut = self:_cf() pcall(c.SetAuraSlotCandidateFilters, c, self.key, cfOut) if not InCombatLockdown() then self._cfPushed = cfOut end @@ -8469,6 +8526,13 @@ local function registerOwnerRegen(owner) AuraContainer._ownerRegen._owners = setmetatable({}, { __mode = "k" }) AuraContainer._ownerRegen:RegisterEvent("PLAYER_REGEN_ENABLED") AuraContainer._ownerRegen:SetScript("OnEvent", function(self) + -- ☠ Same commit-only-on-success rule as SetSlotOwnerUnit — read its note for + -- why an optimistic o.unit write is permanent. It is WORSE here: this drain + -- clears pendingUnit and drops the owner from the registry up front, so a + -- refusal used to lose the retarget outright with nothing left to retry it. + -- ⚠ Failures are re-queued AFTER the loop, never inside it: setting an + -- existing key to nil during a pairs() traversal is legal, ADDING one is not. + local requeue for o in pairs(self._owners) do self._owners[o] = nil local u = o.pendingUnit @@ -8476,14 +8540,26 @@ local function registerOwnerRegen(owner) -- Re-check: the owner may have been retargeted again, or torn down, -- between the defer and now. if u and o.container and o.unit ~= u then - o.unit = u - pcall(o.container.SetUnit, o.container, u) - -- The deferred retarget needs the same partition kick the immediate one - -- does, for the same reason. We are here on PLAYER_REGEN_ENABLED, so - -- reparseContainer takes its OOC branch and the bounce is real. - if not AuraContainer._testMode then reparseContainer(o.container) end + if pcall(o.container.SetUnit, o.container, u) then + o.unit = u + -- The deferred retarget needs the same partition kick the immediate one + -- does, for the same reason. We are here on PLAYER_REGEN_ENABLED, so + -- reparseContainer takes its OOC branch and the bounce is real. + if not AuraContainer._testMode then reparseContainer(o.container) end + else + -- o.unit stays on the OLD token, so the Factory's own per-pass + -- retarget walk will retry on its next sync — that is the fast + -- path back. This re-queue is the backstop for a frame the walk + -- does not reach. + requeue = requeue or {} + requeue[#requeue + 1] = o + o.pendingUnit = u + end end end + if requeue then + for i = 1, #requeue do self._owners[requeue[i]] = true end + end end) end AuraContainer._ownerRegen._owners[owner] = true @@ -8504,8 +8580,48 @@ function AuraContainer:SetSlotOwnerUnit(frame, unit) registerOwnerRegen(owner) return false end - owner.unit = unit + -- ☠☠ COMMIT owner.unit ONLY IF THE ENGINE TOOK THE RETARGET. This used to write it + -- BEFORE the call and ignore the result, which turns any transient SetUnit failure + -- into a PERMANENT desync — and the equality guard at the top of this function is + -- what makes it permanent. owner.unit already reads as the new token, so every later + -- call returns true without ever retrying, while the container is still bound to the + -- PREVIOUS occupant. It then renders that player's auras on this frame forever, and + -- misses this player's own, until a /reload. + -- ☠ Field shape, and it survived the 5.4.0 alpha: "happens every time I join raid, + -- to fix have to reload after everyone has joined" — Earth Shield drawn on a third + -- player who never had it, and a Riptide indicator not lighting for the player who + -- did (Beans, v5.4.0-alpha.3). Raid formation is a burst of retargets, so it only + -- takes one refusal to strand a frame, and out of combat a long-lived buff nobody + -- re-casts fires no UNIT_AURA to correct it. + -- ★ THE COMBAT BRANCH ABOVE ALREADY STATES THE RULE — "owner.unit is left on the OLD + -- token deliberately, so GetUnit stays truthful about what is on screen and a repeat + -- call simply re-queues rather than reporting a retarget that has not happened." That + -- is exactly right, and this path was the one place that did not honour it. + -- ⚠ Everything below is deliberately skipped on failure: the latch re-seed and the + -- reparse are both FOR THE NEW UNIT, and running them against a container still bound + -- to the old one would re-parse the wrong player and stamp the wrong latch state. local ok = pcall(owner.container.SetUnit, owner.container, unit) + if not ok then + DF:DebugWarn(DBG, "slot owner retarget REFUSED: %s -> %s (owner left on the old" + .. " token so a repeat call retries; the container is still bound to it)", + tostring(owner.unit), tostring(unit)) + return false + end + owner.unit = unit + -- ★★ READ THE ENGINE BACK. AuraContainerSharedMixin:GetUnit returns self.unitToken — + -- a PLAIN STRING, not a secret — so "did the retarget actually land" is one of the + -- few things about a container we can ask directly. Believing a write we never + -- checked is what let this class hide: the symptom (one player's auras drawn on + -- another's frame) looks like a filter fault and is nothing of the kind. + -- ⚠ A mismatch here is NOT recoverable by retrying — owner.unit is already committed + -- above and Blizzard's SetUnit no-ops when its own token already matches — so this + -- reports rather than repairs. If it ever fires, the repair is a container rebuild. + local okU, bound = pcall(owner.container.GetUnit, owner.container) + if okU and type(bound) == "string" and bound ~= unit then + DF:DebugWarn(DBG, "slot owner retarget MISMATCH: asked for %s, container is bound" + .. " to %s — this frame will render %s's auras until it is rebuilt.", + tostring(unit), tostring(bound), tostring(bound)) + end -- ⚠ The death latch is UNIT state, so a retarget re-seeds it from the registry the -- same way Handle:SetUnit does: the new unit may already be dead, and that edge -- will never fire again. _setDeathLatch is transition-gated and its push decides @@ -8771,11 +8887,28 @@ end -- last time. This latch closes the instance boundary. That is all it claims. -- -- ⚠ WHY A LATCH AND NOT A FILTER FIX: nothing in readable Lua can express "cast by me" --- — DoesAuraPassCandidateFilters has 13 fields and not one tests caster identity. When --- a unit is out of your world EVERY pool it renders is stale, not just source-relative --- ones; restricting the response to "mine" filters once left a cross-instance unit --- showing a full debuff row and dispel overlay while its buff bar was correctly blanked --- (Krathe, 2026-08-18). So the actuation is per UNIT, like the death latch. +-- — DoesAuraPassCandidateFilters has 13 fields and not one tests caster identity. +-- +-- ☠☠ THE "EVERY POOL IS STALE" ARGUMENT WAS OVERRULED ON 2026-09-07, DELIBERATELY, BY +-- KRATHE — read this before widening the scope back. It used to say: when a unit is out +-- of your world every pool it renders is stale, not just source-relative ones, and +-- restricting the response to "mine" filters once left a cross-instance unit showing a +-- full debuff row and dispel overlay while its buff bar was correctly blanked +-- (2026-08-18). That observation stands and is NOT retracted. What changed is the price: +-- * The blanket response fires on OUT-OF-RENDER-RANGE group members, not only +-- cross-instance ones — measured, Krathe 2026-09-07: 17 raid units latched in one +-- tick while their containers were still reporting live helpful counts (raid18 held +-- 8 buffs throughout). "Out of your world" is not what UnitIsVisible answers. +-- * The leak itself is source-relative BY CONSTRUCTION. The PLAYER-token partition +-- broke 5 times in that session and EVERY one carried vis=0, while the same probe +-- ran 32 times at vis=1 without a break — but a pool with no caster term has nothing +-- to fail. 2 of Krathe's 10 container filters were source-relative; all 10 went dark. +-- ⇒ A stale debuff row on a genuinely cross-instance unit is a smaller, self-correcting +-- harm than a healer's whole Aura Designer going blank on a raid join. If the 2026-08-18 +-- symptom returns and matters more than this one, the switch is _idGateSourceRelative in +-- handleVisDark/slotVisDark — not a rewrite. +-- ⚠ The actuation stays per UNIT for the DEATH latch, which has no such narrowing: death +-- really does freeze every pool, with no aura event to follow. AuraContainer._invisibleUnits = AuraContainer._invisibleUnits or {} function AuraContainer.SetUnitVisibilityLatched(unit, on) @@ -8853,6 +8986,29 @@ function AuraContainer.ReconcileLatches(reason) end end + -- ★★ RE-ASK EVERY CONTAINER (2026-09-07). Clearing the registry above is not enough + -- on its own: the loops in SetUnitVisibilityLatched only reach containers bound to + -- that unit, so one stranded by a retarget was never visited by ANY clear path and + -- only a /reload recovered it (Krathe, raid join from housing). Both setters are now + -- recompute-and-memo, so calling them unconditionally is a cheap idempotent re-ask: + -- a container that already agrees returns on the memo check without touching the + -- engine, and one that has drifted heals here instead of at the next reload. + -- ⚠ This is the BLANKING direction, which had no detector at all — checkDarkMismatch + -- only ever warned about "believed dark but not locked", the leak. A container held + -- dark for a unit that is not latched is the failure the user actually reports, and + -- it was the one thing nothing looked for. + local healed = 0 + for h in pairs(AuraContainer._handles or {}) do + if not h._destroyed and h._visLatched and not handleVisDark(h) then + healed = healed + 1 + end + pcall(function() h:_setVisLatch() end) + end + for s in pairs(AuraContainer._slotHandles or {}) do + if s._visLatched and not slotVisDark(s) then healed = healed + 1 end + pcall(function() s:_setVisLatch() end) + end + -- Silent when there was nothing to do: this runs on every zone-in and every combat -- drop, and a line per run would drown the trail it shares with the latch -- transitions. A line here means a latch had genuinely gone stale — which is a bug @@ -8860,6 +9016,13 @@ function AuraContainer.ReconcileLatches(reason) if cleared > 0 then GateLog("reconcile (%s): cleared %d stale latch(es)", reason or "sweep", cleared) end + if healed > 0 then + DF:DebugWarn(DBG, "reconcile (%s): %d container(s) were held DARK for a unit that" + .. " is not latched — a retarget stranded the verdict. Healed. This is the" + .. " blanking failure class (auras missing until /reload); if it recurs, the" + .. " actuation has found another way to outlive its unit.", + tostring(reason or "sweep"), healed) + end end -- /df debug idgate — identity-gate ground truth: EVERY handle (not just the @@ -9025,9 +9188,10 @@ function AuraContainer.DebugDumpIdentityGate() local byOwner = {} for h in pairs(AuraContainer._slotHandles or {}) do total = total + 1 - local dark = h.parked or h._deathLatched or h._visLatched + local visDark = AuraContainer._slotVisDark(h) + local dark = h.parked or h._deathLatched or visDark if h.parked then parked = parked + 1 end - if h._deathLatched or h._visLatched then latched = latched + 1 end + if h._deathLatched or visDark then latched = latched + 1 end if not dark then live = live + 1 end local ow = h.owner if ow then byOwner[ow] = (byOwner[ow] or 0) + 1 end diff --git a/DandersFrames/Frames/Headers.lua b/DandersFrames/Frames/Headers.lua index 8cf1e5ce..1ebc7857 100755 --- a/DandersFrames/Frames/Headers.lua +++ b/DandersFrames/Frames/Headers.lua @@ -8799,16 +8799,46 @@ headerChildEventFrame:SetScript("OnEvent", function(self, event, arg1) end -- INCOMING_RESURRECT_CHANGED: Update resurrection icon + -- + -- ☠☠ THE PAYLOAD IS DELIBERATELY IGNORED. This used to route the update to + -- unitFrameMap[arg1] -- the one frame whose token matches the event's -- and that + -- is the one status icon where a single missed update is PERMANENT. + -- + -- Why permanent: UpdateResurrectionIcon's only SHOW driver is this event. Its + -- resCache "pending accept" (yellow) state is derived from having previously + -- OBSERVED the cast (resCache[unit] == 1), so a frame that never saw the casting + -- edge cannot reach either state -- it falls through to Hide(). ResTimerCleanup + -- only ever hides; it never discovers. The only recovery is UpdateAllStatusIcons, + -- reachable from a full-frame refresh or a combat transition -- so inside an M+ + -- pull or a boss fight there is NO recovery at all, which is exactly the reported + -- envelope ("sometimes", "in M+", "all of my BRes"). + -- + -- Why the lookup can miss: the payload is typed UnitTokenVariant (retail dump, + -- UnitDocumentation.lua:3497) -- the token that arrives is not guaranteed to be the + -- token unitFrameMap is keyed by. party vs raid vs player naming for the same + -- player is the obvious case; a frame mid-reassignment is another. + -- + -- ⚠ So DO NOT "optimise" this back into a map lookup. Blizzard's own + -- CompactUnitFrame registers this with a plain RegisterEvent and calls + -- CompactUnitFrame_UpdateCenterStatusIcon(self) WITHOUT reading the payload, i.e. + -- every frame refreshes itself. This mirrors that. It is affordable because the + -- event is rare -- it fires when a resurrection starts or stops on a group member, + -- not on a timer -- and each call is a cheap state read. if event == "INCOMING_RESURRECT_CHANGED" then - local unit = arg1 - if unit then - local frame = unitFrameMap[unit] - if frame and frame.dfEventsEnabled ~= false then - if DF.UpdateResurrectionIcon then DF:UpdateResurrectionIcon(frame) end + if DF.UpdateResurrectionIcon then + if DF.IterateAllFrames then + DF:IterateAllFrames(function(frame) + if frame.unit and frame.dfEventsEnabled ~= false then + DF:UpdateResurrectionIcon(frame) + end + end) end - local pinnedFrame = FindPinnedFrameForUnit(unit) - if pinnedFrame then - if DF.UpdateResurrectionIcon then DF:UpdateResurrectionIcon(pinnedFrame) end + -- ☠ DOT, NOT COLON, and a separate walk: IterateAllFrames has no pinned + -- arm, so pinned frames would keep a stale icon for the whole fight. + if DF.IteratePinnedFrames then + DF.IteratePinnedFrames(function(frame) + if frame.unit then DF:UpdateResurrectionIcon(frame) end + end) end end return diff --git a/DandersFrames/Frames/Icons.lua b/DandersFrames/Frames/Icons.lua index 55237942..61b8655f 100644 --- a/DandersFrames/Frames/Icons.lua +++ b/DandersFrames/Frames/Icons.lua @@ -208,24 +208,50 @@ end -- ⚠ Its SINGULAR sibling above, DF:UpdateExternalDefIcon, is a different matter and -- stays: that one has seven real call sites. --- Update auras on all frames (used when entering/leaving combat) +-- Update auras on all frames (combat transitions and the full profile refresh). +-- +-- ☠☠ CHUNKED, and the reason is a HARD FAILURE, not a hitch. Run synchronously this +-- walks every party, raid and pinned frame and rebuilds the whole Aura Designer +-- signature set for each — placedCoSig/placedStructSig per indicator per member group, +-- string-built. On a full raid with a busy Aura Designer profile that is enough for +-- Blizzard's watchdog to fire "script ran too long", and the watchdog does not warn, it +-- ABORTS THE EXECUTION. Field report 2026-08-30, via an auto-profile switch: +-- Factory.lua:890 script ran too long +-- ... SyncFrame -> UpdateAuras -> IterateRaidFrames -> UpdateAllAuras +-- -> FullProfileRefresh -> ApplyRuntimeProfile -> EvaluateAndApply +-- Everything in FullProfileRefresh AFTER this call — the rested indicator, name +-- truncation, the rest of the pass — never ran. A profile switch could half-apply and +-- leave frames stale with nothing to re-drive them. +-- +-- ⚠ THE WATCHDOG MEASURES ONE EXECUTION, so splitting the walk across frames removes the +-- failure outright rather than just making it less likely. Same shape as +-- AuraContainer._kickLiveParse (read its header): the work list is SNAPSHOTTED up front +-- because the iterators walk live registries that churn, each frame is RE-VALIDATED at +-- execution time, and a generation token lets a newer call supersede a pending one +-- instead of two walks interleaving. +-- +-- ⚠ The caller returns immediately now. That is the point — FullProfileRefresh completes +-- and the aura pass lands over the next few frames. Nothing downstream reads aura state +-- back out of this call; both call sites treat it as fire-and-forget. +local AURA_CHUNK = 8 -- frames refreshed per tick +local updateAllGen = 0 + function DF:UpdateAllAuras() - local function updateFrame(frame) - if frame and frame:IsShown() then - DF:UpdateAuras(frame) - end + local work = {} + local function collect(frame) + if frame then work[#work + 1] = frame end end - + -- Party frames via iterator if DF.IteratePartyFrames then - DF:IteratePartyFrames(updateFrame) + DF:IteratePartyFrames(collect) end - + -- Raid frames via iterator if DF.IterateRaidFrames then - DF:IterateRaidFrames(updateFrame) + DF:IterateRaidFrames(collect) end - + -- Pinned frames if DF.PinnedFrames and DF.PinnedFrames.initialized and DF.PinnedFrames.headers then for setIndex = 1, (DF.PinnedFrames.MAX_SETS or 4) do @@ -234,7 +260,7 @@ function DF:UpdateAllAuras() for i = 1, 40 do local child = header:GetAttribute("child" .. i) if child then - updateFrame(child) + collect(child) end end end @@ -247,10 +273,33 @@ function DF:UpdateAllAuras() local frames = DF.PinnedFrames.bossFrames[setIndex] if frames then for i = 1, 8 do - updateFrame(frames[i]) + collect(frames[i]) end end end end + + if #work == 0 then return end + + updateAllGen = updateAllGen + 1 + local gen, i = updateAllGen, 0 + local function step() + -- A newer call has taken over: drop this walk rather than interleave two. + if gen ~= updateAllGen then return end + local stop = math.min(i + AURA_CHUNK, #work) + while i < stop do + i = i + 1 + local frame = work[i] + -- ⚠ RE-VALIDATED HERE, not at snapshot time: a frame can be recycled, + -- hidden or retargeted between chunks, and IsShown was always the gate. + if frame:IsShown() then + DF:UpdateAuras(frame) + end + end + if i < #work then C_Timer.After(0, step) end + end + -- First chunk runs INLINE, so the common small-group case (a 5-man is one chunk) + -- behaves exactly as it did before and nothing is deferred that never needed to be. + step() end diff --git a/DandersFrames/Frames/Update.lua b/DandersFrames/Frames/Update.lua index fd663128..73864144 100644 --- a/DandersFrames/Frames/Update.lua +++ b/DandersFrames/Frames/Update.lua @@ -30,6 +30,9 @@ local UnitPower = UnitPower local UnitPowerMax = UnitPowerMax local UnitIsUnit = UnitIsUnit local UnitIsVisible = UnitIsVisible +-- ⚠ Localised like its neighbours: the visibility-latch edge below reads it twice per +-- full update per unit, and this file's header calls that out as a per-tick path. +local UnitGUID = UnitGUID -- ★ LATCH REGISTRIES, cached as upvalues. Frames\AuraContainer.lua is line 96 of the -- .toc and this file is line 99, so both tables exist by now; each is created once with -- `X = X or {}` and never replaced, so holding the reference is safe. @@ -564,7 +567,26 @@ function DF:UpdateUnitFrame(frame, source) -- "player"` silently never matched your own frame. if DF.AuraContainer and DF.AuraContainer.SetUnitVisibilityLatched and UnitExists(unit) then local invisible = false - if not UnitIsUnit(unit, "player") then + -- ☠☠ THE SELF-EXEMPTION WAS A STATE TEST, AND IT RACED. `UnitIsUnit(unit, + -- "player")` answers FALSE for your OWN raidN token in the window after a reload + -- where UnitExists is already true and the roster has not resolved — so the + -- branch ran on yourself and latched you. Field, Krathe 2026-09-07: "--- UI + -- Reload ---" at 19:19:57, "visibility latch ON unit=raid9" — his own token — at + -- 19:19:58, one second later. Joining a raid is exactly when this window opens. + -- ⇒ Require the identity to have RESOLVED before trusting any answer, and treat + -- unresolved as "not latchable". Two GUIDs that both read back as plain strings + -- are settled; anything else (nil during roster build, a secret under identity + -- restriction, a pcall failure) leaves the unit SHOWN, which is this latch's + -- standing rule — blanking a healthy player is worse than the leak it closes. + -- ⚠ issecretvalue FIRST and as its own statement, on BOTH values: UnitGUID is + -- SecretWhenUnitIdentityRestricted, so comparing two of them unguarded throws. + local okMine, myGUID = pcall(UnitGUID, "player") + local okThem, uGUID = pcall(UnitGUID, unit) + local mineSecret = issecretvalue and issecretvalue(myGUID) or false + local themSecret = issecretvalue and issecretvalue(uGUID) or false + local settled = okMine and okThem and not mineSecret and not themSecret + and type(myGUID) == "string" and type(uGUID) == "string" + if settled and uGUID ~= myGUID then local okv, vis = pcall(UnitIsVisible, unit) local secret = issecretvalue and issecretvalue(vis) or false if okv and not secret and not vis then invisible = true end diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index dfd39d99..cb4312ee 100644 --- a/DandersFrames/Locales/enUS.lua +++ b/DandersFrames/Locales/enUS.lua @@ -1288,6 +1288,9 @@ L["Matched (not applied)"] = true L["Max Bars"] = true L["Max Buffs"] = true L["Max Debuffs"] = true +-- ⚠ TWO FORMAT PLACEHOLDERS, IN ORDER: the number of category groups, then the resulting +-- ceiling (groups x the slider). A translation must keep both %d and their order. +L["The game applies this limit to each category separately. Your filters use %d categories, so up to %d debuffs can show at once."] = true L["Max Health"] = true L["Max HP"] = true L["Max HP Reduction %"] = true diff --git a/DandersFrames_Options/GUI/Pages/Indicators.lua b/DandersFrames_Options/GUI/Pages/Indicators.lua index 090c22cc..c603289b 100644 --- a/DandersFrames_Options/GUI/Pages/Indicators.lua +++ b/DandersFrames_Options/GUI/Pages/Indicators.lua @@ -1826,8 +1826,48 @@ function DF._SetupGUIPagesPart4(GUI, CreateCategory, CreateSubTab, BuildPage, L, DF:RefreshAllVisibleFrames() end), 30) end - local debuffMax = group:AddWidget(GUI:CreateSlider(parent, L["Max Debuffs"], 0, 8, 1, db, "debuffMax", nil, function() DF:RefreshAllVisibleFrames() end, true), 55) + -- ☠ THE CAP IS PER CATEGORY GROUP, NOT PER ROW, and that is an engine limit + -- we cannot close: Blizzard caps at maxFrameCount per aura group with no + -- container-level total, and the groups the row splits into cannot share a + -- budget (see DF:GetDebuffRowGroupCount for why counting them is impossible). + -- Reported as a bug — "set it to a maximum of 3, it shows 4 or more" — with + -- Show All and the stock settings, where the Important Debuffs highlight + -- already makes three groups. + -- ⚠ So the note states the REAL ceiling rather than the addon quietly + -- under-showing or dropping the highlight to make the number true. It is + -- recomputed on every state refresh because the group count moves with the + -- category checkboxes and the highlight toggle, and it stays silent at one + -- group, where the number means exactly what it says. + -- ⚠ tools2.refreshStates, NOT self:RefreshStates — this group is also built + -- into a popout, where the reflow callback is the right one (see the + -- Visibility popout's `refreshStates = reflow`). The note below has to + -- re-run when the number moves, or it would keep quoting the old ceiling. + local debuffMax = group:AddWidget(GUI:CreateSlider(parent, L["Max Debuffs"], 0, 8, 1, db, "debuffMax", nil, function() + DF:RefreshAllVisibleFrames() + tools2.refreshStates() + end, true), 55) debuffMax.disableOn = function(d) return not d.showDebuffs end + + -- ⚠ hideOn for VISIBILITY, refreshContent for TEXT — the two hooks the page + -- walker actually supports, and they are not interchangeable here. + -- RefreshChildStates only calls refreshContent on a widget that IS SHOWN, so + -- hiding this from inside refreshContent would freeze it hidden forever. + -- LayoutChildren evaluates hideOn first, so the pair composes correctly. + local function debuffGroupCount(d) + return (DF.GetDebuffRowGroupCount and DF:GetDebuffRowGroupCount(d)) or 1 + end + local maxNote = group:AddWidget(GUI:CreateNote(parent, "", { tone = "caution", prefix = "Note" }), 30) + maxNote.hideOn = function(d) + return not d.showDebuffs or (tonumber(d.debuffMax) or 0) <= 0 + or debuffGroupCount(d) <= 1 + end + maxNote.refreshContent = function(w, d) + local n = debuffGroupCount(d) + local per = tonumber(d.debuffMax) or 0 + w:SetText(("|c%s%s:|r "):format(GUI:ToneHex("caution"), (L and L["Note"]) or "Note") + .. L["The game applies this limit to each category separately. Your filters use %d categories, so up to %d debuffs can show at once."]:format(n, n * per)) + end + maxNote:refreshContent(db) end -- What the whole page's gate costs when it moves, named once: the state pass,