-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathquestmarks.lua
More file actions
2176 lines (1956 loc) · 100 KB
/
Copy pathquestmarks.lua
File metadata and controls
2176 lines (1956 loc) · 100 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
--[[
questmarks -- WoW-style quest markers over FFXI NPCs.
Reads quest and mission state from packet 0x056, joins it against BG-Wiki-derived
quest-giver metadata, evaluates prerequisites, and draws a marker above the NPC
in the 3D world.
Colour carries the state and the glyph says what the NPC is for. The state
colours, as render/markers.lua defines them:
yellow available now, or ready to hand in (quest)
green the same, for a mission
red the same, for Campaign
grey a prerequisite is definitely unmet, or accepted and in progress
black something could not be verified, usually fame
blue repeatable, and already done
`!` and `?` are the default glyphs; an accepted step swaps in the glyph for
what it asks of you (talk, trade, examine, fight, obtain).
See README.md for the full table, including the per-category variants and the
glyph vocabulary, and NOTICE for data attribution.
MUST NOT: require anything outside this addon or Windower's own stock libraries
(config, packets, logger, bit) -- tools/test_standalone.lua enforces it. Must
not write a global; tools/check.lua enforces that.
]]
_addon.name = 'questmarks'
_addon.author = 'DrNefarius'
--[[ Bump this whenever anything shipped changes. It is the only thing that
tells two log files apart, and questmarks.log APPENDS across sessions --
so during a multi-day test a single file holds several builds, and a line
without a version is a line you cannot attribute. ]]
_addon.version = '1.0.0'
_addon.commands = {'questmarks', 'qm'}
require('logger')
local config = require('config')
local packets = require('packets')
-- Slash form is what works under Windower's Lua. Do not mix with dot form:
-- Lua would treat them as separate modules and load each one twice, giving
-- two independent copies of the state tables.
local state = require('core/state')
local quests = require('core/quests')
local prereq = require('core/prereq')
local fame = require('core/fame')
local inventory = require('core/inventory')
local keyitems = require('core/keyitems')
local kills = require('core/kills')
local pskills = require('core/skills')
local steps = require('core/steps')
local notify = require('core/notify')
local markers = require('render/markers')
local project = require('render/project')
-- ---------------------------------------------------------------------------
-- Settings
-- ---------------------------------------------------------------------------
local defaults = {
enabled = true,
max_markers = 16,
icon_size = 60, -- px at ref_distance; tuned in game
--[[ Scale markers by distance so they read as objects in the world rather
than as a flat HUD overlay. icon_size is then the size at
ref_distance yalms. See markers.size_at(). ]]
perspective = true,
ref_distance = 8,
px_min = 10,
-- 3x icon_size, so the 1/depth curve still works at conversation range;
-- at 96 it would have clamped from 5 yalms inward.
px_max = 180,
max_distance = 50,
floor_tol = 6,
-- 2.3 measured in game as the value that sits correctly across races
head_offset = 2.3,
size_scale = 1.0, -- 0 disables per-NPC (race-based) height scaling
--[[ Real latency, measured at one frame of camera movement while panning.
Measured under prerender; the `hook` note below says how to re-measure
it under postrender, which is now the default. ]]
lag = 1.0,
smooth = 0.3, -- low-pass on that correction; tuned in game
--[[ Which hook draws the markers: 'prerender' or 'postrender'.
postrender, because prerender was measurably the wrong sampling point.
Measured 2026-08-02 against the game's own nameplate: at rest the
marker sat +0.3 px off, so the projection maths is exact; while panning
it trailed by exactly one frame of camera movement, predicted vs
measured within 0.8 px over six frames.
postrender also removes the shimmer on long steady pans, and that is
the tell: steady velocity is the easy case for the estimator, so the
noise is not the estimator's fault. A prerender read lands either side
of the client's own camera update, giving the odd double-movement or
zero-movement frame. The filters (corroboration, time normalisation,
min-magnitude, asymmetric decay) still help at a deceleration but are
not load-bearing.
To re-check whether `lag` still earns its keep under postrender, set
`//qm lag 0` and pan hard. Glued to the nameplate means the sampling
point was the whole problem and the compensation is pure cost; still
trailing, then catching up at the stop, means the prim overlay is
itself a frame behind and lag has a job. Overshoot after a sudden stop
stays either way, because extrapolation assumes the camera keeps
moving.
Kept as a runtime switch so the A/B is one command: it has to be the
same pan twice, and a reload loses marker state. ]]
hook = 'postrender',
scan_interval = 0.5,
-- Only mark NPCs the client has actually spawned; see scan().
visible_only = true,
--[[ Repeatables split by category (300 repeatable quests vs 11 repeatable
missions) so either can be silenced without losing the other.
Named `repeat_quest` rather than `repeat` deliberately: an existing
settings.xml pins whatever it already holds, so reusing the old key
would leave anyone who ever toggled it stuck on their old value. A
NEW key picks up this default. `//qm show repeat` still works as an
alias. See the note on defaults above. ]]
show = {
turnin = true, progress = true, ready = true,
unknown = true, blocked = true,
repeat_quest = true, repeat_mission = true,
},
-- announce newly-actionable quests in chat
notify = true,
notify_detail = 6, -- name them individually up to this many
--[[ Hide markers while a menu or dialogue box is open. See menu_is_open()
-- it refuses to act on the flag until it has seen it read false once,
so an unexpected meaning leaves the addon exactly as it was rather than
blanking it. A NEW key, so the default reaches existing users. ]]
hide_on_menu = true,
--[[ Follow a quest along its steps instead of pinning the marker to the
giver. A NEW key on purpose: once a key exists in settings.xml the
saved value wins forever, so reusing an old one would leave anyone who
had ever toggled it stuck on it. New keys take the default.
//qm steps off reproduces the pre-steps behaviour exactly, so the
feature is A/B-testable in game against the same data file. ]]
steps = true,
--[[ Fame is PER CHARACTER, so it is keyed by character name rather than
relying on the config library's own per-character merge. Explicit
keying is easier to reason about and survives however config chooses
to section the XML. ]]
fame_by_char = {},
}
local settings = config.load(defaults)
-- ---------------------------------------------------------------------------
-- Runtime
-- ---------------------------------------------------------------------------
local candidates = {} -- {index, name, x, y, z, state}
local scan_filtered -- what the last scan discarded, for //qm diag
local wanted = nil -- npc key set for the current zone
local cur_zone = nil
local last_scan = 0
local dirty_state = true -- marker states need recomputing
--[[ An item packet arrived. Deliberately NOT `dirty_state` directly: see the
0x01F/0x020 handler. The scan tick converts it, which coalesces a gear
swap's worth of packets into one re-evaluation. ]]
local inv_dirty = false
local rebuild_pending = false
local index_ok = false
--[[ `frames` and `blank` are COUNTS, not timings, and both are reported by
`//qm diag` and `//qm perf`. `blank` is a frame on which markers.render()
returned nil -- the camera refused, so every marker was hidden. Counted on
this side of the call as well as inside project, because "how many frames
did the player see nothing on" is the question a bug report actually asks,
and a per-frame timing next to a zero drawn count cannot answer it. ]]
local perf = {scan = 0, eval = 0, render = 0, frames = 0, blank = 0}
local MODE_MESSAGE = 150 -- chat mode for NPC dialogue text
--[[ Output helpers.
Everything diagnostic goes to BOTH the chat log and questmarks.log in the
addon directory. In-game chat cannot be copied out, so without a file
there is no way to report what the addon actually saw -- which makes any
bug report a game of telephone. `//qm diag` dumps the whole picture in one
go for exactly that reason. ]]
local LOGFILE = 'questmarks.log'
local function fmt(f, ...)
if select('#', ...) == 0 then return tostring(f) end
local ok, s = pcall(string.format, f, ...)
return ok and s or tostring(f)
end
local function msg(f, ...)
local s = fmt(f, ...)
windower.add_to_chat(207, 'questmarks: ' .. s)
flog(LOGFILE, s)
end
-- Indented detail line, for multi-line command output.
local function line(f, ...)
local s = fmt(f, ...)
windower.add_to_chat(207, ' ' .. s)
flog(LOGFILE, ' ' .. s)
end
local function err(f, ...)
local s = fmt(f, ...)
windower.add_to_chat(167, 'questmarks: ' .. s)
flog(LOGFILE, 'ERROR: ' .. s)
end
local function log_only(f, ...)
flog(LOGFILE, fmt(f, ...))
end
--[[ Declared HERE, far above menu_is_open() which is the only thing that
reasons about it, because the `login` handler resets it and is written
earlier in the file. A `local` below a function that assigns the same name
does not scope it -- the assignment silently becomes a global while every
reader keeps using the local, so the reset compiles, runs, and does
nothing. No test catches this: the file parses either way. ]]
local menu_seen_closed = false
--[[ Render circuit breaker: three consecutive throws out of markers.render
stop the renderer. Drawing nothing beats drawing somewhere wrong.
Do NOT stop it by setting `settings.enabled = false`. That key is persisted
to data/settings.xml by thirteen `settings:save()` calls, so three unlucky
frames would disable every marker on disk with no way back: render_frame()
returns on its first line when `enabled` is false, so the
`render_errors = 0` reset at the bottom is unreachable. `render_halted` is
the latch instead: runtime only, never saved, cleared by `//qm on` /
`//qm toggle` and by `login`, reported by `//qm diag`.
Declared up here beside menu_seen_closed because `login` assigns both and
is written earlier. A `local` below a function that assigns the same name
does not scope it; the assignment silently becomes a global. ]]
local render_errors = 0
local render_halted = false
-- ---------------------------------------------------------------------------
-- res/ name lookups -- DISPLAY ONLY
-- ---------------------------------------------------------------------------
--[[ Turn an id into the name a human recognises: zones, and the environment
fields `//qm diag` reports.
Nothing in the marker path depends on any of this. The index carries ids
because ids are what packets and the entity list speak; a name is only
ever needed to print a line -- "step 3/6, Etteh Sulaej (F-4)" is unreadable
without the city.
Read straight out of Windower's own res/*.lua rather than through
`require('resources')`: that library pulls in functions, tables, sets,
strings and bit, and monkeypatches the base string and table metatables,
which is a large blast radius for a lookup table. loadfile on a plain data
file touches nothing.
Every res table shipped with Windower has the same shape -- `[id] = {id=,
en=, ...}` -- verified for zones, days, weather and moon_phases in this
install, so one reader covers all four.
Lazy, cached, and pcall'd end to end: a missing or changed res file
degrades to printing the raw id, never to a failed command. res/ is
Windower's own directory and not another addon, so this does not weaken
the standalone guarantee NOTICE makes. ]]
local res_cache = {}
local function res_name(file, id)
if type(id) ~= 'number' then return nil end
local t = res_cache[file]
if t == nil then
t = {}
res_cache[file] = t
pcall(function()
local chunk = loadfile(windower.windower_path .. 'res/' .. file .. '.lua')
if not chunk then return end
local raw = chunk()
if type(raw) ~= 'table' then return end
for k, v in pairs(raw) do
local n = type(v) == 'table' and v.en or v
if type(k) == 'number' and type(n) == 'string' then t[k] = n end
end
end)
end
return t[id]
end
local function zone_name(id) return res_name('zones', id) end
--[[ `id (Name)`, or just the value when it is not an id we can resolve. Used
for the environment line, where the RAW value matters as much as the name:
the types get_info() returns for day / weather / moon_phase are documented
nowhere and no addon in this install reads them, so the point of printing
them is to find out. ]]
local function res_label(file, v)
local n = res_name(file, v)
if n then return ('%s (%s)'):format(tostring(v), n) end
return ('%s [%s]'):format(tostring(v), type(v))
end
-- ---------------------------------------------------------------------------
-- Fame persistence (per character)
-- ---------------------------------------------------------------------------
--[[ Push settings into the renderer.
MUST be re-run on login, not only at addon load. Windower's config library
chooses the per-character section using windower.ffxi.get_player(), which
is not available while the addon loads at the character-select screen --
so at load time `settings` holds the GLOBAL values, and any per-character
override silently does not apply. That is true of every per-character
setting, not just one. ]]
local function apply_settings()
markers.config{
max_markers = settings.max_markers,
icon_size = settings.icon_size,
max_distance = settings.max_distance,
floor_tol = settings.floor_tol,
head_offset = settings.head_offset,
size_scale = settings.size_scale,
perspective = settings.perspective,
ref_distance = settings.ref_distance,
px_min = settings.px_min,
px_max = settings.px_max,
}
project.set_lag(settings.lag)
project.set_smooth(settings.smooth)
--[[ Per-character, like everything else here: apply_settings runs at load,
at login and at a mid-session reload, because the config library cannot
pick the right section until a player exists. ]]
steps.enable(settings.steps ~= false)
end
local function fame_key()
local ok, p = pcall(windower.ffxi.get_player)
return (ok and p and p.name or 'unknown'):lower()
end
--[[ Push the player's main job and level into prereq, which must stay free of
any client call so it can be tested offline.
Both stay nil until a player exists (the addon usually loads at character
select), and nil means UNKNOWN, not "fails the gate" -- otherwise every
job-locked quest would grey out on sight. Refreshed on the same events
that already refresh state, since changing job is a job-change packet away
and 88 quests in the index care. ]]
--[[ ...and `skills`.
get_player() returns about 30 fields. Do not assume the two read here are
all it offers: one quest in the corpus is flagged
`craft_rank_gate_unmodelled` for want of a craft rank.
The shape of `skills` is documented nowhere and no addon in this install
reads it, so core/skills.lua IDENTIFIES it against packet 0x062 rather than
assuming one -- and an unidentified shape simply leaves the packet
authoritative. Handing it over costs one table walk on events that were
already happening. ]]
local function refresh_player()
local ok, p = pcall(windower.ffxi.get_player)
if not ok or not p then
prereq.set_player(nil, nil)
pcall(pskills.observe, nil)
return
end
local job = p.main_job
prereq.set_player(job, p.main_job_level)
pcall(pskills.observe, p.skills)
end
--[[ Persist immediately, never only on unload.
Two reasons. config.save() silently does nothing while logged out
(config.lua:330), so an unload during logout would drop the data. And a
crash or a Windower restart never fires unload at all. Fame is expensive
to re-acquire -- it means physically running to Norg or Rabao -- so it is
written the moment it is learned. ]]
local function save_fame()
settings.fame_by_char = settings.fame_by_char or {}
settings.fame_by_char[fame_key()] = fame.export()
local ok, e = pcall(function() settings:save() end)
if not ok then log_only('fame save failed: %s', tostring(e)) end
end
local function load_fame()
fame.reset()
local t = (settings.fame_by_char or {})[fame_key()]
if t then fame.import(t) end
local n = 0
for _ in pairs((t or {}).learned or {}) do n = n + 1 end
for _ in pairs((t or {}).manual or {}) do n = n + 1 end
return n
end
-- ---------------------------------------------------------------------------
-- Candidate scanning
-- ---------------------------------------------------------------------------
--[[ Resolve which nearby entities are quest givers.
Uses get_mob_list() (index -> name, whole zone) to find candidate indices,
then get_mob_by_index() only for those. The probe measured 480 entries in
get_mob_list against 54 in get_mob_array, so this is both cheaper AND sees
more of the zone. Entities the client has not spawned simply have no
position, which doubles as free render-gating. ]]
-- `dead` starts at 0 rather than springing into existence on the first corpse,
-- so //qm diag can print it unconditionally. See the corpse filter below.
scan_filtered = {hidden = 0, duplicate = 0, dead = 0, ghosts = {}}
local function scan()
local t0 = os.clock()
candidates = {}
scan_filtered = {hidden = 0, duplicate = 0, dead = 0, ghosts = {}}
if not wanted then return end
local ok, list = pcall(windower.ffxi.get_mob_list)
if not ok or type(list) ~= 'table' then return end
local me = windower.ffxi.get_mob_by_target('me')
--[[ FFXI keeps spare entity slots for conditionally-visible NPCs (cutscene
copies, quest-state variants, seasonal versions), so one zone can hold
several entities with the same name while only one is rendered.
Marking them all puts markers over empty air. Two filters, in order:
1. visible_only (default on). `valid_target` tracks whether the client
has the entity spawned: false at range, true as you approach. So
markers fade in and out with their NPCs, and lone ghosts go with
them. The cost is deliberate: nothing is marked before the client
spawns it, even inside `//qm dist`. `//qm visible off` marks every
entity in range instead.
2. Dedup by name: one per name, targetable first, then nearest. You
can only talk to one copy, so marking more is always wrong.
No documented "is rendered" flag exists, and `status`/`spawn_type`
measured identical (0 and 2) for real NPCs and ghosts alike.
`//qm dump <npc>` prints every field of every copy. ]]
local best = {}
for idx, name in pairs(list) do
local want = type(name) == 'string' and wanted[quests.fold(name)]
if want then
local ok2, m = pcall(windower.ffxi.get_mob_by_index, idx)
if ok2 and m and m.x then
local targetable = m.valid_target and true or false
--[[ MONSTERS are marker targets too, and they need the opposite
of the NPC rules.
Duplicate NPCs are ghost entity slots -- spare copies the
client keeps for conditionally-visible NPCs -- so only the
nearest may be marked. Duplicate MONSTERS are all real and
all valid: thirty Topaz Quadav is thirty things you can
legitimately kill for the drop, and collapsing them to one
would hide the other twenty-nine.
A corpse is not a target though. res/statuses.lua: 2 = Dead,
3 = Engaged dead. ]]
local is_mob = (want == 'mob')
local dead = is_mob and (m.status == 2 or m.status == 3)
if dead then
scan_filtered.dead = (scan_filtered.dead or 0) + 1
elseif settings.visible_only and not targetable then
scan_filtered.hidden = scan_filtered.hidden + 1
else
local key = quests.fold(name)
local d2 = math.huge
if me and me.x then
local dx, dy, dz = m.x - me.x, m.y - me.y, m.z - me.z
d2 = dx*dx + dy*dy + dz*dz
end
local cand = {index = idx, name = name, x = m.x, y = m.y,
z = m.z, d2 = d2, targetable = targetable,
-- drives per-NPC marker height
model_size = m.model_size, race = m.race}
--[[ A monster gets its OWN slot per entity index, so all
of them survive; an NPC key collapses to the nearest
copy as before. ]]
if is_mob then key = key .. '#' .. tostring(idx) end
cand.mob = is_mob or nil
local prev = best[key]
if not prev then
best[key] = cand
else
scan_filtered.duplicate = scan_filtered.duplicate + 1
local better = (cand.targetable ~= prev.targetable)
and cand.targetable
or (cand.targetable == prev.targetable and cand.d2 < prev.d2)
if #scan_filtered.ghosts < 12 then
local drop = better and prev or cand
scan_filtered.ghosts[#scan_filtered.ghosts + 1] =
('%s: kept idx=%d, dropped idx=%d (targetable=%s d=%.1f)')
:format(name, better and cand.index or prev.index,
drop.index, tostring(drop.targetable),
math.sqrt(drop.d2))
end
if better then best[key] = cand end
end
end
end
end
end
--[[ Truncate by distance, not by hash order.
An unordered `pairs()` walk with a hard break at 64 lets the hash
decide which 64 of an over-full set survive, so two scans from the same
spot can keep different markers -- in the one place that decides what
the player sees.
The cap is reachable: 72 monster names are zone-agnostic, so every zone
can reach it, and a monster takes one slot per live entity. A field
full of `carrion crow` fills it single-handed.
Sorting by distance first keeps the 64 nearest, which is deterministic
and also the right 64; the marker budget in render/markers.lua settles
it the same way. The tie-break is spelled out because table.sort is not
stable and the input order here is arbitrary.
This still does not say whether a live zone ever holds more than 64
spawned wanted entities. It only makes the answer repeatable. ]]
local n = 0
for _, c in pairs(best) do
n = n + 1
candidates[n] = c
end
table.sort(candidates, function(a, b)
if a.d2 ~= b.d2 then return a.d2 < b.d2 end
if a.name ~= b.name then return a.name < b.name end
return (a.index or 0) < (b.index or 0)
end)
for i = n, 65, -1 do candidates[i] = nil end
dirty_state = true
perf.scan = (os.clock() - t0) * 1000
end
--[[ Should this marker state be drawn for this category?
Only `repeat` splits by category -- see the note on defaults.show. ]]
local function shown(st, cat)
if st == 'repeat' then
local key = (cat == 'mission') and 'repeat_mission' or 'repeat_quest'
return settings.show[key] ~= false
end
return settings.show[st] and true or false
end
--[[ Recompute marker states. Event-driven in the sense that matters: every
caller sets `dirty_state` and render_frame consumes it once per frame, so
a frame on which nothing changed does none of this work. It is still the
frame loop that calls it, so keep it cheap. ]]
local function evaluate()
local t0 = os.clock()
inventory.refresh()
for i = 1, #candidates do
local c = candidates[i]
local st, count, _, cat, area, hint = quests.marker_for(c.name, cur_zone)
if st and shown(st, cat) then
c.state, c.count, c.cat, c.area, c.hint = st, count, cat, area, hint
else
c.state, c.count, c.cat, c.area, c.hint = nil, 0, nil, nil, nil
end
end
dirty_state = false
perf.eval = (os.clock() - t0) * 1000
end
--[[ Announce that something became actionable, anywhere in the world -- as ONE
line. Silent on the first snapshot after login; see core/notify.
Do not print the whole batch here, grouped by zone or otherwise. The list
grows without bound: every fame level and every completed quest can unlock
content across the whole game, and dumping all of it floods the chat window
at exactly the moment you are reading something else. Grouping by zone also
has to cope with entries whose zone does not resolve.
The batch is still computed on every settle, so `//qm new` lists it on
demand. This line exists only so you know there is something to list. ]]
local function report_new()
if not settings.notify or not index_ok then return end
local ok, gained = pcall(notify.update, quests)
if not ok or #gained == 0 then return end
msg('%d newly available -- //qm new to list', #gained)
end
-- Debounced: the 0x056 burst arrives as ~27 packets on every zone-in.
local function schedule_rebuild()
if rebuild_pending then return end
rebuild_pending = true
coroutine.schedule(function()
rebuild_pending = false
--[[ Protected as a whole. Unprotected, one bad entry takes the entire
callback down: a raw Lua error in chat, and everything below it --
including the zone-start announcement -- silently skipped.
err() also writes to questmarks.log, so a failure here can be read
back instead of being chased in a chat window that scrolls. ]]
local ok, e = pcall(function()
refresh_player()
quests.rebuild_fame_floors()
--[[ A quest that stopped being in progress has no step position;
keeping its high-water mark would resurrect a stale one if it
were ever repeated. Walks the latch table, not the index. ]]
steps.on_state_change()
--[[ Which monsters are worth counting changed with the quest log.
Recomputed here rather than per packet: 0x028 is a firehose and
core/kills refuses to even parse one while this set is empty. ]]
kills.set_watch(quests.watched_mobs())
dirty_state = true
scan()
report_new()
--[[ Some quests are started by entering a zone, with no NPC to mark.
This is the only way the addon can surface them at all. ]]
if settings.notify and cur_zone then
local zs = quests.zone_starts(cur_zone)
if zs then
--[[ Call a mission a mission. 47 of the 49 zone-triggered
entries in the index are missions, so "quest(s)" for
everything is wrong for nearly all of them.
The distinction is not pedantry here. Missions are the
storyline, they are why the renderer colours them
differently, and a player told a mission is a side
quest may leave it for later. ]]
local nq, nm = 0, 0
for _, z in ipairs(zs) do
if z.entry.cat == 'mission' then nm = nm + 1
else nq = nq + 1 end
end
local what
if nq > 0 and nm > 0 then
what = ('%d quest(s) and %d mission(s)'):format(nq, nm)
elseif nm > 0 then
what = ('%d mission(s)'):format(nm)
else
what = ('%d quest(s)'):format(nq)
end
msg('entering this zone starts %s:', what)
for _, z in ipairs(zs) do
line('%s%s', z.name or '?',
z.entry.cat == 'mission' and ' [mission]' or '')
end
end
end
end)
if not ok then err('state update failed: %s', tostring(e)) end
end, 2)
end
-- ---------------------------------------------------------------------------
-- Events
-- ---------------------------------------------------------------------------
windower.register_event('incoming chunk', function(id, original)
if id == 0x056 then
local ok, p = pcall(packets.parse, 'incoming', original)
if ok and p then
if state.handle_packet(p) then schedule_rebuild() end
end
elseif id == 0x055 then
--[[ 0x055 is a KEY ITEM update and says nothing about your bags. Do not
use it as a bare "re-sweep everything" ping: it carries the answer
itself, in two 512-bit fields, one for held and one for examined.
See core/keyitems.lua, which is deliberately information-only until
its id stride has been checked against get_key_items(). ]]
local ok, p = pcall(packets.parse, 'incoming', original)
if ok and p then pcall(keyitems.handle_packet, p) end
inventory.invalidate_key_items()
dirty_state = true
elseif id == 0x028 and kills.watching() then
--[[ Combat. The ONLY reason to look at it: "defeat 5 Nasu" is a step
the player cannot check their own progress on, and no packet
carries a per-quest kill counter. Counted session-locally and shown
only -- see core/kills.lua, which is emphatic that this never
advances a step.
`kills.watching()` is in the CONDITION, not inside the handler.
This packet arrives for every action of every fight in the zone,
and parse_action on all of it would be the most expensive thing
the addon does -- so with no monster-step quest accepted, which is
the normal case, the packet is not even parsed.
`dirty_state` is deliberately NOT set either: a marker must not
re-evaluate on every hit in a fight. ]]
local ok, a = pcall(windower.packets.parse_action, original)
if ok and a then pcall(kills.handle_action, a) end
elseif id == 0x01F or id == 0x020 then
--[[ 'Item Assign' and 'Item Updates' (fields.lua:1683 and :1692). Both
carry Item, Bag, Index and a `Status` byte -- the same itemstat
enum whose 0x05 means Equipped -- so this is how the client says
an item appeared, disappeared, or was put on.
This is the only event that fires when you equip a weapon or pick
up a turn-in item. 0x055, 0x056, zone change, login and fame do
not, so without it a marker believes the last sweep until you zone
and an equipment-dependent entry reads grey with no way to tell why.
Debounced via the scan tick rather than setting dirty_state here.
0x020 is not rare -- a full gear swap is a burst of them, and a
synthesis or a treasure pool can produce a sustained stream -- and
each one forces the next sweep past its rate limit. Coalescing to
the scan interval bounds that to twice a second whatever the packet
rate. ]]
inventory.invalidate()
inv_dirty = true
elseif id == 0x02D then
--[[ The one exact count the game hands over -- message 558 with its own
numerator and denominator. Parsed rather than pattern-matched out
of chat text. ]]
local ok, p = pcall(packets.parse, 'incoming', original)
if ok and p then pcall(kills.handle_message, p) end
elseif id == 0x062 then
--[[ 'Skills Update' -- 48 combat entries at 0x80 and the ten Synthesis
ones at 0xE0, each a 16-bit word. This is where a craft RANK comes
from, and `Indomitable Spirit` needs one: the quest does not exist
in your log until you buy a key item that requires Fishing rank
Adept.
Handed the RAW chunk, not a parsed table. `packets.parse` never
applies a field's `fn` (paid for once already, in
core/keyitems.lua), and this packet is described with `ref=` /
`count=` structures whose handling would have to be trusted. The
byte layout is unambiguous, so core/skills.lua reads it directly
and depends on none of that.
`dirty_state` only on a real change: 0x062 arrives on every skill
tick while crafting or fighting, and re-evaluating the whole index
on each one would be the 0x028 firehose again. ]]
local ok, changed = pcall(pskills.handle_chunk, original)
if ok and changed then
--[[ A skill-up may have just identified the shape of
get_player().skills, so re-offer it. Cheap, and it means the
experiment is not confined to the login path. ]]
refresh_player()
dirty_state = true
end
elseif id == 0x113 then
--[[ 'Currency Info' -- conquest points, seals, and NINE guild-point
counters at 0x20 in res/skills.lua id order. The other half of
`Indomitable Spirit`: 95,000 Guild Points AND Fishing rank Adept.
Guild points are easy to miss: a grep for them turns up only guild
SHOP packets, so they look unobservable. They are at
fields.lua:3855. ]]
local ok, changed = pcall(pskills.handle_currency, original)
if ok and changed then dirty_state = true end
end
end)
--[[ Fame learning. The client has already rendered the text (DAT lookup, name
and gender substitution done), so this is the real string the player sees. ]]
windower.register_event('incoming text', function(original, modified, mode)
if mode ~= MODE_MESSAGE then return end
local ok, decoded = pcall(windower.from_shift_jis, original)
if not ok then return end
local info = windower.ffxi.get_info()
local region, level, changed = fame.observe(decoded, info and info.zone)
if region and changed then
msg('learned %s fame level %d (saved)', region, level)
save_fame()
dirty_state = true
-- A fame level can unlock quests all over the world.
coroutine.schedule(report_new, 1)
end
end)
windower.register_event('zone change', function(new_id)
cur_zone = new_id
candidates = {}
wanted = index_ok and quests.wanted_names(new_id) or nil
markers.hide_all()
markers.reset_vertical_sign()
inventory.invalidate()
dirty_state = true
last_scan = 0
end)
windower.register_event('login', function()
state.reset()
notify.reset()
inventory.reset()
-- The bitfields describe one character's key items.
keyitems.reset()
-- ...and the kill tally describes one character's session.
kills.reset()
--[[ ...and skills describe ONE character. Carrying a Fishing rank across a
character switch would grey or clear a craft gate on somebody else's
numbers, and the identified shape of get_player().skills is an
inference, which by this project's central rule may not outlive a
logout. Both start empty; the packet re-arrives at login. ]]
pskills.reset()
-- The step high-water mark describes ONE character's session.
steps.reset()
markers.reset_vertical_sign()
--[[ The menu flag is only trusted once it has been seen false, and login
is where that evidence should start over: the character-select screen
is exactly the state in which a flag could look stuck. ]]
menu_seen_closed = false
--[[ ...and so is the render circuit breaker. It latches a runtime fault,
not a preference, so a new session must not inherit it. The latch is
runtime only, so there is nothing on disk to undo. ]]
render_halted, render_errors = false, 0
--[[ Only now does the config library know which character it is, so this
is the first moment per-character settings are actually correct.
Re-apply them; see apply_settings(). ]]
apply_settings()
if markers.settings().max_markers ~= nil then
markers.init(settings.max_markers)
end
local n = load_fame()
if n > 0 then msg('restored %d saved fame reading(s)', n) end
-- Job and level gate 88 quests; until this runs they read UNKNOWN, not
-- blocked, so the worst case before it is a black "!" rather than a hidden
-- marker.
refresh_player()
dirty_state = true
end)
windower.register_event('logout', function()
state.reset()
notify.reset() -- next login must not announce the whole game
steps.reset() -- ... and must not inherit the last one's progress
--[[ Reset inferences at BOTH ends of a session.
Between logout and the next character finishing login, the kill tally,
the bag cache, the key-item bitfields and the identified craft ranks
would otherwise still be answering about somebody else. The client is
not obliged to deliver both events (a crash or an alt-F4 delivers
neither), so whichever one arrives has to leave nothing inferred
behind it.
`fame` is deliberately absent, and a blanket "reset everything" would
break it. Fame readings are observations: saved per character the
moment they are learned, reloaded by `login`, and expensive to
re-acquire, since it means physically running to Norg or Rabao. Worse,
save_fame() in the unload handler writes the in-memory table straight
back to disk, so resetting fame here would erase the saved readings
too. A relog keeps what was measured and forgets what was guessed. ]]
inventory.reset()
keyitems.reset()
kills.reset()
pskills.reset()
--[[ The menu flag is trusted only once it has been observed FALSE; the next
character must earn that again rather than inherit it, for the same
reason `login` clears it. ]]
menu_seen_closed = false
candidates = {}
markers.hide_all()
end)
for _, ev in ipairs({'add item', 'remove item'}) do
windower.register_event(ev, function()
inventory.invalidate()
dirty_state = true
end)
end
--[[ Changing job re-decides 88 job-locked quests -- the whole
`Borghertz's ... Hands` set sits on one NPC, one per job. ]]
windower.register_event('job change', function()
refresh_player()
dirty_state = true
end)
--[[ Suppress markers while a menu or dialogue box covers the world; markers
over an open dialogue box are the most visible way a 3D overlay breaks.
`get_info().menu_open` exists here: the contiguous key block in
plugins/LuaCore.dll gives all thirteen keys (day, moon, moon_phase, time,
zone, logged_in, server, weather, mog_house, language, menu_open,
chat_open, target_arrow). Its type and meaning are documented nowhere and
no addon here reads it, so two guards:
1. `if info.menu_open then` is wrong on its face. In Lua 0 is truthy, so a
count of open menus would hide every marker forever. Handle each
plausible shape; an unknown one never suppresses.
2. Don't trust the flag until it has read false once since login. If it
really means "the menu bar exists", the addon would draw nothing with
no clue why. A real flag earns trust on the first frame you walk
around; a constant never does, so the feature stays inert instead.
`//qm probe` prints the live value and its type. `menu_seen_closed` is
declared near the top of the file, beside the output helpers. ]]
local function menu_is_open(info)
local v = info and info.menu_open
local open
if v == nil or v == false then open = false
elseif v == true then open = true
elseif type(v) == 'number' then open = (v ~= 0)
elseif type(v) == 'string' then open = (v ~= '' and v ~= '0')
else return false end -- unknown shape: never suppress
if not open then
menu_seen_closed = true
return false
end
return menu_seen_closed
end
local function render_frame()
-- `render_halted` is the circuit breaker, `settings.enabled` is the player;
-- deliberately two flags, because only one of them is persisted.
if not settings.enabled or render_halted or not index_ok then return end
--[[ Sampled per frame rather than per scan tick: a dialogue box opens
between ticks, and half a second of markers floating over it is the
exact artefact this removes. One extra get_info() per frame beside the
get_mob_by_target('me') that already runs there. ]]
local info = windower.ffxi.get_info()
if settings.hide_on_menu and menu_is_open(info) then
markers.hide_all()
return
end
local now = os.clock()
if now - last_scan > settings.scan_interval then
last_scan = now
-- Item traffic since the last tick, collapsed into one re-evaluation.
if inv_dirty then
inv_dirty = false
dirty_state = true
end
if info then
-- A Mog House is not its own zone; the client reports the
-- surrounding city and flags it here.
quests.set_mog_house(info.mog_house)
if info.zone ~= cur_zone then
cur_zone = info.zone
wanted = quests.wanted_names(cur_zone)
end
end
scan()
end
if dirty_state then evaluate() end
local me = windower.ffxi.get_mob_by_target('me')
if not me or not me.x then markers.hide_all() return end
local drawable, n = {}, 0
for i = 1, #candidates do
local c = candidates[i]
if c.state then n = n + 1; drawable[n] = c end
end
if n == 0 then markers.hide_all() return end
markers.ensure_vertical_sign(drawable[1].x, drawable[1].y, drawable[1].z)
local t0 = os.clock()
local ok, res = pcall(markers.render, drawable, me)
perf.render = (os.clock() - t0) * 1000
perf.frames = perf.frames + 1
if not ok then
render_errors = render_errors + 1
if render_errors >= 3 then
--[[ HALT, do not disable. `settings.enabled` is the player's own
switch and is written to disk; this fault is neither theirs nor
durable. Zero the counter on the way in so that whatever
resumes -- //qm on, or the next login -- starts from a clean
slate instead of one bad frame away from halting again. ]]
render_halted = true
render_errors = 0
markers.hide_all()
err('render failed 3x, markers HALTED. Last error: %s', tostring(res))
err('nothing was saved -- //qm on resumes, //qm diag reports it.')
end
else
--[[ One good frame stops an intermittent throw accumulating toward the
breaker. This line is only reachable because the halt is a separate
flag from `settings.enabled`; halting via `enabled` would make it
dead code and the latch would never clear. ]]
render_errors = 0
--[[ A nil result is NOT a failure: markers.render returns nil when
project.begin() refused the camera, having already hidden
everything. It is not an error to count against the breaker -- a
cutscene would trip it -- but it is a frame the player saw no
markers on, so it is counted rather than discarded. ]]
if res == nil then perf.blank = perf.blank + 1 end
end
end
--[[ Both hooks are registered; `settings.hook` decides which one draws.
Gating here rather than registering and unregistering means the switch is
a single flag with no lifecycle to get wrong, and the inactive hook costs