Fix marker crash on logout and add module lifecycle table

The marker cleanup hook on CleanupWorldAndEntities was never installed —
markers.installHooks() was missing from install() in main.zig. Entities
created via WorldMarker were never cleaned up before the game's atexit
handler iterated the hash table over freed heap memory.

Key changes:
- Add markers.installHooks() call (the actual crash fix)
- Replace manual install/uninstall/shutdown lists with a single modules
  table that drives all three phases — prevents this class of bug
- Gate marker Lua functions, addon, and keybindings behind isActive()
  so they're skipped when another DLL owns the hooks
- Add world_cleanup_hook.detach() to removeHooks() (was missing)
- Migrate from vendored libs/hook to external zhook dependency
- Unify installHooks return types to void across all modules
- Add diagnostic logging to marker cleanup (temporary, for testing)
This commit is contained in:
MarcelineVQ
2026-03-02 07:34:18 -08:00
parent 43313f439d
commit 475070e910
25 changed files with 1793 additions and 1749 deletions
+2
View File
@@ -2,3 +2,5 @@
zig-out/
reference/
research/
docs/
ideas/
+1 -1
View File
@@ -155,7 +155,7 @@ cd /media/storage/projects/zig/weirdutils
zig build
```
Target: x86-windows-msvc (32-bit DLL), Zig 0.15.
Target: x86-windows-msvc (32-bit DLL), Zig 0.16 (patched: fastcall inreg fix).
Host: Linux (Arch), game runs via Wine/DXVK.
## Hook Installation Order
+21 -16
View File
@@ -13,11 +13,12 @@ pub fn build(b: *std.Build) void {
const enable_interact = b.option(bool, "interact", "Enable interact module") orelse true;
const enable_outline = b.option(bool, "outline", "Enable outline module") orelse true;
const enable_markers = b.option(bool, "markers", "Enable markers module") orelse true;
const enable_framecrash = b.option(bool, "framecrash", "Enable framecrash fix") orelse true;
const enable_framecrash = b.option(bool, "framecrash", "Enable framecrash fix") orelse false;
const enable_combatlog = b.option(bool, "combatlog", "Enable combat log freshness") orelse true;
const enable_minimapicons = b.option(bool, "minimapicons", "Enable custom minimap icons") orelse true;
const enable_transmogfix = b.option(bool, "transmogfix", "Enable transmog update coalescing") orelse true;
const enable_assetfix = b.option(bool, "assetfix", "Enable loose file loading & permissive patch glob") orelse true;
const enable_healtextfix = b.option(bool, "healtextfix", "Enable SuperWoW heal text fix") orelse true;
// Create build options module
const build_options = b.addOptions();
@@ -30,12 +31,14 @@ pub fn build(b: *std.Build) void {
build_options.addOption(bool, "enable_minimapicons", enable_minimapicons);
build_options.addOption(bool, "enable_transmogfix", enable_transmogfix);
build_options.addOption(bool, "enable_assetfix", enable_assetfix);
build_options.addOption(bool, "enable_healtextfix", enable_healtextfix);
const build_options_module = build_options.createModule();
const hook_mod = b.dependency("hook", .{
const zhook_dep = b.dependency("zhook", .{
.target = target,
.optimize = optimize,
}).module("hook");
});
const zhook_mod = zhook_dep.module("zhook");
const lib = b.addLibrary(.{
.name = "weirdutils",
@@ -45,7 +48,7 @@ pub fn build(b: *std.Build) void {
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "hook", .module = hook_mod },
.{ .name = "zhook", .module = zhook_mod },
.{ .name = "build_options", .module = build_options_module },
},
}),
@@ -57,18 +60,19 @@ pub fn build(b: *std.Build) void {
const build_all_step = b.step("all-variants", "Build all DLL variants");
// Helper to create a single-module build
const Variant = struct { name: []const u8, screenshot: bool, interact: bool, outline: bool, markers: bool, framecrash: bool, combatlog: bool, minimapicons: bool, transmogfix: bool, assetfix: bool };
const Variant = struct { name: []const u8, screenshot: bool, interact: bool, outline: bool, markers: bool, framecrash: bool, combatlog: bool, minimapicons: bool, transmogfix: bool, assetfix: bool, healtextfix: bool };
inline for (&[_]Variant{
.{ .name = "full", .screenshot = true, .interact = true, .outline = true, .markers = true, .framecrash = true, .combatlog = true, .minimapicons = true, .transmogfix = true, .assetfix = true },
.{ .name = "screenshot", .screenshot = true, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false },
.{ .name = "interact", .screenshot = false, .interact = true, .outline = false, .markers = false, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false },
.{ .name = "outline", .screenshot = false, .interact = false, .outline = true, .markers = false, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false },
.{ .name = "markers", .screenshot = false, .interact = false, .outline = false, .markers = true, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false },
.{ .name = "framecrash", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = false, .minimapicons = false, .transmogfix = false, .assetfix = false },
.{ .name = "combatlog", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false },
.{ .name = "minimapicons", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = false, .minimapicons = true, .transmogfix = false, .assetfix = false },
.{ .name = "transmogfix", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = false, .minimapicons = false, .transmogfix = true, .assetfix = false },
.{ .name = "assetfix", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = false, .minimapicons = false, .transmogfix = false, .assetfix = true },
.{ .name = "full", .screenshot = true, .interact = true, .outline = true, .markers = true, .framecrash = true, .combatlog = true, .minimapicons = true, .transmogfix = true, .assetfix = true, .healtextfix = true },
.{ .name = "screenshot", .screenshot = true, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "interact", .screenshot = false, .interact = true, .outline = false, .markers = false, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "outline", .screenshot = false, .interact = false, .outline = true, .markers = false, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "markers", .screenshot = false, .interact = false, .outline = false, .markers = true, .framecrash = true, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "framecrash", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = false, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "combatlog", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = true, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "minimapicons", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = true, .combatlog = false, .minimapicons = true, .transmogfix = false, .assetfix = false, .healtextfix = false },
.{ .name = "transmogfix", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = false, .minimapicons = false, .transmogfix = true, .assetfix = false, .healtextfix = false },
.{ .name = "assetfix", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = false, .minimapicons = false, .transmogfix = false, .assetfix = true, .healtextfix = false },
.{ .name = "healtextfix", .screenshot = false, .interact = false, .outline = false, .markers = false, .framecrash = false, .combatlog = false, .minimapicons = false, .transmogfix = false, .assetfix = false, .healtextfix = true },
}) |variant| {
const opts = b.addOptions();
opts.addOption(bool, "enable_screenshot", variant.screenshot);
@@ -80,6 +84,7 @@ pub fn build(b: *std.Build) void {
opts.addOption(bool, "enable_minimapicons", variant.minimapicons);
opts.addOption(bool, "enable_transmogfix", variant.transmogfix);
opts.addOption(bool, "enable_assetfix", variant.assetfix);
opts.addOption(bool, "enable_healtextfix", variant.healtextfix);
const variant_lib = b.addLibrary(.{
.name = variant.name,
@@ -89,7 +94,7 @@ pub fn build(b: *std.Build) void {
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "hook", .module = hook_mod },
.{ .name = "zhook", .module = zhook_mod },
.{ .name = "build_options", .module = opts.createModule() },
},
}),
+2 -2
View File
@@ -3,8 +3,8 @@
.version = "0.1.0",
.fingerprint = 0x54f0a9542562d318,
.dependencies = .{
.hook = .{
.path = "libs/hook",
.zhook = .{
.path = "../zhook",
},
},
.paths = .{
-35
View File
@@ -1,35 +0,0 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const hwbp = b.option(bool, "hwbp", "Use hardware breakpoint hooks instead of inline patching") orelse false;
const options = b.addOptions();
options.addOption(bool, "use_hwbp", hwbp);
const hook_mod = b.addModule("hook", .{
.root_source_file = b.path("src/hook.zig"),
.target = target,
.optimize = optimize,
});
hook_mod.addOptions("config", options);
// x86 length disassembler — standalone, no dependencies
const x86dis_mod = b.addModule("x86dis", .{
.root_source_file = b.path("src/x86dis.zig"),
.target = target,
.optimize = optimize,
});
// Generic hook — uses x86dis + hook for auto-sizing trampolines
const generic_hook_mod = b.addModule("generic_hook", .{
.root_source_file = b.path("src/generic_hook.zig"),
.target = target,
.optimize = optimize,
});
generic_hook_mod.addImport("x86dis", x86dis_mod);
generic_hook_mod.addImport("hook.zig", hook_mod);
generic_hook_mod.addOptions("config", options);
}
-10
View File
@@ -1,10 +0,0 @@
.{
.name = .hook,
.version = "0.1.0",
.fingerprint = 0xa4584355bc807307,
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}
-279
View File
@@ -1,279 +0,0 @@
//! Generic x86 inline hook — no manual prologue size or fixup lists needed.
//!
//! Uses the x86 length disassembler (HDE32 port) to automatically determine
//! how many prologue bytes to steal, and relocates all relative instructions.
//!
//! Combines MinHook's compact disassembler with HadesMem's type-safe approach:
//! declare the function signature once at comptime, get a correctly-typed
//! trampoline and detour with zero manual casting.
//!
//! ## Low-level API (GenericHook)
//!
//! ```zig
//! var my_hook: GenericHook = .{};
//! if (my_hook.install(0x401000, @intFromPtr(&myDetour)) == .ok) {
//! const orig = my_hook.getTrampoline(OrigFnType);
//! _ = orig();
//! }
//! my_hook.remove();
//! ```
//!
//! ## Type-safe API (Detour) — HadesMem-inspired
//!
//! ```zig
//! const MyHook = Detour(fn (u32, u32) callconv(.{ .x86_stdcall = .{} }) u32);
//! var hook: MyHook = .{};
//! hook.attach(0x401000, myDetour);
//! // Inside detour: hook.callOriginal(.{arg1, arg2});
//! ```
const std = @import("std");
const x86dis = @import("x86dis");
const hook_base = @import("hook.zig");
const VirtualAlloc = hook_base.VirtualAlloc;
const VirtualFree = hook_base.VirtualFree;
const PAGE_EXECUTE_READWRITE = hook_base.PAGE_EXECUTE_READWRITE;
const MEM_COMMIT = hook_base.MEM_COMMIT;
const MEM_RELEASE = hook_base.MEM_RELEASE;
const writeProtected = hook_base.writeProtected;
const JMP_SIZE: usize = 5; // E9 + rel32
const MAX_STOLEN: usize = 32;
const TRAMPOLINE_BUF: usize = 64;
// ═══════════════════════════════════════════════════════════════════════
// GenericHook — low-level auto-sizing hook
// ═══════════════════════════════════════════════════════════════════════
pub const GenericHook = struct {
mem: ?[*]u8 = null,
trampoline: usize = 0,
target: usize = 0,
stolen_size: usize = 0,
saved_bytes: [MAX_STOLEN]u8 = undefined,
pub const Error = enum {
ok,
alloc_failed,
disasm_error,
prologue_too_short,
unsupported_relocation,
};
/// Analyse target, build trampoline, patch target → detour. One call.
pub fn install(self: *GenericHook, target: usize, detour_addr: usize) Error {
const err = self.prepare(target);
if (err != .ok) return err;
self.activate(detour_addr);
return .ok;
}
/// Phase 1: disassemble prologue, allocate trampoline, copy + relocate.
pub fn prepare(self: *GenericHook, target: usize) Error {
if (self.mem != null) return .ok;
const src: [*]const u8 = @ptrFromInt(target);
// ── determine how many bytes to steal ──
var stolen: usize = 0;
while (stolen < JMP_SIZE) {
const insn = x86dis.decode(src + stolen);
if (insn.flags & x86dis.F_ERROR != 0) return .disasm_error;
if (insn.len == 0) return .disasm_error;
stolen += insn.len;
if (stolen > MAX_STOLEN) return .prologue_too_short;
}
// ── allocate ──
const mem = VirtualAlloc(null, TRAMPOLINE_BUF, MEM_COMMIT, PAGE_EXECUTE_READWRITE) orelse return .alloc_failed;
self.mem = mem;
self.target = target;
self.stolen_size = stolen;
self.trampoline = @intFromPtr(mem);
@memcpy(self.saved_bytes[0..stolen], src[0..stolen]);
// ── check if already hooked (E9 at target) — chain through ──
if (src[0] == 0xE9) {
const other_detour = hook_base.rel32Target(target);
mem[0] = 0xE9;
hook_base.writeRel32(mem + 1, self.trampoline + 1, other_detour);
return .ok;
}
// ── build trampoline: copy + relocate ──
var t_pos: usize = 0;
var s_pos: usize = 0;
while (s_pos < stolen) {
const insn = x86dis.decode(src + s_pos);
const op = insn.opcode;
const src_addr = target + s_pos;
const dst_addr = self.trampoline + t_pos;
if (insn.flags & x86dis.F_RELATIVE != 0) {
if (op == 0xE8 or op == 0xE9) {
// CALL/JMP rel32
const abs = hook_base.rel32Target(src_addr);
mem[t_pos] = op;
hook_base.writeRel32(mem + t_pos + 1, dst_addr + 1, abs);
t_pos += 5;
} else if (op == 0x0F and insn.opcode2 >= 0x80 and insn.opcode2 <= 0x8F) {
// Jcc rel32 (0F 80-8F)
const abs = jcc32Target(src_addr);
mem[t_pos] = 0x0F;
mem[t_pos + 1] = insn.opcode2;
hook_base.writeRel32(mem + t_pos + 2, dst_addr + 2, abs);
t_pos += 6;
} else if (op >= 0x70 and op <= 0x7F) {
// Short Jcc → expand to near Jcc (0F 8x)
const offset = @as(i8, @bitCast(src[s_pos + 1]));
const abs: usize = @bitCast(@as(isize, @intCast(src_addr + 2)) + offset);
mem[t_pos] = 0x0F;
mem[t_pos + 1] = op + 0x10;
hook_base.writeRel32(mem + t_pos + 2, dst_addr + 2, abs);
t_pos += 6;
} else if (op == 0xEB) {
// Short JMP → expand to near JMP (E9)
const offset = @as(i8, @bitCast(src[s_pos + 1]));
const abs: usize = @bitCast(@as(isize, @intCast(src_addr + 2)) + offset);
mem[t_pos] = 0xE9;
hook_base.writeRel32(mem + t_pos + 1, dst_addr + 1, abs);
t_pos += 5;
} else {
// LOOP/JECXZ or unknown — cannot trivially expand
self.cleanup();
return .unsupported_relocation;
}
} else {
// Non-relative — copy verbatim
@memcpy(mem[t_pos .. t_pos + insn.len], src[s_pos .. s_pos + insn.len]);
t_pos += insn.len;
}
s_pos += insn.len;
}
// ── JMP back to original code after stolen bytes ──
mem[t_pos] = 0xE9;
hook_base.writeRel32(
mem + t_pos + 1,
self.trampoline + t_pos + 1,
target + stolen,
);
return .ok;
}
/// Phase 2: write the E9 JMP patch at the target.
pub fn activate(self: *GenericHook, detour_addr: usize) void {
var patch: [MAX_STOLEN]u8 = .{0x90} ** MAX_STOLEN;
patch[0] = 0xE9;
hook_base.writeRel32(patch[1..5], self.target + 1, detour_addr);
writeProtected(self.target, patch[0..self.stolen_size]);
}
/// Restore original bytes and free trampoline memory.
pub fn remove(self: *GenericHook) void {
if (self.mem == null) return;
writeProtected(self.target, self.saved_bytes[0..self.stolen_size]);
_ = VirtualFree(@ptrFromInt(@intFromPtr(self.mem.?)), 0, MEM_RELEASE);
self.mem = null;
}
/// Get trampoline as a typed function pointer.
pub fn getTrampoline(self: *const GenericHook, comptime T: type) T {
return @ptrFromInt(self.trampoline);
}
fn cleanup(self: *GenericHook) void {
if (self.mem) |m| {
_ = VirtualFree(@ptrFromInt(@intFromPtr(m)), 0, MEM_RELEASE);
self.mem = null;
}
}
};
// ═══════════════════════════════════════════════════════════════════════
// Detour(FnType) — HadesMem-style type-safe generic hook
// ═══════════════════════════════════════════════════════════════════════
/// Comptime-generic typed detour. Declare the target function's type once;
/// get type-checked attach/callOriginal with no manual pointer casts.
///
/// ```zig
/// const StdcallU32x2 = fn (u32, u32) callconv(.{ .x86_stdcall = .{} }) u32;
/// const MyHook = generic_hook.Detour(StdcallU32x2);
/// var hook: MyHook = .{};
/// hook.attach(0x401000, &myDetour);
/// // in detour: hook.callOriginal(.{ a, b });
/// hook.detach();
/// ```
pub fn Detour(comptime FnType: type) type {
const FnInfo = @typeInfo(FnType).@"fn";
const FnPtr = *const FnType;
const ReturnType = FnInfo.return_type orelse void;
const ParamTypes = FnInfo.params;
return struct {
inner: GenericHook = .{},
const Self = @This();
/// Hook the function at `target` to redirect to `detour`.
pub fn attach(self: *Self, target: usize, detour: FnPtr) GenericHook.Error {
return self.inner.install(target, @intFromPtr(detour));
}
/// Call the original (pre-hook) function through the trampoline.
pub fn callOriginal(self: *const Self, args: anytype) ReturnType {
const orig: FnPtr = @ptrFromInt(self.inner.trampoline);
return @call(.auto, orig, coerceArgs(ParamTypes, args));
}
/// Unhook: restore original bytes, free trampoline.
pub fn detach(self: *Self) void {
self.inner.remove();
}
/// Get the trampoline as the correctly-typed function pointer.
pub fn original(self: *const Self) FnPtr {
return @ptrFromInt(self.inner.trampoline);
}
};
}
/// Coerce a tuple of args into the exact parameter types expected.
fn coerceArgs(comptime params: anytype, args: anytype) CoercedTuple(params) {
var result: CoercedTuple(params) = undefined;
inline for (0..params.len) |idx| {
@field(result, std.fmt.comptimePrint("{d}", .{idx})) = args[idx];
}
return result;
}
fn CoercedTuple(comptime params: anytype) type {
var fields: [params.len]std.builtin.Type.StructField = undefined;
inline for (0..params.len) |idx| {
fields[idx] = .{
.name = std.fmt.comptimePrint("{d}", .{idx}),
.type = params[idx].type.?,
.default_value_ptr = null,
.is_comptime = false,
.alignment = 0,
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.fields = &fields,
.decls = &.{},
.is_tuple = true,
} });
}
/// Resolve absolute target of a Jcc rel32 (0F 8x xx xx xx xx) — 6 byte insn.
fn jcc32Target(addr: usize) usize {
const disp: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 2)).*);
return (addr + 6) +% disp;
}
-470
View File
@@ -1,470 +0,0 @@
//! x86 inline hooking library for Windows DLL injection.
//!
//! Provides a `Hook` struct for patching function prologues with JMP detours,
//! building trampolines to call the original, and chaining with other hooks.
//! Also includes memory read/write helpers, rel32 arithmetic, a generic
//! `fastcall` caller, and a fastcall-to-cdecl thunk builder.
//!
//! ## Quick start
//!
//! ```zig
//! const hook = @import("hook.zig");
//!
//! var my_hook = hook.Hook{};
//!
//! // One-shot: prepare trampoline + patch in one call.
//! // The last arg is a slice of opcode offsets within the prologue that
//! // contain E8/E9 (CALL/JMP rel32) instructions needing fixup.
//! _ = my_hook.install(target_addr, prologue_size, @intFromPtr(&detour), &.{1});
//!
//! // Two-phase (when you need the alloc block before patching, e.g. for a thunk):
//! _ = my_hook.prepare(target_addr, prologue_size, &.{});
//! const thunk_buf = my_hook.mem.? + 32;
//! _ = hook.buildFastcallToCdeclThunk(thunk_buf, @intFromPtr(&hookImpl), 1);
//! my_hook.activate(@intFromPtr(thunk_buf));
//!
//! // Call the original from inside the detour:
//! const orig = my_hook.getTrampoline(*const fn () callconv(.{ .x86_stdcall = .{} }) void);
//! orig();
//!
//! // Unhook (restore original bytes, free memory):
//! my_hook.remove();
//! ```
const std = @import("std");
const use_hwbp = @import("config").use_hwbp;
// =============================================================================
// Windows API
// =============================================================================
const WINAPI = std.builtin.CallingConvention.winapi;
pub const PAGE_EXECUTE_READWRITE: u32 = 0x40;
pub const MEM_COMMIT: u32 = 0x1000;
pub const MEM_RELEASE: u32 = 0x8000;
extern "kernel32" fn VirtualProtect(
lpAddress: *anyopaque,
dwSize: usize,
flNewProtect: u32,
lpflOldProtect: *u32,
) callconv(WINAPI) i32;
extern "kernel32" fn VirtualAlloc(
lpAddress: ?*anyopaque,
dwSize: usize,
flAllocationType: u32,
flProtect: u32,
) callconv(WINAPI) ?[*]u8;
extern "kernel32" fn VirtualFree(
lpAddress: *anyopaque,
dwSize: usize,
dwFreeType: u32,
) callconv(WINAPI) i32;
// =============================================================================
// Hardware breakpoint support (DR0-DR3 + Vectored Exception Handler)
// =============================================================================
const CONTEXT_DEBUG_REGISTERS: u32 = 0x00010010;
const EXCEPTION_SINGLE_STEP: u32 = 0x80000004;
const EXCEPTION_CONTINUE_EXECUTION: i32 = -1;
const EXCEPTION_CONTINUE_SEARCH: i32 = 0;
extern "kernel32" fn GetCurrentThread() callconv(WINAPI) *anyopaque;
extern "kernel32" fn GetThreadContext(
hThread: *anyopaque,
lpContext: *CONTEXT,
) callconv(WINAPI) i32;
extern "kernel32" fn SetThreadContext(
hThread: *anyopaque,
lpContext: *const CONTEXT,
) callconv(WINAPI) i32;
extern "kernel32" fn AddVectoredExceptionHandler(
First: u32,
Handler: *const fn (*EXCEPTION_POINTERS) callconv(WINAPI) i32,
) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn RemoveVectoredExceptionHandler(
Handle: *anyopaque,
) callconv(WINAPI) u32;
const CONTEXT = extern struct {
ContextFlags: u32,
Dr0: u32,
Dr1: u32,
Dr2: u32,
Dr3: u32,
Dr6: u32,
Dr7: u32,
FloatSave: [112]u8,
SegGs: u32,
SegFs: u32,
SegEs: u32,
SegDs: u32,
Edi: u32,
Esi: u32,
Ebx: u32,
Edx: u32,
Ecx: u32,
Eax: u32,
Ebp: u32,
Eip: u32,
SegCs: u32,
EFlags: u32,
Esp: u32,
SegSs: u32,
ExtendedRegisters: [512]u8,
};
const EXCEPTION_RECORD = extern struct {
ExceptionCode: u32,
ExceptionFlags: u32,
ExceptionRecord: ?*EXCEPTION_RECORD,
ExceptionAddress: ?*anyopaque,
NumberParameters: u32,
ExceptionInformation: [15]usize,
};
const EXCEPTION_POINTERS = extern struct {
ExceptionRecord: *EXCEPTION_RECORD,
ContextRecord: *CONTEXT,
};
var hwbp_slots: [4]?*Hook = .{ null, null, null, null };
var hwbp_detours: [4]usize = .{ 0, 0, 0, 0 };
var veh_handle: ?*anyopaque = null;
fn vehHandler(info: *EXCEPTION_POINTERS) callconv(WINAPI) i32 {
if (info.ExceptionRecord.ExceptionCode != EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_SEARCH;
const eip = info.ContextRecord.Eip;
for (0..4) |i| {
if (hwbp_slots[i]) |h| {
if (h.target == eip) {
info.ContextRecord.Eip = @intCast(hwbp_detours[i]);
return EXCEPTION_CONTINUE_EXECUTION;
}
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
// =============================================================================
// Memory helpers
// =============================================================================
/// Read a value of type `T` from an arbitrary memory address (unaligned).
pub fn readMem(comptime T: type, addr: usize) T {
return @as(*align(1) const T, @ptrFromInt(addr)).*;
}
/// Write raw bytes to an arbitrary memory address. No protection change —
/// caller must ensure the page is writable (or use `writeProtected`).
pub fn writeMem(addr: usize, bytes: []const u8) void {
const dest: [*]u8 = @ptrFromInt(addr);
for (bytes, 0..) |b, i| {
dest[i] = b;
}
}
/// Write bytes to a potentially read-only/executable page. Temporarily sets
/// PAGE_EXECUTE_READWRITE, writes, then restores the original protection.
pub fn writeProtected(addr: usize, bytes: []const u8) void {
var old: u32 = 0;
_ = VirtualProtect(@ptrFromInt(addr), bytes.len, PAGE_EXECUTE_READWRITE, &old);
writeMem(addr, bytes);
_ = VirtualProtect(@ptrFromInt(addr), bytes.len, old, &old);
}
// =============================================================================
// Rel32 helpers
// =============================================================================
/// Resolve the absolute target of an E8 (CALL) or E9 (JMP) at `addr`.
/// Reads the signed rel32 operand at addr+1 and computes addr+5+offset.
pub fn rel32Target(addr: usize) usize {
const offset: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 1)).*);
return (addr + 5) +% offset;
}
/// Write a rel32 displacement into dest[0..4] such that a JMP/CALL
/// from address `from` reaches `to`. Displacement = to - (from + 4).
pub fn writeRel32(dest: [*]u8, from: usize, to: usize) void {
std.mem.writeInt(u32, dest[0..4], to -% (from + 4), .little);
}
// =============================================================================
// Calling convention helper
// =============================================================================
/// Call a __fastcall function at `addr` with ECX and EDX arguments.
/// Dispatches on return type: void, f64 (x87 ST0), or integer/pointer (EAX).
pub fn fastcall(comptime R: type, addr: usize, ecx: anytype, edx: anytype) R {
if (R == f64) {
return asm volatile ("call *%[func]"
: [ret] "={st}" (-> f64),
: [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr),
: .{ .eax = true, .memory = true, .cc = true }
);
} else if (R == void) {
asm volatile ("call *%[func]"
:
: [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr),
: .{ .eax = true, .memory = true, .cc = true }
);
} else {
return asm volatile ("call *%[func]"
: [ret] "={eax}" (-> R),
: [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr),
: .{ .memory = true, .cc = true }
);
}
}
// =============================================================================
// Hook struct
// =============================================================================
const ALLOC_SIZE: usize = 64;
const TRAMPOLINE_RESERVE: usize = 32;
const MAX_PROLOGUE: usize = 16;
pub const Hook = struct {
mem: ?[*]u8 = null,
trampoline: usize = 0,
target: usize = 0,
prologue_size: usize = 0,
saved_bytes: [MAX_PROLOGUE]u8 = undefined,
/// Allocate executable memory, save current bytes at target, and build the
/// trampoline. Does NOT patch the target yet — call activate() after.
/// `rel32_fixups` is a slice of opcode offsets within the prologue that
/// contain E8/E9 instructions needing rel32 adjustment.
pub fn prepare(
self: *Hook,
target: usize,
prologue_size: usize,
rel32_fixups: []const usize,
) bool {
if (self.mem != null) return true;
const mem = VirtualAlloc(null, ALLOC_SIZE, MEM_COMMIT, PAGE_EXECUTE_READWRITE) orelse return false;
self.mem = mem;
self.target = target;
self.prologue_size = prologue_size;
// Save current bytes for remove()
const src: [*]const u8 = @ptrFromInt(target);
@memcpy(self.saved_bytes[0..prologue_size], src[0..prologue_size]);
// Build trampoline
self.trampoline = @intFromPtr(mem);
if (src[0] == 0xE9) {
// Another DLL already hooked — resolve their JMP and chain through it
const other_detour = rel32Target(target);
mem[0] = 0xE9;
writeRel32(mem + 1, self.trampoline + 1, other_detour);
} else {
// Original prologue — copy bytes, fix up any relative instructions, JMP back
@memcpy(mem[0..prologue_size], src[0..prologue_size]);
for (rel32_fixups) |opcode_offset| {
const abs_target = rel32Target(target + opcode_offset);
const tramp_operand = self.trampoline + opcode_offset + 1;
writeRel32(mem + opcode_offset + 1, tramp_operand, abs_target);
}
mem[prologue_size] = 0xE9;
writeRel32(
mem + prologue_size + 1,
self.trampoline + prologue_size + 1,
target + prologue_size,
);
}
return true;
}
/// Write the E9 JMP patch (inline mode) or set a hardware breakpoint
/// (HWBP mode) to redirect target → detour_addr.
pub fn activate(self: *Hook, detour_addr: usize) void {
if (use_hwbp) {
// Register VEH on first use
if (veh_handle == null) {
veh_handle = AddVectoredExceptionHandler(1, &vehHandler);
}
// Find free DR slot
const slot: u2 = for (0..4) |i| {
if (hwbp_slots[i] == null) break @as(u2, @intCast(i));
} else return; // all 4 slots occupied
hwbp_slots[slot] = self;
hwbp_detours[slot] = detour_addr;
const thread = GetCurrentThread();
var ctx: CONTEXT = std.mem.zeroes(CONTEXT);
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
_ = GetThreadContext(thread, &ctx);
// Set DRn to target address
switch (slot) {
0 => { ctx.Dr0 = @intCast(self.target); },
1 => { ctx.Dr1 = @intCast(self.target); },
2 => { ctx.Dr2 = @intCast(self.target); },
3 => { ctx.Dr3 = @intCast(self.target); },
}
// Enable local execute breakpoint in DR7
const s: u5 = slot;
ctx.Dr7 |= @as(u32, 1) << (s * 2);
ctx.Dr7 &= ~(@as(u32, 0xF) << (16 + s * 4));
_ = SetThreadContext(thread, &ctx);
} else {
var patch: [MAX_PROLOGUE]u8 = .{0x90} ** MAX_PROLOGUE;
patch[0] = 0xE9;
writeRel32(patch[1..5], self.target + 1, detour_addr);
writeProtected(self.target, patch[0..self.prologue_size]);
}
}
/// Convenience: prepare + activate in one call.
pub fn install(
self: *Hook,
target: usize,
prologue_size: usize,
detour_addr: usize,
rel32_fixups: []const usize,
) bool {
if (!self.prepare(target, prologue_size, rel32_fixups)) return false;
self.activate(detour_addr);
return true;
}
/// Restore original bytes (inline) or clear the DR slot (HWBP), then free
/// the trampoline memory.
pub fn remove(self: *Hook) void {
if (self.mem == null) return;
if (use_hwbp) {
for (0..4) |i| {
if (hwbp_slots[i]) |h| {
if (h == self) {
const thread = GetCurrentThread();
var ctx: CONTEXT = std.mem.zeroes(CONTEXT);
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
_ = GetThreadContext(thread, &ctx);
switch (@as(u2, @intCast(i))) {
0 => { ctx.Dr0 = 0; },
1 => { ctx.Dr1 = 0; },
2 => { ctx.Dr2 = 0; },
3 => { ctx.Dr3 = 0; },
}
const s: u5 = @intCast(i);
ctx.Dr7 &= ~(@as(u32, 1) << (s * 2));
ctx.Dr7 &= ~(@as(u32, 0xF) << (16 + s * 4));
_ = SetThreadContext(thread, &ctx);
hwbp_slots[i] = null;
hwbp_detours[i] = 0;
break;
}
}
}
} else {
writeProtected(self.target, self.saved_bytes[0..self.prologue_size]);
}
_ = VirtualFree(@ptrFromInt(@intFromPtr(self.mem.?)), 0, MEM_RELEASE);
self.mem = null;
}
/// Cast trampoline address to a typed function pointer for calling the original.
pub fn getTrampoline(self: *const Hook, comptime T: type) T {
return @ptrFromInt(self.trampoline);
}
};
// =============================================================================
// Thunk builder: fastcall(ECX, EDX, stack...) → cdecl(stack, stack, stack...)
// =============================================================================
/// Build a fastcall-to-cdecl bridge thunk in `buf`.
/// `cdecl_fn` is the address of the cdecl target function.
/// `stack_arg_count` is the number of extra stack arguments beyond ECX/EDX.
/// Returns the total thunk size in bytes.
///
/// Generated code:
/// push dword [esp + 4*N] ; for each stack arg, right-to-left
/// ...
/// push edx ; arg 2
/// push ecx ; arg 1
/// mov eax, <cdecl_fn>
/// call eax
/// add esp, (2 + stack_arg_count) * 4
/// ret (stack_arg_count * 4)
pub fn buildFastcallToCdeclThunk(buf: [*]u8, cdecl_fn: usize, stack_arg_count: u8) usize {
var pos: usize = 0;
// Push stack args right-to-left. At entry, [esp] = return addr,
// [esp+4] = first stack arg, [esp+8] = second, etc.
// But each push shifts esp, so we always read from [esp + 4 * stack_arg_count]
// (the offset stays constant because we push the same number of times as the depth grows).
var i: u8 = stack_arg_count;
while (i > 0) : (i -= 1) {
// push dword ptr [esp + 4 * stack_arg_count]
buf[pos] = 0xFF;
buf[pos + 1] = 0x74;
buf[pos + 2] = 0x24;
buf[pos + 3] = stack_arg_count * 4;
pos += 4;
}
// push edx (arg 2)
buf[pos] = 0x52;
pos += 1;
// push ecx (arg 1)
buf[pos] = 0x51;
pos += 1;
// mov eax, <cdecl_fn>
buf[pos] = 0xB8;
std.mem.writeInt(u32, buf[pos + 1 ..][0..4], @intCast(cdecl_fn), .little);
pos += 5;
// call eax
buf[pos] = 0xFF;
buf[pos + 1] = 0xD0;
pos += 2;
// add esp, (2 + stack_arg_count) * 4 (cdecl caller cleanup)
const cleanup: u8 = (2 + stack_arg_count) * 4;
buf[pos] = 0x83;
buf[pos + 1] = 0xC4;
buf[pos + 2] = cleanup;
pos += 3;
// ret (stack_arg_count * 4) (fastcall callee cleans stack args)
if (stack_arg_count == 0) {
buf[pos] = 0xC3; // ret
pos += 1;
} else {
buf[pos] = 0xC2; // ret imm16
std.mem.writeInt(u16, buf[pos + 1 ..][0..2], @as(u16, stack_arg_count) * 4, .little);
pos += 3;
}
return pos;
}
-402
View File
@@ -1,402 +0,0 @@
//! Minimal x86 (32-bit) length disassembler.
//!
//! Faithful port of Vyacheslav Patkov's Hacker Disassembler Engine 32 (HDE32),
//! used by MinHook. Only computes instruction length + flags needed for
//! relocation (F_RELATIVE). ~470 bytes of table data, compiles to ~1-2 KB.
const std = @import("std");
// ── public flags ───────────────────────────────────────────────────────
pub const F_MODRM: u32 = 0x00000001;
pub const F_SIB: u32 = 0x00000002;
pub const F_IMM8: u32 = 0x00000004;
pub const F_IMM16: u32 = 0x00000008;
pub const F_IMM32: u32 = 0x00000010;
pub const F_DISP8: u32 = 0x00000020;
pub const F_DISP16: u32 = 0x00000040;
pub const F_DISP32: u32 = 0x00000080;
pub const F_RELATIVE: u32 = 0x00000100;
pub const F_ERROR: u32 = 0x00001000;
// ── internal cflags ────────────────────────────────────────────────────
const C_MODRM: u8 = 0x01;
const C_IMM8: u8 = 0x02;
const C_IMM16: u8 = 0x04;
const C_IMM_P66: u8 = 0x10;
const C_REL8: u8 = 0x20;
const C_REL32: u8 = 0x40;
const C_GROUP: u8 = 0x80;
const C_ERROR: u8 = 0xff;
const PRE_NONE: u8 = 0x01;
const PRE_66: u8 = 0x08;
const PRE_67: u8 = 0x10;
const DELTA_OPCODES: usize = 0x4a;
// HDE32 opcode table — verbatim from table32.h
const hde32_table = [_]u8{
0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3, 0xa8, 0xa3,
0xa8, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xac, 0xaa, 0xb2, 0xaa, 0x9f, 0x9f,
0x9f, 0x9f, 0xb5, 0xa3, 0xa3, 0xa4, 0xaa, 0xaa, 0xba, 0xaa, 0x96, 0xaa, 0xa8, 0xaa, 0xc3,
0xc3, 0x96, 0x96, 0xb7, 0xae, 0xd6, 0xbd, 0xa3, 0xc5, 0xa3, 0xa3, 0x9f, 0xc3, 0x9c, 0xaa,
0xaa, 0xac, 0xaa, 0xbf, 0x03, 0x7f, 0x11, 0x7f, 0x01, 0x7f, 0x01, 0x3f, 0x01, 0x01, 0x90,
0x82, 0x7d, 0x97, 0x59, 0x59, 0x59, 0x59, 0x59, 0x7f, 0x59, 0x59, 0x60, 0x7d, 0x7f, 0x7f,
0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x9a, 0x88, 0x7d,
0x59, 0x50, 0x50, 0x50, 0x50, 0x59, 0x59, 0x59, 0x59, 0x61, 0x94, 0x61, 0x9e, 0x59, 0x59,
0x85, 0x59, 0x92, 0xa3, 0x60, 0x60, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59, 0x59,
0x59, 0x59, 0x9f, 0x01, 0x03, 0x01, 0x04, 0x03, 0xd5, 0x03, 0xcc, 0x01, 0xbc, 0x03, 0xf0,
0x10, 0x10, 0x10, 0x10, 0x50, 0x50, 0x50, 0x50, 0x14, 0x20, 0x20, 0x20, 0x20, 0x01, 0x01,
0x01, 0x01, 0xc4, 0x02, 0x10, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0xc0, 0xc2, 0x10, 0x11,
0x02, 0x03, 0x11, 0x03, 0x03, 0x04, 0x00, 0x00, 0x14, 0x00, 0x02, 0x00, 0x00, 0xc6, 0xc8,
0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0xff, 0xca,
0x01, 0x01, 0x01, 0x00, 0x06, 0x00, 0x04, 0x00, 0xc0, 0xc2, 0x01, 0x01, 0x03, 0x01, 0xff,
0xff, 0x01, 0x00, 0x03, 0xc4, 0xc4, 0xc6, 0x03, 0x01, 0x01, 0x01, 0xff, 0x03, 0x03, 0x03,
0xc8, 0x40, 0x00, 0x0a, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x7f, 0x00, 0x33, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xbf, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00,
0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x7f, 0x00, 0x00, 0xff, 0x4a, 0x4a, 0x4a, 0x4a, 0x4b, 0x52, 0x4a, 0x4a, 0x4a, 0x4a, 0x4f,
0x4c, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x55, 0x45, 0x40, 0x4a, 0x4a, 0x4a,
0x45, 0x59, 0x4d, 0x46, 0x4a, 0x5d, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x4a,
0x4a, 0x4a, 0x4a, 0x4a, 0x4a, 0x61, 0x63, 0x67, 0x4e, 0x4a, 0x4a, 0x6b, 0x6d, 0x4a, 0x4a,
0x45, 0x6d, 0x4a, 0x4a, 0x44, 0x45, 0x4a, 0x4a, 0x00, 0x00, 0x00, 0x02, 0x0d, 0x06, 0x06,
0x06, 0x06, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x06, 0x00, 0x06, 0x06, 0x02, 0x06,
0x00, 0x0a, 0x0a, 0x07, 0x07, 0x06, 0x02, 0x05, 0x05, 0x02, 0x02, 0x00, 0x00, 0x04, 0x04,
0x04, 0x04, 0x00, 0x00, 0x00, 0x0e, 0x05, 0x06, 0x06, 0x06, 0x01, 0x06, 0x00, 0x00, 0x08,
0x00, 0x10, 0x00, 0x18, 0x00, 0x20, 0x00, 0x28, 0x00, 0x30, 0x00, 0x80, 0x01, 0x82, 0x01,
0x86, 0x00, 0xf6, 0xcf, 0xfe, 0x3f, 0xab, 0x00, 0xb0, 0x00, 0xb1, 0x00, 0xb3, 0x00, 0xba,
0xf8, 0xbb, 0x00, 0xc0, 0x00, 0xc1, 0x00, 0xc7, 0xbf, 0x62, 0xff, 0x00, 0x8d, 0xff, 0x00,
0xc4, 0xff, 0x00, 0xc5, 0xff, 0x00,
};
pub const Insn = struct {
len: u8,
flags: u32,
opcode: u8,
opcode2: u8,
};
/// Decode the instruction at `code`, returning its length and flags.
pub fn decode(code: [*]const u8) Insn {
var result = Insn{ .len = 0, .flags = 0, .opcode = 0, .opcode2 = 0 };
var p: usize = 0;
var pref: u8 = 0;
var disp_size: u8 = 0;
// ── prefixes ──
var prefix_count: u8 = 16;
prefix_loop: while (prefix_count > 0) : (prefix_count -= 1) {
switch (code[p]) {
0xf3, 0xf2 => pref |= if (code[p] == 0xf3) 0x04 else 0x02,
0xf0 => pref |= 0x20, // PRE_LOCK
0x26, 0x2e, 0x36, 0x3e, 0x64, 0x65 => pref |= 0x40, // PRE_SEG
0x66 => pref |= PRE_66,
0x67 => pref |= PRE_67,
else => break :prefix_loop,
}
p += 1;
}
result.flags = @as(u32, pref) << 23;
if (pref == 0) pref |= PRE_NONE;
// ── opcode ──
var ht_base: usize = 0;
var c = code[p];
p += 1;
result.opcode = c;
if (c == 0x0f) {
// two-byte opcode
result.opcode2 = code[p];
c = code[p];
p += 1;
ht_base = DELTA_OPCODES;
} else if (c >= 0xa0 and c <= 0xa3) {
// MOV moffs — address-size prefix swaps operand-size behavior
if (pref & PRE_67 != 0)
pref |= PRE_66
else
pref &= ~PRE_66;
}
const opcode = c;
// ── two-level table lookup: ht[ht[opcode/4] + (opcode%4)] ──
var cflags: u8 = blk: {
const idx1 = ht_base + @as(usize, opcode / 4);
if (idx1 >= hde32_table.len) break :blk C_ERROR;
const idx2 = ht_base + @as(usize, hde32_table[idx1]) + @as(usize, opcode % 4);
if (idx2 >= hde32_table.len) break :blk C_ERROR;
break :blk hde32_table[idx2];
};
if (cflags == C_ERROR) {
result.flags |= F_ERROR;
cflags = 0;
if ((opcode & 0xfd) == 0x24) // (opcode & -3) == 0x24
cflags +%= 1;
}
// ── group resolution ──
var x: u8 = 0;
if (cflags & C_GROUP != 0) {
const group_idx = ht_base + @as(usize, cflags & 0x7f);
if (group_idx + 1 < hde32_table.len) {
const t = std.mem.readInt(u16, hde32_table[group_idx..][0..2], .little);
cflags = @truncate(t);
x = @truncate(t >> 8);
}
}
// ── modrm ──
if (cflags & C_MODRM != 0) {
result.flags |= F_MODRM;
const modrm = code[p];
p += 1;
const m_mod = modrm >> 6;
const m_rm: u8 = modrm & 7;
const m_reg: u3 = @truncate((modrm & 0x3f) >> 3);
// F6 TEST imm8 / F7 TEST imm16/32
if (m_reg <= 1) {
if (opcode == 0xf6)
cflags |= C_IMM8;
if (opcode == 0xf7)
cflags |= C_IMM_P66;
}
// displacement
switch (m_mod) {
0 => {
if (pref & PRE_67 != 0) {
if (m_rm == 6) disp_size = 2;
} else {
if (m_rm == 5) disp_size = 4;
}
},
1 => disp_size = 1,
2 => {
disp_size = 2;
if (pref & PRE_67 == 0)
disp_size = 4;
},
else => {},
}
// SIB byte
if (m_mod != 3 and m_rm == 4 and (pref & PRE_67 == 0)) {
result.flags |= F_SIB;
const sib = code[p];
p += 1;
if ((sib & 7) == 5 and (m_mod & 1) == 0)
disp_size = 4;
}
// displacement bytes
switch (disp_size) {
1 => {
result.flags |= F_DISP8;
p += 1;
},
2 => {
result.flags |= F_DISP16;
p += 2;
},
4 => {
result.flags |= F_DISP32;
p += 4;
},
else => {},
}
}
// ── immediates ──
if (cflags & C_IMM_P66 != 0) {
if (cflags & C_REL32 != 0) {
if (pref & PRE_66 != 0) {
result.flags |= F_IMM16 | F_RELATIVE;
p += 2;
// disasm_done — skip remaining immediate checks
result.len = @intCast(p);
if (result.len > 15) {
result.flags |= F_ERROR;
result.len = 15;
}
return result;
}
// fall through to rel32_ok below
} else {
if (pref & PRE_66 != 0) {
result.flags |= F_IMM16;
p += 2;
} else {
result.flags |= F_IMM32;
p += 4;
}
}
}
if (cflags & C_IMM16 != 0) {
if (result.flags & F_IMM32 != 0) {
result.flags |= F_IMM16;
} else if (result.flags & F_IMM16 != 0) {
// F_2IMM16
} else {
result.flags |= F_IMM16;
}
p += 2;
}
if (cflags & C_IMM8 != 0) {
result.flags |= F_IMM8;
p += 1;
}
if (cflags & C_REL32 != 0) {
result.flags |= F_IMM32 | F_RELATIVE;
p += 4;
} else if (cflags & C_REL8 != 0) {
result.flags |= F_IMM8 | F_RELATIVE;
p += 1;
}
result.len = @intCast(p);
if (result.len > 15) {
result.flags |= F_ERROR;
result.len = 15;
}
return result;
}
// ── tests ──────────────────────────────────────────────────────────────
test "push ebp" {
const d = decode(&[_]u8{ 0x55, 0xCC });
try std.testing.expectEqual(@as(u8, 1), d.len);
}
test "mov ebp, esp" {
// 8B EC (or 89 E5)
const d = decode(&[_]u8{ 0x8B, 0xEC });
try std.testing.expectEqual(@as(u8, 2), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
}
test "call rel32" {
const d = decode(&[_]u8{ 0xE8, 0x78, 0x56, 0x34, 0x12 });
try std.testing.expectEqual(@as(u8, 5), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
try std.testing.expect(d.flags & F_IMM32 != 0);
}
test "jmp rel32" {
const d = decode(&[_]u8{ 0xE9, 0x00, 0x00, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 5), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
}
test "sub esp, imm8" {
// 83 EC 10
const d = decode(&[_]u8{ 0x83, 0xEC, 0x10 });
try std.testing.expectEqual(@as(u8, 3), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_IMM8 != 0);
}
test "mov eax, [ebp+8]" {
// 8B 45 08
const d = decode(&[_]u8{ 0x8B, 0x45, 0x08 });
try std.testing.expectEqual(@as(u8, 3), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_DISP8 != 0);
}
test "jz rel32 (0F 84)" {
const d = decode(&[_]u8{ 0x0F, 0x84, 0x10, 0x00, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 6), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
}
test "nop" {
const d = decode(&[_]u8{0x90});
try std.testing.expectEqual(@as(u8, 1), d.len);
}
test "ret" {
const d = decode(&[_]u8{0xC3});
try std.testing.expectEqual(@as(u8, 1), d.len);
}
test "short jmp EB" {
const d = decode(&[_]u8{ 0xEB, 0x05 });
try std.testing.expectEqual(@as(u8, 2), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
try std.testing.expect(d.flags & F_IMM8 != 0);
}
test "short jcc 74 (jz rel8)" {
const d = decode(&[_]u8{ 0x74, 0x0A });
try std.testing.expectEqual(@as(u8, 2), d.len);
try std.testing.expect(d.flags & F_RELATIVE != 0);
}
test "mov eax, imm32" {
const d = decode(&[_]u8{ 0xB8, 0x44, 0x33, 0x22, 0x11 });
try std.testing.expectEqual(@as(u8, 5), d.len);
}
test "push imm32" {
const d = decode(&[_]u8{ 0x68, 0x44, 0x33, 0x22, 0x11 });
try std.testing.expectEqual(@as(u8, 5), d.len);
}
test "push imm8" {
// 6A 01
const d = decode(&[_]u8{ 0x6A, 0x01 });
try std.testing.expectEqual(@as(u8, 2), d.len);
}
test "mov [ebp-4], eax" {
// 89 45 FC
const d = decode(&[_]u8{ 0x89, 0x45, 0xFC });
try std.testing.expectEqual(@as(u8, 3), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_DISP8 != 0);
}
test "lea eax, [ecx+edx*4+8]" {
// 8D 44 91 08
const d = decode(&[_]u8{ 0x8D, 0x44, 0x91, 0x08 });
try std.testing.expectEqual(@as(u8, 4), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
try std.testing.expect(d.flags & F_SIB != 0);
try std.testing.expect(d.flags & F_DISP8 != 0);
}
test "mov [disp32], eax" {
// A3 xx xx xx xx
const d = decode(&[_]u8{ 0xA3, 0x00, 0x10, 0x40, 0x00 });
try std.testing.expectEqual(@as(u8, 5), d.len);
}
test "sub esp, imm32" {
// 81 EC 00 01 00 00
const d = decode(&[_]u8{ 0x81, 0xEC, 0x00, 0x01, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 6), d.len);
try std.testing.expect(d.flags & F_MODRM != 0);
}
test "test eax, imm32 (F7 C0)" {
// F7 C0 FF 00 00 00 = test eax, 0xFF
const d = decode(&[_]u8{ 0xF7, 0xC0, 0xFF, 0x00, 0x00, 0x00 });
try std.testing.expectEqual(@as(u8, 6), d.len);
}
test "ret imm16" {
// C2 04 00
const d = decode(&[_]u8{ 0xC2, 0x04, 0x00 });
try std.testing.expectEqual(@as(u8, 3), d.len);
}
+13 -41
View File
@@ -11,7 +11,7 @@
// =============================================================================
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const con = @import("../console.zig");
// =============================================================================
@@ -182,25 +182,18 @@ fn looseFilesLookup(game_path_ptr: u32) ?[*]const u8 {
// Hook: CheckFileExistence (0x654DD0)
// =============================================================================
// __fastcall(ECX=filename, EDX=flags, stack=outputBuffer) → EAX
// Prologue: 9 bytes (push ebp; mov ebp, esp; sub esp, 0x104) — no rel32 fixups
const CHECK_FILE_EXISTENCE: usize = 0x654DD0;
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
const CheckFileExistenceFn = fn (u32, u32, u32) callconv(fc) u32;
var cfe_hook = hook.Hook{};
var cfe_hook: hook.Detour(CheckFileExistenceFn) = .{};
fn hookImpl(filename_ptr: u32, flags: u32, output_buffer_ptr: u32) callconv(.c) u32 {
fn checkFileExistenceDetour(filename_ptr: u32, flags: u32, output_buffer_ptr: u32) callconv(fc) u32 {
if (filename_ptr != 0) {
if (looseFilesLookup(filename_ptr)) |disk_path| {
const raw: [*]const u8 = @ptrFromInt(filename_ptr);
con.fmt("[assetfix] loose hit: \"{s}\"\n", .{raw[0..cStrLen(raw)]});
// Write disk path (e.g. "Data\Character\...") to output buffer.
// The caller (File_FindInArchive) uses this to open the file from disk.
// Game paths contain '\' which makes CheckFileExistence's bit-0 handler
// skip BuildFilePath and use the raw path as-is — failing because the
// game-relative path has no "Data\" prefix. By writing the correct
// disk-relative path to the output buffer ourselves, we bypass that
// bug without transforming the filename argument (preserving chaining).
if (output_buffer_ptr != 0) {
const disk_len = cStrLen(disk_path);
const out: [*]u8 = @ptrFromInt(output_buffer_ptr);
@@ -212,31 +205,11 @@ fn hookImpl(filename_ptr: u32, flags: u32, output_buffer_ptr: u32) callconv(.c)
return 1;
}
}
return callOriginal(filename_ptr, flags, output_buffer_ptr);
}
fn callOriginal(filename: u32, flags: u32, output_buffer: u32) u32 {
// __fastcall: ECX=filename, EDX=flags, push outputBuffer, callee cleans 4
return asm volatile (
\\push %[output]
\\call *%[func]
: [ret] "={eax}" (-> u32),
: [_] "{ecx}" (filename),
[_] "{edx}" (flags),
[output] "r" (output_buffer),
[func] "r" (cfe_hook.trampoline),
: .{ .memory = true, .cc = true });
return cfe_hook.callOriginal(.{ filename_ptr, flags, output_buffer_ptr });
}
fn installHook() bool {
if (!cfe_hook.prepare(CHECK_FILE_EXISTENCE, 9, &.{})) return false;
// Build fastcall→cdecl thunk in the hook's alloc block (after trampoline)
const thunk_buf = cfe_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(thunk_buf, @intFromPtr(&hookImpl), 1);
cfe_hook.activate(@intFromPtr(thunk_buf));
return true;
return cfe_hook.attach(0x654DD0, &checkFileExistenceDetour) == .ok;
}
// =============================================================================
@@ -307,37 +280,36 @@ var installed: bool = false;
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn installHooks() bool {
pub fn installHooks() void {
con.print("[assetfix] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\AssetfixHook_{d}", .{GetCurrentProcessId()}) catch return false;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\AssetfixHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0]));
if (g_mutex == null) return false;
if (g_mutex == null) return;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[assetfix] Another DLL owns hooks (mutex taken), skipping\n");
return true;
return;
}
g_is_hook_owner = true;
applyGlobPatch();
applyLooseFilePatches();
looseFilesInit();
if (!installHook()) return false;
if (!installHook()) return;
installed = true;
return true;
}
pub fn removeHooks() void {
if (g_is_hook_owner and installed) {
cfe_hook.remove();
cfe_hook.detach();
revertLooseFilePatches();
revertGlobPatch();
looseFilesCleanup();
+263
View File
@@ -573,3 +573,266 @@ The existing GetRelativeTo vtable hook can be kept as defense-in-depth.
0x76772b: E8 F0 16 00 00 CALL SetAnimationOrigin ; 5 bytes
```
First 5 bytes (53 56 8B F1 57) can be replaced with JMP rel32 for a detour.
---
## Stale UIParent Pointer — The Persistent Unknown Destruction Path
### Discovery
One persistent stale pointer escapes ALL hooked destruction paths. Pattern:
- Address always ends in `X008` (e.g., `0x17f20008`, `0x17fb4008`, `0x03bc0008`)
- CFrame base = addr - 0x24 = `XXXXffe4` — crosses page boundary
- First page (containing CFrame base, vtable) is DECOMMITTED
- Second page (containing CLayoutFrame inner at +0x24) survives
- Frame name at CFrame+0x98 reads garbage (`"t%Ç"`) from residual second-page data
- NOT in destruction history ring buffer — never went through any hooked detour
### Diagnostic Hooks Added
**PauseAnimationGroup (0x767ee0)** — dependency registration tracker:
- `__thiscall(ECX=relativeTo_frame, owner_frame, bitmask)`, RET 0x8
- Prologue: `55 8B EC 53 8B D9` (6 bytes)
- Silently records every registration to a 2048-entry ring buffer
- Queried by vtable hooks when stale pointer detected
**SetAnimationOrder (0x767c70)** — anchor creation validator:
- `__thiscall(ECX=frame, point_enum, relativeTo, relPoint, xOfs, yOfs, param_6)`, RET 0x18
- Prologue: `55 8B EC 8B 45 0C` (6 bytes)
- Validates relativeTo AND CFrame base (relativeTo - 0x24) with IsBadReadPtr
- Catches race condition: relativeTo already dead when anchor created
### Key Finding: The Stale Frame is UIParent
**Confirmed by SetAnimationOrder RACE detection.** The following frames all call
SetAnimOrder with the dead relativeTo address:
| Owner Frame | Point | Context |
|------------|-------|---------|
| `ScriptErrors` | 4 | Blizzard UI |
| `GroupLootDropDown` | 0 | Blizzard UI |
| `GroupLootFrame1` | 7 | Blizzard UI |
| `PlayerFrame` | 0 | Blizzard UI |
| `TargetFrame` | 0 | Blizzard UI |
| `WorldMapFrame` | 4 | Blizzard UI |
| `GuildBankFrame` | 0 | Blizzard UI |
| `TransmogFrame` | 0 | Addon UI |
| `NewTransmogAlertFrame` | 0 | Addon UI |
| `TWTMain` | 0 | Addon UI |
| `TWTMainSettings` | 0 | Addon UI |
| `TWTMainTankModeWindow` | 0 | Addon UI |
| `TWTWithAddonList` | 0 | Addon UI |
**Every top-level frame** anchors to this address → it's `UIParent`.
### Race Condition Confirmed
- `DEP REGISTERED` — PauseAnimationGroup WAS called for the address (dependency existed)
- But name at registration time was `"t%Ç"` (garbage) — the frame was ALREADY DEAD
when PauseAnimationGroup ran
- `SetAnimOrder` RACE check: `IsBadReadPtr(relativeTo - 0x24)` FAILS (first page
decommitted), but `IsBadReadPtr(relativeTo)` passes (second page survives)
- The original game code doesn't validate relativeTo at all — just stores the raw pointer
### Symptom: Black Screen
When vtable hooks NULL all stale relativeTo pointers, every top-level frame loses its
anchor to UIParent → nothing can lay out → full black screen. The crash is prevented
but the UI is broken.
### Theory
UIParent is destroyed through an unknown path during a UI reload/transition (character
select → world, or loading screen). The destruction does NOT go through:
- `cleanup_linked_list_structures` (0x767720) — hooked, not triggered
- `destroyUIElement` (0x7645a0) — hooked, not triggered
- `ProcessUIUpdateEvent` (0x772ec0) — hooked, not triggered
A new UIParent is created at a different address, but addon/Blizzard initialization
code passes the OLD (now dead) address to SetPoint/SetAnimOrder.
### Next Steps
1. Find UIParent global pointer in WoW binary (Ghidra)
2. Determine when/how UIParent is destroyed and recreated
3. Consider: hook SetAnimOrder to substitute live UIParent address when dead one detected
4. Alternative: find the destruction path that frees UIParent without our hooks firing
---
## Ghidra RE: UIParent Resolution Mechanism
### UIParent String
- Address: `0x00842f14` (DATA, type=string, value="UIParent")
- **Only 1 xref**: from `InitializeGameInterface` at `0x00490065`
### GetFrameFromLua (0x76c760)
Resolves a named frame from the Lua global table at runtime. Called from
`InitializeGameInterface` to populate `PTR_00b4b44c`.
**Calling convention**: `__fastcall(ECX=name_string, EDX=typeID)`, returns `CFrame*`.
Bare `RET` (no stack cleanup -- 0 stack args).
**Prologue**: `53 56 57 8B DA 8B F9` (7 bytes)
**Internal call chain**:
```
0x76c767: CALL 0x7040d0 -- lua.getContext() -> ESI = L
0x76c772: CALL 0x6f3890 -- lua_pushstring(L, name)
0x76c77e: CALL 0x6f3a40 -- lua_gettable(L, LUA_GLOBALSINDEX)
0x76c788: CALL 0x6f3400 -- lua_type(L, -1)
CMP EAX, ... -- type check (userdata? table?)
JZ ... -- branch on type
0x76c794: MOV EDX, ... -- extract frame pointer from Lua value
```
Epilogue: two RET paths at `0x76c7a3` and `0x76c7db` (both bare `RET`).
**Usage in InitializeGameInterface (0x48fbf0)**:
```c
g_ParentFrameTypeID = g_NextTypeID + 1; // if not already set
PTR_00b4b44c = GetFrameFromLua("UIParent", g_ParentFrameTypeID);
PTR_00b4b3c4 = GetFrameFromLua("GameTooltip", another_type_id);
```
**Key insight**: This function does a live Lua global lookup every time it's called.
We can call it from our hooks to get the CURRENT UIParent, not a cached stale pointer.
Just need `g_ParentFrameTypeID` from `0x00cf0c10` (runtime .bss value).
### g_ParentFrameTypeID (0xcf0c10)
Runtime type ID for the parent frame type. Set once during initialization, stable
for the lifetime of the process. Read from `.bss` at runtime.
### Other Relevant Lookup Functions (found but not yet decompiled)
| Address | Name | Notes |
|---------|------|-------|
| 0x4b3250 | `FindUIElementByName` | Alternative name-based lookup |
| 0x4c3c50 | `FrameScript_GetListOffset` | Frame list traversal helper |
| 0x4c3c80 | `FrameScript_GetListNodeAt` | Frame list node access |
### Lua API Functions (confirmed addresses)
| Address | Function | Ghidra Name | Notes |
|---------|----------|-------------|-------|
| 0x6f36e0 | `lua_tolstring` | lua_tolstring | Confirmed |
| 0x6f3740 | `lua_touserdata` | lua_objlen (WRONG) | See below |
| 0x6f3770 | `lua_objlen` | lua_get_userdata_size | Actual objlen |
### lua_touserdata (0x6f3740) -- CONFIRMED
Ghidra mislabels this as `lua_objlen`. Disassembly confirms it's `lua_touserdata`:
```asm
0x6f374a: MOV ECX, [EAX] ; type tag
0x6f374c: SUB ECX, 0x2 ; LUA_TLIGHTUSERDATA = 2
0x6f374f: JZ 0x6f3760 ; -> return value[2] directly
0x6f3751: SUB ECX, 0x5 ; LUA_TUSERDATA = 7 (2+5)
0x6f3754: JZ 0x6f3759 ; -> return value[2] + 0x10 (skip Udata header)
0x6f3756: XOR EAX, EAX ; else return NULL
0x6f3758: RET
0x6f3759: MOV EAX, [EAX+8] ; full userdata data ptr
0x6f375c: ADD EAX, 0x10 ; skip 16-byte Udata header
0x6f375f: RET
0x6f3760: MOV EAX, [EAX+8] ; lightuserdata ptr
0x6f3763: RET
```
### GetFrameFromLua Full Flow (CONFIRMED)
From decompilation and byte-level verification:
```c
CFrame* __fastcall GetFrameFromLua(ECX=name, EDX=typeID) {
L = getContext(); // 0x7040d0
lua_pushstring(L, name); // push "UIParent"
lua_gettable(L, LUA_GLOBALSINDEX); // EDX=0xffffd8ef (-10001)
type = lua_type(L, -1); // 0x6f3400
if (type != 5) { // 5 = LUA_TTABLE
lua_settop(L, -2); // pop, not a table
return NULL;
}
lua_rawgeti(L, -1, 0); // push table[0] (CFrame* as userdata)
ptr = lua_touserdata(L, -1); // extract C pointer (0x6f3740)
lua_settop(L, -3); // pop table + userdata
if (ptr == NULL) return NULL;
if (!ptr->vtable[4](typeID)) return NULL; // validate frame type
return ptr; // CFrame base pointer
}
```
Frame Lua representation: named frame globals are Lua **tables** with the CFrame
pointer stored as userdata at `table[0]`. `GetFrameFromLua` extracts this, validates
the type via vtable dispatch, and returns the raw CFrame pointer.
### Heal Implementation -- STATUS: CRASHES
Replaced the stale C++ global approach (`0x00B4B44C`) with a live Lua lookup via
`hook.fastcall(u32, 0x76c760, name_ptr, type_id)`. The heal log message ("HEALED")
appears in the console, confirming `GetFrameFromLua` returns a valid pointer. But
the game segfaults shortly after with NO WoW crash log (bypasses the exception handler).
**Current code** (in framecrash.zig):
```zig
fn getLiveUIParent() u32 {
const type_id = readAligned(G_PARENT_FRAME_TYPE_ID); // 0xcf0c10
if (type_id == 0) return 0;
const cframe_base = hook.fastcall(u32, GET_FRAME_FROM_LUA,
@intFromPtr(@as([*:0]const u8, "UIParent")), type_id);
if (cframe_base == 0) return 0;
// validate + name check, return CLayoutFrame inner (+ 0x24)
}
```
Called from:
- `setAnimOrderDetour` -- substitutes dead relativeTo BEFORE anchor creation
- `tryFixStaleRelativeTo` -- heals existing anchors in vtable hooks
- Vtable hooks (GetWidth/GetHeight/GetRelativeTo) -- defense-in-depth
**Suspected crash causes** (not yet verified):
1. **`hook.fastcall` clobber list incomplete** -- the inline asm for fastcall does
NOT declare ECX/EDX as clobbered after `call`. This could cause the Zig compiler
to assume those registers are preserved, leading to register corruption in the
calling detour function. Fix: rewrite getLiveUIParent to call Lua API functions
directly via typed function pointers (same pattern as main.zig's lua struct),
avoiding hook.fastcall entirely.
2. **Reentrancy** -- vtable hooks (GetWidth/GetHeight) fire during layout calculation,
which can happen many times per frame. Each call to getLiveUIParent does a full
Lua stack push/pop cycle. If layout triggers a metamethod or callback that
reenters layout, the Lua stack could be corrupted.
3. **Dependency list inconsistency** -- vtable hook HEAL path writes the new UIParent
address directly into `anchor+0x0C`, but the PauseAnimationGroup dependency was
registered on the OLD (dead) address. The dependency list on the new UIParent
doesn't know about these anchors. This could cause issues when the new UIParent
is later destroyed.
### Next Steps for Heal Fix
1. **Rewrite getLiveUIParent with direct Lua calls** -- use typed function pointers
for each Lua API function instead of hook.fastcall into GetFrameFromLua. This
eliminates the clobber list risk and allows per-step error checking. Key addresses:
- `getContext` (0x7040d0): `fn() callconv(fc) u32`
- `lua_pushstring` (0x6f3890): `fn(u32, [*:0]const u8) callconv(fc) void`
- `lua_gettable` (0x6f3a40): `fn(u32, i32) callconv(fc) void`
- `lua_type` (0x6f3400): `fn(u32, i32) callconv(fc) i32`
- `lua_settop` (0x6f3080): `fn(u32, i32) callconv(fc) void`
- `lua_rawgeti` (0x6f3bc0): `fn(u32, i32, i32) callconv(fc) void`
- `lua_touserdata` (0x6f3740): `fn(u32, i32) callconv(fc) u32`
2. **Add reentrancy guard** -- static bool to prevent recursive getLiveUIParent calls.
3. **Cache result** -- call GetFrameFromLua once per stale-pointer batch, reuse for
all heals in the same layout pass. Invalidate on next frame/event.
4. **Fix dependency list** -- after healing an anchor's relativeTo, call
PauseAnimationGroup on the new UIParent to register the dependency. Without
this, the new UIParent's destruction won't clean up these anchors.
### Module currently DISABLED by default
Build flag changed to `orelse false` in build.zig. Enable with `-Dframecrash=true`.
+153 -203
View File
@@ -20,11 +20,12 @@
//! See RESEARCH.md for full reverse engineering notes.
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const con = @import("../console.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
const THISCALL = std.builtin.CallingConvention{ .x86_thiscall = .{} };
const tc: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
extern "kernel32" fn IsBadReadPtr(lp: ?*const anyopaque, ucb: usize) callconv(WINAPI) i32;
extern "kernel32" fn CreateMutexA(lpMutexAttributes: ?*anyopaque, bInitialOwner: i32, lpName: [*:0]const u8) callconv(WINAPI) ?*anyopaque;
@@ -56,6 +57,14 @@ var g_is_hook_owner: bool = false;
const ANCHOR_VTABLE_ADDR: usize = 0x0081c44c;
const GET_RELATIVE_TO_SLOT: usize = ANCHOR_VTABLE_ADDR + 0x0C; // vtable[3]
// GetFrameFromLua (0x76c760): __fastcall(ECX=name_ptr, EDX=typeID) -> CFrame* or NULL.
// Does a live Lua global lookup: pushstring(name) -> gettable(GLOBALS) -> rawgeti(0)
// -> touserdata -> validate type -> return. Stack-neutral (pops what it pushes).
const GET_FRAME_FROM_LUA: usize = 0x76c760;
// g_ParentFrameTypeID: runtime type ID for parent frame type, set once during init.
const G_PARENT_FRAME_TYPE_ID: usize = 0x00cf0c10;
// =============================================================================
// Root cause fix: hook frame destruction to clean up reverse anchor references
//
@@ -76,9 +85,9 @@ const GET_RELATIVE_TO_SLOT: usize = ANCHOR_VTABLE_ADDR + 0x0C; // vtable[3]
// =============================================================================
const CLEANUP_TARGET: usize = 0x767720;
const CLEANUP_PROLOGUE_SIZE: usize = 5;
var cleanup_hook: hook.Hook = .{};
const CleanupFn = fn (u32) callconv(tc) void;
var cleanup_hook: hook.Detour(CleanupFn) = .{};
// =============================================================================
// Second destruction path: destroyUIElement (0x7645a0)
@@ -93,9 +102,9 @@ var cleanup_hook: hook.Hook = .{};
// =============================================================================
const DESTROY_UI_TARGET: usize = 0x7645a0;
const DESTROY_UI_PROLOGUE_SIZE: usize = 6;
var destroy_ui_hook: hook.Hook = .{};
const DestroyUIFn = fn (u32, u32) callconv(tc) u32;
var destroy_ui_hook: hook.Detour(DestroyUIFn) = .{};
// =============================================================================
// Third destruction path: ProcessUIUpdateEvent (0x772ec0)
@@ -107,9 +116,9 @@ var destroy_ui_hook: hook.Hook = .{};
// =============================================================================
const PROCESS_UI_TARGET: usize = 0x772ec0;
const PROCESS_UI_PROLOGUE_SIZE: usize = 6;
var process_ui_hook: hook.Hook = .{};
const ProcessUIFn = fn (u32, u32) callconv(tc) u32;
var process_ui_hook: hook.Detour(ProcessUIFn) = .{};
// =============================================================================
// Priority 1: Hook PauseAnimationGroup (0x767ee0) — dependency registration
@@ -125,9 +134,9 @@ var process_ui_hook: hook.Hook = .{};
// =============================================================================
const PAUSE_ANIM_TARGET: usize = 0x767ee0;
const PAUSE_ANIM_PROLOGUE_SIZE: usize = 6;
var pause_anim_hook: hook.Hook = .{};
const PauseAnimFn = fn (u32, u32, u32) callconv(tc) void;
var pause_anim_hook: hook.Detour(PauseAnimFn) = .{};
// =============================================================================
// Priority 2: Hook SetAnimationOrder (0x767c70) — anchor creation validation
@@ -144,9 +153,9 @@ var pause_anim_hook: hook.Hook = .{};
// =============================================================================
const SET_ANIM_TARGET: usize = 0x767c70;
const SET_ANIM_PROLOGUE_SIZE: usize = 6;
var set_anim_hook: hook.Hook = .{};
const SetAnimFn = fn (u32, u32, u32, u32, u32, u32, u32) callconv(tc) void;
var set_anim_hook: hook.Detour(SetAnimFn) = .{};
// =============================================================================
// Dependency registration ring buffer — track PauseAnimationGroup calls
@@ -162,26 +171,32 @@ const DepRegistration = struct {
owner: u32 = 0, // the frame that owns the anchor
bitmask: u32 = 0, // which anchor slots (OR of 1<<point_enum)
seq: u32 = 0, // monotonic sequence number for ordering
name: [31:0]u8 = @splat(0), // name of relativeTo frame at registration time
};
var reg_history: [REG_HISTORY_SIZE]DepRegistration = @splat(.{});
var reg_idx: u32 = 0;
fn recordRegistration(relativeTo: u32, owner: u32, bitmask: u32) void {
const seq = reg_idx;
reg_history[reg_idx % REG_HISTORY_SIZE] = .{
var entry = DepRegistration{
.relativeTo = relativeTo,
.owner = owner,
.bitmask = bitmask,
.seq = seq,
.seq = reg_idx,
};
// Capture the frame name while it's still alive
if (getFrameName(relativeTo)) |fname| {
const span = std.mem.span(fname);
const len = @min(span.len, 31);
@memcpy(entry.name[0..len], span[0..len]);
}
reg_history[reg_idx % REG_HISTORY_SIZE] = entry;
reg_idx +%= 1;
}
/// Look up whether PauseAnimationGroup was ever called for a given relativeTo address.
/// Returns the most recent registration entry if found, null otherwise.
fn lookupRegistration(relativeTo: u32) ?DepRegistration {
// Search backwards from most recent for best chance of finding it
var best: ?DepRegistration = null;
for (&reg_history) |*entry| {
if (entry.relativeTo == relativeTo) {
@@ -202,6 +217,15 @@ fn countRegistrations(relativeTo: u32) u32 {
return count;
}
/// Get the name stored at registration time for a relativeTo address.
fn getRegisteredName(relativeTo: u32) []const u8 {
if (lookupRegistration(relativeTo)) |reg| {
const span = std.mem.sliceTo(&reg.name, 0);
if (span.len > 0) return span;
}
return "(unknown)";
}
// =============================================================================
// Destruction history ring buffer — correlate stale pointers with frame names
// =============================================================================
@@ -249,169 +273,85 @@ fn fmtStaleInfo(relativeTo: u32) struct { name: []const u8, saw_destroy: bool }
return .{ .name = fmtFrameName(relativeTo), .saw_destroy = false };
}
/// Log registration status for a stale relativeTo address.
fn logRegistrationStatus(relativeTo: u32) void {
const reg_count = countRegistrations(relativeTo);
if (lookupRegistration(relativeTo)) |reg| {
con.fmt("[framecrash] DEP REGISTERED: PauseAnimGroup was called {d}x for 0x{x:0>8}, last owner=0x{x:0>8} mask=0x{x}\n", .{
reg_count, relativeTo, reg.owner, reg.bitmask,
});
} else {
con.fmt("[framecrash] DEP NEVER REGISTERED: PauseAnimGroup was NEVER called for 0x{x:0>8} (in {d}-entry buffer)\n", .{
relativeTo, REG_HISTORY_SIZE,
});
}
}
/// Dump diagnostic info for a stale relativeTo pointer not seen in our detour.
fn dumpStaleContext(relativeTo: u32, anchor: u32) void {
// Anchor relPoint enum at +0x10
if (IsBadReadPtr(@ptrFromInt(anchor + 0x10), 4) != 0) return;
const rel_point = readAligned(anchor + 0x10);
// Derive owner frame: anchor lives at owner_frame + relPoint*4 + 4
const owner_layout = anchor -% (rel_point * 4 + 4);
const owner_name = fmtFrameName(owner_layout);
con.fmt("[framecrash] owner=\"{s}\" (0x{x:0>8}), relPoint={d}, stale=0x{x:0>8}\n", .{
owner_name, owner_layout, rel_point, relativeTo,
});
}
// logRegistrationStatus and dumpStaleContext removed — verbose diagnostic logging
// superseded by HEAL/FIX/RACE messages. Ring buffers still used by tryFixStaleRelativeTo.
/// Detour for cleanup_linked_list_structures. Runs before the original to
/// walk the dying frame's dependency list and destroy referencing anchors.
fn cleanupDetour(frame: u32) callconv(THISCALL) void {
// Record this frame in the destruction history before anything changes
fn cleanupDetour(frame: u32) callconv(tc) void {
recordDestruction(frame);
// Count reverse dependencies for logging
const dep_count = countReverseDependencies(frame);
if (dep_count > 0) {
con.fmt("[framecrash] Destroying frame \"{s}\" (0x{x:0>8}), {d} reverse dependencies\n", .{
fmtFrameName(frame),
frame,
dep_count,
});
}
cleanupReverseDependencies(frame);
// Call original cleanup_linked_list_structures via trampoline
const orig = cleanup_hook.getTrampoline(*const fn (u32) callconv(THISCALL) void);
orig(frame);
cleanup_hook.callOriginal(.{frame});
}
/// Detour for destroyUIElement. This is the second frame destruction path,
/// called from cleanupGraphicsResources during UI teardown/reload. The original
/// frees frames without walking the dependency list, leaving stale anchors.
/// Signature: void* __thiscall destroyUIElement(void* this, byte free_flag)
fn destroyUIDetour(frame: u32, free_flag: u32) callconv(THISCALL) u32 {
// Record both possible interpretations: frame as CLayoutFrame inner,
// and frame+0x24 in case frame is actually a CFrame base.
// Anchors store CLayoutFrame inner ptrs as relativeTo.
fn destroyUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
recordDestruction(frame);
if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) {
recordDestruction(frame + 0x24);
}
// Try cleaning deps at both offsets. cleanupReverseDependencies is
// guarded by IsBadReadPtr so the wrong offset safely no-ops.
const dep_count_a = countReverseDependencies(frame);
const dep_count_b = countReverseDependencies(frame + 0x24);
if (dep_count_a > 0) {
con.fmt("[framecrash] destroyUIElement frame \"{s}\" (0x{x:0>8}), {d} reverse deps (layout)\n", .{
fmtFrameName(frame), frame, dep_count_a,
});
cleanupReverseDependencies(frame);
}
if (dep_count_b > 0) {
con.fmt("[framecrash] destroyUIElement frame \"{s}\" (0x{x:0>8}), {d} reverse deps (inner+0x24)\n", .{
fmtFrameName(frame + 0x24), frame + 0x24, dep_count_b,
});
cleanupReverseDependencies(frame + 0x24);
}
// Call original destroyUIElement via trampoline
const orig: *const fn (u32, u32) callconv(THISCALL) u32 = @ptrFromInt(destroy_ui_hook.trampoline);
return orig(frame, free_flag);
cleanupReverseDependencies(frame);
cleanupReverseDependencies(frame + 0x24);
return destroy_ui_hook.callOriginal(.{ frame, free_flag });
}
/// Detour for ProcessUIUpdateEvent — third destruction path, called via vtable.
fn processUIDetour(frame: u32, free_flag: u32) callconv(THISCALL) u32 {
fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
recordDestruction(frame);
if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) {
recordDestruction(frame + 0x24);
}
const dep_count_a = countReverseDependencies(frame);
const dep_count_b = countReverseDependencies(frame + 0x24);
if (dep_count_a > 0) {
con.fmt("[framecrash] processUI frame \"{s}\" (0x{x:0>8}), {d} reverse deps (layout)\n", .{
fmtFrameName(frame), frame, dep_count_a,
});
cleanupReverseDependencies(frame);
}
if (dep_count_b > 0) {
con.fmt("[framecrash] processUI frame \"{s}\" (0x{x:0>8}), {d} reverse deps (inner+0x24)\n", .{
fmtFrameName(frame + 0x24), frame + 0x24, dep_count_b,
});
cleanupReverseDependencies(frame + 0x24);
}
const orig: *const fn (u32, u32) callconv(THISCALL) u32 = @ptrFromInt(process_ui_hook.trampoline);
return orig(frame, free_flag);
cleanupReverseDependencies(frame);
cleanupReverseDependencies(frame + 0x24);
return process_ui_hook.callOriginal(.{ frame, free_flag });
}
/// Detour for PauseAnimationGroup — records every dependency registration.
/// This tells us whether a stale relativeTo was ever registered through the
/// normal dependency tracking system.
/// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask)
fn pauseAnimDetour(relativeTo_frame: u32, owner_frame: u32, bitmask: u32) callconv(THISCALL) void {
// Record this registration
fn pauseAnimDetour(relativeTo_frame: u32, owner_frame: u32, bitmask: u32) callconv(tc) void {
// Silently record — queried later by vtable hooks via logRegistrationStatus()
recordRegistration(relativeTo_frame, owner_frame, bitmask);
con.fmt("[framecrash] PauseAnimGroup: relativeTo=0x{x:0>8} \"{s}\", owner=0x{x:0>8} \"{s}\", mask=0x{x}\n", .{
relativeTo_frame,
fmtFrameName(relativeTo_frame),
owner_frame,
fmtFrameName(owner_frame),
bitmask,
});
// Call original
const orig = pause_anim_hook.getTrampoline(*const fn (u32, u32, u32) callconv(THISCALL) void);
orig(relativeTo_frame, owner_frame, bitmask);
pause_anim_hook.callOriginal(.{ relativeTo_frame, owner_frame, bitmask });
}
/// Detour for SetAnimationOrder — validates relativeTo param before anchor creation.
/// Uses cdecl thunk bridge because the function has float params.
/// cdecl args: (ecx=frame, edx=unused, point_enum, relativeTo, relPoint, xOfs_bits, yOfs_bits, param_6)
fn setAnimOrderDetour(frame: u32, _edx: u32, point_enum: u32, relativeTo: u32, rel_point: u32, x_ofs: u32, y_ofs: u32, param_6: u32) callconv(.c) void {
_ = _edx;
/// Float params (xOfs, yOfs) are passed as raw u32 bit patterns on the stack.
fn setAnimOrderDetour(frame: u32, point_enum: u32, relativeTo: u32, rel_point: u32, x_ofs: u32, y_ofs: u32, param_6: u32) callconv(tc) void {
var fixed_relativeTo = relativeTo;
// Validate relativeTo BEFORE the original creates the anchor
if (relativeTo != 0) {
if (IsBadReadPtr(@ptrFromInt(relativeTo), 0x10) != 0) {
con.fmt("[framecrash] RACE: SetAnimOrder creating anchor with INVALID relativeTo=0x{x:0>8}! frame=0x{x:0>8} \"{s}\", point={d}\n", .{
relativeTo,
frame,
fmtFrameName(frame),
point_enum,
});
} else if (relativeTo == frame) {
// Self-reference — the original function rejects this, but log it
con.fmt("[framecrash] SetAnimOrder: self-reference rejected, frame=0x{x:0>8}\n", .{frame});
// Validate relativeTo BEFORE the original creates the anchor.
// Check BOTH the CLayoutFrame inner (relativeTo) AND the CFrame base (relativeTo - 0x24).
// The stale pointer pattern: CFrame base crosses a page boundary, first page is
// decommitted but second page (containing the CLayoutFrame inner) survives.
if (relativeTo != 0 and relativeTo != frame) {
const frame_base = relativeTo -% 0x24;
const inner_bad = IsBadReadPtr(@ptrFromInt(relativeTo), 0x10) != 0;
const base_bad = relativeTo >= 0x24 and IsBadReadPtr(@ptrFromInt(frame_base), 0x10) != 0;
if (inner_bad or base_bad) {
// Dead relativeTo detected. Do a live Lua lookup for UIParent.
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", .{
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", .{
relativeTo, fmtFrameName(frame), point_enum,
});
}
}
}
// Call original trampoline as __thiscall(ECX=frame, 6 stack args).
// All args are u32 — float params (xOfs, yOfs) are passed as raw bit patterns
// which the original function reads from the stack as floats. The bit layout
// is identical because __thiscall pushes all non-this args onto the stack.
const orig: *const fn (u32, u32, u32, u32, u32, u32, u32) callconv(THISCALL) void =
@ptrFromInt(set_anim_hook.trampoline);
orig(frame, point_enum, relativeTo, rel_point, x_ofs, y_ofs, param_6);
set_anim_hook.callOriginal(.{ frame, point_enum, fixed_relativeTo, rel_point, x_ofs, y_ofs, param_6 });
}
/// Count how many nodes are in the PauseAnimationGroup dependency list.
@@ -525,6 +465,45 @@ fn fmtFrameName(layout_frame: u32) []const u8 {
return "(unnamed)";
}
/// Get the live UIParent CLayoutFrame inner pointer via Lua global lookup.
/// Calls GetFrameFromLua("UIParent", g_ParentFrameTypeID) which does a live
/// Lua table lookup, bypassing the stale C++ global at 0x00B4B44C.
/// Returns CLayoutFrame inner (CFrame base + 0x24), or 0 if unavailable.
fn getLiveUIParent() u32 {
const type_id = readAligned(G_PARENT_FRAME_TYPE_ID);
if (type_id == 0) return 0;
const cframe_base = hook.fastcall(u32, GET_FRAME_FROM_LUA, @intFromPtr(@as([*:0]const u8, "UIParent")), type_id);
if (cframe_base == 0) return 0;
// Validate the returned pointer
if (IsBadReadPtr(@ptrFromInt(cframe_base), 0xA0) != 0) return 0;
const inner = cframe_base + 0x24;
if (IsBadReadPtr(@ptrFromInt(inner), 0x40) != 0) return 0;
// Verify the name is actually "UIParent" (paranoia check)
if (getFrameName(inner)) |name| {
if (!std.mem.eql(u8, std.mem.span(name), "UIParent")) return 0;
} else return 0;
return inner;
}
/// Attempt to fix a stale relativeTo in an anchor by substituting live UIParent.
/// Uses GetFrameFromLua for a live Lua lookup, not the stale C++ global.
/// Returns true if the fix was applied, false if no valid substitute found.
fn tryFixStaleRelativeTo(anchor: u32, stale: u32) bool {
const live = getLiveUIParent();
if (live == 0 or live == stale) return false;
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", .{
anchor, stale, live,
});
return true;
}
// =============================================================================
// Defense-in-depth: anchor vtable hooks
//
@@ -562,25 +541,19 @@ fn isRelativeToValid(relativeTo: u32) bool {
/// Hook for vtable[3] GetRelativeTo. Validates the stored pointer.
/// If stale, NULLs anchor+0x0C and returns 0 (safe "no relativeTo" path).
fn getRelativeToHook(this: u32) callconv(THISCALL) u32 {
const orig: *const fn (u32) callconv(THISCALL) u32 = @ptrFromInt(orig_get_relative_to);
fn getRelativeToHook(this: u32) callconv(tc) u32 {
const orig: *const fn (u32) callconv(tc) u32 = @ptrFromInt(orig_get_relative_to);
const result = orig(this);
if (result == 0) return 0;
if (!isRelativeToValid(result)) {
const info = fmtStaleInfo(result);
if (info.saw_destroy) {
con.fmt("[framecrash] STALE: frame \"{s}\" (0x{x:0>8}) went through detour but dep list missed anchor 0x{x:0>8}, detected in GetRelativeTo\n", .{
info.name, result, this,
});
} else {
con.fmt("[framecrash] STALE: frame 0x{x:0>8} NOT seen in detour, anchor 0x{x:0>8}, detected in GetRelativeTo\n", .{
result, this,
});
dumpStaleContext(result, this);
// Try to substitute live UIParent before NULLing
if (tryFixStaleRelativeTo(this, result)) {
// Re-call original — it now reads the fixed pointer
return orig(this);
}
logRegistrationStatus(result);
// No substitute available — NULL it out
const field: *align(1) u32 = @ptrFromInt(this + 0x0C);
field.* = 0;
return 0;
@@ -592,58 +565,44 @@ fn getRelativeToHook(this: u32) callconv(THISCALL) u32 {
/// Hook for vtable[1] GetWidth. Checks anchor+0x0C before calling original.
/// Returns sentinel if relativeTo is NULL or dangling.
/// Signature: f32 __thiscall GetWidth(this, u32 param) — callee cleans 1 stack arg.
fn getWidthHook(this: u32, param: u32) callconv(THISCALL) f32 {
fn getWidthHook(this: u32, param: u32) callconv(tc) f32 {
const relativeTo: u32 = readAligned(this + 0x0C);
if (!isRelativeToValid(relativeTo)) {
// Self-heal if dangling (not just NULL)
if (relativeTo != 0) {
const info = fmtStaleInfo(relativeTo);
if (info.saw_destroy) {
con.fmt("[framecrash] STALE: frame \"{s}\" (0x{x:0>8}) went through detour but dep list missed anchor 0x{x:0>8}, detected in GetWidth\n", .{
info.name, relativeTo, this,
});
} else {
con.fmt("[framecrash] STALE: frame 0x{x:0>8} NOT seen in detour, anchor 0x{x:0>8}, detected in GetWidth\n", .{
relativeTo, this,
});
dumpStaleContext(relativeTo, this);
// Try to substitute live UIParent instead of NULLing
if (tryFixStaleRelativeTo(this, relativeTo)) {
// Fixed — call original with the healed pointer
const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_width);
return orig(this, param);
}
logRegistrationStatus(relativeTo);
const field: *align(1) u32 = @ptrFromInt(this + 0x0C);
field.* = 0;
}
return @as(*align(1) const f32, @ptrFromInt(SENTINEL_ADDR)).*;
}
const orig: *const fn (u32, u32) callconv(THISCALL) f32 = @ptrFromInt(orig_get_width);
const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_width);
return orig(this, param);
}
/// Hook for vtable[2] GetHeight. Same pattern as GetWidth.
/// Signature: f32 __thiscall GetHeight(this, u32 param) — callee cleans 1 stack arg.
fn getHeightHook(this: u32, param: u32) callconv(THISCALL) f32 {
fn getHeightHook(this: u32, param: u32) callconv(tc) f32 {
const relativeTo: u32 = readAligned(this + 0x0C);
if (!isRelativeToValid(relativeTo)) {
if (relativeTo != 0) {
const info = fmtStaleInfo(relativeTo);
if (info.saw_destroy) {
con.fmt("[framecrash] STALE: frame \"{s}\" (0x{x:0>8}) went through detour but dep list missed anchor 0x{x:0>8}, detected in GetHeight\n", .{
info.name, relativeTo, this,
});
} else {
con.fmt("[framecrash] STALE: frame 0x{x:0>8} NOT seen in detour, anchor 0x{x:0>8}, detected in GetHeight\n", .{
relativeTo, this,
});
dumpStaleContext(relativeTo, this);
// Try to substitute live UIParent instead of NULLing
if (tryFixStaleRelativeTo(this, relativeTo)) {
const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_height);
return orig(this, param);
}
logRegistrationStatus(relativeTo);
const field: *align(1) u32 = @ptrFromInt(this + 0x0C);
field.* = 0;
}
return @as(*align(1) const f32, @ptrFromInt(SENTINEL_ADDR)).*;
}
const orig: *const fn (u32, u32) callconv(THISCALL) f32 = @ptrFromInt(orig_get_height);
const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_height);
return orig(this, param);
}
@@ -685,8 +644,7 @@ pub fn installHooks() void {
// Root cause fix #1: detour cleanup_linked_list_structures to clean up
// reverse anchor references before the frame is destroyed.
// Prologue: 53 56 8B F1 57 (5 bytes, no rel32 fixups needed)
if (!cleanup_hook.install(CLEANUP_TARGET, CLEANUP_PROLOGUE_SIZE, @intFromPtr(&cleanupDetour), &.{})) {
if (cleanup_hook.attach(CLEANUP_TARGET, &cleanupDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install frame cleanup detour\n");
} else {
con.print("[framecrash] Frame cleanup detour installed\n");
@@ -695,8 +653,7 @@ pub fn installHooks() void {
// 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.
// Prologue: 55 8B EC 56 8B F1 (6 bytes, no rel32 fixups needed)
if (!destroy_ui_hook.install(DESTROY_UI_TARGET, DESTROY_UI_PROLOGUE_SIZE, @intFromPtr(&destroyUIDetour), &.{})) {
if (destroy_ui_hook.attach(DESTROY_UI_TARGET, &destroyUIDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install destroyUIElement detour\n");
} else {
con.print("[framecrash] destroyUIElement detour installed\n");
@@ -704,8 +661,7 @@ pub fn installHooks() void {
// Root cause fix #3: detour ProcessUIUpdateEvent — virtual function that
// calls CleanupUIElement + FreeMemory without layout cleanup.
// Prologue: 55 8B EC 56 8B F1 (6 bytes, no rel32 fixups needed)
if (!process_ui_hook.install(PROCESS_UI_TARGET, PROCESS_UI_PROLOGUE_SIZE, @intFromPtr(&processUIDetour), &.{})) {
if (process_ui_hook.attach(PROCESS_UI_TARGET, &processUIDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install ProcessUIUpdateEvent detour\n");
} else {
con.print("[framecrash] ProcessUIUpdateEvent detour installed\n");
@@ -720,8 +676,7 @@ pub fn installHooks() void {
// Diagnostic: hook PauseAnimationGroup to track dependency registrations.
// Answers: "was a dependency ever registered for this stale address?"
// Prologue: 55 8B EC 53 8B D9 (6 bytes, no rel32)
if (!pause_anim_hook.install(PAUSE_ANIM_TARGET, PAUSE_ANIM_PROLOGUE_SIZE, @intFromPtr(&pauseAnimDetour), &.{})) {
if (pause_anim_hook.attach(PAUSE_ANIM_TARGET, &pauseAnimDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install PauseAnimationGroup detour\n");
} else {
con.print("[framecrash] PauseAnimationGroup detour installed\n");
@@ -729,25 +684,20 @@ pub fn installHooks() void {
// Diagnostic: hook SetAnimationOrder to detect race conditions.
// Validates relativeTo param BEFORE anchor creation.
// Prologue: 55 8B EC 8B 45 0C (6 bytes, no rel32)
// Uses fastcall-to-cdecl thunk because of float stack params.
if (set_anim_hook.prepare(SET_ANIM_TARGET, SET_ANIM_PROLOGUE_SIZE, &.{})) {
const thunk = set_anim_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&setAnimOrderDetour), 6);
set_anim_hook.activate(@intFromPtr(thunk));
con.print("[framecrash] SetAnimationOrder detour installed\n");
} else {
if (set_anim_hook.attach(SET_ANIM_TARGET, &setAnimOrderDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install SetAnimationOrder detour\n");
} else {
con.print("[framecrash] SetAnimationOrder detour installed\n");
}
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
// Remove diagnostic hooks first (reverse install order)
set_anim_hook.remove();
set_anim_hook.detach();
con.print("[framecrash] SetAnimationOrder detour removed\n");
pause_anim_hook.remove();
pause_anim_hook.detach();
con.print("[framecrash] PauseAnimationGroup detour removed\n");
// Restore original vtable pointers (reverse order)
@@ -756,13 +706,13 @@ pub fn removeHooks() void {
restoreVtableSlot(GET_WIDTH_SLOT, &orig_get_width);
con.print("[framecrash] Anchor vtable hooks removed\n");
process_ui_hook.remove();
process_ui_hook.detach();
con.print("[framecrash] ProcessUIUpdateEvent detour removed\n");
destroy_ui_hook.remove();
destroy_ui_hook.detach();
con.print("[framecrash] destroyUIElement detour removed\n");
cleanup_hook.remove();
cleanup_hook.detach();
con.print("[framecrash] Frame cleanup detour removed\n");
}
+8 -22
View File
@@ -1,5 +1,5 @@
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const con = @import("../console.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
@@ -385,25 +385,16 @@ pub fn lootAllCorpses(_: *anyopaque) callconv(.c) u32 {
// Per-frame hook for processing the loot queue.
// =============================================================================
var scene_end_hook = hook.Hook{};
fn hookSceneEnd(device: u32, _edx: u32) callconv(.c) void {
_ = _edx;
const tc: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
const SceneEndFn = fn (u32) callconv(tc) void;
var scene_end_hook: hook.Detour(SceneEndFn) = .{};
fn hookSceneEnd(device: u32) callconv(tc) void {
if (loot_active) {
processLootQueue();
}
callOriginalSceneEnd(device);
}
fn callOriginalSceneEnd(device: u32) void {
asm volatile (
\\call *%[func]
:
: [_] "{ecx}" (device),
[func] "r" (scene_end_hook.trampoline),
: .{ .eax = true, .edx = true, .memory = true, .cc = true });
scene_end_hook.callOriginal(.{device});
}
// =============================================================================
@@ -431,17 +422,12 @@ pub fn installHooks() void {
g_is_hook_owner = true;
// SceneEnd — per-frame loot queue processing
// Uses thunk: __fastcall(ECX=device, EDX) → cdecl(device, edx)
if (scene_end_hook.prepare(Offsets.ADDR_SceneEnd, 9, &.{})) {
const thunk = scene_end_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&hookSceneEnd), 0);
scene_end_hook.activate(@intFromPtr(thunk));
}
_ = scene_end_hook.attach(Offsets.ADDR_SceneEnd, &hookSceneEnd);
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
scene_end_hook.remove();
scene_end_hook.detach();
}
if (g_is_hook_owner) {
+53 -52
View File
@@ -13,6 +13,7 @@ const build_opts = struct {
const minimapicons = @import("build_options").enable_minimapicons;
const transmogfix = @import("build_options").enable_transmogfix;
const assetfix = @import("build_options").enable_assetfix;
const healtextfix = @import("build_options").enable_healtextfix;
};
// Conditional module imports
@@ -25,6 +26,7 @@ const combatlog = if (build_opts.combatlog) @import("combatlog/combatlog.zig") e
const minimapicons = if (build_opts.minimapicons) @import("minimapicons/minimapicons.zig") else struct {};
const transmogfix = if (build_opts.transmogfix) @import("transmogfix/transmogfix.zig") else struct {};
const assetfix = if (build_opts.assetfix) @import("assetfix/assetfix.zig") else struct {};
const healtextfix = if (build_opts.healtextfix) @import("healtextfix/healtextfix.zig") else struct {};
const WINAPI = std.builtin.CallingConvention.winapi;
const fc: std.builtin.CallingConvention = .{ .x86_fastcall = .{} };
@@ -256,10 +258,11 @@ fn registerLuaFunctions() void {
if (build_opts.outline) {
registerFunction("OutlineCommand", @intFromPtr(&outline.outlineCommand));
}
if (build_opts.markers) {
if (build_opts.markers and markers.isActive()) {
registerFunction("WorldMarker", @intFromPtr(&markers.luaWorldMarker));
registerFunction("ClearWorldMarker", @intFromPtr(&markers.luaClearWorldMarker));
registerFunction("GetPlayerPosition", @intFromPtr(&markers.luaGetPlayerPosition));
registerFunction("ProcessMarkerAnimations", @intFromPtr(&markers.luaProcessAnimations));
}
}
@@ -852,7 +855,7 @@ fn loadAddonsDetour(error_handler: u32) callconv(fc) void {
error_handler,
);
}
if (build_opts.markers) {
if (build_opts.markers and markers.isActive()) {
callLoadFileListWithIncludes(
"Interface\\AddOns\\Markers\\Markers.toc",
&md5ctx,
@@ -911,6 +914,9 @@ fn engineInitDetour() callconv(sc) void {
if (build_opts.outline) {
_ = outline.init();
}
if (build_opts.healtextfix) {
healtextfix.lateInit();
}
}
// =============================================================================
@@ -919,11 +925,45 @@ fn engineInitDetour() callconv(sc) void {
var shutdown_hook: hook.Detour(fn () callconv(sc) void) = .{};
// =============================================================================
// Module lifecycle — single table drives install, shutdown, and uninstall.
// Adding a module here guarantees all three phases are handled.
// =============================================================================
const ModuleHooks = struct {
install: ?*const fn () void = null,
remove: ?*const fn () void = null,
/// If true, remove is also called during CGGameUI_Shutdown (before game
/// teardown), not just during DLL unload. Use for modules that create
/// world objects which must be destroyed while game systems are alive.
remove_on_shutdown: bool = false,
};
/// Order matters: modules are installed top-to-bottom, removed bottom-to-top.
/// Modules with remove_on_shutdown run their remove during shutdownDetour too.
const modules = [_]ModuleHooks{
if (build_opts.assetfix) .{ .install = assetfix.installHooks, .remove = assetfix.removeHooks } else .{},
if (build_opts.framecrash) .{ .install = framecrash.installHooks, .remove = framecrash.removeHooks } else .{},
if (build_opts.combatlog) .{ .install = combatlog.installHooks, .remove = combatlog.removeHooks } else .{},
if (build_opts.transmogfix) .{ .install = transmogfix.installHooks, .remove = transmogfix.removeHooks } else .{},
if (build_opts.minimapicons) .{ .install = minimapicons.installHooks, .remove = minimapicons.removeHooks } else .{},
if (build_opts.healtextfix) .{ .install = healtextfix.installHooks, .remove = healtextfix.removeHooks } else .{},
if (build_opts.markers) .{ .install = markers.installHooks, .remove = markers.removeHooks, .remove_on_shutdown = true } else .{},
if (build_opts.interact) .{ .install = interact.installHooks, .remove = interact.removeHooks } else .{},
if (build_opts.outline) .{ .remove = outline.cleanup } else .{},
if (build_opts.screenshot) .{ .remove = screenshot.removeHook } else .{},
};
fn shutdownDetour() callconv(sc) void {
// Clean up world objects BEFORE game shutdown — atexit handlers run before DllMain
// so we must destroy markers here, not in uninstall().
if (build_opts.markers) {
markers.removeHooks();
// Clean up world objects BEFORE game shutdown — atexit handlers run before
// DllMain so modules with remove_on_shutdown must destroy here.
comptime var i = modules.len;
inline while (i > 0) {
i -= 1;
const m = modules[i];
if (m.remove_on_shutdown) {
if (m.remove) |rm| rm();
}
}
shutdown_hook.callOriginal(.{});
@@ -941,28 +981,11 @@ fn install() void {
_ = file_hook.attach(0x648620, &loadFileDetour);
_ = lsf_hook.attach(0x490250, &loadScriptFunctionsDetour);
if (build_opts.assetfix) {
_ = assetfix.installHooks();
}
if (build_opts.framecrash) {
framecrash.installHooks();
}
if (build_opts.combatlog) {
combatlog.installHooks();
}
if (build_opts.transmogfix) {
_ = transmogfix.installHooks();
}
if (build_opts.minimapicons) {
minimapicons.installHooks();
inline for (modules) |m| {
if (m.install) |inst| inst();
}
_ = load_addons_hook.attach(0x51F600, &loadAddonsDetour);
if (build_opts.interact) {
interact.installHooks();
}
_ = engine_init_hook.attach(0x46a400, &engineInitDetour);
_ = shutdown_hook.attach(0x490BD0, &shutdownDetour);
}
@@ -971,33 +994,11 @@ fn uninstall() void {
shutdown_hook.detach();
engine_init_hook.detach();
// Markers must be cleaned up first — destroys world objects while game systems are still alive
if (build_opts.markers) {
markers.removeHooks();
}
if (build_opts.outline) {
outline.cleanup();
}
if (build_opts.screenshot) {
screenshot.removeHook();
}
if (build_opts.interact) {
interact.removeHooks();
}
if (build_opts.framecrash) {
framecrash.removeHooks();
}
if (build_opts.combatlog) {
combatlog.removeHooks();
}
if (build_opts.minimapicons) {
minimapicons.removeHooks();
}
if (build_opts.transmogfix) {
transmogfix.removeHooks();
}
if (build_opts.assetfix) {
assetfix.removeHooks();
// Remove in reverse order
comptime var i = modules.len;
inline while (i > 0) {
i -= 1;
if (modules[i].remove) |rm| rm();
}
load_addons_hook.detach();
File diff suppressed because it is too large Load Diff
+7
View File
@@ -14,6 +14,13 @@ frame:SetScript("OnEvent", function()
end
end)
-- Per-frame animation driver: queues Hold after Stand finishes on new
-- markers and re-queues it periodically so it never falls back to Stand.
local animFrame = CreateFrame("Frame")
animFrame:SetScript("OnUpdate", function()
ProcessMarkerAnimations()
end)
SLASH_MARKERS1 = "/markers"
SLASH_MARKERS2 = "/mark"
SlashCmdList["MARKERS"] = function(msg)
+71 -8
View File
@@ -28,6 +28,11 @@ const ERROR_ALREADY_EXISTS: u32 = 183;
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
/// True if this DLL instance owns the markers hooks and Lua API is safe to use.
pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Constants
// =============================================================================
@@ -83,6 +88,7 @@ var despawning: [MAX_DESPAWNING]?DespawningEntity = .{null} ** MAX_DESPAWNING;
// =============================================================================
const fc = std.builtin.CallingConvention{ .x86_fastcall = .{} };
const sc = std.builtin.CallingConvention{ .x86_stdcall = .{} };
const lapi = struct {
fn gettop(L: u32) i32 {
@@ -461,6 +467,58 @@ pub fn luaGetPlayerPosition(L: u32) callconv(.c) u32 {
return 3;
}
// =============================================================================
// World teardown hook
// =============================================================================
var world_cleanup_hook: hook.Detour(fn () callconv(sc) void) = .{};
/// Pre-hook on CleanupWorldAndEntities (0x66fc40).
/// Destroys all our entities via CleanupEntity_ProcessAttachments before the
/// game's teardown runs — the same pattern every native caller uses (e.g.
/// processCinematicExit, DestroyPathObjectIfPresent). This unlinks them from
/// the WDOODADDEF hash table so the atexit handler never touches freed memory.
fn worldCleanupDetour() callconv(sc) void {
con.print("[markers] >>> worldCleanupDetour FIRING <<<\n");
destroyAllEntities();
world_cleanup_hook.callOriginal(.{});
}
/// Destroy all tracked entities (active markers + despawning).
/// Idempotent — safe to call multiple times.
fn destroyAllEntities() void {
var count: u32 = 0;
for (&marker_entities, 0..) |*slot, i| {
if (slot.*) |existing| {
const addr = @intFromPtr(existing);
const flags = hook.readMem(u32, addr + 0x8);
const refcount = hook.readMem(u16, addr + 0x0E);
con.fmt("[markers] destroying marker[{d}] @0x{x} flags=0x{x} refcount={d}\n", .{ i, addr, flags, refcount });
cleanupEntity(existing);
slot.* = null;
count += 1;
}
}
for (&hold_queued) |*h| h.* = false;
for (&marker_created_tick) |*t| t.* = 0;
for (&despawning, 0..) |*slot, i| {
if (slot.*) |d| {
const addr = @intFromPtr(d.entity);
const flags = hook.readMem(u32, addr + 0x8);
const refcount = hook.readMem(u16, addr + 0x0E);
con.fmt("[markers] destroying despawn[{d}] @0x{x} flags=0x{x} refcount={d}\n", .{ i, addr, flags, refcount });
cleanupEntity(d.entity);
slot.* = null;
count += 1;
}
}
if (count > 0) {
con.fmt("[markers] world cleanup: destroyed {d} entities\n", .{count});
}
}
// =============================================================================
// Install hooks
// =============================================================================
@@ -483,18 +541,23 @@ pub fn installHooks() void {
return;
}
g_is_hook_owner = true;
// Hook CleanupWorldAndEntities to destroy our entities before world teardown.
// This fires on map change, logout, AND exit — before heaps are destroyed.
const hook_result = world_cleanup_hook.attach(o.FN_CLEANUP_WORLD_AND_ENTITIES, &worldCleanupDetour);
if (hook_result != .ok) {
con.print("[markers] FAILED to hook CleanupWorldAndEntities!\n");
} else {
con.print("[markers] hooked CleanupWorldAndEntities OK\n");
}
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
// Force-cleanup: no time for animations during shutdown
for (&marker_entities) |*slot| {
if (slot.*) |existing| {
cleanupEntity(existing);
slot.* = null;
}
}
forceCleanupDespawning();
// destroyAllEntities is idempotent — if worldCleanupDetour already ran,
// all slots are null and this is a no-op.
destroyAllEntities();
world_cleanup_hook.detach();
}
if (g_is_hook_owner) {
+11
View File
@@ -33,6 +33,17 @@ pub const MOVEMENT_POS_Z: usize = 0x18;
/// Increments refcount at entity+0x0E.
pub const FN_CREATE_ENTITY_INSTANCE: usize = 0x006707c0;
// =============================================================================
// World teardown (map unload / logout / exit)
// =============================================================================
/// CleanupWorldAndEntities — void(), no params, __stdcall.
/// Top-level world teardown: calls CleanupEntityList_ProcessAll, then
/// CleanupWorldAndReleaseResources (which iterates heaps and force-frees).
/// Called from InitializeWorldScene (map change) and ShutdownClientSystems (exit).
/// Hook this to destroy custom entities BEFORE the game's teardown begins.
pub const FN_CLEANUP_WORLD_AND_ENTITIES: usize = 0x0066fc40;
// =============================================================================
// World object lifecycle
// =============================================================================
+1 -1
View File
@@ -4,7 +4,7 @@
//! and a Lua C callback for `/wu outline` commands.
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const con = @import("../console.zig");
const tracker = @import("tracker.zig");
const model_hook = @import("model_hook.zig");
+1 -1
View File
@@ -14,7 +14,7 @@
//! occluded by world/WMO/game objects but show through other players.
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const types = @import("types.zig");
const tracker = @import("tracker.zig");
const model_hook = @import("model_hook.zig");
+28 -94
View File
@@ -13,7 +13,7 @@
//! implementation function.
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const api = @import("api.zig");
const o = @import("offsets.zig");
const types = @import("types.zig");
@@ -25,15 +25,19 @@ const wow = @import("wow.zig");
// Calling convention constants
// =============================================================================
const THISCALL = std.builtin.CallingConvention{ .x86_thiscall = .{} };
const tc: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
// =============================================================================
// Hook state
// =============================================================================
var render_draw_hook: hook.Hook = .{};
var manage_render_hook: hook.Hook = .{};
var draw_batch_hook: hook.Hook = .{};
const RenderDrawFn = fn (u32, u32, u32, u32, u32) callconv(tc) void;
const ManageRenderFn = fn (u32, u32) callconv(tc) void;
const DrawBatchFn = fn (u32) callconv(tc) void;
var render_draw_hook: hook.Detour(RenderDrawFn) = .{};
var manage_render_hook: hook.Detour(ManageRenderFn) = .{};
var draw_batch_hook: hook.Detour(DrawBatchFn) = .{};
/// D3D9 hooks are deferred until the first model hook fires, because creating
/// a dummy D3D9 device during engine init corrupts the proxy's state.
@@ -71,7 +75,7 @@ var reordered_indices: [MAX_REORDER]i32 = undefined;
// through other players and gear (since those aren't in depth when stencil
// is written). The outline composites on top of everything in EndScene.
fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) callconv(THISCALL) void {
fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) callconv(tc) void {
// One-time: install D3D9 hooks now that the game is actively rendering.
if (d3d9_deferred_pending) {
d3d9_deferred_pending = false;
@@ -80,7 +84,7 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
// Skip reordering if nothing to outline or too many batches
if (!tracker.enabled or !tracker.hasTargets() or batch_count == 0 or batch_count > MAX_REORDER) {
callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count);
render_draw_hook.callOriginal(.{ this, view_matrix, batch_data, batch_indices, batch_count });
return;
}
@@ -102,7 +106,7 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
}
if (outline_count == 0) {
callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count);
render_draw_hook.callOriginal(.{ this, view_matrix, batch_data, batch_indices, batch_count });
return;
}
@@ -134,26 +138,7 @@ fn renderDrawDetour(this: u32, view_matrix: u32, batch_data: u32, batch_indices:
indices[i] = reordered_indices[i];
}
callOrigRenderDraw(this, view_matrix, batch_data, batch_indices, batch_count);
}
fn callOrigRenderDraw(this: u32, view_matrix: u32, batch_data: u32, batch_indices: u32, batch_count: u32) void {
// __thiscall: ECX = this, stack = viewMatrix, batchData, batchIndices, batchCount
// Callee cleans 16 bytes (4 stack params).
// Pack args into a struct so we only need one "r" register to address them.
const args = [4]u32{ view_matrix, batch_data, batch_indices, batch_count };
asm volatile (
\\push 12(%[a])
\\push 8(%[a])
\\push 4(%[a])
\\push (%[a])
\\call *%[func]
:
: [_] "{ecx}" (this),
[a] "r" (&args),
[func] "r" (render_draw_hook.trampoline),
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
);
render_draw_hook.callOriginal(.{ this, view_matrix, batch_data, batch_indices, batch_count });
}
// =============================================================================
@@ -162,22 +147,13 @@ fn callOrigRenderDraw(this: u32, view_matrix: u32, batch_data: u32, batch_indice
// __thiscall(model_ECX, addToList_stack)
// Native thiscall detour — no thunk needed.
fn manageRenderDetour(model: u32, add_to_list: u32) callconv(THISCALL) void {
fn manageRenderDetour(model: u32, add_to_list: u32) callconv(tc) void {
// Classify the model when it's being ADDED to the render list
if (add_to_list == 1 and model != 0 and tracker.enabled and tracker.hasTargets()) {
tracker.classifyModel(model);
}
// Call original: __thiscall(model_ECX, addToList_stack)
asm volatile (
\\push %[add]
\\call *%[func]
:
: [_] "{ecx}" (model),
[add] "r" (add_to_list),
[func] "r" (manage_render_hook.trampoline),
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
);
manage_render_hook.callOriginal(.{ model, add_to_list });
}
// =============================================================================
@@ -189,26 +165,10 @@ fn manageRenderDetour(model: u32, add_to_list: u32) callconv(THISCALL) void {
// wrong ret instructions for functions with ≤2 register params. The naked
// function bridges fastcall → cdecl and calls the implementation function.
fn drawBatchProjEntry() callconv(.naked) void {
// __fastcall(ECX): ECX = render context, 0 stack args.
// Bridge to cdecl: push edx + ecx as args, call impl, cleanup, ret.
asm volatile (
\\push %%edx
\\push %%ecx
\\call *%%eax
\\add $8, %%esp
\\ret
:
: [_] "{eax}" (@intFromPtr(&drawBatchProjImpl))
);
}
fn drawBatchProjImpl(ctx: u32, _edx: u32) callconv(.c) void {
_ = _edx;
fn drawBatchProjDetour(ctx: u32) callconv(tc) void {
// Fast path: no tracking enabled or nothing tracked → just call original
if (!tracker.enabled or !tracker.hasTargets()) {
callOrigDrawBatch(ctx);
draw_batch_hook.callOriginal(.{ctx});
return;
}
@@ -224,60 +184,34 @@ fn drawBatchProjImpl(ctx: u32, _edx: u32) callconv(.c) void {
rendering_outline = true;
current_model = model_ptr;
callOrigDrawBatch(ctx);
draw_batch_hook.callOriginal(.{ctx});
rendering_outline = false;
current_model = 0;
} else {
// Normal rendering — no special handling needed
callOrigDrawBatch(ctx);
draw_batch_hook.callOriginal(.{ctx});
}
}
fn callOrigDrawBatch(ctx: u32) void {
asm volatile ("call *%[func]"
:
: [_] "{ecx}" (ctx),
[func] "r" (draw_batch_hook.trampoline),
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
);
}
// =============================================================================
// Install / Remove
// =============================================================================
pub fn installHooks() bool {
// CM2SceneRenderDraw — native thiscall detour, no thunk needed.
// Prologue is 9 bytes: PUSH EBP (1) + MOV EBP,ESP (2) + SUB ESP,0x80 (6).
if (!render_draw_hook.install(
o.FN_CM2SCENE_RENDER_DRAW,
9,
@intFromPtr(&renderDrawDetour),
&.{},
)) return false;
if (render_draw_hook.attach(o.FN_CM2SCENE_RENDER_DRAW, &renderDrawDetour) != .ok)
return false;
// ManageRenderListNode — native thiscall detour, no thunk needed.
if (!manage_render_hook.install(
o.FN_CM2MODEL_MANAGE_RENDER_LIST,
6,
@intFromPtr(&manageRenderDetour),
&.{},
)) return false;
if (manage_render_hook.attach(o.FN_CM2MODEL_MANAGE_RENDER_LIST, &manageRenderDetour) != .ok)
return false;
// DrawBatchProj — naked entry bridges fastcall → cdecl, no thunk needed.
if (!draw_batch_hook.install(
o.FN_DRAW_BATCH_PROJ,
6,
@intFromPtr(&drawBatchProjEntry),
&.{},
)) return false;
if (draw_batch_hook.attach(o.FN_DRAW_BATCH_PROJ, &drawBatchProjDetour) != .ok)
return false;
return true;
}
pub fn removeHooks() void {
draw_batch_hook.remove();
manage_render_hook.remove();
render_draw_hook.remove();
draw_batch_hook.detach();
manage_render_hook.detach();
render_draw_hook.detach();
}
+1 -1
View File
@@ -11,7 +11,7 @@
//! comparison against the object manager's validated set.
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const wow = @import("wow.zig");
const o = @import("offsets.zig");
const types = @import("types.zig");
+1 -1
View File
@@ -5,7 +5,7 @@
//! game functions (UnitGUID, GetObjectByGUID, UnitReaction).
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const o = @import("offsets.zig");
const types = @import("types.zig");
+12 -24
View File
@@ -1,5 +1,5 @@
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const con = @import("../console.zig");
const png = @import("png.zig");
@@ -55,7 +55,9 @@ const ERROR_ALREADY_EXISTS: u32 = 183;
var enabled: bool = true;
var compression_level: i32 = 6; // user-facing 0–9, kept for Lua interface
var tga_hook: hook.Hook = .{};
const tc: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
const TgaWriteFn = fn (u32, u32) callconv(tc) i32;
var tga_hook: hook.Detour(TgaWriteFn) = .{};
var screenshot_dir: [260]u8 = undefined;
var screenshot_dir_len: usize = 0;
var screenshot_counter: u8 = 0;
@@ -79,7 +81,7 @@ var queue: [MAX_PENDING]PendingScreenshot = undefined;
var queue_head: usize = 0;
var queue_tail: usize = 0;
var queue_count: usize = 0;
var mutex: std.Thread.Mutex = .{};
var mutex: std.atomic.Mutex = .unlocked;
var worker_running: bool = false;
var g_mutex: ?HANDLE = null;
var g_is_hook_owner: bool = false;
@@ -122,15 +124,7 @@ fn extractDir(filename_ptr: u32) void {
// =============================================================================
fn callOriginal(self: u32, filename: u32) i32 {
return asm volatile (
\\push %[filename]
\\call *%[func]
: [ret] "={eax}" (-> i32),
: [_] "{ecx}" (self),
[filename] "r" (filename),
[func] "r" (tga_hook.trampoline),
: .{ .edx = true, .memory = true, .cc = true }
);
return tga_hook.callOriginal(.{ self, filename });
}
// =============================================================================
@@ -138,8 +132,7 @@ fn callOriginal(self: u32, filename: u32) i32 {
// Thunked from __fastcall(self_ECX, _EDX, filename_stack) → cdecl
// =============================================================================
fn tgaWriteDetour(self: u32, _edx: u32, filename: u32) callconv(.c) i32 {
_ = _edx;
fn tgaWriteDetour(self: u32, filename: u32) callconv(tc) i32 {
if (!enabled) return callOriginal(self, filename);
@@ -167,7 +160,7 @@ fn tgaWriteDetour(self: u32, _edx: u32, filename: u32) callconv(.c) i32 {
@memcpy(buffer, src[0..size]);
// Enqueue for async processing
mutex.lock();
while (!mutex.tryLock()) {}
defer mutex.unlock();
if (!enqueue(.{ .buffer = buffer.ptr, .width = width, .height = height, .size = size, .level = png.mapLevel(compression_level) })) {
@@ -196,7 +189,7 @@ fn workerThread() void {
while (true) {
var shot: PendingScreenshot = undefined;
{
mutex.lock();
while (!mutex.tryLock()) {}
defer mutex.unlock();
if (dequeue()) |s| {
shot = s;
@@ -368,24 +361,19 @@ pub fn installHook() void {
g_is_hook_owner = true;
// CTgaFile::Write at 0x5a4810
// __thiscall(self, filename) — prologue: 55 8B EC 83 EC 08 = 6 bytes, no fixups
// Thunk: fastcall(ECX=self, EDX, stack: filename) → cdecl(self, edx, filename)
// __thiscall(self, filename) ret 4
//
// Another DLL (UnitXP_SP3) hooks this same address during DLL_PROCESS_ATTACH,
// replacing the prologue with an E9 JMP. Restore the original prologue first
// so prepare() builds a trampoline to the real function rather than chaining
// through UnitXP's detour.
hook.writeProtected(0x5a4810, &.{ 0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x08 });
if (tga_hook.prepare(0x5a4810, 6, &.{})) {
const thunk = tga_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(thunk, @intFromPtr(&tgaWriteDetour), 1);
tga_hook.activate(@intFromPtr(thunk));
}
_ = tga_hook.attach(0x5a4810, &tgaWriteDetour);
}
pub fn removeHook() void {
if (g_is_hook_owner) {
tga_hook.remove();
tga_hook.detach();
}
if (g_is_hook_owner) {
+37 -86
View File
@@ -15,7 +15,7 @@
// =============================================================================
const std = @import("std");
const hook = @import("hook");
const hook = @import("zhook");
const con = @import("../console.zig");
const WINAPI = std.builtin.CallingConvention.winapi;
@@ -165,9 +165,14 @@ var g_mutex: ?*anyopaque = null;
// Hooks
// =============================================================================
var set_block_hook = hook.Hook{};
var refresh_hook = hook.Hook{};
var scene_end_hook = hook.Hook{};
const tc: std.builtin.CallingConvention = .{ .x86_thiscall = .{} };
const SetBlockFn = fn (u32, u32, u32) callconv(tc) u32;
const RefreshFn = fn (u32, u32, u32, u32) callconv(tc) void;
const SceneEndFn = fn (u32) callconv(tc) void;
var set_block_hook: hook.Detour(SetBlockFn) = .{};
var refresh_hook: hook.Detour(RefreshFn) = .{};
var scene_end_hook: hook.Detour(SceneEndFn) = .{};
// =============================================================================
// Object manager helpers
@@ -358,55 +363,15 @@ fn findOtherPendingEntry(guid: u64, slot: i32) i32 {
// =============================================================================
fn callOriginalSetBlock(obj: u32, index: u32, value: u32) u32 {
// Save/restore ECX around the call: the callee overwrites ECX internally
// (SetBlock does `mov ecx, [ebp+0xC]`), but we can't list ECX as a clobber
// since it's already an input constraint. Without the save/restore, the
// compiler may reuse the now-stale ECX for `obj` on a subsequent call.
// The trampoline's `ret 8` cleans up the pushed index+value, leaving our
// saved ECX on top for the pop.
return asm volatile (
\\push %%ecx
\\push %[value]
\\push %[index]
\\call *%[func]
\\pop %%ecx
: [ret] "={eax}" (-> u32),
: [_] "{ecx}" (obj),
[index] "r" (index),
[value] "r" (value),
[func] "r" (set_block_hook.trampoline),
: .{ .edx = true, .memory = true, .cc = true }
);
return set_block_hook.callOriginal(.{ obj, index, value });
}
fn callOriginalRefresh(unit: u32, event_data: u32, extra_data: u32, force_update: u32) void {
// __thiscall: ECX=this, stack args right-to-left. EDX is caller-saved scratch
// (confirmed via Ghidra: __thiscall, EDX not part of calling convention).
// Pin force_update to EDX to stay within 3 "r" registers (EBX/ESI/EDI)
// since EBP is the frame pointer on x86.
asm volatile (
\\push %[force]
\\push %[extra]
\\push %[event]
\\call *%[func]
:
: [_] "{ecx}" (unit),
[force] "{edx}" (force_update),
[event] "r" (event_data),
[extra] "r" (extra_data),
[func] "r" (refresh_hook.trampoline),
: .{ .eax = true, .memory = true, .cc = true }
);
refresh_hook.callOriginal(.{ unit, event_data, extra_data, force_update });
}
fn callOriginalSceneEnd(device: u32) void {
asm volatile (
\\call *%[func]
:
: [_] "{ecx}" (device),
[func] "r" (scene_end_hook.trampoline),
: .{ .eax = true, .edx = true, .memory = true, .cc = true }
);
scene_end_hook.callOriginal(.{device});
}
// =============================================================================
@@ -429,7 +394,7 @@ fn processTimeouts(now: u32) void {
}
// OTHER PLAYERS: Use timeout since we don't have their INV_SLOT info
if (g_other_pending_count > 0 and set_block_hook.trampoline != 0) {
if (g_other_pending_count > 0 and set_block_hook.inner.trampoline != 0) {
const UnitSlots = struct {
unit: u32 = 0,
slots: [19]i32 = .{0} ** 19,
@@ -507,7 +472,7 @@ fn processTimeouts(now: u32) void {
// Fallback to RefreshVisualAppearance
con.fmt("[other] REFRESH fallback unit=0x{X:0>8} table=0x{X:0>8}\n", .{ unit, display_table });
if (refresh_hook.trampoline != 0) {
if (refresh_hook.inner.trampoline != 0) {
callOriginalRefresh(unit, 0, 0, 1);
}
}
@@ -518,8 +483,7 @@ fn processTimeouts(now: u32) void {
// Hook 1: SetBlock (0x6142E0)
// =============================================================================
fn hookSetBlock(obj: u32, _edx: u32, index: u32, value: u32) callconv(.c) u32 {
_ = _edx;
fn hookSetBlock(obj: u32, index: u32, value: u32) callconv(tc) u32 {
const val = value;
// VISIBLE_ITEM writes
@@ -694,11 +658,9 @@ fn hookSetBlock(obj: u32, _edx: u32, index: u32, value: u32) callconv(.c) u32 {
// Hook 2: RefreshVisualAppearance (0x5fb880)
// =============================================================================
fn hookRefreshVisualAppearance(unit: u32, _edx: u32, event_data: u32, extra_data: u32, force_update: u32) callconv(.c) void {
_ = _edx;
if (!g_enabled or refresh_hook.trampoline == 0) {
if (refresh_hook.trampoline != 0) {
fn hookRefreshVisualAppearance(unit: u32, event_data: u32, extra_data: u32, force_update: u32) callconv(tc) void {
if (!g_enabled or refresh_hook.inner.trampoline == 0) {
if (refresh_hook.inner.trampoline != 0) {
callOriginalRefresh(unit, event_data, extra_data, force_update);
}
return;
@@ -802,8 +764,7 @@ fn hookRefreshVisualAppearance(unit: u32, _edx: u32, event_data: u32, extra_data
// Hook 3: SceneEnd (0x5a17a0)
// =============================================================================
fn hookSceneEnd(device: u32, _edx: u32) callconv(.c) void {
_ = _edx;
fn hookSceneEnd(device: u32) callconv(tc) void {
if (g_enabled and (g_local_pending_count > 0 or g_other_pending_count > 0)) {
processTimeouts(GetTickCount());
@@ -816,16 +777,16 @@ fn hookSceneEnd(device: u32, _edx: u32) callconv(.c) void {
// Init / Cleanup
// =============================================================================
pub fn installHooks() bool {
pub fn installHooks() void {
con.print("[transmogfix] Module loaded\n");
// Multi-DLL safety: only one instance per process should hook
var mutex_name_buf: [64]u8 = undefined;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\TransmogCoalesceHook_{d}", .{GetCurrentProcessId()}) catch return false;
const mutex_name = std.fmt.bufPrint(&mutex_name_buf, "Local\\TransmogCoalesceHook_{d}", .{GetCurrentProcessId()}) catch return;
mutex_name_buf[mutex_name.len] = 0;
g_mutex = CreateMutexA(null, 1, @ptrCast(mutex_name_buf[0..mutex_name.len :0]));
if (g_mutex == null) return false;
if (g_mutex == null) return;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
@@ -833,7 +794,7 @@ pub fn installHooks() bool {
g_is_hook_owner = false;
g_initialized = true;
con.print("[transmogfix] Another DLL owns hooks (mutex taken), skipping\n");
return true; // Success but not owner — no hooks
return;
}
g_is_hook_owner = true;
@@ -846,41 +807,31 @@ pub fn installHooks() bool {
g_unit_cache = [1]UnitVisualState{.{}} ** UNIT_CACHE_SIZE;
g_cached_visible_item = .{0} ** 19;
// Hook 1: SetBlock (6 bytes, no fixups)
if (!set_block_hook.prepare(ADDR_SetBlock, 6, &.{})) return false;
const sb_thunk = set_block_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(sb_thunk, @intFromPtr(&hookSetBlock), 2);
set_block_hook.activate(@intFromPtr(sb_thunk));
// Hook 1: SetBlock
if (set_block_hook.attach(ADDR_SetBlock, &hookSetBlock) != .ok) return;
// Hook 2: RefreshVisualAppearance (6 bytes, no fixups)
if (!refresh_hook.prepare(ADDR_RefreshVisualAppearance, 6, &.{})) {
set_block_hook.remove();
return false;
// Hook 2: RefreshVisualAppearance
if (refresh_hook.attach(ADDR_RefreshVisualAppearance, &hookRefreshVisualAppearance) != .ok) {
set_block_hook.detach();
return;
}
const rv_thunk = refresh_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(rv_thunk, @intFromPtr(&hookRefreshVisualAppearance), 3);
refresh_hook.activate(@intFromPtr(rv_thunk));
// Hook 3: SceneEnd (9 bytes, no fixups)
if (!scene_end_hook.prepare(ADDR_SceneEnd, 9, &.{})) {
refresh_hook.remove();
set_block_hook.remove();
return false;
// Hook 3: SceneEnd
if (scene_end_hook.attach(ADDR_SceneEnd, &hookSceneEnd) != .ok) {
refresh_hook.detach();
set_block_hook.detach();
return;
}
const se_thunk = scene_end_hook.mem.? + 32;
_ = hook.buildFastcallToCdeclThunk(se_thunk, @intFromPtr(&hookSceneEnd), 0);
scene_end_hook.activate(@intFromPtr(se_thunk));
g_initialized = true;
con.print("[transmogfix] All 3 hooks installed\n");
return true;
}
pub fn removeHooks() void {
if (g_is_hook_owner) {
scene_end_hook.remove();
refresh_hook.remove();
set_block_hook.remove();
scene_end_hook.detach();
refresh_hook.detach();
set_block_hook.detach();
}
if (g_is_hook_owner) {