Repository navigation
Expand file tree
/
Copy pathEngraveHelper.lua
More file actions
4925 lines (4634 loc) · 193 KB
/
Copy pathEngraveHelper.lua
File metadata and controls
4925 lines (4634 loc) · 193 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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- EngraveHelper - one-click weapon engraving, totem-bar style.
-- Built for Project Ascension (WotLK 3.3.5 client, Interface 30300).
--
-- TWO SLOTS on screen: Main Hand and Off Hand. Each slot is configured with
-- a WEAPON (side arrow) and an ENGRAVE (top arrow). The slot icon shows the
-- weapon with the engrave in the corner.
-- Left-click a slot -> engraves that slot's weapon with its engrave in
-- that one click, then puts both weapons back in
-- their configured hands automatically.
-- Top arrow / right-click -> pick the engrave (picking also casts it).
-- Side arrow -> pick which weapon lives in that hand.
-- Shift+drag -> move. Right-click a top arrow -> options.
--
-- Because the desired end state is CONFIGURED (not guessed), any interrupted
-- run self-heals: clicking a slot always converges to "MH weapon in main
-- hand, OH weapon in off-hand, both engraved".
--
-- The addon LEARNS which enchant each engraving spell applies (from the item
-- link after each cast), so slot tooltips show what is currently on each
-- weapon.
--
-- /engrave or /eh opens the setup window. /eh log = event log.
local COLOR = "|cff00ccff"
local TAG = COLOR .. "Engraving Helper|r: "
-- db.spell / db.ohSpell = engrave per hand (nil = none)
-- db.putBack = persistent list of weapons the addon set aside,
-- { name, slot }, so they ALWAYS come back
local db
local panel, statusText, castButton, putBackBtn
local slotUI = {} -- panel slot blocks
local widget -- floating slots: .slots{mh,oh} .status
local spellFlyout, weaponFlyout
local logFrame, logScroll, logEdit
local logLines = {}
local refreshQueued = false
-- forward declarations
local RefreshSlots, UpdateCastButton, SetStatus, OpenSpellFlyout, OpenWeaponFlyout
local Log, SetSpell, ResetRun, DumpState, ToggleLog, InitOptionsMenu, CreateLogFrame
local Pump, QueuePump, TogglePanel, SetSlotArmed, ClearArmed
---------------------------------------------------------------------------
-- Simple timer queue (3.3.5 has no C_Timer)
---------------------------------------------------------------------------
local timerFrame = CreateFrame("Frame")
local timers = {}
timerFrame:Hide()
timerFrame:SetScript("OnUpdate", function(self, elapsed)
for i = #timers, 1, -1 do
local t = timers[i]
t.left = t.left - elapsed
if t.left <= 0 then
table.remove(timers, i)
t.fn()
end
end
if #timers == 0 then self:Hide() end
end)
local function After(delay, fn)
timers[#timers + 1] = { left = delay, fn = fn }
timerFrame:Show()
end
---------------------------------------------------------------------------
-- Event log
---------------------------------------------------------------------------
local MAX_LOG = 400
local function RefreshLogEdit()
if not logEdit then return end
logEdit:SetText(table.concat(logLines, "\n"))
logEdit:SetCursorPosition(logEdit:GetNumLetters())
end
Log = function(fmt, ...)
local msg = (select("#", ...) > 0) and fmt:format(...) or fmt
logLines[#logLines + 1] = date("%H:%M:%S") .. " " .. msg
if #logLines > MAX_LOG then table.remove(logLines, 1) end
if logFrame and logFrame:IsShown() then RefreshLogEdit() end
end
---------------------------------------------------------------------------
-- Inventory helpers - ALWAYS live reads, never cached
---------------------------------------------------------------------------
local WEAPON_EQUIP_LOCS = {
INVTYPE_WEAPON = true, INVTYPE_2HWEAPON = true,
INVTYPE_WEAPONMAINHAND = true, INVTYPE_WEAPONOFFHAND = true,
INVTYPE_RANGED = true, INVTYPE_RANGEDRIGHT = true, INVTYPE_THROWN = true,
}
local function EquippedName(slot)
local link = GetInventoryItemLink("player", slot)
if not link then return nil end
return (GetItemInfo(link))
end
local function IsWeaponLink(link)
if not link then return false end
local name, _, _, _, _, _, _, _, equipLoc = GetItemInfo(link)
if name and WEAPON_EQUIP_LOCS[equipLoc] then return true, name end
return false, name
end
local function FindInBagsByName(name)
if not name then return nil end
for bag = 0, 4 do
for slot = 1, (GetContainerNumSlots(bag) or 0) do
local link = GetContainerItemLink(bag, slot)
if link and (GetItemInfo(link)) == name then
return bag, slot
end
end
end
return nil
end
local function FindFreeBagSlot()
for bag = 0, 4 do
local free, family = GetContainerNumFreeSlots(bag)
if free and free > 0 and (not family or family == 0) then
for slot = 1, (GetContainerNumSlots(bag) or 0) do
if not GetContainerItemLink(bag, slot) then
return bag, slot
end
end
end
end
return nil
end
local function BagItemLocked(bag, slot)
local _, _, locked = GetContainerItemInfo(bag, slot)
return locked
end
local function WeaponExists(name)
if not name then return false end
if EquippedName(16) == name or EquippedName(17) == name then return true end
return FindInBagsByName(name) ~= nil
end
local function FindWeaponLink(name)
if not name then return nil end
if EquippedName(16) == name then return GetInventoryItemLink("player", 16) end
if EquippedName(17) == name then return GetInventoryItemLink("player", 17) end
local bag, slot = FindInBagsByName(name)
if bag then return GetContainerItemLink(bag, slot) end
return nil
end
local function ScanEngraveSpells()
local found, seen = {}, {}
local i = 1
while true do
local name = GetSpellName(i, "spell")
if not name then break end
if not seen[name] and name:lower():find("engrav") then
seen[name] = true
found[#found + 1] = name
end
i = i + 1
end
table.sort(found)
return found
end
-- spellbook index for a spell name (last match = highest rank), so the picker
-- can show the real spell tooltip
local function FindSpellBookIndex(name)
local found
local i = 1
while true do
local n = GetSpellName(i, "spell")
if not n then break end
if n == name then found = i end
i = i + 1
end
return found
end
-- Weapon Engraving is a Runemaster mechanic: the addon stays DORMANT on
-- characters that are not Runemasters and know no engraving spells
local gDormant = false
local function IsEngraver()
local locClass, tokClass = UnitClass("player")
if (locClass and locClass:lower():find("runemaster", 1, true))
or (tokClass and tostring(tokClass):lower():find("runemaster", 1, true)) then
return true
end
return #ScanEngraveSpells() > 0
end
-- cached scan for the frequently-refreshed displays (tooltip reads etc.)
local engraveSpellCache, engraveSpellCacheAt
local function ScanEngraveSpellsCached()
if not engraveSpellCacheAt or GetTime() - engraveSpellCacheAt > 5 then
engraveSpellCache = ScanEngraveSpells()
engraveSpellCacheAt = GetTime()
end
return engraveSpellCache
end
-- unique weapon names across hands and bags (for the weapon picker)
local function ScanWeaponNames()
local list, seen = {}, {}
local function add(link)
local isW, name = IsWeaponLink(link)
if isW and not seen[name] then
seen[name] = true
list[#list + 1] = name
end
end
add(GetInventoryItemLink("player", 16))
add(GetInventoryItemLink("player", 17))
for bag = 0, 4 do
for slot = 1, (GetContainerNumSlots(bag) or 0) do
add(GetContainerItemLink(bag, slot))
end
end
return list
end
-- Short display name: "Weapon Engraving: Fire" -> "Fire"
local function ShortSpell(name)
if not name then return "none" end
local short = name:match(":%s*(.+)$")
return short or name
end
local function SlotSpell(which)
if which == "oh" then return db.ohSpell end
return db.spell
end
-- the slot always mirrors whatever weapon is CURRENTLY equipped in that hand
local function SlotWeapon(which)
local isW, name = IsWeaponLink(GetInventoryItemLink("player", (which == "oh") and 17 or 16))
return isW and name or nil
end
local function SlotSpellText(which)
local s = SlotSpell(which)
return s and ShortSpell(s) or "(none)"
end
-- Engraves are TEMPORARY weapon enchants (imbue-style): they never appear in
-- the item link, only as a line on the weapon's tooltip. So we read the
-- tooltip with a hidden scanner to see what is currently on a weapon.
local scanTip = CreateFrame("GameTooltip", "EngraveHelperScanTip", nil, "GameTooltipTemplate")
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
local function TipEngraveLine()
for i = 2, scanTip:NumLines() do
local fs = _G["EngraveHelperScanTipTextLeft" .. i]
local text = fs and fs:GetText()
if text then
if text:lower():find("engrav") then
-- strip a trailing "(28 min)" style duration
return (text:gsub("%s*%(.-%)%s*$", ""))
end
for _, spell in ipairs(ScanEngraveSpellsCached()) do
local short = ShortSpell(spell)
if short and short ~= "" and text:find(short, 1, true) and text:find("%(") then
return short
end
end
end
end
return nil
end
-- what engraving is CURRENTLY on this weapon (tooltip scan)
local function CurrentEngraveText(weaponName)
if not weaponName then return "none" end
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
scanTip:ClearLines()
if EquippedName(16) == weaponName then
scanTip:SetInventoryItem("player", 16)
elseif EquippedName(17) == weaponName then
scanTip:SetInventoryItem("player", 17)
else
local bag, slot = FindInBagsByName(weaponName)
if not bag then return "weapon not found" end
scanTip:SetBagItem(bag, slot)
end
return TipEngraveLine() or "none"
end
-- compact display of a weapon's current engrave: "Fire", or nil if none
local function CurrentEngraveShort(weaponName)
local cur = CurrentEngraveText(weaponName)
if not cur or cur == "none" or cur == "weapon not found" then return nil end
local disp = cur:gsub("[Ee]ngraving", ""):gsub("^[%s:%-]+", ""):gsub("[%s:%-]+$", "")
if disp == "" then disp = cur end
return disp
end
-- the engraving SPELL currently on this weapon (matched from the tooltip
-- line against the known engrave spells), or nil if the weapon is bare
local function CurrentEngraveSpell(weaponName)
local short = CurrentEngraveShort(weaponName)
if not short then return nil end
for _, spell in ipairs(ScanEngraveSpellsCached()) do
local s = ShortSpell(spell)
if s and s ~= "" and (s == short or short:find(s, 1, true)) then
return spell
end
end
return nil
end
local EMPTY_SLOT_TEX = "Interface\\Buttons\\UI-EmptySlot-Disabled"
-- default border colors per engrave (user-overridable via db.engraveColors)
local DEFAULT_ENGRAVE_COLORS = {
Fire = { 1, 0.25, 0.15 },
Ice = { 0.35, 0.65, 1 },
Earth = { 0.35, 0.9, 0.35 },
Air = { 0.92, 0.95, 1 },
Water = { 0.25, 0.55, 0.95 },
Arcane = { 0.75, 0.35, 0.95 },
}
local function GetEngraveColor(short)
if db.engraveColors and db.engraveColors[short] then
return db.engraveColors[short]
end
return DEFAULT_ENGRAVE_COLORS[short] or { 0.8, 0.8, 0.8 }
end
local function WeaponTexture(name)
if not name then return nil end
local tex = select(10, GetItemInfo(name))
return tex
end
---------------------------------------------------------------------------
-- Engrave run engine (config + reality driven)
-- The END STATE is always the CONFIG: MH weapon in slot 16, OH weapon in
-- slot 17. Every step is decided from live inventory reads.
---------------------------------------------------------------------------
local run = {
active = false, -- engrave phase running
restoring = false, -- putting weapons back
targets = {}, -- { { name, spell, engraved } }
gen = 0,
fails = 0,
pumpQueued = false,
mustSwap = false,
lastMoveAt = 0,
awaiting = nil, -- target whose cast is in flight (guards double casts)
awaitAt = 0,
}
local function FirstPending()
for _, t in ipairs(run.targets) do
if not t.engraved then return t end
end
return nil
end
local function CountEngraved()
local n = 0
for _, t in ipairs(run.targets) do
if t.engraved then n = n + 1 end
end
return n
end
-- quiet = true keeps the message off chat (minor progress chatter)
SetStatus = function(text, quiet)
if statusText then statusText:SetText(text or "") end
if not quiet and text and text ~= "" and (not panel or not panel:IsShown()) then
print(TAG .. text)
end
end
local function SetWidgetStatus(text)
if widget then widget.status:SetText(text or "") end
end
UpdateCastButton = function()
local busy = (run.active or run.restoring) and #run.targets > 0
if castButton then
if busy then
castButton:SetText(("Engrave (%d/%d)"):format(CountEngraved(), #run.targets))
else
castButton:SetText("Engrave Both")
end
end
-- panel Engrave buttons follow the same two-click flow as the slots;
-- while weapons are moving the buttons lock so they can't be mashed.
-- pickS is a SECURE button: Enable/Disable on it is protected in combat,
-- so state toggles wait for combat end (PLAYER_REGEN_ENABLED re-syncs)
do
local pending = (run.active and not run.restoring) and FirstPending() or nil
local busyRun = run.active or run.restoring
local canToggle = not InCombatLockdown()
for which, ui in pairs(slotUI) do
if ui.pickS then
if pending and pending.slot == ((which == "oh") and 17 or 16) then
ui.pickS:SetText("|cffff3333Engrave now!|r")
if canToggle then ui.pickS:Enable() end
elseif busyRun then
ui.pickS:SetText(run.restoring and "|cffffd700re-equipping...|r" or "|cffffd700working...|r")
if canToggle then ui.pickS:Disable() end
else
ui.pickS:SetText("Engrave: " .. SlotSpellText(which))
if canToggle then ui.pickS:Enable() end
end
end
if ui.pickW then
if busyRun then
ui.pickW:Disable()
else
ui.pickW:Enable()
end
end
end
end
if widget then
-- the slot icon shows the engraving CURRENTLY on that hand's weapon
-- (empty slot = no engrave -> pick one); the weapon is the corner
-- icon. While a pick awaits its confirming click, the slot shows the
-- PICKED engrave desaturated and pulsing.
local pending = run.active and FirstPending() or nil
-- weapon icon art carries a much thicker baked-in frame than spell
-- icons, so each type gets its own user-tunable crop
local iconCrop = (db.ui.iconCrop or 2) / 100
local weapCrop = (db.ui.weaponCrop or 8) / 100
local function SetCrop(tex, cr)
tex:SetTexCoord(cr, 1 - cr, cr, 1 - cr)
end
for which, slot in pairs(widget.slots) do
local slotId = (which == "oh") and 17 or 16
if pending and pending.slot == slotId then
local stex = GetSpellTexture(pending.spell)
slot.icon:SetTexture(stex or slot.emptyTex)
SetCrop(slot.icon, iconCrop)
slot.icon:SetDesaturated(true)
if slot.pulse and not slot.pulse:IsPlaying() then
slot.pulse:Play()
end
elseif not (run.active or run.restoring) then
local weapon = SlotWeapon(which)
local curSpell = CurrentEngraveSpell(weapon)
local engraveTex = curSpell and GetSpellTexture(curSpell)
local weapTex = WeaponTexture(weapon)
-- big icon = engrave or weapon (user choice); the other one
-- is the corner mini icon
local bigTex, cornerTex, bigCrop, cornerCrop
if (db.ui.iconMode or "engrave") == "weapon" then
bigTex, cornerTex = weapTex, engraveTex
bigCrop, cornerCrop = weapCrop, iconCrop
else
bigTex, cornerTex = engraveTex, weapTex
bigCrop, cornerCrop = iconCrop, weapCrop
end
slot.icon:SetTexture(bigTex or slot.emptyTex)
SetCrop(slot.icon, bigTex and bigCrop or iconCrop)
slot.icon:SetDesaturated(false)
if slot.pulse then
slot.pulse:Stop()
end
if cornerTex and db.ui.showWeapon ~= false then
slot.cornerIcon:SetTexture(cornerTex)
SetCrop(slot.cornerIcon, cornerCrop)
slot.cornerIcon:Show()
slot.cornerBorder:Show()
else
slot.cornerIcon:Hide()
slot.cornerBorder:Hide()
end
-- engrave-colored border
if db.ui.borderOn ~= false and curSpell then
local col = GetEngraveColor(ShortSpell(curSpell))
slot.engraveBorder:SetVertexColor(col[1], col[2], col[3], 1)
slot.engraveBorder:Show()
else
slot.engraveBorder:Hide()
end
end
end
if busy then
if run.restoring then
SetWidgetStatus("|cffffd700re-equipping...|r")
else
SetWidgetStatus("|cffffd700engraving...|r")
end
else
SetWidgetStatus("")
end
end
end
ResetRun = function(silent)
run.active = false
run.restoring = false
run.gen = run.gen + 1
run.pumpQueued = false
run.fails = 0
run.mustSwap = false
run.awaiting = nil
wipe(run.targets)
if putBackBtn then putBackBtn:Disable() end
if ClearArmed then ClearArmed() end
UpdateCastButton()
if not silent then
Log("Run reset by user.")
SetStatus("Run cancelled.")
end
end
-- which = "mh"/"oh"; value = spell name or "NONE"
SetSpell = function(which, value)
local spell = (value ~= "NONE") and value or nil
if which == "oh" then
db.ohSpell = spell
else
db.spell = spell
end
Log("%s engrave set to '%s'.", which == "oh" and "Off-hand" or "Main-hand", tostring(spell))
RefreshSlots()
UpdateCastButton()
end
DumpState = function(reason)
Log("---- Current state (%s) ----", reason or "now")
Log(" Main hand now: %s", tostring(EquippedName(16)))
Log(" Off hand now: %s", tostring(EquippedName(17)))
if db.putBack then
for _, pb in ipairs(db.putBack) do
Log(" Set aside (to put back): '%s' -> slot %d", tostring(pb.name), pb.slot or -1)
end
end
for i, t in ipairs(run.targets) do
Log(" Target %d: %s with '%s' - %s", i, t.name, tostring(t.spell),
t.engraved and "engraved" or "NOT engraved")
end
Log("--------------------------------")
end
local function PrepareRun()
run.gen = run.gen + 1
run.fails = 0
run.pumpQueued = false
run.mustSwap = false
run.awaiting = nil
run.lastMoveAt = 0
run.cdLogged = nil
run.lastWaitWhy = nil
run.combatLogged = nil
wipe(run.targets)
end
-- Remember which weapon the addon set aside so it ALWAYS comes back - kept
-- in saved variables, so it survives interrupted runs and even reloads.
local function PushPutBack(name, slot)
if not name then return end
db.putBack = db.putBack or {}
for i = #db.putBack, 1, -1 do
if db.putBack[i].name == name then
table.remove(db.putBack, i)
end
end
db.putBack[#db.putBack + 1] = { name = name, slot = slot }
end
-- The engrave target for a hand: the weapon equipped there right now - or,
-- if an interrupted run left it in a bag, the weapon we set aside for it.
local function TargetForHand(which)
local slotId = (which == "oh") and 17 or 16
local isW, name = IsWeaponLink(GetInventoryItemLink("player", slotId))
if isW then return name end
if db.putBack then
for _, pb in ipairs(db.putBack) do
if pb.slot == slotId and WeaponExists(pb.name) then
return pb.name
end
end
end
return nil
end
-- One hand (used by the slot buttons and the flyout picks)
local function BuildRunFor(which, spell)
PrepareRun()
local name = TargetForHand(which)
if spell and name then
run.targets[1] = { name = name, spell = spell, slot = (which == "oh") and 17 or 16, engraved = false }
end
Log("=== New run (%s): '%s' with '%s' ===",
which == "oh" and "off hand" or "main hand", tostring(name), tostring(spell))
if name then
Log(" Current engrave on '%s': %s.", name, CurrentEngraveText(name))
end
end
-- Both hands (used by the setup window's "Engrave Both" button)
local function BuildRunBoth()
PrepareRun()
local mhName = TargetForHand("mh")
local ohName = TargetForHand("oh")
if db.spell and mhName then
run.targets[#run.targets + 1] = { name = mhName, spell = db.spell, slot = 16, engraved = false }
end
if db.ohSpell and ohName then
run.targets[#run.targets + 1] = { name = ohName, spell = db.ohSpell, slot = 17, engraved = false }
end
Log("=== New run (both hands): %d weapon%s ===", #run.targets, #run.targets == 1 and "" or "s")
for i, t in ipairs(run.targets) do
Log(" %d. %s with '%s' (currently: %s)", i, t.name, tostring(t.spell), CurrentEngraveText(t.name))
end
end
-- Decide the next step purely from the CURRENT inventory.
local function ComputeAction()
if run.active then
local pending = FirstPending()
if pending then
-- the engrave lands on the ONLY equipped weapon (in either hand),
-- as long as the cast comes AFTER the other hand's unequip has
-- settled - so the target stays in ITS OWN slot
local tSlot = pending.slot or 16
local oSlot = (tSlot == 16) and 17 or 16
if IsWeaponLink(GetInventoryItemLink("player", oSlot)) then
return { type = "stash", slot = oSlot }
end
if EquippedName(tSlot) == pending.name then
-- duplicate-name support: after engraving one copy, a fresh
-- copy must be swapped in before casting again
if run.mustSwap and FindInBagsByName(pending.name) then
return { type = "equip", name = pending.name, slot = tSlot }
end
return { type = "cast", target = pending }
end
-- bag recovery: bring the target back into its own hand
return { type = "equip", name = pending.name, slot = tSlot }
end
run.active = false
run.restoring = true
Log("Engraving done - putting your weapons back (per slot config).")
end
if run.restoring then
-- put back every weapon the addon set aside (persistent list)
local list = db.putBack
if list then
for i = #list, 1, -1 do
local pb = list[i]
if not pb.name or not WeaponExists(pb.name) then
if pb.name then
Log("Cannot find '%s' anymore - dropping it from the put-back list.", pb.name)
end
table.remove(list, i)
elseif EquippedName(pb.slot) == pb.name then
-- stale-read guard: shortly after a move an item can show
-- up BOTH equipped and in a bag; only trust the read once
-- it is coherent
if GetTime() - run.lastMoveAt >= 3 or not FindInBagsByName(pb.name) then
table.remove(list, i)
else
return { type = "verify" }
end
else
return { type = "equip", name = pb.name, slot = pb.slot }
end
end
end
return { type = "done" }
end
return { type = "idle" }
end
-- Perform one move. Returns "moved", "wait" (item locked etc. - retry soon)
-- or "fail" plus a human-readable reason.
local function DoMove(act)
if act.type == "stash" then
local fromSlot = act.slot or 17
local name = EquippedName(fromSlot)
if IsInventoryItemLocked(fromSlot) then
return "wait", "the weapon is busy (item locked) - waiting"
end
local bag, slot = FindFreeBagSlot()
if not bag then
return "fail", "no free bag slot - free up bag space and click again"
end
ClearCursor()
PickupInventoryItem(fromSlot)
if not CursorHasItem() then
return "wait", "could not pick the weapon up yet - waiting"
end
PickupContainerItem(bag, slot)
if CursorHasItem() then
PickupInventoryItem(fromSlot)
ClearCursor()
return "fail", "the weapon would not go into the bag"
end
PushPutBack(name, fromSlot)
Log("Set '%s' (slot %d) aside to bag %d slot %d.", tostring(name), fromSlot, bag, slot)
return "moved"
elseif act.type == "equip" then
local slotName = (act.slot == 16) and "main hand" or "off hand"
local bag, slot = FindInBagsByName(act.name)
if bag then
if BagItemLocked(bag, slot) then
return "wait", ("'%s' is busy (enchant still applying) - waiting"):format(act.name)
end
if GetInventoryItemLink("player", act.slot) and IsInventoryItemLocked(act.slot) then
return "wait", "the equipped weapon is busy (item locked) - waiting"
end
ClearCursor()
PickupContainerItem(bag, slot)
if not CursorHasItem() then
return "wait", ("could not pick '%s' up yet - waiting"):format(act.name)
end
PickupInventoryItem(act.slot)
if CursorHasItem() then
PickupContainerItem(bag, slot)
ClearCursor()
return "fail", ("'%s' would not equip into the %s"):format(act.name, slotName)
end
if act.slot == 16 then run.mustSwap = false end
Log("Equipped '%s' into the %s (was at bag %d slot %d).", act.name, slotName, bag, slot)
return "moved"
end
local other = (act.slot == 16) and 17 or 16
if EquippedName(other) == act.name then
if IsInventoryItemLocked(other) or (GetInventoryItemLink("player", act.slot) and IsInventoryItemLocked(act.slot)) then
return "wait", "weapons are busy (item locked) - waiting"
end
ClearCursor()
PickupInventoryItem(other)
if not CursorHasItem() then
return "wait", "could not pick the weapon up yet - waiting"
end
PickupInventoryItem(act.slot)
if CursorHasItem() then
PickupInventoryItem(other)
ClearCursor()
end
if act.slot == 16 then run.mustSwap = false end
if run.active and act.slot == 16 and other == 17 then
-- pulled the target out of the off-hand for its cast: it
-- belongs back there afterwards
PushPutBack(act.name, 17)
end
Log("Swapped hands to bring '%s' to the %s.", act.name, slotName)
return "moved"
end
return "fail", ("cannot find '%s' in your bags or hands"):format(act.name)
end
return "fail", "unknown step"
end
QueuePump = function(delay)
if run.pumpQueued then return end
run.pumpQueued = true
local gen = run.gen
After(delay or 0.7, function()
run.pumpQueued = false
if gen == run.gen then Pump() end
end)
end
-- The pump advances the run automatically: it performs the next MOVE whenever
-- one is possible (casts always need your click). It re-reads reality every
-- tick, waits out item locks, and stops loudly on real failures.
Pump = function()
if not (run.active or run.restoring) then return end
if InCombatLockdown() then
if not run.combatLogged then
run.combatLogged = true
Log("In combat - the engine waits until combat ends.")
end
QueuePump(1.5)
return
end
run.combatLogged = nil
-- let the server catch up after a move/cast before trusting any reads
if GetTime() - run.lastMoveAt < 0.55 then
QueuePump(0.3)
return
end
local act = ComputeAction()
if act.type == "verify" then
QueuePump(0.35)
return
end
if act.type == "done" then
run.restoring = false
ClearArmed()
Log("Restore complete - main hand '%s', off hand '%s'.",
tostring(EquippedName(16)), tostring(EquippedName(17)))
SetStatus("Done! Weapons engraved and re-equipped.")
if putBackBtn then putBackBtn:Disable() end
UpdateCastButton()
SetWidgetStatus("|cff00ff00Done!|r")
local gen = run.gen
After(3, function()
if gen == run.gen and not (run.active or run.restoring) then
SetWidgetStatus("")
end
end)
return
elseif act.type == "cast" then
UpdateCastButton()
local start = GetSpellCooldown(act.target.spell)
if start and start ~= 0 then
if not run.cdLogged then
run.cdLogged = true
Log("Engrave spell not ready yet (GetSpellCooldown start=%s) - waiting.", tostring(start))
end
SetStatus("Waiting for the engrave spell to be ready...", true)
QueuePump(0.4)
else
local whichSlot = (act.target.slot == 17) and "oh" or "mh"
local slotW = widget and widget.slots[whichSlot]
if slotW and slotW.castBtn and not slotW.castBtn:IsShown() then
Log("Red Engrave button shown for '%s' - waiting for the click.", act.target.name)
end
SetSlotArmed(whichSlot, act.target)
SetStatus(("Ready - click the red Engrave button to engrave %s."):format(act.target.name), true)
SetWidgetStatus("|cffff4040click Engrave!|r")
end
return
elseif act.type == "idle" then
return
end
local result, why = DoMove(act)
if result == "moved" then
run.fails = 0
run.lastMoveAt = GetTime()
QueuePump(0.6)
elseif result == "wait" then
if why and why ~= run.lastWaitWhy then
run.lastWaitWhy = why
Log("Waiting: %s.", why)
end
if why then
SetStatus(why .. "...", true)
if why:find("bag slot") then
SetWidgetStatus("|cffff5555bags full!|r")
end
end
QueuePump(0.35)
else
run.fails = run.fails + 1
if why and why:find("bag slot") then
run.fails = 5 -- bags full: stop immediately, the user must free space
SetWidgetStatus("|cffff5555bags full!|r")
end
Log("STEP FAILED (%d/5): %s.", run.fails, why or "?")
if run.fails >= 5 then
run.active = false
run.restoring = false
DumpState("stopped after repeated failures")
SetStatus("Stopped: " .. (why or "problem")
.. ". See the Event Log (/eh log), fix your bags, then use 'Put Weapons Back' in /eh.")
if putBackBtn then putBackBtn:Enable() end
else
QueuePump(1.2)
end
end
UpdateCastButton()
end
---------------------------------------------------------------------------
-- Cast-click helper for the setup window's "Engrave Both" button.
---------------------------------------------------------------------------
local function ArmCast(self)
-- a cast may NEVER share a click with an item move (the engrave then
-- races the move and can land on the wrong weapon - field-verified), and
-- must come at least half a second after the last move
local act = ComputeAction()
if act.type == "cast" and GetTime() - run.lastMoveAt >= 0.5 then
if run.awaiting == act.target and GetTime() - run.awaitAt < 2 then
self:SetAttribute("spell", nil)
return false
end
self:SetAttribute("spell", act.target.spell)
run.awaiting = act.target
run.awaitAt = GetTime()
Log("Casting '%s' on '%s' (%d/%d).",
tostring(act.target.spell), tostring(act.target.name), CountEngraved() + 1, #run.targets)
SetStatus(("Engraving %s (%d/%d)..."):format(
act.target.name, CountEngraved() + 1, #run.targets), true)
return true
end
-- not ready: advance one prep step instead, next click casts
if act.type == "stash" or act.type == "equip" then
DoMove(act)
end
self:SetAttribute("spell", nil)
SetWidgetStatus("|cffffd700swapping...|r")
QueuePump(0.5)
return false
end
---------------------------------------------------------------------------
-- Direct, cursor-verified moves used INSIDE a click. The cursor state is
-- client-immediate, but equipment link reads and item locks lag a server
-- round-trip - each item is touched AT MOST ONCE per click, and a cast is
-- NEVER sent in the same event as a move (it races the move server-side and
-- can land on the wrong weapon - field-verified).
---------------------------------------------------------------------------
local function DirectStashSlot(slotId)
local name = EquippedName(slotId)
local bag, slot = FindFreeBagSlot()
if not bag then
Log("CANNOT set '%s' aside: NO FREE BAG SLOT - free up bag space!", tostring(name))
SetStatus("No free bag slot - free one up so a weapon can be set aside.")
SetWidgetStatus("|cffff5555bags full!|r")
return false
end
ClearCursor()
PickupInventoryItem(slotId)
if not CursorHasItem() then
Log("Could not pick up '%s' (slot %d) this click - item busy/locked; the engine will retry.", tostring(name), slotId)
return false
end
PickupContainerItem(bag, slot)
if CursorHasItem() then
PickupInventoryItem(slotId)
ClearCursor()
return false
end
run.lastMoveAt = GetTime()
PushPutBack(name, slotId)
Log("Set '%s' (slot %d) aside to bag %d slot %d.", tostring(name), slotId, bag, slot)
return true
end
local function DirectEquipTo(bag, slot, equipSlot)
local prevName = EquippedName(equipSlot)
ClearCursor()
PickupContainerItem(bag, slot)
if not CursorHasItem() then
return false
end
PickupInventoryItem(equipSlot)
if CursorHasItem() then
-- either the slot refused the item, or it swapped and the previous
-- occupant is on the cursor: put whatever it is into the bag slot
PickupContainerItem(bag, slot)
ClearCursor()
if prevName then PushPutBack(prevName, equipSlot) end
run.lastMoveAt = GetTime()
return false
end
run.lastMoveAt = GetTime()
Log("Equipped weapon from bag %d slot %d into equip slot %d.", bag, slot, equipSlot)
return true
end
-- Show/hide the red per-slot Engrave button (the explicit SECOND click).
SetSlotArmed = function(which, pending)
if not widget then return end
local slot = widget.slots[which]
if not (slot and slot.castBtn) then return end
-- OH slot hidden (2H build): the panel button is the confirm click
if which == "oh" and db.ui.hideOH == true then
slot.castBtn:Hide()
return
end
if pending and not InCombatLockdown() then
slot.castBtn:SetAttribute("spell", pending.spell)
slot.castBtn:Show()
else
slot.castBtn:Hide()
end
end
ClearArmed = function()
SetSlotArmed("mh", nil)
SetSlotArmed("oh", nil)
end
-- If a prep went unused, quietly put the weapons back after a moment.
local function RestoreIfIdle(gen)
if gen == run.gen and run.active and CountEngraved() == 0 and not run.awaiting then
Log("Prep unused - putting weapons back.")
ClearArmed()
run.active = false
run.restoring = true
Pump()
end
end
-- CLICK 1: pick/click a slot = prepare (set the other weapon aside). When
-- the swap has settled, the red Engrave button appears under the slot -
-- CLICK 2 on it casts. Clear, explicit, no holding.
local function StartPrep(which)
if InCombatLockdown() or run.restoring or run.active then return end
local spell = SlotSpell(which)
if not spell then return end
BuildRunFor(which, spell)
if #run.targets == 0 then
ResetRun(true)