-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGenericTrigger.lua
More file actions
5029 lines (4798 loc) · 228 KB
/
Copy pathGenericTrigger.lua
File metadata and controls
5029 lines (4798 loc) · 228 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
-- WeakestAuras -- GenericTrigger-lite: the runtime trigger system for every
-- non-aura trigger kind. Ported from WeakAuras2's GenericTrigger (ref
-- WA2's GenericTrigger (§4), scaled to this client.
--
-- The payoff of the whole engine: a new trigger *category* is a data table (an
-- "event prototype" in PROTOTYPES) declaring its game/internal events, an `init`
-- preamble, an `args` list, and optional display hooks -- no new system code.
-- One prototype's args drive its test function, its state fields, its condition
-- variables (§10) and its options editor at once.
--
-- Split faithful to upstream (and to Conditions.lua's own reasoning): the
-- *matching* logic (init + per-arg tests + stores) is compiled to one Lua
-- function per trigger via ConstructFunction/loadstring -- prototype fragments
-- are Lua source by design, so an interpreter would loadstring the pieces
-- anyway. The *display* side (duration/name/icon) stays plain Lua closures the
-- system calls after a successful test, exactly as upstream's durationFunc/
-- nameFunc/iconFunc do. loadstring is confirmed on this client (StateMachine's
-- customTriggerLogic, Conditions' deferred custom). /wa gen <id> dumps the
-- generated source, making source-assembly errors readable.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
local GenericTrigger = {}
-- events[id][triggernum] = triggerInfo (this display's compiled generic
-- triggers). Present after Add (compile); a ti only enters loaded_events (starts
-- receiving events) once the display is loaded (§11).
local events = {}
-- loaded_events[event][id][triggernum] = triggerInfo -- the dispatch index a
-- game/internal event walks (§4.3). Both game and internal events live here;
-- only game events are RegisterEvent'd on the frame (internal ones arrive via
-- WA.ScanEvents from a watcher).
local loaded_events = {}
-- activeIds[id] = true while this display's tis are in loaded_events (it's
-- loaded). Keeps Load/Unload idempotent.
local activeIds = {}
-- The tis a Delete just retired, kept only until the next Add compares its
-- sourceKey against them (see GenericTrigger.Delete).
local lastCompiled = {}
-- The display being compiled, and the ids whose compile could not resolve a
-- spell or item name. A name resolves to an id baked into the generated source,
-- so an unresolved one leaves that trigger dead until something recompiles it --
-- which is the entire reason the addon recompiles on spell/item cache traffic
-- (OptionsFrame.lua's debounced sweep). Nothing else about a compile depends on
-- the caches, so once every name has an id that sweep has nothing left to fix.
local compilingId
local unresolvedIds = {}
local function noteUnresolved()
if compilingId then unresolvedIds[compilingId] = true end
end
-- Whether any compiled display is still waiting on a name the client cannot
-- resolve yet.
function WA.HasUnresolvedNames()
for _ in pairs(unresolvedIds) do return true end
return false
end
-- Internal (WA-generated) event names never handed to Frame:RegisterEvent -- a
-- watcher re-dispatches them through WA.ScanEvents instead (§4.4).
local INTERNAL_EVENTS = {
SPELL_COOLDOWN_CHANGED = true,
SPELL_COOLDOWN_READY = true,
ITEM_COOLDOWN_CHANGED = true,
ITEM_COOLDOWN_READY = true,
EQUIPSLOT_COOLDOWN_CHANGED = true,
EQUIPSLOT_COOLDOWN_READY = true,
GCD_UPDATE = true,
GCD_END = true,
-- Re-emitted by the threat watcher after parsing a TWThreat addon-message
-- broadcast (there's no threat game event; see the watcher below).
WA_THREAT_CHANGED = true,
-- Re-emitted by the totem watcher after a SPELL_GO_SELF totem drop commits.
WA_TOTEM_UPDATE = true,
-- Re-emitted by the swing-timer watcher on a swing reset / rescale / stop.
WA_SWING_UPDATE = true,
-- Re-emitted with the cast spell id after the player's own cast completes.
WA_SPELL_CAST_SUCCEEDED = true,
-- A shared 1s heartbeat for status prototypes whose value depletes with no
-- natural game event (weapon-enchant / crowd-control remaining time). Started
-- lazily by WA.EnsureSlowTick when such a trigger loads (below).
WA_SLOW_TICK = true,
-- A shared 0.1s heartbeat for status prototypes that track a fast-moving
-- value (range to a unit). Started lazily by WA.EnsureFastTick.
WA_FAST_TICK = true,
-- Fired after the player's login/loading-screen state has settled. Native
-- status APIs such as GetMoney can return their usable value only after the
-- initial PLAYER_ENTERING_WORLD burst.
WA_DELAYED_PLAYER_ENTERING_WORLD = true,
-- Re-emitted by the power-tick watcher when a regen tick is inferred or the
-- timer stops (power type change, full power).
WA_POWERTICK_UPDATE = true,
-- Upstream's per-frame custom-trigger pulse, carrying the frame's elapsed
-- time. Driven by an OnUpdate rather than C_Timer, which cannot schedule
-- per-frame; per-trigger onUpdateThrottle is what keeps it affordable.
FRAME_UPDATE = true,
}
-- ---------------------------------------------------------------------------
-- Code-generation helpers (ConstructFunction, §4.2)
-- ---------------------------------------------------------------------------
-- A config value as a Lua literal for embedding in generated source. %q handles
-- string quoting/escaping (Lua 5.0 has it); numbers/bools go verbatim.
local function fmt(v)
if type(v) == "number" then return tostring(v)
elseif type(v) == "boolean" then return v and "true" or "false"
elseif v == nil then return "nil"
else return string.format("%q", tostring(v)) end
end
-- Splits a user-typed "EVT_A, EVT_B EVT_C" event string (custom-trigger config)
-- into a trimmed list. Commas and any whitespace both separate; empty tokens are
-- dropped. Pattern string ops are fine on Lua 5.0 (gsub/find with patterns work;
-- only gmatch is missing).
local function parseEventList(str)
local out = {}
if not str or str == "" then return out end
local s = string.gsub(str, ",", " ")
local pos = 1
while true do
local a, b = string.find(s, "%s+", pos)
local tok
if a then tok = string.sub(s, pos, a - 1); pos = b + 1
else tok = string.sub(s, pos) end
if tok ~= "" then table.insert(out, tok) end
if not a then break end
end
return out
end
local COMMON_CUSTOM_EVENTS = {
"PLAYER_ENTERING_WORLD", "PLAYER_TARGET_CHANGED", "PLAYER_REGEN_DISABLED",
"PLAYER_REGEN_ENABLED", "UNIT_AURA", "UNIT_HEALTH", "UNIT_MANA",
"UNIT_ENERGY", "UNIT_RAGE", "BAG_UPDATE", "SPELLS_CHANGED",
"SPELL_UPDATE_COOLDOWN", "ACTIONBAR_UPDATE_COOLDOWN", "READY_CHECK",
"PARTY_MEMBERS_CHANGED", "RAID_ROSTER_UPDATE", "CHAT_MSG_SAY",
"CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_WHISPER", "UNIT_CASTEVENT",
}
local customEventSearch = {}
-- One "EVENT:a:b" token's colon-separated parts.
local function splitEventToken(token)
local parts = {}
for part in string.gfind(token, "[^:]+") do table.insert(parts, part) end
return parts
end
-- A custom trigger's event list in upstream's extended syntax, resolved into the
-- three things this client can act on: the events to register, a per-event set of
-- unit tokens to filter arg1 against, and the trigger numbers to watch.
--
-- "UNIT_AURA:player:target" is a filter rather than a narrower registration --
-- RegisterUnitEvent does not exist on this client (gotchas.md), so the general
-- event is registered and dispatch drops payloads whose unit is not listed.
-- "TRIGGER:2" names another of the display's triggers and registers no game
-- event at all. A "CLEU:"/"COMBAT_LOG_EVENT_UNFILTERED:" token resolves to
-- nothing: this client's combat log is SuperWoW's RAW_COMBATLOG, whose adapter
-- does not exist, and registering the modern name would silently never fire.
local function parseCustomEventList(str)
local eventList, unitFilters, watched = {}, nil, nil
local tokens = parseEventList(str)
for i = 1, table.getn(tokens) do
local token = tokens[i]
local parts = splitEventToken(token)
local base = parts[1]
local upper = base and string.upper(base) or ""
if upper == "CLEU" or upper == "COMBAT_LOG_EVENT_UNFILTERED" then
-- no adapter
elseif upper == "TRIGGER" then
for p = 2, table.getn(parts) do
local num = tonumber(parts[p])
if num then
watched = watched or {}
watched[num] = true
end
end
elseif table.getn(parts) > 1 and string.find(upper, "^UNIT_") then
table.insert(eventList, base)
unitFilters = unitFilters or {}
local set = unitFilters[base] or {}
unitFilters[base] = set
for p = 2, table.getn(parts) do
set[string.lower(parts[p])] = true
end
else
table.insert(eventList, base or token)
end
end
return eventList, unitFilters, watched
end
local function isClientEvent(name)
if INTERNAL_EVENTS[name] then return true end
if not (C_EventUtils and C_EventUtils.IsEventValid) then return true end
return C_EventUtils.IsEventValid(name) and true or false
end
-- Names the editor should warn about. A token carrying the extended syntax is
-- judged on its base event, and the two forms that name no game event at all are
-- exempt rather than reported as unregisterable.
local function invalidEventList(str)
local invalid = {}
local names = parseEventList(str)
for i = 1, table.getn(names) do
local parts = splitEventToken(names[i])
local base = parts[1] or names[i]
local upper = string.upper(base)
if upper == "TRIGGER" or upper == "CLEU" or upper == "COMBAT_LOG_EVENT_UNFILTERED" then
-- resolves to no game event
elseif not isClientEvent(base) then
table.insert(invalid, names[i])
end
end
return invalid
end
local function eventPickerValues(query)
local values, labels = {}, {}
local needle = string.upper(query or "")
local source = needle == "" and COMMON_CUSTOM_EVENTS or (WA.customEventCatalog or {})
for i = 1, table.getn(source) do
local name = source[i]
if isClientEvent(name)
and (needle == "" or string.find(name, needle, 1, true)) then
table.insert(values, name)
labels[name] = name
if table.getn(values) >= 40 then break end
end
end
if table.getn(values) == 0 then
table.insert(values, "__NO_EVENT_MATCH__")
labels.__NO_EVENT_MATCH__ = "No matching events"
end
return values, labels
end
local function appendEventName(str, name)
if name == "__NO_EVENT_MATCH__" then return str or "" end
local names = parseEventList(str)
for i = 1, table.getn(names) do
if names[i] == name then return str or "" end
end
if not str or not string.find(str, "%S") then return name end
return str .. " " .. name
end
local VALID_OPS = { ["=="] = true, ["~="] = true, ["<"] = true,
["<="] = true, [">"] = true, [">="] = true }
local function safeOp(op)
return VALID_OPS[op] and op or "=="
end
-- The comparisons a `string` arg offers, and their editor captions.
local STRING_OPS = { "==", "~=", "find", "notfind" }
local STRING_OP_LABELS = { ["=="] = "Is", ["~="] = "Is Not",
find = "Contains", notfind = "Doesn't Contain" }
-- The test source for one arg, or nil if it contributes none. A `test` win:
-- either a format string with a single %s for the user value, or a function
-- (trigger) -> source-string for config-branching tests (e.g. genericShowOn's
-- three show modes each need a different comparison, not one template). A test
-- function returning nil contributes no test. Otherwise a number arg becomes a
-- gated `name <op> value` comparison.
local function constructArgTest(arg, trigger)
if arg.test then
if type(arg.test) == "function" then
local src = arg.test(trigger)
if not src then return nil end
return "(" .. src .. ")"
end
return "(" .. string.format(arg.test, fmt(trigger[arg.name])) .. ")"
end
if arg.type == "number" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
local op = safeOp(trigger[arg.name .. "_operator"] or ">=")
local test = string.format("(%s %s %s)", arg.name, op, fmt(trigger[arg.name] or 0))
-- A second bound on the same value turns one comparison into a range
-- ("between 20 and 50"), which one operator cannot express. Parenthesised
-- as a unit so it stays one term however the caller joins the tests.
if arg.multiEntry and trigger["use_" .. arg.name .. "2"] then
local op2 = safeOp(trigger[arg.name .. "2_operator"] or "<=")
return string.format("(%s and (%s %s %s))", test, arg.name, op2,
fmt(trigger[arg.name .. "2"] or 0))
end
return test
elseif arg.type == "select" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
return string.format("(%s == %s)", arg.name, fmt(trigger[arg.name]))
elseif arg.type == "string" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
local op = trigger[arg.name .. "_operator"] or "=="
local val = fmt(trigger[arg.name] or "")
-- find/notfind are plain substring searches (find's 4th argument), not Lua
-- patterns: spell and unit names carry magic characters the user should not
-- have to escape. Both sides lowered so matching is case-insensitive, and
-- the nil guard matters because a stored name is nil until its unit exists.
if op == "find" then
return string.format("(%s ~= nil and string.find(string.lower(%s), string.lower(%s), 1, true) ~= nil)",
arg.name, arg.name, val)
elseif op == "notfind" then
return string.format("(%s == nil or string.find(string.lower(%s), string.lower(%s), 1, true) == nil)",
arg.name, arg.name, val)
elseif op == "~=" then
return string.format("(%s ~= %s)", arg.name, val)
end
return string.format("(%s == %s)", arg.name, val)
end
return nil
end
-- Compiles prototype+trigger into one test function (§4.2). Signature
-- (state, event, arg1..arg9): the status prototypes re-read live game state in
-- `init` and ignore the event args, but the parameters carry the firing event's
-- real payload, so a prototype can test against it instead of re-polling.
local function constructFunction(proto, trigger, errTag)
local lines = {}
table.insert(lines, "return function(state, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)")
if proto.init then
table.insert(lines, proto.init(trigger))
end
for i = 1, table.getn(proto.args) do
local arg = proto.args[i]
if arg.init then
table.insert(lines, string.format("local %s = %s", arg.name, arg.init))
end
end
local tests = {}
for i = 1, table.getn(proto.args) do
local t = constructArgTest(proto.args[i], trigger)
if t then table.insert(tests, t) end
end
local cond = table.getn(tests) > 0 and table.concat(tests, " and ") or "true"
for i = 1, table.getn(proto.args) do
local arg = proto.args[i]
if arg.store and arg.storeAlways then
table.insert(lines, string.format(
"if state.%s ~= %s then state.%s = %s state.changed = true end",
arg.name, arg.name, arg.name, arg.name))
end
end
table.insert(lines, "if (" .. cond .. ") then")
for i = 1, table.getn(proto.args) do
local arg = proto.args[i]
if arg.store and not arg.storeAlways then
table.insert(lines, string.format(
"if state.%s ~= %s then state.%s = %s state.changed = true end",
arg.name, arg.name, arg.name, arg.name))
end
end
table.insert(lines, "return true else return false end")
table.insert(lines, "end")
local source = table.concat(lines, "\n")
-- Engine-generated, so it keeps the real globals: the sandbox belongs to the
-- code's author, not to the call site. The source is already complete, `return
-- function(...)` and all, which is why it goes through the builtin door.
local fn, err = WA.LoadBuiltinFunction(source, errTag)
return fn, source, err
end
-- Compiles a custom trigger's user text into a function.
-- The text is a whole function expression ("function(event, ...) ... end");
-- WA.LoadFunction owns the wrapper, the sandbox and the error report. Shape
-- mirrors constructFunction's return contract (fn, source, err).
local function constructCustomFunction(trigger, errTag)
local body = trigger.custom or ""
local fn, err = WA.LoadFunction(body, errTag)
return fn, body, err
end
local function compileCustomField(source, errTag)
if not source or not string.find(source, "%S") then return nil end
return WA.LoadFunction(source, errTag)
end
-- A Trigger State Updater's `customVariables` text is a table *expression*, not a
-- function, so it is wrapped into one that returns it. The newlines around the
-- body keep a trailing comment from swallowing the closing `end`.
local function compileTsuVariables(source, errTag)
if not source or not string.find(source, "%S") then return nil end
return WA.LoadFunction("function() return \n" .. source .. "\n end", errTag)
end
local function trueFunction() return true end
-- Resolves a "spell" field's stored value -- a numeric spellID, or a name the
-- user typed -- to a numeric spellID, or nil if it doesn't resolve to
-- anything (yet). Numeric input is trusted as-is and never round-tripped
-- through a lookup: the legacy GetSpellInfo(spellID) works for *any* spell
-- ID, known or not (ref ClassicAPI docs §Spell), so a raw ID the player
-- hasn't learned still resolves. A name only resolves through C_Spell's name
-- resolver (FUN_RESOLVE_SPELL_NAME_TO_BOOK_ID, the same chain
-- CastSpellByName uses) -- unlike the numeric path, that's bounded to the
-- player's own known spellbook, so a name for a spell they don't have (wrong
-- spelling, wrong class, not yet learned) legitimately has no numeric ID to
-- resolve to and this returns nil.
function WA.ResolveSpellID(input)
if input == nil or input == "" then return nil end
local id = tonumber(input)
if id then return id end
if C_Spell and C_Spell.GetSpellInfo then
local info = C_Spell.GetSpellInfo(input)
if info and info.spellID then return info.spellID end
end
noteUnresolved()
return nil
end
-- Resolves an "item" field's stored value (a numeric itemID, or a name/
-- link the user typed) to a numeric itemID, or nil if it doesn't resolve to
-- anything (yet -- an uncached name needs the client to have seen the item
-- once, same GetItemInfo caveat as any other addon). No plain-Lua item-name-
-- >ID API exists, so a name resolves through GetItemInfo's link return and a
-- string.find capture (this client's Lua 5.0 has no string.match, ref
-- TextReplace.lua).
-- ---------------------------------------------------------------------------
-- The spellbook, indexed by name. The global cooldown is read off a slot because
-- there is no GCD spellID to query, and Debug.lua's /wa cdprobe reads slots to
-- show what each rank of a name reports. Ranks of a name occupy consecutive
-- ascending slots, so the last write wins and the index holds the highest rank
-- (the same rank walk DoiteAuras does before its own GetSpellCooldown calls).
-- ---------------------------------------------------------------------------
local spellSlotByName, spellSlotById
local function buildSpellSlotIndex()
spellSlotByName, spellSlotById = {}, {}
if not (GetNumSpellTabs and GetSpellTabInfo and GetSpellName) then return end
for tab = 1, (GetNumSpellTabs() or 0) do
local _, _, offset, numSlots = GetSpellTabInfo(tab)
if offset and numSlots then
for i = offset + 1, offset + numSlots do
local name = GetSpellName(i, BOOKTYPE_SPELL or "spell")
if name then spellSlotByName[name] = i end
end
end
end
end
function WA.InvalidateSpellSlots()
spellSlotByName, spellSlotById = nil, nil
end
-- Slot numbers shift as the book grows, so every cached one stops meaning
-- anything. Registered here rather than on a consumer's frame: the per-spell
-- cooldown watcher depends on this index whether or not anything is watching the
-- global cooldown.
local spellBookFrame = CreateFrame("Frame")
pcall(spellBookFrame.RegisterEvent, spellBookFrame, "SPELLS_CHANGED")
pcall(spellBookFrame.RegisterEvent, spellBookFrame, "LEARNED_SPELL_IN_TAB")
spellBookFrame:SetScript("OnEvent", WA.InvalidateSpellSlots)
function WA.SpellSlotByName(name)
if not name then return nil end
if not spellSlotByName then buildSpellSlotIndex() end
return spellSlotByName[name]
end
-- Slot for a spellID, via its name -- so a specific rank's ID still lands on the
-- book's highest rank, which is what actually goes on cooldown.
function WA.SpellSlotByID(spellId)
if not spellId or spellId == 0 then return nil end
if not spellSlotById then
if not spellSlotByName then buildSpellSlotIndex() end
spellSlotById = {}
end
local hit = spellSlotById[spellId]
if hit ~= nil then return hit or nil end
local name = C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(spellId)
local slot = name and spellSlotByName[name]
spellSlotById[spellId] = slot or false
return slot
end
-- (start, duration) off a spellbook slot, or nil. pcall'd: a slot that has gone
-- out of range (the book shrank) raises rather than answering.
function WA.SpellSlotCooldown(slot)
if not slot or not GetSpellCooldown then return nil end
local ok, start, duration = pcall(GetSpellCooldown, slot, BOOKTYPE_SPELL or "spell")
if not ok or not start or not duration then return nil end
return start, duration
end
-- itemID for a name the user typed, found by walking the player's own
-- containers. Nothing on this client resolves a name: C_Item.GetItemCount and
-- C_Item.GetItemInfo both take an id, an "item:N" string or a link, and say so
-- explicitly -- so an item named rather than linked can only be identified by
-- finding one you hold. A hit is memoized for good (an id never stops belonging
-- to a name); a miss is only rate-limited, since the item may simply not be
-- carried yet and looting one must start working without a reload.
local itemIdByName = {}
local itemNameMissAt = {}
local ITEM_NAME_RESCAN = 0.5
-- Bags the scan walks. GetContainerItemID documents 0 (backpack) and 1..4
-- (equipped bags); the bank indices are tried anyway because the count call
-- reads the bank cold here, and an unsupported index just answers nil.
local SCAN_BAGS = { 0, 1, 2, 3, 4, -1, 5, 6, 7, 8, 9, 10 }
function WA.ItemIDByName(name)
if not name or name == "" then return nil end
local lname = string.lower(name)
if itemIdByName[lname] then return itemIdByName[lname] end
local last = itemNameMissAt[lname]
if last and (GetTime() - last) < ITEM_NAME_RESCAN then return nil end
local function matches(id)
if not id then return nil end
local n = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(id)
if n and string.lower(n) == lname then return id end
return nil
end
local function remember(id)
itemIdByName[lname] = id
itemNameMissAt[lname] = nil
return id
end
-- Equipped first: cheapest, and the one place a bag walk cannot look.
if GetInventoryItemID then
for slot = 1, 19 do
local hit = matches(GetInventoryItemID("player", slot))
if hit then return remember(hit) end
end
end
if C_Container and C_Container.GetContainerItemID and GetContainerNumSlots then
for i = 1, table.getn(SCAN_BAGS) do
local bag = SCAN_BAGS[i]
for slot = 1, (GetContainerNumSlots(bag) or 0) do
local hit = matches(C_Container.GetContainerItemID(bag, slot))
if hit then return remember(hit) end
end
end
end
itemNameMissAt[lname] = GetTime()
return nil
end
-- A name that missed can only start resolving once the player is carrying one,
-- and the id a miss baked into a trigger's compiled source is not revisited on
-- its own. GET_ITEM_INFO_RECEIVED (OptionsFrame.lua's recompile hook) covers an
-- item whose *static data* had not arrived; it does not fire for an item already
-- in the client's cache that simply was not in a bag yet, which is what this
-- covers. Shaped after upstream's itemDataLoadFrame: the retry only walks while
-- a name is outstanding, and a name that starts resolving recompiles.
local itemNameRetryFrame = CreateFrame("Frame")
pcall(itemNameRetryFrame.RegisterEvent, itemNameRetryFrame, "BAG_UPDATE")
itemNameRetryFrame:SetScript("OnEvent", function()
local retry
for lname in pairs(itemNameMissAt) do
retry = retry or {}
table.insert(retry, lname)
end
if not retry then return end
local resolved = false
for i = 1, table.getn(retry) do
-- Clear the rate-limit stamp: a bag change is exactly the event that can
-- make this walk succeed, so it should not be waited out.
itemNameMissAt[retry[i]] = nil
if WA.ItemIDByName(retry[i]) then resolved = true end
end
if resolved and WA.AddAllDisplays then WA.AddAllDisplays() end
end)
function WA.ResolveItemID(input)
if input == nil or input == "" then return nil end
local id = tonumber(input)
if id then return id end
-- A shift-clicked link (or any "item:N" string) carries the id outright,
-- which is the one form needing neither the item cache nor a scan.
local _, _, linkId = string.find(input, "item:(%d+)")
if linkId then return tonumber(linkId) end
local name, link = GetItemInfo(input)
if link then
local _, _, capturedId = string.find(link, "item:(%d+)")
local id2 = capturedId and tonumber(capturedId)
if id2 then return id2 end
end
local byName = WA.ItemIDByName(input)
if byName == nil then noteUnresolved() end
return byName
end
-- Inspect the player's equipment and return the first matching item's details.
-- GetItemInfoInstant is cache-only: an ID can be present while its class data
-- is unavailable, so an uncached item is equipped but cannot match a type.
function WA.ItemTypeEquipped(wantedClassID, wantedSubclassID, selectedSlot)
local firstSeen
local firstID, firstName, firstIcon, firstClass, firstSubclass
local firstClassID, firstSubclassID, firstKnown
local matchingID, matchingName, matchingIcon, matchingClass, matchingSubclass
local matchingClassID, matchingSubclassID
local equipped = false
local startSlot, endSlot = 1, 19
if selectedSlot and tonumber(selectedSlot) and tonumber(selectedSlot) > 0 then
startSlot, endSlot = tonumber(selectedSlot), tonumber(selectedSlot)
end
for slot = startSlot, endSlot do
local id = GetInventoryItemID and GetInventoryItemID("player", slot)
if id then
equipped = true
local itemID, itemClass, itemSubclass, _, itemIcon, classID, subclassID
if C_Item and C_Item.GetItemInfoInstant then
itemID, itemClass, itemSubclass, _, itemIcon, classID, subclassID = C_Item.GetItemInfoInstant(id)
end
local known = itemID and classID ~= nil and subclassID ~= nil
if not firstSeen then firstID = itemID or id end
if known then
local itemName, cachedIcon
if C_Item and C_Item.GetItemInfo then
itemName, _, _, _, _, _, _, _, _, cachedIcon = C_Item.GetItemInfo(itemID)
end
itemIcon = itemIcon or cachedIcon
if not firstKnown and not firstSeen then
firstName, firstIcon = itemName, itemIcon
firstClass, firstSubclass = itemClass, itemSubclass
firstClassID, firstSubclassID, firstKnown = classID, subclassID, true
end
if not matchingID and classID == tonumber(wantedClassID) and subclassID == tonumber(wantedSubclassID) then
matchingID, matchingName, matchingIcon = itemID, itemName, itemIcon
matchingClass, matchingSubclass = itemClass, itemSubclass
matchingClassID, matchingSubclassID = classID, subclassID
end
elseif not firstSeen then
firstID = itemID or id
end
firstSeen = true
end
end
if matchingID then
return matchingID, matchingName, matchingIcon, matchingClass, matchingSubclass,
matchingClassID, matchingSubclassID, equipped, true, true
end
return firstID, firstName, firstIcon, firstClass, firstSubclass,
firstClassID, firstSubclassID, equipped, false, firstKnown and true or false
end
function WA.ItemSetEquipped(setID)
local targetID = tonumber(setID) or 0
local total = 18
local count = 0
local setName
local known = false
if C_Item and C_Item.GetItemSetInfo then
local info = C_Item.GetItemSetInfo(targetID)
if info then
setName = info.name
known = true
end
end
if targetID > 0 and C_Item and C_Item.GetItemSetIDByID and GetInventoryItemID then
for slot = 1, 18 do
local itemID = GetInventoryItemID("player", slot)
local equippedSetID = itemID and C_Item.GetItemSetIDByID(itemID)
if equippedSetID == targetID then count = count + 1 end
end
end
return count, total, setName, known
end
function WA.EquipmentSetInfo(setName, partial)
setName = setName or ""
if C_EquipmentSet and C_EquipmentSet.GetEquipmentSetID
and C_EquipmentSet.GetEquipmentSetInfo then
local setID = C_EquipmentSet.GetEquipmentSetID(setName)
if setID then
local name, icon, _, isEquipped, numItems, numEquipped, _, _, numIgnored =
C_EquipmentSet.GetEquipmentSetInfo(setID)
if name then
numItems = tonumber(numItems) or 0
numEquipped = tonumber(numEquipped) or 0
numIgnored = tonumber(numIgnored) or 0
local active = (partial and numItems > 0 and numEquipped > 0)
or ((not partial) and isEquipped and true or false)
return name, icon, numEquipped, numItems, active and true or false, setID, numIgnored
end
end
end
-- ItemRack is name-keyed and has no numeric set ID. Prefer the
-- ClassicAPI/pfUI set when both systems contain the same name.
if ItemRack_GetUserSets then
local sets = ItemRack_GetUserSets()
local set = sets and sets[setName]
if set then
local total, count = 0, 0
for slot = 0, 19 do
local entry = set[slot]
if entry then
total = total + 1
local equippedID = GetInventoryItemID and GetInventoryItemID("player", slot)
if equippedID and tonumber(entry.id) == tonumber(equippedID) then count = count + 1 end
end
end
local active
if partial then active = total > 0 and count > 0
elseif ItemRack_IsSetEquipped then active = ItemRack_IsSetEquipped(setName) and true or false
else active = total > 0 and count == total end
return setName, set.icon, count, total, active and true or false, nil, 0
end
end
return nil, nil, 0, 0, false
end
-- How many of an item the player holds, plus whether the item was identified at
-- all. Resolved at run time rather than baked into the generated source like
-- other trigger constants: an item can be configured long before one is ever
-- carried, and a typed name only becomes resolvable once one is found.
function WA.ItemCount(input, includeBank)
local id = WA.ResolveItemID(input)
if not id then return 0, false end
if not (C_Item and C_Item.GetItemCount) then return 0, true end
return C_Item.GetItemCount(id, includeBank and true or false) or 0, true
end
-- Bool condition/filter for "is `unit` in range of this spell", unit defaulting
-- to "target" for the spell-cooldown caller. SpellHasRange gates self-buffs and
-- other unrestricted spells to always-true rather than gating on a unit the
-- spell was never range-limited against; UnitInRange is the ClassicAPI 40yd
-- position check. Real Lua (not generated source) since neither call needs
-- per-trigger config.
function WA.SpellInRange(spellId, unit)
unit = unit or "target"
local hasRange = C_Spell and C_Spell.SpellHasRange and C_Spell.SpellHasRange(spellId)
if not hasRange then return true end
if not UnitExists(unit) then return false end
if not UnitInRange then return true end -- can't check on this build; don't gate
return UnitInRange(unit) and true or false
end
-- The unit token a trigger actually targets: the dropdown's choice, or the
-- free-text override when the dropdown's "specific" entry is selected (raid17,
-- partyN, or a SuperWoW GUID). Resolved at compile time and baked into the
-- generated source, like every other trigger constant.
function WA.TriggerUnit(trigger, fallback)
if trigger.unit == "specific" then
if trigger.specificUnit and trigger.specificUnit ~= "" then
return trigger.specificUnit
end
return fallback or "player"
end
return trigger.unit or fallback or "player"
end
-- GetItemInfo's classic 14-tuple; only name (1st) and icon (10th) matter to
-- the item-keyed prototypes below.
-- C_Item.GetItemInfo, not the stock global: the global answers vanilla's short
-- tuple, which has no itemLevel and so puts the texture one slot earlier than
-- the modern 18-value shape documents. Reading position 10 off the global gets
-- nil, which is why an item trigger drew no icon.
local function itemNameIcon(id)
if not id then return nil, nil end
if not (C_Item and C_Item.GetItemInfo) then return nil, nil end
local name, _, _, _, _, _, _, _, _, icon = C_Item.GetItemInfo(id)
return name, icon
end
-- Shared 1s heartbeat: some status prototypes hold a value that depletes with no
-- natural game event (a weapon enchant's remaining time, a crowd-control debuff's
-- countdown-to-expiry). They list "WA_SLOW_TICK" in their events and call this in
-- loadFunc; the ticker starts once and re-dispatches through ScanEvents so those
-- triggers re-read live state ~1x/sec (the region itself animates the countdown;
-- this is only to flip show=false at expiry and re-detect a fresh application).
local slowTicker
function WA.EnsureSlowTick()
if not slowTicker then
slowTicker = C_Timer.NewTicker(1, function() WA.ScanEvents("WA_SLOW_TICK") end)
end
end
-- Shared 0.1s heartbeat for status prototypes tracking a fast-moving value
-- (range to a unit). Started lazily by WA.EnsureFastTick when such a trigger
-- loads and never cancelled; a range readout wants smoother than the 1s slow
-- tick, and 0.1s is cheap because ScanEvents early-outs on an event no trigger
-- is registered for.
local fastTicker
function WA.EnsureFastTick()
if not fastTicker then
fastTicker = C_Timer.NewTicker(0.1, function() WA.ScanEvents("WA_FAST_TICK") end)
end
end
-- Temporary weapon enchant (oils/stones/poisons) for one hand. C_Item.
-- GetWeaponEnchantInfo returns a flat 12-tuple (main/off/ranged x has/expireMs/
-- charges/enchantID) -- captured whole via a plain multi-assign, never an
-- `and`-chain (which truncates multi-returns to one value on this client's Lua
-- 5.0). expireMs is remaining time in ms; the API gives no *original* duration,
-- so weCache remembers the remaining seen when the enchant first appeared (reset
-- when it drops or is re-applied -- expiration jumps up) to drive a proper
-- depleting bar rather than a permanently-full one.
local weCache = {}
function WA.WeaponEnchantInfo(hand)
if not (C_Item and C_Item.GetWeaponEnchantInfo) then return false, 0, 0, nil end
local hM, eM, cM, idM, hO, eO, cO, idO, hR, eR, cR, idR = C_Item.GetWeaponEnchantInfo()
local has, expireMs, enchantId
if hand == "off" then has, expireMs, enchantId = hO, eO, idO
elseif hand == "ranged" then has, expireMs, enchantId = hR, eR, idR
else has, expireMs, enchantId = hM, eM, idM end
if not has then weCache[hand] = nil; return false, 0, 0, nil end
local remaining = (expireMs or 0) / 1000
local expiration = GetTime() + remaining
local c = weCache[hand]
if not c or expiration > c.expiration + 2 then
c = { expiration = expiration, duration = remaining }
weCache[hand] = c
else
c.expiration = expiration
end
return true, expiration, c.duration, enchantId
end
-- Reputation standing for a named faction. Walks the displayed faction list
-- (headers skipped) since GetFactionInfoByID needs an ID the user doesn't know;
-- niche enough that an O(n) scan on the UPDATE_FACTION event is fine. barMin/
-- barMax/barValue bracket the current standing bar. nil when the faction isn't
-- in the player's list (not yet encountered).
function WA.FactionStanding(name)
if not name or name == "" or not GetNumFactions then return nil end
for i = 1, GetNumFactions() do
local fname, _, standingID, barMin, barMax, barValue, _, _, isHeader = GetFactionInfo(i)
if not isHeader and fname == name then
return standingID, barMin, barMax, barValue, fname
end
end
return nil
end
-- Crowd-control category table (English spell names -> category), adapted from
-- the reference LoseControl addon's Babble-Spell keyed table. On an English
-- client Babble-Spell is identity, so those keys ARE the plain spell names --
-- reproduced here directly since we ship no Babble-Spell. Categories match
-- LoseControl's (stuns/incapacitates/fears all fold into "CC"); a couple of its
-- entries that aren't really control effects (e.g. Mortal Strike, a heal debuff)
-- are dropped so a CC display doesn't false-positive on them.
local CC_CATEGORIES = {
-- Druid
["Hibernate"] = "CC", ["Bash"] = "CC", ["Pounce"] = "CC", ["Feral Charge Effect"] = "Root",
["Entangling Roots"] = "Root",
-- Hunter
["Freezing Trap Effect"] = "CC", ["Intimidation"] = "CC", ["Scare Beast"] = "CC",
["Scatter Shot"] = "CC", ["Wyvern Sting"] = "CC", ["Concussive Shot"] = "Snare",
["Frost Trap Aura"] = "Root", ["Counterattack"] = "Root", ["Improved Wing Clip"] = "Root",
["Wing Clip"] = "Snare", ["Entrapment"] = "Root",
-- Mage
["Polymorph"] = "CC", ["Polymorph: Turtle"] = "CC", ["Polymorph: Pig"] = "CC",
["Counterspell - Silenced"] = "Silence", ["Impact"] = "CC", ["Blast Wave"] = "Snare",
["Frostbite"] = "Root", ["Freeze"] = "Root", ["Frost Nova"] = "Root",
["Frostbolt"] = "Snare", ["Chilled"] = "Snare", ["Cone of Cold"] = "Snare",
-- Paladin
["Hammer of Justice"] = "CC", ["Repentance"] = "CC",
-- Priest
["Mind Control"] = "CC", ["Psychic Scream"] = "CC", ["Blackout"] = "CC",
["Silence"] = "Silence", ["Mind Flay"] = "Snare",
-- Rogue
["Blind"] = "CC", ["Cheap Shot"] = "CC", ["Gouge"] = "CC", ["Kidney Shot"] = "CC",
["Sap"] = "CC", ["Kick - Silenced"] = "Silence", ["Crippling Poison"] = "Snare",
-- Warlock
["Death Coil"] = "CC", ["Fear"] = "CC", ["Howl of Terror"] = "CC",
["Curse of Exhaustion"] = "Snare", ["Seduction"] = "CC", ["Spell Lock"] = "Silence",
["Aftermath"] = "Snare", ["Cripple"] = "Snare",
-- Warrior
["Charge Stun"] = "CC", ["Intercept Stun"] = "CC", ["Intimidating Shout"] = "CC",
["Concussion Blow"] = "CC", ["Piercing Howl"] = "Snare",
["Shield Bash - Silenced"] = "Silence", ["Disarm"] = "Disarm",
-- Other / creature
["War Stomp"] = "CC", ["Mace Stun Effect"] = "CC", ["Web"] = "Root", ["Net"] = "Root",
["Knockdown"] = "CC", ["Sleep"] = "CC", ["Dazed"] = "Snare", ["Tidal Charm"] = "CC",
["Gnomish Mind Control Cap"] = "CC",
}
-- First crowd-control debuff on the player, or (false, ...). Player-unit aura
-- timing is the reliable path on this client (real duration/expiration only for
-- unit == "player"), which is exactly what a "am I CC'd" display
-- needs. Scans HARMFUL and stops at the first nil slot (compacting list).
function WA.ScanPlayerCC()
if not (C_UnitAuras and C_UnitAuras.GetAuraDataByIndex) then return false end
for i = 1, 40 do
local aura = C_UnitAuras.GetAuraDataByIndex("player", i, "HARMFUL")
if not aura then break end
local cat = aura.name and CC_CATEGORIES[aura.name]
if cat then
return true, cat, aura.name, aura.icon, aura.expirationTime or 0, aura.duration or 0
end
end
return false, nil, nil, nil, 0, 0
end
-- ---------------------------------------------------------------------------
-- Prototypes (§4). Each = one table; adding a category never touches the
-- system code above or below.
-- ---------------------------------------------------------------------------
local PROTOTYPES = {}
-- A saved trigger field written under an older name, moved in place. Reached
-- through the trigger type's `migrate` hook, which MergeDefaults runs on every
-- load before seeding defaults -- so it has to stay idempotent and must not
-- overwrite a value already stored under the new name.
local function renameArg(trigger, old, new)
if trigger[old] == nil then return end
if trigger[new] == nil then trigger[new] = trigger[old] end
trigger[old] = nil
end
local COMBAT_EVENT_VALUES = { "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED" }
local COMBAT_EVENT_LABELS = {
PLAYER_REGEN_DISABLED = "Entering Combat",
PLAYER_REGEN_ENABLED = "Leaving Combat",
}
local CHAT_MESSAGE_VALUES = {
"CHAT_MSG_SAY", "CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_GUILD",
"CHAT_MSG_OFFICER", "CHAT_MSG_YELL", "CHAT_MSG_WHISPER", "CHAT_MSG_EMOTE",
"CHAT_MSG_SYSTEM", "CHAT_MSG_MONSTER_SAY",
"CHAT_MSG_MONSTER_YELL", "CHAT_MSG_MONSTER_WHISPER", "CHAT_MSG_MONSTER_EMOTE",
"CHAT_MSG_CHANNEL", "CHAT_MSG_LOOT", "CHAT_MSG_RAID_WARNING",
"CHAT_MSG_BG_SYSTEM_NEUTRAL", "CHAT_MSG_BG_SYSTEM_ALLIANCE", "CHAT_MSG_BG_SYSTEM_HORDE",
}
local CHAT_MESSAGE_LABELS = {}
for i = 1, table.getn(CHAT_MESSAGE_VALUES) do
local value = CHAT_MESSAGE_VALUES[i]
CHAT_MESSAGE_LABELS[value] = string.sub(value, 10)
end
local CHAT_MESSAGE_ALL_EVENTS = {}
for i = 1, table.getn(CHAT_MESSAGE_VALUES) do table.insert(CHAT_MESSAGE_ALL_EVENTS, CHAT_MESSAGE_VALUES[i]) end
table.insert(CHAT_MESSAGE_ALL_EVENTS, "CHAT_MSG_PARTY_LEADER")
table.insert(CHAT_MESSAGE_ALL_EVENTS, "CHAT_MSG_RAID_LEADER")
table.insert(CHAT_MESSAGE_ALL_EVENTS, "CHAT_MSG_TEXT_EMOTE")
local function chatMessageEvents(trigger)
if trigger and trigger.use_messageType and trigger.messageType and CHAT_MESSAGE_LABELS[trigger.messageType] then
local events = { trigger.messageType }
if trigger.messageType == "CHAT_MSG_PARTY" then table.insert(events, "CHAT_MSG_PARTY_LEADER") end
if trigger.messageType == "CHAT_MSG_RAID" then table.insert(events, "CHAT_MSG_RAID_LEADER") end
if trigger.messageType == "CHAT_MSG_EMOTE" then table.insert(events, "CHAT_MSG_TEXT_EMOTE") end
return events
end
return CHAT_MESSAGE_ALL_EVENTS
end
function WA.TalentInfo(wanted, wantedTab, wantedTier, wantedColumn)
if not (GetNumTalentTabs and GetNumTalents and GetTalentInfo) then return false, false, 0, 0, 0, 0, 0, nil, nil end
if not wanted or wanted == "" then return false, false, 0, 0, 0, 0, 0, nil, nil end
local tabs = tonumber(GetNumTalentTabs()) or 0
for tab = 1, tabs do
if wantedTab == 0 or wantedTab == tab then
local count = tonumber(GetNumTalents(tab)) or 0
for index = 1, count do
local name, icon, tier, column, rank, maxRank = GetTalentInfo(tab, index)
if name == wanted
and (wantedTier == 0 or tier == wantedTier)
and (wantedColumn == 0 or column == wantedColumn) then
return true, (tonumber(rank) or 0) > 0, tonumber(rank) or 0,
tonumber(maxRank) or 0, tab, tonumber(tier) or 0, tonumber(column) or 0, icon
end
end
end
end
return false, false, 0, 0, 0, 0, 0, nil, nil
end
function WA.PetBehavior()
if not GetPetActionInfo then return nil, nil end
local slots = NUM_PET_ACTION_SLOTS or 10
for index = 1, slots do
local name, _, texture, token, active = GetPetActionInfo(index)
if active and name then
local behavior
if name == "PET_MODE_AGGRESSIVE" then behavior = "aggressive"
elseif name == "PET_MODE_ASSIST" then behavior = "assist"
elseif name == "PET_MODE_DEFENSIVEASSIST" or name == "PET_MODE_DEFENSIVE" then behavior = "defensive"
elseif name == "PET_MODE_PASSIVE" then behavior = "passive" end
if behavior then
if token and texture then texture = _G[texture] end
return behavior, texture
end
end
end
return nil, nil
end
local TALENT_TAB_VALUES = { 0, 1, 2, 3 }
local TALENT_TAB_LABELS = { [0] = "Any Tab", [1] = "Tab 1", [2] = "Tab 2", [3] = "Tab 3" }
local TALENT_RANK_VALUES = { "ignore", "known", "maxed" }
local TALENT_RANK_LABELS = { ignore = "Any Rank", known = "Known (rank > 0)", maxed = "Max Rank" }
PROTOTYPES["talentknown"] = {
displayName = "Talent Known",
wa2Event = "Talent Known",
category = "unit",
progressType = "static",
progressValue = "rank",