Add subname-based NPC classification for minimap tracking

Read creature subname/title from the creature cache (obj+0xB30 → entry+0x10)
to distinguish NPC subtypes sharing the same flag. SetObjectTypeBlip now
accepts include and exclude subname filters (args 4 and 5).

Class Trainer filters by player class name (e.g. "Druid"), Profession
Trainer excludes the player class. Entries with include filters take
priority over exclude-only, which take priority over unfiltered catch-all.
Consolidate vendor subcategories into single Vendor entry.
This commit is contained in:
MarcelineVQ
2026-03-07 21:23:34 -08:00
parent 89b6082d73
commit 1b9396f8dc
2 changed files with 150 additions and 37 deletions
+16 -24
View File
@@ -23,17 +23,20 @@ local SPELL_CATEGORIES = {
-- NPC tracking categories (icons embedded from Wrath client)
-- trackingType maps to DLL SetObjectTypeBlip() type names.
-- Categories sharing a trackingType share the same NPC flag filter:
-- "trainer" = Class Trainer + Profession Trainer (UNIT_NPC_FLAG_TRAINER)
-- subnameFilter: substring match on NPC subname/title (e.g. "Druid" matches "Druid Trainer").
-- Filtered entries take priority over unfiltered catch-all entries with the same flag.
-- "dynamic" filter is resolved at runtime via getFilter().
local NPC_CATEGORIES = {
{ name = "Auctioneer", trackingType = "auctioneer", icon = "Interface\\Minimap\\Tracking\\Auctioneer" },
{ name = "Banker", trackingType = "banker", icon = "Interface\\Minimap\\Tracking\\Banker" },
{ name = "Battle Master", trackingType = "battlemaster", icon = "Interface\\Minimap\\Tracking\\BattleMaster" },
{ name = "Class Trainer", trackingType = "trainer", icon = "Interface\\Minimap\\Tracking\\Class" },
{ name = "Class Trainer", trackingType = "trainer", icon = "Interface\\Minimap\\Tracking\\Class",
getFilter = function() return (UnitClass("player")) end },
{ name = "Flight Master", trackingType = "flightmaster", icon = "Interface\\Minimap\\Tracking\\FlightMaster" },
{ name = "Innkeeper", trackingType = "innkeeper", icon = "Interface\\Minimap\\Tracking\\Innkeeper" },
{ name = "Mailbox", trackingType = "mailbox", icon = "Interface\\Minimap\\Tracking\\Mailbox" },
{ name = "Profession Trainer", trackingType = "trainer", icon = "Interface\\Minimap\\Tracking\\Profession" },
{ name = "Profession Trainer", trackingType = "trainer", icon = "Interface\\Minimap\\Tracking\\Profession",
getExclude = function() return UnitClass("player") end },
{ name = "Repair", trackingType = "repair", icon = "Interface\\Minimap\\Tracking\\Repair" },
{ name = "Stable Master", trackingType = "stablemaster", icon = "Interface\\Minimap\\Tracking\\StableMaster" },
{ name = "Vendor", trackingType = "vendor", icon = "Interface\\Minimap\\Tracking\\Food" },
@@ -46,29 +49,18 @@ local NPC_CATEGORIES = {
local activeNpcCategories = {} -- name -> 1/nil
-- Sync DLL tracking state with addon toggle state.
-- For shared trackingTypes (e.g. "trainer" used by both Class Trainer and
-- Profession Trainer), enables if ANY category with that type is active,
-- using the first active category's icon.
-- Each category gets its own DLL call. Categories with a subnameFilter
-- (e.g. Class Trainer filtering by player class) create filtered entries
-- that take priority over unfiltered catch-all entries.
local function updateDllTracking()
if not SetObjectTypeBlip then return end
local seen = {}
for _, cat in ipairs(NPC_CATEGORIES) do
local tt = cat.trackingType
if not seen[tt] then
seen[tt] = true
-- Find first active category with this tracking type
local icon = nil
for _, c in ipairs(NPC_CATEGORIES) do
if c.trackingType == tt and activeNpcCategories[c.name] then
icon = c.icon
break
end
end
if icon then
SetObjectTypeBlip(tt, icon, 1.3)
else
SetObjectTypeBlip(tt)
end
local inc = cat.getFilter and cat.getFilter() or nil
local exc = cat.getExclude and cat.getExclude() or nil
if activeNpcCategories[cat.name] then
SetObjectTypeBlip(cat.trackingType, cat.icon, 1.5, inc, exc)
else
SetObjectTypeBlip(cat.trackingType, nil, nil, inc, exc)
end
end
end
+134 -13
View File
@@ -59,6 +59,10 @@ const ADDR = struct {
const OBJ_DATA: usize = 0x08; // m_data — update fields descriptor (starts at field 0)
const OBJ_TYPE: usize = 0x14; // m_objectType
const OBJ_WORLD_DATA: usize = 0xE0; // m_worldData (CWorld*)
const OBJ_CREATURE_CACHE: usize = 0xB30; // ptr to creature cache entry
// Creature cache entry offsets (name[0..3] at +0x00..+0x0C, subname at +0x10)
const CACHE_SUBNAME: usize = 0x10; // char* subname/title (e.g. "Druid Trainer")
// Descriptor field byte offsets (absolute_field_index * 4 from m_data)
const DESC_NPC_FLAGS: usize = 0x93 * 4; // UNIT_NPC_FLAGS = OBJECT_END + 0x8D
@@ -114,10 +118,41 @@ const TrackedBlip = struct {
gray: bool,
};
const FILTER_MAX = 32;
const FlagEntry = struct {
flag: u32 = 0,
blip: Blip = .{ .texture = 0, .scale = 1.0 },
active: bool = false,
filter: [FILTER_MAX]u8 = .{0} ** FILTER_MAX, // include: subname must contain
filter_len: u8 = 0,
exclude: [FILTER_MAX]u8 = .{0} ** FILTER_MAX, // exclude: subname must NOT contain
exclude_len: u8 = 0,
fn hasFilter(self: *const FlagEntry) bool {
return self.filter_len > 0 or self.exclude_len > 0;
}
fn matchesSubName(self: *const FlagEntry, subname: [*:0]const u8) bool {
if (self.filter_len > 0 and !containsInsensitive(subname, self.filter[0..self.filter_len]))
return false;
if (self.exclude_len > 0 and containsInsensitive(subname, self.exclude[0..self.exclude_len]))
return false;
return true;
}
fn filtersEqual(self: *const FlagEntry, inc: []const u8, exc: []const u8) bool {
return sliceEqual(self.filter[0..self.filter_len], inc) and
sliceEqual(self.exclude[0..self.exclude_len], exc);
}
fn sliceEqual(a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (a, b) |x, y| {
if (x != y) return false;
}
return true;
}
};
const GoTypeEntry = struct {
@@ -227,6 +262,38 @@ fn getGoType(obj: u32) u32 {
return hook.readMem(u32, desc + ADDR.DESC_GO_TYPE);
}
fn getCreatureSubName(obj: u32) ?[*:0]const u8 {
const cache = hook.readMem(u32, obj + ADDR.OBJ_CREATURE_CACHE);
if (!isValidPtr(cache)) return null;
const subname_ptr = hook.readMem(u32, cache + ADDR.CACHE_SUBNAME);
if (!isValidPtr(subname_ptr)) return null;
const subname: [*:0]const u8 = @ptrFromInt(subname_ptr);
if (subname[0] == 0) return null;
return subname;
}
fn containsInsensitive(haystack: [*:0]const u8, needle: []const u8) bool {
if (needle.len == 0) return true;
var i: u32 = 0;
while (haystack[i] != 0) : (i += 1) {
var match = true;
for (needle, 0..) |nc, j| {
var hc = haystack[i + @as(u32, @intCast(j))];
if (hc == 0) return false;
if (hc >= 'A' and hc <= 'Z') hc += 32;
var nlc = nc;
if (nlc >= 'A' and nlc <= 'Z') nlc += 32;
if (hc != nlc) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
fn getObjectPosition(obj: u32) ?C3Vector {
const vtable = hook.readMem(u32, obj + ADDR.OBJ_VTABLE);
if (!isValidPtr(vtable)) return null;
@@ -508,18 +575,39 @@ fn checkObject(info: u32, guid_lo: u32, guid_hi: u32) bool {
const npc_flags = getNpcFlags(obj);
if (npc_flags == 0) return false;
// Find highest-priority matching flag (higher flag value = higher priority)
// Match entries by NPC flag + subname filter. Include filters take
// priority over exclude-only filters, which take priority over unfiltered.
// Within each tier, higher flag value = higher priority.
const subname = getCreatureSubName(obj);
var best_priority: u8 = 0; // 0=none, 1=unfiltered, 2=exclude-only, 3=include
var best_flag: u32 = 0;
var best_blip: Blip = .{ .texture = 0, .scale = 1.0 };
for (&g_flag_tracking) |*entry| {
if (!entry.active) continue;
if (npc_flags & entry.flag != 0) {
if (entry.flag > best_flag) {
best_flag = entry.flag;
best_blip = entry.blip;
}
if (npc_flags & entry.flag == 0) continue;
const priority: u8 = if (entry.filter_len > 0)
3
else if (entry.exclude_len > 0)
2
else
1;
if (priority < best_priority) continue;
if (priority == best_priority and entry.flag <= best_flag) continue;
// Check subname match
if (entry.hasFilter()) {
if (subname) |sn| {
if (!entry.matchesSubName(sn)) continue;
} else continue; // no subname available, can't match filters
}
best_priority = priority;
best_flag = entry.flag;
best_blip = entry.blip;
}
if (best_flag != 0) {
@@ -643,10 +731,10 @@ fn enumVisibleObjectsDetour(callback: u32, context: u32) callconv(fc) i32 {
// Lua API
// =============================================================================
// SetObjectTypeBlip(typeName [, texturePath [, scale]])
// SetObjectTypeBlip(typeName [, texturePath [, scale [, includeFilter [, excludeFilter]]]])
pub fn luaSetObjectTypeBlip(L: lua.State) callconv(fc) i32 {
if (!lua.isstring(L, 1)) {
lua.luaError(L, "Usage: SetObjectTypeBlip(type [, texture [, scale]])");
lua.luaError(L, "Usage: SetObjectTypeBlip(type [, texture [, scale [, include [, exclude]]]])");
return 0;
}
@@ -657,8 +745,28 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(fc) i32 {
return 0;
};
// Parse optional include filter (arg 4) and exclude filter (arg 5)
var inc_buf: [FILTER_MAX]u8 = .{0} ** FILTER_MAX;
var inc_len: u8 = 0;
var exc_buf: [FILTER_MAX]u8 = .{0} ** FILTER_MAX;
var exc_len: u8 = 0;
if (lua.isstring(L, 4)) {
if (lua.tostring(L, 4)) |f| {
while (inc_len < FILTER_MAX and f[inc_len] != 0) : (inc_len += 1) {
inc_buf[inc_len] = f[inc_len];
}
}
}
if (lua.isstring(L, 5)) {
if (lua.tostring(L, 5)) |f| {
while (exc_len < FILTER_MAX and f[exc_len] != 0) : (exc_len += 1) {
exc_buf[exc_len] = f[exc_len];
}
}
}
if (!lua.isstring(L, 2)) {
// Disable tracking for this type
// Disable tracking for this type + filter combo
if (mapping.is_go_type) {
for (&g_go_tracking) |*entry| {
if (entry.active and entry.go_type == mapping.value) {
@@ -668,7 +776,9 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(fc) i32 {
}
} else {
for (&g_flag_tracking) |*entry| {
if (entry.active and entry.flag == mapping.value) {
if (entry.active and entry.flag == mapping.value and
entry.filtersEqual(inc_buf[0..inc_len], exc_buf[0..exc_len]))
{
entry.active = false;
break;
}
@@ -693,7 +803,6 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(fc) i32 {
const blip = Blip{ .texture = texture, .scale = scale };
if (mapping.is_go_type) {
// Update existing or add new
for (&g_go_tracking) |*entry| {
if (entry.active and entry.go_type == mapping.value) {
entry.blip = blip;
@@ -707,15 +816,27 @@ pub fn luaSetObjectTypeBlip(L: lua.State) callconv(fc) i32 {
}
}
} else {
// Match by flag + include/exclude combo
for (&g_flag_tracking) |*entry| {
if (entry.active and entry.flag == mapping.value) {
if (entry.active and entry.flag == mapping.value and
entry.filtersEqual(inc_buf[0..inc_len], exc_buf[0..exc_len]))
{
entry.blip = blip;
return 0;
}
}
// Add new entry
for (&g_flag_tracking) |*entry| {
if (!entry.active) {
entry.* = .{ .flag = mapping.value, .blip = blip, .active = true };
entry.* = .{
.flag = mapping.value,
.blip = blip,
.active = true,
.filter = inc_buf,
.filter_len = inc_len,
.exclude = exc_buf,
.exclude_len = exc_len,
};
return 0;
}
}