From 75d6667dbd13dd36c5d95ee97b8747b0f4ba245f Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Sat, 31 Oct 2026 20:51:24 -0400 Subject: [PATCH] Use Nampower more fully; fix timer frame leak and persist SS cooldown - Add SPELL_GO_OTHER (spellId-filtered) to record soulstones cast by group members from exact caster/target GUIDs, silently, without changing the SSTracker addon-message protocol. - Classify curses/soulstone by spell name via GetSpellNameAndRankForId when available (covers all ranks); hard-coded IDs remain as fallback. - Register AURA_CAST_* events only while /ss diag aura is on. - Replace per-event CreateFrame debounce timers with reusable frames. - Persist own soulstone cooldown ready-time across reload/relog. - Add .gitattributes (LF). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019W3rwwdDZZ4CD4oJihtY5e --- .gitattributes | 1 + SoulstoneTracker.lua | 189 ++++++++++++++++++++++++++++++------------- 2 files changed, 133 insertions(+), 57 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/SoulstoneTracker.lua b/SoulstoneTracker.lua index af21d96..fb5b4a4 100644 --- a/SoulstoneTracker.lua +++ b/SoulstoneTracker.lua @@ -63,6 +63,28 @@ m.banish = {} -- { warlockName = raidIconIndex } m.shardCounts = {} -- { warlockName = count } m.ssCooldowns = {} -- { warlockName = readyAtEpochSeconds (0/nil = ready now) } m.history = {} -- { {text=..., time=...}, ... } session-only log + +-- Reusable debounce: one persistent frame per key (frames are never freed in +-- 1.12, so creating one per event leaked). Calling again with the same key +-- restarts the delay. +do + local frames = {} + function m.debounce(key, delay, fn) + local f = frames[key] + if not f then + f = CreateFrame("Frame") + frames[key] = f + end + local e = 0 + f:SetScript("OnUpdate", function() + e = e + arg1 + if e >= delay then + f:SetScript("OnUpdate", nil) + fn() + end + end) + end +end m.frame = nil m.settings = nil -- settings panel frame m.locked = false @@ -596,6 +618,10 @@ local lastBroadcastReadyAt = nil function m.startOwnSoulstoneCooldown() local readyAt = time() + SOULSTONE_DURATION m.ssCooldowns[UnitName("player")] = readyAt + if SoulstoneTrackerDB then + SoulstoneTrackerDB.ssReadyAt = SoulstoneTrackerDB.ssReadyAt or {} + SoulstoneTrackerDB.ssReadyAt[UnitName("player")] = readyAt + end if not lastBroadcastReadyAt or math.abs((lastBroadcastReadyAt or 0) - readyAt) > 5 then lastBroadcastReadyAt = readyAt local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) @@ -1849,6 +1875,46 @@ local CURSE_SPELL_IDS = { [11717] = "CoR", -- Curse of Recklessness } +-- Classify a spellId as "SS" (any rank of Soulstone Resurrection), a curse +-- short name (CoE/CoS/CoR/CoW), or nil. Uses nampower's spell name lookup +-- when available so every rank is covered; falls back to the hard-coded IDs. +do + local cache = {} + function m.classifySpell(id) + if not id then return nil end + local v = cache[id] + if v == nil then + v = false + if id == SOULSTONE_SPELL_ID then + v = "SS" + elseif CURSE_SPELL_IDS[id] then + v = CURSE_SPELL_IDS[id] + elseif GetSpellNameAndRankForId then + local name = GetSpellNameAndRankForId(id) + if name == "Soulstone Resurrection" then + v = "SS" + elseif name and SHORT_NAMES[name] then + v = SHORT_NAMES[name] + end + end + cache[id] = v + end + return v or nil + end +end + +function m.isInGroup(name) + if not name then return false end + if name == UnitName("player") then return true end + for i = 1, GetNumRaidMembers() do + if UnitName("raid"..i) == name then return true end + end + for i = 1, GetNumPartyMembers() do + if UnitName("party"..i) == name then return true end + end + return false +end + function m.detectCurse(msg, caster) for fullName, shortName in pairs(SHORT_NAMES) do if string.find(msg, fullName) then @@ -1907,16 +1973,10 @@ function m.detectCurse(msg, caster) -- fixable in code) -- send a redundant follow-up after a -- short delay so a single lost packet doesn't leave -- other clients out of sync. Idempotent to repeat. - local resendTimer = CreateFrame("Frame") - local elapsed = 0 - resendTimer:SetScript("OnUpdate", function() - elapsed = elapsed + arg1 - if elapsed >= 1.5 then - resendTimer:SetScript("OnUpdate", nil) - local ch = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) - if ch and m.curses[shortName] and m.curses[shortName].caster == casterName then - SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..shortName..":"..casterName, ch) - end + m.debounce("curseResend_"..shortName, 1.5, function() + local ch = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) + if ch and m.curses[shortName] and m.curses[shortName].caster == casterName then + SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..shortName..":"..casterName, ch) end end) end @@ -2428,11 +2488,24 @@ eFrame:RegisterEvent("CHAT_MSG_ADDON") local hasNampower = (GetNampowerVersion ~= nil) if hasNampower then eFrame:RegisterEvent("SPELL_GO_SELF") - eFrame:RegisterEvent("AURA_CAST_ON_OTHER") - eFrame:RegisterEvent("AURA_CAST_ON_SELF") - -- Unlike SPELL_GO_SELF, aura cast events default to OFF and must be - -- explicitly enabled via this CVar. - SetCVar("NP_EnableAuraCastEvents", 1) + -- Others' casts: gives exact caster/target GUIDs for soulstones cast by + -- group members who don't run this addon. Filtered by spellId first. + eFrame:RegisterEvent("SPELL_GO_OTHER") + -- AURA_CAST_* only matter for /ss diag aura, and (nampower 4.5+) merely + -- registering an event enables its emission, so they are registered only + -- while the diagnostic is on -- see m.setAuraDiag. + function m.setAuraDiag(on) + m.diagAura = on and true or false + if on then + eFrame:RegisterEvent("AURA_CAST_ON_OTHER") + eFrame:RegisterEvent("AURA_CAST_ON_SELF") + SetCVar("NP_EnableAuraCastEvents", 1) + else + eFrame:UnregisterEvent("AURA_CAST_ON_OTHER") + eFrame:UnregisterEvent("AURA_CAST_ON_SELF") + SetCVar("NP_EnableAuraCastEvents", 0) + end + end -- Fallback for auras without a spell effect component (which -- AURA_CAST_ON_OTHER/SELF won't catch) -- Soulstone Resurrection may be -- one of these, per nampower's own docs. @@ -2473,7 +2546,17 @@ eFrame:SetScript("OnEvent", function() -- session. If you're actually still on cooldown from before this -- reload, it'll incorrectly show ready until corrected by your next -- real cast -- an accepted limitation given no API can tell us. - if playerIsWarlock() then m.ssCooldowns[UnitName("player")] = 0 end + -- The ready-at time of our own last cast is persisted, so a reload + -- or relog mid-cooldown now restores it (item cooldowns tick in + -- real time, including while offline). + if playerIsWarlock() then + local saved = SoulstoneTrackerDB.ssReadyAt and SoulstoneTrackerDB.ssReadyAt[UnitName("player")] + if saved and saved > time() then + m.ssCooldowns[UnitName("player")] = saved + else + m.ssCooldowns[UnitName("player")] = 0 + end + end if SoulstoneTrackerDB.hidden then m.frame:Hide() else m.frame:Show() end if SoulstoneTrackerDB.locked then m.locked = true m.frame.updateLock() end m.updateGroupVisibility() @@ -2507,16 +2590,8 @@ eFrame:SetScript("OnEvent", function() -- Silent unless it actually finds something, and coalesces rapid -- roster churn (e.g. several people joining a raid in quick -- succession) into a single scan instead of one per event. - if m.__rosterScanTimer then - m.__rosterScanTimer:SetScript("OnUpdate", nil) - end - local t = CreateFrame("Frame") - m.__rosterScanTimer = t - local e = 0 - t:SetScript("OnUpdate", function() - e = e + arg1 - if e >= 2 then - t:SetScript("OnUpdate", nil) + m.debounce("rosterScan", 2, function() + do m.scanForSoulstones(true) -- Ask others with the addon to resend their accurate data -- -- this is what lets a scanned "Unknown"/estimated stone get @@ -2536,44 +2611,20 @@ eFrame:SetScript("OnEvent", function() -- from warlocks who don't have the addon (best-effort, no caster -- attribution possible). Debounced since target changes can fire -- rapidly while tab-targeting. - if m.__targetScanTimer then - m.__targetScanTimer:SetScript("OnUpdate", nil) - end - local t2 = CreateFrame("Frame") - m.__targetScanTimer = t2 - local e2 = 0 - t2:SetScript("OnUpdate", function() - e2 = e2 + arg1 - if e2 >= 0.5 then - t2:SetScript("OnUpdate", nil) - m.scanForCurses(true) - end + m.debounce("targetScan", 0.5, function() + m.scanForCurses(true) end) elseif event == "BAG_UPDATE" then -- Debounced since bag updates fire once per slot touched, and a -- single bag operation (looting, splitting stacks) can touch many. - if m.__shardScanTimer then - m.__shardScanTimer:SetScript("OnUpdate", nil) - end - local t3 = CreateFrame("Frame") - m.__shardScanTimer = t3 - local e3 = 0 - t3:SetScript("OnUpdate", function() - e3 = e3 + arg1 - if e3 >= 1 then - t3:SetScript("OnUpdate", nil) - m.updateAndBroadcastShards() - end + m.debounce("shardScan", 1, function() + m.updateAndBroadcastShards() end) elseif event == "PLAYER_ENTERING_WORLD" then if m.frame then - local t = CreateFrame("Frame") local e = 0 - t:SetScript("OnUpdate", function() - e = e + arg1 - if e >= 3 then t:SetScript("OnUpdate", nil) m.scanForSoulstones() end - end) + m.debounce("enterWorldScan", 3, function() m.scanForSoulstones() end) end elseif event == "SPELLCAST_START" then @@ -2641,7 +2692,8 @@ eFrame:SetScript("OnEvent", function() elseif event == "SPELL_GO_SELF" then -- itemId, spellID, casterGuid, targetGuid, castFlags, numTargetsHit, numTargetsMissed local spellID = arg2 - local shortName = spellID and CURSE_SPELL_IDS[spellID] + local shortName = m.classifySpell(spellID) + if shortName == "SS" then shortName = nil end if shortName then -- Guaranteed self-scoped by nampower, so always safe to -- attribute directly to the local player. @@ -2649,6 +2701,25 @@ eFrame:SetScript("OnEvent", function() end m.updateSoulstoneCooldown() + elseif event == "SPELL_GO_OTHER" then + -- itemId, spellId, casterGuid, targetGuid, castFlags, ... + -- Cheap early filter: this fires for every spell cast near us. + if m.classifySpell(arg2) == "SS" then + local casterName = UnitName(arg3) + local targetName = UnitName(arg4) + if m.diagAura then + say("|cff00ff00[DIAG]|r SPELL_GO_OTHER soulstone: spellId="..tostring(arg2).." caster="..tostring(casterName).." target="..tostring(targetName)) + end + -- Only group members' stones; silent (the caster's own client + -- is the one that broadcasts/announces, as before). + if casterName and targetName and m.isInGroup(casterName) then + local ex = m.stones[targetName] + if not (ex and ex.caster == casterName and (time() - (ex.castTime or 0)) < 5) then + m.addStone(targetName, casterName, time() + SOULSTONE_DURATION, true) + end + end + end + elseif event == "AURA_CAST_ON_OTHER" or event == "AURA_CAST_ON_SELF" then -- spellId, casterGuid, targetGuid, effect, effectAuraName, effectAmplitude, effectMiscValue, durationMs, auraCapStatus if m.diagAura then @@ -2967,7 +3038,11 @@ SlashCmdList["SOULSTONETRACKER"] = function(args) local spacePos = string.find(cmd, " ", 1, true) local sub = spacePos and string.sub(cmd, spacePos + 1) if sub == "aura" then - m.diagAura = not m.diagAura + if m.setAuraDiag then + m.setAuraDiag(not m.diagAura) + else + m.diagAura = not m.diagAura + end say("Aura-cast diagnostic "..(m.diagAura and "|cff00ff00ENABLED|r" or "|cffff0000DISABLED|r")..". Cast/receive anything to see spellId/target/durationMs in chat.") else -- On-demand diagnostic: reveal exact string contents (including