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
+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) {