Files
MetaHunt/api/core-scan-stablemaster.lua
T
DuvelCorp 66ff1ae711 Animations: wire remaining effects + 2-column options panel
- Shared FX toolkit in fx-celebrate.lua: mth_shake tween type, MTH_FX_Flash
  glow helper, MTH_CelebrateRunaway red shaking banner, gold title reset.
- pet.runaway, pet.happiness, pet.loyaltyup, pet.feed
- combat.ammoswap, combat.rangedswap, combat.mmprocs
- ui.zbar, ui.stable, ui.minimapping, ui.optionsclose
- book.model, book.search
- Options > Animations panel now uses a balanced two-column layout.
- CHANGELOG Animations entry expanded (no version bump).
2026-09-10 16:38:14 +02:00

4700 lines
149 KiB
Lua

if not MTH then
error("MetaHunt core framework missing: api/core-framework.lua must load before api/core-scan-stablemaster.lua")
end
local MTH_PETS_SCHEMA_VERSION = 3
local MTH_PETS_CORE_HOOK_BOUNDARY_KEY = "core-pet-rename-hook"
local MTH_ST_FULL_DEBUG_TRACE = false
MTH_PETS_TRACE_CONSISTENCY = false
local MTH_PetLifecycleEventFrame = nil
local MTH_PetLifecycleBootstrapFrame = nil
local MTH_ST_LastAutoScanAt = 0
local MTH_ST_LastUnitPetHadPet = false
local MTH_ST_DeferredStableScanFrame = nil
local MTH_ST_Profile = {
enabled = false,
windowStart = 0,
windowSeconds = 5,
eventCount = 0,
totalMs = 0,
maxMs = 0,
byEvent = {},
runawayChecks = 0,
runawayMs = 0,
runawayMaxMs = 0,
}
local MTH_ST_Trace = {
enabled = MTH_ST_FULL_DEBUG_TRACE and true or false,
verbose = MTH_ST_FULL_DEBUG_TRACE and true or false,
windowStart = 0,
windowSeconds = 5,
eventCount = 0,
byEvent = {},
byRoute = {},
byNote = {},
}
local MTH_ST_Diag = {
lines = {},
maxLines = 200,
chatEcho = MTH_ST_FULL_DEBUG_TRACE and true or false,
}
local MTH_ST_DiagCopyFrame = nil
local MTH_ST_PrintStableDiag
local MTH_PETS_LastTameAttempt = nil
local MTH_PETS_LiveState = nil
local MTH_PETS_LiveStateSeq = 0
local MTH_PETS_LiveStateSubscribers = {}
local MTH_PETS_LiveStateEventName = "MTH_PET_LIVE_STATE_CHANGED"
local MTH_PETS_EventThrottle = {}
MTH_PETS_CoreOriginal_PetRename = MTH_PETS_CoreOriginal_PetRename or nil
local function MTH_ST_Log(line)
return
end
local function MTH_ST_TraceNow()
return (type(GetTime) == "function" and GetTime()) or (type(time) == "function" and time()) or nil
end
local function MTH_ST_TraceResetCounters()
MTH_ST_Trace.eventCount = 0
MTH_ST_Trace.byEvent = {}
MTH_ST_Trace.byRoute = {}
MTH_ST_Trace.byNote = {}
MTH_ST_Trace.windowStart = MTH_ST_TraceNow() or 0
end
local function MTH_ST_TraceCount(bucket, key)
if type(bucket) ~= "table" then
return
end
local k = tostring(key or "?")
bucket[k] = (tonumber(bucket[k]) or 0) + 1
end
local function MTH_ST_TraceBuildTopList(bucket, maxRows)
local rows = {}
for key, value in pairs(bucket or {}) do
table.insert(rows, { key = tostring(key), count = tonumber(value) or 0 })
end
table.sort(rows, function(a, b)
if a.count == b.count then
return a.key < b.key
end
return a.count > b.count
end)
local limit = math.min(tonumber(maxRows) or 4, table.getn(rows))
if limit <= 0 then
return "none"
end
local parts = {}
for i = 1, limit do
table.insert(parts, tostring(rows[i].key) .. ":" .. tostring(rows[i].count))
end
return table.concat(parts, ",")
end
local function MTH_ST_TraceRecord(evt, route, note)
if not (MTH_ST_Trace and MTH_ST_Trace.enabled) then
return
end
MTH_ST_Trace.eventCount = (tonumber(MTH_ST_Trace.eventCount) or 0) + 1
MTH_ST_TraceCount(MTH_ST_Trace.byEvent, evt)
MTH_ST_TraceCount(MTH_ST_Trace.byRoute, route)
if note and note ~= "" then
MTH_ST_TraceCount(MTH_ST_Trace.byNote, note)
end
local evtText = tostring(evt or "")
local routeText = tostring(route or "")
local noteText = tostring(note or "")
if string.find(evtText, "PET_STABLE_", 1, true)
or routeText == "scan"
or string.find(noteText, "stable", 1, true)
or string.find(noteText, "deferred", 1, true)
or string.find(noteText, "throttled", 1, true) then
MTH_ST_PrintStableDiag("trace evt=" .. evtText .. " route=" .. routeText .. " note=" .. noteText)
end
if MTH_ST_Trace.verbose and MTH and MTH.Print then
local hasPet = (type(UnitExists) == "function" and UnitExists("pet")) and true or false
local dead = (type(UnitIsDeadOrGhost) == "function" and UnitIsDeadOrGhost("player")) or false
MTH:Print("[STTRACE] evt=" .. tostring(evt)
.. " route=" .. tostring(route)
.. " note=" .. tostring(note or "-")
.. " pet=" .. tostring(hasPet)
.. " dead=" .. tostring(dead and true or false), "debug")
end
end
local function MTH_ST_TraceFlushIfDue(force)
if not (MTH_ST_Trace and MTH_ST_Trace.enabled) then
return
end
local now = MTH_ST_TraceNow()
if not now then
return
end
local started = tonumber(MTH_ST_Trace.windowStart) or 0
if started <= 0 then
MTH_ST_Trace.windowStart = now
return
end
local elapsed = now - started
local windowSeconds = tonumber(MTH_ST_Trace.windowSeconds) or 5
if not force and elapsed < windowSeconds then
return
end
if MTH and MTH.Print then
MTH:Print("[STTRACE] " .. tostring(string.format("%.1fs", elapsed))
.. " events=" .. tostring(tonumber(MTH_ST_Trace.eventCount) or 0)
.. " byEvent=" .. tostring(MTH_ST_TraceBuildTopList(MTH_ST_Trace.byEvent, 6))
.. " byRoute=" .. tostring(MTH_ST_TraceBuildTopList(MTH_ST_Trace.byRoute, 6))
.. " notes=" .. tostring(MTH_ST_TraceBuildTopList(MTH_ST_Trace.byNote, 6)),
"debug")
end
MTH_ST_TraceResetCounters()
end
local function MTH_ST_IsStableUiEvent(evt)
return evt == "PET_STABLE_SHOW"
or evt == "PET_STABLE_UPDATE"
or evt == "PET_STABLE_CLOSED"
end
MTH_ST_PrintStableDiag = function(line)
return
end
local function MTH_ST_BuildDiagDumpText()
local lines = MTH_ST_Diag.lines or {}
if table.getn(lines) <= 0 then
return "(no stable diagnostics captured yet)"
end
return table.concat(lines, "\n")
end
local function MTH_ST_EnsureDiagCopyFrame()
if MTH_ST_DiagCopyFrame then
return MTH_ST_DiagCopyFrame
end
local frame = CreateFrame("Frame", "MTH_StableDiagCopy", UIParent)
if not frame then
return nil
end
frame:SetWidth(820)
frame:SetHeight(440)
frame:SetPoint("CENTER", UIParent, "CENTER")
frame:SetFrameStrata("DIALOG")
frame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true,
tileSize = 32,
edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
frame:SetBackdropColor(0, 0, 0, 0.9)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", function() frame:StartMoving() end)
frame:SetScript("OnDragStop", function() frame:StopMovingOrSizing() end)
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -15)
title:SetText("MetaHunt Stable Diagnostics")
local closeButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -12, -12)
closeButton:SetWidth(24)
closeButton:SetHeight(24)
closeButton:SetText("X")
closeButton:SetScript("OnClick", function() frame:Hide() end)
local copyButton = CreateFrame("Button", nil, frame, "GameMenuButtonTemplate")
copyButton:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 12, 12)
copyButton:SetWidth(110)
copyButton:SetHeight(24)
copyButton:SetText("Select All")
local scrollFrame = CreateFrame("ScrollFrame", "MTH_StableDiagScroll", frame, "UIPanelScrollFrameTemplate")
scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -48)
scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -36, 44)
local editBox = CreateFrame("EditBox", "MTH_StableDiagText", scrollFrame)
editBox:SetMultiLine(true)
editBox:SetMaxLetters(999999)
editBox:SetFont("Fonts\\FRIZQT__.TTF", 11, "")
editBox:SetTextColor(0.85, 0.85, 0.85, 1)
editBox:SetWidth(760)
editBox:SetHeight(1)
editBox:SetAutoFocus(false)
editBox:EnableMouse(true)
editBox:EnableMouseWheel(true)
editBox:SetScript("OnEscapePressed", function() editBox:ClearFocus() end)
editBox:SetScript("OnMouseWheel", function()
local curr = scrollFrame:GetVerticalScroll()
if arg1 and arg1 > 0 then
scrollFrame:SetVerticalScroll(curr - 24)
else
scrollFrame:SetVerticalScroll(curr + 24)
end
end)
scrollFrame:SetScrollChild(editBox)
copyButton:SetScript("OnClick", function()
editBox:HighlightText(0, -1)
editBox:SetFocus()
end)
frame._scroll = scrollFrame
frame._edit = editBox
MTH_ST_DiagCopyFrame = frame
if UISpecialFrames then
local exists = false
for i = 1, table.getn(UISpecialFrames) do
if UISpecialFrames[i] == "MTH_StableDiagCopy" then
exists = true
break
end
end
if not exists then
table.insert(UISpecialFrames, "MTH_StableDiagCopy")
end
end
return frame
end
function MTH_CommandStableDiag(mode)
local lowerMode = string.lower(tostring(mode or "on"))
MTH_ST_Diag.lines = {}
MTH_ST_Diag.chatEcho = (lowerMode ~= "off") and true or false
if MTH and MTH.Print then
MTH:Print("Stable diagnostics chat echo " .. (MTH_ST_Diag.chatEcho and "enabled" or "disabled") .. ".", "debug")
end
return MTH_ST_Diag.chatEcho
end
function MTH_CommandStableTrace(mode)
local lowerMode = string.lower(tostring(mode or "verbose"))
MTH_ST_Trace.enabled = (lowerMode ~= "off") and true or false
MTH_ST_Trace.verbose = (lowerMode == "verbose" or lowerMode == "on") and MTH_ST_Trace.enabled or false
if MTH_ST_Trace.enabled then
MTH_ST_TraceResetCounters()
end
if MTH and MTH.Print then
MTH:Print("Stable trace " .. (MTH_ST_Trace.enabled and "enabled" or "disabled")
.. " (verbose=" .. tostring(MTH_ST_Trace.verbose) .. ").", "debug")
end
return MTH_ST_Trace.enabled
end
local function MTH_ST_ProfileNow()
return (type(GetTime) == "function" and GetTime()) or (type(time) == "function" and time()) or nil
end
local function MTH_ST_ProfileResetCounters()
MTH_ST_Profile.eventCount = 0
MTH_ST_Profile.totalMs = 0
MTH_ST_Profile.maxMs = 0
MTH_ST_Profile.byEvent = {}
MTH_ST_Profile.runawayChecks = 0
MTH_ST_Profile.runawayMs = 0
MTH_ST_Profile.runawayMaxMs = 0
MTH_ST_Profile.windowStart = MTH_ST_ProfileNow() or 0
end
local function MTH_ST_ProfileRecordEvent(evt, elapsedMs)
if not (MTH_ST_Profile and MTH_ST_Profile.enabled) then
return
end
local eventName = tostring(evt or "?")
local elapsed = tonumber(elapsedMs) or 0
MTH_ST_Profile.eventCount = (tonumber(MTH_ST_Profile.eventCount) or 0) + 1
MTH_ST_Profile.totalMs = (tonumber(MTH_ST_Profile.totalMs) or 0) + elapsed
if elapsed > (tonumber(MTH_ST_Profile.maxMs) or 0) then
MTH_ST_Profile.maxMs = elapsed
end
if type(MTH_ST_Profile.byEvent[eventName]) ~= "table" then
MTH_ST_Profile.byEvent[eventName] = { count = 0, totalMs = 0, maxMs = 0 }
end
local row = MTH_ST_Profile.byEvent[eventName]
row.count = (tonumber(row.count) or 0) + 1
row.totalMs = (tonumber(row.totalMs) or 0) + elapsed
if elapsed > (tonumber(row.maxMs) or 0) then
row.maxMs = elapsed
end
end
local function MTH_ST_ProfileRecordRunaway(elapsedMs)
if not (MTH_ST_Profile and MTH_ST_Profile.enabled) then
return
end
local elapsed = tonumber(elapsedMs) or 0
MTH_ST_Profile.runawayChecks = (tonumber(MTH_ST_Profile.runawayChecks) or 0) + 1
MTH_ST_Profile.runawayMs = (tonumber(MTH_ST_Profile.runawayMs) or 0) + elapsed
if elapsed > (tonumber(MTH_ST_Profile.runawayMaxMs) or 0) then
MTH_ST_Profile.runawayMaxMs = elapsed
end
end
local function MTH_ST_ProfileBuildTopList(metricKey, maxRows)
local rows = {}
for eventName, row in pairs(MTH_ST_Profile.byEvent or {}) do
if type(row) == "table" then
table.insert(rows, {
event = tostring(eventName),
count = tonumber(row.count) or 0,
totalMs = tonumber(row.totalMs) or 0,
maxMs = tonumber(row.maxMs) or 0,
})
end
end
table.sort(rows, function(a, b)
local av = tonumber(a[metricKey]) or 0
local bv = tonumber(b[metricKey]) or 0
if av == bv then
return tostring(a.event or "") < tostring(b.event or "")
end
return av > bv
end)
local limit = math.min(tonumber(maxRows) or 3, table.getn(rows))
if limit <= 0 then
return "none"
end
local parts = {}
for i = 1, limit do
local row = rows[i]
if metricKey == "count" then
table.insert(parts, tostring(row.event) .. ":" .. tostring(row.count))
else
table.insert(parts, tostring(row.event) .. ":" .. string.format("%.2f", tonumber(row.totalMs) or 0) .. "ms")
end
end
return table.concat(parts, ",")
end
local function MTH_ST_ProfileFlushIfDue(force)
if not (MTH_ST_Profile and MTH_ST_Profile.enabled) then
return
end
local now = MTH_ST_ProfileNow()
if not now then
return
end
local windowStart = tonumber(MTH_ST_Profile.windowStart) or 0
if windowStart <= 0 then
MTH_ST_Profile.windowStart = now
return
end
local elapsed = now - windowStart
local windowSeconds = tonumber(MTH_ST_Profile.windowSeconds) or 5
if not force and elapsed < windowSeconds then
return
end
if MTH and MTH.Print then
local eventCount = tonumber(MTH_ST_Profile.eventCount) or 0
local totalMs = tonumber(MTH_ST_Profile.totalMs) or 0
local avgMs = (eventCount > 0) and (totalMs / eventCount) or 0
local maxMs = tonumber(MTH_ST_Profile.maxMs) or 0
local runawayChecks = tonumber(MTH_ST_Profile.runawayChecks) or 0
local runawayMs = tonumber(MTH_ST_Profile.runawayMs) or 0
local runawayAvg = (runawayChecks > 0) and (runawayMs / runawayChecks) or 0
local runawayMax = tonumber(MTH_ST_Profile.runawayMaxMs) or 0
local topCount = MTH_ST_ProfileBuildTopList("count", 4)
local topMs = MTH_ST_ProfileBuildTopList("totalMs", 4)
MTH:Print("[STPROF] " .. tostring(string.format("%.1fs", elapsed))
.. " events=" .. tostring(eventCount)
.. " total=" .. tostring(string.format("%.2f", totalMs)) .. "ms"
.. " avg=" .. tostring(string.format("%.3f", avgMs)) .. "ms"
.. " max=" .. tostring(string.format("%.3f", maxMs)) .. "ms"
.. " topCount=" .. tostring(topCount)
.. " topMs=" .. tostring(topMs)
.. " runaway=" .. tostring(runawayChecks)
.. "/" .. tostring(string.format("%.2f", runawayMs)) .. "ms"
.. " avg=" .. tostring(string.format("%.3f", runawayAvg)) .. "ms"
.. " max=" .. tostring(string.format("%.3f", runawayMax)) .. "ms",
"debug")
end
MTH_ST_ProfileResetCounters()
end
function MTH_CommandStableProfile(mode)
local lowerMode = string.lower(tostring(mode or "off"))
MTH_ST_Profile.enabled = (lowerMode ~= "off") and true or false
if MTH_ST_Profile.enabled then
MTH_ST_ProfileResetCounters()
end
if MTH and MTH.Print then
MTH:Print("Stable profiler " .. (MTH_ST_Profile.enabled and "enabled" or "disabled") .. ".", "debug")
end
return MTH_ST_Profile.enabled
end
local function MTH_PETS_LogConsistency(line)
if not MTH_PETS_TRACE_CONSISTENCY then
return
end
if MTH and MTH.Print then
MTH:Print("[PETS] " .. tostring(line or ""), "debug")
end
end
local function MTH_PETS_LogTame(_)
return
end
local function MTH_PETS_FormatRowConsistency(row)
if type(row) ~= "table" then
return "<nil-row>"
end
return "id=" .. tostring(row.id)
.. " name='" .. tostring(row.name or "") .. "'"
.. " family='" .. tostring(row.family or "") .. "'"
.. " level='" .. tostring(row.level or "") .. "'"
.. " signature='" .. tostring(row.signature or "") .. "'"
.. " guid='" .. tostring(row.guid or "") .. "'"
end
local function MTH_PETS_FormatCurrentConsistency(cp)
if type(cp) ~= "table" then
return "<nil-current>"
end
return "id=" .. tostring(cp.id)
.. " exists=" .. tostring(cp.exists)
.. " name='" .. tostring(cp.name or "") .. "'"
.. " family='" .. tostring(cp.family or "") .. "'"
.. " level='" .. tostring(cp.level or "") .. "'"
.. " signature='" .. tostring(cp.signature or "") .. "'"
.. " guid='" .. tostring(cp.guid or "") .. "'"
end
local function MTH_PETS_RequestPetSpellScan(reason)
local stableFrame = (type(getglobal) == "function" and getglobal("PetStableFrame")) or (_G and _G["PetStableFrame"])
if stableFrame and type(stableFrame.IsShown) == "function" and stableFrame:IsShown() then
MTH_ST_PrintStableDiag("swap spellscan-skip reason=stable-open src=" .. tostring(reason or "stablemaster-current-change"))
return
end
MTH_ST_PrintStableDiag("swap spellscan-request reason=" .. tostring(reason or "stablemaster-current-change"))
if type(MTH_PSP_RequestScan) == "function" then
MTH_PSP_RequestScan(tostring(reason or "stablemaster-current-change"), 1)
end
end
local function MTH_ST_IsStableFrameOpen()
local stableFrame = (type(getglobal) == "function" and getglobal("PetStableFrame")) or (_G and _G["PetStableFrame"])
return stableFrame and type(stableFrame.IsShown) == "function" and stableFrame:IsShown() and true or false
end
local function MTH_PETS_ShouldHandleThrottledEvent(key, minIntervalSeconds)
local now = (type(GetTime) == "function" and GetTime()) or (type(time) == "function" and time()) or 0
local threshold = tonumber(minIntervalSeconds) or 0
local last = tonumber(MTH_PETS_EventThrottle[key] or 0) or 0
if threshold > 0 and (now - last) < threshold then
return false
end
MTH_PETS_EventThrottle[key] = now
return true
end
local function MTH_PETS_CopyLiveState(state)
if type(state) ~= "table" then
return nil
end
local copy = {}
for key, value in pairs(state) do
copy[key] = value
end
return copy
end
local function MTH_PETS_BuildLiveState(source, skipRefresh)
local info = (type(MTH_GetCurrentPetInfo) == "function") and MTH_GetCurrentPetInfo(skipRefresh and true or false) or nil
if type(info) ~= "table" then
info = {}
end
return {
exists = info.exists and true or false,
liveExists = info.liveExists and true or false,
dead = info.dead and true or false,
suppressed = info.suppressed and true or false,
suppressedReason = tostring(info.suppressedReason or ""),
id = info.id,
name = info.name,
family = info.family,
level = tonumber(info.level) or nil,
happiness = tonumber(info.happiness) or nil,
loyalty = tonumber(info.loyalty) or nil,
loyaltyLevel = tonumber(info.loyaltyLevel) or nil,
health = tonumber(info.health) or nil,
healthMax = tonumber(info.healthMax) or nil,
xp = tonumber(info.xp) or nil,
xpMax = tonumber(info.xpMax) or nil,
xpPercent = tonumber(info.xpPercent) or nil,
lastSource = info.lastSource,
lastUpdated = tonumber(info.lastUpdated) or nil,
acquiredAt = tonumber(info.acquiredAt) or nil,
withMeSinceAt = tonumber(info.withMeSinceAt) or nil,
source = tostring(source or "stablemaster"),
}
end
local function MTH_PETS_IsSameLiveStateField(a, b)
if a == b then
return true
end
if a == nil or b == nil then
return false
end
return tostring(a) == tostring(b)
end
local function MTH_PETS_AreLiveStatesEqual(left, right)
if type(left) ~= "table" or type(right) ~= "table" then
return false
end
local keys = {
"exists", "liveExists", "dead", "suppressed", "suppressedReason",
"id", "name", "family", "level", "happiness", "loyalty", "loyaltyLevel",
"health", "healthMax", "xp", "xpMax", "xpPercent", "lastSource", "lastUpdated", "acquiredAt", "withMeSinceAt",
}
for _, key in ipairs(keys) do
if not MTH_PETS_IsSameLiveStateField(left[key], right[key]) then
return false
end
end
return true
end
local function MTH_PETS_NotifyLiveSubscribers(state, source)
for key, callback in pairs(MTH_PETS_LiveStateSubscribers) do
if type(callback) == "function" then
local ok, err = pcall(callback, MTH_PETS_CopyLiveState(state), source)
if not ok and MTH and MTH.Print then
MTH:Print("[PETS] live-state subscriber error ('" .. tostring(key) .. "'): " .. tostring(err), "error")
end
end
end
end
function MTH_PETS_EmitLiveState(source, force, options)
local emittedSource = source or "stablemaster"
if type(emittedSource) ~= "string" then
emittedSource = tostring(emittedSource)
end
local skipRefresh = false
if type(options) == "table" and options.skipRefresh == true then
skipRefresh = true
end
local nextState = MTH_PETS_BuildLiveState(emittedSource, skipRefresh)
local changed = force and true or (not MTH_PETS_AreLiveStatesEqual(MTH_PETS_LiveState, nextState))
if not changed then
return false
end
MTH_PETS_LiveStateSeq = MTH_PETS_LiveStateSeq + 1
nextState.seq = MTH_PETS_LiveStateSeq
nextState.changedAt = time()
MTH_PETS_LiveState = nextState
if MTH and MTH.FireEvent then
MTH:FireEvent(MTH_PETS_LiveStateEventName, MTH_PETS_CopyLiveState(nextState), emittedSource)
end
MTH_PETS_NotifyLiveSubscribers(nextState, emittedSource)
return true
end
function MTH_PETS_GetLiveState()
if type(MTH_PETS_LiveState) ~= "table" then
MTH_PETS_EmitLiveState("api:init", true)
end
return MTH_PETS_CopyLiveState(MTH_PETS_LiveState)
end
function MTH_PETS_SubscribeLiveState(key, callback, emitCurrent)
if type(key) ~= "string" or key == "" then
return false, "invalid key"
end
if type(callback) ~= "function" then
return false, "callback must be function"
end
MTH_PETS_LiveStateSubscribers[key] = callback
if emitCurrent then
local current = MTH_PETS_GetLiveState()
if type(current) == "table" then
pcall(callback, current, "subscribe:init")
end
end
return true
end
function MTH_PETS_UnsubscribeLiveState(key)
if type(key) ~= "string" or key == "" then
return false
end
MTH_PETS_LiveStateSubscribers[key] = nil
return true
end
local function MTH_PETS_SafeLower(text)
if text == nil then return "" end
return string.lower(tostring(text))
end
local function MTH_PETS_NormalizeText(text)
local normalized = tostring(text or "")
normalized = string.gsub(normalized, "^%s+", "")
normalized = string.gsub(normalized, "%s+$", "")
return normalized
end
local function MTH_PETS_GetLoyaltyLevelNames()
local defaults = {
[1] = "Rebellious",
[2] = "Unruly",
[3] = "Submissive",
[4] = "Dependable",
[5] = "Faithful",
[6] = "Best Friend",
}
local names = {}
for level = 1, 6 do
local key = "PET_LOYALTY" .. tostring(level)
local value = (_G and _G[key]) or (type(getglobal) == "function" and getglobal(key))
value = MTH_PETS_NormalizeText(value)
if value ~= "" then
names[level] = value
else
names[level] = defaults[level]
end
end
return names
end
local function MTH_PETS_GetLoyaltyNameByLevel(level)
local resolvedLevel = tonumber(level)
if not resolvedLevel or resolvedLevel < 1 or resolvedLevel > 6 then
return nil
end
local names = MTH_PETS_GetLoyaltyLevelNames()
return names[resolvedLevel]
end
local function MTH_PETS_ParseLoyaltyLevelFromText(text)
text = MTH_PETS_NormalizeText(text)
if text == "" then
return nil
end
local _, _, levelText = string.find(text, "[Ll]oyalty%s*[Ll]evel%s*(%d+)")
local level = tonumber(levelText)
if level and level >= 1 and level <= 6 then
return level
end
local lowerText = string.lower(text)
local names = MTH_PETS_GetLoyaltyLevelNames()
for index = 1, 6 do
local loyaltyName = MTH_PETS_NormalizeText(names[index])
if loyaltyName ~= "" and string.find(lowerText, string.lower(loyaltyName), 1, true) then
return index
end
end
return nil
end
local function MTH_PETS_FormatLoyaltyDisplay(level, text)
local resolvedLevel = tonumber(level)
if (not resolvedLevel or resolvedLevel < 1 or resolvedLevel > 6) then
resolvedLevel = MTH_PETS_ParseLoyaltyLevelFromText(text)
end
local resolvedName = MTH_PETS_GetLoyaltyNameByLevel(resolvedLevel)
if resolvedLevel and resolvedName ~= "" then
return "Level " .. tostring(resolvedLevel) .. " " .. tostring(resolvedName)
end
if resolvedLevel then
return "Level " .. tostring(resolvedLevel)
end
return nil
end
local function MTH_PETS_IsPlaceholderPetName(name)
local normalized = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(name))
if normalized == "" then
return true
end
if normalized == "unknown" then
return true
end
local unknownObject = _G and _G["UNKNOWNOBJECT"]
if type(unknownObject) == "string" and unknownObject ~= "" then
local unknownNormalized = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(unknownObject))
if unknownNormalized ~= "" and normalized == unknownNormalized then
return true
end
end
return false
end
local function MTH_PETS_GetPlayerCoords()
if type(GetPlayerMapPosition) ~= "function" then
return nil, nil
end
local x, y = GetPlayerMapPosition("player")
x = tonumber(x)
y = tonumber(y)
if not x or not y then
return nil, nil
end
if x <= 0 and y <= 0 then
return nil, nil
end
return x, y
end
local function MTH_PETS_CaptureContext()
local x, y = MTH_PETS_GetPlayerCoords()
local zoneName = (type(GetRealZoneText) == "function") and GetRealZoneText() or nil
if (not zoneName or zoneName == "") and type(GetZoneText) == "function" then
zoneName = GetZoneText()
end
local subZoneName = (type(GetSubZoneText) == "function") and GetSubZoneText() or nil
if subZoneName == "" then
subZoneName = nil
end
if zoneName == "" then
zoneName = nil
end
local hunterLevel = (type(UnitLevel) == "function") and UnitLevel("player") or nil
return {
timestamp = time(),
zone = zoneName,
subZone = subZoneName,
x = x,
y = y,
hunterLevel = hunterLevel,
}
end
local function MTH_PETS_GetStableMasterName()
if type(UnitName) ~= "function" then
return nil
end
local name = nil
if type(UnitExists) == "function" and UnitExists("npc") then
name = UnitName("npc")
end
if (not name or name == "") and type(UnitExists) == "function" and UnitExists("target") then
if type(UnitIsPlayer) ~= "function" or not UnitIsPlayer("target") then
name = UnitName("target")
end
end
name = MTH_PETS_NormalizeText(name)
if name == "" then
return nil
end
return name
end
local function MTH_PETS_NormalizeSystemMessage(rawMessage)
local message = tostring(rawMessage or "")
if message == "" then
return ""
end
message = string.gsub(message, "|c%x%x%x%x%x%x%x%x", "")
message = string.gsub(message, "|r", "")
message = string.gsub(message, "^%s+", "")
message = string.gsub(message, "%s+$", "")
return message
end
local function MTH_PETS_NormalizeSystemMessageKey(rawMessage)
local message = MTH_PETS_NormalizeSystemMessage(rawMessage)
message = string.lower(tostring(message or ""))
message = string.gsub(message, "%%s", " ")
message = string.gsub(message, "[^%w%s]", " ")
message = string.gsub(message, "%s+", " ")
message = string.gsub(message, "^%s+", "")
message = string.gsub(message, "%s+$", "")
return message
end
local function MTH_PETS_GetUnitGuid(unitToken)
local token = tostring(unitToken or "")
if token == "" then
return nil
end
local getUnitGuid = (type(getglobal) == "function" and getglobal("GetUnitGUID")) or (_G and _G["GetUnitGUID"])
if type(getUnitGuid) == "function" then
local ok, guid = pcall(getUnitGuid, token)
if ok and type(guid) == "string" and guid ~= "" then
return guid
end
end
if type(UnitGUID) == "function" then
local ok, guid = pcall(UnitGUID, token)
if ok and type(guid) == "string" and guid ~= "" then
return guid
end
end
return nil
end
local function MTH_PETS_IsRunawaySystemMessage(rawMessage)
local message = MTH_PETS_NormalizeSystemMessage(rawMessage)
if message == "" then
return false
end
local lostPetMessage = _G and _G["PETTAME_LOSTPET"]
if type(lostPetMessage) == "string" and lostPetMessage ~= "" then
if message == lostPetMessage then
return true
end
local messageKey = MTH_PETS_NormalizeSystemMessageKey(message)
local lostKey = MTH_PETS_NormalizeSystemMessageKey(lostPetMessage)
if messageKey ~= "" and lostKey ~= "" then
if messageKey == lostKey then
return true
end
if string.find(messageKey, lostKey, 1, true) then
return true
end
end
end
local lower = string.lower(message)
if string.find(lower, "pet has run away", 1, true) then return true end
if string.find(lower, "pet ran away", 1, true) then return true end
if string.find(lower, "pet has fled", 1, true) then return true end
if string.find(lower, "pet fled", 1, true) then return true end
if string.find(lower, "pet has left", 1, true) then return true end
if string.find(lower, "pet left", 1, true) then return true end
return false
end
local function MTH_PETS_ParseCreatureIdFromGuid(guid)
local guidText = tostring(guid or "")
if guidText == "" then
return nil
end
-- TBC-style string GUID: "Creature-realm-map-inst-?-ENTRY-spawn"
local _, _, creatureIdText = string.find(guidText, "^Creature%-%d+%-%d+%-%d+%-%d+%-(%d+)%-%d+$")
if creatureIdText then
return tonumber(creatureIdText)
end
-- Vanilla/TurtleWoW hex GUID: "0xF130EEEEEECCCCCC" — entry is bits 24-47 (hex chars 7-12)
if string.sub(guidText, 1, 2) == "0x" and string.len(guidText) >= 14 then
local entryHex = string.sub(guidText, 7, 12)
local parsed = tonumber(entryHex, 16)
if parsed and parsed > 0 then
return parsed
end
end
return nil
end
-- Canonical pet key: the pet GUID with its low 16 bits (last 4 hex chars) zeroed.
-- On this core the low 16 bits change every session while the high portion stays
-- stable per physical pet, so the full GUID cannot be used as a durable identity
-- key. The canonical key is that durable identity, derived purely from the GUID.
function MTH_PETS_CanonicalKey(guid)
if type(guid) ~= "string" or guid == "" then
return nil
end
local body = string.gsub(guid, "^0[xX]", "")
if string.len(body) < 5 then
return nil
end
return "0x" .. string.sub(body, 1, string.len(body) - 4) .. "0000"
end
local function MTH_PETS_ParseBeastLevelBounds(levelField)
local levelText = tostring(levelField or "")
if levelText == "" then
return nil, nil
end
local exact = tonumber(levelText)
if exact then
return exact, exact
end
local _, _, minLevel, maxLevel = string.find(levelText, "(%d+)%s*%-%s*(%d+)")
if minLevel and maxLevel then
return tonumber(minLevel), tonumber(maxLevel)
end
local _, _, single = string.find(levelText, "(%d+)")
if single then
local parsed = tonumber(single)
return parsed, parsed
end
return nil, nil
end
local function MTH_PETS_LevelMatchesBeast(levelField, targetLevel)
local numericTargetLevel = tonumber(targetLevel)
if not numericTargetLevel then
return false
end
local minLevel, maxLevel = MTH_PETS_ParseBeastLevelBounds(levelField)
if not minLevel and not maxLevel then
return false
end
if minLevel and maxLevel then
return numericTargetLevel >= minLevel and numericTargetLevel <= maxLevel
end
if minLevel then
return numericTargetLevel == minLevel
end
return false
end
local function MTH_PETS_FindBeastIdByDataset(name, family, level)
if type(MTH_DS_Beasts) ~= "table" then
return nil
end
local normalizedName = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(name))
if normalizedName == "" then
return nil
end
local normalizedFamily = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(family))
local candidates = {}
for beastId, beast in pairs(MTH_DS_Beasts) do
if type(beast) == "table" then
local beastName = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(beast.name))
if beastName == normalizedName then
table.insert(candidates, { id = tonumber(beastId), beast = beast })
end
end
end
if table.getn(candidates) == 1 then
return candidates[1].id
end
if table.getn(candidates) <= 0 then
return nil
end
local familyMatches = {}
if normalizedFamily ~= "" then
for i = 1, table.getn(candidates) do
local candidate = candidates[i]
local beastFamily = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(candidate.beast and candidate.beast.family))
if beastFamily ~= "" and beastFamily == normalizedFamily then
table.insert(familyMatches, candidate)
end
end
end
if table.getn(familyMatches) == 1 then
return familyMatches[1].id
end
local levelMatches = {}
local scanPool = candidates
if table.getn(familyMatches) > 0 then
scanPool = familyMatches
end
for i = 1, table.getn(scanPool) do
local candidate = scanPool[i]
if MTH_PETS_LevelMatchesBeast(candidate.beast and candidate.beast.lvl, level) then
table.insert(levelMatches, candidate)
end
end
if table.getn(levelMatches) == 1 then
return levelMatches[1].id
end
if table.getn(scanPool) > 0 then
return scanPool[1].id
end
return candidates[1].id
end
local function MTH_PETS_CaptureTargetSnapshot()
if type(UnitExists) ~= "function" or not UnitExists("target") then
return nil
end
local targetName = (type(UnitName) == "function") and UnitName("target") or nil
if MTH_PETS_IsPlaceholderPetName(targetName) then
return nil
end
local targetGuid = MTH_PETS_GetUnitGuid("target")
local beastId = MTH_PETS_ParseCreatureIdFromGuid(targetGuid)
local targetFamily = (type(UnitCreatureFamily) == "function") and UnitCreatureFamily("target") or nil
local targetLevel = (type(UnitLevel) == "function") and UnitLevel("target") or nil
if beastId == nil then
beastId = MTH_PETS_FindBeastIdByDataset(targetName, targetFamily, targetLevel)
end
return {
capturedAt = time(),
name = targetName,
family = targetFamily,
level = targetLevel,
guid = targetGuid,
beastId = beastId,
}
end
local function MTH_PETS_IsTameBeastSpellName(spellName)
local normalized = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(spellName))
if normalized == "" then
return false
end
if normalized == "tame beast" then
return true
end
local globalTameName = _G and _G["TAMEBEAST"] or nil
if type(globalTameName) == "string" then
local tameNormalized = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(globalTameName))
if tameNormalized ~= "" then
if normalized == tameNormalized then
return true
end
if string.find(normalized, tameNormalized, 1, true) then
return true
end
end
end
if string.find(normalized, "tame beast", 1, true) then
return true
end
return false
end
local function MTH_PETS_GetCurrentPlayerCastOrChannelName()
if type(UnitChannelInfo) == "function" then
local channelName = UnitChannelInfo("player")
if channelName and tostring(channelName) ~= "" then
return tostring(channelName), "UnitChannelInfo"
end
end
if type(UnitCastingInfo) == "function" then
local castName = UnitCastingInfo("player")
if castName and tostring(castName) ~= "" then
return tostring(castName), "UnitCastingInfo"
end
end
local castingBarText = _G and _G["CastingBarText"]
if castingBarText and type(castingBarText.GetText) == "function" then
local barText = castingBarText:GetText()
if barText and tostring(barText) ~= "" then
return tostring(barText), "CastingBarText"
end
end
return nil, nil
end
local function MTH_PETS_IsTargetBeastCandidate()
if type(UnitExists) ~= "function" or not UnitExists("target") then
return false
end
if type(UnitIsPlayer) == "function" and UnitIsPlayer("target") then
return false
end
if type(UnitCreatureType) == "function" then
local creatureType = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(UnitCreatureType("target")))
if creatureType ~= "" and creatureType ~= "beast" then
return false
end
end
return true
end
local function MTH_PETS_RecordTameAttempt(source, spellName)
MTH_PETS_LogTame("Channel start source=" .. tostring(source) .. " spell='" .. tostring(spellName or "") .. "'")
local resolvedSpellName = spellName
if not MTH_PETS_IsTameBeastSpellName(resolvedSpellName) then
local numericArg = tonumber(resolvedSpellName)
if numericArg and type(GetSpellInfo) == "function" then
local byIdName = GetSpellInfo(numericArg)
if byIdName and MTH_PETS_IsTameBeastSpellName(byIdName) then
resolvedSpellName = byIdName
MTH_PETS_LogTame("Resolved channel spell via GetSpellInfo(" .. tostring(numericArg) .. ") => '" .. tostring(byIdName) .. "'")
end
end
end
if not MTH_PETS_IsTameBeastSpellName(resolvedSpellName) then
local fallbackName, fallbackSource = MTH_PETS_GetCurrentPlayerCastOrChannelName()
if fallbackName and MTH_PETS_IsTameBeastSpellName(fallbackName) then
resolvedSpellName = fallbackName
MTH_PETS_LogTame("Resolved channel spell via " .. tostring(fallbackSource) .. " => '" .. tostring(fallbackName) .. "'")
end
end
if not MTH_PETS_IsTameBeastSpellName(resolvedSpellName) then
local numericArg = tonumber(spellName)
local hasNoPet = (type(UnitExists) ~= "function") or (not UnitExists("pet"))
if numericArg and numericArg >= 18000 and numericArg <= 22000 and hasNoPet and MTH_PETS_IsTargetBeastCandidate() then
local tameLabel = (_G and _G["TAMEBEAST"]) or "Tame Beast"
resolvedSpellName = tameLabel
MTH_PETS_LogTame("Resolved channel as Tame Beast via duration heuristic arg=" .. tostring(numericArg))
end
end
if not MTH_PETS_IsTameBeastSpellName(resolvedSpellName) then
MTH_PETS_LogTame("Ignored channel start: not Tame Beast")
local fallbackName, fallbackSource = MTH_PETS_GetCurrentPlayerCastOrChannelName()
if fallbackName then
MTH_PETS_LogTame("Fallback spell from " .. tostring(fallbackSource) .. " was '" .. tostring(fallbackName) .. "'")
end
return false
end
local targetSnapshot = MTH_PETS_CaptureTargetSnapshot()
if type(targetSnapshot) ~= "table" then
MTH_PETS_LogTame("Failed capture: no valid target snapshot")
return false
end
MTH_PETS_LastTameAttempt = {
source = tostring(source or "unknown"),
spellName = tostring(resolvedSpellName or ""),
capturedAt = targetSnapshot.capturedAt,
target = targetSnapshot,
}
MTH_PETS_LogTame("Captured target name='" .. tostring(targetSnapshot.name or "")
.. "' family='" .. tostring(targetSnapshot.family or "")
.. "' level='" .. tostring(targetSnapshot.level or "")
.. "' guid='" .. tostring(targetSnapshot.guid or "")
.. "' beastId='" .. tostring(targetSnapshot.beastId or "") .. "'")
MTH_PETS_LogConsistency("TameAttempt captured source=" .. tostring(source)
.. " target='" .. tostring(targetSnapshot.name or "") .. "'"
.. " family='" .. tostring(targetSnapshot.family or "") .. "'"
.. " level='" .. tostring(targetSnapshot.level or "") .. "'"
.. " beastId='" .. tostring(targetSnapshot.beastId or "") .. "'")
return true
end
local function MTH_PETS_GetPendingTameBeastId(snapshot)
local pending = MTH_PETS_LastTameAttempt
if type(pending) ~= "table" or type(pending.target) ~= "table" then
MTH_PETS_LogTame("Resolve beastId: no pending tame attempt")
return nil
end
local pendingAt = tonumber(pending.capturedAt) or 0
if pendingAt <= 0 or (time() - pendingAt) > 45 then
MTH_PETS_LogTame("Resolve beastId: pending tame expired age=" .. tostring((time() - pendingAt)))
return nil
end
local pendingTarget = pending.target
if type(snapshot) == "table" then
local targetFamily = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(pendingTarget.family))
local petFamily = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(snapshot.family))
if targetFamily ~= "" and petFamily ~= "" and targetFamily ~= petFamily then
MTH_PETS_LogTame("Resolve beastId: family mismatch target='" .. tostring(targetFamily) .. "' pet='" .. tostring(petFamily) .. "'")
return nil
end
local targetLevel = tonumber(pendingTarget.level)
local petLevel = tonumber(snapshot.level)
if targetLevel and petLevel and targetLevel ~= petLevel then
MTH_PETS_LogTame("Resolve beastId: level mismatch target='" .. tostring(targetLevel) .. "' pet='" .. tostring(petLevel) .. "'")
return nil
end
end
if tonumber(pendingTarget.beastId) and tonumber(pendingTarget.beastId) > 0 then
MTH_PETS_LogTame("Resolve beastId: using captured beastId=" .. tostring(pendingTarget.beastId))
return tonumber(pendingTarget.beastId)
end
local fromTarget = MTH_PETS_FindBeastIdByDataset(pendingTarget.name, pendingTarget.family, pendingTarget.level)
if fromTarget then
MTH_PETS_LogTame("Resolve beastId: DS lookup matched beastId=" .. tostring(fromTarget))
return fromTarget
end
MTH_PETS_LogTame("Resolve beastId: DS lookup failed for target='" .. tostring(pendingTarget.name or "") .. "'")
return nil
end
local function MTH_PETS_ResolveTameBeastIdForRow(row, snapshot)
if type(row) ~= "table" then
return nil
end
local pendingResolved = MTH_PETS_GetPendingTameBeastId(snapshot)
if pendingResolved and pendingResolved > 0 then
return pendingResolved
end
local fromSnapshot = snapshot and tonumber(snapshot.beastId) or nil
if fromSnapshot and fromSnapshot > 0 then
return fromSnapshot
end
local fromRow = tonumber(row.beastId)
if fromRow and fromRow > 0 then
return fromRow
end
local lookup = MTH_PETS_FindBeastIdByDataset(
tostring((snapshot and snapshot.name) or row.name or ""),
tostring((snapshot and snapshot.family) or row.family or ""),
tonumber((snapshot and snapshot.level) or row.level)
)
if lookup and lookup > 0 then
return lookup
end
return nil
end
local function MTH_PETS_IsTameConfirmationSource(source)
local sourceText = tostring(source or "")
return sourceText == "unit-pet-acquire" or sourceText == "refresh-current-pet"
end
local function MTH_PETS_ResolveConfirmedTameBeastId(source, snapshot)
if not MTH_PETS_IsTameConfirmationSource(source) then
return nil
end
local resolved = MTH_PETS_GetPendingTameBeastId(snapshot)
if resolved and resolved > 0 then
return resolved
end
return nil
end
local function MTH_PETS_MakeSignature(name, family, level)
-- Level intentionally excluded: it mutates on level-up, so baking it into the
-- identity signature made the same pet look like a new one after each level.
local cleanName = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(name))
local cleanFamily = MTH_PETS_SafeLower(MTH_PETS_NormalizeText(family))
return cleanName .. "|" .. cleanFamily
end
local function MTH_PETS_RowHasAnyTameMetadata(row)
if type(row) ~= "table" then
return false
end
if row.tameRecorded == true then
return true
end
if tonumber(row.tamedAt) and tonumber(row.tamedAt) > 0 then
return true
end
if tonumber(row.tameBeastId) and tonumber(row.tameBeastId) > 0 then
return true
end
if type(row.tameZone) == "string" and row.tameZone ~= "" then
return true
end
return false
end
function MTH_PETS_HasVerifiedTameRecord(row)
if type(row) ~= "table" then
return false
end
if row.tameVerified == true then
return true
end
local legacyTameContext = type(row.tameContext) == "table" and row.tameContext or nil
if legacyTameContext and (legacyTameContext.name or legacyTameContext.zone or legacyTameContext.timestamp) then
return true
end
if type(row.events) == "table" then
for i = 1, table.getn(row.events) do
local ev = row.events[i]
if type(ev) == "table" and tostring(ev.type or "") == "pet-tamed" then
return true
end
end
end
return false
end
local function MTH_PETS_BackfillTameMetadataFromEvents(row)
if type(row) ~= "table" then
return false
end
if MTH_PETS_HasVerifiedTameRecord(row) then
return false
end
if type(row.events) ~= "table" then
return false
end
local bestEvent = nil
local stableFirstSeenAt = tonumber(row.stableFirstSeenAt) or tonumber(row.stabledAt) or 0
for i = 1, table.getn(row.events) do
local ev = row.events[i]
if type(ev) == "table" and tostring(ev.type or "") == "pet-tamed" then
local evAt = tonumber(ev.at) or 0
local timelineOk = true
if stableFirstSeenAt > 0 and evAt > 0 and evAt > stableFirstSeenAt then
-- If we only saw this pet as stabled before the acquire event, it was likely a call-out, not a tame.
timelineOk = false
end
if timelineOk then
if not bestEvent then
bestEvent = ev
else
local bestAt = tonumber(bestEvent.at) or 0
if bestAt <= 0 or (evAt > 0 and evAt < bestAt) then
bestEvent = ev
end
end
end
end
end
if type(bestEvent) ~= "table" then
return false
end
local context = type(bestEvent.context) == "table" and bestEvent.context or nil
local changed = false
if row.tamedAt == nil or tonumber(row.tamedAt) == nil or tonumber(row.tamedAt) <= 0 then
local candidateAt = tonumber(bestEvent.at) or (context and tonumber(context.timestamp) or nil) or tonumber(row.createdAt)
if candidateAt and candidateAt > 0 then
row.tamedAt = candidateAt
changed = true
end
end
if context then
if row.tameHunterLevel == nil and context.hunterLevel ~= nil then
row.tameHunterLevel = context.hunterLevel
changed = true
end
if (row.tameZone == nil or row.tameZone == "") and context.zone ~= nil and tostring(context.zone) ~= "" then
row.tameZone = context.zone
changed = true
end
if (row.tameSubZone == nil or row.tameSubZone == "") and context.subZone ~= nil and tostring(context.subZone) ~= "" then
row.tameSubZone = context.subZone
changed = true
end
if row.tameX == nil and context.x ~= nil then
row.tameX = context.x
changed = true
end
if row.tameY == nil and context.y ~= nil then
row.tameY = context.y
changed = true
end
end
if (row.tameBeastId == nil or tonumber(row.tameBeastId) == nil) and row.beastId ~= nil then
local beastId = tonumber(row.beastId)
if beastId and beastId > 0 then
row.tameBeastId = beastId
changed = true
end
end
if changed then
row.tameRecorded = true
row.tameVerified = true
row.lastUpdated = time()
end
return changed
end
local function MTH_PETS_ParseNumericPetId(petId)
local _, _, numericId = string.find(tostring(petId or ""), "^pet%-(%d+)$")
return tonumber(numericId)
end
local function MTH_PETS_GetActiveRowRepairScore(pets, petId, row)
if type(row) ~= "table" then
return -1, 0
end
local score = 0
local resolvedPetId = tostring(petId or "")
local petStore = type(pets) == "table" and pets.petStore or nil
if resolvedPetId ~= "" and tostring((pets and pets.currentPetId) or "") == resolvedPetId then
score = score + 1000000
end
if resolvedPetId ~= "" and type(petStore) == "table" and tostring(petStore.activeCurrentId or "") == resolvedPetId then
score = score + 900000
end
if tonumber(row.stableSlot) and tonumber(row.stableSlot) > 0 then
score = score + 500000
end
if type(row.guid) == "string" and row.guid ~= "" then
score = score + 100000
end
if row.tameVerified == true then
score = score + 50000
end
if row.tameRecorded == true then
score = score + 10000
end
score = score + (tonumber(row.lastSeen) or tonumber(row.lastUpdated) or 0)
return score, tonumber(MTH_PETS_ParseNumericPetId(resolvedPetId)) or 0
end
local function MTH_PETS_MergeActiveRows(pets, winnerId, loserId)
if type(pets) ~= "table" or type(pets.petStore) ~= "table" then
return false
end
local petStore = pets.petStore
local winnerRow = type(petStore.activeById) == "table" and petStore.activeById[winnerId] or nil
local loserRow = type(petStore.activeById) == "table" and petStore.activeById[loserId] or nil
if type(winnerRow) ~= "table" or type(loserRow) ~= "table" or tostring(winnerId) == tostring(loserId) then
return false
end
local copyIfMissingFields = {
"guid", "signature", "beastId", "name", "family", "level",
"happiness", "loyalty", "loyaltyLevel", "xp", "xpMax", "xpPercent",
"origin", "originContext", "previousName", "stableRaw", "stableInfo",
"zone", "subZone", "x", "y", "hunterLevel", "lastSource",
"tameHunterLevel", "tameZone", "tameSubZone", "tameX", "tameY",
"tameBeastId", "tameContext",
}
for i = 1, table.getn(copyIfMissingFields) do
local field = copyIfMissingFields[i]
local winnerValue = winnerRow[field]
if winnerValue == nil or winnerValue == "" then
local loserValue = loserRow[field]
if loserValue ~= nil and loserValue ~= "" then
winnerRow[field] = loserValue
end
end
end
if type(winnerRow.petSpellbook) ~= "table" and type(loserRow.petSpellbook) == "table" then
winnerRow.petSpellbook = loserRow.petSpellbook
winnerRow.lastPetSpellbookSkipAt = winnerRow.lastPetSpellbookSkipAt or loserRow.lastPetSpellbookSkipAt
winnerRow.lastPetSpellbookSkipSource = winnerRow.lastPetSpellbookSkipSource or loserRow.lastPetSpellbookSkipSource
winnerRow.lastPetSpellbookSkipReason = winnerRow.lastPetSpellbookSkipReason or loserRow.lastPetSpellbookSkipReason
end
if type(winnerRow.abilities) ~= "table" then
winnerRow.abilities = {}
end
if type(loserRow.abilities) == "table" then
for token, ability in pairs(loserRow.abilities) do
if winnerRow.abilities[token] == nil then
winnerRow.abilities[token] = ability
end
end
end
if type(winnerRow.events) ~= "table" then
winnerRow.events = {}
end
if type(loserRow.events) == "table" then
for i = 1, table.getn(loserRow.events) do
table.insert(winnerRow.events, loserRow.events[i])
end
end
local winnerCreatedAt = tonumber(winnerRow.createdAt) or 0
local loserCreatedAt = tonumber(loserRow.createdAt) or 0
if winnerCreatedAt <= 0 or (loserCreatedAt > 0 and loserCreatedAt < winnerCreatedAt) then
winnerRow.createdAt = loserCreatedAt > 0 and loserCreatedAt or winnerRow.createdAt
end
local winnerFirstSeen = tonumber(winnerRow.firstSeen) or 0
local loserFirstSeen = tonumber(loserRow.firstSeen) or 0
if winnerFirstSeen <= 0 or (loserFirstSeen > 0 and loserFirstSeen < winnerFirstSeen) then
winnerRow.firstSeen = loserFirstSeen > 0 and loserFirstSeen or winnerRow.firstSeen
end
local winnerLastSeen = tonumber(winnerRow.lastSeen) or 0
local loserLastSeen = tonumber(loserRow.lastSeen) or 0
if loserLastSeen > winnerLastSeen then
winnerRow.lastSeen = loserLastSeen
end
local winnerLastUpdated = tonumber(winnerRow.lastUpdated) or 0
local loserLastUpdated = tonumber(loserRow.lastUpdated) or 0
if loserLastUpdated > winnerLastUpdated then
winnerRow.lastUpdated = loserLastUpdated
end
if winnerRow.tameVerified ~= true and loserRow.tameVerified == true then
winnerRow.tameVerified = true
winnerRow.tameRecorded = true
winnerRow.tamedAt = winnerRow.tamedAt or loserRow.tamedAt
winnerRow.tameHunterLevel = winnerRow.tameHunterLevel or loserRow.tameHunterLevel
winnerRow.tameZone = winnerRow.tameZone or loserRow.tameZone
winnerRow.tameSubZone = winnerRow.tameSubZone or loserRow.tameSubZone
winnerRow.tameX = winnerRow.tameX or loserRow.tameX
winnerRow.tameY = winnerRow.tameY or loserRow.tameY
winnerRow.tameBeastId = winnerRow.tameBeastId or loserRow.tameBeastId
end
if (winnerRow.stableSlot == nil or tonumber(winnerRow.stableSlot) == nil or tonumber(winnerRow.stableSlot) <= 0)
and tonumber(loserRow.stableSlot) and tonumber(loserRow.stableSlot) > 0 then
winnerRow.stableSlot = tonumber(loserRow.stableSlot)
end
if type(petStore.stableSlotIndex) == "table" then
for slotNumber, mappedPetId in pairs(petStore.stableSlotIndex) do
if tostring(mappedPetId or "") == tostring(loserId) then
petStore.stableSlotIndex[slotNumber] = tostring(winnerId)
end
end
end
if tostring(petStore.activeCurrentId or "") == tostring(loserId) then
petStore.activeCurrentId = tostring(winnerId)
end
if tostring(pets.currentPetId or "") == tostring(loserId) then
pets.currentPetId = tostring(winnerId)
end
if type(pets.currentPet) == "table" and tostring(pets.currentPet.id or "") == tostring(loserId) then
pets.currentPet.id = tostring(winnerId)
end
petStore.activeById[loserId] = nil
return true
end
local function MTH_PETS_RepairActiveRowsByGuid(pets)
if type(pets) ~= "table" or type(pets.petStore) ~= "table" or type(pets.petStore.activeById) ~= "table" then
return
end
local groupedByGuid = {}
for petId, row in pairs(pets.petStore.activeById) do
if type(row) == "table" and type(row.guid) == "string" and row.guid ~= "" then
-- Group by the CANONICAL key, not the full guid: the full guid is volatile
-- across sessions, so grouping on it never catches cross-session duplicates
-- of the same physical pet (the root cause of the duplicate-row bug).
local key = MTH_PETS_CanonicalKey(row.guid) or row.guid
if type(groupedByGuid[key]) ~= "table" then
groupedByGuid[key] = {}
end
table.insert(groupedByGuid[key], tostring(petId))
end
end
for _, petIds in pairs(groupedByGuid) do
if type(petIds) == "table" and table.getn(petIds) > 1 then
table.sort(petIds, function(a, b)
local rowA = pets.petStore.activeById[a]
local rowB = pets.petStore.activeById[b]
local scoreA, numericA = MTH_PETS_GetActiveRowRepairScore(pets, a, rowA)
local scoreB, numericB = MTH_PETS_GetActiveRowRepairScore(pets, b, rowB)
if scoreA ~= scoreB then
return scoreA > scoreB
end
if numericA ~= numericB then
return numericA < numericB
end
return tostring(a) < tostring(b)
end)
local winnerId = petIds[1]
for i = 2, table.getn(petIds) do
local loserId = petIds[i]
if pets.petStore.activeById[winnerId] and pets.petStore.activeById[loserId] then
MTH_PETS_MergeActiveRows(pets, winnerId, loserId)
end
end
end
end
end
local function MTH_PETS_EnsurePetStoreSchema(pets)
if type(pets.petStore) ~= "table" then
pets.petStore = {}
end
local petStore = pets.petStore
if type(petStore.activeById) ~= "table" then
petStore.activeById = {}
end
if type(petStore.historyById) ~= "table" then
petStore.historyById = {}
end
if type(petStore.signatureIndex) ~= "table" then
petStore.signatureIndex = {}
end
if type(petStore.guidIndex) ~= "table" then
petStore.guidIndex = {}
end
if type(petStore.stableSlotIndex) ~= "table" then
petStore.stableSlotIndex = {}
end
if tonumber(petStore.nextId) == nil or tonumber(petStore.nextId) < 1 then
petStore.nextId = 1
end
if petStore.activeCurrentId == nil then
petStore.activeCurrentId = nil
end
MTH_PETS_RepairActiveRowsByGuid(pets)
petStore.signatureIndex = {}
petStore.guidIndex = {}
local highestPetNumericId = 0
for petId, row in pairs(petStore.activeById) do
if type(row) == "table" then
local resolvedPetId = tostring(petId)
if row.id == nil or tostring(row.id) == "" then
row.id = resolvedPetId
end
local numericPetId = MTH_PETS_ParseNumericPetId(resolvedPetId)
if numericPetId and numericPetId > highestPetNumericId then
highestPetNumericId = numericPetId
end
if row.tameVerified ~= true then
local legacyTameContext = type(row.tameContext) == "table" and row.tameContext or nil
if legacyTameContext and (legacyTameContext.name or legacyTameContext.zone or legacyTameContext.timestamp) then
row.tameVerified = true
end
end
-- Re-sign level-free every load so the signature index can never retain a
-- level-baked key from older data.
row.signature = MTH_PETS_MakeSignature(row.name, row.family)
if type(row.signature) == "string" and row.signature ~= "" then
if type(petStore.signatureIndex[row.signature]) ~= "table" then
petStore.signatureIndex[row.signature] = {}
end
table.insert(petStore.signatureIndex[row.signature], resolvedPetId)
end
if type(row.guid) == "string" and row.guid ~= "" then
petStore.guidIndex[MTH_PETS_CanonicalKey(row.guid) or row.guid] = resolvedPetId
end
-- beastId is mislabeled (per-pet number, not creature id); expose petNumber
-- additively without breaking existing readers.
if row.beastId ~= nil and row.petNumber == nil then
row.petNumber = row.beastId
end
if row.loyaltyLevel == nil and type(row.loyalty) == "string" then
row.loyaltyLevel = MTH_PETS_ParseLoyaltyLevelFromText(row.loyalty)
end
if type(row.loyalty) == "string" then
row.loyalty = nil
end
row.loyaltyText = nil
row.loyaltyDisplay = nil
if type(row.stableInfo) == "table" then
if row.stableInfo.loyaltyLevel == nil then
row.stableInfo.loyaltyLevel = MTH_PETS_ParseLoyaltyLevelFromText(row.stableInfo.loyalty)
end
row.stableInfo.loyalty = nil
end
if row.tameVerified ~= true and MTH_PETS_RowHasAnyTameMetadata(row) then
row.tameRecorded = nil
end
MTH_PETS_BackfillTameMetadataFromEvents(row)
end
end
if highestPetNumericId >= tonumber(petStore.nextId) then
petStore.nextId = highestPetNumericId + 1
end
if petStore.activeCurrentId ~= nil and petStore.activeById[tostring(petStore.activeCurrentId)] == nil then
petStore.activeCurrentId = nil
end
if pets.currentPetId ~= nil and petStore.activeById[tostring(pets.currentPetId)] == nil then
pets.currentPetId = petStore.activeCurrentId
end
end
local function MTH_PETS_EnsureCurrentPetSchema(pets)
if type(pets.currentPet) ~= "table" then
pets.currentPet = {}
end
local currentPet = pets.currentPet
if currentPet.id == nil then currentPet.id = nil end
if currentPet.guid == nil then currentPet.guid = nil end
if currentPet.signature == nil then currentPet.signature = nil end
if currentPet.beastId == nil then currentPet.beastId = nil end
if currentPet.name == nil then currentPet.name = nil end
if currentPet.family == nil then currentPet.family = nil end
if currentPet.level == nil then currentPet.level = nil end
if currentPet.happiness == nil then currentPet.happiness = nil end
if currentPet.loyalty == nil then currentPet.loyalty = nil end
if currentPet.loyaltyLevel == nil then currentPet.loyaltyLevel = nil end
if currentPet.loyaltyLevel == nil and type(currentPet.loyalty) == "string" then
currentPet.loyaltyLevel = MTH_PETS_ParseLoyaltyLevelFromText(currentPet.loyalty)
end
if type(currentPet.loyalty) == "string" then
currentPet.loyalty = nil
end
currentPet.loyaltyText = nil
currentPet.loyaltyDisplay = nil
if currentPet.xp == nil then currentPet.xp = nil end
if currentPet.xpMax == nil then currentPet.xpMax = nil end
if currentPet.xpPercent == nil then currentPet.xpPercent = nil end
if currentPet.exists == nil then currentPet.exists = false end
if currentPet.lastSeen == nil then currentPet.lastSeen = 0 end
if currentPet.lastUpdated == nil then currentPet.lastUpdated = 0 end
if currentPet.acquiredAt == nil then currentPet.acquiredAt = 0 end
if currentPet.lastSource == nil then currentPet.lastSource = "" end
end
local function MTH_PETS_NormalizePetId(petId)
if petId == nil then
return nil
end
local value = tostring(petId)
if value == "" then
return nil
end
return value
end
local function MTH_PETS_SetCurrentPetId(pets, petId)
if type(pets) ~= "table" then
return nil
end
local normalized = MTH_PETS_NormalizePetId(petId)
pets.currentPetId = normalized
if type(pets.petStore) == "table" then
pets.petStore.activeCurrentId = normalized
end
return normalized
end
local function MTH_PETS_SetCurrentPetSuppressed(pets, suppressed, reason)
if type(pets) ~= "table" then
return
end
pets.currentPetSuppressed = suppressed and true or false
pets.currentPetSuppressedAwaitNoLive = suppressed and true or false
pets.currentPetSuppressedReason = suppressed and tostring(reason or "") or ""
pets.currentPetSuppressedAt = time()
end
local function MTH_PETS_ResolveNoLiveCurrentRow(pets, previousCurrentId)
if type(pets) ~= "table" then
return nil, nil
end
if pets.currentPetSuppressed == true then
return nil, nil
end
local petStore = (type(pets.petStore) == "table") and pets.petStore or nil
if type(petStore) ~= "table" or type(petStore.activeById) ~= "table" then
return nil, nil
end
local cp = pets.currentPet
local candidates = {
previousCurrentId,
cp and cp.id or nil,
pets.currentPetId,
petStore.activeCurrentId,
}
local seen = {}
for i = 1, table.getn(candidates) do
local candidateId = candidates[i]
if candidateId ~= nil then
local key = tostring(candidateId)
if not seen[key] then
seen[key] = true
local row = petStore.activeById[candidateId]
if type(row) == "table" then
local stableSlot = tonumber(row.stableSlot)
if stableSlot and stableSlot > 0 then
return nil, nil
end
return tostring(candidateId), row
end
end
end
end
return nil, nil
end
local function MTH_PETS_ApplyCurrentPetFromActiveRow(cp, petId, row)
if type(cp) ~= "table" or petId == nil or type(row) ~= "table" then
return
end
cp.id = tostring(petId)
cp.guid = row.guid
cp.signature = row.signature
cp.beastId = row.beastId
cp.name = row.name
cp.family = row.family
cp.level = row.level
cp.happiness = row.happiness
cp.loyalty = row.loyalty
cp.loyaltyLevel = row.loyaltyLevel
cp.xp = row.xp
cp.xpMax = row.xpMax
cp.xpPercent = row.xpPercent
if cp.acquiredAt == nil or tonumber(cp.acquiredAt) == nil or tonumber(cp.acquiredAt) <= 0 then
cp.acquiredAt = tonumber(row.createdAt) or tonumber(row.lastSeen) or tonumber(row.lastUpdated) or time()
end
if tonumber(cp.lastSeen) == nil or tonumber(cp.lastSeen) <= 0 then
cp.lastSeen = tonumber(row.lastSeen) or tonumber(row.lastUpdated) or time()
end
end
-- Forward declaration: the one-shot orphan cleanup is defined later (it reuses
-- MTH_PETS_MoveToHistory, declared further down), but MTH_PETS_EnsureSchema needs
-- to reference it here. By call time (a load-time event) it is assigned.
local MTH_PETS_ArchiveOrphanActiveRows
local function MTH_PETS_EnsureSchema(pets)
local now = time()
-- Capture BEFORE the stamp block rewrites schemaVersion, so we know whether the
-- one-time v3 orphan cleanup still needs to run for this character.
local needsOrphanCleanup = (tonumber(pets.schemaVersion) or 0) < 3
if pets.schemaVersion ~= MTH_PETS_SCHEMA_VERSION then
pets.schemaVersion = MTH_PETS_SCHEMA_VERSION
pets.schemaMigratedAt = now
if type(pets.schemaMigration) ~= "table" then
pets.schemaMigration = {}
end
pets.schemaMigration.version = MTH_PETS_SCHEMA_VERSION
pets.schemaMigration.at = now
pets.schemaMigration.note = "canonical identity keys + orphan cleanup"
end
if pets.updatedAt == nil then
pets.updatedAt = 0
end
if pets.createdAt == nil or tonumber(pets.createdAt) == nil or tonumber(pets.createdAt) <= 0 then
pets.createdAt = now
end
if pets.currentPetId == nil then
local cpId = (type(pets.currentPet) == "table") and pets.currentPet.id or nil
pets.currentPetId = MTH_PETS_NormalizePetId(cpId)
end
if pets.currentPetSuppressed == nil then
pets.currentPetSuppressed = false
end
if pets.currentPetSuppressedAwaitNoLive == nil then
pets.currentPetSuppressedAwaitNoLive = false
end
if pets.currentPetSuppressedReason == nil then
pets.currentPetSuppressedReason = ""
end
if pets.hasVisitedStableOnce == nil then
pets.hasVisitedStableOnce = false
end
if pets.stableVisitPromptLastAt == nil then
pets.stableVisitPromptLastAt = 0
end
MTH_PETS_EnsureCurrentPetSchema(pets)
MTH_PETS_EnsurePetStoreSchema(pets)
-- After dedup + index rebuild (done inside EnsurePetStoreSchema), sweep legacy
-- orphan rows (active but neither the current pet nor stabled) into history once.
if needsOrphanCleanup and type(MTH_PETS_ArchiveOrphanActiveRows) == "function" then
MTH_PETS_ArchiveOrphanActiveRows(pets, "migration-recovered")
end
end
local function MTH_PETS_MarkStableVisited(pets, source)
if type(pets) ~= "table" then
return
end
if pets.hasVisitedStableOnce == true then
return
end
pets.hasVisitedStableOnce = true
pets.stableVisitedAt = time()
pets.stableVisitedSource = source or "stable-scan"
pets.updatedAt = time()
end
local function MTH_PETS_MaybePromptStableVisit(pets)
if type(pets) ~= "table" then
return
end
if pets.hasVisitedStableOnce == true then
return
end
local playerLevel = (type(UnitLevel) == "function") and tonumber(UnitLevel("player")) or nil
if not playerLevel or playerLevel <= 12 then
return
end
local now = time()
local lastPrompt = tonumber(pets.stableVisitPromptLastAt) or 0
if lastPrompt > 0 and (now - lastPrompt) <= 60 then
return
end
pets.stableVisitPromptLastAt = now
if MTH and MTH.Print then
MTH:Print("No Stable scan found for this character. Visit a Stable Master once to record stable pets and slots.")
end
end
local function MTH_PETS_EnsurePetTrainingCompatibility(pets)
if type(pets.trainScan) ~= "table" and type(pets.petTraining) == "table" then
pets.trainScan = pets.petTraining
end
if type(pets.trainScan) ~= "table" then
pets.trainScan = {}
end
if type(pets.petTraining) ~= "table" then
pets.petTraining = pets.trainScan
end
if pets.petTraining ~= pets.trainScan then
pets.petTraining = pets.trainScan
end
local pt = pets.trainScan
if type(pt.spellMap) ~= "table" then
pt.spellMap = {}
end
if type(pt.hunterKnownMap) ~= "table" then
pt.hunterKnownMap = {}
end
if pt.lastScan == nil then
pt.lastScan = 0
end
if pt.lastPrompt == nil then
pt.lastPrompt = 0
end
if pt.hasCompletedPetScan == nil then
if (tonumber(pt.lastScan) or 0) > 0 then
pt.hasCompletedPetScan = 1
else
for _ in pairs(pt.spellMap) do
pt.hasCompletedPetScan = 1
break
end
end
end
end
local function MTH_PETS_GetStoreTables(pets)
if type(pets) ~= "table" then
return nil
end
MTH_PETS_EnsurePetStoreSchema(pets)
return pets.petStore
end
local function MTH_PETS_AddSignatureIndex(store, signature, petId)
if signature == nil or signature == "" or petId == nil then
return
end
if type(store.signatureIndex[signature]) ~= "table" then
store.signatureIndex[signature] = {}
end
local list = store.signatureIndex[signature]
for _, existingId in pairs(list) do
if existingId == petId then
return
end
end
table.insert(list, petId)
end
local function MTH_PETS_RemoveSignatureIndex(store, signature, petId)
if signature == nil or signature == "" or petId == nil then
return
end
local list = store.signatureIndex[signature]
if type(list) ~= "table" then
return
end
for i = table.getn(list), 1, -1 do
if list[i] == petId then
table.remove(list, i)
end
end
if table.getn(list) <= 0 then
store.signatureIndex[signature] = nil
end
end
local function MTH_PETS_GetPetById(pets, petId)
if petId == nil or type(pets) ~= "table" then
return nil
end
local store = MTH_PETS_GetStoreTables(pets)
if not store then
return nil
end
return store.activeById[petId]
end
local function MTH_PETS_CreatePetId(store)
local nextId = tonumber(store.nextId) or 1
store.nextId = nextId + 1
return "pet-" .. tostring(nextId)
end
local function MTH_PETS_MakeSnapshotFromLivePet()
local exists = (type(UnitExists) == "function") and UnitExists("pet")
if not exists then
return nil
end
local petName = (type(UnitName) == "function") and UnitName("pet") or nil
if MTH_PETS_IsPlaceholderPetName(petName) then
MTH_PETS_LogConsistency("MakeSnapshotFromLivePet ignored placeholder name='" .. tostring(petName or "") .. "'")
return nil
end
local petFamily = (type(UnitCreatureFamily) == "function") and UnitCreatureFamily("pet") or nil
local petLevel = (type(UnitLevel) == "function") and UnitLevel("pet") or nil
local happiness = nil
local loyalty = nil
local loyaltyLevel = nil
local xp = nil
local xpMax = nil
local xpPercent = nil
if type(GetPetHappiness) == "function" then
local h, _, l = GetPetHappiness()
happiness = h
loyalty = l
end
local getPetLoyalty = (type(getglobal) == "function" and getglobal("GetPetLoyalty")) or (_G and _G["GetPetLoyalty"])
if type(getPetLoyalty) == "function" then
local lLevel, lText = getPetLoyalty()
if lLevel ~= nil then
loyaltyLevel = tonumber(lLevel) or nil
end
if lText ~= nil and tostring(lText) ~= "" then
local lTextValue = tostring(lText)
local parsedFromText = MTH_PETS_ParseLoyaltyLevelFromText(lTextValue)
if loyaltyLevel == nil and parsedFromText ~= nil then
loyaltyLevel = parsedFromText
end
end
end
local petFrameHappiness = (_G and _G["PetFrameHappiness"]) or (type(getglobal) == "function" and getglobal("PetFrameHappiness"))
if type(petFrameHappiness) == "table" and type(petFrameHappiness.tooltipLoyalty) == "string" and petFrameHappiness.tooltipLoyalty ~= "" then
local tooltipText = tostring(petFrameHappiness.tooltipLoyalty)
local parsedFromTooltip = MTH_PETS_ParseLoyaltyLevelFromText(tooltipText)
if loyaltyLevel == nil and parsedFromTooltip ~= nil then
loyaltyLevel = parsedFromTooltip
end
end
local petLoyaltyText = (_G and _G["PetLoyaltyText"]) or (type(getglobal) == "function" and getglobal("PetLoyaltyText"))
if type(petLoyaltyText) == "table" and type(petLoyaltyText.GetText) == "function" then
local uiLoyaltyText = MTH_PETS_NormalizeText(petLoyaltyText:GetText())
if uiLoyaltyText ~= "" then
local parsedFromUi = MTH_PETS_ParseLoyaltyLevelFromText(uiLoyaltyText)
if loyaltyLevel == nil and parsedFromUi ~= nil then
loyaltyLevel = parsedFromUi
end
end
end
local getPetExperience = (type(getglobal) == "function" and getglobal("GetPetExperience")) or (_G and _G["GetPetExperience"])
if type(getPetExperience) == "function" then
local pxp, pxpMax = getPetExperience()
xp = tonumber(pxp)
xpMax = tonumber(pxpMax)
if xp and xpMax and xpMax > 0 then
xpPercent = math.floor((xp / xpMax) * 1000 + 0.5) / 10
end
end
local petGuid = MTH_PETS_GetUnitGuid("pet")
local beastId = MTH_PETS_ParseCreatureIdFromGuid(petGuid)
if beastId == nil then
beastId = MTH_PETS_ParseCreatureIdFromGuid(MTH_PETS_GetUnitGuid("target"))
end
local signature = MTH_PETS_MakeSignature(petName, petFamily, petLevel)
return {
name = petName,
family = petFamily,
level = petLevel,
happiness = happiness,
loyalty = loyalty,
loyaltyLevel = loyaltyLevel,
xp = xp,
xpMax = xpMax,
xpPercent = xpPercent,
guid = petGuid,
beastId = beastId,
signature = signature,
}
end
local function MTH_PETS_SelectPetIdBySnapshot(store, snapshot, previousPetId)
if type(store) ~= "table" or type(snapshot) ~= "table" then
return nil
end
if previousPetId and store.activeById[previousPetId] then
local previousRow = store.activeById[previousPetId]
if previousRow.guid and snapshot.guid
and MTH_PETS_CanonicalKey(previousRow.guid) == MTH_PETS_CanonicalKey(snapshot.guid) then
return previousPetId
end
end
local snapshotKey = snapshot.guid and MTH_PETS_CanonicalKey(snapshot.guid) or nil
if snapshotKey and store.guidIndex[snapshotKey] and store.activeById[store.guidIndex[snapshotKey]] then
return store.guidIndex[snapshotKey]
end
if snapshotKey then
for petId, row in pairs(store.activeById or {}) do
if type(row) == "table" and type(row.guid) == "string"
and MTH_PETS_CanonicalKey(row.guid) == snapshotKey then
store.guidIndex[snapshotKey] = tostring(petId)
return tostring(petId)
end
end
end
if previousPetId and store.activeById[previousPetId] then
local previousRow = store.activeById[previousPetId]
if previousRow.signature and snapshot.signature and previousRow.signature == snapshot.signature then
return previousPetId
end
end
if snapshot.signature and snapshot.signature ~= "" then
local list = store.signatureIndex[snapshot.signature]
if type(list) == "table" then
local count = 0
local candidateId = nil
for _, listedId in pairs(list) do
if listedId and store.activeById[listedId] then
count = count + 1
candidateId = listedId
end
end
if count == 1 then
return candidateId
end
end
end
return nil
end
local function MTH_PETS_ApplySnapshotToRow(row, snapshot, source, context)
if type(row) ~= "table" or type(snapshot) ~= "table" then
return
end
local isStableScan = (source == "stable-scan")
MTH_PETS_LogConsistency("ApplySnapshotToRow(before) source=" .. tostring(source)
.. " row=" .. MTH_PETS_FormatRowConsistency(row)
.. " snapshotName='" .. tostring(snapshot.name or "") .. "'")
local now = time()
row.name = snapshot.name
row.family = snapshot.family
row.level = snapshot.level
if snapshot.happiness ~= nil then
row.happiness = snapshot.happiness
elseif not isStableScan and row.happiness == nil then
row.happiness = nil
end
if not isStableScan then
if snapshot.loyalty ~= nil then
row.loyalty = snapshot.loyalty
end
if snapshot.loyaltyLevel ~= nil then
row.loyaltyLevel = snapshot.loyaltyLevel
end
if snapshot.xp ~= nil then
row.xp = snapshot.xp
end
if snapshot.xpMax ~= nil then
row.xpMax = snapshot.xpMax
end
if snapshot.xpPercent ~= nil then
row.xpPercent = snapshot.xpPercent
end
else
if (row.loyalty == nil or tostring(row.loyalty) == "") and snapshot.loyalty ~= nil and tostring(snapshot.loyalty) ~= "" then
row.loyalty = snapshot.loyalty
end
if row.loyaltyLevel == nil and snapshot.loyaltyLevel ~= nil then
row.loyaltyLevel = snapshot.loyaltyLevel
end
end
row.guid = snapshot.guid or row.guid
row.beastId = snapshot.beastId or row.beastId
local confirmedTameBeastId = MTH_PETS_ResolveConfirmedTameBeastId(source, snapshot)
if confirmedTameBeastId and type(context) == "table" then
row.tameRecorded = true
row.tameVerified = true
row.tamedAt = row.tamedAt or context.timestamp
row.tameHunterLevel = row.tameHunterLevel or context.hunterLevel
row.tameZone = row.tameZone or context.zone
row.tameSubZone = row.tameSubZone or context.subZone
row.tameX = row.tameX or context.x
row.tameY = row.tameY or context.y
row.tameBeastId = row.tameBeastId or confirmedTameBeastId
end
row.signature = snapshot.signature
row.lastUpdated = now
if not isStableScan then
row.lastSeen = now
end
row.status = "active"
row.lastSource = source or row.lastSource
if source ~= "stable-scan" then
if row.stabledAt then
row.lastUnstabledAt = now
end
row.stabledAt = nil
end
if type(context) == "table" then
row.lastContext = context
if not isStableScan then
row.zone = context.zone or row.zone
row.subZone = context.subZone or row.subZone
row.x = context.x or row.x
row.y = context.y or row.y
row.hunterLevel = context.hunterLevel or row.hunterLevel
end
end
if type(row.abilities) ~= "table" then
row.abilities = {}
end
MTH_PETS_LogConsistency("ApplySnapshotToRow(after) source=" .. tostring(source) .. " row=" .. MTH_PETS_FormatRowConsistency(row))
end
local function MTH_PETS_UpsertActivePetFromSnapshot(pets, snapshot, source, options)
if type(pets) ~= "table" or type(snapshot) ~= "table" then
return nil, false
end
local petStore = MTH_PETS_GetStoreTables(pets)
if not petStore then
return nil, false
end
local currentPet = pets.currentPet
local previousPetId = currentPet and currentPet.id or nil
local petId = MTH_PETS_SelectPetIdBySnapshot(petStore, snapshot, previousPetId)
local created = false
if not petId then
petId = MTH_PETS_CreatePetId(petStore)
created = true
end
MTH_PETS_LogConsistency("UpsertActivePet select source=" .. tostring(source)
.. " previousCurrentId=" .. tostring(previousPetId)
.. " selectedId=" .. tostring(petId)
.. " created=" .. tostring(created)
.. " snapshotName='" .. tostring(snapshot.name or "") .. "'")
local row = petStore.activeById[petId]
local wasKnownRow = type(row) == "table"
if type(row) ~= "table" then
row = {
id = petId,
createdAt = time(),
firstSeen = time(),
abilities = {},
events = {},
status = "active",
}
petStore.activeById[petId] = row
created = true
end
if row.signature and row.signature ~= snapshot.signature then
MTH_PETS_RemoveSignatureIndex(petStore, row.signature, petId)
end
if row.stableSlot and petStore.stableSlotIndex[row.stableSlot] == petId then
petStore.stableSlotIndex[row.stableSlot] = nil
end
row.stableSlot = nil
local oldGuidKey = row.guid and MTH_PETS_CanonicalKey(row.guid) or nil
local newGuidKey = snapshot.guid and MTH_PETS_CanonicalKey(snapshot.guid) or nil
if oldGuidKey and oldGuidKey ~= newGuidKey and petStore.guidIndex[oldGuidKey] == petId then
petStore.guidIndex[oldGuidKey] = nil
end
local context = MTH_PETS_CaptureContext()
MTH_PETS_ApplySnapshotToRow(row, snapshot, source, context)
row.firstSeen = row.firstSeen or time()
if newGuidKey then
petStore.guidIndex[newGuidKey] = petId
end
if snapshot.signature and snapshot.signature ~= "" then
MTH_PETS_AddSignatureIndex(petStore, snapshot.signature, petId)
end
if created then
row.origin = source or "unknown"
row.originContext = context
if type(options) == "table" and options.stableSlot then
row.origin = "stable-slot"
end
end
MTH_PETS_SetCurrentPetId(pets, petId)
pets.updatedAt = time()
MTH_PETS_LogConsistency("UpsertActivePet finalized activeCurrentId=" .. tostring(petStore.activeCurrentId)
.. " row=" .. MTH_PETS_FormatRowConsistency(row))
return petId, created
end
local function MTH_PETS_RecordStableSlot(pets, slot, raw1, raw2, raw3, raw4, raw5, raw6, raw7, raw8, scanContext, scanStableMasterName)
if type(pets) ~= "table" then
return
end
local slotNumber = tonumber(slot)
if not slotNumber or slotNumber <= 0 then
return
end
local petStore = MTH_PETS_GetStoreTables(pets)
if not petStore then
return
end
if raw2 == nil or raw2 == "" then
petStore.stableSlotIndex[slotNumber] = nil
MTH_PETS_LogConsistency("RecordStableSlot cleared slot=" .. tostring(slotNumber) .. " because name is empty")
return
end
MTH_PETS_LogConsistency("RecordStableSlot slot=" .. tostring(slotNumber)
.. " name='" .. tostring(raw2 or "") .. "'"
.. " family='" .. tostring(raw4 or "") .. "'"
.. " level='" .. tostring(raw3 or "") .. "'")
local snapshot = {
name = raw2,
family = raw4,
level = tonumber(raw3) or 0,
happiness = nil,
loyalty = nil,
loyaltyLevel = MTH_PETS_ParseLoyaltyLevelFromText(raw5),
xp = nil,
xpMax = nil,
xpPercent = nil,
guid = nil,
beastId = nil,
signature = MTH_PETS_MakeSignature(raw2, raw4, raw3),
}
local existingId = petStore.stableSlotIndex[slotNumber]
local petId = nil
if existingId and petStore.activeById[existingId] then
local existingRow = petStore.activeById[existingId]
local existingSignature = existingRow and existingRow.signature or nil
local snapshotSignature = snapshot and snapshot.signature or nil
local existingGuid = existingRow and existingRow.guid or nil
local snapshotGuid = snapshot and snapshot.guid or nil
local signatureMatches = (existingSignature and snapshotSignature and existingSignature == snapshotSignature) and true or false
local guidMatches = (existingGuid and snapshotGuid and existingGuid ~= "" and existingGuid == snapshotGuid) and true or false
if signatureMatches or guidMatches then
petId = existingId
else
MTH_PETS_LogConsistency("RecordStableSlot remapping stale slot=" .. tostring(slotNumber)
.. " existingId=" .. tostring(existingId)
.. " existingSignature='" .. tostring(existingSignature or "") .. "'"
.. " snapshotSignature='" .. tostring(snapshotSignature or "") .. "'")
petStore.stableSlotIndex[slotNumber] = nil
end
end
if not petId then
petId = MTH_PETS_SelectPetIdBySnapshot(petStore, snapshot, nil)
end
if not petId then
petId = MTH_PETS_CreatePetId(petStore)
end
local row = petStore.activeById[petId]
local wasKnownRow = type(row) == "table"
if type(row) ~= "table" then
row = {
id = petId,
createdAt = time(),
firstSeen = time(),
abilities = {},
events = {},
status = "active",
}
petStore.activeById[petId] = row
end
if row.signature and row.signature ~= snapshot.signature then
MTH_PETS_RemoveSignatureIndex(petStore, row.signature, petId)
end
local previousStableSlot = tonumber(row.stableSlot)
local wasPreviouslyStabled = previousStableSlot ~= nil and previousStableSlot > 0
local lastSourceLower = MTH_PETS_SafeLower(row.lastSource)
local didEnterStableFromActive = wasKnownRow and not wasPreviouslyStabled and lastSourceLower ~= "stable-scan"
local stableContext = type(scanContext) == "table" and scanContext or MTH_PETS_CaptureContext()
local previousStableInfo = (type(row.stableInfo) == "table") and row.stableInfo or nil
local stableMasterName = previousStableInfo and tostring(previousStableInfo.stableMasterName or "") or ""
local stableZone = previousStableInfo and previousStableInfo.stableZone or nil
local stableSubZone = previousStableInfo and previousStableInfo.stableSubZone or nil
if didEnterStableFromActive then
stableMasterName = MTH_PETS_NormalizeText(scanStableMasterName)
if stableMasterName == "" then
stableMasterName = MTH_PETS_GetStableMasterName()
end
if stableMasterName == "" then
stableMasterName = ""
end
stableZone = stableContext and stableContext.zone or nil
stableSubZone = stableContext and stableContext.subZone or nil
end
if stableSubZone == "" then stableSubZone = nil end
if stableZone == "" then stableZone = nil end
MTH_PETS_ApplySnapshotToRow(row, snapshot, "stable-scan", stableContext)
if tonumber(row.stableSlot) ~= slotNumber then
if didEnterStableFromActive then
row.stabledAt = time()
else
row.stableFirstSeenAt = row.stableFirstSeenAt or time()
end
end
row.icon = raw1
row.stableSlot = slotNumber
row.stableRaw = { raw1, raw2, raw3, raw4, raw5, raw6, raw7, raw8 }
row.stableInfo = {
icon = raw1,
name = raw2,
level = raw3,
family = raw4,
loyaltyLevel = MTH_PETS_ParseLoyaltyLevelFromText(raw5),
stabledAt = row.stabledAt,
stableFirstSeenAt = row.stableFirstSeenAt,
stableZone = stableZone,
stableSubZone = stableSubZone,
stableMasterName = stableMasterName,
r6 = raw6,
r7 = raw7,
r8 = raw8,
updatedAt = (didEnterStableFromActive and time()) or (previousStableInfo and previousStableInfo.updatedAt) or time(),
}
if snapshot.signature and snapshot.signature ~= "" then
MTH_PETS_AddSignatureIndex(petStore, snapshot.signature, petId)
end
petStore.stableSlotIndex[slotNumber] = petId
pets.updatedAt = time()
end
local function MTH_PETS_ReconcileStableSlots(pets, slotCount)
if type(pets) ~= "table" or type(pets.petStore) ~= "table" then
return
end
local petStore = pets.petStore
if type(petStore.activeById) ~= "table" or type(petStore.stableSlotIndex) ~= "table" then
return
end
local authoritativeByPetId = {}
for slotNumber, petId in pairs(petStore.stableSlotIndex) do
local numericSlot = tonumber(slotNumber)
local mappedPetId = tostring(petId or "")
if numericSlot and numericSlot > 0 and mappedPetId ~= "" and type(petStore.activeById[mappedPetId]) == "table" then
if not slotCount or numericSlot <= slotCount then
authoritativeByPetId[mappedPetId] = numericSlot
else
petStore.stableSlotIndex[slotNumber] = nil
end
else
petStore.stableSlotIndex[slotNumber] = nil
end
end
for petId, row in pairs(petStore.activeById) do
if type(row) == "table" then
local resolvedPetId = tostring(petId)
local authoritativeSlot = authoritativeByPetId[resolvedPetId]
if authoritativeSlot then
row.stableSlot = authoritativeSlot
elseif tonumber(row.stableSlot) and tonumber(row.stableSlot) > 0 then
row.stableSlot = nil
end
end
end
pets.updatedAt = time()
end
function MTH_PETS_RecordCurrentPetLearnedAbility(abilityName, rankNumber, source)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return false
end
local cp = pets.currentPet
if type(cp) ~= "table" or cp.exists ~= true or not cp.id then
return false
end
local row = MTH_PETS_GetPetById(pets, cp.id)
if type(row) ~= "table" then
return false
end
if type(row.abilities) ~= "table" then
row.abilities = {}
end
local nameText = MTH_PETS_NormalizeText(abilityName)
if nameText == "" then
return false
end
local token = MTH_PETS_SafeLower(nameText)
local numericRank = tonumber(rankNumber)
if numericRank and numericRank > 0 then
token = token .. "#" .. tostring(numericRank)
end
row.abilities[token] = {
name = nameText,
rank = numericRank,
source = source or "unknown",
recordedAt = time(),
}
row.lastUpdated = time()
pets.updatedAt = time()
return true
end
function MTH_PETS_RecordCurrentPetSpellbookSnapshot(spells, source)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return 0, false
end
local cp = pets.currentPet
if type(cp) ~= "table" or cp.exists ~= true or not cp.id then
return 0, false
end
local row = MTH_PETS_GetPetById(pets, cp.id)
if type(row) ~= "table" then
return 0, false
end
if type(spells) ~= "table" then
spells = {}
end
local stored = {}
local count = 0
for i = 1, table.getn(spells) do
local spell = spells[i]
if type(spell) == "table" and tostring(spell.name or "") ~= "" then
count = count + 1
stored[count] = {
name = tostring(spell.name or ""),
rank = spell.rank,
icon = spell.icon,
isPassive = spell.isPassive and true or false,
slot = tonumber(spell.slot) or count,
token = spell.token,
}
end
end
local existingSpellbook = row.petSpellbook
local existingCount = 0
if type(existingSpellbook) == "table" then
existingCount = tonumber(existingSpellbook.count) or 0
end
if count <= 0 and existingCount > 0 then
row.lastPetSpellbookSkipAt = time()
row.lastPetSpellbookSkipSource = source or "unknown"
row.lastPetSpellbookSkipReason = "empty-scan-preserved-existing"
row.lastUpdated = time()
pets.updatedAt = time()
return existingCount, false
end
row.petSpellbook = {
updatedAt = time(),
source = source or "unknown",
count = count,
spells = stored,
}
row.lastUpdated = time()
pets.updatedAt = time()
return count, true
end
local function MTH_PETS_MoveToHistory(pets, petId, source, context, reason)
if type(pets) ~= "table" or not petId then
return false
end
local petStore = MTH_PETS_GetStoreTables(pets)
if type(petStore) ~= "table" then
return false
end
local resolvedPetId = petId
local row = petStore and petStore.activeById and petStore.activeById[resolvedPetId]
if type(row) ~= "table" then
local normalizedPetId = MTH_PETS_NormalizePetId(petId)
if normalizedPetId ~= nil then
resolvedPetId = normalizedPetId
row = petStore and petStore.activeById and petStore.activeById[resolvedPetId]
end
end
if type(row) ~= "table" then
return false
end
if row.signature then
MTH_PETS_RemoveSignatureIndex(petStore, row.signature, resolvedPetId)
end
local guidKey = row.guid and MTH_PETS_CanonicalKey(row.guid) or nil
if guidKey and petStore.guidIndex[guidKey] == resolvedPetId then
petStore.guidIndex[guidKey] = nil
end
if row.stableSlot and petStore.stableSlotIndex[row.stableSlot] == resolvedPetId then
petStore.stableSlotIndex[row.stableSlot] = nil
end
row.status = "abandoned"
row.abandonedAt = (context and context.timestamp) or time()
row.abandonContext = context or MTH_PETS_CaptureContext()
row.abandonSource = source or "unknown"
row.abandonReason = reason or "abandon"
row.lastUpdated = time()
petStore.historyById[resolvedPetId] = row
petStore.activeById[resolvedPetId] = nil
if tostring(petStore.activeCurrentId or "") == tostring(resolvedPetId)
or tostring(pets.currentPetId or "") == tostring(resolvedPetId) then
MTH_PETS_SetCurrentPetId(pets, nil)
end
pets.updatedAt = time()
return true
end
-- One-shot legacy cleanup (v3 migration): move "orphan" active rows into history.
-- An orphan is an active row that is neither the current pet nor associated with a
-- stable slot in any way. In normal play a pet is always either summoned or stabled,
-- so such rows are abandoned pets that older code failed to archive. They are moved
-- (not deleted): all tame history is preserved. Assigned to the forward-declared
-- local so MTH_PETS_EnsureSchema (defined earlier) can call it.
function MTH_PETS_ArchiveOrphanActiveRows(pets, reason)
local petStore = type(pets) == "table" and pets.petStore or nil
if type(petStore) ~= "table" or type(petStore.activeById) ~= "table" then
return 0
end
local currentId = tostring(pets.currentPetId or petStore.activeCurrentId or "")
local orphanIds = {}
for petId, row in pairs(petStore.activeById) do
if type(row) == "table" then
local id = tostring(petId)
local isCurrent = (currentId ~= "" and id == currentId)
local inSlotIndex = false
if type(petStore.stableSlotIndex) == "table" then
for _, mappedId in pairs(petStore.stableSlotIndex) do
if tostring(mappedId or "") == id then
inSlotIndex = true
break
end
end
end
local hasStableSlot = tonumber(row.stableSlot) and tonumber(row.stableSlot) > 0
local hasStableInfo = type(row.stableInfo) == "table"
if not isCurrent and not inSlotIndex and not hasStableSlot and not hasStableInfo then
table.insert(orphanIds, id)
end
end
end
local archived = 0
for i = 1, table.getn(orphanIds) do
local id = orphanIds[i]
local row = petStore.activeById[id]
if type(row) == "table" then
row.migrationNote = "Auto-archived by 2.0 pet migration (orphaned active row)."
if MTH_PETS_MoveToHistory(pets, id, "migration-v3", nil, reason or "migration-recovered") then
archived = archived + 1
end
end
end
if archived > 0 and MTH and MTH.DebugPrint then
MTH:DebugPrint("Pet migration: archived " .. tostring(archived) .. " orphan pet row(s) to history.")
end
return archived
end
function MTH_PETS_RecordPetAbandon(source, explicitName)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return false
end
local cp = pets.currentPet
local petId = cp and cp.id or nil
if not petId then
local petStore = MTH_PETS_GetStoreTables(pets)
if type(petStore) ~= "table" then
return false
end
local petName = MTH_PETS_NormalizeText(explicitName or (cp and cp.name) or "")
local petFamily = cp and cp.family or nil
local petLevel = cp and cp.level or nil
local signature = MTH_PETS_MakeSignature(petName, petFamily, petLevel)
local list = petStore.signatureIndex[signature]
if type(list) == "table" and table.getn(list) == 1 then
petId = list[1]
end
end
local context = MTH_PETS_CaptureContext()
local ok = MTH_PETS_MoveToHistory(pets, petId, source or "abandon", context, "pet-abandon")
if type(cp) == "table" then
cp.exists = false
cp.id = nil
cp.guid = nil
cp.signature = nil
cp.beastId = nil
cp.name = nil
cp.family = nil
cp.level = nil
cp.happiness = nil
cp.loyalty = nil
cp.loyaltyLevel = nil
cp.xp = nil
cp.xpMax = nil
cp.xpPercent = nil
cp.lastUpdated = time()
cp.lastSource = source or "abandon"
end
MTH_PETS_SetCurrentPetId(pets, nil)
MTH_PETS_SetCurrentPetSuppressed(pets, true, "abandon")
MTH_PETS_LastTameAttempt = nil
return ok
end
function MTH_PETS_RecordPetRunaway(source, rawMessage)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return false
end
local cp = pets.currentPet
local petId = cp and cp.id or nil
local petName = cp and cp.name or nil
local context = MTH_PETS_CaptureContext()
context.systemMessage = MTH_PETS_NormalizeSystemMessage(rawMessage)
local ok = MTH_PETS_MoveToHistory(pets, petId, source or "runaway", context, "pet-runaway")
if not ok then
return false
end
if type(cp) == "table" then
cp.exists = false
cp.id = nil
cp.guid = nil
cp.signature = nil
cp.beastId = nil
cp.name = nil
cp.family = nil
cp.level = nil
cp.happiness = nil
cp.loyalty = nil
cp.loyaltyLevel = nil
cp.xp = nil
cp.xpMax = nil
cp.xpPercent = nil
cp.lastUpdated = time()
cp.lastSource = source or "runaway"
end
MTH_PETS_SetCurrentPetId(pets, nil)
MTH_PETS_SetCurrentPetSuppressed(pets, true, "runaway")
MTH_PETS_LastTameAttempt = nil
MTH_ST_LastUnitPetHadPet = false
pets.updatedAt = time()
if MTH and MTH.Print then
local displayName = MTH_PETS_NormalizeText(petName)
if displayName == "" then displayName = "Unknown" end
if MTH:IsMessageEnabled("petRanAway", true) then
MTH:Print("YOUR PET " .. tostring(displayName) .. " HAS RUNAWAY FOREVER :-( Try to feed it better next time.")
end
if type(MTH_CelebrateRunaway) == "function" then
MTH_CelebrateRunaway(displayName)
end
end
return true
end
function MTH_PETS_RecordPetRename(oldName, newName, source)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return false
end
local cp = pets.currentPet
local petId = cp and cp.id or nil
local petStore = MTH_PETS_GetStoreTables(pets)
local petRow = MTH_PETS_GetPetById(pets, petId)
if type(petRow) ~= "table" then
return false
end
local normalizedNewName = MTH_PETS_NormalizeText(newName)
if normalizedNewName == "" then
return false
end
local oldSignature = petRow.signature
petRow.previousName = MTH_PETS_NormalizeText(oldName)
petRow.name = normalizedNewName
petRow.signature = MTH_PETS_MakeSignature(petRow.name, petRow.family, petRow.level)
MTH_PETS_RemoveSignatureIndex(petStore, oldSignature, petId)
MTH_PETS_AddSignatureIndex(petStore, petRow.signature, petId)
petRow.lastUpdated = time()
petRow.lastSource = source or "rename"
if type(petRow.events) ~= "table" then
petRow.events = {}
end
table.insert(petRow.events, {
type = "pet-rename",
from = MTH_PETS_NormalizeText(oldName),
to = normalizedNewName,
source = source or "rename",
at = time(),
context = MTH_PETS_CaptureContext(),
})
cp.name = normalizedNewName
cp.signature = petRow.signature
cp.lastUpdated = time()
cp.lastSource = source or "rename"
pets.updatedAt = time()
return true
end
function MTH_PETS_CorePetRenameHook(newName)
local oldName = nil
if type(UnitName) == "function" then
oldName = UnitName("pet")
end
if (not oldName or oldName == "") and MTH_CharSavedVariables and type(MTH_CharSavedVariables.MTH_Pets) == "table" then
local cp = MTH_CharSavedVariables.MTH_Pets.currentPet
oldName = cp and cp.name or oldName
end
local newNameNormalized = MTH_PETS_NormalizeText(newName)
if newNameNormalized ~= "" then
if type(MTH_PETS_RecordPetRename) == "function" then
MTH_PETS_RecordPetRename(oldName, newNameNormalized, "core:PetRename")
end
if type(FOM_FoodQuality) == "table" and type(FOM_RealmPlayer) == "string" and FOM_RealmPlayer ~= "" then
if type(FOM_FoodQuality[FOM_RealmPlayer]) == "table" then
local realmMap = FOM_FoodQuality[FOM_RealmPlayer]
local oldKey = MTH_PETS_NormalizeText(oldName)
if oldKey ~= "" and oldKey ~= newNameNormalized and realmMap[oldKey] ~= nil then
realmMap[newNameNormalized] = realmMap[oldKey]
realmMap[oldKey] = nil
end
end
end
end
if type(MTH_PETS_CoreOriginal_PetRename) == "function" and MTH_PETS_CoreOriginal_PetRename ~= MTH_PETS_CorePetRenameHook then
return MTH_PETS_CoreOriginal_PetRename(newName)
end
return nil
end
local function MTH_PETS_InstallCoreRenameHook()
if type(PetRename) ~= "function" then
return false
end
if PetRename == MTH_PETS_CorePetRenameHook then
if MTH and MTH.CaptureHookBoundary then
MTH:CaptureHookBoundary(MTH_PETS_CORE_HOOK_BOUNDARY_KEY, {
{ globalName = "PetRename", originalName = "MTH_PETS_CoreOriginal_PetRename" },
})
end
return true
end
if type(MTH_PETS_CoreOriginal_PetRename) ~= "function" or MTH_PETS_CoreOriginal_PetRename == MTH_PETS_CorePetRenameHook then
MTH_PETS_CoreOriginal_PetRename = PetRename
end
if MTH_PETS_CoreOriginal_PetRename == MTH_PETS_CorePetRenameHook then
return false
end
PetRename = MTH_PETS_CorePetRenameHook
if MTH and MTH.CaptureHookBoundary then
MTH:CaptureHookBoundary(MTH_PETS_CORE_HOOK_BOUNDARY_KEY, {
{ globalName = "PetRename", originalName = "MTH_PETS_CoreOriginal_PetRename" },
})
end
return true
end
function MTH_PETS_GetRootStore()
if not MTH_CharSavedVariables then
MTH_CharSavedVariables = {}
end
if type(MTH_CharSavedVariables.MTH_Pets) ~= "table" then
MTH_CharSavedVariables.MTH_Pets = {
petTraining = {},
trainScan = {},
stableScan = {},
petStore = {},
}
end
local pets = MTH_CharSavedVariables.MTH_Pets
if type(pets.petTraining) ~= "table" then
pets.petTraining = {}
end
if type(pets.trainScan) ~= "table" then
pets.trainScan = pets.petTraining
end
if type(pets.stableScan) ~= "table" then
pets.stableScan = {}
end
if type(pets.petStore) ~= "table" then
pets.petStore = {}
end
MTH_PETS_EnsureSchema(pets)
MTH_PETS_EnsurePetTrainingCompatibility(pets)
-- Migration: remove root-level duplicates that bloated SavedVariables
if pets._migrationCleanup ~= 2 then
MTH_PETS_RunSavedVarCleanup(pets)
pets._migrationCleanup = 2
end
return pets
end
function MTH_PETS_RunSavedVarCleanup(pets)
-- 1. Remove root-level duplicates (data lives inside MTH_Pets only)
if MTH_CharSavedVariables then
MTH_CharSavedVariables.petTraining = nil
MTH_CharSavedVariables.trainScan = nil
MTH_CharSavedVariables.stableScan = nil
MTH_CharSavedVariables.petStore = nil
end
-- 2. Purge pet-updated events (unbounded bloat)
local purged = 0
local stores = { pets.petStore and pets.petStore.activeById, pets.petStore and pets.petStore.historyById }
for _, index in ipairs(stores) do
if type(index) == "table" then
for petId, row in pairs(index) do
if type(row) == "table" and type(row.events) == "table" then
local kept = {}
for _, evt in ipairs(row.events) do
if type(evt) == "table" and evt.type ~= "pet-updated" then
table.insert(kept, evt)
else
purged = purged + 1
end
end
row.events = kept
end
end
end
end
-- 3. Remove stale _betaFreshStoreApplied flag
pets._betaFreshStoreApplied = nil
if purged > 0 and MTH and MTH.Print then
end
end
function MTH_PETS_RefreshCurrentPet()
local pets = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil
if type(pets) ~= "table" then
return
end
MTH_PETS_EnsureCurrentPetSchema(pets)
local cp = pets.currentPet
local previousCurrentId = cp and cp.id or nil
local hadPetBeforeRefresh = MTH_ST_LastUnitPetHadPet and true or false
local now = time()
local snapshot = MTH_PETS_MakeSnapshotFromLivePet()
if pets.currentPetSuppressed == true and pets.currentPetSuppressedAwaitNoLive == true then
if type(snapshot) == "table" then
snapshot = nil
else
pets.currentPetSuppressedAwaitNoLive = false
end
end
local hadPendingTame = type(MTH_PETS_LastTameAttempt) == "table"
if hadPendingTame then
MTH_PETS_LogTame("RefreshCurrentPet with pending tame attempt previousCurrentId=" .. tostring(previousCurrentId)
.. " liveSnapshot=" .. tostring(type(snapshot) == "table"))
end
MTH_PETS_LogConsistency("RefreshCurrentPet start current=" .. MTH_PETS_FormatCurrentConsistency(cp)
.. " previousCurrentId=" .. tostring(previousCurrentId)
.. " hasLiveSnapshot=" .. tostring(type(snapshot) == "table"))
if not snapshot then
cp.exists = false
local keepCurrentId, keepCurrentRow = MTH_PETS_ResolveNoLiveCurrentRow(pets, previousCurrentId)
if keepCurrentId ~= nil and type(keepCurrentRow) == "table" then
MTH_PETS_ApplyCurrentPetFromActiveRow(cp, keepCurrentId, keepCurrentRow)
else
cp.id = nil
cp.guid = nil
cp.signature = nil
cp.beastId = nil
cp.name = nil
cp.family = nil
cp.level = nil
cp.happiness = nil
cp.loyalty = nil
cp.loyaltyLevel = nil
cp.xp = nil
cp.xpMax = nil
cp.xpPercent = nil
end
cp.lastUpdated = now
cp.lastSource = "no-pet"
local petStore = MTH_PETS_GetStoreTables(pets)
MTH_PETS_SetCurrentPetId(pets, keepCurrentId)
MTH_PETS_LogConsistency("RefreshCurrentPet no-live result current=" .. MTH_PETS_FormatCurrentConsistency(cp)
.. " activeCurrentId=" .. tostring(petStore and petStore.activeCurrentId or nil))
MTH_ST_LastUnitPetHadPet = false
pets.updatedAt = now
return
end
if snapshot.beastId == nil and hadPendingTame then
snapshot.beastId = MTH_PETS_GetPendingTameBeastId(snapshot)
MTH_PETS_LogTame("RefreshCurrentPet resolved beastId from pending tame => " .. tostring(snapshot.beastId or "nil"))
end
local petId, created = MTH_PETS_UpsertActivePetFromSnapshot(pets, snapshot, "refresh-current-pet", nil)
MTH_PETS_SetCurrentPetSuppressed(pets, false)
cp.exists = true
cp.id = petId
cp.guid = snapshot.guid
cp.signature = snapshot.signature
cp.beastId = snapshot.beastId
cp.name = snapshot.name
cp.family = snapshot.family
cp.level = snapshot.level
cp.happiness = snapshot.happiness
cp.loyalty = snapshot.loyalty
cp.loyaltyLevel = snapshot.loyaltyLevel
cp.xp = snapshot.xp
cp.xpMax = snapshot.xpMax
cp.xpPercent = snapshot.xpPercent
cp.lastUpdated = now
cp.lastSeen = now
if created then
cp.acquiredAt = now
cp.lastSource = "acquired"
else
cp.lastSource = "refresh"
end
if cp.id and cp.id ~= previousCurrentId then
MTH_PETS_RequestPetSpellScan("refresh-current-change")
end
local row = MTH_PETS_GetPetById(pets, petId)
if type(row) == "table" then
if type(row.events) ~= "table" then
row.events = {}
end
local eventType = nil
local confirmedTameBeastId = MTH_PETS_ResolveConfirmedTameBeastId("refresh-current-pet", snapshot)
if created and (not hadPetBeforeRefresh) then
if confirmedTameBeastId then
eventType = "pet-tamed"
else
eventType = "pet-acquired"
end
elseif cp.id ~= nil and previousCurrentId ~= nil and cp.id ~= previousCurrentId then
eventType = "pet-swapped"
end
local eventContext = nil
if eventType then
eventContext = MTH_PETS_CaptureContext()
table.insert(row.events, {
type = eventType,
source = "refresh-current-pet",
at = now,
context = eventContext,
})
end
end
if hadPendingTame and cp.id and cp.id ~= previousCurrentId then
MTH_PETS_LogTame("RefreshCurrentPet acquire/change completed; clearing pending tame attempt")
MTH_PETS_LastTameAttempt = nil
end
MTH_PETS_LogConsistency("RefreshCurrentPet live result current=" .. MTH_PETS_FormatCurrentConsistency(cp))
MTH_ST_LastUnitPetHadPet = true
pets.updatedAt = now
end
function MTH_GetPetsStore()
return MTH_PETS_GetRootStore()
end
function MTH_GetCurrentPetSnapshot()
local pets = MTH_PETS_GetRootStore()
MTH_PETS_RefreshCurrentPet()
return pets and pets.currentPet or nil
end
local function MTH_PETS_ApplyLivePetVitalsFast(source)
local pets = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil
if type(pets) ~= "table" then
return false
end
MTH_PETS_EnsureCurrentPetSchema(pets)
local cp = pets.currentPet
if type(cp) ~= "table" or cp.id == nil then
return false
end
if type(UnitExists) ~= "function" or not UnitExists("pet") then
return false
end
local changed = false
local function assignIfChanged(target, key, value)
if type(target) ~= "table" then
return
end
if not MTH_PETS_IsSameLiveStateField(target[key], value) then
target[key] = value
changed = true
end
end
local happiness = nil
local loyalty = nil
if type(GetPetHappiness) == "function" then
local h, _, l = GetPetHappiness()
happiness = h
loyalty = l
end
local loyaltyLevel = nil
local getPetLoyalty = (type(getglobal) == "function" and getglobal("GetPetLoyalty")) or (_G and _G["GetPetLoyalty"])
if type(getPetLoyalty) == "function" then
local level = getPetLoyalty()
loyaltyLevel = tonumber(level) or nil
end
local xp = nil
local xpMax = nil
local xpPercent = nil
local getPetExperience = (type(getglobal) == "function" and getglobal("GetPetExperience")) or (_G and _G["GetPetExperience"])
if type(getPetExperience) == "function" then
local x, xMax = getPetExperience()
xp = tonumber(x)
xpMax = tonumber(xMax)
if xp and xpMax and xpMax > 0 then
xpPercent = math.floor((xp / xpMax) * 1000 + 0.5) / 10
end
end
assignIfChanged(cp, "happiness", happiness)
assignIfChanged(cp, "loyalty", loyalty)
if loyaltyLevel ~= nil then
assignIfChanged(cp, "loyaltyLevel", loyaltyLevel)
end
assignIfChanged(cp, "xp", xp)
assignIfChanged(cp, "xpMax", xpMax)
assignIfChanged(cp, "xpPercent", xpPercent)
local activeRow = nil
if type(pets.petStore) == "table" and type(pets.petStore.activeById) == "table" then
activeRow = pets.petStore.activeById[cp.id] or pets.petStore.activeById[tostring(cp.id)]
end
if type(activeRow) == "table" then
assignIfChanged(activeRow, "happiness", happiness)
assignIfChanged(activeRow, "loyalty", loyalty)
if loyaltyLevel ~= nil then
assignIfChanged(activeRow, "loyaltyLevel", loyaltyLevel)
end
assignIfChanged(activeRow, "xp", xp)
assignIfChanged(activeRow, "xpMax", xpMax)
assignIfChanged(activeRow, "xpPercent", xpPercent)
if changed then
activeRow.lastUpdated = time()
end
end
if changed then
cp.lastUpdated = time()
cp.lastSource = source or "live-vitals"
pets.updatedAt = cp.lastUpdated
end
return changed
end
function MTH_GetCurrentPetInfo(skipRefresh)
local pets = MTH_PETS_GetRootStore()
if not skipRefresh then
MTH_PETS_RefreshCurrentPet()
end
local cp = pets and pets.currentPet or nil
local info = {
exists = false,
liveExists = false,
dead = false,
suppressed = false,
suppressedReason = "",
id = nil,
name = nil,
family = nil,
level = nil,
happiness = nil,
loyalty = nil,
loyaltyLevel = nil,
loyaltyText = nil,
loyaltyDisplay = nil,
xp = nil,
xpMax = nil,
xpPercent = nil,
lastSource = nil,
lastUpdated = nil,
acquiredAt = nil,
tamedAt = nil,
stabledAt = nil,
stableFirstSeenAt = nil,
lastUnstabledAt = nil,
withMeSinceAt = nil,
}
if type(cp) == "table" then
info.id = cp.id or pets.currentPetId
info.name = cp.name
info.family = cp.family
info.level = cp.level
info.happiness = cp.happiness
info.loyalty = cp.loyalty
info.loyaltyLevel = tonumber(cp.loyaltyLevel) or nil
info.lastSource = cp.lastSource
info.lastUpdated = cp.lastUpdated
info.acquiredAt = tonumber(cp.acquiredAt) or nil
info.exists = info.id ~= nil and tostring(info.id) ~= ""
end
local activeRow = nil
if type(pets) == "table" and type(pets.petStore) == "table" and type(pets.petStore.activeById) == "table" and info.id ~= nil then
activeRow = pets.petStore.activeById[info.id] or pets.petStore.activeById[tostring(info.id)]
end
if type(activeRow) == "table" then
info.tamedAt = tonumber(activeRow.tamedAt) or nil
info.stabledAt = tonumber(activeRow.stabledAt) or nil
info.stableFirstSeenAt = tonumber(activeRow.stableFirstSeenAt) or nil
info.lastUnstabledAt = tonumber(activeRow.lastUnstabledAt) or nil
if info.acquiredAt == nil then
info.acquiredAt = tonumber(activeRow.createdAt) or tonumber(activeRow.lastSeen) or tonumber(activeRow.lastUpdated) or nil
end
if info.loyaltyLevel == nil then
info.loyaltyLevel = tonumber(activeRow.loyaltyLevel) or nil
end
end
info.withMeSinceAt = info.lastUnstabledAt or info.stabledAt or info.stableFirstSeenAt or info.tamedAt or nil
info.suppressed = (pets and pets.currentPetSuppressed) and true or false
info.suppressedReason = tostring((pets and pets.currentPetSuppressedReason) or "")
local hasLivePet = (type(UnitExists) == "function") and UnitExists("pet") and true or false
info.liveExists = hasLivePet
if hasLivePet then
if type(UnitName) == "function" then
local liveName = UnitName("pet")
if liveName and tostring(liveName) ~= "" then
info.name = liveName
end
end
if type(UnitLevel) == "function" then
local liveLevel = tonumber(UnitLevel("pet"))
if liveLevel and liveLevel > 0 then
info.level = liveLevel
end
end
if type(UnitIsDead) == "function" then
info.dead = UnitIsDead("pet") and true or false
end
if type(GetPetHappiness) == "function" then
local h, _, l = GetPetHappiness()
if h ~= nil then
info.happiness = h
end
if l ~= nil then
info.loyalty = l
end
end
local getPetLoyalty = (type(getglobal) == "function" and getglobal("GetPetLoyalty")) or (_G and _G["GetPetLoyalty"])
if type(getPetLoyalty) == "function" then
local loyaltyLevel, loyaltyText = getPetLoyalty()
if loyaltyLevel ~= nil then
info.loyaltyLevel = tonumber(loyaltyLevel) or info.loyaltyLevel
end
if loyaltyText ~= nil and tostring(loyaltyText) ~= "" then
local loyaltyTextValue = tostring(loyaltyText)
local parsedFromText = MTH_PETS_ParseLoyaltyLevelFromText(loyaltyTextValue)
if info.loyaltyLevel == nil and parsedFromText ~= nil then
info.loyaltyLevel = parsedFromText
end
end
end
local petFrameHappiness = (_G and _G["PetFrameHappiness"]) or (type(getglobal) == "function" and getglobal("PetFrameHappiness"))
if type(petFrameHappiness) == "table" and type(petFrameHappiness.tooltipLoyalty) == "string" and petFrameHappiness.tooltipLoyalty ~= "" then
local tooltipText = tostring(petFrameHappiness.tooltipLoyalty)
local parsedFromTooltip = MTH_PETS_ParseLoyaltyLevelFromText(tooltipText)
if info.loyaltyLevel == nil and parsedFromTooltip ~= nil then
info.loyaltyLevel = parsedFromTooltip
end
end
local petLoyaltyText = (_G and _G["PetLoyaltyText"]) or (type(getglobal) == "function" and getglobal("PetLoyaltyText"))
if type(petLoyaltyText) == "table" and type(petLoyaltyText.GetText) == "function" then
local uiLoyaltyText = MTH_PETS_NormalizeText(petLoyaltyText:GetText())
if uiLoyaltyText ~= "" then
local parsedFromUi = MTH_PETS_ParseLoyaltyLevelFromText(uiLoyaltyText)
if info.loyaltyLevel == nil and parsedFromUi ~= nil then
info.loyaltyLevel = parsedFromUi
end
end
end
local getPetExperience = (type(getglobal) == "function" and getglobal("GetPetExperience")) or (_G and _G["GetPetExperience"])
if type(getPetExperience) == "function" then
local xp, xpMax = getPetExperience()
xp = tonumber(xp)
xpMax = tonumber(xpMax)
if xp then
info.xp = xp
end
if xpMax and xpMax > 0 then
info.xpMax = xpMax
if xp then
info.xpPercent = math.floor((xp / xpMax) * 1000 + 0.5) / 10
end
end
end
if type(UnitHealth) == "function" then
info.health = tonumber(UnitHealth("pet")) or nil
end
if type(UnitHealthMax) == "function" then
info.healthMax = tonumber(UnitHealthMax("pet")) or nil
end
end
if info.loyaltyLevel ~= nil then
info.loyaltyText = MTH_PETS_GetLoyaltyNameByLevel(info.loyaltyLevel)
info.loyaltyDisplay = MTH_PETS_FormatLoyaltyDisplay(info.loyaltyLevel)
else
info.loyaltyText = nil
info.loyaltyDisplay = nil
end
if info.liveExists and info.id and type(cp) == "table" then
local changed = false
local function assignIfChanged(target, key, value)
if type(target) ~= "table" then
return
end
if not MTH_PETS_IsSameLiveStateField(target[key], value) then
target[key] = value
changed = true
end
end
assignIfChanged(cp, "happiness", info.happiness)
assignIfChanged(cp, "loyalty", info.loyalty)
if info.loyaltyLevel ~= nil then
assignIfChanged(cp, "loyaltyLevel", info.loyaltyLevel)
end
assignIfChanged(cp, "xp", info.xp)
assignIfChanged(cp, "xpMax", info.xpMax)
assignIfChanged(cp, "xpPercent", info.xpPercent)
if type(activeRow) == "table" then
assignIfChanged(activeRow, "happiness", info.happiness)
assignIfChanged(activeRow, "loyalty", info.loyalty)
if info.loyaltyLevel ~= nil then
assignIfChanged(activeRow, "loyaltyLevel", info.loyaltyLevel)
end
assignIfChanged(activeRow, "xp", info.xp)
assignIfChanged(activeRow, "xpMax", info.xpMax)
assignIfChanged(activeRow, "xpPercent", info.xpPercent)
if changed then
activeRow.lastUpdated = time()
end
end
if changed then
cp.lastUpdated = time()
pets.updatedAt = cp.lastUpdated
end
end
return info
end
function MTH_GetPetDatastoreSnapshot()
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" or type(pets.petStore) ~= "table" then
return nil
end
return pets.petStore
end
function MTH_GetPetHistorySnapshot()
local store = MTH_GetPetDatastoreSnapshot()
if type(store) ~= "table" then
return nil
end
return store.historyById
end
local function MTH_ST_GetStore()
local pets = MTH_PETS_GetRootStore()
if type(pets.stableScan) ~= "table" then
pets.stableScan = {
lastScan = 0,
lastEvent = "",
slotCount = 0,
slots = {},
}
end
if type(pets.stableScan.slots) ~= "table" then
pets.stableScan.slots = {}
end
MTH_CharSavedVariables.stableScan = pets.stableScan
return pets.stableScan
end
function MTH_ST_Scan(reason)
local startedAt = MTH_ST_ProfileNow()
MTH_ST_PrintStableDiag("scan begin reason=" .. tostring(reason or ""))
local slotDiagParts = {}
if UnitClass then
local _, classToken = UnitClass("player")
if classToken ~= "HUNTER" then
MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "class-gate:" .. tostring(classToken or "unknown"))
MTH_ST_PrintStableDiag("scan skip reason=class-gate class=" .. tostring(classToken or "unknown"))
return false
end
end
if type(GetNumStableSlots) ~= "function" or type(GetStablePetInfo) ~= "function" then
MTH_ST_Log("scan skipped: stable APIs unavailable")
MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "api-unavailable")
MTH_ST_PrintStableDiag("scan skip reason=api-unavailable")
return false
end
local slotCount = tonumber(GetNumStableSlots()) or 0
MTH_ST_PrintStableDiag("scan api-ok slots=" .. tostring(slotCount))
MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "begin reason=" .. tostring(reason or "") .. " slots=" .. tostring(slotCount))
local store = MTH_ST_GetStore()
local petsRoot = MTH_PETS_GetRootStore()
MTH_PETS_RefreshCurrentPet()
local scanContext = MTH_PETS_CaptureContext()
local scanStableMasterName = MTH_PETS_GetStableMasterName()
store.lastScan = time()
store.lastEvent = tostring(reason or "manual")
store.slotCount = slotCount
store.zone = scanContext and scanContext.zone or store.zone
store.subZone = scanContext and scanContext.subZone or store.subZone
if scanStableMasterName and scanStableMasterName ~= "" then
store.stableMasterName = scanStableMasterName
end
store.slots = {}
for slot = 1, slotCount do
local r1, r2, r3, r4, r5, r6, r7, r8 = GetStablePetInfo(slot)
local slotName = tostring(r2 or "-")
local slotLevel = tostring(tonumber(r3) or "-")
local slotFamily = tostring(r4 or "-")
table.insert(slotDiagParts, "s" .. tostring(slot) .. "=" .. slotName .. "(lvl=" .. slotLevel .. ",fam=" .. slotFamily .. ")")
MTH_PETS_RecordStableSlot(petsRoot, slot, r1, r2, r3, r4, r5, r6, r7, r8, scanContext, scanStableMasterName or store.stableMasterName)
local petId = nil
if type(petsRoot) == "table" and type(petsRoot.petStore) == "table" and type(petsRoot.petStore.stableSlotIndex) == "table" then
petId = petsRoot.petStore.stableSlotIndex[slot]
end
store.slots[slot] = {
petId = petId,
icon = r1,
name = r2,
level = r3,
family = r4,
loyalty = r5,
raw = { r1, r2, r3, r4, r5, r6, r7, r8 },
r1, r2, r3, r4, r5, r6, r7, r8,
}
end
local c1, c2, c3, c4, c5, c6, c7, c8 = GetStablePetInfo(0)
local currentPetId = (type(petsRoot) == "table" and type(petsRoot.currentPet) == "table") and petsRoot.currentPet.id or nil
if currentPetId and type(petsRoot.petStore) == "table" and type(petsRoot.petStore.activeById) == "table" then
local currentRow = petsRoot.petStore.activeById[currentPetId]
if type(currentRow) == "table" then
MTH_ST_PrintStableDiag("scan currentRow petId=" .. tostring(currentPetId)
.. " guid='" .. tostring(currentRow.guid or "") .. "'"
.. " signature='" .. tostring(currentRow.signature or "") .. "'"
.. " beastId='" .. tostring(currentRow.beastId or "") .. "'")
local currentIcon = tostring(c1 or "")
if currentIcon ~= "" then
currentRow.icon = currentIcon
currentRow.lastUpdated = time()
end
end
end
store.current = {
petId = currentPetId,
icon = c1,
name = c2,
level = c3,
family = c4,
loyalty = c5,
raw = { c1, c2, c3, c4, c5, c6, c7, c8 },
}
MTH_ST_PrintStableDiag("scan current name=" .. tostring(c2 or "-")
.. " lvl=" .. tostring(tonumber(c3) or "-")
.. " fam=" .. tostring(c4 or "-")
.. " petId=" .. tostring(currentPetId or "-"))
if table.getn(slotDiagParts) > 0 then
MTH_ST_PrintStableDiag("scan slots " .. table.concat(slotDiagParts, " | "))
end
MTH_PETS_ReconcileStableSlots(petsRoot, slotCount)
MTH_PETS_MarkStableVisited(petsRoot, tostring(reason or "stable-scan"))
MTH_PETS_RefreshCurrentPet()
local elapsedMs = nil
local finishedAt = MTH_ST_ProfileNow()
if startedAt and finishedAt then
elapsedMs = (finishedAt - startedAt) * 1000
end
if elapsedMs then
MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "done reason=" .. tostring(reason or "")
.. " slots=" .. tostring(slotCount)
.. " elapsedMs=" .. string.format("%.2f", elapsedMs))
MTH_ST_PrintStableDiag("scan done ok=true slots=" .. tostring(slotCount) .. " elapsedMs=" .. string.format("%.2f", elapsedMs))
else
MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "done reason=" .. tostring(reason or "")
.. " slots=" .. tostring(slotCount))
MTH_ST_PrintStableDiag("scan done ok=true slots=" .. tostring(slotCount))
end
MTH_PETS_EmitLiveState("STABLE_SCAN:" .. tostring(reason or "stable"), false)
return true
end
function MTH_GetStableScanSnapshot()
local pets = MTH_PETS_GetRootStore()
if not pets then
return nil
end
if type(pets.stableScan) ~= "table" then
return nil
end
MTH_CharSavedVariables.stableScan = pets.stableScan
return pets.stableScan
end
function MTH_RunStableScan(reason)
if type(MTH_ST_Scan) ~= "function" then
return false
end
return MTH_ST_Scan(reason or "external") and true or false
end
local function MTH_ST_RunScanThrottled(reason, minIntervalSeconds)
local now = tonumber(time()) or 0
local minInterval = tonumber(minIntervalSeconds) or 0
if minInterval > 0 and (now - (MTH_ST_LastAutoScanAt or 0)) < minInterval then
MTH_ST_PrintStableDiag("scan throttled reason=" .. tostring(reason or "")
.. " min=" .. tostring(minInterval)
.. " age=" .. tostring(now - (MTH_ST_LastAutoScanAt or 0)))
MTH_ST_TraceRecord("MTH_ST_RunScanThrottled", "scan", "throttled reason=" .. tostring(reason or "")
.. " min=" .. tostring(minInterval)
.. " age=" .. tostring(now - (MTH_ST_LastAutoScanAt or 0)))
return false
end
MTH_ST_LastAutoScanAt = now
MTH_ST_PrintStableDiag("scan trigger reason=" .. tostring(reason or "") .. " min=" .. tostring(minInterval))
MTH_ST_TraceRecord("MTH_ST_RunScanThrottled", "scan", "trigger reason=" .. tostring(reason or "")
.. " min=" .. tostring(minInterval))
return MTH_ST_Scan(reason)
end
local function MTH_ST_QueueDeferredStableScan(reason, delaySeconds)
if not MTH_ST_DeferredStableScanFrame then
MTH_ST_DeferredStableScanFrame = CreateFrame("Frame", "MTH_DeferredStableScan")
if not MTH_ST_DeferredStableScanFrame then
MTH_ST_TraceRecord("MTH_ST_QueueDeferredStableScan", "scan", "frame-create-failed")
return false
end
end
local frame = MTH_ST_DeferredStableScanFrame
frame._reason = tostring(reason or "deferred")
frame._elapsed = 0
frame._delay = tonumber(delaySeconds) or 0.30
MTH_ST_PrintStableDiag("deferred queue reason=" .. tostring(frame._reason) .. " delay=" .. tostring(frame._delay))
frame:SetScript("OnUpdate", function()
this._elapsed = (this._elapsed or 0) + (arg1 or 0)
if this._elapsed < (this._delay or 0.30) then
return
end
this:SetScript("OnUpdate", nil)
MTH_ST_PrintStableDiag("deferred execute reason=" .. tostring(this._reason or "deferred"))
MTH_ST_TraceRecord("MTH_ST_QueueDeferredStableScan", "scan", "execute reason=" .. tostring(this._reason or "deferred"))
MTH_ST_RunScanThrottled("deferred:" .. tostring(this._reason or "stable"), 1)
end)
MTH_ST_TraceRecord("MTH_ST_QueueDeferredStableScan", "scan", "queued reason=" .. tostring(frame._reason)
.. " delay=" .. tostring(frame._delay))
return true
end
local function MTH_PETS_HandleUnitPetTransition(source)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return
end
MTH_PETS_EnsureCurrentPetSchema(pets)
local hadPet = MTH_ST_LastUnitPetHadPet and true or false
local previousCurrentId = (type(pets.currentPet) == "table") and pets.currentPet.id or nil
local snapshot = MTH_PETS_MakeSnapshotFromLivePet()
if pets.currentPetSuppressed == true and pets.currentPetSuppressedAwaitNoLive == true then
if type(snapshot) == "table" then
snapshot = nil
else
pets.currentPetSuppressedAwaitNoLive = false
end
end
local hasPet = snapshot and true or false
local pending = MTH_PETS_LastTameAttempt
local pendingTarget = (type(pending) == "table" and type(pending.target) == "table") and pending.target or nil
local hasPendingTame = type(pendingTarget) == "table"
if hasPendingTame then
MTH_PETS_LogTame("UNIT_PET source=" .. tostring(source)
.. " hadPet=" .. tostring(hadPet)
.. " hasLiveSnapshot=" .. tostring(type(snapshot) == "table")
.. " pendingTarget='" .. tostring((pendingTarget and pendingTarget["name"]) or "")
.. "' pendingBeastId='" .. tostring((pendingTarget and pendingTarget["beastId"]) or "") .. "'")
end
MTH_PETS_LogConsistency("HandleUnitPetTransition source=" .. tostring(source)
.. " previousCurrentId=" .. tostring(previousCurrentId)
.. " hadPet=" .. tostring(hadPet)
.. " hasLiveSnapshot=" .. tostring(type(snapshot) == "table"))
if type(snapshot) == "table" then
local transitionSource = "unit-pet-refresh"
if not hadPet then
transitionSource = "unit-pet-acquire"
end
if not hadPet and snapshot.beastId == nil then
snapshot.beastId = MTH_PETS_GetPendingTameBeastId(snapshot)
MTH_PETS_LogTame("UNIT_PET acquire beastId resolved to " .. tostring(snapshot.beastId or "nil"))
end
local petId, created = MTH_PETS_UpsertActivePetFromSnapshot(pets, snapshot, transitionSource, nil)
MTH_PETS_SetCurrentPetSuppressed(pets, false)
local cp = pets.currentPet
cp.exists = true
cp.id = petId
cp.guid = snapshot.guid
cp.signature = snapshot.signature
cp.beastId = snapshot.beastId
cp.name = snapshot.name
cp.family = snapshot.family
cp.level = snapshot.level
cp.happiness = snapshot.happiness
cp.loyalty = snapshot.loyalty
cp.loyaltyLevel = snapshot.loyaltyLevel
cp.xp = snapshot.xp
cp.xpMax = snapshot.xpMax
cp.xpPercent = snapshot.xpPercent
cp.lastUpdated = time()
cp.lastSeen = time()
cp.lastSource = source or transitionSource
if not hadPet or created then
cp.acquiredAt = time()
end
if cp.id and cp.id ~= previousCurrentId then
MTH_PETS_RequestPetSpellScan("unit-pet-current-change")
end
local row = MTH_PETS_GetPetById(pets, petId)
if type(row) == "table" then
if type(row.events) ~= "table" then
row.events = {}
end
local confirmedTameBeastId = MTH_PETS_ResolveConfirmedTameBeastId(transitionSource, snapshot)
local eventType = (not hadPet) and "pet-acquired" or "pet-updated"
if not hadPet and confirmedTameBeastId then
eventType = "pet-tamed"
end
-- Only record lifecycle events (acquire, rename, abandon), not routine updates
if eventType ~= "pet-updated" then
table.insert(row.events, {
type = eventType,
source = source or transitionSource,
at = time(),
context = MTH_PETS_CaptureContext(),
})
end
end
if not hadPet and hasPendingTame then
MTH_PETS_LogTame("Acquire completed; clearing pending tame attempt")
MTH_PETS_LastTameAttempt = nil
end
else
local cp = pets.currentPet
cp.exists = false
local keepCurrentId, keepCurrentRow = MTH_PETS_ResolveNoLiveCurrentRow(pets, previousCurrentId)
if keepCurrentId ~= nil and type(keepCurrentRow) == "table" then
MTH_PETS_ApplyCurrentPetFromActiveRow(cp, keepCurrentId, keepCurrentRow)
else
cp.id = nil
cp.guid = nil
cp.signature = nil
cp.beastId = nil
cp.name = nil
cp.family = nil
cp.level = nil
cp.happiness = nil
cp.loyalty = nil
cp.loyaltyLevel = nil
cp.xp = nil
cp.xpMax = nil
cp.xpPercent = nil
end
cp.lastUpdated = time()
cp.lastSource = source or "unit-pet-none"
MTH_PETS_SetCurrentPetId(pets, keepCurrentId)
MTH_PETS_LogConsistency("HandleUnitPetTransition no-live result current=" .. MTH_PETS_FormatCurrentConsistency(cp)
.. " activeCurrentId=" .. tostring(type(pets.petStore) == "table" and pets.petStore.activeCurrentId or nil))
if hasPendingTame then
MTH_PETS_LogTame("UNIT_PET no-live while tame pending; keeping pending attempt")
end
end
MTH_ST_LastUnitPetHadPet = hasPet
pets.updatedAt = time()
end
function MTH_GetStablePetNames()
MTH_PETS_RefreshCurrentPet()
local names = {}
local seen = {}
local snapshot = MTH_GetStableScanSnapshot()
if snapshot and type(snapshot.slots) == "table" then
for _, row in pairs(snapshot.slots) do
local petName = nil
if type(row) == "table" then
petName = row.name or row[2]
end
if petName and petName ~= "" and not seen[petName] then
seen[petName] = true
table.insert(names, petName)
end
end
end
if type(UnitName) == "function" then
local activePetName = UnitName("pet")
if activePetName and activePetName ~= "" and not seen[activePetName] then
seen[activePetName] = true
table.insert(names, activePetName)
end
end
return names
end
local function MTH_ST_CountStableSlots(snapshot)
if type(snapshot) ~= "table" then
return 0
end
local count = tonumber(snapshot.slotCount) or 0
if count > 0 then
return count
end
if type(snapshot.slots) ~= "table" then
return 0
end
for _ in pairs(snapshot.slots) do
count = count + 1
end
return count
end
local function MTH_ST_DebugDumpLine(line)
if MTH and MTH.Print then
end
end
local MTH_PETS_DebugBaselineSnapshot = nil
local function MTH_ST_DebugCloneValue(value, seen)
if type(value) ~= "table" then
return value
end
seen = seen or {}
if seen[value] then
return seen[value]
end
local clone = {}
seen[value] = clone
for key, child in pairs(value) do
clone[MTH_ST_DebugCloneValue(key, seen)] = MTH_ST_DebugCloneValue(child, seen)
end
return clone
end
local function MTH_PETS_CaptureDebugSnapshot(targetPetId)
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
return nil, "pets datastore unavailable"
end
MTH_PETS_RefreshCurrentPet()
local cp = (type(pets.currentPet) == "table") and pets.currentPet or nil
local petId = targetPetId or (cp and cp.id) or pets.currentPetId
if petId == nil or tostring(petId) == "" then
return nil, "no current pet id"
end
local row = MTH_PETS_GetPetById(pets, petId)
if type(row) ~= "table" then
return nil, "active row missing for pet id=" .. tostring(petId)
end
local snapshot = {
capturedAt = time(),
petId = tostring(petId),
currentPetId = pets.currentPetId,
currentPet = MTH_ST_DebugCloneValue(cp or {}),
row = MTH_ST_DebugCloneValue(row),
stableScan = MTH_ST_DebugCloneValue(pets.stableScan or {}),
}
return snapshot, nil
end
local function MTH_ST_DebugSortKeys(tbl)
local keys = {}
for k in pairs(tbl or {}) do
table.insert(keys, k)
end
table.sort(keys, function(a, b)
return tostring(a) < tostring(b)
end)
return keys
end
local function MTH_ST_DebugDumpValue(prefix, value, depth, maxDepth, seen)
depth = tonumber(depth) or 0
maxDepth = tonumber(maxDepth) or 4
seen = seen or {}
if type(value) ~= "table" then
MTH_ST_DebugDumpLine(prefix .. " = " .. tostring(value))
return
end
if seen[value] then
MTH_ST_DebugDumpLine(prefix .. " = <cycle>")
return
end
seen[value] = true
if depth >= maxDepth then
MTH_ST_DebugDumpLine(prefix .. " = <max-depth>")
return
end
local keys = MTH_ST_DebugSortKeys(value)
if table.getn(keys) == 0 then
MTH_ST_DebugDumpLine(prefix .. " = {}")
return
end
for i = 1, table.getn(keys) do
local key = keys[i]
local child = value[key]
local childPrefix = prefix .. "." .. tostring(key)
if type(child) == "table" then
MTH_ST_DebugDumpLine(childPrefix .. " = {")
MTH_ST_DebugDumpValue(childPrefix, child, depth + 1, maxDepth, seen)
MTH_ST_DebugDumpLine(childPrefix .. " = }")
else
MTH_ST_DebugDumpLine(childPrefix .. " = " .. tostring(child))
end
end
end
function MTH_CommandPetsState()
local pets = MTH_PETS_GetRootStore()
MTH_PETS_RefreshCurrentPet()
local currentPet = pets and pets.currentPet or nil
local stableScan = pets and pets.stableScan or nil
local petTraining = pets and (pets.trainScan or pets.petTraining) or nil
local petStore = pets and pets.petStore or nil
local activeCount = 0
local historyCount = 0
if type(petStore) == "table" and type(petStore.activeById) == "table" then
for _ in pairs(petStore.activeById) do activeCount = activeCount + 1 end
end
if type(petStore) == "table" and type(petStore.historyById) == "table" then
for _ in pairs(petStore.historyById) do historyCount = historyCount + 1 end
end
local currentPetText = "none"
if type(currentPet) == "table" and currentPet.exists then
local petName = tostring(currentPet.name or "")
local petLevel = tonumber(currentPet.level) or 0
if petName ~= "" then
if petLevel > 0 then
currentPetText = petName .. " (" .. tostring(petLevel) .. ")"
else
currentPetText = petName
end
end
end
local summary = "MTH_Pets: schemaVersion=" .. tostring(pets and pets.schemaVersion or 0)
.. " currentPet=" .. currentPetText
.. " activePets=" .. tostring(activeCount)
.. " petHistory=" .. tostring(historyCount)
.. " stableSlots=" .. tostring(MTH_ST_CountStableSlots(stableScan))
.. " stableLastScan=" .. tostring(stableScan and stableScan.lastScan or 0)
.. " petTrainingLastScan=" .. tostring(petTraining and petTraining.lastScan or 0)
.. " hasCompletedPetScan=" .. tostring(petTraining and petTraining.hasCompletedPetScan or nil)
MTH:Print(summary)
end
function MTH_CommandPetsReset()
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
MTH:Print("Pets reset failed: datastore unavailable.")
return false
end
MTH_PETS_EnsureSchema(pets)
local store = MTH_PETS_GetStoreTables(pets)
if type(store) ~= "table" then
MTH:Print("Pets reset failed: petStore unavailable.")
return false
end
local activeCount = 0
if type(store.activeById) == "table" then
for _ in pairs(store.activeById) do
activeCount = activeCount + 1
end
end
local historyCount = 0
if type(store.historyById) == "table" then
for _ in pairs(store.historyById) do
historyCount = historyCount + 1
end
else
store.historyById = {}
end
store.activeById = {}
store.signatureIndex = {}
store.guidIndex = {}
store.stableSlotIndex = {}
store.activeCurrentId = nil
pets.currentPetId = nil
pets.currentPetSuppressed = false
pets.currentPetSuppressedReason = ""
store.nextId = 1
MTH_PETS_EnsureCurrentPetSchema(pets)
local cp = pets.currentPet
cp.exists = false
cp.id = nil
cp.guid = nil
cp.signature = nil
cp.beastId = nil
cp.name = nil
cp.family = nil
cp.level = nil
cp.happiness = nil
cp.loyalty = nil
cp.loyaltyLevel = nil
cp.xp = nil
cp.xpMax = nil
cp.xpPercent = nil
cp.acquiredAt = 0
cp.lastSeen = time()
cp.lastUpdated = time()
cp.lastSource = "pets-reset"
if type(pets.stableScan) ~= "table" then
pets.stableScan = {}
end
pets.stableScan.lastScan = 0
pets.stableScan.lastEvent = "reset:/mth pets reset"
pets.stableScan.slotCount = 0
pets.stableScan.slots = {}
MTH_ST_LastUnitPetHadPet = false
pets.updatedAt = time()
if type(MTH_CharSavedVariables) == "table" then
MTH_CharSavedVariables.MTH_Pets = pets
MTH_CharSavedVariables.petStore = pets.petStore
MTH_CharSavedVariables.stableScan = pets.stableScan
end
local line = "Pets reset complete: clearedActive=" .. tostring(activeCount)
.. " preservedHistory=" .. tostring(historyCount)
.. " nextId=" .. tostring(store.nextId)
MTH:Print(line)
return true
end
-- Dev-only: force the v3 pet-store migration to run again (dedup by canonical key,
-- level-free re-sign, index rebuild, orphan cleanup). Resets the pet schema flag
-- and re-runs EnsureSchema. Does NOT touch MTH.version, so nothing is broadcast.
-- Non-destructive: history is preserved. Invoke via /mth dev petmigrate or /run.
function MTH_CommandPetsMigrate()
local pets = MTH_PETS_GetRootStore()
if type(pets) ~= "table" then
MTH:Print("Pet migration failed: datastore unavailable.")
return false
end
local store = MTH_PETS_GetStoreTables(pets)
local function countTable(t)
local n = 0
if type(t) == "table" then for _ in pairs(t) do n = n + 1 end end
return n
end
local beforeActive = countTable(store and store.activeById)
local beforeHistory = countTable(store and store.historyById)
-- Force the one-shot path to re-run regardless of current stamp.
pets.schemaVersion = 2
MTH_PETS_EnsureSchema(pets)
store = MTH_PETS_GetStoreTables(pets)
local afterActive = countTable(store and store.activeById)
local afterHistory = countTable(store and store.historyById)
pets.updatedAt = time()
if type(MTH_CharSavedVariables) == "table" then
MTH_CharSavedVariables.MTH_Pets = pets
MTH_CharSavedVariables.petStore = pets.petStore
end
MTH:Print("Pet migration (v3) complete: active " .. tostring(beforeActive) .. "->" .. tostring(afterActive)
.. ", history " .. tostring(beforeHistory) .. "->" .. tostring(afterHistory)
.. ", schemaVersion=" .. tostring(pets.schemaVersion))
return true
end
function MTH_CommandPetsDump()
local pets = MTH_PETS_GetRootStore()
MTH_PETS_RefreshCurrentPet()
if type(pets) ~= "table" or type(pets.currentPet) ~= "table" or not pets.currentPet.exists or not pets.currentPet.id then
MTH:Print("Current pet dump: no active pet.")
return
end
local cp = pets.currentPet
local row = nil
if type(pets.petStore) == "table" and type(pets.petStore.activeById) == "table" then
row = pets.petStore.activeById[cp.id]
end
if type(row) ~= "table" then
MTH:Print("Current pet dump: active row missing for id=" .. tostring(cp.id))
return
end
MTH_ST_DebugDumpLine("[PETDUMP] BEGIN currentPet")
MTH_ST_DebugDumpValue("[PETDUMP].currentPet", cp, 0, 3, {})
MTH_ST_DebugDumpLine("[PETDUMP] END currentPet")
MTH_ST_DebugDumpLine("[PETDUMP] BEGIN activeRow[" .. tostring(cp.id) .. "]")
MTH_ST_DebugDumpValue("[PETDUMP].activeRow", row, 0, 5, {})
MTH_ST_DebugDumpLine("[PETDUMP] END activeRow")
local petStore = pets.petStore
if type(petStore) == "table" and type(petStore.stableSlotIndex) == "table" then
local slotKeys = MTH_ST_DebugSortKeys(petStore.stableSlotIndex)
for i = 1, table.getn(slotKeys) do
local slot = slotKeys[i]
local petId = petStore.stableSlotIndex[slot]
MTH_ST_DebugDumpLine("[PETDUMP] stableSlotIndex." .. tostring(slot) .. " = " .. tostring(petId))
if petId and type(petStore.activeById) == "table" and type(petStore.activeById[petId]) == "table" then
MTH_ST_DebugDumpLine("[PETDUMP] BEGIN stableRow slot=" .. tostring(slot) .. " id=" .. tostring(petId))
MTH_ST_DebugDumpValue("[PETDUMP].stableRow[" .. tostring(slot) .. "]", petStore.activeById[petId], 0, 5, {})
MTH_ST_DebugDumpLine("[PETDUMP] END stableRow slot=" .. tostring(slot))
end
end
end
if type(pets.stableScan) == "table" then
MTH_ST_DebugDumpLine("[PETDUMP] BEGIN stableScan")
MTH_ST_DebugDumpValue("[PETDUMP].stableScan", pets.stableScan, 0, 4, {})
MTH_ST_DebugDumpLine("[PETDUMP] END stableScan")
end
end
function MTH_CommandPetsSnap()
local snapshot, err = MTH_PETS_CaptureDebugSnapshot(nil)
if not snapshot then
MTH:Print("Pet snapshot failed: " .. tostring(err or "unknown"))
return false
end
MTH_PETS_DebugBaselineSnapshot = snapshot
MTH:Print("Pet snapshot saved for id=" .. tostring(snapshot.petId) .. ". Run /mth petsdiff after reproducing the issue.")
MTH_ST_DebugDumpLine("[PETSNAP] BASELINE BEGIN")
MTH_ST_DebugDumpValue("[PETSNAP].baseline", snapshot, 0, 5, {})
MTH_ST_DebugDumpLine("[PETSNAP] BASELINE END")
return true
end
function MTH_CommandPetsDiff()
if type(MTH_PETS_DebugBaselineSnapshot) ~= "table" then
MTH:Print("No pet snapshot baseline found. Run /mth petssnap first.")
return false
end
local baseline = MTH_PETS_DebugBaselineSnapshot
local current, err = MTH_PETS_CaptureDebugSnapshot(baseline.petId)
if not current then
MTH:Print("Pet snapshot compare failed: " .. tostring(err or "unknown"))
return false
end
MTH:Print("Pet snapshot compare for id=" .. tostring(current.petId) .. ".")
MTH_ST_DebugDumpLine("[PETSNAP] BEFORE BEGIN")
MTH_ST_DebugDumpValue("[PETSNAP].before", baseline, 0, 5, {})
MTH_ST_DebugDumpLine("[PETSNAP] BEFORE END")
MTH_ST_DebugDumpLine("[PETSNAP] AFTER BEGIN")
MTH_ST_DebugDumpValue("[PETSNAP].after", current, 0, 5, {})
MTH_ST_DebugDumpLine("[PETSNAP] AFTER END")
local diffKeys = {
"name", "family", "level", "beastId", "icon",
"tameRecorded", "tamedAt", "tameZone", "tameSubZone", "tameX", "tameY", "tameBeastId",
"stableSlot", "stabledAt", "stableFirstSeenAt", "lastUnstabledAt", "lastSource", "lastUpdated",
}
local diffCount = 0
for i = 1, table.getn(diffKeys) do
local key = diffKeys[i]
local beforeValue = baseline.row and baseline.row[key] or nil
local afterValue = current.row and current.row[key] or nil
if tostring(beforeValue) ~= tostring(afterValue) then
diffCount = diffCount + 1
MTH_ST_DebugDumpLine("[PETSNAP] DIFF row." .. tostring(key) .. ": " .. tostring(beforeValue) .. " -> " .. tostring(afterValue))
end
end
if diffCount <= 0 then
MTH_ST_DebugDumpLine("[PETSNAP] DIFF none")
end
MTH_PETS_DebugBaselineSnapshot = current
return true
end
function MTH_CommandStableScan()
if UnitClass then
local _, classToken = UnitClass("player")
if classToken ~= "HUNTER" then
MTH:Print("Stable scan is hunter-only.")
return
end
end
local ok = MTH_ST_Scan("manual:/mth stablescan")
if not ok then
MTH:Print("Stable scan failed or no stable data available.")
return
end
local store = MTH_CharSavedVariables and MTH_CharSavedVariables.stableScan
local slotCount = store and store.slotCount or 0
MTH:Print("Stable scan captured " .. tostring(slotCount) .. " slot(s).")
end
function MTH_ST_HandlePlayerEnteringWorld(evt)
if evt ~= "PLAYER_ENTERING_WORLD" then
return false
end
local pets = MTH_PETS_GetRootStore()
MTH_PETS_MaybePromptStableVisit(pets)
MTH_PETS_EmitLiveState("PLAYER_ENTERING_WORLD", true)
return true
end
function MTH_ST_HandleSpellcastEvent(evt, eventArg1, eventArg2)
local isUnitSpellcast = (evt == "UNIT_SPELLCAST_START" or evt == "UNIT_SPELLCAST_STOP"
or evt == "UNIT_SPELLCAST_FAILED" or evt == "UNIT_SPELLCAST_INTERRUPTED"
or evt == "UNIT_SPELLCAST_CHANNEL_START" or evt == "UNIT_SPELLCAST_CHANNEL_STOP")
local isSpellcast = isUnitSpellcast
or evt == "SPELLCAST_START" or evt == "SPELLCAST_STOP"
or evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED"
or evt == "SPELLCAST_CHANNEL_START" or evt == "SPELLCAST_CHANNEL_STOP"
if not isSpellcast then
return false
end
local castSpellName = eventArg1
local hasPendingTame = type(MTH_PETS_LastTameAttempt) == "table"
MTH_PETS_LogTame("Spellcast evt=" .. tostring(evt)
.. " arg1='" .. tostring(eventArg1 or "") .. "'"
.. " arg2='" .. tostring(eventArg2 or "") .. "'"
.. " unitCast=" .. tostring(isUnitSpellcast)
.. " pending=" .. tostring(hasPendingTame))
if isUnitSpellcast then
if eventArg1 ~= "player" then
return true
end
castSpellName = eventArg2
end
if evt == "SPELLCAST_START" or evt == "SPELLCAST_CHANNEL_START"
or evt == "UNIT_SPELLCAST_START" or evt == "UNIT_SPELLCAST_CHANNEL_START" then
-- For vanilla SPELLCAST_* events arg1 is a duration (ms), not spell name.
-- Always forward to RecordTameAttempt — it has all the fallback/heuristic logic.
MTH_PETS_RecordTameAttempt(evt, castSpellName)
return true
end
if evt == "SPELLCAST_STOP" or evt == "SPELLCAST_CHANNEL_STOP" or evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED"
or evt == "UNIT_SPELLCAST_STOP" or evt == "UNIT_SPELLCAST_CHANNEL_STOP"
or evt == "UNIT_SPELLCAST_FAILED" or evt == "UNIT_SPELLCAST_INTERRUPTED" then
local terminalIsTame = MTH_PETS_IsTameBeastSpellName(castSpellName)
if not terminalIsTame and hasPendingTame then
terminalIsTame = true
end
if not terminalIsTame then
return true
end
if terminalIsTame then
if evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED" then
MTH_PETS_LastTameAttempt = nil
end
end
return true
end
return false
end
function MTH_ST_HandleUnitPetEvent(evt, eventArg1)
if evt ~= "UNIT_PET" then
return false
end
if eventArg1 ~= "player" then
return true
end
local startedAt = MTH_ST_ProfileNow()
local beforeId = nil
local beforeName = nil
local beforeFamily = nil
local beforeLevel = nil
local beforeExists = nil
local petsBefore = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil
if type(petsBefore) == "table" and type(petsBefore.currentPet) == "table" then
beforeId = petsBefore.currentPet.id
beforeName = petsBefore.currentPet.name
beforeFamily = petsBefore.currentPet.family
beforeLevel = petsBefore.currentPet.level
beforeExists = petsBefore.currentPet.exists and true or false
end
MTH_ST_PrintStableDiag("swap UNIT_PET begin beforeId=" .. tostring(beforeId or "-") .. " beforeName=" .. tostring(beforeName or "-"))
MTH_PETS_HandleUnitPetTransition("UNIT_PET")
local afterId = nil
local afterName = nil
local afterFamily = nil
local afterLevel = nil
local afterExists = nil
local petsAfter = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil
if type(petsAfter) == "table" and type(petsAfter.currentPet) == "table" then
afterId = petsAfter.currentPet.id
afterName = petsAfter.currentPet.name
afterFamily = petsAfter.currentPet.family
afterLevel = petsAfter.currentPet.level
afterExists = petsAfter.currentPet.exists and true or false
end
local sameCore = (tostring(beforeId or "") == tostring(afterId or ""))
and (tostring(beforeName or "") == tostring(afterName or ""))
and (tostring(beforeFamily or "") == tostring(afterFamily or ""))
and (tonumber(beforeLevel) or -1) == (tonumber(afterLevel) or -1)
and (beforeExists and true or false) == (afterExists and true or false)
local emitSkipped = false
local liveChanged = false
if MTH_ST_IsStableFrameOpen() then
emitSkipped = true
MTH_ST_PrintStableDiag("swap UNIT_PET emit-skip reason=stable-open")
elseif sameCore then
emitSkipped = true
MTH_ST_PrintStableDiag("swap UNIT_PET emit-skip reason=unchanged-core")
else
liveChanged = MTH_PETS_EmitLiveState("UNIT_PET", false, { skipRefresh = true }) and true or false
end
local elapsedMs = 0
local finishedAt = MTH_ST_ProfileNow()
if startedAt and finishedAt then
elapsedMs = math.floor(math.max(0, (finishedAt - startedAt) * 1000) + 0.5)
end
MTH_ST_PrintStableDiag("swap UNIT_PET end afterId=" .. tostring(afterId or "-")
.. " afterName=" .. tostring(afterName or "-")
.. " sameCore=" .. tostring(sameCore)
.. " emitSkipped=" .. tostring(emitSkipped)
.. " liveChanged=" .. tostring(liveChanged)
.. " elapsedMs=" .. tostring(elapsedMs))
return true
end
local function MTH_ST_IsPlayerDeadOrGhost()
if type(UnitIsDeadOrGhost) == "function" then
return UnitIsDeadOrGhost("player") and true or false
end
if type(UnitIsDead) == "function" then
return UnitIsDead("player") and true or false
end
return false
end
function MTH_ST_HandleVitalsEvent(evt, eventArg1)
local playerDead = MTH_ST_IsPlayerDeadOrGhost()
local hasLivePet = (type(UnitExists) == "function" and UnitExists("pet")) and true or false
if evt == "PET_BAR_UPDATE" then
if not hasLivePet then
MTH_ST_TraceRecord(evt, "vitals", "pet_bar:no-pet")
return true
end
local interval = playerDead and 1.75 or 0.5
if not MTH_PETS_ShouldHandleThrottledEvent("PET_BAR_UPDATE", interval) then
MTH_ST_TraceRecord(evt, "vitals", "pet_bar:throttled")
return true
end
local changed = MTH_PETS_ApplyLivePetVitalsFast("PET_BAR_UPDATE")
if changed then
MTH_ST_TraceRecord(evt, "vitals", "pet_bar:changed")
MTH_PETS_EmitLiveState("PET_BAR_UPDATE", false, { skipRefresh = true })
else
MTH_ST_TraceRecord(evt, "vitals", "pet_bar:no-change")
end
return true
end
if evt == "UNIT_HAPPINESS" then
if not hasLivePet then
MTH_ST_TraceRecord(evt, "vitals", "happiness:no-pet")
return true
end
if eventArg1 and eventArg1 ~= "pet" then
MTH_ST_TraceRecord(evt, "vitals", "happiness:not-pet-arg")
return true
end
local interval = playerDead and 2.25 or 0.75
if not MTH_PETS_ShouldHandleThrottledEvent("UNIT_HAPPINESS", interval) then
MTH_ST_TraceRecord(evt, "vitals", "happiness:throttled")
return true
end
local changed = MTH_PETS_ApplyLivePetVitalsFast("UNIT_HAPPINESS")
if changed then
MTH_ST_TraceRecord(evt, "vitals", "happiness:changed")
MTH_PETS_EmitLiveState("UNIT_HAPPINESS", false, { skipRefresh = true })
else
MTH_ST_TraceRecord(evt, "vitals", "happiness:no-change")
end
return true
end
if evt == "CHAT_MSG_COMBAT_XP_GAIN" or evt == "PLAYER_XP_UPDATE" then
if not hasLivePet then
MTH_ST_TraceRecord(evt, "vitals", "xp:no-pet")
return true
end
if playerDead then
MTH_ST_TraceRecord(evt, "vitals", "xp:dead")
return true
end
if not MTH_PETS_ShouldHandleThrottledEvent("XP_UPDATE", 1.5) then
MTH_ST_TraceRecord(evt, "vitals", "xp:throttled")
return true
end
local changed = MTH_PETS_ApplyLivePetVitalsFast(evt)
if changed then
MTH_ST_TraceRecord(evt, "vitals", "xp:changed")
MTH_PETS_EmitLiveState(evt, false, { skipRefresh = true })
else
MTH_ST_TraceRecord(evt, "vitals", "xp:no-change")
end
return true
end
return false
end
function MTH_ST_HandleRunawayEvent(evt, eventArg1)
if evt ~= "CHAT_MSG_SYSTEM" and evt ~= "UI_ERROR_MESSAGE"
and evt ~= "CHAT_MSG_SPELL_PET_INFO" and evt ~= "CHAT_MSG_SPELL_PET_DAMAGE"
and evt ~= "CHAT_MSG_COMBAT_PET_HITS" and evt ~= "CHAT_MSG_COMBAT_PET_MISSES" then
return false
end
if type(UnitExists) == "function" and not UnitExists("pet") then
if not MTH_PETS_ShouldHandleThrottledEvent("RUNAWAY_MSG_NO_PET", 2.0) then
MTH_ST_TraceRecord(evt, "runaway", "no-pet:throttled")
return true
end
MTH_ST_TraceRecord(evt, "runaway", "no-pet:parse")
end
if MTH_ST_IsPlayerDeadOrGhost() then
if not MTH_PETS_ShouldHandleThrottledEvent("RUNAWAY_MSG_DEAD", 0.30) then
MTH_ST_TraceRecord(evt, "runaway", "dead:throttled")
return true
end
end
local runawayStarted = MTH_ST_ProfileNow()
local matchedRunaway = MTH_PETS_IsRunawaySystemMessage(eventArg1)
if runawayStarted then
local runawayNow = MTH_ST_ProfileNow()
if runawayNow then
MTH_ST_ProfileRecordRunaway((runawayNow - runawayStarted) * 1000)
end
end
if matchedRunaway then
MTH_ST_TraceRecord(evt, "runaway", "matched")
MTH_PETS_RecordPetRunaway(evt, eventArg1)
MTH_PETS_EmitLiveState(evt)
else
MTH_ST_TraceRecord(evt, "runaway", "not-matched")
end
return true
end
function MTH_ST_HandleStableUiEvent(evt)
if evt == "PET_STABLE_SHOW" then
MTH_ST_TraceRecord(evt, "stable-ui", "show")
MTH_ST_TraceRecord(evt, "stable-ui", "show:no-scan")
return true
end
if evt == "PET_STABLE_UPDATE" then
MTH_ST_TraceRecord(evt, "stable-ui", "update")
MTH_ST_TraceRecord(evt, "stable-ui", "update:no-scan")
MTH_PETS_EmitLiveState(evt)
return true
end
if evt == "PET_STABLE_CLOSED" then
MTH_ST_TraceRecord(evt, "stable-ui", "closed")
MTH_ST_QueueDeferredStableScan("PET_STABLE_CLOSED", 0.20)
MTH_ST_TraceRecord(evt, "stable-ui", "closed:deferred-scan")
MTH_PETS_EmitLiveState(evt)
return true
end
return false
end
local function MTH_ST_DispatchEvent(_, evt, eventArg1, eventArg2)
evt = evt or event
eventArg1 = eventArg1 or arg1
eventArg2 = eventArg2 or arg2
if not evt then
return
end
if type(MTH_ST_LifecycleService) == "table" and type(MTH_ST_LifecycleService.HandleEvent) == "function" then
local lifecycleHandled, lifecycleNote = MTH_ST_LifecycleService:HandleEvent(evt, eventArg1, eventArg2)
if lifecycleHandled then
MTH_ST_TraceRecord(evt, "dispatch", lifecycleNote or "lifecycle-service")
return
end
else
if MTH_ST_HandlePlayerEnteringWorld(evt) then
MTH_ST_TraceRecord(evt, "dispatch", "lifecycle-enter-world")
return
end
if MTH_ST_HandleSpellcastEvent(evt, eventArg1, eventArg2) then
MTH_ST_TraceRecord(evt, "dispatch", "lifecycle-spellcast")
return
end
if MTH_ST_HandleUnitPetEvent(evt, eventArg1) then
MTH_ST_TraceRecord(evt, "dispatch", "lifecycle-unit-pet")
return
end
end
if type(MTH_ST_VitalsService) == "table" and type(MTH_ST_VitalsService.HandleEvent) == "function" then
if MTH_ST_VitalsService:HandleEvent(evt, eventArg1, eventArg2) then
MTH_ST_TraceRecord(evt, "dispatch", "vitals-service")
return
end
else
if MTH_ST_HandleVitalsEvent(evt, eventArg1) then
MTH_ST_TraceRecord(evt, "dispatch", "vitals")
return
end
end
if type(MTH_ST_RunawayService) == "table" and type(MTH_ST_RunawayService.HandleEvent) == "function" then
if MTH_ST_RunawayService:HandleEvent(evt, eventArg1, eventArg2) then
MTH_ST_TraceRecord(evt, "dispatch", "runaway-service")
return
end
else
if MTH_ST_HandleRunawayEvent(evt, eventArg1) then
MTH_ST_TraceRecord(evt, "dispatch", "runaway")
return
end
end
if type(MTH_ST_StableScanService) == "table" and type(MTH_ST_StableScanService.HandleEvent) == "function" then
if MTH_ST_StableScanService:HandleEvent(evt, eventArg1, eventArg2) then
MTH_ST_TraceRecord(evt, "dispatch", "stable-service")
return
end
else
if MTH_ST_HandleStableUiEvent(evt) then
MTH_ST_TraceRecord(evt, "dispatch", "stable-ui")
return
end
end
MTH_ST_TraceRecord(evt, "dispatch", "unhandled")
end
local function MTH_ST_OnEvent(frame, evt, eventArg1, eventArg2)
local started = nil
if MTH_ST_Profile and MTH_ST_Profile.enabled then
started = MTH_ST_ProfileNow()
end
local stableDiagStart = nil
local isStableUiEvt = MTH_ST_IsStableUiEvent(evt)
if isStableUiEvt then
stableDiagStart = MTH_ST_ProfileNow()
MTH_ST_PrintStableDiag("evt=" .. tostring(evt) .. " phase=begin")
end
MTH_ST_DispatchEvent(frame, evt, eventArg1, eventArg2)
if isStableUiEvt then
local stableDiagEnd = MTH_ST_ProfileNow()
local elapsedMs = 0
if stableDiagStart and stableDiagEnd then
elapsedMs = math.floor(math.max(0, (stableDiagEnd - stableDiagStart) * 1000) + 0.5)
end
MTH_ST_PrintStableDiag("evt=" .. tostring(evt) .. " phase=end elapsedMs=" .. tostring(elapsedMs))
MTH_ST_TraceFlushIfDue(true)
end
if started then
local finished = MTH_ST_ProfileNow()
if finished then
MTH_ST_ProfileRecordEvent(evt or event, (finished - started) * 1000)
MTH_ST_ProfileFlushIfDue(false)
end
end
MTH_ST_TraceFlushIfDue(false)
end
function MTH_ST_InitService()
if MTH and MTH.ApplyClassGate and MTH:ApplyClassGate("stablescan-init") then
return
end
if MTH_PetLifecycleEventFrame then
return
end
MTH_PetLifecycleEventFrame = CreateFrame("Frame", "MTH_PetLifecycleEvent")
MTH_PetLifecycleEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
MTH_PetLifecycleEventFrame:RegisterEvent("UNIT_PET")
MTH_PetLifecycleEventFrame:RegisterEvent("PET_BAR_UPDATE")
MTH_PetLifecycleEventFrame:RegisterEvent("UNIT_HAPPINESS")
MTH_PetLifecycleEventFrame:RegisterEvent("SPELLCAST_CHANNEL_START")
MTH_PetLifecycleEventFrame:RegisterEvent("SPELLCAST_CHANNEL_STOP")
MTH_PetLifecycleEventFrame:RegisterEvent("SPELLCAST_START")
MTH_PetLifecycleEventFrame:RegisterEvent("SPELLCAST_STOP")
MTH_PetLifecycleEventFrame:RegisterEvent("SPELLCAST_FAILED")
MTH_PetLifecycleEventFrame:RegisterEvent("SPELLCAST_INTERRUPTED")
MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_COMBAT_XP_GAIN")
MTH_PetLifecycleEventFrame:RegisterEvent("PLAYER_XP_UPDATE")
MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_SYSTEM")
MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_SPELL_PET_INFO")
MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_SPELL_PET_DAMAGE")
MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_COMBAT_PET_HITS")
MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_COMBAT_PET_MISSES")
MTH_PetLifecycleEventFrame:RegisterEvent("UI_ERROR_MESSAGE")
MTH_PetLifecycleEventFrame:RegisterEvent("PET_STABLE_SHOW")
MTH_PetLifecycleEventFrame:RegisterEvent("PET_STABLE_UPDATE")
MTH_PetLifecycleEventFrame:RegisterEvent("PET_STABLE_CLOSED")
MTH_PetLifecycleEventFrame:SetScript("OnEvent", MTH_ST_OnEvent)
MTH_ST_LastUnitPetHadPet = ((type(UnitExists) == "function") and UnitExists("pet")) and true or false
MTH_PETS_InstallCoreRenameHook()
MTH_PETS_EmitLiveState("service:init", true)
if MTH_PETS_TRACE_CONSISTENCY and MTH and MTH.Print then
end
end
function MTH_ST_InitBootstrap()
if MTH and MTH.ApplyClassGate and MTH:ApplyClassGate("stablescan-bootstrap") then
return
end
if MTH_PetLifecycleEventFrame or MTH_PetLifecycleBootstrapFrame then
return
end
local frame = CreateFrame("Frame", "MTH_PetLifecycleBoot")
if not frame then
return
end
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("PET_STABLE_SHOW")
frame:RegisterEvent("PET_STABLE_UPDATE")
frame:SetScript("OnEvent", function()
if event ~= "PLAYER_ENTERING_WORLD" and event ~= "PET_STABLE_SHOW" and event ~= "PET_STABLE_UPDATE" then
return
end
if event == "PET_STABLE_SHOW" or event == "PET_STABLE_UPDATE" then
MTH_ST_PrintStableDiag("bootstrap evt=" .. tostring(event) .. " phase=begin")
end
MTH_ST_InitService()
if event == "PET_STABLE_SHOW" or event == "PET_STABLE_UPDATE" then
MTH_ST_PrintStableDiag("bootstrap evt=" .. tostring(event) .. " phase=end action=no-scan")
end
this:UnregisterAllEvents()
this:SetScript("OnEvent", nil)
MTH_PetLifecycleBootstrapFrame = nil
end)
MTH_PetLifecycleBootstrapFrame = frame
end
function MTH_ST_ShutdownService(_reason)
if not MTH_PetLifecycleEventFrame then
return
end
MTH_PetLifecycleEventFrame:UnregisterAllEvents()
MTH_PetLifecycleEventFrame:SetScript("OnEvent", nil)
MTH_PetLifecycleEventFrame = nil
MTH_ST_LastAutoScanAt = 0
MTH_ST_LastUnitPetHadPet = false
MTH_PETS_LiveState = nil
MTH_PETS_LiveStateSeq = 0
if type(PetRename) == "function" and PetRename == MTH_PETS_CorePetRenameHook and type(MTH_PETS_CoreOriginal_PetRename) == "function" then
PetRename = MTH_PETS_CoreOriginal_PetRename
end
if MTH and MTH.RestoreHookBoundary then
MTH:RestoreHookBoundary(MTH_PETS_CORE_HOOK_BOUNDARY_KEY)
end
end
MTH_PETS_GetRootStore()