Files
WeirdUtils/src/minimapicons/minimapicons.zig
T

1260 lines
44 KiB
Zig

//! Minimap NPC tracking icons module.
//!
//! Adds NPC type tracking to the minimap (flight masters, innkeepers, mailboxes, etc.)
//! by hooking the minimap's object enumeration and blip rendering pipeline.
//!
//! Uses the minimap's own ObjectEnumProc callback rather than the rendering pipeline's
//! CGObjectIsDisabled/CGUnitShouldRender hooks (used by clickthrough/perfboost) because
//! we need access to the minimap info struct for blip positioning and must be in the
//! minimap drawing pipeline to render custom blip textures via RenderObjectBlips.
//!
//! Hooks:
//! ObjectEnumProc (0x4EAA90) — intercepts per-object minimap callback, checks NPC flags
//! RenderObjectBlips (0x4EBC00) — draws custom blip textures after default blips
//! ClntObjMgrEnumVisibleObjects (0x468380) — clears tracked data at start of each cycle
//!
//! Lua API:
//! SetObjectTypeBlip(typeName [, texturePath [, scale]]) — enable/disable NPC tracking
//!
//! Based on VanillaHelpers (reference/VanillaHelpers/src/Blips.cpp).
const std = @import("std");
const hook = @import("zhook");
const lua = @import("../lua.zig");
const logging = @import("../logging.zig");
const mod_mutex = @import("../mutex.zig");
const offsets = @import("../offsets.zig");
const wow = @import("../wow.zig");
pub const module_name: [*:0]const u8 = "minimapicons";
// =============================================================================
// WoW 1.12.1 addresses (module-specific)
// =============================================================================
const ADDR = struct {
// Hooked functions
const ObjectEnumProc: usize = 0x4EAA90;
const RenderObjectBlips: usize = 0x4EBC00;
const EnumVisibleObjects: usize = 0x468380;
// Called functions
const WorldPosToMinimapCoords: usize = 0x4EAA30;
const TextureCreate: usize = 0x449D90;
const TextureGetGxTex: usize = 0x44ACF0;
const GxRsSet: usize = 0x589E80;
const GxPrimLockVertexPtrs: usize = 0x58A2A0;
const GxPrimDrawElements: usize = 0x58A2E0;
const GxPrimUnlockVertexPtrs: usize = 0x58A340;
const CGxTexFlagsInit: usize = 0x58A980;
const CStatusDestructor: usize = 0x419E30;
// Static data
const BlipVertices: usize = 0xBC8230;
const BlipNormal: usize = 0xBC829C;
const BlipTexCoords: usize = 0xBC77F0;
const BlipVertIndices: usize = 0x807A2C;
const CStatusVftable: usize = 0x7FFA10;
// 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;
// MINIMAPINFO struct offsets
const MI_POS: usize = 0x0C;
const MI_RADIUS: usize = 0x18;
const MI_LAYOUT_SCALE: usize = 0x1C;
const MI_FRAME: usize = 0x20;
// CGMinimapFrame
const FRAME_SCRIPT_PART: usize = 0x24;
// Vtable indices
const VT_GET_POSITION: usize = 5 * 4;
};
// NPC flags
const NPC_FLAG_VENDOR: u32 = 0x00000004;
const NPC_FLAG_FLIGHTMASTER: u32 = 0x00000008;
const NPC_FLAG_TRAINER: u32 = 0x00000010;
const NPC_FLAG_INNKEEPER: u32 = 0x00000080;
const NPC_FLAG_BANKER: u32 = 0x00000100;
const NPC_FLAG_BATTLEMASTER: u32 = 0x00000800;
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;
const OBJ_TYPE_GAMEOBJECT: u32 = 5;
// =============================================================================
// Types
// =============================================================================
const C2Vector = extern struct { x: f32, y: f32 };
const C3Vector = extern struct { x: f32, y: f32, z: f32 };
const CImVector = extern struct { b: u8, g: u8, r: u8, a: u8 };
const Blip = struct {
texture: u32, // HTEXTURE__*
scale: f32,
};
const TrackedBlip = struct {
pos: C2Vector,
blip: Blip,
};
const allocator = std.heap.page_allocator;
const FlagEntry = struct {
flag: u32 = 0,
blip: Blip = .{ .texture = 0, .scale = 1.0 },
active: bool = false,
filter: ?[]u8 = null, // include: subname must contain ANY pipe-delimited segment
exclude: ?[]u8 = null, // exclude: subname must NOT contain ANY pipe-delimited segment
fn hasFilter(self: *const FlagEntry) bool {
return self.filter != null or self.exclude != null;
}
/// Match subname against pipe-delimited filter segments.
/// Include: subname must contain ANY segment (OR).
/// Exclude: subname must NOT contain ANY segment (OR).
fn matchesSubName(self: *const FlagEntry, subname: [*:0]const u8) bool {
if (self.filter) |f| {
if (!pipeMatchAny(subname, f)) return false;
}
if (self.exclude) |e| {
if (pipeMatchAny(subname, e)) return false;
}
return true;
}
/// Returns true if haystack contains ANY pipe-delimited segment from pattern.
fn pipeMatchAny(haystack: [*:0]const u8, pattern: []const u8) bool {
var start: usize = 0;
for (pattern, 0..) |c, i| {
if (c == '|') {
if (i > start and containsInsensitive(haystack, pattern[start..i]))
return true;
start = i + 1;
}
}
// Last (or only) segment
if (pattern.len > start and containsInsensitive(haystack, pattern[start..]))
return true;
return false;
}
fn filtersEqual(self: *const FlagEntry, inc: ?[]const u8, exc: ?[]const u8) bool {
return sliceEqual(self.filterSlice(), inc) and sliceEqual(self.excludeSlice(), exc);
}
fn filterSlice(self: *const FlagEntry) ?[]const u8 {
return self.filter;
}
fn excludeSlice(self: *const FlagEntry) ?[]const u8 {
return self.exclude;
}
fn setFilter(self: *FlagEntry, src: ?[]const u8) void {
if (self.filter) |old| allocator.free(old);
self.filter = dupeStr(src);
}
fn setExclude(self: *FlagEntry, src: ?[]const u8) void {
if (self.exclude) |old| allocator.free(old);
self.exclude = dupeStr(src);
}
fn clear(self: *FlagEntry) void {
if (self.filter) |f| allocator.free(f);
if (self.exclude) |e| allocator.free(e);
self.* = .{};
}
fn dupeStr(src: ?[]const u8) ?[]u8 {
const s = src orelse return null;
if (s.len == 0) return null;
return allocator.dupe(u8, s) catch return null;
}
fn sliceEqual(a: ?[]const u8, b: ?[]const u8) bool {
const sa = a orelse return b == null or b.?.len == 0;
const sb = b orelse return sa.len == 0;
if (sa.len != sb.len) return false;
for (sa, sb) |x, y| {
if (x != y) return false;
}
return true;
}
};
const GoTypeEntry = struct {
go_type: u32 = 0,
blip: Blip = .{ .texture = 0, .scale = 1.0 },
active: bool = false,
};
const GoEntryEntry = struct {
entry_id: u32 = 0,
blip: Blip = .{ .texture = 0, .scale = 1.0 },
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,
};
const CStatus = extern struct {
vftable: u32,
unk: i32,
head_next: u32,
head_prev: u32,
max_severity: i32,
fn init(self: *CStatus) void {
self.vftable = ADDR.CStatusVftable;
self.unk = 8;
self.head_next = @intFromPtr(&self.head_next);
self.head_prev = @intFromPtr(&self.head_next) | 1;
self.max_severity = 0;
}
fn ok(self: *const CStatus) bool {
return self.max_severity < 2; // < STATUS_ERROR
}
fn deinit(self: *CStatus) void {
const f: *const fn (*CStatus) callconv(hook.cc.thiscall) void = @ptrFromInt(ADDR.CStatusDestructor);
f(self);
}
};
// =============================================================================
// State
// =============================================================================
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;
var g_city_toggle: bool = false; // when true, suppress tracking in cities
// Capital city zone IDs
const CITY_ZONES = [_]u32{
1519, // Stormwind City
1537, // Ironforge
1657, // Darnassus
1637, // Orgrimmar
1638, // Thunder Bluff
1497, // Undercity
2040, // Alah'Thalas
};
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;
var g_tex_cache_count: u32 = 0;
var g_default_tex_flags: u32 = 0;
// GUID → blip result cache. NPC flags/subnames don't change at runtime,
// so a matched GUID always produces the same blip. Cleared when tracking
// config changes (refreshActiveTrackingCache).
const GUID_CACHE_SIZE = 256; // must be power of 2
const GuidCacheEntry = struct {
guid_lo: u32 = 0,
guid_hi: u32 = 0,
blip: Blip = .{ .texture = 0, .scale = 1.0 },
valid: bool = false, // entry is populated
matched: bool = false, // true = has blip, false = no match (negative cache)
};
var g_guid_cache: [GUID_CACHE_SIZE]GuidCacheEntry = .{GuidCacheEntry{}} ** GUID_CACHE_SIZE;
fn guidCacheIndex(guid_lo: u32, guid_hi: u32) usize {
// Simple hash: XOR fold both halves
return @intCast((guid_lo ^ guid_hi) & (GUID_CACHE_SIZE - 1));
}
fn guidCacheLookup(guid_lo: u32, guid_hi: u32) ?GuidCacheEntry {
const idx = guidCacheIndex(guid_lo, guid_hi);
const entry = &g_guid_cache[idx];
if (entry.valid and entry.guid_lo == guid_lo and entry.guid_hi == guid_hi) {
return entry.*;
}
return null;
}
fn guidCacheStore(guid_lo: u32, guid_hi: u32, blip: ?Blip) void {
const idx = guidCacheIndex(guid_lo, guid_hi);
g_guid_cache[idx] = .{
.guid_lo = guid_lo,
.guid_hi = guid_hi,
.blip = blip orelse .{ .texture = 0, .scale = 1.0 },
.valid = true,
.matched = blip != null,
};
}
fn guidCacheClear() void {
g_guid_cache = .{GuidCacheEntry{}} ** GUID_CACHE_SIZE;
}
// Per-frame minimap info cache (same for all objects in one enumeration cycle)
const MinimapInfoCache = struct {
cur: C3Vector = .{ .x = 0, .y = 0, .z = 0 },
radius: f32 = 0,
layout_scale: f32 = 0,
unk_scale: f32 = 0,
valid: bool = false,
};
var g_minimap_info: MinimapInfoCache = .{};
var g_local_player: u32 = 0; // cached per enumeration cycle
var g_local_player_guid: u64 = 0; // cached per enumeration cycle
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
var log: logging.Logger = .{};
pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Object helpers (delegates to shared wow module)
// =============================================================================
const isValidPtr = wow.isValidPtr;
const getObjectByGUID = wow.getObjectByGUIDSplit;
const getObjectType = wow.getObjectTypeRaw;
const getDescriptor = wow.getDescriptor;
const getNpcFlags = wow.getNpcFlags;
fn getGoType(obj: u32) u32 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
return hook.readMem(u32, desc + offsets.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;
}
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
};
fn isReagentVendorEntry(entry_id: u32) bool {
for (REAGENT_VENDOR_ENTRIES) |e| {
if (e == entry_id) return true;
}
return false;
}
fn getGoFlags(obj: u32) u32 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
return hook.readMem(u32, desc + offsets.DESC_GO_FLAGS);
}
fn getGoDynFlags(obj: u32) u32 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
return hook.readMem(u32, desc + offsets.DESC_GO_DYN_FLAGS);
}
fn getRace(obj: u32) u8 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
return @truncate(hook.readMem(u32, desc + offsets.DESC_BYTES_0));
}
// Alliance: Human(1), Dwarf(3), Night Elf(4), Gnome(7), High Elf(10)
// Horde: Orc(2), Undead(5), Tauren(6), Troll(8), Goblin(9)
const ALLIANCE_RACES: u16 = (1 << 1) | (1 << 3) | (1 << 4) | (1 << 7) | (1 << 10);
const HORDE_RACES: u16 = (1 << 2) | (1 << 5) | (1 << 6) | (1 << 8) | (1 << 9);
fn isSameFaction(race_a: u8, race_b: u8) bool {
if (race_a == 0 or race_b == 0) return false;
if (race_a > 10 or race_b > 10) return false;
const mask_a: u16 = @as(u16, 1) << @intCast(race_a);
const mask_b: u16 = @as(u16, 1) << @intCast(race_b);
// Both alliance or both horde
if ((mask_a & ALLIANCE_RACES) != 0 and (mask_b & ALLIANCE_RACES) != 0) return true;
if ((mask_a & HORDE_RACES) != 0 and (mask_b & HORDE_RACES) != 0) return true;
return false;
}
fn getSummonedByGUID(obj: u32) u64 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
const lo = hook.readMem(u32, desc + offsets.DESC_SUMMONEDBY);
const hi = hook.readMem(u32, desc + offsets.DESC_SUMMONEDBY + 4);
return (@as(u64, hi) << 32) | lo;
}
fn getActivePlayerGUID() u64 {
return wow.getPlayerGUID();
}
fn getActivePlayerObject() u32 {
const guid = getActivePlayerGUID();
if (guid == 0) return 0;
return wow.getObjectByGUID(guid);
}
fn unitReaction(local_player: u32, unit: u32) i32 {
if (local_player == 0 or unit == 0) return 0;
return hook.call(fn (u32, u32) callconv(hook.cc.thiscall) i32, offsets.FN_UNIT_REACTION, .{ local_player, unit });
}
/// Check if a unit passes faction/friendliness filters.
/// Returns false (reject) if:
/// - Unit is hostile (reaction < 4)
/// - Unit has an owner whose faction template differs from local player
fn isUnitAllowed(obj: u32, local_player: u32) bool {
if (local_player == 0) return true; // can't check without player
// Check hostility via UnitReaction
const reaction = unitReaction(local_player, obj);
if (reaction < 4) {
log.fmt("unit 0x{x} rejected: hostile (reaction={d}, race={d})\n", .{ obj, reaction, getRace(obj) });
return false;
}
// If unit has a summoner, check ownership and faction
const summoner_guid = getSummonedByGUID(obj);
if (summoner_guid != 0) {
// Hide our own summons (e.g. our own repair bot)
if (summoner_guid == g_local_player_guid) return false;
const lo: u32 = @truncate(summoner_guid);
const hi: u32 = @truncate(summoner_guid >> 32);
const summoner = getObjectByGUID(lo, hi);
if (summoner != 0 and isValidPtr(summoner)) {
const our_race = getRace(local_player);
const their_race = getRace(summoner);
// log.fmt("unit 0x{x} summoner 0x{x}: our race={d} their race={d}\n", .{ obj, summoner, our_race, their_race });
if (!isSameFaction(our_race, their_race))
return false;
}
}
return true;
}
/// Check if a game object is interactable using the game's own handler.
/// CallSpellCastHandler: __fastcall(obj_ECX) -> char (bool via virtual dispatch).
/// This is what IsValidInteractionTarget uses for type 0x21 (GO).
fn isGoInteractable(obj: u32) bool {
const result: u8 = @truncate(hook.call(fn (u32) callconv(hook.cc.fastcall) u32, offsets.FN_CALL_SPELL_CAST_HANDLER, .{obj}));
if (result == 0) {
log.fmt("GO 0x{x} rejected: not interactable (entry={d})\n", .{ obj, getObjectEntry(obj) });
}
return result != 0;
}
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;
const get_pos_fn = hook.readMem(u32, vtable + ADDR.VT_GET_POSITION);
if (!isValidPtr(get_pos_fn)) return null;
var pos: C3Vector = undefined;
// __thiscall(obj_ECX, &pos_stack) → C3Vector*
_ = hook.call(fn (u32, *C3Vector) callconv(hook.cc.thiscall) u32, get_pos_fn, .{ obj, &pos });
return pos;
}
// =============================================================================
// Minimap coordinate conversion
// =============================================================================
fn worldPosToMinimapCoords(
out: *C2Vector,
cur: C3Vector,
radius: f32,
world_x: f32,
world_y: f32,
layout_scale: f32,
unk_scale: f32,
) void {
// WorldPosToMinimapFrameCoords: __fastcall(out_ECX, edx, C3Vector cur, float radius,
// float worldX, float worldY, float layoutScale, float unkScale)
// EDX unused — use thiscall (ECX=this, rest on stack)
_ = hook.call(fn (*C2Vector, f32, f32, f32, f32, f32, f32, f32, f32) callconv(hook.cc.thiscall) u32, ADDR.WorldPosToMinimapCoords, .{
out, cur.x, cur.y, cur.z, radius, world_x, world_y, layout_scale, unk_scale,
});
}
fn getFrameUnkScale(info: u32) f32 {
const frame = hook.readMem(u32, info + ADDR.MI_FRAME);
if (!isValidPtr(frame)) return 1.0;
const fsp = frame + ADDR.FRAME_SCRIPT_PART;
const vtable = hook.readMem(u32, fsp);
if (!isValidPtr(vtable)) return 1.0;
const fn_addr = hook.readMem(u32, vtable + 7 * 4);
if (!isValidPtr(fn_addr)) return 1.0;
// __thiscall(fsp_ECX) → f32 on FPU ST(0)
return hook.call(fn (u32) callconv(hook.cc.thiscall) f32, fn_addr, .{fsp});
}
// =============================================================================
// Texture management
// =============================================================================
fn simpleHash(s: [*:0]const u8) u32 {
var h: u32 = 5381;
var i: u32 = 0;
while (s[i] != 0) : (i += 1) {
var c = s[i];
if (c >= 'A' and c <= 'Z') c += 32;
h = h *% 33 +% c;
}
return h;
}
fn loadTexture(path: [*:0]const u8) u32 {
const path_hash = simpleHash(path);
// Check cache
for (0..g_tex_cache_count) |i| {
if (g_tex_cache[i].path_hash == path_hash and g_tex_cache[i].handle != 0) {
return g_tex_cache[i].handle;
}
}
// Initialize default tex flags on first use
if (g_default_tex_flags == 0) {
g_default_tex_flags = initTexFlags();
}
var status: CStatus = undefined;
status.init();
defer status.deinit();
// TextureCreate: __fastcall(filename_ECX, status_EDX, texFlags, unk1, unk2)
const texture = hook.call(fn ([*:0]const u8, *CStatus, u32, u32, u32) callconv(hook.cc.fastcall) u32, ADDR.TextureCreate, .{
path, &status, g_default_tex_flags, 0, 1,
});
if (!status.ok() or texture == 0) {
log.print("Failed to load texture\n");
return 0;
}
if (g_tex_cache_count < MAX_TEXTURES) {
g_tex_cache[g_tex_cache_count] = .{ .path_hash = path_hash, .handle = texture };
g_tex_cache_count += 1;
}
return texture;
}
fn initTexFlags() u32 {
var flags: u32 = 0;
// CGxTexFlags constructor: __thiscall(this, filter=0, wrapU=0, wrapV=0,
// forceMipTracking=0, generateMipMaps=0, renderTarget=0, maxAnisotropy=0, unknownFlag=1)
hook.call(fn (*u32, u32, u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.thiscall) void, ADDR.CGxTexFlagsInit, .{
&flags, 0, 0, 0, 0, 0, 0, 0, 1,
});
return flags;
}
// =============================================================================
// Blip drawing (port of VanillaHelpers DrawMinimapTexture)
// =============================================================================
/// Resolve HTEXTURE to CGxTex* for binding. Returns 0 on failure.
fn getGxTex(texture: u32) u32 {
var status: CStatus = undefined;
status.init();
defer status.deinit();
const gx_tex = hook.call(fn (u32, u32, *CStatus) callconv(hook.cc.fastcall) u32, ADDR.TextureGetGxTex, .{
texture, 1, &status,
});
if (!status.ok()) return 0;
return gx_tex;
}
/// Draw a single blip quad. Caller must have already bound the texture via GxRsSet.
fn drawMinimapBlip(pos: C2Vector, scale: f32) void {
const color: CImVector = .{ .b = 0xFF, .g = 0xFF, .r = 0xFF, .a = 0xFF };
// Scale static blip vertex template by blip scale and offset by minimap position
var vertices: [4]C3Vector = undefined;
for (0..4) |i| {
const src = ADDR.BlipVertices + i * 12;
vertices[i] = .{
.x = pos.x + scale * hook.readMem(f32, src),
.y = pos.y + scale * hook.readMem(f32, src + 4),
.z = scale * hook.readMem(f32, src + 8),
};
}
// GxPrimLockVertexPtrs(count=4, vertices, vertStride=12, normal, 0, color, 0,
// null, 0, texCoords, 8, null, 0)
hook.call(fn (u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.fastcall) void, ADDR.GxPrimLockVertexPtrs, .{
4, // count (ECX)
@intFromPtr(&vertices), // vertices (EDX)
12, // vertStride
@as(u32, ADDR.BlipNormal), // normal
0, // normalStride
@intFromPtr(&color), // color
0, // colorStride
0, // specular (null)
0, // specularStride
@as(u32, ADDR.BlipTexCoords), // texCoords
8, // texStride
0, // texCoords2 (null)
0, // tex2Stride
});
// GxPrimDrawElements(TriangleStrip=4, count=4, indices)
const drawElements: *const fn (u32, u32, u32) callconv(hook.cc.fastcall) void = @ptrFromInt(ADDR.GxPrimDrawElements);
drawElements(4, 4, ADDR.BlipVertIndices);
// GxPrimUnlockVertexPtrs()
const unlockPtrs: *const fn () callconv(.c) void = @ptrFromInt(ADDR.GxPrimUnlockVertexPtrs);
unlockPtrs();
}
// =============================================================================
// Object classification
// =============================================================================
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.
if (guidCacheLookup(guid_lo, guid_hi)) |cached| {
if (!cached.matched) return false; // negative cache hit
// Dynamic checks on cached match
const obj = getObjectByGUID(guid_lo, guid_hi);
if (obj == 0 or !isValidPtr(obj)) return false;
const obj_type = getObjectType(obj);
if (obj_type == OBJ_TYPE_UNIT) {
if (!isUnitAllowed(obj, g_local_player)) return false;
} else if (obj_type == OBJ_TYPE_GAMEOBJECT) {
if (!isGoInteractable(obj)) return false;
}
trackObject(info, guid_lo, guid_hi, cached.blip);
return true;
}
const obj = getObjectByGUID(guid_lo, guid_hi);
if (obj == 0 or !isValidPtr(obj)) return false;
const obj_type = getObjectType(obj);
if (obj_type == OBJ_TYPE_UNIT and g_has_active_unit_tracking) {
const npc_flags = getNpcFlags(obj);
if (npc_flags == 0) {
guidCacheStore(guid_lo, guid_hi, null);
return false;
}
// Quick bitmask check — skip loop if no active entries match any of this NPC's flags
if (npc_flags & g_active_flag_mask == 0) {
guidCacheStore(guid_lo, guid_hi, null);
return false;
}
// 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.
// Only read subname/entry when filters are active (expensive per-object reads).
const has_filters = g_has_any_filters;
const subname = if (has_filters) getCreatureSubName(obj) else @as(?[*:0]const u8, null);
const is_reagent_override = if (has_filters) isReagentVendorEntry(getObjectEntry(obj)) else false;
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) continue;
const priority: u8 = if (entry.filter != null)
3
else if (entry.exclude != null)
2
else
1;
if (priority < best_priority) continue;
if (priority == best_priority and entry.flag < best_flag) continue;
if (priority == best_priority and entry.flag == best_flag) continue; // first match wins at same tier
// Check subname match (entry-ID overrides bypass subname requirement)
if (entry.hasFilter()) {
if (subname) |sn| {
if (!entry.matchesSubName(sn)) continue;
} else if (entry.filter != null and is_reagent_override and
entry.flag == NPC_FLAG_VENDOR and FlagEntry.pipeMatchAny("Reagent Vendor", entry.filter.?))
{
// NPC has no subname but entry ID is a known reagent vendor
} else continue;
}
best_priority = priority;
best_flag = entry.flag;
best_blip = entry.blip;
}
if (best_flag != 0) {
// Classification matched — apply dynamic faction/friendliness check.
// Not negative-cached: faction can change (group join/leave).
if (!isUnitAllowed(obj, g_local_player)) return false;
guidCacheStore(guid_lo, guid_hi, best_blip);
trackObject(info, guid_lo, guid_hi, best_blip);
return true;
}
} else if (obj_type == OBJ_TYPE_GAMEOBJECT and g_has_active_go_tracking) {
// Check by specific entry ID first
const go_entry_id = getObjectEntry(obj);
for (&g_go_id_tracking) |*entry| {
if (!entry.active) continue;
if (entry.entry_id == go_entry_id) {
if (!isGoInteractable(obj)) return false;
guidCacheStore(guid_lo, guid_hi, entry.blip);
trackObject(info, guid_lo, guid_hi, entry.blip);
return true;
}
}
// Then by gameobject type
const go_type = getGoType(obj);
for (&g_go_tracking) |*entry| {
if (!entry.active) continue;
if (entry.go_type == go_type) {
if (!isGoInteractable(obj)) return false;
guidCacheStore(guid_lo, guid_hi, entry.blip);
trackObject(info, guid_lo, guid_hi, entry.blip);
return true;
}
}
}
guidCacheStore(guid_lo, guid_hi, null);
return false;
}
fn trackObject(info: u32, guid_lo: u32, guid_hi: u32, blip: Blip) void {
if (g_blip_count >= MAX_BLIPS) return;
const obj = getObjectByGUID(guid_lo, guid_hi);
if (obj == 0 or !isValidPtr(obj)) return;
const pos = getObjectPosition(obj) orelse return;
// Cache minimap info per frame — same for all objects in one enumeration cycle
if (!g_minimap_info.valid) {
g_minimap_info = .{
.cur = .{
.x = hook.readMem(f32, info + ADDR.MI_POS),
.y = hook.readMem(f32, info + ADDR.MI_POS + 4),
.z = hook.readMem(f32, info + ADDR.MI_POS + 8),
},
.radius = hook.readMem(f32, info + ADDR.MI_RADIUS),
.layout_scale = hook.readMem(f32, info + ADDR.MI_LAYOUT_SCALE),
.unk_scale = getFrameUnkScale(info),
.valid = true,
};
}
// Skip objects more than 55y above or below the player (filters multi-level cities and large dungeons like kara40)
if (g_local_player != 0) {
if (getObjectPosition(g_local_player)) |player_pos| {
if (@abs(pos.z - player_pos.z) > 55.0) return;
}
}
var minimap_pos: C2Vector = undefined;
worldPosToMinimapCoords(&minimap_pos, g_minimap_info.cur, g_minimap_info.radius, pos.x, pos.y, g_minimap_info.layout_scale, g_minimap_info.unk_scale);
g_blips[g_blip_count] = .{
.pos = minimap_pos,
.blip = blip,
};
g_blip_count += 1;
}
fn refreshActiveTrackingCache() void {
var has_unit = false;
var has_go = false;
var has_filters = false;
var flag_mask: u32 = 0;
for (&g_flag_tracking) |*entry| {
if (entry.active) {
has_unit = true;
flag_mask |= entry.flag;
if (entry.hasFilter()) has_filters = true;
}
}
for (&g_go_tracking) |*entry| {
if (entry.active) has_go = true;
}
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_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;
// Config changed — cached match results may be stale
guidCacheClear();
}
// =============================================================================
// Hook detours
// =============================================================================
// ObjectEnumProc: __fastcall(MINIMAPINFO* ECX, unused EDX, u64 guid on stack)
// When guid is u64, MSVC fastcall skips EDX and puts the 8 bytes on stack.
// In Zig fastcall with 4 u32 params: ECX=info, EDX=_edx, stack=guid_lo,guid_hi → RET 8.
const EnumProcFn = fn (u32, u32, u32, u32) callconv(hook.cc.fastcall) i32;
var enum_proc_hook: hook.Detour(EnumProcFn) = .{};
fn isInCity() bool {
const zone = hook.readMem(u32, ZONE_AREA_ID);
for (CITY_ZONES) |city| {
if (zone == city) return true;
}
return false;
}
fn objectEnumProcDetour(info: u32, _edx: u32, guid_lo: u32, guid_hi: u32) callconv(hook.cc.fastcall) i32 {
if (g_has_active_tracking and !(g_city_toggle and isInCity())) {
if (checkObject(info, guid_lo, guid_hi)) {
return 1; // Skip original — we draw our own icon
}
}
return enum_proc_hook.callOriginal(.{ info, _edx, guid_lo, guid_hi });
}
// RenderObjectBlips: __thiscall(CGMinimapFrame* ECX, DNInfo* stack)
// Hooked via fastcall with explicit EDX placeholder.
const RenderBlipsFn = fn (u32, u32, u32) callconv(hook.cc.fastcall) void;
var render_blips_hook: hook.Detour(RenderBlipsFn) = .{};
fn renderObjectBlipsDetour(thisptr: u32, _edx: u32, dn_info: u32) callconv(hook.cc.fastcall) void {
render_blips_hook.callOriginal(.{ thisptr, _edx, dn_info });
const count = g_blip_count;
if (count == 0) return;
// Sort blips by texture handle to batch draw calls
const blips = g_blips[0..count];
std.mem.sort(TrackedBlip, blips, {}, struct {
fn lessThan(_: void, a: TrackedBlip, b: TrackedBlip) bool {
return a.blip.texture < b.blip.texture;
}
}.lessThan);
// Draw batched by texture — one GxRsSet per unique texture
var i: usize = 0;
while (i < count) {
const tex = blips[i].blip.texture;
if (tex == 0) {
i += 1;
continue;
}
// Bind texture once for this group
const gx_tex = getGxTex(tex);
if (gx_tex == 0) {
// Skip all blips with this texture
while (i < count and blips[i].blip.texture == tex) : (i += 1) {}
continue;
}
const gxRsSet: *const fn (u32, u32) callconv(hook.cc.fastcall) void = @ptrFromInt(ADDR.GxRsSet);
gxRsSet(23, gx_tex);
// Draw all blips sharing this texture
while (i < count and blips[i].blip.texture == tex) : (i += 1) {
drawMinimapBlip(blips[i].pos, blips[i].blip.scale);
}
}
}
// ClntObjMgrEnumVisibleObjects: __fastcall(callback ECX, context EDX)
const EnumVisFn = fn (u32, u32) callconv(hook.cc.fastcall) i32;
var enum_vis_hook: hook.Detour(EnumVisFn) = .{};
fn enumVisibleObjectsDetour(callback: u32, context: u32) callconv(hook.cc.fastcall) i32 {
// Clear tracked blips when the minimap's own callback is about to enumerate
if (callback == ADDR.ObjectEnumProc) {
g_blip_count = 0;
g_minimap_info.valid = false;
const prev = g_local_player;
g_local_player_guid = getActivePlayerGUID();
g_local_player = if (g_local_player_guid != 0)
getObjectByGUID(@truncate(g_local_player_guid), @truncate(g_local_player_guid >> 32))
else
0;
if (g_local_player != prev) {
log.fmt("local player: 0x{x} race={d}\n", .{
g_local_player,
if (g_local_player != 0) @as(u32, getRace(g_local_player)) else @as(u32, 0),
});
}
}
return enum_vis_hook.callOriginal(.{ callback, context });
}
// =============================================================================
// Lua API
// =============================================================================
/// Read a Lua string arg as a slice (null if absent/empty).
fn luaStringToSlice(L: lua.State, idx: i32) ?[]const u8 {
if (!lua.isstring(L, idx)) return null;
const s = lua.tostring(L, idx) orelse return null;
var len: usize = 0;
while (s[len] != 0) : (len += 1) {}
if (len == 0) return null;
return s[0..len];
}
// SetObjectTypeBlip(typeName [, texturePath [, scale [, includeFilter [, excludeFilter]]]])
pub fn luaSetObjectTypeBlip(L: lua.State) callconv(hook.cc.fastcall) i32 {
defer refreshActiveTrackingCache();
if (!lua.isstring(L, 1)) {
lua.luaError(L, "Usage: SetObjectTypeBlip(type [, texture [, scale [, include [, exclude]]]])");
return 0;
}
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, quest_available");
return 0;
};
// Parse optional include filter (arg 4) and exclude filter (arg 5) as slices
const inc = luaStringToSlice(L, 4);
const exc = luaStringToSlice(L, 5);
if (!lua.isstring(L, 2)) {
// Disable tracking for this type + filter combo
switch (mapping.kind) {
.go_type => {
for (&g_go_tracking) |*entry| {
if (entry.active and entry.go_type == mapping.value) {
entry.active = false;
break;
}
}
},
.go_entry => {
for (&g_go_id_tracking) |*entry| {
if (entry.active and entry.entry_id == mapping.value) {
entry.active = false;
break;
}
}
},
.npc_flag => {
for (&g_flag_tracking) |*entry| {
if (entry.active and entry.flag == mapping.value and
entry.filtersEqual(inc, exc))
{
entry.clear();
break;
}
}
},
.quest_status => {
for (&g_quest_tracking) |*entry| {
if (entry.active and entry.status == mapping.value) {
entry.active = false;
break;
}
}
},
}
return 0;
}
// Enable tracking with texture
const tex_path = lua.tostring(L, 2) orelse return 0;
const texture = loadTexture(tex_path);
if (texture == 0) {
lua.luaError(L, "Failed to load texture");
return 0;
}
var scale: f32 = 1.0;
if (lua.isnumber(L, 3)) {
scale = @floatCast(lua.tonumber(L, 3));
}
const blip = Blip{ .texture = texture, .scale = scale };
switch (mapping.kind) {
.go_type => {
for (&g_go_tracking) |*entry| {
if (entry.active and entry.go_type == mapping.value) {
entry.blip = blip;
return 0;
}
}
for (&g_go_tracking) |*entry| {
if (!entry.active) {
entry.* = .{ .go_type = mapping.value, .blip = blip, .active = true };
return 0;
}
}
},
.go_entry => {
for (&g_go_id_tracking) |*entry| {
if (entry.active and entry.entry_id == mapping.value) {
entry.blip = blip;
return 0;
}
}
for (&g_go_id_tracking) |*entry| {
if (!entry.active) {
entry.* = .{ .entry_id = mapping.value, .blip = blip, .active = true };
return 0;
}
}
},
.npc_flag => {
// Match by flag + include/exclude combo — update existing or add new
for (&g_flag_tracking) |*entry| {
if (entry.active and entry.flag == mapping.value and
entry.filtersEqual(inc, exc))
{
entry.blip = blip;
return 0;
}
}
for (&g_flag_tracking) |*entry| {
if (!entry.active) {
entry.flag = mapping.value;
entry.blip = blip;
entry.active = true;
entry.setFilter(inc);
entry.setExclude(exc);
return 0;
}
}
},
.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;
}
// SetMinimapCityToggle(enabled) — suppress NPC/GO tracking in capital cities
pub fn luaSetCityToggle(L: lua.State) callconv(hook.cc.fastcall) i32 {
g_city_toggle = lua.isnumber(L, 1) and lua.tonumber(L, 1) != 0;
return 0;
}
const TypeMapping = struct {
kind: enum { npc_flag, go_type, go_entry, quest_status } = .npc_flag,
value: u32,
};
fn findTypeMapping(name: [*:0]const u8) ?TypeMapping {
const T = struct { n: []const u8, m: TypeMapping };
const table = [_]T{
.{ .n = "auctioneer", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_AUCTIONEER } },
.{ .n = "banker", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_BANKER } },
.{ .n = "battlemaster", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_BATTLEMASTER } },
.{ .n = "flightmaster", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_FLIGHTMASTER } },
.{ .n = "innkeeper", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_INNKEEPER } },
.{ .n = "repair", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_REPAIR } },
.{ .n = "stablemaster", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_STABLEMASTER } },
.{ .n = "trainer", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_TRAINER } },
.{ .n = "vendor", .m = .{ .kind = .npc_flag, .value = NPC_FLAG_VENDOR } },
.{ .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| {
if (strEqlInsensitive(name, entry.n)) return entry.m;
}
return null;
}
fn strEqlInsensitive(a: [*:0]const u8, b: []const u8) bool {
for (b, 0..) |bc, i| {
var ac = a[i];
if (ac == 0) return false;
if (ac >= 'A' and ac <= 'Z') ac += 32;
var blc = bc;
if (blc >= 'A' and blc <= 'Z') blc += 32;
if (ac != blc) return false;
}
return a[b.len] == 0;
}
// =============================================================================
// Module lifecycle
// =============================================================================
pub fn installHooks() void {
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
log = logging.Logger.open(module_name, .console);
if (enum_proc_hook.attach(ADDR.ObjectEnumProc, &objectEnumProcDetour) != .ok) {
log.print("Failed to hook ObjectEnumProc\n");
return;
}
if (render_blips_hook.attach(ADDR.RenderObjectBlips, &renderObjectBlipsDetour) != .ok) {
log.print("Failed to hook RenderObjectBlips\n");
enum_proc_hook.detach();
return;
}
if (enum_vis_hook.attach(ADDR.EnumVisibleObjects, &enumVisibleObjectsDetour) != .ok) {
log.print("Failed to hook EnumVisibleObjects\n");
render_blips_hook.detach();
enum_proc_hook.detach();
return;
}
log.print("Hooks installed\n");
}
/// Called from CGGameUI_Shutdown (logout/exit) — frees heap-allocated filter strings.
pub fn onShutdown() void {
for (&g_flag_tracking) |*entry| entry.clear();
log.print("filters freed (shutdown)\n");
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
enum_vis_hook.detach();
render_blips_hook.detach();
enum_proc_hook.detach();
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();
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}