minimapicons: add quest available tracking to minimap

Reads quest giver status from unit+0xCB8 (populated by client's
SMSG_QUESTGIVER_STATUS packet handler) to show yellow ! icons on the
minimap for NPCs with available quests. Uses the vanilla gossip frame
AvailableQuestIcon texture. Enabled by default, togglable from the
tracking dropdown like other NPC categories.
This commit is contained in:
MarcelineVQ
2026-03-14 23:18:17 -07:00
parent 3c9a95d51e
commit 944e398e41
4 changed files with 77 additions and 5 deletions
+3 -2
View File
@@ -117,8 +117,9 @@ Can be disabled easily from normal AddOn menu.
- Check/uncheck NPC categories to toggle their minimap icons
- Spell tracking (Hunter tracking, Find Herbs, etc.) remains available alongside NPC tracking
Supports many NPC types such as Auctioneer, Banker, Flightmaster, Repair, Reagents, Poisons, and more
Supported game objects: Mailbox, Brainwasher
Supports many NPC types such as Auctioneer, Banker, Flightmaster, Repair, Reagents, Poisons, and more
Supported game objects: Mailbox, Brainwasher
Quest tracking: shows nearby NPCs with available quests (yellow !) on the minimap
**DLL:** `minimapicons.dll`
+5
View File
@@ -27,6 +27,11 @@ Track notable changes here between releases. Clear this file when cutting a new
- **Addon Profiling Stub (addonperf)** - TBC+ addon CPU profiling API stub
(GetScriptCPUUsage). Internal/experimental, not in DLL_README.
- **Minimap Quest Tracking** - shows nearby NPCs with available quests (yellow !) on the
minimap. Reads the per-player quest status cached on unit objects by the client's
SMSG_QUESTGIVER_STATUS handler. Enabled by default in the tracking dropdown, togglable
like other NPC tracking categories.
## Enhancements
- **Clickthrough**: Lootable corpses now click-through over players. Previously only
+1
View File
@@ -93,6 +93,7 @@ local NPC_CATEGORIES = {
{ name = "Brainwasher", trackingType = "brainwasher", icon = "Interface\\Minimap\\Tracking\\Brainwasher", scale = 1.8, default = 1 },
{ name = "Innkeeper", trackingType = "innkeeper", icon = "Interface\\Minimap\\Tracking\\Innkeeper", default = 1 },
{ name = "Flight Master", trackingType = "flightmaster", icon = "Interface\\Minimap\\Tracking\\FlightMaster" },
{ name = "Quest Available", trackingType = "quest_available", icon = "Interface\\GossipFrame\\AvailableQuestIcon", default = 1 },
{ name = "Reagent Vendor", trackingType = "vendor", icon = "Interface\\Minimap\\Tracking\\Reagents",
getFilter = function() return getLocaleFilter(REAGENT_FILTERS) end },
{ name = "Poison Vendor", trackingType = "vendor", icon = "Interface\\Minimap\\Tracking\\Poison",
+68 -3
View File
@@ -39,6 +39,7 @@ const ADDR = struct {
const EnumVisibleObjects: usize = 0x468380;
// Called functions
const CheckQuestAvailability: usize = 0x5ED210;
const WorldPosToMinimapCoords: usize = 0x4EAA30;
const TextureCreate: usize = 0x449D90;
const TextureGetGxTex: usize = 0x44ACF0;
@@ -59,6 +60,7 @@ const ADDR = struct {
// Object struct offsets (module-specific)
const OBJ_VTABLE: usize = 0x00;
const OBJ_CREATURE_CACHE: usize = 0xB30;
const OBJ_QUEST_STATUS: usize = 0xCB8;
// Creature cache entry offsets
const CACHE_SUBNAME: usize = 0x10;
@@ -87,6 +89,9 @@ const NPC_FLAG_AUCTIONEER: u32 = 0x00001000;
const NPC_FLAG_STABLEMASTER: u32 = 0x00002000;
const NPC_FLAG_REPAIR: u32 = 0x00004000;
// Quest giver status values (cached at unit + 0xCB8 by SMSG_QUESTGIVER_STATUS handler)
const QUEST_STATUS_AVAILABLE: u32 = 5; // yellow ! — quest ready to accept
const GO_TYPE_MAILBOX: u32 = 19;
const OBJ_TYPE_UNIT: u32 = 3;
@@ -209,6 +214,12 @@ const GoEntryEntry = struct {
active: bool = false,
};
const QuestStatusEntry = struct {
status: u32 = 0, // matches one QUEST_STATUS_* value
blip: Blip = .{ .texture = 0, .scale = 1.0 },
active: bool = false,
};
const TextureEntry = struct {
path_hash: u32 = 0,
handle: u32 = 0,
@@ -246,12 +257,14 @@ const CStatus = extern struct {
const MAX_FLAG_ENTRIES = 16;
const MAX_GO_ENTRIES = 4;
const MAX_GO_ID_ENTRIES = 4;
const MAX_QUEST_ENTRIES = 4;
const MAX_BLIPS = 128;
const MAX_TEXTURES = 16;
var g_flag_tracking: [MAX_FLAG_ENTRIES]FlagEntry = .{FlagEntry{}} ** MAX_FLAG_ENTRIES;
var g_go_tracking: [MAX_GO_ENTRIES]GoTypeEntry = .{GoTypeEntry{}} ** MAX_GO_ENTRIES;
var g_go_id_tracking: [MAX_GO_ID_ENTRIES]GoEntryEntry = .{GoEntryEntry{}} ** MAX_GO_ID_ENTRIES;
var g_quest_tracking: [MAX_QUEST_ENTRIES]QuestStatusEntry = .{QuestStatusEntry{}} ** MAX_QUEST_ENTRIES;
var g_blips: [MAX_BLIPS]TrackedBlip = undefined;
var g_blip_count: u32 = 0;
var g_has_active_tracking: bool = false;
@@ -270,6 +283,7 @@ const CITY_ZONES = [_]u32{
const ZONE_AREA_ID: usize = offsets.ZONE_AREA_ID;
var g_has_active_unit_tracking: bool = false;
var g_has_active_go_tracking: bool = false;
var g_has_active_quest_tracking: bool = false;
var g_has_any_filters: bool = false;
var g_active_flag_mask: u32 = 0; // OR of all active flag entry flags
var g_tex_cache: [MAX_TEXTURES]TextureEntry = .{TextureEntry{}} ** MAX_TEXTURES;
@@ -366,6 +380,10 @@ fn getCreatureSubName(obj: u32) ?[*:0]const u8 {
const getObjectEntry = wow.getObjectEntry;
fn getQuestStatus(obj: u32) u32 {
return hook.readMem(u32, obj + ADDR.OBJ_QUEST_STATUS);
}
// Creature entry IDs that should be treated as reagent vendors despite having no subname.
const REAGENT_VENDOR_ENTRIES = [_]u32{
50041, // Field Repair Bot 75B
@@ -672,6 +690,24 @@ fn drawMinimapBlip(pos: C2Vector, scale: f32) void {
// =============================================================================
fn checkObject(info: u32, guid_lo: u32, guid_hi: u32) bool {
// Quest status check runs BEFORE GUID cache — status is dynamic (changes as
// player accepts/completes quests) and server already computes it per-player.
if (g_has_active_quest_tracking) {
const obj = getObjectByGUID(guid_lo, guid_hi);
if (obj != 0 and isValidPtr(obj) and getObjectType(obj) == OBJ_TYPE_UNIT) {
const quest_status = getQuestStatus(obj);
if (quest_status != 0) {
for (&g_quest_tracking) |*entry| {
if (!entry.active) continue;
if (entry.status == quest_status) {
trackObject(info, guid_lo, guid_hi, entry.blip);
return true;
}
}
}
}
}
// GUID cache stores type classification (NPC flags/subname/GO type -> blip).
// Dynamic checks (faction, interactability) run every frame, even on cache hits,
// since these can change as players join/leave groups.
@@ -850,9 +886,14 @@ fn refreshActiveTrackingCache() void {
for (&g_go_id_tracking) |*entry| {
if (entry.active) has_go = true;
}
var has_quest = false;
for (&g_quest_tracking) |*entry| {
if (entry.active) has_quest = true;
}
g_has_active_unit_tracking = has_unit;
g_has_active_go_tracking = has_go;
g_has_active_tracking = has_unit or has_go;
g_has_active_quest_tracking = has_quest;
g_has_active_tracking = has_unit or has_go or has_quest;
g_has_any_filters = has_filters;
g_active_flag_mask = flag_mask;
@@ -984,7 +1025,7 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(hook.cc.fastcall) i32 {
const type_name = lua.tostring(L, 1) orelse return 0;
const mapping = findTypeMapping(type_name) orelse {
lua.luaError(L, "Unknown type. Use: auctioneer, banker, battlemaster, brainwasher, flightmaster, innkeeper, oranges, repair, stablemaster, trainer, vendor, mailbox");
lua.luaError(L, "Unknown type. Use: auctioneer, banker, battlemaster, brainwasher, flightmaster, innkeeper, oranges, repair, stablemaster, trainer, vendor, mailbox, quest_available");
return 0;
};
@@ -1021,6 +1062,14 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(hook.cc.fastcall) i32 {
}
}
},
.quest_status => {
for (&g_quest_tracking) |*entry| {
if (entry.active and entry.status == mapping.value) {
entry.active = false;
break;
}
}
},
}
return 0;
}
@@ -1090,6 +1139,20 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(hook.cc.fastcall) i32 {
}
}
},
.quest_status => {
for (&g_quest_tracking) |*entry| {
if (entry.active and entry.status == mapping.value) {
entry.blip = blip;
return 0;
}
}
for (&g_quest_tracking) |*entry| {
if (!entry.active) {
entry.* = .{ .status = mapping.value, .blip = blip, .active = true };
return 0;
}
}
},
}
return 0;
@@ -1102,7 +1165,7 @@ pub fn luaSetCityToggle(L: lua.State) callconv(hook.cc.fastcall) i32 {
}
const TypeMapping = struct {
kind: enum { npc_flag, go_type, go_entry } = .npc_flag,
kind: enum { npc_flag, go_type, go_entry, quest_status } = .npc_flag,
value: u32,
};
@@ -1121,6 +1184,7 @@ fn findTypeMapping(name: [*:0]const u8) ?TypeMapping {
.{ .n = "mailbox", .m = .{ .kind = .go_type, .value = GO_TYPE_MAILBOX } },
.{ .n = "oranges", .m = .{ .kind = .go_entry, .value = 1000084 } }, // Refreshment Table
.{ .n = "brainwasher", .m = .{ .kind = .go_entry, .value = 1000333 } }, // Goblin Brainwashing Device
.{ .n = "quest_available", .m = .{ .kind = .quest_status, .value = QUEST_STATUS_AVAILABLE } },
};
for (&table) |*entry| {
@@ -1186,6 +1250,7 @@ pub fn removeHooks() void {
for (&g_flag_tracking) |*entry| entry.active = false;
for (&g_go_tracking) |*entry| entry.active = false;
for (&g_go_id_tracking) |*entry| entry.active = false;
for (&g_quest_tracking) |*entry| entry.active = false;
g_blip_count = 0;
log.close();