Release 4.2: spell IDs, base-health verify, macro display

- Find Life Tap ranks by spell ID (FindSpellBookSlotByID) with name scan fallback
- /slt verify [adopt|reset] compares BASE table to C_Spell.GetSpellDescription
- Macro buttons with /slt cast show the current rank (C_Macro.SetMacroDisplay)
- Debug output of raw combat log values to confirm powerType position
- Options: macro display checkbox, taller standalone window

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019W3rwwaFE77MpiHiYhRwLT
This commit is contained in:
2026-10-31 23:20:08 +00:00
co-authored by Claude Sonnet 5.5
parent c1b0800bdb
commit a05e2d5ff9
4 changed files with 277 additions and 19 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 4.2
**New**
- Life Tap ranks are found by spell ID when ClassicAPI is present (`FindSpellBookSlotByID`), so the spell name and rank text no longer have to match. The name scan still runs and covers anything the ID scan misses. The combat log check also recognises Life Tap by spell ID.
- `/slt verify` compares each rank's base health with the game's own spell data (`C_Spell.GetSpellDescription`, needs ClassicAPI). `/slt verify adopt` uses the spell data numbers, `/slt verify reset` goes back to the built-in table. A one-time notice appears at login if they disagree.
- Macro buttons that contain `/slt cast` show the Life Tap rank that would be cast right now (ClassicAPI, `C_Macro.SetMacroDisplay`). Turn it off with `/slt macrodisplay off` or in the options window.
- With `/slt debug on`, the combat log check prints the raw values after the spell fields, to confirm which one is the power type.
**Changed**
- `Options.lua` window is taller to fit the new checkbox.
## 4.1
**New**
- Options window: `/slt options`, or with ClassicAPI, game menu → AddOn Options → ScaledLifeTap. Includes a Ranks page showing the health cost and mana gained per rank. Without ClassicAPI, the same settings open in a small window of their own.
+6 -3
View File
@@ -1,4 +1,4 @@
-- ScaledLifeTap 4.1 - options window
-- ScaledLifeTap 4.2 - options window
--
-- With ClassicAPI, the pages are added to its AddOn Options window (game menu
-- -> AddOn Options -> ScaledLifeTap), following the same conventions as
@@ -11,7 +11,7 @@
-- Lua 5.0 / 1.12 API only: no `...` expressions, no select(), handlers use `this`.
local SLT = ScaledLifeTap
local VERSION = type(GetAddOnMetadata) == "function" and GetAddOnMetadata("ScaledLifeTap", "Version") or "4.1"
local VERSION = type(GetAddOnMetadata) == "function" and GetAddOnMetadata("ScaledLifeTap", "Version") or "4.2"
local T = {
ABOUT = "Casts the highest Life Tap rank that fits into your missing mana. Put |cffffffff/slt cast|r in a macro.",
@@ -23,6 +23,8 @@ local T = {
CALIBRATE_RESET_TIP = "Forget what the mana check has learned.",
DEBUG = "Show each cast in chat",
DEBUG_TIP = "Prints the rank, health cost and mana gained every time Life Tap is cast.",
MACRO_DISPLAY = "Show rank on macro button",
MACRO_DISPLAY_TIP = "Macros that contain /slt cast show the Life Tap rank that would be cast right now. Needs ClassicAPI.",
RESERVE = "Health reserve (%)",
RESERVE_TIP = "Never tap below this percentage of your maximum health. 0 means only the health the tap itself needs.",
SOURCE = "Spell damage source",
@@ -260,6 +262,7 @@ AddButton(main, T.CALIBRATE_RESET, T.CALIBRATE_RESET_TIP, function()
SLT.ResetCalibration()
RefreshControls(main)
end, 220, main.y + 30 - 4, 80)
AddCheckbox(main, T.MACRO_DISPLAY, T.MACRO_DISPLAY_TIP, ConfigSetting("macroDisplay", SLT.RefreshMacroDisplay), SLT.CanMacroDisplay)
AddCheckbox(main, T.DEBUG, T.DEBUG_TIP, ConfigSetting("debug"))
AddSlider(main, T.RESERVE, T.RESERVE_TIP, ConfigSetting("reserve"), 0, 90, 5)
AddDropDown(main, T.SOURCE, T.SOURCE_TIP, ConfigSetting("source", SLT.Rescan), function()
@@ -308,7 +311,7 @@ local function ShowStandalone()
if not standalone then
standalone = CreateFrame("Frame", "ScaledLifeTapOptionsWindow", UIParent)
standalone:SetWidth(420)
standalone:SetHeight(470)
standalone:SetHeight(500)
standalone:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
standalone:SetFrameStrata("DIALOG")
standalone:SetBackdrop({
+260 -15
View File
@@ -1,4 +1,4 @@
-- ScaledLifeTap 4.1
-- ScaledLifeTap 4.2
-- Original by Kimilly (thanks to Aredia of Gilneas for the macro and research).
-- Casts the highest Life Tap rank whose mana return fits in your missing mana.
--
@@ -12,6 +12,13 @@ local Scanner = ScaledLifeTap_Scanner
local BASE = { 30, 75, 140, 220, 310, 424 }
local COEF = { 0.38, 0.68, 0.8, 0.8, 0.8, 0.8 }
-- Spell ID of each Life Tap rank. With ClassicAPI these find the spellbook slot
-- directly (no rank text or spell name needed) and identify the spell in the combat log.
local RANK_SPELL_IDS = { 1454, 1455, 1456, 11687, 11688, 11689 }
local NUM_RANKS = table.getn(RANK_SPELL_IDS)
local ID_TO_RANK = {}
for r = 1, NUM_RANKS do ID_TO_RANK[RANK_SPELL_IDS[r]] = r end
local DEFAULTS = {
overtap = true, -- cast rank 1 if no rank fits but mana is not full
debug = false,
@@ -21,6 +28,9 @@ local DEFAULTS = {
calibrate = true, -- learn a mana correction from the actual mana each tap gives
gainFactor = 1,
samples = 0,
macroDisplay = true, -- ClassicAPI: show the rank that would be cast on /slt cast macro buttons
verifyShown = false, -- the "spell data disagrees with built-in numbers" notice was shown once
-- baseOverride = { [rank] = health } is created by /slt verify adopt
}
local spellIndex = {} -- [rank] = spellbook index
@@ -31,12 +41,22 @@ local talentMult, talentDirty = 1, true
local function msg(text) DEFAULT_CHAT_FRAME:AddMessage("|cff9482c9ScaledLifeTap:|r " .. text) end
local function dbg(text) if SLT_Config and SLT_Config.debug then msg(text) end end
-- Base health for a rank: the built-in table unless /slt verify adopt replaced it.
local function baseOf(r)
local o = SLT_Config and SLT_Config.baseOverride
return (o and o[r]) or BASE[r]
end
local function isLifeTapId(id)
id = tonumber(id)
return id ~= nil and ID_TO_RANK[id] ~= nil
end
---------------------------------------------------------------------------
-- spellbook / talents (cached, refreshed on events)
---------------------------------------------------------------------------
local function scanSpellbook()
for k in pairs(spellIndex) do spellIndex[k] = nil end
maxRank = 0
-- Name scan: works everywhere, depends on the localized spell name and rank text.
local function scanSpellbookByName()
local i = 1
while true do
local name, rankText = GetSpellName(i, BOOKTYPE_SPELL)
@@ -51,6 +71,27 @@ local function scanSpellbook()
end
i = i + 1
end
end
-- ID scan (ClassicAPI): finds each known rank by spell ID. Takes precedence over
-- the name scan for the ranks it finds, so it also works when the name or rank
-- text doesn't match the locale tables.
local function scanSpellbookByID()
if type(FindSpellBookSlotByID) ~= "function" then return end
for r = 1, NUM_RANKS do
local ok, slot, book = pcall(FindSpellBookSlotByID, RANK_SPELL_IDS[r])
if ok and tonumber(slot) and (book == nil or book == BOOKTYPE_SPELL) then
spellIndex[r] = tonumber(slot)
if r > maxRank then maxRank = r end
end
end
end
local function scanSpellbook()
for k in pairs(spellIndex) do spellIndex[k] = nil end
maxRank = 0
scanSpellbookByName()
scanSpellbookByID()
spellsDirty = false
end
@@ -69,6 +110,89 @@ local function scanTalents()
talentDirty = false
end
---------------------------------------------------------------------------
-- base health check against the game's own spell data (ClassicAPI)
-- C_Spell.GetSpellDescription gives the base-rank description text, e.g. the
-- "Converts N health into N mana" line, so the first number in it is the base
-- health of that rank. The check only reports; /slt verify adopt applies it.
---------------------------------------------------------------------------
local descCache, descText = {}, {}
local function hasDescription()
return type(C_Spell) == "table" and type(C_Spell.GetSpellDescription) == "function"
end
local function descBase(r)
if descCache[r] ~= nil then return descCache[r] or nil, descText[r] end
if not hasDescription() then return nil end
local ok, text = pcall(C_Spell.GetSpellDescription, RANK_SPELL_IDS[r])
if not ok or type(text) ~= "string" or text == "" then return nil end
text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "")
text = string.gsub(text, "|r", "")
local _, _, n = string.find(text, "(%d+)")
n = tonumber(n)
descCache[r] = n or false
descText[r] = text
return n, text
end
local function verifyBase(adopt)
if not hasDescription() then
msg("Checking the numbers needs ClassicAPI (C_Spell.GetSpellDescription).")
return
end
local diffs, readable = 0, 0
for r = 1, NUM_RANKS do
local n, text = descBase(r)
local used = baseOf(r)
if not n then
msg(string.format("Rank %d: no readable description", r))
else
readable = readable + 1
if n == used then
msg(string.format("Rank %d: %d health - OK", r, n))
else
diffs = diffs + 1
msg(string.format("Rank %d: addon uses %d, spell data says %d (%s)", r, used, n, string.sub(text, 1, 80)))
if adopt then
if n >= BASE[r] * 0.5 and n <= BASE[r] * 2 then
SLT_Config.baseOverride = SLT_Config.baseOverride or {}
SLT_Config.baseOverride[r] = n
msg(string.format(" rank %d now uses %d", r, n))
else
msg(string.format(" rank %d: %d looks implausible, not adopted", r, n))
end
end
end
end
end
SLT_Config.verifyShown = true
if readable == 0 then
msg("No rank description could be read.")
elseif diffs == 0 then
msg("Built-in numbers match the spell data.")
elseif not adopt then
msg("/slt verify adopt uses the spell data numbers; /slt verify reset goes back to the built-in ones.")
end
end
-- Once per character: tell the player if the spell data disagrees with the table.
local autoVerified = false
local function autoVerify()
if autoVerified then return end
autoVerified = true
if SLT_Config.verifyShown or not hasDescription() then return end
local bad = 0
for r = 1, NUM_RANKS do
local n = descBase(r)
if n and n ~= baseOf(r) then bad = bad + 1 end
end
if bad > 0 then
SLT_Config.verifyShown = true
msg(string.format("The game's spell data disagrees with the built-in Life Tap numbers for %d rank(s). Type /slt verify for details.", bad))
end
end
---------------------------------------------------------------------------
-- spell damage
---------------------------------------------------------------------------
@@ -135,9 +259,11 @@ end
local function onEnergize(spellId, powerType, amount, nameChecked)
if not lastCast or powerType ~= 0 or not tonumber(amount) then return end
if GetTime() - lastCast.time > 3 then lastCast = nil; stopCombatLog(); return end
if not nameChecked and type(GetSpellNameAndRankForId) == "function" then
local ok, name = pcall(GetSpellNameAndRankForId, spellId)
if not ok or name ~= L.LIFETAP then return end
if not nameChecked then
if not isLifeTapId(spellId) and type(GetSpellNameAndRankForId) == "function" then
local ok, name = pcall(GetSpellNameAndRankForId, spellId)
if not ok or name ~= L.LIFETAP then return end
end
end
local base = lastCast.predicted / (SLT_Config.gainFactor or 1)
lastCast = nil
@@ -163,10 +289,13 @@ local function hasCombatLog()
end
local function onCombatLog()
local _, sub, _, src, _, _, _, _, _, _, _, spellId, spellName, _, amount, powerType = CombatLogGetCurrentEventInfo()
if sub ~= "SPELL_ENERGIZE" or spellName ~= L.LIFETAP then return end
local _, sub, _, src, _, _, _, _, _, _, _, spellId, spellName, _, amount, a16, a17 = CombatLogGetCurrentEventInfo()
if sub ~= "SPELL_ENERGIZE" then return end
if not (isLifeTapId(spellId) or spellName == L.LIFETAP) then return end
if src ~= UnitGUID("player") then return end
onEnergize(spellId, powerType, amount, true)
-- raw values after the spell fields, to confirm which one is the power type
dbg(string.format("energize: amount=%s next=%s next2=%s", tostring(amount), tostring(a16), tostring(a17)))
onEnergize(spellId, a16, amount, true)
end
local function watchCombatLog()
@@ -196,7 +325,7 @@ function ScaledLifeTap_ChooseRank()
for r = maxRank, 1, -1 do
if spellIndex[r] then
local cost = BASE[r] + COEF[r] * bonus
local cost = baseOf(r) + COEF[r] * bonus
local gain = cost * mult
if hp - cost > floor and missing >= gain then return r, cost, gain, true end
end
@@ -206,7 +335,7 @@ function ScaledLifeTap_ChooseRank()
local r = 1
while r <= maxRank and not spellIndex[r] do r = r + 1 end
if spellIndex[r] then
local cost = BASE[r] + COEF[r] * bonus
local cost = baseOf(r) + COEF[r] * bonus
if hp - cost > floor then return r, cost, cost * mult, false end
end
end
@@ -239,6 +368,86 @@ function ScaledLifeTap_Debug(arg)
SLT_Config.debug = (arg == 1 or arg == true)
end
---------------------------------------------------------------------------
-- macro button display (ClassicAPI): macros that contain "/slt cast" show the
-- Life Tap rank that would be cast right now (icon and tooltip), the same way
-- #showtooltip follows a /cast line. Updates are throttled and event driven.
---------------------------------------------------------------------------
local macroSlots = {} -- macro indexes whose body calls /slt cast
local macroDirty = true
local lastShown -- rank last published, so unchanged ranks cost nothing
local displayPending = false
local displayBroken = false
local function hasMacroDisplay()
return type(C_Macro) == "table" and type(C_Macro.SetMacroDisplay) == "function"
end
local function findMacros()
for k in pairs(macroSlots) do macroSlots[k] = nil end
if type(GetMacroInfo) == "function" then
for i = 1, 36 do
local name, _, body = GetMacroInfo(i)
if name and body then
local b = string.lower(body)
if string.find(b, "/slt cast", 1, true)
or string.find(b, "/scaledlifetap cast", 1, true)
or string.find(b, "scaledlifetap_castlifetap", 1, true) then
table.insert(macroSlots, i)
end
end
end
end
macroDirty = false
lastShown = nil
end
-- Tries the spell ID first, then "Life Tap(Rank N)" built from the spellbook entry.
local function setMacroDisplay(slot, r)
local ok, res = pcall(C_Macro.SetMacroDisplay, slot, RANK_SPELL_IDS[r])
if ok and res ~= false then return true end
if spellIndex[r] then
local name, rankText = GetSpellName(spellIndex[r], BOOKTYPE_SPELL)
if name then
local value = name
if rankText and rankText ~= "" then value = name .. "(" .. rankText .. ")" end
ok, res = pcall(C_Macro.SetMacroDisplay, slot, value)
if ok and res ~= false then return true end
end
end
return false
end
local function publishDisplay()
displayPending = false
if displayBroken or not SLT_Config.macroDisplay or not hasMacroDisplay() then return end
if macroDirty then findMacros() end
if table.getn(macroSlots) == 0 then return end
if spellsDirty then scanSpellbook() end
if maxRank == 0 then return end
local r = ScaledLifeTap_ChooseRank()
if not r then r = maxRank end -- nothing fits (mana full): show the top rank
if r == lastShown then return end
local failed = 0
for i = 1, table.getn(macroSlots) do
if not setMacroDisplay(macroSlots[i], r) then failed = failed + 1 end
end
if failed == table.getn(macroSlots) then
displayBroken = true
msg("ClassicAPI did not accept the macro display value; macro button display is off for this session. (/slt macrodisplay off to hide this)")
else
lastShown = r
dbg(string.format("macro display: rank %d on %d macro(s)", r, table.getn(macroSlots) - failed))
end
end
local function requestDisplay()
if displayPending or displayBroken or not SLT_Config.macroDisplay or not hasMacroDisplay() then return end
if not (C_Timer and C_Timer.After) then return end
displayPending = true
C_Timer.After(0.3, publishDisplay)
end
---------------------------------------------------------------------------
-- status / slash commands
---------------------------------------------------------------------------
@@ -264,7 +473,7 @@ local function statusLines()
end
for r = maxRank, 1, -1 do
if spellIndex[r] then
local cost = BASE[r] + COEF[r] * total
local cost = baseOf(r) + COEF[r] * total
table.insert(ranks, string.format("Rank %d: -%d health, +%d mana", r, cost, cost * mult))
end
end
@@ -274,7 +483,7 @@ end
local function printStatus()
local summary, ranks = statusLines()
for i = 1, table.getn(summary) do msg(summary[i]) end
msg(string.format("overtap %s | reserve %d%% | mana check %s", onoff(SLT_Config.overtap), SLT_Config.reserve or 0, onoff(SLT_Config.calibrate)))
msg(string.format("overtap %s | reserve %d%% | mana check %s | macro display %s", onoff(SLT_Config.overtap), SLT_Config.reserve or 0, onoff(SLT_Config.calibrate), onoff(SLT_Config.macroDisplay)))
for i = 1, table.getn(ranks) do msg(" " .. ranks[i]) end
end
@@ -296,12 +505,21 @@ ScaledLifeTap = {
CanCalibrate = function()
return type(GetNampowerVersion) == "function" or hasCombatLog()
end,
CanMacroDisplay = hasMacroDisplay,
RefreshMacroDisplay = function()
lastShown = nil
macroDirty = true
displayBroken = false
requestDisplay()
end,
ResetCalibration = function()
SLT_Config.gainFactor, SLT_Config.samples = 1, 0
warned = false
end,
Rescan = function()
Scanner:MarkDirty(); spellsDirty = true; talentDirty = true
lastShown = nil
requestDisplay()
end,
}
@@ -333,6 +551,16 @@ SlashCmdList["SCALEDLIFETAP"] = function(input)
ScaledLifeTap.ResetCalibration(); msg("mana correction reset")
elseif cmd == "source" and (arg == "auto" or SOURCES[arg]) then
SLT_Config.source = arg; Scanner:MarkDirty(); msg("damage source " .. arg)
elseif cmd == "verify" and (arg == "" or arg == "adopt") then
verifyBase(arg == "adopt")
if arg == "adopt" then lastShown = nil; requestDisplay() end
elseif cmd == "verify" and arg == "reset" then
SLT_Config.baseOverride = nil; lastShown = nil; msg("Life Tap base numbers reset to the built-in table"); requestDisplay()
elseif cmd == "macrodisplay" and (arg == "on" or arg == "off" or arg == "") then
SLT_Config.macroDisplay = toggleArg(arg, SLT_Config.macroDisplay)
msg("macro button display " .. onoff(SLT_Config.macroDisplay))
if SLT_Config.macroDisplay then ScaledLifeTap.RefreshMacroDisplay() end
if not hasMacroDisplay() then msg("(needs ClassicAPI; nothing happens without it)") end
elseif cmd == "rescan" then
ScaledLifeTap.Rescan(); printStatus()
elseif cmd == "options" or cmd == "config" then
@@ -346,6 +574,8 @@ SlashCmdList["SCALEDLIFETAP"] = function(input)
msg(" /slt bonus <n> - extra spell damage to add, e.g. from elixirs (" .. (SLT_Config.bonus or 0) .. ")")
msg(" /slt source auto|classicapi|nampower|bonusscanner|internal - where spell damage comes from (" .. SLT_Config.source .. ")")
msg(" /slt calibrate [on|off|reset] - learn actual mana per tap (needs Nampower or ClassicAPI) (" .. onoff(SLT_Config.calibrate) .. ")")
msg(" /slt verify [adopt|reset] - compare the base health numbers with the game's spell data (needs ClassicAPI)")
msg(" /slt macrodisplay [on|off] - show the rank on /slt cast macro buttons (needs ClassicAPI) (" .. onoff(SLT_Config.macroDisplay) .. ")")
msg(" /slt debug [on|off] - print each cast (" .. onoff(SLT_Config.debug) .. ")")
msg(" /slt rescan - force a gear/spellbook/talent rescan")
msg(" /slt options - open the options window")
@@ -364,20 +594,35 @@ f:RegisterEvent("SPELLS_CHANGED")
f:RegisterEvent("LEARNED_SPELL_IN_TAB")
f:RegisterEvent("CHARACTER_POINTS_CHANGED")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:RegisterEvent("UPDATE_MACROS")
f:RegisterEvent("UNIT_MANA")
f:RegisterEvent("UNIT_MAXMANA")
f:RegisterEvent("UNIT_HEALTH")
f:RegisterEvent("UNIT_MAXHEALTH")
-- Nampower custom event (registering it enables it on Nampower 4.5+; older
-- versions need /run SetCVar("NP_EnableSpellEnergizeEvents","1") ). Harmless otherwise.
pcall(f.RegisterEvent, f, "SPELL_ENERGIZE_BY_SELF")
f:SetScript("OnEvent", function()
if event == "SPELL_ENERGIZE_BY_SELF" then
onEnergize(arg3, arg4, arg5)
elseif event == "UNIT_MANA" or event == "UNIT_MAXMANA" or event == "UNIT_HEALTH" or event == "UNIT_MAXHEALTH" then
if arg1 == "player" then requestDisplay() end
elseif event == "UNIT_INVENTORY_CHANGED" then
if arg1 == "player" then Scanner:MarkDirty() end
if arg1 == "player" then Scanner:MarkDirty(); requestDisplay() end
elseif event == "SPELLS_CHANGED" or event == "LEARNED_SPELL_IN_TAB" then
spellsDirty = true
requestDisplay()
elseif event == "CHARACTER_POINTS_CHANGED" then
talentDirty = true
requestDisplay()
elseif event == "UPDATE_MACROS" then
macroDirty = true
requestDisplay()
elseif event == "PLAYER_ENTERING_WORLD" then
Scanner:MarkDirty(); spellsDirty = true; talentDirty = true
macroDirty = true
autoVerify()
requestDisplay()
elseif event == "VARIABLES_LOADED" then
if type(SLT_Config) ~= "table" then SLT_Config = {} end
-- v3 stored "overtapEnabled"
+1 -1
View File
@@ -1,7 +1,7 @@
## Interface: 11200
## Title: ScaledLifeTap
## Author: Kimilly (original), OEVG2 (4.0)
## Version: 4.1
## Version: 4.2
## Notes: Casts the highest Life Tap rank that fits your missing mana, using exact spell damage from ClassicAPI or Nampower when available, otherwise its own gear scanner.
## Notes-deDE: Wirkt den passenden Aderlass-Rang abhaengig von fehlendem Mana, Zauberschaden und Talenten. Benoetigt kein BonusScanner mehr.
## DefaultState: Enabled