Vampify v0.4.0
This commit is contained in:
+71
-5
@@ -319,6 +319,28 @@ local lastHp, lastAt, lastExpected = nil, nil, nil
|
||||
local nextCheck = 0
|
||||
local WATCH_INTERVAL = 15 -- slow on purpose: findings are read by a human, not a loop
|
||||
|
||||
-- ---- the zone, for the per-hit export ---------------------------------------------------------
|
||||
--
|
||||
-- Cached rather than read per hit. GetRealZoneText() is cheap but it RETURNS A STRING, and a
|
||||
-- string per damage event is exactly the per-frame allocation this project forbids in hot paths
|
||||
-- (30-60 hits/sec during an AoE pull). The zone changes on a loading screen and nowhere else, so
|
||||
-- it is refreshed from the two events that already fire there.
|
||||
--
|
||||
-- WHY IT IS RECORDED AT ALL: an offline analysis could not answer "use only the Shadowfang Keep
|
||||
-- data" -- the export had no way to tell one grinding session's zone from another's, and mob
|
||||
-- level (and therefore hit size, and therefore how often the per-source floor of 1 dominates the
|
||||
-- percentage) varies enormously between them. See docs/VAMPIRISM-MODELL-STAND §4.
|
||||
--
|
||||
-- Pipes are stripped because "|" is this format's field separator; no real zone name contains one,
|
||||
-- but a parser that splits on "|" must not be able to be broken by a name at all.
|
||||
local zoneName = "?"
|
||||
local function refreshZone()
|
||||
local z = GetRealZoneText and GetRealZoneText()
|
||||
if not z or z == "" then z = GetZoneText and GetZoneText() end
|
||||
if not z or z == "" then zoneName = "?" return end
|
||||
zoneName = string.gsub(z, "|", "")
|
||||
end
|
||||
|
||||
-- print_ already prefixes "Vampify", so this must not repeat it -- the first field build did, and
|
||||
-- the result read "Vampify Vampify watch:".
|
||||
--
|
||||
@@ -485,6 +507,13 @@ if VampifyDamage.onDamage then
|
||||
pred_heal = healFloat,
|
||||
acc_total = VampifyState.acc.total,
|
||||
crit = isCrit,
|
||||
-- The three fields the offline analysis was missing (docs/VAMPIRISM-MODELL-STAND
|
||||
-- §4). pcts hands over the LIVE list -- PH.recordHit serialises it immediately
|
||||
-- rather than holding the reference, which matters because this very table is
|
||||
-- refilled in place on every gear change (see recompute above).
|
||||
pcts = sourcePercents,
|
||||
zone = zoneName,
|
||||
cast = VampifyDamage.castSeenFor and VampifyDamage.castSeenFor(spellId, GetTime()),
|
||||
}, UnitHealth and UnitHealth("player"), GetTime(), UnitHealthMax and UnitHealthMax("player"))
|
||||
end
|
||||
|
||||
@@ -823,6 +852,9 @@ if CreateFrame then
|
||||
f:RegisterEvent("PLAYER_CAMPING")
|
||||
f:RegisterEvent("PLAYER_QUITING")
|
||||
f:RegisterEvent("LOGOUT_CANCEL")
|
||||
-- Fires on every loading screen and on every zone border crossed on foot -- the only two ways
|
||||
-- the cached zone name above can go stale.
|
||||
f:RegisterEvent("ZONE_CHANGED_NEW_AREA")
|
||||
|
||||
-- The running count of combat seconds the "no damage events at all" verdict is given from.
|
||||
-- Declared out here rather than beside the OnUpdate that uses it because the combat-end branch
|
||||
@@ -860,8 +892,13 @@ if CreateFrame then
|
||||
end
|
||||
return
|
||||
end
|
||||
if event == "ZONE_CHANGED_NEW_AREA" then
|
||||
refreshZone()
|
||||
return
|
||||
end
|
||||
if event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then
|
||||
if event == "PLAYER_ENTERING_WORLD" then wireSession() end
|
||||
refreshZone()
|
||||
recompute()
|
||||
syncPerHit()
|
||||
if VampifyMinimap and VampifyMinimap.update then VampifyMinimap.update() end
|
||||
@@ -1090,6 +1127,8 @@ local function help()
|
||||
print_("/vf pos -- move the display back to its default position")
|
||||
print_("/vf lock -- toggle frame dragging")
|
||||
print_("/vf fct / sct -- toggle scrolling combat text (either engine)")
|
||||
print_("/vf sct duration <s> -- SCT fade duration, 0.5-4.0 seconds")
|
||||
print_("/vf sct shadow on|off -- SCT text shadow")
|
||||
print_("/vf sctmove -- move our own SCT anchor (options window has the same button)")
|
||||
print_("/vf options -- open the options window")
|
||||
print_("/vf source add <pct> -- add a manual source, if tooltip detection fails")
|
||||
@@ -1163,12 +1202,39 @@ SlashCmdList["VAMPIFY"] = function(msg)
|
||||
elseif cmd == "fct" or cmd == "sct" then
|
||||
local cfg = VampifyConfig.get()
|
||||
if not cfg or not cfg.sct then print_("config not loaded yet.") return end
|
||||
cfg.sct.enabled = not cfg.sct.enabled
|
||||
if cfg.sct.enabled and cfg.sct.mode == "blizzard" and SHOW_COMBAT_TEXT ~= "1" then
|
||||
print_("scrolling combat text ON -- but Blizzard's own combat text is disabled in its"
|
||||
.." options, so nothing will show in blizzard mode.")
|
||||
-- Bare "/vf sct" / "/vf fct" keeps its original meaning (toggle enabled); "sct <sub> <val>"
|
||||
-- is the same sub/val dispatch pattern /vf source and /vf exclude already use above. %S*
|
||||
-- rather than %d* for val: duration needs a decimal ("1.5"), shadow needs a word (on/off).
|
||||
local _, _, sub, val = string.find(rest, "^(%a*)%s*(%S*)")
|
||||
if sub == "" then
|
||||
cfg.sct.enabled = not cfg.sct.enabled
|
||||
if cfg.sct.enabled and cfg.sct.mode == "blizzard" and SHOW_COMBAT_TEXT ~= "1" then
|
||||
print_("scrolling combat text ON -- but Blizzard's own combat text is disabled in its"
|
||||
.." options, so nothing will show in blizzard mode.")
|
||||
else
|
||||
print_(cfg.sct.enabled and "scrolling combat text ON." or "scrolling combat text OFF.")
|
||||
end
|
||||
elseif sub == "duration" then
|
||||
local s = tonumber(val)
|
||||
if not s or s < 0.5 or s > 4.0 then
|
||||
print_("/vf sct duration <seconds> -- 0.5 to 4.0, e.g. /vf sct duration 1.5")
|
||||
else
|
||||
cfg.sct.duration = s
|
||||
print_("SCT fade duration set to "..s.."s.")
|
||||
end
|
||||
elseif sub == "shadow" then
|
||||
if val == "on" then
|
||||
cfg.sct.shadow = true
|
||||
elseif val == "off" then
|
||||
cfg.sct.shadow = false
|
||||
else
|
||||
print_("/vf sct shadow on|off")
|
||||
return
|
||||
end
|
||||
if VampifySCT and VampifySCT.applyFont then VampifySCT.applyFont() end
|
||||
print_(cfg.sct.shadow and "SCT text shadow ON." or "SCT text shadow OFF.")
|
||||
else
|
||||
print_(cfg.sct.enabled and "scrolling combat text ON." or "scrolling combat text OFF.")
|
||||
help()
|
||||
end
|
||||
elseif cmd == "sctmove" then
|
||||
if not (VampifySCT and VampifySCT.setMoveMode and VampifySCT.isMoveMode) then
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
VampifyConst = {}
|
||||
local C = VampifyConst
|
||||
|
||||
C.VERSION = "0.3.0"
|
||||
C.VERSION = "0.4.0"
|
||||
|
||||
-- Empty a list buffer for reuse. THE table.setn IS THE POINT: in Lua 5.0 table.insert maintains an
|
||||
-- `n` field, and nil-ing the indices by hand does not reset it -- so the next round of inserts
|
||||
|
||||
+59
-5
@@ -140,13 +140,59 @@ function PH.pushLine(state, line)
|
||||
end
|
||||
end
|
||||
|
||||
-- The INDIVIDUAL source percentages of the hit, as "3/2/2/2".
|
||||
--
|
||||
-- WHY IT HAD TO BE ADDED (offline analysis, 2026-08-30). The line carried only the SUMMED
|
||||
-- percentage P and the source count n, and the server truncates every source SEPARATELY
|
||||
-- (core/model.lua's header): max(1, floor(pct_i * damage / 100)) per source. From P and n alone
|
||||
-- that is not reconstructable -- an offline reader has to approximate the list as n equal shares,
|
||||
-- which is exactly VampifyModel.heal's documented-inexact compatibility shape. Measured against
|
||||
-- the recorded pred_heal over 4907 windows that approximation is off by 4.7 %, and 4.7 % is the
|
||||
-- same order as the effects the analysis is trying to separate. With the real list the offline
|
||||
-- model becomes exact instead of approximate, and the AoE-ordering question core/model.lua's
|
||||
-- header leaves open (damp the damage, or damp each source's heal) becomes decidable from
|
||||
-- recorded data rather than needing a new in-game measurement.
|
||||
--
|
||||
-- "%g", not "%d" or "%.2f": a source percentage is normally a small integer (3, 2, 2) and should
|
||||
-- print as one, but nothing guarantees it -- a fractional percentage must survive rather than be
|
||||
-- silently truncated into a wrong number.
|
||||
--
|
||||
-- Buffer-recycled like every other reused list here (VampifyConst.resetList's comment): this runs
|
||||
-- once per own hit, and a fresh table per hit is avoidable churn on a channel that is meant to run
|
||||
-- permanently. The caller's list is READ ONLY -- and the result is a STRING, which matters: the
|
||||
-- live source list is itself a recycled buffer (core/commands.lua's sourcePercents, refilled in
|
||||
-- place on every gear change), so a row may not hold a reference to it. Serialising here, at
|
||||
-- record time, is what keeps a buffered row from silently re-reading a list that has since changed.
|
||||
local pctBuf = {}
|
||||
function PH.formatPercents(list)
|
||||
if not list then return "" end
|
||||
local n = table.getn(list)
|
||||
for i = table.getn(pctBuf), n + 1, -1 do pctBuf[i] = nil end
|
||||
table.setn(pctBuf, n)
|
||||
for i = 1, n do pctBuf[i] = string.format("%g", list[i] or 0) end
|
||||
return table.concat(pctBuf, "/", 1, n)
|
||||
end
|
||||
|
||||
-- pcts / zone / cast are the three fields the offline analysis asked for (docs/
|
||||
-- VAMPIRISM-MODELL-STAND §4). All three are optional: an older caller that does not supply them
|
||||
-- still produces a well-formed line ("", "?" and 0), so the export never depends on every wiring
|
||||
-- site having been updated in the same change.
|
||||
--
|
||||
-- cast=1 means a SPELL_GO_SELF for THIS spell id arrived shortly before this hit -- i.e. the player
|
||||
-- CAST it. cast=0 means no cast announced it, which for a damaging spell is the signature of a
|
||||
-- proc. That distinction is the whole point: hypothesis H2 says Vampirism is fed by the TRIGGER
|
||||
-- PATH ("was this triggered by an aura") rather than by "is this an item proc", and the cast event
|
||||
-- is the one observable in the client that separates the two paths for the same spell id. Without
|
||||
-- it the offline classification has to be inferred from timing against Lightning Strike hits and
|
||||
-- incoming damage, which is what the current analysis does and why it can only report AMBIG for
|
||||
-- 13.4 % of Tidal Wave hits.
|
||||
function PH.formatHitLine(row)
|
||||
return string.format(
|
||||
"HIT|t=%.3f|src=%s|dmg=%d|aoe=%d|P=%.4f|n=%d|pred_heal=%.4f|hp_before=%s|hp_after=%s|"
|
||||
.."hp_max=%s|acc_total=%.4f|crit=%d",
|
||||
.."hp_max=%s|acc_total=%.4f|crit=%d|pcts=%s|zone=%s|cast=%d",
|
||||
row.t, row.src, row.dmg, (row.aoe and 1 or 0), row.P, row.n, row.pred_heal,
|
||||
tostring(row.hp_before), tostring(row.hp_after), tostring(row.hp_max), row.acc_total,
|
||||
(row.crit and 1 or 0))
|
||||
(row.crit and 1 or 0), row.pcts or "", row.zone or "?", (row.cast and 1 or 0))
|
||||
end
|
||||
|
||||
function PH.formatIncomingLine(row)
|
||||
@@ -177,15 +223,23 @@ function PH.finalizePending(state, hpNow)
|
||||
return true
|
||||
end
|
||||
|
||||
-- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit -- everything except the health
|
||||
-- readings, which this function supplies itself: hp_before = hpNow and hp_max = hpMaxNow are both
|
||||
-- stamped HERE, at the same point, and never touched again; hp_after comes later, at finalize.
|
||||
-- fields: t, src, dmg, aoe, P, n, pred_heal, acc_total, crit, pcts, zone, cast -- everything
|
||||
-- except the health readings, which this function supplies itself: hp_before = hpNow and
|
||||
-- hp_max = hpMaxNow are both stamped HERE, at the same point, and never touched again; hp_after
|
||||
-- comes later, at finalize.
|
||||
--
|
||||
-- fields.pcts is the LIVE source-percent list and is serialised HERE, not held. The row can sit
|
||||
-- pending (and then buffered) for a long time, and that list is a recycled buffer refilled in
|
||||
-- place whenever gear changes -- keeping the reference would make an already-recorded hit report
|
||||
-- whatever the source set happens to be at flush time. Same reasoning as the health readings
|
||||
-- above: a value that describes THIS hit is captured at this hit.
|
||||
function PH.recordHit(state, fields, hpNow, tNow, hpMaxNow)
|
||||
PH.finalizePending(state, hpNow)
|
||||
state.pending = {
|
||||
t = fields.t, src = fields.src, dmg = fields.dmg, aoe = fields.aoe,
|
||||
P = fields.P, n = fields.n, pred_heal = fields.pred_heal,
|
||||
acc_total = fields.acc_total, crit = fields.crit,
|
||||
pcts = PH.formatPercents(fields.pcts), zone = fields.zone, cast = fields.cast,
|
||||
hp_before = hpNow, hp_max = hpMaxNow, pendingAt = tNow,
|
||||
}
|
||||
state.totalHits = state.totalHits + 1
|
||||
|
||||
Reference in New Issue
Block a user