Files
MetaHunt/modules/expammo/engine.lua
T

1120 lines
40 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
------------------------------------------------------
-- MetaHunt: Experimental Ammunition Tracker
-- TWoW 1.18.1 — "Nightmares of Ursol"
------------------------------------------------------
--
-- Mechanic (confirmed from live 1.18.1 talent tooltip):
-- Aimed Shot triggers a cyclic 3-state ammo sequence:
-- EXPLOSIVE (60s) → POISONOUS (60s) → ENCHANTED (60s)
-- Each Aimed Shot (re)starts the cycle at EXPLOSIVE.
-- Each state is consumed early by the matching shot:
-- EXPLOSIVE → Multi-Shot (AoE fire explosion)
-- POISONOUS → Serpent Sting (corrosive poison 15s)
-- ENCHANTED → Arcane Shot (magic resist debuff 6s)
-- Whether consumed or expired, the next state begins
-- automatically. After ENCHANTED the cycle ends; the
-- next Aimed Shot will restart it.
--
-- Requires: Experimental Ammunition talent (MM, 1 rank).
-- Module auto-hides if player does not have the talent.
------------------------------------------------------
local MODULE_NAME = "expammo"
local CYCLE_DURATION = 60 -- seconds per ammo state
local TALENT_NAME = "Experimental Ammunition"
-- ── Spell names (English) ────────────────────────────────────
local SPELL_AIMED = "Aimed Shot"
local SPELL_MULTI = "Multi-Shot"
local SPELL_ARCANE = "Arcane Shot"
local SPELL_SERPENT = "Serpent Sting"
-- ── Buff names (English, as shown in buff bar) ───────────────
-- These match the talent tooltip text for each state.
local BUFF_EXPLOSIVE = "Explosive Ammunition"
local BUFF_POISONOUS = "Poisonous Ammunition"
local BUFF_ENCHANTED = "Enchanted Ammunition"
-- ── State IDs ────────────────────────────────────────────────
local IDLE = "IDLE"
local EXPLOSIVE = "EXPLOSIVE"
local POISONOUS = "POISONOUS"
local ENCHANTED = "ENCHANTED"
local STATE_ORDER = { EXPLOSIVE, POISONOUS, ENCHANTED }
-- Buff name for each state (for recovery scan on login)
local STATE_BUFF = {
[EXPLOSIVE] = BUFF_EXPLOSIVE,
[POISONOUS] = BUFF_POISONOUS,
[ENCHANTED] = BUFF_ENCHANTED,
}
-- Spell that consumes each state
local CONSUME_SPELL = {
[EXPLOSIVE] = SPELL_MULTI,
[POISONOUS] = SPELL_SERPENT,
[ENCHANTED] = SPELL_ARCANE,
}
-- Reverse lookup: buff name → state key (used for CHAT_MSG matching)
-- Built at load time so hot-path matching is a simple table index.
local BUFF_TO_STATE = {}
for _, sk in ipairs(STATE_ORDER) do
BUFF_TO_STATE[STATE_BUFF[sk]] = sk
end
-- State that follows each state (nil = IDLE)
local NEXT_STATE = {
[EXPLOSIVE] = POISONOUS,
[POISONOUS] = ENCHANTED,
[ENCHANTED] = nil, -- cycle ends; restart on next Aimed Shot
}
-- Prediction: which state will the server send next time Aimed Shot fires?
-- The server tracks cycle position: EXPLOSIVE→POISONOUS→ENCHANTED, then restarts.
-- After ENCHANTED (or no prior cycle) the next state is EXPLOSIVE.
local PREDICT_NEXT = {
[EXPLOSIVE] = POISONOUS,
[POISONOUS] = ENCHANTED,
[ENCHANTED] = EXPLOSIVE, -- full cycle done: restart
}
-- ── Per-cell appearance ───────────────────────────────────────
local CELL_DEF = {
{ state = EXPLOSIVE, label = "Explosive", r = 1.00, g = 0.38, b = 0.05,
icon = "Interface\\Icons\\Ability_SearingArrow" },
{ state = POISONOUS, label = "Poisonous", r = 0.12, g = 0.85, b = 0.18,
icon = "Interface\\Icons\\Ability_PoisonArrow" },
{ state = ENCHANTED, label = "Enchanted", r = 0.38, g = 0.12, b = 1.00,
icon = "Interface\\Icons\\Ability_TheBlackArrow" },
}
-- ── Talent gate ──────────────────────────────────────────────
-- The module only shows if the player has the talent.
-- Checked on login and whenever MTH_HT_OnScanComplete fires.
local MTH_EA_HasTalent = false
-- ── Volatile runtime state (not persisted) ────────────────────
local rt = {
state = IDLE,
expiresAt = 0,
consumed = false,
lastState = nil, -- ammo state that most recently ended; used for predictive display
}
-- ── Aura probe for state recovery and PLAYER_AURAS_CHANGED ──
-- Combat log shows "You are afflicted by Explosive Ammunition"
-- meaning the ammo state is implemented as a player DEBUFF, not
-- a buff. We scan both pools so we work either way.
local MTH_EA_BuffProbe = nil
local function MTH_EA_EnsureProbe()
if not MTH_EA_BuffProbe then
MTH_EA_BuffProbe = CreateFrame("GameTooltip", "MTH_EA_BuffProbeTooltip", UIParent, "GameTooltipTemplate")
MTH_EA_BuffProbe:SetOwner(UIParent, "ANCHOR_NONE")
end
return MTH_EA_BuffProbe
end
-- Get the name of buff (isDebuff=false) or debuff (isDebuff=true) at slot index.
local function MTH_EA_GetAuraName(isDebuff, index)
local probe = MTH_EA_EnsureProbe()
if not probe then return nil end
local ok
if isDebuff then
ok = pcall(function() probe:SetUnitDebuff("player", index) end)
else
ok = pcall(function() probe:SetUnitBuff("player", index) end)
end
if not ok then return nil end
local text = getglobal("MTH_EA_BuffProbeTooltipTextLeft1")
local name = text and text:GetText() or nil
if name == "" then return nil end
return name
end
-- Scan the player's buff list for "Lock and Load" and return its
-- actual remaining seconds by reading the tooltip duration line.
-- Returns nil if the buff is not found or duration can't be parsed.
local function MTH_EA_GetLnLRemaining()
local probe = MTH_EA_EnsureProbe()
if not probe then return nil end
for i = 1, 32 do
local ok = pcall(function() probe:SetUnitBuff("player", i) end)
if not ok then return nil end
local t1 = getglobal("MTH_EA_BuffProbeTooltipTextLeft1")
local name = t1 and t1:GetText() or nil
if not name or name == "" then return nil end
if string.find(name, "Lock and Load", 1, true) then
-- The game tooltip shows duration on subsequent lines,
-- e.g. "8 sec remaining" or "Remaining: 8 sec"
for ln = 2, 5 do
local lt = getglobal("MTH_EA_BuffProbeTooltipTextLeft" .. ln)
local ls = lt and lt:GetText() or nil
if ls and ls ~= "" then
local _, _, n = string.find(ls, "(%d+%.?%d*)%s+sec")
if n then return tonumber(n) end
end
local rt2 = getglobal("MTH_EA_BuffProbeTooltipTextRight" .. ln)
local rs = rt2 and rt2:GetText() or nil
if rs and rs ~= "" then
local _, _, n = string.find(rs, "(%d+%.?%d*)%s+sec")
if n then return tonumber(n) end
end
end
-- Buff found but no parseable duration text.
return nil
end
end
return nil
end
-- ── Fast texture-based aura scanners (no tooltip, no pcall) ─────────────────
-- UnitBuff("player", i) → icon texture path for buff slot i (nil = end)
-- UnitDebuff("player", i) → icon texture path for debuff slot i (nil = end)
-- We match by icon basename instead of buff name: zero rendering cost.
-- Known icon basenames (lower-case, no path prefix, no extension):
local MTH_EA_AMMO_TEX = {
[EXPLOSIVE] = "ability_searingarrow",
[POISONOUS] = "ability_poisonarrow",
[ENCHANTED] = "ability_theblackarrow",
}
local MTH_EA_LNL_TEX = "lockandload" -- matches "ability_hunter_lockandload"
local function MTH_EA_IconBasename(texturePath)
if not texturePath then return "" end
local s = string.lower(tostring(texturePath))
local _, _, base = string.find(s, "([^\\]+)$")
return base or s
end
-- Scan player buffs+debuffs for an active ammo state via texture comparison.
local function MTH_EA_FindAmmoStateByTexture()
for i = 1, 32 do
local t = UnitBuff("player", i)
if not t then break end
local base = MTH_EA_IconBasename(t)
for stateKey, frag in pairs(MTH_EA_AMMO_TEX) do
if base == frag then return stateKey end
end
end
for i = 1, 16 do
local t = UnitDebuff("player", i)
if not t then break end
local base = MTH_EA_IconBasename(t)
for stateKey, frag in pairs(MTH_EA_AMMO_TEX) do
if base == frag then return stateKey end
end
end
return nil
end
-- Returns true if LnL buff is currently on the player.
local function MTH_EA_HasLnLByTexture()
for i = 1, 32 do
local t = UnitBuff("player", i)
if not t then break end
if string.find(MTH_EA_IconBasename(t), MTH_EA_LNL_TEX, 1, true) then
return true
end
end
return false
end
-- Scan all player auras (buffs then debuffs) for an active ammo state.
-- Returns the matching STATE_ORDER key, or nil if none found.
-- (Delegates to the fast texture path; tooltip path kept for debug use only.)
local function MTH_EA_FindAmmoAura()
return MTH_EA_FindAmmoStateByTexture()
end
-- Restore cycle state from active auras (survives /reload within session).
local function MTH_EA_RecoverStateFromBuffs()
local stateKey = MTH_EA_FindAmmoAura()
if stateKey then
rt.state = stateKey
rt.expiresAt = GetTime() + CYCLE_DURATION
rt.consumed = false
end
end
-- ── Saved config access ───────────────────────────────────────
local function MTH_EA_GetConfig()
if type(MTH_SavedVariables) ~= "table" then
return {}
end
if type(MTH_SavedVariables.modules) ~= "table" then
MTH_SavedVariables.modules = {}
end
if type(MTH_SavedVariables.modules[MODULE_NAME]) ~= "table" then
MTH_SavedVariables.modules[MODULE_NAME] = {}
end
return MTH_SavedVariables.modules[MODULE_NAME]
end
function MTH_EA_IsEnabled()
local cfg = MTH_EA_GetConfig()
if cfg.enabled == nil then cfg.enabled = false end
return cfg.enabled == true
end
local function MTH_EA_EnsureConfigDefaults()
local cfg = MTH_EA_GetConfig()
if cfg.enabled == nil then cfg.enabled = false end
if cfg.showLnL == nil then cfg.showLnL = true end
if cfg.showHint == nil then cfg.showHint = true end
if cfg.cellSize == nil then cfg.cellSize = 40 end
if cfg.bigCD == nil then cfg.bigCD = false end
if cfg.hideOOC == nil then cfg.hideOOC = false end
end
-- Combat state flag (avoids needing InCombatLockdown, which doesn't exist in 1.12)
local inCombat = false
-- Throttle for PLAYER_AURAS_CHANGED: even with the fast texture path this event
-- fires 10-50+/sec in combat, so cap processing at ~6/sec.
local MTH_EA_AurasChangedLastTime = 0
-- UI frame handles — declared here so MTH_EA_RefreshVisibility (and every
-- function defined below) all close over the SAME locals.
local strip = nil -- root drag frame (sized to 3×1 row)
local cells = {} -- [1..3] ammo state cells
local cdTexts = {} -- [1..3] countdown fontstrings in ammo cells
local cellTop = nil -- Lock and Load cell
local cellTopCD = nil -- L&L countdown fontstring
local cellBot = nil -- action button cell
local cellBotIcon = nil -- action button icon texture
local cellBotCD = nil -- action button CD / "READY" text
local cellBotBlink = 0 -- blink phase accumulator
-- Show/hide the strip according to enabled + hideOOC + inCombat
local function MTH_EA_RefreshVisibility()
if not strip then return end
local cfg = MTH_EA_GetConfig()
local enabled = MTH_EA_IsEnabled()
local hideOOC = cfg.hideOOC == true
local shouldShow = enabled and (not hideOOC or inCombat)
if shouldShow then strip:Show() else strip:Hide() end
end
-- ── Forward declarations ────────────────────────────────────────────
local MTH_EA_UpdateUI
local MTH_EA_ApplyLayout
-- ── State machine ─────────────────────────────────────────────
local function MTH_EA_SetState(newState)
-- Track which state just ended so the predictive display knows what comes next.
if newState == IDLE and rt.state ~= IDLE then
rt.lastState = rt.state
end
rt.state = newState
rt.consumed = false
rt.expiresAt = (newState ~= IDLE) and (GetTime() + CYCLE_DURATION) or 0
MTH_EA_UpdateUI()
end
local function MTH_EA_AdvanceState()
local next = NEXT_STATE[rt.state]
MTH_EA_SetState(next or IDLE)
end
-- ── UI: Cross layout ─────────────────────────────────────────
--
-- [TOP] ← Lock and Load tracker
-- [EXP][POI][ENC] ← 3 ammo-state cells
-- [BOT] ← Action button: consume-spell or Aimed Shot
--
-- TOP and BOT are aligned with the centre cell (Poisonous, index 2).
--
local CELL_SIZE = 40
local CELL_GAP = 2
local CELL_FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" -- reused by ApplyLayout
-- Lock and Load tracking state (independent of ammo cycle)
local lnl = {
active = false,
expiresAt = 0,
DURATION = 10, -- Lock and Load real duration (tooltip: "Lasts 10 sec or until Aimed Shot is cast")
}
-- ── Confirmed icon paths (captured in-game 2026-03-21) ────────
-- Action-button spells (spellbook scan)
local ICON_AIMED = "Interface\\Icons\\INV_Spear_07"
local ICON_MULTI = "Interface\\Icons\\Ability_UpgradeMoonGlaive"
local ICON_SERPENT = "Interface\\Icons\\Ability_Hunter_QuickShot"
local ICON_ARCANE = "Interface\\Icons\\Ability_ImpalingBolt"
-- Aura/effect textures (aura-dump debug capture)
local ICON_EXP = "Interface\\Icons\\Ability_SearingArrow" -- Explosive Ammunition debuff
local ICON_POI = "Interface\\Icons\\Ability_PoisonArrow" -- Poisonous Ammunition debuff
local ICON_ENC = "Interface\\Icons\\Ability_TheBlackArrow" -- Enchanted Ammunition debuff
local ICON_LNL = "Interface\\Icons\\ability_hunter_lockandload" -- Lock and Load buff
-- ── LnL icon capture via aura diff ───────────────────────────
-- Lock and Load is a proc buff, not in the spellbook.
-- Strategy: snapshot all current buff textures while idle; when the
-- GAIN chat-message fires we flag a pending capture; the next
-- PLAYER_AURAS_CHANGED confirms the buff is on the unit and we diff
-- to extract the new texture.
local lnlBaselineBuffs = {} -- kept for compat; aura-diff no longer used
local lnlPendingCapture = false
local function MTH_EA_SnapshotBuffs()
-- No-op: ICON_LNL is hardcoded. Kept so call sites don't error.
end
local function MTH_EA_TryCaptureNewBuffIcon()
-- No-op: ICON_LNL is hardcoded. The aura-diff path was removed because it
-- caused incorrect icons (e.g. Quick Shots) when other buffs landed at the
-- same time as Lock and Load.
lnlPendingCapture = false
end
-- Action button: what spell to show / cast right now
local function MTH_EA_GetActionSpell()
if rt.state == EXPLOSIVE then
return SPELL_MULTI, ICON_MULTI, 1.00, 0.60, 0.05
elseif rt.state == POISONOUS then
return SPELL_SERPENT, ICON_SERPENT, 0.12, 0.85, 0.18
elseif rt.state == ENCHANTED then
return SPELL_ARCANE, ICON_ARCANE, 0.38, 0.12, 1.00
else
return SPELL_AIMED, ICON_AIMED, 1.00, 0.85, 0.00
end
end
-- Called from the keybind and from clicking the action button
function MTH_EA_CastActionSpell()
local spellName = MTH_EA_GetActionSpell()
if spellName then CastSpellByName(spellName) end
end
-- Build a styled cell frame parented to `parent`
local function MTH_EA_MakeCell(name, parent, w, h)
local c = CreateFrame("Frame", name, parent)
c:SetWidth(w or CELL_SIZE)
c:SetHeight(h or CELL_SIZE)
c:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
edgeSize = 10,
insets = { left = 2, right = 2, top = 2, bottom = 2 },
})
c:SetBackdropColor(0, 0, 0, 0.65)
c:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
local icon = c:CreateTexture(nil, "ARTWORK")
icon:SetPoint("TOPLEFT", c, "TOPLEFT", 3, -3)
icon:SetPoint("BOTTOMRIGHT", c, "BOTTOMRIGHT", -3, 3)
c.icon = icon
local fs = c:CreateFontString(nil, "OVERLAY")
fs:SetFont(CELL_FONT or "Fonts\\FRIZQT__.TTF", 10, nil)
fs:SetPoint("BOTTOM", c, "BOTTOM", 0, 3)
fs:SetText("")
c.cd = fs
return c
end
local function MTH_EA_CreateUI()
if strip then return end
local totalW = CELL_SIZE * 3 + CELL_GAP * 2
local totalH = CELL_SIZE * 3 + CELL_GAP * 2 -- 3 rows for cross
-- Root frame sized to the full cross bounding box
strip = CreateFrame("Frame", "MTH_ExpAmmoStrip", UIParent)
strip:SetWidth(totalW)
strip:SetHeight(totalH)
strip:SetMovable(true)
strip:EnableMouse(true)
strip:RegisterForDrag("LeftButton")
strip:SetScript("OnDragStart", function()
if IsAltKeyDown() then this:StartMoving() end
end)
strip:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
local cfg = MTH_EA_GetConfig()
local point, _, relPoint, x, y = this:GetPoint()
if point and x and y then
cfg.pos = { point = point, relPoint = relPoint,
x = math.floor(x + 0.5), y = math.floor(y + 0.5) }
end
end)
strip:SetFrameStrata("MEDIUM")
local cfg = MTH_EA_GetConfig()
if cfg.pos and cfg.pos.point then
strip:SetPoint(cfg.pos.point, UIParent, cfg.pos.relPoint or "CENTER",
cfg.pos.x or 0, cfg.pos.y or -200)
else
strip:SetPoint("CENTER", UIParent, "CENTER", 0, -200)
end
-- ── Middle row: 3 ammo-state cells ──────────────────────
for i = 1, 3 do
local def = CELL_DEF[i]
local c = MTH_EA_MakeCell("MTH_ExpAmmoCell"..i, strip)
if i == 1 then
c:SetPoint("LEFT", strip, "LEFT", 0, 0)
else
c:SetPoint("LEFT", cells[i-1], "RIGHT", CELL_GAP, 0)
end
c.icon:SetTexture(def.icon)
c.icon:SetVertexColor(0.30, 0.30, 0.30, 0.50)
cells[i] = c
cdTexts[i] = c.cd
end
-- ── TOP cell: Lock and Load ──────────────────────────────
cellTop = MTH_EA_MakeCell("MTH_ExpAmmoTop", strip)
-- Align with centre cell (cells[2])
cellTop:SetPoint("BOTTOM", cells[2], "TOP", 0, CELL_GAP)
cellTop.icon:SetTexture(ICON_LNL)
cellTop.icon:SetVertexColor(0.30, 0.30, 0.30, 0.40)
cellTopCD = cellTop.cd
-- ── BOTTOM cell: action button ───────────────────────────
cellBot = MTH_EA_MakeCell("MTH_ExpAmmoBot", strip)
cellBot:SetPoint("TOP", cells[2], "BOTTOM", 0, -CELL_GAP)
cellBot:EnableMouse(true)
cellBot:SetScript("OnMouseUp", function()
if arg1 == "LeftButton" then MTH_EA_CastActionSpell() end
end)
cellBotIcon = cellBot.icon
cellBotCD = cellBot.cd
cellBotIcon:SetTexture(ICON_AIMED)
cellBotIcon:SetVertexColor(1, 0.85, 0, 0.6)
-- Tooltip on the action button
cellBot:SetScript("OnEnter", function()
local spellName = MTH_EA_GetActionSpell()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(spellName or "")
GameTooltip:AddLine("Left-click or use keybind", 0.6, 0.6, 0.6)
GameTooltip:Show()
end)
cellBot:SetScript("OnLeave", function() GameTooltip:Hide() end)
MTH_EA_ApplyLayout()
end
MTH_EA_UpdateUI = function()
if not (strip and strip:IsShown()) then return end
local now = GetTime()
-- ── Ammo state cells ─────────────────────────────────────
for i = 1, 3 do
local c = cells[i]
local def = CELL_DEF[i]
if rt.state == IDLE then
-- Hint: highlight the border of the predicted next ammo cell
local hintState = PREDICT_NEXT[rt.lastState] or EXPLOSIVE
if def.state == hintState then
-- Use full color at moderate alpha so every hue is equally visible
c.icon:SetVertexColor(def.r, def.g, def.b, 0.70)
c:SetBackdropBorderColor(def.r, def.g, def.b, 1.0)
else
c.icon:SetVertexColor(0.28, 0.28, 0.28, 0.45)
c:SetBackdropBorderColor(def.r * 0.20, def.g * 0.20, def.b * 0.20, 0.35)
end
cdTexts[i]:SetText("")
elseif rt.state == def.state then
local remaining = math.max(0, rt.expiresAt - now)
local secs = math.floor(remaining + 0.5)
c.icon:SetVertexColor(def.r, def.g, def.b, 1)
c:SetBackdropBorderColor(def.r, def.g, def.b, 1)
cdTexts[i]:SetText("|cffffffff" .. secs .. "|r")
else
local activeIdx, thisIdx = 0, 0
for j = 1, 3 do
if STATE_ORDER[j] == rt.state then activeIdx = j end
if STATE_ORDER[j] == def.state then thisIdx = j end
end
if thisIdx > activeIdx then
c.icon:SetVertexColor(def.r * 0.45, def.g * 0.45, def.b * 0.45, 0.70)
c:SetBackdropBorderColor(def.r * 0.40, def.g * 0.40, def.b * 0.40, 0.60)
else
c.icon:SetVertexColor(0.22, 0.22, 0.22, 0.38)
c:SetBackdropBorderColor(0.18, 0.18, 0.18, 0.30)
end
cdTexts[i]:SetText("")
end
end
-- ── TOP cell: Lock and Load ───────────────────────────────
if cellTop then
if lnl.active then
local remaining = math.max(0, lnl.expiresAt - now)
local secs = math.floor(remaining + 0.5)
cellTop.icon:SetVertexColor(1.00, 0.82, 0.00, 1)
cellTop:SetBackdropBorderColor(1.00, 0.82, 0.00, 1)
cellTopCD:SetText("|cffffffff" .. secs .. "|r")
else
cellTop.icon:SetVertexColor(0.28, 0.28, 0.28, 0.40)
cellTop:SetBackdropBorderColor(0.25, 0.25, 0.25, 0.35)
cellTopCD:SetText("")
end
end
-- ── BOTTOM cell: action button ────────────────────────────
if cellBot then
local spellName, icon, r, g, b = MTH_EA_GetActionSpell()
cellBotIcon:SetTexture(icon)
if rt.state == IDLE then
-- Show Aimed Shot with CD and blink when ready
local cdRemain = 0
if type(GetSpellCooldown) == "function" and MTH_HT_GetSpellInfo then
local info = MTH_HT_GetSpellInfo(SPELL_AIMED)
if info and info.slot then
local start, duration = GetSpellCooldown(info.slot, BOOKTYPE_SPELL)
-- Ignore GCD-length cooldowns (≤1.5 s) triggered by other casts.
-- Aimed Shot's real cooldown is always longer than the GCD.
if start and duration and duration > 1.5 then
cdRemain = math.max(0, (start + duration) - now)
end
end
end
if cdRemain > 0 then
-- On cooldown: dim, show seconds
cellBotBlink = 0
cellBotIcon:SetVertexColor(r * 0.45, g * 0.45, b * 0.45, 0.70)
cellBot:SetBackdropBorderColor(r * 0.35, g * 0.35, b * 0.35, 0.55)
cellBotCD:SetText("|cffaaaaaa" .. math.floor(cdRemain + 0.5) .. "|r")
else
-- Ready: full colour + blink border
local pulse = math.abs(math.sin(cellBotBlink * 3))
cellBotIcon:SetVertexColor(r, g, b, 0.75 + pulse * 0.25)
cellBot:SetBackdropBorderColor(r, g * pulse, b * 0.2, 0.7 + pulse * 0.3)
cellBotCD:SetText("|cff00ff00READY|r")
end
else
-- Ammo active: show consume spell, fully lit
cellBotBlink = 0
cellBotIcon:SetVertexColor(r, g, b, 1)
cellBot:SetBackdropBorderColor(r, g, b, 1)
cellBotCD:SetText("|cffffe066USE|r")
end
end
end
-- ── Layout / appearance manager ──────────────────────────────────────────────
-- Called once after CreateUI (with saved settings) and on every option change.
MTH_EA_ApplyLayout = function()
if not strip then return end
local cfg = MTH_EA_GetConfig()
local sz = math.max(20, math.min(80, cfg.cellSize or 40))
local bigCD = cfg.bigCD == true
local font = CELL_FONT
strip:SetWidth(sz * 3 + CELL_GAP * 2)
strip:SetHeight(sz * 3 + CELL_GAP * 2)
for i = 1, 3 do
local c = cells[i]
local fs = cdTexts[i]
c:SetWidth(sz)
c:SetHeight(sz)
fs:ClearAllPoints()
if bigCD then
fs:SetPoint("CENTER", c, "CENTER", 0, 0)
fs:SetFont(font, math.floor(sz * 0.55 + 0.5), "OUTLINE")
else
fs:SetPoint("BOTTOM", c, "BOTTOM", 0, 3)
fs:SetFont(font, 10, nil)
end
end
if cellTop then
cellTop:SetWidth(sz)
cellTop:SetHeight(sz)
if cfg.showLnL ~= false then cellTop:Show() else cellTop:Hide() end
if cellTopCD then
cellTopCD:ClearAllPoints()
if bigCD then
cellTopCD:SetPoint("CENTER", cellTop, "CENTER", 0, 0)
cellTopCD:SetFont(font, math.floor(sz * 0.55 + 0.5), "OUTLINE")
else
cellTopCD:SetPoint("BOTTOM", cellTop, "BOTTOM", 0, 3)
cellTopCD:SetFont(font, 10, nil)
end
end
end
if cellBot then
cellBot:SetWidth(sz)
cellBot:SetHeight(sz)
if cfg.showHint ~= false then cellBot:Show() else cellBot:Hide() end
if cellBotCD then
cellBotCD:ClearAllPoints()
if bigCD then
cellBotCD:SetPoint("CENTER", cellBot, "CENTER", 0, 0)
cellBotCD:SetFont(font, math.floor(sz * 0.45 + 0.5), "OUTLINE")
else
cellBotCD:SetPoint("BOTTOM", cellBot, "BOTTOM", 0, 3)
cellBotCD:SetFont(font, 10, nil)
end
end
end
MTH_EA_UpdateUI()
end
-- ── Spell-name capture hook ───────────────────────────────────
-- We hook CastSpellByName / CastSpell / UseAction to know which
-- spell is about to be cast before SPELLCAST_STOP fires (which
-- in 1.12 carries no spell-name argument itself).
-- Guards prevent re-entrancy; we chain into whatever was already
-- installed (Quiver, SmartAmmo, etc.) at the end of our hook.
local MTH_EA_LastSpell = nil
local MTH_EA_InHookCSBN = false
local MTH_EA_InHookCS = false
local MTH_EA_InHookUA = false
local MTH_EA_OrigCSBN = CastSpellByName
local MTH_EA_OrigCS = CastSpell
local MTH_EA_OrigUA = UseAction
local function MTH_EA_HookInstallCSBN()
CastSpellByName = function(spellText, onSelf)
if MTH_EA_InHookCSBN then
local f = MTH_EA_OrigCSBN
if f and f ~= CastSpellByName then return f(spellText, onSelf) end
return
end
MTH_EA_InHookCSBN = true
local _, _, name = string.find(tostring(spellText or ""), "([%w%'%s%-]+)")
MTH_EA_LastSpell = name and (string.gsub(name, "^%s*(.-)%s*$", "%1")) or nil
local upstream = MTH_EA_OrigCSBN
-- Always call the current global so Quiver/SmartAmmo's chain is respected
local cur = CastSpellByName
MTH_EA_InHookCSBN = false
if upstream and upstream ~= cur then
return upstream(spellText, onSelf)
elseif cur ~= MTH_EA_HookInstallCSBN then
-- another addon replaced us; call them
end
-- fallback: nothing more to call (we ARE the last in chain)
end
end
local function MTH_EA_HookInstallCS()
local origCS = CastSpell
CastSpell = function(spellId, bookType)
if MTH_EA_InHookCS then
if origCS then return origCS(spellId, bookType) end
return
end
MTH_EA_InHookCS = true
if type(GetSpellName) == "function" then
local n = GetSpellName(spellId, bookType or "spell")
if n and n ~= "" then MTH_EA_LastSpell = n end
end
local result
if origCS then result = origCS(spellId, bookType) end
MTH_EA_InHookCS = false
return result
end
end
local function MTH_EA_HookInstallUA()
local origUA = UseAction
UseAction = function(slot, checkCursor, onSelf)
if MTH_EA_InHookUA then
if origUA then return origUA(slot, checkCursor, onSelf) end
return
end
MTH_EA_InHookUA = true
if type(GetActionText) == "function" and type(GetSpellName) == "function" then
-- For spell-type actions, resolve name via tooltip probe
local probe = MTH_SA_AmmoProbe -- reuse SmartAmmo's tooltip probe if available
if probe then
probe:SetOwner(UIParent, "ANCHOR_NONE")
probe:SetAction(slot)
local txt = MTH_SA_AmmoProbeTextLeft1 and MTH_SA_AmmoProbeTextLeft1:GetText()
if txt and txt ~= "" then MTH_EA_LastSpell = txt end
end
end
local result
if origUA then result = origUA(slot, checkCursor, onSelf) end
MTH_EA_InHookUA = false
return result
end
end
-- ── Debug mode ────────────────────────────────────────────────
-- /script MTH_EA_DebugMode = true → prints every Ammunition chat event
-- /script MTH_EA_DebugMode = false → silent
MTH_EA_DebugMode = false
-- ── Broad debug sniffer ───────────────────────────────────────
-- A separate frame that registers EVERY CHAT_MSG_SPELL_* event and
-- prints any message containing "Ammunition", so we can find exactly
-- which event the server uses for the gain messages.
local MTH_EA_Sniffer = CreateFrame("Frame", "MTH_EA_SnifferFrame")
local MTH_EA_SNIFFER_EVENTS = {
"CHAT_MSG_SPELL_SELF_BUFF",
"CHAT_MSG_SPELL_AURA_GONE_SELF",
"CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
"CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF",
"CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF",
"CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF",
"CHAT_MSG_SPELL_AURA_GONE_PARTY",
"CHAT_MSG_SPELL_AURA_GONE_OTHER",
}
for _, ev in ipairs(MTH_EA_SNIFFER_EVENTS) do
MTH_EA_Sniffer:RegisterEvent(ev)
end
MTH_EA_Sniffer:SetScript("OnEvent", function()
if not MTH_EA_DebugMode then return end
local msg = arg1 or ""
if string.find(msg, "Ammunition") then
end
end)
-- ── Event frame ───────────────────────────────────────────────
local frame = CreateFrame("Frame", "MTH_ExpAmmoFrame")
frame:Hide()
frame._elapsed = 0
frame:RegisterEvent("VARIABLES_LOADED")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PLAYER_REGEN_DISABLED") -- entering combat
frame:RegisterEvent("PLAYER_REGEN_ENABLED") -- combat ends (cycle position preserved)
frame:RegisterEvent("PLAYER_AURAS_CHANGED") -- /reload recovery
frame:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF") -- gain fallback
frame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE") -- TWoW: debuff gain ("afflicted by")
frame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF") -- loss (confirmed)
frame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS") -- loss fallback
frame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF")
frame:RegisterEvent("SPELLCAST_STOP")
frame:RegisterEvent("SPELLCAST_FAILED")
frame:RegisterEvent("SPELLCAST_INTERRUPTED")
frame:SetScript("OnUpdate", function()
if not MTH_EA_IsEnabled() then return end
local dt = arg1 or 0
this._elapsed = (this._elapsed or 0) + dt
cellBotBlink = cellBotBlink + dt -- accumulate for blink pulse
-- Natural state expiry: the game doesn't auto-advance on expiry,
-- so we go to IDLE. If the player consumed it, CHAT_MSG_AURA_GONE_SELF
-- already handled it; this path only fires on genuine timeout.
if rt.state ~= IDLE and GetTime() >= rt.expiresAt then
MTH_EA_SetState(IDLE)
cellBotBlink = 0
end
-- LnL natural expiry (belt-and-suspenders; AURA_GONE_SELF should cover it)
if lnl.active and GetTime() >= lnl.expiresAt then
lnl.active = false
end
if this._elapsed < 0.25 then return end
this._elapsed = 0
MTH_EA_UpdateUI()
end)
frame:SetScript("OnEvent", function()
if event == "VARIABLES_LOADED" then
-- Install hooks once SavedVariables are available
MTH_EA_HookInstallCSBN()
MTH_EA_HookInstallCS()
MTH_EA_HookInstallUA()
return
end
if event == "PLAYER_ENTERING_WORLD" then
MTH_EA_EnsureConfigDefaults()
rt.state = IDLE
rt.expiresAt = 0
rt.consumed = false
rt.lastState = nil
MTH_EA_LastSpell = nil
lnl.active = false
lnlPendingCapture = false
MTH_EA_SnapshotBuffs()
-- Update talent flag from whatever the scanner has so far.
-- We do NOT gate strip visibility on this: GetTalentInfo can return
-- stale data briefly on login before the client finishes loading
-- talent data. Show the strip if enabled; MTH_HT_OnScanComplete
-- will hide it later if the talent is truly absent after a proper scan.
MTH_EA_HasTalent = MTH_HT_HasTalent and MTH_HT_HasTalent(TALENT_NAME) or false
MTH_EA_CreateUI()
if MTH_EA_IsEnabled() then
-- Recover any active ammo aura (survives /reload in same session)
MTH_EA_RecoverStateFromBuffs()
inCombat = UnitAffectingCombat and UnitAffectingCombat("player") and true or false
frame:Show()
MTH_EA_RefreshVisibility()
else
strip:Hide()
end
MTH_EA_UpdateUI()
return
end
if event == "PLAYER_REGEN_DISABLED" then
-- Entering combat
inCombat = true
MTH_EA_RefreshVisibility()
return
end
if event == "PLAYER_REGEN_ENABLED" then
-- Combat ended. The server does NOT reset the ammo sequence — it resumes
-- exactly where it left off. Keep rt.state and rt.lastState as-is.
inCombat = false
MTH_EA_RefreshVisibility()
return
end
if event == "PLAYER_AURAS_CHANGED" then
if not MTH_EA_IsEnabled() then return end
-- Throttle: cap processing at ~6/sec. Chat-message handlers cover all
-- real-time state transitions; this path is only for /reload recovery
-- and LnL sync, so a 150 ms gate is perfectly safe.
local now = GetTime()
if (now - MTH_EA_AurasChangedLastTime) < 0.15 then return end
MTH_EA_AurasChangedLastTime = now
-- Pending LnL icon capture (no-op stub; kept to avoid errors).
if lnlPendingCapture then
MTH_EA_TryCaptureNewBuffIcon()
end
-- ── LnL sync: texture-based, zero tooltip cost ───────────────────
-- We only SET active here (initial gain / missed chat message).
-- LOSS is authoritative from the AURA_GONE_SELF chat handler + OnUpdate
-- expiry, so we deliberately do NOT clear lnl.active when not found —
-- AURAS_CHANGED fires for every unrelated buff change and a transient
-- miss during slot reordering would prematurely kill the timer.
if MTH_EA_HasLnLByTexture() then
if not lnl.active then
lnl.active = true
lnl.expiresAt = now + lnl.DURATION
end
lnlPendingCapture = false
end
-- ── Recovery: resync ammo state after /reload ─────────────────────
if rt.state == IDLE then
local stateKey = MTH_EA_FindAmmoStateByTexture()
if stateKey then
rt.state = stateKey
rt.expiresAt = now + CYCLE_DURATION
rt.consumed = false
end
MTH_EA_UpdateUI()
end
return
end
-- ── Primary state detection via combat log messages ───────────────
-- On TWoW/MaNGOS the event routing for debuffs may differ from
-- vanilla. We therefore check BOTH gain and loss patterns in
-- every chat_msg_spell event we receive, and match regardless of
-- which specific event name the server chose to use.
local function MTH_EA_TryHandleChatMsg(msg)
if not MTH_EA_IsEnabled() then return false end
msg = msg or ""
-- ── State GAIN patterns ─────────────────────────────────────
local _, _, gained = string.find(msg, "^You are afflicted by (.+)%.$")
if not gained then
_, _, gained = string.find(msg, "^You gain (.+)%.$")
end
if not gained then
_, _, gained = string.find(msg, "^You gain the effect of (.+)%.$")
end
if gained then
-- Ammo state?
local stateKey = BUFF_TO_STATE[gained]
if stateKey then
if MTH_EA_DebugMode then
end
MTH_EA_SetState(stateKey)
return true
end
-- Lock and Load? Chat message is a late-arriving fallback;
-- PLAYER_AURAS_CHANGED + tooltip scan is the primary path.
if string.find(gained, "Lock and Load") then
if not lnl.active then
-- Fallback: AURAS_CHANGED missed it; sync from tooltip now.
local rem = MTH_EA_GetLnLRemaining()
lnl.active = true
lnl.expiresAt = GetTime() + (rem or lnl.DURATION)
end
if MTH_EA_DebugMode then
end
return true
end
end
-- ── State LOSS patterns ──────────────────────────────────────
local _, _, lost = string.find(msg, "^(.+) fades from you%.$")
if not lost then
_, _, lost = string.find(msg, "^(.+) fades from you$") -- no trailing period
end
if lost then
-- Ammo state?
local stateKey = BUFF_TO_STATE[lost]
if stateKey then
if MTH_EA_DebugMode then
end
if rt.state == stateKey then
MTH_EA_SetState(IDLE)
end
return true
end
-- Lock and Load?
if string.find(lost, "Lock and Load") then
if MTH_EA_DebugMode then
end
lnl.active = false
lnlPendingCapture = false
MTH_EA_SnapshotBuffs() -- reset baseline; LnL no longer on player
return true
end
end
-- Debug: print any Ammunition message even if unmatched
if MTH_EA_DebugMode then
if string.find(msg, "Ammunition") then
end
end
return false
end
if event == "CHAT_MSG_SPELL_SELF_BUFF"
or event == "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"
or event == "CHAT_MSG_SPELL_AURA_GONE_SELF"
or event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
or event == "CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF" then
MTH_EA_TryHandleChatMsg(arg1)
return
end
-- ───────────────────────────────────────────────────────────
if not MTH_EA_IsEnabled() then return end
-- We rely on MTH_EA_LastSpell captured in our CastSpellByName hook.
-- Also check MTH_SA_LastSpell as a fallback (SmartAmmo's capture).
local spellName = MTH_EA_LastSpell
or (type(MTH_SA_LastSpell) == "string" and MTH_SA_LastSpell)
or ""
if event == "SPELLCAST_STOP" then
if MTH_EA_DebugMode then
end
-- Predictive: show the expected next ammo state immediately when Aimed Shot
-- fires from IDLE, before the server debuff message arrives (~1s later).
-- We predict based on lastState so we show the correct one (not always EXPLOSIVE):
-- no prior cycle / after ENCHANTED → EXPLOSIVE
-- after EXPLOSIVE → POISONOUS
-- after POISONOUS → ENCHANTED
if spellName == SPELL_AIMED and rt.state == IDLE then
local predicted = PREDICT_NEXT[rt.lastState] or EXPLOSIVE
if MTH_EA_DebugMode then
end
MTH_EA_SetState(predicted)
end
-- Consume-spell detection removed: CHAT_MSG_SPELL_AURA_GONE_SELF handles
-- state loss authoritatively. No AdvanceState here.
MTH_EA_LastSpell = nil
elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then
-- Aimed Shot interrupted: roll back any predictive state set within the last 0.5s.
if spellName == SPELL_AIMED and rt.state ~= IDLE then
local elapsed = GetTime() - (rt.expiresAt - CYCLE_DURATION)
if elapsed < 0.5 then
MTH_EA_SetState(IDLE)
end
end
MTH_EA_LastSpell = nil
end
end)
-- ── Talent re-check on respec ───────────────────────────────
-- MTH_HT_OnScanComplete is called by core-scan-huntertalents.lua
-- after every talent/spellbook scan (login, respec, PLAYER_ENTERING_WORLD).
-- We piggyback on it to update the talent gate without polling.
local MTH_EA_PrevOnScanComplete = nil
local function MTH_EA_InstallScanHook()
MTH_EA_PrevOnScanComplete = MTH_HT_OnScanComplete -- chain any existing handler
MTH_HT_OnScanComplete = function()
if MTH_EA_PrevOnScanComplete then
MTH_EA_PrevOnScanComplete()
end
local hasTalent = MTH_HT_HasTalent and MTH_HT_HasTalent(TALENT_NAME) or false
if hasTalent == MTH_EA_HasTalent then return end -- no change
MTH_EA_HasTalent = hasTalent
if not strip then return end
if MTH_EA_IsEnabled() and hasTalent then
MTH_EA_RefreshVisibility()
else
if not hasTalent then MTH_EA_SetState(IDLE) end
strip:Hide()
end
end
end
-- Install the hook after VARIABLES_LOADED (globals are guaranteed by then).
-- The event frame already handles VARIABLES_LOADED, so we add a secondary
-- one-shot frame here to avoid ordering issues.
do
local hookFrame = CreateFrame("Frame")
hookFrame:RegisterEvent("VARIABLES_LOADED")
hookFrame:SetScript("OnEvent", function()
MTH_EA_InstallScanHook()
hookFrame:UnregisterAllEvents()
end)
end
-- ── Public API ────────────────────────────────────────────────
MTH_ExpAmmo = {
SetEnabled = function(enabled)
local cfg = MTH_EA_GetConfig()
cfg.enabled = enabled and true or false
if strip then
if cfg.enabled then
MTH_EA_RefreshVisibility()
else
strip:Hide()
end
end
end,
IsEnabled = MTH_EA_IsEnabled,
GetState = function() return rt.state end,
ResetCycle = function() MTH_EA_SetState(IDLE) end,
SetShowLnL = function(v)
local cfg = MTH_EA_GetConfig()
cfg.showLnL = v and true or false
MTH_EA_ApplyLayout()
end,
SetShowHint = function(v)
local cfg = MTH_EA_GetConfig()
cfg.showHint = v and true or false
MTH_EA_ApplyLayout()
end,
SetCellSize = function(v)
local cfg = MTH_EA_GetConfig()
cfg.cellSize = math.max(20, math.min(80, tonumber(v) or 40))
MTH_EA_ApplyLayout()
end,
SetBigCD = function(v)
local cfg = MTH_EA_GetConfig()
cfg.bigCD = v and true or false
MTH_EA_ApplyLayout()
end,
SetHideOOC = function(v)
local cfg = MTH_EA_GetConfig()
cfg.hideOOC = v and true or false
MTH_EA_RefreshVisibility()
end,
}