-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathScheduler.lua
More file actions
1243 lines (1186 loc) · 61.7 KB
/
Copy pathScheduler.lua
File metadata and controls
1243 lines (1186 loc) · 61.7 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
-- Scheduling: on ENCOUNTER_START, build a sorted reminder queue and drive it
-- with a single per-frame ticker. Each tick computes the imminent alert (within
-- its lead window) + the upcoming queue, and renders an anticipation countdown.
--
-- Phase-gated bosses: reminders carry a phaseIndex (offset from a phase that
-- begins on a variable trigger - a boss cast, a shield removal, or an HP%). Those
-- cues are deferred until we DETECT the phase live (via BigWigs/DBM boss-mod
-- callbacks - Midnight blocks addons from reading the combat log / enemy spellIds
-- directly), then scheduled relative to the moment the phase began for THIS pull.
-- Absolute cues are unchanged.
local _, ns = ...
local Scheduler = {}
ns.Scheduler = Scheduler
local LINGER = 1.0
-- 3-2-1 countdown window. A cue whose cast lands within this many seconds of the
-- PREVIOUS cast gets no spoken countdown (the lead spell's countdown already
-- covers the window - a back-to-back cue would only stutter a clipped "2..1").
local COUNTDOWN_LEAD = 3
local driver, pullTime, queue, activeId
-- phase state (live, phase-gated bosses only):
-- pendingPhase[idx] = { cue, ... } cues waiting for phase idx to begin
-- phaseTriggers = { {index, kind, spellId, occurrence, pct, seen, fired}, ... }
-- hasHealthTrigger = true if any trigger polls UnitHealth
local pendingPhase, phaseTriggers, hasHealthTrigger
-- C_EncounterTimeline sub-phase tracking (drives firePhase on BW-less bosses):
-- tlAddedAt[id] = elapsed when that timeline event was ADDED (survival fallback)
-- durById[id] = the event's info.duration (for dur-signature matching)
-- tlLastAdvance = elapsed of the last advance (debounce so one signal fires once)
-- tlLastSignal = 'entry' | 'exit' - alternation guard for 5-segment advance
-- tlCursor = index into phaseTriggers; the next phase a TL signal fires
local tlAddedAt, tlLastAdvance, durById, tlLastSignal, tlCursor
-- Raid linear-phase state (raid bosses only; see RAID_PHASE_DURS):
-- raidCur = current phase index (1-based); nil = not a raid-armed pull
-- raidSwapAt = elapsed of the last raid advance (debounce)
-- raidAddedAt = elapsed of each ADDED timeline event this pull (for the isolation
-- guard and the event-count detector below)
local raidCur, raidSwapAt, raidAddedAt
-- HP / boss-count diagnostic state. logBossHealth is defined AFTER recordCapture
-- (it calls it), so forward-declare here for tick() and run() to reference.
local logBossHealth, lastHpLog, lastBossCount, recordCapture
local TL_DEBOUNCE = 8 -- s: ignore further signals within this window. Its only
-- job is to collapse a boundary's burst of simultaneous
-- events (<1s apart) into one advance - the entry/exit
-- alternation already prevents same-type repeats. Kept
-- BELOW the shortest real sub-phase (Crawth fight 2564 sub-
-- phase 1 was ~14s entry→exit; 15s would have eaten its
-- exit and mis-anchored to the next rotation cycle).
-- NSRT-style dur signatures (from /coolplan capture): a boss's MAIN rotation event
-- durations. A main-dur event CANCELED (state=3) = sub-phase ENTRY; the main-dur set
-- re-ADDED = sub-phase EXIT (= next main phase). More robust than survival+occurrence
-- (no occ-skew). Gemellus uses unitCount → not listed here. Crawth (2564) & Lothraxion
-- (3333) reuse their rotation dur during the sub-phase, so their ENTRY comes from
-- PHASE_ENTRY_WARN instead (the dur sig here still drives their exit); L'ura (2068) is
-- likewise EXIT-ONLY here (entry = PHASE_ENTRY_ANYCANCEL, its cancel dur is laser-variable).
-- ✓ ALL FOUR VERIFIED in-game (2026-06-11/12): 3058 Kroluk &
-- 3213 Vordaza use a signature dur going state=3 for ENTRY (Vordaza's dur=70 cancels at
-- the Deathshroud entry); 2564 Crawth & 3333 Lothraxion are warning-gated (PHASE_ENTRY_
-- WARN) so their signature is EXIT-ONLY (the rotation set re-ADDED ends the sub-phase).
local PHASE_DUR_SIGNATURES = {
[2564] = { 20, 5, 14 }, -- Crawth (Gust/Peck/Screech)
[3213] = { 25.333, 3, 70, 14.166, 33.5 }, -- Vordaza
[3058] = { 1001, 999, 1004 }, -- Kroluk: long "main rotation" bars.
-- VERIFIED 2026-06-11 (in-game capture, fight 3058): the main-phase bars are
-- always freshly ADDED at exactly {1001, 999, 1004}; they go state=3 at each
-- sub-phase ENTRY and re-ADD at the EXIT. The 952-998 values seen in the capture
-- are transient "remaining-time" snapshots at the boundary - deliberately NOT
-- listed (an exact match avoids false EXIT fires right after an entry).
[3333] = { 2, 11, 52, 24 }, -- Lothraxion
[2068] = { 24, 12, 35 }, -- L'ura: main rotation SET (Discordant Beam 24 /
-- Disintegrate 12 / Grim Chorus 35). EXIT-ONLY (entry is PHASE_ENTRY_ANYCANCEL, below).
-- VERIFIED 2026-06-12 (capture fight 2068): the main set {1.5,24,12,35} re-ADDs together at
-- each sub-phase exit. 1.5 is DELIBERATELY EXCLUDED - it collides with the Symphony cast bar
-- (dur 1.5) that ADDs AT the entry, which (alternation just set "entry") would fire a false
-- EXIT one tick after the entry. 24/12/35 appear ONLY at the main-set re-ADD - never during
-- the intermission (which shows just 1.5/20) nor in the b-set (5/17/28) - so the first of them
-- after an entry = the rotation resumed = exit. Multi-value (like Kroluk/Crawth) is more
-- robust than a single dur if one value drifts run-to-run (sub-phases 2+ not yet captured).
}
local DUR_EPSILON = 0.3 -- s: float dur match tolerance (25.333, 14.166 …)
-- Warning-gated ENTRY. Some bosses don't surface a clean sub-phase CANCEL on a signature
-- duration, but DO herald the sub-phase with an ENCOUNTER_WARNING. `sev` = minimum
-- severity; optional `dur` pins the warning's own duration when severity alone doesn't
-- disambiguate. For these bosses the warning is the SOLE entry signal - the state=3 entry
-- path is suppressed (their signature durations also CANCEL as routine/exit churn, which
-- must not count as an entry). The signature is kept for the EXIT (rotation set re-ADDED).
-- Crawth (2564): Ruinous Winds = sev 2; routine telegraphs are sev 1, so sev alone
-- suffices. VERIFIED 2026-06-11: sub-phase warnings at 50.6s / 112.6s (both subs).
-- Lothraxion (3333): Divine Guile = a sev-2 warning of dur 3.5, but ROUTINE telegraphs
-- are ALSO sev 2 (dur 3) - so the 3.5 duration is the discriminator. VERIFIED
-- 2026-06-12 (two captures): dur=3.5 sev=2 precedes each sub-phase (player: ~52-53s
-- cycle, the actual mechanic begins ~9.5s later = cast 7.5s + ~2s); routine dur=3
-- sev=2 warnings are correctly excluded; exit = rotation set {2,11,52,24} re-ADDED.
local PHASE_ENTRY_WARN = {
[2564] = { sev = 2 }, -- Crawth: severity alone (routine = sev 1)
[3333] = { sev = 2, dur = 3.5 }, -- Lothraxion: routine warnings are also sev 2 (dur 3)
}
-- ANY-CANCEL ENTRY. A few bosses signal the sub-phase by interrupting their rotation
-- but the CANCELED event's duration isn't fixed (so it can't be dur-gated like
-- Kroluk/Vordaza). For these, ANY state=3 (cancel) during combat = sub-phase entry; the
-- exit still comes from the dur signature (rotation set re-ADD). The entry/exit
-- alternation + debounce keep one signal per boundary.
-- L'ura (2068): L'ura casts Symphony of the Eternal Night (annihilation) until Alleria
-- interrupts it; the cast START cancels whatever rotation event is currently active
-- (laser-variable: dur 28 in two captures, but which event is active shifts with the
-- laser-objective timing), so the entry can't key on a fixed dur. VERIFIED 2026-06-12
-- (two captures): during combat the ONLY state=3 was the intermission entry (~64s);
-- all other transitions were state=2. Combat-end cancels arrive with pullTime nil
-- (guarded below).
local PHASE_ENTRY_ANYCANCEL = {
[2068] = true, -- L'ura: Symphony interrupt cancels a laser-variable rotation event
}
-- ── Raid phase detection (our dur-signature method, applied to RAID bosses) ────
-- Same idea as the dungeon engine above: the official Encounter Timeline never
-- exposes a usable info.phase (secret in Midnight instances - the same lockdown
-- that hides CLEU/HP), so we read the DURATION of each boundary's ADDED timeline
-- event. Dungeons have a main→sub→main structure (entry/exit alternation, above);
-- raids are LINEAR (P1→P2→P3…), so the curation is simpler: per encounterID →
-- difficultyID → { [currentPhase] = the ADDED-event duration that advances that
-- phase to the next }. `mode="ge"` matches dur >= value (a long transition bar);
-- default matches |dur - value| < RAID_DUR_EPSILON. Raid bosses never set
-- phaseTriggers, so the entry/exit dungeon path is structurally never entered for
-- them (and vice-versa) - the two detectors can't interfere.
local RAID_DUR_EPSILON = 0.2
local RAID_PHASE_DEBOUNCE = 5 -- s: min gap between raid advances (collapse the
-- boundary's burst of ADDED events into one advance)
-- Per boss: optional `mode`/`delay`, then difficultyID → advance durations. The
-- duration table is either an ARRAY indexed by current phase ({45,97,180} = the
-- value that advances P1→2, P2→3, P3→4) or `{ all = N }` (any phase advances on
-- duration N - used when every transition shares one bar length). `mode="ge"`
-- matches dur >= value; default matches |dur - value| < RAID_DUR_EPSILON. `delay`
-- (s) fires the advance that many seconds AFTER the matching event.
local RAID_PHASE_DURS = {
[3182] = { -- Belo'ren, Child of Al'ar
mode = "eq", -- exact short bar at each Death Drop
isolate = true, -- only advance on an ISOLATED dur-6 (≤1 ADDED within
-- ISOLATE_WINDOW); a dur-6 inside a same-instant burst
-- isn't the Death Drop transition.
[14] = { all = 6 },
[15] = { all = 6 },
[16] = { all = 6 },
},
[3183] = { -- Midnight Falls (L'ura)
-- P4→P5 isn't duration-gated: it resolves on a fresh encounter-engage once the
-- 2nd boss unit is gone, >guard s into P4 (see ENGAGE handling in onTimelineEvent).
-- Mythic-only (`diff`): the 2-boss P4→P5 split exists only on Mythic.
engage = { from = 4, to = 5, guard = 20, noUnit = "boss2", diff = 16 },
[15] = { 45, 97, 180 }, -- P1→2, P2→3, P3→4 (Heroic)
[16] = { 45, 97, 180 }, -- (Mythic)
},
[3181] = { -- Crown of the Cosmos — Normal/Heroic (dur sequence).
mode = "eq", -- exact bar lengths (1.5 is GCD-sized - needs exact)
[14] = { 1.5, 24, 1.5, 60 }, -- P1→2, P2→3, P3→4, P4→5
[15] = { 1.5, 24, 1.5, 60 },
-- Mythic (16) uses event COUNTING instead (see RAID_COUNT).
},
-- Salhadaar (3179): not a phase boss for our purposes (see RAID_PHASE_ANCHORING handoff).
}
-- Event-count phase detection (when a single transition duration isn't reliable, the
-- phase advances once enough timeline events accumulate in a short window). Same family
-- as the dungeon unit-count / state-cancel detectors, applied to timeline-event counts.
-- required[p] = ADDED events (within `window` s) needed to leave phase p; false = no
-- count rule for that phase (handled by `skip`).
-- phase1Durs = while in phase 1, only count ADDED events of these durations.
-- skip = a phase that jumps forward (Crown P3→P5): reached on `count` events
-- OR an ADDED event of `altDur`.
local RAID_COUNT_WINDOW = 0.3
local RAID_COUNT = {
[3181] = { -- Crown of the Cosmos — Mythic only
[16] = {
required = { 2, 4, false, 4 },
phase1Durs = { [1.5] = true, [25] = true, [6] = true },
skip = { from = 3, to = 5, count = 8, altDur = 60 },
},
},
}
local ISOLATE_WINDOW = 0.1 -- s: window for the Belo'ren isolation guard
-- preview mode: a virtual clock advanced by `speed`x each real tick, with an
-- optional onTick(elapsed, total) callback (used by the Timeline playhead).
local preview = false
local previewClock = 0
local previewSpeed = 1
local previewTotal = 0
local previewOnTick = nil
local previewPhaseStart = nil -- {[phaseIndex]=startMs}: synthetic phase windows (preview)
-- Preview has no live phase timing. The Timeline view passes its own per-phase start
-- offsets (opts.phaseStart) so the playhead/markers/HUD share one layout; this constant
-- is only a FALLBACK (seconds per phase) when a caller arms a preview without one.
local PREVIEW_PHASE_GAP = 30
local function nameMatchesMe(player)
if not player or player == "" then return true end
local me = UnitName("player")
if not me then return true end
return strlower(player) == strlower(me)
end
local function nowElapsed()
if preview then return previewClock end
if not pullTime then return 0 end
return GetTime() - pullTime
end
-- Push a cue onto the live queue at an absolute elapsed-seconds castAt, computing
-- its on-screen (lead) and audible (soundLead) windows. Shared by the initial
-- build and dynamic phase insertion.
local function enqueue(cue, castAt)
local o = ns.DB.Options()
local lead = o.leadSeconds or 4
local soundLead = o.soundLeadSeconds or 0
queue[#queue + 1] = {
cue = cue,
castAt = castAt,
showAt = math.max(0, castAt - lead),
soundAt = math.max(0, castAt - soundLead),
soundCued = false,
}
end
-- Sort the queue by castAt and (re)flag cues that follow the previous cast within
-- the countdown window so they skip their spoken countdown. Recomputed whenever
-- the queue changes (initial build + each phase insertion).
local function finalizeQueue()
table.sort(queue, function(a, b) return a.castAt < b.castAt end)
local prevCast
for _, item in ipairs(queue) do
item.silentCountdown = nil
if prevCast and (item.castAt - prevCast) < COUNTDOWN_LEAD then
item.silentCountdown = true
end
prevCast = item.castAt
end
end
-- A phase began (its trigger fired): anchor here and schedule its deferred cues
-- relative to this instant. One-shot per phase.
local function firePhase(index)
local pend = pendingPhase and pendingPhase[index]
if phaseTriggers then
for _, tr in ipairs(phaseTriggers) do
if tr.index == index then tr.fired = true end
end
end
-- A new phase began: any still-pending cue scheduled for an EARLIER phase is now
-- stale (that phase is over) and must NOT fire during this one. Drop un-cast queued
-- cues from every earlier phase. In a phased plan, absolute (no-pN) cues ARE phase 1
-- - `phaseIndex or 1` - so they're dropped too once we leave phase 1 (e.g. the boss
-- hit the sub-phase early, before all of phase 1's cooldowns came up).
if index and index > 1 and queue then
local now = nowElapsed()
for i = #queue, 1, -1 do
local q = queue[i].cue
if q and (q.phaseIndex or 1) < index and queue[i].castAt > now then
table.remove(queue, i)
end
end
end
if ns.debug then ns.Print("|cff66ff66CoolPlan phase " .. index .. " fired @ " .. string.format("%.1f", nowElapsed()) .. "s, scheduling " .. (pend and #pend or 0) .. " cue(s)|r") end
if not pend then return end
pendingPhase[index] = nil
local anchorElapsed = nowElapsed()
for _, cue in ipairs(pend) do
enqueue(cue, anchorElapsed + cue.timeMs / 1000)
end
finalizeQueue()
end
-- Current instance difficulty id (16=Mythic, 15=Heroic, 14=Normal raid).
local function instanceDifficulty()
return select(3, GetInstanceInfo()) or 0
end
-- Raid LINEAR phase advance: on a timeline ADDED event of duration `dur`, if the
-- active raid boss's table says this duration advances the current phase, fire the
-- next one. No-op for any encounter not in RAID_PHASE_DURS (so dungeons and the
-- demo/preview pulls are untouched). Debounced like the dungeon path so a
-- boundary's burst of ADDED events advances only once.
local function raidAdvance(dur, now)
local cfg = activeId and RAID_PHASE_DURS[activeId]
if not cfg or not raidCur then return end
-- Midnight-secret numbers report type=="number" but throw on arithmetic - guard
-- before math, exactly like isMainDur / the ENCOUNTER_WARNING path.
if type(dur) ~= "number" or (issecretvalue and issecretvalue(dur)) then return end
if (now - (raidSwapAt or -999)) < RAID_PHASE_DEBOUNCE then return end
local byPhase = cfg[instanceDifficulty()]
-- per-current-phase value, or a single `all` value shared by every phase
local want = byPhase and (byPhase[raidCur] or byPhase.all)
if not want then return end
-- Match mode per boss: "ge" = a long transition bar (dur ≥ value); "eq" = an
-- exact bar length (the transition emits one precise duration, so a tolerance
-- would let an ordinary same-ish bar false-trigger - notably a ~1.5s GCD bar);
-- default = approximate (the bar length wobbles run to run).
local matched
if cfg.mode == "ge" then
matched = dur >= want
elseif cfg.mode == "eq" then
matched = dur == want
else
matched = math.abs(dur - want) < RAID_DUR_EPSILON
end
if not matched then return end
-- Isolation guard (Belo'ren): the Death Drop's dur-6 bar appears ALONE. A dur-6
-- inside a same-instant burst of ADDED events isn't the transition. raidAddedAt holds
-- this pull's ADDED times (the current one included); advance only if it's the sole
-- event within ISOLATE_WINDOW.
if cfg.isolate and raidAddedAt then
local n = 0
for i = #raidAddedAt, 1, -1 do
if now - raidAddedAt[i] <= ISOLATE_WINDOW then n = n + 1 else break end
end
if n > 1 then return end
end
raidSwapAt = now
local target = raidCur + 1
raidCur = target -- reserve the slot now so a duplicate event can't double-advance
if ns.debug then recordCapture("raid:ADVANCE", "phase:" .. target .. " dur:" .. tostring(dur), "") end
if cfg.delay then
-- Some transitions resolve a fixed time after their telegraph bar. Capture the
-- pull identity (pullTime is unique per pull) so a timer from a previous pull
-- can't fire after a quick re-pull of the same boss.
local pt = pullTime
C_Timer.After(cfg.delay, function()
if pullTime == pt then firePhase(target) end
end)
else
firePhase(target)
end
end
-- Event-COUNT phase advance (Crown Mythic): a single transition duration isn't
-- reliable, so the phase advances once enough ADDED events accumulate in a short
-- window. Same family as the unit-count / state-cancel detectors, on event counts.
-- Called from the ADDED branch with the event's duration. raidAddedAt accumulates the
-- counted events; it is reset on every advance.
local function raidCountAdvance(dur, now)
local cfg = activeId and RAID_COUNT[activeId]
cfg = cfg and cfg[instanceDifficulty()]
if not cfg or not raidCur then return end
if (now - (raidSwapAt or -999)) < RAID_PHASE_DEBOUNCE then return end
-- In phase 1 only the listed transition-bar durations count; from phase 2 on,
-- every encounter ADDED event counts.
local countThis = true
if raidCur == 1 then
countThis = type(dur) == "number" and not (issecretvalue and issecretvalue(dur))
and cfg.phase1Durs and cfg.phase1Durs[dur] == true
end
if countThis then
raidAddedAt = raidAddedAt or {}
raidAddedAt[#raidAddedAt + 1] = now
end
local n = 0
if raidAddedAt then
for i = #raidAddedAt, 1, -1 do
if now - raidAddedAt[i] <= RAID_COUNT_WINDOW then n = n + 1 else break end
end
end
local req = cfg.required and cfg.required[raidCur]
if raidCur ~= (cfg.skip and cfg.skip.from) and req and n >= req then
raidCur = raidCur + 1
raidSwapAt = now
raidAddedAt = {}
if ns.debug then recordCapture("raid:COUNT", "phase:" .. raidCur .. " n:" .. n, "") end
firePhase(raidCur)
return
end
local skip = cfg.skip
if skip and raidCur == skip.from
and (n >= skip.count or (type(dur) == "number" and not (issecretvalue and issecretvalue(dur)) and dur == skip.altDur)) then
raidCur = skip.to
raidSwapAt = now
raidAddedAt = {}
if ns.debug then recordCapture("raid:COUNT", "skip→" .. raidCur .. " n:" .. n, "") end
firePhase(raidCur)
return
end
end
-- Encounter-roster phase advance (Midnight Falls P4→P5): the boundary isn't a cast/
-- duration but a boss-unit change. On INSTANCE_ENCOUNTER_ENGAGE_UNIT, advance from
-- `from` to `to` once `guard` seconds into `from` and the `noUnit` boss unit is gone.
local function raidEngageAdvance(now)
local cfg = activeId and RAID_PHASE_DURS[activeId]
cfg = cfg and cfg.engage
if not cfg or not raidCur or raidCur ~= cfg.from then return end
if cfg.diff and instanceDifficulty() ~= cfg.diff then return end -- Mythic-only split
if (now - (raidSwapAt or -999)) < (cfg.guard or 0) then return end
if cfg.noUnit and UnitExists(cfg.noUnit) then return end
raidCur = cfg.to
raidSwapAt = now
if ns.debug then recordCapture("raid:ENGAGE", "phase:" .. raidCur, "") end
firePhase(raidCur)
end
-- C_EncounterTimeline sub-phase advance (for BW-less bosses). The official timeline
-- has no usable info.phase; instead we read the entry/exit signals (a main rotation
-- event CANCELED / re-ADDED, or a high-severity warning) and walk the phase triggers
-- in order. We can't read which spellId each signal is, so the Nth signal fires the
-- Nth phase trigger (see timelineAdvancePhase's cursor).
-- Is this duration one of the active boss's MAIN rotation durations?
local function isMainDur(dur)
local sig = activeId and PHASE_DUR_SIGNATURES[activeId]
-- A Midnight-secret number still reports type=="number" but THROWS on arithmetic, so
-- guard it like the ENCOUNTER_WARNING path does before doing math.abs (else the first
-- ADDED/state=3 event on a boss whose duration is secret-guarded errors every frame).
if not sig or type(dur) ~= "number" or (issecretvalue and issecretvalue(dur)) then return false end
for _, d in ipairs(sig) do if math.abs(dur - d) < DUR_EPSILON then return true end end
return false
end
-- Advance to the NEXT phase in plan order. The TL signals give the ORDER of phase
-- transitions (sub-phase entry / exit alternating); we walk phaseTriggers 2→3→4→5
-- in turn via an independent cursor - NOT "lowest unfired". That distinction matters
-- when BigWigs is also running: BW fires the entry phases (e.g. Crawth p2/p4) directly
-- via bossModSpell, marking them fired; a "lowest unfired" walk would then make the
-- NEXT TL signal skip ahead to the exit phase at the entry's time. The cursor is immune
-- - firePhase is idempotent, so BW's pre-fire of a phase the cursor also reaches is
-- harmless. Gemellus (HP-gated) is handled by unitCount, not this.
-- Trade-off (code review #3, judged a NON-ISSUE in practice): a positional cursor can't
-- self-correct if a TL signal is ever spurious/missed (it would offset every later phase
-- by one). Accepted because the happy path is verified live (Crawth w/ BigWigs) and no
-- spurious/missed signal has been observed on any boss - each boundary emits exactly one
-- entry + one exit, and the debounce/alternation collapse simultaneous-cancel bursts. If
-- "cooldowns shifted by one phase / earlier-phase cues vanished" is ever reported, revisit
-- with a fire-highest-fired-index resync. (Not structurally impossible, just unobserved.)
local function timelineAdvancePhase()
if not phaseTriggers then return end
tlCursor = (tlCursor or 0) + 1
local tr = phaseTriggers[tlCursor]
if tr then firePhase(tr.index) end
end
-- Sub-phase ENTRY / EXIT signals, alternated (tlLastSignal) so an entry can't fire
-- twice in a row, and debounced so a boundary's burst of simultaneous events (a whole
-- rotation set canceled / re-added at once) advances the phase only once.
local function tlEntry(now)
local ok = tlLastSignal ~= "entry" and (now - (tlLastAdvance or -999)) >= TL_DEBOUNCE
if ns.debug then
local msg = "id=" .. tostring(activeId) .. " sig=" .. tostring(tlLastSignal) .. " cursor=" .. tostring(tlCursor) .. " → " .. (ok and "FIRE" or "skip")
if recordCapture then recordCapture("tl:ENTRY?", msg, "") end
ns.Print("|cffaaaaaa[tl] ENTRY @" .. string.format("%.1f", now) .. "s " .. msg .. "|r")
end
if ok then
tlLastAdvance, tlLastSignal = now, "entry"
timelineAdvancePhase()
end
end
local function tlExit(now)
local ok = tlLastSignal == "entry" and (now - (tlLastAdvance or -999)) >= TL_DEBOUNCE
if ns.debug then
local msg = "id=" .. tostring(activeId) .. " sig=" .. tostring(tlLastSignal) .. " cursor=" .. tostring(tlCursor) .. " → " .. (ok and "FIRE" or "skip")
if recordCapture then recordCapture("tl:EXIT?", msg, "") end
ns.Print("|cffaaaaaa[tl] EXIT @" .. string.format("%.1f", now) .. "s " .. msg .. "|r")
end
if ok then
tlLastAdvance, tlLastSignal = now, "exit"
timelineAdvancePhase()
end
end
-- Health-gated phases: poll boss unit frames; fire when any boss drops to/below
-- the threshold. Cheap, and only called when a health trigger is pending.
local function pollHealth()
if not phaseTriggers then return end
for _, tr in ipairs(phaseTriggers) do
if (not tr.fired) and tr.kind == "health" and tr.pct then
for u = 1, 5 do
local unit = "boss" .. u
if UnitExists(unit) then
local hp = UnitHealth(unit)
local mx = UnitHealthMax(unit)
-- Midnight: boss HP is a SECRET value inside instances - any compare/
-- arithmetic on it throws a Lua error. Skip when secret (health gating is
-- unsupported there; the phase slot still advances via the TL cursor, or
-- its cues suppress). This is why HP-gated bosses are modeled as `cast`/
-- `unitCount`, not `health` - but guard here so a stray health trigger
-- can never crash the player mid-pull.
local secret = issecretvalue and (issecretvalue(hp) or issecretvalue(mx))
if (not secret) and mx and mx > 0 then
local pc = hp / mx * 100
if pc > 0 and pc <= tr.pct then
firePhase(tr.index)
break
end
end
end
end
end
end
end
-- Driven every frame (OnUpdate) so bars deplete smoothly instead of stepping at
-- a tick. `dt` is the real frame delta; preview advances its virtual clock by
-- dt*speed.
local function tick(dt)
if not queue then return end
if (not preview) and not pullTime then return end
local o = ns.DB.Options()
if preview then previewClock = previewClock + (dt or 0) * previewSpeed end
if (not preview) and hasHealthTrigger then pollHealth() end
if (not preview) and pullTime then logBossHealth() end
local elapsed = nowElapsed()
local lead = o.leadSeconds or 4
local active = nil -- {reminder, remaining, total}
local upcoming = {}
local soonest, soonestItem = nil, nil -- nearest still-upcoming cast (for the countdown)
for _, item in ipairs(queue) do
-- preview: a cue is only "live" during its OWN synthetic phase window. Before its
-- phase starts it isn't visible; once the NEXT phase starts the live addon would
-- have dropped it (firePhase). Skip otherwise so the Test mirrors the real per-phase
-- visibility instead of showing every phase's cues at once.
local visible = true
if preview and previewPhaseStart then
local pidx = (item.cue and item.cue.phaseIndex) or 1
local startN = (previewPhaseStart[pidx] or 0) / 1000
local nextMs = previewPhaseStart[pidx + 1]
if previewClock < startN or (nextMs and previewClock >= nextMs / 1000) then
visible = false
end
end
if visible then
local remaining = item.castAt - elapsed
-- audible cue (sound / TTS) fires once when the SOUND lead window opens -
-- independent of the on-screen lead so audio can lead/trail the visuals.
if (not item.soundCued) and elapsed >= item.soundAt then
item.soundCued = true
ns.Reminders.Cue(item.cue, o)
end
-- track the single nearest upcoming cast in this same pass (the countdown
-- below speaks only that one, so it doesn't need its own queue scan).
if remaining > 0 and ((not soonest) or remaining < soonest) then
soonest, soonestItem = remaining, item
end
if elapsed >= item.showAt and elapsed <= item.castAt + LINGER then
-- in the anticipation window: nearest cast becomes the big alert,
-- any others fall into the queue
if (not active) or remaining < active.remaining then
if active then upcoming[#upcoming + 1] = { cue = active.cue, remaining = active.remaining } end
active = { cue = item.cue, remaining = remaining, total = lead }
else
upcoming[#upcoming + 1] = { cue = item.cue, remaining = remaining }
end
elseif remaining > 0 then
upcoming[#upcoming + 1] = { cue = item.cue, remaining = remaining }
end
end
end
-- spoken 3-2-1 countdown (TTS) over the final 3s before a cast - once per
-- whole second. Fired for ONLY the single soonest upcoming cast so overlapping
-- cues (a queued "next" spell landing inside the active spell's countdown
-- window) never stack two simultaneous countdowns. Independent of the alert
-- mode and the spell-name TTS.
if o.countdownVoice and soonestItem and not soonestItem.silentCountdown then
local sec = math.ceil(soonest)
if sec <= 3 and sec ~= soonestItem.cdLast then
soonestItem.cdLast = sec
ns.Reminders.SpeakCountdown(sec, o)
end
end
-- only preview cues casting within the lookahead window (default 10s), so the
-- queue doesn't list things still half a minute out.
local window = o.queueWindow or 10
for i = #upcoming, 1, -1 do
if upcoming[i].remaining > window then table.remove(upcoming, i) end
end
table.sort(upcoming, function(a, b) return a.remaining < b.remaining end)
local cap = o.queueCount or 3
while #upcoming > cap do table.remove(upcoming) end
ns.Reminders.RenderTick(active, upcoming, o)
if preview then
if previewOnTick then ns.safecall(previewOnTick, elapsed, previewTotal) end
-- auto-stop a short while past the last cast
if elapsed > previewTotal + LINGER + 1 then
Scheduler.Stop()
end
end
end
-- Per-frame driver (only runs OnUpdate while shown → while a schedule is active).
driver = CreateFrame("Frame")
driver:Hide()
driver:SetScript("OnUpdate", function(_, e) ns.safecall(tick, e) end)
-- ── live phase detection via boss mods (BigWigs / DBM) ───────────────────────
-- Midnight blocks addons from reading enemy spellIds / the combat log inside
-- instances (RegisterEvent on COMBAT_LOG_EVENT_UNFILTERED is forbidden, and
-- UNIT_SPELLCAST spellIds are "secret"). Boss mods have sanctioned access and
-- expose callbacks, so we consume THEM: the catalog trigger spellId is matched
-- against the spellId carried by BigWigs/DBM bar/message/timer events. The rest
-- (occurrence count → firePhase, scheduling) is unchanged. Needs BigWigs
-- (+LittleWigs for M+) or DBM; without either, phase-gated bosses stay absolute.
local BOSSMOD_DEBOUNCE = 3 -- seconds: collapse the several bar/message/timer
-- events a boss mod fires for ONE ability instance into a single occurrence.
-- Capture buffer: every boss-mod event seen during the current armed encounter,
-- with its elapsed time - so the trigger spellId can be read exactly (WoW chat
-- isn't copyable). `/coolplan capture` dumps it to a copyable editbox. Reset per
-- pull in run().
local capture = {}
local lastTimelinePhase -- last Encounter-Timeline info.phase seen (diagnostic)
function recordCapture(label, v, name)
if #capture >= 400 then return end
capture[#capture + 1] = { t = nowElapsed(), label = label, v = v, name = name or "?" }
end
-- HP / boss-count diagnostic (assigns the forward-declared upvalue above). Logs
-- boss1-5 health (+ whether it's secret) every ~2s, and the boss UNIT COUNT the
-- instant it changes. Midnight 12.0.5 may mark HP secret/forbidden in instances
-- (project_midnight_addon_restrictions); the count (1→3→5 on Gemellus) uses only
-- UnitExists (a basic API) so it may be a more robust split signal than HP or TL.
function logBossHealth()
-- Boss UNIT COUNT change (no throttle - catches the split instant). Gemellus:
-- 1 → 3 (occ1) → 5 (occ2 / 50%); boss5 appearing == the 50% split.
local count = 0
for u = 1, 5 do if UnitExists("boss" .. u) then count = count + 1 end end
if count ~= lastBossCount then
lastBossCount = count
recordCapture("BOSSCOUNT", tostring(count), "(boss unit count)")
-- Fire any unitCount-gated phase whose threshold the count just reached
-- (Gemellus 50% split = 5 units). UnitExists is readable in Midnight instances
-- (HP/spellID are secret), so this is the robust path where HP and TL-occurrence
-- both fail. firePhase is idempotent, so re-reaching the count is harmless.
if phaseTriggers then
for _, tr in ipairs(phaseTriggers) do
if (not tr.fired) and tr.unitCount and count >= tr.unitCount then
firePhase(tr.index)
end
end
end
end
local now = nowElapsed()
if lastHpLog and (now - lastHpLog) < 2 then return end
lastHpLog = now
for u = 1, 5 do
local unit = "boss" .. u
if UnitExists(unit) then
local hp = UnitHealth(unit)
local mx = UnitHealthMax(unit)
local val
if issecretvalue and (issecretvalue(hp) or issecretvalue(mx)) then
val = "<secret>"
elseif type(hp) == "number" and type(mx) == "number" and mx > 0 then
val = string.format("%.0f%% (%d/%d)", hp / mx * 100, hp, mx)
else
val = "hp=" .. tostring(hp) .. " max=" .. tostring(mx)
end
recordCapture("HP:boss" .. u, val, "(health probe)")
end
end
end
-- Feed a spellId (from a boss-mod callback) into phase detection. `fire` acts on
-- it (an at-the-moment signal); otherwise it's logged only (diagnostics).
-- Secret / non-number keys (some BigWigs bars, or 12.0 secret values) are
-- skipped for matching - they can't be compared to a catalog spellId.
local function bossModSpell(spellID, label, fire)
if not phaseTriggers then return end
if type(spellID) ~= "number" then
recordCapture(label, "key:" .. type(spellID), "(non-number)")
if ns.debug then ns.Print("|cff888888[bossmod " .. label .. "] non-number key (" .. type(spellID) .. ")|r") end
return
end
local nm = (C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(spellID)) or "?"
recordCapture(label, spellID, nm)
if ns.debug then
local match = ""
for _, tr in ipairs(phaseTriggers) do
if tr.spellId == spellID then match = " |cff66ff66<<< matches p" .. tr.index .. (fire and "" or " (log only)") .. "|r" end
end
ns.Print("|cffffcc44[bossmod " .. label .. "] " .. spellID .. " (" .. nm .. ")|r" .. match)
end
if not fire then return end
local now = GetTime()
for _, tr in ipairs(phaseTriggers) do
-- EXIT phases (removebuff / rotation-resume / interrupt) advance ONLY via the
-- Encounter-Timeline cursor, never via a boss-mod spellId match. They often reuse the
-- ENTRY's spellId (Kroluk/Vordaza: cast p2 and removebuff p3 share one id), so matching
-- them here would fire the exit at the ENTRY's time on one boss-mod message.
local exitKind = tr.kind == "removebuff" or tr.kind == "rotation-resume" or tr.kind == "interrupt"
if (not tr.fired) and (not exitKind) and tr.spellId == spellID then
if not (tr.lastSeen and (now - tr.lastSeen) < BOSSMOD_DEBOUNCE) then
tr.lastSeen = now
tr.seen = tr.seen + 1
if tr.seen >= (tr.occurrence or 1) then firePhase(tr.index) end
end
end
end
end
-- ── Encounter Timeline (official C_EncounterTimeline API) ─────────────────────
-- Midnight's SANCTIONED encounter-event feed - the same one NSRT uses (NOT
-- BigWigs/DBM). Events ENCOUNTER_TIMELINE_EVENT_ADDED/REMOVED/STATE_CHANGED +
-- ENCOUNTER_WARNING carry an `info` table (id, phase, duration, time, text, …).
-- CONFIRMED to fire in M+ dungeons (user test 2026-06-10). For now this only
-- DIAGNOSES: it logs every field (secret-guarded) into the capture buffer so one
-- pull reveals whether info.phase tracks our sub-phases - then we can drive
-- firePhase from it (cleaner than the BigWigs bridge: official, no BigWigs
-- dependency, no per-boss spellId curation). Fields may be `secret` inside
-- instances, so guard EVERY access.
local function safeVal(v)
if v == nil then return "nil" end
if issecretvalue and issecretvalue(v) then return "<secret>" end
return tostring(v)
end
local function onTimelineEvent(e, a1)
local line
if e == "ENCOUNTER_TIMELINE_EVENT_ADDED" then
local info = a1
local phase = info and info.phase
line = "id=" .. safeVal(info and info.id)
.. " phase=" .. safeVal(phase)
.. " dur=" .. safeVal(info and info.duration)
.. " text=" .. safeVal(info and info.text)
-- Track phase changes when `phase` is a real (non-secret) number - the signal
-- we hope to drive firePhase from once the info.phase→our-index mapping is known.
if type(phase) == "number" and not (issecretvalue and issecretvalue(phase)) then
if phase ~= lastTimelinePhase then
lastTimelinePhase = phase
recordCapture("TL:PHASE", "phase:" .. phase, "(phase change)")
if ns.debug then ns.Print("|cff00ff00[timeline] PHASE → " .. phase .. "|r") end
end
end
-- Record the ADDED time (survival fallback) and the duration (dur signature).
if phaseTriggers and type(info and info.id) == "number" then
tlAddedAt = tlAddedAt or {}
tlAddedAt[info.id] = nowElapsed()
if type(info.duration) == "number" and not (issecretvalue and issecretvalue(info.duration)) then
durById = durById or {}
durById[info.id] = info.duration
end
end
-- dur-signature EXIT: the MAIN rotation set re-ADDED after a sub-phase means the
-- sub-phase ended → next MAIN phase. tlExit only fires while the previous signal
-- was an entry, so the pull's initial main-set ADDED (and every normal rotation
-- cycle after an exit) is ignored.
if phaseTriggers and isMainDur(info and info.duration) then
tlExit(nowElapsed())
end
-- Raid phase detection (no-op unless a raid boss is armed; structurally separate
-- from the dungeon entry/exit path above, which is gated on phaseTriggers — never
-- set for raids). Crown Mythic counts events (raidCountAdvance); the rest advance
-- linearly off the ADDED duration (raidAdvance), recording the ADDED time first so
-- the isolation guard can see same-instant bursts.
if raidCur then
local rdur, rnow = info and info.duration, nowElapsed()
local countCfg = activeId and RAID_COUNT[activeId]
if countCfg and countCfg[instanceDifficulty()] then
raidCountAdvance(rdur, rnow)
else
raidAddedAt = raidAddedAt or {}
raidAddedAt[#raidAddedAt + 1] = rnow
raidAdvance(rdur, rnow)
end
end
elseif e == "INSTANCE_ENCOUNTER_ENGAGE_UNIT" then
-- Boss-roster phase boundary (Midnight Falls P4→P5). No-op unless the active raid
-- boss has an `engage` rule and its conditions are met.
if raidCur then raidEngageAdvance(nowElapsed()) end
return
elseif e == "ENCOUNTER_TIMELINE_EVENT_STATE_CHANGED" then
local id = a1
local st = C_EncounterTimeline and C_EncounterTimeline.GetEventState and C_EncounterTimeline.GetEventState(id)
line = "id=" .. safeVal(id) .. " state=" .. safeVal(st)
-- state 3 = Canceled. A boss's MAIN rotation event canceled = sub-phase ENTRY, matched
-- by its dur SIGNATURE (Kroluk/Vordaza). SUPPRESSED for warning-gated bosses
-- (PHASE_ENTRY_WARN: Crawth/Lothraxion - their signature durations also cancel as
-- routine/exit churn, so state=3 here would mis-fire; entry is the warning). L'ura
-- (PHASE_ENTRY_ANYCANCEL) fires on ANY cancel (its entry-cancel dur is laser-variable),
-- but ONLY while pullTime is live - a fight's END cancels the whole rotation set at
-- once with pullTime nil (verified: combat-end state=3 burst at elapsed 0), which the
-- guard drops. Bosses with NO signature/anycancel use their OWN gate (Gemellus =
-- unitCount), so there is deliberately NO generic survival fallback - a "lived >= Ns
-- then canceled = entry" guess would fire Gemellus's p2 on any early bar cancel, before
-- the 5-unit split, and firePhase would consume its cues unrecoverably.
if st == 3 and phaseTriggers and activeId and not PHASE_ENTRY_WARN[activeId] then
if PHASE_ENTRY_ANYCANCEL[activeId] then
if pullTime then tlEntry(nowElapsed()) end -- L'ura: any cancel = entry (combat only)
elseif PHASE_DUR_SIGNATURES[activeId] and isMainDur(durById and durById[id]) then
tlEntry(nowElapsed()) -- Kroluk/Vordaza: signature cancel = entry
end
end
elseif e == "ENCOUNTER_TIMELINE_EVENT_REMOVED" then
line = "id=" .. safeVal(a1)
if tlAddedAt and type(a1) == "number" then tlAddedAt[a1] = nil end
if durById and type(a1) == "number" then durById[a1] = nil end
elseif e == "ENCOUNTER_WARNING" then
local info = a1
local sev = info and info.severity
local wdur = info and info.duration
line = "dur=" .. safeVal(wdur) .. " sev=" .. safeVal(sev)
-- warning-gated ENTRY: the sub-phase telegraph. Match severity, and (when the boss
-- needs it) the warning's own duration to tell the sub-phase cast apart from routine
-- telegraphs of the same severity (Crawth = sev only; Lothraxion = sev 2 + dur 3.5).
local cfg = activeId and PHASE_ENTRY_WARN[activeId]
if phaseTriggers and cfg and type(sev) == "number"
and not (issecretvalue and issecretvalue(sev)) and sev >= cfg.sev then
local durOk = true
if cfg.dur then
durOk = type(wdur) == "number" and not (issecretvalue and issecretvalue(wdur))
and math.abs(wdur - cfg.dur) < DUR_EPSILON
end
if durOk then tlEntry(nowElapsed()) end
end
else
line = safeVal(a1)
end
local short = e:gsub("ENCOUNTER_TIMELINE_EVENT_", ""):gsub("ENCOUNTER_", "")
recordCapture("TL:" .. short, line, "")
if ns.debug then ns.Print("|cff66ccff[timeline " .. short .. "] " .. line .. "|r") end
end
-- Registered once on PLAYER_LOGIN, after BigWigs/DBM have loaded (see Core.lua).
function Scheduler.InitBossMods()
local sources = {}
local BW = _G.BigWigsLoader
if BW and BW.RegisterMessage then
local proxy = {} -- any table works as the CallbackHandler registrant
-- key (3rd arg) is the spellId for spell bars/messages. We act on the
-- at-event Message AND StartBar: for the HP/objective-gated abilities we gate
-- on, BigWigs draws the cast bar AT the cast, so timing matches; the debounce
-- collapses the Message+Bar pair into one occurrence.
BW.RegisterMessage(proxy, "BigWigs_Message", function(_, _, key) bossModSpell(key, "BW:Msg", true) end)
BW.RegisterMessage(proxy, "BigWigs_StartBar", function(_, _, key) bossModSpell(key, "BW:Bar", true) end)
BW.RegisterMessage(proxy, "BigWigs_SetStage", function(_, _, stage)
if phaseTriggers then recordCapture("BW:SetStage", "stage:" .. tostring(stage), "(stage)") end
if ns.debug then ns.Print("|cffffcc44[bossmod BW:SetStage] " .. tostring(stage) .. "|r") end
end)
sources[#sources + 1] = "BigWigs"
end
local DBM = _G.DBM
if DBM and DBM.RegisterCallback then
DBM:RegisterCallback("DBM_Announce", function(_, _, _, _, spellId) bossModSpell(spellId, "DBM:Ann", true) end)
DBM:RegisterCallback("DBM_TimerStart", function(_, _, _, _, _, _, spellId) bossModSpell(spellId, "DBM:Timer", false) end)
sources[#sources + 1] = "DBM"
end
-- Official Encounter Timeline feed (sanctioned in Midnight instances incl. M+).
-- Always on (diagnostic) when the client exposes it; independent of BigWigs/DBM.
if C_EncounterTimeline then
local tf = CreateFrame("Frame")
for _, ev in ipairs({
"ENCOUNTER_TIMELINE_EVENT_ADDED",
"ENCOUNTER_TIMELINE_EVENT_REMOVED",
"ENCOUNTER_TIMELINE_EVENT_STATE_CHANGED",
"ENCOUNTER_WARNING",
"INSTANCE_ENCOUNTER_ENGAGE_UNIT", -- boss-roster phase boundary (raid engage rule)
}) do
pcall(tf.RegisterEvent, tf, ev) -- pcall: older clients may not have the event
end
tf:SetScript("OnEvent", function(_, e, a1) onTimelineEvent(e, a1) end)
sources[#sources + 1] = "EncounterTimeline"
end
ns.bossModSources = sources
if ns.debug then
ns.Print("|cff88ccffCoolPlan boss-mod bridge: " .. (#sources > 0 and table.concat(sources, "+") or "NONE: install BigWigs+LittleWigs or DBM for live phases") .. "|r")
end
end
-- Build a copyable dump of the capture buffer (every boss-mod event this pull,
-- with elapsed time) - WoW chat isn't copyable, so /coolplan capture shows this
-- in the editor box. Used to curate each phase boss's live trigger spellId.
function Scheduler.GetCaptureText()
if #capture == 0 then
return "COOLPLAN CAPTURE: nothing recorded.\nArm a plan (or /coolplan testenc <id>) then pull a boss. Captures BigWigs (BW:*) AND official Encounter Timeline (TL:*) events. Then /coolplan capture."
end
local lines = {
"COOLPLAN CAPTURE (elapsed | source | value | name)",
" BW:* = BigWigs/DBM callbacks (id = spellId). TL:* = official C_EncounterTimeline.",
" Look for: TL:PHASE lines (does info.phase track the sub-phases?), and which",
" TL:ADDED / BW line lines up with each sub-phase entry/exit.",
}
for _, c in ipairs(capture) do
lines[#lines + 1] = string.format("%6.1fs | %-11s | %s | %s", c.t, c.label, tostring(c.v), c.name)
end
return table.concat(lines, "\n")
end
-- Run an arbitrary cue list (already filtered). Returns true if armed.
-- opts (optional): { preview=true, speed=1, onTick=fn } for the live preview,
-- and { phases = {...} } (live only) to enable phase re-anchoring.
local function run(cues, opts)
-- Callers set `activeId` just before calling run(); Stop() clears it, so save and
-- restore it across the cleanup. Without this, activeId is nil for the whole pull and
-- every per-encounter table lookup (PHASE_DUR_SIGNATURES / PHASE_ENTRY_WARN) misses.
local keepActiveId = activeId
Scheduler.Stop()
activeId = keepActiveId
local previewMode = opts and opts.preview
-- Capture-only arm: a phase boss with no saved plan still sets pullTime so
-- /coolplan capture timestamps are real (for trigger curation). No reminders.
local captureOnly = opts and opts.captureOnly
wipe(capture) -- fresh capture buffer per pull (for /coolplan capture)
lastTimelinePhase = nil
tlAddedAt = {}
durById = {}
tlLastAdvance = nil
tlLastSignal = nil
tlCursor = nil
raidCur = nil
raidSwapAt = nil
raidAddedAt = nil
lastHpLog = nil
lastBossCount = nil
queue = {}
pendingPhase = nil
phaseTriggers = nil
hasHealthTrigger = false
-- Build the live trigger table from the plan's phases. In preview there are no
-- real triggers, so phase cues are flattened to absolute (anchored at the pull)
-- just so the whole plan is visible in the Timeline test.
local phases = opts and opts.phases
if phases and not previewMode then
local triggers = {}
for _, p in ipairs(phases) do
local t = p.trigger
-- Register EVERY index>1 phase that carries a (recognized) trigger kind - even
-- when it has no spellId/pct (rotation-resume / TL-only exits). Such phases have
-- no BigWigs/CLEU match; they advance purely from the Encounter Timeline via
-- timelineAdvancePhase, so the index SLOT must exist or 2→3→4→5 breaks.
if p.index and p.index > 1 and t and t.kind then
triggers[#triggers + 1] = {
index = p.index, kind = t.kind, spellId = t.spellId,
occurrence = t.occurrence or 1, pct = t.pct, unitCount = t.unitCount,
seen = 0, fired = false,
}
if t.kind == "health" then hasHealthTrigger = true end
end
end
if #triggers > 0 then phaseTriggers = triggers end
if ns.debug and phaseTriggers then
recordCapture("tl:ARMED", "activeId=" .. tostring(activeId)
.. " warnEntry=" .. tostring(PHASE_ENTRY_WARN[activeId] ~= nil)
.. " durSig=" .. tostring(PHASE_DUR_SIGNATURES[activeId] ~= nil)
.. " triggers=" .. #phaseTriggers, "")
local ids = {}
for _, tr in ipairs(phaseTriggers) do
ids[#ids + 1] = "p" .. tr.index .. "=" .. tostring(tr.kind) .. ":" .. tostring(tr.spellId or tr.pct) .. "x" .. tostring(tr.occurrence or 1)
end
ns.Print("|cff88ccffCoolPlan armed " .. #phaseTriggers .. " phase trigger(s): " .. table.concat(ids, ", ") .. "|r")
end
end
-- Does this plan carry a multi-phase @phase table? Used below to suppress phase
-- cues we can't anchor (rather than fire them at a meaningless pull offset). A
-- dungeon phased plan always also sets phaseTriggers, so this matches the prior
-- `phaseTriggers` gate for dungeons and additionally covers raid plans (whose
-- @phase rows carry no trigger, so phaseTriggers stays nil).
local planHasPhases = false
if phases then
for _, p in ipairs(phases) do
if p.index and p.index > 1 then planHasPhases = true break end
end
end
-- Raid bosses advance phases by the duration-signature method (raidAdvance), not
-- the plan's triggers. Arm the linear phase counter so a raid plan's phase cues
-- defer to pendingPhase and fire on advance. Gate on THIS difficulty having a dur
-- table (not just the boss) - otherwise an uncovered difficulty would arm but
-- never advance, parking phase cues silently; leaving it unarmed routes them
-- through the explicit suppress path below instead.
local raidCfg = RAID_PHASE_DURS[activeId]
local raidCountCfg = RAID_COUNT[activeId]
local diff = instanceDifficulty()
local raidArmed = planHasPhases and not previewMode and (
(raidCfg ~= nil and raidCfg[diff] ~= nil) -- duration / engage boss
or (raidCountCfg ~= nil and raidCountCfg[diff] ~= nil) -- event-count boss (Crown Mythic)
)
-- raidSwapAt = 0 (not nil) so the debounce also blocks an advance in the first
-- RAID_PHASE_DEBOUNCE seconds after the pull (avoids a pull-burst false fire).
if raidArmed then raidCur, raidSwapAt, raidAddedAt = 1, 0, {} end
local function phaseHasTrigger(idx)
if not phaseTriggers then return false end
for _, tr in ipairs(phaseTriggers) do
if tr.index == idx then return true end
end
return false
end
local maxCast = 0
for _, c in ipairs(cues) do
local pidx = c.phaseIndex
if pidx and pidx > 1 and (phaseHasTrigger(pidx) or raidArmed) then
-- defer until that phase's trigger (dungeon) or duration advance (raid) fires
pendingPhase = pendingPhase or {}
pendingPhase[pidx] = pendingPhase[pidx] or {}
local list = pendingPhase[pidx]
list[#list + 1] = c
elseif (not previewMode) and planHasPhases and pidx and pidx > 1 then