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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwwdDZZ4CD4oJihtY5e
This commit is contained in:
2026-11-01 00:56:17 +00:00
co-authored by Claude Sonnet 5.5
parent 3ba48b7e37
commit 75d6667dbd
2 changed files with 133 additions and 57 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+132 -57
View File
@@ -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