-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathprism.lua
More file actions
2206 lines (2062 loc) · 99.4 KB
/
prism.lua
File metadata and controls
2206 lines (2062 loc) · 99.4 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
addon.name = 'prism'
addon.author = 'Blake & Watney'
addon.version = '0.7.10'
addon.desc = 'Prism — floating skill overlay. Tier-colored crystals, donuts, or pills. Tracks combat, defense, magic & craft skill progress per main job.'
addon.commands = { '/prism', '/pr' }
require('common')
local chat = require('chat')
local settings = require('settings')
local imgui = require('imgui')
local struct = require('struct')
----------------------------------------------------------------
-- config
----------------------------------------------------------------
local default_config = T{
visible = true,
x = 20,
y = 320,
display_mode = 'crystals', -- 'crystals' | 'donuts' | 'pills'
per_row = 3, -- 1..N (N = number of visible skills)
scale = 1.0, -- 0.6..2.0 visual scale multiplier
sort_mode = 'default',-- 'default' | 'grade' | 'lowest' | 'progress'
show_capped = false, -- hide skills that are already at cap for current level
-- Category visibility — Prism shows ALL skills your main job has access to,
-- grouped by category. Toggle a category off to hide its whole group.
show_combat = true, -- weapons + ranged
combat_only_equipped = false, -- when true, Combat shows only currently-equipped weapons
show_defense = true, -- Guard / Evasion / Shield / Parry
show_magic = true, -- main-job casting schools only (not sub-job spillover)
show_craft = true, -- crafting + fishing (only the ones you've trained)
persist_frac = true, -- save fractional skill progress to disk; survives /logout
chat_skillups = false, -- enhanced chat line on skillup
-- FFXI chat color codes (0..255) for fractional skillup magnitude. Defaults
-- form a subtle->loud ramp (cream->cyan->salmon->magenta) so big skillups
-- grab your eye and tiny ones fade. Override via the swatch picker in
-- /prism settings.
chat_color_low = 106, -- color for 0.1 skillups (cream, subtle)
chat_color_mid = 6, -- color for 0.2 skillups (cyan, noticeable)
chat_color_high = 76, -- color for 0.3 skillups (salmon, loud)
chat_color_max = 5, -- color for 0.4+ skillups (magenta, jackpot)
chat_color_tick = 6, -- color for "level up" integer ticks (cyan, celebratory)
-- Per-skill visibility, keyed by SID (string keys for stable serialization).
-- nil/missing => visible by default. Legacy global table (pre-0.7.2);
-- kept for back-compat as a fallback when no per-job entry exists.
skills_hidden = T{},
-- Per-job per-skill visibility map: { [job_id_str] = { [sid_str] = true } }.
-- Lets you hide Throwing on DRK but keep it visible on RNG, etc. Writes
-- here on toggle from the settings UI or /prism hide; reads fall back to
-- the legacy global skills_hidden table when a per-job entry is missing.
skills_hidden_by_job = T{},
-- Persisted fractional skill progress (sid -> 0.0..0.9). Filled by packet
-- 0x29 / chat capture, reset on integer tick. Persisted so a /logout in
-- the middle of grinding doesn't throw away the 0.1-0.9 you already earned.
skill_frac = T{},
}
local config = settings.load(default_config)
-- normalize legacy/invalid values so a hand-edit can't wedge the overlay
local function normalize_config()
-- v0.7.4: 'gems' was folded into 'crystals' (the FF hex shape is now the
-- default crystal). Migrate any saved 'gems' value silently.
if config.display_mode == 'gems' then
config.display_mode = 'crystals'
end
if config.display_mode ~= 'pills'
and config.display_mode ~= 'donuts'
and config.display_mode ~= 'crystals' then
config.display_mode = 'crystals'
end
if type(config.per_row) ~= 'number' then config.per_row = 3 end
config.per_row = math.max(1, math.min(24, math.floor(config.per_row)))
if type(config.scale) ~= 'number' then config.scale = 1.0 end
config.scale = math.max(0.6, math.min(2.0, config.scale))
if config.sort_mode ~= 'default'
and config.sort_mode ~= 'grade'
and config.sort_mode ~= 'lowest'
and config.sort_mode ~= 'progress' then
config.sort_mode = 'default'
end
if type(config.x) ~= 'number' then config.x = 20 end
if type(config.y) ~= 'number' then config.y = 320 end
if type(config.skills_hidden) ~= 'table' then config.skills_hidden = T{} end
if type(config.skills_hidden_by_job) ~= 'table' then config.skills_hidden_by_job = T{} end
if type(config.persist_frac) ~= 'boolean' then config.persist_frac = true end
if type(config.skill_frac) ~= 'table' then config.skill_frac = T{} end
if type(config.chat_skillups) ~= 'boolean' then config.chat_skillups = false end
if type(config.show_combat) ~= 'boolean' then config.show_combat = true end
if type(config.combat_only_equipped) ~= 'boolean' then config.combat_only_equipped = false end
if type(config.show_defense) ~= 'boolean' then config.show_defense = true end
if type(config.show_magic) ~= 'boolean' then config.show_magic = true end
if type(config.show_craft) ~= 'boolean' then config.show_craft = true end
local function _norm_color(k, dflt)
local v = tonumber(config[k])
if not v then config[k] = dflt; return end
config[k] = math.max(0, math.min(255, math.floor(v)))
end
_norm_color('chat_color_low', 8)
_norm_color('chat_color_mid', 106)
_norm_color('chat_color_high', 6)
end
normalize_config()
local function save() settings.save() end
-- Per-job hide is the source of truth as of v0.7.2. Reads look up the
-- per-job map first; if no entry exists for this job+sid, fall back to the
-- legacy global table so existing /prism hide users don't lose state on
-- upgrade. Writes go to the per-job map only (legacy table is read-only).
local function is_skill_hidden(sid, job_id)
local k = tostring(sid)
if job_id ~= nil then
local row = config.skills_hidden_by_job[tostring(job_id)]
if row and row[k] ~= nil then return row[k] == true end
end
return config.skills_hidden[k] == true
end
local function set_skill_hidden(sid, hidden, job_id)
local k = tostring(sid)
if job_id == nil then
-- callsite didn't know the job — fall back to legacy global so we
-- don't silently no-op. (Settings UI and /prism hide both pass job_id.)
if hidden then config.skills_hidden[k] = true
else config.skills_hidden[k] = nil end
return
end
local jk = tostring(job_id)
local row = config.skills_hidden_by_job[jk]
if not row then row = T{}; config.skills_hidden_by_job[jk] = row end
if hidden then row[k] = true else row[k] = nil end
end
settings.register('settings', 'settings_update', function(s)
if s then config = s end
normalize_config()
end)
local function say(msg)
print(chat.header(addon.name):append(chat.message(msg)))
end
----------------------------------------------------------------
-- skill metadata (lifted from huntpartner; kept self-contained
-- so prism can load on its own)
----------------------------------------------------------------
local SKILL_NAMES = {
[1]='H2H', [2]='Dagger', [3]='Sword', [4]='GSword', [5]='Axe', [6]='GAxe',
[7]='Scythe', [8]='Polearm', [9]='Katana', [10]='GKatana', [11]='Club',
[12]='Staff', [25]='Archery', [26]='Marksmanship', [27]='Throwing',
-- defensive (passive, leveled by being hit / blocking / parrying)
[28]='Guard', [29]='Evasion', [30]='Shield', [31]='Parry',
[32]='Divine', [33]='Healing', [34]='Enhancing', [35]='Enfeebling',
[36]='Elemental', [37]='Dark', [38]='Summoning', [39]='Ninjutsu',
-- crafting / gathering (chat-line only -- no rank table, cap from engine)
[48]='Fishing', [49]='Wood', [50]='Smith', [51]='Gold', [52]='Cloth',
[53]='Leather', [54]='Bone', [55]='Alchemy', [56]='Cooking',
}
local MAGIC_SKILL_IDS = { 33, 34, 35, 32, 36, 37, 38, 39 }
-- Skill IDs grouped by the four overlay categories Prism shows.
-- combat: weapons + ranged. Filtered by JOB_SKILL_RANK[job].
-- defense: passive blocks. Filtered by JOB_SKILL_RANK[job] (Evasion/Parry/Shield/Guard).
-- magic: casting schools. Filtered by JOB_MAGIC_SKILL_RANK[job] (cast allowlist).
-- craft: crafting + fishing. Not job-gated; shown only when trained (cur>0 or frac>0).
local SKILL_CATEGORIES = {
combat = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 25, 26, 27 },
defense = { 28, 29, 30, 31 },
magic = { 32, 33, 34, 35, 36, 37, 38, 39 },
craft = { 48, 49, 50, 51, 52, 53, 54, 55, 56 },
}
-- Inverse lookup: sid -> category. Built lazily.
local SKILL_CATEGORY = {}
for cat, sids in pairs(SKILL_CATEGORIES) do
for _, sid in ipairs(sids) do SKILL_CATEGORY[sid] = cat end
end
-- Crafts live in a separate IPlayer:GetCraftSkill(idx) array (idx 0..8),
-- not in GetCombatSkill. Map our sid space (48..56) to that array.
local CRAFT_SID_TO_IDX = {
[48] = 0, -- Fishing
[49] = 1, -- Woodworking
[50] = 2, -- Smithing
[51] = 3, -- Goldsmithing
[52] = 4, -- Clothcraft
[53] = 5, -- Leathercraft
[54] = 6, -- Bonecraft
[55] = 7, -- Alchemy
[56] = 8, -- Cooking
}
-- Job-id -> 3-letter abbreviation, for settings-panel labels.
local JOB_ABBR = {
[1]='WAR', [2]='MNK', [3]='WHM', [4]='BLM', [5]='RDM', [6]='THF',
[7]='PLD', [8]='DRK', [9]='BST', [10]='BRD', [11]='RNG', [12]='SAM',
[13]='NIN', [14]='DRG', [15]='SMN',
}
-- FFXI chat color palette for the skillup-color picker. Each entry is a
-- single-byte color code that AshitaCore can render in chat (\30<code>), paired
-- with an approximate sRGB triple so we can draw clickable swatches in the
-- settings panel. Calibrated empirically against HorizonXI's renderer via
-- /prism colortest -- note that HorizonXI's palette diverges from retail
-- Ashita stdlib (e.g. code 81 renders as violet here, not the bracket-yellow
-- that libs/chat.lua suggests). Codes that render as plain white on HorizonXI
-- (102, 200, 121, 39, 65) are omitted.
local CHAT_PALETTE = {
{ code = 1, name = 'white', rgb = { 1.00, 1.00, 1.00 } },
{ code = 106, name = 'cream', rgb = { 1.00, 0.92, 0.65 } },
{ code = 104, name = 'yellow', rgb = { 1.00, 0.95, 0.35 } },
{ code = 8, name = 'orange', rgb = { 1.00, 0.60, 0.25 } },
{ code = 93, name = 'red', rgb = { 1.00, 0.20, 0.20 } },
{ code = 99, name = 'blood', rgb = { 0.60, 0.10, 0.10 } },
{ code = 76, name = 'salmon', rgb = { 1.00, 0.55, 0.55 } },
{ code = 68, name = 'pink', rgb = { 1.00, 0.55, 0.75 } },
{ code = 5, name = 'magenta', rgb = { 1.00, 0.45, 1.00 } },
{ code = 81, name = 'violet', rgb = { 0.70, 0.50, 1.00 } },
{ code = 71, name = 'blue', rgb = { 0.40, 0.55, 1.00 } },
{ code = 6, name = 'cyan', rgb = { 0.40, 0.95, 1.00 } },
{ code = 2, name = 'green', rgb = { 0.35, 1.00, 0.35 } },
{ code = 91, name = 'black', rgb = { 0.10, 0.10, 0.10 } },
}
-- Cast-gated magic skills only get skillups from casting (self/party
-- targets). All offensive magic is mob-level-gated like weapons, so we
-- show "Lv N+" for them just like combat skills.
local SKILL_IS_CAST_GATED = { [33]=true, [34]=true }
local RANK_LETTERS = {
[0]='A+', [1]='A', [2]='A-', [3]='B+', [4]='B', [5]='B-',
[6]='C+', [7]='C', [8]='C-', [9]='D', [10]='E', [11]='F', [12]='G',
}
local RANK_SLOPES = {
[0]=3.98, [1]=3.90, [2]=3.82, [3]=3.67, [4]=3.53, [5]=3.39,
[6]=3.24, [7]=3.08, [8]=2.92, [9]=2.69, [10]=2.47, [11]=2.24, [12]=2.02,
}
-- HorizonXI-calibrated cap reference: per-level (1..75) cap for every rank.
-- Source: Nerf's HorizonXI skill-cap spreadsheet (every value, no interp).
-- Index = rank (12-slot retail scheme; HX has no plain "A" so slot 1
-- mirrors slot 2). Array index = level (1..75).
local CAP_REF = {
[0] = { 6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66,69,72,75,78,81,84,87,90,93,96,99,102,105,108,111,114,117,120,123,126,129,132,135,138,141,144,147,150,153,158,163,168,173,178,183,188,193,198,203,207,212,217,222,227,232,236,241,246,251,256,261,266,271,276 }, -- A+
[1] = { 6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66,69,72,75,78,81,84,87,90,93,96,99,102,105,108,111,114,117,120,123,126,129,132,135,138,141,144,147,150,153,158,163,168,173,178,183,188,193,198,203,207,211,215,219,223,227,231,235,239,244,249,254,259,264,269 }, -- A (HX uses A-)
[2] = { 6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66,69,72,75,78,81,84,87,90,93,96,99,102,105,108,111,114,117,120,123,126,129,132,135,138,141,144,147,150,153,158,163,168,173,178,183,188,193,198,203,207,211,215,219,223,227,231,235,239,244,249,254,259,264,269 }, -- A-
[3] = { 5,7,10,13,16,19,22,25,28,31,34,36,39,42,45,48,51,54,57,60,63,65,68,71,74,77,80,83,86,89,92,94,97,100,103,106,109,112,115,118,121,123,126,129,132,135,138,141,144,147,151,156,161,166,171,176,181,186,191,196,199,203,207,210,214,218,221,225,229,233,237,241,246,251,256 }, -- B+
[4] = { 5,7,10,13,16,19,22,25,28,31,34,36,39,42,45,48,51,54,57,60,63,65,68,71,74,77,80,83,86,89,92,94,97,100,103,106,109,112,115,118,121,123,126,129,132,135,138,141,144,147,151,156,161,166,171,176,181,186,191,196,199,202,205,208,212,215,218,221,225,228,232,236,240,245,250 }, -- B
[5] = { 5,7,10,13,16,19,22,25,28,31,34,36,39,42,45,48,51,54,57,60,63,65,68,71,74,77,80,83,86,89,92,94,97,100,103,106,109,112,115,118,121,123,126,129,132,135,138,141,144,147,151,156,161,166,171,176,181,186,191,196,198,201,204,206,209,212,214,217,220,223,226,229,232,236,240 }, -- B-
[6] = { 5,7,10,13,16,19,21,24,27,30,33,35,38,41,44,47,49,52,55,58,61,63,66,69,72,75,77,80,83,86,89,91,94,97,100,103,105,108,111,114,117,119,122,125,128,131,133,136,139,142,146,151,156,161,166,170,175,180,185,190,192,195,197,200,202,205,207,210,212,215,218,221,224,227,230 }, -- C+
[7] = { 5,7,10,13,16,19,21,24,27,30,33,35,38,41,44,47,49,52,55,58,61,63,66,69,72,75,77,80,83,86,89,91,94,97,100,103,105,108,111,114,117,119,122,125,128,131,133,136,139,142,146,151,156,161,166,170,175,180,185,190,192,194,196,199,201,203,205,208,210,212,214,217,219,222,225 }, -- C
[8] = { 5,7,10,13,16,19,21,24,27,30,33,35,38,41,44,47,49,52,55,58,61,63,66,69,72,75,77,80,83,86,89,91,94,97,100,103,105,108,111,114,117,119,122,125,128,131,133,136,139,142,146,151,156,161,166,170,175,180,185,190,192,194,196,198,200,202,204,206,208,210,212,214,216,218,220 }, -- C-
[9] = { 4,6,9,12,14,17,20,22,25,28,31,33,36,39,41,44,47,49,52,55,58,60,63,66,68,71,74,76,79,82,85,87,90,93,95,98,101,103,106,109,112,114,117,120,122,125,128,130,133,136,140,145,150,154,159,164,168,173,178,183,184,186,188,190,192,194,195,197,199,201,203,205,207,208,210 }, -- D
[10] = { 4,6,9,11,14,16,19,21,24,26,29,31,34,36,39,41,44,46,49,51,54,56,59,61,64,66,69,71,74,76,79,81,84,86,89,91,94,96,99,101,104,106,109,111,114,116,119,121,124,126,130,135,139,144,148,153,157,162,166,171,172,174,176,178,180,182,184,186,188,190,192,194,196,198,200 }, -- E
[11] = { 4,6,8,10,13,15,17,20,22,24,27,29,31,33,36,38,40,43,45,47,50,52,54,56,59,61,63,66,68,70,73,75,77,79,82,84,86,89,91,93,96,98,100,102,105,107,109,112,114,116,120,124,128,133,137,141,146,150,154,159,161,163,165,167,169,171,173,175,177,179,181,183,185,187,189 }, -- F
}
-- HorizonXI-calibrated job→skill ranks. Includes combat (1-12), ranged
-- (25-27) and defense (28-31) all in one table — these are the skills
-- the job has main-job access to. Missing entry = skill not granted by
-- this main job (e.g. DRK does not see Polearm/Katana/etc).
-- Source: HorizonXI server data, transcribed from /skill-caps reference.
-- Post-ToAU jobs (BLU/COR/PUP/DNC/SCH/GEO/RUN) are omitted; HX is 75-cap era.
local JOB_SKILL_RANK = {
[1] = { [6]=0, [5]=2, [4]=3, [7]=3, [12]=4, [3]=4, [11]=5, [2]=5, [8]=5, [1]=9, [25]=9, [26]=9, [27]=9, [30]=6, [29]=7, [31]=8 }, -- Warrior
[2] = { [1]=0, [12]=4, [11]=6, [27]=10, [28]=2, [29]=3, [31]=10 }, -- Monk
[3] = { [11]=3, [12]=6, [27]=10, [30]=9, [29]=10 }, -- White Mage
[4] = { [12]=5, [11]=6, [2]=9, [7]=10, [27]=9, [29]=10 }, -- Black Mage
[5] = { [2]=4, [3]=4, [11]=9, [25]=9, [27]=11, [29]=9, [31]=10, [30]=11 }, -- Red Mage
[6] = { [2]=2, [3]=9, [11]=10, [1]=10, [26]=6, [25]=8, [27]=9, [29]=0, [31]=2, [30]=11 }, -- Thief
[7] = { [3]=0, [11]=2, [12]=2, [4]=4, [2]=8, [8]=10, [30]=0, [29]=7, [31]=7 }, -- Paladin
[8] = { [7]=0, [4]=2, [5]=5, [6]=5, [3]=5, [2]=7, [11]=8, [26]=10, [29]=7, [31]=10 }, -- Dark Knight
[9] = { [5]=2, [7]=5, [2]=6, [11]=9, [3]=10, [29]=7, [31]=7, [30]=10 }, -- Beastmaster
[10] = { [2]=5, [12]=6, [3]=8, [11]=9, [27]=10, [29]=9, [31]=10 }, -- Bard
[11] = { [5]=5, [2]=5, [3]=9, [11]=10, [25]=2, [26]=2, [27]=8, [29]=10 }, -- Ranger
[12] = { [10]=0, [8]=5, [3]=6, [11]=10, [2]=10, [25]=6, [27]=6, [31]=2, [29]=3 }, -- Samurai
[13] = { [9]=0, [2]=6, [3]=7, [10]=8, [11]=10, [1]=10, [27]=0, [26]=7, [25]=10, [29]=2, [31]=2 }, -- Ninja
[14] = { [8]=0, [12]=5, [3]=8, [11]=10, [2]=10, [31]=7, [29]=8 }, -- Dragoon
[15] = { [12]=4, [11]=6, [2]=10, [29]=10 }, -- Summoner
}
local JOB_MAGIC_SKILL_RANK = {
-- Casting allowlist: only the magic schools each main job actively casts
-- spells in. DRK has E-rank Divine/Healing/Enhancing latent in memory
-- (from sub-job exposure), but doesn't cast them as main job, so they
-- aren't included here -- otherwise they'd show as noise in the overlay.
-- HorizonXI is 75-cap era; SCH/BLU/etc. omitted.
[3] = { [33]=0, [32]=2, [34]=6, [35]=7 }, -- White Mage: Hea/Div/Enh/Enf
[4] = { [36]=0, [37]=2, [35]=6, [34]=10 }, -- Black Mage: Ele/Dark/Enf/Enh
[5] = { [35]=0, [34]=3, [36]=6, [33]=8, [37]=10, [32]=10 }, -- Red Mage: Enf/Enh/Ele/Hea/Dark/Div
[7] = { [32]=3, [33]=7, [34]=9 }, -- Paladin: Div/Hea/Enh
[8] = { [37]=2, [36]=3, [35]=7 }, -- Dark Knight: Dark/Ele/Enf
[13] = { [39]=2 }, -- Ninja: Ninjutsu
[15] = { [38]=2 }, -- Summoner: Summoning
}
local function rank_for_job_skill(job_id, skill_id)
local row = JOB_SKILL_RANK[job_id]
return row and row[skill_id]
end
local function rank_for_job_magic_skill(job_id, skill_id)
local row = JOB_MAGIC_SKILL_RANK[job_id]
return row and row[skill_id]
end
local function skill_cap_for(rank_idx, level)
if not rank_idx or not level or level < 1 then return nil end
local ref = CAP_REF[rank_idx]
if ref then
local L = math.floor(level)
if L < 1 then L = 1 end
if L > 75 then L = 75 end
local v = ref[L]
if v and v > 0 then return v end
end
local slope = RANK_SLOPES[rank_idx]
if not slope then return nil end
return math.floor(5 + slope * (level - 1) + 0.5)
end
-- Smallest level L (1..75) at which skill_cap_for(rank, L) >= cur.
local function effective_level_for(rank_idx, cur)
if not rank_idx or not cur then return nil end
for L = 1, 75 do
local c = skill_cap_for(rank_idx, L)
if c and c >= cur then return L end
end
return 75
end
-- Smallest mob level whose rank-curve cap exceeds cur. For combat skills
-- this is the minimum mob level you can still get skillups from. Returns
-- nil at the 75 ceiling.
local function min_mob_level_for(rank_idx, cur)
if not rank_idx or not cur then return nil end
for L = 1, 75 do
local c = skill_cap_for(rank_idx, L)
if c and c > cur then return L end
end
return nil
end
----------------------------------------------------------------
-- player + skill readers
----------------------------------------------------------------
-- Read the skill IDs of the player's currently equipped weapons (main hand
-- and ranged). We show combat-skill rows for weapons that are actually
-- equipped so the overlay stays focused on what you're swinging right now.
-- Returns a list of skill IDs (may be empty, main always first when present).
local function get_equipped_weapon_skill_ids()
local out = T{}
pcall(function()
local inv = AshitaCore:GetMemoryManager():GetInventory()
for _, slot_idx in ipairs({ 0, 2 }) do -- 0 = main hand, 2 = ranged
local eq = inv:GetEquippedItem(slot_idx)
if eq and eq.Index ~= 0 then
local container = math.floor(eq.Index / 0x100)
local slot = eq.Index % 0x100
local item = inv:GetContainerItem(container, slot)
if item and item.Id ~= 0 then
local res = AshitaCore:GetResourceManager():GetItemById(item.Id)
if res and res.Skill and res.Skill ~= 0 then
out:append(res.Skill)
end
end
end
end
end)
return out
end
-- Backwards-compat shim for any callers that still want just the main hand.
local function get_main_weapon_skill_id()
local sids = get_equipped_weapon_skill_ids()
return sids[1]
end
-- Is this sid currently equipped as a weapon? Used so prepare() can show
-- a weapon you've sub-equipped even if your main job has no rank for it.
local function is_equipped_weapon_sid(sid)
if not sid then return false end
for _, s in ipairs(get_equipped_weapon_skill_ids()) do
if s == sid then return true end
end
return false
end
-- Returns current value, rank index, and engine-reported cap for a combat
-- skill. The engine's cap is authoritative when present (it reflects the
-- exact server-side ranking, which can differ from our static tables).
-- Falls back to nil for fields the API doesn't expose so the caller can
-- compute from CAP_REF.
local function get_combat_skill(sid)
if not sid then return nil, nil, nil end
local ok, cur, rank, cap = pcall(function()
local pl = AshitaCore:GetMemoryManager():GetPlayer()
local s = pl:GetCombatSkill(sid)
if not s then return nil, nil, nil end
local raw_cur = (type(s.GetSkill) == 'function') and s:GetSkill() or s.Skill
local raw_rank = (type(s.GetRank) == 'function') and s:GetRank() or s.Rank
local raw_cap = (type(s.GetCap) == 'function') and s:GetCap() or s.Cap
return raw_cur, raw_rank, raw_cap
end)
if ok then return cur, rank, cap end
return nil, nil, nil
end
-- Returns current value, guild-rank index (0=Amateur..9=Veteran), and a
-- derived cap for a craft skill. Crafts use IPlayer:GetCraftSkill(idx) --
-- a different memory array from combat skills. The craft struct exposes
-- GetSkill / GetRank / IsCapped (no GetCap), so we derive the cap from
-- guild rank: each rank adds 10 to the ceiling (Amateur=10, Recruit=20,
-- ... Veteran=100). Key items can push beyond 100 by +5/+10 per craft;
-- we surface that by trusting IsCapped and clamping cap up to cur if
-- the engine flags us as capped but cur exceeds the rank ceiling.
-- Returns rank as nil downstream (combat-style A+/B-/etc. ranks don't
-- apply to crafts), so the renderer just shows the craft name + numbers.
local function get_craft_skill(sid)
local idx = CRAFT_SID_TO_IDX[sid]
if not idx then return nil, nil, nil end
local ok, cur, grank, capped = pcall(function()
local pl = AshitaCore:GetMemoryManager():GetPlayer()
local s = pl:GetCraftSkill(idx)
if not s then return nil, nil, nil end
local raw_cur = (type(s.GetSkill) == 'function') and s:GetSkill() or s.Skill
local raw_rank = (type(s.GetRank) == 'function') and s:GetRank() or s.Rank
local raw_capped = (type(s.IsCapped) == 'function') and s:IsCapped() or s.Capped
return raw_cur, raw_rank, raw_capped
end)
if not ok or not cur then return nil, nil, nil end
local cap = nil
if grank and grank >= 0 then
cap = (grank + 1) * 10
if cur > cap then cap = cur end -- key-item raised ceiling
if capped == true and cur > 0 then -- engine confirms cap-hit
cap = math.max(cap, cur)
end
end
return cur, nil, cap
end
-- Fractional skill accumulator: filled by packet 0x29 (authoritative,
-- MessageNum 38=tenths delta, 53=integer tick) AND text_in (chat scrape
-- fallback). Cleared when the integer skill value ticks up.
-- Packet 0x29 path adapted from Jull256/skilluptracker (Mujihina original).
-- Stored in config.skill_frac so progress survives /logout when
-- config.persist_frac is true (default). Helpers below mutate it.
local skill_frac = config.skill_frac
local _frac_dirty_at = 0 -- os.clock() of last unsaved frac change; 0 = clean
local FRAC_SAVE_DEBOUNCE = 2.0 -- seconds; persist at most this often
local function frac_get(sid)
return skill_frac[sid] or 0
end
local function frac_mark_dirty()
if not config.persist_frac then return end
-- Debounce disk writes; rapid skillups within FRAC_SAVE_DEBOUNCE coalesce.
local now = os.clock()
if _frac_dirty_at == 0 then _frac_dirty_at = now end
if (now - _frac_dirty_at) >= FRAC_SAVE_DEBOUNCE then
_frac_dirty_at = 0
settings.save()
end
end
local function frac_flush()
if _frac_dirty_at ~= 0 and config.persist_frac then
_frac_dirty_at = 0
settings.save()
end
end
-- sid -> { delta=number, at=clock } recording the most recent fractional
-- skillup. Renderers draw a floating "+0.X" overlay near each skill while
-- this entry is younger than FRAC_FLASH_LIFETIME, then clear it. Declared
-- before frac_add because frac_add writes to it on each fractional event.
local skill_frac_flash = T{}
local FRAC_FLASH_LIFETIME = 1.8
-- Add tenths/10 to the running fractional. Wraps to 0 on >=1.0 overflow
-- (the integer tick will arrive from memory).
local function frac_add(sid, delta)
local nv = (skill_frac[sid] or 0) + delta
if nv >= 1.0 then nv = 0 end
-- Round to 1 decimal place to keep the persisted Lua table tidy.
nv = math.floor(nv * 10 + 0.5) / 10
skill_frac[sid] = nv
-- Record the delta for the floating "+0.X" flash overlay. Picked up by
-- renderers below; cleared after FRAC_FLASH_LIFETIME via lazy expire.
if delta and delta > 0 then
skill_frac_flash[sid] = { delta = delta, at = os.clock() }
end
frac_mark_dirty()
end
local function frac_reset(sid)
if skill_frac[sid] ~= nil and skill_frac[sid] ~= 0 then
skill_frac[sid] = 0
frac_mark_dirty()
end
end
local _skillup_pkt_at = T{} -- sid -> os.clock() of last packet write; text_in dedupes
local CHAT_SKILL_NAMES = {
['hand-to-hand']=1,['dagger']=2,['sword']=3,['great sword']=4,['axe']=5,
['great axe']=6,['scythe']=7,['polearm']=8,['katana']=9,['great katana']=10,
['club']=11,['staff']=12,['archery']=25,['marksmanship']=26,['throwing']=27,
['guarding']=28, ['guard']=28, ['evasion']=29, ['shield']=30,
['parrying']=31, ['parry']=31,
['divine magic']=32, ['healing magic']=33, ['enhancing magic']=34,
['enfeebling magic']=35, ['elemental magic']=36, ['dark magic']=37,
['summoning magic']=38, ['ninjutsu']=39,
['fishing']=48, ['woodworking']=49, ['smithing']=50, ['goldsmithing']=51,
['clothcraft']=52, ['leathercraft']=53, ['bonecraft']=54,
['alchemy']=55, ['cooking']=56,
}
----------------------------------------------------------------
-- animation state shared across renderers
----------------------------------------------------------------
-- sid -> { pct, last } eases displayed fill toward target over ~600ms
local skill_anim = T{}
-- sid -> os.clock() when integer skill last rose; renderers draw an
-- expanding halo while this is < 0.5s old, then clear the entry.
local skill_tick_burst = T{}
-- sid -> last integer skill value seen; used to detect ticks for burst
local skill_int_seen = T{}
-- Frame-rate-independent ease toward target pct. Decay constant 8.0
-- closes ~99% of the gap in 600ms; dt clamped so a stutter doesn't snap.
local function eased_pct(sid, pct)
pct = math.max(0, math.min(1, pct or 0))
local now = os.clock()
local a = skill_anim[sid]
if not a then
a = { pct = pct, last = now }
skill_anim[sid] = a
else
local dt = math.max(0, math.min(0.1, now - a.last))
a.last = now
local k = 1.0 - math.exp(-8.0 * dt)
a.pct = a.pct + (pct - a.pct) * k
end
return a.pct, now
end
----------------------------------------------------------------
-- draw_frac_flash: floating "+0.X" overlay drawn near a skill when a
-- fractional skillup landed recently. Rises and fades over
-- FRAC_FLASH_LIFETIME seconds. Anchor point is the top-right of the
-- skill's visual cell. Each renderer calls this once per skill per frame.
----------------------------------------------------------------
local function draw_frac_flash(dl, sid, x_right, y_top, now)
local f = skill_frac_flash[sid]
if not f then return end
local age = now - f.at
if age >= FRAC_FLASH_LIFETIME then
skill_frac_flash[sid] = nil
return
end
local t = age / FRAC_FLASH_LIFETIME -- 0..1
local alpha = 1.0 - t * t -- ease-out fade
local rise = 14 * t -- pixels traveled up
local txt = string.format('+%.1f', f.delta)
local tw, th = imgui.CalcTextSize(txt)
tw = tw or 22; th = th or 12
local tx = math.floor(x_right - tw)
local ty = math.floor(y_top - 2 - rise)
local shadow = imgui.GetColorU32({ 0.0, 0.0, 0.0, 0.85 * alpha })
local green = imgui.GetColorU32({ 0.55, 1.0, 0.55, alpha })
dl:AddText({ tx + 1, ty + 1 }, shadow, txt)
dl:AddText({ tx, ty }, green, txt)
end
----------------------------------------------------------------
-- draw_arc: filled-quad arc for the donut ring. Each quad extends
-- slightly past its angular bounds so adjacent quads overlap and
-- anti-aliasing seams between them are invisible.
----------------------------------------------------------------
local function draw_arc(dl, cx, cy, r, a0, a1, color, thickness, segs)
segs = segs or 64
local span = a1 - a0
local step = span / segs
local r_in = r - thickness * 0.5
local r_out = r + thickness * 0.5
local bleed = step * 0.2
for i = 0, segs - 1 do
local qa = a0 + step * i - bleed
local qb = a0 + step * (i + 1) + bleed
local cos_a, sin_a = math.cos(qa), math.sin(qa)
local cos_b, sin_b = math.cos(qb), math.sin(qb)
local p1 = { cx + r_in * cos_a, cy + r_in * sin_a }
local p2 = { cx + r_out * cos_a, cy + r_out * sin_a }
local p3 = { cx + r_out * cos_b, cy + r_out * sin_b }
local p4 = { cx + r_in * cos_b, cy + r_in * sin_b }
dl:AddTriangleFilled(p1, p2, p3, color)
dl:AddTriangleFilled(p1, p3, p4, color)
end
end
----------------------------------------------------------------
-- layout constants
----------------------------------------------------------------
local SKILL_PILL_WIDTH = 220
local SKILL_PILL_HEIGHT = 18
local SKILL_DONUT_RADIUS = 26
local SKILL_DONUT_THICK = 6
local SKILL_DONUT_CELL_W = 78
local SKILL_DONUT_CELL_H = 115
local SKILL_CRYSTAL_R = 30 -- half-height
local SKILL_CRYSTAL_W = 26 -- half-width (slight tall bias)
local SKILL_CRYSTAL_CELL_W = 78
local SKILL_CRYSTAL_CELL_H = 128
----------------------------------------------------------------
-- Crystal color is by RANK TIER (MMO-rarity vibe), not family.
-- A+/A/A- -> gold
-- B+/B/B- -> green
-- C+/C/C- -> cyan
-- D -> light blue
-- E/F/G -> grey
----------------------------------------------------------------
local TIER_COLOR = {
[0] = { 1.00, 0.85, 0.25, 1.0 }, -- A+
[1] = { 1.00, 0.85, 0.25, 1.0 }, -- A
[2] = { 1.00, 0.85, 0.25, 1.0 }, -- A-
[3] = { 0.45, 0.95, 0.50, 1.0 }, -- B+
[4] = { 0.45, 0.95, 0.50, 1.0 }, -- B
[5] = { 0.45, 0.95, 0.50, 1.0 }, -- B-
[6] = { 0.45, 0.88, 0.98, 1.0 }, -- C+
[7] = { 0.45, 0.88, 0.98, 1.0 }, -- C
[8] = { 0.45, 0.88, 0.98, 1.0 }, -- C-
[9] = { 0.50, 0.70, 1.00, 1.0 }, -- D
[10] = { 0.62, 0.65, 0.72, 1.0 }, -- E
[11] = { 0.62, 0.65, 0.72, 1.0 }, -- F
[12] = { 0.62, 0.65, 0.72, 1.0 }, -- G
}
local function tier_color_for(rank_idx)
return TIER_COLOR[rank_idx] or { 0.70, 0.74, 0.80, 1.0 }
end
----------------------------------------------------------------
-- skill_pill: horizontal glass pill with eased fill, near-cap glow,
-- and a tick burst. Label is the whole "Name cur/cap (rank) Lv N+"
-- string -- centered inside the pill.
----------------------------------------------------------------
local function skill_pill(sid, pct, color, label, forced_width, letter, pill_badge_w)
local draw_pct, now = eased_pct(sid, pct)
local sc = config.scale or 1.0
local height = math.floor(14 * sc)
if height < 12 then height = 12 end
local width
if forced_width then
width = forced_width
else
width = math.floor(SKILL_PILL_WIDTH * sc)
if label and label ~= '' then
local tw0 = imgui.CalcTextSize(label) or 0
local min_w = math.floor(tw0 + 16)
if width < min_w then width = min_w end
end
end
local pbw = pill_badge_w or 0
local x0, y0 = imgui.GetCursorScreenPos()
local bar_x = x0 + pbw
local bar_w = width - pbw
local dl = imgui.GetWindowDrawList()
local rounding = math.floor(height * 0.5)
-- Tick burst.
local burst_t = skill_tick_burst[sid]
if burst_t then
local bdt = now - burst_t
if bdt < 0.5 then
local bt = bdt / 0.5
local bc = imgui.GetColorU32({ color[1], color[2], color[3], (1 - bt) * 0.7 })
dl:AddRect({ bar_x - 2, y0 - 2 },
{ bar_x + bar_w + 2, y0 + height + 2 }, bc, rounding + 2, 15, 1.5)
else
skill_tick_burst[sid] = nil
end
end
-- Trough.
local bg_col = imgui.GetColorU32({ 0.05, 0.05, 0.08, 0.88 })
dl:AddRectFilled({ bar_x, y0 }, { bar_x + bar_w, y0 + height }, bg_col, rounding, 15)
-- Colored fill.
if draw_pct > 0.0 then
local cr, cg, cb = color[1], color[2], color[3]
local ca = color[4] or 1.0
local fx2 = bar_x + bar_w * draw_pct
dl:AddRectFilled({ bar_x, y0 }, { fx2, y0 + height },
imgui.GetColorU32({ cr, cg, cb, ca }), rounding, 15)
local light = imgui.GetColorU32({
math.min(1, cr + 0.15),
math.min(1, cg + 0.15),
math.min(1, cb + 0.15), 0.25 })
dl:AddRectFilled({ bar_x, y0 }, { fx2, y0 + math.floor(height * 0.5) },
light, rounding, 3)
end
-- Rank-colored outline.
local ol = imgui.GetColorU32({ color[1], color[2], color[3], 0.85 })
dl:AddRect({ bar_x, y0 }, { bar_x + bar_w, y0 + height }, ol, rounding, 15, 1.2)
-- Text label centered in bar.
if label and label ~= '' then
local tw, th = imgui.CalcTextSize(label)
tw = tw or 0; th = th or 0
local tx = bar_x + (bar_w - tw) * 0.5
local ty = y0 + (height - th) * 0.5
local shadow = imgui.GetColorU32({ 0.0, 0.0, 0.0, 0.85 })
local white = imgui.GetColorU32({ 1.0, 1.0, 1.0, 1.0 })
dl:AddText({ tx + 1, ty + 1 }, shadow, label)
dl:AddText({ tx, ty }, white, label)
end
-- Rank pill badge on the left (fixed width from pill_badge_w, matches bar height).
if letter and letter ~= '' and pbw > 0 then
local lw, lh = imgui.CalcTextSize(letter)
lw = lw or 0; lh = lh or 10
local gap = math.floor(3 * sc)
local pw = pbw - gap
local ph = height
local px = x0
local py = y0
local pillbg = imgui.GetColorU32({ 0.07, 0.08, 0.11, 0.95 })
local pillb = imgui.GetColorU32({ color[1], color[2], color[3], 0.95 })
dl:AddRectFilled({ px, py }, { px + pw, py + ph }, pillbg, 3, 15)
dl:AddRect({ px, py }, { px + pw, py + ph }, pillb, 3, 15, 1.2)
local pill_shadow = imgui.GetColorU32({ 0, 0, 0, 0.85 })
local txtcol = imgui.GetColorU32({ 0.95, 0.97, 1.0, 1.0 })
local ltx = px + (pw - lw) * 0.5
local lty = py + (ph - lh) * 0.5
dl:AddText({ ltx + 1, lty + 1 }, pill_shadow, letter)
dl:AddText({ ltx, lty }, txtcol, letter)
end
imgui.Dummy({ width, height })
-- Floating "+0.X" overlay (drawn last so it sits on top of everything).
draw_frac_flash(dl, sid, x0 + width, y0, now)
end
----------------------------------------------------------------
-- skill_donut: radial gauge with OSRS-style interior.
-- - thick rank-colored arc fills clockwise from 12 o'clock
-- - small dim rank letter near the top
-- - big bright effective level number centered
-- - caption: skill name / cur/cap / "Lv N+" or "cast" hint
----------------------------------------------------------------
local function skill_donut(sid, pct, color, label, cur_str, cap_str, letter, eff_lvl, min_mob_lvl, is_cast_gated)
local draw_pct, now = eased_pct(sid, pct)
local x0, y0 = imgui.GetCursorScreenPos()
local sc = config.scale or 1.0
local r = SKILL_DONUT_RADIUS * sc
local thick = SKILL_DONUT_THICK * sc
local r_out = r + thick * 0.5
local r_in = r - thick * 0.5
local top_pad = math.ceil(thick * 0.5) + 29
local cw = math.max(SKILL_DONUT_CELL_W * sc, 2 * r_out + 16)
local text_block_h = 52
local ch = top_pad + 2 * r_out + 4 + text_block_h
local cx = x0 + cw * 0.5
local cy = y0 + r_out + top_pad
local dl = imgui.GetWindowDrawList()
-- Tick burst behind the donut.
local burst_t = skill_tick_burst[sid]
if burst_t then
local bdt = now - burst_t
if bdt < 0.5 then
local s = 1 - bdt / 0.5
local bc = imgui.GetColorU32({ color[1], color[2], color[3], 0.7 * s })
local bpad = bdt * 14
dl:AddCircle({ cx, cy }, r_out + bpad, bc, 48, 1.5)
else
skill_tick_burst[sid] = nil
end
end
-- Donut ring: scanline strips for both trough and fill (guaranteed
-- gap-free, same technique as the crystal renderer). AA circles on
-- outer/inner edges smooth the curve stairstepping.
local TWO_PI = math.pi * 2
local a0 = -math.pi * 0.5
local fill_span = TWO_PI * draw_pct
local has_fill = draw_pct > 0.005
local a1 = a0 + fill_span
local trough_col = imgui.GetColorU32({ 0.08, 0.08, 0.10, 0.95 })
local fc = imgui.GetColorU32({ color[1], color[2], color[3], 0.98 })
local function is_filled(theta)
local diff = (theta - a0) % TWO_PI
return diff <= fill_span
end
local function boundary_x(a, dy)
local sa = math.sin(a)
if math.abs(sa) < 0.001 then return nil end
local t = dy / sa
if t < 0 then return nil end
return cx + t * math.cos(a)
end
local y_top = math.floor(cy - r_out)
local y_bot = math.ceil(cy + r_out)
for y = y_top, y_bot do
local dy = y + 0.5 - cy
local dy2 = dy * dy
-- Trough uses slightly smaller radii so outline fully covers it.
-- Fill uses slightly larger radii so outline sits on top cleanly.
local r_out_t = r_out - 0.5
local r_in_t = r_in + 0.5
local r_out_f = r_out + 1
local r_in_f = math.max(0, r_in - 1)
if dy2 < r_out_f * r_out_f then
if not has_fill then
if dy2 < r_out_t * r_out_t then
local xo = math.sqrt(r_out_t * r_out_t - dy2)
local xi = (dy2 < r_in_t * r_in_t) and math.sqrt(r_in_t * r_in_t - dy2) or 0
if xi > 0.5 then
dl:AddRectFilled({ cx - xo, y }, { cx - xi, y + 1 }, trough_col)
dl:AddRectFilled({ cx + xi, y }, { cx + xo, y + 1 }, trough_col)
else
dl:AddRectFilled({ cx - xo, y }, { cx + xo, y + 1 }, trough_col)
end
end
else
local xo = math.sqrt(r_out_f * r_out_f - dy2)
local xi = (dy2 < r_in_f * r_in_f) and math.sqrt(r_in_f * r_in_f - dy2) or 0
local xo_t = (dy2 < r_out_t * r_out_t) and math.sqrt(r_out_t * r_out_t - dy2) or 0
local xi_t = (dy2 < r_in_t * r_in_t) and math.sqrt(r_in_t * r_in_t - dy2) or 0
local strips
if xi > 0.5 then
strips = { { cx - xo, cx - xi }, { cx + xi, cx + xo } }
else
strips = { { cx - xo, cx + xo } }
end
local bx0 = boundary_x(a0, dy)
local bx1 = boundary_x(a1, dy)
for _, s in ipairs(strips) do
local sl, sr = s[1], s[2]
local cuts = { sl }
if bx0 and bx0 > sl + 0.5 and bx0 < sr - 0.5 then cuts[#cuts + 1] = bx0 end
if bx1 and bx1 ~= bx0 and bx1 > sl + 0.5 and bx1 < sr - 0.5 then cuts[#cuts + 1] = bx1 end
cuts[#cuts + 1] = sr
table.sort(cuts)
for ci = 1, #cuts - 1 do
local cl, cr = cuts[ci], cuts[ci + 1]
if cr - cl > 0.1 then
local mx = (cl + cr) * 0.5
local theta = math.atan2(dy, mx - cx)
if is_filled(theta) then
dl:AddRectFilled({ cl, y }, { cr, y + 1 }, fc)
else
local tl = math.max(cl, cx - xo_t)
local tr = math.min(cr, cx + xo_t)
if xi_t > 0.5 then
if tl < cx - xi_t then
dl:AddRectFilled({ tl, y }, { math.min(tr, cx - xi_t), y + 1 }, trough_col)
end
if tr > cx + xi_t then
dl:AddRectFilled({ math.max(tl, cx + xi_t), y }, { tr, y + 1 }, trough_col)
end
elseif tr > tl then
dl:AddRectFilled({ tl, y }, { tr, y + 1 }, trough_col)
end
end
end
end
end
end
end
end
-- Tier-colored outlines on outer/inner edges. These AA circles cover
-- the scanline stairstepping on both the trough and fill, giving the
-- donut clean smooth edges.
local outline_col = imgui.GetColorU32({ color[1], color[2], color[3], 0.90 })
dl:AddCircle({ cx, cy }, r_out, outline_col, 64, 1.5)
dl:AddCircle({ cx, cy }, r_in, outline_col, 64, 1.5)
local shadow = imgui.GetColorU32({ 0, 0, 0, 0.85 })
local white = imgui.GetColorU32({ 1, 1, 1, 1.0 })
local dim = imgui.GetColorU32({ 0.65, 0.68, 0.74, 1.0 })
-- Rank pill badge at the top of the donut (same style as crystals).
if letter and letter ~= '' then
local lw, lh = imgui.CalcTextSize(letter)
lw = lw or 0; lh = lh or 10
local pw, ph = math.floor((lw + 12) * sc), math.floor((lh + 4) * sc)
local px = cx - pw * 0.5
local py = (cy - r_out) - ph * 0.5 - 15
local pillbg = imgui.GetColorU32({ 0.07, 0.08, 0.11, 0.95 })
local pillb = imgui.GetColorU32({ color[1], color[2], color[3], 0.95 })
dl:AddRectFilled({ px, py }, { px + pw, py + ph }, pillbg, math.floor(3 * sc), 15)
dl:AddRect({ px, py }, { px + pw, py + ph }, pillb, math.floor(3 * sc), 15, 1.6)
local txtcol = imgui.GetColorU32({ 0.95, 0.97, 1.0, 1.0 })
local tx = px + (pw - lw) * 0.5
local ty = py + (ph - lh) * 0.5
dl:AddText({ tx + 1, ty + 1 }, shadow, letter)
dl:AddText({ tx, ty }, txtcol, letter)
end
-- Big effective level number centered.
if eff_lvl then
local s = tostring(eff_lvl)
local nw, nh = imgui.CalcTextSize(s)
nw = nw or 0; nh = nh or 12
local nx = cx - nw * 0.5
local ny = cy - nh * 0.5 + 2
dl:AddText({ nx + 1, ny + 1 }, shadow, s)
dl:AddText({ nx, ny }, white, s)
end
-- Caption: name, cur/cap, hint.
local cap_y = cy + r + 14
if label then
local maxw = cw - 4
local lstr = label
local lw = imgui.CalcTextSize(lstr) or 0
while lw > maxw and #lstr > 1 do
lstr = lstr:sub(1, -2)
lw = imgui.CalcTextSize(lstr) or 0
end
local lh
lw, lh = imgui.CalcTextSize(lstr)
lw = lw or 0; lh = lh or 12
local lx = x0 + (cw - lw) * 0.5
dl:AddText({ lx + 1, cap_y + 1 }, shadow, lstr)
dl:AddText({ lx, cap_y }, white, lstr)
cap_y = cap_y + lh + 1
end
if cur_str then
local sub = cap_str and (cur_str .. '/' .. cap_str) or cur_str
local sw, sh = imgui.CalcTextSize(sub)
sw = sw or 0; sh = sh or 12
local sx = x0 + (cw - sw) * 0.5
dl:AddText({ sx + 1, cap_y + 1 }, shadow, sub)
dl:AddText({ sx, cap_y }, dim, sub)
cap_y = cap_y + sh + 1
end
local hint
if is_cast_gated then
hint = 'cast'
elseif min_mob_lvl then
hint = ('Lv %d+'):format(min_mob_lvl)
end
if hint then
local hw, hh = imgui.CalcTextSize(hint)
hw = hw or 0; hh = hh or 12
local hx = x0 + (cw - hw) * 0.5
local accent
if is_cast_gated then
accent = imgui.GetColorU32({ 0.55, 0.70, 0.95, 0.95 })
else
accent = imgui.GetColorU32({ 0.95, 0.82, 0.45, 0.95 })
end
dl:AddText({ hx + 1, cap_y + 1 }, shadow, hint)
dl:AddText({ hx, cap_y }, accent, hint)
end
imgui.Dummy({ cw, ch })
-- Floating "+0.X" overlay anchored near the top-right of the donut.
draw_frac_flash(dl, sid, cx + r_out + 6, cy - r_out - 6, now)
end
----------------------------------------------------------------
-- skill_crystal: FF-style tall hexagonal crystal. Scanline-rendered
-- interior (no triangle fans, no ghost lines, no clipping artifacts).
-- - Colored glow halo + soft backlight in tier color
-- - Dark crystal body filled by scanline strips
-- - Gradient fill rises from bottom (bright/white base → tier color)
-- - Facet lines, highlight stripe, sparkles (all AddLine-safe)
-- - Colored outline, rank pill on top, caption below
----------------------------------------------------------------
local function skill_crystal(sid, pct, color, label, cur_str, cap_str, letter, eff_lvl, min_mob_lvl, is_cast_gated)
local draw_pct, now = eased_pct(sid, pct)
local x0, y0 = imgui.GetCursorScreenPos()
local sc = config.scale or 1.0