From 89b6082d73c8eff6640609b2db925e6ab10536f9 Mon Sep 17 00:00:00 2001 From: MarcelineVQ Date: Sat, 7 Mar 2026 20:29:09 -0800 Subject: [PATCH] Add DLL-side minimap NPC tracking with blip rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/interact/addon/Interact.toc | 1 - src/main.zig | 3 + src/minimapicons/addon/MinimapIcons.lua | 79 ++- src/minimapicons/minimapicons.zig | 783 +++++++++++++++++++++++- 4 files changed, 831 insertions(+), 35 deletions(-) diff --git a/src/interact/addon/Interact.toc b/src/interact/addon/Interact.toc index 5ea9460..003f536 100644 --- a/src/interact/addon/Interact.toc +++ b/src/interact/addon/Interact.toc @@ -3,5 +3,4 @@ ## Notes: Nearest NPC/object interaction and bulk looting ## Version: 1.0 - Interact.lua diff --git a/src/main.zig b/src/main.zig index a4bca1c..12591ca 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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)); diff --git a/src/minimapicons/addon/MinimapIcons.lua b/src/minimapicons/addon/MinimapIcons.lua index aac1795..36734b5 100644 --- a/src/minimapicons/addon/MinimapIcons.lua +++ b/src/minimapicons/addon/MinimapIcons.lua @@ -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") -- ============================================================================= diff --git a/src/minimapicons/minimapicons.zig b/src/minimapicons/minimapicons.zig index b774e20..deffadb 100644 --- a/src/minimapicons/minimapicons.zig +++ b/src/minimapicons/minimapicons.zig @@ -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;