Repository navigation
Expand file tree
/
Copy pathmain.lua
More file actions
2876 lines (2715 loc) · 109 KB
/
Copy pathmain.lua
File metadata and controls
2876 lines (2715 loc) · 109 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
--=========================================================================================
-- Main module for SwedgeTimer
--=========================================================================================
local addon_name, st = ...
local version = "2.1.3"
-- local version = "2.0"
local ST = LibStub("AceAddon-3.0"):NewAddon(addon_name, "AceConsole-3.0", "AceEvent-3.0")
-- Exposed for the diagnostic report (diag.lua); keep in sync with the ## Version
-- header in every toc.
ST.addon_version = version
local LSM = LibStub("LibSharedMedia-3.0")
local STL = LibStub("LibClassicSwingTimerAPI", true)
-- The swing library is only shipped on clients with a classic combat log (see the
-- flavor TOCs). WoW Forever has no classic combat log but exposes PLAYER_SWING, so
-- swing_source.lua provides a compatible engine there; Retail keeps the whole
-- initialisation flow intact with an inert stand-in, and the user is told in
-- init_libs. The same stand-in replaces an engine that loads but never reports
-- itself ready (see the init deadline in OnInitialize).
local function make_swing_stub()
return {
RegisterCallback = function(...) end,
UnregisterCallback = function(...) end,
-- Unknown (no engine), never a plausible 1 s cycle: the old fake shape made a
-- dead engine indistinguishable from a live idle swing. init_timers turns the
-- nil shape into the per-hand engine_dead state the bars render (H1).
SwingTimerInfo = function(...)
return nil, nil, nil
end,
}
end
-- The vendored library always wins when it is present (the classic client line);
-- st.swing_source only reports itself available on the FOREVER family.
-- ST.swing_engine records which of the three modes is live, for the diagnostic report.
local SWING_ENGINE_AVAILABLE = STL ~= nil
if SWING_ENGINE_AVAILABLE then
ST.swing_engine = "vendored-lib"
elseif st.swing_source and st.swing_source.available then
STL = st.swing_source
SWING_ENGINE_AVAILABLE = true
ST.swing_engine = "forever-source"
else
STL = make_swing_stub()
ST.swing_engine = "inert-standin"
end
-- D8 rewire (2026-10-22): the init deadline in OnInitialize may swap STL for
-- the inert stand-in when the engine never reports ready. Keep the reporting
-- engine (and the name ST.swing_engine had for it) so a late
-- SWING_TIMER_INFO_INITIALIZED can re-point STL at it and re-register.
local REAL_STL = STL
local REAL_SWING_ENGINE = ST.swing_engine
local LRC = LibStub("LibRangeCheck-2.0")
local LLM = LibStub("LibLatencyMonitor")
local LGC = LibStub("LibGlobalCooldown")
local LWIN = LibStub("LibWindow-1.1")
-- Chat output policy: st.utils.print_msg (utils.lua) is the addon's single chat path
-- for module messages, and ST:Print (AceConsole) is its second, established one - both
-- are named explicitly below. There is deliberately no local alias for the client's raw
-- global print here: a bare call to it would bypass both, so the identifier is kept
-- out of the file on purpose.
-- Cross-client API wrappers (see compat.lua).
local api = st.api
ST.DEATHKNIGHT = {}
ST.DEMONHUNTER = {}
ST.DRUID = {}
ST.EVOKER = {}
ST.HUNTER = {}
ST.MAGE = {}
ST.MONK = {}
ST.PALADIN = {}
ST.PRIEST = {}
ST.ROGUE = {}
ST.SHAMAN = {}
ST.WARLOCK = {}
ST.WARRIOR = {}
--=========================================================================================
-- Funcs/iterables to automate common tasks
--=========================================================================================
local SwingTimerInfo = function(hand)
return STL:SwingTimerInfo(hand)
end
-- Lua 5.1 has no table.pack (pinned by tests/main_init_state.lua and the
-- enemy_swing.lua header note): this shim provides it for 5.1 clients.
function table.pack(...)
return { n = select("#", ...), ... }
end
ST.interfaces_are_initialised = false
-- keyword-accessible tables to handle case-switching
ST.mainhand = {}
ST.offhand = {}
ST.ranged = {}
ST.class_hands = {
DEATHKNIGHT = { "mainhand", "offhand" },
DRUID = { "mainhand" },
HUNTER = { "mainhand", "offhand", "ranged" },
MAGE = { "mainhand", "ranged" },
PALADIN = { "mainhand" },
PRIEST = { "mainhand", "ranged" },
ROGUE = { "mainhand", "offhand", "ranged" },
SHAMAN = { "mainhand", "offhand" },
WARLOCK = { "mainhand", "ranged" },
WARRIOR = { "mainhand", "offhand", "ranged" },
}
-- Hands for the player's class. `class_hands` is keyed by the uppercase class token
-- (UnitClass's second return); a lowercase spelling is tolerated defensively, because a
-- legacy option-table definition used to clobber this table with lowercase keys - which
-- made iter_hands() index nil during OnInitialize and aborted the rest of init at login.
function ST:get_class_hands()
local hands = self.class_hands[self.player_class]
if hands == nil and type(self.player_class) == "string" then
hands = self.class_hands[string.upper(self.player_class)]
end
return hands or {}
end
function ST:iter_hands()
-- Iterates over melee/ranged/offhand
local i = 0
local hands = self:get_class_hands()
local n = #hands
return function()
i = i + 1
if i <= n then
return hands[i]
end
return nil
end
end
-- True when the player's class has a bar for that hand. The class hands list is the
-- same authority iter_hands() uses (init_timers builds exactly one bar per entry),
-- so a class without that hand never reaches a hand-specific frame call. F2: a
-- paladin or a druid has an offhand weapon but no offhand bar, so a hand-specific
-- call for them would touch frames that were never created.
function ST:has_hand(hand)
local hands = self:get_class_hands()
for i = 1, #hands do
if hands[i] == hand then
return true
end
end
return false
end
function ST:generic_iter(array)
local i = 0
local n = #array
return function()
i = i + 1
if i <= n then
return array[i]
end
end
end
function ST:is_value_in_array(value, array)
for _, v in pairs(array) do
if v == value then
return true
end
end
return false
end
function ST:convert_color(t, new_alpha)
-- Convert standard 0-255 colors down to WoW 0-1 ranges.
local r, g, b, a = unpack(t)
a = new_alpha or a
r = r / 255
g = g / 255
b = b / 255
return r, g, b, a
end
function ST:convert_color_up(t, new_alpha)
-- Convert WoW 0-1 ranges up to standard 0-255 ranges.
local r, g, b, a = unpack(t)
a = new_alpha or a
r = r * 255
g = g * 255
b = b * 255
return r, g, b, a
end
function ST:get_anchor_frame(hand)
-- Gets the main anchor frame for the hand.
local state = self[hand]
if type(state) ~= "table" then
return nil
end
return state.anchor_frame
end
function ST:get_hiding_anchor_frame(hand)
-- Get the hiding anchor frame, which also gets calls to show/hide.
local anchor = self:get_anchor_frame(hand)
if anchor == nil then
return nil
end
return anchor.hiding_anchor_frame
end
function ST:get_bar_frame(hand)
-- Gets the bar frame for the hand.
-- This is the frame with the backdrop, but *not* the frame
-- with the bar visuals - that's a child of this one.
local anchor = self:get_anchor_frame(hand)
if anchor == nil then
return nil
end
return anchor.bar_frame
end
function ST:get_visuals_frame(hand)
local bar_frame = self:get_bar_frame(hand)
if bar_frame == nil then
return nil
end
return bar_frame.visuals_frame
end
function ST:get_profile_table()
return self.db.profile
end
function ST:get_class_table()
-- The profile row for the playing class. nil is a legitimate answer: the
-- accessor is the shared seam every module type-checks (bars.lua pcall+type,
-- hud.lua `or {}`, practice_ui.lua), and pre-init / sandbox callers have no
-- AceDB yet. Returning nil here (instead of index-nil-throwing) preserves the
-- one behaviour every caller already branches on; the caller decides whether
-- that is "bar disabled" (main), "no HUD" (hud) or "defaults" (practice).
local profile = self.db and self.db.profile
if type(profile) ~= "table" then
return nil
end
return profile[self.player_class]
end
function ST:get_hand_table(hand)
-- Same contract as get_class_table: a class row without this hand (a class
-- that does not ship the hand) answers nil instead of throwing, and every
-- per-hand consumer already treats nil as "not configured / not built".
local class = self:get_class_table()
if type(class) ~= "table" then
return nil
end
return class[hand]
end
function ST:get_in_range(hand)
if hand == "ranged" then return self.in_ranged_range else return self.in_melee_range end
end
function ST:hide_bar(hand)
-- Twist Practice owns the mainhand bar while its session runs: the simulated swing
-- must stay visible even though the range-finder loop (set_bar_visibilities every
-- 0.05 s) re-applies the user's show conditions. Every other hand keeps them.
if hand == "mainhand" and st.practice_ui and st.practice_ui.is_active() then
return
end
-- HU-22 lazy bars: an unbuilt hand has no frames (bars.lua getters are
-- nil-safe); hiding it is a no-op, exactly like a disabled bar.
local bar_frame = self:get_bar_frame(hand)
if bar_frame == nil then
return
end
bar_frame:Hide()
local hiding_anchor_frame = self:get_hiding_anchor_frame(hand)
if hiding_anchor_frame ~= nil then
hiding_anchor_frame:Hide()
end
end
function ST:check_bar_enable_settings(db_class, hand)
-- Function to check if the settings indicate the bar should be shown or hidden.
-- Twist Practice owns the mainhand bar while its session runs (archive parity: the
-- archived build's should_show_bar hook forced it visible): the simulated swing
-- must show regardless of the user's enable/spec gating.
if hand == "mainhand" and st.practice_ui and st.practice_ui.is_active() then
return true
end
-- No class row means no bar configuration at all (an unsupported class, or a
-- caller before AceDB is up): the bar cannot be enabled without settings, so
-- the answer is the same one a disabled bar gets. Never show an unconfigured
-- bar on invented defaults.
if type(db_class) ~= "table" then
return false
end
if not db_class.class_enabled then -- Global enable/disable
return false
end
if not self:bar_is_enabled(hand) then -- Hand enabled
return false
end
if self.current_talent_group == 1 then -- Spec 1 enabled
if not db_class.enable_spec1 then
return false
end
end
if self.current_talent_group == 2 then -- Spec 1 enabled
if not db_class.enable_spec2 then
return false
end
end
return true -- If we get to the end the bar should be shown.
end
function ST:show_bar(hand)
local db_class = self:get_class_table()
-- HU-22 lazy bars: an unbuilt hand behaves exactly like a disabled bar -
-- nothing to show (its gate failed, so the check below would hide it).
if self:get_bar_frame(hand) == nil then
return
end
if self:check_bar_enable_settings(db_class, hand) then
if self.player_class == "DRUID" then
if self.should_show_bar_this_form then
self:get_bar_frame(hand):Show()
local hiding_anchor_frame = self:get_hiding_anchor_frame(hand)
if hiding_anchor_frame ~= nil then
hiding_anchor_frame:Show()
end
else
self:hide_bar(hand)
end
elseif self.player_class == "WARRIOR" then
if db_class and db_class.hide_in_tank_spec and self.has_imp_def_stance then
self:hide_bar(hand)
elseif db_class and db_class.hide_in_arms_spec and self.has_bladestorm then
self:hide_bar(hand)
else
self:get_bar_frame(hand):Show()
local hiding_anchor_frame = self:get_hiding_anchor_frame(hand)
if hiding_anchor_frame ~= nil then
hiding_anchor_frame:Show()
end
end
else
self:get_bar_frame(hand):Show()
local hiding_anchor_frame = self:get_hiding_anchor_frame(hand)
if hiding_anchor_frame ~= nil then
hiding_anchor_frame:Show()
end
end
else
self:hide_bar(hand)
end
end
function ST:lock_frames()
for hand in self:iter_hands() do
-- HU-22: unbuilt hands have no anchor frame to lock.
local f = self:get_anchor_frame(hand)
if f ~= nil then
f:SetMovable(false)
f:EnableMouse(false)
f:SetScript("OnMouseWheel", nil)
end
end
end
function ST:unlock_frames()
for hand in self:iter_hands() do
-- HU-22: unbuilt hands have no anchor frame to unlock.
local f = self:get_anchor_frame(hand)
if f ~= nil then
f:SetMovable(true)
f:EnableMouse(true)
f:SetScript("OnMouseWheel", function(_, dir)
LWIN.OnMouseWheel(f, dir)
local ACR = LibStub("AceConfigRegistry-3.0")
ACR:NotifyChange(ST.options_table_name)
end
)
end
end
end
--=========================================================================================
-- Funcs to initialise the addon
--=========================================================================================
function ST:OnInitialize()
-- Get player class first
self.player_class_pretty, self.player_class = UnitClass("player")
-- Register our media
LSM:Register(
"border", "Square Full White", [[Interface\BUTTONS\WHITE8X8]]
)
LSM:Register(
"font", "Expressway", [[Interface\Addons\SwedgeTimer\Media\Fonts\Expressway.ttf]]
)
LSM:Register(
"statusbar", "Minimalist", [[Interface\Addons\SwedgeTimer\Media\Textures\Minimalist.tga]]
)
-- The operator's texture, shipped as the DEFAULT status-bar texture for the
-- addon's progress bars (operator order, 2026-10-20 - that order IS the gate
-- for the default-visible change). It is registered in the "statusbar"
-- registry, so it appears in the EXISTING texture dropdowns
-- (option_table_presets.lua, values = LSM:HashTable("statusbar")) with no new
-- widget. "Minimalist" stays registered as the shipped fallback key bars.lua
-- degrades to when a texture fetch comes back nil.
LSM:Register(
"statusbar", "Wglass", [[Interface\Addons\SwedgeTimer\Media\StatusBar\Wglass.tga]]
)
-- Addon database
local SwedgeTimerDB = LibStub("AceDB-3.0"):New(addon_name .. "DB", self.defaults, true)
self.db = SwedgeTimerDB
-----------------------------------------------------------
-- Dynamically construct the options tables
-----------------------------------------------------------
local AC = LibStub("AceConfig-3.0")
local ACD = LibStub("AceConfigDialog-3.0")
self:set_opts()
self.options_table_name = addon_name .. "_Options"
AC:RegisterOptionsTable(self.options_table_name, self.opts_table)
self.optionsFrame = ACD:AddToBlizOptions(self.options_table_name, addon_name)
-- Profile options
local profiles = LibStub("AceDBOptions-3.0"):GetOptionsTable(self.db)
AC:RegisterOptionsTable(addon_name .. "_Profiles", profiles)
ACD:AddToBlizOptions(addon_name .. "_Profiles", "Profiles", addon_name)
-----------------------------------------------------------
-- Callbackhandlers for letting the addon know that dependent
-- libraries have their interfaces enabled.
-----------------------------------------------------------
-- Init our lib interfaces only once the range and swing timer
-- libs are both loaded, as they are interdependent
-- init_libs has a check to ensure this only happens once per reload
self.lrc_ready = false
self.lrc_unavailable = false -- set by the init deadline if LRC never reports ready
self.stl_ready = not SWING_ENGINE_AVAILABLE -- inert stand-in counts as ready
self.llm_ready = false
LRC.RegisterCallback(self, LRC.CHECKERS_CHANGED, function()
self.lrc_ready = true
-- A late report means the init deadline was wrong about LibRangeCheck:
-- clear the fallback flag so /st diag stops reporting lrc=fallback.
-- Range state does not depend on it (exact yards, api.UnitDistance).
self.lrc_unavailable = false
self:init_libs()
end
)
STL.RegisterCallback(self, "SWING_TIMER_INFO_INITIALIZED", function()
self.stl_ready = true
-- D8 rewire (2026-10-22): the init deadline below may have substituted
-- the inert stand-in and finished init on it, leaving the bars
-- engine_dead for the session. A late report from the real engine
-- re-points STL, restores the engine line, re-registers
-- (register_timer_callbacks carries the double-reg guard) and re-seeds
-- the hands from the live engine -- the same seed init_timers runs,
-- minus the frame creation (the frames already exist from the first
-- init, and init_visuals_template must not run twice).
if self.init_deadline_fired and STL ~= REAL_STL then
STL = REAL_STL
SWING_ENGINE_AVAILABLE = true
ST.swing_engine = REAL_SWING_ENGINE
self:register_timer_callbacks()
self:check_weapons()
for hand in self:iter_hands() do
local t = { SwingTimerInfo(hand) }
if type(t[1]) ~= "number" then
self[hand].speed = nil
self[hand].ends_at = nil
self[hand].start = nil
self[hand].engine_dead = (SWING_ENGINE_AVAILABLE == false)
else
if t[1] == 0 then
t[1] = 0.1
end
self[hand].engine_dead = false
self[hand].speed = t[1]
self[hand].ends_at = t[2]
self[hand].start = t[3]
end
end
end
self:init_libs()
end
)
-- A library that never reports ready (an incompatible client, or an error
-- inside its own initialisation) must not stall init_libs forever: after a
-- grace period, initialise anyway, with the missing library disabled for
-- this session. Range state does not depend on LibRangeCheck any more; the flag
-- only feeds /st diag's lrc= line, and a late report clears it (callback above).
C_Timer.After(5, function()
if self.interfaces_are_initialised then
return
end
-- Recorded for the diagnostic report: a library never reported ready in
-- time, so this deadline had to finish the initialisation.
self.init_deadline_fired = true
if not self.lrc_ready then
self.lrc_unavailable = true
self.lrc_ready = true
end
if not self.stl_ready then
STL = make_swing_stub()
SWING_ENGINE_AVAILABLE = false
ST.swing_engine = "inert-standin"
self.stl_ready = true
end
self:init_libs()
end
)
-----------------------------------------------------------
-- Callbackhandlers that are less sensitive to load orders.
-----------------------------------------------------------
-- LibLatencyMonitor
LLM.RegisterCallback(self, LLM.LATENCY_CHANGED, self.callback_event_handler)
-- LibGlobalCooldown
LGC.RegisterCallback(self, LGC.GCD_STARTED, self.callback_event_handler)
LGC.RegisterCallback(self, LGC.GCD_OVER, self.callback_event_handler)
LGC.RegisterCallback(self, LGC.GCD_DURATIONS_UPDATED, self.callback_event_handler)
-----------------------------------------------------------
-- The set of containers and frames used in the addon.
-- Even though many are nilled, this collates them so
-- we have a reference to each container visually
-- in the code.
-----------------------------------------------------------
-- Character info containers
self.player_guid = UnitGUID("player")
-- F2: the pre-probe default only. check_weapons derives the real value from the
-- offhand-presence probe - the PLAYER_TARGET_SET_ATTACKING guard reads it, so it
-- must never stay at this initial value.
self.has_oh = false
self.has_ranged = false
self.mh_timer = 0
self.oh_timer = 0
self.ranged_timer = 0
-- Character state containers
self.in_combat = false
-- Fight clock for totem-maintain gates (shaman Searing): api.get_time() of
-- the last combat-enter; nil outside combat or when the clock did not
-- answer (readers stay nil-safe and never prompt on a guess).
self.fight_start_at = nil
-- The live pull clock (BS-8 item 2 / P-17): the absolute timestamp of the
-- expected pull, in the api.get_time() domain. nil = unknown (never a
-- guess). See the pull-clock section below.
self.pull_at = nil
self.is_melee_attacking = false
self.has_target = false
self.has_attackable_target = false
-- Movement state for the hunter ranged cast-window safety check (phase 1):
-- PLAYER_STARTED_MOVING / PLAYER_STOPPED_MOVING keep api.get_time()-domain stamps,
-- and classes/hunter.lua consumes them through
-- hunter_window.is_stopped_position_safe.
self.is_moving = false
self.last_stopped_moving_at = nil
-- Next-action guide (paladin): landed melee swings this fight; incremented on
-- swing start in combat, reset by the guide display when guidance stops.
self.guide_melee_count = 0
self.current_talent_group = api.GetActiveTalentGroup()
self.form_index = api.GetShapeshiftForm()
self.is_cat_or_bear = false
if self.player_class == "DRUID" then
if self.form_index == 1 or self.form_index == 3 then
self.is_cat_or_bear = true
end
end
self.should_show_bar_this_form = true
-- MH timer containers
self.mainhand.start = nil
self.mainhand.speed = nil
self.mainhand.ends_at = nil
self.mainhand.inactive_timer = nil
self.mainhand.has_weapon = true -- always true
self.mainhand.is_full = true -- deliberately true
self.mainhand.is_full_timer = nil
self.mainhand.is_paused = false
self.mainhand.current_progress = false
-- OH containers
self.offhand.start = nil
self.offhand.speed = nil
self.offhand.ends_at = nil
self.offhand.inactive_timer = nil
self.offhand.has_weapon = false
self.offhand.is_full = false
self.offhand.is_full_timer = nil
self.offhand.is_paused = false
-- ranged containers
self.ranged.start = nil
self.ranged.speed = nil
self.ranged.ends_at = nil
self.ranged.inactive_timer = nil
self.ranged.has_weapon = false
self.ranged.is_full = false
-- Auto Shot sync state (hunter phase 1; the maths live in hunter_window.lua):
-- synced_duration - the auto-shot cycle length last read from
-- GetSpellCooldown(75), api.get_time()-domain seconds
-- hidden_window_start / hidden_window_duration
-- - cached hidden cast-window bounds (phase 2 render consumes
-- them); nil means "recompute"
-- auto_repeat_active - last IsAutoRepeatSpell(75) reading (mounted hunters
-- always read false)
-- last_restart_at - clock stamp of the last deduped ranged start
self.ranged.synced_duration = nil
self.ranged.hidden_window_start = nil
self.ranged.hidden_window_duration = nil
self.ranged.auto_repeat_active = false
self.ranged.last_restart_at = nil
-- GCD info containers
self.gcd = {}
self.gcd.duration = nil
self.gcd.started = nil
self.gcd.expires = nil
self.gcd.phys_length = nil
self.gcd.spell_length = nil
self.gcd1_phys_time_before_swing = nil
self.gcd1_spell_time_before_swing = nil
self.gcd1_marker_position = nil
-- self.gcd2_phys_time_before_swing = nil
-- self.gcd2_spell_time_before_swing = nil
-- self.gcd2_marker_position = nil
-- Latency info containers
self.latency = {}
self.latency.update_interval_s = 1
self.latency.home_ms = 0
self.latency.calibrated_home_ms = 0
self.latency.world_ms = 0
self.latency.calibrated_world_ms = 0
-----------------------------------------------------------
-- Register WoW API events
-----------------------------------------------------------
self:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED")
self:RegisterEvent("CHARACTER_POINTS_CHANGED")
self:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
self:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
self:RegisterEvent("PLAYER_ENTERING_WORLD")
-- F1: the session's end. The chained error handler (installed in init_libs) is
-- removed again here, so the next session never chains onto this session's.
self:RegisterEvent("PLAYER_LOGOUT")
self:RegisterEvent("PLAYER_REGEN_ENABLED")
-- Movement state and Auto Shot sync signals for the hunter ranged feature (phase 1).
-- The movement events and SPELL_UPDATE_COOLDOWN are valid on every supported
-- family; START_AUTOREPEAT_SPELL / STOP_AUTOREPEAT_SPELL are registered only where
-- the client's event-validity probe accepts them (the same pattern as
-- PLAYER_TARGET_SET_ATTACKING just below) - registering an unknown event throws.
-- Without a probe, the family decides: the classic client line keeps them (the
-- live clients expose them too; the probe governs wherever it exists).
self:RegisterEvent("PLAYER_STARTED_MOVING")
self:RegisterEvent("PLAYER_STOPPED_MOVING")
self:RegisterEvent("SPELL_UPDATE_COOLDOWN")
local autorepeat_events_valid = true
if C_EventUtils and C_EventUtils.IsEventValid then
autorepeat_events_valid = C_EventUtils.IsEventValid("START_AUTOREPEAT_SPELL")
and C_EventUtils.IsEventValid("STOP_AUTOREPEAT_SPELL")
elseif st.is_mainline then
autorepeat_events_valid = false
end
if autorepeat_events_valid then
self:RegisterEvent("START_AUTOREPEAT_SPELL")
self:RegisterEvent("STOP_AUTOREPEAT_SPELL")
end
-- PLAYER_TARGET_SET_ATTACKING exists on the classic client line only, and
-- registering an unknown event throws, so gate it by family and probe where
-- the event-validity API is available.
local target_attacking_valid = not st.is_mainline
if target_attacking_valid and C_EventUtils and C_EventUtils.IsEventValid then
target_attacking_valid = C_EventUtils.IsEventValid("PLAYER_TARGET_SET_ATTACKING")
end
if target_attacking_valid then
self:RegisterEvent("PLAYER_TARGET_SET_ATTACKING")
end
self:RegisterEvent("PLAYER_REGEN_DISABLED")
self:RegisterEvent("PLAYER_ENTER_COMBAT")
self:RegisterEvent("PLAYER_LEAVE_COMBAT")
self:RegisterEvent("UNIT_AURA")
-- Resource tracking for the warrior/druid rage-vs-queued-cost colouring. The
-- handler (ST:UNIT_POWER_FREQUENT) already exists; without this registration the
-- entire rage feature was unreachable.
self:RegisterEvent("UNIT_POWER_FREQUENT")
-- The classic combat log feeds the paladin twist state machine and the warrior
-- Overpower dodge-proc window (reference State.lua:1315-1321/1340-1349), plus the
-- neutral pre-pass in ST:COMBAT_LOG_EVENT_UNFILTERED (extra-attack suppression
-- and incoming-parry haste, reference State.lua:1305-1328/1365-1369). Only the
-- classic client line has it; Mainline-code clients (Retail, Forever) have no
-- classic combat log, so no window can ever arm there. Every classic-line class
-- registers (G2: the neutral handling is class-independent - the pre-pass arms
-- suppression/parry for all hands on every class, and the class dispatch below
-- still forwards only to the modules that read the log); Mainline-code clients
-- never register.
if not st.is_mainline then
self:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
end
self:RegisterEvent("UNIT_SPELLCAST_FAILED_QUIET")
self:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
-- Player spellcast lifecycle for the class modules (weave assist and friends).
-- Unlike PLAYER_TARGET_SET_ATTACKING these events exist on every supported
-- client, so they need no family/validity gate.
self:RegisterEvent("UNIT_SPELLCAST_START")
self:RegisterEvent("UNIT_SPELLCAST_DELAYED")
self:RegisterEvent("UNIT_SPELLCAST_STOP")
self:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
self:RegisterEvent("UNIT_SPELLCAST_FAILED")
self:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")
self:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
self:RegisterEvent("UNIT_TARGET")
self:RegisterEvent("UPDATE_SHAPESHIFT_FORM")
-----------------------------------------------------------
-- Register Slashcommands
-----------------------------------------------------------
self:register_slashcommands()
end
--=========================================================================================
-- Class activation (operator order 2026-10-21)
--=========================================================================================
-- The class files register their body as a loader in st.class_loaders at load; the
-- SELECTED class is activated here, during init, because the SavedVariables are not
-- readable while the files execute. Activation runs before post_init, whose class hook
-- is what brings up the practice panel and the HUD (tests/main_init_state.lua pins that
-- order). With the default "Auto" the active class is the one being played, so a rogue
-- session behaves exactly as it did when every class file ran at load.
-- Fail closed: a missing loader or a loader that throws never aborts init. On a throw
-- the class table is reset to a fresh empty table (no half-state) and the user sees one
-- clear line. A class file that still runs its body at load (the pre-loader shape) is
-- already populated and is never reported as missing.
function ST:activate_class()
if self.class_activated then
return self.class_activated
end
self.class_activated = true
-- The shared layer's one fact about class features (C7): the class whose
-- loader ran. nil until a loader succeeds, so a shared module always reads
-- ST.active_class and never guesses from player_class.
self.active_class = nil
-- No class row means no stored class-module choice; nil selection reads as
-- "Auto" below (the shipped default), i.e. activate the class being played.
local class_db = self:get_class_table()
local selection = type(class_db) == "table" and class_db.class_activation or nil
local explicit = type(selection) == "string" and string.lower(selection) ~= "auto"
if not explicit then
selection = self.player_class
end
local class = type(selection) == "string" and string.upper(selection) or nil
local loaders = st.class_loaders
local loader = class and loaders and loaders[class] or nil
if type(loader) ~= "function" then
local already_loaded = class ~= nil and type(self[class]) == "table"
and next(self[class]) ~= nil
-- Auto on a class this addon has no module for is today's unsupported-class
-- path and stays silent; an explicit pick is intent, so it gets the notice.
if explicit and not already_loaded then
self:Print("SwedgeTimer: no class module is available for "
.. tostring(class or selection) .. "; its class features stay disabled.")
end
return false, "no-loader"
end
local ok, err = pcall(loader)
if not ok then
if class then
self[class] = {}
end
self:Print("SwedgeTimer: the " .. tostring(class) .. " class module failed to load ("
.. tostring(err) .. "); its class features stay disabled for this session.")
return false, err
end
-- R0 (class-isolation 2026-10-21): record the class that actually owns the
-- populated module table. The shared layer keys every class feature on this
-- field, so an explicit non-playing pick behaves as the selected class.
self.active_class = class
return true
end
function ST:init_libs()
-- This function inits our interfaces to the libraries we use,
-- but only once those libraries are fully ready and have
-- all of the information necessary.
if self.interfaces_are_initialised then
return
end
if not (self.lrc_ready and self.stl_ready) then
return
end
-- Operator order 2026-10-21: only the SELECTED class's module executes. This is
-- the first point where the SavedVariables (AceDB) are readable, and it is before
-- the class post_init hook and the practice/HUD init that hook performs.
self:activate_class()
self.interfaces_are_initialised = true
-- F1 (deep audit 2026-10-19): the chained error handler was never installed, so
-- the diagnostic report's "errors none" line was a false negative - with no
-- handler there was nothing that could ever have been observed. Install the
-- chain now (the interfaces are up, so the rest of the session is covered) and
-- remove it again on PLAYER_LOGOUT. The handler itself lives in diag.lua; this
-- is the only caller.
if st.diag and type(st.diag.install_error_handler) == "function" then
st.diag.install_error_handler()
end
self:init_timers()
self:init_range_finders()
self:post_init()
-- If requested, print a welcome message once everything is
-- initialised properly.
local db = self:get_profile_table()
if db.welcome_message then
self:Print("Version " .. version .. " loaded!")
end
-- No swing engine at all (Retail, or a Forever engine that never reported
-- ready - see the init deadline): say so once, instead of leaving the user
-- wondering why the swing bars never move.
if not SWING_ENGINE_AVAILABLE then
self:Print("Swing timer bars are unavailable on this client; range and GCD features remain active.")
end
-- Range state no longer depends on LibRangeCheck (exact yards, api.UnitDistance):
-- a library that missed the deadline needs no disable notice here. The lrc_ready /
-- lrc_unavailable flags stay for /st diag's lrc= line only.
-- One-line readiness self-report for the owner's test session; /st diag prints
-- the verbose form. Print-only, pcall-guarded end to end (see diag.lua).
if st.diag then
st.diag.report(self)
end
end
-- HU-22 lazy-bars build point (init / equip; the options layer reuses this
-- method when a hand is enabled mid-session): bring one hand's frames up
-- through the display lane's gate and install its tick exactly once.
-- ST:ensure_bar (bars.lua) builds idempotently and reports false when gated
-- off; environments without bars.lua keep the legacy unconditional build, so
-- offline suites that stub the visuals keep their exact behaviour.
function ST:bring_up_hand(hand)
if type(self.ensure_bar) == "function" then
if not self:ensure_bar(hand) then
return false
end
else
self:init_visuals_template(hand)
self:set_bar_texts(hand)
end
local bar_frame = self:get_bar_frame(hand)
if bar_frame == nil then
return false
end
if bar_frame:GetScript("OnUpdate") ~= nil then
return true
end
-- hook the onupdate (st.diag may install a per-tick counter wrapper; default OFF
-- installs the original untouched - see diag.lua's wrap_onupdate)
local onupdate = self[hand].onupdate
if st.diag and st.diag.wrap_onupdate then
onupdate = st.diag.wrap_onupdate(self, hand, onupdate)
end
bar_frame:SetScript("OnUpdate", onupdate)
return true
end
function ST:init_timers()
self:register_timer_callbacks()
self:check_weapons()
for hand in self:iter_hands() do
local t = { SwingTimerInfo(hand) }
if type(t[1]) ~= "number" then
-- Unknown engine shape (the inert stand-in returns nil): never invent a
-- cycle. The per-hand flag is what bars.lua renders as "engine offline".
self[hand].speed = nil
self[hand].ends_at = nil
self[hand].start = nil
self[hand].engine_dead = (SWING_ENGINE_AVAILABLE == false)
else
-- If the player has a shield, the below check prevents zero
-- division errors in the offhand timer bar initialisation.
if t[1] == 0 then
t[1] = 0.1
end
self[hand].engine_dead = false
self[hand].speed = t[1]
self[hand].ends_at = t[2]
self[hand].start = t[3]
end
-- HU-22 init build point: frames exist only when the hand is enabled
-- AND in use. State seeds above stay unconditional so /st diag reads
-- the engine even for hands with no frames.
self:bring_up_hand(hand)
end
end
function ST:register_timer_callbacks()
-- D8 rewire guard (2026-10-22): init_timers runs once per init, but the
-- late-INFO rewire above re-runs registration on a different engine object.
-- Re-registering on the same v7 registry would overwrite the same
-- self-keyed entries (harmless), but never do it twice all the same.
if self.swing_timer_registered_on == STL then
return
end
self.swing_timer_registered_on = STL
STL.RegisterCallback(self, "SWING_TIMER_CLIPPED", self.callback_event_handler)
STL.RegisterCallback(self, "SWING_TIMER_DELTA", self.callback_event_handler)
STL.RegisterCallback(self, "SWING_TIMER_PAUSED", self.callback_event_handler)
STL.RegisterCallback(self, "SWING_TIMER_START", self.callback_event_handler)
STL.RegisterCallback(self, "SWING_TIMER_STOP", self.callback_event_handler)
STL.RegisterCallback(self, "SWING_TIMER_UPDATE", self.callback_event_handler)
end
function ST:check_weapons()
-- Detect what weapon types are equipped.
-- Before we used the LibClassicSwinngTimer function to get the attack speeds.
-- This lib used the same events that this function is triggered on internally,
-- which led to inconsistencies depending on callback order. Using the basic
-- WoW API to make these checks fixes the issue.
local _, speed_offhand = api.UnitAttackSpeed("player")
speed_offhand = speed_offhand or 0
local speed_ranged = api.UnitRangedDamage("player") or 0
if speed_offhand == 0 then
self.offhand.has_weapon = false
else
self.offhand.has_weapon = true
end
if speed_ranged == 0 then
self.ranged.has_weapon = false
else
self.ranged.has_weapon = true
end
-- F2 (deep audit 2026-10-19): has_oh is the offhand-presence guard of
-- ST:PLAYER_TARGET_SET_ATTACKING. It was initialised false and never set again,
-- so that handler always returned at its guard and the offhand re-sync never ran
-- in production. It is derived here from the very same probe that fills
-- offhand.has_weapon, so the two can never disagree, and it follows every weapon
-- change (PLAYER_EQUIPMENT_CHANGED calls this function for the weapon slots).
self.has_oh = self.offhand.has_weapon
end
function ST:post_init()
-- Takes care of any miscellaneous widget/state manipulation that needs to run
-- once the libraries and addon are initialised.
self:set_gcd_times_before_swing_seconds()
self:on_latency_update()
-- self:get_druid_talent_info()
self:get_talent_information()
self:determine_form_visibility_flag()
for hand in self:iter_hands() do
self:set_gcd_marker_positions(hand)
-- self:set_bar_full_state(hand)
self:on_bar_inactive(hand)
self[hand].is_full = true
end
local post_init_class = self[self.player_class]
if type(post_init_class) == "table" and post_init_class.post_init then
-- R4 (class post_init isolation, 2026-10-21): a throwing class post_init used to
-- abort ST:post_init before the shared widgets below (frame lock state, bar
-- visibility, the TBC aura poll), and the error escaped into init_libs. One
-- guarded site per class: the first throw is announced, every throw is counted
-- and the site is quarantined after st.guard.QUARANTINE_THROWS (25), exactly
-- like the other hook sites.
st.guard.run(self, "class:" .. tostring(self.player_class) .. ":post_init",
function(addon)
local class_module = addon[addon.player_class]
class_module.post_init(addon)
end, self)
end
-- Melee-weave proximity bar (weave_range.lua): creates its hidden frame and driver
-- here. The enable rule lives in weave_range.lua: the hunter default is ON
-- (ST.weave_range_default_enabled), the other classes default off, and an explicit
-- per-class toggle always wins. The driver gates and drives itself through
-- init()/poll(); an absent module (harness sandboxes) stays a no-op here.
if st.weave_range then
st.weave_range.init(self)
end
-- Set frames to locked or unlocked based on settings. No class row means no
-- stored lock choice, and the shipped default is locked (class_defaults
-- timers_locked = true): keep the frames locked rather than invent an
-- unlocked state the user never chose.
local db_class = self:get_class_table()
if type(db_class) ~= "table" or db_class.timers_locked then
self:lock_frames()