From a05e2d5ff9e449b015dac3481f7613bb5451135c Mon Sep 17 00:00:00 2001 From: OEVG <950+oevg@noreply.octowow.st> Date: Sat, 31 Oct 2026 23:20:08 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_019W3rwwaFE77MpiHiYhRwLT --- CHANGELOG.md | 10 ++ Options.lua | 9 +- ScaledLifeTap.lua | 275 +++++++++++++++++++++++++++++++++++++++++++--- ScaledLifeTap.toc | 2 +- 4 files changed, 277 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cec4240..15fad2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Options.lua b/Options.lua index 4b28e83..acde044 100644 --- a/Options.lua +++ b/Options.lua @@ -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({ diff --git a/ScaledLifeTap.lua b/ScaledLifeTap.lua index b87b480..d6d6c01 100644 --- a/ScaledLifeTap.lua +++ b/ScaledLifeTap.lua @@ -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 - 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" diff --git a/ScaledLifeTap.toc b/ScaledLifeTap.toc index 1d7658f..383f8d8 100644 --- a/ScaledLifeTap.toc +++ b/ScaledLifeTap.toc @@ -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