From 9b49d1d0b812182f29dfb654b31e220ff60e97ce Mon Sep 17 00:00:00 2001 From: Krathe Date: Tue, 1 Sep 2026 19:29:46 +0100 Subject: [PATCH 1/7] UpdateAllAuras: chunk the walk, so a profile switch cannot be aborted mid-apply Field report: "Factory.lua:890 script ran too long", through SyncFrame -> UpdateAuras -> IterateRaidFrames -> UpdateAllAuras -> FullProfileRefresh -> ApplyRuntimeProfile, i.e. an auto-profile switch. Run synchronously this walks every party, raid and pinned frame and rebuilds the whole Aura Designer signature set for each -- placedCoSig and placedStructSig per indicator per member group, all string-built. On a full raid with a busy Aura Designer profile that is enough to trip Blizzard's watchdog, and the watchdog does not warn, it ABORTS the execution. Everything in FullProfileRefresh after this call never ran, so a profile switch could half-apply and leave frames stale with nothing to re-drive them. The watchdog measures a single execution, so splitting the walk across frames removes the failure rather than making it less likely. Same shape as AuraContainer._kickLiveParse: snapshot the work list up front because the iterators walk registries that churn, re-validate each frame at execution time, and use a generation token so a newer call supersedes a pending one instead of two walks interleaving. The first chunk runs inline, so a 5-man is one chunk and behaves exactly as before. Not a regression from the current aura work: every function in the reported stack was last touched between 10 July and 12 August, and none of today's Factory changes are in that chain. (cherry picked from commit 8bc2288b656d2f6d53466dc93403ca6eee7d794c) (cherry picked from commit 10f36a085339b64da406fa7c302490384ca9542d) --- DandersFrames/Frames/Icons.lua | 73 ++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/DandersFrames/Frames/Icons.lua b/DandersFrames/Frames/Icons.lua index 552379424..61b8655ff 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 From 73a1bdce2d5f6c4ace47e8c7e53c43f59381f982 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 2 Sep 2026 18:03:24 +0100 Subject: [PATCH 2/7] Two live 5.3.1 reports: the dispel ring ignoring Show Border, and a fatal unguarded GetChildren Dispel colour ring (undee): the by-type flag was read on its own, so a user with Show Border OFF still got a coloured ring on every dispellable debuff, with no reachable setting to turn it off -- the by-type checkbox lives inside the Border section and is out of reach while that section is off. Their only escape was to re-enable the border, untick by-type, and disable the border again. The ring is border art, so it now obeys debuffShowBorder. The colourblind SYMBOL stays ungated on purpose: it is text, not border art, and shipped decoupled deliberately. PropagateMouseOnChildren (mist, 126x in one session): frame:GetChildren() was the one call in that function not pcall'd, while the comment directly under it promised "everything here is pcall'd". The guard above tests that the METHOD EXISTS, which says nothing about whether calling it is permitted -- GetChildren refuses to hand forbidden children to tainted code and throws doing it. The IsForbidden check cannot prevent this: a child passes it and can still have forbidden children of its own, so the throw lands one level down on the recursion. Per that function's own note, a throw there does not skip one child, it abandons the PLAYER_ENTERING_WORLD settle callback and takes ApplyGlobalBindings, RunBindingRepair("zone-in") and ResolveColdStartProfile with it -- so click-casting recovery was failing on every zone-in that touched those frames. Both parse clean, ENV-SAME, CRLF intact. (cherry picked from commit 562b85c1792865bfadc3ebb481acc493ae977860) --- DandersFrames/ClickCasting/Frames.lua | 21 +++++++++++++++++++-- DandersFrames/Features/Auras.lua | 12 +++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/DandersFrames/ClickCasting/Frames.lua b/DandersFrames/ClickCasting/Frames.lua index 987ab5521..fa3819d4c 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 d9654e79d..20b7bcc25 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -2807,7 +2807,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 } From 2e544104963f861df6720c63b820f3b1d4ae2d57 Mon Sep 17 00:00:00 2001 From: Krathe Date: Wed, 2 Sep 2026 18:47:47 +0100 Subject: [PATCH 3/7] Max Debuffs: say what the number really means instead of quietly breaking it Reported as "set it to a maximum of 3, it shows 4 or more" on Show All with stock settings. Confirmed: Blizzard caps at maxFrameCount PER AURA GROUP and the engine has no container-level total, so a row split into N records renders up to N x max. The Important Debuffs highlight splits Show All into three groups and is ON BY DEFAULT, so the stock config already has a 3x ceiling; category mode reaches five or six. It cannot be budgeted away. Sharing one allowance would need to know how many auras each group will match, and the Show All records are separated only by candidate BOOLEANS which GetUnitAuraInstanceIDs cannot evaluate -- all three carry the same filter string, so a count returns the same number for each. Dividing blindly under-shows the common case, and collapsing the split to make the number true silently deletes the highlight. Krathe's call: never lose debuffs or functionality, so state the real ceiling. DF:GetDebuffRowGroupCount reports the worst case from settings alone (claims only ever remove records), and the Max Debuffs slider now carries a note giving the category count and the resulting maximum. It stays silent at one group, where the number means exactly what it says. hideOn for visibility and refreshContent for the text, because RefreshChildStates only calls refreshContent on a shown widget -- hiding from inside it would have frozen the note hidden. Refresh goes through tools2.refreshStates, not self:RefreshStates, since this group is also built into a popout. (cherry picked from commit c93a47b31bba4507a3848635d57316f94c0e1676) --- DandersFrames/Features/Auras.lua | 28 +++++++++++++ DandersFrames/Locales/enUS.lua | 3 ++ .../GUI/Pages/Indicators.lua | 42 ++++++++++++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index 20b7bcc25..aee6003f8 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. diff --git a/DandersFrames/Locales/enUS.lua b/DandersFrames/Locales/enUS.lua index dfd39d999..cb4312ee0 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 090c22ccf..c603289b7 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, From ce4f36524cc8fe10f982ddc9a95fbdfa567fd747 Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 3 Sep 2026 02:41:04 +0100 Subject: [PATCH 4/7] Retargets: commit the unit only if the engine took it, and read the binding back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report, still present on v5.4.0-alpha.3: "happens every time I join raid, to fix have to reload after everyone has joined" -- Earth Shield drawn on a player who never had it, and a Riptide indicator not lighting for the player who did. Only while the raid is forming; a reload clears it. Two places wrote the new unit BEFORE calling SetUnit and ignored the result. SetSlotOwnerUnit guards on `owner.unit == unit` at the top, so an optimistic write turns any refusal into a PERMANENT desync: the owner reads as retargeted, every later call short-circuits, and the container stays bound to the previous occupant until a reload. The regen drain had it worse -- it clears pendingUnit and drops the owner from the registry first, so a refusal lost the retarget outright. Both now commit only on success. On failure the old token is left in place, which is exactly what the combat branch already documented ("owner.unit is left on the OLD token deliberately, so GetUnit stays truthful ... and a repeat call simply re-queues"); the OOC path was the one place not honouring its own contract. The drain re-queues after the loop, never inside it -- adding a key during a pairs() traversal is not legal, only nil-ing an existing one is. Also reads the engine back. AuraContainerSharedMixin:GetUnit returns a plain string, not a secret, so "did the retarget land" is directly answerable and was simply never asked -- on the slot path and in confirmRetarget, which until now only checked that A parse happened and not which unit it parsed. A mismatch is reported, not repaired: the unit is already committed and Blizzard's SetUnit no-ops when its own token matches, so the cure would be a rebuild. ⚠ Candidate fix plus a detector, NOT a confirmed diagnosis. SetUnit is plain mixin state and rarely refuses, so these may be latent rather than the reported cause. The read-back is the part that settles it: if this recurs, the log names the wrong unit instead of leaving it looking like a filter fault. (cherry picked from commit 5a780c35d71e17631587bb41842810a981f92505) --- DandersFrames/Features/Auras.lua | 17 ++++++ DandersFrames/Frames/AuraContainer.lua | 73 +++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/DandersFrames/Features/Auras.lua b/DandersFrames/Features/Auras.lua index aee6003f8..654b490ad 100644 --- a/DandersFrames/Features/Auras.lua +++ b/DandersFrames/Features/Auras.lua @@ -3172,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 4ff622a6e..a3f673020 100644 --- a/DandersFrames/Frames/AuraContainer.lua +++ b/DandersFrames/Frames/AuraContainer.lua @@ -8469,6 +8469,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 +8483,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 +8523,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 From ab3a0fa2d6648d7884e08d806a46a9ebce7cb01a Mon Sep 17 00:00:00 2001 From: Krathe Date: Thu, 3 Sep 2026 19:53:21 +0100 Subject: [PATCH 5/7] Click casting: reach the item APIs through C_Item, not the removed globals GetItemSpell and GetItemCount live in Blizzard_DeprecatedItemScript, which 12.1.5 removes outright. Both were exact 1:1 forwards to C_Item, and both were already dead for anyone running without loadDeprecationFallbacks, so the item pickers in CC:GetSlotItemInfo and CC:GetItemInfoById could error on a nil global today. CC:GetItemCount fell back to the alias of the very function it had just called, so the fallback is dropped rather than renamed. (cherry picked from commit 2d40a8a215a6431582d35192788e6f45218d21c8) --- DandersFrames/ClickCasting/Bindings.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DandersFrames/ClickCasting/Bindings.lua b/DandersFrames/ClickCasting/Bindings.lua index 50d256dc3..c0a972b1e 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 From 657a8b429bf2ff32000d6e6105424838dff0921d Mon Sep 17 00:00:00 2001 From: Krathe Date: Mon, 7 Sep 2026 19:06:07 +0100 Subject: [PATCH 6/7] Resurrection icon: refresh every frame on the event, not the one the payload names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on live 5.3.1: the RES text intermittently does not appear in M+ or raid, while the corpse still shows Blizzard's own res glow and the target can accept. One reporter saw it for every battle rez they cast. What is proven from the code: * INCOMING_RESURRECT_CHANGED is the ONLY show driver for this icon. The "pending accept" (yellow) state is derived from having previously observed the cast -- resCache[unit] == 1 -- so a frame that never saw the casting edge can reach neither state and 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. Inside an M+ pull or a boss fight neither happens, so a single missed update lasts the whole fight -- which is the envelope every report describes. So this icon is the one where dropping one update is permanent, and it was the only status icon routing that update through a single unitFrameMap[arg1] lookup. The payload is typed UnitTokenVariant (retail dump, UnitDocumentation.lua:3497): the token delivered is not guaranteed to be the token the map is keyed by. Blizzard's own CompactUnitFrame registers this event with a plain RegisterEvent and calls CompactUnitFrame_UpdateCenterStatusIcon(self) without reading the payload at all -- every frame refreshes itself. Mirror that: walk all frames, plus a separate pinned walk since IterateAllFrames has no pinned arm. The event fires when a resurrection starts or stops on a group member, not on a timer, so the sweep is affordable. ⚠ This removes the class of failure, not a confirmed trigger. I have not reproduced the miss in game and this does not claim to name it. Two other threads stay open: whether an instant battle rez presents an observable casting edge at all, and that GetStatusIconFadeAlpha multiplies this icon by fadeDeadIcons -- an icon which by definition only ever exists on a dead unit. --- DandersFrames/Frames/Headers.lua | 46 ++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/DandersFrames/Frames/Headers.lua b/DandersFrames/Frames/Headers.lua index 8cf1e5ce2..1ebc7857a 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 From f7a20c6b9c455df028a99205cf65c6c548d7e9f3 Mon Sep 17 00:00:00 2001 From: Krathe Date: Mon, 7 Sep 2026 20:12:43 +0100 Subject: [PATCH 7/7] Visibility latch: ask the registry, never remember the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report, Krathe 2026-09-07: joined a raid from player housing; every Aura Designer icon vanished -- including on his OWN frame -- and did not come back until a reload. The park was clean (not one "believed dark but not locked" warning in 5,043 entries). It was this latch. Three faults, three fixes. 1. THE SELF-EXEMPTION WAS A STATE TEST, AND IT RACED Frames/Update.lua gated on UnitIsUnit(unit, "player"). One second after a reload, with the roster still building, that answers FALSE for your own raidN token while UnitExists is already true and UnitIsVisible already false -- so the branch ran on the player and latched him. The trail: 19:19:57 --- UI Reload --- 19:19:58 visibility latch ON unit=raid9 <- his own token Now it requires the identity to have RESOLVED: two GUIDs that both read back as plain strings. Anything else -- nil during roster build, a secret under identity restriction, a pcall failure -- leaves the unit shown, which is this latch's standing fail-safe rule. UnitGUID is localised like its neighbours; the edge is on a per-tick path. 2. THE VERDICT WAS STORED IN THE CONTAINER, AND CONTAINERS ARE RETARGETED This is the one that made it permanent. The registry is keyed by UNIT; the actuation was keyed by whichever container pointed at that unit AT THAT INSTANT. SetUnitVisibilityLatched(unit, nil) therefore could not reach a container that had since moved -- and Handle:SetUnit re-seeded it from its NEW unit, which during a raid join is very likely also latched. The verdict migrated with the container instead of staying with the unit. 19:20:12 debuff: retarget raid8 -> raid9 19:20:16 defensive: retarget raid9 -> raid8 19:20:18 defensive: retarget raid8 -> raid9 19:20:32 raid2->raid1, raid3->raid2, raid4->raid3, raid5->raid4 ... Nothing could recover it: ReconcileLatches walks the registry and calls the same unit-scoped function, and checkDarkMismatch only ever warned in the LEAK direction. A reload worked because it discards every handle. Nothing stores the verdict now. Every actuation asks the registry for the unit it is bound to RIGHT NOW (unitVisLatched / handleVisDark / slotVisDark); _visLatched survives only as a memo of the last application, to gate redundant work. Both _setVisLatch setters ignore their argument and recompute, so every existing caller became correct without a call-site change and none can poison a handle by reasoning about the wrong unit. ReconcileLatches now re-asks every handle and slot -- idempotent, since a container that agrees returns on the memo check -- and WARNS when it finds one held dark for a unit that is not latched. That direction had no detector at all, which is why the log showed nothing. This is the converse of the August death-latch fix: that established that an actuation keyed by UNIT needs an edge keyed by UNIT. The half never checked was that an edge keyed by UNIT needs an ACTUATION keyed by UNIT. 3. IT DARKENED POOLS THE LEAK CANNOT REACH The guard exists for the PLAYER filter token failing open, which can only affect pools whose filter names PLAYER. Krathe's session: 2 of 10 container filters were source-relative; all 10 were darkened. The two halves both come out of his log -- the PLAYER-token partition broke 5 times and every one carried vis=0, while the same probe ran 32 times at vis=1 without a break; and 17 raid units latched in a single tick while their containers still reported live helpful counts (raid18 held 8 throughout). So the trigger is real and the scope was not. handleVisDark/slotVisDark gate on _idGateSourceRelative -- a flag this file already computed in five places and read in none outside the debug dump. ☠ This overrules a documented 2026-08-18 decision, at Krathe's explicit direction. That note said every pool on a unit outside your world is stale, not just source-relative ones, and narrowing once left a cross-instance unit showing a stale debuff row. It is not retracted -- the comment above SetUnitVisibilityLatched now carries both sides and names the switch to reverse it. What changed is the price: UnitIsVisible goes false for out-of-RENDER-range group members, not only cross-instance ones, so the blanket response fires constantly in a raid. The death latch is untouched and keeps its per-unit blanket scope: death really does freeze every pool, with no aura event to follow. ⚠ Not verified in game. Reproduce by joining a raid from an instance. --- DandersFrames/Frames/AuraContainer.lua | 147 +++++++++++++++++++++---- DandersFrames/Frames/Update.lua | 24 +++- 2 files changed, 149 insertions(+), 22 deletions(-) diff --git a/DandersFrames/Frames/AuraContainer.lua b/DandersFrames/Frames/AuraContainer.lua index a3f673020..72b74a22e 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 @@ -8830,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) @@ -8912,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 @@ -8919,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 @@ -9084,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/Update.lua b/DandersFrames/Frames/Update.lua index fd663128d..738641440 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