-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathStatPanel.lua
More file actions
1859 lines (1621 loc) · 74.9 KB
/
Copy pathStatPanel.lua
File metadata and controls
1859 lines (1621 loc) · 74.9 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
-- StatPanel.lua (Panel construction, stat sources, spec-aware priority)
--
-- Nothing in here hardcodes appearance: every size, color, texture, font and
-- format string is read out of SP.db (see Config.lua) on each Rebuild, so the
-- options UI only has to change a value and call SP:Refresh().
local addonName, SP = ...
local NS = SP
local L = SP.L
local G = SP.Global
local Media = SP.Media
--------------------------------------------------------------------------------
-- API SHIMS
--------------------------------------------------------------------------------
-- Several of these moved into C_ namespaces in recent expansions while the old
-- globals lingered. Bind whichever exists so a future removal doesn't break the
-- whole panel.
local GetSpec = (C_SpecializationInfo and C_SpecializationInfo.GetSpecialization) or _G.GetSpecialization
local GetSpecInfo = (C_SpecializationInfo and C_SpecializationInfo.GetSpecializationInfo) or _G.GetSpecializationInfo
--------------------------------------------------------------------------------
-- SECRET VALUES (patch 12.0)
--------------------------------------------------------------------------------
-- Some character data now arrives as a "secret value". Addon code may store,
-- pass and DISPLAY these, but may not do arithmetic or comparisons with them,
-- use them as a gsub replacement, or take their length.
--
-- The panel handles this by routing secrets straight into the APIs that accept
-- them (FontString:SetFormattedText, StatusBar:SetValue/SetMinMaxValues) and
-- skipping any feature that would need to inspect the number - bar smoothing,
-- auto-scaling and value gradients degrade rather than error.
local isSecret = _G.issecretvalue or function() return false end
SP.IsSecret = isSecret
-- Returns a value only when it is a plain, usable number: not nil, not secret,
-- and provably safe for arithmetic. Anything else comes back nil so callers can
-- omit the field instead of erroring. The pcall makes this correct even if
-- issecretvalue is ever renamed or removed.
local function plainNumber(value)
if value == nil or isSecret(value) then return nil end
local ok, result = pcall(function() return value + 0 end)
if ok and type(result) == "number" then return result end
return nil
end
SP.PlainNumber = plainNumber
local function num(fn, ...)
if type(fn) ~= "function" then return 0 end
local value = fn(...)
-- tonumber() on a secret would be a read; pass it through untouched.
if isSecret(value) then return value end
return tonumber(value) or 0
end
local CR_ID = {
Crit = _G.CR_CRIT_MELEE,
Haste = _G.CR_HASTE_MELEE,
Mastery = _G.CR_MASTERY,
Versatility = _G.CR_VERSATILITY_DAMAGE_DONE,
Dodge = _G.CR_DODGE,
Parry = _G.CR_PARRY,
Block = _G.CR_BLOCK,
Leech = _G.CR_LIFESTEAL,
Avoidance = _G.CR_AVOIDANCE,
Speed = _G.CR_SPEED,
}
local function ratingOf(statName)
local id = CR_ID[statName]
if id and GetCombatRating then return num(GetCombatRating, id) end
return 0
end
-- How much combat rating this stat currently costs per 1% of effect -- the
-- number people actually optimize against, and the one the character sheet
-- makes you do arithmetic to find.
--
-- The denominator is deliberately the RATING BONUS rather than the displayed
-- value. Displayed values include a base the rating did not pay for (crit has
-- one), so dividing by them would quietly understate the cost. That also makes
-- this answer independent of the "total vs bonus" value-source setting.
--
-- Returns nil rather than a number whenever the division can't be trusted: a
-- protected value, a stat with no rating behind it, or a bonus of zero.
local function ratingPerPercent(statName)
local id = CR_ID[statName]
if not id or not GetCombatRating or not GetCombatRatingBonus then return nil end
local rating = plainNumber(GetCombatRating(id))
local bonus = plainNumber(GetCombatRatingBonus(id))
if not rating or not bonus or bonus <= 0 then return nil end
return rating / bonus
end
SP.RatingPerPercent = ratingPerPercent
-- UnitAttackPower reports (base, positiveBuff, negativeBuff), and the number
-- worth showing is the sum. negativeBuff already arrives negative.
--
-- Any one of the three being protected forbids the addition, so the base is
-- returned on its own: it is displayable as-is, and a slightly incomplete
-- number beats an error every frame.
local function totalFrom(getter, ...)
if type(getter) ~= "function" then return 0 end
local base, positive, negative = getter(...)
local b, p, n = plainNumber(base), plainNumber(positive), plainNumber(negative)
if not (b and p and n) then return base or 0 end
return b + p + n
end
--------------------------------------------------------------------------------
-- MOVEMENT SPEED
--------------------------------------------------------------------------------
-- GetUnitSpeed returns (currentSpeed, runSpeed, flightSpeed, swimSpeed) in
-- yards/second. Only the FIRST value is your actual velocity; the others are the
-- mount's rated maxima, which is why reading them pins the display at roughly
-- the mount's cap no matter how fast you're really going.
--
-- Skyriding compounds this: a dive accelerates well past any rated speed, and
-- the authoritative figure comes from C_PlayerInfo.GetGlidingInfo(). We take
-- whichever source reports faster, so ground, flight, swim and skyriding all
-- work without special-casing the mount type.
local sessionPeakSpeed = 0
local function GetSpeed()
local base = _G.BASE_MOVEMENT_SPEED or 7
local current = num(GetUnitSpeed, "player")
-- A secret speed can't be compared or divided; hand it straight through so
-- it still displays, just without the percentage conversion or peak record.
if isSecret(current) then return current, current end
if C_PlayerInfo and C_PlayerInfo.GetGlidingInfo then
local isGliding, _, forwardSpeed = C_PlayerInfo.GetGlidingInfo()
if isGliding and forwardSpeed and not isSecret(forwardSpeed)
and forwardSpeed > current then
current = forwardSpeed
end
end
local percent = (current / base) * 100
if percent > sessionPeakSpeed then sessionPeakSpeed = percent end
return percent, current
end
SP.ResetPeakSpeed = function() sessionPeakSpeed = 0 end
-- The peak is a plain number we derived ourselves, never a secret, so unlike
-- most stats it is safe to hand to chat. Announce.lua reads it through here.
SP.GetPeakSpeed = function() return sessionPeakSpeed end
--------------------------------------------------------------------------------
-- ARMOR
--------------------------------------------------------------------------------
-- Armor mitigation constant K used in: reduction = armor / (armor + K).
-- K scales with the *attacker's* level, so it changes whenever the level cap
-- moves. If a future patch raises the cap and these numbers look off, recompute:
-- K = armor * (1 / reductionFraction - 1)
-- using a known armor value and the reduction on the character sheet.
local ARMOR_K_EVENLY_MATCHED = 114808.1 -- same-level / non-boss target
local ARMOR_K_BOSS = 106634.5 -- +3 level "boss" target
local function GetArmorReduction()
local armor = select(2, UnitArmor("player")) or 0
local level = UnitLevel("player") or 80
-- Damage reduction is a calculation, which a secret armor value forbids.
-- Fall back to reporting armor itself rather than erroring.
if isSecret(armor) then return armor, armor end
local function reduce(k) return (armor / (armor + k)) * 100 end
local evenlyMatched
if level < 60 then
evenlyMatched = (armor / ((85 * level) + armor + 400)) * 100
else
evenlyMatched = reduce(ARMOR_K_EVENLY_MATCHED)
end
if UnitExists("target") then
local targetLevel = UnitLevel("target") or -1
if UnitClassification("target") == "worldboss" or targetLevel == -1 or targetLevel > level + 2 then
return reduce(ARMOR_K_BOSS), armor
end
return evenlyMatched, armor
end
return evenlyMatched, armor
end
--------------------------------------------------------------------------------
-- STAT SOURCES
--------------------------------------------------------------------------------
-- Each definition returns: value (the headline number), rating (the raw combat
-- rating behind it, 0 when the stat has none).
--
-- `total` means the figure the character sheet shows (base + rating + buffs);
-- `bonus` means only the contribution from rating. SP.db.valueSource picks
-- which one the panel prefers.
local function primaryStat(index)
local value = select(2, UnitStat("player", index)) or 0
return value, value
end
-- Whichever of Strength/Agility/Intellect the character actually scales with.
--
-- Picking the largest is a comparison, which is forbidden on a secret value.
-- Primary stats are readable today, but every other read in this file is
-- guarded and this one should be too: if a patch ever protects them, return the
-- value unnamed so the row still displays under its own label rather than
-- erroring and taking the whole panel down.
local function resolvePrimary()
local best, bestIndex = -1, 1
for _, index in ipairs({ 1, 2, 4 }) do
local value = select(2, UnitStat("player", index)) or 0
if isSecret(value) then return value, nil end
if value > best then best, bestIndex = value, index end
end
return best, bestIndex
end
-- Display names for the attributes and ratings. Blizzard's GlobalStrings already
-- carry these in every client locale, so SP.Global prefers the client's own text
-- and keeps the English string as the fallback key. "Primary" and "Armor DR" are
-- ours -- neither is a Blizzard concept -- so those stay in SP.L.
local STAT_NAME = {
Strength = G("SPELL_STAT1_NAME", "Strength"),
Agility = G("SPELL_STAT2_NAME", "Agility"),
Stamina = G("SPELL_STAT3_NAME", "Stamina"),
Intellect = G("SPELL_STAT4_NAME", "Intellect"),
Crit = G("STAT_CRITICAL_STRIKE", "Crit"),
Haste = G("STAT_HASTE", "Haste"),
Mastery = G("STAT_MASTERY", "Mastery"),
Versatility = G("STAT_VERSATILITY", "Versatility"),
Dodge = G("DODGE", "Dodge"),
Parry = G("PARRY", "Parry"),
Block = G("BLOCK", "Block"),
Leech = G("STAT_LIFESTEAL", "Leech"),
Avoidance = G("STAT_AVOIDANCE", "Avoidance"),
Speed = G("STAT_SPEED", "Speed"),
AttackPower = G("STAT_ATTACK_POWER", "Attack Power"),
SpellPower = G("STAT_SPELLPOWER", "Spell Power"),
Health = G("HEALTH", "Health"),
Mana = G("MANA", "Mana"),
Stagger = G("STAGGER", "Stagger"),
}
local PRIMARY_NAME = {
[1] = STAT_NAME.Strength,
[2] = STAT_NAME.Agility,
[4] = STAT_NAME.Intellect,
}
local STAT_DEFS = {
Primary = {
name = L["Primary"],
get = function()
local value, index = resolvePrimary()
-- No index means the attribute couldn't be identified; the row
-- falls back to its own label rather than naming the wrong stat.
return value, value, index and PRIMARY_NAME[index]
end,
},
Strength = { name = STAT_NAME.Strength, get = function() return primaryStat(1) end },
Agility = { name = STAT_NAME.Agility, get = function() return primaryStat(2) end },
Stamina = { name = STAT_NAME.Stamina, get = function() return primaryStat(3) end },
Intellect = { name = STAT_NAME.Intellect, get = function() return primaryStat(4) end },
Crit = {
name = STAT_NAME.Crit,
get = function(source)
local value = (source == "bonus")
and num(GetCombatRatingBonus, CR_ID.Crit)
or num(GetCritChance)
return value, ratingOf("Crit")
end,
},
Haste = {
name = STAT_NAME.Haste,
get = function(source)
local value = (source == "bonus")
and num(GetCombatRatingBonus, CR_ID.Haste)
or num(GetHaste)
return value, ratingOf("Haste")
end,
},
Mastery = {
name = STAT_NAME.Mastery,
get = function(source)
local value = (source == "bonus")
and num(GetCombatRatingBonus, CR_ID.Mastery)
or num(GetMasteryEffect)
return value, ratingOf("Mastery")
end,
},
Versatility = {
name = STAT_NAME.Versatility,
get = function()
return num(GetCombatRatingBonus, CR_ID.Versatility), ratingOf("Versatility")
end,
},
Armor = { name = L["Armor DR"], get = function() return GetArmorReduction() end },
Dodge = { name = STAT_NAME.Dodge, get = function() return num(GetDodgeChance), ratingOf("Dodge") end },
Parry = { name = STAT_NAME.Parry, get = function() return num(GetParryChance), ratingOf("Parry") end },
Block = { name = STAT_NAME.Block, get = function() return num(GetBlockChance), ratingOf("Block") end },
Leech = { name = STAT_NAME.Leech, get = function() return num(GetLifesteal), ratingOf("Leech") end },
Avoidance = { name = STAT_NAME.Avoidance, get = function() return num(GetAvoidance), ratingOf("Avoidance") end },
Speed = {
name = STAT_NAME.Speed,
get = function()
local percent, yards = GetSpeed()
return percent, ratingOf("Speed"), nil, yards
end,
},
-- Power and pools. None of these has a combat rating behind it, so their
-- second return is 0 and $rating renders as such; they are $value stats.
AttackPower = {
name = STAT_NAME.AttackPower,
get = function()
-- Hunters and other ranged-weapon users carry their attack power on
-- the ranged side, and the melee call reports the wrong number for
-- them. Prefer ranged when the class actually has it.
local class = select(2, UnitClass("player"))
if class == "HUNTER" and UnitRangedAttackPower then
return totalFrom(UnitRangedAttackPower, "player"), 0
end
return totalFrom(UnitAttackPower, "player"), 0
end,
},
SpellPower = {
name = STAT_NAME.SpellPower,
get = function()
-- Spell power has been a single unified number since Cataclysm; the
-- per-school argument survives only as API shape. School 2 (Holy) is
-- the conventional one to ask for and reports that unified value.
if not GetSpellBonusDamage then return 0, 0 end
return num(GetSpellBonusDamage, 2), 0
end,
},
Health = {
name = STAT_NAME.Health,
get = function() return num(UnitHealthMax, "player"), 0 end,
},
Mana = {
name = STAT_NAME.Mana,
-- Power type 0 is mana specifically, not "whatever this class uses".
-- A rogue asking for mana correctly gets zero rather than energy.
get = function() return num(UnitPowerMax, "player", 0), 0 end,
},
Stagger = {
name = STAT_NAME.Stagger,
get = function()
-- Brewmaster-only, and the only place the game exposes the
-- percentage rather than the current staggered damage. Absent on
-- every other spec, where this reads zero rather than erroring.
if not (C_PaperDollInfo and C_PaperDollInfo.GetStaggerPercentage) then
return 0, 0
end
return num(C_PaperDollInfo.GetStaggerPercentage, "player"), 0
end,
},
}
SP.STAT_DEFS = STAT_DEFS
-- Stable, display-friendly ordering for the options UI.
SP.STAT_ORDER = {
"Primary", "Strength", "Agility", "Intellect", "Stamina",
"AttackPower", "SpellPower", "Health", "Mana",
"Crit", "Haste", "Mastery", "Versatility",
"Armor", "Dodge", "Parry", "Block", "Stagger",
"Leech", "Avoidance", "Speed",
}
--------------------------------------------------------------------------------
-- STAT PRIORITY
--------------------------------------------------------------------------------
-- Ordered secondary-stat priority per specialization, keyed by spec ID. These
-- are APPROXIMATE, general-purpose baselines - real priorities shift with gear,
-- content and balance patches, and the authoritative answer for YOUR character
-- comes from a sim. Users can override any spec in the options.
NS.StatPriority = {
-- Warrior
[71] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Arms
[72] = {"Haste", "Mastery", "Crit", "Versatility"}, -- Fury
[73] = {"Haste", "Versatility", "Mastery", "Crit"}, -- Protection
-- Paladin
[65] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Holy
[66] = {"Haste", "Mastery", "Versatility", "Crit"}, -- Protection
[70] = {"Haste", "Mastery", "Crit", "Versatility"}, -- Retribution
-- Hunter
[253] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Beast Mastery
[254] = {"Crit", "Haste", "Mastery", "Versatility"}, -- Marksmanship
[255] = {"Haste", "Crit", "Versatility", "Mastery"}, -- Survival
-- Rogue
[259] = {"Crit", "Mastery", "Haste", "Versatility"}, -- Assassination
[260] = {"Haste", "Crit", "Versatility", "Mastery"}, -- Outlaw
[261] = {"Crit", "Versatility", "Haste", "Mastery"}, -- Subtlety
-- Priest
[256] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Discipline
[257] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Holy
[258] = {"Haste", "Mastery", "Crit", "Versatility"}, -- Shadow
-- Death Knight
[250] = {"Haste", "Versatility", "Crit", "Mastery"}, -- Blood
[251] = {"Crit", "Haste", "Mastery", "Versatility"}, -- Frost
[252] = {"Haste", "Mastery", "Crit", "Versatility"}, -- Unholy
-- Shaman
[262] = {"Crit", "Haste", "Mastery", "Versatility"}, -- Elemental
[263] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Enhancement
[264] = {"Crit", "Haste", "Versatility", "Mastery"}, -- Restoration
-- Mage
[62] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Arcane
[63] = {"Crit", "Haste", "Versatility", "Mastery"}, -- Fire
[64] = {"Haste", "Crit", "Versatility", "Mastery"}, -- Frost
-- Warlock
[265] = {"Haste", "Mastery", "Crit", "Versatility"}, -- Affliction
[266] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Demonology
[267] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Destruction
-- Monk
[268] = {"Versatility", "Haste", "Crit", "Mastery"}, -- Brewmaster
[270] = {"Crit", "Haste", "Versatility", "Mastery"}, -- Mistweaver
[269] = {"Crit", "Haste", "Mastery", "Versatility"}, -- Windwalker
-- Druid
[102] = {"Haste", "Mastery", "Crit", "Versatility"}, -- Balance
[103] = {"Crit", "Mastery", "Haste", "Versatility"}, -- Feral
[104] = {"Versatility", "Mastery", "Haste", "Crit"}, -- Guardian
[105] = {"Haste", "Crit", "Mastery", "Versatility"}, -- Restoration
-- Demon Hunter
[577] = {"Crit", "Haste", "Versatility", "Mastery"}, -- Havoc
[581] = {"Versatility", "Haste", "Crit", "Mastery"}, -- Vengeance
-- Evoker
[1467] = {"Mastery", "Crit", "Haste", "Versatility"}, -- Devastation
[1468] = {"Crit", "Haste", "Mastery", "Versatility"}, -- Preservation
[1473] = {"Mastery", "Crit", "Haste", "Versatility"}, -- Augmentation
}
local DEFAULT_PRIORITY = { "Crit", "Haste", "Mastery", "Versatility" }
-- Short labels for the compact priority chain line. These stay in SP.L rather
-- than using the Blizzard globals STAT_DEFS does: the globals are the full
-- names ("Critical Strike"), and the whole point of this line is that it fits.
local SHORT_NAME = { Crit = L["Crit"], Haste = L["Haste"], Mastery = L["Mast"], Versatility = L["Vers"] }
-- Returns the priority list for the player's current spec, its name, and its ID.
-- A user override in SP.db.customPriority always wins.
function SP:GetCurrentPriority()
if not GetSpec then return DEFAULT_PRIORITY end
local index = GetSpec()
if not index then return DEFAULT_PRIORITY end
local specID, specName = GetSpecInfo(index)
if not specID then return DEFAULT_PRIORITY, specName end
local custom = SP.db and SP.db.customPriority and SP.db.customPriority[specID]
return custom or NS.StatPriority[specID] or DEFAULT_PRIORITY, specName, specID
end
-- Maps the many spellings of a secondary stat -- Pawn's rating keys, sim output,
-- and how a person would just type it -- onto our four canonical keys. Keys are
-- normalized by normalizeWord below before lookup; more are registered from the
-- client's own stat names once that function exists.
local SECONDARY_ALIAS = {
crit = "Crit", critical = "Crit", criticalstrike = "Crit", critrating = "Crit", critstrike = "Crit",
haste = "Haste", hasterating = "Haste", hast = "Haste",
mastery = "Mastery", masteryrating = "Mastery", mast = "Mastery",
vers = "Versatility", versatility = "Versatility", versatilityrating = "Versatility",
versa = "Versatility",
}
local SECONDARY_CANON = { "Crit", "Haste", "Mastery", "Versatility" }
-- Case-folded with spaces and punctuation removed, so "Critical Strike",
-- "CritRating" and "crit" all land on the same key.
--
-- Note what is NOT stripped: anything outside ASCII. An earlier version kept
-- only [%a], which erases a Cyrillic or Korean stat name down to the empty
-- string and made the localized aliases below unreachable on exactly the
-- clients that need them.
local function normalizeWord(word)
return (word:lower():gsub("[%s%p]", ""))
end
-- The panel's compact priority chain writes "Crit > Mast > Vers", and the
-- German client calls Haste "Tempo". Both are things a user will reasonably
-- type into the priority box, so both are registered as aliases: the client's
-- own stat names from GlobalStrings, and our own abbreviations from SP.L.
--
-- English keys above are never overwritten -- a Pawn string is English on every
-- client, and it has to keep working.
local function registerAlias(name, stat)
if type(name) ~= "string" or name == "" then return end
local key = normalizeWord(name)
if key ~= "" and not SECONDARY_ALIAS[key] then
SECONDARY_ALIAS[key] = stat
end
end
for _, entry in ipairs({
{ "STAT_CRITICAL_STRIKE", "Crit", "Crit" },
{ "STAT_HASTE", "Haste", "Haste" },
{ "STAT_MASTERY", "Mastery", "Mast" },
{ "STAT_VERSATILITY", "Versatility", "Vers" },
}) do
local globalName, stat, shortKey = entry[1], entry[2], entry[3]
registerAlias(_G[globalName], stat)
registerAlias(L[shortKey], stat)
registerAlias(L[stat], stat)
end
local function aliasOf(word)
return SECONDARY_ALIAS[normalizeWord(word)]
end
-- Turns a pasted stat-weight string into a full four-stat priority order, or
-- nil plus a reason. Accepts two shapes:
-- * a weight string -- Pawn ("... CritRating=1.2, MasteryRating=1.5 ...") or
-- any "stat = number" list from a sim or stat site -- ordered by descending
-- weight;
-- * a plain order -- "Mastery > Haste > Crit > Vers", commas or spaces too.
-- Any secondary the string omits is appended in canonical order, so the result
-- is always a valid permutation the priority line and dropdowns can consume.
function SP:ParsePriorityString(text)
if type(text) ~= "string" or strtrim(text) == "" then
return nil, "Paste a Pawn string or a stat order first."
end
-- Weight form: only trust it when the text actually assigns numbers, so a
-- half-typed Pawn string falls through to an error rather than being read as
-- a bare word list in file order.
if text:find("=") then
local weights, found = {}, 0
for key, value in text:gmatch("(%a+)%s*=%s*(%-?%d*%.?%d+)") do
local stat, n = aliasOf(key), tonumber(value)
if stat and n and not weights[stat] then
weights[stat] = n
found = found + 1
end
end
if found < 2 then
return nil, "Couldn't read at least two secondary-stat weights from that string."
end
local order = {}
for _, stat in ipairs(SECONDARY_CANON) do
if weights[stat] then order[#order + 1] = stat end
end
table.sort(order, function(a, b) return weights[a] > weights[b] end)
for _, stat in ipairs(SECONDARY_CANON) do
if not weights[stat] then order[#order + 1] = stat end
end
return order
end
-- Plain-order form: take the secondaries in the order they appear.
--
-- Split on separators rather than matching %a+ runs: %a is ASCII-only, so
-- matching it would find no words at all in a Cyrillic or Korean order
-- string and reject text that reads perfectly well to the person who typed
-- it. normalizeWord strips the punctuation that survives the split.
local order, seen = {}, {}
for word in text:gmatch("[^%s,;>/|+]+") do
local stat = aliasOf(word)
if stat and not seen[stat] then
seen[stat] = true
order[#order + 1] = stat
end
end
if #order < 2 then
return nil, "Couldn't find a stat order in that text. Try 'Mastery > Haste > Crit > Vers'."
end
for _, stat in ipairs(SECONDARY_CANON) do
if not seen[stat] then order[#order + 1] = stat end
end
return order
end
--------------------------------------------------------------------------------
-- FORMATTING
--------------------------------------------------------------------------------
local function toHex(color)
if type(color) ~= "table" then return "|cffffffff" end
return string.format("|cff%02x%02x%02x",
math.floor((color[1] or 1) * 255 + 0.5),
math.floor((color[2] or 1) * 255 + 0.5),
math.floor((color[3] or 1) * 255 + 0.5))
end
SP.ToHex = toHex
local function commafy(value)
if isSecret(value) then return value end
if BreakUpLargeNumbers then return BreakUpLargeNumbers(math.floor(value + 0.5)) end
return tostring(math.floor(value + 0.5))
end
-- Holds a fontstring inside the panel. Without a width a long line just keeps
-- drawing past the panel's edge and out over the game world, so every text
-- element is bounded and truncated rather than allowed to bleed.
local function clampText(fontString, width, justify)
if width and width > 0 then fontString:SetWidth(width) end
fontString:SetWordWrap(false)
if justify then fontString:SetJustifyH(justify) end
end
-- Measures text as if unbounded (GetStringWidth reports the clamped width once
-- SetWidth is in play, which would stop auto-width from ever growing to fit).
--
-- Returns nil when the width cannot be safely used in arithmetic. Once a
-- fontstring is given secret text the WIDGET is marked as holding secrets, so
-- its reported width is itself secret - on every later frame too, even when the
-- incoming value is ordinary again. Checking the incoming value is therefore
-- not enough; the measurement is verified here at the point of use.
local function stringWidth(fontString)
if fontString.HasSecretValues and fontString:HasSecretValues() then return nil end
local getter = fontString.GetUnboundedStringWidth or fontString.GetStringWidth
local ok, width = pcall(getter, fontString)
if not ok or width == nil or isSecret(width) then return nil end
-- Final proof: a secret survives every other check but dies on comparison.
local usable = pcall(function() return width > 0 end)
return usable and width or nil
end
-- Escapes literal text so it survives being used as a printf format string.
local function escapePercent(text)
return (tostring(text or ""):gsub("%%", "%%%%"))
end
-- Turns a $token template into a printf format string plus an ordered list of
-- which live values feed it, e.g.
-- "$rating - $value%" -> "%d - %.2f%%", {"rating", "value"}
--
-- This split is what makes the panel secret-safe. Secret values may not be used
-- as a gsub replacement (that raises "invalid replacement value (a secret)"),
-- so the gsub here only ever touches the plain template. The secrets themselves
-- go straight to SetFormattedText, which is one of the few APIs allowed to
-- receive them.
local function buildFormat(template, cfg, label, extra, statName)
local decimals = math.max(0, math.min(4, cfg.decimals or 0))
local numberFmt = "%." .. decimals .. "f"
local order = {}
-- Escape any literal % in the template before inserting our own specifiers.
local fmt = escapePercent(template)
fmt = fmt:gsub("%$(%a+)", function(token)
if token == "value" or token == "valuec" then
order[#order + 1] = "value"
return numberFmt
elseif token == "rating" or token == "ratingc" then
order[#order + 1] = "rating"
return "%d"
elseif token == "max" then
return escapePercent(string.format("%d", cfg.max or 100))
elseif token == "label" then
return escapePercent(label)
elseif token == "peak" then
return escapePercent(string.format(numberFmt, sessionPeakSpeed))
elseif token == "yards" then
-- `extra` is the raw yards/sec, which for the Speed stat is the
-- secret velocity itself when the game protects it (GetSpeed returns
-- it unchanged). Unlike $value/$rating this token is baked in here by
-- string.format rather than deferred to SetFormattedText, so it must
-- be reduced to a plain number first or it raises every frame.
return escapePercent(string.format("%.1f", plainNumber(extra) or 0))
elseif token == "per" then
-- Combat rating per 1% of effect. Baked in here rather than
-- deferred like $value, because it is a quotient we computed from
-- plain numbers and never a secret in its own right.
--
-- Whole numbers on purpose: this is a rating cost in the hundreds,
-- and the stat's own decimal setting exists for a percentage.
local per = ratingPerPercent(statName)
return escapePercent(per and string.format("%.0f", per) or "-")
end
end)
return fmt, order
end
-- Exported for tests/spec_format.lua. This is the single most breakage-prone
-- function in the addon -- it is the secret-value workaround, it consumes
-- free-text the user typed, and its output is fed to string.format every frame
-- -- and it is pure, so it is worth checking outside the game.
SP.BuildFormat = buildFormat
-- Collects the arguments a built format expects, in order.
local function formatArgs(order, value, rating)
local args = {}
for index, which in ipairs(order) do
args[index] = (which == "value") and value or rating
end
return args, #order
end
-- string.format with a user-authored template throws if the template carries a
-- stray or extra specifier ("%d %d", "%s"). The rank and footer templates come
-- straight from free-text option boxes and are formatted every frame, so one
-- typo would error continuously. Fall back to the default template, then to the
-- bare value, so a bad template degrades to plain text instead of a flood.
local function safeFormat(template, fallback, value)
local ok, out = pcall(string.format, template, value)
if ok then return out end
ok, out = pcall(string.format, fallback, value)
if ok then return out end
return tostring(value)
end
SP.SafeFormat = safeFormat
--------------------------------------------------------------------------------
-- PANEL
--------------------------------------------------------------------------------
local Panel = {}
SP.Panel = Panel
Panel.rows = {} -- [statName] = row frame
Panel.headers = {} -- [sectionID] = fontstring
Panel.visibleRows = {}-- ordered list of rows currently laid out
-- Session-only ceilings for auto-scaling stats, kept out of the saved profile.
local runtimeMax = {}
SP.ResetRuntimeMax = function() wipe(runtimeMax) end
local frame -- the StatPanel frame itself
-- Creates the frames for one stat row. Both render styles share the row so
-- switching style never has to rebuild frames.
local function CreateRow(statName)
local row = CreateFrame("Frame", nil, frame)
row:SetSize(100, 15)
row.bar = CreateFrame("StatusBar", nil, row)
row.bar:SetAllPoints(row)
row.bar:SetMinMaxValues(0, 100)
row.bar:SetValue(0)
row.track = row.bar:CreateTexture(nil, "BACKGROUND")
row.track:SetAllPoints(row.bar)
row.spark = row.bar:CreateTexture(nil, "OVERLAY")
row.spark:SetTexture([[Interface\CastingBar\UI-CastingBar-Spark]])
row.spark:SetBlendMode("ADD")
row.spark:Hide()
-- Text sits on its own frame above the bar so draw order is guaranteed
-- regardless of the bar's texture layer. It carries the optional per-bar
-- border too, hence BackdropTemplate.
row.overlay = CreateFrame("Frame", nil, row, "BackdropTemplate")
row.overlay:SetAllPoints(row)
row.overlay:SetFrameLevel(row:GetFrameLevel() + 5)
row.label = row.overlay:CreateFontString(nil, "OVERLAY")
row.value = row.overlay:CreateFontString(nil, "OVERLAY")
row.text = row.overlay:CreateFontString(nil, "OVERLAY")
row.statName = statName
row.smoothed = 0
-- Dragging anywhere on the panel, including over a row, moves the panel.
row:RegisterForDrag("LeftButton")
row:SetScript("OnDragStart", function() Panel:StartDrag() end)
row:SetScript("OnDragStop", function() Panel:StopDrag() end)
row:SetScript("OnEnter", function(self) Panel:ShowRowTooltip(self) end)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
-- Rows sit on top of the panel, so they need their own right-click hook or
-- the context menu would only work in the gaps between bars.
row:SetScript("OnMouseUp", function(_, mouseButton)
if mouseButton == "RightButton" then SP:ShowContextMenu(frame) end
end)
Panel.rows[statName] = row
return row
end
function Panel:GetRow(statName)
return self.rows[statName] or (STAT_DEFS[statName] and CreateRow(statName))
end
--------------------------------------------------------------------------------
-- REBUILD (styling + layout)
--------------------------------------------------------------------------------
-- Applies every appearance setting and repositions everything. Cheap enough to
-- call on any option change; the per-frame update loop only pushes values.
function Panel:Rebuild()
if not frame or not SP.db then return end
-- Rebuild ends with an immediate Update, and auto-width can ask for another
-- Rebuild. The measurement converges in one pass, but guard anyway so a
-- pathological font can never spin us.
if self.rebuilding then return end
self.rebuilding = true
-- The styling pass applies many saved values in one go. A single bad one --
-- a font that has since been uninstalled, or a wrong-typed key from an
-- imported profile -- would raise partway through and leave `rebuilding`
-- stuck true, turning every future Rebuild into a permanent no-op that not
-- even switching to a good profile could clear (only /reload would). Run the
-- body under pcall so the flag always resets and the panel stays
-- recoverable, and say what happened rather than failing silently.
local ok, err = pcall(self.RebuildInner, self)
self.rebuilding = false
if not ok then
SP:Print(L["a display setting could not be applied (%s)."]:format(tostring(err)))
end
self:Update(0, true)
end
function Panel:RebuildInner()
local db = SP.db
local p, b, f = db.panel, db.bars, db.font
local textStyle = (b.style == "text")
----------------------------------------------------------------- frame ----
frame:SetScale(p.scale or 1)
-- While previewing, strata and level are owned by FitPreview so the panel
-- stays above the preview window's backdrop. Applying the saved strata here
-- would drop it back to MEDIUM and bury it behind the preview.
if not self.previewing then
frame:SetFrameStrata(p.strata or "MEDIUM")
frame:SetFrameLevel(p.frameLevel or 10)
end
frame:SetClampedToScreen(p.clamp ~= false)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
Media:ApplyBackdrop(frame, {
bgTexture = p.bgTexture,
bgColor = p.bgColor,
borderStyle = p.borderStyle,
borderColor = p.borderColor,
borderSize = p.borderSize,
borderInset = p.borderInset,
tile = p.bgTile,
tileSize = p.bgTileSize,
})
local width = p.autoWidth and (self.measuredWidth or p.minWidth or 120) or (p.width or 208)
frame:SetWidth(math.max(40, width))
local shadow = {
enabled = f.shadow,
color = f.shadowColor,
x = f.shadowX,
y = f.shadowY,
}
local function styleText(fontString, element)
local e = f.elements[element] or {}
Media:ApplyFont(fontString, f.face, e.size or 12, e.flags, shadow)
local c = e.color or { 1, 1, 1, 1 }
fontString:SetTextColor(c[1], c[2], c[3], c[4] or 1)
end
self.styleText = styleText
local padX = p.paddingX or 14
local innerWidth = frame:GetWidth() - padX * 2
----------------------------------------------------------------- title ----
local y = -(p.paddingTop or 12)
if p.showTitle and p.titleMode ~= "none" then
styleText(frame.title, "title")
frame.title:ClearAllPoints()
local align = p.titleAlign or "CENTER"
if align == "LEFT" then
frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", padX, y)
elseif align == "RIGHT" then
frame.title:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -padX, y)
else
frame.title:SetPoint("TOP", frame, "TOP", 0, y)
end
clampText(frame.title, innerWidth, align)
frame.title:Show()
y = y - (f.elements.title.size or 16) - 6
else
frame.title:Hide()
end
if p.showDivider then
frame.divider:ClearAllPoints()
frame.divider:SetHeight(p.dividerThickness or 1)
frame.divider:SetPoint("TOPLEFT", frame, "TOPLEFT", padX, y - 2)
frame.divider:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -padX, y - 2)
local c = p.dividerColor or { 1, 1, 1, 0.08 }
frame.divider:SetColorTexture(c[1], c[2], c[3], c[4] or 1)
frame.divider:Show()
y = y - (p.dividerThickness or 1) - 6
else
frame.divider:Hide()
end
------------------------------------------------------------- sections ----
for _, row in pairs(self.rows) do row:Hide() end
for _, header in pairs(self.headers) do header:Hide() end
frame.priorityLine:Hide()
wipe(self.visibleRows)
local priority = SP:GetCurrentPriority()
local rowHeight = textStyle and (b.textHeight or 15) or (b.height or 15)
local rowStep = rowHeight + (textStyle and 0 or (b.spacing or 6))
local barInset = textStyle and 0 or (b.inset or 0)
for _, section in ipairs(db.sections or {}) do
if section.enabled then
local drewSomething = false
-- Header
if section.showHeader ~= false and (p.headerStep or 20) > 0 then
local header = self.headers[section.id]
if not header then
header = frame:CreateFontString(nil, "OVERLAY")
self.headers[section.id] = header
end
styleText(header, "header")
header:SetText(section.title or "")
header:ClearAllPoints()
local align = p.headerAlign or "LEFT"
if align == "CENTER" then
header:SetPoint("TOP", frame, "TOP", 0, y)
elseif align == "RIGHT" then
header:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -padX, y)
else
header:SetPoint("TOPLEFT", frame, "TOPLEFT", padX, y)
end
header:SetJustifyH(align)
header:Show()
y = y - (p.headerStep or 20)
end
-- Row order: prioritized sections follow the spec priority for any
-- stat they contain, then append whatever the priority didn't cover.
local order = section.stats or {}
if section.prioritized then
local ordered, seen = {}, {}
for _, statName in ipairs(priority) do
for _, member in ipairs(order) do
if member == statName and not seen[statName] then
ordered[#ordered + 1] = statName
seen[statName] = true
end
end
end
for _, member in ipairs(order) do
if not seen[member] then ordered[#ordered + 1] = member end
end
order = ordered
end
local rank = 1
for _, statName in ipairs(order) do
local cfg = db.stats[statName]
if cfg and cfg.enabled and STAT_DEFS[statName] then
local row = self:GetRow(statName)
row:ClearAllPoints()
row:SetPoint("TOPLEFT", frame, "TOPLEFT", padX + barInset, y)
row:SetSize(math.max(1, innerWidth - barInset * 2), rowHeight)
self:StyleRow(row, cfg, statName, textStyle, section.prioritized and rank or nil)
row:Show()
self.visibleRows[#self.visibleRows + 1] = row
y = y - rowStep
rank = rank + 1
drewSomething = true
end
end
-- Priority chain, drawn under the prioritized section.
if section.prioritized and db.priorityLine.enabled and drewSomething then
styleText(frame.priorityLine, "priority")
local chain = {}
for _, statName in ipairs(priority) do
local short = SHORT_NAME[statName] or statName
if db.priorityLine.colorize then
local statCfg = db.stats[statName]
short = toHex(statCfg and statCfg.color) .. short .. "|r"
end
chain[#chain + 1] = short
end
local text = table.concat(chain, db.priorityLine.separator or " > ")
if db.priorityLine.showSpec then
local _, specName = SP:GetCurrentPriority()
if specName then text = specName .. ": " .. text end
end
frame.priorityLine:SetText(text)
frame.priorityLine:ClearAllPoints()
frame.priorityLine:SetPoint("TOPLEFT", frame, "TOPLEFT", padX, y + 2)
clampText(frame.priorityLine, innerWidth, "LEFT")
frame.priorityLine:Show()
y = y - (f.elements.priority.size or 10) - 6
end