Files
2026-08-30 15:37:01 +02:00

393 lines
17 KiB
Lua

-- TotemBar - core/pulsecal.lua
-- Dev-only pulse-calibration telemetry (/tb pulsecal): captures raw
-- totem-related combat lines + our own placements into a fixed ring buffer
-- and exports them via SuperWoW ExportFile, so the dev environment can
-- measure REAL TurtleWoW pulse intervals offline and bake them back into
-- core/pulsedata.lua (verified=true).
-- Pure buffer/serializer on top (offline-tested); WoW-gated capture below.
TotemBar = TotemBar or {}
TotemBar.PULSECAL_CAP = 2000
-- Dev-only diagnostic probe (/tb tprobe) for the destroyed-totem GUID-latch
-- paths in core/cast.lua ("Destroyed-totem detection (GUID liveness)"). Built
-- because two in-game measurement attempts against those latch paths were
-- both silently wiped by a /reload before their taps could be read (see
-- 2026-08-19/20 session) -- unlike pulsecal above (start, drop totems, dump
-- once by hand), this probe survives /reload on its own: it re-exports its
-- WHOLE ring buffer after every kept event (not only on demand) and re-arms
-- itself from TotemBarDB.tprobeArmed at ADDON_LOADED (see the WoW-gated
-- section below). Shares pulsecalPush/pulsecalFormat above for the ring
-- buffer itself -- only the filter and the per-line detail string are new.
TotemBar.TPROBE_CAP = 300
-- Pure: push one record into the ring buffer. state = { n = total pushed,
-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no
-- allocation growth while capturing).
function TotemBar.pulsecalPush(buf, cap, state, t, ev, msg)
local slot = state.idx
local rec = buf[slot]
if not rec then
rec = {}
buf[slot] = rec
end
rec.t = t
rec.e = ev
rec.m = msg
state.n = state.n + 1
state.idx = slot + 1
if state.idx > cap then
state.idx = 1
end
end
-- Pure: serialize surviving records in chronological order, one
-- "t;event;msg" line each (t with millisecond precision). Allocates - dump
-- time only, never per capture.
function TotemBar.pulsecalFormat(buf, cap, state)
local count = state.n
if count > cap then
count = cap
end
if count == 0 then
return ""
end
local start = state.idx - count
if start < 1 then
start = start + cap
end
local lines = {}
for i = 0, count - 1 do
local slot = start + i
if slot > cap then
slot = slot - cap
end
local rec = buf[slot]
lines[i + 1] = string.format("%.3f;%s;%s", rec.t, rec.e, rec.m)
end
return table.concat(lines, "\n")
end
-- ===== /tb tprobe -- pure filter + line-builder (offline-tested) =====
-- Pure: does `name` look like a totem to a plain substring eye -- "Totem" or
-- "Searing" (the latter because Searing Totem's own pulse-cast spell is
-- suspected to be named "Searing Bolt"/"Attack" rather than "Searing Totem",
-- suspicion (a) in the probe's own brief -- a name check that only looked
-- for "Totem" would silently drop exactly the lines needed to confirm or
-- kill that suspicion). Case-sensitive plain substring, same convention the
-- WoW-gated capture below (and pulsecal's own filter above) already uses --
-- not a design decision unique to this function. nil-safe.
function TotemBar.tprobeNameHit(name)
if not name then
return false
end
if string.find(name, "Totem", 1, true) then
return true
end
if string.find(name, "Searing", 1, true) then
return true
end
return false
end
-- Events always kept regardless of name (a death is diagnostic on its own --
-- there is no "name" to filter on for CHAT_MSG_COMBAT_*_DEATH, and UNIT_DIED
-- firing for something that never matched a totem name is itself evidence,
-- e.g. against suspicion (b)/(c) in the probe's brief).
local TPROBE_ALWAYS_EVENTS = {
UNIT_DIED = true,
CHAT_MSG_COMBAT_FRIENDLY_DEATH = true,
CHAT_MSG_COMBAT_HOSTILE_DEATH = true,
}
-- Pure: should this event be kept? nameUnit is UnitName(arg1) as resolved by
-- the caller, nameSpell is SpellInfo(arg4) -- kept if EITHER hits
-- tprobeNameHit, or if the event is one of TPROBE_ALWAYS_EVENTS above.
function TotemBar.tprobeShouldKeep(event, nameUnit, nameSpell)
if event and TPROBE_ALWAYS_EVENTS[event] then
return true
end
if TotemBar.tprobeNameHit(nameUnit) then
return true
end
if TotemBar.tprobeNameHit(nameSpell) then
return true
end
return false
end
-- Pure: the "msg" half of one kept line (the ring buffer's "t;event;msg"
-- shape from pulsecalPush/pulsecalFormat above supplies t and event already).
-- nameUnit and nameSpell are BOTH kept, deliberately never collapsed into a
-- single "resolved name" -- the whole point of the probe is to see whether
-- they AGREE (suspicion (a): does a Searing Totem pulse's SpellInfo name
-- ever equal "Searing Totem" at all?). Every argument may be nil; tostring()
-- turns that into the literal string "nil", never a concat error.
function TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
return "a1=" .. tostring(a1)
.. ";a2=" .. tostring(a2)
.. ";a3=" .. tostring(a3)
.. ";a4=" .. tostring(a4)
.. ";nameUnit=" .. tostring(nameUnit)
.. ";nameSpell=" .. tostring(nameSpell)
.. ";owner=" .. tostring(ownerName)
.. ";hp=" .. tostring(health)
.. ";latched=" .. tostring(latched)
end
-- Pure: would the PRODUCTIVE handler (core/cast.lua's guidFrame) have
-- latched a GUID for this event's totem, and what does that element's
-- record show right now? nameUnit is tried first (arg1's own UnitName --
-- the identity the productive UNIT_MODEL_CHANGED path itself keys off),
-- nameSpell as a fallback (a totem that only ever surfaces through a SPELL
-- name elementOfFn doesn't recognise -- e.g. "Searing Bolt" -- correctly
-- resolves to nil here, which is itself the diagnostic: the productive
-- UNIT_CASTEVENT path (core/cast.lua's TotemBar.elementFromCastName) has
-- exactly the same blind spot). activeTotems/elementOfFn are injected (same
-- dependency-injection convention core/cast.lua's own TotemBar.elementForGuid
-- uses) so this needs no WoW globals to test. Returns nil both when no
-- element matches AND when the matched element's record has no guid yet --
-- either way there is no guid to report, matching the brief's own
-- "latched=<guid|nil>" literally.
function TotemBar.tprobeLatchedGuid(activeTotems, elementOfFn, nameUnit, nameSpell)
if not elementOfFn then
return nil
end
local element = elementOfFn(nameUnit) or elementOfFn(nameSpell)
if not element or not activeTotems then
return nil
end
local rec = activeTotems[element]
if not rec then
return nil
end
return rec.guid
end
-- ---------------------------------------------------------------------------
-- WoW-gated capture (skipped entirely by the offline test runner).
if CreateFrame then
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
local capturing = false
local buf = {}
local state = { n = 0, idx = 1 }
-- Everything that could plausibly carry a totem pulse on 1.12 (no
-- COMBAT_LOG_EVENT_UNFILTERED here - all localized text in arg1).
local CAPTURE_EVENTS = {
"CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
"CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
"CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE",
"CHAT_MSG_SPELL_SELF_DAMAGE",
"CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
"CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
"CHAT_MSG_SPELL_AURA_GONE_SELF",
"CHAT_MSG_SPELL_AURA_GONE_PARTY",
}
local calFrame = CreateFrame("Frame", "TotemBarPulseCalFrame", UIParent)
calFrame:SetScript("OnEvent", function()
if not capturing then
return
end
if event == "UNIT_CASTEVENT" then
-- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
-- Only record casts whose spell name mentions Totem (SpellInfo
-- is a SuperWoW API; guarded because the event only exists there
-- anyway). arg4 is guarded too (adversarial review, 2026-08-19
-- destroyed-totem session) -- SpellInfo(nil) is unproven safe on
-- this client, and a UNIT_CASTEVENT with no spellId is possible
-- in principle (e.g. a non-cast event type on this arg3 slot).
local sname = (SpellInfo and arg4) and SpellInfo(arg4)
if sname and string.find(sname, "Totem", 1, true) then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event,
tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname))
end
return
end
-- Plain substring guard keeps the hot path cheap; "Totem"-less buff
-- names (Mana Spring / Healing Stream gain lines) are the exception,
-- so include the "You gain" prefix too.
local msg = arg1
if msg and (string.find(msg, "Totem", 1, true) or string.find(msg, "You gain", 1, true)) then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, msg)
end
end)
-- Record our own placements as reference marks (t0 for interval math).
-- Wrapping recordCast is safe here: core/pulsecal.lua loads AFTER
-- core/cast.lua (see .toc) and ui.lua resolves TotemBar.recordCast at
-- call time.
local origRecordCast = TotemBar.recordCast
TotemBar.recordCast = function(element, totemName)
origRecordCast(element, totemName)
if capturing then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(),
"TB_PLACED", tostring(element) .. ";" .. tostring(totemName))
end
end
function TotemBar.PulseCal(sub)
if sub == "start" then
if not capturing then
capturing = true
for i = 1, table.getn(CAPTURE_EVENTS) do
calFrame:RegisterEvent(CAPTURE_EVENTS[i])
end
-- SuperWoW-only event; pcall-guarded in case a client build
-- rejects unknown event names.
pcall(function() calFrame:RegisterEvent("UNIT_CASTEVENT") end)
end
ChatOut:AddMessage("TotemBar: pulsecal capture STARTED (drop totems, then /tb pulsecal dump).")
elseif sub == "stop" then
capturing = false
calFrame:UnregisterAllEvents()
ChatOut:AddMessage("TotemBar: pulsecal capture stopped (" .. state.n .. " records kept).")
elseif sub == "dump" then
if not ExportFile then
ChatOut:AddMessage("TotemBar: pulsecal dump needs SuperWoW (ExportFile missing).")
return
end
-- Filename WITHOUT extension - the client appends .txt itself.
ExportFile("totembar_pulsecal", TotemBar.pulsecalFormat(buf, TotemBar.PULSECAL_CAP, state))
ChatOut:AddMessage("TotemBar: pulsecal dump written (imports\\totembar_pulsecal.txt, "
.. state.n .. " records).")
else
local on = capturing and "ON" or "OFF"
ChatOut:AddMessage("TotemBar: pulsecal " .. on .. ", " .. state.n
.. " records. Usage: /tb pulsecal start|stop|dump")
end
end
-- ===== /tb tprobe -- reload-survivable diagnostic probe =====
-- Pure observation only: never touches TotemBar.activeTotems or any
-- other productive state, only READS TotemBar.activeTotems/elementOf to
-- report what the productive handler would have seen (see
-- tprobeLatchedGuid above).
local tprobeCapturing = false
local tprobeBuf = {}
local tprobeState = { n = 0, idx = 1 }
local function tprobeExport()
if ExportFile then
-- Filename WITHOUT extension - the client appends .txt itself.
ExportFile("tb_tprobe", TotemBar.pulsecalFormat(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState))
end
end
-- Takes explicit args (not the WoW globals directly) so the pcall below
-- wraps a real function value, not a closure built fresh every event.
local function tprobeHandleEvent(ev, a1, a2, a3, a4)
local nameUnit = nil
if a1 and type(UnitName) == "function" then
nameUnit = UnitName(a1)
end
local nameSpell = nil
if a4 and type(SpellInfo) == "function" then
nameSpell = SpellInfo(a4)
end
if not TotemBar.tprobeShouldKeep(ev, nameUnit, nameSpell) then
return
end
local ownerName = nil
if a1 and type(UnitName) == "function" then
ownerName = UnitName(a1 .. "owner")
end
local health = nil
if a1 and type(UnitHealth) == "function" then
health = UnitHealth(a1)
end
local latched = TotemBar.tprobeLatchedGuid(TotemBar.activeTotems, TotemBar.elementOf, nameUnit, nameSpell)
local detail = TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
TotemBar.pulsecalPush(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState, GetTime(), ev, detail)
-- Export on EVERY kept event, not only on /tb tprobe dump - the
-- whole reason this probe exists is that two earlier measurement
-- attempts were wiped by a /reload before a manual dump could run.
tprobeExport()
end
local tprobeFrame = CreateFrame("Frame", "TotemBarTProbeFrame", UIParent)
tprobeFrame:SetScript("OnEvent", function()
if not tprobeCapturing then
return
end
-- pcall-wrapped end to end (per spec): observation must never be
-- able to break anything else this client does.
pcall(tprobeHandleEvent, event, arg1, arg2, arg3, arg4)
end)
local function tprobeArm()
if tprobeCapturing then
return
end
tprobeCapturing = true
-- Native 1.12 events, safe to register directly.
tprobeFrame:RegisterEvent("UNIT_MODEL_CHANGED")
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
-- SuperWoW/nampower-only events; pcall-guarded exactly like
-- calFrame's own UNIT_CASTEVENT registration above.
pcall(function() tprobeFrame:RegisterEvent("UNIT_CASTEVENT") end)
pcall(function() tprobeFrame:RegisterEvent("UNIT_DIED") end)
end
local function tprobeDisarm()
tprobeCapturing = false
tprobeFrame:UnregisterAllEvents()
end
function TotemBar.TProbe(sub)
if sub == "on" then
tprobeArm()
if TotemBarDB then
TotemBarDB.tprobeArmed = true
end
ChatOut:AddMessage("TotemBar: tprobe capture STARTED (" .. tprobeState.n .. " records kept so far).")
elseif sub == "off" then
tprobeDisarm()
if TotemBarDB then
TotemBarDB.tprobeArmed = false
end
ChatOut:AddMessage("TotemBar: tprobe capture stopped (" .. tprobeState.n .. " records kept).")
elseif sub == "dump" then
if not ExportFile then
ChatOut:AddMessage("TotemBar: tprobe dump needs SuperWoW (ExportFile missing).")
return
end
tprobeExport()
ChatOut:AddMessage("TotemBar: tprobe dump written (imports\\tb_tprobe.txt, "
.. tprobeState.n .. " records).")
else
local on = tprobeCapturing and "ON" or "OFF"
ChatOut:AddMessage("TotemBar: tprobe " .. on .. ", " .. tprobeState.n
.. " records. Usage: /tb tprobe on|off|dump|status")
end
end
-- Re-arm after /reload if it was left on. SavedVariables survive a
-- /reload; this frame's event registrations do not (a fresh Lua state
-- runs on every reload) - so an on->reload->still-on session needs its
-- own ADDON_LOADED hook to re-attach tprobeFrame's events, same "wait for
-- ADDON_LOADED before trusting TotemBarDB" convention core/config.lua's
-- ensureDefaults (called from ui.lua's own ADDON_LOADED handler) and
-- minimap.lua's build-on-load both already use, rather than assuming
-- load order against either of those.
local tprobeInitFrame = CreateFrame("Frame", "TotemBarTProbeInitFrame", UIParent)
tprobeInitFrame:RegisterEvent("ADDON_LOADED")
tprobeInitFrame:SetScript("OnEvent", function()
if event == "ADDON_LOADED" and arg1 == "TotemBar" then
tprobeInitFrame:UnregisterEvent("ADDON_LOADED")
if TotemBarDB and TotemBarDB.tprobeArmed then
tprobeArm()
ChatOut:AddMessage("TotemBar: tprobe re-armed after reload (" .. tprobeState.n .. " records kept).")
end
end
end)
end