Add DLL-side minimap NPC tracking with blip rendering

Hook ObjectEnumProc, RenderObjectBlips, and EnumVisibleObjects to classify
visible objects by NPC flags / GO type and render custom blip icons.

Includes Lua API (SetObjectTypeBlip), texture caching, WMO indoor/outdoor
filtering, and coordinate projection. Consolidate vendor subcategories
into single entry since vanilla only has one NPC_FLAG_VENDOR. Spell
tracking entries now toggle off when clicked while active.

Fix: queryMapObjIDs was __fastcall (not __thiscall) — pushing 3 stack
args but RET 0x8 only cleaned 2, corrupting ESP and crashing.
This commit is contained in:
MarcelineVQ
2026-03-07 20:29:09 -08:00
parent 44f13c42f4
commit 89b6082d73
4 changed files with 831 additions and 35 deletions
-1
View File
@@ -3,5 +3,4 @@
## Notes: Nearest NPC/object interaction and bulk looting
## Version: 1.0
Interact.lua
+3
View File
@@ -96,6 +96,9 @@ fn registerLuaFunctions() void {
registerFunction("SetCursorScale", @intFromPtr(&bigcursor.luaSetCursorScale));
registerFunction("GetCursorScale", @intFromPtr(&bigcursor.luaGetCursorScale));
}
if (build_opts.minimapicons and minimapicons.isActive()) {
registerFunction("SetObjectTypeBlip", @intFromPtr(&minimapicons.luaSetObjectTypeBlip));
}
if (build_opts.worldmarkers and markers.isActive()) {
// User-facing functions stay global
registerFunction("WorldMarker", @intFromPtr(&markers.luaWorldMarker));
+52 -27
View File
@@ -1,8 +1,8 @@
-- MinimapIcons addon (embedded in DLL, loaded from memory)
-- Takes over the native MiniMapTrackingFrame to add a tracking spell dropdown
-- with Wrath-style NPC tracking categories.
-- with Wrath-style NPC tracking categories backed by DLL-side minimap hooks.
MINIMAPICONS_VERSION = 2
MINIMAPICONS_VERSION = 3
-- =============================================================================
-- Tracking spell definitions grouped by category
@@ -22,21 +22,21 @@ local SPELL_CATEGORIES = {
}
-- NPC tracking categories (icons embedded from Wrath client)
-- These don't filter minimap blips yet — future DLL hook work.
-- 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)
local NPC_CATEGORIES = {
{ name = "Auctioneer", icon = "Interface\\Minimap\\Tracking\\Auctioneer" },
{ name = "Banker", icon = "Interface\\Minimap\\Tracking\\Banker" },
{ name = "Battle Master", icon = "Interface\\Minimap\\Tracking\\BattleMaster" },
{ name = "Class Trainer", icon = "Interface\\Minimap\\Tracking\\Class" },
{ name = "Flight Master", icon = "Interface\\Minimap\\Tracking\\FlightMaster" },
{ name = "Food & Drink", icon = "Interface\\Minimap\\Tracking\\Food" },
{ name = "Innkeeper", icon = "Interface\\Minimap\\Tracking\\Innkeeper" },
{ name = "Mailbox", icon = "Interface\\Minimap\\Tracking\\Mailbox" },
{ name = "Profession Trainer", icon = "Interface\\Minimap\\Tracking\\Profession" },
{ name = "Reagent Vendor", icon = "Interface\\Minimap\\Tracking\\Reagents" },
{ name = "Repair", icon = "Interface\\Minimap\\Tracking\\Repair" },
{ name = "Stable Master", icon = "Interface\\Minimap\\Tracking\\StableMaster" },
{ name = "Ammunition", icon = "Interface\\Minimap\\Tracking\\Ammunition" },
{ 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 = "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 = "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" },
}
-- =============================================================================
@@ -45,6 +45,34 @@ 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.
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
end
end
end
-- =============================================================================
-- Spellbook scanning
-- =============================================================================
@@ -90,7 +118,6 @@ local dropdown = CreateFrame("Frame", "MinimapIconsDropDown", UIParent, "UIDropD
UIDropDownMenu_Initialize(dropdown, function()
-- Spell tracking section
local hasSpells = nil
for _, cat in ipairs(SPELL_CATEGORIES) do
local found = {}
for _, spell in ipairs(cat.spells) do
@@ -115,10 +142,15 @@ UIDropDownMenu_Initialize(dropdown, function()
info.tCoordTop = 0.0625
info.tCoordBottom = 0.9
info.checked = isTrackingActive(sid)
info.func = function() CastSpell(sid, BOOKTYPE_SPELL) end
info.func = function()
if isTrackingActive(sid) then
CancelTrackingBuff()
else
CastSpell(sid, BOOKTYPE_SPELL)
end
end
UIDropDownMenu_AddButton(info)
end
hasSpells = 1
end
end
@@ -142,18 +174,11 @@ UIDropDownMenu_Initialize(dropdown, function()
else
activeNpcCategories[catName] = 1
end
updateDllTracking()
end
UIDropDownMenu_AddButton(info)
end
-- None option (cancel all tracking)
if hasSpells then
local none = {}
none.text = NONE or "None"
none.notCheckable = 1
none.func = function() CancelTrackingBuff() end
UIDropDownMenu_AddButton(none)
end
end, "MENU")
-- =============================================================================
+776 -7
View File
@@ -1,18 +1,178 @@
//! Custom minimap icons module.
//! Minimap NPC tracking icons module.
//!
//! Adds distinct minimap icons for specific NPC types: flight masters,
//! mailboxes, reagent vendors, class trainers, etc.
//! Adds NPC type tracking to the minimap (flight masters, innkeepers, mailboxes, etc.)
//! by hooking the minimap's object enumeration and blip rendering pipeline.
//!
//! See: VanillaHelpers addon (https://github.com/nicholasgasior/VanillaHelpers)
//! for reference on NPC type detection and minimap icon overlay patterns.
//! 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
//!
//! TODO: Hook minimap icon rendering, classify NPCs by type, overlay custom icons.
//! 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 con = @import("../console.zig");
const mod_mutex = @import("../mutex.zig");
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
const tc: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
pub const module_name: [*:0]const u8 = "minimapicons";
// =============================================================================
// WoW 1.12.1 addresses
// =============================================================================
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 GetObjectByGUID: usize = 0x464870;
const QueryMapObjIDs: usize = 0x670540;
const CGxTexFlagsInit: usize = 0x58A980;
const CStatusDestructor: usize = 0x419E30;
// Static data
const BlipVertices: usize = 0xBC8230; // 4x C3Vector
const BlipNormal: usize = 0xBC829C; // C3Vector
const BlipTexCoords: usize = 0xBC77F0; // TexCoord (4x C2Vector)
const BlipVertIndices: usize = 0x807A2C; // 4x u16
const CStatusVftable: usize = 0x7FFA10;
// Object struct offsets
const OBJ_VTABLE: usize = 0x00;
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*)
// Descriptor field byte offsets (absolute_field_index * 4 from m_data)
const DESC_NPC_FLAGS: usize = 0x93 * 4; // UNIT_NPC_FLAGS = OBJECT_END + 0x8D
const DESC_GO_TYPE: usize = 0x15 * 4; // GAMEOBJECT_TYPE_ID
// MINIMAPINFO struct offsets
const MI_WMO_ID: usize = 0x04;
const MI_MAP_OBJ_ID: usize = 0x08;
const MI_POS: usize = 0x0C; // C3Vector
const MI_RADIUS: usize = 0x18;
const MI_LAYOUT_SCALE: usize = 0x1C;
const MI_FRAME: usize = 0x20;
// CGMinimapFrame: FrameScriptPart at +0x24, its vtable[7] = GetUnkScale
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;
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,
gray: bool,
};
const FlagEntry = struct {
flag: u32 = 0,
blip: Blip = .{ .texture = 0, .scale = 1.0 },
active: bool = false,
};
const GoTypeEntry = struct {
go_type: u32 = 0,
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(tc) void = @ptrFromInt(ADDR.CStatusDestructor);
f(self);
}
};
// =============================================================================
// State
// =============================================================================
const MAX_FLAG_ENTRIES = 16;
const MAX_GO_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_blips: [MAX_BLIPS]TrackedBlip = undefined;
var g_blip_count: u32 = 0;
var g_tex_cache: [MAX_TEXTURES]TextureEntry = .{TextureEntry{}} ** MAX_TEXTURES;
var g_tex_cache_count: u32 = 0;
var g_default_tex_flags: u32 = 0;
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
@@ -20,17 +180,626 @@ pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Pointer validation
// =============================================================================
fn isValidPtr(addr: u32) bool {
return addr >= 0x10000 and addr < 0x7F000000;
}
// =============================================================================
// Object helpers
// =============================================================================
fn getObjectByGUID(guid_lo: u32, guid_hi: u32) u32 {
if (guid_lo == 0 and guid_hi == 0) return 0;
return asm volatile (
\\push %[hi]
\\push %[lo]
\\call *%[func]
: [ret] "={eax}" (-> u32),
: [lo] "r" (guid_lo),
[hi] "r" (guid_hi),
[func] "r" (@as(u32, ADDR.GetObjectByGUID)),
: .{ .ecx = true, .edx = true, .memory = true, .cc = true });
}
fn getObjectType(obj: u32) u32 {
if (!isValidPtr(obj)) return 0;
return hook.readMem(u32, obj + ADDR.OBJ_TYPE);
}
fn getDescriptor(obj: u32) u32 {
if (!isValidPtr(obj)) return 0;
return hook.readMem(u32, obj + ADDR.OBJ_DATA);
}
fn getNpcFlags(obj: u32) u32 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
return hook.readMem(u32, desc + ADDR.DESC_NPC_FLAGS);
}
fn getGoType(obj: u32) u32 {
const desc = getDescriptor(obj);
if (!isValidPtr(desc)) return 0;
return hook.readMem(u32, desc + ADDR.DESC_GO_TYPE);
}
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*
asm volatile (
\\push %[out]
\\call *%[func]
:
: [_] "{ecx}" (obj),
[out] "r" (@intFromPtr(&pos)),
[func] "r" (get_pos_fn),
: .{ .eax = true, .edx = true, .memory = true, .cc = true });
return pos;
}
fn queryMapObjIDs(world_data: u32) struct { wmo_id: u32, map_obj_id: u32 } {
if (!isValidPtr(world_data)) return .{ .wmo_id = 0, .map_obj_id = 0 };
var wmo_id: u32 = 0;
var map_obj_id: u32 = 0;
var group_num: u32 = 0;
// __fastcall(world_ECX, &wmoID_EDX, &mapObjID, &groupNum) — RET 0x8
asm volatile (
\\push %[group]
\\push %[map]
\\call *%[func]
:
: [_] "{ecx}" (world_data),
[_] "{edx}" (@intFromPtr(&wmo_id)),
[map] "r" (@intFromPtr(&map_obj_id)),
[group] "r" (@intFromPtr(&group_num)),
[func] "r" (@as(u32, ADDR.QueryMapObjIDs)),
: .{ .eax = true, .memory = true, .cc = true });
return .{ .wmo_id = wmo_id, .map_obj_id = map_obj_id };
}
// =============================================================================
// 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)
// 8 stack params (32 bytes), callee cleanup
const args = [8]u32{
@bitCast(cur.x),
@bitCast(cur.y),
@bitCast(cur.z),
@bitCast(radius),
@bitCast(world_x),
@bitCast(world_y),
@bitCast(layout_scale),
@bitCast(unk_scale),
};
_ = asm volatile (
\\pushl 28(%[args])
\\pushl 24(%[args])
\\pushl 20(%[args])
\\pushl 16(%[args])
\\pushl 12(%[args])
\\pushl 8(%[args])
\\pushl 4(%[args])
\\pushl (%[args])
\\call *%[func]
: [ret] "={eax}" (-> u32),
: [_] "{ecx}" (@intFromPtr(out)),
[args] "r" (&args),
[func] "r" (@as(u32, ADDR.WorldPosToMinimapCoords)),
: .{ .edx = true, .memory = true, .cc = true });
}
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 asm volatile (
\\call *%[func]
: [ret] "={st}" (-> f32),
: [_] "{ecx}" (fsp),
[func] "r" (fn_addr),
: .{ .eax = true, .edx = true, .memory = true, .cc = true });
}
// =============================================================================
// 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: u32 = asm volatile (
\\pushl $1
\\pushl $0
\\push %[flags]
\\call *%[func]
: [ret] "={eax}" (-> u32),
: [_] "{ecx}" (@intFromPtr(path)),
[_] "{edx}" (@intFromPtr(&status)),
[flags] "r" (g_default_tex_flags),
[func] "r" (@as(u32, ADDR.TextureCreate)),
: .{ .memory = true, .cc = true });
if (!status.ok() or texture == 0) {
con.print("[minimapicons] 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)
asm volatile (
\\pushl $1
\\pushl $0
\\pushl $0
\\pushl $0
\\pushl $0
\\pushl $0
\\pushl $0
\\pushl $0
\\call *%[func]
:
: [_] "{ecx}" (@intFromPtr(&flags)),
[func] "r" (@as(u32, ADDR.CGxTexFlagsInit)),
: .{ .eax = true, .edx = true, .memory = true, .cc = true });
return flags;
}
// =============================================================================
// Blip drawing (port of VanillaHelpers DrawMinimapTexture)
// =============================================================================
fn drawMinimapTexture(texture: u32, pos: C2Vector, scale: f32, gray: bool) void {
if (texture == 0) return;
const color: CImVector = if (gray)
.{ .b = 0xB0, .g = 0xB0, .r = 0xB0, .a = 0xFF }
else
.{ .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),
};
}
// TextureGetGxTex: __fastcall(texture_ECX, flag_EDX, status*_stack) → CGxTex*
var status: CStatus = undefined;
status.init();
defer status.deinit();
const gx_tex: u32 = asm volatile (
\\push %[status]
\\call *%[func]
: [ret] "={eax}" (-> u32),
: [_] "{ecx}" (texture),
[_] "{edx}" (@as(u32, 1)),
[status] "r" (@intFromPtr(&status)),
[func] "r" (@as(u32, ADDR.TextureGetGxTex)),
: .{ .memory = true, .cc = true });
if (!status.ok() or gx_tex == 0) return;
// GxRsSet(GxRs_Texture0=23, gxTex)
const gxRsSet: *const fn (u32, u32) callconv(fc) void = @ptrFromInt(ADDR.GxRsSet);
gxRsSet(23, gx_tex);
// GxPrimLockVertexPtrs(count=4, vertices, vertStride=12, normal, 0, color, 0,
// null, 0, texCoords, 8, null, 0)
const lock_args = [11]u32{
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
};
asm volatile (
\\pushl 40(%[args])
\\pushl 36(%[args])
\\pushl 32(%[args])
\\pushl 28(%[args])
\\pushl 24(%[args])
\\pushl 20(%[args])
\\pushl 16(%[args])
\\pushl 12(%[args])
\\pushl 8(%[args])
\\pushl 4(%[args])
\\pushl (%[args])
\\call *%[func]
:
: [_] "{ecx}" (@as(u32, 4)), // count
[_] "{edx}" (@intFromPtr(&vertices)), // vertices
[args] "r" (&lock_args),
[func] "r" (@as(u32, ADDR.GxPrimLockVertexPtrs)),
: .{ .eax = true, .memory = true, .cc = true });
// GxPrimDrawElements(TriangleStrip=4, count=4, indices)
const drawElements: *const fn (u32, u32, u32) callconv(fc) 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 {
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) {
const npc_flags = getNpcFlags(obj);
if (npc_flags == 0) return false;
// Find highest-priority matching flag (higher flag value = higher priority)
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 (best_flag != 0) {
trackObject(info, obj, best_blip);
return true;
}
} else if (obj_type == OBJ_TYPE_GAMEOBJECT) {
const go_type = getGoType(obj);
for (&g_go_tracking) |*entry| {
if (!entry.active) continue;
if (entry.go_type == go_type) {
trackObject(info, obj, entry.blip);
return true;
}
}
}
return false;
}
fn trackObject(info: u32, obj: u32, blip: Blip) void {
if (g_blip_count >= MAX_BLIPS) return;
// WMO indoor/outdoor filtering
const info_wmo = hook.readMem(u32, info + ADDR.MI_WMO_ID);
const info_map = hook.readMem(u32, info + ADDR.MI_MAP_OBJ_ID);
const world_data = hook.readMem(u32, obj + ADDR.OBJ_WORLD_DATA);
var is_different_area = false;
if (isValidPtr(world_data)) {
const ids = queryMapObjIDs(world_data);
// Hide outside blips when player is inside a WMO (replicating original behavior)
if (info_wmo != 0 and (ids.wmo_id != info_wmo or ids.map_obj_id != info_map))
return;
is_different_area = (ids.wmo_id != info_wmo);
}
const pos = getObjectPosition(obj) orelse return;
const cur = C3Vector{
.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),
};
const radius = hook.readMem(f32, info + ADDR.MI_RADIUS);
const layout_scale = hook.readMem(f32, info + ADDR.MI_LAYOUT_SCALE);
const unk_scale = getFrameUnkScale(info);
var minimap_pos: C2Vector = undefined;
worldPosToMinimapCoords(&minimap_pos, cur, radius, pos.x, pos.y, layout_scale, unk_scale);
g_blips[g_blip_count] = .{
.pos = minimap_pos,
.blip = blip,
.gray = is_different_area,
};
g_blip_count += 1;
}
fn hasActiveTracking() bool {
for (&g_flag_tracking) |*entry| {
if (entry.active) return true;
}
for (&g_go_tracking) |*entry| {
if (entry.active) return true;
}
return false;
}
// =============================================================================
// 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(fc) i32;
var enum_proc_hook: hook.Detour(EnumProcFn) = .{};
fn objectEnumProcDetour(info: u32, _edx: u32, guid_lo: u32, guid_hi: u32) callconv(fc) i32 {
if (hasActiveTracking()) {
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(fc) void;
var render_blips_hook: hook.Detour(RenderBlipsFn) = .{};
fn renderObjectBlipsDetour(thisptr: u32, _edx: u32, dn_info: u32) callconv(fc) void {
render_blips_hook.callOriginal(.{ thisptr, _edx, dn_info });
// Draw our custom blips after the original ones
for (0..g_blip_count) |i| {
drawMinimapTexture(
g_blips[i].blip.texture,
g_blips[i].pos,
g_blips[i].blip.scale,
g_blips[i].gray,
);
}
}
// ClntObjMgrEnumVisibleObjects: __fastcall(callback ECX, context EDX)
const EnumVisFn = fn (u32, u32) callconv(fc) i32;
var enum_vis_hook: hook.Detour(EnumVisFn) = .{};
fn enumVisibleObjectsDetour(callback: u32, context: u32) callconv(fc) i32 {
// Clear tracked blips when the minimap's own callback is about to enumerate
if (callback == ADDR.ObjectEnumProc) {
g_blip_count = 0;
}
return enum_vis_hook.callOriginal(.{ callback, context });
}
// =============================================================================
// Lua API
// =============================================================================
// SetObjectTypeBlip(typeName [, texturePath [, scale]])
pub fn luaSetObjectTypeBlip(L: lua.State) callconv(fc) i32 {
if (!lua.isstring(L, 1)) {
lua.luaError(L, "Usage: SetObjectTypeBlip(type [, texture [, scale]])");
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, flightmaster, innkeeper, repair, stablemaster, trainer, vendor, mailbox");
return 0;
};
if (!lua.isstring(L, 2)) {
// Disable tracking for this type
if (mapping.is_go_type) {
for (&g_go_tracking) |*entry| {
if (entry.active and entry.go_type == mapping.value) {
entry.active = false;
break;
}
}
} else {
for (&g_flag_tracking) |*entry| {
if (entry.active and entry.flag == 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 };
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;
return 0;
}
}
for (&g_go_tracking) |*entry| {
if (!entry.active) {
entry.* = .{ .go_type = mapping.value, .blip = blip, .active = true };
return 0;
}
}
} else {
for (&g_flag_tracking) |*entry| {
if (entry.active and entry.flag == mapping.value) {
entry.blip = blip;
return 0;
}
}
for (&g_flag_tracking) |*entry| {
if (!entry.active) {
entry.* = .{ .flag = mapping.value, .blip = blip, .active = true };
return 0;
}
}
}
return 0;
}
const TypeMapping = struct { is_go_type: bool, value: u32 };
fn findTypeMapping(name: [*:0]const u8) ?TypeMapping {
const T = struct { n: []const u8, m: TypeMapping };
const table = [_]T{
.{ .n = "auctioneer", .m = .{ .is_go_type = false, .value = NPC_FLAG_AUCTIONEER } },
.{ .n = "banker", .m = .{ .is_go_type = false, .value = NPC_FLAG_BANKER } },
.{ .n = "battlemaster", .m = .{ .is_go_type = false, .value = NPC_FLAG_BATTLEMASTER } },
.{ .n = "flightmaster", .m = .{ .is_go_type = false, .value = NPC_FLAG_FLIGHTMASTER } },
.{ .n = "innkeeper", .m = .{ .is_go_type = false, .value = NPC_FLAG_INNKEEPER } },
.{ .n = "repair", .m = .{ .is_go_type = false, .value = NPC_FLAG_REPAIR } },
.{ .n = "stablemaster", .m = .{ .is_go_type = false, .value = NPC_FLAG_STABLEMASTER } },
.{ .n = "trainer", .m = .{ .is_go_type = false, .value = NPC_FLAG_TRAINER } },
.{ .n = "vendor", .m = .{ .is_go_type = false, .value = NPC_FLAG_VENDOR } },
.{ .n = "mailbox", .m = .{ .is_go_type = true, .value = GO_TYPE_MAILBOX } },
};
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 {
con.print("[minimapicons] Module loaded (stub)\n");
con.print("[minimapicons] Module loaded\n");
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
if (enum_proc_hook.attach(ADDR.ObjectEnumProc, &objectEnumProcDetour) != .ok) {
con.print("[minimapicons] Failed to hook ObjectEnumProc\n");
return;
}
if (render_blips_hook.attach(ADDR.RenderObjectBlips, &renderObjectBlipsDetour) != .ok) {
con.print("[minimapicons] Failed to hook RenderObjectBlips\n");
enum_proc_hook.detach();
return;
}
if (enum_vis_hook.attach(ADDR.EnumVisibleObjects, &enumVisibleObjectsDetour) != .ok) {
con.print("[minimapicons] Failed to hook EnumVisibleObjects\n");
render_blips_hook.detach();
enum_proc_hook.detach();
return;
}
con.print("[minimapicons] Hooks installed\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;
g_blip_count = 0;
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;