Repository navigation
Expand file tree
/
Copy pathCore.lua
More file actions
3512 lines (3346 loc) · 153 KB
/
Copy pathCore.lua
File metadata and controls
3512 lines (3346 loc) · 153 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
-- CastAhead: predicts when a trash mob will cast next, and names the cast.
--
-- In 12.1 enemy identity and cast info are Secret: no GUID, no unit name, no
-- spell id, no cast duration from the API. What is left is the bare
-- UNIT_SPELLCAST_* events on a nameplate unit, UnitAffectingCombat, and
-- GetTime(). So a cast is identified by how long it took, matched against a
-- table harvested from combat logs (Data.lua), and the schedule runs on dead
-- reckoning from there.
--
-- Identification narrows in three steps:
-- engage: time from entering combat to the first cast (row.first)
-- 1st cast: candidates = spells in this dungeon with a matching cast time
-- 2nd cast: the measured interval picks one of them, and pins the rotation
-- A measured interval always beats the table - the mob is the authority on its
-- own cooldown.
--
-- Each cast length gets its own schedule. A mob with two abilities alternates
-- between them, so a single per-nameplate timer would measure the gap between
-- *different* spells and predict nonsense.
local ICON_SIZE = 26
local BAR_GAP = 6 -- distance from the plate's left edge
local LABEL_HEIGHT = 11 -- one line of advice under the icon; two are measured
local BAR_SPACING = 5 -- gap between one row's label and the next icon
local H_STEP = 68 -- fallback sideways step, before any label is measured
local LABEL_GAP = 8 -- clear space between one icon's label and the next
local ICON_GAP = 4 -- ... and the floor, when both labels are short
local DEMO_PER_PLATE = 3 -- calls the test drive puts on each nameplate
local DEMO_PLATES = 4 -- ... and how many nameplates it uses at all
local COMBAT_POLL_BATCH = 8 -- nameplates re-checked per tick
local PREDICTION_MATCH_WINDOW = 4.0 -- how far off a predicted cast may start
local ESTIMATE_NOW = 2.0 -- an estimate this close just reads as "now"
local STALE_PREDICTION = 60.0 -- an overdue cast this old is abandoned
local THIN_EVIDENCE = 5 -- rows backed by less are always estimates
local COMBAT_POLL_INTERVAL = 0.1
local MAX_NAMEPLATES = 40
-- Declared up front because the bar code above needs them: Lua resolves an
-- undeclared local as a global, which is nil at call time.
local RefreshBar
local GetTrack, NewTrack, SpellTrack
local FinishHeld
local recentCasts
local StartPreview
local RefineByNPC
local ResolvedNPCs, LockedNPCs
local Probe, ProbeCast, ProbeAnchor, ProbeRestore, ProbeCall -- /ca probe, defined next to Debug; the event handler calls them
local Identify
local LockNPC
local diag = { plates = 0, casts = 0, identified = 0, shown = 0, published = 0 }
local function Record(kind, slot, ...)
local recorder = CastAheadRecorder
if recorder and recorder.Enabled() then recorder.Note(kind, slot, ...) end
end
local function Ids(rows)
local ids = {}
for i = 1, #(rows or {}) do ids[i] = rows[i].spell end
return ids
end
local plates = {} -- unit -> state
local dungeon = nil -- rows for the current instance
local traitRows = nil -- creature identity traits for the current instance
local pollIndex = 1
local pollAt = 0
local frame = CreateFrame("Frame")
-- Data --------------------------------------------------------------------
local byNPC = {}
-- Folds the curated priority set into the data once: every row learns its
-- category, and prioritised casts our logs have not captured yet are
-- appended with provisional timings. Runs over all dungeons, not just the
-- current one, because the /castahead window browses any of them.
local extrasMerged = false
local function MergeCuration()
if extrasMerged or not CastAheadData then return end
extrasMerged = true
for instanceID, rows in pairs(CastAheadData) do
local extras = CastAheadExtra and CastAheadExtra[instanceID]
if extras then
for e = 1, #extras do
local extra = extras[e]
local present = false
for i = 1, #rows do
if rows[i].spell == extra.spell and rows[i].npc == extra.npc then
present = true
break
end
end
if not present then table.insert(rows, extra) end
end
end
local learned = CastAheadDB and CastAheadDB.dispel
for i = 1, #rows do
local spell = rows[i].spell
rows[i].prio = CastAheadPriority and CastAheadPriority[spell] or nil
rows[i].dispel = learned and learned[spell] or nil
rows[i].save = CastAheadMatch.SaveRow(spell)
end
end
end
-- Matcher switches the replay harness can flip to measure a rule against real
-- fights. Defaults are the shipped behaviour, each settled by that harness:
-- - traitsNarrow on: switching it off cost 4-5 points of correct calls on
-- both our own and public logs and removed no wrong ones;
-- - trustUnsureTrack off: a single candidate that was never confirmed used
-- to be inherited by every later cast of the same length on that plate,
-- so one guess became a run of wrong calls ("never: track" was the single
-- largest source of them). Re-identifying each cast cut wrong calls by a
-- quarter for two points of correct ones, which the wider opening
-- tolerance in Match.lua then won back.
local tuning = {
traitsNarrow = true, -- let the trait-derived creature set pick among candidates
trustUnsureTrack = false, -- a single unconfirmed candidate is re-identified, not inherited
-- - population off: MDT counts the creatures it places, not the ones a
-- dungeon spawns (Kings' Rest lists two Interment Constructs and the
-- waves bring more), so "every one is dead" came true early and the
-- real creature was excluded - 20 wrong Entomb calls in our own runs,
-- and fewer right calls on both sources. Stays off until a spawn
-- table exists; the counting itself is sound and stays in.
-- - packsNarrow / packsCompany off: preferring a locked neighbour's
-- packmates buys about one wrong call per extra right one (own logs
-- +34 right / +24 wrong, public +44 / +45), and turns cross-creature
-- ties into same-creature ties, which nothing here can settle. A
-- wrong call costs more than silence, so neither is on by default.
population = false, -- a creature seen dying as many times as MDT places it is out
packsNarrow = false, -- a locked neighbour's packmates are preferred among cast candidates
packsCompany = false, -- ... and among trait matches, instead of the trait rows' own `co`
-- - targetNarrow on: in a Murder Row key the game's answer matched the
-- log's cast target on every ordinary cast; channels did not, so their
-- rows only carry `targeted` once the game itself was seen answering.
-- Replay of our logs: 583 right / 67 wrong without it, 631 / 57 with.
targetNarrow = true, -- whether the cast has a target, read at its start, narrows candidates
observedBelowOnly = true, -- an observed interval replaces the tabled cooldown only when shorter
-- - claimWindow 2 and lateClaimMax 4: a start more than 2 s from its
-- prediction, or more than 4 s overdue, was a wrong call about as
-- often as a right one. Replays with game-read targets, voiced start
-- calls right/wrong: public 2129/341 -> 1992/124, own keys 149/19 ->
-- 142/6; completed-cast identification improved slightly.
-- - claimMargin 1: when two of one creature's predictions with different
-- calls are almost equally close to the start (their distances to it
-- differ by under 1 s - Devour and Dreadbellow, both untargeted), the
-- nearer one used to win. Public replay -13 right / -11 wrong, own
-- Voidscar keys -0 / -1.
claimWindow = 2.0, -- how far from its prediction a start may be claimed by it
lateClaimMax = 4.0, -- how long an overdue prediction may still claim a start
claimMargin = 1.0, -- a rival with another call almost as close to the start leaves it unclaimed
firstChannel = true, -- a plate's first channel is the only channel its creatures have
}
-- Population: how many of each creature MDT places in this dungeon (Packs.lua,
-- keyed by the dungeon's English name), how many of each we have watched die,
-- and which kinds are therefore gone. A key restarts the count; a /reload does
-- not, so the count only ever lags behind the truth, never runs ahead of it.
local packs -- { total = { [npc] = n }, packs = { { [npc] = n }, ... } }
local killed = {}
local exhausted = {}
local countedInstance
local function ResetPopulation()
wipe(killed)
wipe(exhausted)
end
-- MDT and our table spell a name apart ("King's Rest" against "Kings' Rest"),
-- so the lookup ignores everything but letters and digits.
local function PacksFor(name)
if not (name and CastAheadPacks) then return nil end
local want = name:lower():gsub("[^%w]", "")
for key, data in pairs(CastAheadPacks) do
if key:lower():gsub("[^%w]", "") == want then return data end
end
return nil
end
local function LoadDungeon()
MergeCuration()
local _, _, _, _, _, _, _, instanceID = GetInstanceInfo()
dungeon = IsInInstance() and instanceID and CastAheadData and CastAheadData[instanceID] or nil
traitRows = dungeon and CastAheadTraits and CastAheadTraits[instanceID] or nil
packs = dungeon and PacksFor(dungeon.name) or nil
if instanceID ~= countedInstance then
ResetPopulation()
countedInstance = instanceID
end
wipe(byNPC)
if dungeon then
for i = 1, #dungeon do
local row = dungeon[i]
-- Tolerate a gap rather than erroring out on the whole dungeon.
if row and row.npc then
byNPC[row.npc] = byNPC[row.npc] or {}
table.insert(byNPC[row.npc], row)
end
end
end
return dungeon ~= nil
end
-- UnitLevel carries no Secret marker in the docs, but a hostile unit can still
-- hand back something unusable (a secret value, or -1 for a skull-level mob),
-- and comparing a secret throws - which would kill the handler that tracks the
-- plate at all. Anything not a plain positive number is treated as "unknown".
local function SafeLevel(unit)
if type(UnitLevel) ~= "function" then return nil end
local ok, level = pcall(UnitLevel, unit)
if not ok then return nil end
if issecretvalue and issecretvalue(level) then return nil end
level = tonumber(level)
if not level or level <= 0 then return nil end
return level
end
-- One alert per kind of response, so the sound alone says what to press. Kept
-- here rather than in Match.lua, which stays free of WoW API so it can be
-- tested outside the game.
local ADVICE_SOUND = { -- keyed by CastAheadMatch.ADVICE key
KICK = "RAID_WARNING",
TANK = "ALARM_CLOCK_WARNING_3",
AOE = "ALARM_CLOCK_WARNING_2",
-- Not UI_RAID_BOSS_WHISPER_WARNING: DBM mutes that file (876098) with its
-- boss-emote option, and the STUN row went silent for every DBM user.
CC = "ALARM_CLOCK_WARNING_1",
-- Curated categories share one fallback beep; the voice line carries the
-- distinction when the combat TTS is available.
DODGE = "RAID_WARNING",
FRONTAL = "RAID_WARNING",
TARGET = "RAID_WARNING",
DISPEL = "RAID_WARNING",
POISON = "RAID_WARNING",
CURSE = "RAID_WARNING",
MAGIC = "RAID_WARNING",
SOOTHE = "RAID_WARNING",
PURGE = "RAID_WARNING",
DISEASE = "RAID_WARNING",
BLEED = "ALARM_CLOCK_WARNING_3",
SWITCH = "RAID_WARNING",
ALERT = "RAID_WARNING",
SMALL = "ALARM_CLOCK_WARNING_2",
BIG = "RAID_WARNING",
HEAL = "ALARM_CLOCK_WARNING_1",
SOON = "ALARM_CLOCK_WARNING_1",
BURST = "RAID_WARNING",
}
-- Default on: only casts in the curated priority set matter enough for icons,
-- voice and the timeline. Switching it off brings every measured spell back.
local function ImportantOnly()
return CastAheadConfig.Enabled("importantOnly")
end
local function Announceable(candidates)
return not ImportantOnly() or CastAheadMatch.AnyImportant(candidates)
end
-- Only a prediction this solid is worth putting on Blizzard's timeline: one
-- candidate, enough observations behind its time - a measured schedule for a
-- repeat cast, a measured opening for a first cast projected from the mob's
-- kit. Everything softer stays on the nameplate, where an estimate can be
-- shown as one.
local function ConfidentRow(track)
local candidates = track.candidates
if track.ambiguous or #candidates ~= 1 then return nil end
local row = candidates[1]
if row.filler or row.approx or (row.n or 0) < THIN_EVIDENCE then return nil end
if track.projected then
return CastAheadMatch.HasOpening(row) and row or nil
end
return CastAheadMatch.HasSchedule(row) and row or nil
end
-- A nil time cancels: unimportant casts stay off the timeline.
local function SyncTimeline(track)
if not (CastAheadTimeline and track and track.candidates) then return end
local row = ConfidentRow(track)
CastAheadTimeline.Sync(track, Announceable(track.candidates) and track.nextAt or nil,
row or track.candidates[1], CastAheadMatch.ConsensusAdvice(track.candidates), row ~= nil)
end
-- Role filter (default on): the priority set is trimmed to what the current
-- spec can act on. Off, or outside the game, every curated cast counts.
CastAheadMatch.PlayerRole = function()
if not CastAheadConfig.Enabled("roleFilter") then return nil end
if not (GetSpecialization and GetSpecializationRole) then return nil end
local spec = GetSpecialization()
return spec and GetSpecializationRole(spec) or nil
end
CastAheadMatch.SizeOn = function(key)
return CastAheadConfig.Enabled(key == "SMALL" and "smallCalls" or "bigCalls")
end
CastAheadMatch.SpecRole = function()
if not CastAheadConfig.SaveCalls() then return nil end
if not (GetSpecialization and GetSpecializationRole) then return nil end
local spec = GetSpecialization()
return spec and GetSpecializationRole(spec) or nil
end
-- Which abilities answer each dispel-school category. A category is shown
-- when the character knows any of them (IsPlayerSpell sees talents too), so a
-- dps druid still gets poison calls and a priest healer never does.
local DISPEL_SPELLS = {
-- Remove Corruption, Nature's Cure, Poison Cleansing Totem, Cleanse
-- Toxins, Cleanse, Detox (both), Naturalize, Expunge, Cauterizing Flame
POISON = { 2782, 88423, 383013, 213644, 4987, 115450, 218164, 360823, 365585, 374251 },
-- Remove Corruption, Nature's Cure, Remove Curse, Purify Spirit, Cleanse
-- Spirit, Cauterizing Flame
CURSE = { 2782, 88423, 475, 77130, 51886, 374251 },
-- Purify, Purify Disease, Cleanse, Cleanse Toxins, Detox (both),
-- Cauterizing Flame
DISEASE = { 527, 213634, 4987, 213644, 115450, 218164, 374251 },
-- Purify, Cleanse, Purify Spirit, Nature's Cure, Detox (MW), Naturalize,
-- Mass Dispel
MAGIC = { 527, 4987, 77130, 88423, 115450, 360823, 32375 },
-- Purge, Dispel Magic, Spellsteal, Consume Magic, Tranquilizing Shot
PURGE = { 370, 528, 30449, 278326, 19801 },
-- Soothe, Tranquilizing Shot
SOOTHE = { 2908, 19801 },
}
local capabilityCache = {}
local function KnowsAny(list)
for i = 1, #list do
if IsPlayerSpell(list[i]) then return true end
end
return false
end
CastAheadMatch.PlayerCanHandle = function(category)
if not CastAheadConfig.Enabled("roleFilter") then return true end
if not IsPlayerSpell then return true end
local known = capabilityCache[category]
if known == nil then
if category == "DISPEL" then
-- Type unknown: anyone who can remove anything from a friend.
known = KnowsAny(DISPEL_SPELLS.MAGIC) or KnowsAny(DISPEL_SPELLS.POISON)
or KnowsAny(DISPEL_SPELLS.CURSE) or KnowsAny(DISPEL_SPELLS.DISEASE)
else
known = DISPEL_SPELLS[category] == nil or KnowsAny(DISPEL_SPELLS[category])
end
capabilityCache[category] = known
end
return known
end
local function InvalidateCapabilities()
for k in pairs(capabilityCache) do capabilityCache[k] = nil end
end
-- Speaking the response beats any beep: "tank buster" says what to do, a chime
-- only says that something happened. 12.1 ships a combat alert voice, so no
-- sound files are needed; the beep stays as the fallback.
local function Speak(text)
if not text then return false end
local api = C_CombatAudioAlert
if not (api and api.SpeakText and Enum and Enum.CombatAudioAlertCategory) then
return false
end
-- The player can switch combat audio alerts off, or zero this category's
-- volume; SpeakText then silently does nothing (MayReturnNothing), and a
-- pcall that merely didn't error must not count as "was heard" - that
-- swallowed the beep fallback and left everything mute.
if api.IsEnabled and not api.IsEnabled() then return false end
local category = Enum.CombatAudioAlertCategory.TargetCast
if api.GetCategoryVolume then
local okVol, volume = pcall(api.GetCategoryVolume, category)
if okVol and tonumber(volume) and tonumber(volume) <= 0 then return false end
end
local ok, utteranceID = pcall(api.SpeakText, text, category, true)
return ok and utteranceID ~= nil
end
-- Our own recorded clips (Sounds/<lang>/<KEY>.ogg, rendered by tools/voice.py):
-- the call is heard whatever the player's game-TTS setting is, in one
-- consistent voice. `CastAheadDB.voiceTTS` prefers the game's voice instead.
local SOUND_ROOT = "Interface\\AddOns\\CastAhead\\Sounds\\"
local function PlayClip(advice, lead)
if not (PlaySoundFile and advice.file) then return false end
local lang = CastAheadDB and CastAheadDB.voiceLang or "en"
local path = SOUND_ROOT .. lang .. "\\" .. advice.file .. (lead and "_soon" or "") .. ".ogg"
local ok, willPlay = pcall(PlaySoundFile, path, CastAheadConfig.SoundChannel())
return ok and willPlay == true
end
-- Our clips join the shared sound list too, so they can be picked as the
-- "sound" of another category or by other addons.
do
local lsm = LibStub and LibStub("LibSharedMedia-3.0", true)
if lsm then
for key, advice in pairs(CastAheadMatch.ADVICE) do
if key ~= "SAVE" then
lsm:Register("sound", "Cast Ahead: " .. advice.say, SOUND_ROOT .. "en\\" .. advice.file .. ".ogg")
end
end
-- The game's own alert kits, so the picker has a spread of beeps even
-- with no sound pack installed. Stored as kit numbers; Beep plays those.
local GAME_SOUNDS = {
{ "Raid warning", "RAID_WARNING" },
{ "Alarm clock 1", "ALARM_CLOCK_WARNING_1" },
{ "Alarm clock 2", "ALARM_CLOCK_WARNING_2" },
{ "Alarm clock 3", "ALARM_CLOCK_WARNING_3" },
{ "Ready check", "READY_CHECK" },
{ "Raid boss emote", "RAID_BOSS_EMOTE_WARNING" },
{ "GM chat", "GM_CHAT_WARNING" },
{ "LFG role check", "LFG_ROLE_CHECK" },
{ "LFG denied", "LFG_DENIED" },
{ "PvP queue pop", "PVP_THROUGH_QUEUE" },
{ "Power aura", "UI_POWER_AURA_GENERIC" },
{ "Battle.net toast", "UI_BNET_TOAST" },
{ "Auto quest complete", "UI_AUTO_QUEST_COMPLETE" },
{ "Tutorial popup", "TUTORIAL_POPUP" },
}
for _, entry in ipairs(GAME_SOUNDS) do
local kit = SOUNDKIT and SOUNDKIT[entry[2]]
if kit then lsm:Register("sound", "Game: " .. entry[1], kit) end
end
end
end
-- Voice chain: our clip, else the game's combat voice, else a beep. Every
-- caller (in-run alerts, the lead warning, the window's speaker buttons, the
-- test drive) goes through here so they cannot disagree.
local ttsHintShown = false
local function Voice(advice, lead)
local text = lead and (advice.say .. " soon") or advice.say
if CastAheadDB and CastAheadDB.voiceTTS then
-- The player prefers the game's voice; the clip covers for it when it
-- cannot speak (alerts disabled in the game options) - and says so
-- once, or the switch looks like it does nothing.
if Speak(text) then return true end
if not ttsHintShown then
ttsHintShown = true
print("|cff33ff99Cast Ahead|r the game's TTS voice could not speak - enable Combat Audio Alerts"
.. " (Options > Accessibility) and give its categories volume. Playing the clip instead.")
end
return PlayClip(advice, lead)
end
return PlayClip(advice, lead) or Speak(text)
end
-- The player's own pick for this spell, else for this category, from
-- LibSharedMedia (every sound any installed addon registered); nil when left
-- on Voice. A spell pick counts only once the cast is that one spell.
local function SpellSound(candidates)
local picks = CastAheadDB and CastAheadDB.spellSounds
if not (picks and candidates) then return nil end
local name
for _, row in ipairs(candidates) do
local pick = picks[row.spell]
if not pick or pick == "" or (name and pick ~= name) then return nil end
name = pick
end
return name
end
local function CustomSound(advice, candidates)
local name = SpellSound(candidates)
or CastAheadDB and CastAheadDB.sounds and CastAheadDB.sounds[advice.key]
if not name or name == "" then return nil end
local lsm = LibStub and LibStub("LibSharedMedia-3.0", true)
return lsm and lsm:Fetch("sound", name, true) or nil
end
-- A file the game can play on its own for a boss event, where the addon is
-- never told the event happened: the player's pick when it is a file, else our clip.
local function AdviceFile(advice)
if not CastAheadConfig.Enabled("sound") then return nil end
local custom = CustomSound(advice)
if type(custom) == "string" then return custom end
if not (advice.file and CastAheadConfig.Enabled("voice")) then return nil end
local lang = CastAheadDB and CastAheadDB.voiceLang or "en"
return SOUND_ROOT .. lang .. "\\" .. advice.file .. ".ogg"
end
local function PlayMedia(sound)
local channel = CastAheadConfig.SoundChannel()
if type(sound) == "number" then PlaySound(sound, channel) else PlaySoundFile(sound, channel) end
end
local function Beep(advice)
if not SOUNDKIT then return end
local kit = SOUNDKIT[ADVICE_SOUND[advice.key] or ""]
if kit then PlaySound(kit, CastAheadConfig.SoundChannel()) end
end
-- One alert per call, either the voice or a sound. A sound picked for the
-- spell or its category replaces the voice; on Voice, the line is spoken
-- (`lead` = "tank buster soon" before the cast) and the stock beep stands in
-- only when nothing could speak. `voice` false skips speaking, for the
-- General switch and for previews of just the sound.
local function Alert(advice, lead, candidates, voice)
local custom = CustomSound(advice, candidates)
if custom then
PlayMedia(custom)
return
end
if voice and Voice(advice, lead) then return end
Beep(advice)
end
local function PlayAdviceSound(advice, lead, candidates)
if not advice then return end
if not CastAheadConfig.Enabled("sound") then return end
-- The heads-up is a number of seconds now; 0 means the player does not
-- want it at all.
if lead and CastAheadConfig.Lead() <= 0 and not CastAheadMatch.IsSave(advice) then return end
Alert(advice, lead, candidates, CastAheadConfig.Enabled("voice"))
end
-- Creature identity ---------------------------------------------------------
--
-- Traits.lua ships, per dungeon, what the game still tells us about a hostile
-- nameplate - level, power type, classification, lieutenant flag, creature
-- family - for every creature, plus which creatures stand together in a pack
-- (co) and which segment of the dungeon they are found in (stage). Matching
-- those the moment a plate appears names most mobs before they act, and every
-- later cast is narrowed to that creature's own spells. Health is deliberately
-- absent: UnitHealth is SecretReturns, so the number on the nameplate can be
-- drawn but never read.
local function ReadTraits(unit)
local traits = { level = SafeLevel(unit) }
local ok, classification = pcall(UnitClassification, unit)
if ok and type(classification) == "string" then
traits.classification = classification
end
local okPower, power = pcall(UnitPowerType, unit)
if okPower and type(power) == "number" then
traits.power = power
end
if type(UnitIsLieutenant) == "function" then
local okLt, lieutenant = pcall(UnitIsLieutenant, unit)
if okLt and type(lieutenant) == "boolean" then
traits.lieutenant = lieutenant
end
end
if type(UnitCreatureFamily) == "function" then
-- The family itself is Secret; whether there is one is not.
local okFam, family = pcall(UnitCreatureFamily, unit)
if okFam and not (issecretvalue and issecretvalue(family)) then
traits.family = family ~= nil
end
end
return traits
end
-- Which segment of the dungeon the group is in: bosses killed so far plus one,
-- read off the Mythic+ scenario. Nil outside a scenario, and then the stage
-- trait is simply not checked.
local function CurrentStage()
local api = C_ScenarioInfo
if not (api and api.GetCriteriaInfo) then return nil end
local killed, any = 0, false
for i = 1, 20 do
local ok, info = pcall(api.GetCriteriaInfo, i)
if not ok or type(info) ~= "table" then break end
if not info.isWeightedProgress then -- enemy forces is weighted
any = true
if info.completed then killed = killed + 1 end
end
end
return any and (killed + 1) or nil
end
-- A trait the table does not know, or the game did not tell us, is not held
-- against a creature; only a known value that disagrees rules it out.
local function MatchTraits(row, obs, stage, state)
if obs.level and row.level ~= obs.level then return false end
if row.power and obs.power and row.power ~= obs.power then return false end
if obs.classification and row.elite ~= (obs.classification == "elite") then
return false
end
if row.lieutenant ~= nil and obs.lieutenant ~= nil and row.lieutenant ~= obs.lieutenant then
return false
end
if row.family ~= nil and obs.family ~= nil and row.family ~= obs.family then
return false
end
if row.stage and stage then
local found = false
for i = 1, #row.stage do
if row.stage[i] == stage then found = true break end
end
if not found then return false end
end
-- Lengths already measured on this plate must all be ones the creature
-- is known to use - cast lengths against `casts`, channel lengths against
-- `channels`. A creature with no list for that kind is not judged on it.
if state and next(state.tracks) then
for _, track in pairs(state.tracks) do
if track.measured then
-- Never `channel and channels or casts`: a channel track on a
-- creature with no `channels` would fall through to its cast
-- list and be judged against the wrong lengths.
local list = row.casts
if track.channel then list = row.channels end
local length = track.length
if list then
local known = false
for i = 1, #list do
if math.abs(list[i] - length) <= CastAheadMatch.CAST_TOLERANCE then
known = true
break
end
end
if not known then return false end
end
end
end
end
return true
end
-- Creatures pinned down on the other plates. Only a locked identity counts as
-- company; a plate still choosing between several says nothing.
-- A plate we knew the identity of has died: one fewer of that kind left.
local function NoteDeath(state)
if not packs or not packs.total then return end
local npc = state.npc
if not npc then
-- No lock, but every sure cast on the plate agreed on one creature.
local known = ResolvedNPCs(state)
if known then
local only
for id in pairs(known) do
if only then only = nil break end
only = id
end
npc = only
end
end
if not npc then return end
killed[npc] = (killed[npc] or 0) + 1
local total = packs.total[npc]
if total and killed[npc] >= total then exhausted[npc] = true end
end
-- Creatures that share a pack with any plate locked nearby. Nil when no pack
-- data or no lock, so callers fall back to whatever else they know.
local function PackMates(except)
if not packs or not packs.packs then return nil end
local locked = LockedNPCs(except)
if not next(locked) then return nil end
local mates
for _, pack in ipairs(packs.packs) do
local shared = false
for npc in pairs(pack) do
if locked[npc] then shared = true break end
end
if shared then
mates = mates or {}
for npc in pairs(pack) do mates[npc] = true end
end
end
return mates
end
function LockedNPCs(except)
local out = {}
for unit, other in pairs(plates) do
if unit ~= except and other.npc then out[other.npc] = true end
end
return out
end
local function IdentifyOthers(except)
for unit, other in pairs(plates) do
if unit ~= except and not other.npc then Identify(unit, other) end
end
end
-- The plate is this creature and nothing else: its pending ambiguities are
-- settled, its kit is previewed, and its neighbours get another look at who
-- they might be standing next to.
function LockNPC(unit, state, npc, source)
if not npc or state.npc == npc then return end
state.npc = npc
state.npcSource = source or "cast"
Record("LOCK", unit, npc, state.npcSource)
state.npcSet = nil
RefineByNPC(state, npc, nil)
if state.inCombat then
-- The lock may have come from a neighbour's cast, with nothing on this
-- plate repainting: its tracks just changed, so its icons and timeline
-- events must follow now, not at its next cast.
for _, track in pairs(state.tracks) do
SyncTimeline(track)
end
RefreshBar(unit, state)
else
StartPreview(unit, state)
end
IdentifyOthers(unit)
end
-- A trait match alone never locks a plate. The trait table lists only the
-- creatures with casts worth calling, while the cast table knows more of the
-- pack, so a creature absent from the traits can look exactly like the one
-- listed neighbour - that is how a Bribed Captain wore Bribed Guard's spells.
-- The lock waits for a measured cast length that the matched creature is known
-- to use; until then the match only narrows.
local function CastsConfirm(row, state)
for _, track in pairs(state.tracks) do
if track.measured then -- a projected sibling proves nothing
local list = row.casts
if track.channel then list = row.channels end
if list then
for i = 1, #list do
if math.abs(list[i] - track.length) <= CastAheadMatch.CAST_TOLERANCE then
return true
end
end
end
end
end
return false
end
-- Name the creature on this plate from its traits. The matches are kept as
-- the set its casts are narrowed to; one match confirmed by a cast locks it.
-- Company narrows the set only positively, as a creature listed next to one
-- already locked nearby - a missing neighbour never rules anyone out.
function Identify(unit, state)
if state.npc or not traitRows then return end
local obs = ReadTraits(unit)
if not obs.level then return end
local stage = CurrentStage()
local matched = {}
for i = 1, #traitRows do
if not (tuning.population and exhausted[traitRows[i].npc])
and MatchTraits(traitRows[i], obs, stage, state) then
matched[#matched + 1] = traitRows[i]
end
end
if #matched > 1 then
-- Company narrows: packmates of a locked neighbour, from MDT's packs
-- when they are known, else from the trait rows' own neighbour list.
local mates = tuning.packsCompany and PackMates(unit) or nil
local locked = LockedNPCs(unit)
if mates or next(locked) then
local kept = {}
for i = 1, #matched do
if mates then
if mates[matched[i].npc] then kept[#kept + 1] = matched[i] end
else
local co = matched[i].co
for j = 1, #(co or {}) do
if locked[co[j]] then
kept[#kept + 1] = matched[i]
break
end
end
end
end
if #kept > 0 then matched = kept end
end
end
state.npcSet = nil
if #matched == 1 and CastsConfirm(matched[1], state) then
LockNPC(unit, state, matched[1].npc, "traits")
elseif #matched > 0 then
state.npcSet = {}
for i = 1, #matched do state.npcSet[matched[i].npc] = true end
end
end
-- The spell's own icon, with a fallback chain: GetSpellInfo returns nothing at
-- all for spells the client has not cached, which is why every mob was showing
-- the same generic placeholder. GetSpellTexture answers for more of them, and
-- anything still missing is requested so it is there next time.
local FALLBACK_ICON = 136243
local iconCache = {}
local function SpellIcon(spellID)
if not spellID then return FALLBACK_ICON end
local cached = iconCache[spellID]
if cached then return cached end
local icon
if C_Spell.GetSpellTexture then
icon = C_Spell.GetSpellTexture(spellID)
end
if not icon then
local info = C_Spell.GetSpellInfo(spellID)
icon = info and (info.iconID or info.originalIconID)
end
if icon then
iconCache[spellID] = icon
return icon
end
-- Not loaded yet: ask for it, and use the placeholder just this once.
if C_Spell.RequestLoadSpellData then
C_Spell.RequestLoadSpellData(spellID)
end
return FALLBACK_ICON
end
local function IsHostileNameplate(unit)
return UnitExists(unit)
and UnitCanAttack("player", unit)
and not UnitIsDead(unit)
end
-- Bar ---------------------------------------------------------------------
-- Frames are never garbage collected in WoW, and nameplates churn constantly in
-- a dungeon, so retired bars go back in the pool instead of being abandoned.
local barPool = {}
local function ReleaseBars(state)
if not state.bars then return end
for i = #state.bars, 1, -1 do
local bar = state.bars[i]
bar:Hide()
bar:ClearAllPoints()
bar._plate = nil
barPool[#barPool + 1] = bar
state.bars[i] = nil
end
end
-- One icon per tracked spell with the seconds left written on it, the way a
-- cooldown reads. No progress bar: the number is the information.
-- The icon size is the player's, so everything drawn on the icon is measured
-- from it rather than from the constant: the countdown, the advice under it
-- and the alert ring all keep their proportions at any size.
local FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local function ApplyBarSize(bar)
local size = CastAheadConfig.IconSize()
local labelScale = CastAheadConfig.LabelScale()
local timeScale = CastAheadConfig.TimeScale()
local strata = CastAheadConfig.Strata()
if bar._strata ~= strata then
bar._strata = strata
-- Unfix, set, fix again: a fixed strata also ignores our own SetFrameStrata.
if bar.SetFixedFrameStrata then bar:SetFixedFrameStrata(false) end
bar:SetFrameStrata(strata)
if bar.SetFixedFrameStrata then bar:SetFixedFrameStrata(true) end
end
if bar._size == size and bar._labelScale == labelScale
and bar._timeScale == timeScale then
return size
end
bar._size, bar._labelScale, bar._timeScale = size, labelScale, timeScale
local scale = size / ICON_SIZE
bar:SetSize(size, size)
bar.glow:SetPoint("TOPLEFT", -size * 0.42, size * 0.42)
bar.glow:SetPoint("BOTTOMRIGHT", size * 0.42, -size * 0.42)
bar.time:SetFont(FONT, math.max(7, math.floor(14 * scale * timeScale + 0.5)), "OUTLINE")
bar.label:SetFont(FONT, math.max(6, math.floor(10 * scale * labelScale + 0.5)), "OUTLINE")
return size
end
local function GetBar(state, index)
state.bars = state.bars or {}
if state.bars[index] then
ApplyBarSize(state.bars[index])
return state.bars[index]
end
local pooled = table.remove(barPool)
if pooled then
state.bars[index] = pooled
ApplyBarSize(pooled)
return pooled
end
local bar = CreateFrame("Frame", nil, UIParent)
bar:SetSize(ICON_SIZE, ICON_SIZE)
bar:EnableMouse(false)
-- Nameplate addons draw at their own strata; without pinning ours above
-- them the icon disappears behind the plate it belongs to. The strata
-- itself is the player's (ApplyBarSize), fixed so no parent moves it.
if bar.SetFixedFrameStrata then bar:SetFixedFrameStrata(true) end
bar:SetFrameLevel(6200)
if bar.SetFixedFrameLevel then bar:SetFixedFrameLevel(true) end
bar.border = bar:CreateTexture(nil, "BACKGROUND")
bar.border:SetPoint("TOPLEFT", -1.5, 1.5)
bar.border:SetPoint("BOTTOMRIGHT", 1.5, -1.5)
bar.icon = bar:CreateTexture(nil, "ARTWORK")
bar.icon:SetAllPoints()
bar.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) -- trim the stock icon border
-- Pulsing alert ring for a dangerous cast in progress. Blizzard's own
-- ActionButtonSpellAlertManager only takes action buttons, so this is the
-- same idea built from its texture directly.
bar.glow = bar:CreateTexture(nil, "OVERLAY")
bar.glow:SetTexture("Interface\\SpellActivationOverlay\\IconAlert")
bar.glow:SetTexCoord(0.00781250, 0.50781250, 0.27734375, 0.52734375)
bar.glow:SetBlendMode("ADD")
bar.glow:SetPoint("TOPLEFT", -ICON_SIZE * 0.42, ICON_SIZE * 0.42)
bar.glow:SetPoint("BOTTOMRIGHT", ICON_SIZE * 0.42, -ICON_SIZE * 0.42)
bar.glow:Hide()
local pulse = bar.glow:CreateAnimationGroup()
pulse:SetLooping("BOUNCE")
local fade = pulse:CreateAnimation("Alpha")
fade:SetFromAlpha(0.35)
fade:SetToAlpha(1)
fade:SetDuration(0.5)
bar.pulse = pulse
bar.time = bar:CreateFontString(nil, "OVERLAY")
bar.time:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
bar.time:SetPoint("CENTER", bar, "CENTER", 0, 0)
bar.time:SetShadowColor(0, 0, 0, 1)
bar.time:SetShadowOffset(1, -1)
-- The advice sits under the icon so a long spell name never widens the row.
bar.label = bar:CreateFontString(nil, "OVERLAY")
bar.label:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
bar.label:SetPoint("TOP", bar, "BOTTOM", 0, -1)
bar:Hide()
ApplyBarSize(bar)
state.bars[index] = bar
return bar
end
-- The nameplate itself is the anchor. It is positioned over the mob by the
-- game, so it is always in the right place, whatever addon draws on top of it.
-- Earlier versions dug for each nameplate addon's own health bar and kept
-- finding frames that live elsewhere - Platynator re-parents Blizzard's
-- UnitFrame into a hidden frame - which stacked every icon in the middle of
-- the screen.
-- Nine positions around (and on) the plate. dx/dy push away from it; `grow`
-- is where the second and later icons go when the player leaves growth on
-- "auto": always away from the plate, so the first icon - the soonest cast -
-- is the one touching it. Sides grow outwards, top and bottom rows stack
-- vertically, the centre runs along the health bar.
local ANCHORS = {
topleft = { point = "BOTTOMRIGHT", relative = "TOPLEFT", dx = -1, dy = 1, grow = "up" },
top = { point = "BOTTOM", relative = "TOP", dx = 0, dy = 1, grow = "up" },
topright = { point = "BOTTOMLEFT", relative = "TOPRIGHT", dx = 1, dy = 1, grow = "up" },
left = { point = "RIGHT", relative = "LEFT", dx = -1, dy = 0, grow = "left" },
center = { point = "CENTER", relative = "CENTER", dx = 0, dy = 0, grow = "right" },
right = { point = "LEFT", relative = "RIGHT", dx = 1, dy = 0, grow = "right" },
bottomleft = { point = "TOPRIGHT", relative = "BOTTOMLEFT", dx = -1, dy = -1, grow = "down" },
bottom = { point = "TOP", relative = "BOTTOM", dx = 0, dy = -1, grow = "down" },
bottomright = { point = "TOPLEFT", relative = "BOTTOMRIGHT", dx = 1, dy = -1, grow = "down" },
}
CastAheadAnchors = ANCHORS -- the options window builds its grid from this
local GROW = { left = true, down = true, right = true, up = true }
CastAheadGrowth = GROW
-- Sideways, the icons cannot simply sit an icon's width apart: the advice
-- under them is wider than the icon and centred on it, so two neighbours need
-- half of each label between them. Only half of each - stepping everything by
-- the widest label on screen puts "TANKBUSTER"'s full width beside "AOE" and
-- leaves a hole the size of a third icon.
--
-- Each bar therefore carries its own distance from the first, and the floor is
-- the icon itself, for the case where both labels are short or empty.
local function SpreadBars(bars, count)
local offset, previous = 0, 0
local fixed = CastAheadConfig.FixedGap()
for i = 1, count do
local bar = bars[i]
local width = bar.label:GetStringWidth() or 0
if i > 1 and fixed then
offset = offset + (bar._size or ICON_SIZE) + fixed
elseif i > 1 then
offset = offset + math.max((bar._size or ICON_SIZE) + ICON_GAP,
(previous + width) / 2 + LABEL_GAP)
end
bar._hOffset = offset
previous = width
end
end
-- Filled by RefreshBar on every repaint, so the spread costs no garbage.
local spread = {}
local function AnchorBar(unit, bar, index)
local plate = C_NamePlate.GetNamePlateForUnit(unit)
-- The bar is a child of UIParent, not of the plate, so it does not inherit
-- the plate's visibility: a plate that is gone or hidden would otherwise
-- leave its icon floating at the last position it had.
if not plate or (plate.IsShown and not plate:IsShown()) then
bar:Hide()
return false
end
-- Bars stay children of UIParent rather than the plate: plates are recycled
-- and carry their own alpha, scale and frame level, all of which would drag
-- the bar around with them.
-- Which side of the plate the icons sit on (/ca anchor, or the window),
-- and how far out (/ca offset N) - so they clear whatever the nameplate
-- addon draws in the same spot. Left and right stack downwards; above and
-- below run sideways, first icon centred.
local side = CastAheadDB and CastAheadDB.anchor or "left"
local spec = ANCHORS[side] or ANCHORS.left