DopingControl v0.6.1

This commit is contained in:
2026-08-03 09:47:19 +02:00
commit 6859d73285
41 changed files with 15056 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
-- DopingControl core/probe.lua
-- Feasibility probe: dumps RAW API returns to dc_probe.txt (in the WoW
-- client's Imports folder) via SuperWoW ExportFile so raw facts can be read
-- from disk instead of retyped from chat lines.
--
-- Entry points (core/init.lua wires the slash command):
-- DC_Probe.HandleSlash(rest) -- rest = text after "probe":
-- "" -> Battery() (part 2, near-range questions)
-- "range <name>" -> RangeStart(name) / RangeStop() toggle (part 1)
-- DC_Probe.Battery()
-- DC_Probe.RangeStart(playerName) / DC_Probe.RangeStop()
-- DC_Probe.IsRangeRunning()
--
-- Part 1 (range run): a raid member with known buffs walks away; a 1 Hz
-- poller writes one line per tick:
-- dist | UnitExists | UnitIsVisible | nA=count(UnitBuff) | 4th-return |
-- nB=count(GetUnitField) | UnitPosition
-- The unit token is RE-RESOLVED from the name every tick (raid indices are
-- volatile after roster changes).
--
-- Part 2 (battery) answers five open client-behavior questions with raw data:
-- Q1 does UnitBuff(unit, i) return a 4th value ~= nil (spell id)?
-- Q2 does GetUnitField(guid,"aura") match the tooltip names; is the
-- slot split really 1-32 buffs / 33-48 debuffs?
-- Q3 does GetWeaponEnchantInfo(unit) name a foreign temp enchant?
-- Q4 does GetInventoryItemLink(unit, slot) serve out-of-sight units?
-- Q5 is the ENCHANT field in foreign item links filled (~= 0)?
--
-- File mechanics: ExportFile appends ".txt" ITSELF -- the name is passed
-- WITHOUT extension ("dc_probe", never "dc_probe.txt"). ExportFile is a
-- whole-file overwrite, so the probe keeps an in-memory ring of lines and
-- rewrites the complete file on every flush.
--
-- Pure Lua 5.0, dofile-loadable offline (all WoW usage inside functions /
-- existence-checked; the poller frame is created lazily in-game).
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Probe = DC_Probe or {}
local P = DC_Probe
P.RING_CAP = 2000 -- max lines kept; oldest dropped first
P.EXPORT_NAME = "dc_probe" -- NO extension (ExportFile appends .txt)
P.BATTERY_UNIT_CAP = 8 -- full detail for at most N foreign units
local ring = {}
-- ------------------------------------------------------------------
-- ring + flush
-- ------------------------------------------------------------------
function P.Clear()
ring = {}
end
function P.Push(line)
table.insert(ring, line)
while table.getn(ring) > P.RING_CAP do
table.remove(ring, 1)
end
end
function P.Text()
return table.concat(ring, "\n")
end
local function say(msg)
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage("|cffe6c87eDopingControl|r probe: " .. msg)
end
end
function P.Flush()
if ExportFile then
-- name WITHOUT extension -- ExportFile appends .txt itself.
-- pcall: optional-DLL call -- a DLL-side I/O error
-- must not escape into the slash handler or the 1 Hz range ticker.
local ok = pcall(ExportFile, P.EXPORT_NAME, P.Text())
if not ok then
say("ExportFile failed - dump not written")
return false
end
return true
end
say("ExportFile missing (SuperWoW required) - dump not written")
return false
end
-- ------------------------------------------------------------------
-- formatting helpers
-- ------------------------------------------------------------------
local function fmt(v)
if v == nil then
return "nil"
end
if v == true then
return "true"
end
if v == false then
return "false"
end
if type(v) == "number" then
return tostring(v)
end
if type(v) == "string" then
return v
end
return type(v)
end
local function gtype(name)
local v = getglobal and getglobal(name)
if v == nil then
return "nil"
end
return type(v)
end
local function nowStamp()
if date then
return date("%Y-%m-%d %H:%M:%S")
end
return "?"
end
local function header(kind)
P.Push("=== DopingControl probe " .. kind .. " " .. nowStamp() .. " ===")
P.Push("client: TURTLE_WOW_VERSION=" .. fmt(TURTLE_WOW_VERSION)
.. " SUPERWOW_VERSION=" .. fmt(SUPERWOW_VERSION))
P.Push("dll-surface: UnitXP=" .. gtype("UnitXP")
.. " GetUnitField=" .. gtype("GetUnitField")
.. " ExportFile=" .. gtype("ExportFile")
.. " UnitPosition=" .. gtype("UnitPosition"))
end
-- resolve a roster unit token by player name; nil if not in group.
-- Re-run every use: raid indices are volatile.
local function resolveUnit(name)
if not UnitName then
return nil
end
local lower = string.lower(name)
local nraid = (GetNumRaidMembers and GetNumRaidMembers()) or 0
if nraid > 0 then
for i = 1, nraid do
local ok, nm = pcall(UnitName, "raid" .. i)
if ok and nm and string.lower(nm) == lower then
return "raid" .. i
end
end
return nil
end
for i = 1, 4 do
local ok, nm = pcall(UnitName, "party" .. i)
if ok and nm and string.lower(nm) == lower then
return "party" .. i
end
end
return nil
end
local function unitGuid(unit)
if not UnitExists then
return nil
end
local ok, ex, g = pcall(UnitExists, unit)
if ok and ex and g then
return g
end
return nil
end
-- count UnitBuff entries + first non-nil 4th return
local function pathACounts(unit)
local n = 0
local firstId = nil
local idCount = 0
if UnitBuff then
for i = 1, 32 do
local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, unit, i)
if not ok or not tex then
break
end
n = n + 1
if type(id4) == "number" and id4 > 0 then
idCount = idCount + 1
if not firstId then
firstId = id4
end
end
end
end
return n, firstId, idCount
end
-- GetUnitField(guid,"aura") -> comma list for a slot range (sparse table)
local function pathBList(guid, from, to)
if not GetUnitField or not guid then
return nil, 0
end
local ok, t = pcall(GetUnitField, guid, "aura")
if not ok then
return "ERR", 0
end
if type(t) ~= "table" then
return "type=" .. type(t), 0
end
local parts = {}
local n = 0
for i = from, to do
local v = t[i]
if type(v) == "number" and v > 0 then
n = n + 1
table.insert(parts, i .. ":" .. v)
end
end
if n == 0 then
return "-", 0
end
return table.concat(parts, ","), n
end
-- ------------------------------------------------------------------
-- Part 2: the battery (/dc probe)
-- ------------------------------------------------------------------
function P.Battery()
if not UnitName then
say("no client APIs (offline?) - battery skipped")
return false
end
P.Clear()
header("battery")
local roster = (DC_Scan and DC_Scan.BuildRoster and DC_Scan.BuildRoster()) or {}
local total = table.getn(roster)
P.Push("roster: " .. total .. " units; full detail for the first "
.. P.BATTERY_UNIT_CAP .. " foreign units")
P.Push("")
-- self weapon enchant baseline (vanilla 6-return form)
if GetWeaponEnchantInfo then
local ok, r1, r2, r3, r4, r5, r6 = pcall(GetWeaponEnchantInfo)
P.Push("self GetWeaponEnchantInfo(): ok=" .. fmt(ok)
.. " hasMain=" .. fmt(r1) .. " mainExp=" .. fmt(r2)
.. " mainCharges=" .. fmt(r3) .. " hasOff=" .. fmt(r4)
.. " offExp=" .. fmt(r5) .. " offCharges=" .. fmt(r6))
else
P.Push("self GetWeaponEnchantInfo(): API missing")
end
P.Push("")
local detailed = 0
for r = 1, total do
local e = roster[r]
local isSelf = false
if UnitIsUnit then
local ok, v = pcall(UnitIsUnit, e.unit, "player")
isSelf = (ok and v) and true or false
end
if not isSelf and detailed < P.BATTERY_UNIT_CAP then
detailed = detailed + 1
local guid = unitGuid(e.unit)
local online, dist = DC_Reach.ReadUnit(e.unit)
P.Push("--- " .. e.unit .. " name=" .. fmt(e.name)
.. " class=" .. fmt(e.class) .. " online=" .. fmt(online)
.. " dist=" .. fmt(dist) .. " guid=" .. fmt(guid))
-- Q1: UnitBuff rows with 4th return + tooltip name
if UnitBuff then
local rows = 0
for i = 1, 32 do
local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, e.unit, i)
if not ok or not tex then
break
end
rows = rows + 1
local tipName = nil
-- same hidden tooltip as scan/aura.lua (shared accessor,
-- never a second frame under the same global name)
local tip = DC_Aura and DC_Aura.GetScanTip
and DC_Aura.GetScanTip()
if tip then
local ok2 = pcall(function()
tip:ClearLines()
tip:SetUnitBuff(e.unit, i)
end)
if ok2 then
local obj = getglobal("DopingControlScanTipTextLeft1")
if obj then
tipName = obj:GetText()
end
end
end
P.Push("Q1 UnitBuff i=" .. i .. " tex=" .. fmt(tex)
.. " stacks=" .. fmt(stacks) .. " dtype=" .. fmt(dtype)
.. " id4=" .. fmt(id4) .. " tipName=" .. fmt(tipName))
end
local nA, firstId, idCount = pathACounts(e.unit)
P.Push("Q1 summary: buffs=" .. nA .. " id4nonNil=" .. idCount
.. " (id4 ~= nil answers Q1)")
else
P.Push("Q1 UnitBuff: API missing")
end
-- Q2: descriptor list, buff and debuff halves separately
local buffList = pathBList(guid, 1, 32)
local debuffList = pathBList(guid, 33, 48)
P.Push("Q2 GetUnitField(guid,\"aura\") slots1-32=" .. fmt(buffList))
P.Push("Q2 GetUnitField(guid,\"aura\") slots33-48=" .. fmt(debuffList)
.. " (compare vs UnitBuff names above -> slot-split check)")
-- Q3: foreign weapon enchant name
if GetWeaponEnchantInfo then
local ok, w1, w2 = pcall(GetWeaponEnchantInfo, e.unit)
P.Push("Q3 GetWeaponEnchantInfo(unit): ok=" .. fmt(ok)
.. " r1=" .. fmt(w1) .. " r2=" .. fmt(w2))
end
-- Q4/Q5: inventory links, raw + parsed enchant field.
-- subType (GetItemInfo return 6, de-shifted by DC_Gear.ItemInfo)
-- is dumped per slot to verify the DC.ENCH_EXEMPT [ASSUMPTION]
-- strings against reality (data/enchants.lua).
local slots = DC_Gear.InvSlots()
local linkCount = 0
for s = 1, table.getn(slots) do
local inv = slots[s]
local ok, link = pcall(GetInventoryItemLink, e.unit, inv)
if ok and link then
linkCount = linkCount + 1
local id, ench = DC_Gear.ParseLink(link)
local subType = nil
if id then
local _, _, st = DC_Gear.ItemInfo(id)
subType = st
end
P.Push("Q4/Q5 slot" .. inv .. " link=" .. fmt(link)
.. " -> id=" .. fmt(id) .. " ench=" .. fmt(ench)
.. " subType=" .. fmt(subType))
end
end
P.Push("Q4 summary: " .. linkCount .. "/" .. table.getn(slots)
.. " slots returned a link (0 for an out-of-sight unit"
.. " answers Q4 negatively)")
P.Push("Q5b: subType above checks the ENCH_EXEMPT assumption"
.. " strings - expected \"Wands\"/\"Thrown\" (slot 18) and"
.. " \"Miscellaneous\" (slot 17), data/enchants.lua")
-- Q6: resistance read paths (RESIST tab, scan/engine.lua
-- readResists) -- school 2 (Fire) as a representative probe,
-- all three paths dumped raw: does UnitResistance answer for a
-- foreign unit token, does it also accept the GUID directly,
-- and does GetUnitField(guid,"resistances") carry a usable
-- table at all -- or does a foreign read silently return 0?
if UnitResistance then
local ok1, base1, total1 = pcall(UnitResistance, e.unit, 2)
P.Push("Q6 UnitResistance(unit,2): ok=" .. fmt(ok1)
.. " base=" .. fmt(base1) .. " total=" .. fmt(total1))
if guid then
local ok2, base2, total2 = pcall(UnitResistance, guid, 2)
P.Push("Q6 UnitResistance(guid,2): ok=" .. fmt(ok2)
.. " base=" .. fmt(base2) .. " total=" .. fmt(total2))
end
else
P.Push("Q6 UnitResistance: API missing")
end
if guid and GetUnitField then
local ok3, t = pcall(GetUnitField, guid, "resistances")
if ok3 and type(t) == "table" then
P.Push("Q6 GetUnitField(guid,\"resistances\"): [1]="
.. fmt(t[1]) .. " [2]=" .. fmt(t[2]) .. " [3]="
.. fmt(t[3]) .. " [4]=" .. fmt(t[4]) .. " [5]="
.. fmt(t[5]) .. " [6]=" .. fmt(t[6]) .. " [7]="
.. fmt(t[7]))
else
P.Push("Q6 GetUnitField(guid,\"resistances\"): ok="
.. fmt(ok3) .. " type=" .. type(t))
end
end
P.Push("")
end
end
if detailed == 0 then
P.Push("no foreign units in group - battery needs a party/raid")
end
local written = P.Flush()
if written then
say("battery done (" .. detailed .. " foreign units) -> "
.. P.EXPORT_NAME .. ".txt")
end
return written
end
-- ------------------------------------------------------------------
-- Part 1: the 1 Hz range run (/dc probe range <name>)
-- ------------------------------------------------------------------
local rangeFrame = nil
local rangeName = nil
local rangeRunning = false
local rangeAcc = 0
local rangeT0 = 0
local function rangeTick()
local t = (GetTime and GetTime()) or 0
local rel = math.floor(t - rangeT0 + 0.5)
local unit = resolveUnit(rangeName)
if not unit then
P.Push("t=" .. rel .. "s target '" .. rangeName .. "' not in roster")
P.Flush()
return
end
local guid = unitGuid(unit)
local dist = nil
if UnitXP then
local ok, d = pcall(UnitXP, "distanceBetween", "player", unit)
if ok and type(d) == "number" then
dist = d
end
end
local exists = nil
if UnitExists then
local ok, ex = pcall(UnitExists, unit)
if ok then
exists = ex
end
end
local visible = nil
if UnitIsVisible then
local ok, v = pcall(UnitIsVisible, unit)
if ok then
visible = v
end
end
local nA, firstId = pathACounts(unit)
local _, nB = pathBList(guid, 1, 32)
local pos = "nil"
if UnitPosition and guid then
local ok, x, y = pcall(UnitPosition, guid)
if ok and type(x) == "number" then
pos = "ok(" .. string.format("%.1f", x) .. ","
.. string.format("%.1f", y or 0) .. ")"
end
end
-- part-1 column order (matches the header line written by RangeStart)
P.Push("t=" .. rel .. "s unit=" .. unit
.. " dist=" .. fmt(dist)
.. " exists=" .. fmt(exists)
.. " visible=" .. fmt(visible)
.. " nA=" .. nA
.. " id4first=" .. fmt(firstId)
.. " nB=" .. nB
.. " pos=" .. pos)
P.Flush()
end
function P.IsRangeRunning()
return rangeRunning
end
function P.RangeStart(name)
if not CreateFrame then
say("no client (offline) - range run unavailable")
return false
end
if rangeRunning then
say("range run already active for " .. fmt(rangeName)
.. " - stop it first (/dc probe range)")
return false
end
rangeName = name
rangeRunning = true
rangeAcc = 0
rangeT0 = (GetTime and GetTime()) or 0
P.Clear()
header("range-run target=" .. name)
P.Push("columns: t | unit | dist | exists | visible | nA=count(UnitBuff) |"
.. " id4first | nB=count(GetUnitField slots 1-32) | pos=UnitPosition")
if not rangeFrame then
rangeFrame = CreateFrame("Frame", "DopingControlProbeFrame")
end
rangeFrame:SetScript("OnUpdate", function()
rangeAcc = rangeAcc + (arg1 or 0)
if rangeAcc >= 1.0 then
rangeAcc = 0
rangeTick()
end
end)
say("range run started, target " .. name .. ", 1 Hz -> "
.. P.EXPORT_NAME .. ".txt (stop: /dc probe range " .. name .. ")")
return true
end
function P.RangeStop()
if rangeFrame then
rangeFrame:SetScript("OnUpdate", nil)
end
if rangeRunning then
rangeRunning = false
P.Push("=== range run stopped " .. nowStamp() .. " ===")
P.Flush()
say("range run stopped, " .. table.getn(ring) .. " lines in "
.. P.EXPORT_NAME .. ".txt")
end
rangeName = nil
end
-- ------------------------------------------------------------------
-- slash glue: rest of the line after "probe"
-- ------------------------------------------------------------------
function P.HandleSlash(rest)
rest = rest or ""
rest = string.gsub(rest, "^%s+", "")
rest = string.gsub(rest, "%s+$", "")
if rest == "" then
return P.Battery()
end
local _, _, word = string.find(rest, "^(%S+)")
if word and string.lower(word) == "range" then
if rangeRunning then
P.RangeStop()
return true
end
local _, _, name = string.find(rest, "^%S+%s+(.+)$")
if name then
return P.RangeStart(name)
end
say("usage: /dc probe range <playername>")
return false
end
say("usage: /dc probe | /dc probe range <playername>")
return false
end