diff --git a/DLL_README.md b/DLL_README.md index e344d93..e2b89c3 100644 --- a/DLL_README.md +++ b/DLL_README.md @@ -130,7 +130,7 @@ Makes interactable Objects and NPCs clickable through players and units. - Players blocking interactable NPCs (vendors, trainers, flight masters, bankers, etc.) or Objects (mailboxes, summoning portals, soulwells) become transparent to clicks - Units (pets, NPCs) blocking interactable Objects become transparent to clicks -- Non-interactable objects (other players' pets, random mobs) are not affected, this solely helps with player dogpiles +- PvP objects and Non-interactable objects (other players' pets, random mobs) are not affected, this solely helps with player dogpiles No configuration needed, install and forget. diff --git a/build.zig b/build.zig index 13520c4..d8d3572 100644 --- a/build.zig +++ b/build.zig @@ -24,6 +24,7 @@ const module_list = [_]ModuleDesc{ .{ .name = "customassets", .desc = "Enable loose file loading & permissive patch glob" }, .{ .name = "healtextfix", .desc = "Enable SuperWoW heal text fix" }, .{ .name = "bigcursor", .desc = "Enable big cursor module" }, + .{ .name = "clickthrough", .desc = "Enable GO click-through (enlarge GO model bounds)" }, .{ .name = "dpslog", .desc = "Enable structured combat log events for addons", .default = false }, }; diff --git a/src/addons.zig b/src/addons.zig index d564a44..d47260e 100644 --- a/src/addons.zig +++ b/src/addons.zig @@ -13,10 +13,10 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("console.zig"); +const logging = @import("logging.zig"); +var log: logging.Logger = .{}; const build_options = @import("build_options"); - // Build option convenience aliases const build_opts = struct { const interact = build_options.enable_interact; @@ -310,7 +310,7 @@ fn setupAddonsDetour(mgr_ptr: u32) callconv(hook.cc.fastcall) void { const active = if (mod.is_active) |f| f() else true; if (active) { const name: [*:0]const u8 = comptime (mod.addon_name.? ++ "\x00").ptr; - con.fmt("[addons] registering embedded addon: {s}\n", .{name}); + log.fmt("registering embedded addon: {s}\n", .{name}); callLoadAddonTOC(name); if (mod.hidden) { @@ -332,12 +332,12 @@ fn hideAddonFromList(name: [*:0]const u8) void { const node_name = hook.readMem([*:0]const u8, node + 0x14); if (std.mem.orderZ(u8, node_name, name) == .eq) { hook.writeMem(node + 0x29, &[_]u8{1}); - con.fmt("[addons] hidden addon {s} excluded from list (+0x29=1)\n", .{name}); + log.fmt("hidden addon {s} excluded from list (+0x29=1)\n", .{name}); return; } node = hook.readMem(u32, list_base + 4 + node); } - con.fmt("[addons] WARNING: could not find {s} in addon list\n", .{name}); + log.fmt("WARNING: could not find {s} in addon list\n", .{name}); } fn callLoadAddonTOC(addon_name: [*:0]const u8) void { @@ -359,6 +359,7 @@ const has_addons = blk: { pub fn install() void { if (!has_addons) return; + log = logging.Logger.open("addons", .console); _ = setup_addons_hook.attach(0x51C740, &setupAddonsDetour); } diff --git a/src/bigcursor/bigcursor.zig b/src/bigcursor/bigcursor.zig index ea38747..721827e 100644 --- a/src/bigcursor/bigcursor.zig +++ b/src/bigcursor/bigcursor.zig @@ -9,7 +9,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const mod_mutex = @import("../mutex.zig"); const WINAPI = std.builtin.CallingConvention.winapi; @@ -18,49 +18,10 @@ pub const module_name: [*:0]const u8 = "bigcursor"; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +var log: logging.Logger = .{}; -// ============================================================================= -// File logging (survives crashes — console closes too fast) -// ============================================================================= - -extern "kernel32" fn CreateFileA(name: [*:0]const u8, access: u32, share: u32, sa: ?*anyopaque, disp: u32, flags: u32, template: ?*anyopaque) callconv(WINAPI) ?*anyopaque; -extern "kernel32" fn WriteFile(handle: *anyopaque, buf: [*]const u8, len: u32, written: ?*u32, overlapped: ?*anyopaque) callconv(WINAPI) i32; -extern "kernel32" fn FlushFileBuffers(handle: *anyopaque) callconv(WINAPI) i32; extern "kernel32" fn CloseHandle(handle: *anyopaque) callconv(WINAPI) i32; -const INVALID_HANDLE: usize = 0xFFFFFFFF; -var g_logfile: ?*anyopaque = null; - -fn logInit() void { - const h = CreateFileA("bigcursor_debug.log", 0x40000000, 1, null, 2, 0x80, null); // GENERIC_WRITE, FILE_SHARE_READ, CREATE_ALWAYS, NORMAL - if (h) |handle| { - if (@intFromPtr(handle) != INVALID_HANDLE) { - g_logfile = handle; - } - } -} - -fn logDeinit() void { - if (g_logfile) |h| { - _ = CloseHandle(h); - g_logfile = null; - } -} - -fn log(msg: []const u8) void { - con.print(msg); - if (g_logfile) |h| { - _ = WriteFile(h, msg.ptr, @intCast(msg.len), null, null); - _ = FlushFileBuffers(h); - } -} - -fn logFmt(comptime f: []const u8, args: anytype) void { - var buf: [512]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, f, args) catch return; - log(msg); -} - // ============================================================================= // Scale2x — edge-aware pixel-art 2x upscaler (EPX/AdvMAME2x) // @@ -567,7 +528,7 @@ pub fn luaSetCursorScale(L: *anyopaque) callconv(.c) u32 { g_scale_f = clamped; cacheClear(); g_hcursor = null; - logFmt("[bigcursor] scale set to {d:.2}\n", .{g_scale_f}); + log.fmt("scale set to {d:.2}\n", .{g_scale_f}); } return 0; } @@ -618,17 +579,17 @@ pub fn lateInit() void { if (hooks_installed) return; const vt = getD3D9VTable() orelse { - con.print("[bigcursor] D3D9 device not available yet\n"); + log.print("D3D9 device not available yet\n"); return; }; d3d9_vtable = vt; if (!patchVtableEntry(vt, VT_SetCursorProperties, @intFromPtr(&hkSetCursorProperties), &orig_set_cursor_props)) { - con.print("[bigcursor] failed to hook SetCursorProperties\n"); + log.print("failed to hook SetCursorProperties\n"); return; } if (!patchVtableEntry(vt, VT_ShowCursor, @intFromPtr(&hkShowCursor), &orig_show_cursor)) { - con.print("[bigcursor] failed to hook ShowCursor\n"); + log.print("failed to hook ShowCursor\n"); restoreVtableEntry(vt, VT_SetCursorProperties, orig_set_cursor_props); return; } @@ -638,7 +599,7 @@ pub fn lateInit() void { // Register CVar for persistence (tenths: 15 = 1.5x, 20 = 2.0x) _ = registerCVar(CVAR_NAME, 0, 0, "12", 0, 1, 0, 0); g_scale_f = readCVarScaleInit(); - logFmt("[bigcursor] D3D9 hooks installed (scale={d:.1}x)\n", .{g_scale_f}); + log.fmt("D3D9 hooks installed (scale={d:.1}x)\n", .{g_scale_f}); } // ============================================================================= @@ -650,13 +611,13 @@ pub fn isActive() bool { } pub fn installHooks() void { - logInit(); - log("[bigcursor] 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; + + log = logging.Logger.open(module_name, .console); } pub fn removeHooks() void { @@ -672,8 +633,8 @@ pub fn removeHooks() void { g_hcursor = null; if (g_is_hook_owner) { + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; - logDeinit(); } diff --git a/src/clickthrough/clickthrough.zig b/src/clickthrough/clickthrough.zig index 1b28605..6c7ca01 100644 --- a/src/clickthrough/clickthrough.zig +++ b/src/clickthrough/clickthrough.zig @@ -22,6 +22,14 @@ pub const module_name: [*:0]const u8 = "clickthrough"; const ADDR_WorldIntersectionTest: usize = 0x480DF0; const ADDR_GetObjectByGUID: usize = 0x464870; +// Map identification (Map.dbc lookup) +const OBJECT_MANAGER_PTR: usize = 0x00B41414; +const OBJMGR_MAP_ID_OFFSET: usize = 0xCC; +const MAP_DBC_DATA: usize = 0x00C0DAA8; +const MAP_DBC_MAX: usize = 0x00C0DAAC; +const MAP_DBC_MAP_TYPE_OFFSET: usize = 0x08; +const MAP_TYPE_BATTLEGROUND: u32 = 3; + // ============================================================================= // HitTestResult layout // ============================================================================= @@ -34,6 +42,8 @@ const HIT_RESULT_SIZE: usize = 0x34; const OBJ_DESCRIPTOR: usize = 0x08; const OBJ_TYPE_MASK_OFFSET: usize = 0x08; // at *(*(obj+8)+8) +// Object type + descriptor field offsets + // Type masks const TYPE_UNIT: u32 = 0x09; const TYPE_PLAYER: u32 = 0x19; @@ -49,14 +59,34 @@ const OBJ_TYPE: usize = 0x14; const OBJ_TYPE_UNIT: u32 = 3; const OBJ_TYPE_GO: u32 = 5; const DESC_NPC_FLAGS: usize = 0x93 * 4; // UNIT_NPC_FLAGS = OBJECT_END(0x06) + 0x8D = 0x93 +const DESC_ENTRY: usize = 0x03 * 4; // OBJECT_FIELD_ENTRY const ADDR_CallSpellCastHandler: usize = 0x5F8800; +// ============================================================================= +// Battleground detection +// ============================================================================= + +/// Check if the current map is a battleground by reading Map.dbc mapType. +fn isInBattleground() bool { + const obj_mgr = hook.readMem(u32, OBJECT_MANAGER_PTR); + if (obj_mgr == 0) return false; + const map_id = hook.readMem(u32, obj_mgr + OBJMGR_MAP_ID_OFFSET); + const dbc_max = hook.readMem(u32, MAP_DBC_MAX); + if (map_id > dbc_max) return false; + const table_base = hook.readMem(u32, MAP_DBC_DATA); + if (table_base == 0) return false; + const row = hook.readMem(u32, table_base + map_id * 4); + if (row == 0) return false; + const map_type = hook.readMem(u32, row + MAP_DBC_MAP_TYPE_OFFSET); + return map_type == MAP_TYPE_BATTLEGROUND; +} + fn getObjectByGUID(guid_lo: u32, guid_hi: u32) u32 { if (guid_lo == 0 and guid_hi == 0) return 0; return hook.call(fn (u32, u32) callconv(hook.cc.stdcall) u32, ADDR_GetObjectByGUID, .{ guid_lo, guid_hi }); } -/// Check if the GUID refers to an interactable GO (mailbox, soulwell, etc.) +/// Check if the GUID refers to an interactable GO. fn isInteractableGO(guid_lo: u32, guid_hi: u32) bool { const obj = getObjectByGUID(guid_lo, guid_hi); if (obj == 0) return false; @@ -109,7 +139,8 @@ fn worldIntersectDetour(world_frame: u32, ray_start: u32, ray_end: u32, flags: u // Call original with caller's flags const hit_type = wit_hook.callOriginal(.{ world_frame, ray_start, ray_end, flags, hit_result }); - if (!g_is_hook_owner or hit_result == 0 or hit_type != 2) return hit_type; + // No click-through in battlegrounds + if (!g_is_hook_owner or hit_result == 0 or hit_type != 2 or isInBattleground()) return hit_type; // hitType 2 = object hit. Check if it's a unit/player. const buf_lo = hook.readMem(u32, hit_result + HIT_GUID_LO); @@ -161,7 +192,6 @@ pub fn isActive() bool { } pub fn installHooks() void { - logging.print("[clickthrough] Module loaded\n"); const result = mod_mutex.acquire(module_name); g_mutex = result.handle; @@ -171,7 +201,7 @@ pub fn installHooks() void { log = logging.Logger.open(module_name, .both); _ = wit_hook.attach(ADDR_WorldIntersectionTest, &worldIntersectDetour); - log.fmt("[clickthrough] WorldIntersectionTest hooked at 0x{x}\n", .{ADDR_WorldIntersectionTest}); + log.fmt("WorldIntersectionTest hooked at 0x{x}\n", .{ADDR_WorldIntersectionTest}); } pub fn removeHooks() void { diff --git a/src/console.zig b/src/console.zig deleted file mode 100644 index 0609a38..0000000 --- a/src/console.zig +++ /dev/null @@ -1,65 +0,0 @@ -//! Debug console - compiles out entirely in non-Debug builds. -//! -//! Usage from any module: -//! const con = @import("../console.zig"); // or appropriate relative path -//! con.print("[markers] loaded\n"); -//! con.fmt("[markers] pos = {d}, {d}, {d}\n", .{ x, y, z }); - -const std = @import("std"); -const builtin = @import("builtin"); - -const debug = builtin.mode == .Debug; - -const WINAPI = std.builtin.CallingConvention.winapi; - -const win = if (debug) struct { - extern "kernel32" fn AllocConsole() callconv(WINAPI) i32; - extern "kernel32" fn FreeConsole() callconv(WINAPI) i32; - extern "kernel32" fn SetConsoleTitleA(title: [*:0]const u8) callconv(WINAPI) i32; - extern "kernel32" fn GetStdHandle(nStdHandle: u32) callconv(WINAPI) ?*anyopaque; - extern "kernel32" fn WriteConsoleA( - hOut: *anyopaque, - buf: [*]const u8, - len: u32, - written: ?*u32, - reserved: ?*anyopaque, - ) callconv(WINAPI) i32; - - const STD_OUTPUT_HANDLE: u32 = 0xFFFFFFF5; - var handle: ?*anyopaque = null; -} else void; - -pub fn init() void { - if (debug) { - _ = win.AllocConsole(); - _ = win.SetConsoleTitleA("weirdutils"); - win.handle = win.GetStdHandle(win.STD_OUTPUT_HANDLE); - print("[weirdutils] Console attached\n"); - } -} - -pub fn deinit() void { - if (debug) { - if (win.handle != null) { - print("[weirdutils] Detaching\n"); - _ = win.FreeConsole(); - win.handle = null; - } - } -} - -pub fn print(msg: []const u8) void { - if (debug) { - if (win.handle) |h| { - _ = win.WriteConsoleA(h, msg.ptr, @intCast(msg.len), null, null); - } - } -} - -pub fn fmt(comptime f: []const u8, args: anytype) void { - if (debug) { - var buf: [512]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, f, args) catch return; - print(msg); - } -} diff --git a/src/customassets/customassets.zig b/src/customassets/customassets.zig index 2cefbe4..da88a9c 100644 --- a/src/customassets/customassets.zig +++ b/src/customassets/customassets.zig @@ -12,7 +12,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); // ============================================================================= // Windows API (project-specific - not in hook lib) @@ -176,7 +176,7 @@ pub fn looseFilesLookup(game_path_ptr: u32, output_buffer_ptr: u32) bool { const disk_path = loose_files.get(norm_buf[0..path.len]) orelse return false; - con.fmt("[customassets] loose hit: \"{s}\"\n", .{path}); + log.fmt("loose hit: \"{s}\"\n", .{path}); if (output_buffer_ptr != 0) { const disk_len = cStrLen(disk_path.ptr); @@ -260,19 +260,21 @@ pub const module_name: [*:0]const u8 = "customassets"; var installed: bool = false; 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; } pub fn installHooks() void { - con.print("[customassets] 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; + log = logging.Logger.open(module_name, .console); + applyGlobPatch(); applyLooseFilePatches(); looseFilesInit(); @@ -288,6 +290,7 @@ pub fn removeHooks() void { } if (g_is_hook_owner) { + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/dpslog/dpslog.zig b/src/dpslog/dpslog.zig index 972f837..07bb8d0 100644 --- a/src/dpslog/dpslog.zig +++ b/src/dpslog/dpslog.zig @@ -1,33 +1,569 @@ -//! DPS log module. +//! DPS log module — structured combat log events. //! -//! Provides structured Lua objects for combat log events so addons can read -//! parsed fields directly instead of re-parsing the combat log string. +//! Hooks packet handlers for SMSG_SPELLNONMELEEDAMAGELOG, +//! SMSG_PERIODICAURALOG, SMSG_SPELLHEALLOG, and SMSG_ATTACKERSTATEUPDATE +//! to fire custom Lua events with structured numeric/string args, so addons +//! can skip string parsing. //! -//! TODO: Hook combat log event dispatch, build Lua tables per event type. +//! Custom events (registered in unused event table slots): +//! COMBAT_LOG_SPELL_DMG (551) — spell damage (direct) +//! COMBAT_LOG_PERIODIC (552) — periodic damage/heal ticks +//! COMBAT_LOG_HEAL (553) — direct heals +//! COMBAT_LOG_MELEE (554) — melee damage (auto-attack + special) +//! +//! Lua args for COMBAT_LOG_SPELL_DMG: +//! arg1=targetGUID, arg2=casterGUID, arg3=spellId, arg4=damage, +//! arg5=school, arg6=absorb, arg7=resist, arg8=blocked, arg9=hitInfo +//! +//! Lua args for COMBAT_LOG_PERIODIC: +//! arg1=targetGUID, arg2=casterGUID, arg3=spellId, arg4=amount, +//! arg5=school, arg6=absorb, arg7=resist, arg8=auraType, arg9=powerType +//! +//! Lua args for COMBAT_LOG_HEAL: +//! arg1=targetGUID, arg2=casterGUID, arg3=spellId, arg4=healAmount, +//! arg5=isCrit +//! +//! Lua args for COMBAT_LOG_MELEE: +//! arg1=targetGUID, arg2=attackerGUID, arg3=totalDamage, arg4=school, +//! arg5=absorb, arg6=resist, arg7=blocked, arg8=hitInfo, arg9=victimState -const con = @import("../console.zig"); +const std = @import("std"); +const hook = @import("zhook"); +const logging = @import("../logging.zig"); const mod_mutex = @import("../mutex.zig"); pub const module_name: [*:0]const u8 = "dpslog"; 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; } +// ============================================================================= +// Custom event IDs & names +// ============================================================================= + +/// We register events at slots 551..554 (beyond nampower's 549/550). +/// The event name string pointer array has 4-byte stride per event ID. +/// Nampower: 549→0xBE1A2C, 550→0xBE1A30 → base = 0xBE1198. +/// 551: 0xBE1A34 552: 0xBE1A38 553: 0xBE1A3C 554: 0xBE1A40 +const EVENT_SPELL_DMG: u32 = 551; +const EVENT_PERIODIC: u32 = 552; +const EVENT_HEAL: u32 = 553; +const EVENT_MELEE: u32 = 554; + +/// Derived from nampower: event 549 string ptr is at 0xBE1A2C → base = 0xBE1198. +const EVENT_STR_PTR_BASE: u32 = 0xBE1198; + +/// Static event name strings — must outlive the process. +const event_name_spell_dmg: [*:0]const u8 = "COMBAT_LOG_SPELL_DMG"; +const event_name_periodic: [*:0]const u8 = "COMBAT_LOG_PERIODIC"; +const event_name_heal: [*:0]const u8 = "COMBAT_LOG_HEAL"; +const event_name_melee: [*:0]const u8 = "COMBAT_LOG_MELEE"; + +// TODO: Detect nampower via GetModuleHandleA("nampower.dll") and skip +// overlapping SpellNonMeleeDmgLog/PeriodicAuraLog hooks if loaded (nampower +// fires SPELL_DAMAGE_EVENT_SELF/OTHER for those). Low priority — most users +// won't run both DLLs simultaneously. + +// ============================================================================= +// CDataStore helpers — direct memory reads from packet buffer +// ============================================================================= +// +// CDataStore layout (from nampower cdatastore.hpp): +// +0x00: vtable +// +0x04: m_buffer (u8 pointer) +// +0x08: m_base +// +0x0C: m_alloc +// +0x10: m_size +// +0x14: m_read (current read position) +// +// NOTE: the nampower header shows m_buffer at +0x00 after vtable, but +// CDataStore is a virtual class — vtable is +0x00, members follow. +// Let's match nampower's actual memory layout which treats it as: +// m_buffer at offset after vtable. In the C++ class, the first member +// after the vtable IS m_buffer. So: +// +0x00: vtable ptr +// +0x04: m_buffer +// +0x08: m_base +// +0x0C: m_alloc +// +0x10: m_size +// +0x14: m_read + +const CDS_BUFFER = 0x04; +const CDS_BASE = 0x08; +const CDS_SIZE = 0x10; +const CDS_READ = 0x14; + +fn cdsGetRead(cds: u32) u32 { + return hook.readMem(u32, cds + CDS_READ); +} + +fn cdsSetRead(cds: u32, pos: u32) void { + @as(*align(1) u32, @ptrFromInt(cds + CDS_READ)).* = pos; +} + +fn cdsBuffer(cds: u32) u32 { + return hook.readMem(u32, cds + CDS_BUFFER); +} + +fn cdsBase(cds: u32) u32 { + return hook.readMem(u32, cds + CDS_BASE); +} + +fn cdsSize(cds: u32) u32 { + return hook.readMem(u32, cds + CDS_SIZE); +} + +/// Read a T from the CDataStore at the current read position, advancing m_read. +fn cdsGet(comptime T: type, cds: u32) ?T { + const rpos = cdsGetRead(cds); + const end = rpos + @sizeOf(T); + if (end > cdsSize(cds)) return null; + const buf = cdsBuffer(cds); + const base = cdsBase(cds); + const val = @as(*align(1) const T, @ptrFromInt(buf -% base + rpos)).*; + cdsSetRead(cds, end); + return val; +} + +/// Read a packed GUID from the CDataStore. Returns null on read error. +/// Packed GUID format: 1-byte mask, then one byte per set bit in the mask. +fn cdsGetPackedGuid(cds: u32) ?u64 { + const mask = cdsGet(u8, cds) orelse return null; + var guid: u64 = 0; + inline for (0..8) |i| { + if (mask & (@as(u8, 1) << @intCast(i)) != 0) { + const b = cdsGet(u8, cds) orelse return null; + guid |= @as(u64, b) << @intCast(i * 8); + } + } + return guid; +} + +// ============================================================================= +// GUID → hex string conversion +// ============================================================================= + +/// Convert a u64 GUID to a 0x-prefixed hex string. Returns pointer to static buffer. +/// Uses two alternating buffers so we can format target + caster in one call. +var guid_bufs: [4][20]u8 = undefined; +var guid_buf_idx: u2 = 0; + +fn guidToString(guid: u64) [*:0]const u8 { + const idx = guid_buf_idx; + guid_buf_idx +%= 1; + const buf = &guid_bufs[idx]; + + buf[0] = '0'; + buf[1] = 'x'; + const hex = "0123456789ABCDEF"; + inline for (0..16) |i| { + const shift: u6 = @intCast((15 - i) * 4); + buf[2 + i] = hex[@intCast((guid >> shift) & 0xF)]; + } + buf[18] = 0; + return @ptrCast(buf[0..18 :0]); +} + +// ============================================================================= +// SignalEventParam — fire a Lua event with structured args +// ============================================================================= +// +// SignalEventParam (0x703F50): __cdecl(eventId, fmtStr, ...) +// The format string uses %s/%d to push args as Lua event args (arg1, arg2, ...). +// We use typed wrapper functions since Zig can't express variadic cdecl generically. + +/// Fire spell damage event: "%s%s%d%d%d%d%d%d%d" +/// args: targetGuid, casterGuid, spellId, damage, school, absorb, resist, blocked, hitInfo +fn fireSpellDamageEvent(event_id: u32, target: [*:0]const u8, caster: [*:0]const u8, spell_id: u32, damage: u32, school: u32, absorb: u32, resist: i32, blocked: u32, hit_info: u32) void { + const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, i32, u32, u32) callconv(hook.cc.cdecl) void; + const f: *const SignalFn = @ptrFromInt(0x703F50); + // Format: targetGuid(s), casterGuid(s), spellId(d), damage(d), school(d), absorb(d), resist(d), blocked(d), hitInfo(d) + @call(.auto, f, .{ event_id, "%s%s%d%d%d%d%d%d%d", target, caster, spell_id, damage, school, absorb, resist, blocked, hit_info }); +} + +/// Fire periodic event: "%s%s%d%d%d%d%d%d%d" +/// Same format as spell damage but with auraType instead of hitInfo. +fn firePeriodicEvent(event_id: u32, target: [*:0]const u8, caster: [*:0]const u8, spell_id: u32, amount: u32, school: u32, absorb: u32, resist: i32, aura_type: u32, power_type: u32) void { + const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, i32, u32, u32) callconv(hook.cc.cdecl) void; + const f: *const SignalFn = @ptrFromInt(0x703F50); + @call(.auto, f, .{ event_id, "%s%s%d%d%d%d%d%d%d", target, caster, spell_id, amount, school, absorb, resist, aura_type, power_type }); +} + +/// Fire heal event: "%s%s%d%d%d" +/// args: targetGuid, casterGuid, spellId, healAmount, isCrit +fn fireHealEvent(event_id: u32, target: [*:0]const u8, caster: [*:0]const u8, spell_id: u32, amount: u32, is_crit: u32) void { + const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32) callconv(hook.cc.cdecl) void; + const f: *const SignalFn = @ptrFromInt(0x703F50); + @call(.auto, f, .{ event_id, "%s%s%d%d%d", target, caster, spell_id, amount, is_crit }); +} + +// ============================================================================= +// Hook: FrameScript_CreateEvents (0x703D90) +// Expands max event count to accommodate our custom event slots. +// ============================================================================= + +/// FrameScript_CreateEvents signature: __fastcall(ECX=param1, EDX=maxEventId) +/// Nampower hooks this as (int param_1, uint32_t maxEventId) — the first two +/// args in fastcall go to ECX and EDX. +var create_events_hook: hook.Detour(fn (u32, u32) callconv(hook.cc.fastcall) void) = .{}; + +fn createEventsDetour(param1: u32, max_event_id: u32) callconv(hook.cc.fastcall) void { + // Expand to at least 556 (we use slots 551..554, need maxId > 554) + var new_max = max_event_id; + if (new_max < 556) { + new_max = 556; + log.fmt("FrameScript_CreateEvents: expanded maxEventId {d} -> {d}\n", .{ max_event_id, new_max }); + } + create_events_hook.callOriginal(.{ param1, new_max }); +} + +// ============================================================================= +// Hook: SpellNonMeleeDmgLogHandler (0x5E85E0) +// Packet: SMSG_SPELLNONMELEEDAMAGELOG +// Convention: FastCall(unk, opCode, unk2, CDataStore*) +// ============================================================================= + +const FastCallPacketHandlerFn = fn (u32, u32, u32, u32) callconv(hook.cc.fastcall) u32; + +var spell_dmg_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn spellNonMeleeDmgLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + // Save read position + const saved_read = cdsGetRead(cds); + + // Parse SMSG_SPELLNONMELEEDAMAGELOG packet + const target_guid = cdsGetPackedGuid(cds); + const caster_guid = cdsGetPackedGuid(cds); + const spell_id = cdsGet(u32, cds); + const damage = cdsGet(u32, cds); + const school = cdsGet(u8, cds); + const absorb = cdsGet(u32, cds); + const resist = cdsGet(i32, cds); + const _periodic_log = cdsGet(u8, cds); + const _unused = cdsGet(u8, cds); + const blocked = cdsGet(u32, cds); + const hit_info = cdsGet(u32, cds); + + // Restore read position before calling original + cdsSetRead(cds, saved_read); + + // Fire event if all fields parsed successfully + if (target_guid != null and caster_guid != null and spell_id != null and + damage != null and school != null and absorb != null and resist != null and + blocked != null and hit_info != null) + { + const target_str = guidToString(target_guid.?); + const caster_str = guidToString(caster_guid.?); + + fireSpellDamageEvent( + EVENT_SPELL_DMG, + target_str, + caster_str, + spell_id.?, + damage.?, + @as(u32, school.?), + absorb.?, + resist.?, + blocked.?, + hit_info.?, + ); + } + + _ = _periodic_log; + _ = _unused; + + return spell_dmg_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +// ============================================================================= +// Hook: PeriodicAuraLogHandler (0x626DD0) +// Packet: SMSG_PERIODICAURALOG +// Convention: FastCall(unk, opCode, unk2, CDataStore*) +// ============================================================================= + +var periodic_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn periodicAuraLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + const saved_read = cdsGetRead(cds); + + // Parse SMSG_PERIODICAURALOG packet + const target_guid = cdsGetPackedGuid(cds); + const caster_guid = cdsGetPackedGuid(cds); + const spell_id = cdsGet(u32, cds); + const count = cdsGet(u32, cds); + + if (target_guid != null and caster_guid != null and spell_id != null and count != null) { + const target_str = guidToString(target_guid.?); + const caster_str = guidToString(caster_guid.?); + + // Process first aura entry (count is almost always 1) + const aura_type = cdsGet(u32, cds); + if (aura_type) |at| { + switch (at) { + 3, 89 => { + // PERIODIC_DAMAGE / PERIODIC_DAMAGE_PERCENT + const amount = cdsGet(u32, cds) orelse 0; + const spell_school = cdsGet(u32, cds) orelse 0; + const absorb = cdsGet(u32, cds) orelse 0; + const resist = cdsGet(i32, cds) orelse 0; + + firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, spell_school, absorb, resist, at, 0); + }, + 8, 20 => { + // PERIODIC_HEAL / OBS_MOD_HEALTH + const amount = cdsGet(u32, cds) orelse 0; + + firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, 0, 0, 0, at, 0); + }, + 21, 24 => { + // OBS_MOD_MANA / PERIODIC_ENERGIZE + const power_type = cdsGet(u32, cds) orelse 0; + const amount = cdsGet(u32, cds) orelse 0; + + firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, 0, 0, 0, at, power_type); + }, + 64 => { + // PERIODIC_MANA_LEECH + const power_type = cdsGet(u32, cds) orelse 0; + const amount = cdsGet(u32, cds) orelse 0; + _ = cdsGet(u32, cds); // multiplier (skip) + + firePeriodicEvent(EVENT_PERIODIC, target_str, caster_str, spell_id.?, amount, 0, 0, 0, at, power_type); + }, + else => {}, + } + } + } + + // Restore read position + cdsSetRead(cds, saved_read); + + return periodic_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +// ============================================================================= +// Hook: SpellHealLogHandler (0x5E89C0) +// Packet: SMSG_SPELLHEALLOG (opcode 0x150) +// Convention: FastCall(unk, opCode, unk2, CDataStore*) +// Packet format: targetGuid(PackedGuid), casterGuid(PackedGuid), +// spellId(u32), healAmount(u32), isCrit(u8) +// ============================================================================= + +var heal_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn spellHealLogDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + const saved_read = cdsGetRead(cds); + + // Parse SMSG_SPELLHEALLOG packet + const target_guid = cdsGetPackedGuid(cds); + const caster_guid = cdsGetPackedGuid(cds); + const spell_id = cdsGet(u32, cds); + const heal_amount = cdsGet(u32, cds); + const is_crit_raw = cdsGet(u8, cds); + + cdsSetRead(cds, saved_read); + + if (target_guid != null and caster_guid != null and spell_id != null and + heal_amount != null and is_crit_raw != null) + { + const target_str = guidToString(target_guid.?); + const caster_str = guidToString(caster_guid.?); + const is_crit: u32 = if (is_crit_raw.? != 0) 1 else 0; + + fireHealEvent(EVENT_HEAL, target_str, caster_str, spell_id.?, heal_amount.?, is_crit); + } + + return heal_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +// ============================================================================= +// Hook: MeleeDispatcher (0x6255B0) +// Shared packet handler for opcodes 0x143-0x14A. We filter for 0x14A only. +// Packet: SMSG_ATTACKERSTATEUPDATE (opcode 0x14A) +// Convention: FastCall(unk, opCode, unk2, CDataStore*) +// +// Packet format: +// hitInfo(u32), attackerGuid(PackedGuid), targetGuid(PackedGuid), +// totalDamage(u32), subDamageCount(u8), +// per sub: school(u32), damageFP(f32), damage(u32), absorb(u32), resist(u32), +// victimState(u32), unknown1(u32), unknown2(u32), spellId(u32), +// if hitInfo & 0x1: blocked(u32) +// ============================================================================= + +const OPCODE_ATTACKERSTATEUPDATE: u32 = 0x14A; + +var melee_hook: hook.Detour(FastCallPacketHandlerFn) = .{}; + +fn meleeDispatcherDetour(unk: u32, opcode: u32, unk2: u32, cds: u32) callconv(hook.cc.fastcall) u32 { + asm volatile ("" ::: .{ .esi = true, .edi = true, .ebx = true }); + + // Only intercept SMSG_ATTACKERSTATEUPDATE; pass all other opcodes through + if (opcode == OPCODE_ATTACKERSTATEUPDATE) { + parseMeleePacket(cds); + } + + return melee_hook.callOriginal(.{ unk, opcode, unk2, cds }); +} + +fn parseMeleePacket(cds: u32) void { + const saved_read = cdsGetRead(cds); + defer cdsSetRead(cds, saved_read); + + const hit_info = cdsGet(u32, cds) orelse return; + const attacker_guid = cdsGetPackedGuid(cds) orelse return; + const target_guid = cdsGetPackedGuid(cds) orelse return; + const total_damage = cdsGet(u32, cds) orelse return; + const sub_count = cdsGet(u8, cds) orelse return; + + // Read first sub-damage entry for school/absorb/resist + var school: u32 = 0; + var absorb: u32 = 0; + var resist: u32 = 0; + + if (sub_count > 0) { + school = cdsGet(u32, cds) orelse 0; + _ = cdsGet(f32, cds); // damageFP (skip) + _ = cdsGet(u32, cds); // damage (skip, use totalDamage) + absorb = cdsGet(u32, cds) orelse 0; + resist = cdsGet(u32, cds) orelse 0; + + // Skip remaining sub-damage entries + var i: u8 = 1; + while (i < sub_count) : (i += 1) { + _ = cdsGet(u32, cds); // school + _ = cdsGet(f32, cds); // damageFP + _ = cdsGet(u32, cds); // damage + _ = cdsGet(u32, cds); // absorb + _ = cdsGet(u32, cds); // resist + } + } + + const victim_state = cdsGet(u32, cds) orelse return; + _ = cdsGet(u32, cds); // unknown1 + _ = cdsGet(u32, cds); // unknown2 + _ = cdsGet(u32, cds); // spellId (0 for melee, nonzero for special attacks) + + var blocked: u32 = 0; + if (hit_info & 0x1 != 0) { + blocked = cdsGet(u32, cds) orelse 0; + } + + const target_str = guidToString(target_guid); + const attacker_str = guidToString(attacker_guid); + + // Fire melee event: same 9-arg format as spell damage + // arg1=target, arg2=attacker, arg3=totalDamage, arg4=school, + // arg5=absorb, arg6=resist, arg7=blocked, arg8=hitInfo, arg9=victimState + fireMeleeEvent(EVENT_MELEE, target_str, attacker_str, total_damage, school, absorb, resist, blocked, hit_info, victim_state); +} + +/// Fire melee event: "%s%s%d%d%d%d%d%d%d" +/// args: targetGuid, attackerGuid, totalDamage, school, absorb, resist, blocked, hitInfo, victimState +fn fireMeleeEvent(event_id: u32, target: [*:0]const u8, attacker: [*:0]const u8, damage: u32, school: u32, absorb: u32, resist: u32, blocked: u32, hit_info: u32, victim_state: u32) void { + const SignalFn = fn (u32, [*:0]const u8, [*:0]const u8, [*:0]const u8, u32, u32, u32, u32, u32, u32, u32) callconv(hook.cc.cdecl) void; + const f: *const SignalFn = @ptrFromInt(0x703F50); + @call(.auto, f, .{ event_id, "%s%s%d%d%d%d%d%d%d", target, attacker, damage, school, absorb, resist, blocked, hit_info, victim_state }); +} + +// ============================================================================= +// Custom event registration — write event name ptrs to unused table slots +// ============================================================================= + +fn registerCustomEvents() void { + // Each event slot's string name pointer is at EVENT_STR_PTR_BASE + eventId * 4 + // These are in .data (RW), so writeProtected is needed since the event name + // array is in .rdata/.data boundary area. + + const spell_dmg_addr = EVENT_STR_PTR_BASE + EVENT_SPELL_DMG * 4; + const periodic_addr = EVENT_STR_PTR_BASE + EVENT_PERIODIC * 4; + const heal_addr = EVENT_STR_PTR_BASE + EVENT_HEAL * 4; + const melee_addr = EVENT_STR_PTR_BASE + EVENT_MELEE * 4; + + const spell_dmg_ptr: u32 = @intFromPtr(event_name_spell_dmg); + const periodic_ptr: u32 = @intFromPtr(event_name_periodic); + const heal_ptr: u32 = @intFromPtr(event_name_heal); + const melee_ptr: u32 = @intFromPtr(event_name_melee); + + hook.writeProtected(spell_dmg_addr, std.mem.asBytes(&spell_dmg_ptr)); + hook.writeProtected(periodic_addr, std.mem.asBytes(&periodic_ptr)); + hook.writeProtected(heal_addr, std.mem.asBytes(&heal_ptr)); + hook.writeProtected(melee_addr, std.mem.asBytes(&melee_ptr)); + + log.fmt("Registered events: {d}={s}, {d}={s}, {d}={s}, {d}={s}\n", .{ + EVENT_SPELL_DMG, std.mem.span(event_name_spell_dmg), + EVENT_PERIODIC, std.mem.span(event_name_periodic), + EVENT_HEAL, std.mem.span(event_name_heal), + EVENT_MELEE, std.mem.span(event_name_melee), + }); +} + +// ============================================================================= +// Install / remove +// ============================================================================= + pub fn installHooks() void { - con.print("[dpslog] Module loaded (stub)\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; + + log = logging.Logger.open(module_name, .console); + + // Register custom events (overwrite event name string pointers) + registerCustomEvents(); + + // Hook FrameScript_CreateEvents to expand event count + if (create_events_hook.attach(0x703D90, &createEventsDetour) != .ok) { + log.print("FAILED to hook FrameScript_CreateEvents\n"); + } else { + log.print("Hooked FrameScript_CreateEvents\n"); + } + + if (spell_dmg_hook.attach(0x5E85E0, &spellNonMeleeDmgLogDetour) != .ok) { + log.print("FAILED to hook SpellNonMeleeDmgLogHandler\n"); + } else { + log.print("Hooked SpellNonMeleeDmgLogHandler\n"); + } + + if (periodic_hook.attach(0x626DD0, &periodicAuraLogDetour) != .ok) { + log.print("FAILED to hook PeriodicAuraLogHandler\n"); + } else { + log.print("Hooked PeriodicAuraLogHandler\n"); + } + + if (heal_hook.attach(0x5E89C0, &spellHealLogDetour) != .ok) { + log.print("FAILED to hook SpellHealLogHandler\n"); + } else { + log.print("Hooked SpellHealLogHandler\n"); + } + + if (melee_hook.attach(0x6255B0, &meleeDispatcherDetour) != .ok) { + log.print("FAILED to hook MeleeDispatcher\n"); + } else { + log.print("Hooked MeleeDispatcher\n"); + } } pub fn removeHooks() void { if (g_is_hook_owner) { + melee_hook.detach(); + heal_hook.detach(); + periodic_hook.detach(); + spell_dmg_hook.detach(); + create_events_hook.detach(); + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/framecrash/framecrash.zig b/src/framecrash/framecrash.zig index 9ddf8f0..8dabaf5 100644 --- a/src/framecrash/framecrash.zig +++ b/src/framecrash/framecrash.zig @@ -21,7 +21,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const WINAPI = std.builtin.CallingConvention.winapi; @@ -39,6 +39,7 @@ pub const module_name: [*:0]const u8 = "framecrash"; 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; @@ -345,12 +346,12 @@ fn setAnimOrderDetour(frame: u32, point_enum: u32, relativeTo: u32, rel_point: u const live_uiparent = getLiveUIParent(); if (live_uiparent != 0 and live_uiparent != relativeTo) { - con.fmt("[framecrash] FIX: SetAnimOrder dead relativeTo=0x{x:0>8} -> UIParent=0x{x:0>8}, owner=\"{s}\" point={d}\n", .{ + log.fmt("FIX: SetAnimOrder dead relativeTo=0x{x:0>8} -> UIParent=0x{x:0>8}, owner=\"{s}\" point={d}\n", .{ relativeTo, live_uiparent, fmtFrameName(frame), point_enum, }); fixed_relativeTo = live_uiparent; } else { - con.fmt("[framecrash] RACE: SetAnimOrder dead relativeTo=0x{x:0>8}, no live UIParent! owner=\"{s}\" point={d}\n", .{ + log.fmt("RACE: SetAnimOrder dead relativeTo=0x{x:0>8}, no live UIParent! owner=\"{s}\" point={d}\n", .{ relativeTo, fmtFrameName(frame), point_enum, }); } @@ -436,7 +437,7 @@ fn cleanupReverseDependencies(dying_frame: u32) void { } if (cleaned > 0) { - con.fmt("[framecrash] Nulled {d} stale relativeTo ptr(s) referencing dying frame 0x{x:0>8}\n", .{ cleaned, dying_frame }); + log.fmt("Nulled {d} stale relativeTo ptr(s) referencing dying frame 0x{x:0>8}\n", .{ cleaned, dying_frame }); } } @@ -504,7 +505,7 @@ fn tryFixStaleRelativeTo(anchor: u32, stale: u32) bool { const field: *align(1) u32 = @ptrFromInt(anchor + 0x0C); field.* = live; - con.fmt("[framecrash] HEALED: anchor 0x{x:0>8} relativeTo 0x{x:0>8} -> UIParent 0x{x:0>8}\n", .{ + log.fmt("HEALED: anchor 0x{x:0>8} relativeTo 0x{x:0>8} -> UIParent 0x{x:0>8}\n", .{ anchor, stale, live, }); return true; @@ -636,29 +637,31 @@ pub fn installHooks() void { g_is_hook_owner = result.is_owner; if (!g_is_hook_owner) return; + log = logging.Logger.open(module_name, .console); + // Root cause fix #1: detour cleanup_linked_list_structures to clean up // reverse anchor references before the frame is destroyed. if (cleanup_hook.attach(CLEANUP_TARGET, &cleanupDetour) != .ok) { - con.print("[framecrash] ERROR: Failed to install frame cleanup detour\n"); + log.print("ERROR: Failed to install frame cleanup detour\n"); } else { - con.print("[framecrash] Frame cleanup detour installed\n"); + log.print("Frame cleanup detour installed\n"); } // Root cause fix #2: detour destroyUIElement - the second destruction path // used by cleanupGraphicsResources during UI teardown/reload. This path // frees frames without walking the dependency list. if (destroy_ui_hook.attach(DESTROY_UI_TARGET, &destroyUIDetour) != .ok) { - con.print("[framecrash] ERROR: Failed to install destroyUIElement detour\n"); + log.print("ERROR: Failed to install destroyUIElement detour\n"); } else { - con.print("[framecrash] destroyUIElement detour installed\n"); + log.print("destroyUIElement detour installed\n"); } // Root cause fix #3: detour ProcessUIUpdateEvent - virtual function that // calls CleanupUIElement + FreeMemory without layout cleanup. if (process_ui_hook.attach(PROCESS_UI_TARGET, &processUIDetour) != .ok) { - con.print("[framecrash] ERROR: Failed to install ProcessUIUpdateEvent detour\n"); + log.print("ERROR: Failed to install ProcessUIUpdateEvent detour\n"); } else { - con.print("[framecrash] ProcessUIUpdateEvent detour installed\n"); + log.print("ProcessUIUpdateEvent detour installed\n"); } // Defense-in-depth: patch anchor vtable[1]/[2]/[3] to validate relativeTo @@ -666,22 +669,22 @@ pub fn installHooks() void { patchVtableSlot(GET_WIDTH_SLOT, @intFromPtr(&getWidthHook), &orig_get_width); patchVtableSlot(GET_HEIGHT_SLOT, @intFromPtr(&getHeightHook), &orig_get_height); patchVtableSlot(GET_RELATIVE_TO_SLOT, @intFromPtr(&getRelativeToHook), &orig_get_relative_to); - con.print("[framecrash] Anchor vtable hooks installed (GetWidth/GetHeight/GetRelativeTo)\n"); + log.print("Anchor vtable hooks installed (GetWidth/GetHeight/GetRelativeTo)\n"); // Diagnostic: hook PauseAnimationGroup to track dependency registrations. // Answers: "was a dependency ever registered for this stale address?" if (pause_anim_hook.attach(PAUSE_ANIM_TARGET, &pauseAnimDetour) != .ok) { - con.print("[framecrash] ERROR: Failed to install PauseAnimationGroup detour\n"); + log.print("ERROR: Failed to install PauseAnimationGroup detour\n"); } else { - con.print("[framecrash] PauseAnimationGroup detour installed\n"); + log.print("PauseAnimationGroup detour installed\n"); } // Diagnostic: hook SetAnimationOrder to detect race conditions. // Validates relativeTo param BEFORE anchor creation. if (set_anim_hook.attach(SET_ANIM_TARGET, &setAnimOrderDetour) != .ok) { - con.print("[framecrash] ERROR: Failed to install SetAnimationOrder detour\n"); + log.print("ERROR: Failed to install SetAnimationOrder detour\n"); } else { - con.print("[framecrash] SetAnimationOrder detour installed\n"); + log.print("SetAnimationOrder detour installed\n"); } } @@ -689,28 +692,29 @@ pub fn removeHooks() void { if (g_is_hook_owner) { // Remove diagnostic hooks first (reverse install order) set_anim_hook.detach(); - con.print("[framecrash] SetAnimationOrder detour removed\n"); + log.print("SetAnimationOrder detour removed\n"); pause_anim_hook.detach(); - con.print("[framecrash] PauseAnimationGroup detour removed\n"); + log.print("PauseAnimationGroup detour removed\n"); // Restore original vtable pointers (reverse order) restoreVtableSlot(GET_RELATIVE_TO_SLOT, &orig_get_relative_to); restoreVtableSlot(GET_HEIGHT_SLOT, &orig_get_height); restoreVtableSlot(GET_WIDTH_SLOT, &orig_get_width); - con.print("[framecrash] Anchor vtable hooks removed\n"); + log.print("Anchor vtable hooks removed\n"); process_ui_hook.detach(); - con.print("[framecrash] ProcessUIUpdateEvent detour removed\n"); + log.print("ProcessUIUpdateEvent detour removed\n"); destroy_ui_hook.detach(); - con.print("[framecrash] destroyUIElement detour removed\n"); + log.print("destroyUIElement detour removed\n"); cleanup_hook.detach(); - con.print("[framecrash] Frame cleanup detour removed\n"); + log.print("Frame cleanup detour removed\n"); } if (g_is_hook_owner) { + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/healtextfix/healtextfix.zig b/src/healtextfix/healtextfix.zig index 046b3df..9d0f324 100644 --- a/src/healtextfix/healtextfix.zig +++ b/src/healtextfix/healtextfix.zig @@ -38,7 +38,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const WINAPI = std.builtin.CallingConvention.winapi; extern "kernel32" fn GetModuleHandleA(lpModuleName: ?[*:0]const u8) callconv(WINAPI) ?*anyopaque; @@ -86,11 +86,11 @@ const patch_sets = [_]PatchSet{ }; fn printHex(prefix: []const u8, bytes: []const u8) void { - con.print(prefix); + log.print(prefix); for (bytes) |b| { - con.fmt("{x:0>2} ", .{b}); + log.fmt("{x:0>2} ", .{b}); } - con.print("\n"); + log.print("\n"); } /// Convert a file offset to a virtual address by walking PE section headers. @@ -131,6 +131,7 @@ pub const module_name: [*:0]const u8 = "healtextfix"; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; +var log: logging.Logger = .{}; var g_applied_set: ?*const PatchSet = null; pub fn isActive() bool { @@ -152,11 +153,11 @@ fn detectVersion(base: [*]const u8) ?[]const u8 { } pub fn installHooks() void { - con.print("[healtextfix] 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) log = logging.Logger.open(module_name, .console); } /// Called from engineInitDetour (GameEngine_MainInitialize hook) - late enough @@ -165,33 +166,33 @@ pub fn lateInit() void { if (!g_is_hook_owner) return; const superwow_base = GetModuleHandleA("SuperWoWhook.dll"); if (superwow_base == null) { - con.print("[healtextfix] SuperWoWhook.dll not found, skipping\n"); + log.print("SuperWoWhook.dll not found, skipping\n"); return; } const base: [*]const u8 = @ptrCast(superwow_base.?); - con.fmt("[healtextfix] SuperWoWhook.dll at 0x{x}\n", .{@intFromPtr(base)}); + log.fmt("SuperWoWhook.dll at 0x{x}\n", .{@intFromPtr(base)}); // Detect SuperWoW version const version = detectVersion(base) orelse { - con.print("[healtextfix] Could not detect SuperWoW version, skipping\n"); + log.print("Could not detect SuperWoW version, skipping\n"); return; }; - con.fmt("[healtextfix] Detected SuperWoW version: {s}\n", .{version}); + log.fmt("Detected SuperWoW version: {s}\n", .{version}); // Find matching patch set const set: *const PatchSet = blk: { for (&patch_sets) |*ps| { if (std.mem.eql(u8, ps.version, version)) break :blk ps; } - con.fmt("[healtextfix] No patches for version \"{s}\", skipping\n", .{version}); + log.fmt("No patches for version \"{s}\", skipping\n", .{version}); return; }; var applied: u32 = 0; for (set.patches, 0..) |patch, idx| { const va = fileOffsetToVA(base, patch.file_offset) orelse { - con.fmt("[healtextfix] Patch {d}: failed to resolve file offset 0x{x}\n", .{ idx, patch.file_offset }); + log.fmt("Patch {d}: failed to resolve file offset 0x{x}\n", .{ idx, patch.file_offset }); continue; }; @@ -217,10 +218,10 @@ pub fn lateInit() void { } } if (already) { - con.fmt("[healtextfix] Patch {d}: already applied\n", .{idx}); + log.fmt("Patch {d}: already applied\n", .{idx}); applied += 1; } else { - con.fmt("[healtextfix] Patch {d}: unexpected bytes at VA 0x{x}\n", .{ idx, @intFromPtr(target) }); + log.fmt("Patch {d}: unexpected bytes at VA 0x{x}\n", .{ idx, @intFromPtr(target) }); printHex("[healtextfix] expected: ", patch.old); printHex("[healtextfix] found: ", target[0..patch.old.len]); } @@ -230,17 +231,18 @@ pub fn lateInit() void { // Apply patch hook.writeProtected(@intFromPtr(target), patch.new); applied += 1; - con.fmt("[healtextfix] Patch {d}: applied at VA 0x{x}\n", .{ idx, @intFromPtr(target) }); + log.fmt("Patch {d}: applied at VA 0x{x}\n", .{ idx, @intFromPtr(target) }); } if (applied > 0) g_applied_set = set; - con.fmt("[healtextfix] {d}/{d} patches applied\n", .{ applied, set.patches.len }); + log.fmt("{d}/{d} patches applied\n", .{ applied, set.patches.len }); } pub fn removeHooks() void { if (!g_is_hook_owner) return; const set = g_applied_set orelse { + log.close(); mod_mutex.release(&g_mutex); g_is_hook_owner = false; return; @@ -267,12 +269,13 @@ pub fn removeHooks() void { if (is_patched) { hook.writeProtected(@intFromPtr(target), patch.old); - con.fmt("[healtextfix] Patch {d}: restored\n", .{idx}); + log.fmt("Patch {d}: restored\n", .{idx}); } } g_applied_set = null; + log.print("All patches restored\n"); + log.close(); mod_mutex.release(&g_mutex); g_is_hook_owner = false; - con.print("[healtextfix] All patches restored\n"); } diff --git a/src/interact/interact.zig b/src/interact/interact.zig index 9ab2d8c..b57edc1 100644 --- a/src/interact/interact.zig +++ b/src/interact/interact.zig @@ -1,6 +1,6 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const WINAPI = std.builtin.CallingConvention.winapi; extern "kernel32" fn GetTickCount() callconv(WINAPI) u32; @@ -55,7 +55,6 @@ const C3Vector = struct { // Calling Conventions // ============================================================================= - // ============================================================================= // Game API // ============================================================================= @@ -249,6 +248,7 @@ pub const module_name: [*:0]const u8 = "interact"; 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; @@ -378,20 +378,20 @@ fn hookSceneEnd(device: u32) callconv(hook.cc.thiscall) void { // ============================================================================= pub fn installHooks() void { - con.print("[interact] 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; - // SceneEnd - per-frame loot queue processing + log = logging.Logger.open(module_name, .console); _ = scene_end_hook.attach(Offsets.ADDR_SceneEnd, &hookSceneEnd); } pub fn removeHooks() void { if (g_is_hook_owner) { scene_end_hook.detach(); + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/logging.zig b/src/logging.zig new file mode 100644 index 0000000..fcc04f2 --- /dev/null +++ b/src/logging.zig @@ -0,0 +1,258 @@ +//! Debug logging - compiles out entirely in non-Debug builds. +//! +//! Per-module Logger with optional file output and per-call destination routing. +//! Log files are created lazily on first write -- modules that never log to +//! file produce no `.log` file. +//! +//! const logging = @import("../logging.zig"); +//! var log: logging.Logger = .{}; +//! +//! // In installHooks: +//! log = logging.Logger.open("mymodule", .console); // console only (file created on demand) +//! log = logging.Logger.open("mymodule", .both); // console + file by default +//! log = logging.Logger.open(null, .console); // no file at all +//! +//! // Default destination (auto-prefixed with [mymodule]): +//! log.print("loaded\n"); // -> [mymodule] loaded +//! log.fmt("val={d}\n", .{v}); // -> [mymodule] val=42 +//! +//! // Override for specific calls: +//! log.to(.file).fmt("noisy={d}\n", .{n}); // file only +//! log.to(.both).print("important event\n"); // console + file +//! log.to(.console).print("console only\n"); // console only +//! +//! Global console-only convenience (no auto-prefix): +//! logging.print("[addons] loaded\n"); +//! logging.fmt("[addons] count={d}\n", .{n}); + +const std = @import("std"); +const builtin = @import("builtin"); + +const debug = builtin.mode == .Debug; + +const WINAPI = std.builtin.CallingConvention.winapi; + +const win = if (debug) struct { + extern "kernel32" fn AllocConsole() callconv(WINAPI) i32; + extern "kernel32" fn FreeConsole() callconv(WINAPI) i32; + extern "kernel32" fn SetConsoleTitleA(title: [*:0]const u8) callconv(WINAPI) i32; + extern "kernel32" fn GetStdHandle(nStdHandle: u32) callconv(WINAPI) ?*anyopaque; + extern "kernel32" fn WriteConsoleA( + hOut: *anyopaque, + buf: [*]const u8, + len: u32, + written: ?*u32, + reserved: ?*anyopaque, + ) callconv(WINAPI) i32; + extern "kernel32" fn CreateFileA( + lpFileName: [*:0]const u8, + dwDesiredAccess: u32, + dwShareMode: u32, + lpSecurityAttributes: ?*anyopaque, + dwCreationDisposition: u32, + dwFlagsAndAttributes: u32, + hTemplateFile: ?*anyopaque, + ) callconv(WINAPI) ?*anyopaque; + extern "kernel32" fn WriteFile( + hFile: *anyopaque, + lpBuffer: [*]const u8, + nNumberOfBytesToWrite: u32, + lpNumberOfBytesWritten: ?*u32, + lpOverlapped: ?*anyopaque, + ) callconv(WINAPI) i32; + extern "kernel32" fn CloseHandle(hObject: *anyopaque) callconv(WINAPI) i32; + + const STD_OUTPUT_HANDLE: u32 = 0xFFFFFFF5; + const GENERIC_WRITE: u32 = 0x40000000; + const FILE_SHARE_READ: u32 = 0x00000001; + const CREATE_ALWAYS: u32 = 2; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; + + var console_handle: ?*anyopaque = null; +} else void; + +// ============================================================================= +// Console management (called from main.zig install/uninstall) +// ============================================================================= + +pub fn init() void { + if (debug) { + _ = win.AllocConsole(); + _ = win.SetConsoleTitleA("weirdutils"); + win.console_handle = win.GetStdHandle(win.STD_OUTPUT_HANDLE); + print("[weirdutils] Console attached\n"); + } +} + +pub fn deinit() void { + if (debug) { + if (win.console_handle != null) { + print("[weirdutils] Detaching\n"); + _ = win.FreeConsole(); + win.console_handle = null; + } + } +} + +// ============================================================================= +// Output destination +// ============================================================================= + +pub const Output = enum { console, file, both }; + +// ============================================================================= +// Logger +// ============================================================================= + +pub const Logger = struct { + file_handle: if (debug) ?*anyopaque else void = if (debug) null else {}, + default_dest: if (debug) Output else void = if (debug) .console else {}, + /// Module name for lazy file creation. Null = no file output. + file_name: if (debug) ?[*:0]const u8 else void = if (debug) null else {}, + /// Auto-prefix: "[name] " prepended to every log line. + prefix: if (debug) [32]u8 else void = if (debug) .{0} ** 32 else {}, + prefix_len: if (debug) u8 else void = if (debug) 0 else {}, + + /// Open a logger with optional file output. File is created lazily on + /// first write, so modules that never log to file produce no `.log` file. + /// Pass null for no file (console-only even when dest is .both/.file). + pub fn open(module_name: ?[*:0]const u8, default: Output) Logger { + if (debug) { + var l: Logger = .{ .default_dest = default, .file_name = module_name }; + if (module_name) |name| { + const span = std.mem.span(name); + if (span.len + 3 <= l.prefix.len) { + l.prefix[0] = '['; + @memcpy(l.prefix[1..][0..span.len], span); + l.prefix[1 + span.len] = ']'; + l.prefix[2 + span.len] = ' '; + l.prefix_len = @intCast(3 + span.len); + } + } + return l; + } + return .{}; + } + + pub fn close(self: *Logger) void { + if (debug) { + if (self.file_handle) |h| { + _ = win.CloseHandle(h); + self.file_handle = null; + } + } + } + + /// Print using the default destination. + pub fn print(self: *Logger, msg: []const u8) void { + if (debug) self.dispatch(self.default_dest, msg); + } + + /// Format and print using the default destination. + pub fn fmt(self: *Logger, comptime f: []const u8, args: anytype) void { + if (debug) { + var buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, f, args) catch return; + self.dispatch(self.default_dest, msg); + } + } + + /// Override the destination for a single call. + pub fn to(self: *Logger, dest: Output) Writer { + return .{ .logger = self, .dest = dest }; + } + + pub const Writer = struct { + logger: *Logger, + dest: Output, + + pub fn print(self: Writer, msg: []const u8) void { + if (debug) self.logger.dispatch(self.dest, msg); + } + + pub fn fmt(self: Writer, comptime f: []const u8, args: anytype) void { + if (debug) { + var buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, f, args) catch return; + self.logger.dispatch(self.dest, msg); + } + } + }; + + fn dispatch(self: *Logger, dest: Output, msg: []const u8) void { + const pfx = self.prefix[0..self.prefix_len]; + switch (dest) { + .console => { + if (pfx.len > 0) writeConsole(pfx); + writeConsole(msg); + }, + .file => { + if (pfx.len > 0) self.writeFile(pfx); + self.writeFile(msg); + }, + .both => { + if (pfx.len > 0) { + writeConsole(pfx); + self.writeFile(pfx); + } + writeConsole(msg); + self.writeFile(msg); + }, + } + } + + fn writeFile(self: *Logger, msg: []const u8) void { + const h = self.ensureFileOpen() orelse return; + _ = win.WriteFile(h, msg.ptr, @intCast(msg.len), null, null); + } + + /// Lazily create the log file on first write. + fn ensureFileOpen(self: *Logger) ?*anyopaque { + if (self.file_handle) |h| return h; + const name = self.file_name orelse return null; + const span = std.mem.span(name); + var buf: [64]u8 = undefined; + if (span.len + 4 >= buf.len) return null; + @memcpy(buf[0..span.len], span); + @memcpy(buf[span.len..][0..4], ".log"); + buf[span.len + 4] = 0; + const fh = win.CreateFileA( + @ptrCast(buf[0 .. span.len + 4 :0]), + win.GENERIC_WRITE, + win.FILE_SHARE_READ, + null, + win.CREATE_ALWAYS, + win.FILE_ATTRIBUTE_NORMAL, + null, + ); + if (fh) |h| { + if (@intFromPtr(h) != 0xFFFFFFFF) { + self.file_handle = h; + return h; + } + } + return null; + } +}; + +// ============================================================================= +// Global console-only convenience (for utility files and main.zig) +// ============================================================================= + +pub fn print(msg: []const u8) void { + if (debug) writeConsole(msg); +} + +pub fn fmt(comptime f: []const u8, args: anytype) void { + if (debug) { + var buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, f, args) catch return; + writeConsole(msg); + } +} + +fn writeConsole(msg: []const u8) void { + if (win.console_handle) |h| { + _ = win.WriteConsoleA(h, msg.ptr, @intCast(msg.len), null, null); + } +} diff --git a/src/logsessions/logsessions.zig b/src/logsessions/logsessions.zig index 046dd4a..6e20865 100644 --- a/src/logsessions/logsessions.zig +++ b/src/logsessions/logsessions.zig @@ -13,7 +13,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const lua = @import("../lua.zig"); const o = @import("offsets.zig"); @@ -77,6 +77,7 @@ pub const module_name: [*:0]const u8 = "logsessions"; 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; @@ -190,7 +191,7 @@ fn setupSessionDir(realm: []const u8, char_name: []const u8) bool { g_dir_path[dir_path.len] = 0; g_dir_path_len = dir_path.len; - con.fmt("[logsessions] dir: {s}\n", .{g_dir_path[0..g_dir_path_len]}); + log.fmt("dir: {s}\n", .{g_dir_path[0..g_dir_path_len]}); return true; } @@ -260,7 +261,7 @@ fn findRecentFile(prefix: []const u8, result_buf: *[260]u8) ?usize { fn resolveLogPath(prefix: []const u8, result_buf: *[260]u8) usize { // Try to reuse a recent file (modified < 60 min ago) if (findRecentFile(prefix, result_buf)) |len| { - con.fmt("[logsessions] reusing: {s}\n", .{result_buf[0..len]}); + log.fmt("reusing: {s}\n", .{result_buf[0..len]}); return len; } @@ -279,7 +280,7 @@ fn resolveLogPath(prefix: []const u8, result_buf: *[260]u8) usize { st.wSecond, }) catch return 0; result_buf[path.len] = 0; - con.fmt("[logsessions] new: {s}\n", .{path}); + log.fmt("new: {s}\n", .{path}); return path.len; } @@ -294,7 +295,7 @@ fn configureSession(char_span: []const u8, realm_span: []const u8) void { g_session_char_len = sanitizeName(char_span, &g_session_char); g_session_realm_len = sanitizeName(realm_span, &g_session_realm); - con.fmt("[logsessions] session: {s} on {s}\n", .{ + log.fmt("session: {s} on {s}\n", .{ g_session_char[0..g_session_char_len], g_session_realm[0..g_session_realm_len], }); @@ -304,7 +305,7 @@ fn configureSession(char_span: []const u8, realm_span: []const u8) void { g_session_realm[0..g_session_realm_len], g_session_char[0..g_session_char_len], )) { - con.print("[logsessions] setup: failed to create directories\n"); + log.print("setup: failed to create directories\n"); return; } @@ -348,7 +349,7 @@ fn enterWorldDetour() callconv(hook.cc.stdcall) void { if (char_name != null and realm_name != null) { configureSession(std.mem.span(char_name.?), std.mem.span(realm_name.?)); } else { - con.print("[logsessions] enter world: char/realm not available\n"); + log.print("enter world: char/realm not available\n"); } } @@ -382,18 +383,18 @@ fn initLogDetour(file_path: u32, flags: u32, handle_out: u32) callconv(hook.cc.s // Redirect based on path suffix if (g_combat_path_len > 0 and std.mem.endsWith(u8, path_span, "WoWCombatLog.txt")) { - con.fmt("[logsessions] redirect: {s} -> {s}\n", .{ path_span, g_combat_path[0..g_combat_path_len] }); + log.fmt("redirect: {s} -> {s}\n", .{ path_span, g_combat_path[0..g_combat_path_len] }); return init_log_hook.callOriginal(.{ @intFromPtr(&g_combat_path), flags, handle_out }); } if (g_raw_combat_path_len > 0 and std.mem.endsWith(u8, path_span, "WoWRawCombatLog.txt")) { g_raw_combat_handle_addr = handle_out; // capture SuperWoW's handle address - con.fmt("[logsessions] redirect: {s} -> {s}\n", .{ path_span, g_raw_combat_path[0..g_raw_combat_path_len] }); + log.fmt("redirect: {s} -> {s}\n", .{ path_span, g_raw_combat_path[0..g_raw_combat_path_len] }); return init_log_hook.callOriginal(.{ @intFromPtr(&g_raw_combat_path), flags, handle_out }); } if (g_chat_path_len > 0 and std.mem.endsWith(u8, path_span, "WoWChatLog.txt")) { - con.fmt("[logsessions] redirect: {s} -> {s}\n", .{ path_span, g_chat_path[0..g_chat_path_len] }); + log.fmt("redirect: {s} -> {s}\n", .{ path_span, g_chat_path[0..g_chat_path_len] }); return init_log_hook.callOriginal(.{ @intFromPtr(&g_chat_path), flags, handle_out }); } @@ -454,7 +455,7 @@ fn writeSessionMarker(handle: u32, fmt_str: [*:0]const u8) void { @intFromPtr(&marker_ptr), }); - con.fmt("[logsessions] marker: {s}\n", .{marker_str}); + log.fmt("marker: {s}\n", .{marker_str}); } // ============================================================================= @@ -465,7 +466,7 @@ fn writeSessionMarker(handle: u32, fmt_str: [*:0]const u8) void { /// Called from logoutDetour/shutdownDetour in main.zig. pub fn onShutdown() void { if (g_paths_configured) { - con.print("[logsessions] session reset\n"); + log.print("session reset\n"); } g_paths_configured = false; g_combat_marker_written = false; @@ -511,31 +512,32 @@ pub fn luaGetChatLogPath(L: lua.State) callconv(.c) u32 { // ============================================================================= pub fn installHooks() void { - con.print("[logsessions] 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; + log = logging.Logger.open(module_name, .console); + // Hook HandleCharacterSelection - sets up paths when player clicks Enter World, // before the world loading sequence calls InitializeLogBuffer. if (enter_world_hook.attach(o.FN_HANDLE_CHAR_SELECT, &enterWorldDetour) != .ok) { - con.print("[logsessions] FAILED to hook HandleCharacterSelection!\n"); + log.print("FAILED to hook HandleCharacterSelection!\n"); } else { - con.print("[logsessions] hooked HandleCharacterSelection OK\n"); + log.print("hooked HandleCharacterSelection OK\n"); } if (init_log_hook.attach(o.FN_INIT_LOG_BUFFER, &initLogDetour) != .ok) { - con.print("[logsessions] FAILED to hook InitializeLogBuffer!\n"); + log.print("FAILED to hook InitializeLogBuffer!\n"); } else { - con.print("[logsessions] hooked InitializeLogBuffer OK\n"); + log.print("hooked InitializeLogBuffer OK\n"); } if (write_log_hook.attach(o.FN_WRITE_FMT_LOG_MSG, &writeLogDetour) != .ok) { - con.print("[logsessions] FAILED to hook WriteFormattedLogMessage!\n"); + log.print("FAILED to hook WriteFormattedLogMessage!\n"); } else { - con.print("[logsessions] hooked WriteFormattedLogMessage OK\n"); + log.print("hooked WriteFormattedLogMessage OK\n"); } } @@ -545,6 +547,7 @@ pub fn removeHooks() void { init_log_hook.detach(); enter_world_hook.detach(); restorePathPointers(); + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/main.zig b/src/main.zig index 9ef69ab..9410722 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,7 @@ const std = @import("std"); const hook = @import("zhook"); -pub const con = @import("console.zig"); +const logging = @import("logging.zig"); +var log: logging.Logger = .{}; // Build options for conditional module compilation const build_opts = struct { @@ -15,6 +16,7 @@ const build_opts = struct { const customassets = @import("build_options").enable_customassets; const healtextfix = @import("build_options").enable_healtextfix; const bigcursor = @import("build_options").enable_bigcursor; + const clickthrough = @import("build_options").enable_clickthrough; const dpslog = @import("build_options").enable_dpslog; }; @@ -30,6 +32,7 @@ const transmogfix = if (build_opts.transmogfix) @import("transmogfix/transmogfix const customassets = if (build_opts.customassets) @import("customassets/customassets.zig") else struct {}; const healtextfix = if (build_opts.healtextfix) @import("healtextfix/healtextfix.zig") else struct {}; const bigcursor = if (build_opts.bigcursor) @import("bigcursor/bigcursor.zig") else struct {}; +const clickthrough = if (build_opts.clickthrough) @import("clickthrough/clickthrough.zig") else struct {}; const dpslog = if (build_opts.dpslog) @import("dpslog/dpslog.zig") else struct {}; const WINAPI = std.builtin.CallingConvention.winapi; @@ -145,7 +148,7 @@ fn loadFileDetour( buf_out.* = buf; if (size_out) |s| s.* = data_len; - con.fmt("[file] served embedded: {s} ({d} bytes)\n", .{ std.mem.span(path), data_len }); + log.fmt("served embedded: {s} ({d} bytes)\n", .{ std.mem.span(path), data_len }); return 1; } @@ -244,7 +247,7 @@ fn openFileDetour( } handle_out.* = @intFromPtr(ctx); - con.fmt("[file] fake ctx @0x{x}: {s} ({d} bytes)\n", .{ @intFromPtr(ctx), path_span, entry.data.len }); + log.fmt("fake ctx @0x{x}: {s} ({d} bytes)\n", .{ @intFromPtr(ctx), path_span, entry.data.len }); return 2; // success (non-zero type code) } @@ -260,7 +263,7 @@ fn getFileSizeDetour( if (isFakeFileContext(file_ctx)) { if (high_size_out) |h| h.* = 0; const size = hook.readMem(u32, file_ctx + 0x34); - con.fmt("[file] getFileSize fake @0x{x} = {d}\n", .{ file_ctx, size }); + log.fmt("getFileSize fake @0x{x} = {d}\n", .{ file_ctx, size }); return size; } @@ -282,7 +285,7 @@ fn readFileDetour( const data_size = hook.readMem(u32, ctx + 0x34); const read_size = @min(size, data_size); - con.fmt("[file] readFile fake @0x{x} size={d}/{d} async=0x{x}\n", .{ ctx, read_size, data_size, async_ptr }); + log.fmt("readFile fake @0x{x} size={d}/{d} async=0x{x}\n", .{ ctx, read_size, data_size, async_ptr }); const src: [*]const u8 = @ptrFromInt(data_ptr); @memcpy(buffer[0..read_size], src[0..read_size]); @@ -363,9 +366,9 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(hook.cc.stdcall) void { const path_ptr = hook.readMem(u32, file_ctx + 0x0C); if (path_ptr != 0) { const path: [*:0]const u8 = @ptrFromInt(path_ptr); - con.fmt("[file] cleanup FAKE @0x{x}: {s}\n", .{ file_ctx, std.mem.span(path) }); + log.fmt("cleanup FAKE @0x{x}: {s}\n", .{ file_ctx, std.mem.span(path) }); } else { - con.fmt("[file] cleanup FAKE @0x{x}: (no path)\n", .{file_ctx}); + log.fmt("cleanup FAKE @0x{x}: (no path)\n", .{file_ctx}); } } @@ -373,7 +376,7 @@ fn cleanupFileHandleDetour(file_ctx: u32) callconv(hook.cc.stdcall) void { // (NULL-safe checks on +0x04/+0x3C/+0x40/+0x08, then cleanupFileContext + FreeMemory). cleanup_file_handle_hook.callOriginal(.{file_ctx}); - if (fake) con.fmt("[file] cleanup FAKE @0x{x} done\n", .{file_ctx}); + if (fake) log.fmt("cleanup FAKE @0x{x} done\n", .{file_ctx}); } // --- Hook 5: loadModelFromFileAsync (0x71d4e0) --- @@ -383,10 +386,10 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) // file_handle IS the file context address directly (Ghidra shows pointer* but // the assembly pushes it directly to GetFileSizeFromHandle - no dereference) if (isFakeFileContext(file_handle)) { - con.fmt("[file] loadModelAsync: model=0x{x} fh=0x{x} cb={d}\n", .{ model, file_handle, should_use_callback }); + log.fmt("loadModelAsync: model=0x{x} fh=0x{x} cb={d}\n", .{ model, file_handle, should_use_callback }); const data_ptr = hook.readMem(u32, file_handle + 0x30); const data_size = hook.readMem(u32, file_handle + 0x34); - con.fmt("[file] embed_ptr=0x{x} embed_size={d}\n", .{ data_ptr, data_size }); + log.fmt(" embed_ptr=0x{x} embed_size={d}\n", .{ data_ptr, data_size }); // Toggle callback flag (bit 1 of model+8) based on shouldUseCallback const flags = hook.readMem(u32, model + 0x08); @@ -403,10 +406,10 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) // setCullMode is __fastcall(ECX=size), returns buffer pointer const buffer_addr = hook.call(fn (u32) callconv(hook.cc.fastcall) u32, 0x71f9a0, .{data_size}); if (buffer_addr == 0) { - con.print("[file] setCullMode alloc failed\n"); + log.print(" setCullMode alloc failed\n"); return 0; } - con.fmt("[file] buffer=0x{x}\n", .{buffer_addr}); + log.fmt(" buffer=0x{x}\n", .{buffer_addr}); // Store buffer in model object @as(*align(1) u32, @ptrFromInt(model + 0x130)).* = buffer_addr; @@ -415,11 +418,11 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) const buffer: [*]u8 = @ptrFromInt(buffer_addr); const src: [*]const u8 = @ptrFromInt(data_ptr); @memcpy(buffer[0..data_size], src[0..data_size]); - con.print("[file] memcpy done\n"); + log.print(" memcpy done\n"); // No async task - set task pointer to NULL @as(*align(1) u32, @ptrFromInt(model + 0x0c)).* = 0; - con.print("[file] task=0 set\n"); + log.print(" task=0 set\n"); // Match onModelLoadComplete ordering: clean up file handle BEFORE processing. // The original async flow does: CleanupFileHandleResources → ReturnAsyncTaskToPool @@ -428,12 +431,12 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) // async tasks that interact with the file I/O system. // Call original CleanupFileHandleResources through the trampoline (bypasses our // detour). Must clean up file context before processLoadedModelData runs. - con.fmt("[file] cleanup via trampoline fh=0x{x}\n", .{file_handle}); + log.fmt(" cleanup via trampoline fh=0x{x}\n", .{file_handle}); cleanup_file_handle_hook.callOriginal(.{file_handle}); - con.print("[file] cleanup done\n"); + log.print(" cleanup done\n"); // Dump model fields before processLoadedModelData - con.fmt("[file] PRE model+0x0c=0x{x} +0x130=0x{x} +0x134=0x{x} +0x138=0x{x}\n", .{ + log.fmt(" PRE model+0x0c=0x{x} +0x130=0x{x} +0x134=0x{x} +0x138=0x{x}\n", .{ hook.readMem(u32, model + 0x0c), hook.readMem(u32, model + 0x130), hook.readMem(u32, model + 0x134), @@ -441,22 +444,22 @@ fn loadModelAsyncDetour(model: u32, file_handle: u32, should_use_callback: u32) }); // Call processLoadedModelData directly - __fastcall(ECX=model) - con.fmt("[file] calling processLoadedModelData(0x{x})...\n", .{model}); + log.fmt(" calling processLoadedModelData(0x{x})...\n", .{model}); const result = hook.call(fn (u32) callconv(hook.cc.fastcall) u32, 0x71d640, .{model}); - con.print("[file] processLoadedModelData returned\n"); - con.fmt("[file] result=0x{x}\n", .{result}); + log.print(" processLoadedModelData returned\n"); + log.fmt(" result=0x{x}\n", .{result}); // Dump model fields after processLoadedModelData - check if texture async task was created - con.print("[file] POST dump:\n"); - con.fmt("[file] POST model+0x0c=0x{x} +0x130=0x{x} +0x134=0x{x} +0x138=0x{x}\n", .{ + log.print(" POST dump:\n"); + log.fmt(" POST model+0x0c=0x{x} +0x130=0x{x} +0x134=0x{x} +0x138=0x{x}\n", .{ hook.readMem(u32, model + 0x0c), hook.readMem(u32, model + 0x130), hook.readMem(u32, model + 0x134), hook.readMem(u32, model + 0x138), }); - con.fmt("[file] sync loaded {d} bytes, returning 1\n", .{data_size}); - con.print("[file] === loadModelAsyncDetour EXIT ===\n"); + log.fmt(" sync loaded {d} bytes, returning 1\n", .{data_size}); + log.print(" === loadModelAsyncDetour EXIT ===\n"); return 1; } @@ -496,7 +499,7 @@ fn installFileHooks() void { _ = cleanup_file_handle_hook.attach(0x648730, &cleanupFileHandleDetour); _ = model_load_hook.attach(0x71d4e0, &loadModelAsyncDetour); _ = cfe_hook.attach(0x654DD0, &checkFileExistenceDetour); - con.print("[file] in-memory file hooks installed\n"); + log.print("in-memory file hooks installed\n"); } fn removeFileHooks() void { @@ -520,7 +523,6 @@ fn loadScriptFunctionsDetour() callconv(hook.cc.stdcall) void { registerLuaFunctions(); } - // ============================================================================= // Hook: GameEngine_MainInitialize (0x46a400) // ============================================================================= @@ -552,11 +554,12 @@ fn engineInitDetour() callconv(hook.cc.stdcall) void { var logout_hook: hook.Detour(fn () callconv(hook.cc.stdcall) void) = .{}; fn logoutDetour() callconv(hook.cc.stdcall) void { - con.print("[weirdutils] World_HandleLogoutCleanup -- player logout\n"); + log.print("World_HandleLogoutCleanup -- player logout\n"); // Reset per-session state - only on real logout/disconnect, not /reload. if (build_opts.worldmarkers) markers.onShutdown(); if (build_opts.minimapicons) minimapicons.onShutdown(); + if (build_opts.clickthrough) clickthrough.onShutdown(); if (build_opts.logsessions) logsessions.onShutdown(); // Clean up world objects BEFORE game teardown - modules with @@ -605,6 +608,7 @@ const modules = [_]ModuleHooks{ if (build_opts.minimapicons) .{ .name = minimapicons.module_name, .install = minimapicons.installHooks, .remove = minimapicons.removeHooks, .is_active = minimapicons.isActive } else .{}, if (build_opts.healtextfix) .{ .name = healtextfix.module_name, .install = healtextfix.installHooks, .remove = healtextfix.removeHooks, .is_active = healtextfix.isActive } else .{}, if (build_opts.bigcursor) .{ .name = bigcursor.module_name, .install = bigcursor.installHooks, .remove = bigcursor.removeHooks, .is_active = bigcursor.isActive } else .{}, + if (build_opts.clickthrough) .{ .name = clickthrough.module_name, .install = clickthrough.installHooks, .remove = clickthrough.removeHooks, .is_active = clickthrough.isActive } else .{}, if (build_opts.dpslog) .{ .name = dpslog.module_name, .install = dpslog.installHooks, .remove = dpslog.removeHooks, .is_active = dpslog.isActive } else .{}, if (build_opts.worldmarkers) .{ .name = markers.module_name, .install = markers.installHooks, .remove = markers.removeHooks, .is_active = markers.isActive } else .{}, if (build_opts.interact) .{ .name = interact.module_name, .install = interact.installHooks, .remove = interact.removeHooks, .is_active = interact.isActive } else .{}, @@ -613,7 +617,7 @@ const modules = [_]ModuleHooks{ }; fn shutdownDetour() callconv(hook.cc.stdcall) void { - con.print("[weirdutils] CGGameUI_Shutdown\n"); + log.print("CGGameUI_Shutdown\n"); // Per-session resets and remove_on_shutdown cleanup live in logoutDetour // (World_HandleLogoutCleanup) - fires on real logout/disconnect only, not /reload. shutdown_hook.callOriginal(.{}); @@ -624,8 +628,9 @@ fn shutdownDetour() callconv(hook.cc.stdcall) void { // ============================================================================= fn install() void { - con.init(); - con.print("[weirdutils] Installing hooks\n"); + logging.init(); + log = logging.Logger.open("weirdutils", .console); + log.print("Installing hooks\n"); _ = protection_hook.attach(0x42a320, &luaProtectionDetour); installFileHooks(); _ = file_hook.attach(0x648620, &loadFileDetour); @@ -658,7 +663,7 @@ fn uninstall() void { file_hook.detach(); removeFileHooks(); protection_hook.detach(); - con.deinit(); + logging.deinit(); } // ============================================================================= diff --git a/src/markers/markers.zig b/src/markers/markers.zig index 0ba6fce..2135d0b 100644 --- a/src/markers/markers.zig +++ b/src/markers/markers.zig @@ -276,7 +276,7 @@ fn getCursorTerrainPosition() ?Vec3 { const y = hook.readMem(f32, world_frame + o.WF_HIT_TERRAIN_Y); const z = hook.readMem(f32, world_frame + o.WF_HIT_TERRAIN_Z); - log.fmt("[worldmarkers] hitTest: type={d} pos={d:.1},{d:.1},{d:.1}\n", .{ hit_type, x, y, z }); + log.fmt("hitTest: type={d} pos={d:.1},{d:.1},{d:.1}\n", .{ hit_type, x, y, z }); if (hit_type == 2) { // Object hit — the intersection point is unreliable (can be at camera). @@ -289,7 +289,7 @@ fn getCursorTerrainPosition() ?Vec3 { if (obj != 0) { const pos = getUnitPosition(obj); if (pos.x != 0 or pos.y != 0 or pos.z != 0) { - log.fmt("[worldmarkers] object hit, using unit pos: {d:.1},{d:.1},{d:.1}\n", .{ pos.x, pos.y, pos.z }); + log.fmt("object hit, using unit pos: {d:.1},{d:.1},{d:.1}\n", .{ pos.x, pos.y, pos.z }); return pos; } } @@ -405,7 +405,7 @@ fn spawnEntity(index: usize, pos: Vec3) bool { var position = [3]f32{ pos.x, pos.y, pos.z + MARKER_Z_OFFSET }; const obj = createEntityInstance(MODEL_PATHS[index], &position, 0.0, 0, 1) orelse { - log.fmt("[worldmarkers] failed to create marker {d}\n", .{index + 1}); + log.fmt("failed to create marker {d}\n", .{index + 1}); return false; }; @@ -419,7 +419,7 @@ fn spawnEntity(index: usize, pos: Vec3) bool { marker_created_tick[index] = GetTickCount(); hold_queued[index] = false; - log.fmt("[worldmarkers] marker {d} spawned at {d:.1}, {d:.1}, {d:.1} @0x{x}\n", .{ + log.fmt("marker {d} spawned at {d:.1}, {d:.1}, {d:.1} @0x{x}\n", .{ index + 1, pos.x, pos.y, pos.z, @intFromPtr(obj), }); return true; @@ -461,7 +461,7 @@ fn clearAllMarkers() void { } marker_defs[i] = EMPTY_DEF; } - if (any) log.print("[worldmarkers] all markers cleared\n"); + if (any) log.print("all markers cleared\n"); } // ============================================================================= @@ -476,21 +476,21 @@ pub fn luaWorldMarker(L: lua.State) callconv(.c) u32 { return 1; } if (!canSetMarkers()) { - log.print("[worldmarkers] WorldMarker: no permission\n"); + log.print("WorldMarker: no permission\n"); return 0; // nil - addon shows permission message } const nargs = lua.gettop(L); if (nargs < 1 or !lua.isnumber(L, 1)) { - log.print("[worldmarkers] WorldMarker: expected index (1-5)\n"); + log.print("WorldMarker: expected index (1-5)\n"); lua.pushnumber(L, -1.0); return 1; } const raw_index = @as(i32, @intFromFloat(lua.tonumber(L, 1))); if (raw_index < 1 or raw_index > NUM_MARKERS) { - log.print("[worldmarkers] WorldMarker: index must be 1-5\n"); + log.print("WorldMarker: index must be 1-5\n"); lua.pushnumber(L, -1.0); return 1; } @@ -506,12 +506,12 @@ pub fn luaWorldMarker(L: lua.State) callconv(.c) u32 { } } else if (nargs >= 2 and lua.isstring(L, 2)) { const unit_id = lua.tostring(L, 2) orelse { - log.print("[worldmarkers] WorldMarker: invalid unit string\n"); + log.print("WorldMarker: invalid unit string\n"); lua.pushnumber(L, -1.0); return 1; }; const pos = resolveUnitPosition(unit_id) orelse { - log.fmt("[worldmarkers] WorldMarker: unit '{s}' not found\n", .{std.mem.span(unit_id)}); + log.fmt("WorldMarker: unit '{s}' not found\n", .{std.mem.span(unit_id)}); lua.pushnumber(L, -1.0); return 1; }; @@ -521,7 +521,7 @@ pub fn luaWorldMarker(L: lua.State) callconv(.c) u32 { } } else { const pos = getCursorTerrainPosition() orelse { - log.print("[worldmarkers] no terrain under cursor\n"); + log.print("no terrain under cursor\n"); lua.pushnumber(L, -1.0); return 1; }; @@ -538,7 +538,7 @@ pub fn luaWorldMarker(L: lua.State) callconv(.c) u32 { /// Returns 1 on success, nil on permission denied. pub fn luaClearWorldMarker(L: lua.State) callconv(.c) u32 { if (!canSetMarkers()) { - log.print("[worldmarkers] ClearWorldMarker: no permission\n"); + log.print("ClearWorldMarker: no permission\n"); return 0; } @@ -553,13 +553,13 @@ pub fn luaClearWorldMarker(L: lua.State) callconv(.c) u32 { } if (!lua.isnumber(L, 1)) { - log.print("[worldmarkers] ClearWorldMarker: expected index (1-5) or nil\n"); + log.print("ClearWorldMarker: expected index (1-5) or nil\n"); return 0; } const raw_index = @as(i32, @intFromFloat(lua.tonumber(L, 1))); if (raw_index < 1 or raw_index > NUM_MARKERS) { - log.print("[worldmarkers] ClearWorldMarker: index must be 1-5\n"); + log.print("ClearWorldMarker: index must be 1-5\n"); return 0; } @@ -603,7 +603,7 @@ fn tickAnimations() void { const addr = @intFromPtr(entity); const refcount = hook.readMem(u16, addr + 0x0E); if (refcount <= 1) { - log.fmt("[worldmarkers] zombie detected [{d}] @0x{x} rc={d}, destroying\n", .{ i + 1, addr, refcount }); + log.fmt("zombie detected [{d}] @0x{x} rc={d}, destroying\n", .{ i + 1, addr, refcount }); cleanupEntity(entity); marker_entities[i] = null; hold_queued[i] = false; @@ -631,7 +631,7 @@ fn tickAnimations() void { const dist_sq = dx * dx + dy * dy + dz * dz; if (dist_sq < RESPAWN_DISTANCE_SQ) { - log.fmt("[worldmarkers] respawning [{d}] dist={d:.0}\n", .{ i + 1, @sqrt(dist_sq) }); + log.fmt("respawning [{d}] dist={d:.0}\n", .{ i + 1, @sqrt(dist_sq) }); _ = spawnEntity(i, marker_defs[i].pos); } } @@ -652,7 +652,7 @@ pub fn luaSetMarkerDef(L: lua.State) callconv(.c) u32 { const sender = lua.tostring(L, 6) orelse return 0; if (!senderHasPermission(sender)) { - log.fmt("[worldmarkers] SetMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); + log.fmt("SetMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); return 0; } @@ -674,7 +674,7 @@ pub fn luaSetMarkerDef(L: lua.State) callconv(.c) u32 { .active = true, }; - log.fmt("[worldmarkers] SetMarkerDef [{d}] at {d:.1},{d:.1},{d:.1} area={d}\n", .{ index + 1, x, y, z, area_id }); + log.fmt("SetMarkerDef [{d}] at {d:.1},{d:.1},{d:.1} area={d}\n", .{ index + 1, x, y, z, area_id }); return 0; } @@ -689,7 +689,7 @@ pub fn luaClearMarkerDef(L: lua.State) callconv(.c) u32 { // ClearMarkerDef(senderName) - clear all const sender = lua.tostring(L, 1) orelse return 0; if (!senderHasPermission(sender)) { - log.fmt("[worldmarkers] ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); + log.fmt("ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); return 0; } clearAllMarkers(); @@ -700,7 +700,7 @@ pub fn luaClearMarkerDef(L: lua.State) callconv(.c) u32 { // ClearMarkerDef(index, senderName) - clear one const sender = lua.tostring(L, 2) orelse return 0; if (!senderHasPermission(sender)) { - log.fmt("[worldmarkers] ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); + log.fmt("ClearMarkerDef: sender '{s}' denied\n", .{std.mem.span(sender)}); return 0; } const raw_index = @as(i32, @intFromFloat(lua.tonumber(L, 1))); @@ -773,7 +773,7 @@ fn worldUpdateDetour(frame: u32) callconv(hook.cc.fastcall) void { /// processCinematicExit, DestroyPathObjectIfPresent). This unlinks them from /// the WDOODADDEF hash table so the atexit handler never touches freed memory. fn worldCleanupDetour() callconv(hook.cc.stdcall) void { - log.print("[worldmarkers] >>> worldCleanupDetour FIRING <<<\n"); + log.print(">>> worldCleanupDetour FIRING <<<\n"); destroyAllEntities(); world_cleanup_hook.callOriginal(.{}); } @@ -786,7 +786,7 @@ fn destroyAllEntities() void { for (&marker_entities, 0..) |*slot, i| { if (slot.*) |existing| { const addr = @intFromPtr(existing); - log.fmt("[worldmarkers] destroying marker[{d}] @0x{x}\n", .{ i, addr }); + log.fmt("destroying marker[{d}] @0x{x}\n", .{ i, addr }); cleanupEntity(existing); slot.* = null; count += 1; @@ -798,7 +798,7 @@ fn destroyAllEntities() void { for (&despawning, 0..) |*slot, i| { if (slot.*) |d| { const addr = @intFromPtr(d.entity); - log.fmt("[worldmarkers] destroying despawn[{d}] @0x{x}\n", .{ i, addr }); + log.fmt("destroying despawn[{d}] @0x{x}\n", .{ i, addr }); cleanupEntity(d.entity); slot.* = null; count += 1; @@ -806,7 +806,7 @@ fn destroyAllEntities() void { } if (count > 0) { - log.fmt("[worldmarkers] world cleanup: destroyed {d} entities\n", .{count}); + log.fmt("world cleanup: destroyed {d} entities\n", .{count}); } } @@ -815,7 +815,6 @@ fn destroyAllEntities() void { // ============================================================================= pub fn installHooks() void { - logging.print("[worldmarkers] Module loaded\n"); const result = mod_mutex.acquire(module_name); g_mutex = result.handle; @@ -825,17 +824,17 @@ pub fn installHooks() void { // Hook OnWorldUpdate for per-frame animation tick (runs every frame while world is active). if (world_update_hook.attach(o.FN_ON_WORLD_UPDATE, &worldUpdateDetour) != .ok) { - log.print("[worldmarkers] FAILED to hook OnWorldUpdate!\n"); + log.print("FAILED to hook OnWorldUpdate!\n"); } else { - log.print("[worldmarkers] hooked OnWorldUpdate OK\n"); + log.print("hooked OnWorldUpdate OK\n"); } // Hook CleanupWorldAndEntities to destroy our entities before world teardown. // This fires on map change, logout, AND exit - before heaps are destroyed. if (world_cleanup_hook.attach(o.FN_CLEANUP_WORLD_AND_ENTITIES, &worldCleanupDetour) != .ok) { - log.print("[worldmarkers] FAILED to hook CleanupWorldAndEntities!\n"); + log.print("FAILED to hook CleanupWorldAndEntities!\n"); } else { - log.print("[worldmarkers] hooked CleanupWorldAndEntities OK\n"); + log.print("hooked CleanupWorldAndEntities OK\n"); } } @@ -844,7 +843,7 @@ pub fn installHooks() void { /// worldCleanupDetour which fires after shutdown. pub fn onShutdown() void { for (&marker_defs) |*d| d.* = EMPTY_DEF; - log.print("[worldmarkers] defs cleared (shutdown)\n"); + log.print("defs cleared (shutdown)\n"); } pub fn removeHooks() void { diff --git a/src/minimapicons/minimapicons.zig b/src/minimapicons/minimapicons.zig index f634895..3eaf106 100644 --- a/src/minimapicons/minimapicons.zig +++ b/src/minimapicons/minimapicons.zig @@ -3,6 +3,11 @@ //! 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 @@ -16,10 +21,9 @@ const std = @import("std"); const hook = @import("zhook"); const lua = @import("../lua.zig"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const mod_mutex = @import("../mutex.zig"); - pub const module_name: [*:0]const u8 = "minimapicons"; // ============================================================================= @@ -343,6 +347,7 @@ 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; @@ -483,7 +488,7 @@ fn isUnitAllowed(obj: u32, local_player: u32) bool { // Check hostility via UnitReaction const reaction = unitReaction(local_player, obj); if (reaction < 4) { - con.fmt("[minimapicons] unit 0x{x} rejected: hostile (reaction={d}, race={d})\n", .{ obj, reaction, getRace(obj) }); + log.fmt("unit 0x{x} rejected: hostile (reaction={d}, race={d})\n", .{ obj, reaction, getRace(obj) }); return false; } @@ -499,7 +504,7 @@ fn isUnitAllowed(obj: u32, local_player: u32) bool { if (summoner != 0 and isValidPtr(summoner)) { const our_race = getRace(local_player); const their_race = getRace(summoner); - con.fmt("[minimapicons] unit 0x{x} summoner 0x{x}: our race={d} their race={d}\n", .{ obj, summoner, our_race, their_race }); + // 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; } @@ -513,7 +518,7 @@ fn isUnitAllowed(obj: u32, local_player: u32) bool { fn isGoInteractable(obj: u32) bool { const result: u8 = @truncate(hook.call(fn (u32) callconv(hook.cc.fastcall) u32, ADDR.CallSpellCastHandler, .{obj})); if (result == 0) { - con.fmt("[minimapicons] GO 0x{x} rejected: not interactable (entry={d})\n", .{ obj, getObjectEntry(obj) }); + log.fmt("GO 0x{x} rejected: not interactable (entry={d})\n", .{ obj, getObjectEntry(obj) }); } return result != 0; } @@ -623,7 +628,7 @@ fn loadTexture(path: [*:0]const u8) u32 { }); if (!status.ok() or texture == 0) { - con.print("[minimapicons] Failed to load texture\n"); + log.print("Failed to load texture\n"); return 0; } @@ -853,6 +858,13 @@ fn trackObject(info: u32, guid_lo: u32, guid_hi: u32, blip: Blip) void { }; } + // 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); @@ -980,7 +992,7 @@ fn enumVisibleObjectsDetour(callback: u32, context: u32) callconv(hook.cc.fastca else 0; if (g_local_player != prev) { - con.fmt("[minimapicons] local player: 0x{x} race={d}\n", .{ + 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), }); @@ -1177,36 +1189,36 @@ fn strEqlInsensitive(a: [*:0]const u8, b: []const u8) bool { // ============================================================================= pub fn installHooks() void { - 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; + log = logging.Logger.open(module_name, .console); if (enum_proc_hook.attach(ADDR.ObjectEnumProc, &objectEnumProcDetour) != .ok) { - con.print("[minimapicons] Failed to hook ObjectEnumProc\n"); + log.print("Failed to hook ObjectEnumProc\n"); return; } if (render_blips_hook.attach(ADDR.RenderObjectBlips, &renderObjectBlipsDetour) != .ok) { - con.print("[minimapicons] Failed to hook RenderObjectBlips\n"); + log.print("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"); + log.print("Failed to hook EnumVisibleObjects\n"); render_blips_hook.detach(); enum_proc_hook.detach(); return; } - con.print("[minimapicons] Hooks installed\n"); + 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(); - con.print("[minimapicons] filters freed (shutdown)\n"); + log.print("filters freed (shutdown)\n"); } pub fn removeHooks() void { @@ -1220,6 +1232,7 @@ pub fn removeHooks() void { for (&g_go_id_tracking) |*entry| entry.active = false; g_blip_count = 0; + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/mutex.zig b/src/mutex.zig index d4063e0..e4147eb 100644 --- a/src/mutex.zig +++ b/src/mutex.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const con = @import("console.zig"); +const con = @import("logging.zig"); const WINAPI = std.builtin.CallingConvention.winapi; extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: ?[*:0]const u8) callconv(WINAPI) ?*anyopaque; diff --git a/src/outline/api.zig b/src/outline/api.zig index 3ec6bfc..f425bed 100644 --- a/src/outline/api.zig +++ b/src/outline/api.zig @@ -5,7 +5,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const tracker = @import("tracker.zig"); const model_hook = @import("model_hook.zig"); const d3d9_hook = @import("d3d9_hook.zig"); @@ -14,6 +14,7 @@ const WINAPI = std.builtin.CallingConvention.winapi; const mod_mutex = @import("../mutex.zig"); pub const module_name: [*:0]const u8 = "outline"; +var log: logging.Logger = .{}; var g_mutex: ?*anyopaque = null; var g_is_hook_owner: bool = false; @@ -27,13 +28,14 @@ pub fn isActive() bool { /// dummy D3D9 device during engine init corrupts the d3d9 proxy's state and /// causes model rendering to stutter at ~10fps. pub fn init() bool { - con.print("[outline] 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 true; + log = logging.Logger.open(module_name, .console); + tracker.initLogger(); if (!model_hook.installHooks()) return false; return true; } @@ -50,6 +52,7 @@ pub fn cleanup() void { if (g_is_hook_owner) { d3d9_hook.removeHooks(); model_hook.removeHooks(); + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/outline/tracker.zig b/src/outline/tracker.zig index ef2fbc2..de22238 100644 --- a/src/outline/tracker.zig +++ b/src/outline/tracker.zig @@ -12,6 +12,7 @@ const std = @import("std"); const hook = @import("zhook"); +const logging = @import("../logging.zig"); const wow = @import("wow.zig"); const o = @import("offsets.zig"); const types = @import("types.zig"); @@ -307,11 +308,13 @@ pub const Diag = struct { }; pub var diag: Diag = .{}; +var log: logging.Logger = .{}; -const WINAPI = std.builtin.CallingConvention.winapi; -extern "kernel32" fn OutputDebugStringA(lpOutputString: [*:0]const u8) callconv(WINAPI) void; +pub fn initLogger() void { + log = logging.Logger.open("outline", .console); +} -/// Log diagnostic counters via OutputDebugStringA. Called from EndScene. +/// Log diagnostic counters. Called from EndScene. /// Only logs when outline activity is detected, limited to first 20 events. pub fn logDiagnostics(cached_draw_count: u32) void { const has_activity = diag.scan_targets > 0 or diag.scan_raid_marks > 0 or @@ -322,8 +325,7 @@ pub fn logDiagnostics(cached_draw_count: u32) void { if (!has_activity or diag.log_count >= 20) return; diag.log_count += 1; - var buf: [256]u8 = undefined; - const msg = std.fmt.bufPrint(&buf, "[Outline] scan t={d} r={d} d={d} | classify t={d} r={d} d={d} | cached={d}\x00", .{ + log.fmt("scan t={d} r={d} d={d} | classify t={d} r={d} d={d} | cached={d}\n", .{ diag.scan_targets, diag.scan_raid_marks, diag.scan_dead_players, @@ -331,8 +333,7 @@ pub fn logDiagnostics(cached_draw_count: u32) void { diag.classify_raid_mark, diag.classify_dead_player, cached_draw_count, - }) catch return; - OutputDebugStringA(@ptrCast(msg.ptr)); + }); } /// Reset per-frame diagnostic counters. Called at start of scanObjects. diff --git a/src/screenshot/screenshot.zig b/src/screenshot/screenshot.zig index 87276e0..87eba28 100644 --- a/src/screenshot/screenshot.zig +++ b/src/screenshot/screenshot.zig @@ -1,6 +1,6 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const png = @import("png.zig"); const WINAPI = std.builtin.CallingConvention.winapi; @@ -53,7 +53,6 @@ const ERROR_ALREADY_EXISTS: u32 = 183; // State // ============================================================================= - // CVar for compression level persistence (0–9, default 6) const CVAR_NAME = "screenshotQuality"; const CVAR_LOOKUP: usize = 0x0063DEC0; @@ -100,6 +99,7 @@ pub const module_name: [*:0]const u8 = "pngscreenshots"; var g_mutex: ?HANDLE = null; var g_is_hook_owner: bool = false; +var log: logging.Logger = .{}; pub fn isActive() bool { return g_is_hook_owner; @@ -296,13 +296,14 @@ fn writePng(path: [*:0]const u8, pixels: [*]const u8, width: u16, height: u16, l // ============================================================================= pub fn installHook() void { - con.print("[screenshot] 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; + log = logging.Logger.open(module_name, .console); + // Register CVar for compression quality persistence (saved to config.wtf) _ = registerCVar(CVAR_NAME, 0, 0, "6", 0, 1, 0, 0); @@ -320,6 +321,7 @@ pub fn installHook() void { pub fn removeHook() void { if (g_is_hook_owner) { tga_hook.detach(); + log.close(); mod_mutex.release(&g_mutex); } g_is_hook_owner = false; diff --git a/src/transmogfix/transmogfix.zig b/src/transmogfix/transmogfix.zig index e050219..cc4381f 100644 --- a/src/transmogfix/transmogfix.zig +++ b/src/transmogfix/transmogfix.zig @@ -16,7 +16,7 @@ const std = @import("std"); const hook = @import("zhook"); -const con = @import("../console.zig"); +const logging = @import("../logging.zig"); const WINAPI = std.builtin.CallingConvention.winapi; @@ -143,6 +143,7 @@ pub const module_name: [*:0]const u8 = "transmogfix"; var g_enabled: bool = true; var g_initialized: bool = false; var g_is_hook_owner: bool = false; +var log: logging.Logger = .{}; var g_mutex: ?*anyopaque = null; pub fn isActive() bool { @@ -401,7 +402,7 @@ fn processTimeouts(now: u32) void { // Re-resolve the GUID to a live object pointer - the cached // unit_ptr may be stale if the player despawned since capture. const unit = getObjectByGUID(g_other_pending[i].guid); - con.fmt("[other] TIMEOUT slot={d:2} guid=0x{X:0>16} {d}ms unit=0x{X:0>8}\n", .{ g_other_pending[i].slot, g_other_pending[i].guid, elapsed, unit }); + log.fmt("TIMEOUT slot={d:2} guid=0x{X:0>16} {d}ms unit=0x{X:0>8}\n", .{ g_other_pending[i].slot, g_other_pending[i].guid, elapsed, unit }); if (unit != 0 and (unit & 1) == 0) { const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(g_other_pending[i].slot)) * VISIBLE_ITEM_STRIDE); _ = callOriginalSetBlock(unit, field_index, 0); @@ -451,14 +452,14 @@ fn processTimeouts(now: u32) void { const dest: *u32 = @ptrFromInt(unit + UNIT_CACHED_MODELDATA_OFFSET); dest.* = box_model_data; - con.fmt("[other] REFRESH unit=0x{X:0>8} table=0x{X:0>8} boxModel=0x{X:0>8}\n", .{ unit, display_table, box_model_data }); + log.fmt("REFRESH unit=0x{X:0>8} table=0x{X:0>8} boxModel=0x{X:0>8}\n", .{ unit, display_table, box_model_data }); refreshEquipmentDisplay(unit); continue; } } // Fallback to RefreshVisualAppearance - con.fmt("[other] REFRESH fallback unit=0x{X:0>8} table=0x{X:0>8}\n", .{ unit, display_table }); + log.fmt("REFRESH fallback unit=0x{X:0>8} table=0x{X:0>8}\n", .{ unit, display_table }); if (refresh_hook.inner.trampoline != 0) { callOriginalRefresh(unit, 0, 0, 1); } @@ -503,7 +504,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 g_local_pending[slot].timestamp = now; g_local_pending[slot].active = true; g_local_pending[slot].has_durability = false; - con.fmt("[local] BLOCK clear slot={d:2} item=0x{X:0>8}\n", .{ slot, g_cached_visible_item[slot] }); + log.fmt("BLOCK clear slot={d:2} item=0x{X:0>8}\n", .{ slot, g_cached_visible_item[slot] }); return 1; // Block the clear } else if (val != 0 and g_local_pending[slot].active) { if (val == g_local_pending[slot].original_visible_item) { @@ -513,10 +514,10 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 const dur = g_local_pending[slot].captured_durability; if (dur != 0) { writeItemDurabilityDirect(slot, dur); - con.fmt("[local] APPLY dur slot={d:2} dur={d}\n", .{ slot, dur }); + log.fmt("APPLY dur slot={d:2} dur={d}\n", .{ slot, dur }); } else { // Don't block - broken items need visual update - con.fmt("[local] PASS broken slot={d:2}\n", .{slot}); + log.fmt("PASS broken slot={d:2}\n", .{slot}); g_local_pending[slot].active = false; g_local_pending[slot].has_durability = false; g_local_pending_count -= 1; @@ -529,7 +530,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 updateInventoryAlertStates(); - con.fmt("[local] BLOCK restore slot={d:2} item=0x{X:0>8} -- coalesced!\n", .{ slot, val }); + log.fmt("BLOCK restore slot={d:2} item=0x{X:0>8} -- coalesced!\n", .{ slot, val }); return 1; // Block the restore } else { // Different item value - real gear change @@ -564,7 +565,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 g_other_pending[ni].timestamp = now; g_other_pending[ni].unit_ptr = obj; g_other_pending[ni].active = true; - con.fmt("[other] BLOCK clear slot={d:2} guid=0x{X:0>16}\n", .{ slot, guid }); + log.fmt("BLOCK clear slot={d:2} guid=0x{X:0>16}\n", .{ slot, guid }); return 1; // Block the clear } } @@ -576,7 +577,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 if (elapsed < OTHER_PLAYER_TIMEOUT_MS and val == current_val) { g_other_pending[ui].active = false; g_other_pending_count -= 1; - con.fmt("[other] BLOCK restore slot={d:2} guid=0x{X:0>16} {d}ms -- coalesced!\n", .{ slot, guid, elapsed }); + log.fmt("BLOCK restore slot={d:2} guid=0x{X:0>16} {d}ms -- coalesced!\n", .{ slot, guid, elapsed }); return 1; // Block the restore } else { g_other_pending[ui].active = false; @@ -597,7 +598,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 g_local_pending[s].captured_durability = val; g_local_pending[s].has_durability = true; g_local_pending[s].timestamp = GetTickCount(); - con.fmt("[local] CATCH dur slot={d:2} dur={d}\n", .{ s, val }); + log.fmt("CATCH dur slot={d:2} dur={d}\n", .{ s, val }); return 1; // Block - captured } } @@ -626,7 +627,7 @@ fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(hook.cc.thiscall) u32 // Low word clear with pending VISIBLE_ITEM block = REAL UNEQUIP if (is_low_word and val == 0 and g_local_pending[es].active) { - con.fmt("[local] REAL UNEQUIP slot={d:2} -- replaying blocked clear\n", .{es}); + log.fmt("REAL UNEQUIP slot={d:2} -- replaying blocked clear\n", .{es}); const field_index = PLAYER_VISIBLE_ITEM_1_0 + (@as(u32, @intCast(es)) * VISIBLE_ITEM_STRIDE); _ = callOriginalSetBlock(obj, field_index, 0); @@ -661,7 +662,7 @@ fn hookRefreshVisualAppearance(unit: u32, event_data: u32, extra_data: u32, forc // LOCAL PLAYER: If we have pending SetBlock blocks, skip expensive refresh if (g_cache.valid and guid == g_cache.local_guid and g_local_pending_count > 0) { - con.fmt("[local] SKIP RefreshVisualAppearance (pending={d})\n", .{g_local_pending_count}); + log.fmt("SKIP RefreshVisualAppearance (pending={d})\n", .{g_local_pending_count}); refreshAppearanceAndEquipment(unit); const flags1: *u32 = @ptrFromInt(unit + 0xccc); const flags2: *u32 = @ptrFromInt(unit + 0xcd0); @@ -731,7 +732,7 @@ fn hookRefreshVisualAppearance(unit: u32, event_data: u32, extra_data: u32, forc const should_skip = (restored_slots > 0) and (cleared_slots == 0) and all_restores_within_timeout; if (should_skip) { - con.fmt("[other] SKIP RefreshVisualAppearance restored={d} guid=0x{X:0>16}\n", .{ restored_slots, guid }); + log.fmt("SKIP RefreshVisualAppearance restored={d} guid=0x{X:0>16}\n", .{ restored_slots, guid }); refreshAppearanceAndEquipment(unit); const flags1: *u32 = @ptrFromInt(unit + 0xccc); const flags2: *u32 = @ptrFromInt(unit + 0xcd0); @@ -764,7 +765,6 @@ fn hookSceneEnd(device: u32) callconv(hook.cc.thiscall) void { // ============================================================================= pub fn installHooks() void { - con.print("[transmogfix] Module loaded\n"); // Legacy mutex name - this DLL existed in the wild before the naming convention const result = mod_mutex.acquireLegacy("TransmogCoalesceHook", module_name); @@ -774,6 +774,7 @@ pub fn installHooks() void { g_initialized = true; return; } + log = logging.Logger.open(module_name, .console); // Initialize state (already zero-initialized by Zig defaults) g_local_pending = [1]LocalPending{.{}} ** 19; @@ -801,7 +802,7 @@ pub fn installHooks() void { } g_initialized = true; - con.print("[transmogfix] All 3 hooks installed\n"); + log.print("All 3 hooks installed\n"); } pub fn removeHooks() void { @@ -809,6 +810,7 @@ pub fn removeHooks() void { scene_end_hook.detach(); refresh_hook.detach(); set_block_hook.detach(); + log.close(); mod_mutex.release(&g_mutex); }