Repository navigation
Expand file tree
/
Copy pathArcUI_Core.lua
More file actions
5325 lines (4853 loc) · 224 KB
/
Copy pathArcUI_Core.lua
File metadata and controls
5325 lines (4853 loc) · 224 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
-- ===================================================================
-- ArcUI_Core.lua
-- Core tracking system supporting multiple bar slots
-- v3.0.0: Event-driven CDM hook architecture
-- - Hooks CDM frame OnAuraInstanceInfoSet/OnAuraInstanceInfoCleared/
-- OnUnitAuraUpdatedEvent for direct bar updates (no UNIT_AURA polling).
-- - PLAYER_TOTEM_UPDATE handler for totem/pet/ground bars.
-- - Cached frame refs from ValidateAllBarTracking with O(1) cooldownID validation.
-- v2.13.0: Fix debuff stack tracking lag ("one behind" on target)
-- v2.12.0: Fix empty CDM bar frames after spec change
-- v2.11.0: Secret-safe auraInstanceID protection
-- v2.7.0: Added sound utilities for conditional events system
--
-- DEBUFF DURATION FIX (v2.2.1):
-- - For debuffs, use CDM frame's auraInstanceID with "target" unit
-- - CDM bar frame provides duration data via Bar:GetValue()
-- ===================================================================
local ADDON, ns = ...
ns = ns or {}
ns.API = ns.API or {}
-- Optional dev tooling: hand our namespace to !ArcUIProfiler for opt-in deep
-- per-module CPU profiling. No-op unless that profiler addon is installed (it
-- loads first and defines the global). We pass the reference; it is only walked
-- on demand via /arcprof deep, never automatically.
if _G.ArcUIProfiler_RegisterNamespace then _G.ArcUIProfiler_RegisterNamespace(ns) end
ns.devMode = false
ns.debugMode = false -- Stack tracking debug output
-- ===================================================================
-- LIBPLEEBUG PROFILING SETUP
-- ===================================================================
local MemDebug = LibStub and LibStub("LibPleebug-1", true)
local P, TrackThis
if MemDebug then
P, TrackThis = MemDebug:DropIn(ns.API)
end
ns.API._TrackThis = TrackThis
-- ===================================================================
-- SECRET-SAFE AURAINSTANCEID HELPERS
-- v2.11.0: Protects against future Blizzard change making auraInstanceID secret
-- Uses issecretvalue() to test presence without direct comparison
-- ===================================================================
-- Check if an auraInstanceID is present (handles potential secret values)
-- Returns: true if aura is active, false if nil/0/absent
local function HasAuraInstanceID(value)
if value == nil then return false end
-- issecretvalue returns true if the value is a secret value
-- A secret auraInstanceID means the aura IS present (just can't compare it)
if issecretvalue and issecretvalue(value) then
return true
end
-- Non-secret: treat 0 as "no aura" (default value in saved variables)
if type(value) == "number" and value == 0 then return false end
-- Non-nil, non-zero, non-secret = valid auraInstanceID
return true
end
-- Expose for other modules
ns.API.HasAuraInstanceID = HasAuraInstanceID
-- ===================================================================
-- SECRET-SAFE SPELL ID READ (detect, don't test)
--
-- CDM's cooldownInfo and GetCooldownViewerCooldownInfo hand back SECRET spell
-- IDs inside instances. Secrecy is INSTANCE-based, not combat-based, so the
-- InCombatLockdown guard on the scanners below is no protection at all: the
-- delayed rescans run out of combat, in a raid, and compared a secret --
-- "attempt to compare local 'spellID' (a secret number value)", 150x per
-- dungeon (Discord 1550642951).
--
-- Returns the id only when it is a plain comparable number, else nil, so the
-- caller skips that entry. A secret id cannot be compared, cannot be named,
-- cannot key a dedupe table (secrets cannot index tables), and must never
-- reach SavedVariables -- there is nothing useful to do with one here.
-- ===================================================================
local function NonSecretSpellID(value)
if value == nil then return nil end
if issecretvalue and issecretvalue(value) then return nil end
if type(value) ~= "number" then return nil end
return value
end
ns.API = ns.API or {}
ns.API.NonSecretSpellID = NonSecretSpellID
-- ===================================================================
-- SECRET-SAFE USABILITY READ (detect, don't test)
--
-- C_Spell.IsSpellUsable takes SecretArguments = "AllowedWhenTainted", so a
-- secret spellID hands back SECRET BOOLEANS -- and a boolean test on a secret
-- THROWS ("attempt to perform boolean test on a secret boolean value"). The
-- 12.1 CDM item entries walk straight into this: a bag-item cooldown refresh
-- (potion) runs CDM's RefreshIconColor on our tainted stack, and every hook
-- that read usability there errored on every BAG_UPDATE_COOLDOWN.
--
-- Returns isUsable, insufficientPower -- or NIL when the answer is secret, so
-- callers can simply leave whatever CDM already decided in place.
-- ===================================================================
function ns.API.SafeIsSpellUsable(spellID)
if not spellID then return nil end
if not (C_Spell and C_Spell.IsSpellUsable) then return nil end
if issecretvalue and issecretvalue(spellID) then return nil end
local usable, insufficientPower = C_Spell.IsSpellUsable(spellID)
if issecretvalue and (issecretvalue(usable) or issecretvalue(insufficientPower)) then
return nil
end
return usable, insufficientPower
end
-- Spec change grace period - don't hide bars due to trackingOK=false for a few seconds after spec change
local specChangeGraceUntil = 0
local SPEC_CHANGE_GRACE_DURATION = 3.0 -- seconds
-- ===================================================================
-- REGISTER ADDON SOUNDS WITH LIBSHAREDMEDIA
-- v2.7.0: Added for conditional events system
-- ===================================================================
local LSM = LibStub("LibSharedMedia-3.0", true)
if LSM then
local SOUND_PATH = "Interface\\AddOns\\ArcUI\\Sounds\\"
local addonSounds = {
-- Animals
["ArcUI: Bleat"] = "Bleat.ogg",
["ArcUI: Cat Meow"] = "CatMeow2.ogg",
["ArcUI: Chicken Alarm"] = "ChickenAlarm.ogg",
["ArcUI: Cow Mooing"] = "CowMooing.ogg",
["ArcUI: Goat Bleating"] = "GoatBleating.ogg",
["ArcUI: Kitten Meow"] = "KittenMeow.ogg",
["ArcUI: Roaring Lion"] = "RoaringLion.ogg",
["ArcUI: Rooster Chicken"] = "RoosterChickenCalls.ogg",
["ArcUI: Sheep Bleat"] = "SheepBleat.ogg",
-- Alerts
["ArcUI: Air Horn"] = "AirHorn.ogg",
["ArcUI: Bike Horn"] = "BikeHorn.ogg",
["ArcUI: Error Beep"] = "ErrorBeep.ogg",
["ArcUI: Ringing Phone"] = "RingingPhone.ogg",
["ArcUI: Robot Blip"] = "RobotBlip.ogg",
["ArcUI: Warning Siren"] = "WarningSiren.ogg",
-- Musical
["ArcUI: Acoustic Guitar"] = "AcousticGuitar.ogg",
["ArcUI: Brass"] = "Brass.mp3",
["ArcUI: Drums"] = "Drums.ogg",
["ArcUI: Glass"] = "Glass.mp3",
["ArcUI: Synth Chord"] = "SynthChord.ogg",
["ArcUI: Tada Fanfare"] = "TadaFanfare.ogg",
["ArcUI: Temple Bell"] = "TempleBellHuge.ogg",
["ArcUI: Xylophone"] = "Xylophone.ogg",
-- Effects
["ArcUI: Applause"] = "Applause.ogg",
["ArcUI: Banana Peel Slip"] = "BananaPeelSlip.ogg",
["ArcUI: Batman Punch"] = "BatmanPunch.ogg",
["ArcUI: Blast"] = "Blast.ogg",
["ArcUI: Boxing Arena"] = "BoxingArenaSound.ogg",
["ArcUI: Double Whoosh"] = "DoubleWhoosh.ogg",
["ArcUI: Heartbeat"] = "HeartbeatSingle.ogg",
["ArcUI: Kaching"] = "Kaching.ogg",
["ArcUI: Ultra Instinct"] = "UltraInstinct.mp3",
["ArcUI: Ultra Instinct Theme"] = "UltraInstinctTheme.mp3",
["ArcUI: Sharp Punch"] = "SharpPunch.ogg",
["ArcUI: Shotgun"] = "Shotgun.ogg",
["ArcUI: Squeaky Toy"] = "SqueakyToyShort.ogg",
["ArcUI: Squish"] = "SquishFart.ogg",
["ArcUI: Torch"] = "Torch.ogg",
["ArcUI: Water Drop"] = "WaterDrop.ogg",
-- Voice
["ArcUI: Cartoon Voice"] = "CartoonVoiceBaritone.ogg",
["ArcUI: Cartoon Walking"] = "CartoonWalking.ogg",
["ArcUI: Oh No"] = "OhNo.ogg",
}
for name, file in pairs(addonSounds) do
LSM:Register("sound", name, SOUND_PATH .. file)
end
end
-- ===================================================================
-- SOUND UTILITIES
-- ===================================================================
ns.Sounds = ns.Sounds or {}
-- Built-in WoW SoundKit IDs
ns.Sounds.builtInSounds = {
[567] = "Snarl",
[569] = "Growl",
[3081] = "Direct Message",
[5274] = "Auction Window",
[8959] = "Raid Warning",
[11466] = "Not Prepared",
[12867] = "Drumroll Ding",
[23404] = "PvP Warning",
[25477] = "Countdown",
}
local currentSoundHandle = nil
-- Play a sound from settings table
-- settings = { soundType = "lsm"|"soundkit"|"custom", lsmSound = name, soundKitID = id, customPath = path }
function ns.Sounds.PlaySound(settings)
if not settings then return end
local willPlay, soundHandle
if settings.soundType == "soundkit" and settings.soundKitID then
willPlay, soundHandle = PlaySound(settings.soundKitID, "Master")
elseif settings.soundType == "lsm" and settings.lsmSound then
if LSM then
local soundPath = LSM:Fetch("sound", settings.lsmSound)
if soundPath then
willPlay, soundHandle = PlaySoundFile(soundPath, "Master")
end
end
elseif settings.soundType == "custom" and settings.customPath and settings.customPath ~= "" then
willPlay, soundHandle = PlaySoundFile(settings.customPath, "Master")
end
-- Handle TTS if enabled
if settings.ttsEnabled and settings.ttsText and settings.ttsText ~= "" then
ns.Sounds.SpeakText(settings.ttsText, settings.ttsVoice)
end
return willPlay, soundHandle
end
-- Preview a sound (for options panel)
function ns.Sounds.PreviewSound(settings)
ns.Sounds.StopPreview()
local willPlay, soundHandle = ns.Sounds.PlaySound(settings)
currentSoundHandle = soundHandle
return willPlay
end
-- Stop the current preview
function ns.Sounds.StopPreview()
if currentSoundHandle then
StopSound(currentSoundHandle)
currentSoundHandle = nil
end
end
-- ===================================================================
-- TEXT TO SPEECH — ONE VOICE FOR THE WHOLE ADDON
--
-- Voice + speech-rate live in ONE place (the character's cooldownReminder
-- table, where ArcUI's TTS controls have always written them) and EVERY
-- speaking feature reads them through here: Cooldown Reminder, CDM aura-icon
-- alerts, Arc aura-icon alerts. Callers may still pass an explicit voice/rate
-- to override for a preview.
-- ===================================================================
-- Shared TTS settings table (may be nil before the DB exists).
function ns.Sounds.GetTTSConfig()
local charDB = ns.API and ns.API.GetDB and ns.API.GetDB()
if not charDB then return nil end
charDB.cooldownReminder = charDB.cooldownReminder or {}
return charDB.cooldownReminder
end
-- "default" (whatever the player picked in WoW's TTS options) / "male" / "female".
function ns.Sounds.GetTTSVoiceID()
local cfg = ns.Sounds.GetTTSConfig()
local override = cfg and cfg.ttsVoiceOverride
-- Cooldown Reminder owns the name-matching resolver; reuse it so both
-- features can never disagree about which voice "Female" means.
if ns.CooldownReminder and ns.CooldownReminder.ResolveTTSVoiceID then
return ns.CooldownReminder.ResolveTTSVoiceID(override) or 0
end
if TextToSpeech_GetSelectedVoice and Enum and Enum.TtsVoiceType then
local v = TextToSpeech_GetSelectedVoice(Enum.TtsVoiceType.Standard)
if v and v.voiceID then return v.voiceID end
end
return 0
end
-- nil rate override = use WoW's own speech rate.
function ns.Sounds.GetTTSRate()
local cfg = ns.Sounds.GetTTSConfig()
if cfg and cfg.ttsRateOverride ~= nil then
return tonumber(cfg.ttsRateOverride) or 0
end
if C_TTSSettings and C_TTSSettings.GetSpeechRate then
return C_TTSSettings.GetSpeechRate() or 0
end
return 0
end
-- ── WoW's OWN text-to-speech settings ──────────────────────────────
-- C_TTSSettings writes are unprotected, so ArcUI can surface the handful of
-- game settings that decide how our speech sounds instead of sending people
-- hunting through the chat config. These are GLOBAL game settings (per
-- character while "Character Specific Settings" is ticked), shared with
-- Blizzard's chat narration -- always read them live, never cache.
-- The blip WoW plays when ANY speech finishes (its own "Play a sound between
-- each new message" option). It fires for addon speech too, which is the tick
-- heard after every ArcUI alert.
function ns.Sounds.GetLineBreakSound()
if not (C_TTSSettings and C_TTSSettings.GetSetting and Enum and Enum.TtsBoolSetting) then
return false
end
return C_TTSSettings.GetSetting(Enum.TtsBoolSetting.PlaySoundSeparatingChatLineBreaks) and true or false
end
function ns.Sounds.SetLineBreakSound(enabled)
if not (C_TTSSettings and C_TTSSettings.SetSetting and Enum and Enum.TtsBoolSetting) then return end
C_TTSSettings.SetSetting(Enum.TtsBoolSetting.PlaySoundSeparatingChatLineBreaks, enabled and true or false)
end
function ns.Sounds.GetSpeechVolume()
if C_TTSSettings and C_TTSSettings.GetSpeechVolume then
return C_TTSSettings.GetSpeechVolume() or 100
end
return 100
end
function ns.Sounds.SetSpeechVolume(v)
if C_TTSSettings and C_TTSSettings.SetSpeechVolume then
C_TTSSettings.SetSpeechVolume(v)
end
end
-- Opens Blizzard's own Text to Speech panel (chat config, TTS tab).
function ns.Sounds.OpenBlizzardTTSOptions()
if ToggleTextToSpeechFrame then ToggleTextToSpeechFrame() end
end
-- Text-to-speech wrapper
function ns.Sounds.SpeakText(text, voiceID, rate)
if not text or text == "" then return end
if not C_VoiceChat or not C_VoiceChat.SpeakText then return end
-- SpeakText(voiceID, text, rate, volume, overlap): rate 0 = normal speed,
-- overlap is a BOOL. (Was passing Enum.TtsVoiceType.Standard as the rate
-- and 100 as the overlap flag — Cooldown Reminder had it right.)
-- Volume follows the player's own TTS slider instead of a hardcoded 100.
local v = voiceID
if v == nil then v = ns.Sounds.GetTTSVoiceID() end
local r = rate
if r == nil then r = ns.Sounds.GetTTSRate() end
local volume = (C_TTSSettings and C_TTSSettings.GetSpeechVolume
and C_TTSSettings.GetSpeechVolume()) or 100
C_VoiceChat.SpeakText(v or 0, text, r or 0, volume, false)
end
-- Get dropdown values for sound selection
-- Returns: { ["lsm:soundName"] = "Sound Name", ["soundkit:123"] = "Built-in Name", ... }
function ns.Sounds.GetSoundDropdown()
local sounds = {}
-- Add LSM sounds
if LSM then
local lsmSounds = LSM:List("sound")
for _, soundName in ipairs(lsmSounds) do
sounds["lsm:" .. soundName] = soundName
end
end
-- Add built-in SoundKit sounds
for id, name in pairs(ns.Sounds.builtInSounds) do
sounds["soundkit:" .. id] = name .. " (Built-in)"
end
return sounds
end
-- Parse a dropdown key into a settings table
function ns.Sounds.ParseSoundKey(key)
if not key then return nil end
if key:match("^lsm:") then
return { soundType = "lsm", lsmSound = key:gsub("^lsm:", "") }
elseif key:match("^soundkit:") then
-- extra parens: gsub's second return (count) must not become tonumber's base
return { soundType = "soundkit", soundKitID = tonumber((key:gsub("^soundkit:", ""))) }
end
return nil
end
-- Create a dropdown key from a settings table
function ns.Sounds.CreateSoundKey(settings)
if not settings then return nil end
if settings.soundType == "lsm" and settings.lsmSound then
return "lsm:" .. settings.lsmSound
elseif settings.soundType == "soundkit" and settings.soundKitID then
return "soundkit:" .. settings.soundKitID
end
return nil
end
-- ===================================================================
-- CUSTOM EDITBOX WIDGET WITHOUT OK BUTTON
-- ===================================================================
local AceGUI = LibStub and LibStub("AceGUI-3.0", true)
if AceGUI then
local Type = "ArcUI_EditBox"
local Version = 3
local function Constructor()
local widget = AceGUI:Create("EditBox")
local originalOnAcquire = widget.OnAcquire
widget.OnAcquire = function(self)
if originalOnAcquire then originalOnAcquire(self) end
self:DisableButton(true)
if self.editbox then self.editbox:SetJustifyH("CENTER") end
end
widget:DisableButton(true)
if widget.editbox then widget.editbox:SetJustifyH("CENTER") end
return widget
end
AceGUI:RegisterWidgetType(Type, Constructor, Version)
end
-- ===================================================================
-- FORWARD DECLARATIONS
-- ===================================================================
local UpdateAllBars
local UpdateBarBuffInfo
local hookedAuraFrames = {} -- [frame] = { barNumbers = {barNum = true} }
-- Per-bar set of bar numbers that have opted into high-frequency SetAuraInstanceInfo updates.
-- Stored separately so the hook can check cheaply without reading barConfig on every fire.
local highFreqBars = {} -- [barNumber] = true
local totemBarNumbers = {} -- [barNum] = true, for PLAYER_TOTEM_UPDATE
-- ===================================================================
-- v3.7.2 AURA-ENGINE REWORK: auraInstanceID → bars reverse map (per unit)
-- ===================================================================
-- CDM frames remain the source of truth for WHICH aura is on a tracked spell
-- (the OnAuraInstanceInfoSet hook / RefreshData / bind all drive an immediate
-- UpdateBarBuffInfo). This map exists so the single UNIT_AURA consumer can
-- refresh ONLY the bars whose specific aura instance changed (O(changed)),
-- instead of every bar bound to a frame.
--
-- SINGLE WRITER: UpdateBarBuffInfo maps the exact instance each bar resolved to
-- DISPLAY (frame-current, cached, or trackedSpellID cross-spell) via
-- SetBarAuraMapping. This is authoritative — it doesn't matter how the bar
-- found its aura, only what it's showing. Per-bar (not per-frame) keying means
-- two bars on one frame, or a bar showing a cached sibling instance, are all
-- tracked correctly.
--
-- Secret auraInstanceIDs (can occur in instances) cannot be used as table keys,
-- so those bars are bucketed in secretAuraBars and refreshed on any UNIT_AURA
-- for their unit — a rare, correctness-preserving fallback.
local auraEntries = { player = {}, target = {} } -- [unit][auraInstanceID] = { [barNum] = true }
local secretAuraBars = { player = {}, target = {} } -- [unit] = { [barNum] = true }
-- Record (or, with nil aiid, clear) the aura instance a bar is currently
-- displaying. Removes any previous mapping for the bar first, so it's safe to
-- call every UpdateBarBuffInfo. `state` is the bar's barStates entry (carries
-- the previous mapping so cleanup is O(1)).
local function SetBarAuraMapping(barNumber, state, unit, aiid)
local valid = (unit == "player" or unit == "target") and HasAuraInstanceID(aiid)
local isSecret = false
if valid and issecretvalue and issecretvalue(aiid) then isSecret = true end
local pu, pa, ps = state._mapUnit, state._mapAiid, state._mapSecret
-- Is the new mapping the same as the previous one? Compare SECRET-SAFELY:
-- only do a numeric == when BOTH ids are plain (non-secret) numbers; never
-- compare a secret against a number (that yields a secret boolean → throws).
local same = false
if valid and pu == unit then
if isSecret and ps then
same = true -- both secret on the same unit: leave as-is
elseif (not isSecret) and (not ps) and pa ~= nil then
same = (pa == aiid) -- both plain numbers: safe to compare
end
end
if same then return end
-- Remove the previous mapping, if any.
if pu then
if ps then
if secretAuraBars[pu] then secretAuraBars[pu][barNumber] = nil end
elseif pa ~= nil then
local b = auraEntries[pu] and auraEntries[pu][pa]
if b then
b[barNumber] = nil
if not next(b) then auraEntries[pu][pa] = nil end
end
end
state._mapUnit, state._mapAiid, state._mapSecret = nil, nil, nil
end
if not valid then return end
if isSecret then
secretAuraBars[unit][barNumber] = true
state._mapUnit, state._mapAiid, state._mapSecret = unit, nil, true
else
local bucket = auraEntries[unit][aiid]
if not bucket then bucket = {}; auraEntries[unit][aiid] = bucket end
bucket[barNumber] = true
state._mapUnit, state._mapAiid, state._mapSecret = unit, aiid, nil
end
end
-- Hook CDM frame's OnAuraInstanceInfoSet / OnAuraInstanceInfoCleared / RefreshData
-- for direct bar updates. Only hooks once per frame. Live stack/duration changes
-- on an already-shown aura are handled by the single UNIT_AURA consumer
-- (HandleUnitAura) via the reverse map above — NOT by per-frame hooks.
-- When aura gained → update bar
-- When aura stack/duration updated → update bar
-- When aura lost → unregister + update bar to hide
-- When totem updates → update bar (totemData already current at hook time)
local function HookCDMFrameForAuraMap(frame, barNumber)
if not frame then return end
if not hookedAuraFrames[frame] then
hookedAuraFrames[frame] = { barNumbers = {} }
-- OnAuraInstanceInfoSet: fires on real aura gained (player buffs/target
-- debuffs) — binds WHICH auraInstanceID is on this frame and shows the bar.
if frame.OnAuraInstanceInfoSet then
hooksecurefunc(frame, "OnAuraInstanceInfoSet", function(self)
local hookData = hookedAuraFrames[self]
if not hookData or not next(hookData.barNumbers) then return end
self._arcAuraActive = true
-- Bump the aura token. A Set arriving after a Cleared (same UNIT_AURA
-- batch: removed pass fires Cleared, added pass fires Set, OR a full CDM
-- layout refresh re-populates auraInstanceID) means the buff is present
-- again — so any deferred "cleared" hide that captured an older token
-- must abort instead of hiding a still-active aura.
self._arcAuraToken = (self._arcAuraToken or 0) + 1
-- Refresh drives UpdateBarBuffInfo, which (re)maps each bar's resolved
-- aura instance into the reverse map for UNIT_AURA targeting.
for barNum in pairs(hookData.barNumbers) do
UpdateBarBuffInfo(barNum)
end
end)
end
-- OnAuraInstanceInfoCleared: fires on real aura lost (player buffs).
if frame.OnAuraInstanceInfoCleared then
hooksecurefunc(frame, "OnAuraInstanceInfoCleared", function(self)
local hookData = hookedAuraFrames[self]
if not hookData or not next(hookData.barNumbers) then return end
self._arcAuraActive = false
-- Capture the token at schedule time. If OnAuraInstanceInfoSet fires
-- before this deferred update runs (same-batch remove+add, or a CDM full
-- layout refresh re-setting the aura), the token will have advanced and
-- we abort — the buff is back, so the bar should NOT process a hide.
local tokenAtSchedule = self._arcAuraToken or 0
local bars = {}
for barNum in pairs(hookData.barNumbers) do bars[barNum] = true end
C_Timer.After(0, function()
if (self._arcAuraToken or 0) ~= tokenAtSchedule then return end
for barNum in pairs(bars) do
UpdateBarBuffInfo(barNum)
end
end)
end)
end
-- NOTE (v3.7.2 rework): the per-frame OnNewTarget and OnUnitAuraUpdatedEvent
-- hooks were removed here. Live stack/duration changes on an already-shown
-- aura — for BOTH player buffs and target debuffs — are now driven by the
-- single UNIT_AURA("player","target") consumer (HandleUnitAura) through the
-- auraInstanceID→bars reverse map, so only the bars whose aura actually
-- changed get refreshed (was: every bar bound to the frame, fired several
-- times per UNIT_AURA batch). RefreshData (below) stays for the same-id
-- duration-extend case that fires no UNIT_AURA id.
-- RefreshData: the ONE signal that fires on a same-instance-ID aura refresh.
-- CDM's SetAuraInstanceInfo only fires OnAuraInstanceInfoSet when the auraInstanceID
-- or spellID CHANGES (CooldownViewerItemData.lua:230). A buff reapplied with the same
-- instance ID (duration extended, ID unchanged) fires NEITHER Set NOR a guaranteed
-- UpdatedEvent — so the bar never re-pushes the new duration and drains to the old
-- expiration. RefreshData runs on every refresh including this case. Coalesce per tick
-- (CDM calls RefreshData several times per UNIT_AURA batch) and only act when the frame
-- currently holds a valid aura instance, so this is a no-op on inactive frames.
if frame.RefreshData then
hooksecurefunc(frame, "RefreshData", function(self)
local hookData = hookedAuraFrames[self]
if not hookData or not next(hookData.barNumbers) then return end
if not HasAuraInstanceID(self.auraInstanceID) then return end
if self._arcRefreshPending then return end
self._arcRefreshPending = true
local bars = {}
for barNum in pairs(hookData.barNumbers) do bars[barNum] = true end
C_Timer.After(0, function()
self._arcRefreshPending = false
for barNum in pairs(bars) do
UpdateBarBuffInfo(barNum)
end
end)
end)
end
-- SetAuraInstanceInfo: HIGH FREQUENCY — fires on every CDM aura refresh (~50-70x/session).
-- Opt-in only via cfg.tracking.highFrequencyUpdates. Disabled by default.
-- Only calls UpdateBarBuffInfo for bars that have the flag enabled.
if frame.SetAuraInstanceInfo then
hooksecurefunc(frame, "SetAuraInstanceInfo", function(self)
local hookData = hookedAuraFrames[self]
if not hookData then return end
for barNum in pairs(hookData.barNumbers) do
if highFreqBars[barNum] then
UpdateBarBuffInfo(barNum)
end
end
end)
end
-- OnPlayerTotemUpdateEvent: totem gained/lost/refreshed.
if frame.OnPlayerTotemUpdateEvent then
hooksecurefunc(frame, "OnPlayerTotemUpdateEvent", function(self)
local hookData = hookedAuraFrames[self]
if not hookData or not next(hookData.barNumbers) then return end
for barNum in pairs(hookData.barNumbers) do
UpdateBarBuffInfo(barNum)
end
end)
end
-- Cooldown widget OnCooldownDone: per Blizzard's own line-712 comment in
-- CooldownViewer.lua: "No external event is dispatched when a totem
-- finishes". CDM uses the cooldown widget's OnCooldownDone as its
-- internal totem-expiry signal — when totem duration runs out,
-- OnCooldownDone fires, then CDM's mixin handler clears totemData and
-- runs RefreshData. Without hooking this, bars wait ~350ms for the
-- followup OnPlayerTotemUpdateEvent (confirmed by probe: totem expired
-- at 230.036s → CDDone fired immediately, but TotemUpd fired only at
-- 230.382s). HookScript chains additively after Blizzard's SetScript.
-- Defer one tick so CDM's mixin handler clears totemData before our
-- UpdateBarBuffInfo reads frame.totemData.
if frame.Cooldown then
frame.Cooldown:HookScript("OnCooldownDone", function()
local hookData = hookedAuraFrames[frame]
if not hookData or not next(hookData.barNumbers) then return end
local bars = {}
for barNum in pairs(hookData.barNumbers) do bars[barNum] = true end
C_Timer.After(0, function()
for barNum in pairs(bars) do
UpdateBarBuffInfo(barNum)
end
end)
end)
end
end
-- Register this bar as using this frame. The reverse map is populated by
-- UpdateBarBuffInfo (the single writer) on the bar's next update, which the
-- caller triggers right after binding.
hookedAuraFrames[frame].barNumbers[barNumber] = true
end
-- Unregister a bar from a frame's aura hooks
local function UnhookBarFromAuraFrame(frame, barNumber)
if not frame or not hookedAuraFrames[frame] then return end
hookedAuraFrames[frame].barNumbers[barNumber] = nil
end
-- Clear all bar registrations on spec change
-- (hooksecurefunc hooks persist but become no-ops with empty barNumbers)
local function ClearAllAuraHookRegistrations()
for frame, data in pairs(hookedAuraFrames) do
wipe(data.barNumbers)
end
wipe(totemBarNumbers)
wipe(highFreqBars)
-- Reverse map is rebuilt as bars re-resolve; barStates (with _map* fields)
-- are wiped by the spec-change handler, so just clear the shared tables.
for _, t in pairs(auraEntries) do wipe(t) end
for _, t in pairs(secretAuraBars) do wipe(t) end
end
-- Register frame hooks appropriate for the bar's track type
local function RegisterBarFrameHooks(frame, barNumber, trackType)
if not frame then return end
-- Sync highFreqBars for this bar based on its current config flag
local barCfg = ns.API.GetBarConfig and ns.API.GetBarConfig(barNumber)
if barCfg and barCfg.tracking and barCfg.tracking.highFrequencyUpdates then
highFreqBars[barNumber] = true
else
highFreqBars[barNumber] = nil
end
if trackType == "pet" or trackType == "totem" or trackType == "ground" then
-- Totem/pet/ground: tracked via PLAYER_TOTEM_UPDATE event
totemBarNumbers[barNumber] = true
-- Also hook SetAuraInstanceInfo in case CDM sets aura data on totem frames
HookCDMFrameForAuraMap(frame, barNumber)
else
-- Buff (default) AND Debuff: hook CDM frame directly for aura updates.
HookCDMFrameForAuraMap(frame, barNumber)
end
end
-- ===================================================================
-- STATE VARIABLES
-- ===================================================================
local barStates = {}
local function GetBarState(barNumber)
if not barStates[barNumber] then
barStates[barNumber] = {
cooldownID = nil,
cachedFrame = nil,
cachedBarFrame = nil,
stacks = 0,
active = false,
trackingOK = false
}
end
return barStates[barNumber]
end
-- Forward declaration for ClearBarState (needs AllowCDMFrameVisible which is defined later)
local ClearBarState
-- ===================================================================
-- CROSS-SPEC COOLDOWNID RESOLUTION SYSTEM
-- Handles bars that need to work across multiple specs where the
-- same spell has different cooldownIDs per spec
-- ===================================================================
-- Cache for spellID → cooldownID mapping (rebuilt on spec change)
local spellToCooldownIDCache = nil
local spellToCooldownIDCacheSpec = nil -- Track which spec the cache was built for
-- Build mapping of spellID → cooldownID for current spec
-- Scans all CDM categories to find what cooldownIDs are available
local function BuildSpellToCooldownIDMapping()
local mapping = {}
if not C_CooldownViewer or not C_CooldownViewer.GetCooldownViewerCategorySet then
return mapping
end
-- Scan all aura categories (TrackedBuff=2, TrackedBar=3)
-- We only care about auras for this cross-spec feature
local auraCategories = {2, 3} -- Enum.CooldownViewerCategory.TrackedBuff, TrackedBar
for _, category in ipairs(auraCategories) do
local cooldownIDs = C_CooldownViewer.GetCooldownViewerCategorySet(category, true) -- allowUnlearned=true
if cooldownIDs then
for _, cdID in ipairs(cooldownIDs) do
local info = C_CooldownViewer.GetCooldownViewerCooldownInfo(cdID)
local infoSpellID = info and NonSecretSpellID(info.spellID)
if infoSpellID and infoSpellID > 0 then
-- Store mapping: spellID → ORDERED LIST of cooldownIDs. One spellID can
-- legitimately produce several (the same aura listed under both
-- TrackedBuff and TrackedBar, ranks, variants), and keeping only the
-- first meant a bar whose sourceType is "bar" could resolve to the icon
-- entry and fail validation anyway. Callers walk the list and take the
-- first one that actually has a live frame.
local function addTo(sid)
sid = NonSecretSpellID(sid)
if not sid or sid <= 0 then return end
local list = mapping[sid]
if not list then
list = {}
mapping[sid] = list
end
for _, existing in ipairs(list) do
if existing == cdID then return end
end
table.insert(list, cdID)
end
-- Index the FULL identity set Blizzard gives an entry, not just the base
-- cast spell: the buff a bar tracks frequently lives on an override or a
-- linked id, and a bar bound in another spec may have stored one of
-- those instead of the base.
addTo(info.spellID)
addTo(info.overrideSpellID)
addTo(info.overrideTooltipSpellID)
if info.linkedSpellIDs then
for _, linkedSpellID in ipairs(info.linkedSpellIDs) do
addTo(linkedSpellID)
end
end
end
end
end
end
return mapping
end
-- Get or rebuild the spellID → cooldownID cache
local function GetSpellToCooldownIDMapping()
local currentSpec = GetSpecialization() or 0
-- Rebuild cache if spec changed or cache is empty
if not spellToCooldownIDCache or spellToCooldownIDCacheSpec ~= currentSpec then
spellToCooldownIDCache = BuildSpellToCooldownIDMapping()
spellToCooldownIDCacheSpec = currentSpec
if ns.devMode then
local count = 0
for _ in pairs(spellToCooldownIDCache) do count = count + 1 end
print(string.format("|cff00FF00[ArcUI]|r Built spellID→cooldownID mapping: %d entries for spec %d", count, currentSpec))
end
end
return spellToCooldownIDCache
end
-- Invalidate the cache (call on spec change)
local function InvalidateSpellToCooldownIDCache()
spellToCooldownIDCache = nil
spellToCooldownIDCacheSpec = nil
end
-- Find every cooldownID a spellID maps to on the current spec (ordered)
local function FindAllCooldownIDsForSpellID(spellID)
if not spellID or spellID <= 0 then return nil end
local mapping = GetSpellToCooldownIDMapping()
return mapping[spellID]
end
-- Find a cooldownID for a spellID on current spec (first match)
local function FindCooldownIDForSpellID(spellID)
local list = FindAllCooldownIDsForSpellID(spellID)
return list and list[1]
end
-- Get the active (working) cooldownID for a bar
-- Tries: primary cooldownID → alternateCooldownIDs → auto-discover via spellID
-- Returns: cooldownID, sourceType ("primary", "alternate", "discovered", or nil)
function ns.API.GetActiveCooldownIDForBar(barNum, validCooldownIDs)
local barConfig = ns.API.GetBarConfig(barNum)
if not barConfig or not barConfig.tracking then return nil, nil end
local tracking = barConfig.tracking
local trackType = tracking.trackType
-- If validCooldownIDs not provided, check bar state for what's currently active
-- This is used by UI to display which cooldownID is currently working
if not validCooldownIDs then
local state = barStates[barNum]
if state and state.trackingOK and state.cooldownID then
-- Determine if it's primary or alternate
if state.cooldownID == tracking.cooldownID then
return state.cooldownID, "primary"
elseif tracking.alternateCooldownIDs then
for _, altCdID in ipairs(tracking.alternateCooldownIDs) do
if state.cooldownID == altCdID then
return state.cooldownID, "alternate"
end
end
end
return state.cooldownID, "discovered"
end
return nil, nil
end
-- Helper to check if a cooldownID has a valid frame
-- Uses validCooldownIDs which is built by scanning all viewers + CDMGroups containers
local function hasValidFrame(cdID)
if not cdID or (type(cdID) == "number" and cdID <= 0) then return false end
-- Check validCooldownIDs map (built by ValidateAllBarTracking's initial scan)
if validCooldownIDs and validCooldownIDs[cdID] then
return true
end
return false
end
-- 1. Try primary cooldownID
if hasValidFrame(tracking.cooldownID) then
return tracking.cooldownID, "primary"
end
-- 2. Try alternate cooldownIDs
if tracking.alternateCooldownIDs then
for _, altCdID in ipairs(tracking.alternateCooldownIDs) do
if hasValidFrame(altCdID) then
return altCdID, "alternate"
end
end
end
-- 3. Cross-spec resolution. The SAME spell carries a DIFFERENT cooldownID in
-- each spec, so a bar bound while in one spec (its id is stored as primary)
-- reads "Tracking Failed" in every other spec the user ticked under Show On
-- Specs -- the reported Alter Time / Ice Cold case. Ask the current spec's
-- spellID→cooldownID mapping (rebuilt on PLAYER_SPECIALIZATION_CHANGED, and it
-- indexes linkedSpellIDs too) for this bar's spell right here.
--
-- This is RESOLUTION ONLY: it returns an id for this spec and never writes to
-- alternateCooldownIDs. That is why the old auto-discover was removed -- it
-- silently accumulated ids into the config -- so adding is still the explicit
-- DiscoverAlternateCooldownID button. Ids the user removed stay excluded, and
-- the result must still have a live frame (hasValidFrame), so an unlearned
-- entry from the allowUnlearned scan can never bind.
local function isExcluded(cdID)
if not tracking.excludedCooldownIDs then return false end
for _, exID in ipairs(tracking.excludedCooldownIDs) do
if exID == cdID then return true end
end
return false
end
local spellCandidates, seenCandidate = {}, {}
local function addCandidate(sid)
-- addSpellIDsOf feeds this straight from cooldownInfo, so in an instance sid
-- can be SECRET: it would throw on the compare AND on the seenCandidate index.
sid = NonSecretSpellID(sid)
if not sid or sid <= 0 or seenCandidate[sid] then return end
seenCandidate[sid] = true
table.insert(spellCandidates, sid)
end
addCandidate(tracking.spellID)
addCandidate(tracking.trackedSpellID)
-- Widen the net with the spell IDs the ORIGINAL binding stands for. The stored
-- id belongs to the spec the bar was made in, but cooldownInfo is static data
-- and still resolves, and its spellID / overrides / linked set are the very ids
-- THIS spec's entry is indexed under. That covers the case where the two specs'
-- entries disagree about which id is the "base" spell.
local function addSpellIDsOf(cdID)
if type(cdID) ~= "number" or cdID <= 0 then return end
if not (C_CooldownViewer and C_CooldownViewer.GetCooldownViewerCooldownInfo) then return end
local info = C_CooldownViewer.GetCooldownViewerCooldownInfo(cdID)
if not info then return end
addCandidate(info.spellID)
addCandidate(info.overrideSpellID)
addCandidate(info.overrideTooltipSpellID)
if info.linkedSpellIDs then
for _, linked in ipairs(info.linkedSpellIDs) do addCandidate(linked) end
end
end
addSpellIDsOf(tracking.cooldownID)
if tracking.alternateCooldownIDs then
for _, altCdID in ipairs(tracking.alternateCooldownIDs) do addSpellIDsOf(altCdID) end
end
-- Prefer a candidate whose live frame matches how this bar reads its source
-- (icon vs bar). The caller rejects a mismatch outright, so handing back an
-- icon-only id for a bar-sourced config would just fail validation one step
-- later; only fall back to a mismatched id when nothing better exists.
local wantSourceType = tracking.sourceType or "icon"
local fallbackResolved
for _, sid in ipairs(spellCandidates) do
for _, resolved in ipairs(FindAllCooldownIDsForSpellID(sid) or {}) do
if not isExcluded(resolved) and hasValidFrame(resolved) then
local kind = validCooldownIDs[resolved]
if kind == "both" or kind == wantSourceType then
return resolved, "discovered"
end
fallbackResolved = fallbackResolved or resolved
end
end
end
if fallbackResolved then
return fallbackResolved, "discovered"
end
-- No valid cooldownID found
return nil, nil
end
-- Manually add a cooldownID to a bar's alternate list
function ns.API.AddAlternateCooldownID(barNum, cooldownID)
local barConfig = ns.API.GetBarConfig(barNum)
if not barConfig or not barConfig.tracking then return false, "Invalid bar" end
if not cooldownID or type(cooldownID) ~= "number" or cooldownID <= 0 then
return false, "Invalid cooldownID"
end
-- Initialize if needed
if not barConfig.tracking.alternateCooldownIDs then
barConfig.tracking.alternateCooldownIDs = {}
end
-- Check if already exists (in primary or alternates)
if barConfig.tracking.cooldownID == cooldownID then
return false, "Already the primary cooldownID"
end
for _, existingCdID in ipairs(barConfig.tracking.alternateCooldownIDs) do
if existingCdID == cooldownID then
return false, "Already in alternate list"
end
end
-- Add it
table.insert(barConfig.tracking.alternateCooldownIDs, cooldownID)
-- Re-validate tracking
if ns.API.ValidateAllBarTracking then
ns.API.ValidateAllBarTracking()
end
return true, string.format("Added cooldownID %d to bar %d", cooldownID, barNum)