-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathKitbagDebug.lua
More file actions
400 lines (354 loc) · 17.9 KB
/
Copy pathKitbagDebug.lua
File metadata and controls
400 lines (354 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
-- KitbagDebug — write the whole world into SavedVariables so it can be read outside the game.
--
-- The addon has no way to talk to anything outside the client: no sockets, no HTTP, no file IO. The
-- one sanctioned channel out is SavedVariables, which the client writes on /reload and on logout.
-- So diagnosis goes: click Dump, /reload, and the file on disk holds everything that was true at the
-- moment of the click.
--
-- This exists because the alternative is asking the player to read numbers out of their chat frame
-- and type them to someone else. Every round trip through a human loses detail and costs a session,
-- and the detail that gets lost is reliably the one that mattered — "the off hand is empty" and
-- "the SET says the off hand should be empty" are different facts that sound identical when relayed.
--
-- Report() is PURE: a plain reading of the world in, text out. The format is the load-bearing part,
-- so it is tested exhaustively in Tests/debug_test.lua rather than by taking a dump and squinting.
Kitbag = Kitbag or {}
local Core = Kitbag.Core
local Debug = {}
-- How many dumps to keep. More than one because the interesting comparison is usually "working" then
-- "broken"; few enough that SavedVariables does not grow without bound, since nothing ever prunes it
-- but this line.
local KEEP = 5
Debug.KEEP = KEEP
local function label(slotId)
local slot = Core and Core.SlotById(slotId)
return slot and slot.label or ("slot " .. tostring(slotId))
end
-- What a set stores for one slot, in words that keep the three states apart. They are the states the
-- whole planner turns on: an item to put on, a deliberate empty, and no opinion at all.
local function stored(value)
if value == false then return "(empty)" end
if value == nil then return "(not named)" end
return tostring(value)
end
-- A state value in words. A membership condition holds a SET rather than a value (thirty buffs, the
-- rule names one), and pairs() order would make two identical dumps look different — so it is sorted
-- and joined rather than printed as a table address nobody can read.
local function shown(value)
if type(value) ~= "table" then return tostring(value) end
local members = {}
for k, held in pairs(value) do
if held then members[#members + 1] = tostring(k) end
end
table.sort(members)
if #members == 0 then return "(none)" end
return table.concat(members, ", ")
end
-- Numbers compare as numbers, so form 10 sorts after form 2 rather than between 1 and 3. Mixed or
-- non-numeric keys fall back to their text, which is enough to make the order stable — and stable is
-- the whole requirement: two dumps must differ only where the world did.
local function sortedKeys(t)
local keys = {}
for k in pairs(t) do keys[#keys + 1] = k end
table.sort(keys, function(a, b)
if type(a) == "number" and type(b) == "number" then return a < b end
return tostring(a) < tostring(b)
end)
return keys
end
--- The dump, as lines. PURE — see Tests/debug_test.lua.
--
-- Everything is stated, including the absences: a slot with nothing in it prints "(nothing)" rather
-- than being left out, because "the dump did not mention the off hand" and "the off hand is empty"
-- are indistinguishable to the reader and only one of them is a fact.
function Debug.Report(world)
world = world or {}
local out = {}
local function add(fmt, ...)
out[#out + 1] = select("#", ...) > 0 and string.format(fmt, ...) or fmt
end
-- Which build produced this. First, because a dump from a stale deploy is worse than no dump —
-- it sends the reader hunting for a bug in source the client never loaded (see UI-15).
add("Kitbag dump — %s", tostring(world.when))
add("addon %s | %s | interface %s",
tostring(world.version), tostring(world.flavour), tostring(world.interface))
if world.character then add("character: %s", tostring(world.character)) end
-- The 1 -> 2 migration runs exactly once, on data nobody can regenerate. Whether it ran is a
-- fact about the file, so it is read off the file rather than inferred from the shape of a set.
add("db schema: %s", tostring(world.schema))
add("bank open: %s", tostring(world.bankOpen))
-- The world the planner was handed. Every slot in slot order, present or not.
add("")
add("WORN")
for _, s in ipairs(Core and Core.SLOTS or {}) do
local key = world.worn and world.worn[s.id]
add(" %-12s %2d: %s", s.label, s.id, key and tostring(key) or "(nothing)")
end
add("")
add("BAGS")
if not world.bags or #world.bags == 0 then
add(" (not read)")
else
for _, bag in ipairs(world.bags) do
-- family is why a bag with room still refuses a helmet, so it is dumped beside the count
-- rather than folded into a single "free slots" total.
add(" bag %s: %s free, family %s",
tostring(bag.id), tostring(bag.free), tostring(bag.family))
end
end
-- The forms the CLIENT reported, verbatim. GetShapeshiftFormInfo's signature differs between
-- flavours and reading it wrong does not error — it silently labels every form "form <n>". That
-- fallback string is the fingerprint of the bug, so it is passed through untouched.
add("")
add("FORMS")
local formIndices = sortedKeys(world.forms or {})
if #formIndices == 0 then
add(" (no forms)")
else
for _, i in ipairs(formIndices) do
add(" %s: %s", tostring(i), tostring(world.forms[i]))
end
end
-- What the client says about the player at the moment of the dump — dead, casting, mounted, in
-- combat. It used to be the rule engine's match snapshot; with the engine shelved (Icebox/) it
-- is read straight off Compat, because the conditions a swap can fail under are the same ones
-- whether or not anything is choosing sets automatically. Absent and false are kept apart here
-- for the same reason they are in a set's slots.
add("")
add("STATE")
if not world.state then
add(" (not read)")
else
for _, k in ipairs(sortedKeys(world.state)) do
add(" %s = %s", tostring(k), shown(world.state[k]))
end
end
-- What the driver actually DID, which no other section can say: a swap that started and did not
-- finish. The reason is the client's own wording, captured by Equip and otherwise printed once to
-- a chat frame nobody was watching (BUG-9). With the rule engine shelved (Icebox/) every entry
-- here was asked for by a person, so "why did this run at all" is no longer a question the dump
-- has to answer — only "why did it not finish".
add("")
add("RECENT SWAPS")
local swaps = world.swaps or {}
-- Newest first, and ALL of them, because SavedVariables only reaches disk on /reload: two
-- attempts before one reload used to mean the first was overwritten and gone, which cost three
-- round trips in one session to a record describing an attempt nobody had asked about.
if #swaps == 0 then add(" (nothing attempted since login)") end
for _, swap in ipairs(swaps) do
add(" %s — %s%s", tostring(swap.set), swap.ok and "succeeded" or "failed",
swap.when and (" at " .. tostring(swap.when)) or "")
-- A success has a reason too, and it is the one BUG-10 turned on: "succeeded" covers both a
-- set that was equipped and a set that had nothing to do, and those are the two halves of
-- "the rule fired and nothing moved". A failure with no reason is stated as such rather than
-- omitted — the client having said nothing is itself a different suspect list.
if swap.reason then
add(" reason: %s", tostring(swap.reason))
elseif not swap.ok then
add(" reason: (not recorded)")
end
-- The world at the moment it ended. The client is not obliged to say anything when it
-- refuses an action, so a bare "stuck on Off hand" would leave BUG-9 where it started; this
-- is the second, independent answer. Every condition is stated in both directions, because
-- "combat no" is the line that RULES OUT the leading suspect and a missing word cannot do
-- that job. Omitted entirely when the state was never captured, so a stale build cannot
-- masquerade as one that looked and found nothing.
local words = Core and Core.StateWords(swap.state)
if words then add(" state: %s", words) end
end
-- Sorted, so two dumps differ only where the world differed. pairs() order would make every
-- line look changed and hide the one that did.
local sets = {}
for _, set in ipairs(world.sets or {}) do sets[#sets + 1] = set end
table.sort(sets, function(a, b) return tostring(a.name) < tostring(b.name) end)
for _, set in ipairs(sets) do
add("")
add('set "%s"%s', tostring(set.name),
set.parent and (" — inherits " .. tostring(set.parent)) or "")
-- Only the slots the set names, in slot order. A set is a patch, not an outfit, so listing
-- the nineteen it says nothing about would bury the two it does.
local named = false
for _, s in ipairs(Core and Core.SLOTS or {}) do
local value = set.slots and set.slots[s.id]
if value ~= nil then
named = true
add(" %s = %s", s.label, stored(value))
end
end
if not named then add(" (names no slots — an empty set)") end
local plan = set.plan
if not plan then
add(" plan: (none — it could not be built)")
else
add(" plan: %d action(s), needs %s bag slot(s), blocked: %s",
#(plan.actions or {}), tostring(plan.needsBagSlots or 0), tostring(plan.blocked))
for i, action in ipairs(plan.actions or {}) do
add(" %d. %s -> %s%s", i, tostring(action.kind), label(action.to),
action.key and (" " .. tostring(action.key)) or "")
end
for _, m in ipairs(plan.missing or {}) do
add(" missing: %s %s (%s)", label(m.slot), tostring(m.key),
m.where or "not found anywhere")
end
end
end
return out
end
-- ---------------------------------------------------------------------------
-- Reading the client (the part that is not pure)
-- ---------------------------------------------------------------------------
-- Anything that reads the client is guarded, because the two calls most likely to be WRONG on a
-- given flavour — FormLabels and the state snapshot — are precisely the two this dump exists to
-- inspect. A probe that dies on them hides the bug behind its own failure. The error is returned
-- rather than swallowed so it appears in the dump as the answer.
local function attempt(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, value = pcall(fn, ...)
if ok then return value end
return { failed = tostring(value) }
end
--- Read the world and hand it to Report. Everything the planner sees, from ONE reading, so the dump
--- cannot show a set planned against bags that had already changed by the time the next set was read.
function Debug.Capture()
local Sets, Inventory, Compat = Kitbag.Sets, Kitbag.Inventory, Kitbag.Compat
-- The four conditions a swap can be refused under, read the same way KitbagSets reads them when
-- one fails. Through `attempt` because ActionState asks the client four questions and a dump is
-- taken when something is already wrong — a diagnostic that throws hides the bug behind its own.
local state = Compat and attempt(Compat.ActionState)
local world = {
when = date("%Y-%m-%d %H:%M:%S"),
version = GetAddOnMetadata and GetAddOnMetadata("Kitbag", "Version") or
(C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata("Kitbag", "Version")),
flavour = Compat and (Compat.IS_MAINLINE and "Retail" or "Classic"),
interface = select(4, GetBuildInfo()),
-- The same key the DB files this character's sets under, not a second spelling of it: a dump
-- that names the character differently from the bucket it read is a false lead.
character = Compat and Compat.CharacterKey(),
schema = Kitbag.db and Kitbag.db.schema,
bankOpen = Inventory and Inventory.IsBankOpen(),
worn = Inventory and Inventory.Equipped(),
bags = Inventory and Inventory.Bags(),
forms = Compat and attempt(Compat.FormLabels),
state = state,
sets = {},
}
world.swaps = Kitbag.char and Kitbag.char.swaps
for _, name in ipairs(Sets and Sets.Names() or {}) do
local plan, set = Sets.Preview(name)
world.sets[#world.sets + 1] = {
name = name,
parent = Sets.ParentOf(name),
-- The RESOLVED slots: what the set will actually put on, parent included. The stored
-- delta alone would show a child as almost empty and send the reader after a set that
-- looks broken and is not.
slots = set and set.slots,
plan = plan,
}
end
return Debug.Report(world)
end
--- Take a dump and keep it in SavedVariables. Returns how many are now stored.
--
-- Written into the DB rather than printed: the chat frame truncates, scrolls away, and cannot be
-- read by anyone who is not sitting at the machine. The file survives all three.
function Debug.Dump()
local db = Kitbag.db
if not db then return 0 end
db.dumps = db.dumps or {}
table.insert(db.dumps, 1, Debug.Capture())
while #db.dumps > KEEP do table.remove(db.dumps) end
return #db.dumps
end
--- Throw the dumps away. They are the only part of the DB that is pure noise once read.
function Debug.Clear()
local count = Kitbag.db and Kitbag.db.dumps and #Kitbag.db.dumps or 0
if Kitbag.db then Kitbag.db.dumps = nil end
return count
end
-- ---------------------------------------------------------------------------
-- The panel
-- ---------------------------------------------------------------------------
--
-- Buttons rather than a slash command, because a dump is asked for when something has just gone
-- wrong and the player is mid-fight with a bug — that is the worst moment to be typing an exact
-- incantation. The panel also has room to say what happens NEXT, which a chat line does not: a dump
-- that is never flushed to disk helps nobody, and /reload is the step that flushes it.
local frame
local function status()
local count = Kitbag.db and Kitbag.db.dumps and #Kitbag.db.dumps or 0
if count == 0 then return "No dumps stored." end
return string.format("%d dump(s) stored — |cffffd100/reload|r to write them to disk.", count)
end
local function build()
frame = CreateFrame("Frame", "KitbagDebugFrame", UIParent, "BasicFrameTemplateWithInset")
frame:SetSize(420, 210)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
frame:SetClampedToScreen(true)
-- Above the main window, which is where the bug being dumped usually is. See KitbagUI's note.
frame:SetFrameStrata("DIALOG")
frame:SetToplevel(true)
frame:Hide()
tinsert(UISpecialFrames, "KitbagDebugFrame")
frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
frame.title:SetPoint("TOP", frame, "TOP", 0, -5)
frame.title:SetText("Kitbag — debug")
frame.blurb = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
frame.blurb:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -34)
frame.blurb:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -16, -34)
frame.blurb:SetJustifyH("LEFT")
frame.blurb:SetText("Dump takes a full reading of your gear, bags, every set and the plan " ..
"each one would run. Nothing leaves your machine — it is written into Kitbag's saved " ..
"variables, which the game flushes to disk on |cffffd100/reload|r or logout.")
frame.where = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
frame.where:SetPoint("TOPLEFT", frame.blurb, "BOTTOMLEFT", 0, -10)
frame.where:SetPoint("TOPRIGHT", frame.blurb, "BOTTOMRIGHT", 0, -10)
frame.where:SetJustifyH("LEFT")
frame.where:SetText("WTF\\Account\\<ACCOUNT>\\SavedVariables\\Kitbag.lua")
frame.status = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
frame.status:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 16, 46)
local dump = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
dump:SetSize(150, 22)
dump:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 16, 14)
dump:SetText("Dump everything")
dump:SetScript("OnClick", function()
local count = Debug.Dump()
frame.status:SetText(status())
Kitbag.Sets.Say("dumped. |cffffd100/reload|r to write it to disk (%d stored).", count)
end)
-- Reload from the panel, because the dump is worthless until the file is written and the step is
-- easy to forget when the reason you opened this was that something else was going wrong.
local reload = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
reload:SetSize(110, 22)
reload:SetPoint("LEFT", dump, "RIGHT", 6, 0)
reload:SetText("Dump + reload")
reload:SetScript("OnClick", function()
Debug.Dump()
ReloadUI()
end)
local clear = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
clear:SetSize(110, 22)
clear:SetPoint("LEFT", reload, "RIGHT", 6, 0)
clear:SetText("Clear")
clear:SetScript("OnClick", function()
Debug.Clear()
frame.status:SetText(status())
end)
return frame
end
--- Open or close the panel.
function Debug.Toggle()
if not frame then build() end
if frame:IsShown() then
frame:Hide()
else
frame.status:SetText(status())
frame:Show()
end
end
Kitbag.Debug = Debug
return Debug