-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdeathclock.lua
More file actions
2211 lines (2099 loc) · 94.9 KB
/
deathclock.lua
File metadata and controls
2211 lines (2099 loc) · 94.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
addon.name = 'deathclock'
addon.author = 'Blake & Watney'
addon.version = '0.3.33'
addon.desc = 'FFXI respawn timers: tracks mob deaths, predicts pops, draws return-arcs to the kill spot.'
addon.commands = { '/dc', '/rt' }
-- Extracted from huntpartner v0.7.93 so the respawn feature can be reloaded
-- independently of the rest of the hunt UI. Same death-detection (HPP-scan),
-- same urgency colors (red > yellow > green), same drawArc return-lines.
require('common')
local chat = require('chat')
local settings = require('settings')
local imgui = require('imgui')
-- Vendored targetlines drawArc machinery for in-world respawn return-lines.
-- See vendor/targetlines/NOTICE.md for source/attribution. Loaded under
-- pcall so a binding mismatch on d3d8/ffi can't keep deathclock from
-- loading at all -- without drawArc, lines just don't render and every
-- other feature still works.
local _tl_root = string.format('%s\\vendor\\targetlines', addon.path)
package.path = string.format('%s\\?.lua;%s', _tl_root, package.path)
local drawArc
do
local ok, mod = pcall(require, 'drawArc')
if ok then drawArc = mod end
end
-- Optional: world->screen helper + d3d8 device for arc labels. Same pcall
-- discipline -- if any of these fail to load, labels just don't render
-- and the arc itself keeps working.
local tl_helpers, d3d8dev, d3dC
do
local ok, mod = pcall(require, 'tl_helpers')
if ok then tl_helpers = mod end
local ok2, d3d8 = pcall(require, 'd3d8')
if ok2 then
local okdev, dev = pcall(d3d8.get_device)
if okdev then d3d8dev = dev end
end
local ok3, ffi = pcall(require, 'ffi')
if ok3 then d3dC = ffi.C end
end
----------------------------------------------------------------
-- persisted config
----------------------------------------------------------------
local default_settings = T{
window = T{
visible = true,
x = 100,
y = 100,
w = 340,
bg_alpha = 1.0,
},
-- 5m 49s -- measured on HorizonXI for claim mobs.
default_respawn = 349,
overrides = T{},
keep_dead_after_respawn = 30,
track_respawns = true,
respawn_lines = true,
-- NMs auto-detected via the kill chat message ("Spiny Spipi falls to the
-- ground" -- no "The " article = NM). `nms[name] = true` is the persistent
-- flag; `nm_kills[name] = {count, first, last, last_zone}` is the counter.
-- NMs are tracked here instead of the regular respawn table because their
-- spawn model (lottery/window/force-pop) doesn't fit a fixed timer.
nms = T{},
nm_kills = T{},
-- Spawn-slot observation log. Server IDs are stable per spawn point in
-- FFXI; the same slot can host an NM or its placeholder depending on
-- the lottery outcome. By recording (server_id -> {names_observed}) we
-- can later infer placeholder relationships: any non-Notorious name
-- observed in the same slot as a known NM is a PH for it.
-- Schema: slot_map[server_id_str] = {
-- zone = zone_id, last_seen = unix_ts,
-- names = { [mob_name] = { count = N, last = unix_ts } }
-- }
-- Server ID is stored as a decimal STRING key (Ashita settings can be
-- inconsistent with huge int keys; strings round-trip cleanly).
slot_map = T{},
-- When mobdb is installed, look up per-mob Notorious flag
-- from its zone data files. Toggle off if it causes problems.
use_mobdb = true,
-- (legacy `respawn_lines_show_all` intentionally NOT in defaults -- its
-- presence in a loaded XML is the signal that the user predates v0.2.0
-- and needs the arc_show_below_pct migration.)
-- Color bands keyed by % ELAPSED of the respawn window. Red = freshly
-- killed (cooling corpse), bands cool through orange/yellow/green/blue
-- as the timer matures, then purple at ready (eta <= 0) for "pop time."
-- Thermometer-inverted-into-spectral, very FFXI-flavored.
-- ImGui RGB floats 0-1. Alpha is not user-editable: bars use 1.0, arcs
-- use 0.75 to read over terrain without becoming a blinding overlay.
colors = T{
red = T{ 1.00, 0.33, 0.33 },
orange = T{ 1.00, 0.60, 0.20 },
yellow = T{ 1.00, 0.93, 0.27 },
green = T{ 0.40, 0.85, 0.55 },
blue = T{ 0.35, 0.55, 1.00 },
purple = T{ 0.80, 0.40, 1.00 },
},
-- How many color bands are active. 1 = single color always. 2 = one
-- timer color plus a distinct "ready" color (eta<=0). 3-6 = spectrum
-- across the timer with the final slot reserved for ready. Each slot
-- is independently recolorable, so any palette (monochrome, black/
-- white/brown, autumn, whatever) is achievable.
color_count = 6,
-- Thresholds are the LOWER bound (% of total respawn ELAPSED) for each
-- band beyond the first. Going up: >= purple → purple, >= blue → blue,
-- ..., otherwise red. Must stay monotonically increasing; clamped on
-- slider edit. Red is the floor (no slider). Purple's default of 100
-- means "only at pop time (eta<=0)" -- the final band always kicks in
-- at pop regardless of its threshold, but you can drag this lower for
-- an early-warning color (e.g. 90 = ready color shows in the last 10%).
thresholds = T{
orange = 20,
yellow = 40,
green = 60,
blue = 80,
purple = 100,
},
-- In-world arcs render only when pct ELAPSED >= this. 0 = always,
-- 100 = never (use the on/off toggle for that). A higher value hides
-- arcs for fresh kills and reveals them as the timer matures.
arc_show_above_elapsed_pct = 0,
-- When true, arcs ignore the threshold above and render the moment a
-- kill is logged. The slider stays in config but is disabled while this
-- is on. Default true because "always on" is what most users want.
arc_always_on = true,
-- Draw the mob name + eta as a floating text label at the death spot,
-- following the arc to its endpoint. Off in the rare case the user
-- doesn't have d3d8/tl_helpers available (graceful fallback).
arc_labels = true,
-- Per-window font scale applied to each arc label. 1.0 = ImGui's
-- default; 1.5-2.0 reads comfortably from a normal viewing distance.
-- Stored as a float and clamped to [0.5, 3.0] in the slider.
arc_label_scale = 1.0,
-- v0.3.29: animated tracer effect. When true, the arc paints itself from
-- player to spawn point on a loop with a glowing orb head, and pulses
-- alpha faster as eta drops. Pure cosmetic, all gated client-side; toggle
-- off via /dc arc-fx if it gets distracting in heavy pull rotations.
arc_fx = true,
-- Base sweep duration in seconds for the tracer. The actual sweep speeds
-- up linearly with urgency so imminent pops feel frantic.
arc_sweep_sec = 1.6,
-- v0.3.32: alternate marker style. When true, each tracked spawn point
-- gets a thin vertical "beacon" pillar in the urgency color INSTEAD of
-- a player->spawn arc. Reads as a map marker, not a line attached to
-- you. Toggle via /dc beacon. Default off so existing users see no
-- change; pure opt-in cosmetic alternative.
arc_beacon = false,
-- When true, only mobs that were claimed by the player or someone in
-- the player's party/alliance at time of death are tracked. Skips
-- random mobs that someone else killed nearby (the noisy default of a
-- pure HPP-scan). Set false to go back to "track every mob that dies".
only_my_kills = true,
}
local config = settings.load(default_settings)
-- One-time migration: old huntpartner defaults (300, 345) were less accurate
-- than measured HorizonXI claim-mob respawn. Anyone migrating from huntpartner
-- whose settings happened to carry the legacy value gets the correction
-- without re-running /dc default.
if config.default_respawn == 300 or config.default_respawn == 345 then
config.default_respawn = 349
settings.save()
end
-- v0.2.0 migration: legacy boolean `respawn_lines_show_all` becomes the
-- continuous `arc_show_above_elapsed_pct` (was briefly `arc_show_below_pct`
-- in v0.2.0). False (the old default) meant "only green and yellow arcs"
-- -- yellow was eta<=60s, roughly the last 20% of a 349s timer. Map false →
-- show only when elapsed >= 80 (last fifth), true → 0 (always show).
-- Sentinel prevents re-applying.
if not config._arc_pct_migrated then
config._arc_pct_migrated = true
if config.respawn_lines_show_all == false then
config.arc_show_above_elapsed_pct = 80
config.arc_always_on = false
end
settings.save()
end
-- v0.3.10 migration: introduced `arc_always_on` checkbox. Existing users
-- whose slider sat at 0 ("always on") get the checkbox flipped on so
-- behavior is unchanged. Anyone with a non-zero threshold keeps the
-- slider active. Sentinel keyed off the new field's absence in storage.
if config._arc_always_on_migrated == nil then
config._arc_always_on_migrated = true
config.arc_always_on = ((config.arc_show_above_elapsed_pct or 0) <= 0)
settings.save()
end
-- v0.2.1 migration: flipped the color-band axis from %-remaining to
-- %-elapsed and replaced the "ready" band with "purple". Old v0.2.0 users
-- have `arc_show_below_pct` (inverse semantics) and a `ready` color but no
-- `purple`. Translate cleanly: new_above_elapsed = 100 - old_below_remaining;
-- carry the ready color over as purple if user customized it.
if not config._v021_axis_flip then
config._v021_axis_flip = true
if type(config.arc_show_below_pct) == 'number' then
config.arc_show_above_elapsed_pct = math.max(0, math.min(100, 100 - config.arc_show_below_pct))
config.arc_show_below_pct = nil
end
if config.colors and config.colors.ready and not (config.colors.purple and config.colors.purple[1]) then
config.colors.purple = config.colors.ready
end
if config.colors then config.colors.ready = nil end
-- v0.2.0 thresholds were %-remaining (blue=75, green=50, yellow=25,
-- orange=10). Flip to %-elapsed equivalents: 100 - old.
if config.thresholds then
local th = config.thresholds
-- Detect v0.2.0 shape by the presence of blue >= 50 (v0.2.1 default
-- blue is 80; v0.2.0 default blue is 75 -- both > 50). If the user
-- never opened the panel, the values match v0.2.0 defaults and
-- need flipping; if they did customize, we still flip because the
-- axis itself inverted.
local looks_like_v020 = (th.blue or 0) > (th.green or 0)
and (th.green or 0) > (th.yellow or 0)
and (th.yellow or 0) > (th.orange or 0)
if looks_like_v020 then
config.thresholds = T{
orange = 100 - (th.orange or 10),
yellow = 100 - (th.yellow or 25),
green = 100 - (th.green or 50),
blue = 100 - (th.blue or 75),
}
end
end
settings.save()
end
-- First-load migration from huntpartner. If our overrides table is empty AND
-- huntpartner has a settings.xml we can read, lift over default_respawn,
-- overrides, keep_dead_after_respawn, track_respawns, respawn_lines,
-- respawn_lines_show_all. Best-effort: any failure leaves us on fresh
-- defaults. Tracked via a sentinel so we only attempt this once per install.
if not config._hp_migration_attempted then
config._hp_migration_attempted = true
pcall(function()
local hp_path = string.format('%s\\..\\huntpartner\\settings\\settings.xml', addon.path)
local f = io.open(hp_path, 'r')
if not f then return end
local xml = f:read('*a')
f:close()
if not xml or xml == '' then return end
local n = tonumber(xml:match('<default_respawn[^>]*>(%d+)</default_respawn>'))
if n and n > 0 then config.default_respawn = n end
local k = tonumber(xml:match('<keep_dead_after_respawn[^>]*>(%d+)</keep_dead_after_respawn>'))
if k and k > 0 then config.keep_dead_after_respawn = k end
local tr = xml:match('<track_respawns[^>]*>([%a]+)</track_respawns>')
if tr then config.track_respawns = (tr:lower() == 'true') end
local rl = xml:match('<respawn_lines[^>]*>([%a]+)</respawn_lines>')
if rl then config.respawn_lines = (rl:lower() == 'true') end
local sa = xml:match('<respawn_lines_show_all[^>]*>([%a]+)</respawn_lines_show_all>')
if sa then config.respawn_lines_show_all = (sa:lower() == 'true') end
-- overrides: <overrides><Mob_Name>secs</Mob_Name>...</overrides>
-- huntpartner's settings lib serializes table keys with spaces as
-- underscores in tag names; lift them back. Best effort.
local ov = xml:match('<overrides>(.-)</overrides>')
if ov then
for tag, val in ov:gmatch('<([%w_]+)[^>]*>(%d+)</%1>') do
local name = tag:gsub('_', ' ')
config.overrides[name] = tonumber(val)
end
end
end)
settings.save()
end
settings.register('settings', 'settings_update', function(s)
if s ~= nil then config = s end
settings.save()
end)
local function save() settings.save() end
----------------------------------------------------------------
-- shared helpers
----------------------------------------------------------------
local function now() return os.time() end
local function say(msg) print(chat.header('dc') .. chat.message(msg)) end
local function fmt_eta(secs)
if secs <= 0 then return 'READY' end
local m = math.floor(secs / 60)
local s = secs % 60
if m > 0 then return ('%dm%02ds'):format(m, s) end
return ('%ds'):format(s)
end
local function get_zone_id()
return AshitaCore:GetMemoryManager():GetParty():GetMemberZone(0)
end
local function get_zone_name(zone_id)
return AshitaCore:GetResourceManager():GetString('zones', zone_id) or ('zone_' .. tostring(zone_id))
end
----------------------------------------------------------------
-- RESPAWN TRACKER
----------------------------------------------------------------
local kills = T{}
-- Last error caught from arc-label rendering. Printed once per unique error
-- by the label block; surfaces via `/dc diag` for cold inspection.
local last_label_err
local last_hpp = T{}
-- Previous frame's claimer server ID per entity index. We read claim_id
-- from the frame BEFORE death because the claim field commonly clears on
-- the same tick that HPP transitions to 0 -- reading at the death frame
-- often returns 0 (unclaimed) for a mob we actually killed. Carrying the
-- previous frame's value preserves credit.
local last_claim = T{}
-- Engage-position snapshot per mob server_id. Captured the first frame
-- claim transitions from 0/nil to a real claimer (us or alliance). That
-- frame's mob (x,y,z) is the best proxy we have for the spawn point --
-- mobs wander only a short distance pre-aggro, and the death position
-- can be hundreds of yalms off after a kite. Keyed by server_id (stable
-- per spawn slot) so it survives the mob despawning and re-popping.
-- Cleared on death after the kill record is built.
local engage_pos = {}
local last_scan = 0
-- Session-only ignore set, keyed by lowercase name. Deliberately not
-- persisted: "I don't care about Svana Rarab right now" is almost always
-- zone-specific or session-specific. Wiping on /lua reload is the feature.
local ignored = {}
local function is_ignored(name)
return name and ignored[name:lower()] ~= nil
end
-- Forward declarations -- promote_to_nm is defined later (it shares helpers
-- with record_nm_kill) but record_kill needs to reach it for mobdb-detected
-- NMs. Declaring `local` here without assignment lets the later `function ...`
-- (no `local`) assign to this slot rather than introduce a new one.
local promote_to_nm
local record_nm_kill
-- ============================================================
-- mobdb integration (optional)
-- ============================================================
-- If the `mobdb` addon is installed, deathclock reads its per-zone mob
-- database to look up the Notorious flag and auto-divert NM kills to the
-- NMs tab. More reliable than the chat-article heuristic and works for
-- the FIRST kill (no race with the entity scanner).
--
-- We deliberately do NOT use mobdb's `Respawn` field. mobdb data is
-- AirSkyBoat/Wings-derived and HorizonXI has tuned respawn timers; the
-- default_respawn here (349s) was measured on HXI and beats mobdb's value
-- for the trash mobs we care about. mobdb is consulted for Notorious only.
--
-- We READ mobdb's data files; we do not require the addon to be loaded.
-- If mobdb is not installed the lookups silently return nil.
local mobdb_zone_cache = {} -- [zone_id] = data_table or false (known-missing)
local function mobdb_load_zone(zone_id)
if not zone_id or zone_id == 0 then return nil end
if mobdb_zone_cache[zone_id] ~= nil then
return mobdb_zone_cache[zone_id] or nil
end
local path = string.format('%saddons/mobdb/data/%u.lua', AshitaCore:GetInstallPath(), zone_id)
local f = io.open(path, 'r')
if not f then
mobdb_zone_cache[zone_id] = false
return nil
end
f:close()
local chunk, err = loadfile(path)
if not chunk then
mobdb_zone_cache[zone_id] = false
return nil
end
local ok, data = pcall(chunk)
if not ok or type(data) ~= 'table' or type(data.Names) ~= 'table' then
mobdb_zone_cache[zone_id] = false
return nil
end
mobdb_zone_cache[zone_id] = data
return data
end
-- Look up a mob in the current zone's mobdb. Returns the record table or nil.
local function mobdb_lookup(name)
if not config.use_mobdb then return nil end
if not name or name == '' then return nil end
local data = mobdb_load_zone(get_zone_id())
if not data then return nil end
return data.Names[name]
end
local function get_respawn_window(name)
return config.overrides[name] or config.default_respawn
end
local function record_kill(name, server_id, x, y, z, spawn_x, spawn_y, spawn_z)
if is_ignored(name) then return end
-- Capture spawn-slot observation BEFORE any early returns. We want
-- slot data for NMs too -- that's literally the point (knowing a slot
-- hosted both Crawler and Spiny Spipi is what proves PH relationship).
if server_id and server_id ~= 0 then
local key = tostring(server_id)
config.slot_map = config.slot_map or T{}
local slot = config.slot_map[key]
if not slot then
slot = T{ zone = get_zone_id(), names = T{}, last_seen = 0 }
config.slot_map[key] = slot
end
local n = slot.names[name] or T{ count = 0, last = 0 }
n.count = (n.count or 0) + 1
n.last = now()
slot.names[name] = n
slot.last_seen = now()
slot.zone = get_zone_id()
end
-- Already-known NM -> handled by the NM tab, not the respawn list.
if config.nms[name] then return end
-- New mobdb-detected NM -> promote on the spot. More reliable than the
-- chat-article heuristic and works for the FIRST kill (no race with the
-- entity scanner). Chat handler stays as a backstop for kills mobdb missed.
local rec = mobdb_lookup(name)
if rec and rec.Notorious then
promote_to_nm(name)
return
end
local t = now()
local window = get_respawn_window(name)
table.insert(kills, {
name = name,
killed_at = t,
respawn_at = t + window,
zone = get_zone_id(),
x = x,
y = y,
z = z,
-- Engage-time mob position. Falls back to death position when no
-- engage snapshot was captured (mob was already claimed when we
-- first saw it, /dc test entries, etc.) so legacy arc/label code
-- never sees nil here.
spawn_x = spawn_x or x,
spawn_y = spawn_y or y,
spawn_z = spawn_z or z,
server_id = server_id,
})
-- PH detection: if this slot has previously hosted a known NM (and the
-- current kill is not that NM), call it out. Helps the player know
-- when a lottery candidate just dropped.
local ph_for = nil
if server_id and server_id ~= 0 then
local slot = config.slot_map and config.slot_map[tostring(server_id)]
if slot and slot.names then
local nm_names = {}
for n, _ in pairs(slot.names) do
if n ~= name and config.nms[n] then
table.insert(nm_names, n)
end
end
if #nm_names > 0 then
ph_for = table.concat(nm_names, ', ')
end
end
end
if ph_for then
say(('%s killed -- respawn in %s [PH for %s]'):format(name, fmt_eta(window), ph_for))
else
say(('%s killed -- respawn in %s'):format(name, fmt_eta(window)))
end
end
-- NM kill recorder. Bumps the counter, marks the name as a known NM so
-- subsequent kills bypass the respawn list entirely. Also retroactively
-- removes any stale `kills` entry the entity-scanner queued for this
-- mob in the last 10 seconds: chat lags entity-scan by ~0.5-1s, so the
-- very first kill of a brand-new NM gets recorded as a respawn before
-- chat fires. This cleanup turns that race into self-correction.
function record_nm_kill(name)
if not name or name == '' then return end
if is_ignored(name) then return end
local first_time = not config.nms[name]
config.nms[name] = true
local t_now = now()
local rec = config.nm_kills[name] or T{ count = 0, first = t_now, last = 0, last_zone = 0 }
rec.count = (rec.count or 0) + 1
rec.first = rec.first or t_now
rec.last = t_now
rec.last_zone = get_zone_id()
config.nm_kills[name] = rec
-- Sweep any respawn-table entry for this name from the last 10s.
-- Entity-scan races chat-parse on the first-ever NM kill.
local kept = T{}
for _, k in ipairs(kills) do
if not (k.name == name and (t_now - k.killed_at) <= 10) then
table.insert(kept, k)
end
end
kills = kept
save()
if first_time then
say(('%s flagged as NM (kill #%d). Now tracked in nms tab.'):format(name, rec.count))
else
say(('%s (NM) killed -- now at %d kill%s'):format(name, rec.count, rec.count == 1 and '' or 's'))
end
end
-- Set ToD for an existing NM. `when_spec` may be:
-- "now" -> current time
-- "<N>" -> N minutes ago (integer)
-- "HH:MM" -> today at that local time; if that's in the future,
-- interpret as yesterday at that time (last night).
-- Returns (ok, new_last_ts, msg).
local function set_nm_tod(name, when_spec)
if not name or name == '' then return false, 0, 'empty name' end
local rec = config.nm_kills[name]
if not rec then return false, 0, ('no NM record for "%s"'):format(name) end
when_spec = (when_spec or ''):gsub('^%s+',''):gsub('%s+$','')
local new_ts
if when_spec == '' or when_spec:lower() == 'now' then
new_ts = now()
else
local hh, mm = when_spec:match('^(%d?%d):(%d%d)$')
if hh then
local t = os.date('*t')
t.hour = tonumber(hh); t.min = tonumber(mm); t.sec = 0
new_ts = os.time(t)
if new_ts > now() then new_ts = new_ts - 86400 end -- assume yesterday
else
local n = tonumber(when_spec)
if not n or n < 0 then
return false, 0, ('bad time spec "%s" -- use now | <minutes> | HH:MM'):format(when_spec)
end
new_ts = now() - math.floor(n * 60)
end
end
rec.last = new_ts
rec.last_zone = (rec.last_zone and rec.last_zone > 0) and rec.last_zone or get_zone_id()
config.nm_kills[name] = rec
save()
return true, new_ts, nil
end
-- Manual promotion: flag as NM and sweep every kills-tab entry for this name
-- regardless of age. Used by the GUI "NM" button and `/dc nm add <name>`.
function promote_to_nm(name)
if not name or name == '' then return 0, false end
local t_now = now()
local rec = config.nm_kills[name] or T{ count = 0, first = t_now, last = 0, last_zone = 0 }
local was_new = not config.nms[name]
config.nms[name] = true
rec.count = (rec.count or 0) + 1
rec.first = rec.first or t_now
rec.last = t_now
rec.last_zone = get_zone_id()
config.nm_kills[name] = rec
local removed = 0
local kept = T{}
for _, k in ipairs(kills) do
if k.name == name then
removed = removed + 1
else
table.insert(kept, k)
end
end
kills = kept
save()
return removed, was_new
end
-- Match a kill message and return the bare mob name. NM kill messages in
-- FFXI omit the "The " article (e.g. "Spiny Spipi falls to the ground.")
-- while trash kill messages keep it ("The Spipi falls..."). This is the
-- canonical server-side NM signal that survives translation between message
-- variants. Returns nil if the line isn't a kill message OR the name has
-- a "The "/"the " prefix (= regular mob).
local function parse_nm_kill_message(text)
if not text or text == '' then return nil end
-- Strip Ashita's leading color/control bytes (typically 0x1E/0x1F prefix
-- with optional trailing bytes). Don't try to clean inline codes -- the
-- mob name itself can't contain control bytes, so a leading trim is
-- enough for "<name> falls to the ground".
local s = text:gsub('^[%z\1-\31]+', '')
-- "<Mob> falls to the ground." is the universal death notification in
-- FFXI -- fires once per kill regardless of who landed the killing
-- blow. NM names omit the "The " article ("Spiny Spipi falls...") while
-- trash keeps it ("The Spipi falls...") -- canonical NM signal.
local name = s:match('^(.-) falls to the ground')
if not name or name == '' then return nil end
name = name:gsub('^%s+', ''):gsub('%s+$', '')
if name == '' then return nil end
-- Reject articled names (trash mobs).
if name:sub(1, 4):lower() == 'the ' then return nil end
return name
end
-- Last claim_id we successfully matched against a party/alliance member.
-- Surfaced via /dc diag so when the filter eats a kill that should have
-- counted, we can compare the seen claim_id to what GetMemberServerId
-- returned (most common drift: 16- vs 32-bit comparison).
local last_seen_claim = 0
local last_seen_player_sid = 0
local last_filter_skip = nil
-- True when claim_id (low 16 bits of GetClaimStatus) belongs to me or to
-- anyone in my party/alliance. Loops 0..17 to cover all 3 alliance parties
-- (18 slots total, matching hpui's pattern). Both sides masked to 16 bits
-- because claim_id is the low 16 of the claimer's 32-bit server ID --
-- comparing without the mask never matches for entities whose high bits
-- are non-zero. Wrapped because the Ashita API can throw on torn reads
-- around zoning.
local function claim_is_mine(claim_id)
if not claim_id or claim_id == 0 then return false end
local target = bit.band(claim_id, 0xFFFF)
local ok, result = pcall(function()
local party = AshitaCore:GetMemoryManager():GetParty()
for i = 0, 17 do
if party:GetMemberIsActive(i) == 1 then
local sid = party:GetMemberServerId(i)
if sid and bit.band(sid, 0xFFFF) == target then
if i == 0 then last_seen_player_sid = sid end
return true
end
end
end
return false
end)
return ok and result or false
end
local function scan_for_deaths()
local entities = AshitaCore:GetMemoryManager():GetEntity()
for i = 0, 2303 do
local name = entities:GetName(i)
local flags = entities:GetSpawnFlags(i)
local hpp = entities:GetHPPercent(i)
local is_mob = (flags == 16)
if name and name ~= '' and is_mob then
local prev = last_hpp[i]
-- Sample claim each frame regardless of HP transition so we
-- always have a "previous frame" value ready when death lands.
local cur_claim
pcall(function()
local cs = entities:GetClaimStatus(i)
if cs then cur_claim = bit.band(cs, 0xFFFF) end
end)
-- Engage snapshot: first frame this entity's claim goes from
-- 0/nil to a real claimer (us or someone in alliance), grab
-- the mob's current position and stash it by server_id. This
-- is the closest proxy we have for the spawn point -- mobs
-- rarely wander far before being claimed, and once they're
-- claimed they may get kited a long way before they die.
local prev_claim = last_claim[i]
if (not prev_claim or prev_claim == 0)
and cur_claim and cur_claim ~= 0
and claim_is_mine(cur_claim) then
local esid, ex, ey, ez
pcall(function()
esid = entities:GetServerId(i)
ex = entities:GetLocalPositionX(i)
ey = entities:GetLocalPositionY(i)
ez = entities:GetLocalPositionZ(i)
end)
if esid and esid ~= 0 and ex and ey and ez then
engage_pos[esid] = { x = ex, y = ey, z = ez }
end
end
if prev and prev > 0 and hpp == 0 then
-- Credit check: prefer prev-frame claim (often cleared on
-- the death frame itself). Fall back to current claim if
-- prev wasn't sampled yet (first-seen-dying edge case).
local credit_claim = last_claim[i] or cur_claim or 0
last_seen_claim = credit_claim
local mine = claim_is_mine(credit_claim)
if (not config.only_my_kills) or mine then
local x, y, z, sid
pcall(function()
x = entities:GetLocalPositionX(i)
y = entities:GetLocalPositionY(i)
z = entities:GetLocalPositionZ(i)
sid = entities:GetServerId(i)
end)
local ep = sid and engage_pos[sid]
local sx, sy, sz
if ep then sx, sy, sz = ep.x, ep.y, ep.z end
record_kill(name, sid, x, y, z, sx, sy, sz)
-- Clear so the next pop in this slot starts fresh.
if sid then engage_pos[sid] = nil end
else
last_filter_skip = ('%s claim=0x%x'):format(name, credit_claim)
end
end
last_hpp[i] = hpp
last_claim[i] = cur_claim
else
last_hpp[i] = nil
last_claim[i] = nil
end
end
end
local function prune_kills()
local t = now()
local cutoff = config.keep_dead_after_respawn
local kept = T{}
for _, k in ipairs(kills) do
if (t - k.respawn_at) <= cutoff then
table.insert(kept, k)
end
end
kills = kept
end
local function build_respawn_rows()
local counts = {}
for _, k in ipairs(kills) do
counts[k.name] = (counts[k.name] or 0) + 1
end
local seen = {}
local rows = {}
for _, k in ipairs(kills) do
seen[k.name] = (seen[k.name] or 0) + 1
local label = k.name
if counts[k.name] > 1 then
label = ('%s #%d'):format(k.name, seen[k.name])
end
table.insert(rows, {
label = label, name = k.name, respawn_at = k.respawn_at, zone = k.zone,
x = k.x, y = k.y, z = k.z,
spawn_x = k.spawn_x, spawn_y = k.spawn_y, spawn_z = k.spawn_z,
server_id = k.server_id,
})
end
table.sort(rows, function(a, b) return a.respawn_at < b.respawn_at end)
return rows
end
-- Color band ordering, fresh kill -> ready. Used by color_for() and the
-- config UI. THRESHOLD_ORDER[i] is the lower-bound %-elapsed threshold
-- for the (i+1)-th band. The final band's threshold defaults to 100
-- (only at pop), but the eta<=0 short-circuit in color_for() ensures the
-- last band ALWAYS lights up at pop time, regardless of slider value.
local BAND_ORDER = { 'red', 'orange', 'yellow', 'green', 'blue', 'purple' }
local THRESHOLD_ORDER = { 'orange', 'yellow', 'green', 'blue', 'purple' }
-- Resolve the configured color_count into the actual list of band names
-- to use. count=1 -> {red} (single color, no ready distinction).
-- count>=2 -> first (count-1) timer bands + purple as the ready slot.
local function active_bands()
local n = math.max(1, math.min(6, config.color_count or 6))
if n == 1 then return { BAND_ORDER[1] } end
local b = {}
for i = 1, n - 1 do b[i] = BAND_ORDER[i] end
b[n] = 'purple'
return b
end
-- Evenly distribute the *intermediate* thresholds across [0,100] when
-- color_count changes (so going 6 -> 3 yields a 50/50 split instead of a
-- stale 20%). The final band's threshold ('purple', the ready slot) is
-- preserved across count changes -- users tune it deliberately for
-- early-warning timing, and we don't want to clobber that on a recount.
local function redistribute_thresholds(n)
if n < 3 then return end
local timer_bands = n - 1
local th = config.thresholds
for i = 1, n - 2 do
th[THRESHOLD_ORDER[i]] = math.floor(100 * i / timer_bands + 0.5)
end
end
-- Pick the band for a kill based on elapsed fraction of its respawn window.
-- count=1 short-circuits to the single slot. Otherwise eta<=0 forces the
-- last band (pop-time guarantee), then thresholds cascade top-down. The
-- last band's threshold can also fire pre-pop for early-warning colors.
local function color_for(eta, total)
local bands = active_bands()
local n = #bands
if n == 1 then return config.colors[bands[1]] end
if eta <= 0 then return config.colors[bands[n]] end
local pct_elapsed
if total and total > 0 then
pct_elapsed = math.max(0, math.min(100, (total - eta) / total * 100))
else
pct_elapsed = 0
end
local th = config.thresholds
for i = n, 2, -1 do
local key = THRESHOLD_ORDER[i - 1]
local floor_pct = th[key] or (key == 'purple' and 100 or 0)
if pct_elapsed >= floor_pct then return config.colors[bands[i]] end
end
return config.colors[bands[1]]
end
-- ImGui RGB floats → drawArc ARGB uint32. Alpha hardcoded to 0xC0 (~75%):
-- matches the original arc alpha and reads over terrain without dominating.
local function rgb_to_argb(c, alpha)
local a = alpha or 0xC0
local r = math.floor((c[1] or 0) * 255 + 0.5)
local g = math.floor((c[2] or 0) * 255 + 0.5)
local b = math.floor((c[3] or 0) * 255 + 0.5)
return a * 0x1000000 + r * 0x10000 + g * 0x100 + b
end
-- ImGui draw-list packs as IM_COL32 (ABGR little-endian): a<<24|b<<16|g<<8|r.
-- Different byte order from D3D ARGB; can't reuse rgb_to_argb. Default alpha
-- is full opacity so labels stay legible against terrain.
local function rgb_to_imu32(c, alpha)
local a = alpha or 0xFF
local r = math.floor((c[1] or 0) * 255 + 0.5)
local g = math.floor((c[2] or 0) * 255 + 0.5)
local b = math.floor((c[3] or 0) * 255 + 0.5)
return a * 0x1000000 + b * 0x10000 + g * 0x100 + r
end
-- Bars use ImGui PushStyleColor which wants {r,g,b,a}. Wrap the RGB triple
-- with a full-opacity alpha so we don't mutate the stored table.
local function bar_rgba(c)
return { c[1], c[2], c[3], 1.0 }
end
----------------------------------------------------------------
-- config tab: tracking + arcs toggles, default respawn editor,
-- per-mob overrides, colors & thresholds, arc visibility.
----------------------------------------------------------------
local function draw_config_tab()
-- Default respawn: input + mm:ss readout + inline reset on one line.
local dr = { config.default_respawn or 349 }
imgui.PushItemWidth(90)
if imgui.InputInt('default respawn (s)', dr, 1, 30) then
if dr[1] < 1 then dr[1] = 1 end
if dr[1] > 86400 then dr[1] = 86400 end
config.default_respawn = dr[1]
save()
end
imgui.PopItemWidth()
imgui.SameLine()
imgui.TextDisabled('(' .. fmt_eta(config.default_respawn or 349) .. ')')
imgui.SameLine()
if imgui.SmallButton('reset 5m49s') then
config.default_respawn = 349
save()
end
imgui.Separator()
-- Window background opacity. 1.0 = fully opaque (original behavior),
-- 0.0 = transparent background (text still draws). Useful for keeping
-- the kill list visible without obscuring the world behind it.
local ba = { config.window.bg_alpha or 1.0 }
imgui.PushItemWidth(120)
if imgui.SliderFloat('bg opacity', ba, 0.0, 1.0, '%.2f') then
config.window.bg_alpha = ba[1]
save()
end
imgui.PopItemWidth()
-- mobdb integration toggle. Off = chat-article heuristic only.
-- On (default) = consult mobdb for Notorious flag and auto-divert
-- NM kills to the NMs tab on the first kill (no scanner race).
-- NOTE: we do NOT use mobdb's Respawn -- the default 349s is measured
-- on HorizonXI and beats mobdb's retail-era values.
local um = { config.use_mobdb }
if imgui.Checkbox('use mobdb (Notorious)', um) then
config.use_mobdb = um[1]
if not config.use_mobdb then mobdb_zone_cache = {} end
save()
end
if imgui.IsItemHovered() then
imgui.SetTooltip('Reads addons/mobdb/data/<zone>.lua at runtime.\nNo-op if mobdb is not installed.')
end
imgui.Separator()
-- Tracking + arcs checkboxes share a row to save vertical space.
local tr = { config.track_respawns }
if imgui.Checkbox('tracking', tr) then
config.track_respawns = tr[1]; save()
end
imgui.SameLine()
local omk = { config.only_my_kills }
if imgui.Checkbox('only my kills', omk) then
config.only_my_kills = omk[1]; save()
end
if imgui.IsItemHovered() then
imgui.SetTooltip('Only track mobs claimed by you or your party/alliance at\ntime of death. Skips random mobs killed by others nearby.')
end
if drawArc then
imgui.SameLine()
local rl = { config.respawn_lines }
if imgui.Checkbox('return arcs', rl) then
config.respawn_lines = rl[1]; save()
end
if tl_helpers and d3d8dev and d3dC then
imgui.SameLine()
local al = { config.arc_labels }
if imgui.Checkbox('labels', al) then
config.arc_labels = al[1]; save()
end
if config.arc_labels then
imgui.SameLine()
local sc = { config.arc_label_scale or 1.0 }
imgui.PushItemWidth(110)
if imgui.SliderFloat('##label_scale', sc, 0.5, 3.0, 'label x%.2f') then
if sc[1] < 0.5 then sc[1] = 0.5 end
if sc[1] > 3.0 then sc[1] = 3.0 end
config.arc_label_scale = sc[1]; save()
end
imgui.PopItemWidth()
end
end
-- "always" toggle bypasses the threshold below. When checked, the
-- slider/secs row is replaced by a short status line so the config
-- tab stays compact.
local ao = { config.arc_always_on }
if imgui.Checkbox('always show arcs', ao) then
config.arc_always_on = ao[1]; save()
end
if config.arc_always_on then
imgui.SameLine()
imgui.TextDisabled('(threshold disabled)')
else
-- Arc visibility threshold. Slider + paired seconds InputInt
-- (against default respawn) so users can reason in either unit.
local total = math.max(1, config.default_respawn or 349)
local pct = math.max(0, math.min(100, config.arc_show_above_elapsed_pct or 0))
local v = { pct }
imgui.PushItemWidth(110)
if imgui.SliderInt('##arc_pct', v, 0, 100, 'arc: %d%% elapsed') then
config.arc_show_above_elapsed_pct = v[1]; save()
end
imgui.PopItemWidth()
imgui.SameLine()
local remaining = math.floor(total * (1 - pct / 100) + 0.5)
local rv = { remaining }
imgui.PushItemWidth(55)
if imgui.InputInt('##arc_secs', rv, 0, 0) then
if rv[1] < 0 then rv[1] = 0 end
if rv[1] > total then rv[1] = total end
config.arc_show_above_elapsed_pct = math.floor((1 - rv[1] / total) * 100 + 0.5)
save()
end
imgui.PopItemWidth()
imgui.SameLine()
if pct >= 100 then
imgui.TextDisabled('s (only at pop)')
elseif pct <= 0 then
imgui.TextDisabled('s (always on)')
else
imgui.TextDisabled(('s (%s left)'):format(fmt_eta(remaining)))
end
end
end
-- Keep-pop-visible: how long to keep popped rows in the list.
do
local kd = math.max(0, config.keep_dead_after_respawn or 30)
local kv = { kd }
imgui.PushItemWidth(60)
-- step=0 step_fast=0 hides the +/- buttons; with them on at width 55
-- the buttons ate the digit field and the number was clipped.
if imgui.InputInt('keep pop visible (s)', kv, 0, 0) then
if kv[1] < 0 then kv[1] = 0 end
config.keep_dead_after_respawn = kv[1]; save()
end
imgui.PopItemWidth()
imgui.SameLine()
if kd <= 0 then
imgui.TextDisabled('(drops at pop)')
else
imgui.TextDisabled(('(%s)'):format(fmt_eta(kd)))
end
end
imgui.Separator()
-- Per-mob overrides. List sorted alphabetically; each row has a delete
-- button + inline-editable seconds + mm:ss preview. Use /dc add to
-- create new entries (cmd line handles quoted names cleanly).
if imgui.CollapsingHeader('per-mob overrides') then
local names = {}
for n, _ in pairs(config.overrides or {}) do table.insert(names, n) end
table.sort(names)
if #names == 0 then
imgui.TextDisabled('none. add via: /dc add "Mob Name" <secs>')
else
for _, name in ipairs(names) do
imgui.PushID('ov_' .. name)
if imgui.SmallButton('x') then
config.overrides[name] = nil
save()
imgui.PopID()