Add module control API, bigcursor with fractional scaling, shared mutex

Module control API:
- Three cdecl exports: WeirdUtils_IsModuleActive, WeirdUtils_DisableModule, WeirdUtils_DisableAll
- C header include/weirdutils_api.h for runtime DLL discovery
- All modules gain pub module_name, isActive(), shared mutex via src/mutex.zig
- build.zig refactored to module_list array of ModuleDesc

Bigcursor:
- D3D9 vtable hooks on SetCursorProperties/ShowCursor
- Scale2x/Scale3x pixel-art upscalers in pure Zig (replaces 12K-line hqx C)
- Bilinear resampler for fractional scales (1.0-4.0, default 1.2)
- Win32 cursor via CreateIconIndirect bypasses D3D9 32x32 limit
- FNV-1a hash cache for ~16 cursor bitmaps
- Lua API: SetCursorScale(n) / GetCursorScale()
- CVar cursorScale for Config.wtf persistence (tenths)

Other:
- Add dpslog module stub
- Docs and README updates
This commit is contained in:
MarcelineVQ
2026-03-06 13:27:35 -08:00
parent 0783f3b0dd
commit 2aaa089c76
44 changed files with 1904 additions and 1022 deletions
+52 -52
View File
@@ -30,7 +30,7 @@ ESI=03319888 EDI=303A4588 EBP=00F2FADC ESP=00F2FACC
processGraphicsFrame (0x764330)
→ renderAllFrameLayers (0x765650)
→ processFrameUpdates
→ DispatchHeartbeatEvent (0x76b2c0) — fires OnUpdate
→ DispatchHeartbeatEvent (0x76b2c0) - fires OnUpdate
→ ExecuteLuaCallback (0x704f10)
→ luaD_pcall (0x6f6960)
→ luaCallFunction (0x6f6050)
@@ -66,14 +66,14 @@ This is the Lua API `frame:GetPoint(index)`. It returns anchor point info:
0x007a2408: CMP EBX,0x9 ; 9 anchor point types max
0x007a240b: JL 0x007a23f8
; === Found anchor — get relativeTo frame ===
; === Found anchor - get relativeTo frame ===
0x007a2416: MOV EDX,[EDI] ; anchor vtable
0x007a2418: MOV ECX,EDI ; this = anchor
0x007a241a: CALL [EDX + 0xc] ; vtable[3]() → GetRelativeTo → returns raw ptr
0x007a241d: TEST EAX,EAX
0x007a241f: JZ 0x007a24d7 ; NULL → safe "no relativeTo" path
; Second call to same vfunc — gets value for real this time
; Second call to same vfunc - gets value for real this time
0x007a2425: MOV EAX,[EDI]
0x007a2429: CALL [EAX + 0xc] ; vtable[3]() again
0x007a242c: TEST EAX,EAX
@@ -106,19 +106,19 @@ the point name + x/y offsets, skipping the relativeTo frame entirely.
## Anchor Object Structure (0x14 = 20 bytes)
Discovered from `SetAnimationOrder` (0x00767c70) — the internal C++ `SetPoint`:
Discovered from `SetAnimationOrder` (0x00767c70) - the internal C++ `SetPoint`:
```
Offset Size Field
+0x00 4 vtable pointer = PTR_GetAnimationOrder_0081c44c
+0x04 4 x offset (float)
+0x08 4 y offset (float)
+0x0C 4 relativeTo frame pointer (RAW — no refcount, no validation!)
+0x0C 4 relativeTo frame pointer (RAW - no refcount, no validation!)
+0x10 4 relative point enum (uint)
```
**Vtable address**: `0x0081c44c` (in .rdata)
**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo — simply returns `this+0x0C`
**vtable[3]** (at vtable+0xC = `0x0081c458`): GetRelativeTo - simply returns `this+0x0C`
### Anchor Creation (in SetAnimationOrder)
```c
@@ -164,7 +164,7 @@ invalidation mechanism.** When the relativeTo frame is destroyed:
| 0x403f50 | `ValidateObjectPointer` | Calls `IsBadReadPtr` via `[0x007ff2b8]` |
| 0x4c38a0 | `FrameScript_ValidateMemory` | Global memory validation |
### Type ID Globals (runtime values, .bss — not readable from Ghidra)
### Type ID Globals (runtime values, .bss - not readable from Ghidra)
| Address | Name |
|---------|------|
| 0x00cf0c10 | `g_ParentFrameTypeID` |
@@ -175,20 +175,20 @@ invalidation mechanism.** When the relativeTo frame is destroyed:
### Frame Lifecycle
| Address | Name |
|---------|------|
| 0x773240 | `DestroyFrame` — clears fields +0/+4, unlinks |
| 0x4c34a0 | `DestroyFrameScriptObject` — clears lua ref, unlinks from list, frees |
| 0x773240 | `DestroyFrame` - clears fields +0/+4, unlinks |
| 0x4c34a0 | `DestroyFrameScriptObject` - clears lua ref, unlinks from list, frees |
| 0x4c3510 | `FrameScript_InsertIntoList` |
| 0x4c3c10 | `FrameScript_UnlinkFromList` |
| 0x701bd0 | `RegisterFrameScriptReference` — creates Lua table + metatable for frame |
| 0x701bd0 | `RegisterFrameScriptReference` - creates Lua table + metatable for frame |
### Anchor Functions
| Address | Name |
|---------|------|
| 0x767c70 | `SetAnimationOrder` — internal SetPoint (creates anchor object) |
| 0x768010 | `IsAnimationPlaying` — recursive anchor dependency check |
| 0x7a2340 | `luaGetPoint` — Lua API, CRASH SITE |
| 0x7a2540 | `luaSetPoint` — Lua API |
| 0x7a2940 | `luaClearAllPoints` — Lua API |
| 0x767c70 | `SetAnimationOrder` - internal SetPoint (creates anchor object) |
| 0x768010 | `IsAnimationPlaying` - recursive anchor dependency check |
| 0x7a2340 | `luaGetPoint` - Lua API, CRASH SITE |
| 0x7a2540 | `luaSetPoint` - Lua API |
| 0x7a2940 | `luaClearAllPoints` - Lua API |
### Windows API
| IAT Address | API |
@@ -267,7 +267,7 @@ ESI=16D046A8 EDI=17077C2C EBP=00F2F7EC ESP=00F2F7D8
0x007A2FB8: F6 41 3C 02 TEST byte ptr [ECX+0x3C], 0x02 ; ECX=0 → reads 0x0000003C
```
ECX is NULL — the frame/layout object pointer is missing.
ECX is NULL - the frame/layout object pointer is missing.
**Note**: EDX=0x0081C44C (the anchor vtable) is just a leftover register value,
NOT caused by our vtable hook. EDX is not used at the crash site.
@@ -275,18 +275,18 @@ NOT caused by our vtable hook. EDX is not used at the crash site.
### Call Chain
```
GetAnimationSmoothing (0x768d20)
→ calculate_negative... (0x7673d0) — pushes 0x0081C3D0, calls SetFrameHitTestMode
→ SetFrameHitTestMode (0x7671a0) — iterates anchors, calls vtable[1] (+0x04)
→ calculate_negative... (0x7673d0) - pushes 0x0081C3D0, calls SetFrameHitTestMode
→ SetFrameHitTestMode (0x7671a0) - iterates anchors, calls vtable[1] (+0x04)
→ luaGetWidth (0x7a2f90) ← CRASH: ECX=NULL
```
This crashes during **frame layout calculation at startup** — the frame layout
This crashes during **frame layout calculation at startup** - the frame layout
system is computing dimensions, and a frame's layout dependency (parent or
anchor target) is NULL.
### Key Disassembly (crash site in luaGetWidth)
```asm
; luaGetWidth prologue — sets up local vars
; luaGetWidth prologue - sets up local vars
0x007a2faa: MOV [EBP-8], 0x0
0x007a2fb1: MOV [EBP-4], 0x0
0x007a2fb8: TEST byte ptr [ECX+0x3C], 0x02 ← CRASH (ECX=NULL)
@@ -339,7 +339,7 @@ anchor target) is NULL.
and dereference it at `+0x3C` **without** a NULL check. The anchor exists,
but its `relativeTo` field is NULL (no target frame set).
**`0x0081C3D0` is NOT a vtable** — it's a static array of 3 anchor-point indices:
**`0x0081C3D0` is NOT a vtable** - it's a static array of 3 anchor-point indices:
`[0, 3, 6]`, used by the width layout pass. `SetFrameHitTestMode` iterates these
indices to look up anchors from the frame's anchor array.
@@ -356,7 +356,7 @@ but the actual functions take an extra stack parameter.
### Root Cause
`SetFrameHitTestMode` (0x7671a0) calls `vtable[1]` with `PUSH EAX` before the
call — passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the
call - passing 1 stack argument. Since GetWidth/GetHeight are `__thiscall`, the
callee must clean up this argument (RET 4). Our hooks with no stack parameter
used RET 0, leaving 4 bytes on the stack after every call, causing misalignment
and eventual crash.
@@ -414,7 +414,7 @@ So given a CLayoutFrame pointer (e.g., relativeTo from an anchor):
## Destruction Path Analysis (Ghidra)
### cleanup_linked_list_structures (0x767720) — The Bottleneck
### cleanup_linked_list_structures (0x767720) - The Bottleneck
All frame destruction goes through this function. Exactly 3 direct callers:
@@ -424,11 +424,11 @@ All frame destruction goes through this function. Exactly 3 direct callers:
| `CleanupRegion` | 0x76c560 | Region cleanup (called by WorldObjectBaseDestructor) |
| `cleanupGraphicsResources` | 0x764390 | Graphics teardown |
### WorldObjectBaseDestructor (0x7693b0) — Common Base
### WorldObjectBaseDestructor (0x7693b0) - Common Base
All frame-type destructors eventually call `WorldObjectBaseDestructor`, which:
1. Calls `cleanup_linked_list_structures(param_1 + 9)` — **our hook fires here**
2. Calls `CleanupRegion` — also calls `cleanup_linked_list_structures`
1. Calls `cleanup_linked_list_structures(param_1 + 9)` - **our hook fires here**
2. Calls `CleanupRegion` - also calls `cleanup_linked_list_structures`
3. Calls `FrameScript_Destructor` (base class cleanup, list unlinking)
16 callers of WorldObjectBaseDestructor (all are frame-type destructors):
@@ -438,12 +438,12 @@ All frame-type destructors eventually call `WorldObjectBaseDestructor`, which:
`statusBarCleanupResources`, `cleanupMessageFrameResources`, `cleanupScrollFrame`,
`CleanupObjectManager`, `CleanupColorSelectFrame`, `CleanupMovieFrame`, `luaIsVisible`
### DestroyFrame (0x773240) — NOT a Destruction Path
### DestroyFrame (0x773240) - NOT a Destruction Path
Only called from `InitializeFrameProperties` (0x7731d0). This is a **re-initialization**
path, not frame destruction. No need to hook.
### DestroyFrameScriptObject (0x4c34a0) — Vtable Entry, Already Covered
### DestroyFrameScriptObject (0x4c34a0) - Vtable Entry, Already Covered
Referenced only as DATA at `0x806cb8` (vtable slot). `FrameScript_Destructor` (0x4c3690)
sets the vtable to this and does list unlinking / FreeMemory. It runs **after**
@@ -454,10 +454,10 @@ called by the time this executes. No need to hook.
**Our single hook on `cleanup_linked_list_structures` covers all frame destruction paths.**
The vtable hooks (GetWidth/GetHeight/GetRelativeTo) remain as defense-in-depth but
are not expected to fire during normal operation — they would only catch bugs in
are not expected to fire during normal operation - they would only catch bugs in
our cleanup logic or unknown destruction paths.
### Anchor Vtable (0x0081C44C) — Full Layout
### Anchor Vtable (0x0081C44C) - Full Layout
```
[0] +0x00 = 0x00767d80 → GetAnimationOrder (destructor)
[1] +0x04 = 0x007a2f90 → luaGetWidth ← CRASH FUNCTION
@@ -465,21 +465,21 @@ our cleanup logic or unknown destruction paths.
[3] +0x0C = 0x00767d70 → GetRelativeTo (already hooked)
```
### Fix Attempt: vtable[1]/[2] hook returning 0.0 — REVERTED
### Fix Attempt: vtable[1]/[2] hook returning 0.0 - REVERTED
Hooked vtable[1] (GetWidth) and vtable[2] (GetHeight) with wrappers that
returned 0.0 when relativeTo was NULL/dangling. **This broke UI layout** because:
1. 0.0 is not the sentinel value that `SetFrameHitTestMode` expects
(it compares via `FCOMP [0x00cf550c]` — a runtime .bss value)
(it compares via `FCOMP [0x00cf550c]` - a runtime .bss value)
2. The functions have side effects (IsAnimationDone, GetAnimationTarget calls)
that update layout state — skipping them entirely is wrong
that update layout state - skipping them entirely is wrong
### Relationship Between Crash 1 and Crash 2
The GetRelativeTo hook (crash 1 fix) self-heals by NULLing anchor+0x0C when
it detects a dangling pointer. But GetWidth/GetHeight read anchor+0x0C
**directly** (not through vtable[3]), so they see the now-NULL value and crash.
The two crashes are likely the same underlying issue — the GetRelativeTo hook
The two crashes are likely the same underlying issue - the GetRelativeTo hook
is masking the dangling pointer but exposing it as a NULL pointer to other code.
---
@@ -491,14 +491,14 @@ is masking the dangling pointer but exposing it as a NULL pointer to other code.
WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` /
`ResumeAnimationGroup`:
**PauseAnimationGroup(relativeTo_frame, owner_frame, bitmask)** — 0x767ee0
**PauseAnimationGroup(relativeTo_frame, owner_frame, bitmask)** - 0x767ee0
- Called by `SetAnimationOrder` (SetPoint) when creating an anchor
- Maintains a linked list on `relativeTo_frame+0x30/0x34`
- Each node is 0x10 bytes: `[link0, next_ptr(+4), owner_frame(+8), bitmask(+C)]`
- Bitmask = `1 << anchor_point_enum` — tracks which anchor slots reference this frame
- Bitmask = `1 << anchor_point_enum` - tracks which anchor slots reference this frame
- If owner already in list, ORs in the new bitmask bits
**ResumeAnimationGroup(relativeTo_frame, owner_frame, bitmask)** — 0x767fa0
**ResumeAnimationGroup(relativeTo_frame, owner_frame, bitmask)** - 0x767fa0
- Called when replacing/removing an anchor
- Walks list at `relativeTo_frame+0x34`, finds matching owner
- Clears bitmask bits: `node+0xC &= ~bitmask`
@@ -506,7 +506,7 @@ WoW's frame system tracks anchor dependencies via `PauseAnimationGroup` /
### Proper Anchor Cleanup (exists but not called on destruction)
**cleanup_array_of_objects** (0x767620) — cleans up a frame's OWN anchors:
**cleanup_array_of_objects** (0x767620) - cleans up a frame's OWN anchors:
```c
for i in 0..9:
anchor = *(frame + i*4 + 4) // anchor slot
@@ -519,13 +519,13 @@ for i in 0..9:
```
Called from: `SetAnimationOrigin` (0x768e20), `StartAnimationGroup` (0x767db0),
`GetAnimationEndDelay` (0x768430) — **never during frame destruction**.
`GetAnimationEndDelay` (0x768430) - **never during frame destruction**.
### Frame Destruction Chain
```
destroy_object (0x7676f0) — thiscall(frame, free_flag)
-> cleanup_linked_list_structures (0x767720) — thiscall(frame)
destroy_object (0x7676f0) - thiscall(frame, free_flag)
-> cleanup_linked_list_structures (0x767720) - thiscall(frame)
-> sets vtable to CLayoutFrame base (0x81c400)
-> SetAnimationOrigin(frame) -> cleanup_array_of_objects(frame)
^ cleans up THIS frame's own anchors (forward direction)
@@ -576,27 +576,27 @@ 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
## 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
- 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
- NOT in destruction history ring buffer - never went through any hooked detour
### Diagnostic Hooks Added
**PauseAnimationGroup (0x767ee0)** — dependency registration tracker:
**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:
**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
@@ -627,12 +627,12 @@ SetAnimOrder with the dead relativeTo address:
### 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
- `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
- The original game code doesn't validate relativeTo at all - just stores the raw pointer
### Symptom: Black Screen
@@ -644,9 +644,9 @@ but the UI is broken.
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
- `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.
+44 -52
View File
@@ -35,23 +35,31 @@ extern "kernel32" fn GetLastError() callconv(WINAPI) u32;
extern "kernel32" fn GetCurrentProcessId() callconv(WINAPI) u32;
const ERROR_ALREADY_EXISTS: u32 = 183;
const mod_mutex = @import("../mutex.zig");
pub const module_name: [*:0]const u8 = "framecrash";
var g_mutex: ?*anyopaque = null;
var g_is_hook_owner: bool = false;
pub fn isActive() bool {
return g_is_hook_owner;
}
// =============================================================================
// Anchor vtable layout (20-byte object allocated in SetPoint / SetAnimationOrder)
//
// +0x00 vtable ptr → 0x0081c44c (.rdata)
// +0x04 x offset (float)
// +0x08 y offset (float)
// +0x0C relativeTo (raw frame pointer — the dangerous one)
// +0x0C relativeTo (raw frame pointer - the dangerous one)
// +0x10 relPoint (uint, anchor point enum on the relativeTo frame)
//
// vtable at 0x0081c44c:
// [0] +0x00 GetAnimationOrder (0x767d80) — destructor/cleanup
// [1] +0x04 luaGetWidth (0x7a2f90) — reads [this+0xC]+0x3C
// [2] +0x08 luaGetHeight (0x7a3070) — reads [this+0xC]+0x3C
// [3] +0x0C GetRelativeTo (0x767d70) — returns *(this+0x0C)
// [0] +0x00 GetAnimationOrder (0x767d80) - destructor/cleanup
// [1] +0x04 luaGetWidth (0x7a2f90) - reads [this+0xC]+0x3C
// [2] +0x08 luaGetHeight (0x7a3070) - reads [this+0xC]+0x3C
// [3] +0x0C GetRelativeTo (0x767d70) - returns *(this+0x0C)
// =============================================================================
const ANCHOR_VTABLE_ADDR: usize = 0x0081c44c;
@@ -93,7 +101,7 @@ var cleanup_hook: hook.Detour(CleanupFn) = .{};
// Second destruction path: destroyUIElement (0x7645a0)
//
// Called from cleanupGraphicsResources (UI teardown/reload) via the strata loop.
// Frees frames WITHOUT calling cleanup_linked_list_structures — just unlinks from
// Frees frames WITHOUT calling cleanup_linked_list_structures - just unlinks from
// lists, cleans up sub-regions, and calls FreeMemory. The dependency list at
// frame+0x34 is never walked, so other frames' anchors are left dangling.
//
@@ -121,12 +129,12 @@ const ProcessUIFn = fn (u32, u32) callconv(tc) u32;
var process_ui_hook: hook.Detour(ProcessUIFn) = .{};
// =============================================================================
// Priority 1: Hook PauseAnimationGroup (0x767ee0) — dependency registration
// Priority 1: Hook PauseAnimationGroup (0x767ee0) - dependency registration
//
// Records every dependency registration so we can later determine whether a
// stale relativeTo pointer was ever registered through PauseAnimationGroup.
// If PauseAnimationGroup was never called for an address, the dependency was
// never created — pointing to a race condition or unknown creation path.
// never created - pointing to a race condition or unknown creation path.
//
// Signature: void __thiscall PauseAnimationGroup(ECX=relativeTo_frame, owner_frame, bitmask)
// Prologue: 55 8B EC 53 8B D9 (6 bytes, no rel32)
@@ -139,7 +147,7 @@ const PauseAnimFn = fn (u32, u32, u32) callconv(tc) void;
var pause_anim_hook: hook.Detour(PauseAnimFn) = .{};
// =============================================================================
// Priority 2: Hook SetAnimationOrder (0x767c70) — anchor creation validation
// Priority 2: Hook SetAnimationOrder (0x767c70) - anchor creation validation
//
// Validates the relativeTo param with IsBadReadPtr BEFORE the original runs.
// If relativeTo is already freed when the anchor is created, the dependency
@@ -158,7 +166,7 @@ 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
// Dependency registration ring buffer - track PauseAnimationGroup calls
//
// When vtable hooks detect a stale pointer, we look up this buffer to answer:
// "was PauseAnimationGroup ever called for this address?"
@@ -227,7 +235,7 @@ fn getRegisteredName(relativeTo: u32) []const u8 {
}
// =============================================================================
// Destruction history ring buffer — correlate stale pointers with frame names
// Destruction history ring buffer - correlate stale pointers with frame names
// =============================================================================
const HISTORY_SIZE = 1024;
@@ -273,7 +281,7 @@ fn fmtStaleInfo(relativeTo: u32) struct { name: []const u8, saw_destroy: bool }
return .{ .name = fmtFrameName(relativeTo), .saw_destroy = false };
}
// logRegistrationStatus and dumpStaleContext removed — verbose diagnostic logging
// 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
@@ -298,7 +306,7 @@ fn destroyUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
return destroy_ui_hook.callOriginal(.{ frame, free_flag });
}
/// Detour for ProcessUIUpdateEvent — third destruction path, called via vtable.
/// Detour for ProcessUIUpdateEvent - third destruction path, called via vtable.
fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
recordDestruction(frame);
if (IsBadReadPtr(@ptrFromInt(frame + 0x24), 4) == 0) {
@@ -309,18 +317,18 @@ fn processUIDetour(frame: u32, free_flag: u32) callconv(tc) u32 {
return process_ui_hook.callOriginal(.{ frame, free_flag });
}
/// Detour for PauseAnimationGroup — records every dependency registration.
/// 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(tc) void {
// Silently record — queried later by vtable hooks via logRegistrationStatus()
// Silently record - queried later by vtable hooks via logRegistrationStatus()
recordRegistration(relativeTo_frame, owner_frame, bitmask);
pause_anim_hook.callOriginal(.{ relativeTo_frame, owner_frame, bitmask });
}
/// Detour for SetAnimationOrder — validates relativeTo param before anchor creation.
/// Detour for SetAnimationOrder - validates relativeTo param before anchor creation.
/// 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;
@@ -373,7 +381,7 @@ fn countReverseDependencies(dying_frame: u32) u32 {
/// Walk the PauseAnimationGroup dependency list on the dying frame and NULL out
/// the relativeTo field in any anchors from other frames that reference it.
///
/// Safety: purely defensive — does NOT call destructors, free nodes, or modify
/// Safety: purely defensive - does NOT call destructors, free nodes, or modify
/// the dying frame's list pointers. The original cleanup_linked_list_structures
/// handles its own data structures.
fn cleanupReverseDependencies(dying_frame: u32) void {
@@ -411,12 +419,12 @@ fn cleanupReverseDependencies(dying_frame: u32) void {
const relativeTo: u32 = readAligned(anchor + 0x0C);
if (relativeTo != dying_frame) continue;
// Verify vtable matches the known anchor vtable — reject garbage objects
// Verify vtable matches the known anchor vtable - reject garbage objects
const vtable: u32 = readAligned(anchor);
if (vtable != ANCHOR_VTABLE_ADDR) continue;
// NULL the relativeTo pointer so it can't dangle.
// Don't call destructors or free the anchor — that risks cascading
// Don't call destructors or free the anchor - that risks cascading
// side effects and is unnecessary. A NULL relativeTo is handled
// gracefully by all code paths (luaGetPoint, GetWidth, GetHeight).
const field: *align(1) u32 = @ptrFromInt(anchor + 0x0C);
@@ -457,7 +465,7 @@ fn getFrameName(layout_frame: u32) ?[*:0]const u8 {
return @ptrFromInt(name_ptr);
}
/// Format a frame name for logging — returns "FrameName" or "(unnamed)".
/// Format a frame name for logging - returns "FrameName" or "(unnamed)".
fn fmtFrameName(layout_frame: u32) []const u8 {
if (getFrameName(layout_frame)) |name| {
return std.mem.span(name);
@@ -509,13 +517,13 @@ fn tryFixStaleRelativeTo(anchor: u32, stale: u32) bool {
//
// Three vtable slots must be hooked because they independently read anchor+0x0C
// (relativeTo) without going through each other:
// [1] GetWidth — reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling
// [2] GetHeight — same pattern as GetWidth
// [3] GetRelativeTo — returns *(this+0x0C), caller dereferences it
// [1] GetWidth - reads [this+0xC]+0x3C, crashes if relativeTo is NULL/dangling
// [2] GetHeight - same pattern as GetWidth
// [3] GetRelativeTo - returns *(this+0x0C), caller dereferences it
//
// GetRelativeTo NULLs the pointer on detection (self-heal). GetWidth/GetHeight
// must independently handle both NULL and dangling relativeTo by returning the
// sentinel value from [0x00cf550c] — the value SetFrameHitTestMode compares
// sentinel value from [0x00cf550c] - the value SetFrameHitTestMode compares
// against to detect "no dimension available".
// =============================================================================
@@ -550,10 +558,10 @@ fn getRelativeToHook(this: u32) callconv(tc) u32 {
if (!isRelativeToValid(result)) {
// Try to substitute live UIParent before NULLing
if (tryFixStaleRelativeTo(this, result)) {
// Re-call original — it now reads the fixed pointer
// Re-call original - it now reads the fixed pointer
return orig(this);
}
// No substitute available — NULL it out
// No substitute available - NULL it out
const field: *align(1) u32 = @ptrFromInt(this + 0x0C);
field.* = 0;
return 0;
@@ -564,14 +572,14 @@ fn getRelativeToHook(this: u32) callconv(tc) 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.
/// Signature: f32 __thiscall GetWidth(this, u32 param) - callee cleans 1 stack arg.
fn getWidthHook(this: u32, param: u32) callconv(tc) f32 {
const relativeTo: u32 = readAligned(this + 0x0C);
if (!isRelativeToValid(relativeTo)) {
if (relativeTo != 0) {
// Try to substitute live UIParent instead of NULLing
if (tryFixStaleRelativeTo(this, relativeTo)) {
// Fixed — call original with the healed pointer
// Fixed - call original with the healed pointer
const orig: *const fn (u32, u32) callconv(tc) f32 = @ptrFromInt(orig_get_width);
return orig(this, param);
}
@@ -586,7 +594,7 @@ fn getWidthHook(this: u32, param: u32) callconv(tc) f32 {
}
/// Hook for vtable[2] GetHeight. Same pattern as GetWidth.
/// Signature: f32 __thiscall GetHeight(this, u32 param) — callee cleans 1 stack arg.
/// Signature: f32 __thiscall GetHeight(this, u32 param) - callee cleans 1 stack arg.
fn getHeightHook(this: u32, param: u32) callconv(tc) f32 {
const relativeTo: u32 = readAligned(this + 0x0C);
if (!isRelativeToValid(relativeTo)) {
@@ -625,22 +633,10 @@ fn restoreVtableSlot(slot_addr: usize, saved: *usize) void {
}
pub fn installHooks() void {
// 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\\WeirdUtils_FramecrashHook_{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;
if (GetLastError() == ERROR_ALREADY_EXISTS) {
_ = CloseHandle(g_mutex.?);
g_mutex = null;
g_is_hook_owner = false;
con.print("[framecrash] Another DLL owns hooks (mutex taken), skipping\n");
return;
}
g_is_hook_owner = true;
const result = mod_mutex.acquire(module_name);
g_mutex = result.handle;
g_is_hook_owner = result.is_owner;
if (!g_is_hook_owner) return;
// Root cause fix #1: detour cleanup_linked_list_structures to clean up
// reverse anchor references before the frame is destroyed.
@@ -650,7 +646,7 @@ pub fn installHooks() void {
con.print("[framecrash] Frame cleanup detour installed\n");
}
// Root cause fix #2: detour destroyUIElement — the second destruction path
// Root cause fix #2: detour destroyUIElement - the second destruction path
// used by cleanupGraphicsResources during UI teardown/reload. This path
// frees frames without walking the dependency list.
if (destroy_ui_hook.attach(DESTROY_UI_TARGET, &destroyUIDetour) != .ok) {
@@ -659,7 +655,7 @@ pub fn installHooks() void {
con.print("[framecrash] destroyUIElement detour installed\n");
}
// Root cause fix #3: detour ProcessUIUpdateEvent — virtual function that
// Root cause fix #3: detour ProcessUIUpdateEvent - virtual function that
// calls CleanupUIElement + FreeMemory without layout cleanup.
if (process_ui_hook.attach(PROCESS_UI_TARGET, &processUIDetour) != .ok) {
con.print("[framecrash] ERROR: Failed to install ProcessUIUpdateEvent detour\n");
@@ -717,11 +713,7 @@ pub fn removeHooks() void {
}
if (g_is_hook_owner) {
if (g_mutex) |m| {
_ = ReleaseMutex(m);
_ = CloseHandle(m);
g_mutex = null;
}
mod_mutex.release(&g_mutex);
}
g_is_hook_owner = false;
}