diff --git a/DLL_README.md b/DLL_README.md index 7bc1200..ad81148 100644 --- a/DLL_README.md +++ b/DLL_README.md @@ -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` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 23fa44f..ceb14e5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/src/minimapicons/addon/MinimapIcons.lua b/src/minimapicons/addon/MinimapIcons.lua index e7bbb85..2b49a3d 100644 --- a/src/minimapicons/addon/MinimapIcons.lua +++ b/src/minimapicons/addon/MinimapIcons.lua @@ -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", diff --git a/src/minimapicons/minimapicons.zig b/src/minimapicons/minimapicons.zig index 38faa06..d226eec 100644 --- a/src/minimapicons/minimapicons.zig +++ b/src/minimapicons/minimapicons.zig @@ -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();