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:
+52
-52
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user